perf: cut core-sim GC churn another 36% (75% cumulative) (#4498)

## Summary

Round 3 of GC-churn reduction (follow-up to #4494 and #4496). All
changes are **behavior-preserving** — final game-state hash unchanged on
three seeded runs.

### Changes

| Site | Change | Churn target |
|---|---|---|
| `MiniMapTransformer` | Path upscaling works in pure numeric
coordinates and emits main-map `TileRef`s directly, replacing three
intermediate `Cell`-object arrays per path (cell path → scaled path →
smoothed path → final map). Identical arithmetic, so identical rounding
and identical tiles. | ~7.1 GB |
| `ShoreCoercingTransformer` | Reused neighbor buffers instead of
`neighbors()` arrays; no per-call `{water, original}` objects.
Tie-breaking preserved (helpers share the unified N,S,W,E order since
#4495). | ~1.5 GB |
| `diffPlayerUpdate` | Allocation-free all-equal fast path. Runs per
player per tick and usually returns `null` (gold/troops/tiles travel via
packed arrays), but previously allocated the diff object + a closure
first. Field list matches the diff exactly. | ~2.6 GB |
| Large-`Set` iteration | `for..of` over a `Set` allocates an
iterator-result object per element — significant on 100k-tile border
sets. `calculateClusters`, `calculateBoundingBox` (indexed fast path for
arrays too) and `getAttackFrontTiles` (also dropped its `neighbors()`
arrays) now use `Set.forEach`. | ~3.6 GB |

### Results (Giant World Map, 400 bots, 12,000 ticks, seed
`perf-default`)

| Metric | Before | After | vs. original (pre-#4494) |
|---|---|---|---|
| Sampled allocations (incl. collected) | 37.8 GB | **24.1 GB (−36%)** |
97.7 GB (**−75%**) |
| Ticks/sec | 82 | **88** | 66 (+33%) |
| Mean / p99 tick | 12.2 / 36.0 ms | 11.3 / 34.8 ms | 15.2 / 49.9 ms |
| Peak heap | 762 MB | 529 MB | 758 MB |

## Determinism

Final 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,906 tests; the `getAttackFrontTiles` test stub
gained a `neighbors4` implementation to match the real interface)
- [x] Hash equality on 3 seeded headless runs (2 maps)
- [x] Before/after 20-min GC benchmarks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-03 13:44:21 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 9e9c608053
commit 20c81ca5f6
7 changed files with 173 additions and 96 deletions
+37
View File
@@ -31,6 +31,43 @@ export function diffPlayerUpdate(
prev: PlayerUpdate,
next: PlayerUpdate,
): PlayerUpdate | null {
// Fast path: this runs for every player every tick and usually finds no
// changes (tilesOwned/gold/troops travel separately) — return without
// allocating anything. The comparisons repeat below only for the rare
// changed player.
if (
prev.clientID === next.clientID &&
prev.name === next.name &&
prev.displayName === next.displayName &&
prev.team === next.team &&
prev.smallID === next.smallID &&
prev.playerType === next.playerType &&
prev.isAlive === next.isAlive &&
prev.isDisconnected === next.isDisconnected &&
prev.isTraitor === next.isTraitor &&
prev.traitorRemainingTicks === next.traitorRemainingTicks &&
prev.inDoomsdayClock === next.inDoomsdayClock &&
prev.markedDoomsdayClockTick === next.markedDoomsdayClockTick &&
prev.hasSpawned === next.hasSpawned &&
prev.spawnTile === next.spawnTile &&
prev.betrayals === next.betrayals &&
prev.lastDeleteUnitTick === next.lastDeleteUnitTick &&
prev.isLobbyCreator === next.isLobbyCreator &&
numberArrayEqual(prev.allies, next.allies) &&
numberArrayEqual(prev.targets, next.targets) &&
stringArrayEqual(
prev.outgoingAllianceRequests,
next.outgoingAllianceRequests,
) &&
stringSetEqual(prev.embargoes, next.embargoes) &&
emojiArrayEqual(prev.outgoingEmojis, next.outgoingEmojis) &&
attackArrayMembershipEqual(prev.outgoingAttacks, next.outgoingAttacks) &&
attackArrayMembershipEqual(prev.incomingAttacks, next.incomingAttacks) &&
allianceArrayEqual(prev.alliances, next.alliances)
) {
return null;
}
const diff: PlayerUpdate = { type: GameUpdateType.Player, id: next.id };
let changed = false;