mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-10 23:14:05 +00:00
perf: reduce core live-memory footprint by 45% on large maps (#4507)
## Summary
Reduces the simulation's steady-state memory footprint. On Giant World
Map at 20 game-minutes (12 000 ticks, 400 bots, seed `perf-default`),
live memory after a full GC drops **293 MB → 161 MB (−45%)**; unforced
peak heap drops **326 MB → 165 MB**. The simulation also runs ~10%
faster (85 → 94 ticks/s). The final game-state hash is **bit-identical**
(`57830793797434300`) — no behavior change.
## Measurement (first commit)
The full-game perf harness gains a footprint mode:
- `--footprint` — forces a full GC at every `--window` boundary and
records the live heap / ArrayBuffer / RSS curve across the game
(requires `NODE_OPTIONS=--expose-gc`).
- `--snapshot-at 0,2000,12000` — writes V8 `.heapsnapshot` files at
chosen ticks.
- `HeapSnapshotRetainers.ts` — attributes every heap node to its nearest
meaningfully-named retainer (e.g. `PlayerImpl._tiles`), plus prints
retainer chains for all nodes ≥128 KB. `HeapSnapshotSummary.ts` is a
streaming fallback for snapshots too large to `JSON.parse`.
Baseline attribution at tick 12 000: player `_tiles`/`_borderTiles` Sets
**83 MB**, GameMap `refToX`/`refToY` lookup tables **38 MB**, two
duplicate 30.5 MB visited-scratch arrays, trade-ship stepper paths **15
MB**, a construction-only flood-fill queue **9.5 MB**.
## Optimizations
**Map-sized buffers (second commit):**
- `GameMap.x()/y()` compute `ref % width` / `(ref / width) | 0` instead
of reading two per-tile Uint16 tables (−38 MB). The arithmetic is
cheaper than the tables' random-access cache misses — this is where the
speedup comes from.
- `PlayerExecution` and `SpatialQuery` each kept their own per-game
generation-stamped visited `Uint32Array`; both now share one via
`TileTraversalScratch` (−30 MB).
- `PathFinderStepper` stores numeric paths as `Uint32Array` (half the
bytes; steppers hold their full path for a unit's whole journey).
- `ConnectedComponents` frees its flood-fill queue after `initialize()`.
**Player tile sets (third commit):**
- New `TileSet`: insertion-ordered set of tile refs backed by a dense
`Uint32Array` plus an open-addressing hash index — ~12 bytes/element vs
~34 for a native `Set<number>`. Deletes tombstone; compaction is
deferred while iteration is in progress so positions never shift under
an iterator.
- Iteration semantics match `Set` exactly (insertion order, entries
added mid-iteration visited, deleted ones skipped, delete+re-add moves
to end) — the simulation relies on this order for determinism, and the
unchanged hash confirms it.
- `Player.borderTiles()` now returns `ReadonlyTileSet` (a native `Set`
still satisfies it structurally); `GameRunner.playerBorderTiles` copies
into a real `Set` since that result crosses the worker boundary via
structured clone.
## Footprint curve (giant world map, live MB after forced GC)
| checkpoint | before | after |
|---|---|---|
| spawn end | 20 + 100 buf | 20 + 55 buf |
| tick 6301 | 119 + 161 buf | 29 + 127 buf |
| tick 12301 | 130 + 161 buf | 32 + 129 buf |
## Validation
- Final hash `57830793797434300` identical across baseline / round 1 /
round 2 runs (12 000 ticks).
- Full suite passes (1798 + 126 tests), including new `TileSet` tests:
order semantics, mutation-during-iteration parity with `Set`, tombstone
compaction, and a 20 000-op randomized differential test against native
`Set`.
- Runs recorded in
`tests/perf/output/footprint-{baseline,round1,round2}-giant.txt`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -275,7 +275,9 @@ export class GameRunner {
|
||||
throw new Error(`player with id ${playerID} not found`);
|
||||
}
|
||||
return {
|
||||
borderTiles: player.borderTiles(),
|
||||
// Copy into a plain Set: this result crosses the worker boundary via
|
||||
// structured clone, which TileSet does not survive.
|
||||
borderTiles: new Set(player.borderTiles()),
|
||||
} as PlayerBorderTiles;
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -2,6 +2,7 @@ import DOMPurify from "dompurify";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { Cell, PlayerType, Unit } from "./game/Game";
|
||||
import { GameMap, TileRef } from "./game/GameMap";
|
||||
import { TileSet } from "./game/TileSet";
|
||||
import {
|
||||
GameConfig,
|
||||
GameID,
|
||||
@@ -148,7 +149,7 @@ export function calculateBoundingBox(
|
||||
for (let i = 0; i < borderTiles.length; i++) {
|
||||
visit(borderTiles[i]);
|
||||
}
|
||||
} else if (borderTiles instanceof Set) {
|
||||
} else if (borderTiles instanceof Set || borderTiles instanceof TileSet) {
|
||||
borderTiles.forEach(visit);
|
||||
} else {
|
||||
for (const tile of borderTiles) {
|
||||
@@ -213,7 +214,7 @@ export function getMode<T>(counts: Map<T, number>): T | null {
|
||||
|
||||
export function calculateBoundingBoxCenter(
|
||||
gm: GameMap,
|
||||
borderTiles: ReadonlySet<TileRef>,
|
||||
borderTiles: Iterable<TileRef>,
|
||||
): Cell {
|
||||
const { min, max } = calculateBoundingBox(gm, borderTiles);
|
||||
return boundingBoxCenter({ min, max });
|
||||
|
||||
@@ -8,18 +8,13 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import {
|
||||
bumpTraversalGeneration,
|
||||
tileTraversalScratch,
|
||||
TileTraversalScratch,
|
||||
} from "../game/TileTraversalScratch";
|
||||
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
|
||||
|
||||
interface ClusterTraversalState {
|
||||
visited: Uint32Array;
|
||||
gen: number;
|
||||
// Reusable DFS stack for flood fills; cleared at the start of each fill.
|
||||
stack: TileRef[];
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -370,28 +365,12 @@ export class PlayerExecution implements Execution {
|
||||
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,
|
||||
stack: [],
|
||||
};
|
||||
traversalStates.set(this.mg, state);
|
||||
}
|
||||
return state;
|
||||
private traversalState(): TileTraversalScratch {
|
||||
return tileTraversalScratch(this.mg);
|
||||
}
|
||||
|
||||
private bumpGeneration(): number {
|
||||
const state = this.traversalState();
|
||||
state.gen++;
|
||||
if (state.gen === 0xffffffff) {
|
||||
state.visited.fill(0);
|
||||
state.gen = 1;
|
||||
}
|
||||
return state.gen;
|
||||
return bumpTraversalGeneration(this.traversalState());
|
||||
}
|
||||
|
||||
private floodFillWithGen(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { MotionPlanRecord } from "./MotionPlans";
|
||||
import { RailNetwork } from "./RailNetwork";
|
||||
import { Stats } from "./Stats";
|
||||
import { ReadonlyTileSet } from "./TileSet";
|
||||
import { UnitPredicate } from "./UnitGrid";
|
||||
|
||||
function isEnumValue<T extends Record<string, string | number>>(
|
||||
@@ -569,7 +570,7 @@ export interface Player {
|
||||
|
||||
// Territory
|
||||
tiles(): ReadonlySet<TileRef>;
|
||||
borderTiles(): ReadonlySet<TileRef>;
|
||||
borderTiles(): ReadonlyTileSet;
|
||||
numTilesOwned(): number;
|
||||
conquer(tile: TileRef): void;
|
||||
relinquish(tile: TileRef): void;
|
||||
|
||||
+16
-29
@@ -111,15 +111,11 @@ export class GameMapImpl implements GameMap {
|
||||
private readonly width_: number;
|
||||
private readonly height_: number;
|
||||
|
||||
// Lookup tables (LUTs) contain pre-computed values to avoid performing division at runtime.
|
||||
// Typed arrays are used instead of plain JS Array to keep memory tight on large maps:
|
||||
// Uint16Array uses 2 bytes/element vs ~8 bytes for a boxed number, saving ~53 MB on
|
||||
// the Indian Subcontinent map (2000×2220 = 4.44 M tiles).
|
||||
// Coordinates never exceed 65535 for any map in the game, so Uint16 is safe for x/y.
|
||||
// yToRef stores tile refs (up to width*height-1) which can exceed 65535 for large maps,
|
||||
// so it uses Int32Array.
|
||||
private readonly refToX: Uint16Array;
|
||||
private readonly refToY: Uint16Array;
|
||||
// Row-start ref per y, so ref(x, y) avoids a multiply. x/y are derived from
|
||||
// a ref arithmetically (ref % width, ref / width) rather than via per-tile
|
||||
// lookup tables — two Uint16 tables cost 4 bytes per tile (~32 MB on the
|
||||
// largest maps) and their random-access reads miss cache more often than
|
||||
// the division costs.
|
||||
private readonly yToRef: Int32Array;
|
||||
|
||||
// Terrain bits (Uint8Array)
|
||||
@@ -154,18 +150,9 @@ export class GameMapImpl implements GameMap {
|
||||
this.height_ = height;
|
||||
this.terrain = terrainData;
|
||||
this.state = new Uint16Array(width * height);
|
||||
// Precompute the LUTs using typed arrays (see field declarations for rationale).
|
||||
let ref = 0;
|
||||
this.refToX = new Uint16Array(width * height);
|
||||
this.refToY = new Uint16Array(width * height);
|
||||
this.yToRef = new Int32Array(height);
|
||||
for (let y = 0; y < height; y++) {
|
||||
this.yToRef[y] = ref;
|
||||
for (let x = 0; x < width; x++) {
|
||||
this.refToX[ref] = x;
|
||||
this.refToY[ref] = y;
|
||||
ref++;
|
||||
}
|
||||
this.yToRef[y] = y * width;
|
||||
}
|
||||
}
|
||||
numTilesWithFallout(): number {
|
||||
@@ -180,15 +167,15 @@ export class GameMapImpl implements GameMap {
|
||||
}
|
||||
|
||||
isValidRef(ref: TileRef): boolean {
|
||||
return ref >= 0 && ref < this.refToX.length;
|
||||
return ref >= 0 && ref < this.width_ * this.height_;
|
||||
}
|
||||
|
||||
x(ref: TileRef): number {
|
||||
return this.refToX[ref];
|
||||
return ref % this.width_;
|
||||
}
|
||||
|
||||
y(ref: TileRef): number {
|
||||
return this.refToY[ref];
|
||||
return (ref / this.width_) | 0;
|
||||
}
|
||||
|
||||
cell(ref: TileRef): Cell {
|
||||
@@ -234,7 +221,7 @@ export class GameMapImpl implements GameMap {
|
||||
return false;
|
||||
}
|
||||
const w = this.width_;
|
||||
const x = this.refToX[ref];
|
||||
const x = ref % w;
|
||||
if (x !== 0 && this.isOcean(ref - 1)) return true;
|
||||
if (x !== w - 1 && this.isOcean(ref + 1)) return true;
|
||||
if (ref >= w && this.isOcean(ref - w)) return true;
|
||||
@@ -330,7 +317,7 @@ export class GameMapImpl implements GameMap {
|
||||
|
||||
isBorder(ref: TileRef): boolean {
|
||||
const w = this.width_;
|
||||
const x = this.refToX[ref];
|
||||
const x = ref % w;
|
||||
const owner = this.ownerID(ref);
|
||||
if (x !== 0 && this.ownerID(ref - 1) !== owner) return true;
|
||||
if (x !== w - 1 && this.ownerID(ref + 1) !== owner) return true;
|
||||
@@ -383,7 +370,7 @@ export class GameMapImpl implements GameMap {
|
||||
neighbors(ref: TileRef): TileRef[] {
|
||||
const neighbors: TileRef[] = [];
|
||||
const w = this.width_;
|
||||
const x = this.refToX[ref];
|
||||
const x = ref % w;
|
||||
|
||||
if (ref >= w) neighbors.push(ref - w);
|
||||
if (ref < (this.height_ - 1) * w) neighbors.push(ref + w);
|
||||
@@ -395,7 +382,7 @@ export class GameMapImpl implements GameMap {
|
||||
|
||||
forEachNeighbor(ref: TileRef, callback: (neighbor: TileRef) => void): void {
|
||||
const w = this.width_;
|
||||
const x = this.refToX[ref];
|
||||
const x = ref % w;
|
||||
|
||||
if (ref >= w) callback(ref - w);
|
||||
if (ref < (this.height_ - 1) * w) callback(ref + w);
|
||||
@@ -405,7 +392,7 @@ export class GameMapImpl implements GameMap {
|
||||
|
||||
neighbors4(ref: TileRef, out: TileRef[]): number {
|
||||
const w = this.width_;
|
||||
const x = this.refToX[ref];
|
||||
const x = ref % w;
|
||||
let n = 0;
|
||||
|
||||
if (ref >= w) out[n++] = ref - w;
|
||||
@@ -420,7 +407,7 @@ export class GameMapImpl implements GameMap {
|
||||
callback: (neighbor: TileRef) => void,
|
||||
): void {
|
||||
const w = this.width_;
|
||||
const x = this.refToX[ref];
|
||||
const x = ref % w;
|
||||
const hasN = ref >= w;
|
||||
const hasS = ref < (this.height_ - 1) * w;
|
||||
|
||||
@@ -501,7 +488,7 @@ export class GameMapImpl implements GameMap {
|
||||
while (q.length > 0) {
|
||||
const curr = q.pop();
|
||||
if (curr === undefined) continue;
|
||||
const x = this.refToX[curr];
|
||||
const x = curr % w;
|
||||
if (curr >= w) visit(curr - w);
|
||||
if (curr < southLimit) visit(curr + w);
|
||||
if (x !== 0) visit(curr - 1);
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
GameUpdateType,
|
||||
PlayerUpdate,
|
||||
} from "./GameUpdates";
|
||||
import { ReadonlyTileSet, TileSet } from "./TileSet";
|
||||
import {
|
||||
bestShoreDeploymentSource,
|
||||
canBuildTransportShip,
|
||||
@@ -111,10 +112,10 @@ export class PlayerImpl implements Player {
|
||||
|
||||
private embargoes = new Map<PlayerID, Embargo>();
|
||||
|
||||
public _borderTiles: Set<TileRef> = new Set();
|
||||
public _borderTiles = new TileSet();
|
||||
|
||||
public _units: Unit[] = [];
|
||||
public _tiles: Set<TileRef> = new Set();
|
||||
public _tiles = new TileSet();
|
||||
|
||||
public pastOutgoingAllianceRequests: AllianceRequest[] = [];
|
||||
private _expiredAlliances: Alliance[] = [];
|
||||
@@ -479,7 +480,7 @@ export class PlayerImpl implements Player {
|
||||
return new Set(this._tiles.values()) as Set<TileRef>;
|
||||
}
|
||||
|
||||
borderTiles(): ReadonlySet<TileRef> {
|
||||
borderTiles(): ReadonlyTileSet {
|
||||
return this._borderTiles;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { TileRef } from "./GameMap";
|
||||
|
||||
// Deleted dense slots hold this sentinel. Tile refs are grid indices and map
|
||||
// coordinates are capped at 65535, so the largest possible ref is
|
||||
// 65535 * 65535 - 1, which is below 2^32 - 1 — the sentinel can never be a
|
||||
// real tile.
|
||||
const TOMBSTONE = 0xffffffff;
|
||||
// Hash-table slot states (slots otherwise hold indices into `dense`).
|
||||
const EMPTY = -1;
|
||||
const DELETED = -2;
|
||||
|
||||
/**
|
||||
* The read surface of TileSet, mirroring the parts of ReadonlySet that
|
||||
* simulation code uses. A native Set<TileRef> also satisfies this interface.
|
||||
*/
|
||||
export interface ReadonlyTileSet {
|
||||
readonly size: number;
|
||||
has(tile: TileRef): boolean;
|
||||
forEach(
|
||||
callback: (tile: TileRef, tile2: TileRef, set: ReadonlyTileSet) => void,
|
||||
): void;
|
||||
values(): IterableIterator<TileRef>;
|
||||
[Symbol.iterator](): IterableIterator<TileRef>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An insertion-ordered set of tile refs with compact storage: values live in
|
||||
* a Uint32Array in insertion order, with an open-addressing hash table (also
|
||||
* a typed array) for membership. Compared to Set<TileRef> at V8's ~30+ bytes
|
||||
* per element this costs ~12 bytes, which matters because every owned tile of
|
||||
* every player sits in one of these for the whole game — tens of MB on large
|
||||
* maps.
|
||||
*
|
||||
* Iteration semantics match Set: insertion order, entries added during
|
||||
* iteration are visited, entries deleted during iteration are skipped, and a
|
||||
* delete + re-add moves the value to the end. Deleted slots are tombstoned
|
||||
* and reclaimed by compaction, which is deferred while any iteration is in
|
||||
* progress so positions never shift under an iterator.
|
||||
*/
|
||||
export class TileSet implements ReadonlyTileSet {
|
||||
private dense: Uint32Array = new Uint32Array(16);
|
||||
// Used dense slots, including tombstones; live entries = size_.
|
||||
private denseLen = 0;
|
||||
private size_ = 0;
|
||||
private table: Int32Array = new Int32Array(32).fill(EMPTY);
|
||||
// Occupied table slots, including DELETED markers (bounds probe lengths).
|
||||
private tableUsed = 0;
|
||||
private iterDepth = 0;
|
||||
|
||||
constructor(values?: Iterable<TileRef>) {
|
||||
if (values !== undefined) {
|
||||
for (const v of values) {
|
||||
this.add(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.size_;
|
||||
}
|
||||
|
||||
private static hash(value: number): number {
|
||||
const h = Math.imul(value, 0x9e3779b1);
|
||||
return (h ^ (h >>> 15)) >>> 0;
|
||||
}
|
||||
|
||||
has(value: TileRef): boolean {
|
||||
const table = this.table;
|
||||
const dense = this.dense;
|
||||
const mask = table.length - 1;
|
||||
let slot = TileSet.hash(value) & mask;
|
||||
for (;;) {
|
||||
const di = table[slot];
|
||||
if (di === EMPTY) return false;
|
||||
if (di !== DELETED && dense[di] === value) return true;
|
||||
slot = (slot + 1) & mask;
|
||||
}
|
||||
}
|
||||
|
||||
add(value: TileRef): this {
|
||||
if (this.has(value)) return this;
|
||||
|
||||
if (this.denseLen === this.dense.length) {
|
||||
// Prefer reclaiming tombstones over growing, unless an iterator is
|
||||
// live (compaction shifts positions).
|
||||
if (this.iterDepth === 0 && this.denseLen - this.size_ >= this.size_) {
|
||||
this.compact(this.dense.length);
|
||||
} else {
|
||||
const grown = new Uint32Array(this.dense.length * 2);
|
||||
grown.set(this.dense);
|
||||
this.dense = grown;
|
||||
}
|
||||
}
|
||||
// Keep the table under ~75% occupied so probe chains stay short and
|
||||
// always hit an EMPTY slot.
|
||||
if ((this.tableUsed + 1) * 4 > this.table.length * 3) {
|
||||
this.rehash(
|
||||
this.size_ * 4 > this.table.length
|
||||
? this.table.length * 2
|
||||
: this.table.length, // mostly DELETED markers — same size, cleaned
|
||||
);
|
||||
}
|
||||
|
||||
const di = this.denseLen++;
|
||||
this.dense[di] = value;
|
||||
this.size_++;
|
||||
const table = this.table;
|
||||
const mask = table.length - 1;
|
||||
let slot = TileSet.hash(value) & mask;
|
||||
while (table[slot] >= 0) {
|
||||
slot = (slot + 1) & mask;
|
||||
}
|
||||
if (table[slot] === EMPTY) this.tableUsed++;
|
||||
table[slot] = di;
|
||||
return this;
|
||||
}
|
||||
|
||||
delete(value: TileRef): boolean {
|
||||
const table = this.table;
|
||||
const dense = this.dense;
|
||||
const mask = table.length - 1;
|
||||
let slot = TileSet.hash(value) & mask;
|
||||
for (;;) {
|
||||
const di = table[slot];
|
||||
if (di === EMPTY) return false;
|
||||
if (di !== DELETED && dense[di] === value) {
|
||||
table[slot] = DELETED;
|
||||
dense[di] = TOMBSTONE;
|
||||
this.size_--;
|
||||
// Mostly tombstones? Compact so long-dead players don't pin memory.
|
||||
if (
|
||||
this.iterDepth === 0 &&
|
||||
this.denseLen >= 64 &&
|
||||
this.denseLen - this.size_ > this.size_ * 2
|
||||
) {
|
||||
this.compact(nextCapacity(this.size_));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
slot = (slot + 1) & mask;
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.dense = new Uint32Array(16);
|
||||
this.denseLen = 0;
|
||||
this.size_ = 0;
|
||||
this.table = new Int32Array(32).fill(EMPTY);
|
||||
this.tableUsed = 0;
|
||||
}
|
||||
|
||||
forEach(
|
||||
callback: (tile: TileRef, tile2: TileRef, set: ReadonlyTileSet) => void,
|
||||
): void {
|
||||
this.iterDepth++;
|
||||
try {
|
||||
// denseLen and dense are re-read every step: entries appended during
|
||||
// iteration must be visited, and an append can swap in a grown buffer.
|
||||
for (let i = 0; i < this.denseLen; i++) {
|
||||
const v = this.dense[i];
|
||||
if (v !== TOMBSTONE) callback(v, v, this);
|
||||
}
|
||||
} finally {
|
||||
this.iterDepth--;
|
||||
}
|
||||
}
|
||||
|
||||
*values(): IterableIterator<TileRef> {
|
||||
this.iterDepth++;
|
||||
try {
|
||||
for (let i = 0; i < this.denseLen; i++) {
|
||||
const v = this.dense[i];
|
||||
if (v !== TOMBSTONE) yield v;
|
||||
}
|
||||
} finally {
|
||||
this.iterDepth--;
|
||||
}
|
||||
}
|
||||
|
||||
[Symbol.iterator](): IterableIterator<TileRef> {
|
||||
return this.values();
|
||||
}
|
||||
|
||||
/** Rewrites dense storage without tombstones, preserving insertion order. */
|
||||
private compact(capacity: number): void {
|
||||
const compacted = new Uint32Array(Math.max(capacity, 16));
|
||||
let n = 0;
|
||||
for (let i = 0; i < this.denseLen; i++) {
|
||||
const v = this.dense[i];
|
||||
if (v !== TOMBSTONE) compacted[n++] = v;
|
||||
}
|
||||
this.dense = compacted;
|
||||
this.denseLen = n;
|
||||
this.rehash(Math.max(nextCapacity(n * 2), 32));
|
||||
}
|
||||
|
||||
private rehash(tableLength: number): void {
|
||||
const table = new Int32Array(tableLength).fill(EMPTY);
|
||||
const mask = tableLength - 1;
|
||||
const dense = this.dense;
|
||||
for (let di = 0; di < this.denseLen; di++) {
|
||||
if (dense[di] === TOMBSTONE) continue;
|
||||
let slot = TileSet.hash(dense[di]) & mask;
|
||||
while (table[slot] !== EMPTY) {
|
||||
slot = (slot + 1) & mask;
|
||||
}
|
||||
table[slot] = di;
|
||||
}
|
||||
this.table = table;
|
||||
this.tableUsed = this.size_;
|
||||
}
|
||||
}
|
||||
|
||||
/** Smallest power of two >= n (and >= 16). */
|
||||
function nextCapacity(n: number): number {
|
||||
let cap = 16;
|
||||
while (cap < n) cap *= 2;
|
||||
return cap;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Game } from "./Game";
|
||||
import { TileRef } from "./GameMap";
|
||||
|
||||
/**
|
||||
* Shared per-game traversal scratch: a generation-stamped visited array (one
|
||||
* slot per tile) plus a reusable stack, so BFS/DFS passes over the map
|
||||
* allocate nothing per query. A single scratch is shared by all traversal
|
||||
* users of a game — the visited array alone is ~32 MB on the largest maps,
|
||||
* so each user keeping its own would multiply that cost.
|
||||
*
|
||||
* Usage contract: call bumpTraversalGeneration() at the start of a traversal
|
||||
* pass and treat visited[t] === gen as "seen this pass". A pass must run to
|
||||
* completion synchronously — starting another pass (by any user) invalidates
|
||||
* the previous generation's marks. The simulation is single-threaded and no
|
||||
* current traversal triggers another mid-pass.
|
||||
*/
|
||||
export interface TileTraversalScratch {
|
||||
visited: Uint32Array;
|
||||
stack: TileRef[];
|
||||
/** Current generation — advance via bumpTraversalGeneration(), not directly. */
|
||||
gen: number;
|
||||
}
|
||||
|
||||
const scratches = new WeakMap<Game, TileTraversalScratch>();
|
||||
|
||||
export function tileTraversalScratch(game: Game): TileTraversalScratch {
|
||||
const totalTiles = game.width() * game.height();
|
||||
let scratch = scratches.get(game);
|
||||
if (!scratch || scratch.visited.length < totalTiles) {
|
||||
scratch = { visited: new Uint32Array(totalTiles), stack: [], gen: 0 };
|
||||
scratches.set(game, scratch);
|
||||
}
|
||||
return scratch;
|
||||
}
|
||||
|
||||
/** Starts a new traversal pass and returns its generation stamp. */
|
||||
export function bumpTraversalGeneration(scratch: TileTraversalScratch): number {
|
||||
scratch.gen++;
|
||||
if (scratch.gen === 0xffffffff) {
|
||||
scratch.visited.fill(0);
|
||||
scratch.gen = 1;
|
||||
}
|
||||
return scratch.gen;
|
||||
}
|
||||
@@ -18,7 +18,10 @@ export interface StepperConfig<T> {
|
||||
* Generic over any PathFinder<T> implementation.
|
||||
*/
|
||||
export class PathFinderStepper<T> implements SteppingPathFinder<T> {
|
||||
private path: T[] | null = null;
|
||||
// Numeric paths (TileRefs) are stored as a Uint32Array: steppers hold their
|
||||
// whole path for the unit's entire journey, and paths across large maps run
|
||||
// to thousands of nodes, so halving the per-node size matters in aggregate.
|
||||
private path: T[] | Uint32Array | null = null;
|
||||
private pathIndex = 0;
|
||||
private lastTo: T | null = null;
|
||||
|
||||
@@ -58,24 +61,29 @@ export class PathFinderStepper<T> implements SteppingPathFinder<T> {
|
||||
|
||||
// Compute path if not cached
|
||||
if (this.path === null) {
|
||||
let path: T[] | null;
|
||||
try {
|
||||
this.path = this.finder.findPath(from, to);
|
||||
path = this.finder.findPath(from, to);
|
||||
} catch (err) {
|
||||
console.error("PathFinder threw an error during findPath", err);
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
|
||||
if (this.path === null) {
|
||||
if (path === null) {
|
||||
return { status: PathStatus.NOT_FOUND };
|
||||
}
|
||||
|
||||
this.path =
|
||||
path.length > 0 && typeof path[0] === "number"
|
||||
? new Uint32Array(path as number[])
|
||||
: path;
|
||||
this.pathIndex = 0;
|
||||
if (this.path.length > 0 && this.config.equals(this.path[0], from)) {
|
||||
if (path.length > 0 && this.config.equals(path[0], from)) {
|
||||
this.pathIndex = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const expectedPos = this.path[this.pathIndex - 1];
|
||||
const expectedPos = this.path[this.pathIndex - 1] as T;
|
||||
if (this.pathIndex > 0 && !this.config.equals(from, expectedPos)) {
|
||||
this.invalidate();
|
||||
this.lastTo = to;
|
||||
@@ -88,7 +96,7 @@ export class PathFinderStepper<T> implements SteppingPathFinder<T> {
|
||||
}
|
||||
|
||||
// Return next step
|
||||
const nextNode = this.path[this.pathIndex];
|
||||
const nextNode = this.path[this.pathIndex] as T;
|
||||
this.pathIndex++;
|
||||
|
||||
return { status: PathStatus.NEXT, node: nextNode };
|
||||
|
||||
@@ -15,7 +15,9 @@ export class ConnectedComponents {
|
||||
private readonly height: number;
|
||||
private readonly numTiles: number;
|
||||
private readonly lastRowStart: number;
|
||||
private readonly queue: Int32Array;
|
||||
// Flood-fill work queue; exists only while initialize() runs — a
|
||||
// numTiles-sized Int32Array is ~8 MB per instance on large maps.
|
||||
private queue: Int32Array | null = null;
|
||||
private componentIds: Uint8Array | Uint16Array | null = null;
|
||||
private _componentSizes: number[] = [];
|
||||
|
||||
@@ -27,11 +29,11 @@ export class ConnectedComponents {
|
||||
this.height = map.height();
|
||||
this.numTiles = this.width * this.height;
|
||||
this.lastRowStart = (this.height - 1) * this.width;
|
||||
this.queue = new Int32Array(this.numTiles);
|
||||
}
|
||||
|
||||
initialize(): void {
|
||||
DebugSpan.start("ConnectedComponents:initialize");
|
||||
this.queue = new Int32Array(this.numTiles);
|
||||
let ids: Uint8Array | Uint16Array = this.createPrefilledIds();
|
||||
|
||||
this._componentSizes = [];
|
||||
@@ -64,6 +66,7 @@ export class ConnectedComponents {
|
||||
}
|
||||
|
||||
this.componentIds = ids;
|
||||
this.queue = null;
|
||||
DebugSpan.end();
|
||||
}
|
||||
|
||||
@@ -148,12 +151,13 @@ export class ConnectedComponents {
|
||||
start: number,
|
||||
componentId: number,
|
||||
): void {
|
||||
const queue = this.queue!;
|
||||
let head = 0;
|
||||
let tail = 0;
|
||||
this.queue[tail++] = start;
|
||||
queue[tail++] = start;
|
||||
|
||||
while (head < tail) {
|
||||
const seed = this.queue[head++]!;
|
||||
const seed = queue[head++]!;
|
||||
|
||||
// Skip if already processed
|
||||
if (ids[seed] !== 0) continue;
|
||||
@@ -184,7 +188,7 @@ export class ConnectedComponents {
|
||||
if (x >= this.width) {
|
||||
const above = x - this.width;
|
||||
if (ids[above] === 0) {
|
||||
this.queue[tail++] = above;
|
||||
queue[tail++] = above;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +196,7 @@ export class ConnectedComponents {
|
||||
if (x < this.lastRowStart) {
|
||||
const below = x + this.width;
|
||||
if (ids[below] === 0) {
|
||||
this.queue[tail++] = below;
|
||||
queue[tail++] = below;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Game, Player, TerraNullius } from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import {
|
||||
bumpTraversalGeneration,
|
||||
tileTraversalScratch,
|
||||
} from "../../game/TileTraversalScratch";
|
||||
import { DebugSpan } from "../../utilities/DebugSpan";
|
||||
import { PathFinding } from "../PathFinder";
|
||||
import { AStarWaterBounded } from "../algorithms/AStar.WaterBounded";
|
||||
@@ -8,16 +12,6 @@ type Owner = Player | TerraNullius;
|
||||
|
||||
const REFINE_MAX_SEARCH_AREA = 100 * 100;
|
||||
|
||||
// Per-game BFS scratch (generation-stamped visited array + reusable stack) so
|
||||
// bfsNearest allocates nothing per query. Keyed by game because SpatialQuery
|
||||
// instances are created per call site.
|
||||
interface BfsScratch {
|
||||
visited: Uint32Array;
|
||||
gen: number;
|
||||
stack: TileRef[];
|
||||
}
|
||||
const bfsScratches = new WeakMap<Game, BfsScratch>();
|
||||
|
||||
export class SpatialQuery {
|
||||
private boundedAStar: AStarWaterBounded | null = null;
|
||||
|
||||
@@ -36,30 +30,14 @@ export class SpatialQuery {
|
||||
* Find nearest tile matching predicate using BFS traversal.
|
||||
* Uses Manhattan distance filter, ignores terrain barriers.
|
||||
*/
|
||||
private bfsScratch(): BfsScratch {
|
||||
const map = this.game.map();
|
||||
const totalTiles = map.width() * map.height();
|
||||
let s = bfsScratches.get(this.game);
|
||||
if (!s || s.visited.length < totalTiles) {
|
||||
s = { visited: new Uint32Array(totalTiles), gen: 0, stack: [] };
|
||||
bfsScratches.set(this.game, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private bfsNearest(
|
||||
from: TileRef,
|
||||
maxDist: number,
|
||||
predicate: (t: TileRef) => boolean,
|
||||
): TileRef | null {
|
||||
const map = this.game.map();
|
||||
const scratch = this.bfsScratch();
|
||||
scratch.gen++;
|
||||
if (scratch.gen === 0xffffffff) {
|
||||
scratch.visited.fill(0);
|
||||
scratch.gen = 1;
|
||||
}
|
||||
const gen = scratch.gen;
|
||||
const scratch = tileTraversalScratch(this.game);
|
||||
const gen = bumpTraversalGeneration(scratch);
|
||||
const visited = scratch.visited;
|
||||
const stack = scratch.stack;
|
||||
stack.length = 0;
|
||||
|
||||
Reference in New Issue
Block a user