From f1f63ec9b485140a5e2221969d1f050f8827afd0 Mon Sep 17 00:00:00 2001
From: FloPinguin <25036848+FloPinguin@users.noreply.github.com>
Date: Thu, 16 Apr 2026 01:17:40 +0200
Subject: [PATCH] =?UTF-8?q?Partial=20Pathfinding=20Rebuild=20=F0=9F=92=A7?=
=?UTF-8?q?=20(#3689)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description:
Another big water nukes performance improvement.
Performance measurements (water-nuke-detonation on GWM):
I did a lot of testing with throwing water nukes and sending boats in
the area, paths are looking clean & correct!
## 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
---
src/core/game/WaterManager.ts | 12 +-
.../pathfinding/algorithms/AbstractGraph.ts | 115 ++++++++++++++++++
2 files changed, 126 insertions(+), 1 deletion(-)
diff --git a/src/core/game/WaterManager.ts b/src/core/game/WaterManager.ts
index 9508a40ba..4af979fc6 100644
--- a/src/core/game/WaterManager.ts
+++ b/src/core/game/WaterManager.ts
@@ -16,6 +16,7 @@ export class WaterManager {
private _waterGraphLastRebuildTick: number = 0;
private _pendingWaterTiles: Set = new Set();
+ private _dirtyMiniTiles: Set = new Set();
// Reusable stamp-based distance tracking for magnitude BFS (avoids allocation per nuke)
private _waterDistArr: Uint16Array | null = null;
@@ -76,8 +77,14 @@ export class WaterManager {
) {
this._waterGraphDirty = false;
this._waterGraphLastRebuildTick = currentTick;
- const graphBuilder = new AbstractGraphBuilder(this.miniMap);
+ const graphBuilder = new AbstractGraphBuilder(
+ this.miniMap,
+ AbstractGraphBuilder.CLUSTER_SIZE,
+ this._miniWaterGraph ?? undefined,
+ this._dirtyMiniTiles.size > 0 ? this._dirtyMiniTiles : undefined,
+ );
this._miniWaterGraph = graphBuilder.build();
+ this._dirtyMiniTiles.clear();
this._miniWaterHPA = new AStarWaterHierarchical(
this.miniMap,
this._miniWaterGraph,
@@ -424,6 +431,9 @@ export class WaterManager {
// ── 5. Mark water graph dirty (rebuilt lazily, throttled) ─────
if (convertedMiniTiles.size > 0) {
this._waterGraphDirty = true;
+ for (const mt of convertedMiniTiles) {
+ this._dirtyMiniTiles.add(mt);
+ }
}
// Drain changed set into output array
diff --git a/src/core/pathfinding/algorithms/AbstractGraph.ts b/src/core/pathfinding/algorithms/AbstractGraph.ts
index 3ab83b5ce..e1cd806c5 100644
--- a/src/core/pathfinding/algorithms/AbstractGraph.ts
+++ b/src/core/pathfinding/algorithms/AbstractGraph.ts
@@ -217,9 +217,15 @@ export class AbstractGraphBuilder {
private nextEdgeId = 0;
private edgeBetween = new Map>();
+ // Partial rebuild state
+ private cleanClusters: Set | null = null;
+ private oldEdgeCosts: Map> | null = null;
+
constructor(
private readonly map: GameMap,
private readonly clusterSize: number = AbstractGraphBuilder.CLUSTER_SIZE,
+ private readonly oldGraph?: AbstractGraph,
+ private readonly dirtyMiniTiles?: Set,
) {
this.width = map.width();
this.height = map.height();
@@ -241,6 +247,11 @@ export class AbstractGraphBuilder {
// Initialize water components
this.waterComponents.initialize();
+ // Compute partial rebuild info (which clusters can skip BFS)
+ if (this.oldGraph && this.dirtyMiniTiles && this.dirtyMiniTiles.size > 0) {
+ this.computePartialRebuildInfo();
+ }
+
// Pre-create all clusters
for (let cy = 0; cy < this.clustersY; cy++) {
for (let cx = 0; cx < this.clustersX; cx++) {
@@ -421,6 +432,14 @@ export class AbstractGraphBuilder {
}
private buildClusterConnections(cx: number, cy: number): void {
+ const clusterKey = cy * this.clustersX + cx;
+
+ // For clean clusters, copy edge costs from old graph instead of BFS
+ if (this.cleanClusters?.has(clusterKey)) {
+ this.buildClusterConnectionsFromCache(cx, cy);
+ return;
+ }
+
const cluster = this.graph.getCluster(cx, cy);
if (!cluster) return;
@@ -582,4 +601,100 @@ export class AbstractGraphBuilder {
return reachable;
}
+
+ /**
+ * Compute which clusters are "clean" (unaffected by water changes) and
+ * build a lookup of old edge costs by tile-pair for fast edge recreation.
+ */
+ private computePartialRebuildInfo(): void {
+ const dirtyMiniTiles = this.dirtyMiniTiles!;
+ const oldGraph = this.oldGraph!;
+
+ // Map dirty minimap tiles to their cluster indices
+ const primaryDirty = new Set();
+ for (const tile of dirtyMiniTiles) {
+ const x = this.map.x(tile);
+ const y = this.map.y(tile);
+ const cx = Math.floor(x / this.clusterSize);
+ const cy = Math.floor(y / this.clusterSize);
+ primaryDirty.add(cy * this.clustersX + cx);
+ }
+
+ // Expand by 1-ring neighbors (gateway nodes sit on cluster boundaries
+ // and belong to both adjacent clusters)
+ const expandedDirty = new Set();
+ for (const key of primaryDirty) {
+ const cy = Math.floor(key / this.clustersX);
+ const cx = key - cy * this.clustersX;
+ for (let dy = -1; dy <= 1; dy++) {
+ for (let dx = -1; dx <= 1; dx++) {
+ const nx = cx + dx;
+ const ny = cy + dy;
+ if (
+ nx >= 0 &&
+ nx < this.clustersX &&
+ ny >= 0 &&
+ ny < this.clustersY
+ ) {
+ expandedDirty.add(ny * this.clustersX + nx);
+ }
+ }
+ }
+ }
+
+ // Everything not in the expanded dirty set is clean
+ this.cleanClusters = new Set();
+ const totalClusters = this.clustersX * this.clustersY;
+ for (let k = 0; k < totalClusters; k++) {
+ if (!expandedDirty.has(k)) this.cleanClusters.add(k);
+ }
+
+ // Build old edge cost lookup: (minTile, maxTile) → cost
+ this.oldEdgeCosts = new Map();
+ for (const edge of oldGraph.getAllEdges()) {
+ const nodeA = oldGraph.getNode(edge.nodeA);
+ const nodeB = oldGraph.getNode(edge.nodeB);
+ if (!nodeA || !nodeB) continue;
+
+ const tileMin = Math.min(nodeA.tile, nodeB.tile);
+ const tileMax = Math.max(nodeA.tile, nodeB.tile);
+ let inner = this.oldEdgeCosts.get(tileMin);
+ if (!inner) {
+ inner = new Map();
+ this.oldEdgeCosts.set(tileMin, inner);
+ }
+ const existing = inner.get(tileMax);
+ if (existing === undefined || edge.cost < existing) {
+ inner.set(tileMax, edge.cost);
+ }
+ }
+ }
+
+ /**
+ * For clean clusters: recreate edges by looking up costs from the old graph
+ * instead of running expensive BFS. The gateway nodes are at the same positions
+ * and the intra-cluster water topology hasn't changed.
+ */
+ private buildClusterConnectionsFromCache(cx: number, cy: number): void {
+ const cluster = this.graph.getCluster(cx, cy);
+ if (!cluster) return;
+
+ const nodeIds = cluster.nodeIds;
+ const nodes = nodeIds.map((id) => this.graph.getNode(id)!);
+ const oldEdgeCosts = this.oldEdgeCosts!;
+
+ for (let i = 0; i < nodes.length; i++) {
+ for (let j = i + 1; j < nodes.length; j++) {
+ // Skip nodes in different water components
+ if (nodes[i].componentId !== nodes[j].componentId) continue;
+
+ const tileMin = Math.min(nodes[i].tile, nodes[j].tile);
+ const tileMax = Math.max(nodes[i].tile, nodes[j].tile);
+ const cost = oldEdgeCosts.get(tileMin)?.get(tileMax);
+ if (cost !== undefined) {
+ this.addOrUpdateEdge(nodes[i].id, nodes[j].id, cost, cx, cy);
+ }
+ }
+ }
+ }
}