@homie-rocks/studio 0.3.0 → 0.6.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/lib/scaffold.mjs CHANGED
@@ -7,19 +7,23 @@
7
7
  * studio.json the studio's name, slug, and its Cloudflare resources
8
8
  * package.json @homie-rocks/studio and wrangler, pinned
9
9
  * games/<id>/ one folder per game (game.json, index.html, src/)
10
- * music/ videos/ manifests in the repo; the big files live in the studio's R2
10
+ * music/ videos/ manifests in the repo; the big files go to the studio's own storage
11
+ * (R2) once it is added with `homie-studio storage add`
11
12
  * posts/ words the studio publishes
12
- * site/ the studio's Worker (pages, rooms), its D1 migrations
13
+ * site/ the studio's Worker (pages, rooms), its D1 migrations (no R2
14
+ * binding until the studio adds storage: a free Cloudflare account
15
+ * without a payment method cannot use R2, and a new studio needs none)
13
16
  * .claude/skills/ skills only this studio uses
14
17
  *
15
18
  * It writes only into a folder that is new or empty, never guesses one, and
16
19
  * lists every file it writes, so the person sees exactly what changed.
17
20
  */
18
21
  import { execFileSync } from 'node:child_process';
19
- import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
22
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
20
23
  import { homedir } from 'node:os';
21
24
  import { dirname, join, relative, resolve } from 'node:path';
22
25
  import { STUDIO_VERSION, packageSpec } from './version.mjs';
26
+ import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
23
27
 
24
28
  export const COMPAT_DATE = '2026-06-01';
25
29
  export const WRANGLER_VERSION = '4.126.0';
@@ -47,7 +51,8 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null }) {
47
51
  observability: { enabled: true },
48
52
  };
49
53
  return `// This studio's site on its own Cloudflare account (written by homie-studio; \`homie-studio deploy\`
50
- // fills in the D1 id, and drops the R2 binding when the account has no R2).
54
+ // fills in the D1 id). Everything here runs on Cloudflare's Workers Free plan. There is no R2 binding
55
+ // until \`homie-studio storage add\` gives the studio storage for large media.
51
56
  ${JSON.stringify(config, null, 2)}
52
57
  `;
53
58
  }
