@homie-rocks/studio 0.48.4 → 0.50.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 (105) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/app-starters/gathering/README.md +4 -0
  3. package/app-starters/gathering/app.json +162 -0
  4. package/app-starters/gathering/index.html +3 -0
  5. package/app-starters/gathering/map/main.json +1 -0
  6. package/app-starters/gathering/src/move.ts +3 -0
  7. package/app-starters/gathering/src/rules.ts +21 -0
  8. package/app-starters/gathering/src/view.ts +74 -0
  9. package/app-starters/giving/README.md +5 -0
  10. package/app-starters/giving/app.json +162 -0
  11. package/app-starters/giving/index.html +3 -0
  12. package/app-starters/giving/map/main.json +1 -0
  13. package/app-starters/giving/src/move.ts +3 -0
  14. package/app-starters/giving/src/rules.ts +21 -0
  15. package/app-starters/giving/src/view.ts +74 -0
  16. package/app-starters/jobs/README.md +4 -0
  17. package/app-starters/jobs/app.json +162 -0
  18. package/app-starters/jobs/index.html +3 -0
  19. package/app-starters/jobs/map/main.json +1 -0
  20. package/app-starters/jobs/src/move.ts +3 -0
  21. package/app-starters/jobs/src/rules.ts +21 -0
  22. package/app-starters/jobs/src/view.ts +74 -0
  23. package/app-starters/quiz/README.md +4 -0
  24. package/app-starters/quiz/app.json +162 -0
  25. package/app-starters/quiz/index.html +3 -0
  26. package/app-starters/quiz/map/main.json +1 -0
  27. package/app-starters/quiz/src/move.ts +3 -0
  28. package/app-starters/quiz/src/rules.ts +21 -0
  29. package/app-starters/quiz/src/view.ts +74 -0
  30. package/app-starters/welcome/app.json +25 -2
  31. package/app-starters/welcome/index.html +1 -1
  32. package/app-starters/welcome/src/view.ts +20 -8
  33. package/bin/homie-studio.mjs +20 -16
  34. package/functions/FUNCTIONS.md +1 -1
  35. package/guides/app/SKILL.md +178 -0
  36. package/guides/art/SKILL.md +154 -0
  37. package/guides/art/references/RIGHTS.md +20 -0
  38. package/guides/art/scripts/art.mjs +308 -0
  39. package/guides/models/SKILL.md +198 -0
  40. package/guides/models/references/RIGHTS.md +46 -0
  41. package/guides/models/references/models.json +175 -0
  42. package/guides/models/scripts/models.mjs +483 -0
  43. package/guides/music/SKILL.md +197 -0
  44. package/guides/music/references/COMPOSITION.md +59 -0
  45. package/guides/music/references/GAME-AUDIO.md +50 -0
  46. package/guides/music/references/RIGHTS.md +43 -0
  47. package/guides/music/scripts/lib/audio.mjs +258 -0
  48. package/guides/music/scripts/lib/eleven.mjs +260 -0
  49. package/guides/music/scripts/lib/studio.mjs +147 -0
  50. package/guides/music/scripts/music.mjs +644 -0
  51. package/guides/parts/SKILL.md +158 -0
  52. package/guides/plan/SKILL.md +126 -0
  53. package/guides/plan/references/CODEX.md +105 -0
  54. package/guides/plan/references/INTERVIEW.md +86 -0
  55. package/guides/playtest/SKILL.md +205 -0
  56. package/guides/playtest/references/METHOD.md +124 -0
  57. package/guides/playtest/references/REVIEWER.md +78 -0
  58. package/guides/playtest/scripts/lib/exit.mjs +31 -0
  59. package/guides/playtest/scripts/lib/judge.mjs +502 -0
  60. package/guides/playtest/scripts/lib/pixels.mjs +128 -0
  61. package/guides/playtest/scripts/lib/preflight.mjs +69 -0
  62. package/guides/playtest/scripts/playtest.mjs +870 -0
  63. package/guides/shop/SKILL.md +153 -0
  64. package/guides/sound/SKILL.md +156 -0
  65. package/guides/sound/references/GAME-SOUND.md +100 -0
  66. package/guides/sound/references/SCORE.md +131 -0
  67. package/guides/sound/references/TRAPS.md +93 -0
  68. package/guides/sound/scripts/lib/measure.mjs +252 -0
  69. package/guides/sound/scripts/lib/score.mjs +415 -0
  70. package/guides/sound/scripts/lib/sfx.mjs +222 -0
  71. package/guides/sound/scripts/lib/synth.mjs +509 -0
  72. package/guides/sound/scripts/sound.mjs +520 -0
  73. package/guides/studio-setup/SKILL.md +419 -0
  74. package/guides/style/SKILL.md +105 -0
  75. package/lib/app-needs.mjs +63 -0
  76. package/lib/authoring-guides.mjs +5 -4
  77. package/lib/build.mjs +5 -4
  78. package/lib/check-site.mjs +6 -6
  79. package/lib/check.mjs +18 -5
  80. package/lib/codex.mjs +95 -18
  81. package/lib/decisions.mjs +9 -7
  82. package/lib/dev.mjs +4 -2
  83. package/lib/doctor.mjs +1 -1
  84. package/lib/handoff.mjs +13 -8
  85. package/lib/mcp-tools.mjs +19 -17
  86. package/lib/mcp.mjs +1 -1
  87. package/lib/parts.mjs +6 -6
  88. package/lib/scaffold.mjs +45 -11
  89. package/lib/shop-links.mjs +2 -2
  90. package/lib/shop.mjs +5 -3
  91. package/lib/studio.mjs +7 -6
  92. package/lib/template-history.json +84 -0
  93. package/package.json +4 -4
  94. package/shop/SHOP.md +4 -0
  95. package/shop/shop.ts +4 -4
  96. package/worker/app-format.mjs +1 -1
  97. package/worker/build-id.mjs +1 -1
  98. package/worker/functions-schema.mjs +15 -0
  99. package/worker/index.mjs +2 -1
  100. package/worker/parts.mjs +4 -4
  101. package/worker/shop-page.mjs +22 -16
  102. package/worker/shop-rules.mjs +4 -3
  103. package/worker/shop.mjs +6 -2
  104. package/worker/site.mjs +4 -2
  105. package/worker/version.mjs +1 -1
package/lib/mcp-tools.mjs CHANGED
@@ -339,7 +339,7 @@ async function studioCard(ctx, root) {
339
339
  const cover = budget > 0 ? coverData(g.dir) : null;
340
340
  if (cover) budget -= cover.length;
341
341
  const isLive = live && onLine.has(g.id);
342
- return { id: g.id, name: String(g.name ?? g.id).slice(0, 60), blurb: String(g.blurb ?? '').slice(0, 160), cover, live: Boolean(isLive), play: { dev: dev ? `${dev.url}/${g.id}/play` : null, live: isLive ? `${live}/${g.id}/play` : null }, page: isLive ? `${live}/${g.id}/` : null };
342
+ return { id: g.id, name: String(g.name ?? g.id).slice(0, 60), blurb: String(g.blurb ?? '').slice(0, 160), cover, live: Boolean(isLive), play: { dev: dev ? `${dev.url}/${g.id}/${g.kind === 'app' ? 'open' : 'play'}` : null, live: isLive ? `${live}/${g.id}/${g.kind === 'app' ? 'open' : 'play'}` : null }, page: isLive ? `${live}/${g.id}/` : null };
343
343
  });
344
344
  const posts = (() => { try { return readdirSync(join(root, 'posts')).filter((f) => /\.md$/.test(f) && f !== 'README.md' && !/^[_.]/.test(f)).length; } catch { return 0; } })();
345
345
  let rooms = [];
