mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-28 13:48:10 +00:00
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>
This commit is contained in:
+113
-18
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user