@homie-rocks/studio 0.36.0 → 0.37.1

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 (67) hide show
  1. package/CHANGELOG.md +34 -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 +19 -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 +13 -4
  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 +8 -8
  24. package/lib/domain.mjs +43 -0
  25. package/lib/handoff.mjs +1 -1
  26. package/lib/jobs.mjs +17 -7
  27. package/lib/lab-check.mjs +1 -1
  28. package/lib/lab.mjs +8 -8
  29. package/lib/library.mjs +2 -2
  30. package/lib/mcp-tools.mjs +71 -21
  31. package/lib/mcp.mjs +6 -11
  32. package/lib/parts-build.mjs +4 -3
  33. package/lib/parts-cli.mjs +3 -3
  34. package/lib/parts-store.mjs +8 -7
  35. package/lib/parts-tools.mjs +7 -7
  36. package/lib/parts.mjs +19 -14
  37. package/lib/perf.mjs +2 -1
  38. package/lib/player-image.mjs +2 -2
  39. package/lib/prepare.mjs +47 -0
  40. package/lib/scaffold.mjs +31 -2
  41. package/lib/shop.mjs +1 -1
  42. package/lib/standalone-files.mjs +2 -2
  43. package/lib/standalone.mjs +7 -2
  44. package/lib/stats.mjs +2 -1
  45. package/lib/studio.mjs +32 -6
  46. package/lib/style-board.mjs +10 -9
  47. package/lib/template-history.json +82 -0
  48. package/links/links.d.ts +3 -0
  49. package/links/links.mjs +16 -0
  50. package/package.json +14 -3
  51. package/parts/PARTS.md +10 -5
  52. package/shop/SHOP.md +13 -0
  53. package/site/SITE.md +1 -1
  54. package/worker/account-page.mjs +4 -3
  55. package/worker/app-format.mjs +42 -0
  56. package/worker/app-records.mjs +100 -0
  57. package/worker/app-shell.mjs +29 -0
  58. package/worker/embed.mjs +4 -2
  59. package/worker/index.mjs +33 -9
  60. package/worker/pages.mjs +33 -27
  61. package/worker/parts.mjs +11 -9
  62. package/worker/schema.mjs +6 -5
  63. package/worker/seats.mjs +2 -2
  64. package/worker/shop-page.mjs +4 -3
  65. package/worker/site.mjs +49 -40
  66. package/worker/standalone.mjs +2 -2
  67. package/worker/version.mjs +1 -1
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; }
@@ -0,0 +1,47 @@
1
+ /** Private Node installation for a Desktop host with a bundled runtime but no system Node.
2
+ * Official archive + SHA-256, no shell, administrator rights or shell profile edits.
3
+ */
4
+ import { createHash } from 'node:crypto';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ export const nodeHome = (home = homedir()) => join(home, '.cache', 'homie-studio', 'node');
11
+ export async function prepareNode({ home = homedir(), platform = process.platform, arch = process.arch, fetchFn = fetch, exec = spawnSync } = {}) {
12
+ const base = nodeHome(home);
13
+ const bin = join(base, platform === 'win32' ? 'node.exe' : 'bin/node');
14
+ if (existsSync(bin)) return { ok: true, bin, installed: false };
15
+ if (!['darwin', 'linux', 'win32'].includes(platform) || !['arm64', 'x64'].includes(arch)) {
16
+ return { ok: false, next: 'Ask the connected AI to install Node.js 22 or newer for this operating system.', open: 'https://nodejs.org/en/download' };
17
+ }
18
+ let scratch;
19
+ try {
20
+ const get = async (url) => {
21
+ const r = await fetchFn(url, { signal: AbortSignal.timeout(15_000) });
22
+ if (!r.ok) throw new Error(`Node download answered ${r.status}`);
23
+ return r;
24
+ };
25
+ const url = 'https://nodejs.org/dist/latest-v22.x';
26
+ const sums = await (await get(`${url}/SHASUMS256.txt`)).text();
27
+ const suffix = `${platform === 'win32' ? 'win' : platform}-${arch}.${platform === 'win32' ? 'zip' : 'tar.gz'}`;
28
+ const entry = sums.split('\n').map((s) => s.trim().split(/\s+/)).find(([hash, file]) => /^[a-f0-9]{64}$/.test(hash) && new RegExp(`^node-v22\\.\\d+\\.\\d+-${suffix.replaceAll('.', '\\.')}$`).test(file ?? ''));
29
+ if (!entry) throw new Error('No supported Node archive in the official checksum list');
30
+ const [hash, file] = entry;
31
+ const data = Buffer.from(await (await get(`https://nodejs.org/dist/${file.match(/^node-(v22\.\d+\.\d+)-/)[1]}/${file}`)).arrayBuffer());
32
+ if (createHash('sha256').update(data).digest('hex') !== hash) throw new Error('Node archive checksum did not match');
33
+ mkdirSync(join(home, '.cache', 'homie-studio'), { recursive: true });
34
+ scratch = mkdtempSync(join(home, '.cache', 'homie-studio', 'node-'));
35
+ const archive = join(scratch, file);
36
+ writeFileSync(archive, data);
37
+ const r = exec('tar', ['-xf', archive, '-C', scratch], { encoding: 'utf8', timeout: 10_000 });
38
+ if (r.status !== 0) throw new Error('Could not unpack Node with the system archive tool');
39
+ const unpacked = join(scratch, file.replace(/\.(tar\.gz|zip)$/, ''));
40
+ const check = exec(join(unpacked, platform === 'win32' ? 'node.exe' : 'bin/node'), ['--version'], { encoding: 'utf8', timeout: 3000 });
41
+ if (check.status !== 0 || !/^v22\./.test(check.stdout ?? '')) throw new Error('Downloaded Node did not start');
42
+ renameSync(unpacked, base);
43
+ return { ok: true, bin, installed: true };
44
+ } catch (error) {
45
+ return { ok: false, why: error.message, next: 'AI: retry setup_prepare; if the network still refuses, use the Node installer.', open: 'https://nodejs.org/en/download' };
46
+ } finally { if (scratch) rmSync(scratch, { recursive: true, force: true }); }
47
+ }
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,26 @@ 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
+ The person describes an outcome; you carry it through setup, building and checks without a tutorial
136
+ or a planning interview. Choose engineering and design defaults from their words and record them
137
+ in the codex. Use "My Studio" when unnamed, phones and computers, local sound and CC0 art.
138
+ They can change anything by asking. A business or charity may want an app, not a game.
139
+ Install missing prerequisites yourself; never ask the person to run a command or handle a key,
140
+ account id, zone id or setting. Honour an existing request to go live or list, subject to host holds.
141
+ For a requested custom hostname, add its custom_domain route in Wrangler along with cloudflare.domain;
142
+ Cloudflare creates DNS and TLS during deploy with the existing sign-in.
143
+
144
+
145
+ ## Apps: one screen, roles and parts
146
+
147
+ 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.
148
+
149
+ 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.
150
+
151
+ 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.
152
+
153
+ 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\`.
154
+
134
155
  ## Commands (all through the pinned CLI in node_modules)
135
156
 
136
157
  Use \`npm run <script>\` or \`npx --no-install homie-studio <command>\`: \`--no-install\` makes sure it is this
@@ -700,6 +721,7 @@ export function studioFiles({ name, slug, homie, template = false }) {
700
721
  'studio.json': `${JSON.stringify(studio, null, 2)}\n`,
701
722
  'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
702
723
  '.gitignore': GITIGNORE,
724
+ '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
725
  '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
726
  '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
727
  'music/manifest.json': '{ "v": 1, "items": [] }\n',
@@ -728,6 +750,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
728
750
  [`site/migrations/${SHOP_RESERVATIONS_FILE}`]: SHOP_RESERVATIONS,
729
751
  [`site/migrations/${SHOP_STATEMENTS_FILE}`]: SHOP_STATEMENTS,
730
752
  [`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
