@homie-rocks/studio 0.5.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 +38 -2
- package/bin/homie-studio.mjs +49 -9
- package/lib/build.mjs +44 -7
- package/lib/cloudflare.mjs +28 -11
- package/lib/directory.mjs +2 -2
- package/lib/media.mjs +2 -2
- package/lib/scaffold.mjs +47 -3
- package/lib/stats.mjs +125 -0
- package/lib/studio.mjs +42 -0
- package/netplay/NETPLAY.md +55 -12
- package/netplay/netplay.ts +19 -2
- package/package.json +1 -1
- package/worker/index.mjs +145 -19
- package/worker/pages.mjs +8 -2
- package/worker/room.mjs +9 -2
- package/worker/seats.mjs +20 -0
- package/worker/stats-page.mjs +144 -0
- package/worker/stats.mjs +311 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Most people never run this by hand: the Homie plugin for Claude Code and Codex d
|
|
|
10
10
|
and the person approves Cloudflare once in their browser.
|
|
11
11
|
|
|
12
12
|
```sh
|
|
13
|
-
npx -y --package=https://homie.rocks/npm/homie-studio-0.
|
|
13
|
+
npx -y --package=https://homie.rocks/npm/homie-studio-0.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
|
|
@@ -18,8 +18,43 @@ 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
|
+
## 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
|
+
|
|
23
58
|
## No payment method needed
|
|
24
59
|
|
|
25
60
|
`deploy` creates only what Cloudflare's free Workers plan gives a new account with no
|
|
@@ -61,7 +96,8 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
|
|
|
61
96
|
| `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
|
|
62
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. |
|
|
63
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`. |
|
|
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>`). |
|
|
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`. |
|
|
65
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. |
|
|
66
102
|
| `netplay/` | The netplay contract (`NETPLAY.md`) and its game helper (`@homie-rocks/studio/netplay`). |
|
|
67
103
|
| `starters/gem-rush/` | The reference multiplayer starter. |
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
* homie-studio media put <file> [--as <key>]
|
|
22
22
|
* homie-studio media list
|
|
23
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)
|
|
24
29
|
*
|
|
25
30
|
* Every command prints a few lines for a person; --json prints the result.
|
|
26
31
|
*/
|
|
@@ -34,9 +39,10 @@ import { publish } from '../lib/directory.mjs';
|
|
|
34
39
|
import { importPort, planPort } from '../lib/port.mjs';
|
|
35
40
|
import { portCheck } from '../lib/port-check.mjs';
|
|
36
41
|
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';
|
|
42
|
+
import { ensureStatsMigration, newStudio } from '../lib/scaffold.mjs';
|
|
43
|
+
import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters } from '../lib/studio.mjs';
|
|
39
44
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
45
|
+
import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
|
|
40
46
|
|
|
41
47
|
const argv = process.argv.slice(2);
|
|
42
48
|
const flags = new Map();
|
|
@@ -85,7 +91,9 @@ function print(result) {
|
|
|
85
91
|
}
|
|
86
92
|
break;
|
|
87
93
|
case 'deploy':
|
|
88
|
-
lines.push(`Live: ${result.url}`, ...result.
|
|
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' : ''}.`,
|
|
89
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.');
|
|
90
98
|
break;
|
|
91
99
|
case 'deploy plan':
|
|
@@ -116,6 +124,29 @@ function print(result) {
|
|
|
116
124
|
lines.push(`${result.ok ? 'PASS' : 'NOT YET'}: ${result.game} at ${result.url} (${Math.round(result.totalMs / 1000)} s)`,
|
|
117
125
|
...result.passed.map((p) => ` ok ${p}`), ...result.failed.map((f) => ` FAIL ${f}`), ...result.skipped.map((f) => ` skip ${f}`), '', `Receipt and screenshots: ${result.out}`);
|
|
118
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;
|
|
119
150
|
case 'check':
|
|
120
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.`,
|
|
121
152
|
...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`));
|
|
@@ -142,7 +173,7 @@ async function main() {
|
|
|
142
173
|
if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
|
|
143
174
|
if (cmd === 'port' && sub === 'check') {
|
|
144
175
|
const game = positional[2] ?? listGames(root)[0]?.id;
|
|
145
|
-
const url = flags.get('url') ??
|
|
176
|
+
const url = flags.get('url') ?? siteUrl(root);
|
|
146
177
|
if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address or the live site)' };
|
|
147
178
|
return portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
|
|
148
179
|
}
|
|
@@ -152,13 +183,13 @@ async function main() {
|
|
|
152
183
|
if (cmd === 'build') return build(root, { only: positional[1] ?? null, log });
|
|
153
184
|
if (cmd === 'status') {
|
|
154
185
|
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)) };
|
|
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)) };
|
|
156
187
|
}
|
|
157
188
|
if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
|
|
158
189
|
if (cmd === 'dev') return dev(root);
|
|
159
190
|
if (cmd === 'check') {
|
|
160
191
|
const game = positional[1] ?? listGames(root)[0]?.id;
|
|
161
|
-
const url = flags.get('url') ??
|
|
192
|
+
const url = flags.get('url') ?? siteUrl(root);
|
|
162
193
|
if (!url) return { ok: false, command: 'check', why: 'give --url (the local dev address or the live site)' };
|
|
163
194
|
return check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
|
|
164
195
|
}
|
|
@@ -171,12 +202,17 @@ async function main() {
|
|
|
171
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)' };
|
|
172
203
|
}
|
|
173
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') });
|
|
174
210
|
if (cmd === 'media' && sub === 'put') return mediaPut(root, positional[2], flags.get('as'));
|
|
175
211
|
if (cmd === 'media' && sub === 'list') {
|
|
176
212
|
const studio = readStudio(root);
|
|
177
213
|
const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
|
|
178
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 }; };
|
|
179
|
-
return { ok: true, command: 'media list', r2, site: studio
|
|
215
|
+
return { ok: true, command: 'media list', r2, site: siteUrl(root, studio), music: view('music'), videos: view('videos') };
|
|
180
216
|
}
|
|
181
217
|
return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
|
|
182
218
|
}
|
|
@@ -222,6 +258,8 @@ async function dev(root) {
|
|
|
222
258
|
if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
|
|
223
259
|
const studio = readStudio(root);
|
|
224
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)`);
|
|
225
263
|
await new Promise((done) => {
|
|
226
264
|
const m = spawn(bin, ['d1', 'migrations', 'apply', studio.cloudflare.d1, '--local'], { cwd: join(root, 'site'), env, stdio: ['ignore', 'ignore', 'inherit'] });
|
|
227
265
|
m.on('close', done);
|
|
@@ -253,8 +291,10 @@ function mediaPut(root, file, as) {
|
|
|
253
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'] });
|
|
254
292
|
p.on('close', (code) => {
|
|
255
293
|
if (code !== 0) return done({ ok: false, command: 'media put', why: 'wrangler r2 object put failed' });
|
|
256
|
-
|
|
257
|
-
|
|
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 });
|
|
258
298
|
});
|
|
259
299
|
});
|
|
260
300
|
}
|
package/lib/build.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { createRequire } from 'node:module';
|
|
|
23
23
|
import { join, relative } from 'node:path';
|
|
24
24
|
import { buildMedia } from './media.mjs';
|
|
25
25
|
import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
|
|
26
|
+
import { SEAT_MAX } from '../worker/seats.mjs';
|
|
26
27
|
import { STUDIO_VERSION } from './version.mjs';
|
|
27
28
|
|
|
28
29
|
const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
|
|
@@ -76,6 +77,31 @@ export function sourceOf(dir, id) {
|
|
|
76
77
|
return { v: 1, kind: 'homie-game-source', id, files };
|
|
77
78
|
}
|
|
78
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
|
+
|
|
79
105
|
export async function build(root, { only = null, log = () => {} } = {}) {
|
|
80
106
|
const require = createRequire(join(root, 'package.json'));
|
|
81
107
|
let esbuild;
|
|
@@ -134,7 +160,9 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
134
160
|
if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id))}\n`);
|
|
135
161
|
const main = join(out, 'assets', 'main.js');
|
|
136
162
|
const bytes = existsSync(main) ? statSync(main).size : dirBytes(out);
|
|
137
|
-
|
|
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 });
|
|
138
166
|
log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB)`);
|
|
139
167
|
}
|
|
140
168
|
const all = listGames(root);
|
|
@@ -146,12 +174,21 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
146
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: [] }; }
|
|
147
175
|
}
|
|
148
176
|
const catalogue = {
|
|
149
|
-
studio: {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
+
}),
|
|
155
192
|
songs: media.songs,
|
|
156
193
|
videos: media.videos,
|
|
157
194
|
};
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
198
|
-
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
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
|
|
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/media.mjs
CHANGED
|
@@ -111,7 +111,7 @@ export function buildMedia(root, dist, { r2 = false, log = () => {} } = {}) {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
/** `homie-studio media put`: record an uploaded file's R2 key on the entry that names it (or a bare entry). */
|
|
114
|
-
export function recordUpload(root, rel, key, bytes
|
|
114
|
+
export function recordUpload(root, rel, key, bytes) {
|
|
115
115
|
const kind = rel.startsWith('videos/') ? 'videos' : 'music';
|
|
116
116
|
const m = readManifest(root, kind);
|
|
117
117
|
let hit = false;
|
|
@@ -120,7 +120,7 @@ export function recordUpload(root, rel, key, bytes, siteUrl) {
|
|
|
120
120
|
if (f.path === rel) { f.key = key; f.bytes = bytes; hit = true; }
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
|
-
if (!hit) m.items = [...m.items.filter((i) => i.key !== key), { key, file: rel, bytes, url:
|
|
123
|
+
if (!hit) m.items = [...m.items.filter((i) => i.key !== key), { key, file: rel, bytes, url: `/media/${key.split('/').map(encodeURIComponent).join('/')}`, at: new Date().toISOString() }];
|
|
124
124
|
writeManifest(root, kind, m);
|
|
125
125
|
return { kind, manifest: relative(root, manifestPath(root, kind)), entry: hit };
|
|
126
126
|
}
|
package/lib/scaffold.mjs
CHANGED
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
* lists every file it writes, so the person sees exactly what changed.
|
|
20
20
|
*/
|
|
21
21
|
import { execFileSync } from 'node:child_process';
|
|
22
|
-
import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { homedir } from 'node:os';
|
|
24
24
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
25
25
|
import { STUDIO_VERSION, packageSpec } from './version.mjs';
|
|
26
|
+
import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
|
|
26
27
|
|
|
27
28
|
export const COMPAT_DATE = '2026-06-01';
|
|
28
29
|
export const WRANGLER_VERSION = '4.126.0';
|
|
@@ -74,7 +75,7 @@ directory lists its games; homie.rocks does not host them.
|
|
|
74
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. |
|
|
75
76
|
| \`posts/\` | Markdown the studio publishes. |
|
|
76
77
|
| \`site/\` | The studio's Worker (\`src/worker.mjs\`), D1 migrations, \`wrangler.jsonc\`. |
|
|
77
|
-
| \`studio.json\` | The studio's name, slug
|
|
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. |
|
|
78
79
|
| \`.claude/skills/\` | Skills only this studio uses. Homie's own skills come from the Homie plugin. |
|
|
79
80
|
|
|
80
81
|
## Commands (all through the pinned CLI in node_modules)
|
|
@@ -107,6 +108,13 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
107
108
|
is a separate step the person agrees to; nothing else needs it.
|
|
108
109
|
- \`npx --no-install homie-studio publish\` — list this studio's games in the homie.rocks directory
|
|
109
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.
|
|
110
118
|
|
|
111
119
|
## Making games
|
|
112
120
|
|
|
@@ -125,6 +133,10 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
125
133
|
- Keys stay in the providers' own logins (Wrangler, ElevenLabs, fal) or the OS
|
|
126
134
|
keychain. Never write a key, token or password into this repository.
|
|
127
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.
|
|
128
140
|
- Nothing in this studio needs \`~/.homie\` or a Homie box.
|
|
129
141
|
|
|
130
142
|
## Beta
|
|
@@ -170,6 +182,8 @@ CREATE INDEX IF NOT EXISTS rounds_game_at ON rounds (game, at);
|
|
|
170
182
|
`;
|
|
171
183
|
|
|
172
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/
|
|
173
187
|
site/dist/
|
|
174
188
|
site/.wrangler/
|
|
175
189
|
.wrangler/
|
|
@@ -197,7 +211,12 @@ export function studioFiles({ name, slug, homie }) {
|
|
|
197
211
|
name, slug,
|
|
198
212
|
homie: { studio: STUDIO_VERSION, directory: homie },
|
|
199
213
|
// r2 stays null until `homie-studio storage add`: a new studio deploys with no R2 at all.
|
|
200
|
-
|
|
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 },
|
|
201
220
|
};
|
|
202
221
|
const pkg = {
|
|
203
222
|
name: `${slug}-studio`,
|
|
@@ -224,11 +243,36 @@ export function studioFiles({ name, slug, homie }) {
|
|
|
224
243
|
export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
225
244
|
`,
|
|
226
245
|
'site/migrations/0001_studio.sql': MIGRATION,
|
|
246
|
+
[`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
|
|
227
247
|
'site/wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2 }),
|
|
228
248
|
'.claude/skills/.gitkeep': '',
|
|
229
249
|
};
|
|
230
250
|
}
|
|
231
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
|
+
|
|
232
276
|
function insideGit(dir) {
|
|
233
277
|
try { return execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() === 'true'; } catch { return false; }
|
|
234
278
|
}
|