@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/README.md CHANGED
@@ -1,24 +1,82 @@
1
1
  # @homie-rocks/studio
2
2
 
3
- A game studio on its own Cloudflare. One repository (`AGENTS.md`, `games/`, `music/`,
4
- `videos/`, `posts/`, `site/`), multiplayer games on the Homie netplay contract (every
5
- browser renders, strangers meet in public rooms, bots fill seats, rounds restart), and a
6
- site Worker with Table/Lobby Durable Objects, D1 and R2 that the studio deploys to its
7
- own Cloudflare account with Wrangler. The homie.rocks directory lists the games.
3
+ A studio for games, music and video on its own Cloudflare. One repository (`AGENTS.md`,
4
+ `games/`, `music/`, `videos/`, `posts/`, `site/`), multiplayer games on the Homie netplay
5
+ contract (every browser renders, strangers meet in public rooms, bots fill seats, rounds
6
+ restart), and a site Worker with Table/Lobby Durable Objects and D1 that the studio deploys
7
+ to its own Cloudflare account with Wrangler. The homie.rocks directory lists the games.
8
8
 
9
9
  Most people never run this by hand: the Homie plugin for Claude Code and Codex does,
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.3.0.tgz homie-studio new ./night-owls --name "Night Owls"
13
+ npx -y --package=https://homie.rocks/npm/homie-studio-0.6.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
17
17
  npx homie-studio check crown-thief --url http://127.0.0.1:8787
18
+ npx homie-studio deploy --plan # what deploy will create, and what it costs; changes nothing
18
19
  npx homie-studio deploy # the studio's own Cloudflare
19
20
  npx homie-studio publish # the homie.rocks directory
21
+ npx homie-studio stats # the studio's own numbers, for its owner
20
22
  ```
21
23
 
24
+ ## Rooms of up to 32
25
+
26
+ Every room of a game has the seats its netplay manifest names: game.json's `netplay`
27
+ block (`maxPlayers`), or a `netplay.json` beside game.json or in the game's build
28
+ (`maxPlayers` or `players.max`), else game.json's `players.max`, else 8; at most 32.
29
+ Bots, a late joiner taking a bot's body and a new host after the old one leaves work
30
+ the same at 32 as at 4. One address may hold every seat plus four sockets, so a party
31
+ on one Wi-Fi (or strangers behind one carrier's address) fills a room with a TV beside it.
32
+
33
+ ## The studio's own stats
34
+
35
+ The site counts, and never tracks: pages opened, Play presses, rooms opened, the most
36
+ people playing at once, rounds finished, songs played, videos watched, and which site
37
+ sent each visitor (a host name: homie.rocks, another studio, search, the web, or a
38
+ `?via=` tag). They are daily counters in the studio's own D1, on the free plan (a write
39
+ per visit and per round, never per frame), with no cookie on a visitor and nothing sent
40
+ to anyone. Prefetches, crawlers and house QA are not counted. Only the owner reads them:
41
+
42
+ ```sh
43
+ npx homie-studio stats [--range 7d] [--game crown-thief] # prints them
44
+ npx homie-studio stats link # a one-time link: /_studio/stats in the owner's browser
45
+ npx homie-studio stats key # a 1-hour read key for the Homie MCP tool studio_stats
46
+ npx homie-studio stats share on # tell the directory "played this week" (two numbers)
47
+ ```
48
+
49
+ Each key is minted on the owner's computer; only its SHA-256 goes into D1, through the
50
+ studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
51
+
52
+ ## The site's address
53
+
54
+ `deploy` keeps the `workers.dev` address in `.studio/local.json`, which git ignores: it
55
+ names the Cloudflare account, often after its owner. A custom domain goes in studio.json
56
+ as `cloudflare.domain` and is what the directory claim, `publish`, `check` and `stats` use.
57
+
58
+ ## No payment method needed
59
+
60
+ `deploy` creates only what Cloudflare's free Workers plan gives a new account with no
61
+ payment method: one Worker, one D1 database and two SQLite-backed Durable Objects
62
+ (`Table`, `Lobby`). It never creates or binds R2. A brand-new Cloudflare account verifies
63
+ its email address before it can run a Worker; a missing `workers.dev` subdomain is
64
+ registered by Wrangler when Claude Code or Codex runs the deploy.
65
+
66
+ Storage for large media (songs, videos, big art) is a separate, optional step:
67
+
68
+ ```sh
69
+ npx homie-studio storage add # an R2 bucket, bound as MEDIA, served at /media/<key>
70
+ npx homie-studio deploy
71
+ npx homie-studio media put music/theme.wav
72
+ ```
73
+
74
+ Cloudflare asks for a payment method on the account before R2 works, even inside R2's free
75
+ tier (10 GB-month of storage, 1 million writes and 10 million reads a month), so `storage add`
76
+ refuses with the dashboard link until the account has R2 turned on, and creates nothing.
77
+ The free plan's daily limits (100,000 Worker requests; D1 5 million rows read and 100,000
78
+ written) reset at 00:00 UTC; past them requests fail until the reset and nothing is charged.
79
+
22
80
  An existing single-player web game becomes a multiplayer one with the port toolkit
23
81
  (the plugin's `port` skill drives it):
24
82
 
@@ -33,19 +91,26 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
33
91
  | `bin/homie-studio.mjs` | The CLI. |
34
92
  | `lib/scaffold.mjs` | `new`: the studio monorepo, only into a new or empty folder, every file listed. |
35
93
  | `lib/build.mjs` | `build`: games bundled with esbuild into `site/dist`, `games.json`, each game's shared `source.json`. |
36
- | `lib/cloudflare.mjs` | `deploy`: Wrangler, D1, R2, migrations, the directory claim; refuses resources it did not create. |
94
+ | `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. |
37
95
  | `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round. |
38
96
  | `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
