@homie-rocks/studio 0.8.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 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.8.0.tgz homie-studio new ./night-owls --name "Night Owls"
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. Every HTML answer is `no-transform`, and the site's pages are never framed by another
37
- site.
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
 
@@ -135,7 +158,8 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
135
158
  | `lib/scaffold.mjs` | `new`: the studio monorepo, only into a new or empty folder, every file listed. |
136
159
  | `lib/build.mjs` | `build`: games bundled with esbuild into `site/dist`, `games.json`, each game's shared `source.json`. |
137
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. |
138
- | `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. |
139
163
  | `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
140
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. |
141
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`. |
@@ -23,6 +23,10 @@
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)
@@ -61,6 +65,7 @@ import { importPort, planPort } from '../lib/port.mjs';
61
65
  import { portCheck } from '../lib/port-check.mjs';
62
66
  import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
63
67
  import { ensureStatsMigration, newStudio } from '../lib/scaffold.mjs';
68
+ import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
64
69
  import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters } from '../lib/studio.mjs';
65
70
  import { STUDIO_VERSION } from '../lib/version.mjs';
66
71
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
@@ -68,7 +73,7 @@ import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, star
68
73
 
69
74
  const argv = process.argv.slice(2);
70
75
  const flags = new Map();
71
- const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share'];
76
+ const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff'];
72
77
  const positional = [];
73
78
  for (let i = 0; i < argv.length; i++) {
74
79
  const a = argv[i];
@@ -198,6 +203,33 @@ function print(result) {
198
203
  case 'look':
199
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}`);
200
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
+ }
201
233
  case 'check':
202
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.`,
203
235
  ...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`));
@@ -237,6 +269,10 @@ async function main() {
237
269
  const studio = readStudio(root);
238
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)) };
239
271
  }
272
+ if (cmd === 'upgrade') {
273
+ const plan = upgradePlan(root);
274
+ return flags.has('apply') || flags.has('yes') ? upgradeApply(root, plan) : plan;
275
+ }
240
276
  if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
241
277
  if (cmd === 'dev') return dev(root);
