@homie-rocks/studio 0.30.2 → 0.31.1

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 (90) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/README.md +349 -23
  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 +204 -124
  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 +203 -10
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/deploy-state.mjs +166 -0
  23. package/lib/dev.mjs +430 -0
  24. package/lib/directory.mjs +172 -9
  25. package/lib/doctor.mjs +12 -11
  26. package/lib/handoff.mjs +2 -6
  27. package/lib/library.mjs +1 -1
  28. package/lib/mcp-tools.mjs +42 -37
  29. package/lib/mcp.mjs +1 -1
  30. package/lib/net.mjs +81 -2
  31. package/lib/node-version.mjs +40 -0
  32. package/lib/office.mjs +6 -6
  33. package/lib/optimise.mjs +39 -4
  34. package/lib/parts-build.mjs +169 -0
  35. package/lib/parts-cli.mjs +104 -0
  36. package/lib/parts-store.mjs +342 -0
  37. package/lib/parts-tools.mjs +92 -0
  38. package/lib/parts.mjs +720 -0
  39. package/lib/perf.mjs +131 -31
  40. package/lib/port-check.mjs +40 -6
  41. package/lib/preview.mjs +77 -0
  42. package/lib/routes.mjs +240 -0
  43. package/lib/scaffold.mjs +83 -22
  44. package/lib/shoot.mjs +307 -0
  45. package/lib/site.mjs +1 -2
  46. package/lib/stage.mjs +118 -0
  47. package/lib/studio.mjs +0 -66
  48. package/lib/style-board.mjs +49 -6
  49. package/lib/template-history.json +101 -0
  50. package/lib/trailer.mjs +47 -0
  51. package/lib/typecheck.mjs +107 -0
  52. package/mcp/ui/lineup.js +3 -3
  53. package/mcp/ui/rights.js +1 -3
  54. package/netplay/NETPLAY.md +552 -16
  55. package/netplay/netplay.ts +950 -43
  56. package/package.json +10 -1
  57. package/parts/PARTS.md +169 -0
  58. package/port/index.ts +8 -2
  59. package/port/personas.ts +275 -0
  60. package/port/powerups.ts +357 -0
  61. package/port/probe.ts +38 -2
  62. package/port/room.ts +329 -40
  63. package/site/SITE.md +102 -20
  64. package/starters/ember-vale/index.html +1 -1
  65. package/starters/ember-vale/src/main.ts +7 -1
  66. package/starters/gem-rush/index.html +1 -1
  67. package/starters/gem-rush/src/main.ts +41 -12
  68. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  69. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  70. package/starters/gem-rush-3d/index.html +1 -1
  71. package/starters/gem-rush-3d/src/main.ts +50 -11
  72. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  73. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  74. package/starters/hero-rush-3d/index.html +1 -1
  75. package/starters/hero-rush-3d/src/main.ts +50 -11
  76. package/worker/arrival.mjs +4 -1
  77. package/worker/discover.mjs +11 -25
  78. package/worker/index.mjs +142 -62
  79. package/worker/license.mjs +45 -41
  80. package/worker/office-page.mjs +0 -4
  81. package/worker/office-schema.mjs +6 -0
  82. package/worker/office.mjs +19 -23
  83. package/worker/pages.mjs +156 -29
  84. package/worker/parts.mjs +275 -0
  85. package/worker/room.mjs +157 -14
  86. package/worker/schema.mjs +6 -8
  87. package/worker/seats.mjs +71 -0
  88. package/worker/site.mjs +51 -53
  89. package/worker/version.mjs +1 -1
  90. package/lib/remix-assets.mjs +0 -84
@@ -55,6 +55,24 @@
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 or did not come back (`alone`), or stopped (`closed`); one line
62
+ * over the game says which, in words that are true ("Reconnecting…" only to a
63
+ * page that was connected; restyle it, or `linkOverlay: false`). `reconnecting`
64
+ * is bounded (`reconnectMaxMs`); `net.full` and `net.line()` say a full room
65
+ * and a game's own sentence (section 28). A host whose frames hitch keeps its
66
+ * role with a heartbeat snapshot from a timer.
67
+ * - Game revisions (section 23): game.json `netplay.version` rides in the hello;
68
+ * a room runs one build at a time and an older tab is told to reload
69
+ * (`on('stale')`). `features` are per-peer capability flags the relay keeps,
70
+ * so a new host knows them without asking again.
71
+ * - The page around the frame (section 24): `net.prefs` (a few settings kept by
72
+ * the play page, because the frame has no storage of its own; read a number
73
+ * with `net.prefs.number(key, fallback)`, never a `null` to multiply by), `net.params`
74
+ * (the address's switches the page passed in), `net.shell` (where the page's
75
+ * own buttons sit over the game), and `guardGestures()` for a touch game.
58
76
  *
59
77
  * WHAT IT DOES NOT DO: rendering, physics, input devices, bots. `Roster` below
60
78
  * is the bot-yield bookkeeping a host needs; the bots themselves are the game's.
@@ -63,9 +81,9 @@
63
81
 
64
82
  export const NETPLAY_VERSION = 1;
65
83
  /** 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;
84
+ export const NETPLAY_REVISION = 9;
67
85
  /** 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';
86
+ export const NETPLAY_MARK = 'homie-netplay-rev:9';
69
87
 
70
88
  export type Role = 'host' | 'replica' | 'screen';
71
89
  export type Device = 'phone' | 'desk' | 'tv';
@@ -115,6 +133,104 @@ export interface NetConfig {
115
133
  chatOff?: boolean;
116
134
  /** Revision 8: this player does not want their own messages over their character (the play page's toggle). */
117
135
  bubbleOff?: boolean;
136
+ /** Revision 9: the game's revision this page was served with (game.json `netplay.version`); the hello says it. */
137
+ ver?: string;
138
+ /** Revision 9: the switches the play page passed in from its own address (`?q=low&debug`), by name. */
139
+ params?: Record<string, string>;
140
+ /** Revision 9: the page around the frame keeps `net.prefs` (a play page from before it does not say so). */
141
+ prefs?: boolean;
142
+ }
143
+
144
+ /* ------------------------------------------------- the link, revisions and the page (revision 9, sections 22-24) */
145
+
146
+ /**
147
+ * Where this browser stands with its room. `connecting`: no welcome yet. `online`: in the room. `reconnecting`: it
148
+ * was in the room and its socket went; it is knocking again (its seat token keeps its body), for `reconnectMaxMs` at
149
+ * most. `alone`: the room never answered in time, or did not come back in that time, so this browser plays by itself
150
+ * as an offline host with its own bots (it still knocks, and joins when answered).
151
+ * `offline`: there is no room at all (a plain file, a dev server with no shell). `closed`: stopped for good
152
+ * (`net.closedWhy` says why: another tab took the seat, the room is full, the game was updated…).
153
+ */
154
+ export type LinkState = 'connecting' | 'online' | 'reconnecting' | 'alone' | 'offline' | 'closed';
155
+ export interface LinkChange {
156
+ state: LinkState;
157
+ prev: LinkState;
158
+ /** Why it changed: `welcome`, `lost` (the socket closed), `stale` (it went silent), `relay-timeout` (the room never answered), `reconnect-timeout` (it was in the room and the room did not come back in `reconnectMaxMs`), `no-shell`, or a refusal's code. */
159
+ why: string;
160
+ /** 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. */
161
+ hosting: boolean;
162
+ /** How long the socket has been down (ms; 0 when it is not). */
163
+ downMs: number;
164
+ }
165
+ /** 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. */
166
+ export interface StaleNotice { ver: string | null; mine: string | null; final: boolean }
167
+ /** One of the play page's own controls drawn over the game, in CSS pixels of the game's own viewport. */
168
+ export interface ShellRect {
169
+ /** `room` (the room button), `server` (the server pill), `chip` (the "3 playing" line), `chat`, `join` (the big screen's QR card), `banner`, `results`. */
170
+ id: string;
171
+ x: number; y: number; w: number; h: number;
172
+ /** It fades by itself after a few seconds (the chip, a toast): still worth keeping important text out of. */
173
+ fades?: boolean;
174
+ }
175
+ /** Where the page's controls sit over the game on this device, held this way (`on('shell')`; null before the page says). */
176
+ export interface ShellLayout {
177
+ device: Device;
178
+ orientation: 'portrait' | 'landscape';
179
+ /** The game frame's size in CSS pixels, as the page sees it. */
180
+ width: number; height: number;
181
+ rects: ShellRect[];
182
+ }
183
+ /**
184
+ * A few settings the play page keeps for this game on this browser (quality, a personal best, the last name typed):
185
+ * the game's frame has no storage of its own. Strings, numbers, booleans and small JSON; 16 KB a game in all, 64
186
+ * characters a key, 32 keys. Never a secret, never progress that must not be lost (that is cloud saves).
187
+ */
188
+ export interface Prefs {
189
+ /**
190
+ * The value kept under `key`, or `fallback`. Always resolves (with the fallback when the page cannot answer, and
191
+ * when nothing or `null` is kept). It is NOT checked against the fallback's type: for a number, a switch or one of
192
+ * a few words, use `number`, `boolean` and `string` below, which cannot hand a game a `null` to multiply by.
193
+ */
194
+ get<T = unknown>(key: string, fallback?: T): Promise<T>;
195
+ /**
196
+ * A NUMBER that cannot go quiet (section 24). The kept value when it is a finite number, else `fallback`: for a
197
+ * key never set, a `null`, a string, a `NaN`, and before the prefs have arrived (`await net.prefs.ready` first;
198
+ * a read before that returns the fallback and warns once). `min` and `max` hold the answer (and the fallback) to
199
+ * a range; `integer` rounds it. `fallback` must itself be a finite number: anything else throws a TypeError.
200
+ */
201
+ number(key: string, fallback: number, range?: { min?: number; max?: number; integer?: boolean }): number;
202
+ /** A switch: the kept value when it is `true` or `false`, else `fallback` (never `0`, `'false'` or `null` read as a boolean). */
203
+ boolean(key: string, fallback: boolean): boolean;
204
+ /** A word: the kept value when it is a string (and one of `oneOf`, when given), else `fallback`. */
205
+ string<T extends string = string>(key: string, fallback: T, opts?: { oneOf?: readonly T[] }): T;
206
+ /**
207
+ * Keep a value (`null` or `undefined` removes it). Resolves false when it was not kept: too big, no page, or a
208
+ * value JSON cannot hold (`NaN`, `Infinity`, a function, a loop), which is refused with a warning rather than
209
+ * kept as `null`.
210
+ */
211
+ set(key: string, value: unknown): Promise<boolean>;
212
+ remove(key: string): Promise<boolean>;
213
+ /** Everything kept for this game. */
214
+ all(): Promise<Record<string, unknown>>;
215
+ /** What this page has read so far, without waiting (after `all()` or `ready` resolved: everything). Before that it is the fallback, and says so once. */
216
+ peek<T = unknown>(key: string, fallback?: T): T;
217
+ /** Resolves once everything kept was read (so `peek`, `number`, `boolean` and `string` are complete). */
218
+ readonly ready: Promise<void>;
219
+ /** Everything kept has been read: the typed readers answer from what is kept, not from their fallbacks. */
220
+ readonly loaded: boolean;
221
+ /** Where the values live: `page` (the play page's storage), `local` (this document's own), `memory` (this visit only). */
222
+ readonly where: 'page' | 'local' | 'memory';
223
+ }
224
+ /** The arrival, for a performance probe (`window.__homieNet.arrival`). Times are ms since the helper was created. */
225
+ export interface ArrivalInfo {
226
+ mode: 'auto' | 'game';
227
+ /** Who gave the game the screen (null: not yet). */
228
+ by: 'auto' | 'game' | null;
229
+ playableMs: number | null;
230
+ /** When the game itself called `net.playable()` (null: it never did). */
231
+ explicitMs: number | null;
232
+ /** The game's own word came this long after an automatic arrival had already lifted the card (null: it did not). */
233
+ lateMs: number | null;
118
234
  }