@@ -350,7 +350,8 @@ async function studioCard(ctx, root) {
350
350
  rooms = (Array.isArray(body?.rooms) ? body.rooms : []).slice(0, 8).map((r) => ({ game: String(r.game ?? '').slice(0, 40), room: String(r.room ?? '').slice(0, 20), players: Number(r.players) || 0 }));
351
351
  } catch { rooms = []; }
352
352
  }
353
- const demo = games.length ? null : await demoGames({ timeoutMs: 4000 }).then((d) => ({ name: d.pick.name, play: d.pick.play, studio: d.studio.name })).catch(() => null);
353
+ const appStudio = ['business', 'cause', 'app'].includes(studio.kind);
354
+ const demo = games.length || appStudio ? null : await demoGames({ timeoutMs: 4000 }).then((d) => ({ name: d.pick.name, play: d.pick.play, studio: d.studio.name })).catch(() => null);
354
355
  const data = {
355
356
  kind: 'studio', folder: basename(root), root, name: studio.name, tagline: studio.tagline ?? null,
356
357
  games, songs: published(root, 'music'), videos: published(root, 'videos'), posts,
@@ -360,7 +361,7 @@ async function studioCard(ctx, root) {
360
361
  if (behind) data.behind = { ...behind, whatsNew: behind.whatsNew ? { ...behind.whatsNew, versions: behind.whatsNew.versions.slice(0, 6).map(({ version, summary, upgrade }) => ({ version, summary, upgrade: upgrade.slice(0, 2) })) } : null };
361
362
  const text = [
362
363
  `${studio.name} (${root})`,
363
- games.length ? `Games: ${games.map((g) => `${g.name} (${g.id}${live ? (g.live ? ', live' : ', not deployed yet') : ''})`).join(', ')}` : 'No game yet: its home page says "First game coming soon".',
364
+ games.length ? `${appStudio ? 'Apps' : 'Apps and games'}: ${games.map((g) => `${g.name} (${g.id}${live ? (g.live ? ', live' : ', not deployed yet') : ''})`).join(', ')}` : appStudio ? 'No app yet: app new creates your first live app.' : 'No game yet: its home page says "First game coming soon".',
364
365
  dev ? `Running here: ${dev.url}/` : 'Not running here (preview_run starts it).',
365
366
  live ? `Live: ${live}/` : 'Not online yet (studio_deploy).',
366
367
  ...(rooms.length ? [`Live rooms: ${rooms.map((r) => `${r.game} ${r.room} (${r.players})`).join(', ')}`] : []),
@@ -814,8 +815,8 @@ export function toolDefs(ctx, avail = {}) {
814
815
  },
815
816
  {
816
817
  name: 'studio_scaffold', title: 'Make a studio',
817
- description: 'Step 1: make a new game studio ON THIS COMPUTER, in the studios folder: one folder with AGENTS.md, games/, music/, videos/, posts/ and a site that later goes online on the studio\'s own Cloudflare (their account). It has NO game: its home page says "First game coming soon". Then installs its pinned toolkit (npm install, in the background). Never put a game in it the person did not ask for.',
818
- inputSchema: { type: 'object', properties: { name: str('The studio\'s name, e.g. "Night Owls"'), folder: str('Optional: the folder name inside the studios folder (default: from the name)') } },
818
+ description: 'Make a studio for a business, cause, app or game ON THIS COMPUTER, in the studios folder: one folder with AGENTS.md, games/, music/, videos/, posts/ and a site that later goes online on the studio\'s own Cloudflare (their account). Choose kind business or cause for an app-first setup. Proceed through setup, build and checks without waiting unless asked. Then installs its pinned toolkit (npm install, in the background). Never put a game in it the person did not ask for.',
819
+ inputSchema: { type: 'object', properties: { name: str('The studio\'s name, e.g. "Night Owls"'), kind: { type: 'string', enum: ['game', 'business', 'cause', 'app'], description: 'Use business or cause for customer apps' }, folder: str('Optional: the folder name inside the studios folder (default: from the name)') } },
819
820
  annotations: { title: 'Make a studio', ...RW }, _meta: ui(UI.setup),
820
821
  run: async (a) => {
821
822
  const name = String(a.name ?? '').trim().slice(0, 60) || 'My Studio';
@@ -833,7 +834,7 @@ export function toolDefs(ctx, avail = {}) {
833
834
  let at = dir;
834
835
  if (existsSync(at) && readdirSync(at).some((f) => !['.DS_Store', '.git'].includes(f)) && !existsSync(join(at, 'studio.json'))) at = `${dir}-studio`;
835
836
  let made;
836
- try { made = newStudio(at, { name, install: false, ...(ctx.directory ? { homie: ctx.directory } : {}) }); } catch (error) { return fail(error.message); }
837
+ try { made = newStudio(at, { name, kind: a.kind ?? 'game', install: false, ...(ctx.directory ? { homie: ctx.directory } : {}) }); } catch (error) { return fail(error.message); }
837
838
  ctx.current = made.dir;
838
839
  ctx.note(made.dir).earlier = earlier.map((e) => e.path);
839
840
  let install = null;
@@ -844,7 +845,7 @@ export function toolDefs(ctx, avail = {}) {
844
845
  }
845
846
  const r = await setupCard(ctx, made.dir, { install, made: { dir: made.dir, wrote: made.wrote } });
846
847
  const fold = earlier.length ? `\n\nAn earlier folder for this name is on this computer, and it is not a studio: ${earlier.map((e) => `${e.path} (${e.files.join(', ') || 'empty'})`).join('; ')}. ASK the person: fold its premise into ${name}? With their yes, studio_fold { "from": "<that folder>" } copies its notes into the studio (notes/earlier/) and shows them to you, so the plan can use them. Then, only with another yes, studio_fold { "from": "<that folder>", "remove": true } moves the old folder to the Trash.` : '';
847
- r.content[0].text = `Made ${name} at ${made.dir} (no game yet: its home page says "First game coming soon").\nWrote: ${made.wrote.join(', ')}\n${!install ? '' : install.endedAt ? (install.code === 0 ? 'Its toolkit is installed.' : `npm install failed: ${whyOf(install)}`) : `Its toolkit is installing (job ${install.id}).`}${fold}\n\n${r.content[0].text}`;
848
+ r.content[0].text = `Made ${name} at ${made.dir} (${!a.kind || a.kind === 'game' ? 'its home page says "First game coming soon"' : 'ready for the requested app'}).\nWrote: ${made.wrote.join(', ')}\n${!install ? '' : install.endedAt ? (install.code === 0 ? 'Its toolkit is installed.' : `npm install failed: ${whyOf(install)}`) : `Its toolkit is installing (job ${install.id}).`}${fold}${a.kind && a.kind !== 'game' ? '\n\n' + made.next.join('\n') : ''}\n\n${r.content[0].text}`;
848
849
  if (r.structuredContent) r.structuredContent.earlier = earlier;
849
850
  return r;
850
851
  },
@@ -908,12 +909,12 @@ export function toolDefs(ctx, avail = {}) {
908
909
  {
909
910
  name: 'app_make', title: 'Make an app',
910
911
  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.',
911
- inputSchema: { type: 'object', properties: { id: str('App id: lowercase, digits, hyphens'), name: str('Display name'), ...STUDIO_ARG }, required: ['id', 'name'] },
912
+ inputSchema: { type: 'object', properties: { id: str('App id: lowercase, digits, hyphens'), name: str('Display name'), from: { type: 'string', enum: ['welcome', 'gathering', 'giving', 'quiz', 'jobs'] }, ...STUDIO_ARG }, required: ['id', 'name'] },
912
913
  annotations: { title: 'Make an app', ...RW },
913
914
  run: async (a) => {
914
915
  if (!GAME_ID.test(String(a.id ?? ''))) return fail('invalid app id');
915
916
  const root = ctx.root(a.studio);
916
- const r = await cli(ctx, root, `app new ${a.id}`, ['app', 'new', a.id, '--name', String(a.name ?? a.id).slice(0, 60)]);
917
+ const r = await cli(ctx, root, `app new ${a.id}`, ['app', 'new', a.id, '--from', a.from ?? 'welcome', '--name', String(a.name ?? a.id).slice(0, 60)]);
917
918
  if (!r.ended) return stillRunning(r.job, 'Making the app');
918
919
  if (r.job.code !== 0) return fail(`Not made: ${whyOf(r.job)}`);
919
920
  let install = '';
@@ -921,7 +922,7 @@ export function toolDefs(ctx, avail = {}) {
921
922
  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.`; }
922
923
  else install = ' Run studio_install for its engine dependencies.';
923
924
  }
924
- 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 });
925
+ return ok(`apps/${a.id} plumbing is ready.${install} Read the app and plan guides. Capture the person’s dream in apps/${a.id}/CODEX.md, design their scene with engines, style from their words, parts_find and media. Build it; fix failures without flattening. Check and playtest each Codex need with wall and phone evidence.`, { kind: 'app', ...r.result });
925
926
  },
926
927
  },
927
928
  {
@@ -978,25 +979,26 @@ export function toolDefs(ctx, avail = {}) {
978
979
  },
979
980
  },
980
981
  {
981
- name: 'game_plan', title: 'Plan a game (its Game Codex)',
982
+ name: 'game_plan', title: 'Plan a game or app (its Codex)',
982
983
  description: 'Step 4: start the game\'s Game Codex (games/<id>/CODEX.md, every section) and record sensible defaults from the request for genre, style, devices, rooms, art, sound and scope. Never route engineering or design questions to the person. A game is planned before it is made: with no game <id> yet, this starts its folder with only the codex. Fill CODEX.md from their request and your defaults in one call (file_write of the whole file, or one file_edit with an edits list), then game_codex shows it. While planning, run `parts find` (the parts_find tool) for each system the game needs, so the plan starts from pieces of existing games and the @homie-rocks/* packages rather than from nothing; the codex\'s Built from section records what was found.',
983
- inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens)'), name: str('Its name, when the game is not made yet'), interview: { type: 'boolean', description: 'Only when the person explicitly requests a planning conversation' }, ...STUDIO_ARG }, required: ['id'] },
984
+ inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens)'), name: str('Its name, when the game is not made yet'), kind: { type: 'string', enum: ['game', 'app'], description: 'App plans use apps/<id>/CODEX.md with the person’s scene, people, offers and needs' }, interview: { type: 'boolean', description: 'Only when the person explicitly requests a planning conversation' }, ...STUDIO_ARG }, required: ['id'] },
984
985
  annotations: { title: 'Plan a game', ...RW },
985
986
  run: async (a) => {
986
987
  const root = ctx.root(a.studio);
987
- const file = join(root, 'games', String(a.id), 'CODEX.md');
988
+ const folder = a.kind === 'app' || existsSync(join(root, 'apps', String(a.id))) ? 'apps' : 'games';
989
+ const file = join(root, folder, String(a.id), 'CODEX.md');
988
990
  let made = null;
989
991
  if (!existsSync(file)) {
990
- made = newCodex(root, String(a.id), { name: a.name ?? null });
992
+ made = newCodex(root, String(a.id), { name: a.name ?? null, kind: folder === 'apps' ? 'app' : 'game' });
991
993
  if (!made.ok) return fail(made.why);
992
994
  }
993
995
  const guide = a.interview === true && ctx.skillsDir && existsSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md')) ? readFileSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md'), 'utf8') : null;
994
996
  return ok([
995
- made ? `Wrote games/${a.id}/CODEX.md (every section${made.planned ? '; the game is planned here before it is made' : ''}).` : `games/${a.id}/CODEX.md is there already: change it, never replace it.`,
996
- 'From the request and sensible defaults, fill CODEX.md in one call, then show it with game_codex and continue building. New games are rules plus view with room.host server; read the game guide and RULES.md. Choose phone and computer controls, a small first version, local sound and CC0 art. Infer persistent progress from the premise. No interview unless the person explicitly asks to plan together; no engineering or design questions.',
997
+ made ? `Wrote ${folder}/${a.id}/CODEX.md (every section${made.planned ? '; the game is planned here before it is made' : ''}).` : `${folder}/${a.id}/CODEX.md is there already: change it, never replace it.`,
998
+ folder === 'apps' ? 'Fill Their words, Scene, People and surfaces, Offers and executable Needs from the person’s request. Use the app guide; starter plumbing is not their scene. Show with game_codex, build and prove every need on wall and phone.' : 'From the request and sensible defaults, fill CODEX.md in one call, then show it with game_codex and continue building. New games are rules plus view with room.host server; read the game guide and RULES.md. Choose phone and computer controls, a small first version, local sound and CC0 art. Infer persistent progress from the premise. No interview unless the person explicitly asks to plan together; no engineering or design questions.',
997
999
  '', PARTS_FIRST,
998
1000
  ...(guide ? ['', 'Optional reference for a person who explicitly requests an interview:', guide] : []),
999
- ].join('\n'), { kind: 'plan', id: a.id, file: `games/${a.id}/CODEX.md`, created: Boolean(made) });
1001
+ ].join('\n'), { kind: 'plan', id: a.id, file: `${folder}/${a.id}/CODEX.md`, created: Boolean(made) });
1000
1002
  },
1001
1003
  },
1002
1004
  {
package/lib/mcp.mjs CHANGED
@@ -144,7 +144,7 @@ function promptText(name, args = {}) {
144
144
  export async function serveMcp({ studios = null, skills = null, cwd = process.cwd(), input = process.stdin, output = process.stdout, waitMs, directory = null, install = true, desktop = inDesktopApp(), log = (line) => process.stderr.write(`[homie-studio mcp] ${line}\n`) } = {}) {
145
145
  // A GUI app starts this with a short PATH: everything it runs sees the usual places Node, npm and ffmpeg live.
146
146
  process.env.PATH = toolPath();
147
- const skillsDir = skills ?? [join(PACKAGE_ROOT, '..', '..', 'plugins', 'homie', 'skills'), join(PACKAGE_ROOT, '..', 'skills')].find((d) => existsSync(join(d, 'studio-setup', 'SKILL.md'))) ?? null;
147
+ const skillsDir = skills ?? [join(PACKAGE_ROOT, '..', '..', 'plugins', 'homie', 'skills'), join(PACKAGE_ROOT, '..', 'skills'), join(PACKAGE_ROOT, 'guides')].find((d) => existsSync(join(d, 'studio-setup', 'SKILL.md'))) ?? null;
148
148
  const ctx = new StudioContext({ studiosDir: studios, cwd, skillsDir, waitMs: waitMs ?? Number(process.env.HOMIE_MCP_WAIT_MS || 40_000), directory, install, desktop });
149
149
  let avail = availability();
150
150
  let names = '';
package/lib/parts.mjs CHANGED
@@ -559,15 +559,15 @@ function liftPlan(root, game, paths) {
559
559
  const files = [];
560
560
  const walk = (rel) => {
561
561
  const abs = join(gdir, rel);
562
- if (!existsSync(abs)) throw new Error(`games/${game}/${rel} is not there`);
562
+ if (!existsSync(abs)) throw new Error(`${relative(root, experienceDir(root, game))}/${rel} is not there`);
563
563
  if (statSync(abs).isDirectory()) { for (const e of readdirSync(abs, { withFileTypes: true })) if (!e.name.startsWith('.') && e.name !== 'node_modules') walk(`${rel}/${e.name}`); } else files.push(rel);
564
564
  };
565
565
  for (const raw of paths) {
566
566
  const rel = posix(String(raw)).replace(/^\.\//, '').replace(/^(?:games|apps)\/[^/]+\//, '').replace(/\/+$/, '');
567
- if (!rel || rel.split('/').includes('..') || rel.startsWith('/')) throw new Error(`"${raw}" is not a path inside games/${game}`);
567
+ if (!rel || rel.split('/').includes('..') || rel.startsWith('/')) throw new Error(`"${raw}" is not a path inside ${relative(root, experienceDir(root, game))}`);
568
568
  walk(rel);
569
569
  }
570
- if (!files.length) throw new Error(`name the files of games/${game} that become the part (src/creature.ts, or a folder)`);
570
+ if (!files.length) throw new Error(`name the files of ${relative(root, experienceDir(root, game))} that become the part (src/creature.ts, or a folder)`);
571
571
  // A part is a piece of a game, never the whole game: its entry, its page and its game.json stay the game's.
572
572
  let entry = 'src/main.ts';
573
573
  try { entry = JSON.parse(readFileSync(experienceFile(root, game), 'utf8')).entry ?? entry; } catch { /* the default entry */ }
@@ -607,7 +607,7 @@ export function newPart(root, id, { kind = 'mechanic', name = null, from = null,
607
607
  if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(from)) || !existsSync(experienceFile(root, from))) return { ok: false, command: 'parts new', why: `there is no game "${from}" in this studio to lift a part out of` };
608
608
  let plan;
609
609
  try { plan = liftPlan(root, from, paths); } catch (error) { return { ok: false, command: 'parts new', why: error.message }; }
610
- if (plan.outside.length) return { ok: false, command: 'parts new', why: `These files still reach into the rest of games/${from}: ${plan.outside.slice(0, 6).join('; ')}. A part has to stand without the game: lift those files too, or pass what they provide in as an argument first. Nothing was moved.`, outside: plan.outside };
610
+ if (plan.outside.length) return { ok: false, command: 'parts new', why: `These files still reach into the rest of ${relative(root, experienceDir(root, from))}: ${plan.outside.slice(0, 6).join('; ')}. A part has to stand without the game: lift those files too, or pass what they provide in as an argument first. Nothing was moved.`, outside: plan.outside };
611
611
  mkdirSync(dir, { recursive: true });
612
612
  lifted = { moved: [], copied: [] };
613
613
  for (const f of plan.files) {
@@ -654,7 +654,7 @@ export function newPart(root, id, { kind = 'mechanic', name = null, from = null,
654
654
  return {
655
655
  ok: true, command: 'parts new', id, dir: `${PARTS_DIR}/${id}`, kind, entry, files: part.files.map((f) => f.path), ...(lifted ? { from, lifted } : {}),
656
656
  next: [
657
- ...(lifted ? [`games/${from} imports it now (each lifted module's old path re-exports the part's); build the game to see it unchanged.`] : []),
657
+ ...(lifted ? [`${relative(root, experienceDir(root, from))} imports it now (each lifted module's old path re-exports the part's); build the app or game to see it unchanged.`] : []),
658
658
  `Write a one-sentence "summary" in parts/${id}/part.json, and what it needs from a game under "contract".`,
659
659
  `Another game of this studio uses it with: import … from '@parts/${id}'`,
660
660
  'It is private. Sharing it needs a licence (an SPDX identifier such as CC-BY-4.0) and is live after the studio\'s next deploy.',
@@ -739,7 +739,7 @@ export function syncPartCredits(root, game, origins) {
739
739
  if (!existsSync(join(experienceDir(root, game)))) return null;
740
740
  const text = `${JSON.stringify({ ...credits, parts: [...keep, ...mine] }, null, 2)}\n`;
741
741
  if (!existsSync(file) || readFileSync(file, 'utf8') !== text) writeFileSync(file, text);
742
- return `games/${game}/credits.json`;
742
+ return `${relative(root, experienceDir(root, game))}/credits.json`;
743
743
  }
744
744
 
745
745
  /* ------------------------------------------------------------------ where brought-in parts came from */
package/lib/scaffold.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { FUNCTIONS_META_MIGRATION, FUNCTIONS_META_MIGRATION_FILE, FUNCTIONS_MIGRATION, FUNCTIONS_MIGRATION_FILE, FUNCTIONS_CURSOR_MIGRATION, FUNCTIONS_CURSOR_MIGRATION_FILE } from '../worker/functions-schema.mjs';
1
+ import { FUNCTIONS_REFERENCE_MIGRATION, FUNCTIONS_REFERENCE_MIGRATION_FILE, FUNCTIONS_META_MIGRATION, FUNCTIONS_META_MIGRATION_FILE, FUNCTIONS_MIGRATION, FUNCTIONS_MIGRATION_FILE, FUNCTIONS_CURSOR_MIGRATION, FUNCTIONS_CURSOR_MIGRATION_FILE } from '../worker/functions-schema.mjs';
2
2
  import { MCP_MIGRATION, MCP_MIGRATION_FILE } from '../worker/mcp-store.mjs';
3
3
  import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
4
4
  import { PURCHASE_MIGRATION, PURCHASE_MIGRATION_FILE, PURCHASE_STATE, PURCHASE_STATE_FILE } from '../worker/purchase-schema.mjs';
@@ -40,7 +40,7 @@ import { rulesIndex } from './studio.mjs';
40
40
 
41
41
  export const COMPAT_DATE = '2026-06-01';
42
42
  /** 4.135.0 or later: Worker Previews (`wrangler preview`, a Durable Object namespace per Preview). */
43
- export const WRANGLER_VERSION = '4.145.0';
43
+ export const WRANGLER_VERSION = '4.150.0';
44
44
  /**
45
45
  * The TypeScript a new studio asks for, so `build --types` (and an editor) has a compiler: the studio's own
46
46
  * devDependency, never one of the toolkit's (a studio that never checks types pays nothing for it at runtime).
@@ -112,11 +112,28 @@ ${JSON.stringify(config, null, 2)}
112
112
  `;
113
113
  }
114
114
 
115
- export function agentsMd({ name, slug }) {
115
+ export function agentsMd({ name, slug, kind = 'game' }) {
116
+ if (kind !== 'game') return `# ${name}
117
+
118
+ This is **${name}** (\`${slug}\`), a ${kind === 'cause' ? 'cause' : 'business'} studio. Its apps, pages and shop run on its own Cloudflare account.
119
+
120
+ Read \`node_modules/@homie-rocks/studio/guides/app/SKILL.md\` and the shared \`guides/plan/SKILL.md\` first. Capture the owner’s exact dream, scene, people, offers and demonstrable needs in \`apps/<id>/CODEX.md\` (\`codex new <id> --kind app\`). A starter is plumbing, never the deliverable. Build their scene with the engine packages, style from their words, parts_find and appropriate art/models/sound/music. Fix build failures without flattening the scene. End with every Codex need shown on wall and phone and judged through the playtest guide. Proceed from the owner's request through building and checking; ask for an interview only when requested or when essential information is missing.
121
+
122
+ - \`apps/<id>/app.json\` defines the app's words, roles, records and surfaces; \`src/view.ts\` draws it.
123
+ - \`site/pages/<path>/index.html\` adds a page and a header link. A page at an app's id replaces its landing, while \`/<id>/open\` and \`/<id>/tv\` remain the phone and wall entrances.
124
+ - \`shop.json\` defines products, services or gifts. Local dev previews items before Stripe is connected.
125
+ - \`studio.json\` names the organisation and its currency. \`site/theme.json\` sets its colours and fonts.
126
+
127
+ Start with \`homie-studio app new <id> --from welcome|gathering|giving|quiz|jobs\`, then \`npm install\`, \`npm run build\`, \`npm run dev\`. Use the printed address; a busy port is skipped. Run \`homie-studio check <id> --url <address> --shots .checks\` and inspect the wall and phone captures.
128
+
129
+ For gifts, use \`homie-studio shop init --giving --currency <currency>\`. The organisation supplies its own receipt/tax wording. The giving wall records intentions, not paid gifts. Service requests are not confirmed reservations.
130
+
131
+ Keep credentials, private staff links and personal records out of source control. Use the studio's own roles and records APIs. Publish only when the owner requests it. Existing authored files belong to the owner; do not overwrite them during upgrades.
132
+ `;
116
133
  return `# ${name}
117
134
 
118
135
  This folder is a studio: **${name}** (\`${slug}\`). It is one repository.
119
- Its games, music, videos and posts live here; its website and public game rooms
136
+ ${kind === 'game' ? 'Its games, music, videos and posts live here;' : 'Its apps and public pages live here. Read node_modules/@homie-rocks/studio/guides/app/SKILL.md first;'} its website and public game rooms
120
137
  run on the studio's **own Cloudflare account** (one Worker, one D1 database and
121
138
  the Table/Lobby Durable Objects, on the studio's Cloudflare account), built
122
139
  from \`@homie-rocks/studio\`, pinned in \`package.json\`. The homie.rocks
@@ -156,6 +173,8 @@ Cloudflare creates DNS and TLS during deploy with the existing sign-in.
156
173
 
157
174
  ## Apps: one screen, roles and parts
158
175
 
176
+ Plan apps with the same Codex mechanism as games: \`codex new <id> --kind app\` writes \`apps/<id>/CODEX.md\`. Read the packaged plan, app and playtest guides. Preserve the person’s words, scene, people, offers and testable needs; build that scene with engines, style, parts and media, then prove each need on wall and phone. Starters are plumbing, not completion.
177
+
159
178
  An app lives in \`apps/<id>/app.json\` and \`src/\`. Make one with \`homie-studio app new <id>\` (or \`app_make\`). Use the Homie plugin's \`app\` skill for businesses, venues, causes and customer apps. No game demo, rounds, scores, bots or mandatory fun: build what the person needs with the fewest human steps.
160
179
 
161
180
  An app is ONE screen that morphs with context: camera moves, panels unfold, its roles see and do different things, and parts plug into the scene. No page-to-page navigation or menus of links inside it. Use all the same engine packages and media skills as games (camera, geom, props, render, postfx, fx, audio, input, ui-world; style, art, models, animate, music, sound, video). It can be fully 3D and wild while remaining useful and legible.
@@ -679,7 +698,7 @@ anything here wins. The whole list is in \`node_modules/@homie-rocks/studio/site
679
698
  | \`theme.json\` | The look: \`bg\`, \`fg\`, \`accent\`, \`glow\` colours, \`display\` and \`text\` fonts (\`fonts\` loads a file from \`public/\`), \`radius\`, \`mark\` (a logo). Or \`palette\`: neon, dock, gold, acid, ember, orchid, tide, candy. |
680
699
  | \`theme.css\` | Extra CSS on every page (restyle anything, the "Made with Homie" footer included). |
681
700
  | \`partials/<name>.html\` | A piece of every page: \`head\`, \`header\`, \`footer\`, \`home\` (a band on Home), \`game\` (a band on every game's landing), \`game-<id>\` (on one game's), \`post\`. |
682
- | \`pages/<path>/index.html\` | A whole page at \`/<path>/\`, instead of the generated one (\`pages/<id>/index.html\` replaces a game's landing) or beside them (\`pages/about/index.html\`). It may borrow \`<!-- homie:style -->\`, \`<!-- homie:header -->\`, \`<!-- homie:footer -->\`, \`<!-- homie:script -->\`, and \`<!-- homie:schema -->\` in its \`<head>\` (the page's structured data for search engines). A Home of your own (\`pages/index.html\`) takes the generated hero with \`<!-- homie:home-hero -->\` (the featured game) or \`<!-- homie:home-hero <id> -->\` (that game); a game's own hero files are served at \`/games/<id>/_landing/<file>\` (\`wide.jpg\`, \`tall.jpg\`, \`wide.mp4\`, \`tall.mp4\`) if a page of yours needs one by address (\`/api/games\` says each exactly, in \`landing.hero\`). |
701
+ | \`pages/<path>/index.html\` | A whole page at \`/<path>/\`, instead of the generated one (\`pages/<id>/index.html\` replaces an app or game landing; the app still opens at \`/<id>/open\` and its wall at \`/<id>/tv\`) or beside them (\`pages/about/index.html\`). It may borrow \`<!-- homie:style -->\`, \`<!-- homie:header -->\`, \`<!-- homie:footer -->\`, \`<!-- homie:script -->\`, and \`<!-- homie:schema -->\` in its \`<head>\` (the page's structured data for search engines). A Home of your own (\`pages/index.html\`) takes the generated hero with \`<!-- homie:home-hero -->\` (the featured game) or \`<!-- homie:home-hero <id> -->\` (that game); a game's own hero files are served at \`/games/<id>/_landing/<file>\` (\`wide.jpg\`, \`tall.jpg\`, \`wide.mp4\`, \`tall.mp4\`) if a page of yours needs one by address (\`/api/games\` says each exactly, in \`landing.hero\`). |
683
702
  | \`public/\` | Files served as they are, at the same path (\`public/fonts/x.woff2\` is \`/fonts/x.woff2\`). A \`robots.txt\`, \`sitemap.xml\` or \`llms.txt\` here replaces the one the site makes. |
684
703
 
685
704
  \`src/worker.mjs\` and \`migrations/\` are the Worker (its config is \`wrangler.jsonc\`, at the studio's root); \`dist/\` is the build (not committed).
@@ -726,10 +745,11 @@ art/**/raw/
726
745
  `;
727
746
 
728
747
  /** Every file a new studio gets, relative to its folder. */
729
- export function studioFiles({ name, slug, homie, template = false }) {
748
+ export function studioFiles({ name, slug, homie, template = false, kind = 'game' }) {
730
749
  const worker = slug;
731
750
  const studio = {
732
751
  name, slug,
752
+ ...(kind !== 'game' ? { kind } : {}),
733
753
  // A copy of the public template (Deploy to Cloudflare) until `homie-studio setup attach` names it for real:
734
754
  // meanwhile its site shows the name the person typed in Cloudflare's form (STUDIO_NAME).
735
755
  ...(template ? { template: true } : {}),
@@ -759,7 +779,7 @@ export function studioFiles({ name, slug, homie, template = false }) {
759
779
  },
760
780
  };
761
781
  const files = {
762
- 'AGENTS.md': agentsMd({ name, slug }),
782
+ 'AGENTS.md': agentsMd({ name, slug, kind }),
763
783
  'CLAUDE.md': claudeMd(),
764
784
  'README.md': template ? templateReadme() : readme({ name }),
765
785
  'HANDOFF.md': HANDOFF_MD,
@@ -775,6 +795,7 @@ export function studioFiles({ name, slug, homie, template = false }) {
775
795
  'posts/README.md': POSTS_README,
776
796
  'site/theme.json': themeFile(slug),
777
797
  'site/README.md': SITE_README,
798
+ 'site/pages/.gitkeep': '',
778
799
  'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1, and R2 once storage is added.
779
800
  // The code is @homie-rocks/studio's, pinned in package.json, so an update never changes a published game by surprise.
780
801
  import { hostRules, useTools } from '@homie-rocks/studio/worker';
@@ -803,6 +824,7 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
803
824
  [`site/migrations/${FUNCTIONS_MIGRATION_FILE}`]: FUNCTIONS_MIGRATION,
804
825
  [`site/migrations/${FUNCTIONS_CURSOR_MIGRATION_FILE}`]: FUNCTIONS_CURSOR_MIGRATION,
805
826
  [`site/migrations/${FUNCTIONS_META_MIGRATION_FILE}`]: FUNCTIONS_META_MIGRATION,
827
+ [`site/migrations/${FUNCTIONS_REFERENCE_MIGRATION_FILE}`]: FUNCTIONS_REFERENCE_MIGRATION,
806
828
  [`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
807
829
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
808
830
  '.claude/skills/.gitkeep': '',
@@ -974,6 +996,11 @@ function ensurePartsStateMigration(root) {
974
996
  return `site/migrations/${PURCHASE_STATE_FILE}`;
975
997
  }
976
998
 
999
+ function ensureFunctionReference(root) {
1000
+ const file=join(root,'site','migrations',FUNCTIONS_REFERENCE_MIGRATION_FILE);
1001
+ if(existsSync(file))return null;
1002
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,FUNCTIONS_REFERENCE_MIGRATION);return `site/migrations/${FUNCTIONS_REFERENCE_MIGRATION_FILE}`;
1003
+ }
977
1004
  function ensureFunctionMeta(root) {
978
1005
  const file=join(root,'site','migrations',FUNCTIONS_META_MIGRATION_FILE);
979
1006
  if(existsSync(file))return null;
@@ -995,7 +1022,7 @@ function ensureMcpMigration(root) {
995
1022
  mkdirSync(dirname(file),{recursive:true});writeFileSync(file,MCP_MIGRATION);return `site/migrations/${MCP_MIGRATION_FILE}`;
996
1023
  }
997
1024
  export function ensureMigrations(root) {
998
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root)].filter(Boolean);
1025
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root)].filter(Boolean);
999
1026
  }
1000
1027
 
1001
1028
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
@@ -1019,7 +1046,7 @@ function insideGit(dir) {
1019
1046
  try { return execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() === 'true'; } catch { return false; }
1020
1047
  }
1021
1048
 
1022
- export function newStudio(folder, { name, homie, slug: askedSlug, install = true, template = false } = {}) {
1049
+ export function newStudio(folder, { name, homie, slug: askedSlug, install = true, template = false, kind = 'game' } = {}) {
1023
1050
  if (typeof folder !== 'string' || !folder.trim()) throw new Error('name the folder for the new studio, e.g. `homie-studio new ~/studios/night-owls --name "Night Owls"`. Nothing was written.');
1024
1051
  if (!name || !String(name).trim()) throw new Error('give the studio a name: --name "Night Owls". Nothing was written.');
1025
1052
  const dir = resolve(folder.replace(/^~(?=\/|$)/, homedir()));
@@ -1034,7 +1061,7 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
1034
1061
  if (entries.length) throw new Error(`${real} is not empty (${entries.slice(0, 5).join(', ')}${entries.length > 5 ? ', …' : ''}). A new studio goes in a new or empty folder. Nothing was written.`);
1035
1062
  }
1036
1063
  const slug = slugify(askedSlug || name);
1037
- const files = studioFiles({ name: String(name).trim(), slug, homie: homie || 'https://homie.rocks', template });
1064
+ const files = studioFiles({ name: String(name).trim(), slug, homie: homie || 'https://homie.rocks', template, kind });
1038
1065
  mkdirSync(dir, { recursive: true });
1039
1066
  const wrote = [];
1040
1067
  for (const [rel, content] of Object.entries(files)) {
@@ -1058,7 +1085,14 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
1058
1085
  } catch (error) { installed = `npm install failed (${String(error.stderr ?? error.message).trim().split('\n').slice(-2).join(' ')}): run it in the studio folder`; }
1059
1086
  }
1060
1087
  const q = JSON.stringify(realpathSync(dir));
1061
- return { ok: true, command: 'new', dir: realpathSync(dir), name, slug, wrote, git, installed, studio: STUDIO_VERSION, next: [
1088
+ return { ok: true, command: 'new', dir: realpathSync(dir), name, slug, wrote, git, installed, studio: STUDIO_VERSION, next: kind !== 'game' ? [
1089
+ `cd ${q}`,
1090
+ 'Read the app and shared plan guides. Create apps/<id>/CODEX.md with codex new <id> --kind app; capture their words, scene, people, offers and testable needs. Proceed without an engineering interview.',
1091
+ 'npx --no-install homie-studio app new <id> --from welcome --name "<Name>"',
1092
+ 'Starter = plumbing only. Install dependencies, style init from their words, parts find, design and build their living scene; repair failures without flattening it. npm run build; npm run dev (use the printed address)',
1093
+ 'npx --no-install homie-studio check <id> --url <printed address> --shots .checks',
1094
+ 'The phone opens /<id>/open; the wall /<id>/tv. Publish when requested.',
1095
+ ] : [
1062
1096
  `cd ${q}${install ? '' : ' && npm install'}`,
1063
1097
  'read the game skill and RULES.md; choose sensible defaults and make the requested game as server rules plus view',
1064
1098
  'optional live example, without copying a game here: npx --no-install homie-studio demo',
@@ -39,7 +39,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
39
39
  else if (!same(product.metadata, metadata) || product.name !== fields.name || (product.description ?? '') !== fields.description || (product.tax_code?.id ?? product.tax_code) !== fields.tax_code || !product.active) await api('POST', `/v1/products/${id}`, fields);
40
40
  // Omit an unset minimum: Stripe applies its settlement-currency floor itself.
41
41
  // https://docs.stripe.com/api/prices/create#create_price-custom_unit_amount-minimum
42
- const priceFields = { product: id, currency: shop.currency, tax_behavior: 'exclusive', ...(item.price === 'choose'
42
+ const priceFields = { product: id, currency: shop.currency, ...(item.interval ? { recurring: { interval: item.interval } } : {}), tax_behavior: 'exclusive', ...(item.price === 'choose'
43
43
  ? { custom_unit_amount: { enabled: true, ...(item.min > 0 ? { minimum: item.min } : {}), ...(item.max !== null ? { maximum: item.max } : {}) } }
44
44
  : { unit_amount: item.price }) };
45
45
  const revision = hash(priceFields);
@@ -52,7 +52,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
52
52
  const params = { line_items: [{ price: price.id, quantity: 1, ...(item.price !== 'choose' ? { adjustable_quantity: { enabled: shop.policy.repeatPurchases, minimum: 1, maximum: 999999 } } : {}) }],
53
53
  after_completion: { type: 'redirect', redirect: { url: `${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}` } },
54
54
  ...(shop.till === 'stripe-managed' ? { managed_payments: { enabled: true } } : { automatic_tax: { enabled: shop.automaticTax } }),
55
- payment_intent_data: { metadata },
55
+ ...(item.interval ? { subscription_data: { metadata } } : { payment_intent_data: { metadata } }),
56
56
  };
57
57
  const linkRevision = hash({ params, item, policy: shop.policy });
58
58
  let link = links.find((p) => own(p) && p.metadata?.revision === linkRevision);
package/lib/shop.mjs CHANGED
@@ -107,13 +107,15 @@ export function supporterItem({ price = 500, days = 365, name = 'Supporter' } =
107
107
  return { id: 'supporter', kind: 'supporter', name, price, days, gives: ['badge:supporter'], badge: 'Supporter', blurb: 'A Supporter badge on your account and beside your name in rooms, for a year. It changes nothing about how any game plays.' };
108
108
  }
109
109
 
110
- export function shopInit(root, { supporter = false, currency = 'usd', price = 500, managed = false } = {}) {
110
+ export function shopInit(root, { supporter = false, giving = false, currency = null, price = 500, managed = false } = {}) {
111
111
  const file = join(root, SHOP_FILE);
112
112
  if (existsSync(file)) return { ok: false, command: 'shop init', why: 'shop.json is here already: change it, then homie-studio shop check' };
113
113
  const studio = readStudio(root);
114
+ if (giving && !currency && !studio.currency) return { ok: false, command: 'shop init', why: 'Set studio.json currency or give --currency for gifts.' };
114
115
  const shop = {
115
- till: managed ? 'stripe-managed' : 'stripe', currency: String(currency).toLowerCase(),
116
- items: supporter ? [supporterItem({ price: Number(price) })] : [],
116
+ ...(giving ? { giving: { receiptText: '' } } : {}),
117
+ till: managed ? 'stripe-managed' : 'stripe', currency: String(currency ?? studio.currency ?? 'usd').toLowerCase(),
118
+ items: giving ? [1000, 2500, 5000].flatMap(amount => [false, true].map(monthly => ({ id: `gift-${amount}${monthly ? '-monthly' : ''}`, kind: 'donation', name: monthly ? 'Monthly gift' : 'One-time gift', price: amount, ...(monthly ? { interval: 'month' } : {}), gives: [], blurb: monthly ? 'Give this amount each month. Manage your gift through Stripe.' : 'A gift to support our work.' }))) : supporter ? [supporterItem({ price: Number(price) })] : [],
117
119
  };
118
120
  const r = checkShop(shop, { games: listGames(root).map((g) => g.id) });
119
121
  if (!r.ok) return { ok: false, command: 'shop init', why: r.errors.map((e) => e.message).join('; ') };
package/lib/studio.mjs CHANGED
@@ -90,7 +90,7 @@ export function listExperiences(root) {
90
90
  for (const row of rows) { if (ids.has(row.id)) throw new Error(`app/game id collision: ${row.id}`); ids.add(row.id); }
91
91
  return rows;
92
92
  }
93
- export const experienceDir = (root, id) => join(root, existsSync(join(root, 'apps', id, 'app.json')) ? 'apps' : 'games', id);
93
+ export const experienceDir = (root, id) => join(root, (existsSync(join(root, 'apps', id, 'app.json')) || existsSync(join(root, 'apps', id, 'CODEX.md'))) ? 'apps' : 'games', id);
94
94
  export const experienceFile = (root, id) => join(experienceDir(root, id), existsSync(join(root, 'apps', id, 'app.json')) ? 'app.json' : 'game.json');
95
95
  function listKind(root, kind) {
96
96
  const dir = join(root, `${kind}s`);
@@ -300,16 +300,17 @@ export function addNeeds(root, needs) {
300
300
  }
301
301
 
302
302
 
303
- export async function newApp(root, id, { name } = {}) {
303
+ export async function newApp(root, id, { name, from = 'welcome' } = {}) {
304
304
  if (!GAME_ID.test(String(id ?? '')) || RESERVED_IDS.has(id)) throw new Error('invalid app id');
305
- if (listExperiences(root).some((g) => g.id === id) || existsSync(join(root, 'apps', id))) throw new Error(`app/game ${id} already exists`);
305
+ if (listExperiences(root).some((g) => g.id === id) || (existsSync(join(root, 'apps', id)) && readdirSync(join(root, 'apps', id)).some(f => !['CODEX.md', 'codex', 'style.json'].includes(f)))) throw new Error(`app/game ${id} already exists`);
306
306
  const dest = join(root, 'apps', id);
307
- cpSync(join(PACKAGE_ROOT, 'app-starters', 'welcome'), dest, { recursive: true });
307
+ if (!['welcome', 'giving', 'gathering', 'quiz', 'jobs'].includes(from)) throw new Error('app starter: welcome, giving, gathering, quiz or jobs');
308
+ cpSync(join(PACKAGE_ROOT, 'app-starters', from), dest, { recursive: true });
308
309
  const file = join(dest, 'app.json');
309
310
  const meta = JSON.parse(readFileSync(file, 'utf8'));
310
- meta.id = id; meta.name = String(name ?? 'Welcome together').slice(0, 60);
311
+ meta.id = id; meta.name = String(name ?? meta.name).slice(0, 60);
311
312
  writeFileSync(file, `${JSON.stringify(meta, null, 2)}\n`);
312
313
  const main = join(dest, meta.entry ?? 'src/main.ts');
313
314
  writeFileSync(main, readFileSync(main, 'utf8').replaceAll("game: 'welcome'", `game: '${id}'`));
314
- return { ok: true, command: 'app new', id, dir: dest, files: readdirSync(dest, { recursive: true }).map(String), ...addNeeds(root, meta.needs) };
315
+ return { ok: true, command: 'app new', id, from, dir: dest, files: readdirSync(dest, { recursive: true }).map(String), ...addNeeds(root, meta.needs), next: ['This starter is plumbing: roles, records, room and a check. It is not the deliverable.', 'Read guides/plan/SKILL.md and guides/app/SKILL.md in the installed toolkit. Capture the person’s scene, people, offers and demonstrable needs in apps/' + id + '/CODEX.md. Build that scene with engines, style and shared parts; repair build failures without flattening it.', 'Install reported dependencies, build, and prove every Codex need on wall and phone before calling it done.'] };
315
316
  }
@@ -3111,6 +3111,90 @@
3111
3111
  }
3112
3112
  }
3113
3113
  }
3114
+ },
3115
+ "0.49.0": {
3116
+ "files": {
3117
+ "CLAUDE.md": "ddf2b2a0376202f6",
3118
+ "README.md": "684b3cca8001673c",
3119
+ "HANDOFF.md": "29337e2ace95b2e7",
3120
+ "games/README.md": "5ad32fe80c61f117",
3121
+ "music/README.md": "a3dc042dadd39df8",
3122
+ "videos/README.md": "16babc3437d5545b",
3123
+ "posts/README.md": "37841aae476bd540",
3124
+ "site/README.md": "1dbdf22c03a1e505",
3125
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3126
+ },
3127
+ "sections": {
3128
+ "AGENTS.md": {
3129
+ "(intro)": "394cbbe934e80722",
3130
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3131
+ "## Layout": "f3a280b8c96cd068",
3132
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
3133
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3134
+ "## The site": "0cb8dd9a6f8a0096",
3135
+ "## Making games": "bb715272fd1a615b",
3136
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3137
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3138
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3139
+ "## Servers and AI seats": "1ccfd549d2369a60",
3140
+ "## Selling things (the shop)": "9e020d69dd6a954e",
3141
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3142
+ "## Rules": "b2234d80f6cbe71d",
3143
+ "## Telling Homie": "80b46fdd9e578db8",
3144
+ "## Beta": "9d02638207a26e73"
3145
+ }
3146
+ },
3147
+ "named": {
3148
+ "Homie Arcade": {
3149
+ "sections": {
3150
+ "AGENTS.md": {
3151
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3152
+ }
3153
+ }
3154
+ }
3155
+ }
3156
+ },
3157
+ "0.50.0": {
3158
+ "files": {
3159
+ "CLAUDE.md": "ddf2b2a0376202f6",
3160
+ "README.md": "684b3cca8001673c",
3161
+ "HANDOFF.md": "29337e2ace95b2e7",
3162
+ "games/README.md": "5ad32fe80c61f117",
3163
+ "music/README.md": "a3dc042dadd39df8",
3164
+ "videos/README.md": "16babc3437d5545b",
3165
+ "posts/README.md": "37841aae476bd540",
3166
+ "site/README.md": "1dbdf22c03a1e505",
3167
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3168
+ },
3169
+ "sections": {
3170
+ "AGENTS.md": {
3171
+ "(intro)": "394cbbe934e80722",
3172
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3173
+ "## Layout": "f3a280b8c96cd068",
3174
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3175
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3176
+ "## The site": "0cb8dd9a6f8a0096",
3177
+ "## Making games": "bb715272fd1a615b",
3178
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3179
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3180
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3181
+ "## Servers and AI seats": "1ccfd549d2369a60",
3182
+ "## Selling things (the shop)": "9e020d69dd6a954e",
3183
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3184
+ "## Rules": "b2234d80f6cbe71d",
3185
+ "## Telling Homie": "80b46fdd9e578db8",
3186
+ "## Beta": "9d02638207a26e73"
3187
+ }
3188
+ },
3189
+ "named": {
3190
+ "Homie Arcade": {
3191
+ "sections": {
3192
+ "AGENTS.md": {
3193
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3194
+ }
3195
+ }
3196
+ }
3197
+ }
3114
3198
  }
3115
3199
  }
3116
3200
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.48.4",
3
+ "version": "0.50.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -141,11 +141,11 @@
141
141
  "@gltf-transform/extensions": "4.5.1",
142
142
  "@gltf-transform/functions": "4.5.1",
143
143
  "@jridgewell/trace-mapping": "0.3.31",
144
- "@modelcontextprotocol/sdk": "1.30.0",
145
- "@modelcontextprotocol/server": "2.0.0",
144
+ "@modelcontextprotocol/sdk": "1.32.1",
145
+ "@modelcontextprotocol/server": "2.3.1",
146
146
  "@x402/core": "2.28.0",
147
147
  "@x402/evm": "2.28.0",
148
- "agents": "0.28.0",
148
+ "agents": "0.28.1",
149
149
  "esbuild": "0.28.1",
150
150
  "gltf-validator": "2.0.0-dev.3.10",
151
151
  "jose": "6.2.12",
package/shop/SHOP.md CHANGED
@@ -215,3 +215,7 @@ itemButton.onclick = () => shop.open('supporter');
215
215
  ```
216
216
 
217
217
  These calls open the same shop in games and apps. Inside a player card, purchases leave for the studio's shop; ordinary play/open pages start Stripe checkout. The studio's TV and kids policies still apply.
218
+
219
+ ## App payment references
220
+
221
+ Create the booking/ticket/gift intention first. `shop.buy('full-tune', 1, undefined, { reference: booking.id })` or `shop.checkout({ reference: booking.id })` sends the app's own reference through checkout. Existing boolean returns are unchanged: checkout opened is not paid. The verified durable `order.paid` event carries `data.order`, `data.reference` (null if omitted) and `data.app`. Reopened Payment Links retain the reference. A reference is buyer-supplied correlation, not authorization: verify the record, app, amount and currency, deduplicate by event/order ID, then fulfil it. Keep personal data out of references. Before Stripe is connected, local prices and unpaid requests can be demonstrated; actual payment and recurring collection remain unverified. Test events must be labelled simulations.
package/shop/shop.ts CHANGED
@@ -61,8 +61,8 @@ export interface Shop {
61
61
  add(item: string, quantity?: number, amount?: number): void;
62
62
  cart(): CartLine[];
63
63
  clear(): void;
64
- checkout(): Promise<boolean>;
65
- buy(item: string, quantity?: number, amount?: number): Promise<boolean>;
64
+ checkout(options?: { reference?: string }): Promise<boolean>;
65
+ buy(item: string, quantity?: number, amount?: number, options?: { reference?: string }): Promise<boolean>;
66
66
  /** Mark an item used; the studio's refundUsedItems policy decides whether self-service refunds still apply. */
67
67
  used(key: string): Promise<boolean>;
68
68
  on(ev: 'change', fn: (owns: string[]) => void): () => void;
@@ -129,8 +129,8 @@ export function createShop(opts: ShopOptions = {}): Shop {
129
129
  add: (item, quantity = 1, amount) => { cart.push({ item, quantity, ...(amount === undefined ? {} : { amount }) }); },
130
130
  cart: () => cart.map((line) => ({ ...line })),
131
131
  clear: () => { cart = []; },
132
- checkout: async () => { const ok = Boolean((await send({ op: 'checkout', lines: cart })).ok); if (ok) cart = []; return ok; },
133
- buy: async (item, quantity = 1, amount) => Boolean((await send({ op: 'checkout', lines: [{ item, quantity, ...(amount === undefined ? {} : { amount }) }] })).ok),
132
+ checkout: async (options = {}) => { const ok = Boolean((await send({ op: 'checkout', lines: cart, reference: options.reference })).ok); if (ok) cart = []; return ok; },
133
+ buy: async (item, quantity = 1, amount, options = {}) => Boolean((await send({ op: 'checkout', reference: options.reference, lines: [{ item, quantity, ...(amount === undefined ? {} : { amount }) }] })).ok),
134
134
  close: () => { void send({ op: 'close' }); },
135
135
  used: async (key) => (KEY.test(String(key)) && owns.includes(String(key)) ? Boolean((await send({ op: 'used', key })).ok) : false),
136
136
  on: ((ev: ShopEvent, fn: (x: unknown) => void) => {
@@ -21,7 +21,7 @@ export function appProblems(meta) {
21
21
  for (const [field, spec] of Object.entries(c.fields)) if (!key.test(field) || !object(spec) || !['string', 'number', 'boolean'].includes(spec.type) || (spec.enum && (!Array.isArray(spec.enum) || !spec.enum.length))) bad.push(`collection ${id}.${field}: invalid field`);
22
22
  }
23
23
  for (const r of Object.values(roles)) for (const cap of Array.isArray(r?.can) ? r.can : []) if (!Object.hasOwn(meta.records?.collections ?? {}, String(cap).split(':')[1])) bad.push(`unknown collection in ${cap}`);
24
- for (const [k, v] of Object.entries(meta.words ?? {})) if (!Object.hasOwn(APP_WORDS, k) || typeof v !== 'string' || !v.trim() || v.length > 60) bad.push(`words.${k}: use a short label`);
24
+ for (const [k, v] of Object.entries(meta.words ?? {})) if (!/^[a-z][a-zA-Z0-9-]*$/.test(k) || typeof v !== 'string' || v.length > 500) bad.push(`words.${k}: use a short label`);
25
25
  return bad;
26
26
  }
27
27
  export const appWords = (g) => g?.kind === 'app' ? { ...APP_WORDS, ...g.words } : { open: 'Play', join: 'Scan to play', one: 'player', many: 'players' };
@@ -1,2 +1,2 @@
1
1
  // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
- export const STUDIO_BUILD = Object.freeze({"version":"0.48.4","sha256":"5a637b57c0a41bb68ece5691250f4f8e7f646d489608a0a2721f571053602b2f"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.50.0","sha256":"817f88f291b3f1dc92e51bb54784034debeabc202a269a2466a1efc2ef07d292"});
@@ -69,3 +69,18 @@ ELSE 0 END BEGIN
69
69
  SELECT 'refund:' || json_extract(NEW.value,'$.id'),'payment.refunded',
70
70
  json_object('refund',json_extract(NEW.value,'$.id'),'payment',json_extract(NEW.value,'$.payment_intent'),'amount',json_extract(NEW.value,'$.amount'),'currency',json_extract(NEW.value,'$.currency')),json_extract(NEW.value,'$.at')*1000;
71
71
  END;`).join('\n');
72
+
73
+ /** Application correlation is saved before payment; only the paid transition emits it. */
74
+ export const FUNCTIONS_REFERENCE_MIGRATION_FILE = '0021_app_payment_reference.sql';
75
+ export const FUNCTIONS_REFERENCE_MIGRATION = `
76
+ DROP TRIGGER IF EXISTS shop_orders_function_paid;
77
+ CREATE TRIGGER shop_orders_function_paid AFTER UPDATE ON shop_orders
78
+ WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='order.paid')
79
+ AND OLD.paid_at IS NULL AND NEW.paid_at IS NOT NULL
80
+ AND NOT EXISTS (SELECT 1 FROM purchase_orders WHERE id=NEW.id) BEGIN
81
+ INSERT OR IGNORE INTO studio_events(id,type,data,at)
82
+ VALUES('shop_orders:' || NEW.id || ':paid','order.paid',
83
+ json_object('order',NEW.id,'source','shop_orders','amount',NEW.amount,'currency',NEW.currency,'mode',NEW.mode,
84
+ 'app',NEW.game,'reference',(SELECT value FROM meta WHERE key='shop-reference:' || NEW.id)),NEW.paid_at);
85
+ END;
86
+ `;