@homie-rocks/studio 0.32.1 → 0.33.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 (66) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/README.md +24 -6
  3. package/agents/agents.ts +57 -24
  4. package/bin/homie-studio.mjs +3 -3
  5. package/lib/build.mjs +64 -9
  6. package/lib/cf-usage.mjs +76 -0
  7. package/lib/cloudflare.mjs +25 -2
  8. package/lib/dev.mjs +45 -4
  9. package/lib/lab-check.mjs +3 -2
  10. package/lib/mcp-tools.mjs +1 -1
  11. package/lib/mcp.mjs +1 -1
  12. package/lib/office.mjs +7 -0
  13. package/lib/perf.mjs +8 -3
  14. package/lib/player-image.mjs +40 -0
  15. package/lib/rules-build.mjs +184 -0
  16. package/lib/rules-guard.mjs +770 -0
  17. package/lib/scaffold.mjs +43 -16
  18. package/lib/shop-catalog.mjs +10 -9
  19. package/lib/shop.mjs +32 -15
  20. package/lib/studio.mjs +21 -1
  21. package/lib/template-history.json +50 -0
  22. package/lib/upgrade.mjs +19 -15
  23. package/lib/version.mjs +2 -7
  24. package/netplay/NETPLAY.md +249 -4
  25. package/netplay/netplay.ts +55 -13
  26. package/package.json +23 -1
  27. package/rules/core.ts +1250 -0
  28. package/rules/guard.ts +419 -0
  29. package/rules/host.ts +707 -0
  30. package/rules/math.ts +266 -0
  31. package/rules/pack.ts +292 -0
  32. package/rules/rules.ts +544 -0
  33. package/rules/view.ts +447 -0
  34. package/shop/SELLING.md +14 -16
  35. package/shop/SHOP.md +119 -30
  36. package/shop/shop.ts +6 -8
  37. package/site/SITE.md +119 -2
  38. package/starters/coin-dash/game.json +11 -0
  39. package/starters/coin-dash/index.html +17 -0
  40. package/starters/coin-dash/map/main.json +16 -0
  41. package/starters/coin-dash/src/move.ts +27 -0
  42. package/starters/coin-dash/src/rules.ts +86 -0
  43. package/starters/coin-dash/src/view.ts +106 -0
  44. package/starters/coin-dash/tunables.json +5 -0
  45. package/starters/ember-vale/index.html +11 -0
  46. package/worker/brain.mjs +8 -7
  47. package/worker/chat-page.mjs +1 -1
  48. package/worker/embed.mjs +135 -0
  49. package/worker/hosted.mjs +56 -0
  50. package/worker/index.mjs +413 -22
  51. package/worker/office-page.mjs +1 -0
  52. package/worker/office.mjs +24 -7
  53. package/worker/pages.mjs +189 -43
  54. package/worker/players.mjs +10 -6
  55. package/worker/referrals.mjs +192 -57
  56. package/worker/room-store.mjs +54 -0
  57. package/worker/room.mjs +294 -119
  58. package/worker/saves-shell.mjs +7 -1
  59. package/worker/schema.mjs +4 -3
  60. package/worker/shop-page.mjs +103 -37
  61. package/worker/shop-rules.mjs +156 -117
  62. package/worker/shop-store.mjs +70 -9
  63. package/worker/shop.mjs +275 -143
  64. package/worker/site.mjs +26 -5
  65. package/worker/stripe.mjs +9 -6
  66. package/worker/version.mjs +1 -1
package/worker/room.mjs CHANGED
@@ -41,6 +41,12 @@
41
41
  * on typed text (worker/chat.mjs), waits for the studio's review when there is one (the Table's Workers AI), and fans
42
42
  * the line out to every socket and every watching shell. It keeps the last few minutes in memory for a page that just
43
43
  * opened, and nothing anywhere else.
44
+ *
45
+ * THE SERVER AS HOST (revision 10, NETPLAY.md section 29): a game whose rules run on the server has no browser host.
46
+ * The Table hands the room a host runtime (`setServerHost`, rules/host.ts). It is not a client: it is not in
47
+ * `clients`, it has no socket, it is never elected away, and stall detection, yield and the caps written for a host
48
+ * that is somebody's phone (`LIMITS`, `RATES`) do not apply to it. The relay calls it with parsed frames (`in`, `ev`,
49
+ * who came and went) and fans out each frame it hands back (`hostFrame`). Everything else here is as it was.
44
50
  * =============================================================================
45
51
  */
46
52
  import { aiName, skillPreset, stripAi } from './agents.mjs';
