@homie-rocks/studio 0.11.0 → 0.12.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
@@ -72,8 +72,8 @@ site. A game made with Homie's arcade controls that knocks for a Homie box (`/__
72
72
  A studio pins one version, so nothing changes until it asks. To take a newer one:
73
73
 
74
74
  ```sh
75
- npx -y @homie-rocks/studio@0.11.0 upgrade # the plan; changes nothing
76
- npx -y @homie-rocks/studio@0.11.0 upgrade --apply # after the person agrees
75
+ npx -y @homie-rocks/studio@0.12.0 upgrade # the plan; changes nothing
76
+ npx -y @homie-rocks/studio@0.12.0 upgrade --apply # after the person agrees
77
77
  npm install && npm run build
78
78
  ```
79
79
 
@@ -94,6 +94,32 @@ Bots, a late joiner taking a bot's body and a new host after the old one leaves
94
94
  the same at 32 as at 4. One address may hold every seat plus four sockets, so a party
95
95
  on one Wi-Fi (or strangers behind one carrier's address) fills a room with a TV beside it.
96
96
 
97
+ ## Player accounts and cloud saves
98
+
99
+ A game whose progress must last (a character that levels up for days, unlocks, a collection, a hardcore mode)
100
+ keeps it in **saves**, not in its room: a room forgets everything 60 s after its last player leaves.
101
+
102
+ ```json
103
+ { "id": "my-rpg", "saves": true }
104
+ ```
105
+
106
+ ```ts
107
+ import { createSaves } from '@homie-rocks/studio/saves';
108
+ const saves = createSaves({ game: 'my-rpg' });
109
+ const hero = (await saves.get('hero')) ?? newHero();
110
+ await saves.set('hero', hero); // on this device at once, in the studio's D1 when online
111
+ saves.stats.add({ kills: 1 }); // lifetime numbers that outlive any hero
112
+ await saves.fall({ character: hero.name, summary: { level: hero.level }, wipe: true }); // hardcore
113
+ ```
114
+
115
+ Pressing Play needs no account. A player who saves something is a guest on that browser; with a **passkey**
116
+ (Face ID, a fingerprint, a PIN, no password, no email) it becomes an account on this studio only, and the same
117
+ saves follow them to every device they sign in on. Saves are per player and game, versioned (a stale offline
118
+ copy is a conflict, never an overwrite), capped in size, and offline-tolerant. Players see, download and delete
119
+ everything at `/account/`; the owner sees counts (`homie-studio players`) and, through the back office, names,
120
+ never a passkey or an email. The `ember-vale` starter is a small persistent-character game on the same public
121
+ rooms. Everything, including what is stored and the back office's API: [saves/SAVES.md](saves/SAVES.md).
122
+
97
123
  ## The studio's own stats
98
124
 
99
125
  The site counts, and never tracks: pages opened, Play presses, rooms opened, the most
@@ -116,7 +142,7 @@ studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
116
142
  ## Setup status
117
143
 
118
144
  ```sh
119
- npx -y @homie-rocks/studio@0.11.0 setup status --connector yes # before a studio exists
145
+ npx -y @homie-rocks/studio@0.12.0 setup status --connector yes # before a studio exists
120
146
  npx homie-studio setup status # in a studio (also: homie-studio doctor)
121
147
  ```
122
148
 
@@ -205,9 +231,10 @@ Workers Builds deploys the branch as a Preview; the card's **Publish** opens the
205
231
  person's merge is the approval; Workers Builds deploys `main`, and the card says **Live** when the live site's
206
232
  manifest lists the change's mark (`homie-studio build` lists the newest marks from `changes/`).
207
233
 
208
- A Claude Code cloud session's default network ("Trusted") does not reach homie.rocks; the toolkit says so, and
209
- names the setting (the environment's Network access: Custom, add homie.rocks), instead of "did not answer". The
210
- build goes on with its local feed.
234
+ A Claude Code cloud session's default network ("Trusted") reaches homie.rocks. If a session's network blocks it
235
+ (its proxy answers 403 `host_not_allowed`, and the progress card stops updating), the toolkit says so and names the
236
+ setting (the environment's Network access: Custom, add homie.rocks), instead of "did not answer". The build goes on
237
+ with its local feed.
211
238
 
212
239
  The feed is `.studio/progress/<build>.json` (git-ignored). With `--share` the studio's
213
240
  directory (studio.json `homie.directory`, homie.rocks by default) keeps a copy for 24
@@ -37,6 +37,8 @@
37
37
  * homie-studio stats link (a one-time link to the private stats page, for the owner's browser)
38
38
  * homie-studio stats revoke (every stats key and page session ends)
39
39
  * homie-studio stats share on|off (tell the homie.rocks directory "played this week", or stop)
40
+ * homie-studio players (player accounts and guests with saves: counts, never a passkey or an email)
41
+ * homie-studio players owner [--revoke] (a one-time link that marks the owner's own player account as the owner's)
40
42
  *
41
43
  * homie-studio progress start [<id>] [--what game|song|video] [--title "<what this build does>"]
42
44
  * [--budget <dollars>] [--unit usd|credits] [--share]
@@ -96,11 +98,12 @@ import { look } from '../lib/look.mjs';
96
98
  import { importPort, planPort } from '../lib/port.mjs';
97
99
  import { portCheck } from '../lib/port-check.mjs';
98
100
  import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
99
- import { ensureStatsMigration, newStudio } from '../lib/scaffold.mjs';
101
+ import { ensureMigrations, newStudio } from '../lib/scaffold.mjs';
100
102
  import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
101
103
  import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
102
104
  import { STUDIO_VERSION } from '../lib/version.mjs';
103
105
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
106
+ import { playersOwner, playersShow } from '../lib/players.mjs';
104
107
  import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
105
108
  import { formatStatus, setupStatus } from '../lib/doctor.mjs';
106
109
  import { codexTarget, newCodex, writeCodexPage } from '../lib/codex.mjs';
@@ -108,7 +111,7 @@ import { installStatusLine, statusLine } from '../lib/statusline.mjs';
108
111
 
109
112
  const argv = process.argv.slice(2);
110
113
  const flags = new Map();
111
- const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open'];
114
+ const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open'];
112
115
  const positional = [];
113
116
  for (let i = 0; i < argv.length; i++) {
114
117
  const a = argv[i];
@@ -207,8 +210,17 @@ function print(result) {
207
210
  for (const e of result.songs) lines.push(` song ${e.slug}: ${n(e.visits)} page visits, played ${n(e.plays)}`);
208
211
  for (const e of result.videos) lines.push(` video ${e.slug}: ${n(e.visits)} page visits, watched ${n(e.views)}`);
209
212
  for (const r of result.referrers.slice(0, 10)) lines.push(` from ${r.from} (${r.kind}): ${n(r.visits)} visits, ${n(r.plays)} plays`);
213
+ if (result.players) lines.push(` players: ${n(result.players.accounts)} accounts (${n(result.players.newAccounts7d)} new this week), ${n(result.players.guests)} guests with saves, ${n(result.players.active7d)} played this week`);
210
214
  break;
211
215
  }
216
+ case 'players': {
217
+ const p = result.players;
218
+ lines.push(`${p.accounts} player accounts (${p.newAccounts7d} new this week), ${p.guests} guests with saves, ${p.active7d} played this week.`, result.note);
219
+ break;
220
+ }
221
+ case 'players owner':
222
+ lines.push(result.revoked ? result.message : `One-time link (until ${result.expiresAt}): ${result.link}`, ...(result.revoked ? [] : [result.use]));
223
+ break;
212
224
  case 'stats key':
213
225
  lines.push(`Read key (until ${result.expiresAt}): ${result.key}`, result.use);
214
226
  break;
@@ -388,6 +400,8 @@ async function main() {
388
400
  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)' };
389
401
  }
390
402
  if (cmd === 'publish') return publish(root, { homie: flags.get('homie'), site: flags.get('site') });
403
+ if (cmd === 'players' && sub === 'owner') return playersOwner(root, { url: flags.get('url'), revoke: flags.has('revoke') });
404
+ if (cmd === 'players' && !sub) return playersShow(root, { url: flags.get('url') });
391
405
  if (cmd === 'stats' && sub === 'key') return statsKey(root, { url: flags.get('url'), hours: flags.get('hours') });
392
406
  if (cmd === 'stats' && sub === 'link') return statsLink(root, { url: flags.get('url') });
393
407
  if (cmd === 'stats' && sub === 'revoke') return statsRevoke(root, { url: flags.get('url') });
@@ -562,8 +576,7 @@ async function dev(root) {
562
576
  if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
563
577
  const studio = readStudio(root);
564
578
  const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1' };
565
- const added = ensureStatsMigration(root);
566
- if (added) log(`added ${added} (the studio's own stats)`);
579
+ for (const added of ensureMigrations(root)) log(`added ${added} (${/players/.test(added) ? 'player accounts and cloud saves' : 'the studio\'s own stats'})`);
567
580
  await new Promise((done) => {
568
581
  const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
569
582
  m.on('close', done);
package/lib/build.mjs CHANGED
@@ -220,6 +220,9 @@ export async function build(root, { only = null, log = () => {} } = {}) {
220
220
  id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
221
221
  roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
222
222
  ...(g.screen ? { screen: g.screen } : {}),
223
+ // game.json "saves": true — player accounts and cloud saves (saves/SAVES.md): the play shell answers the game's
224
+ // saves calls, and the site's nav and the game's landing offer a player account.
225
+ ...(g.saves === true || (g.saves && typeof g.saves === 'object') ? { saves: true } : {}),
223
226
  landing: landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log }),
224
227
  };
225
228
  });
@@ -22,7 +22,7 @@ import { spawnSync } from 'node:child_process';
22
22
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { build } from './build.mjs';
25
- import { ensureLocalIgnored, ensureStatsMigration, wranglerConfig } from './scaffold.mjs';
25
+ import { ensureLocalIgnored, ensureMigrations, wranglerConfig } from './scaffold.mjs';
26
26
  import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
27
27
 
28
28
  const ANSI = /\u001b\[[0-9;]*m/g;
@@ -96,7 +96,7 @@ export function deployPlan(root) {
96
96
  ok: true, command: 'deploy plan', studio: studio.name, account: cf.accountId ?? null,
97
97
  cloudflare: [
98
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' },
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
+ { kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round, the studio\'s own stats (daily counters of visits, plays, rooms, rounds and songs, for the owner only; nothing about a visitor), and, for games that keep saves, player accounts (a passkey\'s public key, never a password) and their saves', state: mark(`d1:${cf.d1}`), plan: 'Workers Free (500 MB per database, 5 GB per account)' },
100
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)' },
101
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)' },
102
102
  storage
@@ -192,8 +192,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
192
192
  if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
193
193
 
194
194
  writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root) }));
