mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-24 13:52:45 +00:00
0801cad0b5
## 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
300 lines
8.4 KiB
TypeScript
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;
|
|
}
|
|
}
|