753
+ [`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
731
754
  [`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
732
755
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
733
756
  '.claude/skills/.gitkeep': '',
@@ -877,13 +900,19 @@ function ensureLoungeMigration(root) {
877
900
  return `site/migrations/${LOUNGE_MIGRATION_FILE}`;
878
901
  }
879
902
 
903
+ function ensureAppsMigration(root) {
904
+ const file = join(root, 'site', 'migrations', APPS_MIGRATION_FILE);
905
+ if (existsSync(file)) return null;
906
+ mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, APPS_MIGRATION);
907
+ return `site/migrations/${APPS_MIGRATION_FILE}`;
908
+ }
880
909
  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);
910
+ 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
911
  }
883
912
 
884
913
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
885
914
  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';
915
+ 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
916
  }
888
917
 
889
918
  /**
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,88 @@
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
+ }
2033
+ },
2034
+ "0.37.1": {
2035
+ "files": {
2036
+ "CLAUDE.md": "ddf2b2a0376202f6",
2037
+ "README.md": "684b3cca8001673c",
2038
+ "HANDOFF.md": "29337e2ace95b2e7",
2039
+ "games/README.md": "5ad32fe80c61f117",
2040
+ "music/README.md": "a3dc042dadd39df8",
2041
+ "videos/README.md": "16babc3437d5545b",
2042
+ "posts/README.md": "37841aae476bd540",
2043
+ "site/README.md": "732894273a32dc3f",
2044
+ "site/src/worker.mjs": "fc90f3609cad18ba"
2045
+ },
2046
+ "sections": {
2047
+ "AGENTS.md": {
2048
+ "(intro)": "214aa46eb3a895d5",
2049
+ "## Layout": "c18362ed28975d81",
2050
+ "## Apps: one screen, roles and parts": "4db0abe4cdfac09f",
2051
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
2052
+ "## The site": "0cb8dd9a6f8a0096",
2053
+ "## Making games": "2da7fa74c8884834",
2054
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2055
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2056
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2057
+ "## Servers and AI seats": "98d08705087a33ec",
2058
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2059
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2060
+ "## Rules": "2e6b29afb85dcf7e",
2061
+ "## Telling Homie": "80b46fdd9e578db8",
2062
+ "## Beta": "9d02638207a26e73"
2063
+ }
2064
+ },
2065
+ "named": {
2066
+ "Homie Arcade": {
2067
+ "sections": {
2068
+ "AGENTS.md": {
2069
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
2070
+ }
2071
+ }
2072
+ }
2073
+ }
1992
2074
  }
1993
2075
  }
1994
2076
  }
@@ -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
+ }