@homie-rocks/studio 0.20.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 (54) hide show
  1. package/CHANGELOG.md +78 -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 +52 -3
  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/check.mjs +3 -1
  14. package/lib/codex.mjs +43 -1
  15. package/lib/colour.mjs +160 -0
  16. package/lib/decisions.mjs +800 -0
  17. package/lib/directory.mjs +9 -1
  18. package/lib/library.mjs +169 -0
  19. package/lib/mcp-tools.mjs +22 -4
  20. package/lib/mcp.mjs +6 -3
  21. package/lib/office.mjs +30 -8
  22. package/lib/optimise.mjs +380 -0
  23. package/lib/port-check.mjs +3 -1
  24. package/lib/progress.mjs +16 -1
  25. package/lib/remix-assets.mjs +84 -0
  26. package/lib/render3d.mjs +81 -0
  27. package/lib/scaffold.mjs +32 -1
  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 +75 -0
  33. package/lib/thumb.mjs +82 -0
  34. package/mcp/ui/card.css +15 -0
  35. package/mcp/ui/cast.js +62 -0
  36. package/mcp/ui/decision.js +61 -0
  37. package/mcp/ui/lineup.js +68 -0
  38. package/mcp/ui/rights.js +30 -0
  39. package/mcp/ui/style.js +154 -0
  40. package/package.json +14 -2
  41. package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
  42. package/starters/gem-rush-3d/assets/manifest.json +2318 -0
  43. package/starters/gem-rush-3d/credits.json +31 -0
  44. package/starters/gem-rush-3d/game.json +11 -0
  45. package/starters/gem-rush-3d/index.html +20 -0
  46. package/starters/gem-rush-3d/lab.json +19 -0
  47. package/starters/gem-rush-3d/src/main.ts +2086 -0
  48. package/starters/gem-rush-3d/style.json +66 -0
  49. package/starters/gem-rush-3d/tunables.json +13 -0
  50. package/worker/index.mjs +5 -3
  51. package/worker/pages.mjs +13 -5
  52. package/worker/qr.mjs +11 -0
  53. package/worker/site.mjs +5 -3
  54. package/worker/version.mjs +1 -1
package/lib/scaffold.mjs CHANGED
@@ -106,6 +106,7 @@ directory lists its games; homie.rocks does not host them.
106
106
  | --- | --- |
