@homie-rocks/studio 0.37.2 → 0.39.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 (48) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/bin/homie-studio.mjs +16 -4
  3. package/lib/build.mjs +3 -0
  4. package/lib/cloudflare.mjs +2 -2
  5. package/lib/dev.mjs +12 -1
  6. package/lib/mcp-tools.mjs +10 -0
  7. package/lib/perf.mjs +17 -10
  8. package/lib/port-check.mjs +101 -19
  9. package/lib/rules-check-worker.mjs +15 -2
  10. package/lib/scaffold.mjs +19 -6
  11. package/lib/shoot.mjs +11 -5
  12. package/lib/studio.mjs +1 -1
  13. package/lib/template-history.json +85 -0
  14. package/lib/tools-build.mjs +55 -0
  15. package/lib/tools-cli.mjs +55 -0
  16. package/lib/worker-build.mjs +35 -0
  17. package/netplay/NETPLAY.md +20 -6
  18. package/netplay/netplay.ts +3 -1
  19. package/package.json +17 -6
  20. package/port/probe.ts +2 -1
  21. package/rules/core.ts +3 -1
  22. package/rules/host.ts +3 -1
  23. package/rules/pack.ts +30 -3
  24. package/rules/view.ts +232 -40
  25. package/starters/coin-dash/lab.json +17 -0
  26. package/starters/coin-dash/src/rules.ts +3 -3
  27. package/starters/gem-rush/game.json +19 -4
  28. package/starters/gem-rush/map/main.json +50 -0
  29. package/starters/gem-rush/src/move.ts +23 -0
  30. package/starters/gem-rush/src/rules.ts +118 -0
  31. package/starters/gem-rush/src/view.ts +429 -0
  32. package/starters/gem-rush/tunables.json +98 -11
  33. package/tools/TOOLS.md +113 -0
  34. package/tools/tools.ts +28 -0
  35. package/worker/app-records.mjs +8 -0
  36. package/worker/index.mjs +26 -0
  37. package/worker/mcp-room.mjs +61 -0
  38. package/worker/mcp-store.mjs +45 -0
  39. package/worker/mcp-tools.mjs +105 -0
  40. package/worker/mcp.mjs +159 -0
  41. package/worker/office-page.mjs +1 -1
  42. package/worker/office.mjs +2 -1
  43. package/worker/purchase-mcp.mjs +3 -3
  44. package/worker/tool-events.mjs +14 -0
  45. package/worker/tool-identity.mjs +4 -0
  46. package/worker/tool-webhook.mjs +28 -0
  47. package/worker/version.mjs +1 -1
  48. package/starters/gem-rush/src/main.ts +0 -1087
