@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
@@ -19,17 +19,28 @@
19
19
  * It REFUSES to touch a Worker, database or bucket of that name that this
20
20
  * studio did not create (studio.json `cloudflare.created` is the record), so it
21
21
  * can never overwrite someone's existing site. `deploy --plan` says all of this
22
- * before anything happens and calls nothing.
22
+ * before anything happens and changes nothing.
23
+ *
24
+ * A studio on a CUSTOM DOMAIN shares that domain with whatever else its owner
25
+ * runs there. The plan and the deploy read the domain's Worker routes (a read,
26
+ * with Wrangler's own sign-in) and warn when another site's catch-all or
27
+ * wildcard covers the studio's hostname: that route is never the studio's to
28
+ * edit or remove (lib/routes.mjs, zoneCheck below).
23
29
  */
24
30
  import { spawn, spawnSync } from 'node:child_process';
25
31
  import { createHash } from 'node:crypto';
26
32
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
27
33
  import { join } from 'node:path';
34
+ import { cloudflareAuth } from './brain-probe.mjs';
28
35
  import { build } from './build.mjs';
36
+ import { builtGames, compareDeploy, deployWords, lastDeploy, lastDeployRecord, lockDeploy, recordDeploy } from './deploy-state.mjs';
37
+ import { listedHere, publish } from './directory.mjs';
29
38
  import { MAX_ASSET_BYTES, R2_COST, mediaPlan, r2OverOf, recordMove, sha256File, sizeOf } from './media.mjs';
39
+ import { whyFailed } from './net.mjs';
30
40
  import { repoOf } from './repo.mjs';
41
+ import { keptRoutes, readConfig, readZoneRoutes, routesOf, shadowedDomain, wideRouteRefusal, zoneFinding } from './routes.mjs';
31
42
  import { ensureLocalIgnored, ensureMigrations, migrationWord, wranglerConfig } from './scaffold.mjs';
