Files
OpenFrontIO/src/core/execution/WarshipExecution.ts
T
TKTK123456andGitHub 6a6c142388 Fixed pirating not being disabled when a warship's owner has no port in that body of water (#4458)
> **Before opening a PR:** discuss new features on
[Discord](https://discord.gg/K9zernJB5z) first, and file bugs or small
improvements as
[issues](https://github.com/openfrontio/OpenFrontIO/issues/new/choose).
You must be assigned to an `approved` issue — unsolicited PRs will be
auto-closed.

**Add approved & assigned issue number here:**

Resolves #4291 

## Description:

Fixes pirating not being disabled when a warship's owner has no port in
that body of water

## 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

## Please put your Discord username so you can be contacted if a bug or
regression is found:

tktk1234567
2026-07-10 16:31:21 +00:00

806 lines
24 KiB
TypeScript

import {
Execution,
Game,
isUnit,
OwnerComp,
Unit,
UnitParams,
UnitType,
} from "../game/Game";
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 {
private random: PseudoRandom;
private warship: Unit;
private mg: Game;
private pathfinder: WaterPathFinder;
private lastShellAttack = 0;
private alreadySentShell = new Set<Unit>();
private lastManualMoveTickRetreatDisabled = 0;
private lastObservedPatrolTile: TileRef | undefined;
private activeHealingRemainder = 0;
private lastEmittedCombat = false;
constructor(
private input: (UnitParams<UnitType.Warship> & OwnerComp) | Unit,
) {}
init(mg: Game, ticks: number): void {
this.mg = mg;
this.pathfinder = new WaterPathFinder(mg);
this.random = new PseudoRandom(mg.ticks());
if (isUnit(this.input)) {
this.warship = this.input;
} else {
const spawn = this.input.owner.canBuild(
UnitType.Warship,
this.input.patrolTile,
);
if (spawn === false) {
console.warn(
`Failed to spawn warship for ${this.input.owner.name()} at ${this.input.patrolTile}`,
);
return;
}
this.warship = this.input.owner.buildUnit(
UnitType.Warship,
spawn,
this.input,
);
}
this.lastObservedPatrolTile = this.warship.warshipState().patrolTile;
}
tick(ticks: number): void {
if (this.warship.health() <= 0) {
this.warship.delete();
return;
}
const isInCombat = this.warship.warshipState().isInCombat ?? false;
if (this.lastEmittedCombat && !isInCombat) {
this.warship.touch();
}
this.lastEmittedCombat = isInCombat;
const healthBeforeHealing = this.warship.health();
this.healWarship();
this.handleManualPatrolOverride();
if (this.warship.warshipState().state === "docked") {
if (this.currentRetreatPort() === undefined) {
this.cancelRepairRetreat();
}
if (this.isFullyHealed()) {
this.cancelRepairRetreat();
}
if (this.warship.warshipState().state === "docked") {
return;
}
}
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());
// Priority 1: Shoot transport ship if in range
if (this.warship.targetUnit()?.type() === UnitType.TransportShip) {
this.shootTarget();
this.patrol();
return;
}
// Priority 2: Fight enemy warship if in range
if (this.warship.targetUnit()?.type() === UnitType.Warship) {
this.shootTarget();
this.patrol();
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 {
const owner = this.warship.owner();
// A doomed side (below the Doomsday Clock bar) cannot repair its navy, so the
// decay in DoomsdayClockExecution actually sinks warships instead of being
// out-healed at a port. Inert when the mode is off: the mark is never set.
if (owner.inDoomsdayClock()) return;
const passiveHealing = this.mg.config().warshipPassiveHealing();
const passiveHealingRange = this.mg.config().warshipPassiveHealingRange();
const passiveHealingRangeSquared =
passiveHealingRange * passiveHealingRange;
const warshipTile = this.warship.tile();
let isNearPort = false;
for (const port of owner.units(UnitType.Port)) {
const distSquared = this.mg.euclideanDistSquared(
warshipTile,
port.tile(),
);
if (distSquared <= passiveHealingRangeSquared) {
isNearPort = true;
break;
}
}
if (isNearPort) {
this.warship.modifyHealth(passiveHealing);
}
if (this.warship.warshipState().state === "docked") {
this.applyActiveDockedHealing();
}
}
private isFullyHealed(): boolean {
if (!this.warship.hasHealth()) {
return true;
}
return this.warship.health() >= this.warship.maxHealth();
}
private shouldStartRepairRetreat(
healthBeforeHealing = this.warship.health(),
): boolean {
if (this.warship.warshipState().state !== "patrolling") {
return false;
}
const manualMoveRetreatDisabledDuration = 50;
if (
this.mg.ticks() - this.lastManualMoveTickRetreatDisabled <
manualMoveRetreatDisabledDuration
) {
return false;
}
// Percentage of (veterancy-adjusted) max health, so a tougher veteran ship
// retreats at the same relative health as a fresh one. Integer math.
const retreatThreshold = Math.floor(
(this.warship.maxHealth() *
this.mg.config().warshipRetreatHealthPercent()) /
100,
);
if (healthBeforeHealing >= retreatThreshold) {
return false;
}
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 findRetreatAggroTarget(): Unit | undefined {
return this.findBestTarget([UnitType.TransportShip, UnitType.Warship]);
}
private findTargetUnit(): Unit | undefined {
return this.findBestTarget(
[UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip],
true,
);
}
/**
* Shared target selection: searches nearby units of given types,
* filters common exclusions (self, friendly, docked, already-shelled),
* picks best by type priority (lower index = higher priority) then distance.
*
* When `includeTradeShips` is true, applies trade-ship-specific filters
* (safe from pirates, patrol range, water component, allied destination).
*/
private findBestTarget(
types: UnitType[],
includeTradeShips = false,
): Unit | undefined {
const mg = this.mg;
const config = mg.config();
const owner = this.warship.owner();
const ships = mg.nearbyUnits(
this.warship.tile(),
config.warshipTargettingRange(),
types,
);
// Trade-ship-specific state, lazily computed.
let hasReachablePort: boolean | undefined;
let patrolTile: number | undefined;
let patrolRangeSquared: number | undefined;
let warshipComponent: number | null | undefined = undefined;
let bestUnit: Unit | undefined = undefined;
let bestTypePriority = 0;
let bestDistSquared = 0;
for (const { unit, distSquared } of ships) {
if (
unit === this.warship ||
unit.owner() === owner ||
!owner.canAttackPlayer(unit.owner(), true) ||
this.alreadySentShell.has(unit) ||
(unit.type() === UnitType.Warship &&
unit.warshipState().state === "docked")
) {
continue;
}
const type = unit.type();
if (includeTradeShips && type === UnitType.TradeShip) {
if (warshipComponent === undefined) {
warshipComponent = mg.getWaterComponent(this.warship.tile());
hasReachablePort =
warshipComponent !== null &&
owner
.units(UnitType.Port)
.some(
(port) =>
port.isActive() &&
!port.isMarkedForDeletion() &&
!port.isUnderConstruction() &&
mg.hasWaterComponent(port.tile(), warshipComponent!),
);
patrolTile = this.warship.warshipState().patrolTile;
patrolRangeSquared = config.warshipPatrolRange() ** 2;
}
if (
!hasReachablePort ||
patrolTile === undefined ||
unit.isSafeFromPirates() ||
unit.targetUnit()?.owner() === owner ||
unit.targetUnit()?.owner().isFriendly(owner)
) {
continue;
}
if (
mg.euclideanDistSquared(patrolTile, unit.tile()) > patrolRangeSquared!
) {
continue;
}
}
const typePriority =
type === UnitType.TransportShip ? 0 : type === UnitType.Warship ? 1 : 2;
if (
bestUnit === undefined ||
typePriority < bestTypePriority ||
(typePriority === bestTypePriority && distSquared < bestDistSquared)
) {
bestUnit = unit;
bestTypePriority = typePriority;
bestDistSquared = distSquared;
}
}
return bestUnit;
}
private startRepairRetreat(): void {
const portTile = this.findNearestPort();
if (portTile === undefined) {
return;
}
this.warship.updateWarshipState({
retreatPort: portTile,
state: "retreating",
});
this.activeHealingRemainder = 0;
this.warship.setTargetUnit(undefined);
}
private cancelRepairRetreat(clearTargetTile = true): void {
this.activeHealingRemainder = 0;
this.warship.updateWarshipState({
state: "patrolling",
retreatPort: undefined,
});
if (clearTargetTile) {
this.warship.setTargetTile(undefined);
}
}
private handleManualPatrolOverride(): void {
const patrolTile = this.warship.warshipState().patrolTile;
if (
this.lastObservedPatrolTile !== undefined &&
patrolTile !== this.lastObservedPatrolTile
) {
this.lastManualMoveTickRetreatDisabled = this.mg.ticks();
if (this.warship.warshipState().state !== "patrolling") {
this.cancelRepairRetreat(false);
}
}
this.lastObservedPatrolTile = patrolTile;
}
private handleRepairRetreat(): boolean {
if (this.warship.warshipState().state === "patrolling") {
return false;
}
const retreatAggroTarget = this.findRetreatAggroTarget();
if (retreatAggroTarget) {
this.warship.setTargetUnit(retreatAggroTarget);
this.shootTarget();
// Fall through — continue retreating toward port even while firing back.
}
if (!this.refreshRetreatPortTile()) {
this.cancelRepairRetreat();
return false;
}
// Only clear the target when there's no active aggro target this tick.
if (!retreatAggroTarget) {
this.warship.setTargetUnit(undefined);
}
const retreatPortTile = this.warship.warshipState().retreatPort;
if (retreatPortTile === undefined) {
return false;
}
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.warship.updateWarshipState({
state: "docked",
});
return true;
} else {
// Port is full - wait near port, but leave if already fully healed
if (this.isFullyHealed()) {
this.cancelRepairRetreat();
return false;
}
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: {
const newPort = this.findNearestAvailablePortTile();
this.warship.updateWarshipState({
retreatPort: newPort,
});
if (newPort === undefined) {
this.cancelRepairRetreat();
}
break;
}
}
return true;
}
private refreshRetreatPortTile(): boolean {
const ports = this.warship.owner().units(UnitType.Port);
if (ports.length === 0) {
return false;
}
const currentRetreatPort = this.warship.warshipState().retreatPort;
// Check if current retreat port still exists
const currentPortExists =
currentRetreatPort !== undefined &&
ports.some((port) => port.tile() === currentRetreatPort);
if (!currentPortExists) {
const newPort = this.findNearestAvailablePortTile();
this.warship.updateWarshipState({
retreatPort: newPort,
});
return newPort !== undefined;
}
// Check if current port is now full of healing (not counting arrived warships)
const currentPort = ports.find((p) => p.tile() === currentRetreatPort);
if (currentPort && this.isPortFullOfHealing(currentPort)) {
// Current port is at healing capacity, look for alternatives
const alternativePort = this.findNearestAvailablePort();
if (alternativePort) {
this.warship.updateWarshipState({
retreatPort: alternativePort,
});
}
return this.warship.warshipState().retreatPort !== undefined;
}
// Check if a significantly closer port is available
const closerPort = this.findBetterPortTile();
if (closerPort && closerPort !== currentRetreatPort) {
this.warship.updateWarshipState({
retreatPort: 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.warshipState().state === "patrolling") 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);
if (!dockedShips.some((ship) => ship === this.warship)) {
return;
}
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 {
const retreatPort = this.warship.warshipState().retreatPort;
if (retreatPort === undefined) {
return undefined;
}
return this.warship
.owner()
.units(UnitType.Port)
.find((port) => port.tile() === retreatPort);
}
private nearestAvailablePortTile(
excludeShip?: Unit,
): { tile: TileRef; distSquared: number } | undefined {
const ports = this.warship.owner().units(UnitType.Port);
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 !== undefined
? { tile: bestTile, distSquared: bestDistance }
: undefined;
}
private findNearestAvailablePort(): TileRef | undefined {
return this.nearestAvailablePortTile()?.tile;
}
private findBetterPortTile(): TileRef | undefined {
const result = this.nearestAvailablePortTile();
if (!result) return undefined;
let currentDistance = Infinity;
const currentRetreatPort = this.warship.warshipState().retreatPort;
if (currentRetreatPort !== undefined) {
currentDistance = this.mg.euclideanDistSquared(
this.warship.tile(),
currentRetreatPort,
);
}
if (
result.distSquared <
currentDistance * this.mg.config().warshipPortSwitchThreshold()
) {
return result.tile;
}
return undefined;
}
private findNearestAvailablePortTile(): TileRef | undefined {
return this.nearestAvailablePortTile(this.warship)?.tile;
}
private shootTarget() {
this.warship.updateWarshipState({ isInCombat: true });
const shellAttackRate = this.mg.config().warshipShellAttackRate();
if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) {
if (this.warship.targetUnit()?.type() !== UnitType.TransportShip) {
// Warships don't need to reload when attacking transport ships.
this.lastShellAttack = this.mg.ticks();
}
this.mg.addExecution(
new ShellExecution(
this.warship.tile(),
this.warship.owner(),
this.warship,
this.warship.targetUnit()!,
),
);
if (!this.warship.targetUnit()!.hasHealth()) {
// Don't send multiple shells to target that can be oneshotted
this.alreadySentShell.add(this.warship.targetUnit()!);
this.warship.setTargetUnit(undefined);
return;
}
}
}
private huntDownTradeShip() {
this.warship.updateWarshipState({ isInCombat: true });
for (let i = 0; i < 2; i++) {
const target = this.warship.targetUnit()!;
const targetTile = target.tile();
const dist = this.mg.manhattanDist(this.warship.tile(), targetTile);
if (dist <= 5) {
this.warship.owner().captureUnit(target);
this.warship.recordTradeCapture();
this.warship.setTargetUnit(undefined);
this.warship.touch();
return;
}
// When close, the minimap (2x scale) produces diagonal upscaled paths that
// make it hard to converge. Use direct greedy movement instead.
if (dist <= 20) {
const nextTile = this.bestNeighborToward(targetTile);
if (nextTile !== undefined) {
this.warship.move(nextTile);
continue;
}
}
const result = this.pathfinder.next(this.warship.tile(), targetTile, 5);
switch (result.status) {
case PathStatus.COMPLETE:
this.warship.owner().captureUnit(target);
this.warship.recordTradeCapture();
this.warship.setTargetUnit(undefined);
this.warship.touch();
return;
case PathStatus.NEXT:
this.warship.move(result.node);
break;
case PathStatus.NOT_FOUND:
console.log(`path not found to target`);
break;
}
}
}
private bestNeighborToward(targetTile: TileRef): TileRef | undefined {
const warshipTile = this.warship.tile();
let best: TileRef | undefined;
let bestDist = this.mg.manhattanDist(warshipTile, targetTile);
this.mg.forEachNeighbor(warshipTile, (neighbor) => {
if (!this.mg.isWater(neighbor)) return;
const d = this.mg.manhattanDist(neighbor, targetTile);
if (d < bestDist) {
bestDist = d;
best = neighbor;
}
});
return best;
}
private patrol() {
if (this.warship.targetTile() === undefined) {
this.warship.setTargetTile(this.randomTile());
if (this.warship.targetTile() === undefined) {
return;
}
}
const result = this.pathfinder.next(
this.warship.tile(),
this.warship.targetTile()!,
);
switch (result.status) {
case PathStatus.COMPLETE:
this.warship.setTargetTile(undefined);
this.warship.move(result.node);
break;
case PathStatus.NEXT:
this.warship.move(result.node);
break;
case PathStatus.NOT_FOUND: {
console.log(`path not found to target`);
this.warship.setTargetTile(undefined);
break;
}
}
}
isActive(): boolean {
return this.warship?.isActive();
}
isDocked(): boolean {
return (this.warship?.warshipState().state ?? "patrolling") === "docked";
}
activeDuringSpawnPhase(): boolean {
return false;
}
randomTile(allowShoreline: boolean = false): TileRef | undefined {
let warshipPatrolRange = this.mg.config().warshipPatrolRange();
const maxAttemptBeforeExpand: number = 500;
let attempts: number = 0;
let expandCount: number = 0;
// Get warship's water component for connectivity check
const warshipComponent = this.mg.getWaterComponent(this.warship.tile());
const patrolTile = this.warship.warshipState().patrolTile;
if (patrolTile === undefined) {
return undefined;
}
while (expandCount < 3) {
const x =
this.mg.x(patrolTile) +
this.random.nextInt(-warshipPatrolRange / 2, warshipPatrolRange / 2);
const y =
this.mg.y(patrolTile) +
this.random.nextInt(-warshipPatrolRange / 2, warshipPatrolRange / 2);
if (!this.mg.isValidCoord(x, y)) {
continue;
}
const tile = this.mg.ref(x, y);
if (
!this.mg.isWater(tile) ||
(!allowShoreline && this.mg.isShoreline(tile))
) {
attempts++;
if (attempts === maxAttemptBeforeExpand) {
expandCount++;
attempts = 0;
warshipPatrolRange =
warshipPatrolRange + Math.floor(warshipPatrolRange / 2);
}
continue;
}
// Check water component connectivity
if (
warshipComponent !== null &&
!this.mg.hasWaterComponent(tile, warshipComponent)
) {
attempts++;
if (attempts === maxAttemptBeforeExpand) {
expandCount++;
attempts = 0;
warshipPatrolRange =
warshipPatrolRange + Math.floor(warshipPatrolRange / 2);
}
continue;
}
return tile;
}
console.warn(
`Failed to find random tile for warship for ${this.warship.owner().name()}`,
);
if (!allowShoreline) {
// If we failed to find a tile on the ocean, try again but allow shoreline
return this.randomTile(true);
}
return undefined;
}
}