@homie-rocks/studio 0.21.0 → 0.22.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 (49) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +55 -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 +47 -1
  7. package/lib/art-cli.mjs +296 -0
  8. package/lib/art-tools.mjs +331 -0
  9. package/lib/asset-check.mjs +189 -0
  10. package/lib/asset-import.mjs +136 -0
  11. package/lib/asset-manifest.mjs +318 -0
  12. package/lib/build.mjs +25 -2
  13. package/lib/codex.mjs +43 -1
  14. package/lib/colour.mjs +160 -0
  15. package/lib/decisions.mjs +800 -0
  16. package/lib/directory.mjs +9 -1
  17. package/lib/library.mjs +169 -0
  18. package/lib/mcp-tools.mjs +22 -4
  19. package/lib/mcp.mjs +6 -3
  20. package/lib/optimise.mjs +380 -0
  21. package/lib/remix-assets.mjs +84 -0
  22. package/lib/render3d.mjs +81 -0
  23. package/lib/scaffold.mjs +26 -0
  24. package/lib/site.mjs +6 -1
  25. package/lib/studio.mjs +136 -9
  26. package/lib/style-board.mjs +227 -0
  27. package/lib/style-presets.mjs +121 -0
  28. package/lib/template-history.json +38 -0
  29. package/mcp/ui/card.css +15 -0
  30. package/mcp/ui/cast.js +62 -0
  31. package/mcp/ui/decision.js +61 -0
  32. package/mcp/ui/lineup.js +68 -0
  33. package/mcp/ui/rights.js +30 -0
  34. package/mcp/ui/style.js +154 -0
  35. package/package.json +14 -2
  36. package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
  37. package/starters/gem-rush-3d/assets/manifest.json +2318 -0
  38. package/starters/gem-rush-3d/credits.json +31 -0
  39. package/starters/gem-rush-3d/game.json +11 -0
  40. package/starters/gem-rush-3d/index.html +20 -0
  41. package/starters/gem-rush-3d/lab.json +19 -0
  42. package/starters/gem-rush-3d/src/main.ts +2086 -0
  43. package/starters/gem-rush-3d/style.json +66 -0
  44. package/starters/gem-rush-3d/tunables.json +13 -0
  45. package/worker/index.mjs +5 -3
  46. package/worker/pages.mjs +13 -5
  47. package/worker/qr.mjs +11 -0
  48. package/worker/site.mjs +5 -3
  49. package/worker/version.mjs +1 -1
