From 22c873cf556918da1f8b46698166356b902d5246 Mon Sep 17 00:00:00 2001 From: Evan Date: Sun, 5 Jul 2026 13:46:02 -0700 Subject: [PATCH] =?UTF-8?q?perf(client):=20tick-dispatch=20timing=20harnes?= =?UTF-8?q?s=20+=20main-thread=20tick=20optimizations=20(late-game=20p95?= =?UTF-8?q?=20=E2=88=9265%)=20(#4512)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Every 100 ms the main thread's worker `onmessage` callback processes a full game tick (`gameView.update` → `webglBuilder.update` → `renderer.tick`). At 60 fps this competes with the 16.7 ms frame budget, and on the Giant World Map it takes several ms — frame drops on low-end hardware. ## Harness (`npm run perf:client-tick`) Headless-Chromium harness that times every worker→main `game_update` dispatch on the main thread, with structured-clone deserialization measured separately from the handler body (via a `Worker.prototype.addEventListener` wrapper installed as a page init script — no product-code changes). It reports windowed distributions, captures `.cpuprofile` files at chosen ticks, writes raw samples and an end-of-run screenshot. `AnalyzeCpuProfile.ts` breaks a profile down by inclusive time under the dispatch subtree. Init scripts are passed as **strings**: tsx compiles function-form init scripts with esbuild `keepNames`, whose injected `__name` helper doesn't exist in-page and silently kills the game worker setup. ## Baseline (Giant World Map, 400 bots, headless) Dispatch handler ms — cost **grows with game progression**: | window | mean | p50 | p95 | max | |---|---|---|---|---| | tick 506 | 2.22 | 2.20 | 3.40 | 5.00 | | tick 1506 | 2.60 | 2.00 | 7.00 | 10.40 | | tick 2000 | 2.67 | 1.90 | **8.70** | **12.70** | Deserialization is negligible (0.12 ms mean). CPU profiles attributed the growing tail to the leaderboard's once-per-second refresh: its Max-troops column calls `config().maxTroops(p)` for **all ~508 players**, and `PlayerView.units()` scanned **every unit in the game** per call — O(players × units), growing as units accumulate. ## Round 1 — algorithmic fixes - **GameView**: new `unitsOwnedBy(smallID)` — an active-units-by-owner index built lazily at most once per tick. `PlayerView.units()` reads its own units from it: O(own units) instead of O(all units). Also speeds up unit display, player panel, and buildables queries. - **NamePass.updateNames**: reads player state directly from the caller's map by smallID instead of rebuilding three lookup maps per tick; skips the slot-assignment sweep once every player has a slot. After (same map, same spawn tile): | window | mean | p50 | p95 | max | |---|---|---|---|---| | tick 506 | 2.12 | 2.00 | 3.10 | 5.20 | | tick 1506 | 1.86 | 1.80 | 2.90 | 4.30 | | tick 2000 | 1.74 | 1.60 | **2.40** | **4.70** | Late-game p95 −65% (8.7 → 2.4 ms), worst dispatch −63% (12.7 → 4.7 ms), and per-dispatch cost no longer grows with game progression. The leaderboard disappeared from the dispatch profile entirely. ## Round 2 — allocation churn + time slicing Aimed at GC pauses and low-end CPUs; measures flat vs round 1 on a fast machine, as expected: - **`FrameData.changedTiles`** is now the plain tile-ref array GameView already builds instead of a per-tile `{ref, state}` object copy — heavy battle ticks allocated tens of thousands of objects per tick for a `state` field that was always 0. `TilePair` removed; `TerritoryPass` buckets refs synchronously, so the live reference is safe. - **`UnitView.lastPos`** is only re-sliced when a move actually appended a position — the unconditional `slice(-1)` allocated an identical 1-element array per unit per tick, including for structures that never move. - **`NamePass.updateNames`** refreshes slots round-robin, a quarter per tick — the full per-player diff pass spreads over ~400 ms, under the existing 500 ms troop-text cadence; positions lerp continuously. Unnamed slots and snap passes (seeks) are always processed so nothing pops in late. Dispatch share: 17% → 13%. Not sliced on purpose: tile ingest and frame upload need a consistent per-tick snapshot (stale `GameMap` reads would leak into hover queries, minimap, attack targeting) — a correctness risk not worth ~1 ms while the worst dispatch already fits in a quarter of the frame budget. ## Verification - `npx tsc --noEmit`, eslint clean; full suite green (1929 tests) - 6 new GameView tests cover the owner index (grouping, inactive exclusion, ownership capture, death, type filtering, copy semantics); changedTiles tests updated to the ref-array contract - Headless end-of-run screenshots verified after each round: leaderboard Max-troops values, map names + troop counts + flags all render correctly (including with name slicing active) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 --- package.json | 1 + src/client/render/frame/Upload.ts | 6 +- src/client/render/gl/MapRenderer.ts | 6 +- src/client/render/gl/Renderer.ts | 6 +- src/client/render/gl/index.ts | 1 - src/client/render/gl/passes/TerritoryPass.ts | 8 +- .../render/gl/passes/name-pass/index.ts | 146 ++--- src/client/render/types/FrameData.ts | 5 +- src/client/render/types/Renderer.ts | 5 - src/client/render/types/index.ts | 1 - src/client/view/GameView.ts | 61 +- src/client/view/PlayerView.ts | 8 +- tests/client/view/GameView.test.ts | 95 ++- tests/perf/client/AnalyzeCpuProfile.ts | 90 +++ tests/perf/client/ClientTickPerf.ts | 568 ++++++++++++++++++ 15 files changed, 893 insertions(+), 114 deletions(-) create mode 100644 tests/perf/client/AnalyzeCpuProfile.ts create mode 100644 tests/perf/client/ClientTickPerf.ts diff --git a/package.json b/package.json index 2030ad6af..5635c7245 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "perf:game": "npx tsx tests/perf/fullgame/FullGamePerf.ts", "perf:client": "npx tsx tests/perf/client/ClientUpdatePerf.ts", "perf:client-mem": "npx tsx tests/perf/client/ClientMemoryPerf.ts", + "perf:client-tick": "npx tsx tests/perf/client/ClientTickPerf.ts", "test:coverage": "vitest run --coverage", "format": "prettier --ignore-unknown --write .", "format:map-generator": "cd map-generator && go fmt .", diff --git a/src/client/render/frame/Upload.ts b/src/client/render/frame/Upload.ts index 85367510f..3545e7fc1 100644 --- a/src/client/render/frame/Upload.ts +++ b/src/client/render/frame/Upload.ts @@ -8,7 +8,6 @@ import type { NukeTelegraphData, PlayerState, PlayerStatusData, - TilePair, UnitState, } from "../types"; @@ -21,7 +20,10 @@ export interface FrameUploadTarget { tileState: Uint16Array, trailState: Uint16Array, ): void; - uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void; + uploadLiveDelta( + tileState: Uint16Array, + changedTiles: readonly number[], + ): void; uploadLiveTrailDelta( trailState: Uint16Array, dirtyRowMin: number, diff --git a/src/client/render/gl/MapRenderer.ts b/src/client/render/gl/MapRenderer.ts index 50495faaf..cf137291e 100644 --- a/src/client/render/gl/MapRenderer.ts +++ b/src/client/render/gl/MapRenderer.ts @@ -25,7 +25,6 @@ import type { PlayerStatic, PlayerStatusData, RendererConfig, - TilePair, UnitState, } from "../types"; import type { SpawnCenter } from "./passes/SpawnOverlayPass"; @@ -124,7 +123,10 @@ export class MapRenderer { // ---- Data upload ---- - uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void { + uploadLiveDelta( + tileState: Uint16Array, + changedTiles: readonly number[], + ): void { this.renderer?.uploadLiveDelta(tileState, changedTiles); } uploadLiveTrailDelta( diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index a2e94307c..0b93ee2fc 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -23,7 +23,6 @@ import type { PlayerStatic, PlayerStatusData, RendererConfig, - TilePair, UnitState, } from "../types"; import { Camera } from "./Camera"; @@ -653,7 +652,10 @@ export class GPURenderer { this.trailPass.setLiveRef(trailState); } - uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void { + uploadLiveDelta( + tileState: Uint16Array, + changedTiles: readonly number[], + ): void { this.territoryPass.applyLiveDelta(tileState, changedTiles); } diff --git a/src/client/render/gl/index.ts b/src/client/render/gl/index.ts index 4e03efb1a..34a520ee8 100644 --- a/src/client/render/gl/index.ts +++ b/src/client/render/gl/index.ts @@ -26,6 +26,5 @@ export type { PlayerState, PlayerStatic, RendererConfig, - TilePair, UnitState, } from "../types"; diff --git a/src/client/render/gl/passes/TerritoryPass.ts b/src/client/render/gl/passes/TerritoryPass.ts index 5b3547264..c40744b9a 100644 --- a/src/client/render/gl/passes/TerritoryPass.ts +++ b/src/client/render/gl/passes/TerritoryPass.ts @@ -12,7 +12,6 @@ * uploads across render frames. */ -import type { TilePair } from "../../types"; import type { RenderSettings } from "../RenderSettings"; import { getPaletteSize } from "../utils/ColorUtils"; import { createMapQuad, createProgram, shaderSrc } from "../utils/GlUtils"; @@ -229,11 +228,14 @@ export class TerritoryPass { * Stable per-ref hash means repeated updates to the same tile stay in * arrival order in the same bucket — last write wins when drained. */ - applyLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void { + applyLiveDelta( + tileState: Uint16Array, + changedTiles: readonly number[], + ): void { const N = this.nBuckets; const buckets = this.dripBuckets; for (let i = 0; i < changedTiles.length; i++) { - const ref = changedTiles[i].ref; + const ref = changedTiles[i]; const b = ((ref * 2654435761) >>> 0) % N; buckets[b].push(ref, tileState[ref]); } diff --git a/src/client/render/gl/passes/name-pass/index.ts b/src/client/render/gl/passes/name-pass/index.ts index 0af8c2352..d66037be5 100644 --- a/src/client/render/gl/passes/name-pass/index.ts +++ b/src/client/render/gl/passes/name-pass/index.ts @@ -81,7 +81,6 @@ export class NamePass { // Player management private playerByID: Map; - private smallIDToPlayerID: Map; private slots: Map = new Map(); private maxPlayers: number; private playerColors: Map = new Map(); @@ -102,16 +101,15 @@ export class NamePass { private stringRow: Uint8Array; private cursorRow: Float32Array; - // Reusable per-tick lookup maps (avoid allocation + GC) - private alivePlayerIDs = new Set(); - private troopsByPlayerID = new Map(); + /** Slots refreshed per updateNames call: index % UPDATE_SLICES === phase. */ + private static readonly UPDATE_SLICES = 4; + private slicePhase = 0; // Hovered player's small ID (0 = no highlight, matches TerritoryPass). private highlightOwnerID = 0; // Cursor in world coords — fades names under it (far off-map = no fade). private mouseWorldX = -1e9; private mouseWorldY = -1e9; - private playerStateByID = new Map(); constructor( gl: WebGL2RenderingContext, @@ -148,10 +146,8 @@ export class NamePass { // Build player lookups and extract territory colors from palette this.playerByID = new Map(); - this.smallIDToPlayerID = new Map(); for (const p of header.players) { this.playerByID.set(p.id, p); - this.smallIDToPlayerID.set(p.smallID, p.id); const off = p.smallID * 4; this.playerColors.set(p.id, [ paletteData[off], @@ -215,7 +211,6 @@ export class NamePass { for (const p of players) { if (this.playerByID.has(p.id)) continue; this.playerByID.set(p.id, p); - this.smallIDToPlayerID.set(p.smallID, p.id); const off = p.smallID * 4; this.playerColors.set(p.id, [ paletteData[off], @@ -294,75 +289,83 @@ export class NamePass { ): void { const now = performance.now() / 1000; - // Build alive set and emoji lookup from smallID → playerID - const alivePlayerIDs = this.alivePlayerIDs; - alivePlayerIDs.clear(); - const troopsByPlayerID = this.troopsByPlayerID; - troopsByPlayerID.clear(); - const playerStateByID = this.playerStateByID; - playerStateByID.clear(); - for (const [, ps] of players) { - const pid = this.smallIDToPlayerID.get(ps.smallID); - if (!pid) continue; - if (ps.isAlive) alivePlayerIDs.add(pid); - troopsByPlayerID.set(pid, ps.troops ?? 0); - playerStateByID.set(pid, ps); - } - - // Assign slot indices to players (stable ordering by header index) - let nextSlotIndex = 0; - for (const p of this.playerByID.values()) { - if (!this.slots.has(p.id)) { - const slot: PlayerSlot = { - index: nextSlotIndex++, - playerID: p.id, - static: p, - srcX: 0, - srcY: 0, - srcScale: 0, - tgtX: 0, - tgtY: 0, - tgtScale: 0, - startTime: now, - alive: false, - nameLen: 0, - troopLen: 0, - lastTroopStr: "", - lastTroopBucket: -1, - flagUrl: p.flag, - flagLayerIdx: -1, - emojiAtlasIdx: -1, - nameHalfWidth: 0, - crown: false, - traitor: false, - disconnected: false, - alliance: false, - allianceReq: false, - target: false, - embargo: false, - nukeActive: false, - nukeTargetsMe: false, - inDoomsdayClock: false, - doomsdayClockDraining: false, - traitorRemainingTicks: 0, - allianceFraction: 0, - allianceRemainingTicks: 0, - }; - this.slots.set(p.id, slot); - this.resolveSlotFlag(slot); - } else { - nextSlotIndex = Math.max( - nextSlotIndex, - this.slots.get(p.id)!.index + 1, - ); + // Assign slot indices to players (stable ordering by header index). + // Both maps only ever grow, and every assignment pass leaves them the + // same size — equal sizes means every player already has a slot. + if (this.slots.size !== this.playerByID.size) { + let nextSlotIndex = 0; + for (const p of this.playerByID.values()) { + if (!this.slots.has(p.id)) { + const slot: PlayerSlot = { + index: nextSlotIndex++, + playerID: p.id, + static: p, + srcX: 0, + srcY: 0, + srcScale: 0, + tgtX: 0, + tgtY: 0, + tgtScale: 0, + startTime: now, + alive: false, + nameLen: 0, + troopLen: 0, + lastTroopStr: "", + lastTroopBucket: -1, + flagUrl: p.flag, + flagLayerIdx: -1, + emojiAtlasIdx: -1, + nameHalfWidth: 0, + crown: false, + traitor: false, + disconnected: false, + alliance: false, + allianceReq: false, + target: false, + embargo: false, + nukeActive: false, + nukeTargetsMe: false, + inDoomsdayClock: false, + doomsdayClockDraining: false, + traitorRemainingTicks: 0, + allianceFraction: 0, + allianceRemainingTicks: 0, + }; + this.slots.set(p.id, slot); + this.resolveSlotFlag(slot); + } else { + nextSlotIndex = Math.max( + nextSlotIndex, + this.slots.get(p.id)!.index + 1, + ); + } } } + // Round-robin time slicing: each call refreshes 1/UPDATE_SLICES of the + // slots, spreading the per-player diff work across game ticks (full + // refresh every UPDATE_SLICES ticks ≈ 400 ms — under the 500 ms troop + // text cadence, and name positions lerp continuously anyway). Slots + // without a written name (new player, refreshNames reset) and snap + // passes (seek) are always processed so nothing pops in late. + const phase = this.slicePhase; + this.slicePhase = (phase + 1) % NamePass.UPDATE_SLICES; + for (const [playerID, entry] of names) { const slot = this.slots.get(playerID); if (!slot) continue; - const alive = alivePlayerIDs.has(playerID); + if ( + !snap && + slot.nameLen !== 0 && + slot.index % NamePass.UPDATE_SLICES !== phase + ) { + continue; + } + + // Per-player state straight from the caller's map — smallID is the key. + const ps = players.get(slot.static.smallID); + const alive = ps?.isAlive ?? false; // Skip dead players already marked dead — no work needed if (!alive && !slot.alive) continue; @@ -394,7 +397,7 @@ export class NamePass { const troopBucket = Math.floor((now + (slot.index % 5) * 0.1) / 0.5); if (snap || slot.troopLen === 0 || troopBucket !== slot.lastTroopBucket) { slot.lastTroopBucket = troopBucket; - const troops = troopsByPlayerID.get(playerID) ?? 0; + const troops = ps?.troops ?? 0; const troopStr = renderTroops(troops); if (troopStr !== slot.lastTroopStr) { slot.troopLen = Math.min(troopStr.length, MAX_CHARS); @@ -433,7 +436,6 @@ export class NamePass { // Resolve active broadcast emoji for this player let newEmoji = -1; - const ps = playerStateByID.get(playerID); if (ps?.outgoingEmojis && ps.outgoingEmojis.length > 0) { for (const e of ps.outgoingEmojis) { if (e.recipientID === "AllPlayers") { diff --git a/src/client/render/types/FrameData.ts b/src/client/render/types/FrameData.ts index 583838191..06a9a3c45 100644 --- a/src/client/render/types/FrameData.ts +++ b/src/client/render/types/FrameData.ts @@ -5,7 +5,6 @@ import type { NukeTelegraphData, PlayerState, PlayerStatusData, - TilePair, UnitState, } from "./Renderer"; @@ -37,11 +36,11 @@ export interface FrameData { // ── Upload hints ────────────────────────────────────────────────────── /** - * Changed tiles this frame for delta uploads. + * Changed tile refs this frame for delta uploads. * - `null` → no delta info; full upload needed (first tick) * - array → only these tiles changed (empty = skip upload) */ - readonly changedTiles: TilePair[] | null; + readonly changedTiles: readonly number[] | null; readonly railroadDirty: boolean; readonly revealedRailTiles: number[]; diff --git a/src/client/render/types/Renderer.ts b/src/client/render/types/Renderer.ts index 4345b12d1..d19c4b69e 100644 --- a/src/client/render/types/Renderer.ts +++ b/src/client/render/types/Renderer.ts @@ -173,11 +173,6 @@ export interface ConquestFx { tickAge?: number; } -export interface TilePair { - ref: number; - state: number; -} - export interface NameEntry { playerID: string; x: number; diff --git a/src/client/render/types/index.ts b/src/client/render/types/index.ts index d2ebe356d..b54e10e22 100644 --- a/src/client/render/types/index.ts +++ b/src/client/render/types/index.ts @@ -21,7 +21,6 @@ export type { PlayerStatic, PlayerStatusData, RendererConfig, - TilePair, UnitState, } from "./Renderer"; diff --git a/src/client/view/GameView.ts b/src/client/view/GameView.ts index 3e02af3cb..6a3b73bc7 100644 --- a/src/client/view/GameView.ts +++ b/src/client/view/GameView.ts @@ -34,7 +34,7 @@ import { computePlayerStatus } from "../render/frame/derive/PlayerStatus"; import { buildRelationMatrix } from "../render/frame/derive/RelationMatrix"; import { RailroadCache } from "../render/frame/RailroadCache"; import { TrailManager } from "../render/frame/TrailManager"; -import type { FrameData, NameEntry, TilePair } from "../render/types"; +import type { FrameData, NameEntry } from "../render/types"; import { STRUCTURE_TYPES } from "../render/types"; import { PlayerView } from "./PlayerView"; import { UnitView } from "./UnitView"; @@ -81,6 +81,14 @@ export class GameView implements GameMap { private _teams = new Map(); private updatedTiles: TileRef[] = []; private updatedTerrainTiles: TileRef[] = []; + /** + * Active units grouped by owner smallID, built lazily at most once per + * tick. Keeps per-player unit queries (PlayerView.units) at O(own units) + * instead of O(all units) — the leaderboard asks for every player's units + * in one pass, which is O(players × units) without this. + */ + private _unitsByOwner = new Map(); + private _unitsByOwnerStale = true; // ── FrameData accumulators (renderer-bound state) ───────────────────── private trailManager!: TrailManager; @@ -88,7 +96,6 @@ export class GameView implements GameMap { /** Long-lived NameEntry map for the renderer's NamePass. */ private _names = new Map(); /** Reusable scratch buffers for per-tick deltas. */ - private readonly _changedTilesScratch: TilePair[] = []; private readonly _trailIdsScratch: number[] = []; /** * The single long-lived FrameData object. Fields are mutated in place each @@ -167,9 +174,9 @@ export class GameView implements GameMap { this.railroadCache = new RailroadCache(mapW, mapH); // Long-lived FrameData. Most fields are mutable references to long-lived - // buffers (tileState, trailState, etc.); some (_changedTilesScratch, - // derived arrays) are reused each tick. Properties marked `readonly` on - // FrameData only prevent reassignment, not mutation through the reference. + // buffers (tileState, trailState, etc.); changedTiles points at this + // tick's updatedTiles. Properties marked `readonly` on FrameData only + // prevent reassignment, not mutation through the reference. // events: fresh arrays we own; cleared and repopulated each tick. this._frame = { tick: 0, @@ -185,7 +192,7 @@ export class GameView implements GameMap { conquestEvents: [], bonusEvents: [], }, - changedTiles: this._changedTilesScratch, + changedTiles: null, railroadDirty: false, revealedRailTiles: this.railroadCache.revealedRailTiles, trailDirtyRowMin: 0, @@ -261,6 +268,9 @@ export class GameView implements GameMap { } public update(gu: GameUpdateViewData) { + // Unit set/ownership changes below; rebuild the owner index on demand. + this._unitsByOwnerStale = true; + this.toDelete.forEach((id) => { this._units.delete(id); this._unitStates.delete(id); @@ -437,7 +447,12 @@ export class GameView implements GameMap { for (const unit of this._units.values()) { unit._wasUpdated = false; - unit.lastPos = unit.lastPos.slice(-1); + // Only trim when a move appended a position — slicing a ≤1-element + // array would allocate an identical array per unit per tick, and most + // units (structures) never move. + if (unit.lastPos.length > 1) { + unit.lastPos = unit.lastPos.slice(-1); + } } gu.updates[GameUpdateType.Unit].forEach((update) => { let unit = this._units.get(update.id); @@ -513,12 +528,6 @@ export class GameView implements GameMap { this._trailIdsScratch, ); - // Changed-tile delta refs (zero-copy: state field unused in live mode). - this._changedTilesScratch.length = 0; - for (let i = 0; i < this.updatedTiles.length; i++) { - this._changedTilesScratch.push({ ref: this.updatedTiles[i], state: 0 }); - } - // Names map — rebuilt only when a placement record arrived or a player // was added (nameData values cannot change between those ticks). Entry // order is irrelevant for the renderer. @@ -608,7 +617,9 @@ export class GameView implements GameMap { f.structuresDirty = true; // force initial structure upload this._firstPopulate = false; } else { - f.changedTiles = this._changedTilesScratch; + // Live reference to this tick's changed refs — consumers copy what + // they keep (TerritoryPass buckets them synchronously in the upload). + f.changedTiles = this.updatedTiles; } // Reset transient flags for next tick. @@ -1059,6 +1070,28 @@ export class GameView implements GameMap { (u) => u.isActive() && types.includes(u.type()), ); } + + /** + * Active units owned by the given player (smallID). The grouping is built + * lazily at most once per tick; the returned array must not be mutated. + */ + unitsOwnedBy(ownerSmallID: number): readonly UnitView[] { + if (this._unitsByOwnerStale) { + this._unitsByOwnerStale = false; + this._unitsByOwner.clear(); + for (const u of this._units.values()) { + if (!u.isActive()) continue; + const sid = u.state.ownerID; + const arr = this._unitsByOwner.get(sid); + if (arr === undefined) { + this._unitsByOwner.set(sid, [u]); + } else { + arr.push(u); + } + } + } + return this._unitsByOwner.get(ownerSmallID) ?? []; + } unit(id: number): UnitView | undefined { return this._units.get(id); } diff --git a/src/client/view/PlayerView.ts b/src/client/view/PlayerView.ts index 26f545055..8d84924f2 100644 --- a/src/client/view/PlayerView.ts +++ b/src/client/view/PlayerView.ts @@ -403,9 +403,11 @@ export class PlayerView { } units(...types: UnitType[]): UnitView[] { - return this.game - .units(...types) - .filter((u) => u.owner().smallID() === this.smallID()); + const owned = this.game.unitsOwnedBy(this.smallID()); + if (types.length === 0) { + return [...owned]; + } + return owned.filter((u) => types.includes(u.type())); } nameLocation(): NameViewData | undefined { diff --git a/tests/client/view/GameView.test.ts b/tests/client/view/GameView.test.ts index 9abe7400d..9a57d0ca6 100644 --- a/tests/client/view/GameView.test.ts +++ b/tests/client/view/GameView.test.ts @@ -663,23 +663,21 @@ describe("GameView.frameData() — renderer contract", () => { game.update(gu2); const ct = game.frameData().changedTiles; expect(ct).not.toBeNull(); - expect(ct!.map((t) => t.ref).sort((a, b) => a - b)).toEqual([3, 5, 9]); + expect([...ct!].sort((a, b) => a - b)).toEqual([3, 5, 9]); }); - it("changedTiles scratch array is reused across ticks (no per-tick alloc)", () => { + it("changedTiles holds only the current tick's refs", () => { const game = makeGameView({ width: 4, height: 4 }); game.update(makeEmptyGu(1)); // first populate (changedTiles = null) const gu2 = makeEmptyGu(2); gu2.packedTileUpdates = new Uint32Array([1, 0]); game.update(gu2); - const ct1 = game.frameData().changedTiles; + expect(game.frameData().changedTiles).toEqual([1]); const gu3 = makeEmptyGu(3); gu3.packedTileUpdates = new Uint32Array([2, 0]); game.update(gu3); - const ct2 = game.frameData().changedTiles; - - expect(ct2).toBe(ct1); // same array instance + expect(game.frameData().changedTiles).toEqual([2]); }); it("frame.units is === gameView.unitStates() (same long-lived map)", () => { @@ -771,3 +769,88 @@ describe("GameView.frameData() — renderer contract", () => { expect(relationMatrix[3 * relationSize + 1]).toBe(RELATION_NEUTRAL); }); }); + +describe("GameView.unitsOwnedBy — per-tick owner index", () => { + function withUnits( + tick: number, + units: ReturnType[], + players: ReturnType[] = [], + ) { + const gu = makeEmptyGu(tick); + gu.updates[GameUpdateType.Unit] = units; + gu.updates[GameUpdateType.Player] = players; + return gu; + } + + it("groups active units by owner smallID", () => { + const game = makeGameView(); + game.update( + withUnits(1, [ + makeUnitUpdate({ id: 1, ownerID: 1, unitType: UnitType.City }), + makeUnitUpdate({ id: 2, ownerID: 1, unitType: UnitType.Port }), + makeUnitUpdate({ id: 3, ownerID: 2, unitType: UnitType.City }), + ]), + ); + expect(game.unitsOwnedBy(1).map((u) => u.id())).toEqual([1, 2]); + expect(game.unitsOwnedBy(2).map((u) => u.id())).toEqual([3]); + expect(game.unitsOwnedBy(99)).toEqual([]); + }); + + it("excludes inactive units", () => { + const game = makeGameView(); + game.update( + withUnits(1, [ + makeUnitUpdate({ id: 1, ownerID: 1, isActive: true }), + makeUnitUpdate({ id: 2, ownerID: 1, isActive: false }), + ]), + ); + expect(game.unitsOwnedBy(1).map((u) => u.id())).toEqual([1]); + }); + + it("reflects ownership changes on the next tick (capture)", () => { + const game = makeGameView(); + game.update(withUnits(1, [makeUnitUpdate({ id: 1, ownerID: 1 })])); + expect(game.unitsOwnedBy(1)).toHaveLength(1); + expect(game.unitsOwnedBy(2)).toHaveLength(0); + + game.update(withUnits(2, [makeUnitUpdate({ id: 1, ownerID: 2 })])); + expect(game.unitsOwnedBy(1)).toHaveLength(0); + expect(game.unitsOwnedBy(2)).toHaveLength(1); + }); + + it("reflects unit death on the tick it happens", () => { + const game = makeGameView(); + game.update(withUnits(1, [makeUnitUpdate({ id: 1, ownerID: 1 })])); + expect(game.unitsOwnedBy(1)).toHaveLength(1); + + game.update( + withUnits(2, [makeUnitUpdate({ id: 1, ownerID: 1, isActive: false })]), + ); + expect(game.unitsOwnedBy(1)).toHaveLength(0); + }); + + it("PlayerView.units() filters the owner's units by type", () => { + const game = makeGameView(); + const gu = withUnits( + 1, + [ + makeUnitUpdate({ id: 1, ownerID: 1, unitType: UnitType.City }), + makeUnitUpdate({ id: 2, ownerID: 1, unitType: UnitType.Port }), + makeUnitUpdate({ id: 3, ownerID: 1, unitType: UnitType.City }), + makeUnitUpdate({ id: 4, ownerID: 2, unitType: UnitType.City }), + ], + [ + makePlayerUpdate({ id: "alice", smallID: 1 }), + makePlayerUpdate({ id: "bob", smallID: 2 }), + ], + ); + game.update(gu); + + const alice = game.player("alice"); + expect(alice.units(UnitType.City).map((u) => u.id())).toEqual([1, 3]); + expect(alice.units().map((u) => u.id())).toEqual([1, 2, 3]); + // Returned arrays are copies — mutating them must not corrupt the index. + alice.units().pop(); + expect(alice.units()).toHaveLength(3); + }); +}); diff --git a/tests/perf/client/AnalyzeCpuProfile.ts b/tests/perf/client/AnalyzeCpuProfile.ts new file mode 100644 index 000000000..dce66fbc1 --- /dev/null +++ b/tests/perf/client/AnalyzeCpuProfile.ts @@ -0,0 +1,90 @@ +// Inclusive-time breakdown of the tick-dispatch subtree in a .cpuprofile +// captured by ClientTickPerf. Attributes every sampled stack under the +// harness's "wrapped" listener to each enclosing function, so the phase +// split (gameView.update vs uploadFrameData vs renderer.tick) is visible. +// Usage: npx tsx tests/perf/client/AnalyzeCpuProfile.ts [rootName] +import fs from "fs"; + +interface ProfileNode { + id: number; + callFrame: { functionName: string; url: string; lineNumber: number }; + hitCount?: number; + children?: number[]; +} + +interface Profile { + nodes: ProfileNode[]; + startTime: number; + endTime: number; + samples?: number[]; + timeDeltas?: number[]; +} + +const file = process.argv[2]; +const rootName = process.argv[3] ?? "wrapped"; +const p: Profile = JSON.parse(fs.readFileSync(file, "utf8")); + +const byId = new Map(p.nodes.map((n) => [n.id, n])); +const parent = new Map(); +for (const n of p.nodes) { + for (const c of n.children ?? []) parent.set(c, n.id); +} + +// Self micros per node id from samples/timeDeltas. +const selfMicros = new Map(); +const samples = p.samples ?? []; +const deltas = p.timeDeltas ?? []; +for (let i = 0; i < samples.length; i++) { + selfMicros.set( + samples[i], + (selfMicros.get(samples[i]) ?? 0) + (deltas[i] ?? 0), + ); +} + +// True when a node's ancestry (inclusive) contains a rootName frame. +const underRoot = (id: number): boolean => { + for ( + let cur: number | undefined = id; + cur !== undefined; + cur = parent.get(cur) + ) { + if (byId.get(cur)?.callFrame.functionName === rootName) return true; + } + return false; +}; + +// Attribute each in-subtree sample to every enclosing function (inclusive +// time), stopping at the root frame. +let rootTotal = 0; +const inclusiveByFn = new Map(); +for (const [id, micros] of selfMicros) { + if (!underRoot(id)) continue; + rootTotal += micros; + const seen = new Set(); + for ( + let cur: number | undefined = id; + cur !== undefined; + cur = parent.get(cur) + ) { + const cf = byId.get(cur)?.callFrame; + if (!cf) break; + const url = cf.url.replace(/^.*\/(src|node_modules)\//, "$1/"); + const key = `${cf.functionName || "(anonymous)"} ${url}:${cf.lineNumber + 1}`; + if (!seen.has(key)) { + seen.add(key); + inclusiveByFn.set(key, (inclusiveByFn.get(key) ?? 0) + micros); + } + if (cf.functionName === rootName) break; + } +} + +console.log( + `total under "${rootName}": ${(rootTotal / 1000).toFixed(1)} ms of ${((p.endTime - p.startTime) / 1000).toFixed(0)} ms profile`, +); +const rows = [...inclusiveByFn.entries()].sort((a, b) => b[1] - a[1]); +for (const [key, micros] of rows.slice(0, 40)) { + const pct = ((micros / rootTotal) * 100).toFixed(1); + console.log( + `${(micros / 1000).toFixed(1).padStart(9)} ms ${pct.padStart(5)}% ${key}`, + ); +} diff --git a/tests/perf/client/ClientTickPerf.ts b/tests/perf/client/ClientTickPerf.ts new file mode 100644 index 000000000..c5d36c095 --- /dev/null +++ b/tests/perf/client/ClientTickPerf.ts @@ -0,0 +1,568 @@ +/** + * Main-thread tick-processing harness: drives a real singleplayer game in + * headless Chromium and measures how long the worker→main "game_update" + * message dispatch takes on the MAIN thread — the per-tick cost that competes + * with the frame budget (16.7 ms at 60 fps) and causes frame drops on low-end + * hardware when it runs long. + * + * What is measured, per dispatch: + * - deserialization: first access to event.data (structured-clone decode) + * - handler: WorkerClient.handleWorkerMessage → gameView.update → + * webglBuilder.update → renderer.tick + * Both are captured by wrapping Worker.prototype.addEventListener in an init + * script, so no product code changes are needed. + * + * For attribution, CDP sampling profiles (chrome .cpuprofile files) can be + * captured over tick windows and are summarized as top self-time functions. + * Open them in Chrome DevTools → Performance → load profile for flame graphs. + * + * The harness starts its own vite dev server on a private port (default + * 9017) so results always come from THIS checkout, even when another + * working copy is serving port 9000. + * + * One-time browser setup (installs playwright + chromium libs, no sudo): + * bash .claude/skills/run-openfront/setup.sh + * + * Usage: + * npm run perf:client-tick -- --map "Giant World Map" --ticks 2000 \ + * --window 250 --profile-at 500,1500 + * + * Flags: + * --map GameMapType value (default "Giant World Map") + * --bots bot count (default 400, the solo-modal default) + * --difficulty Easy|Medium|Hard|Impossible (default modal default) + * --ticks run until this game tick (default 2000) + * --window report stats every n ticks (default 250) + * --profile-at comma-separated ticks to start a CPU profile + * --profile-window ticks each CPU profile spans (default 100) + * --spawn fixed human spawn tile (default: auto-pick) + * --port vite dev-server port (default 9017) + * --raf-interval rAF throttle; SwiftShader frames cost seconds of + * CPU, so an unthrottled frame loop starves the sim + * (default 3000) + * --out-dir output dir (default tests/perf/output) + * + * Headless caveats: rendering uses SwiftShader and the rAF loop is throttled, + * so this measures the tick-dispatch path, not draw calls. GL upload calls + * issued inside the dispatch go through SwiftShader's command buffer and cost + * differently than on real GPUs. Solo games are RNG-driven, so numbers vary + * a few percent run-to-run; compare trends, not microseconds. + */ +import { ChildProcess, spawn as spawnProcess } from "child_process"; +import fs from "fs"; +import path from "path"; + +interface Options { + map: string; + bots: number; + difficulty: string | undefined; + ticks: number; + window: number; + profileAt: number[]; + profileWindow: number; + spawn: { x: number; y: number } | null; + port: number; + rafIntervalMs: number; + outDir: string; +} + +/** One worker→main game-update dispatch, as recorded by the init script. */ +interface TickSample { + /** Game tick of the (last) update in the dispatch. */ + tick: number; + /** ms spent deserializing event.data (structured-clone decode). */ + deserMs: number; + /** ms spent in the message handler (gameView/webglBuilder/renderer). */ + handlerMs: number; + /** Updates in the dispatch (>1 for game_update_batch catch-up). */ + updates: number; +} + +interface WindowStats { + label: string; + fromTick: number; + toTick: number; + dispatches: number; + updates: number; + meanMs: number; + p50Ms: number; + p95Ms: number; + maxMs: number; + meanDeserMs: number; + maxDeserMs: number; + /** Mean handler ms per game tick (normalizes batched dispatches). */ + meanPerUpdateMs: number; +} + +function parseArgs(): Options { + const opts: Options = { + map: "Giant World Map", + bots: 400, + difficulty: undefined, + ticks: 2000, + window: 250, + profileAt: [], + profileWindow: 100, + spawn: null, + port: 9017, + rafIntervalMs: 3000, + outDir: path.join("tests", "perf", "output"), + }; + const argv = process.argv.slice(2); + for (let i = 0; i < argv.length; i++) { + const next = () => argv[++i]; + switch (argv[i]) { + case "--map": + opts.map = next(); + break; + case "--bots": + opts.bots = parseInt(next(), 10); + break; + case "--difficulty": + opts.difficulty = next(); + break; + case "--ticks": + opts.ticks = parseInt(next(), 10); + break; + case "--window": + opts.window = parseInt(next(), 10); + break; + case "--profile-at": + opts.profileAt = next() + .split(",") + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => Number.isFinite(n)); + break; + case "--profile-window": + opts.profileWindow = parseInt(next(), 10); + break; + case "--spawn": { + const [x, y] = next().split(",").map(Number); + opts.spawn = { x, y }; + break; + } + case "--port": + opts.port = parseInt(next(), 10); + break; + case "--raf-interval": + opts.rafIntervalMs = parseInt(next(), 10); + break; + case "--out-dir": + opts.outDir = next(); + break; + default: + throw new Error(`unknown flag: ${argv[i]}`); + } + } + return opts; +} + +// ---------- dev server ---------- + +async function startViteServer(port: number): Promise { + // --strictPort makes vite exit instead of silently picking another port — + // that also guards against measuring a different checkout's server. + const child = spawnProcess( + "npx", + ["vite", "--port", String(port), "--strictPort"], + { + env: { ...process.env, SKIP_BROWSER_OPEN: "true" }, + stdio: ["ignore", "pipe", "pipe"], + detached: true, // own process group, so cleanup kills vite's children + }, + ); + let output = ""; + child.stdout?.on("data", (d: Buffer) => (output += d.toString())); + child.stderr?.on("data", (d: Buffer) => (output += d.toString())); + + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error( + `vite exited with code ${child.exitCode} (port ${port} busy?)\n${output}`, + ); + } + try { + const res = await fetch(`http://localhost:${port}/`); + if (res.ok) return child; + } catch { + // not up yet + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`vite did not become ready on port ${port}\n${output}`); +} + +function stopViteServer(child: ChildProcess): void { + if (child.pid !== undefined && child.exitCode === null) { + try { + process.kill(-child.pid, "SIGTERM"); // whole process group + } catch { + // already gone + } + } +} + +// ---------- stats ---------- + +function quantile(sorted: number[], q: number): number { + if (sorted.length === 0) return 0; + const idx = Math.min(sorted.length - 1, Math.ceil(q * sorted.length) - 1); + return sorted[Math.max(0, idx)]; +} + +function summarizeWindow(label: string, samples: TickSample[]): WindowStats { + const handler = samples.map((s) => s.handlerMs).sort((a, b) => a - b); + const deser = samples.map((s) => s.deserMs); + const updates = samples.reduce((acc, s) => acc + s.updates, 0); + const totalHandler = handler.reduce((acc, v) => acc + v, 0); + return { + label, + fromTick: samples.length > 0 ? samples[0].tick : 0, + toTick: samples.length > 0 ? samples[samples.length - 1].tick : 0, + dispatches: samples.length, + updates, + meanMs: samples.length > 0 ? totalHandler / samples.length : 0, + p50Ms: quantile(handler, 0.5), + p95Ms: quantile(handler, 0.95), + maxMs: handler.length > 0 ? handler[handler.length - 1] : 0, + meanDeserMs: + samples.length > 0 + ? deser.reduce((acc, v) => acc + v, 0) / samples.length + : 0, + maxDeserMs: deser.length > 0 ? Math.max(...deser) : 0, + meanPerUpdateMs: updates > 0 ? totalHandler / updates : 0, + }; +} + +function printReport(windows: WindowStats[], opts: Options): void { + console.log( + `\n=== Main-thread tick dispatch (map=${opts.map}, bots=${opts.bots}) ===`, + ); + console.log( + "handler ms per worker game-update dispatch (frame budget at 60 fps: 16.7 ms)", + ); + console.log( + `${"window".padEnd(14)} ${"disp".padStart(5)} ${"upd".padStart(5)} ${"mean".padStart(7)} ${"p50".padStart(7)} ${"p95".padStart(7)} ${"max".padStart(7)} ${"deser".padStart(7)} ${"ms/upd".padStart(7)}`, + ); + for (const w of windows) { + console.log( + `${w.label.padEnd(14)} ${String(w.dispatches).padStart(5)} ${String(w.updates).padStart(5)} ${w.meanMs.toFixed(2).padStart(7)} ${w.p50Ms.toFixed(2).padStart(7)} ${w.p95Ms.toFixed(2).padStart(7)} ${w.maxMs.toFixed(2).padStart(7)} ${w.meanDeserMs.toFixed(2).padStart(7)} ${w.meanPerUpdateMs.toFixed(2).padStart(7)}`, + ); + } +} + +// ---------- CPU profile summarization ---------- + +interface CpuProfileNode { + id: number; + callFrame: { + functionName: string; + url: string; + lineNumber: number; + }; + hitCount?: number; + children?: number[]; +} + +interface CpuProfile { + nodes: CpuProfileNode[]; + startTime: number; + endTime: number; + samples?: number[]; + timeDeltas?: number[]; +} + +/** Print the top-N functions by self time from a V8 sampling profile. */ +function summarizeProfile(profile: CpuProfile, top: number): void { + // Self time per node = sum of timeDeltas for samples attributed to it. + const selfMicros = new Map(); + const samples = profile.samples ?? []; + const deltas = profile.timeDeltas ?? []; + for (let i = 0; i < samples.length; i++) { + const nodeId = samples[i]; + selfMicros.set(nodeId, (selfMicros.get(nodeId) ?? 0) + (deltas[i] ?? 0)); + } + const byFunction = new Map(); + for (const node of profile.nodes) { + const micros = selfMicros.get(node.id) ?? 0; + if (micros === 0) continue; + const { functionName, url, lineNumber } = node.callFrame; + const shortUrl = url.replace(/^.*\/(src|node_modules)\//, "$1/"); + const key = `${functionName || "(anonymous)"} ${shortUrl}:${lineNumber + 1}`; + byFunction.set(key, (byFunction.get(key) ?? 0) + micros); + } + const totalMicros = profile.endTime - profile.startTime; + const rows = [...byFunction.entries()].sort((a, b) => b[1] - a[1]); + console.log( + ` top self-time functions (of ${(totalMicros / 1000).toFixed(0)} ms profiled):`, + ); + for (const [key, micros] of rows.slice(0, top)) { + const pct = ((micros / totalMicros) * 100).toFixed(1); + console.log( + ` ${(micros / 1000).toFixed(1).padStart(8)} ms ${pct.padStart(5)}% ${key}`, + ); + } +} + +// ---------- main ---------- + +async function main(): Promise { + const opts = parseArgs(); + fs.mkdirSync(opts.outDir, { recursive: true }); + + console.log(`starting vite on port ${opts.port}…`); + const vite = await startViteServer(opts.port); + + // The skill driver reads OPENFRONT_URL at import time, so set it before + // the dynamic imports below. + process.env.OPENFRONT_URL = `http://localhost:${opts.port}`; + const { launch, gotoHome, openSoloModal } = + // @ts-expect-error untyped .mjs skill module + await import("../../../.claude/skills/run-openfront/driver.mjs"); + const { + startSoloGame, + waitForGameReady, + spawn, + waitForSpawnPhaseEnd, + waitForTick, + gameState, + } = + // @ts-expect-error untyped .mjs skill module + await import("../../../.claude/skills/run-openfront/game.mjs"); + + let browser: { close(): Promise } | null = null; + try { + console.log("launching headless chromium…"); + const launched = await launch({ + rafIntervalMs: opts.rafIntervalMs, + // Headless Chromium throttles backgrounded/occluded pages, which + // starves the singleplayer turn loop on top of SwiftShader's cost. + args: [ + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + ], + }); + browser = launched.browser; + const page = launched.page; + + // Surface in-page failures in the harness log — a broken init script or + // GL fault otherwise shows up only as an opaque ready-timeout. Blocked + // external fetches (ads, auth, cosmetics) are expected noise; skip them. + page.on("console", (msg: { type(): string; text(): string }) => { + const text = msg.text(); + if ( + msg.type() === "error" && + !/Failed to load resource|Failed to fetch|ramp\./.test(text) + ) { + console.log(`CONSOLE[error]: ${text}`); + } + }); + + // Headless Chromium only has SwiftShader, and the WebGL gate (#4324) + // refuses software renderers by matching the unmasked renderer string. + // Spoof the string so the gate passes; rendering still runs on + // SwiftShader (hence the rAF throttle). + await page.addInitScript(() => { + const orig = WebGL2RenderingContext.prototype.getParameter; + WebGL2RenderingContext.prototype.getParameter = function (p: number) { + const v = orig.call(this, p); + return typeof v === "string" + ? v.replace(/swiftshader|llvmpipe|software/gi, "PerfHarnessGPU") + : v; + }; + }); + + // Time every worker→main "message" dispatch. The first event.data access + // pays the structured-clone deserialization, so it is timed separately + // from the handler body. Only game updates are recorded. + // + // MUST stay a string, not a function: tsx compiles this file with + // esbuild's keepNames, which rewrites named function expressions to + // `__name(fn, "name")` — and the `__name` helper doesn't exist in the + // page, so a function-form script throws ReferenceError inside + // Worker.addEventListener and silently kills the game worker setup. + // (Member-expression assignments like the GL spoof above escape + // keepNames, which is why that one can stay a function.) + await page.addInitScript(`(() => { + // Init scripts also run in dedicated workers, where window is + // undefined — only the page context is measured. + if (typeof window === "undefined") return; + const samples = []; + window.__tickPerf = { + take() { + return samples.splice(0, samples.length); + }, + }; + const origAdd = Worker.prototype.addEventListener; + Worker.prototype.addEventListener = function (type, listener, options) { + if (type !== "message" || typeof listener !== "function") { + return origAdd.call(this, type, listener, options); + } + const wrapped = function (event) { + const t0 = performance.now(); + const d = event.data; // first access → structured-clone decode + const t1 = performance.now(); + const isTick = d && d.type === "game_update"; + const isBatch = d && d.type === "game_update_batch"; + if (!isTick && !isBatch) { + return listener.call(this, event); + } + try { + return listener.call(this, event); + } finally { + const t2 = performance.now(); + const updates = isBatch ? (d.gameUpdates ?? []) : [d.gameUpdate]; + const last = updates[updates.length - 1]; + samples.push({ + tick: last ? last.tick : -1, + deserMs: t1 - t0, + handlerMs: t2 - t1, + updates: updates.length, + }); + // Bounded so a stalled harness can't grow the page's heap. + if (samples.length > 200000) samples.splice(0, 100000); + } + }; + return origAdd.call(this, type, wrapped, options); + }; + })();`); + + // Solo games are fully local: block external requests (ad scripts) and + // all websockets — vite's HMR socket times out under heavy throttling + // and force-reloads the page mid-game. + await page.route("**/*", (route: any) => { + const host = new URL(route.request().url()).hostname; + return host === "localhost" || host === "127.0.0.1" + ? route.continue() + : route.abort(); + }); + await page.routeWebSocket("**", () => {}); + + // CDP session against the page = the main thread only (the core sim + // worker is a separate target and not included in CPU profiles). + const cdp = await page.context().newCDPSession(page); + await cdp.send("Profiler.enable"); + await cdp.send("Profiler.setSamplingInterval", { interval: 200 }); + + const takeSamples = async (): Promise => + (await page.evaluate(() => (window as any).__tickPerf.take())) ?? []; + + console.log("loading home page + solo modal…"); + await gotoHome(page); + await openSoloModal(page); + + console.log( + `starting solo game: map=${opts.map}, bots=${opts.bots}` + + (opts.difficulty !== undefined + ? `, difficulty=${opts.difficulty}` + : ""), + ); + try { + await startSoloGame(page, { + map: opts.map, + bots: opts.bots, + ...(opts.difficulty !== undefined + ? { difficulty: opts.difficulty } + : {}), + }); + } catch { + // Giant maps can exceed the skill's 180 s ready timeout on a cold + // headless start — give the load one more window before giving up. + console.log("game not ready after 180 s, waiting another 300 s…"); + await waitForGameReady(page, 300_000); + } + + console.log("spawning…"); + const tile = await spawn(page, opts.spawn); + console.log(`spawned at (${tile.x},${tile.y})`); + await waitForSpawnPhaseEnd(page, 120_000); + const spawnState = await gameState(page); + const spawnedTick: number = spawnState?.ticks ?? 0; + // Spawn-phase dispatches are not representative; drop them. + await takeSamples(); + + const startWall = Date.now(); + const windows: WindowStats[] = []; + const allSamples: TickSample[] = []; + const pendingProfiles = [...new Set(opts.profileAt)].sort((a, b) => a - b); + + const captureProfile = async (startTick: number): Promise => { + const endTick = startTick + opts.profileWindow; + console.log(`[profile] capturing ticks ${startTick}–${endTick}…`); + await cdp.send("Profiler.start"); + await waitForTick(page, endTick, opts.profileWindow * 2000 + 120_000); + const { profile } = await cdp.send("Profiler.stop"); + const file = path.join(opts.outDir, `client-tick${startTick}.cpuprofile`); + fs.writeFileSync(file, JSON.stringify(profile)); + console.log(`[profile] ${file}`); + summarizeProfile(profile as CpuProfile, 25); + }; + + const targets: number[] = []; + for (let t = spawnedTick + opts.window; t < opts.ticks; t += opts.window) { + targets.push(t); + } + if (opts.ticks > spawnedTick) targets.push(opts.ticks); + + for (const target of targets) { + // A due profile splits the window: profile spans real ticks inside it. + while (pendingProfiles.length > 0 && pendingProfiles[0] < target) { + const at = pendingProfiles.shift()!; + const state = await gameState(page); + const current: number = state?.ticks ?? 0; + if (at > current) { + // Generous timeout: headless sim speed varies wildly with map size + // and bot count (0.5–10 ticks/s). + await waitForTick(page, at, (at - current) * 2000 + 120_000); + } + await captureProfile(Math.max(at, current)); + } + const state = await gameState(page); + const current: number = state?.ticks ?? 0; + if (target > current) { + await waitForTick(page, target, (target - current) * 2000 + 120_000); + } + const samples = await takeSamples(); + allSamples.push(...samples); + const w = summarizeWindow(`tick ${target}`, samples); + windows.push(w); + console.log( + `[window] ${w.label}: ${w.dispatches} dispatches, mean ${w.meanMs.toFixed(2)} ms, p95 ${w.p95Ms.toFixed(2)} ms, max ${w.maxMs.toFixed(2)} ms`, + ); + } + // Any profiles requested at/beyond the final tick. + while (pendingProfiles.length > 0) { + await captureProfile(pendingProfiles.shift()!); + } + + const samplesFile = path.join(opts.outDir, "client-tick-samples.json"); + fs.writeFileSync(samplesFile, JSON.stringify(allSamples)); + console.log(`[samples] ${samplesFile} (${allSamples.length} dispatches)`); + + // End-of-run screenshot — a cheap rendering sanity check (a black map + // means the GL pipeline broke even if no pageerror surfaced). + const shotPath = path.join(opts.outDir, "client-tick-final.png"); + await page.screenshot({ path: shotPath }); + console.log(`[screenshot] ${shotPath}`); + + printReport(windows, opts); + printReport([summarizeWindow("overall", allSamples)], { + ...opts, + map: `${opts.map} (all windows)`, + }); + const finalState = await gameState(page); + console.log( + `\nfinal state: ${JSON.stringify(finalState)}\ntotal wall time ${((Date.now() - startWall) / 1000 / 60).toFixed(1)} min`, + ); + } finally { + await browser?.close(); + stopViteServer(vite); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});