mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 13:20:43 +00:00
perf: Optimize cluster calculation with DFS and zero-allocation patterns (#2539)
## Description: Replace BFS with DFS and eliminate GC pressure in calculateClusters(): - 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 Describe the PR. ## 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: DISCORD_USERNAME
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
// Per-game traversal state used by calculateClusters() to avoid per-player buffers.
|
||||
const traversalStates = new WeakMap<Game, ClusterTraversalState>();
|
||||
|
||||
export class PlayerExecution implements Execution {
|
||||
private readonly ticksPerClusterCalc = 20;
|
||||
|
||||
@@ -132,17 +139,23 @@ export class PlayerExecution implements Execution {
|
||||
private surroundedBySamePlayer(cluster: Set<TileRef>): false | Player {
|
||||
const enemies = new Set<number>();
|
||||
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<TileRef>): Player | null {
|
||||
const neighbors = new Map<Player, number>();
|
||||
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<TileRef>[] {
|
||||
const seen = new Set<TileRef>();
|
||||
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<TileRef>[] = [];
|
||||
for (const tile of border) {
|
||||
if (seen.has(tile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (visited[startTile] === currentGen) 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 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,63 @@ export class PlayerExecution implements Execution {
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
private traversalState(): ClusterTraversalState {
|
||||
const totalTiles = this.mg.width() * this.mg.height();
|
||||
let state = traversalStates.get(this.mg);
|
||||
if (!state || state.visited.length < totalTiles) {
|
||||
state = {
|
||||
visited: new Uint32Array(totalTiles),
|
||||
gen: 0,
|
||||
};
|
||||
traversalStates.set(this.mg, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
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<TileRef> {
|
||||
const result = new Set<TileRef>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,6 +672,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user