@homie-rocks/studio 0.30.2 → 0.31.1

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 (90) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/README.md +349 -23
  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 +204 -124
  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 +203 -10
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/deploy-state.mjs +166 -0
  23. package/lib/dev.mjs +430 -0
  24. package/lib/directory.mjs +172 -9
  25. package/lib/doctor.mjs +12 -11
  26. package/lib/handoff.mjs +2 -6
  27. package/lib/library.mjs +1 -1
  28. package/lib/mcp-tools.mjs +42 -37
  29. package/lib/mcp.mjs +1 -1
  30. package/lib/net.mjs +81 -2
  31. package/lib/node-version.mjs +40 -0
  32. package/lib/office.mjs +6 -6
  33. package/lib/optimise.mjs +39 -4
  34. package/lib/parts-build.mjs +169 -0
  35. package/lib/parts-cli.mjs +104 -0
  36. package/lib/parts-store.mjs +342 -0
  37. package/lib/parts-tools.mjs +92 -0
  38. package/lib/parts.mjs +720 -0
  39. package/lib/perf.mjs +131 -31
  40. package/lib/port-check.mjs +40 -6
  41. package/lib/preview.mjs +77 -0
  42. package/lib/routes.mjs +240 -0
  43. package/lib/scaffold.mjs +83 -22
  44. package/lib/shoot.mjs +307 -0
  45. package/lib/site.mjs +1 -2
  46. package/lib/stage.mjs +118 -0
  47. package/lib/studio.mjs +0 -66
  48. package/lib/style-board.mjs +49 -6
  49. package/lib/template-history.json +101 -0
  50. package/lib/trailer.mjs +47 -0
  51. package/lib/typecheck.mjs +107 -0
  52. package/mcp/ui/lineup.js +3 -3
  53. package/mcp/ui/rights.js +1 -3
  54. package/netplay/NETPLAY.md +552 -16
  55. package/netplay/netplay.ts +950 -43
  56. package/package.json +10 -1
  57. package/parts/PARTS.md +169 -0
  58. package/port/index.ts +8 -2
  59. package/port/personas.ts +275 -0
  60. package/port/powerups.ts +357 -0
  61. package/port/probe.ts +38 -2
  62. package/port/room.ts +329 -40
  63. package/site/SITE.md +102 -20
  64. package/starters/ember-vale/index.html +1 -1
  65. package/starters/ember-vale/src/main.ts +7 -1
  66. package/starters/gem-rush/index.html +1 -1
  67. package/starters/gem-rush/src/main.ts +41 -12
  68. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  69. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  70. package/starters/gem-rush-3d/index.html +1 -1
  71. package/starters/gem-rush-3d/src/main.ts +50 -11
  72. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  73. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  74. package/starters/hero-rush-3d/index.html +1 -1
  75. package/starters/hero-rush-3d/src/main.ts +50 -11
  76. package/worker/arrival.mjs +4 -1
  77. package/worker/discover.mjs +11 -25
  78. package/worker/index.mjs +142 -62
  79. package/worker/license.mjs +45 -41
  80. package/worker/office-page.mjs +0 -4
  81. package/worker/office-schema.mjs +6 -0
  82. package/worker/office.mjs +19 -23
  83. package/worker/pages.mjs +156 -29
  84. package/worker/parts.mjs +275 -0
  85. package/worker/room.mjs +157 -14
  86. package/worker/schema.mjs +6 -8
  87. package/worker/seats.mjs +71 -0
  88. package/worker/site.mjs +51 -53
  89. package/worker/version.mjs +1 -1
  90. package/lib/remix-assets.mjs +0 -84
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 });
@@ -1,46 +1,49 @@
1
1
  /**
2
- * A GAME'S SOURCE LICENCE AND LINEAGE, shared by the site Worker (worker/index.mjs, worker/site.mjs) and the CLI
3
- * (lib/build.mjs, lib/studio.mjs).
2
+ * A GAME'S OWN LICENCE AND THE CREDIT IT OWES, shared by the site Worker (worker/site.mjs, worker/schema.mjs,
3
+ * worker/discover.mjs) and the CLI (lib/build.mjs).
4
4
  *
5
- * The licence is the owner's pick (game.json `"license"`), carried in the game's source.json beside who made it
6
- * (`credit`: studio, game, page), so a remix knows what it may do and whom to credit:
5
+ * The licence is a statement of rights the owner may make about the game: game.json `"license"` names an SPDX
6
+ * identifier ("MIT", "CC-BY-4.0"), as the word or as { "spdx": "<id>" }. The game's page, its structured data and
7
+ * /llms.txt say it. A game that names none says nothing: no licence is granted, and no page pretends otherwise.
7
8
  *
8
- * "remix-with-credit" the default: remix it, and the remix says "Remix of <game> by <studio>" with a link back
9
- * "remix-freely" remix it; the credit is welcome, not asked for (the remix flow still writes it)
10
- * "no-remix" the source can be read, not remixed: the remix flow refuses
9
+ * REMIX WAS RETIRED. A game used to be handed over whole (its source at /games/<id>/source.json, brought in with
10
+ * `game remix`), and `"license"` also took three words about that: "remix-with-credit", "remix-freely" and
11
+ * "no-remix". Games now build on each other through parts, pieces a studio chooses to share (parts/PARTS.md), each
12
+ * under its own SPDX licence. Nothing here grants or refuses a remix any more:
11
13
  *
12
- * Either the word, or { "kind": "<word>", "spdx": "<SPDX id>" } to name a licence too (e.g. "MIT", "CC-BY-4.0"); a
13
- * bare SPDX id ("MIT") is the default kind with that id. Anything else is the default.
14
- *
15
- * A remix's game.json `remixOf` (written by `homie-studio game remix`) is its lineage: the original's name, studio
16
- * and page, shown on the remix's landing ("Remix of <game> by <studio>") and in its credits.
14
+ * - the three words, and `"kind"` in the object form, are read as no licence named (an SPDX id beside them stays);
15
+ * - game.json `"remix"`, `"share"` and `landing.make` are ignored; retiredKeys() names the ones a game still
16
+ * carries, so the build can say so once;
17
+ * - `"remixOf"`, which `game remix` wrote into a game it made, is still read: it is a credit that game owes its
18
+ * original, and its landing and credits keep showing it ("Based on <game> by <studio>", basedOnRow()).
17
19
  */
