diff --git a/src/client/graphics/layers/AttacksDisplay.ts b/src/client/graphics/layers/AttacksDisplay.ts index 5cadfc192..d3889ddf8 100644 --- a/src/client/graphics/layers/AttacksDisplay.ts +++ b/src/client/graphics/layers/AttacksDisplay.ts @@ -377,16 +377,16 @@ export class AttacksDisplay extends LitElement implements Layer { "text-left text-aquarius inline-flex items-center gap-0.5 lg:gap-1 min-w-0", translate: false, })} - ${!boat.retreating() - ? this.renderButton({ - content: "❌", + ${boat.transportShipState().isRetreating + ? html`(${translateText("events_display.retreating")}...)` + : this.renderButton({ + content: "\u274C", onClick: () => this.emitBoatCancelIntent(boat.id()), className: "ml-auto text-left shrink-0", - disabled: boat.retreating(), - }) - : html`(${translateText("events_display.retreating")}...)`} + disabled: boat.transportShipState().isRetreating, + })} `, ); diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 9a4f4ec1f..d14f4448c 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -456,12 +456,17 @@ export class UnitLayer implements Layer { } private handleWarShipEvent(unit: UnitView) { - if (unit.retreating()) { - this.drawSprite(unit, colord("rgb(0,180,255)")); + if (unit.warshipState().state !== "patrolling" && unit.isActive()) { + if (unit.warshipState().isInCombat) { + this.drawSprite(unit, colord("rgb(200,0,0)")); + } else { + this.drawSprite(unit); + } + this.drawRetreatCross(unit); return; } - if (unit.targetUnitId()) { + if (unit.warshipState().isInCombat) { this.drawSprite(unit, colord("rgb(200,0,0)")); return; } @@ -469,6 +474,27 @@ export class UnitLayer implements Layer { this.drawSprite(unit); } + private drawRetreatCross(unit: UnitView) { + // Blink: 500ms on, 500ms off + if (Math.floor(Date.now() / 500) % 2 === 0) return; + const x = this.game.x(unit.tile()); + const y = this.game.y(unit.tile()); + const ctx = this.context; + ctx.save(); + const cx = x + 0.5; + const cy = y + 0.5; + ctx.lineCap = "square"; + ctx.strokeStyle = "rgb(36,36,36)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(cx, cy - 1.5); + ctx.lineTo(cx, cy + 1.5); + ctx.moveTo(cx - 1.5, cy); + ctx.lineTo(cx + 1.5, cy); + ctx.stroke(); + ctx.restore(); + } + private handleShellEvent(unit: UnitView) { const rel = this.relationship(unit); diff --git a/src/client/graphics/ui/NavalTarget.ts b/src/client/graphics/ui/NavalTarget.ts index 50a2d39a9..0e88487df 100644 --- a/src/client/graphics/ui/NavalTarget.ts +++ b/src/client/graphics/ui/NavalTarget.ts @@ -122,7 +122,8 @@ export class NavalTarget extends Target { if ( !this.ended && (!this.unit.isActive() || - (this.unit.type() === UnitType.TransportShip && this.unit.retreating())) + (this.unit.type() === UnitType.TransportShip && + this.unit.transportShipState().isRetreating)) ) { this.ended = true; } diff --git a/src/core/execution/BoatRetreatExecution.ts b/src/core/execution/BoatRetreatExecution.ts index c6afedff1..d74538a75 100644 --- a/src/core/execution/BoatRetreatExecution.ts +++ b/src/core/execution/BoatRetreatExecution.ts @@ -23,7 +23,7 @@ export class BoatRetreatExecution implements Execution { return; } - unit.orderBoatRetreat(); + unit.updateTransportShipState({ isRetreating: true }); this.active = false; } diff --git a/src/core/execution/DeleteUnitExecution.ts b/src/core/execution/DeleteUnitExecution.ts index 9ba68ee50..ce0e4768f 100644 --- a/src/core/execution/DeleteUnitExecution.ts +++ b/src/core/execution/DeleteUnitExecution.ts @@ -20,8 +20,8 @@ export class DeleteUnitExecution implements Execution { } this.mg = mg; - const unit = this.player.units().find((u) => u.id() === this.unitId); - if (!unit) { + const unit = this.mg.unit(this.unitId); + if (!unit || unit.owner() !== this.player) { console.warn( `SECURITY: unit ${this.unitId} not found or not owned by player ${this.player.displayName()}`, ); diff --git a/src/core/execution/MoveWarshipExecution.ts b/src/core/execution/MoveWarshipExecution.ts index 648d6eab9..fb21d8858 100644 --- a/src/core/execution/MoveWarshipExecution.ts +++ b/src/core/execution/MoveWarshipExecution.ts @@ -28,7 +28,9 @@ export class MoveWarshipExecution implements Execution { console.warn(`MoveWarshipExecution: warship ${unitId} is not active`); continue; } - warship.setPatrolTile(this.position); + warship.updateWarshipState({ + patrolTile: this.position, + }); warship.setTargetTile(undefined); } } diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index a6817a918..77ad21327 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -198,14 +198,14 @@ export class TransportShipExecution implements Execution { // Checked every tick (not just on graph rebuild) because graph rebuilds // are throttled and the tile may already be water before the version bumps. if (this.dst !== null && this.mg.isWater(this.dst)) { - if (!this.boat.retreating()) { - this.boat.orderBoatRetreat(); + if (!this.boat.transportShipState().isRetreating) { + this.boat.updateTransportShipState({ isRetreating: true }); } // Reset cached retreat destination so it's recomputed from current position this.retreatDst = null; } - if (this.boat.retreating()) { + if (this.boat.transportShipState().isRetreating) { // Resolve retreat destination once, based on current boat location when retreat begins. this.retreatDst ??= this.attacker.bestTransportShipSpawn( this.boat.tile(), diff --git a/src/core/execution/UpgradeStructureExecution.ts b/src/core/execution/UpgradeStructureExecution.ts index b0d575a30..bc1a58c0d 100644 --- a/src/core/execution/UpgradeStructureExecution.ts +++ b/src/core/execution/UpgradeStructureExecution.ts @@ -10,9 +10,11 @@ export class UpgradeStructureExecution implements Execution { ) {} init(mg: Game, ticks: number): void { - this.structure = this.player - .units() - .find((unit) => unit.id() === this.unitId); + this.structure = mg.unit(this.unitId); + if (this.structure && this.structure.owner() !== this.player) { + console.warn(`structure not owned by player`); + this.structure = undefined; + } if (this.structure === undefined) { console.warn(`structure is undefined`); diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index f6aefc37f..05ece46bf 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -21,10 +21,10 @@ export class WarshipExecution implements Execution { private pathfinder: WaterPathFinder; private lastShellAttack = 0; private alreadySentShell = new Set(); - private retreatPortTile: TileRef | undefined; - private retreatingForRepair = false; - private docked = false; + private lastManualMoveTickRetreatDisabled = 0; + private lastObservedPatrolTile: TileRef | undefined; private activeHealingRemainder = 0; + private lastEmittedCombat = false; constructor( private input: (UnitParams & OwnerComp) | Unit, @@ -53,6 +53,7 @@ export class WarshipExecution implements Execution { this.input, ); } + this.lastObservedPatrolTile = this.warship.warshipState().patrolTile; } tick(ticks: number): void { @@ -60,20 +61,24 @@ export class WarshipExecution implements Execution { this.warship.delete(); return; } + const isInCombat = this.warship.warshipState().isInCombat ?? false; + if (this.lastEmittedCombat && !isInCombat) { + this.warship.touch(); + } + this.lastEmittedCombat = isInCombat; const healthBeforeHealing = this.warship.health(); this.healWarship(); + this.handleManualPatrolOverride(); - if (this.docked) { + if (this.warship.warshipState().state === "docked") { if (this.currentRetreatPort() === undefined) { - this.docked = false; this.cancelRepairRetreat(); } if (this.isFullyHealed()) { - this.docked = false; this.cancelRepairRetreat(); } - if (this.docked) { + if (this.warship.warshipState().state === "docked") { return; } } @@ -92,21 +97,17 @@ export class WarshipExecution implements Execution { 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(); + this.patrol(); return; } // Priority 2: Fight enemy warship if in range if (this.warship.targetUnit()?.type() === UnitType.Warship) { this.shootTarget(); + this.patrol(); return; } @@ -115,23 +116,35 @@ export class WarshipExecution implements Execution { 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(); - const isNearPort = this.mg - .nearbyUnits(warshipTile, passiveHealingRange, [UnitType.Port]) - .some(({ unit }) => unit.owner() === owner); + 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) { + if (this.warship.warshipState().state === "docked") { this.applyActiveDockedHealing(); } } @@ -139,7 +152,6 @@ export class WarshipExecution implements Execution { 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; @@ -148,7 +160,14 @@ export class WarshipExecution implements Execution { private shouldStartRepairRetreat( healthBeforeHealing = this.warship.health(), ): boolean { - if (this.retreatingForRepair) { + if (this.warship.warshipState().state !== "patrolling") { + return false; + } + const manualMoveRetreatDisabledDuration = 50; + if ( + this.mg.ticks() - this.lastManualMoveTickRetreatDisabled < + manualMoveRetreatDisabledDuration + ) { return false; } if ( @@ -156,7 +175,6 @@ export class WarshipExecution implements Execution { ) { return false; } - // Only retreat if there's a friendly port const ports = this.warship.owner().units(UnitType.Port); return ports.length > 0; } @@ -188,37 +206,159 @@ export class WarshipExecution implements Execution { return nearest?.tile(); } + private findRetreatAggroTarget(): Unit | undefined { + return this.findBestTarget([UnitType.TransportShip, UnitType.Warship]); + } + + private findTargetUnit(): Unit | undefined { + return this.findBestTarget( + [UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip], + true, + ); + } + + /** + * Shared target selection: searches nearby units of given types, + * filters common exclusions (self, friendly, docked, already-shelled), + * picks best by type priority (lower index = higher priority) then distance. + * + * When `includeTradeShips` is true, applies trade-ship-specific filters + * (safe from pirates, patrol range, water component, allied destination). + */ + private findBestTarget( + types: UnitType[], + includeTradeShips = false, + ): Unit | undefined { + const mg = this.mg; + const config = mg.config(); + const owner = this.warship.owner(); + + const ships = mg.nearbyUnits( + this.warship.tile(), + config.warshipTargettingRange(), + types, + ); + + // Trade-ship-specific state, lazily computed. + let hasPort: boolean | undefined; + let patrolTile: number | undefined; + let patrolRangeSquared: number | undefined; + let warshipComponent: number | null | undefined = undefined; + + let bestUnit: Unit | undefined = undefined; + let bestTypePriority = 0; + let bestDistSquared = 0; + + for (const { unit, distSquared } of ships) { + if ( + unit === this.warship || + unit.owner() === owner || + !owner.canAttackPlayer(unit.owner(), true) || + this.alreadySentShell.has(unit) || + (unit.type() === UnitType.Warship && + unit.warshipState().state === "docked") + ) { + continue; + } + + const type = unit.type(); + + if (includeTradeShips && type === UnitType.TradeShip) { + if (hasPort === undefined) { + hasPort = owner.unitCount(UnitType.Port) > 0; + patrolTile = this.warship.warshipState().patrolTile; + patrolRangeSquared = config.warshipPatrolRange() ** 2; + } + if ( + !hasPort || + patrolTile === undefined || + unit.isSafeFromPirates() || + unit.targetUnit()?.owner() === owner || + unit.targetUnit()?.owner().isFriendly(owner) + ) { + continue; + } + if (warshipComponent === undefined) { + warshipComponent = mg.getWaterComponent(this.warship.tile()); + } + if ( + warshipComponent !== null && + !mg.hasWaterComponent(unit.tile(), warshipComponent) + ) { + continue; + } + if ( + mg.euclideanDistSquared(patrolTile, unit.tile()) > patrolRangeSquared! + ) { + continue; + } + } + + const typePriority = + type === UnitType.TransportShip ? 0 : type === UnitType.Warship ? 1 : 2; + + if ( + bestUnit === undefined || + typePriority < bestTypePriority || + (typePriority === bestTypePriority && distSquared < bestDistSquared) + ) { + bestUnit = unit; + bestTypePriority = typePriority; + bestDistSquared = distSquared; + } + } + + return bestUnit; + } + private startRepairRetreat(): void { const portTile = this.findNearestPort(); if (portTile === undefined) { return; } - this.retreatingForRepair = true; - this.retreatPortTile = portTile; - this.docked = false; + this.warship.updateWarshipState({ + retreatPort: portTile, + state: "retreating", + }); 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; + this.warship.updateWarshipState({ + state: "patrolling", + retreatPort: undefined, + }); if (clearTargetTile) { this.warship.setTargetTile(undefined); } } + private handleManualPatrolOverride(): void { + const patrolTile = this.warship.warshipState().patrolTile; + if ( + this.lastObservedPatrolTile !== undefined && + patrolTile !== this.lastObservedPatrolTile + ) { + this.lastManualMoveTickRetreatDisabled = this.mg.ticks(); + if (this.warship.warshipState().state !== "patrolling") { + this.cancelRepairRetreat(false); + } + } + this.lastObservedPatrolTile = patrolTile; + } + private handleRepairRetreat(): boolean { - if (!this.retreatingForRepair) { + if (this.warship.warshipState().state === "patrolling") { return false; } - if (this.isFullyHealed()) { - this.cancelRepairRetreat(); - return false; + const retreatAggroTarget = this.findRetreatAggroTarget(); + if (retreatAggroTarget) { + this.warship.setTargetUnit(retreatAggroTarget); + this.shootTarget(); + // Fall through — continue retreating toward port even while firing back. } if (!this.refreshRetreatPortTile()) { @@ -226,9 +366,12 @@ export class WarshipExecution implements Execution { return false; } - this.warship.setTargetUnit(undefined); + // Only clear the target when there's no active aggro target this tick. + if (!retreatAggroTarget) { + this.warship.setTargetUnit(undefined); + } - const retreatPortTile = this.retreatPortTile; + const retreatPortTile = this.warship.warshipState().retreatPort; if (retreatPortTile === undefined) { return false; } @@ -249,10 +392,16 @@ export class WarshipExecution implements Execution { if (port && !this.isPortFullOfHealing(port, this.warship)) { // Port has capacity - dock here this.warship.setTargetTile(undefined); - this.docked = true; + this.warship.updateWarshipState({ + state: "docked", + }); return true; } else { - // Port is full - don't cancel retreat, keep waiting near port + // Port is full - wait near port, but leave if already fully healed + if (this.isFullyHealed()) { + this.cancelRepairRetreat(); + return false; + } return true; } } @@ -269,12 +418,16 @@ export class WarshipExecution implements Execution { case PathStatus.NEXT: this.warship.move(result.node); break; - case PathStatus.NOT_FOUND: - this.retreatPortTile = this.findNearestAvailablePortTile(this.warship); - if (this.retreatPortTile === undefined) { + case PathStatus.NOT_FOUND: { + const newPort = this.findNearestAvailablePortTile(); + this.warship.updateWarshipState({ + retreatPort: newPort, + }); + if (newPort === undefined) { this.cancelRepairRetreat(); } break; + } } return true; @@ -286,31 +439,40 @@ export class WarshipExecution implements Execution { return false; } + const currentRetreatPort = this.warship.warshipState().retreatPort; + // Check if current retreat port still exists const currentPortExists = - this.retreatPortTile !== undefined && - ports.some((port) => port.tile() === this.retreatPortTile); + currentRetreatPort !== undefined && + ports.some((port) => port.tile() === currentRetreatPort); if (!currentPortExists) { - this.retreatPortTile = this.findNearestAvailablePortTile(this.warship); - return this.retreatPortTile !== undefined; + const newPort = this.findNearestAvailablePortTile(); + this.warship.updateWarshipState({ + retreatPort: newPort, + }); + return newPort !== undefined; } // Check if current port is now full of healing (not counting arrived warships) - const currentPort = ports.find((p) => p.tile() === this.retreatPortTile); + const currentPort = ports.find((p) => p.tile() === currentRetreatPort); if (currentPort && this.isPortFullOfHealing(currentPort)) { // Current port is at healing capacity, look for alternatives - const alternativePort = this.findNearestAvailablePortTile(); + const alternativePort = this.findNearestAvailablePort(); if (alternativePort) { - this.retreatPortTile = alternativePort; + this.warship.updateWarshipState({ + retreatPort: alternativePort, + }); } - return this.retreatPortTile !== undefined; + return this.warship.warshipState().retreatPort !== undefined; } // Check if a significantly closer port is available const closerPort = this.findBetterPortTile(); - if (closerPort && closerPort !== this.retreatPortTile) { - this.retreatPortTile = closerPort; + if (closerPort && closerPort !== currentRetreatPort) { + this.warship.updateWarshipState({ + retreatPort: closerPort, + }); return true; } @@ -331,7 +493,7 @@ export class WarshipExecution implements Execution { .filter(({ unit: ship }) => { if (excludeShip && ship === excludeShip) return false; if (ship.owner() !== owner) return false; - if (!ship.retreating()) return false; + if (ship.warshipState().state === "patrolling") return false; if (ship.targetTile() !== undefined) return false; return true; }) @@ -345,6 +507,9 @@ export class WarshipExecution implements Execution { } const dockedShips = this.dockedShipsAtPort(dockedPort); + if (!dockedShips.some((ship) => ship === this.warship)) { + return; + } const healingPool = dockedPort.level() * this.mg.config().warshipPortHealingBonusPerLevel(); @@ -365,43 +530,21 @@ export class WarshipExecution implements Execution { } private currentRetreatPort(): Unit | undefined { - if (this.retreatPortTile === undefined) { + const retreatPort = this.warship.warshipState().retreatPort; + if (retreatPort === undefined) { return undefined; } return this.warship .owner() .units(UnitType.Port) - .find((port) => port.tile() === this.retreatPortTile); + .find((port) => port.tile() === retreatPort); } - 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( + private nearestAvailablePortTile( excludeShip?: Unit, - ): TileRef | undefined { + ): { tile: TileRef; distSquared: number } | 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) { @@ -410,6 +553,7 @@ export class WarshipExecution implements Execution { let bestTile: TileRef | undefined = undefined; let bestDistance = Infinity; + for (const port of ports) { if (this.isPortFullOfHealing(port, excludeShip)) { continue; @@ -427,99 +571,43 @@ export class WarshipExecution implements Execution { } } - return bestTile; + return bestTile !== undefined + ? { tile: bestTile, distSquared: bestDistance } + : undefined; } - private findTargetUnit(): Unit | undefined { - const mg = this.mg; - const config = mg.config(); - const owner = this.warship.owner(); - const hasPort = owner.unitCount(UnitType.Port) > 0; - const patrolTile = this.warship.patrolTile()!; - const patrolRangeSquared = config.warshipPatrolRange() ** 2; + private findNearestAvailablePort(): TileRef | undefined { + return this.nearestAvailablePortTile()?.tile; + } - // Lazy: only computed if a TradeShip candidate forces the component check. - // `undefined` = not yet computed; `null` = computed, no component found. - let warshipComponent: number | null | undefined = undefined; + private findBetterPortTile(): TileRef | undefined { + const result = this.nearestAvailablePortTile(); + if (!result) return undefined; - const ships = mg.nearbyUnits( - this.warship.tile()!, - config.warshipTargettingRange(), - [UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip], - ); - - let bestUnit: Unit | undefined = undefined; - let bestTypePriority = 0; - let bestDistSquared = 0; - - for (const { unit, distSquared } of ships) { - if ( - unit.owner() === owner || - unit === this.warship || - !owner.canAttackPlayer(unit.owner(), true) || - this.alreadySentShell.has(unit) - ) { - continue; - } - - const type = unit.type(); - if (type === UnitType.TradeShip) { - if ( - !hasPort || - unit.isSafeFromPirates() || - unit.targetUnit()?.owner() === owner || // trade ship is coming to my port - unit.targetUnit()?.owner().isFriendly(owner) // trade ship is coming to my ally - ) { - continue; - } - - if (warshipComponent === undefined) { - warshipComponent = mg.getWaterComponent(this.warship.tile()); - } - if ( - warshipComponent !== null && - !mg.hasWaterComponent(unit.tile(), warshipComponent) - ) { - continue; - } - - if ( - mg.euclideanDistSquared(patrolTile, unit.tile()) > patrolRangeSquared - ) { - // Prevent warship from chasing trade ship that is too far away from - // the patrol tile to prevent warships from wandering around the map. - continue; - } - } - - const typePriority = - type === UnitType.TransportShip ? 0 : type === UnitType.Warship ? 1 : 2; - - if (bestUnit === undefined) { - bestUnit = unit; - bestTypePriority = typePriority; - bestDistSquared = distSquared; - continue; - } - - // Match existing `sort()` semantics: - // - Lower priority is better (TransportShip < Warship < TradeShip). - // - For same type, smaller distance is better. - // - For exact ties, keep the first encountered (stable sort behavior). - if ( - typePriority < bestTypePriority || - (typePriority === bestTypePriority && distSquared < bestDistSquared) - ) { - bestUnit = unit; - bestTypePriority = typePriority; - bestDistSquared = distSquared; - } + let currentDistance = Infinity; + const currentRetreatPort = this.warship.warshipState().retreatPort; + if (currentRetreatPort !== undefined) { + currentDistance = this.mg.euclideanDistSquared( + this.warship.tile(), + currentRetreatPort, + ); } - return bestUnit; + if ( + result.distSquared < + currentDistance * this.mg.config().warshipPortSwitchThreshold() + ) { + return result.tile; + } + return undefined; + } + + private findNearestAvailablePortTile(): TileRef | undefined { + return this.nearestAvailablePortTile(this.warship)?.tile; } private shootTarget() { + this.warship.updateWarshipState({ isInCombat: true }); const shellAttackRate = this.mg.config().warshipShellAttackRate(); if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) { if (this.warship.targetUnit()?.type() !== UnitType.TransportShip) { @@ -544,6 +632,7 @@ export class WarshipExecution implements Execution { } private huntDownTradeShip() { + this.warship.updateWarshipState({ isInCombat: true }); for (let i = 0; i < 2; i++) { // target is trade ship so capture it. const result = this.pathfinder.next( @@ -601,7 +690,7 @@ export class WarshipExecution implements Execution { } isDocked(): boolean { - return this.docked; + return (this.warship?.warshipState().state ?? "patrolling") === "docked"; } activeDuringSpawnPhase(): boolean { @@ -617,7 +706,7 @@ export class WarshipExecution implements Execution { // Get warship's water component for connectivity check const warshipComponent = this.mg.getWaterComponent(this.warship.tile()); - const patrolTile = this.warship.patrolTile(); + const patrolTile = this.warship.warshipState().patrolTile; if (patrolTile === undefined) { return undefined; } diff --git a/src/core/execution/nation/NationWarshipBehavior.ts b/src/core/execution/nation/NationWarshipBehavior.ts index 7b0d23b0c..c86a474f8 100644 --- a/src/core/execution/nation/NationWarshipBehavior.ts +++ b/src/core/execution/nation/NationWarshipBehavior.ts @@ -150,7 +150,7 @@ export class NationWarshipBehavior { return ( target && p.isActive() && - !p.retreating() && + !p.transportShipState().isRetreating && this.game.ownerID(target) === this.player?.smallID() && p.owner().smallID() !== this.player?.smallID() ); @@ -162,7 +162,7 @@ export class NationWarshipBehavior { if ( !transport.isActive() || target === undefined || - transport.retreating() + transport.transportShipState().isRetreating ) { this.trackedIncomingTransportShips.delete(transport); this.dealtWithTransportShip.delete(transport); @@ -194,7 +194,7 @@ export class NationWarshipBehavior { true, ) || this.player.units(UnitType.Warship).filter((p) => { - const patrolTile = p.patrolTile(); + const patrolTile = p.warshipState().patrolTile; return ( patrolTile !== undefined && this.game.manhattanDist(target, patrolTile) < 90 @@ -259,7 +259,7 @@ export class NationWarshipBehavior { const warship = this.player .units(UnitType.Warship) .filter((p) => { - const patrolTile = p.patrolTile(); + const patrolTile = p.warshipState().patrolTile; return ( patrolTile !== undefined && // Dont send ships which are already traveling @@ -274,7 +274,7 @@ export class NationWarshipBehavior { })[0]; if (warship) { - warship.setPatrolTile(tile); + warship.updateWarshipState({ patrolTile: tile }); } } } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index aaea780c0..6ec0fb11e 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -26,6 +26,19 @@ export type PlayerID = string; export type Tick = number; export type Gold = bigint; +export type WarshipState = { + state: "patrolling" | "retreating" | "docked"; + patrolTile?: TileRef; + retreatPort?: TileRef; + isInCombat?: boolean; + lastCombatTick: number; +}; + +export type TransportShipState = { + isRetreating: boolean; + troops: number; +}; + export const AllPlayers = "AllPlayers" as const; // export type GameUpdates = Record; @@ -617,9 +630,10 @@ export interface Unit { // Health hasHealth(): boolean; - retreating(): boolean; - setRetreating(retreating: boolean): void; - orderBoatRetreat(): void; + warshipState(): WarshipState; + updateWarshipState(update: Partial): void; + transportShipState(): TransportShipState; + updateTransportShipState(update: Partial): void; health(): number; modifyHealth(delta: number, attacker?: Player): void; @@ -647,10 +661,6 @@ export interface Unit { level(): number; increaseLevel(): void; decreaseLevel(destroyer?: Player): void; - - // Warships - setPatrolTile(tile: TileRef): void; - patrolTile(): TileRef | undefined; } export interface TerraNullius { @@ -870,6 +880,7 @@ export interface Game extends GameMap { setPaused(paused: boolean): void; // Units + unit(id: number): Unit | undefined; units(...types: UnitType[]): Unit[]; unitCount(type: UnitType): number; unitInfo(type: UnitType): UnitInfo; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 0dec4ffc7..39f254ec8 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -97,6 +97,7 @@ export class GameImpl implements Game { private motionPlanRecords: MotionPlanRecord[] = []; private planDrivenUnitIds = new Set(); private unitGrid: UnitGrid; + private _unitMap = new Map(); private playerTeams: Team[] = []; private botTeam: Team = ColoredTeams.Bot; @@ -287,6 +288,10 @@ export class GameImpl implements Game { this._waterManager.queueTile(tile); } + unit(id: number): Unit | undefined { + return this._unitMap.get(id); + } + units(...types: UnitType[]): Unit[] { return Array.from(this._players.values()).flatMap((p) => p.units(...types)); } @@ -955,9 +960,11 @@ export class GameImpl implements Game { addUnit(u: Unit) { this.unitGrid.addUnit(u); + this._unitMap.set(u.id(), u); } removeUnit(u: Unit) { this.unitGrid.removeUnit(u); + this._unitMap.delete(u.id()); this.planDrivenUnitIds.delete(u.id()); if (u.hasTrainStation()) { this._railNetwork.removeStation(u); diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index a85912bda..cb4ecb230 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -10,7 +10,9 @@ import { Team, Tick, TrainType, + TransportShipState, UnitType, + WarshipState, } from "./Game"; import { TileRef } from "./GameMap"; @@ -137,7 +139,8 @@ export interface UnitUpdate { lastPos: TileRef; isActive: boolean; reachedTarget: boolean; - retreating: boolean; + warshipState?: WarshipState; + transportShipState?: TransportShipState; targetable: boolean; markedForDeletion: number | false; targetUnitId?: number; // Only for trade ships diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index de03e5777..fa06b0c07 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -24,8 +24,10 @@ import { TerraNullius, Tick, TrainType, + TransportShipState, UnitInfo, UnitType, + WarshipState, } from "./Game"; import { GameMap, TileRef } from "./GameMap"; import { @@ -115,14 +117,28 @@ export class UnitView { troops(): number { return this.data.troops; } - retreating(): boolean { - if ( - this.type() !== UnitType.TransportShip && - this.type() !== UnitType.Warship - ) { - throw Error("Must be a transport ship or warship"); + warshipState(): WarshipState { + if (this.data.warshipState === undefined) { + throw new Error("warshipState called on non-warship unit"); } - return this.data.retreating; + return this.data.warshipState; + } + updateWarshipState(_update: Partial): void { + throw new Error("updateWarshipState is not supported on UnitView"); + } + isInCombat(): boolean { + return this.data.warshipState?.isInCombat ?? false; + } + touch(): void { + throw new Error("touch is not supported on UnitView"); + } + transportShipState(): TransportShipState { + return this.data.transportShipState ?? { isRetreating: false, troops: 0 }; + } + updateTransportShipState( + _update: Pick, + ): void { + throw new Error("updateTransportShipState is not supported on UnitView"); } tile(): TileRef { return this.data.pos; diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index e958d266a..9444ed70b 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -6,9 +6,11 @@ import { Tick, TrainType, TrajectoryTile, + TransportShipState, Unit, UnitInfo, UnitType, + WarshipState, } from "./Game"; import { GameImpl } from "./GameImpl"; import { TileRef } from "./GameMap"; @@ -21,7 +23,8 @@ export class UnitImpl implements Unit { private _targetUnit: Unit | undefined; private _health: bigint; private _lastTile: TileRef; - private _retreating: boolean = false; + private _transportShipState: TransportShipState | undefined = undefined; + private _warshipState: WarshipState | undefined = undefined; private _targetedBySAM = false; private _reachedTarget = false; private _wasDestroyedByEnemy: boolean = false; @@ -33,7 +36,6 @@ export class UnitImpl implements Unit { // Number of missiles in cooldown, if empty all missiles are ready. private _missileTimerQueue: number[] = []; private _hasTrainStation: boolean = false; - private _patrolTile: TileRef | undefined; private _level: number = 1; private _targetable: boolean = true; private _loaded: boolean | undefined; @@ -61,8 +63,16 @@ export class UnitImpl implements Unit { "lastSetSafeFromPirates" in params ? (params.lastSetSafeFromPirates ?? 0) : 0; - this._patrolTile = - "patrolTile" in params ? (params.patrolTile ?? undefined) : undefined; + if (this._type === UnitType.TransportShip) { + this._transportShipState = { isRetreating: false, troops: 0 }; + } + if ("patrolTile" in params) { + this._warshipState = { + state: "patrolling", + patrolTile: params.patrolTile, + lastCombatTick: -100, + }; + } this._targetUnit = "targetUnit" in params ? (params.targetUnit ?? undefined) : undefined; this._loaded = @@ -92,14 +102,6 @@ export class UnitImpl implements Unit { return this._targetable; } - setPatrolTile(tile: TileRef): void { - this._patrolTile = tile; - } - - patrolTile(): TileRef | undefined { - return this._patrolTile; - } - isUnit(): this is Unit { return true; } @@ -128,7 +130,14 @@ export class UnitImpl implements Unit { lastOwnerID: this._lastOwner?.smallID(), isActive: this._active, reachedTarget: this._reachedTarget, - retreating: this._retreating, + warshipState: + this._warshipState !== undefined + ? { ...this.warshipState() } + : undefined, + transportShipState: + this._transportShipState !== undefined + ? this.transportShipState() + : undefined, pos: this._tile, markedForDeletion: this._deletionAt ?? false, targetable: this._targetable, @@ -232,6 +241,13 @@ export class UnitImpl implements Unit { return; } + if ( + attacker !== undefined && + delta < 0 && + this._warshipState !== undefined + ) { + this._warshipState.lastCombatTick = this.mg.ticks(); + } this._health = nextHealth; this.mg.addUpdate(this.toUpdate()); if (this._health === 0n) { @@ -335,22 +351,79 @@ export class UnitImpl implements Unit { return this._destroyer; } - retreating(): boolean { - return this._retreating; + warshipState(): WarshipState { + if (this._warshipState === undefined) { + throw new Error("warshipState called on non-warship unit"); + } + this._warshipState.isInCombat = this.isInCombat(); + return this._warshipState; } - setRetreating(retreating: boolean): void { - if (this._retreating !== retreating) { - this._retreating = retreating; + updateWarshipState(update: Partial): void { + if (this._warshipState === undefined) { + throw new Error("updateWarshipState called on non-warship unit"); + } + if (update.isInCombat) { + this.markInCombat(); + } + const merged = { ...this._warshipState, ...update }; + if ( + merged.state === this._warshipState.state && + merged.patrolTile === this._warshipState.patrolTile && + merged.retreatPort === this._warshipState.retreatPort + ) + return; + this._warshipState = { + state: merged.state, + patrolTile: merged.patrolTile, + retreatPort: merged.retreatPort, + lastCombatTick: this._warshipState.lastCombatTick, + }; + this.mg.addUpdate(this.toUpdate()); + } + + isInCombat(): boolean { + return this.mg.ticks() - this._warshipState!.lastCombatTick <= 3; + } + + private markInCombat(): void { + const wasInCombat = this.isInCombat(); + this._warshipState!.lastCombatTick = this.mg.ticks(); + if (!wasInCombat) { this.mg.addUpdate(this.toUpdate()); } } - orderBoatRetreat() { - if (this.type() !== UnitType.TransportShip) { - throw new Error("Cannot retreat " + this.type()); + transportShipState(): TransportShipState { + if (this._transportShipState === undefined) { + throw new Error("transportShipState called on non-transport-ship unit"); + } + return { + isRetreating: this._transportShipState.isRetreating, + troops: this._troops, + }; + } + + updateTransportShipState(update: Partial): void { + if (this._transportShipState === undefined) { + throw new Error( + "updateTransportShipState called on non-transport-ship unit", + ); + } + let changed = false; + if ( + update.isRetreating !== undefined && + this._transportShipState.isRetreating !== update.isRetreating + ) { + this._transportShipState = { + ...this._transportShipState, + isRetreating: update.isRetreating, + }; + changed = true; + } + if (changed) { + this.mg.addUpdate(this.toUpdate()); } - this.setRetreating(true); } isUnderConstruction(): boolean { diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index 5f741559f..8e1b143d9 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -139,7 +139,7 @@ describe("Attack", () => { expect(ship.troops()).toBe(boat_troops); expect(ship.isActive()).toBe(true); - ship.orderBoatRetreat(); + ship.updateTransportShipState({ isRetreating: true }); game.executeNextTick(); expect(ship.isActive()).toBe(false); diff --git a/tests/Disconnected.test.ts b/tests/Disconnected.test.ts index 104a4b6a4..fa6c36933 100644 --- a/tests/Disconnected.test.ts +++ b/tests/Disconnected.test.ts @@ -406,7 +406,7 @@ describe("Disconnected", () => { ); expect(expectedRetreatTile).not.toBe(false); - transportShip.orderBoatRetreat(); + transportShip.updateTransportShipState({ isRetreating: true }); executeTicks(game, 2); expect(transportShip.targetTile()).toBe(expectedRetreatTile); @@ -465,7 +465,7 @@ describe("Disconnected", () => { toInt(player1.troops()) + expectedTroopGrowth, ); - transportShip.orderBoatRetreat(); + transportShip.updateTransportShipState({ isRetreating: true }); executeTicks(game, 1); expect(transportShip.isActive()).toBe(false); diff --git a/tests/Warship.test.ts b/tests/Warship.test.ts index 43b178ea0..c88664f2e 100644 --- a/tests/Warship.test.ts +++ b/tests/Warship.test.ts @@ -174,7 +174,7 @@ describe("Warship", () => { executeTicks(game, 10); - expect(warship.patrolTile()).toBe(game.ref(coastX + 5, 15)); + expect(warship.warshipState().patrolTile).toBe(game.ref(coastX + 5, 15)); }); test("Warship does not not target trade ships outside of patrol range", async () => { @@ -283,7 +283,7 @@ describe("Warship", () => { [warship.id()], game.ref(coastX + 5, 15), ).init(game, 0); - expect(warship.patrolTile()).toBe(originalPatrolTile); + expect(warship.warshipState().patrolTile).toBe(originalPatrolTile); }); test("MoveWarshipExecution fails if warship is not active", async () => { @@ -301,7 +301,7 @@ describe("Warship", () => { [warship.id()], game.ref(coastX + 5, 15), ).init(game, 0); - expect(warship.patrolTile()).toBe(originalPatrolTile); + expect(warship.warshipState().patrolTile).toBe(originalPatrolTile); }); test("MoveWarshipExecution fails gracefully if warship not found", async () => { @@ -346,7 +346,7 @@ describe("Warship", () => { game.executeNextTick(); - expect(warship.retreating()).toBe(true); + expect(warship.warshipState().state).not.toBe("patrolling"); const distanceToPort = game.euclideanDistSquared( warship.tile(), homePort.tile(), @@ -432,7 +432,7 @@ describe("Warship", () => { exec1.isDocked() && !exec2.isDocked() && warship2DistanceToPort <= 25 && - warship2.retreating() + warship2.warshipState().state !== "patrolling" ) { break; } @@ -445,7 +445,7 @@ describe("Warship", () => { expect(exec1.isDocked()).toBe(true); expect(exec2.isDocked()).toBe(false); expect(warship2DistanceToPort).toBeLessThanOrEqual(25); - expect(warship2.retreating()).toBe(true); + expect(warship2.warshipState().state).not.toBe("patrolling"); }); test("Warship cancels docking if its retreat port is destroyed", async () => { @@ -481,7 +481,7 @@ describe("Warship", () => { game.executeNextTick(); expect(warshipExecution.isDocked()).toBe(false); - expect(warship.retreating()).toBe(false); + expect(warship.warshipState().state).toBe("patrolling"); }); test("Warship drops a stale target after patrol movement changes range", async () => { @@ -521,8 +521,9 @@ describe("Warship", () => { }); execution.tick(game.ticks()); - expect(warship.tile()).toBe(movedTile); + + execution.tick(game.ticks()); expect(warship.targetUnit()).toBeUndefined(); }); @@ -549,6 +550,306 @@ describe("Warship", () => { warship.modifyHealth(-300); game.executeNextTick(); - expect(warship.retreating()).toBe(false); + expect(warship.warshipState().state).toBe("patrolling"); + }); + + test("Low-health warship retreats AND fires at nearby enemy warship", async () => { + game.config().warshipPortHealingBonusPerLevel = () => 0; + game.config().warshipRetreatHealthThreshold = () => 600; + 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().warshipRetreatHealthThreshold = () => 600; + 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().warshipRetreatHealthThreshold = () => 600; + + 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().warshipRetreatHealthThreshold = () => 600; + + 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().warshipRetreatHealthThreshold = () => 900; + 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().warshipRetreatHealthThreshold = () => 600; + 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); }); }); diff --git a/tests/WarshipMultiSelection.test.ts b/tests/WarshipMultiSelection.test.ts index 0f5e9009e..f28855653 100644 --- a/tests/WarshipMultiSelection.test.ts +++ b/tests/WarshipMultiSelection.test.ts @@ -57,9 +57,9 @@ describe("Warship multi-selection (MoveWarshipExecution)", () => { executeTicks(game, 5); - expect(w1.patrolTile()).toBe(sharedTarget); - expect(w2.patrolTile()).toBe(sharedTarget); - expect(w3.patrolTile()).toBe(sharedTarget); + expect(w1.warshipState().patrolTile).toBe(sharedTarget); + expect(w2.warshipState().patrolTile).toBe(sharedTarget); + expect(w3.warshipState().patrolTile).toBe(sharedTarget); }); test("moving multiple warships to different targets works independently", () => { @@ -81,8 +81,8 @@ describe("Warship multi-selection (MoveWarshipExecution)", () => { executeTicks(game, 5); - expect(w1.patrolTile()).toBe(target1); - expect(w2.patrolTile()).toBe(target2); + expect(w1.warshipState().patrolTile).toBe(target1); + expect(w2.warshipState().patrolTile).toBe(target2); }); test("enemy cannot move player's warships via MoveWarshipExecution", () => { @@ -97,7 +97,7 @@ describe("Warship multi-selection (MoveWarshipExecution)", () => { 0, ); - expect(w1.patrolTile()).toBe(originalTile); + expect(w1.warshipState().patrolTile).toBe(originalTile); }); test("MoveWarshipExecution on destroyed warship does not throw", () => { @@ -138,7 +138,7 @@ describe("Warship multi-selection (MoveWarshipExecution)", () => { executeTicks(game, 5); - expect(w1.patrolTile()).toBe(target); - expect(w2.patrolTile()).toBe(p2tile); // unchanged — wrong owner + expect(w1.warshipState().patrolTile).toBe(target); + expect(w2.warshipState().patrolTile).toBe(p2tile); // unchanged — wrong owner }); }); diff --git a/tests/core/executions/TradeShipExecution.test.ts b/tests/core/executions/TradeShipExecution.test.ts index 9c91442b5..72e6834da 100644 --- a/tests/core/executions/TradeShipExecution.test.ts +++ b/tests/core/executions/TradeShipExecution.test.ts @@ -55,6 +55,7 @@ describe("TradeShipExecution", () => { } as any; piratePort = { + id: vi.fn(() => 201), tile: vi.fn(() => 56), owner: vi.fn(() => pirate), isActive: vi.fn(() => true), @@ -63,6 +64,7 @@ describe("TradeShipExecution", () => { } as any; piratePort2 = { + id: vi.fn(() => 202), tile: vi.fn(() => 75), owner: vi.fn(() => pirate), isActive: vi.fn(() => true), @@ -71,6 +73,7 @@ describe("TradeShipExecution", () => { } as any; srcPort = { + id: vi.fn(() => 101), tile: vi.fn(() => 10), owner: vi.fn(() => origOwner), isActive: vi.fn(() => true), @@ -79,6 +82,7 @@ describe("TradeShipExecution", () => { } as any; dstPort = { + id: vi.fn(() => 102), tile: vi.fn(() => 100), owner: vi.fn(() => dstOwner), isActive: vi.fn(() => true),