119
235
 
120
236
  /* ------------------------------------------------- room chat (revision 8, section 19) */
@@ -280,11 +396,21 @@ export interface Peer {
280
396
  * Never on a kids server, never for an AI.
281
397
  */
282
398
  badge?: string;
399
+ /**
400
+ * Revision 9: which stay in the seat this is. The relay gives a seat a new number every time it changes hands
401
+ * and keeps it across that browser's reconnects, so a host can tell "the same player came back" from "somebody
402
+ * new has this seat number now". Undefined from an older relay.
403
+ */
404
+ occ?: number;
405
+ /** Revision 9: the game revision this peer's page was served with (game.json `netplay.version`). */
406
+ ver?: string;
407
+ /** Revision 9: what this peer's build says it can do (`createNetplay({ features })`); the relay keeps it. */
408
+ feat?: string[];
283
409
  }
284
410
 
285
411
  /** One seat's entry in the body-control table: [seat, rs, own (1|0), ack]. */
286
412
  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[] }
413
+ 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
414
  export interface Checkpoint<D = unknown> { k: number; st: number; d: D; c?: ControlWire[] }
289
415
  export interface InputFrame<A = unknown> {
290
416
  /** Sequence number per sender. */
@@ -405,6 +531,12 @@ export interface NetStats {
405
531
  stateKeys: number;
406
532
  peers: number;
407
533
  reconnects: number;
534
+ /** Revision 9: times a link that was up went down (a reconnect that worked on the first knock counts once). */
535
+ drops?: number;
536
+ /** Revision 9: where this browser stands with its room. */
537
+ link?: LinkState;
538
+ /** Revision 9: heartbeat snapshots sent from the timer while the game's own frames hitched. */
539
+ heartbeats?: number;
408
540
  promotions: number;
409
541
  round: number | null;
410
542
  /** 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 +575,50 @@ export interface NetplayOptions<C = unknown> {
443
575
  interpDelayMs?: number;
444
576
  /** Host: return the full rules state. Called every `checkpointMs`, before yielding, and on pagehide. */
445
577
  checkpoint?: () => C;
446
- /** ms to wait for a welcome before falling back to offline host. Default 4000. */
578
+ /** ms to wait for a welcome before falling back to offline host, when the socket could not even open. Default 4000. */
447
579
  connectTimeoutMs?: number;
580
+ /**
581
+ * How long a page whose socket is open (or opening) listens for the room's welcome before it plays alone, counted
582
+ * only while the page can listen. Default 10000; 2000 to 120000. A heavy 3D boot under load that takes longer
583
+ * plays a private round for a few seconds first: raise it, or use `connectClock: 'game'`.
584
+ */
585
+ connectOpenMaxMs?: number;
586
+ /**
587
+ * When that wait starts. 'auto' (the default): when the helper is created. 'game': when the game calls
588
+ * `net.start()`, once it has finished booting (or 60 s after the helper was created, so a game that never says is
589
+ * never left without a role). The socket opens at once either way; a welcome that arrives first is used at once.
590
+ */
591
+ connectClock?: 'auto' | 'game';
592
+ /**
593
+ * How long a page that WAS in its room keeps `reconnecting` before it plays `alone` (a private round with its own
594
+ * bots; it still knocks, and joins when it is answered). Default 20000; 5000 to 300000. Before this there was no
595
+ * bound: a replica whose room never came back showed a frozen round and "Reconnecting…" for good.
596
+ */
597
+ reconnectMaxMs?: number;
598
+ /**
599
+ * A host's heartbeat: when the game has not sent a snapshot for this long while others are present (its frames
600
+ * hitched: a long load, a shader compile, a throttled tab), the helper sends the last one again from a timer, so
601
+ * the relay does not hand the room to somebody else for a pause. Default 500 ms; 0 turns it off. It stops after
602
+ * 4 s without a real snapshot: a game that is really frozen is still replaced.
603
+ */
604
+ heartbeatMs?: number;
605
+ /**
606
+ * The game's revision (game.json `netplay.version`; the play page passes it, so a game rarely sets this itself).
607
+ * A room runs one revision at a time: see NETPLAY.md section 23.
608
+ */
609
+ version?: string;
610
+ /**
611
+ * What this build can do, as short words (`['powerups', 'rhythm2']`: up to 8, each a-z, 0-9 and -, 24 characters).
612
+ * The relay keeps them with the peer, so every host (and the next one) reads `net.featuresOf(seat)` instead of
613
+ * running its own handshake. An older build says none.
614
+ */
615
+ features?: string[];
616
+ /**
617
+ * The line drawn over the game while the link is down ("Reconnecting…", "Playing on your own…"), while the room
618
+ * is full, and for `net.line()`. Default true in a room; false turns it off (listen to `link` and draw your own).
619
+ * Restyle it with CSS on `[data-homie-link]`.
620
+ */
621
+ linkOverlay?: boolean;
448
622
  /** A socket that delivered nothing for this long is dropped and reopened (pongs come every 2 s). Default 6000. */
449
623
  staleMs?: number;
450
624
  /** Tests: a WebSocket constructor. */
@@ -510,6 +684,12 @@ export interface NetHandlers<S, A, C> {
510
684
  unchat: (e: { ids: string[] }) => void;
511
685
  /** Revision 8: a message of mine was not sent (slow mode, sign in to type, a word, the studio's filter…). */
512
686
  held: (h: ChatHeld) => void;
687
+ /** Revision 9: where this browser stands with its room changed (online, reconnecting, alone, closed). */
688
+ link: (e: LinkChange) => void;
689
+ /** Revision 9: a newer build of the game is live; this tab should reload (section 23). */
690
+ stale: (e: StaleNotice) => void;
691
+ /** Revision 9: the play page's own controls over the game moved (a turn of the phone, a sheet opened). */
692
+ shell: (e: ShellLayout) => void;
513
693
  }
514
694
 
515
695
  /**
@@ -547,6 +727,49 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
547
727
  readonly closedWhy: string | null;
548
728
  /** How long the socket has been down while it reconnects (ms; 0 while connected). */
549
729
  readonly downMs: number;
730
+ /**
731
+ * THE LINK (revision 9, NETPLAY.md section 22). `offline` is true when this browser is NOT in a room: there is no
732
+ * shell at all, or the room never answered and it plays alone (`link === 'alone'`). `connected` is true only while
733
+ * the socket is up and welcomed. Between the two (`!offline && !connected`) the browser was in a room and is
734
+ * knocking again: `link === 'reconnecting'`. `link` says which in one word, and `on('link')` when it changes.
735
+ */
736
+ readonly link: LinkState;
737
+ /** Times the helper has scheduled another knock at the relay (the same number as `stats().reconnects`). 0: never interrupted. */
738
+ readonly reconnects: number;
739
+ /** 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. */
740
+ readonly resumed: boolean;
741
+ /**
742
+ * This browser asked to play and every seat is taken: it is in the room with no seat (`seat === null`, role
743
+ * `screen`) and the relay seats it when one frees up (a `role` event with `why: 'seated'`). The line over the game
744
+ * says so; `createRoom({ fallback })` decides what the page does meanwhile.
745
+ */
746
+ readonly full: boolean;
747
+ /**
748
+ * The line over the game is the game's to use too: one short sentence about where this player stands ("Playing
749
+ * on your own until the next round"), or null to take it away. Shown while the link is `online`; a link that is
750
+ * down says its own line first. `createRoom` uses it for its `fallback`.
751
+ */
752
+ line(text: string | null): void;
753
+ /** `connectClock: 'game'`: the game has finished booting; the wait for the room's welcome starts now. Once. */
754
+ start(): void;
755
+ /** REVISIONS (section 23): this page's game revision (null: the game names none). */
756
+ readonly version: string | null;
757
+ /** A newer revision is live and this tab should reload: its name (or '' when the relay named none); null otherwise. */
758
+ readonly stale: string | null;
759
+ /** What a seat's build says it can do (its `features`); [] for an older build, an empty seat or a bot. */
760
+ featuresOf(seat: number | null): string[];
761
+ /** Every seated player's build has this feature (bots and AI bodies the host moves are the host's own). */
762
+ allHave(feature: string): boolean;
763
+ /** THE PAGE (section 24): a few settings the play page keeps for this game. */
764
+ readonly prefs: Prefs;
765
+ /** The switches the play page passed in from its address (`/play?q=low&debug`), by name. Offline: this document's own. */
766
+ readonly params: Readonly<Record<string, string>>;
767
+ /** One switch, or `fallback` (a bare `?debug` is `''`). */
768
+ param(name: string, fallback?: string | null): string | null;
769
+ /** Where the play page's own controls sit over the game (null before the page says, or with no page). */
770
+ readonly shell: ShellLayout | null;
771
+ /** The arrival's facts (mode, who lifted the card, when the game itself said it was playable). */
772
+ readonly arrivalInfo: ArrivalInfo;
550
773
  /**
551
774
  * Host: hand the room to another browser — a checkpoint, then the protocol's `yield` (the same road a hidden
552
775
  * tab takes). The relay elects the best other candidate, or keeps me host when nobody else can host.
@@ -760,7 +983,18 @@ export interface RosterOptions {
760
983
  * `aiSeats + guides` slots for AI (marked `agent`, never given to a person); `bots: 'off'` adds no other filler.
761
984
  */
762
985
  policy?: () => Policy | null;
986
+ /**
987
+ * ADMISSION (revision 9, NETPLAY.md section 25): which bot's body a NEW arrival takes. Called with the bot slots
988
+ * it may take (never one kept for AI when a person arrives, never another person's), in slot order; return the
989
+ * `slot` to give, `undefined` for the default (the lowest), or `null` when none of them will do (a new body is
990
+ * added when the room has space; when it has none, the default applies: a seated person always gets a body).
991
+ * It must be deterministic from the game's state (a new host asks it again). It is NOT asked for somebody who
992
+ * comes back to the body they held (a reload, a reconnect): returning players keep their body, alive or not.
993
+ */
994
+ admit?: (candidates: Slot[], who: { seat: number; name: string; agent: boolean }) => number | null | undefined;
763
995
  }
996
+ /** What `Roster.claim` did. `back`: the seat got the body it already had, or the one it held last (nobody new). */
997
+ export interface Claim { slot: Slot; yielded: boolean; added: boolean; back: boolean }
764
998
 
765
999
  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
1000
 
@@ -785,24 +1019,52 @@ export class Roster {
785
1019
  readonly max: number;
786
1020
  readonly botName: (slot: number) => string;
787
1021
  private readonly policyFn: (() => Policy | null) | null;
788
- /** seat → the slot it held when it last left */
789
- private readonly lastSlot = new Map<number, number>();
1022
+ private readonly admitFn: RosterOptions['admit'] | null;
1023
+ /** seat → the slot it held when it last left, and which stay in the seat that was (`Peer.occ`) */
1024
+ private readonly lastSlot = new Map<number, { slot: number; occ: number | undefined }>();
1025
+ /** seat → which stay in the seat holds its slot now (`Peer.occ`; nothing from a relay that does not say) */
1026
+ private readonly occ = new Map<number, number>();
790
1027
 
791
1028
  constructor(opts: RosterOptions) {
792
1029
  this.min = Math.max(0, opts.min | 0);
793
1030
  this.max = Math.max(this.min, opts.max | 0);
794
1031
  this.botName = opts.botName ?? ((slot: number) => `Bot ${slot + 1}`);
795
1032
  this.policyFn = opts.policy ?? null;
1033
+ this.admitFn = opts.admit ?? null;
796
1034
  this.fill();
797
1035
  }
798
1036
 
799
- static from(slots: readonly Slot[], opts: RosterOptions): Roster {
1037
+ /** `occupants`: what `occupants()` returned when the slots were saved (a checkpoint), so a new host can tell who came back. */
1038
+ static from(slots: readonly Slot[], opts: RosterOptions, occupants?: readonly (readonly [seat: number, occ: number])[] | null): Roster {
800
1039
  const r = new Roster(opts);
801
1040
  r.slots = slots.map(copySlot);
1041
+ 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
1042
  r.fill();
803
1043
  return r;
804
1044
  }
805
1045
 
1046
+ /** Which stay in its seat holds each slot ([seat, occ]): keep it in the checkpoint, beside `toJSON()`. */
1047
+ occupants(): [seat: number, occ: number][] {
1048
+ return [...this.occ].filter(([seat]) => Boolean(this.bySeat(seat)));
1049
+ }
1050
+
1051
+ /**
1052
+ * The seat has a slot here, but the relay says somebody else holds that seat number now (it changed hands while
1053
+ * this host was not told: a checkpoint of a room that emptied, a host that was cut off). False when either side
1054
+ * does not know (an older relay, an older checkpoint): then the seat is taken to be the same player's.
1055
+ */
1056
+ changedHands(seat: number, occ: number | undefined | null): boolean {
1057
+ const known = this.occ.get(seat);
1058
+ return typeof occ === 'number' && known !== undefined && known !== occ;
1059
+ }
1060
+
1061
+ /** 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. */
1062
+ vacate(seat: number): Slot | null {
1063
+ const s = this.release(seat);
1064
+ this.lastSlot.delete(seat);
1065
+ return s;
1066
+ }
1067
+
806
1068
  private policy(): Policy | null {
807
1069
  try { return this.policyFn ? this.policyFn() : null; } catch { return null; }
808
1070
  }
@@ -855,18 +1117,41 @@ export class Roster {
855
1117
  /**
856
1118
  * A human (or, revision 6, an agent) takes a slot. Returns the slot, whether it yielded a bot, or null (full:
857
1119
  * spectate). A person never takes a slot kept for AI; an agent takes one first.
1120
+ *
1121
+ * `occ` (revision 9): which stay in the seat this is (`peer.occ`). The same stay keeps its body (`back`); a seat
1122
+ * that changed hands gives its old body back to a bot first, and the newcomer is admitted like any other arrival.
1123
+ * A NEW arrival's body is the `admit` callback's choice among the bots it may take, else the lowest.
858
1124
  */
859
- claim(seat: number, name: string, agent?: { role?: AgentRole; hands?: 'self' | 'host' } | null): { slot: Slot; yielded: boolean; added: boolean } | null {
1125
+ claim(seat: number, name: string, agent?: { role?: AgentRole; hands?: 'self' | 'host' } | null, occ?: number | null): Claim | null {
1126
+ const stay = typeof occ === 'number' ? occ : undefined;
860
1127
  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);
1128
+ if (mine && !this.changedHands(seat, stay)) {
1129
+ if (stay !== undefined) this.occ.set(seat, stay);
1130
+ mine.name = name || mine.name;
1131
+ return { slot: mine, yielded: false, added: false, back: true };
1132
+ }
1133
+ // Somebody new holds this seat number: the body its last holder left goes back to a bot, and is not "theirs".
1134
+ if (mine) this.vacate(seat);
1135
+ const held = this.lastSlot.get(seat);
1136
+ const last = held && (stay === undefined || held.occ === undefined || held.occ === stay) ? held.slot : undefined;
863
1137
  const kept = (s: Slot): boolean => Boolean(s.agent && s.agent.seat === null);
864
1138
  const plainBot = (s: Slot): boolean => s.bot && !s.agent;
1139
+ const settle = (slot: Slot, yielded: boolean, added: boolean, back: boolean): Claim => {
1140
+ if (stay !== undefined) this.occ.set(seat, stay); else this.occ.delete(seat);
1141
+ this.lastSlot.delete(seat);
1142
+ return { slot, yielded, added, back };
1143
+ };
1144
+ /** The game's choice among `cands` (undefined: the default; null: none of them will do). */
1145
+ const chosen = (cands: Slot[]): Slot | null | undefined => {
1146
+ if (!this.admitFn || !cands.length) return undefined;
1147
+ let r: number | null | undefined;
1148
+ try { r = this.admitFn(cands.map(copySlot), { seat, name, agent: Boolean(agent) }); } catch (err) { console.warn('[netplay] admit()', err); return undefined; }
1149
+ if (r === null) return null;
1150
+ return cands.find((s) => s.slot === r);
1151
+ };
865
1152
  if (agent) {
866
1153
  const role: AgentRole = agent.role === 'guide' || agent.role === 'player' ? agent.role : 'party';
867
1154
  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
1155
  const take = (s: Slot): Slot => {
871
1156
  s.agent = { seat, role: s.agent?.role ?? role, hands };
872
1157
  s.name = name || aiName(this.botName(s.slot));
@@ -874,31 +1159,41 @@ export class Roster {
874
1159
  if (hands === 'host') { s.bot = true; s.seat = null; } else { s.bot = false; s.seat = seat; }
875
1160
  return s;
876
1161
  };
877
- if (slot) return { slot: take(slot), yielded: true, added: false };
1162
+ const back = last !== undefined ? this.slots.find((s) => s.slot === last && (kept(s) || plainBot(s))) : undefined;
1163
+ if (back) return settle(take(back), true, false, true);
1164
+ // A seat kept for AI first (the reservation), then a plain bot: the game chooses within whichever it is.
1165
+ const pool = this.slots.some(kept) ? this.slots.filter(kept) : this.slots.filter(plainBot);
1166
+ const pick = chosen(pool);
1167
+ const slot = pick ?? (pick === null && this.slots.length < this.max ? undefined : pool[0]);
1168
+ if (slot) return settle(take(slot), true, false, false);
878
1169
  if (this.slots.length >= this.max) return null;
879
1170
  const s = take({ slot: this.nextSlotId(), seat: null, name: '', bot: true });
880
1171
  this.slots.push(s);
881
1172
  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 };
1173
+ return settle(s, false, true, false);
888
1174
  }
1175
+ const give = (bot: Slot): Slot => { bot.bot = false; bot.seat = seat; bot.name = name || `Player ${seat + 1}`; return bot; };
1176
+ const back = last !== undefined ? this.slots.find((s) => s.slot === last && plainBot(s)) : undefined;
1177
+ if (back) return settle(give(back), true, false, true);
1178
+ const pool = this.slots.filter(plainBot);
1179
+ const pick = chosen(pool);
1180
+ // "None of these" adds a body while the room has space; a full room still seats the person in the default one.
1181
+ const bot = pick ?? (pick === null && this.slots.length < this.max ? undefined : pool[0]);
1182
+ if (bot) return settle(give(bot), true, false, false);
889
1183
  if (this.slots.length >= this.max) return null;
890
1184
  const slot = this.nextSlotId();
891
1185
  const s: Slot = { slot, seat, name: name || `Player ${seat + 1}`, bot: false };
892
1186
  this.slots.push(s);
893
1187
  this.slots.sort((a, b) => a.slot - b.slot);
894
- return { slot: s, yielded: false, added: true };
1188
+ return settle(s, false, true, false);
895
1189
  }
896
1190
 
897
1191
  /** A human leaves: their slot becomes a bot where it stands. An agent's goes back to a seat kept for AI. */
898
1192
  release(seat: number): Slot | null {
899
1193
  const s = this.bySeat(seat);
900
1194
  if (!s) return null;
901
- this.lastSlot.set(seat, s.slot);
1195
+ this.lastSlot.set(seat, { slot: s.slot, occ: this.occ.get(seat) });
1196
+ this.occ.delete(seat);
902
1197
  s.bot = true; s.seat = null;
903
1198
  if (s.agent) {
904
1199
  const keep = this.slots.filter((x) => x.agent && x !== s).length < this.reserved();
@@ -927,23 +1222,32 @@ export class Roster {
927
1222
  return removed;
928
1223
  }
929
1224
 
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 });
1225
+ /**
1226
+ * After a promotion (or a host's own reconnect): make the roster agree with who is actually connected (an agent
1227
+ * with its facts). `claimed`: every body a present seat took just now, through the same `claim` (and `admit`) as
1228
+ * a fresh join (reset each one); a seat whose number somebody else holds now (`occ` differs) is one of them.
1229
+ * `back`: those of `claimed` whose player got the body they held last (nobody new). Players whose slot never left
1230
+ * are in neither.
1231
+ */
1232
+ reconcile(peers: Iterable<{ seat: number | null; name: string; agent?: AgentFacts | null; occ?: number | null }>): { claimed: Slot[]; released: Slot[]; back: Slot[] } {
1233
+ const here = new Map<number, { name: string; agent?: AgentFacts | null; occ?: number | null }>();
1234
+ 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
1235
  const released: Slot[] = [];
935
1236
  const claimed: Slot[] = [];
1237
+ const back: Slot[] = [];
936
1238
  for (const s of this.slots.filter((x) => !x.bot || (x.agent && x.agent.seat !== null))) {
937
1239
  const seat = s.agent && s.agent.seat !== null ? s.agent.seat : s.seat;
938
1240
  if (seat !== null && !here.has(seat)) { const r = this.release(seat); if (r) released.push(r); }
939
1241
  }
940
1242
  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);
1243
+ const had = this.bySeat(seat);
1244
+ if (had && !this.changedHands(seat, p.occ)) { if (typeof p.occ === 'number') this.occ.set(seat, p.occ); continue; }
1245
+ if (had) released.push(had);
1246
+ const c = this.claim(seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ);
1247
+ if (c) { claimed.push(c.slot); if (c.back) back.push(c.slot); }
944
1248
  }
945
1249
  this.fill();
946
- return { claimed, released };
1250
+ return { claimed, released, back };
947
1251
  }
948
1252
 
949
1253
  toJSON(): Slot[] { return this.slots.map(copySlot); }
@@ -958,7 +1262,7 @@ const LADDER = [250, 500, 1000, 2000, 4000];
958
1262
  * second of frames at once), and a kick that ended the page's play for good left a phone frozen with no word.
959
1263
  * After one it comes back (its seat token keeps its body), sending at the slow rate below.
960
1264
  */
961
- const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many', 'kicked', 'room-closed', 'watch-off', 'agent-pass', 'agents-off', 'agents-unsupported']);
1265
+ const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many', 'kicked', 'room-closed', 'watch-off', 'agent-pass', 'agents-off', 'agents-unsupported', 'stale']);
962
1266
  /** An AI closed for being alone in a room (`agents-alone`, section 17) waits this long before it knocks again. */
963
1267
  const AGENTS_ALONE_WAIT_MS = 30_000;
964
1268
  /**
@@ -967,8 +1271,121 @@ const AGENTS_ALONE_WAIT_MS = 30_000;
967
1271
  * stall anywhere between) still fit under the cap.
968
1272
  */
969
1273
  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). */