@@ -0,0 +1,800 @@
1
+ /**
2
+ * ART DIRECTION AS DECISIONS: games/<id>/codex/decisions.json, one record per decision (render style,
3
+ * palette, shape language, proportions, materials, light, camera, UI type, effects, references; then the cast and its
4
+ * routes, library family, scale and budgets; the skeleton and animation standards; the in-game budgets).
5
+ *
6
+ * Every decision has an automatic pick with a one-line why (from the person's words, the codex and the genre), and four
7
+ * states:
8
+ * auto the AI's pick; the AI may change it freely
9
+ * steered the person nudged it ("warmer"); the AI may refine within the nudge, never undo it
10
+ * pinned locked by use: the first asset built on an auto decision pins it, so the automatic path stays coherent
11
+ * locked frozen by the person; changing it needs `unlock` with a reason, and shows the blast radius first
12
+ * Each change bumps the decision's `rev`; every asset records the revisions it was made under (assets/manifest.json
13
+ * `made.under`), so a changed decision makes exactly those assets stale: listed and priced, never remade by itself.
14
+ *
15
+ * The file is private like CODEX.md: the remix source and a static game's served copy skip the whole codex/ folder.
16
+ * games/<id>/style.json (public, tiny) carries the palette and fonts a game, its landing and its title cards draw with.
17
+ */
18
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { HEX, deltaHex, hexToRgb, luminance, mix, nudge } from './colour.mjs';
21
+ import { CAMERAS, FAMILIES, FONT_SETS, GENRES, LIGHTS, MOODS, PALETTES, PALETTE_KEYS, RENDERS, SHAPES } from './style-presets.mjs';
22
+ import { readTheme } from './site.mjs';
23
+ import { GAME_ID, listGames } from './studio.mjs';
24
+
25
+ export const DECISIONS_FILE = join('codex', 'decisions.json');
26
+ export const STATES = Object.freeze(['auto', 'steered', 'pinned', 'locked']);
27
+ export const PHASES = Object.freeze([
28
+ { id: 'style', label: 'Style' }, { id: 'cast', label: 'Cast' }, { id: 'rigs', label: 'Rigs' }, { id: 'animations', label: 'Animations' }, { id: 'game', label: 'In game' },
29
+ ]);
30
+ const HISTORY_MAX = 20;
31
+ const now = () => new Date().toISOString();
32
+ const clean = (s, n = 200) => String(s ?? '').replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, n);
33
+
34
+ /* ------------------------------------------------------------------ the context a pick reads */
35
+
36
+ /** What the automatic picks read: the person's words, the codex (its concept, art text and cards), the game. */
37
+ export function contextOf(root, id, { prompt = '' } = {}) {
38
+ const dir = join(root, 'games', id);
39
+ let codex = '';
40
+ try { codex = readFileSync(join(dir, 'CODEX.md'), 'utf8').replace(/<!--[\s\S]*?-->/g, ''); } catch { codex = ''; }
41
+ let game = {};
42
+ try { game = JSON.parse(readFileSync(join(dir, 'game.json'), 'utf8')); } catch { game = {}; }
43
+ const fm = /^---\n([\s\S]*?)\n---/.exec(codex)?.[1] ?? '';
44
+ const pal = {};
45
+ for (const k of [...PALETTE_KEYS, 'panel']) { const m = new RegExp(`^\\s+${k}:\\s*"?(#[0-9a-fA-F]{3,6})"?`, 'm').exec(fm); if (m) pal[k] = m[1]; }
46
+ const fonts = {};
47
+ for (const k of ['display', 'body']) { const m = new RegExp(`^\\s+${k}:\\s*"([^"]+)"`, 'm').exec(fm); if (m && m[1].trim()) fonts[k] = m[1].trim(); }
48
+ const section = (re) => { const m = new RegExp(`^##\\s+${re}[^\\n]*\\n([\\s\\S]*?)(?=^##\\s|$(?![\\s\\S]))`, 'im').exec(codex); return m ? m[1] : ''; };
49
+ const cards = [];
50
+ for (const [key, re] of [['characters', '(?:characters|cast|heroes|creatures|enemies)'], ['items', '(?:items|props|pickups|collectibles)'], ['places', '(?:world|places|setting|zones?|levels?)']]) {
51
+ for (const m of section(re).matchAll(/^###\s+(.+?)\s*$/gm)) cards.push({ section: key, title: clean(m[1], 60) });
52
+ }
53
+ const words = [prompt, game.name, game.blurb, section('(?:concept|the game|pitch|overview)'), section('(?:art|look|style|visual)'), section('(?:world|setting)')].filter(Boolean).join(' \n ');
54
+ const score = (re) => (words.match(new RegExp(re.source, 'gi')) ?? []).length;
55
+ const genre = Object.entries(GENRES).map(([g, v]) => [g, score(v.words)]).filter(([, n]) => n).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'party';
56
+ const mood = Object.fromEntries(Object.entries(MOODS).map(([m, re]) => [m, score(re)]));
57
+ let theme = {};
58
+ try { theme = readTheme(root); } catch { theme = {}; }
59
+ return { id, prompt: clean(prompt, 600), words, score, genre, mood, codexPalette: pal, codexFonts: fonts, theme, cards, players: Number(game.players?.max ?? game.netplay?.maxPlayers ?? 8) || 8, name: game.name ?? id, codexArt: clean(section('(?:art|look|style|visual)'), 400) };
60
+ }
61
+
62
+ /** The best match in a preset table by word score: [key, score] or null. */
63
+ function best(table, ctx, { skip = [] } = {}) {
64
+ const scored = Object.entries(table).filter(([k]) => !skip.includes(k)).map(([k, v]) => [k, v.words ? ctx.score(v.words) : 0]).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]);
65
+ return scored[0] ?? null;
66
+ }
67
+ const said = (ctx, re) => (ctx.words.match(new RegExp(re.source, 'gi')) ?? []).slice(0, 3).map((w) => `"${w.toLowerCase()}"`).join(', ');
68
+
69
+ /* ------------------------------------------------------------------ values */
70
+
71
+ /** A palette value from a preset (or a person's own colours). */
72
+ export function paletteValue(name, over = {}) {
73
+ const p = PALETTES[name] ?? PALETTES['meadow-morning'];
74
+ const out = { name: PALETTES[name] ? name : 'custom', ...Object.fromEntries(PALETTE_KEYS.map((k) => [k, p[k]])), ramp: [...p.ramp] };
75
+ for (const k of PALETTE_KEYS) if (HEX.test(String(over[k] ?? ''))) out[k] = over[k];
76
+ return out;
77
+ }
78
+ const paletteLabel = (v) => (v?.name && PALETTES[v.name] ? PALETTES[v.name].label.split(':')[0] : 'Own colours') + (v?.steered ? ` (${v.steered})` : '');
79
+
80
+ /** Resolve a light preset's colour words ("mix:bg>#ffffff:0.35") against a palette. */
81
+ export function lightColours(light, palette) {
82
+ const res = (s) => {
83
+ if (HEX.test(String(s))) return s;
84
+ const m = /^mix:(\w+)>(#[0-9a-f]{6}|\w+):([\d.]+)$/i.exec(String(s));
85
+ if (m) return mix(palette[m[1]] ?? palette.bg, HEX.test(m[2]) ? m[2] : palette[m[2]] ?? palette.bg, Number(m[3]));
86
+ return palette[s] ?? palette.bg;
87
+ };
88
+ return { sky: res(light.sky), ground: res(light.ground) };
89
+ }
90
+
91
+ function lightValue(key, palette) {
92
+ const L = LIGHTS[key] ?? LIGHTS.morning;
93
+ const { sky, ground } = lightColours(L, palette);
94
+ return { time: LIGHTS[key] ? key : 'morning', key: L.key, intensity: L.intensity, hardness: L.hardness, sky, ground, fog: L.fog, shadows: L.shadows, toonSteps: L.toonSteps, bloom: L.bloom, tiltShift: L.tiltShift };
95
+ }
96
+ function cameraValue(key, over = {}) {
97
+ const c = CAMERAS[key] ?? CAMERAS['high-3/4'];
98
+ return { angle: CAMERAS[key] ? key : 'high-3/4', projection: c.projection, pitch: c.pitch, distance: c.distance, fov: c.fov, ...over };
99
+ }
100
+
101
+ /* ------------------------------------------------------------------ the catalogue */
102
+
103
+ /*
104
+ * Each decision: its phase, the question it answers, what it depends on (the graph: a change makes everything that
105
+ * depends on it, directly or not, worth a look), and its automatic pick: (ctx, picked) -> { value, label, why, options }.
106
+ * `picked` holds the decisions picked before it in this order, so a palette can follow the render style.
107
+ */
108
+ export const CATALOGUE = Object.freeze([
109
+ ['style.render', 'style', 'How is the world drawn?', [], (ctx) => {
110
+ const hit = best(RENDERS, ctx);
111
+ const key = hit?.[0] ?? 'lowpoly-flat';
112
+ return { value: key, label: RENDERS[key].label, why: hit ? `your words ${said(ctx, RENDERS[key].words)}: ${RENDERS[key].note}` : `nothing named a look: ${RENDERS[key].note}`, options: Object.entries(RENDERS).map(([k, v]) => ({ id: k, label: v.label, value: k })) };
113
+ }],
114
+ ['style.palette', 'style', 'Which colours, and what is each one for?', ['style.render', 'style.light'], (ctx) => {
115
+ const own = PALETTE_KEYS.filter((k) => ctx.codexPalette[k]).length >= 3 && !isStudioTheme(ctx.codexPalette, ctx.theme);
116
+ if (own) {
117
+ const v = paletteValue('custom', ctx.codexPalette);
118
+ v.ramp = rampFrom(v);
119
+ return { value: v, label: 'The codex\'s own colours', why: 'the Game Codex already names them', options: paletteOptions(ctx) };
120
+ }
121
+ const hit = best(PALETTES, ctx);
122
+ const key = hit?.[0] ?? ({ gather: 'meadow-morning', brawl: 'candy-pop', race: 'desert-noon', rpg: 'heroic-dawn', shooter: 'space-ink', platformer: 'meadow-morning', party: 'candy-pop', puzzle: 'candy-pop', sports: 'meadow-morning' })[ctx.genre] ?? 'meadow-morning';
123
+ return { value: paletteValue(key), label: paletteLabel({ name: key }), why: hit ? `your words ${said(ctx, PALETTES[key].words)}` : `a ${ctx.genre} game reads best in bright, separate colours`, options: paletteOptions(ctx) };
124
+ }],
125
+ ['style.shape', 'style', 'What shape language: round, angular or blocky?', ['style.render'], (ctx, p) => {
126
+ const hit = best(SHAPES, ctx);
127
+ const key = p['style.render'] === 'voxel' ? 'blocky' : hit?.[0] ?? (ctx.mood.tense > ctx.mood.cozy ? 'angular' : 'round');
128
+ return { value: { language: key, bevel: SHAPES[key].bevel, silhouette: 'every character readable black on white at 64 px tall' }, label: SHAPES[key].label, why: hit ? `your words ${said(ctx, SHAPES[key].words)}: ${SHAPES[key].note}` : SHAPES[key].note, options: Object.entries(SHAPES).map(([k, v]) => ({ id: k, label: v.label, value: { language: k, bevel: v.bevel } })) };
129
+ }],
130
+ ['style.proportions', 'style', 'How tall are the characters, in heads and metres?', ['style.render', 'style.camera'], (ctx) => {
131
+ const g = GENRES[ctx.genre] ?? GENRES.party;
132
+ const cute = /\b(cute|chibi|tiny|little|baby)\b/i.test(ctx.words); const real = /\b(realistic|heroic|tall)\b/i.test(ctx.words);
133
+ const heads = cute ? 2.5 : real ? 6.5 : g.heads;
134
+ return { value: { heads, heightM: real ? 1.8 : g.heightM, hands: heads < 4 ? 'big' : 'normal' }, label: `${heads} heads tall, ${real ? 1.8 : g.heightM} m`, why: cute || real ? 'your words' : `a ${ctx.genre} game seen from ${CAMERAS[g.camera].label.toLowerCase()}: big heads read on a phone`, options: [2.5, 4.5, 6.5].map((h) => ({ id: `h${h}`, label: `${h} heads`, value: { heads: h, heightM: h < 3 ? 0.9 : h < 5 ? 1.5 : 1.8, hands: h < 4 ? 'big' : 'normal' } })) };
135
+ }],
136
+ ['style.materials', 'style', 'What are surfaces made of: flat colour, a gradient atlas, painted pictures or PBR?', ['style.render', 'game.devices'], (ctx, p) => {
137
+ const r = RENDERS[p['style.render']] ?? RENDERS['lowpoly-flat'];
138
+ const density = { flat: null, toon: 128, 'hand-painted': 256, pbr: 384, pixel: 32 }[r.materials] ?? null;
139
+ return { value: { model: r.materials, outline: r.outline, texelDensity: density, atlas: r.materials === 'flat' }, label: `${({ flat: 'Flat palette colours', toon: 'Toon ramp', 'hand-painted': 'Hand-painted albedo', pbr: 'PBR metal and roughness', pixel: 'Pixel textures' })[r.materials]}${r.outline ? ', outlines' : ''}`, why: `follows the render style (${r.label}); phones: one material per asset`, options: [['flat', 'Flat palette colours'], ['toon', 'Toon ramp'], ['hand-painted', 'Hand-painted albedo'], ['pbr', 'PBR']].map(([k, l]) => ({ id: k, label: l, value: { model: k, outline: k === 'toon', texelDensity: null, atlas: k === 'flat' } })) };
140
+ }],
141
+ ['style.light', 'style', 'Where does the light come from, how hard, and what time of day?', ['style.render'], (ctx, p) => {
142
+ const hit = best(LIGHTS, ctx);
143
+ const palName = p['style.palette']?.name;
144
+ const key = hit?.[0] ?? ({ 'moonlit-grove': 'night', 'neon-dusk': 'night', 'embers-night': 'night', 'desert-noon': 'noon', 'autumn-grove': 'golden', 'ash-dawn': 'overcast', 'space-ink': 'night' })[palName] ?? 'morning';
145
+ return { value: lightValue(key, p['style.palette'] ?? paletteValue('meadow-morning')), label: LIGHTS[key].label, why: hit ? `your words ${said(ctx, LIGHTS[key].words)}` : 'fits the palette', options: Object.entries(LIGHTS).map(([k, v]) => ({ id: k, label: v.label, value: k })) };
146
+ }],
147
+ ['style.camera', 'style', 'Where does the camera sit?', [], (ctx) => {
148
+ const asked = /\btop[- ]?down\b/i.test(ctx.words) ? 'top-down' : /\biso(metric)?\b/i.test(ctx.words) ? 'iso' : /\bside[- ]?(on|scroll\w*|view)?\b/i.test(ctx.words) ? 'side' : /\b(chase|third[- ]person)\b/i.test(ctx.words) ? 'chase' : null;
149
+ const key = asked ?? (GENRES[ctx.genre] ?? GENRES.party).camera;
150
+ return { value: cameraValue(key), label: CAMERAS[key].label, why: asked ? 'your words' : `a ${ctx.genre} game: ${CAMERAS[key].note}`, options: Object.entries(CAMERAS).map(([k, v]) => ({ id: k, label: v.label, value: k })) };
151
+ }],
152
+ ['style.ui', 'style', 'Which fonts, icons and HUD shape?', ['style.render', 'style.shape'], (ctx, p) => {
153
+ const r = RENDERS[p['style.render']] ?? RENDERS['lowpoly-flat'];
154
+ const fonts = { display: ctx.codexFonts.display ?? r.fonts.display, body: ctx.codexFonts.body ?? r.fonts.body };
155
+ const radius = p['style.shape']?.language === 'angular' ? 4 : p['style.shape']?.language === 'blocky' ? 8 : 14;
156
+ return { value: { ...fonts, icons: p['style.render'] === 'pixel-hd2d' ? 'pixel' : 'filled', radius, hud: 'chips' }, label: `${fonts.display} and ${fonts.body}`, why: ctx.codexFonts.display ? 'the codex names the fonts' : `Google Fonts (OFL) that suit ${r.label.toLowerCase()}`, options: Object.entries(FONT_SETS).map(([k, v]) => ({ id: k, label: `${v.display} / ${v.body}`, value: v })) };
157
+ }],
158
+ ['style.vfx', 'style', 'What do effects look like?', ['style.render', 'style.light'], (ctx, p) => {
159
+ const r = RENDERS[p['style.render']] ?? RENDERS['lowpoly-flat'];
160
+ return { value: { particles: r.vfx, hitFlash: true, trails: /\b(race|speed|dash)\b/i.test(ctx.words), glow: (p['style.light']?.bloom ?? 0) > 0.2 }, label: `${({ soft: 'Soft glows', toon: 'Toon shapes', pixel: 'Pixel squares' })[r.vfx]}`, why: `follows ${r.label.toLowerCase()}`, options: [['soft', 'Soft glows'], ['toon', 'Toon shapes'], ['pixel', 'Pixel squares']].map(([k, l]) => ({ id: k, label: l, value: { particles: k, hitFlash: true, trails: false, glow: k === 'soft' } })) };
161
+ }],
162
+ ['style.refs', 'style', 'Which references, in words, and which golden images?', [], (ctx) => {
163
+ const like = [...ctx.prompt.matchAll(/\b(?:like|feels? like|inspired by)\s+([A-Za-z0-9 '’:-]{3,40})/gi)].map((m) => clean(m[1], 40));
164
+ return { value: { words: like, golden: [] }, label: like.length ? like.join('; ') : 'None yet', why: like.length ? 'your words' : 'golden images come once the style is locked (two to six)', options: [] };
165
+ }],
166
+
167
+ ['cast.list', 'cast', 'Which assets does the game need?', ['style.render'], (ctx) => {
168
+ const list = castFrom(ctx);
169
+ return { value: list, label: `${list.length} assets`, why: ctx.cards.length ? 'one per codex card, plus what the genre needs' : `what a ${ctx.genre} game needs; the codex's cards replace it`, options: [] };
170
+ }],
171
+ ['cast.routes', 'cast', 'Where does each family of assets come from?', ['cast.family'], () => ({ value: { characters: 'library', creatures: 'library', props: 'library', environment: 'procedural', textures: 'procedural', skies: 'procedural' }, label: 'Library and procedural (free)', why: 'free routes first; generated only within an art budget, for what the library cannot cover', options: [] })],
172
+ ['cast.family', 'cast', 'Which one starter library family?', ['style.render'], (ctx, p) => {
173
+ const fam = RENDERS[p['style.render']]?.family ?? 'kenney';
174
+ return { value: fam, label: FAMILIES[fam].label, why: `${FAMILIES[fam].note}; one family a game, so every piece fits`, options: Object.entries(FAMILIES).map(([k, v]) => ({ id: k, label: v.label, value: k })) };
175
+ }],
176
+ ['cast.scale', 'cast', 'Units, character height, grid and pivot?', ['style.proportions'], (ctx, p) => {
177
+ const h = p['style.proportions']?.heightM ?? 1;
178
+ return { value: { unit: 'm', characterHeightM: h, grid: 2, pivot: 'bottom centre, +Y up, facing +Z' }, label: `1 unit = 1 m; characters ${h} m; a 2 m grid`, why: 'one scale for every asset, so they stand together in the lineup', options: [] };
179
+ }],
180
+ ['cast.tiers', 'cast', 'What may each asset cost on a phone?', ['style.camera', 'game.devices'], (ctx) => {
181
+ const big = ctx.players > 8;
182
+ const v = { hero: { triangles: big ? 5000 : 8000, texturePx: 1024, kb: 1536, bones: 48 }, npc: { triangles: 3000, texturePx: 512, kb: 600, bones: 32 }, prop: { triangles: 1500, texturePx: 512, kb: 300 }, signature: { triangles: 5000, texturePx: 1024, kb: 800 }, kit: { triangles: 1000, texturePx: 512, kb: 200 }, scene: { drawCalls: 100, triangles: 150_000, textureMB: 48, firstPlayMB: 5 } };
183
+ return { value: v, label: `hero ${v.hero.triangles.toLocaleString('en-US')} · prop 1,500 · scene 150k triangles, 100 draws, 48 MB textures, 5 MB first play`, why: `phone first${big ? `; rooms of ${ctx.players} make heroes lighter` : ''}`, options: [] };
184
+ }],
185
+ ['cast.variation', 'cast', 'How many looks per character from one model?', [], (ctx) => ({ value: { paletteSwaps: Math.min(4, Math.max(2, ctx.players)), partSwaps: false, decals: false }, label: '4 palette swaps of the hero', why: 'team colours without another model', options: [] })],
186
+
187
+ ['rig.skeleton', 'rigs', 'Which skeleton standard?', [], (ctx) => {
188
+ const beast = /\b(fox(es)?|cats?|dogs?|wolf|wolves|bears?|horses?|deer|rabbits?|bunn(y|ies)|animals?|pets?|creatures?|dragons?)\b/i.test(ctx.words);
189
+ return { value: beast ? 'quadruped' : 'humanoid', label: beast ? 'Per-species (four legs)' : 'Homie humanoid (VRM names, Mixamo in)', why: beast ? `your characters are animals (${said(ctx, /\b(fox(es)?|cats?|dogs?|wolf|wolves|bears?|horses?|deer|rabbits?|bunn(y|ies)|animals?|pets?|creatures?|dragons?)\b/i)})` : 'people: one humanoid vocabulary shares one clip library', options: [{ id: 'humanoid', label: 'Humanoid', value: 'humanoid' }, { id: 'quadruped', label: 'Quadruped', value: 'quadruped' }, { id: 'none', label: 'None', value: 'none' }] };
190
+ }],
191
+ ['rig.source', 'rigs', 'Where do rigs come from?', ['rig.skeleton'], () => ({ value: 'library', label: 'The library\'s rigs', why: 'free; generated rigs are phase 2', options: [] })],
192
+ ['rig.bones', 'rigs', 'How many bones?', ['cast.tiers', 'style.camera'], () => ({ value: { hero: 48, npc: 32, fingers: false, influences: 4 }, label: '48 a hero, 32 an NPC, 4 influences', why: 'three.js reads 4 influences; fingers only for close cameras', options: [] })],
193
+ ['rig.sockets', 'rigs', 'Where do held things attach?', [], () => ({ value: ['hand.R', 'hand.L', 'head', 'back'], label: 'hand.R, hand.L, head, back', why: 'the standard set', options: [] })],
194
+ ['rig.face', 'rigs', 'How do faces move?', ['style.camera'], () => ({ value: 'none', label: 'None (phones)', why: 'a face is a few pixels on a phone at this camera', options: [] })],
195
+
196
+ ['anim.style', 'animations', 'How does motion feel?', ['style.render'], (ctx, p) => {
197
+ const k = p['style.render'] === 'pixel-hd2d' ? 'stepped' : ctx.genre === 'rpg' ? 'grounded' : 'snappy';
198
+ return { value: k, label: ({ snappy: 'Snappy cartoon', grounded: 'Grounded, stylised', stepped: 'Stepped (pixel)' })[k], why: `a ${ctx.genre} game`, options: [] };
199
+ }],
200
+ ['anim.clips', 'animations', 'Which clips each role needs?', [], (ctx) => ({ value: (GENRES[ctx.genre] ?? GENRES.party).clips, label: (GENRES[ctx.genre] ?? GENRES.party).clips.join(', '), why: `the verbs of a ${ctx.genre} game`, options: [] })],
201
+ ['anim.source', 'animations', 'Where do clips come from?', ['anim.clips'], () => ({ value: 'library', label: 'Library clips, retargeted', why: 'free first (phase 2 adds generated motion)', options: [] })],
202
+ ['anim.motion', 'animations', 'In place or root motion?', [], () => ({ value: { root: 'in-place', fps: 30 }, label: 'In place, 30 fps', why: 'the host moves bodies; clips never do (netplay)', options: [] })],
203
+ ['anim.blend', 'animations', 'Crossfades and layers?', ['anim.style'], (ctx, p) => ({ value: { crossfadeS: p['anim.style'] === 'grounded' ? 0.25 : 0.12, upperBody: true, additiveHits: true }, label: p['anim.style'] === 'grounded' ? '0.25 s crossfades' : '0.12 s crossfades', why: 'follows the motion style', options: [] })],
204
+ ['anim.procedural', 'animations', 'Which procedural layers?', ['style.camera'], (ctx) => ({ value: { lookAt: true, footIK: false, springs: /\b(tail|cape|hair|fox|ears?)\b/i.test(ctx.words), lean: true, squash: true }, label: 'Look-at, lean, squash; springs on tails and capes', why: 'cheap life on top of clips; foot planting off at this camera', options: [] })],
205
+
206
+ ['game.devices', 'game', 'Which devices set the budgets?', [], () => ({ value: { phone: 1, computer: 2 }, label: 'Phone first; computers get 2x', why: 'every Homie game plays on phones', options: [] })],
207
+ ['game.lod', 'game', 'Levels of detail and crowds?', ['game.devices'], (ctx) => ({ value: { lod1At: 18, crowd: ctx.players > 8 ? 'instanced biped beyond 18 m' : 'off' }, label: ctx.players > 8 ? 'Crowd mode for far players' : 'One level of detail', why: ctx.players > 8 ? `rooms of ${ctx.players}` : 'small rooms', options: [] })],
208
+ ['game.shadows', 'game', 'Shadows?', ['game.devices', 'style.light'], () => ({ value: { phone: 'blob', computer: 'real' }, label: 'Blob on phones, real on computers', why: 'shadow maps cost a phone a whole pass', options: [] })],
209
+ ['game.feel', 'game', 'The Game Lab tunables for feel?', ['anim.style'], () => ({ value: { hitStopMs: 70, crossfadeS: 0.12, lean: 0.25 }, label: 'Defaults from the motion style', why: 'tuned side by side in the Game Lab', options: [] })],
210
+ ]);
211
+ export const CATALOGUE_IDS = CATALOGUE.map(([id]) => id);
212
+ const CAT = new Map(CATALOGUE.map(([id, phase, question, dependsOn, pick]) => [id, { id, phase, question, dependsOn, pick }]));
213
+
214
+ /**
215
+ * A codex palette that is only the studio's site colours (what `codex new` copies in) is not the game's own: the
216
+ * automatic pick reads the person's words instead.
217
+ */
218
+ function isStudioTheme(p, theme = {}) {
219
+ const same = (a, b) => String(a ?? '').toLowerCase() === String(b ?? '').toLowerCase();
220
+ return (same(p.bg, theme.bg ?? '#0b0c12') && same(p.ink, theme.fg ?? '#f1f3f9')) || (same(p.bg, '#0b0c12') && same(p.accent, '#ffcf5a'));
221
+ }
222
+
223
+ function paletteOptions(ctx) {
224
+ const scored = Object.entries(PALETTES).map(([k, v]) => [k, ctx.score(v.words)]).sort((a, b) => b[1] - a[1]);
225
+ return scored.slice(0, 6).map(([k]) => ({ id: k, label: PALETTES[k].label, value: paletteValue(k) }));
226
+ }
227
+
228
+ /** A 12-colour ramp from a palette's seven: each, and a darker and lighter step of the main three. */
229
+ function rampFrom(p) {
230
+ const out = PALETTE_KEYS.map((k) => p[k]).filter((c) => HEX.test(String(c)));
231
+ for (const k of ['bg', 'accent', 'accent2']) if (HEX.test(String(p[k]))) out.push(nudge(p[k], { light: -0.15 }), nudge(p[k], { light: 0.15 }));
232
+ return [...new Set(out)].slice(0, 16);
233
+ }
234
+
235
+ /** The cast a game needs: the codex's cards first, then the genre's starter list, named from the person's words. */
236
+ function castFrom(ctx) {
237
+ const out = [];
238
+ const add = (id, card, kind, tier, heightM) => { if (!out.some((x) => x.id === id)) out.push({ id, card, kind, tier, heightM }); };
239
+ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'asset';
240
+ for (const c of ctx.cards) add(slug(c.title), `${c.section === 'characters' ? 'Characters' : c.section === 'items' ? 'Items' : 'Places'}/${c.title}`, c.section === 'characters' ? 'character' : c.section === 'items' ? 'prop' : 'kit', c.section === 'characters' ? 'hero' : c.section === 'items' ? 'prop' : 'kit', c.section === 'characters' ? 1 : c.section === 'items' ? 0.5 : 2);
241
+ const w = ctx.words.toLowerCase();
242
+ const who = /\b(fox(es)?|cats?|dogs?|bears?|rabbits?|bunn(y|ies)|frogs?|penguins?|robots?|knights?|wizards?|pirates?|aliens?|ninjas?|monkeys?|ducks?|pigs?)\b/.exec(w)?.[0];
243
+ const what = /\b(berr(y|ies)|gems?|coins?|stars?|apples?|mushrooms?|acorns?|crystals?|eggs?|fish|cakes?|crowns?|flags?|orbs?)\b/.exec(w)?.[0];
244
+ const sing = (s) => s.replace(/ies$/, 'y').replace(/(xes|ches|shes)$/, (m) => m.slice(0, -2)).replace(/s$/, '');
245
+ if (!out.some((x) => x.kind === 'character')) add(who ? sing(who) : 'player', `Characters/${who ? sing(who)[0].toUpperCase() + sing(who).slice(1) : 'Player'}`, 'character', 'hero', 1);
246
+ if (what && !out.some((x) => x.kind === 'prop' && x.id.startsWith(sing(what)))) add(sing(what), `Items/${sing(what)[0].toUpperCase() + sing(what).slice(1)}`, 'prop', 'prop', 0.4);
247
+ const places = { forest: ['tree', 'bush', 'rock', 'mushroom'], desert: ['cactus', 'rock', 'dune'], sea: ['rock', 'palm', 'boat'], ocean: ['rock', 'palm', 'boat'], space: ['crate', 'antenna', 'rock'], dungeon: ['wall', 'pillar', 'chest'], city: ['building', 'lamp', 'crate'], snow: ['pine', 'rock', 'snowman'] };
248
+ const place = Object.keys(places).find((p) => w.includes(p)) ?? (ctx.genre === 'gather' ? 'forest' : null);
249
+ for (const p of place ? places[place] : ['rock', 'crate']) add(p, `Places/${p[0].toUpperCase() + p.slice(1)}`, 'prop', 'prop', p === 'tree' || p === 'pine' || p === 'palm' || p === 'building' ? 3 : 0.8);
250
+ add('ground', 'Places/Ground', 'environment', 'kit', 0);
251
+ return out.slice(0, 40);
252
+ }
253
+
254
+ /* ------------------------------------------------------------------ the file */
255
+
256
+ const fileOf = (root, id) => join(root, 'games', id, DECISIONS_FILE);
257
+
258
+ function gameDirOf(root, id) {
259
+ if (!GAME_ID.test(String(id ?? ''))) throw new Error(`"${id}" is not a game id`);
260
+ const dir = join(root, 'games', id);
261
+ if (!existsSync(join(dir, 'game.json')) && !existsSync(join(dir, 'CODEX.md'))) throw new Error(`no game "${id}" (no games/${id}/game.json or CODEX.md)${listGames(root).length ? `; games: ${listGames(root).map((g) => g.id).join(', ')}` : ''}`);
262
+ return dir;
263
+ }
264
+
265
+ export function readDecisions(root, id) {
266
+ const file = fileOf(root, id);
267
+ if (!existsSync(file)) return null;
268
+ const doc = JSON.parse(readFileSync(file, 'utf8'));
269
+ if (doc?.v !== 1 || typeof doc.decisions !== 'object') throw new Error(`games/${id}/${DECISIONS_FILE} is not a decisions file (v 1)`);
270
+ return doc;
271
+ }
272
+
273
+ /** Write it (atomically), and keep games/<id>/style.json in step. */
274
+ export function writeDecisions(root, id, doc) {
275
+ const file = fileOf(root, id);
276
+ mkdirSync(dirname(file), { recursive: true });
277
+ doc.derived = { ...(doc.derived ?? {}), prompt: derivedPrompt(doc) };
278
+ const tmp = `${file}.tmp`;
279
+ writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`);
280
+ renameSync(tmp, file);
281
+ writeStyleTokens(root, id, doc);
282
+ return doc;
283
+ }
284
+
285
+ /** games/<id>/style.json: the palette and fonts the game, its landing and its title cards draw with (public). */
286
+ export function styleTokens(doc) {
287
+ const p = doc?.decisions?.['style.palette']?.value;
288
+ const ui = doc?.decisions?.['style.ui']?.value;
289
+ const light = doc?.decisions?.['style.light']?.value;
290
+ const cam = doc?.decisions?.['style.camera']?.value;
291
+ if (!p) return null;
292
+ return {
293
+ v: 1,
294
+ palette: Object.fromEntries([...PALETTE_KEYS.map((k) => [k, p[k]]), ['ramp', p.ramp ?? []]]),
295
+ fonts: ui ? { display: ui.display, body: ui.body } : null,
296
+ light: light ? { time: light.time, key: light.key, intensity: light.intensity, hardness: light.hardness, sky: light.sky, ground: light.ground, fog: light.fog, bloom: light.bloom } : null,
297
+ camera: cam ? { angle: cam.angle, projection: cam.projection, pitch: cam.pitch, distance: cam.distance, fov: cam.fov } : null,
298
+ render: doc.decisions['style.render']?.value ?? null,
299
+ materials: doc.decisions['style.materials']?.value ?? null,
300
+ rev: Object.fromEntries(['style.palette', 'style.ui', 'style.light', 'style.camera'].map((k) => [k, doc.decisions[k]?.rev ?? 0])),
301
+ };
302
+ }
303
+ function writeStyleTokens(root, id, doc) {
304
+ const t = styleTokens(doc);
305
+ if (!t) return;
306
+ const file = join(root, 'games', id, 'style.json');
307
+ const text = `${JSON.stringify(t, null, 2)}\n`;
308
+ if (!existsSync(file) || readFileSync(file, 'utf8') !== text) writeFileSync(file, text);
309
+ }
310
+
311
+ /**
312
+ * A record's change: set the state, append to its history (newest last, at most 20). The rev moves only when the VALUE
313
+ * changes: a lock or a pin keeps every asset made under it current.
314
+ */
315
+ function change(rec, patch, { by = 'ai', words = null, reason = null } = {}) {
316
+ const at = now();
317
+ const moved = Object.hasOwn(patch, 'value') && JSON.stringify(patch.value) !== JSON.stringify(rec.value);
318
+ Object.assign(rec, patch, { rev: (rec.rev ?? 0) + (moved || !rec.rev ? 1 : 0), at, by });
319
+ rec.history = [...(rec.history ?? []), { rev: rec.rev, state: rec.state, by, label: rec.label, ...(patch.steer ? { steer: patch.steer.at(-1) } : {}), ...(words ? { words: clean(words) } : {}), ...(reason ? { reason: clean(reason) } : {}), at }].slice(-HISTORY_MAX);
320
+ return rec;
321
+ }
322
+
323
+ /**
324
+ * `style init`: fill every decision with its automatic pick (state auto) from the person's words, the codex and the
325
+ * genre. A decision the person steered or locked, or that an asset pinned, is never re-picked.
326
+ */
327
+ export function initDecisions(root, id, { prompt = '', path = null, budget = null } = {}) {
328
+ gameDirOf(root, id);
329
+ const ctx = contextOf(root, id, { prompt });
330
+ const doc = readDecisions(root, id) ?? { v: 1, scope: 'game', game: id, path: path ?? 'automatic', budget: null, prompt: '', decisions: {}, board: null, golden: [] };
331
+ if (path) doc.path = path === 'hands-on' ? 'hands-on' : 'automatic';
332
+ if (budget !== null && budget !== undefined) doc.budget = { usd: Math.max(0, Number(budget) || 0), provider: 'fal', approvedBy: 'the person, in chat', at: now() };
333
+ if (prompt) doc.prompt = clean(prompt, 600);
334
+ // Run again without words (to set a budget, or after the codex grew), it reads the words it was first given.
335
+ if (!prompt && doc.prompt) ctx.prompt = doc.prompt;
336
+ if (!prompt && doc.prompt) { const again = contextOf(root, id, { prompt: doc.prompt }); Object.assign(ctx, again); }
337
+ const picked = {};
338
+ const kept = [];
339
+ const made = [];
340
+ for (const [did] of CATALOGUE) {
341
+ const def = CAT.get(did);
342
+ const rec = doc.decisions[did];
343
+ if (rec && rec.state !== 'auto') { picked[did] = rec.value; kept.push(did); continue; }
344
+ const pick = def.pick(ctx, picked);
345
+ picked[did] = pick.value;
346
+ if (rec && JSON.stringify(rec.value) === JSON.stringify(pick.value)) continue;
347
+ const next = rec ?? { phase: def.phase, question: def.question, state: 'auto', by: 'ai', steer: [], rev: 0, dependsOn: def.dependsOn, history: [] };
348
+ change(next, { value: pick.value, label: pick.label, why: pick.why, options: (pick.options ?? []).slice(0, 8), state: 'auto', phase: def.phase, question: def.question, dependsOn: def.dependsOn }, { by: 'ai' });
349
+ doc.decisions[did] = next;
350
+ made.push(did);
351
+ }
352
+ writeDecisions(root, id, doc);
353
+ return { ok: true, command: 'style init', id, picked: made, kept, genre: ctx.genre, summary: oneLine(doc), file: `games/${id}/${DECISIONS_FILE}` };
354
+ }
355
+
356
+ /** The one line the automatic path says: "Look: flat low-poly, autumn grove palette, high three-quarter camera". */
357
+ export function oneLine(doc) {
358
+ const d = doc.decisions;
359
+ return `Look: ${(d['style.render']?.label ?? '?').toLowerCase()}, ${(d['style.palette']?.label ?? '?').toLowerCase()} palette, ${(d['style.light']?.label ?? '?').toLowerCase()}, ${(d['style.camera']?.label ?? '?').toLowerCase()} camera, ${d['style.ui']?.label ?? ''}`;
360
+ }
361
+
362
+ /** Everything that depends on `id`, directly or not (the decisions it drives). */
363
+ export function drives(doc, id) {
364
+ const out = new Set();
365
+ const walk = (x) => { for (const [k, rec] of Object.entries(doc.decisions)) if ((rec.dependsOn ?? CAT.get(k)?.dependsOn ?? []).includes(x) && !out.has(k)) { out.add(k); walk(k); } };
366
+ walk(id);
367
+ return [...out];
368
+ }
369
+
370
+ /** The value a person typed or tapped for a decision: an option's id, JSON, a preset name, or plain words. */
371
+ function valueFrom(id, rec, input) {
372
+ if (input && typeof input === 'object') return { value: input, label: null };
373
+ const s = String(input ?? '').trim();
374
+ const opt = (rec.options ?? []).find((o) => o.id === s || o.label.toLowerCase() === s.toLowerCase());
375
+ if (opt) return { value: id === 'style.light' ? lightValue(opt.value, docPalette) : id === 'style.camera' ? cameraValue(opt.value) : id === 'style.palette' && typeof opt.value === 'string' ? paletteValue(opt.value) : opt.value, label: opt.label };
376
+ if (/^[[{]/.test(s)) { try { return { value: JSON.parse(s), label: null }; } catch { throw new Error(`${id}: not JSON: ${s.slice(0, 80)}`); } }
377
+ if (id === 'style.render' && RENDERS[s]) return { value: s, label: RENDERS[s].label };
378
+ if (id === 'style.palette' && PALETTES[s]) return { value: paletteValue(s), label: paletteLabel({ name: s }) };
379
+ if (id === 'style.light' && LIGHTS[s]) return { value: lightValue(s, docPalette), label: LIGHTS[s].label };
380
+ if (id === 'style.camera' && CAMERAS[s]) return { value: cameraValue(s), label: CAMERAS[s].label };
381
+ if (id === 'cast.family' && FAMILIES[s]) return { value: s, label: FAMILIES[s].label };
382
+ if (id === 'style.shape' && SHAPES[s]) return { value: { language: s, bevel: SHAPES[s].bevel, silhouette: rec.value?.silhouette }, label: SHAPES[s].label };
383
+ if (id === 'style.ui' && FONT_SETS[s]) return { value: { ...rec.value, ...FONT_SETS[s] }, label: `${FONT_SETS[s].display} and ${FONT_SETS[s].body}` };
384
+ return { value: s, label: s };
385
+ }
386
+ let docPalette = paletteValue('meadow-morning');
387
+
388
+ /** A label for a value the catalogue did not label. */
389
+ export function labelOf(id, value) {
390
+ const v = value ?? {};
391
+ if (id === 'style.palette') return paletteLabel(value);
392
+ if (id === 'style.render') return RENDERS[value]?.label ?? String(value);
393
+ if (id === 'style.light') return `${LIGHTS[v.time]?.label ?? 'Own light'}${v.hardness !== undefined && LIGHTS[v.time] && Math.abs(v.hardness - LIGHTS[v.time].hardness) > 0.01 ? (v.hardness > LIGHTS[v.time].hardness ? ', harder shadows' : ', softer shadows') : ''}`;
394
+ if (id === 'style.camera') return `${CAMERAS[v.angle]?.label ?? 'Own camera'}${v.distance && CAMERAS[v.angle] && Math.abs(v.distance - CAMERAS[v.angle].distance) > 0.01 ? `, ${v.distance} m away` : ''}${v.pitch && CAMERAS[v.angle] && Math.abs(v.pitch - CAMERAS[v.angle].pitch) > 0.01 ? `, ${Math.round(v.pitch)}° down` : ''}`;
395
+ if (id === 'style.ui' && v.display) return `${v.display} and ${v.body}`;
396
+ if (id === 'style.shape' && v.language) return `${SHAPES[v.language]?.label ?? v.language}${v.bevel !== undefined && SHAPES[v.language] && Math.abs(v.bevel - SHAPES[v.language].bevel) > 0.01 ? (v.bevel > SHAPES[v.language].bevel ? ', chunkier bevels' : ', crisper edges') : ''}`;
397
+ if (id === 'style.materials' && v.model) return `${({ flat: 'Flat palette colours', toon: 'Toon ramp', 'hand-painted': 'Hand-painted albedo', pbr: 'PBR metal and roughness', pixel: 'Pixel textures' })[v.model] ?? v.model}${v.outline ? ', outlines' : ''}`;
398
+ if (id === 'style.proportions' && v.heads) return `${v.heads} heads tall, ${v.heightM} m`;
399
+ if (id === 'style.vfx' && v.particles) return `${({ soft: 'Soft glows', toon: 'Toon shapes', pixel: 'Pixel squares' })[v.particles] ?? v.particles}${v.glow ? ', glow' : ''}`;
400
+ if (id === 'cast.family') return FAMILIES[value]?.label ?? String(value);
401
+ if (typeof value === 'string') return value.slice(0, 60);
402
+ return JSON.stringify(value).slice(0, 60);
403
+ }
404
+
405
+ /**
406
+ * Set one decision. `by`: 'ai' or 'person'. The rules:
407
+ * - a locked decision changes only with `unlock: true` and a `reason`; without `confirm: true` nothing changes and
408
+ * the answer is the blast radius (what would go stale, and what remaking it would cost);
409
+ * - the AI changes a pinned decision only while no paid asset depends on it (else: ask the person);
410
+ * - a person's change makes it steered; the AI's keeps it auto.
411
+ * `manifest` (the game's assets/manifest.json) prices the blast radius.
412
+ */
413
+ export function setDecision(root, id, did, input, { by = 'ai', why = null, words = null, unlock = false, reason = null, confirm = false, manifest = null } = {}) {
414
+ const doc = readDecisions(root, id);
415
+ if (!doc) throw new Error(`games/${id} has no decisions yet: homie-studio style init ${id}`);
416
+ const rec = doc.decisions[did];
417
+ if (!rec) throw new Error(`no decision "${did}" (${CATALOGUE_IDS.slice(0, 10).join(', ')}, …)`);
418
+ docPalette = doc.decisions['style.palette']?.value ?? docPalette;
419
+ const { value, label } = valueFrom(did, rec, input);
420
+ if (rec.state === 'locked') {
421
+ if (!unlock) return { ok: false, command: 'style set', id, decision: did, locked: true, why: `${did} is locked (${rec.label}${rec.by === 'person' ? ', by the person' : ''}): change it only with --unlock and a reason, after the person sees what goes stale` };
422
+ if (!reason) return { ok: false, command: 'style set', id, decision: did, locked: true, why: 'a locked decision changes only with a reason: --reason "<what the person asked for>"' };
423
+ const blast = blastRadius(doc, did, manifest, { to: label ?? labelOf(did, value) });
424
+ if (!confirm) return { ok: true, command: 'style set', id, decision: did, pending: true, blast, why: 'Nothing changed yet: this is what would go stale. It changes only once the person says yes (confirm), and nothing is remade by itself.' };
425
+ change(rec, { value, label: label ?? labelOf(did, value), why: why ?? `the person changed it: ${clean(reason)}`, state: 'locked' }, { by: 'person', reason });
426
+ writeDecisions(root, id, doc);
427
+ addLatestLine(root, id, `Changed the locked ${humanName(did)} to ${rec.label} (${clean(reason, 120)}). ${blast.assets.length ? `${blast.assets.length} asset${blast.assets.length === 1 ? '' : 's'} made under the old one ${blast.assets.length === 1 ? 'is' : 'are'} stale; none was remade.` : 'Nothing was made under the old one.'}`);
428
+ return { ok: true, command: 'style set', id, decision: did, changed: true, blast: blastRadius(readDecisions(root, id), did, manifest), record: rec };
429
+ }
430
+ if (rec.state === 'pinned' && by !== 'person') {
431
+ const paid = (manifest?.assets ?? []).filter((a) => a.made?.under?.[did] !== undefined && ['generated', 'premium'].includes(a.route));
432
+ if (paid.length) return { ok: false, command: 'style set', id, decision: did, pinned: true, why: `${did} is pinned by ${paid.length} paid asset(s) (${paid.map((a) => a.id).join(', ')}): ask the person first; their yes changes it with --by person` };
433
+ }
434
+ if (rec.state === 'steered' && by !== 'person' && !rec.steer?.length) { /* a steer without words: the AI may refine */ }
435
+ const state = by === 'person' ? (rec.state === 'pinned' ? 'pinned' : 'steered') : rec.state === 'pinned' ? 'auto' : rec.state;
436
+ change(rec, { value, label: label ?? labelOf(did, value), why: why ?? (by === 'person' ? 'the person chose it' : rec.why), state }, { by, words });
437
+ if (did === 'style.palette' && doc.decisions['style.light']?.state === 'auto') {
438
+ const L = doc.decisions['style.light'];
439
+ const relit = lightValue(L.value.time, rec.value);
440
+ if (JSON.stringify(relit) !== JSON.stringify(L.value)) change(L, { value: relit, state: 'auto' }, { by: 'ai' });
441
+ }
442
+ writeDecisions(root, id, doc);
443
+ return { ok: true, command: 'style set', id, decision: did, changed: true, record: rec, stale: staleAssets(doc, manifest).map((s) => s.id) };
444
+ }
445
+
446
+ /**
447
+ * Steer: the person's words, applied where the vocabulary knows them ("warmer", "less saturated", "golden hour",
448
+ * "closer", "chunkier", "add outlines", a hex colour), and always recorded. The decision becomes steered.
449
+ */
450
+ export function steerDecision(root, id, did, words, { manifest = null } = {}) {
451
+ const doc = readDecisions(root, id);
452
+ if (!doc) throw new Error(`games/${id} has no decisions yet: homie-studio style init ${id}`);
453
+ const rec = doc.decisions[did];
454
+ if (!rec) throw new Error(`no decision "${did}"`);
455
+ const w = clean(words, 120);
456
+ if (!w) throw new Error('steer with words: "warmer", "less saturated", "closer", "golden hour", …');
457
+ if (rec.state === 'locked') return { ok: false, command: 'style steer', id, decision: did, locked: true, why: `${did} is locked: unlock it (with a reason, after the blast radius) before steering it` };
458
+ const { value, applied } = applySteer(did, rec.value, w, doc);
459
+ const steer = [...(rec.steer ?? []), w].slice(-10);
460
+ change(rec, { value, label: did === 'style.palette' ? paletteLabel({ ...value, steered: steer.join(', ') }) : labelOf(did, value), steer, state: rec.state === 'pinned' ? 'pinned' : 'steered', why: `steered by the person: "${w}"` }, { by: 'person', words: w });
461
+ if (did === 'style.palette' && doc.decisions['style.light'] && doc.decisions['style.light'].state !== 'locked') {
462
+ const L = doc.decisions['style.light'];
463
+ L.value = { ...L.value, ...lightColours(LIGHTS[L.value.time] ?? LIGHTS.morning, value) };
464
+ }
465
+ writeDecisions(root, id, doc);
466
+ return { ok: true, command: 'style steer', id, decision: did, applied, recorded: w, record: rec, stale: staleAssets(doc, manifest).map((s) => s.id), note: applied ? null : `"${w}" is recorded; the vocabulary does not move ${did} by itself, so refine the value with style set (the AI may refine within the nudge, never undo it)` };
467
+ }
468
+
469
+ /** What a steer word does to a value. Returns { value, applied }. */
470
+ export function applySteer(did, value, words, doc = null) {
471
+ const w = words.toLowerCase();
472
+ if (did === 'style.palette') {
473
+ const ops = { warm: /warm/.test(w) ? (/(much|lot|very) warm/.test(w) ? 0.5 : 0.28) : 0, cool: /cool|cold|icy/.test(w) ? 0.28 : 0, sat: /(less|de)[- ]?saturat|muted|softer|dull|pastel/.test(w) ? 0.7 : /(more )?saturat|punch|vivid|bolder/.test(w) ? 1.3 : 1, light: /dark|night|moody/.test(w) ? -0.12 : /light|brighter|day/.test(w) ? 0.1 : 0 };
474
+ const hexes = [...w.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/g)].map((m) => m[0]);
475
+ const applied = ops.warm || ops.cool || ops.sat !== 1 || ops.light || hexes.length;
476
+ if (!applied) return { value, applied: false };
477
+ const out = { ...value, ramp: [...(value.ramp ?? [])] };
478
+ for (const k of PALETTE_KEYS) if (HEX.test(String(out[k]))) out[k] = nudge(out[k], ops);
479
+ out.ramp = out.ramp.map((c) => (HEX.test(String(c)) ? nudge(c, ops) : c));
480
+ // A hex named with a key ("#ff8800 accent") sets that key; a bare hex sets the accent.
481
+ for (const h of hexes) { const key = PALETTE_KEYS.find((k) => new RegExp(`${h}\\s+(for\\s+)?(the\\s+)?${k}\\b|${k}\\s*[:=]?\\s*${h}`).test(w)) ?? 'accent'; out[key] = h; }
482
+ // Ink must still read on the background.
483
+ if (Math.abs(luminance(out.ink) - luminance(out.bg)) < 0.35) out.ink = luminance(out.bg) > 0.4 ? '#141414' : '#f4f1ea';
484
+ return { value: out, applied: true };
485
+ }
486
+ if (did === 'style.light') {
487
+ const t = /golden|sunset|dusk/.test(w) ? 'golden' : /night|moon/.test(w) ? 'night' : /noon|midday/.test(w) ? 'noon' : /morning|dawn/.test(w) ? 'morning' : /overcast|cloud|fog/.test(w) ? 'overcast' : null;
488
+ const pal = doc?.decisions?.['style.palette']?.value ?? docPalette;
489
+ let v = t ? lightValue(t, pal) : { ...value };
490
+ let applied = Boolean(t);
491
+ if (/hard(er)? shadows?|crisp|harder/.test(w)) { v = { ...v, hardness: Math.min(1, (v.hardness ?? 0.4) + 0.25) }; applied = true; }
492
+ if (/soft(er)? (shadows?|light)/.test(w)) { v = { ...v, hardness: Math.max(0, (v.hardness ?? 0.4) - 0.25) }; applied = true; }
493
+ if (/more fog|foggier/.test(w)) { v = { ...v, fog: Math.min(1, (v.fog ?? 0.3) + 0.25) }; applied = true; }
494
+ if (/less fog|no fog|clearer/.test(w)) { v = { ...v, fog: Math.max(0, (v.fog ?? 0.3) - 0.25) }; applied = true; }
495
+ if (/more glow|bloom/.test(w)) { v = { ...v, bloom: Math.min(1, (v.bloom ?? 0.2) + 0.2) }; applied = true; }
496
+ if (/less glow|no glow/.test(w)) { v = { ...v, bloom: Math.max(0, (v.bloom ?? 0.2) - 0.2) }; applied = true; }
497
+ return { value: v, applied };
498
+ }
499
+ if (did === 'style.camera') {
500
+ const v = { ...value };
501
+ let applied = true;
502
+ if (/closer|nearer|zoom in/.test(w)) v.distance = +(v.distance * 0.75).toFixed(2);
503
+ else if (/further|farther|zoom out|wider/.test(w)) v.distance = +(v.distance * 1.3).toFixed(2);
504
+ else if (/lower/.test(w)) v.pitch = Math.max(5, v.pitch - 10);
505
+ else if (/higher|overhead/.test(w)) v.pitch = Math.min(85, v.pitch + 10);
506
+ else { const k = Object.keys(CAMERAS).find((c) => w.includes(c.replace('-', ' ')) || w.includes(c)); if (k) return { value: cameraValue(k), applied: true }; applied = false; }
507
+ return { value: v, applied };
508
+ }
509
+ if (did === 'style.render') {
510
+ const k = /painterly|painted/.test(w) ? 'painted' : /crisper|cleaner|flatter|simpler/.test(w) ? 'lowpoly-flat' : /toon|cel|cartoon/.test(w) ? 'toon' : /pixel|retro/.test(w) ? 'pixel-hd2d' : /realistic|pbr|grounded/.test(w) ? 'stylised-pbr' : /voxel|blocky/.test(w) ? 'voxel' : null;
511
+ return k ? { value: k, applied: true } : { value, applied: false };
512
+ }
513
+ if (did === 'style.shape') {
514
+ const v = { ...value };
515
+ if (/chunk|round|soft/.test(w)) { v.language = /chunk/.test(w) ? v.language : 'round'; v.bevel = Math.min(1, (v.bevel ?? 0.4) + 0.2); return { value: v, applied: true }; }
516
+ if (/sharp|angular|pointy|edgy/.test(w)) { v.language = 'angular'; v.bevel = Math.max(0, (v.bevel ?? 0.4) - 0.2); return { value: v, applied: true }; }
517
+ if (/block|boxy/.test(w)) { v.language = 'blocky'; return { value: v, applied: true }; }
518
+ return { value, applied: false };
519
+ }
520
+ if (did === 'style.proportions') {
521
+ const v = { ...value };
522
+ if (/cuter|chibi|smaller/.test(w)) { v.heads = Math.max(2, +(v.heads - 0.5).toFixed(1)); v.hands = 'big'; return { value: v, applied: true }; }
523
+ if (/taller|heroic|realistic/.test(w)) { v.heads = Math.min(8, +(v.heads + 0.5).toFixed(1)); v.heightM = +(v.heightM + 0.1).toFixed(2); return { value: v, applied: true }; }
524
+ return { value, applied: false };
525
+ }
526
+ if (did === 'style.materials') {
527
+ const v = { ...value };
528
+ if (/add outlines?|outlined|ink/.test(w)) { v.outline = true; return { value: v, applied: true }; }
529
+ if (/no outlines?|remove outlines?/.test(w)) { v.outline = false; return { value: v, applied: true }; }
530
+ if (/more texture|textured|detail/.test(w)) { v.model = v.model === 'flat' ? 'hand-painted' : v.model; v.texelDensity = (v.texelDensity ?? 128) * 2; return { value: v, applied: true }; }
531
+ if (/flat(ter)?|plain/.test(w)) { v.model = 'flat'; v.texelDensity = null; return { value: v, applied: true }; }
532
+ return { value, applied: false };
533
+ }
534
+ if (did === 'style.ui') {
535
+ const q = /"([^"]{2,40})"|'([^']{2,40})'/.exec(words);
536
+ if (q) return { value: { ...value, display: q[1] ?? q[2] }, applied: true };
537
+ const set = Object.keys(FONT_SETS).find((k) => w.includes(k));
538
+ return set ? { value: { ...value, ...FONT_SETS[set] }, applied: true } : { value, applied: false };
539
+ }
540
+ if (did === 'style.vfx') {
541
+ if (/less glow|no glow/.test(w)) return { value: { ...value, glow: false }, applied: true };
542
+ if (/more glow|glowier/.test(w)) return { value: { ...value, glow: true }, applied: true };
543
+ return { value, applied: false };
544
+ }
545
+ return { value, applied: false };
546
+ }
547
+
548
+ /** Lock one decision, or the whole style phase ('style'). Only the person locks: `words` records what they said. */
549
+ export function lockDecision(root, id, target, { by = 'person', words = null } = {}) {
550
+ const doc = readDecisions(root, id);
551
+ if (!doc) throw new Error(`games/${id} has no decisions yet: homie-studio style init ${id}`);
552
+ if (by !== 'person') return { ok: false, command: 'style lock', why: 'only the person locks a decision (their tap on a card, or their words with --by person --words "…")' };
553
+ const ids = target === 'style' || PHASES.some((p) => p.id === target) ? Object.keys(doc.decisions).filter((k) => doc.decisions[k].phase === (target === 'style' ? 'style' : target)) : [target];
554
+ const locked = [];
555
+ for (const did of ids) {
556
+ const rec = doc.decisions[did];
557
+ if (!rec) throw new Error(`no decision "${did}"`);
558
+ if (rec.state === 'locked') continue;
559
+ change(rec, { state: 'locked' }, { by: 'person', words });
560
+ locked.push(did);
561
+ }
562
+ if (locked.length) {
563
+ writeDecisions(root, id, doc);
564
+ addLatestLine(root, id, `Locked ${locked.length > 2 ? `the ${target === 'style' ? 'style' : target} (${locked.length} decisions)` : locked.map((d) => `the ${humanName(d)}: ${doc.decisions[d].label}`).join('; ')}${words ? ` ("${clean(words, 100)}")` : ''}.`);
565
+ }
566
+ return { ok: true, command: 'style lock', id, locked, already: ids.filter((d) => !locked.includes(d)) };
567
+ }
568
+
569
+ /** Unlock: the person's word, with a reason, recorded. */
570
+ export function unlockDecision(root, id, did, { reason = null, by = 'person' } = {}) {
571
+ const doc = readDecisions(root, id);
572
+ if (!doc) throw new Error(`games/${id} has no decisions yet`);
573
+ const rec = doc.decisions[did];
574
+ if (!rec) throw new Error(`no decision "${did}"`);
575
+ if (rec.state !== 'locked') return { ok: true, command: 'style unlock', id, decision: did, already: true };
576
+ if (by !== 'person') return { ok: false, command: 'style unlock', why: 'the AI never unlocks on its own: it proposes, with the blast radius, and the person says yes' };
577
+ if (!reason) return { ok: false, command: 'style unlock', why: 'unlock with a reason: --reason "<what the person asked for>"' };
578
+ change(rec, { state: 'steered' }, { by, reason });
579
+ writeDecisions(root, id, doc);
580
+ addLatestLine(root, id, `Unlocked the ${humanName(did)} (${clean(reason, 120)}).`);
581
+ return { ok: true, command: 'style unlock', id, decision: did, record: rec };
582
+ }
583
+
584
+ /**
585
+ * Pinned by use: the first asset built on an auto decision pins it. Returns the decisions pinned now.
586
+ * Called by the asset manifest whenever an asset is recorded.
587
+ */
588
+ export function pinByUse(root, id, dids, assetId) {
589
+ const doc = readDecisions(root, id);
590
+ if (!doc) return [];
591
+ const pinned = [];
592
+ for (const did of dids) {
593
+ const rec = doc.decisions[did];
594
+ if (!rec || rec.state !== 'auto') continue;
595
+ change(rec, { state: 'pinned', pinnedBy: assetId }, { by: 'use' });
596
+ pinned.push(did);
597
+ }
598
+ if (pinned.length) writeDecisions(root, id, doc);
599
+ return pinned;
600
+ }
601
+
602
+ /** The current revision of each decision an asset kind depends on (what `made.under` records). */
603
+ export function revisionsFor(doc, dids) {
604
+ const out = {};
605
+ for (const d of dids) {
606
+ if (d === 'derived.prompt') out[d] = doc?.derived?.prompt?.rev ?? 0;
607
+ else if (doc?.decisions?.[d]) out[d] = doc.decisions[d].rev;
608
+ }
609
+ return out;
610
+ }
611
+
612
+ /** Assets made under an older revision of any decision they name: [{ id, decisions: [{ id, was, now }] }]. */
613
+ export function staleAssets(doc, manifest) {
614
+ const out = [];
615
+ for (const a of manifest?.assets ?? []) {
616
+ const old = [];
617
+ for (const [d, rev] of Object.entries(a.made?.under ?? {})) {
618
+ const nowRev = d === 'derived.prompt' ? doc?.derived?.prompt?.rev : doc?.decisions?.[d]?.rev;
619
+ if (nowRev !== undefined && nowRev !== rev) old.push({ id: d, was: rev, now: nowRev });
620
+ }
621
+ if (old.length) out.push({ id: a.id, route: a.route, decisions: old });
622
+ }
623
+ return out;
624
+ }
625
+
626
+ /**
627
+ * What changing `did` would make stale, and what remaking each would cost: [{ id, kind, route,
628
+ * card, why, remake: { usd, how }, free }]. Generated assets cost their recorded steps again; library and procedural
629
+ * ones nothing; a palette change re-tints flat-coloured assets for free with no model call.
630
+ */
631
+ export function blastRadius(doc, did, manifest, { to = null } = {}) {
632
+ const touched = new Set([did, ...drives(doc, did)]);
633
+ const promptMoves = did.startsWith('style.');
634
+ const assets = [];
635
+ for (const a of manifest?.assets ?? []) {
636
+ const under = Object.keys(a.made?.under ?? {});
637
+ const hits = under.filter((d) => touched.has(d) || (promptMoves && d === 'derived.prompt'));
638
+ if (!hits.length) continue;
639
+ const paid = (a.made?.steps ?? []).filter((s) => Number(s.usd) > 0);
640
+ const usd = +paid.reduce((n, s) => n + Number(s.usd), 0).toFixed(3);
641
+ const flat = a.measured?.flat === true || a.from?.paletteSwap === true || a.route === 'procedural';
642
+ const free = did === 'style.palette' && flat ? 'a palette re-tint (no model call, free)' : a.route === 'procedural' ? 'regenerated by its generator (free)' : a.route === 'library' ? 'another library match, or a re-tint (free)' : null;
643
+ assets.push({ id: a.id, kind: a.kind, route: a.route, card: a.card ?? null, why: hits.map((h) => (h === 'derived.prompt' ? 'its concept prompt' : humanName(h))).join(', '), remake: { usd: a.route === 'generated' ? usd : 0, how: a.route === 'generated' ? `${paid.length} paid step${paid.length === 1 ? '' : 's'} again (${paid.map((s) => s.what).join(' + ')})` : a.route === 'imported' ? 'the person\'s own file: re-exported by hand' : 'free' }, free });
644
+ }
645
+ const rec = doc.decisions[did];
646
+ return { decision: did, name: humanName(did), from: rec?.label ?? null, to, state: rec?.state ?? null, assets, totals: { assets: assets.length, usd: +assets.reduce((n, a) => n + (a.free ? 0 : a.remake.usd), 0).toFixed(3), usdIfAllRemade: +assets.reduce((n, a) => n + a.remake.usd, 0).toFixed(3), free: assets.filter((a) => a.free).length }, decisions: [...touched].filter((d) => d !== did), note: 'Nothing is remade by itself: the person approves the list, under the budget.' };
647
+ }
648
+
649
+ const HUMAN = { 'style.render': 'render style', 'style.palette': 'palette', 'style.shape': 'shape language', 'style.proportions': 'proportions', 'style.materials': 'materials', 'style.light': 'light', 'style.camera': 'camera', 'style.ui': 'fonts and HUD', 'style.vfx': 'effects', 'style.refs': 'references', 'cast.list': 'cast', 'cast.routes': 'routes', 'cast.family': 'library family', 'cast.scale': 'scale', 'cast.tiers': 'budgets', 'cast.variation': 'variations' };
650
+ export const humanName = (did) => HUMAN[did] ?? did.split('.').pop();
651
+
652
+ /* ------------------------------------------------------------------ the derived style prompt */
653
+
654
+ /**
655
+ * About 80 words for every generation call, computed from the style decisions and never edited by hand: medium, shape,
656
+ * palette hexes, light, camera, then "plain background, no text". Its rev moves only when its text does.
657
+ */
658
+ export function derivedPrompt(doc) {
659
+ const d = doc.decisions ?? {};
660
+ const r = RENDERS[d['style.render']?.value]?.label ?? 'Stylised';
661
+ const shape = d['style.shape']?.value;
662
+ const pal = d['style.palette']?.value ?? {};
663
+ const mat = d['style.materials']?.value ?? {};
664
+ const light = d['style.light']?.value ?? {};
665
+ const hexes = [pal.accent, pal.accent2, pal.gold, pal.good, pal.danger, ...(pal.ramp ?? []).slice(0, 6)].filter((c) => HEX.test(String(c)));
666
+ const text = [
667
+ `${r} 3D game asset`,
668
+ shape ? `${SHAPES[shape.language]?.label.toLowerCase() ?? shape.language} shapes${shape.bevel > 0.4 ? ' with soft bevels' : ' with crisp edges'}` : null,
669
+ ({ flat: 'flat matte colours, no texture noise', toon: 'cel shading in two or three bands', 'hand-painted': 'soft hand-painted texture', pbr: 'clean stylised PBR materials', pixel: 'crisp pixel texture' })[mat.model] ?? null,
670
+ mat.outline ? 'thin dark outline' : null,
671
+ hexes.length ? `palette ${[...new Set(hexes)].slice(0, 8).join(' ')}` : null,
672
+ `${LIGHTS[light.time]?.label.toLowerCase() ?? 'soft'} feel but lit evenly and neutrally for modelling`,
673
+ '3/4 view, centred, whole object in frame, plain light grey background, no ground shadow, no text, no logo',
674
+ ].filter(Boolean).join(', ');
675
+ const prev = doc.derived?.prompt;
676
+ const from = Object.fromEntries(['style.render', 'style.shape', 'style.palette', 'style.materials', 'style.light'].map((k) => [k, d[k]?.rev ?? 0]));
677
+ return { text, rev: prev?.text === text ? prev.rev : (prev?.rev ?? 0) + 1, from: prev?.text === text ? prev.from ?? from : from };
678
+ }
679
+
680
+ /* ------------------------------------------------------------------ directions for the style board */
681
+
682
+ /**
683
+ * Three coherent directions: A is the automatic pick; B and C contrast it within what the game's
684
+ * words allow (another render style and palette, another light and camera). Each is a full set of style values.
685
+ */
686
+ export function directionsFor(root, id, { prompt = '' } = {}) {
687
+ const doc = readDecisions(root, id);
688
+ const ctx = contextOf(root, id, { prompt: prompt || doc?.prompt || '' });
689
+ const base = {};
690
+ for (const [did] of CATALOGUE.filter(([, ph]) => ph === 'style')) base[did] = doc?.decisions?.[did]?.value ?? CAT.get(did).pick(ctx, base).value;
691
+ const renderA = base['style.render'];
692
+ const palA = base['style.palette']?.name;
693
+ const rank = (table, skip) => Object.entries(table).filter(([k]) => !skip.includes(k)).map(([k, v]) => [k, v.words ? ctx.score(v.words) : 0]).sort((a, b) => b[1] - a[1]).map(([k]) => k);
694
+ const renderB = rank(RENDERS, [renderA, 'pixel-hd2d', 'stylised-pbr'])[0] ?? 'toon';
695
+ const renderC = rank(RENDERS, [renderA, renderB, 'stylised-pbr'])[0] ?? 'painted';
696
+ const pals = rank(PALETTES, [palA]);
697
+ const lightA = base['style.light']?.time ?? 'morning';
698
+ const lights = rank(LIGHTS, [lightA]);
699
+ const camA = base['style.camera']?.angle ?? 'high-3/4';
700
+ const cams = [camA === 'high-3/4' ? 'close-3/4' : 'high-3/4', camA === 'iso' ? 'top-down' : 'iso'];
701
+ // A palette's own light: night palettes at night, autumn at golden hour, unless the person's words named one.
702
+ const lightOf = (palName) => (best(LIGHTS, ctx)?.[0]) ?? ({ 'moonlit-grove': 'night', 'neon-dusk': 'night', 'embers-night': 'night', 'space-ink': 'night', 'desert-noon': 'noon', 'autumn-grove': 'golden', 'ash-dawn': 'overcast', 'heroic-dawn': 'morning' })[palName] ?? 'morning';
703
+ const make = (dir, render, palName, lightKey, camKey) => {
704
+ const r = RENDERS[render];
705
+ const palette = palName ? paletteValue(palName) : base['style.palette'];
706
+ const shape = render === 'voxel' ? 'blocky' : render === 'toon' && base['style.shape']?.language === 'round' ? 'round' : base['style.shape']?.language ?? 'round';
707
+ const values = {
708
+ 'style.render': render,
709
+ 'style.palette': palette,
710
+ 'style.shape': { language: shape, bevel: SHAPES[shape].bevel, silhouette: base['style.shape']?.silhouette ?? 'every character readable black on white at 64 px tall' },
711
+ 'style.proportions': base['style.proportions'],
712
+ 'style.materials': { model: r.materials, outline: r.outline, texelDensity: { flat: null, toon: 128, 'hand-painted': 256, pbr: 384, pixel: 32 }[r.materials] ?? null, atlas: r.materials === 'flat' },
713
+ 'style.light': lightValue(lightKey, palette),
714
+ 'style.camera': cameraValue(camKey),
715
+ 'style.ui': { ...(base['style.ui'] ?? {}), ...r.fonts, radius: shape === 'angular' ? 4 : shape === 'blocky' ? 8 : 14 },
716
+ 'style.vfx': { particles: r.vfx, hitFlash: true, trails: false, glow: LIGHTS[lightKey].bloom > 0.2 },
717
+ };
718
+ return { id: dir, label: `${r.label} · ${paletteLabel(palette)} · ${LIGHTS[lightKey].label}`, values, family: r.family };
719
+ };
720
+ const a = make('a', renderA, null, lightA, camA);
721
+ const palB = pals[0] ?? 'candy-pop';
722
+ const palC = pals.find((p) => p !== palB && lightOf(p) !== lightOf(palB)) ?? pals[1] ?? 'moonlit-grove';
723
+ const dirs = [
724
+ { ...a, values: { ...a.values, ...base } },
725
+ make('b', renderB, palB, lightOf(palB) === lightA ? lights[0] ?? 'golden' : lightOf(palB), cams[0]),
726
+ make('c', renderC, palC, lightOf(palC), cams[1]),
727
+ ];
728
+ dirs[0].label = `${RENDERS[renderA].label} · ${doc?.decisions?.['style.palette']?.label ?? paletteLabel(base['style.palette'])} · ${LIGHTS[lightA].label}`;
729
+ return { ctx: { genre: ctx.genre }, directions: dirs };
730
+ }
731
+
732
+ /** Pick a direction (all its style values) or Mix rows from several: { 'style.palette': 'b', … }. By the person. */
733
+ export function pickDirection(root, id, choice, { mixes = {}, manifest = null } = {}) {
734
+ const doc = readDecisions(root, id);
735
+ if (!doc?.board?.directions?.length) throw new Error(`games/${id} has no style board yet: homie-studio style board ${id}`);
736
+ const by = Object.fromEntries(doc.board.directions.map((d) => [d.id, d]));
737
+ if (choice && !by[choice]) throw new Error(`no direction "${choice}" (${Object.keys(by).join(', ')})`);
738
+ const changed = [];
739
+ const refused = [];
740
+ const rows = new Set([...(choice ? Object.keys(by[choice].values) : []), ...Object.keys(mixes)]);
741
+ for (const did of rows) {
742
+ const from = by[mixes[did] ?? choice];
743
+ if (!from) throw new Error(`no direction "${mixes[did]}" for ${did}`);
744
+ const rec = doc.decisions[did];
745
+ if (!rec) continue;
746
+ const value = from.values[did];
747
+ if (value === undefined || JSON.stringify(rec.value) === JSON.stringify(value)) continue;
748
+ if (rec.state === 'locked') { refused.push(did); continue; }
749
+ change(rec, { value, label: labelOf(did, value), why: `the person picked direction ${from.id.toUpperCase()} on the style board`, state: rec.state === 'pinned' ? 'pinned' : 'steered' }, { by: 'person' });
750
+ changed.push(did);
751
+ }
752
+ doc.board.chosen = choice ?? doc.board.chosen ?? null;
753
+ if (Object.keys(mixes).length) doc.board.mix = { ...(doc.board.mix ?? {}), ...mixes };
754
+ writeDecisions(root, id, doc);
755
+ if (changed.length) addLatestLine(root, id, `Picked ${choice ? `direction ${choice.toUpperCase()}` : 'a mix'} on the style board${Object.keys(mixes).length ? ` (${Object.entries(mixes).map(([d, x]) => `${humanName(d)} from ${x.toUpperCase()}`).join(', ')})` : ''}.`);
756
+ return { ok: true, command: 'style pick', id, chosen: choice, changed, refused, stale: staleAssets(readDecisions(root, id), manifest).map((s) => s.id) };
757
+ }
758
+
759
+ /* ------------------------------------------------------------------ the codex */
760
+
761
+ /** One dated line under the codex's Latest, newest first ("- 2026-10-02: …"). */
762
+ export function addLatestLine(root, id, text) {
763
+ const file = join(root, 'games', id, 'CODEX.md');
764
+ if (!existsSync(file)) return false;
765
+ const src = readFileSync(file, 'utf8');
766
+ const line = `- ${now().slice(0, 10)}: ${clean(text, 300)}`;
767
+ const m = /^##\s+Latest[^\n]*\n/m.exec(src);
768
+ let out;
769
+ if (m) {
770
+ const at = m.index + m[0].length;
771
+ const rest = src.slice(at);
772
+ const lead = /^\s*\n?/.exec(rest)[0];
773
+ out = `${src.slice(0, at)}${lead.includes('\n') ? '\n' : ''}${line}\n${rest.replace(/^\s*\n?/, '')}`;
774
+ } else {
775
+ const first = /^##\s/m.exec(src);
776
+ out = first ? `${src.slice(0, first.index)}## Latest\n\n${line}\n\n${src.slice(first.index)}` : `${src.trimEnd()}\n\n## Latest\n\n${line}\n`;
777
+ }
778
+ writeFileSync(file, out);
779
+ return true;
780
+ }
781
+
782
+ /** The decisions as rows a card, the codex and the mod draw: [{ phase, rows: [{ id, question, label, why, state, by, at, rev, steer }] }]. */
783
+ export function decisionRows(doc) {
784
+ if (!doc) return [];
785
+ // A label written before a value had words of its own (or by hand) is drawn again from the value.
786
+ const labelFor = (did, r) => (typeof r.label === 'string' && !/^[[{]/.test(r.label) ? r.label : labelOf(did, r.value));
787
+ return PHASES.map((p) => ({ ...p, rows: Object.entries(doc.decisions).filter(([, r]) => r.phase === p.id).map(([did, r]) => ({ id: did, name: humanName(did), question: r.question, label: labelFor(did, r), why: r.why, state: r.state, by: r.by, at: r.at, rev: r.rev, steer: r.steer ?? [], colours: did === 'style.palette' ? PALETTE_KEYS.map((k) => r.value?.[k]).filter((c) => HEX.test(String(c))) : null })) })).filter((p) => p.rows.length);
788
+ }
789
+
790
+ /** Phase progress for the strip: { style: { decided, total }, … } (decided = steered, pinned or locked). */
791
+ export function phaseProgress(doc) {
792
+ const out = {};
793
+ for (const p of PHASES) {
794
+ const rows = Object.values(doc?.decisions ?? {}).filter((r) => r.phase === p.id);
795
+ out[p.id] = { total: rows.length, settled: rows.filter((r) => r.state !== 'auto').length, locked: rows.filter((r) => r.state === 'locked').length };
796
+ }
797
+ return out;
798
+ }
799
+
800
+ export { deltaHex, hexToRgb };