@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.
- package/CHANGELOG.md +39 -1
- package/README.md +55 -0
- package/assets/assets.ts +500 -0
- package/assets/render-page.ts +629 -0
- package/assets/safety.mjs +194 -0
- package/bin/homie-studio.mjs +47 -1
- package/lib/art-cli.mjs +296 -0
- package/lib/art-tools.mjs +331 -0
- package/lib/asset-check.mjs +189 -0
- package/lib/asset-import.mjs +136 -0
- package/lib/asset-manifest.mjs +318 -0
- package/lib/build.mjs +25 -2
- package/lib/codex.mjs +43 -1
- package/lib/colour.mjs +160 -0
- package/lib/decisions.mjs +800 -0
- package/lib/directory.mjs +9 -1
- package/lib/library.mjs +169 -0
- package/lib/mcp-tools.mjs +22 -4
- package/lib/mcp.mjs +6 -3
- package/lib/optimise.mjs +380 -0
- package/lib/remix-assets.mjs +84 -0
- package/lib/render3d.mjs +81 -0
- package/lib/scaffold.mjs +26 -0
- package/lib/site.mjs +6 -1
- package/lib/studio.mjs +136 -9
- package/lib/style-board.mjs +227 -0
- package/lib/style-presets.mjs +121 -0
- package/lib/template-history.json +38 -0
- package/mcp/ui/card.css +15 -0
- package/mcp/ui/cast.js +62 -0
- package/mcp/ui/decision.js +61 -0
- package/mcp/ui/lineup.js +68 -0
- package/mcp/ui/rights.js +30 -0
- package/mcp/ui/style.js +154 -0
- package/package.json +14 -2
- package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
- package/starters/gem-rush-3d/assets/manifest.json +2318 -0
- package/starters/gem-rush-3d/credits.json +31 -0
- package/starters/gem-rush-3d/game.json +11 -0
- package/starters/gem-rush-3d/index.html +20 -0
- package/starters/gem-rush-3d/lab.json +19 -0
- package/starters/gem-rush-3d/src/main.ts +2086 -0
- package/starters/gem-rush-3d/style.json +66 -0
- package/starters/gem-rush-3d/tunables.json +13 -0
- package/worker/index.mjs +5 -3
- package/worker/pages.mjs +13 -5
- package/worker/qr.mjs +11 -0
- package/worker/site.mjs +5 -3
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ART TOOLS OF THE LOCAL MCP (`homie-studio mcp`, lib/mcp-tools.mjs pushes these): art direction as decisions, the
|
|
3
|
+
* style board, the cast, the starter library, generated props, the checks, the lineup and the rights, each with a card.
|
|
4
|
+
*
|
|
5
|
+
* style_explore three directions drawn by the engine (free); painted mood images only with paid + approve style card
|
|
6
|
+
* style_board the board and the decisions, as they are (nothing drawn) style card
|
|
7
|
+
* decision_set pick, mix, set, steer, lock, unlock one decision; a locked change answers with its blast radius
|
|
8
|
+
* first and changes only on confirm (the person's yes) decision card
|
|
9
|
+
* assets_plan the cast: each asset's route, a library match or a price, against the art budget cast card
|
|
10
|
+
* assets_find the free CC0 starter library cast card
|
|
11
|
+
* asset_add a library item, or a file with its licence, into the game (checked, optimised, recorded)
|
|
12
|
+
* asset_make one generated prop on the person's own fal account: a priced dry run unless approve; the
|
|
13
|
+
* concept first, then (after looking) the mesh lineup card
|
|
14
|
+
* asset_check phone budgets, the validator, licences, staleness
|
|
15
|
+
* asset_lineup true scale, silhouettes, palette drift, flags lineup card
|
|
16
|
+
* asset_rights RIGHTS.md and every asset's licence rights card
|
|
17
|
+
*
|
|
18
|
+
* Work that needs the studio's own tools (rendering, optimising) runs the studio's pinned CLI as a job; decisions are
|
|
19
|
+
* plain files and change here directly. Pictures go to cards as small data: URLs (each a few hundred KB at most).
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { join, resolve } from 'node:path';
|
|
23
|
+
import { readManifest } from './asset-manifest.mjs';
|
|
24
|
+
import { artSpend, writeArtSummary } from './art-cli.mjs';
|
|
25
|
+
import { CATALOGUE_IDS, decisionRows, lockDecision, oneLine, phaseProgress, pickDirection, readDecisions, setDecision, staleAssets, steerDecision, unlockDecision } from './decisions.mjs';
|
|
26
|
+
import { libraryBase, loadIndex, searchLibrary, itemThumb } from './library.mjs';
|
|
27
|
+
import { boardView } from './style-board.mjs';
|
|
28
|
+
import { listGames } from './studio.mjs';
|
|
29
|
+
|
|
30
|
+
export const ART_UI = Object.freeze({
|
|
31
|
+
style: 'ui://homie-studio/style',
|
|
32
|
+
decision: 'ui://homie-studio/decision',
|
|
33
|
+
cast: 'ui://homie-studio/cast',
|
|
34
|
+
lineup: 'ui://homie-studio/lineup',
|
|
35
|
+
rights: 'ui://homie-studio/rights',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** A picture file as a data: URL no bigger than `max` (a smaller JPEG when it is over), or null. */
|
|
39
|
+
function picture(h, file, max = 160 * 1024) {
|
|
40
|
+
if (!file || !existsSync(file)) return null;
|
|
41
|
+
try { const p = h.pictureFor(file, { max }); return `data:${p.mimeType};base64,${p.data}`; } catch { return null; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function gameOf(root, id) {
|
|
45
|
+
const games = listGames(root);
|
|
46
|
+
if (id && (games.some((g) => g.id === id) || existsSync(join(root, 'games', String(id), 'CODEX.md')))) return String(id);
|
|
47
|
+
if (!id && games.length === 1) return games[0].id;
|
|
48
|
+
throw new Error(`which game? ${games.map((g) => g.id).join(', ') || 'there is none yet (game_plan or game_make first)'}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The style card's data: the board's directions with their pictures, the style phase's decisions, money, staleness. */
|
|
52
|
+
function styleData(h, root, game, extra = {}) {
|
|
53
|
+
const doc = readDecisions(root, game);
|
|
54
|
+
let title = null;
|
|
55
|
+
try { title = JSON.parse(readFileSync(join(root, 'games', game, 'game.json'), 'utf8')).name ?? null; } catch { /* planned: its codex names it */ }
|
|
56
|
+
if (!title) { try { title = /^#\s+(.+)$/m.exec(readFileSync(join(root, 'games', game, 'CODEX.md'), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, ''))?.[1]?.trim() ?? null; } catch { title = null; } }
|
|
57
|
+
title = title ?? game;
|
|
58
|
+
const manifest = readManifest(root, game);
|
|
59
|
+
const dirs = doc ? boardView(root, game, doc).map((d) => ({ ...d, swatch: picture(h, d.swatch ? join(root, 'games', game, d.swatch) : null), mood: d.mood?.path ? { image: picture(h, join(root, 'games', game, d.mood.path)), usd: d.mood.usd ?? null, label: 'a painted target, not what the game draws' } : null })) : [];
|
|
60
|
+
const rows = doc ? decisionRows(doc) : [];
|
|
61
|
+
return {
|
|
62
|
+
kind: 'style', game, title, path: doc?.path ?? null, line: doc ? oneLine(doc) : null, budget: doc?.budget?.usd ?? null, chosen: doc?.board?.chosen ?? null,
|
|
63
|
+
directions: dirs, phases: rows.map((p) => ({ id: p.id, label: p.label, rows: p.rows.map(({ id, name, label, why, state, by, colours, steer }) => ({ id, name, label, why, state, by, colours, steer: steer.at(-1) ?? null })) })),
|
|
64
|
+
progress: doc ? phaseProgress(doc) : null, prompt: doc?.derived?.prompt?.text ?? null, golden: (doc?.golden ?? []).length,
|
|
65
|
+
stale: doc ? staleAssets(doc, manifest).map((s) => s.id) : [], spend: artSpend(root, game), ...extra,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function styleText(d) {
|
|
70
|
+
if (!d.line) return `${d.title}: no decisions yet.`;
|
|
71
|
+
return [
|
|
72
|
+
`${d.title}. ${d.line}.`,
|
|
73
|
+
...(d.directions.length ? [`Style board: ${d.directions.map((x) => `${x.id.toUpperCase()} ${x.label}${x.chosen ? ' (chosen)' : ''}`).join(' | ')}.`] : ['No style board yet.']),
|
|
74
|
+
...(d.phases.find((p) => p.id === 'style')?.rows ?? []).map((r) => ` ${r.id}: ${r.label} [${r.state}]`),
|
|
75
|
+
...(d.stale.length ? [`Stale: ${d.stale.join(', ')}.`] : []),
|
|
76
|
+
`Spent US$${d.spend.used.toFixed(2)}${d.spend.cap !== null ? ` of US$${d.spend.cap}` : ' (no art budget: free routes only)'}.`,
|
|
77
|
+
].join('\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function artToolDefs(ctx, h) {
|
|
81
|
+
const { ok, fail, cli, stillRunning, whyOf, needsInstall, ui, str, STUDIO_ARG, RO, RW } = h;
|
|
82
|
+
const GAME = { game: str('The game\'s id (default: the only game)'), ...STUDIO_ARG };
|
|
83
|
+
const modelsScript = () => (ctx.skillsDir && existsSync(join(ctx.skillsDir, 'models', 'scripts', 'models.mjs')) ? join(ctx.skillsDir, 'models', 'scripts', 'models.mjs') : null);
|
|
84
|
+
/** The models skill's script in the studio, as a job (it prices, caps, receipts and resumes every paid call). */
|
|
85
|
+
async function models(root, label, args, { wait = ctx.waitMs } = {}) {
|
|
86
|
+
const script = modelsScript();
|
|
87
|
+
if (!script) throw new Error('the models guide is not on this computer (it comes with the Homie plugin and the Homie extension)');
|
|
88
|
+
const node = h.findNode();
|
|
89
|
+
if (!node) throw new Error('no Node.js 22 or newer on this computer');
|
|
90
|
+
const job = h.startJob({ root, label, cmd: node.bin, args: [script, ...args, '--json'], json: true });
|
|
91
|
+
const ended = await h.waitJob(job, wait);
|
|
92
|
+
return { ended, job, result: job.result };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return [
|
|
96
|
+
{
|
|
97
|
+
name: 'style_explore', title: 'Explore the look',
|
|
98
|
+
description: 'The style board: three coherent directions for a game\'s look (render style, palette, light, camera, materials, fonts, proportions), each DRAWN BY THE GAME ENGINE as the game would draw it, with the starter library\'s own pieces re-tinted into its palette. Free, about 10 to 30 s. The card has Pick, Mix, Steer and Lock. A painted mood image per direction is optional and paid (paid: true quotes it; approve: true after the person agreed makes them on their own fal account, labelled a target). Decisions start automatic from the person\'s words (prompt) and the codex.',
|
|
99
|
+
inputSchema: { type: 'object', properties: { ...GAME, prompt: str('Optional: the person\'s words about the game and its look (first time)'), paid: { type: 'boolean', description: 'Also quote painted mood images (paid, on the person\'s fal account)' }, approve: { type: 'boolean', description: 'Make the mood images now: only after the person agreed to the price' } } },
|
|
100
|
+
annotations: { title: 'Explore the look', ...RW }, _meta: ui(ART_UI.style),
|
|
101
|
+
run: async (a) => {
|
|
102
|
+
const root = ctx.root(a.studio);
|
|
103
|
+
const need = needsInstall(ctx, root); if (need) return need;
|
|
104
|
+
const game = gameOf(root, a.game);
|
|
105
|
+
const r = await cli(ctx, root, `style board ${game}`, ['style', 'board', game, ...(a.prompt ? ['--prompt', String(a.prompt).slice(0, 600)] : [])]);
|
|
106
|
+
if (!r.ended) return stillRunning(r.job, 'The style board');
|
|
107
|
+
if (r.job.code !== 0) return fail(`The style board was not drawn: ${whyOf(r.job)}`);
|
|
108
|
+
let quote = null; let moods = null;
|
|
109
|
+
if (a.paid === true) {
|
|
110
|
+
const q = await models(root, `mood images for ${game}`, ['mood', game, 'all', ...(a.approve === true ? ['--yes'] : ['--dry-run'])], { wait: a.approve === true ? ctx.waitMs : 30_000 }).catch((e) => ({ error: e.message }));
|
|
111
|
+
if (q.error) quote = { error: q.error };
|
|
112
|
+
else if (!q.ended) return stillRunning(q.job, 'The mood images');
|
|
113
|
+
else if (a.approve === true) moods = q.result;
|
|
114
|
+
else quote = { usd: +(q.result?.images ?? []).reduce((n, x) => n + (x.price?.usd ?? 0), 0).toFixed(3), per: q.result?.images?.[0]?.price?.usd ?? null, why: q.result?.why ?? null };
|
|
115
|
+
}
|
|
116
|
+
const d = styleData(h, root, game, { quote, moods: moods ? { ok: moods.ok, why: moods.why ?? null } : null });
|
|
117
|
+
writeArtSummary(root, game);
|
|
118
|
+
return ok([styleText(d), ...(quote?.usd ? [`Painted mood images: about US$${quote.usd} for all three on the person's own fal account (US$${quote.per} each). Ask first; then style_explore with paid and approve.`] : []), ...(quote?.error ? [`Mood images: ${quote.error}`] : []), 'The swatches are what the game will look like; Pick, Mix, Steer and Lock are on the card (decision_set does the same from chat).'].join('\n'), d);
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'style_board', title: 'Show the look',
|
|
123
|
+
description: 'The style board and a game\'s art-direction decisions as they are now (auto, steered, pinned by use, locked), on the style card. Nothing is drawn or changed.',
|
|
124
|
+
inputSchema: { type: 'object', properties: { ...GAME } },
|
|
125
|
+
annotations: { title: 'Show the look', ...RO }, _meta: ui(ART_UI.style),
|
|
126
|
+
run: async (a) => {
|
|
127
|
+
const root = ctx.root(a.studio);
|
|
128
|
+
const game = gameOf(root, a.game);
|
|
129
|
+
if (!readDecisions(root, game)) return fail(`${game} has no art-direction decisions yet: style_explore draws the board (or the plan's automatic picks: npx --no-install homie-studio style init ${game} --prompt "…")`);
|
|
130
|
+
const d = styleData(h, root, game);
|
|
131
|
+
return ok(styleText(d), d);
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: 'decision_set', title: 'Change a look decision',
|
|
136
|
+
description: `One art-direction decision: pick a style-board direction (pick: "b") or mix rows (mix: { "style.palette": "b" }), set a value, steer it in the person's words ("warmer", "less saturated", "closer", "golden hour"), lock it (lock: true, or lock: "style" for the whole style phase; only when the person said so, with their words), or unlock it (with the reason). A LOCKED decision changes only with unlock: true and a reason, and answers first with its blast radius (every asset made under it that would go stale and what remaking each would cost, and what a free palette re-tint fixes); it changes only with confirm: true after the person said yes, and nothing is ever remade by itself. Decisions: ${CATALOGUE_IDS.slice(0, 16).join(', ')}, …`,
|
|
137
|
+
inputSchema: { type: 'object', properties: {
|
|
138
|
+
...GAME, decision: str('The decision, e.g. style.palette'), value: str('A new value: an option id, a preset name, or JSON'), steer: str('The person\'s words to nudge it'),
|
|
139
|
+
lock: { description: 'true: lock this decision; "style" (or a phase): lock the whole phase' }, unlock: { type: 'boolean' }, reason: str('Why a locked one changes (the person\'s words)'),
|
|
140
|
+
confirm: { type: 'boolean', description: 'The person saw the blast radius and said yes' }, pick: str('A style-board direction: a, b or c'), mix: { type: 'object', description: 'Rows from other directions: { "style.palette": "b" }' },
|
|
141
|
+
words: str('What the person said (recorded with a lock)'), by: str('person when the person asked for it or tapped it (the card always does); else the AI', { enum: ['person', 'ai'] }),
|
|
142
|
+
} },
|
|
143
|
+
annotations: { title: 'Change a look decision', ...RW }, _meta: ui(ART_UI.decision),
|
|
144
|
+
run: async (a) => {
|
|
145
|
+
const root = ctx.root(a.studio);
|
|
146
|
+
const game = gameOf(root, a.game);
|
|
147
|
+
if (!readDecisions(root, game)) return fail(`${game} has no decisions yet: style_explore first`);
|
|
148
|
+
const manifest = readManifest(root, game);
|
|
149
|
+
const by = a.by === 'person' ? 'person' : 'ai';
|
|
150
|
+
let r; let action;
|
|
151
|
+
try {
|
|
152
|
+
if (a.pick || a.mix) { action = 'pick'; r = pickDirection(root, game, a.pick ? String(a.pick).toLowerCase() : null, { mixes: a.mix && typeof a.mix === 'object' ? a.mix : {}, manifest }); }
|
|
153
|
+
else if (!a.decision) return fail('name the decision (decision: "style.palette"), or pick a direction (pick: "b")');
|
|
154
|
+
else if (a.lock) { action = 'lock'; r = lockDecision(root, game, a.lock === true ? a.decision : String(a.lock), { by: 'person', words: a.words ?? (by === 'person' ? 'tapped Lock' : null) }); }
|
|
155
|
+
else if (a.unlock && a.value === undefined && a.steer === undefined) { action = 'unlock'; r = unlockDecision(root, game, a.decision, { reason: a.reason ?? null, by: 'person' }); }
|
|
156
|
+
else if (a.steer) { action = 'steer'; r = steerDecision(root, game, a.decision, String(a.steer), { manifest }); }
|
|
157
|
+
else if (a.value !== undefined) { action = 'set'; r = setDecision(root, game, a.decision, a.value, { by, why: null, words: a.words ?? null, unlock: a.unlock === true, reason: a.reason ?? null, confirm: a.confirm === true, manifest }); }
|
|
158
|
+
else return fail('say what to do: value, steer, lock, unlock, pick or mix');
|
|
159
|
+
} catch (error) { return fail(error.message); }
|
|
160
|
+
writeArtSummary(root, game);
|
|
161
|
+
const doc = readDecisions(root, game);
|
|
162
|
+
const rec = a.decision ? doc.decisions[a.decision] : null;
|
|
163
|
+
const data = {
|
|
164
|
+
kind: 'decision', game, action, decision: a.decision ?? null, ok: r.ok !== false, pending: Boolean(r.pending), why: r.why ?? r.note ?? null,
|
|
165
|
+
record: rec ? { id: a.decision, label: rec.label, state: rec.state, by: rec.by, rev: rec.rev, why: rec.why, colours: a.decision === 'style.palette' ? ['bg', 'ink', 'accent', 'accent2', 'danger', 'good', 'gold'].map((k) => rec.value?.[k]).filter(Boolean) : null } : null,
|
|
166
|
+
changed: r.changed ?? r.locked ?? null, refused: r.refused ?? null, blast: r.blast ?? null, stale: staleAssets(doc, manifest).map((s) => s.id), spend: artSpend(root, game),
|
|
167
|
+
ask: r.pending ? { decision: a.decision, value: a.value, reason: a.reason ?? null } : null,
|
|
168
|
+
};
|
|
169
|
+
const text = r.ok === false ? r.why
|
|
170
|
+
: r.pending ? [`Not changed yet. If ${a.decision} changes, ${r.blast.assets.length} asset(s) go stale:`, ...r.blast.assets.map((x) => ` ${x.id}: ${x.free ? `free (${x.free})` : x.remake.usd ? `US$${x.remake.usd.toFixed(2)} to remake` : x.remake.how}`), `Total to remake what has no free fix: about US$${r.blast.totals.usd.toFixed(2)}. Nothing is remade by itself. Ask the person; their yes is decision_set again with confirm: true.`].join('\n')
|
|
171
|
+
: action === 'pick' ? `Picked ${a.pick ? `direction ${String(a.pick).toUpperCase()}` : 'a mix'}: ${r.changed.join(', ') || 'nothing changed'}.${r.refused.length ? ` Left as locked: ${r.refused.join(', ')}.` : ''}${r.stale?.length ? ` Stale now: ${r.stale.join(', ')}.` : ''}`
|
|
172
|
+
: action === 'lock' ? `Locked: ${r.locked.join(', ') || 'nothing new'}.`
|
|
173
|
+
: `${a.decision}: ${rec?.label} (${rec?.state}).${data.stale.length ? ` Stale: ${data.stale.join(', ')} (nothing remade).` : ''}${r.blast ? ` ${r.blast.assets.length} asset(s) made under the old one.` : ''}`;
|
|
174
|
+
return r.ok === false ? fail(text, data) : ok(text, data);
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: 'assets_plan', title: 'Plan the cast',
|
|
179
|
+
description: 'The cast a game needs (its codex cards and genre: characters, props, places), each with its route (procedural, the free CC0 starter library, generated on the person\'s fal account, imported), a library match with a thumbnail or a price, its tier and budget, and the total against the art budget. Quotes only; nothing is made.',
|
|
180
|
+
inputSchema: { type: 'object', properties: { ...GAME } },
|
|
181
|
+
annotations: { title: 'Plan the cast', ...RO }, _meta: ui(ART_UI.cast),
|
|
182
|
+
run: async (a) => {
|
|
183
|
+
const root = ctx.root(a.studio);
|
|
184
|
+
const game = gameOf(root, a.game);
|
|
185
|
+
const doc = readDecisions(root, game);
|
|
186
|
+
if (!doc) return fail(`${game} has no decisions yet: style_explore, or npx --no-install homie-studio style init ${game}`);
|
|
187
|
+
const manifest = readManifest(root, game);
|
|
188
|
+
const family = doc.decisions['cast.family']?.value ?? null;
|
|
189
|
+
let lib = null;
|
|
190
|
+
try { lib = await loadIndex({ lib: libraryBase() }); } catch (error) { lib = { error: error.message }; }
|
|
191
|
+
const perProp = 0.535;
|
|
192
|
+
const rows = [];
|
|
193
|
+
for (const c of doc.decisions['cast.list']?.value ?? []) {
|
|
194
|
+
const made = manifest.assets.find((x) => x.id === c.id || x.card === c.card);
|
|
195
|
+
let match = null;
|
|
196
|
+
if (!made && lib?.index && c.kind !== 'environment') {
|
|
197
|
+
const hit = searchLibrary(lib.index, c.id.replace(/-/g, ' '), { family, limit: 1 })[0] ?? searchLibrary(lib.index, c.id.replace(/-/g, ' '), { limit: 1 })[0];
|
|
198
|
+
if (hit) { const t = await itemThumb(lib.lib, hit.item); match = { id: hit.item.id, name: hit.item.name, family: hit.item.family, tris: hit.item.tris, thumb: t ? `data:image/webp;base64,${Buffer.from(t).toString('base64')}` : null }; }
|
|
199
|
+
}
|
|
200
|
+
rows.push({ ...c, state: made ? (staleAssets(doc, manifest).some((s) => s.id === made.id) ? 'stale' : 'made') : 'planned', asset: made?.id ?? null, route: made?.route ?? (c.kind === 'environment' ? 'procedural' : match ? 'library' : 'generated'), match, usd: made ? 0 : match || c.kind === 'environment' ? 0 : perProp });
|
|
201
|
+
}
|
|
202
|
+
const spend = artSpend(root, game);
|
|
203
|
+
const total = +rows.reduce((n, r) => n + r.usd, 0).toFixed(3);
|
|
204
|
+
const data = { kind: 'cast', mode: 'plan', game, family, rows, total, budget: doc.budget?.usd ?? null, spend, library: lib?.error ? { error: lib.error } : { items: lib?.index?.items?.length ?? 0 } };
|
|
205
|
+
return ok([`The cast of ${game} (${rows.length}):`, ...rows.map((r) => ` ${r.id} (${r.kind}, ${r.tier}): ${r.state}${r.state === 'planned' ? `, ${r.route}${r.match ? ` match ${r.match.id}` : r.usd ? ` about US$${r.usd} (concept + mesh)` : ''}` : ` (${r.route})`}`), `To generate what has no match: about US$${total}${doc.budget ? ` against a budget of US$${doc.budget.usd}` : ' (no art budget yet: free routes only until the person sets one)'}.`, ...(lib?.error ? [`Library: ${lib.error}`] : [])].join('\n'), data);
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: 'assets_find', title: 'Find free models',
|
|
210
|
+
description: 'Search Homie\'s free starter library (CC0 only: Kenney, KayKit, Poly Haven, ambientCG; every item phone-sized, with its triangles, size and licence) by words, e.g. "fox", "pine tree", "crate", "knight rigged". Free. asset_add brings one into the game (copied, never hot-linked).',
|
|
211
|
+
inputSchema: { type: 'object', properties: { query: str('Words: what it is'), kind: str('Optional: prop, character, creature, kit, texture or sky'), family: str('Optional: kenney, kaykit, polyhaven or ambientcg'), limit: { type: 'number', description: 'At most this many (default 8)' }, ...GAME } },
|
|
212
|
+
annotations: { title: 'Find free models', readOnlyHint: true, destructiveHint: false, openWorldHint: true }, _meta: ui(ART_UI.cast),
|
|
213
|
+
run: async (a) => {
|
|
214
|
+
let lib;
|
|
215
|
+
try { lib = await loadIndex({ lib: libraryBase() }); } catch (error) { return fail(`The starter library is not reachable: ${error.message}`); }
|
|
216
|
+
const hits = searchLibrary(lib.index, String(a.query ?? ''), { kind: a.kind ?? null, family: a.family ?? null, limit: Math.min(16, Number(a.limit ?? 8)) });
|
|
217
|
+
const items = [];
|
|
218
|
+
for (const { item } of hits) { const t = await itemThumb(lib.lib, item); items.push({ id: item.id, name: item.name, kind: item.kind, family: item.family, pack: item.pack, tris: item.tris, kb: Math.round((item.files?.[0]?.bytes ?? 0) / 1024), heightM: item.heightM, rigged: Boolean(item.rigged), license: 'CC0 1.0', thumb: t ? `data:image/webp;base64,${Buffer.from(t).toString('base64')}` : null }); }
|
|
219
|
+
const game = a.game ?? null;
|
|
220
|
+
return ok([`${items.length} free CC0 model(s) for "${a.query}":`, ...items.map((i) => ` ${i.id} (${i.kind}, ${i.tris} triangles, ${i.kb} KB${i.rigged ? ', rigged' : ''})`), items.length ? `asset_add { "game": "<id>", "item": "${items[0].id}" } copies one in.` : 'Nothing matched: other words, or a generated prop (asset_make, paid).'].join('\n'), { kind: 'cast', mode: 'find', game, query: String(a.query ?? ''), items });
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: 'asset_add', title: 'Add a model',
|
|
225
|
+
description: 'Bring a model into a game: a starter-library item (item: its id; free, CC0), or a file on this computer (file, with license: own, cc0, cc-by-4.0 with attribution, eula:<store>, …; ask the person whose it is). It is checked first (a file with an external URI, or one over the size caps, is refused), made phone-sized (triangles, pictures, pivot, height in metres), copied into games/<id>/public/models/ and recorded with its licence; RIGHTS.md and the credits follow, and the decisions it was made under are pinned.',
|
|
226
|
+
inputSchema: { type: 'object', properties: { ...GAME, item: str('A starter-library item id (assets_find)'), file: str('Or a .glb/.gltf on this computer'), license: str('With file: own, cc0, cc-by-4.0, cc-by-3.0, eula:<name>, other'), attribution: str('Who made it, where from (CC BY)'), as: str('The asset\'s id in the game'), height: { type: 'number', description: 'Its height in metres' }, card: str('Its codex card, e.g. "Items/Lantern"'), kind: str('prop, character, creature or kit') } },
|
|
227
|
+
annotations: { title: 'Add a model', ...RW }, _meta: ui(ART_UI.cast),
|
|
228
|
+
run: async (a) => {
|
|
229
|
+
const root = ctx.root(a.studio);
|
|
230
|
+
const need = needsInstall(ctx, root); if (need) return need;
|
|
231
|
+
const game = gameOf(root, a.game);
|
|
232
|
+
if (!a.item && !a.file) return fail('item (from assets_find) or file (with its licence)');
|
|
233
|
+
const args = ['assets', 'add', game, ...(a.item ? [String(a.item)] : ['--file', resolve(root, String(a.file)), '--license', String(a.license ?? '')]), ...(a.attribution ? ['--attribution', String(a.attribution)] : []), ...(a.as ? ['--as', String(a.as)] : []), ...(a.height ? ['--height', String(a.height)] : []), ...(a.card ? ['--card', String(a.card)] : []), ...(a.kind ? ['--kind', String(a.kind)] : [])];
|
|
234
|
+
const r = await cli(ctx, root, `assets add ${game}`, args);
|
|
235
|
+
if (!r.ended) return stillRunning(r.job, 'Adding the model');
|
|
236
|
+
if (r.job.code !== 0) return fail(`Not added: ${whyOf(r.job)}`);
|
|
237
|
+
const x = r.result;
|
|
238
|
+
writeArtSummary(root, game);
|
|
239
|
+
return ok(`${x.asset} is in games/${game} (${x.item ? `from the library: ${x.item}, CC0` : `${x.route}, ${x.license}`})${x.after ? `: ${x.after.tris} triangles, ${x.after.kb} KB, ${x.after.heightM} m` : ''}.${x.pinned?.length ? ` Pinned by use: ${x.pinned.join(', ')}.` : ''} asset_lineup shows it beside the rest.`, { kind: 'cast', mode: 'added', game, added: x });
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
name: 'asset_make', title: 'Make a prop (paid)',
|
|
244
|
+
description: 'One generated 3D prop for a game, in its locked style, on the person\'s OWN fal account: a concept image (the derived style prompt; the golden images as references), then Tripo P1 image-to-3D, then free local optimising and recording (receipts, licence, decisions). About US$0.54 a prop. Without approve it is a priced dry run: tell the person the price and ask. budget sets the cap they agreed to (refused past it). Step 1 (no concept yet) makes only the concept: LOOK at it; then step: "mesh" with approve makes the model. Never in a loop.',
|
|
245
|
+
inputSchema: { type: 'object', properties: { ...GAME, asset: str('The new asset\'s id, e.g. ember-lantern'), card: str('Its codex card, e.g. "Items/Ember Lantern"'), what: str('The thing in plain words: shape, material, what it is for'), height: { type: 'number', description: 'Its height in metres' }, step: str('concept (default) or mesh', { enum: ['concept', 'mesh'] }), approve: { type: 'boolean', description: 'The person agreed to the price' }, budget: { type: 'number', description: 'The cap in US dollars the person agreed to for this game\'s models' }, again: { type: 'boolean', description: 'A new concept with changed words (costs another concept)' } }, required: ['asset'] },
|
|
246
|
+
annotations: { title: 'Make a prop (paid)', readOnlyHint: false, destructiveHint: false, openWorldHint: true }, _meta: ui(ART_UI.lineup),
|
|
247
|
+
run: async (a) => {
|
|
248
|
+
const root = ctx.root(a.studio);
|
|
249
|
+
const need = needsInstall(ctx, root); if (need) return need;
|
|
250
|
+
const game = gameOf(root, a.game);
|
|
251
|
+
const asset = String(a.asset ?? '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
252
|
+
if (a.budget !== undefined) {
|
|
253
|
+
const b = await models(root, `budget for ${game}`, ['budget', game, '--cap', String(Number(a.budget))], { wait: 20_000 }).catch((e) => ({ error: e.message }));
|
|
254
|
+
if (b.error || b.job?.code) return fail(`The budget was not set: ${b.error ?? whyOf(b.job)}`);
|
|
255
|
+
}
|
|
256
|
+
const args = ['prop', game, asset, ...(a.card ? ['--card', String(a.card)] : []), ...(a.what ? ['--what', String(a.what)] : []), ...(a.height ? ['--height', String(a.height)] : []), ...(a.step === 'mesh' ? ['--mesh'] : []), ...(a.again ? ['--concept-again'] : []), ...(a.approve === true ? ['--yes'] : ['--dry-run'])];
|
|
257
|
+
let r;
|
|
258
|
+
try { r = await models(root, `${a.step === 'mesh' ? 'the model' : 'the concept'} for ${asset}`, args, { wait: a.approve === true ? ctx.waitMs : 30_000 }); } catch (error) { return fail(error.message); }
|
|
259
|
+
if (!r.ended) return stillRunning(r.job, a.step === 'mesh' ? `The 3D model for ${asset} (about 1 to 3 minutes)` : `The concept for ${asset}`);
|
|
260
|
+
const x = r.result ?? {};
|
|
261
|
+
writeArtSummary(root, game);
|
|
262
|
+
const concept = x.concept ? picture(h, join(root, x.concept), 260 * 1024) : null;
|
|
263
|
+
// What the card sends back with the person's press: the same prop, the same words.
|
|
264
|
+
const request = { ...(a.card ? { card: String(a.card) } : {}), ...(a.what ? { what: String(a.what) } : {}), ...(a.height ? { height: Number(a.height) } : {}) };
|
|
265
|
+
const data = { kind: 'lineup', mode: 'make', game, asset, request, step: x.step ?? a.step ?? 'concept', dryRun: Boolean(x.dryRun), price: x.price ?? null, total: x.total ?? null, then: x.then ?? null, needs: x.needs ?? null, concept, model: x.model ?? null, before: x.before ?? null, after: x.after ?? null, usd: x.usd ?? null, spend: artSpend(root, game), next: x.next ?? null, why: x.why ?? null };
|
|
266
|
+
if (r.job.code !== 0 && !x.needs) return fail(`Not made: ${x.why ?? whyOf(r.job)}`, data);
|
|
267
|
+
const text = x.dryRun ? `${x.step === 'mesh' ? 'The 3D model' : `The concept for ${asset}`} would cost about US$${x.price?.usd} (${x.price?.basis})${x.total ? `; with the model, about US$${x.total} for the prop` : ''}, on the person's own fal account. Ask them; then asset_make with approve: true (and budget: <their cap> the first time).`
|
|
268
|
+
: x.needs === 'approval' ? x.why
|
|
269
|
+
: x.step === 'concept' ? `The concept for ${asset} is made (US$${x.usd}). LOOK at it (file_read ${x.concept}): the thing, whole, in the game's style, on a plain background? Then asset_make { "asset": "${asset}", "step": "mesh", "approve": true } (about US$${x.then?.usd ?? '0.50'}), or new words with again: true.`
|
|
270
|
+
: x.step === 'mesh' ? `${asset} is a model in games/${game}: ${x.before?.tris} -> ${x.after?.tris} triangles, ${x.after?.kb} KB, ${x.after?.heightM} m (US$${x.usd}; receipts ${x.receipts?.join(', ')}). asset_lineup shows it at true scale beside the rest.`
|
|
271
|
+
: (x.next ?? JSON.stringify(x));
|
|
272
|
+
return ok(text, data);
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
name: 'asset_check', title: 'Check the assets',
|
|
277
|
+
description: 'Every asset of a game against the phone budgets (triangles, draw calls, picture memory, download), the Khronos glTF-Validator, its licence record, and whether a decision it was made under has changed; scene totals; big files in git. Free, a few seconds.',
|
|
278
|
+
inputSchema: { type: 'object', properties: { ...GAME } },
|
|
279
|
+
annotations: { title: 'Check the assets', ...RO },
|
|
280
|
+
run: async (a) => {
|
|
281
|
+
const root = ctx.root(a.studio);
|
|
282
|
+
const need = needsInstall(ctx, root); if (need) return need;
|
|
283
|
+
const game = gameOf(root, a.game);
|
|
284
|
+
const r = await cli(ctx, root, `assets check ${game}`, ['assets', 'check', game]);
|
|
285
|
+
if (!r.ended) return stillRunning(r.job, 'The asset check');
|
|
286
|
+
const x = r.result;
|
|
287
|
+
if (!x?.rows) return fail(`The check did not run: ${whyOf(r.job)}`);
|
|
288
|
+
const { checkLines } = await import('./asset-check.mjs');
|
|
289
|
+
return ok(checkLines(x).join('\n'), { kind: 'check', ...x });
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: 'asset_lineup', title: 'Lineup',
|
|
294
|
+
description: 'The made assets of a game side by side at true scale on a 1 m grid under the game\'s light (front and three-quarter), their silhouettes at phone size, and flags: over budget, palette drift from the locked palette, an unreadable silhouette, another library family, stale. Free. Look at it, then have a fresh reviewer score "does it look like one game?" (the playtest guide\'s blind review).',
|
|
295
|
+
inputSchema: { type: 'object', properties: { ...GAME } },
|
|
296
|
+
annotations: { title: 'Lineup', ...RO }, _meta: ui(ART_UI.lineup),
|
|
297
|
+
run: async (a) => {
|
|
298
|
+
const root = ctx.root(a.studio);
|
|
299
|
+
const need = needsInstall(ctx, root); if (need) return need;
|
|
300
|
+
const game = gameOf(root, a.game);
|
|
301
|
+
const r = await cli(ctx, root, `assets lineup ${game}`, ['assets', 'lineup', game]);
|
|
302
|
+
if (!r.ended) return stillRunning(r.job, 'The lineup');
|
|
303
|
+
const x = r.result;
|
|
304
|
+
if (!x?.rows) return fail(x?.why ?? `The lineup was not drawn: ${whyOf(r.job)}`);
|
|
305
|
+
writeArtSummary(root, game);
|
|
306
|
+
let check = null;
|
|
307
|
+
try { check = JSON.parse(readFileSync(join(root, '.studio', 'art', game, 'check.json'), 'utf8')); } catch { check = null; }
|
|
308
|
+
const data = { kind: 'lineup', mode: 'lineup', game, rows: x.rows, flagged: x.flagged, palette: x.palette, family: x.family, images: { front: picture(h, join(root, x.images.front), 220 * 1024), quarter: picture(h, join(root, x.images.quarter), 220 * 1024), silhouettes: picture(h, join(root, x.images.silhouettes), 80 * 1024) }, files: x.images, totals: check?.totals ?? null, budgets: check?.budgets ?? null, spend: artSpend(root, game) };
|
|
309
|
+
return ok([`Lineup of ${x.rows.length} asset(s), ${x.flagged} flagged. Pictures: ${x.images.front}, ${x.images.quarter} (file_read shows them).`, ...x.rows.map((row) => ` ${row.flags.length ? 'FLAG' : 'ok'} ${row.id}${row.size ? ` ${row.size[1]} m` : ''}${row.flags.length ? `: ${row.flags.join('; ')}` : ''}`)].join('\n'), data);
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
name: 'asset_rights', title: 'Rights',
|
|
314
|
+
description: 'Every asset\'s licence in plain words (what it is, where it came from, what the licence allows, any credit owed, what a remixer gets) and what the providers\' terms say; writes RIGHTS.md and the credits. Anything that would stop a public game from publishing is named with its fix.',
|
|
315
|
+
inputSchema: { type: 'object', properties: { ...GAME } },
|
|
316
|
+
annotations: { title: 'Rights', ...RW }, _meta: ui(ART_UI.rights),
|
|
317
|
+
run: async (a) => {
|
|
318
|
+
const root = ctx.root(a.studio);
|
|
319
|
+
const need = needsInstall(ctx, root); if (need) return need;
|
|
320
|
+
const game = gameOf(root, a.game);
|
|
321
|
+
const r = await cli(ctx, root, `assets rights ${game}`, ['assets', 'rights', game]);
|
|
322
|
+
if (!r.ended) return stillRunning(r.job, 'RIGHTS.md');
|
|
323
|
+
const x = r.result;
|
|
324
|
+
if (!x?.text) return fail(`Not written: ${whyOf(r.job)}`);
|
|
325
|
+
const m = readManifest(root, game);
|
|
326
|
+
const rows = m.assets.map((e) => ({ id: e.id, kind: e.kind, route: e.route, license: e.license?.kind ?? null, remix: e.license?.remix ?? null, attribution: e.license?.attribution ?? null, from: e.from?.pack ?? e.from?.item ?? null, placeholder: Boolean(e.placeholder) }));
|
|
327
|
+
return ok(`${x.file}:\n\n${x.text.slice(0, 6000)}`, { kind: 'rights', game, file: x.file, rows, problems: x.licence, text: x.text.slice(0, 20_000) });
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
];
|
|
331
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio assets check <id>`: static, free, on every asset and on the whole game.
|
|
3
|
+
*
|
|
4
|
+
* per asset the file is there and is the one recorded (SHA-256); the safety check (assets/safety.mjs); the Khronos
|
|
5
|
+
* glTF-Validator; triangles, materials, picture sizes, bones and bytes against its tier's budget
|
|
6
|
+
* (the game's cast.tiers decision, else the phone defaults); the pivot at the bottom centre; its height against
|
|
7
|
+
* the card's; its licence (lib/asset-manifest.mjs); whether a decision it was made under has moved on
|
|
8
|
+
* the game shipped models with no record; draw calls, triangles and picture memory summed (each asset times its
|
|
9
|
+
* `placements`, instanced ones drawn once); the first-play download from the last build (gzip); a
|
|
10
|
+
* committed binary over 5 MB under games/ (it belongs in R2, never in git)
|
|
11
|
+
*
|
|
12
|
+
* The result also goes to .studio/art/<id>/check.json, which the Studio mod's Art tab and the cards read.
|
|
13
|
+
*/
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { gzipSync } from 'node:zlib';
|
|
18
|
+
import { LIMITS, checkGlb } from '../assets/safety.mjs';
|
|
19
|
+
import { licenceProblems, readManifest, unrecordedModels } from './asset-manifest.mjs';
|
|
20
|
+
import { readDecisions, staleAssets } from './decisions.mjs';
|
|
21
|
+
|
|
22
|
+
/** The phone tier: the defaults when the game has no cast.tiers decision. Hard caps are never loosened. */
|
|
23
|
+
export const BUDGETS = Object.freeze({
|
|
24
|
+
hero: { triangles: 8000, texturePx: 1024, kb: 1536, bones: 48, materials: 1 },
|
|
25
|
+
npc: { triangles: 3000, texturePx: 512, kb: 600, bones: 32, materials: 1 },
|
|
26
|
+
prop: { triangles: 1500, texturePx: 512, kb: 300, bones: 0, materials: 1 },
|
|
27
|
+
signature: { triangles: 5000, texturePx: 1024, kb: 800, bones: 0, materials: 1 },
|
|
28
|
+
kit: { triangles: 1000, texturePx: 512, kb: 200, bones: 0, materials: 1 },
|
|
29
|
+
scene: { drawCalls: 100, triangles: 150_000, textureMB: 48, firstPlayMB: 5 },
|
|
30
|
+
});
|
|
31
|
+
export const HARD_CAPS = Object.freeze({ triangles: { hero: 15000, npc: 6000, prop: 8000, signature: 8000, kit: 3000 }, texturePx: 2048, kb: 5120, bones: 64, materials: 2, scene: { drawCalls: 150, triangles: 250_000, textureMB: 64, firstPlayMB: 8 } });
|
|
32
|
+
const BIG_FILE = 5 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
const tierOf = (a) => (BUDGETS[a.tier] ? a.tier : a.kind === 'character' ? 'hero' : a.kind === 'creature' ? 'npc' : a.kind === 'kit' || a.kind === 'environment' ? 'kit' : 'prop');
|
|
35
|
+
|
|
36
|
+
/** The budgets this game holds itself to: its cast.tiers decision over the phone defaults, never past a hard cap. */
|
|
37
|
+
export function budgetsFor(doc) {
|
|
38
|
+
const v = doc?.decisions?.['cast.tiers']?.value ?? {};
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const [k, def] of Object.entries(BUDGETS)) out[k] = { ...def, ...(typeof v[k] === 'object' ? v[k] : {}) };
|
|
41
|
+
for (const t of ['hero', 'npc', 'prop', 'signature', 'kit']) {
|
|
42
|
+
out[t].triangles = Math.min(out[t].triangles, HARD_CAPS.triangles[t]);
|
|
43
|
+
out[t].texturePx = Math.min(out[t].texturePx, HARD_CAPS.texturePx);
|
|
44
|
+
out[t].kb = Math.min(out[t].kb, HARD_CAPS.kb);
|
|
45
|
+
out[t].bones = Math.min(out[t].bones ?? 0, HARD_CAPS.bones);
|
|
46
|
+
}
|
|
47
|
+
for (const k of ['drawCalls', 'triangles', 'textureMB', 'firstPlayMB']) out.scene[k] = Math.min(out.scene[k], HARD_CAPS.scene[k]);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Files tracked by git under games/ over 5 MB (they belong in the studio's R2, never in git). */
|
|
52
|
+
export function bigCommittedFiles(root) {
|
|
53
|
+
const r = spawnSync('git', ['ls-files', '-z', '--', 'games'], { cwd: root, encoding: 'utf8', timeout: 10_000 });
|
|
54
|
+
if (r.status !== 0) return [];
|
|
55
|
+
return r.stdout.split('\0').filter(Boolean).map((p) => { try { return { path: p, bytes: statSync(join(root, p)).size }; } catch { return null; } }).filter((x) => x && x.bytes > BIG_FILE);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The first-play download of the last build of this game: every file under site/dist/games/<id>, gzipped. */
|
|
59
|
+
export function firstPlayBytes(root, id) {
|
|
60
|
+
const dir = join(root, 'site', 'dist', 'games', id);
|
|
61
|
+
if (!existsSync(dir)) return null;
|
|
62
|
+
let raw = 0; let gz = 0; let files = 0;
|
|
63
|
+
// The same sound in several formats (an .ogg and its .wav fallback for an old Safari): a browser fetches one, so
|
|
64
|
+
// only the smallest of each set counts.
|
|
65
|
+
const audio = new Map();
|
|
66
|
+
const walk = (d) => {
|
|
67
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
68
|
+
const p = join(d, e.name);
|
|
69
|
+
if (e.isDirectory()) { if (e.name !== '_landing') walk(p); continue; }
|
|
70
|
+
if (/^(source|assets)\.json$|\.map$/.test(e.name)) continue;
|
|
71
|
+
const b = readFileSync(p);
|
|
72
|
+
if (/\.(ogg|mp3|m4a|aac|wav|webm|opus|flac)$/i.test(e.name)) {
|
|
73
|
+
const stem = p.replace(/\.[^.\/]+$/, '');
|
|
74
|
+
const prev = audio.get(stem);
|
|
75
|
+
if (!prev || b.byteLength < prev) audio.set(stem, b.byteLength);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
raw += b.byteLength; files += 1;
|
|
79
|
+
gz += /\.(png|jpe?g|webp|glb|mp4|woff2|ktx2)$/i.test(e.name) ? b.byteLength : gzipSync(b, { level: 6 }).byteLength;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
walk(dir);
|
|
83
|
+
for (const bytes of audio.values()) { raw += bytes; gz += bytes; files += 1; }
|
|
84
|
+
return { raw, gzip: gz, files };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const kb = (n) => Math.round(n / 1024);
|
|
88
|
+
|
|
89
|
+
/** `assets check`: { ok, game, rows, totals, budgets, licence, stale, unrecorded, bigFiles, firstPlay, problems }. */
|
|
90
|
+
export async function assetsCheck(root, id, { validate = true, write = true } = {}) {
|
|
91
|
+
const manifest = readManifest(root, id);
|
|
92
|
+
const doc = readDecisions(root, id);
|
|
93
|
+
const budgets = budgetsFor(doc);
|
|
94
|
+
const gdir = join(root, 'games', id);
|
|
95
|
+
let tools = null;
|
|
96
|
+
try { tools = await import('./optimise.mjs'); } catch { tools = null; }
|
|
97
|
+
const rows = [];
|
|
98
|
+
let tris = 0; let draws = 0; let texBytes = 0;
|
|
99
|
+
const seenFiles = new Set();
|
|
100
|
+
for (const a of manifest.assets) {
|
|
101
|
+
const problems = []; const warnings = [];
|
|
102
|
+
const tier = tierOf(a);
|
|
103
|
+
const b = budgets[tier] ?? budgets.prop;
|
|
104
|
+
const n = Math.max(1, Number(a.placements ?? 1) || 1);
|
|
105
|
+
const model = (a.files ?? []).find((f) => f.role === 'model');
|
|
106
|
+
let m = null;
|
|
107
|
+
if (model) {
|
|
108
|
+
const abs = join(gdir, model.path);
|
|
109
|
+
if (!existsSync(abs)) problems.push(`${model.path} is missing`);
|
|
110
|
+
else {
|
|
111
|
+
const bytes = readFileSync(abs);
|
|
112
|
+
const safe = checkGlb(bytes, LIMITS.game);
|
|
113
|
+
if (!safe.ok) problems.push(...safe.problems.map((p) => `unsafe: ${p}`));
|
|
114
|
+
if (model.sha256 && tools) { const sum = tools.sha256(bytes); if (sum !== model.sha256) warnings.push('the file changed since it was recorded (its SHA-256 differs): record it again (assets add) so its measurements and provenance are true'); }
|
|
115
|
+
if (safe.ok && tools) {
|
|
116
|
+
const ins = await tools.inspectModel(bytes, { limits: LIMITS.game }).catch((e) => ({ ok: false, why: e.message }));
|
|
117
|
+
m = ins.measured;
|
|
118
|
+
if (validate && ins.validator?.errors) problems.push(`the glTF-Validator found ${ins.validator.errors} error${ins.validator.errors === 1 ? '' : 's'}: ${ins.validator.messages.slice(0, 2).join('; ')}`);
|
|
119
|
+
if (ins.why) problems.push(ins.why);
|
|
120
|
+
}
|
|
121
|
+
if (m) {
|
|
122
|
+
if (m.triangles > HARD_CAPS.triangles[tier]) problems.push(`${m.triangles} triangles is over the hard cap of ${HARD_CAPS.triangles[tier]} for a ${tier}`);
|
|
123
|
+
else if (m.triangles > b.triangles) problems.push(`${m.triangles} triangles (a ${tier}'s budget is ${b.triangles}): assets optimise --triangles ${b.triangles}`);
|
|
124
|
+
if (m.maxTexturePx > b.texturePx) problems.push(`a ${m.maxTexturePx} px picture (a ${tier}'s budget is ${b.texturePx} px)`);
|
|
125
|
+
if (m.materials > HARD_CAPS.materials) problems.push(`${m.materials} materials (at most ${HARD_CAPS.materials})`);
|
|
126
|
+
else if (m.materials > b.materials) warnings.push(`${m.materials} materials (the budget is ${b.materials}: one draw call per material)`);
|
|
127
|
+
if (bytes.byteLength / 1024 > b.kb) problems.push(`${kb(bytes.byteLength)} KB (a ${tier}'s budget is ${b.kb} KB)`);
|
|
128
|
+
if (m.bones > (b.bones || HARD_CAPS.bones)) problems.push(`${m.bones} bones (a ${tier}'s budget is ${b.bones || HARD_CAPS.bones})`);
|
|
129
|
+
if (m.pivot && !(m.pivot.bottom && m.pivot.centred)) warnings.push('its pivot is not at the bottom centre (assets optimise puts it there)');
|
|
130
|
+
const want = Number(a.heightM ?? a.card?.heightM ?? 0);
|
|
131
|
+
if (want > 0 && m.heightM && Math.abs(m.heightM / want - 1) > 0.1) warnings.push(`${m.heightM} m tall; its card says ${want} m`);
|
|
132
|
+
if (!seenFiles.has(model.path)) { texBytes += m.textureGpuBytes; seenFiles.add(model.path); }
|
|
133
|
+
tris += m.triangles * n;
|
|
134
|
+
draws += a.instanced ? m.drawCalls : m.drawCalls * n;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} else if (a.route !== 'procedural' && !['texture', 'sky'].includes(a.kind)) problems.push('no model file recorded');
|
|
138
|
+
for (const f of (a.files ?? []).filter((x) => x.role !== 'model' && x.public !== false && /^public\//.test(x.path ?? ''))) {
|
|
139
|
+
if (!existsSync(join(gdir, f.path))) problems.push(`${f.path} is missing`);
|
|
140
|
+
}
|
|
141
|
+
if (a.route === 'procedural' && a.measured) { tris += (a.measured.tris ?? 0) * n; draws += a.instanced ? (a.measured.drawCalls ?? 1) : (a.measured.drawCalls ?? 1) * n; }
|
|
142
|
+
rows.push({ id: a.id, kind: a.kind, tier, route: a.route, ok: problems.length === 0, problems, warnings, measured: m ? { tris: m.triangles, drawCalls: m.drawCalls, materials: m.materials, maxTexturePx: m.maxTexturePx, textureKB: kb(m.textureGpuBytes), bones: m.bones, heightM: m.heightM, kb: model ? kb(statSync(join(gdir, model.path)).size) : null } : null, budget: b, placements: n });
|
|
143
|
+
}
|
|
144
|
+
const licence = licenceProblems(root, id, manifest, { public: publicSource(root, id) });
|
|
145
|
+
const stale = doc ? staleAssets(doc, manifest) : [];
|
|
146
|
+
const unrecorded = unrecordedModels(root, id, manifest);
|
|
147
|
+
const bigFiles = bigCommittedFiles(root).filter((f) => f.path.startsWith(`games/${id}/`));
|
|
148
|
+
const firstPlay = firstPlayBytes(root, id);
|
|
149
|
+
const s = budgets.scene;
|
|
150
|
+
const totals = { assets: manifest.assets.length, triangles: tris, drawCalls: draws, textureMB: +(texBytes / 1024 / 1024).toFixed(1), firstPlayMB: firstPlay ? +(firstPlay.gzip / 1024 / 1024).toFixed(2) : null };
|
|
151
|
+
const problems = [];
|
|
152
|
+
if (totals.drawCalls > s.drawCalls) problems.push(`about ${totals.drawCalls} draw calls in a scene (the phone budget is ${s.drawCalls}): instance repeated props (placements with instanced: true), or fewer materials`);
|
|
153
|
+
if (totals.triangles > s.triangles) problems.push(`about ${totals.triangles.toLocaleString('en-US')} triangles in a scene (the phone budget is ${s.triangles.toLocaleString('en-US')})`);
|
|
154
|
+
if (totals.textureMB > s.textureMB) problems.push(`${totals.textureMB} MB of picture memory (the phone budget is ${s.textureMB} MB; an iPhone page dies near 100 MB)`);
|
|
155
|
+
if (totals.firstPlayMB !== null && totals.firstPlayMB > s.firstPlayMB) problems.push(`${totals.firstPlayMB} MB to download before the first round (the budget is ${s.firstPlayMB} MB)`);
|
|
156
|
+
for (const l of licence.filter((x) => x.level === 'refuse')) problems.push(`${l.asset}: ${l.problem}${l.fix ? ` (${l.fix})` : ''}`);
|
|
157
|
+
for (const f of bigFiles) problems.push(`${f.path} is ${(f.bytes / 1024 / 1024).toFixed(1)} MB in git: big files go to the studio's R2 (homie-studio storage add, then media move), raw files to art/<slug>/raw/ (git-ignored)`);
|
|
158
|
+
const ok = rows.every((r) => r.ok) && problems.length === 0;
|
|
159
|
+
const result = { ok, command: 'assets check', game: id, rows, totals, budgets: { ...s, tiers: Object.fromEntries(['hero', 'npc', 'prop', 'signature', 'kit'].map((t) => [t, budgets[t]])) }, licence, stale, unrecorded, bigFiles, firstPlay, problems, at: new Date().toISOString() };
|
|
160
|
+
if (write) {
|
|
161
|
+
const dir = join(root, '.studio', 'art', id);
|
|
162
|
+
mkdirSync(dir, { recursive: true });
|
|
163
|
+
writeFileSync(join(dir, 'check.json'), `${JSON.stringify(result, null, 2)}\n`);
|
|
164
|
+
}
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Whether a game's source is shared (game.json share.source is not false) and it is meant to be public. */
|
|
169
|
+
export function publicSource(root, id) {
|
|
170
|
+
try { const g = JSON.parse(readFileSync(join(root, 'games', id, 'game.json'), 'utf8')); return g.share?.source !== false; } catch { return true; }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The check in lines a person reads. */
|
|
174
|
+
export function checkLines(r) {
|
|
175
|
+
const lines = [`${r.ok ? 'PASS' : 'NOT YET'}: ${r.game}'s ${r.totals.assets} asset${r.totals.assets === 1 ? '' : 's'} against the phone budgets`];
|
|
176
|
+
for (const row of r.rows) {
|
|
177
|
+
const m = row.measured;
|
|
178
|
+
lines.push(` ${row.ok ? 'ok ' : 'FIX '} ${row.id} (${row.kind}, ${row.tier}, ${row.route})${m ? `: ${m.tris} triangles, ${m.drawCalls} draw${m.drawCalls === 1 ? '' : 's'}, ${m.kb} KB, ${m.maxTexturePx ? `${m.maxTexturePx} px pictures` : 'no pictures'}${m.heightM ? `, ${m.heightM} m` : ''}` : ''}`);
|
|
179
|
+
for (const p of row.problems) lines.push(` ${p}`);
|
|
180
|
+
for (const w of row.warnings) lines.push(` note: ${w}`);
|
|
181
|
+
}
|
|
182
|
+
for (const u of r.unrecorded) lines.push(` FIX ${u}: shipped with no record (homie-studio assets add ${r.game} --file ${u} --license <kind>)`);
|
|
183
|
+
const s = r.budgets;
|
|
184
|
+
lines.push(` scene: about ${r.totals.drawCalls} draw calls (budget ${s.drawCalls}), ${r.totals.triangles.toLocaleString('en-US')} triangles (${s.triangles.toLocaleString('en-US')}), ${r.totals.textureMB} MB of picture memory (${s.textureMB} MB)${r.totals.firstPlayMB !== null ? `, ${r.totals.firstPlayMB} MB first play (${s.firstPlayMB} MB)` : ', first play: build first'}`);
|
|
185
|
+
for (const l of r.licence) lines.push(` ${l.level === 'refuse' ? 'FIX ' : 'note'} licence ${l.asset}: ${l.problem}${l.fix ? ` (${l.fix})` : ''}`);
|
|
186
|
+
for (const st of r.stale) lines.push(` stale ${st.id}: made under an older ${st.decisions.map((d) => d.id).join(', ')} (homie-studio style blast shows the cost; nothing is remade by itself)`);
|
|
187
|
+
for (const p of r.problems.filter((x) => !/^[a-z0-9-]+: /.test(x))) lines.push(` FIX ${p}`);
|
|
188
|
+
return lines;
|
|
189
|
+
}
|