Files
OpenFrontIO/tests/util/viewStubs.ts
Evan bca980f572 Shrink the per-tick worker → main update payload by ~90% (#4244)
Stacked on #4243 (the `perf:client` harness) — first step of fixing the
every-100ms main-thread stutter: make the per-tick burst small before
spreading what remains across frames.

## Problem

The harness showed the main-thread burst was dominated by
`structuredClone` of the `updates` object, and the clone was dominated
by two kinds of per-tick churn that re-sent object payloads every tick:

- `gold` / `troops` / `tilesOwned` change for nearly every alive player
every tick → ~278 partial `PlayerUpdate` objects per tick (world/400
bots), ~508 on giantworldmap.
- Attack troop counts tick down every tick → whole
`outgoingAttacks`/`incomingAttacks` arrays re-cloned for every fighting
player every tick.
- `playerNameViewData` (an all-players record) was cloned every tick but
only recomputed every 30 ticks.

## Change

Three additions to the worker → main protocol (all transferable,
zero-clone):

1. **`packedPlayerUpdates`** — `[smallID, tilesOwned, gold, troops]`
float64 quads for players whose stats changed. These fields no longer
appear in `PlayerUpdate` diffs (first emissions still carry the full
snapshot). Gold is exact in a float64 (game values ≪ 2^53).
2. **`packedAttackUpdates`** — `[ownerSmallID, direction, index,
troops]` quads. Attack arrays are only resent when
membership/order/retreating changes — which is exactly the condition
that keeps the patch indexes valid (a tick either resends an array or
patches it, never both).
3. **`playerNameViewData` is now optional** — attached only on
placement-rebuild ticks (spawn ticks, first ticks, every 30th, spawn
end). The client keeps the last applied values; dead players' name
placements freeze at death (matching the previous effective behavior).

On the client, `GameView.populateFrame` now also rebuilds `names` /
`relationMatrix` / `allianceClusters` only when their inputs changed
that tick — field presence on a partial `PlayerUpdate` marks them dirty.
(`playerStatus`, nuke telegraphs, and attack rings still recompute every
tick; they're tick- or unit-dependent.)

## Results (perf:client, this machine; low-end devices ~5–20× slower)

Default run (world, 400 bots, 1800 ticks):

| stage | before | after |
|---|---|---|
| clone (serialize+deserialize) | 1.02ms | **0.09ms** |
| GameView.update | 0.62ms | **0.29ms** |
| WebGLFrameBuilder.update | 0.04ms | 0.04ms |
| **TOTAL burst mean** | **1.67ms** | **0.42ms** |
| TOTAL p99 / max | 3.47 / 10.3ms | **1.21 / 3.92ms** |

giantworldmap/600t: 2.54 → 0.68ms mean. Player update objects: 278 → 6.5
per tick (world), 508 → 12 (giant). The remaining burst is mostly tile
apply + per-tick derivations — the part that frame-spreading (next step)
addresses.

## Verification

- **Sim final hash unchanged** on all three reference configs
(`5607618202213430`, `29309648281599524`, `39945089450032050`) — no
simulation behavior change.
- **View hash unchanged** on all three configs (`942106e9`, `a3aae227`,
`cbaaf265`) — the rendered view state is provably identical
tick-for-tick, including the name-freeze semantics.
- New tests: `tests/PackedPlayerUpdates.test.ts` (drain + GameRunner
cadence), packed-channel and freeze-at-death cases in
`tests/client/view/GameView.test.ts`, `packAttackTroopDeltas` unit tests
and updated diff contract in `tests/GameUpdateUtils.test.ts` /
`tests/PlayerUpdateDiff.test.ts`.
- `npm test` (1490 tests), `eslint`, `prettier`, `tsc --noEmit` all
pass.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:50:56 -07:00

215 lines
5.9 KiB
TypeScript

/**
* Stub builders for GameView/PlayerView/UnitView unit tests.
*
* These tests don't go through the full game setup (which creates a worker
* and runs the simulation) — they exercise the view classes directly with
* minimal stubs for their dependencies.
*/
import { colord } from "colord";
import { Theme } from "../../src/client/theme/ThemeProvider";
import { GameView } from "../../src/client/view/GameView";
import { PlayerView } from "../../src/client/view/PlayerView";
import { Config } from "../../src/core/configuration/Config";
import {
NameViewData,
PlayerType,
Team,
UnitType,
} from "../../src/core/game/Game";
import { GameMapImpl } from "../../src/core/game/GameMap";
import {
GameUpdateType,
GameUpdateViewData,
PlayerUpdate,
UnitUpdate,
} from "../../src/core/game/GameUpdates";
import { TerrainMapData } from "../../src/core/game/TerrainMapLoader";
import { Player, PlayerCosmetics } from "../../src/core/Schemas";
import { WorkerClient } from "../../src/core/worker/WorkerClient";
/** Theme stub — returns deterministic colors so PlayerView's color math works. */
export function stubTheme(): Theme {
const white = colord("#ffffff");
const grey = colord("#808080");
const defended = { light: white, dark: grey };
return {
teamColor: () => white,
territoryColor: () => white,
structureColors: () => defended,
borderColor: () => grey,
defendedBorderColors: () => defended,
focusedBorderColor: () => grey,
spawnHighlightColor: () => white,
};
}
/** Minimum Config stub for view tests. Extend as test needs grow. */
export function stubConfig(overrides: Partial<Config> = {}): Config {
const theme = stubTheme();
const cfg = {
theme: () => theme,
SAMCooldown: () => 120,
SiloCooldown: () => 75,
deleteUnitCooldown: () => 0,
spawnImmunityDuration: () => 0,
nationSpawnImmunityDuration: () => 0,
unitInfo: () => ({ maxHealth: 100, constructionDuration: 20 }),
disableAlliances: () => false,
allianceDuration: () => 100,
deletionMarkDuration: () => 300,
nukeMagnitudes: () => ({ inner: 0, outer: 0 }),
nukeAllianceBreakThreshold: () => 0,
userSettings: () => ({}),
...overrides,
} as unknown as Config;
return cfg;
}
/** WorkerClient stub. View classes only call worker.* in async methods we don't exercise. */
export function stubWorker(): WorkerClient {
return {} as unknown as WorkerClient;
}
/** Build TerrainMapData wrapping a fresh GameMapImpl of the given size. */
export function stubTerrainMap(width = 10, height = 10): TerrainMapData {
const terrain = new Uint8Array(width * height);
const gameMap = new GameMapImpl(width, height, terrain, 0);
return {
nations: [],
additionalNations: [],
gameMap,
miniGameMap: gameMap,
} as unknown as TerrainMapData;
}
export interface GameViewStubOptions {
width?: number;
height?: number;
myClientID?: string;
myUsername?: string;
myClanTag?: string | null;
humans?: Player[];
config?: Config;
}
/** Construct a GameView with minimal dependencies. */
export function makeGameView(opts: GameViewStubOptions = {}): GameView {
return new GameView(
stubWorker(),
opts.config ?? stubConfig(),
stubTerrainMap(opts.width ?? 10, opts.height ?? 10),
opts.myClientID,
opts.myUsername ?? "tester",
opts.myClanTag ?? null,
"test-game",
opts.humans ?? [],
);
}
// ── Synthetic update builders ──
export function makePlayerUpdate(
overrides: Partial<PlayerUpdate> = {},
): PlayerUpdate {
return {
type: GameUpdateType.Player,
clientID: "client-a",
name: "Alice",
displayName: "Alice",
id: "player-a",
smallID: 1,
playerType: PlayerType.Human,
isAlive: true,
isDisconnected: false,
tilesOwned: 0,
gold: 0n,
troops: 100,
allies: [],
embargoes: new Set(),
isTraitor: false,
targets: [],
outgoingEmojis: [],
outgoingAttacks: [],
incomingAttacks: [],
outgoingAllianceRequests: [],
alliances: [],
hasSpawned: true,
betrayals: 0,
lastDeleteUnitTick: 0,
isLobbyCreator: false,
...overrides,
};
}
export function makeUnitUpdate(
overrides: Partial<UnitUpdate> = {},
): UnitUpdate {
return {
type: GameUpdateType.Unit,
unitType: UnitType.Warship,
troops: 0,
id: 1,
ownerID: 1,
pos: 0,
lastPos: 0,
isActive: true,
reachedTarget: false,
targetable: true,
markedForDeletion: false,
missileTimerQueue: [],
level: 1,
hasTrainStation: false,
...overrides,
};
}
export function makeNameViewData(
overrides: Partial<NameViewData> = {},
): NameViewData {
return { x: 0, y: 0, size: 12, ...overrides };
}
export interface PlayerViewStubOptions {
game?: GameView;
data?: Partial<PlayerUpdate>;
nameData?: NameViewData;
cosmetics?: PlayerCosmetics;
}
/** Construct a PlayerView with minimal dependencies. */
export function makePlayerView(opts: PlayerViewStubOptions = {}): PlayerView {
return new PlayerView(
opts.game ?? makeGameView(),
makePlayerUpdate(opts.data),
opts.nameData ?? makeNameViewData(),
opts.cosmetics ?? {},
);
}
/**
* Build a GameUpdateViewData with no updates and an empty packed tile delta.
* Caller can fill in updates[GameUpdateType.X] arrays as needed.
*/
export function makeEmptyGu(
tick: number,
overrides: Partial<GameUpdateViewData> = {},
): GameUpdateViewData {
const updates = Object.fromEntries(
Object.values(GameUpdateType)
.filter((v): v is number => typeof v === "number")
.map((k) => [k, []]),
) as unknown as GameUpdateViewData["updates"];
return {
tick,
updates,
packedTileUpdates: new Uint32Array(0),
// playerNameViewData deliberately absent — production omits it on every
// tick between placement rebuilds, so the stub default must exercise the
// absent path. Tests that need placements set it explicitly.
...overrides,
};
}
export { Team };