32
- import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
43
+ import { LOCAL_STATE, configPath, domainOrigin, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
33
44
  import { projectsCloudflareEnv } from './projects-env.mjs';
34
45
 
35
46
  const ANSI = /\u001b\[[0-9;]*m/g;
@@ -90,12 +101,66 @@ export function explainCloudflare(out, accountId = null) {
90
101
  if (/maximum number of (D1 )?databases|database limit|too many databases/i.test(text)) {
91
102
  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
103
  }
104
+ // A route in wrangler.jsonc that another Worker of the zone already holds. Wrangler refuses, and so does the
105
+ // studio: that route is not the studio's to take (lib/routes.mjs).
106
+ if (/assigned to another worker|already assigned to routes/i.test(text)) {
107
+ const routes = [...text.matchAll(/^\s*-\s+(\S+)\s*$/gm)].map((m) => m[1]);
108
+ 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.` };
109
+ }
93
110
  if (/\b1027\b|exceeded .*daily request limit/i.test(text)) {
94
111
  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
112
  }
96
113
  return null;
97
114
  }
98
115
 
116
+ /** The studio's custom hostname ("play.example.com"), or null for a studio on its workers.dev address. */
117
+ export function customHost(studio) {
118
+ const cf = studio?.cloudflare ?? {};
119
+ const origin = domainOrigin(cf.domain) ?? (cf.url && !isWorkersDev(cf.url) ? domainOrigin(cf.url) : null);
120
+ return origin ? new URL(origin).hostname : null;
121
+ }
122
+
123
+ /**
124
+ * For a studio on a custom domain: what the domain's other Worker routes mean for it (lib/routes.mjs zoneFinding),
125
+ * read from Cloudflare with the sign-in Wrangler already has (lib/brain-probe.mjs cloudflareAuth: a token in the
126
+ * environment, else `wrangler auth token`). Null for a studio with no custom domain, which is asked nothing. It
127
+ * only reads; a list that cannot be read comes back as state 'unmeasured' with the reason. `config` stands in for
128
+ * wrangler.jsonc (a deploy that is about to add a route), `fetchFn` for the network in tests.
129
+ */
130
+ export async function zoneCheck(root, { studio = readStudio(root), config = readConfig(root), accountId = null, fetchFn = null } = {}) {
131
+ const hostname = customHost(studio);
132
+ if (!hostname) return null;
133
+ const worker = studio.cloudflare?.worker ?? null;
134
+ // The toolkit's own tests (HOMIE_STUDIO_WARM=0) with no stand-in network ask nobody, as readLiveSite does.
135
+ if (!fetchFn && process.env.HOMIE_STUDIO_WARM === '0') return zoneFinding({ hostname, worker, config, zone: { read: false, why: 'not asked' } });
136
+ const bin = wranglerBin(root);
137
+ let headers = null;
138
+ try {
139
+ const w = bin ? runner(root, accountId ? { CLOUDFLARE_ACCOUNT_ID: accountId } : {}) : null;
140
+ headers = await cloudflareAuth({
141
+ env: { ...process.env, ...projectsCloudflareEnv(root) }, bin, cwd: root,
142
+ exec: async (_bin, args) => { const r = w(args, { cwd: root }); return { code: r.code, stdout: r.stdout }; },
143
+ });
144
+ } catch { headers = null; }
145
+ const zone = await readZoneRoutes({ hostname, config, headers, ...(fetchFn ? { fetchFn } : {}) });
146
+ return zoneFinding({ hostname, worker, config, zone });
147
+ }
148
+
149
+ /** A zone finding as a deploy's result and its plan carry it: the state, the routes that are not the studio's, the one line. */
150
+ function zoneView(z) {
151
+ return {
152
+ state: z.state, host: z.host, domain: z.zone, foreign: z.foreign.map((r) => ({ pattern: r.pattern, worker: r.script, kind: r.kind })),
153
+ ...(z.state === 'foreign' || z.state === 'unmeasured' ? { route: z.route, line: z.line } : {}),
154
+ ...(z.warning ? { warning: z.warning } : {}), ...(z.why ? { why: z.why } : {}),
155
+ };
156
+ }
157
+
158
+ /** `deploy --plan` for a studio on a custom domain: the plan, and what the domain's other routes mean for it. */
159
+ export async function zonePlan(root, opts = {}) {
160
+ const z = await zoneCheck(root, opts);
161
+ return z ? zoneView(z) : null;
162
+ }
163
+
99
164
  /** What `deploy` will create and what it costs, from studio.json alone. It calls nothing. */
100
165
  export function deployPlan(root) {
101
166
  const studio = readStudio(root);
@@ -127,10 +192,29 @@ export function deployPlan(root) {
127
192
  };
128
193
  }
129
194
 
130
- export async function deploy(root, { log = () => {}, homie } = {}) {
195
+ /**
196
+ * `homie-studio deploy`. One at a time for a studio: it holds the studio's deploy lock (lib/deploy-state.mjs) from
197
+ * before it reads anything until it ends, however it ends. `fetchFn` is the toolkit's own tests' stand-in network.
198
+ */
199
+ export async function deploy(root, opts = {}) {
200
+ const lock = lockDeploy(root);
201
+ if (!lock.ok) return { ok: false, command: 'deploy', needs: lock.needs, why: lock.why };
202
+ // A deploy that is killed still lets go (a kill -9 cannot; the next deploy sees its process is gone).
203
+ const onExit = () => lock.release();
204
+ process.once('exit', onExit);
205
+ try { return await deployLocked(root, opts); } finally { process.off('exit', onExit); lock.release(); }
206
+ }
207
+
208
+ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRoute = false } = {}) {
131
209
  const studio = readStudio(root);
132
210
  const cf = { r2: null, created: [], ...studio.cloudflare };
133
211
  const created = new Set(cf.created ?? []);
212
+ // A wildcard or catch-all route in the studio's own config would hand other hostnames of the zone to this Worker:
213
+ // refused before Cloudflare is asked anything (lib/routes.mjs). Read once here; every rewrite below keeps the
214
+ // studio's own routes exactly as its owner wrote them.
215
+ const wide = wideRouteRefusal(readConfig(root), studio);
216
+ if (wide) return { ok: false, command: 'deploy', ...wide };
217
+ let routes = keptRoutes(root, studio);
134
218
  const who = whoami(root);
135
219
  if (!who) {
136
220
  if (cf.auth === 'stripe-projects') {
@@ -157,6 +241,25 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
157
241
  };
158
242
  const steps = [];
159
243
  const step = (what, extra = {}) => { steps.push({ what, ...extra }); log(what); };
244
+ const notes = [];
245
+
246
+ // A studio on a custom domain: is another site's catch-all or wildcard route on the same domain covering its
247
+ // hostname? Read (never written) and said before Wrangler changes anything: the repair that suggests itself,
248
+ // editing or removing that route, takes the other site down (lib/routes.mjs). `--own-route` adds the one line
249
+ // that is the studio's to add, and only when the domain's routes were read and show it is needed.
250
+ let zone = await zoneCheck(root, { studio, accountId, fetchFn });
251
+ if (zone) {
252
+ if (ownRoute && zone.state === 'foreign') {
253
+ routes = [...(routes ?? []), zone.route];
254
+ step(`added the studio's own exact-host route to "routes" in wrangler.jsonc: ${zone.line}. It covers ${zone.host} only, and every deploy keeps it; the other site's route was not touched.`, { route: zone.route });
255
+ const config = readConfig(root) ?? {};
256
+ zone = await zoneCheck(root, { studio, accountId, fetchFn, config: { ...config, routes: [...routesOf(config), zone.route] } });
257
+ } else if (ownRoute) {
258
+ step(`--own-route added nothing: ${zone.state === 'unmeasured' ? 'the domain\'s routes could not be read, so the domain\'s name in Cloudflare is not known for sure' : zone.state === 'taken' ? `${zone.host} is routed to another Worker, which only the person can change` : zone.state === 'own-route' ? 'the studio\'s own exact-host route is already there' : `no other route covers ${zone.host}`}.`);
259
+ }
260
+ if (zone.warning) { notes.push(zone.warning); step(`warning: ${zone.warning}`, { needs: 'cloudflare-routes', ...(zone.state === 'foreign' ? { route: zone.route } : {}) }); }
261
+ else if (zone.why) step(zone.why);
262
+ } else if (ownRoute) step('--own-route added nothing: this studio has no custom domain (studio.json cloudflare.domain).');
160
263
 
161
264
  // The first deploy says what it is about to create, and what it costs, before it creates anything: the person
162
265
  // reads it in the transcript whether or not the AI repeated it.
@@ -187,6 +290,14 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
187
290
  // A new studio goes live with its own Home ("First game coming soon") before it has a game.
188
291
  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
292
  for (const n of b.mediaNotes ?? []) step(`note: ${n.file}: ${n.why}`);
293
+ if (b.retired) step(`note: ${b.retired}`);
294
+ // Which games this deploy changes, against what the last deploy from this computer put live, each with the
295
+ // build's own hash for it (lib/deploy-state.mjs: one hash vocabulary). The record is written only once Wrangler has deployed.
296
+ const builtNow = builtGames(root, b.catalogue);
297
+ const delta = compareDeploy(builtNow, lastDeploy(root), lastDeployRecord(root));
298
+ 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 });
299
+ 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');
300
+ for (const id of delta.removed) step(`${id}: no longer in the build; it leaves the site with this deploy`);
190
301
 
191
302
  // The Worker: never one this studio did not make.
192
303
  if (!created.has(`worker:${cf.worker}`)) {
@@ -218,14 +329,15 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
218
329
  const r2 = storage;
219
330
  if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
220
331
 
221
- writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root) }));
332
+ writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes }));
333
+ if (routes) step(`kept the studio's own route${routes.length === 1 ? '' : 's'} in wrangler.jsonc: ${routes.map((r) => r.pattern).join(', ')}`);
222
334
  for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
223
335
  const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
224
336
  if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
225
337
  step('D1 migrations applied');
226
338
  // Workers AI (0.17.0): bound only when a server's AI guides think with it.
227
339
  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 }));
340
+ writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes }));
229
341
  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
