@homie-rocks/studio 0.30.1 → 0.31.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 (92) hide show
  1. package/CHANGELOG.md +111 -1
  2. package/README.md +361 -22
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +142 -123
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +127 -8
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/demo.mjs +16 -4
  23. package/lib/deploy-state.mjs +166 -0
  24. package/lib/dev.mjs +430 -0
  25. package/lib/directory.mjs +116 -5
  26. package/lib/doctor.mjs +143 -16
  27. package/lib/feedback.mjs +3 -2
  28. package/lib/handoff.mjs +2 -6
  29. package/lib/library.mjs +1 -1
  30. package/lib/mcp-tools.mjs +36 -35
  31. package/lib/mcp.mjs +1 -1
  32. package/lib/net.mjs +81 -2
  33. package/lib/node-version.mjs +40 -0
  34. package/lib/office.mjs +6 -6
  35. package/lib/optimise.mjs +39 -4
  36. package/lib/parts-build.mjs +169 -0
  37. package/lib/parts-cli.mjs +104 -0
  38. package/lib/parts-store.mjs +342 -0
  39. package/lib/parts-tools.mjs +92 -0
  40. package/lib/parts.mjs +693 -0
  41. package/lib/perf.mjs +131 -31
  42. package/lib/port-check.mjs +40 -6
  43. package/lib/preview.mjs +77 -0
  44. package/lib/routes.mjs +132 -0
  45. package/lib/scaffold.mjs +88 -22
  46. package/lib/shoot.mjs +307 -0
  47. package/lib/site.mjs +1 -2
  48. package/lib/stage.mjs +118 -0
  49. package/lib/studio.mjs +0 -66
  50. package/lib/style-board.mjs +49 -6
  51. package/lib/template-history.json +101 -0
  52. package/lib/trailer.mjs +47 -0
  53. package/lib/typecheck.mjs +107 -0
  54. package/mcp/ui/lineup.js +3 -3
  55. package/mcp/ui/rights.js +1 -3
  56. package/netplay/NETPLAY.md +424 -16
  57. package/netplay/netplay.ts +756 -40
  58. package/package.json +10 -1
  59. package/parts/PARTS.md +163 -0
  60. package/port/index.ts +8 -2
  61. package/port/personas.ts +275 -0
  62. package/port/powerups.ts +357 -0
  63. package/port/probe.ts +38 -2
  64. package/port/room.ts +103 -19
  65. package/site/SITE.md +102 -20
  66. package/starters/ember-vale/index.html +1 -1
  67. package/starters/ember-vale/src/main.ts +3 -1
  68. package/starters/gem-rush/index.html +1 -1
  69. package/starters/gem-rush/src/main.ts +13 -6
  70. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  71. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  72. package/starters/gem-rush-3d/index.html +1 -1
  73. package/starters/gem-rush-3d/src/main.ts +15 -5
  74. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  75. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  76. package/starters/hero-rush-3d/index.html +1 -1
  77. package/starters/hero-rush-3d/src/main.ts +15 -5
  78. package/worker/arrival.mjs +4 -1
  79. package/worker/discover.mjs +11 -25
  80. package/worker/index.mjs +142 -62
  81. package/worker/license.mjs +45 -41
  82. package/worker/office-page.mjs +0 -4
  83. package/worker/office-schema.mjs +6 -0
  84. package/worker/office.mjs +19 -23
  85. package/worker/pages.mjs +141 -23
  86. package/worker/parts.mjs +234 -0
  87. package/worker/room.mjs +157 -14
  88. package/worker/schema.mjs +6 -8
  89. package/worker/seats.mjs +71 -0
  90. package/worker/site.mjs +51 -53
  91. package/worker/version.mjs +1 -1
  92. package/lib/remix-assets.mjs +0 -84