195
- const added = ensureStatsMigration(root);
196
- if (added) step(`added ${added} (the studio's own stats: counts, never tracks)`);
195
+ for (const added of ensureMigrations(root)) step(`added ${added} (${/players/.test(added) ? 'player accounts and cloud saves' : 'the studio\'s own stats: counts, never tracks'})`);
197
196
  const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
198
197
  if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
199
198
  step('D1 migrations applied');
@@ -283,7 +282,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
283
282
  const step = (what) => { steps.push({ what }); log(what); };
284
283
  const refuse = (why, out) => ({ ok: false, command: 'deploy', ci: true, ...(explainCloudflare(out) ?? { why }), steps });
285
284
  if (!existsSync(join(root, 'site', 'dist', 'games.json'))) return { ok: false, command: 'deploy', ci: true, why: 'nothing is built: the build command is `npm run build` (homie-studio build), and it runs before this', steps };
286
- if (ensureStatsMigration(root)) step('added the stats migration (the studio\'s own counters)');
285
+ for (const added of ensureMigrations(root)) step(`added ${added} (${/players/.test(added) ? 'player accounts and cloud saves' : 'the studio\'s own counters'})`);
287
286
  const apply = () => w(['d1', 'migrations', 'apply', names.binding, '--remote']);
288
287
  let migrate = apply();