1274
+ /** 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
1275
  const CONNECT_OPEN_MAX_MS = 10_000;
1276
+ /** `connectClock: 'game'`: the wait starts by itself this long after the helper was created, if the game never calls `net.start()`. */
1277
+ const CONNECT_CLOCK_MAX_MS = 60_000;
1278
+ /** 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. */
1279
+ const HEARTBEAT_MS = 500;
1280
+ const HEARTBEAT_MAX_MS = 4000;
1281
+ /** How long a page that was in its room stays `reconnecting` before it plays alone: the default of `reconnectMaxMs`. */
1282
+ const RECONNECT_MAX_MS = 20_000;
1283
+ /** The link overlay: a reconnect shorter than this is never drawn. */
1284
+ const LINK_OVERLAY_MS = 700;
1285
+ /** `net.prefs` (section 24): what the play page keeps for one game on one browser. worker/pages.mjs has the same numbers. */
1286
+ export const PREFS_LIMITS = Object.freeze({ bytes: 16_384, keys: 32, key: 64 });
1287
+ const PREFS_WAIT_MS = 2000;
1288
+ const FEATURE = /^[a-z0-9][a-z0-9-]{0,23}$/;
1289
+ const VERSION = /^[A-Za-z0-9._-]{1,32}$/;
1290
+ /** 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. */
1291
+ export function cleanVersion(v: unknown): string | null {
1292
+ const t = typeof v === 'number' && Number.isFinite(v) ? String(v) : typeof v === 'string' ? v.trim() : '';
1293
+ return VERSION.test(t) ? t : null;
1294
+ }
1295
+ /** A build's features as the contract carries them: up to 8 short lowercase words. */
1296
+ export function cleanFeatures(list: unknown): string[] {
1297
+ if (!Array.isArray(list)) return [];
1298
+ const out: string[] = [];
1299
+ 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; }
1300
+ return out;
1301
+ }
1302
+
1303
+ /**
1304
+ * PLACES (NETPLAY.md section 26): rows ranked by score, with a tie policy said out loud. `rows` must already be in the
1305
+ * order to show them (the tiebreak is the caller's: createRoom puts people before bots, then the lower slot).
1306
+ * `'order'` (the default, what results always were): places 1, 2, 3… in that order, so two equal scores get different
1307
+ * places. `'shared'`: standard competition places, equal scores share one and the next is skipped (1, 1, 3).
1308
+ * `'dense'`: equal scores share one and none is skipped (1, 1, 2).
1309
+ */
1310
+ export type TiePolicy = 'order' | 'shared' | 'dense';
1311
+ export function placesOf(scores: readonly number[], ties: TiePolicy = 'order'): number[] {
1312
+ const out: number[] = [];
1313
+ for (let i = 0; i < scores.length; i += 1) {
1314
+ if (ties === 'order' || i === 0 || scores[i] !== scores[i - 1]) out.push(ties === 'dense' && i > 0 ? (out[i - 1] as number) + 1 : i + 1);
1315
+ else out.push(out[i - 1] as number);
1316
+ }
1317
+ return out;
1318
+ }
1319
+
1320
+ /**
1321
+ * A TOUCH GAME'S GESTURE GUARD (NETPLAY.md section 24), for the game's own page. The play page around the frame
1322
+ * already refuses selection, the callout and page gestures on itself, but a long press INSIDE the game's document
1323
+ * (a HUD label, a button's text, the canvas) is the game's: on a phone it raises the copy/paste callout or selects
1324
+ * text, and the thumb's touch is cancelled. Call it once, early:
1325
+ *
1326
+ * const off = guardGestures({ touch: 'canvas, [data-action]' });
1327
+ *
1328
+ * It makes the page's text unselectable and suppresses the callout and the context menu, and it stops the browser's
1329
+ * default for touches that start on the elements `touch` names (pan, pinch, double-tap zoom: `touch-action: none`
1330
+ * plus a non-passive `preventDefault`). Text fields, selects, links, ordinary buttons and anything inside
1331
+ * `[data-selectable]` keep their native behaviour. Returns the function that takes it all off again.
1332
+ */
1333
+ export interface GestureGuardOptions {
1334
+ /** Where gameplay touches land: a selector (default 'canvas'). Touches that start there never pan, zoom or select. */
1335
+ touch?: string;
1336
+ /** The document to guard (default: this one). */
1337
+ document?: Document;
1338
+ /** false: add no stylesheet (the listeners only). */
1339
+ css?: boolean;
1340
+ }
1341
+ const NATIVE_TOUCH = 'input, textarea, select, option, a[href], label, [contenteditable]:not([contenteditable="false"]), [data-selectable]';
1342
+ export function guardGestures(o: GestureGuardOptions = {}): () => void {
1343
+ const doc = o.document ?? (globalThis as { document?: Document }).document;
1344
+ if (!doc || typeof doc.addEventListener !== 'function') return () => {};
1345
+ const touch = typeof o.touch === 'string' && o.touch.trim() ? o.touch : 'canvas';
1346
+ const within = (t: EventTarget | null, sel: string): boolean => {
1347
+ const el = t as { closest?: (s: string) => unknown; parentElement?: { closest?: (s: string) => unknown } } | null;
1348
+ try { return Boolean(el && (typeof el.closest === 'function' ? el.closest(sel) : el.parentElement?.closest?.(sel))); } catch { return false; }
1349
+ };
1350
+ /** A field, a link, a select, a marked region: the browser's own behaviour is the right one there. */
1351
+ const native = (t: EventTarget | null): boolean => within(t, NATIVE_TOUCH);
1352
+ /** A touch the game owns: it started on a gameplay surface. An ordinary button outside one keeps its click. */
1353
+ const gameplay = (t: EventTarget | null): boolean => !native(t) && within(t, touch);
1354
+ let style: { remove?: () => void } | null = null;
1355
+ if (o.css !== false && typeof doc.createElement === 'function') {
1356
+ try {
1357
+ const el = doc.createElement('style');
1358
+ el.setAttribute('data-homie-gestures', '');
1359
+ el.textContent = `html,body{-webkit-touch-callout:none;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;overscroll-behavior:none}
1360
+ ${touch}{touch-action:none;-webkit-touch-callout:none;-webkit-user-select:none;user-select:none}
1361
+ 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}`;
1362
+ (doc.head ?? doc.documentElement)?.appendChild(el);
1363
+ style = el;
1364
+ } catch { /* a document that takes no styles keeps the listeners */ }
1365
+ }
1366
+ const stop = (e: Event): void => { if (e.cancelable) e.preventDefault(); };
1367
+ const onSelect = (e: Event): void => { if (!native(e.target)) stop(e); };
1368
+ const onMenu = (e: Event): void => { if (!native(e.target)) stop(e); };
1369
+ const onTouch = (e: Event): void => { if (gameplay(e.target)) stop(e); };
1370
+ // iOS's pinch on the page (it has no touch-action for it before 13, and ignores user-scalable).
1371
+ const onGesture = (e: Event): void => { if (!native(e.target)) stop(e); };
1372
+ const active = { passive: false, capture: true } as AddEventListenerOptions;
1373
+ doc.addEventListener('selectstart', onSelect, true);
1374
+ doc.addEventListener('contextmenu', onMenu, true);
1375
+ doc.addEventListener('touchstart', onTouch, active);
1376
+ doc.addEventListener('touchmove', onTouch, active);
1377
+ doc.addEventListener('gesturestart', onGesture, active);
1378
+ doc.addEventListener('dblclick', onTouch, true);
1379
+ return () => {
1380
+ doc.removeEventListener('selectstart', onSelect, true);
1381
+ doc.removeEventListener('contextmenu', onMenu, true);
1382
+ doc.removeEventListener('touchstart', onTouch, active);
1383
+ doc.removeEventListener('touchmove', onTouch, active);
1384
+ doc.removeEventListener('gesturestart', onGesture, active);
1385
+ doc.removeEventListener('dblclick', onTouch, true);
1386
+ try { style?.remove?.(); } catch { /* gone */ }
1387
+ };
1388
+ }
972
1389
  /** After the relay reports dropped inputs (or kicked this browser for them): the cap for the next while. */