39
97
  | `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. |
40
98
  | `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`. |
41
- | `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`). |
99
+ | `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). |
100
+ | `worker/stats.mjs`, `worker/stats-page.mjs`, `lib/stats.mjs` | The studio's own stats: what is counted and how, the owner-only `/api/stats` and `/_studio/stats`, and `homie-studio stats`. |
101
+ | `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. |
42
102
  | `netplay/` | The netplay contract (`NETPLAY.md`) and its game helper (`@homie-rocks/studio/netplay`). |
43
103
  | `starters/gem-rush/` | The reference multiplayer starter. |
44
104
 
45
105
  Versions are immutable: a published version never changes, so a studio that pinned one
46
- never changes by surprise. A change ships as a new `version`. Until the package is on the
47
- npm registry, each version's tarball is served at
48
- `https://homie.rocks/npm/homie-studio-<version>.tgz`.
106
+ never changes by surprise. A change ships as a new `version`, on the npm registry as
107
+ `@homie-rocks/studio` and as a tarball at `https://homie.rocks/npm/homie-studio-<version>.tgz`
108
+ (which is what `homie-studio new` pins).
109
+
110
+ ## Beta
111
+
112
+ This is a beta. Bugs, port requests and questions go to
113
+ <https://github.com/homie-rocks/homie/issues/new/choose>.
49
114
 
50
115
  ## License
51
116
 
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * homie-studio — make a game studio, make its games, put its site on the
4
- * studio's own Cloudflare, and list its games in the homie.rocks directory.
3
+ * homie-studio — make a studio (games, music, videos, posts), make its games,
4
+ * put its site on the studio's own Cloudflare (free plan, no payment method), and
5
+ * list its games in the homie.rocks directory.
5
6
  *
6
7
  * homie-studio new <folder> --name "<Studio Name>" [--homie <directory url>] [--no-install]
7
8
  * homie-studio starters
@@ -9,34 +10,43 @@
9
10
  * homie-studio game remix <source.json url> --id <new id>
10
11
  * homie-studio games
11
12
  * homie-studio build [<id>]
