From bca980f57295c6c4712124874fc5aecba3bac8c9 Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 12 Jun 2026 16:50:56 -0700 Subject: [PATCH] =?UTF-8?q?Shrink=20the=20per-tick=20worker=20=E2=86=92=20?= =?UTF-8?q?main=20update=20payload=20by=20~90%=20(#4244)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/client/TransformHandler.ts | 5 +- src/client/view/GameView.ts | 131 ++++++++++-- src/client/view/PlayerView.ts | 5 +- src/core/GameRunner.ts | 13 +- src/core/game/Game.ts | 7 +- src/core/game/GameImpl.ts | 25 ++- src/core/game/GameUpdateUtils.ts | 63 +++++- src/core/game/GameUpdates.ts | 30 ++- src/core/game/PlayerImpl.ts | 49 ++++- src/core/worker/Worker.worker.ts | 6 + tests/GameUpdateUtils.test.ts | 135 +++++++++--- tests/PackedPlayerUpdates.test.ts | 155 ++++++++++++++ tests/PlayerUpdateDiff.test.ts | 78 ++++++- tests/client/view/GameView.test.ts | 286 +++++++++++++++++++++++++- tests/perf/client/ClientUpdatePerf.ts | 6 + tests/util/viewStubs.ts | 4 +- 16 files changed, 924 insertions(+), 74 deletions(-) create mode 100644 tests/PackedPlayerUpdates.test.ts diff --git a/src/client/TransformHandler.ts b/src/client/TransformHandler.ts index 41122c90d..77a0bac69 100644 --- a/src/client/TransformHandler.ts +++ b/src/client/TransformHandler.ts @@ -227,8 +227,9 @@ export class TransformHandler { centerCamera() { this.clearTarget(); const player = this.game.myPlayer(); - if (!player || !player.nameLocation()) return; - this.target = new Cell(player.nameLocation().x, player.nameLocation().y); + const nameLocation = player?.nameLocation(); + if (!nameLocation) return; + this.target = new Cell(nameLocation.x, nameLocation.y); this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS); } diff --git a/src/client/view/GameView.ts b/src/client/view/GameView.ts index 0d2ac7b0f..2efcdc9b5 100644 --- a/src/client/view/GameView.ts +++ b/src/client/view/GameView.ts @@ -16,6 +16,7 @@ import { GameUpdateViewData, SpawnPhaseEndUpdate, } from "../../core/game/GameUpdates"; +import { ATTACK_DELTA_OUTGOING } from "../../core/game/GameUpdateUtils"; import { MotionPlanRecord, unpackMotionPlans, @@ -100,6 +101,17 @@ export class GameView implements GameMap { private _myPlayer: PlayerView | null = null; + // ── populateFrame dirty flags ────────────────────────────────────────── + // The derived structures below only depend on rarely-changing player + // fields, so they're rebuilt only when one of their inputs arrived this + // tick (PlayerUpdates are partial — field presence means "changed"). + /** Names: nameData record applied, or a player was added. */ + private _namesDirty = true; + /** Relation matrix: allies/embargoes changed, or a player was added. */ + private _relationsDirty = true; + /** Alliance clusters: allies changed, or a player was added. */ + private _clustersDirty = true; + private unitGrid: UnitGrid; private unitMotionPlans = new Map< number, @@ -290,6 +302,21 @@ export class GameView implements GameMap { this._myClanTag, ); + // Name placements arrive only on ticks where the worker recomputed them + // (see GameUpdateViewData.playerNameViewData). Apply to existing alive + // players here; dead players keep their last placement (names freeze at + // death), and new players get theirs via the PlayerView constructor in + // pass 1 below. + if (gu.playerNameViewData !== undefined) { + for (const id in gu.playerNameViewData) { + const pv = this._players.get(id); + if (pv !== undefined && pv.state.isAlive) { + pv.nameData = gu.playerNameViewData[id]; + } + } + this._namesDirty = true; + } + // Pass 1: ensure every player exists with up-to-date PlayerState. We need // all smallIDs registered before pass 2 can translate embargo PlayerIDs. // PlayerUpdate is now partial: only `id` is guaranteed; everything else @@ -311,9 +338,19 @@ export class GameView implements GameMap { this.smallIDToID.set(pu.smallID, pu.id); } + // Derived-data dirty tracking: field presence on a partial update + // means the field changed this tick. + if (pu.allies !== undefined) { + this._relationsDirty = true; + this._clustersDirty = true; + } + if (pu.embargoes !== undefined) { + this._relationsDirty = true; + } + if (existing !== undefined) { existing.applyUpdate(pu); - const nextNameData = gu.playerNameViewData[pu.id]; + const nextNameData = gu.playerNameViewData?.[pu.id]; if (nextNameData !== undefined) { existing.nameData = nextNameData; } @@ -321,7 +358,7 @@ export class GameView implements GameMap { const player = new PlayerView( this, pu, - gu.playerNameViewData[pu.id], + gu.playerNameViewData?.[pu.id], // First check human by clientID, then check nation by name. this._cosmetics.get(pu.clientID ?? "") ?? this._cosmetics.get(pu.name!) ?? @@ -333,6 +370,9 @@ export class GameView implements GameMap { if (team !== null) { this._teams.set(pu.smallID!, team); } + this._namesDirty = true; + this._relationsDirty = true; + this._clustersDirty = true; } }); @@ -353,6 +393,43 @@ export class GameView implements GameMap { player.setEmbargoSmallIDs(smallIDs); }); + // Packed per-player stats: [smallID, tilesOwned, gold, troops] quads for + // every player whose stats changed this tick (the per-tick churn that no + // longer travels in PlayerUpdate objects). Applied after pass 1 so + // first-emission players exist; their quad carries the same values as + // the full update, so double-applying is harmless. + const packedStats = gu.packedPlayerUpdates; + if (packedStats !== undefined) { + for (let i = 0; i + 3 < packedStats.length; i += 4) { + const state = this._playerStates.get(packedStats[i]); + if (state === undefined) continue; + state.tilesOwned = packedStats[i + 1]; + state.gold = packedStats[i + 2]; + state.troops = packedStats[i + 3]; + } + } + + // Packed attack troop counts: [ownerSmallID, direction, index, troops] + // quads. The attack arrays themselves are only resent when membership/ + // order changes, which is also what keeps these indexes valid — a tick + // either resends an array (fresh troops included) or patches it, never + // both. See packAttackTroopDeltas. + const packedAttacks = gu.packedAttackUpdates; + if (packedAttacks !== undefined) { + for (let i = 0; i + 3 < packedAttacks.length; i += 4) { + const state = this._playerStates.get(packedAttacks[i]); + if (state === undefined) continue; + const attacks = + packedAttacks[i + 1] === ATTACK_DELTA_OUTGOING + ? state.outgoingAttacks + : state.incomingAttacks; + const attack = attacks[packedAttacks[i + 2]]; + if (attack !== undefined) { + attack.troops = packedAttacks[i + 3]; + } + } + } + if (this._myClientID) { this._myPlayer ??= this.playerByClientID(this._myClientID); } @@ -441,16 +518,20 @@ export class GameView implements GameMap { this._changedTilesScratch.push({ ref: this.updatedTiles[i], state: 0 }); } - // Names map — rebuilt every tick. Cheap (one entry per player, no big - // arrays). Entry order is irrelevant for the renderer. - this._names.clear(); - for (const p of this._players.values()) { - this._names.set(p.id(), { - playerID: p.id(), - x: p.nameData?.x ?? 0, - y: p.nameData?.y ?? 0, - size: p.nameData?.size ?? 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. + if (this._namesDirty) { + this._namesDirty = false; + this._names.clear(); + for (const p of this._players.values()) { + this._names.set(p.id(), { + playerID: p.id(), + x: p.nameData?.x ?? 0, + y: p.nameData?.y ?? 0, + size: p.nameData?.size ?? 0, + }); + } } // FrameEvents — clear arrays, then re-populate from this tick's updates. @@ -478,16 +559,29 @@ export class GameView implements GameMap { isTransitiveTarget: (sid) => this._myPlayer?.hasTransitiveTarget(sid) ?? false, }); - const rel = buildRelationMatrix(this._playerStates, this._teams); - f.relationMatrix = rel.matrix; - f.relationSize = rel.size; - f.allianceClusters = computeAllianceClusters(this._playerStates); + // Relations + clusters depend only on allies/embargoes/teams, which + // change rarely (teams only when a player is added) — recompute only + // when one of those inputs arrived this tick. buildRelationMatrix + // writes into a reusable module-level buffer, so skipping the call + // leaves f.relationMatrix's contents intact. + if (this._relationsDirty) { + this._relationsDirty = false; + const rel = buildRelationMatrix(this._playerStates, this._teams); + f.relationMatrix = rel.matrix; + f.relationSize = rel.size; + } + if (this._clustersDirty) { + this._clustersDirty = false; + f.allianceClusters = computeAllianceClusters(this._playerStates); + } f.nukeTelegraphs = extractNukeTelegraphs( this._unitStates, this._map.width(), this._myPlayer?.smallID() ?? 0, - rel.matrix, - rel.size, + // The latest relation matrix — recomputed above when dirty, otherwise + // carried over on the frame from the last rebuild. + f.relationMatrix, + f.relationSize, ); f.attackRings = this._myPlayer ? extractAttackRings( @@ -535,6 +629,7 @@ export class GameView implements GameMap { const conquered = this._players.get(c.conqueredId); if (conquered === undefined) continue; const loc = conquered.nameLocation(); + if (loc === undefined) continue; ev.conquestEvents.push({ x: loc.x, y: loc.y, diff --git a/src/client/view/PlayerView.ts b/src/client/view/PlayerView.ts index 175c75035..e425d5ed6 100644 --- a/src/client/view/PlayerView.ts +++ b/src/client/view/PlayerView.ts @@ -123,7 +123,8 @@ export class PlayerView { constructor( private game: GameView, data: PlayerUpdate, - public nameData: NameViewData, + // Undefined until the worker's first name placement for this player. + public nameData: NameViewData | undefined, public cosmetics: PlayerCosmetics, ) { this.state = stateFromUpdate(data); @@ -405,7 +406,7 @@ export class PlayerView { .filter((u) => u.owner().smallID() === this.smallID()); } - nameLocation(): NameViewData { + nameLocation(): NameViewData | undefined { return this.nameData; } diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 914152163..8f931502a 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -160,6 +160,11 @@ export class GameRunner { 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) { @@ -167,6 +172,7 @@ export class GameRunner { } if (p.spawnTile() === undefined) continue; this.playerViewData[p.id()] = placeSpawnName(this.game, p); + viewDataChanged = true; } } @@ -179,17 +185,22 @@ export class GameRunner { 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, - playerNameViewData: this.playerViewData, + ...(viewDataChanged ? { playerNameViewData: this.playerViewData } : {}), tickExecutionDuration: tickExecutionDuration, pendingTurns: pendingTurns ?? 0, }); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 67d6f66c2..dfb751e0a 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -666,7 +666,10 @@ export interface Player { executeRetreat(attackID: string): void; // Misc - toUpdate(): PlayerUpdate | null; + toUpdate( + statsOut?: number[], + attackTroopsOut?: number[], + ): PlayerUpdate | null; playerProfile(): PlayerProfile; // WARNING: this operation is expensive. bestTransportShipSpawn(tile: TileRef): TileRef | false; @@ -720,6 +723,8 @@ export interface Game extends GameMap { drainPackedTileUpdates(): Uint32Array; recordMotionPlan(record: MotionPlanRecord): void; drainPackedMotionPlans(): Uint32Array | null; + drainPackedPlayerUpdates(): Float64Array | null; + drainPackedAttackUpdates(): Float64Array | null; setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void; getWinner(): Player | Team | null; config(): Config; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 397d454ab..04a1473a0 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -95,6 +95,10 @@ export class GameImpl implements Game { private updates: GameUpdates = createGameUpdatesMap(); private tileUpdatePairs: number[] = []; + /** [smallID, tilesOwned, gold, troops] quads — see PlayerImpl.toUpdate. */ + private playerStatsQuads: number[] = []; + /** [smallID, direction, index, troops] quads — see packAttackTroopDeltas. */ + private attackTroopsQuads: number[] = []; private motionPlanRecords: MotionPlanRecord[] = []; private planDrivenUnitIds = new Set(); private unitGrid: UnitGrid; @@ -451,7 +455,10 @@ export class GameImpl implements Game { this.execs.push(...inited); this.unInitExecs = unInited; for (const player of this._players.values()) { - const update = player.toUpdate(); + const update = player.toUpdate( + this.playerStatsQuads, + this.attackTroopsQuads, + ); if (update !== null) this.addUpdate(update); } if (this.ticks() % 10 === 0) { @@ -489,6 +496,22 @@ export class GameImpl implements Game { return packed; } + drainPackedPlayerUpdates(): Float64Array | null { + const quads = this.playerStatsQuads; + if (quads.length === 0) return null; + const packed = Float64Array.from(quads); + quads.length = 0; + return packed; + } + + drainPackedAttackUpdates(): Float64Array | null { + const quads = this.attackTroopsQuads; + if (quads.length === 0) return null; + const packed = Float64Array.from(quads); + quads.length = 0; + return packed; + } + recordMotionPlan(record: MotionPlanRecord): void { switch (record.kind) { case "grid": diff --git a/src/core/game/GameUpdateUtils.ts b/src/core/game/GameUpdateUtils.ts index 751d0b089..0ac2d6e3c 100644 --- a/src/core/game/GameUpdateUtils.ts +++ b/src/core/game/GameUpdateUtils.ts @@ -20,6 +20,12 @@ import { * PlayerUpdate, you MUST add a matching setIfDifferent(...) line here, and an * apply line in applyStateUpdate below. A field missing here is never diffed, * so its changes silently never reach the main thread after the first update. + * + * EXCEPTION: tilesOwned / gold / troops are deliberately NOT diffed here. + * They change for nearly every alive player every tick, so they travel on + * the transferable `GameUpdateViewData.packedPlayerUpdates` channel instead + * (see PlayerImpl.toUpdate) and appear in PlayerUpdate objects only on a + * player's first (full) emission. */ export function diffPlayerUpdate( prev: PlayerUpdate, @@ -46,9 +52,7 @@ export function diffPlayerUpdate( setIfDifferent("playerType", prev.playerType === next.playerType); setIfDifferent("isAlive", prev.isAlive === next.isAlive); setIfDifferent("isDisconnected", prev.isDisconnected === next.isDisconnected); - setIfDifferent("tilesOwned", prev.tilesOwned === next.tilesOwned); - setIfDifferent("gold", prev.gold === next.gold); - setIfDifferent("troops", prev.troops === next.troops); + // tilesOwned / gold / troops intentionally absent — see EXCEPTION above. setIfDifferent("isTraitor", prev.isTraitor === next.isTraitor); setIfDifferent( "traitorRemainingTicks", @@ -76,13 +80,17 @@ export function diffPlayerUpdate( "outgoingEmojis", emojiArrayEqual(prev.outgoingEmojis, next.outgoingEmojis), ); + // Attack arrays are compared WITHOUT troop counts: troops change every + // tick for every active attack and travel via packedAttackUpdates (see + // packAttackTroopDeltas below). The arrays are only resent when + // membership/order/retreating changes. setIfDifferent( "outgoingAttacks", - attackArrayEqual(prev.outgoingAttacks, next.outgoingAttacks), + attackArrayMembershipEqual(prev.outgoingAttacks, next.outgoingAttacks), ); setIfDifferent( "incomingAttacks", - attackArrayEqual(prev.incomingAttacks, next.incomingAttacks), + attackArrayMembershipEqual(prev.incomingAttacks, next.incomingAttacks), ); setIfDifferent( "alliances", @@ -159,7 +167,17 @@ function stringSetEqual(a?: Set, b?: Set): boolean { return true; } -function attackArrayEqual(a?: AttackUpdate[], b?: AttackUpdate[]): boolean { +/** + * Attack-array equality ignoring troop counts: same attacks, same order, + * same retreating flags. When this holds, only troop counts can differ, and + * those travel as packed quads (packAttackTroopDeltas) addressed by index — + * which stays valid precisely because any membership/order change makes + * this false and resends the whole array. + */ +function attackArrayMembershipEqual( + a?: AttackUpdate[], + b?: AttackUpdate[], +): boolean { if (a === b) return true; if (!a || !b) return false; if (a.length !== b.length) return false; @@ -169,7 +187,6 @@ function attackArrayEqual(a?: AttackUpdate[], b?: AttackUpdate[]): boolean { if ( x.attackerID !== y.attackerID || x.targetID !== y.targetID || - x.troops !== y.troops || x.id !== y.id || x.retreating !== y.retreating ) { @@ -179,6 +196,38 @@ function attackArrayEqual(a?: AttackUpdate[], b?: AttackUpdate[]): boolean { return true; } +/** + * Direction lane of a `packedAttackUpdates` quad: which of the owner's attack + * arrays the index addresses. Encoder (PlayerImpl.toUpdate → + * packAttackTroopDeltas) and decoder (client GameView.update) must both use + * these. + */ +export const ATTACK_DELTA_OUTGOING = 0; +export const ATTACK_DELTA_INCOMING = 1; + +/** + * Push a `[ownerSmallID, direction, index, troops]` quad onto `out` for each + * attack whose troop count changed between `prev` and `next`. No-op when the + * arrays are not membership-equal — diffPlayerUpdate resends the whole array + * that tick (carrying fresh troop counts), so patches would be redundant and + * their indexes unreliable. + */ +export function packAttackTroopDeltas( + prev: AttackUpdate[] | undefined, + next: AttackUpdate[] | undefined, + ownerSmallID: number, + direction: typeof ATTACK_DELTA_OUTGOING | typeof ATTACK_DELTA_INCOMING, + out: number[], +): void { + if (prev === next || !prev || !next) return; + if (!attackArrayMembershipEqual(prev, next)) return; + for (let i = 0; i < next.length; i++) { + if (prev[i].troops !== next[i].troops) { + out.push(ownerSmallID, direction, i, next[i].troops); + } + } +} + function allianceArrayEqual(a?: AllianceView[], b?: AllianceView[]): boolean { if (a === b) return true; if (!a || !b) return false; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 900d9d989..1cc35996c 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -33,7 +33,35 @@ export interface GameUpdateViewData { * (similar to `packedTileUpdates`) to avoid structured-clone copies. */ packedMotionPlans?: Uint32Array; - playerNameViewData: Record; + /** + * Packed per-player numeric stats as `[smallID, tilesOwned, gold, troops]` + * float64 quads — the fields that change for nearly every alive player + * every tick. They travel here (transferred, not structured-cloned) instead + * of in `PlayerUpdate` object diffs, which only carry them on a player's + * first emission. Gold is exact in a float64 (game values stay far below + * 2^53). Absent when no player's stats changed. + */ + packedPlayerUpdates?: Float64Array; + /** + * Packed attack troop-count changes as + * `[ownerSmallID, direction, index, troops]` float64 quads, where + * `direction` is 0 for the owner's outgoingAttacks and 1 for + * incomingAttacks, and `index` addresses that array. Troop counts change + * every tick for every active attack, so they travel here instead of + * re-sending whole attack arrays in PlayerUpdate diffs; the arrays + * themselves are only resent when membership/order/retreating changes — + * which also guarantees the receiver's indexes line up (see + * packAttackTroopDeltas). Absent when no attack troop count changed. + */ + packedAttackUpdates?: Float64Array; + /** + * Name placement per player. Only present on ticks where the worker + * recomputed placements (spawn ticks, the first ticks, every 30th tick, + * spawn end) — between those the values wouldn't change, so the record is + * omitted instead of re-cloned every tick. Consumers keep the last applied + * values. + */ + playerNameViewData?: Record; tickExecutionDuration?: number; pendingTurns?: number; } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 92fc3e3d8..7d762efc2 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -41,7 +41,12 @@ import { } from "./Game"; import { GameImpl } from "./GameImpl"; import { andFN, manhattanDistFN, TileRef } from "./GameMap"; -import { diffPlayerUpdate } from "./GameUpdateUtils"; +import { + ATTACK_DELTA_INCOMING, + ATTACK_DELTA_OUTGOING, + diffPlayerUpdate, + packAttackTroopDeltas, +} from "./GameUpdateUtils"; import { AllianceView, AttackUpdate, @@ -154,13 +159,53 @@ export class PlayerImpl implements Player { * return only fields that changed since the previous call (a partial * `{ type, id, ...changedFields }`), or `null` if nothing changed. * + * tilesOwned / gold / troops are excluded from partial updates (they churn + * for every alive player every tick): when any of them changed, a + * `[smallID, tilesOwned, gold, troops]` quad is pushed to `statsOut` + * instead, which GameImpl drains into the transferable + * `packedPlayerUpdates` buffer. Attack troop counts likewise go to + * `attackTroopsOut` as `[smallID, direction, index, troops]` quads + * (→ `packedAttackUpdates`) instead of re-sending whole attack arrays. + * * `lastSentUpdate` is updated to the full snapshot on every call. */ - toUpdate(): PlayerUpdate | null { + toUpdate( + statsOut?: number[], + attackTroopsOut?: number[], + ): PlayerUpdate | null { const full = this.toFullUpdate(); const prev = this.lastSentUpdate; this.lastSentUpdate = full; if (prev === undefined) return full; + if ( + statsOut !== undefined && + (prev.tilesOwned !== full.tilesOwned || + prev.gold !== full.gold || + prev.troops !== full.troops) + ) { + statsOut.push( + full.smallID!, + full.tilesOwned!, + Number(full.gold), + full.troops!, + ); + } + if (attackTroopsOut !== undefined) { + packAttackTroopDeltas( + prev.outgoingAttacks, + full.outgoingAttacks, + full.smallID!, + ATTACK_DELTA_OUTGOING, + attackTroopsOut, + ); + packAttackTroopDeltas( + prev.incomingAttacks, + full.incomingAttacks, + full.smallID!, + ATTACK_DELTA_INCOMING, + attackTroopsOut, + ); + } return diffPlayerUpdate(prev, full); } diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index aa0b83616..6a5acb8e6 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -113,6 +113,12 @@ function sendGameUpdateBatch(gameUpdates: GameUpdateViewData[]): void { if (gu.packedMotionPlans) { transfers.push(gu.packedMotionPlans.buffer); } + if (gu.packedPlayerUpdates) { + transfers.push(gu.packedPlayerUpdates.buffer); + } + if (gu.packedAttackUpdates) { + transfers.push(gu.packedAttackUpdates.buffer); + } } ctx.postMessage( diff --git a/tests/GameUpdateUtils.test.ts b/tests/GameUpdateUtils.test.ts index 9b440b3cc..842188cf4 100644 --- a/tests/GameUpdateUtils.test.ts +++ b/tests/GameUpdateUtils.test.ts @@ -4,8 +4,13 @@ import { PlayerType } from "../src/core/game/Game"; import { applyStateUpdate, diffPlayerUpdate, + packAttackTroopDeltas, } from "../src/core/game/GameUpdateUtils"; -import { GameUpdateType, PlayerUpdate } from "../src/core/game/GameUpdates"; +import { + AttackUpdate, + GameUpdateType, + PlayerUpdate, +} from "../src/core/game/GameUpdates"; import { makePlayerUpdate } from "./util/viewStubs"; function makePlayerState(overrides: Partial = {}): PlayerState { @@ -41,24 +46,30 @@ describe("diffPlayerUpdate", () => { }); it("returns a diff with only changed primitives plus type+id", () => { - const prev = makePlayerUpdate({ gold: 100n }); - const next = makePlayerUpdate({ gold: 250n }); + const prev = makePlayerUpdate({ betrayals: 0 }); + const next = makePlayerUpdate({ betrayals: 1 }); const diff = diffPlayerUpdate(prev, next); expect(diff).not.toBeNull(); expect(diff).toEqual({ type: GameUpdateType.Player, id: "player-a", - gold: 250n, + betrayals: 1, }); }); it("includes every changed primitive in a single diff", () => { - const prev = makePlayerUpdate({ gold: 100n, troops: 50, tilesOwned: 5 }); - const next = makePlayerUpdate({ gold: 200n, troops: 75, tilesOwned: 5 }); + const prev = makePlayerUpdate({ betrayals: 0, isTraitor: false }); + const next = makePlayerUpdate({ betrayals: 1, isTraitor: true }); const diff = diffPlayerUpdate(prev, next)!; - expect(diff.gold).toBe(200n); - expect(diff.troops).toBe(75); - expect(diff.tilesOwned).toBeUndefined(); + expect(diff.betrayals).toBe(1); + expect(diff.isTraitor).toBe(true); + expect(diff.hasSpawned).toBeUndefined(); + }); + + it("ignores tilesOwned/gold/troops — they travel via packedPlayerUpdates", () => { + const prev = makePlayerUpdate({ gold: 100n, troops: 50, tilesOwned: 5 }); + const next = makePlayerUpdate({ gold: 200n, troops: 75, tilesOwned: 9 }); + expect(diffPlayerUpdate(prev, next)).toBeNull(); }); it("detects allies array additions", () => { @@ -94,7 +105,22 @@ describe("diffPlayerUpdate", () => { expect(diffPlayerUpdate(prev, next)).toBeNull(); }); - it("detects outgoingAttacks element changes", () => { + it("detects outgoingAttacks membership/retreating changes", () => { + const prev = makePlayerUpdate({ + outgoingAttacks: [ + { attackerID: 1, targetID: 2, troops: 10, id: "a", retreating: false }, + ], + }); + const next = makePlayerUpdate({ + outgoingAttacks: [ + { attackerID: 1, targetID: 2, troops: 10, id: "a", retreating: true }, + ], + }); + const diff = diffPlayerUpdate(prev, next)!; + expect(diff.outgoingAttacks).toEqual(next.outgoingAttacks); + }); + + it("ignores attack troop-count changes — they travel via packedAttackUpdates", () => { const prev = makePlayerUpdate({ outgoingAttacks: [ { attackerID: 1, targetID: 2, troops: 10, id: "a", retreating: false }, @@ -105,8 +131,7 @@ describe("diffPlayerUpdate", () => { { attackerID: 1, targetID: 2, troops: 20, id: "a", retreating: false }, ], }); - const diff = diffPlayerUpdate(prev, next)!; - expect(diff.outgoingAttacks).toEqual(next.outgoingAttacks); + expect(diffPlayerUpdate(prev, next)).toBeNull(); }); it("detects alliance list changes", () => { @@ -143,14 +168,76 @@ describe("diffPlayerUpdate", () => { }); it("always includes type and id on a non-null diff", () => { - const prev = makePlayerUpdate({ gold: 100n }); - const next = makePlayerUpdate({ gold: 200n }); + const prev = makePlayerUpdate({ betrayals: 0 }); + const next = makePlayerUpdate({ betrayals: 1 }); const diff = diffPlayerUpdate(prev, next)!; expect(diff.type).toBe(GameUpdateType.Player); expect(diff.id).toBe(next.id); }); }); +describe("packAttackTroopDeltas", () => { + const attack = ( + troops: number, + id = "a", + retreating = false, + ): AttackUpdate => ({ + attackerID: 1, + targetID: 2, + troops, + id, + retreating, + }); + + it("emits [owner, direction, index, troops] quads for changed troop counts", () => { + const out: number[] = []; + packAttackTroopDeltas( + [attack(10, "a"), attack(20, "b")], + [attack(10, "a"), attack(15, "b")], + 7, + 1, + out, + ); + expect(out).toEqual([7, 1, 1, 15]); + }); + + it("emits nothing when arrays are not membership-equal (diff resends them)", () => { + const out: number[] = []; + packAttackTroopDeltas( + [attack(10, "a")], + [attack(15, "a"), attack(5, "b")], + 7, + 0, + out, + ); + expect(out).toEqual([]); + }); + + it("a retreat flip suppresses quads even when troops also changed", () => { + // retreating is part of membership equality, so the whole array resends + // (with fresh troops) and patches must NOT be emitted — a tick resends + // or patches, never both. + const out: number[] = []; + packAttackTroopDeltas( + [attack(10, "a", false)], + [attack(5, "a", true)], + 7, + 0, + out, + ); + expect(out).toEqual([]); + }); + + it("emits nothing for identical references or missing arrays", () => { + const out: number[] = []; + const arr = [attack(10)]; + packAttackTroopDeltas(arr, arr, 7, 0, out); + packAttackTroopDeltas(undefined, arr, 7, 0, out); + packAttackTroopDeltas(arr, undefined, 7, 0, out); + expect(out).toEqual([]); + }); +}); + describe("applyStateUpdate", () => { it("applies every field from a full update", () => { const target = makePlayerState(); @@ -276,18 +363,10 @@ describe("applyStateUpdate", () => { describe("diff + apply round-trip", () => { it("emitting full first + diff second reconstructs final state", () => { - const v0 = makePlayerUpdate({ - gold: 0n, - troops: 100, - tilesOwned: 0, - allies: [], - }); - const v1 = makePlayerUpdate({ - gold: 200n, - troops: 150, - tilesOwned: 5, - allies: [2], - }); + // tilesOwned/gold/troops round-trip via packedPlayerUpdates instead + // (covered in tests/client/view/GameView.test.ts). + const v0 = makePlayerUpdate({ betrayals: 0, allies: [] }); + const v1 = makePlayerUpdate({ betrayals: 2, allies: [2] }); // Initial state: receiver applies the full update. const target = makePlayerState(); @@ -298,9 +377,7 @@ describe("diff + apply round-trip", () => { expect(diff).not.toBeNull(); applyStateUpdate(target, diff); - expect(target.gold).toBe(200); - expect(target.troops).toBe(150); - expect(target.tilesOwned).toBe(5); + expect(target.betrayals).toBe(2); expect(target.allies).toEqual([2]); }); diff --git a/tests/PackedPlayerUpdates.test.ts b/tests/PackedPlayerUpdates.test.ts new file mode 100644 index 000000000..5eca4dae6 --- /dev/null +++ b/tests/PackedPlayerUpdates.test.ts @@ -0,0 +1,155 @@ +/** + * The worker→main tick payload: per-tick numeric stat churn travels on the + * transferable `packedPlayerUpdates` quad buffer (drained from GameImpl), + * and `playerNameViewData` is attached only on ticks where the worker + * recomputed name placements. See GameUpdateViewData in GameUpdates.ts. + */ +import { Executor } from "../src/core/execution/ExecutionManager"; +import { SpawnExecution } from "../src/core/execution/SpawnExecution"; +import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game"; +import { + GameUpdateType, + GameUpdateViewData, +} from "../src/core/game/GameUpdates"; +import { GameRunner } from "../src/core/GameRunner"; +import { setup } from "./util/Setup"; + +const gameID = "game_id"; + +describe("packedPlayerUpdates (GameImpl drain)", () => { + let game: Game; + let alice: Player; + + beforeEach(async () => { + game = await setup("plains", {}); + const aliceInfo = new PlayerInfo( + "alice", + PlayerType.Human, + "alice_client", + "alice_id", + ); + game.addPlayer(aliceInfo); + game.addExecution(new SpawnExecution(gameID, aliceInfo, game.ref(10, 10))); + game.executeNextTick(); + game.executeNextTick(); + alice = game.player("alice_id"); + game.drainPackedPlayerUpdates(); // discard spawn-time churn + }); + + test("a stat change is drained as a [smallID, tiles, gold, troops] quad", () => { + alice.addGold(500n); + game.executeNextTick(); + const packed = game.drainPackedPlayerUpdates(); + expect(packed).not.toBeNull(); + // Find alice's quad (other players may have churned too). + let quad: number[] | undefined; + for (let i = 0; i + 3 < packed!.length; i += 4) { + if (packed![i] === alice.smallID()) { + quad = Array.from(packed!.subarray(i, i + 4)); + } + } + expect(quad).toEqual([ + alice.smallID(), + alice.numTilesOwned(), + Number(alice.gold()), + alice.troops(), + ]); + }); + + test("drain returns null when no stats changed and resets between drains", () => { + alice.addGold(500n); + game.executeNextTick(); + expect(game.drainPackedPlayerUpdates()).not.toBeNull(); + // Drained — a second drain without a tick has nothing. + expect(game.drainPackedPlayerUpdates()).toBeNull(); + }); +}); + +describe("GameRunner payload cadence", () => { + let game: Game; + let byTick: Map; + let tick: () => void; + + beforeEach(async () => { + game = await setup( + "plains", + {}, + [], + undefined, + undefined, + false, // keep the spawn phase under the test's control + ); + const aliceInfo = new PlayerInfo( + "alice", + PlayerType.Human, + "alice_client", + "alice_id", + ); + game.addPlayer(aliceInfo); + game.addExecution(new SpawnExecution(gameID, aliceInfo, game.ref(10, 10))); + byTick = new Map(); + const runner = new GameRunner( + game, + new Executor(game, gameID, "alice_client"), + (gu) => { + if (!("errMsg" in gu)) byTick.set(gu.tick, gu); + }, + ); + // No runner.init(): no SpawnTimerExecution — the game stays in the spawn + // phase until the test ends it manually. + let turn = 0; + tick = () => { + runner.addTurn({ turnNumber: turn++, intents: [] }); + runner.executeNextTick(); + }; + }); + + test("playerNameViewData is attached only on placement-rebuild ticks", () => { + tick(); // 1 + tick(); // 2 + game.endSpawnPhase(); + for (let t = 3; t <= 61; t++) tick(); + + // ticks < 3 always rebuild; every 30th tick rebuilds; everything else + // omits the record. (The in-tick spawn-end rebuild also sets the flag, + // but ending the spawn phase between ticks doesn't exercise it here.) + expect(byTick.get(1)!.playerNameViewData).toBeDefined(); + expect(byTick.get(2)!.playerNameViewData).toBeDefined(); + expect(byTick.get(4)!.playerNameViewData).toBeUndefined(); + expect(byTick.get(29)!.playerNameViewData).toBeUndefined(); + expect(byTick.get(30)!.playerNameViewData).toBeDefined(); + expect(byTick.get(31)!.playerNameViewData).toBeUndefined(); + expect(byTick.get(60)!.playerNameViewData).toBeDefined(); + }); + + test("stat churn arrives as packedPlayerUpdates quads on the view data", () => { + tick(); // 1 + tick(); // 2 + game.endSpawnPhase(); + tick(); // 3 — flush spawn churn + + const alice = game.player("alice_id"); + alice.addGold(500n); + tick(); // 4 + const gu = byTick.get(game.ticks())!; + const packed = gu.packedPlayerUpdates; + expect(packed).toBeDefined(); + expect(packed!.length % 4).toBe(0); + let quad: number[] | undefined; + for (let i = 0; i + 3 < packed!.length; i += 4) { + if (packed![i] === alice.smallID()) { + quad = Array.from(packed!.subarray(i, i + 4)); + } + } + expect(quad).toEqual([ + alice.smallID(), + alice.numTilesOwned(), + Number(alice.gold()), + alice.troops(), + ]); + // And the object channel no longer carries the stat fields: alice must + // not appear in this tick's PlayerUpdates for a gold-only change. + const playerUpdates = gu.updates[GameUpdateType.Player]; + expect(playerUpdates.find((u) => u.id === "alice_id")).toBeUndefined(); + }); +}); diff --git a/tests/PlayerUpdateDiff.test.ts b/tests/PlayerUpdateDiff.test.ts index 049ddae5b..37e9c8a2f 100644 --- a/tests/PlayerUpdateDiff.test.ts +++ b/tests/PlayerUpdateDiff.test.ts @@ -72,10 +72,10 @@ describe("Player update diffing (toUpdate)", () => { test("primitive changes appear in the diff without unchanged collections", () => { alice.toUpdate(); - alice.addGold(123n); + alice.markTraitor(); const diff = alice.toUpdate(); expect(diff).not.toBeNull(); - expect(diff!.gold).toBe(alice.gold()); + expect(diff!.isTraitor).toBe(true); // Unchanged collection fields must be absent from the diff. expect(diff!.allies).toBeUndefined(); expect(diff!.embargoes).toBeUndefined(); @@ -83,6 +83,52 @@ describe("Player update diffing (toUpdate)", () => { expect(diff!.alliances).toBeUndefined(); }); + test("stat churn (gold/troops/tilesOwned) travels via statsOut, not the diff", () => { + const statsOut: number[] = []; + alice.toUpdate(statsOut); + statsOut.length = 0; + + alice.addGold(123n); + const diff = alice.toUpdate(statsOut); + // No object diff — gold alone must not put the player on the object + // channel (that's the whole point of the packed stats channel). + expect(diff).toBeNull(); + expect(statsOut).toEqual([ + alice.smallID(), + alice.numTilesOwned(), + Number(alice.gold()), + alice.troops(), + ]); + + // Nothing changed → no quad, no diff. + statsOut.length = 0; + expect(alice.toUpdate(statsOut)).toBeNull(); + expect(statsOut).toEqual([]); + + // A non-stat change produces an object diff but no quad. + alice.markTraitor(); + expect(alice.toUpdate(statsOut)).not.toBeNull(); + expect(statsOut).toEqual([]); + }); + + test("first emission carries the stats in the full snapshot, not statsOut", () => { + const info = new PlayerInfo( + "dora", + PlayerType.Human, + "dora_client", + "dora_id", + ); + game.addPlayer(info); + const dora = game.player("dora_id"); + const statsOut: number[] = []; + const full = dora.toUpdate(statsOut); + expect(full).not.toBeNull(); + expect(full!.gold).toBe(dora.gold()); + expect(full!.troops).toBe(dora.troops()); + expect(full!.tilesOwned).toBe(dora.numTilesOwned()); + expect(statsOut).toEqual([]); + }); + test("adding and removing an embargo shows up in consecutive diffs", () => { alice.toUpdate(); alice.addEmbargo(bob, false); @@ -156,17 +202,33 @@ describe("Player update diffing (toUpdate)", () => { alice.smallID(), ); - // As the attack progresses, troop counts change and must keep flowing - // through subsequent diffs. + // As the attack progresses, troop counts change — but attack arrays are + // NOT resent for troop-only changes. Troops flow as packed + // [ownerSmallID, direction, index, troops] quads instead. + game.drainPackedAttackUpdates(); // discard quads from earlier ticks const nextUpdates = game.executeNextTick(); const nextPlayerUpdates = nextUpdates[ GameUpdateType.Player ] as PlayerUpdate[]; const next = nextPlayerUpdates.find((u) => u.id === "alice_id"); - expect(next).toBeDefined(); - expect( - next!.outgoingAttacks!.some((a) => a.targetID === bob.smallID()), - ).toBe(true); + if (next !== undefined) { + // Alice may appear for other field changes, but not for attack arrays. + expect(next.outgoingAttacks).toBeUndefined(); + } + const packed = game.drainPackedAttackUpdates(); + expect(packed).not.toBeNull(); + // Find alice's outgoing quads and check one matches her current attack. + const aliceQuads: number[][] = []; + for (let i = 0; i + 3 < packed!.length; i += 4) { + if (packed![i] === alice.smallID() && packed![i + 1] === 0) { + aliceQuads.push(Array.from(packed!.subarray(i, i + 4))); + } + } + expect(aliceQuads.length).toBeGreaterThan(0); + const aliceAttacks = alice.outgoingAttacks(); + for (const [, , index, troops] of aliceQuads) { + expect(troops).toBe(aliceAttacks[index].troops()); + } }); test("in-worker mutation of shared empty collections fails loudly", () => { diff --git a/tests/client/view/GameView.test.ts b/tests/client/view/GameView.test.ts index f520aba9f..9abe7400d 100644 --- a/tests/client/view/GameView.test.ts +++ b/tests/client/view/GameView.test.ts @@ -27,9 +27,11 @@ function withPlayers( ) { const gu = makeEmptyGu(tick); gu.updates[GameUpdateType.Player] = players; + const nameViewData: NonNullable = {}; for (const p of players) { - gu.playerNameViewData[p.id] = nameDataMap[p.id] ?? makeNameViewData(); + nameViewData[p.id] = nameDataMap[p.id] ?? makeNameViewData(); } + gu.playerNameViewData = nameViewData; return gu; } @@ -140,6 +142,288 @@ describe("GameView.update — players", () => { }); }); +describe("GameView.update — packed channels", () => { + it("packedPlayerUpdates quads update tilesOwned/gold/troops in place", () => { + const game = makeGameView(); + game.update( + withPlayers(1, [ + makePlayerUpdate({ id: "alice", smallID: 1, troops: 100, gold: 5n }), + ]), + ); + + const gu = makeEmptyGu(2); + // [smallID, tilesOwned, gold, troops] + gu.packedPlayerUpdates = new Float64Array([1, 42, 999, 250]); + game.update(gu); + + const alice = game.player("alice"); + expect(alice.numTilesOwned()).toBe(42); + expect(alice.gold()).toBe(999n); + expect(alice.troops()).toBe(250); + }); + + it("packedAttackUpdates patches troop counts by direction and index", () => { + const game = makeGameView(); + game.update( + withPlayers(1, [ + makePlayerUpdate({ + id: "alice", + smallID: 1, + outgoingAttacks: [ + { + attackerID: 1, + targetID: 2, + troops: 500, + id: "a1", + retreating: false, + }, + { + attackerID: 1, + targetID: 3, + troops: 300, + id: "a2", + retreating: false, + }, + ], + incomingAttacks: [ + { + attackerID: 4, + targetID: 1, + troops: 80, + id: "a3", + retreating: false, + }, + ], + }), + ]), + ); + + const gu = makeEmptyGu(2); + // [ownerSmallID, direction (0=outgoing, 1=incoming), index, troops] + gu.packedAttackUpdates = new Float64Array([1, 0, 1, 290, 1, 1, 0, 75]); + game.update(gu); + + const alice = game.player("alice"); + expect(alice.outgoingAttacks().map((a) => a.troops)).toEqual([500, 290]); + expect(alice.incomingAttacks().map((a) => a.troops)).toEqual([75]); + }); + + it("quads for unknown smallIDs and out-of-range attack indexes are ignored", () => { + const game = makeGameView(); + game.update( + withPlayers(1, [makePlayerUpdate({ id: "alice", smallID: 1 })]), + ); + const gu = makeEmptyGu(2); + gu.packedPlayerUpdates = new Float64Array([99, 1, 1, 1]); + gu.packedAttackUpdates = new Float64Array([1, 0, 5, 123, 99, 1, 0, 7]); + expect(() => game.update(gu)).not.toThrow(); + }); + + it("same-tick array resend and patch on different directions both apply", () => { + const game = makeGameView(); + game.update( + withPlayers(1, [ + makePlayerUpdate({ + id: "alice", + smallID: 1, + outgoingAttacks: [ + { + attackerID: 1, + targetID: 2, + troops: 500, + id: "a1", + retreating: false, + }, + ], + incomingAttacks: [ + { + attackerID: 4, + targetID: 1, + troops: 80, + id: "a3", + retreating: false, + }, + ], + }), + ]), + ); + + // Outgoing membership changed → full array resent with fresh troops; + // incoming membership unchanged → troops arrive as a patch. The patch + // must land on the long-lived incoming array and not interfere with the + // resent outgoing array (a tick resends or patches each array, never + // both — but different directions can mix on one tick). + const gu = makeEmptyGu(2); + gu.updates[GameUpdateType.Player] = [ + { + type: GameUpdateType.Player, + id: "alice", + outgoingAttacks: [ + { + attackerID: 1, + targetID: 2, + troops: 450, + id: "a1", + retreating: false, + }, + { + attackerID: 1, + targetID: 3, + troops: 100, + id: "a2", + retreating: false, + }, + ], + }, + ]; + gu.packedAttackUpdates = new Float64Array([1, 1, 0, 75]); + game.update(gu); + + const alice = game.player("alice"); + expect(alice.outgoingAttacks().map((a) => a.troops)).toEqual([450, 100]); + expect(alice.incomingAttacks().map((a) => a.troops)).toEqual([75]); + }); + + it("gold survives the float64 quad exactly, including > 2^32 values", () => { + const game = makeGameView(); + game.update( + withPlayers(1, [makePlayerUpdate({ id: "alice", smallID: 1 })]), + ); + const bigGold = 2 ** 52 + 11; // integer, exactly representable in f64 + const gu = makeEmptyGu(2); + gu.packedPlayerUpdates = new Float64Array([1, 0, bigGold, 0]); + game.update(gu); + expect(game.player("alice").gold()).toBe(BigInt(bigGold)); + }); + + it("nameData persists across ticks without a playerNameViewData record", () => { + const game = makeGameView(); + game.update( + withPlayers(1, [makePlayerUpdate({ id: "alice", smallID: 1 })], { + alice: { x: 7, y: 9, size: 3 }, + }), + ); + expect(game.frameData().names.get("alice")).toMatchObject({ x: 7, y: 9 }); + + // Tick without a record (worker omits it between placement rebuilds) — + // even with a player update present, the old placement must survive. + const gu = makeEmptyGu(2); + gu.updates[GameUpdateType.Player] = [ + makePlayerUpdate({ id: "alice", smallID: 1 }), + ]; + game.update(gu); + expect(game.frameData().names.get("alice")).toMatchObject({ x: 7, y: 9 }); + + // A new record updates the placement (alice is alive). + const gu3 = makeEmptyGu(3); + gu3.playerNameViewData = { alice: { x: 11, y: 13, size: 4 } }; + game.update(gu3); + expect(game.frameData().names.get("alice")).toMatchObject({ x: 11, y: 13 }); + }); + + it("dead players keep their last name placement (freeze at death)", () => { + const game = makeGameView(); + game.update( + withPlayers(1, [makePlayerUpdate({ id: "alice", smallID: 1 })], { + alice: { x: 7, y: 9, size: 3 }, + }), + ); + + // Alice dies. + const gu2 = makeEmptyGu(2); + gu2.updates[GameUpdateType.Player] = [ + makePlayerUpdate({ id: "alice", smallID: 1, isAlive: false }), + ]; + game.update(gu2); + + // A later record must not move her name. + const gu3 = makeEmptyGu(3); + gu3.playerNameViewData = { alice: { x: 0, y: 0, size: 0 } }; + game.update(gu3); + expect(game.frameData().names.get("alice")).toMatchObject({ x: 7, y: 9 }); + }); +}); + +describe("GameView.update — derived-data dirty flags", () => { + function twoPlayers() { + const game = makeGameView(); + game.update( + withPlayers(1, [ + makePlayerUpdate({ id: "alice", smallID: 1 }), + makePlayerUpdate({ id: "bob", smallID: 2 }), + ]), + ); + return game; + } + + it("relationMatrix recomputes when allies arrive on a partial update", () => { + const game = twoPlayers(); + const size = game.frameData().relationSize; + expect(game.frameData().relationMatrix[1 * size + 2]).toBe(0); // neutral + + const gu = makeEmptyGu(2); + gu.updates[GameUpdateType.Player] = [ + { type: GameUpdateType.Player, id: "alice", allies: [2] }, + ]; + game.update(gu); + // friendly, both directions + expect(game.frameData().relationMatrix[1 * size + 2]).toBe(1); + expect(game.frameData().relationMatrix[2 * size + 1]).toBe(1); + }); + + it("relationMatrix recomputes when embargoes arrive on a partial update", () => { + const game = twoPlayers(); + const size = game.frameData().relationSize; + + const gu = makeEmptyGu(2); + gu.updates[GameUpdateType.Player] = [ + { + type: GameUpdateType.Player, + id: "alice", + embargoes: new Set(["bob"]), + }, + ]; + game.update(gu); + expect(game.frameData().relationMatrix[1 * size + 2]).toBe(2); // embargo + }); + + it("allianceClusters keep identity on clean ticks and recompute on allies change", () => { + const game = twoPlayers(); + const before = game.frameData().allianceClusters; + expect(before.get(1)).not.toBe(before.get(2)); // separate clusters + + // Clean tick: no relation inputs changed → cached object, untouched. + game.update(makeEmptyGu(2)); + expect(game.frameData().allianceClusters).toBe(before); + + // Alliance forms → recomputed: alice and bob share a cluster root. + const gu = makeEmptyGu(3); + gu.updates[GameUpdateType.Player] = [ + { type: GameUpdateType.Player, id: "alice", allies: [2] }, + { type: GameUpdateType.Player, id: "bob", allies: [1] }, + ]; + game.update(gu); + const after = game.frameData().allianceClusters; + expect(after).not.toBe(before); + expect(after.get(1)).toBe(after.get(2)); + }); + + it("names map keeps identity and content on ticks without a record", () => { + const game = makeGameView(); + game.update( + withPlayers(1, [makePlayerUpdate({ id: "alice", smallID: 1 })], { + alice: { x: 7, y: 9, size: 3 }, + }), + ); + const names = game.frameData().names; + const entry = names.get("alice"); + + game.update(makeEmptyGu(2)); + expect(game.frameData().names).toBe(names); // long-lived map + expect(game.frameData().names.get("alice")).toBe(entry); // not rebuilt + }); +}); + describe("GameView.update — units", () => { it("creates a UnitView on first sighting and reuses it after", () => { const game = makeGameView(); diff --git a/tests/perf/client/ClientUpdatePerf.ts b/tests/perf/client/ClientUpdatePerf.ts index 77774685d..c6ba717b0 100644 --- a/tests/perf/client/ClientUpdatePerf.ts +++ b/tests/perf/client/ClientUpdatePerf.ts @@ -424,6 +424,12 @@ async function main(): Promise { if (gu.packedMotionPlans) { transfers.push(gu.packedMotionPlans.buffer); } + if (gu.packedPlayerUpdates) { + transfers.push(gu.packedPlayerUpdates.buffer); + } + if (gu.packedAttackUpdates) { + transfers.push(gu.packedAttackUpdates.buffer); + } start = performance.now(); const cloned = structuredClone(gu, { transfer: transfers }); const cloneMs = performance.now() - start; diff --git a/tests/util/viewStubs.ts b/tests/util/viewStubs.ts index e8a059d31..af8e3b75d 100644 --- a/tests/util/viewStubs.ts +++ b/tests/util/viewStubs.ts @@ -204,7 +204,9 @@ export function makeEmptyGu( tick, updates, packedTileUpdates: new Uint32Array(0), - playerNameViewData: {}, + // 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, }; }