@homie-rocks/studio 0.35.0 → 0.36.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 (45) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/agents/agents.ts +8 -6
  3. package/bin/homie-studio.mjs +9 -6
  4. package/lab/lab.ts +15 -0
  5. package/lib/build.mjs +20 -15
  6. package/lib/dev.mjs +16 -1
  7. package/lib/lab-check.mjs +18 -3
  8. package/lib/lab.mjs +21 -7
  9. package/lib/mcp-tools.mjs +2 -1
  10. package/lib/rules-build.mjs +97 -49
  11. package/lib/rules-check-worker.mjs +405 -0
  12. package/lib/rules-check.mjs +69 -0
  13. package/lib/rules-compiler.mjs +30 -0
  14. package/lib/rules-guard.mjs +26 -7
  15. package/lib/rules-types.mjs +167 -0
  16. package/lib/shop-catalog.mjs +20 -1
  17. package/lib/shop-links.mjs +3 -1
  18. package/lib/shop.mjs +14 -8
  19. package/lib/standalone-files.mjs +2 -2
  20. package/lib/standalone.mjs +6 -2
  21. package/lib/stripe-connect.mjs +18 -7
  22. package/lib/template-history.json +83 -0
  23. package/lib/typecheck.mjs +52 -3
  24. package/netplay/NETPLAY.md +123 -6
  25. package/netplay/netplay.ts +289 -53
  26. package/netplay/rules-output.mjs +85 -0
  27. package/package.json +3 -2
  28. package/rules/browser.ts +100 -0
  29. package/rules/core.ts +159 -58
  30. package/rules/guard.ts +11 -4
  31. package/rules/host.ts +90 -15
  32. package/rules/math.ts +16 -0
  33. package/rules/pack.ts +94 -17
  34. package/rules/rules.ts +21 -13
  35. package/rules/types.ts +63 -0
  36. package/rules/view.ts +36 -33
  37. package/standalone/shell/shell.js +8 -6
  38. package/worker/brain.mjs +2 -1
  39. package/worker/hosted.mjs +1 -1
  40. package/worker/index.mjs +7 -11
  41. package/worker/limits.mjs +41 -0
  42. package/worker/pages.mjs +4 -2
  43. package/worker/room.mjs +136 -63
  44. package/worker/stripe.mjs +2 -2
  45. package/worker/version.mjs +1 -1
@@ -88,9 +88,9 @@
88
88
 
89
89
  export const NETPLAY_VERSION = 1;
90
90
  /** The contract revision this helper speaks (NETPLAY.md): its hello says so (`rev`), and so does every build of it. */
91
- export const NETPLAY_REVISION = 10;
91
+ export const NETPLAY_REVISION = 11;
92
92
  /** In every bundle that includes the helper: `homie-studio build` reads it to tell the office which revision a build speaks. */
93
- export const NETPLAY_MARK = 'homie-netplay-rev:10';
93
+ export const NETPLAY_MARK = 'homie-netplay-rev:11';
94
94
 
95
95
  export type Role = 'host' | 'replica' | 'screen';
96
96
  export type Device = 'phone' | 'desk' | 'tv';
@@ -571,6 +571,35 @@ export interface Probes {
571
571
  [extra: string]: unknown;
572
572
  }
573
573
 
