2661 PR 2/3 Warship Port Healing, Docking Capacity, and Waiting Behavior (#3499)

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

## Description:

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

This PR adds port-based healing and docking behavior:
- Passive healing near friendly ports
- Active docked healing pool scaled by port level and shared across
docked ships
- Docking radius and capacity-by-port-level behavior
- Waiting behavior near full ports until a slot opens
- Auto-undock once fully healed

For the active healing, it works like `ActiveHeal = (PortLevel * 5) /
DockedShipsAtThatPort`
Ex:
1 ship at level 1 port -> +5 HP/tick
1 ship at level 2 port → +10 HP/tick
2 ships at level 3 port → +7.5 HP/tick each

Includes regression tests covering healing math and docking/waiting
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._
This commit is contained in:
Zixer1
2026-04-26 17:42:13 -04:00
committed by GitHub
parent 4338d70420
commit c0febacb8e
5 changed files with 451 additions and 7 deletions
+5
View File
@@ -153,7 +153,12 @@ export interface Config {
warshipPatrolRange(): number;
warshipShellAttackRate(): number;
warshipTargettingRange(): number;
warshipDockingRange(): number;
warshipPortHealingBonusPerLevel(): number;
warshipRetreatHealthThreshold(): number;
warshipPassiveHealing(): number;
warshipPassiveHealingRange(): number;
warshipPortSwitchThreshold(): number;
defensePostShellAttackRate(): number;
defensePostTargettingRange(): number;
// 0-1
+20
View File
@@ -969,10 +969,30 @@ export class DefaultConfig implements Config {
return 20;
}
warshipDockingRange(): number {
return 5;
}
warshipPortHealingBonusPerLevel(): number {
return 5;
}
warshipRetreatHealthThreshold(): number {
return 750;
}
warshipPassiveHealing(): number {
return 1;
}
warshipPassiveHealingRange(): number {
return 150;
}
warshipPortSwitchThreshold(): number {
return 0.75;
}
defensePostShellAttackRate(): number {
return 100;
}
+220 -6
View File
@@ -23,6 +23,8 @@ export class WarshipExecution implements Execution {
private alreadySentShell = new Set<Unit>();
private retreatPortTile: TileRef | undefined;
private retreatingForRepair = false;
private docked = false;
private activeHealingRemainder = 0;
constructor(
private input: (UnitParams<UnitType.Warship> & OwnerComp) | Unit,
@@ -62,6 +64,20 @@ export class WarshipExecution implements Execution {
this.healWarship();
if (this.docked) {
if (this.currentRetreatPort() === undefined) {
this.docked = false;
this.cancelRepairRetreat();
}
if (this.isFullyHealed()) {
this.docked = false;
this.cancelRepairRetreat();
}
if (this.docked) {
return;
}
}
if (this.handleRepairRetreat()) {
return;
}
@@ -79,6 +95,9 @@ export class WarshipExecution implements Execution {
// 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();
@@ -99,14 +118,28 @@ export class WarshipExecution implements Execution {
}
private healWarship(): void {
if (this.warship.owner().unitCount(UnitType.Port) > 0) {
this.warship.modifyHealth(1);
const owner = this.warship.owner();
const passiveHealing = this.mg.config().warshipPassiveHealing();
const passiveHealingRange = this.mg.config().warshipPassiveHealingRange();
const warshipTile = this.warship.tile();
const isNearPort = this.mg
.nearbyUnits(warshipTile, passiveHealingRange, [UnitType.Port])
.some(({ unit }) => unit.owner() === owner);
if (isNearPort) {
this.warship.modifyHealth(passiveHealing);
}
if (this.docked) {
this.applyActiveDockedHealing();
}
}
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;
@@ -162,12 +195,15 @@ export class WarshipExecution implements Execution {
}
this.retreatingForRepair = true;
this.retreatPortTile = portTile;
this.docked = false;
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;
if (clearTargetTile) {
@@ -185,6 +221,11 @@ export class WarshipExecution implements Execution {
return false;
}
if (!this.refreshRetreatPortTile()) {
this.cancelRepairRetreat();
return false;
}
this.warship.setTargetUnit(undefined);
const retreatPortTile = this.retreatPortTile;
@@ -192,9 +233,28 @@ export class WarshipExecution implements Execution {
return false;
}
if (this.warship.tile() === retreatPortTile) {
this.warship.setTargetTile(undefined);
return true;
const dockingRadius = this.mg.config().warshipDockingRange();
const dockingRadiusSq = dockingRadius * dockingRadius;
const distToPort = this.mg.euclideanDistSquared(
this.warship.tile(),
retreatPortTile,
);
if (distToPort <= dockingRadiusSq) {
// Check if the port has capacity available (excluding this warship from capacity check)
const port = this.warship
.owner()
.units(UnitType.Port)
.find((p) => p.tile() === retreatPortTile);
if (port && !this.isPortFullOfHealing(port, this.warship)) {
// Port has capacity - dock here
this.warship.setTargetTile(undefined);
this.docked = true;
return true;
} else {
// Port is full - don't cancel retreat, keep waiting near port
return true;
}
}
this.warship.setTargetTile(retreatPortTile);
@@ -210,7 +270,7 @@ export class WarshipExecution implements Execution {
this.warship.move(result.node);
break;
case PathStatus.NOT_FOUND:
this.retreatPortTile = this.findNearestPort();
this.retreatPortTile = this.findNearestAvailablePortTile(this.warship);
if (this.retreatPortTile === undefined) {
this.cancelRepairRetreat();
}
@@ -220,6 +280,156 @@ export class WarshipExecution implements Execution {
return true;
}
private refreshRetreatPortTile(): boolean {
const ports = this.warship.owner().units(UnitType.Port);
if (ports.length === 0) {
return false;
}
// Check if current retreat port still exists
const currentPortExists =
this.retreatPortTile !== undefined &&
ports.some((port) => port.tile() === this.retreatPortTile);
if (!currentPortExists) {
this.retreatPortTile = this.findNearestAvailablePortTile(this.warship);
return this.retreatPortTile !== undefined;
}
// Check if current port is now full of healing (not counting arrived warships)
const currentPort = ports.find((p) => p.tile() === this.retreatPortTile);
if (currentPort && this.isPortFullOfHealing(currentPort)) {
// Current port is at healing capacity, look for alternatives
const alternativePort = this.findNearestAvailablePortTile();
if (alternativePort) {
this.retreatPortTile = alternativePort;
}
return this.retreatPortTile !== undefined;
}
// Check if a significantly closer port is available
const closerPort = this.findBetterPortTile();
if (closerPort && closerPort !== this.retreatPortTile) {
this.retreatPortTile = closerPort;
return true;
}
return true;
}
private isPortFullOfHealing(port: Unit, excludeShip?: Unit): boolean {
const maxShipsHealing = port.level();
return this.dockedShipsAtPort(port, excludeShip).length >= maxShipsHealing;
}
private dockedShipsAtPort(port: Unit, excludeShip?: Unit): Unit[] {
const dockingRadius = this.mg.config().warshipDockingRange();
const owner = this.warship.owner();
return this.mg
.nearbyUnits(port.tile(), dockingRadius, [UnitType.Warship])
.filter(({ unit: ship }) => {
if (excludeShip && ship === excludeShip) return false;
if (ship.owner() !== owner) return false;
if (!ship.retreating()) return false;
if (ship.targetTile() !== undefined) return false;
return true;
})
.map(({ unit }) => unit);
}
private applyActiveDockedHealing(): void {
const dockedPort = this.currentRetreatPort();
if (!dockedPort) {
return;
}
const dockedShips = this.dockedShipsAtPort(dockedPort);
const healingPool =
dockedPort.level() * this.mg.config().warshipPortHealingBonusPerLevel();
if (healingPool <= 0 || dockedShips.length === 0) {
return;
}
// Preserve fractional split healing over time with a per-ship remainder.
const activeHealing = healingPool / dockedShips.length;
this.activeHealingRemainder += activeHealing;
const integerHealing = Math.floor(this.activeHealingRemainder);
if (integerHealing <= 0) {
return;
}
this.activeHealingRemainder -= integerHealing;
this.warship.modifyHealth(integerHealing);
}
private currentRetreatPort(): Unit | undefined {
if (this.retreatPortTile === undefined) {
return undefined;
}
return this.warship
.owner()
.units(UnitType.Port)
.find((port) => port.tile() === this.retreatPortTile);
}
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(
excludeShip?: Unit,
): 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`);
}
let bestTile: TileRef | undefined = undefined;
let bestDistance = Infinity;
for (const port of ports) {
if (this.isPortFullOfHealing(port, excludeShip)) {
continue;
}
const portTile = port.tile();
if (!this.mg.hasWaterComponent(portTile, warshipComponent)) {
continue;
}
const distance = this.mg.euclideanDistSquared(warshipTile, portTile);
if (distance < bestDistance) {
bestDistance = distance;
bestTile = portTile;
}
}
return bestTile;
}
private findTargetUnit(): Unit | undefined {
const mg = this.mg;
const config = mg.config();
@@ -390,6 +600,10 @@ export class WarshipExecution implements Execution {
return this.warship?.isActive();
}
isDocked(): boolean {
return this.docked;
}
activeDuringSpawnPhase(): boolean {
return false;
}
+8 -1
View File
@@ -221,11 +221,18 @@ export class UnitImpl implements Unit {
}
modifyHealth(delta: number, attacker?: Player): void {
this._health = withinInt(
const previousHealth = this._health;
const nextHealth = withinInt(
this._health + toInt(delta),
0n,
toInt(this.info().maxHealth ?? 1),
);
if (nextHealth === previousHealth) {
return;
}
this._health = nextHealth;
this.mg.addUpdate(this.toUpdate());
if (this._health === 0n) {
this.delete(true, attacker);
+198
View File
@@ -8,6 +8,7 @@ import {
UnitType,
} from "../src/core/game/Game";
import { TileRef } from "../src/core/game/GameMap";
import { PathStatus } from "../src/core/pathfinding/types";
import { setup } from "./util/Setup";
import { executeTicks } from "./util/utils";
@@ -327,6 +328,7 @@ describe("Warship", () => {
throw new Error("unreachable");
}
game.config().warshipPortHealingBonusPerLevel = () => 0;
game.config().warshipRetreatHealthThreshold = () => 600;
const homePort = player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
@@ -353,4 +355,200 @@ describe("Warship", () => {
distanceToPort <= 25 || warship.targetTile() === homePort.tile(),
).toBe(true);
});
test("Warship gets active healing when docked at a friendly port", async () => {
const maxHealth = game.config().unitInfo(UnitType.Warship).maxHealth;
if (typeof maxHealth !== "number") {
expect(typeof maxHealth).toBe("number");
throw new Error("unreachable");
}
game.config().warshipPassiveHealing = () => 0;
game.config().warshipPortHealingBonusPerLevel = () => 6;
game.config().warshipDockingRange = () => 5;
game.config().warshipRetreatHealthThreshold = () => 900;
const portTile = game.ref(coastX, 10);
player1.buildUnit(UnitType.Port, portTile, {});
const warship = player1.buildUnit(
UnitType.Warship,
game.ref(coastX + 1, 11),
{
patrolTile: game.ref(coastX + 1, 11),
},
);
const warshipExecution = new WarshipExecution(warship);
game.addExecution(warshipExecution);
game.executeNextTick();
warship.modifyHealth(-300);
for (let i = 0; i < 60; i++) {
game.executeNextTick();
if (warshipExecution.isDocked()) {
break;
}
}
expect(warshipExecution.isDocked()).toBe(true);
const before = warship.health();
game.executeNextTick();
expect(warship.health()).toBe(before + 6);
});
test("Warship waits at port when capacity is full", async () => {
game.config().warshipPassiveHealing = () => 0;
game.config().warshipDockingRange = () => 5;
game.config().warshipRetreatHealthThreshold = () => 900;
const portTile = game.ref(coastX, 10);
const warship1Tile = game.ref(coastX + 1, 11);
const warship2Tile = game.ref(coastX + 1, 12);
player1.buildUnit(UnitType.Port, portTile, {});
const warship1 = player1.buildUnit(UnitType.Warship, warship1Tile, {
patrolTile: warship1Tile,
});
const warship2 = player1.buildUnit(UnitType.Warship, warship2Tile, {
patrolTile: warship2Tile,
});
const exec1 = new WarshipExecution(warship1);
const exec2 = new WarshipExecution(warship2);
game.addExecution(exec1);
game.addExecution(exec2);
game.executeNextTick();
warship1.modifyHealth(-300);
warship2.modifyHealth(-300);
for (let i = 0; i < 80; i++) {
game.executeNextTick();
const warship2DistanceToPort = game.euclideanDistSquared(
warship2.tile(),
portTile,
);
if (
exec1.isDocked() &&
!exec2.isDocked() &&
warship2DistanceToPort <= 25 &&
warship2.retreating()
) {
break;
}
}
const warship2DistanceToPort = game.euclideanDistSquared(
warship2.tile(),
portTile,
);
expect(exec1.isDocked()).toBe(true);
expect(exec2.isDocked()).toBe(false);
expect(warship2DistanceToPort).toBeLessThanOrEqual(25);
expect(warship2.retreating()).toBe(true);
});
test("Warship cancels docking if its retreat port is destroyed", async () => {
game.config().warshipPassiveHealing = () => 0;
game.config().warshipPortHealingBonusPerLevel = () => 0;
game.config().warshipDockingRange = () => 5;
game.config().warshipRetreatHealthThreshold = () => 900;
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),
},
);
const warshipExecution = new WarshipExecution(warship);
game.addExecution(warshipExecution);
game.executeNextTick();
warship.modifyHealth(-300);
for (let i = 0; i < 60; i++) {
game.executeNextTick();
if (warshipExecution.isDocked()) {
break;
}
}
expect(warshipExecution.isDocked()).toBe(true);
homePort.delete();
game.executeNextTick();
expect(warshipExecution.isDocked()).toBe(false);
expect(warship.retreating()).toBe(false);
});
test("Warship drops a stale target after patrol movement changes range", async () => {
game.config().warshipTargettingRange = () => 1;
game.config().warshipShellAttackRate = () => Number.MAX_SAFE_INTEGER;
const startTile = game.ref(coastX + 1, 10);
const movedTile = game
.map()
.neighbors(startTile)
.find((tile) => game.isOcean(tile));
expect(movedTile).toBeDefined();
const warship = player1.buildUnit(UnitType.Warship, startTile, {
patrolTile: startTile,
});
warship.setTargetTile(movedTile!);
const transport = player2.buildUnit(UnitType.TransportShip, movedTile!, {
targetTile: movedTile!,
});
const execution = new WarshipExecution(warship);
const executionInternals = execution as unknown as {
findTargetUnit: () => typeof transport | undefined;
pathfinder: {
next: () => { status: PathStatus; node: number };
};
};
execution.init(game, game.ticks());
vi.spyOn(executionInternals, "findTargetUnit")
.mockReturnValueOnce(transport)
.mockReturnValueOnce(undefined);
vi.spyOn(executionInternals.pathfinder, "next").mockReturnValue({
status: PathStatus.NEXT,
node: movedTile!,
});
execution.tick(game.ticks());
expect(warship.tile()).toBe(movedTile);
expect(warship.targetUnit()).toBeUndefined();
});
test("Warship cancels retreat if no friendly port is reachable by water", async () => {
game.config().warshipRetreatHealthThreshold = () => 900;
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));
const warshipTile = warship.tile();
vi.spyOn(game, "getWaterComponent").mockImplementation((tile) =>
tile === warshipTile ? 1 : 2,
);
vi.spyOn(game, "hasWaterComponent").mockReturnValue(false);
game.executeNextTick();
warship.modifyHealth(-300);
game.executeNextTick();
expect(warship.retreating()).toBe(false);
});
});