@homie-rocks/studio 0.35.1 → 0.37.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 (89) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +4 -2
  3. package/agents/agents.ts +8 -6
  4. package/app-starters/welcome/app.json +106 -0
  5. package/app-starters/welcome/index.html +4 -0
  6. package/app-starters/welcome/src/main.ts +107 -0
  7. package/apps/APPS.md +61 -0
  8. package/apps/apps.ts +28 -0
  9. package/bin/homie-studio.mjs +22 -10
  10. package/lab/lab.ts +15 -0
  11. package/lib/art-cli.mjs +2 -2
  12. package/lib/art-tools.mjs +6 -6
  13. package/lib/asset-check.mjs +3 -2
  14. package/lib/asset-import.mjs +3 -2
  15. package/lib/asset-manifest.mjs +8 -8
  16. package/lib/build.mjs +26 -18
  17. package/lib/characters.mjs +7 -7
  18. package/lib/check.mjs +49 -0
  19. package/lib/cloudflare.mjs +1 -1
  20. package/lib/codex.mjs +2 -2
  21. package/lib/collision.mjs +1 -1
  22. package/lib/decisions.mjs +7 -7
  23. package/lib/dev.mjs +42 -10
  24. package/lib/directory.mjs +1 -1
  25. package/lib/doctor.mjs +1 -1
  26. package/lib/handoff.mjs +1 -1
  27. package/lib/lab-check.mjs +18 -3
  28. package/lib/lab.mjs +29 -15
  29. package/lib/library.mjs +2 -2
  30. package/lib/mcp-tools.mjs +23 -3
  31. package/lib/parts-build.mjs +4 -3
  32. package/lib/parts-cli.mjs +3 -3
  33. package/lib/parts-store.mjs +8 -7
  34. package/lib/parts-tools.mjs +7 -7
  35. package/lib/parts.mjs +19 -14
  36. package/lib/perf.mjs +2 -1
  37. package/lib/player-image.mjs +2 -2
  38. package/lib/rules-build.mjs +97 -49
  39. package/lib/rules-check-worker.mjs +405 -0
  40. package/lib/rules-check.mjs +69 -0
  41. package/lib/rules-compiler.mjs +30 -0
  42. package/lib/rules-guard.mjs +26 -7
  43. package/lib/rules-types.mjs +167 -0
  44. package/lib/scaffold.mjs +21 -2
  45. package/lib/shop.mjs +1 -1
  46. package/lib/standalone-files.mjs +2 -2
  47. package/lib/standalone.mjs +12 -3
  48. package/lib/stats.mjs +2 -1
  49. package/lib/studio.mjs +32 -6
  50. package/lib/style-board.mjs +10 -9
  51. package/lib/template-history.json +85 -1
  52. package/lib/typecheck.mjs +52 -3
  53. package/links/links.d.ts +3 -0
  54. package/links/links.mjs +16 -0
  55. package/netplay/NETPLAY.md +123 -6
  56. package/netplay/netplay.ts +289 -53
  57. package/netplay/rules-output.mjs +85 -0
  58. package/package.json +16 -4
  59. package/parts/PARTS.md +10 -5
  60. package/rules/browser.ts +100 -0
  61. package/rules/core.ts +159 -58
  62. package/rules/guard.ts +11 -4
  63. package/rules/host.ts +90 -15
  64. package/rules/math.ts +16 -0
  65. package/rules/pack.ts +94 -17
  66. package/rules/rules.ts +21 -13
  67. package/rules/types.ts +63 -0
  68. package/rules/view.ts +36 -33
  69. package/shop/SHOP.md +13 -0
  70. package/site/SITE.md +1 -1
  71. package/standalone/shell/shell.js +8 -6
  72. package/worker/account-page.mjs +4 -3
  73. package/worker/app-format.mjs +42 -0
  74. package/worker/app-records.mjs +100 -0
  75. package/worker/app-shell.mjs +29 -0
  76. package/worker/brain.mjs +2 -1
  77. package/worker/embed.mjs +4 -2
  78. package/worker/hosted.mjs +1 -1
  79. package/worker/index.mjs +40 -20
  80. package/worker/limits.mjs +41 -0
  81. package/worker/pages.mjs +37 -29
  82. package/worker/parts.mjs +11 -9
  83. package/worker/room.mjs +136 -63
  84. package/worker/schema.mjs +6 -5
  85. package/worker/seats.mjs +2 -2
  86. package/worker/shop-page.mjs +4 -3
  87. package/worker/site.mjs +49 -40
  88. package/worker/standalone.mjs +2 -2
  89. package/worker/version.mjs +1 -1
package/lib/dev.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { networkInterfaces } from 'node:os';
1
2
  /**
2
3
  * `homie-studio dev` and `dev --stop`: the whole site on this computer (pages, rooms in a local Durable Object, D1),
3
4
  * and exactly this studio's dev server stopped, never another project's.
@@ -32,7 +33,7 @@ import { detectLocalAi, localAiVars } from './local-ai.mjs';
32
33
  import { projectsCloudflareEnv } from './projects-env.mjs';
33
34
  import { routesOf } from './routes.mjs';
34
35
  import { ensureMigrations, migrationWord } from './scaffold.mjs';
35
- import { isRulesGame, listGames, workerDir } from './studio.mjs';
36
+ import { isRulesGame, listExperiences as listGames, workerDir } from './studio.mjs';
36
37
 
37
38
  export const devFile = (root) => join(workerDir(root), '.wrangler', 'homie-dev.json');
38
39
 
@@ -289,7 +290,7 @@ function passLines(stream, to, opts) {
289
290
  * saved. `rulesStamp` is what it compares: the newest change among them, and how many there are.
290
291
  */
