@homie-rocks/studio 0.5.0 → 0.7.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.5.0.tgz homie-studio new ./night-owls --name "Night Owls"
13
+ npx -y --package=https://homie.rocks/npm/homie-studio-0.7.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
@@ -18,8 +18,58 @@ npx homie-studio check crown-thief --url http://127.0.0.1:8787
18
18
  npx homie-studio deploy --plan # what deploy will create, and what it costs; changes nothing
19
19
  npx homie-studio deploy # the studio's own Cloudflare
20
20
  npx homie-studio publish # the homie.rocks directory
21
+ npx homie-studio stats # the studio's own numbers, for its owner
21
22
  ```
22
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
+
39
+ ## Rooms of up to 32
40
+
41
+ Every room of a game has the seats its netplay manifest names: game.json's `netplay`
42
+ block (`maxPlayers`), or a `netplay.json` beside game.json or in the game's build
43
+ (`maxPlayers` or `players.max`), else game.json's `players.max`, else 8; at most 32.
44
+ Bots, a late joiner taking a bot's body and a new host after the old one leaves work
45
+ the same at 32 as at 4. One address may hold every seat plus four sockets, so a party
46
+ on one Wi-Fi (or strangers behind one carrier's address) fills a room with a TV beside it.
47
+
48
+ ## The studio's own stats
49
+
50
+ The site counts, and never tracks: pages opened, Play presses, rooms opened, the most
51
+ people playing at once, rounds finished, songs played, videos watched, and which site
52
+ sent each visitor (a host name: homie.rocks, another studio, search, the web, or a
53
+ `?via=` tag). They are daily counters in the studio's own D1, on the free plan (a write
54
+ per visit and per round, never per frame), with no cookie on a visitor and nothing sent
55
+ to anyone. Prefetches, crawlers and house QA are not counted. Only the owner reads them:
56
+
57
+ ```sh
58
+ npx homie-studio stats [--range 7d] [--game crown-thief] # prints them
59
+ npx homie-studio stats link # a one-time link: /_studio/stats in the owner's browser
60
+ npx homie-studio stats key # a 1-hour read key for the Homie MCP tool studio_stats
61
+ npx homie-studio stats share on # tell the directory "played this week" (two numbers)
62
+ ```
63
+
64
+ Each key is minted on the owner's computer; only its SHA-256 goes into D1, through the
65
+ studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
66
+
67
+ ## The site's address
68
+
69
+ `deploy` keeps the `workers.dev` address in `.studio/local.json`, which git ignores: it
70
+ names the Cloudflare account, often after its owner. A custom domain goes in studio.json
71
+ as `cloudflare.domain` and is what the directory claim, `publish`, `check` and `stats` use.
72
+
23
73
  ## No payment method needed
24
74
 
25
75
  `deploy` creates only what Cloudflare's free Workers plan gives a new account with no
@@ -61,7 +111,9 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
61
111
  | `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
62
112
  | `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. |
63
113
  | `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`. |
64
- | `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>`). |
114
+ | `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). |
115
+ | `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. |
116
+ | `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`. |
65
117
  | `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. |
66
118
  | `netplay/` | The netplay contract (`NETPLAY.md`) and its game helper (`@homie-rocks/studio/netplay`). |
67
119
  | `starters/gem-rush/` | The reference multiplayer starter. |
@@ -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>]
@@ -21,6 +23,11 @@
21
23
  * homie-studio media put <file> [--as <key>]
22
24
  * homie-studio media list
23
25
  * homie-studio status