973
1390
  const IN_SLOW_CAP_PER_S = 15;
974
1391
  const IN_SLOW_MS = 8000;
@@ -1018,7 +1435,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1018
1435
  const inputMs = 1000 / Math.max(1, Math.min(30, opts.inputHz ?? 20));
1019
1436
  const checkpointMs = Math.max(250, opts.checkpointMs ?? 1000);
1020
1437
  const connectTimeoutMs = opts.connectTimeoutMs ?? 4000;
1438
+ const connectOpenMaxMs = Math.max(2000, Math.min(120_000, Number(opts.connectOpenMaxMs) || CONNECT_OPEN_MAX_MS));
1439
+ const reconnectMaxMs = Math.max(5000, Math.min(300_000, Number(opts.reconnectMaxMs) || RECONNECT_MAX_MS));
1440
+ const heartbeatMs = opts.heartbeatMs === 0 ? 0 : Math.max(200, Math.min(2000, Number(opts.heartbeatMs) || HEARTBEAT_MS));
1021
1441
  const staleMs = Math.max(2500, opts.staleMs ?? 6000);
1442
+ // Revisions (section 23): the game's revision (the page's word, or the game's own) and what this build can do.
1443
+ const version = cleanVersion(opts.version ?? cfg?.ver);
1444
+ const features = cleanFeatures(opts.features);
1022
1445
  const device: Device = cfg?.device ?? guessDevice();
1023
1446
  // A watcher (section 16) is a screen that never takes a seat, whatever else it is told.
1024
1447
  const watching = Boolean(opts.watch ?? cfg?.watch);
@@ -1042,6 +1465,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1042
1465
  role: new Set(), join: new Set(), leave: new Set(), input: new Set(), event: new Set(),
1043
1466
  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
1467
  policy: new Set(), vote: new Set(), chat: new Set(), say: new Set(), unchat: new Set(), held: new Set(),
1468
+ link: new Set(), stale: new Set(), shell: new Set(),
1045
1469
  };
