@homie-rocks/studio 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +111 -1
  2. package/README.md +361 -22
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +142 -123
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +127 -8
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/demo.mjs +16 -4
  23. package/lib/deploy-state.mjs +166 -0
  24. package/lib/dev.mjs +430 -0
  25. package/lib/directory.mjs +116 -5
  26. package/lib/doctor.mjs +143 -16
  27. package/lib/feedback.mjs +3 -2
  28. package/lib/handoff.mjs +2 -6
  29. package/lib/library.mjs +1 -1
  30. package/lib/mcp-tools.mjs +36 -35
  31. package/lib/mcp.mjs +1 -1
  32. package/lib/net.mjs +81 -2
  33. package/lib/node-version.mjs +40 -0
  34. package/lib/office.mjs +6 -6
  35. package/lib/optimise.mjs +39 -4
  36. package/lib/parts-build.mjs +169 -0
  37. package/lib/parts-cli.mjs +104 -0
  38. package/lib/parts-store.mjs +342 -0
  39. package/lib/parts-tools.mjs +92 -0
  40. package/lib/parts.mjs +693 -0
  41. package/lib/perf.mjs +131 -31
  42. package/lib/port-check.mjs +40 -6
  43. package/lib/preview.mjs +77 -0
  44. package/lib/routes.mjs +132 -0
  45. package/lib/scaffold.mjs +88 -22
  46. package/lib/shoot.mjs +307 -0
  47. package/lib/site.mjs +1 -2
  48. package/lib/stage.mjs +118 -0
  49. package/lib/studio.mjs +0 -66
  50. package/lib/style-board.mjs +49 -6
  51. package/lib/template-history.json +101 -0
  52. package/lib/trailer.mjs +47 -0
  53. package/lib/typecheck.mjs +107 -0
  54. package/mcp/ui/lineup.js +3 -3
  55. package/mcp/ui/rights.js +1 -3
  56. package/netplay/NETPLAY.md +424 -16
  57. package/netplay/netplay.ts +756 -40
  58. package/package.json +10 -1
  59. package/parts/PARTS.md +163 -0
  60. package/port/index.ts +8 -2
  61. package/port/personas.ts +275 -0
  62. package/port/powerups.ts +357 -0
  63. package/port/probe.ts +38 -2
  64. package/port/room.ts +103 -19
  65. package/site/SITE.md +102 -20
  66. package/starters/ember-vale/index.html +1 -1
  67. package/starters/ember-vale/src/main.ts +3 -1
  68. package/starters/gem-rush/index.html +1 -1
  69. package/starters/gem-rush/src/main.ts +13 -6
  70. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  71. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  72. package/starters/gem-rush-3d/index.html +1 -1
  73. package/starters/gem-rush-3d/src/main.ts +15 -5
  74. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  75. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  76. package/starters/hero-rush-3d/index.html +1 -1
  77. package/starters/hero-rush-3d/src/main.ts +15 -5
  78. package/worker/arrival.mjs +4 -1
  79. package/worker/discover.mjs +11 -25
  80. package/worker/index.mjs +142 -62
  81. package/worker/license.mjs +45 -41
  82. package/worker/office-page.mjs +0 -4
  83. package/worker/office-schema.mjs +6 -0
  84. package/worker/office.mjs +19 -23
  85. package/worker/pages.mjs +141 -23
  86. package/worker/parts.mjs +234 -0
  87. package/worker/room.mjs +157 -14
  88. package/worker/schema.mjs +6 -8
  89. package/worker/seats.mjs +71 -0
  90. package/worker/site.mjs +51 -53
  91. package/worker/version.mjs +1 -1
  92. package/lib/remix-assets.mjs +0 -84
@@ -55,6 +55,20 @@
55
55
  * progress line) gives it the screen: by itself once seated with the room's
56
56
  * state, or the game's own `net.playable()` (`arrival: 'game'`), with
57
57
  * `net.loading(p, what)` while it loads. Nothing crosses the relay.
58
+ * - The link, said out loud (revision 9, NETPLAY.md section 22): `net.link` and
59
+ * `on('link')` say whether this browser is in its room (`online`), trying to
60
+ * get back (`reconnecting`), playing by itself because the room never
61
+ * answered (`alone`), or stopped (`closed`); a small "Reconnecting…" line is
62
+ * drawn over the game meanwhile (restyle it, or `linkOverlay: false`). A host
63
+ * whose frames hitch keeps its role with a heartbeat snapshot from a timer.
64
+ * - Game revisions (section 23): game.json `netplay.version` rides in the hello;
65
+ * a room runs one build at a time and an older tab is told to reload
66
+ * (`on('stale')`). `features` are per-peer capability flags the relay keeps,
67
+ * so a new host knows them without asking again.
68
+ * - The page around the frame (section 24): `net.prefs` (a few settings kept by
69
+ * the play page, because the frame has no storage of its own), `net.params`
70
+ * (the address's switches the page passed in), `net.shell` (where the page's
71
+ * own buttons sit over the game), and `guardGestures()` for a touch game.
58
72
  *
59
73
  * WHAT IT DOES NOT DO: rendering, physics, input devices, bots. `Roster` below
60
74
  * is the bot-yield bookkeeping a host needs; the bots themselves are the game's.
@@ -63,9 +77,9 @@
63
77
 
64
78
  export const NETPLAY_VERSION = 1;
65
79
  /** The contract revision this helper speaks (NETPLAY.md): its hello says so (`rev`), and so does every build of it. */
66
- export const NETPLAY_REVISION = 8;
80
+ export const NETPLAY_REVISION = 9;
67
81
  /** In every bundle that includes the helper: `homie-studio build` reads it to tell the office which revision a build speaks. */
68
- export const NETPLAY_MARK = 'homie-netplay-rev:8';
82
+ export const NETPLAY_MARK = 'homie-netplay-rev:9';
69
83
 
70
84
  export type Role = 'host' | 'replica' | 'screen';
71
85
  export type Device = 'phone' | 'desk' | 'tv';
@@ -115,6 +129,82 @@ export interface NetConfig {
115
129
  chatOff?: boolean;
116
130
  /** Revision 8: this player does not want their own messages over their character (the play page's toggle). */
117
131
  bubbleOff?: boolean;
132
+ /** Revision 9: the game's revision this page was served with (game.json `netplay.version`); the hello says it. */
133
+ ver?: string;
134
+ /** Revision 9: the switches the play page passed in from its own address (`?q=low&debug`), by name. */
135
+ params?: Record<string, string>;
136
+ /** Revision 9: the page around the frame keeps `net.prefs` (a play page from before it does not say so). */
137
+ prefs?: boolean;
138
+ }
139
+
140
+ /* ------------------------------------------------- the link, revisions and the page (revision 9, sections 22-24) */
141
+
142
+ /**
143
+ * Where this browser stands with its room. `connecting`: no welcome yet. `online`: in the room. `reconnecting`: it
144
+ * was in the room and its socket went; it is knocking again (its seat token keeps its body). `alone`: the room never
145
+ * answered in time, so this browser plays by itself as an offline host (it still knocks, and joins when answered).
146
+ * `offline`: there is no room at all (a plain file, a dev server with no shell). `closed`: stopped for good
147
+ * (`net.closedWhy` says why: another tab took the seat, the room is full, the game was updated…).
148
+ */
149
+ export type LinkState = 'connecting' | 'online' | 'reconnecting' | 'alone' | 'offline' | 'closed';
150
+ export interface LinkChange {
151
+ state: LinkState;
152
+ prev: LinkState;
153
+ /** Why it changed: `welcome`, `lost` (the socket closed), `stale` (it went silent), `relay-timeout`, `no-shell`, or a refusal's code. */
154
+ why: string;
155
+ /** This browser is running the rules right now (alone, or as the room's host): two browsers can both say so while one is cut off. */
156
+ hosting: boolean;
157
+ /** How long the socket has been down (ms; 0 when it is not). */
158
+ downMs: number;
159
+ }
160
+ /** A newer build of the game is live (`on('stale')`): this tab still runs `mine`; `final`: it was kept out of a room for it. */
161
+ export interface StaleNotice { ver: string | null; mine: string | null; final: boolean }
162
+ /** One of the play page's own controls drawn over the game, in CSS pixels of the game's own viewport. */
163
+ export interface ShellRect {
164
+ /** `room` (the room button), `server` (the server pill), `chip` (the "3 playing" line), `chat`, `join` (the big screen's QR card), `banner`, `results`. */
165
+ id: string;
166
+ x: number; y: number; w: number; h: number;
167
+ /** It fades by itself after a few seconds (the chip, a toast): still worth keeping important text out of. */
168
+ fades?: boolean;
169
+ }
170
+ /** Where the page's controls sit over the game on this device, held this way (`on('shell')`; null before the page says). */
171
+ export interface ShellLayout {
172
+ device: Device;
173
+ orientation: 'portrait' | 'landscape';
174
+ /** The game frame's size in CSS pixels, as the page sees it. */
175
+ width: number; height: number;
176
+ rects: ShellRect[];
177
+ }
178
+ /**
179
+ * A few settings the play page keeps for this game on this browser (quality, a personal best, the last name typed):
180
+ * the game's frame has no storage of its own. Strings, numbers, booleans and small JSON; 16 KB a game in all, 64
181
+ * characters a key, 32 keys. Never a secret, never progress that must not be lost (that is cloud saves).
182
+ */
183
+ export interface Prefs {
184
+ /** The value kept under `key`, or `fallback`. Always resolves (with the fallback when the page cannot answer). */
185
+ get<T = unknown>(key: string, fallback?: T): Promise<T>;
186
+ /** Keep a value (`null` or `undefined` removes it). Resolves false when it was not kept (too big, no page). */
187
+ set(key: string, value: unknown): Promise<boolean>;
188
+ remove(key: string): Promise<boolean>;
189
+ /** Everything kept for this game. */
190
+ all(): Promise<Record<string, unknown>>;
191
+ /** What this page has read so far, without waiting (after `all()` or `ready` resolved: everything). */
192
+ peek<T = unknown>(key: string, fallback?: T): T;
193
+ /** Resolves once everything kept was read (so `peek` is complete). */
194
+ readonly ready: Promise<void>;
195
+ /** Where the values live: `page` (the play page's storage), `local` (this document's own), `memory` (this visit only). */
196
+ readonly where: 'page' | 'local' | 'memory';
197
+ }
198
+ /** The arrival, for a performance probe (`window.__homieNet.arrival`). Times are ms since the helper was created. */
199
+ export interface ArrivalInfo {
200
+ mode: 'auto' | 'game';
201
+ /** Who gave the game the screen (null: not yet). */
202
+ by: 'auto' | 'game' | null;
203
+ playableMs: number | null;
204
+ /** When the game itself called `net.playable()` (null: it never did). */
205
+ explicitMs: number | null;
206
+ /** The game's own word came this long after an automatic arrival had already lifted the card (null: it did not). */
207
+ lateMs: number | null;
118
208
  }
