perf: Optimize cluster calculation with DFS and zero-allocation patterns

Replace BFS with DFS and eliminate GC pressure in calculateClusters() hot path:

- Switch from O(N) queue.shift() to O(1) stack.pop() operations
- Replace Set.has()/Set.add() with Uint8Array bitfield
- Add reusable buffer management to avoid repeated allocations
- Implement callback-based neighbor iteration to eliminate array allocations
- Add forEachNeighborWithDiag() method to Game interface and GameImpl
- Remove now unused GameImpl import from PlayerExecution
This commit is contained in:
scamiv
2025-11-29 15:28:08 +01:00
parent 906f3e7950
commit 24eabb0ecc
3 changed files with 69 additions and 22 deletions
+38 -22
View File
@@ -1,6 +1,5 @@
import { Config } from "../configuration/Config";
import { Execution, Game, Player, UnitType } from "../game/Game";
import { GameImpl } from "../game/GameImpl";
import { GameMap, TileRef } from "../game/GameMap";
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
@@ -11,8 +10,11 @@ export class PlayerExecution implements Execution {
private lastCalc = 0;
private mg: Game;
private active = true;
private _visitedBuffer: Uint8Array;
constructor(private player: Player) {}
constructor(private player: Player) {
this._visitedBuffer = new Uint8Array(0); // Initialize empty buffer
}
activeDuringSpawnPhase(): boolean {
return false;
@@ -259,31 +261,45 @@ export class PlayerExecution implements Execution {
}
private calculateClusters(): Set<TileRef>[] {
const seen = new Set<TileRef>();
const border = this.player.borderTiles();
const borderTiles = this.player.borderTiles();
if (borderTiles.size === 0) return [];
// Ensure buffer is large enough
const mapSize = this.mg.width() * this.mg.height();
if (!this._visitedBuffer || this._visitedBuffer.length < mapSize) {
this._visitedBuffer = new Uint8Array(mapSize);
} else {
// Fast clear (much faster than creating a new Set)
this._visitedBuffer.fill(0);
}
const clusters: Set<TileRef>[] = [];
for (const tile of border) {
if (seen.has(tile)) {
continue;
}
const stack: TileRef[] = []; // Reusable stack
const cluster = new Set<TileRef>();
const queue: TileRef[] = [tile];
seen.add(tile);
while (queue.length > 0) {
const curr = queue.shift();
if (curr === undefined) throw new Error("curr is undefined");
cluster.add(curr);
for (const startTile of borderTiles) {
// FAST: Array access instead of Set.has()
if (this._visitedBuffer[startTile] === 1) continue;
const neighbors = (this.mg as GameImpl).neighborsWithDiag(curr);
for (const neighbor of neighbors) {
if (border.has(neighbor) && !seen.has(neighbor)) {
queue.push(neighbor);
seen.add(neighbor);
const currentCluster = new Set<TileRef>();
stack.push(startTile);
this._visitedBuffer[startTile] = 1;
while (stack.length > 0) {
const tile = stack.pop()!;
currentCluster.add(tile);
//Use callback to avoid creating a 'neighbors' Array
this.mg.forEachNeighborWithDiag(tile, (neighbor) => {
if (
borderTiles.has(neighbor) &&
this._visitedBuffer[neighbor] === 0
) {
stack.push(neighbor);
this._visitedBuffer[neighbor] = 1;
}
}
});
}
clusters.push(cluster);
clusters.push(currentCluster);
}
return clusters;
}
+7
View File
@@ -668,6 +668,13 @@ export interface Game extends GameMap {
map(): GameMap;
miniMap(): GameMap;
forEachTile(fn: (tile: TileRef) => void): void;
// Zero-allocation neighbor iteration for performance-critical cluster calculation
// Alternative to neighborsWithDiag() that returns arrays
// Avoids creating intermediate arrays and uses a callback for better performance
forEachNeighborWithDiag(
tile: TileRef,
callback: (neighbor: TileRef) => void,
): void;
// Player Management
player(id: PlayerID): Player;
+24
View File
@@ -517,6 +517,30 @@ export class GameImpl implements Game {
return ns;
}
// Zero-allocation neighbor iteration for performance-critical code
forEachNeighborWithDiag(
tile: TileRef,
callback: (neighbor: TileRef) => void,
): void {
const x = this.x(tile);
const y = this.y(tile);
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
if (dx === 0 && dy === 0) continue; // Skip the center tile
const newX = x + dx;
const newY = y + dy;
if (
newX >= 0 &&
newX < this._width &&
newY >= 0 &&
newY < this._height
) {
callback(this._map.ref(newX, newY));
}
}
}
}
conquer(owner: PlayerImpl, tile: TileRef): void {
if (!this.isLand(tile)) {
throw Error(`cannot conquer water`);