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(); }); });