@homie-rocks/studio 0.30.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +111 -1
- package/README.md +361 -22
- package/assets/animate.ts +16 -6
- package/assets/assets.ts +14 -3
- package/assets/render-page.ts +10 -2
- package/assets/safety.mjs +7 -1
- package/bin/homie-studio.mjs +142 -123
- package/lib/art-cli.mjs +61 -18
- package/lib/art-tools.mjs +53 -10
- package/lib/asset-check.mjs +249 -46
- package/lib/asset-import.mjs +11 -5
- package/lib/asset-manifest.mjs +128 -51
- package/lib/build.mjs +239 -72
- package/lib/characters.mjs +119 -20
- package/lib/check.mjs +110 -7
- package/lib/chrome.mjs +11 -0
- package/lib/clips.mjs +54 -0
- package/lib/cloudflare.mjs +127 -8
- package/lib/codex.mjs +6 -0
- package/lib/collision.mjs +245 -0
- package/lib/decisions.mjs +2 -2
- package/lib/demo.mjs +16 -4
- package/lib/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +116 -5
- package/lib/doctor.mjs +143 -16
- package/lib/feedback.mjs +3 -2
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +36 -35
- package/lib/mcp.mjs +1 -1
- package/lib/net.mjs +81 -2
- package/lib/node-version.mjs +40 -0
- package/lib/office.mjs +6 -6
- package/lib/optimise.mjs +39 -4
- package/lib/parts-build.mjs +169 -0
- package/lib/parts-cli.mjs +104 -0
- package/lib/parts-store.mjs +342 -0
- package/lib/parts-tools.mjs +92 -0
- package/lib/parts.mjs +693 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +132 -0
- package/lib/scaffold.mjs +88 -22
- package/lib/shoot.mjs +307 -0
- package/lib/site.mjs +1 -2
- package/lib/stage.mjs +118 -0
- package/lib/studio.mjs +0 -66
- package/lib/style-board.mjs +49 -6
- package/lib/template-history.json +101 -0
- package/lib/trailer.mjs +47 -0
- package/lib/typecheck.mjs +107 -0
- package/mcp/ui/lineup.js +3 -3
- package/mcp/ui/rights.js +1 -3
- package/netplay/NETPLAY.md +424 -16
- package/netplay/netplay.ts +756 -40
- package/package.json +10 -1
- package/parts/PARTS.md +163 -0
- package/port/index.ts +8 -2
- package/port/personas.ts +275 -0
- package/port/powerups.ts +357 -0
- package/port/probe.ts +38 -2
- package/port/room.ts +103 -19
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +3 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +13 -6
- package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
- package/starters/gem-rush-3d/assets/manifest.json +0 -24
- package/starters/gem-rush-3d/index.html +1 -1
- package/starters/gem-rush-3d/src/main.ts +15 -5
- package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
- package/starters/hero-rush-3d/assets/manifest.json +0 -19
- package/starters/hero-rush-3d/index.html +1 -1
- package/starters/hero-rush-3d/src/main.ts +15 -5
- package/worker/arrival.mjs +4 -1
- package/worker/discover.mjs +11 -25
- package/worker/index.mjs +142 -62
- package/worker/license.mjs +45 -41
- package/worker/office-page.mjs +0 -4
- package/worker/office-schema.mjs +6 -0
- package/worker/office.mjs +19 -23
- package/worker/pages.mjs +141 -23
- package/worker/parts.mjs +234 -0
- package/worker/room.mjs +157 -14
- package/worker/schema.mjs +6 -8
- package/worker/seats.mjs +71 -0
- package/worker/site.mjs +51 -53
- package/worker/version.mjs +1 -1
- package/lib/remix-assets.mjs +0 -84
package/worker/parts.mjs
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
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{width:100%;aspect-ratio:16/9;border:1px solid color-mix(in srgb,var(--fg) 14%,transparent);border-radius:16px;background:#000}
|
|
116
|
+
.pfacts{display:grid;grid-template-columns:max-content 1fr;gap:8px 20px;margin:0}
|
|
117
|
+
.pfacts dt{color:var(--dim);font-size:14px}.pfacts dd{margin:0;overflow-wrap:anywhere}
|
|
118
|
+
.pband{max-width:calc(1180px + 2 * var(--gutter));margin:0 auto;padding:clamp(40px,6vw,80px) var(--gutter)}
|
|
119
|
+
.pband h2{margin:6px 0 10px;font:800 clamp(28px,4vw,44px)/1.05 var(--display)}
|
|
120
|
+
.pband .plead,.plead{max-width:62ch;color:var(--dim);margin:0 0 18px}
|
|
121
|
+
.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}
|
|
122
|
+
.psay code{display:block;margin-top:4px;font:500 15px/1.4 var(--mono);overflow-wrap:anywhere}
|
|
123
|
+
.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}
|
|
124
|
+
.pcopy svg{width:16px;height:16px}
|
|
125
|
+
</style>`;
|
|
126
|
+
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>`;
|
|
127
|
+
const studioOf = (cat) => cat.studio?.name ?? 'Studio';
|
|
128
|
+
const fromLine = (p) => (p.from?.game ? `From <a href="/${esc(p.from.game)}/">${esc(p.from.name ?? p.from.game)}</a>` : '');
|
|
129
|
+
/** What a person says to their AI to get the part, in plain words with its name and reference: no command to type. */
|
|
130
|
+
const sayAdd = (p) => `Add the "${p.name}" part from ${p.add} to my game`;
|
|
131
|
+
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>`;
|
|
132
|
+
|
|
133
|
+
function card(p) {
|
|
134
|
+
const img = p.preview?.image ? `<img src="${esc(`${p.url}${p.preview.image}`)}" alt="" loading="lazy">` : '';
|
|
135
|
+
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>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function partsPage(cat, parts, url) {
|
|
139
|
+
const name = studioOf(cat);
|
|
140
|
+
// Grouped by the game each came out of: a mashup starts from "the creature in that game".
|
|
141
|
+
const groups = new Map();
|
|
142
|
+
for (const p of parts) { const k = p.from?.game ?? ''; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(p); }
|
|
143
|
+
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('');
|
|
144
|
+
return layout(cat, {
|
|
145
|
+
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,
|
|
146
|
+
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.`)}
|
|
147
|
+
<div class="wrap">${body}</div>`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function requiresRows(p) {
|
|
152
|
+
const r = p.requires ?? {};
|
|
153
|
+
const rows = [];
|
|
154
|
+
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)`);
|
|
155
|
+
for (const x of Array.isArray(r.parts) ? r.parts : []) rows.push(`${esc(x)} (another part)`);
|
|
156
|
+
return rows;
|
|
157
|
+
}
|
|
158
|
+
function costLine(c) {
|
|
159
|
+
if (!c || typeof c !== 'object') return '';
|
|
160
|
+
const bits = [];
|
|
161
|
+
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`);
|
|
162
|
+
if (Number.isFinite(c.triangles)) bits.push(`${c.triangles.toLocaleString('en')} triangles`);
|
|
163
|
+
if (Number.isFinite(c.drawCalls)) bits.push(`${c.drawCalls} draw calls`);
|
|
164
|
+
if (Number.isFinite(c.textureMB)) bits.push(`${c.textureMB} MB of textures`);
|
|
165
|
+
return `${bits.join(' · ')}${c.measuredOn ? ` (measured on ${String(c.measuredOn).slice(0, 80)})` : ''}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function partPage(cat, p, url) {
|
|
169
|
+
const name = studioOf(cat);
|
|
170
|
+
const preview = p.preview?.page
|
|
171
|
+
? `<iframe class="pframe" title="${esc(p.name)}: try it" src="${esc(`${p.url}${p.preview.page}`)}" sandbox="allow-scripts allow-pointer-lock" loading="lazy" referrerpolicy="no-referrer"></iframe>`
|
|
172
|
+
: p.preview?.image ? `<img class="pframe" src="${esc(`${p.url}${p.preview.image}`)}" alt="${esc(p.name)}">` : '';
|
|
173
|
+
const facts = [
|
|
174
|
+
['Kind', esc(kindOf(p))],
|
|
175
|
+
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,
|
|
176
|
+
['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>` : ''}`],
|
|
177
|
+
p.attribution ? ['Credit', esc(p.attribution)] : null,
|
|
178
|
+
['Version', `${esc(p.version)}${(p.versions ?? []).length > 1 ? ` (also ${esc(p.versions.filter((v) => v !== p.version).join(', '))})` : ''}`],
|
|
179
|
+
requiresRows(p).length ? ['Builds on', requiresRows(p).join('<br>')] : null,
|
|
180
|
+
costLine(p.cost) ? ['Costs', esc(costLine(p.cost))] : null,
|
|
181
|
+
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,
|
|
182
|
+
p.skeleton?.rig ? ['Skeleton', esc(`${p.skeleton.rig}${(p.skeleton.clips ?? []).length ? `: ${p.skeleton.clips.join(', ')}` : ''}`)] : null,
|
|
183
|
+
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,
|
|
184
|
+
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,
|
|
185
|
+
(p.tags ?? []).length ? ['Tags', esc(p.tags.join(', '))] : null,
|
|
186
|
+
].filter(Boolean);
|
|
187
|
+
return layout(cat, {
|
|
188
|
+
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,
|
|
189
|
+
image: p.preview?.image ? `${p.url}${p.preview.image}` : null,
|
|
190
|
+
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>`)}
|
|
191
|
+
<div class="wrap">
|
|
192
|
+
${preview ? `<p class="sec">Try it</p>${preview}` : ''}
|
|
193
|
+
<p class="sec">Use it in your game</p>
|
|
194
|
+
<p class="plead">In a chat with your Homie studio open, ask for it by name:</p>
|
|
195
|
+
${sayRow('Say', sayAdd(p))}
|
|
196
|
+
<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>
|
|
197
|
+
<p class="sec">About this part</p>
|
|
198
|
+
<dl class="pfacts">${facts.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join('')}</dl>
|
|
199
|
+
</div>`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* ------------------------------------------------------------------ a game's landing: "Parts from this game" */
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 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
|
|
207
|
+
* build something new from them. Empty when the game shared none. It stands by itself: its own markup and styles.
|
|
208
|
+
*/
|
|
209
|
+
export function partsBand(g, parts, host) {
|
|
210
|
+
const mine = parts.filter((p) => p.from?.game === g.id);
|
|
211
|
+
if (!mine.length) return '';
|
|
212
|
+
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(', ')}`;
|
|
213
|
+
return `${CSS}<section class="pband" aria-labelledby="parts-title" data-parts-band>
|
|
214
|
+
<p class="ptag">Take a piece</p>
|
|
215
|
+
<h2 id="parts-title">Parts from this game</h2>
|
|
216
|
+
<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>
|
|
217
|
+
<ul class="pgrid">${mine.map(card).join('')}</ul>
|
|
218
|
+
${sayRow('In a chat with your Homie studio open, say', say)}
|
|
219
|
+
<div class="keys"><a class="btn" href="/parts/#from-${esc(g.id)}">${icon('spark')}<span>See the parts</span></a></div>
|
|
220
|
+
</section>`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A game's landing with its parts band, when it has shared parts (worker/index.mjs calls this on the landing it built). */
|
|
224
|
+
export async function withPartsBand(res, env, url, g) {
|
|
225
|
+
try {
|
|
226
|
+
if (!res || res.status !== 200 || !/text\/html/i.test(res.headers.get('content-type') ?? '')) return res;
|
|
227
|
+
const index = await partsIndex(env, url.origin);
|
|
228
|
+
const band = partsBand(g, index.parts.map((p) => ({ ...p, from: p.from?.game === g.id ? { ...p.from, name: g.name } : p.from })), url.host);
|
|
229
|
+
if (!band) return res;
|
|
230
|
+
const html = await res.text();
|
|
231
|
+
const at = html.lastIndexOf('</main>');
|
|
232
|
+
return new Response(at < 0 ? html : `${html.slice(0, at)}${band}\n${html.slice(at)}`, { status: res.status, headers: res.headers });
|
|
233
|
+
} catch { return res; }
|
|
234
|
+
}
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]) =>
|
|
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: {
|
package/worker/schema.mjs
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* Home Organization (the studio: name, url, logo, image, description, the owner's sameAs) and WebSite
|
|
7
7
|
* /games/, /rooms/ ItemList of the public games' landings (the Rooms list never carries a live count)
|
|
8
8
|
* /<game>/ VideoGame (co-typed WebApplication: it runs in a browser) with its pictures and screenshots,
|
|
9
|
-
* genre, players, play modes, platforms, the studio as author and publisher, the
|
|
10
|
-
*
|
|
9
|
+
* genre, players, play modes, platforms, the studio as author and publisher, the licence
|
|
10
|
+
* it names (when it names one), isBasedOn (a port's original), a trailer (VideoObject), dates,
|
|
11
11
|
* and one free-to-play Offer whose addOn lists what the studio's shop really sells in it
|
|
12
12
|
* /music/, /music/<s>/ MusicAlbum (when the manifest names one) or ItemList; MusicRecording (byArtist: the studio)
|
|
13
13
|
* /videos/, /<v>/ ItemList; VideoObject (thumbnailUrl, uploadDate, duration, contentUrl)
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* win, and `aggregateRating`, `review` and `offers` are refused (lib/build.mjs warns). A page of the studio's own
|
|
23
23
|
* (site/pages) takes `<!-- homie:schema -->` where it wants these blocks.
|
|
24
24
|
*/
|
|
25
|
-
import { licenseOf
|
|
25
|
+
import { licenseOf } from './license.mjs';
|
|
26
26
|
|
|
27
27
|
const CONTEXT = 'https://schema.org';
|
|
28
28
|
|
|
@@ -123,10 +123,10 @@ export function itemList(origin, id, name, items) {
|
|
|
123
123
|
|
|
124
124
|
/* ------------------------------------------------------------------ a game */
|
|
125
125
|
|
|
126
|
-
/**
|
|
126
|
+
/** The licence a game names for itself ("MIT") as a CreativeWork linked to its SPDX page; null when it names none. */
|
|
127
127
|
export function licenseNode(value) {
|
|
128
128
|
const l = licenseOf(value);
|
|
129
|
-
return { '@type': 'CreativeWork', name:
|
|
129
|
+
return l ? { '@type': 'CreativeWork', name: l.spdx, url: `https://spdx.org/licenses/${encodeURIComponent(l.spdx)}.html` } : null;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
const PLAY_MODE = (mode) => `https://schema.org/${mode}`;
|
|
@@ -170,10 +170,8 @@ export function gameNode(cat, g, origin, { listed = true, shop = null } = {}) {
|
|
|
170
170
|
const images = uniq([absUrl(origin, h.wideImage), absUrl(origin, h.tallImage), absUrl(origin, L.cover)]);
|
|
171
171
|
const shots = uniq((L.screenshots ?? []).map((u) => absUrl(origin, u)));
|
|
172
172
|
const trailer = L.trailer ? (cat.videos ?? []).find((v) => v.slug === L.trailer) : null;
|
|
173
|
-
const lineage = remixRow(g.remixOf);
|
|
174
173
|
const o = L.credits?.original;
|
|
175
174
|
const based = [
|
|
176
|
-
lineage ? { '@type': 'VideoGame', name: lineage.name, ...(lineage.page ? { url: lineage.page } : {}), ...(lineage.studio ? { creditText: `${lineage.name} by ${lineage.studio}` } : {}) } : null,
|
|
177
175
|
o && !o.house && o.title ? { '@type': 'VideoGame', name: o.title, ...(o.url ? { url: o.url } : {}), ...(o.author ? { creditText: `${o.title} by ${o.author}${o.year ? ` (${o.year})` : ''}${o.licence ? `, ${o.licence} licence` : ''}` } : {}) } : null,
|
|
178
176
|
].filter(Boolean);
|
|
179
177
|
// The studio by name and address, not only its @id: a page is read on its own, and Home is where it is in full.
|
|
@@ -199,7 +197,7 @@ export function gameNode(cat, g, origin, { listed = true, shop = null } = {}) {
|
|
|
199
197
|
isAccessibleForFree: true,
|
|
200
198
|
author: studio,
|
|
201
199
|
publisher: studio,
|
|
202
|
-
license: licenseNode(g.license),
|
|
200
|
+
...(licenseNode(g.license) ? { license: licenseNode(g.license) } : {}),
|
|
203
201
|
...(based.length ? { isBasedOn: based.length === 1 ? based[0] : based } : {}),
|
|
204
202
|
...(trailer ? { trailer: videoNode(cat, trailer, origin, { nested: true }) } : {}),
|
|
205
203
|
...(when(g.dates?.published) ? { datePublished: g.dates.published } : {}),
|