12
- * homie-studio dev [--port 8787]
13
+ * homie-studio dev [--port 8787] (--stop: stop exactly this studio's dev server, nothing else)
13
14
  * homie-studio check <id> [--url <site>] [--shots <dir>]
14
15
  * homie-studio port plan <game folder>
15
16
  * homie-studio port import <game folder> --id <id> [--name "<Name>"] [--mode static|bundle|command]
16
17
  * homie-studio port check <id> [--url <site>] [--only owner-desk,owner-phone,owner-iphone,round,life,tv] [--shots <dir>]
17
- * homie-studio deploy
18
+ * homie-studio deploy [--plan] (--plan: what it will create on Cloudflare and what it costs; changes nothing)
18
19
  * homie-studio publish
20
+ * homie-studio storage add (large media only: an R2 bucket; needs R2 turned on for the account)
19
21
  * homie-studio media put <file> [--as <key>]
22
+ * homie-studio media list
20
23
  * homie-studio status
24
+ * homie-studio stats [--range 1d|7d|30d|90d] [--game <id> | --song <slug> | --video <slug>] [--url <site>]
25
+ * homie-studio stats key [--hours 1] (a read key for the Homie MCP tool studio_stats)
26
+ * homie-studio stats link (a one-time link to the private stats page, for the owner's browser)
27
+ * homie-studio stats revoke (every stats key and page session ends)
28
+ * homie-studio stats share on|off (tell the homie.rocks directory "played this week", or stop)
21
29
  *
22
30
  * Every command prints a few lines for a person; --json prints the result.
23
31
  */
24
- import { spawn } from 'node:child_process';
25
- import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
26
- import { basename, join, relative, resolve } from 'node:path';
32
+ import { spawn, spawnSync } from 'node:child_process';
33
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
34
+ import { basename, dirname, join, relative, resolve } from 'node:path';
27
35
  import { build } from '../lib/build.mjs';
28
36
  import { check } from '../lib/check.mjs';
29
- import { deploy, whoami, wranglerBin } from '../lib/cloudflare.mjs';
37
+ import { deploy, deployPlan, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
30
38
  import { publish } from '../lib/directory.mjs';
31
39
  import { importPort, planPort } from '../lib/port.mjs';
32
40
  import { portCheck } from '../lib/port-check.mjs';
33
- import { newStudio } from '../lib/scaffold.mjs';
34
- import { listGames, newGame, readStudio, remixGame, requireStudio, starters, writeStudio } from '../lib/studio.mjs';
41
+ import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
42
+ import { ensureStatsMigration, newStudio } from '../lib/scaffold.mjs';
43
+ import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters } from '../lib/studio.mjs';
35
44
  import { STUDIO_VERSION } from '../lib/version.mjs';
45
+ import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
36
46
 
37
47
  const argv = process.argv.slice(2);
38
48
  const flags = new Map();
39
- const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install'];
49
+ const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop'];
40
50
  const positional = [];
41
51
  for (let i = 0; i < argv.length; i++) {
42
52
  const a = argv[i];
@@ -57,7 +67,7 @@ function print(result) {
57
67
  const lines = [];
58
68
  switch (result.command) {
59
69
  case 'new':
60
- lines.push(`${result.name} is a studio now: ${result.dir}`, '', 'Wrote:', ...result.wrote.map((f) => ` ${f}`), '', `Dependencies: ${result.installed}`, '', 'Next:', ...result.next.map((n) => ` ${n}`));
70
+ lines.push(`${result.name} is a studio now: ${result.dir}`, '', 'Wrote:', ...result.wrote.map((f) => ` ${f}`), '', `Dependencies: ${result.installed}`, '', 'Next:', ...result.next.map((n) => ` ${n}`), '', result.online);
61
71
  break;
62
72
  case 'game remix':
63
73
  lines.push(`games/${result.id} is a remix of ${result.from} (${result.files.length} files). Make it yours in games/${result.id}/src/, then: npx homie-studio dev`);
@@ -65,12 +75,35 @@ function print(result) {
65
75
  case 'game new':
66
76
  lines.push(`games/${result.id} is a new game from the ${result.from} starter. Change it in games/${result.id}/src/, then: npx homie-studio dev`);
67
77
  break;
78
+ case 'dev stop':
79
+ lines.push(result.stopped.length ? `Stopped this studio's dev server (${result.stopped.join(', ')}).` : `Nothing to stop: ${result.why ?? 'no dev server of this studio is running'}.`);
80
+ break;
68
81
  case 'build':
69
- lines.push(`Built ${result.games.map((g) => `${g.id} (${Math.round(g.bytes / 1024)} KB)`).join(', ') || 'nothing'} into ${relative(process.cwd(), result.dist) || result.dist}`);
82
+ lines.push(`Built ${result.games.map((g) => `${g.id} (${Math.round(g.bytes / 1024)} KB)`).join(', ') || 'no games'} into ${relative(process.cwd(), result.dist) || result.dist}`);
83
+ if (result.songs?.length || result.videos?.length) lines.push(`Media pages: ${[...result.songs.map((x) => `/music/${x}/`), ...result.videos.map((x) => `/videos/${x}/`)].join(', ')}`);
84
+ for (const m of result.mediaSkipped ?? []) lines.push(` left out: ${m.kind}/${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
85
+ break;
86
+ case 'media list':
87
+ for (const kind of ['music', 'videos']) {
88
+ lines.push(`${kind}/manifest.json: ${result[kind].pages.length} page(s)`);
89
+ for (const e of result[kind].pages) lines.push(` /${kind}/${e.slug}/ ${e.title} (${e.files.map((f) => `${f.role} from ${f.from}`).join(', ')})`);
90
+ for (const m of result[kind].skipped) lines.push(` left out: ${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
91
+ }
70
92
  break;
71
93
  case 'deploy':
72
- lines.push(`Live: ${result.url}`, ...result.games.map((g) => ` ${g.id}: ${g.play}`), '', `Cloudflare: Worker ${result.worker}, D1 ${result.d1}${result.r2 ? `, R2 ${result.r2}` : ', no R2 (not enabled on this account)'}`,
73
- result.claim ? 'Directory claim stored: list the games with the Homie MCP tool studio_publish, or: npx homie-studio publish' : 'No directory claim yet (the directory did not answer); publish will try again.');
94
+ lines.push(`Live: ${result.url}`, ...(result.workersDev && result.workersDev !== result.url ? [` also at ${result.workersDev}`] : []),
95
+ ...(result.local ? [` (the workers.dev address names your Cloudflare account, so it is kept in ${result.local} on this computer, never in studio.json)`] : []),
96
+ ...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' : ''}.`,
97
+ 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.');
98
+ break;
99
+ case 'deploy plan':
100
+ lines.push(`What \`npm run deploy\` does for ${result.studio}, on the Cloudflare account the person approves:`, '',
101
+ ...result.cloudflare.map((r) => ` ${r.kind}${r.name ? ` ${r.name}` : ''}: ${r.what} [${r.state}${r.plan ? `; ${r.plan}` : ''}]`), '',
102
+ `Cost: ${result.cost}`, `Sign-in: ${result.login}`, `Address: ${result.address}`,
103
+ `The directory (${result.directory.site}) stores: ${result.directory.stores}`, result.never);
104
+ break;
105
+ case 'storage add':
106
+ lines.push(result.already ? `Storage is already added: R2 bucket ${result.bucket}.` : `Storage added: R2 bucket ${result.bucket}.`, 'Next:', ...result.next.map((n) => ` ${n}`));
74
107
  break;
75
108
  case 'publish':
76
109
  lines.push(`Listed in the directory: ${result.studioPage ?? result.directory}`, ...(result.games ?? []).map((g) => ` ${g.name}: ${g.play}`));
@@ -91,6 +124,29 @@ function print(result) {
91
124
  lines.push(`${result.ok ? 'PASS' : 'NOT YET'}: ${result.game} at ${result.url} (${Math.round(result.totalMs / 1000)} s)`,
92
125
  ...result.passed.map((p) => ` ok ${p}`), ...result.failed.map((f) => ` FAIL ${f}`), ...result.skipped.map((f) => ` skip ${f}`), '', `Receipt and screenshots: ${result.out}`);
93
126
  break;
127
+ case 'stats': {
128
+ const t = result.totals;
129
+ const n = (x) => Number(x || 0).toLocaleString('en-US');
130
+ lines.push(`${result.studio ?? 'Studio'}: ${result.range.from} to ${result.range.to} (UTC)${result.only ? `, ${result.only.kind} ${result.only.id}` : ''}`,
131
+ ` ${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)`,
132
+ ` 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)}`,
133
+ ` 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)}`);
134
+ 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)}`);
135
+ for (const e of result.songs) lines.push(` song ${e.slug}: ${n(e.visits)} page visits, played ${n(e.plays)}`);
136
+ for (const e of result.videos) lines.push(` video ${e.slug}: ${n(e.visits)} page visits, watched ${n(e.views)}`);
137
+ for (const r of result.referrers.slice(0, 10)) lines.push(` from ${r.from} (${r.kind}): ${n(r.visits)} visits, ${n(r.plays)} plays`);
138
+ break;
139
+ }
140
+ case 'stats key':
141
+ lines.push(`Read key (until ${result.expiresAt}): ${result.key}`, result.use);
142
+ break;
143
+ case 'stats link':
144
+ lines.push(`One-time link (until ${result.expiresAt}): ${result.link}`, result.use);
145
+ break;
146
+ case 'stats revoke':
147
+ case 'stats share':
148
+ lines.push(result.message);
149
+ break;
94
150
  case 'check':
95
151
  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.`,
96
152
  ...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`));
@@ -117,7 +173,7 @@ async function main() {
117
173
  if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
118
174
  if (cmd === 'port' && sub === 'check') {
119
175
  const game = positional[2] ?? listGames(root)[0]?.id;
120
- const url = flags.get('url') ?? readStudio(root).cloudflare?.url;
176
+ const url = flags.get('url') ?? siteUrl(root);
121
177
  if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address or the live site)' };
122
178
  return portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
123
179
  }
@@ -127,60 +183,118 @@ async function main() {
127
183
  if (cmd === 'build') return build(root, { only: positional[1] ?? null, log });
128
184
  if (cmd === 'status') {
129
185
  const studio = readStudio(root);
130
- return { ok: true, command: 'status', root, studio, games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
186
+ return { ok: true, command: 'status', root, studio, site: siteUrl(root, studio), games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
131
187
  }
188
+ if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
132
189
  if (cmd === 'dev') return dev(root);
133
190
  if (cmd === 'check') {
134
191
  const game = positional[1] ?? listGames(root)[0]?.id;
135
- const url = flags.get('url') ?? readStudio(root).cloudflare?.url;
192
+ const url = flags.get('url') ?? siteUrl(root);
136
193
  if (!url) return { ok: false, command: 'check', why: 'give --url (the local dev address or the live site)' };
137
194
  return check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
138
195
  }
196
+ if (cmd === 'deploy' && flags.has('plan')) return deployPlan(root);
139
197
  if (cmd === 'deploy') return deploy(root, { log, homie: flags.get('homie') });
198
+ if (cmd === 'storage' && sub === 'add') return storageAdd(root, { log });
199
+ if (cmd === 'storage') {
200
+ const cf = readStudio(root).cloudflare ?? {};
201
+ const has = Boolean(cf.r2 && (cf.created ?? []).includes(`r2:${cf.r2}`));
202
+ 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)' };
203
+ }
140
204
  if (cmd === 'publish') return publish(root, { homie: flags.get('homie'), site: flags.get('site') });
205
+ if (cmd === 'stats' && sub === 'key') return statsKey(root, { url: flags.get('url'), hours: flags.get('hours') });
206
+ if (cmd === 'stats' && sub === 'link') return statsLink(root, { url: flags.get('url') });
207
+ if (cmd === 'stats' && sub === 'revoke') return statsRevoke(root, { url: flags.get('url') });
208
+ if (cmd === 'stats' && sub === 'share') return statsShare(root, positional[2]);
209
+ 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') });
141
210
  if (cmd === 'media' && sub === 'put') return mediaPut(root, positional[2], flags.get('as'));
211
+ if (cmd === 'media' && sub === 'list') {
212
+ const studio = readStudio(root);
213
+ const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
214
+ 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 }; };
215
+ return { ok: true, command: 'media list', r2, site: siteUrl(root, studio), music: view('music'), videos: view('videos') };
216
+ }
142
217
  return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
143
218
  }
144
219
 
220
+ /*
221
+ * WHICH DEV SERVER IS THIS STUDIO'S. `dev` writes its own PID and Wrangler's to site/.wrangler/homie-dev.json,
222
+ * and `dev --stop` stops exactly those two (each checked to still be a process of this studio's folder), so an AI
223
+ * never has to reach for `pkill -f "wrangler dev"`, which stops every project's dev server on the machine.
224
+ */
225
+ const devFile = (root) => join(root, 'site', '.wrangler', 'homie-dev.json');
226
+ function processOfStudio(pid, root, kind) {
227
+ if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
228
+ try { process.kill(pid, 0); } catch { return false; }
229
+ const ps = spawnSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' });
230
+ if (ps.status !== 0) return process.platform === 'win32'; // no ps: the PID file is all there is
231
+ let real = root;
232
+ try { real = realpathSync(root); } catch { /* keep */ }
233
+ // A PID the file names is still ours when it runs from this studio, or is still the kind of process it was
234
+ // (a recycled PID is neither), so nothing else on the machine is ever stopped.
235
+ return ps.stdout.includes(root) || ps.stdout.includes(real) || (kind === 'dev' ? /homie-studio/.test(ps.stdout) : /wrangler|workerd/.test(ps.stdout));
236
+ }
237
+ async function stopDev(root) {
238
+ const file = devFile(root);
239
+ if (!existsSync(file)) return { ok: true, command: 'dev stop', stopped: [], why: 'no dev server of this studio is running' };
240
+ let rec = {};
241
+ try { rec = JSON.parse(readFileSync(file, 'utf8')); } catch { /* a torn file */ }
242
+ const pids = [[rec.child, 'wrangler'], [rec.pid, 'dev']].filter(([p, kind]) => processOfStudio(p, root, kind)).map(([p]) => p);
243
+ for (const pid of pids) { try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ } }
244
+ const deadline = Date.now() + 8000;
245
+ const alive = () => pids.filter((p) => { try { process.kill(p, 0); return true; } catch { return false; } });
246
+ while (alive().length && Date.now() < deadline) await new Promise((r) => setTimeout(r, 200));
247
+ for (const pid of alive()) { try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } }
248
+ rmSync(file, { force: true });
249
+ return { ok: true, command: 'dev stop', stopped: pids };
250
+ }
251
+
145
252
  /** The whole site locally: pages, rooms (room.mjs in a local Durable Object), D1. */
146
253
  async function dev(root) {
147
254
  const port = String(flags.get('port') ?? 8787);
148
255
  const b = await build(root, { log });
149
- if (!b.catalogue.length) return { ok: false, command: 'dev', why: 'no games yet: npx homie-studio game new <id> --from gem-rush' };
256
+ if (!b.catalogue.length && !b.songs.length && !b.videos.length) return { ok: false, command: 'dev', why: 'nothing to show yet: npx homie-studio game new <id> --from gem-rush, or publish a song or video (media/MEDIA.md)' };
150
257
  const bin = wranglerBin(root);
151
258
  if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
152
259
  const studio = readStudio(root);
153
260
  const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1' };
261
+ const added = ensureStatsMigration(root);
262
+ if (added) log(`added ${added} (the studio's own stats)`);
154
263
  await new Promise((done) => {
155
264
  const m = spawn(bin, ['d1', 'migrations', 'apply', studio.cloudflare.d1, '--local'], { cwd: join(root, 'site'), env, stdio: ['ignore', 'ignore', 'inherit'] });
156
265
  m.on('close', done);
157
266
  });
158
267
  log(`Local site: http://127.0.0.1:${port}/ (each game: http://127.0.0.1:${port}/<id>/play — open it in two browsers)`);
159
268
  const child = spawn(bin, ['dev', '--local', '--ip', '127.0.0.1', '--port', port], { cwd: join(root, 'site'), env, stdio: 'inherit' });
269
+ mkdirSync(dirname(devFile(root)), { recursive: true });
270
+ writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
271
+ log(`Stop it with: npx --no-install homie-studio dev --stop (this studio's dev server only)`);
272
+ // Stopping this process stops Wrangler with it, so no dev server is left running on its own.
273
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => { try { child.kill(signal); } catch { /* gone */ } });
160
274
  await new Promise((done) => child.on('close', done));
275
+ rmSync(devFile(root), { force: true });
161
276
  return { ok: true, command: 'dev', stopped: true };
162
277
  }
163
278
 
164
- /** A big file into the studio's R2, and its line in music/ or videos/ manifest.json. */
279
+ /** A big file into the studio's R2, and its key on the music/ or videos/ manifest entry that names it. */
165
280
  function mediaPut(root, file, as) {
166
281
  if (!file || !existsSync(file) || !statSync(file).isFile()) return { ok: false, command: 'media put', why: 'usage: homie-studio media put <file> [--as <key>]' };
167
282
  const studio = readStudio(root);
168
283
  const r2 = studio.cloudflare?.r2;
169
- if (!r2 || !(studio.cloudflare?.created ?? []).includes(`r2:${r2}`)) return { ok: false, command: 'media put', why: 'this studio has no R2 bucket yet (deploy first; R2 needs to be enabled on the Cloudflare account)' };
284
+ if (!r2 || !(studio.cloudflare?.created ?? []).includes(`r2:${r2}`)) return { ok: false, command: 'media put', needs: 'storage', why: 'this studio has no storage yet. `npx --no-install homie-studio storage add` adds an R2 bucket; Cloudflare asks for a payment method on the account before R2 works (10 GB-month free), so ask the person first. Games never need it.' };
170
285
  const rel = relative(root, resolve(file));
171
286
  const folder = rel.startsWith('videos/') ? 'videos' : 'music';
172
- const key = as ?? `${folder}/${basename(file)}`;
287
+ // A file already in music/ or videos/ keeps its repository path as its key (two songs may share a file name).
288
+ const key = as ?? (/^(music|videos)\//.test(rel) && !rel.includes('..') ? rel : `${folder}/${basename(file)}`);
173
289
  const bin = wranglerBin(root);
174
290
  return new Promise((done) => {
175
- const p = spawn(bin, ['r2', 'object', 'put', `${r2}/${key}`, '--file', resolve(file), '--remote'], { cwd: join(root, 'site'), env: { ...process.env, CI: '1' }, stdio: ['ignore', 'ignore', 'inherit'] });
291
+ 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'] });
176
292
  p.on('close', (code) => {
177
293
  if (code !== 0) return done({ ok: false, command: 'media put', why: 'wrangler r2 object put failed' });
178
- const manifest = join(root, folder, 'manifest.json');
179
- const m = existsSync(manifest) ? JSON.parse(readFileSync(manifest, 'utf8')) : { items: [] };
180
- m.items = [...(m.items ?? []).filter((i) => i.key !== key), { key, file: rel, bytes: statSync(file).size, url: studio.cloudflare?.url ? `${studio.cloudflare.url}/media/${key}` : null, at: new Date().toISOString() }];
181
- writeFileSync(manifest, `${JSON.stringify(m, null, 2)}\n`);
182
- writeStudio(root, studio);
183
- done({ ok: true, command: 'media put', key, manifest: relative(root, manifest) });
294
+ // The manifest is committed: it names the file by its path on the site, never the workers.dev address.
295
+ const rec = recordUpload(root, rel, key, statSync(file).size);
296
+ const site = siteUrl(root, studio);
297
+ done({ ok: true, command: 'media put', key, manifest: rec.manifest, entry: rec.entry, url: site ? `${site}/media/${key}` : null });
184
298
  });
185
299
  });
186
300
  }
package/lib/build.mjs CHANGED
@@ -5,7 +5,9 @@
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[] } from studio.json and game.json files
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)
10
+ * site/dist/music/..., site/dist/videos/... media files the site serves itself (R2 keys are served from R2)
9
11
  *
10
12
  * game.json "build" picks how a game becomes files (a ported game keeps its own shape):
11
13
  * (absent) / { "mode": "bundle" } src/main.ts (or "entry") bundled by esbuild — new games and ES-module ports
@@ -19,7 +21,9 @@ import { spawnSync } from 'node:child_process';
19
21
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
20
22
  import { createRequire } from 'node:module';
21
23
  import { join, relative } from 'node:path';
24
+ import { buildMedia } from './media.mjs';
22
25
  import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
26
+ import { SEAT_MAX } from '../worker/seats.mjs';
23
27
  import { STUDIO_VERSION } from './version.mjs';
24
28
 
25
29
  const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
@@ -73,6 +77,31 @@ export function sourceOf(dir, id) {
73
77
  return { v: 1, kind: 'homie-game-source', id, files };
74
78
  }
75
79
 
80
+ function readJson(path) {
81
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
82
+ }
83
+
84
+ /**
85
+ * A game's netplay manifest: game.json's `netplay` block, over a `netplay.json` beside game.json or in the game's
86
+ * built output (a ported Vite game ships public/netplay.json), over nothing.
87
+ */
88
+ export function netplayOf(g, out = null) {
89
+ const file = readJson(join(g.dir, 'netplay.json')) ?? (out ? readJson(join(out, 'netplay.json')) : null) ?? {};
90
+ return { ...(file && typeof file === 'object' ? file : {}), ...(g.netplay && typeof g.netplay === 'object' ? g.netplay : {}) };
91
+ }
92
+
93
+ /**
94
+ * How many seats a room of this game has: the netplay manifest's `maxPlayers` (or `players.max`), else game.json's
95
+ * `players.max`, else 8; at most SEAT_MAX (32). `asked` is what the game named, so a build can say it was capped.
96
+ */
97
+ export function seatsFor(g, net = netplayOf(g)) {
98
+ const named = [net.maxPlayers, net.players?.max, g.players?.max].map((n) => Math.floor(Number(n))).find((n) => Number.isFinite(n) && n >= 1);
99
+ const asked = named ?? 8;
100
+ const max = Math.min(SEAT_MAX, asked);
101
+ const minNamed = [net.minPlayers, net.players?.min, g.players?.min].map((n) => Math.floor(Number(n))).find((n) => Number.isFinite(n) && n >= 1);
102
+ return { min: Math.min(max, minNamed ?? 1), max, asked };
103
+ }
104
+
76
105
  export async function build(root, { only = null, log = () => {} } = {}) {
77
106
  const require = createRequire(join(root, 'package.json'));
78
107
  let esbuild;
@@ -131,18 +160,41 @@ export async function build(root, { only = null, log = () => {} } = {}) {
131
160
  if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id))}\n`);
132
161
  const main = join(out, 'assets', 'main.js');
133
162
  const bytes = existsSync(main) ? statSync(main).size : dirBytes(out);
134
- built.push({ id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings });
163
+ const seats = seatsFor(g, netplayOf(g, out));
164
+ 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`);
165
+ built.push({ id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings, seats: seats.max });
135
166
  log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB)`);
136
167
  }
137
168
  const all = listGames(root);
169
+ // Songs and videos (music/ and videos/ manifests): rebuilt with every full build; a one-game build keeps them.
170
+ const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
171
+ let media = null;
172
+ if (!only) media = buildMedia(root, dist, { r2, log });
173
+ else {
174
+ try { const prev = JSON.parse(readFileSync(join(dist, 'games.json'), 'utf8')); media = { songs: prev.songs ?? [], videos: prev.videos ?? [], skipped: [] }; } catch { media = { songs: [], videos: [], skipped: [] }; }
175
+ }
138
176
  const catalogue = {
139
- studio: { name: studio.name, slug: studio.slug, version: STUDIO_VERSION },
140
- games: all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))).map((g) => ({
141
- id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: g.players ?? { min: 1, max: 8 },
142
- roundSeconds: g.roundSeconds ?? null, movement: g.netplay?.movement ?? null, cover: g.cover ?? null,
143
- ...(g.screen ? { screen: g.screen } : {}),
144
- })),
177
+ studio: {
178
+ name: studio.name, slug: studio.slug, version: STUDIO_VERSION,
179
+ // studio.json `stats.share`: the site tells the directory two numbers for the hub (played this week).
180
+ ...(studio.stats?.share === true ? { stats: { share: true }, directory: studio.homie?.directory ?? 'https://homie.rocks' } : {}),
181
+ },
182
+ games: all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))).map((g) => {
183
+ // The seats come from the game's netplay manifest (NETPLAY.md §3): what the Worker gives every room of it.
184
+ const net = netplayOf(g, join(dist, 'games', g.id));
185
+ const { min, max } = seatsFor(g, net);
186
+ return {
187
+ id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
188
+ roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
189
+ ...(g.screen ? { screen: g.screen } : {}),
190
+ };
191
+ }),
192
+ songs: media.songs,
193
+ videos: media.videos,
145
194
  };
146
195
  writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
147
- return { ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id) };
196
+ return {
197
+ ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id),
198
+ songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped,
199
+ };
148
200
  }