Files
OpenFrontIO/tests/Warship.test.ts
T
bijx 006f1690a5 Warship veterancy (#4433)
## 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
2026-07-01 21:38:09 -07:00

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));
});
});