mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-18 09:14:09 +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 { Config } from "../configuration/Config";
|
||||||
import { Execution, Game, Player, UnitType } from "../game/Game";
|
import { Execution, Game, Player, UnitType } from "../game/Game";
|
||||||
import { GameImpl } from "../game/GameImpl";
|
import { TileRef } from "../game/GameMap";
|
||||||
import { GameMap, TileRef } from "../game/GameMap";
|
|
||||||
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
|
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 {
|
export class PlayerExecution implements Execution {
|
||||||
private readonly ticksPerClusterCalc = 20;
|
private readonly ticksPerClusterCalc = 20;
|
||||||
|
|
||||||
@@ -132,17 +139,23 @@ export class PlayerExecution implements Execution {
|
|||||||
private surroundedBySamePlayer(cluster: Set<TileRef>): false | Player {
|
private surroundedBySamePlayer(cluster: Set<TileRef>): false | Player {
|
||||||
const enemies = new Set<number>();
|
const enemies = new Set<number>();
|
||||||
for (const tile of cluster) {
|
for (const tile of cluster) {
|
||||||
if (
|
let hasUnownedNeighbor = false;
|
||||||
this.mg.isOceanShore(tile) ||
|
if (this.mg.isOceanShore(tile) || this.mg.isOnEdgeOfMap(tile)) {
|
||||||
this.mg.isOnEdgeOfMap(tile) ||
|
return false;
|
||||||
this.mg.neighbors(tile).some((n) => !this.mg?.hasOwner(n))
|
}
|
||||||
) {
|
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;
|
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) {
|
if (enemies.size !== 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -165,14 +178,12 @@ export class PlayerExecution implements Execution {
|
|||||||
if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) {
|
if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.mg
|
this.mg.forEachNeighbor(tr, (n) => {
|
||||||
.neighbors(tr)
|
const owner = this.mg.owner(n);
|
||||||
.filter(
|
if (owner.isPlayer() && this.mg.ownerID(n) !== this.player.smallID()) {
|
||||||
(n) =>
|
enemyTiles.add(n);
|
||||||
this.mg?.owner(n).isPlayer() &&
|
}
|
||||||
this.mg?.ownerID(n) !== this.player?.smallID(),
|
});
|
||||||
)
|
|
||||||
.forEach((n) => enemyTiles.add(n));
|
|
||||||
}
|
}
|
||||||
if (enemyTiles.size === 0) {
|
if (enemyTiles.size === 0) {
|
||||||
return false;
|
return false;
|
||||||
@@ -203,9 +214,13 @@ export class PlayerExecution implements Execution {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filter = (_: GameMap, t: TileRef): boolean =>
|
const tiles = this.floodFillWithGen(
|
||||||
this.mg?.ownerID(t) === this.player?.smallID();
|
this.bumpGeneration(),
|
||||||
const tiles = this.mg.bfs(firstTile, filter);
|
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) {
|
if (this.player.numTilesOwned() === tiles.size) {
|
||||||
this.mg.conquerPlayer(capturing, this.player);
|
this.mg.conquerPlayer(capturing, this.player);
|
||||||
@@ -219,7 +234,7 @@ export class PlayerExecution implements Execution {
|
|||||||
private getCapturingPlayer(cluster: Set<TileRef>): Player | null {
|
private getCapturingPlayer(cluster: Set<TileRef>): Player | null {
|
||||||
const neighbors = new Map<Player, number>();
|
const neighbors = new Map<Player, number>();
|
||||||
for (const t of cluster) {
|
for (const t of cluster) {
|
||||||
for (const neighbor of this.mg.neighbors(t)) {
|
this.mg.forEachNeighbor(t, (neighbor) => {
|
||||||
const owner = this.mg.owner(neighbor);
|
const owner = this.mg.owner(neighbor);
|
||||||
if (
|
if (
|
||||||
owner.isPlayer() &&
|
owner.isPlayer() &&
|
||||||
@@ -228,7 +243,7 @@ export class PlayerExecution implements Execution {
|
|||||||
) {
|
) {
|
||||||
neighbors.set(owner, (neighbors.get(owner) ?? 0) + 1);
|
neighbors.set(owner, (neighbors.get(owner) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there are no enemies, return null
|
// If there are no enemies, return null
|
||||||
@@ -259,30 +274,25 @@ export class PlayerExecution implements Execution {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private calculateClusters(): Set<TileRef>[] {
|
private calculateClusters(): Set<TileRef>[] {
|
||||||
const seen = new Set<TileRef>();
|
const borderTiles = this.player.borderTiles();
|
||||||
const border = this.player.borderTiles();
|
if (borderTiles.size === 0) return [];
|
||||||
|
|
||||||
|
const state = this.traversalState();
|
||||||
|
const currentGen = this.bumpGeneration();
|
||||||
|
const visited = state.visited;
|
||||||
|
|
||||||
const clusters: Set<TileRef>[] = [];
|
const clusters: Set<TileRef>[] = [];
|
||||||
for (const tile of border) {
|
|
||||||
if (seen.has(tile)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cluster = new Set<TileRef>();
|
for (const startTile of borderTiles) {
|
||||||
const queue: TileRef[] = [tile];
|
if (visited[startTile] === currentGen) continue;
|
||||||
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);
|
const cluster = this.floodFillWithGen(
|
||||||
for (const neighbor of neighbors) {
|
currentGen,
|
||||||
if (border.has(neighbor) && !seen.has(neighbor)) {
|
visited,
|
||||||
queue.push(neighbor);
|
[startTile],
|
||||||
seen.add(neighbor);
|
(tile, cb) => this.mg.forEachNeighborWithDiag(tile, cb),
|
||||||
}
|
(tile) => borderTiles.has(tile),
|
||||||
}
|
);
|
||||||
}
|
|
||||||
clusters.push(cluster);
|
clusters.push(cluster);
|
||||||
}
|
}
|
||||||
return clusters;
|
return clusters;
|
||||||
@@ -298,4 +308,63 @@ export class PlayerExecution implements Execution {
|
|||||||
isActive(): boolean {
|
isActive(): boolean {
|
||||||
return this.active;
|
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;
|
map(): GameMap;
|
||||||
miniMap(): GameMap;
|
miniMap(): GameMap;
|
||||||
forEachTile(fn: (tile: TileRef) => void): void;
|
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 Management
|
||||||
player(id: PlayerID): Player;
|
player(id: PlayerID): Player;
|
||||||
|
|||||||
@@ -517,6 +517,30 @@ export class GameImpl implements Game {
|
|||||||
return ns;
|
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 {
|
conquer(owner: PlayerImpl, tile: TileRef): void {
|
||||||
if (!this.isLand(tile)) {
|
if (!this.isLand(tile)) {
|
||||||
throw Error(`cannot conquer water`);
|
throw Error(`cannot conquer water`);
|
||||||
@@ -858,6 +882,15 @@ export class GameImpl implements Game {
|
|||||||
neighbors(ref: TileRef): TileRef[] {
|
neighbors(ref: TileRef): TileRef[] {
|
||||||
return this._map.neighbors(ref);
|
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 {
|
isWater(ref: TileRef): boolean {
|
||||||
return this._map.isWater(ref);
|
return this._map.isWater(ref);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user