291
292
  export function rulesStamp(g) {
292
- const files = [join(g.dir, 'game.json'), join(g.dir, 'tunables.json')];
293
+ const files = [join(g.dir, g.kind === 'app' ? 'app.json' : 'game.json'), join(g.dir, 'tunables.json')];
293
294
  for (const sub of ['src', 'map']) {
294
295
  const dir = join(g.dir, sub);
295
296
  if (existsSync(dir)) for (const name of readdirSync(dir, { recursive: true })) files.push(join(dir, String(name)));
@@ -306,7 +307,7 @@ const builtIds = (root) => { try { return JSON.parse(readFileSync(join(root, 'si
306
307
  * `homie-studio dev`: build once, then Wrangler's local runtime until it is stopped. Returns the command's result.
307
308
  * options: port, remoteAi, localAi (false: --no-local-ai), timestamps (every line of Wrangler's gets a time), log.
308
309
  */
309
- export async function dev(root, { port: askedPort = 8787, remoteAi = false, localAi = true, timestamps = false, log = () => {}, watchMs = 2000 } = {}) {
310
+ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi = false, localAi = true, timestamps = false, log = () => {}, watchMs = 2000 } = {}) {
310
311
  const port = String(askedPort ?? 8787);
311
312
  const stop = 'npx --no-install homie-studio dev --stop';
312
313
 
@@ -344,6 +345,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
344
345
  });
345
346
  const origin = `http://127.0.0.1:${port}`;
346
347
  log(`Local site: ${origin}/ (each game: ${origin}/<id>/play — open it in two browsers)`);
348
+ if (lan) for (const address of lanAddresses()) log(`Same Wi-Fi: http://${address}:${port}/ (open this address on phones and wall screens)`);
347
349
  const ai = devConfig(root, remoteAi);
348
350
  if (ai.note) log(ai.note);
349
351
  // The person's own Clef (0.24.4, lib/local-ai.mjs): with Ollama and clef-flash on this computer, the local Worker's
@@ -356,7 +358,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
356
358
  }
357
359
  // --remote-ai: Wrangler's --local turns every remote binding off ("not supported"), so a dev with the real Workers AI
358
360
  // (the guides' brains, room chat's review) runs without it; everything else stays local all the same.
359
- const args = ['dev', ...(remoteAi ? [] : ['--local']), '--ip', '127.0.0.1', '--port', port, '--var', 'HOMIE_EMBED_PREVIEW:1', ...ai.args, ...localVars];
361
+ const args = ['dev', ...(remoteAi ? [] : ['--local']), '--ip', lan ? '0.0.0.0' : '127.0.0.1', '--port', port, '--var', 'HOMIE_EMBED_PREVIEW:1', ...ai.args, ...localVars];
360
362
  log(`Stop it with: ${stop} (this studio's dev server only)`);
361
363
 
362
364
  let child = null;
@@ -390,7 +392,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
390
392
  for (const g of listGames(root)) {
391
393
  if (built.includes(g.id) || told.has(g.id)) continue;
392
394
  told.add(g.id);
393
- log(`games/${g.id} is new and not built yet, so ${origin}/${g.id}/play is a 404 for now. Build it (npx --no-install homie-studio build): this dev server serves what build wrote and picks the new game up by itself.`);
395
+ log(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id} is new and not built yet, so ${origin}/${g.id}/play is a 404 for now. Build it (npx --no-install homie-studio build): this dev server serves what build wrote and picks the new game up by itself.`);
394
396
  }
395
397
  for (const id of built) {
396
398
  if (startedWith.has(id)) continue;
@@ -412,22 +414,47 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
412
414
  };
413
415
  // A rules game saved while dev runs is built again (see rulesStamp), one game at a time.
414
416
  const stamps = new Map(listGames(root).filter(isRulesGame).map((g) => [g.id, rulesStamp(g)]));
417
+ const timingRetries = new Map();
415
418
  let rebuilding = false;
419
+ let published = Promise.resolve();
416
420
  const rulesLook = async () => {
417
421
  if (rebuilding || ending) return;
418
422
  for (const g of listGames(root).filter(isRulesGame)) {
419
423
  const now = rulesStamp(g);
420
- if (stamps.get(g.id) === now) continue;
424
+ const retry = timingRetries.get(g.id);
425
+ if (stamps.get(g.id) === now && !(retry?.stamp === now && Date.now() >= retry.at)) continue;
426
+ if (retry?.stamp !== now) timingRetries.delete(g.id);
421
427
  stamps.set(g.id, now);
422
428
  rebuilding = true;
423
429
  try {
424
- log(`games/${g.id} changed: checking its rules and building it again…`);
430
+ log(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id} changed: checking its rules and building it again…`);
425
431
  const hash = () => { try { return JSON.parse(readFileSync(join(root, 'site/dist/games.json'), 'utf8')).games.find((x) => x.id === g.id)?.room?.stateHash; } catch { return null; } };
426
432
  const before = hash();
427
- await build(root, { only: g.id, log });
428
- log(`games/${g.id} is rebuilt. ${before && before === hash() ? 'Local rooms resume their saved match; pages reconnect.' : 'The stored shape changed: local rooms reset to a fresh match; pages reconnect.'}`);
433
+ let release;
434
+ published = new Promise(resolve => { release = resolve; });
435
+ try {
436
+ await build(root, { only: g.id, log, beforePublish: async () => {
437
+ // Wrangler independently watches assets and code. Publishing both while it runs
438
+ // reloads the old bundle first, then the new one. Stop once at the publish boundary;
439
+ // the loop below starts once with the complete build and the same local storage.
440
+ if (!child || child.exitCode !== null) return;
441
+ restart = g.id;
442
+ const stopped = new Promise(resolve => child.once('close', resolve));
443
+ child.kill('SIGTERM');
444
+ await stopped;
445
+ } });
446
+ } finally { release(); }
447
+ timingRetries.delete(g.id);
448
+ log(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id} is rebuilt. ${before && before === hash() ? 'Local rooms resume their saved match; pages reconnect.' : 'The stored shape changed: local rooms reset to a fresh match; pages reconnect.'}`);
429
449
  } catch (error) {
430
- log(`games/${g.id} did not build, so the local site still runs what it had:\n${String(error?.message ?? error)}`);
450
+ log(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id} did not build, so the local site still runs what it had:\n${String(error?.message ?? error)}`);
451
+ // A busy machine can fail the wall-clock tick guard once. Keep the guard, resample at most three times,
452
+ // and never retry syntax, contract or deterministic budget failures until the author edits them.
453
+ const attempts = timingRetries.get(g.id)?.attempts ?? 0;
454
+ if (/rules ran for .*seconds with bots and were too slow/.test(String(error?.message ?? error)) && attempts < 3) {
455
+ timingRetries.set(g.id, { stamp: now, attempts: attempts + 1, at: Date.now() + 10000 });
456
+ log(`${g.id}: retrying the timing check in 10 seconds (${attempts + 1}/3); the last good build stays live.`);
457
+ } else timingRetries.delete(g.id);
431
458
  } finally { rebuilding = false; }
432
459
  return;
433
460
  }
@@ -461,6 +488,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
461
488
  writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
462
489
  settle().catch(() => {});
463
490
  await new Promise((done) => { child.on('close', done); child.on('error', done); });
491
+ await published;
464
492
  if (!restart || ending) break;
465
493
  restart = null;
466
494
  }
@@ -469,3 +497,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
469
497
  if (failed) return { ok: false, command: 'dev', needs: 'local-rooms', socket: failed.socket, expected: failed.expected, why: failed.why };
470
498
  return { ok: true, command: 'dev', stopped: true };
471
499
  }
500
+
501
+ export function lanAddresses(interfaces = networkInterfaces()) {
502
+ return [...new Set(Object.values(interfaces).flat().filter((n) => n && !n.internal && n.family === 'IPv4' && !n.address.startsWith('169.254.')).map((n) => n.address))].sort();
503
+ }
package/lib/directory.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * nobody can list games under someone else's site.
6
6
  */
7
7
  import { request } from './net.mjs';
8
- import { listGames, readLocal, readStudio, siteUrl, writeLocal } from './studio.mjs';
8
+ import { listExperiences as listGames, readLocal, readStudio, siteUrl, writeLocal } from './studio.mjs';
9
9
  import { licenceProblems, readManifest } from './asset-manifest.mjs';
