mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-07 19:21:30 +00:00
Optimize core simulation hot paths (no behavior change) (#4230)
## 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>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { FlatBinaryHeap } from "../src/core/execution/utils/FlatBinaryHeap";
|
||||
|
||||
describe("FlatBinaryHeap", () => {
|
||||
test("dequeues tiles in ascending priority order", () => {
|
||||
const heap = new FlatBinaryHeap();
|
||||
const entries: [number, number][] = [
|
||||
[100, 5.0],
|
||||
[200, 1.0],
|
||||
[300, 3.0],
|
||||
[400, 2.0],
|
||||
[500, 4.0],
|
||||
];
|
||||
for (const [tile, pri] of entries) {
|
||||
heap.enqueue(tile, pri);
|
||||
}
|
||||
expect(heap.size()).toBe(5);
|
||||
expect(heap.dequeue()).toBe(200);
|
||||
expect(heap.dequeue()).toBe(400);
|
||||
expect(heap.dequeue()).toBe(300);
|
||||
expect(heap.dequeue()).toBe(500);
|
||||
expect(heap.dequeue()).toBe(100);
|
||||
expect(heap.size()).toBe(0);
|
||||
});
|
||||
|
||||
test("throws when dequeuing an empty heap", () => {
|
||||
const heap = new FlatBinaryHeap();
|
||||
expect(() => heap.dequeue()).toThrow("heap empty");
|
||||
});
|
||||
|
||||
test("clear empties the heap without breaking subsequent use", () => {
|
||||
const heap = new FlatBinaryHeap();
|
||||
heap.enqueue(1, 1);
|
||||
heap.enqueue(2, 2);
|
||||
heap.clear();
|
||||
expect(heap.size()).toBe(0);
|
||||
heap.enqueue(3, 3);
|
||||
expect(heap.dequeue()).toBe(3);
|
||||
});
|
||||
|
||||
test("grows past its initial capacity and stays ordered", () => {
|
||||
const heap = new FlatBinaryHeap(4);
|
||||
// Insert in descending priority so every enqueue sifts up.
|
||||
const n = 1000;
|
||||
for (let i = 0; i < n; i++) {
|
||||
heap.enqueue(i, n - i);
|
||||
}
|
||||
expect(heap.size()).toBe(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
expect(heap.dequeue()).toBe(n - 1 - i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { AttackExecution } from "../src/core/execution/AttackExecution";
|
||||
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
|
||||
import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game";
|
||||
import { TileRef } from "../src/core/game/GameMap";
|
||||
import { GameID } from "../src/core/Schemas";
|
||||
import { setup } from "./util/Setup";
|
||||
|
||||
let game: Game;
|
||||
const gameID: GameID = "game_id";
|
||||
|
||||
function collectNeighbors(tile: TileRef): TileRef[] {
|
||||
const out: TileRef[] = [];
|
||||
game.forEachNeighbor(tile, (n) => out.push(n));
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectNeighborsWithDiag(tile: TileRef): TileRef[] {
|
||||
const out: TileRef[] = [];
|
||||
game.forEachNeighborWithDiag(tile, (n) => out.push(n));
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("Neighbor iteration", () => {
|
||||
beforeEach(async () => {
|
||||
game = await setup("ocean_and_land"); // 16x16
|
||||
});
|
||||
|
||||
test("forEachNeighbor visits W, E, N, S in that exact order for interior tiles", () => {
|
||||
const tile = game.ref(5, 7);
|
||||
expect(collectNeighbors(tile)).toEqual([
|
||||
game.ref(4, 7),
|
||||
game.ref(6, 7),
|
||||
game.ref(5, 6),
|
||||
game.ref(5, 8),
|
||||
]);
|
||||
});
|
||||
|
||||
test("forEachNeighbor clips at corners and edges", () => {
|
||||
const w = game.width();
|
||||
const h = game.height();
|
||||
// top-left corner: E, S only
|
||||
expect(collectNeighbors(game.ref(0, 0))).toEqual([
|
||||
game.ref(1, 0),
|
||||
game.ref(0, 1),
|
||||
]);
|
||||
// bottom-right corner: W, N only
|
||||
expect(collectNeighbors(game.ref(w - 1, h - 1))).toEqual([
|
||||
game.ref(w - 2, h - 1),
|
||||
game.ref(w - 1, h - 2),
|
||||
]);
|
||||
// left edge: E, N, S
|
||||
expect(collectNeighbors(game.ref(0, 5))).toEqual([
|
||||
game.ref(1, 5),
|
||||
game.ref(0, 4),
|
||||
game.ref(0, 6),
|
||||
]);
|
||||
// bottom edge: W, E, N
|
||||
expect(collectNeighbors(game.ref(5, h - 1))).toEqual([
|
||||
game.ref(4, h - 1),
|
||||
game.ref(6, h - 1),
|
||||
game.ref(5, h - 2),
|
||||
]);
|
||||
});
|
||||
|
||||
test("forEachNeighbor matches map.neighbors() as a set for every tile", () => {
|
||||
game.forEachTile((tile) => {
|
||||
const a = [...collectNeighbors(tile)].sort((x, y) => x - y);
|
||||
const b = [...game.map().neighbors(tile)].sort((x, y) => x - y);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
});
|
||||
|
||||
test("forEachNeighborWithDiag visits all 8 neighbors in dx-major order", () => {
|
||||
const tile = game.ref(5, 7);
|
||||
expect(collectNeighborsWithDiag(tile)).toEqual([
|
||||
game.ref(4, 6),
|
||||
game.ref(4, 7),
|
||||
game.ref(4, 8),
|
||||
game.ref(5, 6),
|
||||
game.ref(5, 8),
|
||||
game.ref(6, 6),
|
||||
game.ref(6, 7),
|
||||
game.ref(6, 8),
|
||||
]);
|
||||
});
|
||||
|
||||
test("forEachNeighborWithDiag clips at corners and edges", () => {
|
||||
const w = game.width();
|
||||
const h = game.height();
|
||||
expect(collectNeighborsWithDiag(game.ref(0, 0))).toEqual([
|
||||
game.ref(0, 1),
|
||||
game.ref(1, 0),
|
||||
game.ref(1, 1),
|
||||
]);
|
||||
expect(collectNeighborsWithDiag(game.ref(w - 1, h - 1))).toEqual([
|
||||
game.ref(w - 2, h - 2),
|
||||
game.ref(w - 2, h - 1),
|
||||
game.ref(w - 1, h - 2),
|
||||
]);
|
||||
expect(collectNeighborsWithDiag(game.ref(5, 0))).toEqual([
|
||||
game.ref(4, 0),
|
||||
game.ref(4, 1),
|
||||
game.ref(5, 1),
|
||||
game.ref(6, 0),
|
||||
game.ref(6, 1),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Conquer border invariants", () => {
|
||||
let attacker: Player;
|
||||
let defender: Player;
|
||||
|
||||
// For every player: borderTiles ⊆ tiles, and a tile is a border tile iff
|
||||
// some in-bounds cardinal neighbor has a different owner.
|
||||
function checkBorderInvariant() {
|
||||
for (const player of game.players()) {
|
||||
const tiles = player.tiles();
|
||||
const borderTiles = player.borderTiles();
|
||||
for (const tile of borderTiles) {
|
||||
expect(tiles.has(tile)).toBe(true);
|
||||
}
|
||||
const mismatches: TileRef[] = [];
|
||||
for (const tile of tiles) {
|
||||
let isBorder = false;
|
||||
game.forEachNeighbor(tile, (n) => {
|
||||
if (game.owner(n) !== player) {
|
||||
isBorder = true;
|
||||
}
|
||||
});
|
||||
if (borderTiles.has(tile) !== isBorder) {
|
||||
mismatches.push(tile);
|
||||
}
|
||||
}
|
||||
expect(mismatches).toEqual([]);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
game = await setup("plains", { infiniteTroops: true }); // 100x100, all land
|
||||
const attackerInfo = new PlayerInfo(
|
||||
"attacker dude",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"attacker_id",
|
||||
);
|
||||
game.addPlayer(attackerInfo);
|
||||
const defenderInfo = new PlayerInfo(
|
||||
"defender dude",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"defender_id",
|
||||
);
|
||||
game.addPlayer(defenderInfo);
|
||||
|
||||
game.addExecution(
|
||||
new SpawnExecution(gameID, attackerInfo, game.ref(0, 0)),
|
||||
new SpawnExecution(gameID, defenderInfo, game.ref(5, 5)),
|
||||
);
|
||||
game.executeNextTick();
|
||||
game.executeNextTick();
|
||||
|
||||
attacker = game.player(attackerInfo.id);
|
||||
defender = game.player(defenderInfo.id);
|
||||
});
|
||||
|
||||
test("border invariant holds after expanding into terra nullius", () => {
|
||||
game.addExecution(
|
||||
new AttackExecution(1000, attacker, game.terraNullius().id()),
|
||||
);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
game.executeNextTick();
|
||||
}
|
||||
expect(attacker.numTilesOwned()).toBeGreaterThan(10);
|
||||
checkBorderInvariant();
|
||||
});
|
||||
|
||||
test("border invariant holds while two players fight over territory", () => {
|
||||
game.addExecution(
|
||||
new AttackExecution(1000, attacker, game.terraNullius().id()),
|
||||
new AttackExecution(1000, defender, game.terraNullius().id()),
|
||||
);
|
||||
for (let i = 0; i < 40; i++) {
|
||||
game.executeNextTick();
|
||||
}
|
||||
game.addExecution(new AttackExecution(5000, attacker, defender.id()));
|
||||
// Check the invariant repeatedly while the fight is in progress, not
|
||||
// just at the end.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
game.executeNextTick();
|
||||
if (i % 10 === 0) {
|
||||
checkBorderInvariant();
|
||||
}
|
||||
}
|
||||
expect(attacker.numTilesOwned()).toBeGreaterThan(10);
|
||||
checkBorderInvariant();
|
||||
});
|
||||
|
||||
test("conquering a specific tile updates owner and neighbors' border status", () => {
|
||||
game.addExecution(
|
||||
new AttackExecution(1000, attacker, game.terraNullius().id()),
|
||||
);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
game.executeNextTick();
|
||||
}
|
||||
// Pick a border tile of the attacker and verify its interior neighbors
|
||||
// are not border tiles.
|
||||
for (const tile of attacker.tiles()) {
|
||||
expect(game.owner(tile)).toBe(attacker);
|
||||
}
|
||||
checkBorderInvariant();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user