@homie-rocks/studio 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +78 -1
  2. package/README.md +55 -0
  3. package/assets/assets.ts +500 -0
  4. package/assets/render-page.ts +629 -0
  5. package/assets/safety.mjs +194 -0
  6. package/bin/homie-studio.mjs +52 -3
  7. package/lib/art-cli.mjs +296 -0
  8. package/lib/art-tools.mjs +331 -0
  9. package/lib/asset-check.mjs +189 -0
  10. package/lib/asset-import.mjs +136 -0
  11. package/lib/asset-manifest.mjs +318 -0
  12. package/lib/build.mjs +25 -2
  13. package/lib/check.mjs +3 -1
  14. package/lib/codex.mjs +43 -1
  15. package/lib/colour.mjs +160 -0
  16. package/lib/decisions.mjs +800 -0
  17. package/lib/directory.mjs +9 -1
  18. package/lib/library.mjs +169 -0
  19. package/lib/mcp-tools.mjs +22 -4
  20. package/lib/mcp.mjs +6 -3
  21. package/lib/office.mjs +30 -8
  22. package/lib/optimise.mjs +380 -0
  23. package/lib/port-check.mjs +3 -1
  24. package/lib/progress.mjs +16 -1
  25. package/lib/remix-assets.mjs +84 -0
  26. package/lib/render3d.mjs +81 -0
  27. package/lib/scaffold.mjs +32 -1
  28. package/lib/site.mjs +6 -1
  29. package/lib/studio.mjs +136 -9
  30. package/lib/style-board.mjs +227 -0
  31. package/lib/style-presets.mjs +121 -0
  32. package/lib/template-history.json +75 -0
  33. package/lib/thumb.mjs +82 -0
  34. package/mcp/ui/card.css +15 -0
  35. package/mcp/ui/cast.js +62 -0
  36. package/mcp/ui/decision.js +61 -0
  37. package/mcp/ui/lineup.js +68 -0
  38. package/mcp/ui/rights.js +30 -0
  39. package/mcp/ui/style.js +154 -0
  40. package/package.json +14 -2
  41. package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
  42. package/starters/gem-rush-3d/assets/manifest.json +2318 -0
  43. package/starters/gem-rush-3d/credits.json +31 -0
  44. package/starters/gem-rush-3d/game.json +11 -0
  45. package/starters/gem-rush-3d/index.html +20 -0
  46. package/starters/gem-rush-3d/lab.json +19 -0
  47. package/starters/gem-rush-3d/src/main.ts +2086 -0
  48. package/starters/gem-rush-3d/style.json +66 -0
  49. package/starters/gem-rush-3d/tunables.json +13 -0
  50. package/worker/index.mjs +5 -3
  51. package/worker/pages.mjs +13 -5
  52. package/worker/qr.mjs +11 -0
  53. package/worker/site.mjs +5 -3
  54. package/worker/version.mjs +1 -1
package/lib/directory.mjs CHANGED
@@ -5,12 +5,20 @@
5
5
  * nobody can list games under someone else's site.
6
6
  */
7
7
  import { request } from './net.mjs';
8
- import { readStudio, siteUrl } from './studio.mjs';
8
+ import { listGames, readStudio, siteUrl } from './studio.mjs';
9
+ import { licenceProblems, readManifest } from './asset-manifest.mjs';
9
10
 
