@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
package/lib/mcp-tools.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * THE LOCAL MCP SERVER'S TOOLS (`homie-studio mcp`, lib/mcp.mjs): the studio toolkit as MCP tools, so the Claude
3
3
  * desktop app (through the Homie extension), Claude Code or any MCP client builds games in the SAME chat that shows
4
4
  * the cards. Where a tool overlaps the remote Homie MCP (homie.rocks/mcp) it has the same name and the same input
5
- * shape: studio_scaffold, studio_card, game_make, game_remix, game_port, preview_run, studio_deploy,
5
+ * shape: studio_scaffold, studio_card, game_make, game_port, preview_run, studio_deploy,
6
6
  * studio_publish, build_open, build_progress, build_stop. The remote ones say what to run; these run it, here.
7
7
  *
8
8
  * Long work never holds a tool call: a check, a playtest, a deploy or an npm install starts as a job (lib/jobs.mjs)
@@ -28,7 +28,10 @@ import { cliJob, findNode, getJob, installJob, installed, jobView, runningJobs,
28
28
  import { Feed, currentId, publicFeed, readFeed, startProgress } from './progress.mjs';
29
29
  import { newStudio, slugify } from './scaffold.mjs';
30
30
  import { repoFromUrl, studioRepo } from './repo.mjs';
31
- import { GAME_ID, PACKAGE_ROOT, findStudio, listGames, readLocal, readStudio, siteUrl, workerDir } from './studio.mjs';
31
+ import { GAME_ID, PACKAGE_ROOT, findStudio, listGames, readLocal, readStudio, siteUrl } from './studio.mjs';
32
+ import { runningDev } from './dev.mjs';
33
+ import { deployWords } from './deploy-state.mjs';
34
+ import { beforeLine, partsForListing, publishesSoFar } from './directory.mjs';
32
35
  import { STUDIO_VERSION } from './version.mjs';
33
36
  import { compareVersions, whatsNew, whatsNewLines } from './changelog.mjs';
34
37
  import { pinnedVersion } from './upgrade.mjs';
@@ -37,6 +40,9 @@ import { detectLocalAi } from './local-ai.mjs';
37
40
  import { LAB_PORT, runningLab } from './lab.mjs';
38
41
  import { pictureFor } from './pictures.mjs';
39
42
  import { ART_UI, artToolDefs } from './art-tools.mjs';
43
+ // GAME PARTS (parts/PARTS.md): parts_find, part_add, part_new and part_share are lib/parts-tools.mjs.
44
+ import { PARTS_FIRST, partsToolDefs } from './parts-tools.mjs';
45
+ import { partsPlanLines } from './parts-build.mjs';
40
46
  import { FEEDBACK_APPS, FEEDBACK_KINDS, FEEDBACK_LIMITS, appOf, cleanNote, draftId, noteBlock, sendNote, withLine } from './feedback.mjs';
41
47
 
42
48
  export const UI = Object.freeze({
@@ -285,12 +291,9 @@ function coverData(dir) {
285
291
  return null;
286
292
  }
287
293
 
294
+ /** This studio's dev server when it really runs (lib/dev.mjs: a stale record, or a recycled process id, is not one). */
288
295
  function devOf(root) {
289
- try {
290
- const rec = JSON.parse(readFileSync(join(workerDir(root), '.wrangler', 'homie-dev.json'), 'utf8'));
291
- process.kill(rec.pid, 0);
292
- return { url: `http://127.0.0.1:${rec.port}`, port: rec.port, pid: rec.pid };
293
- } catch { return null; }
296
+ try { return runningDev(root); } catch { return null; }
294
297
  }
295
298
 
296
299
  function published(root, kind) {
@@ -419,6 +422,10 @@ function buildCard(ctx, root, found) {
419
422
  feed.error ? `Last error: ${feed.error}` : null,
420
423
  ...(run?.look ? [`Pictures of the site (file_read shows one): ${run.look.join(', ')}`] : []),
421
424
  ...jobs.filter((j) => j.state === 'failed').map((j) => `${j.label} failed: ${j.result?.why ?? j.tail.slice(-2).join(' ')}`),
425
+ // A deploy that went through: which games it changed with each one's content hash, and anything it had to say
426
+ // about the studio's domain or the directory (lib/cloudflare.mjs).
427
+ ...jobs.filter((j) => j.result?.command === 'deploy' && j.result?.ok && (j.result.games ?? []).some((g) => g.hash)).map((j) => `Deployed: ${j.result.games.map((g) => `${g.id} ${deployWords(g)}`).join('; ')}.`),
428
+ ...jobs.filter((j) => j.result?.command === 'deploy').flatMap((j) => (j.result.notes ?? []).map((n) => `Note: ${n}`)),
422
429
  feed.state === 'running' ? 'The card on screen follows it live; you need not call this again to watch it.' : null,
423
430
  ].filter(Boolean).join('\n');
424
431
  // A check, playtest or deploy inside a build the person opened (build_open) ends before the build does: say so.
@@ -857,7 +864,7 @@ export function toolDefs(ctx, avail = {}) {
857
864
  },
858
865
  {
859
866
  name: 'game_make', title: 'Make a multiplayer game',
860
- description: 'Make a game in the studio from a multiplayer starter, under the id you choose, to change into the person\'s game: gem-rush (an arena: every browser renders, one hosts the rules, bots fill empty seats, rounds restart), gem-rush-3d (the same arena in three.js, dressed with free CC0 models through @homie-rocks/studio/assets: the start for a 3D game), hero-rush-3d (the arena with animated CC0 heroes that run, jump and swing through @homie-rocks/studio/animate, a shared clip library per skeleton, a jump and a swing tuned in the Game Lab: the start for a 3D game with characters) or ember-vale (a hero who lasts for days, with cloud saves: for persistent games). In a new studio only once their game is planned (game_plan), or when they ask for a copy of a working starter; never as a first step. A planned game\'s codex stays.',
867
+ description: 'Make a game in the studio from a multiplayer starter, under the id you choose, to change into the person\'s game: gem-rush (an arena: every browser renders, one hosts the rules, bots fill empty seats, rounds restart), gem-rush-3d (the same arena in three.js, dressed with free CC0 models through @homie-rocks/studio/assets: the start for a 3D game), hero-rush-3d (the arena with animated CC0 heroes that run, jump and swing through @homie-rocks/studio/animate, a shared clip library per skeleton, a jump and a swing tuned in the Game Lab: the start for a 3D game with characters) or ember-vale (a hero who lasts for days, with cloud saves: for persistent games). In a new studio only once their game is planned (game_plan), or when they ask for a copy of a working starter; never as a first step. A planned game\'s codex stays. Before writing one of its systems from scratch, look for a piece of an existing game that does it (parts_find; part_add brings it in), and for the general mechanism in the @homie-rocks/* packages.',
861
868
  inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens): its address'), name: str('The game\'s display name'), from: str('Starter id (default gem-rush)'), ...STUDIO_ARG }, required: ['id', 'name'] },
862
869
  annotations: { title: 'Make a game', ...RW },
863
870
  run: async (a) => {
@@ -883,24 +890,6 @@ export function toolDefs(ctx, avail = {}) {
883
890
  return ok(`games/${a.id} is ${a.name}, from the ${a.from ?? 'gem-rush'} starter (${(r.result?.files ?? []).length} files).${got}${install} Change it in games/${a.id}/src/main.ts: file_read it once (a long file in two or three reads), decide every change, and make them in ONE file_edit with an edits list (never a call per change: twenty single edits use up a turn before the game is checked); then build and preview_run; check proves two browsers finish a round.`, { kind: 'game', ...r.result });
884
891
  },
885
892
  },
886
- {
887
- name: 'game_remix', title: 'Remix a shared game',
888
- description: 'Bring a game another studio shared in the Homie directory into this studio as a new game of its own. The original stays credited: game.json remixOf says "Remix of <game> by <studio>" with a link back, shown on the new game\'s landing and in its credits. A game whose owner\'s licence says no remix is refused.',
889
- inputSchema: { type: 'object', properties: { game: str('The directory game: "<studio site>/<game id>" or its play URL'), id: str('The new game id in this studio'), name: str('Optional: its new name'), ...STUDIO_ARG }, required: ['game', 'id'] },
890
- annotations: { title: 'Remix a game', ...RW, openWorldHint: true },
891
- run: async (a) => {
892
- const root = ctx.root(a.studio);
893
- let site; let gameId;
894
- try { const u = new URL(String(a.game ?? '')); site = u.origin; gameId = u.pathname.split('/').filter(Boolean)[0]; } catch { return fail('game is "<studio site>/<game id>" or its play URL'); }
895
- if (!/^https:\/\//.test(site) || !GAME_ID.test(String(gameId ?? '')) || !GAME_ID.test(String(a.id ?? ''))) return fail('game is "https://<studio site>/<game id>", and id a new lowercase id');
896
- const source = `${site}/games/${gameId}/source.json`;
897
- const r = await cli(ctx, root, `game remix ${a.id}`, ['game', 'remix', source, '--id', a.id, ...(a.name ? ['--name', String(a.name).slice(0, 60)] : [])]);
898
- if (!r.ended) return stillRunning(r.job, 'The remix');
899
- if (r.job.code !== 0) return fail(`Not remixed: ${whyOf(r.job)}`);
900
- const credit = r.result?.credit ? ` Its game.json credits the original: "${r.result.credit}"${r.result.page ? ` (${r.result.page})` : ''}, on its landing and in its credits; keep it.` : '';
901
- return ok(`games/${a.id} is a remix of ${site}/${gameId} (${(r.result?.files ?? []).length} files).${credit}`, { kind: 'game', ...r.result });
902
- },
903
- },
904
893
  {
905
894
  name: 'game_port', title: 'Make an existing game multiplayer',
906
895
  description: 'Read an existing single-player web game on this computer and grade how hard making it multiplayer will be (easy, medium, hard, not a fit, with its risks); with id, also bring it into the studio as games/<id>/ for the port. studio_guide { "topic": "port" } has the whole job; check (and studio_run ["port","check",…]) proves it.',
@@ -928,7 +917,7 @@ export function toolDefs(ctx, avail = {}) {
928
917
  },
929
918
  {
930
919
  name: 'game_plan', title: 'Plan a game (its Game Codex)',
931
- description: 'Step 4: start the game\'s Game Codex (games/<id>/CODEX.md, every section) and get the plan interview to run with the person: two or three questions a message, each with options and your pick, about game type and genre, style, devices, players and rooms, art and film, music and sound, and scope. A game is planned before it is made: with no game <id> yet, this starts its folder with only the codex. Fill CODEX.md from their answers in one call (file_write of the whole file, or one file_edit with an edits list), then game_codex shows it.',
920
+ description: 'Step 4: start the game\'s Game Codex (games/<id>/CODEX.md, every section) and get the plan interview to run with the person: two or three questions a message, each with options and your pick, about game type and genre, style, devices, players and rooms, art and film, music and sound, and scope. A game is planned before it is made: with no game <id> yet, this starts its folder with only the codex. Fill CODEX.md from their answers in one call (file_write of the whole file, or one file_edit with an edits list), then game_codex shows it. While planning, run `parts find` (the parts_find tool) for each system the game needs, so the plan starts from pieces of existing games and the @homie-rocks/* packages rather than from nothing; the codex\'s Built from section records what was found.',
932
921
  inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens)'), name: str('Its name, when the game is not made yet'), ...STUDIO_ARG }, required: ['id'] },
933
922
  annotations: { title: 'Plan a game', ...RW },
934
923
  run: async (a) => {
@@ -943,6 +932,7 @@ export function toolDefs(ctx, avail = {}) {
943
932
  return ok([
944
933
  made ? `Wrote games/${a.id}/CODEX.md (every section${made.planned ? '; the game is planned here before it is made' : ''}).` : `games/${a.id}/CODEX.md is there already: change it, never replace it.`,
945
934
  'Now the interview: two or three questions a message, each with concrete options and your pick, so "yes" is an answer. Say back what you heard in one line before the next. Stop after three or four rounds; "just build it" means fill the rest with your own choices and list them under Open questions. Then fill CODEX.md in one call (file_read it once; then file_write the whole file, or one file_edit whose edits list has every section) and show it with game_codex.',
935
+ '', PARTS_FIRST,
946
936
  ...(guide ? ['', guide] : []),
947
937
  ].join('\n'), { kind: 'plan', id: a.id, file: `games/${a.id}/CODEX.md`, created: Boolean(made) });
948
938
  },
@@ -977,7 +967,8 @@ export function toolDefs(ctx, avail = {}) {
977
967
  if (!r.ended) return stillRunning(r.job, 'The build');
978
968
  if (r.job.code !== 0) return fail(`The build failed: ${whyOf(r.job)}`);
979
969
  const g = r.result?.games ?? [];
980
- return ok(`Built ${g.length ? g.map((x) => `${x.id} (${Math.round(x.bytes / 1024)} KB)`).join(', ') : 'the home page (no game yet)'}.${devOf(root) ? ' Reload the preview to see it.' : ''}`, { kind: 'built', ...r.result });
970
+ // Each game's build hash, in the words the terminal prints: the hash a deploy reports and the live site answers with.
971
+ return ok(`Built ${g.length ? g.map((x) => `${x.id} (${Math.round(x.bytes / 1024)} KB${x.hash ? `, ${x.changed ?? 'new'}${x.changed && x.changed !== 'new' ? ' since the last build here' : ''}, build ${x.hash}` : ''})`).join(', ') : 'the home page (no game yet)'}.${devOf(root) ? ' Reload the preview to see it.' : ''}`, { kind: 'built', ...r.result });
981
972
  },
982
973
  },
983
974
  {
@@ -1008,7 +999,9 @@ export function toolDefs(ctx, avail = {}) {
1008
999
  run: async (a) => {
1009
1000
  const root = ctx.root(a.studio);
1010
1001
  const r = await cli(ctx, root, 'dev --stop', ['dev', '--stop'], { wait: 20_000 });
1011
- return ok(r.result?.stopped?.length ? 'Stopped the studio\'s site here.' : 'It was not running.', { kind: 'preview', stopped: r.result?.stopped ?? [] });
1002
+ // What a server stopped another way left behind is cleaned up and said (lib/dev.mjs).
1003
+ const note = r.result?.why && (r.result?.stale || r.result?.stopped?.length) ? ` ${r.result.why[0].toUpperCase()}${r.result.why.slice(1)}.` : '';
1004
+ return ok(`${r.result?.stopped?.length ? 'Stopped the studio\'s site here.' : 'It was not running.'}${note}`, { kind: 'preview', stopped: r.result?.stopped ?? [], ...(r.result?.stale ? { stale: true } : {}) });
1012
1005
  },
1013
1006
  },
1014
1007
  {
@@ -1102,7 +1095,7 @@ export function toolDefs(ctx, avail = {}) {
1102
1095
  if (!r.ended) return stillRunning(r.job, 'The deploy plan');
1103
1096
  if (r.job.code !== 0) return fail(whyOf(r.job));
1104
1097
  const p = r.result;
1105
- return ok([`What going online does for ${p.studio}:`, ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), `Cost: ${p.cost}`, `Sign-in: ${p.login}`, `The directory stores: ${p.directory?.stores ?? ''}`, 'The card on screen shows this plan (nothing is made yet): tell the person its gist in two or three lines, then go on.'].join('\n'), { kind: 'deploy-plan', ...p });
1098
+ return ok([`What going online does for ${p.studio}:`, ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), `Cost: ${p.cost}`, `Sign-in: ${p.login}`, `The directory stores: ${p.directory?.stores ?? ''}`, ...(p.parts ? partsPlanLines(p.parts) : []), 'The card on screen shows this plan (nothing is made yet): tell the person its gist in two or three lines, then go on.'].join('\n'), { kind: 'deploy-plan', ...p });
1106
1099
  }
1107
1100
  const studio = readStudio(root);
1108
1101
  return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy'] }] });
@@ -1126,15 +1119,22 @@ export function toolDefs(ctx, avail = {}) {
1126
1119
  },
1127
1120
  {
1128
1121
  name: 'studio_publish', title: 'List the studio in the Homie directory',
1129
- description: 'List a deployed studio\'s games, songs and videos in the homie.rocks directory, with their Play links (at most 12 games per studio in the beta).',
1130
- inputSchema: { type: 'object', properties: { site: str('Optional: the live site address (default: the one deploy got)'), ...STUDIO_ARG } },
1122
+ description: 'List a deployed studio\'s games, songs and videos in the homie.rocks directory, with their Play links (at most 12 games per studio in the beta). The beta also caps how many times a studio may publish in a day: the answer says how many are left when the directory gives the number, and before: true only says what this computer knows (how many it has sent today, what the directory last said was left) without publishing.',
1123
+ inputSchema: { type: 'object', properties: { site: str('Optional: the live site address (default: the one deploy got)'), before: { type: 'boolean', description: 'Only say how many publishes this computer has sent today and what the directory last said was left; publish nothing' }, ...STUDIO_ARG } },
1131
1124
  annotations: { title: 'List in the directory', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1132
1125
  run: async (a) => {
1133
1126
  const root = ctx.root(a.studio);
1127
+ // What this computer knows of the day's cap, before anything is sent (lib/directory.mjs).
1128
+ const soFar = beforeLine(publishesSoFar(root));
1129
+ // The parts in the games a listing shows, in the deploy plan's own lines (licences, credits, and plainly when
1130
+ // two cannot be combined): said before the studio is listed, and again with the answer.
1131
+ const partsNow = partsForListing(root);
1132
+ const partsSaid = partsPlanLines(partsNow, { at: 'publish' });
1133
+ if (a.before === true) return ok([`${soFar.replace(/^Publishing to the directory: /, 'Not published. ')} A publish from another computer or from the Homie connector is not counted here.`, ...partsSaid].join('\n'), { kind: 'publish-count', ...publishesSoFar(root), ...(partsSaid.length ? { parts: partsNow } : {}) });
1134
1134
  const r = await cli(ctx, root, 'publish', ['publish', ...(a.site ? ['--site', String(a.site)] : [])]);
1135
1135
  if (!r.ended) return stillRunning(r.job, 'Listing');
1136
- if (r.job.code !== 0) return fail(`Not listed: ${whyOf(r.job)}`);
1137
- return ok(`Listed: ${r.result?.studioPage ?? r.result?.directory ?? ''}\n${(r.result?.games ?? []).map((g) => ` ${g.name}: ${g.play}`).join('\n')}`, { kind: 'publish', ...r.result });
1136
+ if (r.job.code !== 0) return fail(`Not listed: ${whyOf(r.job)}`, r.result?.publishes ? { kind: 'publish', ok: false, needs: r.result.needs ?? null, publishes: r.result.publishes } : undefined);
1137
+ return ok(`Listed: ${r.result?.studioPage ?? r.result?.directory ?? ''}\n${(r.result?.games ?? []).map((g) => ` ${g.name}: ${g.play}`).join('\n')}${r.result?.publishes?.line ? `\n${r.result.publishes.line}` : ''}${partsSaid.length ? `\n${partsSaid.join('\n')}` : ''}`, { kind: 'publish', ...r.result });
1138
1138
  },
1139
1139
  },
1140
1140
  {
@@ -1184,7 +1184,7 @@ export function toolDefs(ctx, avail = {}) {
1184
1184
  },
1185
1185
  {
1186
1186
  name: 'studio_run', title: 'Run a studio command',
1187
- description: 'Any other homie-studio command in the studio, by its words (the guides name them): e.g. ["progress","stage","plan","done","--note","…"], ["progress","spend","0.40","--what","a cover"], ["look"], ["stats"], ["codex","link","<id>"], ["port","check","<id>","--url","…"], ["upgrade"], ["storage","add"]. Runs the studio\'s own pinned toolkit; long commands hand back a job. ["upgrade"] runs with THIS Homie\'s toolkit when the studio pins an older one (that is how a studio moves up to it): it says what is new since the studio\'s version and what would change, and changes nothing; ["upgrade","--apply"] only after the person agrees, then studio_install.',
1187
+ description: 'Any other homie-studio command in the studio, by its words (the guides name them): e.g. ["progress","stage","plan","done","--note","…"], ["progress","spend","0.40","--what","a cover"], ["look"], ["stats"], ["codex","link","<id>"], ["port","check","<id>","--url","…"], ["shoot","<id>","--preview"], ["build","--types"], ["assets","use","<id>","<asset>","unused"], ["upgrade"], ["storage","add"]. Runs the studio\'s own pinned toolkit; long commands hand back a job. ["upgrade"] runs with THIS Homie\'s toolkit when the studio pins an older one (that is how a studio moves up to it): it says what is new since the studio\'s version and what would change, and changes nothing; ["upgrade","--apply"] only after the person agrees, then studio_install.',
1188
1188
  inputSchema: { type: 'object', properties: { args: { type: 'array', items: { type: 'string' }, description: 'The command\'s words after `homie-studio`' }, ...STUDIO_ARG }, required: ['args'] },
1189
1189
  annotations: { title: 'Run a studio command', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1190
1190
  run: async (a) => {
@@ -1361,6 +1361,7 @@ export function toolDefs(ctx, avail = {}) {
1361
1361
  ];
1362
1362
  // Art direction, the cast, the starter library, generated props, checks, the lineup and the rights (lib/art-tools.mjs).
1363
1363
  tools.push(...artToolDefs(ctx, { ok, fail, cli, stillRunning, whyOf, needsInstall, ui, str, STUDIO_ARG, RO, RW, pictureFor, findNode, startJob, waitJob }));
1364
+ tools.push(...partsToolDefs(ctx, { ok, fail, str, STUDIO_ARG, RO, RW }));
1364
1365
  const topics = guideTopics(ctx);
1365
1366
  if (topics.length) {
1366
1367
  tools.push({
package/lib/mcp.mjs CHANGED
@@ -38,7 +38,7 @@ A new studio follows one checklist, in order and never ahead. Show it in your fi
38
38
  1. The studio (studio_scaffold): it has NO game; its home page says "First game coming soon".
39
39
  2. See a working game (game_demo): a live game on Homie Arcade with its Play link, nothing copied in. Copy a starter in (game_make) only if they ask.
40
40
  3. One small change from one sentence of theirs: to the copied game, or to the studio's home (site/theme.json colours, a tagline in studio.json, a first post in posts/). Then build and preview_run, and they reload.
41
- 4. Plan their game (game_plan): a short interview, two or three questions a message with options and your pick; then fill games/<id>/CODEX.md and show it (game_codex).
41
+ 4. Plan their game (game_plan): a short interview, two or three questions a message with options and your pick; then fill games/<id>/CODEX.md and show it (game_codex). Look for pieces first: the @homie-rocks/* packages for general mechanisms, and parts_find for pieces other studios shared (part_add brings one in).
42
42
  5. Build it: build_open, then game_make under the planned id (the codex stays), file edits (few and large: one file_edit carries every change to a file in its edits list, never a call per change), build, preview_run, check. The card follows every build.
43
43
  6. Playtest it (playtest), then put it online: studio_deploy with plan: true first (say what it creates and costs, free), cloudflare_login when not signed in (they approve once in their browser), studio_deploy, then studio_publish.
44
44
  If they ask for everything at once, show the list, make your own choices for steps 2 to 4 in one line each, and go on.
package/lib/net.mjs CHANGED
@@ -76,6 +76,14 @@ export function restartWithProxy({ argv = process.argv, env = process.env, execA
76
76
  });
77
77
  }
78
78
 
79
+ /**
80
+ * The error codes that mean "this computer could not look the name up" (the resolver's, never the site's). ONE list:
81
+ * every command that works against a site reads it through `whyFailed`, and the plugin's own preflight
82
+ * (skills/playtest/scripts/lib/preflight.mjs, which cannot import this file: it runs against whatever studio is
83
+ * installed) keeps the same list, held equal by a test that feeds both the same errors.
84
+ */
85
+ export const DNS_CODES = ['ENOTFOUND', 'EAI_AGAIN', 'EAI_NODATA', 'EAI_NONAME', 'ESERVFAIL'];
86
+
79
87
  const hostOf = (url) => { try { return new URL(String(url)).host; } catch { return String(url); } };
80
88
  const oneLine = (text, max = 200) => String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
81
89
 
@@ -104,7 +112,9 @@ export function whyFailed(error, url, { env = process.env, execArgv = process.ex
104
112
  if (refused) return { why: `the network proxy (${proxy?.variable ?? 'HTTPS_PROXY'}) answered ${refused[1]} when asked to connect to ${host}`, code: `PROXY_${refused[1]}` };
105
113
  const via = proxy ? (used ? ` (through the proxy in ${proxy.variable})` : ` (directly: this machine's proxy in ${proxy.variable} was not used${canUseProxy() ? '' : `, because Node.js ${process.versions.node} cannot use it; Node.js 22.21 or newer can`})`) : '';
106
114
  if (code === 'TIMEOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || code === 'ETIMEDOUT') return { why: `no answer from ${host} in time${via}`, code: code ?? 'TIMEOUT' };
107
- if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return { why: `could not look up ${host} (${code})${via}`, code };
115
+ // A name this process could not look up is this computer's network, never the site's fault: `preflight` says so
116
+ // to callers that would otherwise read "did not answer" as a broken site or game (see `reachSite`).
117
+ if (DNS_CODES.includes(code)) return { why: `could not look up ${host} (${code})${via}`, code, preflight: 'dns' };
108
118
  if (code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH') return { why: `could not connect to ${host} (${code})${via}`, code };
109
119
  if (/CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/i.test(code ?? '') || /certificate/i.test(words)) {
110
120
  return { why: `the TLS certificate ${host} presented is not trusted here (${code ?? words})${via}; when a proxy inspects traffic, its CA belongs in NODE_EXTRA_CA_CERTS`, code: code ?? 'CERT' };
@@ -132,6 +142,75 @@ export async function request(url, init = {}, { timeout = 10_000, fetchFn = glob
132
142
  } catch (error) { return { ok: false, ...whyFailed(error, url) }; }
133
143
  let body = null;
134
144
  try { body = text ? JSON.parse(text) : null; } catch { body = null; }
135
- if (!res.ok || body?.ok === false) return { ok: false, body, ...whyRefused(res, body, text, url) };
145
+ if (!res.ok || body?.ok === false) return { ok: false, body, headers: res.headers, ...whyRefused(res, body, text, url) };
136
146
  return { ok: true, status: res.status, body, headers: res.headers };
137
147
  }
148
+
149
+ /** Whether an address is this computer's own (a dev site): never a network question. */
150
+ export function isLoopback(url) {
151
+ try { const h = new URL(String(url)).hostname.toLowerCase(); return LOOPBACK.includes(h) || h.endsWith('.localhost'); } catch { return false; }
152
+ }
153
+
154
+ /*
155
+ * NODE'S DNS IS NOT THE BROWSER'S. On one computer the public studio opened in Chrome while Node's fetch() could
156
+ * not look its hostname up at all (a browser may use its own secure DNS, a VPN's or a cached answer; Node asks the
157
+ * system resolver and nothing else). A check against the public address stopped before it opened a game, and what
158
+ * it printed read as the site, or the game, being down.
159
+ *
160
+ * `reachSite` is the one preflight for a command that is about to work against a site: it asks once, and a failure
161
+ * says what kind it is.
162
+ * { ok: true, status } the site answered (any status is an answer)
163
+ * { ok: false, preflight: 'dns', why, instead, ... } THIS COMPUTER could not look the name up: a network
164
+ * preflight failure, with local testing offered instead
165
+ * { ok: false, preflight: 'network', why, instead, ... } no connection, a timeout, a certificate, a proxy refusal
166
+ * { ok: false, preflight: 'site', status, why } it answered, and the answer is an error (5xx)
167
+ * It never says a game is broken: it has not opened one.
168
+ */
169
+ export const LOCAL_INSTEAD = 'To test without this computer\'s network in the way: run the site here (`npx --no-install homie-studio dev`, as a background task) and give the command --url http://127.0.0.1:8787 (or leave --url out where the command finds the dev site itself).';
170
+
171
+ export async function reachSite(url, { fetchFn = globalThis.fetch, timeout = 10_000, path = '/', env = process.env, execArgv = process.execArgv } = {}) {
172
+ const base = String(url ?? '').replace(/\/+$/, '');
173
+ const host = hostOf(base);
174
+ let res;
175
+ try { res = await fetchFn(`${base}${path}`, { method: 'GET', redirect: 'manual', headers: { accept: '*/*' }, signal: AbortSignal.timeout(timeout) }); } catch (error) {
176
+ const w = whyFailed(error, base, { env, execArgv });
177
+ if (isLoopback(base)) return { ok: false, preflight: 'local', code: w.code ?? null, why: `${base} does not answer (${w.code ?? 'no connection'}): the site is not running here. Start it (npx --no-install homie-studio dev, as a background task) or check the port.` };
178
+ if (w.preflight === 'dns') {
179
+ return {
180
+ ok: false, preflight: 'dns', code: w.code, needs: 'network-preflight',
181
+ why: `network preflight failed, before any page or game was opened: this computer's Node.js ${w.why}. A browser on the same computer may still open ${host} (browsers can use their own DNS), so this says nothing about the site or the game: it is this computer's name lookup.`,
182
+ instead: LOCAL_INSTEAD,
183
+ };
184
+ }
185
+ return { ok: false, preflight: 'network', code: w.code ?? null, needs: w.needs ?? 'network-preflight', why: `network preflight failed, before any page or game was opened: ${w.why}. That is the connection from this computer to ${host}, not a result about the game.`, instead: LOCAL_INSTEAD };
186
+ }
187
+ try { await res.body?.cancel?.(); } catch { /* nothing to drop */ }
188
+ if (res.status >= 500) return { ok: false, preflight: 'site', status: res.status, why: `${host} answered ${res.status}: the site itself is failing (not this computer's network).` };
189
+ return { ok: true, status: res.status };
190
+ }
191
+
192
+ /**
193
+ * WHAT A COMMAND SAYS WHEN `reachSite` DID NOT FIND THE PLAY PAGE: one classification and one sentence for `check`,
194
+ * `perf`, `shoot` and `port check` (and, word for word in its DNS case, the playtest and perf skills' scripts), so
195
+ * the same failure is never "the site does not answer" from one command and "network preflight" from the next.
196
+ *
197
+ * null the page answered: go on
198
+ * preflight 'local' this computer's own address has nothing listening: start the site
199
+ * preflight 'site' it answered, with an error (a 5xx, or a 4xx: a wrong address or game id)
200
+ * preflight 'dns' | 'network' BLOCKED: this computer could not ask. Nothing was measured, nothing is said
201
+ * about the site or the game, and local testing is offered instead.
202
+ *
203
+ * A command that only drives a browser (check, shoot) may still try on a BLOCKED one, because a browser can open a
204
+ * site this process cannot look up; if the browser then fails too, this is what it reports.
205
+ */
206
+ export function siteRefusal(reach, { command, play }) {
207
+ if (reach?.ok) {
208
+ return reach.status >= 400 ? { ok: false, command, preflight: 'site', status: reach.status, why: `${play} answered ${reach.status}: start the site (npm run dev, as a background task) or check the address and the game's id` } : null;
209
+ }
210
+ if (reach?.preflight === 'local') return { ok: false, command, preflight: 'local', why: `${play} does not answer: start the site (npm run dev, as a background task that outlives this command) or check the address` };
211
+ if (reach?.preflight === 'site') return { ok: false, command, preflight: 'site', status: reach.status ?? null, why: reach.why };
212
+ return {
213
+ ok: false, command, blocked: true, verdict: 'BLOCKED', preflight: reach?.preflight ?? 'network', ...(reach?.code ? { code: reach.code } : {}), needs: reach?.needs ?? 'network-preflight', instead: reach?.instead ?? LOCAL_INSTEAD,
214
+ why: `BLOCKED ${reach?.why ?? 'network preflight failed, before any page or game was opened.'} Nothing was measured. ${reach?.instead ?? LOCAL_INSTEAD}`,
215
+ };
216
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * THE NODE.JS THIS TOOLKIT NEEDS, SAID BEFORE ANYTHING ELSE RUNS.
3
+ *
4
+ * `@homie-rocks/studio` needs Node.js 22 or newer (package.json `engines`), and npm only warns about that. On an
5
+ * older Node the commands did not say so: `homie-studio dev` under Node 20 ended with no word at all, and the person
6
+ * was left to guess. So the CLI imports this file FIRST. An ES module's imports are evaluated in order, so this
7
+ * runs before any other file of the toolkit does, and on a Node that is too old the command ends here, with the
8
+ * version it found, the version it needs and how to get it.
9
+ *
10
+ * This file is read by the old Node it complains about, so it stays plain: no syntax or built-in newer than Node 14.
11
+ * (A file the old Node cannot even parse fails before any code runs; nothing in this package's own code is known to
12
+ * do that on Node 18 or 20, and if one ever does, Node prints its own syntax error, which is not silent either.)
13
+ */
14
+ export const NODE_MIN = 22;
15
+
16
+ /** What is wrong with this Node.js version for the toolkit, as one sentence, or null when it is new enough. */
17
+ export function nodeProblem(version) {
18
+ var found = String(version === undefined ? process.versions.node : version);
19
+ var major = Number(found.split('.')[0]);
20
+ if (major >= NODE_MIN) return null;
21
+ return 'homie-studio needs Node.js ' + NODE_MIN + ' or newer, and this is Node.js ' + (found || 'of an unknown version') + '. '
22
+ + 'Install Node.js ' + NODE_MIN + ' (https://nodejs.org, or `nvm install ' + NODE_MIN + '` / `fnm install ' + NODE_MIN + '`), '
23
+ + 'make it the one this terminal runs (`node --version` says which), then run the command again. Nothing was started or changed.';
24
+ }
25
+
26
+ /**
27
+ * End the command now when this Node.js is too old. `--json` gets the same refusal as JSON on stdout, like every
28
+ * other refusal of the CLI; a person gets one line on stderr. Exit status 1 either way.
29
+ */
30
+ export function requireNode(version, argv) {
31
+ var why = nodeProblem(version);
32
+ if (!why) return;
33
+ var args = argv === undefined ? process.argv.slice(2) : argv;
34
+ if (args.indexOf('--json') >= 0) process.stdout.write(JSON.stringify({ ok: false, needs: 'node', why: why }, null, 2) + '\n');
35
+ else process.stderr.write('homie-studio: ' + why + '\n');
36
+ process.exit(1);
37
+ }
38
+
39
+ // HOMIE_STUDIO_NODE: the toolkit's own tests say which version to judge (no machine here has an old Node to run).
40
+ requireNode(process.env.HOMIE_STUDIO_NODE || undefined);
package/lib/office.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  * room_kick, room_announce, room_close, game_launch_state)
9
9
  * homie-studio office announce "<text>" [--game <id>] [--room <code>] [--seconds 30]
10
10
  * homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--days <n>]
11
- * homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]
11
+ * homie-studio office launch <game> private|invite|public [--max <n>|game]
12
12
  * homie-studio office kick <game> <room> <seat number | name> [--minutes 10]
13
13
  * homie-studio office mute <game> <room> <seat number | name> [--minutes 10] [--off]
14
14
  * homie-studio office close <game> <room> [--minutes 10] [--reopen]
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * THE PROOF OF OWNERSHIP IS THE STUDIO'S OWN CLOUDFLARE LOGIN, as for the stats: a key is minted here and only its
18
18
  * SHA-256 goes into the studio's D1. Looking, announcing and inviting happen at once. Kick, mute, closing a room and
19
- * a game's launch state, remix switch or room size are only ASKED for here: the answer is a one-time sign-in link
19
+ * a game's launch state or room size are only ASKED for here: the answer is a one-time sign-in link
20
20
  * that opens the ask in the owner's own browser, where one tap does it (the AI that ran this cannot).
21
21
  */
22
22
  import { readStudio } from './studio.mjs';
@@ -113,11 +113,11 @@ export async function officeInvite(root, game, { url, label, uses, count, days,
113
113
  return { ok: true, command: 'office invite', game, launch: r.launch, invites: r.invites, note: server || r.launch === 'invite' ? null : `${game} is ${r.launch} now: invites let people in once it is an invite-only beta (homie-studio office launch ${game} invite).` };
114
114
  }
115
115
 
116
- export async function officeLaunch(root, game, state, { url, remixable, max } = {}) {
116
+ export async function officeLaunch(root, game, state, { url, max } = {}) {
117
117
  if (!GAME.test(String(game ?? '')) || (state !== undefined && state !== null && !['private', 'invite', 'public'].includes(state))) {
118
- return { ok: false, command: 'office launch', why: 'usage: homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]' };
118
+ return { ok: false, command: 'office launch', why: 'usage: homie-studio office launch <game> private|invite|public [--max <n>|game]' };
119
119
  }
120
- const body = { game, ...(state ? { launch: state } : {}), ...(remixable !== undefined ? { remix: remixable === 'on' || remixable === true } : {}), ...(max !== undefined ? { maxPlayers: max === 'game' ? null : Number(max) } : {}) };
120
+ const body = { game, ...(state ? { launch: state } : {}), ...(max !== undefined ? { maxPlayers: max === 'game' ? null : Number(max) } : {}) };
121
121
  const r = await withKey(root, url, (call) => call('/_studio/api/game', body));
122
122
  return askedFor(root, url, r, 'office launch');
123
123
  }
@@ -181,7 +181,7 @@ export function officeLines(r) {
181
181
  const now = r.now ?? Date.now();
182
182
  const mins = (ms) => `${Math.max(0, Math.round(ms / 60000))} min`;
183
183
  for (const g of r.games ?? []) {
184
- lines.push(` ${g.name} (${g.id}): ${g.launch}${g.launch === 'public' ? '' : ' (not listed)'}, ${g.remix ? 'remixable' : 'source closed'}, rooms of ${g.maxPlayers}${g.maxSet ? ` (the game's own: ${g.seats})` : ''}`);
184
+ lines.push(` ${g.name} (${g.id}): ${g.launch}${g.launch === 'public' ? '' : ' (not listed)'}, rooms of ${g.maxPlayers}${g.maxSet ? ` (the game's own: ${g.seats})` : ''}`);
185
185
  for (const i of (g.invites ?? []).filter((x) => x.open)) lines.push(` invite ${i.code}${i.label ? ` (${i.label})` : ''}: ${i.uses}${i.maxUses ? ` of ${i.maxUses}` : ''} used · ${i.link}`);
186
186
  if (!g.rooms.length) lines.push(' no live rooms');
187
187
  for (const rm of g.rooms) {
package/lib/optimise.mjs CHANGED
@@ -6,6 +6,10 @@
6
6
  * bottom centre, scaled to its size in metres, welded and simplified to its triangle budget (meshoptimizer),
7
7
  * pictures resized and re-encoded as WebP (sharp), meshopt geometry, nothing unused. Raw files are never
8
8
  * changed; the result says what each step did, before and after.
9
+ * `lo: { ratio }` also writes `<name>_lo.glb` beside it: the same model through the same steps (the same
10
+ * height, pivot, palette and pictures, so the two line up) with `ratio` of the shipped triangles, for the far
11
+ * band of @homie-rocks/render's LodProps. A rigged model is skipped (its skin is one mesh with its bones).
12
+ * The result's `lo` is { path, triangles, bytes, sha256 }, or null with the reason in `warnings`.
9
13
  * inspectModel(input) triangles, vertices, draw calls, materials, pictures and their GPU memory,
10
14
  * bones and clips, the bounding box in metres, the Khronos glTF-Validator's errors, and the file's own safety
11
15
  * check (assets/safety.mjs).
@@ -312,7 +316,10 @@ const linearToSrgb255 = (v) => { const c = Math.max(0, Math.min(1, v)); return M
312
316
  * usually says metallic 1 and leaves the rest to its texture)
313
317
  * Returns { ok, glb (the bytes), bytes (their count), sha256, out, before, after, ops, warnings }.
314
318
  */
315
- export async function optimiseModel(input, { out = null, triangles = 1500, texture = 512, height = null, quality = 82, rigged = false, metal = false, log = () => {} } = {}) {
319
+ /** Where a model's low copy goes: `<name>_lo.glb` beside it. */
320
+ export const loPathOf = (out) => String(out).replace(/(\.glb)?$/i, '_lo.glb');
321
+
322
+ export async function optimiseModel(input, { out = null, triangles = 1500, texture = 512, height = null, quality = 82, rigged = false, metal = false, lo = null, log = () => {} } = {}) {
316
323
  const t = await modelTools();
317
324
  const { fn, mo, sharp, io } = t;
318
325
  const raw = await selfContained(input, LIMITS.import);
@@ -410,7 +417,35 @@ export async function optimiseModel(input, { out = null, triangles = 1500, textu
410
417
  if (!safe.ok) warnings.push(...safe.problems.map((p) => `the result: ${p}`));
411
418
  if (out) { mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, bytes); }
412
419
  log(`optimised: ${before.triangles} -> ${after.triangles} triangles, ${Math.round(raw.byteLength / 1024)} -> ${Math.round(bytes.byteLength / 1024)} KB`);
413
- return { ok: safe.ok, out, glb: bytes, bytes: bytes.byteLength, sha256: sha256(bytes), rawBytes: raw.byteLength, rawSha256: sha256(raw), before, after, ops, warnings, tool: `@gltf-transform/functions ${await toolVersion()}` };
420
+
421
+ /*
422
+ * THE LOW COPY (`lo: { ratio }`), for the far band of a prop drawn at two levels of detail (@homie-rocks/render
423
+ * LodProps: the full model near, this one far). It is the SAME raw file through the SAME steps with a smaller
424
+ * triangle budget, never a second recipe: the same scale to the card's height, the same pivot, the same palette
425
+ * and pictures, so the two models sit on each other exactly and share a material. A rigged model has none: its
426
+ * skin is one mesh bound to its bones, and a character is not scattered by the hundred.
427
+ */
428
+ let low = null;
429
+ if (lo) {
430
+ const ratio = Number(lo.ratio);
431
+ if (!(ratio > 0 && ratio < 1)) warnings.push(`no low copy: lo.ratio is the share of the shipped triangles to keep, between 0 and 1 (0.2 keeps a fifth), not ${JSON.stringify(lo.ratio)}`);
432
+ else if (hasRig) warnings.push('no low copy: a rigged model is one skinned mesh with its bones, and is not drawn at two levels of detail');
433
+ else {
434
+ const want = Math.max(4, Math.round(after.triangles * ratio));
435
+ const loOut = out ? loPathOf(out) : null;
436
+ const r = await optimiseModel(raw, { out: null, triangles: want, texture, height, quality, rigged: false, metal });
437
+ if (r.after.triangles >= after.triangles) warnings.push(`no low copy: the shape would not simplify below its ${after.triangles} triangles`);
438
+ else if (!r.ok) warnings.push(`no low copy: ${r.warnings.join('; ')}`);
439
+ else {
440
+ if (loOut) writeFileSync(loOut, r.glb);
441
+ low = { path: loOut, triangles: r.after.triangles, bytes: r.bytes, sha256: r.sha256, ratio, glb: r.glb };
442
+ ops.push(`low copy at ${ratio} (${after.triangles} to ${r.after.triangles} triangles)`);
443
+ if (r.after.triangles > want * 1.25) warnings.push(`the low copy has ${r.after.triangles} triangles, not the ${want} asked (ratio ${ratio}): the shape resists`);
444
+ log(`low copy: ${r.after.triangles} triangles, ${Math.round(r.bytes / 1024)} KB`);
445
+ }
446
+ }
447
+ }
448
+ return { ok: safe.ok, out, glb: bytes, bytes: bytes.byteLength, sha256: sha256(bytes), rawBytes: raw.byteLength, rawSha256: sha256(raw), before, after, ops, warnings, ...(lo ? { lo: low } : {}), tool: `@gltf-transform/functions ${await toolVersion()}` };
414
449
  }
415
450
 
416
451
  let version = null;
@@ -427,8 +462,8 @@ async function toolVersion() {
427
462
  }
428
463
 
429
464
  /**
430
- * A grey box .glb of `size` metres (x, y, z), its pivot at the bottom centre: what a remix gets in place of an asset
431
- * it may not carry, and what a refused model is drawn as.
465
+ * A grey box .glb of `size` metres (x, y, z), its pivot at the bottom centre: what a refused model is drawn as,
466
+ * and a stand-in for one that is not there yet.
432
467
  */
433
468
  export async function placeholderGlb(size = [0.5, 0.5, 0.5], { name = 'placeholder', colour = [0.54, 0.56, 0.6, 1] } = {}) {
434
469
  const { core, io } = await modelTools();