package/CHANGELOG.md CHANGED
@@ -17,6 +17,67 @@ 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.39.0] - 2026-10-09
21
+
22
+ **Plugin 0.39.0** · [#78](https://github.com/homie-rocks/homie/pull/78)
23
+
24
+ Your studio has its own MCP server. Connect your AI with one browser approval and add tools for the work your studio does.
25
+
26
+ ### Added
27
+
28
+ - Connect Claude, ChatGPT or Grok to your studio's own `/mcp` address. Use your existing studio account; there is no key to find or copy. Client setup and workspace policies still apply.
29
+ - Let your AI use the office, app records, shared parts and marked guide seats in rooms. Staff keep their own app permissions. Destructive office controls still wait for your confirmation.
30
+ - Add a typed tool with `homie-studio tool new`, test it with `tool call` and share its code as a part. Tools can also supply prompts and signed inbound integrations; readable app records are resources.
31
+ - Review AI connections and recent tool calls in the office. Revoke a connection there. Role checks, validated input and configurable call limits apply on the server.
32
+
33
+ ### Changed
34
+
35
+ - Worker builds keep MCP libraries in separate modules, loaded when that surface is used. Ordinary page traffic performs no MCP storage work.
36
+
37
+ ### Fixed
38
+
39
+ - Browser input checks wait for the final touch movement to reach the game before measuring its response, including when Chrome delays delivery. Successful browser calls clear their timeout timers.
40
+
41
+ - Clean installs on Node 24 include the MCP SDK required by the payment library, so purchase tools and selling builds work alongside the studio MCP server.
42
+ - MCP integration tests close clients after failures as well as successes. The desktop check clears request timers instead of waiting an extra minute after success. Package and plugin test files have a hard timeout.
43
+
44
+ ### Upgrade notes
45
+
46
+ - Ask your AI to upgrade and build your studio. The build writes Worker modules and the normal deploy applies the new connection and audit tables. No separate Homie service or Cloudflare resource is needed.
47
+ - Studio tools are trusted code in your Worker. Review tools received as parts before adding them. The tool context does not expose raw credentials.
48
+
49
+ ## [0.38.0] - 2026-10-09
50
+
51
+ **Plugin 0.39.0**
52
+
53
+ Your own character answers as soon as you press, even when the room runs on the server.
54
+
55
+ ### Added
56
+
57
+ - Rules games predict your own movement with the same movement code the host runs. They ease corrections from the host and smoothly draw everyone else. Authors get this through the view library.
58
+ - The build checks that browser movement agrees with the server and stays within its budget.
59
+
60
+ ### Changed
61
+
62
+ - New Gem Rush games use rules plus view, with the server keeping movement, pickups and scores. The canvas, controls, waves and knockback keep their familiar look and play. Coin Dash also uses server movement with prediction.
63
+
64
+ ### Fixed
65
+
66
+ - Rules prediction keeps movement continuous when snapshots replace its catch-up path or its input clock recalibrates, fixing backward steps on delayed, lossy connections.
67
+ - A rules correction that catches up to the current fractional tick uses the next movement preview instead of holding still and jumping when the next whole tick arrives. Snapshots also preserve the drawn catch-up pose when they replace its history. Offset fading is bounded by normal movement speed, so it does not compound a fast knock's catch-up speed.
68
+ - Gem Rush reports its downward Y axis to the rules probe, so control checks judge its actual on-screen direction. Rules views can supply their camera axes through `screenBasis`.
69
+ - Playtest no longer attributes its default timeout duration to a rules game that declares no round duration in `game.json`.
70
+ - Perf checks body presence in the last drawn frame without imposing a hidden 400 ms frame-rate requirement on software rendering.
71
+ - Studio checks accept two replicas when the server hosts the rules. Perf reports and regression guards include both replicas, and choose a replica as the default goal for server rooms.
72
+ - Shoot sends held keys to the game frame and reports a server-only preview without a running room as not playing. Its saved receipt now includes the final verdict.
73
+ - The Game Lab exits after writing its completed report, even when Chrome leaves a pipe open. Coin Dash includes a reproducible Lab take.
74
+ - Control timing starts at the game frame's receipt of a key or completed thumb gesture, judges the first changed frame, collects enough frames on a slow renderer, and measures the round clock between matching observations (including slow screenshots). The response and steering limits are unchanged.
75
+ - The controls check exercises a browser disconnect and late joining in server rooms as well as host takeover in browser rooms. A hold with too little runway is repeated after moving away from the wall, with the same steering requirements.
76
+
77
+ ### Upgrade notes
78
+
79
+ - Existing games keep their own source when you upgrade. Games written the old way keep working.
80
+
20
81
  ## [0.37.2] - 2026-10-09
21
82
 
22
83
  **Plugin 0.38.2** · [#70](https://github.com/homie-rocks/homie/pull/70)
@@ -155,6 +155,8 @@
155
155
  * homie-studio servers new <game> "<Name>" --policy open|humans-only|hybrid|beginner [--ai <n>] [--guides <n>]
156
156
  * [--kids] [--door open|accounts|invite] [--level 1-5] [--level-max 1-5]
157
157
  * [--speech game|lines|off] [--bots fill|off] [--rooms <n>] [--listed on|off]
158
+ * homie-studio tool new <name> [--app <id>]
159
+ * homie-studio tool call <name> --input <JSON> [--url <site>] [--public]
158
160
  * homie-studio servers set <game> <server> [the same flags]
159
161
  * homie-studio servers close <game> <server> [--reopen]
160
162
  * homie-studio servers level <game> <room> <1-5> (the AI's level in one room: Rookie, Steady, Fair, Strong, Maxed)
@@ -336,7 +338,7 @@ import { publish, publishBefore } from '../lib/directory.mjs';
336
338
  import { look } from '../lib/look.mjs';
337
339
  import { importPort, planPort } from '../lib/port.mjs';
338
340
  import { portCheck } from '../lib/port-check.mjs';
339
- import { DEFAULT_GOAL, perfCompare, perfRun, perfSizes } from '../lib/perf.mjs';
341
+ import { perfCompare, perfRun, perfSizes } from '../lib/perf.mjs';
340
342
  import { LAB_PORT, labServe, labSet, labStop } from '../lib/lab.mjs';
341
343
  import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeOf, typeOf } from '../lib/media.mjs';
342
344
  import { newStudio } from '../lib/scaffold.mjs';
@@ -377,7 +379,7 @@ import { standaloneDeployNotes } from '../lib/standalone.mjs';
377
379
 
378
380
  const argv = process.argv.slice(2);
379
381
  const flags = new Map();
380
- 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'];
382
+ const BOOL_FLAGS = ['public', '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'];
381
383
  const positional = [];
382
384
  for (let i = 0; i < argv.length; i++) {
383
385
  const a = argv[i];
@@ -487,7 +489,15 @@ function print(result) {
487
489
  lines.push(`R2 holds about ${sizeOf(result.inR2)} of this studio's media. ${result.cost}`, ...(result.warning ? [`Warning: ${result.warning}`] : []));
488
490
  break;
489
491
  }
492
+ case 'tool new':
493
+ lines.push(result.file, result.next);
494
+ break;
495
+ case 'tool call':
496
+ case 'tool list':
497
+ lines.push(JSON.stringify(result.result, null, 2));
498
+ break;
490
499
  case 'deploy':
500
+ if (result.mcp) lines.push(`Connect your AI: ${result.mcp} (approve in your studio's browser page; no key)`);
491
501
  if (result.ci) {
492
502
  lines.push(`Live: ${result.url ?? '(Wrangler printed no address)'}${result.commit ? ` (commit ${result.commit.slice(0, 7)}${result.branch ? ` on ${result.branch}` : ''})` : ''}`, ...result.steps.map((x) => ` ${x.what}`), ...result.games.map((g) => ` ${g.id}: ${g.play}${g.hash ? ` [${deployWords(g)}]` : ''}`));
493
503
  break;
@@ -823,6 +833,7 @@ const OUTWARD_FLAGS = {
823
833
  'shop connect': ['url', 'managed', 'live', 'manual', 'renew', 'from-clipboard'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note', 'manual-transaction'], 'shop statements': ['url', 'period', 'send', 'cursor'],
824
834
  'agents pass': ['url', 'label', 'server', 'hands', 'role', 'days'], 'agents revoke': ['url'], 'agents brain': ['url', 'budget', 'remove'],
825
835
  'agents sit': ['url', 'server', 'pass', 'label', 'brain'],
836
+ 'tool new': ['app'], 'tool call': ['url', 'input', 'public'], 'tool list': ['url', 'public'],
826
837
  // With --device it adds a phone to the person's Apple team and installs on it: a flag it does not know stops it.
827
838
  'standalone run': ['for', 'device', 'site'],
828
839
  };
@@ -878,11 +889,12 @@ async function main() {
878
889
  }
879
890
  if (cmd === 'perf' && sub === 'compare') {
880
891
  if (!positional[2] || !positional[3]) return { ok: false, command: 'perf compare', why: 'usage: homie-studio perf compare <before dir> <after dir> [--goal <metric>] [--min 3]' };
881
- return perfCompare(positional[2], positional[3], { goal: String(flags.get('goal') ?? DEFAULT_GOAL), min: flags.has('min') ? Number(flags.get('min')) / 100 : 0.03, guards: flags.get('guards') ? String(flags.get('guards')).split(',').map((x) => x.trim()).filter(Boolean) : null, also: flags.get('also') ? String(flags.get('also')).split(',').map((x) => x.trim()).filter(Boolean) : [] });
892
+ return perfCompare(positional[2], positional[3], { goal: flags.has('goal') ? String(flags.get('goal')) : null, min: flags.has('min') ? Number(flags.get('min')) / 100 : 0.03, guards: flags.get('guards') ? String(flags.get('guards')).split(',').map((x) => x.trim()).filter(Boolean) : null, also: flags.get('also') ? String(flags.get('also')).split(',').map((x) => x.trim()).filter(Boolean) : [] });
882
893
  }
883
894
  // The starter library and the optimiser work anywhere; the rest of style and assets inside a studio (lib/art-cli.mjs).
884
895
  if (cmd === 'assets' && ['find', 'optimise', 'optimize'].includes(sub)) return assetsCommand(null, sub, positional, flags, { log });
885
896
  const root = requireStudio();
897
+ if (cmd === 'tool') return (await import('../lib/tools-cli.mjs')).toolCommand(root, sub, positional, flags, { log });
886
898
  // A trailer is the plugin's video skill; this hands the words after the id over to it (lib/trailer.mjs).
887
899
  if (cmd === 'trailer') return trailerCommand(root, sub, argv.slice(argv.indexOf(sub) + 1), { skills: flags.get('skills') ?? null, slug: flags.get('slug') ?? null });
888
900
  if (cmd === 'style') return styleCommand(root, sub, positional, flags, { log });
@@ -1327,7 +1339,7 @@ try {
1327
1339
  if (result && result.command !== 'help') print(result);
1328
1340
  if (result?.ok === false) process.exitCode = 1;
1329
1341
  // Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
1330
- if (['check', 'port check', 'look', 'perf', 'shoot'].includes(result?.command)) process.exit(process.exitCode ?? 0);
1342
+ if (['check', 'port check', 'look', 'perf', 'shoot', 'lab check'].includes(result?.command)) process.exit(process.exitCode ?? 0);
1331
1343
  } catch (error) {
1332
1344
  await flushProgress().catch(() => {});
1333
1345
  print({ ok: false, why: error instanceof Error ? error.message : String(error) });
package/lib/build.mjs CHANGED
@@ -491,6 +491,7 @@ export function orderGames(games, order, log = () => {}) {
491
491
  export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false, beforePublish = async () => {}, longCheck = false } = {}) {
492
492
  const esbuild = await studioEsbuild(root);
493
493
  const studio = readStudio(root);
494
+ await (await import('./tools-build.mjs')).buildTools(root, esbuild);
494
495
  // The shop first (shop/SHOP.md): invalid shop settings stop the build before anything is built.
495
496
  const shop = shopForBuild(root, { log });
496
497
  const live = join(root, 'site', 'dist');
@@ -689,6 +690,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
689
690
  ...(audienceOf(studio) !== 'general' ? { audience: audienceOf(studio) } : {}),
690
691
  ...(studio.referrals === false ? { referrals: false } : {}),
691
692
  ...(lounge ? { lounge } : {}),
693
+ ...(studio.mcp ? { mcp: studio.mcp } : {}),
692
694
  build: buildInfo(root),
693
695
  },
694
696
  games: rows,
@@ -723,6 +725,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
723
725
  // site, never before: a game that did not build has left both as they were.
724
726
  await beforePublish();
725
727
  const hosted = (await rulesBuild()).writeRules(root, ruled, all.filter((g) => rulesOf.get(g.id)?.host === 'server').map((g) => g.id));
728
+ await (await import('./worker-build.mjs')).buildWorker(root, esbuild);
726
729
  // All of it is there: now, and only now, it becomes site/dist (lib/stage.mjs).
727
730
  const swapped = swapIn(dist, live);
728
731
  return {
@@ -466,7 +466,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
466
466
  const next = { ...kept, ...(cf.url && !isWorkersDev(cf.url) ? { url: cf.url } : {}), accountId, d1Id: db.uuid, r2: cf.r2 ?? null, created: [...created].sort() };
467
467
  writeStudio(root, { ...readStudio(root), cloudflare: next });
468
468
  return {
469
- ok: true, command: 'deploy', url, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
469
+ ok: true, command: 'deploy', url, mcp: url ? `${url}/mcp` : null, connectAI: url ? `${url}/_studio/office/connections` : null, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
470
470
  ...(media ? { media: { moved: media.moved ?? [], failed: media.failed ?? [], inR2: media.inR2 ?? null } } : {}),
471
471
  games: b.catalogue.map((id) => { const g = delta.games.find((x) => x.id === id); return { id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null, hash: g?.hash ?? null, change: delta.first ? 'first deploy from this computer' : g?.change ?? null }; }),
472
472
  ...(delta.removed.length ? { removed: delta.removed } : {}),
@@ -628,7 +628,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
628
628
  const hashes = builtGames(root, games);
629
629
  for (const id of games) if (hashes[id]?.hash) step(`${id}: ${deployWords(hashes[id])}`);
630
630
  return {
631
- ok: true, command: 'deploy', ci: true, url, worker: names.worker, d1: names.d1, r2: null, steps, announced: [],
631
+ ok: true, command: 'deploy', ci: true, url, mcp: url ? `${url}/mcp` : null, connectAI: url ? `${url}/_studio/office/connections` : null, worker: names.worker, d1: names.d1, r2: null, steps, announced: [],
632
632
  commit: process.env.WORKERS_CI_COMMIT_SHA ?? null, branch: process.env.WORKERS_CI_BRANCH ?? null,
633
633
  // A CI checkout has no earlier deploy to compare with: each game's content hash only (lib/deploy-state.mjs).
634
634
  games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null, hash: hashes[id]?.hash ?? null })), songs: [], videos: [],
package/lib/dev.mjs CHANGED
@@ -459,7 +459,18 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
459
459
  return;
460
460
  }
461
461
  };
462
- const timer = setInterval(() => { look().catch(() => {}); rulesLook().catch(() => {}); }, watchMs);
462
+ const { toolFiles } = await import('./tools-build.mjs');
463
+ const toolsStamp = () => toolFiles(root).map(file => `${file}:${statSync(file).mtimeMs}:${statSync(file).size}`).join('|');
464
+ let lastTools = toolsStamp();
465
+ const toolsLook = async () => {
466
+ if (rebuilding || ending) return;
467
+ const stamp = toolsStamp(); if (stamp === lastTools) return;
468
+ lastTools = stamp; rebuilding = true;
469
+ try { log('Studio tools changed: type-checking and rebuilding…'); await build(root, { log }); }
470
+ catch (error) { log(`Studio tools did not build: ${error.message}`); }
471
+ finally { rebuilding = false; }
472
+ };
473
+ const timer = setInterval(() => { look().catch(() => {}); rulesLook().catch(() => {}); toolsLook().catch(() => {}); }, watchMs);
463
474
  timer.unref?.();
464
475
 
465
476
  /** Wait for the local site, then hold it to its own address (socketOriginProblem); a mismatch ends dev. */
package/lib/mcp-tools.mjs CHANGED
@@ -782,6 +782,16 @@ export function toolDefs(ctx, avail = {}) {
782
782
  return r.ok ? ok('Node and npm are ready. AI: continue studio_scaffold.', r) : fail(r.next, r);
783
783
  },
784
784
  },
785
+ {
786
+ name: 'studio_connect_ai', title: 'Connect your AI to the studio',
787
+ description: 'Give the person this studio’s own remote MCP address and connection page. Their AI connects directly with browser approval and their existing studio account; no key or local server.',
788
+ inputSchema: { type: 'object', properties: { ...STUDIO_ARG } },
789
+ annotations: { title: 'Connect your AI', ...RO },
790
+ run: async (a) => {
791
+ const root = ctx.root(a.studio), site = siteUrl(root);
792
+ return site ? ok(`Connect your AI to ${site}/mcp. Approve with your studio account in the browser. Manage connections at ${site}/_studio/office/connections. Client setup varies; do not promise a one-click import a client does not support.`, { mcp: `${site}/mcp`, connections: `${site}/_studio/office/connections` }) : fail('This studio has no published address yet. Local tools can be tested with homie-studio dev.');
793
+ },
794
+ },
785
795
  {
786
796
  name: 'studio_domain', title: 'Set the studio domain',
787
797
  description: 'Prepare a requested custom hostname for deployment. Cloudflare creates DNS and TLS using its existing browser sign-in. The AI handles configuration; never ask for an account id, zone id or DNS value. Does not deploy or change the domain registrar.',
package/lib/perf.mjs CHANGED
@@ -194,6 +194,17 @@ export function renderCostOf(samples) {
194
194
  return { samples: Math.max(calls.length, tris.length), drawCalls: sum(calls), triangles: sum(tris) };
195
195
  }
196
196
 
197
+ /** Presence in the last drawn frame is independent of its frame rate. Runs measure frame timing separately. */
198
+ export function gameReadiness() {
199
+ const p = window.__homiePort;
200
+ let self = null;
201
+ if (p && p.view !== 'board') {
202
+ const rows = p.rows(); const last = rows[rows.length - 1];
203
+ self = Boolean(last && Number.isFinite(last[1]) && Number.isFinite(last[2]));
204
+ }
205
+ return { first: window.__perf?.first?.() ?? null, port: Boolean(p), view: p?.view ?? null, self };
206
+ }
207
+
197
208
  /** Open one browser of a run on the play page, instrumented, and wait until it is playable. */
198
209
  async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
199
210
  const dev = PERF_DEVICES[device];
@@ -243,12 +254,7 @@ async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
243
254
  shell = await T(page.evaluate(() => { const s = window.__shell; const a = s?.arrival; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, arrival: a ? { phase: a.phase, lookMs: a.lookMs, liftedMs: a.liftedMs, by: a.by, mode: a.mode, explicitMs: a.explicitMs, lateMs: a.lateMs } : null } : null; }), 5000);
244
255
  const now = Date.now();
245
256
  if (seatedAt === null && shell?.room && Number.isInteger(shell.seat) && shell.role) seatedAt = now;
246
- const g = await inGame(h, () => {
247
- const p = window.__homiePort;
248
- let self = null;
249
- if (p && p.view !== 'board') { const r = p.rows(p.now() - 400); const last = r[r.length - 1]; self = last ? Number.isFinite(last[1]) : false; }
250
- return { first: window.__perf?.first?.() ?? null, port: Boolean(p), view: p?.view ?? null, self };
251
- });
257
+ const g = await inGame(h, gameReadiness);
252
258
  // The play page's arrival card (0.26.0) covers the game until the game says it is playable: not playable before.
253
259
  const lifted = !shell?.arrival || shell.arrival.phase === 'done';
254
260
  if (seatedAt !== null && g?.first !== null && g?.first !== undefined && lifted) {
@@ -651,7 +657,7 @@ function arrivalWords(summary, device, role, readyMs) {
651
657
  function headlineOf(summary) {
652
658
  const out = [];
653
659
  for (const device of summary.devices) {
654
- for (const role of ['host', 'replica']) {
660
+ for (const role of ['host', 'replica', 'replica-2']) {
655
661
  const m = (k) => summary.metrics[`${device}.${role}.${k}`]?.median;
656
662
  if (m('frame.p50') === undefined) continue;
657
663
  out.push(`${device} ${role}: frames ${m('frame.p50')} ms median, ${m('frame.p95')} ms p95, ${m('frame.over50') ?? 0}% over 50 ms; game JS ${m('work.p50')} ms a frame (p95 ${m('work.p95')}); main thread ${m('busy')} ms a frame; first look at ${m('load.look') ?? '?'} ms, playable (control-ready) at ${m('load.playable') ?? '?'} ms${arrivalWords(summary, device, role, m('load.ready'))}; netplay ${m('net.msgsOut')} out / ${m('net.msgsIn')} in a second; heap ${m('heap')} MB; fetched before playable (measured) ${m('load.prePlayKb') ?? '?'} KB; renderer ${m('render.calls') !== undefined ? `${m('render.calls')} draw calls, ${m('render.triangles') ?? '?'} triangles (measured while playing)` : 'cost not exposed by the game (not measured)'}`);
@@ -749,8 +755,8 @@ export function readRuns(dir) {
749
755
  /** The metrics guarded by default: frame time and main-thread work of each role, time to playable and heap. */
750
756
  export function defaultGuards(devices, goal) {
751
757
  const g = [];
752
- for (const d of devices) for (const role of ['host', 'replica']) g.push(`${d}.${role}.frame.p95`, `${d}.${role}.busy`, `${d}.${role}.load.playable`);
753
- for (const d of devices) g.push(`${d}.host.heap`, `${d}.host.net.kbOut`);
758
+ for (const d of devices) for (const role of ['host', 'replica', 'replica-2']) g.push(`${d}.${role}.frame.p95`, `${d}.${role}.busy`, `${d}.${role}.load.playable`);
759
+ for (const d of devices) for (const role of ['host', 'replica', 'replica-2']) g.push(`${d}.${role}.heap`, `${d}.${role}.net.kbOut`);
754
760
  return g.filter((k) => k !== goal);
755
761
  }
756
762
 
@@ -758,10 +764,11 @@ export function defaultGuards(devices, goal) {
758
764
  * `homie-studio perf compare <before> <after>`: the goal metric and the guards, before against after, from the runs
759
765
  * in two folders. A run that was blocked, taken on a busy computer or profiled is left out (and counted).
760
766
  */
761
- export function perfCompare(beforeDir, afterDir, { goal = DEFAULT_GOAL, guards = null, also = [], min = 0.03, write = true } = {}) {
767
+ export function perfCompare(beforeDir, afterDir, { goal = null, guards = null, also = [], min = 0.03, write = true } = {}) {
762
768
  const A = readRuns(beforeDir);
763
769
  const B = readRuns(afterDir);
764
770
  if (!A.runs.length || !B.runs.length) return { ok: false, command: 'perf compare', why: `no runs in ${!A.runs.length ? beforeDir : afterDir} (folders that homie-studio perf wrote)` };
771
+ goal ??= A.runs.every((r) => r.room?.hosted === 'server') ? DEFAULT_GOAL.replace('.host.', '.replica.') : DEFAULT_GOAL;
765
772
  const usable = (r) => !r.blocked && !r.loaded;
766
773
  const left = { before: A.runs.filter((r) => !usable(r)).length, after: B.runs.filter((r) => !usable(r)).length };
767
774
  const flat = (runs, sizes) => {
@@ -44,7 +44,11 @@ const GPU = [
44
44
  const TAG = 'homie-studio-check';
45
45
  const ANDROID_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36';
46
46
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
47
- const T = (p, ms, v = null) => Promise.race([Promise.resolve(p).catch(() => v), sleep(ms).then(() => v)]);
47
+ const T = async (p, ms, v = null) => {
48
+ let timer;
49
+ try { return await Promise.race([Promise.resolve(p).catch(() => v), new Promise(resolve => { timer = setTimeout(() => resolve(v), ms); })]); }
50
+ finally { clearTimeout(timer); }
51
+ };
48
52
  const DIRV = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
49
53
  const ALL = ['owner-desk', 'owner-phone', 'owner-iphone', 'round', 'life', 'tv'];
50
54
 
@@ -60,6 +64,25 @@ function yawDeg(a, b) {
60
64
  const d = a[5] * b[5] + a[6] * b[6]; const la = Math.hypot(a[5], a[6]) || 1; const lb = Math.hypot(b[5], b[6]) || 1;
61
65
  return (Math.acos(Math.max(-1, Math.min(1, d / (la * lb)))) * 180) / Math.PI;
62
66
  }
67
+ /** A server room keeps its authority and advances; a browser room must promote a host. */
68
+ export function roomContinued(before, after, serverHosted) {
69
+ return serverHosted
70
+ ? after?.hosted === 'server' && after?.role === 'replica' && Number.isFinite(before?.tick) && after?.tick > before.tick && after?.frames > before.frames
71
+ : after?.role === 'host';
72
+ }
73
+
74
+ /** Compare two observations on the same browser clock, including time spent taking screenshots. */
75
+ export function roundClockContinued(before, after) {
76
+ return !before?.round || !after?.round || before.round.n !== after.round.n
77
+ || Math.abs((before.round.leftMs - after.round.leftMs) - (after.observedAt - before.observedAt)) < 6000;
78
+ }
79
+
80
+ /** A wall can leave too little runway; retry the same hold after moving away, never excuse steering faults. */
81
+ export function needsRunway(res) {
82
+ return !res.ok && (res.why === 'barely moved' || res.why?.startsWith('blocked at once'))
83
+ && res.yawChangeDeg < 10 && (res.dirErrDeg === null || res.dirErrDeg < 15);
84
+ }
85
+
63
86
  const finite = (r) => r && Number.isFinite(r[1]) && Number.isFinite(r[2]);
64
87
  /** A frame the player steers (the probe's `busy` says when a knockback, a stun or a respawn has the body). */
65
88
  const steerable = (r) => finite(r) && !r[8];
@@ -134,6 +157,9 @@ export function judgePresses(rows, presses, size, view) {
134
157
  const out = presses.map((p) => {
135
158
  const seg = rows.filter((r) => r[0] >= p.a && r[0] <= p.b + 60 && steerable(r));
136
159
  if (seg.length < 4) return rows.some((r) => r[0] >= p.a && r[0] <= p.b && finite(r) && r[8]) ? { dir: p.dir, ok: false, blocked: true, busy: true } : { dir: p.dir, ok: false, why: 'no samples' };
160
+ // The first post-input frame can already show the response. Keep its preceding drawn pose.
161
+ const before = rows.filter((r) => r[0] < p.a).at(-1);
162
+ if (before && steerable(before)) seg.unshift(before);
137
163
  const u = DIRV[p.dir]; const r0 = seg[0];
138
164
  let matchAt = null, after = 0, afterOk = 0, maxYaw = 0, peakAlong = 0, contact = false;
139
165
  for (let i = 1; i < seg.length; i++) {
@@ -258,7 +284,7 @@ async function webkit(pw) {
258
284
  const frameOf = (h) => h.page.frames().find((f) => f.url().includes('/__game/')) ?? null;
259
285
  async function inFrame(h, src, v = null) { const f = frameOf(h); return f ? T(f.evaluate(src), 10_000, v) : v; }
260
286
  const shell = (h) => T(h.page.evaluate(() => { const s = window.__shell; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, results: s.results ?? [], facts: s.facts ? { counts: s.facts.counts } : null } : null; }), 8000, null);
261
- const info = (h) => inFrame(h, '(() => { const p = window.__homiePort; if (!p) return null; const i = p.info(); const n = window.__homieNet; i.net = n ? { snapHzIn: n.stats().snapHzIn, snapHzOut: n.stats().snapHzOut, rtt: n.stats().rtt, promotions: n.stats().promotions } : null; i.v = p.view; i.size = p.size; i.keys = p.keys; i.thumb = p.thumb; return i; })()');
287
+ const info = (h) => inFrame(h, '(() => { const p = window.__homiePort; if (!p) return null; const i = p.info(); i.observedAt = performance.now(); const n = window.__homieNet; i.net = n ? { snapHzIn: n.stats().snapHzIn, snapHzOut: n.stats().snapHzOut, rtt: n.stats().rtt, promotions: n.stats().promotions } : null; i.hosted = n?.probe?.hosted?.() ?? null; i.tick = n?.probe?.tick?.() ?? null; i.v = p.view; i.size = p.size; i.keys = p.keys; i.thumb = p.thumb; return i; })()');
262
288
  const rowsSince = (h, t) => inFrame(h, `(window.__homiePort ? window.__homiePort.rows(${Number(t) || 0}) : [])`, []);
263
289
  const frameNow = (h) => inFrame(h, '(window.__homiePort ? window.__homiePort.now() : performance.now())', 0);
264
290
 
@@ -285,13 +311,14 @@ async function focusGame(h) {
285
311
 
286
312
  /* ------------------------------------------------------------------ input */
287
313
 
288
- async function holdKey(h, code, ms) { await h.page.keyboard.down(code); await sleep(ms); await h.page.keyboard.up(code); }
314
+ async function holdKey(h, code, ms, ready) { await h.page.keyboard.down(code); if (ready) await ready(); await sleep(ms); await h.page.keyboard.up(code); }
289
315
 
290
316
  function thumbAt(h, i) { const t = i?.thumb ?? [0.24, 0.74]; return [Math.round(h.vp.width * t[0]), Math.round(h.vp.height * t[1])]; }
291
- async function touchDrag(h, from, dir, reach, holdMs) {
317
+ async function touchDrag(h, from, dir, reach, holdMs, ready) {
292
318
  const [x0, y0] = from; const u = DIRV[dir];
293
319
  await h.touch('touchStart', [{ id: 1, x: x0, y: y0 }]);
294
320
  for (let k = 1; k <= 8; k++) { await h.touch('touchMove', [{ id: 1, x: Math.round(x0 + (u[0] * reach * k) / 8), y: Math.round(y0 + (u[1] * reach * k) / 8) }]); await sleep(12); }
321
+ if (ready) await ready();
295
322
  const end = Date.now() + holdMs;
296
323
  let w = 0;
297
324
  while (Date.now() < end) {
@@ -302,6 +329,50 @@ async function touchDrag(h, from, dir, reach, holdMs) {
302
329
  }
303
330
  await h.touch('touchEnd', h.touchAll ? [] : [{ id: 1, x: Math.round(x0 + u[0] * reach), y: Math.round(y0 + u[1] * reach) }]);
304
331
  }
332
+ /** Timestamp real input delivery, then keep it held until four frames can judge its direction.
333
+ * The response limit stays 600 ms; slow automation delivery and the thumb's ramp are not game latency. */
334
+ export async function measuredPress(h, how, code, from, dir) {
335
+ // CDP points use page coordinates; DOM touch receipts use frame coordinates.
336
+ let offset = { x: 0, y: 0 };
337
+ if (how !== 'keys') {
338
+ const element = await frameOf(h)?.frameElement();
339
+ if (element) {
340
+ try { offset = await element.boundingBox() ?? offset; }
341
+ finally { await element.dispose(); }
342
+ }
343
+ }
344
+ await inFrame(h, `(() => {
345
+ if (!window.__homieCheckInput) {
346
+ const state = window.__homieCheckInput = {};
347
+ addEventListener('keydown', e => { if (state.code === e.code && state.key === null) state.key = performance.now(); }, true);
348
+ addEventListener('touchmove', e => {
349
+ const touch = e.touches[0];
350
+ if (state.touch === null && touch && touch.clientX === state.touchX && touch.clientY === state.touchY) state.touch = performance.now();
351
+ }, { capture: true, passive: true });
352
+ }
353
+ Object.assign(window.__homieCheckInput, { code: ${JSON.stringify(code)}, key: null, touch: null, touchX: ${Math.round(from[0] + DIRV[dir][0] * 70) - offset.x}, touchY: ${Math.round(from[1] + DIRV[dir][1] * 70) - offset.y} });
354
+ })()`);
355
+ let a = null, b = null;
356
+ const ready = async () => {
357
+ // CDP acknowledgement is not DOM delivery. Wait for the final ramp point,
358
+ // then latch that receipt so later/coalesced moves cannot change it.
359
+ const deliveryDeadline = Date.now() + 3000;
360
+ do {
361
+ a = await inFrame(h, `window.__homieCheckInput.${how === 'keys' ? 'key' : 'touch'}`);
362
+ if (Number.isFinite(a)) break;
363
+ await sleep(20);
364
+ } while (Date.now() < deliveryDeadline);
365
+ if (!Number.isFinite(a)) throw new Error('the game frame did not receive the measured input');
366
+ await sleep(420);
367
+ const until = Date.now() + 3000;
368
+ while (Date.now() < until && (await rowsSince(h, a)).length < 4) await sleep(50);
369
+ b = await frameNow(h);
370
+ };
371
+ if (how === 'keys') await holdKey(h, code, 0, ready);
372
+ else await touchDrag(h, from, dir, 70, 0, ready);
373
+ return { dir, a, b };
374
+ }
375
+
305
376
  async function swipe(h, dir) {
306
377
  const x0 = Math.round(h.vp.width * 0.5); const y0 = Math.round(h.vp.height * 0.55); const u = DIRV[dir]; const reach = Math.round(Math.min(h.vp.width, h.vp.height) * 0.3);
307
378
  await h.touch('touchStart', [{ id: 2, x: x0, y: y0 }]);
@@ -365,11 +436,22 @@ async function ownerTests(h, how, log) {
365
436
  const holds = view === 'side' ? ['right', 'left'] : ['down', 'left'];
366
437
  out.holds = [];
367
438
  for (const dir of holds) {
368
- const a = await frameNow(h);
369
- if (how === 'keys') await holdKey(h, keys[dir], 5000); else await touchDrag(h, thumbAt(h, i), dir, 70, 5000);
370
- const b = await frameNow(h);
371
- await sleep(250);
372
- const res = judgeHold(await rowsSince(h, a - 50), dir, a, b, size, view);
439
+ const hold = async () => {
440
+ const a = await frameNow(h);
441
+ if (how === 'keys') await holdKey(h, keys[dir], 5000); else await touchDrag(h, thumbAt(h, i), dir, 70, 5000);
442
+ const b = await frameNow(h);
443
+ await sleep(250);
444
+ return judgeHold(await rowsSince(h, a - 50), dir, a, b, size, view);
445
+ };
446
+ let res = await hold();
447
+ if (needsRunway(res)) {
448
+ const first = res;
449
+ const away = { up: 'down', down: 'up', left: 'right', right: 'left' }[dir];
450
+ log(` ${h.engine}-${h.kind} ${how} hold ${dir}: ${first.why}; move ${away} and retry the same hold`);
451
+ if (how === 'keys') await holdKey(h, keys[away], 1100); else await touchDrag(h, thumbAt(h, i), away, 70, 1100);
452
+ await sleep(400);
453
+ res = { ...await hold(), runwayRetry: first };
454
+ }
373
455
  out.holds.push(res);
374
456
  log(` ${h.engine}-${h.kind} ${how} hold ${dir}: ${res.ok ? 'ok' : `FAIL (${res.why})`} travel ${res.travelBodies} bodies, yaw ${res.yawChangeDeg}°`);
375
457
  await sleep(400);
@@ -383,12 +465,9 @@ async function ownerTests(h, how, log) {
383
465
  const presses = [];
384
466
  const a0 = await frameNow(h);
385
467
  const until = Date.now() + 10_000;
386
- for (let k = 0; Date.now() < until; k++) {
468
+ for (let k = 0; k < alt.length || Date.now() < until; k++) {
387
469
  const dir = alt[k % alt.length];
388
- const a = await frameNow(h);
389
- if (how === 'keys') await holdKey(h, keys[dir], 420); else await touchDrag(h, thumbAt(h, i), dir, 70, 380);
390
- const b = await frameNow(h);
391
- presses.push({ dir, a, b });
470
+ presses.push(await measuredPress(h, how, keys[dir], thumbAt(h, i), dir));
392
471
  await sleep(170);
393
472
  }
394
473
  await sleep(200);
@@ -595,10 +674,13 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
595
674
  if (!oa.ok || !ob.ok) { row('host-kill', false, { why: 'a browser was never seated' }); row('late-join', null, { why: 'skipped: no room' }); collect(A); collect(B); await close(A); await close(B); }
596
675
  else {
597
676
  await sleep(3000);
598
- const ia = await info(A); const ib = await info(B);
599
- if (ia?.role !== 'host') row('host-kill', null, { why: `the first browser was not the host (it was ${ia?.role}); skipped`, ia: ia?.role, ib: ib?.role });
677
+ const ia = await info(A); let ib = await info(B);
678
+ const serverHosted = ia?.hosted === 'server' && ib?.hosted === 'server';
679
+ if (ia?.role !== 'host' && !serverHosted) row('host-kill', null, { why: `the first browser was not the host (it was ${ia?.role}); skipped`, ia: ia?.role, ib: ib?.role });
600
680
  else {
601
681
  await shot(B, 'life-before');
682
+ // Screenshot encoding may span a round on a software renderer. Observe the room we actually leave.
683
+ ib = await info(B);
602
684
  const pids = A.pid ? [A.pid, ...descendants(A.pid)] : [];
603
685
  const killAt = Date.now();
604
686
  for (const p of pids) { try { process.kill(p, 'SIGKILL'); } catch { /* gone */ } }
@@ -606,14 +688,14 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
606
688
  collect(A);
607
689
  void A.close();
608
690
  let promoted = null; let ibAfter = null;
609
- while (Date.now() - killAt < 15_000) { ibAfter = await info(B); if (ibAfter?.role === 'host') { promoted = Date.now() - killAt; break; } await sleep(100); }
691
+ while (Date.now() - killAt < 15_000) { ibAfter = await info(B); if (roomContinued(ib, ibAfter, serverHosted)) { promoted = Date.now() - killAt; break; } await sleep(100); }
610
692
  await sleep(1500);
611
693
  const ib2 = await info(B);
612
694
  const sameRound = ib?.round && ib2?.round && (ib2.round.n === ib.round.n || (ib.round.phase === 'over' || ib.round.leftMs < 3000));
613
- const clockOk = ib?.round && ib2?.round && ib2.round.n === ib.round.n ? Math.abs((ib.round.leftMs - ib2.round.leftMs) - (Date.now() - killAt + 0)) < 6000 : true;
695
+ const clockOk = roundClockContinued(ib, ib2);
614
696
  const running = (ib2?.frames ?? 0) > (ibAfter?.frames ?? 0) && (ib2?.net?.snapHzOut ?? 0) >= 0;
615
697
  await shot(B, 'life-after-kill');
616
- row('host-kill', Boolean(promoted !== null && promoted <= 5000 && sameRound && clockOk && running), { promotedMs: promoted, roundBefore: ib?.round ?? null, roundAfter: ib2?.round ?? null, why: promoted === null ? 'nobody took over as host within 15 s' : promoted > 5000 ? `the phone took over after ${promoted} ms (over 5 s)` : !sameRound ? 'the round did not continue (a new round started)' : !clockOk ? 'the round clock jumped at the takeover' : undefined });
698
+ row('host-kill', Boolean(promoted !== null && promoted <= 5000 && sameRound && clockOk && running), { hosted: serverHosted ? 'server' : 'browser', promotedMs: serverHosted ? null : promoted, continuedMs: serverHosted ? promoted : null, roundBefore: ib?.round ?? null, roundAfter: ib2?.round ?? null, why: promoted === null ? (serverHosted ? 'the server room stopped after a browser left' : 'nobody took over as host within 15 s') : promoted > 5000 ? `the phone took over after ${promoted} ms (over 5 s)` : !sameRound ? 'the round did not continue (a new round started)' : !clockOk ? 'the round clock jumped at the takeover' : undefined });
617
699
  // Late joiner into the same, running room.
618
700
  const C = await launch('desk');
619
701
  const joinAt = Date.now();
@@ -183,12 +183,14 @@ function play(c, companions, allow) {
183
183
  const free = (seat) => { if (!seated.has(seat)) return; seated.delete(seat); did.push(`seat ${seat} left`); frames.push({ t: 'free', seat }); };
184
184
  const bots = (mode) => { policy = { ...(policy ?? {}), bots: mode }; frames.push({ t: 'policy', policy }); };
185
185
 
186
- let A = open({ observe, noted, send });
186
+ const movesToCheck = [];
187
+ const moved = (...args) => movesToCheck.push(args);
188
+ let A = open({ observe, noted, moved, send });
187
189
  for (const m of told()) A.frame(m);
188
190
  /** The room stops and starts again as a deploy restarts it: a new epoch, everybody away until they are back. */
189
191
  const restart = () => {
190
192
  const bytes = A.save(); const epoch = A.epoch + 1; A.stop();
191
- A = open({ observe, noted, send, restore: bytes, restoreEpoch: epoch });
193
+ A = open({ observe, noted, moved, send, restore: bytes, restoreEpoch: epoch });
192
194
  for (const m of told()) A.frame(m);
193
195
  // Everybody is back at once, but for the highest seat, which is a second late.
194
196
  const top = Math.max(...seated.keys());
@@ -342,6 +344,17 @@ function play(c, companions, allow) {
342
344
  const stats = A.core.stats; const cut = stats.ticksCut; const held = stats.held;
343
345
  A.tickNow();
344
346
  units += A.core.stats.tickUnits;
347
+ for (const [name, tick, input, before, after] of movesToCheck.splice(0)) {
348
+ const kind = c.kinds.find(k => k.name === name);
349
+ moveTick = tick;
350
+ const result = L.P.stepMove(kind.move, before, input, moveCtx.get(name), Math.max(1, Math.floor(budget / 4)), kind.motion, c.dims,
351
+ error => { fault ??= `${siteOf(name, 'move')} ${name}.move: prediction failed: ${error.message}.${when()}`; });
352
+ units += result.used;
353
+ const r = kind.body.radius, bounds = c.map.bounds;
354
+ result.pos = L.P.vec3({ x: Math.max(bounds.min.x + r, Math.min(bounds.max.x - r, result.pos.x)), y: Math.max(bounds.min.y + r, Math.min(bounds.max.y - r, result.pos.y)), z: result.pos.z }, c.dims);
355
+ delete result.used;
356
+ if (JSON.stringify(result) !== JSON.stringify(after)) fault ??= `${siteOf(name, 'move')} ${name}.move: prediction and the server produced different bodies from the same input.${when()}`;
357
+ }
345
358
  if (A.core.stats.tickUnits > peak) peak = A.core.stats.tickUnits;
346
359
  if (A.core.stats.maxUnits > most) { most = A.core.stats.maxUnits; worst = A.core.stats.worst; }
347
360
  const facts = A.facts();