107
107
  | \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`; and, when it has them, \`tunables.json\` (the numbers that shape how it feels: the Game Lab's sliders, kept values written back) and \`lab.json\` (the Game Lab's takes). |
108
108
  | \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug>/\`. Without storage the site serves each file itself (up to 25 MiB). Once the studio has storage (see below), its big media (over 1 MiB, or left out of git) lives in its own R2: every deploy uploads it, checks it by SHA-256 and serves it from R2 at the same address. Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
109
+ | \`games/<id>/assets/\` | Every model and texture a game ships, with where it came from and its licence (\`manifest.json\`), and \`RIGHTS.md\` in plain words; \`games/<id>/codex/decisions.json\` (private, like the codex) is its look as decisions, and \`style.json\` the palette, fonts, light and camera the game draws with. \`art/<slug>/\` holds art jobs (concepts, receipts; \`raw/\` is git-ignored). |
109
110
  | \`posts/\` | The studio's news and drops: one markdown file each (\`posts/2026-09-30-we-are-live.md\`: \`title:\`, \`date:\`, \`summary:\`, and \`game:\` / \`song:\` / \`video:\` to link one). They are the site's Posts, with Atom and JSON feeds. |
110
111
  | \`site/\` | The studio's site: its look (\`theme.json\`), and anything of its own that wins over the generated pages (\`site/README.md\`); the Worker (\`src/worker.mjs\`) and its D1 migrations. |
111
112
  | \`wrangler.jsonc\` | The Worker's Cloudflare config (the Worker, D1, the Table and Lobby Durable Objects, and \`previews\` for branch Previews), at the root, where Cloudflare's Workers Builds reads it. A studio made before 0.10.0 keeps it in \`site/\` and deploys from a computer; every command finds either. |
@@ -264,6 +265,26 @@ studio's pinned copy, never a registry lookup of the bare name.
264
265
  Cloudflare, Chrome, ffmpeg, GitHub, ElevenLabs, fal), what each unlocks, and the exact fix. Optional ones never
265
266
  block anything.
266
267
 
268
+ ## Art direction and models
269
+
270
+ - **A game's look is a set of decisions** (\`games/<id>/codex/decisions.json\`, drawn in the codex's Art direction
271
+ tab): render style, palette, shape, proportions, materials, light, camera, fonts, effects; the cast, its library
272
+ family, scale and phone budgets. \`npx --no-install homie-studio style init <id> --prompt "<the person's words>"\`
273
+ picks every one automatically with a why. The person can steer one ("warmer"), lock one, or explore three
274
+ directions drawn by the game engine itself (\`style board <id>\`, free). The first asset built on a decision pins
275
+ it; a locked one changes only with the person's yes after \`style blast\` shows what goes stale and what remaking
276
+ it would cost. Nothing is ever remade by itself. The Homie plugin's \`style\` skill has the rest.
277
+ - **Models come free first**: the engine, then Homie's CC0 starter library (\`assets find "<words>"\`, then
278
+ \`assets add <id> <item>\`, copied in, never hot-linked), then the person's own files (\`assets add <id> --file …
279
+ --license …\`), then generated props on the person's own fal account under a budget with receipts (the plugin's
280
+ \`models\` skill). Every model is checked (no external URIs, no oversized files), made phone-sized and recorded
281
+ with its licence; \`RIGHTS.md\` and the credits page follow. \`assets check <id>\` holds a game to the phone
282
+ budgets; \`assets lineup <id>\` shows everything at true scale. A three.js game loads models through
283
+ \`@homie-rocks/studio/assets\` (\`createModels\`); \`game new <id> --from gem-rush-3d\` starts one.
284
+ - **Licences**: a public game ships only assets whose licence allows it (CC0, CC BY with credit, the studio's own,
285
+ generated); \`publish\` refuses an asset with no licence record. A remix gets each redistributable model,
286
+ checked by SHA-256, and a grey placeholder for the rest.
287
+
267
288
  ## Running live games (the back office)
268
289
 
269
290
  - The owner runs the studio's live games from \`/_studio/office\` (\`npx --no-install homie-studio office link\` gives
@@ -279,7 +300,7 @@ studio's pinned copy, never a registry lookup of the bare name.
279
300
  - The owner is recognised in their own games: signed in, their play page has a small Owner button (tap a player:
280
301
  Mute, Kick; Announce). Nobody else's page has it.
281
302
  - **From the AI:** \`npx --no-install homie-studio office\` lists who is playing now; \`office announce "<text>"\` and
282
- \`office invite <id>\` happen at once; \`office kick\`, \`office close\` and \`office launch\` only ASK, and print a
303
+ \`office invite <id>\` happen at once; \`office kick\`, \`office mute\`, \`office close\` and \`office launch\` only ASK, and print a
283
304
  one-time link that opens the ask in the owner's own browser, where one tap does it. An ask is not done until the
284
305
  owner tapped. \`office key\` gives a key for the Homie MCP's owner tools (\`studio_office\`, \`room_announce\`,
285
306
  \`room_kick\`, \`room_close\`, \`game_launch_state\`), which ask the same way; never paste a key anywhere else.
@@ -337,6 +358,11 @@ chat's card follows the work from then on) and, for a studio still being set up,
337
358
  studio.json as \`cloudflare.domain\`.
338
359
  - A game's room size is its netplay manifest's \`maxPlayers\` (game.json \`netplay\`, or netplay.json), up to 32.
339
360
  - Nothing in this studio needs \`~/.homie\` or a Homie box.
361
+ - Optional in studio.json: \`"protect"\` (globs of files the owner wants to look at before any change, such as
362
+ \`"games/*/game.json"\`) and \`"budget"\` (\`{ "usd": <n>, "credits": <n> }\`: the most the studio's media jobs spend
363
+ in all). In Claude Code, the Homie plugin's mod holds an edit to a protected file, a deploy, and a paid call past
364
+ the budget until the person says Proceed, and takes keys out of command output. When it refuses a call, say what
365
+ you meant to do and why; do not retry it unless the person asks.
340
366
 
341
367
  ## Beta
342
368
 
@@ -480,6 +506,11 @@ music/**/*.flac
480
506
  videos/**/*.mp4
481
507
  videos/**/*.mov
482
508
  videos/**/*.webm
509
+ # 3D: raw provider output, high-poly sources and Blender files stay on this computer (or the studio's R2): never shipped, never committed. The phone-sized model in games/<id>/public/models/ is committed.
510
+ art/**/raw/
511
+ *.blend
512
+ *.blend1
513
+ *.fbx
483
514
  `;
484
515
 
485
516
  /** Every file a new studio gets, relative to its folder. */
package/lib/site.mjs CHANGED
@@ -417,7 +417,12 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
417
417
 
418
418
  const words = L.players && typeof L.players === 'object' ? { one: str(L.players.one, 24), many: str(L.players.many, 24) } : null;
419
419
  // The landing's own colours (landing.theme): only colours, and the ink on its own accent worked out like the studio's.
420
- const theme = L.theme && typeof L.theme === 'object' ? Object.fromEntries(['accent', 'glow', 'bg', 'fg'].filter((k) => COLOR.test(String(L.theme[k] ?? ''))).map((k) => [k, L.theme[k]])) : null;
420
+ // Else the game's own palette (games/<id>/style.json, its art-direction decisions): one look from the game to its page.
421
+ const style = !L.theme ? readJson(join(g.dir, 'style.json'))?.palette : null;
422
+ // Its accents only: the page keeps the studio's light or dark scheme, so its text always reads.
423
+ const fromStyle = style && typeof style === 'object' ? { accent: style.accent, glow: style.accent2 } : null;
424
+ const raw = L.theme && typeof L.theme === 'object' ? L.theme : fromStyle;
425
+ const theme = raw ? Object.fromEntries(['accent', 'glow', 'bg', 'fg'].filter((k) => COLOR.test(String(raw[k] ?? ''))).map((k) => [k, raw[k]])) : null;
421
426
  if (theme?.accent) { const l = luminance(theme.accent); if (l !== null) theme.accentInk = l > 0.4 ? '#0b0b10' : '#ffffff'; }
422
427
  // landing.scheme: "light" for a white or cream game (its hero is tinted light and its words dark), "dark" for a
