mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:20:45 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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) >=
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user