@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.
Files changed (92) hide show
  1. package/CHANGELOG.md +111 -1
  2. package/README.md +361 -22
  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 +142 -123
  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 +127 -8
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/demo.mjs +16 -4
  23. package/lib/deploy-state.mjs +166 -0
  24. package/lib/dev.mjs +430 -0
  25. package/lib/directory.mjs +116 -5
  26. package/lib/doctor.mjs +143 -16
  27. package/lib/feedback.mjs +3 -2
  28. package/lib/handoff.mjs +2 -6
  29. package/lib/library.mjs +1 -1
  30. package/lib/mcp-tools.mjs +36 -35
  31. package/lib/mcp.mjs +1 -1
  32. package/lib/net.mjs +81 -2
  33. package/lib/node-version.mjs +40 -0
  34. package/lib/office.mjs +6 -6
  35. package/lib/optimise.mjs +39 -4
  36. package/lib/parts-build.mjs +169 -0
  37. package/lib/parts-cli.mjs +104 -0
  38. package/lib/parts-store.mjs +342 -0
  39. package/lib/parts-tools.mjs +92 -0
  40. package/lib/parts.mjs +693 -0
  41. package/lib/perf.mjs +131 -31
  42. package/lib/port-check.mjs +40 -6
  43. package/lib/preview.mjs +77 -0
  44. package/lib/routes.mjs +132 -0
  45. package/lib/scaffold.mjs +88 -22
  46. package/lib/shoot.mjs +307 -0
  47. package/lib/site.mjs +1 -2
  48. package/lib/stage.mjs +118 -0
  49. package/lib/studio.mjs +0 -66
  50. package/lib/style-board.mjs +49 -6
  51. package/lib/template-history.json +101 -0
  52. package/lib/trailer.mjs +47 -0
  53. package/lib/typecheck.mjs +107 -0
  54. package/mcp/ui/lineup.js +3 -3
  55. package/mcp/ui/rights.js +1 -3
  56. package/netplay/NETPLAY.md +424 -16
  57. package/netplay/netplay.ts +756 -40
  58. package/package.json +10 -1
  59. package/parts/PARTS.md +163 -0
  60. package/port/index.ts +8 -2
  61. package/port/personas.ts +275 -0
  62. package/port/powerups.ts +357 -0
  63. package/port/probe.ts +38 -2
  64. package/port/room.ts +103 -19
  65. package/site/SITE.md +102 -20
  66. package/starters/ember-vale/index.html +1 -1
  67. package/starters/ember-vale/src/main.ts +3 -1
  68. package/starters/gem-rush/index.html +1 -1
  69. package/starters/gem-rush/src/main.ts +13 -6
  70. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  71. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  72. package/starters/gem-rush-3d/index.html +1 -1
  73. package/starters/gem-rush-3d/src/main.ts +15 -5
  74. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  75. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  76. package/starters/hero-rush-3d/index.html +1 -1
  77. package/starters/hero-rush-3d/src/main.ts +15 -5
  78. package/worker/arrival.mjs +4 -1
  79. package/worker/discover.mjs +11 -25
  80. package/worker/index.mjs +142 -62
  81. package/worker/license.mjs +45 -41
  82. package/worker/office-page.mjs +0 -4
  83. package/worker/office-schema.mjs +6 -0
  84. package/worker/office.mjs +19 -23
  85. package/worker/pages.mjs +141 -23
  86. package/worker/parts.mjs +234 -0
  87. package/worker/room.mjs +157 -14
  88. package/worker/schema.mjs +6 -8
  89. package/worker/seats.mjs +71 -0
  90. package/worker/site.mjs +51 -53
  91. package/worker/version.mjs +1 -1
  92. package/lib/remix-assets.mjs +0 -84
@@ -26,8 +26,12 @@ import { createHash } from 'node:crypto';
26
26
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
27
27
  import { join } from 'node:path';
28
28
  import { build } from './build.mjs';
29
+ import { builtGames, compareDeploy, deployWords, lastDeploy, lastDeployRecord, lockDeploy, recordDeploy } from './deploy-state.mjs';
30
+ import { listedHere, publish } from './directory.mjs';
29
31
  import { MAX_ASSET_BYTES, R2_COST, mediaPlan, r2OverOf, recordMove, sha256File, sizeOf } from './media.mjs';
32
+ import { whyFailed } from './net.mjs';
30
33
  import { repoOf } from './repo.mjs';
34
+ import { keptRoutes, readConfig, shadowedDomain, wideRouteRefusal } from './routes.mjs';
31
35
  import { ensureLocalIgnored, ensureMigrations, migrationWord, wranglerConfig } from './scaffold.mjs';
32
36
  import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
33
37
  import { projectsCloudflareEnv } from './projects-env.mjs';
@@ -90,6 +94,12 @@ export function explainCloudflare(out, accountId = null) {
90
94
  if (/maximum number of (D1 )?databases|database limit|too many databases/i.test(text)) {
91
95
  return { needs: 'd1-limit', why: 'This Cloudflare account has used all the D1 databases its plan allows (10 on the free plan). Nothing was deployed. Ask the person which unused database of theirs to remove, or use another account; never delete one yourself.' };
92
96
  }
97
+ // A route in wrangler.jsonc that another Worker of the zone already holds. Wrangler refuses, and so does the
98
+ // studio: that route is not the studio's to take (lib/routes.mjs).
99
+ if (/assigned to another worker|already assigned to routes/i.test(text)) {
100
+ const routes = [...text.matchAll(/^\s*-\s+(\S+)\s*$/gm)].map((m) => m[1]);
101
+ return { needs: 'cloudflare-routes', ...(routes.length ? { routes } : {}), why: `A route in wrangler.jsonc${routes.length ? ` (${routes.join(', ')})` : ''} already belongs to another Worker on this Cloudflare zone, so Cloudflare refused the deploy and the live site is unchanged. The studio never takes a route from another Worker. If the route is wider than the studio's own hostname (a wildcard, a catch-all), take it out of wrangler.jsonc and give the studio its own: { "pattern": "<its hostname>/*", "zone_name": "<the domain>" }. If it IS the studio's own hostname, ask the person: only they can unassign it from the other Worker, in the zone's Workers Routes in the Cloudflare dashboard.` };
102
+ }
93
103
  if (/\b1027\b|exceeded .*daily request limit/i.test(text)) {
94
104
  return { needs: 'free-plan-daily-limit', why: 'This account used its free plan\'s 100,000 Worker requests for today (it resets at 00:00 UTC). Nothing is broken; try again after the reset.' };
95
105
  }
@@ -127,10 +137,29 @@ export function deployPlan(root) {
127
137
  };
128
138
  }