119
209
 
120
210
  /* ------------------------------------------------- room chat (revision 8, section 19) */
@@ -280,11 +370,21 @@ export interface Peer {
280
370
  * Never on a kids server, never for an AI.
281
371
  */
282
372
  badge?: string;
373
+ /**
374
+ * Revision 9: which stay in the seat this is. The relay gives a seat a new number every time it changes hands
375
+ * and keeps it across that browser's reconnects, so a host can tell "the same player came back" from "somebody
376
+ * new has this seat number now". Undefined from an older relay.
377
+ */
378
+ occ?: number;
379
+ /** Revision 9: the game revision this peer's page was served with (game.json `netplay.version`). */
380
+ ver?: string;
381
+ /** Revision 9: what this peer's build says it can do (`createNetplay({ features })`); the relay keeps it. */
382
+ feat?: string[];
283
383
  }
284
384
 
285
385
  /** One seat's entry in the body-control table: [seat, rs, own (1|0), ack]. */
286
386
  export type ControlWire = [seat: number, rs: number, own: number, ack: number];
287
- export interface Snapshot<D = unknown> { k: number; st: number; d: D; from?: number | null; c?: ControlWire[] }
387
+ export interface Snapshot<D = unknown> { k: number; st: number; d: D; from?: number | null; c?: ControlWire[]; /** Revision 9: a heartbeat: the host's last state again, while its frames hitch. */ hb?: 1 }
288
388
  export interface Checkpoint<D = unknown> { k: number; st: number; d: D; c?: ControlWire[] }
