diff --git a/src/client/view/GameView.ts b/src/client/view/GameView.ts index 2832673f0..3543076af 100644 --- a/src/client/view/GameView.ts +++ b/src/client/view/GameView.ts @@ -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) → 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[] = []; diff --git a/src/client/view/PlayerView.ts b/src/client/view/PlayerView.ts index 2e49d9916..771fd23a6 100644 --- a/src/client/view/PlayerView.ts +++ b/src/client/view/PlayerView.ts @@ -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(); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 0c71f4584..6cefde656 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -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; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 509ce5dd0..0979e25a1 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -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({ diff --git a/src/core/game/GameUpdateUtils.ts b/src/core/game/GameUpdateUtils.ts new file mode 100644 index 000000000..a4e6d7fdd --- /dev/null +++ b/src/core/game/GameUpdateUtils.ts @@ -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 = ( + 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, b?: Set): 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); +} diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index eeefba656..55922c9c5 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -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; - isTraitor: boolean; + smallID?: number; + playerType?: PlayerType; + isAlive?: boolean; + isDisconnected?: boolean; + tilesOwned?: number; + gold?: Gold; + troops?: number; + allies?: number[]; + embargoes?: Set; + 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 { diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 6f163598b..be6941910 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -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 worker→main 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 worker→main 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(), ); diff --git a/tests/Disconnected.test.ts b/tests/Disconnected.test.ts index b515dfa27..721b2a4b1 100644 --- a/tests/Disconnected.test.ts +++ b/tests/Disconnected.test.ts @@ -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); }); }); diff --git a/tests/GameUpdateUtils.test.ts b/tests/GameUpdateUtils.test.ts new file mode 100644 index 000000000..9b440b3cc --- /dev/null +++ b/tests/GameUpdateUtils.test.ts @@ -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 { + 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(); + }); +});