@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
package/lib/build.mjs CHANGED
@@ -3,10 +3,16 @@
3
3
  * catalogue the Worker serves (games.json).
4
4
  *
5
5
  * site/dist/games/<id>/index.html the game's page (the Worker adds HOMIE_NET)
6
- * site/dist/games/<id>/assets/main.js its bundle (esbuild; @homie-rocks/studio/netplay inlined)
6
+ * site/dist/games/<id>/assets/main-<HASH>.js its bundle (esbuild; @homie-rocks/studio/netplay inlined), named by
7
+ * its content, so a browser or an edge that kept the last build's bundle is
8
+ * never asked for this one under the same name; the built index.html names it
9
+ * site/dist/games/<id>/assets/chunk-<HASH>.js whatever the game loads later with `await import('./x')`: one file
10
+ * each, fetched when asked for, from beside the bundle
11
+ * site/dist/games/<id>/assets/main.js the address tools knew before bundles had hashes: one line that imports
12
+ * the hashed bundle (bundleOf() says the real file)
13
+ * site/dist/_site/build.json what this build made: per game its content hash, its bundle and chunks,
14
+ * and whether it changed since the build before (what a deploy reads)
7
15
  * site/dist/games/<id>/... everything in games/<id>/public/
8
- * site/dist/games/<id>/assets.json its assets' licences and, for those a remix may carry, their address and SHA-256
9
- * (games/<id>/assets/manifest.json; lib/asset-manifest.mjs servedAssets)
10
16
  * site/dist/games/<id>/agents.json the AI guides' vocabulary (games/<id>/agents.json, checked; NETPLAY.md
11
17
  * section 18): the only goals and lines an AI in its rooms has
12
18
  * site/dist/games.json { studio, games[], songs[], videos[], posts[], site, shop } from studio.json,
@@ -36,17 +42,19 @@ import { spawnSync } from 'node:child_process';
36
42
  import { createHash } from 'node:crypto';
37
43
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
38
44
  import { createRequire } from 'node:module';
39
- import { join, relative } from 'node:path';
45
+ import { basename, join, relative } from 'node:path';
40
46
  import { buildCodexPages } from './codex.mjs';
41
- import { servedAssets } from './asset-manifest.mjs';
47
+ import { BUDGET_FIELDS, BUDGET_TIERS, loaderBudget } from './asset-check.mjs';
42
48
  import { buildMedia } from './media.mjs';
43
49
  import { buildSiteFiles, isoDate, landingOf, readPosts, readTheme } from './site.mjs';
44
50
  import { checkJsonLd } from './schema-check.mjs';
45
51
  import { SCHEMA_REFUSED } from '../worker/schema.mjs';
46
52
  import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
47
- import { SEAT_MAX } from '../worker/seats.mjs';
53
+ import { SEAT_MAX, netplayRow } from '../worker/seats.mjs';
48
54
  import { STUDIO_VERSION } from './version.mjs';
49
- import { licenseOf, remixRow } from '../worker/license.mjs';
55
+ import { basedOnRow, licenseOf, retiredKeys } from '../worker/license.mjs';
56
+ import { openStage, swapIn } from './stage.mjs';
57
+ import { typecheck } from './typecheck.mjs';
50
58
  import { SERVER_LIMITS, serverOf } from '../worker/servers.mjs';
51
59
  import { chatProblems } from '../worker/chat.mjs';
52
60
  import { screenChatProblems } from '../worker/chat-page.mjs';
@@ -54,8 +62,9 @@ import { vocabularyOf } from '../worker/brain.mjs';
54
62
  import { shopForBuild } from './shop.mjs';
55
63
  import { audienceOf } from '../worker/shop-rules.mjs';
56
64
  import { loungeConfig, loungeProblems } from '../worker/lounge-store.mjs';
65
+ // GAME PARTS (parts/PARTS.md): the three call-outs below are all the build knows of them.
66
+ import { buildParts, partsPlugin } from './parts-build.mjs';
57
67
 
58
- const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
59
68
  /** Never copied into a static game's served folder. */
60
69
  const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md', 'CODEX.md', 'lab.json', 'codex']);
61
70
  const LOADERS = { '.png': 'file', '.jpg': 'file', '.jpeg': 'file', '.gif': 'file', '.webp': 'file', '.mp3': 'file', '.ogg': 'file', '.wav': 'file', '.m4a': 'file', '.glb': 'file', '.gltf': 'file', '.bin': 'file', '.hdr': 'file', '.svg': 'file', '.json': 'json', '.woff2': 'file', '.ttf': 'file' };
