@homie-rocks/studio 0.32.1 → 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.
Files changed (66) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/README.md +24 -6
  3. package/agents/agents.ts +57 -24
  4. package/bin/homie-studio.mjs +3 -3
  5. package/lib/build.mjs +64 -9
  6. package/lib/cf-usage.mjs +76 -0
  7. package/lib/cloudflare.mjs +25 -2
  8. package/lib/dev.mjs +45 -4
  9. package/lib/lab-check.mjs +3 -2
  10. package/lib/mcp-tools.mjs +1 -1
  11. package/lib/mcp.mjs +1 -1
  12. package/lib/office.mjs +7 -0
  13. package/lib/perf.mjs +8 -3
  14. package/lib/player-image.mjs +40 -0
  15. package/lib/rules-build.mjs +184 -0
  16. package/lib/rules-guard.mjs +770 -0
  17. package/lib/scaffold.mjs +43 -16
  18. package/lib/shop-catalog.mjs +10 -9
  19. package/lib/shop.mjs +32 -15
  20. package/lib/studio.mjs +21 -1
  21. package/lib/template-history.json +50 -0
  22. package/lib/upgrade.mjs +19 -15
  23. package/lib/version.mjs +2 -7
  24. package/netplay/NETPLAY.md +249 -4
  25. package/netplay/netplay.ts +55 -13
  26. package/package.json +23 -1
  27. package/rules/core.ts +1250 -0
  28. package/rules/guard.ts +419 -0
  29. package/rules/host.ts +707 -0
  30. package/rules/math.ts +266 -0
  31. package/rules/pack.ts +292 -0
  32. package/rules/rules.ts +544 -0
  33. package/rules/view.ts +447 -0
  34. package/shop/SELLING.md +14 -16
  35. package/shop/SHOP.md +119 -30
  36. package/shop/shop.ts +6 -8
  37. package/site/SITE.md +119 -2
  38. package/starters/coin-dash/game.json +11 -0
  39. package/starters/coin-dash/index.html +17 -0
  40. package/starters/coin-dash/map/main.json +16 -0
  41. package/starters/coin-dash/src/move.ts +27 -0
  42. package/starters/coin-dash/src/rules.ts +86 -0
  43. package/starters/coin-dash/src/view.ts +106 -0
  44. package/starters/coin-dash/tunables.json +5 -0
  45. package/starters/ember-vale/index.html +11 -0
  46. package/worker/brain.mjs +8 -7
  47. package/worker/chat-page.mjs +1 -1
  48. package/worker/embed.mjs +135 -0
  49. package/worker/hosted.mjs +56 -0
  50. package/worker/index.mjs +413 -22
  51. package/worker/office-page.mjs +1 -0
  52. package/worker/office.mjs +24 -7
  53. package/worker/pages.mjs +189 -43
  54. package/worker/players.mjs +10 -6
  55. package/worker/referrals.mjs +192 -57
  56. package/worker/room-store.mjs +54 -0
  57. package/worker/room.mjs +294 -119
  58. package/worker/saves-shell.mjs +7 -1
  59. package/worker/schema.mjs +4 -3
  60. package/worker/shop-page.mjs +103 -37
  61. package/worker/shop-rules.mjs +156 -117
  62. package/worker/shop-store.mjs +70 -9
  63. package/worker/shop.mjs +275 -143
  64. package/worker/site.mjs +26 -5
  65. package/worker/stripe.mjs +9 -6
  66. 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
- return { mode, warnings, metafile, bundle: bundled, chunks };
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
- roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
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 ?? [],
@@ -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
+ }
@@ -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
- const timer = setInterval(() => { look().catch(() => {}); }, watchMs);
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
- const browser = await puppeteer.launch({
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, grants, hub,
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
- const role = shell?.role === 'host' ? 'host' : 'replica';
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
- if (!run.browsers.some((x) => x.role === 'host')) run.blocked ??= 'neither browser became the room\'s host';
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
+ }