129
139
 
130
- export async function deploy(root, { log = () => {}, homie } = {}) {
140
+ /**
141
+ * `homie-studio deploy`. One at a time for a studio: it holds the studio's deploy lock (lib/deploy-state.mjs) from
142
+ * before it reads anything until it ends, however it ends. `fetchFn` is the toolkit's own tests' stand-in network.
143
+ */
144
+ export async function deploy(root, opts = {}) {
145
+ const lock = lockDeploy(root);
146
+ if (!lock.ok) return { ok: false, command: 'deploy', needs: lock.needs, why: lock.why };
147
+ // A deploy that is killed still lets go (a kill -9 cannot; the next deploy sees its process is gone).
148
+ const onExit = () => lock.release();
149
+ process.once('exit', onExit);
150
+ try { return await deployLocked(root, opts); } finally { process.off('exit', onExit); lock.release(); }
151
+ }
152
+
153
+ async function deployLocked(root, { log = () => {}, homie, fetchFn = null } = {}) {
131
154
  const studio = readStudio(root);
132
155
  const cf = { r2: null, created: [], ...studio.cloudflare };
133
156
  const created = new Set(cf.created ?? []);
157
+ // A wildcard or catch-all route in the studio's own config would hand other hostnames of the zone to this Worker:
158
+ // refused before Cloudflare is asked anything (lib/routes.mjs). Read once here; every rewrite below keeps the
159
+ // studio's own routes exactly as its owner wrote them.
160
+ const wide = wideRouteRefusal(readConfig(root), studio);
161
+ if (wide) return { ok: false, command: 'deploy', ...wide };
162
+ const routes = keptRoutes(root, studio);
134
163
  const who = whoami(root);
135
164
  if (!who) {
136
165
  if (cf.auth === 'stripe-projects') {
@@ -187,6 +216,14 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
187
216
  // A new studio goes live with its own Home ("First game coming soon") before it has a game.
188
217
  step(b.catalogue.length || b.songs.length || b.videos.length ? `built ${b.catalogue.length} game(s), ${b.songs.length} song(s), ${b.videos.length} video(s)` : 'built the home page (no game yet: "First game coming soon")');
189
218
  for (const n of b.mediaNotes ?? []) step(`note: ${n.file}: ${n.why}`);
219
+ if (b.retired) step(`note: ${b.retired}`);
220
+ // Which games this deploy changes, against what the last deploy from this computer put live, each with the
221
+ // build's own hash for it (lib/deploy-state.mjs: one hash vocabulary). The record is written only once Wrangler has deployed.
222
+ const builtNow = builtGames(root, b.catalogue);
223
+ const delta = compareDeploy(builtNow, lastDeploy(root), lastDeployRecord(root));
224
+ for (const g of delta.games) step(`${g.id}: ${deployWords(delta.first ? { hash: g.hash } : g)}`, { game: g.id, hash: g.hash, change: g.change });
225
+ if (delta.first && delta.games.length) step('no earlier deploy from this computer to compare with: the next one says changed or unchanged for each game');
226
+ for (const id of delta.removed) step(`${id}: no longer in the build; it leaves the site with this deploy`);
190
227
 
191
228
  // The Worker: never one this studio did not make.
192
229
  if (!created.has(`worker:${cf.worker}`)) {
@@ -218,14 +255,15 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
218
255
  const r2 = storage;
219
256
  if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
220
257
 
221
- writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root) }));
258
+ writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes }));
259
+ if (routes) step(`kept the studio's own route${routes.length === 1 ? '' : 's'} in wrangler.jsonc: ${routes.map((r) => r.pattern).join(', ')}`);
222
260
  for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
223
261
  const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
224
262
  if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
225
263
  step('D1 migrations applied');
226
264
  // Workers AI (0.17.0): bound only when a server's AI guides think with it.
227
265
  if (needsWorkersAi(root, w, cf.d1)) {
228
- writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true }));
266
+ writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes }));
229
267
  step('Workers AI bound (AI): a server\'s AI guides think with it and typed room chat is reviewed with it, each within its day\'s budget (free allocation: 10,000 neurons a day)');
230
268
  }
231
269
 
@@ -250,11 +288,46 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
250
288
  // The homie.rocks directory claim: the live site claims itself the first time its manifest is read (0.10.0), so
251
289
  // reading it once now is all it takes; nothing is stored by hand.
252
290
  const directory = homie || studio.homie?.directory || 'https://homie.rocks';
291
+ recordDeploy(root, builtNow);
253
292
  let claim = null;
