@homie-rocks/studio 0.7.0 → 0.9.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 +56 -4
- package/bin/homie-studio.mjs +173 -5
- package/lib/build.mjs +2 -0
- package/lib/check.mjs +139 -34
- package/lib/look.mjs +3 -2
- package/lib/media.mjs +8 -1
- package/lib/port-check.mjs +29 -6
- package/lib/progress.mjs +420 -0
- package/lib/scaffold.mjs +13 -1
- package/lib/site.mjs +8 -1
- package/lib/template-history.json +157 -0
- package/lib/upgrade.mjs +285 -0
- package/media/MEDIA.md +13 -0
- package/package.json +1 -1
- package/site/SITE.md +51 -6
- package/worker/index.mjs +50 -21
- package/worker/pages.mjs +67 -11
- package/worker/site.mjs +64 -19
- package/worker/stats.mjs +17 -0
- package/worker/version.mjs +1 -1
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.9.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
|
|
@@ -19,6 +19,7 @@ npx homie-studio deploy --plan # what deploy will create
|
|
|
19
19
|
npx homie-studio deploy # the studio's own Cloudflare
|
|
20
20
|
npx homie-studio publish # the homie.rocks directory
|
|
21
21
|
npx homie-studio stats # the studio's own numbers, for its owner
|
|
22
|
+
npx homie-studio upgrade # what a newer template adds to this studio (--apply to take it)
|
|
22
23
|
```
|
|
23
24
|
|
|
24
25
|
## The site: the hub's shape, and a landing for every game
|
|
@@ -33,8 +34,30 @@ live rooms, credits (the original and its licence for a port), and "Make a game
|
|
|
33
34
|
files. `site/SITE.md` says all of it.
|
|
34
35
|
|
|
35
36
|
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.
|
|
37
|
-
|
|
37
|
+
screen and the room code. game.json's `screen.share` puts it where each game has room, per device (a corner or
|
|
38
|
+
the top's middle, moved in by `x` / `y`, or kept a small icon), so it never covers a scoreboard. A game whose
|
|
39
|
+
picture is white or cream gets a light landing (`landing.scheme: "light"`). Every card and the directory show a
|
|
40
|
+
game's landing still. Every HTML answer is `no-transform`, and the site's pages are never framed by another
|
|
41
|
+
site. A game made with Homie's arcade controls that knocks for a Homie box (`/__homie/call`) is told
|
|
42
|
+
`not-a-homie`, at the site's root as under the game.
|
|
43
|
+
|
|
44
|
+
## Upgrading a studio
|
|
45
|
+
|
|
46
|
+
A studio pins one version, so nothing changes until it asks. To take a newer one:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
npx -y --package=https://homie.rocks/npm/homie-studio-0.9.0.tgz homie-studio upgrade # the plan; changes nothing
|
|
50
|
+
npx -y --package=https://homie.rocks/npm/homie-studio-0.9.0.tgz homie-studio upgrade --apply # after the person agrees
|
|
51
|
+
npm install && npm run build
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`upgrade` compares the studio with what this version's `new` would write for it: an AGENTS.md section, a README,
|
|
55
|
+
a `.gitignore` line, a D1 migration or a package.json script it lacks is added, and one it never changed since an
|
|
56
|
+
older template wrote it (`lib/template-history.json` knows every earlier version's text by a fingerprint) takes
|
|
57
|
+
the newer text. Anything the studio changed or wrote itself is kept as it is: the plan lists it, and `--diff`
|
|
58
|
+
shows how it differs from the template. `--apply` also pins this version in package.json and sets studio.json's
|
|
59
|
+
`homie.studio`. When the template's words change, `node scripts/studio-template-history.mjs` records them (a test
|
|
60
|
+
fails until it has).
|
|
38
61
|
|
|
39
62
|
## Rooms of up to 32
|
|
40
63
|
|
|
@@ -64,6 +87,34 @@ npx homie-studio stats share on # tell the directory "pl
|
|
|
64
87
|
Each key is minted on the owner's computer; only its SHA-256 goes into D1, through the
|
|
65
88
|
studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
|
|
66
89
|
|
|
90
|
+
## A build the person can watch (the progress feed)
|
|
91
|
+
|
|
92
|
+
A build of a game, song or video can keep a small progress feed, so the person follows it
|
|
93
|
+
from wherever they are (the Claude app on a phone, through the Homie MCP's build progress
|
|
94
|
+
card), sees each check go green, what it spent against its budget, and can press Stop:
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
npx homie-studio progress start crown-thief --share --title "Crown Thief: faster rounds" --budget 2
|
|
98
|
+
npx homie-studio progress stage plan done --note "Rounds from 60 s to 45 s"
|
|
99
|
+
npm run build && npx homie-studio check crown-thief --url http://127.0.0.1:8787 && npm run deploy
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
While a feed is open, `build`, `check`, `port check` and `deploy` report into it by
|
|
103
|
+
themselves: their stage (a game: plan → build → checks → deploy), each check as it runs
|
|
104
|
+
and passes, a small picture of the game and the address to play it; a deploy with every
|
|
105
|
+
check green ends the build. The AI marks what only it knows with `progress stage`,
|
|
106
|
+
`progress check`, `progress spend`, `progress shot` (a video's shots) and `progress song`
|
|
107
|
+
(a song's waveform and lyric check). Before each stage starts, and every few seconds while
|
|
108
|
+
one runs, a shared feed asks whether Stop was pressed; a stopped command closes its
|
|
109
|
+
browsers and says so, and nothing already built or deployed is undone.
|
|
110
|
+
|
|
111
|
+
The feed is `.studio/progress/<build>.json` (git-ignored). With `--share` the studio's
|
|
112
|
+
directory (studio.json `homie.directory`, homie.rocks by default) keeps a copy for 24
|
|
113
|
+
hours so the Claude app can show it: the feed only (plain bounded text, pictures under
|
|
114
|
+
96 KB), never a key, a path or code. Its write key stays in `.studio/progress/` and is
|
|
115
|
+
never printed; the shared id is printed for the MCP tool `build_progress`. Without an open
|
|
116
|
+
feed every command behaves exactly as before. `lib/progress.mjs` has the whole format.
|
|
117
|
+
|
|
67
118
|
## The site's address
|
|
68
119
|
|
|
69
120
|
`deploy` keeps the `workers.dev` address in `.studio/local.json`, which git ignores: it
|
|
@@ -107,7 +158,8 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
|
|
|
107
158
|
| `lib/scaffold.mjs` | `new`: the studio monorepo, only into a new or empty folder, every file listed. |
|
|
108
159
|
| `lib/build.mjs` | `build`: games bundled with esbuild into `site/dist`, `games.json`, each game's shared `source.json`. |
|
|
109
160
|
| `lib/cloudflare.mjs` | `deploy` (and `deploy --plan`): Wrangler, D1, migrations, the directory claim; refuses resources it did not create. `storage add`: the optional R2 bucket. |
|
|
110
|
-
| `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round. |
|
|
161
|
+
| `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round with both in it; on a busy computer it waits out a round a browser was dropped from, and says why when none counts. |
|
|
162
|
+
| `lib/upgrade.mjs`, `lib/template-history.json` | `upgrade`: an existing studio takes what a newer template adds, never over its own edits. |
|
|
111
163
|
| `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
|
|
112
164
|
| `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. |
|
|
113
165
|
| `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`. |
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -23,12 +23,34 @@
|
|
|
23
23
|
* homie-studio media put <file> [--as <key>]
|
|
24
24
|
* homie-studio media list
|
|
25
25
|
* homie-studio status
|
|
26
|
+
* homie-studio upgrade [--apply] [--diff]
|
|
27
|
+
* (what this version's template adds to an existing studio: AGENTS.md
|
|
28
|
+
* sections, READMEs, .gitignore lines, the pin; changes nothing until
|
|
29
|
+
* --apply, and never a file or section the studio changed)
|
|
26
30
|
* homie-studio stats [--range 1d|7d|30d|90d] [--game <id> | --song <slug> | --video <slug>] [--url <site>]
|
|
27
31
|
* homie-studio stats key [--hours 1] (a read key for the Homie MCP tool studio_stats)
|
|
28
32
|
* homie-studio stats link (a one-time link to the private stats page, for the owner's browser)
|
|
29
33
|
* homie-studio stats revoke (every stats key and page session ends)
|
|
30
34
|
* homie-studio stats share on|off (tell the homie.rocks directory "played this week", or stop)
|
|
31
35
|
*
|
|
36
|
+
* homie-studio progress start [<id>] [--what game|song|video] [--title "<what this build does>"]
|
|
37
|
+
* [--budget <dollars>] [--unit usd|credits] [--share]
|
|
38
|
+
* (a progress feed for one build; --share shows it in the Claude app
|
|
39
|
+
* through the Homie MCP's build_progress widget, which can press Stop)
|
|
40
|
+
* homie-studio progress stage <stage> running|done|failed|skipped [--note "<one line>"]
|
|
41
|
+
* homie-studio progress check <id> pending|running|pass|fail|skip [--label "<words>"] [--note "<one line>"]
|
|
42
|
+
* homie-studio progress preview [--url <address>] [--image <small .jpg/.png>] [--caption "<words>"]
|
|
43
|
+
* homie-studio progress spend <amount> --what "<what it bought>" [--receipt <file>]
|
|
44
|
+
* homie-studio progress shot <id> pending|running|pass|fail [--label "<words>"] [--image <small .jpg>]
|
|
45
|
+
* homie-studio progress song [--peaks <peaks.json>] [--lyric "<line>" --sung yes|no]
|
|
46
|
+
* homie-studio progress log "<one line>"
|
|
47
|
+
* homie-studio progress stop (ask the running build to stop at its next safe point)
|
|
48
|
+
* homie-studio progress end passed|failed|stopped [--note "<one line>"]
|
|
49
|
+
* homie-studio progress show [<build>]
|
|
50
|
+
* While a feed is open, build, check, port check and deploy report
|
|
51
|
+
* into it (stage, each check going green, a preview picture) and stop
|
|
52
|
+
* when asked. Without one, nothing changes.
|
|
53
|
+
*
|
|
32
54
|
* Every command prints a few lines for a person; --json prints the result.
|
|
33
55
|
*/
|
|
34
56
|
import { spawn, spawnSync } from 'node:child_process';
|
|
@@ -43,13 +65,15 @@ import { importPort, planPort } from '../lib/port.mjs';
|
|
|
43
65
|
import { portCheck } from '../lib/port-check.mjs';
|
|
44
66
|
import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
|
|
45
67
|
import { ensureStatsMigration, newStudio } from '../lib/scaffold.mjs';
|
|
68
|
+
import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
|
|
46
69
|
import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters } from '../lib/studio.mjs';
|
|
47
70
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
48
71
|
import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
|
|
72
|
+
import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, startProgress } from '../lib/progress.mjs';
|
|
49
73
|
|
|
50
74
|
const argv = process.argv.slice(2);
|
|
51
75
|
const flags = new Map();
|
|
52
|
-
const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop'];
|
|
76
|
+
const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff'];
|
|
53
77
|
const positional = [];
|
|
54
78
|
for (let i = 0; i < argv.length; i++) {
|
|
55
79
|
const a = argv[i];
|
|
@@ -155,9 +179,57 @@ function print(result) {
|
|
|
155
179
|
case 'stats share':
|
|
156
180
|
lines.push(result.message);
|
|
157
181
|
break;
|
|
182
|
+
case 'progress start':
|
|
183
|
+
lines.push(`Build ${result.build}: ${result.title} (${result.stages.join(' → ')})${result.budget !== null ? `, budget ${result.unit === 'usd' ? `$${result.budget.toFixed(2)}` : `${result.budget} credits`}` : ''}`,
|
|
184
|
+
` feed: ${result.file}`,
|
|
185
|
+
...(result.shared ? [` shared for the Claude app until ${result.shared.expiresAt ?? 'tomorrow'}: ${result.widget}`] : []),
|
|
186
|
+
...(result.sharedWhy ? [` ${result.sharedWhy}`] : []),
|
|
187
|
+
' 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>"');
|
|
188
|
+
break;
|
|
189
|
+
case 'progress':
|
|
190
|
+
lines.push(result.message ?? `${result.feed?.title}: ${result.feed?.state}${result.feed?.stage ? ` (${result.feed.stage})` : ''}`);
|
|
191
|
+
break;
|
|
192
|
+
case 'progress show': {
|
|
193
|
+
const f = result.feed;
|
|
194
|
+
const mark = { done: 'ok ', pass: 'ok ', running: '... ', failed: 'FAIL', fail: 'FAIL', skipped: 'skip', skip: 'skip', stopped: 'stop', pending: ' ' };
|
|
195
|
+
lines.push(`${f.title} [${f.state}] build ${f.build}${f.shared ? ` (shared as ${f.shared.build})` : ''}`,
|
|
196
|
+
...f.stages.map((s) => ` ${mark[s.state] ?? s.state} ${s.label}${s.note ? `: ${s.note}` : ''}`),
|
|
197
|
+
...f.checks.map((c) => ` ${mark[c.state] ?? c.state} ${c.label}${c.note ? `: ${c.note}` : ''}`),
|
|
198
|
+
...(f.preview?.url ? [` preview: ${f.preview.url}`] : []),
|
|
199
|
+
` 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`}` : ''}`,
|
|
200
|
+
...(f.stop?.requested ? [` stop asked (${f.stop.by}) at ${f.stop.at}`] : []));
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
158
203
|
case 'look':
|
|
159
204
|
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}`);
|
|
160
205
|
break;
|
|
206
|
+
case 'upgrade': {
|
|
207
|
+
const show = (text, n = 14) => { const l = String(text ?? '').replace(/\n+$/, '').split('\n'); return [...l.slice(0, n).map((x) => ` ${x}`), ...(l.length > n ? [` … (${l.length - n} more lines; --json has all of it)`] : [])]; };
|
|
208
|
+
const mark = { 'add-file': '+', 'add-section': '+', 'add-lines': '+', scripts: '+', 'update-file': '~', 'update-section': '~', pin: '~', version: '~' };
|
|
209
|
+
const list = result.applied ? result.done : result.changes;
|
|
210
|
+
lines.push(result.applied
|
|
211
|
+
? `${result.studio} is on the @homie-rocks/studio ${result.to} template now (${list.length} change${list.length === 1 ? '' : 's'}).`
|
|
212
|
+
: list.length ? `${result.studio}: what the @homie-rocks/studio ${result.to} template adds (from ${result.from ?? 'an older version'}). Nothing is changed until --apply.` : `${result.studio} has everything the @homie-rocks/studio ${result.to} template writes.`);
|
|
213
|
+
if (list.length) lines.push('');
|
|
214
|
+
for (const c of list) {
|
|
215
|
+
lines.push(` ${mark[c.kind] ?? '·'} ${c.file.padEnd(18)} ${c.what}${c.kind === 'add-section' && (c.after || c.before) ? ` (${c.after ? `after "${c.after}"` : `before "${c.before}"`})` : ''}`);
|
|
216
|
+
if (result.applied) continue;
|
|
217
|
+
if (c.kind === 'add-section' || (c.kind === 'add-file' && c.file.endsWith('.md'))) lines.push(...show(c.text));
|
|
218
|
+
if (c.kind === 'update-section' || c.kind === 'update-file') lines.push(...lineDiff(c.from, c.text).map((x) => ` ${x}`));
|
|
219
|
+
}
|
|
220
|
+
for (const c of result.skipped ?? []) lines.push(` ! ${c.file.padEnd(18)} not applied: ${c.why}`);
|
|
221
|
+
if (result.kept?.length) {
|
|
222
|
+
lines.push('', 'Kept as this studio wrote them (never changed by upgrade; the template\'s text differs):');
|
|
223
|
+
for (const k of result.kept) {
|
|
224
|
+
lines.push(` = ${k.file.padEnd(18)} ${k.what}`);
|
|
225
|
+
if (flags.has('diff') && k.mine !== undefined) lines.push(...lineDiff(k.mine, k.template).map((x) => ` ${x}`));
|
|
226
|
+
}
|
|
227
|
+
if (!flags.has('diff')) lines.push(' (--diff shows how each differs from the template: "-" this studio\'s lines, "+" the template\'s)');
|
|
228
|
+
}
|
|
229
|
+
if (result.uncommitted && !result.applied && result.changes.length) lines.push('', `Note: ${result.uncommitted} file(s) have uncommitted changes; commit them first so the upgrade is a change of its own.`);
|
|
230
|
+
if (result.next?.length) lines.push('', 'Next:', ...result.next.map((n) => ` ${n}`));
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
161
233
|
case 'check':
|
|
162
234
|
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.`,
|
|
163
235
|
...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`));
|
|
@@ -181,28 +253,33 @@ async function main() {
|
|
|
181
253
|
|
|
182
254
|
if (cmd === 'port' && sub === 'plan') return planPort(positional[2] ?? '.');
|
|
183
255
|
const root = requireStudio();
|
|
256
|
+
if (cmd === 'progress') return progressCommand(root, sub);
|
|
184
257
|
if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
|
|
185
258
|
if (cmd === 'port' && sub === 'check') {
|
|
186
259
|
const game = positional[2] ?? listGames(root)[0]?.id;
|
|
187
260
|
const url = flags.get('url') ?? siteUrl(root);
|
|
188
261
|
if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address or the live site)' };
|
|
189
|
-
return portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
|
|
262
|
+
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');
|
|
190
263
|
}
|
|
191
264
|
if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
|
|
192
265
|
if (cmd === 'game' && sub === 'remix') return remixGame(root, positional[2], flags.get('id'), { name: flags.get('name') });
|
|
193
266
|
if (cmd === 'games') return { ok: true, command: 'games', games: listGames(root).map(({ dir, ...g }) => ({ ...g, dir: relative(root, dir) })) };
|
|
194
|
-
if (cmd === 'build') return build(root, { only: positional[1] ?? null, log });
|
|
267
|
+
if (cmd === 'build') return tracked(root, 'build', () => build(root, { only: positional[1] ?? null, log }), 'build');
|
|
195
268
|
if (cmd === 'status') {
|
|
196
269
|
const studio = readStudio(root);
|
|
197
270
|
return { ok: true, command: 'status', root, studio, site: siteUrl(root, studio), games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
|
|
198
271
|
}
|
|
272
|
+
if (cmd === 'upgrade') {
|
|
273
|
+
const plan = upgradePlan(root);
|
|
274
|
+
return flags.has('apply') || flags.has('yes') ? upgradeApply(root, plan) : plan;
|
|
275
|
+
}
|
|
199
276
|
if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
|
|
200
277
|
if (cmd === 'dev') return dev(root);
|
|
201
278
|
if (cmd === 'check') {
|
|
202
279
|
const game = positional[1] ?? listGames(root)[0]?.id;
|
|
203
280
|
const url = flags.get('url') ?? siteUrl(root);
|
|
204
281
|
if (!url) return { ok: false, command: 'check', why: 'give --url (the local dev address or the live site)' };
|
|
205
|
-
return check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
|
|
282
|
+
return tracked(root, 'checks', (report) => check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'check');
|
|
206
283
|
}
|
|
207
284
|
if (cmd === 'look') {
|
|
208
285
|
const url = flags.get('url') ?? siteUrl(root);
|
|
@@ -218,7 +295,7 @@ async function main() {
|
|
|
218
295
|
return look({ url, paths, shots: flags.get('shots') ? resolve(flags.get('shots')) : join(root, '.studio', 'look'), devices: only, log });
|
|
219
296
|
}
|
|
220
297
|
if (cmd === 'deploy' && flags.has('plan')) return deployPlan(root);
|
|
221
|
-
if (cmd === 'deploy') return deploy(root, { log, homie: flags.get('homie') });
|
|
298
|
+
if (cmd === 'deploy') return tracked(root, 'deploy', () => deploy(root, { log, homie: flags.get('homie') }), 'deploy');
|
|
222
299
|
if (cmd === 'storage' && sub === 'add') return storageAdd(root, { log });
|
|
223
300
|
if (cmd === 'storage') {
|
|
224
301
|
const cf = readStudio(root).cloudflare ?? {};
|
|
@@ -241,6 +318,94 @@ async function main() {
|
|
|
241
318
|
return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
|
|
242
319
|
}
|
|
243
320
|
|
|
321
|
+
/*
|
|
322
|
+
* THE PROGRESS FEED (lib/progress.mjs). A command that is a stage of the open build (build, check, port check,
|
|
323
|
+
* deploy) says so: the stage runs, then is done or failed with one line; a check's steps go green as they pass; a
|
|
324
|
+
* stop asked in the Claude app (or by `progress stop`) ends it at the next safe point. With no open feed, or a
|
|
325
|
+
* feed whose build has no such stage (a song has no deploy), the command runs exactly as it did before.
|
|
326
|
+
*/
|
|
327
|
+
async function tracked(root, stage, run, command) {
|
|
328
|
+
const feed = currentFeed(root);
|
|
329
|
+
if (!feed || !feed.doc.stages.some((s) => s.id === stage)) return run(null);
|
|
330
|
+
await feed.sync();
|
|
331
|
+
if (feed.stopRequested()) {
|
|
332
|
+
feed.stage(stage, 'stopped', 'stopped by the person before it started');
|
|
333
|
+
feed.end('stopped', 'Stopped by the person.');
|
|
334
|
+
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' };
|
|
335
|
+
}
|
|
336
|
+
if (stage === 'checks') feed.resetChecks('checks');
|
|
337
|
+
feed.stage(stage, 'running');
|
|
338
|
+
const report = {
|
|
339
|
+
check: (id, state, opts) => feed.check(id, state, { ...opts, stage }),
|
|
340
|
+
preview: (p) => feed.preview(p),
|
|
341
|
+
stopped: () => feed.stopRequested(),
|
|
342
|
+
state: (id) => feed.doc?.checks.find((c) => c.id === id)?.state,
|
|
343
|
+
};
|
|
344
|
+
let result;
|
|
345
|
+
try { result = await run(report); } catch (error) { feed.stage(stage, 'failed', error instanceof Error ? error.message : String(error)); throw error; }
|
|
346
|
+
if (result?.stopped || feed.stopRequested()) {
|
|
347
|
+
feed.stage(stage, 'stopped', 'stopped by the person');
|
|
348
|
+
feed.end('stopped', 'Stopped by the person.');
|
|
349
|
+
return { ...result, ok: false, stopped: true, why: result?.why ?? 'stopped by the person' };
|
|
350
|
+
}
|
|
351
|
+
if (!result?.ok) { feed.stage(stage, 'failed', result?.why ?? 'failed'); return result; }
|
|
352
|
+
const note = stage === 'build' ? `Built ${(result.games ?? []).map((g) => `${g.id} (${Math.round(g.bytes / 1024)} KB)`).join(', ') || 'the site'}`
|
|
353
|
+
: stage === 'deploy' ? `Live: ${result.url ?? 'deployed'}`
|
|
354
|
+
: result.command === 'port check' ? `${result.passed.length} passed${result.skipped.length ? `, ${result.skipped.length} skipped` : ''} in ${Math.round(result.totalMs / 1000)} s`
|
|
355
|
+
: `Room ${result.room}: round ${result.round?.n} finished with ${result.round?.humans} people in ${Math.round(result.totalMs / 1000)} s`;
|
|
356
|
+
feed.stage(stage, 'done', note);
|
|
357
|
+
if (stage === 'deploy') {
|
|
358
|
+
const doc = feed.doc;
|
|
359
|
+
const game = (result.games ?? []).find((g) => g.id === doc.id) ?? (result.games ?? [])[0];
|
|
360
|
+
if (game?.play) feed.preview({ url: game.play });
|
|
361
|
+
// Deployed, and every check that ran passed: the build is done.
|
|
362
|
+
if (doc.checks.every((c) => ['pass', 'skip'].includes(c.state))) feed.end('passed', `Live: ${game?.play ?? result.url}`);
|
|
363
|
+
}
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function progressCommand(root, sub) {
|
|
368
|
+
const arg = positional[2];
|
|
369
|
+
if (sub === 'start') {
|
|
370
|
+
return startProgress(root, {
|
|
371
|
+
what: flags.get('what') ?? 'game', id: arg ?? flags.get('id') ?? null, title: flags.get('title'),
|
|
372
|
+
budget: flags.get('budget'), unit: flags.get('unit'), share: flags.has('share'), directory: flags.get('homie'),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
if (sub === 'show') {
|
|
376
|
+
const id = arg ?? currentId(root) ?? flags.get('build');
|
|
377
|
+
const doc = readFeed(root, id);
|
|
378
|
+
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)' };
|
|
379
|
+
}
|
|
380
|
+
const id = flags.get('build') ?? currentId(root);
|
|
381
|
+
if (!id || !readFeed(root, id)) return { ok: false, command: 'progress', why: 'no build is open: start one with `homie-studio progress start <id> --share`' };
|
|
382
|
+
const feed = new Feed(root, id);
|
|
383
|
+
const done = (message) => ({ ok: true, command: 'progress', build: id, message, feed: publicFeed(feed.doc) });
|
|
384
|
+
const note = flags.has('note') ? String(flags.get('note')) : undefined;
|
|
385
|
+
try {
|
|
386
|
+
switch (sub) {
|
|
387
|
+
case 'stage': feed.stage(arg, positional[3], note); return done(`${arg}: ${positional[3]}`);
|
|
388
|
+
case 'check': feed.check(arg, positional[3], { label: flags.get('label'), note }); return done(`check ${arg}: ${positional[3]}`);
|
|
389
|
+
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');
|
|
390
|
+
case 'spend': feed.spend(arg, flags.get('what'), { receipt: flags.get('receipt'), unit: flags.get('unit') }); {
|
|
391
|
+
const s = feed.doc.spend;
|
|
392
|
+
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`}` : ''}`);
|
|
393
|
+
}
|
|
394
|
+
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]}`);
|
|
395
|
+
case 'song': {
|
|
396
|
+
const peaks = flags.get('peaks') ? JSON.parse(readFileSync(resolve(String(flags.get('peaks'))), 'utf8')) : undefined;
|
|
397
|
+
const sung = flags.get('sung') === 'yes' ? true : flags.get('sung') === 'no' ? false : null;
|
|
398
|
+
feed.song({ peaks: Array.isArray(peaks) ? peaks : peaks?.peaks, lyric: flags.get('lyric'), sung });
|
|
399
|
+
return done('song updated');
|
|
400
|
+
}
|
|
401
|
+
case 'log': feed.log(positional.slice(2).join(' ')); return done('logged');
|
|
402
|
+
case 'stop': feed.stop('local'); return done('stop asked: the running command stops at its next safe point');
|
|
403
|
+
case 'end': feed.end(arg, note); return done(`ended: ${arg}`);
|
|
404
|
+
default: return { ok: false, command: 'progress', why: `unknown: progress ${sub ?? ''} (start, stage, check, preview, spend, shot, song, log, stop, end, show)` };
|
|
405
|
+
}
|
|
406
|
+
} catch (error) { return { ok: false, command: 'progress', why: error instanceof Error ? error.message : String(error) }; }
|
|
407
|
+
}
|
|
408
|
+
|
|
244
409
|
/*
|
|
245
410
|
* WHICH DEV SERVER IS THIS STUDIO'S. `dev` writes its own PID and Wrangler's to site/.wrangler/homie-dev.json,
|
|
246
411
|
* and `dev --stop` stops exactly those two (each checked to still be a process of this studio's folder), so an AI
|
|
@@ -325,11 +490,14 @@ function mediaPut(root, file, as) {
|
|
|
325
490
|
|
|
326
491
|
try {
|
|
327
492
|
const result = await main();
|
|
493
|
+
// A shared progress feed sends its last word before the command exits.
|
|
494
|
+
await flushProgress();
|
|
328
495
|
if (result && result.command !== 'help') print(result);
|
|
329
496
|
if (result?.ok === false) process.exitCode = 1;
|
|
330
497
|
// Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
|
|
331
498
|
if (result?.command === 'check' || result?.command === 'port check' || result?.command === 'look') process.exit(process.exitCode ?? 0);
|
|
332
499
|
} catch (error) {
|
|
500
|
+
await flushProgress().catch(() => {});
|
|
333
501
|
print({ ok: false, why: error instanceof Error ? error.message : String(error) });
|
|
334
502
|
process.exitCode = 1;
|
|
335
503
|
}
|
package/lib/build.mjs
CHANGED
|
@@ -208,6 +208,8 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
208
208
|
},
|
|
209
209
|
// studio.json `stats.share`: the site tells the directory two numbers for the hub (played this week).
|
|
210
210
|
...(studio.stats?.share === true ? { stats: { share: true }, directory: studio.homie?.directory ?? 'https://homie.rocks' } : {}),
|
|
211
|
+
// studio.json `"rooms": { "share": false }`: the manifest names no rooms, so the hub shows none of this studio's.
|
|
212
|
+
...(studio.rooms?.share === false ? { rooms: { share: false } } : {}),
|
|
211
213
|
},
|
|
212
214
|
games: rows,
|
|
213
215
|
songs: media.songs,
|
package/lib/check.mjs
CHANGED
|
@@ -4,9 +4,20 @@
|
|
|
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.
|
|
11
|
+
*
|
|
12
|
+
* UNDER LOAD (a busy computer, another project's browsers): Chrome gets 150 s to start, a page 90 s to open and a
|
|
13
|
+
* seat 90 s. A browser that stalls for 10 s is let go by the relay and comes back with its seat token; the round it
|
|
14
|
+
* was away for lists its body as a bot, so that round cannot prove anything and the check waits for the next one
|
|
15
|
+
* (up to `rounds` rounds). A person the game lists as a bot while it keeps their seat (an idle player driven by
|
|
16
|
+
* the game's autopilot) is still that person. When no round counts, the result says which seat was missing from
|
|
17
|
+
* each finished round and whether that browser lost its connection, instead of only "no round finished".
|
|
7
18
|
*/
|
|
8
19
|
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
9
|
-
import { tmpdir } from 'node:os';
|
|
20
|
+
import { cpus, loadavg, tmpdir } from 'node:os';
|
|
10
21
|
import { join } from 'node:path';
|
|
11
22
|
|
|
12
23
|
const CHROMES = [
|
|
@@ -18,7 +29,39 @@ const CHROMES = [
|
|
|
18
29
|
|
|
19
30
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
20
31
|
|
|
21
|
-
|
|
32
|
+
/** The steps a progress feed shows for this check, in order. */
|
|
33
|
+
/** Chrome's own start, on a loaded computer (a cold start there took over a minute). */
|
|
34
|
+
export const LAUNCH_TIMEOUT_MS = 150_000;
|
|
35
|
+
|
|
36
|
+
export const CHECK_STEPS = [
|
|
37
|
+
['computer-seated', 'A computer gets a seat'],
|
|
38
|
+
['phone-seated', 'A phone gets a seat'],
|
|
39
|
+
['same-room', 'Both in the same public room'],
|
|
40
|
+
['round', 'They finish a round together'],
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
class Stopped extends Error {}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* WHICH FINISHED ROUND PROVES THE TWO BROWSERS PLAYED TOGETHER. `browsers`: each `{ kind, seats, seen }`, where
|
|
47
|
+
* `seats` is every seat it held (a Set) and `seen` the rounds it saw finish after both were seated (a Map from
|
|
48
|
+
* `n:endsAt` to `{ n, endsAt, results, ms }`). A round counts when both saw it and each has a row carrying one of
|
|
49
|
+
* its seats, whatever the row's `bot` says (a game may hand an idle person's body to its autopilot and still keep
|
|
50
|
+
* their seat on the row: that is still the person). A round seen by both without one of them is a miss, kept in
|
|
51
|
+
* `missed` with who was missing and the rows, so the check can say why. Returns the round that counts, or null.
|
|
52
|
+
*/
|
|
53
|
+
export function judgeRounds(browsers, missed = new Map()) {
|
|
54
|
+
const mine = new Set(browsers.flatMap((b) => [...b.seats]));
|
|
55
|
+
for (const [key, r] of browsers[0]?.seen ?? []) {
|
|
56
|
+
if (missed.has(key) || !browsers.every((b) => b.seen.has(key))) continue;
|
|
57
|
+
const absent = browsers.filter((b) => !(r.results ?? []).some((row) => b.seats.has(row?.seat))).map((b) => b.kind);
|
|
58
|
+
if (!absent.length) return { ...r, key, overMs: browsers.map((b) => b.seen.get(key).ms), people: r.results.filter((row) => !row?.bot || mine.has(row?.seat)).length };
|
|
59
|
+
missed.set(key, { n: r.n, missing: absent, rows: (r.results ?? []).slice(0, 12).map((row) => ({ seat: row?.seat ?? null, name: row?.name ?? null, bot: Boolean(row?.bot) })) });
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
|
|
22
65
|
if (!url || !game) throw new Error('usage: homie-studio check <game> --url <site url>');
|
|
23
66
|
const chrome = CHROMES.find((p) => existsSync(p));
|
|
24
67
|
if (!chrome) return { ok: false, command: 'check', why: 'no Chrome found (set CHROME_PATH)' };
|
|
@@ -35,12 +78,29 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
|
|
|
35
78
|
{ name: 'phone', viewport: { width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 } },
|
|
36
79
|
];
|
|
37
80
|
const players = [];
|
|
81
|
+
// The playing loop runs until this is false: the round is settled, or the check ends in any way (finally).
|
|
82
|
+
let playingOn = true;
|
|
83
|
+
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 */ } };
|
|
84
|
+
const halt = () => { if (report?.stopped?.()) throw new Stopped('stopped by the person'); };
|
|
85
|
+
// The computer's view, small (480 px wide), for the feed's preview.
|
|
86
|
+
const picture = async (caption) => {
|
|
87
|
+
if (!report?.preview || !players[0]) return;
|
|
88
|
+
try {
|
|
89
|
+
const vp = kinds[0].viewport;
|
|
90
|
+
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' });
|
|
91
|
+
report.preview({ url: play, image: `data:image/jpeg;base64,${b64}`, caption });
|
|
92
|
+
} catch { /* no picture this time */ }
|
|
93
|
+
};
|
|
94
|
+
for (const [id] of CHECK_STEPS) step(id, 'pending');
|
|
38
95
|
try {
|
|
96
|
+
halt();
|
|
97
|
+
step('computer-seated', 'running');
|
|
98
|
+
step('phone-seated', 'running');
|
|
39
99
|
for (const kind of kinds) {
|
|
40
100
|
const profile = mkdtempSync(join(tmpdir(), 'homie-studio-check-'));
|
|
41
101
|
profiles.push(profile);
|
|
42
102
|
const browser = await puppeteer.launch({
|
|
43
|
-
executablePath: chrome, headless: true, userDataDir: profile,
|
|
103
|
+
executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, protocolTimeout: 180_000,
|
|
44
104
|
args: ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist', '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
|
|
45
105
|
});
|
|
46
106
|
browsers.push(browser);
|
|
@@ -49,74 +109,119 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
|
|
|
49
109
|
const ua = await browser.userAgent();
|
|
50
110
|
await page.setUserAgent(`${ua} ${userAgentTag}`);
|
|
51
111
|
const t0 = Date.now();
|
|
52
|
-
await page.goto(play, { waitUntil: 'domcontentloaded', timeout:
|
|
112
|
+
await page.goto(play, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
|
53
113
|
players.push({ kind: kind.name, page, t0 });
|
|
54
114
|
log(`${kind.name} opened ${play}`);
|
|
55
115
|
}
|
|
56
116
|
// Seated: the shell knows its room and the game reported a seat.
|
|
57
117
|
const seated = await Promise.all(players.map(async (p) => {
|
|
58
|
-
const deadline = Date.now() +
|
|
118
|
+
const deadline = Date.now() + 90_000;
|
|
59
119
|
while (Date.now() < deadline) {
|
|
60
120
|
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) {
|
|
121
|
+
if (s?.room && s.seat !== null && s.seat !== undefined && s.role) {
|
|
122
|
+
p.seatedAt = Date.now(); p.seat = s.seat;
|
|
123
|
+
step(`${p.kind}-seated`, 'pass', { ms: Date.now() - p.t0, note: `seat ${s.seat}, ${s.role}` });
|
|
124
|
+
return { ...s, ms: Date.now() - p.t0 };
|
|
125
|
+
}
|
|
126
|
+
halt();
|
|
62
127
|
await sleep(250);
|
|
63
128
|
}
|
|
129
|
+
step(`${p.kind}-seated`, 'fail', { note: 'no seat within 90 s' });
|
|
64
130
|
return null;
|
|
65
131
|
}));
|
|
66
|
-
if (seated.some((s) => !s)) return { ok: false, command: 'check', why: 'a browser never got a seat within
|
|
132
|
+
if (seated.some((s) => !s)) return { ok: false, command: 'check', why: 'a browser never got a seat within 90 s', seated };
|
|
67
133
|
const sameRoom = seated[0].room === seated[1].room;
|
|
68
134
|
log(`seated: ${seated.map((s, i) => `${kinds[i].name} room ${s.room} seat ${s.seat} ${s.role}`).join('; ')}`);
|
|
135
|
+
step('same-room', sameRoom ? 'pass' : 'fail', { note: sameRoom ? `room ${seated[0].room}` : `rooms ${seated[0].room} and ${seated[1].room}` });
|
|
69
136
|
if (!sameRoom) return { ok: false, command: 'check', why: `the two browsers landed in different rooms (${seated[0].room}, ${seated[1].room})`, seated };
|
|
70
|
-
|
|
137
|
+
step('round', 'running');
|
|
138
|
+
// Play: the computer holds keys; the phone drags from the lower left (a stick) and swipes across the bottom
|
|
139
|
+
// middle (a wheel, a slider). A game that hands an untouched player to its autopilot, or lets an idle one go,
|
|
140
|
+
// sees a hand on both. It plays until the check is over, through as many rounds as that takes.
|
|
71
141
|
const playing = (async () => {
|
|
72
142
|
const keys = ['KeyD', 'KeyS', 'KeyA', 'KeyW'];
|
|
73
|
-
|
|
143
|
+
const vp = kinds[1].viewport;
|
|
144
|
+
for (let i = 0; playingOn && i < 5000; i++) {
|
|
74
145
|
const k = keys[i % keys.length];
|
|
75
146
|
await players[0].page.keyboard.down(k).catch(() => {});
|
|
76
147
|
await sleep(600);
|
|
77
148
|
await players[0].page.keyboard.up(k).catch(() => {});
|
|
78
|
-
const
|
|
79
|
-
await players[1].page.touchscreen.touchStart(
|
|
80
|
-
await players[1].page.touchscreen.touchMove(
|
|
149
|
+
const [x, y, dx, dy] = i % 3 === 2 ? [vp.width * 0.5, vp.height * 0.86, i % 2 ? 70 : -70, 0] : [vp.width * 0.2, vp.height * 0.8, i % 2 ? 60 : -60, -40];
|
|
150
|
+
await players[1].page.touchscreen.touchStart(x, y).catch(() => {});
|
|
151
|
+
await players[1].page.touchscreen.touchMove(x + dx, y + dy).catch(() => {});
|
|
81
152
|
await sleep(300);
|
|
82
153
|
await players[1].page.touchscreen.touchEnd().catch(() => {});
|
|
83
|
-
if (
|
|
154
|
+
if (report?.stopped?.()) break;
|
|
84
155
|
}
|
|
85
156
|
})();
|
|
86
157
|
if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-playing.png`) }).catch(() => {});
|
|
87
|
-
|
|
88
|
-
|
|
158
|
+
await sleep(1500);
|
|
159
|
+
await picture('Playing: computer and phone in one room');
|
|
160
|
+
// Only a round that finishes AFTER both browsers were seated, seen by both, with BOTH of them in its results,
|
|
161
|
+
// counts: an idle room remembers its last round for a while, and that stale result proves nothing. A browser is
|
|
162
|
+
// in a round when a row carries a seat it held (a reconnect keeps its seat; a game may still mark an idle
|
|
163
|
+
// person's row as a bot, and that is still the person). A round finished without one of them is a miss: its
|
|
164
|
+
// cause is kept, and the check waits for the next round, up to `rounds` of them.
|
|
89
165
|
const bothSeated = Math.max(...players.map((p) => p.seatedAt));
|
|
90
|
-
const seats =
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
166
|
+
for (const p of players) { p.seats = new Set([p.seat]); p.reconnects = null; p.reconnects0 = null; p.closed = null; p.seen = new Map(); }
|
|
167
|
+
const t1 = Date.now();
|
|
168
|
+
const budget = roundTimeoutMs * Math.max(1, rounds);
|
|
169
|
+
const missed = new Map();
|
|
170
|
+
let hit = null;
|
|
171
|
+
while (!hit && Date.now() - t1 < budget && missed.size < Math.max(1, rounds)) {
|
|
172
|
+
for (const p of players) {
|
|
173
|
+
const s = await p.page.evaluate(() => {
|
|
174
|
+
const sh = window.__shell;
|
|
175
|
+
return sh ? { seat: sh.seat, room: sh.room, closed: sh.closed, reconnects: sh.stats?.reconnects ?? null, results: (sh.results ?? []).map((x) => ({ n: x.n, endsAt: x.endsAt, at: x.at, results: x.results })) } : null;
|
|
176
|
+
}).catch(() => null);
|
|
177
|
+
if (!s) continue;
|
|
178
|
+
if (Number.isInteger(s.seat)) p.seats.add(s.seat);
|
|
179
|
+
if (Number.isFinite(s.reconnects)) { p.reconnects0 ??= s.reconnects; p.reconnects = s.reconnects; }
|
|
180
|
+
if (s.closed) p.closed = s.closed;
|
|
181
|
+
for (const r of s.results) if (r.at > bothSeated && Array.isArray(r.results) && !p.seen.has(`${r.n}:${r.endsAt}`)) p.seen.set(`${r.n}:${r.endsAt}`, { ...r, room: s.room, ms: Date.now() - p.t0 });
|
|
101
182
|
}
|
|
102
|
-
|
|
103
|
-
|
|
183
|
+
const before = missed.size;
|
|
184
|
+
hit = judgeRounds(players, missed);
|
|
185
|
+
for (const m of [...missed.values()].slice(before)) log(`round ${m.n} finished without the ${m.missing.join(' and the ')}: waiting for the next one`);
|
|
186
|
+
if (hit) break;
|
|
187
|
+
halt();
|
|
188
|
+
await sleep(700);
|
|
189
|
+
}
|
|
190
|
+
playingOn = false;
|
|
104
191
|
await playing.catch(() => {});
|
|
105
192
|
if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-round-over.png`) }).catch(() => {});
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
193
|
+
const connection = players.map((p) => ({ browser: p.kind, seats: [...p.seats], reconnects: p.reconnects !== null && p.reconnects0 !== null ? p.reconnects - p.reconnects0 : null, closed: p.closed }));
|
|
194
|
+
const load = { perCore: +(loadavg()[0] / Math.max(1, cpus().length)).toFixed(2), average: +loadavg()[0].toFixed(1), cores: cpus().length };
|
|
195
|
+
if (!hit) {
|
|
196
|
+
const misses = [...missed.values()];
|
|
197
|
+
const dropped = connection.filter((c) => c.reconnects > 0).map((c) => `the ${c.browser} lost its connection ${c.reconnects} time${c.reconnects === 1 ? '' : 's'}`);
|
|
198
|
+
const why = misses.length
|
|
199
|
+
? `${misses.length} round${misses.length === 1 ? '' : 's'} finished, and none had both browsers in it: ${misses.map((m) => `round ${m.n} left out the ${m.missing.join(' and the ')}`).join('; ')}.${dropped.length ? ` ${dropped.join(', ')} (a browser silent for 10 s is let go by the relay and its body plays as a bot until it is back).` : ' The game listed that seat\'s body as a bot with no seat: it may hand an idle player to a bot.'}${load.perCore > 1.5 ? ` This computer is busy (load ${load.average} on ${load.cores} cores): run the check again when it is quieter.` : ''}`
|
|
200
|
+
: `no round finished within ${Math.round(budget / 1000)} s of both browsers being seated${load.perCore > 1.5 ? ` (this computer is busy: load ${load.average} on ${load.cores} cores)` : ''}`;
|
|
201
|
+
step('round', 'fail', { note: misses.length ? `${misses.length} round(s) without both` : 'no round finished in time' });
|
|
202
|
+
return { ok: false, command: 'check', why, play, room: seated[0].room, seated, missed: misses, connection, load, totalMs: Date.now() - started };
|
|
203
|
+
}
|
|
204
|
+
const humans = hit.people;
|
|
205
|
+
step('round', humans >= 2 ? 'pass' : 'fail', { ms: Math.max(...hit.overMs), note: `round ${hit.n}: ${humans} people, ${hit.results.length - humans} bots${missed.size ? ` (after ${missed.size} round(s) without both)` : ''}` });
|
|
206
|
+
await picture(`Round ${hit.n} finished with both; the next one is on`);
|
|
109
207
|
return {
|
|
110
|
-
ok: sameRoom &&
|
|
208
|
+
ok: sameRoom && humans >= 2,
|
|
111
209
|
command: 'check',
|
|
112
210
|
play,
|
|
113
211
|
room: seated[0].room,
|
|
114
212
|
seats: seated.map((s, i) => ({ browser: kinds[i].name, seat: s.seat, role: s.role, seatedMs: s.ms })),
|
|
115
|
-
round: { n:
|
|
116
|
-
|
|
213
|
+
round: { n: hit.n, humans, bots: hit.results.length - humans, results: hit.results, overMs: hit.overMs },
|
|
214
|
+
...(missed.size ? { missed: [...missed.values()] } : {}),
|
|
215
|
+
connection,
|
|
216
|
+
why: humans >= 2 ? undefined : `the round's results list ${humans} person(s); both browsers should be in it`,
|
|
117
217
|
totalMs: Date.now() - started,
|
|
118
218
|
};
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (!(error instanceof Stopped)) throw error;
|
|
221
|
+
for (const [id] of CHECK_STEPS) { try { if (['pending', 'running'].includes(report?.state?.(id))) step(id, 'skip', { note: 'stopped' }); } catch { /* fine */ } }
|
|
222
|
+
return { ok: false, command: 'check', stopped: true, why: 'stopped by the person', play, totalMs: Date.now() - started };
|
|
119
223
|
} finally {
|
|
224
|
+
playingOn = false;
|
|
120
225
|
for (const b of browsers) await b.close().catch(() => {});
|
|
121
226
|
for (const p of profiles) rmSync(p, { recursive: true, force: true });
|
|
122
227
|
}
|