@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,136 @@
1
+ /**
2
+ * A MODEL FILE INTO A GAME: the person's own (imported), or one a provider generated on their account (the models
3
+ * skill's image-to-3D). `homie-studio assets add <id> --file <model> --license <kind>`.
4
+ *
5
+ * The file is checked first (assets/safety.mjs, the import limits: a self-contained GLB, or a .glb/.gltf whose
6
+ * pictures sit beside it; no address anywhere in it; nothing over 64 MB, no buffer declaring more), then made
7
+ * phone-sized (lib/optimise.mjs: its tier's triangles and picture size, pivot at the bottom centre, scaled to its card's
8
+ * height), written to games/<id>/public/models/<asset>.glb, and recorded with its licence and every step that made it
9
+ * (lib/asset-manifest.mjs). The raw file is kept beside its art job in art/<slug>/raw/ (git-ignored; the studio's R2 by
10
+ * `media move` once it has storage): it is never shipped and never committed.
11
+ */
12
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
13
+ import { basename, join, relative, resolve } from 'node:path';
14
+ import { LIMITS, checkGlb } from '../assets/safety.mjs';
15
+ import { ASSET_ID, KINDS, licenseInfo, readManifest, recordAsset } from './asset-manifest.mjs';
16
+ import { readDecisions } from './decisions.mjs';
17
+ import { budgetsFor } from './asset-check.mjs';
18
+
19
+ const TIER_OF_KIND = { character: 'hero', creature: 'npc', prop: 'prop', kit: 'kit', environment: 'kit' };
20
+
21
+ /** Refuse what must never come in, before anything reads it as a model: the safety rules at the import limits. */
22
+ export function screenImport(path) {
23
+ if (!existsSync(path)) throw new Error(`no file at ${path}`);
24
+ const size = statSync(path).size;
25
+ if (size > LIMITS.import.bytes) throw new Error(`refused ${basename(path)}: ${Math.round(size / 1024 / 1024)} MB is over the ${LIMITS.import.bytes / 1024 / 1024} MB a model may be when it comes in`);
26
+ const bytes = readFileSync(path);
27
+ if (bytes.subarray(0, 4).toString('latin1') === 'glTF') {
28
+ const safe = checkGlb(bytes, LIMITS.import);
29
+ // A pack's .glb may name pictures that sit beside it ("Textures/colormap.png"): the optimiser reads those from the
30
+ // file's own folder. Anything with a scheme (http:, file:), an absolute path or ".." is an external URI: refused.
31
+ const sibling = (p) => { const m = /is loaded from an address \(([^)]*)\)/.exec(p); return Boolean(m) && !/^[a-z][a-z0-9+.-]*:|^\/|(^|[\\/])\.\.([\\/]|$)/i.test(m[1]); };
32
+ const hard = safe.problems.filter((p) => !sibling(p));
33
+ if (hard.length) throw new Error(`refused ${basename(path)}: ${hard.slice(0, 4).join('; ')}`);
34
+ return { bytes: size, glb: true };
35
+ }
36
+ if (/\.gltf$/i.test(path)) {
37
+ let json;
38
+ try { json = JSON.parse(bytes.toString('utf8')); } catch { throw new Error(`refused ${basename(path)}: not JSON`); }
39
+ for (const x of [...(json.buffers ?? []), ...(json.images ?? [])]) {
40
+ const u = String(x?.uri ?? '');
41
+ if (/^[a-z][a-z0-9+.-]*:/i.test(u) && !/^data:/i.test(u)) throw new Error(`refused ${basename(path)}: it loads ${u.slice(0, 60)} from an address (an external URI)`);
42
+ if (u.split(/[\\/]/).includes('..')) throw new Error(`refused ${basename(path)}: it reaches outside its folder (${u.slice(0, 60)})`);
43
+ }
44
+ for (const b of json.buffers ?? []) if (Number(b?.byteLength) > LIMITS.import.buffer) throw new Error(`refused ${basename(path)}: a buffer declares ${Math.round(Number(b.byteLength) / 1024 / 1024)} MB`);
45
+ return { bytes: size, glb: false };
46
+ }
47
+ if (/\.(fbx|obj|blend|dae|stl|usdz?)$/i.test(path)) throw new Error(`${basename(path)} is not glTF: export it as .glb (Blender: File > Export > glTF 2.0, binary), then add that`);
48
+ throw new Error(`refused ${basename(path)}: not a glTF file (.glb or .gltf)`);
49
+ }
50
+
51
+ /**
52
+ * Import one file. Options: as (asset id), kind, tier, card ("Items/Lantern"), height (m), triangles, texture (px),
53
+ * license (kind), attribution, owner, notes, route ('imported' or 'generated'), steps (the made.steps before optimising:
54
+ * the concept and mesh calls with their receipts and prices), slug (the art job's folder).
55
+ */
56
+ export async function importModel(root, id, file, opts = {}) {
57
+ const gdir = join(root, 'games', id);
58
+ if (!existsSync(join(gdir, 'game.json')) && !existsSync(join(gdir, 'CODEX.md'))) throw new Error(`no game "${id}"`);
59
+ const path = resolve(root, file);
60
+ const route = opts.route === 'generated' ? 'generated' : 'imported';
61
+ const licKind = opts.license ?? (route === 'generated' ? 'generated' : null);
62
+ const info = licenseInfo(licKind);
63
+ if (!info) throw new Error('say whose it is and under what licence: --license own (the person made it), cc0, cc-by-4.0 (with --attribution), eula:<store>, market:<listing> or other (with --notes)');
64
+ if (info.attribution && !opts.attribution) throw new Error(`${info.label} needs credit: --attribution "<who made it, where it is from>"`);
65
+ screenImport(path);
66
+ const kind = KINDS.includes(opts.kind) ? opts.kind : 'prop';
67
+ const asId = String(opts.as ?? basename(path).replace(/\.(glb|gltf)$/i, '')).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);
68
+ if (!ASSET_ID.test(asId)) throw new Error('give it an id: --as <lowercase-id>');
69
+ const tier = opts.tier ?? TIER_OF_KIND[kind] ?? 'prop';
70
+ const doc = readDecisions(root, id);
71
+ const budgets = budgetsFor(doc);
72
+ const b = budgets[tier] ?? budgets.prop;
73
+ // The raw file stays on this computer, beside its art job (git-ignored), unless it is there already.
74
+ const slug = String(opts.slug ?? asId);
75
+ const rawDir = join(root, 'art', slug, 'raw');
76
+ let rawPath = path;
77
+ if (!path.startsWith(`${resolve(root, 'art')}/`)) {
78
+ mkdirSync(rawDir, { recursive: true });
79
+ rawPath = join(rawDir, basename(path));
80
+ if (resolve(rawPath) !== path) copyFileSync(path, rawPath);
81
+ }
82
+ const { optimiseModel, inspectModel } = await import('./optimise.mjs');
83
+ const out = join(gdir, 'public', 'models', `${asId}.glb`);
84
+ const r = await optimiseModel(path, { out, triangles: Number(opts.triangles ?? b.triangles), texture: Number(opts.texture ?? b.texturePx), height: opts.height ? Number(opts.height) : null, rigged: Boolean(opts.rigged), metal: doc?.decisions?.['style.render']?.value === 'pbr' });
85
+ if (!r.ok) throw new Error(`the optimised file is still refused: ${r.warnings.join('; ')}`);
86
+ const ins = await inspectModel(out);
87
+ const rel = relative(gdir, out).split('\\').join('/');
88
+ const steps = [...(Array.isArray(opts.steps) ? opts.steps : []), { what: 'optimise', tool: r.tool, ops: r.ops, from: { bytes: r.rawBytes, sha256: r.rawSha256 } }];
89
+ const entry = {
90
+ id: asId, kind, tier, card: opts.card ?? null, route,
91
+ ...(opts.height ? { heightM: Number(opts.height) } : {}),
92
+ files: [
93
+ { role: 'model', path: rel, bytes: r.bytes, sha256: r.sha256 },
94
+ ...(opts.concept ? [{ role: 'concept', path: relative(gdir, resolve(root, opts.concept)).split('\\').join('/'), public: false }] : []),
95
+ { role: 'raw', path: relative(gdir, rawPath).split('\\').join('/'), public: false, bytes: r.rawBytes, sha256: r.rawSha256 },
96
+ ],
97
+ made: { steps, ...(opts.under ? { under: opts.under } : {}) },
98
+ license: { kind: info.kind, spdx: info.spdx, owner: opts.owner ?? (route === 'generated' || licKind === 'own' ? 'studio' : null), attribution: opts.attribution ?? null, remix: info.remix, ...(route === 'generated' ? { terms: [{ who: 'fal', url: 'https://fal.ai/terms', read: '2026-10-02', label: 'Commercial use' }], notes: 'Made through fal on the studio\'s own account; fal\'s terms do not assign output ownership expressly (RIGHTS.md).' } : opts.notes ? { notes: opts.notes } : {}) },
99
+ measured: { tris: r.after.triangles, materials: r.after.materials, drawCalls: r.after.drawCalls, textures: r.after.textures.map((t) => ({ px: t.px, format: t.mimeType, gpuKB: Math.round(t.gpuBytes / 1024) })), bones: r.after.bones, clips: r.after.clips.length, heightM: r.after.heightM, box: r.after.box, glbKB: Math.round(r.bytes / 1024), flat: r.ops.some((o) => o.startsWith('palette')) && !r.after.textures.some((t) => t.slots?.includes('normalTexture')) },
100
+ review: { state: 'auto' },
101
+ };
102
+ const { entry: e, pinned } = recordAsset(root, id, entry);
103
+ return { ok: true, command: 'assets add', game: id, asset: e.id, route, file: rel, raw: relative(root, rawPath), before: { tris: r.before.triangles, kb: Math.round(r.rawBytes / 1024) }, after: { tris: r.after.triangles, kb: Math.round(r.bytes / 1024), heightM: r.after.heightM }, ops: r.ops, warnings: [...r.warnings, ...(ins.validator?.errors ? [`validator: ${ins.validator.messages[0]}`] : [])], pinned, license: info.label };
104
+ }
105
+
106
+
107
+ /**
108
+ * MADE AGAIN FROM ITS RAW FILE, FREE: `homie-studio assets redo <id> <asset>`. The kept raw file (art/<slug>/raw/) is
109
+ * checked against its recorded SHA-256 and optimised again under the game's budgets and render style now; the entry
110
+ * keeps its route, licence, receipts and concept. Only the free steps run again, so the decisions a paid step baked
111
+ * in (palette, shape, render) stay as they were recorded: an asset stale on those is still stale (a new concept and
112
+ * mesh fix it); one stale only on its budgets is not.
113
+ */
114
+ export async function redoModel(root, id, assetId) {
115
+ const gdir = join(root, 'games', id);
116
+ const e = readManifest(root, id).assets.find((a) => a.id === assetId);
117
+ if (!e) throw new Error(`no asset "${assetId}" in games/${id}/assets/manifest.json`);
118
+ const raw = (e.files ?? []).find((f) => f.role === 'raw');
119
+ if (!raw) throw new Error(`${assetId} kept no raw file (${e.route === 'library' ? 'a library item: assets add it again' : 'add the file again'})`);
120
+ const rawPath = resolve(gdir, raw.path);
121
+ if (!rawPath.startsWith(`${resolve(root, 'art')}/`) || !existsSync(rawPath)) throw new Error(`the raw file ${raw.path} is not on this computer`);
122
+ const { createHash } = await import('node:crypto');
123
+ if (raw.sha256 && createHash('sha256').update(readFileSync(rawPath)).digest('hex') !== raw.sha256) throw new Error(`${raw.path} is not the file ${assetId} was made from (its SHA-256 differs)`);
124
+ const doc = readDecisions(root, id);
125
+ const under = { ...(e.made?.under ?? {}) };
126
+ for (const d of ['cast.tiers', 'style.render']) if (d in under && doc?.decisions?.[d]) under[d] = doc.decisions[d].rev;
127
+ const concept = (e.files ?? []).find((f) => f.role === 'concept');
128
+ const slug = relative(join(root, 'art'), rawPath).split(/[\\/]/)[0];
129
+ const r = await importModel(root, id, rawPath, {
130
+ as: e.id, kind: e.kind, tier: e.tier, card: e.card, height: e.heightM ?? null, route: e.route,
131
+ license: e.license?.kind, attribution: e.license?.attribution ?? undefined, owner: e.license?.owner ?? undefined, notes: e.license?.notes,
132
+ steps: (e.made?.steps ?? []).filter((s) => s.what !== 'optimise'), concept: concept ? relative(root, resolve(gdir, concept.path)) : undefined,
133
+ slug, rigged: Number(e.measured?.bones) > 0 || Number(e.measured?.clips) > 0, under,
134
+ });
135
+ return { ...r, command: 'assets redo' };
136
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * EVERY ASSET'S PROVENANCE AND LICENCE: games/<id>/assets/manifest.json, one entry per model,
3
+ * texture, sky or procedural part a game ships, with where it came from (procedural, library, generated, imported),
4
+ * each step that made it (endpoint, receipt, price), the decision revisions it was made under (lib/decisions.mjs),
5
+ * its licence and what a remix gets, its measurements and its review. An entry the game's code changes at runtime
6
+ * says so in `inGame` ({ heightM, repaint }: drawn at that height, repainted from style.json), so the lineup draws
7
+ * it as the game does. From it:
8
+ *
9
+ * games/<id>/assets/RIGHTS.md what each file is, where it came from, its licence and what that allows, in plain words
10
+ * games/<id>/credits.json a `parts[]` line per pack or attribution-required file (the landing's credits page)
11
+ * licenceProblems() what `publish` and `assets check` refuse: a file with no licence record, a licence
12
+ * that forbids a public source or a web game, attribution that never reached credits
13
+ * servedAssets() /games/<id>/assets.json on the site: each redistributable file's address and SHA-256,
14
+ * which `game remix` fetches
15
+ *
16
+ * Both files are text: they travel in the remix source; the codex/ folder (decisions, the board) does not.
17
+ */
18
+ import { createHash } from 'node:crypto';
19
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
20
+ import { dirname, join, relative } from 'node:path';
21
+ import { pinByUse, readDecisions, revisionsFor } from './decisions.mjs';
22
+ import { GAME_ID } from './studio.mjs';
23
+
24
+ export const MANIFEST_FILE = join('assets', 'manifest.json');
25
+ export const RIGHTS_FILE = join('assets', 'RIGHTS.md');
26
+ export const ROUTES = Object.freeze(['procedural', 'library', 'generated', 'imported', 'premium']);
27
+ export const KINDS = Object.freeze(['prop', 'character', 'creature', 'kit', 'environment', 'texture', 'sky', 'clip']);
28
+ export const ASSET_ID = /^[a-z0-9][a-z0-9-]{0,47}$/;
29
+
30
+ /**
31
+ * Licence kinds: whether the file may sit in a public remix source and on Homie's mirror, whether
32
+ * credit is required, what a remix gets by default, and whether a browser game may serve the file at all.
33
+ */
34
+ export const LICENSES = Object.freeze({
35
+ cc0: { label: 'CC0 1.0 (public domain)', spdx: 'CC0-1.0', url: 'https://creativecommons.org/publicdomain/zero/1.0/', redistribute: true, attribution: false, remix: 'include', web: true },
36
+ 'cc-by-4.0': { label: 'CC BY 4.0', spdx: 'CC-BY-4.0', url: 'https://creativecommons.org/licenses/by/4.0/', redistribute: true, attribution: true, remix: 'include', web: true },
37
+ 'cc-by-3.0': { label: 'CC BY 3.0', spdx: 'CC-BY-3.0', url: 'https://creativecommons.org/licenses/by/3.0/', redistribute: true, attribution: true, remix: 'include', web: true },
38
+ own: { label: 'The studio\'s own work', spdx: null, url: null, redistribute: true, attribution: false, remix: 'include', web: true },
39
+ generated: { label: 'Generated on the studio\'s own account', spdx: null, url: null, redistribute: true, attribution: false, remix: 'include', web: true },
40
+ qal: { label: 'Quaternius Asset License', spdx: null, url: 'https://quaternius.com/license.html', redistribute: false, attribution: false, remix: 'reference', web: true },
41
+ mixamo: { label: 'Mixamo (Adobe)', spdx: null, url: 'https://helpx.adobe.com/creative-cloud/faq/mixamo-faq.html', redistribute: false, attribution: false, remix: 'none', web: true },
42
+ other: { label: 'Other (see notes)', spdx: null, url: null, redistribute: false, attribution: false, remix: 'none', web: true },
43
+ });
44
+ /** `eula:<name>` and `market:<listing>` are commercial licences: never in a public source; TurboSquid never on the web. */
45
+ export function licenseInfo(kind) {
46
+ const k = String(kind ?? '');
47
+ if (LICENSES[k]) return { kind: k, ...LICENSES[k] };
48
+ if (/^eula:[a-z0-9-]{1,40}$/.test(k)) return { kind: k, label: `Commercial licence (${k.slice(5)})`, spdx: null, url: null, redistribute: false, attribution: false, remix: 'none', web: k !== 'eula:turbosquid' };
49
+ if (/^market:[a-z0-9:._-]{1,80}$/.test(k)) return { kind: k, label: `Bought (${k.slice(7)})`, spdx: null, url: null, redistribute: false, attribution: false, remix: 'none', web: true };
50
+ return null;
51
+ }
52
+
53
+ /** The decisions each kind of asset is made under (its `made.under`). */
54
+ const UNDER = {
55
+ prop: ['style.render', 'style.palette', 'style.shape', 'style.materials', 'style.light', 'cast.scale'],
56
+ character: ['style.render', 'style.palette', 'style.shape', 'style.materials', 'style.light', 'style.proportions', 'cast.scale', 'rig.skeleton'],
57
+ kit: ['style.render', 'style.palette', 'style.materials', 'cast.scale'],
58
+ texture: ['style.render', 'style.palette', 'style.materials'],
59
+ sky: ['style.light', 'style.palette'],
60
+ clip: ['anim.style', 'rig.skeleton'],
61
+ };
62
+ UNDER.creature = UNDER.character;
63
+ UNDER.environment = UNDER.kit;
64
+ export function underFor(kind, route) {
65
+ return [...(UNDER[kind] ?? UNDER.prop), ...(route === 'library' ? ['cast.family'] : []), ...(route === 'generated' ? ['derived.prompt'] : [])];
66
+ }
67
+
68
+ const fileOf = (root, id) => join(root, 'games', id, MANIFEST_FILE);
69
+ const now = () => new Date().toISOString();
70
+ const clean = (s, n = 300) => (typeof s === 'string' ? s.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, n) : null);
71
+
72
+ export function readManifest(root, id) {
73
+ if (!GAME_ID.test(String(id ?? ''))) throw new Error(`"${id}" is not a game id`);
74
+ const file = fileOf(root, id);
75
+ if (!existsSync(file)) return { v: 1, assets: [] };
76
+ const m = JSON.parse(readFileSync(file, 'utf8'));
77
+ if (m?.v !== 1 || !Array.isArray(m.assets)) throw new Error(`games/${id}/${MANIFEST_FILE} is not an asset manifest (v 1)`);
78
+ return m;
79
+ }
80
+
81
+ export function writeManifest(root, id, manifest) {
82
+ const file = fileOf(root, id);
83
+ mkdirSync(dirname(file), { recursive: true });
84
+ manifest.assets.sort((a, b) => a.id.localeCompare(b.id));
85
+ writeFileSync(`${file}.tmp`, `${JSON.stringify(manifest, null, 2)}\n`);
86
+ renameSync(`${file}.tmp`, file);
87
+ return manifest;
88
+ }
89
+
90
+ /** One entry's shape, checked: { ok, problems }. */
91
+ export function checkEntry(e) {
92
+ const problems = [];
93
+ if (!ASSET_ID.test(String(e?.id ?? ''))) problems.push('an id: lowercase letters, digits and hyphens');
94
+ if (!KINDS.includes(e?.kind)) problems.push(`a kind: ${KINDS.join(', ')}`);
95
+ if (!ROUTES.includes(e?.route)) problems.push(`a route: ${ROUTES.join(', ')}`);
96
+ if (!e?.license || !licenseInfo(e.license.kind)) problems.push('a licence record (license.kind: cc0, cc-by-4.0, own, generated, …)');
97
+ else if (licenseInfo(e.license.kind).attribution && !clean(e.license.attribution)) problems.push(`${e.license.kind} requires an attribution line (license.attribution)`);
98
+ if (e?.route !== 'procedural' && !(e?.files ?? []).some((f) => f.role === 'model' || f.role === 'texture' || f.role === 'sky' || f.role === 'clip')) problems.push('a shipped file (files[].role model, texture, sky or clip)');
99
+ return { ok: problems.length === 0, problems };
100
+ }
101
+
102
+ /**
103
+ * Record (or replace) one asset. Fills `made.under` with the current decision revisions for its kind and route,
104
+ * pins the auto decisions it was made under (pinned by use), then writes the manifest, RIGHTS.md and credits.json.
105
+ * Returns { entry, pinned }.
106
+ */
107
+ export function recordAsset(root, id, entry) {
108
+ const manifest = readManifest(root, id);
109
+ const e = JSON.parse(JSON.stringify(entry));
110
+ e.license = { remix: licenseInfo(e.license?.kind)?.remix ?? 'none', ...e.license };
111
+ const check = checkEntry(e);
112
+ if (!check.ok) throw new Error(`asset ${e.id ?? '?'} needs ${check.problems.join('; ')}`);
113
+ const doc = readDecisions(root, id);
114
+ const under = underFor(e.kind, e.route);
115
+ e.made = { ...(e.made ?? {}), under: { ...revisionsFor(doc, under), ...(e.made?.under ?? {}) } };
116
+ e.recordedAt = now();
117
+ const at = manifest.assets.findIndex((a) => a.id === e.id);
118
+ if (at >= 0) manifest.assets[at] = e; else manifest.assets.push(e);
119
+ writeManifest(root, id, manifest);
120
+ const pinned = pinByUse(root, id, under.filter((d) => d !== 'derived.prompt'), e.id);
121
+ writeRights(root, id, manifest);
122
+ syncCredits(root, id, manifest);
123
+ return { entry: e, pinned };
124
+ }
125
+
126
+ /**
127
+ * `assets remove`: the record goes, and so does the copy the game ships (its files under public/) when it is still
128
+ * byte for byte what the record put there and no other asset names it. A raw file in art/ stays, and so does a
129
+ * shipped file someone changed since. Returns { removed, deleted: [paths], kept: [paths] }.
130
+ */
131
+ export function removeAsset(root, id, assetId) {
132
+ const manifest = readManifest(root, id);
133
+ const gone = manifest.assets.find((a) => a.id === assetId);
134
+ if (!gone) return { removed: false, deleted: [], kept: [] };
135
+ manifest.assets = manifest.assets.filter((a) => a.id !== assetId);
136
+ writeManifest(root, id, manifest);
137
+ writeRights(root, id, manifest);
138
+ syncCredits(root, id, manifest);
139
+ const gdir = join(root, 'games', id);
140
+ const named = new Set(manifest.assets.flatMap((a) => (a.files ?? []).map((f) => f.path)));
141
+ const deleted = []; const kept = [];
142
+ for (const f of gone.files ?? []) {
143
+ const path = String(f.path ?? '');
144
+ if (!/^public\//.test(path) || path.split('/').includes('..') || named.has(path)) continue;
145
+ const abs = join(gdir, path);
146
+ if (!existsSync(abs)) continue;
147
+ if (f.sha256 && createHash('sha256').update(readFileSync(abs)).digest('hex') === f.sha256) { rmSync(abs); deleted.push(path); } else kept.push(path);
148
+ }
149
+ return { removed: true, deleted, kept };
150
+ }
151
+
152
+ /* ------------------------------------------------------------------ the licence check (publish, assets check) */
153
+
154
+ /**
155
+ * What stops a public game, per asset: [{ asset, level: 'refuse' | 'warn', problem, fix }]. `public`: the game's
156
+ * source is shared (game.json share.source is not false) and it is listed. A file a game ships under games/<id>/public
157
+ * with no manifest entry at all is refused too: every shipped model needs a record.
158
+ */
159
+ export function licenceProblems(root, id, manifest = readManifest(root, id), { public: pub = true } = {}) {
160
+ const out = [];
161
+ for (const a of manifest.assets) {
162
+ const info = licenseInfo(a.license?.kind);
163
+ if (!info) { out.push({ asset: a.id, level: 'refuse', problem: 'no licence record', fix: `record its licence: homie-studio assets add ${id} --file … --license <cc0|cc-by-4.0|own|…>` }); continue; }
164
+ if (!info.web) out.push({ asset: a.id, level: 'refuse', problem: `${info.label} does not allow a three.js web game to serve the file`, fix: 'replace it (a library match, or generate one)' });
165
+ if (info.attribution && !clean(a.license.attribution)) out.push({ asset: a.id, level: 'refuse', problem: `${info.label} requires credit, and there is no attribution line`, fix: 'add license.attribution (who made it, where it is from)' });
166
+ if (pub && !info.redistribute && a.license.remix === 'include') out.push({ asset: a.id, level: 'refuse', problem: `${info.label} forbids handing the file to remixers, but its remix is "include"`, fix: `set license.remix to "${info.remix}" (remixers get ${info.remix === 'reference' ? 'it from its origin' : 'a placeholder'})` });
167
+ else if (pub && !info.redistribute) out.push({ asset: a.id, level: 'warn', problem: `${info.label}: a public game serves the file to players but never to remixers (${a.license.remix}); keep the studio's repository private while it holds the raw file`, fix: null });
168
+ }
169
+ for (const f of unrecordedModels(root, id, manifest)) out.push({ asset: f, level: 'refuse', problem: 'a shipped model with no record in assets/manifest.json', fix: `homie-studio assets add ${id} --file ${f} --license <kind>` });
170
+ return out;
171
+ }
172
+
173
+ /** Model files a game ships (.glb, .gltf, .fbx, .obj, .vrm under games/<id>/) that no manifest entry names. */
174
+ export function unrecordedModels(root, id, manifest = readManifest(root, id)) {
175
+ const dir = join(root, 'games', id);
176
+ const named = new Set(manifest.assets.flatMap((a) => (a.files ?? []).map((f) => f.path)));
177
+ const out = [];
178
+ const walk = (rel, depth) => {
179
+ if (depth > 6) return;
180
+ let entries = [];
181
+ try { entries = readdirSyncSafe(join(dir, rel)); } catch { return; }
182
+ for (const e of entries) {
183
+ if (e.name.startsWith('.') || ['node_modules', 'codex', 'dist', 'raw'].includes(e.name)) continue;
184
+ const p = rel ? `${rel}/${e.name}` : e.name;
185
+ if (e.isDirectory()) walk(p, depth + 1);
186
+ else if (/\.(glb|gltf|fbx|obj|vrm)$/i.test(e.name) && !named.has(p)) out.push(p);
187
+ }
188
+ };
189
+ walk('', 0);
190
+ return out;
191
+ }
192
+ const readdirSyncSafe = (p) => readdirSync(p, { withFileTypes: true });
193
+
194
+ /* ------------------------------------------------------------------ RIGHTS.md */
195
+
196
+ /**
197
+ * What the providers' own terms say, with the date they were read (the models skill's references/RIGHTS.md keeps the
198
+ * same text and says to read the live pages again before commercial publishing).
199
+ */
200
+ export const PROVIDER_TERMS = Object.freeze([
201
+ { who: 'fal (generated images and models)', url: 'https://fal.ai/terms', read: '2026-10-02', says: 'You own what you upload; fal\'s terms give no express assignment of the output to you, and say output may not be unique and is not promised to be non-infringing. The models used here carry fal\'s "Commercial use" label.' },
202
+ { who: 'Tripo (image to 3D through fal)', url: 'https://developers.tripo3d.ai/en/terms', read: '2026-10-02', says: 'Calls through fal are paid calls. Tripo\'s developer terms (last updated 2025-07-11): "you may use Outputs for lawful commercial or non-commercial purposes". Its free plan\'s outputs are public under CC BY 4.0 (a free plan is not used here).' },
203
+ { who: 'Kenney', url: 'https://kenney.nl/support', read: '2026-10-02', says: 'Public domain (CC0): use in any project, commercial or not; credit is not required (kindly given anyway).' },
204
+ { who: 'KayKit', url: 'https://kaylousberg.itch.io/', read: '2026-10-02', says: 'CC0 1.0: free for personal and commercial use, no attribution required.' },
205
+ { who: 'Poly Haven', url: 'https://polyhaven.com/license', read: '2026-10-02', says: 'CC0: use for any purpose, redistribute, even in a product you sell; no credit needed for downloaded files.' },
206
+ { who: 'ambientCG', url: 'https://docs.ambientcg.com/license/', read: '2026-10-02', says: 'CC0 1.0: the raw files may be included in a project such as a video game.' },
207
+ ]);
208
+
209
+ const REMIX_WORDS = { include: 'a remix gets this file (fetched from this studio\'s site, checked by SHA-256)', reference: 'a remix fetches it from its origin, never from this studio', none: 'a remix gets a grey placeholder of the same size and needs its own licence' };
210
+ const ROUTE_WORDS = { procedural: 'made by the game\'s own code', library: 'from the Homie starter library', generated: 'generated on the studio\'s own account', imported: 'brought in by the studio', premium: 'bought' };
211
+ /** A remix's file was made by the original studio, not this one: say whose. */
212
+ const whoseAccount = (a) => (a.from?.remixOf?.studio ? `${a.from.remixOf.studio}'s` : 'the original studio\'s');
213
+ function routeWords(a) {
214
+ if (!a.from?.remixOf) return ROUTE_WORDS[a.route] ?? a.route;
215
+ if (a.route === 'generated') return `generated on ${whoseAccount(a)} own account`;
216
+ if (a.route === 'imported') return `brought in by ${a.from.remixOf.studio ?? 'the original studio'}`;
217
+ return ROUTE_WORDS[a.route] ?? a.route;
218
+ }
219
+
220
+ /** RIGHTS.md for one game, in plain words. */
221
+ export function rightsMarkdown(root, id, manifest = readManifest(root, id)) {
222
+ let game = {};
223
+ try { game = JSON.parse(readFileSync(join(root, 'games', id, 'game.json'), 'utf8')); } catch { game = {}; }
224
+ const lines = [
225
+ `# Rights: ${game.name ?? id}`,
226
+ '',
227
+ `Every file this game ships, where it came from, its licence and what that allows. Written by \`homie-studio assets rights ${id}\` from \`assets/manifest.json\`; do not edit it by hand.`,
228
+ '',
229
+ '## What the providers\' terms say',
230
+ '',
231
+ 'Read on the dates shown. Terms change: read the live page again before publishing anything commercial.',
232
+ '',
233
+ ...PROVIDER_TERMS.filter((t) => manifest.assets.some((a) => termsApply(t, a))).map((t) => `- **${t.who}** (${t.url}, read ${t.read}): ${t.says}`),
234
+ ...(manifest.assets.some((a) => a.route === 'generated') ? ['- **Copyright in AI-made files** may be thin: "remix with credit" may not bind a remixer for files no person authored. This file never claims more than the terms above.'] : []),
235
+ '',
236
+ '## Files',
237
+ '',
238
+ ];
239
+ if (!manifest.assets.length) lines.push('None yet: everything on screen is drawn by the game\'s own code.');
240
+ for (const a of manifest.assets) {
241
+ const info = licenseInfo(a.license?.kind);
242
+ const files = (a.files ?? []).filter((f) => f.public !== false).map((f) => `\`${f.path}\``).join(', ');
243
+ lines.push(`### ${a.id}`, '', `- **What:** ${a.kind}${a.card ? ` (${a.card})` : ''}${files ? `: ${files}` : ''}`,
244
+ `- **From:** ${routeWords(a)}${a.from?.pack ? `: ${a.from.packLabel ?? a.from.pack}${a.from.item ? `, ${String(a.from.item).split('/').pop()}` : ''}` : ''}${a.from?.packUrl ?? a.from?.origin ? ` (${a.from.packUrl ?? a.from.origin})` : ''}${a.from?.remixOf ? `; carried from ${a.from.remixOf.game ?? 'the original'}${a.from.remixOf.studio ? ` by ${a.from.remixOf.studio}` : ''}` : ''}${a.made?.steps?.length ? `; ${a.made.steps.map((s) => s.endpoint ? `${s.what} with ${s.endpoint}${Number(s.usd) ? ` (US$${Number(s.usd).toFixed(3)})` : ''}` : `${s.what}${s.tool ? ` (${s.tool})` : ''}`).join(', ')}` : ''}`,
245
+ `- **Licence:** ${info ? (a.from?.remixOf && info.kind === 'generated' ? `Generated on ${whoseAccount(a)} own account` : info.label) : 'NONE RECORDED: this file cannot be published'}${info?.url ? ` (${info.url})` : ''}${a.license?.owner ? `; owner: ${a.license.owner}` : ''}`,
246
+ ...(a.license?.attribution ? [`- **Credit:** ${a.license.attribution}`] : []),
247
+ `- **A remix:** ${REMIX_WORDS[a.license?.remix] ?? REMIX_WORDS.none}`,
248
+ ...(a.license?.notes ? [`- **Notes:** ${a.license.notes}`] : []),
249
+ '');
250
+ }
251
+ return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
252
+ }
253
+ function termsApply(t, a) {
254
+ const w = t.who.toLowerCase();
255
+ if (w.startsWith('fal')) return a.route === 'generated';
256
+ if (w.startsWith('tripo')) return (a.made?.steps ?? []).some((s) => /tripo/i.test(String(s.endpoint ?? '')));
257
+ return String(a.from?.pack ?? a.from?.library ?? a.license?.attribution ?? '').toLowerCase().includes(w.split(' ')[0]) || String(a.from?.origin ?? '').toLowerCase().includes(w.split(' ')[0].replace(/\s+/g, ''));
258
+ }
259
+
260
+ export function writeRights(root, id, manifest = readManifest(root, id)) {
261
+ const file = join(root, 'games', id, RIGHTS_FILE);
262
+ mkdirSync(dirname(file), { recursive: true });
263
+ const text = rightsMarkdown(root, id, manifest);
264
+ if (!existsSync(file) || readFileSync(file, 'utf8') !== text) writeFileSync(file, text);
265
+ return relative(root, file);
266
+ }
267
+
268
+ /* ------------------------------------------------------------------ credits.json */
269
+
270
+ /**
271
+ * credits.json `parts[]` (the landing's credits page): one line per library pack used (CC0 needs none; it is kind),
272
+ * and one per attribution-required file. Lines this function wrote carry `"asset": true`; every other part stays.
273
+ */
274
+ export function syncCredits(root, id, manifest = readManifest(root, id)) {
275
+ const file = join(root, 'games', id, 'credits.json');
276
+ let credits = {};
277
+ try { credits = JSON.parse(readFileSync(file, 'utf8')); } catch { credits = {}; }
278
+ const keep = (Array.isArray(credits.parts) ? credits.parts : []).filter((p) => p?.asset !== true);
279
+ const parts = [];
280
+ const packs = new Map();
281
+ for (const a of manifest.assets) {
282
+ const info = licenseInfo(a.license?.kind);
283
+ if (!info) continue;
284
+ if (info.attribution) parts.push({ what: `${a.card ?? a.id} (${a.kind})`, author: clean(a.license.attribution, 120), url: a.from?.origin ?? null, licence: info.spdx ?? info.label, licenceUrl: info.url, asset: true });
285
+ else if (a.route === 'library' && a.from?.pack) {
286
+ const key = a.from.pack;
287
+ if (!packs.has(key)) packs.set(key, { what: `${a.from.packLabel ?? a.from.pack} (${a.kind === 'texture' ? 'textures' : 'models'})`, author: a.from.author ?? null, url: a.from.packUrl ?? a.from.origin ?? null, licence: info.spdx ?? info.label, licenceUrl: info.url, note: 'Copied from the Homie starter library', asset: true });
288
+ }
289
+ }
290
+ const next = [...keep, ...packs.values(), ...parts].slice(0, 40);
291
+ if (!next.length && !existsSync(file)) return null;
292
+ const out = { ...credits, parts: next };
293
+ const text = `${JSON.stringify(out, null, 2)}\n`;
294
+ if (!existsSync(file) || readFileSync(file, 'utf8') !== text) writeFileSync(file, text);
295
+ return relative(root, file);
296
+ }
297
+
298
+ /* ------------------------------------------------------------------ the site's assets.json (remix carry-over) */
299
+
300
+ /**
301
+ * /games/<id>/assets.json on the built site: each asset's licence and remix rule, and for `include` files their
302
+ * served address (relative to /games/<id>/) and SHA-256, so `game remix` can fetch them. Only files under the
303
+ * game's public/ folder are served; a file's address there is its path without "public/".
304
+ */
305
+ export function servedAssets(root, id, out, manifest = readManifest(root, id)) {
306
+ const assets = [];
307
+ for (const a of manifest.assets) {
308
+ const info = licenseInfo(a.license?.kind);
309
+ const remix = info ? (info.redistribute ? a.license.remix ?? info.remix : info.remix === 'include' ? 'none' : info.remix) : 'none';
310
+ const files = (a.files ?? []).filter((f) => f.public !== false && /^public\//.test(String(f.path ?? ''))).map((f) => {
311
+ const served = f.path.replace(/^public\//, '');
312
+ const abs = join(out, served);
313
+ return existsSync(abs) ? { role: f.role, path: f.path, url: served, bytes: statSync(abs).size, sha256: f.sha256 ?? null } : null;
314
+ }).filter(Boolean);
315
+ assets.push({ id: a.id, kind: a.kind, route: a.route, card: a.card ?? null, license: { kind: a.license?.kind ?? null, spdx: info?.spdx ?? null, attribution: a.license?.attribution ?? null, remix }, from: a.from ? { library: a.from.library ?? null, pack: a.from.pack ?? null, item: a.from.item ?? null, origin: a.from.origin ?? null, sha256: a.from.sha256 ?? null } : null, size: a.measured?.box?.size ?? null, files: remix === 'include' ? files : files.map(({ url, ...f }) => ({ ...f, url: null })) });
316
+ }
317
+ return { v: 1, kind: 'homie-game-assets', game: id, assets };
318
+ }
package/lib/build.mjs CHANGED
@@ -5,6 +5,8 @@
5
5
  * site/dist/games/<id>/index.html the game's page (the Worker adds HOMIE_NET)
6
6
  * site/dist/games/<id>/assets/main.js its bundle (esbuild; @homie-rocks/studio/netplay inlined)
7
7
  * site/dist/games/<id>/... everything in games/<id>/public/
8
+ * site/dist/games/<id>/assets.json its assets' licences and, for those a remix may carry, their address and SHA-256
9
+ * (games/<id>/assets/manifest.json; lib/asset-manifest.mjs servedAssets)
8
10
  * site/dist/games/<id>/agents.json the AI guides' vocabulary (games/<id>/agents.json, checked; NETPLAY.md
9
11
  * section 18): the only goals and lines an AI in its rooms has
10
12
  * site/dist/games.json { studio, games[], songs[], videos[], posts[], site } from studio.json,
@@ -35,6 +37,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statS
35
37
  import { createRequire } from 'node:module';
36
38
  import { join, relative } from 'node:path';
37
39
  import { buildCodexPages } from './codex.mjs';
40
+ import { servedAssets } from './asset-manifest.mjs';
38
41
  import { buildMedia } from './media.mjs';
39
42
  import { buildSiteFiles, landingOf, readPosts, readTheme } from './site.mjs';
40
43
  import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
@@ -46,7 +49,7 @@ import { vocabularyOf } from '../worker/brain.mjs';
46
49
 
47
50
  const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
48
51
  /** Never copied into a static game's served folder. */
49
- const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md', 'CODEX.md', 'lab.json']);
52
+ const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md', 'CODEX.md', 'lab.json', 'codex']);
50
53
  const LOADERS = { '.png': 'file', '.jpg': 'file', '.jpeg': 'file', '.gif': 'file', '.webp': 'file', '.mp3': 'file', '.ogg': 'file', '.wav': 'file', '.m4a': 'file', '.glb': 'file', '.gltf': 'file', '.bin': 'file', '.hdr': 'file', '.svg': 'file', '.json': 'json', '.woff2': 'file', '.ttf': 'file' };
51
54
 
52
55
  /** The port toolkit as one classic script (window.HomiePort), for static games. Built once per build. */
@@ -79,12 +82,25 @@ const TEXT = /\.(ts|tsx|js|mjs|jsx|json|html|css|md|txt|svg|glsl|wgsl|frag|vert)
79
82
  * it (`credit`: the studio and the game; the live site adds the page, worker/index.mjs) and its licence (game.json
80
83
  * "license", worker/license.mjs).
81
84
  */
85
+ /** From games/<id>/style.json: the light colour as paper, the dark as text, and the accent; null without one. */
86
+ function uiOf(g) {
87
+ let pal = null;
88
+ try { pal = JSON.parse(readFileSync(join(g.dir, 'style.json'), 'utf8'))?.palette ?? null; } catch { pal = null; }
89
+ const hex = (v) => (/^#[0-9a-f]{6}$/i.test(String(v ?? '')) ? String(v).toLowerCase() : null);
90
+ const ink = hex(pal?.ink); const bg = hex(pal?.bg);
91
+ if (!ink || !bg) return null;
92
+ const lum = (h) => (0.2126 * parseInt(h.slice(1, 3), 16) + 0.7152 * parseInt(h.slice(3, 5), 16) + 0.0722 * parseInt(h.slice(5, 7), 16)) / 255;
93
+ const light = lum(ink) >= lum(bg);
94
+ return { paper: light ? ink : bg, text: light ? bg : ink, ...(hex(pal?.accent) ? { hot: hex(pal.accent) } : {}) };
95
+ }
96
+
82
97
  export function sourceOf(dir, id, { studio = null, game = null, license } = {}) {
83
98
  const files = {};
84
99
  let total = 0;
85
100
  const walk = (rel) => {
86
101
  for (const entry of readdirSync(join(dir, rel), { withFileTypes: true })) {
87
- if (entry.name.startsWith('.') || SOURCE_SKIP.has(entry.name) || (!rel && entry.name === 'CODEX.md')) continue;
102
+ // The codex (CODEX.md, and codex/: the art-direction decisions and the style board) is the owner's, never shared.
103
+ if (entry.name.startsWith('.') || SOURCE_SKIP.has(entry.name) || (!rel && (entry.name === 'CODEX.md' || entry.name === 'codex'))) continue;
88
104
  const path = rel ? `${rel}/${entry.name}` : entry.name;
89
105
  if (entry.isDirectory()) walk(path);
90
106
  else if (TEXT.test(entry.name)) {
@@ -298,6 +314,9 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
298
314
  vocabFor(g, out);
299
315
  // The game's own source, for other studios to remix (game.json "share": { "source": false } keeps it private).
300
316
  if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id, { studio: studio.name ?? null, game: g.name ?? g.id, license: g.license }))}\n`);
317
+ // Its assets' licences, and the address and SHA-256 of each one a remix may carry (`game remix` fetches them).
318
+ if (g.share?.source !== false && existsSync(join(g.dir, 'assets', 'manifest.json'))) writeFileSync(join(out, 'assets.json'), `${JSON.stringify(servedAssets(root, g.id, out))}\n`);
319
+ else rmSync(join(out, 'assets.json'), { force: true });
301
320
  const main = join(out, 'assets', 'main.js');
302
321
  const bytes = existsSync(main) ? statSync(main).size : dirBytes(out);
303
322
  const seats = seatsFor(g, netplayOf(g, out));
@@ -321,8 +340,12 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
321
340
  const net = netplayOf(g, join(dist, 'games', g.id));
322
341
  const { min, max } = seatsFor(g, net);
323
342
  const seeds = serverSeeds(g, log);
343
+ // The game's own palette (style.json, its art direction): the play page's buttons wear its paper and ink, so the
344
+ // page's pills and the game's own HUD are one UI.
345
+ const ui = uiOf(g);
324
346
  return {
325
347
  id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
348
+ ...(ui ? { ui } : {}),
326
349
  roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
327
350
  ...(g.screen ? { screen: g.screen } : {}),
328
351
  // game.json "saves": true — player accounts and cloud saves (saves/SAVES.md): the play shell answers the game's
package/lib/codex.mjs CHANGED
@@ -42,6 +42,7 @@ import { latestFeedFor, summarize } from './feed-summary.mjs';
42
42
  import { escapeHtml as esc, renderMarkdown } from './markdown.mjs';
43
43
  import { readTheme } from './site.mjs';
44
44
  import { GAME_ID, listGames, readStudio } from './studio.mjs';
45
+ import { decisionRows, readDecisions } from './decisions.mjs';
45
46
 
46
47
  export const CODEX_FILE = 'CODEX.md';
47
48
  export const CODEX_DIR = join('.studio', 'codex');
@@ -314,6 +315,9 @@ function sectionHtml(section, emb, ctx) {
314
315
  }
315
316
  const questions = section.key === 'questions';
316
317
  let body = blocks(intro, emb, { dated: section.key === 'latest' });
318
+ // The Art direction tab is the style bible: the decisions (games/<id>/codex/decisions.json) drawn after the prose.
319
+ const decided = section.key === 'art' && ctx.decisions ? decisionsHtml(ctx.decisions, emb) : '';
320
+ if (decided) body += decided;
317
321
  if (questions) body = body.replace(/^<(ul|ol)>/, '<ol class="questions">').replace(/<\/(ul|ol)>$/, '</ol>');
318
322
  const empty = !body && !cards.length;
319
323
  return `<section class="tab" id="${esc(section.id)}" data-key="${esc(section.key ?? '')}">
@@ -324,6 +328,29 @@ ${cards.length ? `<div class="cards">${cards.map((c) => card(c.title, c.lines, e
324
328
  </section>`;
325
329
  }
326
330
 
331
+ /* ------------------------------------------------------------------ the art direction decisions */
332
+
333
+ const STATE_WORD = { auto: 'Auto', steered: 'Steered', pinned: 'Pinned by use', locked: 'Locked' };
334
+
335
+ /**
336
+ * The decisions as the style bible: the board's directions (the engine's swatches, a painted mood image only as a
337
+ * labelled target), the golden images, then one table per phase: decision, value (with the palette's colours), why, its
338
+ * state and who set it. Pictures come from codex/board/ inside the game's folder, embedded like any codex picture.
339
+ */
340
+ function decisionsHtml(doc, emb) {
341
+ const phases = decisionRows(doc);
342
+ if (!phases.length) return '';
343
+ const swatch = (c) => `<span class="sw" style="background:${esc(c)}" title="${esc(c)}"></span>`;
344
+ const board = (doc.board?.directions ?? []).map((d) => {
345
+ const pic = d.swatch ? emb.image(d.swatch) : null;
346
+ const mood = d.mood?.path ? emb.image(d.mood.path) : null;
347
+ return `<figure class="dir${doc.board.chosen === d.id ? ' chosen' : ''}">${pic ? `<img src="${pic}" alt="Direction ${esc(d.id.toUpperCase())}, drawn by the game engine">` : ''}${mood ? `<img src="${mood}" alt="A painted mood image for direction ${esc(d.id.toUpperCase())} (a target)">` : ''}<figcaption><b>${esc(d.id.toUpperCase())}</b> ${esc(d.label)}${doc.board.chosen === d.id ? ' · chosen' : ''}${mood ? ' · the painting is a target, not what the game draws' : ''}</figcaption></figure>`;
348
+ }).join('');
349
+ const golden = (doc.golden ?? []).map((g) => { const pic = emb.image(g.path); return pic ? `<img src="${pic}" alt="Golden image">` : ''; }).join('');
350
+ const tables = phases.map((p) => `<h3 class="phase">${esc(p.label)}</h3><div class="scroll"><table class="decisions"><thead><tr><th>Decision</th><th>Value</th><th>Why</th><th>State</th></tr></thead><tbody>${p.rows.map((r) => `<tr class="${esc(r.state)}"><td>${esc(r.name)}</td><td>${r.colours ? `<span class="sws">${r.colours.map(swatch).join('')}</span> ` : ''}${esc(r.label)}${r.steer.length ? ` <em>(“${esc(r.steer.at(-1))}”)</em>` : ''}</td><td>${esc(r.why ?? '')}</td><td><span class="st ${esc(r.state)}">${esc(STATE_WORD[r.state] ?? r.state)}</span>${r.by === 'person' ? ' <small>the person</small>' : ''}${r.at ? ` <small>${esc(String(r.at).slice(0, 10))}</small>` : ''}</td></tr>`).join('')}</tbody></table></div>`).join('');
351
+ return `<div class="decided"><p class="eyebrow">The decisions</p><p class="sub">Every look decision with its why. Auto: picked from the plan; steered: nudged by the person; pinned: an asset is built on it; locked: frozen by the person. Your AI changes a locked one only after you say so, and shows what would need remaking first.</p>${board ? `<div class="board">${board}</div>` : ''}${golden ? `<p class="eyebrow">Golden images</p><div class="gallery golden">${golden}</div>` : ''}${tables}${doc.derived?.prompt?.text ? `<details class="prompt"><summary>The style prompt every generated picture starts from</summary><p>${esc(doc.derived.prompt.text)}</p></details>` : ''}</div>`;
352
+ }
353
+
327
354
  /* ------------------------------------------------------------------ the build status tab */
328
355
 
329
356
  const STATE_MARK = { done: '✓', pass: '✓', skipped: '–', skip: '–', running: '◌', failed: '✗', fail: '✗', stopped: '■', pending: '' };
@@ -432,6 +459,13 @@ code{font-family:var(--mono);font-size:.92em}pre{overflow-x:auto;background:var(
432
459
  .try{margin:16px 0;padding:14px 16px;border:1px solid var(--gold);border-radius:14px;background:color-mix(in srgb,var(--gold) 8%,transparent)}.try .eyebrow{color:var(--gold)}.try p{margin:0}
433
460
  .panel h3{margin-bottom:6px}.status .panel{margin-top:14px}
434
461
  footer{max-width:1080px;margin:0 auto;padding:18px 16px 40px;color:var(--dim);font-size:13px;border-top:1px solid var(--line)}
462
+ .decided{margin-top:22px}.decided h3.phase{margin:22px 0 4px;font:600 13px/1.4 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--accent2)}
463
+ table.decisions td:first-child{white-space:nowrap;font-weight:600}table.decisions td:nth-child(3){color:var(--dim);font-size:14px}
464
+ .sws{display:inline-flex;gap:3px;vertical-align:middle}.sw{display:inline-block;width:16px;height:16px;border-radius:4px;border:1px solid var(--line)}
465
+ .st{display:inline-block;padding:1px 8px;border-radius:999px;font:600 12px/1.6 var(--mono);border:1px solid var(--line);color:var(--dim)}
466
+ .st.locked{color:var(--gold);border-color:var(--gold)}.st.pinned{color:var(--accent2);border-color:color-mix(in srgb,var(--accent2) 60%,transparent)}.st.steered{color:var(--ink)}
467
+ .board{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr));gap:12px;margin:12px 0}.board figure{margin:0}.board figure.chosen img:first-child{outline:3px solid var(--accent);outline-offset:-3px}.board img+img{margin-top:6px}
468
+ details.prompt{margin:16px 0;color:var(--dim);font-size:14px}details.prompt summary{cursor:pointer;color:var(--accent2)}
435
469
  @media (max-width:560px){.card .art{width:72px;height:72px}ol.timeline li{grid-template-columns:1fr;gap:2px}}`;
436
470
  }
437
471
 
@@ -502,10 +536,17 @@ export function renderCodex(root, id, { mode = 'file', feed, now = new Date() }
502
536
  let studio = {};
503
537
  try { studio = readStudio(root); } catch { studio = {}; }
504
538
  const emb = new Embedder(root, game.dir);
539
+ let decisions = null;
540
+ try { decisions = readDecisions(root, id); } catch { decisions = null; }
541
+ // A palette or fonts the person steered or locked, or an asset pinned, is the game's look: the codex wears it too.
542
+ const pal = decisions?.decisions?.['style.palette'];
543
+ const ui = decisions?.decisions?.['style.ui'];
544
+ if (pal && pal.state !== 'auto') meta.palette = { ...(meta.palette ?? {}), ...Object.fromEntries(['bg', 'ink', 'accent', 'accent2', 'danger', 'good', 'gold'].filter((k) => pal.value?.[k]).map((k) => [k, pal.value[k]])) };
545
+ if (ui && ui.state !== 'auto' && ui.value?.display) meta.fonts = { ...(meta.fonts ?? {}), display: ui.value.display, body: ui.value.body ?? meta.fonts?.body };
505
546
  const look = lookOf(root, game, meta, emb);
506
547
  const title = parsed.title ?? game.name ?? id;
507
548
  const eyebrow = typeof meta.eyebrow === 'string' && meta.eyebrow.trim() ? meta.eyebrow.trim() : `${studio.name ?? 'Studio'} · ${title}`;
508
- const ctx = { eyebrow: String(eyebrow).slice(0, 80), pixel: meta.pixel === true, try: typeof meta.try === 'string' ? meta.try.slice(0, 300) : '', mode };
549
+ const ctx = { eyebrow: String(eyebrow).slice(0, 80), pixel: meta.pixel === true, try: typeof meta.try === 'string' ? meta.try.slice(0, 300) : '', mode, decisions };
509
550
  const s = summarize(feed === undefined ? latestFeedFor(root, id) : feed);
510
551
  const sections = parsed.sections.filter((x) => x.body.join('\n').trim() || x.key);
511
552
  const tabs = sections.map((x) => `<a href="#${esc(x.id)}">${esc(x.title)}</a>`);
@@ -519,6 +560,7 @@ export function renderCodex(root, id, { mode = 'file', feed, now = new Date() }
519
560
  const start = s?.state === 'running' ? 'build-status' : (sections[0]?.id ?? 'build-status');
520
561
  const tagline = typeof meta.tagline === 'string' ? meta.tagline : '';
521
562
  const gaps = codexGaps(parsed);
563
+ if (decisions) gaps.missing = gaps.missing.filter((x) => x !== 'Art direction');
522
564
  const html = `<!doctype html>
523
565
  <html lang="en">
524
566
  <head>