26
+ * homie-studio stats [--range 1d|7d|30d|90d] [--game <id> | --song <slug> | --video <slug>] [--url <site>]
27
+ * homie-studio stats key [--hours 1] (a read key for the Homie MCP tool studio_stats)
28
+ * homie-studio stats link (a one-time link to the private stats page, for the owner's browser)
29
+ * homie-studio stats revoke (every stats key and page session ends)
30
+ * homie-studio stats share on|off (tell the homie.rocks directory "played this week", or stop)
24
31
  *
25
32
  * Every command prints a few lines for a person; --json prints the result.
26
33
  */
@@ -31,12 +38,14 @@ import { build } from '../lib/build.mjs';
31
38
  import { check } from '../lib/check.mjs';
32
39
  import { deploy, deployPlan, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
33
40
  import { publish } from '../lib/directory.mjs';
41
+ import { look } from '../lib/look.mjs';
34
42
  import { importPort, planPort } from '../lib/port.mjs';
35
43
  import { portCheck } from '../lib/port-check.mjs';
36
44
  import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
37
- import { newStudio } from '../lib/scaffold.mjs';
38
- import { listGames, newGame, readStudio, remixGame, requireStudio, starters } from '../lib/studio.mjs';
45
+ import { ensureStatsMigration, newStudio } from '../lib/scaffold.mjs';
46
+ import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters } from '../lib/studio.mjs';
39
47
  import { STUDIO_VERSION } from '../lib/version.mjs';
48
+ import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
40
49
 
41
50
  const argv = process.argv.slice(2);
42
51
  const flags = new Map();
@@ -56,7 +65,7 @@ const log = asJson ? () => {} : (line) => process.stderr.write(`${line}\n`);
56
65
 
57
66
  function print(result) {
58
67
  if (asJson) { process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; }
59
- if (result.ok === false && result.command !== 'port check') { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
68
+ if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
60
69
  if (result.ok === false && result.command === 'port check' && !result.rows) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
61
70
  const lines = [];
62
71
  switch (result.command) {
@@ -75,7 +84,12 @@ function print(result) {
75
84
  case 'build':
76
85
  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}`);
77
86
  if (result.songs?.length || result.videos?.length) lines.push(`Media pages: ${[...result.songs.map((x) => `/music/${x}/`), ...result.videos.map((x) => `/videos/${x}/`)].join(', ')}`);
87
+ 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' : ''}`);
88
+ if (result.posts?.length) lines.push(`Posts: ${result.posts.map((x) => `/posts/${x}/`).join(', ')} (feeds: /posts/feed.xml, /posts/feed.json)`);
89
+ 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(', ')}`);
78
90
  for (const m of result.mediaSkipped ?? []) lines.push(` left out: ${m.kind}/${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
91
+ for (const m of result.postsSkipped ?? []) lines.push(` left out: posts/${m.post}: ${m.why}`);
92
+ for (const m of result.siteSkipped ?? []) lines.push(` left out: ${m.what}: ${m.why}`);
79
93
  break;
80
94
  case 'media list':
81
95
  for (const kind of ['music', 'videos']) {
@@ -85,7 +99,9 @@ function print(result) {
85
99
  }
86
100
  break;
87
101
  case 'deploy':
88
- lines.push(`Live: ${result.url}`, ...result.games.map((g) => ` ${g.id}: ${g.play}`), ...(result.songs ?? []).map((m) => ` song ${m.slug}: ${m.page}`), ...(result.videos ?? []).map((m) => ` video ${m.slug}: ${m.page}`), '', `Cloudflare: Worker ${result.worker}, D1 ${result.d1}, Durable Objects Table + Lobby${result.r2 ? `, R2 ${result.r2}` : ' (no storage: none needed; `homie-studio storage add` adds it for large media)'}. All on the free Workers plan${result.r2 ? ' plus R2' : ''}.`,
102
+ lines.push(`Live: ${result.url}`, ...(result.workersDev && result.workersDev !== result.url ? [` also at ${result.workersDev}`] : []),
103
+ ...(result.local ? [` (the workers.dev address names your Cloudflare account, so it is kept in ${result.local} on this computer, never in studio.json)`] : []),
104
+ ...result.games.map((g) => ` ${g.id}: ${g.play}`), ...(result.songs ?? []).map((m) => ` song ${m.slug}: ${m.page}`), ...(result.videos ?? []).map((m) => ` video ${m.slug}: ${m.page}`), '', `Cloudflare: Worker ${result.worker}, D1 ${result.d1}, Durable Objects Table + Lobby${result.r2 ? `, R2 ${result.r2}` : ' (no storage: none needed; `homie-studio storage add` adds it for large media)'}. All on the free Workers plan${result.r2 ? ' plus R2' : ''}.`,
89
105
  result.claim ? 'Directory claim stored: list the games with the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish' : 'No directory claim yet (the directory did not answer); publish will try again.');
90
106
  break;
91
107
  case 'deploy plan':
@@ -116,6 +132,32 @@ function print(result) {
116
132
  lines.push(`${result.ok ? 'PASS' : 'NOT YET'}: ${result.game} at ${result.url} (${Math.round(result.totalMs / 1000)} s)`,
117
133
  ...result.passed.map((p) => ` ok ${p}`), ...result.failed.map((f) => ` FAIL ${f}`), ...result.skipped.map((f) => ` skip ${f}`), '', `Receipt and screenshots: ${result.out}`);
118
134
  break;
135
+ case 'stats': {
136
+ const t = result.totals;
137
+ const n = (x) => Number(x || 0).toLocaleString('en-US');
138
+ lines.push(`${result.studio ?? 'Studio'}: ${result.range.from} to ${result.range.to} (UTC)${result.only ? `, ${result.only.kind} ${result.only.id}` : ''}`,
139
+ ` ${n(t.visits)} visits · ${n(t.plays)} Play presses · ${n(t.rooms)} rooms opened · ${n(t.rounds)} rounds finished (${n(t.roundsWithPeople)} with people, ${n(t.peopleInRounds)} people in them)`,
140
+ ` most playing at once: ${n(t.peakPlayers)} (${n(t.peakInOneRoom)} in one room) · playing now: ${n(t.playingNow)} · songs played: ${n(t.songPlays)} · videos watched: ${n(t.videoViews)}`,
141
+ ` came from homie.rocks: ${n(result.crossings.fromHub)} · other studios: ${n(result.crossings.fromStudios)} · search: ${n(result.crossings.fromSearch)} · the web: ${n(result.crossings.fromWeb)} · ?via= links: ${n(result.crossings.fromLinks)}`);
142
+ for (const g of result.games) lines.push(` game ${g.id}: ${n(g.visits)} visits, ${n(g.plays)} plays, ${n(g.rooms)} rooms, ${n(g.rounds)} rounds, peak ${n(g.peakPlayers)}, now ${n(g.playingNow)}`);
143
+ for (const e of result.songs) lines.push(` song ${e.slug}: ${n(e.visits)} page visits, played ${n(e.plays)}`);
144
+ for (const e of result.videos) lines.push(` video ${e.slug}: ${n(e.visits)} page visits, watched ${n(e.views)}`);
145
+ for (const r of result.referrers.slice(0, 10)) lines.push(` from ${r.from} (${r.kind}): ${n(r.visits)} visits, ${n(r.plays)} plays`);
146
+ break;
147
+ }
148
+ case 'stats key':
149
+ lines.push(`Read key (until ${result.expiresAt}): ${result.key}`, result.use);
150
+ break;
151
+ case 'stats link':
152
+ lines.push(`One-time link (until ${result.expiresAt}): ${result.link}`, result.use);
153
+ break;
154
+ case 'stats revoke':
155
+ case 'stats share':
156
+ lines.push(result.message);
157
+ break;
158
+ case 'look':
159
+ 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
+ break;
119
161
  case 'check':
120
162
  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.`,
121
163
  ...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`));
@@ -142,7 +184,7 @@ async function main() {
142
184
  if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
143
185
  if (cmd === 'port' && sub === 'check') {
144
186
  const game = positional[2] ?? listGames(root)[0]?.id;
145
- const url = flags.get('url') ?? readStudio(root).cloudflare?.url;
187
+ const url = flags.get('url') ?? siteUrl(root);
146
188
  if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address or the live site)' };
147
189
  return portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
148
190
  }
@@ -152,16 +194,29 @@ async function main() {
152
194
  if (cmd === 'build') return build(root, { only: positional[1] ?? null, log });
153
195
  if (cmd === 'status') {
154
196
  const studio = readStudio(root);
155
- return { ok: true, command: 'status', root, studio, games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
197
+ return { ok: true, command: 'status', root, studio, site: siteUrl(root, studio), games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
156
198
  }
157
199
  if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
158
200
  if (cmd === 'dev') return dev(root);
159
201
  if (cmd === 'check') {
160
202
  const game = positional[1] ?? listGames(root)[0]?.id;
161
- const url = flags.get('url') ?? readStudio(root).cloudflare?.url;
203
+ const url = flags.get('url') ?? siteUrl(root);
162
204
  if (!url) return { ok: false, command: 'check', why: 'give --url (the local dev address or the live site)' };
163
205
  return check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
164
206
  }
207
+ if (cmd === 'look') {
208
+ const url = flags.get('url') ?? siteUrl(root);
209
+ if (!url) return { ok: false, command: 'look', why: 'give --url (the local dev address or the live site)' };
210
+ let paths = positional.slice(1).map((p) => (p.startsWith('/') ? p : `/${p}`));
211
+ if (!paths.length) {
212
+ let cat = null;
213
+ try { cat = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')); } catch { /* not built */ }
214
+ const games = cat?.games?.map((g) => g.id) ?? listGames(root).map((g) => g.id);
215
+ paths = ['/', ...games.map((id) => `/${id}/`), ...(games.length ? ['/games/', '/rooms/'] : []), ...(cat?.posts?.length ? ['/posts/', `/posts/${cat.posts[0].slug}/`] : [])];
216
+ }
217
+ const only = flags.get('only') ? String(flags.get('only')).split(',').filter((d) => ['computer', 'phone', 'sideways'].includes(d)) : undefined;
218
+ return look({ url, paths, shots: flags.get('shots') ? resolve(flags.get('shots')) : join(root, '.studio', 'look'), devices: only, log });
219
+ }
165
220
  if (cmd === 'deploy' && flags.has('plan')) return deployPlan(root);
166
221
  if (cmd === 'deploy') return deploy(root, { log, homie: flags.get('homie') });
167
222
  if (cmd === 'storage' && sub === 'add') return storageAdd(root, { log });
@@ -171,12 +226,17 @@ async function main() {
171
226
  return { ok: true, command: 'storage', storage: has ? { kind: 'r2', bucket: cf.r2 } : null, why: has ? undefined : 'no storage yet: the studio runs without it; `homie-studio storage add` adds an R2 bucket for large media (Cloudflare asks for a payment method before R2 works)' };
172
227
  }
173
228
  if (cmd === 'publish') return publish(root, { homie: flags.get('homie'), site: flags.get('site') });
229
+ if (cmd === 'stats' && sub === 'key') return statsKey(root, { url: flags.get('url'), hours: flags.get('hours') });
230
+ if (cmd === 'stats' && sub === 'link') return statsLink(root, { url: flags.get('url') });
231
+ if (cmd === 'stats' && sub === 'revoke') return statsRevoke(root, { url: flags.get('url') });
232
+ if (cmd === 'stats' && sub === 'share') return statsShare(root, positional[2]);
233
+ if (cmd === 'stats' && !sub) return statsShow(root, { url: flags.get('url'), range: flags.get('range'), game: flags.get('game'), song: flags.get('song'), video: flags.get('video') });
174
234
  if (cmd === 'media' && sub === 'put') return mediaPut(root, positional[2], flags.get('as'));
175
235
  if (cmd === 'media' && sub === 'list') {
176
236
  const studio = readStudio(root);
177
237
  const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
178
238
  const view = (kind) => { const r = resolveMedia(root, kind, { r2 }); return { pages: r.entries.map((e) => ({ slug: e.slug, title: e.title, kind: e.kind, files: e.files.map(({ abs, rel, ...f }) => f) })), skipped: r.skipped }; };
179
- return { ok: true, command: 'media list', r2, site: studio.cloudflare?.url ?? null, music: view('music'), videos: view('videos') };
239
+ return { ok: true, command: 'media list', r2, site: siteUrl(root, studio), music: view('music'), videos: view('videos') };
180
240
  }
181
241
  return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
182
242
  }
@@ -222,6 +282,8 @@ async function dev(root) {
222
282
  if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
223
283
  const studio = readStudio(root);
224
284
  const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1' };
285
+ const added = ensureStatsMigration(root);
286
+ if (added) log(`added ${added} (the studio's own stats)`);
225
287
  await new Promise((done) => {
226
288
  const m = spawn(bin, ['d1', 'migrations', 'apply', studio.cloudflare.d1, '--local'], { cwd: join(root, 'site'), env, stdio: ['ignore', 'ignore', 'inherit'] });
227
289
  m.on('close', done);
@@ -253,8 +315,10 @@ function mediaPut(root, file, as) {
253
315
  const p = spawn(bin, ['r2', 'object', 'put', `${r2}/${key}`, '--file', resolve(file), '--content-type', typeOf(file), '--remote'], { cwd: join(root, 'site'), env: { ...process.env, CI: '1' }, stdio: ['ignore', 'ignore', 'inherit'] });
254
316
  p.on('close', (code) => {
255
317
  if (code !== 0) return done({ ok: false, command: 'media put', why: 'wrangler r2 object put failed' });
256
- const rec = recordUpload(root, rel, key, statSync(file).size, studio.cloudflare?.url ?? null);
257
- done({ ok: true, command: 'media put', key, manifest: rec.manifest, entry: rec.entry, url: studio.cloudflare?.url ? `${studio.cloudflare.url}/media/${key}` : null });
318
+ // The manifest is committed: it names the file by its path on the site, never the workers.dev address.
319
+ const rec = recordUpload(root, rel, key, statSync(file).size);
320
+ const site = siteUrl(root, studio);
321
+ done({ ok: true, command: 'media put', key, manifest: rec.manifest, entry: rec.entry, url: site ? `${site}/media/${key}` : null });
258
322
  });
259
323
  });
260
324
  }
@@ -264,7 +328,7 @@ try {
264
328
  if (result && result.command !== 'help') print(result);
265
329
  if (result?.ok === false) process.exitCode = 1;
266
330
  // Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
267
- if (result?.command === 'check' || result?.command === 'port check') process.exit(process.exitCode ?? 0);
331
+ if (result?.command === 'check' || result?.command === 'port check' || result?.command === 'look') process.exit(process.exitCode ?? 0);
268
332
  } catch (error) {
269
333
  print({ ok: false, why: error instanceof Error ? error.message : String(error) });
270
334
  process.exitCode = 1;
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, game.json
9
- * files and the music/ and videos/ manifests (media/MEDIA.md)
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,7 +26,9 @@ 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';
31
+ import { SEAT_MAX } from '../worker/seats.mjs';
26
32
  import { STUDIO_VERSION } from './version.mjs';
27
33
 
28
34
  const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
@@ -76,6 +82,31 @@ export function sourceOf(dir, id) {
76
82
  return { v: 1, kind: 'homie-game-source', id, files };
77
83
  }
78
84
 
85
+ function readJson(path) {
86
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
87
+ }
88
+
89
+ /**
90
+ * A game's netplay manifest: game.json's `netplay` block, over a `netplay.json` beside game.json or in the game's
91
+ * built output (a ported Vite game ships public/netplay.json), over nothing.
92
+ */
93
+ export function netplayOf(g, out = null) {
94
+ const file = readJson(join(g.dir, 'netplay.json')) ?? (out ? readJson(join(out, 'netplay.json')) : null) ?? {};
95
+ return { ...(file && typeof file === 'object' ? file : {}), ...(g.netplay && typeof g.netplay === 'object' ? g.netplay : {}) };
96
+ }
97
+
98
+ /**
99
+ * How many seats a room of this game has: the netplay manifest's `maxPlayers` (or `players.max`), else game.json's
100
+ * `players.max`, else 8; at most SEAT_MAX (32). `asked` is what the game named, so a build can say it was capped.
101
+ */
102
+ export function seatsFor(g, net = netplayOf(g)) {
103
+ const named = [net.maxPlayers, net.players?.max, g.players?.max].map((n) => Math.floor(Number(n))).find((n) => Number.isFinite(n) && n >= 1);
104
+ const asked = named ?? 8;
105
+ const max = Math.min(SEAT_MAX, asked);
106
+ const minNamed = [net.minPlayers, net.players?.min, g.players?.min].map((n) => Math.floor(Number(n))).find((n) => Number.isFinite(n) && n >= 1);
107
+ return { min: Math.min(max, minNamed ?? 1), max, asked };
108
+ }
109
+
79
110
  export async function build(root, { only = null, log = () => {} } = {}) {
80
111
  const require = createRequire(join(root, 'package.json'));
81
112
  let esbuild;
@@ -134,7 +165,9 @@ export async function build(root, { only = null, log = () => {} } = {}) {
134
165
  if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id))}\n`);
135
166
  const main = join(out, 'assets', 'main.js');
136
167
  const bytes = existsSync(main) ? statSync(main).size : dirBytes(out);
137
- built.push({ id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings });
168
+ const seats = seatsFor(g, netplayOf(g, out));
169
+ if (seats.asked > SEAT_MAX) log(`warning: games/${g.id} asks for ${seats.asked} players; a room holds at most ${SEAT_MAX}, so its rooms have ${SEAT_MAX} seats`);
170
+ built.push({ id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings, seats: seats.max });
138
171
  log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB)`);
139
172
  }
140
173
  const all = listGames(root);
@@ -145,19 +178,49 @@ export async function build(root, { only = null, log = () => {} } = {}) {
145
178
  else {
146
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: [] }; }
147
180
  }
148
- const catalogue = {
149
- studio: { name: studio.name, slug: studio.slug, version: STUDIO_VERSION },
150
- games: all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))).map((g) => ({
151
- id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: g.players ?? { min: 1, max: 8 },
152
- roundSeconds: g.roundSeconds ?? null, movement: g.netplay?.movement ?? null, cover: g.cover ?? null,
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,
153
191
  ...(g.screen ? { screen: g.screen } : {}),
154
- })),
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 : {};
200
+ const catalogue = {
201
+ studio: {
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
+ },
209
+ // studio.json `stats.share`: the site tells the directory two numbers for the hub (played this week).
210
+ ...(studio.stats?.share === true ? { stats: { share: true }, directory: studio.homie?.directory ?? 'https://homie.rocks' } : {}),
211
+ },
212
+ games: rows,
155
213
  songs: media.songs,
156
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 } : {}) },
157
218
  };
