@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.
- package/CHANGELOG.md +72 -0
- package/README.md +2 -0
- package/apps/APPS.md +9 -0
- package/apps/apps.ts +19 -1
- package/bin/homie-studio.mjs +1 -1
- package/docs/custom-routes.md +72 -0
- package/docs/studio-to-studio.md +248 -0
- package/functions/FUNCTIONS.md +9 -0
- package/guides/app/SKILL.md +7 -0
- package/guides/playtest/scripts/playtest.mjs +7 -5
- package/lib/check.mjs +8 -1
- package/lib/cloudflare.mjs +52 -22
- package/lib/dev.mjs +6 -8
- package/lib/local-database-start.mjs +6 -0
- package/lib/mcp-tools.mjs +3 -3
- package/lib/needs-headless.mjs +1 -1
- package/lib/port-check.mjs +3 -2
- package/lib/scaffold.mjs +2 -0
- package/lib/studios-keys.mjs +13 -0
- package/lib/template-history.json +88 -1
- package/lib/worker-build.mjs +4 -1
- package/lib/worker-config.mjs +7 -0
- package/netplay/NETPLAY.md +12 -1
- package/netplay/netplay.ts +24 -9
- package/package.json +1 -1
- package/rules/core.ts +9 -0
- package/rules/host.ts +40 -11
- package/rules/view.ts +1 -0
- package/tools/TOOLS.md +22 -1
- package/tools/tools.ts +14 -1
- package/worker/background-queue.mjs +1 -0
- package/worker/booking-availability.mjs +35 -0
- package/worker/booking-holds.mjs +2 -2
- package/worker/booking-resources.mjs +14 -6
- package/worker/build-id.mjs +1 -1
- package/worker/business-events.mjs +2 -1
- package/worker/custom-routes.mjs +16 -0
- package/worker/gate.mjs +94 -23
- package/worker/idle-chat.mjs +56 -0
- package/worker/index.mjs +2 -0
- package/worker/lazy-objects.mjs +3 -0
- package/worker/lobby-peak.mjs +44 -0
- package/worker/lobby-shards.mjs +43 -0
- package/worker/lobby-store.mjs +35 -0
- package/worker/lobby-summary.mjs +28 -0
- package/worker/lobby.mjs +86 -18
- package/worker/lounge-store.mjs +2 -2
- package/worker/match-router.mjs +74 -0
- package/worker/mcp-room.mjs +2 -1
- package/worker/mcp-store.mjs +3 -1
- package/worker/mcp-tools.mjs +29 -4
- package/worker/mcp.mjs +43 -8
- package/worker/office.mjs +2 -1
- package/worker/operational-schema.mjs +2 -0
- package/worker/pages.mjs +1 -1
- package/worker/relay-channel.mjs +119 -0
- package/worker/request-db.mjs +4 -3
- package/worker/room-chat.mjs +2 -1
- package/worker/room-sessions.mjs +65 -0
- package/worker/room.mjs +45 -9
- package/worker/route-auth.mjs +22 -0
- package/worker/routes.mjs +8 -5
- package/worker/runtime-support.mjs +5 -3
- package/worker/session-codec.mjs +24 -0
- package/worker/stats.mjs +2 -1
- package/worker/studios-auth.mjs +71 -0
- package/worker/studios-client.mjs +93 -0
- package/worker/studios-events.mjs +52 -0
- package/worker/studios-keys.mjs +54 -0
- package/worker/table.mjs +277 -41
- package/worker/version.mjs +1 -1
- package/worker/watch-sockets.mjs +75 -0
package/lib/cloudflare.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
687
|
-
if (!
|
|
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
|
|
357
|
-
|
|
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
|
-
|
|
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
|
|
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 });
|
package/lib/needs-headless.mjs
CHANGED
|
@@ -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)
|
|
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;
|
package/lib/port-check.mjs
CHANGED
|
@@ -628,12 +628,13 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
628
628
|
}
|
|
629
629
|
}
|
|
630
630
|
|
|
631
|
-
/* ----
|
|
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
|
|
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)":
|
|
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",
|
package/lib/worker-build.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/lib/worker-config.mjs
CHANGED
|
@@ -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);
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -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) => …)`**
|
|
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
|
package/netplay/netplay.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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')`
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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; },
|