Big Water-Nukes Performance Improvements 💧 (#3668)

## Description:

### 1. Water Magnitude Calculation Optimization (WaterManager.ts)
* Boxed BFS Approach: Refactored the water magnitude recomputation to
use "Dirty" and "Seed" boxes. Instead of a global update, the system now
only recalculates magnitudes within a specific radius of the affected
area, significantly reducing CPU load after water-nuke-explosions
* Shoreline Bit Optimization: Narrowed the scope for updating shoreline
bits to a 2-ring neighborhood around converted tiles, avoiding
unnecessary checks across the entire map.

Performance test on the world map:
- AtomBomb (r=30): 24ms (was 344ms with global BFS), 2,993 changed tiles
(was 630k)
- Massive (r=200): 178ms (was 378ms), 130k changed tiles (was 654k)

### 2. Pathfinding Rebuild Staggering (PathFinder.ts,
TradeShipExecution.ts, TransportShipExecution.ts)
* Distributed Rebuilds: Introduced a staggering mechanism in
WaterPathFinder. Ship pathfinders now wait a randomized/distributed
number of ticks (0 - 5 seconds) before rebuilding after a water graph
change.
* CPU Spike Mitigation: By spreading out these expensive A* rebuilds
over time, we prevent lag when hundreds of ships attempt to re-path
simultaneously
* Like Mole said it: "Pretty realistic I;d say the capitan needs a
second to realize the big nuke on the left opened a new path"

From a performance test on the big new Luna map:
Graph rebuild: 256.4ms
Pathfinder-Rebuild of 329 ships (Including other Executions): 1564.4ms
(No longer noticeable, spread over 5s)

###  3. Performance Refinements
* Simplified deep ocean magnitude logic within the optimized BFS flow.
   * Improved memory efficiency by utilizing clipped BFS wavefronts.

