diff --git a/src/core/game/GameUpdateUtils.ts b/src/core/game/GameUpdateUtils.ts index e52cd89b8..751d0b089 100644 --- a/src/core/game/GameUpdateUtils.ts +++ b/src/core/game/GameUpdateUtils.ts @@ -1,5 +1,11 @@ import type { PlayerState } from "../../client/render/types"; -import { GameUpdateType, PlayerUpdate } from "./GameUpdates"; +import type { EmojiMessage } from "./Game"; +import { + AllianceView, + AttackUpdate, + GameUpdateType, + PlayerUpdate, +} from "./GameUpdates"; /** * Build a partial PlayerUpdate containing only fields whose value differs @@ -8,6 +14,12 @@ import { GameUpdateType, PlayerUpdate } from "./GameUpdates"; * `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 `===`. + * + * WARNING: this diff is field-by-field by design (no JSON.stringify, for + * perf — see tests/perf/DiffPlayerUpdatePerf.ts). When you add a field to + * PlayerUpdate, you MUST add a matching setIfDifferent(...) line here, and an + * apply line in applyStateUpdate below. A field missing here is never diffed, + * so its changes silently never reach the main thread after the first update. */ export function diffPlayerUpdate( prev: PlayerUpdate, @@ -62,17 +74,20 @@ export function diffPlayerUpdate( setIfDifferent("embargoes", stringSetEqual(prev.embargoes, next.embargoes)); setIfDifferent( "outgoingEmojis", - jsonEqual(prev.outgoingEmojis, next.outgoingEmojis), + emojiArrayEqual(prev.outgoingEmojis, next.outgoingEmojis), ); setIfDifferent( "outgoingAttacks", - jsonEqual(prev.outgoingAttacks, next.outgoingAttacks), + attackArrayEqual(prev.outgoingAttacks, next.outgoingAttacks), ); setIfDifferent( "incomingAttacks", - jsonEqual(prev.incomingAttacks, next.incomingAttacks), + attackArrayEqual(prev.incomingAttacks, next.incomingAttacks), + ); + setIfDifferent( + "alliances", + allianceArrayEqual(prev.alliances, next.alliances), ); - setIfDifferent("alliances", jsonEqual(prev.alliances, next.alliances)); return changed ? diff : null; } @@ -144,7 +159,61 @@ function stringSetEqual(a?: Set, b?: Set): boolean { return true; } -function jsonEqual(a: unknown, b: unknown): boolean { +function attackArrayEqual(a?: AttackUpdate[], b?: AttackUpdate[]): boolean { if (a === b) return true; - return JSON.stringify(a) === JSON.stringify(b); + if (!a || !b) return false; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const x = a[i]; + const y = b[i]; + if ( + x.attackerID !== y.attackerID || + x.targetID !== y.targetID || + x.troops !== y.troops || + x.id !== y.id || + x.retreating !== y.retreating + ) { + return false; + } + } + return true; +} + +function allianceArrayEqual(a?: AllianceView[], b?: AllianceView[]): 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++) { + const x = a[i]; + const y = b[i]; + if ( + x.id !== y.id || + x.other !== y.other || + x.createdAt !== y.createdAt || + x.expiresAt !== y.expiresAt || + x.hasExtensionRequest !== y.hasExtensionRequest + ) { + return false; + } + } + return true; +} + +function emojiArrayEqual(a?: EmojiMessage[], b?: EmojiMessage[]): 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++) { + const x = a[i]; + const y = b[i]; + if ( + x.message !== y.message || + x.senderID !== y.senderID || + x.recipientID !== y.recipientID || + x.createdAt !== y.createdAt + ) { + return false; + } + } + return true; } diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index d0a098304..900d9d989 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -181,6 +181,10 @@ export interface AttackUpdate { * 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. + * + * When adding a field here, also wire it into diffPlayerUpdate() and + * applyStateUpdate() in GameUpdateUtils.ts — otherwise it is only ever sent on + * the first emission and later changes are silently dropped. */ export interface PlayerUpdate { type: GameUpdateType.Player; diff --git a/tests/perf/DiffPlayerUpdatePerf.ts b/tests/perf/DiffPlayerUpdatePerf.ts new file mode 100644 index 000000000..0589d004b --- /dev/null +++ b/tests/perf/DiffPlayerUpdatePerf.ts @@ -0,0 +1,219 @@ +import Benchmark from "benchmark"; +import { PlayerType } from "../../src/core/game/Game"; +import { diffPlayerUpdate } from "../../src/core/game/GameUpdateUtils"; +import { + AllianceView, + AttackUpdate, + GameUpdateType, + PlayerUpdate, +} from "../../src/core/game/GameUpdates"; + +/** + * Benchmark for diffPlayerUpdate, which runs once per player per tick on the + * worker thread. + * + * BEFORE compared the array/object fields (outgoingAttacks, incomingAttacks, + * alliances, outgoingEmojis) with JSON.stringify — two allocations per field, + * run on every call even when nothing changed. AFTER uses typed structural + * comparisons that early-exit and allocate nothing. + */ + +function makeAttacks(n: number): AttackUpdate[] { + return Array.from({ length: n }, (_, i) => ({ + attackerID: 1, + targetID: 2 + i, + troops: 1000 + i, + id: `attack-${i}`, + retreating: false, + })); +} + +function makeAlliances(n: number): AllianceView[] { + return Array.from({ length: n }, (_, i) => ({ + id: i, + other: `player-${i}`, + createdAt: 100 + i, + expiresAt: 1000 + i, + hasExtensionRequest: false, + })); +} + +function makeRealisticUpdate( + overrides: Partial = {}, +): PlayerUpdate { + return { + type: GameUpdateType.Player, + clientID: "client-a", + name: "Alice", + displayName: "Alice", + id: "player-a", + smallID: 1, + playerType: PlayerType.Human, + isAlive: true, + isDisconnected: false, + tilesOwned: 5000, + gold: 123456n, + troops: 50000, + allies: [2, 3, 4, 5, 6], + embargoes: new Set(["7", "8", "9"]), + isTraitor: false, + traitorRemainingTicks: 0, + targets: [10, 11], + outgoingEmojis: [], + outgoingAttacks: makeAttacks(4), + incomingAttacks: makeAttacks(3), + outgoingAllianceRequests: ["12", "13"], + alliances: makeAlliances(5), + hasSpawned: true, + spawnTile: 999, + betrayals: 0, + lastDeleteUnitTick: 0, + isLobbyCreator: false, + ...overrides, + }; +} + +// ── BEFORE: the JSON.stringify-based diff ── + +function jsonEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + return JSON.stringify(a) === JSON.stringify(b); +} + +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 diffPlayerUpdateBefore( + 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("spawnTile", prev.spawnTile === next.spawnTile); + 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; +} + +// ── Benchmark cases ── + +const unchangedPrev = makeRealisticUpdate(); +const unchangedNext = makeRealisticUpdate(); + +const primPrev = makeRealisticUpdate(); +const primNext = makeRealisticUpdate({ gold: 200000n }); + +const arrPrev = makeRealisticUpdate(); +const arrNext = makeRealisticUpdate({ outgoingAttacks: makeAttacks(5) }); + +const results: string[] = []; + +const suite = new Benchmark.Suite() + .add("BEFORE unchanged (JSON.stringify)", () => + diffPlayerUpdateBefore(unchangedPrev, unchangedNext), + ) + .add("AFTER unchanged (typed compare)", () => + diffPlayerUpdate(unchangedPrev, unchangedNext), + ) + .add("BEFORE primitive changed (JSON.stringify)", () => + diffPlayerUpdateBefore(primPrev, primNext), + ) + .add("AFTER primitive changed (typed compare)", () => + diffPlayerUpdate(primPrev, primNext), + ) + .add("BEFORE array changed (JSON.stringify)", () => + diffPlayerUpdateBefore(arrPrev, arrNext), + ) + .add("AFTER array changed (typed compare)", () => + diffPlayerUpdate(arrPrev, arrNext), + ) + .on("cycle", (event: Benchmark.Event) => { + results.push(String(event.target)); + }) + .on("complete", function (this: Benchmark.Suite) { + console.log("\n=== diffPlayerUpdate Benchmark Results ==="); + for (const result of results) { + console.log(result); + } + const fastest = this.filter("fastest").map("name"); + console.log(`\nFastest: ${fastest.join(", ")}`); + }); + +suite.run({ async: false });