293
+ const notes = [];
254
294
  if (url) {
255
- claim = await warmClaim(url);
295
+ const read = await readLiveSite(url, fetchFn ? { fetchFn } : {});
296
+ claim = read.claim;
256
297
  if (claim) step('the site claimed itself in the directory');
257
- else step('the directory claim is not there yet (the site claims itself when the directory first reads it)');
298
+ else if (read.state === 'other' && !isWorkersDev(url)) {
299
+ // The studio's own domain answered as something else: say which route is needed (lib/routes.mjs). The deploy
300
+ // itself went through; the zone's other routes are never touched.
301
+ const s = shadowedDomain(readConfig(root), new URL(url).hostname, read);
302
+ notes.push(s.why);
303
+ step(s.why, s.route ? { needs: 'cloudflare-routes', route: s.route } : {});
304
+ } else if (read.state === 'unreachable') {
305
+ // This computer could not ask (its own name lookup, no connection): a network preflight failure, never a
306
+ // verdict on the site that was just deployed.
307
+ const said = `deployed, but this computer could not read ${url} back to check it: ${read.why}. ${read.preflight === 'dns' ? 'That is this computer\'s name lookup (a browser here may still open the site), not the deploy.' : 'That is this computer\'s connection, not the deploy.'} The site claims itself when the directory first reads it.`;
308
+ notes.push(said);
309
+ step(said, { preflight: read.preflight ?? 'network' });
310
+ } else step('the directory claim is not there yet (the site claims itself when the directory first reads it)');
311
+ }
312
+
313
+ // The deploy before this one came from a toolkit that still had remix, and left games open to be taken whole. No
314
+ // game's source is served any more, but the directory keeps its old copy of the studio (and its offer of those
315
+ // games) until it reads the site again, so ask it to, now. ONLY for a studio this computer listed (publish records
316
+ // it): going online never lists a studio, and a deploy never lists one either. Once: see compareDeploy.
317
+ let reread = null;
318
+ if (url && delta.withdrawn.length) {
319
+ const which = delta.withdrawn.join(', ');
320
+ const dir = String(directory).replace(/\/+$/, '');
321
+ if (listedHere(root, { site: url, directory: dir })) {
322
+ const again = await publish(root, { homie: dir, site: url, ...(fetchFn ? { fetchFn } : {}) });
323
+ reread = { ok: again.ok, games: delta.withdrawn, ...(again.ok ? {} : { why: again.why }), ...(again.publishes ? { publishes: again.publishes } : {}) };
324
+ step(again.ok
325
+ ? `asked the directory to read the studio again: it still offered the source of ${which}, and no game's source is served whole any more${again.publishes?.line ? ` (${again.publishes.line})` : ''}`
326
+ : `the directory still offers the source of ${which}, which is no longer served, and it did not take the re-read (${String(again.why ?? 'no answer').split('\n')[0]}): it keeps its old copy until the next publish`);
327
+ if (!again.ok) notes.push(`The directory still offers the source of ${which}, which this site no longer serves: publish again later (homie-studio publish).`);
328
+ } else {
329
+ step(`the source of ${which} is no longer served (games build on each other through parts now); this computer has no record of listing the studio in the directory, so the directory was not asked anything (a deploy never lists a studio). If it is listed, publish again and the directory reads the site as it is.`);
330
+ }
258
331
  }
259
332
 
260
333
  // studio.json keeps what is safe to commit: names, ids, the custom domain; never the workers.dev address.
@@ -264,7 +337,11 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
264
337
  return {
265
338
  ok: true, command: 'deploy', url, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
266
339
  ...(media ? { media: { moved: media.moved ?? [], failed: media.failed ?? [], inR2: media.inR2 ?? null } } : {}),
267
- games: b.catalogue.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null })),
340
+ games: b.catalogue.map((id) => { const g = delta.games.find((x) => x.id === id); return { id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null, hash: g?.hash ?? null, change: delta.first ? 'first deploy from this computer' : g?.change ?? null }; }),
341
+ ...(delta.removed.length ? { removed: delta.removed } : {}),
342
+ ...(routes ? { routes: routes.map((r) => r.pattern) } : {}),
343
+ ...(notes.length ? { notes } : {}),
344
+ ...(reread ? { reread } : {}),
268
345
  songs: b.songs.map((slug) => ({ slug, page: url ? `${url}/music/${slug}/` : null })),
269
346
  videos: b.videos.map((slug) => ({ slug, page: url ? `${url}/videos/${slug}/` : null })),
270
347
  claim: Boolean(claim), directory,
@@ -301,6 +378,44 @@ export function repoVar(root) {
301
378
  return repo ? ['--var', `HOMIE_REPO:${repo}`] : [];
302
379
  }
303
380
 
