@homie-rocks/studio 0.10.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.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * THE CLAUDE CODE STATUS LINE: one quiet line under the prompt with the studio's current build, from its progress
3
+ * feed (lib/progress.mjs), for example
4
+ *
5
+ * ▶ Ember Run · Checks ▰▰▰▰▰▰▱▱▱▱ 62% · 3/5 checks · $0.40 of $2.00
6
+ *
7
+ * Optional, and only on the person's yes: `homie-studio statusline --install` writes the `statusLine` setting into
8
+ * this studio's `.claude/settings.local.json` (this person, this studio; Claude Code keeps that file out of git, and
9
+ * the studio's .gitignore does too). Claude Code runs the command after each message and, with `refreshInterval`,
10
+ * every few seconds while a long check runs; it sends the session as JSON on stdin (`workspace.current_dir`), and
11
+ * shows what the command prints. With no build running it shows only the studio's name, dimmed; a build that ended
12
+ * stays for a few minutes (passed, failed or stopped), then goes.
13
+ *
14
+ * Plugins cannot set a status line (a plugin's settings take only `agent` and `subagentStatusLine`), so this is a
15
+ * one-line project setting the person agrees to, never something the plugin switches on.
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { join, resolve } from 'node:path';
20
+ import { currentFeedDoc, latestFeedFor, summarize } from './feed-summary.mjs';
21
+ import { PACKAGE_ROOT, findStudio } from './studio.mjs';
22
+
23
+ const ANSI = { reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m', green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', cyan: '\x1b[36m' };
24
+ /** How long an ended build stays on the line. */
25
+ export const RECENT_MS = 10 * 60_000;
26
+
27
+ function bar(percent, width) {
28
+ const full = Math.round((percent / 100) * width);
29
+ return { done: '▰'.repeat(full), left: '▱'.repeat(Math.max(0, width - full)) };
30
+ }
31
+
32
+ /**
33
+ * The line for the studio at or above `dir`, or '' outside a studio. Parts are dropped from the end (spend, then
34
+ * checks) until the line fits in `columns`.
35
+ */
36
+ export function statusLine({ dir = process.cwd(), now = Date.now(), columns = 100, color = true } = {}) {
37
+ const root = findStudio(dir);
38
+ if (!root) return '';
39
+ let name = 'Studio';
40
+ try { name = JSON.parse(readFileSync(join(root, 'studio.json'), 'utf8')).name || name; } catch { /* keep */ }
41
+ const s = summarize(currentFeedDoc(root) ?? latestFeedFor(root, null));
42
+ const paint = (code, text) => (color && text ? `${code}${text}${ANSI.reset}` : text);
43
+ const fit = (parts) => {
44
+ // parts: [[plain, painted], …]; keep the first two whole, then as many as fit.
45
+ const width = Math.max(20, columns - 2);
46
+ let keep = parts.length;
47
+ const len = (n) => parts.slice(0, n).reduce((a, [plain]) => a + plain.length, 0) + (n - 1) * 3;
48
+ while (keep > 1 && len(keep) > width) keep -= 1;
49
+ const out = parts.slice(0, keep).map(([, painted]) => painted).join(paint(ANSI.dim, ' · '));
50
+ const plain = parts.slice(0, keep).map(([p]) => p).join(' · ');
51
+ return plain.length > width ? `${plain.slice(0, width - 1)}…` : out;
52
+ };
53
+ const ended = s && s.state !== 'running' ? Date.parse(s.endedAt ?? s.updatedAt ?? 0) : null;
54
+ if (!s || (ended !== null && !(now - ended < RECENT_MS))) return fit([[`◇ ${name}`, paint(ANSI.dim, `◇ ${name}`)]]);
55
+
56
+ const title = s.title.length > 40 ? `${s.title.slice(0, 39)}…` : s.title;
57
+ if (s.state === 'passed') {
58
+ return fit([[`✓ ${title} done`, paint(ANSI.green, `✓ ${title} done`)], ...(s.preview ? [[s.preview, paint(ANSI.dim, s.preview)]] : [])]);
59
+ }
60
+ if (s.state === 'failed') {
61
+ const why = `failed${s.stage ? ` at ${s.stage.label}` : ''}${s.error ? `: ${s.error}` : ''}`;
62
+ return fit([[`✗ ${title}`, paint(ANSI.red, `✗ ${title}`)], [why, paint(ANSI.red, why)]]);
63
+ }
64
+ if (s.state === 'stopped') return fit([[`■ ${title} stopped`, paint(ANSI.yellow, `■ ${title} stopped`)]]);
65
+
66
+ const width = columns < 70 ? 6 : 10;
67
+ const b = bar(s.percent, width);
68
+ const stage = s.stopping ? 'stopping…' : (s.stage?.label ?? 'Starting');
69
+ const progress = `${stage} ${b.done}${b.left} ${s.percent}%`;
70
+ const parts = [
71
+ [`▶ ${title}`, paint(ANSI.bold, `▶ ${title}`)],
72
+ [progress, `${paint(ANSI.cyan, stage)} ${paint(ANSI.green, b.done)}${paint(ANSI.dim, b.left)} ${s.percent}%`],
73
+ ];
74
+ if (s.checks.total) {
75
+ const text = `${s.checks.pass}/${s.checks.total} checks${s.checks.fail ? `, ${s.checks.fail} failing` : ''}`;
76
+ parts.push([text, paint(s.checks.fail ? ANSI.red : ANSI.dim, text)]);
77
+ }
78
+ if (s.spend.text) parts.push([s.spend.text, paint(ANSI.dim, s.spend.text)]);
79
+ return fit(parts);
80
+ }
81
+
82
+ /* ------------------------------------------------------------------ the setting */
83
+
84
+ const LOCAL = join('.claude', 'settings.local.json');
85
+ const readJson = (path) => { if (!existsSync(path)) return {}; return JSON.parse(readFileSync(path, 'utf8')); };
86
+ /** Our own status line: a command that runs this toolkit's statusline. */
87
+ export const isOurs = (statusLine) => typeof statusLine?.command === 'string' && /homie-studio|@homie-rocks[\\/]studio/.test(statusLine.command) && /statusline/.test(statusLine.command);
88
+
89
+ /**
90
+ * The command the setting runs: this studio's pinned toolkit, by an absolute path (forward slashes, for Windows
91
+ * too). Set in another folder (the one Claude Code was started in, above the studio), it names the studio too.
92
+ */
93
+ export function statusLineCommand(root, { named = false } = {}) {
94
+ const pinned = join(root, 'node_modules', '@homie-rocks', 'studio', 'bin', 'statusline.mjs');
95
+ const script = existsSync(pinned) ? pinned : join(PACKAGE_ROOT, 'bin', 'statusline.mjs');
96
+ const slash = (p) => p.replace(/\\/g, '/');
97
+ return `node "${slash(script)}"${named ? ` --studio "${slash(root)}"` : ''}`;
98
+ }
99
+
100
+ /** Every settings file that may already hold a status line for this studio, most specific first. */
101
+ function settingsFiles(project, env) {
102
+ const user = env.CLAUDE_CONFIG_DIR ? join(env.CLAUDE_CONFIG_DIR, 'settings.json') : join(env.HOME || homedir(), '.claude', 'settings.json');
103
+ return [
104
+ { where: 'this project, this person (.claude/settings.local.json)', path: join(project, LOCAL) },
105
+ { where: 'this project (.claude/settings.json)', path: join(project, '.claude', 'settings.json') },
106
+ { where: 'every project of this person (the user settings)', path: user },
107
+ ];
108
+ }
109
+
110
+ /**
111
+ * `homie-studio statusline --install`: the status line in this studio's .claude/settings.local.json. It never
112
+ * replaces a status line the person already has (anywhere Claude Code would read one) unless `replace`.
113
+ * `remove`: take ours out again.
114
+ */
115
+ export function installStatusLine(root, { replace = false, remove = false, project = null, env = process.env } = {}) {
116
+ // Claude Code reads project settings from the folder it was started in. A studio made as a subfolder of that
117
+ // folder is named in the command, and the setting goes where Claude Code reads it (`--project <that folder>`).
118
+ const here = project ? resolve(project) : root;
119
+ if (!existsSync(here)) return { ok: false, command: 'statusline install', why: `no folder ${here}` };
120
+ const files = settingsFiles(here, env);
121
+ const local = files[0].path;
122
+ let mine;
123
+ try { mine = readJson(local); } catch { return { ok: false, command: 'statusline install', why: `${LOCAL} is not valid JSON; fix it by hand first (nothing was changed)` }; }
124
+ if (remove) {
125
+ if (!isOurs(mine.statusLine)) return { ok: true, command: 'statusline remove', removed: false, message: 'No Homie status line in this studio; nothing changed.' };
126
+ delete mine.statusLine;
127
+ writeFileSync(local, `${JSON.stringify(mine, null, 2)}\n`);
128
+ return { ok: true, command: 'statusline remove', removed: true, file: LOCAL, message: `Removed the Homie status line from ${LOCAL}.` };
129
+ }
130
+ const command = statusLineCommand(root, { named: here !== root });
131
+ if (isOurs(mine.statusLine) && mine.statusLine.command === command) return { ok: true, command: 'statusline install', already: true, file: LOCAL, statusLine: mine.statusLine, message: 'The status line is already on in this studio.' };
132
+ if (!replace) {
133
+ for (const f of files) {
134
+ let s = null;
135
+ try { s = readJson(f.path).statusLine ?? null; } catch { s = null; }
136
+ if (s && !isOurs(s)) {
137
+ return { ok: false, command: 'statusline install', exists: { where: f.where, command: String(s.command ?? s.type ?? '').slice(0, 120) }, why: `there is already a status line (${f.where}). Nothing was changed. With --replace, this studio shows the build's progress instead (only here; the other one stays everywhere else).` };
138
+ }
139
+ }
140
+ }
141
+ mine.statusLine = { type: 'command', command, padding: 0, refreshInterval: 5 };
142
+ mkdirSync(join(here, '.claude'), { recursive: true });
143
+ writeFileSync(local, `${JSON.stringify(mine, null, 2)}\n`);
144
+ // Claude Code keeps settings.local.json out of git when it makes the file; this one we made, so say it here too.
145
+ const ignore = join(here, '.gitignore');
146
+ let ignored = false;
147
+ if (existsSync(ignore) || here === root) try {
148
+ const text = existsSync(ignore) ? readFileSync(ignore, 'utf8') : '';
149
+ if (!/^\.claude\/settings\.local\.json\s*$/m.test(text)) { writeFileSync(ignore, `${text}${text && !text.endsWith('\n') ? '\n' : ''}# This person's own Claude Code settings for this studio (the status line).\n.claude/settings.local.json\n`); ignored = true; }
150
+ } catch { /* no .gitignore to keep */ }
151
+ return {
152
+ ok: true, command: 'statusline install', file: here === root ? LOCAL : join(here, LOCAL), statusLine: mine.statusLine, gitignore: ignored,
153
+ message: `The status line is on ${here === root ? `in this studio (${LOCAL})` : `for Claude Code started in ${here} (${LOCAL} there)`}: one line under the prompt with the current build's stage, a progress bar, its checks and what it spent. Claude Code reloads settings by itself; it shows from the next message. Turn it off with: npx --no-install homie-studio statusline --remove`,
154
+ };
155
+ }
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);
@@ -175,6 +175,54 @@
175
175
  "## Beta": "9d02638207a26e73"
