@homie-rocks/studio 0.35.0 → 0.36.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 (45) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/agents/agents.ts +8 -6
  3. package/bin/homie-studio.mjs +9 -6
  4. package/lab/lab.ts +15 -0
  5. package/lib/build.mjs +20 -15
  6. package/lib/dev.mjs +16 -1
  7. package/lib/lab-check.mjs +18 -3
  8. package/lib/lab.mjs +21 -7
  9. package/lib/mcp-tools.mjs +2 -1
  10. package/lib/rules-build.mjs +97 -49
  11. package/lib/rules-check-worker.mjs +405 -0
  12. package/lib/rules-check.mjs +69 -0
  13. package/lib/rules-compiler.mjs +30 -0
  14. package/lib/rules-guard.mjs +26 -7
  15. package/lib/rules-types.mjs +167 -0
  16. package/lib/shop-catalog.mjs +20 -1
  17. package/lib/shop-links.mjs +3 -1
  18. package/lib/shop.mjs +14 -8
  19. package/lib/standalone-files.mjs +2 -2
  20. package/lib/standalone.mjs +6 -2
  21. package/lib/stripe-connect.mjs +18 -7
  22. package/lib/template-history.json +83 -0
  23. package/lib/typecheck.mjs +52 -3
  24. package/netplay/NETPLAY.md +123 -6
  25. package/netplay/netplay.ts +289 -53
  26. package/netplay/rules-output.mjs +85 -0
  27. package/package.json +3 -2
  28. package/rules/browser.ts +100 -0
  29. package/rules/core.ts +159 -58
  30. package/rules/guard.ts +11 -4
  31. package/rules/host.ts +90 -15
  32. package/rules/math.ts +16 -0
  33. package/rules/pack.ts +94 -17
  34. package/rules/rules.ts +21 -13
  35. package/rules/types.ts +63 -0
  36. package/rules/view.ts +36 -33
  37. package/standalone/shell/shell.js +8 -6
  38. package/worker/brain.mjs +2 -1
  39. package/worker/hosted.mjs +1 -1
  40. package/worker/index.mjs +7 -11
  41. package/worker/limits.mjs +41 -0
  42. package/worker/pages.mjs +4 -2
  43. package/worker/room.mjs +136 -63
  44. package/worker/stripe.mjs +2 -2
  45. package/worker/version.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -17,9 +17,45 @@ 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.36.0] - 2026-10-08