@@ -136,7 +136,10 @@ export const ARRIVAL_JS = String.raw`(function () {
136
136
  var stepEl = el.querySelector('[data-arrive-step]');
137
137
  var roomEl = el.querySelector('[data-arrive-room]');
138
138
  var bar = el.querySelector('[data-arrive-bar]');
139
- var a = { phase: 'room', step: stepEl.textContent, p: 0.06, lookMs: null, liftedMs: null, by: null, mode: null };
139
+ // mode: who was to say when the game is playable ('auto': the helper, 'game': the game, 'seat': an older helper);
140
+ // explicitMs: when the game itself said so (the page's clock; null: it never did); lateMs: how long after an
141
+ // automatic arrival had already lifted the card that was (null: it was not late). The shell's script fills both.
142
+ var a = { phase: 'room', step: stepEl.textContent, p: 0.06, lookMs: null, liftedMs: null, by: null, mode: null, explicitMs: null, lateMs: null };
140
143
  api.state = a;
141
144
  // The card's first frame on the screen: what perf reads as the time to the first meaningful frame.
142
145
  frame(function () { frame(function () { a.lookMs = Math.round(now()); mark('homie:look'); }); });
@@ -8,17 +8,17 @@
8
8
  * /sitemap.xml every public page (Home, Games and each landing, Music, Videos, Rooms, Posts, the studio's own
9
9
  * pages), with lastmod only where a real date says when it changed
10
10
  * /llms.txt the llms.txt convention (an H1, a > summary, then ## sections of links): what the studio is, each
11
- * public game with its pitch, players, Play, Watch, the big screen and whether and how it can be
12
- * remixed (the licence, the source, the words to say to Claude Code or Codex with Homie), songs,
11
+ * public game with its pitch, players, Play, Watch, the big screen, the licence it names and the
12
+ * game it was based on (when it has either), songs,
13
13
  * videos, posts with their feeds, the studio's own pages, and a link to homie.rocks's llms.txt
14
14
  * /llms-full.txt the same, with each game's whole description, how to play, controls and credits, and every
15
15
  * post's text
16
16
  *
17
- * Only public games are ever named (a private or invite-only game is in none of them), and a game is offered for
18
- * remixing only when its owner's remix switch is on, its source is shared and its licence allows it. A file of the
17
+ * Only public games are ever named (a private or invite-only game is in none of them), and no game is offered to
18
+ * be taken whole (the pieces a studio shares are its parts, at /parts/, worker/parts.mjs). A file of the
19
19
  * studio's own in site/public (robots.txt, sitemap.xml, llms.txt, llms-full.txt) wins over the made one.
20
20
  */
21
- import { licenseOf, LICENSE_KINDS, remixRow } from './license.mjs';
21
+ import { basedOnRow, licenseLabel } from './license.mjs';
22
22
 
23
23
  export const DISCOVERY_FILES = ['/robots.txt', '/sitemap.xml', '/llms.txt', '/llms-full.txt'];
24
24
 
@@ -111,15 +111,6 @@ export function sitemapXml(cat, origin, opts = {}) {
111
111
 
112
112
  /* ------------------------------------------------------------------ llms.txt */
113
113
 
114
- const HOMIE_INSTALL = 'in Claude Code, `/plugin marketplace add homie-rocks/homie` and then `/plugin install homie@homie` (Codex takes the same marketplace)';
115
-
116
- function licenceWords(g) {
117
- const l = licenseOf(g.license);
118
- const name = `${LICENSE_KINDS[l.kind]}${l.spdx ? `, ${l.spdx}` : ''}`;
119
- if (l.kind === 'remix-freely') return `${name}: a credit is welcome, not asked for`;
120
- return `${name}: a remix says "Remix of ${one(g.name, 80)} by ${one(g.studio, 80)}" with a link back`;
121
- }
122
-
123
114
  function summaryOf(cat, origin) {
124
115
  const name = studioName(cat);
125
116
  const tag = one(cat.studio?.tagline, 140);
@@ -137,8 +128,8 @@ function summaryOf(cat, origin) {
137
128
  ];
138
129
  }
139
130
 
140
- /** /llms.txt. `remixable(g)`: the owner's remix switch is on, the source is shared, and the licence allows it. */
141
- export function llmsTxt(cat, origin, { remixable = () => false, directory = 'https://homie.rocks', full = false, posts = null, all = null } = {}) {
131
+ /** /llms.txt (and, with `full`, /llms-full.txt). */
132
+ export function llmsTxt(cat, origin, { directory = 'https://homie.rocks', full = false, posts = null, all = null } = {}) {
142
133
  const name = studioName(cat);
143
134
  const games = (cat.games ?? []).map((g) => ({ ...g, studio: name }));
144
135
  const songs = cat.songs ?? [];
@@ -154,9 +145,10 @@ export function llmsTxt(cat, origin, { remixable = () => false, directory = 'htt
154
145
  const L = g.landing ?? {};
155
146
  const doors = [`Play: ${origin}/${g.id}/play`, ...(watchable(g) ? [`Watch a live room: ${origin}/${g.id}/watch`] : []), ...(tv(g) ? [`On a TV: ${origin}/${g.id}/tv`] : [])];
156
147
  const facts = [players(g), rounds(g.roundSeconds), ...(Array.isArray(g.genre) ? g.genre : g.genre ? [g.genre] : [])].filter(Boolean).join(', ');
157
- const lineage = remixRow(g.remixOf);
158
- const open = remixable(g);
159
- out.push(`- [${label(g.name)}](${origin}/${g.id}/): ${one(L.pitch || g.blurb, 240)}${/[.!?]$/.test(one(L.pitch || g.blurb)) ? '' : '.'} ${facts}.${lineage ? ` A remix of ${one(lineage.name, 80)}${lineage.studio ? ` by ${one(lineage.studio, 80)}` : ''}.` : ''} ${doors.join('. ')}. ${open ? `Open to remix (${LICENSE_KINDS[licenseOf(g.license).kind]}${licenseOf(g.license).spdx ? `, ${licenseOf(g.license).spdx}` : ''}): see Remix below.` : 'Not open to remix.'}`);
148
+ // The credit a game owes the one it was made from, and the licence it names for itself: facts, when it has them.
149
+ const lineage = basedOnRow(g.basedOn ?? g.remixOf);
150
+ const licence = licenseLabel(g.license);
151
+ out.push(`- [${label(g.name)}](${origin}/${g.id}/): ${one(L.pitch || g.blurb, 240)}${/[.!?]$/.test(one(L.pitch || g.blurb)) ? '' : '.'} ${facts}.${lineage ? ` Based on ${one(lineage.name, 80)}${lineage.studio ? ` by ${one(lineage.studio, 80)}` : ''}.` : ''} ${doors.join('. ')}.${licence ? ` Licence: ${licence}.` : ''}`);
160
152
  if (full) {
161
153
  const C = L.credits ?? {};
162
154
  const lines = [];
@@ -173,12 +165,6 @@ export function llmsTxt(cat, origin, { remixable = () => false, directory = 'htt
173
165
  for (const l of lines) out.push(` ${l}`);
174
166
  }
175
167
  }
176
- const open = games.filter((g) => remixable(g));
177
- if (open.length) {
178
- out.push('', '## Remix', '');
179
- out.push(`Homie is a plugin for Claude Code and Codex that makes multiplayer games in a studio of your own, on your own free Cloudflare account. To remix one of these games, ${HOMIE_INSTALL}, then say "Remix <game> from <its source address> into a game of my own in my Homie studio". The plugin's \`game_remix\` tool (or \`npx --no-install homie-studio game remix <source address> --id <new-id>\` in a studio's folder) copies it in and writes the credit its licence asks for.`, '');
180
- for (const g of open) out.push(`- [${label(g.name)} source](${origin}/games/${g.id}/source.json): ${licenceWords(g)}. Say: "Remix ${one(g.name, 80)} from ${origin}/games/${g.id}/source.json into a game of my own in my Homie studio".`);
181
- }
182
168
  }
183
169
  if (songs.length) {
184
170
  out.push('', '## Music', '');
package/worker/index.mjs CHANGED
@@ -39,7 +39,7 @@
39
39
  * /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt for search engines and AI agents, made from the public
40
40
  * catalogue (worker/discover.mjs); every generated page also carries schema.org JSON-LD
41
41
  * (worker/schema.mjs)
42
- * /games/<id>/source.json a public game's source for remixing, with its credit and licence (worker/license.mjs)
42
+ * /games/<id>/source.json 410, for a client that still asks: remix was retired, no game's source is served whole
43
43
  * /media/<key> a loose file in the studio's R2 (`media put`), with byte ranges
44
44
  * /music/<slug>/<file>, /videos/<slug>/<file> a song's or video's files: from the site's own files, or, for a
45
45
  * file `media move` put in the studio's R2 (the catalogue names its key), from R2 at the
@@ -47,7 +47,7 @@
47
47
  * /api/stats the studio's numbers, for its owner only (a read key, or the owner's page session)
48
48
  * /_studio/stats the owner's private stats page (one-time sign-in link from `homie-studio stats link`)
49
49
  * /_studio/office the owner's back office: live rooms and who is in them, kick, mute, announce, close,
50
- * each game's launch state (private, invite-only beta, public), remix switch, room size
50
+ * each game's launch state (private, invite-only beta, public), room size
51
51
  * and invites (worker/office.mjs); /_studio/api/* is the same as JSON
52
52
  * /<game>/invite an invite code spent for this browser's pass to an invite-only game
53
53
  * /_homie/site.js the pages' one script
@@ -82,7 +82,7 @@
82
82
  * on the Table's alarm: Workers AI through the optional AI binding (deploy adds
83
83
  * it when a server uses it), or the owner's own key (secret HOMIE_BRAIN_KEY).
84
84
  */
85
- import { NetRoom, WATCH_POLICIES } from './room.mjs';
85
+ import { NetRoom, WATCH_POLICIES, departure, errorLine, versionOf } from './room.mjs';
86
86
  import { ROOM_ID, badRoomPage, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
87
87
  import {
88
88
  PUBLIC_SERVER, SERVER_ID, homeOf, memberCounts, memberOf, noteMember, policyOf, pooledRoom, roomCode, roomServer, serverAccess, serverPassOf, serverView, serversOf,
@@ -96,7 +96,7 @@ import { forgetLines, historyOf, keepLines, keptLine } from './lounge-store.mjs'
96
96
  import { isLoungePath, loungeRoutes } from './lounge.mjs';
97
97
  import { doorPage, serverPage, serversPage } from './site.mjs';
98
98
  import { isLocalOrigin, qrSvg } from './qr.mjs';
99
- import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
99
+ import { SEAT_MAX, paramsFrom, perAddress, seatsOf } from './seats.mjs';
100
100
  import {
101
101
  SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homeLd, homePage, jsonFeed, landingLd, mediaArt, mediaIndexPage,
102
102
  notFoundPage, postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage, watchOf,
@@ -105,7 +105,7 @@ import { cookieValues, count, countVisit, counter, isQa, onlyOf, ownerAllowed, p
105
105
  import { ownerRoutes } from './stats-page.mjs';
106
106
  import { playerRoutes, players as playerAccounts } from './players.mjs';
107
107
  import {
108
- accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, remixOf, sameOrigin, seatsFor, settingsOf, ticketAllows,
108
+ accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, sameOrigin, seatsFor, settingsOf, ticketAllows,
109
109
  ticketFor, ticketSub, usePlayers, verifyControl,
110
110
  } from './office.mjs';
111
111
 
@@ -117,7 +117,9 @@ import { readiness, roomBadge, shellShop, shopOf, shopRoutes } from './shop.mjs'
117
117
  import { DISCOVERY_FILES, discoveryResponse, llmsTxt, robotsTxt, sitemapXml } from './discover.mjs';
118
118
  import { ldScript, studioNode } from './schema.mjs';
119
119
  import { arrivalCookie, manifestReferrals } from './referrals.mjs';
120
- import { licenseOf, remixAllowed, remixRow } from './license.mjs';
120
+ import { licenseOf } from './license.mjs';
121
+ // GAME PARTS (parts/PARTS.md section 4): three call-outs below, everything else is worker/parts.mjs.
122
+ import { PART_FILES, isPartsPath, partsRoutes, withPartsBand } from './parts.mjs';
121
123
 
122
124
  export { SEAT_MAX } from './seats.mjs';
123
125
  /** For a studio whose Worker has player accounts: hand their server API to the back office once (worker/office.mjs). */
@@ -462,10 +464,19 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
462
464
  // in the socket's address, which every netplay helper opens as it is given.
463
465
  const b = BROWSER_KEY.test(url.searchParams.get('b') ?? '') ? url.searchParams.get('b') : null;
464
466
  const t = TICKET_TEXT.test(url.searchParams.get('t') ?? '') ? url.searchParams.get('t') : null;
467
+ // The game's revision (game.json `netplay.version`, NETPLAY.md section 23) as this page is served: it rides in the
468
+ // socket's address (`gv`), so a build whose helper predates revisions is still known for the build it is.
469
+ const ver = versionOf(meta?.netplay?.version);
470
+ // The play page's switches for the game (section 24): the allowed names only, checked again here.
471
+ const params = paramsFrom(url.searchParams, meta);
465
472
  const cfg = {
466
473
  v: 1,
467
- url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}${watching ? '&w=1' : ''}`,
474
+ url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}${watching ? '&w=1' : ''}${ver ? `&gv=${encodeURIComponent(ver)}` : ''}`,
468
475
  room,
476
+ ...(ver ? { ver } : {}),
477
+ ...(Object.keys(params).length ? { params } : {}),
478
+ // The page around this frame keeps net.prefs (the play page says so; a watch page or an older page does not).
479
+ ...(url.searchParams.get('pf') === '1' ? { prefs: true } : {}),
469
480
  ...(url.searchParams.get('k') ? { token: url.searchParams.get('k').slice(0, 128) } : {}),
470
481
  ...(agent ? { name: agent.name } : url.searchParams.get('name') ? { name: url.searchParams.get('name').slice(0, 24) } : {}),
471
482
  // An AI that runs the game itself (hands `self`): its hello says so; the relay knows it from its ticket anyway.
@@ -553,7 +564,8 @@ function finish(res, path) {
553
564
  if (!/text\/html/i.test(res.headers.get('content-type') ?? '')) return res;
554
565
  const cache = res.headers.get('cache-control') ?? '';
555
566
  const addNt = !/no-transform/i.test(cache);
556
- const addXfo = !res.headers.has('x-frame-options') && !GAME_FILES.test(path);
567
+ // GAME PARTS: a shared part's preview page is framed by the part's page and the hub, sandboxed (worker/parts.mjs).
568
+ const addXfo = !res.headers.has('x-frame-options') && !GAME_FILES.test(path) && !PART_FILES.test(path);
557
569
  if (!addNt && !addXfo) return res;
558
570
  const headers = new Headers(res.headers);
559
571
  if (addNt) headers.set('cache-control', cache ? `${cache}, no-transform` : 'no-transform');
@@ -623,6 +635,11 @@ async function route(request, env, ctx) {
623
635
  return json({ ok: false, error: 'no-stats', message: `The counters are not in this studio's D1 yet: \`npm run deploy\` applies migration 0002_studio_stats.sql. (${String(error?.message ?? error).slice(0, 120)})` }, 503);
