@homie-rocks/studio 0.58.14 → 0.58.16
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 +21 -0
- package/lib/cloudflare.mjs +31 -15
- package/lib/move-drain.mjs +2 -1
- package/lib/template-history.json +84 -0
- package/package.json +1 -1
- package/worker/build-id.mjs +1 -1
- package/worker/move-drain.mjs +16 -5
- package/worker/room.mjs +2 -0
- package/worker/table.mjs +38 -4
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,27 @@ 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.58.16] - 2026-10-21
|
|
21
|
+
|
|
22
|
+
**Plugin 0.58.10**
|
|
23
|
+
|
|
24
|
+
A failed deploy says what Cloudflare or the host actually refused.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- A failed deploy whose Worker uses a queue no longer says the token needs Account > Queues > Edit unless Cloudflare refused a Queues request for authentication. Any other failure, an asset upload for one, now shows Wrangler's own error.
|
|
29
|
+
- When the host refuses a database call during a deploy, the reason it gives (for example "A routine does not publish the live site: the owner does.") now shows after "D1 migrations failed" instead of being cut off by Wrangler's closing lines. The same goes for creating the database and registering the studio's functions.
|
|
30
|
+
|
|
31
|
+
## [0.58.15] - 2026-10-20
|
|
32
|
+
|
|
33
|
+
**Plugin 0.58.9**
|
|
34
|
+
|
|
35
|
+
A tab left open in a lobby no longer holds a studio move's drain.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- A tab left open in a lobby no longer holds a studio move's drain. Once `homie-studio drain on` has run for 5 minutes, a room still waiting in its lobby (no round started, or the last round over and its results seen) is ended: the people in it are told "This studio is moving. Please join again shortly." and their pages do not knock on the paused door again. A room with a round in progress still plays until it finishes, and a room whose results are not yet saved is never ended. `homie-studio drain status` says how many rooms it ended this way (`idleLobbiesEnded`), so you can see why the drain finished.
|
|
40
|
+
|
|
20
41
|
## [0.58.14] - 2026-10-20
|
|
21
42
|
|
|
22
43
|
**Plugin 0.58.8**
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -57,12 +57,26 @@ import { LOCAL_STATE, configPath, domainOrigin, isRulesGame, isWorkersDev, layou
|
|
|
57
57
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
58
58
|
|
|
59
59
|
/** Keep Wrangler's error and its context; progress/footer lines must not hide it. */
|
|
60
|
-
export function
|
|
60
|
+
export function wranglerError(out) {
|
|
61
61
|
const clean = redactText(String(out ?? '').replace(ANSI, '')).text;
|
|
62
62
|
const error = clean.search(/(?:^|\n)[^\n]*\[ERROR\]/);
|
|
63
63
|
return (error >= 0 ? clean.slice(error) : clean).trim();
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Each Cloudflare API request Wrangler reports as failed, with the codes Cloudflare answered it with. Wrangler prints
|
|
68
|
+
* "[ERROR] A request to the Cloudflare API (/accounts/<id>/queues?name=…) failed." and then each of Cloudflare's errors
|
|
69
|
+
* as "<message> [code: <n>]" (workers-utils cfetch throwFetchError).
|
|
70
|
+
*/
|
|
71
|
+
export function failedRequests(out) {
|
|
72
|
+
const text = String(out ?? '').replace(ANSI, '');
|
|
73
|
+
return [...text.matchAll(/\[ERROR\] A request to the Cloudflare API \(([^)\s]+)\) failed\.([\s\S]*?)(?=\n[^\n]*\[ERROR\]|$)/g)]
|
|
74
|
+
.map(([, resource, notes]) => ({ resource, codes: [...notes.matchAll(/\[code: (\d+)\]/g)].map((m) => Number(m[1])) }));
|
|
75
|
+
}
|
|
76
|
+
// The codes Wrangler itself treats as an authentication error (wrangler's AUTHENTICATION_ERROR_CODES): a token
|
|
77
|
+
// without the permission the request needs is answered with 10000, "Authentication error".
|
|
78
|
+
const AUTH_CODES = new Set([9106, 10000]);
|
|
79
|
+
|
|
66
80
|
export function experienceAddresses(root, id, url) {
|
|
67
81
|
const app = listGames(root).find(g => g.id === id)?.kind === 'app';
|
|
68
82
|
return { [app ? 'open' : 'play']: url ? `${url}/${id}/${app ? 'open' : 'play'}` : null, wall: url ? `${url}/${id}/tv` : null };
|
|
@@ -132,7 +146,9 @@ function parseJson(text) {
|
|
|
132
146
|
export function explainCloudflare(out, accountId = null) {
|
|
133
147
|
const text = String(out ?? '');
|
|
134
148
|
const dash = accountId ? `https://dash.cloudflare.com/${accountId}` : 'https://dash.cloudflare.com';
|
|
135
|
-
|
|
149
|
+
// Only a Queues API request Cloudflare refused for authentication: a failed deploy that merely binds a queue keeps
|
|
150
|
+
// Wrangler's own error.
|
|
151
|
+
if (failedRequests(text).some((r) => /\/accounts\/[^/]+\/queues(?:[/?]|$)/.test(r.resource) && r.codes.some((c) => AUTH_CODES.has(c)))) {
|
|
136
152
|
return {needs:'queues-permission',why:'The deployment token needs Account > Queues > Edit on this account. Update the CI/Workers Builds token permissions, then retry deploy.'};
|
|
137
153
|
}
|
|
138
154
|
if (/(?:cron|schedule).*?(?:limit|maximum|exceed)|(?:limit|maximum|exceed).*?(?:cron|schedule)/i.test(text)) {
|
|
@@ -388,7 +404,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
388
404
|
if (exists.code === 0) {
|
|
389
405
|
return { ok: false, command: 'deploy', why: `A Worker named "${cf.worker}" already exists on this Cloudflare account and this studio did not create it, so nothing was deployed. Pick another name in studio.json (cloudflare.worker) and site/wrangler.jsonc (name).` };
|
|
390
406
|
}
|
|
391
|
-
if (!/10007|does not exist/i.test(exists.out)) return refuse(`could not check the Worker name: ${exists.out
|
|
407
|
+
if (!/10007|does not exist/i.test(exists.out)) return refuse(`could not check the Worker name: ${wranglerError(exists.out)}`, exists.out);
|
|
392
408
|
}
|
|
393
409
|
|
|
394
410
|
// D1.
|
|
@@ -401,7 +417,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
401
417
|
if (!db) {
|
|
402
418
|
const made = w(['d1', 'create', cf.d1]);
|
|
403
419
|
const id = /"?database_id"?\s*[:=]\s*"([0-9a-f-]{36})"/.exec(made.out)?.[1];
|
|
404
|
-
if (made.code !== 0 || !id) return refuse(`could not create the D1 database "${cf.d1}": ${made.out
|
|
420
|
+
if (made.code !== 0 || !id) return refuse(`could not create the D1 database "${cf.d1}": ${wranglerError(made.out)}`, made.out);
|
|
405
421
|
db = { name: cf.d1, uuid: id };
|
|
406
422
|
remember(`d1:${cf.d1}`);
|
|
407
423
|
step(`created D1 ${cf.d1}`);
|
|
@@ -431,11 +447,11 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
431
447
|
for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
|
|
432
448
|
try {await ensureBackgroundQueues(root,w,{accountId,...(fetchFn?{fetchFn}:{})});for(const key of readStudio(root).cloudflare.created??[])created.add(key);} catch(error) {return refuse(error.message);}
|
|
433
449
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
434
|
-
if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out
|
|
450
|
+
if (migrate.code !== 0) return refuse(`D1 migrations failed: ${wranglerError(migrate.out)}`, migrate.out);
|
|
435
451
|
try {const replication=await enableReadReplication(root,w,{accountId,databaseId:db.uuid,...(fetchFn?{fetchFn}:{})});step(replication.enabled?'D1 read replication enabled':`D1 uses the primary: ${replication.reason}`);} catch(error) {step(`D1 uses the primary: ${error.message}`);}
|
|
436
452
|
step('D1 migrations applied');
|
|
437
453
|
const subscriptions=w(['d1','execute',cf.d1,'--remote','--command',(await import('./functions-build.mjs')).functionDeploymentSQL(root)]);
|
|
438
|
-
if(subscriptions.code!==0)return refuse(
|
|
454
|
+
if(subscriptions.code!==0)return refuse(`Could not register studio functions: ${wranglerError(subscriptions.out)}`,subscriptions.out);
|
|
439
455
|
|
|
440
456
|
const started = Date.now();
|
|
441
457
|
let dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
|
|
@@ -447,7 +463,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
447
463
|
step(address.created ? 'registered the account’s workers.dev address automatically' : 'kept the account’s existing workers.dev address');
|
|
448
464
|
dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
|
|
449
465
|
}
|
|
450
|
-
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${
|
|
466
|
+
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${wranglerError(dep.out)}`, dep.out);
|
|
451
467
|
try{await ensureStudioSigningKey(readStudio(root),w);}catch(error){return refuse(error.message);}
|
|
452
468
|
if (paidReleases(root).length) {
|
|
453
469
|
const bin = wranglerBin(root);
|
|
@@ -658,11 +674,11 @@ export async function ciDeploy(root, { log = () => {}, wrangler = null, preview
|
|
|
658
674
|
const apply = () => w(['d1', 'migrations', 'apply', names.binding, '--remote']);
|
|
659
675
|
let migrate = apply();
|
|
660
676
|
const first = !target && migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
|
|
661
|
-
if (migrate.code !== 0 && !first) return refuse(`D1 migrations failed: ${migrate.out
|
|
677
|
+
if (migrate.code !== 0 && !first) return refuse(`D1 migrations failed: ${wranglerError(migrate.out)}`, migrate.out);
|
|
662
678
|
if (!first) step('D1 migrations applied');
|
|
663
679
|
const register=()=>w(['d1','execute',names.binding,'--remote','--command',functionDeploymentSQL(root)]);
|
|
664
680
|
const {functionDeploymentSQL}=await import('./functions-build.mjs');
|
|
665
|
-
if(!first) {const registered=register();if(registered.code!==0)return refuse(
|
|
681
|
+
if(!first) {const registered=register();if(registered.code!==0)return refuse(`Could not register studio functions: ${wranglerError(registered.out)}`,registered.out);}
|
|
666
682
|
step(`Cron triggers: ${(readConfig(root)?.triggers?.crons??[]).join('; ')||'none (remove toolkit triggers)'}`);
|
|
667
683
|
// Workers AI (0.17.0): bound only when a server's AI guides think with it. The checkout is thrown away after.
|
|
668
684
|
if (!first && needsWorkersAi(root, w, names.binding)) {
|
|
@@ -674,16 +690,16 @@ export async function ciDeploy(root, { log = () => {}, wrangler = null, preview
|
|
|
674
690
|
}
|
|
675
691
|
const started = Date.now();
|
|
676
692
|
const dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
|
|
677
|
-
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${
|
|
693
|
+
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${wranglerError(dep.out)}`, dep.out);
|
|
678
694
|
try{await ensureStudioSigningKey(readStudio(root),w);}catch(error){return refuse(error.message);}
|
|
679
695
|
step(`deployed ${names.worker ?? 'the Worker'} in ${Math.round((Date.now() - started) / 1000)} s`);
|
|
680
696
|
const repo = repoOf(root);
|
|
681
697
|
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');
|
|
682
698
|
if (first) {
|
|
683
699
|
migrate = apply();
|
|
684
|
-
if (migrate.code !== 0) return refuse(`D1 migrations failed after the first deploy: ${migrate.out
|
|
700
|
+
if (migrate.code !== 0) return refuse(`D1 migrations failed after the first deploy: ${wranglerError(migrate.out)}`, migrate.out);
|
|
685
701
|
step('D1 migrations applied (the database was created with this first deploy)');
|
|
686
|
-
const registered=register();if(registered.code!==0)return refuse(
|
|
702
|
+
const registered=register();if(registered.code!==0)return refuse(`Could not register studio functions: ${wranglerError(registered.out)}`,registered.out);
|
|
687
703
|
}
|
|
688
704
|
try {const replication=await enableReadReplication(root,w);step(replication.enabled?'D1 read replication enabled':`D1 uses the primary: ${replication.reason}`);} catch(error) {step(`D1 uses the primary: ${error.message}`);}
|
|
689
705
|
// The live address, from what Wrangler printed (a custom domain in studio.json wins).
|
|
@@ -735,7 +751,7 @@ export async function storageAdd(root, { log = () => {} } = {}) {
|
|
|
735
751
|
const buckets = w(['r2', 'bucket', 'list']);
|
|
736
752
|
if (buckets.code !== 0) {
|
|
737
753
|
if (/enable R2|R2 is not enabled|10042|purchase|subscription|payment/i.test(buckets.out)) return enable;
|
|
738
|
-
return { ok: false, command: 'storage add', ...(explainCloudflare(buckets.out, accountId) ?? { why: `could not list R2 buckets: ${buckets.out
|
|
754
|
+
return { ok: false, command: 'storage add', ...(explainCloudflare(buckets.out, accountId) ?? { why: `could not list R2 buckets: ${wranglerError(buckets.out)}` }) };
|
|
739
755
|
}
|
|
740
756
|
const names = [...buckets.out.matchAll(/^name:\s+(\S+)/gm)].map((m) => m[1]);
|
|
741
757
|
if (names.includes(bucket)) {
|
|
@@ -744,7 +760,7 @@ export async function storageAdd(root, { log = () => {} } = {}) {
|
|
|
744
760
|
const made = w(['r2', 'bucket', 'create', bucket]);
|
|
745
761
|
if (made.code !== 0) {
|
|
746
762
|
if (/enable R2|R2 is not enabled|10042|purchase|subscription|payment/i.test(made.out)) return enable;
|
|
747
|
-
return { ok: false, command: 'storage add', why: `could not create the R2 bucket "${bucket}": ${made.out
|
|
763
|
+
return { ok: false, command: 'storage add', why: `could not create the R2 bucket "${bucket}": ${wranglerError(made.out)}` };
|
|
748
764
|
}
|
|
749
765
|
created.add(`r2:${bucket}`);
|
|
750
766
|
const next = { ...readStudio(root), cloudflare: { ...readStudio(root).cloudflare, accountId, r2: bucket, created: [...created].sort() } };
|
|
@@ -844,7 +860,7 @@ export async function mediaMove(root, { paths = null, dryRun = false, verify = f
|
|
|
844
860
|
// Where the file is meanwhile: on the site when it fits; a bigger one keeps an older R2 copy, or waits.
|
|
845
861
|
const meanwhile = r.bytes <= MAX_ASSET_BYTES ? ' It stays on the site.' : r.held ? ' Its page keeps playing the older copy in R2.' : ' At this size the site cannot carry it, so its page waits until it is in R2.';
|
|
846
862
|
if (put.code !== 0) {
|
|
847
|
-
failed.push({ path: r.path, bytes: r.bytes, why: `${explainCloudflare(put.out, account)?.why ?? `the upload failed: ${put.out
|
|
863
|
+
failed.push({ path: r.path, bytes: r.bytes, why: `${explainCloudflare(put.out, account)?.why ?? `the upload failed: ${wranglerError(put.out)}`}${meanwhile}` });
|
|
848
864
|
continue;
|
|
849
865
|
}
|
|
850
866
|
const got = await hashRemote(bin, `${bucket}/${key}`, opts);
|
package/lib/move-drain.mjs
CHANGED
|
@@ -14,5 +14,6 @@ export async function drainCommand(root,action,{run=null,fetchFn=fetch,url=null}
|
|
|
14
14
|
if(!response.ok)throw Error('Room drain is unmeasured; keep the source running');
|
|
15
15
|
const status=await response.json();
|
|
16
16
|
if(typeof status.draining!=='boolean'||typeof status.drained!=='boolean')throw Error('Upgrade and deploy the toolkit before draining');
|
|
17
|
-
|
|
17
|
+
const ended=status.idleLobbiesEnded?` ${status.idleLobbiesEnded===1?'1 room was':status.idleLobbiesEnded+' rooms were'} ended while waiting in a lobby with no round running; ${status.idleLobbiesEnded===1?'its':'their'} people were told the studio is moving and to join again shortly.`:'';
|
|
18
|
+
return {ok:true,command:'drain '+action,...status,message:status.drained?'All measured rooms are empty and their pending records are delivered.'+ended+' The source can now be made read-only.':status.draining?'Existing rooms keep playing. New admissions are paused. A room still waiting in its lobby 5 minutes after the drain began is ended, and its people are told the studio is moving.'+ended+' Run homie-studio drain status until drained is true before freezing or changing hosts.':'Room admissions are open.'};
|
|
18
19
|
}
|
|
@@ -4596,6 +4596,90 @@
|
|
|
4596
4596
|
}
|
|
4597
4597
|
}
|
|
4598
4598
|
}
|
|
4599
|
+
},
|
|
4600
|
+
"0.58.15": {
|
|
4601
|
+
"files": {
|
|
4602
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
4603
|
+
"README.md": "684b3cca8001673c",
|
|
4604
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
4605
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
4606
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
4607
|
+
"videos/README.md": "16babc3437d5545b",
|
|
4608
|
+
"posts/README.md": "37841aae476bd540",
|
|
4609
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
4610
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
4611
|
+
},
|
|
4612
|
+
"sections": {
|
|
4613
|
+
"AGENTS.md": {
|
|
4614
|
+
"(intro)": "af7f2f732505d379",
|
|
4615
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
4616
|
+
"## Layout": "f3a280b8c96cd068",
|
|
4617
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
4618
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
4619
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
4620
|
+
"## Making games": "bb715272fd1a615b",
|
|
4621
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
4622
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
4623
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
4624
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
4625
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
4626
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
4627
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
4628
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
4629
|
+
"## Beta": "9d02638207a26e73"
|
|
4630
|
+
}
|
|
4631
|
+
},
|
|
4632
|
+
"named": {
|
|
4633
|
+
"Homie Arcade": {
|
|
4634
|
+
"sections": {
|
|
4635
|
+
"AGENTS.md": {
|
|
4636
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
4637
|
+
}
|
|
4638
|
+
}
|
|
4639
|
+
}
|
|
4640
|
+
}
|
|
4641
|
+
},
|
|
4642
|
+
"0.58.16": {
|
|
4643
|
+
"files": {
|
|
4644
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
4645
|
+
"README.md": "684b3cca8001673c",
|
|
4646
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
4647
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
4648
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
4649
|
+
"videos/README.md": "16babc3437d5545b",
|
|
4650
|
+
"posts/README.md": "37841aae476bd540",
|
|
4651
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
4652
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
4653
|
+
},
|
|
4654
|
+
"sections": {
|
|
4655
|
+
"AGENTS.md": {
|
|
4656
|
+
"(intro)": "af7f2f732505d379",
|
|
4657
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
4658
|
+
"## Layout": "f3a280b8c96cd068",
|
|
4659
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
4660
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
4661
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
4662
|
+
"## Making games": "bb715272fd1a615b",
|
|
4663
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
4664
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
4665
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
4666
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
4667
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
4668
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
4669
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
4670
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
4671
|
+
"## Beta": "9d02638207a26e73"
|
|
4672
|
+
}
|
|
4673
|
+
},
|
|
4674
|
+
"named": {
|
|
4675
|
+
"Homie Arcade": {
|
|
4676
|
+
"sections": {
|
|
4677
|
+
"AGENTS.md": {
|
|
4678
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
4679
|
+
}
|
|
4680
|
+
}
|
|
4681
|
+
}
|
|
4682
|
+
}
|
|
4599
4683
|
}
|
|
4600
4684
|
}
|
|
4601
4685
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.58.
|
|
3
|
+
"version": "0.58.16",
|
|
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/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.58.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.58.16","sha256":"b981661e89026fb01e4ebd4e0cc06a403b0f90a5b22cf07d3425afcf6f99b075"});
|
package/worker/move-drain.mjs
CHANGED
|
@@ -14,12 +14,23 @@ export async function drainSince(env,{now=Date.now(),fresh=false}={}){
|
|
|
14
14
|
readings.set(env.DB,{at:now,since});
|
|
15
15
|
return since;
|
|
16
16
|
}
|
|
17
|
+
/** How long a drain counts rooms before it may say drained: an admission a few seconds late (DRAIN_TTL_MS) is counted well within it. */
|
|
18
|
+
export const DRAIN_CENSUS_MS=240000;
|
|
19
|
+
/** A room still waiting in its lobby this long after the drain began is ended, so a tab left open never holds a move:
|
|
20
|
+
* a minute past the census, when every late admission has been counted and a lobby has had five minutes to start. */
|
|
21
|
+
export const DRAIN_IDLE_MS=300000;
|
|
22
|
+
/** A round's results stay on screen this long before an idle room may end (the 5 s a launch change gives them too). */
|
|
23
|
+
export const RESULTS_MS=5000;
|
|
24
|
+
export const MOVING_MESSAGE='This studio is moving. Please join again shortly.';
|
|
25
|
+
/** Nothing in play: no round has started, or the last one is over and its results have been seen. A live round is in
|
|
26
|
+
* play whatever its clock says (only its host says it is over), and so is a phase this toolkit does not name. */
|
|
27
|
+
export function idleLobby(room,now){const r=room.lastRound;return !r||r.phase==='over'&&now-(room.roundOverAt??0)>=RESULTS_MS;}
|
|
17
28
|
export function isAdmission(request){const path=new URL(request.url).pathname;return request.headers.get('upgrade')?.toLowerCase()==='websocket'||/\/api\/(lobby|agents)(\/|$)/.test(path);}
|
|
18
29
|
export const drainResponse=()=>Response.json({ok:false,error:'moving',message:'This studio is moving. Existing rooms can finish; please join again shortly.'},{status:503,headers:{'retry-after':'30','cache-control':'no-store'}});
|
|
19
30
|
/** Read every shard directly: cached public summaries exclude private games and hide outages. */
|
|
20
31
|
export async function drainStatus(env,cat,{now=Date.now()}={}){
|
|
21
32
|
const since=await drainSince(env,{fresh:true});if(!since)return {draining:false,drained:false};
|
|
22
|
-
let clients=0,pending=0,rooms=0;
|
|
33
|
+
let clients=0,pending=0,rooms=0,idleLobbiesEnded=0;
|
|
23
34
|
for(const game of cat.games??[]){
|
|
24
35
|
const names=new Set();
|
|
25
36
|
let cursor='';
|
|
@@ -36,12 +47,12 @@ export async function drainStatus(env,cat,{now=Date.now()}={}){
|
|
|
36
47
|
for(const room of body.rooms)names.add(room.name);
|
|
37
48
|
}
|
|
38
49
|
for(const name of names){
|
|
39
|
-
const r=await env.TABLE.get(env.TABLE.idFromName(`${game.id}/${name}`)).fetch(`https://table/__move-status?game=${encodeURIComponent(game.id)}&room=${encodeURIComponent(name)}`);
|
|
50
|
+
const r=await env.TABLE.get(env.TABLE.idFromName(`${game.id}/${name}`)).fetch(`https://table/__move-status?game=${encodeURIComponent(game.id)}&room=${encodeURIComponent(name)}&since=${since}`);
|
|
40
51
|
if(!r.ok)throw Error('Cannot measure a room while moving');
|
|
41
52
|
const body=await r.json();
|
|
42
|
-
if(!Number.isSafeInteger(body.clients)||body.clients<0||!Number.isSafeInteger(body.pending)||body.pending<0)throw Error('Invalid room drain receipt');
|
|
43
|
-
clients+=body.clients;pending+=body.pending;rooms++;
|
|
53
|
+
if(!Number.isSafeInteger(body.clients)||body.clients<0||!Number.isSafeInteger(body.pending)||body.pending<0||typeof body.idleEnded!=='boolean')throw Error('Invalid room drain receipt');
|
|
54
|
+
clients+=body.clients;pending+=body.pending;rooms++;if(body.idleEnded)idleLobbiesEnded++;
|
|
44
55
|
}
|
|
45
56
|
}
|
|
46
|
-
return {draining:true,since,clients,pending,rooms,drained:now-since>=
|
|
57
|
+
return {draining:true,since,clients,pending,rooms,idleLobbiesEnded,idleEndsAt:since+DRAIN_IDLE_MS,drained:now-since>=DRAIN_CENSUS_MS&&clients===0&&pending===0};
|
|
47
58
|
}
|
package/worker/room.mjs
CHANGED
|
@@ -379,6 +379,8 @@ export class NetRoom {
|
|
|
379
379
|
// The results name every AI as AI, whatever the host's game says (section 17).
|
|
380
380
|
const round = retime && this.lastRound?.n === m.round.n && this.lastRound?.phase === m.round.phase ? { ...m.round, ...(this.lastRound.results ? { results: this.lastRound.results } : {}) } : Array.isArray(m.round.results) ? { ...m.round, results: this.labelResults(m.round.results) } : m.round;
|
|
381
381
|
const wasLive = this.lastRound && this.lastRound.phase === 'live' ? Number(this.lastRound.n) : null;
|
|
382
|
+
// When the results went up: a studio move's drain lets them be seen before it ends an idle room (move-drain.mjs).
|
|
383
|
+
if (round.phase === 'over' && !(this.lastRound?.phase === 'over' && this.lastRound.n === round.n)) this.roundOverAt = this.now();
|
|
382
384
|
this.lastRound = round;
|
|
383
385
|
// The round a launch change waits for is over: everyone sees its results for a moment, then the room re-gates.
|
|
384
386
|
if (this.regate && round.phase === 'over' && (this.regate.roundN === null || Number(round.n) >= this.regate.roundN)) this.regate.until = Math.min(this.regate.until, this.now() + 5000);
|
package/worker/table.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {lobbyFor} from './lobby-shards.mjs';
|
|
2
|
+
import {DRAIN_IDLE_MS,MOVING_MESSAGE,drainSince,idleLobby} from './move-drain.mjs';
|
|
2
3
|
import {relayStream} from './relay-channel.mjs';
|
|
3
4
|
import {roomSessions,restoreRoomFields} from './room-sessions.mjs';
|
|
4
5
|
import {idleChatSnapshot,restoreIdleChat,readIdleChat,writeIdleChat,expireIdleChat} from './idle-chat.mjs';
|
|
@@ -526,6 +527,26 @@ export class Table {
|
|
|
526
527
|
this.report();
|
|
527
528
|
}
|
|
528
529
|
|
|
530
|
+
/**
|
|
531
|
+
* A studio move's drain ends a room left waiting in its lobby (no round running, no record waiting; the caller
|
|
532
|
+
* checked both). Its people are told the studio is moving, as a final word, so no page knocks on the paused door,
|
|
533
|
+
* and the room forgets everything, as an empty one does.
|
|
534
|
+
*/
|
|
535
|
+
endForMove(room) {
|
|
536
|
+
const people = room.live().length;
|
|
537
|
+
if (this.hostRt) this.finishRoom('moving'); else this.tellLobby({ ended: true });
|
|
538
|
+
for (const c of [...room.clients.values()]) {
|
|
539
|
+
if (c.helloed) room.error(c, 'room-over', MOVING_MESSAGE);
|
|
540
|
+
room.clients.delete(c.id);
|
|
541
|
+
try { c.conn.close(1000, 'room-over'); } catch { /* gone */ }
|
|
542
|
+
}
|
|
543
|
+
room.emptySince = Date.now();
|
|
544
|
+
room.forget();
|
|
545
|
+
if (this.house) this.house.sync();
|
|
546
|
+
this.say({ ev: 'move-idle-ended', people });
|
|
547
|
+
this.report();
|
|
548
|
+
}
|
|
549
|
+
|
|
529
550
|
/**
|
|
530
551
|
* The room's part of the alarm (section 4.5 of the design). A room nobody came back to within a minute of its
|
|
531
552
|
* pause ends: the relay forgets it and its stored seats go (`office` and `recorded` stay). A room in play keeps an
|
|
@@ -1090,11 +1111,24 @@ export class Table {
|
|
|
1090
1111
|
// The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
|
|
1091
1112
|
// owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
|
|
1092
1113
|
if (url.pathname === '/__move-status') {
|
|
1093
|
-
await this.flushRoundRecords();await this.flushFunctionEvents();
|
|
1094
1114
|
const sql=this.ctx.storage.sql;
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1115
|
+
const pendingNow=async()=>{
|
|
1116
|
+
await this.flushRoundRecords();await this.flushFunctionEvents();
|
|
1117
|
+
let n=0;
|
|
1118
|
+
for(const name of ['round_outbox','function_outbox'])if(sql?.exec("SELECT name FROM sqlite_master WHERE name=?",name).toArray().length)n+=Number(sql.exec('SELECT COUNT(*) AS n FROM '+name).toArray()[0].n);
|
|
1119
|
+
return n;
|
|
1120
|
+
};
|
|
1121
|
+
let pending=await pendingNow();
|
|
1122
|
+
// The drain's deadline for a room left waiting in its lobby (worker/move-drain.mjs). The drain itself is read
|
|
1123
|
+
// here, not taken from the request, and the room remembers which drain ended it so every census counts it.
|
|
1124
|
+
const since=Number(url.searchParams.get('since'));
|
|
1125
|
+
let idleEnded=Number.isSafeInteger(since)&&since>0&&await this.ctx.storage.get('move-idle-ended')===since;
|
|
1126
|
+
if(!idleEnded&&!pending&&room.live().length&&!this.isPersistent()&&idleLobby(room,Date.now())&&Date.now()-since>=DRAIN_IDLE_MS&&await drainSince(this.env,{fresh:true})===since){
|
|
1127
|
+
await this.ctx.storage.put('move-idle-ended',since);
|
|
1128
|
+
this.endForMove(room);idleEnded=true;
|
|
1129
|
+
pending=await pendingNow();
|
|
1130
|
+
}
|
|
1131
|
+
return json({clients:room.live().length,pending,idleEnded});
|
|
1098
1132
|
}
|
|
1099
1133
|
if (url.pathname === '/__facts') return json({ ...room.officeFacts(), gates: [...(this.gateMetrics?.values() ?? [])], durability: this.storageHealth ?? { ok: true }, ...(this.house ? { brains: this.house.facts() } : {}) });
|
|
1100
1134
|
// A chat line as the room keeps it (section 19), for a report: the Worker files the room's own copy, never a reporter's.
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.58.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.58.16';
|