21
+
22
+ **Plugin 0.37.0** · [#68](https://github.com/homie-rocks/homie/pull/68) · [#73](https://github.com/homie-rocks/homie/pull/73)
23
+
24
+ The build plays your rules game and names problems before you publish it.
25
+
26
+ ### Added
27
+
28
+ - **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.
29
+ - **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.
30
+ - **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.
31
+ - **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.
32
+
33
+ - **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.
34
+ - **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.
35
+ - **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.
36
+ - **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.
37
+
38
+ ### Fixed
39
+
40
+ - Local development publishes one complete rules build per restart, avoiding the two reloads caused by separate code and asset watchers.
41
+ - 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.
42
+ - 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.
43
+
44
+ ## [0.35.1] - 2026-10-08
45
+
46
+ **Plugin 0.36.0** · [#71](https://github.com/homie-rocks/homie/pull/71)
47
+
48
+ A tip with no minimum connects, and Stripe's own reason is shown when setup cannot finish.
49
+
50
+ ### Fixed
51
+
52
+ - A pay-what-you-want item with no minimum connects.
53
+ - When Stripe refuses something during setup, you are told what Stripe said. Keys are masked, including during renewal, catalog reads and the manual connection.
54
+ - The test Stripe now refuses invalid amounts and incomplete shop objects, so these mistakes fail a test before release.
55
+
20
56
  ## [0.35.0] - 2026-10-08
21
57
 
22
- **Plugin 0.36.0** · [#67](https://github.com/homie-rocks/homie/pull/67)
58
+ **Plugin 0.36.0** · [#67](https://github.com/homie-rocks/homie/pull/67) · [release-2026-10-08-studio-0.35.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-08-studio-0.35.0)
23
59
 
24
60
  A shop with no Stripe key to find. You approve once in your browser, and your AI sets up the rest in your own Stripe account.
25
61
 
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);
@@ -15,10 +15,13 @@
15
15
  * the cards (lib/mcp.mjs); --studios: the folder the studios live in
16
16
  * homie-studio game new <id> [--from gem-rush] [--name "<Game Name>"]
17
17
  * 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
18
+ * homie-studio build [<id>] [--maps] [--types] [--long-check] (--maps: also keep each bundle's source map and
19
+ * module sizes in .studio/maps/<id>/, never in site/dist: what `perf` reads a
20
+ * CPU profile through; --types: check the games' TypeScript first, with the
21
+ * studio's own `typescript`; a type error stops the build; --long-check: play
22
+ * each rules game eight times as long before it is written (a rules game is
23
+ * always type-checked and played; this is more of the same play, for a
24
+ * release or a CI run). The site is built in a folder of
22
25
  * its own and put in place only when all of it is there: a game that does not
23
26
  * build fails the command and leaves site/dist as it was. Each game's line says
24
27
  * changed or unchanged, its build hash, and whether its source is shared)
@@ -414,7 +417,7 @@ function print(result) {
414
417
  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
418
  // Per game: did it change since the build before, which build it is (the hash the live site's manifest says once
416
419
  // 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` : ''})` : ''}`);
420
+ 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
421
  if (result.retired) lines.push(`Note: ${result.retired}`);
419
422
  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
423
  if (result.songs?.length || result.videos?.length) lines.push(`Media pages: ${[...result.songs.map((x) => `/music/${x}/`), ...result.videos.map((x) => `/videos/${x}/`)].join(', ')}`);
@@ -906,7 +909,7 @@ async function main() {
906
909
  // go, so an old note, an old card or an agent's memory of the toolkit does not end at "unknown command".
907
910
  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
911
  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');
912
+ 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
913
  if (cmd === 'preview') return preview(root, positional[1] ?? (listGames(root).length === 1 ? listGames(root)[0].id : null));
911
914
  if (cmd === 'perf' && sub === 'sizes') return perfSizes(root, positional[2] ?? listGames(root)[0]?.id);
912
915
  if (cmd === 'lab') return labCommand(root, sub);
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/build.mjs CHANGED
@@ -75,7 +75,7 @@ import { audienceOf } from '../worker/shop-rules.mjs';
75
75
  import { loungeConfig, loungeProblems } from '../worker/lounge-store.mjs';
76
76
  // GAME PARTS (parts/PARTS.md): the three call-outs below are all the build knows of them.
77
77
  import { buildParts, partsPlugin } from './parts-build.mjs';
78
- // RULES ON THE SERVER (NETPLAY.md section 29): a game with a src/rules.ts is built as a view bundle plus a rules module.
78
+ // RULES ON THE SERVER (NETPLAY.md section 29): a game declaring a room object and src/rules.ts builds as a view bundle plus a rules module.
79
79
 
80
80
  /** Never copied into a static game's served folder. */
81
81
  const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md', 'CODEX.md', 'lab.json', 'codex']);
@@ -376,7 +376,7 @@ function pointAtBundle(html, bundle) {
376
376
  * game is served (/games/<id>/ on the site, /<id>/__game/ in its frame, the lab). A game with no dynamic import is
377
377
  * one file, as before. `hashed` (the site's build) names the bundle by its content too: see the top of this file.
378
378
  */
379
- export async function buildGameFiles(esbuild, root, g, out, { maps = false, sourcemap = null, cache = {}, log = () => {}, hashed = false } = {}) {
379
+ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sourcemap = null, cache = {}, log = () => {}, hashed = false, lab = false, longCheck = false } = {}) {
380
380
  rmSync(out, { recursive: true, force: true });
381
381
  mkdirSync(join(out, 'assets'), { recursive: true });
382
382
  const mode = g.build?.mode ?? 'bundle';
@@ -394,7 +394,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
394
394
  }
395
395
  if (ruled && mode !== 'bundle') throw new Error(`games/${g.id} has a src/rules.ts and game.json "build": { "mode": "${mode}" }. A game written as rules plus view is built by Homie itself: take the "build" setting out.`);
396
396
  const { prepareRules, viewPlugin } = ruled ? await rulesBuild() : {};
397
- const rules = ruled ? await prepareRules(esbuild, root, g, { log }) : null;
397
+ const rules = ruled ? await prepareRules(esbuild, root, g, { log, longCheck }) : null;
398
398
  const bundle = async (entryRel) => {
399
399
  const entry = join(g.dir, entryRel);
400
400
  if (!existsSync(entry)) throw new Error(`games/${g.id}: entry ${entryRel} not found`);
@@ -406,7 +406,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
406
406
  // game.json assets.budgets, read by createModels() (assets/assets.ts): a constant in the bundle, never a fetch.
407
407
  define: { __HOMIE_MODEL_BUDGETS__: JSON.stringify(budgets ?? {}) },
408
408
  // GAME PARTS: `@parts/<host>/<id>` and `@parts/<id>` resolve to the part's entry, and the game is credited.
409
- plugins: [partsPlugin(root, g.id), ...(rules ? [viewPlugin(g, rules, entry)] : [])],
409
+ plugins: [partsPlugin(root, g.id), ...(rules ? [viewPlugin(g, rules, entry, { lab })] : [])],
410
410
  };
411
411
  const failed = (error) => {
412
412
  const first = error.errors?.[0];
@@ -416,7 +416,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
416
416
  warnings += result.warnings.length;
417
417
  metafile = result.metafile;
418
418
  const outputs = Object.entries(result.metafile.outputs).filter(([k]) => k.endsWith('.js'));
419
- const main = outputs.find(([, v]) => v.entryPoint)?.[0];
419
+ const main = outputs.find(([, v]) => rules ? v.entryPoint === 'homie-view:homie:view' : Boolean(v.entryPoint))?.[0];
420
420
  if (!main) throw new Error(`games/${g.id} did not build: esbuild wrote no bundle for ${entryRel}`);
421
421
  bundled = `assets/${basename(main)}`;
422
422
  chunks = outputs.filter(([k]) => k !== main).map(([k]) => `assets/${basename(k)}`).sort();
@@ -466,6 +466,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
466
466
  for (const file of [bundled, ...chunks].filter(Boolean)) digest.update(readFileSync(join(out, file)));
467
467
  digest.update(readFileSync(join(out, 'index.html')));
468
468
  rules.build = digest.digest('hex').slice(0, 32);
469
+ writeFileSync(join(out, 'rules.json'), `${JSON.stringify({ v: 1, host: rules.settings.host, offline: rules.settings.offline, build: rules.build, files: [bundled, ...chunks] })}\n`);
469
470
  if (g.netplay?.version !== undefined) log(` ${g.id}: rules games use their build hash as the revision; netplay.version is not read`);
470
471
  }
471
472
  return { mode, warnings, metafile, bundle: bundled, chunks, rules };
@@ -486,7 +487,7 @@ export function orderGames(games, order, log = () => {}) {
486
487
  return [...games].sort((a, b) => (at.get(a.id) ?? Infinity) - (at.get(b.id) ?? Infinity) || a.id.localeCompare(b.id));
487
488
  }
488
489
 
489
- export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false } = {}) {
490
+ export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false, beforePublish = async () => {}, longCheck = false } = {}) {
490
491
  const esbuild = await studioEsbuild(root);
491
492
  const studio = readStudio(root);
492
493
  // The shop first (shop/SHOP.md): invalid shop settings stop the build before anything is built.
@@ -504,13 +505,13 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
504
505
  // from the site as it is.
505
506
  const dist = openStage(root, { from: only ? live : null });
506
507
  try {
507
- return await buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps });
508
+ return await buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps, beforePublish, longCheck });
508
509
  } finally {
509
510
  rmSync(dist, { recursive: true, force: true });
510
511
  }