@@ -84,12 +93,6 @@ function dirBytes(dir) {
84
93
  for (const e of readdirSync(dir, { withFileTypes: true })) n += e.isDirectory() ? dirBytes(join(dir, e.name)) : statSync(join(dir, e.name)).size;
85
94
  return n;
86
95
  }
87
- const TEXT = /\.(ts|tsx|js|mjs|jsx|json|html|css|md|txt|svg|glsl|wgsl|frag|vert)$/i;
88
- /**
89
- * Text files of a game folder, capped (2 MB total, 512 KB each): what `homie-studio game remix` takes; with who made
90
- * it (`credit`: the studio and the game; the live site adds the page, worker/index.mjs) and its licence (game.json
91
- * "license", worker/license.mjs).
92
- */
93
96
  /** From games/<id>/style.json: the light colour as paper, the dark as text, and the accent; null without one. */
94
97
  function uiOf(g) {
95
98
  let pal = null;
@@ -102,27 +105,6 @@ function uiOf(g) {
102
105
  return { paper: light ? ink : bg, text: light ? bg : ink, ...(hex(pal?.accent) ? { hot: hex(pal.accent) } : {}) };
103
106
  }
104
107
 
105
- export function sourceOf(dir, id, { studio = null, game = null, license } = {}) {
106
- const files = {};
107
- let total = 0;
108
- const walk = (rel) => {
109
- for (const entry of readdirSync(join(dir, rel), { withFileTypes: true })) {
110
- // The codex (CODEX.md, and codex/: the art-direction decisions and the style board) is the owner's, never shared.
111
- if (entry.name.startsWith('.') || SOURCE_SKIP.has(entry.name) || (!rel && (entry.name === 'CODEX.md' || entry.name === 'codex'))) continue;
112
- const path = rel ? `${rel}/${entry.name}` : entry.name;
113
- if (entry.isDirectory()) walk(path);
114
- else if (TEXT.test(entry.name)) {
115
- const text = readFileSync(join(dir, path), 'utf8');
116
- if (text.length > 512 * 1024 || total + text.length > 2 * 1024 * 1024) continue;
117
- files[path] = text;
118
- total += text.length;
119
- }
120
- }
121
- };
122
- walk('');
123
- return { v: 1, kind: 'homie-game-source', id, credit: { studio, game: game ?? id }, license: licenseOf(license), files };
124
- }
125
-
126
108
  /*
127
109
  * WHAT THIS BUILD IS, for the site's manifest: the commit and branch (Cloudflare's Workers Builds names them in
128
110
  * WORKERS_CI_COMMIT_SHA and WORKERS_CI_BRANCH; elsewhere git is asked), when it was built, and the marks of the
@@ -287,14 +269,75 @@ export function seatsFor(g, net = netplayOf(g)) {
287
269
  * bundle itself is byte for byte the one a plain build makes (an external map adds no comment to it); main.js.sha256
288
270
  * says which bundle the map belongs to, so a map left over from an older build is never used.
289
271
  */
290
- function keepMap(root, id, out, metafile) {
272
+ function keepMap(root, id, out, metafile, bundle, maps) {
291
273
  const dir = join(root, '.studio', 'maps', id);
292
274
  rmSync(dir, { recursive: true, force: true });
293
275
  mkdirSync(dir, { recursive: true });
294
- const map = join(out, 'assets', 'main.js.map');
295
- if (existsSync(map)) { cpSync(map, join(dir, 'main.js.map')); rmSync(map, { force: true }); }
276
+ // The bundle's map keeps the name the perf tools know (main.js.map) whatever hash the bundle has.
277
+ for (const m of maps) writeFileSync(join(dir, m.entry ? 'main.js.map' : m.name), m.text);
296
278
  writeFileSync(join(dir, 'meta.json'), JSON.stringify(metafile ?? {}));
297
- writeFileSync(join(dir, 'main.js.sha256'), `${createHash('sha256').update(readFileSync(join(out, 'assets', 'main.js'))).digest('hex')}\n`);
279
+ writeFileSync(join(dir, 'main.js.sha256'), `${createHash('sha256').update(readFileSync(join(out, bundle))).digest('hex')}\n`);
280
+ }
281
+
282
+ /**
283
+ * A built game's real bundle, as a path in its folder (`assets/main-K3J2H1AB.js`): what its index.html loads. The
284
+ * build writes it to bundle.json beside the page; a game built before bundles had hashes (or by its own build) has
285
+ * assets/main.js itself. Null when the folder has neither.
286
+ */
287
+ export function bundleOf(dir) {
288
+ const named = readJson(join(dir, 'bundle.json'))?.bundle;
289
+ if (typeof named === 'string' && /^assets\/[A-Za-z0-9._-]+\.js$/.test(named) && existsSync(join(dir, named))) return named;
290
+ return existsSync(join(dir, 'assets', 'main.js')) ? 'assets/main.js' : null;
291
+ }
292
+
293
+ /**
294
+ * One digest of a built game's files (everything in its folder but _landing/, the landing page's own pictures): the
295
+ * same sixteen characters `homie-studio perf` names a build by. Two builds with the same digest serve the same game.
296
+ */
297
+ export function gameDigest(dir) {
298
+ if (!existsSync(dir)) return null;
299
+ const files = [];
300
+ const walk = (rel) => {
301
+ for (const e of readdirSync(join(dir, rel), { withFileTypes: true })) {
302
+ const r = rel ? `${rel}/${e.name}` : e.name;
303
+ if (e.isDirectory()) { if (r !== '_landing') walk(r); } else files.push(r);
304
+ }
305
+ };
306
+ walk('');
307
+ const h = createHash('sha256');
308
+ for (const f of files.sort()) h.update(`${f}\0${createHash('sha256').update(readFileSync(join(dir, f))).digest('hex')}\n`);
309
+ return h.digest('hex').slice(0, 16);
310
+ }
311
+
312
+ /**
313
+ * game.json `"assets": { "budgets": { "triangles": 8000, "bytes": 1500000 } }`: what ONE model may cost before
314
+ * `createModels()` says so in the console during development (assets/assets.ts; the defaults, 1,500 triangles and
315
+ * 300 KB, are a small prop's, and a 3D game's hero or its level is neither). `texturePx` and `materials` are taken
316
+ * the same way. Null when the game sets none; a field that is not a positive number is left out, with a warning.
317
+ *
318
+ * The key has ONE reader (lib/asset-check.mjs gameBudgets, which `assets check` and `assets add` hold a model to):
319
+ * this is that reading, as the loader can use it. The loader knows no tiers, so a game that sets a budget a tier
320
+ * ({ "hero": { "triangles": 12000 } }) gets the loosest one, and a number past a hard cap is held to the cap here
321
+ * as it is there, said in the build's log. What the loader warns about is then never something the check passes.
322
+ */
323
+ export function modelBudgetsOf(g, log = () => {}) {
324
+ const raw = g.assets && typeof g.assets === 'object' ? g.assets.budgets : undefined;
325
+ if (raw === undefined) return null;
326
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { log(`warning: games/${g.id}/game.json assets.budgets is an object ({ "triangles": 8000, "bytes": 1500000 }); left out`); return null; }
327
+ const whole = (v) => { const n = Math.floor(Number(v)); return Number.isFinite(n) && n >= 1 ? n : null; };
328
+ const said = (where, o) => { for (const k of [...BUDGET_FIELDS, 'materials']) if (o[k] !== undefined && whole(o[k]) === null) log(`warning: games/${g.id}/game.json assets.budgets.${where}${k} is a whole number above zero; left out`); };
329
+ said('', raw);
330
+ for (const k of Object.keys(raw)) {
331
+ if (BUDGET_FIELDS.includes(k) || k === 'materials') continue;
332
+ // A budget for one tier (hero, npc, prop, signature, kit), as `assets check` reads it.
333
+ if (BUDGET_TIERS.includes(k) && raw[k] && typeof raw[k] === 'object' && !Array.isArray(raw[k])) said(`${k}.`, raw[k]);
334
+ else log(`warning: games/${g.id}/game.json assets.budgets.${k} is not one of triangles, bytes, texturePx, materials${BUDGET_TIERS.includes(k) ? ' (a tier is an object: { "triangles": … })' : `, or a tier (${BUDGET_TIERS.join(', ')})`}; left out`);
335
+ }
336
+ const read = loaderBudget(g);
337
+ for (const c of read?.capped ?? []) log(`warning: games/${g.id}/game.json assets.budgets asks ${c.asked} ${c.field} for a model; ${c.cap} is the most any model may have (assets check holds the same), so ${c.cap} it is`);
338
+ const out = { ...(read?.budget ?? {}) };
339
+ if (whole(raw.materials) !== null) out.materials = whole(raw.materials);
340
+ return Object.keys(out).length ? out : null;
298
341
  }
299
342
 
300
343
  /** esbuild, as the studio has it installed (the version its package.json pins). */
@@ -303,32 +346,72 @@ export async function studioEsbuild(root) {
303
346
  try { return require('esbuild'); } catch { return import('esbuild'); }
304
347
  }
305
348
 
349
+ /** In a built page, the bundle's old address (./assets/main.js, in a src or an href) becomes its hashed one. */
350
+ function pointAtBundle(html, bundle) {
351
+ return html.replace(/((?:src|href)\s*=\s*)(["'])((?:\.\/|\/)?)assets\/main\.js((?:[?#][^"']*)?)\2/gi, (m, attr, q, lead, tail) => `${attr}${q}${lead}${bundle}${tail}${q}`);
352
+ }
353
+
306
354
  /**
307
355
  * One game's own files into `out` (emptied first): its bundle, static copy or own build's output, its index.html and
308
356
  * its public/ folder. What `build` serves at /games/<id>/, and what the Game Lab (lib/lab.mjs) builds New and Today
309
357
  * with. `sourcemap` is esbuild's (the lab keeps a linked map beside its builds); `maps` keeps the site build's map in
310
- * .studio/maps/<id>/. Returns { mode, warnings, metafile } (metafile: the bundle's inputs, null for other modes).
358
+ * .studio/maps/<id>/. Returns { mode, warnings, metafile, bundle, chunks } (metafile: the bundle's inputs, null for
359
+ * other modes; bundle: its path in `out`, null when the game has none; chunks: what it loads later).
360
+ *
361
+ * The bundle is ES modules with code splitting: every `await import('./later')` in the game becomes a chunk of its
362
+ * own (assets/chunk-<HASH>.js) that the browser fetches when the game asks, from beside the bundle, wherever the
363
+ * game is served (/games/<id>/ on the site, /<id>/__game/ in its frame, the lab). A game with no dynamic import is
364
+ * one file, as before. `hashed` (the site's build) names the bundle by its content too: see the top of this file.
311
365
  */
312
- export async function buildGameFiles(esbuild, root, g, out, { maps = false, sourcemap = null, cache = {}, log = () => {} } = {}) {
366
+ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sourcemap = null, cache = {}, log = () => {}, hashed = false } = {}) {
313
367
  rmSync(out, { recursive: true, force: true });
314
368
  mkdirSync(join(out, 'assets'), { recursive: true });
315
369
  const mode = g.build?.mode ?? 'bundle';
316
370
  let warnings = 0;
317
371
  let metafile = null;
372
+ let bundled = null;
373
+ let chunks = [];
374
+ const budgets = modelBudgetsOf(g, log);
318
375
  const bundle = async (entryRel) => {
319
376
  const entry = join(g.dir, entryRel);
320
377
  if (!existsSync(entry)) throw new Error(`games/${g.id}: entry ${entryRel} not found`);
321
- const result = await esbuild.build({
322
- entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: sourcemap ?? (maps ? 'external' : false),
323
- outfile: join(out, 'assets', 'main.js'), absWorkingDir: root, logLevel: 'silent', metafile: true,
378
+ const options = {
379
+ entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: sourcemap ?? false,
380
+ outdir: join(out, 'assets'), splitting: true, entryNames: hashed ? 'main-[hash]' : 'main', chunkNames: 'chunk-[hash]',
381
+ absWorkingDir: root, logLevel: 'silent', metafile: true,
324
382
  loader: LOADERS, assetNames: '[name]-[hash]',
325
- }).catch((error) => {
383
+ // game.json assets.budgets, read by createModels() (assets/assets.ts): a constant in the bundle, never a fetch.
384
+ define: { __HOMIE_MODEL_BUDGETS__: JSON.stringify(budgets ?? {}) },
385
+ // GAME PARTS: `@parts/<host>/<id>` and `@parts/<id>` resolve to the part's entry, and the game is credited.
386
+ plugins: [partsPlugin(root, g.id)],
387
+ };
388
+ const failed = (error) => {
326
389
  const first = error.errors?.[0];
327
390
  throw new Error(`games/${g.id} did not build: ${first ? `${first.text}${first.location ? ` (${first.location.file}:${first.location.line})` : ''}` : error.message}`);
328
- });
391
+ };
392
+ const result = await esbuild.build(options).catch(failed);
329
393
  warnings += result.warnings.length;
330
394
  metafile = result.metafile;
331
- if (maps) keepMap(root, g.id, out, result.metafile);
395
+ const outputs = Object.entries(result.metafile.outputs).filter(([k]) => k.endsWith('.js'));
396
+ const main = outputs.find(([, v]) => v.entryPoint)?.[0];
397
+ if (!main) throw new Error(`games/${g.id} did not build: esbuild wrote no bundle for ${entryRel}`);
398
+ bundled = `assets/${basename(main)}`;
399
+ chunks = outputs.filter(([k]) => k !== main).map(([k]) => `assets/${basename(k)}`).sort();
400
+ if (maps) {
401
+ // The map comes from a second pass that writes nothing. esbuild's name hash covers a file's map as well as its
402
+ // code, so a build made WITH maps would name the bundle (and its chunks) differently from a plain build of the
403
+ // same source, and `build --maps` would no longer be the build a deploy ships. The second pass's code differs
404
+ // from the first's only in those names, which are all the same length, so its map fits the shipped bundle
405
+ // position for position.
406
+ const mapped = await esbuild.build({ ...options, sourcemap: 'external', write: false, metafile: true }).catch(failed);
407
+ const entryOut = Object.entries(mapped.metafile.outputs).find(([k, v]) => k.endsWith('.js') && v.entryPoint)?.[0];
408
+ keepMap(root, g.id, out, result.metafile, bundled, mapped.outputFiles.filter((f) => f.path.endsWith('.js.map')).map((f) => ({ name: basename(f.path), text: f.text, entry: Boolean(entryOut) && basename(f.path) === `${basename(entryOut)}.map` })));
409
+ }
410
+ if (hashed) {
411
+ // The address tools and older pages knew: it imports the real bundle, so both are one module, run once.
412
+ writeFileSync(join(out, 'assets', 'main.js'), `import"./${basename(main)}";\n`);
413
+ writeFileSync(join(out, 'bundle.json'), `${JSON.stringify({ v: 1, bundle: bundled, chunks })}\n`);
414
+ }
332
415
  };
333
416
  if (mode === 'static') {
334
417
  copyStatic(g.dir, out);
@@ -336,6 +419,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
336
419
  if (g.entry) await bundle(g.entry);
337
420
  const html = readFileSync(join(out, 'index.html'), 'utf8');
338
421
  if (!/homie-port\.js/.test(html)) log(`warning: games/${g.id}/index.html does not load ./homie-port.js (the port toolkit); add <script src="./homie-port.js"></script> first in <head>`);
422
+ if (hashed && bundled) writeFileSync(join(out, 'index.html'), pointAtBundle(html, bundled));
339
423
  } else if (mode === 'command') {
340
424
  const command = String(g.build.command ?? 'npm run build');
341
425
  const res = spawnSync(command, { cwd: g.dir, shell: true, encoding: 'utf8', timeout: 10 * 60_000, maxBuffer: 64 * 1024 * 1024 });
@@ -348,29 +432,61 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
348
432
  await bundle(g.entry ?? 'src/main.ts');
349
433
  const html = join(g.dir, 'index.html');
350
434
  if (!existsSync(html)) throw new Error(`games/${g.id}/index.html is missing`);
351
- writeFileSync(join(out, 'index.html'), readFileSync(html, 'utf8'));
435
+ const text = readFileSync(html, 'utf8');
436
+ writeFileSync(join(out, 'index.html'), hashed ? pointAtBundle(text, bundled) : text);
352
437
  }
353
438
  if (mode !== 'static' && existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
354
439
  if (!existsSync(join(out, 'index.html'))) throw new Error(`games/${g.id}/index.html is missing`);
355
- return { mode, warnings, metafile };
440
+ return { mode, warnings, metafile, bundle: bundled, chunks };
441
+ }
442
+
443
+ /**
444
+ * studio.json `"site": { "order": ["<id>", …] }`: the order every list of games is in (Home, the Games page, the
445
+ * cards, the feeds for crawlers and /.well-known/homie-studio.json, which all read the catalogue's order). The games
446
+ * it names come first, as it names them; every other game follows by id, as all of them did before.
447
+ */
448
+ export function orderGames(games, order, log = () => {}) {
449
+ if (order === undefined) return games;
450
+ if (!Array.isArray(order) || order.some((x) => typeof x !== 'string')) { log('warning: studio.json site.order is a list of game ids (["newest-game", "older-game"]); left out'); return games; }
451
+ const ids = new Set(games.map((g) => g.id));
452
+ const want = [...new Set(order)];
453
+ for (const id of want) if (!ids.has(id)) log(`warning: studio.json site.order names "${String(id).slice(0, 40)}", which is not one of this studio's games; skipped`);
454
+ const at = new Map(want.filter((id) => ids.has(id)).map((id, i) => [id, i]));
455
+ return [...games].sort((a, b) => (at.get(a.id) ?? Infinity) - (at.get(b.id) ?? Infinity) || a.id.localeCompare(b.id));
356
456
  }
357
457
 
358
- export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false } = {}) {
458
+ export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false } = {}) {
359
459
  const esbuild = await studioEsbuild(root);
360
460
  const studio = readStudio(root);
361
461
  // The shop first (shop/SHOP.md): a shop.json that breaks the kit's rules stops the build before anything is built.
362
462
  const shop = shopForBuild(root, { log });
363
- const dist = join(root, 'site', 'dist');
463
+ const live = join(root, 'site', 'dist');
364
464
  const games = listGames(root).filter((g) => !only || g.id === only);
365
465
  if (only && !games.length) throw new Error(`no game "${only}" in games/`);
366
- if (!only) rmSync(dist, { recursive: true, force: true });
367
- mkdirSync(dist, { recursive: true });
466
+ // `--types`: the games' TypeScript is checked first (esbuild only strips types, it never reads them), and a type
467
+ // error stops the build before anything is built.
468
+ const typed = types ? typecheck(root, games, { log }) : null;
469
+ // What the build before this one made, to say which games changed.
470
+ const before = readJson(join(live, '_site', 'build.json'))?.games ?? {};
471
+ // Everything is built in a folder of its own and put in place only when all of it is there (lib/stage.mjs): a
472
+ // game that does not build leaves site/dist as it was, never a site without that game. A one-game build starts
473
+ // from the site as it is.
474
+ const dist = openStage(root, { from: only ? live : null });
475
+ try {
476
+ return await buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps });
477
+ } finally {
478
+ rmSync(dist, { recursive: true, force: true });
479
+ }
480
+ }
481
+
482
+ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps }) {
368
483
  const built = [];
484
+ const retired = [];
369
485
  const cache = {};
370
486
  for (const g of games) {
371
487
  const out = join(dist, 'games', g.id);
372
488
  const started = Date.now();
373
- const { mode, warnings } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log });
489
+ const { mode, warnings, bundle, chunks } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log, hashed: true });
374
490
  // The guides' vocabulary (NETPLAY.md section 18): checked here, so a room never meets a line it cannot say.