1046
1470
  const emit = <K extends keyof NetHandlers<S, A, C>>(kind: K, arg: Parameters<NetHandlers<S, A, C>[K]>[0]): void => {
1047
1471
  for (const fn of [...handlers[kind]]) {
@@ -1064,6 +1488,11 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1064
1488
  let ws: WebSocketLike | null = null;
1065
1489
  let attempt = 0;
1066
1490
  let reconnects = 0;
1491
+ let drops = 0;
1492
+ let heartbeats = 0;
1493
+ /** My welcome gave me back the seat my token named (the same player), and a newer build is live (section 23). */
1494
+ let resumed = false;
1495
+ let staleVer: string | null = null;
1067
1496
  let promotions = 0;
1068
1497
  let lastMsgAt = 0;
1069
1498
  let waitingVisible = false;
@@ -1140,11 +1569,31 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1140
1569
  let autoArmed = false;
1141
1570
  let snapSeen = false;
1142
1571
  let loadingAt = 0;
1572
+ const createdAt = (typeof performance !== 'undefined' ? performance.now() : Date.now());
1573
+ const arrived: ArrivalInfo = { mode: arrival, by: null, playableMs: null, explicitMs: null, lateMs: null };
1143
1574
  function sayPlayable(by: 'game' | 'auto'): void {
1144
1575
  if (playableSent) return;
1145
1576
  playableSent = true;
1577
+ arrived.by = by;
1578
+ arrived.playableMs = Math.round(wall() - createdAt);
1146
1579
  post?.({ what: 'playable', by });
1147
1580
  }
1581
+ /**
1582
+ * The game's own `net.playable()`. With `arrival: 'auto'` the helper may already have given the game the screen
1583
+ * (seated, the room's state in): a later call then changes nothing, and a performance probe has measured an empty
1584
+ * world as playable. Say so once, and tell the page when the game itself was ready, so a report can show both.
1585
+ */
1586
+ function explicitPlayable(): void {
1587
+ if (arrived.explicitMs !== null) return;
1588
+ arrived.explicitMs = Math.round(wall() - createdAt);
1589
+ const late = playableSent && arrived.by === 'auto' && arrived.playableMs !== null ? arrived.explicitMs - arrived.playableMs : null;
1590
+ if (late !== null && late >= 250) {
1591
+ arrived.lateMs = late;
1592
+ 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().`);
1593
+ }
1594
+ post?.({ what: 'ready', by: 'game', mode: arrival, ms: arrived.explicitMs, ...(arrived.lateMs !== null ? { lateMs: arrived.lateMs } : {}) });
1595
+ sayPlayable('game');
1596
+ }
1148
1597
  /** 'auto': a host at once, anyone else at its first snapshot, then two animation frames (the game drew with it). */
1149
1598
  function autoPlayable(): void {
1150
1599
  if (arrival !== 'auto' || playableSent || autoArmed || !roleKnown) return;
@@ -1225,6 +1674,274 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1225
1674
  return true;
1226
1675
  }
1227
1676
 
1677
+ /* ------------------------------------------------------------ the link (revision 9, section 22) */
1678
+ let link: LinkState = offline ? 'offline' : 'connecting';
1679
+ /** The line over the game while the link is down: made on first need, in this document, so a game's CSS reaches it. */
1680
+ const overlayOn = opts.linkOverlay !== false && !offline;
1681
+ let overlay: HTMLElement | null = null;
1682
+ let overlayTimer: ReturnType<typeof setTimeout> | null = null;
1683
+ let overlayHide: ReturnType<typeof setTimeout> | null = null;
1684
+ function overlayEl(): HTMLElement | null {
1685
+ const doc = g.document;
1686
+ if (!overlayOn || !doc || typeof doc.createElement !== 'function' || !doc.body) return null;
1687
+ if (overlay) return overlay;
1688
+ try {
1689
+ // :where() has no specificity: any rule a game writes for [data-homie-link] wins.
1690
+ const st = doc.createElement('style');
1691
+ st.setAttribute('data-homie-link-style', '');
1692
+ 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}';
1693
+ (doc.head ?? doc.documentElement).appendChild(st);
1694
+ const el = doc.createElement('div');
1695
+ el.setAttribute('role', 'status');
1696
+ el.setAttribute('aria-live', 'polite');
1697
+ el.hidden = true;
1698
+ el.addEventListener('click', () => { if (el.getAttribute('data-homie-link') === 'stale') { try { (globalThis as { location?: Location }).location?.reload(); } catch { /* not a page */ } } });
1699
+ doc.body.appendChild(el);
1700
+ overlay = el;
1701
+ } catch { overlay = null; }
1702
+ return overlay;
1703
+ }
1704
+ /** Asked to play and every seat is taken (the welcome's `full`): in the room, watching, seated when one frees up. */
1705
+ let full = false;
1706
+ /** The game's own sentence for the line (`net.line`), shown while the link is up. */
1707
+ let gameLine: string | null = null;
1708
+ /** Why this page plays alone, and the last refusal that was not final (`room-stale`): the line says the true one. */
1709
+ let aloneWhy = '';
1710
+ let lastRefusal = '';
1711
+ type LineKind = 'reconnecting' | 'alone' | 'stale' | 'closed' | 'full' | 'seat';
1712
+ function showOverlay(kind: LineKind, text: string, forMs = 0): void {
1713
+ const el = overlayEl();
1714
+ if (!el) return;
1715
+ if (overlayHide) { clearTimeout(overlayHide); overlayHide = null; }
1716
+ el.setAttribute('data-homie-link', kind);
1717
+ el.textContent = text;
1718
+ el.hidden = false;
1719
+ if (forMs > 0) overlayHide = setTimeout(() => { overlayHide = null; if (el.getAttribute('data-homie-link') === kind) el.hidden = true; }, forMs);
1720
+ }
1721
+ /** What is true of a page that plays by itself: it never says "reconnecting" to somebody who was never connected. */
1722
+ function aloneText(): string {
1723
+ if (aloneWhy === 'reconnect-timeout') return 'Playing on your own · the room dropped, still trying…';
1724
+ if (lastRefusal === 'room-stale') return 'Playing on your own · this room opens when its players have the new version';
1725
+ return 'Playing on your own · still looking for the room…';
1726
+ }
1727
+ /** A page stopped for good says why in words; the play page around it offers the way out (another room, a reload). */
1728
+ function closedText(): string | null {
1729
+ if (closedWhy === 'room-full' || closedWhy === 'too-many') return offline ? 'This room is full · playing on your own' : 'This room is full.';
1730
+ if (closedWhy === 'replaced') return 'This game is open in another tab.';
1731
+ if (closedWhy === 'kicked' || closedWhy === 'room-closed') return 'This room is closed.';
1732
+ if (closedWhy === 'version') return 'This game needs a reload to play online.';
1733
+ return null;
1734
+ }
1735
+ function paintLink(): void {
1736
+ if (!overlayOn) return;
1737
+ if (overlayTimer) { clearTimeout(overlayTimer); overlayTimer = null; }
1738
+ const closedSays = link === 'closed' ? closedText() : null;
1739
+ if (link === 'reconnecting') {
1740
+ // A blip shorter than a blink is never drawn.
1741
+ overlayTimer = setTimeout(() => { overlayTimer = null; if (link === 'reconnecting') showOverlay('reconnecting', 'Reconnecting…'); }, LINK_OVERLAY_MS);
1742
+ } else if (link === 'alone') showOverlay('alone', aloneText());
1743
+ else if (link === 'closed' && closedWhy === 'stale') showOverlay('stale', 'This game was updated. Tap to reload.');
1744
+ else if (closedSays) showOverlay('closed', closedSays);
1745
+ // In the room: the game's own sentence about where this player stands, else that the room is full.
1746
+ else if (link === 'online' && gameLine) showOverlay('seat', gameLine);
1747
+ else if (link === 'online' && full) showOverlay('full', 'This room is full · watching until a seat is free');
1748
+ else if (overlay && overlay.getAttribute('data-homie-link') !== 'stale') overlay.hidden = true;
1749
+ }
1750
+ function setLink(next: LinkState, why: string): void {
1751
+ if (next === link) return;
1752
+ const prev = link;
1753
+ link = next;
1754
+ const e: LinkChange = { state: next, prev, why, hosting: roleKnown && role === 'host', downMs: connected || !downSince ? 0 : Math.round(wall() - downSince) };
1755
+ post?.({ what: 'link', state: next, prev, why, hosting: e.hosting });
1756
+ paintLink();
1757
+ emit('link', e);
1758
+ }
1759
+ /** A newer build is live (section 23): the relay's `stale` frame (still playing) or its `stale` refusal (kept out). */
1760
+ function noteStale(ver: unknown, final: boolean): void {
1761
+ const next = cleanVersion(ver) ?? '';
1762
+ const first = staleVer === null;
1763
+ staleVer = next;
1764
+ if (!first && !final) return;
1765
+ post?.({ what: 'stale', ver: next || null, mine: version, final });
1766
+ if (!final) showOverlay('stale', 'A new version is ready. Tap to reload.', 10_000);
1767
+ emit('stale', { ver: next || null, mine: version, final });
1768
+ }
1769
+
1770
+ /* ------------------------------------------------------------ the page around the frame (revision 9, section 24) */
1771
+ // The address's switches: the play page's allow-listed ones (HOMIE_NET.params), or, with no page, this document's own.
1772
+ const params: Readonly<Record<string, string>> = Object.freeze((() => {
1773
+ const out: Record<string, string> = {};
1774
+ const given = cfg?.params;
1775
+ if (given && typeof given === 'object') {
1776
+ for (const [k, v] of Object.entries(given).slice(0, 32)) if (typeof v === 'string') out[k] = v;
1777
+ return out;
1778
+ }
1779
+ try {
1780
+ const search = (globalThis as { location?: { search?: string } }).location?.search ?? '';
1781
+ // With no page at all, the document's own address. In a frame whose page predates `params`, only the
1782
+ // switches that page always passed on (its own words, the seat's token among them, are in the address too).
1783
+ const always = ['debug', 'q', 'touchdebug', 'cam', 'view'];
1784
+ // forEach, not iteration: a game checked with lib "DOM" alone (no DOM.Iterable) has no iterator on URLSearchParams.
1785
+ let seen = 0;
1786
+ if (search) new URLSearchParams(search).forEach((v, k) => { if (seen++ < 32 && (!cfg || always.includes(k))) out[k] = v; });
1787
+ } catch { /* not a page */ }
1788
+ return out;
1789
+ })());
1790
+ let shellLayout: ShellLayout | null = null;
1791
+ function readShell(m: Record<string, unknown>): void {
1792
+ if (!Array.isArray(m['rects'])) return;
1793
+ const num = (v: unknown): number => (Number.isFinite(Number(v)) ? Math.round(Number(v)) : 0);
1794
+ const rects: ShellRect[] = [];
1795
+ for (const r of (m['rects'] as Record<string, unknown>[]).slice(0, 16)) {
1796
+ if (!r || typeof r !== 'object' || typeof r['id'] !== 'string') continue;
1797
+ 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'])) };
1798
+ if (r['fades'] === true) rect.fades = true;
1799
+ if (rect.w && rect.h) rects.push(rect);
1800
+ }
1801
+ const dev = m['device'] === 'phone' || m['device'] === 'tv' ? m['device'] : 'desk';
1802
+ shellLayout = { device: dev, orientation: m['orientation'] === 'portrait' ? 'portrait' : 'landscape', width: num(m['width']), height: num(m['height']), rects };
1803
+ emit('shell', shellLayout);
1804
+ }
1805
+
1806
+ // net.prefs: the play page keeps them (it says so in HOMIE_NET.prefs); with no page, this document's own storage
1807
+ // when it has one (a plain file, a dev server), else memory for the visit.
1808
+ const prefsKey = `homie-prefs.${String(opts.game ?? 'game').slice(0, 64)}`;
1809
+ const prefsCache = new Map<string, unknown>();
1810
+ const prefsAsks = new Map<number, (m: Record<string, unknown>) => void>();
1811
+ let prefsSeq = 0;
1812
+ let prefsReady: Promise<void> | null = null;
1813
+ const localStore = (): Storage | null => {
1814
+ try { const st = (globalThis as { localStorage?: Storage }).localStorage; if (!st) return null; st.getItem(prefsKey); return st; } catch { return null; }
1815
+ };
1816
+ const prefsWhere: Prefs['where'] = cfg?.prefs === true && post ? 'page' : !cfg && localStore() ? 'local' : 'memory';
1817
+ const prefsKeyOk = (k: unknown): k is string => typeof k === 'string' && k.length > 0 && k.length <= PREFS_LIMITS.key;
1818
+ function prefsAsk(msg: Record<string, unknown>): Promise<Record<string, unknown>> {
1819
+ return new Promise((done) => {
1820
+ const n = (prefsSeq += 1);
1821
+ const timer = setTimeout(() => { if (prefsAsks.delete(n)) done({ ok: false, why: 'no-answer' }); }, PREFS_WAIT_MS);
1822
+ prefsAsks.set(n, (m) => { clearTimeout(timer); done(m); });
1823
+ post?.({ what: 'prefs', n, ...msg });
1824
+ });
1825
+ }
1826
+ function prefsLoad(): Promise<void> {
1827
+ if (prefsReady) return prefsReady;
1828
+ const take = (all: unknown): void => {
1829
+ if (!all || typeof all !== 'object') return;
1830
+ // What this visit already set wins over what was read (a set that raced the first read).
1831
+ // A `null` on disk is what an earlier build kept for a NaN: it is nothing, not a setting.
1832
+ for (const [k, v] of Object.entries(all as Record<string, unknown>)) if (!prefsCache.has(k) && v !== null && v !== undefined) prefsCache.set(k, v);
1833
+ };
1834
+ if (prefsWhere === 'page') prefsReady = prefsAsk({ op: 'all' }).then((m) => { take(m['all']); prefsLoaded = true; });
1835
+ else {
1836
+ if (prefsWhere === 'local') { try { take(JSON.parse(localStore()?.getItem(prefsKey) ?? '{}')); } catch { /* nothing kept */ } }
1837
+ // With no page to ask, everything is read at once: a typed reader never meets "not arrived yet" here.
1838
+ prefsLoaded = true;
1839
+ prefsReady = Promise.resolve();
1840
+ }
1841
+ return prefsReady;
1842
+ }
1843
+ /** Whether the whole set still fits (16 KB, 32 keys): the page checks again; this keeps the three places alike. */
1844
+ function prefsFits(key: string, value: unknown): boolean {
1845
+ const next = Object.fromEntries(prefsCache);
1846
+ next[key] = value;
1847
+ let text: string;
1848
+ try { text = JSON.stringify(next); } catch { return false; }
1849
+ return typeof text === 'string' && text.length <= PREFS_LIMITS.bytes && Object.keys(next).length <= PREFS_LIMITS.keys;
1850
+ }
1851
+ /** Said once a visit, not once every ten seconds: a mistake in the game's code, for whoever is building it. */
1852
+ const saidOnce = new Set<string>();
1853
+ const sayOnce = (key: string, msg: string): void => { if (saidOnce.has(key)) return; saidOnce.add(key); console.warn('[netplay]', msg); };
1854
+ let prefsLoaded = false;
1855
+ const kindOf = (v: unknown): string => (v === null ? 'null' : Array.isArray(v) ? 'an array' : typeof v === 'number' && !Number.isFinite(v) ? String(v) : `a ${typeof v}`);
1856
+ /** Why JSON cannot hold `v` as it is (it would be kept as `null`, or not at all), or null when it can. */
1857
+ function unkeepable(v: unknown, depth = 0, seen: Set<object> = new Set()): string | null {
1858
+ if (v === null || typeof v === 'string' || typeof v === 'boolean') return null;
1859
+ if (typeof v === 'number') return Number.isFinite(v) ? null : `${String(v)} (JSON keeps it as null)`;
1860
+ if (typeof v === 'undefined') return depth ? 'undefined inside it' : null;
1861
+ if (typeof v === 'function' || typeof v === 'symbol' || typeof v === 'bigint') return `a ${typeof v}`;
1862
+ if (depth > 8) return 'nested too deep';
1863
+ const o = v as object;
1864
+ if (seen.has(o)) return 'a loop';
1865
+ seen.add(o);
1866
+ for (const x of Array.isArray(o) ? o : Object.values(o)) { const why = unkeepable(x, depth + 1, seen); if (why) return why; }
1867
+ seen.delete(o);
1868
+ return null;
1869
+ }
1870
+ /**
1871
+ * The kept value for a typed reader, or `undefined` for "use the fallback": nothing kept, a `null`, or the prefs
1872
+ * not read yet. Reading before they arrive is the mistake that made a volume silent, so it is said once, with the fix.
1873
+ */
1874
+ function prefsRaw(key: string, how: string, fallback: unknown): unknown {
1875
+ if (!prefsLoaded) {
1876
+ void prefsLoad();
1877
+ if (!prefsCache.has(key)) {
1878
+ sayOnce('prefs-early', `net.prefs.${how}('${key}') was read before the prefs had arrived, so it answered with its fallback (${JSON.stringify(fallback)}). Wait for them first: await net.prefs.ready`);
1879
+ return undefined;
1880
+ }
1881
+ }
1882
+ const v = prefsCache.get(key);
1883
+ return v === null ? undefined : v;
1884
+ }
1885
+ const wrongType = (key: string, how: string, v: unknown, fallback: unknown): void =>
1886
+ sayOnce(`prefs-type:${key}`, `net.prefs.${how}('${key}'): what is kept is ${kindOf(v)}, so it answered with its fallback (${JSON.stringify(fallback)})`);
1887
+ const prefs: Prefs = {
1888
+ // A kept `null` is never a value (set(key, null) removes): the fallback, not a null a game multiplies into silence.
1889
+ get: <T,>(key: string, fallback?: T): Promise<T> => prefsLoad().then(() => { const v = prefsCache.get(key); return (v === undefined || v === null ? fallback : v) as T; }),
1890
+ peek: <T,>(key: string, fallback?: T): T => { const v = prefsRaw(key, 'peek', fallback); return (v === undefined ? fallback : v) as T; },
1891
+ number(key: string, fallback: number, range: { min?: number; max?: number; integer?: boolean } = {}): number {
1892
+ if (typeof fallback !== 'number' || !Number.isFinite(fallback)) throw new TypeError(`net.prefs.number('${key}', fallback): the fallback must be a finite number, not ${kindOf(fallback)}`);
1893
+ const min = typeof range.min === 'number' && Number.isFinite(range.min) ? range.min : -Infinity;
1894
+ const max = typeof range.max === 'number' && Number.isFinite(range.max) ? range.max : Infinity;
1895
+ if (min > max) throw new TypeError(`net.prefs.number('${key}'): min (${min}) is above max (${max})`);
1896
+ const v = prefsRaw(key, 'number', fallback);
1897
+ let n = fallback;
1898
+ if (typeof v === 'number' && Number.isFinite(v)) n = v; else if (v !== undefined) wrongType(key, 'number', v, fallback);
1899
+ if (range.integer) n = Math.round(n);
1900
+ return Math.max(min, Math.min(max, n));
1901
+ },
1902
+ boolean(key: string, fallback: boolean): boolean {
1903
+ if (typeof fallback !== 'boolean') throw new TypeError(`net.prefs.boolean('${key}', fallback): the fallback must be true or false, not ${kindOf(fallback)}`);
1904
+ const v = prefsRaw(key, 'boolean', fallback);
1905
+ if (typeof v === 'boolean') return v;
1906
+ if (v !== undefined) wrongType(key, 'boolean', v, fallback);
1907
+ return fallback;
1908
+ },
1909
+ string<T extends string = string>(key: string, fallback: T, o: { oneOf?: readonly T[] } = {}): T {
1910
+ if (typeof fallback !== 'string') throw new TypeError(`net.prefs.string('${key}', fallback): the fallback must be a string, not ${kindOf(fallback)}`);
1911
+ if (o.oneOf && !o.oneOf.includes(fallback)) throw new TypeError(`net.prefs.string('${key}'): the fallback '${fallback}' is not one of ${JSON.stringify(o.oneOf)}`);
1912
+ const v = prefsRaw(key, 'string', fallback);
1913
+ if (typeof v === 'string' && (!o.oneOf || o.oneOf.includes(v as T))) return v as T;
1914
+ if (v !== undefined) sayOnce(`prefs-type:${key}`, typeof v === 'string' ? `net.prefs.string('${key}'): '${v.slice(0, 40)}' is not one of ${JSON.stringify(o.oneOf)}, so it answered with its fallback ('${fallback}')` : `net.prefs.string('${key}'): what is kept is ${kindOf(v)}, so it answered with its fallback ('${fallback}')`);
1915
+ return fallback;
1916
+ },
1917
+ all: () => prefsLoad().then(() => Object.fromEntries(prefsCache)),
1918
+ remove: (key: string) => prefs.set(key, null),
1919
+ async set(key: string, value: unknown): Promise<boolean> {
1920
+ if (!prefsKeyOk(key)) { sayOnce('prefs-key', `net.prefs.set: a key is 1 to ${PREFS_LIMITS.key} characters; ${JSON.stringify(String(key as unknown).slice(0, 24))} was not kept`); return false; }
1921
+ const del = value === null || value === undefined;
1922
+ // NaN, Infinity, a function, a loop: JSON would keep `null` (or nothing), and the next visit would read that as
1923
+ // a setting. Refused here, in words, instead of a volume that is silent from then on.
1924
+ const bad = del ? null : unkeepable(value);
1925
+ if (bad) { sayOnce(`prefs-value:${key}`, `net.prefs.set('${key}', …): the value is ${bad}, which cannot be kept, so nothing was changed. Keep a finite number, a string, a boolean or plain JSON`); return false; }
1926
+ await prefsLoad();
1927
+ 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; }
1928
+ const had = prefsCache.has(key);
1929
+ const before = prefsCache.get(key);
1930
+ if (del) prefsCache.delete(key); else prefsCache.set(key, JSON.parse(JSON.stringify(value)));
1931
+ if (prefsWhere === 'page') {
1932
+ const m = await prefsAsk(del ? { op: 'del', k: key } : { op: 'set', k: key, v: value });
1933
+ // Refused by the page (over its cap): what was kept before stands.
1934
+ if (m['ok'] !== true && m['why'] !== 'no-answer') { if (had) prefsCache.set(key, before); else prefsCache.delete(key); return false; }
1935
+ return m['ok'] === true;
1936
+ }
1937
+ if (prefsWhere === 'local') { try { localStore()?.setItem(prefsKey, JSON.stringify(Object.fromEntries(prefsCache))); } catch { return false; } }
1938
+ return true;
1939
+ },
1940
+ get ready() { return prefsLoad(); },
1941
+ get loaded() { return prefsLoaded; },
1942
+ get where() { return prefsWhere; },
1943
+ };
1944
+
1228
1945
  /* ------------------------------------------------------------ body control (host) */
1229
1946
  function ctlOf(s: number): { rs: number; own: boolean; taken: boolean; ack: number; giveAt: number } {
1230
1947
  let e = ctl.get(s);
@@ -1517,6 +2234,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1517
2234
  colour = typeof m['colour'] === 'number' ? m['colour'] : colour;
1518
2235
  if (id) { const p = peers.get(id); if (p) { p.seat = seat; p.name = name; p.colour = colour; } }
1519
2236
  post?.({ what: 'token', token, seat, room: cfg?.room ?? null });
2237
+ if (full) { full = false; post?.({ what: 'full', full: false }); paintLink(); }
1520
2238
  setRole(m['role'] === 'host' ? 'host' : 'replica', 'seated');
1521
2239
  return;
1522
2240
  }
@@ -1536,6 +2254,11 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1536
2254
  if (next === 'host') { lastSnapSentAt = -Infinity; if (snap) tick = Math.max(tick, snap.k); }
1537
2255
  return;
1538
2256
  }
2257
+ case 'stale': {
2258
+ // A newer build of the game is live (section 23): this tab keeps playing in its own room, and is told.
2259
+ noteStale(m['ver'], false);
2260
+ return;
2261
+ }
1539
2262
  case 'watch': {
1540
2263
  // The relay's word on following (section 16): a seat this browser took in another tab, or taken back.
1541
2264
  relayFollow = m['follow'] !== false;
@@ -1574,6 +2297,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1574
2297
  if (code === 'agents-alone') aloneUntil = wall() + AGENTS_ALONE_WAIT_MS;
1575
2298
  if (code === 'vote') { warnOnce('vote', m['message']); return; }
1576
2299
  if (code === 'rate' || code === 'state-full') { warnOnce(`${code}:${String(m['of'] ?? '')}`, m['message']); return; }
2300
+ // The room still runs another build of the game (section 23): not final. Its players were told to reload; this
2301
+ // browser keeps knocking and is let in when they have. Said once, not at every knock.
2302
+ if (code === 'room-stale') { lastRefusal = code; warnOnce('room-stale', m['message']); if (link === 'alone') paintLink(); return; }
1577
2303
  console.warn('[netplay] relay refused:', code, m['message']);
1578
2304
  // The same seat opened in another tab, a full room, a version the relay does not speak: reconnecting
1579
2305
  // would repeat the refusal (or, for 'replaced', make the two tabs evict each other for ever).
@@ -1581,6 +2307,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1581
2307
  closed = true;
1582
2308
  closedWhy = code;
1583
2309
  post?.({ what: 'closed', why: code, ...(typeof m['until'] === 'number' ? { until: m['until'] } : {}), ...(typeof m['message'] === 'string' ? { message: String(m['message']).slice(0, 200) } : {}) });
2310
+ // Kept out of a room for running an older build (section 23): the page reloads the game; the game may too.
2311
+ if (code === 'stale') noteStale(m['ver'], true);
2312
+ setLink('closed', code);
1584
2313
  }
1585
2314
  return;
1586
2315
  }
@@ -1601,9 +2330,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1601
2330
  const wasOffline = offline;
1602
2331
  // Reconnected (a network blip, or a relay restart) and still the host: my rules state is the live one.
1603
2332
  const continuing = roleKnown && role === 'host' && next === 'host' && !wasOffline;
2333
+ // Who this host had in its room before its socket went: what it missed is worked out below.
2334
+ const had = continuing ? [...peers.values()] : [];
2335
+ const hadSeat = seat;
2336
+ const offered = token;
1604
2337
  id = String(m['id']);
1605
2338
  seat = typeof m['seat'] === 'number' ? m['seat'] : null;
1606
2339
  token = typeof m['token'] === 'string' ? m['token'] : token;
2340
+ // The relay gave back the seat my token named: I am the player who was here, not a new one in that seat number.
2341
+ resumed = seat !== null && typeof offered === 'string' && offered !== '' && offered === token;
1607
2342
  name = typeof m['name'] === 'string' ? m['name'] : name;
1608
2343
  colour = typeof m['colour'] === 'number' ? m['colour'] : colour;
1609
2344
  host = (m['host'] as HostRef | null) ?? null;
@@ -1636,6 +2371,11 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1636
2371
  connected = true;
1637
2372
  downSince = 0;
1638
2373
  attempt = 0;
2374
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
2375
+ lastRefusal = '';
2376
+ // Asked to play and every seat is taken: in the room with no seat, and seated when one frees up (a `seat` frame).
2377
+ const nowFull = m['full'] === true && seat === null && !watching;
2378
+ if (nowFull !== full) { full = nowFull; post?.({ what: 'full', full }); }
1639
2379
  emit('status', true);
1640
2380
  post?.({ what: 'token', token, seat, room: cfg?.room ?? null });
1641
2381
  // First welcome, a reconnect, or coming back from an offline fallback: only a CHANGE is a role event,
@@ -1644,11 +2384,45 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1644
2384
  setRole(next, wasOffline && roleKnown ? 'reconnected' : String(m['why'] ?? 'welcome'), { ckpt, snap, round: roundInfo, roster: slots });
1645
2385
  if (next === 'host' && snap) tick = Math.max(tick, snap.k);
1646
2386
  }
1647
- if (continuing) reannounce();
2387
+ if (continuing) { reannounce(); catchUp(had, hadSeat); }
2388
+ if (link === 'online') paintLink(); else setLink('online', 'welcome');
2389
+ // A build older than the live one, let in to its own room (section 23).
2390
+ if (m['stale'] && typeof m['stale'] === 'object') noteStale((m['stale'] as { ver?: unknown }).ver, false);
1648
2391
  resolveView('seat');
1649
2392
  ping(); setTimeout(ping, 120); setTimeout(ping, 260);
1650
2393
  }
1651
2394
 
2395
+ /**
2396
+ * A host that reconnected and is still the host hears no `role` (nothing changed for it), and the relay never told
2397
+ * it who came or went while its socket was down: the welcome only replaced the peer list. Without this, a player
2398
+ * who joined meanwhile had no body for the rest of the visit (no `join` ever reached the host's roster), and one
2399
+ * who left kept a frozen one. So the difference is said as the `leave` and `join` events it would have been:
2400
+ * by seat (a socket id changes on every reconnect), and a seat whose number changed hands (`occ`) is both.
2401
+ */
2402
+ function catchUp(had: Peer[], hadSeat: number | null): void {
2403
+ const seatedOf = (list: Iterable<Peer>): Map<number, Peer> => {
2404
+ const out = new Map<number, Peer>();
2405
+ for (const p of list) if (typeof p.seat === 'number' && p.seat !== seat && p.seat !== hadSeat) out.set(p.seat, p);
2406
+ return out;
2407
+ };
2408
+ const before = seatedOf(had);
2409
+ const after = seatedOf(peers.values());
2410
+ const swapped = (a: Peer, b: Peer): boolean => typeof a.occ === 'number' && typeof b.occ === 'number' && a.occ !== b.occ;
2411
+ for (const [s, old] of before) {
2412
+ const cur = after.get(s);
2413
+ if (cur && !swapped(old, cur)) continue;
2414
+ inputs.delete(s); presses.delete(s);
2415
+ emit('leave', { id: old.id, seat: s, why: 'gone' });
2416
+ }
2417
+ for (const [s, cur] of after) {
2418
+ const old = before.get(s);
2419
+ if (old && !swapped(old, cur)) continue;
2420
+ ctlOf(s);
2421
+ emit('join', cur);
2422
+ }
2423
+ if (watching) resolveView('auto');
2424
+ }
2425
+
1652
2426
  /** A host that reconnected (the relay may have restarted): hand the relay the round, roster, state and a checkpoint. */
1653
2427
  function reannounce(): void {
1654
2428
  if (roundInfo) raw({ t: 'round', round: roundInfo });
@@ -1731,6 +2505,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1731
2505
  if (role === 'host') return; // a stale frame from a previous host
1732
2506
  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
2507
  if (Array.isArray(m['c'])) s.c = m['c'] as ControlWire[];
2508
+ if (m['hb'] === 1) s.hb = 1;
1734
2509
  if (acceptSnap(s, bytes, true)) { emit('snapshot', s); if (!snapSeen) { snapSeen = true; autoPlayable(); } }
1735
2510
  }
1736
2511
 
@@ -1782,13 +2557,65 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1782
2557
  let ckptTimer: ReturnType<typeof setInterval> | null = null;
1783
2558
  let statsTimer: ReturnType<typeof setInterval> | null = null;
1784
2559
  let connectTimer: ReturnType<typeof setTimeout> | undefined;
2560
+ let hbTimer: ReturnType<typeof setInterval> | null = null;
2561
+ /** `reconnecting` is bounded (`reconnectMaxMs`): when this fires and the room is still not back, the page plays alone. */
2562
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
2563
+ /** When `ping()` last ran: one that runs seconds late (the page was blocked) says nothing about the socket. */
2564
+ let lastPingTick = 0;
2565
+ /** `connectClock: 'game'`: the wait for the welcome is not counted until the game says it has booted. */
2566
+ let clockRunning = opts.connectClock !== 'game';
1785
2567
  let lastStatsPost = 0;
2568
+ /** The last snapshot's state as text, and when the GAME last sent one (a heartbeat is not the game's). */
2569
+ let lastSnapData: string | null = null;
2570
+ let lastRealSnapAt = -Infinity;
2571
+ /** A snapshot's time stamp is always later than the one before it: a replica drops one that is not, and a real
2572
+ * snapshot sent in the same millisecond as a heartbeat must not be the one dropped. */
2573
+ let lastSnapSt = 0;
2574
+ const snapStamp = (): number => (lastSnapSt = Math.max(now(), lastSnapSt + 1));
2575
+
2576
+ /**
2577
+ * A host's heartbeat (section 22). The relay replaces a host that sent no snapshot for its stall time, which is
2578
+ * right for a frozen tab and wrong for a game whose frames hitched for a second (a level loading, shaders
2579
+ * compiling, a throttled window): the room changed hands again and again. So when the game has sent nothing for
2580
+ * `heartbeatMs`, this timer sends its last state again with a fresh time stamp, marked `hb`. Replicas hold the
2581
+ * picture, which is what the host is showing too. Only for HEARTBEAT_MAX_MS: after that the game is taken to be
2582
+ * frozen, the heartbeat stops, and the relay hands the room on as before.
2583
+ */
2584
+ function heartbeat(): void {
2585
+ if (role !== 'host' || offline || !connected || closed || peers.size < 2 || lastSnapData === null || hidden()) return;
2586
+ const t = wall();
2587
+ if (t - lastSnapSentAt < heartbeatMs || t - lastRealSnapAt > HEARTBEAT_MAX_MS) return;
2588
+ if (!ws || ws.readyState !== 1 || (ws.bufferedAmount ?? 0) > 3 * 1024) return;
2589
+ const c = ctlWire();
2590
+ const text = `{"t":"snap","k":${tick},"st":${snapStamp()},"d":${lastSnapData}${c.length ? `,"c":${JSON.stringify(c)}` : ''},"hb":1}`;
2591
+ try { ws.send(text); } catch { return; }
2592
+ countBytes(bytesOut, text.length);
2593
+ lastSnapSentAt = t;
2594
+ heartbeats += 1;
2595
+ }
1786
2596
 
1787
2597
  function hidden(): boolean { try { return Boolean(g.document?.hidden); } catch { return false; } }
1788
2598
  function ping(): void {
1789
- if (!connected) return;
1790
- // A socket that delivered nothing (not even a pong) for staleMs is half-open: drop it and reconnect.
1791
- if (ws && lastMsgAt && wall() - lastMsgAt > staleMs) { lost(ws, 'stale'); return; }
2599
+ // This runs every two seconds at least. A gap of more than three and a half means the page itself was held.
2600
+ const beat = wall();
2601
+ const pingHeld = beat - lastPingTick > 2000 + 1500;
2602
+ lastPingTick = beat;
2603
+ if (!connected) {
2604
+ // A socket that opened and was never welcomed (a room that is not answering) is as dead as a silent one: drop
2605
+ // it and knock again, so a page that gave up and plays alone still finds its room when the room is back.
2606
+ if (ws && ws.readyState === 1 && lastMsgAt && wall() - lastMsgAt > Math.max(staleMs, connectOpenMaxMs)) lost(ws, 'stale');
2607
+ return;
2608
+ }
2609
+ // A socket that delivered nothing (not even a pong) for staleMs is half-open: drop it and reconnect. But not on
2610
+ // the word of a timer that itself ran seconds late: the page was blocked (a heavy boot, shaders compiling) and
2611
+ // could not have heard anything, and what the relay sent meanwhile is still queued behind this very timer.
2612
+ // Dropping a healthy socket there turned every long hitch into a reconnect.
2613
+ if (ws && lastMsgAt && wall() - lastMsgAt > staleMs) {
2614
+ if (!pingHeld) { lost(ws, 'stale'); return; }
2615
+ // Two and a half seconds to be heard from (a pong answers the ping below): a socket that is really dead is
2616
+ // still dropped, two beats from now.
2617
+ lastMsgAt = wall() - staleMs + 2500;
2618
+ }
1792
2619
  const c = Date.now();
1793
2620
  pingsOut.set(c, c);
1794
2621
  if (pingsOut.size > 16) { const first = pingsOut.keys().next().value; if (first !== undefined) pingsOut.delete(first); }
@@ -1854,7 +2681,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1854
2681
  lastSnapBytes, maxSnapBytes, bytesInPerS: perSecond(bytesIn), bytesOutPerS: perSecond(bytesOut),
1855
2682
  interpDelay: Math.round(interpDelay()), snapAgeP90: Math.round(ageP90), starvedPct: +starvedPct.toFixed(3), rejectedSnaps,
1856
2683
  owned: api.owned, pending: sentHist.length, stateKeys: stateMap.size,
1857
- peers: peers.size, reconnects, promotions, round: roundInfo ? roundInfo.n : null,
2684
+ peers: peers.size, reconnects, drops, link, heartbeats, promotions, round: roundInfo ? roundInfo.n : null,
1858
2685
  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
2686
  };
1860
2687
  }
@@ -1876,7 +2703,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1876
2703
  offline = true;
1877
2704
  connected = false;
1878
2705
  seat = null;
1879
- if (!roleKnown || role !== 'host') setRole('host', why);
2706
+ if (full) { full = false; post?.({ what: 'full', full: false }); }
2707
+ aloneWhy = why;
2708
+ // 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
2709
+ // gone with the room, and the game seats itself again for a round of its own (createRoom keeps the round it had).
2710
+ if (!roleKnown || role !== 'host' || why === 'reconnect-timeout') setRole('host', why);
2711
+ // No shell at all is `offline` from the start; a room that never answered is `alone` (and still knocked at). A
2712
+ // page the relay refused for good stays `closed` (nothing is knocking): it plays by itself and its line says why.
2713
+ if (closed) paintLink();
2714
+ else if (why !== 'offline') setLink('alone', why);
1880
2715
  }
1881
2716
 
1882
2717
  function lost(sock: WebSocketLike, why: string): void {
@@ -1886,8 +2721,17 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1886
2721
  if (why !== 'closed') { try { sock.close(4000, why); } catch { /* gone */ } }
1887
2722
  const was = connected;
1888
2723
  connected = false;
1889
- if (was) downSince = wall();
2724
+ if (was) { downSince = wall(); drops += 1; }
1890
2725
  if (was) emit('status', false);
2726
+ // It was in its room and the socket went: it knocks again with its token. (Never welcomed yet: still `connecting`,
2727
+ // or `alone` once it gave up waiting; stopped for good: `closed`.)
2728
+ if (was && !closed) {
2729
+ setLink('reconnecting', why === 'stale' ? 'stale' : 'lost');
2730
+ // BOUNDED (section 22): a room that does not come back in reconnectMaxMs is not waited for with a frozen round
2731
+ // on screen. The page plays alone with its own bots, says so, and keeps knocking.
2732
+ if (reconnectTimer) clearTimeout(reconnectTimer);
2733
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; if (link === 'reconnecting' && !closed) goOfflineHost('reconnect-timeout'); }, reconnectMaxMs);
2734
+ }
1891
2735
  notifyStats(true);
1892
2736
  retry();
1893
2737
  }
@@ -1902,6 +2746,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1902
2746
  raw({
1903
2747
  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
2748
  ...(watching ? { watch: true } : {}), ...(caps.size ? { caps: [...caps] } : {}), ...(asAgent ? { agent: asAgent } : {}),
2749
+ // Revision 9 (an older relay ignores both): the game's revision and what this build can do.
2750
+ ...(version ? { ver: version } : {}), ...(features.length ? { feat: features } : {}),
1905
2751
  });
1906
2752
  };
1907
2753
  sock.onmessage = (ev) => onMessage(ev.data);
@@ -1931,18 +2777,24 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1931
2777
  // started a private fight 1 at every loaded boot and swapped it for the room's a moment later. A socket
1932
2778
  // that is connecting or open gets CONNECT_OPEN_MAX_MS of that listening time; one that failed outright
1933
2779
  // (ws is null between retries) gets connectTimeoutMs.
2780
+ // `connectOpenMaxMs` sizes that wait; with `connectClock: 'game'` it starts only at `net.start()`, once the game
2781
+ // has finished booting (or CONNECT_CLOCK_MAX_MS after the helper was made: a game that never says still gets a role).
1934
2782
  let listenedMs = 0;
1935
2783
  let lastCheckAt = wall();
2784
+ const bornAt = wall();
1936
2785
  const connectCheck = (): void => {
1937
2786
  if (roleKnown) return;
1938
2787
  const t = wall();
1939
- listenedMs += Math.min(t - lastCheckAt, 600);
2788
+ if (!clockRunning && t - bornAt >= CONNECT_CLOCK_MAX_MS) clockRunning = true;
2789
+ if (clockRunning) listenedMs += Math.min(t - lastCheckAt, 600);
1940
2790
  lastCheckAt = t;
1941
2791
  const live = Boolean(ws && (ws.readyState === 0 || ws.readyState === 1));
1942
- if (listenedMs < (live ? CONNECT_OPEN_MAX_MS : connectTimeoutMs)) { connectTimer = setTimeout(connectCheck, 250); return; }
2792
+ if (listenedMs < (live ? connectOpenMaxMs : connectTimeoutMs)) { connectTimer = setTimeout(connectCheck, 250); return; }
1943
2793
  goOfflineHost('relay-timeout');
1944
2794
  };
1945
2795
  connectTimer = setTimeout(connectCheck, 250);
2796
+ if (heartbeatMs) hbTimer = setInterval(heartbeat, 250);
2797
+ lastPingTick = wall();
1946
2798
  pingTimer = setInterval(ping, 2000);
1947
2799
  ckptTimer = setInterval(sendCheckpoint, checkpointMs);
1948
2800
  statsTimer = setInterval(() => notifyStats(true), 500);
@@ -2012,6 +2864,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2012
2864
  if (ev.source !== g.parent || !ev.data || typeof ev.data !== 'object') return;
2013
2865
  const d = ev.data as { t?: unknown; on?: unknown };
2014
2866
  if (d.t === 'homie-hush') hushed = d.on === true;
2867
+ // The page's answer to a net.prefs call, and where its own controls sit over the game (section 24).
2868
+ 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); } }
2869
+ if (d.t === 'homie-shell') readShell(d as Record<string, unknown>);
2015
2870
  // Room chat (section 19): the play page's "Show chat" and "Show my messages over my character".
2016
2871
  if (d.t === 'homie-chat') {
2017
2872
  const c = d as { show?: unknown; bubble?: unknown };
@@ -2084,6 +2939,37 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2084
2939
  get connected() { return connected; },
2085
2940
  get closedWhy() { return closedWhy; },
2086
2941
  get downMs() { return connected || !downSince ? 0 : wall() - downSince; },
2942
+ get link() { return link; },
2943
+ get reconnects() { return reconnects; },
2944
+ get resumed() { return resumed; },
2945
+ get full() { return full; },
2946
+ line(text: string | null): void {
2947
+ const next = typeof text === 'string' && text.trim() ? text.trim().slice(0, 120) : null;
2948
+ if (next === gameLine) return;
2949
+ gameLine = next;
2950
+ paintLink();
2951
+ },
2952
+ start(): void { clockRunning = true; },
2953
+ get version() { return version; },
2954
+ get stale() { return staleVer; },
2955
+ featuresOf(s: number | null): string[] {
2956
+ if (s === null || s === undefined) return [];
2957
+ if (s === seat && !offline) return [...features];
2958
+ for (const p of peers.values()) if (p.seat === s) return Array.isArray(p.feat) ? p.feat.filter((x) => typeof x === 'string') : [];
2959
+ return [];
2960
+ },
2961
+ allHave(feature: string): boolean {
2962
+ const k = String(feature ?? '').toLowerCase();
2963
+ if (!features.includes(k)) return false;
2964
+ // A lite agent (hands `host`) has no game client: its body is the host's own bot code.
2965
+ 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;
2966
+ return true;
2967
+ },
2968
+ get prefs() { return prefs; },
2969
+ get params() { return params; },
2970
+ param(n: string, fallback: string | null = null): string | null { return Object.prototype.hasOwnProperty.call(params, n) ? params[n] as string : fallback; },
2971
+ get shell() { return shellLayout; },
2972
+ get arrivalInfo() { return { ...arrived }; },
2087
2973
  get owned() { return role === 'host' || offline || seat === null ? true : mine.own; },
2088
2974
  get id() { return id; },
2089
2975
  get name() { return name; },
@@ -2096,7 +2982,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2096
2982
  get announcement() { return announcement && (announcement.until === undefined || announcement.until > now()) ? announcement : null; },
2097
2983
  isMuted(s: number | null): boolean { return s !== null && (muted.get(s) ?? 0) > now(); },
2098
2984
  pickPlayer(s: number | null): void { post?.({ what: 'pick', seat: typeof s === 'number' ? s : null }); },
2099
- playable(): void { sayPlayable('game'); },
2985
+ playable(): void { explicitPlayable(); },
2100
2986
  loading(fraction: number, what?: string): void {
2101
2987
  if (playableSent) return;
2102
2988
  const t = wall();
@@ -2181,9 +3067,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2181
3067
  if (role !== 'host' || offline || !connected) return false;
2182
3068
  if (!force && wall() - lastSnapSentAt < (peers.size > 1 ? snapshotMs : 1000) - 2) return false;
2183
3069
  tick = k ?? tick + 1;
2184
- const st = now();
3070
+ const st = snapStamp();
2185
3071
  const c = ctlWire();
2186
- const text = JSON.stringify(c.length ? { t: 'snap', k: tick, st, d, c } : { t: 'snap', k: tick, st, d });
3072
+ // The state as text once: the heartbeat sends the same text again while the game's frames hitch.
3073
+ const data = JSON.stringify(d ?? null);
3074
+ const text = `{"t":"snap","k":${JSON.stringify(tick)},"st":${st},"d":${data}${c.length ? `,"c":${JSON.stringify(c)}` : ''}}`;
3075
+ lastSnapData = data;
3076
+ lastRealSnapAt = wall();
2187
3077
  if (!ws || ws.readyState !== 1) return false;
2188
3078
  // A congested socket drops a snapshot rather than queueing a stale one. At 3 KB (about six
2189
3079
  // snapshots), not 256 KB. A starved network thread on a loaded phone held a second of snapshots and then
@@ -2330,9 +3220,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2330
3220
  if (ckptTimer) clearInterval(ckptTimer);
2331
3221
  if (statsTimer) clearInterval(statsTimer);
2332
3222
  if (watchTimer) clearInterval(watchTimer);
3223
+ if (hbTimer) clearInterval(hbTimer);
3224
+ if (reconnectTimer) clearTimeout(reconnectTimer);
2333
3225
  if (spotTimer) clearTimeout(spotTimer);
3226
+ if (overlayTimer) clearTimeout(overlayTimer);
3227
+ if (overlayHide) clearTimeout(overlayHide);
2334
3228
  clearTimeout(connectTimer);
2335
3229
  try { raw({ t: 'bye' }); ws?.close(1000, 'bye'); } catch { /* gone */ }
3230
+ if (!offline || link === 'alone') setLink('closed', 'close');
3231
+ if (overlay) overlay.hidden = true;
2336
3232
  },
2337
3233
  };
2338
3234
 
@@ -2353,6 +3249,17 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2353
3249
  get policy() { return policy; },
2354
3250
  get vote() { return voteState; },
2355
3251
  get revision() { return NETPLAY_REVISION; },
3252
+ /** Revision 9: the link, how often it was interrupted, the game's revision, and the arrival's facts (for a probe). */
3253
+ get link() { return link; },
3254
+ get full() { return full; },
3255
+ get line() { return overlay && !overlay.hidden ? { kind: overlay.getAttribute('data-homie-link'), text: overlay.textContent } : null; },
3256
+ get reconnects() { return reconnects; },
3257
+ get drops() { return drops; },
3258
+ get version() { return version; },
3259
+ get stale() { return staleVer; },
3260
+ get arrival() { return { ...arrived }; },
3261
+ get shell() { return shellLayout; },
3262
+ get params() { return params; },
2356
3263
  get agent() { return asAgent; },
2357
3264
  get following() { return following; },
2358
3265
  get state() { return Object.fromEntries(stateMap); },