242
278
  if (cmd === 'check') {
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
@@ -8,9 +8,16 @@
8
8
  * `report` (optional; the open progress feed, lib/progress.mjs) hears each step as it goes green or red, gets a
9
9
  * small picture of the computer's view, and can ask the check to stop: then the browsers close and the result
10
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".
11
18
  */
12
19
  import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
13
- import { tmpdir } from 'node:os';
20
+ import { cpus, loadavg, tmpdir } from 'node:os';
14
21
  import { join } from 'node:path';
15
22
 
16
23
  const CHROMES = [
@@ -23,6 +30,9 @@ const CHROMES = [
23
30
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
24
31
 
25
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
+
26
36
  export const CHECK_STEPS = [
27
37
  ['computer-seated', 'A computer gets a seat'],
28
38
  ['phone-seated', 'A phone gets a seat'],
@@ -32,7 +42,26 @@ export const CHECK_STEPS = [
32
42
 
33
43
  class Stopped extends Error {}
34
44
 
35
- export async function check({ url, game, roundTimeoutMs = 150_000, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
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 }) {
36
65
  if (!url || !game) throw new Error('usage: homie-studio check <game> --url <site url>');
37
66
  const chrome = CHROMES.find((p) => existsSync(p));
38
67
  if (!chrome) return { ok: false, command: 'check', why: 'no Chrome found (set CHROME_PATH)' };
@@ -49,6 +78,8 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
49
78
  { name: 'phone', viewport: { width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 } },
50
79
  ];
51
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;
52
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 */ } };
53
84
  const halt = () => { if (report?.stopped?.()) throw new Stopped('stopped by the person'); };
54
85
  // The computer's view, small (480 px wide), for the feed's preview.
@@ -69,7 +100,7 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
69
100
  const profile = mkdtempSync(join(tmpdir(), 'homie-studio-check-'));
70
101
  profiles.push(profile);
71
102
  const browser = await puppeteer.launch({
72
- executablePath: chrome, headless: true, userDataDir: profile,
103
+ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, protocolTimeout: 180_000,
73
104
  args: ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist', '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
74
105
  });
75
106
  browsers.push(browser);
@@ -78,13 +109,13 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
78
109
  const ua = await browser.userAgent();
79
110
  await page.setUserAgent(`${ua} ${userAgentTag}`);
80
111
  const t0 = Date.now();
81
- await page.goto(play, { waitUntil: 'domcontentloaded', timeout: 60_000 });
112
+ await page.goto(play, { waitUntil: 'domcontentloaded', timeout: 90_000 });
82
113
  players.push({ kind: kind.name, page, t0 });
83
114
  log(`${kind.name} opened ${play}`);
84
115
  }
85
116
  // Seated: the shell knows its room and the game reported a seat.
86
117
  const seated = await Promise.all(players.map(async (p) => {
87
- const deadline = Date.now() + 45_000;
118
+ const deadline = Date.now() + 90_000;
88
119
  while (Date.now() < deadline) {
89
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);
90
121
  if (s?.room && s.seat !== null && s.seat !== undefined && s.role) {
@@ -95,67 +126,94 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
95
126
  halt();
96
127
  await sleep(250);
97
128
  }
98
- step(`${p.kind}-seated`, 'fail', { note: 'no seat within 45 s' });
129
+ step(`${p.kind}-seated`, 'fail', { note: 'no seat within 90 s' });
99
130
  return null;
100
131
  }));
101
- if (seated.some((s) => !s)) return { ok: false, command: 'check', why: 'a browser never got a seat within 45 s', seated };
132
+ if (seated.some((s) => !s)) return { ok: false, command: 'check', why: 'a browser never got a seat within 90 s', seated };
102
133
  const sameRoom = seated[0].room === seated[1].room;
103
134
  log(`seated: ${seated.map((s, i) => `${kinds[i].name} room ${s.room} seat ${s.seat} ${s.role}`).join('; ')}`);
104
135
  step('same-room', sameRoom ? 'pass' : 'fail', { note: sameRoom ? `room ${seated[0].room}` : `rooms ${seated[0].room} and ${seated[1].room}` });
105
136
  if (!sameRoom) return { ok: false, command: 'check', why: `the two browsers landed in different rooms (${seated[0].room}, ${seated[1].room})`, seated };
106
137
  step('round', 'running');
107
- // Play: the computer holds keys, the phone drags from the lower left.
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.
108
141
  const playing = (async () => {
109
142
  const keys = ['KeyD', 'KeyS', 'KeyA', 'KeyW'];
110
- for (let i = 0; Date.now() - started < roundTimeoutMs && i < 400; i++) {
143
+ const vp = kinds[1].viewport;
144
+ for (let i = 0; playingOn && i < 5000; i++) {
111
145
  const k = keys[i % keys.length];
112
146
  await players[0].page.keyboard.down(k).catch(() => {});
113
147
  await sleep(600);
114
148
  await players[0].page.keyboard.up(k).catch(() => {});
115
- const vp = kinds[1].viewport;
116
- await players[1].page.touchscreen.touchStart(vp.width * 0.2, vp.height * 0.8).catch(() => {});
117
- await players[1].page.touchscreen.touchMove(vp.width * 0.2 + (i % 2 ? 60 : -60), vp.height * 0.8 - 40).catch(() => {});
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(() => {});
118
152
  await sleep(300);
119
153
  await players[1].page.touchscreen.touchEnd().catch(() => {});
120
- if (players.every((p) => p.over) || report?.stopped?.()) break;
154
+ if (report?.stopped?.()) break;
121
155
  }
122
156
  })();
123
157
  if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-playing.png`) }).catch(() => {});
124
158
  await sleep(1500);
125
159
  await picture('Playing: computer and phone in one room');
126
- // Only a round that finishes AFTER both browsers were seated, with BOTH of them in its results, counts: an
127
- // idle room remembers its last round for a while, and that stale result proves nothing.
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.
128
165
  const bothSeated = Math.max(...players.map((p) => p.seatedAt));
129
- const seats = players.map((p) => p.seat);
130
- const over = await Promise.all(players.map(async (p) => {
131
- const deadline = Date.now() + roundTimeoutMs;
132
- while (Date.now() < deadline) {
133
- const r = await p.page.evaluate((after, want) => {
134
- const s = window.__shell;
135
- const hit = (s?.results ?? []).find((x) => x.at > after && want.every((seat) => x.results.some((row) => row.seat === seat)));
136
- return hit ? { n: hit.n, endsAt: hit.endsAt, results: hit.results, room: s.room } : null;
137
- }, bothSeated, seats).catch(() => null);
138
- if (r) { p.over = true; return { ...r, ms: Date.now() - p.t0 }; }
139
- halt();
140
- await sleep(500);
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 });
141
182
  }
142
- return null;
143
- }));
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;
144
191
  await playing.catch(() => {});
145
192
  if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-round-over.png`) }).catch(() => {});
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 }; }
147
- const humans = over[0].results.filter((r) => !r.bot).length;
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`);
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`);
151
207
  return {
152
- ok: sameRoom && sameRound && humans >= 2,
208
+ ok: sameRoom && humans >= 2,
153
209
  command: 'check',
154
210
  play,
155
211
  room: seated[0].room,
156
212
  seats: seated.map((s, i) => ({ browser: kinds[i].name, seat: s.seat, role: s.role, seatedMs: s.ms })),
157
- round: { n: over[0].n, humans, bots: over[0].results.length - humans, results: over[0].results, overMs: over.map((o) => o.ms) },
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`,
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`,
159
217
  totalMs: Date.now() - started,
160
218
  };
