@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
@@ -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;
@@ -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,10 +452,26 @@ 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;
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
+ // The game's own word that it was playable (section 21), beside who lifted the card: a late one is a game whose
459
+ // arrival should be 'game'. A performance probe reads both here.
460
+ if (m.what === 'ready') {
461
+ 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()) };
462
+ if (A.state) { A.state.explicitMs = state.ready.atMs; A.state.lateMs = state.ready.lateMs; }
463
+ }
464
+ if (m.what === 'prefs') answerPrefs(m);
465
+ // A newer build of the game is live. Kept out of a room for it (final): the game loads again now. Still
466
+ // playing in its own room: it loads again at the round's break, or at once on a big screen (nothing to lose).
467
+ if (m.what === 'stale') {
468
+ state.stale = { ver: typeof m.ver === 'string' ? m.ver : null, final: m.final === true };
469
+ if (m.final === true || screenMode) reloadGame();
470
+ else flash('A new version of ' + boot.name + ' is ready: it loads after this round.');
471
+ }
432
472
  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(); }
433
473
  if (m.what === 'stats') state.stats = m.stats;
434
- if (m.what === 'round') onRound(m.round);
474
+ if (m.what === 'round') { onRound(m.round); if (state.stale && m.round && m.round.phase === 'over') reloadGame(); }
435
475
  if (m.what === 'roster') state.roster = m.slots;
436
476
  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); }
437
477
  // A game's net.pickPlayer(seat): only an owner's page listens (its overlay opens that player's card).
@@ -440,6 +480,8 @@ const SHELL_JS = String.raw`(function () {
440
480
  });
441
481
  watch(room);
442
482
  shareReady(room);
483
+ addEventListener('resize', tellRects);
484
+ if (typeof setInterval === 'function') setInterval(tellRects, 1000);
443
485
  if (screenMode && !document.querySelector('[data-join]') && !LOCAL) {
444
486
  var h2 = document.createElement('h2'); h2.textContent = 'Join on your phone';
445
487
  var div = document.createElement('div'); div.textContent = location.origin + '/' + boot.game + '/play?room=' + encodeURIComponent(room);
@@ -447,6 +489,79 @@ const SHELL_JS = String.raw`(function () {
447
489
  }
448
490
  }
449
491
 