511
512
  }
512
513
 
513
- async function buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps }) {
514
+ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps, beforePublish, longCheck }) {
514
515
  const built = [];
515
516
  const retired = [];
516
517
  const cache = {};
@@ -519,7 +520,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
519
520
  for (const g of games) {
520
521
  const out = join(dist, 'games', g.id);
521
522
  const started = Date.now();
522
- const { mode, warnings, bundle, chunks, rules } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log, hashed: true });
523
+ const { mode, warnings, bundle, chunks, rules } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log, hashed: true, longCheck });
523
524
  if (rules) ruled.push({ id: g.id, ...rules }); else browserHosted.push(g.id);
524
525
  // The guides' vocabulary (NETPLAY.md section 18): checked here, so a room never meets a line it cannot say.
525
526
  vocabFor(g, out);
@@ -542,11 +543,14 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
542
543
  id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings, seats: seats.max,
543
544
  ...(bundle ? { bundle } : {}), ...(chunks.length ? { chunks: chunks.length, chunkBytes: later } : {}),
544
545
  });
545
- log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB${chunks.length ? ` + ${chunks.length} ${chunks.length === 1 ? 'chunk' : 'chunks'} loaded later, ${Math.max(1, Math.round(later / 1024))} KB` : ''})`);
546
- if (rules) log(` ${g.id}: its rules run on the server (checked and guarded, ${Math.max(1, Math.round(rules.code.length / 1024))} KB, build ${rules.build}; three seconds with bots: the busiest tick used ${rules.tickUnits} of ${rules.settings.budget.tick} budget units, ${rules.units} of them in one handler)`);
546
+ log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB${chunks.length ? ` + ${chunks.length} additional ${chunks.length === 1 ? 'chunk' : 'chunks'}, ${Math.max(1, Math.round(later / 1024))} KB` : ''})`);
547
+ if (rules && (rules.settings.offline || rules.settings.host === 'browser')) log(`${g.id}: this game's rules and tunables are sent to players' devices so it can be played offline`);
548
+ if (rules) log(` ${g.id}: its rules run ${rules.settings.host === 'browser' ? "in a player's browser" : 'on the server'} (checked and guarded, ${Math.max(1, Math.round(rules.code.length / 1024))} KB, build ${rules.build}; ${rules.check.ticks} ticks played, the room rebuilt from its save ${rules.check.restores} times: the busiest tick used ${rules.tickUnits} of ${rules.settings.budget.tick} budget units, ${rules.units} of them in one handler; the largest save was ${rules.check.largestSaveBytes} bytes)`);
549
+ if (rules && rules.settings.host === 'server' && (rules.snapshotBytes > rules.snapshotCap || rules.checkpointBytes > rules.checkpointCap)) log(` ${g.id}: this state fits server and offline play but exceeds browser hosting limits.`);
550
+ if (rules) log(` ${g.id}: smoke-run largest snapshot ${rules.snapshotBytes} B / ${rules.snapshotCap} B browser cap; checkpoint ${rules.checkpointBytes} B / ${rules.checkpointCap} B browser cap (measured, not an upper bound)`);
547
551
  }