161
219
  } catch (error) {
@@ -163,6 +221,7 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
163
221
  for (const [id] of CHECK_STEPS) { try { if (['pending', 'running'].includes(report?.state?.(id))) step(id, 'skip', { note: 'stopped' }); } catch { /* fine */ } }
164
222
  return { ok: false, command: 'check', stopped: true, why: 'stopped by the person', play, totalMs: Date.now() - started };
165
223
  } finally {
224
+ playingOn = false;
166
225
  for (const b of browsers) await b.close().catch(() => {});
167
226
  for (const p of profiles) rmSync(p, { recursive: true, force: true });
168
227
  }
package/lib/look.mjs CHANGED
@@ -36,8 +36,9 @@ export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVIC
36
36
  const base = String(url).replace(/\/+$/, '');
37
37
  mkdirSync(shots, { recursive: true });
38
38
  const profile = mkdtempSync(join(tmpdir(), 'homie-studio-look-'));
39
+ // 150 s for Chrome to start: on a loaded computer a cold start has taken over a minute.
39
40
  const browser = await puppeteer.launch({
40
- executablePath: chrome, headless: true, userDataDir: profile,
41
+ executablePath: chrome, headless: true, userDataDir: profile, timeout: 150_000, protocolTimeout: 180_000,
41
42
  args: ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist', '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
42
43
  });
43
44
  const rows = [];
@@ -51,7 +52,7 @@ export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVIC
51
52
  await page.setViewport(LOOK_DEVICES[device]);
52
53
  await page.setUserAgent(`${await browser.userAgent()} homie-studio-check look`);
53
54
  const t0 = Date.now();
54
- const res = await page.goto(`${base}${path}`, { waitUntil: 'networkidle2', timeout: 45_000 }).catch((e) => { errors.push(`open: ${e.message}`); return null; });
55
+ const res = await page.goto(`${base}${path}`, { waitUntil: 'networkidle2', timeout: 90_000 }).catch((e) => { errors.push(`open: ${e.message}`); return null; });
55
56
  const ms = Date.now() - t0;
56
57
  await sleep(900);
