From e0028b4669913f6bcb60b08c20ceae1277caf2cb Mon Sep 17 00:00:00 2001 From: iamlewis Date: Fri, 9 Oct 2026 17:59:48 +0100 Subject: [PATCH 1/3] perf: benchmark the lobby roster's level badges Moved from #5771 (level-optout): they measure #5763's roster badges. - tests/perf/LobbyInfoWirePerf.ts (also in `npm run perf`): lobby_info frame size and encode/decode time for 10/50/125/150 clients with and without levelBadge, plus the join-time stamping. - tests/perf/client/LobbyPlayerViewRenderPerf.ts: first-paint and update cost for 150 clients under jsdom, with and without badges. The fixture now carries badges packed (packLevelBadge), as lobby_info does and as LobbyPlayerView unpacks them, and passes the number through unchanged when building each fresh roster. Co-Authored-By: Claude Opus 5.5 --- tests/perf/LobbyInfoWirePerf.ts | 181 +++++++++++++++ .../perf/client/LobbyPlayerViewRenderPerf.ts | 218 ++++++++++++++++++ 2 files changed, 399 insertions(+) create mode 100644 tests/perf/LobbyInfoWirePerf.ts create mode 100644 tests/perf/client/LobbyPlayerViewRenderPerf.ts diff --git a/tests/perf/LobbyInfoWirePerf.ts b/tests/perf/LobbyInfoWirePerf.ts new file mode 100644 index 0000000000..beb40e319e --- /dev/null +++ b/tests/perf/LobbyInfoWirePerf.ts @@ -0,0 +1,181 @@ +import { Progress } from "@openfront/shared/ApiSchemas"; +import { packLevelBadge } from "@openfront/shared/LevelBadgeWire"; +import { + ClientInfo, + ServerLobbyInfoMessage, + ServerMessage, +} from "@openfront/shared/WireSchemas"; +import { + decodeServerMessage, + encodeServerMessage, +} from "@openfront/shared/ZbinWire"; +import Benchmark from "benchmark"; +import * as LevelBadgeModule from "../../src/server/LevelBadge"; +import { testGameConfig } from "../util/Wire"; + +/** + * Cost of the lobby roster's level badges on the lobby_info broadcast. + * + * While a game is in its lobby the server sends every connected client a + * lobby_info frame once a second (GameServer.broadcastLobbyInfo), encoding it + * once PER RECIPIENT — so one broadcast of an N-player lobby is N encodes of + * an N-entry roster. This measures, for 10 / 50 / 125 / 150 clients: + * + * - the encoded frame size with no badges and with a badge on every entry + * (the worst case: everyone signed in, progression on, nobody hidden); + * - the time to encode one frame, and so one whole broadcast (x N); + * - the time a client takes to decode one frame. + * + * Plus a microbenchmark of the join-time badge stamping (levelBadgeFromProgress, + * and levelBadgeForPlayer with the "hide my level" check when present). + * + * The lobby is not yet started, so the server encodes with no zbin context + * (zbinCtx is undefined until start): clientIDs go out as plain strings. + * + * Usage: npx tsx tests/perf/LobbyInfoWirePerf.ts + */ + +// Deterministic data: a tiny LCG, so every run (and branch) encodes the same +// rosters. +let seed = 0x5eed; +function rand(): number { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 0x100000000; +} +const ALNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +function word(len: number): string { + let s = ""; + for (let i = 0; i < len; i++) s += ALNUM[Math.floor(rand() * ALNUM.length)]; + return s; +} + +function roster(n: number, withBadges: boolean): ClientInfo[] { + seed = 0x5eed + n; + const ids = Array.from({ length: n }, () => word(8)); + return ids.map((clientID, i) => { + const c: ClientInfo = { + clientID, + username: word(6 + Math.floor(rand() * 9)), + // Roughly a third in a clan, a quarter verified, a few friends here and + // there — the shape NameVisibility.lobbyClients sends with names on. + clanTag: rand() < 0.33 ? word(2 + Math.floor(rand() * 4)) : null, + }; + if (rand() < 0.25) c.verified = true; + if (rand() < 0.1) c.friends = [ids[(i + 1) % n]]; + // Drawn for every entry either way, so both rosters consume the same + // random numbers and differ ONLY in the badges. + const badge = + rand() < 0.01 + ? { level: 100, prestige: 10, legend: true } + : { + level: 1 + Math.floor(rand() * 100), + prestige: Math.floor(rand() * 11), + legend: false, + }; + // The roster carries a badge packed into one integer. + if (withBadges) c.levelBadge = packLevelBadge(badge); + return c; + }); +} + +function lobbyInfo(clients: ClientInfo[]): ServerMessage { + return { + type: "lobby_info", + lobby: { + gameID: "AbCdEfGh", + clients, + lobbyCreatorClientID: clients[0]?.clientID, + serverTime: 1_790_000_000_000, + gameConfig: testGameConfig({ maxPlayers: 150 }), + }, + myClientID: clients[0]?.clientID ?? "AbCdEfGh", + groupToken: "0123456789abcdef0123456789abcdef", + } satisfies ServerLobbyInfoMessage; +} + +function fmtUs(seconds: number): string { + return seconds < 1e-6 + ? `${(seconds * 1e9).toFixed(1)} ns` + : `${(seconds * 1e6).toFixed(2)} µs`; +} + +function bench(name: string, fn: () => void): Benchmark { + const b = new Benchmark(name, fn, { minSamples: 40 }); + b.run({ async: false }); + return b; +} + +console.log("=== lobby_info frame size (bytes) ==="); +console.log("clients | no badges | all badges | delta | delta/entry"); +for (const n of [10, 50, 125, 150]) { + const plain = encodeServerMessage(lobbyInfo(roster(n, false)), undefined); + const badged = encodeServerMessage(lobbyInfo(roster(n, true)), undefined); + const delta = badged.byteLength - plain.byteLength; + console.log( + `${String(n).padStart(7)} | ${String(plain.byteLength).padStart(9)} | ${String( + badged.byteLength, + ).padStart(10)} | ${String(delta).padStart(5)} | ${(delta / n).toFixed(2)}`, + ); +} + +console.log("\n=== encode / decode time (benchmark.js, mean ± rme) ==="); +console.log( + "clients | badges | encode 1 frame | broadcast (x N) | decode 1 frame", +); +for (const n of [10, 50, 125, 150]) { + for (const withBadges of [false, true]) { + const msg = lobbyInfo(roster(n, withBadges)); + const bytes = encodeServerMessage(msg, undefined); + const enc = bench(`encode ${n} ${withBadges}`, () => { + encodeServerMessage(msg, undefined); + }); + const dec = bench(`decode ${n} ${withBadges}`, () => { + decodeServerMessage(bytes, undefined); + }); + console.log( + `${String(n).padStart(7)} | ${(withBadges ? "yes" : "no").padStart(6)} | ${fmtUs( + enc.stats.mean, + ).padStart(10)} ±${enc.stats.rme.toFixed(1)}% | ${( + (enc.stats.mean * n * 1e3).toFixed(3) + " ms" + ).padStart( + 15, + )} | ${fmtUs(dec.stats.mean).padStart(10)} ±${dec.stats.rme.toFixed(1)}%`, + ); + } +} + +console.log("\n=== join-time badge stamping (per join) ==="); +const progress: Progress = { + prestige: 3, + level: 57, + xpInLevel: 120, + xpForNext: 900, + lifetimeXp: 1_234_567, + legend: false, + canPrestige: false, +}; +let sink: unknown; +const fromProgress = bench("levelBadgeFromProgress", () => { + sink = LevelBadgeModule.levelBadgeFromProgress(progress); +}); +console.log( + `levelBadgeFromProgress(progress) ${fmtUs(fromProgress.stats.mean)} ±${fromProgress.stats.rme.toFixed(1)}%`, +); +// Looked up loosely so the harness still runs on a checkout from before +// levelBadgeForPlayer (the "hide my level" check) existed. +const forPlayer = (LevelBadgeModule as Record) + .levelBadgeForPlayer as + | ((p: { progress?: Progress; levelHidden?: boolean }) => unknown) + | undefined; +if (forPlayer !== undefined) { + for (const levelHidden of [undefined, false, true]) { + const player = { progress, levelHidden }; + const b = bench(`levelBadgeForPlayer ${levelHidden}`, () => { + sink = forPlayer(player); + }); + console.log( + `levelBadgeForPlayer({ levelHidden: ${String(levelHidden).padEnd(9)} }) ${fmtUs(b.stats.mean)} ±${b.stats.rme.toFixed(1)}%`, + ); + } +} +void sink; diff --git a/tests/perf/client/LobbyPlayerViewRenderPerf.ts b/tests/perf/client/LobbyPlayerViewRenderPerf.ts new file mode 100644 index 0000000000..768fb2ed72 --- /dev/null +++ b/tests/perf/client/LobbyPlayerViewRenderPerf.ts @@ -0,0 +1,218 @@ +/** + * render cost with ~150 clients, with and without level + * badges. + * + * The lobby list re-renders on every lobby_info frame (once a second): each + * frame decodes into a brand-new ClientInfo[] of brand-new objects, so this + * times both the first paint and that steady-state "same roster, new objects" + * update, which is what the player pays every second while waiting. + * + * Scenarios (150 clients): + * - FFA pills, no badges / every player badged; + * - team mode (Players column + team cards), no badges / every player + * badged / half badged (the other half get the empty alignment slot); + * - and a 1-in-150 change (one player joins) on top of a badged roster. + * + * Runs under jsdom, so absolute numbers are jsdom's (no layout or paint) and + * only the relative cost is meaningful. Reports median / p95 over many runs + * after a warm-up. + * + * Usage: npx tsx tests/perf/client/LobbyPlayerViewRenderPerf.ts + * (RUNS=n to change the sample count, LOBBY_PERF_SRC=/src + * to time another branch's component with the same harness) + */ +import { packLevelBadge } from "@openfront/shared/LevelBadgeWire"; +import { readFileSync } from "fs"; +// @ts-expect-error -- jsdom ships no types and @types/jsdom is not a dependency +import { JSDOM } from "jsdom"; +import { performance } from "perf_hooks"; +import { pathToFileURL } from "url"; + +const dom = new JSDOM("", { + url: "http://localhost/", + pretendToBeVisual: true, +}); +// Expose jsdom's window as the global scope, like vitest's jsdom environment: +// every window property Node doesn't already have, plus the few it has its +// own versions of. +const w = dom.window as unknown as Record; +const OVERRIDE = new Set(["navigator", "localStorage", "sessionStorage"]); +for (const key of Object.getOwnPropertyNames(w)) { + if (key in globalThis && !OVERRIDE.has(key)) continue; + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value: w[key], + }); +} +Object.defineProperty(globalThis, "window", { value: globalThis }); + +const RUNS = Number(process.env.RUNS ?? 100); +const WARMUP = 20; +// Point at another checkout's src/ to compare branches, e.g. +// LOBBY_PERF_SRC=C:/dev/OpenFrontIO/.worktrees/roster-levels/src +const SRC = process.env.LOBBY_PERF_SRC + ? pathToFileURL(process.env.LOBBY_PERF_SRC.replace(/\/?$/, "/")).href + : new URL("../../../src/", import.meta.url).href; + +interface Client { + clientID: string; + username: string; + clanTag: string | null; + verified?: boolean; + // Packed, as lobby_info carries it (packLevelBadge). + levelBadge?: number; +} + +function roster(n: number, badged: (i: number) => boolean): Client[] { + return Array.from({ length: n }, (_, i) => { + const c: Client = { + clientID: `c${String(i).padStart(7, "0")}`, + username: `Player${i}`, + clanTag: i % 3 === 0 ? "CLAN" : null, + }; + if (i % 4 === 0) c.verified = true; + if (badged(i)) { + c.levelBadge = packLevelBadge({ + level: 1 + ((i * 37) % 100), + prestige: i % 11, + legend: i === 7, + }); + } + return c; + }); +} + +// A lobby_info frame decodes to fresh objects every time; the packed badge +// is a plain number, so it passes through unchanged. +const fresh = (clients: Client[]): Client[] => clients.map((c) => ({ ...c })); + +function stats(samples: number[]): string { + const s = [...samples].sort((a, b) => a - b); + const at = (q: number) => s[Math.min(s.length - 1, Math.floor(q * s.length))]; + return `median ${at(0.5).toFixed(3)} ms p95 ${at(0.95).toFixed(3)} ms`; +} + +// translateText() looks the up once and caches it; without one +// in the page it re-runs document.querySelector on EVERY call, which scans the +// whole lobby list and swamps the timings. The real page always has one, so +// stand one in with the English strings. +function installLangSelector() { + const flat: Record = {}; + const walk = (obj: Record, prefix: string) => { + for (const [k, v] of Object.entries(obj)) { + if (typeof v === "string") flat[prefix + k] = v; + else walk(v as Record, `${prefix}${k}.`); + } + }; + walk( + JSON.parse( + readFileSync( + new URL("../../../resources/lang/en.json", import.meta.url), + "utf8", + ), + ), + "", + ); + const sel = document.createElement("lang-selector") as HTMLElement & { + currentLang: string; + translations: Record; + defaultTranslations: Record; + }; + sel.currentLang = "en"; + sel.translations = flat; + sel.defaultTranslations = flat; + document.body.append(sel); +} + +async function main() { + installLangSelector(); + const { GameMode } = await import("@openfront/engine-api/game/GameTypes"); + await import(SRC + "client/components/LobbyPlayerView.ts"); + + type View = HTMLElement & { + gameMode: unknown; + clients: Client[]; + currentClientID: string; + teamCount: unknown; + updateComplete: Promise; + }; + + async function mount(mode: unknown, clients: Client[]): Promise { + const view = document.createElement("lobby-player-view") as View; + view.gameMode = mode; + view.teamCount = 2; + view.currentClientID = clients[0].clientID; + view.clients = clients; + document.body.append(view); + await view.updateComplete; + return view; + } + + async function firstPaint(mode: unknown, clients: Client[]) { + const samples: number[] = []; + for (let i = 0; i < WARMUP + RUNS; i++) { + const t0 = performance.now(); + const view = await mount(mode, fresh(clients)); + const t1 = performance.now(); + view.remove(); + if (i >= WARMUP) samples.push(t1 - t0); + } + return stats(samples); + } + + async function update(mode: unknown, a: Client[], b: Client[] = a) { + const view = await mount(mode, fresh(a)); + const samples: number[] = []; + for (let i = 0; i < WARMUP + RUNS; i++) { + const next = fresh(i % 2 === 0 ? b : a); + const t0 = performance.now(); + view.clients = next; + await view.updateComplete; + const t1 = performance.now(); + if (i >= WARMUP) samples.push(t1 - t0); + } + view.remove(); + return stats(samples); + } + + const N = 150; + const none = roster(N, () => false); + const all = roster(N, () => true); + const half = roster(N, (i) => i % 2 === 0); + const allPlusOne = [...all, ...roster(N + 1, () => true).slice(N)]; + + const rows: [string, () => Promise][] = [ + ["FFA first paint, no badges ", () => firstPaint(GameMode.FFA, none)], + ["FFA first paint, all badged ", () => firstPaint(GameMode.FFA, all)], + ["FFA update, no badges ", () => update(GameMode.FFA, none)], + ["FFA update, all badged ", () => update(GameMode.FFA, all)], + [ + "FFA update, +1 join badged", + () => update(GameMode.FFA, all, allPlusOne), + ], + ["Team first paint, no badges ", () => firstPaint(GameMode.Team, none)], + ["Team first paint, all badged ", () => firstPaint(GameMode.Team, all)], + ["Team first paint, half (slots) ", () => firstPaint(GameMode.Team, half)], + ["Team update, no badges ", () => update(GameMode.Team, none)], + ["Team update, all badged ", () => update(GameMode.Team, all)], + ["Team update, half (slots) ", () => update(GameMode.Team, half)], + ]; + console.log( + `=== , ${N} clients, jsdom, ${RUNS} runs after ${WARMUP} warm-up ===`, + ); + // ONLY= runs just the matching rows (e.g. ONLY="first paint"). + const only = process.env.ONLY; + for (const [name, run] of rows) { + if (only !== undefined && !name.includes(only)) continue; + console.log(`${name} ${await run()}`); + } +} + +main().then( + () => process.exit(0), + (e) => { + console.error(e); + process.exit(1); + }, +); From f94d096196dab4c959ceb2179277a9896f201c42 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Fri, 9 Oct 2026 19:38:16 +0100 Subject: [PATCH 2/3] Lobby perf: time one roster change per sample and the staggered badges The update rows alternated rosters, so "+1 join" averaged a join with a departure; the view now goes back to the base roster untimed before each sample. Each row also reports, next to the list's own update, the time until LevelBadgeFill has drawn every staggered badge (badgesDrawn). Co-Authored-By: Claude Opus 5.5 --- .../perf/client/LobbyPlayerViewRenderPerf.ts | 53 ++++++++++++++++--- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/tests/perf/client/LobbyPlayerViewRenderPerf.ts b/tests/perf/client/LobbyPlayerViewRenderPerf.ts index 768fb2ed72..9a334c3ee4 100644 --- a/tests/perf/client/LobbyPlayerViewRenderPerf.ts +++ b/tests/perf/client/LobbyPlayerViewRenderPerf.ts @@ -15,7 +15,10 @@ * * Runs under jsdom, so absolute numbers are jsdom's (no layout or paint) and * only the relative cost is meaningful. Reports median / p95 over many runs - * after a warm-up. + * after a warm-up, two ways: "list" stops at the list's own updateComplete; + * "drawn" stops once the level badges it staggers onto later animation frames + * (LevelBadgeFill) are all drawn too, so it includes waiting for those frames + * (jsdom runs them at 60 fps) and reads as latency rather than work. * * Usage: npx tsx tests/perf/client/LobbyPlayerViewRenderPerf.ts * (RUNS=n to change the sample count, LOBBY_PERF_SRC=/src @@ -93,6 +96,15 @@ function stats(samples: number[]): string { return `median ${at(0.5).toFixed(3)} ms p95 ${at(0.95).toFixed(3)} ms`; } +// The two end points of each sample: the list's update, then its badges. +class Timings { + list: number[] = []; + drawn: number[] = []; + report(): string { + return `list ${stats(this.list)} | drawn ${stats(this.drawn)}`; + } +} + // translateText() looks the up once and caches it; without one // in the page it re-runs document.querySelector on EVERY call, which scans the // whole lobby list and swamps the timings. The real page always has one, so @@ -129,6 +141,14 @@ async function main() { installLangSelector(); const { GameMode } = await import("@openfront/engine-api/game/GameTypes"); await import(SRC + "client/components/LobbyPlayerView.ts"); + // Resolves once every staggered badge is drawn. A checkout from before the + // stagger draws them all in the list's own update, so there it is immediate. + const badgesDrawn: () => Promise = await import( + SRC + "client/components/LevelBadgeFill.ts" + ).then( + (m: { badgesDrawn: () => Promise }) => m.badgesDrawn, + () => () => Promise.resolve(), + ); type View = HTMLElement & { gameMode: unknown; @@ -150,30 +170,47 @@ async function main() { } async function firstPaint(mode: unknown, clients: Client[]) { - const samples: number[] = []; + const t = new Timings(); for (let i = 0; i < WARMUP + RUNS; i++) { const t0 = performance.now(); const view = await mount(mode, fresh(clients)); const t1 = performance.now(); + await badgesDrawn(); + const t2 = performance.now(); view.remove(); - if (i >= WARMUP) samples.push(t1 - t0); + if (i >= WARMUP) { + t.list.push(t1 - t0); + t.drawn.push(t2 - t0); + } } - return stats(samples); + return t.report(); } + // Times the change from roster `a` to roster `b` (the same roster as new + // objects when `b` is `a`). The view goes back to `a` between samples, + // untimed, so every sample is that one change and never its reverse. async function update(mode: unknown, a: Client[], b: Client[] = a) { const view = await mount(mode, fresh(a)); - const samples: number[] = []; + await badgesDrawn(); + const t = new Timings(); for (let i = 0; i < WARMUP + RUNS; i++) { - const next = fresh(i % 2 === 0 ? b : a); + view.clients = fresh(a); + await view.updateComplete; + await badgesDrawn(); + const next = fresh(b); const t0 = performance.now(); view.clients = next; await view.updateComplete; const t1 = performance.now(); - if (i >= WARMUP) samples.push(t1 - t0); + await badgesDrawn(); + const t2 = performance.now(); + if (i >= WARMUP) { + t.list.push(t1 - t0); + t.drawn.push(t2 - t0); + } } view.remove(); - return stats(samples); + return t.report(); } const N = 150; From c95c89f91636208d173f09ff2d6bc1cbf3b852ae Mon Sep 17 00:00:00 2001 From: iamlewis Date: Fri, 9 Oct 2026 19:53:24 +0100 Subject: [PATCH 3/3] Lobby badge benchmarks: import levelBadgeForPlayer directly, generic path example levelBadgeForPlayer is on main now, so the loose lookup only cost type checking. The LOBBY_PERF_SRC example no longer names a local checkout. Co-Authored-By: Claude Opus 5.5 --- tests/perf/LobbyInfoWirePerf.ts | 33 ++++++++----------- .../perf/client/LobbyPlayerViewRenderPerf.ts | 2 +- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/tests/perf/LobbyInfoWirePerf.ts b/tests/perf/LobbyInfoWirePerf.ts index beb40e319e..c640717c3b 100644 --- a/tests/perf/LobbyInfoWirePerf.ts +++ b/tests/perf/LobbyInfoWirePerf.ts @@ -10,7 +10,10 @@ import { encodeServerMessage, } from "@openfront/shared/ZbinWire"; import Benchmark from "benchmark"; -import * as LevelBadgeModule from "../../src/server/LevelBadge"; +import { + levelBadgeForPlayer, + levelBadgeFromProgress, +} from "../../src/server/LevelBadge"; import { testGameConfig } from "../util/Wire"; /** @@ -27,7 +30,7 @@ import { testGameConfig } from "../util/Wire"; * - the time a client takes to decode one frame. * * Plus a microbenchmark of the join-time badge stamping (levelBadgeFromProgress, - * and levelBadgeForPlayer with the "hide my level" check when present). + * and levelBadgeForPlayer with the "hide my level" check). * * The lobby is not yet started, so the server encodes with no zbin context * (zbinCtx is undefined until start): clientIDs go out as plain strings. @@ -156,26 +159,18 @@ const progress: Progress = { }; let sink: unknown; const fromProgress = bench("levelBadgeFromProgress", () => { - sink = LevelBadgeModule.levelBadgeFromProgress(progress); + sink = levelBadgeFromProgress(progress); }); console.log( `levelBadgeFromProgress(progress) ${fmtUs(fromProgress.stats.mean)} ±${fromProgress.stats.rme.toFixed(1)}%`, ); -// Looked up loosely so the harness still runs on a checkout from before -// levelBadgeForPlayer (the "hide my level" check) existed. -const forPlayer = (LevelBadgeModule as Record) - .levelBadgeForPlayer as - | ((p: { progress?: Progress; levelHidden?: boolean }) => unknown) - | undefined; -if (forPlayer !== undefined) { - for (const levelHidden of [undefined, false, true]) { - const player = { progress, levelHidden }; - const b = bench(`levelBadgeForPlayer ${levelHidden}`, () => { - sink = forPlayer(player); - }); - console.log( - `levelBadgeForPlayer({ levelHidden: ${String(levelHidden).padEnd(9)} }) ${fmtUs(b.stats.mean)} ±${b.stats.rme.toFixed(1)}%`, - ); - } +for (const levelHidden of [undefined, false, true]) { + const player = { progress, levelHidden }; + const b = bench(`levelBadgeForPlayer ${levelHidden}`, () => { + sink = levelBadgeForPlayer(player); + }); + console.log( + `levelBadgeForPlayer({ levelHidden: ${String(levelHidden).padEnd(9)} }) ${fmtUs(b.stats.mean)} ±${b.stats.rme.toFixed(1)}%`, + ); } void sink; diff --git a/tests/perf/client/LobbyPlayerViewRenderPerf.ts b/tests/perf/client/LobbyPlayerViewRenderPerf.ts index 9a334c3ee4..7792eb626a 100644 --- a/tests/perf/client/LobbyPlayerViewRenderPerf.ts +++ b/tests/perf/client/LobbyPlayerViewRenderPerf.ts @@ -53,7 +53,7 @@ Object.defineProperty(globalThis, "window", { value: globalThis }); const RUNS = Number(process.env.RUNS ?? 100); const WARMUP = 20; // Point at another checkout's src/ to compare branches, e.g. -// LOBBY_PERF_SRC=C:/dev/OpenFrontIO/.worktrees/roster-levels/src +// LOBBY_PERF_SRC=../other-checkout/src const SRC = process.env.LOBBY_PERF_SRC ? pathToFileURL(process.env.LOBBY_PERF_SRC.replace(/\/?$/, "/")).href : new URL("../../../src/", import.meta.url).href;