@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/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 |
package/saves/saves.ts ADDED
@@ -0,0 +1,326 @@
1
+ /*
2
+ * saves.ts — cloud saves for a game on a Homie studio (@homie-rocks/studio/saves). SAVES.md beside it is the guide.
3
+ * =============================================================================
4
+ *
5
+ * import { createSaves } from '@homie-rocks/studio/saves';
6
+ * const saves = createSaves({ game: 'my-game' });
7
+ * const hero = (await saves.get<Hero>('hero')) ?? newHero();
8
+ * await saves.set('hero', hero); // safe in this browser at once, in the cloud when online
9
+ * saves.stats.add({ kills: 1, seconds: 30 }); // lifetime numbers that survive a wipe
10
+ * await saves.fall({ character: hero.name, summary: { level: hero.level }, wipe: true }); // hardcore death
11
+ * saves.on('player', () => reload()); // the player signed in on this device: their saves changed
12
+ *
13
+ * WHERE IT KEEPS THINGS. A game on a studio site runs in a sandboxed frame that can keep nothing of its own. The
14
+ * play shell around it (on the studio's own origin) does it: a local copy in that browser, an outbox that syncs to
15
+ * the studio's Worker and D1, and the player's passkey sign-in. This file only talks to the shell (postMessage),
16
+ * so the game never sees a session or a cookie, and can only reach its OWN game's saves. Without a shell (the
17
+ * game opened as a plain file, a Vite dev server, a game.json with no "saves": true) it keeps everything in this
18
+ * page's localStorage instead (mode 'local'), so the same code runs everywhere.
19
+ *
20
+ * ROOMS ARE NOT SAVES. A room forgets everything 60 s after its last player leaves. A character that must last
21
+ * days lives in saves: load it when the player arrives, save it when it changes, and let the room carry only the
22
+ * live round.
23
+ *
24
+ * It has no imports and uses only erasable TypeScript: vendor it into a game like netplay.ts, or import it.
25
+ * =============================================================================
26
+ */
27
+
28
+ export const SAVES_VERSION = 1;
29
+
30
+ export interface Player {
31
+ /** `pl_…` on a studio site (null before this browser saved anything, and in local mode). */
32
+ id: string | null;
33
+ name: string;
34
+ /** No passkey yet: progress is kept in this browser only until the player makes an account. */
35
+ guest: boolean;
36
+ /** The studio owner's own account (marked once with `homie-studio players owner`). Show, never trust. */
37
+ owner: boolean;
38
+ signedIn: boolean;
39
+ /** No studio shell around the game: everything is in this page's localStorage. */
40
+ local: boolean;
41
+ }
42
+
43
+ export interface SaveEntry { key: string; version: number; updatedAt: number | null; pending?: boolean }
44
+ export interface SetResult { ok: boolean; synced: boolean; version: number; error?: string; message?: string }
45
+ export interface Memorial { character: string; player: string; summary: Record<string, unknown>; at: number }
46
+ export interface SavesStatus { mode: 'cloud' | 'local'; online: boolean; pending: number; lastSyncAt: number | null; lastError: string | null }
47
+ export interface Conflict<T = unknown> { key: string; mine: T | null; theirs: T | null; theirsVersion: number }
48
+ export type SavesEvent = 'player' | 'synced' | 'conflict' | 'status' | 'error';
49
+
50
+ export interface SavesOptions {
51
+ /** The game's id (its folder): names the local-mode store. On a studio site the shell's own game id is used. */
52
+ game?: string;
53
+ /**
54
+ * Another device saved a key since this one's copy (this browser was offline, or two devices played at once).
55
+ * Return the value to keep (merge them, or return `mine`), or `undefined` for the cloud's (the default: a stale
56
+ * offline copy never overwrites newer progress, so a hardcore character cannot come back from the dead).
57
+ */
58
+ onConflict?: (c: Conflict) => unknown | Promise<unknown>;
59
+ /** Tests and custom shells: where messages go (default: the parent frame) and the shell's answer window. */
60
+ target?: { postMessage: (m: unknown, origin: string) => void } | null;
61
+ /** Force local mode (true) or cloud mode (false). Default: cloud when the shell said so (HOMIE_NET.saves). */
62
+ local?: boolean;
63
+ /** Local mode's storage (default: localStorage when usable, else memory). */
64
+ storage?: { getItem(k: string): string | null; setItem(k: string, v: string): void } | null;
65
+ }
66
+
67
+ export interface Saves {
68
+ /** Resolves once the player and their saves are loaded (cloud mode: from the shell's copy, then the cloud). */
69
+ readonly ready: Promise<Player>;
70
+ readonly player: Player;
71
+ readonly mode: 'cloud' | 'local';
72
+ /** The last known value of a key, at once (for a render loop). `get` is the same, as a promise. */
73
+ peek<T = unknown>(key: string): T | null;
74
+ get<T = unknown>(key: string): Promise<T | null>;
75
+ /** Keep a value (JSON, 64 KiB; 1 MiB with the studio's storage). Resolves when synced, or after ~4 s saved locally. */
76
+ set<T = unknown>(key: string, value: T): Promise<SetResult>;
77
+ remove(key: string): Promise<SetResult>;
78
+ list(): Promise<SaveEntry[]>;
79
+ /** Delete this game's saves (except `keep`): "new game". Lifetime stats and memorials stay. */
80
+ wipe(opts?: { keep?: string[] }): Promise<{ ok: boolean; synced: boolean }>;
81
+ readonly stats: {
82
+ get(): Promise<Record<string, number>>;
83
+ /** Add to counters: { kills: 1, gold: 25, seconds: 30 }. */
84
+ add(values: Record<string, number>): Promise<{ ok: boolean }>;
85
+ /** Keep the highest: { level: 12 }. */
86
+ max(values: Record<string, number>): Promise<{ ok: boolean }>;
87
+ /** Keep the lowest: { fastestRun: 312 }. */
88
+ min(values: Record<string, number>): Promise<{ ok: boolean }>;
89
+ };
90
+ /**
91
+ * A character died for good (hardcore): a memorial in the hall of the fallen (its name, your display name, a
92
+ * small summary), and with `wipe: true` this game's saves are deleted in the same step (except `keep`).
93
+ */
94
+ fall(m: { character: string; summary?: Record<string, unknown>; wipe?: boolean; keep?: string[] }): Promise<{ ok: boolean; synced: boolean; memorial: Memorial | null }>;
95
+ /** The hall of the fallen: the newest memorials of this game (everyone's), or `mine: true`. */
96
+ fallen(opts?: { limit?: number; mine?: boolean }): Promise<Memorial[]>;
97
+ /** Change the player's display name on this studio (the same rules as the account page). */
98
+ rename(name: string): Promise<{ ok: boolean; name?: string; message?: string }>;
99
+ /** Ask the shell to show its sign-in sheet (a passkey). Call it from a button the player pressed. */
100
+ signIn(reason?: string): void;
101
+ /** Wait until every change is in the cloud (or the network said no). */
102
+ flush(): Promise<SavesStatus>;
103
+ status(): SavesStatus;
104
+ on(ev: 'player', fn: (p: Player) => void): () => void;
105
+ on(ev: 'conflict', fn: (c: Conflict & { kept: 'theirs' | 'game' }) => void): () => void;
106
+ on(ev: 'synced' | 'status', fn: (s: SavesStatus) => void): () => void;
107
+ on(ev: 'error', fn: (e: { key?: string; error: string; message?: string }) => void): () => void;
108
+ }
109
+
110
+ const KEY = /^[A-Za-z0-9_.:-]{1,64}$/;
111
+ type Msg = Record<string, unknown> & { t?: string; q?: number; ev?: string; ok?: boolean };
112
+
113
+ export function createSaves(opts: SavesOptions = {}): Saves {
114
+ const g = globalThis as unknown as {
115
+ HOMIE_NET?: { saves?: boolean }; parent?: unknown; addEventListener?: typeof addEventListener; localStorage?: Storage;
116
+ };
117
+ const cloud = opts.local === undefined ? Boolean(g.HOMIE_NET?.saves) : !opts.local;
118
+ const game = String(opts.game ?? 'game').replace(/[^a-z0-9-]/g, '').slice(0, 40) || 'game';
119
+ const listeners = new Map<string, Set<(x: unknown) => void>>();
120
+ const emit = (ev: string, x: unknown): void => { for (const fn of listeners.get(ev) ?? []) { try { fn(x); } catch { /* the game's */ } } };
121
+ let player: Player = { id: null, name: 'Guest', guest: true, owner: false, signedIn: false, local: !cloud };
122
+ let mirror: Record<string, unknown> = {};
123
+ let st: SavesStatus = { mode: cloud ? 'cloud' : 'local', online: true, pending: 0, lastSyncAt: null, lastError: null };
124
+
125
+ /* ------------------------------------------------------------ cloud mode: the shell */
126
+ const target = opts.target !== undefined ? opts.target : (g.parent && g.parent !== (globalThis as unknown) ? g.parent as { postMessage: (m: unknown, o: string) => void } : null);
127
+ let seq = 0;
128
+ const waiting = new Map<number, (m: Msg) => void>();
129
+ const ask = (op: string, args: Record<string, unknown> = {}, timeoutMs = 20_000): Promise<Msg> => new Promise((resolve) => {
130
+ if (!target) { resolve({ ok: false, error: 'no-shell', message: 'no studio shell around this game' }); return; }
131
+ const q = ++seq;
132
+ const timer = setTimeout(() => { waiting.delete(q); resolve({ ok: false, error: 'timeout', message: 'the play shell did not answer' }); }, timeoutMs);
133
+ waiting.set(q, (m) => { clearTimeout(timer); resolve(m); });
134
+ try { target.postMessage({ t: 'homie-save', q, op, ...args }, '*'); } catch { clearTimeout(timer); waiting.delete(q); resolve({ ok: false, error: 'no-shell' }); }
135
+ });
136
+ const asPlayer = (p: unknown): Player => {
137
+ const o = (p ?? {}) as Partial<Player>;
138
+ return { id: typeof o.id === 'string' ? o.id : null, name: typeof o.name === 'string' ? o.name : 'Guest', guest: o.guest !== false, owner: o.owner === true, signedIn: o.guest === false, local: false };
139
+ };
140
+
141
+ if (cloud && g.addEventListener) {
142
+ g.addEventListener('message', (ev: MessageEvent) => {
143
+ const m = ev.data as Msg;
144
+ if (!m || typeof m !== 'object' || m.t !== 'homie-save') return;
145
+ if (target && ev.source && ev.source !== (target as unknown)) return;
146
+ if (typeof m.q === 'number' && waiting.has(m.q)) { const f = waiting.get(m.q)!; waiting.delete(m.q); f(m); return; }
147
+ if (m.ev === 'player') {
148
+ player = asPlayer(m.player);
149
+ if (m.cache && typeof m.cache === 'object') mirror = { ...(m.cache as Record<string, unknown>) };
150
+ emit('player', player);
151
+ } else if (m.ev === 'status' || m.ev === 'synced') {
152
+ if (m.status) st = { ...(m.status as SavesStatus) };
153
+ emit(m.ev, st);
154
+ } else if (m.ev === 'error') emit('error', { key: m.key, error: m.error, message: m.message });
155
+ else if (m.ev === 'conflict') {
156
+ const c = { key: String(m.key), mine: m.mine ?? null, theirs: m.theirs ?? null, theirsVersion: Number(m.theirsVersion) || 0 };
157
+ if (typeof m.ask === 'number' && opts.onConflict) {
158
+ Promise.resolve().then(() => opts.onConflict!(c)).then((keep) => {
159
+ target?.postMessage({ t: 'homie-save', op: 'resolve', ask: m.ask, ...(keep === undefined ? { keep: 'theirs' } : { keep: 'value', value: keep }) }, '*');
160
+ }, () => target?.postMessage({ t: 'homie-save', op: 'resolve', ask: m.ask, keep: 'theirs' }, '*'));
161
+ return;
162
+ }
163
+ if (m.kept === 'theirs') { if (c.theirs === null) delete mirror[c.key]; else mirror[c.key] = c.theirs; }
164
+ emit('conflict', { ...c, kept: m.kept === 'game' ? 'game' : 'theirs' });
165
+ }
166
+ });
167
+ }
168
+
169
+ /* ------------------------------------------------------------ local mode: this page's storage */
170
+ const memory = new Map<string, string>();
171
+ const store = opts.storage !== undefined && opts.storage !== null ? opts.storage : (() => {
172
+ try { const ls = g.localStorage; if (ls) { ls.setItem('homie-saves.test', '1'); return ls; } } catch { /* sandboxed */ }
173
+ return { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => { memory.set(k, v); } };
174
+ })();
175
+ interface LocalDb { saves: Record<string, { v: unknown; ver: number; at: number }>; stats: Record<string, number>; fallen: Memorial[] }
176
+ const LOCAL = `homie-saves.local.${game}`;
177
+ const readLocal = (): LocalDb => { try { const j = JSON.parse(store.getItem(LOCAL) ?? 'null'); if (j && j.saves) return j; } catch { /* fresh */ } return { saves: {}, stats: {}, fallen: [] }; };
178
+ const writeLocal = (db: LocalDb): void => { try { store.setItem(LOCAL, JSON.stringify(db)); } catch { /* full */ } };
179
+
180
+ /* ------------------------------------------------------------ ready */
181
+ const ready: Promise<Player> = (async () => {
182
+ if (!cloud || !target) {
183
+ player = { id: null, name: 'You', guest: true, owner: false, signedIn: false, local: true };
184
+ st = { ...st, mode: 'local' };
185
+ const db = readLocal();
186
+ mirror = Object.fromEntries(Object.entries(db.saves).map(([k, e]) => [k, e.v]));
187
+ return player;
188
+ }
189
+ const r = await ask('hello', { v: SAVES_VERSION, resolver: Boolean(opts.onConflict) }, 15_000);
190
+ if (r.ok) {
191
+ player = asPlayer(r.player);
192
+ mirror = { ...((r.cache as Record<string, unknown>) ?? {}) };
193
+ if (r.status) st = { ...(r.status as SavesStatus) };
194
+ } else st = { ...st, online: false, lastError: String(r.message ?? r.error ?? 'no shell') };
195
+ return player;
196
+ })();
197
+
198
+ const checkKey = (key: string): string | null => (KEY.test(String(key)) ? null : 'a save key is 1 to 64 letters, digits, _ . : or -');
199
+
200
+ const api: Saves = {
201
+ ready,
202
+ get player() { return player; },
203
+ get mode() { return cloud && target ? 'cloud' : 'local'; },
204
+ peek<T>(key: string): T | null { return (key in mirror ? mirror[key] : null) as T | null; },
205
+ async get<T>(key: string): Promise<T | null> {
206
+ await ready;
207
+ if (checkKey(key)) return null;
208
+ if (api.mode === 'local') return (readLocal().saves[key]?.v ?? null) as T | null;
209
+ const r = await ask('get', { key });
210
+ if (r.ok) { if (r.value === null || r.value === undefined) delete mirror[key]; else mirror[key] = r.value; }
211
+ return (r.ok ? r.value ?? null : mirror[key] ?? null) as T | null;
212
+ },
213
+ async set<T>(key: string, value: T): Promise<SetResult> {
214
+ await ready;
215
+ const bad = checkKey(key);
216
+ if (bad) return { ok: false, synced: false, version: 0, error: 'key', message: bad };
217
+ if (value === undefined) return { ok: false, synced: false, version: 0, error: 'value', message: 'a save needs a value; use remove() to delete a key' };
218
+ let copy: unknown;
219
+ try { copy = JSON.parse(JSON.stringify(value)); } catch { return { ok: false, synced: false, version: 0, error: 'value', message: 'a save is JSON' }; }
220
+ mirror[key] = copy;
221
+ if (api.mode === 'local') {
222
+ const db = readLocal();
223
+ const ver = (db.saves[key]?.ver ?? 0) + 1;
224
+ db.saves[key] = { v: copy, ver, at: Date.now() };
225
+ writeLocal(db);
226
+ return { ok: true, synced: true, version: ver };
227
+ }
228
+ const r = await ask('set', { key, value: copy });
229
+ return { ok: Boolean(r.ok), synced: Boolean(r.synced), version: Number(r.version) || 0, ...(r.ok ? {} : { error: String(r.error), message: String(r.message ?? '') }) };
230
+ },
231
+ async remove(key: string): Promise<SetResult> {
232
+ await ready;
233
+ const bad = checkKey(key);
234
+ if (bad) return { ok: false, synced: false, version: 0, error: 'key', message: bad };
235
+ delete mirror[key];
236
+ if (api.mode === 'local') { const db = readLocal(); delete db.saves[key]; writeLocal(db); return { ok: true, synced: true, version: 0 }; }
237
+ const r = await ask('del', { key });
238
+ return { ok: Boolean(r.ok), synced: Boolean(r.synced), version: 0, ...(r.ok ? {} : { error: String(r.error), message: String(r.message ?? '') }) };
239
+ },
240
+ async list(): Promise<SaveEntry[]> {
241
+ await ready;
242
+ if (api.mode === 'local') return Object.entries(readLocal().saves).sort(([a], [b]) => a.localeCompare(b)).map(([key, e]) => ({ key, version: e.ver, updatedAt: e.at }));
243
+ const r = await ask('list');
244
+ return Array.isArray(r.keys) ? r.keys as SaveEntry[] : [];
245
+ },
246
+ async wipe({ keep = [] } = {}) {
247
+ await ready;
248
+ for (const k of Object.keys(mirror)) if (!keep.includes(k)) delete mirror[k];
249
+ if (api.mode === 'local') { const db = readLocal(); for (const k of Object.keys(db.saves)) if (!keep.includes(k)) delete db.saves[k]; writeLocal(db); return { ok: true, synced: true }; }
250
+ const r = await ask('wipe', { keep });
251
+ return { ok: Boolean(r.ok), synced: Boolean(r.synced) };
252
+ },
253
+ stats: {
254
+ async get() {
255
+ await ready;
256
+ if (api.mode === 'local') return { ...readLocal().stats };
257
+ const r = await ask('stats', { kind: 'get' });
258
+ return (r.stats as Record<string, number>) ?? {};
259
+ },
260
+ add: (values) => bump('add', values),
261
+ max: (values) => bump('max', values),
262
+ min: (values) => bump('min', values),
263
+ },
264
+ async fall({ character, summary = {}, wipe = false, keep = [] }) {
265
+ await ready;
266
+ if (wipe) for (const k of Object.keys(mirror)) if (!keep.includes(k)) delete mirror[k];
267
+ if (api.mode === 'local') {
268
+ const db = readLocal();
269
+ const memorial: Memorial = { character: String(character).slice(0, 32), player: player.name, summary, at: Date.now() };
270
+ db.fallen.unshift(memorial);
271
+ db.fallen = db.fallen.slice(0, 50);
272
+ if (wipe) for (const k of Object.keys(db.saves)) if (!keep.includes(k)) delete db.saves[k];
273
+ writeLocal(db);
274
+ return { ok: true, synced: true, memorial };
275
+ }
276
+ const r = await ask('fall', { character, summary, wipe, keep });
277
+ return { ok: Boolean(r.ok), synced: Boolean(r.synced), memorial: (r.memorial as Memorial) ?? null };
278
+ },
279
+ async fallen({ limit = 20, mine = false } = {}) {
280
+ await ready;
281
+ if (api.mode === 'local') return readLocal().fallen.slice(0, limit);
282
+ const r = await ask('fallen', { limit, mine });
283
+ return Array.isArray(r.fallen) ? r.fallen as Memorial[] : [];
284
+ },
285
+ async rename(name: string) {
286
+ await ready;
287
+ if (api.mode === 'local') { player = { ...player, name: String(name).slice(0, 24) }; emit('player', player); return { ok: true, name: player.name }; }
288
+ const r = await ask('rename', { name });
289
+ return { ok: Boolean(r.ok), ...(r.name ? { name: String(r.name) } : {}), ...(r.message ? { message: String(r.message) } : {}) };
290
+ },
291
+ signIn(reason?: string) { if (api.mode === 'cloud') void ask('signin', reason ? { reason } : {}); },
292
+ async flush() {
293
+ await ready;
294
+ if (api.mode === 'local') return st;
295
+ const r = await ask('flush', {}, 30_000);
296
+ if (r.status) st = { ...(r.status as SavesStatus) };
297
+ return st;
298
+ },
299
+ status() { return st; },
300
+ on(ev: SavesEvent, fn: (x: never) => void) {
301
+ const set = listeners.get(ev) ?? new Set();
302
+ set.add(fn as (x: unknown) => void);
303
+ listeners.set(ev, set);
304
+ return () => { set.delete(fn as (x: unknown) => void); };
305
+ },
306
+ } as Saves;
307
+
308
+ async function bump(kind: 'add' | 'max' | 'min', values: Record<string, number>): Promise<{ ok: boolean }> {
309
+ await ready;
310
+ if (api.mode === 'local') {
311
+ const db = readLocal();
312
+ for (const [k, raw] of Object.entries(values ?? {})) {
313
+ const n = Number(raw);
314
+ if (!Number.isFinite(n)) continue;
315
+ const cur = db.stats[k];
316
+ db.stats[k] = cur === undefined ? n : kind === 'add' ? cur + n : kind === 'max' ? Math.max(cur, n) : Math.min(cur, n);
317
+ }
318
+ writeLocal(db);
319
+ return { ok: true };
320
+ }
321
+ const r = await ask('stats', { kind, values });
322
+ return { ok: Boolean(r.ok) };
323
+ }
324
+
325
+ return api;
326
+ }
package/site/SITE.md CHANGED
@@ -156,6 +156,15 @@ The game runs in a sandboxed frame with an opaque origin, which is why its allow
156
156
  (`fullscreen *; autoplay *; gamepad *`): Safari refuses a bare `gamepad` there (`getGamepads()` throws a
157
157
  SecurityError), so a controller would not work.
