@homie-rocks/studio 0.35.1 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +4 -2
  3. package/agents/agents.ts +8 -6
  4. package/app-starters/welcome/app.json +106 -0
  5. package/app-starters/welcome/index.html +4 -0
  6. package/app-starters/welcome/src/main.ts +107 -0
  7. package/apps/APPS.md +61 -0
  8. package/apps/apps.ts +28 -0
  9. package/bin/homie-studio.mjs +22 -10
  10. package/lab/lab.ts +15 -0
  11. package/lib/art-cli.mjs +2 -2
  12. package/lib/art-tools.mjs +6 -6
  13. package/lib/asset-check.mjs +3 -2
  14. package/lib/asset-import.mjs +3 -2
  15. package/lib/asset-manifest.mjs +8 -8
  16. package/lib/build.mjs +26 -18
  17. package/lib/characters.mjs +7 -7
  18. package/lib/check.mjs +49 -0
  19. package/lib/cloudflare.mjs +1 -1
  20. package/lib/codex.mjs +2 -2
  21. package/lib/collision.mjs +1 -1
  22. package/lib/decisions.mjs +7 -7
  23. package/lib/dev.mjs +42 -10
  24. package/lib/directory.mjs +1 -1
  25. package/lib/doctor.mjs +1 -1
  26. package/lib/handoff.mjs +1 -1
  27. package/lib/lab-check.mjs +18 -3
  28. package/lib/lab.mjs +29 -15
  29. package/lib/library.mjs +2 -2
  30. package/lib/mcp-tools.mjs +23 -3
  31. package/lib/parts-build.mjs +4 -3
  32. package/lib/parts-cli.mjs +3 -3
  33. package/lib/parts-store.mjs +8 -7
  34. package/lib/parts-tools.mjs +7 -7
  35. package/lib/parts.mjs +19 -14
  36. package/lib/perf.mjs +2 -1
  37. package/lib/player-image.mjs +2 -2
  38. package/lib/rules-build.mjs +97 -49
  39. package/lib/rules-check-worker.mjs +405 -0
  40. package/lib/rules-check.mjs +69 -0
  41. package/lib/rules-compiler.mjs +30 -0
  42. package/lib/rules-guard.mjs +26 -7
  43. package/lib/rules-types.mjs +167 -0
  44. package/lib/scaffold.mjs +21 -2
  45. package/lib/shop.mjs +1 -1
  46. package/lib/standalone-files.mjs +2 -2
  47. package/lib/standalone.mjs +12 -3
  48. package/lib/stats.mjs +2 -1
  49. package/lib/studio.mjs +32 -6
  50. package/lib/style-board.mjs +10 -9
  51. package/lib/template-history.json +85 -1
  52. package/lib/typecheck.mjs +52 -3
  53. package/links/links.d.ts +3 -0
  54. package/links/links.mjs +16 -0
  55. package/netplay/NETPLAY.md +123 -6
  56. package/netplay/netplay.ts +289 -53
  57. package/netplay/rules-output.mjs +85 -0
  58. package/package.json +16 -4
  59. package/parts/PARTS.md +10 -5
  60. package/rules/browser.ts +100 -0
  61. package/rules/core.ts +159 -58
  62. package/rules/guard.ts +11 -4
  63. package/rules/host.ts +90 -15
  64. package/rules/math.ts +16 -0
  65. package/rules/pack.ts +94 -17
  66. package/rules/rules.ts +21 -13
  67. package/rules/types.ts +63 -0
  68. package/rules/view.ts +36 -33
  69. package/shop/SHOP.md +13 -0
  70. package/site/SITE.md +1 -1
  71. package/standalone/shell/shell.js +8 -6
  72. package/worker/account-page.mjs +4 -3
  73. package/worker/app-format.mjs +42 -0
  74. package/worker/app-records.mjs +100 -0
  75. package/worker/app-shell.mjs +29 -0
  76. package/worker/brain.mjs +2 -1
  77. package/worker/embed.mjs +4 -2
  78. package/worker/hosted.mjs +1 -1
  79. package/worker/index.mjs +40 -20
  80. package/worker/limits.mjs +41 -0
  81. package/worker/pages.mjs +37 -29
  82. package/worker/parts.mjs +11 -9
  83. package/worker/room.mjs +136 -63
  84. package/worker/schema.mjs +6 -5
  85. package/worker/seats.mjs +2 -2
  86. package/worker/shop-page.mjs +4 -3
  87. package/worker/site.mjs +49 -40
  88. package/worker/standalone.mjs +2 -2
  89. package/worker/version.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -17,6 +17,46 @@ 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
