@homie-rocks/studio 0.45.3 → 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.
- package/CHANGELOG.md +102 -0
- package/README.md +3 -2
- package/docs/rooms-milestone-2-notes.md +345 -0
- package/guides/game/RULES.md +65 -3
- package/guides/game/SKILL.md +46 -2
- package/lib/build-identity.mjs +43 -0
- package/lib/build.mjs +1 -1
- package/lib/cloudflare.mjs +13 -11
- package/lib/crowd-cloud.mjs +88 -0
- package/lib/crowd-diagnostics.mjs +73 -0
- package/lib/crowd-load.mjs +603 -0
- package/lib/crowd-probe/README.md +61 -0
- package/lib/crowd-probe/client.mjs +96 -0
- package/lib/crowd-probe/worker.mjs +72 -0
- package/lib/crowd-probe/wrangler.jsonc +12 -0
- package/lib/crowd-summary.mjs +140 -0
- package/lib/crowd-worker.mjs +496 -0
- package/lib/rules-types.mjs +1 -1
- package/lib/scaffold.mjs +14 -8
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +3 -1
- package/netplay/netplay.ts +18 -5
- package/package.json +4 -2
- package/rules/core.ts +12 -6
- package/rules/host.ts +132 -17
- package/rules/interest.mjs +320 -51
- package/rules/rules.ts +32 -9
- package/rules/timing.mjs +20 -0
- package/worker/build-id.mjs +2 -0
- package/worker/characters.mjs +41 -0
- package/worker/functions-schema.mjs +16 -0
- package/worker/gate.mjs +436 -53
- package/worker/index.mjs +241 -25
- package/worker/office-page.mjs +11 -0
- package/worker/office.mjs +27 -0
- package/worker/room-store.mjs +113 -11
- package/worker/room.mjs +85 -16
- package/worker/version.mjs +1 -1
- package/worker/world-journal.mjs +109 -0
- package/worker/world-wire.mjs +110 -0
|
@@ -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'));
|
package/lib/build.mjs
CHANGED
|
@@ -602,7 +602,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
602
602
|
// studio.json site.order: the catalogue's order is every listing's order.
|
|
603
603
|
const shown = orderGames(all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))), s.order, log);
|
|
604
604
|
// Server-hosted games: what this build made, and for a one-game build what the catalogue before it said of the others.
|
|
605
|
-
const rulesOf = new Map(ruled.map((r) => [r.id, { host: r.settings.host, offline: r.settings.offline, tickHz: r.settings.tickHz, inputHz: r.settings.inputHz, contract: r.schema.contract, build: r.build, stateHash: r.stateHash, rounds: r.rounds, ...(r.schema.records?{records:true}:{}) }]));
|
|
605
|
+
const rulesOf = new Map(ruled.map((r) => [r.id, { ...(r.settings.lifetime ? { lifetime: r.settings.lifetime } : {}), ...(r.settings.characters ? { characters: true } : {}), host: r.settings.host, offline: r.settings.offline, tickHz: r.settings.tickHz, inputHz: r.settings.inputHz, contract: r.schema.contract, build: r.build, stateHash: r.stateHash, rounds: r.rounds, ...(r.schema.records?{records:true}:{}) }]));
|
|
606
606
|
if (only) for (const row of readJson(join(dist, 'games.json'))?.games ?? []) if (row?.room?.contract && row.id !== only && !rulesOf.has(row.id)) rulesOf.set(row.id, row.room);
|
|
607
607
|
// What each game's build is (its landing's own pictures are not part of it): the digest the manifest names.
|
|
608
608
|
const builds = {};
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -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
|
|
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}`)
|
|
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}`)
|
|
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'
|
|
203
|
-
...hosted.map((h) => ({ kind: 'Game rules', name: h.id, what: `
|
|
204
|
-
{ kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker'
|
|
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:
|
|
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
|
-
'
|
|
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;
|