@homie-rocks/studio 0.32.0 → 0.33.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 +85 -2
- package/README.md +34 -9
- package/agents/agents.ts +57 -24
- package/bin/homie-studio.mjs +13 -4
- package/lib/build.mjs +64 -9
- package/lib/cf-usage.mjs +76 -0
- package/lib/cloudflare.mjs +25 -2
- package/lib/dev.mjs +45 -4
- package/lib/lab-check.mjs +3 -2
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +1 -1
- package/lib/office.mjs +7 -0
- package/lib/perf.mjs +8 -3
- package/lib/player-image.mjs +40 -0
- package/lib/rules-build.mjs +184 -0
- package/lib/rules-guard.mjs +770 -0
- package/lib/scaffold.mjs +43 -16
- package/lib/shop-catalog.mjs +10 -9
- package/lib/shop.mjs +32 -15
- package/lib/standalone-cli.mjs +30 -2
- package/lib/standalone-tools.mjs +20 -2
- package/lib/standalone.mjs +215 -9
- package/lib/studio.mjs +21 -1
- package/lib/template-history.json +90 -0
- package/lib/upgrade.mjs +19 -15
- package/lib/version.mjs +2 -7
- package/netplay/NETPLAY.md +249 -4
- package/netplay/netplay.ts +55 -13
- package/package.json +23 -1
- package/rules/core.ts +1250 -0
- package/rules/guard.ts +419 -0
- package/rules/host.ts +707 -0
- package/rules/math.ts +266 -0
- package/rules/pack.ts +292 -0
- package/rules/rules.ts +544 -0
- package/rules/view.ts +447 -0
- package/shop/SELLING.md +14 -16
- package/shop/SHOP.md +119 -30
- package/shop/shop.ts +6 -8
- package/site/SITE.md +119 -2
- package/standalone/STANDALONE.md +61 -6
- package/starters/coin-dash/game.json +11 -0
- package/starters/coin-dash/index.html +17 -0
- package/starters/coin-dash/map/main.json +16 -0
- package/starters/coin-dash/src/move.ts +27 -0
- package/starters/coin-dash/src/rules.ts +86 -0
- package/starters/coin-dash/src/view.ts +106 -0
- package/starters/coin-dash/tunables.json +5 -0
- package/starters/ember-vale/index.html +11 -0
- package/worker/brain.mjs +8 -7
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +135 -0
- package/worker/hosted.mjs +56 -0
- package/worker/index.mjs +413 -22
- package/worker/office-page.mjs +1 -0
- package/worker/office.mjs +24 -7
- package/worker/pages.mjs +189 -43
- package/worker/players.mjs +10 -6
- package/worker/referrals.mjs +192 -57
- package/worker/room-store.mjs +54 -0
- package/worker/room.mjs +294 -119
- package/worker/saves-shell.mjs +7 -1
- package/worker/schema.mjs +4 -3
- package/worker/shop-page.mjs +103 -37
- package/worker/shop-rules.mjs +156 -117
- package/worker/shop-store.mjs +70 -9
- package/worker/shop.mjs +275 -143
- package/worker/site.mjs +26 -5
- package/worker/stripe.mjs +9 -6
- package/worker/version.mjs +1 -1
package/lib/build.mjs
CHANGED
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
* fits, for `dev` (whose R2 is empty). Workers Builds (WORKERS_CI=1) builds
|
|
31
31
|
* as a deploy.
|
|
32
32
|
*
|
|
33
|
+
* site/src/rules/<id>.mjs, index.mjs for a game written as rules plus view (games/<id>/src/rules.ts): its rules as
|
|
34
|
+
* one checked, guarded module for the Worker (lib/rules-build.mjs,
|
|
35
|
+
* lib/rules-guard.mjs). The Table runs them: the server is the room's host.
|
|
36
|
+
*
|
|
33
37
|
* game.json "build" picks how a game becomes files (a ported game keeps its own shape):
|
|
34
38
|
* (absent) / { "mode": "bundle" } src/main.ts (or "entry") bundled by esbuild — new games and ES-module ports
|
|
35
39
|
* { "mode": "static" } the folder copied as it is (plain <script> games), plus homie-port.js,
|
|
@@ -38,6 +42,8 @@
|
|
|
38
42
|
* { "mode": "command", "command": "npm run build", "out": "dist" }
|
|
39
43
|
* the game's own build (Vite, webpack…), then its output copied; base must be './'
|
|
40
44
|
*/
|
|
45
|
+
import { playerImage } from './player-image.mjs';
|
|
46
|
+
import { playerOrigins } from '../worker/embed.mjs';
|
|
41
47
|
import { spawnSync } from 'node:child_process';
|
|
42
48
|
import { createHash } from 'node:crypto';
|
|
43
49
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
@@ -49,7 +55,12 @@ import { buildMedia } from './media.mjs';
|
|
|
49
55
|
import { buildSiteFiles, isoDate, landingOf, readPosts, readTheme } from './site.mjs';
|
|
50
56
|
import { checkJsonLd } from './schema-check.mjs';
|
|
51
57
|
import { SCHEMA_REFUSED } from '../worker/schema.mjs';
|
|
52
|
-
import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
|
|
58
|
+
import { PACKAGE_ROOT, isRulesGame, listGames, readStudio } from './studio.mjs';
|
|
59
|
+
|
|
60
|
+
// The rules build brings a JavaScript parser with it. It is loaded when a build first needs it, never when this
|
|
61
|
+
// module is: Homie for Claude Desktop starts the toolkit with no node_modules beside it (scripts/desktop.mjs).
|
|
62
|
+
let rulesBuildModule = null;
|
|
63
|
+
const rulesBuild = () => (rulesBuildModule ??= import('./rules-build.mjs'));
|
|
53
64
|
import { SEAT_MAX, netplayRow } from '../worker/seats.mjs';
|
|
54
65
|
import { STUDIO_VERSION } from './version.mjs';
|
|
55
66
|
import { basedOnRow, licenseOf, retiredKeys } from '../worker/license.mjs';
|
|
@@ -64,6 +75,7 @@ import { audienceOf } from '../worker/shop-rules.mjs';
|
|
|
64
75
|
import { loungeConfig, loungeProblems } from '../worker/lounge-store.mjs';
|
|
65
76
|
// GAME PARTS (parts/PARTS.md): the three call-outs below are all the build knows of them.
|
|
66
77
|
import { buildParts, partsPlugin } from './parts-build.mjs';
|
|
78
|
+
// RULES ON THE SERVER (NETPLAY.md section 29): a game with a src/rules.ts is built as a view bundle plus a rules module.
|
|
67
79
|
|
|
68
80
|
/** Never copied into a static game's served folder. */
|
|
69
81
|
const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md', 'CODEX.md', 'lab.json', 'codex']);
|
|
@@ -356,7 +368,8 @@ function pointAtBundle(html, bundle) {
|
|
|
356
368
|
* its public/ folder. What `build` serves at /games/<id>/, and what the Game Lab (lib/lab.mjs) builds New and Today
|
|
357
369
|
* with. `sourcemap` is esbuild's (the lab keeps a linked map beside its builds); `maps` keeps the site build's map in
|
|
358
370
|
* .studio/maps/<id>/. Returns { mode, warnings, metafile, bundle, chunks } (metafile: the bundle's inputs, null for
|
|
359
|
-
* other modes; bundle: its path in `out`, null when the game has none; chunks: what it loads later
|
|
371
|
+
* other modes; bundle: its path in `out`, null when the game has none; chunks: what it loads later; rules: for a game
|
|
372
|
+
* written as rules plus view, what lib/rules-build.mjs made of its rules, else null).
|
|
360
373
|
*
|
|
361
374
|
* The bundle is ES modules with code splitting: every `await import('./later')` in the game becomes a chunk of its
|
|
362
375
|
* own (assets/chunk-<HASH>.js) that the browser fetches when the game asks, from beside the bundle, wherever the
|
|
@@ -372,18 +385,28 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
372
385
|
let bundled = null;
|
|
373
386
|
let chunks = [];
|
|
374
387
|
const budgets = modelBudgetsOf(g, log);
|
|
388
|
+
// RULES PLUS VIEW (rooms on the server). A game with a src/rules.ts has its rules checked, guarded and loaded first:
|
|
389
|
+
// a rule the wall refuses stops the build here, with the line named. A game without one is hosted by a player's
|
|
390
|
+
// browser exactly as before, and cannot ask for the server.
|
|
391
|
+
const ruled = isRulesGame(g);
|
|
392
|
+
if (!ruled && g.room?.host === 'server') {
|
|
393
|
+
throw new Error(`games/${g.id}/game.json asks for "room": { "host": "server" }, but ${mode === 'bundle' ? 'this game has no src/rules.ts: its rules are inside its own code and run in a player\'s browser. Ask for it to be rewritten as rules plus view' : 'a ported game is someone else\'s browser code, which the server cannot run. It stays hosted by a player\'s browser'}.`);
|
|
394
|
+
}
|
|
395
|
+
if (ruled && mode !== 'bundle') throw new Error(`games/${g.id} has a src/rules.ts and game.json "build": { "mode": "${mode}" }. A game written as rules plus view is built by Homie itself: take the "build" setting out.`);
|
|
396
|
+
const { prepareRules, viewPlugin } = ruled ? await rulesBuild() : {};
|
|
397
|
+
const rules = ruled ? await prepareRules(esbuild, root, g, { log }) : null;
|
|
375
398
|
const bundle = async (entryRel) => {
|
|
376
399
|
const entry = join(g.dir, entryRel);
|
|
377
400
|
if (!existsSync(entry)) throw new Error(`games/${g.id}: entry ${entryRel} not found`);
|
|
378
401
|
const options = {
|
|
379
|
-
entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: sourcemap ?? false,
|
|
402
|
+
entryPoints: [rules ? 'homie:view' : entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: sourcemap ?? false,
|
|
380
403
|
outdir: join(out, 'assets'), splitting: true, entryNames: hashed ? 'main-[hash]' : 'main', chunkNames: 'chunk-[hash]',
|
|
381
404
|
absWorkingDir: root, logLevel: 'silent', metafile: true,
|
|
382
405
|
loader: LOADERS, assetNames: '[name]-[hash]',
|
|
383
406
|
// game.json assets.budgets, read by createModels() (assets/assets.ts): a constant in the bundle, never a fetch.
|
|
384
407
|
define: { __HOMIE_MODEL_BUDGETS__: JSON.stringify(budgets ?? {}) },
|
|
385
408
|
// GAME PARTS: `@parts/<host>/<id>` and `@parts/<id>` resolve to the part's entry, and the game is credited.
|
|
386
|
-
plugins: [partsPlugin(root, g.id)],
|
|
409
|
+
plugins: [partsPlugin(root, g.id), ...(rules ? [viewPlugin(g, rules, entry)] : [])],
|
|
387
410
|
};
|
|
388
411
|
const failed = (error) => {
|
|
389
412
|
const first = error.errors?.[0];
|
|
@@ -429,7 +452,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
429
452
|
cpSync(built, out, { recursive: true });
|
|
430
453
|
writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
|
|
431
454
|
} else {
|
|
432
|
-
await bundle(g.entry ?? 'src/main.ts');
|
|
455
|
+
await bundle(g.entry ?? (rules ? 'src/view.ts' : 'src/main.ts'));
|
|
433
456
|
const html = join(g.dir, 'index.html');
|
|
434
457
|
if (!existsSync(html)) throw new Error(`games/${g.id}/index.html is missing`);
|
|
435
458
|
const text = readFileSync(html, 'utf8');
|
|
@@ -437,7 +460,15 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
437
460
|
}
|
|
438
461
|
if (mode !== 'static' && existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
|
|
439
462
|
if (!existsSync(join(out, 'index.html'))) throw new Error(`games/${g.id}/index.html is missing`);
|
|
440
|
-
|
|
463
|
+
if (rules) {
|
|
464
|
+
// Only this game's executable view and rules data: buildInfo, the site and other games cannot reload its players.
|
|
465
|
+
const digest = createHash('sha256').update(rules.build);
|
|
466
|
+
for (const file of [bundled, ...chunks].filter(Boolean)) digest.update(readFileSync(join(out, file)));
|
|
467
|
+
digest.update(readFileSync(join(out, 'index.html')));
|
|
468
|
+
rules.build = digest.digest('hex').slice(0, 32);
|
|
469
|
+
if (g.netplay?.version !== undefined) log(` ${g.id}: rules games use their build hash as the revision; netplay.version is not read`);
|
|
470
|
+
}
|
|
471
|
+
return { mode, warnings, metafile, bundle: bundled, chunks, rules };
|
|
441
472
|
}
|
|
442
473
|
|
|
443
474
|
/**
|
|
@@ -483,10 +514,13 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
483
514
|
const built = [];
|
|
484
515
|
const retired = [];
|
|
485
516
|
const cache = {};
|
|
517
|
+
const ruled = [];
|
|
518
|
+
const browserHosted = [];
|
|
486
519
|
for (const g of games) {
|
|
487
520
|
const out = join(dist, 'games', g.id);
|
|
488
521
|
const started = Date.now();
|
|
489
|
-
const { mode, warnings, bundle, chunks } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log, hashed: true });
|
|
522
|
+
const { mode, warnings, bundle, chunks, rules } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log, hashed: true });
|
|
523
|
+
if (rules) ruled.push({ id: g.id, ...rules }); else browserHosted.push(g.id);
|
|
490
524
|
// The guides' vocabulary (NETPLAY.md section 18): checked here, so a room never meets a line it cannot say.
|
|
491
525
|
vocabFor(g, out);
|
|
492
526
|
// Room chat (NETPLAY.md section 19): game.json "chat", checked here (its quick lines pass the chat floor too).
|
|
@@ -509,7 +543,10 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
509
543
|
...(bundle ? { bundle } : {}), ...(chunks.length ? { chunks: chunks.length, chunkBytes: later } : {}),
|
|
510
544
|
});
|
|
511
545
|
log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB${chunks.length ? ` + ${chunks.length} ${chunks.length === 1 ? 'chunk' : 'chunks'} loaded later, ${Math.max(1, Math.round(later / 1024))} KB` : ''})`);
|
|
546
|
+
if (rules) log(` ${g.id}: its rules run on the server (checked and guarded, ${Math.max(1, Math.round(rules.code.length / 1024))} KB, build ${rules.build}; three seconds with bots: the busiest tick used ${rules.tickUnits} of ${rules.settings.budget.tick} budget units, ${rules.units} of them in one handler)`);
|
|
512
547
|
}
|
|
548
|
+
// A game written before rules (its own code is the host) builds and runs exactly as it did. Said in one line.
|
|
549
|
+
if (browserHosted.length) log(`hosted by a player's browser, as before (no src/rules.ts; nothing to do): ${browserHosted.join(', ')}`);
|
|
513
550
|
const all = listGames(root);
|
|
514
551
|
// No game is handed over whole any more (remix was retired; worker/license.mjs). A one-game build starts from the
|
|
515
552
|
// site as it is, which an older toolkit may have built: what that wrote for remixers (a game's whole source, and
|
|
@@ -533,6 +570,9 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
533
570
|
const s = studio.site && typeof studio.site === 'object' ? studio.site : {};
|
|
534
571
|
// studio.json site.order: the catalogue's order is every listing's order.
|
|
535
572
|
const shown = orderGames(all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))), s.order, log);
|
|
573
|
+
// Server-hosted games: what this build made, and for a one-game build what the catalogue before it said of the others.
|
|
574
|
+
const rulesOf = new Map(ruled.map((r) => [r.id, { host: 'server', tickHz: r.settings.tickHz, inputHz: r.settings.inputHz, contract: r.schema.contract, build: r.build, stateHash: r.stateHash, rounds: r.rounds }]));
|
|
575
|
+
if (only) for (const row of readJson(join(dist, 'games.json'))?.games ?? []) if (row?.room?.host === 'server' && row.id !== only && !rulesOf.has(row.id)) rulesOf.set(row.id, row.room);
|
|
536
576
|
// What each game's build is (its landing's own pictures are not part of it): the digest the manifest names.
|
|
537
577
|
const builds = {};
|
|
538
578
|
const rows = shown.map((g) => {
|
|
@@ -540,6 +580,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
540
580
|
const net = netplayOf(g, join(dist, 'games', g.id));
|
|
541
581
|
const { min, max } = seatsFor(g, net);
|
|
542
582
|
const netRow = netplayRow(net);
|
|
583
|
+
if (rulesOf.has(g.id)) netRow.row = { ...netRow.row, version: rulesOf.get(g.id).build };
|
|
543
584
|
for (const p of netRow.problems) log(`warning: games/${g.id}/game.json: ${p}`);
|
|
544
585
|
const seeds = serverSeeds(g, log);
|
|
545
586
|
// The game's own palette (style.json, its art direction): the play page's buttons wear its paper and ink, so the
|
|
@@ -558,11 +599,15 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
558
599
|
return {
|
|
559
600
|
id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
|
|
560
601
|
...(ui ? { ui } : {}),
|
|
561
|
-
|
|
602
|
+
// A rules game's round is its rules' own (`room.rounds`), and its movement a per-kind choice there: game.json's
|
|
603
|
+
// `roundSeconds` and `netplay.movement` are not read for it. `room` says where its rules run, to the Worker.
|
|
604
|
+
roundSeconds: rulesOf.has(g.id) ? rulesOf.get(g.id).rounds?.seconds ?? null : g.roundSeconds ?? net.roundSeconds ?? null, movement: rulesOf.has(g.id) ? null : net.movement ?? null, cover: g.cover ?? null,
|
|
605
|
+
...(rulesOf.has(g.id) ? { room: rulesOf.get(g.id) } : {}),
|
|
562
606
|
// game.json "netplay": { "version", "stallMs", "params" } (NETPLAY.md sections 22 to 24): the game's revision (a
|
|
563
607
|
// room runs one build at a time), its own host stall time, and the address's switches its frame is handed.
|
|
564
608
|
...(netRow.row ? { netplay: netRow.row } : {}),
|
|
565
609
|
...(g.screen ? { screen: g.screen } : {}),
|
|
610
|
+
...(g.playerCard === false ? { playerCard: false } : {}),
|
|
566
611
|
// game.json "saves": true — player accounts and cloud saves (saves/SAVES.md): the play shell answers the game's
|
|
567
612
|
// saves calls, and the site's nav and the game's landing offer a player account.
|
|
568
613
|
...(g.saves === true || (g.saves && typeof g.saves === 'object') ? { saves: true } : {}),
|
|
@@ -616,6 +661,9 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
616
661
|
// studio.json "site": { "schema": { … } }: the owner's own schema.org properties on the studio (sameAs, …).
|
|
617
662
|
...(studioExtras ? { schema: studioExtras } : {}),
|
|
618
663
|
site: {
|
|
664
|
+
...(/^@[A-Za-z0-9_]{1,15}$/.test(s.twitterSite || '') ? { twitterSite: s.twitterSite } : {}),
|
|
665
|
+
...(s.playerCard === false ? { playerCard: false } : {}),
|
|
666
|
+
...(Array.isArray(s.playerCardOrigins) ? { playerCardOrigins: playerOrigins(s.playerCardOrigins) } : {}),
|
|
619
667
|
...(rows.some((g) => g.id === s.featured) ? { featured: s.featured } : {}),
|
|
620
668
|
...(Array.isArray(s.frameAncestors) ? { frameAncestors: s.frameAncestors.filter((o) => /^https:\/\/[a-z0-9.-]+(?::\d+)?$/i.test(String(o))).slice(0, 8) } : {}),
|
|
621
669
|
},
|
|
@@ -645,6 +693,10 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
645
693
|
// shop.json, checked (nothing secret is ever in it): the Worker sells from this and checks it again.
|
|
646
694
|
...(shop ? { shop } : {}),
|
|
647
695
|
};
|
|
696
|
+
for (const game of rows) {
|
|
697
|
+
const image = await playerImage(catalogue, game, dist, log);
|
|
698
|
+
if (image) game.playerImage = image;
|
|
699
|
+
}
|
|
648
700
|
writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
|
|
649
701
|
// What this build made, for whatever runs next (a deploy says which games it changes; a script checks the live
|
|
650
702
|
// site against it): per game its digest, its bundle and chunks, and whether it differs from the build before.
|
|
@@ -660,11 +712,14 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
660
712
|
// A deploy builds first and prints its own result, not the build's: so the build a deploy is about to ship says
|
|
661
713
|
// here which games it changes and which build each is (`homie-studio build` prints the same from its result).
|
|
662
714
|
if (deploy) for (const b of built) log(` ${b.id}: ${b.changed}${b.changed === 'new' ? '' : ' since the last build here'}, build ${b.hash}`);
|
|
715
|
+
// The rules of the server-hosted games, where the studio's Worker imports them (site/src/rules/). Written with the
|
|
716
|
+
// site, never before: a game that did not build has left both as they were.
|
|
717
|
+
const hosted = (await rulesBuild()).writeRules(root, ruled, all.filter((g) => rulesOf.has(g.id)).map((g) => g.id));
|
|
663
718
|
// All of it is there: now, and only now, it becomes site/dist (lib/stage.mjs).
|
|
664
719
|
const swapped = swapIn(dist, live);
|
|
665
720
|
return {
|
|
666
721
|
ok: true, command: 'build', dist: live, games: built, catalogue: catalogue.games.map((g) => g.id),
|
|
667
|
-
builds: report, swapped, ...(typed ? { types: typed } : {}),
|
|
722
|
+
builds: report, swapped, ...(typed ? { types: typed } : {}), ...(hosted.length ? { hosted } : {}),
|
|
668
723
|
// One plain note for the whole build, however many games and keys it is about (never one a key).
|
|
669
724
|
...(retired.length ? { retired: `Remix was retired, and games now build on each other through parts (pieces a studio shares; ask for a part, or see parts/PARTS.md). These settings no longer do anything and can be deleted: ${retired.join('; ')}. No game's source is served whole; a game made from another keeps its credit.` } : {}),
|
|
670
725
|
songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped, mediaNotes: media.notes ?? [],
|
package/lib/cf-usage.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare's own figures for a studio's rooms, read from its GraphQL Analytics API: what the rooms of one Worker
|
|
3
|
+
* (or one room object) were billed for over a stretch of time. It is how the real-Cloudflare measurement of a
|
|
4
|
+
* server-hosted room (T1 in rooms-milestone-1-design.md, section 12) is checked against the arithmetic the deploy
|
|
5
|
+
* plan states (lib/cloudflare.mjs `hostedUse`), and how "a room everyone has left stops being billed" is seen on
|
|
6
|
+
* Cloudflare's side and not only in the room's own log.
|
|
7
|
+
*
|
|
8
|
+
* It reads; it changes nothing. It needs an API token that may read Account Analytics, which the caller supplies
|
|
9
|
+
* (never stored, never logged, never in a result).
|
|
10
|
+
*
|
|
11
|
+
* NOT YET RUN AGAINST CLOUDFLARE. The two datasets and their fields are Cloudflare's published ones
|
|
12
|
+
* (`durableObjectsInvocationsAdaptiveGroups`, `durableObjectsPeriodicGroups`), but this file has only been run
|
|
13
|
+
* against a stand-in for the API (test/rules-usage.test.mjs). Its first real run must confirm the field names and
|
|
14
|
+
* the units before any number from it is quoted.
|
|
15
|
+
*/
|
|
16
|
+
import { hostedUse } from './cloudflare.mjs';
|
|
17
|
+
|
|
18
|
+
const ENDPOINT = 'https://api.cloudflare.com/client/v4/graphql';
|
|
19
|
+
const QUERY = `query RoomUsage($account: String!, $filter: AccountDurableObjectsInvocationsAdaptiveGroupsFilter_InputObject!, $periodic: AccountDurableObjectsPeriodicGroupsFilter_InputObject!) {
|
|
20
|
+
viewer { accounts(filter: { accountTag: $account }) {
|
|
21
|
+
invocations: durableObjectsInvocationsAdaptiveGroups(limit: 10000, filter: $filter) { sum { requests errors responseBodySize wallTime } }
|
|
22
|
+
periodic: durableObjectsPeriodicGroups(limit: 10000, filter: $periodic) { sum { activeTime cpuTime duration inboundWebsocketMsgCount outboundWebsocketMsgCount rowsRead rowsWritten subrequests exceededCpuErrors exceededMemoryErrors fatalInternalErrors } }
|
|
23
|
+
} }
|
|
24
|
+
}`;
|
|
25
|
+
|
|
26
|
+
/** Cloudflare bills twenty incoming socket messages as one request. */
|
|
27
|
+
export const SOCKET_MESSAGES_PER_REQUEST = 20;
|
|
28
|
+
|
|
29
|
+
const total = (groups, key) => (Array.isArray(groups) ? groups : []).reduce((n, g) => n + (Number(g?.sum?.[key]) || 0), 0);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What Cloudflare counted between `since` and `until` (ISO times) for the Durable Objects of one Worker script,
|
|
33
|
+
* or for one object of it (`objectId`).
|
|
34
|
+
* Returns { ok, requests, socketMessagesIn, socketMessagesOut, billedRequests, gbSeconds, cpuMs, activeMs, rowsWritten,
|
|
35
|
+
* rowsRead, errors, exceeded } or { ok: false, why }.
|
|
36
|
+
*/
|
|
37
|
+
export async function cfUsage({ accountId, token, script, since, until, objectId = null, fetchFn = globalThis.fetch }) {
|
|
38
|
+
if (!accountId || !token || !script) return { ok: false, why: 'needs the account id, an API token that may read Account Analytics, and the Worker\'s name' };
|
|
39
|
+
if (Number.isNaN(Date.parse(since)) || Number.isNaN(Date.parse(until)) || Date.parse(until) <= Date.parse(since)) return { ok: false, why: 'needs a stretch of time: since and until, as ISO times' };
|
|
40
|
+
const filter = { datetime_geq: new Date(since).toISOString(), datetime_leq: new Date(until).toISOString(), scriptName: script, ...(objectId ? { objectId } : {}) };
|
|
41
|
+
let body;
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetchFn(ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify({ query: QUERY, variables: { account: accountId, filter, periodic: filter } }), signal: AbortSignal.timeout(30_000) });
|
|
44
|
+
body = await res.json();
|
|
45
|
+
if (!res.ok || body?.errors?.length) return { ok: false, why: `Cloudflare's analytics answered ${res.status}${body?.errors?.length ? `: ${String(body.errors[0]?.message ?? '').slice(0, 200)}` : ''}` };
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return { ok: false, why: `Cloudflare's analytics could not be reached: ${String(error?.message ?? error).slice(0, 120)}` };
|
|
48
|
+
}
|
|
49
|
+
const acct = body?.data?.viewer?.accounts?.[0];
|
|
50
|
+
if (!acct) return { ok: false, why: 'Cloudflare\'s analytics has nothing for that account' };
|
|
51
|
+
const requests = total(acct.invocations, 'requests');
|
|
52
|
+
const messagesIn = total(acct.periodic, 'inboundWebsocketMsgCount');
|
|
53
|
+
return {
|
|
54
|
+
ok: true, script, ...(objectId ? { objectId } : {}), since: filter.datetime_geq, until: filter.datetime_leq, hours: (Date.parse(until) - Date.parse(since)) / 3_600_000,
|
|
55
|
+
requests, socketMessagesIn: messagesIn, socketMessagesOut: total(acct.periodic, 'outboundWebsocketMsgCount'),
|
|
56
|
+
billedRequests: requests + Math.ceil(messagesIn / SOCKET_MESSAGES_PER_REQUEST),
|
|
57
|
+
gbSeconds: total(acct.periodic, 'duration'),
|
|
58
|
+
// Cloudflare reports processor and active time in microseconds.
|
|
59
|
+
cpuMs: total(acct.periodic, 'cpuTime') / 1000, activeMs: total(acct.periodic, 'activeTime') / 1000,
|
|
60
|
+
rowsWritten: total(acct.periodic, 'rowsWritten'), rowsRead: total(acct.periodic, 'rowsRead'),
|
|
61
|
+
errors: total(acct.invocations, 'errors'),
|
|
62
|
+
exceeded: { cpu: total(acct.periodic, 'exceededCpuErrors'), memory: total(acct.periodic, 'exceededMemoryErrors'), internal: total(acct.periodic, 'fatalInternalErrors') },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Cloudflare's figures for one room beside what the deploy plan says such a room uses: per room-hour, with how far
|
|
68
|
+
* each is from the estimate. T1's line is 10% for requests and GB-seconds.
|
|
69
|
+
*/
|
|
70
|
+
export function compareUse(use, { seats = 8, inputHz = 20, roomHours = use?.hours ?? 1 } = {}) {
|
|
71
|
+
if (!use?.ok || !(roomHours > 0)) return null;
|
|
72
|
+
const plan = hostedUse({ seats, inputHz });
|
|
73
|
+
const row = (measured, estimate) => ({ measured: Math.round(measured * 10) / 10, estimate, off: estimate ? Math.round(((measured - estimate) / estimate) * 1000) / 10 : null });
|
|
74
|
+
const out = { roomHours, requests: row(use.billedRequests / roomHours, plan.requests), gbSeconds: row(use.gbSeconds / roomHours, plan.gbSeconds), rowsWritten: row(use.rowsWritten / roomHours, plan.rows) };
|
|
75
|
+
return { ...out, within10: Math.abs(out.requests.off) <= 10 && Math.abs(out.gbSeconds.off) <= 10 };
|
|
76
|
+
}
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import { whyFailed } from './net.mjs';
|
|
|
40
40
|
import { repoOf } from './repo.mjs';
|
|
41
41
|
import { keptRoutes, readConfig, readZoneRoutes, routesOf, shadowedDomain, wideRouteRefusal, zoneFinding } from './routes.mjs';
|
|
42
42
|
import { ensureLocalIgnored, ensureMigrations, migrationWord, wranglerConfig } from './scaffold.mjs';
|
|
43
|
-
import { LOCAL_STATE, configPath, domainOrigin, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
43
|
+
import { LOCAL_STATE, configPath, domainOrigin, isRulesGame, isWorkersDev, layoutOf, listGames, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
44
44
|
import { projectsCloudflareEnv } from './projects-env.mjs';
|
|
45
45
|
|
|
46
46
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
@@ -161,9 +161,30 @@ export async function zonePlan(root, opts = {}) {
|
|
|
161
161
|
return z ? zoneView(z) : null;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* What an hour of play in one full room of a server-hosted game uses of the free plan's three daily allowances for
|
|
166
|
+
* Durable Objects (NETPLAY.md section 29; the arithmetic is Cloudflare's published pricing): incoming socket messages
|
|
167
|
+
* are billed 20 to 1 (at most `inputHz` input frames a second a player, and a ping every two seconds); an object in
|
|
168
|
+
* memory is billed as 128 MB; and until a room is saved (a later release) it writes only its alarm, twelve times an
|
|
169
|
+
* hour. Estimates, for the plan to say. Homie limits nothing because of them.
|
|
170
|
+
*/
|
|
171
|
+
export function hostedUse({ seats = 8, inputHz = 20 } = {}) {
|
|
172
|
+
const requests = Math.round((seats * (inputHz * 3600 + 1800)) / 20);
|
|
173
|
+
const gbSeconds = 0.128 * 3600;
|
|
174
|
+
const pct = (n, of) => (n / of < 0.001 ? 'under 0.1%' : `${Math.round((n / of) * 1000) / 10}%`);
|
|
175
|
+
return { seats, requests, gbSeconds, rows: 12, share: { requests: pct(requests, 100_000), gbSeconds: pct(gbSeconds, 13_000), rows: pct(12, 100_000) } };
|
|
176
|
+
}
|
|
177
|
+
|
|
164
178
|
/** What `deploy` will create and what it costs, from studio.json alone. It calls nothing. */
|
|
165
179
|
export function deployPlan(root) {
|
|
166
180
|
const studio = readStudio(root);
|
|
181
|
+
// Games whose rules run on the server (a src/rules.ts, and not "room": { "host": "browser" }): one line each.
|
|
182
|
+
const hosted = listGames(root).filter((g) => isRulesGame(g) && g.room?.host !== 'browser').map((g) => {
|
|
183
|
+
const seats = Math.max(1, Math.min(32, Math.floor(Number(g.players?.max)) || 8));
|
|
184
|
+
const tickHz = Math.max(1, Math.min(60, Math.floor(Number(g.room?.tickHz)) || 20));
|
|
185
|
+
const use = hostedUse({ seats, inputHz: Math.max(1, Math.min(tickHz, Math.floor(Number(g.room?.inputHz)) || tickHz)) });
|
|
186
|
+
return { id: g.id, name: g.name ?? g.id, ...use };
|
|
187
|
+
});
|
|
167
188
|
const cf = { created: [], ...studio.cloudflare };
|
|
168
189
|
const created = new Set(cf.created ?? []);
|
|
169
190
|
const storage = cf.r2 && created.has(`r2:${cf.r2}`) ? cf.r2 : null;
|
|
@@ -173,7 +194,8 @@ export function deployPlan(root) {
|
|
|
173
194
|
cloudflare: [
|
|
174
195
|
{ kind: 'Worker', name: cf.worker, what: 'the studio\'s pages, each game\'s page and play shell, and /.well-known/homie-studio.json for the directory', state: mark(`worker:${cf.worker}`), plan: 'Workers Free' },
|
|
175
196
|
{ kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round, the studio\'s own stats (daily counters of visits, plays, rooms, rounds and songs, for the owner only; nothing about a visitor), and, for games that keep saves, player accounts (a passkey\'s public key, never a password) and their saves', state: mark(`d1:${cf.d1}`), plan: 'Workers Free (500 MB per database, 5 GB per account)' },
|
|
176
|
-
{ kind: 'Durable Object', name: 'Table', what: 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
|
|
197
|
+
{ kind: 'Durable Object', name: 'Table', what: hosted.length ? `one per public room: the netplay relay (seats, snapshots), and for ${hosted.map((h) => h.id).join(', ')} the game's own rules, which run here and nowhere else` : 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
|
|
198
|
+
...hosted.map((h) => ({ kind: 'Game rules', name: h.id, what: `the rules of ${h.name} run on Cloudflare, in each room's Table, not in a player's browser. An hour of play in one full room (${h.seats} players) uses about ${h.share.requests} of the free plan's 100,000 requests a day, ${h.share.gbSeconds} of its 13,000 GB-seconds of running time and ${h.share.rows} of its 100,000 rows written. A room with nobody in it uses nothing. Out of requests, every room of every game stops until 00:00 UTC; the Workers Paid plan removes the three allowances`, state: 'in the Worker', plan: 'Workers Free' })),
|
|
177
199
|
{ kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
|
|
178
200
|
storage
|
|
179
201
|
? { kind: 'R2 bucket', name: storage, what: 'the studio\'s big media: songs and videos over the size studio.json media.r2Over names (1 MiB unless set) or that git leaves out, uploaded and checked by SHA-256 before the site stops carrying them, served at their same addresses', state: 'exists (this studio made it)', plan: 'R2 (payment method on the account; 10 GB-month free)' }
|
|
@@ -189,6 +211,7 @@ export function deployPlan(root) {
|
|
|
189
211
|
stores: 'the site\'s address and a claim token, the studio\'s name and slug, the @homie-rocks/studio version, and each game\'s id, name, blurb and Play/page links. Never code, media, keys or accounts.',
|
|
190
212
|
},
|
|
191
213
|
never: 'It never touches a Worker, database or bucket this studio did not create, and it never adds a payment method or buys anything.',
|
|
214
|
+
...(hosted.length ? { hosted } : {}),
|
|
192
215
|
};
|
|
193
216
|
}
|
|
194
217
|
|
package/lib/dev.mjs
CHANGED
|
@@ -25,14 +25,14 @@
|
|
|
25
25
|
* named and left alone.
|
|
26
26
|
*/
|
|
27
27
|
import { spawn, spawnSync } from 'node:child_process';
|
|
28
|
-
import { existsSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
29
29
|
import { connect } from 'node:net';
|
|
30
30
|
import { dirname, join, sep } from 'node:path';
|
|
31
31
|
import { detectLocalAi, localAiVars } from './local-ai.mjs';
|
|
32
32
|
import { projectsCloudflareEnv } from './projects-env.mjs';
|
|
33
33
|
import { routesOf } from './routes.mjs';
|
|
34
34
|
import { ensureMigrations, migrationWord } from './scaffold.mjs';
|
|
35
|
-
import { listGames, workerDir } from './studio.mjs';
|
|
35
|
+
import { isRulesGame, listGames, workerDir } from './studio.mjs';
|
|
36
36
|
|
|
37
37
|
export const devFile = (root) => join(workerDir(root), '.wrangler', 'homie-dev.json');
|
|
38
38
|
|
|
@@ -281,6 +281,25 @@ function passLines(stream, to, opts) {
|
|
|
281
281
|
stream.on('end', () => { if (rest) to.write(relayLines(rest, opts).join('\n')); });
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* RULES ON THE SERVER, WHILE DEV RUNS (NETPLAY.md section 29). A game whose rules run in the Table is in the Worker's
|
|
286
|
+
* own bundle (site/src/rules/), so a change to its rules, its movement, its tunables, its map or its settings is live
|
|
287
|
+
* only after a build: the build checks and guards the rules again and writes the module, and Wrangler, which watches
|
|
288
|
+
* the Worker's files, restarts the local Worker with it. dev does that build by itself when one of those files is
|
|
289
|
+
* saved. `rulesStamp` is what it compares: the newest change among them, and how many there are.
|
|
290
|
+
*/
|
|
291
|
+
export function rulesStamp(g) {
|
|
292
|
+
const files = [join(g.dir, 'game.json'), join(g.dir, 'tunables.json')];
|
|
293
|
+
for (const sub of ['src', 'map']) {
|
|
294
|
+
const dir = join(g.dir, sub);
|
|
295
|
+
if (existsSync(dir)) for (const name of readdirSync(dir, { recursive: true })) files.push(join(dir, String(name)));
|
|
296
|
+
}
|
|
297
|
+
let newest = 0;
|
|
298
|
+
let n = 0;
|
|
299
|
+
for (const f of files) { try { const st = statSync(f); if (st.isFile()) { n += 1; newest = Math.max(newest, st.mtimeMs); } } catch { /* gone */ } }
|
|
300
|
+
return `${n}:${Math.round(newest)}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
284
303
|
const builtIds = (root) => { try { return JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')).games.map((g) => g.id); } catch { return null; } };
|
|
285
304
|
|
|
286
305
|
/**
|
|
@@ -337,7 +356,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
|
|
|
337
356
|
}
|
|
338
357
|
// --remote-ai: Wrangler's --local turns every remote binding off ("not supported"), so a dev with the real Workers AI
|
|
339
358
|
// (the guides' brains, room chat's review) runs without it; everything else stays local all the same.
|
|
340
|
-
const args = ['dev', ...(remoteAi ? [] : ['--local']), '--ip', '127.0.0.1', '--port', port, ...ai.args, ...localVars];
|
|
359
|
+
const args = ['dev', ...(remoteAi ? [] : ['--local']), '--ip', '127.0.0.1', '--port', port, '--var', 'HOMIE_EMBED_PREVIEW:1', ...ai.args, ...localVars];
|
|
341
360
|
log(`Stop it with: ${stop} (this studio's dev server only)`);
|
|
342
361
|
|
|
343
362
|
let child = null;
|
|
@@ -391,7 +410,29 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
|
|
|
391
410
|
}
|
|
392
411
|
} finally { busy = false; }
|
|
393
412
|
};
|
|
394
|
-
|
|
413
|
+
// A rules game saved while dev runs is built again (see rulesStamp), one game at a time.
|
|
414
|
+
const stamps = new Map(listGames(root).filter(isRulesGame).map((g) => [g.id, rulesStamp(g)]));
|
|
415
|
+
let rebuilding = false;
|
|
416
|
+
const rulesLook = async () => {
|
|
417
|
+
if (rebuilding || ending) return;
|
|
418
|
+
for (const g of listGames(root).filter(isRulesGame)) {
|
|
419
|
+
const now = rulesStamp(g);
|
|
420
|
+
if (stamps.get(g.id) === now) continue;
|
|
421
|
+
stamps.set(g.id, now);
|
|
422
|
+
rebuilding = true;
|
|
423
|
+
try {
|
|
424
|
+
log(`games/${g.id} changed: checking its rules and building it again…`);
|
|
425
|
+
const hash = () => { try { return JSON.parse(readFileSync(join(root, 'site/dist/games.json'), 'utf8')).games.find((x) => x.id === g.id)?.room?.stateHash; } catch { return null; } };
|
|
426
|
+
const before = hash();
|
|
427
|
+
await build(root, { only: g.id, log });
|
|
428
|
+
log(`games/${g.id} is rebuilt. ${before && before === hash() ? 'Local rooms resume their saved match; pages reconnect.' : 'The stored shape changed: local rooms reset to a fresh match; pages reconnect.'}`);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
log(`games/${g.id} did not build, so the local site still runs what it had:\n${String(error?.message ?? error)}`);
|
|
431
|
+
} finally { rebuilding = false; }
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
const timer = setInterval(() => { look().catch(() => {}); rulesLook().catch(() => {}); }, watchMs);
|
|
395
436
|
timer.unref?.();
|
|
396
437
|
|
|
397
438
|
/** Wait for the local site, then hold it to its own address (socketOriginProblem); a mismatch ends dev. */
|
package/lib/lab-check.mjs
CHANGED
|
@@ -111,10 +111,11 @@ export async function labCheck(root, id, { take = null, today = 'HEAD', device =
|
|
|
111
111
|
const dir = out ?? join(labDir(root, game), `check-${stamp}`);
|
|
112
112
|
mkdirSync(dir, { recursive: true });
|
|
113
113
|
const server = await startLabServer(root, { port: 0, today, watchFiles: false, log: () => {} });
|
|
114
|
-
|
|
114
|
+
let browser;
|
|
115
|
+
try { browser = await puppeteer.launch({
|
|
115
116
|
executablePath: chrome, headless: true, timeout: 150_000, protocolTimeout: 240_000,
|
|
116
117
|
args: [...chromeArgs(), '--mute-audio', '--window-size=1600,1000', '--no-first-run', '--no-default-browser-check', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--force-color-profile=srgb'],
|
|
117
|
-
});
|
|
118
|
+
}); } catch (error) { await server.close(); throw error; }
|
|
118
119
|
const t0 = Date.now();
|
|
119
120
|
try {
|
|
120
121
|
const page = await browser.newPage();
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -1096,7 +1096,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1096
1096
|
if (!r.ended) return stillRunning(r.job, 'The deploy plan');
|
|
1097
1097
|
if (r.job.code !== 0) return fail(whyOf(r.job));
|
|
1098
1098
|
const p = r.result;
|
|
1099
|
-
return ok([`What going online does for ${p.studio}:`, ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), `Cost: ${p.cost}`, `Sign-in: ${p.login}`, `The directory stores: ${p.directory?.stores ?? ''}`, ...(p.parts ? partsPlanLines(p.parts) : []), ...(p.zone?.warning ? [`Warning (say this to the person as it is): ${p.zone.warning}`] : p.zone?.why ? [p.zone.why] : []), 'The card on screen shows this plan (nothing is made yet): tell the person its gist in two or three lines, then go on.'].join('\n'), { kind: 'deploy-plan', ...p });
|
|
1099
|
+
return ok([`What going online does for ${p.studio}:`, 'Rules rooms resume when their stored shape matches. Changes to fields, declaration order or maximum seats start a fresh match.', ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), `Cost: ${p.cost}`, `Sign-in: ${p.login}`, `The directory stores: ${p.directory?.stores ?? ''}`, ...(p.parts ? partsPlanLines(p.parts) : []), ...(p.zone?.warning ? [`Warning (say this to the person as it is): ${p.zone.warning}`] : p.zone?.why ? [p.zone.why] : []), 'The card on screen shows this plan (nothing is made yet): tell the person its gist in two or three lines, then go on.'].join('\n'), { kind: 'deploy-plan', ...p });
|
|
1100
1100
|
}
|
|
1101
1101
|
const studio = readStudio(root);
|
|
1102
1102
|
return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy', ...(a.ownRoute === true ? ['--own-route'] : [])] }] });
|
package/lib/mcp.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `homie-studio mcp` — the studio toolkit as a LOCAL MCP server (stdio), so the chat that shows Homie's cards also
|
|
3
3
|
* does the building: in the Claude desktop app through the Homie extension (.mcpb, desktop/ in this repository), in
|
|
4
|
-
* Claude Code, Codex or any MCP client. The remote Homie MCP (homie.rocks/mcp) coordinates (directory,
|
|
4
|
+
* Claude Code, Codex or any MCP client. The remote Homie MCP (homie.rocks/mcp) coordinates (directory, hub,
|
|
5
5
|
* progress relay for phones); this one does the work on this computer: studios, games, builds, checks with real
|
|
6
6
|
* browsers, deploys. Tools that overlap the remote ones keep their names and input shapes (lib/mcp-tools.mjs).
|
|
7
7
|
*
|
package/lib/office.mjs
CHANGED
|
@@ -32,9 +32,16 @@ export async function withKey(root, url, fn) {
|
|
|
32
32
|
if (!site) return { ok: false, why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://127.0.0.1:8787 for `npm run dev`' };
|
|
33
33
|
const local = loopback(site);
|
|
34
34
|
const k = mint(root, studio, 'office', 10 * 60_000, { local });
|
|
35
|
+
let expiresAt = Date.now() + 10 * 60_000;
|
|
35
36
|
if (!k.ok) return { ok: false, why: /no such table: stats_keys/.test(k.why) ? k.why : k.why };
|
|
36
37
|
try {
|
|
37
38
|
const call = async (path, body = null) => {
|
|
39
|
+
if (Date.now() >= expiresAt - 60_000) {
|
|
40
|
+
expiresAt = Date.now() + 10 * 60_000;
|
|
41
|
+
const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
42
|
+
const renewed = w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', `UPDATE stats_keys SET expires_at = ${expiresAt} WHERE hash = '${k.hash}';`]);
|
|
43
|
+
if (renewed.code !== 0) throw new Error('could not renew the office key; run again to resume statements');
|
|
44
|
+
}
|
|
38
45
|
const res = await fetch(`${site}${path}`, {
|
|
39
46
|
method: body ? 'POST' : 'GET',
|
|
40
47
|
headers: { authorization: `Bearer ${k.key}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },
|
package/lib/perf.mjs
CHANGED
|
@@ -477,8 +477,10 @@ async function oneRun({ puppeteer, chrome, root, url, game, device, k, seconds,
|
|
|
477
477
|
const win = await windowOf(h, a[i], b[i], seconds);
|
|
478
478
|
win.heap.afterGcMb = round((after[i].m.JSHeapUsedSize ?? NaN) / 1048576, 2);
|
|
479
479
|
win.heap.gcGrowthMbPerMin = round((((after[i].m.JSHeapUsedSize ?? NaN) - (a[i].m.JSHeapUsedSize ?? NaN)) / 1048576) / (win.seconds / 60), 2);
|
|
480
|
-
const shell = await T(h.page.evaluate(() => { const s = window.__shell; if (!s) return null; const t = s.stats ?? {}; return { role: t.role ?? null, seat: s.seat ?? null, peers: t.peers ?? null, snapHzOut: t.snapHzOut ?? null, snapHzIn: t.snapHzIn ?? null, inputHzOut: t.inputHzOut ?? null, inputHzIn: t.inputHzIn ?? null, lastSnapBytes: t.lastSnapBytes ?? null, maxSnapBytes: t.maxSnapBytes ?? null, bytesOutPerS: t.bytesOutPerS ?? null, bytesInPerS: t.bytesInPerS ?? null, rtt: t.rtt ?? null, interpDelay: t.interpDelay ?? null, starvedPct: t.starvedPct ?? null, reconnects: t.reconnects ?? null, link: s.link && typeof s.link === 'object' ? s.link.state ?? null : null, stale: s.stale ? s.stale.ver ?? true : null, reloaded: s.reloaded ?? 0 }; }), 5000);
|
|
481
|
-
|
|
480
|
+
const shell = await T(h.page.evaluate(() => { const s = window.__shell; if (!s) return null; const t = s.stats ?? {}; return { role: t.role ?? null, seat: s.seat ?? null, peers: t.peers ?? null, snapHzOut: t.snapHzOut ?? null, snapHzIn: t.snapHzIn ?? null, inputHzOut: t.inputHzOut ?? null, inputHzIn: t.inputHzIn ?? null, lastSnapBytes: t.lastSnapBytes ?? null, maxSnapBytes: t.maxSnapBytes ?? null, bytesOutPerS: t.bytesOutPerS ?? null, bytesInPerS: t.bytesInPerS ?? null, rtt: t.rtt ?? null, interpDelay: t.interpDelay ?? null, starvedPct: t.starvedPct ?? null, reconnects: t.reconnects ?? null, link: s.link && typeof s.link === 'object' ? s.link.state ?? null : null, stale: s.stale ? s.stale.ver ?? true : null, reloaded: s.reloaded ?? 0, hosted: s.facts?.hosted ?? null, ticks: s.facts?.ticks ?? null }; }), 5000);
|
|
481
|
+
// A room whose rules run on the server (NETPLAY.md section 29) has no browser host: both browsers are replicas,
|
|
482
|
+
// and the second is named apart so its numbers and pictures do not land on the first's.
|
|
483
|
+
const role = shell?.role === 'host' ? 'host' : run.browsers.some((b) => b.role === 'replica') ? 'replica-2' : 'replica';
|
|
482
484
|
// A browser that ended the window cut off from its room (the helper's link: reconnecting, alone, offline,
|
|
483
485
|
// closed) was not measured playing with the other one: the run is labelled and left out, never judged.
|
|
484
486
|
if (shell) shell.link = linkOf(shell.link);
|
|
@@ -492,7 +494,10 @@ async function oneRun({ puppeteer, chrome, root, url, game, device, k, seconds,
|
|
|
492
494
|
await T(h.page.screenshot({ path: shot, type: 'png', clip: { x: 0, y: 0, width: vp.width, height: vp.height, scale } }), 15_000);
|
|
493
495
|
run.browsers.push({ role, seat: shell?.seat ?? null, load: h.loadInfo, ...win, render: renderCostOf(cost[i]) ?? { samples: 0, drawCalls: null, triangles: null, note: 'not exposed by the game (exposePort extra: drawCalls, triangles): runtime scene cost was not measured' }, netplay: shell, errors: h.errors.slice(0, 10), screenshot: existsSync(shot) ? relative(out, shot) : null });
|
|
494
496
|
}
|
|
495
|
-
|
|
497
|
+
// The server is such a room's host, and that is a valid room: its tick figures come from the room's watch feed.
|
|
498
|
+
const hosted = run.browsers.find((x) => x.netplay?.hosted === 'server');
|
|
499
|
+
if (hosted) run.room = { hosted: 'server', ticks: hosted.netplay.ticks ?? null };
|
|
500
|
+
else if (!run.browsers.some((x) => x.role === 'host')) run.blocked ??= 'neither browser became the room\'s host';
|
|
496
501
|
if (profile) {
|
|
497
502
|
// A window of its own, still playing: a profiler slows what it watches, so the measured window above never has one.
|
|
498
503
|
const maps = mapsFor(root, game);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { resolve, sep } from 'node:path';
|
|
3
|
+
import { PLAYER_IMAGE, playerEnabled, playerImageRule } from '../worker/embed.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The picture a game's player card shows, measured by the build: the first of the wide landing still, the landing
|
|
7
|
+
* cover, the game's cover and the studio's social picture that X's reference says will render (worker/embed.mjs
|
|
8
|
+
* PLAYER_IMAGE: format, pixel count, size) and that this build can read whole. A refused picture is a warning
|
|
9
|
+
* naming the file and the rule, and the page keeps the picture card it had. A picture whose shape is not the
|
|
10
|
+
* player's still gets the card: the reference only says the two "should" match, so that is a warning too.
|
|
11
|
+
* Only built local files are measured; a remote picture can change without this build knowing.
|
|
12
|
+
*/
|
|
13
|
+
export async function playerImage(cat, game, dist, log) {
|
|
14
|
+
if (!playerEnabled(cat, game)) return null;
|
|
15
|
+
const sources = [...new Set([game.landing?.hero?.wideImage, game.landing?.cover,
|
|
16
|
+
game.cover ? `/games/${game.id}/${game.cover}` : null, cat.studio.theme?.social].filter(Boolean))];
|
|
17
|
+
const warn = (file, rule) => log(`warning: ${file}: player card needs ${rule}; keeping the picture card unless another image qualifies.`);
|
|
18
|
+
if (!sources.length) warn(`games/${game.id}/game.json`, 'a cover or a studio social picture in the built site (starters do not include one)');
|
|
19
|
+
const root = realpathSync(dist);
|
|
20
|
+
for (const src of sources) {
|
|
21
|
+
if (!src.startsWith('/') || src.startsWith('//')) { warn(src, 'a local image the build can measure'); continue; }
|
|
22
|
+
if (!/\.(jpe?g|png|webp|gif)$/i.test(src)) { warn(src, 'JPG, PNG, WEBP or GIF; SVG and AVIF are unsupported'); continue; }
|
|
23
|
+
try {
|
|
24
|
+
const file = realpathSync(resolve(dist, '.' + src));
|
|
25
|
+
if (!file.startsWith(root + sep)) throw new Error('outside built assets');
|
|
26
|
+
const bytes = statSync(file).size;
|
|
27
|
+
if (!(bytes < PLAYER_IMAGE.maxBytes)) { warn(src, 'an image under 5 MB'); continue; }
|
|
28
|
+
const sharp = (await import('sharp')).default;
|
|
29
|
+
const data = readFileSync(file);
|
|
30
|
+
const { width, height, format } = await sharp(data).metadata();
|
|
31
|
+
const rule = playerImageRule({ width, height, format, bytes });
|
|
32
|
+
if (rule === 'format') { warn(src, 'JPG, PNG, WEBP or GIF image contents'); continue; }
|
|
33
|
+
if (rule) { warn(src, `at least 68,600 pixels; found ${width}×${height}`); continue; }
|
|
34
|
+
// Decode as well as reading headers: a truncated/corrupt file is not a usable card picture.
|
|
35
|
+
await sharp(data).resize(1, 1).raw().toBuffer();
|
|
36
|
+
return { src, width, height, format, bytes };
|
|
37
|
+
} catch { warn(src, 'a readable, complete image in the built assets'); }
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|