548
552
  // A game written before rules (its own code is the host) builds and runs exactly as it did. Said in one line.
549
- if (browserHosted.length) log(`hosted by a player's browser, as before (no src/rules.ts; nothing to do): ${browserHosted.join(', ')}`);
553
+ if (browserHosted.length) log(`hosted by a player's browser, as before (no room object; nothing to do): ${browserHosted.join(', ')}`);
550
554
  const all = listGames(root);
551
555
  // No game is handed over whole any more (remix was retired; worker/license.mjs). A one-game build starts from the
552
556
  // site as it is, which an older toolkit may have built: what that wrote for remixers (a game's whole source, and
@@ -571,8 +575,8 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
571
575
  // studio.json site.order: the catalogue's order is every listing's order.
572
576
  const shown = orderGames(all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))), s.order, log);
573
577
  // Server-hosted games: what this build made, and for a one-game build what the catalogue before it said of the others.
574
- const rulesOf = new Map(ruled.map((r) => [r.id, { host: 'server', tickHz: r.settings.tickHz, inputHz: r.settings.inputHz, contract: r.schema.contract, build: r.build, stateHash: r.stateHash, rounds: r.rounds }]));
575
- if (only) for (const row of readJson(join(dist, 'games.json'))?.games ?? []) if (row?.room?.host === 'server' && row.id !== only && !rulesOf.has(row.id)) rulesOf.set(row.id, row.room);
578
+ const rulesOf = new Map(ruled.map((r) => [r.id, { host: r.settings.host, offline: r.settings.offline, tickHz: r.settings.tickHz, inputHz: r.settings.inputHz, contract: r.schema.contract, build: r.build, stateHash: r.stateHash, rounds: r.rounds }]));
579
+ if (only) for (const row of readJson(join(dist, 'games.json'))?.games ?? []) if (row?.room?.contract && row.id !== only && !rulesOf.has(row.id)) rulesOf.set(row.id, row.room);
576
580
  // What each game's build is (its landing's own pictures are not part of it): the digest the manifest names.