492
+ /*
493
+ * NET.PREFS (NETPLAY.md section 24). The game's frame is an opaque origin (no allow-same-origin: a stranger's game
494
+ * must never read this site's storage, cookies or the owner's session), so its own localStorage throws. This page
495
+ * keeps a few settings for it instead: one JSON object per game under this site's storage, 16 KB and 32 keys at
496
+ * most, read and written only for this page's own game. With no storage (a private window): for the visit.
497
+ */
498
+ var PREFS_KEY = 'homie-prefs.' + boot.game;
499
+ var PREFS = boot.prefs || { bytes: 16384, keys: 32, key: 64 };
500
+ var prefsMem = null;
501
+ function prefsRead() {
502
+ if (prefsMem) return prefsMem;
503
+ try { var o = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}'); return o && typeof o === 'object' && !Array.isArray(o) ? o : {}; } catch (e) { prefsMem = {}; return prefsMem; }
504
+ }
505
+ function prefsWrite(o) {
506
+ var text = JSON.stringify(o);
507
+ if (typeof text !== 'string' || text.length > PREFS.bytes || Object.keys(o).length > PREFS.keys) return 'too-large';
508
+ if (prefsMem) { prefsMem = o; return 'memory'; }
509
+ try { localStorage.setItem(PREFS_KEY, text); return 'kept'; } catch (e) { prefsMem = o; return 'memory'; }
510
+ }
511
+ function answerPrefs(m) {
512
+ var out = { t: 'homie-prefs', n: m.n, ok: false };
513
+ try {
514
+ var all = prefsRead();
515
+ var k = m.k;
516
+ var okKey = typeof k === 'string' && k.length > 0 && k.length <= PREFS.key && k !== '__proto__';
517
+ if (m.op === 'all') { out.ok = true; out.all = all; }
518
+ else if (!okKey) out.why = 'key';
519
+ else if (m.op === 'set' || m.op === 'del') {
520
+ var next = {};
521
+ Object.keys(all).forEach(function (x) { if (x !== k) next[x] = all[x]; });
522
+ if (m.op === 'set' && m.v !== null && m.v !== undefined) next[k] = m.v;
523
+ var how = prefsWrite(next);
524
+ if (how === 'too-large') out.why = how; else { out.ok = true; out.kept = how; }
525
+ } else out.why = 'op';
526
+ } catch (e) { out.why = 'error'; }
527
+ state.prefs = { op: m.op, ok: out.ok, why: out.why || null };
528
+ try { frame.contentWindow.postMessage(out, '*'); } catch (e2) {}
529
+ }
530
+
531
+ /*
532
+ * WHERE THIS PAGE'S OWN CONTROLS SIT OVER THE GAME (section 24). The frame fills the page, so these rectangles are
533
+ * in the game's own CSS pixels. A game's HUD check that only measures inside the frame cannot see them: the room
534
+ * button, the server pill, the chat pill, the "3 playing" chip (it fades, and comes back), the big screen's join
535
+ * card, the studio's banner. Said to the helper when it attaches, on a resize or a turn, and when one moves.
536
+ */
537
+ 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]']];
538
+ var lastRects = '';
539
+ function shellRects() {
540
+ var out = [];
541
+ RECTS.forEach(function (r) {
542
+ var el = null;
543
+ try { el = document.querySelector(r[1]); } catch (e) { el = null; }
544
+ if (!el || el.hidden || typeof el.getBoundingClientRect !== 'function') return;
545
+ var b = el.getBoundingClientRect();
546
+ if (!b || !(b.width > 0) || !(b.height > 0)) return;
547
+ 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) };
548
+ if (r[2]) o.fades = true;
549
+ out.push(o);
550
+ });
551
+ return out;
552
+ }
553
+ function tellRects() {
554
+ if (!state.attached) return;
555
+ var w = typeof innerWidth === 'number' ? innerWidth : 0;
556
+ var h = typeof innerHeight === 'number' ? innerHeight : 0;
557
+ var msg = { t: 'homie-shell', device: device, orientation: w > h ? 'landscape' : 'portrait', width: w, height: h, rects: shellRects() };
558
+ var sig = JSON.stringify(msg);
559
+ if (sig === lastRects) return;
560
+ lastRects = sig;
561
+ state.rects = msg;
562
+ try { frame.contentWindow.postMessage(msg, '*'); } catch (e) {}
563
+ }
564
+
450
565
  function onRound(r) {
451
566
  if (!r) return;
452
567
  state.round = r;
@@ -566,6 +681,9 @@ const SHELL_JS = String.raw`(function () {
566
681
  if (f && f.counts && f.counts.watchers) bits.push(f.counts.watchers + ' watching');
567
682
  if (state.full) bits.push('waiting for a seat');
568
683
  if (state.closed) bits.push(state.closed === 'replaced' ? 'opened in another tab' : 'reconnecting');
684
+ // The link (section 22): cut off from the room and knocking again, or playing alone because it never answered.
685
+ else if (state.link && state.link.state === 'reconnecting') bits.push('reconnecting');
686
+ else if (state.link && state.link.state === 'alone') bits.push('offline, reconnecting');
569
687
  // The arrival card's line: the room and who is in it ("Room 2 · 3 playing · 2 AI").
570
688
  if (!A.done && state.room) A.facts([labelOf(state.room), n ? n + ' playing' : '', aiN ? aiN + ' AI' : ''].filter(Boolean).join(' · '));
571
689
  say(bits.join(' · ') || 'joining…');
@@ -899,7 +1017,7 @@ html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: non
899
1017
  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
1018
  body.big { --u: clamp(16px, calc(1vmin + 6px), 32px); }
901
1019
  .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; }
1020
+ 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
1021
  .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
1022
  .grow { flex: 1; }
905
1023
  .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; }