mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 19:10:55 +00:00
be87c7658f
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>
220 lines
6.7 KiB
TypeScript
220 lines
6.7 KiB
TypeScript
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 });
|