577
581
  const builds = {};
578
582
  const rows = shown.map((g) => {
@@ -714,7 +718,8 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
714
718
  if (deploy) for (const b of built) log(` ${b.id}: ${b.changed}${b.changed === 'new' ? '' : ' since the last build here'}, build ${b.hash}`);
715
719
  // The rules of the server-hosted games, where the studio's Worker imports them (site/src/rules/). Written with the
716
720
  // site, never before: a game that did not build has left both as they were.
717
- const hosted = (await rulesBuild()).writeRules(root, ruled, all.filter((g) => rulesOf.has(g.id)).map((g) => g.id));
721
+ await beforePublish();
722
+ const hosted = (await rulesBuild()).writeRules(root, ruled, all.filter((g) => rulesOf.get(g.id)?.host === 'server').map((g) => g.id));
718
723
  // All of it is there: now, and only now, it becomes site/dist (lib/stage.mjs).
719
724
  const swapped = swapIn(dist, live);
720
725
  return {
package/lib/dev.mjs CHANGED
@@ -413,6 +413,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
413
413
  // A rules game saved while dev runs is built again (see rulesStamp), one game at a time.
414
414
  const stamps = new Map(listGames(root).filter(isRulesGame).map((g) => [g.id, rulesStamp(g)]));
415
415
  let rebuilding = false;
416
+ let published = Promise.resolve();
416
417
  const rulesLook = async () => {
417
418
  if (rebuilding || ending) return;
418
419
  for (const g of listGames(root).filter(isRulesGame)) {
@@ -424,7 +425,20 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
424
425
  log(`games/${g.id} changed: checking its rules and building it again…`);
425
426
  const hash = () => { try { return JSON.parse(readFileSync(join(root, 'site/dist/games.json'), 'utf8')).games.find((x) => x.id === g.id)?.room?.stateHash; } catch { return null; } };
426
427
  const before = hash();
427
- await build(root, { only: g.id, log });
428
+ let release;
429
+ published = new Promise(resolve => { release = resolve; });
430
+ try {
431
+ await build(root, { only: g.id, log, beforePublish: async () => {
432
+ // Wrangler independently watches assets and code. Publishing both while it runs
433
+ // reloads the old bundle first, then the new one. Stop once at the publish boundary;
434
+ // the loop below starts once with the complete build and the same local storage.
435
+ if (!child || child.exitCode !== null) return;
436
+ restart = g.id;
437
+ const stopped = new Promise(resolve => child.once('close', resolve));
438
+ child.kill('SIGTERM');
439
+ await stopped;
440
+ } });
441
+ } finally { release(); }
428
442
  log(`games/${g.id} is rebuilt. ${before && before === hash() ? 'Local rooms resume their saved match; pages reconnect.' : 'The stored shape changed: local rooms reset to a fresh match; pages reconnect.'}`);
429
443
  } catch (error) {
430
444
  log(`games/${g.id} did not build, so the local site still runs what it had:\n${String(error?.message ?? error)}`);
@@ -461,6 +475,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
461
475
  writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
462
476
  settle().catch(() => {});
463
477
  await new Promise((done) => { child.on('close', done); child.on('error', done); });
478
+ await published;
464
479
  if (!restart || ending) break;
465
480
  restart = null;
466
481
  }
package/lib/lab-check.mjs CHANGED
@@ -25,7 +25,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
25
25
  import { join, relative } from 'node:path';
26
26
  import { chromeArgs, findChrome, noChrome, SOFTWARE_GL } from './chrome.mjs';
27
27
  import { labDir, startLabServer } from './lab.mjs';
28
- import { listGames } from './studio.mjs';
28
+ import { isRulesGame, listGames } from './studio.mjs';
29
29
 
30
30
  const round = (v, d = 2) => (Number.isFinite(v) ? Math.round(v * 10 ** d) / 10 ** d : null);
31
31
  const pct = (xs, p) => { const s = xs.filter(Number.isFinite).sort((a, b) => a - b); return s.length ? s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))] : null; };
@@ -58,6 +58,11 @@ export function summarise(pane, { fps, units }) {
58
58
  };
59
59
  }
