@homie-rocks/studio 0.21.0 → 0.23.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 (70) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/README.md +92 -0
  3. package/assets/assets.ts +500 -0
  4. package/assets/render-page.ts +629 -0
  5. package/assets/safety.mjs +194 -0
  6. package/bin/homie-studio.mjs +81 -2
  7. package/chat/CHAT.md +101 -0
  8. package/chat/OWNERS.md +78 -0
  9. package/lib/art-cli.mjs +296 -0
  10. package/lib/art-tools.mjs +331 -0
  11. package/lib/asset-check.mjs +189 -0
  12. package/lib/asset-import.mjs +136 -0
  13. package/lib/asset-manifest.mjs +318 -0
  14. package/lib/build.mjs +32 -2
  15. package/lib/chat-cli.mjs +89 -0
  16. package/lib/cloudflare.mjs +5 -2
  17. package/lib/codex.mjs +43 -1
  18. package/lib/colour.mjs +160 -0
  19. package/lib/decisions.mjs +800 -0
  20. package/lib/directory.mjs +9 -1
  21. package/lib/library.mjs +169 -0
  22. package/lib/mcp-tools.mjs +22 -4
  23. package/lib/mcp.mjs +6 -3
  24. package/lib/optimise.mjs +380 -0
  25. package/lib/remix-assets.mjs +84 -0
  26. package/lib/render3d.mjs +81 -0
  27. package/lib/scaffold.mjs +39 -2
  28. package/lib/site.mjs +6 -1
  29. package/lib/studio.mjs +136 -9
  30. package/lib/style-board.mjs +227 -0
  31. package/lib/style-presets.mjs +121 -0
  32. package/lib/template-history.json +76 -0
  33. package/mcp/ui/card.css +15 -0
  34. package/mcp/ui/cast.js +62 -0
  35. package/mcp/ui/decision.js +61 -0
  36. package/mcp/ui/lineup.js +68 -0
  37. package/mcp/ui/rights.js +30 -0
  38. package/mcp/ui/style.js +154 -0
  39. package/netplay/NETPLAY.md +191 -3
  40. package/netplay/netplay.ts +150 -3
  41. package/package.json +15 -2
  42. package/port/index.ts +3 -2
  43. package/port/view.ts +238 -0
  44. package/site/SITE.md +11 -1
  45. package/starters/ember-vale/src/main.ts +22 -2
  46. package/starters/gem-rush/src/main.ts +21 -1
  47. package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
  48. package/starters/gem-rush-3d/assets/manifest.json +2318 -0
  49. package/starters/gem-rush-3d/credits.json +31 -0
  50. package/starters/gem-rush-3d/game.json +11 -0
  51. package/starters/gem-rush-3d/index.html +20 -0
  52. package/starters/gem-rush-3d/lab.json +19 -0
  53. package/starters/gem-rush-3d/src/main.ts +2107 -0
  54. package/starters/gem-rush-3d/style.json +66 -0
  55. package/starters/gem-rush-3d/tunables.json +13 -0
  56. package/worker/agents.mjs +3 -3
  57. package/worker/chat-page.mjs +556 -0
  58. package/worker/chat-store.mjs +161 -0
  59. package/worker/chat-words.mjs +46 -0
  60. package/worker/chat.mjs +419 -0
  61. package/worker/index.mjs +153 -8
  62. package/worker/office-page.mjs +115 -0
  63. package/worker/office.mjs +137 -6
  64. package/worker/pages.mjs +32 -9
  65. package/worker/qr.mjs +11 -0
  66. package/worker/room.mjs +288 -10
  67. package/worker/servers.mjs +5 -1
  68. package/worker/site.mjs +9 -4
  69. package/worker/stats.mjs +4 -1
  70. package/worker/version.mjs +1 -1