423
428
  // dark game in a light studio. Anything else follows the studio's look.
package/lib/studio.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  * current directory, and its games.
4
4
  */
5
5
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
6
- import { dirname, join, resolve } from 'node:path';
6
+ import { dirname, join, resolve, sep } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { httpsPage, licenseOf, pageOfSource, remixAllowed, remixCredit, remixRow } from '../worker/license.mjs';
9
9
 
@@ -115,17 +115,24 @@ function renameTakeSaves(dir, from, id) {
115
115
  if (next !== text) writeFileSync(file, next);
116
116
  }
117
117
 
118
- /** `homie-studio game new <id> --from <starter> --name "<Name>"`: a starter copied in as this studio's own game. */
119
- export function newGame(root, id, { from = 'gem-rush', name } = {}) {
118
+ /**
119
+ * `homie-studio game new <id> --from <starter> --name "<Name>"`: a starter copied in as this studio's own game. A starter
120
+ * whose game.json says `"assets": "library"` gets its models from the starter library here (fetchStarterModels).
121
+ */
122
+ export async function newGame(root, id, { from = 'gem-rush', name } = {}) {
120
123
  if (!GAME_ID.test(String(id ?? '')) || RESERVED_IDS.has(id)) throw new Error(`a game id is lowercase letters, digits and hyphens, up to 40, and not one of ${[...RESERVED_IDS].join(', ')} (got ${JSON.stringify(id)})`);
121
124
  const src = join(PACKAGE_ROOT, 'starters', from);
122
125
  if (!existsSync(join(src, 'game.json'))) throw new Error(`no starter "${from}"; starters: ${starters().map((s) => s.id).join(', ')}`);
123
126
  const dest = join(root, 'games', id);
124
127
  if (existsSync(join(dest, 'game.json'))) throw new Error(`games/${id} already exists; pick another id or change that game`);
125
- // A planned game (its folder holds the Game Codex, maybe art, and no game.json yet): the starter goes in around
126
- // what is there, and nothing of the plan is overwritten.
127
- if (existsSync(dest) && readdirSync(dest).some((f) => existsSync(join(src, f)))) throw new Error(`games/${id} holds files of its own that a starter would overwrite; pick another id`);
128
- cpSync(src, dest, { recursive: true });
128
+ // A planned game (its folder holds the Game Codex, maybe its art direction and models, and no game.json yet): the
129
+ // starter goes in around what is there, and nothing of the plan is overwritten. Its style.json (from its own
130
+ // decisions) and credits.json stay; the starter's models join its assets/manifest.json beside the game's own.
131
+ const ADOPT = ['style.json', 'credits.json', 'assets'];
132
+ const planned = existsSync(dest) ? readdirSync(dest).filter((f) => existsSync(join(src, f))) : [];
133
+ const clash = planned.filter((f) => !ADOPT.includes(f));
134
+ if (clash.length) throw new Error(`games/${id} holds files of its own that a starter would overwrite (${clash.join(', ')}); pick another id`);
135
+ cpSync(src, dest, { recursive: true, filter: (from) => !planned.some((f) => from === join(src, f) || from.startsWith(join(src, f) + sep)) });
129
136
  const meta = JSON.parse(readFileSync(join(dest, 'game.json'), 'utf8'));
130
137
  meta.id = id;
131
138
  if (name) meta.name = String(name).slice(0, 60);
@@ -136,7 +143,113 @@ export function newGame(root, id, { from = 'gem-rush', name } = {}) {
136
143
  renameTakeSaves(dest, from, id);
137
144
  const html = join(dest, 'index.html');
138
145
  if (name && existsSync(html)) writeFileSync(html, readFileSync(html, 'utf8').replace(/<title>[^<]*<\/title>/, `<title>${String(name).replace(/[<&]/g, '')}</title>`));
139
- return { ok: true, command: 'game new', id, from, dir: dest, files: readdirSync(dest, { recursive: true }).map(String) };
146
+ const merged = planned.includes('assets') && existsSync(join(src, 'assets', 'manifest.json')) ? await adoptStarterAssets(root, id, src) : null;
147
+ const needs = addNeeds(root, meta.needs);
148
+ const models = meta.assets === 'library' ? await fetchStarterModels(dest, merged) : null;
149
+ // A starter with models carries their rights: assets/RIGHTS.md and the credits, written again for this game.
150
+ if (existsSync(join(dest, 'assets', 'manifest.json'))) {
151
+ const { readManifest, syncCredits, writeRights } = await import('./asset-manifest.mjs');
152
+ const manifest = readManifest(root, id);
153
+ writeRights(root, id, manifest);
154
+ syncCredits(root, id, manifest);
155
+ }
156
+ return { ok: true, command: 'game new', id, from, dir: dest, files: readdirSync(dest, { recursive: true }).map(String), ...needs, ...(models ? { models } : {}) };
157
+ }
158
+
159
+ /**
160
+ * A planned game that already has models: the starter's entries join its assets/manifest.json (an id the game
161
+ * already uses keeps the game's own). The game's code draws them under the game's current decisions (it reads
162
+ * style.json), so each adopted entry is recorded as made under those revisions, not the starter's. Returns the
163
+ * adopted ids (the only ones fetched).
164
+ */
165
+ async function adoptStarterAssets(root, id, src) {
166
+ const { readManifest, writeManifest } = await import('./asset-manifest.mjs');
167
+ const { readDecisions, revisionsFor, setDecision } = await import('./decisions.mjs');
168
+ const mine = readManifest(root, id);
169
+ const theirs = JSON.parse(readFileSync(join(src, 'assets', 'manifest.json'), 'utf8'));
170
+ // The starter is dressed from one library family: an automatic pick follows it (one family a game); one the person
171
+ // chose stays, and the lineup flags the starter's models until they are swapped.
172
+ const family = (theirs.assets ?? []).map((a) => String(a.from?.pack ?? '').split('-')[0]).find(Boolean);
173
+ const before = readDecisions(root, id)?.decisions?.['cast.family'];
174
+ if (family && before && before.value !== family && before.state === 'auto') {
175
+ setDecision(root, id, 'cast.family', family, { by: 'ai', why: `the starter's models are ${family}; one family a game, so every piece fits` });
176
+ }
177
+ const doc = readDecisions(root, id);
178
+ const adopted = [];
179
+ for (const a of theirs.assets ?? []) {
180
+ if (mine.assets.some((x) => x.id === a.id)) continue;
181
+ if (doc && a.made?.under) a.made.under = revisionsFor(doc, Object.keys(a.made.under));
182
+ mine.assets.push(a);
183
+ adopted.push(a.id);
184
+ }
185
+ writeManifest(root, id, mine);
186
+ return adopted;
187
+ }
188
+
189
+ /**
190
+ * A STARTER'S MODELS COME FROM THE STARTER LIBRARY: the repository keeps no binaries, so such a starter ships only its
191
+ * assets/manifest.json, each model a library item (`from.item`) and the file it goes to. Each is fetched from the
192
+ * library (HOMIE_LIBRARY: a folder or an address; homie.rocks by default), checked by the index's SHA-256 and the GLB
193
+ * safety rules (lib/library.mjs fetchItemFile), and written there; the manifest keeps the SHA-256 of what was written.
194
+ * When the library cannot be reached the models stay out, `why` says so, and the game draws its stand-ins until they
195
+ * come (`homie-studio assets add <game> <item> --as <asset>`). Returns { fetched, missing: [{ asset, item, why }], from }.
196
+ */
197
+ async function fetchStarterModels(dest, only = null) {
198
+ const file = join(dest, 'assets', 'manifest.json');
199
+ let manifest;
200
+ try { manifest = JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
201
+ const wanted = (manifest.assets ?? []).filter((a) => (!only || only.includes(a.id)) && a.route === 'library' && a.from?.item && (a.files ?? []).some((f) => f.role === 'model' && /^public\/models\/[a-z0-9][a-z0-9-]{0,47}\.glb$/.test(String(f.path))));
202
+ if (!wanted.length) return null;
203
+ const { fetchItemFile, libraryBase, loadIndex } = await import('./library.mjs');
204
+ const lib = libraryBase();
205
+ let index;
206
+ try { index = (await loadIndex({ lib })).index; } catch (error) {
207
+ return { fetched: 0, missing: wanted.map((a) => ({ asset: a.id, item: a.from.item, why: 'no library' })), why: error.message, from: lib.base };
208
+ }
209
+ const missing = [];
210
+ let fetched = 0;
211
+ for (const a of wanted) {
212
+ const item = index.items.find((x) => x.id === a.from.item);
213
+ const f = a.files.find((x) => x.role === 'model');
214
+ if (!item) { missing.push({ asset: a.id, item: a.from.item, why: `the library has no ${a.from.item}` }); continue; }
215
+ try {
216
+ const { bytes, sha256 } = await fetchItemFile(lib, item);
217
+ mkdirSync(dirname(join(dest, f.path)), { recursive: true });
218
+ writeFileSync(join(dest, f.path), bytes);
219
+ Object.assign(f, { bytes: bytes.byteLength, sha256 });
220
+ a.from.sha256 = sha256;
221
+ fetched += 1;
222
+ } catch (error) { missing.push({ asset: a.id, item: a.from.item, why: error.message }); }
223
+ }
224
+ writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
225
+ return { fetched, missing, from: lib.base };
226
+ }
227
+
228
+ /**
229
+ * WHAT A STARTER NEEDS FROM NPM. A starter's game.json may say `"needs": { "three": "0.185.1" }`: libraries its code
230
+ * imports that a studio does not have yet. Each one the studio's package.json lacks (in dependencies or
231
+ * devDependencies) goes into devDependencies at that exact version, so the game bundles one copy of it (a second
232
+ * three.js beside the one @homie-rocks/studio/assets imports would be two engines). A version the studio already
233
+ * pins is left as it is and named in `needsHeld`. `needs` stays in the game's game.json: it is what the game was
234
+ * written against. Returns { needs, needsAdded, needsHeld, installNeeded }.
235
+ */
236
+ export function addNeeds(root, needs) {
237
+ const want = needs && typeof needs === 'object' && !Array.isArray(needs) ? Object.entries(needs).filter(([n, v]) => /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]{0,213}$/.test(n) && /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(String(v))) : [];
238
+ if (!want.length) return {};
239
+ const file = join(root, 'package.json');
240
+ const pkg = JSON.parse(readFileSync(file, 'utf8'));
241
+ const added = []; const held = [];
242
+ for (const [n, v] of want) {
243
+ const have = pkg.dependencies?.[n] ?? pkg.devDependencies?.[n];
244
+ if (have) { if (have !== v) held.push({ name: n, want: v, have }); continue; }
245
+ pkg.devDependencies = { ...(pkg.devDependencies ?? {}), [n]: String(v) };
246
+ added.push({ name: n, version: String(v) });
247
+ }
248
+ if (added.length) {
249
+ pkg.devDependencies = Object.fromEntries(Object.entries(pkg.devDependencies).sort(([a], [b]) => a.localeCompare(b)));
250
+ writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`);
251
+ }
252
+ return { needs: Object.fromEntries(want), needsAdded: added, needsHeld: held, installNeeded: added.length > 0 };
140
253
  }
141
254
 
142
255
  /**
@@ -188,5 +301,19 @@ export async function remixGame(root, source, id, { name } = {}) {
188
301
  const main = join(dest, meta.entry ?? 'src/main.ts');
189
302
  if (existsSync(main) && from) writeFileSync(main, readFileSync(main, 'utf8').replace(new RegExp(`game: '${from}'`, 'g'), `game: '${id}'`));
190
303
  if (from) renameTakeSaves(dest, from, id);
191
- return { ok: true, command: 'game remix', id, from: String(url), credit: meta.remixOf.credit, page: meta.remixOf.page, license, files: wrote };
304
+ // Its models and pictures (0.22.0): fetched from the original site and checked by SHA-256 where their licence lets a
305
+ // remix carry them; a grey placeholder of the same size where it does not (lib/remix-assets.mjs).
306
+ let assets = null;
307
+ if (existsSync(join(dest, 'assets', 'manifest.json'))) {
308
+ const { fetchRemixAssets } = await import('./remix-assets.mjs');
309
+ assets = await fetchRemixAssets(root, id, url, { credit: { studio: credit.studio ?? null, game: credit.game ?? meta.name ?? from } });
310
+ }
311
+ // What its code imports beyond the toolkit (a 3D game: three.js). A remix source is someone else's text, so only the
312
+ // libraries the toolkit's own starters use are added; anything else is named for the person to decide.
313
+ const asked = meta.needs && typeof meta.needs === 'object' && !Array.isArray(meta.needs) ? meta.needs : {};
314
+ const refusedNeeds = Object.keys(asked).filter((n) => !REMIX_NEEDS.has(n));
315
+ const needs = addNeeds(root, Object.fromEntries(Object.entries(asked).filter(([n]) => REMIX_NEEDS.has(n))));
316
+ return { ok: true, command: 'game remix', id, from: String(url), credit: meta.remixOf.credit, page: meta.remixOf.page, license, files: wrote, ...(assets ? { assets } : {}), ...needs, ...(refusedNeeds.length ? { needsNotAdded: refusedNeeds } : {}) };
192
317
  }
318
+ /** The libraries a remix may add to a studio's package.json by itself (the ones the toolkit's starters use). */
319
+ const REMIX_NEEDS = new Set(['three']);
@@ -0,0 +1,227 @@
1
+ /**
2
+ * THE STYLE BOARD AND THE LINEUP: pictures the engine draws for free (lib/render3d.mjs).
3
+ *
4
+ * styleBoard(root, id) three coherent directions (lib/decisions.mjs directionsFor), each drawn as the game would
5
+ * draw it: its palette, light, camera, material model, fonts and proportions, with the
6
+ * starter library family's own pieces re-tinted into its palette. Saved as
7
+ * games/<id>/codex/board/<a|b|c>-swatch.jpg (private, under 300 KB each), the board in
8
+ * decisions.json. A painted mood image per direction is optional and paid: the art skill
9
+ * makes it on the creator's own fal account and `style mood` records it, labelled a target.
10
+ * assetsLineup(root, id) every made asset at true scale on a 1 m grid under the game's light (front and
11
+ * three-quarter), silhouettes black on white at 64 px tall, and flags: over budget, palette
12
+ * drift (CIEDE2000 from the locked palette), an unreadable silhouette, another library family,
13
+ * stale. .studio/art/<id>/lineup-*.jpg and lineup.json.
14
+ */
15
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { extname, join, relative } from 'node:path';
17
+ import { readManifest } from './asset-manifest.mjs';
18
+ import { assetsCheck } from './asset-check.mjs';
19
+ import { mix, nearest } from './colour.mjs';
20
+ import { directionsFor, initDecisions, labelOf, lightColours, readDecisions, staleAssets, styleTokens, writeDecisions, addLatestLine } from './decisions.mjs';
21
+ import { PALETTE_KEYS } from './style-presets.mjs';
22
+ import { loadIndex, searchLibrary, fetchItemFile, libraryBase } from './library.mjs';
23
+ import { modelIn, saveDataUrl, withRenderer, dataUrlBytes } from './render3d.mjs';
24
+
25
+ const BOARD_DIR = join('codex', 'board');
26
+ const PICTURE_MAX = 300 * 1024;
27
+
28
+ /** A direction's (or a game's) style values as the render page's tokens. */
29
+ export function tokensOf(values) {
30
+ const ui = values['style.ui'] ?? {};
31
+ return {
32
+ palette: values['style.palette'], light: values['style.light'], camera: values['style.camera'], render: values['style.render'],
33
+ materials: values['style.materials'], fonts: { display: ui.display, body: ui.body }, shape: values['style.shape'], proportions: values['style.proportions'],
34
+ };
35
+ }
36
+
37
+ /** Up to `n` library pieces of a family that suit the game's words, as model inputs (empty when no library is reachable). */
38
+ async function libraryPieces(family, query, n, log) {
39
+ try {
40
+ const { index, lib } = await loadIndex({ lib: libraryBase() });
41
+ const picks = [];
42
+ const seen = new Set();
43
+ for (const q of [query, 'tree', 'rock', 'bush plant', 'crate barrel']) {
44
+ for (const hit of searchLibrary(index, q, { family, limit: 6 })) {
45
+ if (picks.length >= n) break;
46
+ const it = hit.item;
47
+ if (seen.has(it.id) || it.kind === 'texture' || it.kind === 'sky' || it.rigged) continue;
48
+ seen.add(it.id);
49
+ try { const { bytes } = await fetchItemFile(lib, it); picks.push(modelIn(it.id, bytes, { label: it.name })); } catch (error) { log(`library ${it.id}: ${error.message}`); }
50
+ }
51
+ }
52
+ return picks;
53
+ } catch (error) { log(`no library pieces: ${error.message}`); return []; }
54
+ }
55
+
56
+ /** Save a data: URL picture under `max` bytes (re-encoding JPEG quality down if it is over). */
57
+ async function savePicture(url, file, max = PICTURE_MAX) {
58
+ let bytes = saveDataUrl(url, file);
59
+ if (bytes <= max) return bytes;
60
+ try {
61
+ const sharp = (await import('sharp')).default;
62
+ for (const q of [78, 68, 58]) {
63
+ const out = await sharp(dataUrlBytes(url).bytes).jpeg({ quality: q, mozjpeg: true }).toBuffer();
64
+ writeFileSync(file, out);
65
+ bytes = out.byteLength;
66
+ if (bytes <= max) break;
67
+ }
68
+ } catch { /* kept as it is */ }
69
+ return bytes;
70
+ }
71
+
72
+ export async function styleBoard(root, id, { prompt = '', log = () => {}, library = true } = {}) {
73
+ let doc = readDecisions(root, id);
74
+ if (!doc) { initDecisions(root, id, { prompt, path: 'hands-on' }); doc = readDecisions(root, id); }
75
+ const { directions } = directionsFor(root, id, { prompt: prompt || doc.prompt });
76
+ let game = {};
77
+ try { game = JSON.parse(readFileSync(join(root, 'games', id, 'game.json'), 'utf8')); } catch { game = {}; }
78
+ const title = game.name ?? id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
79
+ const words = [prompt, doc.prompt, (doc.decisions['cast.list']?.value ?? []).map((c) => c.id).join(' ')].join(' ');
80
+ const dir = join(root, 'games', id, BOARD_DIR);
81
+ mkdirSync(dir, { recursive: true });
82
+ const pieces = {};
83
+ if (library) for (const d of directions) if (!pieces[d.family]) pieces[d.family] = await libraryPieces(d.family, words, 4, log);
84
+ const out = [];
85
+ const renderer = await withRenderer(async (r) => {
86
+ for (const d of directions) {
87
+ const url = await r.swatch(tokensOf(d.values), pieces[d.family] ?? [], { title });
88
+ const file = join(dir, `${d.id}-swatch.jpg`);
89
+ const bytes = await savePicture(url, file);
90
+ out.push({ ...d, swatch: relative(join(root, 'games', id), file), bytes, library: (pieces[d.family] ?? []).map((m) => m.id) });
91
+ }
92
+ return r.renderer;
93
+ }, { log });
94
+ const prev = doc.board?.directions ?? [];
95
+ doc = readDecisions(root, id);
96
+ // A pick survives a redraw only while its letter still names the same direction.
97
+ const was = prev.find((p) => p.id === doc.board?.chosen);
98
+ const still = was && out.some((d) => d.id === was.id && d.label === was.label);
99
+ doc.board = {
100
+ at: new Date().toISOString(), renderer, chosen: still ? was.id : null,
101
+ directions: out.map((d) => ({ id: d.id, label: d.label, family: d.family, values: d.values, swatch: d.swatch.split('\\').join('/'), library: d.library, mood: prev.find((p) => p.id === d.id && p.label === d.label)?.mood ?? null })),
102
+ };
103
+ writeDecisions(root, id, doc);
104
+ return { ok: true, command: 'style board', id, title, renderer, directions: boardView(root, id, doc) };
105
+ }
106
+
107
+ /** The board as cards and the CLI show it: per direction its rows (decision, label), swatch path and mood. */
108
+ export function boardView(root, id, doc = readDecisions(root, id)) {
109
+ return (doc?.board?.directions ?? []).map((d) => ({
110
+ id: d.id, label: d.label, family: d.family, swatch: d.swatch, mood: d.mood,
111
+ chosen: doc.board.chosen === d.id,
112
+ colours: PALETTE_KEYS.map((k) => d.values['style.palette']?.[k]).filter(Boolean),
113
+ fonts: { display: d.values['style.ui']?.display, body: d.values['style.ui']?.body },
114
+ rows: Object.entries(d.values).map(([k, v]) => ({ decision: k, label: labelOf(k, v), same: JSON.stringify(doc.decisions[k]?.value) === JSON.stringify(v), state: doc.decisions[k]?.state ?? null })),
115
+ }));
116
+ }
117
+
118
+ /** `style mood <id> <dir> --image <file>`: a painted mood image for one direction (made and paid for by the art skill). */
119
+ export async function recordMood(root, id, dirId, image, { usd = null, receipt = null, model = null } = {}) {
120
+ const doc = readDecisions(root, id);
121
+ const d = doc?.board?.directions?.find((x) => x.id === dirId);
122
+ if (!d) throw new Error(`no direction "${dirId}" on the style board (style board ${id} draws it first)`);
123
+ if (!existsSync(image)) throw new Error(`no picture at ${image}`);
124
+ const file = join(root, 'games', id, BOARD_DIR, `${dirId}-mood.jpg`);
125
+ const sharp = (await import('sharp')).default;
126
+ let q = 82;
127
+ let out = await sharp(readFileSync(image)).resize(960, 540, { fit: 'cover' }).jpeg({ quality: q, mozjpeg: true }).toBuffer();
128
+ while (out.byteLength > PICTURE_MAX && q > 50) { q -= 10; out = await sharp(readFileSync(image)).resize(960, 540, { fit: 'cover' }).jpeg({ quality: q, mozjpeg: true }).toBuffer(); }
129
+ writeFileSync(file, out);
130
+ d.mood = { path: relative(join(root, 'games', id), file).split('\\').join('/'), usd: usd === null ? null : Number(usd), receipt, model, label: 'target (painted, not what the game draws)', at: new Date().toISOString() };
131
+ writeDecisions(root, id, doc);
132
+ return { ok: true, command: 'style mood', id, direction: dirId, mood: d.mood };
133
+ }
134
+
135
+ /** `style golden <id> add <image>`: one of two to six golden images (references for every concept call). */
136
+ export async function addGolden(root, id, image, { from = null } = {}) {
137
+ const doc = readDecisions(root, id);
138
+ if (!doc) throw new Error(`games/${id} has no decisions yet`);
139
+ if (!existsSync(image)) throw new Error(`no picture at ${image}`);
140
+ doc.golden = doc.golden ?? [];
141
+ if (doc.golden.length >= 6) throw new Error('six golden images already: remove one first (style golden <id> remove <n>)');
142
+ const n = doc.golden.reduce((m, g) => Math.max(m, Number(/(\d+)\.jpg$/.exec(g.path)?.[1] ?? 0)), 0) + 1;
143
+ const dir = join(root, 'games', id, BOARD_DIR, 'golden');
144
+ mkdirSync(dir, { recursive: true });
145
+ const file = join(dir, `${n}.jpg`);
146
+ try {
147
+ const sharp = (await import('sharp')).default;
148
+ writeFileSync(file, await sharp(readFileSync(image)).resize(1024, 1024, { fit: 'inside' }).jpeg({ quality: 84, mozjpeg: true }).toBuffer());
149
+ } catch { if (/\.jpe?g$/i.test(extname(image))) copyFileSync(image, file); else throw new Error('sharp could not read that picture'); }
150
+ doc.golden.push({ path: relative(join(root, 'games', id), file).split('\\').join('/'), from: from ?? relative(root, image), approvedBy: 'person', at: new Date().toISOString() });
151
+ writeDecisions(root, id, doc);
152
+ addLatestLine(root, id, `Added golden image ${n} (from ${from ?? relative(root, image)}): every concept is made with it as a reference.`);
153
+ return { ok: true, command: 'style golden', id, golden: doc.golden };
154
+ }
155
+
156
+ /* ------------------------------------------------------------------ the lineup */
157
+
158
+ const DRIFT = 18; // mean CIEDE2000 (area-weighted) from the palette above which an asset is flagged
159
+
160
+ export async function assetsLineup(root, id, { log = () => {} } = {}) {
161
+ const manifest = readManifest(root, id);
162
+ const doc = readDecisions(root, id);
163
+ const tokens = styleTokens(doc) ?? { palette: { bg: '#20242c', ink: '#f2f2f2', accent: '#ffcf5a', accent2: '#7dffb0', danger: '#ff5d5d', good: '#5fdc8b', gold: '#f2c14e', ramp: [] } };
164
+ const models = [];
165
+ for (const a of manifest.assets) {
166
+ const f = (a.files ?? []).find((x) => x.role === 'model');
167
+ if (!f) continue;
168
+ const abs = join(root, 'games', id, f.path);
169
+ if (!existsSync(abs)) continue;
170
+ // `inGame`: what the game's code does to it at runtime (drawn at another height, repainted from style.json): the
171
+ // lineup draws it the same way (the repaint as the board's re-tint, the nearest the game's own swap can be shown).
172
+ const scale = a.inGame?.heightM && a.measured?.heightM ? a.inGame.heightM / a.measured.heightM : null;
173
+ models.push({ a, input: modelIn(a.id, readFileSync(abs), { label: a.card ? String(a.card).split('/').pop() : a.id, ...(scale ? { scale } : {}), ...(a.inGame?.tint ? { tint: lightColours({ sky: a.inGame.tint, ground: a.inGame.tint }, tokens.palette).sky } : a.inGame?.repaint ? { retint: true } : Number(a.inGame?.pull) > 0 ? { pull: Number(a.inGame.pull) } : {}) }), abs });
174
+ }
175
+ if (!models.length) return { ok: false, command: 'assets lineup', id, why: `games/${id} has no recorded models yet (assets add, or the models skill)` };
176
+ const dir = join(root, '.studio', 'art', id);
177
+ mkdirSync(dir, { recursive: true });
178
+ const shot = await withRenderer(async (r) => ({ renderer: r.renderer, ...(await r.lineup(models.map((m) => m.input), tokens)) }), { log });
179
+ const images = { front: join(dir, 'lineup-front.jpg'), quarter: join(dir, 'lineup-quarter.jpg'), silhouettes: join(dir, 'lineup-silhouettes.png') };
180
+ saveDataUrl(shot.front, images.front); saveDataUrl(shot.quarter, images.quarter); saveDataUrl(shot.silhouettes, images.silhouettes);
181
+ const palette = [...PALETTE_KEYS.map((k) => tokens.palette[k]), ...(tokens.palette.ramp ?? [])].filter(Boolean);
182
+ const family = doc?.decisions?.['cast.family']?.value ?? null;
183
+ const check = await assetsCheck(root, id, { validate: false, write: true }).catch(() => null);
184
+ const stale = doc ? staleAssets(doc, manifest) : [];
185
+ const { albedoOf } = await import('./optimise.mjs');
186
+ const rows = [];
187
+ for (const m of models) {
188
+ const flags = [];
189
+ let drift = null;
190
+ try {
191
+ const colours = await albedoOf(m.abs);
192
+ // A model the game pulls toward the palette (inGame.pull) is scored on the colours it is drawn in.
193
+ const pull = Math.max(0, Math.min(1, Number(m.a.inGame?.pull) || 0));
194
+ const drawn = colours.map((c) => (pull ? { ...c, hex: mix(c.hex, nearest(c.hex, palette).hex, pull) } : c));
195
+ const scored = drawn.map((c) => { const n = nearest(c.hex, palette); return { hex: c.hex, share: c.share, palette: n.hex, delta: +n.delta.toFixed(1) }; });
196
+ const mean = scored.reduce((n, c) => n + c.delta * c.share, 0) / Math.max(0.0001, scored.reduce((n, c) => n + c.share, 0));
197
+ drift = { mean: +mean.toFixed(1), colours: scored };
198
+ if (m.a.inGame?.repaint) drift.repaint = m.a.inGame.repaint;
199
+ else if (mean > DRIFT) flags.push(`palette drift: its colours sit ${drift.mean} from the palette (over ${DRIFT}); a re-tint (free) or a remake`);
200
+ } catch (error) { log(`${m.a.id}: colours not read (${error.message})`); }
201
+ const row = shot.rows.find((x) => x.id === m.a.id);
202
+ if (row && ['character', 'creature'].includes(m.a.kind) && (row.silhouette.widthPx < 6 || row.silhouette.fill > 0.92)) flags.push(`silhouette: ${row.silhouette.widthPx < 6 ? 'too thin to read at 64 px' : 'a blob at 64 px (no limbs, ears or tail break the outline)'}`);
203
+ const c = check?.rows?.find((x) => x.id === m.a.id);
204
+ if (c && !c.ok) flags.push(`over budget: ${c.problems[0]}`);
205
+ if (family && m.a.route === 'library' && m.a.from?.pack && !String(m.a.from.pack).startsWith(family)) flags.push(`another library family (${m.a.from.pack}; the game's is ${family}): mixing families shows`);
206
+ if (stale.some((s) => s.id === m.a.id)) flags.push('stale: made under an older decision');
207
+ rows.push({ id: m.a.id, label: m.input.label, kind: m.a.kind, route: m.a.route, size: row?.size ?? null, silhouette: row?.silhouette ?? null, drift, flags });
208
+ }
209
+ const result = { ok: true, command: 'assets lineup', id, renderer: shot.renderer, images: Object.fromEntries(Object.entries(images).map(([k, v]) => [k, relative(root, v)])), rows, palette: palette.slice(0, 7), family, at: new Date().toISOString(), flagged: rows.filter((r) => r.flags.length).length };
210
+ writeFileSync(join(dir, 'lineup.json'), `${JSON.stringify(result, null, 2)}\n`);
211
+ return result;
212
+ }
213
+
214
+ /** `assets review <id> --score n --outliers a,b`: a fresh reviewer's "one game?" score, and the assets it named. */
215
+ export function recordReview(root, id, { score, outliers = [], note = null, by = 'reviewer' } = {}) {
216
+ const s = Number(score);
217
+ if (!(s >= 1 && s <= 10)) throw new Error('--score 1 to 10 ("does it look like one game?")');
218
+ const dir = join(root, '.studio', 'art', id);
219
+ mkdirSync(dir, { recursive: true });
220
+ const manifest = readManifest(root, id);
221
+ const needs = new Set(outliers.filter(Boolean));
222
+ if (s < 7) for (const a of manifest.assets) if (a.route !== 'procedural') needs.add(a.id);
223
+ const review = { score: s, outliers: [...needs], note, by, at: new Date().toISOString(), verdict: s >= 7 && !outliers.length ? 'one game' : 'needs review' };
224
+ writeFileSync(join(dir, 'review.json'), `${JSON.stringify(review, null, 2)}\n`);
225
+ return { ok: true, command: 'assets review', id, review };
226
+ }
227
+