@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
package/CHANGELOG.md CHANGED
@@ -17,6 +17,22 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.37.0] - 2026-10-09
21
+
22
+ **Plugin 0.38.0** · [#72](https://github.com/homie-rocks/homie/pull/72)
23
+
24
+ A studio holds apps beside its games, with public player cards and a shop you can find.
25
+
26
+ ### Added
27
+
28
+ - Apps are a studio kind: `apps/<id>/app.json`, `app new` / `app_make`, one morphing screen with roles and parts, and a 3D welcome queue starter. Apps share game build, rooms, screen, shop, parts and standalone paths; their buttons say Open and Scan to join.
29
+ - App records outlast rooms in the studio's D1, with validated fields, role permissions, account grants, private staff addresses and optimistic updates. Public links/QR and cryptographic IDs work on LAN HTTP; `dev --lan` prints phone-reachable addresses.
30
+ - Local rules rebuilds retry transient wall-clock timing rejections up to three times, keeping the last good build and the existing performance guard.
31
+ - App checks exercise a real action across a wall and two phones and prove reconnect without requiring rounds. The app skill and scaffold guidance cover roles, single-screen composition, engine/media reuse and customer store builds. Parts now include app components, music and video with explicit intended uses and the existing licence checks; sharing adds no selling flow.
32
+
33
+ - Public apps carry the same player card as games, opening their own screen and using their own picture and wording.
34
+ - An open shop has a visible Shop button above the play/open screen, with room reserved for it. Set `screen.shop` to `false` to hide it; `createShop().open()` or `.open(item)` opens the same shop from your own button. Closed shops and experiences with nothing for sale show no control.
35
+
20
36
  ## [0.36.0] - 2026-10-08
21
37
 
22
38
  **Plugin 0.37.0** · [#68](https://github.com/homie-rocks/homie/pull/68) · [#73](https://github.com/homie-rocks/homie/pull/73)
package/README.md CHANGED
@@ -1,11 +1,13 @@
1
1
  # @homie-rocks/studio
2
2
 
3
- A studio for games, music and video on its own Cloudflare. One repository (`AGENTS.md`,
4
- `games/`, `music/`, `videos/`, `posts/`, `site/`), multiplayer games on the Homie netplay
3
+ A studio for games, apps, music and video on its own Cloudflare. One repository (`AGENTS.md`,
4
+ `games/`, `apps/`, `music/`, `videos/`, `posts/`, `site/`), multiplayer games on the Homie netplay
5
5
  contract (every browser renders, strangers meet in public rooms, bots fill seats, rounds
6
6
  restart), and a site Worker with Table/Lobby Durable Objects and D1 that the studio deploys
7
7
  to its own Cloudflare account with Wrangler. The homie.rocks directory lists the games.
8
8
 
9
+ Apps are one morphing screen, its roles and its parts: a customer phone, a staff tablet and a wall can share a 3D scene without rounds or scores. `homie-studio app new welcome` creates a working welcome queue in `apps/welcome/`; install its declared dependencies, build, then use `dev --lan` and the printed Wi-Fi address. `check welcome --url <origin>` proves a shared action and reconnect across three screens. Staff permissions, lasting D1 records, public QR/link helpers and customer standalone builds use the existing studio machinery. See [the app guide](apps/APPS.md).
10
+
9
11
  Most people never run this by hand: the Homie plugin for Claude Code, Codex and Grok does,
10
12
  and the person approves Cloudflare once in their browser.
11
13
 
@@ -0,0 +1,106 @@
1
+ {
2
+ "id": "welcome",
3
+ "name": "Welcome together",
4
+ "blurb": "A little place to gather. Join the queue, keep your ticket, and see when it is your turn.",
5
+ "entry": "src/main.ts",
6
+ "roles": {
7
+ "customer": {
8
+ "signIn": false,
9
+ "can": [
10
+ "read:queue",
11
+ "create:queue"
12
+ ]
13
+ },
14
+ "wall": {
15
+ "signIn": false,
16
+ "can": [
17
+ "read:queue"
18
+ ]
19
+ },
20
+ "staff": {
21
+ "signIn": true,
22
+ "can": [
23
+ "read:queue",
24
+ "update:queue",
25
+ "delete:queue"
26
+ ]
27
+ }
28
+ },
29
+ "surfaces": {
30
+ "phone": "customer",
31
+ "wall": "wall",
32
+ "kiosk": "customer",
33
+ "tablet": "staff"
34
+ },
35
+ "words": {
36
+ "open": "Open",
37
+ "join": "Scan to join",
38
+ "one": "person",
39
+ "many": "people"
40
+ },
41
+ "records": {
42
+ "persist": true,
43
+ "collections": {
44
+ "queue": {
45
+ "fields": {
46
+ "label": {
47
+ "type": "string",
48
+ "required": true,
49
+ "maxLength": 24
50
+ },
51
+ "status": {
52
+ "type": "string",
53
+ "required": true,
54
+ "enum": [
55
+ "waiting",
56
+ "called"
57
+ ],
58
+ "initial": "waiting"
59
+ }
60
+ }
61
+ }
62
+ }
63
+ },
64
+ "parts": [
65
+ "queue",
66
+ "ticket",
67
+ "welcome-scene"
68
+ ],
69
+ "netplay": {
70
+ "v": 1,
71
+ "public": true,
72
+ "version": "1",
73
+ "params": [
74
+ "role",
75
+ "surface",
76
+ "ticket"
77
+ ]
78
+ },
79
+ "playerCard": false,
80
+ "chat": false,
81
+ "agents": {
82
+ "vote": false
83
+ },
84
+ "watch": "overview",
85
+ "screen": {
86
+ "join": "bottom-right",
87
+ "share": "top-right"
88
+ },
89
+ "needs": {
90
+ "three": "0.185.1",
91
+ "@homie-rocks/camera": "0.2.1",
92
+ "@homie-rocks/geom": "0.1.1"
93
+ },
94
+ "check": {
95
+ "action": "[data-join]",
96
+ "observe": "[data-count]",
97
+ "surface": "phone"
98
+ },
99
+ "landing": {
100
+ "pitch": "A warm welcome. A place in line. One screen, together.",
101
+ "controls": {
102
+ "phone": "Join once and keep your ticket.",
103
+ "computer": "Staff call the next person from their signed-in tablet."
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,4 @@
1
+ <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Welcome together</title>
2
+ <style>
3
+ *{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#122f38;color:#fff4da;font-family:ui-rounded,system-ui,sans-serif}canvas{position:fixed;inset:0;width:100%;height:100%}main{position:fixed;inset:0;padding:clamp(22px,5vw,72px);pointer-events:none;display:flex;flex-direction:column;justify-content:space-between}header{max-width:580px}.eyebrow{font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:#b7d8ce}h1{font-size:clamp(46px,7.5vw,108px);line-height:.94;letter-spacing:-.06em;margin:18px 0}p{line-height:1.5;max-width:31em;color:#cce0d4}.panel{pointer-events:auto;background:#fff4da;color:#163b40;width:min(390px,100%);border-radius:24px;padding:22px;box-shadow:0 18px 70px #061e3244;transform:translateY(0);transition:transform .7s,background .7s}.panel.called{background:#c6efb3;transform:translateY(-10px)}.number{font-size:clamp(34px,5vw,60px);font-weight:750;letter-spacing:-.05em}button{border:0;min-height:50px;border-radius:14px;padding:12px 20px;font:700 16px system-ui;background:#163b40;color:#fff4da;cursor:pointer;width:100%;touch-action:manipulation}button:disabled{opacity:.45;cursor:default}button:focus-visible{outline:3px solid #ffb655;outline-offset:4px}.panel p{color:#49655f;font-size:13px;margin:10px 0}.line{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.tag{font:600 12px system-ui;text-transform:uppercase;letter-spacing:.1em}#ticket-qr{width:80px;height:80px;float:right;margin:0 0 6px 14px}#ticket-qr:empty{display:none}#ticket-qr svg{width:100%;height:100%}.live{display:flex;gap:8px;align-items:center;font-size:12px}.dot{width:7px;height:7px;background:#c6efb3;border-radius:50%}#status{min-height:18px;font-size:12px}body.wall .panel{width:min(510px,45vw)}body.wall header{max-width:800px}body.staff .panel{width:420px}footer{position:absolute;right:35px;bottom:24px;font-size:10px;letter-spacing:.15em;color:#9fbbb4}@media(max-width:600px){main{padding:78px 22px 26px}h1{font-size:48px}header p{font-size:13px;max-width:245px}.panel{padding:18px}footer{display:none}}@media(max-height:520px){main{flex-direction:row;align-items:center;padding:60px 24px 20px;gap:30px}h1{font-size:40px}.panel{max-width:310px}header p{display:none}}@media(prefers-reduced-motion:reduce){*{transition:none!important}}
4
+ </style></head><body><canvas aria-hidden="true"></canvas><main><header><div class="eyebrow">A space for everyone</div><h1>Good things<br>start here.</h1><p id="intro">Take a little moment. We’ll keep your place while you settle in.</p><div class="live"><i class="dot"></i><span id="connection">Joining the room…</span></div></header><section class="panel" aria-label="Your place"><div id="ticket-qr"></div><div class="line"><span class="tag" id="panel-label">Welcome in</span><span><b data-count>0</b> waiting</span></div><div class="number" id="ticket">Make yourself at home.</div><p id="detail">No account needed. Just a place in line.</p><button data-join>Join the queue</button><button data-next hidden>Call the next person</button><div id="status" role="status" aria-live="polite"></div></section><footer>ONE PLACE · TOGETHER</footer></main><script type="module" src="./assets/main.js"></script></body></html>
@@ -0,0 +1,107 @@
1
+ import * as THREE from 'three';
2
+ import { damp1 } from '@homie-rocks/camera/spring.js';
3
+ import { chamferBox } from '@homie-rocks/geom/chamfer.js';
4
+ import { createNetplay } from '@homie-rocks/studio/netplay';
5
+ import { createAppRecords, type AppRecord } from '@homie-rocks/studio/apps';
6
+ import { appLink, qrSvg, randomId } from '@homie-rocks/studio/links';
7
+
8
+ type Ticket = { label: string; status: string };
9
+ const net = createNetplay<{ light: number }>({ game: 'welcome', maxPlayers: 32, snapshotHz: 2 });
10
+ const role = net.params.role ?? 'customer';
11
+ document.body.className = role;
12
+ const $ = <T extends HTMLElement = HTMLElement>(s: string) => document.querySelector<T>(s)!;
13
+ const join = $<HTMLButtonElement>('[data-join]');
14
+ const next = $<HTMLButtonElement>('[data-next]');
15
+ const records = createAppRecords<Ticket>({ collection: 'queue' });
16
+ let rows: AppRecord<Ticket>[] = [];
17
+ let mine = net.params.ticket ?? '';
18
+ let pending = false;
19
+ let loaded = false;
20
+ let light = 0;
21
+ let refreshing = false;
22
+ join.hidden = role !== 'customer'; next.hidden = role !== 'staff';
23
+ if (role === 'wall') { $('h1').innerHTML = 'You’re in<br>good company.'; $('#intro').textContent = 'A warm welcome, one person at a time. Scan to find your place.'; }
24
+ if (role === 'staff') { $('h1').innerHTML = 'Make someone’s<br>day brighter.'; $('#intro').textContent = 'Your welcome desk. Call the next person when you are ready.'; }
25
+ function paint() {
26
+ const waiting = rows.filter((r) => r.data.status === 'waiting');
27
+ const called = rows.filter((r) => r.data.status === 'called').at(-1);
28
+ const own = rows.find((r) => r.id === mine);
29
+ $('[data-count]').textContent = String(waiting.length);
30
+ $('#panel-label').textContent = role === 'customer' ? (own ? 'Your ticket' : 'Welcome in') : 'Now welcoming';
31
+ $('#ticket').textContent = role === 'customer' ? own ? `No. ${own.data.label}` : 'Make yourself at home.' : called ? `No. ${called.data.label}` : 'Ready when you are.';
32
+ $('#detail').textContent = own?.data.status === 'called' ? 'It’s your turn. Come on over!' : own ? `You have a place. ${Math.max(0, waiting.findIndex((r) => r.id === mine))} ahead of you.` : role === 'customer' ? 'No account needed. Just a place in line.' : waiting.length ? `${waiting.length} ${waiting.length === 1 ? 'person is' : 'people are'} waiting for a welcome.` : 'The next arrival will appear here.';
33
+ $('.panel').classList.toggle('called', own?.data.status === 'called' || (role !== 'customer' && !!called));
34
+ join.hidden = role !== 'customer' || !!own;
35
+ join.disabled = pending || !loaded; next.disabled = pending || !waiting.length;
36
+ }
37
+ async function refresh() {
38
+ if (refreshing) return; refreshing = true;
39
+ try { rows = await records.list(); loaded = true; $('#status').textContent = ''; paint(); }
40
+ catch (e) { $('#status').textContent = (e as Error).message; }
41
+ finally { refreshing = false; }
42
+ }
43
+ async function action(fn: () => Promise<unknown>) {
44
+ pending = true; paint();
45
+ try { await fn(); net.send('records-changed'); await refresh(); }
46
+ catch (e) { $('#status').textContent = (e as Error).message; }
47
+ finally { pending = false; paint(); }
48
+ }
49
+ join.onclick = () => action(async () => {
50
+ mine = randomId();
51
+ const label = mine.slice(0, 4).toUpperCase();
52
+ await records.create({ label, status: 'waiting' }, mine);
53
+ // Build a customer link from a clean address, never from a private staff URL.
54
+ const origin = new URL((window as any).HOMIE_NET.url.replace(/^ws/, 'http')).origin;
55
+ const url = appLink(`/${(window as any).HOMIE_NET.url.split('/')[3]}/open`, { ticket: mine, room: (window as any).HOMIE_NET.room }, origin);
56
+ $('#ticket-qr').innerHTML = qrSvg(url, { title: 'Keep your ticket' });
57
+ parent.postMessage({ type: 'homie-app-ticket', ticket: mine }, '*');
58
+ });
59
+ next.onclick = () => action(async () => {
60
+ const first = rows.find((r) => r.data.status === 'waiting');
61
+ if (first) await records.update(first, { ...first.data, status: 'called' });
62
+ });
63
+ // The room carries scene state and change notices; records always come from the authorized database.
64
+ net.on('event', (e) => { if (e.k === 'records-changed') { if (net.role === 'host') net.send('records-changed'); void refresh(); } });
65
+ net.on('snapshot', (s) => { light = s.d.light; });
66
+ net.on('link', () => { void refresh(); });
67
+ setInterval(() => { if (net.role === 'host') { light = net.now() / 1000; net.snapshot({ light }); } $('#connection').textContent = net.link === 'online' ? 'Here together · live' : 'Reconnecting…'; }, 500);
68
+ setInterval(() => void refresh(), 2000); // recover a missed notice; no dependency on a trusted browser host
69
+ void refresh(); paint();
70
+
71
+ // A single sculptural scene. Queue arrivals unfold the lanterns; the camera follows your context.
72
+ const renderer = new THREE.WebGLRenderer({ canvas: $('canvas'), antialias: true, alpha: false });
73
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5)); renderer.setClearColor('#122f38');
74
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
75
+ const scene = new THREE.Scene(); scene.fog = new THREE.Fog('#122f38', 12, 30);
76
+ const camera = new THREE.PerspectiveCamera(37, 1, .1, 60);
77
+ scene.add(new THREE.HemisphereLight('#fff1ce', '#214f5b', 3));
78
+ const sun = new THREE.DirectionalLight('#ffd2a2', 5); sun.position.set(-3, 8, 6); scene.add(sun);
79
+ const mat = (color: string, metalness = 0) => new THREE.MeshStandardMaterial({ color, roughness: .45, metalness });
80
+ const cream = mat('#f4d8a4'); const green = mat('#9dbda0'); const gold = mat('#e8a953', .35);
81
+ const stage = new THREE.Group(); scene.add(stage); stage.position.set(1.5, -.9, 0);
82
+ const plinth = new THREE.Mesh(new THREE.CylinderGeometry(3.5, 3.7, .35, 64), mat('#254952')); plinth.position.y = -.4; stage.add(plinth);
83
+ const ring = new THREE.Mesh(new THREE.TorusGeometry(2.4, .055, 10, 90), gold); ring.rotation.x = Math.PI / 2; ring.position.y = -.15; stage.add(ring);
84
+ const arch = new THREE.Mesh(new THREE.TorusGeometry(1.65, .18, 16, 64, Math.PI), cream); arch.position.set(0, .15, -.8); stage.add(arch);
85
+ for (const x of [-1.65, 1.65]) { const leg = new THREE.Mesh(chamferBox(.36, 1.5, .36, .08), cream); leg.position.set(x, -.55, -.8); stage.add(leg); }
86
+ const lanterns: THREE.Group[] = [];
87
+ for (let i = 0; i < 12; i++) {
88
+ const g = new THREE.Group(); const body = new THREE.Mesh(chamferBox(.42, .7, .42, .08), i % 3 ? green : gold); g.add(body);
89
+ const cap = new THREE.Mesh(new THREE.SphereGeometry(.14, 16, 12), cream); cap.position.y = .5; g.add(cap);
90
+ const angle = i * Math.PI * 2 / 12; g.position.set(Math.sin(angle) * 2.35, 0, Math.cos(angle) * 2.35); stage.add(g); lanterns.push(g);
91
+ }
92
+ const reduced = matchMedia('(prefers-reduced-motion: reduce)');
93
+ const vel = { v: 0 }; let distance = 10; let last = performance.now();
94
+ function resize() { renderer.setSize(innerWidth, innerHeight); camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); }
95
+ addEventListener('resize', resize); resize();
96
+ function draw(now: number) {
97
+ requestAnimationFrame(draw); const dt = Math.min(.05, (now - last) / 1000); last = now;
98
+ const waiting = rows.filter((r) => r.data.status === 'waiting').length;
99
+ distance = damp1(distance, mine ? 8.7 : 10.8, vel, .8, dt);
100
+ const phone = innerWidth < 600;
101
+ camera.position.set(phone ? 7 : 6, phone ? 6 : 5, distance); camera.lookAt(phone ? 1 : -.4, .6, 0);
102
+ stage.position.x = phone ? 1.8 : 2.4;
103
+ stage.rotation.y = reduced.matches ? 0 : Math.sin(light * .12) * .09;
104
+ lanterns.forEach((g, i) => { const target = i < waiting ? 1 : .28; g.scale.y += (target - g.scale.y) * (1 - Math.exp(-dt * 5)); g.position.y = .18 + (reduced.matches ? 0 : Math.sin(now / 1000 + i) * .05); });
105
+ renderer.render(scene, camera);
106
+ }
107
+ requestAnimationFrame(draw);
package/apps/APPS.md ADDED
@@ -0,0 +1,61 @@
1
+ # One screen, roles, parts
2
+
3
+ `homie-studio app new welcome` writes `apps/welcome/app.json` and the welcome scene. Games and apps share one ID namespace, bundle/room/TV/watch pipeline, parts, shop and standalone builds. Source apps build into the compatibility asset namespace `site/dist/games/<id>`; `games.json` uses `kind: "app"`. Public manifests expose `apps[]` with `open` and `wall` addresses separately from `games[]`.
4
+
5
+ Open `/<id>/open`, its landing `/<id>/`, or its wall `/<id>/tv`. `?surface=kiosk` and `?role=customer` choose a declared public presentation. The phone surface must be public for standalone customer builds. Every app remains one view that transforms; roles choose what is visible and permitted, not a menu of pages.
6
+
7
+ ## Lasting records
8
+
9
+ The studio's existing D1 stores app records independently of room checkpoints. Deploy/dev apply `0014_studio_apps.sql`; old studios gain it through the normal migration helper. Previews without D1 return a clear unavailable response, never pretend to persist. Room saves still expire according to the room contract.
10
+
11
+ ```ts
12
+ import { createAppRecords } from '@homie-rocks/studio/apps';
13
+ const queue = createAppRecords<{ label: string; status: string }>({ collection: 'queue' });
14
+ const rows = await queue.list();
15
+ await queue.create({ label: 'A17', status: 'waiting' });
16
+ await queue.update(rows[0], { ...rows[0].data, status: 'called' });
17
+ await queue.remove(rows[0]);
18
+ queue.dispose();
19
+ ```
20
+
21
+ The shell handles requests only from its own frame and uses its own role, link and account, never authority claimed by the message. The Worker checks every request. Roles declare `signIn: boolean` and `can: ["read:queue", "create:queue", "update:queue", "delete:queue"]`. Collections declare fields with `type` (string/number/boolean), optional `required`, `maxLength`, `enum`, and `initial` (the only value allowed on create). Unknown fields are refused. Records are at most 8 KB, collection reads at most 1,000 records, request bodies at most 12 KB. A full collection refuses creates. This is a small operational helper, not an unbounded ledger.
22
+
23
+ Generate a fresh ID for every new record; never reuse a deleted ID. Updates and deletes compare `version` atomically. Conflicts return 409; reload and resolve them. A successful write is committed before acknowledgement. Records are scoped by app and collection, not by room. Deleting a room does not delete records. With `records.persist: false`, use netplay keyed state for transient data instead. Database failures are visible and never replaced with fabricated local success.
24
+
25
+ Use existing netplay events for refresh notices and shared animation. A missed or malicious notice cannot change authoritative records: reload them on reconnect and at a bounded fallback interval. The starter polls every two seconds and accelerates on room notices. Browser hosts remain untrusted for business data. Public collection reads reveal the whole public collection; ticket IDs and UI filtering are not access control. Use separate protected collections for private data.
26
+
27
+ ## Staff
28
+
29
+ `homie-studio app role welcome staff --url <origin>` returns an unpredictable role address for the owner. Add `--player <account-id>` to grant an existing non-guest account, or `--revoke` with that player to remove it. The command reuses the office's short-lived owner key. Accounts/passkeys and the office owner's existing session are reused. An anonymous browser with the link goes to `/account/` to sign in; a signed-in account without a grant receives 403. Every record call rechecks the grant, so revocation applies to open tablets too.
30
+
31
+ The private address hides the staff entrance; it is not a bearer permission. It is never in public metadata. Passkeys require HTTPS or localhost. `dev --lan` exposes the local site to the same Wi-Fi, but does not weaken staff authentication. On local HTTP, provision test sessions explicitly in tests or use a secure local origin for real sign-in.
32
+
33
+ ## Links and plain HTTP
34
+
35
+ ```ts
36
+ import { appLink, qrSvg, randomId } from '@homie-rocks/studio/links';
37
+ const ticket = randomId(); // 128 cryptographic bits through getRandomValues
38
+ const url = appLink('/welcome/open', { ticket, room: 'main' }, 'https://studio.example');
39
+ const svg = qrSvg(url, { title: 'Your ticket' });
40
+ ```
41
+
42
+ The QR encoder is the existing wall encoder, publicly exported with types. Links allow HTTP(S), preserve parameters and encode values. Do not copy `access` or any other private parameter into a public ticket. The helper works on LAN HTTP, where `crypto.randomUUID` may not exist.
43
+
44
+ ## Parts, check and stores
45
+
46
+ Parts import exactly as games do (`@parts/<id>` or `@parts/<host>/<id>`), with the same provenance and credit checks. `check.action` and `check.observe` name real UI selectors: the checker clicks on a phone, waits for changed text on wall and both phones, then reloads the actor and checks again. `check.params` optionally supplies public URL parameters (for example `{ "ticket": "404" }`) to all three surfaces so the action can address an existing sample record. The checker always chooses its own room. No round hooks are required. This performs the declared action and writes real records: use a local/test studio or a workflow explicitly safe to exercise.
47
+
48
+ `homie-studio standalone plan/build <app-id>` reuses Electron/Capacitor and the same built bundle. Public customer record calls use the existing standalone origin allowlist; credentials are never shared across origins. Native staff sign-in, private collections, offline record writes, native billing and automatic store upload are not implemented. Existing game standalone behavior is unchanged. A store build still needs its normal local SDK, signing and store review.
49
+
50
+ ## Shop button on the screen
51
+
52
+ An open shop with items for this game or app shows **Shop** above its play/open screen. The shell reserves 52 pixels plus the safe area above the content, so the button never covers its controls. In `game.json` or `app.json`, set `"screen": { "shop": false }` to hide this button (merge with any existing screen settings). The room panel and your own shop buttons still work. Closed shops, kids policies and an empty selection show no button.
53
+
54
+ ```ts
55
+ import { createShop } from '@homie-rocks/studio/shop';
56
+ const shop = createShop();
57
+ shopButton.onclick = () => shop.open();
58
+ itemButton.onclick = () => shop.open('supporter');
59
+ ```
60
+
61
+ These calls open the same shop in games and apps. Inside a player card, purchases leave for the studio's shop; ordinary play/open pages start Stripe checkout. The studio's TV and kids policies still apply.
package/apps/apps.ts ADDED
@@ -0,0 +1,28 @@
1
+ /** Lasting records through the same sandbox bridge used by saves and the shop. */
2
+ import { randomId } from '../links/links.mjs';
3
+ export interface AppRecord<T> { id: string; data: T; version: number; updatedAt: number }
4
+ export function createAppRecords<T extends Record<string, unknown>>({ collection }: { collection: string }) {
5
+ if (!/^[a-z][a-z0-9-]{0,39}$/.test(collection)) throw new Error('invalid collection');
6
+ const pending = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
7
+ const receive = (e: MessageEvent) => {
8
+ if (e.source !== parent || e.data?.type !== 'homie-app-result') return;
9
+ const p = pending.get(e.data.id); if (!p) return;
10
+ pending.delete(e.data.id); clearTimeout(p.timer);
11
+ if (e.data.result?.ok) p.resolve(e.data.result); else p.reject(new Error(e.data.result?.error ?? 'app request failed'));
12
+ };
13
+ addEventListener('message', receive);
14
+ const ask = (method: string, record?: string, data?: T, version?: number): Promise<any> => new Promise((resolve, reject) => {
15
+ const id = randomId();
16
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error('app shell did not answer')); }, 15000);
17
+ pending.set(id, { resolve, reject, timer });
18
+ parent.postMessage({ type: 'homie-app', id, collection, method, record, data, version }, '*');
19
+ });
20
+ return {
21
+ async list(): Promise<AppRecord<T>[]> { return (await ask('GET')).records; },
22
+ async get(id: string): Promise<AppRecord<T> | null> { return (await ask('GET', id)).records[0] ?? null; },
23
+ create: (data: T, id = randomId()) => ask('POST', id, data),
24
+ update: (record: AppRecord<T>, data: T) => ask('PUT', record.id, data, record.version),
25
+ remove: (record: AppRecord<T>) => ask('DELETE', record.id, undefined, record.version),
26
+ dispose() { removeEventListener('message', receive); for (const p of pending.values()) { clearTimeout(p.timer); p.reject(new Error('disposed')); } pending.clear(); },
27
+ };
28
+ }
@@ -13,6 +13,8 @@
13
13
  * this toolkit as a local MCP server (stdio): the Claude desktop app's Homie