60
60
 
61
+ /** A rules take must actually advance its local runtime without joining a room. */
62
+ export function rulesRun(s) {
63
+ return Boolean(s?.hosting && !s.connected && s.tick > 0 && s.status === 'playing');
64
+ }
65
+
61
66
  /** Where two runs of one build first differ (frame number), or null when every frame matches. */
62
67
  export function firstDifference(a, b) {
63
68
  const n = Math.max(a?.length ?? 0, b?.length ?? 0);
@@ -137,8 +142,18 @@ export async function labCheck(root, id, { take = null, today = 'HEAD', device =
137
142
  new: first.new.available ? firstDifference(first.new.frames, second.new.frames) : null,
138
143
  today: first.today.available ? firstDifference(first.today.frames, second.today.frames) : null,
139
144
  };
145
+ const rules = isRulesGame(games.find((g) => g.id === game)) ? await page.evaluate(() => {
146
+ const out = {};
147
+ for (const pane of ['new', 'today']) {
148
+ const frame = [...document.querySelectorAll('iframe')].find((f) => f.src.includes('/' + pane + '/'));
149
+ const n = frame?.contentWindow?.__homieNet;
150
+ out[pane] = n ? { format: typeof n.probe?.hosted === 'function', hosting: n.rulesHosting, connected: n.connected, tick: n.probe?.tick?.(), status: n.probe?.status?.() } : null;
151
+ }
152
+ return out;
153
+ }) : null;
154
+ if (rules && (!rulesRun(rules.new) || (first.today.available && rules.today?.format && !rulesRun(rules.today)))) pageErrors.push('A rules pane did not play on its local host runtime.');
140
155
  const summary = {
141
- ok: true, game, take: first.take, note: first.note, fps: first.fps, frames: first.frames, device: first.device, seed: first.seed, inputs: first.inputs,
156
+ ok: !rules || pageErrors.length === 0, rules, game, take: first.take, note: first.note, fps: first.fps, frames: first.frames, device: first.device, seed: first.seed, inputs: first.inputs,
142
157
  newBuild: first.newBuild, todayBuild: first.todayBuild, overrides: first.overrides, renderer, software: SOFTWARE_GL.test(String(renderer ?? '')),
143
158
  deterministic, new: summarise(first.new, first), today: summarise(first.today, first), pageErrors,
144
159
  };
@@ -182,7 +197,7 @@ export async function labCheck(root, id, { take = null, today = 'HEAD', device =
182
197
  };
183
198
  if (!out) writeFileSync(join(labDir(root, game), 'latest.json'), `${JSON.stringify(latest, null, 2)}\n`);
184
199
  return {
185
- ok: true, command: 'lab check', game, take: summary.take, out: rel(dir), report: rel(join(dir, 'REPORT.md')), sheet: rel(join(dir, 'sheet.png')), still: rel(stillPath),
200
+ ok: summary.ok, rules: summary.rules, command: 'lab check', game, take: summary.take, out: rel(dir), report: rel(join(dir, 'REPORT.md')), sheet: rel(join(dir, 'sheet.png')), still: rel(stillPath),
186
201
  frames: summary.frames, fps: summary.fps, deterministic, renderer, software: summary.software,
187
202
  phases: { new: summary.new.phases?.map((p) => `${p.name} ${p.frames}f`) ?? [], today: summary.today.available ? summary.today.phases.map((p) => `${p.name} ${p.frames}f`) : null },
188
203
  timeline: { new: (summary.new.phases ?? []).map(({ name, from, to }) => ({ name, from, to })), today: summary.today.available ? summary.today.phases.map(({ name, from, to }) => ({ name, from, to })) : null },
package/lib/lab.mjs CHANGED
@@ -67,6 +67,20 @@ export function readTunables(dir) {
67
67
  return json;
68
68
  }
69
69
 
70
+ /** Rules keep public movement knobs grouped on disk; sliders name them public.speed, public.jump, and so on. */
71
+ export function flatTunables(spec) {
72
+ if (!spec) return null;
73
+ const { public: pub, ...rest } = spec;
74
+ return { ...rest, ...Object.fromEntries(Object.entries(pub ?? {}).map(([k, v]) => [`public.${k}`, v])) };
75
+ }
76
+ export function setLabTunables(spec, values) {
77
+ const r = setTunables(flatTunables(spec), values);
78
+ if (!spec?.public) return r;
79
+ const out = { ...r.spec, public: { ...spec.public } };
80
+ for (const [k, v] of Object.entries(r.spec)) if (k.startsWith('public.')) { out.public[k.slice(7)] = v; delete out[k]; }
81
+ return { ...r, spec: out };
82
+ }
83
+
70
84
  /** `homie-studio lab set <game> name=value ...`: values written into tunables.json, one tunable a line. */
71
85
  export function labSet(root, id, pairs) {
72
86
  const g = gameOf(root, id);
@@ -74,12 +88,12 @@ export function labSet(root, id, pairs) {
74
88
  if (!spec) return { ok: false, command: 'lab set', why: `games/${g.id} has no tunables.json yet: the lab skill says how a game gets one (lab.tunables)` };
75
89
  const values = {};
76
90
  for (const p of pairs) {
77
- const m = /^([A-Za-z_$][\w$]{0,47})=(.+)$/.exec(String(p));
91
+ const m = /^((?:public\.)?[A-Za-z_$][\w$]{0,47})=(.+)$/.exec(String(p));
78
92
  if (!m) return { ok: false, command: 'lab set', why: `"${p}" is not name=value` };
79
93
  values[m[1]] = m[2];
80
94
  }
81
95
  if (!Object.keys(values).length) return { ok: false, command: 'lab set', why: 'usage: homie-studio lab set <game> <name>=<value> ...' };
82
- const r = setTunables(spec, values);
96
+ const r = setLabTunables(spec, values);
83
97
  if (r.refused.length) return { ok: false, command: 'lab set', why: r.refused.join('; ') };
84
98
  writeFileSync(join(g.dir, 'tunables.json'), formatTunables(r.spec));
85
99
  return { ok: true, command: 'lab set', game: g.id, file: `games/${g.id}/tunables.json`, changed: r.changed };
@@ -179,7 +193,7 @@ class Builds {
179
193
  const t0 = Date.now();
180
194
  this.new = { ...this.new, building: true };
181
195
  try {
182
- const r = await buildGameFiles(this.esbuild, this.root, g, out, { sourcemap: 'linked', log: this.log });
196
+ const r = await buildGameFiles(this.esbuild, this.root, g, out, { sourcemap: 'linked', lab: true, log: this.log });
183
197
  this.new = { ok: true, building: false, error: null, dir: out, at: new Date().toISOString(), ms: Date.now() - t0, bytes: sizeOf(join(out, 'assets', 'main.js')), metafile: r.metafile, mode: r.mode, dirty: dirtyOf(this.root, this.id) };
184
198
  } catch (error) {
185
199
  this.new = { ok: false, building: false, error: error.message, dir: out, at: new Date().toISOString() };
@@ -209,7 +223,7 @@ class Builds {
209
223
  const g = listGames(dir).find((x) => x.id === this.id);
210
224
  if (!g) throw new Error(`games/${this.id}/game.json is not in ${c.short}`);
211
225
  this.esbuild ??= await studioEsbuild(this.root);
212
- await buildGameFiles(this.esbuild, dir, g, out, { sourcemap: 'linked', log: this.log });
226
+ await buildGameFiles(this.esbuild, dir, g, out, { sourcemap: 'linked', lab: true, log: this.log });
213
227
  let tunables = null;
214
228
  try { tunables = readTunables(g.dir); } catch { tunables = null; }
215
229
  const at = new Date().toISOString();
@@ -350,7 +364,7 @@ export async function startLabServer(root, { port = LAB_PORT, today = 'HEAD', lo
350
364
  if (what === 'tunables') {
351
365
  const spec = readTunables(dir);
352
366
  if (!spec) return send(res, 400, { ok: false, why: `games/${id} has no tunables.json` });
353
- const r = setTunables(spec, json.values ?? {});
367
+ const r = setLabTunables(spec, json.values ?? {});
354
368
  if (r.refused.length) return send(res, 400, { ok: false, why: r.refused.join('; ') });
355
369
  writeFileSync(join(dir, 'tunables.json'), formatTunables(r.spec));
356
370
  log(`kept in games/${id}/tunables.json: ${r.changed.map((c) => `${c.name} ${c.from} -> ${c.to}`).join(', ') || 'nothing changed'}`);
@@ -422,7 +436,7 @@ function stateOf(root, b) {
422
436
  let tunablesError = null;
423
437
  try { tunables = readTunables(g.dir); } catch (error) { tunablesError = error.message; }
424
438
  const pane = (p) => ({ ok: p.ok, building: p.building, error: p.error ?? null, none: p.none ?? null, at: p.at ?? null, ms: p.ms ?? null, bytes: p.bytes ?? null });
425
- const todayValues = b.today.tunables ? Object.fromEntries(Object.entries(b.today.tunables).map(([k, s]) => [k, tunableValue(s)])) : null;
439
+ const todayValues = b.today.tunables ? Object.fromEntries(Object.entries(flatTunables(b.today.tunables)).map(([k, s]) => [k, tunableValue(s)])) : null;
426
440
  return {
427
441
  ok: true,
428
442
  version: STUDIO_VERSION,
@@ -431,7 +445,7 @@ function stateOf(root, b) {
431
445
  new: { ...pane(b.new), dirty: b.new.dirty ?? null, mode: b.new.mode ?? null },
432
446
  today: { ...pane(b.today), ref: b.todayRef, commit: b.today.commit ?? null },
433
447
  takes: { default: takes.default, takes: takes.takes, problems: takes.problems, file: `games/${g.id}/lab.json`, exists: takes.exists },
434
- tunables: { file: `games/${g.id}/tunables.json`, spec: tunables, today: todayValues, error: tunablesError },
448
+ tunables: { file: `games/${g.id}/tunables.json`, spec: flatTunables(tunables), today: todayValues, error: tunablesError },
435
449
  };
436
450
  }
437
451
 
package/lib/mcp-tools.mjs CHANGED
@@ -28,7 +28,7 @@ import { cliJob, findNode, getJob, installJob, installed, jobView, runningJobs,
28
28
  import { Feed, currentId, publicFeed, readFeed, startProgress } from './progress.mjs';
29
29
  import { newStudio, slugify } from './scaffold.mjs';
30
30
  import { repoFromUrl, studioRepo } from './repo.mjs';
31
- import { GAME_ID, PACKAGE_ROOT, findStudio, listGames, readLocal, readStudio, siteUrl } from './studio.mjs';
31
+ import { GAME_ID, PACKAGE_ROOT, findStudio, isRulesGame, listGames, readLocal, readStudio, siteUrl } from './studio.mjs';
32
32
  import { runningDev } from './dev.mjs';
33
33
  import { deployWords } from './deploy-state.mjs';
34
34
  import { partsForListing, publishBefore, publishesSoFar } from './directory.mjs';
@@ -1018,6 +1018,7 @@ export function toolDefs(ctx, avail = {}) {
1018
1018
  if (!game) return fail(games.length ? `which game? ${games.map((g) => g.id).join(', ')}` : 'there is no game to check yet');
1019
1019
  const url = a.url ? String(a.url) : await devUrl(ctx, root);
1020
1020
  return startRun(ctx, root, { kind: 'check', game, title: `${games.find((g) => g.id === game)?.name ?? game}: two-browser check`, steps: [
1021
+ ...(games.some((g) => g.id === game && isRulesGame(g)) ? [{ label: 'rules build check', args: ['build', game] }] : []),
1021
1022
  { label: 'two-browser check', args: ['check', game, '--url', url, '--shots', join('.checks', game)] },
1022
1023
  ] });
1023
1024
  },