10
11
  export async function publish(root, { homie, site } = {}) {
11
12
  const studio = readStudio(root);
12
13
  const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
13
14
  const url = site || siteUrl(root, studio);
15
+ // Licences first: a public game may ship only assets with a licence record that allows it.
16
+ const refused = [];
17
+ for (const g of listGames(root)) {
18
+ if (g.launch === 'private' || g.launch === 'invite') continue;
19
+ for (const p of licenceProblems(root, g.id, readManifest(root, g.id), { public: g.share?.source !== false })) if (p.level === 'refuse') refused.push({ game: g.id, ...p });
20
+ }
21
+ if (refused.length) return { ok: false, command: 'publish', refused, why: `not listed: ${refused.length} asset licence problem${refused.length === 1 ? '' : 's'}:\n${refused.map((p) => ` ${p.game}/${p.asset}: ${p.problem}${p.fix ? ` (${p.fix})` : ''}`).join('\n')}\nhomie-studio assets check <id> says the same; fix them, deploy, then publish again.` };
14
22
  if (!url) return { ok: false, command: 'publish', why: 'this studio has no live site yet: run `npm run deploy` first' };
15
23
  const sent = await request(`${directory}/api/studio/publish`, {
16
24
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ site: url }),
@@ -0,0 +1,169 @@
1
+ /**
2
+ * THE HOMIE STARTER LIBRARY, from a studio: free CC0 models, materials and skies (Kenney, KayKit, Poly
3
+ * Haven, ambientCG), each optimised to Homie's phone budgets, with a thumbnail, its measurements, its licence, its
4
+ * origin and its SHA-256. Hosted by homie.rocks (https://homie.rocks/api/library/v0/); built by the repository's
5
+ * scripts/library.mjs.
6
+ *
7
+ * searchLibrary(index, "fox forest", { kind, family }) ranked items (free, no account)
8
+ * addFromLibrary(root, game, itemId, { as, height }) the item COPIED into games/<id>/public/models/ (checked by
9
+ * SHA-256 and the safety rules), with its manifest entry:
10
+ * route library, licence cc0, origin, pack, measurements
11
+ *
12
+ * A game never loads anything from homie.rocks at runtime: what it uses is copied in, so it works if the library moves.
13
+ * HOMIE_LIBRARY (a folder, or another address) points the studio at another copy of the library, such as a local one.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { dirname, join, resolve } from 'node:path';
19
+ import { LIMITS, checkGlb } from '../assets/safety.mjs';
20
+ import { recordAsset } from './asset-manifest.mjs';
21
+ import { GAME_ID } from './studio.mjs';
22
+
23
+ export const LIBRARY_URL = 'https://homie.rocks/api/library/v0';
24
+ const CACHE = join(homedir(), '.cache', 'homie-studio', 'library');
25
+ const INDEX_MAX = 16 * 1024 * 1024;
26
+ const FILE_MAX = 16 * 1024 * 1024;
27
+
28
+ /** Where the library is: HOMIE_LIBRARY (a folder or an address), else homie.rocks. */
29
+ export function libraryBase(from = process.env.HOMIE_LIBRARY) {
30
+ const v = String(from ?? '').trim();
31
+ if (!v) return { kind: 'url', base: LIBRARY_URL };
32
+ if (/^https?:\/\//i.test(v)) return { kind: 'url', base: v.replace(/\/+$/, '') };
33
+ const dir = resolve(v.replace(/^~(?=$|\/)/, homedir()));
34
+ return { kind: 'dir', base: dir };
35
+ }
36
+
37
+ async function getBytes(lib, rel, cap) {
38
+ if (!/^[A-Za-z0-9._/-]+$/.test(rel) || rel.split('/').includes('..')) throw new Error(`not a library path: ${rel}`);
39
+ if (lib.kind === 'dir') {
40
+ const file = join(lib.base, rel);
41
+ if (!existsSync(file)) throw new Error(`the library at ${lib.base} has no ${rel}`);
42
+ if (statSync(file).size > cap) throw new Error(`${rel} is over ${Math.round(cap / 1024 / 1024)} MB`);
43
+ return readFileSync(file);
44
+ }
45
+ const res = await fetch(`${lib.base}/${rel}`, { headers: { 'user-agent': 'homie-studio' }, signal: AbortSignal.timeout(30_000) });
46
+ if (!res.ok) throw new Error(`the library answered ${res.status} for ${rel}${res.status === 404 && rel === 'index.json' ? ' (it is not online at this address yet; HOMIE_LIBRARY can point at a local copy)' : ''}`);
47
+ const bytes = Buffer.from(await res.arrayBuffer());
48
+ if (bytes.byteLength > cap) throw new Error(`${rel} is over ${Math.round(cap / 1024 / 1024)} MB`);
49
+ return bytes;
50
+ }
51
+
52
+ /** The library's index: from the folder, or fetched (kept a day in the home folder's .cache/homie-studio/library). */
53
+ export async function loadIndex({ lib = libraryBase(), fresh = false } = {}) {
54
+ let text = null;
55
+ if (lib.kind === 'url') {
56
+ const cached = join(CACHE, `${createHash('sha256').update(lib.base).digest('hex').slice(0, 12)}-index.json`);
57
+ if (!fresh && existsSync(cached) && Date.now() - statSync(cached).mtimeMs < 24 * 3600_000) text = readFileSync(cached, 'utf8');
58
+ else { text = (await getBytes(lib, 'index.json', INDEX_MAX)).toString('utf8'); mkdirSync(CACHE, { recursive: true }); writeFileSync(cached, text); }
59
+ } else text = (await getBytes(lib, 'index.json', INDEX_MAX)).toString('utf8');
60
+ const index = JSON.parse(text);
61
+ if (index?.kind !== 'homie-starter-library' || !Array.isArray(index.items)) throw new Error('that is not a Homie starter library index');
62
+ return { index, lib };
63
+ }
64
+
65
+ const words = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().split(/\s+/).filter(Boolean);
66
+ const stem = (w) => w.replace(/(ies)$/, 'y').replace(/(es)$/, '').replace(/s$/, '');
67
+
68
+ /**
69
+ * Ranked matches for `query`: [{ item, score, why }]. Every word counts: a tag or name hit beats a pack hit; `kind`,
70
+ * `family`, `pack` and `rigged` filter; a smaller file wins a tie (phones).
71
+ */
72
+ export function searchLibrary(index, query, { kind = null, family = null, pack = null, rigged = null, limit = 12 } = {}) {
73
+ const q = words(query).map(stem);
74
+ const out = [];
75
+ for (const it of index.items) {
76
+ if (kind && it.kind !== kind) continue;
77
+ if (family && it.family !== family) continue;
78
+ if (pack && it.pack !== pack) continue;
79
+ if (rigged !== null && Boolean(it.rigged) !== Boolean(rigged)) continue;
80
+ const tags = new Set((it.tags ?? []).flatMap(words).map(stem));
81
+ const name = new Set(words(`${it.name} ${it.item}`).map(stem));
82
+ const packWords = new Set(words(`${it.pack} ${it.family}`).map(stem));
83
+ let score = 0;
84
+ const why = [];
85
+ for (const w of q) {
86
+ if (name.has(w)) { score += 3; why.push(w); } else if (tags.has(w)) { score += 2; why.push(w); } else if (packWords.has(w)) score += 0.5;
87
+ else if ([...name, ...tags].some((t) => t.length > 3 && (t.startsWith(w) || w.startsWith(t)))) { score += 1; why.push(`${w}~`); }
88
+ }
89
+ if (!q.length) score = 1;
90
+ if (score > 0) out.push({ item: it, score: score - Math.min(0.4, (it.files?.[0]?.bytes ?? 0) / 4_000_000), why });
91
+ }
92
+ return out.sort((a, b) => b.score - a.score || a.item.id.localeCompare(b.item.id)).slice(0, limit);
93
+ }
94
+
95
+ /** One item's file bytes, checked by SHA-256 against the index and by the GLB safety rules. */
96
+ export async function fetchItemFile(lib, item, file = item.files?.find((f) => f.role === 'model') ?? item.files?.[0]) {
97
+ if (!file) throw new Error(`${item.id} has no file`);
98
+ const bytes = await getBytes(lib, file.path, FILE_MAX);
99
+ const sum = createHash('sha256').update(bytes).digest('hex');
100
+ if (file.sha256 && sum !== file.sha256) throw new Error(`${file.path}: its SHA-256 does not match the library's index (refused)`);
101
+ if (/\.glb$/i.test(file.path)) { const s = checkGlb(bytes, LIMITS.game); if (!s.ok) throw new Error(`${file.path}: ${s.problems.join('; ')}`); }
102
+ return { bytes, sha256: sum };
103
+ }
104
+
105
+ /** A library item's thumbnail as bytes (for cards), or null. */
106
+ export async function itemThumb(lib, item) {
107
+ if (!item.thumb) return null;
108
+ try { return await getBytes(lib, item.thumb, 512 * 1024); } catch { return null; }
109
+ }
110
+
111
+ const KIND_DIR = { texture: 'tex', sky: 'sky' };
112
+
113
+ /**
114
+ * Copy one library item into a game and record it (assets/manifest.json, RIGHTS.md, credits.json). `as`: the asset's
115
+ * id in the game (default: the item's own name); `height`: metres to scale it to (models; default its suggested size).
116
+ * Returns { ok, asset, files, pinned }.
117
+ */
118
+ export async function addFromLibrary(root, game, itemId, { as = null, height = null, card = null, lib = libraryBase(), index = null } = {}) {
119
+ if (!GAME_ID.test(String(game ?? ''))) throw new Error(`"${game}" is not a game id`);
120
+ const gdir = join(root, 'games', game);
121
+ if (!existsSync(gdir)) throw new Error(`no game "${game}"`);
122
+ const idx = index ?? (await loadIndex({ lib })).index;
123
+ const item = idx.items.find((x) => x.id === itemId) ?? idx.items.find((x) => x.item === itemId);
124
+ if (!item) throw new Error(`no library item "${itemId}" (assets find <words> lists them)`);
125
+ if (item.license !== 'cc0') throw new Error(`${item.id} is not CC0; the starter library holds only CC0`);
126
+ const id = String(as ?? item.item).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);
127
+ const files = [];
128
+ let measured = null;
129
+ if (item.kind === 'texture' || item.kind === 'sky') {
130
+ for (const f of item.files ?? []) {
131
+ const { bytes, sha256 } = await fetchItemFile(lib, item, f);
132
+ const rel = `public/${KIND_DIR[item.kind]}/${id}/${f.path.split('/').pop()}`;
133
+ mkdirSync(dirname(join(gdir, rel)), { recursive: true });
134
+ writeFileSync(join(gdir, rel), bytes);
135
+ files.push({ role: f.role, path: rel, bytes: bytes.byteLength, sha256 });
136
+ }
137
+ } else {
138
+ let { bytes, sha256 } = await fetchItemFile(lib, item);
139
+ const rel = `public/models/${id}.glb`;
140
+ mkdirSync(dirname(join(gdir, rel)), { recursive: true });
141
+ const want = Number(height ?? item.suggestM ?? 0);
142
+ let ops = [];
143
+ if (want > 0 && item.heightM && Math.abs(want / item.heightM - 1) > 0.02) {
144
+ // Scaled to the game's size here (the library's own file is unchanged; its SHA-256 is in from.sha256).
145
+ const { optimiseModel } = await import('./optimise.mjs');
146
+ const r = await optimiseModel(bytes, { height: want, triangles: 0, texture: Math.max(16, item.texturePx || 512), rigged: Boolean(item.rigged), metal: true });
147
+ bytes = Buffer.from(r.glb);
148
+ sha256 = r.sha256;
149
+ ops = r.ops;
150
+ measured = r.after;
151
+ }
152
+ writeFileSync(join(gdir, rel), bytes);
153
+ files.push({ role: 'model', path: rel, bytes: bytes.byteLength, sha256 });
154
+ if (ops.length) files[0].ops = ops;
155
+ }
156
+ const entry = {
157
+ id, kind: item.kind === 'creature' ? 'creature' : item.kind, tier: item.tier ?? 'prop', card, route: 'library',
158
+ from: { library: 'homie-starter', version: idx.version ?? 'v0', pack: item.pack, packLabel: idx.packs?.find((p) => p.id === item.pack)?.label ?? item.pack, packUrl: idx.packs?.find((p) => p.id === item.pack)?.url ?? null, item: item.id, author: item.author ?? null, origin: item.origin ?? null, sha256: item.files?.[0]?.sha256 ?? null, paletteSwap: Boolean(item.paletteSwap) },
159
+ files,
160
+ license: { kind: 'cc0', spdx: 'CC0-1.0', owner: item.author ?? null, attribution: null, remix: 'include', notes: `From the Homie starter library (${item.pack}); the pack's own licence text: ${idx.packs?.find((p) => p.id === item.pack)?.licenseFile ?? 'licenses/'}` },
161
+ measured: measured ? { tris: measured.triangles, materials: measured.materials, drawCalls: measured.drawCalls, textures: measured.textures.map((t) => ({ px: t.px, format: t.mimeType, gpuKB: Math.round(t.gpuBytes / 1024) })), bones: measured.bones, clips: measured.clips.length, heightM: measured.heightM, box: measured.box, glbKB: Math.round(files[0].bytes / 1024), flat: Boolean(item.paletteSwap) }
162
+ : { tris: item.tris ?? null, materials: item.materials ?? null, drawCalls: item.drawCalls ?? null, bones: item.bones ?? 0, clips: (item.clips ?? []).length, heightM: item.heightM ?? null, box: item.box ? { size: item.box } : null, glbKB: Math.round((files[0]?.bytes ?? 0) / 1024), flat: Boolean(item.paletteSwap) },
163
+ made: { steps: [{ what: 'library', item: item.id, sha256: item.files?.[0]?.sha256 ?? null }, ...(files[0]?.ops ? [{ what: 'optimise', tool: '@gltf-transform/functions', ops: files[0].ops }] : [])] },
164
+ review: { state: 'auto' },
165
+ };
166
+ const { entry: e, pinned } = recordAsset(root, game, entry);
167
+ return { ok: true, command: 'assets add', game, asset: e.id, item: item.id, files: files.map((f) => f.path), pinned, license: 'CC0 1.0' };
168
+ }
169
+
package/lib/mcp-tools.mjs CHANGED
@@ -35,6 +35,7 @@ import { pinnedVersion } from './upgrade.mjs';
35
35
  import { AgentSeat } from './agent-seat.mjs';
36
36
  import { LAB_PORT, runningLab } from './lab.mjs';
37
37
  import { pictureFor } from './pictures.mjs';
38
+ import { ART_UI, artToolDefs } from './art-tools.mjs';
38
39
 
39
40
  export const UI = Object.freeze({
40
41
  setup: 'ui://homie-studio/setup',
@@ -42,6 +43,7 @@ export const UI = Object.freeze({
42
43
  studio: 'ui://homie-studio/studio',
43
44
  codex: 'ui://homie-studio/codex',
44
45
  lab: 'ui://homie-studio/lab',
46
+ ...ART_UI,
45
47
  });
46
48
 
47
49
  const inside = (base, p) => p === base || p.startsWith(base.endsWith(sep) ? base : `${base}${sep}`);
@@ -698,7 +700,7 @@ export function toolDefs(ctx, avail = {}) {
698
700
  },
699
701
  {
700
702
  name: 'game_make', title: 'Make a multiplayer game',
701
- description: 'Make a game in the studio from a multiplayer starter, under the id you choose, to change into the person\'s game: gem-rush (an arena: every browser renders, one hosts the rules, bots fill empty seats, rounds restart) or ember-vale (a hero who lasts for days, with cloud saves: for persistent games). In a new studio only once their game is planned (game_plan), or when they ask for a copy of a working starter; never as a first step. A planned game\'s codex stays.',
703
+ description: 'Make a game in the studio from a multiplayer starter, under the id you choose, to change into the person\'s game: gem-rush (an arena: every browser renders, one hosts the rules, bots fill empty seats, rounds restart), gem-rush-3d (the same arena in three.js, dressed with free CC0 models through @homie-rocks/studio/assets: the start for a 3D game) or ember-vale (a hero who lasts for days, with cloud saves: for persistent games). In a new studio only once their game is planned (game_plan), or when they ask for a copy of a working starter; never as a first step. A planned game\'s codex stays.',
702
704
  inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens): its address'), name: str('The game\'s display name'), from: str('Starter id (default gem-rush)'), ...STUDIO_ARG }, required: ['id', 'name'] },