+
36
+ ## [0.36.0] - 2026-10-08
37
+
38
+ **Plugin 0.37.0** · [#68](https://github.com/homie-rocks/homie/pull/68) · [#73](https://github.com/homie-rocks/homie/pull/73)
39
+
40
+ The build plays your rules game and names problems before you publish it.
41
+
42
+ ### Added
43
+
44
+ - **The build plays your rules game before it is written.** After checking the types in rules, movement and view against the game's own declarations, it seats generated players in a room on the real server runtime. They steer, hold every control, send every command the game declares, and sometimes send nothing at all; they leave, come back and give up their seats; the room is restarted as a deploy restarts it. A game with AI companions is played with them too.
45
+ - **A build is refused only for what that play proved.** A handler that threw, a tick that used its whole budget, a character the server had to hold back because its movement outran its declared top speed, a room that did not come back the same from its own save, or a value the room had to change and lose: `NaN`, an infinite number in a fraction or a vector, more entries than a list or a map declares, a text longer than declared. The message names the file, the line of the write and the handler, says what a live room does with the value, and how to repair it. Everything else is an `info:` line that never fails a build: how much was played, which handlers never ran, and how often a whole number was held to its range.
46
+ - **The same game gets the same answer on every computer.** The play is counted in ticks and in the rules' own budget units, never in seconds, so a slow or busy computer builds what a fast one builds and prints the same lines. A small game is played for fifteen minutes of the room's clock, which takes about a second; a heavy game is played until its units are used, a few seconds, and the line says how far that was. `homie-studio build --long-check` plays eight times as much, for a release or a CI run.
47
+ - **A live room never stops for a value that does not fit its field.** It stores the nearest thing the type holds, as before: `NaN` becomes `0`, a list is cut to its size, a whole number is held to its range (`Infinity` is the top of it, so it can stand for "never"). The build is where such a write is caught. `-0` is now stored as `0`, so a room divides the same way before and after a restart, and a map keeps its keys in the order they were added. The `game` skill's `RULES.md` has the table. Saved matches are written in a new form: a match saved by a build made before this change starts afresh once.
48
+
49
+ - **Let a player host a friends game.** Studios can choose `room.host: "browser"` to run the same rules in an elected player's browser. When that player leaves, another takes over with the saved seats, scores, round clock and AI companions. Server hosting remains the default.
50
+ - **Play offline from the same rules.** Browsers and packaged apps can play with local bots without a connection, unless the studio turns offline play off. Offline progress stays local. The Game Lab uses the same rules with its stages and sliders; online rooms can also use AI guides and decisions.
51
+ - **Browser hosting has different limits.** It depends on a player's device and connection and offers no protection against a modified host: that player can change scores or the match. It has no server recovery guarantee. When a host hides or closes the tab, another player takes over from the current tick; a crash can lose up to a second. Updates are paced to fit the connection, and a room whose state grows beyond the browser limits stops with a clear message. Choose server hosting for stronger authority or larger rooms.
52
+ - **The netplay contract is revision 11** (`netplay/NETPLAY.md`, section 29): the server as a host, input as steps stamped with the room's tick, explicit browser runtime output, and checkpoint handover. Nothing a game written before sends or receives has changed.
53
+
54
+ ### Fixed
55
+
56
+ - Local development publishes one complete rules build per restart, avoiding the two reloads caused by separate code and asset watchers.
57
+ - Browser rooms recover after a hidden tab, sleep or a dropped connection. Returning players regain a playable body, unavailable offline files keep reconnecting, and failed rules explain why the room stopped.
58
+ - Builds check the relay's browser state limits. A room that grows beyond them stops with a clear message instead of repeatedly changing hosts. Saved inputs and answers are checked before rules use them.
59
+
20
60
  ## [0.35.1] - 2026-10-08
21
61
 
22
62
  **Plugin 0.36.0** · [#71](https://github.com/homie-rocks/homie/pull/71)
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
 
package/agents/agents.ts CHANGED
@@ -59,6 +59,8 @@ export interface AgentsOptions {
59
59
  /** Runtime integration: a room clock and an explicit beat replace the browser timer. */
60
60
  now?: () => number;
61
61
  manual?: boolean;
62
+ /** A rules view is always a client; the separate runtime owns guides. */
63
+ viewOnly?: boolean;
62
64
  /** Keep an asked-for goal through scripted floor decisions until completion or sixty seconds. */
63
65
  carryFloor?: boolean;
64
66
  restore?: AgentsSaved;
@@ -283,7 +285,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
283
285
  function onAsk(slot: number, k: string, args: Record<string, unknown>, from: number): void {
284
286
  const def = own(vocab.asks, k);
285
287
  const s = slotOf(slot);
286
- if (!def || !s || argsWhy(def.args, args, lastView.get(slot) ?? viewOf(slot), null)) { st.drops += 1; return; }
288
+ if (!def || !s || argsWhy(def.args, args, viewOf(slot), people())) { st.drops += 1; return; }
287
289
  const now = wall();
288
290
  if (def.leave) { const m = avoid.get(slot) ?? new Map<number, number>(); m.set(from, now + AGENT_RULES.leaveMs); avoid.set(slot, m); }
289
291
  asks.set(slot, [...(asks.get(slot) ?? []), { k, args, from, at: now }].slice(-4));
@@ -298,7 +300,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
298
300
  const offEvent = net.on('event', (e: NetEvent) => {
299
301
  const k = e.k;
300
302
  const d = (e.d && typeof e.d === 'object' ? e.d : {}) as Record<string, unknown>;
301
- if (net.isHost) {
303
+ if (net.isHost && !opts.viewOnly) {
302
304
  const s = slotOfSeat(e.from);
303
305
  if (k === 'agent:do' && s) {
304
306
  st.dos += 1;
@@ -311,11 +313,11 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
311
313
  if (ask && e.from !== null && !net.isAgent(e.from) && Number.isInteger(d['slot'])) { onAsk(d['slot'] as number, ask, (d['args'] ?? {}) as Record<string, unknown>, e.from); return; }
312
314
  return;
313
315
  }
314
- if (k === 'agent:offer' && e.from === net.host?.seat && Number.isInteger(d['slot']) && d['view'] && typeof d['view'] === 'object') { lastView.set(d['slot'] as number, d['view'] as Record<string, unknown>); return; }
316
+ if (k === 'agent:offer' && e.from === (opts.viewOnly ? null : net.host?.seat) && Number.isInteger(d['slot']) && d['view'] && typeof d['view'] === 'object') { lastView.set(d['slot'] as number, d['view'] as Record<string, unknown>); return; }
315
317
  // Every other browser: a guide's line the host relayed (Quiet AI already dropped it in the helper). Its words are
316
318
  // this browser's own copy of the vocabulary; an argument is a seat, a value of the line's list, or an id.
317
319
  const line = /^say:([a-z][a-z0-9_]{0,31})$/.exec(k)?.[1];
318
- if (e.from === net.host?.seat && line && d['ai'] === true && Number.isInteger(d['slot'])) {
320
+ if (e.from === (opts.viewOnly ? null : net.host?.seat) && line && d['ai'] === true && Number.isInteger(d['slot'])) {
319
321
  const args = (d['args'] ?? {}) as Record<string, unknown>;
320
322
  const def = own(vocab.lines, line);
321
323
  const ids = Object.fromEntries(Object.entries(def?.args ?? {}).map(([n, t]) => [n, t]).filter(([, t]) => typeof t === 'string' && VIEW_ARG.test(t)).map(([n, t]) => [VIEW_ARG.exec(t as string)![1], /^[a-z0-9][a-z0-9_-]{0,39}$/.test(String(args[n as string] ?? '')) ? [args[n as string]] : []]));
@@ -327,7 +329,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
327
329
 
328
330
  // The host's beat: views to the guides an AI holds (at most one every 2 s each), the floor for the rest.
329
331
  const beat = (): void => {
330
- if (!net.isHost || net.offline) return;
332
+ if (opts.viewOnly || !net.isHost || net.offline) return;
331
333
  const now = wall();
332
334
  for (const s of guides()) {
333
335
  const seat = s.agent?.seat ?? null;
@@ -372,7 +374,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
372
374
  const s = slotOf(slot);
373
375
  if (!def || !s) return false;
374
376
  const seat = s.agent?.seat ?? null;
375
- if (net.isHost) {
377
+ if (net.isHost && !opts.viewOnly) {
376
378
  const me = net.offline ? 0 : net.seat;
377
379
  if (me === null) return false;
378
380
  onAsk(slot, k, args, me);
@@ -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,19 +13,24 @@
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
- * homie-studio build [<id>] [--maps] [--types] (--maps: also keep each bundle's source map and module sizes in
19
- * .studio/maps/<id>/, never in site/dist: what `perf` reads a CPU profile through;
20
- * --types: check the games' TypeScript first, with the studio's own
21
- * `typescript`; a type error stops the build. The site is built in a folder of
20
+ * homie-studio build [<id>] [--maps] [--types] [--long-check] (--maps: also keep each bundle's source map and
21
+ * module sizes in .studio/maps/<id>/, never in site/dist: what `perf` reads a
22
+ * CPU profile through; --types: check the games' TypeScript first, with the
23
+ * studio's own `typescript`; a type error stops the build; --long-check: play
24
+ * each rules game eight times as long before it is written (a rules game is
25
+ * always type-checked and played; this is more of the same play, for a
26
+ * release or a CI run). The site is built in a folder of
22
27
  * its own and put in place only when all of it is there: a game that does not
23
28
  * build fails the command and leaves site/dist as it was. Each game's line says
24
29
  * changed or unchanged, its build hash, and whether its source is shared)
25
30
  * homie-studio preview <id> [--port 8788] (one built game's files at an address on this computer, nothing else: no
26
31
  * Wrangler, no rooms, no database; for a capture, a screenshot or a perf script.
27
32
  * The game plays offline with its bots. Stop it with Ctrl-C)
28
- * 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,
29
34
  * nothing else, and clear a stale record; rooms here are always local: production
30
35
  * routes in wrangler.jsonc are left out; a game built while it runs is picked up;
31
36
  * --timestamps: a time on every line of Wrangler's, not only the room and error ones;
@@ -333,7 +338,7 @@ import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeO
333
338
  import { newStudio } from '../lib/scaffold.mjs';
334
339
  import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
335
340
  import { whatsNewLines } from '../lib/changelog.mjs';
336
- 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';
337
342
  import { STUDIO_VERSION } from '../lib/version.mjs';
338
343
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
339
344
  import { playersOwner, playersShow } from '../lib/players.mjs';
@@ -368,7 +373,7 @@ import { standaloneDeployNotes } from '../lib/standalone.mjs';
368
373
 
369
374
  const argv = process.argv.slice(2);
370
375
  const flags = new Map();
371
- 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'];
372
377
  const positional = [];
373
378
  for (let i = 0; i < argv.length; i++) {
374
379
  const a = argv[i];
@@ -414,7 +419,7 @@ function print(result) {
414
419
  lines.push(`Built ${result.games.map((g) => `${g.id} (${Math.round(g.bytes / 1024)} KB)`).join(', ') || 'no games'} into ${relative(process.cwd(), result.dist) || result.dist}`);
415
420
  // Per game: did it change since the build before, which build it is (the hash the live site's manifest says once
416
421
  // deployed), and what loads later.
417
- for (const g of result.games) lines.push(` ${g.id}: ${g.changed ?? 'new'}, build ${g.hash ?? '?'}${g.bundle ? ` (${g.bundle}${g.chunks ? ` + ${g.chunks} ${g.chunks === 1 ? 'chunk' : 'chunks'} loaded later` : ''})` : ''}`);
422
+ for (const g of result.games) lines.push(` ${g.id}: ${g.changed ?? 'new'}, build ${g.hash ?? '?'}${g.bundle ? ` (${g.bundle}${g.chunks ? ` + ${g.chunks} additional ${g.chunks === 1 ? 'chunk' : 'chunks'}` : ''})` : ''}`);
418
423
  if (result.retired) lines.push(`Note: ${result.retired}`);
419
424
  if (result.types) lines.push(`Types: ${result.types.games.filter((g) => g.checked).map((g) => g.id).join(', ') || 'no game has TypeScript to check'}${result.types.games.some((g) => g.checked) ? ` checked with TypeScript ${result.types.typescript ?? '?'}, no errors` : ''}`);
420
425
  if (result.songs?.length || result.videos?.length) lines.push(`Media pages: ${[...result.songs.map((x) => `/music/${x}/`), ...result.videos.map((x) => `/videos/${x}/`)].join(', ')}`);
@@ -901,12 +906,19 @@ async function main() {
901
906
  if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address or the live site)' };
902
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');
903
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') });
904
916
  if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
905
917
  // Remix was retired (a game is never handed over whole): the command is refused in a sentence that says where to
906
918
  // go, so an old note, an old card or an agent's memory of the toolkit does not end at "unknown command".
907
919
  if (cmd === 'game' && sub === 'remix') return { ok: false, command: 'game remix', why: 'remix was retired: a game is no longer handed over whole, and no studio serves one. Games build on each other through parts, the pieces of a game its studio chose to share: find one with `homie-studio parts find <words>` and bring it in with `homie-studio parts add` (in chat: parts_find, part_add; parts/PARTS.md)' };
908
920
  if (cmd === 'games') return { ok: true, command: 'games', games: listGames(root).map(({ dir, ...g }) => ({ ...g, dir: relative(root, dir) })) };
909
- if (cmd === 'build') return tracked(root, 'build', () => build(root, { only: positional[1] ?? null, log, maps: flags.has('maps'), types: flags.has('types') }), 'build');
921
+ if (cmd === 'build') return tracked(root, 'build', () => build(root, { only: positional[1] ?? null, log, maps: flags.has('maps'), types: flags.has('types'), longCheck: flags.has('long-check') }), 'build');
910
922
  if (cmd === 'preview') return preview(root, positional[1] ?? (listGames(root).length === 1 ? listGames(root)[0].id : null));
911
923
  if (cmd === 'perf' && sub === 'sizes') return perfSizes(root, positional[2] ?? listGames(root)[0]?.id);
912
924
  if (cmd === 'lab') return labCommand(root, sub);
@@ -928,7 +940,7 @@ async function main() {
928
940
  }
929
941
  // lib/dev.mjs: the dev server, its registration file, and what `--stop` will and will not signal.
930
942
  if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
931
- 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 });
932
944
  if (cmd === 'check') {
933
945
  const game = positional[1] ?? listGames(root)[0]?.id;
934
946
  const url = flags.get('url') ?? siteUrl(root);
package/lab/lab.ts CHANGED
@@ -166,6 +166,21 @@ export const lab = {
166
166
  return out as Tuned<S>;
167
167
  },
168
168
 
169
+ /** Rules use the same slider file as the view. Public values keep their public. prefix in the Lab's UI. */
170
+ rulesTune(raw: Record<string, unknown>): Record<string, unknown> {
171
+ if (!ON) return raw;
172
+ const pub = raw.public && typeof raw.public === 'object' ? raw.public as Record<string, unknown> : {};
173
+ const spec: TunableFile = {};
174
+ for (const [k, v] of Object.entries(raw)) if (k !== 'public' && (typeof v === 'number' || typeof (v as TunableSpec)?.value === 'number')) spec[k] = v as TunableSpec | number;
175
+ for (const [k, v] of Object.entries(pub)) if (typeof v === 'number' || typeof (v as TunableSpec)?.value === 'number') spec[`public.${k}`] = v as TunableSpec | number;
176
+ const values = host!.tunables(spec);
177
+ const out: Record<string, unknown> & { public: Record<string, unknown> } = { ...raw, public: { ...pub } };
178
+ for (const k of Object.keys(spec)) if (Number.isFinite(values[k])) {
179
+ if (k.startsWith('public.')) out.public[k.slice(7)] = values[k]; else out[k] = values[k];
180
+ }
181
+ return out;
182
+ },
183
+
169
184
  /**
170
185
  * Dice for juice (particles, shake, sparks): its own stream in the lab, so a build that adds a particle never moves
171
186
  * a gem or a bot in the other build (Math.random() is the world's dice, the same in both). Math.random() outside.
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(', ')})` : ''}`);