574
+ /** The guarded rules module supplies this adapter lazily; the vendorable helper imports no runtime. */
575
+ export interface RulesHost {
576
+ frame(m: Record<string, unknown>): void;
577
+ sync(peers: Peer[]): void;
578
+ /** The round, the roster, the shared state and the capabilities again, as output: a relay that restarted has none of them. */
579
+ announce(): void;
580
+ pause(): void;
581
+ resume(): void;
582
+ start(): void;
583
+ stop(): void;
584
+ save(): unknown;
585
+ readonly tick: number;
586
+ facts(): Record<string, unknown>;
587
+ }
588
+ export type RulesHostFactory = (o: { now(): number; send(m: Record<string, unknown>): void; restore: unknown; peers?: Peer[]; held?: Peer[]; policy?: Policy; onEnd(why: string): void }) => RulesHost;
589
+
590
+ export interface RulesOutputSender {
591
+ push(frame: Record<string, unknown>): void;
592
+ finish(frame: Record<string, unknown>): void;
593
+ /** `yield`, in order behind the checkpoint; what the rules make after it is held until `release()` or `stop()`. */
594
+ handOver(frame: Record<string, unknown>): void;
595
+ readonly handing: boolean;
596
+ readonly waiting: boolean;
597
+ release(): void;
598
+ /** The page is going: what the allowance permits, then the last checkpoint, always. */
599
+ leave(): void;
600
+ stop(): void;
601
+ }
602
+
574
603
  export interface NetplayOptions<C = unknown> {
575
604
  /** Defaults to `window.HOMIE_NET`. `null` forces offline. */
576
605
  config?: NetConfig | null;
@@ -582,11 +611,15 @@ export interface NetplayOptions<C = unknown> {
582
611
  /** May this browser be elected host? Default true. */
583
612
  canHost?: boolean;
584
613
  /**
585
- * Revision 10: this game is written as rules plus view, and its room's host is the server (NETPLAY.md section 29).
586
- * This browser is never elected host, sends its input with `net.steps`, and reads the control table as rows of
614
+ * Revision 10: this game is written as rules plus view (NETPLAY.md section 29). Its elected browser or server
615
+ * runs the same host runtime. The view sends input with `net.steps` and reads the control table as rows of
587
616
  * `[seat, r, ack, lead]`. Set by the view library (`openRoom`), not by a game.
588
617
  */
589
618
  rules?: boolean;
619
+ /** Shared relay limits supplied by the rules view; legacy vendor copies stay dependency-free. */
620
+ rulesLimits?: { bytes: Record<string, number>; rates: Record<string, number>; output: (opts: { rates: Record<string, number>; send: (frame: Record<string, unknown>) => void; now: () => number }) => RulesOutputSender };
621
+ /** Set by the rules view's build. Server games load this only once playable, or when starting offline. */
622
+ rulesHost?: { mode: 'server' | 'browser'; offline: boolean; load?: () => Promise<RulesHostFactory> };
590
623
  /** Game id, for the relay's logs. */
591
624
  game?: string;
592
625
  /** Seats in a room (the manifest's players.max). The relay takes it from the first visitor of an empty room. */
@@ -747,6 +780,7 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
747
780
  readonly seat: number | null;
748
781
  readonly spectator: boolean;
749
782
  readonly offline: boolean;
783
+ readonly rulesHosting: boolean;
750
784
  readonly connected: boolean;
751
785
  /** Why the client stopped for good (a refusal reconnecting would repeat: 'replaced', 'room-full', ...), else null. */
752
786
  readonly closedWhy: string | null;
@@ -1293,7 +1327,7 @@ const LADDER = [250, 500, 1000, 2000, 4000];
1293
1327
  * second of frames at once), and a kick that ended the page's play for good left a phone frozen with no word.
1294
1328
  * After one it comes back (its seat token keeps its body), sending at the slow rate below.
1295
1329
  */
1296
- const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many', 'kicked', 'room-closed', 'watch-off', 'agent-pass', 'agents-off', 'agents-unsupported', 'stale']);
1330
+ const FINAL_ERRORS = new Set(['room-over', 'host-failed', 'replaced', 'version', 'room-full', 'too-many', 'kicked', 'room-closed', 'watch-off', 'agent-pass', 'agents-off', 'agents-unsupported', 'stale']);
1297
1331
  /** An AI closed for being alone in a room (`agents-alone`, section 17) waits this long before it knocks again. */
1298
1332
  const AGENTS_ALONE_WAIT_MS = 30_000;
1299
1333
  /**
@@ -1469,8 +1503,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1469
1503
  const checkpointMs = Math.max(250, opts.checkpointMs ?? 1000);
1470
1504
  const connectTimeoutMs = opts.connectTimeoutMs ?? 4000;
1471
1505
  const connectOpenMaxMs = Math.max(2000, Math.min(120_000, Number(opts.connectOpenMaxMs) || CONNECT_OPEN_MAX_MS));
1472
- const reconnectMaxMs = Math.max(5000, Math.min(300_000, Number(opts.reconnectMaxMs) || RECONNECT_MAX_MS));
1473
- const heartbeatMs = opts.heartbeatMs === 0 ? 0 : Math.max(200, Math.min(2000, Number(opts.heartbeatMs) || HEARTBEAT_MS));
1506
+ const reconnectMaxMs = Math.max(5000, Math.min(300_000, Number(opts.reconnectMaxMs) || (opts.rules && (opts.rulesHost?.offline || opts.rulesHost?.mode === 'browser') ? 5000 : RECONNECT_MAX_MS)));
1507
+ const heartbeatMs = opts.rules === true || opts.heartbeatMs === 0 ? 0 : Math.max(200, Math.min(2000, Number(opts.heartbeatMs) || HEARTBEAT_MS));
1474
1508
  const staleMs = Math.max(2500, opts.staleMs ?? 6000);
1475
1509
  // Revisions (section 23): the game's revision (the page's word, or the game's own) and what this build can do.
1476
1510
  const version = cleanVersion(opts.version ?? cfg?.ver);
@@ -1480,7 +1514,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1480
1514
  const watching = Boolean(opts.watch ?? cfg?.watch);
1481
1515
  const want: Want = watching ? 'screen' : (opts.want ?? cfg?.want ?? 'play');
1482
1516
  const rulesGame = opts.rules === true;
1483
- const canHost = rulesGame ? false : opts.canHost ?? true;
1517
+ let canHost = rulesGame ? opts.rulesHost?.mode === 'browser' : opts.canHost ?? true;
1484
1518
  const defaultOwn = (opts.movement ?? 'owner') === 'owner';
1485
1519
 
1486
1520
  // Where parent notifications go: the shell page around a framed game.
@@ -1508,6 +1542,143 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1508
1542
  };
1509
1543
 
1510
1544
  // ---------------------------------------------------------------- state
1545
+ let rulesHost: RulesHost | null = null;
1546
+ let hostLoad: Promise<RulesHostFactory> | null = null;
1547
+ let hostGeneration = 0;
1548
+ let pendingRules: Record<string, unknown>[] = [];
1549
+ let disposed = false;
1550
+ let onlineRulesSave: unknown = null;
1551
+ const canOffline = !rulesGame || Boolean(opts.rulesHost?.offline || canHost);
1552
+ function loadHost(): Promise<RulesHostFactory> | null {
1553
+ if (!opts.rulesHost?.load) return null;
1554
+ return hostLoad ??= Promise.resolve().then(() => opts.rulesHost!.load!()).catch((error) => { hostLoad = null; throw error; });
1555
+ }
1556
+ let rulesSender: RulesOutputSender | null = null;
1557
+ let yieldTimer: ReturnType<typeof setTimeout> | null = null;
1558
+ function stopRules(): void { if (yieldTimer !== null) { clearTimeout(yieldTimer); yieldTimer = null; } rulesSender?.stop(); rulesSender = null; hostGeneration += 1; rulesHost?.stop(); rulesHost = null; pendingRules = []; }
1559
+ /**
1560
+ * Checkpoint, then yield: one ordered act. A rules host's yield follows its checkpoint through the sender, and what
1561
+ * its rules make afterwards is held until the relay answers (a `role` to somebody else, or `host` with
1562
+ * `why: 'host-kept'`), so no page is shown a tick the next host will run again.
1563
+ */
1564
+ function yieldHost(extra: Record<string, unknown> = {}): boolean {
1565
+ sendCheckpoint();
1566
+ if (!rulesHost || !rulesSender) return raw({ t: 'yield', ...extra });
1567
+ rulesSender.handOver(extra);
1568
+ // An answer that never comes (a relay that does not know the frame) must not hold the room's output for good.
1569
+ if (yieldTimer === null) yieldTimer = setTimeout(yieldAnswered, 2000);
1570
+ return true;
1571
+ }
1572
+ function yieldAnswered(): void {
1573
+ if (yieldTimer !== null) { clearTimeout(yieldTimer); yieldTimer = null; }
1574
+ rulesPace = null;
1575
+ rulesSender?.release();
1576
+ // A second yield was queued behind what was held: it waits for its own answer.
1577
+ if (rulesSender?.waiting) yieldTimer = setTimeout(yieldAnswered, 2000);
1578
+ }
1579
+ function rulesFrame(m: Record<string, unknown>): boolean {
1580
+ if (!rulesGame || role !== 'host' || offline) return false;
1581
+ if (rulesHost) rulesHost.frame(m);
1582
+ else if (pendingRules.length < 256) pendingRules.push(m);
1583
+ return true;
1584
+ }
1585
+ function rulesPeers(): Peer[] {
1586
+ if (!offline) return [...peers.values()];
1587
+ return seat === null ? [] : [{ id: 'offline', seat, name: name || 'You', colour: seat % 12, device, want: 'play', role: 'host', occ: 1 }];
1588
+ }
1589
+ function rulesNotice(text: string): void {
1590
+ api.line(text);
1591
+ setTimeout(() => { if (!disposed && gameLine === text) api.line(null); }, 8000);
1592
+ }
1593
+ function unavailableRules(): void {
1594
+ canHost = false;
1595
+ stopRules(); setRole('replica', 'rules-unavailable');
1596
+ if (!offline && ws) { raw({ t: 'bye' }); lost(ws, 'rules-unavailable'); }
1597
+ api.line('This game needs a connection.');
1598
+ }
1599
+ function endRules(why: string, message = `This game's rules stopped: ${why}`): void {
1600
+ if (offline && cfg?.url) {
1601
+ stopRules(); setRole('replica', 'offline-rules-ended');
1602
+ api.line(`Offline rules stopped: ${why}. This game needs a connection.`);
1603
+ return;
1604
+ }
1605
+ const sender = rulesSender; rulesSender = null;
1606
+ if (sender) sender.finish({ t: 'rules-end', why }); else raw({ t: 'rules-end', why, rules: true });
1607
+ stopRules(); setRole('replica', 'rules-ended');
1608
+ closed = true; api.line(message); setLink('closed', 'rules-ended');
1609
+ }
1610
+ function startRules(restore: unknown): void {
1611
+ stopRules();
1612
+ const generation = hostGeneration;
1613
+ const loading = loadHost();
1614
+ if (!loading) { unavailableRules(); return; }
1615
+ void loading.then((make) => {
1616
+ if (disposed || generation !== hostGeneration || role !== 'host') return;
1617
+ try {
1618
+ rebase = true;
1619
+ rulesPace = null;
1620
+ if (!opts.rulesLimits) throw new Error('Rules output limits are missing');
1621
+ rulesSender = opts.rulesLimits.output({ rates: opts.rulesLimits.rates, send: raw, now: () => performance.now() });
1622
+ const options = { now: () => performance.now(), restore, peers: rulesPeers(), held: offline ? [] : heldPeers, policy: offline ? DEFAULT_POLICY : policy, send: rulesOut, onEnd: (why: string) => endRules(why) };
1623
+ try { rulesHost = make(options); }
1624
+ catch (error) {
1625
+ if (!restore) throw error;
1626
+ rulesHost = make({ ...options, restore: null });
1627
+ rulesNotice('The saved round could not be restored. A new round has started.');
1628
+ }
1629
+ rulesHost.frame({ t: 'policy', policy: offline ? DEFAULT_POLICY : policy });
1630
+ rulesHost.sync(rulesPeers());
1631
+ for (const m of pendingRules) rulesHost.frame(m);
1632
+ pendingRules = [];
1633
+ rulesHost.start();
1634
+ if (offline && cfg?.url && !closed) { if (retryTimer !== null) clearTimeout(retryTimer); retryTimer = setTimeout(open, 0); }
1635
+ if (hidden()) rulesHost.pause();
1636
+ sendCheckpoint(); paintLink();
1637
+ } catch (error) {
1638
+ if (generation === hostGeneration && !disposed) { endRules('fault', 'The game rules could not start. Reload to try again.'); console.warn('[netplay] rules startup failed', error); }
1639
+ }
1640
+ }, (error) => {
1641
+ if (generation === hostGeneration && !disposed) { unavailableRules(); console.warn('[netplay] rules module failed to load', error); }
1642
+ });
1643
+ }
1644
+ /** A local host sees its own output too. No offline frame is ever sent or saved to the relay. */
1645
+ let rulesPace: { at: number; tick: number } | null = null;
1646
+ let rulesWireStamp = -Infinity;
1647
+ let rulesSimStamp = 0;
1648
+ let rulesFrameAt = 0;
1649
+ let rulesWireOffset: number | null = null;
1650
+ let rulesRound: Record<string, unknown> | null = null;
1651
+ function rulesOut(m: Record<string, unknown>): void {
1652
+ const offset = now() - performance.now();
1653
+ let slowYield: Record<string, unknown> | null = null;
1654
+ if (m.t === 'snap') {
1655
+ const at = performance.now();
1656
+ if (!rulesPace) rulesPace = { at, tick: Number(m.k) };
1657
+ else if (at - rulesPace.at >= 5000) {
1658
+ const { at: paceAt, tick: paceTick } = rulesPace;
1659
+ const slow = (Number(m.k) - rulesPace.tick) * 1000 < (at - rulesPace.at) * (opts.snapshotHz ?? 20) / 2;
1660
+ rulesPace = { at, tick: Number(m.k) };
1661
+ if (slow && !offline && peers.size > 1 && !hidden()) slowYield = { slow: true, speed: (Number(m.k) - paceTick) * 1000 / ((at - paceAt) * (opts.snapshotHz ?? 20)) };
1662
+ }
1663
+ rulesWireStamp = Math.max(rulesWireStamp + Math.max(1, Number(m.st) - rulesSimStamp), Math.round(Number(m.st) + offset));
1664
+ rulesSimStamp = Number(m.st); rulesFrameAt = performance.now();
1665
+ m = { ...m, st: rulesWireStamp };
1666
+ if (rulesWireOffset !== null && Math.abs(offset - rulesWireOffset) > 50 && rulesRound) { const { results, ...round } = rulesRound.round as RoundInfo; rulesOut({ ...rulesRound, round, retime: true }); }
1667
+ rulesWireOffset = offset;
1668
+ }
1669
+ if (m.t === 'round') rulesRound = m;
1670
+ if (m.t === 'round') { const r = m.round as RoundInfo; m = { ...m, round: { ...r, startedAt: Math.round(r.startedAt + offset), endsAt: Math.round(r.endsAt + offset) } }; }
1671
+
1672
+ if (!offline && connected) rulesSender?.push(m);
1673
+ // A slow host yields after the snapshot of the tick its checkpoint saves, so nothing of that tick follows the yield.
1674
+ if (slowYield) yieldHost(slowYield);
1675
+ if (!offline && connected && ['ev', 'roster', 'round', 'state'].includes(String(m.t))) return;
1676
+ if (m.t === 'snap') { tick = Number(m.k); onSnap(m, 0, true); }
1677
+ else if (m.t === 'state' && typeof m.k === 'string') applyState(m.k, m.d);
1678
+ else if (m.t === 'round') { const incoming = m.round as RoundInfo; roundInfo = m.retime && roundInfo?.n === incoming.n ? { ...roundInfo, ...incoming } : incoming; if (!m.retime) { emit('round', roundInfo); post?.({ what: 'round', round: roundInfo }); } }
1679
+ else if (m.t === 'roster' && (offline || !connected)) { slots = m.slots as Slot[]; emit('roster', slots); post?.({ what: 'roster', slots }); }
1680
+ else if (m.t === 'ev' && (m.to === undefined || m.to === seat || m.to === id)) emit('event', { k: String(m.k), d: m.d, from: typeof m.from === 'number' ? m.from : null });
1681
+ }
1511
1682
  let role: Role = 'replica';
1512
1683
  let roleKnown = false;
1513
1684
  let seat: number | null = null;
@@ -1529,8 +1700,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1529
1700
  let staleVer: string | null = null;
1530
1701
  let promotions = 0;
1531
1702
  let lastMsgAt = 0;
1703
+ let socketStartedAt = 0;
1532
1704
  let waitingVisible = false;
1533
1705
  const peers = new Map<string, Peer>();
1706
+ let heldPeers: Peer[] = [];
1534
1707
  let roundInfo: RoundInfo | null = null;
1535
1708
  let slots: Slot[] | null = null;
1536
1709
  let lastCkpt: Checkpoint<C> | null = null;
@@ -1611,6 +1784,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1611
1784
  arrived.by = by;
1612
1785
  arrived.playableMs = Math.round(wall() - createdAt);
1613
1786
  post?.({ what: 'playable', by });
1787
+ if (rulesGame && canOffline && !canHost) void loadHost()?.catch(() => {});
1614
1788
  }
