@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.
- package/CHANGELOG.md +108 -1
- package/README.md +349 -23
- package/assets/animate.ts +16 -6
- package/assets/assets.ts +14 -3
- package/assets/render-page.ts +10 -2
- package/assets/safety.mjs +7 -1
- package/bin/homie-studio.mjs +204 -124
- package/lib/art-cli.mjs +61 -18
- package/lib/art-tools.mjs +53 -10
- package/lib/asset-check.mjs +249 -46
- package/lib/asset-import.mjs +11 -5
- package/lib/asset-manifest.mjs +128 -51
- package/lib/build.mjs +239 -72
- package/lib/characters.mjs +119 -20
- package/lib/check.mjs +110 -7
- package/lib/chrome.mjs +11 -0
- package/lib/clips.mjs +54 -0
- package/lib/cloudflare.mjs +203 -10
- package/lib/codex.mjs +6 -0
- package/lib/collision.mjs +245 -0
- package/lib/decisions.mjs +2 -2
- package/lib/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +172 -9
- package/lib/doctor.mjs +12 -11
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +42 -37
- package/lib/mcp.mjs +1 -1
- package/lib/net.mjs +81 -2
- package/lib/node-version.mjs +40 -0
- package/lib/office.mjs +6 -6
- package/lib/optimise.mjs +39 -4
- package/lib/parts-build.mjs +169 -0
- package/lib/parts-cli.mjs +104 -0
- package/lib/parts-store.mjs +342 -0
- package/lib/parts-tools.mjs +92 -0
- package/lib/parts.mjs +720 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +240 -0
- package/lib/scaffold.mjs +83 -22
- package/lib/shoot.mjs +307 -0
- package/lib/site.mjs +1 -2
- package/lib/stage.mjs +118 -0
- package/lib/studio.mjs +0 -66
- package/lib/style-board.mjs +49 -6
- package/lib/template-history.json +101 -0
- package/lib/trailer.mjs +47 -0
- package/lib/typecheck.mjs +107 -0
- package/mcp/ui/lineup.js +3 -3
- package/mcp/ui/rights.js +1 -3
- package/netplay/NETPLAY.md +552 -16
- package/netplay/netplay.ts +950 -43
- package/package.json +10 -1
- package/parts/PARTS.md +169 -0
- package/port/index.ts +8 -2
- package/port/personas.ts +275 -0
- package/port/powerups.ts +357 -0
- package/port/probe.ts +38 -2
- package/port/room.ts +329 -40
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +7 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +41 -12
- package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
- package/starters/gem-rush-3d/assets/manifest.json +0 -24
- package/starters/gem-rush-3d/index.html +1 -1
- package/starters/gem-rush-3d/src/main.ts +50 -11
- package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
- package/starters/hero-rush-3d/assets/manifest.json +0 -19
- package/starters/hero-rush-3d/index.html +1 -1
- package/starters/hero-rush-3d/src/main.ts +50 -11
- package/worker/arrival.mjs +4 -1
- package/worker/discover.mjs +11 -25
- package/worker/index.mjs +142 -62
- package/worker/license.mjs +45 -41
- package/worker/office-page.mjs +0 -4
- package/worker/office-schema.mjs +6 -0
- package/worker/office.mjs +19 -23
- package/worker/pages.mjs +156 -29
- package/worker/parts.mjs +275 -0
- package/worker/room.mjs +157 -14
- package/worker/schema.mjs +6 -8
- package/worker/seats.mjs +71 -0
- package/worker/site.mjs +51 -53
- package/worker/version.mjs +1 -1
- package/lib/remix-assets.mjs +0 -84
package/netplay/netplay.ts
CHANGED
|
@@ -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 =
|
|
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:
|
|
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
|
-
|
|
789
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
862
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
|
|
942
|
-
|
|
943
|
-
if (
|
|
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
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
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 (
|
|
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
|
-
|
|
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 ?
|
|
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 {
|
|
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 =
|
|
3070
|
+
const st = snapStamp();
|
|
2185
3071
|
const c = ctlWire();
|
|
2186
|
-
|
|
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); },
|