@homie-rocks/studio 0.35.1 → 0.37.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 (89) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +4 -2
  3. package/agents/agents.ts +8 -6
  4. package/app-starters/welcome/app.json +106 -0
  5. package/app-starters/welcome/index.html +4 -0
  6. package/app-starters/welcome/src/main.ts +107 -0
  7. package/apps/APPS.md +61 -0
  8. package/apps/apps.ts +28 -0
  9. package/bin/homie-studio.mjs +22 -10
  10. package/lab/lab.ts +15 -0
  11. package/lib/art-cli.mjs +2 -2
  12. package/lib/art-tools.mjs +6 -6
  13. package/lib/asset-check.mjs +3 -2
  14. package/lib/asset-import.mjs +3 -2
  15. package/lib/asset-manifest.mjs +8 -8
  16. package/lib/build.mjs +26 -18
  17. package/lib/characters.mjs +7 -7
  18. package/lib/check.mjs +49 -0
  19. package/lib/cloudflare.mjs +1 -1
  20. package/lib/codex.mjs +2 -2
  21. package/lib/collision.mjs +1 -1
  22. package/lib/decisions.mjs +7 -7
  23. package/lib/dev.mjs +42 -10
  24. package/lib/directory.mjs +1 -1
  25. package/lib/doctor.mjs +1 -1
  26. package/lib/handoff.mjs +1 -1
  27. package/lib/lab-check.mjs +18 -3
  28. package/lib/lab.mjs +29 -15
  29. package/lib/library.mjs +2 -2
  30. package/lib/mcp-tools.mjs +23 -3
  31. package/lib/parts-build.mjs +4 -3
  32. package/lib/parts-cli.mjs +3 -3
  33. package/lib/parts-store.mjs +8 -7
  34. package/lib/parts-tools.mjs +7 -7
  35. package/lib/parts.mjs +19 -14
  36. package/lib/perf.mjs +2 -1
  37. package/lib/player-image.mjs +2 -2
  38. package/lib/rules-build.mjs +97 -49
  39. package/lib/rules-check-worker.mjs +405 -0
  40. package/lib/rules-check.mjs +69 -0
  41. package/lib/rules-compiler.mjs +30 -0
  42. package/lib/rules-guard.mjs +26 -7
  43. package/lib/rules-types.mjs +167 -0
  44. package/lib/scaffold.mjs +21 -2
  45. package/lib/shop.mjs +1 -1
  46. package/lib/standalone-files.mjs +2 -2
  47. package/lib/standalone.mjs +12 -3
  48. package/lib/stats.mjs +2 -1
  49. package/lib/studio.mjs +32 -6
  50. package/lib/style-board.mjs +10 -9
  51. package/lib/template-history.json +85 -1
  52. package/lib/typecheck.mjs +52 -3
  53. package/links/links.d.ts +3 -0
  54. package/links/links.mjs +16 -0
  55. package/netplay/NETPLAY.md +123 -6
  56. package/netplay/netplay.ts +289 -53
  57. package/netplay/rules-output.mjs +85 -0
  58. package/package.json +16 -4
  59. package/parts/PARTS.md +10 -5
  60. package/rules/browser.ts +100 -0
  61. package/rules/core.ts +159 -58
  62. package/rules/guard.ts +11 -4
  63. package/rules/host.ts +90 -15
  64. package/rules/math.ts +16 -0
  65. package/rules/pack.ts +94 -17
  66. package/rules/rules.ts +21 -13
  67. package/rules/types.ts +63 -0
  68. package/rules/view.ts +36 -33
  69. package/shop/SHOP.md +13 -0
  70. package/site/SITE.md +1 -1
  71. package/standalone/shell/shell.js +8 -6
  72. package/worker/account-page.mjs +4 -3
  73. package/worker/app-format.mjs +42 -0
  74. package/worker/app-records.mjs +100 -0
  75. package/worker/app-shell.mjs +29 -0
  76. package/worker/brain.mjs +2 -1
  77. package/worker/embed.mjs +4 -2
  78. package/worker/hosted.mjs +1 -1
  79. package/worker/index.mjs +40 -20
  80. package/worker/limits.mjs +41 -0
  81. package/worker/pages.mjs +37 -29
  82. package/worker/parts.mjs +11 -9
  83. package/worker/room.mjs +136 -63
  84. package/worker/schema.mjs +6 -5
  85. package/worker/seats.mjs +2 -2
  86. package/worker/shop-page.mjs +4 -3
  87. package/worker/site.mjs +49 -40
  88. package/worker/standalone.mjs +2 -2
  89. package/worker/version.mjs +1 -1
