diff --git a/tests/perf/LobbyInfoWirePerf.ts b/tests/perf/LobbyInfoWirePerf.ts new file mode 100644 index 00000000000..c640717c3b9 --- /dev/null +++ b/tests/perf/LobbyInfoWirePerf.ts @@ -0,0 +1,176 @@ +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 { + levelBadgeForPlayer, + levelBadgeFromProgress, +} 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). + * + * 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 = levelBadgeFromProgress(progress); +}); +console.log( + `levelBadgeFromProgress(progress) ${fmtUs(fromProgress.stats.mean)} ±${fromProgress.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 new file mode 100644 index 00000000000..7792eb626a3 --- /dev/null +++ b/tests/perf/client/LobbyPlayerViewRenderPerf.ts @@ -0,0 +1,255 @@ +/** + * 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, 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 + * 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=../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; + +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`; +} + +// 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 +// 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"); + // 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; + 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 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) { + t.list.push(t1 - t0); + t.drawn.push(t2 - t0); + } + } + 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)); + await badgesDrawn(); + const t = new Timings(); + for (let i = 0; i < WARMUP + RUNS; i++) { + 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(); + await badgesDrawn(); + const t2 = performance.now(); + if (i >= WARMUP) { + t.list.push(t1 - t0); + t.drawn.push(t2 - t0); + } + } + view.remove(); + return t.report(); + } + + 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); + }, +);