381
+ /**
382
+ * The live site's manifest, read back after a deploy, and what the answer was:
383
+ * state 'studio' it answered as a studio; `claim` is its directory claim (null until it has one)
384
+ * state 'other' the address answered, and not as a studio (another Worker's page, a 404 that is not ours):
385
+ * `status`, and `said`, the first words of what it said
386
+ * state 'unreachable' this computer could not ask: `why`, and `preflight` ('dns' for a name it could not look up)
387
+ * state 'skipped' the toolkit's own tests (HOMIE_STUDIO_WARM=0) with no stand-in network
388
+ * Reading it is also what makes a 0.10.0 site claim itself. A fresh deploy can take a few seconds to answer
389
+ * everywhere, so a site that does not yet answer as a studio is asked three times.
390
+ */
391
+ export async function readLiveSite(site, { tries = 3, wait = 2500, fetchFn = null } = {}) {
392
+ if (!fetchFn && process.env.HOMIE_STUDIO_WARM === '0') return { state: 'skipped', claim: null };
393
+ const ask = fetchFn ?? globalThis.fetch;
394
+ const base = String(site).replace(/\/+$/, '');
395
+ let last = { state: 'unreachable', claim: null, why: 'no answer' };
396
+ for (let i = 0; i < tries; i++) {
397
+ try {
398
+ const res = await ask(`${base}/.well-known/homie-studio.json`, { headers: { accept: 'application/json', 'user-agent': 'homie-studio-deploy' }, signal: AbortSignal.timeout(15_000) });
399
+ const text = await res.text();
400
+ let body = null;
401
+ try { body = JSON.parse(text); } catch { body = null; }
402
+ const studio = res.ok && body && typeof body === 'object' && ('claim' in body || Array.isArray(body.games));
403
+ if (studio) {
404
+ const claim = /^[a-f0-9]{16,128}$/.test(body.claim ?? '') ? body.claim : null;
405
+ if (claim) return { state: 'studio', claim };
406
+ last = { state: 'studio', claim: null };
407
+ } else last = { state: 'other', claim: null, status: res.status, said: String(body?.error ?? body?.message ?? text ?? '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80) };
408
+ } catch (error) {
409
+ const w = whyFailed(error, base);
410
+ last = { state: 'unreachable', claim: null, why: w.why, code: w.code ?? null, ...(w.preflight ? { preflight: w.preflight } : {}) };
411
+ // A name this computer cannot look up does not start resolving in five seconds: say so now.
412
+ if (w.preflight === 'dns') return last;
413
+ }
414
+ if (i < tries - 1) await new Promise((r) => setTimeout(r, wait));
415
+ }
416
+ return last;
417
+ }
418
+
304
419
  /**
305
420
  * Read the live site's manifest once, which makes a 0.10.0 site claim itself in its directory. Returns the claim it
306
421
  * serves, or null (the directory did not answer, the site is not reachable yet, or it is a Preview). A fresh deploy
@@ -378,10 +493,13 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
378
493
  step(claim ? 'the site claimed itself in the directory' : 'no directory claim yet (the site claims itself when the directory first reads it)');
379
494
  let games = [];
380
495
  try { games = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')).games.map((g) => g.id); } catch { games = []; }
496
+ const hashes = builtGames(root, games);
497
+ for (const id of games) if (hashes[id]?.hash) step(`${id}: ${deployWords(hashes[id])}`);
381
498
  return {
382
499
  ok: true, command: 'deploy', ci: true, url, worker: names.worker, d1: names.d1, r2: null, steps, announced: [],
383
500
  commit: process.env.WORKERS_CI_COMMIT_SHA ?? null, branch: process.env.WORKERS_CI_BRANCH ?? null,
384
- games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null })), songs: [], videos: [],
501
+ // A CI checkout has no earlier deploy to compare with: each game's content hash only (lib/deploy-state.mjs).
502
+ games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null, hash: hashes[id]?.hash ?? null })), songs: [], videos: [],
385
503
  claim: Boolean(claim),
386
504
  };
387
505
  }
@@ -429,7 +547,8 @@ export async function storageAdd(root, { log = () => {} } = {}) {
429
547
  const next = { ...readStudio(root), cloudflare: { ...readStudio(root).cloudflare, accountId, r2: bucket, created: [...created].sort() } };
430
548
  writeStudio(root, next);
431
549
  if (next.cloudflare.d1Id) {
432
- writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket, layout: layoutOf(root) }));
550
+ // The studio's own routes stay through this rewrite too (lib/routes.mjs).
551
+ writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket, layout: layoutOf(root), routes: keptRoutes(root, studio) }));
433
552
  }
434
553
  log(`created R2 ${bucket}`);
435
554
  return { ok: true, command: 'storage add', bucket, account: accountId, cost: R2_COST, next: ['npx --no-install homie-studio media move --dry-run (which songs and videos go to R2: over 1 MiB, or left out of git)', 'npm run deploy (binds the bucket as MEDIA, moves them, checks each by SHA-256, and serves them from R2 at the same addresses; the files stay in this folder)'] };
package/lib/codex.mjs CHANGED
@@ -702,6 +702,12 @@ ${game.blurb ?? ''}
702
702
 
703
703
  <!-- The theme's mood and tempo, the sounds that matter (pick-up, hit, win), and whether songs come from the free synth or ElevenLabs. -->
704
704
 
705
+ ## Built from
706
+
707
+ <!-- What this game is made of, looked for BEFORE writing (the plan tool says how: \`parts find\` for each system, and the @homie-rocks/* packages). One line each:
708
+ packages (camera, input, audio, effects… from npm); parts from other games (which part, the game and studio it came from, its licence);
709
+ and what was written here from scratch, and why nothing that exists fitted. -->
710
+
705
711
  ## Milestones
706
712
 
707
713
  ${game.planned ? `- [ ] The plan (this codex)
@@ -0,0 +1,245 @@
1
+ /**
2
+ * `homie-studio collision`: a model that can be drawn becomes a model that can be stood on, and stays that way.
3
+ *
4
+ * homie-studio collision bake <game> <model.glb> [--cell 0.25] [--side top|under] [--max-y <m>]
5
+ * homie-studio collision check <game> [<model.glb>]
6
+ *
7
+ * Importing a model records what it looks like and where it came from. Nothing in that says where a figure may walk
8
+ * on it, so the first playable version of a generated landmark is one figures fall through. `bake` reads the
9
+ * model's triangles (every node's transform applied, in metres) and writes two files beside it:
10
+ *
11
+ * <model>.collision.json the height grid (@homie-rocks/heightfield MeshBake), the SHA-256 of the model file and
12
+ * a checksum of its triangles, and three things a person AUTHORS and a re-bake keeps:
13
+ * "proxies" (boxes, cylinders and ramps for what a figure walks around), "mover" (the
14
+ * body that must fit: radius, height, step, arrival radius) and "routes" (ways a bot
15
+ * should be able to walk, as lists of points)
16
+ * <model>.collision.svg the plan at true scale (40 px to the metre, a metre grid, a scale bar): the grid shaded
17
+ * by height, the proxies outlined, the routes drawn, and a mark where a route fails
18
+ *
19
+ * `check` is the gate: the model file is the one that was baked (a changed file is "stale: bake it again", by name),
20
+ * the proxies are well formed, and a body of the mover's size both passes the static route check and walks every
21
+ * route to its end. It answers ok: false with a row for each thing wrong, so it can stand in a build script.
22
+ *
23
+ * The game loads the JSON with `unpackBake` and `bakeField` and calls `bakeStale` against the mesh it loaded.
24
+ *
25
+ * The arithmetic is @homie-rocks/heightfield's, not a copy of it. It is not installed with the toolkit (a pinned
26
+ * version of it that is not on npm yet would stop every new studio's install): the studio's own copy is used, else one
27
+ * beside the toolkit, else the answer says the one install to run, which an AI with a shell runs itself.
28
+ */
29
+ import { createHash } from 'node:crypto';
30
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
31
+ import { createRequire } from 'node:module';
32
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
33
+ import { pathToFileURL } from 'node:url';
34
+ import { modelTools } from './optimise.mjs';
35
+ import { listGames } from './studio.mjs';
36
+
37
+ /** The body that must fit when a file names none: a person, in metres. */
38
+ export const DEFAULT_MOVER = Object.freeze({ radius: 0.35, height: 1.8, step: 0.3, maxDrop: 0.5, arrival: 1, speed: 4 });
39
+ const PX = 40;
40
+ /** How a walk that did not arrive reads in a sentence after "the bot". */
41
+ const ENDED = { fell: 'fell', blocked: 'was blocked', headroom: 'ran out of headroom', 'wrong level': 'reached the wrong level', timeout: 'ran out of time' };
42
+
43
+ /** @homie-rocks/heightfield's modules, from the studio's own install first, then from beside this toolkit. */
44
+ export async function heightfieldModules(root) {
45
+ const names = ['MeshBake', 'Proxy', 'Levels', 'Route'];
46
+ const from = [];
47
+ if (root) { try { const req = createRequire(join(root, 'package.json')); from.push((n) => import(pathToFileURL(req.resolve(`@homie-rocks/heightfield/${n}.js`)).href)); } catch { /* not a package: the toolkit's own */ } }
48
+ from.push((n) => import(`@homie-rocks/heightfield/${n}.js`));
49
+ for (const load of from) {
50
+ try { const [bake, proxy, levels, route] = await Promise.all(names.map(load)); if (bake.bakeMesh && route.traverse) return { ...bake, ...proxy, ...levels, ...route }; } catch { /* the next place */ }
51
+ }
52
+ return null;
53
+ }
54
+ const NEEDS = { ok: false, needs: 'heightfield', why: 'collision needs the package @homie-rocks/heightfield (0.2.0 or later, for its MeshBake module) in this studio, and found none', instead: 'npm install --save-exact @homie-rocks/heightfield (in the studio folder; then ask again)' };
55
+
56
+ /** Every triangle of a model, in the scene's own space (each node's transform applied): { positions, indices }. */
57
+ export async function modelTriangles(bytes) {
58
+ const { io } = await modelTools();
59
+ const doc = await io.readBinary(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
60
+ const positions = []; const indices = [];
61
+ const scenes = doc.getRoot().listScenes();
62
+ const scene = doc.getRoot().getDefaultScene() ?? scenes[0];
63
+ if (!scene) return { positions: new Float32Array(0), indices: new Uint32Array(0) };
64
+ scene.traverse((node) => {
65
+ const mesh = node.getMesh();
66
+ if (!mesh) return;
67
+ const m = node.getWorldMatrix();
68
+ for (const prim of mesh.listPrimitives()) {
69
+ // 4 is TRIANGLES. Lines and points are not something to stand on; strips and fans are not what an optimiser writes.
70
+ if (prim.getMode() !== 4) continue;
71
+ const pos = prim.getAttribute('POSITION');
72
+ if (!pos) continue;
73
+ const base = positions.length / 3; const el = [0, 0, 0];
74
+ for (let i = 0; i < pos.getCount(); i += 1) {
75
+ pos.getElement(i, el);
76
+ positions.push(m[0] * el[0] + m[4] * el[1] + m[8] * el[2] + m[12], m[1] * el[0] + m[5] * el[1] + m[9] * el[2] + m[13], m[2] * el[0] + m[6] * el[1] + m[10] * el[2] + m[14]);
77
+ }
78
+ const ix = prim.getIndices();
79
+ if (ix) for (let i = 0; i < ix.getCount(); i += 1) indices.push(base + ix.getScalar(i));
80
+ else for (let i = 0; i < pos.getCount(); i += 1) indices.push(base + i);
81
+ }
82
+ });
83
+ return { positions: new Float32Array(positions), indices: new Uint32Array(indices) };
84
+ }
85
+
86
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
87
+
88
+ /**
89
+ * The plan at true scale, as SVG text: 40 px to the metre, north (-z) up. `problems` are points to mark.
90
+ * A text file on purpose: it diffs, and a person can open it beside the lineup.
91
+ */
92
+ export function collisionSvg(bake, { proxies = [], routes = [], problems = [], title = '' } = {}) {
93
+ const { nx, nz, x0, z0, cell, heights, covered } = bake;
94
+ const pad = 1; // metres of margin
95
+ const w = ((nx - 1) * cell + pad * 2) * PX; const h = ((nz - 1) * cell + pad * 2) * PX + 30;
96
+ const sx = (x) => ((x - x0 + pad) * PX).toFixed(1); const sz = (z) => ((z - z0 + pad) * PX).toFixed(1);
97
+ let lo = Infinity; let hi = -Infinity;
98
+ for (let k = 0; k < heights.length; k += 1) if (covered[k]) { lo = Math.min(lo, heights[k]); hi = Math.max(hi, heights[k]); }
99
+ const out = [`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w.toFixed(0)} ${h.toFixed(0)}" width="${w.toFixed(0)}" height="${h.toFixed(0)}" font-family="system-ui, sans-serif" font-size="12">`,
100
+ `<title>${esc(title || 'collision plan')}</title>`, `<rect width="100%" height="100%" fill="#101418"/>`];
101
+ // Sixteen shades of height, runs of one shade along a row merged into one rectangle.
102
+ for (let j = 0; j < nz; j += 1) {
103
+ let run = -1; let shade = -1;
104
+ const flush = (i) => { if (run >= 0) { const v = 60 + shade * 12; out.push(`<rect x="${sx(x0 + (run - 0.5) * cell)}" y="${sz(z0 + (j - 0.5) * cell)}" width="${((i - run) * cell * PX).toFixed(1)}" height="${(cell * PX).toFixed(1)}" fill="rgb(${v},${v + 8},${v + 14})"/>`); } run = -1; };
105
+ for (let i = 0; i <= nx; i += 1) {
106
+ const k = j * nx + i;
107
+ const s = i < nx && covered[k] ? Math.min(15, Math.floor(((heights[k] - lo) / Math.max(1e-6, hi - lo)) * 16)) : -1;
108
+ if (s !== shade) { flush(i); shade = s; if (s >= 0) run = i; }
109
+ }
110
+ }
111
+ for (let x = Math.ceil(x0 - pad); x <= x0 + (nx - 1) * cell + pad; x += 1) out.push(`<line x1="${sx(x)}" y1="0" x2="${sx(x)}" y2="${(h - 30).toFixed(0)}" stroke="#ffffff" stroke-opacity="${x === 0 ? 0.35 : 0.08}"/>`);
112
+ for (let z = Math.ceil(z0 - pad); z <= z0 + (nz - 1) * cell + pad; z += 1) out.push(`<line x1="0" y1="${sz(z)}" x2="${w.toFixed(0)}" y2="${sz(z)}" stroke="#ffffff" stroke-opacity="${z === 0 ? 0.35 : 0.08}"/>`);
113
+ for (const p of proxies) {
114
+ if (p.kind === 'cylinder') { out.push(`<circle cx="${sx(p.x)}" cy="${sz(p.z)}" r="${(p.r * PX).toFixed(1)}" fill="none" stroke="#ff7a59" stroke-width="2"><title>${esc(p.id ?? p.kind)}: ${p.h} m high</title></circle>`); continue; }
115
+ const yaw = p.yaw ?? 0; const c = Math.cos(yaw); const s = Math.sin(yaw);
116
+ const pts = [[-p.hx, -p.hz], [p.hx, -p.hz], [p.hx, p.hz], [-p.hx, p.hz]].map(([ax, az]) => `${sx(p.x + ax * c + az * s)},${sz(p.z - ax * s + az * c)}`).join(' ');
117
+ out.push(`<polygon points="${pts}" fill="none" stroke="#ff7a59" stroke-width="2"${p.kind === 'ramp' ? ' stroke-dasharray="6 3"' : ''}><title>${esc(p.id ?? p.kind)}: ${p.h} m high</title></polygon>`);
118
+ }
119
+ for (const r of routes) out.push(`<polyline points="${(r.path ?? []).map((q) => `${sx(q.x)},${sz(q.z)}`).join(' ')}" fill="none" stroke="#5ec8ff" stroke-width="2"><title>${esc(r.name ?? 'route')}</title></polyline>`);
120
+ for (const q of problems) out.push(`<circle cx="${sx(q.x)}" cy="${sz(q.z)}" r="7" fill="none" stroke="#ff3355" stroke-width="3"><title>${esc(q.why ?? '')}</title></circle>`);
121
+ out.push(`<line x1="20" y1="${(h - 14).toFixed(0)}" x2="${20 + PX}" y2="${(h - 14).toFixed(0)}" stroke="#fff" stroke-width="3"/>`,
122
+ `<text x="${28 + PX}" y="${(h - 10).toFixed(0)}" fill="#fff">1 m · ${esc(title)} · ${cell} m cell · heights ${Number.isFinite(lo) ? `${lo.toFixed(2)} to ${hi.toFixed(2)} m` : 'none'}</text>`, '</svg>');
123
+ return `${out.join('\n')}\n`;
124
+ }
125
+
126
+ const gameDir = (root, id) => listGames(root).find((g) => g.id === id)?.dir ?? null;
127
+ /** Every file under a game's folder whose name passes `want`, sorted (no dot folders, no node_modules). */
128
+ function filesUnder(dir, want) {
129
+ const out = [];
130
+ const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { if (e.name === 'node_modules' || e.name.startsWith('.')) continue; const p = join(d, e.name); if (e.isDirectory()) walk(p); else if (want(e.name)) out.push(p); } };
131
+ walk(dir);
132
+ return out.sort();
133
+ }
134
+ /** A model named on the command line: a path from the game's folder or from here, or a bare file name anywhere in the game. */
135
+ function modelPath(dir, file) {
136
+ const direct = isAbsolute(file) ? file : [join(dir, file), resolve(file)].find((p) => existsSync(p));
137
+ if (direct && existsSync(direct)) return direct;
138
+ return file === basename(file) ? filesUnder(dir, (n) => n === file)[0] ?? null : null;
139
+ }
140
+ const sidecar = (model) => model.replace(/\.(glb|gltf)$/i, '') + '.collision.json';
141
+ const readJson = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
142
+ const sha256 = (b) => createHash('sha256').update(b).digest('hex');
143
+
144
+ /** Check one sidecar against its model: rows of { label, ok, note }, and the points where a route failed. */
145
+ async function checkOne(H, model, file) {
146
+ const rows = []; const marks = [];
147
+ const row = (label, ok, note = '') => rows.push({ label, ok, note });
148
+ const side = readJson(file);
149
+ if (!side || side.v !== 1 || !side.bake) { row('collision file', false, 'not a version 1 collision file: bake it again'); return { rows, marks, side: null, bake: null }; }
150
+ let bake = null;
151
+ try { bake = H.unpackBake(side.bake); } catch (e) { row('height grid', false, e.message); return { rows, marks, side, bake }; }
152
+ if (!existsSync(model)) { row('model', false, `${basename(model)} is not beside its collision file`); return { rows, marks, side, bake }; }
153
+ const bytes = readFileSync(model);
154
+ if (sha256(bytes) === side.sha256) row('model', true, 'the file that was baked');
155
+ else {
156
+ // The file changed. Whether the SURFACE changed is the mesh checksum's question: a re-export with new textures
157
+ // and the same triangles is still the same collider.
158
+ const why = H.bakeStale(bake, await modelTriangles(bytes));
159
+ row('model', !why, why ? `stale: ${why}` : 'the file changed, its triangles did not');
160
+ }
161
+ const proxies = Array.isArray(side.proxies) ? side.proxies : [];
162
+ const bad = H.checkProxies(proxies);
163
+ row('proxies', bad.length === 0, bad.length ? bad.join('; ') : `${proxies.length} authored`);
164
+ const mover = { ...DEFAULT_MOVER, ...(side.mover ?? {}) };
165
+ const world = H.createWorld({ ground: H.bakeField(bake), proxies: bad.length ? [] : proxies });
166
+ const routes = Array.isArray(side.routes) ? side.routes : [];
167
+ if (!routes.length) row('routes', true, 'none authored: add "routes" to the collision file to have a bot walk it');
168
+ for (const r of routes) {
169
+ const name = `route ${r.name ?? routes.indexOf(r) + 1}`;
170
+ const path = Array.isArray(r.path) ? r.path : [];
171
+ if (path.length < 2) { row(name, false, 'a route is { "name", "path": [{ "x", "y", "z" }, …] } with at least two points'); continue; }
172
+ const found = H.checkRoute(world, path, mover);
173
+ const run = H.traverse(world, path, mover);
174
+ for (const p of found) marks.push({ x: p.at.x, z: p.at.z, why: `${name}: ${p.why}` });
175
+ if (!run.ok) marks.push({ x: run.at.x, z: run.at.z, why: `${name}: the bot ${ENDED[run.why] ?? run.why} here` });
176
+ const first = found[0];
177
+ row(name, found.length === 0 && run.ok, found.length === 0 && run.ok ? `walked ${run.metres.toFixed(1)} m in ${run.seconds.toFixed(1)} s with a ${mover.arrival} m arrival radius`
178
+ : [first ? `${first.part} ${first.index}: ${first.why} (at ${first.at.x.toFixed(2)}, ${first.at.z.toFixed(2)})${found.length > 1 ? ` and ${found.length - 1} more` : ''}` : null, run.ok ? null : `the bot ${ENDED[run.why] ?? run.why} at ${run.at.x.toFixed(2)}, ${run.at.z.toFixed(2)} heading for point ${run.index}`].filter(Boolean).join('; '));
179
+ }
180
+ return { rows, marks, side, bake };
181
+ }
182
+
183
+ /** `collision bake`: the grid, the checksums and the plan, beside the model. Authored proxies, mover and routes are kept. */
184
+ export async function collisionBake(root, id, file, { cell = 0.25, side = 'top', maxY, minY } = {}) {
185
+ const command = 'collision bake';
186
+ const dir = gameDir(root, id);
187
+ if (!dir) return { ok: false, command, why: `no game ${id} in this studio` };
188
+ const model = file ? modelPath(dir, file) : null;
189
+ if (!model) return { ok: false, command, why: `which model? ${file ? `${file} is not in games/${id}` : `homie-studio collision bake ${id} <model.glb>`}` };
190
+ if (!(Number(cell) > 0)) return { ok: false, command, why: '--cell is metres between posts, a positive number (0.25 suits a walkway)' };
191
+ const H = await heightfieldModules(root);
192
+ if (!H) return { ...NEEDS, command };
193
+ const bytes = readFileSync(model);
194
+ let bake;
195
+ try { bake = H.bakeMesh(await modelTriangles(bytes), { cell: Number(cell), side: side === 'under' ? 'under' : 'top', ...(maxY !== undefined ? { maxY: Number(maxY) } : {}), ...(minY !== undefined ? { minY: Number(minY) } : {}) }); } catch (e) { return { ok: false, command, why: e.message }; }
196
+ const out = sidecar(model);
197
+ const before = readJson(out) ?? {};
198
+ const doc = { v: 1, model: basename(model), sha256: sha256(bytes), bake: H.packBake(bake), proxies: Array.isArray(before.proxies) ? before.proxies : [], mover: { ...DEFAULT_MOVER, ...(before.mover ?? {}) }, routes: Array.isArray(before.routes) ? before.routes : [] };
199
+ writeFileSync(out, `${JSON.stringify(doc)}\n`);
200
+ const checked = await checkOne(H, model, out);
201
+ const svg = out.replace(/\.json$/, '.svg');
202
+ writeFileSync(svg, collisionSvg(bake, { proxies: doc.proxies, routes: doc.routes, problems: checked.marks, title: doc.model }));
203
+ const cover = bake.covered.reduce((n, c) => n + c, 0);
204
+ const rel = (p) => relative(root, p).split('\\').join('/');
205
+ return {
206
+ ok: true, command, game: id, model: rel(model), file: rel(out), plan: rel(svg), cell: bake.cell, posts: [bake.nx, bake.nz], covered: cover, triangles: bake.source.triangles, checksum: bake.source.checksum, rows: checked.rows,
207
+ lines: [`Baked ${rel(model)}: ${bake.nx} x ${bake.nz} posts at ${bake.cell} m (${(bake.nx - 1) * bake.cell} m by ${(bake.nz - 1) * bake.cell} m), ${cover} over the model, from ${bake.source.triangles} triangles.`,
208
+ ` ${rel(out)} (authored "proxies", "mover" and "routes" in it are kept by a re-bake)`, ` ${rel(svg)} the plan at true scale, 40 px to the metre`,
209
+ ...checked.rows.map((r) => ` ${r.ok ? 'ok ' : 'FAIL'} ${r.label}${r.note ? `: ${r.note}` : ''}`)],
210
+ };
211
+ }
212
+
213
+ /** `collision check`: every collision file of a game (or one model's) against its model, its proxies and its routes. */
214
+ export async function collisionCheck(root, id, file) {
215
+ const command = 'collision check';
216
+ const dir = gameDir(root, id);
217
+ if (!dir) return { ok: false, command, why: `no game ${id} in this studio` };
218
+ const H = await heightfieldModules(root);
219
+ if (!H) return { ...NEEDS, command };
220
+ let files;
221
+ if (file) { const model = modelPath(dir, file); if (!model || !existsSync(sidecar(model))) return { ok: false, command, why: `${file} has no collision file: homie-studio collision bake ${id} ${file}` }; files = [sidecar(model)]; }
222
+ else files = filesUnder(dir, (n) => n.endsWith('.collision.json'));
223
+ const rel = (p) => relative(root, p).split('\\').join('/');
224
+ const models = []; const lines = [];
225
+ for (const f of files) {
226
+ const side = readJson(f);
227
+ const model = join(dirname(f), side?.model ?? `${basename(f, '.collision.json')}.glb`);
228
+ const one = await checkOne(H, model, f);
229
+ if (one.bake) writeFileSync(f.replace(/\.json$/, '.svg'), collisionSvg(one.bake, { proxies: one.side?.proxies ?? [], routes: one.side?.routes ?? [], problems: one.marks, title: basename(model) }));
230
+ models.push({ file: rel(f), ok: one.rows.every((r) => r.ok), rows: one.rows });
231
+ lines.push(`${rel(f)}`, ...one.rows.map((r) => ` ${r.ok ? 'ok ' : 'FAIL'} ${r.label}${r.note ? `: ${r.note}` : ''}`));
232
+ }
233
+ if (!files.length) lines.push(`No collision files in games/${id}. homie-studio collision bake ${id} <model.glb> makes one.`);
234
+ const failed = models.filter((m) => !m.ok);
235
+ return { ok: failed.length === 0, command, game: id, models, lines, ...(failed.length ? { why: `${lines.join('\n')}\n${failed.length} of ${models.length} collision file(s) need attention` } : {}) };
236
+ }
237
+
238
+ /** The CLI's `collision` command (bin/homie-studio.mjs hands it over whole). */
239
+ export async function collisionCommand(root, sub, positional, flags) {
240
+ const id = positional[2] ?? (listGames(root).length === 1 ? listGames(root)[0].id : null);
241
+ const usage = 'homie-studio collision bake <game> <model.glb> [--cell 0.25] [--side top|under] [--max-y <m>] | collision check <game> [<model.glb>]';
242
+ if (!id || !['bake', 'check'].includes(sub)) return { ok: false, command: 'collision', why: usage };
243
+ if (sub === 'check') return collisionCheck(root, id, positional[3]);
244
+ return collisionBake(root, id, positional[3], { cell: flags.get('cell') ?? 0.25, side: flags.get('side') ?? 'top', maxY: flags.get('max-y'), minY: flags.get('min-y') });
245
+ }
package/lib/decisions.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
  * Each change bumps the decision's `rev`; every asset records the revisions it was made under (assets/manifest.json
13
13
  * `made.under`), so a changed decision makes exactly those assets stale: listed and priced, never remade by itself.
14
14
  *
15
- * The file is private like CODEX.md: the remix source and a static game's served copy skip the whole codex/ folder.
15
+ * The file is private like CODEX.md: a static game's served copy skips the whole codex/ folder.
16
16
  * games/<id>/style.json (public, tiny) carries the palette and fonts a game, its landing and its title cards draw with.
17
17
  */
18
18
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
@@ -207,7 +207,7 @@ export const CATALOGUE = Object.freeze([
207
207
  ['cast.tiers', 'cast', 'What may each asset cost on a phone?', ['style.camera', 'game.devices'], (ctx) => {
208
208
  const big = ctx.players > 8;
209
209
  const v = { hero: { triangles: big ? 5000 : 8000, texturePx: 1024, kb: 1536, bones: 48 }, npc: { triangles: 3000, texturePx: 512, kb: 600, bones: 32 }, prop: { triangles: 1500, texturePx: 512, kb: 300 }, signature: { triangles: 5000, texturePx: 1024, kb: 800 }, kit: { triangles: 1000, texturePx: 512, kb: 200 }, scene: { drawCalls: 100, triangles: 150_000, textureMB: 48, firstPlayMB: 5 } };
210
- return { value: v, label: `hero ${v.hero.triangles.toLocaleString('en-US')} · prop 1,500 · scene 150k triangles, 100 draws, 48 MB textures, 5 MB first play`, why: `phone first${big ? `; rooms of ${ctx.players} make heroes lighter` : ''}`, options: [] };
210
+ return { value: v, label: `hero ${v.hero.triangles.toLocaleString('en-US')} · prop 1,500 · scene 150k triangles, 100 draws, 48 MB textures, 5 MB shipped payload`, why: `phone first${big ? `; rooms of ${ctx.players} make heroes lighter` : ''}`, options: [] };
211
211
  }],
212
212
  ['cast.variation', 'cast', 'How many looks per character from one model?', [], (ctx) => ({ value: { paletteSwaps: Math.min(4, Math.max(2, ctx.players)), partSwaps: false, decals: false }, label: '4 palette swaps of the hero', why: 'team colours without another model', options: [] })],
213
213
 
package/lib/demo.mjs CHANGED
@@ -8,8 +8,12 @@
8
8
  * studio only when the person asks for one: `homie-studio game new <id> --from gem-rush`.
9
9
  *
10
10
  * It reads the arcade's own public manifest (/.well-known/homie-studio.json) and its live rooms (/api/rooms), so the
11
- * links are the ones live right now; when the arcade does not answer, it names its usual first pick. Read-only.
11
+ * links are the ones live right now. When this session cannot reach the arcade (a sandbox with no network, as Codex
12
+ * runs commands by default; a proxy; the arcade slow), it still answers, with the arcade's standing first pick
13
+ * (DEMO_FALLBACK, built in) and `reached: false`: the person's own browser opens that link, not this session, so it
14
+ * is still a live game to try. Read-only.
12
15
  */
16
+ import { whyFailed } from './net.mjs';
13
17
  import { STUDIO_VERSION } from './version.mjs';
14
18
 
15
19
  export const DEMO_STUDIO = 'https://arcade.homie.rocks';
@@ -34,13 +38,17 @@ async function getJson(fetchFn, url, ms) {
34
38
 
35
39
  export async function demoGames({ fetchFn = globalThis.fetch, studio = DEMO_STUDIO, timeoutMs = 6000 } = {}) {
36
40
  const site = String(studio).replace(/\/+$/, '');
37
- let manifest = null; let rooms = null; let why = null;
41
+ let manifest = null; let rooms = null; let why = null; let refused = false;
38
42
  try {
39
43
  [manifest, rooms] = await Promise.all([
40
44
  getJson(fetchFn, `${site}/.well-known/homie-studio.json`, timeoutMs),
41
45
  getJson(fetchFn, `${site}/api/rooms`, timeoutMs).catch(() => null),
42
46
  ]);
43
- } catch (error) { why = error?.message ?? String(error); }
47
+ } catch (error) {
48
+ // The arcade answered with an error, or this session never got to it (no network here, a proxy, a timeout).
49
+ refused = /answered \d{3}$/.test(String(error?.message));
50
+ why = refused ? error.message : whyFailed(error, site).why;
51
+ }
44
52
  const live = new Map();
45
53
  for (const r of Array.isArray(rooms?.rooms) ? rooms.rooms : []) {
46
54
  const id = text(r?.game, 40);
@@ -63,7 +71,11 @@ export async function demoGames({ fetchFn = globalThis.fetch, studio = DEMO_STUD
63
71
  ok: true, command: 'demo',
64
72
  studio: { name: text(manifest?.name, 60) || 'Homie Arcade', site },
65
73
  pick, games: games.length ? games : [pick],
66
- ...(why ? { note: `the arcade did not answer just now (${why}); this is its usual first pick` } : {}),
74
+ // Whether this session read the arcade just now. Not reached: the pick is the built-in one, and its link is as good.
75
+ reached: !why,
76
+ ...(why ? { note: refused
77
+ ? `the arcade did not give its list just now (${why}), so this is its standing first pick`
78
+ : `this session could not reach the arcade just now (${why}), so this is its standing first pick; the Play link opens in the person's own browser all the same` } : {}),
67
79
  how: HOW, copy: COPY,
68
80
  };
69
81
  }