Files
OpenFrontIO/src/core/execution/WarshipExecution.ts
T
FloPinguin 0801cad0b5 Fix NaN coordinates in Warship patrol logic 🚢 (#3697)
## Description:

This PR fixes the `Invalid coordinates: NaN,NaN` crash during Warship
patrol execution.

### Root cause

`WarshipExecution.randomTile` picks a patrol destination inside
`warshipPatrolRange / 2` of the current patrol tile. When a search fails
to find a valid tile, the range expands by 50% per retry (`100 → 150 →
225 → 337`) and becomes odd. Once odd, `warshipPatrolRange / 2` is a
float (e.g. `112.5`), which is handed straight to
`PseudoRandom.nextInt`:

```ts
Math.floor(this.rng() * (max - min)) + min;
```

With a float `min`, this returns `integer + float` - a float. Despite
its name, `nextInt` was silently returning a non-integer. From there:

- `x = mg.x(patrolTile) + floatOffset` → float
- `mg.isValidCoord(floatX, floatY)` → `true` (only bounds were checked)
- `mg.ref(floatX, floatY)` → `yToRef[floatY] + floatX` → `undefined +
float` → `NaN`
- `hasWaterComponent(NaN, …)` → `miniMap.ref(NaN, NaN)` → **throw**

### Why this only started crashing recently

The float‑leaking `nextInt` bug has been latent since at least the
pathfinding refactor (#2866, January), which introduced the
`hasWaterComponent` check. It was invisible because the guard directly
above it short‑circuited on `NaN`:

```ts
if (!this.mg.isOcean(tile) || (!allowShoreline && this.mg.isShoreline(tile))) continue;
```

For a `NaN` tile ref, `terrain[NaN]` is `undefined`, so:

- `isOcean(NaN)` → `Boolean(undefined & OCEAN_BIT)` → **`false`**
- `isLand(NaN)` → **`false`**
- `isWater(NaN)` → `!isLand(NaN)` → **`true`**

Before: `!isOcean(NaN)` was `true`, execution hit `continue`, and the
poisoned ref never reached `hasWaterComponent`. The "Trading in lakes"
PR (#3653) relaxed that single line to allow patrol on lakes:

```diff
- if (!this.mg.isOcean(tile) || ...) continue;
+ if (!this.mg.isWater(tile) || ...) continue;
```

Because `isWater(NaN)` is `true`, `!isWater(NaN)` is now `false` -
execution falls through to `hasWaterComponent(NaN, …)` and crashes.
#3653 didn't introduce the bug; it just happened to remove the
accidental NaN filter that was hiding it.

### Changes

- **`PseudoRandom.nextInt`** - root‑cause fix. Floors both `min` and
`max` so `nextInt` always returns an integer regardless of what callers
pass. Future callers can't re‑trip this trap.
- **`WarshipExecution.randomTile`** - replaced the unsafe
`this.warship.patrolTile()!` non‑null assertion with a proper
`undefined` guard that returns early.
- **`GameMap.isValidCoord`** - defense in depth: also requires
`Number.isInteger(x)` and `Number.isInteger(y)`. Non‑integer coords can
still be produced outside `nextInt` (trig, arithmetic); this makes
`ref()` fail loudly at the boundary instead of silently producing `NaN`
refs.

### Original stacktrace

Please paste the following in your bug report in Discord:
Game crashed!
game id: gGicMpDh
client id: wXE5SpT2
Error: Invalid coordinates: NaN,NaN
Message: Error: Invalid coordinates: NaN,NaN
at at.ref
(https://nightly.openfront.dev/assets/Worker.worker-DL_guV2P.js:31:64853)
at r_.hasWaterComponent
(https://nightly.openfront.dev/assets/Worker.worker-DL_guV2P.js:31:243326)
at l_.hasWaterComponent
(https://nightly.openfront.dev/assets/Worker.worker-DL_guV2P.js:31:260740)
at b1.randomTile
(https://nightly.openfront.dev/assets/Worker.worker-DL_guV2P.js:31:92634)
at b1.patrol
(https://nightly.openfront.dev/assets/Worker.worker-DL_guV2P.js:31:91728)
at b1.tick
(https://nightly.openfront.dev/assets/Worker.worker-DL_guV2P.js:31:89996)
at
https://nightly.openfront.dev/assets/Worker.worker-DL_guV2P.js:31:251463
    at Array.forEach (<anonymous>)
at l_.executeNextTick
(https://nightly.openfront.dev/assets/Worker.worker-DL_guV2P.js:31:251383)
at p_.executeNextTick
(https://nightly.openfront.dev/assets/Worker.worker-DL_guV2P.js:31:271256)

Discord:
https://discord.com/channels/1284581928254701718/1494336024740888667

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

FloPinguin
2026-04-17 17:34:12 -07:00

300 lines
8.4 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 { 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>();
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,
);
}
}
tick(ticks: number): void {
if (this.warship.health() <= 0) {
this.warship.delete();
return;
}
const hasPort = this.warship.owner().unitCount(UnitType.Port) > 0;
if (hasPort) {
this.warship.modifyHealth(1);
}
this.warship.setTargetUnit(this.findTargetUnit());
if (this.warship.targetUnit()?.type() === UnitType.TradeShip) {
this.huntDownTradeShip();
return;
}
this.patrol();
if (this.warship.targetUnit() !== undefined) {
this.shootTarget();
return;
}
}
private findTargetUnit(): Unit | undefined {
const mg = this.mg;
const config = mg.config();
const owner = this.warship.owner();
const hasPort = owner.unitCount(UnitType.Port) > 0;
const patrolTile = this.warship.patrolTile()!;
const patrolRangeSquared = config.warshipPatrolRange() ** 2;
const ships = mg.nearbyUnits(
this.warship.tile()!,
config.warshipTargettingRange(),
[UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip],
);
let bestUnit: Unit | undefined = undefined;
let bestTypePriority = 0;
let bestDistSquared = 0;
for (const { unit, distSquared } of ships) {
if (
unit.owner() === owner ||
unit === this.warship ||
!owner.canAttackPlayer(unit.owner(), true) ||
this.alreadySentShell.has(unit)
) {
continue;
}
const type = unit.type();
if (type === UnitType.TradeShip) {
if (
!hasPort ||
unit.isSafeFromPirates() ||
unit.targetUnit()?.owner() === owner || // trade ship is coming to my port
unit.targetUnit()?.owner().isFriendly(owner) // trade ship is coming to my ally
) {
continue;
}
if (
mg.euclideanDistSquared(patrolTile, unit.tile()) > patrolRangeSquared
) {
// Prevent warship from chasing trade ship that is too far away from
// the patrol tile to prevent warships from wandering around the map.
continue;
}
}
const typePriority =
type === UnitType.TransportShip ? 0 : type === UnitType.Warship ? 1 : 2;
if (bestUnit === undefined) {
bestUnit = unit;
bestTypePriority = typePriority;
bestDistSquared = distSquared;
continue;
}
// Match existing `sort()` semantics:
// - Lower priority is better (TransportShip < Warship < TradeShip).
// - For same type, smaller distance is better.
// - For exact ties, keep the first encountered (stable sort behavior).
if (
typePriority < bestTypePriority ||
(typePriority === bestTypePriority && distSquared < bestDistSquared)
) {
bestUnit = unit;
bestTypePriority = typePriority;
bestDistSquared = distSquared;
}
}
return bestUnit;
}
private shootTarget() {
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() {
for (let i = 0; i < 2; i++) {
// target is trade ship so capture it.
const result = this.pathfinder.next(
this.warship.tile(),
this.warship.targetUnit()!.tile(),
5,
);
switch (result.status) {
case PathStatus.COMPLETE:
this.warship.owner().captureUnit(this.warship.targetUnit()!);
this.warship.setTargetUnit(undefined);
this.warship.move(this.warship.tile());
return;
case PathStatus.NEXT:
this.warship.move(result.node);
break;
case PathStatus.NOT_FOUND: {
console.log(`path not found to target`);
break;
}
}
}
}
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`);
break;
}
}
}
isActive(): boolean {
return this.warship?.isActive();
}
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.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;
}
}