18
- export const LICENSE_KINDS = Object.freeze({
19
- 'remix-with-credit': 'Remix with credit',
20
- 'remix-freely': 'Remix freely',
21
- 'no-remix': 'Not for remixing',
22
- });
23
- export const DEFAULT_LICENSE = 'remix-with-credit';
24
20
  const SPDX = /^[A-Za-z0-9][A-Za-z0-9.+-]{0,63}$/;
21
+ /** The three words `"license"` took while a game could be handed over whole: none of them is a licence. */
22
+ const RETIRED_KINDS = new Set(['remix-with-credit', 'remix-freely', 'no-remix']);
25
23
 
26
- /** { kind, spdx } from what game.json (or a source.json) says. */
24
+ /** { spdx } from what game.json (or a built catalogue row) says, or null when it names no licence. */
27
25
  export function licenseOf(value) {
28
- const v = typeof value === 'string'
29
- ? (Object.hasOwn(LICENSE_KINDS, value.trim()) ? { kind: value.trim() } : { spdx: value })
30
- : value && typeof value === 'object' ? value : {};
31
- const kind = Object.hasOwn(LICENSE_KINDS, v.kind) ? v.kind : DEFAULT_LICENSE;
32
- const spdx = typeof v.spdx === 'string' && SPDX.test(v.spdx.trim()) ? v.spdx.trim() : null;
33
- return { kind, spdx };
26
+ const v = typeof value === 'string' ? value : value && typeof value === 'object' ? value.spdx : null;
27
+ const spdx = typeof v === 'string' && SPDX.test(v.trim()) && !RETIRED_KINDS.has(v.trim()) ? v.trim() : null;
28
+ return spdx ? { spdx } : null;
34
29
  }
35
30
 
36
- /** "Remix with credit (MIT)". */
37
- export function licenseLabel(value) {
38
- const l = licenseOf(value);
39
- return `${LICENSE_KINDS[l.kind]}${l.spdx ? ` (${l.spdx})` : ''}`;
40
- }
31
+ /** "MIT", or null for a game that names no licence. */
32
+ export const licenseLabel = (value) => licenseOf(value)?.spdx ?? null;
41
33
 
42
- /** Whether the owner lets the game be remixed. */
43
- export const remixAllowed = (value) => licenseOf(value).kind !== 'no-remix';
34
+ /**
35
+ * The retired settings a game.json still carries, as the words to print: `"remix"`, `"share"`, `landing.make`, and
36
+ * a `"license"` that is one of the three old words (or has a `"kind"`). `"remixOf"` is not one: it is still read.
37
+ */
38
+ export function retiredKeys(g) {
39
+ const out = [];
40
+ if (g?.remix !== undefined) out.push('"remix"');
41
+ if (g?.share !== undefined) out.push('"share"');
42
+ const lic = g?.license;
43
+ if ((typeof lic === 'string' && RETIRED_KINDS.has(lic.trim())) || (lic && typeof lic === 'object' && lic.kind !== undefined)) out.push(`"license": ${JSON.stringify(typeof lic === 'string' ? lic.trim() : String(lic.kind)).slice(0, 40)}`);
44
+ if (g?.landing && typeof g.landing === 'object' && g.landing.make !== undefined) out.push('landing.make');
45
+ return out;
46
+ }
44
47
 
