mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
Speed up diffPlayerUpdate with typed field comparisons
diffPlayerUpdate runs once per player per tick on the worker thread. The array/object fields (outgoingAttacks, incomingAttacks, alliances, outgoingEmojis) were compared via JSON.stringify — two string allocations per field, run on every call even when nothing changed. This made the cost flat at ~3.4µs/call regardless of what actually changed. Replace jsonEqual with three typed structural comparators (attackArrayEqual, allianceArrayEqual, emojiArrayEqual) that short-circuit on reference/length, compare known fields with ===, early-exit on the first difference, and allocate nothing — matching the existing numberArrayEqual/stringArrayEqual style. ~9-10x faster across all cases (276k -> 2.4M ops/sec when unchanged). Add tests/perf/DiffPlayerUpdatePerf.ts (BEFORE/AFTER benchmark, run via npm run perf) and warnings on PlayerUpdate and diffPlayerUpdate noting that new fields must be wired into the diff/apply functions or their changes are silently dropped after the first emission. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string>, b?: Set<string>): 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> = {},
|
||||
): 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<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 diffPlayerUpdateBefore(
|
||||
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("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 });
|
||||
Reference in New Issue
Block a user