@@ -55,22 +60,22 @@ ${JSON.stringify(config, null, 2)}
55
60
  function agentsMd({ name, slug }) {
56
61
  return `# ${name}
57
62
 
58
- This folder is a game studio: **${name}** (\`${slug}\`). It is one repository.
63
+ This folder is a studio: **${name}** (\`${slug}\`). It is one repository.
59
64
  Its games, music, videos and posts live here; its website and public game rooms
60
- run on the studio's **own Cloudflare account** (a Worker, D1, R2, and the
61
- Table/Lobby Durable Objects), built from \`@homie-rocks/studio\`, pinned in
62
- \`package.json\`. The homie.rocks directory lists its games; homie.rocks does
63
- not host them.
65
+ run on the studio's **own Cloudflare account** (one Worker, one D1 database and
66
+ the Table/Lobby Durable Objects, all on Cloudflare's free Workers plan), built
67
+ from \`@homie-rocks/studio\`, pinned in \`package.json\`. The homie.rocks
68
+ directory lists its games; homie.rocks does not host them.
64
69
 
65
70
  ## Layout
66
71
 
67
72
  | Path | What it is |
68
73
  | --- | --- |
69
74
  | \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`. |
70
- | \`music/\`, \`videos/\` | \`manifest.json\` lists each file and its R2 key. Large files go to R2 (\`npx --no-install homie-studio media put <file>\`), never into git. |
75
+ | \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug>/\`, served from the site itself (files up to 25 MiB) or, for larger media, from the studio's storage once it has storage (see below; \`npx --no-install homie-studio media put <file>\`). Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
71
76
  | \`posts/\` | Markdown the studio publishes. |
72
77
  | \`site/\` | The studio's Worker (\`src/worker.mjs\`), D1 migrations, \`wrangler.jsonc\`. |
73
- | \`studio.json\` | The studio's name, slug and Cloudflare resource names. |
78
+ | \`studio.json\` | The studio's name, slug, Cloudflare resource names, custom domain and stats sharing. \`.studio/\` (git-ignored) is this computer's own state. |
74
79
  | \`.claude/skills/\` | Skills only this studio uses. Homie's own skills come from the Homie plugin. |
75
80
 
76
81
  ## Commands (all through the pinned CLI in node_modules)
@@ -87,13 +92,29 @@ studio's pinned copy, never a registry lookup of the bare name.
87
92
  - \`npm run build\` — bundle every game into \`site/dist\`.
88
93
  - \`npm run dev\` — the whole site locally (pages, the netplay relay in a local
89
94
  Durable Object, D1): open the printed address in two browsers and they share a room.
95
+ Stop it with \`npx --no-install homie-studio dev --stop\` (this studio's dev server only;
96
+ never \`pkill\` by name, which stops other projects' dev servers too).
90
97
  - \`npx --no-install homie-studio check <id> --url <site>\` — two headless browsers press Play and must
91
98
  land in the same room and finish a round. Run it before you say a game works.
92
- - \`npm run deploy\` — the site on this studio's Cloudflare (Workers, D1, R2).
99
+ - \`npx --no-install homie-studio deploy --plan\` — says what deploy will create on Cloudflare and what it
100
+ costs, and changes nothing. Tell the person before the first deploy.
101
+ - \`npm run deploy\` — the site on this studio's Cloudflare: one Worker, one D1 database, two
102
+ SQLite-backed Durable Objects, all on the free Workers plan (no payment method needed).
93
103
  If Wrangler is not signed in, run \`npx wrangler login\`: the person approves once in
94
104
  their browser. It never overwrites a Worker or database this studio did not create.
105
+ - \`npx --no-install homie-studio storage add\` — only when the studio needs large media (songs,
106
+ videos): an R2 bucket for \`media put\`, served at \`/media/<key>\`. Cloudflare asks for a
107
+ payment method on the account before R2 works (its first 10 GB a month are free), so this
108
+ is a separate step the person agrees to; nothing else needs it.
95
109
  - \`npx --no-install homie-studio publish\` — list this studio's games in the homie.rocks directory
96
110
  (or call the Homie MCP tool \`studio_publish\`).
111
+ - \`npx --no-install homie-studio stats\` — the studio's own numbers, for its owner: visits, Play presses,
112
+ rooms, the most people playing at once, rounds, songs played, videos watched, and where visitors came
113
+ from (homie.rocks, other studios, search, the web). \`stats link\` gives the owner a one-time link to the
114
+ private page \`/_studio/stats\` in their own browser; \`stats key\` a short read key for the Homie MCP tool
115
+ \`studio_stats\` (never paste a key anywhere else). \`stats share on\` tells the directory two numbers
116
+ (played this week). The site counts and never tracks: no cookie on a visitor, no person identified,
117
+ nothing sent anywhere; house QA and \`check\` runs are not counted.
97
118
 
98
119
  ## Making games
99
120
 
@@ -112,7 +133,17 @@ studio's pinned copy, never a registry lookup of the bare name.
112
133
  - Keys stay in the providers' own logins (Wrangler, ElevenLabs, fal) or the OS
113
134
  keychain. Never write a key, token or password into this repository.
114
135
  - Never touch a Cloudflare resource this studio did not create (\`studio.json\` says which).
136
+ - The site's workers.dev address names the Cloudflare account (often after its owner): \`deploy\` keeps it in
137
+ \`.studio/local.json\`, which git ignores. Never copy it into a committed file. A custom domain goes in
138
+ studio.json as \`cloudflare.domain\`.
139
+ - A game's room size is its netplay manifest's \`maxPlayers\` (game.json \`netplay\`, or netplay.json), up to 32.
115
140
  - Nothing in this studio needs \`~/.homie\` or a Homie box.
141
+
142
+ ## Beta
143
+
144
+ Homie for studios is in beta. When something breaks, or a game you want to port does
145
+ not fit, open an issue at https://github.com/homie-rocks/homie/issues/new/choose (a bug,
146
+ a port request or a question). Leave keys, tokens and private addresses out of it.
116
147
  `;
117
148
  }
118
149
 
@@ -151,6 +182,8 @@ CREATE INDEX IF NOT EXISTS rounds_game_at ON rounds (game, at);
151
182
  `;
152
183
 
153
184
  const GITIGNORE = `node_modules/
185
+ # This computer's own state: the site's workers.dev address (it names the Cloudflare account, often after its owner).
186
+ .studio/
154
187
  site/dist/
155
188
  site/.wrangler/
156
189
  .wrangler/
@@ -159,7 +192,10 @@ site/.wrangler/
159
192
  *.log
160
193
  # Port checks: receipts and screenshots of each run (games/<id>/.port/check-*/).
161
194
  games/*/.port/
162
- # Large media lives in this studio's R2; the manifests beside it are committed.
195
+ # Large media lives in this studio's storage (R2, after storage add) or is served by the site; the manifests beside it are committed.
196
+ # Working files of the music and video skills (frames, captures, provider answers) stay on this computer.
197
+ music/**/work/
198
+ videos/**/work/
163
199
  music/**/*.wav
164
200
  music/**/*.mp3
165
201
  music/**/*.flac