@@ -49,7 +55,7 @@ import { CHAT_LIMITS, CHAT_RATES, HELD_WORDS, allows, cleanText, floor, normaliz
49
55
 
50
56
  export const NET_VERSION = 1;
51
57
  /** The contract revision this relay speaks (NETPLAY.md): optional fields, frames and refusals; the wire stays `v: 1`. */
52
- export const NET_REVISION = 9;
58
+ export const NET_REVISION = 10;
53
59
  /**
54
60
  * Revision 9 (NETPLAY.md sections 22 and 23). STALL: how long a host may send no snapshot, while others are present,
55
61
  * before the room is handed on: 1.5 s unless the game names its own (game.json `netplay.stallMs`), never under
@@ -376,6 +382,180 @@ export class NetRoom {
376
382
  this.decidePending = 0;
377
383
  this.watchIds = new WeakMap();
378
384
  this.defaultChat = null;
385
+ /**
386
+ * Revision 10: the server as host (`setServerHost`), the seats it has been told of (seat → the stay's number), why
387
+ * it could not start (`hostFailed`: the room then refuses joins and says why), and what the Table does when the
388
+ * room forgets everything (`onForget`: it ends the host runtime).
389
+ */
390
+ this.server = null;
391
+ this.hosted = new Map();
392
+ this.hostFailed = null;
393
+ this.onForget = null;
394
+ }
395
+
396
+ /* ------------------------------------------------------------ the server as host (revision 10, section 29) */
397
+
398
+ /** The host runtime of a server-hosted room (`{ frame(m), facts() }`), or null. Seats already here are said as joins. */
399
+ setServerHost(h) {
400
+ this.server = h;
401
+ this.hostFailed = null;
402
+ this.hostId = null;
403
+ this.preferHost = null;
404
+ this.hosted.clear();
405
+ if (!h) return;
406
+ this.askedMax = null; this.maxPlayers = this.seatCap;
407
+ // Restored holders also need a later free frame, even if their socket never returns.
408
+ for (const [seat, s] of this.seats) this.hosted.set(seat, s.occ ?? null);
409
+ this.toServer({ t: 'policy', policy: this.policyOut() });
410
+ if (this.rawVocab) this.toServer({ t: 'vocabulary', vocab: this.rawVocab });
411
+ for (const c of this.live()) if (c.seat !== null && !c.watch) this.joinServer(c);
412
+ }
413
+
414
+ /** The rules could not start: the room refuses joins and says why. It never falls back to a browser by itself. */
415
+ failServerHost(message) {
416
+ this.server = null;
417
+ this.hostFailed = String(message ?? 'this game\'s rules could not start').slice(0, 200);
418
+ this.log({ ev: 'host-failed', room: this.code, error: this.hostFailed });
419
+ }
420
+
421
+ /** One parsed frame for the host runtime. Rules that throw are caught there; anything thrown here is the runtime's own failure, logged with the room. */
422
+ toServer(m) {
423
+ if (!this.server) return;
424
+ try { this.server.frame(m); } catch (error) { this.stats.failed = (this.stats.failed ?? 0) + 1; this.log({ ev: 'failed', room: this.code, op: 'host-frame', frame: m.t, error: errorLine(error) }); }
425
+ }
426
+
427
+ joinServer(c) {
428
+ this.hosted.set(c.seat, this.seats.get(c.seat)?.occ ?? null);
429
+ this.toServer({ t: 'join', peer: this.peer(c) });
430
+ }
431
+
432
+ /** 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
+ syncServerSeats() {
434
+ if (!this.server) return;
435
+ for (const [seat, occ] of [...this.hosted]) {
436
+ const s = this.seats.get(seat);
437
+ if (s && (s.occ ?? null) === occ) continue;
438
+ this.hosted.delete(seat);
439
+ this.toServer({ t: 'free', seat });
440
+ }
441
+ }
442
+
443
+ /**
444
+ * One frame from the host runtime, fanned out as a browser host's would be: `snap`, `state`, `round`, `roster`,
445
+ * `caps` and `ev`. `text` is the frame already encoded, when the runtime encoded it, so nothing is encoded twice.
446
+ */
447
+ hostFrame(m, text = null) {
448
+ if (!this.server || !m || typeof m.t !== 'string') return;
449
+ const now = this.now();
450
+ switch (m.t) {
451
+ case 'snap': return this.hostSnap(null, m, now, text);
452
+ case 'state': return this.hostState(null, m, 0);
453
+ case 'round': return this.hostRound(null, m);
454
+ case 'roster': return this.hostRoster(null, m);
455
+ case 'caps': return this.hostCaps(null, m);
456
+ case 'decide': return this.onDecide(null, m, true);
457
+ case 'ev': {
458
+ const kind = String(m.k ?? '').slice(0, 64);
459
+ if (SPEECH.test(kind) && (this.policy.speech === 'off' || (this.policy.speech === 'lines' && /^chat/i.test(kind)))) { this.stats.speechDrops += 1; return; }
460
+ 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;
462
+ this.stats.evs += 1;
463
+ const out = { t: 'ev', from: Number.isInteger(m.from) ? m.from : null, k: kind, d: m.d ?? null };
464
+ const line = JSON.stringify(out);
465
+ if (Number.isInteger(m.to)) { for (const o of this.live()) if (o.seat === m.to) this.sendText(o.conn, line); return; }
466
+ for (const o of this.live()) if (!this.lite(o) || this.liteHears(kind)) this.sendText(o.conn, line);
467
+ return;
468
+ }
469
+ default: return;
470
+ }
471
+ }
472
+
473
+ /** A snapshot from the host (a browser's socket `c`, or the server: `c` null), kept for joiners and fanned out. */
474
+ hostSnap(c, m, now, text = null) {
475
+ // The server's stamp is this relay's own clock; a browser's is its estimate of it, and is checked.
476
+ const snap = { k: Number(m.k) || 0, st: c ? this.stamp(m.st) : Number(m.st) || now, d: m.d ?? null };
477
+ if (Array.isArray(m.c)) snap.c = m.c.slice(0, 64);
478
+ // Revision 10: a rules game's snapshot names the room's epoch.
479
+ if (!c && Number.isFinite(m.e)) snap.e = m.e;
480
+ this.lastSnap = snap;
481
+ if (c) c.lastSnapAt = now;
482
+ this.stats.snaps += 1;
483
+ this.snapTimes.push(now);
484
+ while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
485
+ // A heartbeat (revision 9): the host's last state again while its frames hitch. It counts as a snapshot here
486
+ // (the host is alive), and replicas are told which it is.
487
+ const out = text ?? JSON.stringify({ t: 'snap', from: c ? c.seat : null, ...snap, ...(m.hb === 1 ? { hb: 1 } : {}) });
488
+ if (m.hb === 1) this.stats.heartbeats = (this.stats.heartbeats ?? 0) + 1;
489
+ this.lastSnapText = out;
490
+ for (const o of this.others(c)) {
491
+ if (this.lite(o)) continue; // an agent with no game client draws nothing
492
+ // A congested socket skips a snapshot rather than queueing a stale one.
493
+ if (o.conn.buffered && o.conn.buffered() > 256 * 1024) { this.stats.drops += 1; continue; }
494
+ this.sendText(o.conn, out);
495
+ }
496
+ }
497
+
498
+ /** One key of the slow state channel from the host. `bytes`: the frame's size, counted against the room's cap for a browser host. */
499
+ hostState(c, m, bytes) {
500
+ const key = typeof m.k === 'string' ? m.k.slice(0, 64) : '';
501
+ if (!key) return;
502
+ const prev = this.state.get(key);
503
+ if (m.d === null || m.d === undefined) {
504
+ if (!prev) return;
505
+ this.state.delete(key);
506
+ this.stateBytes -= prev.bytes;
507
+ } else {
508
+ if (c && ((!prev && this.state.size >= STATE_CAPS.keys) || this.stateBytes - (prev?.bytes ?? 0) + bytes > STATE_CAPS.bytes)) {
509
+ this.error(c, 'state-full', `state is capped at ${STATE_CAPS.keys} keys and ${STATE_CAPS.bytes} B`);
510
+ return;
511
+ }
512
+ this.state.set(key, { d: m.d, bytes });
513
+ this.stateBytes += bytes - (prev?.bytes ?? 0);
514
+ }
515
+ this.stats.states += 1;
516
+ this.persistDirty = true;
517
+ 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);
519
+ }
520
+
521
+ hostRound(c, m) {
522
+ if (!m.round || typeof m.round !== 'object') return;
523
+ // 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;
525
+ const wasLive = this.lastRound && this.lastRound.phase === 'live' ? Number(this.lastRound.n) : null;
526
+ this.lastRound = round;
527
+ // The round a launch change waits for is over: everyone sees its results for a moment, then the room re-gates.
528
+ if (this.regate && round.phase === 'over' && (this.regate.roundN === null || Number(round.n) >= this.regate.roundN)) this.regate.until = Math.min(this.regate.until, this.now() + 5000);
529
+ 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
+ this.stats.rounds += 1;
531
+ this.persistDirty = true;
532
+ for (const o of this.others(c)) this.send(o, { t: 'round', round });
533
+ // A party's first live round with AI seats: the vote on how strong the AI should be (once per 30 minutes).
534
+ if (round.phase === 'live' && wasLive !== Number(round.n)) this.autoVote();
535
+ this.tellWatchers();
536
+ }
537
+
538
+ hostRoster(c, m) {
539
+ if (!Array.isArray(m.slots)) return;
540
+ // 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
+ this.lastRoster = this.labelRoster(m.slots.slice(0, 64));
542
+ this.persistDirty = true;
543
+ for (const o of this.others(c)) this.send(o, { t: 'roster', slots: this.lastRoster });
544
+ this.tellWatchers();
545
+ }
546
+
547
+ hostCaps(c, m) {
548
+ // The host's game reads the dial and/or moves agents' bodies (section 17): the office and the shell say so.
549
+ if (!Array.isArray(m.caps)) return;
550
+ if (c) { for (const k of m.caps) if (CAPS.includes(k)) c.caps.add(k); this.caps = new Set(c.caps); } else this.caps = new Set(m.caps.filter((k) => CAPS.includes(k)));
551
+ this.persistDirty = true;
552
+ this.tellWatchers();
553
+ }
554
+
555
+ /** The room's policy, to everyone in it and to the server host. */
556
+ tellPolicy() {
557
+ this.broadcast({ t: 'policy', policy: this.policyOut() });
558
+ this.toServer({ t: 'policy', policy: this.policyOut() });
379
559
  }
