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: image Ship healing: image Back to being deployed: image ## 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); + }); });