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),