@homie-rocks/studio 0.46.0 → 0.46.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ # Measuring a big room
2
+
3
+ Two tools, both for a trial on your own Cloudflare account. Neither is part of a studio: nothing in a studio's
4
+ Worker imports them.
5
+
6
+ - **From your computer** (`lib/crowd-load.mjs`): simulated players and two real Chrome players over your own
7
+ connection. What a player at home sees, up to what one connection and one computer can carry.
8
+
9
+ ```sh
10
+ CHROME_PATH=/path/to/chrome node node_modules/@homie-rocks/studio/lib/crowd-load.mjs \
11
+ --url https://<studio> --n 300 --seconds 150 --room home-300 --out reports/home-300
12
+ ```
13
+
14
+ - **From inside Cloudflare** (this folder): what the room and its Gates can do, with no home connection in it.
15
+
16
+ `lib/crowd-diagnostics.mjs` records the room's own figures beside either (a row a second), and
17
+ `lib/crowd-summary.mjs <folder>` puts a run's steps in one table. Both players steer as the crowd-circuit
18
+ fixture does; for another game, change the input they send.
19
+
20
+ # The in-Cloudflare crowd probe
21
+
22
+ A Worker that plays simulated players against a studio from inside Cloudflare's network. It separates what a
23
+ studio's room and Gates can do from what one home connection and one busy computer can carry. It is a trial's
24
+ tool: no studio's Worker imports it, and it is deleted when the trial is over.
25
+
26
+ It proves the servers at a given number of players. It does not prove the last hop to a real player: its sockets
27
+ never leave Cloudflare, and they are not compressed.
28
+
29
+ ## Deploy (in the account the studio is in, on a paid Workers plan)
30
+
31
+ ```sh
32
+ cd node_modules/@homie-rocks/studio/lib/crowd-probe
33
+ KEY=$(openssl rand -hex 24)
34
+ npx wrangler deploy --var PROBE_KEY:$KEY
35
+ ```
36
+
37
+ `wrangler` prints the probe's address (`https://homie-crowd-probe.<account>.workers.dev`). The probe answers
38
+ nothing without the key.
39
+
40
+ ## Run one step
41
+
42
+ ```sh
43
+ node node_modules/@homie-rocks/studio/lib/crowd-cloud.mjs \
44
+ --probe https://homie-crowd-probe.<account>.workers.dev --key $KEY \
45
+ --url https://<studio> --n 300 --seconds 150 --room cloud-300 --out reports/cloud-300
46
+ ```
47
+
48
+ It opens one request to the probe for every five players, each held open for the step, adds up what they
49
+ measured, and writes `report.json` in the shape `crowd-load.mjs` writes, so `crowd-summary.mjs` reads both.
50
+
51
+ ## Remove
52
+
53
+ ```sh
54
+ npx wrangler delete --name homie-crowd-probe
55
+ ```
56
+
57
+ ## What it needs of Cloudflare
58
+
59
+ - A paid plan: a request plays five players for minutes (the free plan's 10 ms of CPU a request is not enough).
60
+ - Six connections open at once per request, which is why a request plays five players.
61
+ - `global_fetch_strictly_public`, so its sockets reach the studio by its public address as a player's do.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * One simulated player, as the load probe plays it: says hello as the toolkit's client does (rules, coasting),
3
+ * decodes every snapshot, steers across the arena with a slow weave at ten inputs a second stamped a few ticks
4
+ * ahead, follows the room's word on how early those arrive, and names the tick it holds. No socket and no clock of
5
+ * its own: whoever runs it (the in-Cloudflare probe, a test) hands it messages and the time.
6
+ */
7
+ import { snapshotDecoder } from '../../rules/interest.mjs';
8
+
9
+ /** Counts by whole milliseconds, mergeable across players and across the Workers that ran them. */
10
+ export class Tally {
11
+ constructor(bins = {}) { this.bins = bins; }
12
+ add(ms) { if (Number.isFinite(ms) && ms >= 0) { const k = Math.min(60000, Math.round(ms)); this.bins[k] = (this.bins[k] ?? 0) + 1; } }
13
+ merge(other) { for (const [k, n] of Object.entries(other?.bins ?? other ?? {})) this.bins[k] = (this.bins[k] ?? 0) + n; return this; }
14
+ json() {
15
+ const keys = Object.keys(this.bins).map(Number).sort((a, b) => a - b), count = keys.reduce((a, k) => a + this.bins[k], 0);
16
+ const at = (q) => { let n = 0; for (const k of keys) { n += this.bins[k]; if (n >= Math.ceil(count * q)) return k; } return null; };
17
+ return { count, p50: count ? at(0.5) : null, p95: count ? at(0.95) : null, p99: count ? at(0.99) : null, max: keys.at(-1) ?? 0 };
18
+ }
19
+ }
20
+
21
+ export function crowdClient({ index, game, send, tallies, period = 50, lead = 3 }) {
22
+ const decoder = snapshotDecoder(), pending = new Map();
23
+ const c = { index, seat: null, epoch: null, k: 0, at: 0, r: 0, own: null, lead, lastSent: 0, nextInput: 0, nextPing: 0, early: [], leadAt: 0,
24
+ measuring: false, frames: 0, firstTick: null, firstAt: null, lastTick: null, lastAt: null, down: 0, up: 0, inputs: 0, reports: 0, late: 0,
25
+ undecodable: 0, ownMissing: 0, maxGap: 0, welcomed: null, first: null, began: null, errors: [], closed: null, rawTick: undefined };
26
+ const say = (m, now) => { const text = JSON.stringify(m); if (c.measuring) c.up += text.length; send(text, now); };
27
+ return {
28
+ c,
29
+ open(now) {
30
+ c.began = now; c.nextPing = now + (index % 20) * 100; c.nextInput = now + (index % 10) * 10;
31
+ say({ t: 'hello', rules: true, v: 1, rev: 12, token: null, name: `Probe ${index}`, device: 'desk', want: 'play', canHost: false, coast: 1, game, max: 1000 }, now);
32
+ },
33
+ measure() { c.measuring = true; pending.clear(); c.firstTick = c.firstAt = null; c.frames = 0; c.down = c.up = c.inputs = c.reports = c.late = 0; c.maxGap = 0; },
34
+ message(text, now) {
35
+ if (c.measuring) c.down += text.length;
36
+ let m; try { m = JSON.parse(text); } catch { c.errors.push('decode'); return; }
37
+ if (m.t === 'welcome') { c.seat = m.seat; c.welcomed = now - c.began; if (m.full || c.seat === null) c.errors.push('unseated'); if (m.snap) this.snapshot(m.snap, now); }
38
+ else if (m.t === 'snap') this.snapshot(m, now);
39
+ else if (m.t === 'seat') c.seat = m.seat;
40
+ else if (m.t === 'err' || m.t === 'error') c.errors.push(`${m.code ?? m.t}: ${String(m.message ?? '').slice(0, 80)}`);
41
+ },
42
+ snapshot(raw, now) {
43
+ c.rawTick = raw.k;
44
+ const snap = decoder.decode(raw);
45
+ if (!snap) { c.undecodable += 1; return; }
46
+ const own = snap.d[1].find((e) => e[9] === c.seat && e[10] !== 1);
47
+ if (!own) { c.ownMissing += 1; return; }
48
+ if (c.first === null) c.first = now - c.began;
49
+ if (c.epoch !== snap.e) { pending.clear(); c.lastSent = 0; c.epoch = snap.e; }
50
+ const gap = c.at ? now - c.at : 0;
51
+ c.k = snap.k; c.at = now; c.own = own; c.r = own[2];
52
+ if (c.measuring) { c.firstTick ??= snap.k; c.firstAt ??= now; c.lastTick = snap.k; c.lastAt = now; c.frames += 1; c.maxGap = Math.max(c.maxGap, gap); tallies.gaps.add(gap); }
53
+ const control = snap.c?.find((row) => row[0] === c.seat);
54
+ if (!control) return;
55
+ if (control[3] !== -128 && control[3] !== undefined) {
56
+ const early = control[3] / 16;
57
+ if (c.measuring) {
58
+ c.reports += 1; if (early <= 0) c.late += 1;
59
+ // From the tick this player held when it stamped an input to the tick that input reached the rules:
60
+ // what the room, the Gate and both ways of the connection took, whatever lead the player keeps.
61
+ tallies.loops.add((c.lead - early) * period);
62
+ }
63
+ c.early.push(early); if (c.early.length > 40) c.early.shift();
64
+ if (c.early.length >= 20 && now - c.leadAt > 2000) {
65
+ const low = c.early.slice().sort((a, b) => a - b)[Math.floor(c.early.length / 20)];
66
+ if (low < 0.5 && c.lead < 12) c.lead += 1; else if (low > 1.5 && c.lead > 1) c.lead -= 1;
67
+ c.leadAt = now; c.early.length = 0;
68
+ }
69
+ }
70
+ for (const [tick, at] of pending) {
71
+ if (tick > control[2]) break;
72
+ pending.delete(tick);
73
+ if (c.measuring && tick === control[2]) tallies.ack.add(now - at);
74
+ }
75
+ },
76
+ /** Call every ten milliseconds or so. */
77
+ tick(now) {
78
+ if (c.closed) return;
79
+ if (now >= c.nextPing) { say({ t: 'ping', c: Math.round(now), hid: false, ...(c.rawTick !== undefined ? { g: c.rawTick } : {}) }, now); c.nextPing = now + 2000; }
80
+ if (!c.own || now - c.at > 2000 || now < c.nextInput) return;
81
+ c.nextInput += 100; if (c.nextInput <= now) c.nextInput = now + 100;
82
+ const k = Math.max(c.lastSent + 1, c.k + Math.min(10, Math.floor((now - c.at) / period)) + c.lead), y = c.own[3][1], east = Boolean(c.own[7][1]);
83
+ say({ t: 'in', e: c.epoch, k, s: [[0, east ? 127 : -127, y > 55 ? -35 : y < -55 ? 35 : Math.round(Math.sin(now / 3100 + index) * 35)]], r: c.r, g: c.rawTick }, now);
84
+ if (c.measuring) c.inputs += 1;
85
+ pending.set(k, now); c.lastSent = k;
86
+ for (const [tick, at] of pending) { if (now - at <= 5000) break; pending.delete(tick); }
87
+ },
88
+ close(code, reason) { c.closed = [code, String(reason ?? '')]; },
89
+ facts() {
90
+ const span = c.lastAt > c.firstAt ? c.lastAt - c.firstAt : 0;
91
+ return { i: index, seat: c.seat, firstStateMs: c.first, welcomeMs: c.welcomed, closed: c.closed, lead: c.lead, frames: c.frames, maxStateGapMs: c.maxGap,
92
+ tickHz: span ? (c.lastTick - c.firstTick) * 1000 / span : null, deliveredHz: span ? (c.frames - 1) * 1000 / span : null,
93
+ downBytes: c.down, upBytes: c.up, inputs: c.inputs, reports: c.reports, late: c.late, undecodable: c.undecodable, ownMissing: c.ownMissing, errors: c.errors.slice(0, 8), fresh: c.at ? true : false };
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The in-Cloudflare crowd probe: a Worker of its own that plays a few simulated players against a studio from
3
+ * inside Cloudflare's network, so a trial can tell what the studio's servers can do from what one home connection
4
+ * and one busy computer can carry. NOT part of a studio: nothing in a studio's Worker imports it. Deploy it beside
5
+ * the studio only for a trial (`lib/crowd-probe/README.md`), and delete it after.
6
+ *
7
+ * One request plays up to FIVE players (a Worker may hold six connections open at once) for as long as the caller
8
+ * keeps reading, and answers with a line of JSON every five seconds and a last one with everything it measured.
9
+ * `lib/crowd-cloud.mjs` opens as many requests as the step needs and adds them up.
10
+ *
11
+ * It proves the room and its Gates at that many players. It says nothing of the last hop to a real player.
12
+ */
13
+ import { Tally, crowdClient } from './client.mjs';
14
+
15
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
16
+
17
+ export default {
18
+ async fetch(request, env, ctx) {
19
+ const url = new URL(request.url);
20
+ if (!env.PROBE_KEY || request.headers.get('x-probe-key') !== env.PROBE_KEY) return new Response('not found', { status: 404 });
21
+ if (url.pathname === '/hello') return Response.json({ ok: true, probe: 1 });
22
+ const origin = url.searchParams.get('origin'), game = url.searchParams.get('game') ?? 'crowd-circuit', room = url.searchParams.get('room');
23
+ const from = Number(url.searchParams.get('from') ?? 0), count = Math.min(5, Math.max(1, Number(url.searchParams.get('count') ?? 5)));
24
+ const joinAt = Number(url.searchParams.get('joinAt')), rampMs = Number(url.searchParams.get('rampMs') ?? 50);
25
+ const measureAt = Number(url.searchParams.get('measureAt')), endAt = Number(url.searchParams.get('endAt'));
26
+ if (!/^https:\/\/[a-z0-9.-]+$/.test(origin ?? '') || !/^[A-Za-z0-9_-]{1,40}$/.test(room ?? '') || ![joinAt, measureAt, endAt].every(Number.isFinite) || endAt - joinAt > 900_000) return new Response('bad request', { status: 400 });
27
+ const { readable, writable } = new TransformStream(), writer = writable.getWriter(), encoder = new TextEncoder();
28
+ const line = (o) => writer.write(encoder.encode(JSON.stringify(o) + '\n')).catch(() => {});
29
+ const tallies = { ack: new Tally(), gaps: new Tally(), loops: new Tally() }, players = [];
30
+ const run = async () => {
31
+ const join = async (i) => {
32
+ const key = crypto.randomUUID().replaceAll('-', '');
33
+ const player = { socket: null, client: null };
34
+ player.client = crowdClient({ index: i, game, tallies, send: (text) => { try { player.socket?.send(text); } catch { /* closing */ } } });
35
+ players.push(player);
36
+ try {
37
+ await sleep(Math.max(0, joinAt + i * rampMs - Date.now()));
38
+ // As a browser arrives: the game's page says where its socket is.
39
+ const page = await fetch(`${origin}/${game}/__game/?room=${encodeURIComponent(room)}&device=desk&want=play&b=${key}`);
40
+ const match = (await page.text()).match(/window\.HOMIE_NET=(\{[^<]+\})<\/script>/);
41
+ if (!page.ok || !match) throw new Error(`bootstrap HTTP ${page.status}`);
42
+ const cfg = JSON.parse(match[1]);
43
+ const answer = await fetch(cfg.url.replace(/^ws/, 'http'), { headers: { Upgrade: 'websocket', Origin: 'null' } });
44
+ const socket = answer.webSocket;
45
+ if (!socket) throw new Error(`upgrade HTTP ${answer.status}`);
46
+ socket.accept(); player.socket = socket;
47
+ socket.addEventListener('message', (event) => { if (typeof event.data === 'string') player.client.message(event.data, Date.now()); });
48
+ socket.addEventListener('close', (event) => player.client.close(event.code, event.reason));
49
+ socket.addEventListener('error', () => player.client.close(1006, 'error'));
50
+ player.client.open(Date.now());
51
+ } catch (error) { player.client.c.errors.push(String(error?.message ?? error).slice(0, 120)); player.client.close(0, 'never connected'); }
52
+ };
53
+ const joins = Promise.all(Array.from({ length: count }, (_, n) => join(from + n)));
54
+ let measuring = false, lastLine = 0, lastTick = Date.now(), worstLag = 0;
55
+ while (Date.now() < endAt) {
56
+ const now = Date.now();
57
+ // This Worker's own lateness: how long past ten milliseconds its timer came back.
58
+ worstLag = Math.max(worstLag, now - lastTick - 10); lastTick = now;
59
+ if (!measuring && now >= measureAt) { measuring = true; for (const p of players) p.client.measure(); }
60
+ for (const p of players) p.client.tick(now);
61
+ if (now - lastLine >= 5000) { lastLine = now; await line({ t: 'sample', at: now, from, joined: players.filter((p) => p.client.c.first !== null).length, open: players.filter((p) => p.socket && !p.client.c.closed).length, measuring }); }
62
+ await sleep(10);
63
+ }
64
+ await joins;
65
+ const facts = players.map((p) => p.client.facts());
66
+ for (const p of players) { try { p.socket?.close(1000, 'probe step complete'); } catch { /* gone */ } }
67
+ await line({ t: 'final', from, count, colo: request.cf?.colo ?? null, measuredMs: Math.max(0, endAt - measureAt), worstTimerLagMs: worstLag, players: facts, tallies: { ack: tallies.ack.bins, gaps: tallies.gaps.bins, loops: tallies.loops.bins } });
68
+ };
69
+ ctx.waitUntil(run().catch((error) => line({ t: 'failed', from, error: String(error?.stack ?? error).slice(0, 400) })).finally(() => writer.close().catch(() => {})));
70
+ return new Response(readable, { headers: { 'content-type': 'application/x-ndjson', 'cache-control': 'no-store' } });
71
+ },
72
+ };
@@ -0,0 +1,12 @@
1
+ {
2
+ // The in-Cloudflare crowd probe (see README.md here). A trial's tool: deploy it for the trial, delete it after.
3
+ "name": "homie-crowd-probe",
4
+ "main": "worker.mjs",
5
+ "compatibility_date": "2026-06-01",
6
+ // Its sockets go to the studio by its public address, as a player's do, even when both are in one account.
7
+ "compatibility_flags": ["global_fetch_strictly_public"],
8
+ "workers_dev": true,
9
+ // A step holds each request open for minutes and decodes every frame five players are sent.
10
+ "limits": { "cpu_ms": 300000 },
11
+ "observability": { "enabled": false }
12
+ }
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * One line a step for a crowd run: what the players saw (the driver's report) beside what the server did (the
4
+ * room's diagnostics, a row a second) and what this computer's connection could carry (the link probe).
5
+ *
6
+ * node lib/crowd-summary.mjs <run-dir> reads n<N>/report.json, n<N>/diagnostics.jsonl, n<N>/link.json;
7
+ * writes summary.json and summary.md there and prints the table
8
+ *
9
+ * A step is LINK-LIMITED when the bytes the driver's sockets read reached four fifths of what the probe measured
10
+ * the connection carrying: its client-side latency then measures the connection, and the server's own columns
11
+ * (ticks a second, input transit, frames a Gate wrote per player) are the ones to read.
12
+ */
13
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { join, resolve } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const sorted = (xs) => xs.filter(Number.isFinite).sort((a, b) => a - b);
18
+ const pick = (xs, p) => { const a = sorted(xs); return a.length ? a[Math.min(a.length - 1, Math.floor(a.length * p))] : null; };
19
+ const round = (n, d = 2) => (Number.isFinite(n) ? Math.round(n * 10 ** d) / 10 ** d : null);
20
+
21
+ /** `report`: the driver's report.json. `rows`: diagnostics.jsonl, parsed. `link`: { downMBps } or null. */
22
+ export function summarize(report, rows = [], link = null) {
23
+ const from = Date.parse(report.measurementStarted ?? ''), to = Date.parse(report.ended ?? '');
24
+ const during = rows.filter((r) => r.ok && r.ticks && (!Number.isFinite(from) || r.at >= from) && (!Number.isFinite(to) || r.at <= to));
25
+ const gates = during.flatMap((r) => (r.gates ?? []).filter((g) => g.subscribed > 0));
26
+ const seconds = report.measuredSeconds || 0;
27
+ const wire = report.payload?.wireDownBytes || report.payload?.downBytes || 0;
28
+ const wireMBps = seconds ? wire / seconds / 1e6 : null;
29
+ const linkMBps = link?.downMBps ?? null;
30
+ return {
31
+ // Where the players were: this computer and its connection ('home'), or inside Cloudflare's network.
32
+ from: report.generator === 'cloudflare' ? 'cloudflare' : 'home',
33
+ n: report.n, total: report.total, room: report.room, toolkit: report.toolkit ?? null, stopReason: report.stopReason ?? null,
34
+ joined: report.joined, connectedAtEnd: report.connectedAtEnd, disconnects: report.disconnects,
35
+ client: {
36
+ inputAckMs: { p50: report.inputAckMs?.p50 ?? null, p95: report.inputAckMs?.p95 ?? null, p99: report.inputAckMs?.p99 ?? null },
37
+ // A player's tick progression and the frames they received, a second: the median player, and the worst.
38
+ tickHz: { median: round(report.tickHz?.median), min: round(report.tickHz?.min) },
39
+ deliveredHz: { median: round(report.deliveredHz?.median), min: round(report.deliveredHz?.min) },
40
+ receiveGapMs: { p95: report.receiveGapMs?.p95 ?? null, p99: report.receiveGapMs?.p99 ?? null },
41
+ ackExpired: report.ackExpired ?? null,
42
+ compressedSockets: report.payload?.compressedSockets ?? null,
43
+ // Did the server agree to compress these sockets, and what it saved (decoded bytes over wire bytes).
44
+ compression: report.compression ?? null,
45
+ // Whole ticks ahead the median player stamped its input, and the share of the room's reports that said late.
46
+ inputLead: report.inputLead ?? null,
47
+ // From the tick a player held when it stamped an input to the tick that input reached the rules: the room,
48
+ // the Gate and the connection both ways, whatever lead the player keeps.
49
+ inputLoopMs: report.inputLoopMs ? { p50: report.inputLoopMs.p50, p95: report.inputLoopMs.p95, p99: report.inputLoopMs.p99 } : null,
50
+ // The driver's own health: a number above this is the driver's, not the server's.
51
+ driverLoopP99Ms: Math.max(0, ...(report.driver?.workerLoopP99Ms ?? [])) || null, driverPeakLagMs: Math.max(0, ...(report.driver?.peakTimerLagMs ?? []).filter(Number.isFinite)) || null,
52
+ driverLimited: Math.max(0, ...(report.driver?.peakTimerLagMs ?? []).filter(Number.isFinite)) > 250 || Math.max(0, ...(report.driver?.workerLoopP99Ms ?? [])) > 30,
53
+ wireKBsPerPlayer: round(seconds && report.joined && wire ? wire / seconds / report.joined / 1000 : null),
54
+ wireMBps: round(wireMBps || null), decodedMBps: round(seconds ? (report.payload?.downBytes ?? 0) / seconds / 1e6 : null),
55
+ errors: (report.errors ?? []).reduce((a, e) => a + (e.count ?? 1), 0),
56
+ },
57
+ link: { downMBps: round(linkMBps), limited: report.generator !== 'cloudflare' && linkMBps && wireMBps ? wireMBps >= linkMBps * 0.8 : null },
58
+ server: {
59
+ samples: during.length, failedPolls: rows.filter((r) => !r.ok).length,
60
+ maxAgeMs: pick(during.map((r) => r.diagnostics?.ageMs), 1),
61
+ // Ticks the rules ran a second, by the room's own clock.
62
+ tickRate: { median: round(pick(during.map((r) => r.tickRate), 0.5)), min: round(pick(during.map((r) => r.tickRate), 0)) },
63
+ // A Gate's stamp on a player's message to the room handling it.
64
+ inputTransitMs: { p50: pick(during.map((r) => r.inputTransitMs?.p50), 0.5), p95: pick(during.map((r) => r.inputTransitMs?.p95), 0.5), max: pick(during.map((r) => r.inputTransitMs?.max), 1) },
65
+ // Inputs that reached the rules after the tick they were stamped for (they apply on the next one).
66
+ lateInputs: (() => { const ins = during.reduce((a, r) => a + (r.ticks.ins ?? 0), 0), late = during.reduce((a, r) => a + (r.ticks.lateEntries ?? 0), 0); return ins ? round(late / ins, 3) : null; })(),
67
+ // Frames a Gate wrote to each of its players a second: the median Gate-second, and the worst.
68
+ gateDeliveredHz: { median: round(pick(gates.map((g) => g.deliveredHz), 0.5)), p05: round(pick(gates.map((g) => g.deliveredHz), 0.05)), min: round(pick(gates.map((g) => g.deliveredHz), 0)) },
69
+ gateWorldHz: { median: round(pick(gates.map((g) => g.worldHz), 0.5)), min: round(pick(gates.map((g) => g.worldHz), 0)) },
70
+ worldAgeMs: { p50: pick(gates.map((g) => g.worldAgeMs?.p50), 0.5), p95: pick(gates.map((g) => g.worldAgeMs?.p95), 0.95), max: pick(gates.map((g) => g.worldAgeMs?.max), 1) },
71
+ roomLinkAckMs: { median: pick(gates.map((g) => g.ackMs), 0.5), max: pick(gates.map((g) => g.maxAckMs), 1) },
72
+ gates: Math.max(0, ...during.map((r) => (r.gates ?? []).length)),
73
+ playersPerGate: (() => { const last = during.at(-1)?.gates ?? []; const c = sorted(last.map((g) => g.subscribed)); return c.length ? { min: c[0], max: c.at(-1) } : null; })(),
74
+ pacedPlayers: pick(during.map((r) => (r.gates ?? []).reduce((a, g) => a + (g.paced ?? 0), 0)), 1),
75
+ coastingPlayers: pick(during.map((r) => (r.gates ?? []).reduce((a, g) => a + (g.coasting ?? 0), 0)), 1),
76
+ // A Gate's own round trip to the room (its event loop's wait is in it): the median Gate-second and the worst.
77
+ // Ticks the room's worlds advanced a second by each Gate's own clock, and the longest a Gate went without one.
78
+ roomTickHzAtGates: { median: round(pick(gates.map((g) => g.roomTickHz), 0.5)), p05: round(pick(gates.map((g) => g.roomTickHz), 0.05)) }, worldGapMsMax: pick(gates.map((g) => g.worldGapMs), 1),
79
+ // The room's clock less a Gate's (median Gate), and how long the room says it held a Gate's frame.
80
+ roomClockOffsetMs: pick(gates.map((g) => g.roomClock?.offsetMs), 0.5), channelDelayMs: pick(gates.map((g) => g.roomClock?.delayMs), 0.5),
81
+ gateRttMs: { median: round(pick(gates.map((g) => g.rttMs), 0.5), 0), p95: round(pick(gates.map((g) => g.rttMs), 0.95), 0) },
82
+ inputOffsetMs: pick(gates.map((g) => g.inputOffsetMs), 0.5),
83
+ clientLagTicks: { p95: pick(gates.map((g) => g.clientLagTicks?.p95), 0.95), max: pick(gates.map((g) => g.clientLagTicks?.max), 1) },
84
+ },
85
+ };
86
+ }
87
+
88
+ /**
89
+ * The columns, in order. Some are easy to misread:
90
+ * - "from": where the players were. `home` is this computer and its connection, what a real player at home
91
+ * sees. `cloudflare` is the in-Cloudflare probe: what the servers can do, with no home connection in it.
92
+ * - "ack": from sending an input to the snapshot that acknowledges its tick. It is never less than the lead.
93
+ * - "loop": from the tick a player held when it stamped an input to the tick it reached the rules: the room, the
94
+ * Gate and the connection both ways. This is the latency the system adds, whatever lead the player keeps.
95
+ * - "lead (late)": whole ticks ahead the median player stamps its input, and the share the room called late.
96
+ * - "room Hz at Gates": ticks the room's worlds advanced a second by the GATES' clocks. The room's own count of
97
+ * its ticks cannot see the room run late.
98
+ * - "world age": a Gate's clock when it wrote a world, less the room's stamp, corrected by the offset between
99
+ * the two clocks that the Gate measures on its channel.
100
+ * - "driver": the load driver's worst timer lag. Marked DRIVER when it was over a quarter of a second: that
101
+ * step's client-side numbers are then the driver's.
102
+ */
103
+ export function table(steps) {
104
+ const cell = (v) => (v === null || v === undefined ? '—' : String(v));
105
+ const head = ['N', 'from', 'joined/end/drops', 'ack p50/p95/p99 ms', 'loop p50/p95 ms', 'lead (late)', 'client tick Hz', 'client delivered Hz', 'wire MB/s (link)', 'KB/s a player', 'compressed (ratio)', 'driver lag ms', 'server late', 'Gates', 'room Hz at Gates med/p05', 'Gate delivered Hz med/p05', 'Gate rtt med/p95 ms', 'world age p50/p95 ms', 'paced', 'diag ok/failed'];
106
+ const lines = [`| ${head.join(' | ')} |`, `| ${head.map(() => '---').join(' | ')} |`];
107
+ for (const s of steps) lines.push(`| ${[
108
+ s.n, s.from, `${cell(s.joined)}/${cell(s.connectedAtEnd)}/${cell(s.disconnects)}`,
109
+ `${cell(s.client.inputAckMs.p50)}/${cell(s.client.inputAckMs.p95)}/${cell(s.client.inputAckMs.p99)}`,
110
+ `${cell(s.client.inputLoopMs?.p50)}/${cell(s.client.inputLoopMs?.p95)}`,
111
+ `${cell(s.client.inputLead?.ticksMedian)} (${cell(s.client.inputLead?.lateShare)})`,
112
+ cell(s.client.tickHz.median), cell(s.client.deliveredHz.median),
113
+ `${cell(s.client.wireMBps)} (${cell(s.link.downMBps)}${s.link.limited ? ', LINK-LIMITED' : ''})`,
114
+ cell(s.client.wireKBsPerPlayer),
115
+ s.client.compression ? `${s.client.compression.sockets}/${s.client.compression.of} (${cell(s.client.compression.ratio)})` : cell(s.client.compressedSockets),
116
+ `${cell(s.client.driverPeakLagMs === null ? null : Math.round(s.client.driverPeakLagMs))}${s.client.driverLimited ? ' DRIVER' : ''}`,
117
+ cell(s.server.lateInputs), cell(s.server.gates || null),
118
+ `${cell(s.server.roomTickHzAtGates.median)}/${cell(s.server.roomTickHzAtGates.p05)}`,
119
+ `${cell(s.server.gateDeliveredHz.median)}/${cell(s.server.gateDeliveredHz.p05)}`, `${cell(s.server.gateRttMs.median)}/${cell(s.server.gateRttMs.p95)}`,
120
+ `${cell(s.server.worldAgeMs.p50)}/${cell(s.server.worldAgeMs.p95)}`, cell(s.server.pacedPlayers),
121
+ `${s.server.samples}/${s.server.failedPolls}`,
122
+ ].join(' | ')} |`);
123
+ return lines.join('\n');
124
+ }
125
+
126
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
127
+ const dir = resolve(process.argv[2] ?? '.');
128
+ const read = (file) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null);
129
+ const steps = [];
130
+ for (const name of readdirSync(dir).filter((n) => /^n\d+(-cloud)?$/.test(n)).sort((a, b) => parseInt(a.slice(1), 10) - parseInt(b.slice(1), 10) || a.length - b.length)) {
131
+ const report = read(join(dir, name, 'report.json'));
132
+ if (!report) continue;
133
+ const file = join(dir, name, 'diagnostics.jsonl');
134
+ const rows = existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean).flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } }) : [];
135
+ steps.push(summarize(report, rows, read(join(dir, name, 'link.json'))));
136
+ }
137
+ writeFileSync(join(dir, 'summary.json'), JSON.stringify(steps, null, 2));
138
+ writeFileSync(join(dir, 'summary.md'), `${table(steps)}\n`);
139
+ console.log(table(steps));
140
+ }