mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-11 20:18:07 +00:00
7fa81c6bb9
## 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>
314 lines
9.6 KiB
TypeScript
314 lines
9.6 KiB
TypeScript
import { placeName, placeSpawnName } from "../client/hud/NameBoxCalculator";
|
|
import { Config } from "./configuration/Config";
|
|
import { DoomsdayClockExecution } from "./execution/DoomsdayClockExecution";
|
|
import { Executor } from "./execution/ExecutionManager";
|
|
import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterExecution";
|
|
import { SpawnTimerExecution } from "./execution/SpawnTimerExecution";
|
|
import { WinCheckExecution } from "./execution/WinCheckExecution";
|
|
import {
|
|
AllPlayers,
|
|
BuildableUnit,
|
|
Game,
|
|
GameType,
|
|
GameUpdates,
|
|
NameViewData,
|
|
Player,
|
|
PlayerActions,
|
|
PlayerBorderTiles,
|
|
PlayerBuildableUnitType,
|
|
PlayerID,
|
|
PlayerInfo,
|
|
PlayerProfile,
|
|
PlayerType,
|
|
UnitType,
|
|
} from "./game/Game";
|
|
import { createGame } from "./game/GameImpl";
|
|
import { TileRef } from "./game/GameMap";
|
|
import { GameMapLoader } from "./game/GameMapLoader";
|
|
import { ErrorUpdate, GameUpdateViewData } from "./game/GameUpdates";
|
|
import { createNationsForGame } from "./game/NationCreation";
|
|
import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
|
|
import { PseudoRandom } from "./PseudoRandom";
|
|
import { ClientID, GameStartInfo, Turn } from "./Schemas";
|
|
import { simpleHash } from "./Util";
|
|
|
|
export async function createGameRunner(
|
|
gameStart: GameStartInfo,
|
|
clientID: ClientID | undefined,
|
|
mapLoader: GameMapLoader,
|
|
callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
|
|
): Promise<GameRunner> {
|
|
const config = new Config(gameStart.config, null, false);
|
|
const gameMap = await loadGameMap(
|
|
gameStart.config.gameMap,
|
|
gameStart.config.gameMapSize,
|
|
mapLoader,
|
|
);
|
|
const random = new PseudoRandom(simpleHash(gameStart.gameID));
|
|
|
|
const humans = gameStart.players.map((p) => {
|
|
return new PlayerInfo(
|
|
p.username,
|
|
PlayerType.Human,
|
|
p.clientID,
|
|
random.nextID(),
|
|
p.isLobbyCreator ?? false,
|
|
p.clanTag,
|
|
p.friends ?? [],
|
|
);
|
|
});
|
|
|
|
const nations = createNationsForGame(
|
|
gameStart,
|
|
gameMap.nations,
|
|
gameMap.additionalNations,
|
|
humans.length,
|
|
random,
|
|
);
|
|
|
|
const game: Game = createGame(
|
|
humans,
|
|
nations,
|
|
gameMap.gameMap,
|
|
gameMap.miniGameMap,
|
|
config,
|
|
gameMap.teamGameSpawnAreas,
|
|
);
|
|
|
|
const gr = new GameRunner(
|
|
game,
|
|
new Executor(game, gameStart.gameID, clientID),
|
|
callBack,
|
|
);
|
|
gr.init();
|
|
return gr;
|
|
}
|
|
|
|
export class GameRunner {
|
|
private turns: Turn[] = [];
|
|
private currTurn = 0;
|
|
private isExecuting = false;
|
|
|
|
private playerViewData: Record<PlayerID, NameViewData> = {};
|
|
|
|
constructor(
|
|
public game: Game,
|
|
private execManager: Executor,
|
|
private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
|
|
) {}
|
|
|
|
init() {
|
|
if (this.game.config().gameConfig().gameType !== GameType.Singleplayer) {
|
|
this.game.addExecution(new SpawnTimerExecution());
|
|
}
|
|
if (this.game.config().spawnNations()) {
|
|
this.game.addExecution(...this.execManager.nationExecutions());
|
|
}
|
|
if (this.game.config().isRandomSpawn()) {
|
|
this.game.addExecution(...this.execManager.spawnPlayers());
|
|
}
|
|
if (this.game.config().bots() > 0) {
|
|
this.game.addExecution(
|
|
...this.execManager.spawnTribes(this.game.config().bots()),
|
|
);
|
|
}
|
|
this.game.addExecution(new WinCheckExecution());
|
|
if (this.game.config().doomsdayClockConfig().enabled) {
|
|
this.game.addExecution(new DoomsdayClockExecution());
|
|
}
|
|
if (!this.game.config().isUnitDisabled(UnitType.Factory)) {
|
|
this.game.addExecution(
|
|
new RecomputeRailClusterExecution(this.game.railNetwork()),
|
|
);
|
|
}
|
|
}
|
|
|
|
public addTurn(turn: Turn): void {
|
|
this.turns.push(turn);
|
|
}
|
|
|
|
public executeNextTick(pendingTurns?: number): boolean {
|
|
if (this.isExecuting) {
|
|
return false;
|
|
}
|
|
if (this.currTurn >= this.turns.length) {
|
|
return false;
|
|
}
|
|
this.isExecuting = true;
|
|
|
|
this.game.addExecution(
|
|
...this.execManager.createExecs(this.turns[this.currTurn]),
|
|
);
|
|
this.currTurn++;
|
|
|
|
const wasInSpawnPhase = this.game.inSpawnPhase();
|
|
let updates: GameUpdates;
|
|
let tickExecutionDuration: number;
|
|
|
|
try {
|
|
const startTime = performance.now();
|
|
updates = this.game.executeNextTick();
|
|
const endTime = performance.now();
|
|
tickExecutionDuration = endTime - startTime;
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error) {
|
|
console.error("Game tick error:", error.message);
|
|
this.callBack({
|
|
errMsg: error.message,
|
|
stack: error.stack,
|
|
} as ErrorUpdate);
|
|
} else {
|
|
console.error("Game tick error:", error);
|
|
}
|
|
this.isExecuting = false;
|
|
return false;
|
|
}
|
|
|
|
// Track whether placements were recomputed this tick — the record is
|
|
// only attached to the update when it could have changed, so the main
|
|
// thread doesn't structured-clone an identical ~all-players record on
|
|
// every other tick.
|
|
let viewDataChanged = false;
|
|
if (this.game.inSpawnPhase()) {
|
|
for (const p of this.game.players()) {
|
|
if (p.type() !== PlayerType.Human && p.type() !== PlayerType.Nation) {
|
|
continue;
|
|
}
|
|
if (p.spawnTile() === undefined) continue;
|
|
this.playerViewData[p.id()] = placeSpawnName(this.game, p);
|
|
viewDataChanged = true;
|
|
}
|
|
}
|
|
|
|
const spawnJustEnded = wasInSpawnPhase && !this.game.inSpawnPhase();
|
|
if (
|
|
spawnJustEnded ||
|
|
this.game.ticks() < 3 ||
|
|
this.game.ticks() % 30 === 0
|
|
) {
|
|
for (const p of this.game.players()) {
|
|
this.playerViewData[p.id()] = placeName(this.game, p);
|
|
}
|
|
viewDataChanged = true;
|
|
}
|
|
|
|
const packedTileUpdates = this.game.drainPackedTileUpdates();
|
|
const packedMotionPlans = this.game.drainPackedMotionPlans();
|
|
const packedPlayerUpdates = this.game.drainPackedPlayerUpdates();
|
|
const packedAttackUpdates = this.game.drainPackedAttackUpdates();
|
|
|
|
this.callBack({
|
|
tick: this.game.ticks(),
|
|
packedTileUpdates,
|
|
...(packedMotionPlans ? { packedMotionPlans } : {}),
|
|
...(packedPlayerUpdates ? { packedPlayerUpdates } : {}),
|
|
...(packedAttackUpdates ? { packedAttackUpdates } : {}),
|
|
updates: updates,
|
|
...(viewDataChanged ? { playerNameViewData: this.playerViewData } : {}),
|
|
tickExecutionDuration: tickExecutionDuration,
|
|
pendingTurns: pendingTurns ?? 0,
|
|
});
|
|
this.isExecuting = false;
|
|
return true;
|
|
}
|
|
|
|
public pendingTurns(): number {
|
|
return Math.max(0, this.turns.length - this.currTurn);
|
|
}
|
|
|
|
public playerBuildables(
|
|
playerID: PlayerID,
|
|
x?: number,
|
|
y?: number,
|
|
units?: readonly PlayerBuildableUnitType[],
|
|
): BuildableUnit[] {
|
|
const player = this.game.player(playerID);
|
|
const tile =
|
|
x !== undefined && y !== undefined ? this.game.ref(x, y) : null;
|
|
return player.buildableUnits(tile, units);
|
|
}
|
|
|
|
public playerActions(
|
|
playerID: PlayerID,
|
|
x?: number,
|
|
y?: number,
|
|
units?: readonly PlayerBuildableUnitType[] | null,
|
|
): PlayerActions {
|
|
const player = this.game.player(playerID);
|
|
const tile =
|
|
x !== undefined && y !== undefined ? this.game.ref(x, y) : null;
|
|
const actions = {
|
|
canAttack: tile !== null && player.canAttack(tile),
|
|
buildableUnits: units === null ? [] : player.buildableUnits(tile, units),
|
|
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
|
|
canEmbargoAll: player.canEmbargoAll(),
|
|
} as PlayerActions;
|
|
|
|
if (tile !== null && this.game.hasOwner(tile)) {
|
|
const other = this.game.owner(tile) as Player;
|
|
actions.interaction = {
|
|
sharedBorder: player.sharesBorderWith(other),
|
|
canSendEmoji: player.canSendEmoji(other),
|
|
canTarget: player.canTarget(other),
|
|
canSendAllianceRequest: player.canSendAllianceRequest(other),
|
|
canBreakAlliance: player.isAlliedWith(other),
|
|
canDonateGold: player.canDonateGold(other),
|
|
canDonateTroops: player.canDonateTroops(other),
|
|
canEmbargo: !player.hasEmbargoAgainst(other),
|
|
allianceInfo: player.allianceInfo(other) ?? undefined,
|
|
};
|
|
}
|
|
|
|
return actions;
|
|
}
|
|
|
|
public playerProfile(playerID: number): PlayerProfile {
|
|
const player = this.game.playerBySmallID(playerID);
|
|
if (!player.isPlayer()) {
|
|
throw new Error(`player with id ${playerID} not found`);
|
|
}
|
|
return player.playerProfile();
|
|
}
|
|
public playerBorderTiles(playerID: PlayerID): PlayerBorderTiles {
|
|
const player = this.game.player(playerID);
|
|
if (!player.isPlayer()) {
|
|
throw new Error(`player with id ${playerID} not found`);
|
|
}
|
|
return {
|
|
// 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;
|
|
}
|
|
|
|
public attackClusteredPositions(
|
|
playerID: number,
|
|
attackID?: string,
|
|
): { id: string; positions: { x: number; y: number }[] }[] {
|
|
const player = this.game.playerBySmallID(playerID);
|
|
if (!player.isPlayer())
|
|
throw new Error(`player with id ${playerID} not found`);
|
|
const all = [...player.outgoingAttacks(), ...player.incomingAttacks()];
|
|
const attacks = attackID ? all.filter((a) => a.id() === attackID) : all;
|
|
|
|
return attacks.map((a) => ({
|
|
id: a.id(),
|
|
positions: a.clusteredPositions().map((tile) => ({
|
|
x: this.game.map().x(tile),
|
|
y: this.game.map().y(tile),
|
|
})),
|
|
}));
|
|
}
|
|
|
|
public bestTransportShipSpawn(
|
|
playerID: PlayerID,
|
|
targetTile: TileRef,
|
|
): TileRef | false {
|
|
const player = this.game.player(playerID);
|
|
if (!player.isPlayer()) {
|
|
throw new Error(`player with id ${playerID} not found`);
|
|
}
|
|
return player.bestTransportShipSpawn(targetTile);
|
|
}
|
|
}
|