1615
1789
  /**
1616
1790
  * The game's own `net.playable()`. With `arrival: 'auto'` the helper may already have given the game the screen
@@ -1631,7 +1805,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1631
1805
  /** 'auto': a host at once, anyone else at its first snapshot, then two animation frames (the game drew with it). */
1632
1806
  function autoPlayable(): void {
1633
1807
  if (arrival !== 'auto' || playableSent || autoArmed || !roleKnown) return;
1634
- if (role !== 'host' && !snapSeen) return;
1808
+ if ((rulesGame || role !== 'host') && !snapSeen) return;
1635
1809
  autoArmed = true;
1636
1810
  const raf = (globalThis as { requestAnimationFrame?: (cb: () => void) => number }).requestAnimationFrame;
1637
1811
  const later = (fn: () => void): void => { if (typeof raf === 'function') raf(fn); else setTimeout(fn, 16); };
@@ -1744,7 +1918,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1744
1918
  /** Why this page plays alone, and the last refusal that was not final (`room-stale`): the line says the true one. */
1745
1919
  let aloneWhy = '';
1746
1920
  let lastRefusal = '';
1747
- type LineKind = 'reconnecting' | 'alone' | 'stale' | 'closed' | 'full' | 'seat';
1921
+ type LineKind = 'offline' | 'reconnecting' | 'alone' | 'stale' | 'closed' | 'full' | 'seat';
1748
1922
  function showOverlay(kind: LineKind, text: string, forMs = 0): void {
1749
1923
  const el = overlayEl();
1750
1924
  if (!el) return;
@@ -1756,6 +1930,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1756
1930
  }
1757
1931
  /** What is true of a page that plays by itself: it never says "reconnecting" to somebody who was never connected. */
1758
1932
  function aloneText(): string {
1933
+ if (!canOffline) return 'This game needs a connection.';
1934
+ if (rulesGame && !rulesHost) return 'Loading offline play…';
1759
1935
  if (aloneWhy === 'reconnect-timeout') return 'Playing on your own · the room dropped, still trying…';
1760
1936
  if (lastRefusal === 'room-stale') return 'Playing on your own · this room opens when its players have the new version';
1761
1937
  return 'Playing on your own · still looking for the room…';
@@ -1771,11 +1947,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1771
1947
  function paintLink(): void {
1772
1948
  if (!overlayOn) return;
1773
1949
  if (overlayTimer) { clearTimeout(overlayTimer); overlayTimer = null; }
1774
- const closedSays = link === 'closed' ? closedText() : null;
1950
+ const closedSays = link === 'closed' ? gameLine || closedText() : null;
1775
1951
  if (link === 'reconnecting') {
1776
1952
  // A blip shorter than a blink is never drawn.
1777
1953
  overlayTimer = setTimeout(() => { overlayTimer = null; if (link === 'reconnecting') showOverlay('reconnecting', 'Reconnecting…'); }, LINK_OVERLAY_MS);
1778
- } else if (link === 'alone') showOverlay('alone', aloneText());
1954
+ } else if (link === 'offline' && !canOffline) showOverlay('offline', 'This game needs a connection.');
1955
+ else if (link === 'alone') showOverlay('alone', gameLine || aloneText());
1779
1956
  else if (link === 'closed' && closedWhy === 'stale') showOverlay('stale', inApp ? APP_STALE_LINE : 'This game was updated. Tap to reload.');
1780
1957
  else if (closedSays) showOverlay('closed', closedSays);
1781
1958
  // In the room: the game's own sentence about where this player stands, else that the room is full.
@@ -2147,6 +2324,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2147
2324
  }
2148
2325
  post?.({ what: 'role', role: next, prev, why, seat });
2149
2326
  if (next === 'host') { capsSent = ''; sendCaps(); }
2327
+ if (rulesGame) { if (next === 'host') startRules(extra.ckpt?.d ?? null); else stopRules(); }
2150
2328
  resolveReady(e);
2151
2329
  emit('role', e);
2152
2330
  resolveView('seat');
@@ -2171,8 +2349,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2171
2349
  switch (m['t']) {
2172
2350
  case 'welcome': return onWelcome(m);
2173
2351
  case 'snap': return onSnap(m, text.length);
2174
- case 'in': return onInput(m);
2352
+ case 'free': if (![...peers.values()].some((p) => p.seat === m.seat)) rulesFrame(m); return;
2353
+ case 'in': if (rulesFrame(m)) return; return onInput(m);
2175
2354
  case 'ev': {
2355
+ if (m.hosted !== true && rulesFrame(m)) return;
2176
2356
  const k = String(m['k'] ?? '');
2177
2357
  const from = typeof m['from'] === 'number' ? m['from'] : null;
2178
2358
  // Quiet AI (section 17): this browser does not hear an AI's speech, its own or relayed by the host (section 18:
@@ -2182,6 +2362,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2182
2362
  return;
2183
2363
  }
2184
2364
  case 'policy': {
2365
+ rulesFrame(m);
2185
2366
  if (m['policy'] && typeof m['policy'] === 'object') { policy = readPolicy(m['policy']); emit('policy', policy); post?.({ what: 'policy', policy }); }
2186
2367
  return;
2187
2368
  }
@@ -2229,11 +2410,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2229
2410
  return;
2230
2411
  }
2231
2412
  case 'join': {
2413
+ rulesFrame(m);
2232
2414
  const p = m['peer'] as Peer | undefined;
2233
2415
  if (p && typeof p.id === 'string') { peers.set(p.id, p); if (role === 'host' && p.seat !== null) ctlOf(p.seat); emit('join', p); if (watching) resolveView('auto'); }
2234
2416
  return;
2235
2417
  }
2236
2418
  case 'leave': {
2419
+ rulesFrame(m);
2237
2420
  const pid = String(m['id'] ?? '');
2238
2421
  const p = peers.get(pid);
2239
2422
  peers.delete(pid);
@@ -2244,7 +2427,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2244
2427
  return;
2245
2428
  }
2246
2429
  case 'round': {
2247
- roundInfo = m['round'] as RoundInfo;
2430
+ const incoming = m['round'] as RoundInfo;
2431
+ roundInfo = m.retime && roundInfo?.n === incoming.n ? { ...roundInfo, ...incoming } : incoming;
2432
+ if (m.retime) return;
2248
2433
  post?.({ what: 'round', round: roundInfo });
2249
2434
  emit('round', roundInfo);
2250
2435
  return;
@@ -2256,8 +2441,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2256
2441
  return;
2257
2442
  }
2258
2443
  case 'host': {
2444
+ // The relay kept this page as the host after its yield: what the rules made meanwhile goes out.
2445
+ if (m['why'] === 'host-kept') yieldAnswered();
2259
2446
  host = (m['host'] as HostRef | null) ?? null;
2260
- rebase = true; // the next snapshot is on the new host's timeline
2447
+ if (m['why'] !== 'host-kept') rebase = true; // the next snapshot is on the new host's timeline
2261
2448
  for (const p of peers.values()) p.role = host && p.id === host.id ? 'host' : (p.seat === null ? 'screen' : 'replica');
2262
2449
  notifyStats(true);
2263
2450
  return;
@@ -2275,6 +2462,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2275
2462
  return;
2276
2463
  }
2277
2464
  case 'role': {
2465
+ heldPeers = Array.isArray(m.held) ? m.held as Peer[] : [];
2278
2466
  const next = m['role'] as Role;
2279
2467
  host = (m['host'] as HostRef | null) ?? host;
2280
2468
  rebase = true;
@@ -2282,7 +2470,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2282
2470
  if (m['round']) roundInfo = m['round'] as RoundInfo;
2283
2471
  if (m['roster']) slots = m['roster'] as Slot[];
2284
2472
  if (m['state']) replaceState(m['state']);
2285
- const ckpt = (m['ckpt'] as Checkpoint<C> | null) ?? null;
2473
+ const ckpt = (m['ckpt'] as Checkpoint<C> | null) ?? (next === 'host' && !m['snap'] && onlineRulesSave ? { k: tick, st: now(), d: onlineRulesSave as C } : null);
2474
+ onlineRulesSave = null;
2286
2475
  let snap = (m['snap'] as Snapshot<S> | null) ?? null;
2287
2476
  if (next === 'host' && !snap) snap = localSnap();
2288
2477
  if (ckpt) lastCkpt = ckpt;
@@ -2303,6 +2492,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2303
2492
  return;
2304
2493
  }
2305
2494
  case 'decided': {
2495
+ if (rulesFrame(m)) return;
2306
2496
  const n = String(m['n'] ?? '');
2307
2497
  const w = deciding.get(n);
2308
2498
  if (!w) return;
@@ -2333,10 +2523,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2333
2523
  setLink('closed', code);
2334
2524
  return;
2335
2525
  }
2526
+ if (rulesGame && role === 'host' && m.view !== true && ['too-large', 'state-full'].includes(code)) {
2527
+ const message = `This game's rules exceeded the browser room size cap: ${m.message}. Join again for a fresh room.`;
2528
+ console.warn('[netplay]', message); endRules('size', message); return;
2529
+ }
2336
2530
  // Inputs over the relay's cap were dropped: send fewer for a while (the newest frame still goes out).
2337
2531
  if ((code === 'rate' && m['of'] === 'in') || code === 'flood') inSlowUntil = wall() + IN_SLOW_MS;
2338
2532
  // An AI alone in a room (nobody seated): it knocks again only after a while (section 17).
2339
2533
  if (code === 'agents-alone') aloneUntil = wall() + AGENTS_ALONE_WAIT_MS;
2534
+ if (code === 'host-fault') { rulesNotice(String(m.message)); return; }
2340
2535
  if (code === 'vote') { warnOnce('vote', m['message']); return; }
2341
2536
  if (code === 'rate' || code === 'state-full') { warnOnce(`${code}:${String(m['of'] ?? '')}`, m['message']); return; }
2342
2537
  // The room still runs another build of the game (section 23): not final. Its players were told to reload; this
@@ -2351,6 +2546,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2351
2546
  post?.({ what: 'closed', why: code, room: cfg?.room ?? null, ...(typeof m['until'] === 'number' ? { until: m['until'] } : {}), ...(typeof m['message'] === 'string' ? { message: String(m['message']).slice(0, 200) } : {}) });
2352
2547
  // Kept out of a room for running an older build (section 23): the page reloads the game; the game may too.
2353
2548
  if (code === 'stale') noteStale(m['ver'], true, m['immediate'] === true);
2549
+ if (rulesGame) { stopRules(); setRole('replica', code); if (typeof m['message'] === 'string' && !(code === 'room-over' && gameLine?.includes('cap:'))) api.line(m['message']); if (inApp && code === 'stale' && canOffline) goOfflineHost('stale'); }
2354
2550
  setLink('closed', code);
2355
2551
  }