@@ -174,7 +210,13 @@ export function studioFiles({ name, slug, homie }) {
174
210
  const studio = {
175
211
  name, slug,
176
212
  homie: { studio: STUDIO_VERSION, directory: homie },
177
- cloudflare: { worker, d1: `${slug}-db`, r2: `${slug}-media`, accountId: null, url: null, created: [] },
213
+ // r2 stays null until `homie-studio storage add`: a new studio deploys with no R2 at all.
214
+ // `domain`: the studio's own domain once it has one (e.g. "night-owls.example"). The workers.dev address never
215
+ // goes here: it names the Cloudflare account, so deploy keeps it in .studio/local.json (git-ignored).
216
+ cloudflare: { worker, d1: `${slug}-db`, r2: null, accountId: null, domain: null, created: [] },
217
+ // The site counts visits, plays, rooms, rounds and songs for the owner only (`homie-studio stats`); `share`
218
+ // also tells the homie.rocks directory "played this week" (two numbers) for the hub.
219
+ stats: { share: false },
178
220
  };
179
221
  const pkg = {
180
222
  name: `${slug}-studio`,
@@ -191,21 +233,46 @@ export function studioFiles({ name, slug, homie }) {
191
233
  'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
192
234
  '.gitignore': GITIGNORE,
193
235
  'games/README.md': 'One folder per game. Start one with `npx --no-install homie-studio game new <id> --from gem-rush`.\n',
194
- 'music/README.md': 'Songs and stems. `manifest.json` lists them; the files themselves go to R2 with `npx --no-install homie-studio media put`.\n',
195
- 'music/manifest.json': '{ "items": [] }\n',
196
- 'videos/README.md': 'Trailers and clips. `manifest.json` lists them; the files themselves go to R2 with `npx --no-install homie-studio media put`.\n',
197
- 'videos/manifest.json': '{ "items": [] }\n',
236
+ 'music/README.md': 'Songs, game scores, loops and stems, one folder each (`music/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/music/<slug>/`. The site serves files up to 25 MiB itself; larger ones go to the studio\'s storage with `npx --no-install homie-studio media put` (after `npx --no-install homie-studio storage add`). Never into git.\n',
237
+ 'music/manifest.json': '{ "v": 1, "items": [] }\n',
238
+ 'videos/README.md': 'Trailers, music videos and cutscenes, one folder each (`videos/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/videos/<slug>/`. The site serves files up to 25 MiB itself; larger ones go to the studio\'s storage with `npx --no-install homie-studio media put` (after `npx --no-install homie-studio storage add`). Never into git.\n',
239
+ 'videos/manifest.json': '{ "v": 1, "items": [] }\n',
198
240
  'posts/README.md': 'Markdown posts this studio publishes, one file each.\n',
199
- 'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1 and R2.
241
+ 'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1, and R2 once storage is added.
200
242
  // The code is @homie-rocks/studio's, pinned in package.json, so an update never changes a published game by surprise.
201
243
  export { default, Table, Lobby } from '@homie-rocks/studio/worker';
202
244
  `,
203
245
  'site/migrations/0001_studio.sql': MIGRATION,
246
+ [`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
204
247
  'site/wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2 }),
205
248
  '.claude/skills/.gitkeep': '',
206
249
  };
207
250
  }
208
251
 
252
+ /**
253
+ * A studio made before 0.6.0 has no stats migration: add it (the template owns site/migrations/), so the next
254
+ * `d1 migrations apply` makes the counters. Returns the file it wrote, or null when it was there.
255
+ */
256
+ export function ensureStatsMigration(root) {
257
+ const file = join(root, 'site', 'migrations', STATS_MIGRATION_FILE);
258
+ if (existsSync(file)) return null;
259
+ mkdirSync(dirname(file), { recursive: true });
260
+ writeFileSync(file, STATS_MIGRATION);
261
+ return `site/migrations/${STATS_MIGRATION_FILE}`;
262
+ }
263
+
264
+ /**
265
+ * A studio made before 0.6.0 committed nothing under .studio/ yet: make sure git leaves it out before the first
266
+ * deploy writes the workers.dev address there. Returns true when it added the line.
267
+ */
268
+ export function ensureLocalIgnored(root) {
269
+ const file = join(root, '.gitignore');
270
+ const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
271
+ if (/^\/?\.studio\/?$/m.test(text)) return false;
272
+ writeFileSync(file, `${text}${text && !text.endsWith('\n') ? '\n' : ''}# This computer's own state: the site's workers.dev address (it names the Cloudflare account).\n.studio/\n`);
273
+ return true;
274
+ }
275
+
209
276
  function insideGit(dir) {
210
277
  try { return execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() === 'true'; } catch { return false; }
211
278
  }
@@ -254,7 +321,7 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
254
321
  'npx --no-install homie-studio game new <id> --from gem-rush --name "<Game Name>"',
255
322
  'npm run dev (then: npx --no-install homie-studio check <id> --url http://127.0.0.1:8787)',
256
323
  'npm run deploy (then the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish)',
257
- ] };
324
+ ], online: 'Going online creates one Worker, one D1 database and two Durable Objects on your own Cloudflare account: free plan, no payment method, no R2. `npx --no-install homie-studio deploy --plan` says exactly what, and changes nothing.' };
258
325
  }
259
326
 
260
327
  export const _test = { relative };
