mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 12:12:03 +00:00
9e9c608053
## Summary Round 2 of GC-churn reduction, attacking the next tier of allocation sources found by the profiling harness from #4494. All changes are **behavior-preserving** — the simulation is bit-identical (final hash unchanged on three seeded runs). ### Changes | Site | Change | Churn target | |---|---|---| | `Player.units()` / `Game.units()` | Rest parameter → fixed-arity + array overloads (`units()`, `units(types[])`, `units(t1, t2?, t3?)`). The rest array was allocated on **every call** of one of the hottest functions in the sim. Spread call sites (`units(...Structures.types)`) now pass the array directly. `GameImpl.units()` builds one flat array instead of `Array.from().flatMap()` per-player intermediates. | ~18 GB | | `PlayerExecution` cluster flood fill | Results are plain `TileRef[]` in mark order instead of `Set<TileRef>` — the generation-stamped visited array already deduplicates, and consumers only iterate/measure. DFS stack reused across fills. | ~3.7 GB | | `SpatialQuery.bfsNearest` | Fused generation-stamped BFS with per-game scratch buffers (`WeakMap`-keyed, same pattern as `PlayerExecution`) instead of materializing a `Set` of the entire search area per query. Identical traversal and tie-breaking. | ~2.2 GB | | `NationWarshipBehavior` ship tracking | Single-pass loops instead of `filter().forEach()`; dropped defensive `Array.from(set)` copies (deleting the current entry while iterating a `Set` is well-defined). | ~1.4 GB | ### Results (Giant World Map, 400 bots, 12,000 ticks ≈ 20 game-min, seed `perf-default`) | Metric | Before | After | vs. pre-#4494 | |---|---|---|---| | Sampled allocations (incl. collected) | 59.2 GB | **37.8 GB (−36%)** | 97.7 GB (**−61%**) | | GC count / total pause | 1,076 / 1,830 ms | 772 / 1,442 ms | 1,682 / 3,313 ms | | Ticks/sec | 73 | **82** | 66 (+24%) | | Mean / p99 tick | 13.6 / 39.2 ms | 12.2 / 36.0 ms | 15.2 / 49.9 ms | `units()` no longer appears in the top-30 allocator list at all. The remaining leaders (possible round 3): the minimap pathfinding `Cell` pipeline (~8.5 GB), `diffPlayerUpdate`/`toFullUpdate` per-tick serialization (~4.6 GB), and iterator allocations (~3.3 GB). ## Determinism Final game-state hash unchanged on all three reference runs: - Giant World Map 12,000 ticks: `57830793797434300` ✓ - Giant World Map 2,000 ticks: `55125379638382860` ✓ - World 1,800 ticks: `32337437717390864` ✓ ## Test plan - [x] Full suite green (1,905 tests), including updated `units()` semantics tests (array overload, snapshot isolation, insertion order) - [x] Hash equality on 3 seeded headless runs (2 maps) - [x] Before/after 20-min GC benchmarks on the same commit base 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
147 lines
4.6 KiB
TypeScript
147 lines
4.6 KiB
TypeScript
import {
|
|
Game,
|
|
Player,
|
|
PlayerInfo,
|
|
PlayerType,
|
|
UnitType,
|
|
} from "../src/core/game/Game";
|
|
import { setup } from "./util/Setup";
|
|
|
|
let game: Game;
|
|
let player: Player;
|
|
let other: Player;
|
|
|
|
describe("PlayerImpl", () => {
|
|
beforeEach(async () => {
|
|
game = await setup("plains", { instantBuild: true }, [
|
|
new PlayerInfo("player", PlayerType.Human, null, "player_id"),
|
|
new PlayerInfo("other", PlayerType.Human, null, "other_id"),
|
|
]);
|
|
|
|
player = game.player("player_id");
|
|
other = game.player("other_id");
|
|
|
|
player.conquer(game.ref(0, 0));
|
|
other.conquer(game.ref(50, 50));
|
|
player.addGold(BigInt(1000000));
|
|
|
|
game.config().structureMinDist = () => 10;
|
|
});
|
|
|
|
test("City can be upgraded", () => {
|
|
const city = player.buildUnit(UnitType.City, game.ref(0, 0), {});
|
|
const buCity = player
|
|
.buildableUnits(game.ref(0, 0))
|
|
.find((bu) => bu.type === UnitType.City);
|
|
expect(buCity).toBeDefined();
|
|
expect(buCity!.canUpgrade).toBe(city.id());
|
|
});
|
|
|
|
test("DefensePost cannot be upgraded", () => {
|
|
player.buildUnit(UnitType.DefensePost, game.ref(0, 0), {});
|
|
const buDefensePost = player
|
|
.buildableUnits(game.ref(0, 0))
|
|
.find((bu) => bu.type === UnitType.DefensePost);
|
|
expect(buDefensePost).toBeDefined();
|
|
expect(buDefensePost!.canUpgrade).toBeFalsy();
|
|
});
|
|
|
|
test("City can be upgraded from another city", () => {
|
|
const city = player.buildUnit(UnitType.City, game.ref(0, 0), {});
|
|
const cityToUpgrade = player.findUnitToUpgrade(
|
|
UnitType.City,
|
|
game.ref(0, 1),
|
|
);
|
|
expect(cityToUpgrade).toBeTruthy();
|
|
if (cityToUpgrade === false) {
|
|
return;
|
|
}
|
|
expect(cityToUpgrade.id()).toBe(city.id());
|
|
});
|
|
test("City cannot be upgraded when too far away", () => {
|
|
player.buildUnit(UnitType.City, game.ref(0, 0), {});
|
|
const cityToUpgrade = player.findUnitToUpgrade(
|
|
UnitType.City,
|
|
game.ref(50, 50),
|
|
);
|
|
expect(cityToUpgrade).toBe(false);
|
|
});
|
|
test("Unit cannot be upgraded when not enough gold", () => {
|
|
player.buildUnit(UnitType.City, game.ref(0, 0), {});
|
|
player.removeGold(BigInt(1000000));
|
|
const cityToUpgrade = player.findUnitToUpgrade(
|
|
UnitType.City,
|
|
game.ref(0, 1),
|
|
);
|
|
expect(cityToUpgrade).toBe(false);
|
|
});
|
|
|
|
describe("units() type filtering", () => {
|
|
beforeEach(() => {
|
|
player.buildUnit(UnitType.City, game.ref(0, 0), {});
|
|
player.buildUnit(UnitType.DefensePost, game.ref(11, 0), {});
|
|
player.buildUnit(UnitType.City, game.ref(0, 11), {});
|
|
player.buildUnit(UnitType.MissileSilo, game.ref(11, 11), {});
|
|
});
|
|
|
|
// Reference implementation: filter _units preserving insertion order.
|
|
function expected(...types: UnitType[]) {
|
|
const ts = new Set(types);
|
|
return player.units().filter((u) => ts.has(u.type()));
|
|
}
|
|
|
|
test("single type returns matching units in insertion order", () => {
|
|
expect(player.units(UnitType.City)).toEqual(expected(UnitType.City));
|
|
expect(player.units(UnitType.City)).toHaveLength(2);
|
|
});
|
|
|
|
test("returns a fresh array, not the internal or shared buffer", () => {
|
|
const a = player.units(UnitType.City);
|
|
const b = player.units(UnitType.City);
|
|
expect(a).not.toBe(b);
|
|
expect(a).not.toBe(player.units());
|
|
// Mutating one result must not affect a later query.
|
|
a.length = 0;
|
|
expect(player.units(UnitType.City)).toHaveLength(2);
|
|
});
|
|
|
|
test("two and three types return the union in insertion order", () => {
|
|
expect(player.units(UnitType.City, UnitType.MissileSilo)).toEqual(
|
|
expected(UnitType.City, UnitType.MissileSilo),
|
|
);
|
|
expect(
|
|
player.units(UnitType.City, UnitType.DefensePost, UnitType.MissileSilo),
|
|
).toEqual(
|
|
expected(UnitType.City, UnitType.DefensePost, UnitType.MissileSilo),
|
|
);
|
|
// Duplicate types don't duplicate results.
|
|
expect(player.units(UnitType.City, UnitType.City)).toEqual(
|
|
expected(UnitType.City),
|
|
);
|
|
});
|
|
|
|
test("array of types (Set path) and no match", () => {
|
|
expect(
|
|
player.units([
|
|
UnitType.City,
|
|
UnitType.DefensePost,
|
|
UnitType.MissileSilo,
|
|
UnitType.Port,
|
|
]),
|
|
).toEqual(
|
|
expected(UnitType.City, UnitType.DefensePost, UnitType.MissileSilo),
|
|
);
|
|
expect(player.units(UnitType.Port)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
test("Can't send alliance requests when dead", () => {
|
|
// conquer other
|
|
const otherTiles = other.tiles();
|
|
for (const tile of otherTiles) {
|
|
player.conquer(tile);
|
|
}
|
|
expect(other.canSendAllianceRequest(player)).toBe(false);
|
|
});
|
|
});
|