@homie-rocks/studio 0.30.2 → 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.
Files changed (90) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/README.md +349 -23
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +204 -124
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +203 -10
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/deploy-state.mjs +166 -0
  23. package/lib/dev.mjs +430 -0
  24. package/lib/directory.mjs +172 -9
  25. package/lib/doctor.mjs +12 -11
  26. package/lib/handoff.mjs +2 -6
  27. package/lib/library.mjs +1 -1
  28. package/lib/mcp-tools.mjs +42 -37
  29. package/lib/mcp.mjs +1 -1
  30. package/lib/net.mjs +81 -2
  31. package/lib/node-version.mjs +40 -0
  32. package/lib/office.mjs +6 -6
  33. package/lib/optimise.mjs +39 -4
  34. package/lib/parts-build.mjs +169 -0
  35. package/lib/parts-cli.mjs +104 -0
  36. package/lib/parts-store.mjs +342 -0
  37. package/lib/parts-tools.mjs +92 -0
  38. package/lib/parts.mjs +720 -0
  39. package/lib/perf.mjs +131 -31
  40. package/lib/port-check.mjs +40 -6
  41. package/lib/preview.mjs +77 -0
  42. package/lib/routes.mjs +240 -0
  43. package/lib/scaffold.mjs +83 -22
  44. package/lib/shoot.mjs +307 -0
  45. package/lib/site.mjs +1 -2
  46. package/lib/stage.mjs +118 -0
  47. package/lib/studio.mjs +0 -66
  48. package/lib/style-board.mjs +49 -6
  49. package/lib/template-history.json +101 -0
  50. package/lib/trailer.mjs +47 -0
  51. package/lib/typecheck.mjs +107 -0
  52. package/mcp/ui/lineup.js +3 -3
  53. package/mcp/ui/rights.js +1 -3
  54. package/netplay/NETPLAY.md +552 -16
  55. package/netplay/netplay.ts +950 -43
  56. package/package.json +10 -1
  57. package/parts/PARTS.md +169 -0
  58. package/port/index.ts +8 -2
  59. package/port/personas.ts +275 -0
  60. package/port/powerups.ts +357 -0
  61. package/port/probe.ts +38 -2
  62. package/port/room.ts +329 -40
  63. package/site/SITE.md +102 -20
  64. package/starters/ember-vale/index.html +1 -1
  65. package/starters/ember-vale/src/main.ts +7 -1
  66. package/starters/gem-rush/index.html +1 -1
  67. package/starters/gem-rush/src/main.ts +41 -12
  68. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  69. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  70. package/starters/gem-rush-3d/index.html +1 -1
  71. package/starters/gem-rush-3d/src/main.ts +50 -11
  72. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  73. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  74. package/starters/hero-rush-3d/index.html +1 -1
  75. package/starters/hero-rush-3d/src/main.ts +50 -11
  76. package/worker/arrival.mjs +4 -1
  77. package/worker/discover.mjs +11 -25
  78. package/worker/index.mjs +142 -62
  79. package/worker/license.mjs +45 -41
  80. package/worker/office-page.mjs +0 -4
  81. package/worker/office-schema.mjs +6 -0
  82. package/worker/office.mjs +19 -23
  83. package/worker/pages.mjs +156 -29
  84. package/worker/parts.mjs +275 -0
  85. package/worker/room.mjs +157 -14
  86. package/worker/schema.mjs +6 -8
  87. package/worker/seats.mjs +71 -0
  88. package/worker/site.mjs +51 -53
  89. package/worker/version.mjs +1 -1
  90. package/lib/remix-assets.mjs +0 -84
