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 21:42:13 +00:00
committed by GitHub
parent 4338d70420
commit c0febacb8e
5 changed files with 451 additions and 7 deletions
+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;
}