703
705
  annotations: { title: 'Make a game', ...RW },
704
706
  run: async (a) => {
@@ -707,7 +709,21 @@ export function toolDefs(ctx, avail = {}) {
707
709
  const r = await cli(ctx, root, `game new ${a.id}`, ['game', 'new', a.id, '--from', String(a.from ?? 'gem-rush'), '--name', String(a.name ?? a.id).slice(0, 60)]);
708
710
  if (!r.ended) return stillRunning(r.job, 'Making the game');
709
711
  if (r.job.code !== 0) return fail(`Not made: ${whyOf(r.job)}`);
710
- return ok(`games/${a.id} is ${a.name}, from the ${a.from ?? 'gem-rush'} starter (${(r.result?.files ?? []).length} files). Change it in games/${a.id}/src/main.ts (file_read, file_edit), then build and preview_run; check proves two browsers finish a round.`, { kind: 'game', ...r.result });
712
+ // A starter that needs a library the studio did not have (game.json "needs", e.g. three.js): it is in the
713
+ // studio's package.json now, and nothing builds until it is installed, so the install starts here.
714
+ let install = '';
715
+ if (r.result?.installNeeded) {
716
+ const libs = (r.result.needsAdded ?? []).map((n) => `${n.name} ${n.version}`).join(', ');
717
+ if (ctx.install) {
718
+ const running = runningJobs(root).find((j) => j.label.startsWith('npm install'));
719
+ let job = running ?? null;
720
+ if (!job) { try { job = installJob(root); } catch (error) { install = ` It needs ${libs}, added to the studio's package.json, but the install did not start (${error.message}): studio_install installs it.`; } }
721
+ if (job) install = ` It needs ${libs}, added to the studio's package.json; npm install is running (job ${job.id}): build once studio_job says it is done.`;
722
+ } else install = ` It needs ${libs}, added to the studio's package.json: studio_install installs it before the first build.`;
723
+ }
724
+ const m = r.result?.models;
725
+ const got = m ? ` Its models: ${m.fetched} from the starter library${m.missing.length ? `; ${m.missing.length} could not be fetched (${m.why ?? m.missing[0].why}), and it draws stand-ins for those` : ''}.` : '';
726
+ return ok(`games/${a.id} is ${a.name}, from the ${a.from ?? 'gem-rush'} starter (${(r.result?.files ?? []).length} files).${got}${install} Change it in games/${a.id}/src/main.ts (file_read, file_edit), then build and preview_run; check proves two browsers finish a round.`, { kind: 'game', ...r.result });
711
727
  },
712
728
  },