@@ -0,0 +1,275 @@
1
+ /**
2
+ * THE STUDIO'S SHARED GAME PARTS, AS ITS SITE SERVES THEM (parts/PARTS.md section 4).
3
+ *
4
+ * /.well-known/homie-parts.json what the hub and `parts add` read: the shared parts, each with the game it came from
5
+ * /parts/<id>/<version>/<file> a packed version's files, exactly as hashed: immutable, CORS open for GET
6
+ * /parts/ and /parts/<id>/ the studio's own pages, in its own look: the list, and one part with its preview
7
+ * a game's landing a "Parts from this game" band of its own (partsBand)
8
+ *
9
+ * A PRIVATE PART IS NEVER REACHABLE. Two things hold that: the build copies only shared parts into the site
10
+ * (lib/parts-build.mjs), and nothing here answers a file the built index does not name, so a file that reached the
11
+ * assets some other way is still not served from /parts/.
12
+ */
13
+ import { esc, icon, layout, notFoundPage } from './site.mjs';
14
+
15
+ const PART_ID = /^[a-z0-9][a-z0-9-]{0,47}$/;
16
+ const VERSION = /^\d{1,6}\.\d{1,6}\.\d{1,6}(?:-[0-9A-Za-z.-]{1,40})?$/;
17
+ /** A packed part's files: framed by the part's own page and by the hub, so they keep their own frame rule (index.mjs finish). */
18
+ export const PART_FILES = /^\/parts\/[a-z0-9][a-z0-9-]{0,47}\/\d{1,6}\.\d{1,6}\.\d{1,6}[^/]*\//;
19
+ export const PARTS_WELL_KNOWN = '/.well-known/homie-parts.json';
20
+ export const isPartsPath = (path) => path === PARTS_WELL_KNOWN || path === '/parts' || path.startsWith('/parts/');
21
+
22
+ const CORS = { 'access-control-allow-origin': '*', 'x-content-type-options': 'nosniff' };
23
+ const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json; charset=utf-8', ...headers } });
24
+ const TYPES = { html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8', js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8', ts: 'text/plain; charset=utf-8', tsx: 'text/plain; charset=utf-8', json: 'application/json; charset=utf-8', css: 'text/css; charset=utf-8', md: 'text/plain; charset=utf-8', txt: 'text/plain; charset=utf-8', glsl: 'text/plain; charset=utf-8', wgsl: 'text/plain; charset=utf-8', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', glb: 'model/gltf-binary', gltf: 'model/gltf+json', bin: 'application/octet-stream', mp3: 'audio/mpeg', ogg: 'audio/ogg', wav: 'audio/wav', m4a: 'audio/mp4', woff2: 'font/woff2', ktx2: 'image/ktx2', hdr: 'application/octet-stream' };
25
+
26
+ /** The built index (site/dist/parts/index.json): only shared parts are ever in it. An older build has none. */
27
+ export async function partsIndex(env, origin) {
28
+ try {
29
+ const res = await env.ASSETS.fetch(new Request(`${origin}/parts/index.json`));
30
+ if (!res.ok) return { v: 1, studio: {}, parts: [] };
31
+ const body = await res.json();
32
+ return body && Array.isArray(body.parts) ? body : { v: 1, studio: {}, parts: [] };
33
+ } catch { return { v: 1, studio: {}, parts: [] }; }
34
+ }
35
+
36
+ /**
37
+ * One index entry as the world sees it: how to add it, and the game it came out of with its page and Play address.
38
+ * Only a PUBLIC game is named (a private or invite-only game's parts are still shared, without saying where from).
39
+ */
40
+ function entryOf(p, cat, url) {
41
+ const g = p.from?.game ? (cat.games ?? []).find((x) => x.id === p.from.game) : null;
42
+ const { from, ...rest } = p;
43
+ return {
44
+ ...rest,
45
+ ...(g ? { from: { game: g.id, name: g.name ?? g.id, studio: cat.studio?.name ?? from.studio ?? null, page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play` } } : {}),
46
+ add: `${url.host}/${p.id}`,
47
+ };
48
+ }
49
+
50
+ export async function partsRoutes(request, env, url, { catalogueOf }) {
51
+ const path = url.pathname;
52
+ const read = request.method === 'GET' || request.method === 'HEAD';
53
+ if (path === PARTS_WELL_KNOWN) {
54
+ if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: { ...CORS, 'access-control-allow-methods': 'GET, HEAD' } });
55
+ if (!read) return json({ ok: false, error: 'method' }, 405, { allow: 'GET, HEAD', ...CORS });
56
+ const [index, cat] = await Promise.all([partsIndex(env, url.origin), catalogueOf()]);
57
+ return json({ v: 1, studio: { name: cat.studio?.name ?? index.studio?.name ?? null, url: url.origin }, parts: index.parts.map((p) => entryOf(p, cat, url)) }, 200, { ...CORS, 'cache-control': 'public, max-age=300' });
58
+ }
59
+ const segs = path.split('/').filter(Boolean);
60
+ // /parts/<id>/<version>/<file…>: a packed file, only when the index names that part and that version.
61
+ if (segs.length >= 4 && PART_ID.test(segs[1]) && VERSION.test(segs[2])) {
62
+ if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: { ...CORS, 'access-control-allow-methods': 'GET, HEAD' } });
63
+ if (!read) return new Response('GET only', { status: 405, headers: { allow: 'GET, HEAD', ...CORS } });
64
+ const index = await partsIndex(env, url.origin);
65
+ const part = index.parts.find((p) => p.id === segs[1]);
66
+ let file;
67
+ try { file = segs.slice(3).map(decodeURIComponent).join('/'); } catch { file = '..'; }
68
+ const gone = () => new Response('not found', { status: 404, headers: { ...CORS, 'cache-control': 'no-store' } });
69
+ if (!part || !(part.versions ?? []).includes(segs[2]) || file.split('/').some((s) => !s || s === '..' || s.startsWith('.'))) return gone();
70
+ const res = await env.ASSETS.fetch(new Request(`${url.origin}/parts/${segs[1]}/${segs[2]}/${file.split('/').map(encodeURIComponent).join('/')}`));
71
+ if (!res.ok) return gone();
72
+ const ext = file.split('.').pop().toLowerCase();
73
+ const html = ext === 'html' || ext === 'htm';
74
+ return new Response(request.method === 'HEAD' ? null : res.body, {
75
+ headers: {
76
+ 'content-type': TYPES[ext] ?? 'application/octet-stream',
77
+ // A version's bytes never change (lib/parts.mjs packPart refuses), so a year and immutable is true.
78
+ 'cache-control': `public, max-age=31536000, immutable${html ? ', no-transform' : ''}`,
79
+ ...CORS,
80
+ // A preview page runs with no origin of its own: none of this site's cookies or storage, framable anywhere.
81
+ ...(html ? { 'content-security-policy': 'sandbox allow-scripts allow-pointer-lock' } : {}),
82
+ },
83
+ });
84
+ }
85
+ if (!read) return null;
86
+ const cat = await catalogueOf();
87
+ // A game called "parts" keeps its own address; its studio's parts are still in the well-known file.
88
+ if ((cat.games ?? []).some((g) => g.id === 'parts')) return null;
89
+ if (path === '/parts') return Response.redirect(`${url.origin}/parts/`, 301);
90
+ const index = await partsIndex(env, url.origin);
91
+ const parts = index.parts.map((p) => entryOf(p, cat, url));
92
+ if (path === '/parts/') return parts.length ? partsPage(cat, parts, url) : notFoundPage('This studio shares no game parts yet.', cat);
93
+ if (segs.length === 2 && PART_ID.test(segs[1])) {
94
+ const part = parts.find((p) => p.id === segs[1]);
95
+ // The same answer for a private part and one that never existed: nothing says which.
96
+ if (!part) return notFoundPage('There is no shared part at this address.', cat);
97
+ if (!path.endsWith('/')) return Response.redirect(`${url.origin}/parts/${part.id}/`, 301);
98
+ return partPage(cat, part, url);
99
+ }
100
+ return notFoundPage('There is no shared part at this address.', cat);
101
+ }
102
+
103
+ /* ------------------------------------------------------------------ the pages */
104
+
105
+ const KIND_WORDS = { character: 'Character', rig: 'Rig', clips: 'Animation clips', environment: 'Environment', effect: 'Effect', sound: 'Sound', ui: 'UI', mechanic: 'Mechanic', shader: 'Shader', 'level-generator': 'Level generator', 'bot-brain': 'Bot brain', 'audio-pack': 'Audio pack', 'set-piece': 'Set piece' };
106
+ const kindOf = (p) => KIND_WORDS[p.kind] ?? 'Part';
107
+ const CSS = `<style>
108
+ .pgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:18px;margin:8px 0 0;padding:0;list-style:none}
109
+ .pcard{display:flex;flex-direction:column;gap:8px;padding:18px;border:1px solid color-mix(in srgb,var(--fg) 14%,transparent);border-radius:16px;text-decoration:none;color:inherit;background:color-mix(in srgb,var(--fg) 3%,transparent)}
110
+ .pcard:hover{border-color:var(--fg)}
111
+ .pcard img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px}
112
+ .pcard h3{margin:0;font:800 19px/1.2 var(--display)}
113
+ .pcard p{margin:0;color:var(--dim);font-size:14px}
114
+ .ptag{font:600 11px/1.4 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--dim)}
115
+ .pframe{display:block;width:100%;aspect-ratio:16/9;border:1px solid color-mix(in srgb,var(--fg) 14%,transparent);border-radius:16px;background:#000}
116
+ .pview{container-type:inline-size}
117
+ .pview .pframe{aspect-ratio:var(--pv,16/9)}
118
+ @container (max-width:599.98px){.pview .pframe{min-height:min(320px,70vh);min-height:min(320px,70svh)}.pview.pview-own .pframe{aspect-ratio:var(--pvn,var(--pv,16/9));min-height:0}}
119
+ .popen{display:inline-flex;align-items:center;gap:8px;min-height:44px;margin:8px 0 0;color:inherit;font-weight:600;text-decoration:underline;text-underline-offset:3px}
120
+ .popen svg{width:16px;height:16px}
121
+ .popen:focus-visible{outline:3px solid var(--fg);outline-offset:3px;border-radius:6px}
122
+ .pfacts{display:grid;grid-template-columns:max-content 1fr;gap:8px 20px;margin:0}
123
+ .pfacts dt{color:var(--dim);font-size:14px}.pfacts dd{margin:0;overflow-wrap:anywhere}
124
+ .pband{max-width:calc(1180px + 2 * var(--gutter));margin:0 auto;padding:clamp(40px,6vw,80px) var(--gutter)}
125
+ .pband h2{margin:6px 0 10px;font:800 clamp(28px,4vw,44px)/1.05 var(--display)}
126
+ .pband .plead,.plead{max-width:62ch;color:var(--dim);margin:0 0 18px}
127
+ .psay{display:flex;align-items:center;justify-content:space-between;gap:14px;margin:18px 0;padding:14px 16px;border:1px solid color-mix(in srgb,var(--fg) 14%,transparent);border-radius:14px}
128
+ .psay code{display:block;margin-top:4px;font:500 15px/1.4 var(--mono);overflow-wrap:anywhere}
129
+ .pcopy{display:inline-flex;align-items:center;gap:8px;min-height:40px;padding:0 14px;border:1px solid color-mix(in srgb,var(--fg) 24%,transparent);border-radius:10px;background:none;color:inherit;font:600 14px/1 var(--body,inherit);cursor:pointer}
130
+ .pcopy svg{width:16px;height:16px}
131
+ </style>`;
132
+ const head = (kicker, title, lead, extra = '') => `<header class="head"><p class="kicker">${esc(kicker)}</p><h1>${esc(title)}</h1>${lead ? `<p class="lead">${esc(lead)}</p>` : ''}${extra}</header>`;
133
+ const studioOf = (cat) => cat.studio?.name ?? 'Studio';
134
+ const fromLine = (p) => (p.from?.game ? `From <a href="/${esc(p.from.game)}/">${esc(p.from.name ?? p.from.game)}</a>` : '');
135
+ /** What a person says to their AI to get the part, in plain words with its name and reference: no command to type. */
136
+ const sayAdd = (p) => `Add the "${p.name}" part from ${p.add} to my game`;
137
+ const sayRow = (label, say) => `<div class="psay"><div><span class="ptag">${esc(label)}</span><code>${esc(say)}</code></div><button class="pcopy" type="button" data-copy="${esc(say)}">${icon('copy')}<span data-copy-word>Copy</span></button></div>`;
138
+
139
+ function card(p) {
140
+ const img = p.preview?.image ? `<img src="${esc(`${p.url}${p.preview.image}`)}" alt="" loading="lazy">` : '';
141
+ return `<li><a class="pcard" href="/parts/${esc(p.id)}/">${img}<span class="ptag">${esc(kindOf(p))} · ${esc(p.license ?? '')}</span><h3>${esc(p.name)}</h3><p>${esc(p.summary ?? '')}</p>${p.from?.game ? `<p>From ${esc(p.from.name ?? p.from.game)}</p>` : ''}</a></li>`;
142
+ }
143
+
144
+ export function partsPage(cat, parts, url) {
145
+ const name = studioOf(cat);
146
+ // Grouped by the game each came out of: a mashup starts from "the creature in that game".
147
+ const groups = new Map();
148
+ for (const p of parts) { const k = p.from?.game ?? ''; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(p); }
149
+ const body = [...groups].sort(([a], [b]) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b))).map(([k, list]) => `<p class="sec"${k ? ` id="from-${esc(k)}"` : ''}>${k ? `From ${esc(list[0].from.name ?? k)}` : 'More parts'}</p><ul class="pgrid">${list.map(card).join('')}</ul>`).join('');
150
+ return layout(cat, {
151
+ title: `Game parts · ${name}`, description: `Pieces of ${name}'s games that other studios can use in their own: creatures, levels, mechanics, sounds. Each with its licence.`, origin: url.origin, path: '/parts/', page: 'parts', head: CSS,
152
+ main: `${head('Game parts', 'Take a piece, make a game', `Pieces of ${name}'s games, shared one by one. Take the creature from one game, the level from another and the bot brain from a third, and make something new. Each part says its licence and who to credit.`)}
153
+ <div class="wrap">${body}</div>`,
154
+ });
155
+ }
156
+
157
+ function requiresRows(p) {
158
+ const r = p.requires ?? {};
159
+ const rows = [];
160
+ for (const [k, v] of Object.entries(r.packages && typeof r.packages === 'object' ? r.packages : {})) rows.push(`${esc(k)} ${esc(v)} (a package, from npm)`);
161
+ for (const x of Array.isArray(r.parts) ? r.parts : []) rows.push(`${esc(x)} (another part)`);
162
+ return rows;
163
+ }
164
+ function costLine(c) {
165
+ if (!c || typeof c !== 'object') return '';
166
+ const bits = [];
167
+ if (Number.isFinite(c.bytes)) bits.push(`${c.bytes >= 1048576 ? `${(c.bytes / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(c.bytes / 1024))} KB`} to download`);
168
+ if (Number.isFinite(c.triangles)) bits.push(`${c.triangles.toLocaleString('en')} triangles`);
169
+ if (Number.isFinite(c.drawCalls)) bits.push(`${c.drawCalls} draw calls`);
170
+ if (Number.isFinite(c.textureMB)) bits.push(`${c.textureMB} MB of textures`);
171
+ return `${bits.join(' · ')}${c.measuredOn ? ` (measured on ${String(c.measuredOn).slice(0, 80)})` : ''}`;
172
+ }
173
+
174
+ /**
175
+ * A preview's declared shape ("4:3") as two whole numbers, or null. The same reading as previewAspect() in
176
+ * lib/parts.mjs (a Worker cannot import that file: it reads the disk); test/parts.test.mjs holds the two together.
177
+ * The page's CSS is built from these NUMBERS, never from the text in part.json, so nothing a part declares can
178
+ * write CSS or HTML of its own.
179
+ */
180
+ export function aspectOf(value) {
181
+ const m = typeof value === 'string' ? /^([1-9]\d?):([1-9]\d?)$/.exec(value) : null;
182
+ if (!m) return null;
183
+ const w = Number(m[1]); const h = Number(m[2]);
184
+ return w > 32 || h > 32 || w > 3 * h || h > 2 * w ? null : { w, h };
185
+ }
186
+
187
+ /**
188
+ * The part's interactive preview: its frame in the shape the part declares, and a plain link out of the frame.
189
+ *
190
+ * THE SHAPE. `preview.aspect` (default 16:9), and `preview.phoneAspect` when the frame is under 600 px wide. With no
191
+ * phone shape declared a narrow frame still gets a minimum height: 16:9 across a 390 px phone is about 197 px,
192
+ * which hides whatever a preview draws under its heading and controls.
193
+ *
194
+ * THE LINK opens the same preview file in a tab of its own, at the size of the screen. That file is this studio's
195
+ * own packed part on this studio's own address, and it is answered with `content-security-policy: sandbox
196
+ * allow-scripts allow-pointer-lock` (above), so a tab of its own gives it nothing the frame did not: no origin, so
197
+ * none of the site's cookies or storage, no forms, no pop-ups, no navigating the page that opened it (and
198
+ * rel="noopener" gives it no handle on that page either). It is a link, so it needs no script and a keyboard reaches it.
199
+ */
200
+ function previewOf(p) {
201
+ const src = esc(`${p.url}${p.preview.page}`);
202
+ const shape = aspectOf(p.preview.aspect) ?? { w: 16, h: 9 };
203
+ const phone = aspectOf(p.preview.phoneAspect);
204
+ const vars = `--pv:${Number(shape.w)}/${Number(shape.h)}${phone ? `;--pvn:${Number(phone.w)}/${Number(phone.h)}` : ''}`;
205
+ return `<div class="pview${phone ? ' pview-own' : ''}" style="${vars}"><iframe class="pframe" title="${esc(p.name)}: try it" src="${src}" sandbox="allow-scripts allow-pointer-lock" loading="lazy" referrerpolicy="no-referrer"></iframe></div>
206
+ <a class="popen" href="${src}" target="_blank" rel="noopener noreferrer">${icon('arrow')}<span>Open the preview in its own tab</span></a>`;
207
+ }
208
+
209
+ export function partPage(cat, p, url) {
210
+ const name = studioOf(cat);
211
+ const preview = p.preview?.page
212
+ ? previewOf(p)
213
+ : p.preview?.image ? `<img class="pframe" src="${esc(`${p.url}${p.preview.image}`)}" alt="${esc(p.name)}">` : '';
214
+ const facts = [
215
+ ['Kind', esc(kindOf(p))],
216
+ p.from?.game ? ['From the game', `<a href="/${esc(p.from.game)}/">${esc(p.from.name ?? p.from.game)}</a> · <a href="/${esc(p.from.game)}/play">Play it</a>`] : null,
217
+ ['Licence', `${esc(p.license ?? 'none named')}${p.license && !/^LicenseRef-/.test(p.license) ? ` · <a href="https://spdx.org/licenses/${esc(p.license)}.html" rel="noopener">what it allows</a>` : ''}`],
218
+ p.attribution ? ['Credit', esc(p.attribution)] : null,
219
+ ['Version', `${esc(p.version)}${(p.versions ?? []).length > 1 ? ` (also ${esc(p.versions.filter((v) => v !== p.version).join(', '))})` : ''}`],
220
+ requiresRows(p).length ? ['Builds on', requiresRows(p).join('<br>')] : null,
221
+ costLine(p.cost) ? ['Costs', esc(costLine(p.cost))] : null,
222
+ p.physical ? ['Size', esc([p.physical.units ? `in ${p.physical.units}` : '', Number.isFinite(p.physical.scale) ? `scale ${p.physical.scale}` : '', p.physical.pivot ? `pivot at the ${p.physical.pivot}` : '', p.physical.collision ? 'with collision data' : ''].filter(Boolean).join(', '))] : null,
223
+ p.skeleton?.rig ? ['Skeleton', esc(`${p.skeleton.rig}${(p.skeleton.clips ?? []).length ? `: ${p.skeleton.clips.join(', ')}` : ''}`)] : null,
224
+ p.contract?.netplay ? ['In a room', esc({ 'host-authoritative': 'the host decides; everyone else is shown the result', replicated: 'every player runs it from shared state', local: 'each screen runs its own' }[p.contract.netplay] ?? p.contract.netplay)] : null,
225
+ p.provenance?.source ? ['Made', esc({ original: `by ${name}`, generated: `generated on ${name}'s own account`, imported: `brought in by ${name}${p.provenance.origin ? ` from ${p.provenance.origin}` : ''}` }[p.provenance.source] ?? p.provenance.source)] : null,
226
+ (p.tags ?? []).length ? ['Tags', esc(p.tags.join(', '))] : null,
227
+ ].filter(Boolean);
228
+ return layout(cat, {
229
+ title: `${p.name} · a game part from ${name}`, description: `${p.summary ?? ''} A ${kindOf(p).toLowerCase()} from ${name}${p.from?.name ? `'s ${p.from.name}` : ''}, shared under ${p.license ?? 'its own terms'}.`.trim(), origin: url.origin, path: `/parts/${p.id}/`, page: 'part', head: CSS,
230
+ image: p.preview?.image ? `${p.url}${p.preview.image}` : null,
231
+ main: `${head(`${kindOf(p)} · game part`, p.name, p.summary ?? '', `<div class="keys"><a class="ghost" href="/parts/">${icon('arrow')}<span>All parts</span></a>${p.from?.game ? `<a class="ghost" href="/${esc(p.from.game)}/play">${icon('games')}<span>Play ${esc(p.from.name ?? p.from.game)}</span></a>` : ''}</div>`)}
232
+ <div class="wrap">
233
+ ${preview ? `<p class="sec">Try it</p>${preview}` : ''}
234
+ <p class="sec">Use it in your game</p>
235
+ <p class="plead">In a chat with your Homie studio open, ask for it by name:</p>
236
+ ${sayRow('Say', sayAdd(p))}
237
+ <p class="plead">Your AI fetches the files, checks every one, copies them into your studio and credits ${esc(name)} in your game. They are yours to tune from there.</p>
238
+ <p class="sec">About this part</p>
239
+ <dl class="pfacts">${facts.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join('')}</dl>
240
+ </div>`,
241
+ });
242
+ }
243
+
244
+ /* ------------------------------------------------------------------ a game's landing: "Parts from this game" */
245
+
246
+ /**
247
+ * A band of its own on a game's landing: the SHARED parts that came out of this game, and what to ask an AI for to
248
+ * build something new from them. Empty when the game shared none. It stands by itself: its own markup and styles.
249
+ */
250
+ export function partsBand(g, parts, host) {
251
+ const mine = parts.filter((p) => p.from?.game === g.id);
252
+ if (!mine.length) return '';
253
+ const say = mine.length === 1 ? sayAdd({ ...mine[0], add: `${host}/${mine[0].id}` }) : `Make a new game in my Homie studio with these parts of ${g.name}: ${mine.slice(0, 4).map((p) => `${host}/${p.id}`).join(', ')}`;
254
+ return `${CSS}<section class="pband" aria-labelledby="parts-title" data-parts-band>
255
+ <p class="ptag">Take a piece</p>
256
+ <h2 id="parts-title">Parts from this game</h2>
257
+ <p class="plead">Pieces of ${esc(g.name)} you can put in a game of your own, each with its licence. Mix them with parts from other games: a creature from here, a level from there.</p>
258
+ <ul class="pgrid">${mine.map(card).join('')}</ul>
259
+ ${sayRow('In a chat with your Homie studio open, say', say)}
260
+ <div class="keys"><a class="btn" href="/parts/#from-${esc(g.id)}">${icon('spark')}<span>See the parts</span></a></div>
261
+ </section>`;
262
+ }
263
+
264
+ /** A game's landing with its parts band, when it has shared parts (worker/index.mjs calls this on the landing it built). */
265
+ export async function withPartsBand(res, env, url, g) {
266
+ try {
267
+ if (!res || res.status !== 200 || !/text\/html/i.test(res.headers.get('content-type') ?? '')) return res;
268
+ const index = await partsIndex(env, url.origin);
269
+ const band = partsBand(g, index.parts.map((p) => ({ ...p, from: p.from?.game === g.id ? { ...p.from, name: g.name } : p.from })), url.host);
270
+ if (!band) return res;
271
+ const html = await res.text();
272
+ const at = html.lastIndexOf('</main>');
273
+ return new Response(at < 0 ? html : `${html.slice(0, at)}${band}\n${html.slice(at)}`, { status: res.status, headers: res.headers });
274
+ } catch { return res; }
275
+ }
package/worker/room.mjs CHANGED
@@ -49,7 +49,49 @@ import { CHAT_LIMITS, CHAT_RATES, HELD_WORDS, allows, cleanText, floor, normaliz
49
49
 
50
50
  export const NET_VERSION = 1;
51
51
  /** The contract revision this relay speaks (NETPLAY.md): optional fields, frames and refusals; the wire stays `v: 1`. */
52
- export const NET_REVISION = 8;
52
+ export const NET_REVISION = 9;
53
+ /**
54
+ * Revision 9 (NETPLAY.md sections 22 and 23). STALL: how long a host may send no snapshot, while others are present,
55
+ * before the room is handed on: 1.5 s unless the game names its own (game.json `netplay.stallMs`), never under
56
+ * 1.5 s (a shorter one swaps hosts on a single slow frame) and never over 10 s (a frozen host must still be replaced
57
+ * while its players are watching a still picture).
58
+ */
59
+ export const STALL = Object.freeze({ ms: 1500, min: 1500, max: 10_000 });
60
+ /** A game's own stall time, held to STALL's bounds; null when it names none (or not a number). */
61
+ export function stallOf(v) {
62
+ const n = Math.floor(Number(v));
63
+ return Number.isFinite(n) && n > 0 ? Math.max(STALL.min, Math.min(STALL.max, n)) : null;
64
+ }
65
+ const VERSION_TEXT = /^[A-Za-z0-9._-]{1,32}$/;
66
+ /** A game revision (game.json `netplay.version`): 1 to 32 of A-Z a-z 0-9 . _ -, a number as its digits; else null. */
67
+ export function versionOf(v) {
68
+ const t = typeof v === 'number' && Number.isFinite(v) ? String(v) : typeof v === 'string' ? v.trim() : '';
69
+ return VERSION_TEXT.test(t) ? t : null;
70
+ }
71
+ const FEATURE_TEXT = /^[a-z0-9][a-z0-9-]{0,23}$/;
72
+ /** What a build says it can do (`hello.feat`): up to 8 short lowercase words. */
73
+ export function featuresOf(list) {
74
+ if (!Array.isArray(list)) return [];
75
+ const out = [];
76
+ for (const x of list) { const k = String(x ?? '').toLowerCase(); if (FEATURE_TEXT.test(k) && !out.includes(k)) out.push(k); if (out.length >= 8) break; }
77
+ return out;
78
+ }
79
+ /**
80
+ * A SOCKET THAT WENT AWAY IS NOT A FAILED ROOM. A browser that closes its tab, loses its network or is replaced by a
81
+ * new host ends its socket without a goodbye, and the runtime reports that as an error ("Network connection lost",
82
+ * "WebSocket peer disconnected"…). Those are departures: the room has already handled them (the seat is held, the
83
+ * host is replaced). Anything else thrown while the room was doing something IS a failure, and is logged with the
84
+ * room and what it was doing. `departure(error)` says which; the transport (the Table) asks it at its boundary.
85
+ */
86
+ const DEPARTURE = /network connection lost|connection (?:was )?(?:closed|reset|aborted|lost)|peer disconnected|websocket (?:is )?(?:closed|closing|not open|already closed)|socket (?:is )?closed|client disconnected|disconnected|broken pipe|econnreset|epipe|aborted|cancell?ed|the script will never generate a response|durable object reset/i;
87
+ export function departure(error) {
88
+ const text = typeof error === 'string' ? error : `${error?.name ?? ''} ${error?.message ?? ''} ${error?.code ?? ''}`;
89
+ return DEPARTURE.test(text);
90
+ }
91
+ /** An error as a log line carries it: its name and message, cut short. Never the frame it was thrown for. */
92
+ export function errorLine(error) {
93
+ return String(error?.stack && typeof error.stack === 'string' ? error.stack.split('\n').slice(0, 4).join(' | ') : error?.message ?? error).slice(0, 400);
94
+ }
53
95
  const POLICY_KINDS = ['open', 'humans-only', 'hybrid', 'beginner'];
54
96
  /**
55
97
  * A room's policy before the Worker says anything (and the public server's with nothing set): today's behaviour.
@@ -220,7 +262,8 @@ export class NetRoom {
220
262
  * @param {number} [o.persistMs] the checkpoint/round/state are saved at most this often
221
263
  * @param {{save: (o: object) => void, clear?: () => void} | null} [o.store]
222
264
  * @param {() => number} [o.now]
223
- * @param {(line: object) => void} [o.log]
265
+ * @param {(line: object) => void} [o.log] one line per lifecycle event (hello, leave, elect, a failed operation),
266
+ * each stamped `at` (ISO time) with its room; never a token, a ticket, a browser key or a frame's contents
224
267
  */
225
268
  constructor({
226
269
  code, maxPlayers = 8, maxScreens = 16, perIp = 12, stallMs = 1500, silentMs = 2500, idleMs = 10_000,
@@ -257,7 +300,16 @@ export class NetRoom {
257
300
  this.persistMs = persistMs;
258
301
  this.store = store;
259
302
  this.now = now;
260
- this.log = log;
303
+ // Every line says when: a departure and a failure seconds apart are told apart by their times.
304
+ const stamp = () => { try { return new Date(now()).toISOString(); } catch { return new Date().toISOString(); } };
305
+ this.log = (line) => { try { log({ at: stamp(), ...line }); } catch { /* a log that throws never takes the room with it */ } };
306
+ /**
307
+ * Revision 9, section 23: the build this room runs (undefined: nobody has come yet), the build that is live now
308
+ * (the Worker's word with every socket; undefined: nobody says), and the count of stays in a seat (`peer.occ`).
309
+ */
310
+ this.gameVer = undefined;
311
+ this.currentVer = undefined;
312
+ this.occSeq = 0;
261
313
  /** id → client (including sockets that have not said hello yet) */
262
314
  this.clients = new Map();
263
315
  /** seat → { token, name, since, present } */
@@ -346,15 +398,35 @@ export class NetRoom {
346
398
  badge: typeof conn.badge === 'string' && conn.badge.length <= 16 ? conn.badge : null,
347
399
  // Revision 7: the latest view the host showed this AI (what its arguments are checked against), and its pace.
348
400
  lastView: null, viewAt: 0, doAt: 0,
401
+ // Revision 9: the game revision this socket's page was served with. The Worker's word (it is in the socket's
402
+ // address, so a helper from before revisions carries it too) when the transport gives one, else the hello's.
403
+ verWord: 'ver' in conn ? versionOf(conn.ver) : undefined, ver: null, feat: [], staleTold: false,
349
404
  };
350
405
  this.clients.set(c.id, c);
351
406
  return {
352
407
  id: c.id,
353
- onMessage: (text) => this.onMessage(c, text),
354
- onClose: () => this.onClose(c, 'closed'),
408
+ onMessage: (text) => this.guard(c, 'message', () => this.onMessage(c, text), text),
409
+ /** `via`: how the transport learned it (`close`, or `error`: the socket failed, which is how a lost network arrives). */
410
+ onClose: (via = 'close') => this.guard(c, 'close', () => this.onClose(c, 'closed', via)),
355
411
  };
356
412
  }
357
413
 
414
+ /**
415
+ * The boundary every socket callback crosses. A room operation that throws is logged with what the room was doing
416
+ * (the frame's type, never its contents: a hello carries a seat token) and never reaches the transport, where the
417
+ * runtime would print it as an uncaught error with no room and no time on it. The socket's client stays as it is:
418
+ * one bad frame does not end a seat.
419
+ */
420
+ guard(c, op, fn, text = null) {
421
+ try { return fn(); } catch (error) {
422
+ let frame = null;
423
+ if (typeof text === 'string') { const m = /^\s*\{\s*"t"\s*:\s*"([a-z-]{1,16})"/.exec(text.slice(0, 64)); frame = m ? m[1] : null; }
424
+ this.stats.failed = (this.stats.failed ?? 0) + 1;
425
+ this.log({ ev: 'failed', room: this.code, op, ...(frame ? { frame } : {}), id: c?.id ?? null, seat: c?.seat ?? null, role: c && c.helloed ? this.roleOf(c) : null, clients: this.clients.size, error: errorLine(error) });
426
+ return undefined;
427
+ }
428
+ }
429
+
358
430
  /**
359
431
  * The shell's watch socket: the room's facts, and (revision 6) the party's vote from the play page's card. A vote
360
432
  * from it counts as the seat of the client in this room with the same browser key (`conn.browser`).
@@ -403,7 +475,11 @@ export class NetRoom {
403
475
  host() { return this.hostId ? this.clients.get(this.hostId) ?? null : null; }
404
476
  hostRef() { const h = this.host(); return h ? { id: h.id, seat: h.seat } : null; }
405
477
  roleOf(c) { return c.id === this.hostId ? 'host' : c.seat === null ? 'screen' : 'replica'; }
406
- peer(c) { return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c), ...(c.watch ? { watch: true } : {}), ...(c.agent ? { agent: { ...c.agent } } : {}), ...(this.muteOf(c) ? { muted: true } : {}), ...(c.badge && !c.agent ? { badge: c.badge } : {}) }; }
478
+ peer(c) {
479
+ // Revision 9: which stay in the seat this is (`occ`), the peer's game revision and what its build can do.
480
+ const occ = c.seat !== null ? this.seats.get(c.seat)?.occ : undefined;
481
+ return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c), ...(c.watch ? { watch: true } : {}), ...(c.agent ? { agent: { ...c.agent } } : {}), ...(this.muteOf(c) ? { muted: true } : {}), ...(c.badge && !c.agent ? { badge: c.badge } : {}), ...(Number.isInteger(occ) ? { occ } : {}), ...(c.ver ? { ver: c.ver } : {}), ...(c.feat.length ? { feat: [...c.feat] } : {}) };
482
+ }
407
483
  /** A hands-`host` agent (section 17): no game client of its own, so no snapshots, checkpoints or state. */
408
484
  lite(c) { return Boolean(c.agent && c.agent.hands === 'host'); }
409
485
  seatedHumans() { return this.live().filter((c) => c.seat !== null && !c.agent); }
@@ -513,7 +589,10 @@ export class NetRoom {
513
589
  this.stats.snaps += 1;
514
590
  this.snapTimes.push(now);
515
591
  while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
516
- const out = JSON.stringify({ t: 'snap', from: c.seat, ...snap });
592
+ // A heartbeat (revision 9): the host's last state again while its frames hitch. It counts as a snapshot here
593
+ // (the host is alive), and replicas are told which it is.
594
+ const out = JSON.stringify({ t: 'snap', from: c.seat, ...snap, ...(m.hb === 1 ? { hb: 1 } : {}) });
595
+ if (m.hb === 1) this.stats.heartbeats = (this.stats.heartbeats ?? 0) + 1;
517
596
  this.lastSnapText = out;
518
597
  for (const o of this.others(c)) {
519
598
  if (this.lite(o)) continue; // an agent with no game client draws nothing
@@ -720,6 +799,33 @@ export class NetRoom {
720
799
  c.agent = { pass: String(a.pass ?? '').slice(0, 16), role: ['party', 'guide', 'player'].includes(a.role) ? a.role : 'party', hands: a.hands === 'host' ? 'host' : 'self', by: ['studio', 'guest', 'service'].includes(a.by) ? a.by : 'studio' };
721
800
  c.agentName = aiName(a.name ?? a.label ?? 'Agent');
722
801
  }
802
+ // REVISIONS (section 23): a room runs one build of its game at a time. A client with no game code of its own (an
803
+ // AI the host moves) has no build.
804
+ c.ver = c.verWord !== undefined ? c.verWord : versionOf(m.ver);
805
+ c.feat = featuresOf(m.feat);
806
+ const lite = Boolean(c.agentWord && c.agentWord.hands === 'host');
807
+ if (!lite) {
808
+ const here = live.filter((o) => !this.lite(o));
809
+ const cur = this.currentVer;
810
+ if (here.length && (this.gameVer ?? null) !== c.ver) {
811
+ if (cur !== undefined && c.ver !== cur) {
812
+ // The newcomer is the old one (a tab open since before a deploy): it is told to reload, for good.
813
+ this.log({ ev: 'stale', room: this.code, id: c.id, ver: c.ver, room_ver: this.gameVer ?? null, current: cur });
814
+ return refuse('stale', 'This game was updated. Reload to play the new version.', { ver: cur });
815
+ }
816
+ // The room is the old one: its players are told a new build is live (once each), and the newcomer waits for
817
+ // it (not final: its helper knocks again, and is let in once nobody on the old build is left).
818
+ for (const o of here) if (cur !== undefined && o.ver !== cur && !o.staleTold) { o.staleTold = true; this.send(o, { t: 'stale', ver: cur }); }
819
+ this.log({ ev: 'room-stale', room: this.code, id: c.id, ver: c.ver, room_ver: this.gameVer ?? null, current: cur ?? null });
820
+ return refuse('room-stale', 'This room is still running another version of the game. It opens to this one when its players reload.', { ver: this.gameVer ?? null });
821
+ }
822
+ if (!here.length) {
823
+ // Nobody on the other build is left: the room is this build's now, and starts fresh. A checkpoint, a snapshot
824
+ // and keyed state written by other code are exactly what a new build must not read.
825
+ if (this.gameVer !== undefined && this.gameVer !== c.ver) this.forgetWorld(c.ver);
826
+ this.gameVer = c.ver;
827
+ }
828
+ }
723
829
  if (this.perIp && c.ip && live.filter((o) => o.ip === c.ip).length >= this.perIp) return refuse('too-many', `at most ${this.perIp} sockets per address in one room`);
724
830
  if (live.length >= this.maxPlayers + this.maxScreens) return refuse('room-full', 'this room is full; try another');
725
831
  c.device = m.device === 'phone' || m.device === 'tv' ? m.device : 'desk';
@@ -792,9 +898,12 @@ export class NetRoom {
792
898
  this.stats.elections.push({ at: now, id: c.id, seat: c.seat, why });
793
899
  if (this.stats.elections.length > 32) this.stats.elections.shift();
794
900
  }
795
- const lite = this.lite(c);
901
+ // An older build let in to its own room (it was here first, or the room was empty): it plays, and is told.
902
+ const behind = !lite && this.currentVer !== undefined && c.ver !== this.currentVer;
903
+ if (behind) c.staleTold = true;
796
904
  this.send(c, {
797
905
  t: 'welcome', v: NET_VERSION, rev: NET_REVISION, id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
906
+ ...(this.gameVer ? { ver: this.gameVer } : {}), ...(behind ? { stale: { ver: this.currentVer } } : {}), stall: this.stallMs,
798
907
  role, why, host: this.hostRef(), peers: this.peers(), st: now, max: this.maxPlayers,
799
908
  round: this.lastRound, roster: this.lastRoster, snap: lite ? null : this.lastSnap, state: lite ? {} : this.stateObject(),
800
909
  ...(role === 'host' ? { ckpt: this.lastCkpt } : {}),
@@ -814,7 +923,7 @@ export class NetRoom {
814
923
  }
815
924
  // A roster from before an agent sat is labelled again now (its seat is an AI's).
816
925
  if (c.agent && Array.isArray(this.lastRoster)) this.lastRoster = this.labelRoster(this.lastRoster);
817
- this.log({ ev: 'hello', room: this.code, id: c.id, seat: c.seat, device: c.device, want: c.want, role, why, full, resumed: typeof m.token === 'string' && m.token === c.token, ...(c.watch ? { watch: true } : {}), ...(c.agent ? { agent: c.agent.hands } : {}) });
926
+ this.log({ ev: 'hello', room: this.code, id: c.id, seat: c.seat, device: c.device, want: c.want, role, why, full, resumed: typeof m.token === 'string' && m.token === c.token, clients: this.live().length, ...(c.watch ? { watch: true } : {}), ...(c.agent ? { agent: c.agent.hands } : {}), ...(c.ver ? { ver: c.ver } : {}) });
818
927
  this.persist(now); // a new seat (or a new host) is written now, not on the next tick: a deploy can come any moment
819
928
  this.tellWatchers();
820
929
  }
@@ -853,7 +962,8 @@ export class NetRoom {
853
962
  */
854
963
  seatFor(token, kind = 'human') {
855
964
  if (token) for (const [seat, s] of this.seats) if (s.token === token && Boolean(s.agent) === (kind === 'agent')) return seat;
856
- const fresh = () => ({ token: randomId(18), name: '', since: this.now(), present: true, agent: null });
965
+ // Every new holder of a seat is a new stay in it (`peer.occ`): a host tells "they came back" from "somebody new".
966
+ const fresh = () => ({ token: randomId(18), name: '', since: this.now(), present: true, agent: null, occ: (this.occSeq += 1) });
857
967
  const reserve = this.reserve();
858
968
  const order = [];
859
969
  if (kind === 'agent') {
@@ -1247,17 +1357,20 @@ export class NetRoom {
1247
1357
  }
1248
1358
  }
1249
1359
 
1250
- onClose(c, why) {
1360
+ onClose(c, why, via = null) {
1251
1361
  if (this.clients.get(c.id) !== c) return;
1252
1362
  this.clients.delete(c.id);
1253
1363
  if (!c.helloed) return;
1364
+ const wasHost = c.id === this.hostId;
1254
1365
  const now = this.now();
1255
1366
  if (c.seat !== null) {
1256
1367
  const s = this.seats.get(c.seat);
1257
1368
  if (s && s.token === c.token) { s.present = false; s.since = now; }
1258
1369
  }
1259
1370
  for (const o of this.others(c)) this.send(o, { t: 'leave', id: c.id, seat: c.seat, why });
1260
- this.log({ ev: 'leave', room: this.code, id: c.id, seat: c.seat, why });
1371
+ // One line per departure, with what the room looked like. A `leave` is a departure (a closed tab, a lost network
1372
+ // arriving as the socket's error, a host that was replaced); a `failed` line (guard) is a room operation that threw.
1373
+ this.log({ ev: 'leave', room: this.code, id: c.id, seat: c.seat, why, ...(via ? { via } : {}), role: wasHost ? 'host' : c.seat === null ? 'screen' : 'replica', left: this.live().length });
1261
1374
  if (c.id === this.hostId) {
1262
1375
  this.hostId = null;
1263
1376
  this.elect(null, why === 'replaced' ? 'host-replaced' : why === 'silent' ? 'host-stalled' : 'host-left');
@@ -1372,6 +1485,8 @@ export class NetRoom {
1372
1485
  this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
1373
1486
  this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null;
1374
1487
  this.emptySince = 0; this.openedAt = 0; this.askedMax = null;
1488
+ // The room is nobody's build again (section 23): the next visitor's is its build.
1489
+ this.gameVer = undefined;
1375
1490
  // The party's dial and vote are the room's: a new party starts from the server's level.
1376
1491
  this.level = null; this.levelBy = null; this.vote = null; this.agentsOut = null;
1377
1492
  // Room chat (section 19): an empty room forgets what was said, as it forgets everything else.
@@ -1602,7 +1717,8 @@ export class NetRoom {
1602
1717
  saved(now = this.now()) {
1603
1718
  return {
1604
1719
  v: NET_VERSION, room: this.code, savedAt: now, maxPlayers: this.maxPlayers, hostSeat: this.host()?.seat ?? null, openedAt: this.openedAt || null,
1605
- seats: [...this.seats].map(([seat, s]) => (s.agent ? [seat, s.token, s.name, s.agent] : [seat, s.token, s.name])),
1720
+ seats: [...this.seats].map(([seat, s]) => [seat, s.token, s.name, s.agent ?? null, s.occ ?? null]),
1721
+ occSeq: this.occSeq, ...(this.gameVer !== undefined ? { gameVer: this.gameVer } : {}),
1606
1722
  ckpt: this.lastCkpt, round: this.lastRound, roster: this.lastRoster, state: this.stateObject(),
1607
1723
  // Section 17: a deploy keeps the room's policy, the party's dial and what its game reads.
1608
1724
  policy: this.policy, level: this.level, levelBy: this.levelBy, caps: [...this.caps],
@@ -1619,7 +1735,10 @@ export class NetRoom {
1619
1735
  if (!saved || saved.v !== NET_VERSION || !(now - Number(saved.savedAt) < 120_000)) return false;
1620
1736
  if (Number.isInteger(saved.maxPlayers)) this.maxPlayers = Math.max(1, Math.min(this.seatCap, saved.maxPlayers));
1621
1737
  if (Number.isFinite(saved.openedAt) && saved.openedAt > 0) this.openedAt = saved.openedAt;
1622
- for (const [seat, token, name, agent] of saved.seats ?? []) this.seats.set(seat, { token, name, since: now, present: false, agent: agent && typeof agent === 'object' ? agent : null });
1738
+ for (const [seat, token, name, agent, occ] of saved.seats ?? []) this.seats.set(seat, { token, name, since: now, present: false, agent: agent && typeof agent === 'object' ? agent : null, ...(Number.isInteger(occ) ? { occ } : {}) });
1739
+ // A stay's number is never given twice while a checkpoint may still name it.
1740
+ this.occSeq = Math.max(this.occSeq, Number.isInteger(saved.occSeq) ? saved.occSeq : 0, ...[...this.seats.values()].map((x) => x.occ ?? 0));
1741
+ if ('gameVer' in saved) this.gameVer = versionOf(saved.gameVer);
1623
1742
  const pol = normalizePolicy(saved.policy);
1624
1743
  if (pol) this.policy = pol;
1625
1744
  if (Number.isInteger(saved.level)) { this.level = Math.max(1, Math.min(this.policy.levelMax, saved.level)); this.levelBy = saved.levelBy === 'owner' ? 'owner' : 'vote'; }
@@ -1911,6 +2030,28 @@ export class NetRoom {
1911
2030
  * The room's seats, changed while it runs (the owner's "max players per room"). Players already seated above the
1912
2031
  * new number keep their seat until they leave; nobody new is seated there. Never above the room's own cap.
1913
2032
  */
2033
+ /** The game's own stall time (game.json `netplay.stallMs`), held to STALL's bounds; anything else: the default. */
2034
+ setStall(ms) {
2035
+ this.stallMs = stallOf(ms) ?? STALL.ms;
2036
+ return this.stallMs;
2037
+ }
2038
+
2039
+ /** The build that is live now (the Worker's word, from the catalogue): a string, or null when the game names none. */
2040
+ setCurrent(ver) {
2041
+ this.currentVer = ver === undefined ? undefined : versionOf(ver);
2042
+ }
2043
+
2044
+ /**
2045
+ * The room changes build (section 23): everything the old build's rules wrote is dropped, so the new build's first
2046
+ * host starts a fresh round. Seats keep their tokens and names (a player who reloads comes back to their seat).
2047
+ */
2048
+ forgetWorld(next) {
2049
+ this.log({ ev: 'build-changed', room: this.code, from: this.gameVer ?? null, to: next ?? null });
2050
+ this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
2051
+ this.state.clear(); this.stateBytes = 0; this.preferHost = null;
2052
+ this.persistDirty = true;
2053
+ }
2054
+
1914
2055
  setSeats(n, perIp = null) {
1915
2056
  this.seatCap = Math.max(1, Math.floor(n));
1916
2057
  this.maxPlayers = Math.max(1, Math.min(this.seatCap, this.askedMax ?? this.seatCap));
@@ -2009,6 +2150,8 @@ export class NetRoom {
2009
2150
  policy: this.policyOut(),
2010
2151
  ...(this.vote ? { vote: this.voteView() } : {}),
2011
2152
  caps: [...this.caps],
2153
+ // Revision 9: the build this room runs (null: the game names none, or nobody is here) and its stall time.
2154
+ ver: this.gameVer ?? null, stallMs: this.stallMs,
2012
2155
  // Revision 7: whether this room's game has a vocabulary its AI may speak (agents.json).
2013
2156
  vocab: Boolean(this.vocab),
2014
2157
  memory: {