@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/art-cli.mjs CHANGED
@@ -21,9 +21,16 @@
21
21
  * assets add <id> <library item> [--as <id>] [--height <m>] [--card "Items/Berry"]
22
22
  * assets add <id> --file <model> --license <kind> [--attribution "…"] [--kind prop] [--as <id>] [--height <m>]
23
23
  * [--triangles <n>] [--texture <px>] [--route imported|generated] [--steps <steps.json>] [--concept <image>]
24
- * assets optimise <in> --out <file> [--triangles 1500] [--texture 512] [--height <m>]
25
- * assets check <id> budgets, validator, licences, staleness, scene totals, big files
26
- * assets lineup <id> the lineup, silhouettes and palette drift (pictures in .studio/art/<id>/)
24
+ * assets optimise <in> --out <file> [--triangles 1500] [--texture 512] [--height <m>] [--lo 0.2]
25
+ * --lo <ratio> (here and on `assets add --file`): also write <name>_lo.glb, the same model
26
+ * at that share of its triangles, for the far band of a prop drawn at two levels of detail
27
+ * assets check <id> budgets, validator, licences, staleness, big files, and an INVENTORY ESTIMATE of the scene
28
+ * (the recorded files' own numbers; the running game is measured by the playtest and perf)
29
+ * assets lineup <id> [--scope inventory|cast|environment]
30
+ * the lineup, silhouettes and palette drift (pictures in .studio/art/<id>/): the whole
31
+ * inventory, and apart from it what is in play (the cast) and the scenery
32
+ * assets use <id> <asset> cast|prop|environment|unused|auto
33
+ * what the game does with an asset now (unused: kept in the folder, drawn by nothing)
27
34
  * assets review <id> --score <1-10> [--outliers a,b] [--note "…"]
28
35
  * assets rights <id> RIGHTS.md and credits.json written from the manifest
29
36
  * assets stale <id> assets made under an older decision
@@ -40,7 +47,7 @@
40
47
  */
41
48
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
42
49
  import { join, resolve } from 'node:path';
43
- import { licenceProblems, readManifest, removeAsset, rightsMarkdown, syncCredits, writeRights } from './asset-manifest.mjs';
50
+ import { USAGES, licenceProblems, readManifest, removeAsset, rightsMarkdown, setUsage, syncCredits, usageOf, writeRights } from './asset-manifest.mjs';
44
51
  import { CATALOGUE_IDS, PHASES, blastRadius, decisionRows, derivedPrompt, initDecisions, lockDecision, oneLine, phaseProgress, pickDirection, readDecisions, setDecision, staleAssets, steerDecision, unlockDecision } from './decisions.mjs';
45
52
  import { listGames } from './studio.mjs';
46
53
  import { checkLines } from './asset-check.mjs';
@@ -98,7 +105,7 @@ export function writeArtSummary(root, id) {
98
105
  stale: doc ? staleAssets(doc, manifest).map((s) => s.id) : [],
99
106
  licence: licenceProblems(root, id, manifest),
100
107
  spend: artSpend(root, id),
101
- check: check ? { ok: check.ok, at: check.at, totals: check.totals, budgets: { drawCalls: check.budgets?.drawCalls, triangles: check.budgets?.triangles, textureMB: check.budgets?.textureMB, firstPlayMB: check.budgets?.firstPlayMB }, failing: check.rows.filter((r) => !r.ok).map((r) => r.id) } : null,
108
+ check: check ? { ok: check.ok, at: check.at, totals: check.totals, budgets: { drawCalls: check.budgets?.drawCalls, triangles: check.budgets?.triangles, textureMB: check.budgets?.textureMB, shippedPayloadMB: check.budgets?.shippedPayloadMB ?? check.budgets?.firstPlayMB, firstPlayMB: check.budgets?.firstPlayMB }, scope: check.scope ?? 'inventory-estimate', failing: check.rows.filter((r) => !r.ok).map((r) => r.id) } : null,
102
109
  lineup: lineup ? { at: lineup.at, flagged: lineup.flagged, images: lineup.images } : null,
103
110
  // The characters and their clips (lib/characters.mjs animPlan): the Art tab's Characters, /cast and /clips.
104
111
  ...(() => {
@@ -170,7 +177,7 @@ export async function styleCommand(root, sub, positional, flags, { log = () => {
170
177
  }
171
178
 
172
179
  export async function assetsCommand(root, sub, positional, flags, { log = () => {} } = {}) {
173
- const known = ['list', 'find', 'add', 'redo', 'optimise', 'optimize', 'check', 'lineup', 'review', 'rights', 'stale', 'remove'];
180
+ const known = ['list', 'find', 'add', 'redo', 'optimise', 'optimize', 'check', 'lineup', 'review', 'rights', 'stale', 'remove', 'use'];
174
181
  const verb = known.includes(sub) ? (sub === 'optimize' ? 'optimise' : sub) : 'list';
175
182
  const args = known.includes(sub) ? positional.slice(2) : positional.slice(1);
176
183
  if (verb === 'find') {
@@ -181,17 +188,18 @@ export async function assetsCommand(root, sub, positional, flags, { log = () =>
181
188
  }
182
189
  if (verb === 'optimise') {
183
190
  const { optimiseModel } = await import('./optimise.mjs');
184
- if (!args[0] || !flags.get('out')) return { ok: false, command: 'assets optimise', why: 'usage: homie-studio assets optimise <model.glb> --out <file.glb> [--triangles 1500] [--texture 512] [--height <m>]' };
185
- const r = await optimiseModel(resolve(args[0]), { out: resolve(String(flags.get('out'))), triangles: Number(flags.get('triangles') ?? 1500), texture: Number(flags.get('texture') ?? 512), height: flags.has('height') ? Number(flags.get('height')) : null, rigged: flags.has('rigged'), log });
186
- const { glb, ...rest } = r;
187
- return { ...rest, command: 'assets optimise' };
191
+ if (!args[0] || !flags.get('out')) return { ok: false, command: 'assets optimise', why: 'usage: homie-studio assets optimise <model.glb> --out <file.glb> [--triangles 1500] [--texture 512] [--height <m>] [--lo 0.2]' };
192
+ const r = await optimiseModel(resolve(args[0]), { out: resolve(String(flags.get('out'))), triangles: Number(flags.get('triangles') ?? 1500), texture: Number(flags.get('texture') ?? 512), height: flags.has('height') ? Number(flags.get('height')) : null, rigged: flags.has('rigged'), ...(flags.has('lo') ? { lo: { ratio: flags.get('lo') === true ? 0.2 : Number(flags.get('lo')) } } : {}), log });
193
+ const { glb, lo, ...rest } = r;
194
+ // The low copy's bytes stay out of the answer too: its path and numbers are what a person reads.
195
+ return { ...rest, ...(lo !== undefined ? { lo: lo ? { path: lo.path, triangles: lo.triangles, bytes: lo.bytes, sha256: lo.sha256 } : null } : {}), command: 'assets optimise' };
188
196
  }
189
197
  const id = gameOf(root, args[0], `assets ${verb}`);
190
198
  let r;
191
199
  if (verb === 'list') {
192
200
  const m = readManifest(root, id);
193
201
  const doc = readDecisions(root, id);
194
- r = { ok: true, command: 'assets', id, assets: m.assets.map((a) => ({ id: a.id, kind: a.kind, route: a.route, tier: a.tier ?? null, card: a.card ?? null, license: a.license?.kind ?? null, remix: a.license?.remix ?? null, file: (a.files ?? []).find((f) => f.role === 'model')?.path ?? null, tris: a.measured?.tris ?? null, kb: a.measured?.glbKB ?? null, usd: +(a.made?.steps ?? []).reduce((n, s) => n + (Number(s.usd) || 0), 0).toFixed(3) })), stale: doc ? staleAssets(doc, m).map((s) => s.id) : [], spend: artSpend(root, id) };
202
+ r = { ok: true, command: 'assets', id, assets: m.assets.map((a) => ({ id: a.id, kind: a.kind, usage: usageOf(a), route: a.route, tier: a.tier ?? null, card: a.card ?? null, license: a.license?.kind ?? null, file: (a.files ?? []).find((f) => f.role === 'model')?.path ?? null, tris: a.measured?.tris ?? null, kb: a.measured?.glbKB ?? null, usd: +(a.made?.steps ?? []).reduce((n, s) => n + (Number(s.usd) || 0), 0).toFixed(3) })), stale: doc ? staleAssets(doc, m).map((s) => s.id) : [], spend: artSpend(root, id) };
195
203
  } else if (verb === 'add') {
196
204
  const rigFlags = { keep: flags.has('keep') ? list(flags.get('keep')) : null, verbs: list(flags.get('verbs')), clipsFrom: flags.get('clips-from') ?? null, texture: flags.get('texture') ?? null, triangles: flags.get('triangles') ?? null };
197
205
  const characterKind = ['character', 'creature'].includes(String(flags.get('kind') ?? ''));
@@ -206,7 +214,7 @@ export async function assetsCommand(root, sub, positional, flags, { log = () =>
206
214
  const { importModel } = await import('./asset-import.mjs');
207
215
  let steps = [];
208
216
  if (flags.get('steps')) steps = JSON.parse(readFileSync(resolve(String(flags.get('steps'))), 'utf8'));
209
- r = await importModel(root, id, String(flags.get('file')), { as: flags.get('as') ?? args[1] ?? null, kind: flags.get('kind') ?? 'prop', tier: flags.get('tier') ?? null, card: flags.get('card') ?? null, height: flags.get('height') ?? null, triangles: flags.get('triangles') ?? null, texture: flags.get('texture') ?? null, license: flags.get('license') ?? null, attribution: flags.get('attribution') ?? null, notes: flags.get('notes') ?? null, owner: flags.get('owner') ?? null, route: flags.get('route') ?? 'imported', steps, concept: flags.get('concept') ?? null, slug: flags.get('slug') ?? null, rigged: flags.has('rigged') });
217
+ r = await importModel(root, id, String(flags.get('file')), { as: flags.get('as') ?? args[1] ?? null, kind: flags.get('kind') ?? 'prop', tier: flags.get('tier') ?? null, card: flags.get('card') ?? null, height: flags.get('height') ?? null, triangles: flags.get('triangles') ?? null, texture: flags.get('texture') ?? null, license: flags.get('license') ?? null, attribution: flags.get('attribution') ?? null, notes: flags.get('notes') ?? null, owner: flags.get('owner') ?? null, route: flags.get('route') ?? 'imported', steps, concept: flags.get('concept') ?? null, slug: flags.get('slug') ?? null, rigged: flags.has('rigged'), lo: flags.get('lo') && flags.get('lo') !== true ? flags.get('lo') : flags.has('lo') ? 0.2 : null });
210
218
  } else {
211
219
  if (!args[1]) return { ok: false, command: 'assets add', why: 'usage: homie-studio assets add <game> <library item> (assets find "<words>" lists them), or --file <model> --license <kind>' };
212
220
  const { addFromLibrary, libraryBase, loadIndex } = await import('./library.mjs');
@@ -223,7 +231,13 @@ export async function assetsCommand(root, sub, positional, flags, { log = () =>
223
231
  const { redoModel } = await import('./asset-import.mjs');
224
232
  r = await redoModel(root, id, args[1]);
225
233
  } else if (verb === 'check') { const { assetsCheck } = await import('./asset-check.mjs'); r = await assetsCheck(root, id, { validate: !flags.has('no-validate') }); }
226
- else if (verb === 'lineup') { const { assetsLineup } = await import('./style-board.mjs'); r = await assetsLineup(root, id, { log }); }
234
+ else if (verb === 'lineup') { const { assetsLineup } = await import('./style-board.mjs'); r = await assetsLineup(root, id, { log, scope: flags.get('scope') ?? null }); }
235
+ else if (verb === 'use') {
236
+ // What the game does with an asset now: `assets use <id> <asset> cast|prop|environment|unused|auto`.
237
+ if (!args[1] || !args[2]) return { ok: false, command: 'assets use', why: `usage: homie-studio assets use <game> <asset> <${USAGES.join('|')}|auto> (unused: a file kept in the folder that the game no longer draws)` };
238
+ const x = setUsage(root, id, args[1], String(args[2]));
239
+ r = { ok: true, command: 'assets use', id, ...x };
240
+ }
227
241
  else if (verb === 'review') { const { recordReview } = await import('./style-board.mjs'); r = recordReview(root, id, { score: flags.get('score'), outliers: list(flags.get('outliers')), note: flags.get('note') ?? null }); }
228
242
  else if (verb === 'rights') { const m = readManifest(root, id); const file = writeRights(root, id, m); syncCredits(root, id, m); r = { ok: true, command: 'assets rights', id, file, text: rightsMarkdown(root, id, m), licence: licenceProblems(root, id, m) }; }
229
243
  else if (verb === 'stale') { const doc = readDecisions(root, id); r = { ok: true, command: 'assets stale', id, stale: doc ? staleAssets(doc, readManifest(root, id)) : [] }; }
@@ -325,7 +339,7 @@ export function artLines(r) {
325
339
  break;
326
340
  case 'assets':
327
341
  L.push(`${r.id}: ${r.assets.length} asset${r.assets.length === 1 ? '' : 's'}${r.spend.used ? `, US$${r.spend.used} spent${r.spend.cap !== null ? ` of US$${r.spend.cap}` : ''}` : ', US$0 spent'}`);
328
- for (const a of r.assets) L.push(` ${a.id.padEnd(20)} ${a.kind.padEnd(9)} ${a.route.padEnd(10)} ${String(a.license ?? 'NO LICENCE').padEnd(10)} ${a.tris ?? '?'} tris, ${a.kb ?? '?'} KB${a.usd ? `, US$${a.usd}` : ''}${r.stale.includes(a.id) ? ' STALE' : ''}`);
342
+ for (const a of r.assets) L.push(` ${a.id.padEnd(20)} ${a.kind.padEnd(9)} ${a.route.padEnd(10)} ${String(a.license ?? 'NO LICENCE').padEnd(10)} ${a.tris ?? '?'} tris, ${a.kb ?? '?'} KB${a.usd ? `, US$${a.usd}` : ''}${a.usage === 'unused' ? ' unused' : ''}${r.stale.includes(a.id) ? ' STALE' : ''}`);
329
343
  break;
330
344
  case 'assets find':
331
345
  L.push(`${r.items.length} of ${r.total} library items for "${r.query}" (CC0, free; ${r.library}):`);
@@ -344,9 +358,19 @@ export function artLines(r) {
344
358
  break;
345
359
  case 'assets lineup':
346
360
  if (r.ok === false) { L.push(r.why); break; }
347
- L.push(`Lineup of ${r.rows.length} asset${r.rows.length === 1 ? '' : 's'} (${r.flagged} flagged), pictures: ${r.images.front}, ${r.images.quarter}, ${r.images.silhouettes}`);
348
- for (const x of r.rows) L.push(` ${x.flags.length ? 'FLAG' : 'ok '} ${x.id}${x.size ? ` (${x.size[1]} m)` : ''}${x.drift ? (x.drift.repaint ? ', repainted from style.json in the game' : `, palette distance ${x.drift.mean}`) : ''}${x.flags.length ? `: ${x.flags.join('; ')}` : ''}`);
349
- L.push(' Next: a fresh reviewer scores "one game?" from the board, golden images and this lineup (assets review <id> --score <n>).');
361
+ // Three different questions, never one picture: everything in the folder, what is in play, and the scenery.
362
+ L.push(`Inventory lineup of ${r.rows.length} asset${r.rows.length === 1 ? '' : 's'} (every recorded model; ${r.flagged} flagged${r.unused?.length ? `; ${r.unused.length} marked unused, which the game does not draw` : ''}), pictures: ${r.images.front}, ${r.images.quarter}, ${r.images.silhouettes}`);
363
+ for (const [name, what] of [['cast', 'what is in play (characters, creatures, props)'], ['environment', 'the scenery']]) {
364
+ const l = r.lineups?.[name];
365
+ if (l?.images) L.push(`${name[0].toUpperCase()}${name.slice(1)} lineup of ${l.ids.length} (${what}): ${l.images.front}, ${l.images.quarter}, ${l.images.silhouettes}`);
366
+ else if (l?.same) L.push(`${name[0].toUpperCase()}${name.slice(1)} lineup: the same ${l.ids.length} as the inventory (no separate picture).`);
367
+ }
368
+ for (const x of r.rows) L.push(` ${x.flags.length ? 'FLAG' : 'ok '} ${x.id} [${x.usage}${x.usageExplicit ? '' : ', by its kind'}]${x.size ? ` (${x.size[1]} m)` : ''}${x.drift ? (x.drift.repaint ? ', repainted from style.json in the game' : `, palette distance ${x.drift.mean}`) : ''}${x.flags.length ? `: ${x.flags.join('; ')}` : ''}`);
369
+ if (!r.rows.some((x) => x.usageExplicit)) L.push(' Usage is read from each asset\'s kind. An asset the game no longer draws: homie-studio assets use <id> <asset> unused (it leaves the cast and environment lineups and the scene estimate).');
370
+ L.push(' Next: a fresh reviewer scores "one game?" from the board, golden images and the CAST and ENVIRONMENT lineups (what the game draws); the inventory lineup is the folder, not the game (assets review <id> --score <n>).');
371
+ break;
372
+ case 'assets use':
373
+ L.push(`${r.asset}: ${r.usage}${r.explicit ? '' : ' (by its kind)'}.${r.usage === 'unused' ? ' It is left out of the scene estimate and of the cast and environment lineups; it still ships until it is removed (assets remove).' : ''}`);
350
374
  break;
351
375
  case 'assets review':
352
376
  L.push(`Review: ${r.review.score}/10, ${r.review.verdict}${r.review.outliers.length ? `; needs review: ${r.review.outliers.join(', ')}` : ''}`);
@@ -372,12 +396,14 @@ export function artLines(r) {
372
396
  out.push(` ${row.id} (${row.familyLabel}, ${row.skeleton}, ${row.bones} bones${row.animsKB ? `, its clip library ${row.animsKB} KB` : ''})`);
373
397
  for (const c of row.clips) out.push(` ${c.have ? 'ok ' : 'MISS'} ${c.verb}${c.have ? ` <- ${c.source}${c.retargeted ? ` (retargeted from ${c.from})` : ''}` : ''}`);
374
398
  if (row.extra.length) out.push(` also: ${row.extra.join(', ')}`);
399
+ if (row.supplemental?.length) out.push(` with the supplemental librar${row.supplemental.length === 1 ? 'y' : 'ies'} ${row.supplemental.join(', ')} (the game loads ${row.supplemental.length === 1 ? 'it' : 'them'} itself: loadCharacter(models, url, { anims: [...] }))`);
400
+ out.push(...gaitLines(row.gait).map((l) => ` ${l}`));
375
401
  }
376
402
  for (const u of r.unrigged) out.push(` ${u}: no rig (a static model; the animate guide says how to rig one)`);
377
403
  return out;
378
404
  }
379
405
  case 'anim add': return [`${r.asset}: ${r.added.length ? `added ${r.added.join(', ')}` : 'nothing new'} (its skeleton ${r.skeleton}: ${r.verbs.join(', ')}${r.kb ? `; ${r.kb} KB` : ''})${r.missing.length ? `; no source has ${r.missing.join(', ')}` : ''}${r.why ? ` (${r.why})` : ''}`];
380
- case 'anim preview': return [`Previews in .studio/art/${r.game}/anim/:`, ...r.rows.map((row) => ` ${row.id}: ${row.verbs.map((v) => v.verb).join(', ')}${row.sheet ? ` (sheet ${row.sheet})` : ''}`)];
406
+ case 'anim preview': return [`Previews in .studio/art/${r.game}/anim/:`, ...r.rows.flatMap((row) => [` ${row.id}: ${row.verbs.map((v) => v.verb).join(', ')}${row.sheet ? ` (sheet ${row.sheet})` : ''}${row.why ? ` (${row.why})` : ''}`, ...gaitLines(row.gait).map((l) => ` ${l}`)])];
381
407
  case 'assets check':
382
408
  L.push(...checkLines(r));
383
409
  break;
@@ -387,6 +413,23 @@ export function artLines(r) {
387
413
  return L;
388
414
  }
389
415
 
416
+ /**
417
+ * A character's measured ground speed, in words: what its walk and run cover on THIS rig beside animate's defaults,
418
+ * and what to do when they differ. With nothing measured, it says the default is unverified rather than nothing.
419
+ */
420
+ export function gaitLines(gait) {
421
+ if (!gait || (!gait.walk && !gait.run)) return ['ground speed: not measured on this rig (no walk or run clip, or no foot bones), so animate\'s default walkSpeed 1.5 and runSpeed 4.2 m/s are unverified for it: look at its feet in the game'];
422
+ const out = [];
423
+ for (const [verb, key] of [['walk', 'walkSpeed'], ['run', 'runSpeed']]) {
424
+ const g = gait[verb];
425
+ if (!g) continue;
426
+ const off = Math.abs(g.mps / g.default - 1) > 0.2;
427
+ out.push(`${verb}: its feet cover about ${g.mps} m/s on this rig (animate's default ${key} is ${g.default})${off ? `: its feet will slide at the default; start from loadCharacter(models, url, { tune: { ${key}: ${g.mps} } })` : ''}`);
428
+ }
429
+ out.push('(a guide from the foot bones while they are down, not a verified foot contact)');
430
+ return out;
431
+ }
432
+
390
433
  export function blastLines(b) {
391
434
  const L = [`Changing the ${b.name} (${b.from ?? '?'}${b.to ? ` -> ${b.to}` : ''}): ${b.assets.length} asset${b.assets.length === 1 ? '' : 's'} would go stale${b.assets.length ? `; remaking the ones with no free fix costs about US$${b.totals.usd.toFixed(2)}` : ''}.`];
392
435
  for (const a of b.assets) L.push(` ${a.id} (${a.route}${a.card ? `, ${a.card}` : ''}): ${a.free ? `free: ${a.free}` : a.remake.usd ? `US$${a.remake.usd.toFixed(2)} (${a.remake.how})` : a.remake.how}`);
package/lib/art-tools.mjs CHANGED
@@ -11,7 +11,9 @@
11
11
  * asset_add a library item, or a file with its licence, into the game (checked, optimised, recorded)
12
12
  * asset_make one generated prop on the person's own fal account: a priced dry run unless approve; the
13
13
  * concept first, then (after looking) the mesh lineup card
14
- * asset_check phone budgets, the validator, licences, staleness
14
+ * asset_check phone budgets, the validator, licences, staleness: an inventory estimate, never a measured frame
15
+ * collision_bake a model's walkable surface as a height grid beside it, with a checksum and a true-scale plan
16
+ * collision_check every collision file: stale against its model, its proxies, its routes walked by a bot
15
17
  * asset_lineup true scale, silhouettes, palette drift, flags lineup card
16
18
  * asset_rights RIGHTS.md and every asset's licence rights card
17
19
  * cast_plan the characters: proportions, silhouette, palette, skeleton family, source, clips, cost cast card
@@ -346,7 +348,7 @@ export function artToolDefs(ctx, h) {
346
348
  },
347
349
  {
348
350
  name: 'asset_check', title: 'Check the assets',
349
- description: 'Every asset of a game against the phone budgets (triangles, draw calls, picture memory, download), the Khronos glTF-Validator, its licence record, and whether a decision it was made under has changed; scene totals; big files in git. Free, a few seconds.',
351
+ description: 'Every asset of a game against the phone budgets, the Khronos glTF-Validator, its licence record, and whether a decision it was made under has changed; standalone textures and skies measured (size, hash, decode, mipmapped memory); big files in git. Its scene numbers are an INVENTORY ESTIMATE (the recorded files\' own triangles, draw calls and pictures times their declared placements): it never ran the game, so procedural geometry, repeated instances, effects and shadow passes are not in it, and a pass is not a measured frame (playtest and perf measure the running game). "Shipped payload" is every built file gzipped, not a measured first-play download. Free, a few seconds.',
350
352
  inputSchema: { type: 'object', properties: { ...GAME } },
351
353
  annotations: { title: 'Check the assets', ...RO },
352
354
  run: async (a) => {
@@ -358,32 +360,73 @@ export function artToolDefs(ctx, h) {
358
360
  const x = r.result;
359
361
  if (!x?.rows) return fail(`The check did not run: ${whyOf(r.job)}`);
360
362
  const { checkLines } = await import('./asset-check.mjs');
361
- return ok(checkLines(x).join('\n'), { kind: 'check', ...x });
363
+ return ok(checkLines(x).join('\n'), { kind: 'check', ...x, about: { scope: 'An inventory estimate from the recorded files. Not a measurement of the running game.', totals: { drawCalls: 'draw calls of the recorded models times their declared placements (estimate)', triangles: 'triangles of the recorded models times their declared placements (estimate)', textureMB: 'mipmapped RGBA memory of every recorded picture, each unique picture once (estimate; maps declared unused are left out)', standaloneTextureMB: 'the part of textureMB that is standalone texture and sky records', unusedMapMB: 'normal and roughness maps left out because the manifest declares base colour only', shippedPayloadMB: 'every file of the last build, gzipped: what ships, not what a browser fetched before the first round', firstPlayMB: 'the old name of shippedPayloadMB (the same number); it never was a measured first-play download' } } });
364
+ },
365
+ },
366
+ // A model that can be drawn is not yet one that can be stood on. The two collision commands, for a creator who
367
+ // works in chat and never types one (lib/collision.mjs: the same code, through the studio's own CLI).
368
+ {
369
+ name: 'collision_bake', title: 'Make a model walkable',
370
+ description: 'Bake a model\'s walkable surface: a height grid read off its triangles, written beside it as <model>.collision.json with a checksum of the mesh and a true-scale plan (<model>.collision.svg, 40 px to the metre). Use it after asset_add or asset_make for anything figures stand or walk on (a landmark, a bridge, a deck): without it they fall through. Authored "proxies" (boxes, cylinders, ramps to walk around), "mover" (the body that must fit) and "routes" (ways a bot should be able to walk) in the JSON are kept by a re-bake; edit them with file_edit, then collision_check. Free, local. The game loads the file with @homie-rocks/heightfield (unpackBake, bakeField).',
371
+ inputSchema: { type: 'object', properties: { ...GAME, model: str('The model, as a path inside the game, e.g. assets/models/pier.glb'), cell: { type: 'number', description: 'Metres between grid posts (default 0.25, which suits a walkway)' }, side: str('top (default: the upper surface) or under', { enum: ['top', 'under'] }), maxY: { type: 'number', description: 'Optional: ignore triangles above this height in metres (a roof over a floor)' } }, required: ['model'] },
372
+ annotations: { title: 'Make a model walkable', ...RW },
373
+ run: async (a) => {
374
+ const root = ctx.root(a.studio);
375
+ const need = needsInstall(ctx, root); if (need) return need;
376
+ const game = gameOf(root, a.game);
377
+ if (!a.model) return fail('model: the .glb to bake, as a path inside the game');
378
+ const r = await cli(ctx, root, `collision bake ${game}`, ['collision', 'bake', game, String(a.model), ...(a.cell ? ['--cell', String(a.cell)] : []), ...(a.side ? ['--side', String(a.side)] : []), ...(a.maxY !== undefined ? ['--max-y', String(a.maxY)] : [])]);
379
+ if (!r.ended) return stillRunning(r.job, 'The bake');
380
+ const x = r.result;
381
+ if (!x?.ok) return fail(`Not baked: ${x?.why ?? whyOf(r.job)}${x?.instead ? ` (${x.instead})` : ''}`);
382
+ return ok(x.lines.join('\n'), { kind: 'collision', ...x });
383
+ },
384
+ },
385
+ {
386
+ name: 'collision_check', title: 'Check where figures can walk',
387
+ description: 'The gate for a game\'s collision files (every <model>.collision.json, or one model\'s): the model is still the one that was baked (a changed surface is "stale: bake it again", by name; a re-export with the same triangles is not), the authored proxies are well formed, and a body of the mover\'s size passes every authored route and walks it to its end. Each failure names the place. Redraws each plan (.collision.svg) with a mark where a route fails. Run it after a model changes and before a deploy. Free, local.',
388
+ inputSchema: { type: 'object', properties: { ...GAME, model: str('Optional: only this model\'s collision file') } },
389
+ annotations: { title: 'Check where figures can walk', ...RW },
390
+ run: async (a) => {
391
+ const root = ctx.root(a.studio);
392
+ const need = needsInstall(ctx, root); if (need) return need;
393
+ const game = gameOf(root, a.game);
394
+ const r = await cli(ctx, root, `collision check ${game}`, ['collision', 'check', game, ...(a.model ? [String(a.model)] : [])]);
395
+ if (!r.ended) return stillRunning(r.job, 'The collision check');
396
+ const x = r.result;
397
+ if (!x?.models) return fail(`The check did not run: ${x?.why ?? whyOf(r.job)}${x?.instead ? ` (${x.instead})` : ''}`);
398
+ const text = x.lines.join('\n');
399
+ return x.ok ? ok(text, { kind: 'collision', ...x }) : fail(`${text}\n${x.models.filter((m) => !m.ok).length} of ${x.models.length} collision file(s) need attention.`);
362
400
  },
363
401
  },
364
402
  {
365
403
  name: 'asset_lineup', title: 'Lineup',
366
- description: 'The made assets of a game side by side at true scale on a 1 m grid under the game\'s light (front and three-quarter), their silhouettes at phone size, and flags: over budget, palette drift from the locked palette, an unreadable silhouette, another library family, stale. Free. Look at it, then have a fresh reviewer score "does it look like one game?" (the playtest guide\'s blind review).',
367
- inputSchema: { type: 'object', properties: { ...GAME } },
404
+ description: 'The made assets of a game side by side at true scale on a 1 m grid under the game\'s light (front and three-quarter), their silhouettes at phone size, and flags: over budget, palette drift from the locked palette, an unreadable silhouette, another library family, stale. THREE lineups, never one: the inventory (every recorded model, including any the game no longer draws), the cast (what is in play: characters, creatures, props) and the environment (scenery). A reviewer judging "does it look like one game?" looks at the cast and environment lineups; the inventory is the folder. use marks what the game does with an asset now ({ "old-tree": "unused" }: it leaves the cast and environment lineups and the scene estimate). Free.',
405
+ inputSchema: { type: 'object', properties: { ...GAME, scope: str('Optional: draw only the cast or the environment lineup beside the inventory', { enum: ['inventory', 'cast', 'environment'] }), use: { type: 'object', description: 'Optional, before drawing: what the game does with an asset now, { "<asset id>": "cast" | "prop" | "environment" | "unused" | "auto" } (only when the person or the game\'s code says so)' } } },
368
406
  annotations: { title: 'Lineup', ...RO }, _meta: ui(ART_UI.lineup),
369
407
  run: async (a) => {
370
408
  const root = ctx.root(a.studio);
371
409
  const need = needsInstall(ctx, root); if (need) return need;
372
410
  const game = gameOf(root, a.game);
373
- const r = await cli(ctx, root, `assets lineup ${game}`, ['assets', 'lineup', game]);
411
+ if (a.use && typeof a.use === 'object') {
412
+ const { setUsage } = await import('./asset-manifest.mjs');
413
+ try { for (const [asset, usage] of Object.entries(a.use)) setUsage(root, game, String(asset), String(usage)); } catch (error) { return fail(error.message); }
414
+ }
415
+ const r = await cli(ctx, root, `assets lineup ${game}`, ['assets', 'lineup', game, ...(a.scope ? ['--scope', String(a.scope)] : [])]);
374
416
  if (!r.ended) return stillRunning(r.job, 'The lineup');
375
417
  const x = r.result;
376
418
  if (!x?.rows) return fail(x?.why ?? `The lineup was not drawn: ${whyOf(r.job)}`);
377
419
  writeArtSummary(root, game);
378
420
  let check = null;
379
421
  try { check = JSON.parse(readFileSync(join(root, '.studio', 'art', game, 'check.json'), 'utf8')); } catch { check = null; }
380
- const data = { kind: 'lineup', mode: 'lineup', game, rows: x.rows, flagged: x.flagged, palette: x.palette, family: x.family, images: { front: picture(h, join(root, x.images.front), 220 * 1024), quarter: picture(h, join(root, x.images.quarter), 220 * 1024), silhouettes: picture(h, join(root, x.images.silhouettes), 80 * 1024) }, files: x.images, totals: check?.totals ?? null, budgets: check?.budgets ?? null, spend: artSpend(root, game) };
381
- return ok([`Lineup of ${x.rows.length} asset(s), ${x.flagged} flagged. Pictures: ${x.images.front}, ${x.images.quarter} (file_read shows them).`, ...x.rows.map((row) => ` ${row.flags.length ? 'FLAG' : 'ok'} ${row.id}${row.size ? ` ${row.size[1]} m` : ''}${row.flags.length ? `: ${row.flags.join('; ')}` : ''}`)].join('\n'), data);
422
+ const data = { kind: 'lineup', mode: 'lineup', game, rows: x.rows, lineups: x.lineups ?? null, unused: x.unused ?? [], flagged: x.flagged, palette: x.palette, family: x.family, images: { front: picture(h, join(root, x.images.front), 220 * 1024), quarter: picture(h, join(root, x.images.quarter), 220 * 1024), silhouettes: picture(h, join(root, x.images.silhouettes), 80 * 1024) }, files: x.images, totals: check?.totals ?? null, budgets: check?.budgets ?? null, spend: artSpend(root, game) };
423
+ const scoped = ['cast', 'environment'].map((n) => (x.lineups?.[n]?.images ? `${n} lineup (${x.lineups[n].ids.length}): ${x.lineups[n].images.front}, ${x.lineups[n].images.quarter}` : x.lineups?.[n]?.same ? `${n} lineup: the same models as the inventory` : null)).filter(Boolean);
424
+ return ok([`Inventory lineup of ${x.rows.length} asset(s): every recorded model, ${x.flagged} flagged${x.unused?.length ? `, ${x.unused.length} marked unused (not drawn by the game: ${x.unused.join(', ')})` : ''}. Pictures: ${x.images.front}, ${x.images.quarter} (file_read shows them).`, ...scoped, 'A review of how the GAME looks reads the cast and environment lineups; the inventory is the folder.', ...x.rows.map((row) => ` ${row.flags.length ? 'FLAG' : 'ok'} ${row.id} [${row.usage ?? '?'}]${row.size ? ` ${row.size[1]} m` : ''}${row.flags.length ? `: ${row.flags.join('; ')}` : ''}`)].join('\n'), data);
382
425
  },
383
426
  },
384
427
  {
385
428
  name: 'asset_rights', title: 'Rights',
386
- description: 'Every asset\'s licence in plain words (what it is, where it came from, what the licence allows, any credit owed, what a remixer gets) and what the providers\' terms say; writes RIGHTS.md and the credits. Anything that would stop a public game from publishing is named with its fix.',
429
+ description: 'Every asset\'s licence in plain words (what it is, where it came from, what the licence allows, any credit owed) and what the providers\' terms say; writes RIGHTS.md and the credits. Anything that would stop a public game from publishing is named with its fix.',
387
430
  inputSchema: { type: 'object', properties: { ...GAME } },
388
431
  annotations: { title: 'Rights', ...RW }, _meta: ui(ART_UI.rights),
389
432
  run: async (a) => {
@@ -395,7 +438,7 @@ export function artToolDefs(ctx, h) {
395
438
  const x = r.result;
396
439
  if (!x?.text) return fail(`Not written: ${whyOf(r.job)}`);
397
440
  const m = readManifest(root, game);
398
- const rows = m.assets.map((e) => ({ id: e.id, kind: e.kind, route: e.route, license: e.license?.kind ?? null, remix: e.license?.remix ?? null, attribution: e.license?.attribution ?? null, from: e.from?.pack ?? e.from?.item ?? null, placeholder: Boolean(e.placeholder) }));
441
+ const rows = m.assets.map((e) => ({ id: e.id, kind: e.kind, route: e.route, license: e.license?.kind ?? null, attribution: e.license?.attribution ?? null, from: e.from?.pack ?? e.from?.item ?? null, placeholder: Boolean(e.placeholder) }));
399
442
  return ok(`${x.file}:\n\n${x.text.slice(0, 6000)}`, { kind: 'rights', game, file: x.file, rows, problems: x.licence, text: x.text.slice(0, 20_000) });
400
443
  },
401
444
  },