@homie-rocks/studio 0.11.0 → 0.12.1

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/progress.mjs CHANGED
@@ -57,6 +57,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, sta
57
57
  import { extname, join } from 'node:path';
58
58
  import { refreshCodexFile } from './codex.mjs';
59
59
  import { readStudio, siteUrl } from './studio.mjs';
60
+ import { request, whyRefused } from './net.mjs';
60
61
 
61
62
  export const PROGRESS_KIND = 'homie-studio-progress';
62
63
  export const PROGRESS_DIR = join('.studio', 'progress');
@@ -89,18 +90,10 @@ const keyPath = (root, id) => join(dir(root), `${id}.key`);
89
90
  const currentPath = (root) => join(dir(root), 'current');
90
91
  const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
91
92
 
92
- /*
93
- * A CLAUDE CODE CLOUD SESSION ON THE DEFAULT NETWORK. Its "Trusted" network reaches package registries and GitHub,
94
- * not homie.rocks: the session's proxy answers 403 with `x-deny-reason: host_not_allowed`. Say which setting lets
95
- * the card follow the build, instead of "did not answer"; the build itself goes on with its local feed.
96
- */
93
+ /** Kept for callers of 0.11.0: whether an answer is the proxy refusing the host (lib/net.mjs says the rest). */
97
94
  export function networkWhy(res, at) {
98
- if (res?.status === 403 && /host_not_allowed/i.test(res.headers?.get?.('x-deny-reason') ?? '')) {
99
- let host = at;
100
- try { host = new URL(at).host; } catch { /* as given */ }
101
- return `this Claude Code cloud environment's network does not reach ${host}. In claude.ai/code, open the environment's settings, set Network access to Custom, add ${host} (keep "Also include default list of common package managers"), and start a new session; until then the build goes on with its local feed only`;
102
- }
103
- return null;
95
+ const r = whyRefused(res, null, '', at);
96
+ return r.needs === 'network' ? r.why : null;
104
97
  }
105
98
 
106
99
  /** The studio's live address for the card: an https origin (or this computer's own dev site), else null. */
