diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 82425c8e8..48f405d67 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -153,7 +153,12 @@ export interface Config { warshipPatrolRange(): number; warshipShellAttackRate(): number; warshipTargettingRange(): number; + warshipDockingRange(): number; + warshipPortHealingBonusPerLevel(): 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 568e71c27..642db819b 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -969,10 +969,30 @@ export class DefaultConfig implements Config { return 20; } + warshipDockingRange(): number { + return 5; + } + + warshipPortHealingBonusPerLevel(): 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 d707abf55..f6aefc37f 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -23,6 +23,8 @@ export class WarshipExecution implements Execution { private alreadySentShell = new Set(); private retreatPortTile: TileRef | undefined; private retreatingForRepair = false; + private docked = false; + private activeHealingRemainder = 0; constructor( private input: (UnitParams & OwnerComp) | Unit, @@ -62,6 +64,20 @@ export class WarshipExecution implements Execution { 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; } @@ -79,6 +95,9 @@ export class WarshipExecution implements Execution { // 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(); @@ -99,14 +118,28 @@ export class WarshipExecution implements Execution { } private healWarship(): void { - if (this.warship.owner().unitCount(UnitType.Port) > 0) { - this.warship.modifyHealth(1); + const owner = this.warship.owner(); + const passiveHealing = this.mg.config().warshipPassiveHealing(); + const passiveHealingRange = this.mg.config().warshipPassiveHealingRange(); + const warshipTile = this.warship.tile(); + + const isNearPort = this.mg + .nearbyUnits(warshipTile, passiveHealingRange, [UnitType.Port]) + .some(({ unit }) => unit.owner() === owner); + + 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") { + console.warn("Warship maxHealth is not a number, disabling retreat"); return true; } return this.warship.health() >= maxHealth; @@ -162,12 +195,15 @@ export class WarshipExecution implements Execution { } this.retreatingForRepair = true; this.retreatPortTile = portTile; + this.docked = false; + this.activeHealingRemainder = 0; this.warship.setRetreating(true); this.warship.setTargetUnit(undefined); } private cancelRepairRetreat(clearTargetTile = true): void { this.retreatingForRepair = false; + this.activeHealingRemainder = 0; this.warship.setRetreating(false); this.retreatPortTile = undefined; if (clearTargetTile) { @@ -185,6 +221,11 @@ export class WarshipExecution implements Execution { return false; } + if (!this.refreshRetreatPortTile()) { + this.cancelRepairRetreat(); + return false; + } + this.warship.setTargetUnit(undefined); const retreatPortTile = this.retreatPortTile; @@ -192,9 +233,28 @@ export class WarshipExecution implements Execution { return false; } - if (this.warship.tile() === retreatPortTile) { - this.warship.setTargetTile(undefined); - return true; + const dockingRadius = this.mg.config().warshipDockingRange(); + 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); @@ -210,7 +270,7 @@ export class WarshipExecution implements Execution { this.warship.move(result.node); break; case PathStatus.NOT_FOUND: - this.retreatPortTile = this.findNearestPort(); + this.retreatPortTile = this.findNearestAvailablePortTile(this.warship); if (this.retreatPortTile === undefined) { this.cancelRepairRetreat(); } @@ -220,6 +280,156 @@ export class WarshipExecution implements Execution { 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(this.warship); + 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.findNearestAvailablePortTile(); + 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 dockingRadius = this.mg.config().warshipDockingRange(); + const owner = this.warship.owner(); + + return this.mg + .nearbyUnits(port.tile(), dockingRadius, [UnitType.Warship]) + .filter(({ unit: ship }) => { + if (excludeShip && ship === excludeShip) return false; + if (ship.owner() !== owner) return false; + if (!ship.retreating()) return false; + if (ship.targetTile() !== undefined) return false; + return true; + }) + .map(({ unit }) => unit); + } + + private applyActiveDockedHealing(): void { + const dockedPort = this.currentRetreatPort(); + if (!dockedPort) { + return; + } + + const dockedShips = this.dockedShipsAtPort(dockedPort); + + const healingPool = + dockedPort.level() * this.mg.config().warshipPortHealingBonusPerLevel(); + 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 findBetterPortTile(): TileRef | undefined { + const warshipTile = this.warship.tile(); + const currentDistance = this.retreatPortTile + ? this.mg.euclideanDistSquared(warshipTile, this.retreatPortTile) + : Infinity; + const bestTile = this.findNearestAvailablePortTile(this.warship); + if (!bestTile) { + return undefined; + } + const bestDistance = this.mg.euclideanDistSquared(warshipTile, bestTile); + if ( + bestDistance < + currentDistance * this.mg.config().warshipPortSwitchThreshold() + ) { + return bestTile; + } + return undefined; + } + + private findNearestAvailablePortTile( + excludeShip?: Unit, + ): 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); + if (warshipComponent === null) { + throw new Error(`Warship at tile ${warshipTile} has no water component`); + } + + let bestTile: TileRef | undefined = undefined; + let bestDistance = Infinity; + for (const port of ports) { + if (this.isPortFullOfHealing(port, excludeShip)) { + continue; + } + + const portTile = port.tile(); + if (!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 { const mg = this.mg; const config = mg.config(); @@ -390,6 +600,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/UnitImpl.ts b/src/core/game/UnitImpl.ts index 81b6caa69..e958d266a 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -221,11 +221,18 @@ 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); diff --git a/tests/Warship.test.ts b/tests/Warship.test.ts index 925ca298a..43b178ea0 100644 --- a/tests/Warship.test.ts +++ b/tests/Warship.test.ts @@ -8,6 +8,7 @@ import { 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"; @@ -327,6 +328,7 @@ describe("Warship", () => { throw new Error("unreachable"); } + game.config().warshipPortHealingBonusPerLevel = () => 0; game.config().warshipRetreatHealthThreshold = () => 600; const homePort = player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {}); @@ -353,4 +355,200 @@ describe("Warship", () => { 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().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().warshipDockingRange = () => 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().warshipPortHealingBonusPerLevel = () => 0; + game.config().warshipDockingRange = () => 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); + }); });