@homie-rocks/studio 0.6.0 → 0.8.0
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/README.md +45 -1
- package/bin/homie-studio.mjs +163 -7
- package/lib/build.mjs +38 -12
- package/lib/check.mjs +50 -4
- package/lib/look.mjs +101 -0
- package/lib/markdown.mjs +121 -0
- package/lib/port-check.mjs +26 -4
- package/lib/progress.mjs +420 -0
- package/lib/scaffold.mjs +68 -5
- package/lib/site.mjs +437 -0
- package/lib/studio.mjs +1 -1
- package/package.json +3 -2
- package/site/SITE.md +128 -0
- package/worker/index.mjs +297 -143
- package/worker/pages.mjs +149 -212
- package/worker/room.mjs +28 -1
- package/worker/site.mjs +1052 -0
- package/worker/stats.mjs +8 -5
- package/worker/version.mjs +2 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Most people never run this by hand: the Homie plugin for Claude Code and Codex d
|
|
|
10
10
|
and the person approves Cloudflare once in their browser.
|
|
11
11
|
|
|
12
12
|
```sh
|
|
13
|
-
npx -y --package=https://homie.rocks/npm/homie-studio-0.
|
|
13
|
+
npx -y --package=https://homie.rocks/npm/homie-studio-0.8.0.tgz homie-studio new ./night-owls --name "Night Owls"
|
|
14
14
|
cd night-owls && npm install
|
|
15
15
|
npx homie-studio game new crown-thief --from gem-rush --name "Crown Thief"
|
|
16
16
|
npx homie-studio dev # the whole site locally
|
|
@@ -21,6 +21,21 @@ npx homie-studio publish # the homie.rocks directo
|
|
|
21
21
|
npx homie-studio stats # the studio's own numbers, for its owner
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
## The site: the hub's shape, and a landing for every game
|
|
25
|
+
|
|
26
|
+
Every studio site has the same sections as homie.rocks, in the studio's own look (`site/theme.json`): Home (the
|
|
27
|
+
featured game, live rooms, the latest posts), Games, Music, Videos, Rooms (every public room playing now,
|
|
28
|
+
joinable) and Posts (`posts/*.md`, with Atom and JSON feeds). A section with nothing in it has no tab, and its
|
|
29
|
+
page answers 404. Every game gets a landing at `/<game>/`: a full-bleed hero from its own footage or art, the
|
|
30
|
+
pitch, a big Play button into a public room, how to play on a phone, a computer and a TV (with the join code),
|
|
31
|
+
live rooms, credits (the original and its licence for a port), and "Make a game like this". Every page ends with
|
|
32
|
+
"Made with Homie". Anything the studio puts in `site/` wins: a whole page, a partial, its tokens, its CSS, its
|
|
33
|
+
files. `site/SITE.md` says all of it.
|
|
34
|
+
|
|
35
|
+
The play page writes its room into the address, and a small room button at the edge shares it: Invite, Big
|
|
36
|
+
screen and the room code. Every HTML answer is `no-transform`, and the site's pages are never framed by another
|
|
37
|
+
site.
|
|
38
|
+
|
|
24
39
|
## Rooms of up to 32
|
|
25
40
|
|
|
26
41
|
Every room of a game has the seats its netplay manifest names: game.json's `netplay`
|
|
@@ -49,6 +64,34 @@ npx homie-studio stats share on # tell the directory "pl
|
|
|
49
64
|
Each key is minted on the owner's computer; only its SHA-256 goes into D1, through the
|
|
50
65
|
studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
|
|
51
66
|
|
|
67
|
+
## A build the person can watch (the progress feed)
|
|
68
|
+
|
|
69
|
+
A build of a game, song or video can keep a small progress feed, so the person follows it
|
|
70
|
+
from wherever they are (the Claude app on a phone, through the Homie MCP's build progress
|
|
71
|
+
card), sees each check go green, what it spent against its budget, and can press Stop:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
npx homie-studio progress start crown-thief --share --title "Crown Thief: faster rounds" --budget 2
|
|
75
|
+
npx homie-studio progress stage plan done --note "Rounds from 60 s to 45 s"
|
|
76
|
+
npm run build && npx homie-studio check crown-thief --url http://127.0.0.1:8787 && npm run deploy
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
While a feed is open, `build`, `check`, `port check` and `deploy` report into it by
|
|
80
|
+
themselves: their stage (a game: plan → build → checks → deploy), each check as it runs
|
|
81
|
+
and passes, a small picture of the game and the address to play it; a deploy with every
|
|
82
|
+
check green ends the build. The AI marks what only it knows with `progress stage`,
|
|
83
|
+
`progress check`, `progress spend`, `progress shot` (a video's shots) and `progress song`
|
|
84
|
+
(a song's waveform and lyric check). Before each stage starts, and every few seconds while
|
|
85
|
+
one runs, a shared feed asks whether Stop was pressed; a stopped command closes its
|
|
86
|
+
browsers and says so, and nothing already built or deployed is undone.
|
|
87
|
+
|
|
88
|
+
The feed is `.studio/progress/<build>.json` (git-ignored). With `--share` the studio's
|
|
89
|
+
directory (studio.json `homie.directory`, homie.rocks by default) keeps a copy for 24
|
|
90
|
+
hours so the Claude app can show it: the feed only (plain bounded text, pictures under
|
|
91
|
+
96 KB), never a key, a path or code. Its write key stays in `.studio/progress/` and is
|
|
92
|
+
never printed; the shared id is printed for the MCP tool `build_progress`. Without an open
|
|
93
|
+
feed every command behaves exactly as before. `lib/progress.mjs` has the whole format.
|
|
94
|
+
|
|
52
95
|
## The site's address
|
|
53
96
|
|
|
54
97
|
`deploy` keeps the `workers.dev` address in `.studio/local.json`, which git ignores: it
|
|
@@ -97,6 +140,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
|
|
|
97
140
|
| `lib/port-check.mjs` | `port check`: held and alternating directions on keys, Android Chrome and iPhone WebKit touch, UI cover, two browsers finishing a round, a killed host, a late joiner, the big screen. |
|
|
98
141
|
| `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): `createRoom`, the touch kit, keys, camera rules, bots, a HUD, sandbox shims, first-touch audio, `exposePort`. |
|
|
99
142
|
| `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site or from storage at `/media/<key>`). `seats.mjs`: room sizes (up to 32). |
|
|
143
|
+
| `worker/site.mjs`, `lib/site.mjs`, `lib/markdown.mjs`, `site/SITE.md` | The site: its sections, each game's landing, posts and their feeds, the look (theme tokens) and what the studio's `site/` folder overrides; the safe markdown posts are written in. `worker/pages.mjs`: the play page. |
|
|
100
144
|
| `worker/stats.mjs`, `worker/stats-page.mjs`, `lib/stats.mjs` | The studio's own stats: what is counted and how, the owner-only `/api/stats` and `/_studio/stats`, and `homie-studio stats`. |
|
|
101
145
|
| `lib/media.mjs`, `media/MEDIA.md` | The `music/` and `videos/` manifests: which entries get a page, and where each file's bytes come from (the site itself up to 25 MiB a file, the studio's storage, or a link). `media list` shows it; `media put` uploads a file to storage and records its key. |
|
|
102
146
|
| `netplay/` | The netplay contract (`NETPLAY.md`) and its game helper (`@homie-rocks/studio/netplay`). |
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* homie-studio build [<id>]
|
|
13
13
|
* homie-studio dev [--port 8787] (--stop: stop exactly this studio's dev server, nothing else)
|
|
14
14
|
* homie-studio check <id> [--url <site>] [--shots <dir>]
|
|
15
|
+
* homie-studio look [<path>...] [--url <site>] [--shots <dir>] [--only computer,phone,sideways]
|
|
16
|
+
* (the site's pages on a computer and a phone, as pictures, with what is wrong)
|
|
15
17
|
* homie-studio port plan <game folder>
|
|
16
18
|
* homie-studio port import <game folder> --id <id> [--name "<Name>"] [--mode static|bundle|command]
|
|
17
19
|
* homie-studio port check <id> [--url <site>] [--only owner-desk,owner-phone,owner-iphone,round,life,tv] [--shots <dir>]
|
|
@@ -27,6 +29,24 @@
|
|
|
27
29
|
* homie-studio stats revoke (every stats key and page session ends)
|
|
28
30
|
* homie-studio stats share on|off (tell the homie.rocks directory "played this week", or stop)
|
|
29
31
|
*
|
|
32
|
+
* homie-studio progress start [<id>] [--what game|song|video] [--title "<what this build does>"]
|
|
33
|
+
* [--budget <dollars>] [--unit usd|credits] [--share]
|
|
34
|
+
* (a progress feed for one build; --share shows it in the Claude app
|
|
35
|
+
* through the Homie MCP's build_progress widget, which can press Stop)
|
|
36
|
+
* homie-studio progress stage <stage> running|done|failed|skipped [--note "<one line>"]
|
|
37
|
+
* homie-studio progress check <id> pending|running|pass|fail|skip [--label "<words>"] [--note "<one line>"]
|
|
38
|
+
* homie-studio progress preview [--url <address>] [--image <small .jpg/.png>] [--caption "<words>"]
|
|
39
|
+
* homie-studio progress spend <amount> --what "<what it bought>" [--receipt <file>]
|
|
40
|
+
* homie-studio progress shot <id> pending|running|pass|fail [--label "<words>"] [--image <small .jpg>]
|
|
41
|
+
* homie-studio progress song [--peaks <peaks.json>] [--lyric "<line>" --sung yes|no]
|
|
42
|
+
* homie-studio progress log "<one line>"
|
|
43
|
+
* homie-studio progress stop (ask the running build to stop at its next safe point)
|
|
44
|
+
* homie-studio progress end passed|failed|stopped [--note "<one line>"]
|
|
45
|
+
* homie-studio progress show [<build>]
|
|
46
|
+
* While a feed is open, build, check, port check and deploy report
|
|
47
|
+
* into it (stage, each check going green, a preview picture) and stop
|
|
48
|
+
* when asked. Without one, nothing changes.
|
|
49
|
+
*
|
|
30
50
|
* Every command prints a few lines for a person; --json prints the result.
|
|
31
51
|
*/
|
|
32
52
|
import { spawn, spawnSync } from 'node:child_process';
|
|
@@ -36,6 +56,7 @@ import { build } from '../lib/build.mjs';
|
|
|
36
56
|
import { check } from '../lib/check.mjs';
|
|
37
57
|
import { deploy, deployPlan, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
|
|
38
58
|
import { publish } from '../lib/directory.mjs';
|
|
59
|
+
import { look } from '../lib/look.mjs';
|
|
39
60
|
import { importPort, planPort } from '../lib/port.mjs';
|
|
40
61
|
import { portCheck } from '../lib/port-check.mjs';
|
|
41
62
|
import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
|
|
@@ -43,10 +64,11 @@ import { ensureStatsMigration, newStudio } from '../lib/scaffold.mjs';
|
|
|
43
64
|
import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters } from '../lib/studio.mjs';
|
|
44
65
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
45
66
|
import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
|
|
67
|
+
import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, startProgress } from '../lib/progress.mjs';
|
|
46
68
|
|
|
47
69
|
const argv = process.argv.slice(2);
|
|
48
70
|
const flags = new Map();
|
|
49
|
-
const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop'];
|
|
71
|
+
const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share'];
|
|
50
72
|
const positional = [];
|
|
51
73
|
for (let i = 0; i < argv.length; i++) {
|
|
52
74
|
const a = argv[i];
|
|
@@ -62,7 +84,7 @@ const log = asJson ? () => {} : (line) => process.stderr.write(`${line}\n`);
|
|
|
62
84
|
|
|
63
85
|
function print(result) {
|
|
64
86
|
if (asJson) { process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; }
|
|
65
|
-
if (result.ok === false && result.command !== 'port check') { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
|
|
87
|
+
if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
|
|
66
88
|
if (result.ok === false && result.command === 'port check' && !result.rows) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
|
|
67
89
|
const lines = [];
|
|
68
90
|
switch (result.command) {
|
|
@@ -81,7 +103,12 @@ function print(result) {
|
|
|
81
103
|
case 'build':
|
|
82
104
|
lines.push(`Built ${result.games.map((g) => `${g.id} (${Math.round(g.bytes / 1024)} KB)`).join(', ') || 'no games'} into ${relative(process.cwd(), result.dist) || result.dist}`);
|
|
83
105
|
if (result.songs?.length || result.videos?.length) lines.push(`Media pages: ${[...result.songs.map((x) => `/music/${x}/`), ...result.videos.map((x) => `/videos/${x}/`)].join(', ')}`);
|
|
106
|
+
for (const l of result.landings ?? []) lines.push(`Landing /${l.id}/: hero from ${l.hero === 'footage' ? 'its footage' : l.hero === 'art' ? 'its art (moving)' : 'the studio\'s colours (add a cover or hero/ footage)'}${l.credits ? ', credits' : ''}${l.source ? ', "Make a game like this" with its source' : ''}`);
|
|
107
|
+
if (result.posts?.length) lines.push(`Posts: ${result.posts.map((x) => `/posts/${x}/`).join(', ')} (feeds: /posts/feed.xml, /posts/feed.json)`);
|
|
108
|
+
if (result.pages?.length || result.partials?.length) lines.push(`The studio's own: ${[...(result.pages ?? []).map((x) => `page ${x}`), ...(result.partials ?? []).map((x) => `partial ${x}`)].join(', ')}`);
|
|
84
109
|
for (const m of result.mediaSkipped ?? []) lines.push(` left out: ${m.kind}/${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
|
|
110
|
+
for (const m of result.postsSkipped ?? []) lines.push(` left out: posts/${m.post}: ${m.why}`);
|
|
111
|
+
for (const m of result.siteSkipped ?? []) lines.push(` left out: ${m.what}: ${m.why}`);
|
|
85
112
|
break;
|
|
86
113
|
case 'media list':
|
|
87
114
|
for (const kind of ['music', 'videos']) {
|
|
@@ -147,6 +174,30 @@ function print(result) {
|
|
|
147
174
|
case 'stats share':
|
|
148
175
|
lines.push(result.message);
|
|
149
176
|
break;
|
|
177
|
+
case 'progress start':
|
|
178
|
+
lines.push(`Build ${result.build}: ${result.title} (${result.stages.join(' → ')})${result.budget !== null ? `, budget ${result.unit === 'usd' ? `$${result.budget.toFixed(2)}` : `${result.budget} credits`}` : ''}`,
|
|
179
|
+
` feed: ${result.file}`,
|
|
180
|
+
...(result.shared ? [` shared for the Claude app until ${result.shared.expiresAt ?? 'tomorrow'}: ${result.widget}`] : []),
|
|
181
|
+
...(result.sharedWhy ? [` ${result.sharedWhy}`] : []),
|
|
182
|
+
' build, check, port check and deploy now report into it; mark the plan done with: npx --no-install homie-studio progress stage plan done --note "<the plan in one line>"');
|
|
183
|
+
break;
|
|
184
|
+
case 'progress':
|
|
185
|
+
lines.push(result.message ?? `${result.feed?.title}: ${result.feed?.state}${result.feed?.stage ? ` (${result.feed.stage})` : ''}`);
|
|
186
|
+
break;
|
|
187
|
+
case 'progress show': {
|
|
188
|
+
const f = result.feed;
|
|
189
|
+
const mark = { done: 'ok ', pass: 'ok ', running: '... ', failed: 'FAIL', fail: 'FAIL', skipped: 'skip', skip: 'skip', stopped: 'stop', pending: ' ' };
|
|
190
|
+
lines.push(`${f.title} [${f.state}] build ${f.build}${f.shared ? ` (shared as ${f.shared.build})` : ''}`,
|
|
191
|
+
...f.stages.map((s) => ` ${mark[s.state] ?? s.state} ${s.label}${s.note ? `: ${s.note}` : ''}`),
|
|
192
|
+
...f.checks.map((c) => ` ${mark[c.state] ?? c.state} ${c.label}${c.note ? `: ${c.note}` : ''}`),
|
|
193
|
+
...(f.preview?.url ? [` preview: ${f.preview.url}`] : []),
|
|
194
|
+
` spent ${f.spend.unit === 'usd' ? `$${f.spend.used.toFixed(2)}` : `${f.spend.used} credits`}${f.spend.budget !== null ? ` of ${f.spend.unit === 'usd' ? `$${f.spend.budget.toFixed(2)}` : `${f.spend.budget} credits`}` : ''}`,
|
|
195
|
+
...(f.stop?.requested ? [` stop asked (${f.stop.by}) at ${f.stop.at}`] : []));
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
case 'look':
|
|
199
|
+
lines.push(`${result.ok ? 'Looks right' : 'Look again'}: ${result.rows.length} views of ${result.url}`, ...result.rows.map((r) => ` ${r.ok ? 'ok ' : 'FIX '} ${r.path} on ${({ computer: 'a computer', phone: 'a phone', sideways: 'a phone turned sideways' })[r.device] ?? r.device}${r.problems.length ? `: ${r.problems.join('; ')}` : ''}`), '', `Pictures (open them and look): ${result.shots}`);
|
|
200
|
+
break;
|
|
150
201
|
case 'check':
|
|
151
202
|
lines.push(`PASS: two fresh browsers in room ${result.room} finished round ${result.round.n} (${result.round.humans} humans, ${result.round.bots} bots) in ${Math.round(result.totalMs / 1000)} s.`,
|
|
152
203
|
...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`));
|
|
@@ -170,17 +221,18 @@ async function main() {
|
|
|
170
221
|
|
|
171
222
|
if (cmd === 'port' && sub === 'plan') return planPort(positional[2] ?? '.');
|
|
172
223
|
const root = requireStudio();
|
|
224
|
+
if (cmd === 'progress') return progressCommand(root, sub);
|
|
173
225
|
if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
|
|
174
226
|
if (cmd === 'port' && sub === 'check') {
|
|
175
227
|
const game = positional[2] ?? listGames(root)[0]?.id;
|
|
176
228
|
const url = flags.get('url') ?? siteUrl(root);
|
|
177
229
|
if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address or the live site)' };
|
|
178
|
-
return portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
|
|
230
|
+
return tracked(root, 'checks', (report) => portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'port check');
|
|
179
231
|
}
|
|
180
232
|
if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
|
|
181
233
|
if (cmd === 'game' && sub === 'remix') return remixGame(root, positional[2], flags.get('id'), { name: flags.get('name') });
|
|
182
234
|
if (cmd === 'games') return { ok: true, command: 'games', games: listGames(root).map(({ dir, ...g }) => ({ ...g, dir: relative(root, dir) })) };
|
|
183
|
-
if (cmd === 'build') return build(root, { only: positional[1] ?? null, log });
|
|
235
|
+
if (cmd === 'build') return tracked(root, 'build', () => build(root, { only: positional[1] ?? null, log }), 'build');
|
|
184
236
|
if (cmd === 'status') {
|
|
185
237
|
const studio = readStudio(root);
|
|
186
238
|
return { ok: true, command: 'status', root, studio, site: siteUrl(root, studio), games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
|
|
@@ -191,10 +243,23 @@ async function main() {
|
|
|
191
243
|
const game = positional[1] ?? listGames(root)[0]?.id;
|
|
192
244
|
const url = flags.get('url') ?? siteUrl(root);
|
|
193
245
|
if (!url) return { ok: false, command: 'check', why: 'give --url (the local dev address or the live site)' };
|
|
194
|
-
return check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
|
|
246
|
+
return tracked(root, 'checks', (report) => check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'check');
|
|
247
|
+
}
|
|
248
|
+
if (cmd === 'look') {
|
|
249
|
+
const url = flags.get('url') ?? siteUrl(root);
|
|
250
|
+
if (!url) return { ok: false, command: 'look', why: 'give --url (the local dev address or the live site)' };
|
|
251
|
+
let paths = positional.slice(1).map((p) => (p.startsWith('/') ? p : `/${p}`));
|
|
252
|
+
if (!paths.length) {
|
|
253
|
+
let cat = null;
|
|
254
|
+
try { cat = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')); } catch { /* not built */ }
|
|
255
|
+
const games = cat?.games?.map((g) => g.id) ?? listGames(root).map((g) => g.id);
|
|
256
|
+
paths = ['/', ...games.map((id) => `/${id}/`), ...(games.length ? ['/games/', '/rooms/'] : []), ...(cat?.posts?.length ? ['/posts/', `/posts/${cat.posts[0].slug}/`] : [])];
|
|
257
|
+
}
|
|
258
|
+
const only = flags.get('only') ? String(flags.get('only')).split(',').filter((d) => ['computer', 'phone', 'sideways'].includes(d)) : undefined;
|
|
259
|
+
return look({ url, paths, shots: flags.get('shots') ? resolve(flags.get('shots')) : join(root, '.studio', 'look'), devices: only, log });
|
|
195
260
|
}
|
|
196
261
|
if (cmd === 'deploy' && flags.has('plan')) return deployPlan(root);
|
|
197
|
-
if (cmd === 'deploy') return deploy(root, { log, homie: flags.get('homie') });
|
|
262
|
+
if (cmd === 'deploy') return tracked(root, 'deploy', () => deploy(root, { log, homie: flags.get('homie') }), 'deploy');
|
|
198
263
|
if (cmd === 'storage' && sub === 'add') return storageAdd(root, { log });
|
|
199
264
|
if (cmd === 'storage') {
|
|
200
265
|
const cf = readStudio(root).cloudflare ?? {};
|
|
@@ -217,6 +282,94 @@ async function main() {
|
|
|
217
282
|
return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
|
|
218
283
|
}
|
|
219
284
|
|
|
285
|
+
/*
|
|
286
|
+
* THE PROGRESS FEED (lib/progress.mjs). A command that is a stage of the open build (build, check, port check,
|
|
287
|
+
* deploy) says so: the stage runs, then is done or failed with one line; a check's steps go green as they pass; a
|
|
288
|
+
* stop asked in the Claude app (or by `progress stop`) ends it at the next safe point. With no open feed, or a
|
|
289
|
+
* feed whose build has no such stage (a song has no deploy), the command runs exactly as it did before.
|
|
290
|
+
*/
|
|
291
|
+
async function tracked(root, stage, run, command) {
|
|
292
|
+
const feed = currentFeed(root);
|
|
293
|
+
if (!feed || !feed.doc.stages.some((s) => s.id === stage)) return run(null);
|
|
294
|
+
await feed.sync();
|
|
295
|
+
if (feed.stopRequested()) {
|
|
296
|
+
feed.stage(stage, 'stopped', 'stopped by the person before it started');
|
|
297
|
+
feed.end('stopped', 'Stopped by the person.');
|
|
298
|
+
return { ok: false, command, stopped: true, why: 'stopped by the person (in the Claude app, or `progress stop`): the build is over; ask them before starting again' };
|
|
299
|
+
}
|
|
300
|
+
if (stage === 'checks') feed.resetChecks('checks');
|
|
301
|
+
feed.stage(stage, 'running');
|
|
302
|
+
const report = {
|
|
303
|
+
check: (id, state, opts) => feed.check(id, state, { ...opts, stage }),
|
|
304
|
+
preview: (p) => feed.preview(p),
|
|
305
|
+
stopped: () => feed.stopRequested(),
|
|
306
|
+
state: (id) => feed.doc?.checks.find((c) => c.id === id)?.state,
|
|
307
|
+
};
|
|
308
|
+
let result;
|
|
309
|
+
try { result = await run(report); } catch (error) { feed.stage(stage, 'failed', error instanceof Error ? error.message : String(error)); throw error; }
|
|
310
|
+
if (result?.stopped || feed.stopRequested()) {
|
|
311
|
+
feed.stage(stage, 'stopped', 'stopped by the person');
|
|
312
|
+
feed.end('stopped', 'Stopped by the person.');
|
|
313
|
+
return { ...result, ok: false, stopped: true, why: result?.why ?? 'stopped by the person' };
|
|
314
|
+
}
|
|
315
|
+
if (!result?.ok) { feed.stage(stage, 'failed', result?.why ?? 'failed'); return result; }
|
|
316
|
+
const note = stage === 'build' ? `Built ${(result.games ?? []).map((g) => `${g.id} (${Math.round(g.bytes / 1024)} KB)`).join(', ') || 'the site'}`
|
|
317
|
+
: stage === 'deploy' ? `Live: ${result.url ?? 'deployed'}`
|
|
318
|
+
: result.command === 'port check' ? `${result.passed.length} passed${result.skipped.length ? `, ${result.skipped.length} skipped` : ''} in ${Math.round(result.totalMs / 1000)} s`
|
|
319
|
+
: `Room ${result.room}: round ${result.round?.n} finished with ${result.round?.humans} people in ${Math.round(result.totalMs / 1000)} s`;
|
|
320
|
+
feed.stage(stage, 'done', note);
|
|
321
|
+
if (stage === 'deploy') {
|
|
322
|
+
const doc = feed.doc;
|
|
323
|
+
const game = (result.games ?? []).find((g) => g.id === doc.id) ?? (result.games ?? [])[0];
|
|
324
|
+
if (game?.play) feed.preview({ url: game.play });
|
|
325
|
+
// Deployed, and every check that ran passed: the build is done.
|
|
326
|
+
if (doc.checks.every((c) => ['pass', 'skip'].includes(c.state))) feed.end('passed', `Live: ${game?.play ?? result.url}`);
|
|
327
|
+
}
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function progressCommand(root, sub) {
|
|
332
|
+
const arg = positional[2];
|
|
333
|
+
if (sub === 'start') {
|
|
334
|
+
return startProgress(root, {
|
|
335
|
+
what: flags.get('what') ?? 'game', id: arg ?? flags.get('id') ?? null, title: flags.get('title'),
|
|
336
|
+
budget: flags.get('budget'), unit: flags.get('unit'), share: flags.has('share'), directory: flags.get('homie'),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
if (sub === 'show') {
|
|
340
|
+
const id = arg ?? currentId(root) ?? flags.get('build');
|
|
341
|
+
const doc = readFeed(root, id);
|
|
342
|
+
return doc ? { ok: true, command: 'progress show', feed: publicFeed(doc) } : { ok: false, command: 'progress show', why: id ? `no feed ${id} in .studio/progress/` : 'no build is open (homie-studio progress start)' };
|
|
343
|
+
}
|
|
344
|
+
const id = flags.get('build') ?? currentId(root);
|
|
345
|
+
if (!id || !readFeed(root, id)) return { ok: false, command: 'progress', why: 'no build is open: start one with `homie-studio progress start <id> --share`' };
|
|
346
|
+
const feed = new Feed(root, id);
|
|
347
|
+
const done = (message) => ({ ok: true, command: 'progress', build: id, message, feed: publicFeed(feed.doc) });
|
|
348
|
+
const note = flags.has('note') ? String(flags.get('note')) : undefined;
|
|
349
|
+
try {
|
|
350
|
+
switch (sub) {
|
|
351
|
+
case 'stage': feed.stage(arg, positional[3], note); return done(`${arg}: ${positional[3]}`);
|
|
352
|
+
case 'check': feed.check(arg, positional[3], { label: flags.get('label'), note }); return done(`check ${arg}: ${positional[3]}`);
|
|
353
|
+
case 'preview': feed.preview({ url: flags.get('url'), image: flags.get('image') ? resolve(String(flags.get('image'))) : undefined, caption: flags.get('caption') }); return done('preview updated');
|
|
354
|
+
case 'spend': feed.spend(arg, flags.get('what'), { receipt: flags.get('receipt'), unit: flags.get('unit') }); {
|
|
355
|
+
const s = feed.doc.spend;
|
|
356
|
+
return done(`spent ${s.unit === 'usd' ? `$${s.used.toFixed(2)}` : `${s.used} credits`}${s.budget !== null ? ` of ${s.unit === 'usd' ? `$${s.budget.toFixed(2)}` : `${s.budget} credits`}` : ''}`);
|
|
357
|
+
}
|
|
358
|
+
case 'shot': feed.shot(arg, positional[3], { label: flags.get('label'), image: flags.get('image') ? resolve(String(flags.get('image'))) : undefined }); return done(`shot ${arg}: ${positional[3]}`);
|
|
359
|
+
case 'song': {
|
|
360
|
+
const peaks = flags.get('peaks') ? JSON.parse(readFileSync(resolve(String(flags.get('peaks'))), 'utf8')) : undefined;
|
|
361
|
+
const sung = flags.get('sung') === 'yes' ? true : flags.get('sung') === 'no' ? false : null;
|
|
362
|
+
feed.song({ peaks: Array.isArray(peaks) ? peaks : peaks?.peaks, lyric: flags.get('lyric'), sung });
|
|
363
|
+
return done('song updated');
|
|
364
|
+
}
|
|
365
|
+
case 'log': feed.log(positional.slice(2).join(' ')); return done('logged');
|
|
366
|
+
case 'stop': feed.stop('local'); return done('stop asked: the running command stops at its next safe point');
|
|
367
|
+
case 'end': feed.end(arg, note); return done(`ended: ${arg}`);
|
|
368
|
+
default: return { ok: false, command: 'progress', why: `unknown: progress ${sub ?? ''} (start, stage, check, preview, spend, shot, song, log, stop, end, show)` };
|
|
369
|
+
}
|
|
370
|
+
} catch (error) { return { ok: false, command: 'progress', why: error instanceof Error ? error.message : String(error) }; }
|
|
371
|
+
}
|
|
372
|
+
|
|
220
373
|
/*
|
|
221
374
|
* WHICH DEV SERVER IS THIS STUDIO'S. `dev` writes its own PID and Wrangler's to site/.wrangler/homie-dev.json,
|
|
222
375
|
* and `dev --stop` stops exactly those two (each checked to still be a process of this studio's folder), so an AI
|
|
@@ -301,11 +454,14 @@ function mediaPut(root, file, as) {
|
|
|
301
454
|
|
|
302
455
|
try {
|
|
303
456
|
const result = await main();
|
|
457
|
+
// A shared progress feed sends its last word before the command exits.
|
|
458
|
+
await flushProgress();
|
|
304
459
|
if (result && result.command !== 'help') print(result);
|
|
305
460
|
if (result?.ok === false) process.exitCode = 1;
|
|
306
461
|
// Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
|
|
307
|
-
if (result?.command === 'check' || result?.command === 'port check') process.exit(process.exitCode ?? 0);
|
|
462
|
+
if (result?.command === 'check' || result?.command === 'port check' || result?.command === 'look') process.exit(process.exitCode ?? 0);
|
|
308
463
|
} catch (error) {
|
|
464
|
+
await flushProgress().catch(() => {});
|
|
309
465
|
print({ ok: false, why: error instanceof Error ? error.message : String(error) });
|
|
310
466
|
process.exitCode = 1;
|
|
311
467
|
}
|
package/lib/build.mjs
CHANGED
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
* site/dist/games/<id>/index.html the game's page (the Worker adds HOMIE_NET)
|
|
6
6
|
* site/dist/games/<id>/assets/main.js its bundle (esbuild; @homie-rocks/studio/netplay inlined)
|
|
7
7
|
* site/dist/games/<id>/... everything in games/<id>/public/
|
|
8
|
-
* site/dist/games.json { studio, games[], songs[], videos[] } from studio.json,
|
|
9
|
-
* files
|
|
8
|
+
* site/dist/games.json { studio, games[], songs[], videos[], posts[], site } from studio.json,
|
|
9
|
+
* game.json files, the music/ and videos/ manifests (media/MEDIA.md),
|
|
10
|
+
* posts/*.md and the studio's site/ folder (site/SITE.md)
|
|
11
|
+
* site/dist/games/<id>/_landing/... what the game's landing shows (hero footage, its cover, licence texts)
|
|
12
|
+
* site/dist/_site/ posts.json (the posts' HTML), the studio's own pages (site/pages)
|
|
13
|
+
* site/dist/<file> site/public, as it is (fonts, a logo, hero footage)
|
|
10
14
|
* site/dist/music/..., site/dist/videos/... media files the site serves itself (R2 keys are served from R2)
|
|
11
15
|
*
|
|
12
16
|
* game.json "build" picks how a game becomes files (a ported game keeps its own shape):
|
|
@@ -22,6 +26,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statS
|
|
|
22
26
|
import { createRequire } from 'node:module';
|
|
23
27
|
import { join, relative } from 'node:path';
|
|
24
28
|
import { buildMedia } from './media.mjs';
|
|
29
|
+
import { buildSiteFiles, landingOf, readPosts, readTheme } from './site.mjs';
|
|
25
30
|
import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
|
|
26
31
|
import { SEAT_MAX } from '../worker/seats.mjs';
|
|
27
32
|
import { STUDIO_VERSION } from './version.mjs';
|
|
@@ -173,28 +178,49 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
173
178
|
else {
|
|
174
179
|
try { const prev = JSON.parse(readFileSync(join(dist, 'games.json'), 'utf8')); media = { songs: prev.songs ?? [], videos: prev.videos ?? [], skipped: [] }; } catch { media = { songs: [], videos: [], skipped: [] }; }
|
|
175
180
|
}
|
|
181
|
+
// The site around the games (site/SITE.md): the look, each game's landing, posts, and what site/ overrides.
|
|
182
|
+
const theme = readTheme(root, { log });
|
|
183
|
+
const shown = all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html')));
|
|
184
|
+
const rows = shown.map((g) => {
|
|
185
|
+
// The seats come from the game's netplay manifest (NETPLAY.md §3): what the Worker gives every room of it.
|
|
186
|
+
const net = netplayOf(g, join(dist, 'games', g.id));
|
|
187
|
+
const { min, max } = seatsFor(g, net);
|
|
188
|
+
return {
|
|
189
|
+
id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
|
|
190
|
+
roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
|
|
191
|
+
...(g.screen ? { screen: g.screen } : {}),
|
|
192
|
+
landing: landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log }),
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
const { posts, skipped: postsSkipped } = readPosts(root, { games: rows, songs: media.songs, videos: media.videos, log });
|
|
196
|
+
const site = buildSiteFiles(root, dist, { gameIds: rows.map((g) => g.id), log });
|
|
197
|
+
mkdirSync(join(dist, '_site'), { recursive: true });
|
|
198
|
+
writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
|
|
199
|
+
const s = studio.site && typeof studio.site === 'object' ? studio.site : {};
|
|
176
200
|
const catalogue = {
|
|
177
201
|
studio: {
|
|
178
202
|
name: studio.name, slug: studio.slug, version: STUDIO_VERSION,
|
|
203
|
+
...(typeof studio.tagline === 'string' && studio.tagline.trim() ? { tagline: studio.tagline.trim().slice(0, 140) } : {}),
|
|
204
|
+
theme,
|
|
205
|
+
site: {
|
|
206
|
+
...(rows.some((g) => g.id === s.featured) ? { featured: s.featured } : {}),
|
|
207
|
+
...(Array.isArray(s.frameAncestors) ? { frameAncestors: s.frameAncestors.filter((o) => /^https:\/\/[a-z0-9.-]+(?::\d+)?$/i.test(String(o))).slice(0, 8) } : {}),
|
|
208
|
+
},
|
|
179
209
|
// studio.json `stats.share`: the site tells the directory two numbers for the hub (played this week).
|
|
180
210
|
...(studio.stats?.share === true ? { stats: { share: true }, directory: studio.homie?.directory ?? 'https://homie.rocks' } : {}),
|
|
181
211
|
},
|
|
182
|
-
games:
|
|
183
|
-
// The seats come from the game's netplay manifest (NETPLAY.md §3): what the Worker gives every room of it.
|
|
184
|
-
const net = netplayOf(g, join(dist, 'games', g.id));
|
|
185
|
-
const { min, max } = seatsFor(g, net);
|
|
186
|
-
return {
|
|
187
|
-
id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
|
|
188
|
-
roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
|
|
189
|
-
...(g.screen ? { screen: g.screen } : {}),
|
|
190
|
-
};
|
|
191
|
-
}),
|
|
212
|
+
games: rows,
|
|
192
213
|
songs: media.songs,
|
|
193
214
|
videos: media.videos,
|
|
215
|
+
// Summaries only; each post's HTML is in _site/posts.json.
|
|
216
|
+
posts: posts.map(({ html, record, ...p }) => p),
|
|
217
|
+
site: { pages: site.pages, partials: site.partials, ...(site.css ? { css: site.css } : {}) },
|
|
194
218
|
};
|
|
195
219
|
writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
|
|
196
220
|
return {
|
|
197
221
|
ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id),
|
|
198
222
|
songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped,
|
|
223
|
+
posts: posts.map((p) => p.slug), postsSkipped, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
|
|
224
|
+
landings: rows.map((g) => ({ id: g.id, hero: g.landing.hero.wide || g.landing.hero.tall ? 'footage' : g.landing.hero.wideImage ? 'art' : 'colours', credits: Boolean(g.landing.credits.original || g.landing.credits.people.length), source: g.landing.source })),
|
|
199
225
|
};
|
|
200
226
|
}
|
package/lib/check.mjs
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* phone) press Play, land in the SAME public room, play, and both see the round
|
|
5
5
|
* finish. Exit 0 only when all of that happened. Uses this Mac's Chrome through
|
|
6
6
|
* puppeteer-core, on the GPU (a software renderer fakes a stuck game).
|
|
7
|
+
*
|
|
8
|
+
* `report` (optional; the open progress feed, lib/progress.mjs) hears each step as it goes green or red, gets a
|
|
9
|
+
* small picture of the computer's view, and can ask the check to stop: then the browsers close and the result
|
|
10
|
+
* says `stopped`. Without it the check is exactly what it was.
|
|
7
11
|
*/
|
|
8
12
|
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
9
13
|
import { tmpdir } from 'node:os';
|
|
@@ -18,7 +22,17 @@ const CHROMES = [
|
|
|
18
22
|
|
|
19
23
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
20
24
|
|
|
21
|
-
|
|
25
|
+
/** The steps a progress feed shows for this check, in order. */
|
|
26
|
+
export const CHECK_STEPS = [
|
|
27
|
+
['computer-seated', 'A computer gets a seat'],
|
|
28
|
+
['phone-seated', 'A phone gets a seat'],
|
|
29
|
+
['same-room', 'Both in the same public room'],
|
|
30
|
+
['round', 'They finish a round together'],
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
class Stopped extends Error {}
|
|
34
|
+
|
|
35
|
+
export async function check({ url, game, roundTimeoutMs = 150_000, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
|
|
22
36
|
if (!url || !game) throw new Error('usage: homie-studio check <game> --url <site url>');
|
|
23
37
|
const chrome = CHROMES.find((p) => existsSync(p));
|
|
24
38
|
if (!chrome) return { ok: false, command: 'check', why: 'no Chrome found (set CHROME_PATH)' };
|
|
@@ -35,7 +49,22 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
|
|
|
35
49
|
{ name: 'phone', viewport: { width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 } },
|
|
36
50
|
];
|
|
37
51
|
const players = [];
|
|
52
|
+
const step = (id, state, opts = {}) => { try { report?.check?.(id, state, { label: CHECK_STEPS.find(([k]) => k === id)?.[1], ...opts }); } catch { /* a feed never breaks a check */ } };
|
|
53
|
+
const halt = () => { if (report?.stopped?.()) throw new Stopped('stopped by the person'); };
|
|
54
|
+
// The computer's view, small (480 px wide), for the feed's preview.
|
|
55
|
+
const picture = async (caption) => {
|
|
56
|
+
if (!report?.preview || !players[0]) return;
|
|
57
|
+
try {
|
|
58
|
+
const vp = kinds[0].viewport;
|
|
59
|
+
const b64 = await players[0].page.screenshot({ type: 'jpeg', quality: 62, clip: { x: 0, y: 0, width: vp.width, height: vp.height, scale: 480 / vp.width }, encoding: 'base64' });
|
|
60
|
+
report.preview({ url: play, image: `data:image/jpeg;base64,${b64}`, caption });
|
|
61
|
+
} catch { /* no picture this time */ }
|
|
62
|
+
};
|
|
63
|
+
for (const [id] of CHECK_STEPS) step(id, 'pending');
|
|
38
64
|
try {
|
|
65
|
+
halt();
|
|
66
|
+
step('computer-seated', 'running');
|
|
67
|
+
step('phone-seated', 'running');
|
|
39
68
|
for (const kind of kinds) {
|
|
40
69
|
const profile = mkdtempSync(join(tmpdir(), 'homie-studio-check-'));
|
|
41
70
|
profiles.push(profile);
|
|
@@ -58,15 +87,23 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
|
|
|
58
87
|
const deadline = Date.now() + 45_000;
|
|
59
88
|
while (Date.now() < deadline) {
|
|
60
89
|
const s = await p.page.evaluate(() => { const s = window.__shell; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, players: s.facts?.counts?.players ?? null } : null; }).catch(() => null);
|
|
61
|
-
if (s?.room && s.seat !== null && s.seat !== undefined && s.role) {
|
|
90
|
+
if (s?.room && s.seat !== null && s.seat !== undefined && s.role) {
|
|
91
|
+
p.seatedAt = Date.now(); p.seat = s.seat;
|
|
92
|
+
step(`${p.kind}-seated`, 'pass', { ms: Date.now() - p.t0, note: `seat ${s.seat}, ${s.role}` });
|
|
93
|
+
return { ...s, ms: Date.now() - p.t0 };
|
|
94
|
+
}
|
|
95
|
+
halt();
|
|
62
96
|
await sleep(250);
|
|
63
97
|
}
|
|
98
|
+
step(`${p.kind}-seated`, 'fail', { note: 'no seat within 45 s' });
|
|
64
99
|
return null;
|
|
65
100
|
}));
|
|
66
101
|
if (seated.some((s) => !s)) return { ok: false, command: 'check', why: 'a browser never got a seat within 45 s', seated };
|
|
67
102
|
const sameRoom = seated[0].room === seated[1].room;
|
|
68
103
|
log(`seated: ${seated.map((s, i) => `${kinds[i].name} room ${s.room} seat ${s.seat} ${s.role}`).join('; ')}`);
|
|
104
|
+
step('same-room', sameRoom ? 'pass' : 'fail', { note: sameRoom ? `room ${seated[0].room}` : `rooms ${seated[0].room} and ${seated[1].room}` });
|
|
69
105
|
if (!sameRoom) return { ok: false, command: 'check', why: `the two browsers landed in different rooms (${seated[0].room}, ${seated[1].room})`, seated };
|
|
106
|
+
step('round', 'running');
|
|
70
107
|
// Play: the computer holds keys, the phone drags from the lower left.
|
|
71
108
|
const playing = (async () => {
|
|
72
109
|
const keys = ['KeyD', 'KeyS', 'KeyA', 'KeyW'];
|
|
@@ -80,10 +117,12 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
|
|
|
80
117
|
await players[1].page.touchscreen.touchMove(vp.width * 0.2 + (i % 2 ? 60 : -60), vp.height * 0.8 - 40).catch(() => {});
|
|
81
118
|
await sleep(300);
|
|
82
119
|
await players[1].page.touchscreen.touchEnd().catch(() => {});
|
|
83
|
-
if (players.every((p) => p.over)) break;
|
|
120
|
+
if (players.every((p) => p.over) || report?.stopped?.()) break;
|
|
84
121
|
}
|
|
85
122
|
})();
|
|
86
123
|
if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-playing.png`) }).catch(() => {});
|
|
124
|
+
await sleep(1500);
|
|
125
|
+
await picture('Playing: computer and phone in one room');
|
|
87
126
|
// Only a round that finishes AFTER both browsers were seated, with BOTH of them in its results, counts: an
|
|
88
127
|
// idle room remembers its last round for a while, and that stale result proves nothing.
|
|
89
128
|
const bothSeated = Math.max(...players.map((p) => p.seatedAt));
|
|
@@ -97,15 +136,18 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
|
|
|
97
136
|
return hit ? { n: hit.n, endsAt: hit.endsAt, results: hit.results, room: s.room } : null;
|
|
98
137
|
}, bothSeated, seats).catch(() => null);
|
|
99
138
|
if (r) { p.over = true; return { ...r, ms: Date.now() - p.t0 }; }
|
|
139
|
+
halt();
|
|
100
140
|
await sleep(500);
|
|
101
141
|
}
|
|
102
142
|
return null;
|
|
103
143
|
}));
|
|
104
144
|
await playing.catch(() => {});
|
|
105
145
|
if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-round-over.png`) }).catch(() => {});
|
|
106
|
-
if (over.some((o) => !o)) return { ok: false, command: 'check', why: 'a browser did not see a round finish in time', seated, over };
|
|
146
|
+
if (over.some((o) => !o)) { step('round', 'fail', { note: 'no round finished in time' }); return { ok: false, command: 'check', why: 'a browser did not see a round finish in time', seated, over }; }
|
|
107
147
|
const humans = over[0].results.filter((r) => !r.bot).length;
|
|
108
148
|
const sameRound = over[0].n === over[1].n && over[0].endsAt === over[1].endsAt && over[0].room === over[1].room;
|
|
149
|
+
step('round', sameRound && humans >= 2 ? 'pass' : 'fail', { ms: Math.max(...over.map((o) => o.ms)), note: `round ${over[0].n}: ${humans} people, ${over[0].results.length - humans} bots` });
|
|
150
|
+
await picture(`Round ${over[0].n} finished with both; the next one is on`);
|
|
109
151
|
return {
|
|
110
152
|
ok: sameRoom && sameRound && humans >= 2,
|
|
111
153
|
command: 'check',
|
|
@@ -116,6 +158,10 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
|
|
|
116
158
|
why: !sameRound ? 'the two browsers saw different rounds finish' : humans >= 2 ? undefined : `the round's results list ${humans} human(s); both browsers should be in it`,
|
|
117
159
|
totalMs: Date.now() - started,
|
|
118
160
|
};
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (!(error instanceof Stopped)) throw error;
|
|
163
|
+
for (const [id] of CHECK_STEPS) { try { if (['pending', 'running'].includes(report?.state?.(id))) step(id, 'skip', { note: 'stopped' }); } catch { /* fine */ } }
|
|
164
|
+
return { ok: false, command: 'check', stopped: true, why: 'stopped by the person', play, totalMs: Date.now() - started };
|
|
119
165
|
} finally {
|
|
120
166
|
for (const b of browsers) await b.close().catch(() => {});
|
|
121
167
|
for (const p of profiles) rmSync(p, { recursive: true, force: true });
|