@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
@@ -1,5 +1,11 @@
1
1
  /** The back office's D1 tables (worker/office.mjs), as the template's migration 0003 and `homie-studio upgrade` write them. */
2
2
  export const OFFICE_MIGRATION_FILE = '0005_studio_office.sql';
3
+ /*
4
+ * This text is a migration that has run in studios in the field: it is never edited, not even its first comment.
5
+ * `office_games.remix` (the owner's switch for handing a game over whole) is still made here and still in every
6
+ * studio's table, though remix was retired: worker/office.mjs neither reads nor writes it, and no later migration
7
+ * drops it.
8
+ */
3
9
  export const OFFICE_MIGRATION = `-- The studio's back office (@homie-rocks/studio 0.13.0): each game's launch state, remix switch and room size,
4
10
  -- its invites and the passes they gave, and the controls the owner's AI asked the owner to confirm.
5
11
  CREATE TABLE IF NOT EXISTS office_games (
package/worker/office.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * /_studio/office the owner's live view: every live room of every game (players, bots, round,
6
6
  * uptime) and who is in it, refreshing by itself; kick, mute, announce, close a room;
7
- * each game's launch state, remix switch, room size and invites
7
+ * each game's launch state, room size and invites
8
8
  * /_studio/confirm/<ask> a control the owner's AI asked for, waiting for the owner's one tap
9
9
  * /_studio/api/... the same, as JSON, for the page, the in-game owner overlay, the CLI and the MCP
10
10
  * /<game>/invite an invite code, spent for this browser's pass to an invite-only game
@@ -14,8 +14,8 @@
14
14
  * office key (`homie-studio office key`, also minted with that login; D1 keeps only its SHA-256). A player can
15
15
  * never be the owner: the session is an HttpOnly cookie no page or game can read, and the games run in a sandboxed,
16
16
  * opaque-origin frame that can send no request as this site. With an office key, the AI can look, announce and
17
- * make invites; a control that takes something away (kick, mute, closing a room, a game's launch state, remix
18
- * switch or room size) only becomes an ASK, which the owner confirms with one tap in their signed-in browser.
17
+ * make invites; a control that takes something away (kick, mute, closing a room, a game's launch state
18
+ * or room size) only becomes an ASK, which the owner confirms with one tap in their signed-in browser.
19
19
  * No key can confirm an ask.
20
20
  *
21
21
  * CONTROLS ARE SIGNED. The Worker hands a room a control as a `ctl` message signed with the studio's own office
@@ -28,8 +28,12 @@
28
28
  * is left out of the studio's pages, /api/games, /api/rooms and the directory manifest, so the directory drops it
29
29
  * the next time it reads the studio; its own pages, rooms and play frame refuse anyone without access (the play
30
30
  * frame and its sockets carry a signed ticket the play page mints). A Preview (no D1, an unlisted address of a
31
- * branch under review) enforces no launch state. The remix switch publishes or withdraws the game's
32
- * /games/<id>/source.json (a game.json `"share": { "source": false }` builds none to publish).
31
+ * branch under review) enforces no launch state.
32
+ *
33
+ * `office_games` also has a `remix` column: the owner's switch for handing a game over whole, from when that existed
34
+ * (remix was retired). A migration that ran in the field is never edited and the column is never dropped, so it
35
+ * stays in the table with whatever it held; nothing here reads it and nothing writes it (settingsOf, the `game`
36
+ * action below).
33
37
  *
34
38
  * PLAYER ACCOUNTS (worker/players.mjs, handed over in worker/index.mjs with `usePlayers()`): `of(request, env)` names
35
39
  * the signed-in player in their play page's ticket, so their seat carries their account and a kick holds it on every
@@ -61,7 +65,6 @@ import { DEFAULT_MODEL, NEURONS_PER_M, OWNER_MODEL, vocabularyOf } from './brain
61
65
  import { OWNER_COOKIE, cookieValues, counter, ownerAllowed, ownerSession, today } from './stats.mjs';
62
66
  import { esc, layout, notFoundPage } from './site.mjs';
63
67
  import { confirmPage, lockedPage, officePage } from './office-page.mjs';
64
- import { licenseOf } from './license.mjs';
65
68
  import { CHAT_MODES, CHAT_WHO, checkChatRules, publicChat } from './chat.mjs';
66
69
  import { chatDay, chatOf, chatRowsOf, clearChatRules, dismissReport, reportsOf, writeChatRules } from './chat-store.mjs';
67
70
  import { checkRefund, checkSettle, describeRefund, officeShopPage, ordersCsv, performRefund, settleReferrer, shopOffice, statementsOut, statementsSend } from './shop.mjs';
@@ -178,7 +181,7 @@ export async function settingsOf(env, { fresh = false } = {}) {
178
181
  if (!fresh && hit && Date.now() - hit.at < 5000) return hit.map;
179
182
  let map = new Map();
180
183
  try {
181
- const { results } = await env.DB.prepare('SELECT game, launch, remix, max_players, updated_at FROM office_games LIMIT 500').all();
184
+ const { results } = await env.DB.prepare('SELECT game, launch, max_players, updated_at FROM office_games LIMIT 500').all();
182
185
  map = new Map((results ?? []).map((r) => [r.game, r]));
183
186
  } catch { map = new Map(); }
184
187
  settingsCache.set(env.DB, { at: Date.now(), map });
@@ -192,11 +195,6 @@ export function launchOf(meta, settings, env) {
192
195
  const v = settings?.get(meta?.id)?.launch ?? meta?.launch ?? 'public';
193
196
  return LAUNCH_STATES.includes(v) ? v : 'public';
194
197
  }
195
- /** Whether the game's source is offered for remix: built (game.json share.source) and not withdrawn by the owner. */
196
- export function remixOf(meta, settings) {
197
- if (meta?.landing?.source === false) return false;
198
- return settings?.get(meta?.id)?.remix !== 0;
199
- }
200
198
  /** A room's seats: the game's own (its netplay manifest), lowered by the owner's room size. */
201
199
  export function seatsFor(meta, settings) {
202
200
  const own = seatsOf(meta);
@@ -616,7 +614,6 @@ export async function officeView(env, cat, origin) {
616
614
  // 0.17.0: the game's AI guides have words of their own (agents.json): without it they play but never talk.
617
615
  vocab: g.vocab === true,
618
616
  passes: (await passList(env, { game: g.id })).filter((x) => x.live || Date.now() - x.createdAt < 7 * 86_400_000),
619
- remix: remixOf(g, settings), remixBuilt: g.landing?.source !== false, license: licenseOf(g.license).kind,
620
617
  seats: seatsOf(g), maxPlayers: max, maxSet: Number(row?.max_players) >= 1 ? Number(row.max_players) : null,
621
618
  play: `${origin}/${g.id}/play`, page: `${origin}/${g.id}/`,
622
619
  invites: launch === 'invite' || (await hasInvites(env, g.id)) ? await invitesOf(env, g.id, origin) : [],
@@ -691,7 +688,6 @@ function checkAction(cat, op, body) {
691
688
  if (!meta) return bad('game is one of this studio\'s game ids');
692
689
  const launch = body.launch === undefined || body.launch === null ? undefined : String(body.launch);
693
690
  if (launch !== undefined && !LAUNCH_STATES.includes(launch)) return bad('launch is private, invite or public');
694
- const remix = body.remix === undefined || body.remix === null ? undefined : body.remix === true || body.remix === 'on';
695
691
  let maxPlayers;
696
692
  if (body.maxPlayers === null || body.maxPlayers === 'game') maxPlayers = null;
697
693
  else if (body.maxPlayers !== undefined) {
@@ -699,9 +695,10 @@ function checkAction(cat, op, body) {
699
695
  if (!(maxPlayers >= 1)) return bad('maxPlayers is a number from 1 to the game\'s own seats, or null for the game\'s own');
700
696
  maxPlayers = Math.min(maxPlayers, seatsOf(meta));
701
697
  }
702
- if (launch === undefined && remix === undefined && maxPlayers === undefined) return bad('say what to change: launch, remix or maxPlayers');
703
- if (remix === true && meta.landing?.source === false) return bad(`${meta.name}'s source is not in its build (game.json "share": { "source": false }); set it to true and deploy first`);
704
- return { ok: true, action: { op, game: meta.id, ...(launch !== undefined ? { launch } : {}), ...(remix !== undefined ? { remix } : {}), ...(maxPlayers !== undefined ? { maxPlayers } : {}) } };
698
+ // An older plugin or CLI may still send `remix` (the switch that offered a game whole). It is not a setting
699
+ // any more: alone it is refused in a sentence, and beside a launch state or a room size it is left out.
700
+ if (launch === undefined && maxPlayers === undefined) return bad(body.remix !== undefined && body.remix !== null ? 'remix was retired: a game is no longer handed over whole, so there is no switch to set. A studio shares pieces of its games as parts (/parts/)' : 'say what to change: launch or maxPlayers');
701
+ return { ok: true, action: { op, game: meta.id, ...(launch !== undefined ? { launch } : {}), ...(maxPlayers !== undefined ? { maxPlayers } : {}) } };
705
702
  }
