mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-25 10:18:59 +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:
@@ -11,7 +11,7 @@ import {
|
||||
TerrainType,
|
||||
TerraNullius,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { assertNever } from "../Util";
|
||||
import { FlatBinaryHeap } from "./utils/FlatBinaryHeap"; // adjust path if needed
|
||||
@@ -26,9 +26,18 @@ export class AttackExecution implements Execution {
|
||||
private target: Player | TerraNullius;
|
||||
|
||||
private mg: Game;
|
||||
// Direct GameMap reference to skip the Game delegation hop in hot loops.
|
||||
private map: GameMap;
|
||||
|
||||
private attack: Attack | null = null;
|
||||
|
||||
// Cached smallIDs for integer owner comparisons in hot loops.
|
||||
private ownerSmallID: number;
|
||||
private targetSmallID: number;
|
||||
// Reusable neighbor buffers to avoid closures/allocation in hot loops.
|
||||
private nbuf: TileRef[] = [0, 0, 0, 0];
|
||||
private nbuf2: TileRef[] = [0, 0, 0, 0];
|
||||
|
||||
constructor(
|
||||
private startTroops: number | null = null,
|
||||
private _owner: Player,
|
||||
@@ -50,6 +59,7 @@ export class AttackExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
this.mg = mg;
|
||||
this.map = mg.map();
|
||||
|
||||
if (this._targetID !== null && !mg.hasPlayer(this._targetID)) {
|
||||
console.warn(`target ${this._targetID} not found`);
|
||||
@@ -61,6 +71,8 @@ export class AttackExecution implements Execution {
|
||||
this._targetID === this.mg.terraNullius().id()
|
||||
? mg.terraNullius()
|
||||
: mg.player(this._targetID);
|
||||
this.ownerSmallID = this._owner.smallID();
|
||||
this.targetSmallID = this.target.smallID();
|
||||
|
||||
if (this._owner === this.target) {
|
||||
console.error(`Player ${this._owner} cannot attack itself`);
|
||||
@@ -270,19 +282,21 @@ export class AttackExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
const [tileToConquer] = this.toConquer.dequeue();
|
||||
const tileToConquer = this.toConquer.dequeue();
|
||||
this.attack.removeBorderTile(tileToConquer);
|
||||
|
||||
let onBorder = false;
|
||||
this.mg.forEachNeighbor(tileToConquer, (n) => {
|
||||
if (!onBorder && this.mg.owner(n) === this._owner) {
|
||||
const numNeighbors = this.map.neighbors4(tileToConquer, this.nbuf);
|
||||
for (let i = 0; i < numNeighbors; i++) {
|
||||
if (this.map.ownerID(this.nbuf[i]) === this.ownerSmallID) {
|
||||
onBorder = true;
|
||||
break;
|
||||
}
|
||||
});
|
||||
if (this.mg.owner(tileToConquer) !== this.target || !onBorder) {
|
||||
}
|
||||
if (this.map.ownerID(tileToConquer) !== this.targetSmallID || !onBorder) {
|
||||
continue;
|
||||
}
|
||||
if (!this.mg.isLand(tileToConquer)) {
|
||||
if (!this.map.isLand(tileToConquer)) {
|
||||
continue;
|
||||
}
|
||||
this.addNeighbors(tileToConquer);
|
||||
@@ -322,23 +336,26 @@ export class AttackExecution implements Execution {
|
||||
|
||||
const tickNow = this.mg.ticks(); // cache tick
|
||||
|
||||
this.mg.forEachNeighbor(tile, (neighbor) => {
|
||||
const numNeighbors = this.map.neighbors4(tile, this.nbuf);
|
||||
for (let i = 0; i < numNeighbors; i++) {
|
||||
const neighbor = this.nbuf[i];
|
||||
if (
|
||||
this.mg.isWater(neighbor) ||
|
||||
this.mg.owner(neighbor) !== this.target
|
||||
this.map.isWater(neighbor) ||
|
||||
this.map.ownerID(neighbor) !== this.targetSmallID
|
||||
) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
this.attack!.addBorderTile(neighbor);
|
||||
this.attack.addBorderTile(neighbor);
|
||||
let numOwnedByMe = 0;
|
||||
this.mg.forEachNeighbor(neighbor, (n) => {
|
||||
if (this.mg.owner(n) === this._owner) {
|
||||
const numInner = this.map.neighbors4(neighbor, this.nbuf2);
|
||||
for (let j = 0; j < numInner; j++) {
|
||||
if (this.map.ownerID(this.nbuf2[j]) === this.ownerSmallID) {
|
||||
numOwnedByMe++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mag: number;
|
||||
switch (this.mg.terrainType(neighbor)) {
|
||||
switch (this.map.terrainType(neighbor)) {
|
||||
case TerrainType.Plains:
|
||||
mag = 1;
|
||||
break;
|
||||
@@ -358,7 +375,7 @@ export class AttackExecution implements Execution {
|
||||
tickNow;
|
||||
|
||||
this.toConquer.enqueue(neighbor, priority);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleDeadDefender() {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Structures,
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
|
||||
|
||||
interface ClusterTraversalState {
|
||||
@@ -24,7 +24,11 @@ export class PlayerExecution implements Execution {
|
||||
private config: Config;
|
||||
private lastCalc = 0;
|
||||
private mg: Game;
|
||||
// Direct GameMap reference to skip the Game delegation hop in hot loops.
|
||||
private map: GameMap;
|
||||
private active = true;
|
||||
// Reusable neighbor buffer to avoid closures/allocation in cluster checks.
|
||||
private nbuf: TileRef[] = [0, 0, 0, 0];
|
||||
|
||||
constructor(private player: Player) {}
|
||||
|
||||
@@ -34,6 +38,7 @@ export class PlayerExecution implements Execution {
|
||||
|
||||
init(mg: Game, ticks: number) {
|
||||
this.mg = mg;
|
||||
this.map = mg.map();
|
||||
this.config = mg.config();
|
||||
this.lastCalc =
|
||||
ticks + (simpleHash(this.player.name()) % this.ticksPerClusterCalc);
|
||||
@@ -163,29 +168,29 @@ export class PlayerExecution implements Execution {
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
|
||||
const map = this.map;
|
||||
const mySmallID = this.player.smallID();
|
||||
for (const tile of cluster) {
|
||||
let hasUnownedNeighbor = false;
|
||||
if (this.mg.isOceanShore(tile) || this.mg.isOnEdgeOfMap(tile)) {
|
||||
if (map.isOceanShore(tile) || map.isOnEdgeOfMap(tile)) {
|
||||
return false;
|
||||
}
|
||||
this.mg.forEachNeighbor(tile, (n) => {
|
||||
if (!this.mg.hasOwner(n)) {
|
||||
hasUnownedNeighbor = true;
|
||||
return;
|
||||
const numNeighbors = map.neighbors4(tile, this.nbuf);
|
||||
for (let i = 0; i < numNeighbors; i++) {
|
||||
const n = this.nbuf[i];
|
||||
const ownerId = map.ownerID(n);
|
||||
if (ownerId === 0) {
|
||||
// Unowned neighbor: the cluster is not fully surrounded.
|
||||
return false;
|
||||
}
|
||||
const ownerId = this.mg.ownerID(n);
|
||||
if (ownerId !== this.player.smallID()) {
|
||||
if (ownerId !== mySmallID) {
|
||||
enemies.add(ownerId);
|
||||
const px = this.mg.x(n);
|
||||
const py = this.mg.y(n);
|
||||
const px = map.x(n);
|
||||
const py = map.y(n);
|
||||
minX = Math.min(minX, px);
|
||||
minY = Math.min(minY, py);
|
||||
maxX = Math.max(maxX, px);
|
||||
maxY = Math.max(maxY, py);
|
||||
}
|
||||
});
|
||||
if (hasUnownedNeighbor) {
|
||||
return false;
|
||||
}
|
||||
if (enemies.size !== 1) {
|
||||
return false;
|
||||
@@ -212,22 +217,26 @@ export class PlayerExecution implements Execution {
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
const map = this.map;
|
||||
const mySmallID = this.player.smallID();
|
||||
for (const tr of cluster) {
|
||||
if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) {
|
||||
if (map.isShore(tr) || map.isOnEdgeOfMap(tr)) {
|
||||
return false;
|
||||
}
|
||||
this.mg.forEachNeighbor(tr, (n) => {
|
||||
const owner = this.mg.owner(n);
|
||||
if (owner.isPlayer() && this.mg.ownerID(n) !== this.player.smallID()) {
|
||||
const numNeighbors = map.neighbors4(tr, this.nbuf);
|
||||
for (let i = 0; i < numNeighbors; i++) {
|
||||
const n = this.nbuf[i];
|
||||
const ownerId = map.ownerID(n);
|
||||
if (ownerId !== 0 && ownerId !== mySmallID) {
|
||||
hasEnemy = true;
|
||||
const x = this.mg.x(n);
|
||||
const y = this.mg.y(n);
|
||||
const x = map.x(n);
|
||||
const y = map.y(n);
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!hasEnemy) {
|
||||
return false;
|
||||
@@ -275,17 +284,20 @@ export class PlayerExecution implements Execution {
|
||||
|
||||
private getCapturingPlayer(cluster: Set<TileRef>): Player | null {
|
||||
const neighbors = new Map<Player, number>();
|
||||
const map = this.map;
|
||||
const mySmallID = this.player.smallID();
|
||||
for (const t of cluster) {
|
||||
this.mg.forEachNeighbor(t, (neighbor) => {
|
||||
const owner = this.mg.owner(neighbor);
|
||||
if (
|
||||
owner.isPlayer() &&
|
||||
owner !== this.player &&
|
||||
!owner.isFriendly(this.player)
|
||||
) {
|
||||
const numNeighbors = map.neighbors4(t, this.nbuf);
|
||||
for (let i = 0; i < numNeighbors; i++) {
|
||||
const ownerId = map.ownerID(this.nbuf[i]);
|
||||
if (ownerId === 0 || ownerId === mySmallID) {
|
||||
continue;
|
||||
}
|
||||
const owner = this.mg.playerBySmallID(ownerId) as Player;
|
||||
if (!owner.isFriendly(this.player)) {
|
||||
neighbors.set(owner, (neighbors.get(owner) ?? 0) + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no enemies, return null
|
||||
@@ -392,19 +404,21 @@ export class PlayerExecution implements Execution {
|
||||
stack.push(start);
|
||||
}
|
||||
|
||||
const visit = (neighbor: TileRef) => {
|
||||
if (visited[neighbor] === currentGen) {
|
||||
return;
|
||||
}
|
||||
if (!includeFn(neighbor)) {
|
||||
return;
|
||||
}
|
||||
visited[neighbor] = currentGen;
|
||||
result.add(neighbor);
|
||||
stack.push(neighbor);
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
neighborFn(tile, visit);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -43,12 +43,11 @@ export class FlatBinaryHeap {
|
||||
this.tiles[i] = tile;
|
||||
}
|
||||
|
||||
//remove tiles
|
||||
dequeue(): [TileRef, number] {
|
||||
/** 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 topPri = this.pri[0];
|
||||
|
||||
const lastPri = this.pri[--this.len];
|
||||
const lastTile = this.tiles[this.len];
|
||||
@@ -68,7 +67,7 @@ export class FlatBinaryHeap {
|
||||
}
|
||||
this.pri[i] = lastPri;
|
||||
this.tiles[i] = lastTile;
|
||||
return [topTile, topPri];
|
||||
return topTile;
|
||||
}
|
||||
|
||||
/** double the underlying storage */
|
||||
|
||||
Reference in New Issue
Block a user