@@ -153,23 +146,24 @@ async function pushDoc(root, id) {
153
146
  let key = null;
154
147
  try { key = readFileSync(keyPath(root, id), 'utf8').trim(); } catch { return null; }
155
148
  const { shared, ...body } = doc;
156
- const res = await fetch(`${shared.directory}/api/studio/progress/${shared.build}`, {
149
+ const sent = await request(`${shared.directory}/api/studio/progress/${shared.build}`, {
157
150
  method: 'PUT',
158
151
  headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', 'user-agent': 'homie-studio' },
159
152
  body: JSON.stringify({ ...body, build: shared.build }),
160
- signal: AbortSignal.timeout(8000),
161
- });
162
- const blocked = networkWhy(res, shared.directory);
163
- if (blocked) {
164
- mutateDoc(root, id, (d) => { d.shared = { ...d.shared, ended: true }; pushLog(d, `The Claude app cannot follow this build: ${blocked}`); });
165
- return null;
166
- }
167
- if (res.status === 404 || res.status === 410) {
168
- // The shared copy ended (24 hours, or the directory dropped it): keep the local feed, stop sending.
169
- mutateDoc(root, id, (d) => { d.shared = { ...d.shared, ended: true }; });
153
+ }, { timeout: 8000 });
154
+ if (!sent.ok) {
155
+ if (sent.status === 404 || sent.status === 410 || sent.needs === 'network') {
156
+ // The shared copy ended (24 hours, or the directory dropped it), or the proxy refuses the directory: keep the
157
+ // local feed, stop sending, and say why in its log.
158
+ mutateDoc(root, id, (d) => { d.shared = { ...d.shared, ended: true }; pushLog(d, `The Claude app's card stopped following this build: ${sent.why}`); });
159
+ return null;
160
+ }
161
+ // Anything else (a slow answer, a refused write): the next write tries again; the log keeps what happened once.
162
+ mutateDoc(root, id, (d) => { if (d.shared && d.shared.lastError !== sent.why) { d.shared = { ...d.shared, lastError: sent.why }; pushLog(d, `Sending to the card failed: ${sent.why}`); } });
170
163
  return null;
171
164
  }
172
- const reply = await res.json().catch(() => null);
165
+ const reply = sent.body;
166
+ if (readFeed(root, id)?.shared?.lastError) mutateDoc(root, id, (d) => { delete d.shared.lastError; });
173
167
  if (reply?.stop) {
174
168
  mutateDoc(root, id, (d) => {
175
169
  if (!d.stop?.requested) { d.stop = { requested: true, at: now(), by: 'person' }; pushLog(d, 'Stop pressed in the Claude app; stopping at the next safe point.'); }
@@ -467,36 +461,38 @@ export async function startProgress(root, { what = 'game', id, title, budget, un
467
461
  writeAtomic(currentPath(root), `${build}\n`);
468
462
  let shared = null;
469
463
  let why = null;
464
+ let failure = {};
470
465
  if (share) {
471
466
  const at = String(directory || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
472
467
  if (!/^https:\/\//.test(at) && !(/^http:\/\//.test(at) && loopback(at))) why = `the directory ${at} is not https`;
473
468
  else {
474
- try {
475
- // A build the chat opened is attached (once); otherwise a new one is opened here.
476
- const res = await fetch(attach ? `${at}/api/studio/progress/${attach}/attach` : `${at}/api/studio/progress`, {
477
- method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'homie-studio' },
478
- body: JSON.stringify({ what, id: id ?? null, title: doc.title, studio: doc.studio }), signal: AbortSignal.timeout(10_000),
479
- });
480
- const body = await res.json().catch(() => null);
481
- if (!res.ok && networkWhy(res, at)) why = networkWhy(res, at);
482
- else if (res.ok && body?.ok && REMOTE_ID.test(String(body.build)) && /^hbk_[a-f0-9]{48}$/.test(String(body.key))) {
483
- // The chat named the build: its title is the one the person saw.
484
- if (attach && body.title && !title) mutateDoc(root, build, (d) => { d.title = plain(body.title, LIMITS.title); });
485
- writeFileSync(keyPath(root, build), `${body.key}\n`, { mode: 0o600 });
486
- try { chmodSync(keyPath(root, build), 0o600); } catch { /* not on this filesystem */ }
487
- shared = { build: body.build, directory: at, expiresAt: body.expiresAt ?? null };
488
- mutateDoc(root, build, (d) => { d.shared = { build: body.build, directory: at }; });
489
- schedule(root, build, { soon: true });
490
- heartbeat(root, build, true);
491
- } else why = body?.message ?? `${at} answered ${res.status}`;
492
- } catch (error) { why = `${at} did not answer (${error?.message ?? error})`; }
469
+ // A build the chat opened is attached (once); otherwise a new one is opened here.
470
+ const sent = await request(attach ? `${at}/api/studio/progress/${attach}/attach` : `${at}/api/studio/progress`, {
471
+ method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'homie-studio' },
472
+ body: JSON.stringify({ what, id: id ?? null, title: doc.title, studio: doc.studio }),
473
+ });
474
+ const body = sent.body;
475
+ if (sent.ok && REMOTE_ID.test(String(body?.build)) && /^hbk_[a-f0-9]{48}$/.test(String(body?.key))) {
476
+ // The chat named the build: its title is the one the person saw.
477
+ if (attach && body.title && !title) mutateDoc(root, build, (d) => { d.title = plain(body.title, LIMITS.title); });
478
+ writeFileSync(keyPath(root, build), `${body.key}\n`, { mode: 0o600 });
479
+ try { chmodSync(keyPath(root, build), 0o600); } catch { /* not on this filesystem */ }
480
+ shared = { build: body.build, directory: at, expiresAt: body.expiresAt ?? null };
481
+ mutateDoc(root, build, (d) => { d.shared = { build: body.build, directory: at }; });
482
+ schedule(root, build, { soon: true });
483
+ heartbeat(root, build, true);
484
+ } else {
485
+ // As it is: the directory's own answer (its status and message) or the connection's error, never a guess.
486
+ why = sent.ok ? `${at} answered without a build and key` : sent.why;
487
+ failure = { ...(sent.status ? { status: sent.status } : {}), ...(sent.code ? { code: sent.code } : {}), ...(sent.needs ? { needs: sent.needs } : {}) };
488
+ }
493
489
  }
494
490
  }
495
491
  if (attach && !shared) {
496
492
  // Attaching is the point of `progress attach`: without it, say why and leave no open feed behind.
497
493
  rmSync(docPath(root, build), { force: true });
498
494
  rmSync(currentPath(root), { force: true });
499
- return { ok: false, command: 'progress attach', build: attach, why: `could not attach to ${attach}: ${why}` };
495
+ return { ok: false, command: 'progress attach', build: attach, ...failure, why: `could not attach to ${attach}: ${why}` };
500
496
  }
501
497
  return {
502
498
  ok: true, command: attach ? 'progress attach' : 'progress start', build, file: join(PROGRESS_DIR, `${build}.json`), what, id: id ?? null, title: readFeed(root, build)?.title ?? doc.title,
package/lib/repo.mjs ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * WHICH GITHUB REPOSITORY THIS STUDIO IS. The Claude app's hand-off opens Claude Code on it, so it must be the
3
+ * studio's own: never the engine and template repository (homie-rocks/homie) that Cloudflare's "Deploy to
4
+ * Cloudflare" copied the studio from, and never a guess.
5
+ *
6
+ * In order:
7
+ * 1. HOMIE_REPO, a build variable naming it;
8
+ * 2. studio.json `github` (`setup attach` writes it, and the person may);
9
+ * 3. the checkout's git remotes, origin first. A Workers Builds checkout, a Claude Code cloud session (its remote
10
+ * goes through the session's own git proxy) and a person's clone all carry one. Only owner/name is kept,
11
+ * never a token in the URL.
12
+ *
13
+ * A deploy hands it to the live Worker as a variable (HOMIE_REPO), never into a public file. The Worker tells its
14
+ * directory alongside its claim, so the setup card can open the right repository.
15
+ */
16
+ import { spawnSync } from 'node:child_process';
17
+ import { readStudio } from './studio.mjs';
18
+
19
+ export const REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/;
20
+ /** The engine and template repository: a studio is never this. */
21
+ export const ENGINE_REPO = 'homie-rocks/homie';
22
+
23
+ /** owner/name in a git remote URL (https, ssh, a proxy's /git/owner/name path), without any credentials; or null. */
24
+ export function repoFromUrl(url) {
25
+ const s = String(url ?? '').trim().replace(/\.git$/, '').replace(/\/+$/, '');
26
+ if (!s) return null;
27
+ const m = /[/:]([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100})$/.exec(s);
28
+ return m ? studioRepo(`${m[1]}/${m[2]}`) : null;
29
+ }
30
+
31
+ /** A studio repository slug, or null (not a slug, or the engine repository). */
32
+ export function studioRepo(value) {
33
+ const r = String(value ?? '').trim();
34
+ return REPO.test(r) && r.toLowerCase() !== ENGINE_REPO ? r : null;
35
+ }
36
+
37
+ /** This studio's repository: HOMIE_REPO, studio.json `github`, else its git remotes (origin first). */
38
+ export function repoOf(root, { env = process.env } = {}) {
39
+ const named = studioRepo(env.HOMIE_REPO);
40
+ if (named) return named;
41
+ try { const s = studioRepo(readStudio(root).github); if (s) return s; } catch { /* no studio.json */ }
42
+ const git = (args) => spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 5000 });
43
+ const origin = git(['remote', 'get-url', 'origin']);
44
+ if (origin.status === 0) { const r = repoFromUrl(origin.stdout); if (r) return r; }
45
+ const all = git(['config', '--get-regexp', '^remote\\..*\\.url$']);
46
+ for (const line of (all.status === 0 ? all.stdout : '').split('\n')) {
47
+ const r = repoFromUrl(line.split(/\s+/)[1]);
48
+ if (r) return r;
49
+ }
50
+ return null;
51
+ }
package/lib/scaffold.mjs CHANGED
@@ -24,6 +24,7 @@ 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
26
  import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
27
+ import { PLAYERS_MIGRATION, PLAYERS_MIGRATION_FILE } from '../worker/players.mjs';
27
28
  import { themeFile } from './site.mjs';
28
29
  import { PACKAGE_ROOT } from './studio.mjs';
29
30
 
@@ -149,6 +150,10 @@ studio's pinned copy, never a registry lookup of the bare name.
149
150
  \`studio_stats\` (never paste a key anywhere else). \`stats share on\` tells the directory two numbers
150
151
  (played this week). The site counts and never tracks: no cookie on a visitor, no person identified,
151
152
  nothing sent anywhere; house QA and \`check\` runs are not counted.
153
+ - \`npx --no-install homie-studio players\` — how many players have accounts here (and guests, and who played
154
+ this week): counts and names for the owner, never a passkey or an email. \`players owner\` gives the owner a
155
+ one-time link that marks their own player account (a passkey on this site) as the owner's, so their games and
156
+ the studio's back office recognise them.
152
157
  - \`npx --no-install homie-studio upgrade\` — after pinning a newer \`@homie-rocks/studio\` (or through
153
158
  \`npx -y --package=<its tarball> homie-studio upgrade\`): what the newer template adds to this studio (AGENTS.md
154
159
  sections, READMEs, .gitignore lines) and what it keeps. It changes nothing until \`--apply\`, and never
@@ -191,6 +196,13 @@ studio's pinned copy, never a registry lookup of the bare name.
191
196
  a small icon (\`node_modules/@homie-rocks/studio/site/SITE.md\`). Look at it on a phone and a computer.
192
197
  - Change a game in small steps, build, and look at it (\`dev\`, then \`check\`).
193
198
  - A game's id is its URL (\`/<id>/\`); keep it once published.
199
+ - **Progress that lasts** (a character, unlocks, a collection, days of play) goes in **saves**, never in the room:
200
+ a room forgets everything 60 s after its last player leaves. game.json \`"saves": true\` and
201
+ \`createSaves\` from \`@homie-rocks/studio/saves\` (\`node_modules/@homie-rocks/studio/saves/SAVES.md\`): per
202
+ player and game, versioned, offline-tolerant, in this studio's own D1. Pressing Play needs no account; a guest's
203
+ progress stays on that device until they make a passkey account (at \`/account/\`, or the game's own button), and
204
+ then it follows them to every device. Lifetime stats and a hardcore "hall of the fallen" are in it too. The
205
+ \`ember-vale\` starter shows the whole pattern.
194
206
 
195
207
  ## The Game Codex and progress
196
208
 
@@ -359,7 +371,7 @@ export function studioFiles({ name, slug, homie, template = false }) {
359
371
  products: ['Workers', 'D1', 'Durable Objects'],
360
372
  bindings: {
361
373
  STUDIO_NAME: { description: 'Your studio\'s name, as its site shows it (for example **Night Owls**). Claude can change it later.' },
362
- DB: { description: 'The studio\'s own database: finished rounds, the studio\'s own stats (counts, never a visitor) and its claim in the [homie.rocks](https://homie.rocks/studios/) directory. Free plan.' },
374
+ DB: { description: 'The studio\'s own database: finished rounds, the studio\'s own stats (counts, never a visitor), player accounts and cloud saves for games that keep them, and its claim in the [homie.rocks](https://homie.rocks/studios/) directory. Free plan.' },
363
375
  },
364
376
  },
365
377
  };
@@ -384,6 +396,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
384
396
  `,
385
397
  'site/migrations/0001_studio.sql': MIGRATION,
386
398
  [`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
399
+ [`site/migrations/${PLAYERS_MIGRATION_FILE}`]: PLAYERS_MIGRATION,
387
400
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
388
401
  '.claude/skills/.gitkeep': '',
389
402
  };
@@ -453,6 +466,23 @@ export function ensureStatsMigration(root) {
453
466
  return `site/migrations/${STATS_MIGRATION_FILE}`;
454
467
  }
455
468
 
469
+ /**
470
+ * A studio made before 0.12.0 has no players migration (player accounts and cloud saves, saves/SAVES.md): add it,
471
+ * so the next `d1 migrations apply` makes the tables. Returns the file it wrote, or null when it was there.
472
+ */
473
+ export function ensurePlayersMigration(root) {
474
+ const file = join(root, 'site', 'migrations', PLAYERS_MIGRATION_FILE);
475
+ if (existsSync(file)) return null;
476
+ mkdirSync(dirname(file), { recursive: true });
477
+ writeFileSync(file, PLAYERS_MIGRATION);
478
+ return `site/migrations/${PLAYERS_MIGRATION_FILE}`;
479
+ }
480
+
481
+ /** Every migration the template owns that this studio lacks, added: the files written (deploy and dev say so). */
482
+ export function ensureMigrations(root) {
483
+ return [ensureStatsMigration(root), ensurePlayersMigration(root)].filter(Boolean);
484
+ }
485
+
456
486
  /**
457
487
  * A studio made before 0.6.0 committed nothing under .studio/ yet: make sure git leaves it out before the first
458
488
  * deploy writes the workers.dev address there. Returns true when it added the line.
package/lib/setup.mjs CHANGED
@@ -13,43 +13,32 @@
13
13
  * A setup id is single use per repository: a second attach from the same repository answers the same; another
14
14
  * repository is refused.
15
15
  */
16
- import { spawnSync } from 'node:child_process';
17
16
  import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
17
  import { join } from 'node:path';
19
18
  import { configNames } from './cloudflare.mjs';
20
- import { networkWhy } from './progress.mjs';
19
+ import { request } from './net.mjs';
20
+ import { ENGINE_REPO, repoOf } from './repo.mjs';
21
21
  import { CONNECT_BAND, agentsMd, readme, slugify, templateReadme } from './scaffold.mjs';
22
22
  import { configPath, readStudio, writeLocal, writeStudio } from './studio.mjs';
23
23
  import { STUDIO_VERSION } from './version.mjs';
24
24
 
25
25
  const SETUP_ID = /^hs_[a-f0-9]{32}$/;
26
26
 
27
- /** owner/repo of this checkout's GitHub remote (a Claude Code cloud session's remote goes through its own proxy). */
28
- export function repoOf(root) {
29
- const r = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8', timeout: 5000 });
30
- if (r.status !== 0) return null;
31
- const url = r.stdout.trim().replace(/\.git$/, '').replace(/\/+$/, '');
32
- const m = /[/:]([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100})$/.exec(url);
33
- return m ? `${m[1]}/${m[2]}` : null;
34
- }
35
-
36
27
  export async function setupAttach(root, id, { homie } = {}) {
37
28
  if (!SETUP_ID.test(String(id ?? ''))) return { ok: false, command: 'setup attach', why: 'attach to the setup id the Claude app\'s setup card showed: hs_ and 32 hex digits' };
38
29
  const studio = readStudio(root);
39
30
  const directory = String(homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
40
31
  const repo = repoOf(root);
41
- let res; let body = null;
42
- try {
43
- res = await fetch(`${directory}/api/studio/setup/${id}/attach`, {
44
- method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION}` },
45
- body: JSON.stringify({ repo, studio: { name: studio.name, slug: studio.slug, template: studio.template === true }, version: STUDIO_VERSION }),
46
- signal: AbortSignal.timeout(10_000),
47
- });
48
- body = await res.json().catch(() => null);
49
- } catch (error) { return { ok: false, command: 'setup attach', why: `${directory} did not answer (${error?.message ?? error})` }; }
50
- const blocked = networkWhy(res, directory);
51
- if (blocked) return { ok: false, command: 'setup attach', needs: 'network', why: blocked };
52
- if (!res.ok || !body?.ok) return { ok: false, command: 'setup attach', why: body?.message ?? `${directory} answered ${res.status}` };
32
+ if (!repo) {
33
+ return { ok: false, command: 'setup attach', needs: 'repository', why: `this checkout names no GitHub repository of its own (its git remote is missing, or is ${ENGINE_REPO}, Homie's engine and template, which a studio never is). Run this in the studio's own repository, the one Cloudflare's Deploy to Cloudflare made, or name it in studio.json as "github": "<owner>/<name>"` };
34
+ }
35
+ const sent = await request(`${directory}/api/studio/setup/${id}/attach`, {
36
+ method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION}` },
37
+ body: JSON.stringify({ repo, studio: { name: studio.name, slug: studio.slug, template: studio.template === true }, version: STUDIO_VERSION }),
38
+ });
39
+ // What went wrong, as it is: the directory's own answer, or the connection's error. Never a guess at the network.
40
+ if (!sent.ok) return { ok: false, command: 'setup attach', repo, ...(sent.status ? { status: sent.status } : {}), ...(sent.code ? { code: sent.code } : {}), ...(sent.needs ? { needs: sent.needs } : {}), why: sent.why };
41
+ const body = sent.body;
53
42
 
54
43
  const renamed = [];
55
44
  const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim().slice(0, 60) : null;
@@ -78,6 +67,9 @@ export async function setupAttach(root, id, { homie } = {}) {
78
67
  const band = join(root, 'site', 'partials', 'home.html');
79
68
  if (existsSync(band) && readFileSync(band, 'utf8') === CONNECT_BAND) { rmSync(band); renamed.push('site/partials/home.html (removed)'); }
80
69
  }
70
+ // The repository goes into studio.json, so the next deploy (and Workers Builds) tells the live Worker which it is.
71
+ const now = readStudio(root);
72
+ if (now.github !== repo) { writeStudio(root, { ...now, github: repo }); if (!renamed.includes('studio.json')) renamed.push('studio.json'); }
81
73
  if (body.site) writeLocal(root, { url: body.site, connectedAt: new Date().toISOString() });
82
74
  return {
83
75
  ok: true, command: 'setup attach', setup: id, repo, site: body.site ?? null, name: name ?? studio.name, renamed,
package/lib/site.mjs CHANGED
@@ -214,9 +214,9 @@ export function readPosts(root, { games = [], songs = [], videos = [], log = ()
214
214
 
215
215
  export const PARTIALS = ['head', 'header', 'footer', 'home', 'game', 'post'];
216
216
  /** Paths a page of the studio's own can never take: the rooms, the game files, the API and the owner's pages. */
217
- const RESERVED_PAGE = /^\/(?:api|_studio|_homie|_site|media|\.well-known|games\/[^/]+)\/|^\/[a-z0-9][a-z0-9-]{0,39}\/(?:play|tv|live|api|__[a-z]+)(?:\/|$)/;
217
+ const RESERVED_PAGE = /^\/(?:api|account|_studio|_homie|_site|media|\.well-known|games\/[^/]+)\/|^\/[a-z0-9][a-z0-9-]{0,39}\/(?:play|tv|live|api|__[a-z]+)(?:\/|$)/;
218
218
  /** Top-level names site/public must not take (the build's own output). */
219
- const RESERVED_PUBLIC = new Set(['games', 'music', 'videos', 'games.json', '_site', '_homie', '_studio', 'api', 'media']);
219
+ const RESERVED_PUBLIC = new Set(['games', 'music', 'videos', 'games.json', '_site', '_homie', '_studio', 'api', 'media', 'account']);
220
220
 
221
221
  function walk(dir, fn, rel = '') {
222
222
  for (const e of readdirSync(join(dir, rel), { withFileTypes: true })) {
package/lib/stats.mjs CHANGED
@@ -21,9 +21,9 @@ const HOUR = 3600_000;
21
21
  const RANGES = new Set(['1d', '7d', '30d', '90d']);
22
22
  const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
23
23
 
24
- const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
24
+ export const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
25
25
 
26
- function siteOf(root, url) {
26
+ export function siteOf(root, url) {
27
27
  const studio = readStudio(root);
28
28
  const site = String(url || siteUrl(root, studio) || '').replace(/\/+$/, '');
29
29
  if (!/^https?:\/\//.test(site)) return { studio, site: null };
@@ -31,7 +31,7 @@ function siteOf(root, url) {
31
31
  }
32
32
 
33
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 }) {
34
+ export function mint(root, studio, kind, ttlMs, { local }) {
35
35
  const key = `hsk_${randomBytes(24).toString('hex')}`;
36
36
  const hash = createHash('sha256').update(key).digest('hex');
37
37
  const now = Date.now();
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', '_site', '_studio', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts', 'rooms', 'feed']);
11
+ const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', '_site', '_studio', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts', 'rooms', 'feed', 'account']);
12
12
 
13
13
  export function findStudio(from = process.cwd()) {
14
14
  let at = resolve(from);
@@ -199,6 +199,54 @@
199
199
  "## Beta": "9d02638207a26e73"
200
200
  }
201
201
  }
202
+ },
203
+ "0.12.0": {
204
+ "files": {
205
+ "CLAUDE.md": "ddf2b2a0376202f6",
206
+ "README.md": "684b3cca8001673c",
207
+ "games/README.md": "5e5e942baa7e0372",
208
+ "music/README.md": "b8a828de9311bd28",
209
+ "videos/README.md": "70614388cbb203c7",
210
+ "posts/README.md": "37841aae476bd540",
211
+ "site/README.md": "ca131cd0472464fb",
212
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
213
+ },
214
+ "sections": {
215
+ "AGENTS.md": {
216
+ "(intro)": "214aa46eb3a895d5",
217
+ "## Layout": "f14d641159480e6c",
218
+ "## Commands (all through the pinned CLI in node_modules)": "3a3d3bef7e8d1a50",
219
+ "## The site": "7d3525ba39b7f262",
220
+ "## Making games": "63069b31c87a2aa2",
221
+ "## The Game Codex and progress": "0738ef719cef3a4c",
222
+ "## Rules": "969318425c99f5a5",
223
+ "## Beta": "9d02638207a26e73"
224
+ }
225
+ }
226
+ },
227
+ "0.12.1": {
228
+ "files": {
229
+ "CLAUDE.md": "ddf2b2a0376202f6",
230
+ "README.md": "684b3cca8001673c",
231
+ "games/README.md": "5e5e942baa7e0372",
232
+ "music/README.md": "b8a828de9311bd28",
233
+ "videos/README.md": "70614388cbb203c7",
234
+ "posts/README.md": "37841aae476bd540",
235
+ "site/README.md": "ca131cd0472464fb",
236
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
237
+ },
238
+ "sections": {
239
+ "AGENTS.md": {
240
+ "(intro)": "214aa46eb3a895d5",
241
+ "## Layout": "f14d641159480e6c",
242
+ "## Commands (all through the pinned CLI in node_modules)": "3a3d3bef7e8d1a50",
243
+ "## The site": "7d3525ba39b7f262",
244
+ "## Making games": "63069b31c87a2aa2",
245
+ "## The Game Codex and progress": "0738ef719cef3a4c",
246
+ "## Rules": "969318425c99f5a5",
247
+ "## Beta": "9d02638207a26e73"
248
+ }
249
+ }
202
250
  }
203
251
  }
204
252
  }
@@ -194,6 +194,11 @@ it.
194
194
  - If the old host does not return, the relay elects someone else. That browser restores
195
195
  from the stored checkpoint (at most 30 s old) and from its own newest snapshot.
196
196
 
197
+ **What a room is not.** A room forgets everything 60 s after its last player leaves (`forgetMs`). Progress that
198
+ must last (a character, unlocks, days of play) is the player's, not the room's: it goes in cloud saves
199
+ (`@homie-rocks/studio/saves`, `../saves/SAVES.md`). The host decides what happened and tells that player's own
200
+ browser (an `ev` to its seat); only that browser changes and saves the player's progress.
201
+
197
202
  ## 5. Wire protocol v1
198
203
 
199
204
  - **Transport:** one WebSocket per game document, carrying JSON text frames
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -26,6 +26,10 @@
26
26
  "types": "./port/early.ts",
27
27
  "default": "./port/early.ts"
28
28
  },
29
+ "./saves": {
30
+ "types": "./saves/saves.ts",
31
+ "default": "./saves/saves.ts"
32
+ },
29
33
  "./worker": "./worker/index.mjs",
30
34
  "./package.json": "./package.json"
31
35
  },
@@ -35,6 +39,7 @@
35
39
  "netplay/",
36
40
  "port/",
37
41
  "worker/",
42
+ "saves/",
38
43
  "starters/",
39
44
  "media/",
40
45
  "site/",
package/port/index.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * createHud a minimal clock / scores / results overlay
13
13
  * BotBrain, seek, nearest, rubberBand
14
14
  * bot scaffolds
15
+ * createSaves player accounts and cloud saves (a character that lasts days, on every device; saves/SAVES.md)
15
16
  * and everything from @homie-rocks/studio/netplay.
16
17
  */
17
18
  import './early';
@@ -27,3 +28,4 @@ export { exposePort, type PortProbeOptions, type View } from './probe';
27
28
  export { createRoom, type Room, type RoomOptions, type BodyBase, type RoomSnap, type RoomCkpt } from './room';
28
29
  export { createHud, type HudOptions } from './hud';
29
30
  export { BotBrain, seek, flee, wanderer, nearest, rubberBand, protectedNewcomer, type V2 } from './bots';
31
+ export { createSaves, SAVES_VERSION, type Saves, type SavesOptions, type Player, type SaveEntry, type SetResult, type Memorial, type SavesStatus, type Conflict } from '../saves/saves';
package/port/keys.ts CHANGED
@@ -75,6 +75,9 @@ export function createKeys(target: Window | HTMLElement = window): Keys {
75
75
  const edges = new Set<string>();
76
76
  const kd = (e: Event): void => {
77
77
  const k = e as KeyboardEvent;
78
+ // A key typed into the game's own text box (a hero's name, a chat line) is a word, never a move.
79
+ const el = k.target as HTMLElement | null;
80
+ if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName ?? ''))) return;
78
81
  if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(k.code)) k.preventDefault();
79
82
  if (!held.has(k.code)) edges.add(k.code);
80
83
  held.add(k.code);