Files
OpenFrontIO/tests/perf/DiffPlayerUpdatePerf.ts
T
Evan Pelle a7f992e9b0 refactor: convert to npm-workspaces monorepo (engine/core-public/shared/client/server)
Restructure the single src/ tree into an npm-workspaces monorepo under
packages/, rename core -> engine, extract a types-only core-public layer,
and break the pre-existing engine -> client dependency cycle.

Structure (packages/):
  core-public  public API/wire schemas + shared enums (clean leaf)
  shared       framework-agnostic helpers (clean leaf)
  engine       deterministic simulation (was src/core)
  client       rendering/UI (was src/client)
  server       coordination (was src/server)

Dependency DAG: engine -> {core-public, shared}; client -> {core-public,
shared, engine}; server -> {core-public, engine}.

- npm workspaces: root package.json workspaces + per-package package.json;
  tsconfig.base.json holds shared options + path aliases
  (core-public/* shared/* engine/* client/* server/*) resolved uniformly by
  tsc, Vite (resolve.tsconfigPaths), Vitest, and tsx. Lockfile regenerated.
- core-public: moved Schemas/ApiSchemas/CosmeticSchemas/StatsSchemas/
  ClanApiSchemas/WorkerSchemas/Base64/PatternDecoder; extracted the enums
  (GameTypes), GameEvent type, emoji table, and GraphicsOverrides schema.
  Engine re-exports the moved enums/types so existing imports keep working.
- Broke engine -> client cycle:
  - renderNumber/renderTroops -> shared/format
  - NameBoxCalculator moved into engine
  - username validation returns translation key + params; client translates
  - applyStateUpdate moved to client (operates on the render-only PlayerState)
  - Config/UnitGrid/execution-Util/GameImpl now use structural read
    interfaces (engine/game/ReadViews: PlayerLike/UnitLike/GameLike) instead
    of importing client view classes; client imports view classes from a new
    client/view barrel; deleted the engine/game/GameView re-export shim.
- Build/deploy updated: vite.config, index.html, eslint, Dockerfile
  (copies packages/ + tsconfig.base.json before npm ci), .vscode, tests.

Verified: tsc --noEmit clean; 1364 + 65 tests pass; production vite build
succeeds; engine has zero client/server imports; core-public and shared are
dependency leaves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:51:03 +00:00

220 lines
6.6 KiB
TypeScript

import Benchmark from "benchmark";
import { PlayerType } from "engine/game/Game";
import { diffPlayerUpdate } from "engine/game/GameUpdateUtils";
import {
AllianceView,
AttackUpdate,
GameUpdateType,
PlayerUpdate,
} from "engine/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 });