@homie-rocks/studio 0.36.0 → 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 (63) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +4 -2
  3. package/app-starters/welcome/app.json +106 -0
  4. package/app-starters/welcome/index.html +4 -0
  5. package/app-starters/welcome/src/main.ts +107 -0
  6. package/apps/APPS.md +61 -0
  7. package/apps/apps.ts +28 -0
  8. package/bin/homie-studio.mjs +13 -4
  9. package/lib/art-cli.mjs +2 -2
  10. package/lib/art-tools.mjs +6 -6
  11. package/lib/asset-check.mjs +3 -2
  12. package/lib/asset-import.mjs +3 -2
  13. package/lib/asset-manifest.mjs +8 -8
  14. package/lib/build.mjs +6 -3
  15. package/lib/characters.mjs +7 -7
  16. package/lib/check.mjs +49 -0
  17. package/lib/cloudflare.mjs +1 -1
  18. package/lib/codex.mjs +2 -2
  19. package/lib/collision.mjs +1 -1
  20. package/lib/decisions.mjs +7 -7
  21. package/lib/dev.mjs +26 -9
  22. package/lib/directory.mjs +1 -1
  23. package/lib/doctor.mjs +1 -1
  24. package/lib/handoff.mjs +1 -1
  25. package/lib/lab-check.mjs +1 -1
  26. package/lib/lab.mjs +8 -8
  27. package/lib/library.mjs +2 -2
  28. package/lib/mcp-tools.mjs +22 -3
  29. package/lib/parts-build.mjs +4 -3
  30. package/lib/parts-cli.mjs +3 -3
  31. package/lib/parts-store.mjs +8 -7
  32. package/lib/parts-tools.mjs +7 -7
  33. package/lib/parts.mjs +19 -14
  34. package/lib/perf.mjs +2 -1
  35. package/lib/player-image.mjs +2 -2
  36. package/lib/scaffold.mjs +21 -2
  37. package/lib/shop.mjs +1 -1
  38. package/lib/standalone-files.mjs +2 -2
  39. package/lib/standalone.mjs +7 -2
  40. package/lib/stats.mjs +2 -1
  41. package/lib/studio.mjs +32 -6
  42. package/lib/style-board.mjs +10 -9
  43. package/lib/template-history.json +41 -0
  44. package/links/links.d.ts +3 -0
  45. package/links/links.mjs +16 -0
  46. package/package.json +14 -3
  47. package/parts/PARTS.md +10 -5
  48. package/shop/SHOP.md +13 -0
  49. package/site/SITE.md +1 -1
  50. package/worker/account-page.mjs +4 -3
  51. package/worker/app-format.mjs +42 -0
  52. package/worker/app-records.mjs +100 -0
  53. package/worker/app-shell.mjs +29 -0
  54. package/worker/embed.mjs +4 -2
  55. package/worker/index.mjs +33 -9
  56. package/worker/pages.mjs +33 -27
  57. package/worker/parts.mjs +11 -9
  58. package/worker/schema.mjs +6 -5
  59. package/worker/seats.mjs +2 -2
  60. package/worker/shop-page.mjs +4 -3
  61. package/worker/site.mjs +49 -40
  62. package/worker/standalone.mjs +2 -2
  63. package/worker/version.mjs +1 -1
@@ -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 ?? '');
package/lib/parts.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { experienceDir, experienceFile } from './studio.mjs';
1
2
  /**
2
3
  * GAME PARTS: THE FORMAT AND ITS CHECKER (parts/PARTS.md is the design; this file holds it to its word).
3
4
  *
@@ -24,7 +25,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, r
24
25
  import { dirname, join, relative } from 'node:path';
25
26
 
26
27
  export const PART_ID = /^[a-z0-9][a-z0-9-]{0,47}$/;
27
- export const PART_KINDS = Object.freeze(['character', 'rig', 'clips', 'environment', 'effect', 'sound', 'ui', 'mechanic', 'shader', 'level-generator', 'bot-brain', 'audio-pack', 'set-piece']);
28
+ export const PART_KINDS = Object.freeze(['character', 'rig', 'clips', 'environment', 'effect', 'sound', 'ui', 'mechanic', 'shader', 'level-generator', 'bot-brain', 'audio-pack', 'set-piece', 'quiz', 'menu-board', 'waitlist', 'live-total', 'loop', 'stem', 'track', 'video-intro', 'video-template']);
28
29
  export const NETPLAY_KINDS = Object.freeze(['host-authoritative', 'replicated', 'local']);
29
30
  export const PROVENANCE = Object.freeze(['original', 'generated', 'imported']);
30
31
  export const PIVOTS = Object.freeze(['feet', 'centre', 'base', 'origin']);
@@ -315,8 +316,9 @@ export function checkPart(part, { id = null } = {}) {
315
316
  if (!isObj(part.cost)) bad('cost', 'it is not an object', '{ "triangles": 0, "drawCalls": 0, "textureMB": 0, "bytes": 0, "measuredOn": "…" }');
316
317
  else for (const k of ['triangles', 'drawCalls', 'textureMB', 'bytes']) if (part.cost[k] !== undefined && !(Number.isFinite(part.cost[k]) && part.cost[k] >= 0)) bad(`cost.${k}`, 'it is not a number of 0 or more', 'leave out a cost nobody measured');
317
318
  }
319
+ if (part.uses !== undefined && (!Array.isArray(part.uses) || part.uses.some((u) => !['game', 'app', 'venue', 'cause', 'music', 'video'].includes(u)))) bad('uses', 'uses must name game, app, venue, cause, music or video');
318
320
  if (part.from !== undefined && part.from !== null) {
319
- if (!isObj(part.from) || (part.from.game != null && !/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(part.from.game)))) bad('from', 'it does not name the game the part came out of', '{ "game": "<the game\'s id>", "studio": "<the studio\'s name>" }');
321
+ if (!isObj(part.from) || ((part.from.game ?? part.from.app ?? part.from.music ?? part.from.video) != null && !/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(part.from.game ?? part.from.app ?? part.from.music ?? part.from.video)))) bad('from', 'it does not name the game the part came out of', '{ "game": "<the game\'s id>", "studio": "<the studio\'s name>" }');
320
322
  }
321
323
  if (part.provenance !== undefined) {
322
324
  if (!isObj(part.provenance)) bad('provenance', 'it is not an object', '{ "source": "original" }');
@@ -353,14 +355,16 @@ export function requiredParts(part) {
353
355
  */