2356
2552
  return;
@@ -2385,6 +2581,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2385
2581
  name = typeof m['name'] === 'string' ? m['name'] : name;
2386
2582
  colour = typeof m['colour'] === 'number' ? m['colour'] : colour;
2387
2583
  host = (m['host'] as HostRef | null) ?? null;
2584
+ heldPeers = Array.isArray(m.held) ? m.held as Peer[] : [];
2388
2585
  peers.clear();
2389
2586
  if (Array.isArray(m['peers'])) for (const p of m['peers'] as Peer[]) peers.set(p.id, p);
2390
2587
  if (typeof m['st'] === 'number' && rtt === null) offset = (m['st'] as number) - Date.now();
@@ -2397,7 +2594,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2397
2594
  // Revision 6: the room's policy and an open vote (an older relay says neither: open, Fair).
2398
2595
  if (m['policy'] && typeof m['policy'] === 'object') { policy = readPolicy(m['policy']); queueMicrotask(() => { emit('policy', policy); post?.({ what: 'policy', policy }); }); }
2399
2596
  if (m['vote'] && typeof m['vote'] === 'object') voteState = readVote(m['vote'] as Record<string, unknown>);
2400
- const ckpt = (m['ckpt'] as Checkpoint<C> | null) ?? null;
2597
+ const ckpt = (m['ckpt'] as Checkpoint<C> | null) ?? (next === 'host' && !m['snap'] && onlineRulesSave ? { k: tick, st: now(), d: onlineRulesSave as C } : null);
2598
+ onlineRulesSave = null;
2401
2599
  let snap = (m['snap'] as Snapshot<S> | null) ?? null;