706
703
  case 'server-create': {
707
704
  if (!meta) return bad('game is one of this studio\'s game ids');
@@ -822,7 +819,6 @@ export function describe(cat, a) {
822
819
  case 'game': {
823
820
  const bits = [];
824
821
  if (a.launch) bits.push({ private: `make ${gname} private (only you can open it; after the current round everyone else leaves its rooms with a thank-you, and it leaves the directory on the next read)`, invite: `make ${gname} an invite-only beta (invited players only; after the current round everyone else leaves its rooms with a thank-you, and it leaves the directory on the next read)`, public: `make ${gname} public (anyone can play; listed in the directory on the next read)` }[a.launch]);
825
- if (a.remix !== undefined) bits.push(a.remix ? `publish ${gname}'s source for remixing` : `withdraw ${gname}'s source from remixing`);
826
822
  if (a.maxPlayers !== undefined) bits.push(a.maxPlayers === null ? `give ${gname}'s rooms the game's own number of seats` : `set ${gname}'s rooms to at most ${a.maxPlayers} players`);
827
823
  const s = bits.join('; ');
828
824
  return `${s.charAt(0).toUpperCase()}${s.slice(1)}.`;
@@ -950,16 +946,16 @@ export async function perform(env, cat, a) {
950
946
  const row = settings.get(meta.id) ?? {};
951
947
  const next = {
952
948
  launch: a.launch ?? row.launch ?? null,
953
- remix: a.remix === undefined ? (row.remix ?? null) : (a.remix ? 1 : 0),
954
949
  max: a.maxPlayers === undefined ? (row.max_players ?? null) : a.maxPlayers,
955
950
  };
956
- await env.DB.prepare(`INSERT INTO office_games (game, launch, remix, max_players, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
957
- ON CONFLICT(game) DO UPDATE SET launch = excluded.launch, remix = excluded.remix, max_players = excluded.max_players, updated_at = excluded.updated_at`)
958
- .bind(meta.id, next.launch, next.remix, next.max, Date.now()).run();
951
+ // The table's `remix` column is not named: a new row leaves it NULL and an old row keeps what it had (above).
952
+ await env.DB.prepare(`INSERT INTO office_games (game, launch, max_players, updated_at) VALUES (?1, ?2, ?3, ?4)
953
+ ON CONFLICT(game) DO UPDATE SET launch = excluded.launch, max_players = excluded.max_players, updated_at = excluded.updated_at`)
954
+ .bind(meta.id, next.launch, next.max, Date.now()).run();
959
955
  forgetSettings(env);
960
956
  const fresh = await settingsOf(env, { fresh: true });
961
957
  const launch = launchOf(meta, fresh, env);
962
- const done = { ok: true, op: 'game', game: meta.id, launch, remix: remixOf(meta, fresh), maxPlayers: seatsFor(meta, fresh), regating: 0 };
958
+ const done = { ok: true, op: 'game', game: meta.id, launch, maxPlayers: seatsFor(meta, fresh), regating: 0 };
963
959
  const rooms = await liveRooms(env, meta.id);
964
960
  // Who may play changed: every live room finishes its current round, with a notice, and then whoever the new
965
961
  // state leaves out is sent out of it (the owner, and in an invite-only beta the invited, play on). Back to a
package/worker/pages.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { esc, layout } from './site.mjs';
6
6
  import { NET_PALETTE } from './room.mjs';
7
+ import { PARAM_VALUE, PREFS_LIMITS, playParams } from './seats.mjs';
7
8
  import { SKILLS } from './agents.mjs';
8
9
  import { KIDS_LINE, POLICY_WORDS } from './servers.mjs';
9
10
  import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
@@ -101,7 +102,7 @@ export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, lo
101
102
  const arrive = arrivalCard(cat, g, { screen });
102
103
  const css = `:root{--hot:${/^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a'}}
103
104
  html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: none; background: #04060c; touch-action: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }
104
- iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; }
105
+ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
105
106
  .chip { position: fixed; left: max(10px, env(safe-area-inset-left)); bottom: max(10px, env(safe-area-inset-bottom)); z-index: 5; padding: 6px 10px; border-radius: 999px; background: rgba(0,0,0,.55); color: #dfe6f5; font: 12px/1.2 ui-sans-serif, system-ui, sans-serif; pointer-events: none; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); transition: opacity .6s; }
106
107
  .chip.quiet { opacity: 0; }
107
108
  .card { position: fixed; right: 12px; bottom: 12px; z-index: 4; max-width: 300px; padding: 10px 12px; border-radius: 12px; background: rgba(8,12,22,.82); border: 1px solid rgba(255,255,255,.14); color: #e8ecf5; font: 13px/1.35 ui-sans-serif, system-ui, sans-serif; }
@@ -171,7 +172,8 @@ ${arrive.css}${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''
171
172
  const srv = server ? { id: server.id, name: server.name, badge: server.badge, line: server.line, policy: server.policy, kids: Boolean(server.kids), mentor: Boolean(server.mentor), levelMax: server.levelMax } : null;
172
173
  // game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
173
174
  const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
174
- const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
175
+ // The address's switches the game's frame is handed (NETPLAY.md section 24): the defaults and the game's own names.
176
+ const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
175
177
  // game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
176
178
  const joinCorner = corner('join', 'bottom-right');
177
179
  const first = places.desk;
@@ -401,25 +403,47 @@ const SHELL_JS = String.raw`(function () {
401
403
  }
402
404
  } catch (e) {}
403
405
  var KEY = 'homie-net.' + boot.game + '.' + room + '.' + want;
404
- var token = null;
405
- try { token = sessionStorage.getItem(KEY); } catch (e) {}
406
- var q = new URLSearchParams({ room: room, device: device, want: want });
407
- if (token) q.set('k', token);
408
- q.set('b', roomKey);
409
- if (boot.t) q.set('t', boot.t);
410
- if (params.get('name')) q.set('name', params.get('name'));
411
- else {
412
- // A player with an account (or a named guest) on this studio plays under their own name in every room.
413
- try { var who = JSON.parse(localStorage.getItem('homie.player') || 'null'); if (who && typeof who.name === 'string' && who.name) q.set('name', who.name.slice(0, 24)); } catch (e) {}
406
+ // The frame's address, made again for a reload of the game (a newer build, section 23): the seat's token as it is now.
407
+ function frameUrl() {
408
+ var token = null;
409
+ try { token = sessionStorage.getItem(KEY); } catch (e) {}
410
+ var q = new URLSearchParams({ room: room, device: device, want: want });
411
+ if (token) q.set('k', token);
412
+ q.set('b', roomKey);
413
+ if (boot.t) q.set('t', boot.t);
414
+ if (params.get('name')) q.set('name', params.get('name'));
415
+ else {
416
+ // A player with an account (or a named guest) on this studio plays under their own name in every room.
417
+ try { var who = JSON.parse(localStorage.getItem('homie.player') || 'null'); if (who && typeof who.name === 'string' && who.name) q.set('name', who.name.slice(0, 24)); } catch (e) {}
418
+ }
419
+ // Quiet AI (this browser hides AI speech): the helper starts hushed, and hears a change by message.
420
+ if (state.quietAi) q.set('hush', '1');
421
+ // Room chat (section 19): this browser's "Show chat" and "Show my messages over my character", for the game's bubbles.
422
+ try { if (localStorage.getItem('homie-chat-show') === '0') q.set('chat', '0'); if (localStorage.getItem('homie-chat-bubble') === '0') q.set('bub', '0'); } catch (e) {}
423
+ // The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches (section 24).
424
+ // An allow-list: ?debug and ?q always, the port kit's three, and the names game.json "netplay.params" declares;
425
+ // never one this page uses itself (the room, the token, the ticket). The game reads them as net.params.
426
+ var okValue = new RegExp(boot.paramValue || '^[A-Za-z0-9_.~-]{0,48}$');
427
+ (boot.params || ['debug', 'q', 'touchdebug', 'cam', 'view']).forEach(function (k) { var v = params.get(k); if (v !== null && okValue.test(v)) q.set(k, v); });
428
+ // This page keeps net.prefs for the frame (section 24).
429
+ q.set('pf', '1');
430
+ return '/' + boot.game + '/__game/?' + q.toString();
414
431
  }
415
- if (params.get('debug') === '1') q.set('debug', '1');
416
- // Quiet AI (this browser hides AI speech): the helper starts hushed, and hears a change by message.
417
- if (state.quietAi) q.set('hush', '1');
418
- // Room chat (section 19): this browser's "Show chat" and "Show my messages over my character", for the game's bubbles.
419
- try { if (localStorage.getItem('homie-chat-show') === '0') q.set('chat', '0'); if (localStorage.getItem('homie-chat-bubble') === '0') q.set('bub', '0'); } catch (e) {}
420
- // The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches.
421
- ['touchdebug', 'cam', 'view'].forEach(function (k) { var v = params.get(k); if (v && /^[A-Za-z0-9_-]{1,16}$/.test(v)) q.set(k, v); });
422
- frame.src = '/' + boot.game + '/__game/?' + q.toString();
432
+ state.params = {};
433
+ (boot.params || []).forEach(function (k) { var v = params.get(k); if (v !== null && new RegExp(boot.paramValue || '^$').test(v)) state.params[k] = v; });
434
+ // A newer build of the game is live (section 23): load the frame again, at most twice a minute (a build whose
435
+ // files are still the old ones at the edge would otherwise reload for ever).
436
+ var reloads = [];
437
+ function reloadGame() {
438
+ var t = Date.now();
439
+ reloads = reloads.filter(function (x) { return t - x < 60000; });
440
+ if (reloads.length >= 2) { flash('This game was updated. Reload the page to play the new version.'); return false; }
441
+ reloads.push(t);
442
+ state.stale = null; state.closed = null; state.reloaded = (state.reloaded || 0) + 1;
443
+ try { frame.src = frameUrl(); } catch (e) {}
444
+ return true;
445
+ }
446
+ frame.src = frameUrl();
423
447
  A.room(labelOf(room));
424
448
  frame.addEventListener('load', function () { A.frameLoaded(); try { frame.focus(); frame.contentWindow.focus(); } catch (e) {} });
425
449
  window.addEventListener('pointerdown', function (e) { if ((ui && ui.contains(e.target)) || (e.target.closest && e.target.closest('[data-keep-focus]'))) return; try { frame.focus(); } catch (e2) {} }, { passive: true });
@@ -428,18 +452,41 @@ const SHELL_JS = String.raw`(function () {
428
452
  var m = ev.data;
429
453
  if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
430
454
  A.message(m);
431
- if (m.what === 'attached') state.attached = true;
432
- if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} if (window.__homieChat) window.__homieChat.seat(); }
455
+ if (m.what === 'attached') { state.attached = true; lastRects = ''; tellRects(); }
456
+ // The link (section 22): in the room, knocking again, or playing alone because the room never answered.
457
+ if (m.what === 'link' && typeof m.state === 'string') state.link = { state: m.state, why: String(m.why || ''), hosting: m.hosting === true, at: Date.now() };
458
+ // Every seat is taken and this browser asked to play (the room's own word, in its welcome): it watches until one
459
+ // frees up, and the chip says so. Not full any more once it has been seated, or plays by itself.
460
+ if (m.what === 'full') state.full = m.full === true;
461
+ // The game's own word that it was playable (section 21), beside who lifted the card: a late one is a game whose
462
+ // arrival should be 'game'. A performance probe reads both here.
463
+ if (m.what === 'ready') {
464
+ state.ready = { mode: m.mode === 'game' ? 'game' : 'auto', ms: Number(m.ms) || 0, lateMs: typeof m.lateMs === 'number' ? m.lateMs : null, atMs: Math.round(typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()) };
465
+ if (A.state) { A.state.explicitMs = state.ready.atMs; A.state.lateMs = state.ready.lateMs; }
466
+ }
467
+ if (m.what === 'prefs') answerPrefs(m);
468
+ // A newer build of the game is live. Kept out of a room for it (final): the game loads again now. Still
469
+ // playing in its own room: it loads again at the round's break, or at once on a big screen (nothing to lose).
470
+ if (m.what === 'stale') {
471
+ state.stale = { ver: typeof m.ver === 'string' ? m.ver : null, final: m.final === true };
472
+ if (m.final === true || screenMode) reloadGame();
473
+ else flash('A new version of ' + boot.name + ' is ready: it loads after this round.');
474
+ }
475
+ if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; if (typeof m.seat === 'number') state.full = false; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} if (window.__homieChat) window.__homieChat.seat(); }
433
476
  if (m.what === 'stats') state.stats = m.stats;
434
- if (m.what === 'round') onRound(m.round);
477
+ if (m.what === 'round') { onRound(m.round); if (state.stale && m.round && m.round.phase === 'over') reloadGame(); }
435
478
  if (m.what === 'roster') state.roster = m.slots;
436
- if (m.what === 'closed') { state.closed = m.why; if (m.why === 'kicked' || m.why === 'room-closed' || m.why === 'agents-off') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); }
479
+ // Stopped for good. Removed, or the room closed: the notice. A room with no place left at all: the same notice
480
+ // with its own words, because "try another room" is the only thing a player can do about it.
481
+ if (m.what === 'closed') { state.closed = m.why; if (m.why === 'kicked' || m.why === 'room-closed' || m.why === 'agents-off') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); else if (m.why === 'room-full' || m.why === 'too-many') notice('full', m); }
437
482
  // A game's net.pickPlayer(seat): only an owner's page listens (its overlay opens that player's card).
438
483
  if (m.what === 'pick' && (m.seat === null || typeof m.seat === 'number')) { try { window.dispatchEvent(new CustomEvent('homie-pick', { detail: { seat: m.seat } })); } catch (e) {} }
439
484
  paint();
440
485
  });
441
486
  watch(room);
442
487
  shareReady(room);
488
+ addEventListener('resize', tellRects);
489
+ if (typeof setInterval === 'function') setInterval(tellRects, 1000);
443
490
  if (screenMode && !document.querySelector('[data-join]') && !LOCAL) {
444
491
  var h2 = document.createElement('h2'); h2.textContent = 'Join on your phone';
445
492
  var div = document.createElement('div'); div.textContent = location.origin + '/' + boot.game + '/play?room=' + encodeURIComponent(room);
@@ -447,6 +494,81 @@ const SHELL_JS = String.raw`(function () {
447
494
  }
448
495
  }
449
496
 
497
+ /*
498
+ * NET.PREFS (NETPLAY.md section 24). The game's frame is an opaque origin (no allow-same-origin: a stranger's game
499
+ * must never read this site's storage, cookies or the owner's session), so its own localStorage throws. This page
500
+ * keeps a few settings for it instead: one JSON object per game under this site's storage, 16 KB and 32 keys at
501
+ * most, read and written only for this page's own game. With no storage (a private window): for the visit.
502
+ */
503
+ var PREFS_KEY = 'homie-prefs.' + boot.game;
504
+ var PREFS = boot.prefs || { bytes: 16384, keys: 32, key: 64 };
505
+ var prefsMem = null;
506
+ function prefsRead() {
507
+ if (prefsMem) return prefsMem;
508
+ try { var o = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}'); return o && typeof o === 'object' && !Array.isArray(o) ? o : {}; } catch (e) { prefsMem = {}; return prefsMem; }
509
+ }
510
+ function prefsWrite(o) {
511
+ var text = JSON.stringify(o);
512
+ if (typeof text !== 'string' || text.length > PREFS.bytes || Object.keys(o).length > PREFS.keys) return 'too-large';
513
+ if (prefsMem) { prefsMem = o; return 'memory'; }
514
+ try { localStorage.setItem(PREFS_KEY, text); return 'kept'; } catch (e) { prefsMem = o; return 'memory'; }
515
+ }
516
+ function answerPrefs(m) {
517
+ var out = { t: 'homie-prefs', n: m.n, ok: false };
518
+ try {
519
+ var all = prefsRead();
520
+ var k = m.k;
521
+ var okKey = typeof k === 'string' && k.length > 0 && k.length <= PREFS.key && k !== '__proto__';
522
+ if (m.op === 'all') { out.ok = true; out.all = all; }
523
+ else if (!okKey) out.why = 'key';
524
+ else if (m.op === 'set' || m.op === 'del') {
525
+ var next = {};
526
+ Object.keys(all).forEach(function (x) { if (x !== k) next[x] = all[x]; });
527
+ if (m.op === 'set' && m.v !== null && m.v !== undefined) next[k] = m.v;
528
+ // A NaN or an Infinity would be written as null and read back as a setting (a volume of null plays as 0):
529
+ // refused, whatever helper sent it.
530
+ var how = m.op === 'set' && typeof m.v === 'number' && !isFinite(m.v) ? 'value' : prefsWrite(next);
531
+ if (how === 'too-large' || how === 'value') out.why = how; else { out.ok = true; out.kept = how; }
532
+ } else out.why = 'op';
533
+ } catch (e) { out.why = 'error'; }
534
+ state.prefs = { op: m.op, ok: out.ok, why: out.why || null };
535
+ try { frame.contentWindow.postMessage(out, '*'); } catch (e2) {}
536
+ }
537
+
538
+ /*
539
+ * WHERE THIS PAGE'S OWN CONTROLS SIT OVER THE GAME (section 24). The frame fills the page, so these rectangles are
540
+ * in the game's own CSS pixels. A game's HUD check that only measures inside the frame cannot see them: the room
541
+ * button, the server pill, the chat pill, the "3 playing" chip (it fades, and comes back), the big screen's join
542
+ * card, the studio's banner. Said to the helper when it attaches, on a resize or a turn, and when one moves.
543
+ */
544
+ var RECTS = [['room', '[data-share-toggle]'], ['server', '[data-server-toggle]'], ['chat', '[data-chat-toggle]'], ['chip', '[data-chip]', true], ['join', '[data-join]'], ['banner', '[data-banner]'], ['ticker', '.ctick', true], ['results', '[data-results]'], ['screen', '[data-screen]'], ['sheet', '[data-share-sheet]'], ['server-sheet', '[data-server-sheet]'], ['vote', '[data-vote]']];
545
+ var lastRects = '';
546
+ function shellRects() {
547
+ var out = [];
548
+ RECTS.forEach(function (r) {
549
+ var el = null;
550
+ try { el = document.querySelector(r[1]); } catch (e) { el = null; }
551
+ if (!el || el.hidden || typeof el.getBoundingClientRect !== 'function') return;
552
+ var b = el.getBoundingClientRect();
553
+ if (!b || !(b.width > 0) || !(b.height > 0)) return;
554
+ var o = { id: r[0], x: Math.round(b.left), y: Math.round(b.top), w: Math.round(b.width), h: Math.round(b.height) };
555
+ if (r[2]) o.fades = true;
556
+ out.push(o);
557
+ });
558
+ return out;
559
+ }
560
+ function tellRects() {
561
+ if (!state.attached) return;
562
+ var w = typeof innerWidth === 'number' ? innerWidth : 0;
563
+ var h = typeof innerHeight === 'number' ? innerHeight : 0;
564
+ var msg = { t: 'homie-shell', device: device, orientation: w > h ? 'landscape' : 'portrait', width: w, height: h, rects: shellRects() };
565
+ var sig = JSON.stringify(msg);
566
+ if (sig === lastRects) return;
567
+ lastRects = sig;
568
+ state.rects = msg;
569
+ try { frame.contentWindow.postMessage(msg, '*'); } catch (e) {}
570
+ }
571
+
450
572
  function onRound(r) {
451
573
  if (!r) return;
452
574
  state.round = r;
@@ -541,8 +663,8 @@ const SHELL_JS = String.raw`(function () {
541
663
  var label = labelOf(state.room || '');
542
664
  var box = document.createElement('div'); box.className = 'notice'; box.setAttribute('data-notice', kind); box.setAttribute('role', 'alertdialog'); box.setAttribute('data-keep-focus', '');
543
665
  var inner = document.createElement('div'); inner.className = 'box';
544
- var h = document.createElement('h1'); h.textContent = kind === 'kicked' ? 'You were removed from this room' : 'This room is closed';
545
- var p = document.createElement('p'); p.textContent = state.notice.message || (kind === 'kicked' ? 'The studio removed you from this room.' : 'The studio closed this room. Thanks for playing!');
666
+ var h = document.createElement('h1'); h.textContent = kind === 'kicked' ? 'You were removed from this room' : kind === 'full' ? 'This room is full' : 'This room is closed';
667
+ var p = document.createElement('p'); p.textContent = kind === 'full' ? label + ' has no place left, to play or to watch. Another room is one tap away.' : state.notice.message || (kind === 'kicked' ? 'The studio removed you from this room.' : 'The studio closed this room. Thanks for playing!');
546
668
  var w = document.createElement('p'); w.className = 'when';
547
669
  w.textContent = mins ? (kind === 'kicked' ? 'You can come back to ' + label + ' in ' + mins + ' min.' : label + ' opens again in ' + mins + ' min.') : '';
548
670
  var acts = document.createElement('div'); acts.className = 'acts';
@@ -551,7 +673,7 @@ const SHELL_JS = String.raw`(function () {
551
673
  acts.append(other, back);
552
674
  inner.append(h, p, w, acts); box.appendChild(inner);
553
675
  document.body.appendChild(box);
554
- say(kind === 'kicked' ? 'removed from this room' : 'room closed');
676
+ say(kind === 'kicked' ? 'removed from this room' : kind === 'full' ? 'room full' : 'room closed');
555
677
  }
556
678
 
557
679
  function paint() {
@@ -565,7 +687,12 @@ const SHELL_JS = String.raw`(function () {
565
687
  if (aiN) bits.push(aiN + ' AI');
566
688
  if (f && f.counts && f.counts.watchers) bits.push(f.counts.watchers + ' watching');
567
689
  if (state.full) bits.push('waiting for a seat');
568
- if (state.closed) bits.push(state.closed === 'replaced' ? 'opened in another tab' : 'reconnecting');
690
+ // Stopped for good: nothing is knocking any more, so it never says "reconnecting". What is true, in a word.
691
+ if (state.closed) bits.push({ replaced: 'opened in another tab', 'room-full': 'room full', 'too-many': 'room full', stale: 'updating…', kicked: 'removed from this room', 'room-closed': 'room closed', 'agents-off': 'room closed' }[state.closed] || 'disconnected: reload to play');
692
+ // The link (section 22): cut off from a room it was in and knocking again ("reconnecting" is true only then), or
693
+ // playing by itself because the room never answered, or did not come back in time.
694
+ else if (state.link && state.link.state === 'reconnecting') bits.push('reconnecting');
695
+ else if (state.link && state.link.state === 'alone') bits.push(state.link.why === 'reconnect-timeout' ? 'playing on your own: the room dropped' : 'playing on your own: looking for the room');
569
696
  // The arrival card's line: the room and who is in it ("Room 2 · 3 playing · 2 AI").
570
697
  if (!A.done && state.room) A.facts([labelOf(state.room), n ? n + ' playing' : '', aiN ? aiN + ' AI' : ''].filter(Boolean).join(' · '));
571
698
  say(bits.join(' · ') || 'joining…');
@@ -899,7 +1026,7 @@ html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: non
899
1026
  body { --u: clamp(12px, calc(0.8vmin + 7px), 22px); --band: #080b14; --line: rgba(255,255,255,.08); display: flex; flex-direction: column; font: 600 var(--u)/1.25 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
900
1027
  body.big { --u: clamp(16px, calc(1vmin + 6px), 32px); }
901
1028
  .stage { position: relative; flex: 1; min-height: 0; background: #04060c; }
902
- iframe.game { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; }
1029
+ iframe.game { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
903
1030
  .wtop { flex: none; display: flex; align-items: center; gap: calc(var(--u) * .7); padding: max(calc(var(--u) * .5), env(safe-area-inset-top)) max(calc(var(--u) * .9), env(safe-area-inset-right)) calc(var(--u) * .5) max(calc(var(--u) * .9), env(safe-area-inset-left)); background: linear-gradient(#0b0f1a, var(--band)); border-bottom: 1px solid var(--line); min-width: 0; }
904
1031
  .grow { flex: 1; }
905
1032
  .live { flex: none; display: inline-flex; align-items: center; gap: .45em; padding: .3em .7em .3em .6em; border-radius: 999px; background: rgba(255,59,92,.16); border: 1px solid rgba(255,90,120,.5); font-weight: 800; font-size: .74em; letter-spacing: .14em; text-transform: uppercase; color: #ffd9e0; }