158
219
  writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
159
220
  return {
160
221
  ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id),
161
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 })),
162
225
  };
163
226
  }
@@ -22,8 +22,8 @@ import { spawnSync } from 'node:child_process';
22
22
  import { existsSync, writeFileSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { build } from './build.mjs';
25
- import { wranglerConfig } from './scaffold.mjs';
26
- import { readStudio, writeStudio } from './studio.mjs';
25
+ import { ensureLocalIgnored, ensureStatsMigration, wranglerConfig } from './scaffold.mjs';
26
+ import { LOCAL_STATE, isWorkersDev, readLocal, readStudio, siteUrl, writeLocal, writeStudio } from './studio.mjs';
27
27
 
28
28
  const ANSI = /\u001b\[[0-9;]*m/g;
29
29
 
@@ -32,7 +32,8 @@ export function wranglerBin(root) {
32
32
  return existsSync(local) ? local : null;
33
33
  }
34
34
 
35
- function runner(root, env) {
35
+ /** Wrangler from the studio's own node_modules, run in site/ with the given environment. */
36
+ export function runner(root, env) {
36
37
  const bin = wranglerBin(root);
37
38
  if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
38
39
  return (args, { cwd = join(root, 'site'), input } = {}) => {
@@ -95,7 +96,7 @@ export function deployPlan(root) {
95
96
  ok: true, command: 'deploy plan', studio: studio.name, account: cf.accountId ?? null,
96
97
  cloudflare: [
97
98
  { kind: 'Worker', name: cf.worker, what: 'the studio\'s pages, each game\'s page and play shell, and /.well-known/homie-studio.json for the directory', state: mark(`worker:${cf.worker}`), plan: 'Workers Free' },
98
- { kind: 'D1 database', name: cf.d1, what: 'the directory claim and every finished round', state: mark(`d1:${cf.d1}`), plan: 'Workers Free (500 MB per database, 5 GB per account)' },
99
+ { kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round, and the studio\'s own stats (daily counters of visits, plays, rooms, rounds and songs, for the owner only; nothing about a visitor)', state: mark(`d1:${cf.d1}`), plan: 'Workers Free (500 MB per database, 5 GB per account)' },
99
100
  { kind: 'Durable Object', name: 'Table', what: 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
100
101
  { kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
101
102
  storage
@@ -144,7 +145,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
144
145
  if (!created.has(`worker:${cf.worker}`)) {
145
146
  const account = who.accounts.find((a) => a.id === accountId)?.name ?? 'the signed-in account';
146
147
  announced.push(
147
- `First deploy of ${studio.name} to the Cloudflare account "${account}". It creates: the Worker ${cf.worker} (the site and its rooms), the D1 database ${cf.d1}, and the Durable Objects Table and Lobby (SQLite-backed).`,
148
+ `First deploy of ${studio.name} to the Cloudflare account "${account}". It creates: the Worker ${cf.worker} (the site and its rooms), the D1 database ${cf.d1} (rounds, and the studio's own stats: counts for the owner, never a visitor's identity), and the Durable Objects Table and Lobby (SQLite-backed).`,
148
149
  'Cost: free, on the Workers Free plan; no payment method, no R2 (storage for large media is `homie-studio storage add`, later, only if wanted).',
149
150
  `The directory (${homie || studio.homie?.directory || 'https://homie.rocks'}) will store the site's address and claim, the studio's name, and each game's name, blurb and Play link.`,
150
151
  );
@@ -186,6 +187,8 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
186
187
  if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
187
188
 
188
189
  writeFileSync(join(root, 'site', 'wrangler.jsonc'), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2 }));
190
+ const added = ensureStatsMigration(root);
191
+ if (added) step(`added ${added} (the studio's own stats: counts, never tracks)`);
189
192
  const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
190
193
  if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
191
194
  step('D1 migrations applied');
@@ -194,10 +197,22 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
194
197
  const dep = w(['deploy']);
195
198
  if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
196
199
  remember(`worker:${cf.worker}`);
197
- const url = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? cf.url ?? null;
198
- step(`deployed ${cf.worker} in ${Math.round((Date.now() - started) / 1000)} s`, { url });
200
+ // The workers.dev address names the Cloudflare account (often after its owner): it stays on this computer, in
201
+ // .studio/local.json (git-ignored), and never in the committed studio.json. A custom domain stays in studio.json.
202
+ if (ensureLocalIgnored(root)) step(`added .studio/ to .gitignore (${LOCAL_STATE} keeps this computer's own state)`);
203
+ const workersDev = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? readLocal(root).url ?? (isWorkersDev(cf.url) ? cf.url : null);
204
+ if (workersDev) writeLocal(root, { url: workersDev, deployedAt: new Date().toISOString() });
205
+ const movedOut = isWorkersDev(cf.url);
206
+ if (movedOut) {
207
+ cf.url = null;
208
+ writeStudio(root, { ...readStudio(root), cloudflare: { ...readStudio(root).cloudflare, url: null } });
209
+ step(`studio.json no longer keeps the workers.dev address (it names the Cloudflare account); it is in ${LOCAL_STATE} on this computer. An earlier commit of studio.json still has it in the repository's history.`);
210
+ }
211
+ const url = siteUrl(root);
212
+ step(`deployed ${cf.worker} in ${Math.round((Date.now() - started) / 1000)} s`, { url, ...(workersDev && workersDev !== url ? { workersDev } : {}) });
199
213
 
200
- // The homie.rocks directory claim: the site serves it, which proves this studio controls the site.
214
+ // The homie.rocks directory claim, for the site's public address (its own domain when it has one): the site
215
+ // serves it, which proves this studio controls the site.
201
216
  const directory = homie || studio.homie?.directory || 'https://homie.rocks';
202
217
  let claim = null;
203
218
  if (url) {
@@ -212,10 +227,12 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
212
227
  }
213
228
  }
214
229
 
215
- const next = { ...cf, accountId, url, d1Id: db.uuid, r2: cf.r2 ?? null, created: [...created].sort(), deployedAt: new Date().toISOString() };
216
- writeStudio(root, { ...studio, cloudflare: next });
230
+ // studio.json keeps what is safe to commit: names, ids, the custom domain; never the workers.dev address.
231
+ const { url: _url, deployedAt: _at, ...kept } = cf;
232
+ const next = { ...kept, ...(cf.url && !isWorkersDev(cf.url) ? { url: cf.url } : {}), accountId, d1Id: db.uuid, r2: cf.r2 ?? null, created: [...created].sort() };
233
+ writeStudio(root, { ...readStudio(root), cloudflare: next });
217
234
  return {
218
- ok: true, command: 'deploy', url, worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
235
+ ok: true, command: 'deploy', url, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
219
236
  games: b.catalogue.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null })),
220
237
  songs: b.songs.map((slug) => ({ slug, page: url ? `${url}/music/${slug}/` : null })),
221
238
  videos: b.videos.map((slug) => ({ slug, page: url ? `${url}/videos/${slug}/` : null })),
package/lib/directory.mjs CHANGED
@@ -4,12 +4,12 @@
4
4
  * claim the directory gave it (`homie-studio deploy` stores it in D1), so
5
5
  * nobody can list games under someone else's site.
6
6
  */
7
- import { readStudio } from './studio.mjs';
7
+ import { readStudio, siteUrl } from './studio.mjs';
8
8
 
9
9
  export async function publish(root, { homie, site } = {}) {
10
10
  const studio = readStudio(root);
11
11
  const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
12
- const url = site || studio.cloudflare?.url;
12
+ const url = site || siteUrl(root, studio);
13
13
  if (!url) return { ok: false, command: 'publish', why: 'this studio has no live site yet: run `npm run deploy` first' };
14
14
  const res = await fetch(`${directory}/api/studio/publish`, {
15
15
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ site: url }),
package/lib/look.mjs ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `homie-studio look [<path>...] --url <site> [--shots <dir>]` — the studio's pages as a stranger sees them:
3
+ * each page on a computer (1440 x 900), a phone held upright (390 x 844) and the phone turned sideways
4
+ * (844 x 390), scrolled to the end so everything that rises into view has, then shot whole and above the fold.
5
+ * Measured on every shot: the answer's status, anything wider than the screen, whether the Play button is on the
6
+ * first screen (Home and a game's landing), pictures that did not load, footage that did not play, and script errors. Pictures go to
7
+ * `--shots` (default .studio/look/), which git ignores. One Chrome, closed at the end; its visits are house QA
8
+ * (the studio's stats leave them out).
9
+ */
10
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ const CHROMES = [
15
+ process.env.CHROME_PATH,
16
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
17
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
18
+ '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
19
+ ].filter(Boolean);
20
+
21
+ export const LOOK_DEVICES = {
22
+ computer: { width: 1440, height: 900, deviceScaleFactor: 1 },
23
+ phone: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
24
+ sideways: { width: 844, height: 390, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
25
+ };
26
+
27
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
28
+ const nameOf = (path) => path.replace(/^\/+|\/+$/g, '').replace(/[^a-z0-9]+/gi, '-') || 'home';
29
+
30
+ export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVICES), log = () => {} }) {
31
+ if (!url) return { ok: false, command: 'look', why: 'give --url (the local dev address or the live site)' };
32
+ const chrome = CHROMES.find((p) => existsSync(p));
33
+ if (!chrome) return { ok: false, command: 'look', why: 'no Chrome found (set CHROME_PATH)' };
34
+ let puppeteer;
35
+ try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'look', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
36
+ const base = String(url).replace(/\/+$/, '');
37
+ mkdirSync(shots, { recursive: true });
38
+ const profile = mkdtempSync(join(tmpdir(), 'homie-studio-look-'));
39
+ const browser = await puppeteer.launch({
40
+ executablePath: chrome, headless: true, userDataDir: profile,
41
+ 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
+ const rows = [];
44
+ try {
45
+ for (const path of paths) {
46
+ for (const device of devices) {
47
+ const page = await browser.newPage();
48
+ const errors = [];
49
+ page.on('pageerror', (e) => errors.push(String(e?.message ?? e).slice(0, 200)));
50
+ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text().slice(0, 200)); });
51
+ await page.setViewport(LOOK_DEVICES[device]);
52
+ await page.setUserAgent(`${await browser.userAgent()} homie-studio-check look`);
53
+ 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 ms = Date.now() - t0;
56
+ await sleep(900);
57
+ const fold = await page.evaluate(() => {
58
+ // Home and a game's landing lead with Play; other pages (a post, the rooms) are not judged on it.
59
+ if (!['home', 'landing'].includes(document.body?.dataset?.page ?? '')) return { play: null };
60
+ const play = [...document.querySelectorAll('.play')].find((a) => { const r = a.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
61
+ const r = play?.getBoundingClientRect();
62
+ return { play: r ? r.bottom <= innerHeight && r.top >= 0 : false };
63
+ });
64
+ const foldFile = join(shots, `${nameOf(path)}-${device}-fold.png`);
65
+ await page.screenshot({ path: foldFile });
66
+ await page.evaluate(async () => { for (let y = 0; y < document.documentElement.scrollHeight; y += Math.round(innerHeight * 0.7)) { window.scrollTo({ top: y, behavior: 'instant' }); await new Promise((r) => setTimeout(r, 110)); } window.scrollTo({ top: 0, behavior: 'instant' }); });
67
+ await sleep(900);
68
+ const facts = await page.evaluate(() => ({
69
+ title: document.title,
70
+ wide: [...document.querySelectorAll('body *')].filter((e) => { const b = e.getBoundingClientRect(); return b.width > 0 && b.right > innerWidth + 1 && !e.closest('[data-hero-media],.nav,pre,.hero-media'); }).slice(0, 5).map((e) => `${e.tagName.toLowerCase()}${e.className ? `.${String(e.className).split(' ')[0]}` : ''}`),
71
+ brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0 && i.getAttribute('src')).map((i) => i.getAttribute('src')).slice(0, 5),
72
+ footage: [...document.querySelectorAll('[data-hero-media] video')].map((v) => ({ playing: !v.paused && v.readyState >= 2, src: (v.currentSrc || '').split('/').pop() })),
73
+ height: document.documentElement.scrollHeight,
74
+ }));
75
+ const file = join(shots, `${nameOf(path)}-${device}.png`);
76
+ await page.screenshot({ path: file, fullPage: true });
77
+ const row = {
78
+ path, device, status: res?.status() ?? null, ms, title: facts.title, height: facts.height, playAboveFold: fold.play,
79
+ tooWide: facts.wide, brokenImages: facts.brokenImages, footage: facts.footage, errors, shot: file, fold: foldFile,
80
+ };
81
+ const bad = [];
82
+ if (!row.status || row.status >= 400) bad.push(`answered ${row.status}`);
83
+ if (row.tooWide.length) bad.push(`wider than the screen: ${row.tooWide.join(', ')}`);
84
+ if (row.playAboveFold === false && device !== 'sideways') bad.push('Play is not on the first screen');
85
+ if (row.brokenImages.length) bad.push(`pictures that did not load: ${row.brokenImages.join(', ')}`);
86
+ if (row.footage.some((f) => !f.playing)) bad.push('the hero footage did not play');
87
+ if (errors.length) bad.push(`${errors.length} script error(s): ${errors[0]}`);
88
+ row.ok = bad.length === 0;
89
+ row.problems = bad;
90
+ rows.push(row);
91
+ log(`${row.ok ? 'ok ' : 'LOOK'} ${path} ${device}: ${row.status} in ${ms} ms${bad.length ? ` · ${bad.join(' · ')}` : ''}`);
92
+ await page.close();
93
+ }
94
+ }
95
+ } finally {
96
+ await browser.close().catch(() => {});
97
+ rmSync(profile, { recursive: true, force: true });
98
+ }
99
+ writeFileSync(join(shots, 'look.json'), `${JSON.stringify(rows, null, 2)}\n`);
100
+ return { ok: rows.every((r) => r.ok), command: 'look', url: base, shots, rows };
101
+ }