package/lib/stats.mjs ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `homie-studio stats` — the studio's own numbers, for its owner (worker/stats.mjs says what is counted).
3
+ *
4
+ * homie-studio stats [--range 1d|7d|30d|90d] [--game <id> | --song <slug> | --video <slug>]
5
+ * the numbers, read with a key that lives ten minutes and is dropped after
6
+ * homie-studio stats key [--hours 1] a read key for the Homie MCP tool `studio_stats` (up to 30 days)
7
+ * homie-studio stats link a one-time link that opens /_studio/stats in the owner's browser
8
+ * homie-studio stats revoke every key and page session ends now
9
+ * homie-studio stats share on|off tell the homie.rocks directory "played this week" (two numbers), or stop
10
+ *
11
+ * THE PROOF OF OWNERSHIP IS THE STUDIO'S OWN CLOUDFLARE LOGIN. A key is minted here, on this computer, and only
12
+ * its SHA-256 goes into the studio's D1 (through Wrangler, which only the studio's owner is signed in to). The key
13
+ * itself is never sent to Cloudflare, never written to a file, and lives for its hours only. A studio running
14
+ * locally (`npm run dev`, a loopback --url) uses the local D1.
15
+ */
16
+ import { createHash, randomBytes } from 'node:crypto';
17
+ import { runner } from './cloudflare.mjs';
18
+ import { readStudio, siteUrl, writeStudio } from './studio.mjs';
19
+
20
+ const HOUR = 3600_000;
21
+ const RANGES = new Set(['1d', '7d', '30d', '90d']);
22
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
23
+
24
+ const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
25
+
26
+ function siteOf(root, url) {
27
+ const studio = readStudio(root);
28
+ const site = String(url || siteUrl(root, studio) || '').replace(/\/+$/, '');
29
+ if (!/^https?:\/\//.test(site)) return { studio, site: null };
30
+ return { studio, site };
31
+ }
32
+
33
+ /** Put a key's hash into the studio's D1 (remote, or local for a loopback site). The key never leaves this machine. */
34
+ function mint(root, studio, kind, ttlMs, { local }) {
35
+ const key = `hsk_${randomBytes(24).toString('hex')}`;
36
+ const hash = createHash('sha256').update(key).digest('hex');
37
+ const now = Date.now();
38
+ const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
39
+ const sql = `DELETE FROM stats_keys WHERE expires_at < ${now}; INSERT INTO stats_keys (hash, kind, expires_at) VALUES ('${hash}', '${kind}', ${now + ttlMs});`;
40
+ const res = w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', sql]);
41
+ if (res.code !== 0) {
42
+ const why = /no such table: stats_keys/i.test(res.out)
43
+ ? 'this studio\'s D1 has no stats tables yet: run `npm run deploy` (it applies migration 0002_studio_stats.sql), or `npm run dev` for a local site'
44
+ : /not logged in|login|authenticat/i.test(res.out) ? 'Wrangler is not signed in to this studio\'s Cloudflare: run `npx wrangler login` (the person approves once)'
45
+ : `could not write the key's hash to D1: ${res.out.trim().split('\n').slice(-3).join(' ')}`;
46
+ return { ok: false, why };
47
+ }
48
+ return { ok: true, key, hash, expiresAt: new Date(now + ttlMs).toISOString() };
49
+ }
50
+
51
+ function drop(root, studio, hash, { local }) {
52
+ const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
53
+ w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', `DELETE FROM stats_keys WHERE hash = '${hash}';`]);
54
+ }
55
+
56
+ export function statsQuery({ range, game, song, video } = {}) {
57
+ const q = new URLSearchParams();
58
+ q.set('range', RANGES.has(range) ? range : '7d');
59
+ if (game && SLUG.test(game)) q.set('game', game);
60
+ else if (song && SLUG.test(song)) q.set('song', song);
61
+ else if (video && SLUG.test(video)) q.set('video', video);
62
+ return q;
63
+ }
64
+
65
+ /** The numbers, read with a ten-minute key that is dropped as soon as they are in. */
66
+ export async function statsShow(root, { url, range, game, song, video } = {}) {
67
+ const { studio, site } = siteOf(root, url);
68
+ if (!site) return { ok: false, command: 'stats', why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://127.0.0.1:8787 for `npm run dev`' };
69
+ const local = loopback(site);
70
+ const k = mint(root, studio, 'read', 10 * 60_000, { local });
71
+ if (!k.ok) return { ok: false, command: 'stats', why: k.why };
72
+ try {
73
+ const res = await fetch(`${site}/api/stats?${statsQuery({ range, game, song, video })}`, { headers: { authorization: `Bearer ${k.key}` }, signal: AbortSignal.timeout(20_000) });
74
+ const body = await res.json().catch(() => ({}));
75
+ if (!res.ok || !body.ok) return { ok: false, command: 'stats', why: body.message ?? `${site}/api/stats answered ${res.status}` };
76
+ return { command: 'stats', ...body, ok: true };
77
+ } catch (error) {
78
+ return { ok: false, command: 'stats', why: `could not read ${site}/api/stats: ${error.message}` };
79
+ } finally { drop(root, studio, k.hash, { local }); }
80
+ }
81
+
82
+ /** A read key for `studio_stats` (the Homie MCP): 1 hour by default, 30 days at most. */
83
+ export function statsKey(root, { url, hours } = {}) {
84
+ const { studio, site } = siteOf(root, url);
85
+ if (!site) return { ok: false, command: 'stats key', why: 'this studio has no live site yet: `npm run deploy` first' };
86
+ const h = Math.max(1, Math.min(24 * 30, Math.floor(Number(hours) || 1)));
87
+ const k = mint(root, studio, 'read', h * HOUR, { local: loopback(site) });
88
+ if (!k.ok) return { ok: false, command: 'stats key', why: k.why };
89
+ return {
90
+ ok: true, command: 'stats key', site, key: k.key, expiresAt: k.expiresAt, hours: h,
91
+ use: `Call the Homie MCP tool studio_stats with { "site": "${site}", "key": "<this key>" } (and range, game, song or video). The key only reads this studio's numbers, ends at ${k.expiresAt}, and \`homie-studio stats revoke\` ends it sooner. Never paste it anywhere else.`,
92
+ };
93
+ }
94
+
95
+ /** A one-time sign-in link (30 minutes) that opens the private stats page in the owner's own browser. */
96
+ export function statsLink(root, { url } = {}) {
97
+ const { studio, site } = siteOf(root, url);
98
+ if (!site) return { ok: false, command: 'stats link', why: 'this studio has no live site yet: `npm run deploy` first' };
99
+ const k = mint(root, studio, 'signin', 30 * 60_000, { local: loopback(site) });
100
+ if (!k.ok) return { ok: false, command: 'stats link', why: k.why };
101
+ return {
102
+ ok: true, command: 'stats link', site, link: `${site}/_studio/signin?k=${k.key}`, expiresAt: k.expiresAt,
103
+ use: 'Give this link to the studio\'s owner to open in their own browser: it works once, within 30 minutes, and keeps that browser signed in to the stats page for 30 days. It is theirs alone; do not post it anywhere.',
104
+ };
105
+ }
106
+
107
+ export function statsRevoke(root, { url } = {}) {
108
+ const { studio, site } = siteOf(root, url);
109
+ const local = site ? loopback(site) : false;
110
+ const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
111
+ const res = w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', 'DELETE FROM stats_keys;']);
112
+ return res.code === 0 ? { ok: true, command: 'stats revoke', site, message: 'Every stats key and page session has ended.' } : { ok: false, command: 'stats revoke', why: res.out.trim().split('\n').slice(-3).join(' ') };
113
+ }
114
+
115
+ export function statsShare(root, on) {
116
+ const studio = readStudio(root);
117
+ const share = on === 'on' || on === true || on === 'true';
118
+ writeStudio(root, { ...studio, stats: { ...(studio.stats ?? {}), share } });
119
+ return {
120
+ ok: true, command: 'stats share', share,
121
+ message: share
122
+ ? 'studio.json stats.share is on. After `npm run deploy`, the site tells the homie.rocks directory two numbers for the whole studio: Play presses and rounds with people in them, over the last 7 days. Nothing else leaves the studio.'
123
+ : 'studio.json stats.share is off. After `npm run deploy`, the site tells the directory nothing.',
124
+ };
125
+ }
package/lib/studio.mjs CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
 
9
9
  export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
10
  export const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
11
- const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', 'well-known', 'index', 'play', 'studio', 'studios']);
11
+ const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts']);
12
12
 
13
13
  export function findStudio(from = process.cwd()) {
14
14
  let at = resolve(from);
@@ -29,6 +29,48 @@ export function requireStudio(from) {
29
29
  export function readStudio(root) { return JSON.parse(readFileSync(join(root, 'studio.json'), 'utf8')); }
30
30
  export function writeStudio(root, studio) { writeFileSync(join(root, 'studio.json'), `${JSON.stringify(studio, null, 2)}\n`); }
31
31
 
32
+ /*
33
+ * WHERE THE SITE'S ADDRESS LIVES. studio.json is committed, and a workers.dev address carries the Cloudflare
34
+ * account's subdomain, which is often the person's own name. So studio.json keeps only a CUSTOM domain
35
+ * (`cloudflare.domain`, e.g. "night-owls.example"); the workers.dev address `deploy` gets back stays on this
36
+ * computer in .studio/local.json, which the studio's .gitignore leaves out.
37
+ */
38
+ export const LOCAL_STATE = '.studio/local.json';
39
+ export const isWorkersDev = (url) => { try { return /\.workers\.dev$/i.test(new URL(url).hostname); } catch { return false; } };
40
+
41
+ /** `cloudflare.domain` as an origin: "example.com" or "https://example.com/" become "https://example.com"; else null. */
42
+ export function domainOrigin(value) {
43
+ const raw = String(value ?? '').trim();
44
+ if (!raw) return null;
45
+ try {
46
+ const u = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
47
+ if (u.protocol !== 'https:' || !u.hostname.includes('.') || isWorkersDev(u.origin)) return null;
48
+ return u.origin;
49
+ } catch { return null; }
50
+ }
51
+
52
+ export function readLocal(root) {
53
+ try { return JSON.parse(readFileSync(join(root, LOCAL_STATE), 'utf8')); } catch { return {}; }
54
+ }
55
+ export function writeLocal(root, patch) {
56
+ const path = join(root, LOCAL_STATE);
57
+ mkdirSync(dirname(path), { recursive: true });
58
+ const next = { ...readLocal(root), ...patch };
59
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
60
+ return next;
61
+ }
62
+
63
+ /**
64
+ * The studio site's public address: its custom domain (studio.json `cloudflare.domain`, or a non-workers.dev
65
+ * `cloudflare.url` an older studio wrote by hand), else the workers.dev address this computer's deploy got back,
66
+ * else an older studio.json's workers.dev `url` (until the next deploy moves it out).
67
+ */
68
+ export function siteUrl(root, studio = readStudio(root)) {
69
+ const cf = studio.cloudflare ?? {};
70
+ const custom = domainOrigin(cf.domain) ?? (cf.url && !isWorkersDev(cf.url) ? domainOrigin(cf.url) : null);
71
+ return custom ?? readLocal(root).url ?? cf.url ?? null;
72
+ }
73
+
32
74
  export function listGames(root) {
33
75
  const dir = join(root, 'games');
34
76
  if (!existsSync(dir)) return [];
package/media/MEDIA.md ADDED
@@ -0,0 +1,81 @@
1
+ # Studio media: music/ and videos/
2
+
3
+ A studio's songs, scores, loops, trailers, music videos and cutscenes live in the studio
4
+ repository as folders plus one manifest per kind. The manifests are the contract between
5
+ whatever made the media (the Homie plugin's `music` and `video` skills, or a person) and the
6
+ studio site, which turns every published entry into a page:
7
+
8
+ | Manifest | Page | Listed in |
9
+ | --- | --- | --- |
10
+ | `music/manifest.json` | `/music/<slug>/` (an audio player) | the home page, `/music/`, `/.well-known/homie-studio.json` `songs` |
11
+ | `videos/manifest.json` | `/videos/<slug>/` (a video player) | the home page, `/videos/`, `/.well-known/homie-studio.json` `videos` |
12
+
13
+ `homie-studio build` reads both manifests. `homie-studio media list` prints what the site will
14
+ show and why anything is left out.
15
+
16
+ ## An entry
17
+
18
+ ```json
19
+ {
20
+ "v": 1,
21
+ "items": [
22
+ {
23
+ "slug": "theme",
24
+ "kind": "song",
25
+ "title": "Night Owls Theme",
26
+ "blurb": "The studio's theme: a short sung hook over a driving synth line.",
27
+ "published": true,
28
+ "duration": 16.0,
29
+ "bpm": 120,
30
+ "key": "A minor",
31
+ "files": [
32
+ { "role": "audio", "path": "music/theme/theme.mp3", "type": "audio/mpeg" },
33
+ { "role": "cover", "path": "music/theme/cover.jpg", "type": "image/jpeg" },
34
+ { "role": "loop", "path": "music/theme/theme-loop.ogg", "type": "audio/ogg", "bars": 8 },
35
+ { "role": "master", "path": "music/theme/theme-master.wav", "type": "audio/wav", "public": false }
36
+ ],
37
+ "lyrics": "…",
38
+ "credits": "Music made with Eleven Music (ElevenLabs).",
39
+ "rights": { "provider": "elevenlabs", "plan": "creator", "commercial": true, "attribution": null },
40
+ "for": { "game": "crown-thief" },
41
+ "made": { "provider": "elevenlabs", "model": "music_v2", "at": "2026-09-30T02:00:00Z", "receipt": "music/theme/receipt.json" }
42
+ }
43
+ ]
44
+ }
45
+ ```
46
+
47
+ - The manifest's order is the site's order (the plugin's skills put a new entry first).
48
+ - `slug`: lowercase letters, digits and hyphens, up to 40; it is the page address and never changes
49
+ once published. `title` is required for a page; an entry without `slug` and `title` is kept in
50
+ the manifest but gets no page.
51
+ - `kind`: music is `song`, `score`, `loop`, `stem` or `sfx`; video is `trailer`, `music-video`,
52
+ `cutscene` or `clip`.
53
+ - `published`: only `true` entries get a page and a directory listing. Work in progress stays
54
+ `false`.
55
+ - `files[].role`: music uses `audio` (what the player plays), `cover`, `loop`, `stem`,
56
+ `master`; video uses `video` (the 16:9 cut the player plays), `vertical` (the 9:16 cut),
57
+ `poster`, `captions` (WebVTT), `master`. `"public": false` keeps a file off the site (masters,
58
+ raw captures). Loops and stems are offered for download on the song page, so another game can
59
+ use them.
60
+ - `rights` and `credits` are shown on the page, in plain words. Say what the provider's terms
61
+ say for the plan the file was made on; nothing on the site claims more.
62
+
63
+ ## Where the bytes come from
64
+
65
+ A studio needs no storage for songs and videos: the site serves them itself. For every public
66
+ file of a published entry, the build picks the first that exists:
67
+
68
+ 1. `key`: the file is in the studio's storage, an R2 bucket that `homie-studio storage add`
69
+ creates (Cloudflare asks for a payment method on the account before R2 works, so the person
70
+ agrees first) and `homie-studio media put <file>` uploads to; the site serves it at
71
+ `/media/<key>`, with byte ranges so phones can seek.
72
+ 2. `path`: the file is in the studio folder; the build copies it into the site as `/<path>` when
73
+ it is 25 MiB or smaller (Cloudflare's limit for one static file), and the Worker answers byte
74
+ ranges for it too. This is the default, and it needs nothing but a deploy.
75
+ 3. `url`: an absolute `https://` address somewhere else.
76
+
77
+ A file with none of these is left out and `media list` says so. Media never goes into git: the
78
+ studio's `.gitignore` keeps `*.wav`, `*.mp3`, `*.flac`, `*.mp4`, `*.mov` and `*.webm` under
79
+ `music/` and `videos/` out of the repository, so without storage the site is deployed from the
80
+ computer that has the files. Keep deliveries under 25 MiB (the video skill's cuts do) or add
81
+ storage for bigger ones.
@@ -1,8 +1,19 @@
1
- # Homie netplay contract, v1 (revision 2)
2
-
3
- Status: **v1, revision 2** (2026-09-26). The wire version is `v: 1`. Everything revision
4
- 2 added is either an optional field or a new message type, and both sides ignore types
5
- they do not know. A change to the contract bumps `v` and keeps v1 working.
1
+ # Homie netplay contract, v1 (revision 3)
2
+
3
+ Status: **v1, revision 3** (2026-09-30, `@homie-rocks/studio` 0.6.0). The wire version is
4
+ `v: 1`. Everything revision 2 and 3 added is either an optional field, a new message type,
5
+ or a change of pace inside the old caps, and both sides ignore types they do not know. A
6
+ change to the contract bumps `v` and keeps v1 working.
7
+
8
+ **What revision 3 added** (nothing on the wire changed; every v1 game plays on it):
9
+ - **Rooms of up to 32 seats**, sized by the game's netplay manifest (section 3).
10
+ - **One address may hold every seat plus four sockets** (section 6), so a party on one
11
+ Wi-Fi fills a 32-seat room with a TV beside it.
12
+ - **The checkpoint, round and roster caps double for 17–32 seats** (section 6): a
13
+ 32-seat courier game's checkpoint measured 56 KB against the 64 KB cap.
14
+ - **Idle input keepalive** in the helper: an unchanged input frame goes out at most four
15
+ times a second (section 11).
16
+ - Measured cost and free-plan headroom of a 32-seat room (section 11).
6
17
 
7
18
  **What revision 2 added:**
8
19
  - Interpolation measures transit time.
@@ -95,8 +106,12 @@ code.
95
106
  | `colour` | `seat % 12` | An index into a 12-colour palette. |
96
107
 
97
108
  - **Every visitor gets a seat at once**, phone or desktop. There is no gathering.
98
- - **Room size.** `maxPlayers` comes from the manifest. On the site the Table reads it.
99
- Locally, the first visitor of an empty room sends it in `hello.max`.
109
+ - **Room size.** `maxPlayers` comes from the game's netplay manifest: game.json's
110
+ `netplay.maxPlayers`, or a `netplay.json` beside game.json or in the game's build
111
+ (`maxPlayers` or `players.max`), else game.json's `players.max`, else 8. **A room holds
112
+ at most 32** (`SEAT_MAX`). `homie-studio build` writes the number into the catalogue and
113
+ the site's Table takes it from there; the first visitor of an empty room may lower it
114
+ (`hello.max`), never raise it.
100
115
  - **A dropped seat is held 60 s** for its token, which covers a reload or a network blip.
101
116
  **A visitor who is present beats one who left:** when no seat is free, the relay
102
117
  reclaims the seat that has been absent longest. Visitors who leave can never lock a
@@ -259,13 +274,15 @@ so `ev.origin` is `"null"`.
259
274
  | `in` | < 256 B | 2 KB | 20 Hz, plus an immediate flush (≤ 60 Hz) on a press edge | 60 |
260
275
  | `ev` | < 1 KB | 4 KB (512 B from a screen) | as needed | host 30, replica 10, screen 2 |
261
276
  | `state` | < 2 KB per key | 8 KB per key; **64 keys and 64 KB per room** → `state-full` | only on change | 64 |
262
- | `ckpt` | < 32 KB | 64 KB | 1 Hz | 4 |
263
- | `round` / `roster` | < 2 KB / < 1 KB | 8 KB / 4 KB | on change | 4 / 8 |
277
+ | `ckpt` | < 32 KB | 64 KB (128 KB for 17–32 seats) | 1 Hz | 4 |
278
+ | `round` / `roster` | < 2 KB / < 1 KB | 8 KB / 4 KB (16 / 8 KB for 17–32 seats) | on change | 4 / 8 |
264
279
  | `ping` | — | 256 B | every 2 s | 8 |
265
280
 
266
281
  **Sockets.**
267
- - At most `maxPlayers + 16` per room, and **12 per client address per room**. The Worker
268
- passes `CF-Connecting-IP` as `conn.ip`.
282
+ - At most `maxPlayers + 16` per room, and per client address per room **12, or every seat
283
+ plus four when that is more** (`perAddress`: 12 for 8 seats, 20 for 16, 36 for 32). The
284
+ Worker passes `CF-Connecting-IP` as `conn.ip`. Measured before revision 3: 32 visitors on
285
+ one address got 12 seats and 20 `too-many` refusals.
269
286
  - A client dropping more than 100 messages over the caps within 5 s is closed (`flood`).
270
287
 
271
288
  **Keep it small.**
@@ -494,6 +511,31 @@ Measured, with Gem Rush's bump (owner movement, 30–50 ms one way):
494
511
  writes/h ≈ $0.07/h, 9× everything else), or rewriting a whole log per event.
495
512
  - Re-check Cloudflare's published prices before quoting.
496
513
 
514
+ **A 32-seat room (measured 2026-09-30, `@homie-rocks/studio` 0.6.0).** Every incoming
515
+ socket message is a Durable Object request (billed 20:1; outgoing messages are free), and
516
+ in a full room the replicas' inputs are nearly all of them:
517
+
518
+ | 32 seats, one host | Incoming msgs/s at the relay | Requests/hour (20:1) | Free plan (100,000 a day) |
519
+ |---|---|---|---|
520
+ | every replica moving, input 20 Hz | ≈ 630 | ≈ 114 k | ≈ 53 room-minutes a day |
521
+ | a game's own vendored helper, input 30 Hz | ≈ 860 | ≈ 155 k | ≈ 39 room-minutes a day |
522
+ | half the replicas standing still, 0.6.0 helper | ≈ 415 | ≈ 75 k | ≈ 80 room-minutes a day |
523
+
524
+ - Put another way, the free plan carries roughly **28 player-hours a day** at 20 Hz input
525
+ (each seated player costs about one request a second). A studio that plays more than
526
+ that a day needs Workers Paid ($5 a month), where a full 32-seat room-hour costs about
527
+ $0.02 in requests plus $0.006 in duration.
528
+ - **Idle keepalive (helper, 0.6.0):** an input frame identical to the last one sent (same
529
+ avatar or intent, same held keys, no press, same reset epoch) is re-sent at most every
530
+ 250 ms. The host already holds that frame, so nothing it reads changes. A player standing
531
+ still costs 4 messages a second instead of 20. A game that vendors its own copy of the
532
+ helper gets this by updating that copy.
533
+ - Keep `inputHz` at 20 unless the game needs more: 30 Hz costs 1.5× the requests.
534
+ - Snapshots of a real 32-seat courier game (32 bodies plus the control table) were 1.1–1.7 KB at
535
+ 20 Hz: about 32 KB/s down per replica, 2 KB/s up. No snapshot delta or compression was
536
+ needed; the 2 KB target and 16 KB cap hold at 32 seats. Duration (13,000 GB-s a day free)
537
+ covers one busy room all day.
538
+
497
539
  ## 12. Trust and safety
498
540
 
499
541
  - **The host is a visitor's browser.** A modified host could fake scores. That is
@@ -569,7 +611,8 @@ npx --no-install homie-studio check my-game --url http://127.0.0.1:8787
569
611
  candidate for v2, not in v1.
570
612
  - **No lag compensation for hits.** A game can rewind a victim by the attacker's
571
613
  `rtt/2 + delay` itself.
572
- - **No snapshot deltas.** They are not needed at the measured sizes (Gem Rush: 364–427 B).
614
+ - **No snapshot deltas.** They are not needed at the measured sizes (Gem Rush: 364–427 B;
615
+ a 32-body courier game's snapshot: 1.1–1.7 KB).
573
616
  - **A reload shows a bot in the player's body for about 1–2 s**, and then the player
574
617
  takes the same body back.
575
618
  - **The host is a visitor's browser** (section 12).