@homie-rocks/studio 0.49.0 → 0.50.1

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 (70) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/bin/homie-studio.mjs +5 -5
  3. package/functions/FUNCTIONS.md +71 -1
  4. package/guides/app/SKILL.md +130 -1
  5. package/guides/art/SKILL.md +154 -0
  6. package/guides/art/references/RIGHTS.md +20 -0
  7. package/guides/art/scripts/art.mjs +308 -0
  8. package/guides/models/SKILL.md +198 -0
  9. package/guides/models/references/RIGHTS.md +46 -0
  10. package/guides/models/references/models.json +175 -0
  11. package/guides/models/scripts/models.mjs +483 -0
  12. package/guides/music/SKILL.md +197 -0
  13. package/guides/music/references/COMPOSITION.md +59 -0
  14. package/guides/music/references/GAME-AUDIO.md +50 -0
  15. package/guides/music/references/RIGHTS.md +43 -0
  16. package/guides/music/scripts/lib/audio.mjs +258 -0
  17. package/guides/music/scripts/lib/eleven.mjs +260 -0
  18. package/guides/music/scripts/lib/studio.mjs +147 -0
  19. package/guides/music/scripts/music.mjs +644 -0
  20. package/guides/office/SKILL.md +99 -0
  21. package/guides/plan/SKILL.md +126 -0
  22. package/guides/plan/references/CODEX.md +105 -0
  23. package/guides/plan/references/INTERVIEW.md +86 -0
  24. package/guides/playtest/SKILL.md +205 -0
  25. package/guides/playtest/references/METHOD.md +124 -0
  26. package/guides/playtest/references/REVIEWER.md +78 -0
  27. package/guides/playtest/scripts/lib/exit.mjs +31 -0
  28. package/guides/playtest/scripts/lib/judge.mjs +502 -0
  29. package/guides/playtest/scripts/lib/pixels.mjs +128 -0
  30. package/guides/playtest/scripts/lib/preflight.mjs +69 -0
  31. package/guides/playtest/scripts/playtest.mjs +870 -0
  32. package/guides/servers/SKILL.md +111 -0
  33. package/guides/shop/SKILL.md +4 -0
  34. package/guides/sound/SKILL.md +156 -0
  35. package/guides/sound/references/GAME-SOUND.md +100 -0
  36. package/guides/sound/references/SCORE.md +131 -0
  37. package/guides/sound/references/TRAPS.md +93 -0
  38. package/guides/sound/scripts/lib/measure.mjs +252 -0
  39. package/guides/sound/scripts/lib/score.mjs +415 -0
  40. package/guides/sound/scripts/lib/sfx.mjs +222 -0
  41. package/guides/sound/scripts/lib/synth.mjs +509 -0
  42. package/guides/sound/scripts/sound.mjs +520 -0
  43. package/guides/style/SKILL.md +105 -0
  44. package/lib/app-needs.mjs +63 -0
  45. package/lib/check.mjs +10 -4
  46. package/lib/codex.mjs +95 -18
  47. package/lib/decisions.mjs +9 -7
  48. package/lib/functions-build.mjs +10 -0
  49. package/lib/handoff.mjs +1 -1
  50. package/lib/mcp-tools.mjs +10 -9
  51. package/lib/mcp.mjs +1 -1
  52. package/lib/scaffold.mjs +15 -7
  53. package/lib/studio.mjs +3 -3
  54. package/lib/template-history.json +84 -0
  55. package/lib/worker-config.mjs +1 -1
  56. package/package.json +1 -1
  57. package/shop/SHOP.md +4 -0
  58. package/shop/shop.ts +4 -4
  59. package/tools/TOOLS.md +21 -0
  60. package/tools/tools.ts +1 -1
  61. package/worker/build-id.mjs +1 -1
  62. package/worker/event-resources.mjs +116 -0
  63. package/worker/functions-schema.mjs +15 -0
  64. package/worker/functions.mjs +36 -22
  65. package/worker/index.mjs +23 -3
  66. package/worker/mcp-tools.mjs +14 -3
  67. package/worker/mcp.mjs +45 -8
  68. package/worker/shop-page.mjs +1 -1
  69. package/worker/shop.mjs +6 -2
  70. package/worker/version.mjs +1 -1