@@ -0,0 +1,296 @@
1
+ /**
2
+ * `homie-studio style …` and `homie-studio assets …` (bin/homie-studio.mjs hands both here): art direction as decisions,
3
+ * the style board, the asset list with its licences, the checks and the lineup. Every command that changes something
4
+ * also writes .studio/art/<id>/latest.json, the summary the Studio mod's Art tab and the cards read.
5
+ *
6
+ * style init <id> [--prompt "<the person's words>"] [--hands-on] [--budget <usd>]
7
+ * style [show] <id> [--phase style|cast|rigs|animations|game]
8
+ * style set <id> <decision> <value> [--by person] [--why "…"] [--unlock --reason "…" [--confirm]]
9
+ * style steer <id> <decision> "<words>" ("warmer", "less saturated", "closer", "golden hour", …)
10
+ * style lock <id> <decision>|style --words "<what the person said>"
11
+ * style unlock <id> <decision> --reason "<why>"
12
+ * style board <id> [--prompt "…"] three directions drawn by the engine (free)
13
+ * style pick <id> <a|b|c> [--mix style.palette=b,style.camera=c]
14
+ * style mood <id> <a|b|c> --image <file> [--usd <n>] [--receipt <file>] [--model <endpoint>]
15
+ * style golden <id> add <image> | list
16
+ * style blast <id> <decision> what changing it makes stale, and what remaking costs
17
+ * style prompt <id> the derived style prompt every generation call starts from
18
+ *
19
+ * assets [list] <id>
20
+ * assets find "<words>" [--kind prop] [--family kenney] [--limit 12]
21
+ * assets add <id> <library item> [--as <id>] [--height <m>] [--card "Items/Berry"]
22
+ * assets add <id> --file <model> --license <kind> [--attribution "…"] [--kind prop] [--as <id>] [--height <m>]
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>/)
27
+ * assets review <id> --score <1-10> [--outliers a,b] [--note "…"]
28
+ * assets rights <id> RIGHTS.md and credits.json written from the manifest
29
+ * assets stale <id> assets made under an older decision
30
+ * assets redo <id> <asset> made again from its kept raw file (free): today's budgets and render style
31
+ * assets remove <id> <asset>
32
+ */
33
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
34
+ import { join, resolve } from 'node:path';
35
+ import { licenceProblems, readManifest, removeAsset, rightsMarkdown, syncCredits, writeRights } from './asset-manifest.mjs';
36
+ import { CATALOGUE_IDS, PHASES, blastRadius, decisionRows, derivedPrompt, initDecisions, lockDecision, oneLine, phaseProgress, pickDirection, readDecisions, setDecision, staleAssets, steerDecision, unlockDecision } from './decisions.mjs';
37
+ import { listGames } from './studio.mjs';
38
+ import { checkLines } from './asset-check.mjs';
39
+
40
+ /** The game a command means: the one named, else the only one. */
41
+ function gameOf(root, id, command) {
42
+ const games = listGames(root);
43
+ const planned = id && existsSync(join(root, 'games', id, 'CODEX.md'));
44
+ if (id && (games.some((g) => g.id === id) || planned)) return id;
45
+ if (!id && games.length === 1) return games[0].id;
46
+ throw new Error(`name the game: homie-studio ${command} <id>${games.length ? ` (${games.map((g) => g.id).join(', ')})` : ''}`);
47
+ }
48
+
49
+ /**
50
+ * Spend on a game's art, from the receipts (art/receipts.jsonl lines naming the game: every paid call, a redo too),
51
+ * against the cap the person agreed to (art/<game>-models/budget.json, else the decisions' art budget). A studio
52
+ * whose older receipts name no game falls back to its assets' recorded steps.
53
+ */
54
+ export function artSpend(root, id) {
55
+ const doc = readDecisions(root, id);
56
+ const items = [];
57
+ try {
58
+ for (const line of readFileSync(join(root, 'art', 'receipts.jsonl'), 'utf8').split('\n')) {
59
+ if (!line.trim()) continue;
60
+ let r = null;
61
+ try { r = JSON.parse(line); } catch { continue; }
62
+ if (r?.game === id && Number(r.cost) > 0 && r.unit === 'usd') items.push({ what: r.what ?? r.artifact ?? r.model, usd: Number(r.cost), receipt: r.requestId ?? null, at: r.at });
63
+ }
64
+ } catch { /* no receipts yet */ }
65
+ if (!items.length) {
66
+ for (const a of readManifest(root, id).assets) for (const s of a.made?.steps ?? []) if (Number(s.usd) > 0) items.push({ what: `${a.id}: ${s.what}`, usd: Number(s.usd), receipt: s.receipt ?? null });
67
+ for (const d of doc?.board?.directions ?? []) if (Number(d.mood?.usd) > 0) items.push({ what: `mood image ${d.id}`, usd: Number(d.mood.usd), receipt: d.mood.receipt ?? null });
68
+ }
69
+ let cap = doc?.budget?.usd ?? null;
70
+ try { const b = JSON.parse(readFileSync(join(root, 'art', `${id}-models`, 'budget.json'), 'utf8')); if (Number.isFinite(Number(b.cap))) cap = Number(b.cap); } catch { /* the decisions' budget */ }
71
+ return { used: +items.reduce((n, x) => n + x.usd, 0).toFixed(3), cap, items };
72
+ }
73
+
74
+ /** .studio/art/<id>/latest.json: what the mod's Art tab and the cards show, without reading a dozen files. */
75
+ export function writeArtSummary(root, id) {
76
+ try {
77
+ const doc = readDecisions(root, id);
78
+ const manifest = readManifest(root, id);
79
+ const dir = join(root, '.studio', 'art', id);
80
+ mkdirSync(dir, { recursive: true });
81
+ const readJson = (f) => { try { return JSON.parse(readFileSync(join(dir, f), 'utf8')); } catch { return null; } };
82
+ const check = readJson('check.json');
83
+ const lineup = readJson('lineup.json');
84
+ const summary = {
85
+ v: 1, game: id, at: new Date().toISOString(), path: doc?.path ?? null, line: doc ? oneLine(doc) : null,
86
+ phases: doc ? PHASES.map((p) => ({ ...p, ...phaseProgress(doc)[p.id] })) : [],
87
+ decisions: decisionRows(doc).map((p) => ({ phase: p.id, label: p.label, rows: p.rows.map(({ id: d, name, label, state, by, colours }) => ({ id: d, name, label, state, by, colours })) })),
88
+ cast: manifest.assets.map((a) => ({ id: a.id, kind: a.kind, route: a.route, tier: a.tier ?? null, license: a.license?.kind ?? null, state: a.review?.state ?? 'auto', usd: +(a.made?.steps ?? []).reduce((n, s) => n + (Number(s.usd) || 0), 0).toFixed(3) })),
89
+ stale: doc ? staleAssets(doc, manifest).map((s) => s.id) : [],
90
+ licence: licenceProblems(root, id, manifest),
91
+ spend: artSpend(root, id),
92
+ 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,
93
+ lineup: lineup ? { at: lineup.at, flagged: lineup.flagged, images: lineup.images } : null,
94
+ board: doc?.board ? { chosen: doc.board.chosen, directions: doc.board.directions.map((d) => ({ id: d.id, label: d.label, swatch: d.swatch, mood: d.mood?.path ?? null })) } : null,
95
+ };
96
+ writeFileSync(join(dir, 'latest.json'), `${JSON.stringify(summary, null, 2)}\n`);
97
+ return summary;
98
+ } catch { return null; }
99
+ }
100
+
101
+ const list = (v) => String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean);
102
+
103
+ export async function styleCommand(root, sub, positional, flags, { log = () => {} } = {}) {
104
+ const known = ['init', 'show', 'set', 'steer', 'lock', 'unlock', 'board', 'pick', 'mood', 'golden', 'blast', 'prompt'];
105
+ const verb = known.includes(sub) ? sub : 'show';
106
+ const args = known.includes(sub) ? positional.slice(2) : positional.slice(1);
107
+ const id = gameOf(root, args[0], `style ${verb}`);
108
+ const manifest = () => readManifest(root, id);
109
+ let r;
110
+ if (verb === 'init') r = initDecisions(root, id, { prompt: String(flags.get('prompt') ?? ''), path: flags.has('hands-on') ? 'hands-on' : flags.has('automatic') ? 'automatic' : null, budget: flags.has('budget') ? Number(flags.get('budget')) : null });
111
+ else if (verb === 'show') {
112
+ const doc = readDecisions(root, id);
113
+ if (!doc) return { ok: false, command: 'style', why: `games/${id} has no decisions yet: homie-studio style init ${id} --prompt "<the person's words>"` };
114
+ const phase = flags.get('phase') ?? null;
115
+ r = { ok: true, command: 'style', id, line: oneLine(doc), path: doc.path, budget: doc.budget, phases: decisionRows(doc).filter((p) => !phase || p.id === phase), progress: phaseProgress(doc), prompt: doc.derived?.prompt ?? null, board: doc.board ? { chosen: doc.board.chosen, directions: doc.board.directions.map((d) => ({ id: d.id, label: d.label, swatch: d.swatch, mood: d.mood?.path ?? null })) } : null, golden: doc.golden ?? [], stale: staleAssets(doc, manifest()) };
116
+ } else if (verb === 'set') {
117
+ if (!args[1] || args[2] === undefined) return { ok: false, command: 'style set', why: `usage: homie-studio style set <id> <decision> <value> (decisions: ${CATALOGUE_IDS.slice(0, 10).join(', ')}, …)` };
118
+ r = setDecision(root, id, args[1], args.slice(2).join(' '), { by: flags.get('by') === 'person' ? 'person' : 'ai', why: flags.get('why') ?? null, words: flags.get('words') ?? null, unlock: flags.has('unlock'), reason: flags.get('reason') ?? null, confirm: flags.has('confirm'), manifest: manifest() });
119
+ } else if (verb === 'steer') r = steerDecision(root, id, args[1], args.slice(2).join(' ') || String(flags.get('words') ?? ''), { manifest: manifest() });
120
+ else if (verb === 'lock') r = lockDecision(root, id, args[1] ?? 'style', { by: 'person', words: flags.get('words') ?? null });
121
+ else if (verb === 'unlock') r = unlockDecision(root, id, args[1], { reason: flags.get('reason') ?? null, by: 'person' });
122
+ else if (verb === 'board') { const { styleBoard } = await import('./style-board.mjs'); r = await styleBoard(root, id, { prompt: String(flags.get('prompt') ?? ''), log, library: !flags.has('no-library') }); }
123
+ else if (verb === 'pick') {
124
+ const mixes = Object.fromEntries(list(flags.get('mix')).map((x) => x.split('=')).filter(([k, v]) => k && v));
125
+ r = pickDirection(root, id, args[1] && !args[1].includes('=') ? args[1] : null, { mixes, manifest: manifest() });
126
+ } else if (verb === 'mood') { const { recordMood } = await import('./style-board.mjs'); r = await recordMood(root, id, args[1], resolve(String(flags.get('image') ?? '')), { usd: flags.get('usd') ?? null, receipt: flags.get('receipt') ?? null, model: flags.get('model') ?? null }); }
127
+ else if (verb === 'golden') {
128
+ if (args[1] === 'add') { const { addGolden } = await import('./style-board.mjs'); r = await addGolden(root, id, resolve(String(args[2] ?? flags.get('image') ?? '')), { from: flags.get('from') ?? null }); }
129
+ else r = { ok: true, command: 'style golden', id, golden: readDecisions(root, id)?.golden ?? [] };
130
+ } else if (verb === 'blast') {
131
+ const doc = readDecisions(root, id);
132
+ if (!doc) return { ok: false, command: 'style blast', why: `games/${id} has no decisions yet` };
133
+ if (!doc.decisions[args[1]]) return { ok: false, command: 'style blast', why: `no decision "${args[1] ?? ''}"` };
134
+ r = { ok: true, command: 'style blast', id, blast: blastRadius(doc, args[1], manifest()) };
135
+ } else if (verb === 'prompt') {
136
+ const doc = readDecisions(root, id);
137
+ if (!doc) return { ok: false, command: 'style prompt', why: `games/${id} has no decisions yet` };
138
+ r = { ok: true, command: 'style prompt', id, prompt: derivedPrompt(doc), golden: doc.golden ?? [] };
139
+ }
140
+ if (r?.ok !== false) writeArtSummary(root, id);
141
+ return r;
142
+ }
143
+
144
+ export async function assetsCommand(root, sub, positional, flags, { log = () => {} } = {}) {
145
+ const known = ['list', 'find', 'add', 'redo', 'optimise', 'optimize', 'check', 'lineup', 'review', 'rights', 'stale', 'remove'];
146
+ const verb = known.includes(sub) ? (sub === 'optimize' ? 'optimise' : sub) : 'list';
147
+ const args = known.includes(sub) ? positional.slice(2) : positional.slice(1);
148
+ if (verb === 'find') {
149
+ const { loadIndex, searchLibrary, libraryBase } = await import('./library.mjs');
150
+ const { index, lib } = await loadIndex({ lib: libraryBase(flags.get('library') ?? undefined) });
151
+ const hits = searchLibrary(index, args.join(' '), { kind: flags.get('kind') ?? null, family: flags.get('family') ?? null, limit: Number(flags.get('limit') ?? 12) });
152
+ return { ok: true, command: 'assets find', query: args.join(' '), library: lib.base, version: index.version, total: index.items.length, items: hits.map(({ item, score, why }) => ({ id: item.id, name: item.name, kind: item.kind, family: item.family, pack: item.pack, tris: item.tris, kb: Math.round((item.files?.[0]?.bytes ?? 0) / 1024), heightM: item.heightM, suggestM: item.suggestM ?? null, rigged: Boolean(item.rigged), clips: item.clips ?? [], thumb: item.thumb, license: 'CC0 1.0', score: +score.toFixed(2), why })) };
153
+ }
154
+ if (verb === 'optimise') {
155
+ const { optimiseModel } = await import('./optimise.mjs');
156
+ 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>]' };
157
+ 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 });
158
+ const { glb, ...rest } = r;
159
+ return { ...rest, command: 'assets optimise' };
160
+ }
161
+ const id = gameOf(root, args[0], `assets ${verb}`);
162
+ let r;
163
+ if (verb === 'list') {
164
+ const m = readManifest(root, id);
165
+ const doc = readDecisions(root, id);
166
+ 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) };
167
+ } else if (verb === 'add') {
168
+ if (flags.get('file')) {
169
+ const { importModel } = await import('./asset-import.mjs');
170
+ let steps = [];
171
+ if (flags.get('steps')) steps = JSON.parse(readFileSync(resolve(String(flags.get('steps'))), 'utf8'));
172
+ 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') });
173
+ } else {
174
+ 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>' };
175
+ const { addFromLibrary, libraryBase } = await import('./library.mjs');
176
+ r = await addFromLibrary(root, id, args[1], { as: flags.get('as') ?? null, height: flags.get('height') ?? null, card: flags.get('card') ?? null, lib: libraryBase(flags.get('library') ?? undefined) });
177
+ }
178
+ } else if (verb === 'redo') {
179
+ if (!args[1]) return { ok: false, command: 'assets redo', why: 'usage: homie-studio assets redo <game> <asset> (made again from its kept raw file, free)' };
180
+ const { redoModel } = await import('./asset-import.mjs');
181
+ r = await redoModel(root, id, args[1]);
182
+ } else if (verb === 'check') { const { assetsCheck } = await import('./asset-check.mjs'); r = await assetsCheck(root, id, { validate: !flags.has('no-validate') }); }
183
+ else if (verb === 'lineup') { const { assetsLineup } = await import('./style-board.mjs'); r = await assetsLineup(root, id, { log }); }
184
+ 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 }); }
185
+ 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) }; }
186
+ else if (verb === 'stale') { const doc = readDecisions(root, id); r = { ok: true, command: 'assets stale', id, stale: doc ? staleAssets(doc, readManifest(root, id)) : [] }; }
187
+ else if (verb === 'remove') { const x = removeAsset(root, id, args[1]); r = { ok: x.removed, command: 'assets remove', id, asset: args[1], deleted: x.deleted, kept: x.kept, ...(x.removed ? {} : { why: `no asset "${args[1]}" in games/${id}/assets/manifest.json` }) }; }
188
+ if (r?.ok !== false || verb === 'check') writeArtSummary(root, id);
189
+ return r;
190
+ }
191
+
192
+ /* ------------------------------------------------------------------ what a person reads */
193
+
194
+ const MARK = { auto: '·', steered: '~', pinned: '●', locked: '■' };
195
+
196
+ export function artLines(r) {
197
+ const L = [];
198
+ switch (r.command) {
199
+ case 'style init':
200
+ L.push(`${r.summary}.`, ` ${r.picked.length} decision${r.picked.length === 1 ? '' : 's'} picked automatically (a ${r.genre} game)${r.kept.length ? `; ${r.kept.length} kept as the person set them` : ''}. ${r.file}`, ' Open the codex to change anything; steer, lock or the style board give the person control.');
201
+ break;
202
+ case 'style': {
203
+ L.push(`${r.id}: ${r.line}`, ` path: ${r.path}${r.budget ? `, art budget US$${r.budget.usd}` : ', free routes only (no art budget)'}`);
204
+ for (const p of r.phases) {
205
+ L.push(` ${p.label}:`);
206
+ for (const x of p.rows) L.push(` ${MARK[x.state] ?? ' '} ${x.id.padEnd(18)} ${x.label}${x.state !== 'auto' ? ` [${x.state}${x.by === 'person' ? ', the person' : x.by === 'use' ? ', by use' : ''}]` : ''}`);
207
+ }
208
+ L.push(' · auto ~ steered ● pinned by use ■ locked');
209
+ if (r.stale?.length) L.push(` stale: ${r.stale.map((s) => s.id).join(', ')}`);
210
+ break;
211
+ }
212
+ case 'style set':
213
+ if (r.pending) { L.push(`Not changed yet: ${r.decision} is locked. If it changes:`, ...blastLines(r.blast), r.why); break; }
214
+ L.push(`${r.decision}: ${r.record.label} (${r.record.state}, rev ${r.record.rev})`, ...(r.blast ? blastLines(r.blast) : []), ...(r.stale?.length ? [` stale now: ${r.stale.join(', ')}`] : []));
215
+ break;
216
+ case 'style steer':
217
+ L.push(`${r.decision}: ${r.record.label} (steered: "${r.recorded}")`, ...(r.note ? [` ${r.note}`] : []), ...(r.stale?.length ? [` stale now: ${r.stale.join(', ')}`] : []));
218
+ break;
219
+ case 'style lock':
220
+ L.push(r.locked.length ? `Locked: ${r.locked.join(', ')}` : 'Nothing new to lock.', ...(r.already.length ? [` already locked: ${r.already.join(', ')}`] : []));
221
+ break;
222
+ case 'style unlock':
223
+ L.push(r.already ? `${r.decision} was not locked.` : `Unlocked ${r.decision} (now steered).`);
224
+ break;
225
+ case 'style board':
226
+ L.push(`The style board for ${r.title} (drawn on ${r.renderer ?? 'this computer'}, free):`);
227
+ for (const d of r.directions) L.push(` ${d.id.toUpperCase()}: ${d.label} [${d.family}]`, ` swatch: games/${r.id}/${d.swatch}${d.mood ? ` mood (target): games/${r.id}/${d.mood.path}` : ''}`);
228
+ L.push(' Pick, Mix, Steer or Lock: homie-studio style pick <id> <a|b|c> [--mix style.palette=b]; style lock <id> style --words "…"');
229
+ break;
230
+ case 'style pick':
231
+ L.push(r.changed.length ? `Picked ${r.chosen ? `direction ${r.chosen.toUpperCase()}` : 'a mix'}: ${r.changed.join(', ')}` : 'Nothing changed (the board\'s values are the game\'s already).', ...(r.refused.length ? [` left as locked: ${r.refused.join(', ')}`] : []), ...(r.stale?.length ? [` stale now: ${r.stale.join(', ')}`] : []));
232
+ break;
233
+ case 'style mood':
234
+ L.push(`Mood image (a target, not what the game draws) for direction ${r.direction.toUpperCase()}: ${r.mood.path}${r.mood.usd ? ` (US$${r.mood.usd})` : ''}`);
235
+ break;
236
+ case 'style golden':
237
+ L.push(`${r.golden.length} golden image${r.golden.length === 1 ? '' : 's'}:`, ...r.golden.map((g) => ` ${g.path} (from ${g.from})`));
238
+ break;
239
+ case 'style blast':
240
+ L.push(...blastLines(r.blast));
241
+ break;
242
+ case 'style prompt':
243
+ L.push(`Derived style prompt (rev ${r.prompt.rev}):`, ` ${r.prompt.text}`, ...(r.golden.length ? [`Golden images (references): ${r.golden.map((g) => g.path).join(', ')}`] : []));
244
+ break;
245
+ case 'assets':
246
+ 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'}`);
247
+ 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' : ''}`);
248
+ break;
249
+ case 'assets find':
250
+ L.push(`${r.items.length} of ${r.total} library items for "${r.query}" (CC0, free; ${r.library}):`);
251
+ for (const i of r.items) L.push(` ${i.id.padEnd(46)} ${i.kind.padEnd(9)} ${String(i.tris).padStart(5)} tris ${String(i.kb).padStart(4)} KB${i.rigged ? ` rigged${i.clips.length ? `, ${i.clips.length} clips` : ''}` : ''}`);
252
+ if (r.items.length) L.push(` Add one: homie-studio assets add <game> ${r.items[0].id} [--height <m>]`);
253
+ break;
254
+ case 'assets add':
255
+ L.push(`games/${r.game}: ${r.asset} added (${r.item ? `from the library: ${r.item}` : r.route}; licence ${r.license}).`, ...(r.after ? [` ${r.before.tris} -> ${r.after.tris} triangles, ${r.before.kb} -> ${r.after.kb} KB, ${r.after.heightM} m tall; raw kept in ${r.raw} (git-ignored)`] : []), ...(r.pinned?.length ? [` pinned by use: ${r.pinned.join(', ')}`] : []), ...(r.warnings ?? []).map((w) => ` note: ${w}`));
256
+ break;
257
+ case 'assets redo':
258
+ L.push(`games/${r.game}: ${r.asset} made again from its raw file (free): ${r.before.tris} -> ${r.after.tris} triangles, ${r.after.kb} KB, ${r.after.heightM} m (${r.ops.join(', ')})`, ...(r.warnings ?? []).map((w) => ` note: ${w}`));
259
+ break;
260
+ case 'assets optimise':
261
+ L.push(`${r.out}: ${r.before.triangles} -> ${r.after.triangles} triangles, ${Math.round(r.rawBytes / 1024)} -> ${Math.round(r.bytes / 1024)} KB (${r.ops.join(', ')})`, ...r.warnings.map((w) => ` note: ${w}`));
262
+ break;
263
+ case 'assets lineup':
264
+ if (r.ok === false) { L.push(r.why); break; }
265
+ 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}`);
266
+ 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('; ')}` : ''}`);
267
+ L.push(' Next: a fresh reviewer scores "one game?" from the board, golden images and this lineup (assets review <id> --score <n>).');
268
+ break;
269
+ case 'assets review':
270
+ L.push(`Review: ${r.review.score}/10, ${r.review.verdict}${r.review.outliers.length ? `; needs review: ${r.review.outliers.join(', ')}` : ''}`);
271
+ break;
272
+ case 'assets rights':
273
+ L.push(`Wrote ${r.file} (and credits.json).`, ...r.licence.map((l) => ` ${l.level === 'refuse' ? 'FIX ' : 'note'} ${l.asset}: ${l.problem}`));
274
+ break;
275
+ case 'assets stale':
276
+ L.push(r.stale.length ? `Stale (made under an older decision; nothing is remade by itself):` : 'Nothing is stale.', ...r.stale.map((s) => ` ${s.id}: ${s.decisions.map((d) => `${d.id} rev ${d.was} -> ${d.now}`).join(', ')}`));
277
+ break;
278
+ case 'assets remove':
279
+ L.push(r.ok ? `Removed ${r.asset} from the manifest${r.deleted.length ? `, and its shipped copy (${r.deleted.join(', ')})` : ''}.${r.kept.length ? ` Kept ${r.kept.join(', ')}: changed since it was recorded, so delete it yourself if it is unused.` : ''} A raw file in art/ stays.` : r.why);
280
+ break;
281
+ case 'assets check':
282
+ L.push(...checkLines(r));
283
+ break;
284
+ default:
285
+ L.push(JSON.stringify(r, null, 2));
286
+ }
287
+ return L;
288
+ }
289
+
290
+ export function blastLines(b) {
291
+ 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)}` : ''}.`];
292
+ 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}`);
293
+ if (b.decisions.length) L.push(` also worth a look: ${b.decisions.slice(0, 8).join(', ')}`);
294
+ L.push(` ${b.note}`);
295
+ return L;
296
+ }