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();
+1 -1
View File
@@ -828,7 +828,7 @@ export interface Player {
executeRetreat(attackID: string): void;
// Misc
toUpdate(): PlayerUpdate;
toUpdate(): PlayerUpdate | null;
playerProfile(): PlayerProfile;
// WARNING: this operation is expensive.
bestTransportShipSpawn(tile: TileRef): TileRef | false;
+2 -2
View File
@@ -455,8 +455,8 @@ export class GameImpl implements Game {
this.execs.push(...inited);
this.unInitExecs = unInited;
for (const player of this._players.values()) {
// Players change each to so always add them
this.addUpdate(player.toUpdate());
const update = player.toUpdate();
if (update !== null) this.addUpdate(update);
}
if (this.ticks() % 10 === 0) {
this.addUpdate({
+148
View File
@@ -0,0 +1,148 @@
import type { PlayerState } from "../../client/render/types";
import { GameUpdateType, PlayerUpdate } from "./GameUpdates";
/**
* Build a partial PlayerUpdate containing only fields whose value differs
* between `prev` and `next`. Returns null if nothing changed.
*
* `type` and `id` are always included on the returned diff. Array/object
* fields are compared by structural equality (length + per-element);
* `embargoes` is compared as a set; primitive fields by `===`.
*/
export function diffPlayerUpdate(
prev: PlayerUpdate,
next: PlayerUpdate,
): PlayerUpdate | null {
const diff: PlayerUpdate = { type: GameUpdateType.Player, id: next.id };
let changed = false;
const setIfDifferent = <K extends keyof PlayerUpdate>(
key: K,
equal: boolean,
) => {
if (!equal) {
(diff[key] as PlayerUpdate[K]) = next[key] as PlayerUpdate[K];
changed = true;
}
};
setIfDifferent("clientID", prev.clientID === next.clientID);
setIfDifferent("name", prev.name === next.name);
setIfDifferent("displayName", prev.displayName === next.displayName);
setIfDifferent("team", prev.team === next.team);
setIfDifferent("smallID", prev.smallID === next.smallID);
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);
setIfDifferent("isTraitor", prev.isTraitor === next.isTraitor);
setIfDifferent(
"traitorRemainingTicks",
prev.traitorRemainingTicks === next.traitorRemainingTicks,
);
setIfDifferent("hasSpawned", prev.hasSpawned === next.hasSpawned);
setIfDifferent("betrayals", prev.betrayals === next.betrayals);
setIfDifferent(
"lastDeleteUnitTick",
prev.lastDeleteUnitTick === next.lastDeleteUnitTick,
);
setIfDifferent("isLobbyCreator", prev.isLobbyCreator === next.isLobbyCreator);
setIfDifferent("allies", numberArrayEqual(prev.allies, next.allies));
setIfDifferent("targets", numberArrayEqual(prev.targets, next.targets));
setIfDifferent(
"outgoingAllianceRequests",
stringArrayEqual(
prev.outgoingAllianceRequests,
next.outgoingAllianceRequests,
),
);
setIfDifferent("embargoes", stringSetEqual(prev.embargoes, next.embargoes));
setIfDifferent(
"outgoingEmojis",
jsonEqual(prev.outgoingEmojis, next.outgoingEmojis),
);
setIfDifferent(
"outgoingAttacks",
jsonEqual(prev.outgoingAttacks, next.outgoingAttacks),
);
setIfDifferent(
"incomingAttacks",
jsonEqual(prev.incomingAttacks, next.incomingAttacks),
);
setIfDifferent("alliances", jsonEqual(prev.alliances, next.alliances));
return changed ? diff : null;
}
/**
* Merge a partial PlayerUpdate into a long-lived PlayerState in place.
*
* Only fields present on `pu` are applied; `undefined` means "no change since
* last emission". The first emission per player carries every field, so the
* target state is fully populated after one merge of the initial update.
*/
export function applyStateUpdate(target: PlayerState, pu: PlayerUpdate): void {
// smallID is identity — never changes for a given player.
if (pu.isAlive !== undefined) target.isAlive = pu.isAlive;
if (pu.isDisconnected !== undefined)
target.isDisconnected = pu.isDisconnected;
if (pu.tilesOwned !== undefined) target.tilesOwned = pu.tilesOwned;
if (pu.gold !== undefined) target.gold = Number(pu.gold);
if (pu.troops !== undefined) target.troops = pu.troops;
if (pu.isTraitor !== undefined) target.isTraitor = pu.isTraitor;
if (pu.traitorRemainingTicks !== undefined) {
target.traitorRemainingTicks = Math.max(0, pu.traitorRemainingTicks);
}
if (pu.betrayals !== undefined) target.betrayals = pu.betrayals;
if (pu.hasSpawned !== undefined) target.hasSpawned = pu.hasSpawned;
if (pu.lastDeleteUnitTick !== undefined) {
target.lastDeleteUnitTick = pu.lastDeleteUnitTick;
}
// Slice() to detach from the wire object — accumulated state mustn't share
// mutable arrays with per-tick update payloads.
if (pu.allies !== undefined) target.allies = pu.allies.slice();
if (pu.targets !== undefined) target.targets = pu.targets.slice();
if (pu.outgoingAllianceRequests !== undefined) {
target.outgoingAllianceRequests = pu.outgoingAllianceRequests.slice();
}
if (pu.outgoingAttacks !== undefined) {
target.outgoingAttacks = pu.outgoingAttacks;
}
if (pu.incomingAttacks !== undefined) {
target.incomingAttacks = pu.incomingAttacks;
}
if (pu.alliances !== undefined) target.alliances = pu.alliances;
if (pu.outgoingEmojis !== undefined)
target.outgoingEmojis = pu.outgoingEmojis;
}
function numberArrayEqual(a?: number[], b?: number[]): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
function stringArrayEqual(a?: string[], b?: string[]): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
function stringSetEqual(a?: Set<string>, b?: Set<string>): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.size !== b.size) return false;
for (const v of a) if (!b.has(v)) return false;
return true;
}
function jsonEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
return JSON.stringify(a) === JSON.stringify(b);
}
+32 -24
View File
@@ -164,35 +164,43 @@ export interface AttackUpdate {
retreating: boolean;
}
/**
* Player snapshot delivered worker -> main thread.
*
* Only `type` and `id` are guaranteed. Every other field is omitted when its
* value matches the previous emission for the same player. The first emission
* for a player always includes all fields; consumers must handle subsequent
* partial updates by merging into local state, not overwriting.
*/
export interface PlayerUpdate {
type: GameUpdateType.Player;
nameViewData?: NameViewData;
clientID: ClientID | null;
name: string;
displayName: string;
id: PlayerID;
nameViewData?: NameViewData;
clientID?: ClientID | null;
name?: string;
displayName?: string;
team?: Team;
smallID: number;
playerType: PlayerType;
isAlive: boolean;
isDisconnected: boolean;
tilesOwned: number;
gold: Gold;
troops: number;
allies: number[];
embargoes: Set<PlayerID>;
isTraitor: boolean;
smallID?: number;
playerType?: PlayerType;
isAlive?: boolean;
isDisconnected?: boolean;
tilesOwned?: number;
gold?: Gold;
troops?: number;
allies?: number[];
embargoes?: Set<PlayerID>;
isTraitor?: boolean;
traitorRemainingTicks?: number;
targets: number[];
outgoingEmojis: EmojiMessage[];
outgoingAttacks: AttackUpdate[];
incomingAttacks: AttackUpdate[];
outgoingAllianceRequests: PlayerID[];
alliances: AllianceView[];
hasSpawned: boolean;
betrayals: number;
lastDeleteUnitTick: Tick;
isLobbyCreator: boolean;
targets?: number[];
outgoingEmojis?: EmojiMessage[];
outgoingAttacks?: AttackUpdate[];
incomingAttacks?: AttackUpdate[];
outgoingAllianceRequests?: PlayerID[];
alliances?: AllianceView[];
hasSpawned?: boolean;
betrayals?: number;
lastDeleteUnitTick?: Tick;
isLobbyCreator?: boolean;
}
export interface AllianceView {
+26 -1
View File
@@ -43,6 +43,7 @@ import {
} from "./Game";
import { GameImpl } from "./GameImpl";
import { andFN, manhattanDistFN, TileRef } from "./GameMap";
import { diffPlayerUpdate } from "./GameUpdateUtils";
import {
AllianceView,
AttackUpdate,
@@ -105,6 +106,13 @@ export class PlayerImpl implements Player {
private _spawnTile: TileRef | undefined;
private _isDisconnected = false;
/**
* Last PlayerUpdate emitted for this player on the workermain channel.
* Used by GameImpl's tick loop to compute field-level diffs. Undefined on
* first emission (full snapshot sent).
*/
public lastSentUpdate: PlayerUpdate | undefined;
constructor(
private mg: GameImpl,
private _smallID: number,
@@ -119,7 +127,24 @@ export class PlayerImpl implements Player {
largestClusterBoundingBox: { min: Cell; max: Cell } | null;
toUpdate(): PlayerUpdate {
/**
* Build a PlayerUpdate for the workermain wire.
*
* The first call for a player returns the full snapshot. Subsequent calls
* return only fields that changed since the previous call (a partial
* `{ type, id, ...changedFields }`), or `null` if nothing changed.
*
* `lastSentUpdate` is updated to the full snapshot on every call.
*/
toUpdate(): PlayerUpdate | null {
const full = this.toFullUpdate();
const prev = this.lastSentUpdate;
this.lastSentUpdate = full;
if (prev === undefined) return full;
return diffPlayerUpdate(prev, full);
}
private toFullUpdate(): PlayerUpdate {
const outgoingAllianceRequests = this.outgoingAllianceRequests().map((ar) =>
ar.recipient().id(),
);
+4 -3
View File
@@ -80,7 +80,7 @@ describe("Disconnected", () => {
test("should include disconnected state in player update", () => {
player1.markDisconnected(true);
const update = player1.toUpdate();
expect(update.isDisconnected).toBe(true);
expect(update?.isDisconnected).toBe(true);
});
});
@@ -153,8 +153,9 @@ describe("Disconnected", () => {
test("should maintain disconnected state in player updates across ticks", () => {
player1.markDisconnected(true);
executeTicks(game, 3);
const update = player1.toUpdate();
expect(update.isDisconnected).toBe(true);
// toUpdate() returns diffs after the first call, so query engine state
// directly rather than the wire payload (which only carries changed fields).
expect(player1.isDisconnected()).toBe(true);
});
});
+312
View File
@@ -0,0 +1,312 @@
import { describe, expect, it } from "vitest";
import type { PlayerState } from "../src/client/render/types";
import { PlayerType } from "../src/core/game/Game";
import {
applyStateUpdate,
diffPlayerUpdate,
} from "../src/core/game/GameUpdateUtils";
import { GameUpdateType, PlayerUpdate } from "../src/core/game/GameUpdates";
import { makePlayerUpdate } from "./util/viewStubs";
function makePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
return {
smallID: 1,
isAlive: true,
isDisconnected: false,
tilesOwned: 0,
gold: 0,
troops: 100,
isTraitor: false,
traitorRemainingTicks: 0,
betrayals: 0,
hasSpawned: true,
lastDeleteUnitTick: 0,
allies: [],
embargoes: [],
targets: [],
outgoingAttacks: [],
incomingAttacks: [],
outgoingAllianceRequests: [],
alliances: [],
outgoingEmojis: [],
...overrides,
};
}
describe("diffPlayerUpdate", () => {
it("returns null when prev and next are identical", () => {
const prev = makePlayerUpdate();
const next = makePlayerUpdate();
expect(diffPlayerUpdate(prev, next)).toBeNull();
});
it("returns a diff with only changed primitives plus type+id", () => {
const prev = makePlayerUpdate({ gold: 100n });
const next = makePlayerUpdate({ gold: 250n });
const diff = diffPlayerUpdate(prev, next);
expect(diff).not.toBeNull();
expect(diff).toEqual({
type: GameUpdateType.Player,
id: "player-a",
gold: 250n,
});
});
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 diff = diffPlayerUpdate(prev, next)!;
expect(diff.gold).toBe(200n);
expect(diff.troops).toBe(75);
expect(diff.tilesOwned).toBeUndefined();
});
it("detects allies array additions", () => {
const prev = makePlayerUpdate({ allies: [2, 3] });
const next = makePlayerUpdate({ allies: [2, 3, 4] });
const diff = diffPlayerUpdate(prev, next)!;
expect(diff.allies).toEqual([2, 3, 4]);
});
it("ignores allies array when contents are equal (different identity)", () => {
const prev = makePlayerUpdate({ allies: [2, 3] });
const next = makePlayerUpdate({ allies: [2, 3] });
expect(diffPlayerUpdate(prev, next)).toBeNull();
});
it("treats allies reorder as a change (order is significant)", () => {
const prev = makePlayerUpdate({ allies: [2, 3] });
const next = makePlayerUpdate({ allies: [3, 2] });
const diff = diffPlayerUpdate(prev, next)!;
expect(diff.allies).toEqual([3, 2]);
});
it("detects embargo set membership changes", () => {
const prev = makePlayerUpdate({ embargoes: new Set(["x", "y"]) });
const next = makePlayerUpdate({ embargoes: new Set(["x", "y", "z"]) });
const diff = diffPlayerUpdate(prev, next)!;
expect(diff.embargoes).toEqual(new Set(["x", "y", "z"]));
});
it("ignores embargo set when membership is equal regardless of object identity", () => {
const prev = makePlayerUpdate({ embargoes: new Set(["x", "y"]) });
const next = makePlayerUpdate({ embargoes: new Set(["y", "x"]) });
expect(diffPlayerUpdate(prev, next)).toBeNull();
});
it("detects outgoingAttacks element changes", () => {
const prev = makePlayerUpdate({
outgoingAttacks: [
{ attackerID: 1, targetID: 2, troops: 10, id: "a", retreating: false },
],
});
const next = makePlayerUpdate({
outgoingAttacks: [
{ attackerID: 1, targetID: 2, troops: 20, id: "a", retreating: false },
],
});
const diff = diffPlayerUpdate(prev, next)!;
expect(diff.outgoingAttacks).toEqual(next.outgoingAttacks);
});
it("detects alliance list changes", () => {
const prev = makePlayerUpdate({ alliances: [] });
const next = makePlayerUpdate({
alliances: [
{
id: 1,
other: "player-b",
createdAt: 10,
expiresAt: 110,
hasExtensionRequest: false,
},
],
});
const diff = diffPlayerUpdate(prev, next)!;
expect(diff.alliances).toEqual(next.alliances);
});
it("treats undefined→number transition as a change", () => {
const prev = makePlayerUpdate({ traitorRemainingTicks: undefined });
const next = makePlayerUpdate({ traitorRemainingTicks: 5 });
const diff = diffPlayerUpdate(prev, next)!;
expect(diff.traitorRemainingTicks).toBe(5);
});
it("treats number→undefined transition as a change", () => {
const prev = makePlayerUpdate({ traitorRemainingTicks: 5 });
const next = makePlayerUpdate({ traitorRemainingTicks: undefined });
const diff = diffPlayerUpdate(prev, next);
expect(diff).not.toBeNull();
expect("traitorRemainingTicks" in diff!).toBe(true);
expect(diff!.traitorRemainingTicks).toBeUndefined();
});
it("always includes type and id on a non-null diff", () => {
const prev = makePlayerUpdate({ gold: 100n });
const next = makePlayerUpdate({ gold: 200n });
const diff = diffPlayerUpdate(prev, next)!;
expect(diff.type).toBe(GameUpdateType.Player);
expect(diff.id).toBe(next.id);
});
});
describe("applyStateUpdate", () => {
it("applies every field from a full update", () => {
const target = makePlayerState();
const pu = makePlayerUpdate({
gold: 500n,
troops: 999,
tilesOwned: 42,
allies: [7, 8],
targets: [9],
outgoingAllianceRequests: ["player-b"],
isAlive: false,
isTraitor: true,
traitorRemainingTicks: 3,
betrayals: 2,
hasSpawned: true,
lastDeleteUnitTick: 50,
});
applyStateUpdate(target, pu);
expect(target.gold).toBe(500);
expect(target.troops).toBe(999);
expect(target.tilesOwned).toBe(42);
expect(target.allies).toEqual([7, 8]);
expect(target.targets).toEqual([9]);
expect(target.outgoingAllianceRequests).toEqual(["player-b"]);
expect(target.isAlive).toBe(false);
expect(target.isTraitor).toBe(true);
expect(target.traitorRemainingTicks).toBe(3);
expect(target.betrayals).toBe(2);
expect(target.lastDeleteUnitTick).toBe(50);
});
it("converts bigint gold to number", () => {
const target = makePlayerState({ gold: 0 });
applyStateUpdate(target, {
type: GameUpdateType.Player,
id: "p",
gold: 9_999_999_999n,
});
expect(target.gold).toBe(9_999_999_999);
expect(typeof target.gold).toBe("number");
});
it("clamps negative traitorRemainingTicks to zero", () => {
const target = makePlayerState({ traitorRemainingTicks: 5 });
applyStateUpdate(target, {
type: GameUpdateType.Player,
id: "p",
traitorRemainingTicks: -10,
});
expect(target.traitorRemainingTicks).toBe(0);
});
it("only mutates fields present on the partial update", () => {
const target = makePlayerState({ gold: 100, troops: 50, tilesOwned: 7 });
const partial: PlayerUpdate = {
type: GameUpdateType.Player,
id: "p",
gold: 200n,
};
applyStateUpdate(target, partial);
expect(target.gold).toBe(200);
expect(target.troops).toBe(50);
expect(target.tilesOwned).toBe(7);
});
it("leaves array fields untouched when omitted", () => {
const original = [1, 2, 3];
const target = makePlayerState({ allies: original });
applyStateUpdate(target, { type: GameUpdateType.Player, id: "p" });
expect(target.allies).toBe(original);
});
it("detaches array fields by slicing (no shared reference with wire payload)", () => {
const wireAllies = [1, 2, 3];
const wireTargets = [9];
const wireRequests = ["player-b"];
const target = makePlayerState();
applyStateUpdate(target, {
type: GameUpdateType.Player,
id: "p",
allies: wireAllies,
targets: wireTargets,
outgoingAllianceRequests: wireRequests,
});
expect(target.allies).toEqual(wireAllies);
expect(target.allies).not.toBe(wireAllies);
expect(target.targets).not.toBe(wireTargets);
expect(target.outgoingAllianceRequests).not.toBe(wireRequests);
});
it("does not touch smallID even when present (identity field)", () => {
const target = makePlayerState({ smallID: 42 });
applyStateUpdate(target, {
type: GameUpdateType.Player,
id: "p",
smallID: 999,
});
expect(target.smallID).toBe(42);
});
it("merges several partial updates into a cumulative state", () => {
const target = makePlayerState();
applyStateUpdate(target, {
type: GameUpdateType.Player,
id: "p",
gold: 100n,
});
applyStateUpdate(target, {
type: GameUpdateType.Player,
id: "p",
troops: 250,
});
applyStateUpdate(target, {
type: GameUpdateType.Player,
id: "p",
isAlive: false,
});
expect(target.gold).toBe(100);
expect(target.troops).toBe(250);
expect(target.isAlive).toBe(false);
});
});
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],
});
// Initial state: receiver applies the full update.
const target = makePlayerState();
applyStateUpdate(target, v0);
// Subsequent tick: emitter sends only the diff.
const diff = diffPlayerUpdate(v0, v1)!;
expect(diff).not.toBeNull();
applyStateUpdate(target, diff);
expect(target.gold).toBe(200);
expect(target.troops).toBe(150);
expect(target.tilesOwned).toBe(5);
expect(target.allies).toEqual([2]);
});
it("no-change tick produces null diff so receiver state is untouched", () => {
const v0 = makePlayerUpdate({ gold: 100n, playerType: PlayerType.Human });
const v1 = makePlayerUpdate({ gold: 100n, playerType: PlayerType.Human });
expect(diffPlayerUpdate(v0, v1)).toBeNull();
});
});