mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-04 07:10:47 +00:00
006f1690a5
## Description: Warship veterancy! This is an idea inspired by the unit veterancy feature of games like C&C: Red Alert 2 in which unit eliminations increases the level of individual units. I've been trying to build this mechanic for months with different ideas, and I finally landed on this being one of the more balanced implementation. Warships can earn up to three levels, represented by the gold bar insignia in the bottom right of their warship sprite. <img width="622" height="202" alt="image" src="https://github.com/user-attachments/assets/a8c31a45-4ae9-41a9-b054-9c4a7f4ab1f1" /> A veterancy bar grants 20% health from the base amount, and a 20% increase in shell damage applied _after_ the random damage roll. For example, a level 3 warship will apply a 60% damage boost on top of the random shell damage value (something between 200-325. If the random value is 250, the final damage output will be `250 * 1.60 = 400`. There are three ways to achieve a veteran level: 1. **Eliminate another warship:** any time a warship neutralizes another warship, it immediately get's a veterancy increase. https://github.com/user-attachments/assets/6a9e0958-5171-4ca3-94f6-9c2300a12f8b 2. **Eliminate transport boats:** Destroying 10 transport boats will level a warship to the next veterancy bar. https://github.com/user-attachments/assets/619ce0c0-033c-4e0b-9c64-b41eabaa791b 3. **Steal trade ships:** If the warship captures 25 trade ships, it will earn a veterancy bar. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: bijx
951 lines
29 KiB
TypeScript
951 lines
29 KiB
TypeScript
import { MoveWarshipExecution } from "../src/core/execution/MoveWarshipExecution";
|
|
import { WarshipExecution } from "../src/core/execution/WarshipExecution";
|
|
import {
|
|
Game,
|
|
Player,
|
|
PlayerInfo,
|
|
PlayerType,
|
|
UnitType,
|
|
} from "../src/core/game/Game";
|
|
import { TileRef } from "../src/core/game/GameMap";
|
|
import { PathStatus } from "../src/core/pathfinding/types";
|
|
import { setup } from "./util/Setup";
|
|
import { executeTicks } from "./util/utils";
|
|
|
|
const coastX = 7;
|
|
let game: Game;
|
|
let player1: Player;
|
|
let player2: Player;
|
|
|
|
describe("Warship", () => {
|
|
beforeEach(async () => {
|
|
game = await setup(
|
|
"half_land_half_ocean",
|
|
{ infiniteGold: true, instantBuild: true },
|
|
[
|
|
new PlayerInfo("boat dude", PlayerType.Human, null, "player_1_id"),
|
|
new PlayerInfo("boat dude", PlayerType.Human, null, "player_2_id"),
|
|
],
|
|
);
|
|
|
|
player1 = game.player("player_1_id");
|
|
player2 = game.player("player_2_id");
|
|
|
|
// Advance past the manualMoveRetreatDisabledDuration window.
|
|
executeTicks(game, 50);
|
|
});
|
|
|
|
test("Warship heals only if player has port", async () => {
|
|
const maxHealth = game.config().unitInfo(UnitType.Warship).maxHealth;
|
|
if (typeof maxHealth !== "number") {
|
|
expect(typeof maxHealth).toBe("number");
|
|
throw new Error("unreachable");
|
|
}
|
|
|
|
const port = player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 10),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 10),
|
|
},
|
|
);
|
|
game.addExecution(new WarshipExecution(warship));
|
|
|
|
game.executeNextTick();
|
|
|
|
expect(warship.health()).toBe(maxHealth);
|
|
warship.modifyHealth(-10);
|
|
expect(warship.health()).toBe(maxHealth - 10);
|
|
game.executeNextTick();
|
|
expect(warship.health()).toBe(maxHealth - 9);
|
|
|
|
port.delete();
|
|
|
|
game.executeNextTick();
|
|
expect(warship.health()).toBe(maxHealth - 9);
|
|
});
|
|
|
|
test("Warship captures trade if player has port", async () => {
|
|
const portTile = game.ref(coastX, 10);
|
|
player1.buildUnit(UnitType.Port, portTile, {});
|
|
game.addExecution(
|
|
new WarshipExecution(
|
|
player1.buildUnit(UnitType.Warship, portTile, {
|
|
patrolTile: portTile,
|
|
}),
|
|
),
|
|
);
|
|
|
|
const tradeShip = player2.buildUnit(
|
|
UnitType.TradeShip,
|
|
game.ref(coastX + 1, 7),
|
|
{
|
|
targetUnit: player2.buildUnit(UnitType.Port, game.ref(coastX, 10), {}),
|
|
},
|
|
);
|
|
|
|
expect(tradeShip.owner().id()).toBe(player2.id());
|
|
// Let plenty of time for A* to execute
|
|
for (let i = 0; i < 10; i++) {
|
|
game.executeNextTick();
|
|
}
|
|
expect(tradeShip.owner()).toBe(player1);
|
|
});
|
|
|
|
test("Warship do not capture trade if player has no port", async () => {
|
|
game.addExecution(
|
|
new WarshipExecution(
|
|
player1.buildUnit(UnitType.Warship, game.ref(coastX + 1, 11), {
|
|
patrolTile: game.ref(coastX + 1, 11),
|
|
}),
|
|
),
|
|
);
|
|
|
|
const tradeShip = player2.buildUnit(
|
|
UnitType.TradeShip,
|
|
game.ref(coastX + 1, 11),
|
|
{
|
|
targetUnit: player1.buildUnit(UnitType.Port, game.ref(coastX, 11), {}),
|
|
},
|
|
);
|
|
|
|
expect(tradeShip.owner().id()).toBe(player2.id());
|
|
// Let plenty of time for warship to potentially capture trade ship
|
|
for (let i = 0; i < 10; i++) {
|
|
game.executeNextTick();
|
|
}
|
|
|
|
expect(tradeShip.owner().id()).toBe(player2.id());
|
|
});
|
|
|
|
test("Warship does not target trade ships that are safe from pirates", async () => {
|
|
// build port so warship can target trade ships
|
|
player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
|
|
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 10),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 10),
|
|
},
|
|
);
|
|
game.addExecution(new WarshipExecution(warship));
|
|
|
|
const tradeShip = player2.buildUnit(
|
|
UnitType.TradeShip,
|
|
game.ref(coastX + 1, 10),
|
|
{
|
|
targetUnit: player2.buildUnit(UnitType.Port, game.ref(coastX, 10), {}),
|
|
},
|
|
);
|
|
|
|
tradeShip.setSafeFromPirates();
|
|
|
|
executeTicks(game, 10);
|
|
|
|
expect(tradeShip.owner().id()).toBe(player2.id());
|
|
});
|
|
|
|
test("Warship moves to new patrol tile", async () => {
|
|
game.config().warshipTargettingRange = () => 1;
|
|
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 10),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 10),
|
|
},
|
|
);
|
|
|
|
game.addExecution(new WarshipExecution(warship));
|
|
|
|
game.addExecution(
|
|
new MoveWarshipExecution(
|
|
player1,
|
|
[warship.id()],
|
|
game.ref(coastX + 5, 15),
|
|
),
|
|
);
|
|
|
|
executeTicks(game, 10);
|
|
|
|
expect(warship.warshipState().patrolTile).toBe(game.ref(coastX + 5, 15));
|
|
});
|
|
|
|
test("Warship does not not target trade ships outside of patrol range", async () => {
|
|
game.config().warshipTargettingRange = () => 3;
|
|
|
|
// build port so warship can target trade ships
|
|
player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
|
|
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 10),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 10),
|
|
},
|
|
);
|
|
game.addExecution(new WarshipExecution(warship));
|
|
|
|
const tradeShip = player2.buildUnit(
|
|
UnitType.TradeShip,
|
|
game.ref(coastX + 1, 15),
|
|
{
|
|
targetUnit: player2.buildUnit(UnitType.Port, game.ref(coastX, 10), {}),
|
|
},
|
|
);
|
|
|
|
executeTicks(game, 10);
|
|
|
|
// Trade ship should not be captured
|
|
expect(tradeShip.owner().id()).toBe(player2.id());
|
|
});
|
|
|
|
test("Warship prioritizes transport ships over warships", async () => {
|
|
game.config().warshipShellAttackRate = () => Number.MAX_SAFE_INTEGER;
|
|
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 10),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 10),
|
|
},
|
|
);
|
|
player2.buildUnit(UnitType.Warship, game.ref(coastX + 2, 10), {
|
|
patrolTile: game.ref(coastX + 2, 10),
|
|
});
|
|
player2.buildUnit(UnitType.TransportShip, game.ref(coastX + 1, 11), {
|
|
targetTile: game.ref(coastX + 1, 11),
|
|
});
|
|
|
|
game.addExecution(new WarshipExecution(warship));
|
|
|
|
let selectedType: UnitType | undefined = undefined;
|
|
for (let i = 0; i < 5; i++) {
|
|
game.executeNextTick();
|
|
selectedType = warship.targetUnit()?.type();
|
|
if (selectedType === UnitType.TransportShip) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
expect(selectedType).toBe(UnitType.TransportShip);
|
|
});
|
|
|
|
test("Warship does not target trade ships in different water components", async () => {
|
|
// build port so warship can target trade ships
|
|
player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
|
|
|
|
const warshipTile = game.ref(coastX + 1, 2);
|
|
const tradeShipTile = game.ref(coastX + 1, 12);
|
|
|
|
const warship = player1.buildUnit(UnitType.Warship, warshipTile, {
|
|
patrolTile: warshipTile,
|
|
});
|
|
game.addExecution(new WarshipExecution(warship));
|
|
|
|
const tradeShip = player2.buildUnit(UnitType.TradeShip, tradeShipTile, {
|
|
targetUnit: player2.buildUnit(UnitType.Port, game.ref(coastX, 10), {}),
|
|
});
|
|
|
|
// Mock different water components
|
|
game.getWaterComponent = (tile: TileRef) => {
|
|
if (tile === warshipTile) return 1;
|
|
return 2;
|
|
};
|
|
|
|
game.hasWaterComponent = (tile: TileRef, component: number) => {
|
|
return game.getWaterComponent(tile) === component;
|
|
};
|
|
|
|
executeTicks(game, 10);
|
|
|
|
// Trade ship should not be captured because it's in a different component
|
|
expect(tradeShip.owner().id()).toBe(player2.id());
|
|
});
|
|
|
|
test("MoveWarshipExecution fails if player is not the owner", async () => {
|
|
const originalPatrolTile = game.ref(coastX + 1, 10);
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 5),
|
|
{
|
|
patrolTile: originalPatrolTile,
|
|
},
|
|
);
|
|
new MoveWarshipExecution(
|
|
player2,
|
|
[warship.id()],
|
|
game.ref(coastX + 5, 15),
|
|
).init(game, 0);
|
|
expect(warship.warshipState().patrolTile).toBe(originalPatrolTile);
|
|
});
|
|
|
|
test("MoveWarshipExecution fails if warship is not active", async () => {
|
|
const originalPatrolTile = game.ref(coastX + 1, 10);
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 5),
|
|
{
|
|
patrolTile: originalPatrolTile,
|
|
},
|
|
);
|
|
warship.delete();
|
|
new MoveWarshipExecution(
|
|
player1,
|
|
[warship.id()],
|
|
game.ref(coastX + 5, 15),
|
|
).init(game, 0);
|
|
expect(warship.warshipState().patrolTile).toBe(originalPatrolTile);
|
|
});
|
|
|
|
test("MoveWarshipExecution fails gracefully if warship not found", async () => {
|
|
const exec = new MoveWarshipExecution(
|
|
player1,
|
|
[123],
|
|
game.ref(coastX + 5, 15),
|
|
);
|
|
|
|
// Verify that no error is thrown.
|
|
exec.init(game, 0);
|
|
|
|
expect(exec.isActive()).toBe(false);
|
|
});
|
|
|
|
test("Warship retreats when pre-heal health is below threshold", async () => {
|
|
const maxHealth = game.config().unitInfo(UnitType.Warship).maxHealth;
|
|
if (typeof maxHealth !== "number") {
|
|
expect(typeof maxHealth).toBe("number");
|
|
throw new Error("unreachable");
|
|
}
|
|
if (maxHealth <= 599) {
|
|
expect(maxHealth).toBeGreaterThan(599);
|
|
throw new Error("unreachable");
|
|
}
|
|
|
|
game.config().warshipPortHealingBonusPerLevel = () => 0;
|
|
game.config().warshipRetreatHealthPercent = () => 60;
|
|
|
|
const homePort = player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 11),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 11),
|
|
},
|
|
);
|
|
game.addExecution(new WarshipExecution(warship));
|
|
|
|
game.executeNextTick();
|
|
warship.modifyHealth(-(maxHealth - 599));
|
|
|
|
game.executeNextTick();
|
|
|
|
expect(warship.warshipState().state).not.toBe("patrolling");
|
|
const distanceToPort = game.euclideanDistSquared(
|
|
warship.tile(),
|
|
homePort.tile(),
|
|
);
|
|
expect(
|
|
distanceToPort <= 25 || warship.targetTile() === homePort.tile(),
|
|
).toBe(true);
|
|
});
|
|
|
|
test("Warship gets active healing when docked at a friendly port", async () => {
|
|
const maxHealth = game.config().unitInfo(UnitType.Warship).maxHealth;
|
|
if (typeof maxHealth !== "number") {
|
|
expect(typeof maxHealth).toBe("number");
|
|
throw new Error("unreachable");
|
|
}
|
|
|
|
game.config().warshipPassiveHealing = () => 0;
|
|
game.config().warshipPortHealingBonusPerLevel = () => 6;
|
|
game.config().warshipDockingRange = () => 5;
|
|
game.config().warshipRetreatHealthPercent = () => 90;
|
|
|
|
const portTile = game.ref(coastX, 10);
|
|
player1.buildUnit(UnitType.Port, portTile, {});
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 11),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 11),
|
|
},
|
|
);
|
|
const warshipExecution = new WarshipExecution(warship);
|
|
game.addExecution(warshipExecution);
|
|
|
|
game.executeNextTick();
|
|
warship.modifyHealth(-300);
|
|
|
|
for (let i = 0; i < 60; i++) {
|
|
game.executeNextTick();
|
|
if (warshipExecution.isDocked()) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
expect(warshipExecution.isDocked()).toBe(true);
|
|
const before = warship.health();
|
|
game.executeNextTick();
|
|
expect(warship.health()).toBe(before + 6);
|
|
});
|
|
|
|
test("Warship waits at port when capacity is full", async () => {
|
|
game.config().warshipPassiveHealing = () => 0;
|
|
game.config().warshipDockingRange = () => 5;
|
|
game.config().warshipRetreatHealthPercent = () => 90;
|
|
|
|
const portTile = game.ref(coastX, 10);
|
|
const warship1Tile = game.ref(coastX + 1, 11);
|
|
const warship2Tile = game.ref(coastX + 1, 12);
|
|
|
|
player1.buildUnit(UnitType.Port, portTile, {});
|
|
const warship1 = player1.buildUnit(UnitType.Warship, warship1Tile, {
|
|
patrolTile: warship1Tile,
|
|
});
|
|
const warship2 = player1.buildUnit(UnitType.Warship, warship2Tile, {
|
|
patrolTile: warship2Tile,
|
|
});
|
|
|
|
const exec1 = new WarshipExecution(warship1);
|
|
const exec2 = new WarshipExecution(warship2);
|
|
game.addExecution(exec1);
|
|
game.addExecution(exec2);
|
|
|
|
game.executeNextTick();
|
|
warship1.modifyHealth(-300);
|
|
warship2.modifyHealth(-300);
|
|
|
|
for (let i = 0; i < 80; i++) {
|
|
game.executeNextTick();
|
|
const warship2DistanceToPort = game.euclideanDistSquared(
|
|
warship2.tile(),
|
|
portTile,
|
|
);
|
|
if (
|
|
exec1.isDocked() &&
|
|
!exec2.isDocked() &&
|
|
warship2DistanceToPort <= 25 &&
|
|
warship2.warshipState().state !== "patrolling"
|
|
) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
const warship2DistanceToPort = game.euclideanDistSquared(
|
|
warship2.tile(),
|
|
portTile,
|
|
);
|
|
expect(exec1.isDocked()).toBe(true);
|
|
expect(exec2.isDocked()).toBe(false);
|
|
expect(warship2DistanceToPort).toBeLessThanOrEqual(25);
|
|
expect(warship2.warshipState().state).not.toBe("patrolling");
|
|
});
|
|
|
|
test("Warship cancels docking if its retreat port is destroyed", async () => {
|
|
game.config().warshipPassiveHealing = () => 0;
|
|
game.config().warshipPortHealingBonusPerLevel = () => 0;
|
|
game.config().warshipDockingRange = () => 5;
|
|
game.config().warshipRetreatHealthPercent = () => 90;
|
|
|
|
const homePort = player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 11),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 11),
|
|
},
|
|
);
|
|
const warshipExecution = new WarshipExecution(warship);
|
|
game.addExecution(warshipExecution);
|
|
|
|
game.executeNextTick();
|
|
warship.modifyHealth(-300);
|
|
|
|
for (let i = 0; i < 60; i++) {
|
|
game.executeNextTick();
|
|
if (warshipExecution.isDocked()) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
expect(warshipExecution.isDocked()).toBe(true);
|
|
|
|
homePort.delete();
|
|
game.executeNextTick();
|
|
|
|
expect(warshipExecution.isDocked()).toBe(false);
|
|
expect(warship.warshipState().state).toBe("patrolling");
|
|
});
|
|
|
|
test("Warship drops a stale target after patrol movement changes range", async () => {
|
|
game.config().warshipTargettingRange = () => 1;
|
|
game.config().warshipShellAttackRate = () => Number.MAX_SAFE_INTEGER;
|
|
const startTile = game.ref(coastX + 1, 10);
|
|
const movedTile = game
|
|
.map()
|
|
.neighbors(startTile)
|
|
.find((tile) => game.isOcean(tile));
|
|
|
|
expect(movedTile).toBeDefined();
|
|
|
|
const warship = player1.buildUnit(UnitType.Warship, startTile, {
|
|
patrolTile: startTile,
|
|
});
|
|
warship.setTargetTile(movedTile!);
|
|
const transport = player2.buildUnit(UnitType.TransportShip, movedTile!, {
|
|
targetTile: movedTile!,
|
|
});
|
|
|
|
const execution = new WarshipExecution(warship);
|
|
const executionInternals = execution as unknown as {
|
|
findTargetUnit: () => typeof transport | undefined;
|
|
pathfinder: {
|
|
next: () => { status: PathStatus; node: number };
|
|
};
|
|
};
|
|
execution.init(game, game.ticks());
|
|
|
|
vi.spyOn(executionInternals, "findTargetUnit")
|
|
.mockReturnValueOnce(transport)
|
|
.mockReturnValueOnce(undefined);
|
|
vi.spyOn(executionInternals.pathfinder, "next").mockReturnValue({
|
|
status: PathStatus.NEXT,
|
|
node: movedTile!,
|
|
});
|
|
|
|
execution.tick(game.ticks());
|
|
expect(warship.tile()).toBe(movedTile);
|
|
|
|
execution.tick(game.ticks());
|
|
expect(warship.targetUnit()).toBeUndefined();
|
|
});
|
|
|
|
test("Warship cancels retreat if no friendly port is reachable by water", async () => {
|
|
game.config().warshipRetreatHealthPercent = () => 90;
|
|
|
|
player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 11),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 11),
|
|
},
|
|
);
|
|
game.addExecution(new WarshipExecution(warship));
|
|
|
|
const warshipTile = warship.tile();
|
|
vi.spyOn(game, "getWaterComponent").mockImplementation((tile) =>
|
|
tile === warshipTile ? 1 : 2,
|
|
);
|
|
vi.spyOn(game, "hasWaterComponent").mockReturnValue(false);
|
|
|
|
game.executeNextTick();
|
|
warship.modifyHealth(-300);
|
|
game.executeNextTick();
|
|
|
|
expect(warship.warshipState().state).toBe("patrolling");
|
|
});
|
|
|
|
test("Low-health warship retreats AND fires at nearby enemy warship", async () => {
|
|
game.config().warshipPortHealingBonusPerLevel = () => 0;
|
|
game.config().warshipRetreatHealthPercent = () => 60;
|
|
game.config().warshipTargettingRange = () => 5;
|
|
game.config().warshipShellAttackRate = () => 10_000;
|
|
|
|
player1.buildUnit(UnitType.Port, game.ref(coastX, 5), {});
|
|
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 15),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 15),
|
|
},
|
|
);
|
|
const enemyWarship = player2.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 2, 15),
|
|
{
|
|
patrolTile: game.ref(coastX + 2, 15),
|
|
},
|
|
);
|
|
|
|
game.addExecution(new WarshipExecution(warship));
|
|
game.addExecution(new WarshipExecution(enemyWarship));
|
|
|
|
game.executeNextTick();
|
|
warship.modifyHealth(-700);
|
|
game.executeNextTick();
|
|
|
|
// New behavior: retreat starts immediately even with enemy nearby
|
|
expect(warship.warshipState().state).not.toBe("patrolling");
|
|
// AND the warship still targets the enemy to fire back while retreating
|
|
expect(warship.targetUnit()).toBe(enemyWarship);
|
|
});
|
|
|
|
test("Retreating warship aggroes nearby enemy transport before continuing retreat", async () => {
|
|
game.config().warshipPortHealingBonusPerLevel = () => 0;
|
|
game.config().warshipRetreatHealthPercent = () => 60;
|
|
game.config().warshipTargettingRange = () => 5;
|
|
game.config().warshipShellAttackRate = () => 10_000;
|
|
|
|
const homePort = player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 6, 12),
|
|
{
|
|
patrolTile: game.ref(coastX + 6, 12),
|
|
},
|
|
);
|
|
game.addExecution(new WarshipExecution(warship));
|
|
|
|
game.executeNextTick();
|
|
warship.modifyHealth(-700);
|
|
for (let i = 0; i < 10; i++) {
|
|
game.executeNextTick();
|
|
if (
|
|
warship.warshipState().state !== "patrolling" &&
|
|
warship.targetTile() === homePort.tile() &&
|
|
warship.tile() !== homePort.tile()
|
|
) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
expect(warship.warshipState().state).not.toBe("patrolling");
|
|
expect(warship.targetTile()).toBe(homePort.tile());
|
|
|
|
const enemyTransport = player2.buildUnit(
|
|
UnitType.TransportShip,
|
|
game.ref(coastX + 5, 12),
|
|
{
|
|
targetTile: game.ref(coastX + 5, 12),
|
|
},
|
|
);
|
|
|
|
game.executeNextTick();
|
|
|
|
expect(warship.warshipState().state).not.toBe("patrolling");
|
|
expect(warship.targetTile()).toBe(homePort.tile());
|
|
expect(warship.targetUnit()).toBe(enemyTransport);
|
|
});
|
|
|
|
test("Manual MoveWarshipExecution cancels retreat and keeps manual order", async () => {
|
|
game.config().warshipPortHealingBonusPerLevel = () => 0;
|
|
game.config().warshipRetreatHealthPercent = () => 60;
|
|
|
|
const homePortTile = game.ref(coastX, 10);
|
|
player1.buildUnit(UnitType.Port, homePortTile, {});
|
|
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 11),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 11),
|
|
},
|
|
);
|
|
game.addExecution(new WarshipExecution(warship));
|
|
|
|
game.executeNextTick();
|
|
warship.modifyHealth(-700);
|
|
executeTicks(game, 20);
|
|
|
|
expect(warship.warshipState().state).not.toBe("patrolling");
|
|
|
|
const manualPatrolTile = game.ref(coastX + 5, 15);
|
|
game.addExecution(
|
|
new MoveWarshipExecution(player1, [warship.id()], manualPatrolTile),
|
|
);
|
|
|
|
executeTicks(game, 2);
|
|
|
|
expect(warship.warshipState().state).toBe("patrolling");
|
|
expect(warship.warshipState().patrolTile).toBe(manualPatrolTile);
|
|
expect(warship.targetTile()).not.toBe(homePortTile);
|
|
});
|
|
|
|
test("Manual MoveWarshipExecution suppresses auto-retreat for 5 seconds before retreat starts", async () => {
|
|
game.config().warshipPortHealingBonusPerLevel = () => 0;
|
|
game.config().warshipRetreatHealthPercent = () => 60;
|
|
|
|
player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
|
|
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 11),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 11),
|
|
},
|
|
);
|
|
game.addExecution(new WarshipExecution(warship));
|
|
game.executeNextTick();
|
|
|
|
const manualPatrolTile = game.ref(coastX + 6, 15);
|
|
game.addExecution(
|
|
new MoveWarshipExecution(player1, [warship.id()], manualPatrolTile),
|
|
);
|
|
game.executeNextTick();
|
|
|
|
warship.modifyHealth(-700);
|
|
|
|
game.executeNextTick();
|
|
expect(warship.warshipState().state).toBe("patrolling");
|
|
expect(warship.warshipState().patrolTile).toBe(manualPatrolTile);
|
|
|
|
executeTicks(game, 48);
|
|
expect(warship.warshipState().state).toBe("patrolling");
|
|
|
|
let resumedRetreat = false;
|
|
for (let i = 0; i < 5; i++) {
|
|
game.executeNextTick();
|
|
if (warship.warshipState().state !== "patrolling") {
|
|
resumedRetreat = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
expect(resumedRetreat).toBe(true);
|
|
});
|
|
|
|
test("Warship isInCombat becomes true when hit by a shell from an enemy", async () => {
|
|
game.config().warshipPassiveHealing = () => 0;
|
|
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 10),
|
|
{ patrolTile: game.ref(coastX + 1, 10) },
|
|
);
|
|
game.addExecution(new WarshipExecution(warship));
|
|
game.executeNextTick();
|
|
|
|
expect(warship.warshipState().isInCombat).toBe(false);
|
|
|
|
// Simulate incoming shell damage from an enemy player
|
|
warship.modifyHealth(-50, player2);
|
|
|
|
expect(warship.warshipState().isInCombat).toBe(true);
|
|
});
|
|
|
|
test("Warship isInCombat becomes true when firing at an enemy", async () => {
|
|
game.config().warshipPortHealingBonusPerLevel = () => 0;
|
|
game.config().warshipShellAttackRate = () => 0;
|
|
game.config().warshipTargettingRange = () => 5;
|
|
|
|
player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 10),
|
|
{ patrolTile: game.ref(coastX + 1, 10) },
|
|
);
|
|
const enemyWarship = player2.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 2, 10),
|
|
{ patrolTile: game.ref(coastX + 2, 10) },
|
|
);
|
|
|
|
game.addExecution(new WarshipExecution(warship));
|
|
game.executeNextTick();
|
|
|
|
expect(warship.warshipState().isInCombat).toBe(false);
|
|
|
|
// Give warship a target and tick — shootTarget sets inCombat
|
|
warship.setTargetUnit(enemyWarship);
|
|
game.executeNextTick();
|
|
|
|
expect(warship.warshipState().isInCombat).toBe(true);
|
|
});
|
|
|
|
test("Docked warship is not targeted by enemy warship", async () => {
|
|
game.config().warshipPassiveHealing = () => 0;
|
|
game.config().warshipDockingRange = () => 5;
|
|
game.config().warshipRetreatHealthPercent = () => 90;
|
|
game.config().warshipTargettingRange = () => 20;
|
|
|
|
const portTile = game.ref(coastX, 10);
|
|
player1.buildUnit(UnitType.Port, portTile, {});
|
|
const friendlyWarship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 10),
|
|
{ patrolTile: game.ref(coastX + 1, 10) },
|
|
);
|
|
const exec = new WarshipExecution(friendlyWarship);
|
|
game.addExecution(exec);
|
|
|
|
const enemyWarship = player2.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 2, 10),
|
|
{ patrolTile: game.ref(coastX + 2, 10) },
|
|
);
|
|
const enemyExec = new WarshipExecution(enemyWarship);
|
|
game.addExecution(enemyExec);
|
|
|
|
game.executeNextTick();
|
|
friendlyWarship.modifyHealth(-300);
|
|
|
|
// Wait until friendly warship docks
|
|
for (let i = 0; i < 80; i++) {
|
|
game.executeNextTick();
|
|
if (exec.isDocked()) break;
|
|
}
|
|
|
|
expect(exec.isDocked()).toBe(true);
|
|
expect(friendlyWarship.warshipState().state).toBe("docked");
|
|
|
|
// Enemy warship should not be targeting the docked warship
|
|
game.executeNextTick();
|
|
expect(enemyWarship.targetUnit()).not.toBe(friendlyWarship);
|
|
});
|
|
|
|
test("Retreating warship continues moving to port after firing back", async () => {
|
|
game.config().warshipPortHealingBonusPerLevel = () => 0;
|
|
game.config().warshipRetreatHealthPercent = () => 60;
|
|
game.config().warshipTargettingRange = () => 5;
|
|
game.config().warshipShellAttackRate = () => 10_000;
|
|
|
|
const homePort = player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 6, 12),
|
|
{ patrolTile: game.ref(coastX + 6, 12) },
|
|
);
|
|
game.addExecution(new WarshipExecution(warship));
|
|
|
|
game.executeNextTick();
|
|
warship.modifyHealth(-700);
|
|
|
|
// Wait until retreating and heading to port
|
|
for (let i = 0; i < 15; i++) {
|
|
game.executeNextTick();
|
|
if (
|
|
warship.warshipState().state !== "patrolling" &&
|
|
warship.targetTile() === homePort.tile()
|
|
) {
|
|
break;
|
|
}
|
|
}
|
|
expect(warship.warshipState().state).not.toBe("patrolling");
|
|
expect(warship.targetTile()).toBe(homePort.tile());
|
|
|
|
const tileBeforeCombat = warship.tile();
|
|
|
|
const enemyTransport = player2.buildUnit(
|
|
UnitType.TransportShip,
|
|
game.ref(coastX + 5, 12),
|
|
{ targetTile: game.ref(coastX + 5, 12) },
|
|
);
|
|
|
|
// After encountering enemy: still retreating, still targeting port,
|
|
// AND targeting the enemy transport simultaneously
|
|
game.executeNextTick();
|
|
|
|
expect(warship.warshipState().state).not.toBe("patrolling");
|
|
expect(warship.targetTile()).toBe(homePort.tile());
|
|
expect(warship.targetUnit()).toBe(enemyTransport);
|
|
|
|
// Warship should still be moving (not frozen at tileBeforeCombat)
|
|
game.executeNextTick();
|
|
expect(warship.tile()).not.toBe(tileBeforeCombat);
|
|
});
|
|
|
|
test("Warship captures trade ship immediately when already within capture range", async () => {
|
|
// Trade ship is within Manhattan distance 5 — should be captured on the first tick
|
|
// via the dist <= 5 fast path in huntDownTradeShip, without needing pathfinding.
|
|
player1.buildUnit(UnitType.Port, game.ref(coastX, 8), {});
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 8),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 8),
|
|
},
|
|
);
|
|
const tradeShip = player2.buildUnit(
|
|
UnitType.TradeShip,
|
|
game.ref(coastX + 1, 11), // Manhattan distance 3 from warship
|
|
{
|
|
targetUnit: player2.buildUnit(UnitType.Port, game.ref(coastX, 11), {}),
|
|
},
|
|
);
|
|
|
|
const execution = new WarshipExecution(warship);
|
|
const executionInternals = execution as unknown as {
|
|
findTargetUnit: () => typeof tradeShip | undefined;
|
|
};
|
|
execution.init(game, game.ticks());
|
|
vi.spyOn(executionInternals, "findTargetUnit").mockReturnValue(tradeShip);
|
|
|
|
expect(tradeShip.owner().id()).toBe(player2.id());
|
|
execution.tick(game.ticks());
|
|
expect(tradeShip.owner()).toBe(player1);
|
|
});
|
|
|
|
test("Warship uses greedy pursuit to capture trade ship within 20 tiles", async () => {
|
|
// Trade ship is within the 20-tile greedy range but outside the 5-tile instant-capture
|
|
// range. The warship should use direct neighbor movement (not minimap pathfinding)
|
|
// and close the gap cleanly.
|
|
player1.buildUnit(UnitType.Port, game.ref(coastX, 3), {});
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 3),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 3),
|
|
},
|
|
);
|
|
const tradeShip = player2.buildUnit(
|
|
UnitType.TradeShip,
|
|
game.ref(coastX + 1, 13), // Manhattan distance 10 — within greedy range
|
|
{
|
|
targetUnit: player2.buildUnit(UnitType.Port, game.ref(coastX, 13), {}),
|
|
},
|
|
);
|
|
|
|
const execution = new WarshipExecution(warship);
|
|
const executionInternals = execution as unknown as {
|
|
findTargetUnit: () => typeof tradeShip | undefined;
|
|
};
|
|
execution.init(game, game.ticks());
|
|
vi.spyOn(executionInternals, "findTargetUnit").mockReturnValue(tradeShip);
|
|
|
|
expect(tradeShip.owner().id()).toBe(player2.id());
|
|
// 10 tiles at 2 steps/tick = 5 ticks minimum
|
|
for (let i = 0; i < 10; i++) {
|
|
execution.tick(game.ticks());
|
|
if (tradeShip.owner() === player1) break;
|
|
}
|
|
expect(tradeShip.owner()).toBe(player1);
|
|
});
|
|
|
|
test("Warship doesn't accept a new patrol tile if in a different water component", async () => {
|
|
const newPatrolTile = game.ref(coastX + 5, 15);
|
|
|
|
const warship = player1.buildUnit(
|
|
UnitType.Warship,
|
|
game.ref(coastX + 1, 10),
|
|
{
|
|
patrolTile: game.ref(coastX + 1, 10),
|
|
},
|
|
);
|
|
|
|
game.addExecution(new WarshipExecution(warship));
|
|
|
|
// Mock different water components
|
|
game.getWaterComponent = (tile: TileRef) => {
|
|
if (tile === newPatrolTile) return 1;
|
|
return 2;
|
|
};
|
|
|
|
game.hasWaterComponent = (tile: TileRef, component: number) => {
|
|
return game.getWaterComponent(tile) === component;
|
|
};
|
|
|
|
game.addExecution(
|
|
new MoveWarshipExecution(player1, [warship.id()], newPatrolTile),
|
|
);
|
|
|
|
executeTicks(game, 10);
|
|
|
|
expect(warship.warshipState().patrolTile).toBe(game.ref(coastX + 1, 10));
|
|
});
|
|
});
|