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:
<img width="630" height="488" alt="image"
src="https://github.com/user-attachments/assets/56d3e6d5-08af-453d-afe5-ee21dd6f3414"
/>
Ship healing:
<img width="483" height="311" alt="image"
src="https://github.com/user-attachments/assets/aeaf2239-bb81-444f-84ef-62dbcb48fddf"
/>
Back to being deployed:
<img width="585" height="358" alt="image"
src="https://github.com/user-attachments/assets/875828a2-8a24-4593-ac76-26426bb81057"
/>

## 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._
This commit is contained in:
Zixer1
2026-04-24 10:26:14 -04:00
committed by GitHub
parent 66bbbc664b
commit 37079e6a05
8 changed files with 250 additions and 16 deletions
+8 -2
View File
@@ -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) {
+1
View File
@@ -153,6 +153,7 @@ export interface Config {
warshipPatrolRange(): number;
warshipShellAttackRate(): number;
warshipTargettingRange(): number;
warshipRetreatHealthThreshold(): number;
defensePostShellAttackRate(): number;
defensePostTargettingRange(): number;
// 0-1
+4
View File
@@ -969,6 +969,10 @@ export class DefaultConfig implements Config {
return 20;
}
warshipRetreatHealthThreshold(): number {
return 750;
}
defensePostShellAttackRate(): number {
return 100;
}
+151 -6
View File
@@ -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<Unit>();
private retreatPortTile: TileRef | undefined;
private retreatingForRepair = false;
constructor(
private input: (UnitParams<UnitType.Warship> & 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 {
+1
View File
@@ -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;
+5 -2
View File
@@ -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;
}
+11 -6
View File
@@ -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;
}
+69
View File
@@ -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);
});
});