From 37079e6a059931bc5a5ff581c1b773006e460c38 Mon Sep 17 00:00:00 2001
From: Zixer1 <99333209+Zixer1@users.noreply.github.com>
Date: Fri, 24 Apr 2026 10:26:14 -0400
Subject: [PATCH] 2661 PR 1/3 Warship Retreat Core, Blue UI Signal, and
Transport-First Target Priority (#3498)
Part of #2661 (split into 3 PRs so they are not too large..)
## Description:
Part 1/3 of #2661.
This PR adds warship retreat basics, a blue retreating UI state, and
updates target priority.
Added:
- Retreat state handling
- Blue visual for retreating warships
- Target priority: transport > warship > trade
- Tests for retreat and target priority
Example video:
https://youtu.be/2hE2qeOeY48
Ship retreating:
Ship healing:
Back to being deployed:
## 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._
---
src/client/graphics/layers/UnitLayer.ts | 10 +-
src/core/configuration/Config.ts | 1 +
src/core/configuration/DefaultConfig.ts | 4 +
src/core/execution/WarshipExecution.ts | 157 +++++++++++++++++++++++-
src/core/game/Game.ts | 1 +
src/core/game/GameView.ts | 7 +-
src/core/game/UnitImpl.ts | 17 ++-
tests/Warship.test.ts | 69 +++++++++++
8 files changed, 250 insertions(+), 16 deletions(-)
diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts
index 84b54eb36..9a4f4ec1f 100644
--- a/src/client/graphics/layers/UnitLayer.ts
+++ b/src/client/graphics/layers/UnitLayer.ts
@@ -456,11 +456,17 @@ export class UnitLayer implements Layer {
}
private handleWarShipEvent(unit: UnitView) {
+ if (unit.retreating()) {
+ this.drawSprite(unit, colord("rgb(0,180,255)"));
+ return;
+ }
+
if (unit.targetUnitId()) {
this.drawSprite(unit, colord("rgb(200,0,0)"));
- } else {
- this.drawSprite(unit);
+ return;
}
+
+ this.drawSprite(unit);
}
private handleShellEvent(unit: UnitView) {
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index 92915e371..82425c8e8 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -153,6 +153,7 @@ export interface Config {
warshipPatrolRange(): number;
warshipShellAttackRate(): number;
warshipTargettingRange(): number;
+ warshipRetreatHealthThreshold(): number;
defensePostShellAttackRate(): number;
defensePostTargettingRange(): number;
// 0-1
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index f1e1924d4..44ed435ac 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -969,6 +969,10 @@ export class DefaultConfig implements Config {
return 20;
}
+ warshipRetreatHealthThreshold(): number {
+ return 750;
+ }
+
defensePostShellAttackRate(): number {
return 100;
}
diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts
index 5693152fc..d707abf55 100644
--- a/src/core/execution/WarshipExecution.ts
+++ b/src/core/execution/WarshipExecution.ts
@@ -11,6 +11,7 @@ import { TileRef } from "../game/GameMap";
import { WaterPathFinder } from "../pathfinding/PathFinder";
import { PathStatus } from "../pathfinding/types";
import { PseudoRandom } from "../PseudoRandom";
+import { findMinimumBy } from "../Util";
import { ShellExecution } from "./ShellExecution";
export class WarshipExecution implements Execution {
@@ -20,6 +21,8 @@ export class WarshipExecution implements Execution {
private pathfinder: WaterPathFinder;
private lastShellAttack = 0;
private alreadySentShell = new Set();
+ private retreatPortTile: TileRef | undefined;
+ private retreatingForRepair = false;
constructor(
private input: (UnitParams & OwnerComp) | Unit,
@@ -55,24 +58,166 @@ export class WarshipExecution implements Execution {
this.warship.delete();
return;
}
+ const healthBeforeHealing = this.warship.health();
- const hasPort = this.warship.owner().unitCount(UnitType.Port) > 0;
- if (hasPort) {
- this.warship.modifyHealth(1);
+ this.healWarship();
+
+ if (this.handleRepairRetreat()) {
+ return;
+ }
+
+ // Priority 1: Check if need to heal before doing anything else
+ if (this.shouldStartRepairRetreat(healthBeforeHealing)) {
+ this.startRepairRetreat();
+ if (this.handleRepairRetreat()) {
+ return;
+ }
}
this.warship.setTargetUnit(this.findTargetUnit());
+
+ // Always patrol for movement
+ this.patrol();
+
+ // Priority 1: Shoot transport ship if in range
+ if (this.warship.targetUnit()?.type() === UnitType.TransportShip) {
+ this.shootTarget();
+ return;
+ }
+
+ // Priority 2: Fight enemy warship if in range
+ if (this.warship.targetUnit()?.type() === UnitType.Warship) {
+ this.shootTarget();
+ return;
+ }
+
+ // Priority 3: Hunt trade ship only if not healing and no enemy warship
if (this.warship.targetUnit()?.type() === UnitType.TradeShip) {
this.huntDownTradeShip();
return;
}
+ }
- this.patrol();
+ private healWarship(): void {
+ if (this.warship.owner().unitCount(UnitType.Port) > 0) {
+ this.warship.modifyHealth(1);
+ }
+ }
- if (this.warship.targetUnit() !== undefined) {
- this.shootTarget();
+ private isFullyHealed(): boolean {
+ const maxHealth = this.mg.config().unitInfo(UnitType.Warship).maxHealth;
+ if (typeof maxHealth !== "number") {
+ return true;
+ }
+ return this.warship.health() >= maxHealth;
+ }
+
+ private shouldStartRepairRetreat(
+ healthBeforeHealing = this.warship.health(),
+ ): boolean {
+ if (this.retreatingForRepair) {
+ return false;
+ }
+ if (
+ healthBeforeHealing >= this.mg.config().warshipRetreatHealthThreshold()
+ ) {
+ return false;
+ }
+ // Only retreat if there's a friendly port
+ const ports = this.warship.owner().units(UnitType.Port);
+ return ports.length > 0;
+ }
+
+ private findNearestPort(): TileRef | undefined {
+ const ports = this.warship.owner().units(UnitType.Port);
+ if (ports.length === 0) {
+ return undefined;
+ }
+
+ const warshipTile = this.warship.tile();
+ const warshipComponent = this.mg.getWaterComponent(warshipTile);
+ if (warshipComponent === null) {
+ throw new Error(`Warship at tile ${warshipTile} has no water component`);
+ }
+
+ const nearest = findMinimumBy(
+ ports,
+ (port) => this.mg.euclideanDistSquared(warshipTile, port.tile()),
+ (port) => {
+ const portComponent = this.mg.getWaterComponent(port.tile());
+ if (portComponent === null) {
+ throw new Error(`Port at tile ${port.tile()} has no water component`);
+ }
+ return portComponent === warshipComponent;
+ },
+ );
+
+ return nearest?.tile();
+ }
+
+ private startRepairRetreat(): void {
+ const portTile = this.findNearestPort();
+ if (portTile === undefined) {
return;
}
+ this.retreatingForRepair = true;
+ this.retreatPortTile = portTile;
+ this.warship.setRetreating(true);
+ this.warship.setTargetUnit(undefined);
+ }
+
+ private cancelRepairRetreat(clearTargetTile = true): void {
+ this.retreatingForRepair = false;
+ this.warship.setRetreating(false);
+ this.retreatPortTile = undefined;
+ if (clearTargetTile) {
+ this.warship.setTargetTile(undefined);
+ }
+ }
+
+ private handleRepairRetreat(): boolean {
+ if (!this.retreatingForRepair) {
+ return false;
+ }
+
+ if (this.isFullyHealed()) {
+ this.cancelRepairRetreat();
+ return false;
+ }
+
+ this.warship.setTargetUnit(undefined);
+
+ const retreatPortTile = this.retreatPortTile;
+ if (retreatPortTile === undefined) {
+ return false;
+ }
+
+ if (this.warship.tile() === retreatPortTile) {
+ this.warship.setTargetTile(undefined);
+ return true;
+ }
+
+ this.warship.setTargetTile(retreatPortTile);
+ const result = this.pathfinder.next(this.warship.tile(), retreatPortTile);
+ switch (result.status) {
+ case PathStatus.COMPLETE:
+ this.warship.move(result.node);
+ if (result.node === retreatPortTile) {
+ this.warship.setTargetTile(undefined);
+ }
+ break;
+ case PathStatus.NEXT:
+ this.warship.move(result.node);
+ break;
+ case PathStatus.NOT_FOUND:
+ this.retreatPortTile = this.findNearestPort();
+ if (this.retreatPortTile === undefined) {
+ this.cancelRepairRetreat();
+ }
+ break;
+ }
+
+ return true;
}
private findTargetUnit(): Unit | undefined {
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index f3baab468..59d61f9f2 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -614,6 +614,7 @@ export interface Unit {
// Health
hasHealth(): boolean;
retreating(): boolean;
+ setRetreating(retreating: boolean): void;
orderBoatRetreat(): void;
health(): number;
modifyHealth(delta: number, attacker?: Player): void;
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index 780e25e9c..de03e5777 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -116,8 +116,11 @@ export class UnitView {
return this.data.troops;
}
retreating(): boolean {
- if (this.type() !== UnitType.TransportShip) {
- throw Error("Must be a transport ship");
+ if (
+ this.type() !== UnitType.TransportShip &&
+ this.type() !== UnitType.Warship
+ ) {
+ throw Error("Must be a transport ship or warship");
}
return this.data.retreating;
}
diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts
index 161e4aa7f..81b6caa69 100644
--- a/src/core/game/UnitImpl.ts
+++ b/src/core/game/UnitImpl.ts
@@ -226,6 +226,7 @@ export class UnitImpl implements Unit {
0n,
toInt(this.info().maxHealth ?? 1),
);
+ this.mg.addUpdate(this.toUpdate());
if (this._health === 0n) {
this.delete(true, attacker);
}
@@ -331,16 +332,20 @@ export class UnitImpl implements Unit {
return this._retreating;
}
- orderBoatRetreat() {
- if (this.type() !== UnitType.TransportShip) {
- throw new Error(`Cannot retreat ${this.type()}`);
- }
- if (!this._retreating) {
- this._retreating = true;
+ setRetreating(retreating: boolean): void {
+ if (this._retreating !== retreating) {
+ this._retreating = retreating;
this.mg.addUpdate(this.toUpdate());
}
}
+ orderBoatRetreat() {
+ if (this.type() !== UnitType.TransportShip) {
+ throw new Error("Cannot retreat " + this.type());
+ }
+ this.setRetreating(true);
+ }
+
isUnderConstruction(): boolean {
return this._underConstruction;
}
diff --git a/tests/Warship.test.ts b/tests/Warship.test.ts
index 4ea53f9fb..925ca298a 100644
--- a/tests/Warship.test.ts
+++ b/tests/Warship.test.ts
@@ -205,6 +205,37 @@ describe("Warship", () => {
expect(tradeShip.owner().id()).toBe(player2.id());
});
+ test("Warship prioritizes transport ships over warships", async () => {
+ game.config().warshipShellAttackRate = () => Number.MAX_SAFE_INTEGER;
+
+ const warship = player1.buildUnit(
+ UnitType.Warship,
+ game.ref(coastX + 1, 10),
+ {
+ patrolTile: game.ref(coastX + 1, 10),
+ },
+ );
+ player2.buildUnit(UnitType.Warship, game.ref(coastX + 2, 10), {
+ patrolTile: game.ref(coastX + 2, 10),
+ });
+ player2.buildUnit(UnitType.TransportShip, game.ref(coastX + 1, 11), {
+ targetTile: game.ref(coastX + 1, 11),
+ });
+
+ game.addExecution(new WarshipExecution(warship));
+
+ let selectedType: UnitType | undefined = undefined;
+ for (let i = 0; i < 5; i++) {
+ game.executeNextTick();
+ selectedType = warship.targetUnit()?.type();
+ if (selectedType === UnitType.TransportShip) {
+ break;
+ }
+ }
+
+ expect(selectedType).toBe(UnitType.TransportShip);
+ });
+
test("Warship does not target trade ships in different water components", async () => {
// build port so warship can target trade ships
player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
@@ -284,4 +315,42 @@ describe("Warship", () => {
expect(exec.isActive()).toBe(false);
});
+
+ test("Warship retreats when pre-heal health is below threshold", async () => {
+ const maxHealth = game.config().unitInfo(UnitType.Warship).maxHealth;
+ if (typeof maxHealth !== "number") {
+ expect(typeof maxHealth).toBe("number");
+ throw new Error("unreachable");
+ }
+ if (maxHealth <= 599) {
+ expect(maxHealth).toBeGreaterThan(599);
+ throw new Error("unreachable");
+ }
+
+ game.config().warshipRetreatHealthThreshold = () => 600;
+
+ const homePort = player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
+ const warship = player1.buildUnit(
+ UnitType.Warship,
+ game.ref(coastX + 1, 11),
+ {
+ patrolTile: game.ref(coastX + 1, 11),
+ },
+ );
+ game.addExecution(new WarshipExecution(warship));
+
+ game.executeNextTick();
+ warship.modifyHealth(-(maxHealth - 599));
+
+ game.executeNextTick();
+
+ expect(warship.retreating()).toBe(true);
+ const distanceToPort = game.euclideanDistSquared(
+ warship.tile(),
+ homePort.tile(),
+ );
+ expect(
+ distanceToPort <= 25 || warship.targetTile() === homePort.tile(),
+ ).toBe(true);
+ });
});