@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/CHANGELOG.md +33 -1
- package/README.md +10 -6
- package/bin/homie-studio.mjs +71 -6
- package/lib/cloudflare.mjs +80 -6
- package/lib/directory.mjs +61 -9
- package/lib/doctor.mjs +10 -9
- package/lib/mcp-tools.mjs +13 -9
- package/lib/parts.mjs +27 -0
- package/lib/routes.mjs +110 -2
- package/lib/scaffold.mjs +3 -3
- package/lib/template-history.json +40 -0
- package/netplay/NETPLAY.md +138 -10
- package/netplay/netplay.ts +220 -29
- package/package.json +1 -1
- package/parts/PARTS.md +8 -2
- package/port/index.ts +1 -1
- package/port/room.ts +228 -23
- package/starters/ember-vale/src/main.ts +4 -0
- package/starters/gem-rush/src/main.ts +29 -7
- package/starters/gem-rush-3d/src/main.ts +36 -7
- package/starters/hero-rush-3d/src/main.ts +36 -7
- package/worker/pages.mjs +19 -10
- package/worker/parts.mjs +43 -2
- package/worker/version.mjs +1 -1
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.
|
|
20
|
-
*
|
|
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\` (\`
|
|
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
|
|
493
|
-
|
|
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
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -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,
|
|
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.**
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
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"
|
|
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('
|
|
1685
|
-
net.prefs.
|
|
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('…')`.
|