380
560
 
381
561
  /* ------------------------------------------------------------ sockets */
@@ -395,7 +575,7 @@ export class NetRoom {
395
575
  // An agent (section 17): only the Worker's word, from a verified pass, makes one; its hello cannot.
396
576
  agentWord: conn.agent && typeof conn.agent === 'object' ? conn.agent : null, agent: null, caps: new Set(), speechAt: [],
397
577
  // A badge the studio's Worker verified (the shop: a supporter's); a hello cannot set one.
398
- badge: typeof conn.badge === 'string' && conn.badge.length <= 16 ? conn.badge : null,
578
+ badge: typeof conn.badge === 'string' ? conn.badge : null,
399
579
  // Revision 7: the latest view the host showed this AI (what its arguments are checked against), and its pace.
400
580
  lastView: null, viewAt: 0, doAt: 0,
401
581
  // Revision 9: the game revision this socket's page was served with. The Worker's word (it is in the socket's
@@ -473,7 +653,7 @@ export class NetRoom {
473
653
  live() { return [...this.clients.values()].filter((c) => c.helloed); }
474
654
  others(c) { return this.live().filter((o) => o !== c); }
475
655
  host() { return this.hostId ? this.clients.get(this.hostId) ?? null : null; }
476
- hostRef() { const h = this.host(); return h ? { id: h.id, seat: h.seat } : null; }
656
+ hostRef() { if (this.server) return { id: 'server', seat: null }; const h = this.host(); return h ? { id: h.id, seat: h.seat } : null; }
477
657
  roleOf(c) { return c.id === this.hostId ? 'host' : c.seat === null ? 'screen' : 'replica'; }
478
658
  peer(c) {
479
659
  // Revision 9: which stay in the seat this is (`occ`), the peer's game revision and what its build can do.
@@ -513,8 +693,9 @@ export class NetRoom {
513
693
  */
514
694
  onDecide(c, m, isHost) {
515
695
  const n = typeof m.n === 'string' || Number.isInteger(m.n) ? String(m.n).slice(0, 16) : '';
516
- const reply = (o) => this.send(c, { t: 'decided', n, ...o });
517
- if (!isHost || this.lite(c)) return reply({ ok: false, why: 'not-host' });
696
+ 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); };
698
+ if (!isHost || (c && this.lite(c))) return reply({ ok: false, why: 'not-host' });
518
699
  if (typeof this.decider !== 'function') return reply({ ok: false, why: 'off' });
519
700
  const now = this.now();
520
701
  while (this.decideTimes.length && this.decideTimes[0] <= now - 60_000) this.decideTimes.shift();
@@ -582,28 +763,18 @@ export class NetRoom {
582
763
  switch (m.t) {
583
764
  case 'snap': {
584
765
  if (!isHost) return;
585
- const snap = { k: Number(m.k) || 0, st: this.stamp(m.st), d: m.d ?? null };
586
- if (Array.isArray(m.c)) snap.c = m.c.slice(0, 64);
587
- this.lastSnap = snap;
588
- c.lastSnapAt = now;
589
- this.stats.snaps += 1;
590
- this.snapTimes.push(now);
591
- while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
592
- // A heartbeat (revision 9): the host's last state again while its frames hitch. It counts as a snapshot here
593
- // (the host is alive), and replicas are told which it is.
594
- const out = JSON.stringify({ t: 'snap', from: c.seat, ...snap, ...(m.hb === 1 ? { hb: 1 } : {}) });
595
- if (m.hb === 1) this.stats.heartbeats = (this.stats.heartbeats ?? 0) + 1;
596
- this.lastSnapText = out;
597
- for (const o of this.others(c)) {
598
- if (this.lite(o)) continue; // an agent with no game client draws nothing
599
- // A congested socket skips a snapshot rather than queueing a stale one.
600
- if (o.conn.buffered && o.conn.buffered() > 256 * 1024) { this.stats.drops += 1; continue; }
601
- this.sendText(o.conn, out);
602
- }
603
- return;
766
+ return this.hostSnap(c, m, now);
604
767
  }
605
768
  case 'in': {
606
769
  if (isHost || c.seat === null || this.lite(c)) return;
770
+ // Revision 10: a rules game's input goes to the server host as it came (`e`, `k`, `s`, `r`); the runtime holds
771
+ // every value to its declared type, so nothing here needs to know the game's fields.
772
+ if (this.server) {
773
+ if (!Array.isArray(m.s)) return;
774
+ this.stats.ins += 1;
775
+ this.toServer({ 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 });
776
+ return;
777
+ }
607
778
  const h = this.host();
608
779
  if (!h) return;
609
780
  this.stats.ins += 1;
@@ -632,12 +803,7 @@ export class NetRoom {
632
803
  // A line the host relays for an AI (`d.ai`): still only a line of the vocabulary, with fitting arguments.
633
804
  if (isHost && d0(m).ai === true && !this.aiLineOk(kind, d0(m))) { this.stats.agentDrops += 1; return; }
634
805
  // The host shows one AI the game (`agent:view`): to that AI's seat only, small, at most one every 2 s.
635
- if (kind === 'agent:view') {
636
- const o = isHost && Number.isInteger(m.to) ? this.live().find((x) => x.seat === m.to && x.agent) : null;
637
- if (!o || text.length > AGENT_FRAMES.viewBytes || now - o.viewAt < AGENT_FRAMES.viewMs - 250) { this.stats.agentDrops += 1; return; }
638
- o.viewAt = now;
639
- o.lastView = m.d && typeof m.d === 'object' && !Array.isArray(m.d) ? m.d : null;
640
- }
806
+ if (kind === 'agent:view' && !this.agentViewOk(m, text.length, now, isHost)) return;
641
807
  this.stats.evs += 1;
642
808
  const out = { t: 'ev', from: c.seat, k: kind, d: m.d ?? null };
643
809
  if (isHost) {
@@ -647,7 +813,8 @@ export class NetRoom {
647
813
  else for (const o of this.others(c)) { if (!this.lite(o) || this.liteHears(kind)) this.send(o, out); }
648
814
  } else {
649
815
  const h = this.host();
650
- if (h) this.send(h, { ...out, id: c.id });
816
+ if (this.server) this.toServer({ ...out, id: c.id });
817
+ else if (h) this.send(h, { ...out, id: c.id });
651
818
  // The lite feed (section 18): an AI with no game client hears the party's lines, and an ask made of it.
652
819
  if (!c.agent) this.copyToLite(c, kind, out);
653
820
  }
@@ -663,62 +830,19 @@ export class NetRoom {
663
830
  }
664
831
  case 'state': {
665
832
  if (!isHost) return;
666
- const key = typeof m.k === 'string' ? m.k.slice(0, 64) : '';
667
- if (!key) return;
668
- const prev = this.state.get(key);
669
- if (m.d === null || m.d === undefined) {
670
- if (!prev) return;
671
- this.state.delete(key);
672
- this.stateBytes -= prev.bytes;
673
- } else {
674
- const bytes = text.length;
675
- if ((!prev && this.state.size >= STATE_CAPS.keys) || this.stateBytes - (prev?.bytes ?? 0) + bytes > STATE_CAPS.bytes) {
676
- this.error(c, 'state-full', `state is capped at ${STATE_CAPS.keys} keys and ${STATE_CAPS.bytes} B`);
677
- return;
678
- }
679
- this.state.set(key, { d: m.d, bytes });
680
- this.stateBytes += bytes - (prev?.bytes ?? 0);
681
- }
682
- this.stats.states += 1;
683
- this.persistDirty = true;
684
- const out = JSON.stringify({ t: 'state', k: key, d: m.d ?? null });
685
- for (const o of this.others(c)) if (!this.lite(o)) this.sendText(o.conn, out);
686
- return;
833
+ return this.hostState(c, m, text.length);
687
834
  }
688
835
  case 'round': {
689
- if (!isHost || !m.round || typeof m.round !== 'object') return;
690
- // The results name every AI as AI, whatever the host's game says (section 17).
691
- const round = Array.isArray(m.round.results) ? { ...m.round, results: this.labelResults(m.round.results) } : m.round;
692
- const wasLive = this.lastRound && this.lastRound.phase === 'live' ? Number(this.lastRound.n) : null;
693
- this.lastRound = round;
694
- // The round a launch change waits for is over: everyone sees its results for a moment, then the room re-gates.
695
- if (this.regate && round.phase === 'over' && (this.regate.roundN === null || Number(round.n) >= this.regate.roundN)) this.regate.until = Math.min(this.regate.until, this.now() + 5000);
696
- 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);
697
- this.stats.rounds += 1;
698
- this.persistDirty = true;
699
- for (const o of this.others(c)) this.send(o, { t: 'round', round });
700
- // A party's first live round with AI seats: the vote on how strong the AI should be (once per 30 minutes).
701
- if (round.phase === 'live' && wasLive !== Number(round.n)) this.autoVote();
702
- this.tellWatchers();
703
- return;
836
+ if (!isHost) return;
837
+ return this.hostRound(c, m);
704
838
  }
705
839
  case 'roster': {
706
- if (!isHost || !Array.isArray(m.slots)) return;
707
- // A host cannot hide an AI: a seat an agent holds is named and marked so, a slot with no seat is a bot.
708
- this.lastRoster = this.labelRoster(m.slots.slice(0, 64));
709
- this.persistDirty = true;
710
- for (const o of this.others(c)) this.send(o, { t: 'roster', slots: this.lastRoster });
711
- this.tellWatchers();
712
- return;
840
+ if (!isHost) return;
841
+ return this.hostRoster(c, m);
713
842
  }
714
843
  case 'caps': {
715
- // The host's game reads the dial and/or moves agents' bodies (section 17): the office and the shell say so.
716
- if (!isHost || !Array.isArray(m.caps)) return;
717
- for (const k of m.caps) if (CAPS.includes(k)) c.caps.add(k);
718
- this.caps = new Set(c.caps);
719
- this.persistDirty = true;
720
- this.tellWatchers();
721
- return;
844
+ if (!isHost) return;
845
+ return this.hostCaps(c, m);
722
846
  }
723
847
  case 'vote': {
724
848
  this.onVote(c, m);
@@ -778,6 +902,11 @@ export class NetRoom {
778
902
  this.clients.delete(c.id);
779
903
  try { c.conn.close(1008, code); } catch { /* gone */ }
780
904
  };
905
+ // Revision 10: this game's rules run on the server and could not start. Nobody is let in, and the reason is said.
906
+ if (this.endedMatch?.until <= this.now()) this.endedMatch = null;
907
+ if (this.endedMatch?.tokens.has(m.token)) return refuse('room-over', 'This match ended. Joining a fresh room.', { rematch: true, ver: this.endedMatch.ver });
908
+ if (this.ended) return refuse('room-over', 'This room ended. Joining a fresh room.', { rematch: true, ver: this.ended.ver });
909
+ if (this.hostFailed) return refuse('host-failed', this.hostFailed);
781
910
  // The owner closed this room, or held this player out of it (section 15): refused, with when it ends.
782
911
  if (this.closedUntil > this.now()) return refuse('room-closed', this.closedWhy || 'The studio closed this room.', { until: this.closedUntil });
783
912
  const ban = this.banOf(c, typeof m.token === 'string' ? m.token : '');
@@ -804,7 +933,8 @@ export class NetRoom {
804
933
  c.ver = c.verWord !== undefined ? c.verWord : versionOf(m.ver);
805
934
  c.feat = featuresOf(m.feat);
806
935
  const lite = Boolean(c.agentWord && c.agentWord.hands === 'host');
807
- if (!lite) {
936
+ if (!lite && this.server && this.currentVer !== undefined && c.ver !== this.currentVer) return refuse('stale', 'This game was updated. Loading it again.', { ver: this.currentVer, immediate: true });
937
+ if (!lite && !this.server) {
808
938
  const here = live.filter((o) => !this.lite(o));
809
939
  const cur = this.currentVer;
810
940
  if (here.length && (this.gameVer ?? null) !== c.ver) {
@@ -839,10 +969,22 @@ export class NetRoom {
839
969
  c.typed = c.agent || this.policy.kids ? '' : typeof m.name === 'string' ? stripAi(m.name.replace(/\s+/g, ' ').trim()).slice(0, 24) : '';
840
970
  if (Array.isArray(m.caps)) for (const k of m.caps) if (CAPS.includes(k)) c.caps.add(k);
841
971
  // The manifest's player count, from the first visitor of an empty room (the site's Table reads the manifest).
842
- if (Number.isInteger(m.max) && !this.seats.size && !live.length) { this.askedMax = m.max; this.maxPlayers = Math.max(1, Math.min(this.seatCap, m.max)); }
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)); }
843
973
  this.reapSeats(now);
974
+ // The verified pass names one AI. Reopening its socket resumes that seat and replaces the old socket.
975
+ const agentStay = c.agent?.pass ? [...this.seats.values()].find(s => s.agent?.pass === c.agent.pass) : null;
976
+ const resumeToken = agentStay?.token ?? (typeof m.token === 'string' ? m.token : '');
977
+ const resumeBan = agentStay && this.banOf(c, resumeToken);
978
+ if (resumeBan) return refuse('kicked', resumeBan.message, { until: resumeBan.until });
979
+ // Pace the verified pass's stay, not its replaceable socket. Refusal cannot evict its current holder.
980
+ if (agentStay) {
981
+ const times = (agentStay.reopenTimes ?? []).filter(at => at > now - 60_000);
982
+ const retryMs = Math.max((times.at(-1) ?? -Infinity) + 4000 - now, times.length >= 8 ? times[0] + 60_000 - now : 0);
983
+ if (retryMs > 0) return refuse('agent-pace', 'this AI seat reopened too recently', { retryMs, final: false });
984
+ agentStay.reopenTimes = [...times, now];
985
+ }
844
986
  let full = false;
845
- if (c.want === 'play' && !this.seatClient(c, typeof m.token === 'string' ? m.token : '')) {
987
+ if (c.want === 'play' && !this.seatClient(c, resumeToken)) {
846
988
  // The studio's own house guide (a loopback) makes way for an AI with a pass (the owner's Claude, say).
847
989
  const house = c.agent && !c.conn.loopback ? this.live().find((o) => o.agent && o.conn.loopback && o.seat !== null) : null;
848
990
  if (house) {
@@ -872,7 +1014,8 @@ export class NetRoom {
872
1014
  // A watcher hosts only a room nobody else can host (a player who can is elected first), and never deposes one;
873
1015
  // an agent hosts only a room no person can host.
874
1016
  const lastResort = (c.watch && live.some((o) => o.canHost && !o.watch && !o.agent && !o.hidden)) || (c.agent && live.some((o) => o.canHost && !o.agent && !o.hidden));
875
- if (c.canHost && !lastResort && (preferred === undefined || preferred === c.seat)) {
1017
+ // A browser never becomes host of a server-hosted room.
1018
+ if (!this.server && c.canHost && !lastResort && (preferred === undefined || preferred === c.seat)) {
876
1019
  if (!h) {
877
1020
  role = 'host';
878
1021
  why = this.lastSnap || this.lastCkpt ? 'resumed' : 'first';
@@ -915,6 +1058,8 @@ export class NetRoom {
915
1058
  ...(c.agent ? { agent: { ...c.agent, name: c.name } } : {}),
916
1059
  });
917
1060
  for (const o of this.others(c)) this.send(o, { t: 'join', peer: this.peer(c) });
1061
+ // 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); }
918
1063
  // A seat taken by a browser that also watches this room: its watching tab sees the overview from now on.
919
1064
  this.refreshWatchers(c);
920
1065
  if (deposed) {
@@ -1003,6 +1148,7 @@ export class NetRoom {
1003
1148
  this.stats.seated += 1;
1004
1149
  this.send(c, { t: 'seat', seat: c.seat, token: c.token, name: c.name, colour: c.colour, role: this.roleOf(c) });
1005
1150
  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); }
1006
1152
  this.log({ ev: 'seated', room: this.code, id: c.id, seat: c.seat });
1007
1153
  this.persist();
1008
1154
  this.refreshWatchers();
@@ -1015,9 +1161,20 @@ export class NetRoom {
1015
1161
  /** The game's agents.json (the Table reads it once from the build): the only goals and lines an AI here has. */
1016
1162
  setVocabulary(raw) {
1017
1163
  this.vocab = raw ? vocabularyOf(raw).vocab : null;
1164
+ this.rawVocab = this.vocab ? raw : null;
1165
+ if (this.server) this.toServer({ t: 'vocabulary', vocab: this.rawVocab });
1018
1166
  return Boolean(this.vocab);
1019
1167
  }
1020
1168
 
1169
+ /** Browser and server hosts share the same view validation and pacing. */
1170
+ agentViewOk(m, bytes, now, isHost) {
1171
+ const o = isHost && Number.isInteger(m.to) ? this.live().find((x) => x.seat === m.to && x.agent) : null;
1172
+ if (!o || bytes > AGENT_FRAMES.viewBytes || now - o.viewAt < AGENT_FRAMES.viewMs - 250) { this.stats.agentDrops += 1; return false; }
1173
+ o.viewAt = now;
1174
+ o.lastView = m.d && typeof m.d === 'object' && !Array.isArray(m.d) ? m.d : null;
1175
+ return true;
1176
+ }
1177
+
1021
1178
  /** The seats people hold now (a "player" argument names one of them). */
1022
1179
  peopleSeats() { return this.seatedHumans().map((c) => c.seat); }
1023
1180
 
@@ -1032,14 +1189,14 @@ export class NetRoom {
1032
1189
  const d = m.d && typeof m.d === 'object' && !Array.isArray(m.d) ? m.d : {};
1033
1190
  if (kind === 'agent:do') {
1034
1191
  if (c.id === this.hostId || !this.vocab || bytes > 1024 || now - c.doAt < AGENT_FRAMES.doMs) return false;
1035
- const goal = this.vocab.goals[d.goal];
1192
+ const goal = (typeof d.goal === 'string' && Object.hasOwn(this.vocab.goals, d.goal) ? this.vocab.goals[d.goal] : null);
1036
1193
  if (typeof d.goal !== 'string' || !goal || checkArgs(goal.args, d.args, ctx)) return false;
1037
1194
  c.doAt = now;
1038
1195
  return true;
1039
1196
  }
1040
1197
  if (SPEECH.test(kind)) {
1041
1198
  const id = SAY.exec(kind)?.[1];
1042
- const line = id && this.vocab ? this.vocab.lines[id] : null;
1199
+ const line = id && this.vocab ? (Object.hasOwn(this.vocab.lines, id) ? this.vocab.lines[id] : null) : null;
1043
1200
  return Boolean(line && talks(this.policy) && bytes <= 1024 && !checkArgs(line.args, d.args, ctx));
1044
1201
  }
1045
1202
  return !this.lite(c);
@@ -1052,7 +1209,7 @@ export class NetRoom {
1052
1209
  */
1053
1210
  aiLineOk(kind, d) {
1054
1211
  const id = SAY.exec(kind)?.[1];
1055
- const line = id && this.vocab ? this.vocab.lines[id] : null;
1212
+ const line = id && this.vocab ? (Object.hasOwn(this.vocab.lines, id) ? this.vocab.lines[id] : null) : null;
1056
1213
  if (!line || !talks(this.policy)) return false;
1057
1214
  const speaker = Number.isInteger(d.seat) ? this.live().find((o) => o.seat === d.seat && o.agent) : null;
1058
1215
  if (Number.isInteger(d.seat) && !speaker) return false;
@@ -1069,7 +1226,7 @@ export class NetRoom {
1069
1226
  const ask = ASK.exec(kind)?.[1];
1070
1227
  if (ask) {
1071
1228
  const seat = out.d && Number.isInteger(out.d.seat) ? out.d.seat : null;
1072
- if (seat === null || (this.vocab && !this.vocab.asks[ask])) return;
1229
+ if (seat === null || (this.vocab && !Object.hasOwn(this.vocab.asks, ask))) return;
1073
1230
  for (const o of this.live()) if (o !== c && this.lite(o) && o.seat === seat) this.send(o, out);
1074
1231
  return;
1075
1232
  }
@@ -1375,6 +1532,8 @@ export class NetRoom {
1375
1532
  this.hostId = null;
1376
1533
  this.elect(null, why === 'replaced' ? 'host-replaced' : why === 'silent' ? 'host-stalled' : 'host-left');
1377
1534
  }
1535
+ // The server host: the socket closed and the seat is held, so the body is away.
1536
+ if (this.server && c.seat !== null) this.toServer({ t: 'leave', id: c.id, seat: c.seat, why });
1378
1537
  if (c.seat !== null && !c.agent) this.lastHumanAt = now;
1379
1538
  // An agent never keeps a room from forgetting (DESIGN D9): the room is empty when no person is left.
1380
1539
  if (!this.live().some((o) => !o.agent) && !this.emptySince) this.emptySince = now;
@@ -1389,6 +1548,8 @@ export class NetRoom {
1389
1548
  * is handed the last checkpoint, the last snapshot (newer), the round, the roster and the keyed state.
1390
1549
  */
1391
1550
  elect(excludeId, why) {
1551
+ // The server is never elected away.
1552
+ if (this.server) return null;
1392
1553
  const now = this.now();
1393
1554
  const silent = (c) => Number(now - c.lastSeen > this.silentMs);
1394
1555
  const tenure = (c) => Math.floor((now - c.joinedAt) / 30_000);
@@ -1458,7 +1619,7 @@ export class NetRoom {
1458
1619
  }
1459
1620
  }
1460
1621
  // Nobody hosting (a restored room whose old host did not come back in time): elect.
1461
- if (!this.host() && (!this.preferHost || now >= this.preferHost.until)) {
1622
+ if (!this.server && !this.host() && (!this.preferHost || now >= this.preferHost.until)) {
1462
1623
  this.preferHost = null;
1463
1624
  if (this.live().some((c) => c.canHost)) this.elect(null, 'host-left');
1464
1625
  }
@@ -1474,31 +1635,37 @@ export class NetRoom {
1474
1635
  }
1475
1636
  }
1476
1637
  this.reapSeats(now);
1638
+ this.syncServerSeats();
1477
1639
  this.seatWaiting();
1478
1640
  // The chat window is minutes long; buckets of senders who went quiet are dropped.
1479
1641
  const keepMs = windowMsOf(this.chatRules());
1480
1642
  while (this.chatLog.length && now - this.chatLog[0].at > keepMs) this.chatLog.shift();
1481
1643
  if (this.chatBuckets.size > 512) this.chatBuckets.clear();
1482
1644
  if (this.chatAddress.size > 512) this.chatAddress.clear();
1483
- if (!this.live().some((c) => !c.agent) && this.emptySince && now - this.emptySince > this.forgetMs) {
1484
- for (const c of this.live()) this.kick(c, 'agents-alone', 4001);
1485
- this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
1486
- this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null;
1487
- this.emptySince = 0; this.openedAt = 0; this.askedMax = null;
1488
- // The room is nobody's build again (section 23): the next visitor's is its build.
1489
- this.gameVer = undefined;
1490
- // The party's dial and vote are the room's: a new party starts from the server's level.
1491
- this.level = null; this.levelBy = null; this.vote = null; this.agentsOut = null;
1492
- // Room chat (section 19): an empty room forgets what was said, as it forgets everything else.
1493
- this.chatLog = []; this.chatBuckets.clear(); this.chatAddress.clear(); this.chatReacts = [];
1494
- // A room that keeps history reads it back when somebody comes again (it is in D1, not here).
1495
- this.hydrated = false;
1496
- this.seatsDirty = false; this.persistDirty = false;
1497
- try { this.store?.clear?.(); } catch { /* best effort */ }
1498
- }
1645
+ if (!this.live().some((c) => !c.agent) && this.emptySince && now - this.emptySince > this.forgetMs) this.forget();
1499
1646
  this.persist(now);
1500
1647
  }
1501
1648
 
1649
+ /** An EMPTY room forgets everything (after `forgetMs`, or when the Table's alarm ends a server-hosted room nobody came back to). */
1650
+ forget() {
1651
+ for (const c of this.live()) this.kick(c, 'agents-alone', 4001);
1652
+ this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
1653
+ this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null;
1654
+ this.emptySince = 0; this.openedAt = 0; this.askedMax = null;
1655
+ // The room is nobody's build again (section 23): the next visitor's is its build.
1656
+ this.gameVer = undefined;
1657
+ // The party's dial and vote are the room's: a new party starts from the server's level.
1658
+ this.level = null; this.levelBy = null; this.vote = null; this.agentsOut = null;
1659
+ // Room chat (section 19): an empty room forgets what was said, as it forgets everything else.
1660
+ this.chatLog = []; this.chatBuckets.clear(); this.chatAddress.clear(); this.chatReacts = [];
1661
+ // A room that keeps history reads it back when somebody comes again (it is in D1, not here).
1662
+ this.hydrated = false;
1663
+ this.seatsDirty = false; this.persistDirty = false;
1664
+ try { this.store?.clear?.(); } catch { /* best effort */ }
1665
+ this.hosted.clear();
1666
+ try { this.onForget?.(); } catch { /* the room is forgotten either way */ }
1667
+ }
1668
+
1502
1669
  /* ------------------------------------------------------------ servers, agents and the dial (section 17) */
1503
1670
 
1504
1671
  /**
@@ -1526,7 +1693,7 @@ export class NetRoom {
1526
1693
  if (Array.isArray(this.lastRoster)) this.lastRoster = this.labelRoster(this.lastRoster);
1527
1694
  this.seatsDirty = true;
1528
1695
  this.officeDirty = true;
1529
- this.broadcast({ t: 'policy', policy: this.policyOut() });
1696
+ this.tellPolicy();
1530
1697
  this.tellWatchers();
1531
1698
  return { ok: true, policy: this.policyOut(), leaving: this.agentsOut ? agents : 0, ...(this.agentsOut ? { until: this.agentsOut.until } : {}) };
1532
1699
  }
@@ -1694,7 +1861,7 @@ export class NetRoom {
1694
1861
  v.result = { level: this.level, name: s.name, votes: counted.length, why };
1695
1862
  // Saved on the next tick, not within 30 s: a deploy right after the vote keeps the party's choice.
1696
1863
  this.seatsDirty = true;
1697
- this.broadcast({ t: 'policy', policy: this.policyOut() });
1864
+ this.tellPolicy();
1698
1865
  } else v.result = { level: this.levelNow(), name: skillPreset(this.levelNow()).name, votes: 0, why };
1699
1866
  this.tellVote();
1700
1867
  return v.result;
@@ -1705,7 +1872,7 @@ export class NetRoom {
1705
1872
  /** Seats, the policy and the party's dial on change; checkpoint/round/roster/state at most every persistMs. Never per frame. */
1706
1873
  persist(now = this.now(), force = false) {
1707
1874
  if (!this.store) return;
1708
- const due = this.seatsDirty || (this.persistDirty && now - this.lastPersistAt >= this.persistMs);
1875
+ const due = this.seatsDirty || (!this.server && this.persistDirty && now - this.lastPersistAt >= this.persistMs);
1709
1876
  if (!force && !due) return;
1710
1877
  this.seatsDirty = false;
1711
1878
  this.persistDirty = false;
@@ -1717,11 +1884,13 @@ export class NetRoom {
1717
1884
  saved(now = this.now()) {
1718
1885
  return {
1719
1886
  v: NET_VERSION, room: this.code, savedAt: now, maxPlayers: this.maxPlayers, hostSeat: this.host()?.seat ?? null, openedAt: this.openedAt || null,
1720
- seats: [...this.seats].map(([seat, s]) => [seat, s.token, s.name, s.agent ?? null, s.occ ?? null]),
1887
+ seats: [...this.seats].map(([seat, s]) => [seat, s.token, s.name, s.agent ?? null, s.occ ?? null, ...(this.server ? [s.present ? null : s.since] : [])]),
1721
1888
  occSeq: this.occSeq, ...(this.gameVer !== undefined ? { gameVer: this.gameVer } : {}),
1722
1889
  ckpt: this.lastCkpt, round: this.lastRound, roster: this.lastRoster, state: this.stateObject(),
1723
1890
  // Section 17: a deploy keeps the room's policy, the party's dial and what its game reads.
1724
1891
  policy: this.policy, level: this.level, levelBy: this.levelBy, caps: [...this.caps],
1892
+ // The Table saves this seat map and the server's whole world in one transaction.
1893
+ ...(this.server ? { hosted: 'server' } : {}),
1725
1894
  };
1726
1895
  }
1727
1896
 
@@ -1733,9 +1902,9 @@ export class NetRoom {
1733
1902
  restore(saved, graceMs = 3000) {
1734
1903
  const now = this.now();
1735
1904
  if (!saved || saved.v !== NET_VERSION || !(now - Number(saved.savedAt) < 120_000)) return false;
1736
- if (Number.isInteger(saved.maxPlayers)) this.maxPlayers = Math.max(1, Math.min(this.seatCap, saved.maxPlayers));
1905
+ if (!this.server && saved.hosted !== 'server' && Number.isInteger(saved.maxPlayers)) this.maxPlayers = Math.max(1, Math.min(this.seatCap, saved.maxPlayers));
1737
1906
  if (Number.isFinite(saved.openedAt) && saved.openedAt > 0) this.openedAt = saved.openedAt;
1738
- for (const [seat, token, name, agent, occ] of saved.seats ?? []) this.seats.set(seat, { token, name, since: now, present: false, agent: agent && typeof agent === 'object' ? agent : null, ...(Number.isInteger(occ) ? { occ } : {}) });
1907
+ 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 } : {}) });
1739
1908
  // A stay's number is never given twice while a checkpoint may still name it.
1740
1909
  this.occSeq = Math.max(this.occSeq, Number.isInteger(saved.occSeq) ? saved.occSeq : 0, ...[...this.seats.values()].map((x) => x.occ ?? 0));
1741
1910
  if ('gameVer' in saved) this.gameVer = versionOf(saved.gameVer);
@@ -1745,15 +1914,17 @@ export class NetRoom {
1745
1914
  if (Array.isArray(saved.caps)) this.caps = new Set(saved.caps.filter((k) => CAPS.includes(k)));
1746
1915
  // The people coming back get the agents' grace too (an AI is not "alone" in a room a deploy just emptied).
1747
1916
  if ((saved.seats ?? []).some((s) => !s[3])) this.lastHumanAt = now;
1748
- this.lastCkpt = saved.ckpt ?? null;
1749
- this.lastRound = saved.round ?? null;
1750
- this.lastRoster = saved.roster ?? null;
1751
- for (const [k, d] of Object.entries(saved.state ?? {})) {
1917
+ // 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
+ const fresh = saved.hosted === 'server' && !saved.durable;
1919
+ this.lastCkpt = fresh ? null : saved.ckpt ?? null;
1920
+ this.lastRound = fresh ? null : saved.round ?? null;
1921
+ this.lastRoster = fresh ? null : saved.roster ?? null;
1922
+ for (const [k, d] of Object.entries(fresh ? {} : saved.state ?? {})) {
1752
1923
  const bytes = JSON.stringify({ t: 'state', k, d }).length;
1753
1924
  this.state.set(k, { d, bytes });
1754
1925
  this.stateBytes += bytes;
1755
1926
  }
1756
- if (Number.isInteger(saved.hostSeat)) this.preferHost = { seat: saved.hostSeat, until: now + graceMs };
1927
+ if (Number.isInteger(saved.hostSeat) && saved.hosted !== 'server') this.preferHost = { seat: saved.hostSeat, until: now + graceMs };
1757
1928
  this.emptySince = now;
1758
1929
  this.stats.restoredFrom = now - saved.savedAt;
1759
1930
  this.log({ ev: 'restored', room: this.code, seats: this.seats.size, ageMs: now - saved.savedAt, hostSeat: saved.hostSeat });
@@ -1947,6 +2118,8 @@ export class NetRoom {
1947
2118
  this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null; this.openedAt = 0; this.askedMax = null;
1948
2119
  this.emptySince = now; this.seatsDirty = false; this.persistDirty = false;
1949
2120
  try { this.store?.clear?.(); } catch { /* best effort */ }
2121
+ this.hosted.clear();
2122
+ try { this.onForget?.(); } catch { /* the room is closed either way */ }
1950
2123
  const msg = JSON.stringify({ t: 'closed', room: this.code, until, message });
1951
2124
  for (const w of this.watchers) this.sendText(w, msg);
1952
2125
  this.tellWatchers();
@@ -1987,7 +2160,7 @@ export class NetRoom {
1987
2160
  this.level = Math.min(this.policy.levelMax, n);
1988
2161
  this.levelBy = 'owner';
1989
2162
  this.seatsDirty = true;
1990
- this.broadcast({ t: 'policy', policy: this.policyOut() });
2163
+ this.tellPolicy();
1991
2164
  this.tellWatchers();
1992
2165
  return { ok: true, op, level: this.level, name: skillPreset(this.level, { kids: this.policy.kids }).name };
1993
2166
  }
@@ -2054,7 +2227,7 @@ export class NetRoom {
2054
2227
 
2055
2228
  setSeats(n, perIp = null) {
2056
2229
  this.seatCap = Math.max(1, Math.floor(n));
2057
- this.maxPlayers = Math.max(1, Math.min(this.seatCap, this.askedMax ?? this.seatCap));
2230
+ this.maxPlayers = Math.max(1, Math.min(this.seatCap, this.server ? this.seatCap : this.askedMax ?? this.seatCap));
2058
2231
  if (Number.isFinite(perIp) && perIp > 0) this.perIp = perIp;
2059
2232
  }
2060
2233
 
@@ -2152,6 +2325,8 @@ export class NetRoom {
2152
2325
  caps: [...this.caps],
2153
2326
  // Revision 9: the build this room runs (null: the game names none, or nobody is here) and its stall time.
2154
2327
  ver: this.gameVer ?? null, stallMs: this.stallMs,
2328
+ // Revision 10: the rules run on the server, and how its clock is keeping (ticks run, late ticks, the budget).
2329
+ ...(this.server ? { hosted: 'server', ticks: this.server.facts?.() ?? null } : this.hostFailed ? { hosted: 'server', hostFailed: this.hostFailed } : {}),
2155
2330
  // Revision 7: whether this room's game has a vocabulary its AI may speak (agents.json).
2156
2331
  vocab: Boolean(this.vocab),
2157
2332
  memory: {