713
729
  {
@@ -1152,6 +1168,8 @@ export function toolDefs(ctx, avail = {}) {
1152
1168
  },
1153
1169
  },
1154
1170
  ];
1171
+ // Art direction, the cast, the starter library, generated props, checks, the lineup and the rights (lib/art-tools.mjs).
1172
+ tools.push(...artToolDefs(ctx, { ok, fail, cli, stillRunning, whyOf, needsInstall, ui, str, STUDIO_ARG, RO, RW, pictureFor, findNode, startJob, waitJob }));
1155
1173
  const topics = guideTopics(ctx);
1156
1174
  if (topics.length) {
1157
1175
  tools.push({
@@ -1173,7 +1191,7 @@ export function toolDefs(ctx, avail = {}) {
1173
1191
  });
1174
1192
  }
1175
1193
  // The media skills' own scripts, where their provider (or the free tool they need) is set up on this computer.
1176
- for (const [name, title, needs] of [['music', 'Music (ElevenLabs)', 'elevenlabs'], ['sound', 'Sound effects and a theme (free)', 'ffmpeg'], ['art', 'Art: covers, frames, painted art', 'ffmpeg'], ['video', 'Video: trailers and clips', 'ffmpeg']]) {
1194
+ for (const [name, title, needs] of [['music', 'Music (ElevenLabs)', 'elevenlabs'], ['sound', 'Sound effects and a theme (free)', 'ffmpeg'], ['art', 'Art: covers, frames, painted art', 'ffmpeg'], ['video', 'Video: trailers and clips', 'ffmpeg'], ['models', 'Models: generated 3D props (paid, on your fal account)', 'node']]) {
1177
1195
  const script = ctx.skillsDir ? join(ctx.skillsDir, name, 'scripts', `${name}.mjs`) : null;
1178
1196
  if (!script || !existsSync(script) || !avail[needs]) continue;
1179
1197
  tools.push({
@@ -1201,7 +1219,7 @@ export function toolDefs(ctx, avail = {}) {
1201
1219
 
1202
1220
  /** What the media tools need, cheaply (no network): ffmpeg on the PATH, ElevenLabs' CLI or key. */
1203
1221
  export function availability(env = process.env) {
1204
- return { ffmpeg: Boolean(which(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')), elevenlabs: Boolean(env.ELEVENLABS_API_KEY || which(process.platform === 'win32' ? 'elevenlabs.exe' : 'elevenlabs')) };
1222
+ return { node: true, ffmpeg: Boolean(which(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')), elevenlabs: Boolean(env.ELEVENLABS_API_KEY || which(process.platform === 'win32' ? 'elevenlabs.exe' : 'elevenlabs')) };
1205
1223
  }
1206
1224
 
1207
1225
  /** The codex page small enough to hand a card (a tool result over ~150,000 characters is not shown). */
package/lib/mcp.mjs CHANGED
@@ -48,10 +48,12 @@ Links are the person's to open: give them in your reply (the cards have their ow
48
48
 
49
49
  To make one mechanic of a game feel better (a jump, a hit, a dash), game_lab opens the Game Lab: New beside Today, one take, its phases and sliders (studio_guide { "topic": "lab" }).
50
50
 
51
+ A game's look is a set of decisions (render style, palette, light, camera, fonts, the cast and its budgets), automatic from the person's words until they steer or lock one (studio_guide { "topic": "style" }). style_explore draws three directions with the game engine for free; decision_set picks, steers and locks; assets_find searches the free CC0 starter library and asset_add copies a model in with its licence; asset_make makes a prop on the person's own fal account only after they agreed to its price; asset_check, asset_lineup and asset_rights keep it one game, phone-sized and properly licensed (studio_guide { "topic": "models" }).
52
+
51
53
  Long work (npm install, check, playtest, deploy, renders) runs in the background: the tool answers at once with a card that follows it, and build_progress or studio_job reads where it is. studio_guide has Homie's full guide for each job (game, plan, port, playtest, publish, music, sound, art, video). Never put a key or password in a file or the chat. If Homie's homie.rocks connector is connected too, its tools of the same names say what to run; these run it.`;
52
54
 
53
- const CARD_FILES = { [UI.setup]: 'setup.js', [UI.build]: 'build.js', [UI.studio]: 'studio.js', [UI.codex]: 'codex.js', [UI.lab]: 'lab.js' };
54
- const CARD_TITLES = { [UI.setup]: 'Studio setup', [UI.build]: 'Build progress', [UI.studio]: 'Studio', [UI.codex]: 'Game Codex', [UI.lab]: 'Game Lab' };
55
+ const CARD_FILES = { [UI.setup]: 'setup.js', [UI.build]: 'build.js', [UI.studio]: 'studio.js', [UI.codex]: 'codex.js', [UI.lab]: 'lab.js', [UI.style]: 'style.js', [UI.decision]: 'decision.js', [UI.cast]: 'cast.js', [UI.lineup]: 'lineup.js', [UI.rights]: 'rights.js' };
56
+ const CARD_TITLES = { [UI.setup]: 'Studio setup', [UI.build]: 'Build progress', [UI.studio]: 'Studio', [UI.codex]: 'Game Codex', [UI.lab]: 'Game Lab', [UI.style]: 'Style board', [UI.decision]: 'Look decision', [UI.cast]: 'Cast', [UI.lineup]: 'Lineup', [UI.rights]: 'Rights' };
55
57
  const UI_DIR = join(PACKAGE_ROOT, 'mcp', 'ui');
56
58
 
57
59
  /** One card's whole document: the shared look and bridge, and the card's own script, as the files are. */
@@ -67,7 +69,8 @@ export function cardHtml(uri) {
67
69
 
68
70
  /** What a card may load: nothing from the network, except the codex page's Google Fonts. */
69
71
  export function cardCsp(uri) {
70
- if (uri === UI.codex) return { connectDomains: [], resourceDomains: ['https://fonts.googleapis.com', 'https://fonts.gstatic.com'] };
72
+ // The codex page, and the style board's type samples in each direction's fonts: Google Fonts only.
73
+ if (uri === UI.codex || uri === UI.style) return { connectDomains: [], resourceDomains: ['https://fonts.googleapis.com', 'https://fonts.gstatic.com'] };
71
74
  return { connectDomains: [], resourceDomains: [] };
72
75
  }
73
76
 
package/lib/office.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  * homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--days <n>]
11
11
  * homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]
12
12
  * homie-studio office kick <game> <room> <seat number | name> [--minutes 10]
13
+ * homie-studio office mute <game> <room> <seat number | name> [--minutes 10] [--off]
13
14
  * homie-studio office close <game> <room> [--minutes 10] [--reopen]
14
15
  * homie-studio office revoke every office key ends; every play ticket and pending ask too
15
16
  *
@@ -121,21 +122,42 @@ export async function officeLaunch(root, game, state, { url, remixable, max } =
121
122
  return askedFor(root, url, r, 'office launch');
122
123
  }
123
124
 
125
+ /** One player of one live room, by seat number (from 1) or name: { room, client } or { message }. */
126
+ async function playerIn(call, game, room, who) {
127
+ const office = await call('/_studio/api/office');
128
+ const g = (office.games ?? []).find((x) => x.id === game);
129
+ const rm = (g?.rooms ?? []).find((x) => x.room === room || x.label.toLowerCase() === String(room).toLowerCase());
130
+ if (!rm) return { message: `nobody is in ${room} of ${game} now (homie-studio office lists the rooms)` };
131
+ const seat = /^\d+$/.test(String(who)) ? Number(who) - 1 : null;
132
+ const c = rm.clients.find((x) => (seat !== null && x.seat === seat) || String(x.name).toLowerCase() === String(who).toLowerCase());
133
+ if (!c) return { message: `no "${who}" in ${rm.label} (seats are numbered from 1)` };
134
+ return { room: rm, client: c };
135
+ }
136
+
124
137
  export async function officeKick(root, game, room, who, { url, minutes } = {}) {
125
138
  if (!GAME.test(String(game ?? '')) || !room || who === undefined) return { ok: false, command: 'office kick', why: 'usage: homie-studio office kick <game> <room> <seat number | name> [--minutes 10]' };
126
139
  const r = await withKey(root, url, async (call) => {
127
- const office = await call('/_studio/api/office');
128
- const g = (office.games ?? []).find((x) => x.id === game);
129
- const rm = (g?.rooms ?? []).find((x) => x.room === room || x.label.toLowerCase() === String(room).toLowerCase());
130
- if (!rm) return { ok: false, message: `nobody is in ${room} of ${game} now (homie-studio office lists the rooms)` };
131
- const seat = /^\d+$/.test(String(who)) ? Number(who) - 1 : null;
132
- const c = rm.clients.find((x) => (seat !== null && x.seat === seat) || String(x.name).toLowerCase() === String(who).toLowerCase());
133
- if (!c) return { ok: false, message: `no "${who}" in ${rm.label} (seats are numbered from 1)` };
134
- return call('/_studio/api/kick', { game, room: rm.room, id: c.id, minutes: Number(minutes) || 10 });
140
+ const p = await playerIn(call, game, room, who);
141
+ if (!p.client) return { ok: false, message: p.message };
142
+ return call('/_studio/api/kick', { game, room: p.room.room, id: p.client.id, minutes: Number(minutes) || 10 });
135
143
  });
136
144
  return askedFor(root, url, r, 'office kick');
137
145
  }
138
146
 
147
+ /**
148
+ * `office mute <game> <room> <seat | name> [--minutes 10] [--off]`: the player's chat and emotes reach nobody for a
149
+ * while (`--off` lifts it). Like a kick, it is only ASKED for: the owner confirms with one tap.
150
+ */
151
+ export async function officeMute(root, game, room, who, { url, minutes, off = false } = {}) {
152
+ if (!GAME.test(String(game ?? '')) || !room || who === undefined) return { ok: false, command: 'office mute', why: 'usage: homie-studio office mute <game> <room> <seat number | name> [--minutes 10] [--off]' };
153
+ const r = await withKey(root, url, async (call) => {
154
+ const p = await playerIn(call, game, room, who);
155
+ if (!p.client) return { ok: false, message: p.message };
156
+ return call('/_studio/api/mute', { game, room: p.room.room, id: p.client.id, minutes: Number(minutes) || 10, ...(off ? { off: true } : {}) });
157
+ });
158
+ return askedFor(root, url, r, 'office mute');
159
+ }
160
+
139
161
  export async function officeClose(root, game, room, { url, minutes, reopen } = {}) {
140
162
  if (!GAME.test(String(game ?? '')) || !room) return { ok: false, command: 'office close', why: 'usage: homie-studio office close <game> <room> [--minutes 10] [--reopen]' };
141
163
  const r = await withKey(root, url, (call) => call('/_studio/api/close', { game, room, minutes: Number(minutes) || 10, reopen: Boolean(reopen) }));