@homie-rocks/studio 0.46.0 → 0.46.3

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +3 -2
  3. package/docs/rooms-milestone-2-notes.md +345 -0
  4. package/guides/game/RULES.md +115 -5
  5. package/guides/game/SKILL.md +5 -0
  6. package/lib/build-identity.mjs +43 -0
  7. package/lib/cloudflare.mjs +13 -11
  8. package/lib/crowd-cloud.mjs +88 -0
  9. package/lib/crowd-diagnostics.mjs +73 -0
  10. package/lib/crowd-load.mjs +603 -0
  11. package/lib/crowd-probe/README.md +61 -0
  12. package/lib/crowd-probe/client.mjs +96 -0
  13. package/lib/crowd-probe/worker.mjs +72 -0
  14. package/lib/crowd-probe/wrangler.jsonc +12 -0
  15. package/lib/crowd-summary.mjs +140 -0
  16. package/lib/crowd-worker.mjs +496 -0
  17. package/lib/rules-check-worker.mjs +1 -1
  18. package/lib/rules-check.mjs +2 -2
  19. package/lib/rules-types.mjs +1 -1
  20. package/lib/scaffold.mjs +5 -5
  21. package/lib/template-history.json +84 -0
  22. package/netplay/netplay.ts +19 -6
  23. package/package.json +4 -2
  24. package/rules/core.ts +29 -4
  25. package/rules/host.ts +119 -21
  26. package/rules/interest.mjs +320 -51
  27. package/rules/pack.ts +1 -0
  28. package/rules/rules.ts +29 -8
  29. package/rules/timing.mjs +20 -0
  30. package/rules/types.ts +5 -0
  31. package/rules/view.ts +229 -25
  32. package/saves/SAVES.md +83 -3
  33. package/saves/saves.ts +2 -0
  34. package/worker/build-id.mjs +2 -0
  35. package/worker/gate.mjs +436 -53
  36. package/worker/index.mjs +92 -10
  37. package/worker/office-page.mjs +11 -0
  38. package/worker/office.mjs +27 -0
  39. package/worker/players.mjs +13 -15
  40. package/worker/room.mjs +113 -19
  41. package/worker/saves-shell.mjs +22 -2
  42. package/worker/saves.mjs +282 -19
  43. package/worker/version.mjs +1 -1
  44. package/worker/world-wire.mjs +110 -0
@@ -243,6 +243,11 @@ world or inventory, use Lasting worlds above. Ordinary match rooms expire after
243
243
  handle. Draw the character's name over its body and in the ranking (a declared command requests it; rules validate and keep it in the player entity), except on a kids server, where the others stay handles. Ember Vale does it.
244
244
  - Show who is playing (`saves.player.name`, guest or signed in) and a small "Keep my progress" button that calls
245
245
  `saves.signIn()`; the play page shows its passkey sheet. Pressing Play never needs an account.
246
+ - A guest who signs in to an account that already has the same key: say how the two combine, once, in
247
+ `site/src/worker.mjs`: `mergeSaves({ '<id>': { saves: { coins: 'sum', 'best-level': 'larger', settings: 'newer' },
248
+ stats: { kills: 'sum' } } })` from `@homie-rocks/studio/worker` (or a function for an inventory). Server records
249
+ are joined without a rule; any other key with no rule reaches the game's `onConflict` (SAVES.md, "When a guest
250
+ signs in to their account").
246
251
  - Prove it: build, `npm run dev`, open `http://localhost:8787/<id>/play` (passkeys need `localhost`, not
247
252
  `127.0.0.1`), play, reload: the progress is still there. The whole guide is
248
253
  `node_modules/@homie-rocks/studio/saves/SAVES.md` (the limits, offline and conflicts, what is stored, privacy).