10
10
  import { partsPlanLines, partsPublishReport } from './parts-build.mjs';
11
11
 
package/lib/doctor.mjs CHANGED
@@ -50,7 +50,7 @@ import { detectLocalAi } from './local-ai.mjs';
50
50
  import { findChrome } from './chrome.mjs';
51
51
  import { whyFailed } from './net.mjs';
52
52
  import { isOurs } from './statusline.mjs';
53
- import { findStudio, listGames, readLocal, readStudio } from './studio.mjs';
53
+ import { findStudio, listExperiences as listGames, readLocal, readStudio } from './studio.mjs';
54
54
  import { standaloneRows } from './standalone.mjs';
55
55
  import { projectsCloudflareEnv, projectsElevenLabs } from './projects-env.mjs';
56
56
  import { STUDIO_VERSION } from './version.mjs';
package/lib/handoff.mjs CHANGED
@@ -26,7 +26,7 @@ function originRepo(root) {
26
26
  return m ? `${m[1]}/${m[2]}` : null;
27
27
  }
28
28
  import { setupAttach } from './setup.mjs';
29
- import { listGames, readStudio } from './studio.mjs';
29
+ import { listExperiences as listGames, readStudio } from './studio.mjs';
30
30
  import { STUDIO_VERSION } from './version.mjs';
31
31
 
32
32
  const BUILD = /^hb_[a-f0-9]{32}$/;
package/lib/lab-check.mjs CHANGED
@@ -25,7 +25,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
25
25
  import { join, relative } from 'node:path';
26
26
  import { chromeArgs, findChrome, noChrome, SOFTWARE_GL } from './chrome.mjs';
27
27
  import { labDir, startLabServer } from './lab.mjs';
28
- import { listGames } from './studio.mjs';
28
+ import { isRulesGame, listExperiences as listGames } from './studio.mjs';
29
29
 
30
30
  const round = (v, d = 2) => (Number.isFinite(v) ? Math.round(v * 10 ** d) / 10 ** d : null);
31
31
  const pct = (xs, p) => { const s = xs.filter(Number.isFinite).sort((a, b) => a - b); return s.length ? s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))] : null; };
@@ -58,6 +58,11 @@ export function summarise(pane, { fps, units }) {
58
58
  };
59
59
  }
60
60
 
61
+ /** A rules take must actually advance its local runtime without joining a room. */
62
+ export function rulesRun(s) {
63
+ return Boolean(s?.hosting && !s.connected && s.tick > 0 && s.status === 'playing');
64
+ }
65
+
61
66
  /** Where two runs of one build first differ (frame number), or null when every frame matches. */