342
  }
231
343
 
@@ -250,11 +362,45 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
250
362
  // The homie.rocks directory claim: the live site claims itself the first time its manifest is read (0.10.0), so
251
363
  // reading it once now is all it takes; nothing is stored by hand.
252
364
  const directory = homie || studio.homie?.directory || 'https://homie.rocks';
365
+ recordDeploy(root, builtNow);
253
366
  let claim = null;
254
367
  if (url) {
255
- claim = await warmClaim(url);
368
+ const read = await readLiveSite(url, fetchFn ? { fetchFn } : {});
369
+ claim = read.claim;
256
370
  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)');
371
+ else if (read.state === 'other' && !isWorkersDev(url)) {
372
+ // The studio's own domain answered as something else: say which route is needed (lib/routes.mjs). The deploy
373
+ // itself went through; the zone's other routes are never touched.
374
+ const s = shadowedDomain(readConfig(root), new URL(url).hostname, read);
375
+ notes.push(s.why);
376
+ step(s.why, s.route ? { needs: 'cloudflare-routes', route: s.route } : {});
377
+ } else if (read.state === 'unreachable') {
378
+ // This computer could not ask (its own name lookup, no connection): a network preflight failure, never a
379
+ // verdict on the site that was just deployed.
380
+ 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.`;
381
+ notes.push(said);
382
+ step(said, { preflight: read.preflight ?? 'network' });
383
+ } else step('the directory claim is not there yet (the site claims itself when the directory first reads it)');
384
+ }
385
+
386
+ // The deploy before this one came from a toolkit that still had remix, and left games open to be taken whole. No
387
+ // game's source is served any more, but the directory keeps its old copy of the studio (and its offer of those
388
+ // games) until it reads the site again, so ask it to, now. ONLY for a studio this computer listed (publish records
389
+ // it): going online never lists a studio, and a deploy never lists one either. Once: see compareDeploy.
390
+ let reread = null;
391
+ if (url && delta.withdrawn.length) {
392
+ const which = delta.withdrawn.join(', ');
393
+ const dir = String(directory).replace(/\/+$/, '');
394
+ if (listedHere(root, { site: url, directory: dir })) {
395
+ const again = await publish(root, { homie: dir, site: url, ...(fetchFn ? { fetchFn } : {}) });
396
+ reread = { ok: again.ok, games: delta.withdrawn, ...(again.ok ? {} : { why: again.why }), ...(again.publishes ? { publishes: again.publishes } : {}) };
397
+ step(again.ok
398
+ ? `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})` : ''}`
399
+ : `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`);
400
+ 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).`);
401
+ } else {
402
+ 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.`);
403
+ }
258
404
  }
