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