57
58
  const fold = await page.evaluate(() => {
package/lib/media.mjs CHANGED
@@ -50,6 +50,11 @@ export function resolveMedia(root, kind, { r2 = false } = {}) {
50
50
  const entries = [];
51
51
  const skipped = [];
52
52
  const seen = new Set();
53
+ // The manifest's own picture (music: `cover`, videos: `poster`): an album cover for every song that has none of
54
+ // its own. A path in the studio (copied into the site) or an https:// address, like any file.
55
+ const artRole = kind === 'music' ? 'cover' : 'poster';
56
+ const shared = manifest[artRole];
57
+ const sharedFile = typeof shared === 'string' ? (/^https:\/\//.test(shared) ? { url: shared } : { path: shared }) : shared && typeof shared === 'object' ? shared : null;
53
58
  for (const item of manifest.items) {
54
59
  if (!item || typeof item !== 'object') continue;
55
60
  const label = item.slug ?? item.key ?? item.file ?? '(unnamed)';
@@ -58,7 +63,9 @@ export function resolveMedia(root, kind, { r2 = false } = {}) {
58
63
  seen.add(item.slug);
59
64
  if (item.published !== true) { skipped.push({ kind, item: label, why: 'not published yet ("published": true shows it)' }); continue; }
60
65
  const files = [];
61
- for (const f of Array.isArray(item.files) ? item.files : []) {
66
+ const own = Array.isArray(item.files) ? item.files : [];
67
+ const list = sharedFile && !own.some((f) => f?.role === artRole) ? [...own, { ...sharedFile, role: artRole }] : own;
68
+ for (const f of list) {
62
69
  if (!f || f.public === false) continue;
63
70
  let src = null;
64
71
  if (typeof f.key === 'string' && f.key && r2) src = { from: 'r2', url: `/media/${f.key.split('/').map(encodeURIComponent).join('/')}` };
@@ -197,7 +197,8 @@ function descendants(pid) {
197
197
  async function chrome(puppeteer, kind, exe) {
198
198
  const dir = mkdtempSync(join(tmpdir(), 'homie-port-check-'));
199
199
  const vp = kind === 'phone' ? { width: 412, height: 915 } : kind === 'tv' ? { width: 1280, height: 720 } : { width: 1280, height: 800 };
200
- const browser = await puppeteer.launch({ executablePath: exe, headless: true, userDataDir: dir, protocolTimeout: 120_000, args: [...GPU, `--window-size=${vp.width},${vp.height}`] });
200
+ // 150 s for Chrome to start: on a loaded computer a cold start has taken over a minute.
201
+ const browser = await puppeteer.launch({ executablePath: exe, headless: true, userDataDir: dir, timeout: 150_000, protocolTimeout: 120_000, args: [...GPU, `--window-size=${vp.width},${vp.height}`] });
201
202
  const page = (await browser.pages())[0] ?? await browser.newPage();
202
203
  if (kind === 'phone') await page.emulate({ viewport: { ...vp, deviceScaleFactor: 2.625, isMobile: true, hasTouch: true }, userAgent: `${ANDROID_UA} ${TAG}` });
203
204
  else { await page.setViewport({ ...vp, deviceScaleFactor: 1 }); await page.setUserAgent(`${await browser.userAgent()} ${TAG}`); }
@@ -215,7 +216,7 @@ async function chrome(puppeteer, kind, exe) {
215
216
  }
216
217
 
217
218
  async function webkit(pw) {
218
- const browser = await pw.webkit.launch({ headless: true });
219
+ const browser = await pw.webkit.launch({ headless: true, timeout: 150_000 });
219
220
  const dev = pw.devices['iPhone 15'];
220
221
  const ctx = await browser.newContext({ ...dev, userAgent: `${dev.userAgent} ${TAG}` });
221
222
  const page = await ctx.newPage();
package/lib/scaffold.mjs CHANGED
@@ -116,6 +116,10 @@ studio's pinned copy, never a registry lookup of the bare name.
116
116
  \`studio_stats\` (never paste a key anywhere else). \`stats share on\` tells the directory two numbers
117
117
  (played this week). The site counts and never tracks: no cookie on a visitor, no person identified,
118
118
  nothing sent anywhere; house QA and \`check\` runs are not counted.
119
+ - \`npx --no-install homie-studio upgrade\` — after pinning a newer \`@homie-rocks/studio\` (or through
120
+ \`npx -y --package=<its tarball> homie-studio upgrade\`): what the newer template adds to this studio (AGENTS.md
121
+ sections, READMEs, .gitignore lines) and what it keeps. It changes nothing until \`--apply\`, and never
122
+ touches a file or section this studio changed; \`--diff\` shows how those differ from the template's.
119
123
 
120
124
  ## The site
121
125
 
@@ -132,7 +136,11 @@ studio's pinned copy, never a registry lookup of the bare name.
132
136
  (\`pitch\`, \`about\`, \`controls\`, \`howToPlay\`, \`credits\`) and art with the plugin's \`art\` and \`video\` skills.
133
137
  - **The look** is \`site/theme.json\` (colours, fonts, corner radius, a logo). Anything in \`site/\` wins: a whole
134
138
  page in \`site/pages/\`, a piece of every page in \`site/partials/\`, files in \`site/public/\`, extra CSS in
135
- \`site/theme.css\` (\`site/README.md\`).
139
+ \`site/theme.css\` (\`site/README.md\`). A game whose picture is white or cream gets a light landing with
140
+ game.json \`"landing": { "scheme": "light" }\` (a dark tint would turn it grey).
141
+ - **Cards and the directory** show each game's landing still (\`hero/wide.jpg\`), else its cover. A song without
142
+ a cover of its own shows the music manifest's \`cover\`, else its game's still.
143
+ - **Live rooms** are listed on homie.rocks too; studio.json \`"rooms": { "share": false }\` keeps them off it.
136
144
  - Every page ends with "Made with Homie", linking to homie.rocks/studio/. Restyle it in \`site/theme.css\`; keep it.
137
145
 
138
146
  ## Making games
@@ -144,6 +152,10 @@ studio's pinned copy, never a registry lookup of the bare name.
144
152
  - Import it as \`import { createNetplay } from '@homie-rocks/studio/netplay'\`.
145
153
  - Phones and computers: touch controls on phones only, keys on computers; keep the
146
154
  centre of the screen clear during play.
155
+ - The play page's small room button (Invite, Big screen, the room code) sits top right. If the game's
156
+ scoreboard or a bar is there, move it in game.json \`"screen": { "share": … }\`: a corner or \`top-center\`, per
157
+ device (\`desk\`, \`phone\`, \`sideways\`), with an \`x\` / \`y\` offset in pixels, and \`"label": false\` to keep it
158
+ a small icon (\`node_modules/@homie-rocks/studio/site/SITE.md\`). Look at it on a phone and a computer.
147
159
  - Change a game in small steps, build, and look at it (\`dev\`, then \`check\`).
148
160
  - A game's id is its URL (\`/<id>/\`); keep it once published.
149
161
 
package/lib/site.mjs CHANGED
@@ -416,6 +416,12 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
416
416
  if (texts.length) { mkdirSync(landDir, { recursive: true }); writeFileSync(join(landDir, 'credits.json'), `${JSON.stringify({ v: 1, texts })}\n`); }
417
417
 
418
418
  const words = L.players && typeof L.players === 'object' ? { one: str(L.players.one, 24), many: str(L.players.many, 24) } : null;
419
+ // The landing's own colours (landing.theme): only colours, and the ink on its own accent worked out like the studio's.
420
+ const theme = L.theme && typeof L.theme === 'object' ? Object.fromEntries(['accent', 'glow', 'bg', 'fg'].filter((k) => COLOR.test(String(L.theme[k] ?? ''))).map((k) => [k, L.theme[k]])) : null;
421
+ if (theme?.accent) { const l = luminance(theme.accent); if (l !== null) theme.accentInk = l > 0.4 ? '#0b0b10' : '#ffffff'; }
422
+ // landing.scheme: "light" for a white or cream game (its hero is tinted light and its words dark), "dark" for a
423
+ // dark game in a light studio. Anything else follows the studio's look.
424
+ if (L.scheme !== undefined && L.scheme !== 'light' && L.scheme !== 'dark') log(`warning: games/${g.id}/game.json landing.scheme is "light" or "dark"; left out`);
419
425
  return {
420
426
  pitch: str(L.pitch, 240) ?? str(credits.tagline, 240),
421
427
  kicker: str(L.kicker, 120),
@@ -431,7 +437,8 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
431
437
  music: forGame(songs).map((e) => e.slug),
432
438
  source: g.share?.source !== false,
433
439
  tv: g.screen?.tv !== false && L.tv !== false,
434
- theme: L.theme && typeof L.theme === 'object' ? Object.fromEntries(['accent', 'glow', 'bg', 'fg'].filter((k) => COLOR.test(String(L.theme[k] ?? ''))).map((k) => [k, L.theme[k]])) : null,
440
+ theme: theme && Object.keys(theme).length ? theme : null,
441
+ ...(L.scheme === 'light' || L.scheme === 'dark' ? { scheme: L.scheme } : {}),
435
442
  };
436
443
  }
437
444