375
491
  vocabFor(g, out);
376
492
  // Room chat (NETPLAY.md section 19): game.json "chat", checked here (its quick lines pass the chat floor too).
@@ -380,19 +496,31 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
380
496
  if (g.decide !== undefined && typeof g.decide !== 'boolean') throw new Error(`games/${g.id}/game.json: "decide" is true (the host may ask the studio's decision model, within the AI brains' day) or false`);
381
497
  // Where chat sits on the screen (game.json "screen": { "chat" }, 0.24.5): a wrong field is the default on the page.
382
498
  for (const p of screenChatProblems(g.screen?.chat)) log(`warning: games/${g.id}/game.json: ${p}; the play page uses the default there`);
383
- // The game's own source, for other studios to remix (game.json "share": { "source": false } keeps it private).
384
- if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id, { studio: studio.name ?? null, game: g.name ?? g.id, license: g.license }))}\n`);
385
- // Its assets' licences, and the address and SHA-256 of each one a remix may carry (`game remix` fetches them).
386
- if (g.share?.source !== false && existsSync(join(g.dir, 'assets', 'manifest.json'))) writeFileSync(join(out, 'assets.json'), `${JSON.stringify(servedAssets(root, g.id, out))}\n`);
387
- else rmSync(join(out, 'assets.json'), { force: true });
388
- const main = join(out, 'assets', 'main.js');
389
- const bytes = existsSync(main) ? statSync(main).size : dirBytes(out);
499
+ // A game.json written for an older toolkit may still carry the retired settings. They are ignored, never an
500
+ // error (a studio that upgrades must still build); what they were is said once, after the games (below).
501
+ const old = retiredKeys(g);
502
+ if (old.length) retired.push(`games/${g.id}/game.json ${old.join(', ')}`);
503
+ const bytes = bundle ? statSync(join(out, bundle)).size : dirBytes(out);
504
+ const later = chunks.reduce((n, c) => n + statSync(join(out, c)).size, 0);
390
505
  const seats = seatsFor(g, netplayOf(g, out));
391
506
  if (seats.asked > SEAT_MAX) log(`warning: games/${g.id} asks for ${seats.asked} players; a room holds at most ${SEAT_MAX}, so its rooms have ${SEAT_MAX} seats`);
392
- built.push({ id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings, seats: seats.max });
393
- log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB)`);
507
+ built.push({
508
+ id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings, seats: seats.max,
509
+ ...(bundle ? { bundle } : {}), ...(chunks.length ? { chunks: chunks.length, chunkBytes: later } : {}),
510
+ });
511
+ log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB${chunks.length ? ` + ${chunks.length} ${chunks.length === 1 ? 'chunk' : 'chunks'} loaded later, ${Math.max(1, Math.round(later / 1024))} KB` : ''})`);
394
512
  }
395
513
  const all = listGames(root);
514
+ // No game is handed over whole any more (remix was retired; worker/license.mjs). A one-game build starts from the
515
+ // site as it is, which an older toolkit may have built: what that wrote for remixers (a game's whole source, and
516
+ // its assets' addresses) is taken out of every game's folder here. Only those two files as that toolkit wrote them,
517
+ // told by their own `kind`: a game's own public/assets.json is the game's, and stays.
518
+ for (const g of all) {
519
+ for (const [f, kind] of [['source.json', 'homie-game-source'], ['assets.json', 'homie-game-assets']]) {
520
+ const file = join(dist, 'games', g.id, f);
521
+ if (existsSync(file) && statSync(file).size <= 8 * 1024 * 1024 && readJson(file)?.kind === kind) rmSync(file, { force: true });
522
+ }
523
+ }
396
524
  // Songs and videos (music/ and videos/ manifests): rebuilt with every full build; a one-game build keeps them.
397
525
  const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
398
526
  let media = null;
@@ -402,11 +530,17 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
402
530
  }
403
531
  // The site around the games (site/SITE.md): the look, each game's landing, posts, and what site/ overrides.
404
532
  const theme = readTheme(root, { log });
405
- const shown = all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html')));
533
+ const s = studio.site && typeof studio.site === 'object' ? studio.site : {};
534
+ // studio.json site.order: the catalogue's order is every listing's order.
535
+ const shown = orderGames(all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))), s.order, log);
536
+ // What each game's build is (its landing's own pictures are not part of it): the digest the manifest names.
537
+ const builds = {};
406
538
  const rows = shown.map((g) => {
407
539
  // The seats come from the game's netplay manifest (NETPLAY.md §3): what the Worker gives every room of it.
408
540
  const net = netplayOf(g, join(dist, 'games', g.id));
409
541
  const { min, max } = seatsFor(g, net);
542
+ const netRow = netplayRow(net);
543
+ for (const p of netRow.problems) log(`warning: games/${g.id}/game.json: ${p}`);
410
544
  const seeds = serverSeeds(g, log);
411
545
  // The game's own palette (style.json, its art direction): the play page's buttons wear its paper and ink, so the
412
546
  // page's pills and the game's own HUD are one UI.
@@ -416,10 +550,18 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
416
550
  const genre = genreOf(g, log);
417
551
  const dates = gameDates(root, g, log);
418
552
  const extras = schemaExtras(g.schema, { where: `games/${g.id}/game.json`, types: ['VideoGame', 'WebApplication'], log });
553
+ // The landing first: it may put the game's cover into the build, which is part of what the digest names.
554
+ const landing = landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log });
555
+ const dir = join(dist, 'games', g.id);
556
+ const bundle = bundleOf(dir);
557
+ builds[g.id] = { hash: gameDigest(dir), ...(bundle ? { bundle } : {}) };
419
558
  return {
420
559
  id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
421
560
  ...(ui ? { ui } : {}),
422
561
  roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
562
+ // game.json "netplay": { "version", "stallMs", "params" } (NETPLAY.md sections 22 to 24): the game's revision (a
563
+ // room runs one build at a time), its own host stall time, and the address's switches its frame is handed.
564
+ ...(netRow.row ? { netplay: netRow.row } : {}),
423
565
  ...(g.screen ? { screen: g.screen } : {}),
424
566
  // game.json "saves": true — player accounts and cloud saves (saves/SAVES.md): the play shell answers the game's
425
567
  // saves calls, and the site's nav and the game's landing offer a player account.
@@ -440,22 +582,28 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
440
582
  // 0.17.0: the game has a vocabulary for its AI guides (agents.json), so they can talk once the owner says so.
441
583
  ...(existsSync(join(dist, 'games', g.id, 'agents.json')) ? { vocab: true } : {}),
442
584
  ...(g.agents && typeof g.agents === 'object' && (g.agents.vote === 'game' || g.agents.vote === false) ? { agents: { vote: g.agents.vote } } : {}),
443
- // Its source licence (worker/license.mjs), and, for a remix, what it is a remix of (shown on its landing).
444
- license: licenseOf(g.license),
445
- ...(remixRow(g.remixOf) ? { remixOf: remixRow(g.remixOf) } : {}),
585
+ // The licence the game names for itself (an SPDX id, worker/license.mjs), when it names one; and, for a game
586
+ // that was made from another studio's while that was possible (game.json `remixOf`), the credit it owes the
587
+ // original, which its landing and credits keep showing.
588
+ ...(licenseOf(g.license) ? { license: licenseOf(g.license) } : {}),
589
+ ...(basedOnRow(g.remixOf) ? { basedOn: basedOnRow(g.remixOf) } : {}),
446
590
  ...(genre ? { genre } : {}),
447
591
  ...(dates ? { dates } : {}),
448
592
  ...(extras ? { schema: extras } : {}),
449
- landing: landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log }),
593
+ landing,
594
+ // What is built: its digest (the same one `perf` names a build by) and the bundle its page loads. The live
595
+ // site says them in /.well-known/homie-studio.json, so "is the live game this build?" is one comparison.
596
+ built: builds[g.id],
450
597
  };
451
598
  });
452
599
  const { posts, skipped: postsSkipped } = readPosts(root, { games: rows, songs: media.songs, videos: media.videos, log });
453
600
  const site = buildSiteFiles(root, dist, { gameIds: rows.map((g) => g.id), log });
454
601
  // Each game's Game Codex (games/<id>/CODEX.md), as the owner's private page at /_studio/codex/<id>/ (never listed).
455
602
  const codexes = buildCodexPages(root, dist, games.map((g) => g.id), { log });
603
+ // GAME PARTS: every packed version of every SHARED part into dist/parts/, and its index; a private part is never copied.
604
+ const partsBuilt = buildParts(root, dist, { studio, log });
456
605
  mkdirSync(join(dist, '_site'), { recursive: true });
457
606
  writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
458
- const s = studio.site && typeof studio.site === 'object' ? studio.site : {};
459
607
  const studioExtras = schemaExtras(s.schema, { where: 'studio.json site', types: 'Organization', log });
460
608
  // The Lounge (0.29.0): studio.json "lounge", checked; off when a game already has /lounge/.
461
609
  for (const p of loungeProblems(studio, rows, chatProblems)) log(`warning: studio.json: ${p}`);
@@ -498,10 +646,29 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
498
646
  ...(shop ? { shop } : {}),
499
647
  };
500
648
  writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
649
+ // What this build made, for whatever runs next (a deploy says which games it changes; a script checks the live
650
+ // site against it): per game its digest, its bundle and chunks, and whether it differs from the build before.
651
+ const changedOf = (id) => (!before[id]?.hash ? 'new' : before[id].hash === builds[id].hash ? 'unchanged' : 'changed');
652
+ const report = {};
653
+ for (const g of rows) {
654
+ const dir = join(dist, 'games', g.id);
655
+ const named = readJson(join(dir, 'bundle.json'));
656
+ report[g.id] = { ...builds[g.id], ...(Array.isArray(named?.chunks) && named.chunks.length ? { chunks: named.chunks } : {}), changed: changedOf(g.id) };
657
+ }
658
+ writeFileSync(join(dist, '_site', 'build.json'), `${JSON.stringify({ v: 1, at: catalogue.studio.build.at, commit: catalogue.studio.build.commit, games: report }, null, 2)}\n`);
659
+ for (const b of built) Object.assign(b, { hash: builds[b.id]?.hash ?? null, changed: report[b.id]?.changed ?? 'new' });
660
+ // A deploy builds first and prints its own result, not the build's: so the build a deploy is about to ship says
661
+ // here which games it changes and which build each is (`homie-studio build` prints the same from its result).
662
+ if (deploy) for (const b of built) log(` ${b.id}: ${b.changed}${b.changed === 'new' ? '' : ' since the last build here'}, build ${b.hash}`);
663
+ // All of it is there: now, and only now, it becomes site/dist (lib/stage.mjs).
664
+ const swapped = swapIn(dist, live);
501
665
  return {
502
- ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id),
666
+ ok: true, command: 'build', dist: live, games: built, catalogue: catalogue.games.map((g) => g.id),
667
+ builds: report, swapped, ...(typed ? { types: typed } : {}),
668
+ // One plain note for the whole build, however many games and keys it is about (never one a key).
669
+ ...(retired.length ? { retired: `Remix was retired, and games now build on each other through parts (pieces a studio shares; ask for a part, or see parts/PARTS.md). These settings no longer do anything and can be deleted: ${retired.join('; ')}. No game's source is served whole; a game made from another keeps its credit.` } : {}),
503
670
  songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped, mediaNotes: media.notes ?? [],
504
- posts: posts.map((p) => p.slug), postsSkipped, codexes, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
505
- landings: rows.map((g) => ({ id: g.id, hero: g.landing.hero.wide || g.landing.hero.tall ? 'footage' : g.landing.hero.wideImage ? 'art' : 'colours', credits: Boolean(g.landing.credits.original || g.landing.credits.people.length), source: g.landing.source })),
671
+ posts: posts.map((p) => p.slug), postsSkipped, codexes, parts: partsBuilt, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
672
+ landings: rows.map((g) => ({ id: g.id, hero: g.landing.hero.wide || g.landing.hero.tall ? 'footage' : g.landing.hero.wideImage ? 'art' : 'colours', credits: Boolean(g.landing.credits.original || g.landing.credits.people.length) })),
506
673
  };
507
674
  }