package/worker/parts.mjs CHANGED
@@ -38,11 +38,12 @@ export async function partsIndex(env, origin) {
38
38
  * Only a PUBLIC game is named (a private or invite-only game's parts are still shared, without saying where from).
39
39
  */
40
40
  function entryOf(p, cat, url) {
41
- const g = p.from?.game ? (cat.games ?? []).find((x) => x.id === p.from.game) : null;
41
+ const g = (p.from?.game ?? p.from?.app) ? (cat.games ?? []).find((x) => x.id === (p.from.game ?? p.from.app)) : null;
42
42
  const { from, ...rest } = p;
43
43
  return {
44
44
  ...rest,
45
- ...(g ? { from: { game: g.id, name: g.name ?? g.id, studio: cat.studio?.name ?? from.studio ?? null, page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play` } } : {}),
45
+ ...(!g && from && (from.music || from.video) ? { from: { ...(from.music ? { music: from.music } : { video: from.video }), name: from.name ?? null, studio: from.studio ?? null } } : {}),
46
+ ...(g ? { from: { [g.kind === 'app' ? 'app' : 'game']: g.id, name: g.name ?? g.id, studio: cat.studio?.name ?? from.studio ?? null, page: `${url.origin}/${g.id}/`, [g.kind === 'app' ? 'open' : 'play']: `${url.origin}/${g.id}/${g.kind === 'app' ? 'open' : 'play'}` } } : {}),
46
47
  add: `${url.host}/${p.id}`,
47
48
  };
48
49
  }
@@ -131,21 +132,21 @@ const CSS = `<style>
131
132
  </style>`;
132
133
  const head = (kicker, title, lead, extra = '') => `<header class="head"><p class="kicker">${esc(kicker)}</p><h1>${esc(title)}</h1>${lead ? `<p class="lead">${esc(lead)}</p>` : ''}${extra}</header>`;
133
134
  const studioOf = (cat) => cat.studio?.name ?? 'Studio';
134
- const fromLine = (p) => (p.from?.game ? `From <a href="/${esc(p.from.game)}/">${esc(p.from.name ?? p.from.game)}</a>` : '');
135
+ const fromLine = (p) => ((p.from?.game ?? p.from?.app) ? `From <a href="/${esc((p.from.game ?? p.from.app))}/">${esc(p.from.name ?? (p.from.game ?? p.from.app))}</a>` : '');
135
136
  /** What a person says to their AI to get the part, in plain words with its name and reference: no command to type. */
136
137
  const sayAdd = (p) => `Add the "${p.name}" part from ${p.add} to my game`;
137
138
  const sayRow = (label, say) => `<div class="psay"><div><span class="ptag">${esc(label)}</span><code>${esc(say)}</code></div><button class="pcopy" type="button" data-copy="${esc(say)}">${icon('copy')}<span data-copy-word>Copy</span></button></div>`;
138
139
 
139
140
  function card(p) {
140
141
  const img = p.preview?.image ? `<img src="${esc(`${p.url}${p.preview.image}`)}" alt="" loading="lazy">` : '';
141
- return `<li><a class="pcard" href="/parts/${esc(p.id)}/">${img}<span class="ptag">${esc(kindOf(p))} · ${esc(p.license ?? '')}</span><h3>${esc(p.name)}</h3><p>${esc(p.summary ?? '')}</p>${p.from?.game ? `<p>From ${esc(p.from.name ?? p.from.game)}</p>` : ''}</a></li>`;
142
+ return `<li><a class="pcard" href="/parts/${esc(p.id)}/">${img}<span class="ptag">${esc(kindOf(p))} · ${esc(p.license ?? '')}</span><h3>${esc(p.name)}</h3><p>${esc(p.summary ?? '')}</p>${(p.from?.game ?? p.from?.app) ? `<p>From ${esc(p.from.name ?? (p.from.game ?? p.from.app))}</p>` : ''}</a></li>`;
142
143
  }
143
144
 
144
145
  export function partsPage(cat, parts, url) {
145
146
  const name = studioOf(cat);
146
147
  // Grouped by the game each came out of: a mashup starts from "the creature in that game".
147
148
  const groups = new Map();
148
- for (const p of parts) { const k = p.from?.game ?? ''; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(p); }
149
+ for (const p of parts) { const k = (p.from?.game ?? p.from?.app) ?? ''; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(p); }
149
150
  const body = [...groups].sort(([a], [b]) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b))).map(([k, list]) => `<p class="sec"${k ? ` id="from-${esc(k)}"` : ''}>${k ? `From ${esc(list[0].from.name ?? k)}` : 'More parts'}</p><ul class="pgrid">${list.map(card).join('')}</ul>`).join('');
150
151
  return layout(cat, {
151
152
  title: `Game parts · ${name}`, description: `Pieces of ${name}'s games that other studios can use in their own: creatures, levels, mechanics, sounds. Each with its licence.`, origin: url.origin, path: '/parts/', page: 'parts', head: CSS,
@@ -213,7 +214,8 @@ export function partPage(cat, p, url) {
213
214
  : p.preview?.image ? `<img class="pframe" src="${esc(`${p.url}${p.preview.image}`)}" alt="${esc(p.name)}">` : '';
214
215
  const facts = [
215
216
  ['Kind', esc(kindOf(p))],
216
- p.from?.game ? ['From the game', `<a href="/${esc(p.from.game)}/">${esc(p.from.name ?? p.from.game)}</a> · <a href="/${esc(p.from.game)}/play">Play it</a>`] : null,
217
+ p.uses?.length ? ['For', esc(p.uses.join(', '))] : null,
218
+ (p.from?.game ?? p.from?.app) ? [p.from.app ? 'From the app' : 'From the game', `<a href="/${esc((p.from.game ?? p.from.app))}/">${esc(p.from.name ?? (p.from.game ?? p.from.app))}</a> · <a href="/${esc((p.from.game ?? p.from.app))}/${p.from.app ? 'open' : 'play'}">${p.from.app ? 'Open it' : 'Play it'}</a>`] : null,
217
219
  ['Licence', `${esc(p.license ?? 'none named')}${p.license && !/^LicenseRef-/.test(p.license) ? ` · <a href="https://spdx.org/licenses/${esc(p.license)}.html" rel="noopener">what it allows</a>` : ''}`],
218
220
  p.attribution ? ['Credit', esc(p.attribution)] : null,
219
221
  ['Version', `${esc(p.version)}${(p.versions ?? []).length > 1 ? ` (also ${esc(p.versions.filter((v) => v !== p.version).join(', '))})` : ''}`],
@@ -228,7 +230,7 @@ export function partPage(cat, p, url) {
228
230
  return layout(cat, {
229
231
  title: `${p.name} · a game part from ${name}`, description: `${p.summary ?? ''} A ${kindOf(p).toLowerCase()} from ${name}${p.from?.name ? `'s ${p.from.name}` : ''}, shared under ${p.license ?? 'its own terms'}.`.trim(), origin: url.origin, path: `/parts/${p.id}/`, page: 'part', head: CSS,
230
232
  image: p.preview?.image ? `${p.url}${p.preview.image}` : null,
231
- main: `${head(`${kindOf(p)} · game part`, p.name, p.summary ?? '', `<div class="keys"><a class="ghost" href="/parts/">${icon('arrow')}<span>All parts</span></a>${p.from?.game ? `<a class="ghost" href="/${esc(p.from.game)}/play">${icon('games')}<span>Play ${esc(p.from.name ?? p.from.game)}</span></a>` : ''}</div>`)}
233
+ main: `${head(`${kindOf(p)} · shared part`, p.name, p.summary ?? '', `<div class="keys"><a class="ghost" href="/parts/">${icon('arrow')}<span>All parts</span></a>${(p.from?.game ?? p.from?.app) ? `<a class="ghost" href="/${esc((p.from.game ?? p.from.app))}/${p.from.app ? 'open' : 'play'}">${icon('games')}<span>${p.from.app ? 'Open' : 'Play'} ${esc(p.from.name ?? (p.from.game ?? p.from.app))}</span></a>` : ''}</div>`)}
232
234
  <div class="wrap">
233
235
  ${preview ? `<p class="sec">Try it</p>${preview}` : ''}
234
236
  <p class="sec">Use it in your game</p>
@@ -248,7 +250,7 @@ ${sayRow('Say', sayAdd(p))}
248
250
  * build something new from them. Empty when the game shared none. It stands by itself: its own markup and styles.
249
251
  */
250
252
  export function partsBand(g, parts, host) {
251
- const mine = parts.filter((p) => p.from?.game === g.id);
253
+ const mine = parts.filter((p) => (p.from?.game ?? p.from?.app) === g.id);
252
254
  if (!mine.length) return '';
253
255
  const say = mine.length === 1 ? sayAdd({ ...mine[0], add: `${host}/${mine[0].id}` }) : `Make a new game in my Homie studio with these parts of ${g.name}: ${mine.slice(0, 4).map((p) => `${host}/${p.id}`).join(', ')}`;
254
256
  return `${CSS}<section class="pband" aria-labelledby="parts-title" data-parts-band>
@@ -266,7 +268,7 @@ export async function withPartsBand(res, env, url, g) {
266
268
  try {
267
269
  if (!res || res.status !== 200 || !/text\/html/i.test(res.headers.get('content-type') ?? '')) return res;
268
270
  const index = await partsIndex(env, url.origin);
269
- const band = partsBand(g, index.parts.map((p) => ({ ...p, from: p.from?.game === g.id ? { ...p.from, name: g.name } : p.from })), url.host);
271
+ const band = partsBand(g, index.parts.map((p) => ({ ...p, from: (p.from?.game ?? p.from?.app) === g.id ? { ...p.from, name: g.name } : p.from })), url.host);
270
272
  if (!band) return res;
271
273
  const html = await res.text();
272
274
  const at = html.lastIndexOf('</main>');
package/worker/room.mjs CHANGED
@@ -49,13 +49,15 @@
49
49
  * who came and went) and fans out each frame it hands back (`hostFrame`). Everything else here is as it was.
50
50
  * =============================================================================
51
51
  */
52
+ import { LIMITS, capOf, RATES, EV_RATES, STATE_CAPS, rulesRates, RULES_FRAMES, takeRulesToken } from './limits.mjs';
53
+ export { LIMITS, GROWS, capOf } from './limits.mjs';
52
54
  import { aiName, skillPreset, stripAi } from './agents.mjs';
53
55
  import { DECIDE, checkArgs, checkDecide, talks, vocabularyOf } from './brain.mjs';
54
56
  import { CHAT_LIMITS, CHAT_RATES, HELD_WORDS, allows, cleanText, floor, normalizeChat, publicChat, windowMsOf } from './chat.mjs';
55
57
 
56
58
  export const NET_VERSION = 1;
57
59
  /** The contract revision this relay speaks (NETPLAY.md): optional fields, frames and refusals; the wire stays `v: 1`. */
58
- export const NET_REVISION = 10;
60
+ export const NET_REVISION = 11;
59
61
  /**
60
62
  * Revision 9 (NETPLAY.md sections 22 and 23). STALL: how long a host may send no snapshot, while others are present,
61
63
  * before the room is handed on: 1.5 s unless the game names its own (game.json `netplay.stallMs`), never under
@@ -143,30 +145,13 @@ export function medianVote(list) {
143
145
  const v = [...list].filter(Number.isFinite).sort((a, b) => a - b);
144
146
  return v.length ? v[Math.floor((v.length - 1) / 2)] : null;
145
147
  }
148
+ const utf8 = new TextEncoder();
149
+ const byteLength = text => utf8.encode(text).length;
146
150
  const PALETTE_SIZE = 12;
147
151
  const DEVICE_RANK = { desk: 0, tv: 1, phone: 2 };
148
152
 
149
- /** Size caps (bytes of the JSON text as received), for a room of up to 16 seats. */
150
- export const LIMITS = Object.freeze({
151
- hello: 2048, snap: 16384, in: 2048, ev: 4096, ckpt: 65536, state: 8192, round: 8192, roster: 4096, ping: 256, other: 512,
152
- // Room chat (revision 8): a typed line (280 characters, emoji count double), a reaction.
153
- say: 1536, react: 256,
154
- // A game's own decision (section 20): a few typed questions about its state.
155
- decide: DECIDE.bytes,
156
- });
157
- /**
158
- * What grows with the seats (every body, every result, every slot): the checkpoint, round and roster caps double
159
- * for a room of 17 to 32 (revision 3). Measured: a 32-seat courier game's checkpoint reached 56 KB of the 64 KB
160
- * a 16-seat room allows. The snapshot cap does not grow: 32 bodies are about 1.5 KB.
161
- */
162
- export const GROWS = Object.freeze(['ckpt', 'round', 'roster']);
163
- export const capOf = (t, seats) => (LIMITS[t] ?? LIMITS.other) * (GROWS.includes(t) && seats > 16 ? 2 : 1);
164
153
  /** Rate caps (messages per rolling second, per client). Over the cap a message is dropped, counted and reported. */
165
- export const RATES = Object.freeze({ snap: 30, in: 60, ev: 30, ckpt: 4, state: 64, round: 4, roster: 8, ping: 8, other: 8, say: 4, react: 10, decide: 2 });
166
- /** `ev` per second by the sender's role: a screen is a spectator, and the host is somebody's phone. */
167
- export const EV_RATES = Object.freeze({ host: 30, replica: 10, screen: 2 });
168
- /** The keyed state channel: at most this many keys and bytes per room. */
169
- export const STATE_CAPS = Object.freeze({ keys: 64, bytes: 65536 });
154
+ export { RATES, EV_RATES, STATE_CAPS } from './limits.mjs';
170
155
 
171
156
  /**
172
157
  * THE OWNER'S CONTROLS (revision 4, NETPLAY.md section 15). The studio's Worker, acting for the signed-in owner,
@@ -272,7 +257,7 @@ export class NetRoom {
272
257
  * each stamped `at` (ISO time) with its room; never a token, a ticket, a browser key or a frame's contents
273
258
  */
274
259
  constructor({
275
- code, maxPlayers = 8, maxScreens = 16, perIp = 12, stallMs = 1500, silentMs = 2500, idleMs = 10_000,
260
+ code, rules = false, tickHz = 20, maxPlayers = 8, maxScreens = 16, perIp = 12, stallMs = 1500, silentMs = 2500, idleMs = 10_000,
276
261
  holdMs = 60_000, forgetMs = 60_000, persistMs = 30_000, store = null, now = Date.now, log = () => {},
277
262
  agentsAloneMs = AGENTS_ALONE_MS, voteMs = VOTE.ms, voteCooldownMs = VOTE.cooldownMs, autoVoteMs = VOTE.autoMs,
278
263
  }) {
@@ -294,6 +279,8 @@ export class NetRoom {
294
279
  /** Revision 7: the game's agents.json (the Table reads it once), the only words an AI here may say. */
295
280
  this.vocab = null;
296
281
  this.code = code;
282
+ this.rules = rules === true;
283
+ this.tickHz = tickHz;
297
284
  this.seatCap = maxPlayers;
298
285
  this.maxPlayers = maxPlayers;
299
286
  this.maxScreens = maxScreens;
@@ -431,12 +418,11 @@ export class NetRoom {
431
418
 
432
419
  /** A seat the host runtime knows that is no longer held (its hold ran out, the owner removed its player): its body's seat is given up. */
433
420
  syncServerSeats() {
434
- if (!this.server) return;
435
421
  for (const [seat, occ] of [...this.hosted]) {
436
422
  const s = this.seats.get(seat);
437
423
  if (s && (s.occ ?? null) === occ) continue;
438
424
  this.hosted.delete(seat);
439
- this.toServer({ t: 'free', seat });
425
+ if (this.server) this.toServer({ t: 'free', seat }); else if (this.rules && this.host()?.rules) this.send(this.host(), { t: 'free', seat });
440
426
  }
441
427
  }
442
428
 
@@ -458,7 +444,7 @@ export class NetRoom {
458
444
  const kind = String(m.k ?? '').slice(0, 64);
459
445
  if (SPEECH.test(kind) && (this.policy.speech === 'off' || (this.policy.speech === 'lines' && /^chat/i.test(kind)))) { this.stats.speechDrops += 1; return; }
460
446
  if (d0(m).ai === true && !this.aiLineOk(kind, d0(m))) { this.stats.agentDrops += 1; return; }
461
- if (kind === 'agent:view' && !this.agentViewOk(m, JSON.stringify(m).length, now, true)) return;
447
+ if (kind === 'agent:view' && !this.agentViewOk(m, byteLength(JSON.stringify(m)), now, true)) return;
462
448
  this.stats.evs += 1;
463
449
  const out = { t: 'ev', from: Number.isInteger(m.from) ? m.from : null, k: kind, d: m.d ?? null };
464
450
  const line = JSON.stringify(out);
@@ -476,7 +462,7 @@ export class NetRoom {
476
462
  const snap = { k: Number(m.k) || 0, st: c ? this.stamp(m.st) : Number(m.st) || now, d: m.d ?? null };
477
463
  if (Array.isArray(m.c)) snap.c = m.c.slice(0, 64);
478
464
  // Revision 10: a rules game's snapshot names the room's epoch.
479
- if (!c && Number.isFinite(m.e)) snap.e = m.e;
465
+ if ((!c || this.rules && c.rules) && Number.isFinite(m.e)) snap.e = m.e;
480
466
  this.lastSnap = snap;
481
467
  if (c) c.lastSnapAt = now;
482
468
  this.stats.snaps += 1;
@@ -507,6 +493,7 @@ export class NetRoom {
507
493
  } else {
508
494
  if (c && ((!prev && this.state.size >= STATE_CAPS.keys) || this.stateBytes - (prev?.bytes ?? 0) + bytes > STATE_CAPS.bytes)) {
509
495
  this.error(c, 'state-full', `state is capped at ${STATE_CAPS.keys} keys and ${STATE_CAPS.bytes} B`);
496
+ this.stopBrowserRules(c, 'size', `This game's rules exceeded the browser room state cap: ${STATE_CAPS.keys} keys and ${STATE_CAPS.bytes} B. Join again for a fresh room.`);
510
497
  return;
511
498
  }
512
499
  this.state.set(key, { d: m.d, bytes });
@@ -515,13 +502,14 @@ export class NetRoom {
515
502
  this.stats.states += 1;
516
503
  this.persistDirty = true;
517
504
  const out = JSON.stringify({ t: 'state', k: key, d: m.d ?? null });
518
- for (const o of this.others(c)) if (!this.lite(o)) this.sendText(o.conn, out);
505
+ for (const o of (c && this.rules && c.rules ? this.live() : this.others(c))) if (!this.lite(o)) this.sendText(o.conn, out);
519
506
  }
520
507
 
521
508
  hostRound(c, m) {
522
509
  if (!m.round || typeof m.round !== 'object') return;
510
+ const retime = Boolean(m.retime && (this.server || this.rules && c?.rules));
523
511
  // The results name every AI as AI, whatever the host's game says (section 17).
524
- const round = Array.isArray(m.round.results) ? { ...m.round, results: this.labelResults(m.round.results) } : m.round;
512
+ const round = retime && this.lastRound?.n === m.round.n && this.lastRound?.phase === m.round.phase ? { ...m.round, ...(this.lastRound.results ? { results: this.lastRound.results } : {}) } : Array.isArray(m.round.results) ? { ...m.round, results: this.labelResults(m.round.results) } : m.round;
525
513
  const wasLive = this.lastRound && this.lastRound.phase === 'live' ? Number(this.lastRound.n) : null;
526
514
  this.lastRound = round;
527
515
  // The round a launch change waits for is over: everyone sees its results for a moment, then the room re-gates.
@@ -529,18 +517,22 @@ export class NetRoom {
529
517
  if (this.agentsOut && round.phase === 'over' && (this.agentsOut.roundN === null || Number(round.n) >= this.agentsOut.roundN)) this.agentsOut.until = Math.min(this.agentsOut.until, this.now() + 5000);
530
518
  this.stats.rounds += 1;
531
519
  this.persistDirty = true;
532
- for (const o of this.others(c)) this.send(o, { t: 'round', round });
520
+ for (const o of (c && this.rules && c.rules ? this.live() : this.others(c))) this.send(o, { t: 'round', round: retime ? m.round : round, ...(retime ? { retime: true } : {}) });
533
521
  // A party's first live round with AI seats: the vote on how strong the AI should be (once per 30 minutes).
534
522
  if (round.phase === 'live' && wasLive !== Number(round.n)) this.autoVote();
535
523
  this.tellWatchers();
536
524
  }
537
525
 
526
+ heldPeers() {
527
+ return [...this.seats].filter(([, s]) => !s.present).map(([seat, s]) => ({ id: `held-${s.occ}`, seat, occ: s.occ, name: s.name, ...(s.agent ? { agent: s.agent } : {}) }));
528
+ }
529
+
538
530
  hostRoster(c, m) {
539
531
  if (!Array.isArray(m.slots)) return;
540
532
  // A host cannot hide an AI: a seat an agent holds is named and marked so, a slot with no seat is a bot.
541
533
  this.lastRoster = this.labelRoster(m.slots.slice(0, 64));
542
534
  this.persistDirty = true;
543
- for (const o of this.others(c)) this.send(o, { t: 'roster', slots: this.lastRoster });
535
+ for (const o of (c && this.rules && c.rules ? this.live() : this.others(c))) this.send(o, { t: 'roster', slots: this.lastRoster });
544
536
  this.tellWatchers();
545
537
  }
546
538
 
@@ -646,7 +638,7 @@ export class NetRoom {
646
638
  /* ------------------------------------------------------------ wire */
647
639
 
648
640
  sendText(conn, text) {
649
- try { conn.send(text); this.stats.bytesOut += text.length; } catch { /* closing */ }
641
+ try { conn.send(text); this.stats.bytesOut += this.rules ? byteLength(text) : text.length; } catch { /* closing */ }
650
642
  }
651
643
  send(c, msg) { this.sendText(c.conn, JSON.stringify(msg)); }
652
644
  error(c, code, message, extra = {}) { this.send(c, { t: 'error', code, message, ...extra }); }
@@ -694,7 +686,7 @@ export class NetRoom {
694
686
  onDecide(c, m, isHost) {
695
687
  const n = typeof m.n === 'string' || Number.isInteger(m.n) ? String(m.n).slice(0, 16) : '';
696
688
  const server = this.server;
697
- const reply = (o) => { const frame = { t: 'decided', n, ...o }; if (c) this.send(c, frame); else if (server && this.server === server) this.toServer(frame); };
689
+ const reply = (o) => { const frame = { t: 'decided', n, ...o }; if (c) { const recipient = this.rules && isHost ? this.host() : c; if (recipient) this.send(recipient, frame); } else if (server && this.server === server) this.toServer(frame); };
698
690
  if (!isHost || (c && this.lite(c))) return reply({ ok: false, why: 'not-host' });
699
691
  if (typeof this.decider !== 'function') return reply({ ok: false, why: 'off' });
700
692
  const now = this.now();
@@ -726,22 +718,55 @@ export class NetRoom {
726
718
  try { c.conn.close(code, why); } catch { /* gone */ }
727
719
  }
728
720
 
729
- allow(c, t, bytes) {
721
+ stopBrowserRules(c, why, message) {
722
+ if (!this.rules || !c.rules || c.id !== this.hostId) return false;
723
+ this.log({ ev: 'host-ended', room: this.code, why });
724
+ for (const peer of this.live()) {
725
+ this.error(peer, 'room-over', message);
726
+ this.clients.delete(peer.id);
727
+ try { peer.conn.close(1011, 'room-over'); } catch { /* gone */ }
728
+ }
729
+ this.forget();
730
+ return true;
731
+ }
732
+
733
+ allow(c, t, bytes, view = false) {
730
734
  // A spectator screen has little to say: its events are capped at 512 B (16 screens x 2/s x 512 B at most reach the host).
731
- const cap = t === 'ev' && c.seat === null && c.id !== this.hostId ? 512 : capOf(t, this.seatCap);
732
- if (bytes > cap) { this.stats.oversize += 1; this.error(c, 'too-large', `${t} is ${bytes} B; the cap is ${cap} B`); return false; }
735
+ const cap = t === 'ev' && c.seat === null && (view || c.id !== this.hostId) ? 512 : capOf(t, this.seatCap);
736
+ const rulesOutput = !view && this.rules && c.rules && c.id === this.hostId;
737
+ if (bytes > cap) {
738
+ this.stats.oversize += 1;
739
+ const detail = `${t} is ${bytes} B; the cap is ${cap} B`;
740
+ this.error(c, 'too-large', detail, view ? { view: true } : {});
741
+ if (rulesOutput) this.stopBrowserRules(c, 'size', `This game's rules exceeded the browser room size cap: ${detail}. Join again for a fresh room.`);
742
+ return false;
743
+ }
733
744
  const now = this.now();
734
- const limit = t === 'ev' ? EV_RATES[this.roleOf(c)] : (RATES[t] ?? RATES.other);
735
- const list = c.rates.get(t) ?? [];
745
+ if (rulesOutput) {
746
+ c.rulesBuckets ??= new Map();
747
+ const rate = rulesRates(this.tickHz, this.seatCap)[t];
748
+ if (!takeRulesToken(c.rulesBuckets, t, rate, now)) return true;
749
+ this.stats.drops += 1;
750
+ if (t === 'snap') return false;
751
+ const message = `The hosting page exceeded its ${t} allowance. Moving the rules to another player.`;
752
+ for (const peer of this.live()) this.send(peer, { t: 'error', code: 'host-fault', message });
753
+ c.canHost = false;
754
+ if (this.elect(c.id, 'host-fault')) this.send(c, { t: 'role', role: c.seat === null ? 'screen' : 'replica', why: 'host-fault', host: this.hostRef(), peers: this.peers() });
755
+ else { this.hostId = c.id; this.stopBrowserRules(c, 'rate', 'No player can host this room after its host failed. Join again for a fresh room.'); }
756
+ return false;
757
+ }
758
+ const limit = t === 'ev' ? EV_RATES[view ? (c.seat === null ? 'screen' : 'replica') : this.roleOf(c)] : (RATES[t] ?? RATES.other);
759
+ const rateKey = view ? `${t}:view` : t;
760
+ const list = c.rates.get(rateKey) ?? [];
736
761
  while (list.length && list[0] <= now - 1000) list.shift();
737
- c.rates.set(t, list);
762
+ c.rates.set(rateKey, list);
738
763
  if (list.length >= limit) {
739
764
  this.stats.drops += 1;
740
765
  c.drops.push(now);
741
766
  while (c.drops.length && c.drops[0] <= now - 5000) c.drops.shift();
742
767
  // Sustained abuse (20/s over a cap for 5 s) ends the socket; anything less is reported once a second.
743
768
  if (c.drops.length > 100) { this.error(c, 'flood', 'too many messages over the rate caps'); this.kick(c, 'flood'); return false; }
744
- if (now - c.errAt >= 1000) { c.errAt = now; this.send(c, { t: 'error', code: 'rate', of: t, message: `${t} over ${limit}/s was dropped` }); }
769
+ if (now - c.errAt >= 1000) { c.errAt = now; this.send(c, { t: 'error', code: 'rate', of: t, message: `${t} over ${limit}/s was dropped`, ...(view ? { view: true } : {}) }); }
745
770
  return false;
746
771
  }
747
772
  list.push(now);
@@ -753,14 +778,27 @@ export class NetRoom {
753
778
  text = String(text);
754
779
  const now = this.now();
755
780
  c.lastSeen = now;
756
- this.stats.bytesIn += text.length;
781
+ const bytes = this.rules ? byteLength(text) : text.length;
782
+ this.stats.bytesIn += bytes;
757
783
  let m;
758
784
  try { m = JSON.parse(text); } catch { return; }
759
785
  if (!m || typeof m.t !== 'string') return;
760
- if (!c.helloed) { if (m.t === 'hello' && text.length <= LIMITS.hello) this.hello(c, m); return; }
761
- if (!this.allow(c, m.t, text.length)) return;
762
- const isHost = c.id === this.hostId;
786
+ if (!c.helloed) { if (m.t === 'hello' && bytes <= LIMITS.hello) this.hello(c, m); return; }
787
+ const view = this.rules && !(c.rules && c.id === this.hostId && m.rules === true && RULES_FRAMES.includes(m.t));
788
+ if (!this.allow(c, m.t, bytes, view)) return;
789
+ const isHost = c.id === this.hostId && !view;
763
790
  switch (m.t) {
791
+ case 'rules-end': {
792
+ if (!isHost || !c.rules || !this.rules) return;
793
+ const why = ['budget', 'overrun', 'fault', 'clock', 'load', 'size', 'rate'].includes(m.why) ? m.why : 'fault';
794
+ for (const peer of this.live()) {
795
+ this.error(peer, 'room-over', `This game's rules stopped: ${why}. Join again for a fresh room.`);
796
+ this.clients.delete(peer.id);
797
+ try { peer.conn.close(1011, 'room-over'); } catch { /* gone */ }
798
+ }
799
+ this.forget();
800
+ return;
801
+ }
764
802
  case 'snap': {
765
803
  if (!isHost) return;
766
804
  return this.hostSnap(c, m, now);
@@ -778,6 +816,7 @@ export class NetRoom {
778
816
  const h = this.host();
779
817
  if (!h) return;
780
818
  this.stats.ins += 1;
819
+ if (h.rules && Array.isArray(m.s)) { this.send(h, { t: 'in', from: c.seat, e: Number(m.e) || 0, k: Number(m.k) || 0, s: m.s.slice(0, 64), r: Number(m.r) || 0 }); return; }
781
820
  const out = { t: 'in', from: c.seat, q: Number(m.q) || 0, a: m.a ?? null, h: Array.isArray(m.h) ? m.h.slice(0, 16).map((x) => String(x).slice(0, 32)) : [] };
782
821
  if (Number.isFinite(m.r)) out.r = m.r;
783
822
  const p = clampPresses(m.p);
@@ -793,7 +832,7 @@ export class NetRoom {
793
832
  // drops all speech, whoever sends it (a host relaying a player's chat too).
794
833
  if (SPEECH.test(kind) && (this.policy.speech === 'off' || (this.policy.speech === 'lines' && /^chat/i.test(kind)))) { this.stats.speechDrops += 1; return; }
795
834
  // What an AI may send (section 18): its own goals and its game's lines, checked here whatever its host says.
796
- if (c.agent && !this.agentEvOk(c, kind, m, text.length, now)) { this.stats.agentDrops += 1; return; }
835
+ if (c.agent && !this.agentEvOk(c, kind, m, bytes, now)) { this.stats.agentDrops += 1; return; }
797
836
  // An agent says at most one line every 4 s and 8 a minute.
798
837
  if (c.agent && SPEECH.test(kind)) {
799
838
  while (c.speechAt.length && c.speechAt[0] <= now - 60_000) c.speechAt.shift();
@@ -803,14 +842,16 @@ export class NetRoom {
803
842
  // A line the host relays for an AI (`d.ai`): still only a line of the vocabulary, with fitting arguments.
804
843
  if (isHost && d0(m).ai === true && !this.aiLineOk(kind, d0(m))) { this.stats.agentDrops += 1; return; }
805
844
  // The host shows one AI the game (`agent:view`): to that AI's seat only, small, at most one every 2 s.
806
- if (kind === 'agent:view' && !this.agentViewOk(m, text.length, now, isHost)) return;
845
+ if (kind === 'agent:view' && !this.agentViewOk(m, bytes, now, isHost)) return;
807
846
  this.stats.evs += 1;
808
- const out = { t: 'ev', from: c.seat, k: kind, d: m.d ?? null };
847
+ const speaker = isHost && this.rules && c.rules ? (Number.isInteger(m.from) && this.live().some(p => p.seat === m.from && !p.agent) ? m.from : null) : c.seat;
848
+ const out = { t: 'ev', from: speaker, k: kind, d: m.d ?? null, ...(isHost && this.rules && c.rules ? { hosted: true } : {}) };
849
+ const recipients = isHost && this.rules && c.rules ? this.live() : this.others(c);
809
850
  if (isHost) {
810
- if (typeof m.to === 'string') { const o = this.clients.get(m.to); if (o && o.helloed && o !== c) this.send(o, out); }
811
- else if (Number.isInteger(m.to)) { for (const o of this.others(c)) if (o.seat === m.to) this.send(o, out); }
851
+ if (typeof m.to === 'string') { const o = this.clients.get(m.to); if (o && o.helloed && (o !== c || (this.rules && c.rules))) this.send(o, out); }
852
+ else if (Number.isInteger(m.to)) { for (const o of recipients) if (o.seat === m.to) this.send(o, out); }
812
853
  // An agent with no game client hears only what is addressed to its seat (the lite feed), and the party's lines.
813
- else for (const o of this.others(c)) { if (!this.lite(o) || this.liteHears(kind)) this.send(o, out); }
854
+ else for (const o of recipients) { if (!this.lite(o) || this.liteHears(kind)) this.send(o, out); }
814
855
  } else {
815
856
  const h = this.host();
816
857
  if (this.server) this.toServer({ ...out, id: c.id });
@@ -830,7 +871,7 @@ export class NetRoom {
830
871
  }
831
872
  case 'state': {
832
873
  if (!isHost) return;
833
- return this.hostState(c, m, text.length);
874
+ return this.hostState(c, m, bytes);
834
875
  }
835
876
  case 'round': {
836
877
  if (!isHost) return;
@@ -855,15 +896,23 @@ export class NetRoom {
855
896
  return;
856
897
  }
857
898
  case 'ping': {
899
+ if (this.rules && c.rules && Number.isFinite(m.readySpeed)) { c.readySpeed = Math.max(0, Math.min(1, m.readySpeed)); c.readyAt = now; }
858
900
  const wasHidden = c.hidden;
859
901
  c.hidden = m.hid === true;
860
902
  this.send(c, { t: 'pong', c: m.c, st: now });
861
903
  // A host whose tab went to the background will stop ticking: hand the round on now.
862
- if (isHost && c.hidden && !wasHidden) this.yieldHost(c, 'host-hidden');
904
+ if (c.id === this.hostId && c.hidden && !wasHidden) this.yieldHost(c, 'host-hidden');
863
905
  return;
864
906
  }
865
907
  case 'yield': {
866
- if (isHost) this.yieldHost(c, 'host-yielded');
908
+ if (c.id === this.hostId && this.rules && c.rules && m.slow === true) {
909
+ c.rulesSpeed = Math.max(0, Math.min(1, Number(m.speed) || 0));
910
+ // Both a recent event-loop measurement and any observed hosting speed must be substantially faster.
911
+ if (this.live().some(p => p !== c && p.canHost && this.fasterRulesHost(p, c))) this.yieldHost(c, 'host-slow');
912
+ } else if (c.id === this.hostId) this.yieldHost(c, 'host-yielded');
913
+ // A rules host holds its output from its yield on: it is told when the role stays with it (and the others,
914
+ // who were just told nobody hosts, are told who does).
915
+ if (this.rules && c.rules && c.id === this.hostId) for (const o of this.live()) this.send(o, { t: 'host', host: this.hostRef(), why: 'host-kept' });
867
916
  return;
868
917
  }
869
918
  case 'bye': {
@@ -958,18 +1007,21 @@ export class NetRoom {
958
1007
  }
959
1008
  if (this.perIp && c.ip && live.filter((o) => o.ip === c.ip).length >= this.perIp) return refuse('too-many', `at most ${this.perIp} sockets per address in one room`);
960
1009
  if (live.length >= this.maxPlayers + this.maxScreens) return refuse('room-full', 'this room is full; try another');
1010
+ c.rules = m.rules === true;
961
1011
  c.device = m.device === 'phone' || m.device === 'tv' ? m.device : 'desk';
962
1012
  // A watcher is a screen that never takes a seat, whatever else its hello says. An agent always plays.
963
1013
  c.watch = watching;
964
1014
  c.want = c.agent ? 'play' : watching || m.want === 'screen' ? 'screen' : 'play';
965
1015
  // An agent with no game client never hosts; one that runs the game hosts only a room no person can (DESIGN D10).
966
1016
  c.canHost = c.agent ? c.agent.hands === 'self' && m.canHost !== false : m.canHost !== false;
1017
+ // Rules output is marked since revision 11: an older page's would be taken for a player's, so it never hosts.
1018
+ if (this.rules && c.rules && !(Number(m.rev) >= 11)) c.canHost = false;
967
1019
  c.game = typeof m.game === 'string' ? m.game.slice(0, 64) : '';
968
1020
  // A person's typed name never ends in an AI or bot mark; on a kids server nobody's typed name is used (handles).
969
1021
  c.typed = c.agent || this.policy.kids ? '' : typeof m.name === 'string' ? stripAi(m.name.replace(/\s+/g, ' ').trim()).slice(0, 24) : '';
970
1022
  if (Array.isArray(m.caps)) for (const k of m.caps) if (CAPS.includes(k)) c.caps.add(k);
971
1023
  // The manifest's player count, from the first visitor of an empty room (the site's Table reads the manifest).
972
- if (!this.server && Number.isInteger(m.max) && !this.seats.size && !live.length) { this.askedMax = m.max; this.maxPlayers = Math.max(1, Math.min(this.seatCap, m.max)); }
1024
+ if (!this.server && !this.rules && Number.isInteger(m.max) && !this.seats.size && !live.length) { this.askedMax = m.max; this.maxPlayers = Math.max(1, Math.min(this.seatCap, m.max)); }
973
1025
  this.reapSeats(now);
974
1026
  // The verified pass names one AI. Reopening its socket resumes that seat and replaces the old socket.
975
1027
  const agentStay = c.agent?.pass ? [...this.seats.values()].find(s => s.agent?.pass === c.agent.pass) : null;
@@ -1033,6 +1085,8 @@ export class NetRoom {
1033
1085
  }
1034
1086
  }
1035
1087
  if (role === 'host') {
1088
+ // Welcome contains the current holders; old occupancy notices must precede it.
1089
+ this.hosted = new Map([...this.seats].map(([seat, s]) => [seat, s.occ ?? null]));
1036
1090
  this.hostId = c.id;
1037
1091
  this.hostSince = now;
1038
1092
  c.lastSnapAt = 0;
@@ -1047,7 +1101,7 @@ export class NetRoom {
1047
1101
  this.send(c, {
1048
1102
  t: 'welcome', v: NET_VERSION, rev: NET_REVISION, id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
1049
1103
  ...(this.gameVer ? { ver: this.gameVer } : {}), ...(behind ? { stale: { ver: this.currentVer } } : {}), stall: this.stallMs,
1050
- role, why, host: this.hostRef(), peers: this.peers(), st: now, max: this.maxPlayers,
1104
+ role, why, host: this.hostRef(), peers: this.peers(), ...(this.rules ? { held: this.heldPeers() } : {}), st: now, max: this.maxPlayers,
1051
1105
  round: this.lastRound, roster: this.lastRoster, snap: lite ? null : this.lastSnap, state: lite ? {} : this.stateObject(),
1052
1106
  ...(role === 'host' ? { ckpt: this.lastCkpt } : {}),
1053
1107
  ...(full ? { full: true } : {}),
@@ -1059,7 +1113,7 @@ export class NetRoom {
1059
1113
  });
1060
1114
  for (const o of this.others(c)) this.send(o, { t: 'join', peer: this.peer(c) });
1061
1115
  // The server host hears of every seat taken (a reconnect is the same stay: its body is back, not new).
1062
- if (this.server && c.seat !== null) { this.syncServerSeats(); this.joinServer(c); }
1116
+ if (c.seat !== null) { this.syncServerSeats(); this.joinServer(c); }
1063
1117
  // A seat taken by a browser that also watches this room: its watching tab sees the overview from now on.
1064
1118
  this.refreshWatchers(c);
1065
1119
  if (deposed) {
@@ -1148,7 +1202,7 @@ export class NetRoom {
1148
1202
  this.stats.seated += 1;
1149
1203
  this.send(c, { t: 'seat', seat: c.seat, token: c.token, name: c.name, colour: c.colour, role: this.roleOf(c) });
1150
1204
  for (const o of this.others(c)) this.send(o, { t: 'join', peer: this.peer(c) });
1151
- if (this.server) { this.syncServerSeats(); this.joinServer(c); }
1205
+ this.syncServerSeats(); this.joinServer(c);
1152
1206
  this.log({ ev: 'seated', room: this.code, id: c.id, seat: c.seat });
1153
1207
  this.persist();
1154
1208
  this.refreshWatchers();
@@ -1542,6 +1596,11 @@ export class NetRoom {
1542
1596
  this.tellWatchers();
1543
1597
  }
1544
1598
 
1599
+ fasterRulesHost(candidate, current) {
1600
+ return !candidate.hidden && this.now() - (candidate.readyAt ?? -Infinity) <= 5000 &&
1601
+ Math.min(candidate.rulesSpeed ?? 1, candidate.readySpeed ?? 0) > (current?.rulesSpeed ?? 0) * 1.5 + 0.1;
1602
+ }
1603
+
1545
1604
  /**
1546
1605
  * Choose a host. Visible and responsive first; then tenure in 30 s steps (a newcomer cannot jump the queue by
1547
1606
  * calling itself a desktop); then desk > tv > phone as a tie-break; then whoever came first. The chosen browser
@@ -1549,12 +1608,12 @@ export class NetRoom {
1549
1608
  */
1550
1609
  elect(excludeId, why) {
1551
1610
  // The server is never elected away.
1552
- if (this.server) return null;
1611
+ if (this.server || this.hostFailed) return null;
1553
1612
  const now = this.now();
1554
1613
  const silent = (c) => Number(now - c.lastSeen > this.silentMs);
1555
1614
  const tenure = (c) => Math.floor((now - c.joinedAt) / 30_000);
1556
1615
  const cands = this.live()
1557
- .filter((c) => c.canHost && c.id !== excludeId)
1616
+ .filter((c) => c.canHost && c.id !== excludeId && (why !== 'host-slow' || this.fasterRulesHost(c, this.clients.get(excludeId))))
1558
1617
  .sort((a, b) => Number(a.hidden) - Number(b.hidden)
1559
1618
  || silent(a) - silent(b)
1560
1619
  // An agent hosts only when no person can (DESIGN D10); a watcher is next to last: any player comes first.
@@ -1569,6 +1628,7 @@ export class NetRoom {
1569
1628
  for (const o of this.live()) this.send(o, { t: 'host', host: null, why });
1570
1629
  return null;
1571
1630
  }
1631
+ this.hosted = new Map([...this.seats].map(([seat, s]) => [seat, s.occ ?? null]));
1572
1632
  this.hostId = next.id;
1573
1633
  this.hostSince = now;
1574
1634
  this.preferHost = null;
@@ -1580,7 +1640,7 @@ export class NetRoom {
1580
1640
  this.stats.elections.push({ at: now, id: next.id, seat: next.seat, why });
1581
1641
  if (this.stats.elections.length > 32) this.stats.elections.shift();
1582
1642
  this.send(next, {
1583
- t: 'role', role: 'host', why, host: this.hostRef(), peers: this.peers(),
1643
+ t: 'role', role: 'host', why, host: this.hostRef(), peers: this.peers(), ...(this.rules ? { held: this.heldPeers() } : {}),
1584
1644
  ckpt: this.lastCkpt, snap: this.lastSnap, round: this.lastRound, roster: this.lastRoster, state: this.stateObject(),
1585
1645
  });
1586
1646
  for (const o of this.others(next)) this.send(o, { t: 'host', host: this.hostRef(), why });
@@ -1648,6 +1708,7 @@ export class NetRoom {
1648
1708
 
1649
1709
  /** An EMPTY room forgets everything (after `forgetMs`, or when the Table's alarm ends a server-hosted room nobody came back to). */
1650
1710
  forget() {
1711
+ this.hostId = null;
1651
1712
  for (const c of this.live()) this.kick(c, 'agents-alone', 4001);
1652
1713
  this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
1653
1714
  this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null;
@@ -1747,6 +1808,16 @@ export class NetRoom {
1747
1808
 
1748
1809
  labelOne(raw, result) {
1749
1810
  if (!raw || typeof raw !== 'object') return null;
1811
+ if (this.rules) {
1812
+ if (!Number.isInteger(raw.slot) || raw.slot < 0 || raw.slot >= this.seatCap) return null;
1813
+ raw = { ...raw, seat: raw.slot };
1814
+ const holder = this.holderOf(raw.slot);
1815
+ if (holder.kind !== 'human' && holder.kind !== 'agent') {
1816
+ if (raw.slot >= this.maxPlayers - this.reserve())
1817
+ raw.agent = { seat: null, role: raw.slot >= this.maxPlayers - this.policy.guides ? 'guide' : 'party', hands: 'host' };
1818
+ else delete raw.agent;
1819
+ }
1820
+ }
1750
1821
  const out = { ...raw };
1751
1822
  const seat = Number.isInteger(raw.seat) ? raw.seat : null;
1752
1823
  const agentSeat = !result && raw.agent && Number.isInteger(raw.agent.seat) ? raw.agent.seat : null;
@@ -1761,7 +1832,7 @@ export class NetRoom {
1761
1832
  } else if (seat !== null && who.kind === 'human') {
1762
1833
  delete out.agent;
1763
1834
  out.bot = false;
1764
- out.name = stripAi(raw.name) || who.name;
1835
+ out.name = this.rules ? who.name : stripAi(raw.name) || who.name;
1765
1836
  } else {
1766
1837
  // No seat, or a seat nobody holds: a body the host's own bot code moves.
1767
1838
  if (seat !== null) this.stats.labelled += 1;
@@ -1902,7 +1973,7 @@ export class NetRoom {
1902
1973
  restore(saved, graceMs = 3000) {
1903
1974
  const now = this.now();
1904
1975
  if (!saved || saved.v !== NET_VERSION || !(now - Number(saved.savedAt) < 120_000)) return false;
1905
- if (!this.server && saved.hosted !== 'server' && Number.isInteger(saved.maxPlayers)) this.maxPlayers = Math.max(1, Math.min(this.seatCap, saved.maxPlayers));
1976
+ if (!this.server && !this.rules && saved.hosted !== 'server' && Number.isInteger(saved.maxPlayers)) this.maxPlayers = Math.max(1, Math.min(this.seatCap, saved.maxPlayers));
1906
1977
  if (Number.isFinite(saved.openedAt) && saved.openedAt > 0) this.openedAt = saved.openedAt;
1907
1978
  for (const [seat, token, name, agent, occ, since] of saved.seats ?? []) this.seats.set(seat, { token, name, since: saved.durable && Number.isFinite(since) ? since : now, present: false, agent: agent && typeof agent === 'object' ? agent : null, ...(Number.isInteger(occ) ? { occ } : {}) });
1908
1979
  // A stay's number is never given twice while a checkpoint may still name it.
@@ -1916,11 +1987,13 @@ export class NetRoom {
1916
1987
  if ((saved.seats ?? []).some((s) => !s[3])) this.lastHumanAt = now;
1917
1988
  // A server-hosted room has no browser host to hand a checkpoint to, and what its last match showed is not this one's.
1918
1989
  const fresh = saved.hosted === 'server' && !saved.durable;
1990
+ if (!fresh) for (const [seat, s] of this.seats) this.hosted.set(seat, s.occ ?? null);
1919
1991
  this.lastCkpt = fresh ? null : saved.ckpt ?? null;
1920
1992
  this.lastRound = fresh ? null : saved.round ?? null;
1921
1993
  this.lastRoster = fresh ? null : saved.roster ?? null;
1922
1994
  for (const [k, d] of Object.entries(fresh ? {} : saved.state ?? {})) {
1923
- const bytes = JSON.stringify({ t: 'state', k, d }).length;
1995
+ const text = JSON.stringify({ t: 'state', k, d });
1996
+ const bytes = this.rules ? byteLength(text) : text.length;
1924
1997
  this.state.set(k, { d, bytes });
1925
1998
  this.stateBytes += bytes;
1926
1999
  }
@@ -2227,7 +2300,7 @@ export class NetRoom {
2227
2300
 
2228
2301
  setSeats(n, perIp = null) {
2229
2302
  this.seatCap = Math.max(1, Math.floor(n));
2230
- this.maxPlayers = Math.max(1, Math.min(this.seatCap, this.server ? this.seatCap : this.askedMax ?? this.seatCap));
2303
+ this.maxPlayers = Math.max(1, Math.min(this.seatCap, (this.server || this.rules) ? this.seatCap : this.askedMax ?? this.seatCap));
2231
2304
  if (Number.isFinite(perIp) && perIp > 0) this.perIp = perIp;
2232
2305
  }
2233
2306
 
package/worker/schema.mjs CHANGED
@@ -164,7 +164,7 @@ export function gameNode(cat, g, origin, { listed = true, shop = null } = {}) {
164
164
  const L = g.landing ?? {};
165
165
  const h = L.hero ?? {};
166
166
  const page = `${origin}/${g.id}/`;
167
- const play = `${origin}/${g.id}/play`;
167
+ const play = `${origin}/${g.id}/${g.kind === 'app' ? 'open' : 'play'}`;
168
168
  const min = Number(g.players?.min) || 1;
169
169
  const max = Number(g.players?.max) || 8;
170
170
  const modes = [...(min <= 1 ? ['SinglePlayer'] : []), ...(max > 1 ? ['MultiPlayer'] : [])];
@@ -180,7 +180,7 @@ export function gameNode(cat, g, origin, { listed = true, shop = null } = {}) {
180
180
  const addOn = listed ? shopOffers(shop, g, origin) : [];
181
181
  const genre = (Array.isArray(g.genre) ? g.genre : g.genre ? [g.genre] : []).map((x) => text(x, 40)).filter(Boolean);
182
182
  const node = {
183
- '@type': ['VideoGame', 'WebApplication'],
183
+ '@type': g.kind === 'app' ? 'WebApplication' : ['VideoGame', 'WebApplication'],
184
184
  '@id': `${page}#game`,
185
185
  name: g.name,
186
186
  ...(text(g.blurb ?? L.pitch) ? { description: text(g.blurb || L.pitch) } : {}),
@@ -189,10 +189,11 @@ export function gameNode(cat, g, origin, { listed = true, shop = null } = {}) {
189
189
  ...(images.length ? { image: images.length === 1 ? images[0] : images } : {}),
190
190
  ...(shots.length ? { screenshot: shots } : {}),
191
191
  ...(genre.length ? { genre: genre.length === 1 ? genre[0] : genre } : {}),
192
- playMode: modes.map(PLAY_MODE),
192
+ ...(g.kind === 'app' ? {} : { playMode: modes.map(PLAY_MODE),
193
193
  numberOfPlayers: { '@type': 'QuantitativeValue', minValue: min, maxValue: max },
194
194
  gamePlatform: ['Web browser', 'Phone', 'Computer', ...(L.tv !== false ? ['TV'] : [])],
195
- applicationCategory: 'GameApplication',
195
+ }),
196
+ applicationCategory: g.kind === 'app' ? 'BusinessApplication' : 'GameApplication',
196
197
  operatingSystem: 'Any',
197
198
  browserRequirements: 'A modern web browser with JavaScript',
198
199
  isAccessibleForFree: true,
@@ -203,7 +204,7 @@ export function gameNode(cat, g, origin, { listed = true, shop = null } = {}) {
203
204
  ...(trailer ? { trailer: videoNode(cat, trailer, origin, { nested: true }) } : {}),
204
205
  ...(when(g.dates?.published) ? { datePublished: g.dates.published } : {}),
205
206
  ...(when(g.dates?.modified) ? { dateModified: g.dates.modified } : {}),
206
- potentialAction: { '@type': 'PlayAction', target: play },
207
+ potentialAction: { '@type': g.kind === 'app' ? 'ViewAction' : 'PlayAction', target: play },
207
208
  ...(listed ? { offers: { '@type': 'Offer', price: 0, priceCurrency: /^[A-Z]{3}$/.test(String(shop?.currency ?? '').toUpperCase()) ? String(shop.currency).toUpperCase() : 'USD', availability: 'https://schema.org/OnlineOnly', url: play, ...(addOn.length ? { addOn } : {}) } } : {}),
208
209
  };
209
210
  return withExtras(node, g.schema);