diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index 0f3df5f7b..b1abdaaf3 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -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 { diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index f80001bc7..a6817a918 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -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) >= diff --git a/src/core/game/WaterManager.ts b/src/core/game/WaterManager.ts index 69009d5be..9508a40ba 100644 --- a/src/core/game/WaterManager.ts +++ b/src/core/game/WaterManager.ts @@ -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(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(); 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; diff --git a/src/core/pathfinding/PathFinder.ts b/src/core/pathfinding/PathFinder.ts index 81a76113d..b40c914bb 100644 --- a/src/core/pathfinding/PathFinder.ts +++ b/src/core/pathfinding/PathFinder.ts @@ -98,11 +98,28 @@ export class WaterPathFinder implements SteppingPathFinder { 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 { 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 {