Cut worker→main bandwidth ~3.3× by switching PlayerUpdate to deltas (#3967)

## Description:

Cut worker→main bandwidth ~3.3× by switching PlayerUpdate from a full
per-tick snapshot to a field-level diff. PlayerImpl.toUpdate() now
caches the last sent update and returns only changed fields, or null if
nothing changed. The client-side applyStateUpdate() merges instead of
overwriting.

Per-tick total dropped from ~297 KB to ~89 KB; the Player bucket alone
went from 258 KB/tick to 50 KB/tick. Diff/apply logic lives in a new
GameUpdateUtils.ts module with unit tests.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
Evan
2026-05-18 17:07:40 -07:00
committed by GitHub
parent ed928db081
commit 62e15d2794
9 changed files with 577 additions and 92 deletions
+22 -13
View File
@@ -304,42 +304,51 @@ export class GameView implements GameMap {
// 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
// is present only when its value changed since the last emission.
gu.updates[GameUpdateType.Player].forEach((pu) => {
// First-emission (new player) — must have all static fields populated.
// Subsequent emissions for an existing player carry only changed fields.
const existing = this._players.get(pu.id);
// Replace the local player's name/displayName with their own stored values.
// This way the user does not know they are being censored.
if (pu.clientID === this._myClientID) {
// This way the user does not know they are being censored. clientID is
// static — present only on first emission — so this branch only runs once.
if (pu.clientID !== undefined && pu.clientID === this._myClientID) {
pu.name = this._myUsername;
pu.displayName = myDisplayName;
}
this.smallIDToID.set(pu.smallID, pu.id);
let player = this._players.get(pu.id);
if (player !== undefined) {
player.applyUpdate(pu);
if (pu.smallID !== undefined) {
this.smallIDToID.set(pu.smallID, pu.id);
}
if (existing !== undefined) {
existing.applyUpdate(pu);
const nextNameData = gu.playerNameViewData[pu.id];
if (nextNameData !== undefined) {
player.nameData = nextNameData;
existing.nameData = nextNameData;
}
} else {
player = new PlayerView(
const player = new PlayerView(
this,
pu,
gu.playerNameViewData[pu.id],
// First check human by clientID, then check nation by name.
this._cosmetics.get(pu.clientID ?? "") ??
this._cosmetics.get(pu.name) ??
this._cosmetics.get(pu.name!) ??
{},
);
this._players.set(pu.id, player);
this._playerStates.set(pu.smallID, player.state);
this._playerStates.set(pu.smallID!, player.state);
}
});
// Pass 2: translate engine embargoes (Set<PlayerID>) → renderer-format
// stringified smallIDs. We could do this only on changes, but embargo sets
// are typically small (<50 entries per player). Pass through all in case
// any pu in this tick referenced a player created in this same tick.
// smallIDs. Only re-translate when embargoes changed (field present);
// unchanged sets stay at the previously-computed renderer-format list.
gu.updates[GameUpdateType.Player].forEach((pu) => {
if (pu.embargoes === undefined) return;
const player = this._players.get(pu.id);
if (player === undefined) return;
const smallIDs: number[] = [];
+30 -48
View File
@@ -21,6 +21,7 @@ import {
UnitType,
} from "../../core/game/Game";
import { TileRef } from "../../core/game/GameMap";
import { applyStateUpdate } from "../../core/game/GameUpdateUtils";
import {
AllianceView,
AttackUpdate,
@@ -50,16 +51,19 @@ function gamePlayerTypeToEnum(t: PlayerType): PlayerTypeEnum {
}
}
// First-emission updates from the engine always include every field; these
// builders assert non-null for that contract. Subsequent diffs are partial
// and flow through applyStateUpdate() below.
function staticFromUpdate(pu: PlayerUpdate): PlayerStatic {
return {
smallID: pu.smallID,
smallID: pu.smallID!,
id: pu.id,
name: pu.name,
displayName: pu.displayName,
clientID: pu.clientID,
playerType: gamePlayerTypeToEnum(pu.playerType),
name: pu.name!,
displayName: pu.displayName!,
clientID: pu.clientID ?? null,
playerType: gamePlayerTypeToEnum(pu.playerType!),
team: pu.team ?? null,
isLobbyCreator: pu.isLobbyCreator,
isLobbyCreator: pu.isLobbyCreator!,
};
}
@@ -68,51 +72,28 @@ function stateFromUpdate(pu: PlayerUpdate): PlayerState {
// smallIDs (numbers). GameView fills these in via setEmbargoes() because
// it has the PlayerID → smallID lookup table.
return {
smallID: pu.smallID,
isAlive: pu.isAlive,
isDisconnected: pu.isDisconnected,
tilesOwned: pu.tilesOwned,
gold: Number(pu.gold),
troops: pu.troops,
isTraitor: pu.isTraitor,
smallID: pu.smallID!,
isAlive: pu.isAlive!,
isDisconnected: pu.isDisconnected!,
tilesOwned: pu.tilesOwned!,
gold: Number(pu.gold!),
troops: pu.troops!,
isTraitor: pu.isTraitor!,
traitorRemainingTicks: Math.max(0, pu.traitorRemainingTicks ?? 0),
betrayals: pu.betrayals,
hasSpawned: pu.hasSpawned,
lastDeleteUnitTick: pu.lastDeleteUnitTick,
allies: pu.allies.slice(),
betrayals: pu.betrayals!,
hasSpawned: pu.hasSpawned!,
lastDeleteUnitTick: pu.lastDeleteUnitTick!,
allies: pu.allies!.slice(),
embargoes: [],
targets: pu.targets.slice(),
outgoingAttacks: pu.outgoingAttacks,
incomingAttacks: pu.incomingAttacks,
outgoingAllianceRequests: pu.outgoingAllianceRequests.slice(),
alliances: pu.alliances,
outgoingEmojis: pu.outgoingEmojis,
targets: pu.targets!.slice(),
outgoingAttacks: pu.outgoingAttacks!,
incomingAttacks: pu.incomingAttacks!,
outgoingAllianceRequests: pu.outgoingAllianceRequests!.slice(),
alliances: pu.alliances!,
outgoingEmojis: pu.outgoingEmojis!,
};
}
function applyStateUpdate(target: PlayerState, pu: PlayerUpdate): void {
// smallID is identity — never changes for a given PlayerView.
target.isAlive = pu.isAlive;
target.isDisconnected = pu.isDisconnected;
target.tilesOwned = pu.tilesOwned;
target.gold = Number(pu.gold);
target.troops = pu.troops;
target.isTraitor = pu.isTraitor;
target.traitorRemainingTicks = Math.max(0, pu.traitorRemainingTicks ?? 0);
target.betrayals = pu.betrayals;
target.hasSpawned = pu.hasSpawned;
target.lastDeleteUnitTick = pu.lastDeleteUnitTick;
// Slice() to detach from the wire object — accumulated state mustn't share
// mutable arrays with per-tick update payloads.
target.allies = pu.allies.slice();
target.targets = pu.targets.slice();
target.outgoingAllianceRequests = pu.outgoingAllianceRequests.slice();
target.outgoingAttacks = pu.outgoingAttacks;
target.incomingAttacks = pu.incomingAttacks;
target.alliances = pu.alliances;
target.outgoingEmojis = pu.outgoingEmojis;
}
export class PlayerView {
public anonymousName: string | null = null;
private decoder?: PatternDecoder;
@@ -144,10 +125,11 @@ export class PlayerView {
this.state = stateFromUpdate(data);
this.static = staticFromUpdate(data);
// First emission always carries name + playerType (see staticFromUpdate).
if (data.clientID === game.myClientID()) {
this.anonymousName = data.name;
this.anonymousName = data.name!;
} else {
this.anonymousName = createRandomName(data.name, data.playerType);
this.anonymousName = createRandomName(data.name!, data.playerType!);
}
const theme = this.game.config().theme();