14
14
  * extension, Claude Code or any MCP client builds in the same chat that shows
15
15
  * the cards (lib/mcp.mjs); --studios: the folder the studios live in
16
+ * homie-studio app new <id> [--name "<App Name>"]
17
+ * homie-studio app role <id> <role> [--player <account>] [--revoke] [--url <site>]
16
18
  * homie-studio game new <id> [--from gem-rush] [--name "<Game Name>"]
17
19
  * homie-studio games
18
20
  * homie-studio build [<id>] [--maps] [--types] [--long-check] (--maps: also keep each bundle's source map and
@@ -28,7 +30,7 @@
28
30
  * homie-studio preview <id> [--port 8788] (one built game's files at an address on this computer, nothing else: no
29
31
  * Wrangler, no rooms, no database; for a capture, a screenshot or a perf script.
30
32
  * The game plays offline with its bots. Stop it with Ctrl-C)
31
- * homie-studio dev [--port 8787] [--remote-ai] [--no-local-ai] [--timestamps] (--stop: stop exactly this studio's dev server,
33
+ * homie-studio dev [--lan] [--port 8787] [--remote-ai] [--no-local-ai] [--timestamps] (--stop: stop exactly this studio's dev server,
32
34
  * nothing else, and clear a stale record; rooms here are always local: production
33
35
  * routes in wrangler.jsonc are left out; a game built while it runs is picked up;
34
36
  * --timestamps: a time on every line of Wrangler's, not only the room and error ones;
@@ -336,7 +338,7 @@ import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeO
336
338
  import { newStudio } from '../lib/scaffold.mjs';
337
339
  import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
338
340
  import { whatsNewLines } from '../lib/changelog.mjs';
339
- import { listGames, newGame, readStudio, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
341
+ import { listGames, newGame, newApp, readStudio, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
340
342
  import { STUDIO_VERSION } from '../lib/version.mjs';
341
343
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
342
344
  import { playersOwner, playersShow } from '../lib/players.mjs';
@@ -371,7 +373,7 @@ import { standaloneDeployNotes } from '../lib/standalone.mjs';
371
373
 
372
374
  const argv = process.argv.slice(2);
373
375
  const flags = new Map();
374
- const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
376
+ const BOOL_FLAGS = ['lan', 'off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
375
377
  const positional = [];
376
378
  for (let i = 0; i < argv.length; i++) {
377
379
  const a = argv[i];
@@ -904,6 +906,13 @@ async function main() {
904
906
  if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address or the live site)' };
905
907
  return tracked(root, 'checks', (report) => portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'port check');
906
908
  }
909
+ if (cmd === 'app' && sub === 'role') {
910
+ const { withKey } = await import('../lib/office.mjs');
911
+ const id = positional[2]; const role = positional[3];
912
+ if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(id ?? '') || !/^[a-z][a-z0-9-]{0,39}$/.test(role ?? '')) throw new Error('app role <id> <role> [--player <account>] [--revoke] [--url <site>]');
913
+ return withKey(root, flags.get('url'), (call) => call(`/${id}/api/app/roles`, { role, ...(flags.get('player') ? { player: flags.get('player') } : {}), revoke: flags.has('revoke') }));
914
+ }
915
+ if (cmd === 'app' && sub === 'new') return newApp(root, positional[2], { name: flags.get('name') });
907
916
  if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
908
917
  // Remix was retired (a game is never handed over whole): the command is refused in a sentence that says where to
909
918
  // go, so an old note, an old card or an agent's memory of the toolkit does not end at "unknown command".
@@ -931,7 +940,7 @@ async function main() {
931
940
  }
932
941
  // lib/dev.mjs: the dev server, its registration file, and what `--stop` will and will not signal.
933
942
  if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
934
- if (cmd === 'dev') return dev(root, { port: flags.get('port') ?? 8787, remoteAi: flags.has('remote-ai'), localAi: !flags.has('no-local-ai'), timestamps: flags.has('timestamps'), log });
943
+ if (cmd === 'dev') return dev(root, { lan: flags.has('lan'), port: flags.get('port') ?? 8787, remoteAi: flags.has('remote-ai'), localAi: !flags.has('no-local-ai'), timestamps: flags.has('timestamps'), log });
935
944
  if (cmd === 'check') {
936
945
  const game = positional[1] ?? listGames(root)[0]?.id;
937
946
  const url = flags.get('url') ?? siteUrl(root);
package/lib/art-cli.mjs CHANGED
@@ -49,14 +49,14 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
49
49
  import { join, resolve } from 'node:path';
50
50
  import { USAGES, licenceProblems, readManifest, removeAsset, rightsMarkdown, setUsage, syncCredits, usageOf, writeRights } from './asset-manifest.mjs';
51
51
  import { CATALOGUE_IDS, PHASES, blastRadius, decisionRows, derivedPrompt, initDecisions, lockDecision, oneLine, phaseProgress, pickDirection, readDecisions, setDecision, staleAssets, steerDecision, unlockDecision } from './decisions.mjs';
52
- import { listGames } from './studio.mjs';
52
+ import { experienceDir, experienceFile, listExperiences as listGames } from './studio.mjs';
53
53
  import { checkLines } from './asset-check.mjs';
54
54
  import { animPlan as animPlanOf } from './characters.mjs';
55
55
 
56
56
  /** The game a command means: the one named, else the only one. */
57
57
  function gameOf(root, id, command) {
58
58
  const games = listGames(root);
59
- const planned = id && existsSync(join(root, 'games', id, 'CODEX.md'));
59
+ const planned = id && existsSync(join(experienceDir(root, id), 'CODEX.md'));
60
60
  if (id && (games.some((g) => g.id === id) || planned)) return id;
61
61
  if (!id && games.length === 1) return games[0].id;
62
62
  throw new Error(`name the game: homie-studio ${command} <id>${games.length ? ` (${games.map((g) => g.id).join(', ')})` : ''}`);
package/lib/art-tools.mjs CHANGED
@@ -36,7 +36,7 @@ import { animPlan } from './characters.mjs';
36
36
  import { CATALOGUE_IDS, decisionRows, lockDecision, oneLine, phaseProgress, pickDirection, readDecisions, setDecision, staleAssets, steerDecision, unlockDecision } from './decisions.mjs';
37
37
  import { libraryBase, loadIndex, searchLibrary, itemThumb } from './library.mjs';
38
38
  import { boardView } from './style-board.mjs';
39
- import { listGames } from './studio.mjs';
39
+ import { experienceDir, experienceFile, listExperiences as listGames } from './studio.mjs';
40
40
 
41
41
  export const ART_UI = Object.freeze({
42
42
  style: 'ui://homie-studio/style',
@@ -55,7 +55,7 @@ function picture(h, file, max = 160 * 1024) {
55
55
 
56
56
  function gameOf(root, id) {
57
57
  const games = listGames(root);
58
- if (id && (games.some((g) => g.id === id) || existsSync(join(root, 'games', String(id), 'CODEX.md')))) return String(id);
58
+ if (id && (games.some((g) => g.id === id) || existsSync(join(experienceDir(root, String(id)), 'CODEX.md')))) return String(id);
59
59
  if (!id && games.length === 1) return games[0].id;
60
60
  throw new Error(`which game? ${games.map((g) => g.id).join(', ') || 'there is none yet (game_plan or game_make first)'}`);
61
61
  }
@@ -64,11 +64,11 @@ function gameOf(root, id) {
64
64
  function styleData(h, root, game, extra = {}) {
65
65
  const doc = readDecisions(root, game);
66
66
  let title = null;
67
- try { title = JSON.parse(readFileSync(join(root, 'games', game, 'game.json'), 'utf8')).name ?? null; } catch { /* planned: its codex names it */ }
68
- if (!title) { try { title = /^#\s+(.+)$/m.exec(readFileSync(join(root, 'games', game, 'CODEX.md'), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, ''))?.[1]?.trim() ?? null; } catch { title = null; } }
67
+ try { title = JSON.parse(readFileSync(experienceFile(root, game), 'utf8')).name ?? null; } catch { /* planned: its codex names it */ }
68
+ if (!title) { try { title = /^#\s+(.+)$/m.exec(readFileSync(join(experienceDir(root, game), 'CODEX.md'), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, ''))?.[1]?.trim() ?? null; } catch { title = null; } }
69
69
  title = title ?? game;
70
70
  const manifest = readManifest(root, game);
71
- const dirs = doc ? boardView(root, game, doc).map((d) => ({ ...d, swatch: picture(h, d.swatch ? join(root, 'games', game, d.swatch) : null), mood: d.mood?.path ? { image: picture(h, join(root, 'games', game, d.mood.path)), usd: d.mood.usd ?? null, label: 'a painted target, not what the game draws' } : null })) : [];
71
+ const dirs = doc ? boardView(root, game, doc).map((d) => ({ ...d, swatch: picture(h, d.swatch ? join(experienceDir(root, game), d.swatch) : null), mood: d.mood?.path ? { image: picture(h, join(experienceDir(root, game), d.mood.path)), usd: d.mood.usd ?? null, label: 'a painted target, not what the game draws' } : null })) : [];
72
72
  const rows = doc ? decisionRows(doc) : [];
73
73
  return {
74
74
  kind: 'style', game, title, path: doc?.path ?? null, line: doc ? oneLine(doc) : null, budget: doc?.budget?.usd ?? null, chosen: doc?.board?.chosen ?? null,
@@ -98,7 +98,7 @@ async function characterThumb(h, root, game, row) {
98
98
  try { const lib = await loadIndex({ lib: libraryBase() }); const it = lib.index.items.find((x) => x.id === m.from.item); const t = it ? await itemThumb(lib.lib, it) : null; if (t) return `data:image/webp;base64,${Buffer.from(t).toString('base64')}`; } catch { /* no library here */ }
99
99
  }
100
100
  const concept = (m?.files ?? []).find((f) => f.role === 'concept');
101
- return concept ? picture(h, join(root, 'games', game, concept.path), 60 * 1024) : null;
101
+ return concept ? picture(h, join(experienceDir(root, game), concept.path), 60 * 1024) : null;
102
102
  }
103
103
 
104
104
  /** Which Game Lab take tunes a verb (lab.json): one named for it or whose note says it, else the default. */
@@ -1,3 +1,4 @@
1
+ import { experienceDir, experienceFile } from './studio.mjs';
1
2
  /**
2
3
  * `homie-studio assets check <id>`: static, free, on every asset and on the whole game. What it adds up is an
3
4
  * INVENTORY ESTIMATE: the recorded files' own triangles, draw calls and pictures times their declared placements. It
@@ -74,7 +75,7 @@ const tierOf = (a) => (a.kind === 'clip' ? 'clip' : BUDGETS[a.tier] ? a.tier : a
74
75
 
75
76
  /** A game's game.json, or {} (a planned game has none yet). */
76
77
  export function gameJson(root, id) {
77
- try { return JSON.parse(readFileSync(join(root, 'games', id, 'game.json'), 'utf8')) ?? {}; } catch { return {}; }
78
+ try { return JSON.parse(readFileSync(experienceFile(root, id), 'utf8')) ?? {}; } catch { return {}; }
78
79
  }
79
80
 
80
81
  /*
@@ -238,7 +239,7 @@ export async function assetsCheck(root, id, { validate = true, write = true } =
238
239
  const doc = readDecisions(root, id);
239
240
  const game = gameJson(root, id);
240
241
  const budgets = budgetsFor(doc, game);
241
- const gdir = join(root, 'games', id);
242
+ const gdir = experienceDir(root, id);
242
243
  let tools = null;
243
244
  try { tools = await import('./optimise.mjs'); } catch { tools = null; }
244
245
  const rows = [];
@@ -1,3 +1,4 @@
1
+ import { experienceDir, experienceFile } from './studio.mjs';
1
2
  /**
2
3
  * A MODEL FILE INTO A GAME: the person's own (imported), or one a provider generated on their account (the models
3
4
  * skill's image-to-3D). `homie-studio assets add <id> --file <model> --license <kind>`.
@@ -54,7 +55,7 @@ export function screenImport(path) {
54
55
  * the concept and mesh calls with their receipts and prices), slug (the art job's folder).
55
56
  */
56
57
  export async function importModel(root, id, file, opts = {}) {
57
- const gdir = join(root, 'games', id);
58
+ const gdir = experienceDir(root, id);
58
59
  if (!existsSync(join(gdir, 'game.json')) && !existsSync(join(gdir, 'CODEX.md'))) throw new Error(`no game "${id}"`);
59
60
  const path = resolve(root, file);
60
61
  const route = opts.route === 'generated' ? 'generated' : 'imported';
@@ -117,7 +118,7 @@ export async function importModel(root, id, file, opts = {}) {
117
118
  * mesh fix it); one stale only on its budgets is not.
118
119
  */
119
120
  export async function redoModel(root, id, assetId) {
120
- const gdir = join(root, 'games', id);
121
+ const gdir = experienceDir(root, id);
121
122
  const e = readManifest(root, id).assets.find((a) => a.id === assetId);
122
123
  if (!e) throw new Error(`no asset "${assetId}" in games/${id}/assets/manifest.json`);
123
124
  const raw = (e.files ?? []).find((f) => f.role === 'raw');
@@ -22,7 +22,7 @@ import { createHash } from 'node:crypto';
22
22
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
23
23
  import { dirname, join, relative } from 'node:path';
24
24
  import { pinByUse, readDecisions, revisionsFor } from './decisions.mjs';
25
- import { GAME_ID } from './studio.mjs';
25
+ import { experienceDir, experienceFile, GAME_ID } from './studio.mjs';
26
26
 
27
27
  export const MANIFEST_FILE = join('assets', 'manifest.json');
28
28
  export const RIGHTS_FILE = join('assets', 'RIGHTS.md');
@@ -81,7 +81,7 @@ export function underFor(kind, route) {
81
81
  return [...(UNDER[kind] ?? UNDER.prop), ...(route === 'library' ? ['cast.family'] : []), ...(route === 'generated' ? ['derived.prompt'] : [])];
82
82
  }
83
83
 
84
- const fileOf = (root, id) => join(root, 'games', id, MANIFEST_FILE);
84
+ const fileOf = (root, id) => join(experienceDir(root, id), MANIFEST_FILE);
85
85
  const now = () => new Date().toISOString();
86
86
  const clean = (s, n = 300) => (typeof s === 'string' ? s.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, n) : null);
87
87
 
@@ -167,7 +167,7 @@ export function removeAsset(root, id, assetId) {
167
167
  writeManifest(root, id, manifest);
168
168
  writeRights(root, id, manifest);
169
169
  syncCredits(root, id, manifest);
170
- const gdir = join(root, 'games', id);
170
+ const gdir = join(experienceDir(root, id));
171
171
  const named = new Set(manifest.assets.flatMap((a) => (a.files ?? []).map((f) => f.path)));
172
172
  const deleted = []; const kept = [];
173
173
  for (const f of gone.files ?? []) {
@@ -216,7 +216,7 @@ export function licenceProblems(root, id, manifest = readManifest(root, id)) {
216
216
 
217
217
  /** Model files a game ships (.glb, .gltf, .fbx, .obj, .vrm under games/<id>/) that no manifest entry names. */
218
218
  export function unrecordedModels(root, id, manifest = readManifest(root, id)) {
219
- const dir = join(root, 'games', id);
219
+ const dir = join(experienceDir(root, id));
220
220
  const named = new Set(manifest.assets.flatMap((a) => (a.files ?? []).map((f) => f.path)));
221
221
  const out = [];
222
222
  const walk = (rel, depth) => {
@@ -245,7 +245,7 @@ const PICTURE = /\.(png|jpe?g|webp|avif|gif|hdr|exr|ktx2)$/i;
245
245
  * The codex (private, the style board's own swatches) is not walked at all.
246
246
  */
247
247
  export function unrecordedPictures(root, id, manifest = readManifest(root, id), { cover = null } = {}) {
248
- const dir = join(root, 'games', id);
248
+ const dir = join(experienceDir(root, id));
249
249
  const named = new Set(manifest.assets.flatMap((a) => (a.files ?? []).map((f) => f.path)));
250
250
  const coverPaths = new Set(cover ? [String(cover).replace(/^\.\//, ''), `public/${String(cover).replace(/^\.\//, '')}`] : []);
251
251
  const shipped = []; const notShipped = [];
@@ -296,7 +296,7 @@ function routeWords(a) {
296
296
  /** RIGHTS.md for one game, in plain words. */
297
297
  export function rightsMarkdown(root, id, manifest = readManifest(root, id)) {
298
298
  let game = {};
299
- try { game = JSON.parse(readFileSync(join(root, 'games', id, 'game.json'), 'utf8')); } catch { game = {}; }
299
+ try { game = JSON.parse(readFileSync(experienceFile(root, id), 'utf8')); } catch { game = {}; }
300
300
  const lines = [
301
301
  `# Rights: ${game.name ?? id}`,
302
302
  '',
@@ -333,7 +333,7 @@ function termsApply(t, a) {
333
333
  }
334
334
 
335
335
  export function writeRights(root, id, manifest = readManifest(root, id)) {
336
- const file = join(root, 'games', id, RIGHTS_FILE);
336
+ const file = join(experienceDir(root, id), RIGHTS_FILE);
337
337
  mkdirSync(dirname(file), { recursive: true });
338
338
  const text = rightsMarkdown(root, id, manifest);
339
339
  if (!existsSync(file) || readFileSync(file, 'utf8') !== text) writeFileSync(file, text);
@@ -347,7 +347,7 @@ export function writeRights(root, id, manifest = readManifest(root, id)) {
347
347
  * and one per attribution-required file. Lines this function wrote carry `"asset": true`; every other part stays.
348
348
  */
349
349
  export function syncCredits(root, id, manifest = readManifest(root, id)) {
350
- const file = join(root, 'games', id, 'credits.json');
350
+ const file = join(experienceDir(root, id), 'credits.json');
351
351
  let credits = {};
352
352
  try { credits = JSON.parse(readFileSync(file, 'utf8')); } catch { credits = {}; }
353
353
  const before = Array.isArray(credits.parts) ? credits.parts : [];