2402
2600
  const a = m['announce'] as Announcement | undefined;
2403
2601
  if (a && typeof a === 'object' && typeof a.id === 'string' && typeof a.text === 'string' && (!announcement || announcement.id !== a.id)) { announcement = { ...a, from: 'studio' }; queueMicrotask(() => emit('announce', announcement as Announcement)); }
@@ -2410,6 +2608,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2410
2608
  // The relay's last snapshot comes with the welcome: the room's state is in (the arrival, section 21).
2411
2609
  if (snap && next !== 'host' && acceptSnap(snap, JSON.stringify(snap).length, false)) snapSeen = true;
2412
2610
  if (next === 'host' && !snap && !continuing) snap = localSnap();
2611
+ if (wasOffline && rulesGame) rulesNotice('Connection restored. Joining the online round.');
2413
2612
  offline = false;
2414
2613
  connected = true;
2415
2614
  downSince = 0;
@@ -2427,7 +2626,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2427
2626
  setRole(next, wasOffline && roleKnown ? 'reconnected' : String(m['why'] ?? 'welcome'), { ckpt, snap, round: roundInfo, roster: slots });
2428
2627
  if (next === 'host' && snap) tick = Math.max(tick, snap.k);
2429
2628
  }
2430
- if (continuing) { reannounce(); catchUp(had, hadSeat); }
2629
+ if (continuing) { rulesHost?.sync(rulesPeers()); reannounce(); catchUp(had, hadSeat); }
2431
2630
  if (link === 'online') paintLink(); else setLink('online', 'welcome');
2432
2631
  // A build older than the live one, let in to its own room (section 23).
2433
2632
  if (m['stale'] && typeof m['stale'] === 'object') noteStale((m['stale'] as { ver?: unknown }).ver, false);
@@ -2468,6 +2667,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2468
2667
 
2469
2668
  /** A host that reconnected (the relay may have restarted): hand the relay the round, roster, state and a checkpoint. */
