2661 PR 3/3 Warship Manual Override, Aggro Override, and Heal-at-Port Command (#3501)

Part of [#2661](https://github.com/openfrontio/OpenFrontIO/issues/2661)
(split into 3 PRs so they are not too large..)

## Description:

Part 3/3 of
[#2661](https://github.com/openfrontio/OpenFrontIO/issues/2661).

This PR adds the retreat control and override behavior for warships:

- Manual override: moving a warship manually cancels retreat and
suppresses auto-retreat for 5 seconds
- Aggro override: a retreating warship will aggro a nearby enemy
transport or warship before continuing retreat
- Heal-at-port command for sending a warship to a friendly port manually
- Friendly-port validation for HealAtPortExecution
- Regression tests for manual override, aggro override, and heal-at-port
behavior



## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

zixer._

---------

Co-authored-by: iamlewis <lewismmmm@gmail.com>
Co-authored-by: evanpelle <evanpelle@gmail.com>
This commit is contained in:
Zixer1
2026-04-30 15:54:28 -04:00
committed by GitHub
parent f1d0136a06
commit 742a544a69
20 changed files with 781 additions and 246 deletions
+8 -8
View File
@@ -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`<span class="ml-auto truncate text-aquarius"
>(${translateText("events_display.retreating")}...)</span
>`
: this.renderButton({
content: "\u274C",
onClick: () => this.emitBoatCancelIntent(boat.id()),
className: "ml-auto text-left shrink-0",
disabled: boat.retreating(),
})
: html`<span class="ml-auto truncate text-aquarius"
>(${translateText("events_display.retreating")}...)</span
>`}
disabled: boat.transportShipState().isRetreating,
})}
</div>
`,
);
+29 -3
View File
@@ -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);
+2 -1
View File
@@ -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;
}
+1 -1
View File
@@ -23,7 +23,7 @@ export class BoatRetreatExecution implements Execution {
return;
}
unit.orderBoatRetreat();
unit.updateTransportShipState({ isRetreating: true });
this.active = false;
}
+2 -2
View File
@@ -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()}`,
);
+3 -1
View File
@@ -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);
}
}
+3 -3
View File
@@ -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(),
@@ -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`);
+244 -155
View File
@@ -21,10 +21,10 @@ export class WarshipExecution implements Execution {
private pathfinder: WaterPathFinder;
private lastShellAttack = 0;
private alreadySentShell = new Set<Unit>();
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<UnitType.Warship> & 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;
}
// 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,13 +418,17 @@ 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 currentDistance = Infinity;
const currentRetreatPort = this.warship.warshipState().retreatPort;
if (currentRetreatPort !== undefined) {
currentDistance = this.mg.euclideanDistSquared(
this.warship.tile(),
currentRetreatPort,
);
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
result.distSquared <
currentDistance * this.mg.config().warshipPortSwitchThreshold()
) {
// Prevent warship from chasing trade ship that is too far away from
// the patrol tile to prevent warships from wandering around the map.
continue;
return result.tile;
}
return undefined;
}
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;
}
}
return bestUnit;
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;
}
@@ -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 });
}
}
}
+18 -7
View File
@@ -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<GameUpdateType, GameUpdate[]>;
@@ -617,9 +630,10 @@ export interface Unit {
// Health
hasHealth(): boolean;
retreating(): boolean;
setRetreating(retreating: boolean): void;
orderBoatRetreat(): void;
warshipState(): WarshipState;
updateWarshipState(update: Partial<WarshipState>): void;
transportShipState(): TransportShipState;
updateTransportShipState(update: Partial<TransportShipState>): 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;
+7
View File
@@ -97,6 +97,7 @@ export class GameImpl implements Game {
private motionPlanRecords: MotionPlanRecord[] = [];
private planDrivenUnitIds = new Set<number>();
private unitGrid: UnitGrid;
private _unitMap = new Map<number, Unit>();
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);
+4 -1
View File
@@ -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
+23 -7
View File
@@ -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<WarshipState>): 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<TransportShipState, "isRetreating">,
): void {
throw new Error("updateTransportShipState is not supported on UnitView");
}
tile(): TileRef {
return this.data.pos;
+95 -22
View File
@@ -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<WarshipState>): 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<TransportShipState>): 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 {
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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);
+310 -9
View File
@@ -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);
});
});
+8 -8
View File
@@ -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
});
});
@@ -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),