@homie-rocks/studio 0.31.0 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/parts.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  * is `parts/<id>/part.json` and the files beside it. A part is a piece of a game, never the whole game.
7
7
  *
8
8
  * checkPart() the shape of a part.json, each problem in plain words with its fix
9
+ * previewAspect() a preview's declared shape ("4:3") as two whole numbers, or null: the only reading of it
9
10
  * hashDir() every file of a part with its SHA-256 and size (node:crypto): written by the tool, never by hand
10
11
  * newPart() a part lifted out of one of the studio's games (the game still builds and plays the same)
11
12
  * fileRights() whose each file is: its own record, a game's asset record of the same bytes, or the part's provenance
@@ -42,6 +43,30 @@ const clean = (s, n = 300) => (typeof s === 'string' ? s.replace(/[\x00-\x1f\x7f
42
43
  const isObj = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
43
44
  export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
44
45
 
46
+ /* ------------------------------------------------------------------ a preview's shape */
47
+
48
+ /**
49
+ * The shape of a part's preview frame: `preview.aspect`, and `preview.phoneAspect` for a frame narrower than
50
+ * 600 CSS px. Each is "W:H" in whole numbers 1 to 32, no flatter than 3:1 and no taller than 1:2. The default
51
+ * is 16:9, which on a 390 px phone is a frame about 197 px tall: too short for a preview somebody plays with.
52
+ */
53
+ const PREVIEW_SHAPE = Object.freeze({ max: 32, flattest: 3, tallest: 2 });
54
+ /** { w, h } for a shape inside those bounds; null for anything else (the page then uses the default). */
55
+ export function previewAspect(value) {
56
+ const m = typeof value === 'string' ? /^([1-9]\d?):([1-9]\d?)$/.exec(value) : null;
57
+ if (!m) return null;
58
+ const w = Number(m[1]); const h = Number(m[2]);
59
+ if (w > PREVIEW_SHAPE.max || h > PREVIEW_SHAPE.max || w > PREVIEW_SHAPE.flattest * h || h > PREVIEW_SHAPE.tallest * w) return null;
60
+ return { w, h };
61
+ }
62
+ /** Why a declared shape is refused, in plain words; null when it is fine. */
63
+ function aspectProblem(value) {
64
+ if (previewAspect(value)) return null;
65
+ const m = typeof value === 'string' ? /^([1-9]\d?):([1-9]\d?)$/.exec(value) : null;
66
+ if (!m || Number(m[1]) > PREVIEW_SHAPE.max || Number(m[2]) > PREVIEW_SHAPE.max) return 'it is not a shape: two whole numbers from 1 to 32 with a colon between them';
67
+ return Number(m[1]) > Number(m[2]) ? 'it is flatter than 3:1, too short a frame to show a part in' : 'it is taller than 1:2, too tall a frame to fit a screen';
68
+ }
69
+
45
70
  /* ------------------------------------------------------------------ versions and ranges */
46
71
 
47
72
  const VERSION = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:-[0-9A-Za-z.-]{1,40})?$/;
@@ -241,6 +266,8 @@ export function checkPart(part, { id = null } = {}) {
241
266
  else {
242
267
  if (part.preview.page != null && (!safePath(part.preview.page) || !/\.html?$/.test(part.preview.page))) bad('preview.page', 'it is not an HTML file inside the part', 'preview/index.html');
243
268
  if (part.preview.image != null && (!safePath(part.preview.image) || !/\.(png|jpe?g|webp|gif|svg)$/i.test(part.preview.image))) bad('preview.image', 'it is not a picture inside the part', 'preview/cover.jpg');
269
+ // The frame's shape. Refused, never repaired: the studio's page builds its CSS from these two numbers and nothing else.
270
+ for (const k of ['aspect', 'phoneAspect']) if (part.preview[k] !== undefined && aspectProblem(part.preview[k])) bad(`preview.${k}`, aspectProblem(part.preview[k]), k === 'aspect' ? '"4:3", "1:1", "3:4"; leave it out for 16:9' : '"3:4" or "1:1": the shape on a phone; leave it out for the usual one');
244
271
  const listed = new Set((Array.isArray(part.files) ? part.files : []).map((f) => f?.path));
245
272
  for (const k of ['page', 'image']) if (part.preview[k] && Array.isArray(part.files) && safePath(part.preview[k]) && !listed.has(part.preview[k])) bad(`preview.${k}`, `${part.preview[k]} is not one of the part's files`, 'check the path; checking the part rewrites its file list');
246
273
  }
package/lib/routes.mjs CHANGED
@@ -16,8 +16,17 @@
16
16
  * So: the routes the owner wrote for the studio's OWN hostnames (a custom domain, an exact host) are kept across
17
17
  * every rewrite; a wildcard or catch-all route is never deployed by the studio unless studio.json says the zone is
18
18
  * the studio's (`cloudflare.allowWildcardRoutes: true`); and a deploy whose domain answers as something else says
19
- * which route is needed. Nothing here calls Cloudflare: a zone's other routes belong to other Workers, and the
20
- * studio never lists, changes or removes one.
19
+ * which route is needed.
20
+ *
21
+ * 4. The zone ALREADY has another site's catch-all or wildcard route, and the studio's custom domain sits beside
22
+ * it. The studio's own config is clean, so nothing above speaks; the route answers the studio's hostname first,
23
+ * and the tempting repair (edit or remove that route) takes the other site down. So a deploy and its plan READ
24
+ * the zone's Worker routes when the studio has a custom domain (readZoneRoutes: two GETs with the sign-in
25
+ * Wrangler already has, never a write) and say, before anything is deployed, that the route is another site's,
26
+ * must not be edited or removed, and which one line makes the studio's hostname its own (zoneFinding). A list
27
+ * that cannot be read is said as unmeasured, never as fine.
28
+ *
29
+ * The studio never changes or removes a route it did not make: a zone's other routes belong to other Workers.
21
30
  */
22
31
  import { readFileSync } from 'node:fs';
23
32
  import { configPath } from './studio.mjs';
@@ -130,3 +139,102 @@ export function shadowedDomain(config, hostname, answer = {}) {
130
139
  why: `https://${host} ${said}, not as this studio: something else on the domain answers that hostname first. The usual reason is another Worker's route on the same zone that covers it (a wildcard like "*${route.zone_name}/*", or a catch-all): Cloudflare matches a route before a custom domain. The studio does not touch that route (it is not the studio's). Add the studio's own exact-host route to "routes" in wrangler.jsonc, which wins over a wildcard for this one hostname, and deploy again: ${JSON.stringify(route)} (zone_name is the domain as it is named in Cloudflare). Every deploy keeps it.`,
131
140
  };
132
141
  }
142
+
143
+ /* ------------------------------------------------------------------ the zone's routes, beside the studio's domain */
144
+
145
+ const CF_API = 'https://api.cloudflare.com/client/v4';
146
+
147
+ /** The zones a hostname may be in, likeliest first: the ones the config's routes name, then each parent of the hostname. */
148
+ export function zoneCandidates(hostname, config = null) {
149
+ const parts = String(hostname ?? '').toLowerCase().split('.').filter(Boolean);
150
+ const named = routesOf(config).map((r) => String(r.zone_name ?? '').toLowerCase()).filter(Boolean);
151
+ const parents = [];
152
+ for (let i = 0; i <= parts.length - 2; i += 1) parents.push(parts.slice(i).join('.'));
153
+ return [...new Set([...named, ...parents])].slice(0, 6);
154
+ }
155
+
156
+ /**
157
+ * The Worker routes of the zone a hostname is in, READ from Cloudflare (GET /zones?name=…, then GET
158
+ * /zones/<id>/workers/routes) with headers the toolkit already has (lib/brain-probe.mjs cloudflareAuth: Wrangler's
159
+ * own sign-in). { read: true, zone: { id, name }, routes: [{ pattern, script }] }, or { read: false, why } when it
160
+ * could not be read (no sign-in, a sign-in that may not read the zone, no such zone on it, no network). It never
161
+ * writes. `fetchFn` stands in for the network in tests.
162
+ */
163
+ export async function readZoneRoutes({ hostname, config = null, headers = null, fetchFn = globalThis.fetch, base = CF_API, ms = 10_000 } = {}) {
164
+ if (!headers) return { read: false, why: 'this computer has no Cloudflare sign-in to read them with' };
165
+ const root = String(base).replace(/\/+$/, '');
166
+ const get = async (path) => {
167
+ const res = await fetchFn(`${root}${path}`, { headers: { ...headers, accept: 'application/json', 'user-agent': 'homie-studio-deploy' }, signal: AbortSignal.timeout(ms) });
168
+ let body = null;
169
+ try { body = await res.json(); } catch { body = null; }
170
+ return { status: res.status, ok: res.ok && body?.success !== false, result: Array.isArray(body?.result) ? body.result : null };
171
+ };
172
+ const refused = (status) => (status === 401 || status === 403 ? 'this Cloudflare sign-in may not read the domain\'s routes' : `Cloudflare answered ${status}`);
173
+ try {
174
+ let zone = null;
175
+ for (const name of zoneCandidates(hostname, config)) {
176
+ const r = await get(`/zones?name=${encodeURIComponent(name)}`);
177
+ if (!r.ok || !r.result) return { read: false, why: refused(r.status) };
178
+ const hit = r.result.find((z) => String(z?.name ?? '').toLowerCase() === name && z?.id);
179
+ if (hit) { zone = { id: String(hit.id), name }; break; }
180
+ }
181
+ if (!zone) return { read: false, why: `no domain that ${String(hostname).toLowerCase()} belongs to is on the Cloudflare account this computer is signed in to` };
182
+ const r = await get(`/zones/${encodeURIComponent(zone.id)}/workers/routes`);
183
+ if (!r.ok || !r.result) return { read: false, zone, why: refused(r.status) };
184
+ return { read: true, zone, routes: r.result.filter((x) => typeof x?.pattern === 'string' && x.pattern.trim()).map((x) => ({ pattern: x.pattern.trim(), script: x.script ? String(x.script) : null })) };
185
+ } catch (error) {
186
+ return { read: false, why: error?.name === 'TimeoutError' ? `Cloudflare did not answer in ${Math.round(ms / 1000)} s` : `Cloudflare could not be reached (${String(error?.cause?.code ?? error?.code ?? error?.message ?? error).slice(0, 80)})` };
187
+ }
188
+ }
189
+
190
+ const NOT_OURS = 'Never edit or remove a route this studio did not make';
191
+
192
+ /**
193
+ * What the zone's routes mean for a studio on a custom domain, in words for the plan, the terminal and the tool
194
+ * result. `zone` is readZoneRoutes' answer; `worker` the studio's own Worker; `config` its wrangler.jsonc.
195
+ * state 'clear' no other route covers the studio's hostname: nothing to say (`why` is null)
196
+ * state 'foreign' another site's wildcard or catch-all covers it and the studio has no exact-host route of its
197
+ * own: `warning`, with the one line to add (`route`, `line`)
198
+ * state 'own-route' another site's route covers it, and the studio's own exact-host route already wins for its
199
+ * hostname: `why` says to leave the other route alone
200
+ * state 'taken' the studio's own hostname is routed to another Worker: only the person can decide that
201
+ * state 'unmeasured' the list could not be read: said as that, with the reason
202
+ */
203
+ export function zoneFinding({ hostname, worker = null, config = null, zone = null } = {}) {
204
+ const host = String(hostname ?? '').toLowerCase();
205
+ const guess = zone?.zone?.name ?? likelyZone(host);
206
+ const route = exactRouteFor(host, guess);
207
+ const line = JSON.stringify(route);
208
+ const ownInConfig = sortRoutes(config).own.find((r) => routeKind(r) === 'exact-host' && routeHost(r) === host) ?? null;
209
+ if (!zone?.read) {
210
+ return {
211
+ state: 'unmeasured', host, zone: zone?.zone?.name ?? null, foreign: [], route, line,
212
+ why: `Not checked: whether another site's route on the domain covers ${host} (${zone?.why ?? 'not read'}). That is unmeasured, not a pass. If ${host} answers as something else after a deploy, the fix is the studio's own exact-host route in "routes" in wrangler.jsonc, ${line} (zone_name is the domain as Cloudflare names it), which every deploy keeps. ${NOT_OURS}.`,
213
+ };
214
+ }
215
+ const others = zone.routes.filter((r) => r.script !== worker && routeCovers(r, host)).map((r) => ({ pattern: r.pattern, script: r.script, kind: routeKind(r) }));
216
+ const taken = others.filter((r) => r.kind === 'exact-host');
217
+ const wide = others.filter((r) => r.kind !== 'exact-host');
218
+ const said = (list) => list.map((r) => `"${r.pattern}" (${r.script ? `Worker ${r.script}` : 'no Worker: it turns Workers off for what it covers'})`).join(', ');
219
+ const base = { host, zone: zone.zone.name, route, line };
220
+ if (taken.length) {
221
+ return {
222
+ ...base, state: 'taken', foreign: others,
223
+ warning: `${host} itself is routed to another Worker on ${zone.zone.name}: ${said(taken)}. That route is not this studio's, so the studio does not take, edit or remove it, and neither should you: whatever it serves would stop answering. Only the person can decide to hand the hostname over, in the domain's Workers Routes in the Cloudflare dashboard; or give the studio a hostname nothing else uses (studio.json cloudflare.domain).`,
224
+ };
225
+ }
226
+ if (!wide.length) return { ...base, state: 'clear', foreign: [], why: null };
227
+ const ownLive = zone.routes.find((r) => r.script === worker && routeKind(r) === 'exact-host' && routeHost(r) === host) ?? null;
228
+ const all = wide.some((r) => r.kind === 'catch-all');
229
+ const them = `${wide.length === 1 ? 'a route' : 'routes'} that cover${wide.length === 1 ? 's' : ''} ${host} and ${wide.length === 1 ? 'is' : 'are'} not this studio's: ${said(wide)}`;
230
+ if (ownInConfig || ownLive) {
231
+ return {
232
+ ...base, state: 'own-route', foreign: wide,
233
+ why: `${zone.zone.name} has ${them}. ${wide.length === 1 ? 'It belongs' : 'They belong'} to another site on this domain and must not be edited or removed. The studio's own exact-host route ("${(ownInConfig ?? ownLive).pattern}") wins over ${wide.length === 1 ? 'it' : 'them'} for this one hostname, and every deploy keeps it: nothing to change.`,
234
+ };
235
+ }
236
+ return {
237
+ ...base, state: 'foreign', foreign: wide,
238
+ warning: `${zone.zone.name} already has ${them}. ${wide.length === 1 ? 'That route belongs' : 'Those routes belong'} to another site on this domain: ${wide.length === 1 ? 'it' : 'they'} must not be edited or removed, here or in the Cloudflare dashboard, or that site stops answering. Cloudflare matches a route before a custom domain, so ${all ? 'the catch-all' : 'the wildcard'} answers ${host} first and the studio's own domain would show the other site. The one safe fix is the studio's own exact-host route, which wins over a wider route for this one hostname only and which every deploy keeps. Add this line to "routes" in wrangler.jsonc and deploy: ${line} (or \`homie-studio deploy --own-route\`, which adds exactly that line and nothing else).`,
239
+ };
240
+ }
package/lib/scaffold.mjs CHANGED
@@ -281,7 +281,7 @@ studio's pinned copy, never a registry lookup of the bare name.
281
281
  does not raise the callout.
282
282
  - **The frame has no storage of its own.** The game runs in a sandboxed frame without \`allow-same-origin\` (a
283
283
  stranger's game must never read this site's storage or the owner's session), so \`localStorage\` throws inside it.
284
- A setting or a personal best goes in \`net.prefs\` (\`await net.prefs.get('quality', 'high')\`,
284
+ A setting or a personal best goes in \`net.prefs\` (\`net.prefs.string('quality', 'high')\`, \`net.prefs.number('volume', 0.8, { min: 0, max: 1 })\` after \`await net.prefs.ready\`: the typed readers give your fallback for a missing key, never a null that turns into 0,
285
285
  \`net.prefs.set('quality', 'low')\`: the play page keeps 16 KB a game); progress that must last goes in saves.
286
286
  The play page's \`?debug\` and \`?q=…\` reach the game as \`net.params\`, with any names game.json
287
287
  \`"netplay": { "params": ["seed"] }\` declares (\`NETPLAY.md\` section 24).
@@ -489,8 +489,8 @@ chat's card follows the work from then on) and, for a studio still being set up,
489
489
  \`"games/*/game.json"\`) and \`"budget"\` (\`{ "usd": <n>, "credits": <n> }\`: the most the studio's media jobs spend
490
490
  in all). In Claude Code, the Homie plugin's mod holds an edit to a protected file, a deploy, and a paid call past
491
491
  the budget until the person says Proceed, and takes keys out of command output. In Codex the plugin's hooks hold
492
- the same calls until the person's own \`proceed <code>\`, once the person has trusted them; in Grok Build nothing
493
- holds them yet. \`npx --no-install homie-studio setup status --client codex\` (or \`grok\`) says whether Homie's
492
+ the same calls until the person's own \`proceed <code>\`, once the person has trusted them, and in Grok Build once
493
+ the plugin is trusted. \`npx --no-install homie-studio setup status --client codex\` (or \`grok\`) says whether Homie's
494
494
  holds are on, and while they are off, ask before each of those yourself. When a call is refused, say what you
495
495
  meant to do and why; do not retry it unless the person asks.
496
496
 
@@ -1612,6 +1612,46 @@
1612
1612
  }
1613
1613
  }
1614
1614
  }
1615
+ },
1616
+ "0.31.1": {
1617
+ "files": {
1618
+ "CLAUDE.md": "ddf2b2a0376202f6",
1619
+ "README.md": "684b3cca8001673c",
1620
+ "HANDOFF.md": "29337e2ace95b2e7",
1621
+ "games/README.md": "5ad32fe80c61f117",
1622
+ "music/README.md": "a3dc042dadd39df8",
1623
+ "videos/README.md": "16babc3437d5545b",
1624
+ "posts/README.md": "37841aae476bd540",
1625
+ "site/README.md": "732894273a32dc3f",
1626
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
1627
+ },
1628
+ "sections": {
1629
+ "AGENTS.md": {
1630
+ "(intro)": "214aa46eb3a895d5",
1631
+ "## Layout": "8baf49e4291e7e88",
1632
+ "## Commands (all through the pinned CLI in node_modules)": "29a04ed3d956610a",
1633
+ "## The site": "0cb8dd9a6f8a0096",
1634
+ "## Making games": "2da7fa74c8884834",
1635
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1636
+ "## Art direction and models": "a48fff7bf0a5f2fc",
1637
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
1638
+ "## Servers and AI seats": "98d08705087a33ec",
1639
+ "## Selling things (the shop)": "b04b0a2b25f524f2",
1640
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1641
+ "## Rules": "2e6b29afb85dcf7e",
1642
+ "## Telling Homie": "80b46fdd9e578db8",
1643
+ "## Beta": "9d02638207a26e73"
1644
+ }
1645
+ },
1646
+ "named": {
1647
+ "Homie Arcade": {
1648
+ "sections": {
1649
+ "AGENTS.md": {
1650
+ "## Commands (all through the pinned CLI in node_modules)": "5d1c068adda1116b"
1651
+ }
1652
+ }
1653
+ }
1654
+ }
1615
1655
  }
1616
1656
  }
1617
1657
  }
@@ -23,6 +23,9 @@ revision-8 relay ignore every new field and frame):
23
23
  the `admit` callback, and createRoom's takeover and adopt callbacks for a room revived from its
24
24
  checkpoint and for a host that reconnects as host.
25
25
  - **Places** (section 26): a tie policy for results. **The relay's log** (section 27).
26
+ - **Seat or solo** (section 28, added after 0.31.0 with no change to the wire): `createRoom({ fallback })`,
27
+ `room.standing`, `net.full`, `net.line()`, a bound on `reconnecting` (`reconnectMaxMs`), and typed
28
+ readers for `net.prefs` (section 24).
26
29
  - New optional fields `hello.ver`, `hello.feat`, `welcome.ver`, `welcome.stale`, `welcome.stall`,
27
30
  `peer.occ`, `peer.ver`, `peer.feat`, `snap.hb`; a relay → client `stale` frame; the final refusal
28
31
  `stale` and the reported `room-stale`; `NETPLAY_REVISION` 9 and the build mark `homie-netplay-rev:9`.
@@ -1529,8 +1532,8 @@ A page stands in one of six places with its room, and the helper says which:
1529
1532
  |---|---|---|---|
1530
1533
  | `connecting` | No welcome yet. | false | false |
1531
1534
  | `online` | In the room. | false | true |
1532
- | `reconnecting` | It was in the room and its socket went. It knocks again with its token (250 ms, 500 ms, 1 s, 2 s, then every 4 s); its seat is held 60 s and a bot drives its body meanwhile. | false | false |
1533
- | `alone` | The room never answered in time, so this page plays by itself as an offline host: no seat, its own bots, nobody else. It still knocks, and joins when it is answered (a `role` with `why: 'reconnected'`). | **true** | false |
1535
+ | `reconnecting` | It was in the room and its socket went. It knocks again with its token (250 ms, 500 ms, 1 s, 2 s, then every 4 s); its seat is held 60 s and a bot drives its body meanwhile. **Bounded**: after `reconnectMaxMs` (default 20000, 5000 to 300000) it is `alone`. | false | false |
1536
+ | `alone` | The room never answered in time (`why: 'relay-timeout'`), or it was in the room and the room did not come back (`why: 'reconnect-timeout'`). This page plays by itself as an offline host: no seat, its own bots, nobody else. It still knocks, and joins when it is answered (a `role` with `why: 'reconnected'`). | **true** | false |
1534
1537
  | `offline` | There is no room at all: a plain file, a dev server with no shell. | true | false |
1535
1538
  | `closed` | Stopped for good; `net.closedWhy` says why (`replaced`, `room-full`, `kicked`, `stale`…). | as it was | false |
1536
1539
 
@@ -1544,12 +1547,40 @@ A page stands in one of six places with its room, and the helper says which:
1544
1547
  anything, which is how two browsers of one room came to report `hosting: true` with different
1545
1548
  rosters. A game that must not score, save or award anything while it is cut off checks
1546
1549
  `net.link === 'online'`.
1547
- - **The line over the game.** While the link is `reconnecting` (for more than 0.7 s) or `alone`,
1548
- the helper draws one line at the top of the game's own document: "Reconnecting…" or "Playing
1549
- offline · reconnecting…". It is an element `[data-homie-link="reconnecting" | "alone" |
1550
- "stale"]` whose default style has no specificity (`:where(…)`), so any rule the game writes for
1550
+ - **The line over the game.** One line at the top of the game's own document says where the
1551
+ page stands whenever that is not "playing in the room". It says what is true, so "Reconnecting…"
1552
+ is only ever shown to a page that was connected:
1553
+
1554
+ | When | `data-homie-link` | The line |
1555
+ |---|---|---|
1556
+ | `reconnecting` for more than 0.7 s | `reconnecting` | Reconnecting… |
1557
+ | `alone`, never answered | `alone` | Playing on your own · still looking for the room… |
1558
+ | `alone`, a room still on the old build (`room-stale`) | `alone` | Playing on your own · this room opens when its players have the new version |
1559
+ | `alone`, the room did not come back | `alone` | Playing on your own · the room dropped, still trying… |
1560
+ | `online`, every seat taken (`net.full`) | `full` | This room is full · watching until a seat is free |
1561
+ | `online`, the game's own sentence (`net.line(text)`; `createRoom`'s `fallback` uses it) | `seat` | for example: Playing on your own until the next round |
1562
+ | `closed`: `room-full`, `too-many` | `closed` | This room is full. (with "· playing on your own" once it does) |
1563
+ | `closed`: `replaced`, `kicked`, `room-closed`, `version` | `closed` | This game is open in another tab. / This room is closed. / This game needs a reload to play online. |
1564
+ | `closed`: `stale`, or a newer build is live | `stale` | This game was updated. Tap to reload. |
1565
+
1566
+ Its default style has no specificity (`:where(…)`), so any rule the game writes for
1551
1567
  `[data-homie-link]` restyles it. `createNetplay({ linkOverlay: false })` turns it off: listen
1552
- to `link` and draw your own. The play page's chip says "reconnecting" too.
1568
+ to `link` and draw your own. The play page's chip says the same in a word ("reconnecting",
1569
+ "playing on your own: looking for the room", "room full"), and a room with no place left at all
1570
+ gets the page's notice with **Play in another room**. A game that draws its own "Reconnecting…"
1571
+ tests `net.link === 'reconnecting'`, not `!net.connected`: that is also true of a page still
1572
+ joining and of one that stopped for good.
1573
+ - **Every state ends somewhere a player can act, in bounded time.** `connecting` ends in `online`
1574
+ or, after `connectOpenMaxMs`, in `alone`. `reconnecting` ends in `online` or, after
1575
+ `reconnectMaxMs`, in `alone` (before this bound a replica whose room never came back showed a
1576
+ frozen round and "Reconnecting…" for good). Going `alone` from a room is a `role` event (`role:
1577
+ 'host'`, `why: 'reconnect-timeout'`), also for a page that was the room's host: its seat went
1578
+ with the room. `createRoom` keeps the round such a host was running (its own body and score,
1579
+ everybody else's body a bot's); a game on raw `createNetplay` starts a round of its own, as it
1580
+ does for `relay-timeout`. `closed` is said in words and the play page offers the way out.
1581
+ - **`net.full`** is true when this browser asked to play and every seat is taken: it is in the
1582
+ room with no seat (`seat === null`, role `screen`) and the relay seats it when one frees up (a
1583
+ `role` event with `why: 'seated'`). Section 28 says what a game does meanwhile.
1553
1584
  - **`net.reconnects`** is how many times the helper has scheduled another knock (the same number
1554
1585
  as `stats().reconnects`, which was always there; `net.reconnects` and
1555
1586
  `window.__homieNet.reconnects` are new). 0 means the connection was never interrupted, which is
@@ -1572,6 +1603,12 @@ With `connectClock: 'game'` the socket still opens at once and a welcome that ar
1572
1603
  used at once; only the giving-up waits for `net.start()` (or for 60 s, so a game that never says
1573
1604
  is not left without a role).
1574
1605
 
1606
+ **A page that was blocked is not a dead socket.** The helper drops a socket that delivered
1607
+ nothing for `staleMs` (6 s) and knocks again. A heavy boot blocks the page for longer than that,
1608
+ and when it wakes its ping timer runs before the frames queued behind it: a healthy socket was
1609
+ dropped and every long hitch became a reconnect. A ping that itself runs more than 3.5 s late now
1610
+ gives the socket 2.5 s to be heard from first; one that is really dead is still dropped.
1611
+
1575
1612
  **A host that hitches.** The relay hands the room on when its host has sent no snapshot for the
1576
1613
  stall time while others are present. That is right for a frozen tab and wrong for a game whose
1577
1614
  frames stopped for a second (a level loading, shaders compiling): a pair of slow 3D clients swapped
@@ -1680,10 +1717,38 @@ those through the helper.
1680
1717
 
1681
1718
  **`net.prefs`: a few settings that last.** Quality, volume, a personal best, the last name typed.
1682
1719
 
1720
+ **The trap, and the fix: a setting that is not there must never reach your game as `null`.**
1721
+ `null * x` is `0` and `Number(null)` is `0`, so a volume that was never set, or was kept as `null`,
1722
+ plays as silence with no error. Read a number, a switch or a word with the typed readers: they
1723
+ answer with your fallback for a key never set, a `null`, a wrong type, a `NaN`, and a read before
1724
+ the prefs arrived.
1725
+
1726
+ ```ts
1727
+ await net.prefs.ready; // once, before the first read
1728
+ const volume = net.prefs.number('volume', 0.8, { min: 0, max: 1 }); // never null, never NaN, never out of range
1729
+ const muted = net.prefs.boolean('muted', false); // true or false, never 0 or 'false'
1730
+ const quality = net.prefs.string('quality', 'high', { oneOf: ['low', 'high'] });
1731
+ net.prefs.set('volume', 0.4); // resolves true when kept
1732
+ ```
1733
+
1734
+ - `number(key, fallback, { min, max, integer })`, `boolean(key, fallback)` and
1735
+ `string(key, fallback, { oneOf })` read what has arrived, without waiting. The fallback is
1736
+ yours and required: one of the wrong type (`number('volume')`, a `NaN`) throws a `TypeError`.
1737
+ - **Before `ready`** the page has not answered yet (on the play page the prefs come by message):
1738
+ a typed reader and `peek` answer with the fallback and the console says so once, naming
1739
+ `await net.prefs.ready`. `net.prefs.loaded` says whether they have arrived.
1740
+ - **A wrong type is said once** per key in the console, with what was kept (a string where a
1741
+ number was asked for, a word that is not one of `oneOf`). A key that is simply not set says nothing.
1742
+ - **`set` refuses what JSON cannot keep**: `NaN`, `Infinity`, a function, a loop, `undefined`
1743
+ inside an object. JSON would write `null`, and the next visit would read that as the setting.
1744
+ It resolves false, warns once for the key, and what was kept stands. `set(key, null)` still
1745
+ removes. A `null` that an earlier build kept this way is read as nothing kept, by `get` too.
1746
+ - `get(key, fallback)`, `all()` and `peek(key, fallback)` remain for JSON values (an object, a
1747
+ list): they are not checked against the fallback's type.
1748
+
1683
1749
  ```ts
1684
- const q = await net.prefs.get('quality', 'high'); // always resolves, with the fallback if nothing is kept
1685
- net.prefs.set('quality', 'low'); // resolves true when kept
1686
- await net.prefs.ready; net.prefs.peek('best', 0); // after ready: read without waiting
1750
+ const q = await net.prefs.get('layout', { hud: 'left' }); // always resolves, with the fallback if nothing is kept
1751
+ await net.prefs.ready; net.prefs.peek('best', 0); // after ready: read without waiting
1687
1752
  ```
1688
1753
 
1689
1754
  - The play page keeps them in its own storage, one JSON object per game on this browser
@@ -1834,6 +1899,13 @@ real browsers:
1834
1899
  6. The host's network drops for a second while a third browser joins: after it reconnects, three
1835
1900
  people, three bodies, and nobody's score was reset.
1836
1901
  7. A deploy with tabs open: section 23.
1902
+ 8. **A slow browser joins mid-round** (throttle one to "4x slowdown" and open the room's link
1903
+ while a round runs, then again during the results). Within a few seconds it is in one of the
1904
+ places section 28 names, and the screen says which: playing in the room on its own body,
1905
+ playing on its own with bots, or watching with a line that says until when. Never a body-less
1906
+ camera with nothing said, never "Reconnecting…" on a page that was never connected, and never
1907
+ a line left up after the link came back. `room.standing` (or `net.link`, `net.full` and your
1908
+ own roster on raw `createNetplay`) is what a test asserts.
1837
1909
 
1838
1910
  ## 26. Places and ties (revision 9)
1839
1911
 
@@ -1876,3 +1948,59 @@ and `failed`.
1876
1948
  tested with stand-in sockets. If `wrangler dev` still prints an error with no room on it after a
1877
1949
  host is closed, it comes from somewhere these boundaries do not cover; the `leave` and
1878
1950
  `socket-gone` lines beside it say what the room was doing at that second.
1951
+
1952
+ ## 28. Seat or solo: a page with no body (after 0.31.0)
1953
+
1954
+ A page can be in its room and have nothing to play: `net.link === 'online'`, and no body. It
1955
+ happened three ways, and none of them said anything:
1956
+
1957
+ | How | What the page had | What happens now |
1958
+ |---|---|---|
1959
+ | The relay seated the player and the host had no body free (every body was somebody's: a server stopped keeping seats for AI mid-round, or the game has fewer bodies than the room has seats). The claim was tried once, at the join. | a seat, role `replica`, no body, for the rest of the visit | The host seats everybody who is waiting **whenever a body frees up and at every round start** (`createRoom`; the starters on raw `createNetplay` do the same in `seatWaiting()`). |
1960
+ | Every seat is taken. The relay lets the page in with no seat. | `seat === null`, role `screen`, nothing said | `net.full` is true and the line says so; the relay seats it when a seat frees up, as before. |
1961
+ | The page was reset by its host before a snapshot showed its body. | a body it never adopted (it could not move until the next round) | `createRoom` owes the adoption until a snapshot shows the body. |
1962
+
1963
+ **The recipe is an option, not prose.** `createRoom` decides what a body-less page does, after
1964
+ `seatWaitMs` (default 4000, 1000 to 30000) of time it could actually listen:
1965
+
1966
+ ```ts
1967
+ const room = createRoom({
1968
+ // …
1969
+ fallback: 'solo', // 'wait' (default) | 'solo' | 'spectate'
1970
+ seatWaitMs: 4000,
1971
+ onStanding: (s) => hud.say(s.line), // optional: the line over the game says it already
1972
+ });
1973
+ room.standing; // { state: 'joining' | 'playing' | 'solo' | 'watching' | 'waiting' | 'closed', why, line }
1974
+ ```
1975
+
1976
+ | `fallback` | What the page does while the room has no body for it | The line |
1977
+ |---|---|---|
1978
+ | `'wait'` (default) | What a game did before: it waits. Now it says so, and the host seats it as above. | Waiting for a place in this round… |
1979
+ | `'solo'` | A private round with bots, here: `room.hosting` is true, `room.mine()` is its body, `room.solo` is true. Nothing of it is sent (no round, roster, snapshot or `room.send`). The moment the room has a body for it, the private round ends and `adopt` is called with the room's body. | Playing on your own until the next round (in a full room: This room is full · playing on your own until a seat is free) |
1980
+ | `'spectate'` | No body; `room.viewBody()` follows a person in the room until it has one. | Watching until the next round |
1981
+
1982
+ `room.standing` ties it to the link states of section 22, so a game (and a test) asks one thing:
1983
+
1984
+ | `standing.state` | `why` | `net.link` | Can the player play? |
1985
+ |---|---|---|---|
1986
+ | `joining` | `connecting`, `no-body`, `full`, `reconnecting` | `connecting`, or `online` inside `seatWaitMs` | not yet; a line after 0.7 s ("Joining the round…") |
1987
+ | `playing` | `seated`, `reconnecting` | `online` (or `reconnecting`, bounded) | yes, in the room |
1988
+ | `solo` | `alone`, `offline` (the link); `no-body`, `full` (the fallback) | `alone`, `offline`, or `online` | yes, on their own, with bots |
1989
+ | `watching` | `screen` (a watcher or a big screen, by choice); `no-body`, `full` (`spectate`) | `online` | no; the line says until when |
1990
+ | `waiting` | `no-body`, `full` (`wait`) | `online` | no; the line says so |
1991
+ | `closed` | `net.closedWhy` | `closed` | no; the line says why and the play page offers another room |
1992
+
1993
+ - **Solo is the game's own host code, run privately.** `onRoundStart` runs for the private round
1994
+ (with the room's round number, a clock of its own) and the game's rules move its bots, exactly
1995
+ as offline. A score made there stays there: the room's body starts as the host made it. Events
1996
+ from the real room still arrive on `room.on('event')` while solo; a game that plays sounds for
1997
+ them checks `room.solo`.
1998
+ - **The wait counts only time the page could listen**, like the wait for the welcome: a page
1999
+ blocked for six seconds by its own boot runs its timers before the snapshots queued behind
2000
+ them, and a body that is already on its way is not "missing".
2001
+ - **The default changes nothing a working game did.** A page that gets its body with its first
2002
+ snapshots never sees any of this; `'wait'` only adds the line and the host's second try.
2003
+ - **On raw `createNetplay`** there is no option: do what the starters do. Host: try the claim
2004
+ again on `leave` and at a round's rollover (`seatWaiting()`). Everyone: test
2005
+ `net.link === 'reconnecting'` for your own "Reconnecting…", read `net.full`, and say where the
2006
+ player stands with `net.line('…')`.