158
158
 
159
+ ## Player accounts: `/account/`
160
+
161
+ A studio whose games keep saves (game.json `"saves": true`, [../saves/SAVES.md](../saves/SAVES.md)) has a player
162
+ account page at `/account/`, in the studio's look: make an account with a passkey, sign in on another device,
163
+ choose a name, add or remove passkeys, a recovery email (only with a mail sender), download everything, delete
164
+ everything. The play page of such a game carries the saves bridge and a "who is playing" row in its room sheet,
165
+ and shows a passkey sheet over the game when the game asks (`saves.signIn()`); its landing links the account page.
166
+ `/account/` belongs to the site: `site/pages/account/` and `site/public/account/` are refused.
167
+
159
168
  ## The arcade knock
160
169
 
161
170
  A game made with Homie's arcade controls asks its page's origin for a Homie box (`GET /__homie/call`), and a score
@@ -0,0 +1,20 @@
1
+ {
2
+ "id": "ember-vale",
3
+ "name": "Ember Vale",
4
+ "blurb": "A hero who lasts for days. Hunt slimes with whoever is here tonight; your level, gold and name follow you to every device. Go hardcore and a death is forever.",
5
+ "players": { "min": 1, "max": 8 },
6
+ "roundSeconds": 90,
7
+ "entry": "src/main.ts",
8
+ "netplay": { "v": 1, "public": true, "movement": "owner" },
9
+ "saves": true,
10
+ "landing": {
11
+ "pitch": "Your hero keeps every level. The night does not.",
12
+ "howToPlay": [
13
+ "Make a hero, or pick up the one you left.",
14
+ "Strike slimes for experience and gold; the King Slime pays best.",
15
+ "Every night ends with a ranking; your hero keeps what it earned.",
16
+ "Hardcore: one life. Fall and you join the Hall of the Fallen."
17
+ ],
18
+ "controls": { "phone": "Drag on the left to move, STRIKE on the right.", "computer": "WASD or arrows to move, Space to strike." }
19
+ }
20
+ }
@@ -0,0 +1,46 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
6
+ <title>Ember Vale</title>
7
+ <link rel="icon" href="data:,">
8
+ <style>
9
+ html, body { margin: 0; height: 100%; background: #0b0f0a; overflow: hidden; overscroll-behavior: none; -webkit-user-select: none; user-select: none; font: 15px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; color: #f3ead2; }
10
+ canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
11
+ .ui { position: fixed; z-index: 3; }
12
+ .pill { left: calc(12px + env(safe-area-inset-left)); top: calc(86px + env(safe-area-inset-top)); display: flex; gap: 6px; }
13
+ .pill button { min-height: 32px; padding: 0 11px; border-radius: 999px; border: 1px solid rgba(243,234,210,.25); background: rgba(10,14,9,.6); color: inherit; font: 600 12.5px/1 inherit; font-family: inherit; cursor: pointer; backdrop-filter: blur(4px); }
14
+ .panel { left: 50%; top: 50%; transform: translate(-50%, -50%); box-sizing: border-box; width: min(360px, calc(100vw - 24px)); padding: 18px; border-radius: 16px; background: rgba(14,19,12,.94); border: 1px solid rgba(243,234,210,.2); box-shadow: 0 20px 60px rgba(0,0,0,.55); -webkit-user-select: text; user-select: text; }
15
+ .panel h2 { margin: 0 0 6px; font-size: 20px; color: #ffcf6e; }
16
+ .panel p { margin: 0 0 12px; color: #c9c0a6; }
17
+ .panel input[type=text] { box-sizing: border-box; width: 100%; min-height: 44px; padding: 0 12px; border-radius: 10px; border: 1px solid rgba(243,234,210,.25); background: #070a06; color: #fff; font: 16px inherit; font-family: inherit; }
18
+ .panel label.hc { display: flex; gap: 8px; align-items: center; margin: 12px 0; color: #e7d9b4; }
19
+ .panel .go { width: 100%; min-height: 46px; border: 0; border-radius: 12px; background: #ffcf6e; color: #1b1405; font: 800 16px inherit; font-family: inherit; cursor: pointer; }
20
+ .panel ol { margin: 0 0 12px; padding-left: 20px; max-height: 40vh; overflow: auto; }
21
+ .panel li { margin: 4px 0; color: #e7d9b4; }
22
+ .panel li small { color: #a39a80; }
23
+ .panel .close { float: right; background: none; border: 0; color: #c9c0a6; font-size: 20px; cursor: pointer; }
24
+ [hidden] { display: none !important; }
25
+ </style>
26
+ </head>
27
+ <body>
28
+ <canvas id="c"></canvas>
29
+ <div class="ui pill"><button type="button" id="who"></button><button type="button" id="hall-btn">Hall of the Fallen</button></div>
30
+ <div class="ui panel" id="make" hidden>
31
+ <h2>A new hero</h2>
32
+ <p>Your hero keeps every level and coin, on every device you play on.</p>
33
+ <input type="text" id="hero-name" maxlength="20" placeholder="Name your hero" autocomplete="off">
34
+ <label class="hc"><input type="checkbox" id="hero-hc"> Hardcore: one life. A death is forever, and the Hall remembers.</label>
35
+ <button type="button" class="go" id="hero-go">Into the vale</button>
36
+ </div>
37
+ <div class="ui panel" id="hall" hidden>
38
+ <button type="button" class="close" id="hall-close" aria-label="Close">×</button>
39
+ <h2 id="hall-title">Hall of the Fallen</h2>
40
+ <p id="hall-text">Hardcore heroes who fell in Ember Vale.</p>
41
+ <ol id="hall-list"></ol>
42
+ <button type="button" class="go" id="hall-go" hidden>Make a new hero</button>
43
+ </div>
44
+ <script type="module" crossorigin src="./assets/main.js"></script>
45
+ </body>
46
+ </html>