diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index a8cc3fb421..3f00de2748 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -1,9 +1,16 @@ 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 { TileRef } from "../game/GameMap"; import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util"; +interface ClusterTraversalState { + visited: Uint32Array; + gen: number; +} + +// Single shared traversal state; there is only ever one Game per runtime. +let traversalState: ClusterTraversalState | null = null; + export class PlayerExecution implements Execution { private readonly ticksPerClusterCalc = 20; @@ -132,17 +139,23 @@ export class PlayerExecution implements Execution { private surroundedBySamePlayer(cluster: Set): false | Player { const enemies = new Set(); for (const tile of cluster) { - if ( - this.mg.isOceanShore(tile) || - this.mg.isOnEdgeOfMap(tile) || - this.mg.neighbors(tile).some((n) => !this.mg?.hasOwner(n)) - ) { + let hasUnownedNeighbor = false; + if (this.mg.isOceanShore(tile) || this.mg.isOnEdgeOfMap(tile)) { + return false; + } + this.mg.forEachNeighbor(tile, (n) => { + if (!this.mg.hasOwner(n)) { + hasUnownedNeighbor = true; + return; + } + const ownerId = this.mg.ownerID(n); + if (ownerId !== this.player.smallID()) { + enemies.add(ownerId); + } + }); + if (hasUnownedNeighbor) { return false; } - this.mg - .neighbors(tile) - .filter((n) => this.mg?.ownerID(n) !== this.player?.smallID()) - .forEach((p) => this.mg && enemies.add(this.mg.ownerID(p))); if (enemies.size !== 1) { return false; } @@ -165,14 +178,12 @@ export class PlayerExecution implements Execution { if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) { return false; } - this.mg - .neighbors(tr) - .filter( - (n) => - this.mg?.owner(n).isPlayer() && - this.mg?.ownerID(n) !== this.player?.smallID(), - ) - .forEach((n) => enemyTiles.add(n)); + this.mg.forEachNeighbor(tr, (n) => { + const owner = this.mg.owner(n); + if (owner.isPlayer() && this.mg.ownerID(n) !== this.player.smallID()) { + enemyTiles.add(n); + } + }); } if (enemyTiles.size === 0) { return false; @@ -203,9 +214,13 @@ export class PlayerExecution implements Execution { return; } - const filter = (_: GameMap, t: TileRef): boolean => - this.mg?.ownerID(t) === this.player?.smallID(); - const tiles = this.mg.bfs(firstTile, filter); + const tiles = this.floodFillWithGen( + this.bumpGeneration(), + this.traversalState().visited, + [firstTile], + (tile, cb) => this.mg.forEachNeighbor(tile, cb), + (tile) => this.mg.ownerID(tile) === this.player.smallID(), + ); if (this.player.numTilesOwned() === tiles.size) { this.mg.conquerPlayer(capturing, this.player); @@ -219,7 +234,7 @@ export class PlayerExecution implements Execution { private getCapturingPlayer(cluster: Set): Player | null { const neighbors = new Map(); for (const t of cluster) { - for (const neighbor of this.mg.neighbors(t)) { + this.mg.forEachNeighbor(t, (neighbor) => { const owner = this.mg.owner(neighbor); if ( owner.isPlayer() && @@ -228,7 +243,7 @@ export class PlayerExecution implements Execution { ) { neighbors.set(owner, (neighbors.get(owner) ?? 0) + 1); } - } + }); } // If there are no enemies, return null @@ -259,30 +274,25 @@ export class PlayerExecution implements Execution { } private calculateClusters(): Set[] { - const seen = new Set(); - const border = this.player.borderTiles(); + const borderTiles = this.player.borderTiles(); + if (borderTiles.size === 0) return []; + + const state = this.traversalState(); + const currentGen = this.bumpGeneration(); + const visited = state.visited; + const clusters: Set[] = []; - for (const tile of border) { - if (seen.has(tile)) { - continue; - } - const cluster = new Set(); - 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); - - 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); - } - } - } + for (const startTile of borderTiles) { + if (visited[startTile] === currentGen) continue; + + const cluster = this.floodFillWithGen( + currentGen, + visited, + [startTile], + (tile, cb) => this.mg.forEachNeighborWithDiag(tile, cb), + (tile) => borderTiles.has(tile), + ); clusters.push(cluster); } return clusters; @@ -298,4 +308,61 @@ export class PlayerExecution implements Execution { isActive(): boolean { return this.active; } + + private traversalState(): ClusterTraversalState { + const totalTiles = this.mg.width() * this.mg.height(); + if (!traversalState || traversalState.visited.length < totalTiles) { + traversalState = { + visited: new Uint32Array(totalTiles), + gen: 0, + }; + } + return traversalState; + } + + private bumpGeneration(): number { + const state = this.traversalState(); + state.gen++; + if (state.gen === 0xffffffff) { + state.visited.fill(0); + state.gen = 1; + } + return state.gen; + } + + private floodFillWithGen( + currentGen: number, + visited: Uint32Array, + startTiles: TileRef[], + neighborFn: (tile: TileRef, callback: (neighbor: TileRef) => void) => void, + includeFn: (tile: TileRef) => boolean, + ): Set { + const result = new Set(); + const stack: TileRef[] = []; + + for (const start of startTiles) { + if (visited[start] === currentGen) continue; + if (!includeFn(start)) continue; + visited[start] = currentGen; + result.add(start); + stack.push(start); + } + + while (stack.length > 0) { + const tile = stack.pop()!; + neighborFn(tile, (neighbor) => { + if (visited[neighbor] === currentGen) { + return; + } + if (!includeFn(neighbor)) { + return; + } + visited[neighbor] = currentGen; + result.add(neighbor); + stack.push(neighbor); + }); + } + + return result; + } } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 9587e32be8..93b8af56d4 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -668,6 +668,15 @@ export interface Game extends GameMap { map(): GameMap; miniMap(): GameMap; forEachTile(fn: (tile: TileRef) => void): void; + // Zero-allocation neighbor iteration (cardinal only) to avoid creating arrays + forEachNeighbor(tile: TileRef, callback: (neighbor: 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; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 2c23ad8641..61a6e38d72 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -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`); @@ -858,6 +882,15 @@ export class GameImpl implements Game { neighbors(ref: TileRef): TileRef[] { return this._map.neighbors(ref); } + // Zero-allocation neighbor iteration (cardinal only) + forEachNeighbor(tile: TileRef, callback: (neighbor: TileRef) => void): void { + const x = this.x(tile); + const y = this.y(tile); + if (x > 0) callback(this._map.ref(x - 1, y)); + if (x + 1 < this._width) callback(this._map.ref(x + 1, y)); + if (y > 0) callback(this._map.ref(x, y - 1)); + if (y + 1 < this._height) callback(this._map.ref(x, y + 1)); + } isWater(ref: TileRef): boolean { return this._map.isWater(ref); }