62
67
  export function firstDifference(a, b) {
63
68
  const n = Math.max(a?.length ?? 0, b?.length ?? 0);
@@ -137,8 +142,18 @@ export async function labCheck(root, id, { take = null, today = 'HEAD', device =
137
142
  new: first.new.available ? firstDifference(first.new.frames, second.new.frames) : null,
138
143
  today: first.today.available ? firstDifference(first.today.frames, second.today.frames) : null,
139
144
  };
145
+ const rules = isRulesGame(games.find((g) => g.id === game)) ? await page.evaluate(() => {
146
+ const out = {};
147
+ for (const pane of ['new', 'today']) {
148
+ const frame = [...document.querySelectorAll('iframe')].find((f) => f.src.includes('/' + pane + '/'));
149
+ const n = frame?.contentWindow?.__homieNet;
150
+ out[pane] = n ? { format: typeof n.probe?.hosted === 'function', hosting: n.rulesHosting, connected: n.connected, tick: n.probe?.tick?.(), status: n.probe?.status?.() } : null;
151
+ }
152
+ return out;
153
+ }) : null;
154
+ if (rules && (!rulesRun(rules.new) || (first.today.available && rules.today?.format && !rulesRun(rules.today)))) pageErrors.push('A rules pane did not play on its local host runtime.');
140
155
  const summary = {
141
- ok: true, game, take: first.take, note: first.note, fps: first.fps, frames: first.frames, device: first.device, seed: first.seed, inputs: first.inputs,
156
+ ok: !rules || pageErrors.length === 0, rules, game, take: first.take, note: first.note, fps: first.fps, frames: first.frames, device: first.device, seed: first.seed, inputs: first.inputs,
142
157
  newBuild: first.newBuild, todayBuild: first.todayBuild, overrides: first.overrides, renderer, software: SOFTWARE_GL.test(String(renderer ?? '')),
143
158
  deterministic, new: summarise(first.new, first), today: summarise(first.today, first), pageErrors,
144
159
  };
@@ -182,7 +197,7 @@ export async function labCheck(root, id, { take = null, today = 'HEAD', device =
182
197
  };
183
198
  if (!out) writeFileSync(join(labDir(root, game), 'latest.json'), `${JSON.stringify(latest, null, 2)}\n`);
184
199
  return {
185
- ok: true, command: 'lab check', game, take: summary.take, out: rel(dir), report: rel(join(dir, 'REPORT.md')), sheet: rel(join(dir, 'sheet.png')), still: rel(stillPath),
200
+ ok: summary.ok, rules: summary.rules, command: 'lab check', game, take: summary.take, out: rel(dir), report: rel(join(dir, 'REPORT.md')), sheet: rel(join(dir, 'sheet.png')), still: rel(stillPath),
186
201
  frames: summary.frames, fps: summary.fps, deterministic, renderer, software: summary.software,
187
202
  phases: { new: summary.new.phases?.map((p) => `${p.name} ${p.frames}f`) ?? [], today: summary.today.available ? summary.today.phases.map((p) => `${p.name} ${p.frames}f`) : null },
188
203
  timeline: { new: (summary.new.phases ?? []).map(({ name, from, to }) => ({ name, from, to })), today: summary.today.available ? summary.today.phases.map(({ name, from, to }) => ({ name, from, to })) : null },
package/lib/lab.mjs CHANGED
@@ -28,7 +28,7 @@ import { createServer } from 'node:http';
28
28
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, watch, writeFileSync } from 'node:fs';
29
29
  import { extname, join, normalize, relative, resolve, sep } from 'node:path';
30
30
  import { buildGameFiles, studioEsbuild } from './build.mjs';
31
- import { GAME_ID, PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
31
+ import { experienceDir, experienceFile, GAME_ID, PACKAGE_ROOT, listExperiences as listGames, readStudio } from './studio.mjs';
32
32
  import { STUDIO_VERSION } from './version.mjs';
33
33
  import { formatLabFile, formatTunables, readLabFile, checkTake, setTunables, tunableValue, TAKE_NAME } from '../lab/take.js';
34
34
 
@@ -67,6 +67,20 @@ export function readTunables(dir) {
67
67
  return json;
68
68
  }
69
69
 
70
+ /** Rules keep public movement knobs grouped on disk; sliders name them public.speed, public.jump, and so on. */
71
+ export function flatTunables(spec) {
72
+ if (!spec) return null;
73
+ const { public: pub, ...rest } = spec;
74
+ return { ...rest, ...Object.fromEntries(Object.entries(pub ?? {}).map(([k, v]) => [`public.${k}`, v])) };
75
+ }
76
+ export function setLabTunables(spec, values) {
77
+ const r = setTunables(flatTunables(spec), values);
78
+ if (!spec?.public) return r;
79
+ const out = { ...r.spec, public: { ...spec.public } };
80
+ for (const [k, v] of Object.entries(r.spec)) if (k.startsWith('public.')) { out.public[k.slice(7)] = v; delete out[k]; }
81
+ return { ...r, spec: out };
82
+ }
83
+
70
84
  /** `homie-studio lab set <game> name=value ...`: values written into tunables.json, one tunable a line. */
71
85
  export function labSet(root, id, pairs) {
72
86
  const g = gameOf(root, id);
@@ -74,12 +88,12 @@ export function labSet(root, id, pairs) {
74
88
  if (!spec) return { ok: false, command: 'lab set', why: `games/${g.id} has no tunables.json yet: the lab skill says how a game gets one (lab.tunables)` };
75
89
  const values = {};
76
90
  for (const p of pairs) {
77
- const m = /^([A-Za-z_$][\w$]{0,47})=(.+)$/.exec(String(p));
91
+ const m = /^((?:public\.)?[A-Za-z_$][\w$]{0,47})=(.+)$/.exec(String(p));
78
92
  if (!m) return { ok: false, command: 'lab set', why: `"${p}" is not name=value` };
79
93
  values[m[1]] = m[2];
80
94
  }
81
95
  if (!Object.keys(values).length) return { ok: false, command: 'lab set', why: 'usage: homie-studio lab set <game> <name>=<value> ...' };
82
- const r = setTunables(spec, values);
96
+ const r = setLabTunables(spec, values);
83
97
  if (r.refused.length) return { ok: false, command: 'lab set', why: r.refused.join('; ') };
84
98
  writeFileSync(join(g.dir, 'tunables.json'), formatTunables(r.spec));
85
99
  return { ok: true, command: 'lab set', game: g.id, file: `games/${g.id}/tunables.json`, changed: r.changed };
@@ -109,16 +123,16 @@ export function commitOf(root, ref = 'HEAD') {
109
123
 
110
124
  /** Whether games/<id>/ differs from HEAD (staged, changed or new files). */
111
125
  export function dirtyOf(root, id) {
112
- const r = git(root, ['status', '--porcelain', '--', `games/${id}`]);
126
+ const r = git(root, ['status', '--porcelain', '--', relative(root, experienceDir(root, id))]);
113
127
  return r.status === 0 ? r.stdout.trim().split('\n').filter(Boolean).length : null;
114
128
  }
115
129
 
116
130
  /** The paths of `sha` that New's bundle read from outside games/<id>/ (a shared folder), plus the game's own. */
117
131
  function pathsFor(root, id, sha, metafile) {
118
- const want = new Set([`games/${id}`]);
132
+ const want = new Set([relative(root, experienceDir(root, id))]);
119
133
  for (const input of Object.keys(metafile?.inputs ?? {})) {
120
134
  const rel = input.split(sep).join('/');
121
- if (rel.startsWith('node_modules/') || rel.startsWith('../') || rel.startsWith('/') || rel.startsWith(`games/${id}/`) || rel.includes('/node_modules/')) continue;
135
+ if (rel.startsWith('node_modules/') || rel.startsWith('../') || rel.startsWith('/') || rel.startsWith(relative(root, experienceDir(root, id)) + '/') || rel.includes('/node_modules/')) continue;
122
136
  want.add(rel);
123
137
  }
124
138
  for (const f of ['package.json', 'studio.json']) want.add(f);
@@ -132,7 +146,7 @@ function checkout(root, id, sha, paths) {
132
146
  const dir = join(labDir(root, id), 'checkout');
133
147
  const stamp = join(labDir(root, id), 'checkout.json');
134
148
  const had = readJson(stamp);
135
- if (had?.sha === sha && JSON.stringify(had.paths) === JSON.stringify(paths) && existsSync(join(dir, 'games', id))) return dir;
149
+ if (had?.sha === sha && JSON.stringify(had.paths) === JSON.stringify(paths) && existsSync(experienceDir(dir, id))) return dir;
136
150
  removeCheckout(root, dir);
137
151
  mkdirSync(labDir(root, id), { recursive: true });
138
152
  const add = git(root, ['worktree', 'add', '--no-checkout', '--detach', dir, sha]);
@@ -140,8 +154,8 @@ function checkout(root, id, sha, paths) {
140
154
  const co = git(dir, ['checkout', sha, '--', ...paths]);
141
155
  if (co.status !== 0) throw new Error(`git could not check out games/${id} at ${sha.slice(0, 7)}: ${(co.stderr || co.stdout).trim().split('\n').pop()}`);
142
156
  // A game with its own build (game.json build.mode "command") builds with its own node_modules: the working tree's.
143
- const nm = join(root, 'games', id, 'node_modules');
144
- if (existsSync(nm) && existsSync(join(dir, 'games', id)) && !existsSync(join(dir, 'games', id, 'node_modules'))) { try { symlinkSync(nm, join(dir, 'games', id, 'node_modules'), 'dir'); } catch { /* builds without */ } }
157
+ const nm = join(experienceDir(root, id), 'node_modules');
158
+ if (existsSync(nm) && existsSync(experienceDir(dir, id)) && !existsSync(join(experienceDir(dir, id), 'node_modules'))) { try { symlinkSync(nm, join(experienceDir(dir, id), 'node_modules'), 'dir'); } catch { /* builds without */ } }
145
159
  writeFileSync(stamp, `${JSON.stringify({ sha, paths, at: new Date().toISOString() })}\n`);
146
160
  return dir;
147
161
  }
@@ -179,7 +193,7 @@ class Builds {
179
193
  const t0 = Date.now();
180
194
  this.new = { ...this.new, building: true };
181
195
  try {
182
- const r = await buildGameFiles(this.esbuild, this.root, g, out, { sourcemap: 'linked', log: this.log });
196
+ const r = await buildGameFiles(this.esbuild, this.root, g, out, { sourcemap: 'linked', lab: true, log: this.log });
183
197
  this.new = { ok: true, building: false, error: null, dir: out, at: new Date().toISOString(), ms: Date.now() - t0, bytes: sizeOf(join(out, 'assets', 'main.js')), metafile: r.metafile, mode: r.mode, dirty: dirtyOf(this.root, this.id) };
184
198
  } catch (error) {
185
199
  this.new = { ok: false, building: false, error: error.message, dir: out, at: new Date().toISOString() };
@@ -209,7 +223,7 @@ class Builds {
209
223
  const g = listGames(dir).find((x) => x.id === this.id);
210
224
  if (!g) throw new Error(`games/${this.id}/game.json is not in ${c.short}`);
211
225
  this.esbuild ??= await studioEsbuild(this.root);
212
- await buildGameFiles(this.esbuild, dir, g, out, { sourcemap: 'linked', log: this.log });
226
+ await buildGameFiles(this.esbuild, dir, g, out, { sourcemap: 'linked', lab: true, log: this.log });
213
227
  let tunables = null;
214
228
  try { tunables = readTunables(g.dir); } catch { tunables = null; }
215
229
  const at = new Date().toISOString();
@@ -229,7 +243,7 @@ class Builds {
229
243
  // A commit changes Today: look every 2 s (one cheap `git rev-parse`).
230
244
  this.refTimer = setInterval(() => { void this.followRef(); }, 2000);
231
245
  this.refTimer.unref?.();
232
- const dir = join(this.root, 'games', this.id);
246
+ const dir = experienceDir(this.root, this.id);
233
247
  try {
234
248
  this.watcher = watch(dir, { recursive: true }, (_e, file) => {
235
249
  const f = String(file ?? '');
@@ -350,7 +364,7 @@ export async function startLabServer(root, { port = LAB_PORT, today = 'HEAD', lo
350
364
  if (what === 'tunables') {
351
365
  const spec = readTunables(dir);
352
366
  if (!spec) return send(res, 400, { ok: false, why: `games/${id} has no tunables.json` });
353
- const r = setTunables(spec, json.values ?? {});
367
+ const r = setLabTunables(spec, json.values ?? {});
354
368
  if (r.refused.length) return send(res, 400, { ok: false, why: r.refused.join('; ') });
355
369
  writeFileSync(join(dir, 'tunables.json'), formatTunables(r.spec));
356
370
  log(`kept in games/${id}/tunables.json: ${r.changed.map((c) => `${c.name} ${c.from} -> ${c.to}`).join(', ') || 'nothing changed'}`);
@@ -422,7 +436,7 @@ function stateOf(root, b) {
422
436
  let tunablesError = null;
423
437
  try { tunables = readTunables(g.dir); } catch (error) { tunablesError = error.message; }
424
438
  const pane = (p) => ({ ok: p.ok, building: p.building, error: p.error ?? null, none: p.none ?? null, at: p.at ?? null, ms: p.ms ?? null, bytes: p.bytes ?? null });
425
- const todayValues = b.today.tunables ? Object.fromEntries(Object.entries(b.today.tunables).map(([k, s]) => [k, tunableValue(s)])) : null;
439
+ const todayValues = b.today.tunables ? Object.fromEntries(Object.entries(flatTunables(b.today.tunables)).map(([k, s]) => [k, tunableValue(s)])) : null;
426
440
  return {
427
441
  ok: true,
428
442
  version: STUDIO_VERSION,
@@ -431,7 +445,7 @@ function stateOf(root, b) {
431
445
  new: { ...pane(b.new), dirty: b.new.dirty ?? null, mode: b.new.mode ?? null },
432
446
  today: { ...pane(b.today), ref: b.todayRef, commit: b.today.commit ?? null },
433
447
  takes: { default: takes.default, takes: takes.takes, problems: takes.problems, file: `games/${g.id}/lab.json`, exists: takes.exists },
434
- tunables: { file: `games/${g.id}/tunables.json`, spec: tunables, today: todayValues, error: tunablesError },
448
+ tunables: { file: `games/${g.id}/tunables.json`, spec: flatTunables(tunables), today: todayValues, error: tunablesError },
435
449
  };
436
450
  }
437
451
 
package/lib/library.mjs CHANGED
@@ -18,7 +18,7 @@ import { homedir } from 'node:os';
18
18
  import { dirname, join, resolve } from 'node:path';
19
19
  import { LIMITS, checkGlb } from '../assets/safety.mjs';
20
20
  import { recordAsset } from './asset-manifest.mjs';
21
- import { GAME_ID } from './studio.mjs';
21
+ import { experienceDir, experienceFile, GAME_ID } from './studio.mjs';
22
22
 
23
23
  export const LIBRARY_URL = 'https://homie.rocks/api/library/v0';
24
24
  const CACHE = join(homedir(), '.cache', 'homie-studio', 'library');
@@ -117,7 +117,7 @@ const KIND_DIR = { texture: 'tex', sky: 'sky' };
117
117
  */
118
118
  export async function addFromLibrary(root, game, itemId, { as = null, height = null, card = null, lib = libraryBase(), index = null } = {}) {
119
119
  if (!GAME_ID.test(String(game ?? ''))) throw new Error(`"${game}" is not a game id`);
120
- const gdir = join(root, 'games', game);
120
+ const gdir = join(experienceDir(root, game));
121
121
  if (!existsSync(gdir)) throw new Error(`no game "${game}"`);
122
122
  const idx = index ?? (await loadIndex({ lib })).index;
123
123
  const item = idx.items.find((x) => x.id === itemId) ?? idx.items.find((x) => x.item === itemId);
package/lib/mcp-tools.mjs CHANGED
@@ -28,7 +28,7 @@ import { cliJob, findNode, getJob, installJob, installed, jobView, runningJobs,
28
28
  import { Feed, currentId, publicFeed, readFeed, startProgress } from './progress.mjs';
29
29
  import { newStudio, slugify } from './scaffold.mjs';
30
30
  import { repoFromUrl, studioRepo } from './repo.mjs';
31
- import { GAME_ID, PACKAGE_ROOT, findStudio, listGames, readLocal, readStudio, siteUrl } from './studio.mjs';
31
+ import { GAME_ID, PACKAGE_ROOT, findStudio, isRulesGame, listExperiences as listGames, readLocal, readStudio, siteUrl } from './studio.mjs';
32
32
  import { runningDev } from './dev.mjs';
33
33
  import { deployWords } from './deploy-state.mjs';
34
34
  import { partsForListing, publishBefore, publishesSoFar } from './directory.mjs';
@@ -863,6 +863,25 @@ export function toolDefs(ctx, avail = {}) {
863
863
  return ok(`${formatDemo(r)}\n\nFor you, the AI: give the person this Play link in your reply; they open it themselves. Do not open a browser or run any command to open it.`, { kind: 'demo', ...r });
864
864
  },
865
865
  },
866
+ {
867
+ name: 'app_make', title: 'Make an app',
868
+ description: 'Make one morphing screen with customer, staff and wall roles, lasting records and a shared 3D scene. Uses the same engines, netplay, parts and standalone builds as games. No rounds required.',
869
+ inputSchema: { type: 'object', properties: { id: str('App id: lowercase, digits, hyphens'), name: str('Display name'), ...STUDIO_ARG }, required: ['id', 'name'] },
870
+ annotations: { title: 'Make an app', ...RW },
871
+ run: async (a) => {
872
+ if (!GAME_ID.test(String(a.id ?? ''))) return fail('invalid app id');
873
+ const root = ctx.root(a.studio);
874
+ const r = await cli(ctx, root, `app new ${a.id}`, ['app', 'new', a.id, '--name', String(a.name ?? a.id).slice(0, 60)]);
875
+ if (!r.ended) return stillRunning(r.job, 'Making the app');
876
+ if (r.job.code !== 0) return fail(`Not made: ${whyOf(r.job)}`);
877
+ let install = '';
878
+ if (r.result?.installNeeded) {
879
+ if (ctx.install) { const job = runningJobs(root).find((j) => j.label.startsWith('npm install')) ?? installJob(root); install = ` Dependencies are installing (job ${job.id}); wait for studio_job.`; }
880
+ else install = ' Run studio_install for its engine dependencies.';
881
+ }
882
+ return ok(`apps/${a.id} is ready.${install} Build, dev --lan, then check. Follow the app skill: one screen, roles and parts.`, { kind: 'app', ...r.result });
883
+ },
884
+ },
866
885
  {
867
886
  name: 'game_make', title: 'Make a multiplayer game',
868
887
  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), hero-rush-3d (the arena with animated CC0 heroes that run, jump and swing through @homie-rocks/studio/animate, a shared clip library per skeleton, a jump and a swing tuned in the Game Lab: the start for a 3D game with characters) 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. Before writing one of its systems from scratch, look for a piece of an existing game that does it (parts_find; part_add brings it in), and for the general mechanism in the @homie-rocks/* packages.',
@@ -1007,7 +1026,7 @@ export function toolDefs(ctx, avail = {}) {
1007
1026
  },
1008
1027
  {
1009
1028
  name: 'check', title: 'Two-browser check',
1010
- description: 'Prove a game works: two fresh browsers (a computer and a phone) press Play, land in the same public room and finish a round (about 70 s). Starts the site here first when it is not running. Runs in the background; the build card follows each step going green. Run it before saying a game works.',
1029
+ description: 'Prove an app works across a wall and two phones: an action propagates and survives reconnect, with no rounds. For a game: two fresh browsers (a computer and a phone) press Play, land in the same public room and finish a round (about 70 s). Starts the site here first when it is not running. Runs in the background; the build card follows each step going green. Run it before saying a game works.',
1011
1030
  inputSchema: { type: 'object', properties: { game: str('The game\'s id (default: the only game)'), url: str('Optional: the site to check (default: this computer\'s preview); the live site after a deploy'), ...STUDIO_ARG } },
1012
1031
  annotations: { title: 'Two-browser check', ...RW }, _meta: ui(UI.build),
1013
1032
  run: async (a) => {
@@ -1017,7 +1036,8 @@ export function toolDefs(ctx, avail = {}) {
1017
1036
  const game = a.game ?? (games.length === 1 ? games[0].id : null);
1018
1037
  if (!game) return fail(games.length ? `which game? ${games.map((g) => g.id).join(', ')}` : 'there is no game to check yet');
1019
1038
  const url = a.url ? String(a.url) : await devUrl(ctx, root);
1020
- return startRun(ctx, root, { kind: 'check', game, title: `${games.find((g) => g.id === game)?.name ?? game}: two-browser check`, steps: [
1039
+ return startRun(ctx, root, { kind: 'check', game, title: `${games.find((g) => g.id === game)?.name ?? game}: ${games.find((g) => g.id === game)?.kind === 'app' ? 'multi-screen' : 'two-browser'} check`, steps: [
1040
+ ...(games.some((g) => g.id === game && isRulesGame(g)) ? [{ label: 'rules build check', args: ['build', game] }] : []),
1021
1041
  { label: 'two-browser check', args: ['check', game, '--url', url, '--shots', join('.checks', game)] },
1022
1042
  ] });
1023
1043
  },
@@ -1,3 +1,4 @@
1
+ import { experienceDir, experienceFile } from './studio.mjs';
1
2
  /**
2
3
  * GAME PARTS IN THE BUILD (parts/PARTS.md). Three small things lib/build.mjs and the deploy plan call, so the build
3
4
  * itself stays one line longer in each place:
@@ -63,7 +64,7 @@ export function partsPlugin(root, game = null) {
63
64
  });
64
65
  // A brought-in part a game imports is that game's to credit, even when it was added without naming the game.
65
66
  build.onEnd(() => {
66
- if (!game || !used.size || !existsSync(join(root, 'games', game))) return;
67
+ if (!game || !used.size || !existsSync(join(experienceDir(root, game)))) return;
67
68
  try {
68
69
  const now = readOrigins(root);
69
70
  let changed = false;
@@ -112,7 +113,7 @@ export function buildParts(root, dist, { studio = {}, log = () => {} } = {}) {
112
113
  writeFileSync(join(dest, 'part.json'), `${JSON.stringify(publicPart({ ...packed, share: true }))}\n`);
113
114
  }
114
115
  // The game it came out of, by the name the studio gives it.
115
- const g = part.from?.game;
116
+ const g = part.from?.game ?? part.from?.app;
116
117
  if (g && !names.has(g)) names.set(g, gameFacts(root, g).name);
117
118
  index.push({ ...part, ...(g ? { from: { ...part.from, name: names.get(g) } } : {}), url: `/parts/${id}/${versions.at(-1)}/`, page: `/parts/${id}/`, versions });
118
119
  log(`shared part ${id} (${versions.join(', ')})`);
@@ -142,7 +143,7 @@ export function partsPublishReport(root) {
142
143
  for (const [ref, e] of Object.entries(lock.parts)) for (const g of e.games ?? []) { if (!byGame.has(g)) byGame.set(g, []); byGame.get(g).push([ref, e]); }
143
144
  const games = [];
144
145
  for (const [game, list] of [...byGame].sort(([a], [b]) => a.localeCompare(b))) {
145
- if (!existsSync(join(root, 'games', game, 'game.json'))) continue;
146
+ if (!existsSync(experienceFile(root, game))) continue;
146
147
  const issues = licenceIssues(partItems(root, { game }), { game: gameFacts(root, game) });
147
148
  games.push({
148
149
  game,
package/lib/parts-cli.mjs CHANGED
@@ -28,7 +28,7 @@ export async function partsCommand(root, sub, positional, flags, { log = () => {
28
28
  const str = (k) => (typeof flags.get(k) === 'string' ? flags.get(k) : null);
29
29
  switch (sub ?? 'find') {
30
30
  case 'find': return findParts(root, positional.slice(2).join(' '), { kind: str('kind'), tag: str('tag'), license: str('license'), builds: str('builds'), fetch, hub: str('hub') });
31
- case 'new': return newPart(root, arg, { kind: str('kind') ?? 'mechanic', name: str('name'), from: str('from'), paths: positional.slice(3) });
31
+ case 'new': return newPart(root, arg, { kind: str('kind') ?? 'mechanic', name: str('name'), from: str('from'), paths: positional.slice(3), uses: str('uses')?.split(',') });
32
32
  case 'check': return checkParts(root, arg ?? null, { write: true });
33
33
  case 'share': return arg ? sharePart(root, arg, true) : { ok: false, command: 'parts share', why: 'name the part to share' };
34
34
  case 'unshare': return arg ? sharePart(root, arg, false) : { ok: false, command: 'parts unshare', why: 'name the part to stop sharing' };
@@ -49,7 +49,7 @@ function addLines(r) {
49
49
  if (r.was && r.same) L.push(`${r.ref} ${r.version} is already here and unchanged.`);
50
50
  else if (r.was) L.push(`${r.ref}: ${r.was} → ${r.version}. ${[r.changes.changed.length ? `changed: ${r.changes.changed.join(', ')}` : '', r.changes.added.length ? `new: ${r.changes.added.join(', ')}` : '', r.changes.removed.length ? `gone: ${r.changes.removed.join(', ')}` : ''].filter(Boolean).join('; ') || 'the same files'}. Your tuning.json was kept${r.replaced?.length ? `; REPLACED your edited ${r.replaced.join(', ')} as asked` : ''}.`);
51
51
  else L.push(`${r.name} ${r.version} (${r.kind}) from ${r.host} is in ${r.dir}: ${r.files} files, every one checked against its SHA-256. It is this studio's to tune now.`);
52
- if (r.from?.name || r.from?.game) L.push(` It came out of ${r.from.name ?? r.from.game}${r.from.studio ? ` by ${r.from.studio}` : ''}.`);
52
+ if (r.from?.name || (r.from?.game ?? r.from?.app)) L.push(` It came out of ${r.from.name ?? (r.from.game ?? r.from.app)}${r.from.studio ? ` by ${r.from.studio}` : ''}.`);
53
53
  L.push(` Licence: ${r.license ?? 'NONE NAMED'} (asks ${r.asks ?? 'unknown'})${r.attribution ? `; credit: ${r.attribution}` : ''}`);
54
54
  if (r.credits?.length) L.push(` Credited in ${r.credits.join(', ')}.`);
55
55
  else if (!r.game) L.push(' It is credited in a game\'s credits when that game imports it (the build does it).');
@@ -69,7 +69,7 @@ export function partsLines(r) {
69
69
  for (const p of r.results) {
70
70
  L.push(`${p.name} (${p.kind}) ${p.version ?? ''} · ${p.license ?? 'no licence named'} · ${p.where === 'hub' ? p.ref : p.where === 'here' ? `${p.ref} (already here)` : `${p.id} (this studio's own${p.share ? ', shared' : ', private'})`}`);
71
71
  if (p.summary) L.push(` ${p.summary}`);
72
- if (p.from?.name || p.from?.game) L.push(` from ${p.from.name ?? p.from.game}${p.from.studio ? ` by ${p.from.studio}` : ''}`);
72
+ if (p.from?.name || (p.from?.game ?? p.from?.app)) L.push(` from ${p.from.name ?? (p.from.game ?? p.from.app)}${p.from.studio ? ` by ${p.from.studio}` : ''}`);
73
73
  const bits = [costWords(p.cost) && `costs ${costWords(p.cost)}`, buildsWords(p.requires) && `builds on ${buildsWords(p.requires)}`, p.rig && `rig ${p.rig}`, p.netplay && `in a room: ${p.netplay}`].filter(Boolean);
74
74
  if (bits.length) L.push(` ${bits.join(' · ')}`);
75
75
  L.push(` ${p.say}`);
@@ -23,7 +23,7 @@ import {
23
23
  packedVersions, parseRef, partDir, readOrigins, readPart, requiredParts, sha256, shareProblems, syncPartCredits, vendorDir, verifyFiles, writeHashes,
24
24
  writeOrigins, writePart,
25
25
  } from './parts.mjs';
26
- import { listGames, readStudio } from './studio.mjs';
26
+ import { experienceDir, experienceFile, listExperiences as listGames, readStudio } from './studio.mjs';
27
27
 
28
28
  export const HUB = 'https://homie.rocks';
29
29
  const isObj = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
@@ -139,8 +139,8 @@ export function ensurePackages(root, wants, { npm = runNpm, install = false, say
139
139
  /** What a part's licence can care about in the game using it: whether the studio sells, and who the game credits. */
140
140
  export function gameFacts(root, id) {
141
141
  let g = {}; let credits = {};
142
- try { g = JSON.parse(readFileSync(join(root, 'games', id, 'game.json'), 'utf8')); } catch { g = {}; }
143
- try { credits = JSON.parse(readFileSync(join(root, 'games', id, 'credits.json'), 'utf8')); } catch { credits = {}; }
142
+ try { g = JSON.parse(readFileSync(experienceFile(root, id), 'utf8')); } catch { g = {}; }
143
+ try { credits = JSON.parse(readFileSync(join(experienceDir(root, id), 'credits.json'), 'utf8')); } catch { credits = {}; }
144
144
  return { id, name: g.name ?? id, sells: existsSync(join(root, 'shop.json')), credits: Array.isArray(credits.parts) ? credits.parts : [] };
145
145
  }
146
146
 
@@ -177,7 +177,7 @@ const diffFiles = (was, now) => {
177
177
  export async function addPart(root, refText, { game = null, fetch, npm = runNpm, install = true, overwrite = false, say = () => {} } = {}) {
178
178
  const command = 'parts add';
179
179
  const fail = (why, extra = {}) => ({ ok: false, command, why, ...extra });
180
- if (game && !existsSync(join(root, 'games', game, 'game.json'))) return fail(`there is no game "${game}" in this studio (${listGames(root).map((g) => g.id).join(', ') || 'it has none yet'})`);
180
+ if (game && !existsSync(experienceFile(root, game))) return fail(`there is no game "${game}" in this studio (${listGames(root).map((g) => g.id).join(', ') || 'it has none yet'})`);
181
181
  let got;
182
182
  try { got = await fetchPart(refText, { fetch }); } catch (error) { return fail(error.message); }
183
183
  const { ref, part, files, version, base, entry } = got;
@@ -281,14 +281,15 @@ export function sharePart(root, id, on = true) {
281
281
  const packed = packPart(root, id);
282
282
  if (!packed.ok) { part.share = false; writePart(dir, part); return { ...packed, command }; }
283
283
  const lic = licenseOfPart(part.license);
284
- return { ok: true, command, id, share: true, version: part.version, license: lic.id, asks: lic.asks, files: part.files.length, bytes: part.cost?.bytes ?? 0, ...(part.from?.game ? { from: part.from } : {}), effect: 'It is live after the studio\'s next deploy, not before: nothing was uploaded now. Sharing a part does not list the studio in the directory.' };
284
+ return { ok: true, command, id, share: true, version: part.version, license: lic.id, asks: lic.asks, files: part.files.length, bytes: part.cost?.bytes ?? 0, ...(part.from ? { from: part.from } : {}), effect: 'It is live after the studio\'s next deploy, not before: nothing was uploaded now. Sharing a part does not list the studio in the directory.' };
285
285
  }
286
286
 
287
287
  /* ------------------------------------------------------------------ find */
288
288
 
289
289
  const row = (p, extra) => ({
290
290
  id: p.id, name: p.name, kind: p.kind, version: p.version, summary: line(p.summary), license: p.license || null, tags: Array.isArray(p.tags) ? p.tags.slice(0, 12) : [],
291
- from: isObj(p.from) ? { game: p.from.game ?? null, name: p.from.name ?? null, studio: p.from.studio ?? null, play: p.from.play ?? null } : null,
291
+ ...(Array.isArray(p.uses) ? { uses: p.uses } : {}),
292
+ from: isObj(p.from) ? { ...(p.from.app ? { app: p.from.app, open: p.from.open ?? null } : {}), ...(p.from.music ? { music: p.from.music } : {}), ...(p.from.video ? { video: p.from.video } : {}), game: p.from.game ?? null, name: p.from.name ?? null, studio: p.from.studio ?? null, play: p.from.play ?? null } : null,
292
293
  cost: isObj(p.cost) ? p.cost : {}, requires: isObj(p.requires) ? p.requires : {}, rig: p.skeleton?.rig ?? null, netplay: p.contract?.netplay ?? null, ...extra,
293
294
  });
294
295
  function matches(p, { words, kind, tag, license, builds }) {
@@ -297,7 +298,7 @@ function matches(p, { words, kind, tag, license, builds }) {
297
298
  if (license && String(p.license ?? '').toLowerCase() !== String(license).toLowerCase()) return false;
298
299
  // What a part builds on: a package it names, or the skeleton it is made for.
299
300
  if (builds && !(Object.keys(p.requires?.packages && typeof p.requires.packages === 'object' ? p.requires.packages : {}).includes(builds) || p.rig === builds)) return false;
300
- const hay = [p.id, p.name, p.kind, p.summary, ...(p.tags ?? []), p.from?.name, p.from?.game, p.from?.studio].filter(Boolean).join(' ').toLowerCase();
301
+ const hay = [p.id, p.name, p.kind, p.summary, ...(p.tags ?? []), p.from?.name, p.from?.game, p.from?.app, p.from?.music, p.from?.video, ...(p.uses ?? []), p.from?.studio].filter(Boolean).join(' ').toLowerCase();
301
302
  return words.every((w) => hay.includes(w));
302
303
  }
303
304
 
@@ -51,22 +51,22 @@ export function partsToolDefs(ctx, h) {
51
51
  },
52
52
  },
53
53
  {
54
- name: 'part_new', title: 'Make a game part',
55
- description: 'Make a part of this studio: a piece of one of its games that another game can use. With from (a game\'s id) and files (paths inside that game: a module or a folder), the files are LIFTED out of the game into parts/<id>/ and the game imports them from there, so it builds and behaves as before; a file that still reaches into the rest of the game is refused by name (pass what it needs in as an argument first). Without from, an empty part to write. A part is a piece of a game, never the whole game, in whatever form suits it (code, assets, data, a tuned config). A new part is private. Offer this after building something reusable.',
56
- inputSchema: { type: 'object', properties: { id: str('The part\'s id (lowercase, digits, hyphens): "chase-camera"'), kind: { type: 'string', enum: [...PART_KINDS], description: 'What kind of part (default mechanic)' }, name: str('Its name'), from: str('The game to lift it out of'), files: { type: 'array', items: { type: 'string' }, description: 'With from: paths inside games/<from>/ that become the part ("src/creature.ts", "src/cave")' }, ...STUDIO_ARG }, required: ['id'] },
57
- annotations: { title: 'Make a game part', ...RW },
54
+ name: 'part_new', title: 'Make a shared part',
55
+ description: 'Make a part of this studio: a reusable piece of a game, app, music or video. With from (a game\'s id) and files (paths inside that game: a module or a folder), the files are LIFTED out of the game into parts/<id>/ and the game imports them from there, so it builds and behaves as before; a file that still reaches into the rest of the game is refused by name (pass what it needs in as an argument first). Without from, an empty part to write. A part is a reusable piece, never an entire experience, in whatever form suits it (code, assets, data, a tuned config). A new part is private. Offer this after building something reusable.',
56
+ inputSchema: { type: 'object', properties: { id: str('The part\'s id (lowercase, digits, hyphens): "chase-camera"'), kind: { type: 'string', enum: [...PART_KINDS], description: 'What kind of part (default mechanic)' }, name: str('Its name'), uses: { type: 'array', items: { type: 'string', enum: ['game', 'app', 'venue', 'cause', 'music', 'video'] } }, from: str('The game to lift it out of'), files: { type: 'array', items: { type: 'string' }, description: 'With from: paths inside games/<from>/ that become the part ("src/creature.ts", "src/cave")' }, ...STUDIO_ARG }, required: ['id'] },
57
+ annotations: { title: 'Make a shared part', ...RW },
58
58
  run: async (a) => {
59
59
  const root = ctx.root(a.studio);
60
- const r = newPart(root, String(a.id ?? ''), { kind: a.kind ?? 'mechanic', name: a.name ?? null, from: a.from ? String(a.from) : null, paths: Array.isArray(a.files) ? a.files.map(String) : [] });
60
+ const r = newPart(root, String(a.id ?? ''), { kind: a.kind ?? 'mechanic', uses: a.uses, name: a.name ?? null, from: a.from ? String(a.from) : null, paths: Array.isArray(a.files) ? a.files.map(String) : [] });
61
61
  if (!r.ok) return fail(`No part was made: ${r.why}`, { kind: 'part', ...r });
62
62
  return ok([text(r), r.lifted ? `Build ${r.from} now and check it still plays the same.` : '', 'Fill in parts/' + r.id + '/part.json: a one-sentence summary, what it takes from a game ("contract"), and its costs if measured. It stays private until the person asks to share it (part_share).'].filter(Boolean).join('\n'), { kind: 'part', ...r });
63
63
  },
64
64
  },
65
65
  {
66
- name: 'part_share', title: 'Share a game part',
66
+ name: 'part_share', title: 'Share a part',
67
67
  description: 'Share one of this studio\'s parts with other studios, or stop sharing it. Only when the person asked: a part is private until they say otherwise. It needs a licence the person picked (an SPDX identifier: CC0-1.0, CC-BY-4.0, MIT…; with license it is set here), who to credit when the licence asks, and known rights to every file: anything in the way is listed in plain words and nothing is shared. It is live after the studio\'s next deploy (studio_deploy), not before; stopping takes effect then too. With share left out it only checks and reports.',
68
68
  inputSchema: { type: 'object', properties: { id: str('The part\'s id'), share: { type: 'boolean', description: 'true: share it; false: stop sharing it; left out: only check what stands in the way' }, license: str('The licence the person picked, an SPDX identifier (set before sharing)'), attribution: str('Who a game using it should credit'), ...STUDIO_ARG }, required: ['id'] },
69
- annotations: { title: 'Share a game part', ...RW },
69
+ annotations: { title: 'Share a part', ...RW },
70
70
  run: async (a) => {
71
71
  const root = ctx.root(a.studio);
72
72
  const id = String(a.id ?? '');