259
405
 
260
406
  // studio.json keeps what is safe to commit: names, ids, the custom domain; never the workers.dev address.
@@ -264,7 +410,12 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
264
410
  return {
265
411
  ok: true, command: 'deploy', url, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
266
412
  ...(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 })),
413
+ 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 }; }),
414
+ ...(delta.removed.length ? { removed: delta.removed } : {}),
415
+ ...(routes ? { routes: routes.map((r) => r.pattern) } : {}),
416
+ ...(zone ? { zone: zoneView(zone) } : {}),
417
+ ...(notes.length ? { notes } : {}),
418
+ ...(reread ? { reread } : {}),
268
419
  songs: b.songs.map((slug) => ({ slug, page: url ? `${url}/music/${slug}/` : null })),
269
420
  videos: b.videos.map((slug) => ({ slug, page: url ? `${url}/videos/${slug}/` : null })),
270
421
  claim: Boolean(claim), directory,
@@ -301,6 +452,44 @@ export function repoVar(root) {
301
452
  return repo ? ['--var', `HOMIE_REPO:${repo}`] : [];
302
453
  }
303
454
 
455
+ /**
456
+ * The live site's manifest, read back after a deploy, and what the answer was:
457
+ * state 'studio' it answered as a studio; `claim` is its directory claim (null until it has one)
458
+ * state 'other' the address answered, and not as a studio (another Worker's page, a 404 that is not ours):
459
+ * `status`, and `said`, the first words of what it said
460
+ * state 'unreachable' this computer could not ask: `why`, and `preflight` ('dns' for a name it could not look up)
461
+ * state 'skipped' the toolkit's own tests (HOMIE_STUDIO_WARM=0) with no stand-in network
462
+ * Reading it is also what makes a 0.10.0 site claim itself. A fresh deploy can take a few seconds to answer
463
+ * everywhere, so a site that does not yet answer as a studio is asked three times.
464
+ */
465
+ export async function readLiveSite(site, { tries = 3, wait = 2500, fetchFn = null } = {}) {
466
+ if (!fetchFn && process.env.HOMIE_STUDIO_WARM === '0') return { state: 'skipped', claim: null };
467
+ const ask = fetchFn ?? globalThis.fetch;
468
+ const base = String(site).replace(/\/+$/, '');
469
+ let last = { state: 'unreachable', claim: null, why: 'no answer' };
470
+ for (let i = 0; i < tries; i++) {
471
+ try {
472
+ const res = await ask(`${base}/.well-known/homie-studio.json`, { headers: { accept: 'application/json', 'user-agent': 'homie-studio-deploy' }, signal: AbortSignal.timeout(15_000) });
473
+ const text = await res.text();
474
+ let body = null;
475
+ try { body = JSON.parse(text); } catch { body = null; }
476
+ const studio = res.ok && body && typeof body === 'object' && ('claim' in body || Array.isArray(body.games));
477
+ if (studio) {
478
+ const claim = /^[a-f0-9]{16,128}$/.test(body.claim ?? '') ? body.claim : null;
479
+ if (claim) return { state: 'studio', claim };
480
+ last = { state: 'studio', claim: null };
481
+ } 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) };
482
+ } catch (error) {
483
+ const w = whyFailed(error, base);
484
+ last = { state: 'unreachable', claim: null, why: w.why, code: w.code ?? null, ...(w.preflight ? { preflight: w.preflight } : {}) };
485
+ // A name this computer cannot look up does not start resolving in five seconds: say so now.
486
+ if (w.preflight === 'dns') return last;
487
+ }
488
+ if (i < tries - 1) await new Promise((r) => setTimeout(r, wait));
489
+ }
490
+ return last;
491
+ }
492
+
304
493
  /**
305
494
  * Read the live site's manifest once, which makes a 0.10.0 site claim itself in its directory. Returns the claim it
306
495
  * serves, or null (the directory did not answer, the site is not reachable yet, or it is a Preview). A fresh deploy
@@ -378,10 +567,13 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
378
567
  step(claim ? 'the site claimed itself in the directory' : 'no directory claim yet (the site claims itself when the directory first reads it)');
379
568
  let games = [];
380
569
  try { games = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')).games.map((g) => g.id); } catch { games = []; }
570
+ const hashes = builtGames(root, games);
571
+ for (const id of games) if (hashes[id]?.hash) step(`${id}: ${deployWords(hashes[id])}`);
381
572
  return {
382
573
  ok: true, command: 'deploy', ci: true, url, worker: names.worker, d1: names.d1, r2: null, steps, announced: [],
383
574
  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: [],
575
+ // A CI checkout has no earlier deploy to compare with: each game's content hash only (lib/deploy-state.mjs).
576
+ games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null, hash: hashes[id]?.hash ?? null })), songs: [], videos: [],
385
577
  claim: Boolean(claim),
386
578
  };
387
579
  }
@@ -429,7 +621,8 @@ export async function storageAdd(root, { log = () => {} } = {}) {
429
621
  const next = { ...readStudio(root), cloudflare: { ...readStudio(root).cloudflare, accountId, r2: bucket, created: [...created].sort() } };
430
622
  writeStudio(root, next);
431
623
  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) }));
624
+ // The studio's own routes stay through this rewrite too (lib/routes.mjs).
625
+ 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
626
  }
434
627
  log(`created R2 ${bucket}`);
435
628
  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