@@ -0,0 +1,43 @@
1
+ /** Content identity of the runtime, independent of a studio's package pin or git commit. */
2
+ import { createHash } from 'node:crypto';
3
+ import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { join } from 'node:path';
6
+ const root = fileURLToPath(new URL('../', import.meta.url));
7
+ export function runtimeIdentity() {
8
+ const hash = createHash('sha256');
9
+ function walk(dir) {
10
+ for (const entry of readdirSync(join(root, dir), {
11
+ withFileTypes: true
12
+ }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
13
+ const path = dir + '/' + entry.name;
14
+ if (entry.isDirectory()) walk(path);
15
+ else if (/\.(mjs|ts|json)$/.test(path) && path !== 'worker/build-id.mjs')
16
+ hash
17
+ .update(path + '\0')
18
+ .update(readFileSync(join(root, path)))
19
+ .update('\0');
20
+ }
21
+ }
22
+ for (const dir of ['worker', 'rules', 'netplay']) walk(dir);
23
+ return {
24
+ version: JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
25
+ .version,
26
+ sha256: hash.digest('hex')
27
+ };
28
+ }
29
+ export function stampRuntime(check = false) {
30
+ const text =
31
+ '// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.\nexport const STUDIO_BUILD = Object.freeze(' +
32
+ JSON.stringify(runtimeIdentity()) +
33
+ ');\n',
34
+ path = join(root, 'worker/build-id.mjs');
35
+ if (check) {
36
+ if (readFileSync(path, 'utf8') !== text)
37
+ throw Error(
38
+ 'Runtime identity is stale: node packages/studio/lib/build-identity.mjs --write'
39
+ );
40
+ } else writeFileSync(path, text);
41
+ }
42
+ if (process.argv[1] === fileURLToPath(import.meta.url))
43
+ stampRuntime(process.argv.includes('--check'));
@@ -1,3 +1,4 @@
1
+ import { roomLayout } from '../worker/gate.mjs';
1
2
  import {workerEntry} from './worker-build.mjs';
2
3
  import { seatCount } from '../worker/seats.mjs';
3
4
  import { refreshWorkerConfig } from './worker-config.mjs';
@@ -175,9 +176,12 @@ export async function zonePlan(root, opts = {}) {
175
176
  */
176
177
  export function hostedUse({ seats = 8, inputHz = 20 } = {}) {
177
178
  const requests = Math.round((seats * (inputHz * 3600 + 1800)) / 20);
178
- const gbSeconds = 0.128 * 3600;
179
+ const layout = roomLayout(seats);
180
+ // A room behind Gates: the room, its Gates, and the object that keeps its diagnostics.
181
+ const objects = 1 + (layout.gates > 1 ? layout.gates + 1 : 0);
182
+ const gbSeconds = 0.128 * 3600 * objects;
179
183
  const pct = (n, of) => (n / of < 0.001 ? 'under 0.1%' : `${Math.round((n / of) * 1000) / 10}%`);
180
- return { seats, requests, gbSeconds, rows: 12, share: { requests: pct(requests, 100_000), gbSeconds: pct(gbSeconds, 13_000), rows: pct(12, 100_000) } };
184
+ return { seats, requests, gbSeconds, objects, layout, requestBasis: 'player input and ping only; relay frames, receipts, HTTP, saves and Lobby are additional', rows: 12, share: { requests: pct(requests, 100_000), gbSeconds: pct(gbSeconds, 13_000), rows: pct(12, 100_000) } };
181
185
  }
182
186
 
183
187
  /** What `deploy` will create and what it costs, from studio.json alone. It calls nothing. */
@@ -197,18 +201,16 @@ export function deployPlan(root) {
197
201
  return {
198
202
  ok: true, command: 'deploy plan', studio: studio.name, account: cf.accountId ?? null,
199
203
  cloudflare: [
200
- { kind: 'Worker', name: cf.worker, what: 'the studio\'s pages, each game\'s page and play shell, and /.well-known/homie-studio.json for the directory', state: mark(`worker:${cf.worker}`), plan: 'Workers Free' },
201
- { kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round, the studio\'s own stats (daily counters of visits, plays, rooms, rounds and songs, for the owner only; nothing about a visitor), and, for games that keep saves, player accounts (a passkey\'s public key, never a password) and their saves', state: mark(`d1:${cf.d1}`), plan: 'Workers Free (500 MB per database, 5 GB per account)' },
202
- { kind: 'Durable Object', name: 'Table', what: hosted.length ? `one per public room: the netplay relay (seats, snapshots), and for ${hosted.map((h) => h.id).join(', ')} the game's own rules, which run here and nowhere else` : 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
203
- ...hosted.map((h) => ({ kind: 'Game rules', name: h.id, what: `the rules of ${h.name} run on Cloudflare, in each room's Table, not in a player's browser. An hour of play in one full room (${h.seats} players) uses about ${h.share.requests} of the free plan's 100,000 requests a day, ${h.share.gbSeconds} of its 13,000 GB-seconds of running time and ${h.share.rows} of its 100,000 rows written. A room with nobody in it uses nothing. Out of requests, every room of every game stops until 00:00 UTC; the Workers Paid plan removes the three allowances`, state: 'in the Worker', plan: 'Workers Free' })),
204
- { kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
204
+ { kind: 'Worker', name: cf.worker, what: 'the studio\'s pages, each game\'s page and play shell, and /.well-known/homie-studio.json for the directory', state: mark(`worker:${cf.worker}`) },
205
+ { kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round, the studio\'s own stats (daily counters of visits, plays, rooms, rounds and songs, for the owner only; nothing about a visitor), and, for games that keep saves, player accounts (a passkey\'s public key, never a password) and their saves', state: mark(`d1:${cf.d1}`) },
206
+ { kind: 'Durable Object', name: 'Table', what: hosted.length ? `one per public room: the netplay relay (seats, snapshots), and for ${hosted.map((h) => h.id).join(', ')} the game's own rules, which run here and nowhere else` : 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker' },
207
+ ...hosted.map((h) => ({ kind: 'Game rules', name: h.id, what: `One full ${h.seats}-player room activates ${h.objects} objects (${h.layout.gates > 1 ? `${h.layout.gates} Gates, the room and its diagnostics` : 'the room'}): approximately ${h.gbSeconds} GB-seconds/hour before Lobby activity. Player input/ping alone contributes ${h.requests} request units/hour; relay frames and receipts, HTTP and storage are additional. Occupancy, batching and account usage determine the bill.`, state: 'in the Worker' })),
208
+ { kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker' },
205
209
  storage
206
210
  ? { kind: 'R2 bucket', name: storage, what: 'the studio\'s big media: songs and videos over the size studio.json media.r2Over names (1 MiB unless set) or that git leaves out, uploaded and checked by SHA-256 before the site stops carrying them, served at their same addresses', state: 'exists (this studio made it)', plan: 'R2 (payment method on the account; 10 GB-month free)' }
207
211
  : { kind: 'R2 bucket', name: null, what: 'none: a new studio needs no storage. `homie-studio storage add` adds it later, for large media only', state: 'not created', plan: null },
208
212
  ],
209
- cost: storage
210
- ? `Free on Cloudflare's Workers Free plan. ${R2_COST} Beyond the free tier Cloudflare bills the account directly.`
211
- : 'Free: everything above is on Cloudflare\'s Workers Free plan, which needs no payment method. Its daily limits (100,000 Worker requests; D1 5 million rows read and 100,000 written) reset at 00:00 UTC; past them requests fail until the reset, nothing is charged.',
213
+ cost: 'Account subscription not queried. Cloudflare bills usage under the account’s current plan; estimates are not a billing quote.',
212
214
  login: 'One approval: `npx wrangler login` opens Cloudflare in the person\'s browser (a free account works; a new one verifies its email address first).',
213
215
  address: `https://${cf.worker}.<the account's workers.dev subdomain>.workers.dev`,
214
216
  directory: {
@@ -299,7 +301,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
299
301
  const account = who.accounts.find((a) => a.id === accountId)?.name ?? 'the signed-in account';
300
302
  announced.push(
301
303
  `First deploy of ${studio.name} to the Cloudflare account "${account}". It creates: the Worker ${cf.worker} (the site and its rooms), the D1 database ${cf.d1} (rounds, and the studio's own stats: counts for the owner, never a visitor's identity), and the Durable Objects Table and Lobby (SQLite-backed).`,
302
- 'Cost: free, on the Workers Free plan; no payment method, no R2 (storage for large media is `homie-studio storage add`, later, only if wanted).',
304
+ 'Account subscription not queried; usage is billed under the account’s current Cloudflare plan.',
303
305
  `The directory (${homie || studio.homie?.directory || 'https://homie.rocks'}) will store the site's address and claim, the studio's name, and each game's name, blurb and Play link.`,
304
306
  );
305
307
  for (const line of announced) log(line);
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * One crowd step played from inside Cloudflare, through the in-Cloudflare probe (lib/crowd-probe/README.md).
4
+ *
5
+ * node lib/crowd-cloud.mjs --probe <probe address> --key <PROBE_KEY> --url <studio> --n 300
6
+ * [--seconds 150] [--room <name>] [--out <dir>] [--ramp-ms 50] [--game crowd-circuit]
7
+ *
8
+ * It opens a request to the probe for every five players, holds them open for the step, and writes report.json in
9
+ * the shape crowd-load.mjs writes (what applies: there is no browser and no home connection in this one).
10
+ */
11
+ import { mkdir, writeFile } from 'node:fs/promises';
12
+ import { resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { Tally } from './crowd-probe/client.mjs';
15
+
16
+ const pick = (sorted, q) => (sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * q))] : null);
17
+
18
+ /** Every probe request's last line, added up into one report. */
19
+ export function cloudReport({ origin, room, n, seconds, started, measureAt, endAt, finals, failures = [], toolkit = null }) {
20
+ const players = finals.flatMap((f) => f.players), tallies = { ack: new Tally(), gaps: new Tally(), loops: new Tally() };
21
+ for (const f of finals) for (const key of Object.keys(tallies)) tallies[key].merge(f.tallies?.[key]);
22
+ const of = (key) => players.map((p) => p[key]).filter(Number.isFinite).sort((a, b) => a - b);
23
+ const range = (key) => { const a = of(key); return { count: a.length, min: a[0] ?? null, median: pick(a, 0.5), p50: pick(a, 0.5), max: a.at(-1) ?? null }; };
24
+ const sum = (key) => players.reduce((a, p) => a + (p[key] ?? 0), 0), leads = of('lead');
25
+ const errors = new Map();
26
+ for (const p of players) { for (const text of p.errors ?? []) errors.set(text, (errors.get(text) ?? 0) + 1); if (p.closed && p.closed[1] !== 'probe step complete') { const text = `close: ${p.closed[0]} ${p.closed[1]}`; errors.set(text, (errors.get(text) ?? 0) + 1); } }
27
+ for (const text of failures) errors.set(text, (errors.get(text) ?? 0) + 1);
28
+ const joined = players.filter((p) => p.firstStateMs !== null).length, measured = Math.max(0, (endAt - measureAt) / 1000);
29
+ return {
30
+ generator: 'cloudflare', colos: [...new Set(finals.map((f) => f.colo).filter(Boolean))],
31
+ started: new Date(started).toISOString(), measurementStarted: new Date(measureAt).toISOString(), ended: new Date(endAt).toISOString(),
32
+ origin, room, n, total: n, simulated: n, chromePlayers: 0, toolkit, requestedSeconds: seconds, measuredSeconds: measured, stopReason: finals.length ? null : 'no probe answered',
33
+ joined, connectedAtEnd: players.filter((p) => p.firstStateMs !== null && (!p.closed || p.closed[1] === 'probe step complete')).length,
34
+ disconnects: players.filter((p) => p.closed && p.closed[1] !== 'probe step complete').length,
35
+ firstStateMs: (() => { const a = of('firstStateMs'); return { count: a.length, p50: pick(a, 0.5), p95: pick(a, 0.95), p99: pick(a, 0.99), max: a.at(-1) ?? null }; })(),
36
+ inputAckMs: tallies.ack.json(), receiveGapMs: tallies.gaps.json(),
37
+ // From the tick a player held when it stamped an input to the tick that input reached the rules.
38
+ inputLoopMs: tallies.loops.json(),
39
+ tickHz: range('tickHz'), deliveredHz: range('deliveredHz'),
40
+ payload: { upBytes: sum('upBytes'), downBytes: sum('downBytes'), inputFrames: sum('inputs'), downFrames: sum('frames'), wireDownBytes: 0, compressedSockets: 0 },
41
+ // Sockets that never leave Cloudflare are not compressed and cross no home connection.
42
+ compression: { offered: false, sockets: 0, of: players.length, ratio: null },
43
+ inputLead: { ticksMedian: pick(leads, 0.5), ticksMax: leads.at(-1) ?? null, lateShare: sum('reports') ? Math.round(sum('late') / sum('reports') * 1000) / 1000 : null },
44
+ driver: { requests: finals.length, failedRequests: failures.length, workerLoopP99Ms: [], peakTimerLagMs: finals.map((f) => f.worstTimerLagMs) },
45
+ errors: [...errors].map(([text, count]) => ({ text, count })),
46
+ players,
47
+ };
48
+ }
49
+
50
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
51
+ const opts = {};
52
+ for (let i = 2; i < process.argv.length; i += 2) { if (!process.argv[i].startsWith('--') || process.argv[i + 1] === undefined) throw Error('Use --key value'); opts[process.argv[i].slice(2)] = process.argv[i + 1]; }
53
+ const probe = String(opts.probe ?? '').replace(/\/$/, ''), key = opts.key, origin = new URL(opts.url).origin, n = Number(opts.n ?? 50), seconds = Number(opts.seconds ?? 150);
54
+ const rampMs = Number(opts['ramp-ms'] ?? 50), game = opts.game ?? 'crowd-circuit', room = opts.room ?? `cloud-${n}-${Date.now().toString(36)}`, out = resolve(opts.out ?? `reports/crowd-circuit/${room}`);
55
+ if (!probe || !key || !Number.isInteger(n) || n < 1 || n > 1000 || !(seconds >= 1 && seconds <= 600)) throw Error('--probe, --key, --url, --n 1..1000, --seconds 1..600');
56
+ await mkdir(out, { recursive: true });
57
+ const hello = await fetch(`${probe}/hello`, { headers: { 'x-probe-key': key }, signal: AbortSignal.timeout(15000) });
58
+ if (!hello.ok) throw Error(`The probe did not answer (${hello.status}): is it deployed, and is the key its PROBE_KEY?`);
59
+ let toolkit = null;
60
+ try { toolkit = (await (await fetch(`${origin}/.well-known/homie-studio.json?probe=${Date.now()}`, { signal: AbortSignal.timeout(15000) })).json()).toolkit ?? null; } catch { /* reported as null */ }
61
+ // Every request is told the same moments: when its players join (one every ramp-ms), when to start measuring
62
+ // (fifteen seconds after the last has joined) and when to stop.
63
+ const started = Date.now(), joinAt = started + 5000, measureAt = joinAt + n * rampMs + 15000, endAt = measureAt + seconds * 1000;
64
+ console.log(JSON.stringify({ phase: 'start', generator: 'cloudflare', origin, room, n, seconds, requests: Math.ceil(n / 5), toolkit }));
65
+ const finals = [], failures = []; let lastNote = 0; const progress = new Map();
66
+ await Promise.all(Array.from({ length: Math.ceil(n / 5) }, async (_, r) => {
67
+ const from = r * 5, count = Math.min(5, n - from);
68
+ try {
69
+ const res = await fetch(`${probe}/play?origin=${encodeURIComponent(origin)}&game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&from=${from}&count=${count}&joinAt=${joinAt}&rampMs=${rampMs}&measureAt=${measureAt}&endAt=${endAt}`, { headers: { 'x-probe-key': key }, signal: AbortSignal.timeout(endAt - Date.now() + 60000) });
70
+ if (!res.ok || !res.body) throw Error(`probe HTTP ${res.status}`);
71
+ let text = '', final = null;
72
+ for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) {
73
+ text += chunk;
74
+ for (let at = text.indexOf('\n'); at >= 0; at = text.indexOf('\n')) {
75
+ const row = JSON.parse(text.slice(0, at)); text = text.slice(at + 1);
76
+ if (row.t === 'final') final = row; else if (row.t === 'failed') throw Error(row.error); else if (row.t === 'sample') progress.set(from, row);
77
+ }
78
+ if (Date.now() - lastNote > 10000) { lastNote = Date.now(); const rows = [...progress.values()]; console.log(JSON.stringify({ phase: rows.some((x) => x.measuring) ? 'steady' : 'ramp', joined: rows.reduce((a, x) => a + x.joined, 0), open: rows.reduce((a, x) => a + x.open, 0) })); }
79
+ }
80
+ if (!final) throw Error('the probe ended without its report');
81
+ finals.push(final);
82
+ } catch (error) { failures.push(`probe request ${from}: ${String(error?.message ?? error).slice(0, 160)}`); }
83
+ }));
84
+ const report = cloudReport({ origin, room, n, seconds, started, measureAt, endAt, finals, failures, toolkit });
85
+ await writeFile(`${out}/report.json`, JSON.stringify(report, null, 2));
86
+ console.log(JSON.stringify({ phase: 'done', out: `${out}/report.json`, joined: report.joined, connectedAtEnd: report.connectedAtEnd, ack: report.inputAckMs, loop: report.inputLoopMs, deliveredHz: report.deliveredHz, lead: report.inputLead, failedRequests: failures.length }));
87
+ if (!finals.length) process.exitCode = 1;
88
+ }
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ /** Authenticated, bounded capture, including named QA rooms absent from office lists. */
3
+ import { appendFile, mkdir } from 'node:fs/promises';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { withKey } from './office.mjs';
6
+ const opts = {};
7
+ for (let i = 2; i < process.argv.length; i += 2) {
8
+ if (!process.argv[i].startsWith('--') || !process.argv[i + 1])
9
+ throw Error('Use --key value');
10
+ opts[process.argv[i].slice(2)] = process.argv[i + 1];
11
+ }
12
+ const room = opts.room,
13
+ game = opts.game ?? 'crowd-circuit',
14
+ seconds = Number(opts.seconds ?? 300),
15
+ out = resolve(opts.out ?? 'diagnostics.jsonl');
16
+ if (
17
+ !/^[A-Za-z0-9_-]{1,32}$/.test(room ?? '') ||
18
+ !Number.isFinite(seconds) ||
19
+ seconds < 1 ||
20
+ seconds > 3600
21
+ )
22
+ throw Error('--room is required; --seconds 1..3600');
23
+ await mkdir(dirname(out), { recursive: true });
24
+ let stopped = false;
25
+ for (const s of ['SIGINT', 'SIGTERM']) process.on(s, () => (stopped = true));
26
+ const result = await withKey(
27
+ resolve(opts.root ?? process.cwd()),
28
+ opts.url,
29
+ async (call) => {
30
+ const end = Date.now() + seconds * 1000;
31
+ while (!stopped && Date.now() < end) {
32
+ const start = Date.now();
33
+ let r;
34
+ try {
35
+ r = await call(
36
+ `/_studio/api/room?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}`
37
+ );
38
+ } catch (error) {
39
+ r = { ok: false, error: String(error?.message ?? error) };
40
+ }
41
+ const f = r.facts;
42
+ // The endpoint answers from the room's diagnostics object: a second's
43
+ // facts from the room (tick rate, input transit) and from each Gate
44
+ // (frames written per player, world age, pacing), with the copy's age.
45
+ await appendFile(
46
+ out,
47
+ JSON.stringify({
48
+ at: Date.now(),
49
+ requestMs: Date.now() - start,
50
+ status: r.status,
51
+ ok: r.ok,
52
+ toolkit: f?.toolkit,
53
+ room: f?.room,
54
+ counts: f?.counts,
55
+ ticks: f?.ticks,
56
+ tickRate: f?.tickRate,
57
+ inputTransitMs: f?.inputTransitMs,
58
+ transport: f?.transport,
59
+ gates: f?.gates,
60
+ durability: f?.durability,
61
+ diagnostics: f?.diagnostics,
62
+ error: r.error
63
+ }) + '\n'
64
+ );
65
+ // A failed poll is evidence, not the end of the capture. Authentication
66
+ // errors are also retained; withKey renews and revokes its one key.
67
+ await new Promise((r) => setTimeout(r, 1000));
68
+ }
69
+ return { ok: true };
70
+ }
71
+ );
72
+ console.log(JSON.stringify({ out, ...result }));
73
+ if (!result.ok) process.exitCode = 1;