diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index e5d72f0e8f..f854feb12e 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -357,11 +357,17 @@ export class UnitLayer implements Layer { } private handleWarShipEvent(unit: UnitView) { + if (unit.retreating()) { + this.drawSprite(unit, colord("rgb(0,180,255)")); + return; + } + if (unit.targetUnitId()) { this.drawSprite(unit, colord("rgb(200,0,0)")); - } else { - this.drawSprite(unit); + return; } + + this.drawSprite(unit); } private handleShellEvent(unit: UnitView) { diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 990da255ba..c4c69a6eb2 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -151,6 +151,12 @@ export interface Config { warshipPatrolRange(): number; warshipShellAttackRate(): number; warshipTargettingRange(): number; + warshipPortHealingRadius(): number; + warshipPortHealingBonus(): number; + warshipRetreatHealthThreshold(): number; + warshipPassiveHealing(): number; + warshipPassiveHealingRange(): number; + warshipPortSwitchThreshold(): number; defensePostShellAttackRate(): number; defensePostTargettingRange(): number; // 0-1 diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 4ad8b2e330..db5fc6f37b 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -908,6 +908,30 @@ export class DefaultConfig implements Config { return 20; } + warshipPortHealingRadius(): number { + return 5; + } + + warshipPortHealingBonus(): number { + return 5; + } + + warshipRetreatHealthThreshold(): number { + return 750; + } + + warshipPassiveHealing(): number { + return 1; + } + + warshipPassiveHealingRange(): number { + return 150; + } + + warshipPortSwitchThreshold(): number { + return 0.75; + } + defensePostShellAttackRate(): number { return 100; } diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index 70bfb654cc..a161ad3850 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -20,6 +20,10 @@ export class WarshipExecution implements Execution { private pathfinder: SteppingPathFinder; private lastShellAttack = 0; private alreadySentShell = new Set(); + private retreatPortTile: TileRef | undefined; + private retreatingForRepair = false; + private docked = false; + private activeHealingRemainder = 0; constructor( private input: (UnitParams & OwnerComp) | Unit, @@ -55,24 +59,460 @@ export class WarshipExecution implements Execution { this.warship.delete(); return; } + const healthBeforeHealing = this.warship.health(); - const hasPort = this.warship.owner().unitCount(UnitType.Port) > 0; - if (hasPort) { - this.warship.modifyHealth(1); + this.healWarship(); + + if (this.docked) { + if (this.currentRetreatPort() === undefined) { + this.docked = false; + this.cancelRepairRetreat(); + } + if (this.isFullyHealed()) { + this.docked = false; + this.cancelRepairRetreat(); + } + if (this.docked) { + return; + } + } + + if (this.handleRepairRetreat()) { + return; + } + + // Priority 1: Check if need to heal before doing anything else + if (this.shouldStartRepairRetreat(healthBeforeHealing)) { + this.startRepairRetreat(); + if (this.handleRepairRetreat()) { + return; + } } this.warship.setTargetUnit(this.findTargetUnit()); + + // Always patrol for movement + this.patrol(); + + // Movement can change what is actually in range, so recompute before acting. + this.warship.setTargetUnit(this.findTargetUnit()); + + // Priority 1: Shoot transport ship if in range + if (this.warship.targetUnit()?.type() === UnitType.TransportShip) { + this.shootTarget(); + return; + } + + // Priority 2: Fight enemy warship if in range + if (this.warship.targetUnit()?.type() === UnitType.Warship) { + this.shootTarget(); + return; + } + + // Priority 3: Hunt trade ship only if not healing and no enemy warship if (this.warship.targetUnit()?.type() === UnitType.TradeShip) { this.huntDownTradeShip(); return; } + } - this.patrol(); + private healWarship(): void { + const owner = this.warship.owner(); + const passiveHealing = this.mg.config().warshipPassiveHealing(); + const passiveHealingRange = this.mg.config().warshipPassiveHealingRange(); + const passiveHealingRangeSquared = + passiveHealingRange * passiveHealingRange; + const warshipTile = this.warship.tile(); - if (this.warship.targetUnit() !== undefined) { - this.shootTarget(); + let isNearPort = false; + for (const port of owner.units(UnitType.Port)) { + const distSquared = this.mg.euclideanDistSquared( + warshipTile, + port.tile(), + ); + if (distSquared <= passiveHealingRangeSquared) { + isNearPort = true; + break; + } + } + + if (isNearPort) { + this.warship.modifyHealth(passiveHealing); + } + + if (this.docked) { + this.applyActiveDockedHealing(); + } + } + + private isFullyHealed(): boolean { + const maxHealth = this.mg.config().unitInfo(UnitType.Warship).maxHealth; + if (typeof maxHealth !== "number") { + return true; + } + return this.warship.health() >= maxHealth; + } + + private shouldStartRepairRetreat( + healthBeforeHealing = this.warship.health(), + ): boolean { + if (this.retreatingForRepair) { + return false; + } + if ( + healthBeforeHealing >= this.mg.config().warshipRetreatHealthThreshold() + ) { + return false; + } + const ports = this.warship.owner().units(UnitType.Port); + return ports.length > 0; + } + + private findNearestPort(): TileRef | undefined { + const ports = this.warship.owner().units(UnitType.Port); + if (ports.length === 0) { + return undefined; + } + + const warshipTile = this.warship.tile(); + const warshipComponent = this.mg.getWaterComponent(warshipTile); + + let bestTile: TileRef | undefined = undefined; + let bestDistance = Infinity; + for (const port of ports) { + const portTile = port.tile(); + if ( + warshipComponent !== null && + !this.mg.hasWaterComponent(portTile, warshipComponent) + ) { + continue; + } + + const distance = this.mg.euclideanDistSquared(warshipTile, portTile); + if (distance < bestDistance) { + bestDistance = distance; + bestTile = portTile; + } + } + + return bestTile; + } + + private startRepairRetreat(): void { + this.retreatingForRepair = true; + this.docked = false; + this.activeHealingRemainder = 0; + this.warship.setRetreating(true); + this.retreatPortTile = this.findNearestPort(); + this.warship.setTargetUnit(undefined); + if (this.retreatPortTile === undefined) { + this.cancelRepairRetreat(); + } + } + + private cancelRepairRetreat(clearTargetTile = true): void { + this.retreatingForRepair = false; + this.activeHealingRemainder = 0; + this.warship.setRetreating(false); + this.retreatPortTile = undefined; + if (clearTargetTile) { + this.warship.setTargetTile(undefined); + } + } + + private handleRepairRetreat(): boolean { + if (!this.retreatingForRepair) { + return false; + } + + if (this.isFullyHealed()) { + this.cancelRepairRetreat(); + return false; + } + + if (!this.refreshRetreatPortTile()) { + this.cancelRepairRetreat(); + return false; + } + + this.warship.setTargetUnit(undefined); + + const retreatPortTile = this.retreatPortTile; + if (retreatPortTile === undefined) { + return false; + } + + const dockingRadius = this.mg.config().warshipPortHealingRadius(); + const dockingRadiusSq = dockingRadius * dockingRadius; + const distToPort = this.mg.euclideanDistSquared( + this.warship.tile(), + retreatPortTile, + ); + + if (distToPort <= dockingRadiusSq) { + // Check if the port has capacity available (excluding this warship from capacity check) + const port = this.warship + .owner() + .units(UnitType.Port) + .find((p) => p.tile() === retreatPortTile); + if (port && !this.isPortFullOfHealing(port, this.warship)) { + // Port has capacity - dock here + this.warship.setTargetTile(undefined); + this.docked = true; + return true; + } else { + // Port is full - don't cancel retreat, keep waiting near port + return true; + } + } + + this.warship.setTargetTile(retreatPortTile); + const result = this.pathfinder.next(this.warship.tile(), retreatPortTile); + switch (result.status) { + case PathStatus.COMPLETE: + this.warship.move(result.node); + if (result.node === retreatPortTile) { + this.warship.setTargetTile(undefined); + } + break; + case PathStatus.NEXT: + this.warship.move(result.node); + break; + case PathStatus.NOT_FOUND: + this.retreatPortTile = this.findNearestAvailablePortTile(); + if (this.retreatPortTile === undefined) { + this.cancelRepairRetreat(); + } + break; + } + + return true; + } + + private refreshRetreatPortTile(): boolean { + const ports = this.warship.owner().units(UnitType.Port); + if (ports.length === 0) { + return false; + } + + // Check if current retreat port still exists + const currentPortExists = + this.retreatPortTile !== undefined && + ports.some((port) => port.tile() === this.retreatPortTile); + + if (!currentPortExists) { + this.retreatPortTile = this.findNearestAvailablePortTile(); + return this.retreatPortTile !== undefined; + } + + // Check if current port is now full of healing (not counting arrived warships) + const currentPort = ports.find((p) => p.tile() === this.retreatPortTile); + if (currentPort && this.isPortFullOfHealing(currentPort)) { + // Current port is at healing capacity, look for alternatives + const alternativePort = this.findNearestAvailablePort(); + if (alternativePort) { + this.retreatPortTile = alternativePort; + } + return this.retreatPortTile !== undefined; + } + + // Check if a significantly closer port is available + const closerPort = this.findBetterPortTile(); + if (closerPort && closerPort !== this.retreatPortTile) { + this.retreatPortTile = closerPort; + return true; + } + + return true; + } + + private isPortFullOfHealing(port: Unit, excludeShip?: Unit): boolean { + const maxShipsHealing = port.level(); + return this.dockedShipsAtPort(port, excludeShip).length >= maxShipsHealing; + } + + private dockedShipsAtPort(port: Unit, excludeShip?: Unit): Unit[] { + const portTile = port.tile(); + const dockingRadius = this.mg.config().warshipPortHealingRadius(); + const dockingRadiusSq = dockingRadius * dockingRadius; + + return this.warship + .owner() + .units(UnitType.Warship) + .filter((ship) => { + if (excludeShip && ship === excludeShip) { + return false; + } + if (!ship.retreating()) { + return false; + } + // Docked ships are retreating ships that are stationary at the port. + if (ship.targetTile() !== undefined) { + return false; + } + return ( + this.mg.euclideanDistSquared(ship.tile(), portTile) <= dockingRadiusSq + ); + }); + } + + private applyActiveDockedHealing(): void { + const dockedPort = this.currentRetreatPort(); + if (!dockedPort) { return; } + + const dockedShips = this.dockedShipsAtPort(dockedPort); + if (!dockedShips.some((ship) => ship === this.warship)) { + return; + } + + const healingPool = + dockedPort.level() * this.mg.config().warshipPortHealingBonus(); + if (healingPool <= 0 || dockedShips.length === 0) { + return; + } + + // Preserve fractional split healing over time with a per-ship remainder. + const activeHealing = healingPool / dockedShips.length; + this.activeHealingRemainder += activeHealing; + const integerHealing = Math.floor(this.activeHealingRemainder); + if (integerHealing <= 0) { + return; + } + + this.activeHealingRemainder -= integerHealing; + this.warship.modifyHealth(integerHealing); + } + + private currentRetreatPort(): Unit | undefined { + if (this.retreatPortTile === undefined) { + return undefined; + } + + return this.warship + .owner() + .units(UnitType.Port) + .find((port) => port.tile() === this.retreatPortTile); + } + + private findNearestAvailablePort(): TileRef | undefined { + const ports = this.warship.owner().units(UnitType.Port); + const warshipTile = this.warship.tile(); + const warshipComponent = this.mg.getWaterComponent(warshipTile); + + // Find a port that's not at healing capacity + let bestTile: TileRef | undefined = undefined; + let bestDistance = Infinity; + + for (const port of ports) { + // Skip ports that are at healing capacity + if (this.isPortFullOfHealing(port)) { + continue; + } + + const portTile = port.tile(); + if ( + warshipComponent !== null && + !this.mg.hasWaterComponent(portTile, warshipComponent) + ) { + continue; + } + + const distance = this.mg.euclideanDistSquared(warshipTile, portTile); + if (distance < bestDistance) { + bestDistance = distance; + bestTile = portTile; + } + } + + return bestTile; + } + + private findBetterPortTile(): TileRef | undefined { + const ports = this.warship.owner().units(UnitType.Port); + const warshipTile = this.warship.tile(); + const warshipComponent = this.mg.getWaterComponent(warshipTile); + + // Get current distance to retreat port + let currentDistance = Infinity; + if (this.retreatPortTile) { + currentDistance = this.mg.euclideanDistSquared( + warshipTile, + this.retreatPortTile, + ); + } + + // Find closest port with healing capacity available + let bestTile: TileRef | undefined = undefined; + let bestDistance = Infinity; + + for (const port of ports) { + // Prefer ports that have healing capacity available + if (this.isPortFullOfHealing(port)) { + continue; + } + + const portTile = port.tile(); + if ( + warshipComponent !== null && + !this.mg.hasWaterComponent(portTile, warshipComponent) + ) { + continue; + } + + const distance = this.mg.euclideanDistSquared(warshipTile, portTile); + if (distance < bestDistance) { + bestDistance = distance; + bestTile = portTile; + } + } + + // Switch to better port only if significantly closer (use config threshold) + if ( + bestTile && + bestDistance < + currentDistance * this.mg.config().warshipPortSwitchThreshold() + ) { + return bestTile; + } + + return undefined; + } + + private findNearestAvailablePortTile(): TileRef | undefined { + const ports = this.warship.owner().units(UnitType.Port); + if (ports.length === 0) { + return undefined; + } + + const warshipTile = this.warship.tile(); + const warshipComponent = this.mg.getWaterComponent(warshipTile); + + let bestTile: TileRef | undefined = undefined; + let bestDistance = Infinity; + for (const port of ports) { + // Skip ports that are at capacity + if (this.isPortFullOfHealing(port, this.warship)) { + continue; + } + + const portTile = port.tile(); + if ( + warshipComponent !== null && + !this.mg.hasWaterComponent(portTile, warshipComponent) + ) { + continue; + } + + const distance = this.mg.euclideanDistSquared(warshipTile, portTile); + if (distance < bestDistance) { + bestDistance = distance; + bestTile = portTile; + } + } + + return bestTile; } private findTargetUnit(): Unit | undefined { @@ -229,6 +669,10 @@ export class WarshipExecution implements Execution { return this.warship?.isActive(); } + isDocked(): boolean { + return this.docked; + } + activeDuringSpawnPhase(): boolean { return false; } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index d77d460699..c7303a2cfb 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -576,6 +576,7 @@ export interface Unit { // Health hasHealth(): boolean; retreating(): boolean; + setRetreating(retreating: boolean): void; orderBoatRetreat(): void; health(): number; modifyHealth(delta: number, attacker?: Player): void; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index d3e3ad87e5..9874af2378 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -116,8 +116,11 @@ export class UnitView { return this.data.troops; } retreating(): boolean { - if (this.type() !== UnitType.TransportShip) { - throw Error("Must be a transport ship"); + if ( + this.type() !== UnitType.TransportShip && + this.type() !== UnitType.Warship + ) { + throw Error("Must be a transport ship or warship"); } return this.data.retreating; } diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 161e4aa7f9..e958d266a6 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -221,11 +221,19 @@ export class UnitImpl implements Unit { } modifyHealth(delta: number, attacker?: Player): void { - this._health = withinInt( + const previousHealth = this._health; + const nextHealth = withinInt( this._health + toInt(delta), 0n, toInt(this.info().maxHealth ?? 1), ); + + if (nextHealth === previousHealth) { + return; + } + + this._health = nextHealth; + this.mg.addUpdate(this.toUpdate()); if (this._health === 0n) { this.delete(true, attacker); } @@ -331,14 +339,18 @@ export class UnitImpl implements Unit { return this._retreating; } + setRetreating(retreating: boolean): void { + if (this._retreating !== retreating) { + this._retreating = retreating; + this.mg.addUpdate(this.toUpdate()); + } + } + orderBoatRetreat() { if (this.type() !== UnitType.TransportShip) { - throw new Error(`Cannot retreat ${this.type()}`); - } - if (!this._retreating) { - this._retreating = true; - this.mg.addUpdate(this.toUpdate()); + throw new Error("Cannot retreat " + this.type()); } + this.setRetreating(true); } isUnderConstruction(): boolean { diff --git a/tests/Warship.test.ts b/tests/Warship.test.ts index ee6c556de4..39516325ac 100644 --- a/tests/Warship.test.ts +++ b/tests/Warship.test.ts @@ -7,6 +7,7 @@ import { PlayerType, UnitType, } from "../src/core/game/Game"; +import { PathStatus } from "../src/core/pathfinding/types"; import { setup } from "./util/Setup"; import { executeTicks } from "./util/utils"; @@ -200,6 +201,37 @@ describe("Warship", () => { 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("MoveWarshipExecution fails if player is not the owner", async () => { const originalPatrolTile = game.ref(coastX + 1, 10); const warship = player1.buildUnit( @@ -247,4 +279,239 @@ describe("Warship", () => { 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().warshipPortHealingBonus = () => 0; + game.config().warshipRetreatHealthThreshold = () => 600; + + 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.retreating()).toBe(true); + 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().warshipPortHealingBonus = () => 6; + game.config().warshipPortHealingRadius = () => 5; + game.config().warshipRetreatHealthThreshold = () => 900; + + 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().warshipPortHealingRadius = () => 5; + game.config().warshipRetreatHealthThreshold = () => 900; + + 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.retreating() + ) { + break; + } + } + + const warship2DistanceToPort = game.euclideanDistSquared( + warship2.tile(), + portTile, + ); + expect(exec1.isDocked()).toBe(true); + expect(exec2.isDocked()).toBe(false); + expect(warship2DistanceToPort).toBeLessThanOrEqual(25); + expect(warship2.retreating()).toBe(true); + }); + + test("Warship cancels docking if its retreat port is destroyed", async () => { + game.config().warshipPassiveHealing = () => 0; + game.config().warshipPortHealingBonus = () => 0; + game.config().warshipPortHealingRadius = () => 5; + game.config().warshipRetreatHealthThreshold = () => 900; + + 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.retreating()).toBe(false); + }); + + 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); + expect(warship.targetUnit()).toBeUndefined(); + }); + + test("Warship cancels retreat if no friendly port is reachable by water", async () => { + game.config().warshipRetreatHealthThreshold = () => 900; + + 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.retreating()).toBe(false); + }); });