## 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
This commit is contained in:
FloPinguin
2026-04-15 01:17:15 +02:00
committed by GitHub
parent e42c99bae0
commit 35a64fa0d9
4 changed files with 122 additions and 71 deletions
+5 -1
View File
@@ -22,6 +22,8 @@ export class TradeShipExecution implements Execution {
private motionPlanId = 1;
private motionPlanDst: TileRef | null = null;
private static _staggerCounter = 0;
constructor(
private origOwner: Player,
private srcPort: Unit,
@@ -30,7 +32,9 @@ export class TradeShipExecution implements Execution {
init(mg: Game, ticks: number): void {
this.mg = mg;
this.pathFinder = new WaterPathFinder(mg);
const stagger =
TradeShipExecution._staggerCounter++ % WaterPathFinder.STAGGER_SPREAD;
this.pathFinder = new WaterPathFinder(mg, stagger);
}
tick(ticks: number): void {
+5 -1
View File
@@ -29,6 +29,8 @@ export class TransportShipExecution implements Execution {
private target: Player | TerraNullius;
private pathFinder: WaterPathFinder;
private static _staggerCounter = 0;
private dst: TileRef | null;
private src: TileRef | null;
private retreatDst: TileRef | false | null = null;
@@ -60,7 +62,9 @@ export class TransportShipExecution implements Execution {
this.lastMove = ticks;
this.mg = mg;
this.target = mg.owner(this.ref);
this.pathFinder = new WaterPathFinder(mg);
const stagger =
TransportShipExecution._staggerCounter++ % WaterPathFinder.STAGGER_SPREAD;
this.pathFinder = new WaterPathFinder(mg, stagger);
if (
this.attacker.unitCount(UnitType.TransportShip) >=
+78 -64
View File
@@ -245,92 +245,114 @@ export class WaterManager {
const distArr = this._waterDistArr;
const magQueue: TileRef[] = [];
const h = map.height();
// Seed candidates: converted tiles + their immediate water neighbors
const seedCandidates = new Set<TileRef>(converted);
// Magnitude BFS: recompute ceil(manhattan_dist_to_nearest_coast / 2)
// for tiles affected by the nuke.
//
// Dirty box (±MAX_MAG_DIST from crater bounds): the region where
// magnitudes may have changed. Only tiles here get updated.
//
// Seed box (±2*MAX_MAG_DIST from crater bounds): coastlines here are
// seeded for BFS. This ensures that every coastline that could be
// nearest to a dirty-box tile is included (a dirty-box tile is at most
// MAX_MAG_DIST from the crater, and the nearest coast is at most
// MAX_MAG_DIST from the tile, so the coast is at most 2*MAX_MAG_DIST
// from the crater).
//
// The BFS runs WITHOUT convergence inside the seed box so that
// wavefronts from distant coastlines correctly reach the dirty box.
// BFS is clipped at the seed box boundary for performance.
const MAX_MAG_DIST = 62; // magnitude 31 ≈ 62 tile hops from coast
let cMinX = w,
cMaxX = 0,
cMinY = h,
cMaxY = 0;
for (const tile of converted) {
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
if (map.isWater(nb[i]) && !converted.has(nb[i])) {
seedCandidates.add(nb[i]);
}
}
const tx = tile % w;
const ty = (tile - tx) / w;
if (tx < cMinX) cMinX = tx;
if (tx > cMaxX) cMaxX = tx;
if (ty < cMinY) cMinY = ty;
if (ty > cMaxY) cMaxY = ty;
}
// Seed: water tiles adjacent to remaining land get distance 0
for (const tile of seedCandidates) {
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
if (map.isLand(nb[i])) {
if (stampArr[tile] !== stamp) {
// Dirty box: tiles whose magnitude may need updating.
const dMinX = Math.max(0, cMinX - MAX_MAG_DIST);
const dMaxX = Math.min(w - 1, cMaxX + MAX_MAG_DIST);
const dMinY = Math.max(0, cMinY - MAX_MAG_DIST);
const dMaxY = Math.min(h - 1, cMaxY + MAX_MAG_DIST);
// Seed box: coastlines here are seeded; BFS is clipped here.
const sMinX = Math.max(0, cMinX - MAX_MAG_DIST * 2);
const sMaxX = Math.min(w - 1, cMaxX + MAX_MAG_DIST * 2);
const sMinY = Math.max(0, cMinY - MAX_MAG_DIST * 2);
const sMaxY = Math.min(h - 1, cMaxY + MAX_MAG_DIST * 2);
// Seed from coastline water tiles inside the seed box.
for (let by = sMinY; by <= sMaxY; by++) {
const rowStart = by * w;
for (let bx = sMinX; bx <= sMaxX; bx++) {
const tile = (rowStart + bx) as TileRef;
if (!map.isWater(tile) || stampArr[tile] === stamp) continue;
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
if (map.isLand(nb[i])) {
stampArr[tile] = stamp;
distArr[tile] = 0;
if (map.magnitude(tile) !== 0) {
map.setMagnitude(tile, 0);
changed.add(tile);
}
magQueue.push(tile);
break;
}
break;
}
}
}
// BFS outward through water, stopping at convergence.
// BFS outward through water, clipped to seed box.
// No convergence — every reachable tile inside the seed box is visited
// to ensure correct shortest distances reach the dirty box.
// Only DIRTY BOX tiles get their magnitude updated.
let magHead = 0;
while (magHead < magQueue.length) {
const tile = magQueue[magHead++];
const dist = distArr[tile];
const nextDist = dist + 1;
const nextMag = Math.min(Math.ceil(nextDist / 2), 31);
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
const n = nb[i];
if (!map.isWater(n) || stampArr[n] === stamp) continue;
const oldMag = map.magnitude(n);
if (oldMag === nextMag && !seedCandidates.has(n)) continue;
// Clip to seed box
const nx = n % w;
const ny = (n - nx) / w;
if (nx < sMinX || nx > sMaxX || ny < sMinY || ny > sMaxY) continue;
stampArr[n] = stamp;
distArr[n] = nextDist;
magQueue.push(n);
if (oldMag !== nextMag) {
map.setMagnitude(n, nextMag);
changed.add(n);
}
}
}
// Phase 2: unreached seed candidates (fully destroyed island)
const MAX_DEEP_DIST = 30;
const DEEP_OCEAN_MAGNITUDE = 20;
const deepQueue: TileRef[] = [];
for (const tile of seedCandidates) {
if (stampArr[tile] !== stamp && map.isWater(tile)) {
stampArr[tile] = stamp;
distArr[tile] = 0;
if (map.magnitude(tile) !== DEEP_OCEAN_MAGNITUDE) {
map.setMagnitude(tile, DEEP_OCEAN_MAGNITUDE);
// Update magnitudes only for dirty-box tiles.
for (let dy = dMinY; dy <= dMaxY; dy++) {
const rowStart = dy * w;
for (let dx = dMinX; dx <= dMaxX; dx++) {
const tile = (rowStart + dx) as TileRef;
if (!map.isWater(tile)) continue;
const oldMag = map.magnitude(tile);
let newMag: number;
if (stampArr[tile] === stamp) {
// Reached by BFS — compute magnitude from distance
newMag = Math.min(Math.ceil(distArr[tile] / 2), 31);
} else {
// Unreached: nearest coast is >MAX_MAG_DIST away → magnitude 31
newMag = 31;
}
if (oldMag !== newMag) {
map.setMagnitude(tile, newMag);
changed.add(tile);
}
deepQueue.push(tile);
}
}
let deepHead = 0;
while (deepHead < deepQueue.length) {
const tile = deepQueue[deepHead++];
const dist = distArr[tile];
if (dist >= MAX_DEEP_DIST) continue;
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
const n = nb[i];
if (!map.isWater(n) || stampArr[n] === stamp) continue;
const oldMag = map.magnitude(n);
if (oldMag >= DEEP_OCEAN_MAGNITUDE) continue;
stampArr[n] = stamp;
distArr[n] = dist + 1;
map.setMagnitude(n, DEEP_OCEAN_MAGNITUDE);
changed.add(n);
deepQueue.push(n);
}
}
// ── 3. Fix shoreline bits ──────────────────────────────────────
// Only converted tiles changed terrain type (land→water), so only
// they and their 2-ring neighborhood can have shoreline bit changes.
const tilesToCheck = new Set<TileRef>();
for (const tile of converted) {
tilesToCheck.add(tile);
@@ -343,14 +365,6 @@ export class WaterManager {
}
}
}
for (let i = 0; i < magQueue.length; i++) {
const tile = magQueue[i];
tilesToCheck.add(tile);
const end = pushNeighbors(tile, nb, 0);
for (let j = 0; j < end; j++) {
tilesToCheck.add(nb[j]);
}
}
for (const tile of tilesToCheck) {
const tileIsLand = map.isLand(tile);
let hasOpposite = false;
+34 -5
View File
@@ -98,11 +98,28 @@ export class WaterPathFinder implements SteppingPathFinder<TileRef> {
private _waterGraphVersion: number;
private _rebuilt = false;
constructor(private game: Game) {
// Stagger support: spread pathfinder rebuilds over multiple ticks so all
// ships don't re-run A* simultaneously after a water-nuke.
private _staggerCountdown: number;
private _pendingVersion: number = -1;
/**
* @param stagger - How many ticks to wait before rebuilding when the water
* graph changes. 0 = immediate (default). Pass a value spread across
* [0, STAGGER_SPREAD) to distribute rebuilds over time.
*/
constructor(
private game: Game,
private _stagger: number = 0,
) {
this.inner = PathFinding.Water(game);
this._waterGraphVersion = game.waterGraphVersion();
this._staggerCountdown = 0;
}
/** Spread to use when auto-staggering ship pathfinders */
static readonly STAGGER_SPREAD = 50;
/** True if the pathfinder was rebuilt since the last call to `rebuilt`. Resets on read. */
get rebuilt(): boolean {
this.ensureFresh();
@@ -113,11 +130,23 @@ export class WaterPathFinder implements SteppingPathFinder<TileRef> {
private ensureFresh(): void {
const v = this.game.waterGraphVersion();
if (v !== this._waterGraphVersion) {
this._waterGraphVersion = v;
this.inner = PathFinding.Water(this.game);
this._rebuilt = true;
if (v === this._waterGraphVersion) return;
// New graph version detected — start or continue the stagger countdown.
if (this._pendingVersion !== v) {
this._pendingVersion = v;
this._staggerCountdown = this._stagger;
}
if (this._staggerCountdown > 0) {
this._staggerCountdown--;
return; // Keep using old pathfinder for now
}
// Countdown complete — rebuild.
this._waterGraphVersion = v;
this.inner = PathFinding.Water(this.game);
this._rebuilt = true;
}
next(from: TileRef, to: TileRef, dist?: number): PathResult<TileRef> {