624
636
  }
625
637
  }
638
+ // GAME PARTS: /.well-known/homie-parts.json, /parts/<id>/<version>/… and the studio's own parts pages.
639
+ if (isPartsPath(path)) {
640
+ const shared = await partsRoutes(request, env, url, { catalogueOf: getCat });
641
+ if (shared) return shared;
642
+ }
626
643
  if (path === '/.well-known/homie-studio.json') {
627
644
  const cat = await getCat();
628
645
  const sharing = Boolean(cat.studio?.stats?.share);
@@ -645,17 +662,17 @@ async function route(request, env, ctx) {
645
662
  games: await Promise.all((cat.games ?? []).map(async (g) => {
646
663
  // The card picture is the landing's hero still (what the landing leads with), else the game's cover.
647
664
  const cover = gameCover(g);
648
- // Open to remix: the owner's switch is on and the licence the owner picked allows it (worker/license.mjs).
665
+ // The licence the game names for itself (an SPDX id), when it names one (worker/license.mjs). Nothing here
666
+ // offers the game to be taken whole: `remix`, `source` and `remixOf` left this manifest with remix.
649
667
  const license = licenseOf(g.license);
650
- const remix = remixOf(g, await settingsOf(env)) && remixAllowed(license);
651
- const lineage = remixRow(g.remixOf);
652
668
  return {
653
669
  id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
654
670
  page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: cover ? (cover.startsWith('/') ? `${url.origin}${cover}` : cover) : null,
655
- // The owner's remix switch (0.13.0): whether the source is open for other studios to remix, and where.
656
- remix, ...(remix ? { source: `${url.origin}/games/${g.id}/source.json` } : {}), license,
657
- // A remix names what it is a remix of (its game.json `remixOf`): "Remix of <name> by <studio>", linked.
658
- ...(lineage ? { remixOf: lineage } : {}),
671
+ ...(license ? { license } : {}),
672
+ // Which build of the game is live: its digest (what `homie-studio build` printed for it, and what
673
+ // site/dist/_site/build.json says) and the bundle its page loads. "Is the live game the one I built?" is
674
+ // this against that, with no file to fetch and no edge cache to wait out.
675
+ ...(g.built?.hash ? { build: { hash: g.built.hash, ...(g.built.bundle ? { bundle: `${url.origin}/games/${g.id}/${g.built.bundle}` } : {}) } } : {}),
659
676
  // Shared only with `stats.share`: this game's own Play presses and rounds with people, this week.
660
677
  ...(byGame ? { played: byGame[g.id] ?? { days: 7, plays: 0, rounds: 0 } } : {}),
661
678
  };
@@ -700,28 +717,12 @@ async function route(request, env, ctx) {
700
717
  return path.endsWith('.xml') ? atomFeed(cat, posts, url.origin) : jsonFeed(cat, posts, url.origin);
701
718
  }
702
719
 
703
- // A game's remix source is served while the game is public and its owner has not withdrawn it (the remix switch),
704
- // with who made it as this site says it (the studio's name, the game's name and page here) and the owner's licence
705
- // (worker/license.mjs): the remix flow credits the first and refuses a game whose licence says no remix.
706
- const src = /^\/games\/([a-z0-9][a-z0-9-]{0,39})\/source\.json$/.exec(path);
707
- if (src) {
708
- const all = await getAll();
709
- const meta = (all.games ?? []).find((g) => g.id === src[1]);
710
- if (meta) {
711
- const settings = await settingsOf(env, { fresh: true });
712
- if (launchOf(meta, settings, env) !== 'public' || !remixOf(meta, settings)) return json({ ok: false, error: 'not-shared', message: 'This game\'s source is not shared for remixing.' }, 404);
713
- if (request.method === 'GET') {
714
- const res = await env.ASSETS.fetch(new Request(`${url.origin}${path}`));
715
- let body = null;
716
- if (res.ok) { try { body = await res.json(); } catch { body = null; } }
717
- if (body?.kind === 'homie-game-source' && body.files && typeof body.files === 'object') {
718
- const credit = { studio: all.studio?.name ?? body.credit?.studio ?? null, game: meta.name ?? body.credit?.game ?? meta.id, page: `${url.origin}/${meta.id}/` };
719
- const { files, ...head } = body;
720
- return json({ ...head, credit, license: licenseOf(body.license ?? meta.license), files }, 200, { 'access-control-allow-origin': '*' });
721
- }
722
- }
723
- }
724
- }
720
+ // REMIX WAS RETIRED: no game's source is served whole. An older toolkit's `game remix`, the directory's copy of a
721
+ // studio, or a link somebody kept may still ask for one, so the address answers 410 (gone, on purpose, for good)
722
+ // with where to go instead: /parts/, the pieces of games this studio chose to share. The same answer for every
723
+ // id, whether or not such a game exists, and before the site's files are looked at: a source.json an older build
724
+ // left in the deployed folder is never reachable.
725
+ if (/^\/games\/[a-z0-9][a-z0-9-]{0,39}\/source\.json$/.test(path)) return json({ retired: 'remix', see: '/parts/' }, 410, { 'access-control-allow-origin': '*' });
725
726
 
726
727
  // Search engines and AI agents (0.27.0, worker/discover.mjs): robots.txt, sitemap.xml, llms.txt and llms-full.txt,
727
728
  // made from the public catalogue (a private or invite-only game is in none of them); a file of the studio's own in
@@ -734,10 +735,8 @@ async function route(request, env, ctx) {
734
735
  if (path === '/robots.txt') return discoveryResponse(robotsTxt(cat, url.origin, { preview: env.HOMIE_PREVIEW === '1' }), 'text/plain', { method });
735
736
  const all = await getAll();
736
737
  if (path === '/sitemap.xml') return discoveryResponse(sitemapXml(cat, url.origin, { all }), 'application/xml', { method });
737
- const settings = await settingsOf(env);
738
- const remixable = (g) => g.landing?.source !== false && remixOf(g, settings) && remixAllowed(g.license);
739
738
  const full = path === '/llms-full.txt';
740
- return discoveryResponse(llmsTxt(cat, url.origin, { remixable, directory: directoryOf(cat), full, posts: full ? await postsOf(env, url.origin) : null, all }), 'text/plain', { method });
739
+ return discoveryResponse(llmsTxt(cat, url.origin, { directory: directoryOf(cat), full, posts: full ? await postsOf(env, url.origin) : null, all }), 'text/plain', { method });
741
740
  }
742
741
 
743
742
  // A page the studio made itself (site/pages) wins at its address.
@@ -871,7 +870,8 @@ async function route(request, env, ctx) {
871
870
  const { rooms, live } = await roomsOf(env, [meta], { servers: { [game]: servers } });
872
871
  const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
873
872
  const band = servers.some((x) => x.id !== 'public' && x.listed && x.state === 'open') ? await serverLive(env, meta, servers, url.origin, lobby) : null;
874
- return gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week, remix: launch === 'public' && remixOf(meta, settings), servers: band, listed: launch === 'public', shop: launch === 'public' ? await sellingShop(env, cat) : null });
873
+ // GAME PARTS: the landing's "Parts from this game" band (shared parts only).
874
+ return withPartsBand(gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week, servers: band, listed: launch === 'public', shop: launch === 'public' ? await sellingShop(env, cat) : null }), env, url, meta);
875
875
  }
876
876
  if (sub === 'live') {
877
877
  if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
@@ -953,7 +953,7 @@ async function route(request, env, ctx) {
953
953
  // The big screen picks its room now, so the QR it shows puts every phone in that same room.
954
954
  let room = asked;
955
955
  if (!room) {
956
- try { room = (await (await lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
956
+ try { room = (await (await lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${versionOf(meta?.netplay?.version) ? `&ver=${encodeURIComponent(versionOf(meta.netplay.version))}` : ''}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
957
957
  }
958
958
  const joinUrl = `${url.origin}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
959
959
  // Local development: no phone can open this computer's own address, so no code for it; the card says to deploy.
@@ -993,7 +993,8 @@ async function route(request, env, ctx) {
993
993
  const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
994
994
  const pools = servers.filter((x) => x.state === 'open' && x.door === 'open').map((x) => x.id).join(',');
995
995
  let best = null;
996
- try { best = await (await lobby().fetch(`https://lobby/busiest?pools=${encodeURIComponent(pools)}${not ? `&not=${encodeURIComponent(not)}` : ''}`)).json(); } catch { best = null; }
996
+ const wver = versionOf(meta?.netplay?.version);
997
+ try { best = await (await lobby().fetch(`https://lobby/busiest?pools=${encodeURIComponent(pools)}${not ? `&not=${encodeURIComponent(not)}` : ''}${wver ? `&ver=${encodeURIComponent(wver)}` : ''}`)).json(); } catch { best = null; }
997
998
  return json({ ok: true, game, room: best?.room ?? null, players: best?.players ?? 0, max }, 200, { 'cache-control': 'no-store' });
998
999
  }
999
1000
  if (sub === 'api/lobby') {
@@ -1008,7 +1009,9 @@ async function route(request, env, ctx) {
1008
1009
  // A browser held out of a room asks for any other (`not`), so the Lobby never sends it back there.
1009
1010
  const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
1010
1011
  const pol = policyFor(srv);
1011
- return lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${not ? `&not=${encodeURIComponent(not)}` : ''}`, { method: 'POST' });
1012
+ // Strangers on different builds of the game never meet (section 23): the Lobby matches within the live build.
1013
+ const ver = versionOf(meta?.netplay?.version);
1014
+ return lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${not ? `&not=${encodeURIComponent(not)}` : ''}${ver ? `&ver=${encodeURIComponent(ver)}` : ''}`, { method: 'POST' });
1012
1015
  }
1013
1016
  if (sub === 'api/agent') {
1014
1017
  // An AI's seat (worker/agents.mjs): its pass, the server's policy, a room with people in it, a ticket.
@@ -1020,7 +1023,7 @@ async function route(request, env, ctx) {
1020
1023
  join: async (srv, pol) => {
1021
1024
  try {
1022
1025
  const reserve = pol.aiSeats + pol.guides;
1023
- const r = await (await lobby().fetch(`https://lobby/join?max=${max}&server=${srv.id}&agent=1&ai=${reserve}`, { method: 'POST' })).json();
1026
+ const r = await (await lobby().fetch(`https://lobby/join?max=${max}&server=${srv.id}&agent=1&ai=${reserve}${versionOf(meta?.netplay?.version) ? `&ver=${encodeURIComponent(versionOf(meta.netplay.version))}` : ''}`, { method: 'POST' })).json();
1024
1027
  return r.room ?? null;
1025
1028
  } catch { return null; }
1026
1029
  },
@@ -1089,8 +1092,21 @@ async function route(request, env, ctx) {
1089
1092
  const pid = !ag && !w && sub === '__net' ? parts.find((x) => x.startsWith('p-'))?.slice(2) : null;
1090
1093
  const badge = pid ? await roomBadge(env, cat, pid, { kids: pol.kids }) : null;
1091
1094
  const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
1092
- const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}`;
1093
- return stub.fetch(new Request(target, request));
1095
+ // Revision 9 (NETPLAY.md sections 22 and 23): the build that is live now (`cur`, always said, empty when the game
1096
+ // names none), the build this socket's page was served with (`gv`, from the socket's own address), and the game's
1097
+ // own stall time.
1098
+ const cur = versionOf(meta?.netplay?.version) ?? '';
1099
+ const gv = versionOf(url.searchParams.get('gv')) ?? '';
1100
+ const stall = Number(meta?.netplay?.stallMs) || 0;
1101
+ const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}&cur=${encodeURIComponent(cur)}${gv ? `&gv=${encodeURIComponent(gv)}` : ''}${stall ? `&stall=${stall}` : ''}`;
1102
+ // The room's own failure to answer is told apart from the browser going away while it was connecting (a closed
1103
+ // tab, a reload racing its own socket): the second is a departure, said in one line with its room, never an
1104
+ // uncaught error with nothing on it.
1105
+ try { return await stub.fetch(new Request(target, request)); } catch (error) {
1106
+ const gone = departure(error);
1107
+ try { console[gone ? 'log' : 'error'](JSON.stringify({ at: new Date().toISOString(), ev: gone ? 'socket-gone' : 'room-unreachable', game, room, sub, error: errorLine(error) })); } catch { /* no console */ }
1108
+ return new Response(gone ? 'gone' : 'the room did not answer; try again', { status: gone ? 499 : 503 });
1109
+ }
1094
1110
  }
1095
1111
  // The same knock, relative to the game's own page: the same answer (see notAHomie).
1096
1112
  if (sub === '__homie' || sub.startsWith('__homie/')) return notAHomie(request);
@@ -1203,6 +1219,10 @@ export class Table {
1203
1219
  save: (o) => { storage.put('net', o).catch(() => {}); },
1204
1220
  clear: () => { storage.delete('net').catch(() => {}); },
1205
1221
  },
1222
+ // The room's lifecycle, one line each with its time and room (worker/room.mjs `log`): a hello, a leave, an
1223
+ // election, a build change, and `failed` (a room operation that threw). A departure and a failure seconds apart
1224
+ // are told apart by these. HOMIE_ROOM_LOG=0 keeps only the failures.
1225
+ log: (line) => this.say(line),
1206
1226
  });
1207
1227
  if (this.saved) this.room.restore(this.saved);
1208
1228
  if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
@@ -1218,6 +1238,27 @@ export class Table {
1218
1238
  return this.room;
1219
1239
  }
1220
1240
 
1241
+ /**
1242
+ * One log line of this room: `{ at, ev, game, room, … }`. Never a seat token, a ticket, a browser key or an address;
1243
+ * a failure goes to the error stream, everything else to the log (and not at all with HOMIE_ROOM_LOG=0).
1244
+ */
1245
+ say(line) {
1246
+ const bad = line?.ev === 'failed' || line?.ev === 'socket-error' || line?.ev === 'tick-failed' || line?.ev === 'persist-failed';
1247
+ if (!bad && this.env.HOMIE_ROOM_LOG === '0') return;
1248
+ try { console[bad ? 'error' : 'log'](JSON.stringify({ at: new Date().toISOString(), ...line, game: this.game ?? null, room: line?.room ?? this.code ?? null })); } catch { /* no console */ }
1249
+ }
1250
+
1251
+ /**
1252
+ * A socket's `error` event. The runtime raises one when the other end simply went away ("Network connection lost":
1253
+ * a closed tab, a phone that slept, a browser the test harness shut): that is a departure, and the room handles it
1254
+ * as one. Anything else is a failed socket, logged as such. Both lines carry the room, the client and its role.
1255
+ */
1256
+ socketError(kind, event, facts = {}) {
1257
+ const error = event?.error ?? event?.message ?? event;
1258
+ const gone = departure(error) || !error || typeof error === 'object' && !error.message;
1259
+ this.say({ ev: gone ? 'socket-gone' : 'socket-error', kind, ...facts, error: errorLine(error) });
1260
+ }
1261
+
1221
1262
  /** Kept chat's writes, in order, each finished before the next (D1 calls from one room never pass each other). */
1222
1263
  keptQueue(fn) {
1223
1264
  if (!this.env.DB) return;
@@ -1458,6 +1499,9 @@ export class Table {
1458
1499
  }
1459
1500
  // The owner's room size: a room already open takes it from the next visitor on.
1460
1501
  if (room.seatCap !== max) room.setSeats(max, perAddress(max));
1502
+ // Revision 9: the game's own stall time and the build that is live now, the Worker's word with every socket.
1503
+ const said = url.searchParams.has('cur');
1504
+ if (said) { room.setStall(url.searchParams.get('stall')); room.setCurrent(url.searchParams.get('cur') || null); }
1461
1505
  // The room's policy (section 17), composed by the Worker for every socket: a newer one than the room's applies.
1462
1506
  const pol = decodeFacts(url.searchParams.get('pol'));
1463
1507
  // The room's chat rules ride beside it (they may be longer than a policy): section 19.
@@ -1486,6 +1530,9 @@ export class Table {
1486
1530
  watchPolicy: WATCH_POLICIES.includes(url.searchParams.get('wp')) ? url.searchParams.get('wp') : 'follow',
1487
1531
  // House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
1488
1532
  qa: isQa(request),
1533
+ // The build this socket's page was served with (section 23), from the socket's own address: only when the
1534
+ // Worker speaks of builds at all (`cur`), so a relay behind an older Worker reads the hello's own.
1535
+ ...(said && url.pathname === '/__net' ? { ver: url.searchParams.get('gv') || null } : {}),
1489
1536
  // Room chat (section 19), the Worker's word: a signed-in account (a passkey), a member of this server, a page of
1490
1537
  // another site (homie.rocks's room page) watching from elsewhere.
1491
1538
  acct: url.searchParams.get('acct') === '1',
@@ -1505,9 +1552,12 @@ export class Table {
1505
1552
  if (room.watchers.size >= 256 || same >= 24) { try { server.close(1013, 'too many watching'); } catch { /* gone */ } return new Response(null, { status: 101, webSocket: client }); }
1506
1553
  const w = room.watch(conn);
1507
1554
  // The play page's vote card speaks on this socket (section 17); nothing else it says is read.
1508
- server.addEventListener('message', (e) => { if (typeof e.data === 'string') w.onMessage(e.data); });
1555
+ server.addEventListener('message', (e) => {
1556
+ if (typeof e.data !== 'string') return;
1557
+ try { w.onMessage(e.data); } catch (error) { this.say({ ev: 'failed', op: 'watch-message', error: errorLine(error) }); }
1558
+ });
1509
1559
  server.addEventListener('close', () => w.onClose());
1510
- server.addEventListener('error', () => w.onClose());
1560
+ server.addEventListener('error', (e) => { this.socketError('watch', e); w.onClose(); });
1511
1561
  } else {
1512
1562
  const h = room.attach(conn);
1513
1563
  // A room is OPENED when the first seat is taken in an empty one (a room restored after a deploy keeps its
@@ -1524,17 +1574,24 @@ export class Table {
1524
1574
  }
1525
1575
  });
1526
1576
  // An AI's time in a seat, counted once as it leaves (stats `agent-minutes`; never for house QA).
1527
- const left = () => {
1577
+ const left = (via) => {
1528
1578
  const c = room.clients.get(h.id);
1529
1579
  if (c?.agent && c.helloed && c.seat !== null && !conn.qa) {
1530
1580
  const minutes = Math.round((Date.now() - c.joinedAt) / 60_000);
1531
1581
  if (minutes > 0) this.ctx.waitUntil(count(this.env, null, { metric: 'agent-minutes', subject: this.game ?? '', source: c.agent.role, n: minutes }).catch(() => {}));
1532
1582
  }
1533
- h.onClose();
1534
- this.report();
1583
+ // The room says the departure itself (its `leave` line: who, which seat, host or not, how many are left).
1584
+ h.onClose(via);
1585
+ try { this.report(); } catch (error) { this.say({ ev: 'failed', op: 'report', error: errorLine(error) }); }
1535
1586
  };
1536
- server.addEventListener('close', left);
1537
- server.addEventListener('error', left);
1587
+ server.addEventListener('close', () => left('close'));
1588
+ // A lost network arrives here, not as a close: said as what it is, with the client it was, then handled as the
1589
+ // departure it is. (An error with a listener is never the runtime's "Uncaught".)
1590
+ server.addEventListener('error', (e) => {
1591
+ const c = room.clients.get(h.id);
1592
+ this.socketError('net', e, { id: h.id, seat: c?.seat ?? null, role: c && c.helloed ? room.roleOf(c) : null });
1593
+ left('error');
1594
+ });
1538
1595
  }
1539
1596
  this.start();
1540
1597
  return new Response(null, { status: 101, webSocket: client });
@@ -1546,7 +1603,8 @@ export class Table {
1546
1603
  this.timer = setInterval(() => {
1547
1604
  const room = this.room;
1548
1605
  if (!room) return;
1549
- room.tick();
1606
+ // A beat that throws is one failed beat, said with its room; the next one still runs.
1607
+ try { room.tick(); } catch (error) { this.say({ ev: 'tick-failed', clients: room.clients.size, error: errorLine(error) }); }
1550
1608
  // A room that just opened reads its game's launch state once: a change made in the moment it opened (before the
1551
1609
  // Lobby knew of it) still reaches it, after the current round as always.
1552
1610
  if (room.openedAt && this.launchReadFor !== room.openedAt) { this.launchReadFor = room.openedAt; this.ctx.waitUntil(this.rereadLaunch().catch(() => {})); }
@@ -1607,7 +1665,9 @@ export class Table {
1607
1665
  this.lastAi = ai;
1608
1666
  this.lastReportAt = Date.now();
1609
1667
  const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
1610
- this.ctx.waitUntil(lobby.fetch(`https://lobby/report?game=${encodeURIComponent(this.game)}`, { method: 'POST', body: JSON.stringify({ room: this.code, players, people, agents, ai }) }).catch(() => {}));
1668
+ // The build this room runs (section 23): the Lobby sends a visitor only to a room of the live build.
1669
+ const ver = room.gameVer === undefined ? undefined : room.gameVer ?? '';
1670
+ this.ctx.waitUntil(lobby.fetch(`https://lobby/report?game=${encodeURIComponent(this.game)}`, { method: 'POST', body: JSON.stringify({ room: this.code, players, people, agents, ai, ...(ver !== undefined ? { ver } : {}) }) }).catch(() => {}));
1611
1671
  }
1612
1672
 
1613
1673
  /** A round the host called over becomes one D1 row (once per round number). */
@@ -1657,7 +1717,7 @@ export class Lobby {
1657
1717
  const saved = await ctx.storage.get('lobby');
1658
1718
  if (saved) {
1659
1719
  this.next = typeof saved.next === 'number' ? { public: saved.next } : saved.next && typeof saved.next === 'object' ? { public: 1, ...saved.next } : { public: 1 };
1660
- for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, server: r.server ?? roomServer(r.name), agents: r.agents ?? 0, ai: r.ai ?? 0, pending: [], agentPending: [] });
1720
+ for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, server: r.server ?? roomServer(r.name), agents: r.agents ?? 0, ai: r.ai ?? 0, ver: typeof r.ver === 'string' ? r.ver : '', pending: [], agentPending: [] });
1661
1721
  for (const [name, until] of saved.closed ?? []) if (until > Date.now()) this.closed.set(name, until);
1662
1722
  }
1663
1723
  });
@@ -1683,7 +1743,7 @@ export class Lobby {
1683
1743
  if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(() => {}));
1684
1744
  }
1685
1745
 
1686
- save() { this.ctx.storage.put('lobby', { next: this.next, rooms: [...this.rooms.values()].map(({ name, players, agents, ai, server, at }) => ({ name, players, agents, ai, server, at })), closed: [...this.closed] }).catch(() => {}); }
1746
+ save() { this.ctx.storage.put('lobby', { next: this.next, rooms: [...this.rooms.values()].map(({ name, players, agents, ai, server, at, ver }) => ({ name, players, agents, ai, server, at, ...(ver ? { ver } : {}) })), closed: [...this.closed] }).catch(() => {}); }
1687
1747
 
1688
1748
  prune(now) {
1689
1749
  for (const [name, r] of this.rooms) {
@@ -1693,10 +1753,23 @@ export class Lobby {
1693
1753
  }
1694
1754
  }
1695
1755
 
1756
+ /** A request that throws is said with what it was (never its body) and answered as a failure, not left uncaught. */
1696
1757
  async fetch(request) {
1758
+ try { return await this.answer(request); } catch (error) {
1759
+ let path = '';
1760
+ try { path = new URL(request.url).pathname; } catch { /* not an address */ }
1761
+ try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-failed', game: this.game ?? null, path, error: errorLine(error) })); } catch { /* no console */ }
1762
+ return json({ ok: false, error: 'lobby' }, 500);
1763
+ }
1764
+ }
1765
+
1766
+ async answer(request) {
1697
1767
  const url = new URL(request.url);
1698
1768
  const now = Date.now();
1699
1769
  this.prune(now);
1770
+ // REVISIONS (section 23): rooms are matched within one build of the game. A room that never said its build is
1771
+ // the build of a game that names none ('').
1772
+ const ver = versionOf(url.searchParams.get('ver')) ?? '';
1700
1773
  if (url.pathname === '/join') {
1701
1774
  const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
1702
1775
  const sid = url.searchParams.get('server') ?? 'public';
@@ -1705,7 +1778,9 @@ export class Lobby {
1705
1778
  // A room the owner closed, or one this visitor is held out of (`not`), is never the answer.
1706
1779
  const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
1707
1780
  for (const [name, until] of this.closed) if (until <= now) this.closed.delete(name);
1708
- const pool = [...this.rooms.values()].filter((r) => (r.server ?? 'public') === server && !not.has(r.name) && !this.closed.has(r.name));
1781
+ // Every room of the server counts toward its `rooms_max` and its numbering; only this build's rooms are matched.
1782
+ const ofServer = [...this.rooms.values()].filter((r) => (r.server ?? 'public') === server && !not.has(r.name) && !this.closed.has(r.name));
1783
+ const pool = ofServer.filter((r) => (r.ver ?? '') === ver);
1709
1784
  if (url.searchParams.get('agent') === '1') {
1710
1785
  // An AI: a room of this pool with people in it and an AI seat free (the most people first). Never a new room.
1711
1786
  const reserve = Math.max(0, Math.floor(Number(url.searchParams.get('ai'))) || 0);
@@ -1735,7 +1810,7 @@ export class Lobby {
1735
1810
  if (!room) {
1736
1811
  let n = this.next[server] ?? 1;
1737
1812
  while (not.has(roomCode(server, n)) || this.closed.has(roomCode(server, n)) || this.rooms.has(roomCode(server, n))) n += 1;
1738
- room = { name: roomCode(server, n), server, players: 0, agents: 0, ai: 0, at: now, pending: [], agentPending: [] };
1813
+ room = { name: roomCode(server, n), server, players: 0, agents: 0, ai: 0, at: now, ver, pending: [], agentPending: [] };
1739
1814
  this.next[server] = n + 1;
1740
1815
  this.rooms.set(room.name, room);
1741
1816
  }
@@ -1762,9 +1837,12 @@ export class Lobby {
1762
1837
  this.notePeak(body.room, Math.max(0, Math.floor(Number(body.people ?? body.players) || 0)), url.searchParams.get('game'));
1763
1838
  // Only rooms of a pool are matched with strangers (pub-N, s-<id>-<n>); a named room (?room=) stays private.
1764
1839
  if (!this.rooms.has(body.room) && !pooledRoom(body.room)) return json({ ok: true, private: true });
1765
- const r = this.rooms.get(body.room) ?? { name: body.room, server: roomServer(body.room), players: 0, agents: 0, ai: 0, at: now, pending: [], agentPending: [] };
1840
+ const r = this.rooms.get(body.room) ?? { name: body.room, server: roomServer(body.room), players: 0, agents: 0, ai: 0, at: now, ver: '', pending: [], agentPending: [] };
1766
1841
  const grew = Math.max(0, seated - r.players);
1767
- const changed = !this.rooms.has(r.name) || r.players !== seated || (r.agents ?? 0) !== agents || (r.ai ?? 0) !== ai;
1842
+ // The room says which build it runs (it changes only when nobody on the old one is left).
1843
+ const rver = typeof body.ver === 'string' ? versionOf(body.ver) ?? '' : r.ver ?? '';
1844
+ const changed = !this.rooms.has(r.name) || r.players !== seated || (r.agents ?? 0) !== agents || (r.ai ?? 0) !== ai || (r.ver ?? '') !== rver;
1845
+ r.ver = rver;
1768
1846
  r.pending.splice(0, grew);
1769
1847
  if ((r.agents ?? 0) < agents) (r.agentPending ?? []).splice(0, agents - (r.agents ?? 0));
1770
1848
  r.players = seated;
@@ -1784,6 +1862,8 @@ export class Lobby {
1784
1862
  let best = null;
1785
1863
  for (const r of this.rooms.values()) {
1786
1864
  if (r.players <= 0 || not.has(r.name) || (this.closed.get(r.name) ?? 0) > now || (pools && !pools.has(r.server ?? 'public'))) continue;
1865
+ // A watcher's page runs the live build: a room still on an older one would turn it away.
1866
+ if (url.searchParams.has('ver') && (r.ver ?? '') !== ver) continue;
1787
1867
  if (!best || r.players > best.players || (r.players === best.players && r.name < best.name)) best = r;
1788
1868
  }
1789
1869
  return json({ room: best?.name ?? null, players: best?.players ?? 0 });