@homie-rocks/studio 0.55.0 → 0.57.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 (72) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +2 -0
  3. package/apps/APPS.md +9 -0
  4. package/apps/apps.ts +19 -1
  5. package/bin/homie-studio.mjs +1 -1
  6. package/docs/custom-routes.md +72 -0
  7. package/docs/studio-to-studio.md +248 -0
  8. package/functions/FUNCTIONS.md +9 -0
  9. package/guides/app/SKILL.md +7 -0
  10. package/guides/playtest/scripts/playtest.mjs +7 -5
  11. package/lib/check.mjs +8 -1
  12. package/lib/cloudflare.mjs +52 -22
  13. package/lib/dev.mjs +6 -8
  14. package/lib/local-database-start.mjs +6 -0
  15. package/lib/mcp-tools.mjs +3 -3
  16. package/lib/needs-headless.mjs +1 -1
  17. package/lib/port-check.mjs +3 -2
  18. package/lib/scaffold.mjs +2 -0
  19. package/lib/studios-keys.mjs +13 -0
  20. package/lib/template-history.json +88 -1
  21. package/lib/worker-build.mjs +4 -1
  22. package/lib/worker-config.mjs +7 -0
  23. package/netplay/NETPLAY.md +12 -1
  24. package/netplay/netplay.ts +24 -9
  25. package/package.json +1 -1
  26. package/rules/core.ts +9 -0
  27. package/rules/host.ts +40 -11
  28. package/rules/view.ts +1 -0
  29. package/tools/TOOLS.md +22 -1
  30. package/tools/tools.ts +14 -1
  31. package/worker/background-queue.mjs +1 -0
  32. package/worker/booking-availability.mjs +35 -0
  33. package/worker/booking-holds.mjs +2 -2
  34. package/worker/booking-resources.mjs +14 -6
  35. package/worker/build-id.mjs +1 -1
  36. package/worker/business-events.mjs +2 -1
  37. package/worker/custom-routes.mjs +16 -0
  38. package/worker/gate.mjs +94 -23
  39. package/worker/idle-chat.mjs +56 -0
  40. package/worker/index.mjs +2 -0
  41. package/worker/lazy-objects.mjs +3 -0
  42. package/worker/lobby-peak.mjs +44 -0
  43. package/worker/lobby-shards.mjs +43 -0
  44. package/worker/lobby-store.mjs +35 -0
  45. package/worker/lobby-summary.mjs +28 -0
  46. package/worker/lobby.mjs +86 -18
  47. package/worker/lounge-store.mjs +2 -2
  48. package/worker/match-router.mjs +74 -0
  49. package/worker/mcp-room.mjs +2 -1
  50. package/worker/mcp-store.mjs +3 -1
  51. package/worker/mcp-tools.mjs +29 -4
  52. package/worker/mcp.mjs +43 -8
  53. package/worker/office.mjs +2 -1
  54. package/worker/operational-schema.mjs +2 -0
  55. package/worker/pages.mjs +1 -1
  56. package/worker/relay-channel.mjs +119 -0
  57. package/worker/request-db.mjs +4 -3
  58. package/worker/room-chat.mjs +2 -1
  59. package/worker/room-sessions.mjs +65 -0
  60. package/worker/room.mjs +45 -9
  61. package/worker/route-auth.mjs +22 -0
  62. package/worker/routes.mjs +8 -5
  63. package/worker/runtime-support.mjs +5 -3
  64. package/worker/session-codec.mjs +24 -0
  65. package/worker/stats.mjs +2 -1
  66. package/worker/studios-auth.mjs +71 -0
  67. package/worker/studios-client.mjs +93 -0
  68. package/worker/studios-events.mjs +52 -0
  69. package/worker/studios-keys.mjs +54 -0
  70. package/worker/table.mjs +277 -41
  71. package/worker/version.mjs +1 -1
  72. package/worker/watch-sockets.mjs +75 -0
@@ -1,3 +1,5 @@
1
+ import {ensureStudioSigningKey} from './studios-keys.mjs';
2
+ import {redactText} from './redact.mjs';
1
3
  import {enableReadReplication} from './read-replication.mjs';
2
4
  import {ensureBackgroundQueues,backgroundQueues} from './background-queue.mjs';
3
5
  import { directoryUrl } from '../worker/directory-choice.mjs';