2470
2669
  function reannounce(): void {
2670
+ // A rules host's are its runtime's output, by the one way out every other frame of the rules takes.
2671
+ if (rulesGame) { rulesHost?.announce(); sendCheckpoint(); return; }
2471
2672
  if (roundInfo) raw({ t: 'round', round: roundInfo });
2472
2673
  if (slots) raw({ t: 'roster', slots });
2473
2674
  for (const [k, d] of stateMap) raw({ t: 'state', k, d });
@@ -2476,10 +2677,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2476
2677
  }
2477
2678
 
2478
2679
  /** Buffer a snapshot. `live`: it came off the wire just now (its age is a transit sample). */
2479
- function acceptSnap(s: Snapshot<S>, bytes: number, live: boolean): boolean {
2680
+ function acceptSnap(s: Snapshot<S>, bytes: number, live: boolean, local = false): boolean {
2480
2681
  const t = now();
2481
2682
  // A snapshot stamped far in the future would hold the buffer for good (the relay clamps st too).
2482
- if (!Number.isFinite(s.st) || s.st > t + 1000) { rejectedSnaps += 1; return false; }
2683
+ if (!Number.isFinite(s.st) || (!local && s.st > t + 1000)) { rejectedSnaps += 1; return false; }
2483
2684
  if (rebase) {
2484
2685
  // New host, new timeline: its clock may sit a little behind the old one's. Keep older frames to hold on.
2485
2686
  while (buf.length && (buf[buf.length - 1] as Snapshot<S>).st >= s.st) buf.pop();
@@ -2546,13 +2747,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2546
2747
  if (changed) emit('control', { seat, rs, own: ownB, ack, reset, snap: s });
2547
2748
  }
2548
2749
 
2549
- function onSnap(m: Record<string, unknown>, bytes: number): void {
2550
- if (role === 'host') return; // a stale frame from a previous host
2750
+ function onSnap(m: Record<string, unknown>, bytes: number, local = false): void {
2751
+ if (role === 'host' && !local) return; // a stale frame from a previous host
2551
2752
  const s: Snapshot<S> = { k: Number(m['k']) || 0, st: Number(m['st']), d: m['d'] as S, from: typeof m['from'] === 'number' ? m['from'] : null };
2552
2753
  if (Array.isArray(m['c'])) s.c = m['c'] as ControlWire[];
2553
2754
  if (m['hb'] === 1) s.hb = 1;
2554
2755
  if (typeof m['e'] === 'number') s.e = m['e'];
2555
- if (acceptSnap(s, bytes, true)) { emit('snapshot', s); if (!snapSeen) { snapSeen = true; autoPlayable(); } }
2756
+ if (acceptSnap(s, bytes, true, local)) { emit('snapshot', s); if (!snapSeen) { snapSeen = true; autoPlayable(); } }
2556
2757
  }
2557
2758
 
2558
2759
  function onInput(m: Record<string, unknown>): void {
@@ -2600,6 +2801,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2600
2801
 
2601
2802
  // ---------------------------------------------------------------- wire out
2602
2803
  let pingTimer: ReturnType<typeof setInterval> | null = null;
2804
+ let paceTimer: ReturnType<typeof setInterval> | null = null;
2805
+ let readySpeed = 0;
2603
2806
  let ckptTimer: ReturnType<typeof setInterval> | null = null;
2604
2807
  let statsTimer: ReturnType<typeof setInterval> | null = null;
2605
2808
  let connectTimer: ReturnType<typeof setTimeout> | undefined;
@@ -2647,6 +2850,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2647
2850
  const pingHeld = beat - lastPingTick > 2000 + 1500;
2648
2851
  lastPingTick = beat;
2649
2852
  if (!connected) {
2853
+ if (rulesGame && offline && rulesHost && ws && wall() - socketStartedAt >= 2500) { lost(ws, 'connect-timeout'); return; }
2650
2854
  // A socket that opened and was never welcomed (a room that is not answering) is as dead as a silent one: drop
2651
2855
  // it and knock again, so a page that gave up and plays alone still finds its room when the room is back.
2652
2856
  if (ws && ws.readyState === 1 && lastMsgAt && wall() - lastMsgAt > Math.max(staleMs, connectOpenMaxMs)) lost(ws, 'stale');
@@ -2665,16 +2869,18 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2665
2869
  const c = Date.now();
2666
2870
  pingsOut.set(c, c);
2667
2871
  if (pingsOut.size > 16) { const first = pingsOut.keys().next().value; if (first !== undefined) pingsOut.delete(first); }
2668
- raw({ t: 'ping', c, hid: hidden() });
2872
+ // A hidden host's yield is behind its checkpoint in the sender: the relay hears of the hidden tab after both.
2873
+ raw({ t: 'ping', c, hid: hidden() && !rulesSender?.handing, ...(rulesGame ? { readySpeed } : {}) });
2669
2874
  }
2670
2875
 
2671
2876
  function sendCheckpoint(): void {
2672
- if (role !== 'host' || !opts.checkpoint || offline || !connected) return;
2877
+ if (role !== 'host' || (!opts.checkpoint && !rulesHost) || offline || !connected) return;
2673
2878
  let d: C;
2674
- try { d = opts.checkpoint(); } catch (err) { console.warn('[netplay] checkpoint()', err); return; }
2675
- const c: Checkpoint<C> = { k: tick, st: now(), d, c: ctlWire() };
2879
+ try { d = (rulesHost ? rulesHost.save() : opts.checkpoint!()) as C; } catch (err) { console.warn('[netplay] checkpoint()', err); return; }
2880
+ const c: Checkpoint<C> = { k: rulesHost?.tick ?? tick, st: now(), d, c: ctlWire() };
2676
2881
  lastCkpt = c;
2677
- raw({ t: 'ckpt', k: c.k, st: c.st, d: c.d, c: c.c });
2882
+ const frame = { t: 'ckpt', k: c.k, st: c.st, d: c.d, c: c.c };
2883
+ if (rulesHost) rulesSender?.push(frame); else raw(frame);
2678
2884
  }
2679
2885
 
2680
2886
  /** How long until one more input frame fits the rolling-second cap (0: now). */
@@ -2746,18 +2952,21 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2746
2952
 
2747
2953
  // ---------------------------------------------------------------- connect
2748
2954
  function goOfflineHost(why: string): void {
2955
+ const kept = rulesHost?.save() ?? null;
2956
+ if (!offline && kept && peers.size <= 1) onlineRulesSave = kept;
2749
2957
  offline = true;
2750
2958
  connected = false;
2751
- seat = null;
2959
+ seat = rulesGame && canOffline && want === 'play' ? (seat ?? 0) : null;
2752
2960
  if (full) { full = false; post?.({ what: 'full', full: false }); }
2753
2961
  aloneWhy = why;
2754
2962
  // A page that gives up on a room it WAS in says so as a role event even when it was that room's host: its seat is
2755
2963
  // gone with the room, and the game seats itself again for a round of its own (createRoom keeps the round it had).
2756
- if (!roleKnown || role !== 'host' || why === 'reconnect-timeout') setRole('host', why);
2964
+ if (!roleKnown || role !== 'host' || (rulesGame && !rulesHost) || why === 'reconnect-timeout') setRole(canOffline ? 'host' : 'replica', why, rulesGame && kept ? { ckpt: { k: tick, st: now(), d: kept as C } } : {});
2757
2965
  // No shell at all is `offline` from the start; a room that never answered is `alone` (and still knocked at). A
2758
2966
  // page the relay refused for good stays `closed` (nothing is knocking): it plays by itself and its line says why.
2759
2967
  if (closed) paintLink();
2760
2968
  else if (why !== 'offline') setLink('alone', why);
2969
+ else paintLink();
2761
2970
  }
2762
2971
 
2763
2972
  function lost(sock: WebSocketLike, why: string): void {
@@ -2786,11 +2995,11 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2786
2995
  if (closed || !cfg || !WS || ws) return;
2787
2996
  let sock: WebSocketLike;
2788
2997
  try { sock = new WS(cfg.url); } catch { retry(); return; }
2789
- ws = sock;
2998
+ ws = sock; socketStartedAt = wall();
2790
2999
  sock.onopen = () => {
2791
3000
  lastMsgAt = wall();
2792
3001
  raw({
2793
- t: 'hello', v: NETPLAY_VERSION, rev: NETPLAY_REVISION, token, name: name || undefined, device, want, canHost: asAgent && asAgent.hands === 'host' ? false : canHost, game: opts.game, max: opts.maxPlayers,
3002
+ t: 'hello', rules: rulesGame, v: NETPLAY_VERSION, rev: NETPLAY_REVISION, token, name: name || undefined, device, want, canHost: asAgent && asAgent.hands === 'host' ? false : canHost, game: opts.game, max: opts.maxPlayers,
2794
3003
  ...(watching ? { watch: true } : {}), ...(caps.size ? { caps: [...caps] } : {}), ...(asAgent ? { agent: asAgent } : {}),
2795
3004
  // Revision 9 (an older relay ignores both): the game's revision and what this build can do.
2796
3005
  ...(version ? { ver: version } : {}), ...(features.length ? { feat: features } : {}),
@@ -2801,6 +3010,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2801
3010
  // Browsers follow an error with close; Node's WebSocket, on a refused connection, never does.
2802
3011
  sock.onerror = () => lost(sock, 'error');
2803
3012
  }
3013
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
2804
3014
  function retry(): void {
2805
3015
  if (closed) return;
2806
3016
  // A hidden tab does not play and its timers crawl: reconnect when it is looked at again.
@@ -2808,7 +3018,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2808
3018
  const wait = Math.max(rulesGame ? (attempt < 3 ? Math.random() * Math.min(2000, 500 * 2 ** attempt) : (0.5 + Math.random() * 0.5) * Math.min(60_000, 2000 * 2 ** Math.min(attempt - 2, 5))) : LADDER[Math.min(attempt, LADDER.length - 1)] as number, aloneUntil - wall());
2809
3019
  attempt += 1;
2810
3020
  reconnects += 1;
2811
- setTimeout(open, wait);
3021
+ retryTimer = setTimeout(open, rulesGame && offline && rulesHost ? Math.min(wait, 2500) : wait);
2812
3022
  }
2813
3023
 
2814
3024
  if (offline) {
@@ -2842,21 +3052,32 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2842
3052
  if (heartbeatMs) hbTimer = setInterval(heartbeat, 250);
2843
3053
  lastPingTick = wall();
2844
3054
  pingTimer = setInterval(ping, 2000);
3055
+ if (rulesGame) {
3056
+ let at = wall(); const samples: number[] = [];
3057
+ paceTimer = setInterval(() => {
3058
+ const next = wall(); samples.push(Math.min(1, 100 / Math.max(100, next - at))); at = next;
3059
+ if (samples.length > 20) samples.shift();
3060
+ readySpeed = samples.reduce((a, b) => a + b, 0) / samples.length;
3061
+ }, 100);
3062
+ }
2845
3063
  ckptTimer = setInterval(sendCheckpoint, checkpointMs);
2846
3064
  statsTimer = setInterval(() => notifyStats(true), 500);
2847
- try {
2848
- g.document?.addEventListener('visibilitychange', () => {
2849
- if (!hidden() && waitingVisible) { waitingVisible = false; attempt = 0; open(); return; }
2850
- if (!connected) return;
2851
- if (hidden()) {
2852
- // A hidden phone tab loses its timers and rAF: hand the round on before it stalls.
2853
- if (role === 'host' && peers.size > 1) { sendCheckpoint(); raw({ t: 'yield' }); }
2854
- ping();
2855
- } else ping();
2856
- });
2857
- g.addEventListener?.('pagehide', () => { if (role === 'host') sendCheckpoint(); raw({ t: 'bye' }); });
2858
- } catch { /* not a browser */ }
2859
3065
  }
3066
+ try {
3067
+ g.document?.addEventListener('visibilitychange', () => {
3068
+ if (disposed) return;
3069
+ if (hidden()) rulesHost?.pause(); else rulesHost?.resume();
3070
+ if (!hidden() && waitingVisible) { waitingVisible = false; attempt = 0; open(); return; }
3071
+ if (!connected) return;
3072
+ if (hidden()) {
3073
+ // A hidden phone tab loses its timers and rAF: hand the round on before it stalls.
3074
+ if (role === 'host' && peers.size > 1) yieldHost();
3075
+ ping();
3076
+ } else ping();
3077
+ });
3078
+ g.addEventListener?.('online', () => { if (!closed && !connected && rulesGame) { if (ws) lost(ws, 'online'); if (retryTimer !== null) clearTimeout(retryTimer); retryTimer = null; attempt = 0; open(); } });
3079
+ g.addEventListener?.('pagehide', () => { if (role === 'host') { sendCheckpoint(); rulesSender?.leave(); } raw({ t: 'bye' }); });
3080
+ } catch { /* not a browser */ }
2860
3081
 
2861
3082
  // ---------------------------------------------------------------- watching
2862
3083
  let watchTimer: ReturnType<typeof setInterval> | null = null;
@@ -2924,7 +3145,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2924
3145
 
2925
3146
  /** Tell the relay what this game does with servers (only a host's word counts), once per change. */
2926
3147
  function sendCaps(): void {
2927
- if (role !== 'host' || offline || !connected || !caps.size) return;
3148
+ if (rulesGame || role !== 'host' || offline || !connected || !caps.size) return;
2928
3149
  const list = [...caps].sort();
2929
3150
  const sig = list.join(',');
2930
3151
  if (sig === capsSent) return;
@@ -2982,6 +3203,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2982
3203
  get seat() { return seat; },
2983
3204
  get spectator() { return seat === null && !offline; },
2984
3205
  get offline() { return offline; },
3206
+ get rulesHosting() { return Boolean(rulesHost); },
2985
3207
  get connected() { return connected; },
2986
3208
  get closedWhy() { return closedWhy; },
2987
3209
  get downMs() { return connected || !downSince ? 0 : wall() - downSince; },
@@ -2993,6 +3215,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2993
3215
  const next = typeof text === 'string' && text.trim() ? text.trim().slice(0, 120) : null;
2994
3216
  if (next === gameLine) return;
2995
3217
  gameLine = next;
3218
+ post?.({ what: 'line', text: next });
2996
3219
  paintLink();
2997
3220
  },
2998
3221
  start(): void { clockRunning = true; },
@@ -3110,6 +3333,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3110
3333
  return wall() - lastSnapSentAt >= (peers.size > 1 ? snapshotMs : 1000) - 2;
3111
3334
  },
3112
3335
  snapshot(d, k, force = false) {
3336
+ if (rulesGame) return false;
3113
3337
  if (role !== 'host' || offline || !connected) return false;
3114
3338
  if (!force && wall() - lastSnapSentAt < (peers.size > 1 ? snapshotMs : 1000) - 2) return false;
3115
3339
  tick = k ?? tick + 1;
@@ -3137,21 +3361,23 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3137
3361
  checkpointNow: sendCheckpoint,
3138
3362
  handOff() {
3139
3363
  if (role !== 'host' || offline || !connected || peers.size < 2) return false;
3140
- sendCheckpoint();
3141
- return raw({ t: 'yield' });
3364
+ return yieldHost();
3142
3365
  },
3143
3366
  round(r) {
3367
+ if (rulesGame) return;
3144
3368
  roundInfo = r;
3145
3369
  post?.({ what: 'round', round: r });
3146
3370
  if (role === 'host') raw({ t: 'round', round: r });
3147
3371
  emit('round', r);
3148
3372
  },
3149
3373
  roster(s) {
3374
+ if (rulesGame) return;
3150
3375
  slots = s.map((x) => ({ slot: x.slot, seat: x.seat, name: x.name, bot: x.bot, ...(x.agent ? { agent: { seat: x.agent.seat, role: x.agent.role, hands: x.agent.hands } } : {}) }));
3151
3376
  post?.({ what: 'roster', slots });
3152
3377
  if (role === 'host') raw({ t: 'roster', slots });
3153
3378
  },
3154
3379
  state(key, d) {
3380
+ if (rulesGame) return false;
3155
3381
  if (role !== 'host' && !offline) return false;
3156
3382
  const text = JSON.stringify(d ?? null);
3157
3383
  if (stateSent.get(key) === text) return false;
@@ -3164,6 +3390,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3164
3390
  stateOf<T = unknown>(key: string) { return stateMap.get(key) as T | undefined; },
3165
3391
  stateKeys() { return [...stateMap.keys()]; },
3166
3392
  input(a, held) {
3393
+ if (rulesGame) return;
3167
3394
  pendingA = a;
3168
3395
  const list = heldList(held);
3169
3396
  let edge = false;
@@ -3174,20 +3401,24 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3174
3401
  scheduleInput(edge);
3175
3402
  },
3176
3403
  steps(e, k, entries, r) {
3177
- if (role === 'host' || seat === null || offline || !connected || !entries.length) return false;
3404
+ if (seat === null || !entries.length) return false;
3405
+ if (rulesHost && offline) { rulesHost.frame({ t: 'in', from: seat, e, k, s: entries, r }); return true; }
3406
+ if ((!rulesGame && role === 'host') || offline || !connected) return false;
3178
3407
  if (!raw({ t: 'in', e, k, s: entries, r })) return false;
3179
3408
  inputOut.hit(wall());
3180
3409
  return true;
3181
3410
  },
3182
3411
  press(idp) {
3412
+ if (rulesGame) return;
3183
3413
  if (role === 'host' || seat === null || offline || !connected) return;
3184
3414
  pendingPresses[idp] = (pendingPresses[idp] ?? 0) + 1;
3185
3415
  scheduleInput(true);
3186
3416
  },
3187
3417
  send(kind, data, to) {
3418
+ if (rulesHost && offline) { rulesHost.frame({ t: 'ev', from: seat, id, k: kind, d: data ?? null }); return; }
3188
3419
  if (offline) return;
3189
- const m: Record<string, unknown> = { t: 'ev', k: kind, d: data ?? null };
3190
- if (role === 'host' && (typeof to === 'number' || typeof to === 'string')) m['to'] = to;
3420
+ const m: Record<string, unknown> = { t: 'ev', k: kind, d: data ?? null, ...(rulesGame ? { view: true } : {}) };
3421
+ if (!rulesGame && role === 'host' && (typeof to === 'number' || typeof to === 'string')) m['to'] = to;
3191
3422
  raw(m);
3192
3423
  },
3193
3424
  inputOf(s) { return inputs.get(s) ?? null; },
@@ -3215,7 +3446,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3215
3446
  },
3216
3447
  sample(delayMs) {
3217
3448
  if (!buf.length) return null;
3218
- const renderT = now() - (delayMs ?? interpDelay());
3449
+ const renderT = (rulesHost ? rulesWireStamp + performance.now() - rulesFrameAt : now()) - (delayMs ?? interpDelay());
3219
3450
  const newest = buf[buf.length - 1] as Snapshot<S>;
3220
3451
  const oldest = buf[0] as Snapshot<S>;
3221
3452
  const t = wall();
@@ -3253,7 +3484,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3253
3484
  };
3254
3485
  decideStats.asked += 1;
3255
3486
  const now0 = wall();
3256
- const why = role !== 'host' ? 'not-host' : offline || !connected ? 'offline' : now0 < decideOffUntil ? 'off' : now0 < decideNextAt ? 'pace' : null;
3487
+ const why = rulesGame || role !== 'host' ? 'not-host' : offline || !connected ? 'offline' : now0 < decideOffUntil ? 'off' : now0 < decideNextAt ? 'pace' : null;
3257
3488
  if (why) { decideStats.floor += 1; return Promise.resolve({ ...floor(), why }); }
3258
3489
  decideNextAt = now0 + 3000;
3259
3490
  const n = `d${(decideSeq += 1).toString(36)}`;
@@ -3267,13 +3498,17 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3267
3498
  stats: statsNow,
3268
3499
  expose(p) { probes = { ...probes, ...p }; },
3269
3500
  close() {
3501
+ sendCheckpoint(); rulesSender?.leave();
3502
+ disposed = true; stopRules();
3270
3503
  closed = true;
3271
3504
  if (pingTimer) clearInterval(pingTimer);
3505
+ if (paceTimer) clearInterval(paceTimer);
3272
3506
  if (ckptTimer) clearInterval(ckptTimer);
3273
3507
  if (statsTimer) clearInterval(statsTimer);
3274
3508
  if (watchTimer) clearInterval(watchTimer);
3275
3509
  if (hbTimer) clearInterval(hbTimer);
3276
3510
  if (reconnectTimer) clearTimeout(reconnectTimer);
3511
+ if (retryTimer) clearTimeout(retryTimer);
3277
3512
  if (spotTimer) clearTimeout(spotTimer);
3278
3513
  if (overlayTimer) clearTimeout(overlayTimer);
3279
3514
  if (overlayHide) clearTimeout(overlayHide);
@@ -3290,6 +3525,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
3290
3525
  get role() { return role; },
3291
3526
  get seat() { return seat; },
3292
3527
  get offline() { return offline; },
3528
+ get rulesHosting() { return Boolean(rulesHost); },
3293
3529
  get connected() { return connected; },
3294
3530
  get owned() { return api.owned; },
3295
3531
  get host() { return host; },