@@ -0,0 +1,870 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * playtest.mjs: play a studio's game in real browsers and come back with numbers about it:
4
+ * how fast a stranger is in and moving, what the screen looks like on a computer and on a phone held
5
+ * both ways, how much of it the UI covers, what the game really sounds like, and how a round goes when
6
+ * one person plays hard and another does nothing. Then the owner tests and the two-stranger round from
7
+ * @homie-rocks/studio. It writes pictures and a report, and a brief for a blind reviewer.
8
+ *
9
+ * run <game> --url <site> [--only first,look,ui,sound,play,controls,round] [--seconds 20] [--out <dir>]
10
+ * review <run folder> [--to "<who reviews>"] [--local]
11
+ * write REVIEW.md: the brief a fresh reviewer (a subagent that never saw the code) scores from,
12
+ * and say exactly which files the review step hands over, as one question to approve once.
13
+ * --local writes REVIEW-LOCAL.md instead: the same rubric for this session to score the
14
+ * pictures itself when nothing may leave it (labelled NOT independent).
15
+ * reviewed <run folder> --kind independent|local|none [--by "<who>"] [--reason "<why>"] [--score <0-100>]
16
+ * record which review ran (or that none did, and why) in the run's report
17
+ * report <run folder> print a finished run's report again
18
+ *
19
+ * Headless Chrome on the GPU through puppeteer-core from the studio's node_modules (it comes with
20
+ * @homie-rocks/studio). At most two browsers at a time. The browsers are muted: nothing plays out loud.
21
+ * Rows say PASS, FAIL, WARN, BLOCKED or N/A. BLOCKED means it could not be measured (a software renderer, a stuck
22
+ * decoder, a round that was on its results screen for the whole wait), which is never the same as "fine". N/A means
23
+ * the row did not apply to what was on screen (a results card is not judged against the active-play UI bar).
24
+ *
25
+ * What the game's state is at each press and each picture (round phase, time left, whether the body is the player's
26
+ * to steer, control mode) comes from the port probe and the play page's shell: lib/judge.mjs lists every name read.
27
+ * game.json may declare the game's primary action ("playtest": { "primary": ... }) and "scoring": "together".
28
+ */
29
+ import { spawn } from 'node:child_process';
30
+ import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, statfsSync, writeFileSync } from 'node:fs';
31
+ import { tmpdir } from 'node:os';
32
+ import { dirname, join, relative, resolve } from 'node:path';
33
+ import { fileURLToPath } from 'node:url';
34
+ import { experienceDir, experienceJson, findStudio, readJson } from '../../music/scripts/lib/studio.mjs';
35
+ import { measure, sheetPng, warnings } from '../../sound/scripts/lib/measure.mjs';
36
+ import { wavBytes } from '../../sound/scripts/lib/synth.mjs';
37
+ import { GPU_FLAGS, chromePath, loadPuppeteer } from '../../video/scripts/lib/browser.mjs';
38
+ import { finish, within } from './lib/exit.mjs';
39
+ import { actionProfile, activePlay, connectionNote, contextOf, describeActions, describeState, judgeFirst, judgeMove, judgeScores, judgeUi, oppositeOf, shellOverlaps, EXTRA_NAMES, readPort, UI_CAVEAT, pairMismatch, readinessOf, renderCost, reportMd, reviewLine, stateOf, weakest, REVIEW_KINDS } from './lib/judge.mjs';
40
+ import { decode, motion, stats, uiCover } from './lib/pixels.mjs';
41
+ import { preflight } from './lib/preflight.mjs';
42
+
43
+ const HERE = dirname(fileURLToPath(import.meta.url));
44
+ const TAP = readFileSync(join(HERE, '..', '..', 'video', 'scripts', 'tap.js'), 'utf8');
45
+ const argv = process.argv.slice(2);
46
+ const flags = new Map();
47
+ const pos = [];
48
+ for (let i = 0; i < argv.length; i++) {
49
+ const a = argv[i];
50
+ if (a.startsWith('--')) { const k = a.slice(2); const v = argv[i + 1]; if (v === undefined || v.startsWith('--')) flags.set(k, true); else { flags.set(k, v); i++; } } else pos.push(a);
51
+ }
52
+ const JSON_OUT = flags.has('json');
53
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
54
+ const log = (m) => { if (!JSON_OUT) process.stderr.write(`${m}\n`); };
55
+ // A bounded wait whose losing timer is cleared (lib/exit.mjs): a race that leaves its timer behind keeps Node alive.
56
+ const T = (p, ms, v = null) => within(p, ms, v);
57
+
58
+ const DEVICES = {
59
+ desk: { width: 1280, height: 800, isMobile: false, hasTouch: false, deviceScaleFactor: 1 },
60
+ phone: { width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 },
61
+ 'phone-landscape': { width: 844, height: 390, isMobile: true, hasTouch: true, deviceScaleFactor: 2 },
62
+ };
63
+ const PHONE_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36';
64
+
65
+ /* ---------------------------------------------------------------- browsers */
66
+
67
+ let puppeteer = null;
68
+ let EXE = null;
69
+ const open = [];
70
+
71
+ /** Open one muted headless GPU Chrome on the game's play page (or /tv). */
72
+ async function launch(device, url, { tap = false, autoplay = false } = {}) {
73
+ const vp = DEVICES[device];
74
+ const profile = mkdtempSync(join(tmpdir(), 'homie-playtest-'));
75
+ const browser = await puppeteer.launch({
76
+ executablePath: EXE, headless: true, userDataDir: profile, timeout: 150_000, protocolTimeout: 120_000, // 150 s to start on a loaded computer
77
+ args: [...GPU_FLAGS, '--mute-audio', `--window-size=${vp.width},${vp.height}`, '--no-first-run', '--no-default-browser-check', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--force-color-profile=srgb', ...(autoplay ? ['--autoplay-policy=no-user-gesture-required'] : [])],
78
+ });
79
+ const page = await browser.newPage();
80
+ await page.setViewport(vp);
81
+ const ua = vp.isMobile ? PHONE_UA : await browser.userAgent();
82
+ await page.setUserAgent(`${ua} homie-playtest`);
83
+ const h = { device, vp, browser, page, profile, errors: [], bad: [], t0: 0 };
84
+ page.on('pageerror', (e) => h.errors.push(String(e?.message ?? e).slice(0, 300)));
85
+ page.on('console', (m) => { if (m.type() === 'error') h.errors.push(`console: ${m.text().slice(0, 300)}`); });
86
+ page.on('response', (r) => { if (r.status() >= 400) h.bad.push(`${r.status()} ${r.url().replace(/^https?:\/\/[^/]+/, '')}`); });
87
+ if (tap) await page.evaluateOnNewDocument(TAP);
88
+ // A frame counter in every frame: rAF ticks, so a software renderer's 1-2 fps is seen before it is judged.
89
+ await page.evaluateOnNewDocument(() => { let n = 0; const tick = () => { n++; requestAnimationFrame(tick); }; requestAnimationFrame(tick); window.__ptFrames = () => n; });
90
+ open.push(h);
91
+ h.t0 = Date.now();
92
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
93
+ return h;
94
+ }
95
+
96
+ async function close(h) {
97
+ const i = open.indexOf(h);
98
+ if (i >= 0) open.splice(i, 1);
99
+ try { await T(h.browser.close(), 8000); } catch { /* */ }
100
+ try { h.browser.process()?.kill('SIGKILL'); } catch { /* */ }
101
+ rmSync(h.profile, { recursive: true, force: true });
102
+ }
103
+
104
+ const gameFrame = (h) => h.page.frames().find((f) => /\/__game\//.test(f.url())) ?? null;
105
+ async function waitFrame(h, ms = 30_000) {
106
+ const until = Date.now() + ms;
107
+ while (Date.now() < until) { const f = gameFrame(h); if (f) return f; await sleep(200); }
108
+ return null;
109
+ }
110
+ const shell = (h) => T(h.page.evaluate(() => { const s = window.__shell; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, round: s.round, results: s.results } : null; }), 5000);
111
+ const inFrame = async (h, fn, arg) => { const f = gameFrame(h); return f ? T(f.evaluate(fn, arg), 8000) : null; };
112
+ const probeInfo = (h) => inFrame(h, () => { const p = window.__homiePort; if (!p) return null; const i = p.info(); return { movement: window.__homieNet?.probe?.movement?.() ?? null, view: p.view, size: p.size, keys: p.keys, thumb: p.thumb, world: p.world, info: i, now: p.now() }; });
113
+ const selfAt = (h, since) => inFrame(h, (s) => { const p = window.__homiePort; if (!p) return null; const rows = p.rows(s); return rows.length ? rows[rows.length - 1] : null; }, since);
114
+ const frameNow = (h) => inFrame(h, () => performance.now());
115
+
116
+ /**
117
+ * The game's visible DOM HUD, as rectangles in the frame's CSS pixels (run inside the game's frame): every element
118
+ * that shows something of its own (text, a picture, a control) and is not the world (a canvas, a video, the game's
119
+ * declared world element) nor the helper's own "Reconnecting" line. At most 150, the outermost of each. A HUD drawn
120
+ * inside the canvas has no element and is not here.
121
+ */
122
+ const HUD_ELEMENTS = (worldSel) => {
123
+ const out = [];
124
+ const world = worldSel ? [...document.querySelectorAll(worldSel)] : [];
125
+ const press = (el) => /^(BUTTON|A|INPUT|SELECT|TEXTAREA)$/.test(el.tagName) || el.getAttribute('role') === 'button' || el.hasAttribute('data-action');
126
+ const own = (el) => press(el) || /^(IMG|SVG|svg)$/.test(el.tagName) || [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
127
+ const walk = (el) => {
128
+ if (out.length >= 150) return;
129
+ if (/^(CANVAS|VIDEO|SCRIPT|STYLE|IFRAME)$/.test(el.tagName) || world.includes(el) || el.hasAttribute('data-homie-link')) return;
130
+ const cs = getComputedStyle(el);
131
+ if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) < 0.05) return;
132
+ if (own(el)) {
133
+ const b = el.getBoundingClientRect();
134
+ if (b.width > 0 && b.height > 0 && b.right > 0 && b.bottom > 0 && b.left < innerWidth && b.top < innerHeight) {
135
+ const name = `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : el.classList[0] ? `.${el.classList[0]}` : ''}`;
136
+ const text = (el.textContent || el.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim().slice(0, 24);
137
+ out.push({ label: text ? `${name} "${text}"` : name, x: Math.round(b.left), y: Math.round(b.top), w: Math.round(b.width), h: Math.round(b.height), interactive: press(el) });
138
+ return; // the outermost element that shows something stands for what is inside it
139
+ }
140
+ }
141
+ for (const c of el.children) walk(c);
142
+ };
143
+ if (document.body) walk(document.body);
144
+ return out;
145
+ };
146
+
147
+ /**
148
+ * The game's state right now, from what it exposes (lib/judge.mjs says which names): the round and time left, whether
149
+ * the body is the player's to steer, where it is, the control mode and loadout, and the page's loading cover.
150
+ * Anything the game does not expose stays null; nothing here changes the game.
151
+ */
152
+ async function readState(h) {
153
+ const [port, sh, net] = await Promise.all([
154
+ inFrame(h, readPort, EXTRA_NAMES),
155
+ T(h.page.evaluate(() => { const s = window.__shell; if (!s) return null; const a = s.arrival; const r = s.round; return { seat: s.seat, round: r ? { n: r.n, phase: r.phase, endsAt: r.endsAt } : null, arrival: a ? { phase: a.phase, liftedMs: a.liftedMs, by: a.by } : null, link: s.link && typeof s.link === 'object' ? { state: s.link.state } : null, stale: s.stale ? { ver: s.stale.ver ?? null } : null }; }), 5000),
156
+ // The helper's own word on its link (revision 9), in the game's frame: a browser cut off from its room is labelled, not judged as play.
157
+ inFrame(h, () => { const n = window.__homieNet; return n ? { link: typeof n.link === 'string' ? n.link : null, reconnects: Number.isFinite(n.reconnects) ? n.reconnects : null, stale: typeof n.stale === 'string' ? n.stale : null } : null; }),
158
+ ]);
159
+ const st = stateOf({ at: Date.now(), port, shell: sh, net });
160
+ // Not part of the game's state, kept beside it: the renderer's counters at this moment (measured runtime cost).
161
+ Object.defineProperty(st, 'render', { enumerable: false, value: port?.extra ? { drawCalls: port.extra.drawCalls, triangles: port.extra.triangles } : null });
162
+ return st;
163
+ }
164
+
165
+ /** Wait (at most `ms`) for a moment that can be judged as active play: a live round, a free body, the cover gone. */
166
+ async function waitActive(h, ms) {
167
+ const t0 = Date.now(); let state = null;
168
+ while (Date.now() - t0 < ms) {
169
+ state = await readState(h);
170
+ // true: active play. null: the game does not say, so there is nothing to wait for (the row says "unknown").
171
+ if (activePlay(state) !== false) return { ok: true, ms: Date.now() - t0, state };
172
+ await sleep(300);
173
+ }
174
+ return { ok: false, ms: Date.now() - t0, state };
175
+ }
176
+
177
+ /** The game's own frame counter (the probe's, else requestAnimationFrame in the game's frame): its render heartbeat. */
178
+ const heartbeat = (h) => inFrame(h, () => { let n = null; try { n = window.__homiePort?.info?.().frames ?? null; } catch { /* */ } return n ?? window.__ptFrames?.() ?? null; });
179
+
180
+ async function screenshot(h) { return T(h.page.screenshot({ type: 'png', captureBeyondViewport: false }), 15_000); }
181
+
182
+ /** What a session's script really pressed: counted as it goes, so a score conclusion can say what was exercised. */
183
+ const newActions = (profile) => ({ moves: 0, primary: { declared: profile.declared, how: profile.how, pressed: 0, missed: 0 } });
184
+
185
+ /**
186
+ * Press the game's primary action once, the way game.json declares it (lib/judge.mjs actionProfile): a key, a mouse
187
+ * button, the game's own touch control found by its selector, or a declared region. False when a declared control
188
+ * was not on screen (hidden between rounds, another loadout): counted as missed, never as pressed.
189
+ */
190
+ async function pressPrimary(h, profile) {
191
+ try {
192
+ if (profile.kind === 'key') { await h.page.keyboard.press(profile.key); return true; }
193
+ if (profile.kind === 'mouse') { await h.page.mouse.click(Math.round(h.vp.width * profile.at[0]), Math.round(h.vp.height * profile.at[1]), { button: profile.button }); return true; }
194
+ if (profile.kind === 'selector') {
195
+ const f = gameFrame(h);
196
+ const el = f ? await T(f.$(profile.selector), 3000) : null;
197
+ // An element in the game's frame: puppeteer gives its box in the page's own coordinates.
198
+ const box = el ? await T(el.boundingBox(), 3000) : null;
199
+ try { await el?.dispose(); } catch { /* */ }
200
+ if (!box || box.width < 2 || box.height < 2) return false;
201
+ await h.page.touchscreen.tap(Math.round(box.x + box.width / 2), Math.round(box.y + box.height / 2));
202
+ return true;
203
+ }
204
+ const [x, y, w, hh] = profile.region;
205
+ await h.page.touchscreen.tap(Math.round(h.vp.width * (x + w / 2)), Math.round(h.vp.height * (y + hh / 2)));
206
+ return true;
207
+ } catch { return false; }
208
+ }
209
+
210
+ /** Hold one direction for `ms`: a key on a computer, a thumb on the game's stick on a phone. */
211
+ async function hold(h, dir, ms, probe) {
212
+ const keys = probe?.keys ?? { up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight' };
213
+ if (h.vp.hasTouch) await drag(h, dir, ms, probe);
214
+ else { await h.page.keyboard.down(keys[dir]).catch(() => {}); await sleep(ms); await h.page.keyboard.up(keys[dir]).catch(() => {}); }
215
+ }
216
+
217
+ /** Play like a person: hold a direction for most of a second, now and then press the primary action, change your mind. */
218
+ async function playFor(h, seconds, probe, { rng = Math.random, profile = actionProfile(null, h.device), actions = newActions(profile) } = {}) {
219
+ const until = Date.now() + seconds * 1000;
220
+ const dirs = ['up', 'right', 'down', 'left'];
221
+ let presses = 0;
222
+ const times = [];
223
+ while (Date.now() < until) {
224
+ const d = dirs[Math.floor(rng() * 4)];
225
+ const ms = 500 + Math.floor(rng() * 900);
226
+ times.push(Date.now());
227
+ await hold(h, d, ms, probe);
228
+ presses++; actions.moves++;
229
+ if (rng() < 0.3) {
230
+ times.push(Date.now());
231
+ if (await pressPrimary(h, profile)) { actions.primary.pressed++; presses++; } else actions.primary.missed++;
232
+ }
233
+ await sleep(80 + Math.floor(rng() * 200));
234
+ }
235
+ return { presses, times, actions };
236
+ }
237
+
238
+ /** A thumb lands where the game's stick lives, slides 70 px the pressed way in small steps, holds, lifts. */
239
+ async function drag(h, dir, hold, probe) {
240
+ const [fx, fy] = probe?.thumb ?? [0.24, 0.74];
241
+ const x = Math.round(h.vp.width * fx); const y = Math.round(h.vp.height * fy);
242
+ const v = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[dir];
243
+ const ts = h.page.touchscreen;
244
+ try {
245
+ await ts.touchStart(x, y);
246
+ for (let k = 1; k <= 6; k++) { await ts.touchMove(x + v[0] * 12 * k, y + v[1] * 12 * k); await sleep(16); }
247
+ const until = Date.now() + hold;
248
+ let w = 0;
249
+ while (Date.now() < until) { w++; await ts.touchMove(x + v[0] * 72 + (w % 2), y + v[1] * 72); await sleep(60); }
250
+ await ts.touchEnd();
251
+ } catch { /* a closed page */ }
252
+ }
253
+
254
+ /* ---------------------------------------------------------------- rows */
255
+
256
+ const rows = [];
257
+ const row = (name, verdict, detail = {}) => { rows.push({ name, verdict, ...detail }); log(`${verdict.padEnd(7)} ${name}${detail.why ? `: ${detail.why}` : ''}`); };
258
+
259
+ /**
260
+ * One device, one browser: the first seconds (seated, first paint, the loading cover gone, the first picture of the
261
+ * game itself), the first move, the look during play, and how much of the screen the UI covers. Every press and
262
+ * every picture is taken with the game's state beside it (readState), and a row that needs active play waits for it.
263
+ */
264
+ async function deviceSession(device, base, game, out, seconds, only, gameJson) {
265
+ const h = await launch(device, `${base}/${game}/play`);
266
+ const profile = actionProfile(gameJson, device);
267
+ const actions = newActions(profile);
268
+ const save = async (name, png) => { if (!png) return null; const f = join(out, `${device}-${name}.png`); writeFileSync(f, png); return f; };
269
+ try {
270
+ // First seconds. Four separate times (lib/judge.mjs readinessOf): a seat; the page's first paint, which is the
271
+ // loading card and not the game; the cover gone; and the first picture with the cover gone and the game's own
272
+ // frames advancing. A non-black loading screen at 35 ms is not "the first picture of the game".
273
+ const samples = []; let paintPng = null; let gamePng = null; let lastBeat = null; let beatFrom = null;
274
+ const marks = [1000, 3000, 5000, 10000];
275
+ const firstShots = [];
276
+ const until = h.t0 + 30_000;
277
+ while (Date.now() < until && (marks.length || !samples.some((x) => x.seated) || !gamePng)) {
278
+ const s = await shell(h);
279
+ const seated = Boolean(s?.room && s.seat !== null && s.seat !== undefined && s.role);
280
+ const st = await readState(h);
281
+ const beat = await heartbeat(h);
282
+ const moved = Number.isFinite(beat) && Number.isFinite(lastBeat) && beat > lastBeat;
283
+ if (Number.isFinite(beat)) { lastBeat = beat; beatFrom ??= st.source === 'probe' ? 'the port probe\'s frame counter' : 'requestAnimationFrame in the game\'s frame'; }
284
+ const el = Date.now() - h.t0;
285
+ let picture = false;
286
+ if (!gamePng || (marks.length && el >= marks[0])) {
287
+ const png = await screenshot(h);
288
+ if (png) {
289
+ const ps = stats(decode(png, 320));
290
+ picture = !ps.black && !ps.flat;
291
+ if (picture && !paintPng) paintPng = png;
292
+ if (picture && moved && st.cover !== 'up' && !gamePng) gamePng = png;
293
+ if (marks.length && el >= marks[0]) { const m = marks.shift(); firstShots.push({ at: m, file: await save(`first-${m / 1000}s`, png), state: describeState(st), ...ps }); }
294
+ }
295
+ }
296
+ samples.push({ ms: el, seated, picture, cover: st.cover, heartbeat: moved });
297
+ await sleep(250);
298
+ }
299
+ const ready = readinessOf(samples);
300
+ const frame = await waitFrame(h, 5000);
301
+ const probe = await probeInfo(h);
302
+ const fps = frame ? await T(frame.evaluate(() => new Promise((r) => { const a = window.__ptFrames?.() ?? 0; setTimeout(() => r(((window.__ptFrames?.() ?? 0) - a) / 2), 2000); })), 6000) : null;
303
+ const renderer = frame ? await T(frame.evaluate(() => { try { const c = document.createElement('canvas'); const g = c.getContext('webgl2') || c.getContext('webgl'); if (!g) return null; const e = g.getExtension('WEBGL_debug_renderer_info'); return e ? g.getParameter(e.UNMASKED_RENDERER_WEBGL) : g.getParameter(g.RENDERER); } catch { return null; } }), 5000) : null;
304
+ const slow = fps !== null && fps < 20;
305
+ const soft = /swiftshader|llvmpipe|software/i.test(String(renderer ?? ''));
306
+ const softWhy = `this browser renders at ${fps} fps on "${renderer}": a software renderer makes any game look stuck, so nothing here is judged (run on a computer with a GPU)`;
307
+ if (only.has('first')) {
308
+ const j = judgeFirst(ready);
309
+ row(`first ${device}`, slow || soft ? 'BLOCKED' : j.verdict, { why: slow || soft ? softWhy : j.why, note: j.note, ...ready, heartbeatFrom: beatFrom, fps, renderer, shots: firstShots.map((x) => rel(out, x.file)), frames: firstShots.map((x) => ({ at: x.at, state: x.state })) });
310
+ // First move, a row of its own: it needs a live round and a body that is the player's, and says so when it
311
+ // had neither. A press into a wall, a press on the results screen and a press as a spectator are not "the
312
+ // controls do not answer".
313
+ const HOLD_MS = 3000;
314
+ if (slow || soft) row(`move ${device}`, 'BLOCKED', { why: softWhy });
315
+ else if (!probe) row(`move ${device}`, 'BLOCKED', { why: 'the game has no port probe (exposePort), so the script cannot see its body: first-move latency was not measured (the controls row needs the probe too)' });
316
+ else if (probe.movement === 'stationary') row(`move ${device}`, 'N/A', { why: 'Rules declare stationary player anchors (maxSpeed 0). Test actual choices/taps/turns in two browsers; this does not pass their action latency.' });
317
+ else if (probe.view === 'board') row(`move ${device}`, 'N/A', { why: 'a board game has no body to move: the controls row checks that a press is applied' });
318
+ else {
319
+ const tryMove = async (dir) => {
320
+ const before = await readState(h);
321
+ const t0 = await frameNow(h);
322
+ const from = await selfAt(h, t0 - 500);
323
+ const started = Date.now();
324
+ const press = hold(h, dir, 1600, probe);
325
+ let ms = null;
326
+ while (Date.now() - started < HOLD_MS) {
327
+ const now = await selfAt(h, t0);
328
+ if (from && now && Number.isFinite(from[1]) && Number.isFinite(now[1]) && Math.hypot(now[1] - from[1], now[2] - from[2]) > (probe.size || 10) * 0.5) { ms = Date.now() - started; break; }
329
+ await sleep(40);
330
+ }
331
+ await press;
332
+ actions.moves++;
333
+ return { dir, moved: ms !== null, ms, before, after: await readState(h) };
334
+ };
335
+ const waited = await waitActive(h, 25_000);
336
+ const attempts = [];
337
+ if (waited.ok) {
338
+ attempts.push(await tryMove('right'));
339
+ // Nothing moved: a wall on that side looks exactly like this. Press the other way before saying anything.
340
+ if (!attempts[0].moved) { await waitActive(h, 10_000); attempts.push(await tryMove(oppositeOf('right'))); }
341
+ }
342
+ const j = judgeMove(attempts, { waited, holdMs: HOLD_MS });
343
+ const png = await screenshot(h);
344
+ row(`move ${device}`, j.verdict, { why: j.why, controlMs: j.controlMs, waitedForPlayMs: waited.ms, attempts: j.attempts, shot: rel(out, await save('move', png)) });
345
+ }
346
+ }
347
+ if (paintPng) await save('first-paint', paintPng);
348
+ if (gamePng) await save('first-frame', gamePng);
349
+ // The look, while playing like a person.
350
+ if (only.has('look')) {
351
+ const lookShots = [];
352
+ const cost = [];
353
+ const playing = playFor(h, seconds, probe, { profile, actions });
354
+ const every = Math.max(2500, (seconds * 1000) / 6);
355
+ let prev = null;
356
+ for (let t = 0; t < seconds * 1000 - 500; t += every) {
357
+ await sleep(t === 0 ? 1500 : every);
358
+ const st = await readState(h);
359
+ const png = await screenshot(h);
360
+ if (!png) continue;
361
+ const img = decode(png, 480);
362
+ const ps = stats(img);
363
+ const mv = prev ? motion(prev, img) : null;
364
+ prev = img;
365
+ if (st.render) cost.push(st.render);
366
+ lookShots.push({ file: rel(out, await save(`look-${lookShots.length + 1}`, png)), motion: mv, state: describeState(st), screen: contextOf(st).kind, ...ps });
367
+ }
368
+ const { presses } = await playing;
369
+ const black = lookShots.filter((x) => x.black || x.flat).length;
370
+ const dead = lookShots.filter((x) => x.flatBlackShare > 0.05).length;
371
+ const plain = lookShots.filter((x) => x.flatDarkShare > 0.3).length;
372
+ const still = lookShots.filter((x) => x.motion !== null && x.motion < 0.002).length;
373
+ const avg = (k) => +(lookShots.reduce((a, x) => a + x[k], 0) / Math.max(1, lookShots.length)).toFixed(3);
374
+ const notes = [];
375
+ if (avg('mean') < 40) notes.push(`dark: mean brightness ${avg('mean')} of 255`);
376
+ if (avg('sd') < 22) notes.push(`low contrast: luma spread ${avg('sd')}`);
377
+ if (avg('edges') < 0.03) notes.push(`little visible detail (${(avg('edges') * 100).toFixed(1)}% edge pixels): flat shapes read as unfinished`);
378
+ if (plain >= 2) notes.push(`over 30% of the screen is one flat dark colour in ${plain} shots: an empty backdrop reads as unfinished; give the floor texture, light or props`);
379
+ if (still >= 2 && probe?.movement !== 'stationary') notes.push(`${still} pairs of shots barely changed while playing: is anything moving?`);
380
+ const offPlay = lookShots.filter((x) => !['live', 'unknown'].includes(x.screen));
381
+ const why = black ? `${black} of ${lookShots.length} shots were black or one colour while playing` : dead >= 2 ? `${dead} shots have pure-black holes over 5% of the screen: nothing drew there (a failed shader, a world that never loaded, the clear colour)` : undefined;
382
+ // Measured runtime cost, when the game exposes its renderer's counters; "not exposed" is said, never a zero.
383
+ const rc = renderCost(cost);
384
+ row(`look ${device}`, slow || soft ? 'BLOCKED' : why ? 'FAIL' : notes.length ? 'WARN' : 'PASS', {
385
+ why: why ?? (notes.length ? notes.join('; ') : undefined), presses, mean: avg('mean'), contrast: avg('sd'), saturation: avg('saturation'), edges: avg('edges'),
386
+ qualifier: offPlay.length ? `${offPlay.length} of ${lookShots.length} shots were not active play (${[...new Set(offPlay.map((x) => x.screen))].join(', ')}): each shot carries its state` : undefined,
387
+ render: rc ? `draw calls median ${rc.drawCalls?.median ?? '?'} (max ${rc.drawCalls?.max ?? '?'}), triangles median ${rc.triangles?.median ?? '?'} (max ${rc.triangles?.max ?? '?'}) over ${rc.samples} samples` : 'not exposed by the game (exposePort extra: drawCalls, triangles): runtime scene cost was not measured here',
388
+ renderCost: rc, shots: lookShots,
389
+ });
390
+ }
391
+ // UI cover: the world hidden, the page's background black and then white; what stays is the UI.
392
+ if (only.has('ui') && h.vp.isMobile) {
393
+ const world = probe?.world ?? null;
394
+ const paint = async (bg) => {
395
+ await T(h.page.evaluate((c) => { let s = document.getElementById('__pt_ui'); if (!s) { s = document.createElement('style'); s.id = '__pt_ui'; document.head.appendChild(s); } s.textContent = c ? `html,body,iframe.game{background:${c}!important}` : ''; }, bg), 5000);
396
+ await inFrame(h, ([c, sel]) => { let s = document.getElementById('__pt_ui'); if (!s) { s = document.createElement('style'); s.id = '__pt_ui'; (document.head || document.documentElement).appendChild(s); } s.textContent = c ? `canvas,video${sel ? `,${sel}` : ''}{visibility:hidden!important} html,body{background:${c}!important;background-image:none!important}` : ''; }, [bg, world]);
397
+ await sleep(350);
398
+ };
399
+ await playFor(h, 1.2, probe, { profile, actions });
400
+ // Self-check (PLAYTEST_UI_SELFCHECK=1): a known opaque panel, a third of the width square, in the middle of the game.
401
+ if (process.env.PLAYTEST_UI_SELFCHECK) await inFrame(h, () => { const d = document.createElement('div'); d.style.cssText = 'position:fixed;left:33.4%;top:40%;width:33.2vw;height:33.2vw;background:#345;z-index:99999'; document.body.appendChild(d); });
402
+ // Active play is what the 12% bar is about: wait for a live round and a free body. If the wait runs out, the
403
+ // screen that IS there (a results card, a spectator view) is measured and labelled, and not judged by that bar.
404
+ const waited = await waitActive(h, 25_000);
405
+ let pair = null;
406
+ for (let k = 0; k < 3; k++) {
407
+ // The thumb stays down on the stick for both frames: a held stick is part of what a player sees in play.
408
+ const [fx, fy] = probe?.thumb ?? [0.24, 0.74];
409
+ const tx = Math.round(h.vp.width * fx); const ty = Math.round(h.vp.height * fy);
410
+ let thumb = 'up';
411
+ try { await h.page.touchscreen.touchStart(tx, ty); for (let m = 1; m <= 6; m++) { await h.page.touchscreen.touchMove(tx + 12 * m, ty); await sleep(16); } thumb = 'held'; } catch { /* a closed page */ }
412
+ const before = await readState(h);
413
+ // The page's own controls and the game's DOM HUD, in the same pixels, at the same moment (lib/judge.mjs shellOverlaps).
414
+ const layout = await T(h.page.evaluate(() => { const r = window.__shell?.rects; return r ? { width: r.width, height: r.height, rects: r.rects } : null; }), 5000);
415
+ const hud = await inFrame(h, HUD_ELEMENTS, world);
416
+ await paint('#000'); const a = await screenshot(h);
417
+ await paint('#fff'); const b = await screenshot(h);
418
+ const after = await readState(h);
419
+ await paint(null);
420
+ try { await h.page.touchscreen.touchEnd(); } catch { /* */ }
421
+ pair = { a, b, before, after, thumb, tries: k + 1, layout, hud };
422
+ // A round that ended between the two frames makes them two different screens: take the pair again.
423
+ if (!a || !b || !pairMismatch(before, after)) break;
424
+ await waitActive(h, 15_000);
425
+ }
426
+ if (pair.a && pair.b) {
427
+ // Both frames are kept, with the state each was taken in: the pair can be checked by eye.
428
+ await save('ui-on-black', pair.a); await save('ui-on-white', pair.b);
429
+ const kept = { shot: `${device}-ui-on-black.png`, shotWhite: `${device}-ui-on-white.png`, stateBlack: describeState(pair.before), stateWhite: describeState(pair.after), thumb: pair.thumb, pairTries: pair.tries, waitedForPlayMs: waited.ms };
430
+ const mismatch = pairMismatch(pair.before, pair.after);
431
+ const cov = mismatch ? null : uiCover(decode(pair.a, 390), decode(pair.b, 390));
432
+ const overlaps = shellOverlaps(pair.layout, pair.hud);
433
+ const j = judgeUi(cov ?? { cover: 0, opaque: 0, centreOpaque: 0 }, { before: pair.before, after: pair.after, thumb: pair.thumb, overlaps });
434
+ row(`ui ${device}`, j.verdict, { why: j.why, ...(cov ?? {}), screen: j.screen, state: j.state, qualifier: j.qualifier, note: UI_CAVEAT, ...kept, hudUnderShell: overlaps.known ? overlaps.overlaps.length : 'not checked', shellOverlaps: overlaps.overlaps });
435
+ } else row(`ui ${device}`, 'BLOCKED', { why: 'no screenshot' });
436
+ }
437
+ return { errors: h.errors, bad: h.bad, probe: Boolean(probe), actions };
438
+ } finally { await close(h); }
439
+ }
440
+
441
+ /** What the game really sounds like: its own Web Audio output copied off the graph for `seconds` of play. */
442
+ async function soundSession(base, game, out, seconds, gameJson) {
443
+ const h = await launch('desk', `${base}/${game}/play`, { tap: true });
444
+ const profile = actionProfile(gameJson, 'desk');
445
+ try {
446
+ const frame = await waitFrame(h, 30_000);
447
+ if (!frame) { row('sound', 'BLOCKED', { why: 'the game frame never opened' }); return; }
448
+ await sleep(2500);
449
+ const probe = await probeInfo(h);
450
+ const pre = await inFrame(h, () => window.__homieTap?.clock?.() ?? null);
451
+ // The first gesture: a click in the middle of the game, then keys.
452
+ const gestureWall = Date.now();
453
+ await h.page.mouse.click(Math.round(h.vp.width / 2), Math.round(h.vp.height / 2)).catch(() => {});
454
+ let runningMs = null;
455
+ for (let k = 0; k < 30; k++) { const c = await inFrame(h, () => window.__homieTap?.clock?.() ?? null); if (c?.state === 'running') { runningMs = Date.now() - gestureWall; break; } await sleep(100); }
456
+ await inFrame(h, () => window.__homieTap?.start?.());
457
+ const gestureClock = await inFrame(h, () => window.__homieTap?.clock?.() ?? null);
458
+ const runs = [];
459
+ const presses = [];
460
+ const pull = async () => { const r = await inFrame(h, () => window.__homieTap?.take?.() ?? []); if (Array.isArray(r)) runs.push(...r); };
461
+ const until = Date.now() + seconds * 1000;
462
+ const keys = probe?.keys ?? { up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight' };
463
+ const seq = ['right', 'up', 'left', 'down'];
464
+ let i = 0;
465
+ while (Date.now() < until) {
466
+ const c = await inFrame(h, () => window.__homieTap?.clock?.() ?? null);
467
+ // Every fifth press is the game's primary action (game.json playtest.primary, else the space bar), taken with
468
+ // the round's state: a game that ignores the action between rounds owes it no sound there.
469
+ if (i % 5 === 4) {
470
+ const st = await readState(h);
471
+ if (await pressPrimary(h, profile)) { if (c?.now !== undefined) presses.push({ ctx: c.now, action: true, play: activePlay(st), state: describeState(st) }); }
472
+ await sleep(90);
473
+ } else {
474
+ const k = keys[seq[i % 4]];
475
+ if (c?.now !== undefined) presses.push({ ctx: c.now, action: false });
476
+ await h.page.keyboard.down(k).catch(() => {}); await sleep(700); await h.page.keyboard.up(k).catch(() => {});
477
+ }
478
+ i++;
479
+ await sleep(250);
480
+ if (i % 3 === 0) await pull();
481
+ }
482
+ await pull();
483
+ const post = await inFrame(h, () => ({ clock: window.__homieTap?.clock?.() ?? null, sound: window.__homieSound?.stats ?? null }));
484
+ const ctxs = post?.clock?.ctxs ?? 0;
485
+ const rate = post?.clock?.rate ?? 48000;
486
+ if (!ctxs) { row('sound', 'FAIL', { why: 'the game made no Web Audio context: it is silent (or plays only <audio> elements, which this capture cannot hear)', runningMs }); return; }
487
+ if (!runs.length) { row('sound', 'FAIL', { why: `the game's audio never ran after the first click and keys (context ${post?.clock?.state ?? '?'})`, runningMs }); return; }
488
+ // Lay the captured runs onto one timeline by their context frames; gaps stay silent.
489
+ const f0 = Math.min(...runs.map((r) => r.f));
490
+ const f1 = Math.max(...runs.map((r) => r.f + r.frames));
491
+ const L = new Float32Array(f1 - f0); const R = new Float32Array(f1 - f0);
492
+ for (const r of runs) {
493
+ const b = Buffer.from(r.b64, 'base64');
494
+ const s = new Int16Array(b.buffer, b.byteOffset, b.byteLength / 2);
495
+ for (let k = 0; k < r.frames; k++) { L[r.f - f0 + k] = s[2 * k] / 32768; R[r.f - f0 + k] = s[2 * k + 1] / 32768; }
496
+ }
497
+ const wav = join(out, 'sound-capture.wav');
498
+ writeFileSync(wav, wavBytes({ L, R }, { bits: 16, rate }));
499
+ const m = measure(wav, { rate: 48000 });
500
+ let sheet = null; try { sheet = rel(out, sheetPng(wav, join(out, 'sound-capture.png'))); } catch { /* */ }
501
+ // Did presses answer with sound? An onset (10 ms energy 6 dB over the 300 ms before) within 250 ms of a press.
502
+ const env = [];
503
+ const w = Math.round(rate * 0.01);
504
+ for (let k = 0; k + w <= L.length; k += w) { let e = 0; for (let j = k; j < k + w; j++) e += L[j] * L[j] + R[j] * R[j]; env.push(e / w); }
505
+ const t0 = f0 / rate;
506
+ // Movement holds owe no sound; an action (a button, the space bar) does.
507
+ const judge = (list) => {
508
+ let yes = 0; let n = 0;
509
+ for (const p of list) {
510
+ const at = Math.round((p.ctx - t0) / 0.01);
511
+ if (at < 30 || at + 25 >= env.length) continue;
512
+ n++;
513
+ const before = env.slice(at - 30, at).reduce((a, v) => a + v, 0) / 30;
514
+ const after = Math.max(...env.slice(at, at + 25));
515
+ if (after > before * 4 && after > 1e-7) yes++;
516
+ }
517
+ return { yes, n };
518
+ };
519
+ // Only action presses made in active play (or where the game does not say) are owed a sound; the ones that
520
+ // landed on a results screen or a dead body are counted apart, with the state they landed in.
521
+ const act = judge(presses.filter((p) => p.action && p.play !== false));
522
+ const offPlay = presses.filter((p) => p.action && p.play === false);
523
+ const mov = judge(presses.filter((p) => !p.action));
524
+ const answered = act.yes; const judged = act.n;
525
+ const firstLoud = (() => { const floor = 10 ** (-50 / 20); for (let k = 0; k < L.length; k++) if (Math.abs(L[k]) > floor || Math.abs(R[k]) > floor) return k; return -1; })();
526
+ const firstSoundMs = firstLoud < 0 || !gestureClock ? null : Math.max(0, Math.round(((f0 + firstLoud) / rate - (gestureClock.now ?? 0)) * 1000));
527
+ const warn = warnings(m, 'capture');
528
+ const answerShare = judged ? +(answered / judged).toFixed(2) : null;
529
+ if (answerShare !== null && answerShare < 0.5) warn.push(`only ${answered} of ${judged} presses of ${profile.how} made in active play were followed by a sound within 250 ms: give every action a sound`);
530
+ if (m.loudness.lufs !== null && m.loudness.lufs < -32) warn.push(`very quiet: ${m.loudness.lufs} LUFS while playing (a phone at half volume will hear almost nothing)`);
531
+ if (m.silentShare > 0.5) warn.push(`${Math.round(m.silentShare * 100)}% of the time was silent while someone played: no music bed?`);
532
+ const verdict = m.samplePeakDb !== null && m.samplePeakDb < -60 ? 'FAIL' : m.clippedSamples > 0 ? 'FAIL' : warn.length ? 'WARN' : 'PASS';
533
+ row('sound', verdict, {
534
+ why: verdict === 'FAIL' ? (m.clippedSamples > 0 ? `${m.clippedSamples} clipped samples: it distorts` : 'the capture is silent after the first click') : warn.join('; ') || undefined,
535
+ contexts: ctxs, before: pre?.state ?? null, runningAfterGestureMs: runningMs, firstSoundMs, seconds: m.seconds, actionPresses: judged, answered, answerShare, action: profile.how, actionDeclared: profile.declared, actionPressesOutsidePlay: offPlay.length,
536
+ qualifier: [offPlay.length ? `${offPlay.length} action press(es) landed outside active play (${[...new Set(offPlay.map((p) => p.state))].slice(0, 2).join(' | ')}) and were not judged for sound` : null, !judged ? 'no action press was judged for sound in this capture' : null, profile.declared ? null : 'the action pressed was a guess (game.json declares no playtest.primary): a game whose action is another input was not asked for its sound'].filter(Boolean).join('; ') || undefined,
537
+ movesWithSound: `${mov.yes} of ${mov.n}`,
538
+ loudness: m.loudness, samplePeakDb: m.samplePeakDb, clippedSamples: m.clippedSamples, silentShare: m.silentShare, gaps: m.gaps.slice(0, 5), bands: m.bands, under300Share: m.under300Share, onsetsPerSecond: m.onsetsPerSecond,
539
+ soundJs: post?.sound ? { plays: post.sound.plays?.length ?? 0, names: [...new Set((post.sound.plays ?? []).map((p) => p.name))], missing: post.sound.missing, music: post.sound.music, errors: post.sound.errors } : null,
540
+ capture: rel(out, wav), sheet,
541
+ });
542
+ } finally { await close(h); }
543
+ }
544
+
545
+ /** A round with one person playing hard (a computer) and one doing nothing (a phone), both strangers in the public room. */
546
+ async function playSession(base, game, out, roundSeconds, gameJson) {
547
+ const profile = actionProfile(gameJson, 'desk');
548
+ const actions = newActions(profile);
549
+ const active = await launch('desk', `${base}/${game}/play`);
550
+ const idle = await launch('phone', `${base}/${game}/play`);
551
+ try {
552
+ const seat = async (h) => { const until = Date.now() + 45_000; while (Date.now() < until) { const s = await shell(h); if (s?.room && s.seat !== null && s.seat !== undefined && s.role) return s; await sleep(300); } return null; };
553
+ const [sa, si] = await Promise.all([seat(active), seat(idle)]);
554
+ if (!sa || !si) { row('play', 'FAIL', { why: 'a browser never got a seat in 45 s' }); return; }
555
+ if (sa.room !== si.room) { row('play', 'FAIL', { why: `the two browsers landed in different rooms (${sa.room}, ${si.room}): two strangers who press Play must meet` }); return; }
556
+ const since = Date.now();
557
+ // Only a round that STARTS after both are seated counts: a newcomer takes a bot's place mid-round and inherits
558
+ // its body and score, so the round already running says nothing about how these two played.
559
+ let at0 = null;
560
+ for (let k = 0; k < 40 && !at0; k++) { at0 = (await shell(active))?.round ?? null; if (!at0) await sleep(250); }
561
+ const minN = (at0?.n ?? 0) + 1;
562
+ const probe = await probeInfo(active);
563
+ const scores = [];
564
+ let lastLive = null; let liveSeenAt = null; let overAt = null; let result = null;
565
+ const budget = (roundSeconds * 2 + 60) * 1000;
566
+ const playing = (async () => { while (!result && Date.now() - since < budget) await playFor(active, 4, probe, { profile, actions }); })();
567
+ while (!result && Date.now() - since < budget) {
568
+ const [a, b] = await Promise.all([shell(active), shell(idle)]);
569
+ const pa = await inFrame(active, () => window.__homiePort?.info?.().score ?? null);
570
+ const pi = await inFrame(idle, () => window.__homiePort?.info?.().score ?? null);
571
+ if ((a?.round?.n ?? 0) >= minN) scores.push({ t: Math.round((Date.now() - since) / 1000), active: pa, idle: pi });
572
+ const r = a?.round;
573
+ if (r?.phase === 'live' && r.n >= minN) { if (!lastLive || lastLive.n !== r.n) liveSeenAt = Date.now(); lastLive = r; }
574
+ const hit = (a?.results ?? []).find((x) => x.at > since && x.n >= minN && x.results.some((row0) => row0.seat === sa.seat) && x.results.some((row0) => row0.seat === si.seat));
575
+ if (hit) { result = hit; overAt = Date.now(); }
576
+ void b;
577
+ await sleep(1000);
578
+ }
579
+ await playing.catch(() => {});
580
+ const shot = await screenshot(active);
581
+ if (shot) writeFileSync(join(out, 'play-round-over.png'), shot);
582
+ if (!result) { row('play', 'FAIL', { why: `no round finished with both of them in its results within ${Math.round(budget / 1000)} s`, scores: scores.slice(-10) }); return; }
583
+ const rowsR = result.results;
584
+ const me = rowsR.find((x) => x.seat === sa.seat); const them = rowsR.find((x) => x.seat === si.seat);
585
+ const planned = lastLive ? Math.round((lastLive.endsAt - lastLive.startedAt) / 1000) : null;
586
+ const watched = liveSeenAt && overAt ? Math.round((overAt - liveSeenAt) / 1000) : null;
587
+ let changes = 0; let lead = null;
588
+ for (const s of scores) { if (s.active === null || s.idle === null) continue; const l = s.active > s.idle ? 'active' : s.idle > s.active ? 'idle' : lead; if (lead && l !== lead) changes++; lead = l; }
589
+ // What the scores of this one round say, and no more (lib/judge.mjs judgeScores): a cooperative game's shared
590
+ // total is not a ranking, a bot's win is a measured gap, and a conclusion says which inputs the script used.
591
+ const j = judgeScores({ me, them, rows: rowsR, scoring: gameJson?.scoring ?? null, planned, roundSeconds: gameJson?.roundSeconds ?? null, actions });
592
+ const notes = j.notes;
593
+ row('play', notes.length ? 'WARN' : 'PASS', {
594
+ why: notes.join('; ') || undefined, room: sa.room, round: result.n, plannedSeconds: planned, watchedSeconds: watched, players: rowsR.length, bots: rowsR.filter((x) => x.bot).length,
595
+ scoring: j.scoring, comparison: j.comparison, actions: describeActions(actions), primaryDeclared: profile.declared, primaryPresses: actions.primary.pressed, primaryMissed: actions.primary.missed, directionHolds: actions.moves,
596
+ active: me ? { place: me.place, score: me.score } : null, idle: them ? { place: them.place, score: them.score } : null, leadChanges: changes,
597
+ results: rowsR.map((x) => ({ place: x.place, score: x.score, bot: x.bot, who: x.seat === sa.seat ? 'the active player' : x.seat === si.seat ? 'the idle player' : x.bot ? 'a bot' : 'another person' })),
598
+ scoreSamples: scores.filter((_, k) => k % 5 === 0).slice(0, 40), shot: 'play-round-over.png',
599
+ });
600
+ } finally { await close(active); await close(idle); }
601
+ }
602
+
603
+ /** The studio's own checks, run as the creator would: the owner tests (port check) and the two-stranger round. */
604
+ function studioCheck(root, args, name, timeoutMs) {
605
+ return new Promise((ok) => {
606
+ const bin = join(root, 'node_modules', '.bin', 'homie-studio');
607
+ if (!existsSync(bin)) { row(name, 'BLOCKED', { why: '@homie-rocks/studio is not installed in this studio (npm install)' }); ok(null); return; }
608
+ const child = spawn(bin, [...args, '--json'], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
609
+ let outText = '';
610
+ child.stdout.on('data', (d) => { outText += d; });
611
+ child.stderr.on('data', () => {});
612
+ // Bounded all the way down: asked to stop at the timeout, killed 5 s later, and answered 5 s after that even if
613
+ // `close` never comes (a helper that inherited the child's pipes keeps them open after the child is gone).
614
+ let settled = false; let timedOut = false;
615
+ const timers = [];
616
+ const done = () => {
617
+ if (settled) return; settled = true;
618
+ for (const t of timers) clearTimeout(t);
619
+ let j = null; try { j = JSON.parse(outText); } catch { /* */ }
620
+ if (!j) { row(name, 'BLOCKED', { why: timedOut ? `homie-studio ${args.join(' ')} did not finish in ${Math.round(timeoutMs / 1000)} s and was stopped: nothing was measured` : `homie-studio ${args.join(' ')} gave no result`, instrument: timedOut }); ok(null); return; }
621
+ ok(j);
622
+ };
623
+ timers.push(setTimeout(() => { timedOut = true; try { child.kill('SIGTERM'); } catch { /* */ } }, timeoutMs));
624
+ timers.push(setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* */ } }, timeoutMs + 5000));
625
+ timers.push(setTimeout(() => { try { child.stdout.destroy(); child.stderr.destroy(); } catch { /* */ } done(); }, timeoutMs + 10_000));
626
+ child.on('close', done);
627
+ });
628
+ }
629
+
630
+ /* ---------------------------------------------------------------- run */
631
+
632
+ async function run() {
633
+ const game = pos[1];
634
+ const url = String(flags.get('url') ?? '').replace(/\/+$/, '');
635
+ if (!game || !/^https?:\/\//.test(url)) throw new Error('usage: run <game> --url <site> (http://127.0.0.1:8787 from npm run dev, or the live site)');
636
+ const root = findStudio();
637
+ const only = new Set(String(flags.get('only') ?? 'first,look,ui,sound,play,controls,round').split(',').map((s) => s.trim()));
638
+ const seconds = Math.max(8, Math.min(120, Number(flags.get('seconds') ?? 20)));
639
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..*/, '');
640
+ const out = resolve(String(flags.get('out') ?? (root ? join(root, '.playtest', game, stamp) : join(process.cwd(), '.playtest', game, stamp))));
641
+ // First of all, before a folder is made or the disk is looked at: can this process reach the site at all? A name Node cannot resolve (while a browser can) is a network preflight
642
+ // failure of the instrument, BLOCKED, never a game failure (lib/preflight.mjs).
643
+ const pre = await preflight(`${url}/${game}/play`);
644
+ if (!pre.ok) { const e = new Error(pre.why); e.verdict = pre.verdict; e.kind = pre.kind; throw e; }
645
+ mkdirSync(out, { recursive: true });
646
+ if (root) {
647
+ const gi = join(root, '.gitignore');
648
+ const text = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
649
+ if (!/^\.playtest\/?$/m.test(text)) appendFileSync(gi, `${text.endsWith('\n') || !text ? '' : '\n'}.playtest/\n`);
650
+ }
651
+ try { const f = statfsSync(out); if ((f.bavail * f.bsize) / 1e9 < 10) throw new Error('under 10 GB free on this disk: free some space before a playtest'); } catch (e) { if (/GB free/.test(e.message)) throw e; }
652
+ EXE = chromePath();
653
+ puppeteer = loadPuppeteer(root);
654
+ if (!EXE || !puppeteer) throw new Error(!EXE ? 'no Chrome found (set CHROME_PATH to a Chrome or Chromium)' : 'puppeteer-core is not installed (it comes with @homie-rocks/studio: npm install in the studio)');
655
+ const gameJson = root ? readJson(experienceJson(experienceDir(root, game)), {}) : {};
656
+ const isApp = root && experienceJson(experienceDir(root, game)).endsWith('/app.json');
657
+ const started = Date.now();
658
+ const errors = [];
659
+ const bad = [];
660
+ let probed = false;
661
+ const instrumentFaults = [];
662
+ for (const device of ['desk', 'phone', 'phone-landscape']) {
663
+ if (!['first', 'look', 'ui'].some((k) => only.has(k))) break;
664
+ log(`… ${device}: first seconds, the look while playing, the UI`);
665
+ let r;
666
+ try { r = await deviceSession(device, url, game, out, seconds, only, gameJson); } catch (e) {
667
+ // The instrument could not read its own pictures (a stuck or missing decoder). The browser is closed already
668
+ // (deviceSession's finally); every row of this device that was not written yet is BLOCKED, never failed, and
669
+ // the run ends non-zero because it did not measure what it was asked to.
670
+ if (!e?.blocked) throw e;
671
+ instrumentFaults.push(`${device}: ${e.message}`);
672
+ for (const kind of ['first', 'move', 'look', 'ui']) {
673
+ const wanted = kind === 'move' ? only.has('first') : kind === 'ui' ? only.has('ui') && DEVICES[device].isMobile : only.has(kind);
674
+ if (wanted && !rows.some((x) => x.name === `${kind} ${device}`)) row(`${kind} ${device}`, 'BLOCKED', { why: e.message, instrument: true });
675
+ }
676
+ continue;
677
+ }
678
+ errors.push(...r.errors.map((e) => `${device}: ${e}`)); bad.push(...r.bad.map((e) => `${device}: ${e}`)); probed ||= r.probe;
679
+ }
680
+ if (only.has('sound')) { log('… sound: the game\'s own audio while someone plays'); await soundSession(url, game, out, Math.max(15, seconds), gameJson); }
681
+ if (only.has('play') && !isApp) { log('… play: a round with one person playing hard and one doing nothing'); await playSession(url, game, out, Number(gameJson.roundSeconds ?? 90), gameJson); }
682
+ if (only.has('controls') && root && !isApp) {
683
+ log('… controls: the owner tests (homie-studio port check)');
684
+ const pc = await studioCheck(root, ['port', 'check', game, '--url', url, '--only', 'owner-desk,owner-phone,owner-iphone,ui-cover,life,tv,audio,errors', '--shots', join(out, 'port-check')], 'controls', 9 * 60_000);
685
+ if (pc) {
686
+ const failed = (pc.rows ?? []).filter((x) => x.ok === false);
687
+ // A skipped row (no WebKit for the iPhone, say) was not tested: that is never a pass.
688
+ const skipped = (pc.rows ?? []).filter((x) => x.ok === null || x.ok === undefined);
689
+ const verdict = failed.length ? 'FAIL' : !pc.rows?.length ? 'BLOCKED' : skipped.length ? 'WARN' : 'PASS';
690
+ const why = failed.length ? failed.map((x) => `${x.name}: ${x.why ?? 'failed'}`).join('; ') : !pc.rows?.length ? pc.why : skipped.length ? `not tested: ${skipped.map((x) => `${x.name} (${x.why ?? 'skipped'})`).join('; ')}` : undefined;
691
+ // What a row did not check rides with it (the big screen's join QR on a loopback preview is not applicable, and
692
+ // a pass there says nothing about the live QR).
693
+ const unchecked = (pc.rows ?? []).filter((x) => x.qrNote).map((x) => `${x.name}: ${x.qrNote}`);
694
+ row('controls', verdict, { why, qualifier: unchecked.join('; ') || undefined, rows: (pc.rows ?? []).map((x) => ({ name: x.name, ok: x.ok, why: x.why ?? null, ...(x.qrNote ? { note: x.qrNote } : {}) })), receipt: 'port-check/receipt.json' });
695
+ }
696
+ }
697
+ if ((only.has('round') || (isApp && only.has('play'))) && root) {
698
+ log(isApp ? '… sync: a wall and two phones share an action and reconnect' : '… round: two fresh browsers press Play and finish a round together (homie-studio check)');
699
+ const ck = await studioCheck(root, ['check', game, '--url', url, '--shots', join(out, 'round')], 'round', 5 * 60_000);
700
+ if (ck) {
701
+ // A round that finished is not a connection that held: reconnects are said beside completion, and a round
702
+ // completed across a reconnect is WARN (measured, worth a look), not a quiet pass.
703
+ const conn = connectionNote(ck.connection);
704
+ if (isApp) row('sync', ck.ok ? 'PASS' : 'FAIL', { why: ck.why, room: ck.room, surfaces: ck.surfaces, reconnect: ck.reconnect });
705
+ else row('round', !ck.ok ? 'FAIL' : conn.uninterrupted === false ? 'WARN' : 'PASS', { why: !ck.ok ? ck.why : conn.uninterrupted === false ? conn.note : undefined, completed: Boolean(ck.ok), reconnects: conn.reconnects ?? 'not reported', uninterrupted: conn.uninterrupted ?? 'unknown', connection: conn.note, readiness: ck.readiness ?? null, room: ck.room, seats: ck.seats, round: ck.round ? { n: ck.round.n, humans: ck.round.humans, bots: ck.round.bots } : null, seconds: ck.totalMs ? Math.round(ck.totalMs / 1000) : null });
706
+ }
707
+ }
708
+ const uniq = (xs) => [...new Set(xs)].slice(0, 30);
709
+ row('errors', errors.length ? 'FAIL' : bad.length ? 'WARN' : 'PASS', { why: errors.length ? `${errors.length} uncaught error(s) or console errors, first: ${errors[0]}` : bad.length ? `${bad.length} failed request(s), first: ${bad[0]}` : undefined, errors: uniq(errors), requests: uniq(bad) });
710
+ const report = { v: 1, game, url, at: new Date().toISOString(), seconds: Math.round((Date.now() - started) / 1000), probe: probed, rows, weak: weakest(rows) };
711
+ writeFileSync(join(out, 'report.json'), `${JSON.stringify(report, null, 1)}\n`);
712
+ writeFileSync(join(out, 'REPORT.md'), reportMd(report, readJson(join(out, 'review.json'), null)));
713
+ await sheets(out, root);
714
+ return { ok: !rows.some((r) => r.verdict === 'FAIL') && !instrumentFaults.length, ...(instrumentFaults.length ? { blocked: instrumentFaults } : {}), command: 'run', game, out, report: join(out, 'REPORT.md'), seconds: report.seconds, rows: rows.map((r) => `${r.verdict.padEnd(7)} ${r.name}${r.why ? `: ${r.why}` : ''}`), weak: report.weak, review: reviewLine(null).line, next: `node playtest.mjs review ${relative(process.cwd(), out) || '.'} --local (read the pictures and rubric, save VERDICT.json, then record the local review; outside review only when requested)` };
715
+ }
716
+
717
+ /** Contact sheets of a run's pictures, labelled (drawn in Chrome, so no font setup). */
718
+ async function sheets(out, root) {
719
+ const { htmlToPng } = await import('../../video/scripts/lib/browser.mjs');
720
+ const rep = readJson(join(out, 'report.json'), { rows: [] });
721
+ const groups = [];
722
+ for (const device of ['desk', 'phone', 'phone-landscape']) {
723
+ const first = rep.rows.find((r) => r.name === `first ${device}`);
724
+ const look = rep.rows.find((r) => r.name === `look ${device}`);
725
+ const cells = [...(first?.shots ?? []).map((f, i) => ({ file: f, label: `${device} · ${[1, 3, 5, 10][i]} s after opening` })), ...(look?.shots ?? []).map((s, i) => ({ file: s.file, label: `${device} · playing, shot ${i + 1} · brightness ${s.mean} · contrast ${s.sd} · detail ${(s.edges * 100).toFixed(1)}%` }))];
726
+ if (cells.length) groups.push({ name: device, cells, wide: DEVICES[device].width > DEVICES[device].height });
727
+ }
728
+ for (const g of groups) {
729
+ const cols = g.wide ? 3 : 5;
730
+ const cw = g.wide ? 560 : 300;
731
+ const ch = g.wide ? Math.round(cw * (g.name === 'desk' ? 800 / 1280 : 390 / 844)) : Math.round(cw * 844 / 390);
732
+ const imgs = g.cells.filter((c) => c.file && existsSync(join(out, c.file))).map((c) => `<figure><img src="data:image/png;base64,${readFileSync(join(out, c.file)).toString('base64')}"><figcaption>${c.label.replace(/</g, '&lt;')}</figcaption></figure>`);
733
+ if (!imgs.length) continue;
734
+ const rowsN = Math.ceil(imgs.length / cols);
735
+ const W = cols * (cw + 12) + 12; const H = rowsN * (ch + 40) + 12;
736
+ const html = `<!doctype html><meta charset="utf-8"><style>body{margin:0;background:#111;color:#ddd;font:13px/1.3 system-ui,sans-serif}main{display:grid;grid-template-columns:repeat(${cols},${cw}px);gap:12px;padding:12px}figure{margin:0}img{width:${cw}px;height:${ch}px;object-fit:contain;background:#000;display:block}figcaption{padding-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}</style><main>${imgs.join('')}</main>`;
737
+ try { await htmlToPng(root, html, join(out, `sheet-${g.name}.png`), { width: W, height: H }); } catch (e) { log(`sheet ${g.name}: ${e.message}`); }
738
+ }
739
+ }
740
+
741
+ /* ---------------------------------------------------------------- review */
742
+
743
+ /** The pictures a review is given, in the order a reviewer should open them. */
744
+ const REVIEW_PICTURES = ['sheet-desk.png', 'sheet-phone.png', 'sheet-phone-landscape.png', 'desk-first-frame.png', 'phone-ui-on-black.png', 'phone-ui-on-white.png', 'phone-landscape-ui-on-black.png', 'phone-landscape-ui-on-white.png', 'sound-capture.png', 'play-round-over.png'];
745
+
746
+ /**
747
+ * THE REVIEW STEP, AND WHAT IT HANDS OVER. A blind review means giving a game's screenshots to a reviewer outside
748
+ * this session, and a host that reviews what an agent sends (an approval reviewer in front of a second coding agent's
749
+ * command line, say) may refuse that transfer; Homie cannot and must not get round it. So this command says, before
750
+ * anything is sent, exactly which files go and to whom, as ONE question a person answers once per report folder
751
+ * (review-request.json keeps the same list, so an approval can be tied to that exact payload and destination). When
752
+ * the answer is no, or the host refuses anyway, `review --local` is the fallback that sends nothing, and `reviewed`
753
+ * records which review ran, or that none did: a missing review is BLOCKED in the report, never a quiet pass.
754
+ */
755
+ function review() {
756
+ const out = resolve(String(pos[1] ?? ''));
757
+ const rep = readJson(join(out, 'report.json'), null);
758
+ if (!rep) throw new Error('usage: review <run folder> (the folder a run wrote, with report.json)');
759
+ const brief = readFileSync(join(HERE, '..', 'references', 'REVIEWER.md'), 'utf8');
760
+ const pics = REVIEW_PICTURES.filter((f) => existsSync(join(out, f)));
761
+ const numbers = rep.rows.map((r) => `- ${r.verdict} ${r.name}${r.why ? `: ${r.why}` : ''}`).join('\n');
762
+ const text = brief
763
+ .replaceAll('{{GAME}}', rep.game)
764
+ .replaceAll('{{URL}}', `${rep.url}/${rep.game}/play`)
765
+ .replaceAll('{{FOLDER}}', out)
766
+ .replaceAll('{{PICTURES}}', pics.map((p) => `- \`${join(out, p)}\``).join('\n') || '- (none: the run made no pictures)')
767
+ .replaceAll('{{NUMBERS}}', numbers);
768
+ if (flags.has('local')) {
769
+ // The fallback that sends nothing anywhere: this session scores the same pictures with the same rubric. It is
770
+ // the builder grading its own build, and every line of it says so.
771
+ const rubric = text.slice(text.indexOf('## Look at these yourself'));
772
+ const local = [`# Local review (NOT independent): ${rep.game}`, '',
773
+ 'This is the fallback for when the blind review could not run: the reviewer could not be reached, or the person or the host did not approve handing the screenshots to one. Nothing leaves this session.',
774
+ '',
775
+ 'You are the session that made this game, so this is NOT a blind review and must never be reported as one. Score only what the pictures and the numbers below show, not what you know the code does or meant to do. Where you catch yourself explaining a weakness away, write the weakness down instead.',
776
+ '',
777
+ `The game: ${rep.url}/${rep.game}/play. The run folder: \`${out}\`.`, '',
778
+ rubric.trimEnd(), '',
779
+ 'Add `"review": "local"` to the JSON, save it as VERDICT.json in the run folder, then record it. Parts use 0–10; the overall JSON score and --score use 0–100 (seven out of ten means 70):', '',
780
+ ` node playtest.mjs reviewed ${relative(process.cwd(), out) || '.'} --kind local --reason "<why no independent review ran>" --score <overall-0-100>`, '',
781
+ 'When you report to the person, say in the first sentence that this was a local review and not an independent one, and offer the independent review again.', ''].join('\n');
782
+ writeFileSync(join(out, 'REVIEW-LOCAL.md'), local);
783
+ return { ok: true, command: 'review', kind: 'local', brief: join(out, 'REVIEW-LOCAL.md'), pictures: pics.length, sends: 'nothing: the pictures stay in this session', how: 'Read REVIEW-LOCAL.md and every picture it lists yourself, score with its rubric (parts 0–10, overall 0–100), save VERDICT.json (with "review": "local"), then run `reviewed <run folder> --kind local --reason "<why>" --score <overall-0-100>`. This is NOT independent: say so wherever you report it.' };
784
+ }
785
+ writeFileSync(join(out, 'REVIEW.md'), text);
786
+ // Exactly what the review step hands over: the brief, the numbers and these pictures, all from this one folder.
787
+ const files = ['REVIEW.md', 'report.json', ...pics].filter((f) => existsSync(join(out, f))).map((f) => ({ file: f, bytes: statSync(join(out, f)).size }));
788
+ const to = typeof flags.get('to') === 'string' ? flags.get('to') : 'a fresh reviewer that has not seen the code (say which: a subagent inside this session, or an external command such as a second coding agent)';
789
+ const question = [
790
+ `Approve the blind review of "${rep.game}" from the report folder ${out}?`,
791
+ `It hands ${files.length} files from that folder to ${to}:`,
792
+ ...files.map((f) => ` - ${f.file} (${Math.max(1, Math.round(f.bytes / 1024))} KB)`),
793
+ 'They are screenshots of the game (private while it is unreleased), the instruments\' numbers and the review brief. The reviewer is told the folder and may open the other screenshots in it; nothing outside that folder is sent, and the reviewer also plays the game at the address in the brief.',
794
+ 'One yes covers this report folder and this reviewer. Another folder or another reviewer is a new question.',
795
+ 'Say "local" instead and nothing leaves this session: the session scores the same pictures itself with the same rubric, and the report says that review was not independent.',
796
+ ].join('\n');
797
+ writeFileSync(join(out, 'review-request.json'), `${JSON.stringify({ v: 1, game: rep.game, folder: out, destination: to, files, question, at: new Date().toISOString() }, null, 1)}\n`);
798
+ return {
799
+ ok: true, command: 'review', brief: join(out, 'REVIEW.md'), pictures: pics.length,
800
+ sends: files.map((f) => f.file), folder: out, destination: to,
801
+ approval: question,
802
+ how: 'FIRST ask the person the approval question above, once, word for word (it names every file and the destination; pass --to "<the reviewer>" to name it). After a yes: give the WHOLE TEXT of REVIEW.md, as it is, to a FRESH reviewer that has not seen the code, the plan or your summary (Claude Code: the Agent tool, with the file\'s full contents as the prompt, not its path or a summary of it; Codex: a new session). Add nothing about the code or what you changed. It plays the game itself and saves VERDICT.json in the run folder; then run `reviewed <run folder> --kind independent --by "<the reviewer>"`.',
803
+ ifRefused: 'If the person says no, or the host refuses the transfer (an approval reviewer rejecting the command is the host\'s decision: do not retry it in other words, and do not send the pictures another way): run `review <run folder> --local` for a review that sends nothing, or record that none ran with `reviewed <run folder> --kind none --reason "<why>"`. Either way the report then says which review this run had.',
804
+ };
805
+ }
806
+
807
+ /** `reviewed <run folder> --kind ...`: write review.json and put the review's line into REPORT.md. */
808
+ function reviewed() {
809
+ const out = resolve(String(pos[1] ?? ''));
810
+ const rep = readJson(join(out, 'report.json'), null);
811
+ const kind = String(flags.get('kind') ?? '');
812
+ if (!rep || !REVIEW_KINDS.includes(kind)) throw new Error(`usage: reviewed <run folder> --kind ${REVIEW_KINDS.join('|')} [--by "<who>"] [--reason "<why>"] [--score <0-100>]`);
813
+ const text = (k) => (typeof flags.get(k) === 'string' ? flags.get(k).slice(0, 300) : null);
814
+ if (kind !== 'independent' && !text('reason')) throw new Error(`--reason "<why>" is needed with --kind ${kind}: a report that says no independent review ran must say why`);
815
+ const verdict = readJson(join(out, 'VERDICT.json'), null);
816
+ if (kind !== 'none' && !verdict) throw new Error(`no VERDICT.json in ${out}: a review that ran saves its verdict there first (for a review that did not run: --kind none --reason "<why>")`);
817
+ const score = Number.isFinite(Number(flags.get('score'))) && flags.get('score') !== true ? Number(flags.get('score')) : Number.isFinite(verdict?.score) ? verdict.score : null;
818
+ const record = { v: 1, kind, by: text('by'), reason: text('reason'), score: kind === 'none' ? null : score, at: new Date().toISOString() };
819
+ writeFileSync(join(out, 'review.json'), `${JSON.stringify(record, null, 1)}\n`);
820
+ writeFileSync(join(out, 'REPORT.md'), reportMd(rep, record));
821
+ const line = reviewLine(record);
822
+ return { ok: true, command: 'reviewed', kind, review: line.line, report: join(out, 'REPORT.md') };
823
+ }
824
+
825
+ function showReport() {
826
+ const out = resolve(String(pos[1] ?? ''));
827
+ const rep = readJson(join(out, 'report.json'), null);
828
+ if (!rep) throw new Error('usage: report <run folder>');
829
+ // The review is part of what a report says: which kind ran, or BLOCKED when none did.
830
+ const line = reviewLine(readJson(join(out, 'review.json'), null));
831
+ return { ok: !rep.rows.some((r) => r.verdict === 'FAIL'), command: 'report', game: rep.game, rows: rep.rows.map((r) => `${r.verdict.padEnd(7)} ${r.name}${r.why ? `: ${r.why}` : ''}`), weak: rep.weak, review: line.line, reviewKind: line.kind, report: join(out, 'REPORT.md') };
832
+ }
833
+
834
+ const rel = (out, f) => (f ? relative(out, f) : null);
835
+
836
+ async function main() {
837
+ const cmd = pos[0];
838
+ if (!cmd || cmd === 'help' || flags.has('help')) {
839
+ const head = readFileSync(fileURLToPath(import.meta.url), 'utf8').split('*/')[0];
840
+ return { ok: true, command: 'help', usage: head.replace(/^#!.*\n\/\*\*?/, '').replace(/^ \* ?/gm, '').trim() };
841
+ }
842
+ if (cmd === 'run') return run();
843
+ if (cmd === 'review') return review();
844
+ if (cmd === 'reviewed') return reviewed();
845
+ if (cmd === 'report') return showReport();
846
+ return { ok: false, command: cmd, why: `unknown command "${cmd}" (playtest.mjs help)` };
847
+ }
848
+
849
+ const print = (o) => {
850
+ if (JSON_OUT) { process.stdout.write(`${JSON.stringify(o, null, 2)}\n`); return; }
851
+ if (o.ok === false && o.why) { process.stdout.write(`playtest: ${o.why}\n`); return; }
852
+ const lines = [];
853
+ for (const [k, v] of Object.entries(o)) if (k !== 'ok' && k !== 'command') lines.push(Array.isArray(v) ? `${k}:\n ${v.join('\n ')}` : `${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`);
854
+ process.stdout.write(`${lines.join('\n')}\n`);
855
+ };
856
+
857
+ try {
858
+ const r = await main();
859
+ if (r) { print(r); if (r.ok === false) process.exitCode = 1; }
860
+ } catch (error) {
861
+ const msg = error instanceof Error ? error.message : String(error);
862
+ // A preflight that could not reach the site measured nothing: BLOCKED, said as such, and still a non-zero exit.
863
+ const blocked = error?.verdict === 'BLOCKED';
864
+ print({ ok: false, ...(blocked ? { verdict: 'BLOCKED', kind: error.kind } : {}), why: /ERR_CONNECTION_REFUSED|ECONNREFUSED/.test(msg) && !error?.verdict ? `the site stopped answering during the playtest (${msg}): is the dev server still running? Restart it as a background task (a rebuild while \`npm run dev\` runs can kill it) and run again` : `${blocked ? 'BLOCKED ' : ''}${msg}` });
865
+ process.exitCode = 1;
866
+ }
867
+ // The report is written and printed: close whatever browser is still open (bounded), then leave. A controller once
868
+ // stayed alive after its report with every browser and decoder it owned already gone; whatever handle held it was
869
+ // never isolated, so the end does not wait for the event loop to empty (lib/exit.mjs). The exit code set above stays.
870
+ await finish({ cleanup: async () => { for (const h of [...open]) await close(h); } });