@@ -53,6 +55,13 @@ import { LOCAL_STATE, configPath, domainOrigin, isRulesGame, isWorkersDev, layou
53
55
 
54
56
  const ANSI = /\u001b\[[0-9;]*m/g;
55
57
 
58
+ /** Keep Wrangler's error and its context; progress/footer lines must not hide it. */
59
+ export function wranglerDeployError(out) {
60
+ const clean = redactText(String(out ?? '').replace(ANSI, '')).text;
61
+ const error = clean.search(/(?:^|\n)[^\n]*\[ERROR\]/);
62
+ return (error >= 0 ? clean.slice(error) : clean).trim();
63
+ }
64
+
56
65
  export function experienceAddresses(root, id, url) {
57
66
  const app = listGames(root).find(g => g.id === id)?.kind === 'app';
58
67
  return { [app ? 'open' : 'play']: url ? `${url}/${id}/${app ? 'open' : 'play'}` : null, wall: url ? `${url}/${id}/tv` : null };
@@ -67,24 +76,41 @@ export function wranglerBin(root) {
67
76
  export function runner(root, env) {
68
77
  const bin = wranglerBin(root);
69
78
  if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
70
- return (args, { cwd = workerDir(root), input } = {}) => {
79
+ return (args, { cwd = workerDir(root), input, timeout = 10 * 60_000 } = {}) => {
71
80
  const dev = join(workerDir(root), '.wrangler', 'homie-dev.wrangler.json');
72
81
  if (args.includes('--local') && !args.includes('--config') && existsSync(dev)) args = [...args, '--config', dev, '--persist-to', join(workerDir(root), '.wrangler', 'state')];
73
82
  const res = spawnSync(bin, args, {
74
83
  cwd, env: { ...process.env, ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
75
- input, maxBuffer: 64 * 1024 * 1024, timeout: 10 * 60_000,
84
+ input, maxBuffer: 64 * 1024 * 1024, timeout,
76
85
  });
77
- const out = `${res.stdout ?? ''}${res.stderr ?? ''}`.replace(ANSI, '');
86
+ const out = `${res.stdout ?? ''}${res.stderr ?? ''}${res.error ? '\n' + res.error.message : ''}`.replace(ANSI, '');
78
87
  return { code: res.status ?? 1, out, stdout: (res.stdout ?? '').replace(ANSI, '') };
79
88
  };
80
89
  }
81
90
 
91
+ /** Retry with fresh Wrangler processes: another studio may be rotating shared OAuth credentials. */
92
+ export async function checkWranglerLogin(root, env = {}, {run = null, sleep = ms => new Promise(resolve => setTimeout(resolve, ms))} = {}) {
93
+ const bin = run ? null : wranglerBin(root);
94
+ if (!run && !bin) return {ok:false, needs:'cloudflare-login', why:'Wrangler is not installed in this studio yet: run `npm install` first.'};
95
+ const w = run ?? (args => wranglerRun(bin, args, {cwd:root, env:{...process.env,...env,WRANGLER_SEND_METRICS:'false',CI:'1'}, timeout:25_000}));
96
+ let res, data;
97
+ for (let attempt = 0; attempt < 4; attempt++) {
98
+ if (attempt) await sleep([200, 600, 1400][attempt - 1] + Math.floor(Math.random() * 100));
99
+ res = await w(['whoami', '--json'], {cwd: root, timeout: 25_000});
100
+ try { data = JSON.parse(res.stdout ?? ''); } catch { data = null; }
101
+ if (res.code === 0 && data?.loggedIn === false) return {ok:false, needs:'cloudflare-login', why:'Wrangler is not signed in. Run `npx wrangler login` and approve once in your browser, then retry.'};
102
+ if (res.code === 0 && data?.loggedIn === true) return {ok:true, who:{accounts:(data.accounts ?? []).map(a => ({id:a.id,name:a.name})),authType:data.authType}};
103
+ }
104
+ const detail = String(res.out ?? '').trim();
105
+ const expired = data?.loggedIn === false || /(?:refresh token.*(?:expired|invalid)|invalid_grant|not (?:logged|signed) in|authentication error|authenticate request)/i.test(detail);
106
+ return {ok:false, needs:expired ? 'cloudflare-login' : 'cloudflare-auth-check', why: (expired
107
+ ? 'Wrangler could not authenticate after four attempts. Run `npx wrangler login`, then retry.'
108
+ : 'Wrangler could not check sign-in after four attempts. Another process may be refreshing credentials, or Cloudflare may be unreachable. Retry shortly.') + (detail ? '\nWrangler: '+detail : '')};
109
+ }
110
+
82
111
  /** Who Wrangler is signed in as, or null. Never prints a token. */
83
- export function whoami(root, env = {}) {
84
- const w = runner(root, env);
85
- const res = w(['whoami', '--json'], { cwd: root });
86
- if (res.code !== 0) return null;
87
- try { const d = JSON.parse(res.stdout); return d.loggedIn ? { accounts: (d.accounts ?? []).map((a) => ({ id: a.id, name: a.name })), authType: d.authType } : null; } catch { return null; }
112
+ export async function whoami(root, env = {}) {
113
+ return (await checkWranglerLogin(root, env)).who ?? null;
88
114
  }
89
115
 
90
116
  function parseJson(text) {
@@ -262,12 +288,13 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
262
288
  const wide = wideRouteRefusal(readConfig(root), studio);
263
289
  if (wide) return { ok: false, command: 'deploy', ...wide };
264
290
  let routes = keptRoutes(root, studio);
265
- const who = whoami(root);
266
- if (!who) {
267
- if (cf.auth === 'stripe-projects') {
268
- return { ok: false, command: 'deploy', needs: 'cloudflare-login', why: 'Wrangler did not accept the Cloudflare token Stripe Projects keeps for this studio (studio.json "auth": "stripe-projects"). Run `stripe projects env --pull` in the studio folder (it rewrites the git-ignored .env from Projects\' vault), then `npm run deploy` again; or sign in the usual way (`npx wrangler login`) and remove "auth" from studio.json.' };
291
+ const login = await checkWranglerLogin(root);
292
+ const who = login.who;
293
+ if (!login.ok) {
294
+ if (cf.auth === 'stripe-projects' && login.needs === 'cloudflare-login') {
295
+ return { ok: false, command: 'deploy', needs: 'cloudflare-login', why: login.why + '\nWrangler did not accept the Cloudflare token Stripe Projects keeps for this studio (studio.json "auth": "stripe-projects"). Run `stripe projects env --pull` in the studio folder (it rewrites the git-ignored .env from Projects\' vault), then `npm run deploy` again; or sign in the usual way (`npx wrangler login`) and remove "auth" from studio.json.' };
269
296
  }
270
- return { ok: false, command: 'deploy', needs: 'cloudflare-login', why: 'Wrangler is not signed in to Cloudflare. Run `npx wrangler login` in the studio folder: it opens Cloudflare in the browser and the person approves once (a free account works, no payment method). Then run `npm run deploy` again.' };
297
+ return { ...login, command: 'deploy' };
271
298
  }
272
299
  const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || (who.accounts.length === 1 ? who.accounts[0].id : null);
273
300
  if (!accountId) {
@@ -413,7 +440,8 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
413
440
  step(address.created ? 'registered the account’s workers.dev address automatically' : 'kept the account’s existing workers.dev address');
414
441
  dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
415
442
  }
416
- if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
443
+ if (dep.code !== 0) return refuse(`wrangler deploy failed: ${wranglerDeployError(dep.out)}`, dep.out);
444
+ try{await ensureStudioSigningKey(readStudio(root),w);}catch(error){return refuse(error.message);}
417
445
  if (paidReleases(root).length) {
418
446
  const bin = wranglerBin(root);
419
447
  const uploadOptions = { cwd: workerDir(root), env: { ...process.env, CLOUDFLARE_ACCOUNT_ID: accountId, WRANGLER_SEND_METRICS: 'false', CI: '1' } };
@@ -637,7 +665,8 @@ export async function ciDeploy(root, { log = () => {}, wrangler = null } = {}) {
637
665
  }
638
666
  const started = Date.now();
639
667
  const dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
640
- if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
668
+ if (dep.code !== 0) return refuse(`wrangler deploy failed: ${wranglerDeployError(dep.out)}`, dep.out);
669
+ try{await ensureStudioSigningKey(readStudio(root),w);}catch(error){return refuse(error.message);}
641
670
  step(`deployed ${names.worker ?? 'the Worker'} in ${Math.round((Date.now() - started) / 1000)} s`);
642
671
  const repo = repoOf(root);
643
672
  step(repo ? `the live site knows its GitHub repository (${repo}), for the Claude app's hand-off` : 'no GitHub repository found here (no HOMIE_REPO, studio.json "github" or git remote): the Claude app\'s card asks the person which it is');
@@ -683,8 +712,9 @@ export async function storageAdd(root, { log = () => {} } = {}) {
683
712
  if (created.has(`r2:${bucket}`)) {
684
713
  return { ok: true, command: 'storage add', bucket, already: true, next: ['npm run deploy (binds it as MEDIA if the live site does not have it yet)'] };
685
714
  }
686
- const who = whoami(root);
687
- if (!who) return { ok: false, command: 'storage add', needs: 'cloudflare-login', why: 'Wrangler is not signed in to Cloudflare. Run `npx wrangler login` first (one approval in the browser).' };
715
+ const login = await checkWranglerLogin(root);
716
+ if (!login.ok) return {...login, command:'storage add'};
717
+ const who = login.who;
688
718
  const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || (who.accounts.length === 1 ? who.accounts[0].id : null);
689
719
  if (!accountId) return { ok: false, command: 'storage add', needs: 'cloudflare-account', why: 'This login reaches several Cloudflare accounts; put the studio\'s in studio.json (cloudflare.accountId) first.', accounts: who.accounts };
690
720
  const w = runner(root, { CLOUDFLARE_ACCOUNT_ID: accountId });
@@ -718,15 +748,15 @@ export async function storageAdd(root, { log = () => {} } = {}) {
718
748
  }
719
749
 
720
750
  /** One Wrangler command, run without blocking (an upload of a music video takes a while); the last of its words kept. */
721
- function wranglerRun(bin, args, { cwd, env }) {
751
+ function wranglerRun(bin, args, { cwd, env, timeout }) {
722
752
  return new Promise((done) => {
723
- const p = spawn(bin, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
724
- let out = '';
753
+ const p = spawn(bin, args, { cwd, env, timeout, stdio: ['ignore', 'pipe', 'pipe'] });
754
+ let out = '', stdout = '';
725
755
  const keep = (d) => { out = `${out}${d}`.slice(-8000); };
726
- p.stdout.on('data', keep);
756
+ p.stdout.on('data', data => {stdout = `${stdout}${data}`.slice(-65536); keep(data);});
727
757
  p.stderr.on('data', keep);
728
758
  p.on('error', (e) => done({ code: 1, out: String(e.message) }));
729
- p.on('close', (code) => done({ code: code ?? 1, out: out.replace(ANSI, '') }));
759
+ p.on('close', (code) => done({ code: code ?? 1, out: out.replace(ANSI, ''), stdout: stdout.replace(ANSI, '') }));
730
760
  });
731
761
  }
732
762
 
package/lib/dev.mjs CHANGED
@@ -347,17 +347,15 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
347
347
  if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
348
348
  const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1', ...(process.stdout.isTTY && !process.env.NO_COLOR ? { FORCE_COLOR: process.env.FORCE_COLOR ?? '1' } : {}) };
349
349
  for (const added of ensureMigrations(root)) log(`added ${added} (${migrationWord(added)})`);
350
- await new Promise((done) => {
351
- const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
352
- m.on('close', done);
353
- m.on('error', done);
354
- });
355
350
  const {functionDeploymentSQL}=await import('./functions-build.mjs');
356
- const registerFunctions=()=>new Promise(resolve=>{
357
- const child=spawn(bin,['d1','execute','DB','--local','--command',functionDeploymentSQL(root)],{cwd:workerDir(root),env,stdio:['ignore','ignore','inherit']});
351
+ const {prepareLocalDatabase}=await import('./local-database-start.mjs');
352
+ const runLocal=args=>new Promise(resolve=>{
353
+ const child=spawn(bin,args,{cwd:workerDir(root),env,stdio:['ignore','ignore','inherit']});
358
354
  child.on('close',code=>resolve(code===0));child.on('error',()=>resolve(false));
359
355
  });
360
- if(!await registerFunctions())return {ok:false,command:'dev',why:'Local function registration failed; fix the D1 error above before serving requests.'};
356
+ const registerFunctions=()=>runLocal(['d1','execute','DB','--local','--command',functionDeploymentSQL(root)]);
357
+ const failure=await prepareLocalDatabase({migrate:()=>runLocal(['d1','migrations','apply','DB','--local']),register:registerFunctions});
358
+ if(failure)return {ok:false,command:'dev',why:failure};
361
359
  const origin = `http://127.0.0.1:${port}`;
362
360
  const printedOrigin = `http://localhost:${port}`;
363
361
  log(`Local site: ${printedOrigin}/ (each game: ${printedOrigin}/<id>/play — open it in two browsers)`);
@@ -0,0 +1,6 @@
1
+ /** Complete local schema and subscriptions before any request can emit an event. */
2
+ export async function prepareLocalDatabase({migrate,register}) {
3
+ if(!await migrate())return 'Local database migration failed; fix the D1 error above before serving requests.';
4
+ if(!await register())return 'Local function registration failed; fix the D1 error above before serving requests.';
5
+ return null;
6
+ }
package/lib/mcp-tools.mjs CHANGED
@@ -843,7 +843,7 @@ export function toolDefs(ctx, avail = {}) {
843
843
  let at = dir;
844
844
  if (existsSync(at) && readdirSync(at).some((f) => !['.DS_Store', '.git'].includes(f)) && !existsSync(join(at, 'studio.json'))) at = `${dir}-studio`;
845
845
  let made;
846
- try { made = newStudio(at, { name, kind: a.kind ?? 'game', install: false, ...(ctx.directory ? { homie: ctx.directory } : {}) }); } catch (error) { return fail(error.message); }
846
+ try { made = newStudio(at, { name, kind: a.kind ?? 'game', install: false }); } catch (error) { return fail(error.message); }
847
847
  ctx.current = made.dir;
848
848
  ctx.note(made.dir).earlier = earlier.map((e) => e.path);
849
849
  let install = null;
@@ -1258,10 +1258,10 @@ export function toolDefs(ctx, avail = {}) {
1258
1258
  // the directory's read-only count for this site, the parts' lines, and nothing published. Run here, by THIS
1259
1259
  // toolkit, never handed to the studio's pinned one: a 0.31.0 toolkit reads `publish --before` as `publish`.
1260
1260
  if (a.before === true) {
1261
- const b = await publishBefore(root, { site: a.site ? String(a.site) : undefined });
1261
+ const b = await publishBefore(root, { homie: ctx.directory ?? undefined, site: a.site ? String(a.site) : undefined });
1262
1262
  return ok(b.lines.join('\n'), { kind: 'publish-count', ...publishesSoFar(root), ...b });
1263
1263
  }
1264
- const r = await cli(ctx, root, 'publish', ['publish', ...(a.site ? ['--site', String(a.site)] : [])]);
1264
+ const r = await cli(ctx, root, 'publish', ['publish', ...(ctx.directory ? ['--homie', ctx.directory] : []), ...(a.site ? ['--site', String(a.site)] : [])]);
1265
1265
  if (!r.ended) return stillRunning(r.job, 'Listing');
1266
1266
  if (r.job.code !== 0) return fail(`Not listed: ${whyOf(r.job)}`, r.result?.publishes ? { kind: 'publish', ok: false, needs: r.result.needs ?? null, publishes: r.result.publishes } : undefined);
1267
1267
  return ok(`Listed: ${r.result?.studioPage ?? r.result?.directory ?? ''}\n${(r.result?.games ?? []).map((g) => ` ${g.name}: ${g.play}`).join('\n')}${r.result?.publishes?.line ? `\n${r.result.publishes.line}` : ''}${partsSaid.length ? `\n${partsSaid.join('\n')}` : ''}`, { kind: 'publish', ...r.result });
@@ -26,7 +26,7 @@ export function needsHeadless({url,secret,app,meta,scope,sessionFor}) {
26
26
  if(!call?.tool)throw new Error('MCP step requires tool and optional arguments');
27
27
  const result=await client.callTool({name:call.tool,arguments:call.arguments??{}},undefined,{timeout:item.schedule?15000:10000});
28
28
  if(result.isError)throw new Error(result.content?.filter(c=>c.type==='text').map(c=>c.text).join('; ')||'MCP tool failed');
29
- if(result.structuredContent!==undefined)return result.structuredContent;
29
+ if(result.structuredContent!==undefined){const value=result.structuredContent;return value?.kind==='untrusted-tool-data'?value.data:value;}
30
30
  const content=result.content?.filter(c=>c.type==='text');
31
31
  if(content?.length===1) {try {const parsed=JSON.parse(content[0].text);return parsed.kind==='untrusted-tool-data'?parsed.data:parsed;}catch{}}
32
32
  return result;
@@ -628,12 +628,13 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
628
628
  }
629
629
  }
630
630
 
631
- /* ---- two fresh browsers meet through the lobby and finish a round */
631
+ /* ---- a public join and an invited browser finish a round */
632
632
  if (want.has('round') && await gate('round')) {
633
633
  try {
634
634
  const A = await launch('desk');
635
635
  const B = await launch('phone');
636
- const [oa, ob] = [await open(A, `${base}/${game}/play?name=Desk`), await open(B, `${base}/${game}/play?name=Phone`)];
636
+ const oa = await open(A, `${base}/${game}/play?name=Desk`);
637
+ const ob = await open(B, `${base}/${game}/play?name=Phone${oa.shell?.room ? `&room=${encodeURIComponent(oa.shell.room)}` : ''}`);
637
638
  if (!oa.ok || !ob.ok) row('round', false, { why: `a browser was never seated (${!oa.ok ? `computer: ${oa.why}` : ''} ${!ob.ok ? `phone: ${ob.why}` : ''})` });
638
639
  else if (oa.shell.room !== ob.shell.room) row('round', false, { why: `the two browsers landed in different rooms (${oa.shell.room}, ${ob.shell.room})` });
639
640
  else {
package/lib/scaffold.mjs CHANGED
@@ -152,6 +152,8 @@ the Table/Lobby Durable Objects, on the studio's Cloudflare account), built
152
152
  from \`@homie-rocks/studio\`, pinned in \`package.json\`. The homie.rocks
153
153
  directory lists its games; homie.rocks does not host them.
154
154
 
155
+ For studio-owned HTTP endpoints, read \`node_modules/@homie-rocks/studio/docs/custom-routes.md\` before editing \`site/src/worker.mjs\`. Use the reserved \`/api/custom/*\` namespace and the documented authorization and cookie scopes.
156
+
155
157
  ## Version-matched authoring guides
156
158
 
157
159
  Before authoring or rewriting a game, read \`node_modules/@homie-rocks/studio/guides/game/SKILL.md\`,
@@ -0,0 +1,13 @@
1
+ import {generateKeyPairSync,randomUUID} from 'node:crypto';
2
+ /** Key material travels only in memory and Wrangler stdin. Never return it or include command output in errors. */
3
+ export async function ensureStudioSigningKey(studio,w){
4
+ if(!studio.mcp?.studios)return;
5
+ const result=await w(['secret','list']);
6
+ if(result.code!==0)throw new Error('Cannot inspect studio signing key bindings');
7
+ let names;try{names=JSON.parse(result.stdout??result.out);}catch{throw new Error('Cannot inspect studio signing key bindings');}
8
+ if(names.some(entry=>entry.name==='STUDIO_SIGNING_KEY'))return;
9
+ const {privateKey}=generateKeyPairSync('ed25519');
10
+ const key={...privateKey.export({format:'jwk'}),kid:randomUUID()};
11
+ const saved=await w(['secret','bulk'],{input:JSON.stringify({STUDIO_SIGNING_KEY:JSON.stringify(key)})});
12
+ if(saved.code!==0)throw new Error('Cannot provision studio signing key');
13
+ }
@@ -4020,7 +4020,94 @@
4020
4020
  },
4021
4021
  "sections": {
4022
4022
  "AGENTS.md": {
4023
- "(intro)": "394cbbe934e80722",
4023
+ "(intro)": [
4024
+ "394cbbe934e80722",
4025
+ "af7f2f732505d379"
4026
+ ],
4027
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
4028
+ "## Layout": "f3a280b8c96cd068",
4029
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
4030
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
4031
+ "## The site": "0cb8dd9a6f8a0096",
4032
+ "## Making games": "bb715272fd1a615b",
4033
+ "## The Game Codex and progress": "0738ef719cef3a4c",
4034
+ "## Art direction and models": "a48fff7bf0a5f2fc",
4035
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
4036
+ "## Servers and AI seats": "1ccfd549d2369a60",
4037
+ "## Selling things (the shop)": "0df5a5fe1833c624",
4038
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
4039
+ "## Rules": "b2234d80f6cbe71d",
4040
+ "## Telling Homie": "80b46fdd9e578db8",
4041
+ "## Beta": "9d02638207a26e73"
4042
+ }
4043
+ },
4044
+ "named": {
4045
+ "Homie Arcade": {
4046
+ "sections": {
4047
+ "AGENTS.md": {
4048
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
4049
+ }
4050
+ }
4051
+ }
4052
+ }
4053
+ },
4054
+ "0.56.0": {
4055
+ "files": {
4056
+ "CLAUDE.md": "ddf2b2a0376202f6",
4057
+ "README.md": "684b3cca8001673c",
4058
+ "HANDOFF.md": "29337e2ace95b2e7",
4059
+ "games/README.md": "5ad32fe80c61f117",
4060
+ "music/README.md": "a3dc042dadd39df8",
4061
+ "videos/README.md": "16babc3437d5545b",
4062
+ "posts/README.md": "37841aae476bd540",
4063
+ "site/README.md": "1dbdf22c03a1e505",
4064
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
4065
+ },
4066
+ "sections": {
4067
+ "AGENTS.md": {
4068
+ "(intro)": "af7f2f732505d379",
4069
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
4070
+ "## Layout": "f3a280b8c96cd068",
4071
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
4072
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
4073
+ "## The site": "0cb8dd9a6f8a0096",
4074
+ "## Making games": "bb715272fd1a615b",
4075
+ "## The Game Codex and progress": "0738ef719cef3a4c",
4076
+ "## Art direction and models": "a48fff7bf0a5f2fc",
4077
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
4078
+ "## Servers and AI seats": "1ccfd549d2369a60",
4079
+ "## Selling things (the shop)": "0df5a5fe1833c624",
4080
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
4081
+ "## Rules": "b2234d80f6cbe71d",
4082
+ "## Telling Homie": "80b46fdd9e578db8",
4083
+ "## Beta": "9d02638207a26e73"
4084
+ }
4085
+ },
4086
+ "named": {
4087
+ "Homie Arcade": {
4088
+ "sections": {
4089
+ "AGENTS.md": {
4090
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
4091
+ }
4092
+ }
4093
+ }
4094
+ }
4095
+ },
4096
+ "0.57.0": {
4097
+ "files": {
4098
+ "CLAUDE.md": "ddf2b2a0376202f6",
4099
+ "README.md": "684b3cca8001673c",
4100
+ "HANDOFF.md": "29337e2ace95b2e7",
4101
+ "games/README.md": "5ad32fe80c61f117",
4102
+ "music/README.md": "a3dc042dadd39df8",
4103
+ "videos/README.md": "16babc3437d5545b",
4104
+ "posts/README.md": "37841aae476bd540",
4105
+ "site/README.md": "1dbdf22c03a1e505",
4106
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
4107
+ },
4108
+ "sections": {
4109
+ "AGENTS.md": {
4110
+ "(intro)": "af7f2f732505d379",
4024
4111
  "## Version-matched authoring guides": "f6261a0e5801b96b",
4025
4112
  "## Layout": "f3a280b8c96cd068",
4026
4113
  "## Apps: one screen, roles and parts": "c10aa0441ea89702",
@@ -20,7 +20,10 @@ export async function buildWorker(root,esbuild,metadata=null) {
20
20
  // classes in the generated bundle without rewriting the studio's source/customizations.
21
21
  const standardRooms=/export\s*\{[^}]*\bTable\b[^}]*\bLobby\b[^}]*\}\s*from\s*['"]@homie-rocks\/studio\/worker['"]/.test(readFileSync(source,'utf8'));
22
22
  const lazyRooms=standardRooms?`export {Table,Lobby} from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\n`:'';
23
- const contents=`${lazyRooms}export * from ${JSON.stringify(source)};\nexport { default } from ${JSON.stringify(source)};\nexport { Gate, Concentrator } from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\nexport {AppRecordHub, EventHub} from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\n`;
23
+ // A custom fetch wrapper may predate background delivery. Complete the event
24
+ // boundary here, preserving explicit studio handlers and leaving source alone.
25
+ const toolkitEntry=alias['@homie-rocks/studio/worker']==='@homie-rocks/studio/worker/selling'?'worker/selling/index.mjs':'worker/index.mjs';
26
+ const contents=`${lazyRooms}export * from ${JSON.stringify(source)};\nimport studioWorker from ${JSON.stringify(source)};\nimport toolkitWorker from ${JSON.stringify(join(PACKAGE_ROOT,toolkitEntry))};\nexport default { ...studioWorker, queue: studioWorker.queue ?? toolkitWorker.queue, scheduled: studioWorker.scheduled ?? toolkitWorker.scheduled };\nexport { Gate, Concentrator } from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\nexport {AppRecordHub, EventHub} from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\n`;
24
27
  const result=await esbuild.build({stdin:{contents,resolveDir:root,sourcefile:'worker.mjs'},entryNames:'worker',absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent',plugins:[{name:'studio-runtime',setup(build){
25
28
  if(metadata)build.onLoad({filter:/[/\\]worker[/\\]built-metadata\.mjs$/},()=>({contents:'export default '+JSON.stringify(metadata),loader:'js'}));
26
29
  // Merely overriding entry exports still evaluates the barrel's dependencies.
@@ -28,6 +28,13 @@ export function updateWorkerConfig(root,generated) {
28
28
  writeFileSync(join(root,'wrangler.custom.json'),JSON.stringify(custom,null,2)+'\n');
29
29
  }
30
30
  const next=merge(base,custom);
31
+ // Authorization paths are additive: custom assets must never bypass toolkit gates.
32
+ const requiredRoutes=base.assets?.run_worker_first, customRoutes=custom.assets?.run_worker_first;
33
+ // A "!" pattern excludes paths from the Worker. Only Homie writes those (static-assets.mjs): a custom one
34
+ // could take /games/*, /_studio/* or an app's files out from behind their access checks.
35
+ const exclusions=Array.isArray(customRoutes)?customRoutes.filter(p=>String(p).startsWith('!')):[];
36
+ if(exclusions.length)throw new Error(`wrangler.custom.json assets.run_worker_first cannot exclude paths (${exclusions.join(', ')}): list only the extra paths your own routes need`);
37
+ next.assets={...next.assets,run_worker_first:requiredRoutes===true || customRoutes===true ? true : [...new Set([...(Array.isArray(requiredRoutes)?requiredRoutes:[]),...(Array.isArray(customRoutes)?customRoutes:[])])]};
31
38
  if(readStudio(root).concept===true)next.assets={...next.assets,run_worker_first:true};
32
39
  next.vars={...next.vars,HOMIE_CONCEPT:readStudio(root).concept===true?'1':'0'};
33
40
  verifiedMcpConfig(root);
@@ -290,6 +290,13 @@ at once, with bots filling the slots (Gem Rush: 1 human + 2 bots).
290
290
  - A human who leaves turns back into a bot in the same body.
291
291
  - `trim()` removes surplus bots between rounds.
292
292
  - After every change the host calls `net.roster(slots)`.
293
+ - A browser host may receive `join` before its world is ready. Once it publishes a roster,
294
+ snapshot traffic makes the relay retry any connected replica seat missing from that roster,
295
+ with delays doubling from 1 second to a 30-second cap, for at most eight retries per peer
296
+ and host. A roster seat (including `agent.seat` for host-driven AI) acknowledges the claim.
297
+ If still unacknowledged after the final wait, the relay logs once and stops retrying. Treat a repeated `join` for an
298
+ already claimed seat as idempotent; keep its body and score. This uses the existing wire
299
+ message, including for older bundled clients. Rules hosts reconcile seats separately.
293
300
 
294
301
  **Join in progress.**
295
302
  1. The `welcome` carries the last snapshot, round, roster and **all keyed state**. The
@@ -1591,7 +1598,11 @@ A page stands in one of six places with its room, and the helper says which:
1591
1598
  - **`net.offline` means "not in a room"**, not "no network": it is true for `alone` and `offline`.
1592
1599
  **`net.connected`** is true only while the socket is up and welcomed. The state between them
1593
1600
  (`!net.offline && !net.connected`) is `reconnecting`.
1594
- - **`on('link', (e) => …)`** fires on every change with `{ state, prev, why, hosting, downMs }`.
1601
+ - **`on('link', (e) => …)`** immediately calls the listener with the current state
1602
+ (`prev === state`, `why: 'subscribe'`), then fires on every change with `{ state, prev, why, hosting, downMs }`.
1603
+ Ignore `why === 'subscribe'` when triggering reconnect recovery; it initializes
1604
+ UI and does not signal a new connection. Listener errors are isolated, including
1605
+ this immediate call, and `on()` always returns the unsubscribe function.
1595
1606
  `hosting` says this page is running the rules right now. Two pages can both say so for a
1596
1607
  moment: a host that was cut off keeps simulating until its welcome tells it the room moved on,
1597
1608
  and a page that is `alone` hosts its own private round. Before revision 9 neither said
@@ -170,7 +170,7 @@ export type LinkState = 'connecting' | 'online' | 'reconnecting' | 'alone' | 'of
170
170
  export interface LinkChange {
171
171
  state: LinkState;
172
172
  prev: LinkState;
173
- /** Why it changed: `welcome`, `lost` (the socket closed), `stale` (it went silent), `relay-timeout` (the room never answered), `reconnect-timeout` (it was in the room and the room did not come back in `reconnectMaxMs`), `no-shell`, or a refusal's code. */
173
+ /** `subscribe` is the current state delivered immediately on subscription (prev equals state). Otherwise, why it changed: `welcome`, `lost` (the socket closed), `stale` (it went silent), `relay-timeout` (the room never answered), `reconnect-timeout` (it was in the room and the room did not come back in `reconnectMaxMs`), `no-shell`, or a refusal's code. */
174
174
  why: string;
175
175
  /** This browser is running the rules right now (alone, or as the room's host): two browsers can both say so while one is cut off. */
176
176
  hosting: boolean;
@@ -749,7 +749,7 @@ export interface NetHandlers<S, A, C> {
749
749
  unchat: (e: { ids: string[] }) => void;
750
750
  /** Revision 8: a message of mine was not sent (slow mode, sign in to type, a word, the studio's filter…). */
751
751
  held: (h: ChatHeld) => void;
752
- /** Revision 9: where this browser stands with its room changed (online, reconnecting, alone, closed). */
752
+ /** Current link on subscription (why: subscribe), then changes (online, reconnecting, alone, closed). */
753
753
  link: (e: LinkChange) => void;
754
754
  /** Revision 9: a newer build of the game is live; this tab should reload (section 23). */
755
755
  stale: (e: StaleNotice) => void;
@@ -797,7 +797,7 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
797
797
  * THE LINK (revision 9, NETPLAY.md section 22). `offline` is true when this browser is NOT in a room: there is no
798
798
  * shell at all, or the room never answered and it plays alone (`link === 'alone'`). `connected` is true only while
799
799
  * the socket is up and welcomed. Between the two (`!offline && !connected`) the browser was in a room and is
800
- * knocking again: `link === 'reconnecting'`. `link` says which in one word, and `on('link')` when it changes.
800
+ * knocking again: `link === 'reconnecting'`. `link` says which in one word, and `on('link')` immediately reports the current state, then each change.
801
801
  */
802
802
  readonly link: LinkState;
803
803
  /** Times the helper has scheduled another knock at the relay (the same number as `stats().reconnects`). 0: never interrupted. */
@@ -1713,6 +1713,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1713
1713
  const peers = new Map<string, Peer>();
1714
1714
  let heldPeers: Peer[] = [];
1715
1715
  let roundInfo: RoundInfo | null = null;
1716
+ let idlePing = false, lastClockPing = -Infinity;
1716
1717
  let slots: Slot[] | null = null;
1717
1718
  let lastCkpt: Checkpoint<C> | null = null;
1718
1719
 
@@ -2352,12 +2353,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2352
2353
 
2353
2354
  // ---------------------------------------------------------------- wire in
2354
2355
  function onMessage(data: unknown): void {
2355
- lastMsgAt = wall();
2356
+ if (data === 'homie-pong' && idlePing) { lastMsgAt = wall(); return; }
2356
2357
  const text = typeof data === 'string' ? data : String(data);
2357
2358
  countBytes(bytesIn, text.length);
2358
2359
  let m: Record<string, unknown>;
2359
2360
  try { m = JSON.parse(text) as Record<string, unknown>; } catch { return; }
2360
2361
  if (!m || typeof m['t'] !== 'string') return;
2362
+ lastMsgAt = wall();
2361
2363
  switch (m['t']) {
2362
2364
  case 'welcome': return onWelcome(m);
2363
2365
  case 'snap': return onSnap(m, text.length);
@@ -2583,6 +2585,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2583
2585
  }
2584
2586
 
2585
2587
  function onWelcome(m: Record<string, unknown>): void {
2588
+ idlePing = m.idlePing === true; lastClockPing = -Infinity;
2586
2589
  post?.({ what: 'build', ver: version });
2587
2590
  clearTimeout(connectTimer);
2588
2591
  const next = m['role'] as Role;
@@ -2810,7 +2813,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2810
2813
  const st = Number(m['st']);
2811
2814
  const sent = pingsOut.get(c);
2812
2815
  if (sent === undefined || !Number.isFinite(st)) return;
2813
- pingsOut.delete(c);
2816
+ for (const key of pingsOut.keys()) if (key <= c) pingsOut.delete(key);
2814
2817
  const recv = Date.now();
2815
2818
  const r = recv - sent;
2816
2819
  const off = st + r / 2 - recv;
@@ -2869,7 +2872,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2869
2872
  }
2870
2873
 
2871
2874
  function hidden(): boolean { try { return Boolean(g.document?.hidden); } catch { return false; } }
2872
- function ping(): void {
2875
+ function ping(force = false): void {
2873
2876
  // This runs every two seconds at least. A gap of more than three and a half means the page itself was held.
2874
2877
  const beat = wall();
2875
2878
  const pingHeld = beat - lastPingTick > 2000 + 1500;
@@ -2885,12 +2888,22 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2885
2888
  // the word of a timer that itself ran seconds late: the page was blocked (a heavy boot, shaders compiling) and
2886
2889
  // could not have heard anything, and what the relay sent meanwhile is still queued behind this very timer.
2887
2890
  // Dropping a healthy socket there turned every long hitch into a reconnect.
2888
- if (ws && lastMsgAt && wall() - lastMsgAt > staleMs) {
2891
+ const oldestPing = pingsOut.values().next().value;
2892
+ if (ws && oldestPing !== undefined && Date.now() - oldestPing >= 30_000 && !pingHeld) { lost(ws, 'stale'); return; }
2893
+ const silence = idlePing && (roundInfo?.phase !== 'live' || wall() - lastClockPing > staleMs) ? Math.max(staleMs, 60_000) : staleMs;
2894
+ if (ws && lastMsgAt && wall() - lastMsgAt > silence) {
2889
2895
  if (!pingHeld) { lost(ws, 'stale'); return; }
2890
2896
  // Two and a half seconds to be heard from (a pong answers the ping below): a socket that is really dead is
2891
2897
  // still dropped, two beats from now.
2892
2898
  lastMsgAt = wall() - staleMs + 2500;
2893
2899
  }
2900
+ // An idle hibernatable room can answer this at the edge. Keep a regular
2901
+ // clock/visibility sample on visibility changes, and every heartbeat during play.
2902
+ if (idlePing && roundInfo?.phase !== 'live' && !(roundInfo?.phase === 'over' && now() >= roundInfo.endsAt) && !force) {
2903
+ try { ws?.send('homie-ping'); } catch { if(ws)lost(ws,'send'); }
2904
+ return;
2905
+ }
2906
+ lastClockPing = wall();
2894
2907
  const c = Date.now();
2895
2908
  pingsOut.set(c, c);
2896
2909
  if (pingsOut.size > 16) { const first = pingsOut.keys().next().value; if (first !== undefined) pingsOut.delete(first); }
@@ -3026,6 +3039,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3026
3039
  ws = sock; socketStartedAt = wall();
3027
3040
  sock.onopen = () => {
3028
3041
  lastMsgAt = wall();
3042
+ pingsOut.clear();
3029
3043
  raw({
3030
3044
  t: 'hello', rules: rulesGame, v: NETPLAY_VERSION, rev: NETPLAY_REVISION, token, name: name || undefined, device, want, canHost: asAgent && asAgent.hands === 'host' ? false : canHost, game: opts.game, max: opts.maxPlayers,
3031
3045
  ...(rulesGame ? { coast: 1 } : {}),
@@ -3101,8 +3115,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3101
3115
  if (hidden()) {
3102
3116
  // A hidden phone tab loses its timers and rAF: hand the round on before it stalls.
3103
3117
  if (role === 'host' && peers.size > 1) yieldHost();
3104
- ping();
3105
- } else ping();
3118
+ ping(true);
3119
+ } else ping(true);
3106
3120
  });
3107
3121
  g.addEventListener?.('online', () => { if (!closed && !connected && rulesGame) { if (ws) lost(ws, 'online'); if (retryTimer !== null) clearTimeout(retryTimer); retryTimer = null; attempt = 0; open(); } });
3108
3122
  g.addEventListener?.('pagehide', () => { if (role === 'host') { sendCheckpoint(); rulesSender?.leave(); } raw({ t: 'bye' }); });
@@ -3353,6 +3367,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3353
3367
  on(kind, fn) {
3354
3368
  (handlers[kind] as Set<unknown>).add(fn);
3355
3369
  if (kind === 'view') markFollows();
3370
+ if (kind === 'link') try { (fn as NetHandlers<S, A, C>['link'])({state:link,prev:link,why:'subscribe',hosting:roleKnown && role === 'host',downMs:connected || !downSince ? 0 : Math.round(wall() - downSince)}); } catch { /* A failed UI listener must still be removable. */ }
3356
3371
  return () => { (handlers[kind] as Set<unknown>).delete(fn); };
3357
3372
  },
3358
3373
  now,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.55.0",
3
+ "version": "0.57.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/rules/core.ts CHANGED
@@ -173,6 +173,8 @@ export interface Core {
173
173
  shared(): unknown[];
174
174
  /** The current round, for a host rebasing wall-clock deadlines after a pause or restore. */
175
175
  round(): Extract<CoreOut, { t: 'round' }>;
176
+ /** End a settled wall-clock break without simulating its idle ticks. */
177
+ resumeRound(): boolean;
176
178
  bodies(): Body[];
177
179
  /** Every person's body with where it stands: who an effect with an audience reaches (0.46.3). */
178
180
  listeners(): { seat: number; id: string; pos: Vec3 }[];
@@ -1537,6 +1539,13 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1537
1539
  }
1538
1540
  return {
1539
1541
  round: () => ({ t: 'round', ...round, ...(round.phase === 'over' && !overAt ? { results: results() } : {}) }),
1542
+ resumeRound() {
1543
+ if (round.phase !== 'over' || overAt) return false;
1544
+ // Preserve queued events and their simulation-time deadlines. Nothing
1545
+ // moves during the wall-clock break; the next step delivers them.
1546
+ if (playing) startRound(); else restartAt = tick;
1547
+ return true;
1548
+ },
1540
1549
  get tick() { return tick; },
1541
1550
  reviseMovement,
1542
1551
  get epoch() { return epoch; },