289
288
  const first = migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
package/lib/codex.mjs CHANGED
@@ -637,6 +637,7 @@ ${game.blurb ?? ''}
637
637
  - **Players in a room:** ${players}
638
638
  - **Round:** ${game.roundSeconds ? `${game.roundSeconds} s` : 'how long, and how it ends'}
639
639
  - **Bots:** fill empty seats; a person who arrives takes a bot's place
640
+ - **Progress that lasts:** ${game.saves ? 'the player\'s own saves (cloud saves: what is kept, lifetime stats, hardcore or not)' : 'none: a round is all there is (or cloud saves, if the plan says progress persists across sessions or devices)'}
640
641
 
641
642
  ## Music and sound
642
643
 
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `homie-studio players` — the studio's players, for its owner (worker/players.mjs, saves/SAVES.md).
3
+ *
4
+ * homie-studio players [--url <site>] how many have accounts, guests with saves, who played this week
5
+ * homie-studio players owner [--url <site>] a one-time link that marks the owner's OWN player account as the
6
+ * owner's (open it in the browser where they are signed in on the site)
7
+ * homie-studio players owner --revoke no account is the owner's any more
8
+ *
9
+ * The proof of ownership is the studio's own Cloudflare login, as for `stats`: the link's key is minted here and
10
+ * only its SHA-256 goes into the studio's D1 (through Wrangler). The link works once, for 30 minutes.
11
+ */
12
+ import { runner } from './cloudflare.mjs';
13
+ import { loopback, mint, siteOf, statsShow } from './stats.mjs';
14
+
15
+ export async function playersShow(root, { url } = {}) {
16
+ const s = await statsShow(root, { url, range: '7d' });
17
+ if (!s.ok) return { ...s, command: 'players' };
18
+ if (!s.players) return { ok: false, command: 'players', why: 'this studio\'s D1 has no player tables yet: `npm run deploy` applies migration 0004_players.sql (or `npm run dev` for a local site)' };
19
+ return { ok: true, command: 'players', site: s.site, players: s.players, note: 'Counts only. A player\'s name, passkeys and saves are theirs; the back office lists names, never a passkey or an email.' };
20
+ }
21
+
22
+ export function playersOwner(root, { url, revoke = false } = {}) {
23
+ const { studio, site } = siteOf(root, url);
24
+ if (!site) return { ok: false, command: 'players owner', why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://127.0.0.1:8787 for `npm run dev`' };
25
+ const local = loopback(site);
26
+ if (revoke) {
27
+ const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
28
+ const res = w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', "UPDATE players SET owner = 0; DELETE FROM stats_keys WHERE kind = 'player-owner';"]);
29
+ return res.code === 0 ? { ok: true, command: 'players owner', site, revoked: true, message: 'No player account is the owner\'s now. `homie-studio players owner` marks one again.' }
30
+ : { ok: false, command: 'players owner', why: res.out.trim().split('\n').slice(-3).join(' ') };
31
+ }
32
+ const k = mint(root, studio, 'player-owner', 30 * 60_000, { local });
33
+ if (!k.ok) return { ok: false, command: 'players owner', why: k.why };
34
+ return {
35
+ ok: true, command: 'players owner', site, link: `${site}/account/?owner=${k.key}`, expiresAt: k.expiresAt,
36
+ use: 'Give this link to the studio\'s owner to open in the browser where they play: they sign in (or make an account with a passkey) on that page, then press "Yes, this account is mine". It works once, within 30 minutes. It is theirs alone; do not post it anywhere.',
37
+ };
38
+ }
package/lib/progress.mjs CHANGED
@@ -90,9 +90,10 @@ const currentPath = (root) => join(dir(root), 'current');
90
90
  const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
91
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.
93
+ * A CLAUDE CODE CLOUD SESSION WHOSE NETWORK BLOCKS THE DIRECTORY. The default "Trusted" network reached homie.rocks
94
+ * in a real cloud session (2026-10-01); a stricter one makes the session's proxy answer 403 with
95
+ * `x-deny-reason: host_not_allowed`. Then, and only then, say which setting lets the card follow the build,
96
+ * instead of "did not answer"; the build itself goes on with its local feed.
96
97
  */
97
98
  export function networkWhy(res, at) {
98
99
  if (res?.status === 403 && /host_not_allowed/i.test(res.headers?.get?.('x-deny-reason') ?? '')) {
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/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,30 @@
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
+ }
202
226
  }
203
227
  }
204
228
  }
@@ -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.0",
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);