mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-24 06:54:41 +00:00
2789db8b96
## Summary Pure performance optimizations to the attack/conquer/cluster hot paths in `src/core`, driven by the full-game perf harness from #4228. **No behavior change**: the final game-state hash is identical before/after on every config tested — world quick run (2 different seeds), giantworldmap, and the default 1800-tick run. ### Changes - **Flat-arithmetic neighbor iteration**: `forEachNeighbor` / `forEachNeighborWithDiag` / `isBorder` / `isOceanShore` are now implemented inside `GameMapImpl` using raw `ref±1` / `ref±width` index math, skipping the per-neighbor `ref()` coordinate validation (`Number.isInteger` etc.). `GameImpl` and `GameView` delegate. - **New `neighbors4(ref, out)`**: zero-allocation, callback-free neighbor query for hot loops (W, E, N, S — same order as `forEachNeighbor`). - **`AttackExecution`**: the per-tile closures in `tick()` / `addNeighbors()` are replaced with reusable neighbor buffers, a cached `GameMap` reference, and integer `smallID()` owner comparisons instead of owner-object lookups. - **`GameImpl`**: the per-conquer `updateBorders` closure is hoisted to a method with a reusable buffer; `removeInactiveExecutions` compacts the executions array in place instead of allocating a new ~4200-element array every tick. - **`PlayerExecution`**: `surroundedBySamePlayer` / `isSurrounded` / `getCapturingPlayer` de-closured (`neighbors4` + integer compares; neighbor visit order preserved, so `getCapturingPlayer`'s Map-insertion-order tie-breaking is unchanged); flood-fill visit closure hoisted out of the while loop. - **`FlatBinaryHeap.dequeue`**: returns the tile directly instead of allocating a `[tile, priority]` tuple per dequeued tile (AttackExecution is the only caller). ### Performance (`npm run perf:game`, same machine, before → after) | run | mean tick | ticks/sec | max tick | |---|---|---|---| | default (world, 400 bots, 1800 ticks) | 9.04 → **7.98 ms** | 111 → **125** | 31.7 → 35.7 ms | | giantworldmap, 600 ticks | 22.5 → **17.4 ms** | 44 → **58** | 52.8 → **36.2 ms** | The giantworldmap tail improvement (max tick −31%) is the most relevant for the 100 ms tick budget. ### Determinism verification Identical `Final hash` before and after on all configs: | config | hash | |---|---| | `--map world --ticks 200 --bots 100` | `5455008589403520` | | same + `--seed second-seed-check` | `5580840142777488` | | `--map giantworldmap --ticks 600` | `37373734953428430` | | default run | `26773450321979388` | ### Tests - New `tests/NeighborIteration.test.ts` pins the exact neighbor iteration orders (W,E,N,S cardinal; dx-major diagonal — conquest order and RNG consumption depend on them) and conquer/border-tile invariants checked mid-battle. - New `tests/FlatBinaryHeap.test.ts` covers heap ordering, clear, and growth. - Full suite passes (122 files / 1386 tests + server tests); lint and prettier clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
84 lines
2.1 KiB
TypeScript
84 lines
2.1 KiB
TypeScript
import { TileRef } from "../../game/GameMap";
|
|
|
|
/**
|
|
* Lightweight min-heap specialised for (priority:number, tile:TileRef) pairs.
|
|
* - priorities stored in a contiguous Float32Array
|
|
* - tiles stored in a parallel object array
|
|
*/
|
|
export class FlatBinaryHeap {
|
|
/** parallel arrays: pri[ i ] is the priority of tiles[ i ] */
|
|
private pri: Float32Array;
|
|
private tiles: TileRef[];
|
|
private len = 0; // current number of elements
|
|
|
|
constructor(capacity = 1024) {
|
|
this.pri = new Float32Array(capacity);
|
|
this.tiles = new Array<TileRef>(capacity);
|
|
}
|
|
|
|
/** remove every element without reallocating */
|
|
clear(): void {
|
|
this.len = 0;
|
|
}
|
|
|
|
/** current heap size */
|
|
size(): number {
|
|
return this.len;
|
|
}
|
|
|
|
//insert tiles
|
|
enqueue(tile: TileRef, priority: number): void {
|
|
if (this.len === this.pri.length) this.grow(); // ensure space
|
|
let i = this.len++;
|
|
|
|
/* sift-up */
|
|
while (i > 0) {
|
|
const parent = (i - 1) >> 1;
|
|
if (priority >= this.pri[parent]) break;
|
|
this.pri[i] = this.pri[parent];
|
|
this.tiles[i] = this.tiles[parent];
|
|
i = parent;
|
|
}
|
|
this.pri[i] = priority;
|
|
this.tiles[i] = tile;
|
|
}
|
|
|
|
/** remove and return the lowest-priority tile (no per-call allocation) */
|
|
dequeue(): TileRef {
|
|
if (this.len === 0) throw new Error("heap empty");
|
|
|
|
const topTile = this.tiles[0];
|
|
|
|
const lastPri = this.pri[--this.len];
|
|
const lastTile = this.tiles[this.len];
|
|
|
|
/* sift-down */
|
|
let i = 0;
|
|
while (true) {
|
|
const left = (i << 1) + 1;
|
|
if (left >= this.len) break;
|
|
const right = left + 1;
|
|
const child =
|
|
right < this.len && this.pri[right] < this.pri[left] ? right : left;
|
|
if (lastPri <= this.pri[child]) break;
|
|
this.pri[i] = this.pri[child];
|
|
this.tiles[i] = this.tiles[child];
|
|
i = child;
|
|
}
|
|
this.pri[i] = lastPri;
|
|
this.tiles[i] = lastTile;
|
|
return topTile;
|
|
}
|
|
|
|
/** double the underlying storage */
|
|
private grow(): void {
|
|
const newCap = this.pri.length << 1;
|
|
|
|
const newPri = new Float32Array(newCap);
|
|
newPri.set(this.pri);
|
|
this.pri = newPri;
|
|
|
|
this.tiles.length = newCap;
|
|
}
|
|
}
|