354
356
  export function assetRecords(root) {
355
357
  const out = new Map();
356
- const games = join(root, 'games');
357
- if (!existsSync(games)) return out;
358
+ for (const folder of ['games', 'apps']) {
359
+ const games = join(root, folder);
360
+ if (!existsSync(games)) continue;
358
361
  for (const e of readdirSync(games, { withFileTypes: true })) {
359
362
  if (!e.isDirectory()) continue;
360
363
  let m = null;
361
364
  try { m = JSON.parse(readFileSync(join(games, e.name, 'assets', 'manifest.json'), 'utf8')); } catch { m = null; }
362
365
  for (const a of Array.isArray(m?.assets) ? m.assets : []) for (const f of a.files ?? []) if (/^[a-f0-9]{64}$/.test(String(f.sha256 ?? ''))) out.set(f.sha256, { game: e.name, asset: a.id, kind: a.license?.kind ?? null, attribution: a.license?.attribution ?? null });
363
366
  }
367
+ }
364
368
  return out;
365
369
  }
366
370
  /** The asset-manifest licence kinds a file may be handed on under (lib/asset-manifest.mjs LICENSES, `redistribute`). */
@@ -520,7 +524,7 @@ function relSpec(fromFile, toFile) {
520
524
  * would point nowhere, and a part that reaches back into one game is not a part another game can use.
521
525
  */
522
526
  function liftPlan(root, game, paths) {
523
- const gdir = join(root, 'games', game);
527
+ const gdir = join(experienceDir(root, game));
524
528
  const files = [];
525
529
  const walk = (rel) => {
526
530
  const abs = join(gdir, rel);
@@ -528,15 +532,15 @@ function liftPlan(root, game, paths) {
528
532
  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);
529
533
  };
530
534
  for (const raw of paths) {
531
- const rel = posix(String(raw)).replace(/^\.\//, '').replace(/^games\/[^/]+\//, '').replace(/\/+$/, '');
535
+ const rel = posix(String(raw)).replace(/^\.\//, '').replace(/^(?:games|apps)\/[^/]+\//, '').replace(/\/+$/, '');
532
536
  if (!rel || rel.split('/').includes('..') || rel.startsWith('/')) throw new Error(`"${raw}" is not a path inside games/${game}`);
533
537
  walk(rel);
534
538
  }
535
539
  if (!files.length) throw new Error(`name the files of games/${game} that become the part (src/creature.ts, or a folder)`);
536
540
  // A part is a piece of a game, never the whole game: its entry, its page and its game.json stay the game's.
537
541
  let entry = 'src/main.ts';
538
- try { entry = JSON.parse(readFileSync(join(gdir, 'game.json'), 'utf8')).entry ?? entry; } catch { /* the default entry */ }
539
- const whole = files.filter((f) => f === entry || f === 'game.json' || f === 'index.html');
542
+ try { entry = JSON.parse(readFileSync(experienceFile(root, game), 'utf8')).entry ?? entry; } catch { /* the default entry */ }
543
+ const whole = files.filter((f) => f === entry || f === 'game.json' || f === 'app.json' || f === 'index.html');
540
544
  if (whole.length) throw new Error(`${whole.join(', ')} ${whole.length === 1 ? 'is' : 'are'} the game itself. A part is a piece of a game, never the whole game: name the files of one piece (the creature, the level generator, the camera).`);
541
545
  // The folder they share (without a leading src/, which every part has of its own).
542
546
  const dirs = files.map((f) => f.split('/').slice(0, -1));
@@ -560,15 +564,16 @@ function liftPlan(root, game, paths) {
560
564
  * `parts new`: a private part with every field a sharer will need, so the gaps are visible from the first minute.
561
565
  * `from` (a game's id) with `paths` lifts those files out of the game (above); without, the part starts empty.
562
566
  */
563
- export function newPart(root, id, { kind = 'mechanic', name = null, from = null, paths = [] } = {}) {
567
+ export function newPart(root, id, { kind = 'mechanic', name = null, from = null, paths = [], uses = [] } = {}) {
564
568
  if (!PART_ID.test(String(id ?? ''))) return { ok: false, command: 'parts new', why: `"${id ?? ''}" is not a part id: lowercase letters, digits and hyphens (chase-camera)` };
565
569
  if (!PART_KINDS.includes(kind)) return { ok: false, command: 'parts new', why: `"${kind}" is not a kind of part: ${PART_KINDS.join(', ')}` };
570
+ if (!Array.isArray(uses) || uses.some((u) => !['game', 'app', 'venue', 'cause', 'music', 'video'].includes(u))) return { ok: false, command: 'parts new', why: 'invalid intended uses' };
566
571
  const dir = partDir(root, id);
567
572
  if (existsSync(dir)) return { ok: false, command: 'parts new', why: `parts/${id} is there already` };
568
573
  const title = clean(name, 80) || id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
569
574
  let entry = null; let lifted = null; let fromGame = null;
570
575
  if (from) {
571
- if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(from)) || !existsSync(join(root, 'games', from, 'game.json'))) return { ok: false, command: 'parts new', why: `there is no game "${from}" in this studio to lift a part out of` };
576
+ 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` };
572
577
  let plan;
573
578
  try { plan = liftPlan(root, from, paths); } catch (error) { return { ok: false, command: 'parts new', why: error.message }; }
574
579
  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 };
@@ -589,7 +594,7 @@ export function newPart(root, id, { kind = 'mechanic', name = null, from = null,
589
594
  entry = mods.find((p) => /^src\/index\.[a-z]+$/.test(p)) ?? mods[0] ?? null;
590
595
  let studio = null;
591
596
  try { studio = JSON.parse(readFileSync(join(root, 'studio.json'), 'utf8')).name ?? null; } catch { studio = null; }
592
- fromGame = { game: from, ...(studio ? { studio: clean(studio, 80) } : {}) };
597
+ fromGame = { [experienceFile(root, from).endsWith('/app.json') ? 'app' : 'game']: from, ...(studio ? { studio: clean(studio, 80) } : {}) };
593
598
  } else {
594
599
  mkdirSync(dir, { recursive: true });
595
600
  if (['mechanic', 'ui', 'effect', 'shader', 'level-generator', 'bot-brain'].includes(kind)) {
@@ -602,7 +607,7 @@ export function newPart(root, id, { kind = 'mechanic', name = null, from = null,
602
607
  mkdirSync(join(dir, 'preview'), { recursive: true });
603
608
  if (!existsSync(join(dir, 'preview', 'index.html'))) writeFileSync(join(dir, 'preview', 'index.html'), PREVIEW_HTML(title));
604
609
  writePart(dir, {
605
- id, name: title, kind, version: '0.1.0', summary: '', license: '', attribution: '', share: false, tags: [],
610
+ id, name: title, kind, version: '0.1.0', summary: '', license: '', attribution: '', share: false, tags: [], ...(uses.length ? { uses } : {}),
606
611
  ...(fromGame ? { from: fromGame } : {}),
607
612
  ...(entry ? { entry } : {}),
608
613
  files: [],
@@ -628,7 +633,7 @@ export function newPart(root, id, { kind = 'mechanic', name = null, from = null,
628
633
 
629
634
  /* ------------------------------------------------------------------ checking a set together */
630
635
 
631
- const creditsFile = (root, game) => join(root, 'games', game, 'credits.json');
636
+ const creditsFile = (root, game) => join(experienceDir(root, game), 'credits.json');
632
637
 
633
638
  /**
634
639
  * THE LICENCES OF THE PARTS A GAME USES, and where they cannot be combined: [{ level: 'conflict' | 'warn' | 'note',
@@ -687,7 +692,7 @@ export function syncPartCredits(root, game, origins) {
687
692
  const keep = (Array.isArray(credits.parts) ? credits.parts : []).filter((p) => typeof p?.part !== 'string');
688
693
  const mine = Object.entries(origins.parts ?? {}).filter(([, e]) => (e.games ?? []).includes(game)).map(([ref, e]) => creditLine(ref, e));
689
694
  if (!mine.length && !existsSync(file)) return null;
690
- if (!existsSync(join(root, 'games', game))) return null;
695
+ if (!existsSync(join(experienceDir(root, game)))) return null;
691
696
  const text = `${JSON.stringify({ ...credits, parts: [...keep, ...mine] }, null, 2)}\n`;
692
697
  if (!existsSync(file) || readFileSync(file, 'utf8') !== text) writeFileSync(file, text);
693
698
  return `games/${game}/credits.json`;
package/lib/perf.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { experienceDir, experienceFile } from './studio.mjs';
1
2
  /**
2
3
  * `homie-studio perf <game> --url <site>` — how fast a game runs, measured the same way every time, so a change can be
3
4
  * judged against the build before it (`perf compare`, lib/perf-stats.mjs).
@@ -681,7 +682,7 @@ export function perfSizes(root, game) {
681
682
  const dir = join(root, 'site', 'dist', 'games', game);
682
683
  if (!existsSync(dir)) return { ok: false, command: 'perf sizes', why: `no build of ${game}: run npm run build first` };
683
684
  let cover = null;
684
- try { cover = JSON.parse(readFileSync(join(root, 'games', game, 'game.json'), 'utf8')).cover ?? null; } catch { /* none */ }
685
+ try { cover = JSON.parse(readFileSync(experienceFile(root, game), 'utf8')).cover ?? null; } catch { /* none */ }
685
686
  const files = listFiles(dir).map((f) => {
686
687
  const buf = readFileSync(join(dir, f));
687
688
  const kind = kindOf(f);
@@ -12,10 +12,10 @@ import { PLAYER_IMAGE, playerEnabled, playerImageRule } from '../worker/embed.mj
12
12
  */
13
13
  export async function playerImage(cat, game, dist, log) {
14
14
  if (!playerEnabled(cat, game)) return null;
15
- const sources = [...new Set([game.landing?.hero?.wideImage, game.landing?.cover,
15
+ const sources = [...new Set([game.landing?.hero?.wideImage, game.landing?.hero?.tallImage, game.landing?.cover,
16
16
  game.cover ? `/games/${game.id}/${game.cover}` : null, cat.studio.theme?.social].filter(Boolean))];
17
17
  const warn = (file, rule) => log(`warning: ${file}: player card needs ${rule}; keeping the picture card unless another image qualifies.`);
18
- if (!sources.length) warn(`games/${game.id}/game.json`, 'a cover or a studio social picture in the built site (starters do not include one)');
18
+ if (!sources.length) warn(`${game.kind === 'app' ? 'apps' : 'games'}/${game.id}/${game.kind === 'app' ? 'app' : 'game'}.json`, 'a cover or a studio social picture in the built site (starters do not include one)');
19
19
  const root = realpathSync(dist);
20
20
  for (const src of sources) {
21
21
  if (!src.startsWith('/') || src.startsWith('//')) { warn(src, 'a local image the build can measure'); continue; }
package/lib/scaffold.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
1
2
  /**
2
3
  * `homie-studio new <folder> --name "<Studio Name>"` — a studio is ONE
3
4
  * monorepo: a visible folder the person opens in Claude or Codex.
@@ -131,6 +132,16 @@ directory lists its games; homie.rocks does not host them.
131
132
  | \`studio.json\` | The studio's name, slug, Cloudflare resource names, custom domain and stats sharing. \`.studio/\` (git-ignored) is this computer's own state. |
132
133
  | \`.claude/skills/\` | Skills only this studio uses. Homie's own skills come from the Homie plugin. |
133
134
 
135
+ ## Apps: one screen, roles and parts
136
+
137
+ 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.
138
+
139
+ 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.
140
+
141
+ Declare roles, surfaces, words and lasting record collections in app.json. Netplay's host is not a staff permission. Use \`@homie-rocks/studio/apps\` for authorized lasting records and \`@homie-rocks/studio/links\` for ticket links, QR and HTTP-safe IDs. Staff use private role links plus existing account grants; keep private records out of public collections. Look for engine mechanisms and shared parts before writing a component. Credit licences; sharing is optional and separate from selling.
142
+
143
+ Build normally, run \`homie-studio dev --lan\`, then \`homie-studio check <id> --url <origin>\`: the app check proves an actual action across a wall and two phones plus reconnect. The public screen is \`/<id>/open\`, the wall \`/<id>/tv\`. Customer apps use the same \`standalone\` build for desktop/iOS/Android; its existing sign-in and store limitations still apply. See \`node_modules/@homie-rocks/studio/apps/APPS.md\`.
144
+
134
145
  ## Commands (all through the pinned CLI in node_modules)
135
146
 
136
147
  Use \`npm run <script>\` or \`npx --no-install homie-studio <command>\`: \`--no-install\` makes sure it is this
@@ -700,6 +711,7 @@ export function studioFiles({ name, slug, homie, template = false }) {
700
711
  'studio.json': `${JSON.stringify(studio, null, 2)}\n`,
701
712
  'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
702
713
  '.gitignore': GITIGNORE,
714
+ 'apps/README.md': 'One folder per app: app.json, index.html and src/. One transforming screen, its roles and its parts. Start with `npx --no-install homie-studio app new <id>`. The app guide explains lasting records, staff sign-in, LAN preview and standalone builds.\n',
703
715
  'games/README.md': 'One folder per game. A new studio has none (its home page says "First game coming soon"). Start one with `npx --no-install homie-studio game new <id> --from gem-rush` when the person asks for a copy of the starter, or once their game is planned.\n',
704
716
  'music/README.md': 'Songs, game scores, loops and stems, one folder each (`music/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/music/<slug>/`. Without storage the site serves files up to 25 MiB itself. With storage (`npx --no-install homie-studio storage add`), the big ones live in the studio\'s R2: `npx --no-install homie-studio media move` and every deploy put them there, checked by SHA-256, at the same address. Never into git.\n',
705
717
  'music/manifest.json': '{ "v": 1, "items": [] }\n',
@@ -728,6 +740,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
728
740
  [`site/migrations/${SHOP_RESERVATIONS_FILE}`]: SHOP_RESERVATIONS,
729
741
  [`site/migrations/${SHOP_STATEMENTS_FILE}`]: SHOP_STATEMENTS,
730
742
  [`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
743
+ [`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
731
744
  [`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
732
745
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
733
746
  '.claude/skills/.gitkeep': '',
@@ -877,13 +890,19 @@ function ensureLoungeMigration(root) {
877
890
  return `site/migrations/${LOUNGE_MIGRATION_FILE}`;
878
891
  }
879
892
 
893
+ function ensureAppsMigration(root) {
894
+ const file = join(root, 'site', 'migrations', APPS_MIGRATION_FILE);
895
+ if (existsSync(file)) return null;
896
+ mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, APPS_MIGRATION);
897
+ return `site/migrations/${APPS_MIGRATION_FILE}`;
898
+ }
880
899
  export function ensureMigrations(root) {
881
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root)].filter(Boolean);
900
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root)].filter(Boolean);
882
901
  }
883
902
 
884
903
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
885
904
  export function migrationWord(file) {
886
- return /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
905
+ return /studio_apps/.test(file) ? 'apps: lasting records and signed-in role grants' : /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
887
906
  }
888
907
 
889
908
  /**
package/lib/shop.mjs CHANGED
@@ -32,7 +32,7 @@ import { dirname, join } from 'node:path';
32
32
  import { fileURLToPath } from 'node:url';
33
33
  import { askedFor, withKey } from './office.mjs';
34
34
  import { runner } from './cloudflare.mjs';
35
- import { listGames, readStudio, siteUrl } from './studio.mjs';
35
+ import { listExperiences as listGames, readStudio, siteUrl } from './studio.mjs';
36
36
  import { SHOP_FILE, audienceOf, checkShop, defaultTaxCode, money } from '../worker/shop-rules.mjs';
37
37
  import {
38
38
  KEY_SHAPE, STRIPE_VERSION, StripeError, WEBHOOK_SECRET_SHAPE, createWebhookEndpoint, expireCheckoutSession, isPermissionError, listWebhookEndpoints, modeOf,
@@ -105,8 +105,8 @@ export function projectPackage(meta, { targets = TARGETS } = {}) {
105
105
  }
106
106
 
107
107
  /** web/config.js: everything the shell knows about the game it frames. `site` '' is a copy that only plays offline. */
108
- export function configScript(meta, { target, site = '', share, colours = null, movement = null, params = {}, room = null }) {
109
- const cfg = { v: 1, game: meta.id, name: meta.name, site: String(site ?? '').replace(/\/+$/, ''), ver: meta.netplayVersion ?? '', ...(movement ? { movement } : {}), params, share, colours, ...(room ? { room } : {}), version: meta.version, target };
108
+ export function configScript(meta, { target, site = '', share, colours = null, movement = null, params = {}, room = null, kind = null }) {
109
+ const cfg = { ...(kind === 'app' ? { kind: 'app' } : {}), v: 1, game: meta.id, name: meta.name, site: String(site ?? '').replace(/\/+$/, ''), ver: meta.netplayVersion ?? '', ...(movement ? { movement } : {}), params, share, colours, ...(room ? { room } : {}), version: meta.version, target };
110
110
  return `window.__HOMIE_APP = ${JSON.stringify(cfg, null, 2).replace(/</g, '\\u003c')};\n`;
111
111
  }
112
112
 
@@ -1,3 +1,4 @@
1
+ import { APP_SHELL_JS } from '../worker/app-shell.mjs';
1
2
  /**
2
3
  * `homie-studio standalone plan|build|run|steam|ci <game>`: A STANDALONE COPY OF A GAME (standalone/STANDALONE.md).
3
4
  *
@@ -30,7 +31,7 @@ import { homedir, tmpdir } from 'node:os';
30
31
  import { dirname, join, relative } from 'node:path';
31
32
  import { build as buildSite, uiOf } from './build.mjs';
32
33
  import { compareVersions } from './changelog.mjs';
33
- import { GAME_ID, PACKAGE_ROOT, isWorkersDev, listGames, readStudio, siteUrl } from './studio.mjs';
34
+ import { GAME_ID, PACKAGE_ROOT, isWorkersDev, listExperiences as listGames, readStudio, siteUrl } from './studio.mjs';
34
35
  import { pinnedVersion } from './upgrade.mjs';
35
36
  import { STUDIO_VERSION } from './version.mjs';
36
37
  import { sharePlaces } from '../worker/pages.mjs';
@@ -402,11 +403,15 @@ export function webBundle(root, game, dir, { meta, target = 'web', site = '', st
402
403
  rmSync(web, { recursive: true, force: true });
403
404
  mkdirSync(web, { recursive: true });
404
405
  for (const f of ['index.html', 'shell.js', 'shell.css']) copyFileSync(join(SHELL, f), join(web, f));
406
+ if (game.kind === 'app') {
407
+ const shell = join(web, 'shell.js');
408
+ writeFileSync(shell, readFileSync(shell, 'utf8') + '\n' + APP_SHELL_JS);
409
+ }
405
410
  const row = (readJson(join(root, 'site', 'dist', 'games.json'))?.games ?? []).find((g) => g.id === game.id);
406
411
  const m = { ...(meta ?? standaloneMeta(studio ?? readStudio(root), game)), ...(row?.room ? { netplayVersion: row.netplay?.version ?? null } : {}) };
407
412
  const rules = readJson(join(built, 'rules.json'));
408
413
  if (rules) for (const file of rules.files ?? []) if (!existsSync(join(built, file))) throw new Error(`The rules build is incomplete: ${file} is missing. Build the game again before making its app.`);
409
- writeFileSync(join(web, 'config.js'), configScript(m, { target, site, share: sharePlaces(game.screen?.share), colours: uiOf(game), movement: game.netplay?.movement ?? game.movement ?? null, room: row?.room ?? null }));
414
+ writeFileSync(join(web, 'config.js'), configScript(m, { target, site, share: sharePlaces(game.screen?.share), colours: uiOf(game), movement: game.netplay?.movement ?? game.movement ?? null, room: row?.room ?? null, ...(game.kind === 'app' ? { kind: 'app', params: { role: game.surfaces.phone } } : {}) }));
410
415
  copyTree(built, join(web, 'game'));
411
416
  const index = join(web, 'game', 'index.html');
412
417
  writeFileSync(index, withNetScript(readFileSync(index, 'utf8')));
package/lib/stats.mjs CHANGED
@@ -13,6 +13,7 @@
13
13
  * itself is never sent to Cloudflare, never written to a file, and lives for its hours only. A studio running
14
14
  * locally (`npm run dev`, a loopback --url) uses the local D1.
15
15
  */
16
+ import { networkInterfaces } from 'node:os';
16
17
  import { createHash, randomBytes } from 'node:crypto';
17
18
  import { runner } from './cloudflare.mjs';
18
19
  import { readStudio, siteUrl, writeStudio } from './studio.mjs';
@@ -21,7 +22,7 @@ const HOUR = 3600_000;
21
22
  const RANGES = new Set(['1d', '7d', '30d', '90d']);
22
23
  const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
23
24
 
24
- export const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
25
+ export const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]', ...Object.values(networkInterfaces()).flat().filter((n) => n?.family === 'IPv4').map((n) => n.address)].includes(new URL(url).hostname); } catch { return false; } };
25
26
 
26
27
  export function siteOf(root, url) {
27
28
  const studio = readStudio(root);
package/lib/studio.mjs CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
 
9
9
  export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
10
  export const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
11
- const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', '_site', '_studio', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts', 'rooms', 'feed', 'account']);
11
+ const RESERVED_IDS = new Set(['api', 'media', 'games', 'apps', 'assets', '_homie', '_site', '_studio', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts', 'rooms', 'feed', 'account']);
12
12
 
13
13
  export function findStudio(from = process.cwd()) {
14
14
  let at = resolve(from);
@@ -82,14 +82,25 @@ export function siteUrl(root, studio = readStudio(root)) {
82
82
  return custom ?? readLocal(root).url ?? cf.url ?? null;
83
83
  }
84
84
 
85
- export function listGames(root) {
86
- const dir = join(root, 'games');
85
+ export function listGames(root) { return listKind(root, 'game'); }
86
+ export function listApps(root) { return listKind(root, 'app'); }
87
+ export function listExperiences(root) {
88
+ const rows = [...listGames(root), ...listApps(root)].sort((a, b) => a.id.localeCompare(b.id));
89
+ const ids = new Set();
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
+ return rows;
92
+ }
93
+ export const experienceDir = (root, id) => join(root, existsSync(join(root, 'apps', id, 'app.json')) ? 'apps' : 'games', id);
94
+ export const experienceFile = (root, id) => join(experienceDir(root, id), existsSync(join(root, 'apps', id, 'app.json')) ? 'app.json' : 'game.json');
95
+ function listKind(root, kind) {
96
+ const dir = join(root, `${kind}s`);
87
97
  if (!existsSync(dir)) return [];
88
98
  return readdirSync(dir, { withFileTypes: true })
89
- .filter((d) => d.isDirectory() && existsSync(join(dir, d.name, 'game.json')))
99
+ .filter((d) => d.isDirectory() && existsSync(join(dir, d.name, `${kind}.json`)))
90
100
  .map((d) => {
91
- const meta = JSON.parse(readFileSync(join(dir, d.name, 'game.json'), 'utf8'));
92
- return { ...meta, id: meta.id ?? d.name, dir: join(dir, d.name) };
101
+ const meta = JSON.parse(readFileSync(join(dir, d.name, `${kind}.json`), 'utf8'));
102
+ if (kind === 'app' && (!GAME_ID.test(d.name) || RESERVED_IDS.has(d.name) || (meta.id !== undefined && meta.id !== d.name))) throw new Error(`apps/${d.name}: id must match its folder and use a free site address`);
103
+ return { ...meta, ...(kind === 'app' ? { kind: 'app' } : {}), id: meta.id ?? d.name, dir: join(dir, d.name) };
93
104
  })
94
105
  .sort((a, b) => a.id.localeCompare(b.id));
95
106
  }
@@ -141,6 +152,7 @@ function renameTakeSaves(dir, from, id) {
141
152
  */
142
153
  export async function newGame(root, id, { from = 'gem-rush', name } = {}) {
143
154
  if (!GAME_ID.test(String(id ?? '')) || RESERVED_IDS.has(id)) throw new Error(`a game id is lowercase letters, digits and hyphens, up to 40, and not one of ${[...RESERVED_IDS].join(', ')} (got ${JSON.stringify(id)})`);
155
+ if (listApps(root).some((a) => a.id === id)) throw new Error(`apps/${id} already owns this address`);
144
156
  const src = join(PACKAGE_ROOT, 'starters', from);
145
157
  if (!existsSync(join(src, 'game.json'))) throw new Error(`no starter "${from}"; starters: ${starters().map((s) => s.id).join(', ')}`);
146
158
  const dest = join(root, 'games', id);
@@ -287,3 +299,17 @@ export function addNeeds(root, needs) {
287
299
  return { needs: Object.fromEntries(want), needsAdded: added, needsHeld: held, installNeeded: added.length > 0 };
288
300
  }
289
301
 
302
+
303
+ export async function newApp(root, id, { name } = {}) {
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`);
306
+ const dest = join(root, 'apps', id);
307
+ cpSync(join(PACKAGE_ROOT, 'app-starters', 'welcome'), dest, { recursive: true });
308
+ const file = join(dest, 'app.json');
309
+ const meta = JSON.parse(readFileSync(file, 'utf8'));
310
+ meta.id = id; meta.name = String(name ?? 'Welcome together').slice(0, 60);
311
+ writeFileSync(file, `${JSON.stringify(meta, null, 2)}\n`);
312
+ const main = join(dest, 'src/main.ts');
313
+ 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
+ }
@@ -1,3 +1,4 @@
1
+ import { experienceDir, experienceFile } from './studio.mjs';
1
2
  /**
2
3
  * THE STYLE BOARD AND THE LINEUP: pictures the engine draws for free (lib/render3d.mjs).
3
4
  *
@@ -77,10 +78,10 @@ export async function styleBoard(root, id, { prompt = '', log = () => {}, librar
77
78
  if (!doc) { initDecisions(root, id, { prompt, path: 'hands-on' }); doc = readDecisions(root, id); }
78
79
  const { directions } = directionsFor(root, id, { prompt: prompt || doc.prompt });
79
80
  let game = {};
80
- try { game = JSON.parse(readFileSync(join(root, 'games', id, 'game.json'), 'utf8')); } catch { game = {}; }
81
+ try { game = JSON.parse(readFileSync(experienceFile(root, id), 'utf8')); } catch { game = {}; }
81
82
  const title = game.name ?? id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
82
83
  const words = [prompt, doc.prompt, (doc.decisions['cast.list']?.value ?? []).map((c) => c.id).join(' ')].join(' ');
83
- const dir = join(root, 'games', id, BOARD_DIR);
84
+ const dir = join(experienceDir(root, id), BOARD_DIR);
84
85
  mkdirSync(dir, { recursive: true });
85
86
  const pieces = {};
86
87
  if (library) for (const d of directions) if (!pieces[d.family]) pieces[d.family] = await libraryPieces(d.family, words, 4, log);
@@ -90,7 +91,7 @@ export async function styleBoard(root, id, { prompt = '', log = () => {}, librar
90
91
  const url = await r.swatch(tokensOf(d.values), pieces[d.family] ?? [], { title });
91
92
  const file = join(dir, `${d.id}-swatch.jpg`);
92
93
  const bytes = await savePicture(url, file);
93
- out.push({ ...d, swatch: relative(join(root, 'games', id), file), bytes, library: (pieces[d.family] ?? []).map((m) => m.id) });
94
+ out.push({ ...d, swatch: relative(experienceDir(root, id), file), bytes, library: (pieces[d.family] ?? []).map((m) => m.id) });
94
95
  }
95
96
  return r.renderer;
96
97
  }, { log });
@@ -124,13 +125,13 @@ export async function recordMood(root, id, dirId, image, { usd = null, receipt =
124
125
  const d = doc?.board?.directions?.find((x) => x.id === dirId);
125
126
  if (!d) throw new Error(`no direction "${dirId}" on the style board (style board ${id} draws it first)`);
126
127
  if (!existsSync(image)) throw new Error(`no picture at ${image}`);
127
- const file = join(root, 'games', id, BOARD_DIR, `${dirId}-mood.jpg`);
128
+ const file = join(experienceDir(root, id), BOARD_DIR, `${dirId}-mood.jpg`);
128
129
  const sharp = (await import('sharp')).default;
129
130
  let q = 82;
130
131
  let out = await sharp(readFileSync(image)).resize(960, 540, { fit: 'cover' }).jpeg({ quality: q, mozjpeg: true }).toBuffer();
131
132
  while (out.byteLength > PICTURE_MAX && q > 50) { q -= 10; out = await sharp(readFileSync(image)).resize(960, 540, { fit: 'cover' }).jpeg({ quality: q, mozjpeg: true }).toBuffer(); }
132
133
  writeFileSync(file, out);
133
- d.mood = { path: relative(join(root, 'games', id), file).split('\\').join('/'), usd: usd === null ? null : Number(usd), receipt, model, label: 'target (painted, not what the game draws)', at: new Date().toISOString() };
134
+ d.mood = { path: relative(experienceDir(root, id), file).split('\\').join('/'), usd: usd === null ? null : Number(usd), receipt, model, label: 'target (painted, not what the game draws)', at: new Date().toISOString() };
134
135
  writeDecisions(root, id, doc);
135
136
  return { ok: true, command: 'style mood', id, direction: dirId, mood: d.mood };
136
137
  }
@@ -143,14 +144,14 @@ export async function addGolden(root, id, image, { from = null } = {}) {
143
144
  doc.golden = doc.golden ?? [];
144
145
  if (doc.golden.length >= 6) throw new Error('six golden images already: remove one first (style golden <id> remove <n>)');
145
146
  const n = doc.golden.reduce((m, g) => Math.max(m, Number(/(\d+)\.jpg$/.exec(g.path)?.[1] ?? 0)), 0) + 1;
146
- const dir = join(root, 'games', id, BOARD_DIR, 'golden');
147
+ const dir = join(experienceDir(root, id), BOARD_DIR, 'golden');
147
148
  mkdirSync(dir, { recursive: true });
148
149
  const file = join(dir, `${n}.jpg`);
149
150
  try {
150
151
  const sharp = (await import('sharp')).default;
151
152
  writeFileSync(file, await sharp(readFileSync(image)).resize(1024, 1024, { fit: 'inside' }).jpeg({ quality: 84, mozjpeg: true }).toBuffer());
152
153
  } catch { if (/\.jpe?g$/i.test(extname(image))) copyFileSync(image, file); else throw new Error('sharp could not read that picture'); }
153
- doc.golden.push({ path: relative(join(root, 'games', id), file).split('\\').join('/'), from: from ?? relative(root, image), approvedBy: 'person', at: new Date().toISOString() });
154
+ doc.golden.push({ path: relative(experienceDir(root, id), file).split('\\').join('/'), from: from ?? relative(root, image), approvedBy: 'person', at: new Date().toISOString() });
154
155
  writeDecisions(root, id, doc);
155
156
  addLatestLine(root, id, `Added golden image ${n} (from ${from ?? relative(root, image)}): every concept is made with it as a reference.`);
156
157
  return { ok: true, command: 'style golden', id, golden: doc.golden };
@@ -183,13 +184,13 @@ export async function assetsLineup(root, id, { log = () => {}, scope = null } =
183
184
  for (const a of manifest.assets) {
184
185
  const f = (a.files ?? []).find((x) => x.role === 'model');
185
186
  if (!f) continue;
186
- const abs = join(root, 'games', id, f.path);
187
+ const abs = join(experienceDir(root, id), f.path);
187
188
  if (!existsSync(abs)) continue;
188
189
  // `inGame`: what the game's code does to it at runtime (drawn at another height, repainted from style.json): the
189
190
  // lineup draws it the same way (the repaint as the board's re-tint, the nearest the game's own swap can be shown).
190
191
  const scale = a.inGame?.heightM && a.measured?.heightM ? a.inGame.heightM / a.measured.heightM : null;
191
192
  // A character stands in its idle pose from its skeleton's clip library (not its bind pose: arms out in a T).
192
- const anims = a.rig?.anims && existsSync(join(root, 'games', id, a.rig.anims)) ? readFileSync(join(root, 'games', id, a.rig.anims)).toString('base64') : null;
193
+ const anims = a.rig?.anims && existsSync(join(experienceDir(root, id), a.rig.anims)) ? readFileSync(join(experienceDir(root, id), a.rig.anims)).toString('base64') : null;
193
194
  models.push({ a, input: modelIn(a.id, readFileSync(abs), { label: a.card ? String(a.card).split('/').pop() : a.id, ...(scale ? { scale } : {}), ...(anims ? { anims, pose: 'idle', poseAt: 0.3 } : {}), ...(a.inGame?.tint ? { tint: lightColours({ sky: a.inGame.tint, ground: a.inGame.tint }, tokens.palette).sky } : a.inGame?.repaint ? { retint: true } : Number(a.inGame?.pull) > 0 ? { pull: Number(a.inGame.pull) } : {}) }), abs });
194
195
  }
195
196
  if (!models.length) return { ok: false, command: 'assets lineup', id, why: `games/${id} has no recorded models yet (assets add, or the models skill)` };
@@ -1989,6 +1989,47 @@
1989
1989
  }
1990
1990
  }
1991
1991
  }
1992
+ },
1993
+ "0.37.0": {
1994
+ "files": {
1995
+ "CLAUDE.md": "ddf2b2a0376202f6",
1996
+ "README.md": "684b3cca8001673c",
1997
+ "HANDOFF.md": "29337e2ace95b2e7",
1998
+ "games/README.md": "5ad32fe80c61f117",
1999
+ "music/README.md": "a3dc042dadd39df8",
2000
+ "videos/README.md": "16babc3437d5545b",
2001
+ "posts/README.md": "37841aae476bd540",
2002
+ "site/README.md": "732894273a32dc3f",
2003
+ "site/src/worker.mjs": "fc90f3609cad18ba"
2004
+ },
2005
+ "sections": {
2006
+ "AGENTS.md": {
2007
+ "(intro)": "214aa46eb3a895d5",
2008
+ "## Layout": "8baf49e4291e7e88",
2009
+ "## Apps: one screen, roles and parts": "4db0abe4cdfac09f",
2010
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
2011
+ "## The site": "0cb8dd9a6f8a0096",
2012
+ "## Making games": "2da7fa74c8884834",
2013
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2014
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2015
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2016
+ "## Servers and AI seats": "98d08705087a33ec",
2017
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2018
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2019
+ "## Rules": "2e6b29afb85dcf7e",
2020
+ "## Telling Homie": "80b46fdd9e578db8",
2021
+ "## Beta": "9d02638207a26e73"
2022
+ }
2023
+ },
2024
+ "named": {
2025
+ "Homie Arcade": {
2026
+ "sections": {
2027
+ "AGENTS.md": {
2028
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
2029
+ }
2030
+ }
2031
+ }
2032
+ }
1992
2033
  }
1993
2034
  }
1994
2035
  }
@@ -0,0 +1,3 @@
1
+ export function randomId(): string;
2
+ export function appLink(address: string, params?: Record<string, string | number | boolean | null | undefined>, base?: string): string;
3
+ export function qrSvg(text: string, options?: { quiet?: number; dark?: string; light?: string; title?: string; minLevel?: 'L' | 'M' | 'Q' | 'H' }): string;
@@ -0,0 +1,16 @@
1
+ /** Public links, QR and HTTP-safe IDs; the encoder is shared with the wall shell. */
2
+ export { qrSvg } from '../worker/qr.mjs';
3
+ export function randomId() {
4
+ const bytes = new Uint8Array(16);
5
+ globalThis.crypto.getRandomValues(bytes);
6
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
7
+ }
8
+ export function appLink(address, params = {}, base = globalThis.location?.href) {
9
+ const url = new URL(address, base);
10
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('links use an HTTP(S) address without credentials');
11
+ for (const [key, value] of Object.entries(params)) {
12
+ if (value === null || value === undefined) url.searchParams.delete(key);
13
+ else url.searchParams.set(key, String(value));
14
+ }
15
+ return url.href;
16
+ }