176
176
  }
177
177
  }
178
+ },
179
+ "0.11.0": {
180
+ "files": {
181
+ "CLAUDE.md": "ddf2b2a0376202f6",
182
+ "README.md": "684b3cca8001673c",
183
+ "games/README.md": "5e5e942baa7e0372",
184
+ "music/README.md": "b8a828de9311bd28",
185
+ "videos/README.md": "70614388cbb203c7",
186
+ "posts/README.md": "37841aae476bd540",
187
+ "site/README.md": "ca131cd0472464fb",
188
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
189
+ },
190
+ "sections": {
191
+ "AGENTS.md": {
192
+ "(intro)": "214aa46eb3a895d5",
193
+ "## Layout": "f14d641159480e6c",
194
+ "## Commands (all through the pinned CLI in node_modules)": "2af7c804c1e81d86",
195
+ "## The site": "7d3525ba39b7f262",
196
+ "## Making games": "c204473d9b468f49",
197
+ "## The Game Codex and progress": "0738ef719cef3a4c",
198
+ "## Rules": "969318425c99f5a5",
199
+ "## Beta": "9d02638207a26e73"
200
+ }
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
+ }
178
226
  }
179
227
  }
180
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.10.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);
package/saves/SAVES.md ADDED
@@ -0,0 +1,236 @@
1
+ # Players and cloud saves
2
+
3
+ A studio's games can keep progress that lasts: a character that levels up for days, unlocks, a collection,
4
+ lifetime stats, a hardcore mode with a hall of the fallen. It follows the player to every device they sign in
5
+ on. Everything lives in the studio's **own** Worker and D1, on its own Cloudflare account: no third party, no
6
+ tracker, no cookie from anyone else, and no other studio can see these players.
7
+
8
+ ## Rooms are rooms; saves are saves
9
+
10
+ A public room is tonight's match. It keeps the live round (bodies, the clock, the world) and **forgets everything
11
+ 60 seconds after its last player leaves**. That is right for a round and wrong for a hero. So a persistent game
12
+ keeps two kinds of state apart:
13
+
14
+ | | The room (`@homie-rocks/studio/netplay`, `createRoom`) | The save (`@homie-rocks/studio/saves`) |
15
+ | --- | --- | --- |
16
+ | Lasts | while anyone is in the room (+60 s) | until the player deletes it |
17
+ | Belongs to | the room; the host browser runs the rules | one player, one game |
18
+ | Holds | positions, enemies, pickups, the round's score | name, level, gold, inventory, unlocks, settings |
19
+ | Written | 20 times a second, in memory | when it changes, at most about once a second |
20
+
21
+ The pattern (the `ember-vale` starter does exactly this):
22
+
23
+ 1. When the player arrives, **load** the hero from saves; until it loads, play as a level-1 body.
24
+ 2. The host decides what happened (who killed what) and tells **that player's own browser** (a netplay event
25
+ to its seat). Only the hero's own browser changes the hero and **saves** it.
26
+ 3. The room carries what the others need to see (my level, so my health bar is right), never the save itself.
27
+
28
+ ## Quick start
29
+
30
+ game.json:
31
+
32
+ ```json
33
+ { "id": "my-rpg", "saves": true }
34
+ ```
35
+
36
+ `src/main.ts`:
37
+
38
+ ```ts
39
+ import { createSaves } from '@homie-rocks/studio/saves'; // also in '@homie-rocks/studio/port'
40
+
41
+ interface Hero { name: string; level: number; xp: number; gold: number }
42
+ const saves = createSaves({ game: 'my-rpg' });
43
+
44
+ let hero = (await saves.get<Hero>('hero')) ?? { name: 'Wanderer', level: 1, xp: 0, gold: 0 };
45
+ // … the hero gains something:
46
+ hero.gold += 25;
47
+ await saves.set('hero', hero); // safe on this device at once; in the cloud when online
48
+
49
+ // The player signed in on this device (or signed out): these are someone else's saves now.
50
+ saves.on('player', async () => { hero = (await saves.get<Hero>('hero')) ?? newHero(); });
51
+ ```
52
+
53
+ `"saves": true` turns on the play page's saves bridge for that game, and a "Your progress follows you" link on its
54
+ landing. Pressing Play never needs an account.
55
+
56
+ ## Who the player is
57
+
58
+ - **A guest** is made the first time a game saves something (never on Play). Their progress is kept on the server
59
+ for that browser (a cookie that is this site's alone) and in that browser's local copy.
60
+ - **An account** is a guest (or anyone) who made a **passkey** on this site: Face ID, a fingerprint, a PIN or a
61
+ security key. No password, no email. A guest who makes an account keeps everything: it is the same player.
62
+ - **Another device**: "Sign in" with the same passkey. The device's own passkey sync carries it (iCloud Keychain,
63
+ Google Password Manager, a password manager), or the browser offers a phone's QR code. The same saves are there.
64
+ A guest who signs in to an account they already have keeps the account's saves, and anything only the guest had
65
+ (keys, stats, memorials the account lacks) moves over.
66
+ - **Recovery**: add another passkey on another device or a security key (`/account/`). A **recovery email** is
67
+ offered only when the studio has a mail sender: a Cloudflare Email Service binding named `PLAYER_MAIL`
68
+ (`"send_email": [{ "name": "PLAYER_MAIL" }]` in wrangler.jsonc; Email Sending needs the Workers paid plan and a
69
+ domain onboarded to it) and a `PLAYER_MAIL_FROM` variable on that domain. The address is confirmed by a
70
+ one-time link, and a recovery link lets the player make a new passkey; the old sessions end.
71
+ - **Names** follow the same rules as a room's names: one line, at most 24 characters, whitespace collapsed, no
72
+ control, zero-width or bidi characters, at least one letter or digit, never a role ("admin", "moderator",
73
+ "owner", "official"…) or the studio's own name, and always drawn as text. A guest's name is a two-word handle
74
+ until they choose one. A signed-in player plays under their name in every room of the studio.
75
+ - The game can show who is playing (`saves.player`: `name`, `guest`, `signedIn`, `owner`) and offer
76
+ `saves.signIn(reason)` from its own button: the play page shows its sign-in sheet over the game.
77
+ - **The account page** is `/account/` on the studio's site: make an account, sign in, name, passkeys (add,
78
+ remove), recovery email, download everything, delete everything.
79
+
80
+ Passkeys belong to the **host name** they were made on. Accounts made on a studio's workers.dev address do not
81
+ sign in on its custom domain later: give a persistent game its final address before people make accounts. A
82
+ Preview (a branch's own URL) has no database, so it has no accounts. In `npm run dev`, open
83
+ `http://localhost:8787` (not `127.0.0.1`): a passkey needs a name, not an IP address.
84
+
85
+ ## The API
86
+
87
+ `createSaves(options?)`:
88
+
89
+ | Option | |
90
+ | --- | --- |
91
+ | `game` | The game's id (names the local-only store). On a studio site the shell's own game id is used: a game can only ever reach its own saves. |
92
+ | `onConflict(c)` | Another device saved a key since this one's copy. Return the value to keep (merge `c.mine` and `c.theirs`), or `undefined` for the cloud's (the default). |
93
+ | `local` | `true` forces local-only mode (tests, a game outside a studio). |
94
+
95
+ | Call | |
96
+ | --- | --- |
97
+ | `ready` | Resolves with the player once their saves are loaded. |
98
+ | `player` | `{ id, name, guest, owner, signedIn, local }`. |
99
+ | `peek(key)` | The last known value, at once (for a render loop). |
100
+ | `get(key)` | The value, or `null`. |
101
+ | `set(key, value)` | `{ ok, synced, version }`. JSON only. Resolves when synced, or after about 4 s with `synced: false` (it is safe on this device and syncs later). |
102
+ | `remove(key)` | Delete one key. |
103
+ | `list()` | `[{ key, version, updatedAt, pending }]`. |
104
+ | `wipe({ keep })` | Delete this game's saves except `keep` ("new game"). Stats and memorials stay. |
105
+ | `stats.add({…})`, `stats.max({…})`, `stats.min({…})`, `stats.get()` | Lifetime numbers that only add up, or keep the highest or lowest: kills, gold earned, seconds played, best level, fastest run. A wipe or a death keeps them. |
106
+ | `fall({ character, summary, wipe, keep })` | A memorial in the hall of the fallen; with `wipe: true`, this game's saves (except `keep`) are deleted in the same database transaction. |
107
+ | `fallen({ limit, mine })` | The hall of the fallen: the newest memorials of this game (`{ character, player, summary, at }`, no player id). |
108
+ | `rename(name)` | The player's display name on this studio. |
109
+ | `signIn(reason?)` | Show the sign-in sheet. Call it from a button the player pressed. |
110
+ | `flush()`, `status()` | Wait for the cloud; `{ mode, online, pending, lastSyncAt, lastError }`. |
111
+ | `on('player' \| 'synced' \| 'status' \| 'conflict' \| 'error', fn)` | Returns an unsubscribe function. |
112
+
113
+ Without a studio around it (the game opened as a plain file, in a Vite dev server, or a game.json without
114
+ `"saves": true`) the same calls keep everything in that page's own storage (`mode: 'local'`).
115
+
116
+ ## Offline, versions and conflicts
117
+
118
+ The play page keeps a copy of the player's saves in that browser and an **outbox** of changes. A `set` while
119
+ offline is safe at once and syncs when the connection comes back (the `online` event, and retries that back off
120
+ to 30 s), in order: memorials first, then saves, then stats. Leaving the page sends what is left.
121
+
122
+ Every key has a **version** that goes up by one with each write. Every change names the version it was made from,
123
+ so a stale copy never overwrites newer progress silently: if another device saved the key in between, the change
124
+ is a **conflict**. By default the newer cloud copy wins and the game hears `conflict`; with `onConflict` the game
125
+ decides (merge two inventories, keep the higher level). This is also what stops a hardcore hero from coming back:
126
+ an old offline copy of a dead hero is a conflict with the wipe, and the wipe wins.
127
+
128
+ A write is atomic **per key**, not across keys: keep one character in one key.
129
+
130
+ ## The hall of the fallen: a hardcore recipe
131
+
132
+ ```ts
133
+ async function heroDied(cause: string) {
134
+ if (!hero.hardcore) { hero.gold = Math.floor(hero.gold * 0.9); await saves.set('hero', hero); return; }
135
+ const fallen = hero;
136
+ hero = null;
137
+ await saves.stats.add({ deaths: 1, heroesLost: 1 }); // lifetime: kept
138
+ await saves.fall({
139
+ character: fallen.name,
140
+ summary: { level: fallen.level, gold: fallen.gold, kills: fallen.kills, cause },
141
+ wipe: true, // the hero's save goes, in the same step
142
+ keep: ['settings'], // what survives (sound volume, key bindings)
143
+ });
144
+ showHall(await saves.fallen({ limit: 10 })); // the names of the dead, newest first
145
+ }
146
+ ```
147
+
148
+ - The death and the wipe are one transaction on the server; on the device the wipe happens at once, even offline.
149
+ - Memorials are public to the game's players: the character's name, the player's display name and the summary
150
+ (at most 2 KB). A player's own memorials go with their account if they delete it.
151
+ - At most 20 memorials a day per player and game.
152
+
153
+ ## Limits
154
+
155
+ | | |
156
+ | --- | --- |
157
+ | A value | 64 KiB of JSON (1 MiB with the studio's storage, R2: kept under a random key that `/media/` never serves) |
158
+ | Keys | 64 per player per game; names of 1 to 64 letters, digits, `_ . : -` |
159
+ | A game's saves | 256 KiB per player in D1 (and 4 MiB of large ones with storage) |
160
+ | One write | 32 keys |
161
+ | Stats | 64 names per player per game, finite numbers |
162
+ | Passkeys | 10 per account |
163
+ | Rates (per Worker instance, in memory) | 120 writes and 240 reads a minute per player; 60 sign-in or sign-up tries per address per 10 minutes; 60 new players per address an hour |
164
+ | New players | 5,000 a day for the whole studio (the `PLAYER_LIMIT_DAILY` variable changes it) |
165
+
166
+ On Cloudflare's free plan a D1 database has 100,000 row writes a day for everything (stats included). A save is
167
+ one write; the play page coalesces a burst into one request per key. Save on change, not every frame.
168
+
169
+ ## What is stored, and privacy
170
+
171
+ Migration `site/migrations/0004_players.sql` (deploy applies it; an older studio gets it from the next deploy):
172
+
173
+ | Table | What | Never |
174
+ | --- | --- | --- |
175
+ | `players` | a random id (`pl_…`), the display name, guest and owner flags, when made and last seen (refreshed at most every six hours) | an email, a password, an IP address |
176
+ | `player_passkeys` | each passkey's public key, algorithm, signature counter, a label ("iPhone"), when made and last used | a private key (it cannot leave the device) |
177
+ | `player_sessions` | the SHA-256 of each session cookie and short-lived room pass | the cookie itself |
178
+ | `player_challenges` | the SHA-256 of each one-time challenge or emailed link, for minutes | |
179
+ | `player_emails` | a recovery email, only if the player added one | |
180
+ | `saves`, `player_stats`, `memorials` | what the game saved | anything the game did not |
181
+
182
+ - **The player** sees all of it at `/account/`, downloads it as one JSON file (`/api/player/export`), and deletes
183
+ it all (`/api/player/delete`): their passkeys, sessions, saves, stats, memorials and email, and their large
184
+ saves in R2. The page tells them to remove the passkey from their device too, and tells the device itself where
185
+ the browser supports it.
186
+ - **The owner** sees counts (`homie-studio players`, the private stats page, `studio_stats`) and, through the back
187
+ office, names and when players were last seen: never a passkey, a session or an email.
188
+ - **No IP address is stored.** Rate limits count in memory and forget. No third-party script, beacon or cookie is
189
+ used; the passkey ceremony is between the player's device and this site.
190
+ - **Guests** nobody has played for 180 days are deleted with everything they kept.
191
+ - The session cookie is `studio_player`: HttpOnly, Secure, SameSite=Lax, this site only. The game, in its sandboxed
192
+ frame, never sees it: the play page makes every saves call for it, for that game only.
193
+
194
+ ## For the studio's back office
195
+
196
+ Server-side, from the Worker (`import { players } from '@homie-rocks/studio/worker'`):
197
+
198
+ ```js
199
+ await players.count(env); // { accounts, guests, owners, new7d, active1d, active7d }
200
+ await players.list(env, { limit, cursor, q, guests }); // { players: [{ id, name, guest, owner, createdAt, seenAt, passkeys }], next }
201
+ await players.get(env, id); // the same, plus saves per game (keys, bytes, last write), stats, memorials
202
+ await players.of(request, env); // the request's signed-in player, or null
203
+ await players.isOwner(request, env); // the request is the owner's own account
204
+ await players.passFor(env, id, { game, minutes }); // a short-lived pass naming a player ('pp_…')
205
+ await players.readPass(env, pass); // { id, name, guest, owner, game } or null
206
+ await players.rename(env, id, name); // moderation: the same name rules
207
+ await players.remove(env, id); // everything of that player, as their own Delete does
208
+ ```
209
+
210
+ **The owner's own account**: the owner plays with an ordinary passkey account on their site, and marks it once:
211
+ `npx --no-install homie-studio players owner` (it uses the studio's own Cloudflare login, like `stats link`) gives
212
+ a one-time link; opened where they are signed in, one press marks that account as the owner's. `players owner
213
+ --revoke` unmarks every account.
214
+
215
+ **In a room**: the play page can ask `POST /api/player/pass { "game": "<id>" }` for a 10-minute pass; a room that
216
+ needs to know who a socket is (to recognise the owner, or to kick by account) takes it on the socket URL and calls
217
+ `players.readPass` before handing the socket to the Table.
218
+
219
+ ## HTTP
220
+
221
+ Same origin only (a POST must come from this site's own pages, as JSON). For reference; games use the SDK.
222
+
223
+ | | |
224
+ | --- | --- |
225
+ | `GET /api/player/me` | the signed-in player, the features (`accounts`, `email`), the limits |
226
+ | `POST /api/player/signup/options`, `POST /api/player/signup` | make an account, or add a passkey to this browser's guest or account |
227
+ | `POST /api/player/signin/options`, `POST /api/player/signin` | sign in with a passkey (discoverable: no name typed) |
228
+ | `POST /api/player/signout`, `POST /api/player/name`, `GET /api/player/passkeys`, `POST /api/player/passkeys/remove` | |
229
+ | `GET /api/player/export`, `POST /api/player/delete` (`{ "confirm": "delete" }`) | everything, and nothing |
230
+ | `GET /api/player/saves/<game>[?values=1]`, `GET /api/player/saves/<game>/<key>` | read |
231
+ | `POST /api/player/saves/<game>` `{ set: [{ key, value, base }], del: [{ key, base }] }` | write (a guest is made on the first) |
232
+ | `POST /api/player/saves/<game>/wipe` `{ keep }` | |
233
+ | `GET`/`POST /api/player/stats/<game>` `{ add, max, min }` | lifetime stats |
234
+ | `GET /api/player/fallen/<game>[?mine=1]`, `POST /api/player/fallen/<game>` `{ character, summary, wipe, keep }` | the hall of the fallen |
235
+ | `POST /api/player/pass`, `POST /api/player/owner` | a room pass; the owner's one-time mark |
236
+ | `POST /api/player/email`, `/email/verify`, `/email/remove`, `/recover`, `/recover/options` | the recovery email, with a mail sender |