45
48
  const line = (v, max) => (typeof v === 'string' && v.trim() ? v.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, max) : null);
46
49
  /** An https address, else null. */
@@ -48,8 +51,8 @@ export function httpsPage(v) {
48
51
  try { const u = new URL(String(v ?? '')); return u.protocol === 'https:' ? u.href : null; } catch { return null; }
49
52
  }
50
53
 
51
- /** A game's page from its source address: https://<site>/games/<id>/source.json is https://<site>/<id>/. */
52
- export function pageOfSource(source) {
54
+ /** A game's page from the address its source once had: https://<site>/games/<id>/source.json is https://<site>/<id>/. */
55
+ function pageOfSource(source) {
53
56
  try {
54
57
  const u = new URL(String(source ?? ''));
55
58
  const m = /^\/games\/([a-z0-9][a-z0-9-]{0,39})\/source\.json$/.exec(u.pathname);
@@ -58,17 +61,18 @@ export function pageOfSource(source) {
58
61
  }
59
62
 
60
63
  /**
61
- * What a game.json `remixOf` may show: the original's name, studio and page (an https link; a remix made before
62
- * 0.14.4 has only its source address, which names the page too), plain text only.
64
+ * The credit a game owes the game it was made from, as it may be shown: the original's name, studio and page (an
65
+ * https link), plain text only. Read from game.json `"remixOf"` (what `game remix` wrote; one made before 0.14.4
66
+ * has only the original's source address, which names its page too) or from a built catalogue row's `basedOn`.
63
67
  */
64
- export function remixRow(r) {
68
+ export function basedOnRow(r) {
65
69
  if (!r || typeof r !== 'object') return null;
66
70
  const name = line(r.name, 80);
67
71
  return name ? { name, studio: line(r.studio, 80), page: httpsPage(r.page) ?? pageOfSource(r.source) } : null;
68
72
  }
69
73
 
70
- /** "Remix of Gem Rush by Night Owl Games" (the studio left out when the source named none). */
71
- export function remixCredit(r) {
72
- const row = remixRow(r);
73
- return row ? `Remix of ${row.name}${row.studio ? ` by ${row.studio}` : ''}` : null;
74
+ /** "Based on Gem Rush by Night Owl Games" (the studio left out when the record named none). */
75
+ export function basedOnCredit(r) {
76
+ const row = basedOnRow(r);
77
+ return row ? `Based on ${row.name}${row.studio ? ` by ${row.studio}` : ''}` : null;
74
78
  }
@@ -226,16 +226,12 @@ export const OFFICE_SCRIPT = String.raw`(function () {
226
226
  b.setAttribute('data-v', p[0]); b.setAttribute('aria-pressed', g.launch === p[0] ? 'true' : 'false');
227
227
  st.appendChild(b);
228
228
  });
229
- var remix = el('input', 'switch'); remix.type = 'checkbox'; remix.checked = g.remix; remix.disabled = !g.remixBuilt;
230
- remix.setAttribute('aria-label', 'Remixable');
231
- remix.onchange = function () { act('/_studio/api/game', { game: g.id, remix: remix.checked }, remix.checked ? g.name + '\'s source is open for remixing.' : g.name + '\'s source is withdrawn.'); };
232
229
  var max = el('input'); max.type = 'number'; max.min = '1'; max.max = String(g.seats); max.value = String(g.maxPlayers); max.setAttribute('aria-label', 'Players per room');
233
230
  var setMax = btn('Set', 'ghost small', function () {
234
231
  var n = Math.floor(Number(max.value));
235
232
  act('/_studio/api/game', { game: g.id, maxPlayers: n >= g.seats ? null : n }, 'Rooms of ' + g.name + ' now hold ' + Math.min(n, g.seats) + '.');
236
233
  });
237
234
  add(box, st,
238
- add(el('label'), remix, el('span', '', !g.remixBuilt ? 'Remixable (not in the build)' : g.license === 'no-remix' ? 'Source shared (its licence: not for remixing)' : 'Remixable')),
239
235
  add(el('label'), el('span', 'dim', 'Players per room'), max, el('span', 'faint', 'of ' + g.seats), setMax),
240
236
  add(el('span', 'faint'), document.createTextNode(g.launchFrom === 'game.json' ? 'state from game.json' : '')));
241
237
  return box;