289
389
  export interface InputFrame<A = unknown> {
290
390
  /** Sequence number per sender. */
@@ -405,6 +505,12 @@ export interface NetStats {
405
505
  stateKeys: number;
406
506
  peers: number;
407
507
  reconnects: number;
508
+ /** Revision 9: times a link that was up went down (a reconnect that worked on the first knock counts once). */
509
+ drops?: number;
510
+ /** Revision 9: where this browser stands with its room. */
511
+ link?: LinkState;
512
+ /** Revision 9: heartbeat snapshots sent from the timer while the game's own frames hitched. */
513
+ heartbeats?: number;
408
514
  promotions: number;
409
515
  round: number | null;
410
516
  /** Section 20: the host's decisions: asked, answered by the studio's model ('ai'), the person's own Ollama ('local'), or the floor; median ms. */
@@ -443,8 +549,43 @@ export interface NetplayOptions<C = unknown> {
443
549
  interpDelayMs?: number;
444
550
  /** Host: return the full rules state. Called every `checkpointMs`, before yielding, and on pagehide. */
445
551
  checkpoint?: () => C;
446
- /** ms to wait for a welcome before falling back to offline host. Default 4000. */
552
+ /** ms to wait for a welcome before falling back to offline host, when the socket could not even open. Default 4000. */
447
553
  connectTimeoutMs?: number;
554
+ /**
555
+ * How long a page whose socket is open (or opening) listens for the room's welcome before it plays alone, counted
556
+ * only while the page can listen. Default 10000; 2000 to 120000. A heavy 3D boot under load that takes longer
557
+ * plays a private round for a few seconds first: raise it, or use `connectClock: 'game'`.
558
+ */
559
+ connectOpenMaxMs?: number;
560
+ /**
561
+ * When that wait starts. 'auto' (the default): when the helper is created. 'game': when the game calls
562
+ * `net.start()`, once it has finished booting (or 60 s after the helper was created, so a game that never says is
563
+ * never left without a role). The socket opens at once either way; a welcome that arrives first is used at once.
564
+ */
565
+ connectClock?: 'auto' | 'game';
566
+ /**
567
+ * A host's heartbeat: when the game has not sent a snapshot for this long while others are present (its frames
568
+ * hitched: a long load, a shader compile, a throttled tab), the helper sends the last one again from a timer, so
569
+ * the relay does not hand the room to somebody else for a pause. Default 500 ms; 0 turns it off. It stops after
570
+ * 4 s without a real snapshot: a game that is really frozen is still replaced.
571
+ */
572
+ heartbeatMs?: number;
573
+ /**
574
+ * The game's revision (game.json `netplay.version`; the play page passes it, so a game rarely sets this itself).
575
+ * A room runs one revision at a time: see NETPLAY.md section 23.
576
+ */
577
+ version?: string;
578
+ /**
579
+ * What this build can do, as short words (`['powerups', 'rhythm2']`: up to 8, each a-z, 0-9 and -, 24 characters).
580
+ * The relay keeps them with the peer, so every host (and the next one) reads `net.featuresOf(seat)` instead of
581
+ * running its own handshake. An older build says none.
582
+ */
583
+ features?: string[];
584
+ /**
585
+ * The line drawn over the game while the link is down ("Reconnecting…"). Default true in a room; false turns it
586
+ * off (listen to `link` and draw your own). Restyle it with CSS on `[data-homie-link]`.
587
+ */
588
+ linkOverlay?: boolean;
448
589
  /** A socket that delivered nothing for this long is dropped and reopened (pongs come every 2 s). Default 6000. */
449
590
  staleMs?: number;
450
591
  /** Tests: a WebSocket constructor. */
@@ -510,6 +651,12 @@ export interface NetHandlers<S, A, C> {
510
651
  unchat: (e: { ids: string[] }) => void;
511
652
  /** Revision 8: a message of mine was not sent (slow mode, sign in to type, a word, the studio's filter…). */
512
653
  held: (h: ChatHeld) => void;
654
+ /** Revision 9: where this browser stands with its room changed (online, reconnecting, alone, closed). */
655
+ link: (e: LinkChange) => void;
656
+ /** Revision 9: a newer build of the game is live; this tab should reload (section 23). */
657
+ stale: (e: StaleNotice) => void;
658
+ /** Revision 9: the play page's own controls over the game moved (a turn of the phone, a sheet opened). */
659
+ shell: (e: ShellLayout) => void;
513
660
  }
514
661
 
515
662
  /**
@@ -547,6 +694,37 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
547
694
  readonly closedWhy: string | null;
548
695
  /** How long the socket has been down while it reconnects (ms; 0 while connected). */
549
696
  readonly downMs: number;
697
+ /**
698
+ * THE LINK (revision 9, NETPLAY.md section 22). `offline` is true when this browser is NOT in a room: there is no
699
+ * shell at all, or the room never answered and it plays alone (`link === 'alone'`). `connected` is true only while
700
+ * the socket is up and welcomed. Between the two (`!offline && !connected`) the browser was in a room and is
701
+ * knocking again: `link === 'reconnecting'`. `link` says which in one word, and `on('link')` when it changes.
702
+ */
703
+ readonly link: LinkState;
704
+ /** Times the helper has scheduled another knock at the relay (the same number as `stats().reconnects`). 0: never interrupted. */
705
+ readonly reconnects: number;
706
+ /** My welcome gave me back the seat my token named: I am the player who was here, not a new one in the same seat number. */
707
+ readonly resumed: boolean;
708
+ /** `connectClock: 'game'`: the game has finished booting; the wait for the room's welcome starts now. Once. */
709
+ start(): void;
710
+ /** REVISIONS (section 23): this page's game revision (null: the game names none). */
711
+ readonly version: string | null;
712
+ /** A newer revision is live and this tab should reload: its name (or '' when the relay named none); null otherwise. */
713
+ readonly stale: string | null;
714
+ /** What a seat's build says it can do (its `features`); [] for an older build, an empty seat or a bot. */
715
+ featuresOf(seat: number | null): string[];
716
+ /** Every seated player's build has this feature (bots and AI bodies the host moves are the host's own). */
717
+ allHave(feature: string): boolean;
718
+ /** THE PAGE (section 24): a few settings the play page keeps for this game. */
719
+ readonly prefs: Prefs;
720
+ /** The switches the play page passed in from its address (`/play?q=low&debug`), by name. Offline: this document's own. */
721
+ readonly params: Readonly<Record<string, string>>;
722
+ /** One switch, or `fallback` (a bare `?debug` is `''`). */
723
+ param(name: string, fallback?: string | null): string | null;
724
+ /** Where the play page's own controls sit over the game (null before the page says, or with no page). */
725
+ readonly shell: ShellLayout | null;
726
+ /** The arrival's facts (mode, who lifted the card, when the game itself said it was playable). */
727
+ readonly arrivalInfo: ArrivalInfo;
550
728
  /**
551
729
  * Host: hand the room to another browser — a checkpoint, then the protocol's `yield` (the same road a hidden
552
730
  * tab takes). The relay elects the best other candidate, or keeps me host when nobody else can host.
@@ -760,7 +938,18 @@ export interface RosterOptions {
760
938
  * `aiSeats + guides` slots for AI (marked `agent`, never given to a person); `bots: 'off'` adds no other filler.
761
939
  */
762
940
  policy?: () => Policy | null;
941
+ /**
942
+ * ADMISSION (revision 9, NETPLAY.md section 25): which bot's body a NEW arrival takes. Called with the bot slots
943
+ * it may take (never one kept for AI when a person arrives, never another person's), in slot order; return the
944
+ * `slot` to give, `undefined` for the default (the lowest), or `null` when none of them will do (a new body is
945
+ * added when the room has space; when it has none, the default applies: a seated person always gets a body).
946
+ * It must be deterministic from the game's state (a new host asks it again). It is NOT asked for somebody who
947
+ * comes back to the body they held (a reload, a reconnect): returning players keep their body, alive or not.
948
+ */
949
+ admit?: (candidates: Slot[], who: { seat: number; name: string; agent: boolean }) => number | null | undefined;
763
950
  }
951
+ /** What `Roster.claim` did. `back`: the seat got the body it already had, or the one it held last (nobody new). */
952
+ export interface Claim { slot: Slot; yielded: boolean; added: boolean; back: boolean }
764
953
 
765
954
  const copySlot = (s: Slot): Slot => ({ slot: s.slot, seat: s.seat, name: s.name, bot: s.bot, ...(s.agent ? { agent: { seat: s.agent.seat, role: s.agent.role, hands: s.agent.hands } } : {}) });
766
955
 
@@ -785,24 +974,52 @@ export class Roster {
785
974
  readonly max: number;
786
975
  readonly botName: (slot: number) => string;
787
976
  private readonly policyFn: (() => Policy | null) | null;
788
- /** seat → the slot it held when it last left */
789
- private readonly lastSlot = new Map<number, number>();
977
+ private readonly admitFn: RosterOptions['admit'] | null;
978
+ /** seat → the slot it held when it last left, and which stay in the seat that was (`Peer.occ`) */
979
+ private readonly lastSlot = new Map<number, { slot: number; occ: number | undefined }>();
980
+ /** seat → which stay in the seat holds its slot now (`Peer.occ`; nothing from a relay that does not say) */
981
+ private readonly occ = new Map<number, number>();
790
982
 
791
983
  constructor(opts: RosterOptions) {
792
984
  this.min = Math.max(0, opts.min | 0);
793
985
  this.max = Math.max(this.min, opts.max | 0);
794
986
  this.botName = opts.botName ?? ((slot: number) => `Bot ${slot + 1}`);
795
987
  this.policyFn = opts.policy ?? null;
988
+ this.admitFn = opts.admit ?? null;
796
989
  this.fill();
797
990
  }
798
991
 
799
- static from(slots: readonly Slot[], opts: RosterOptions): Roster {
992
+ /** `occupants`: what `occupants()` returned when the slots were saved (a checkpoint), so a new host can tell who came back. */
993
+ static from(slots: readonly Slot[], opts: RosterOptions, occupants?: readonly (readonly [seat: number, occ: number])[] | null): Roster {
800
994
  const r = new Roster(opts);
801
995
  r.slots = slots.map(copySlot);
996
+ if (Array.isArray(occupants)) for (const row of occupants) if (Array.isArray(row) && Number.isInteger(row[0]) && Number.isInteger(row[1]) && r.bySeat(row[0] as number)) r.occ.set(row[0] as number, row[1] as number);
802
997
  r.fill();
803
998
  return r;
804
999
  }
805
1000
 
1001
+ /** Which stay in its seat holds each slot ([seat, occ]): keep it in the checkpoint, beside `toJSON()`. */
1002
+ occupants(): [seat: number, occ: number][] {
1003
+ return [...this.occ].filter(([seat]) => Boolean(this.bySeat(seat)));
1004
+ }
1005
+
1006
+ /**
1007
+ * The seat has a slot here, but the relay says somebody else holds that seat number now (it changed hands while
1008
+ * this host was not told: a checkpoint of a room that emptied, a host that was cut off). False when either side
1009
+ * does not know (an older relay, an older checkpoint): then the seat is taken to be the same player's.
1010
+ */
1011
+ changedHands(seat: number, occ: number | undefined | null): boolean {
1012
+ const known = this.occ.get(seat);
1013
+ return typeof occ === 'number' && known !== undefined && known !== occ;
1014
+ }
1015
+
1016
+ /** The seat's player is gone for good and a stranger may get the number: its body is a bot's, and nobody "returns" to it. */
1017
+ vacate(seat: number): Slot | null {
1018
+ const s = this.release(seat);
1019
+ this.lastSlot.delete(seat);
1020
+ return s;
1021
+ }
1022
+
806
1023
  private policy(): Policy | null {
807
1024
  try { return this.policyFn ? this.policyFn() : null; } catch { return null; }
808
1025
  }
@@ -855,18 +1072,41 @@ export class Roster {
855
1072
  /**
856
1073
  * A human (or, revision 6, an agent) takes a slot. Returns the slot, whether it yielded a bot, or null (full:
857
1074
  * spectate). A person never takes a slot kept for AI; an agent takes one first.
1075
+ *
1076
+ * `occ` (revision 9): which stay in the seat this is (`peer.occ`). The same stay keeps its body (`back`); a seat
1077
+ * that changed hands gives its old body back to a bot first, and the newcomer is admitted like any other arrival.
1078
+ * A NEW arrival's body is the `admit` callback's choice among the bots it may take, else the lowest.
858
1079
  */
859
- claim(seat: number, name: string, agent?: { role?: AgentRole; hands?: 'self' | 'host' } | null): { slot: Slot; yielded: boolean; added: boolean } | null {
1080
+ claim(seat: number, name: string, agent?: { role?: AgentRole; hands?: 'self' | 'host' } | null, occ?: number | null): Claim | null {
1081
+ const stay = typeof occ === 'number' ? occ : undefined;
860
1082
  const mine = this.bySeat(seat);
861
- if (mine) { mine.name = name || mine.name; return { slot: mine, yielded: false, added: false }; }
862
- const last = this.lastSlot.get(seat);
1083
+ if (mine && !this.changedHands(seat, stay)) {
1084
+ if (stay !== undefined) this.occ.set(seat, stay);
1085
+ mine.name = name || mine.name;
1086
+ return { slot: mine, yielded: false, added: false, back: true };
1087
+ }
1088
+ // Somebody new holds this seat number: the body its last holder left goes back to a bot, and is not "theirs".
1089
+ if (mine) this.vacate(seat);
1090
+ const held = this.lastSlot.get(seat);
1091
+ const last = held && (stay === undefined || held.occ === undefined || held.occ === stay) ? held.slot : undefined;
863
1092
  const kept = (s: Slot): boolean => Boolean(s.agent && s.agent.seat === null);
864
1093
  const plainBot = (s: Slot): boolean => s.bot && !s.agent;
1094
+ const settle = (slot: Slot, yielded: boolean, added: boolean, back: boolean): Claim => {
1095
+ if (stay !== undefined) this.occ.set(seat, stay); else this.occ.delete(seat);
1096
+ this.lastSlot.delete(seat);
1097
+ return { slot, yielded, added, back };
1098
+ };
1099
+ /** The game's choice among `cands` (undefined: the default; null: none of them will do). */
1100
+ const chosen = (cands: Slot[]): Slot | null | undefined => {
1101
+ if (!this.admitFn || !cands.length) return undefined;
1102
+ let r: number | null | undefined;
1103
+ try { r = this.admitFn(cands.map(copySlot), { seat, name, agent: Boolean(agent) }); } catch (err) { console.warn('[netplay] admit()', err); return undefined; }
1104
+ if (r === null) return null;
1105
+ return cands.find((s) => s.slot === r);
1106
+ };
865
1107
  if (agent) {
866
1108
  const role: AgentRole = agent.role === 'guide' || agent.role === 'player' ? agent.role : 'party';
867
1109
  const hands = agent.hands === 'host' ? 'host' : 'self';
868
- const slot = (last !== undefined ? this.slots.find((s) => s.slot === last && (kept(s) || plainBot(s))) : undefined)
869
- ?? this.slots.find(kept) ?? this.slots.find(plainBot);
870
1110
  const take = (s: Slot): Slot => {
871
1111
  s.agent = { seat, role: s.agent?.role ?? role, hands };
872
1112
  s.name = name || aiName(this.botName(s.slot));
@@ -874,31 +1114,41 @@ export class Roster {
874
1114
  if (hands === 'host') { s.bot = true; s.seat = null; } else { s.bot = false; s.seat = seat; }
875
1115
  return s;
876
1116
  };
877
- if (slot) return { slot: take(slot), yielded: true, added: false };
1117
+ const back = last !== undefined ? this.slots.find((s) => s.slot === last && (kept(s) || plainBot(s))) : undefined;
1118
+ if (back) return settle(take(back), true, false, true);
1119
+ // A seat kept for AI first (the reservation), then a plain bot: the game chooses within whichever it is.
1120
+ const pool = this.slots.some(kept) ? this.slots.filter(kept) : this.slots.filter(plainBot);
1121
+ const pick = chosen(pool);
1122
+ const slot = pick ?? (pick === null && this.slots.length < this.max ? undefined : pool[0]);
1123
+ if (slot) return settle(take(slot), true, false, false);
878
1124
  if (this.slots.length >= this.max) return null;
879
1125
  const s = take({ slot: this.nextSlotId(), seat: null, name: '', bot: true });
880
1126
  this.slots.push(s);
881
1127
  this.slots.sort((a, b) => a.slot - b.slot);
882
- return { slot: s, yielded: false, added: true };
883
- }
884
- const bot = (last !== undefined ? this.slots.find((s) => s.slot === last && plainBot(s)) : undefined) ?? this.slots.find(plainBot);
885
- if (bot) {
886
- bot.bot = false; bot.seat = seat; bot.name = name || `Player ${seat + 1}`;
887
- return { slot: bot, yielded: true, added: false };
1128
+ return settle(s, false, true, false);
888
1129
  }
1130
+ const give = (bot: Slot): Slot => { bot.bot = false; bot.seat = seat; bot.name = name || `Player ${seat + 1}`; return bot; };
1131
+ const back = last !== undefined ? this.slots.find((s) => s.slot === last && plainBot(s)) : undefined;
1132
+ if (back) return settle(give(back), true, false, true);
1133
+ const pool = this.slots.filter(plainBot);
1134
+ const pick = chosen(pool);
1135
+ // "None of these" adds a body while the room has space; a full room still seats the person in the default one.
1136
+ const bot = pick ?? (pick === null && this.slots.length < this.max ? undefined : pool[0]);
1137
+ if (bot) return settle(give(bot), true, false, false);
889
1138
  if (this.slots.length >= this.max) return null;
890
1139
  const slot = this.nextSlotId();
891
1140
  const s: Slot = { slot, seat, name: name || `Player ${seat + 1}`, bot: false };
892
1141
  this.slots.push(s);
893
1142
  this.slots.sort((a, b) => a.slot - b.slot);
894
- return { slot: s, yielded: false, added: true };
1143
+ return settle(s, false, true, false);
895
1144
  }
896
1145
 
897
1146
  /** A human leaves: their slot becomes a bot where it stands. An agent's goes back to a seat kept for AI. */
898
1147
  release(seat: number): Slot | null {
899
1148
  const s = this.bySeat(seat);
900
1149
  if (!s) return null;
901
- this.lastSlot.set(seat, s.slot);
1150
+ this.lastSlot.set(seat, { slot: s.slot, occ: this.occ.get(seat) });
1151
+ this.occ.delete(seat);
902
1152
  s.bot = true; s.seat = null;
903
1153
  if (s.agent) {
904
1154
  const keep = this.slots.filter((x) => x.agent && x !== s).length < this.reserved();
@@ -927,23 +1177,32 @@ export class Roster {
927
1177
  return removed;
928
1178
  }
929
1179
 
930
- /** After a promotion: make the roster agree with who is actually connected (an agent with its facts). */
931
- reconcile(peers: Iterable<{ seat: number | null; name: string; agent?: AgentFacts | null }>): { claimed: Slot[]; released: Slot[] } {
932
- const here = new Map<number, { name: string; agent?: AgentFacts | null }>();
933
- for (const p of peers) if (p.seat !== null && p.seat !== undefined) here.set(p.seat, { name: p.name, agent: p.agent ?? null });
1180
+ /**
1181
+ * After a promotion (or a host's own reconnect): make the roster agree with who is actually connected (an agent
1182
+ * with its facts). `claimed`: every body a present seat took just now, through the same `claim` (and `admit`) as
1183
+ * a fresh join (reset each one); a seat whose number somebody else holds now (`occ` differs) is one of them.
1184
+ * `back`: those of `claimed` whose player got the body they held last (nobody new). Players whose slot never left
1185
+ * are in neither.
1186
+ */
1187
+ reconcile(peers: Iterable<{ seat: number | null; name: string; agent?: AgentFacts | null; occ?: number | null }>): { claimed: Slot[]; released: Slot[]; back: Slot[] } {
1188
+ const here = new Map<number, { name: string; agent?: AgentFacts | null; occ?: number | null }>();
1189
+ for (const p of peers) if (p.seat !== null && p.seat !== undefined) here.set(p.seat, { name: p.name, agent: p.agent ?? null, occ: p.occ ?? null });
934
1190
  const released: Slot[] = [];
935
1191
  const claimed: Slot[] = [];
1192
+ const back: Slot[] = [];
936
1193
  for (const s of this.slots.filter((x) => !x.bot || (x.agent && x.agent.seat !== null))) {
937
1194
  const seat = s.agent && s.agent.seat !== null ? s.agent.seat : s.seat;
938
1195
  if (seat !== null && !here.has(seat)) { const r = this.release(seat); if (r) released.push(r); }
939
1196
  }
940
1197
  for (const [seat, p] of here) {
941
- if (this.bySeat(seat)) continue;
942
- const c = this.claim(seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null);
943
- if (c) claimed.push(c.slot);
1198
+ const had = this.bySeat(seat);
1199
+ if (had && !this.changedHands(seat, p.occ)) { if (typeof p.occ === 'number') this.occ.set(seat, p.occ); continue; }
1200
+ if (had) released.push(had);
1201
+ const c = this.claim(seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ);
1202
+ if (c) { claimed.push(c.slot); if (c.back) back.push(c.slot); }
944
1203
  }
945
1204
  this.fill();
946
- return { claimed, released };
1205
+ return { claimed, released, back };
947
1206
  }
948
1207
 
949
1208
  toJSON(): Slot[] { return this.slots.map(copySlot); }
@@ -958,7 +1217,7 @@ const LADDER = [250, 500, 1000, 2000, 4000];
958
1217
  * second of frames at once), and a kick that ended the page's play for good left a phone frozen with no word.
959
1218
  * After one it comes back (its seat token keeps its body), sending at the slow rate below.
960
1219
  */
961
- const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many', 'kicked', 'room-closed', 'watch-off', 'agent-pass', 'agents-off', 'agents-unsupported']);
1220
+ const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many', 'kicked', 'room-closed', 'watch-off', 'agent-pass', 'agents-off', 'agents-unsupported', 'stale']);
962
1221
  /** An AI closed for being alone in a room (`agents-alone`, section 17) waits this long before it knocks again. */
963
1222
  const AGENTS_ALONE_WAIT_MS = 30_000;
964
1223
  /**
@@ -967,8 +1226,119 @@ const AGENTS_ALONE_WAIT_MS = 30_000;
967
1226
  * stall anywhere between) still fit under the cap.
968
1227
  */
969
1228
  const IN_CAP_PER_S = 32;
970
- /** Longest a page with a live socket listens for the relay's welcome before it plays alone (ms of time it could listen). */
1229
+ /** Longest a page with a live socket listens for the relay's welcome before it plays alone (ms of time it could listen): the default of `connectOpenMaxMs`. */
971
1230
  const CONNECT_OPEN_MAX_MS = 10_000;
1231
+ /** `connectClock: 'game'`: the wait starts by itself this long after the helper was created, if the game never calls `net.start()`. */
1232
+ const CONNECT_CLOCK_MAX_MS = 60_000;
1233
+ /** A host's heartbeat (section 22): the default gap without a snapshot before the timer sends one, and how long it keeps a silent game its role. */
1234
+ const HEARTBEAT_MS = 500;
1235
+ const HEARTBEAT_MAX_MS = 4000;
1236
+ /** The link overlay: a reconnect shorter than this is never drawn. */
1237
+ const LINK_OVERLAY_MS = 700;
1238
+ /** `net.prefs` (section 24): what the play page keeps for one game on one browser. worker/pages.mjs has the same numbers. */
1239
+ export const PREFS_LIMITS = Object.freeze({ bytes: 16_384, keys: 32, key: 64 });
1240
+ const PREFS_WAIT_MS = 2000;
1241
+ const FEATURE = /^[a-z0-9][a-z0-9-]{0,23}$/;
1242
+ const VERSION = /^[A-Za-z0-9._-]{1,32}$/;
1243
+ /** A game revision as the contract carries it: 1 to 32 of A-Z a-z 0-9 . _ - (a number is its digits), else null. */
1244
+ export function cleanVersion(v: unknown): string | null {
1245
+ const t = typeof v === 'number' && Number.isFinite(v) ? String(v) : typeof v === 'string' ? v.trim() : '';
1246
+ return VERSION.test(t) ? t : null;
1247
+ }
1248
+ /** A build's features as the contract carries them: up to 8 short lowercase words. */
1249
+ export function cleanFeatures(list: unknown): string[] {
1250
+ if (!Array.isArray(list)) return [];
1251
+ const out: string[] = [];
1252
+ for (const x of list) { const k = String(x ?? '').toLowerCase(); if (FEATURE.test(k) && !out.includes(k)) out.push(k); if (out.length >= 8) break; }
1253
+ return out;
1254
+ }
1255
+
1256
+ /**
1257
+ * PLACES (NETPLAY.md section 26): rows ranked by score, with a tie policy said out loud. `rows` must already be in the
1258
+ * order to show them (the tiebreak is the caller's: createRoom puts people before bots, then the lower slot).
1259
+ * `'order'` (the default, what results always were): places 1, 2, 3… in that order, so two equal scores get different
1260
+ * places. `'shared'`: standard competition places, equal scores share one and the next is skipped (1, 1, 3).
1261
+ * `'dense'`: equal scores share one and none is skipped (1, 1, 2).
1262
+ */
1263
+ export type TiePolicy = 'order' | 'shared' | 'dense';
1264
+ export function placesOf(scores: readonly number[], ties: TiePolicy = 'order'): number[] {
1265
+ const out: number[] = [];
1266
+ for (let i = 0; i < scores.length; i += 1) {
1267
+ if (ties === 'order' || i === 0 || scores[i] !== scores[i - 1]) out.push(ties === 'dense' && i > 0 ? (out[i - 1] as number) + 1 : i + 1);
1268
+ else out.push(out[i - 1] as number);
1269
+ }
1270
+ return out;
1271
+ }
1272
+
1273
+ /**
1274
+ * A TOUCH GAME'S GESTURE GUARD (NETPLAY.md section 24), for the game's own page. The play page around the frame
1275
+ * already refuses selection, the callout and page gestures on itself, but a long press INSIDE the game's document
1276
+ * (a HUD label, a button's text, the canvas) is the game's: on a phone it raises the copy/paste callout or selects
1277
+ * text, and the thumb's touch is cancelled. Call it once, early:
1278
+ *
1279
+ * const off = guardGestures({ touch: 'canvas, [data-action]' });
1280
+ *
1281
+ * It makes the page's text unselectable and suppresses the callout and the context menu, and it stops the browser's
1282
+ * default for touches that start on the elements `touch` names (pan, pinch, double-tap zoom: `touch-action: none`
1283
+ * plus a non-passive `preventDefault`). Text fields, selects, links, ordinary buttons and anything inside
1284
+ * `[data-selectable]` keep their native behaviour. Returns the function that takes it all off again.
1285
+ */
1286
+ export interface GestureGuardOptions {
1287
+ /** Where gameplay touches land: a selector (default 'canvas'). Touches that start there never pan, zoom or select. */
1288
+ touch?: string;
1289
+ /** The document to guard (default: this one). */
1290
+ document?: Document;
1291
+ /** false: add no stylesheet (the listeners only). */
1292
+ css?: boolean;
1293
+ }
1294
+ const NATIVE_TOUCH = 'input, textarea, select, option, a[href], label, [contenteditable]:not([contenteditable="false"]), [data-selectable]';
1295
+ export function guardGestures(o: GestureGuardOptions = {}): () => void {
1296
+ const doc = o.document ?? (globalThis as { document?: Document }).document;
1297
+ if (!doc || typeof doc.addEventListener !== 'function') return () => {};
1298
+ const touch = typeof o.touch === 'string' && o.touch.trim() ? o.touch : 'canvas';
1299
+ const within = (t: EventTarget | null, sel: string): boolean => {
1300
+ const el = t as { closest?: (s: string) => unknown; parentElement?: { closest?: (s: string) => unknown } } | null;
1301
+ try { return Boolean(el && (typeof el.closest === 'function' ? el.closest(sel) : el.parentElement?.closest?.(sel))); } catch { return false; }
1302
+ };
1303
+ /** A field, a link, a select, a marked region: the browser's own behaviour is the right one there. */
1304
+ const native = (t: EventTarget | null): boolean => within(t, NATIVE_TOUCH);
1305
+ /** A touch the game owns: it started on a gameplay surface. An ordinary button outside one keeps its click. */
1306
+ const gameplay = (t: EventTarget | null): boolean => !native(t) && within(t, touch);
1307
+ let style: { remove?: () => void } | null = null;
1308
+ if (o.css !== false && typeof doc.createElement === 'function') {
1309
+ try {
1310
+ const el = doc.createElement('style');
1311
+ el.setAttribute('data-homie-gestures', '');
1312
+ el.textContent = `html,body{-webkit-touch-callout:none;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;overscroll-behavior:none}
1313
+ ${touch}{touch-action:none;-webkit-touch-callout:none;-webkit-user-select:none;user-select:none}
1314
+ input,textarea,select,[contenteditable]:not([contenteditable="false"]),[data-selectable],[data-selectable] *{-webkit-user-select:text;user-select:text;-webkit-touch-callout:default;touch-action:auto}`;
1315
+ (doc.head ?? doc.documentElement)?.appendChild(el);
1316
+ style = el;
1317
+ } catch { /* a document that takes no styles keeps the listeners */ }
1318
+ }
1319
+ const stop = (e: Event): void => { if (e.cancelable) e.preventDefault(); };
1320
+ const onSelect = (e: Event): void => { if (!native(e.target)) stop(e); };
1321
+ const onMenu = (e: Event): void => { if (!native(e.target)) stop(e); };
1322
+ const onTouch = (e: Event): void => { if (gameplay(e.target)) stop(e); };
1323
+ // iOS's pinch on the page (it has no touch-action for it before 13, and ignores user-scalable).
1324
+ const onGesture = (e: Event): void => { if (!native(e.target)) stop(e); };
1325
+ const active = { passive: false, capture: true } as AddEventListenerOptions;
1326
+ doc.addEventListener('selectstart', onSelect, true);
1327
+ doc.addEventListener('contextmenu', onMenu, true);
1328
+ doc.addEventListener('touchstart', onTouch, active);
1329
+ doc.addEventListener('touchmove', onTouch, active);
1330
+ doc.addEventListener('gesturestart', onGesture, active);
1331
+ doc.addEventListener('dblclick', onTouch, true);
1332
+ return () => {
1333
+ doc.removeEventListener('selectstart', onSelect, true);
1334
+ doc.removeEventListener('contextmenu', onMenu, true);
1335
+ doc.removeEventListener('touchstart', onTouch, active);
1336
+ doc.removeEventListener('touchmove', onTouch, active);
1337
+ doc.removeEventListener('gesturestart', onGesture, active);
1338
+ doc.removeEventListener('dblclick', onTouch, true);
1339
+ try { style?.remove?.(); } catch { /* gone */ }
1340
+ };
1341
+ }
972
1342
  /** After the relay reports dropped inputs (or kicked this browser for them): the cap for the next while. */
973
1343
  const IN_SLOW_CAP_PER_S = 15;
974
1344
  const IN_SLOW_MS = 8000;
@@ -1018,7 +1388,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1018
1388
  const inputMs = 1000 / Math.max(1, Math.min(30, opts.inputHz ?? 20));
1019
1389
  const checkpointMs = Math.max(250, opts.checkpointMs ?? 1000);
1020
1390
  const connectTimeoutMs = opts.connectTimeoutMs ?? 4000;
1391
+ const connectOpenMaxMs = Math.max(2000, Math.min(120_000, Number(opts.connectOpenMaxMs) || CONNECT_OPEN_MAX_MS));
1392
+ const heartbeatMs = opts.heartbeatMs === 0 ? 0 : Math.max(200, Math.min(2000, Number(opts.heartbeatMs) || HEARTBEAT_MS));
1021
1393
  const staleMs = Math.max(2500, opts.staleMs ?? 6000);
1394
+ // Revisions (section 23): the game's revision (the page's word, or the game's own) and what this build can do.
1395
+ const version = cleanVersion(opts.version ?? cfg?.ver);
1396
+ const features = cleanFeatures(opts.features);
1022
1397
  const device: Device = cfg?.device ?? guessDevice();
1023
1398
  // A watcher (section 16) is a screen that never takes a seat, whatever else it is told.
1024
1399
  const watching = Boolean(opts.watch ?? cfg?.watch);
@@ -1042,6 +1417,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1042
1417
  role: new Set(), join: new Set(), leave: new Set(), input: new Set(), event: new Set(),
1043
1418
  snapshot: new Set(), control: new Set(), state: new Set(), round: new Set(), roster: new Set(), status: new Set(), announce: new Set(), mute: new Set(), view: new Set(),
1044
1419
  policy: new Set(), vote: new Set(), chat: new Set(), say: new Set(), unchat: new Set(), held: new Set(),
1420
+ link: new Set(), stale: new Set(), shell: new Set(),
1045
1421
  };
1046
1422
  const emit = <K extends keyof NetHandlers<S, A, C>>(kind: K, arg: Parameters<NetHandlers<S, A, C>[K]>[0]): void => {
1047
1423
  for (const fn of [...handlers[kind]]) {
@@ -1064,6 +1440,11 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1064
1440
  let ws: WebSocketLike | null = null;
1065
1441
  let attempt = 0;
1066
1442
  let reconnects = 0;
1443
+ let drops = 0;
1444
+ let heartbeats = 0;
1445
+ /** My welcome gave me back the seat my token named (the same player), and a newer build is live (section 23). */
1446
+ let resumed = false;
1447
+ let staleVer: string | null = null;
1067
1448
  let promotions = 0;
1068
1449
  let lastMsgAt = 0;
1069
1450
  let waitingVisible = false;
@@ -1140,11 +1521,31 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1140
1521
  let autoArmed = false;
1141
1522
  let snapSeen = false;
1142
1523
  let loadingAt = 0;
1524
+ const createdAt = (typeof performance !== 'undefined' ? performance.now() : Date.now());
1525
+ const arrived: ArrivalInfo = { mode: arrival, by: null, playableMs: null, explicitMs: null, lateMs: null };
1143
1526
  function sayPlayable(by: 'game' | 'auto'): void {
1144
1527
  if (playableSent) return;
1145
1528
  playableSent = true;
1529
+ arrived.by = by;
1530
+ arrived.playableMs = Math.round(wall() - createdAt);
1146
1531
  post?.({ what: 'playable', by });
1147
1532
  }
1533
+ /**
1534
+ * The game's own `net.playable()`. With `arrival: 'auto'` the helper may already have given the game the screen
1535
+ * (seated, the room's state in): a later call then changes nothing, and a performance probe has measured an empty
1536
+ * world as playable. Say so once, and tell the page when the game itself was ready, so a report can show both.
1537
+ */
1538
+ function explicitPlayable(): void {
1539
+ if (arrived.explicitMs !== null) return;
1540
+ arrived.explicitMs = Math.round(wall() - createdAt);
1541
+ const late = playableSent && arrived.by === 'auto' && arrived.playableMs !== null ? arrived.explicitMs - arrived.playableMs : null;
1542
+ if (late !== null && late >= 250) {
1543
+ arrived.lateMs = late;
1544
+ console.warn(`[netplay] net.playable() came ${late} ms after the arrival card had already lifted: this game uses the automatic arrival (seated, with the room's state), so its own word changed nothing and the card left before the game was ready. Pass createNetplay({ arrival: 'game' }) (createRoom: netplay: { arrival: 'game' }) so the card waits for net.playable().`);
1545
+ }
1546
+ post?.({ what: 'ready', by: 'game', mode: arrival, ms: arrived.explicitMs, ...(arrived.lateMs !== null ? { lateMs: arrived.lateMs } : {}) });
1547
+ sayPlayable('game');
1548
+ }
1148
1549
  /** 'auto': a host at once, anyone else at its first snapshot, then two animation frames (the game drew with it). */
1149
1550
  function autoPlayable(): void {
1150
1551
  if (arrival !== 'auto' || playableSent || autoArmed || !roleKnown) return;
@@ -1225,6 +1626,176 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1225
1626
  return true;
1226
1627
  }
1227
1628
 
1629
+ /* ------------------------------------------------------------ the link (revision 9, section 22) */
1630
+ let link: LinkState = offline ? 'offline' : 'connecting';
1631
+ /** The line over the game while the link is down: made on first need, in this document, so a game's CSS reaches it. */
1632
+ const overlayOn = opts.linkOverlay !== false && !offline;
1633
+ let overlay: HTMLElement | null = null;
1634
+ let overlayTimer: ReturnType<typeof setTimeout> | null = null;
1635
+ let overlayHide: ReturnType<typeof setTimeout> | null = null;
1636
+ function overlayEl(): HTMLElement | null {
1637
+ const doc = g.document;
1638
+ if (!overlayOn || !doc || typeof doc.createElement !== 'function' || !doc.body) return null;
1639
+ if (overlay) return overlay;
1640
+ try {
1641
+ // :where() has no specificity: any rule a game writes for [data-homie-link] wins.
1642
+ const st = doc.createElement('style');
1643
+ st.setAttribute('data-homie-link-style', '');
1644
+ st.textContent = ':where([data-homie-link]){position:fixed;left:50%;top:max(12px,env(safe-area-inset-top));transform:translateX(-50%);z-index:2147483000;max-width:calc(100vw - 24px);padding:8px 14px;border-radius:999px;background:rgba(8,12,22,.9);color:#fff;font:600 13px/1.25 ui-sans-serif,system-ui,-apple-system,sans-serif;text-align:center;pointer-events:none;-webkit-user-select:none;user-select:none}:where([data-homie-link][hidden]){display:none}:where([data-homie-link="stale"]){pointer-events:auto;cursor:pointer}';
1645
+ (doc.head ?? doc.documentElement).appendChild(st);
1646
+ const el = doc.createElement('div');
1647
+ el.setAttribute('role', 'status');
1648
+ el.setAttribute('aria-live', 'polite');
1649
+ el.hidden = true;
1650
+ el.addEventListener('click', () => { if (el.getAttribute('data-homie-link') === 'stale') { try { (globalThis as { location?: Location }).location?.reload(); } catch { /* not a page */ } } });
1651
+ doc.body.appendChild(el);
1652
+ overlay = el;
1653
+ } catch { overlay = null; }
1654
+ return overlay;
1655
+ }
1656
+ function showOverlay(kind: 'reconnecting' | 'alone' | 'stale', text: string, forMs = 0): void {
1657
+ const el = overlayEl();
1658
+ if (!el) return;
1659
+ if (overlayHide) { clearTimeout(overlayHide); overlayHide = null; }
1660
+ el.setAttribute('data-homie-link', kind);
1661
+ el.textContent = text;
1662
+ el.hidden = false;
1663
+ if (forMs > 0) overlayHide = setTimeout(() => { overlayHide = null; if (el.getAttribute('data-homie-link') === kind) el.hidden = true; }, forMs);
1664
+ }
1665
+ function paintLink(): void {
1666
+ if (!overlayOn) return;
1667
+ if (overlayTimer) { clearTimeout(overlayTimer); overlayTimer = null; }
1668
+ if (link === 'reconnecting') {
1669
+ // A blip shorter than a blink is never drawn.
1670
+ overlayTimer = setTimeout(() => { overlayTimer = null; if (link === 'reconnecting') showOverlay('reconnecting', 'Reconnecting…'); }, LINK_OVERLAY_MS);
1671
+ } else if (link === 'alone') showOverlay('alone', 'Playing offline · reconnecting…');
1672
+ else if (link === 'closed' && closedWhy === 'stale') showOverlay('stale', 'This game was updated. Tap to reload.');
1673
+ else if (overlay && overlay.getAttribute('data-homie-link') !== 'stale') overlay.hidden = true;
1674
+ }
1675
+ function setLink(next: LinkState, why: string): void {
1676
+ if (next === link) return;
1677
+ const prev = link;
1678
+ link = next;
1679
+ const e: LinkChange = { state: next, prev, why, hosting: roleKnown && role === 'host', downMs: connected || !downSince ? 0 : Math.round(wall() - downSince) };
1680
+ post?.({ what: 'link', state: next, prev, why, hosting: e.hosting });
1681
+ paintLink();
1682
+ emit('link', e);
1683
+ }
1684
+ /** A newer build is live (section 23): the relay's `stale` frame (still playing) or its `stale` refusal (kept out). */
1685
+ function noteStale(ver: unknown, final: boolean): void {
1686
+ const next = cleanVersion(ver) ?? '';
1687
+ const first = staleVer === null;
1688
+ staleVer = next;
1689
+ if (!first && !final) return;
1690
+ post?.({ what: 'stale', ver: next || null, mine: version, final });
1691
+ if (!final) showOverlay('stale', 'A new version is ready. Tap to reload.', 10_000);
1692
+ emit('stale', { ver: next || null, mine: version, final });
1693
+ }
1694
+
1695
+ /* ------------------------------------------------------------ the page around the frame (revision 9, section 24) */
1696
+ // The address's switches: the play page's allow-listed ones (HOMIE_NET.params), or, with no page, this document's own.
1697
+ const params: Readonly<Record<string, string>> = Object.freeze((() => {
1698
+ const out: Record<string, string> = {};
1699
+ const given = cfg?.params;
1700
+ if (given && typeof given === 'object') {
1701
+ for (const [k, v] of Object.entries(given).slice(0, 32)) if (typeof v === 'string') out[k] = v;
1702
+ return out;
1703
+ }
1704
+ try {
1705
+ const search = (globalThis as { location?: { search?: string } }).location?.search ?? '';
1706
+ // With no page at all, the document's own address. In a frame whose page predates `params`, only the
1707
+ // switches that page always passed on (its own words, the seat's token among them, are in the address too).
1708
+ const always = ['debug', 'q', 'touchdebug', 'cam', 'view'];
1709
+ // forEach, not iteration: a game checked with lib "DOM" alone (no DOM.Iterable) has no iterator on URLSearchParams.
1710
+ let seen = 0;
1711
+ if (search) new URLSearchParams(search).forEach((v, k) => { if (seen++ < 32 && (!cfg || always.includes(k))) out[k] = v; });
1712
+ } catch { /* not a page */ }
1713
+ return out;
1714
+ })());
1715
+ let shellLayout: ShellLayout | null = null;
1716
+ function readShell(m: Record<string, unknown>): void {
1717
+ if (!Array.isArray(m['rects'])) return;
1718
+ const num = (v: unknown): number => (Number.isFinite(Number(v)) ? Math.round(Number(v)) : 0);
1719
+ const rects: ShellRect[] = [];
1720
+ for (const r of (m['rects'] as Record<string, unknown>[]).slice(0, 16)) {
1721
+ if (!r || typeof r !== 'object' || typeof r['id'] !== 'string') continue;
1722
+ const rect: ShellRect = { id: String(r['id']).slice(0, 24), x: num(r['x']), y: num(r['y']), w: Math.max(0, num(r['w'])), h: Math.max(0, num(r['h'])) };
1723
+ if (r['fades'] === true) rect.fades = true;
1724
+ if (rect.w && rect.h) rects.push(rect);
1725
+ }
1726
+ const dev = m['device'] === 'phone' || m['device'] === 'tv' ? m['device'] : 'desk';
1727
+ shellLayout = { device: dev, orientation: m['orientation'] === 'portrait' ? 'portrait' : 'landscape', width: num(m['width']), height: num(m['height']), rects };
1728
+ emit('shell', shellLayout);
1729
+ }
1730
+
1731
+ // net.prefs: the play page keeps them (it says so in HOMIE_NET.prefs); with no page, this document's own storage
1732
+ // when it has one (a plain file, a dev server), else memory for the visit.
1733
+ const prefsKey = `homie-prefs.${String(opts.game ?? 'game').slice(0, 64)}`;
1734
+ const prefsCache = new Map<string, unknown>();
1735
+ const prefsAsks = new Map<number, (m: Record<string, unknown>) => void>();
1736
+ let prefsSeq = 0;
1737
+ let prefsReady: Promise<void> | null = null;
1738
+ const localStore = (): Storage | null => {
1739
+ try { const st = (globalThis as { localStorage?: Storage }).localStorage; if (!st) return null; st.getItem(prefsKey); return st; } catch { return null; }
1740
+ };
1741
+ const prefsWhere: Prefs['where'] = cfg?.prefs === true && post ? 'page' : !cfg && localStore() ? 'local' : 'memory';
1742
+ const prefsKeyOk = (k: unknown): k is string => typeof k === 'string' && k.length > 0 && k.length <= PREFS_LIMITS.key;
1743
+ function prefsAsk(msg: Record<string, unknown>): Promise<Record<string, unknown>> {
1744
+ return new Promise((done) => {
1745
+ const n = (prefsSeq += 1);
1746
+ const timer = setTimeout(() => { if (prefsAsks.delete(n)) done({ ok: false, why: 'no-answer' }); }, PREFS_WAIT_MS);
1747
+ prefsAsks.set(n, (m) => { clearTimeout(timer); done(m); });
1748
+ post?.({ what: 'prefs', n, ...msg });
1749
+ });
1750
+ }
1751
+ function prefsLoad(): Promise<void> {
1752
+ if (prefsReady) return prefsReady;
1753
+ const take = (all: unknown): void => {
1754
+ if (!all || typeof all !== 'object') return;
1755
+ // What this visit already set wins over what was read (a set that raced the first read).
1756
+ for (const [k, v] of Object.entries(all as Record<string, unknown>)) if (!prefsCache.has(k)) prefsCache.set(k, v);
1757
+ };
1758
+ if (prefsWhere === 'page') prefsReady = prefsAsk({ op: 'all' }).then((m) => take(m['all']));
1759
+ else {
1760
+ if (prefsWhere === 'local') { try { take(JSON.parse(localStore()?.getItem(prefsKey) ?? '{}')); } catch { /* nothing kept */ } }
1761
+ prefsReady = Promise.resolve();
1762
+ }
1763
+ return prefsReady;
1764
+ }
1765
+ /** Whether the whole set still fits (16 KB, 32 keys): the page checks again; this keeps the three places alike. */
1766
+ function prefsFits(key: string, value: unknown): boolean {
1767
+ const next = Object.fromEntries(prefsCache);
1768
+ next[key] = value;
1769
+ let text: string;
1770
+ try { text = JSON.stringify(next); } catch { return false; }
1771
+ return typeof text === 'string' && text.length <= PREFS_LIMITS.bytes && Object.keys(next).length <= PREFS_LIMITS.keys;
1772
+ }
1773
+ const prefs: Prefs = {
1774
+ get: <T,>(key: string, fallback?: T): Promise<T> => prefsLoad().then(() => (prefsCache.has(key) ? prefsCache.get(key) as T : fallback as T)),
1775
+ peek: <T,>(key: string, fallback?: T): T => (prefsCache.has(key) ? prefsCache.get(key) as T : fallback as T),
1776
+ all: () => prefsLoad().then(() => Object.fromEntries(prefsCache)),
1777
+ remove: (key: string) => prefs.set(key, null),
1778
+ async set(key: string, value: unknown): Promise<boolean> {
1779
+ if (!prefsKeyOk(key)) return false;
1780
+ await prefsLoad();
1781
+ const del = value === null || value === undefined;
1782
+ if (!del && !prefsFits(key, value)) { warnOnce('prefs-size', `net.prefs: '${key}' was not kept (a game's prefs are ${PREFS_LIMITS.bytes / 1024} KB and ${PREFS_LIMITS.keys} keys in all)`); return false; }
1783
+ const had = prefsCache.has(key);
1784
+ const before = prefsCache.get(key);
1785
+ if (del) prefsCache.delete(key); else prefsCache.set(key, JSON.parse(JSON.stringify(value)));
1786
+ if (prefsWhere === 'page') {
1787
+ const m = await prefsAsk(del ? { op: 'del', k: key } : { op: 'set', k: key, v: value });
1788
+ // Refused by the page (over its cap): what was kept before stands.
1789
+ if (m['ok'] !== true && m['why'] !== 'no-answer') { if (had) prefsCache.set(key, before); else prefsCache.delete(key); return false; }
1790
+ return m['ok'] === true;
1791
+ }
1792
+ if (prefsWhere === 'local') { try { localStore()?.setItem(prefsKey, JSON.stringify(Object.fromEntries(prefsCache))); } catch { return false; } }
1793
+ return true;
1794
+ },
1795
+ get ready() { return prefsLoad(); },
1796
+ get where() { return prefsWhere; },
1797
+ };
1798
+
1228
1799
  /* ------------------------------------------------------------ body control (host) */
1229
1800
  function ctlOf(s: number): { rs: number; own: boolean; taken: boolean; ack: number; giveAt: number } {
1230
1801
  let e = ctl.get(s);
@@ -1536,6 +2107,11 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1536
2107
  if (next === 'host') { lastSnapSentAt = -Infinity; if (snap) tick = Math.max(tick, snap.k); }
1537
2108
  return;
1538
2109
  }
2110
+ case 'stale': {
2111
+ // A newer build of the game is live (section 23): this tab keeps playing in its own room, and is told.
2112
+ noteStale(m['ver'], false);
2113
+ return;
2114
+ }
1539
2115
  case 'watch': {
1540
2116
  // The relay's word on following (section 16): a seat this browser took in another tab, or taken back.
1541
2117
  relayFollow = m['follow'] !== false;
@@ -1574,6 +2150,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1574
2150
  if (code === 'agents-alone') aloneUntil = wall() + AGENTS_ALONE_WAIT_MS;
1575
2151
  if (code === 'vote') { warnOnce('vote', m['message']); return; }
1576
2152
  if (code === 'rate' || code === 'state-full') { warnOnce(`${code}:${String(m['of'] ?? '')}`, m['message']); return; }
2153
+ // The room still runs another build of the game (section 23): not final. Its players were told to reload; this
2154
+ // browser keeps knocking and is let in when they have. Said once, not at every knock.
2155
+ if (code === 'room-stale') { warnOnce('room-stale', m['message']); return; }
1577
2156
  console.warn('[netplay] relay refused:', code, m['message']);
1578
2157
  // The same seat opened in another tab, a full room, a version the relay does not speak: reconnecting
1579
2158
  // would repeat the refusal (or, for 'replaced', make the two tabs evict each other for ever).
@@ -1581,6 +2160,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1581
2160
  closed = true;
1582
2161
  closedWhy = code;
1583
2162
  post?.({ what: 'closed', why: code, ...(typeof m['until'] === 'number' ? { until: m['until'] } : {}), ...(typeof m['message'] === 'string' ? { message: String(m['message']).slice(0, 200) } : {}) });
2163
+ // Kept out of a room for running an older build (section 23): the page reloads the game; the game may too.
2164
+ if (code === 'stale') noteStale(m['ver'], true);
2165
+ setLink('closed', code);
1584
2166
  }
1585
2167
  return;
1586
2168
  }
@@ -1601,9 +2183,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1601
2183
  const wasOffline = offline;
1602
2184
  // Reconnected (a network blip, or a relay restart) and still the host: my rules state is the live one.
1603
2185
  const continuing = roleKnown && role === 'host' && next === 'host' && !wasOffline;
2186
+ // Who this host had in its room before its socket went: what it missed is worked out below.
2187
+ const had = continuing ? [...peers.values()] : [];
2188
+ const hadSeat = seat;
2189
+ const offered = token;
1604
2190
  id = String(m['id']);
1605
2191
  seat = typeof m['seat'] === 'number' ? m['seat'] : null;
1606
2192
  token = typeof m['token'] === 'string' ? m['token'] : token;
2193
+ // The relay gave back the seat my token named: I am the player who was here, not a new one in that seat number.
2194
+ resumed = seat !== null && typeof offered === 'string' && offered !== '' && offered === token;
1607
2195
  name = typeof m['name'] === 'string' ? m['name'] : name;
1608
2196
  colour = typeof m['colour'] === 'number' ? m['colour'] : colour;
1609
2197
  host = (m['host'] as HostRef | null) ?? null;
@@ -1644,11 +2232,45 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1644
2232
  setRole(next, wasOffline && roleKnown ? 'reconnected' : String(m['why'] ?? 'welcome'), { ckpt, snap, round: roundInfo, roster: slots });
1645
2233
  if (next === 'host' && snap) tick = Math.max(tick, snap.k);
1646
2234
  }
1647
- if (continuing) reannounce();
2235
+ if (continuing) { reannounce(); catchUp(had, hadSeat); }
2236
+ setLink('online', 'welcome');
2237
+ // A build older than the live one, let in to its own room (section 23).
2238
+ if (m['stale'] && typeof m['stale'] === 'object') noteStale((m['stale'] as { ver?: unknown }).ver, false);
1648
2239
  resolveView('seat');
1649
2240
  ping(); setTimeout(ping, 120); setTimeout(ping, 260);
1650
2241
  }
1651
2242
 
2243
+ /**
2244
+ * A host that reconnected and is still the host hears no `role` (nothing changed for it), and the relay never told
2245
+ * it who came or went while its socket was down: the welcome only replaced the peer list. Without this, a player
2246
+ * who joined meanwhile had no body for the rest of the visit (no `join` ever reached the host's roster), and one
2247
+ * who left kept a frozen one. So the difference is said as the `leave` and `join` events it would have been:
2248
+ * by seat (a socket id changes on every reconnect), and a seat whose number changed hands (`occ`) is both.
2249
+ */
2250
+ function catchUp(had: Peer[], hadSeat: number | null): void {
2251
+ const seatedOf = (list: Iterable<Peer>): Map<number, Peer> => {
2252
+ const out = new Map<number, Peer>();
2253
+ for (const p of list) if (typeof p.seat === 'number' && p.seat !== seat && p.seat !== hadSeat) out.set(p.seat, p);
2254
+ return out;
2255
+ };
2256
+ const before = seatedOf(had);
2257
+ const after = seatedOf(peers.values());
2258
+ const swapped = (a: Peer, b: Peer): boolean => typeof a.occ === 'number' && typeof b.occ === 'number' && a.occ !== b.occ;
2259
+ for (const [s, old] of before) {
2260
+ const cur = after.get(s);
2261
+ if (cur && !swapped(old, cur)) continue;
2262
+ inputs.delete(s); presses.delete(s);
2263
+ emit('leave', { id: old.id, seat: s, why: 'gone' });
2264
+ }
2265
+ for (const [s, cur] of after) {
2266
+ const old = before.get(s);
2267
+ if (old && !swapped(old, cur)) continue;
2268
+ ctlOf(s);
2269
+ emit('join', cur);
2270
+ }
2271
+ if (watching) resolveView('auto');
2272
+ }
2273
+
1652
2274
  /** A host that reconnected (the relay may have restarted): hand the relay the round, roster, state and a checkpoint. */
1653
2275
  function reannounce(): void {
1654
2276
  if (roundInfo) raw({ t: 'round', round: roundInfo });
@@ -1731,6 +2353,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1731
2353
  if (role === 'host') return; // a stale frame from a previous host
1732
2354
  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 };
1733
2355
  if (Array.isArray(m['c'])) s.c = m['c'] as ControlWire[];
2356
+ if (m['hb'] === 1) s.hb = 1;
1734
2357
  if (acceptSnap(s, bytes, true)) { emit('snapshot', s); if (!snapSeen) { snapSeen = true; autoPlayable(); } }
1735
2358
  }
1736
2359
 
@@ -1782,11 +2405,47 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1782
2405
  let ckptTimer: ReturnType<typeof setInterval> | null = null;
1783
2406
  let statsTimer: ReturnType<typeof setInterval> | null = null;
1784
2407
  let connectTimer: ReturnType<typeof setTimeout> | undefined;
2408
+ let hbTimer: ReturnType<typeof setInterval> | null = null;
2409
+ /** `connectClock: 'game'`: the wait for the welcome is not counted until the game says it has booted. */
2410
+ let clockRunning = opts.connectClock !== 'game';
1785
2411
  let lastStatsPost = 0;
2412
+ /** The last snapshot's state as text, and when the GAME last sent one (a heartbeat is not the game's). */
2413
+ let lastSnapData: string | null = null;
2414
+ let lastRealSnapAt = -Infinity;
2415
+ /** A snapshot's time stamp is always later than the one before it: a replica drops one that is not, and a real
2416
+ * snapshot sent in the same millisecond as a heartbeat must not be the one dropped. */
2417
+ let lastSnapSt = 0;
2418
+ const snapStamp = (): number => (lastSnapSt = Math.max(now(), lastSnapSt + 1));
2419
+
2420
+ /**
2421
+ * A host's heartbeat (section 22). The relay replaces a host that sent no snapshot for its stall time, which is
2422
+ * right for a frozen tab and wrong for a game whose frames hitched for a second (a level loading, shaders
2423
+ * compiling, a throttled window): the room changed hands again and again. So when the game has sent nothing for
2424
+ * `heartbeatMs`, this timer sends its last state again with a fresh time stamp, marked `hb`. Replicas hold the
2425
+ * picture, which is what the host is showing too. Only for HEARTBEAT_MAX_MS: after that the game is taken to be
2426
+ * frozen, the heartbeat stops, and the relay hands the room on as before.
2427
+ */
2428
+ function heartbeat(): void {
2429
+ if (role !== 'host' || offline || !connected || closed || peers.size < 2 || lastSnapData === null || hidden()) return;
2430
+ const t = wall();
2431
+ if (t - lastSnapSentAt < heartbeatMs || t - lastRealSnapAt > HEARTBEAT_MAX_MS) return;
2432
+ if (!ws || ws.readyState !== 1 || (ws.bufferedAmount ?? 0) > 3 * 1024) return;
2433
+ const c = ctlWire();
2434
+ const text = `{"t":"snap","k":${tick},"st":${snapStamp()},"d":${lastSnapData}${c.length ? `,"c":${JSON.stringify(c)}` : ''},"hb":1}`;
2435
+ try { ws.send(text); } catch { return; }
2436
+ countBytes(bytesOut, text.length);
2437
+ lastSnapSentAt = t;
2438
+ heartbeats += 1;
2439
+ }
1786
2440
 
1787
2441
  function hidden(): boolean { try { return Boolean(g.document?.hidden); } catch { return false; } }
1788
2442
  function ping(): void {
1789
- if (!connected) return;
2443
+ if (!connected) {
2444
+ // A socket that opened and was never welcomed (a room that is not answering) is as dead as a silent one: drop
2445
+ // it and knock again, so a page that gave up and plays alone still finds its room when the room is back.
2446
+ if (ws && ws.readyState === 1 && lastMsgAt && wall() - lastMsgAt > Math.max(staleMs, connectOpenMaxMs)) lost(ws, 'stale');
2447
+ return;
2448
+ }
1790
2449
  // A socket that delivered nothing (not even a pong) for staleMs is half-open: drop it and reconnect.
1791
2450
  if (ws && lastMsgAt && wall() - lastMsgAt > staleMs) { lost(ws, 'stale'); return; }
1792
2451
  const c = Date.now();
@@ -1854,7 +2513,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1854
2513
  lastSnapBytes, maxSnapBytes, bytesInPerS: perSecond(bytesIn), bytesOutPerS: perSecond(bytesOut),
1855
2514
  interpDelay: Math.round(interpDelay()), snapAgeP90: Math.round(ageP90), starvedPct: +starvedPct.toFixed(3), rejectedSnaps,
1856
2515
  owned: api.owned, pending: sentHist.length, stateKeys: stateMap.size,
1857
- peers: peers.size, reconnects, promotions, round: roundInfo ? roundInfo.n : null,
2516
+ peers: peers.size, reconnects, drops, link, heartbeats, promotions, round: roundInfo ? roundInfo.n : null,
1858
2517
  decides: { asked: decideStats.asked, ai: decideStats.ai, local: decideStats.local, floor: decideStats.floor, msP50: [...decideStats.ms].sort((x, y) => x - y)[Math.floor(decideStats.ms.length / 2)] ?? null },
1859
2518
  };
1860
2519
  }
@@ -1877,6 +2536,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1877
2536
  connected = false;
1878
2537
  seat = null;
1879
2538
  if (!roleKnown || role !== 'host') setRole('host', why);
2539
+ // No shell at all is `offline` from the start; a room that never answered is `alone` (and still knocked at).
2540
+ if (why !== 'offline') setLink('alone', why);
1880
2541
  }
1881
2542
 
1882
2543
  function lost(sock: WebSocketLike, why: string): void {
@@ -1886,8 +2547,11 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1886
2547
  if (why !== 'closed') { try { sock.close(4000, why); } catch { /* gone */ } }
1887
2548
  const was = connected;
1888
2549
  connected = false;
1889
- if (was) downSince = wall();
2550
+ if (was) { downSince = wall(); drops += 1; }
1890
2551
  if (was) emit('status', false);
2552
+ // It was in its room and the socket went: it knocks again with its token. (Never welcomed yet: still `connecting`,
2553
+ // or `alone` once it gave up waiting; stopped for good: `closed`.)
2554
+ if (was && !closed) setLink('reconnecting', why === 'stale' ? 'stale' : 'lost');
1891
2555
  notifyStats(true);
1892
2556
  retry();
1893
2557
  }
@@ -1902,6 +2566,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1902
2566
  raw({
1903
2567
  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,
1904
2568
  ...(watching ? { watch: true } : {}), ...(caps.size ? { caps: [...caps] } : {}), ...(asAgent ? { agent: asAgent } : {}),
2569
+ // Revision 9 (an older relay ignores both): the game's revision and what this build can do.
2570
+ ...(version ? { ver: version } : {}), ...(features.length ? { feat: features } : {}),
1905
2571
  });
1906
2572
  };
1907
2573
  sock.onmessage = (ev) => onMessage(ev.data);
@@ -1931,18 +2597,23 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1931
2597
  // started a private fight 1 at every loaded boot and swapped it for the room's a moment later. A socket
1932
2598
  // that is connecting or open gets CONNECT_OPEN_MAX_MS of that listening time; one that failed outright
1933
2599
  // (ws is null between retries) gets connectTimeoutMs.
2600
+ // `connectOpenMaxMs` sizes that wait; with `connectClock: 'game'` it starts only at `net.start()`, once the game
2601
+ // has finished booting (or CONNECT_CLOCK_MAX_MS after the helper was made: a game that never says still gets a role).
1934
2602
  let listenedMs = 0;
1935
2603
  let lastCheckAt = wall();
2604
+ const bornAt = wall();
1936
2605
  const connectCheck = (): void => {
1937
2606
  if (roleKnown) return;
1938
2607
  const t = wall();
1939
- listenedMs += Math.min(t - lastCheckAt, 600);
2608
+ if (!clockRunning && t - bornAt >= CONNECT_CLOCK_MAX_MS) clockRunning = true;
2609
+ if (clockRunning) listenedMs += Math.min(t - lastCheckAt, 600);
1940
2610
  lastCheckAt = t;
1941
2611
  const live = Boolean(ws && (ws.readyState === 0 || ws.readyState === 1));
1942
- if (listenedMs < (live ? CONNECT_OPEN_MAX_MS : connectTimeoutMs)) { connectTimer = setTimeout(connectCheck, 250); return; }
2612
+ if (listenedMs < (live ? connectOpenMaxMs : connectTimeoutMs)) { connectTimer = setTimeout(connectCheck, 250); return; }
1943
2613
  goOfflineHost('relay-timeout');
1944
2614
  };
1945
2615
  connectTimer = setTimeout(connectCheck, 250);
2616
+ if (heartbeatMs) hbTimer = setInterval(heartbeat, 250);
1946
2617
  pingTimer = setInterval(ping, 2000);
1947
2618
  ckptTimer = setInterval(sendCheckpoint, checkpointMs);
1948
2619
  statsTimer = setInterval(() => notifyStats(true), 500);
@@ -2012,6 +2683,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2012
2683
  if (ev.source !== g.parent || !ev.data || typeof ev.data !== 'object') return;
2013
2684
  const d = ev.data as { t?: unknown; on?: unknown };
2014
2685
  if (d.t === 'homie-hush') hushed = d.on === true;
2686
+ // The page's answer to a net.prefs call, and where its own controls sit over the game (section 24).
2687
+ if (d.t === 'homie-prefs') { const m = d as Record<string, unknown>; const w = prefsAsks.get(Number(m['n'])); if (w) { prefsAsks.delete(Number(m['n'])); w(m); } }
2688
+ if (d.t === 'homie-shell') readShell(d as Record<string, unknown>);
2015
2689
  // Room chat (section 19): the play page's "Show chat" and "Show my messages over my character".
2016
2690
  if (d.t === 'homie-chat') {
2017
2691
  const c = d as { show?: unknown; bubble?: unknown };
@@ -2084,6 +2758,30 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2084
2758
  get connected() { return connected; },
2085
2759
  get closedWhy() { return closedWhy; },
2086
2760
  get downMs() { return connected || !downSince ? 0 : wall() - downSince; },
2761
+ get link() { return link; },
2762
+ get reconnects() { return reconnects; },
2763
+ get resumed() { return resumed; },
2764
+ start(): void { clockRunning = true; },
2765
+ get version() { return version; },
2766
+ get stale() { return staleVer; },
2767
+ featuresOf(s: number | null): string[] {
2768
+ if (s === null || s === undefined) return [];
2769
+ if (s === seat && !offline) return [...features];
2770
+ for (const p of peers.values()) if (p.seat === s) return Array.isArray(p.feat) ? p.feat.filter((x) => typeof x === 'string') : [];
2771
+ return [];
2772
+ },
2773
+ allHave(feature: string): boolean {
2774
+ const k = String(feature ?? '').toLowerCase();
2775
+ if (!features.includes(k)) return false;
2776
+ // A lite agent (hands `host`) has no game client: its body is the host's own bot code.
2777
+ for (const p of peers.values()) if (typeof p.seat === 'number' && !p.watch && !(p.agent && p.agent.hands === 'host') && !(Array.isArray(p.feat) && p.feat.includes(k))) return false;
2778
+ return true;
2779
+ },
2780
+ get prefs() { return prefs; },
2781
+ get params() { return params; },
2782
+ param(n: string, fallback: string | null = null): string | null { return Object.prototype.hasOwnProperty.call(params, n) ? params[n] as string : fallback; },
2783
+ get shell() { return shellLayout; },
2784
+ get arrivalInfo() { return { ...arrived }; },
2087
2785
  get owned() { return role === 'host' || offline || seat === null ? true : mine.own; },
2088
2786
  get id() { return id; },
2089
2787
  get name() { return name; },
@@ -2096,7 +2794,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2096
2794
  get announcement() { return announcement && (announcement.until === undefined || announcement.until > now()) ? announcement : null; },
2097
2795
  isMuted(s: number | null): boolean { return s !== null && (muted.get(s) ?? 0) > now(); },
2098
2796
  pickPlayer(s: number | null): void { post?.({ what: 'pick', seat: typeof s === 'number' ? s : null }); },
2099
- playable(): void { sayPlayable('game'); },
2797
+ playable(): void { explicitPlayable(); },
2100
2798
  loading(fraction: number, what?: string): void {
2101
2799
  if (playableSent) return;
2102
2800
  const t = wall();
@@ -2181,9 +2879,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2181
2879
  if (role !== 'host' || offline || !connected) return false;
2182
2880
  if (!force && wall() - lastSnapSentAt < (peers.size > 1 ? snapshotMs : 1000) - 2) return false;
2183
2881
  tick = k ?? tick + 1;
2184
- const st = now();
2882
+ const st = snapStamp();
2185
2883
  const c = ctlWire();
2186
- const text = JSON.stringify(c.length ? { t: 'snap', k: tick, st, d, c } : { t: 'snap', k: tick, st, d });
2884
+ // The state as text once: the heartbeat sends the same text again while the game's frames hitch.
2885
+ const data = JSON.stringify(d ?? null);
2886
+ const text = `{"t":"snap","k":${JSON.stringify(tick)},"st":${st},"d":${data}${c.length ? `,"c":${JSON.stringify(c)}` : ''}}`;
2887
+ lastSnapData = data;
2888
+ lastRealSnapAt = wall();
2187
2889
  if (!ws || ws.readyState !== 1) return false;
2188
2890
  // A congested socket drops a snapshot rather than queueing a stale one. At 3 KB (about six
2189
2891
  // snapshots), not 256 KB. A starved network thread on a loaded phone held a second of snapshots and then
@@ -2330,9 +3032,14 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2330
3032
  if (ckptTimer) clearInterval(ckptTimer);
2331
3033
  if (statsTimer) clearInterval(statsTimer);
2332
3034
  if (watchTimer) clearInterval(watchTimer);
3035
+ if (hbTimer) clearInterval(hbTimer);
2333
3036
  if (spotTimer) clearTimeout(spotTimer);
3037
+ if (overlayTimer) clearTimeout(overlayTimer);
3038
+ if (overlayHide) clearTimeout(overlayHide);
2334
3039
  clearTimeout(connectTimer);
2335
3040
  try { raw({ t: 'bye' }); ws?.close(1000, 'bye'); } catch { /* gone */ }
3041
+ if (!offline || link === 'alone') setLink('closed', 'close');
3042
+ if (overlay) overlay.hidden = true;
2336
3043
  },
2337
3044
  };
2338
3045
 
@@ -2353,6 +3060,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2353
3060
  get policy() { return policy; },
2354
3061
  get vote() { return voteState; },
2355
3062
  get revision() { return NETPLAY_REVISION; },
3063
+ /** Revision 9: the link, how often it was interrupted, the game's revision, and the arrival's facts (for a probe). */
3064
+ get link() { return link; },
3065
+ get reconnects() { return reconnects; },
3066
+ get drops() { return drops; },
3067
+ get version() { return version; },
3068
+ get stale() { return staleVer; },
3069
+ get arrival() { return { ...arrived }; },
3070
+ get shell() { return shellLayout; },
3071
+ get params() { return params; },
2356
3072
  get agent() { return asAgent; },
2357
3073
  get following() { return following; },
2358
3074
  get state() { return Object.fromEntries(stateMap); },