@homie-rocks/studio 0.2.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.
@@ -0,0 +1,1309 @@
1
+ /*
2
+ * netplay.ts — Homie netplay contract v1, the game side.
3
+ * =============================================================================
4
+ *
5
+ * Vendor this ONE file into a game (copy it to src/net/netplay.ts). It has no
6
+ * imports and no dependencies, uses only erasable TypeScript (Node can import
7
+ * it with type stripping), and compiles under `strict` and under the games'
8
+ * `strict: false`. The wire protocol, the roles and the budgets it implements
9
+ * are specified in NETPLAY.md beside it; this file is the reference client.
10
+ *
11
+ * WHAT IT DOES
12
+ * - Finds its shell: `window.HOMIE_NET` (injected by the web shell before the
13
+ * game's first module runs). No HOMIE_NET — a plain file, a Vite dev
14
+ * server — means OFFLINE: role `host`, seat `null`, every send a cheap
15
+ * no-op. The game then seats its local player itself (keys or touch).
16
+ * - Speaks the v1 wire protocol over one WebSocket: hello/welcome, snapshots
17
+ * (host -> all), inputs (replica -> host, press edges never lost), events,
18
+ * keyed slow state, ~1 Hz checkpoints, round and roster, ping/pong clock.
19
+ * - Keeps a snapshot interpolation buffer in server time (`sample()`), with a
20
+ * delay measured from how old snapshots really are when they arrive.
21
+ * - Keeps the body-control table: per seat a reset epoch `rs`, who drives the
22
+ * body (`own`), and the last input the host had (`ack`). The host resets,
23
+ * takes and gives bodies; the replica hears it as a `control` event.
24
+ * - Tells the game about role changes at ANY time: first role, promotion to
25
+ * host (with the relay's last checkpoint + snapshot + state), demotion.
26
+ * - Stays alive: yields the host role when its tab is hidden, drops a socket
27
+ * that went quiet, reconnects with its seat token.
28
+ * - Reports role/seat/rtt/snapshot rate to the parent shell by postMessage
29
+ * (for the debug strip) and on `window.__homieNet` (for e2e probes).
30
+ *
31
+ * WHAT IT DOES NOT DO: rendering, physics, input devices, bots. `Roster` below
32
+ * is the bot-yield bookkeeping a host needs; the bots themselves are the game's.
33
+ * =============================================================================
34
+ */
35
+
36
+ export const NETPLAY_VERSION = 1;
37
+
38
+ export type Role = 'host' | 'replica' | 'screen';
39
+ export type Device = 'phone' | 'desk' | 'tv';
40
+ export type Want = 'play' | 'screen';
41
+ /** Who moves a seated body by default. `owner`: the seat's own browser (instant). `host`: the host's rules. */
42
+ export type Movement = 'owner' | 'host';
43
+
44
+ /** What the shell injects as `window.HOMIE_NET` before the game boots. */
45
+ export interface NetConfig {
46
+ v: number;
47
+ /** The relay socket: dev `ws://127.0.0.1:<port>/__net?room=main`, site `wss://<host>/api/table/<CODE>/ws?as=net&s=<nonce>`. */
48
+ url: string;
49
+ room?: string;
50
+ /** Seat capability from an earlier welcome (or a lobby reservation). Resumes the same seat. */
51
+ token?: string;
52
+ name?: string;
53
+ device?: Device;
54
+ want?: Want;
55
+ debug?: boolean;
56
+ }
57
+
58
+ export interface HostRef { id: string; seat: number | null }
59
+
60
+ export interface Peer {
61
+ id: string;
62
+ seat: number | null;
63
+ name: string;
64
+ colour: number;
65
+ device: Device;
66
+ want: Want;
67
+ role: Role;
68
+ }
69
+
70
+ /** One seat's entry in the body-control table: [seat, rs, own (1|0), ack]. */
71
+ export type ControlWire = [seat: number, rs: number, own: number, ack: number];
72
+ export interface Snapshot<D = unknown> { k: number; st: number; d: D; from?: number | null; c?: ControlWire[] }
73
+ export interface Checkpoint<D = unknown> { k: number; st: number; d: D; c?: ControlWire[] }
74
+ export interface InputFrame<A = unknown> {
75
+ /** Sequence number per sender. */
76
+ q: number;
77
+ /** Client-owned avatar state (owner movement), or intent (host movement). */
78
+ a: A;
79
+ /** Buttons held right now. */
80
+ h: string[];
81
+ /** Press edges since the previous frame, by button id (the relay clamps each to 0..8). */
82
+ p: Record<string, number>;
83
+ /** Sender seat, stamped by the relay (never trusted from the body). */
84
+ from: number;
85
+ /** The reset epoch the sender had adopted when it sent this frame. */
86
+ r: number | null;
87
+ }
88
+ export interface NetEvent<D = unknown> {
89
+ k: string;
90
+ d: D;
91
+ from: number | null;
92
+ /** The sender's peer id (events to the host only), so the host can answer a screen with `send(k, d, id)`. */
93
+ id?: string;
94
+ }
95
+
96
+ /** A seat's body control as the host keeps it. */
97
+ export interface Control {
98
+ seat: number;
99
+ /** Reset epoch: bumped whenever the host moved the body itself; the owner adopts the host's position. */
100
+ rs: number;
101
+ /** true: the seat's browser moves the body (owner movement). false: the host does (host movement, or taken). */
102
+ own: boolean;
103
+ /** Sequence number of the newest input frame the host had from this seat when it sent the snapshot. */
104
+ ack: number;
105
+ }
106
+ export interface ControlChange<S = unknown> extends Control {
107
+ /** rs changed: put your avatar where `snap` has your body, and drop any prediction. */
108
+ reset: boolean;
109
+ snap: Snapshot<S>;
110
+ }
111
+ export interface PendingInput<A = unknown> { q: number; a: A; h: string[]; at: number; dt: number }
112
+
113
+ export interface RoundResult { slot: number; seat: number | null; name: string; score: number; bot: boolean; place: number }
114
+ export interface RoundInfo {
115
+ n: number;
116
+ phase: 'live' | 'over';
117
+ /** Server ms. */
118
+ startedAt: number;
119
+ /** Server ms: end of the live phase, or end of the intermission when phase is 'over'. */
120
+ endsAt: number;
121
+ results?: RoundResult[];
122
+ }
123
+ export interface Slot { slot: number; seat: number | null; name: string; bot: boolean }
124
+
125
+ export interface RoleChange<S = unknown, C = unknown> {
126
+ role: Role;
127
+ prev: Role | null;
128
+ why: string;
129
+ /** Became host while a round was already running somewhere: restore from ckpt/snap/state. */
130
+ promoted: boolean;
131
+ /** Was host, is not any more: stop running rules. */
132
+ demoted: boolean;
133
+ ckpt: Checkpoint<C> | null;
134
+ snap: Snapshot<S> | null;
135
+ round: RoundInfo | null;
136
+ roster: Slot[] | null;
137
+ /** Every connected peer including this one. */
138
+ peers: Peer[];
139
+ }
140
+
141
+ export interface Sample<S = unknown> {
142
+ a: Snapshot<S>;
143
+ b: Snapshot<S>;
144
+ /** 0..1 between a and b. */
145
+ alpha: number;
146
+ /** The server time being rendered. */
147
+ renderT: number;
148
+ /** ms the render time is past the newest snapshot (0 when interpolating). */
149
+ starved: number;
150
+ }
151
+
152
+ export interface NetStats {
153
+ role: Role;
154
+ seat: number | null;
155
+ host: HostRef | null;
156
+ connected: boolean;
157
+ offline: boolean;
158
+ rtt: number | null;
159
+ offset: number;
160
+ snapHzIn: number;
161
+ snapHzOut: number;
162
+ inputHzIn: number;
163
+ inputHzOut: number;
164
+ lastSnapBytes: number;
165
+ maxSnapBytes: number;
166
+ bytesInPerS: number;
167
+ bytesOutPerS: number;
168
+ interpDelay: number;
169
+ /** 90th percentile of (arrival time - st) over the last 40 snapshots: transit plus clock error. */
170
+ snapAgeP90: number;
171
+ /** Share of sample() calls in the last 2 s window that ran past the newest snapshot. */
172
+ starvedPct: number;
173
+ rejectedSnaps: number;
174
+ owned: boolean;
175
+ pending: number;
176
+ stateKeys: number;
177
+ peers: number;
178
+ reconnects: number;
179
+ promotions: number;
180
+ round: number | null;
181
+ }
182
+
183
+ export interface Vec { x: number; y: number; z?: number }
184
+ /** What a game can expose for harnesses (an end-to-end test reads these through `window.__homieNet.probe`). */
185
+ export interface Probes {
186
+ self?: () => Vec | null;
187
+ peer?: (seat: number) => Vec | null;
188
+ frames?: () => number;
189
+ [extra: string]: unknown;
190
+ }
191
+
192
+ export interface NetplayOptions<C = unknown> {
193
+ /** Defaults to `window.HOMIE_NET`. `null` forces offline. */
194
+ config?: NetConfig | null;
195
+ want?: Want;
196
+ /** May this browser be elected host? Default true. */
197
+ canHost?: boolean;
198
+ /** Game id, for the relay's logs. */
199
+ game?: string;
200
+ /** Seats in a room (the manifest's players.max). The relay takes it from the first visitor of an empty room. */
201
+ maxPlayers?: number;
202
+ /** Who moves a seated body by default. Default `owner`. */
203
+ movement?: Movement;
204
+ snapshotHz?: number;
205
+ inputHz?: number;
206
+ checkpointMs?: number;
207
+ /** Fixed interpolation delay in ms. Default: adaptive (arrival age p90 + 1.2 intervals). */
208
+ interpDelayMs?: number;
209
+ /** Host: return the full rules state. Called every `checkpointMs`, before yielding, and on pagehide. */
210
+ checkpoint?: () => C;
211
+ /** ms to wait for a welcome before falling back to offline host. Default 4000. */
212
+ connectTimeoutMs?: number;
213
+ /** A socket that delivered nothing for this long is dropped and reopened (pongs come every 2 s). Default 6000. */
214
+ staleMs?: number;
215
+ /** Tests: a WebSocket constructor. */
216
+ WebSocketImpl?: WebSocketCtor;
217
+ /** Tests / custom shells: where parent notifications go. Default `parent.postMessage` when framed. */
218
+ post?: ((msg: Record<string, unknown>) => void) | null;
219
+ }
220
+
221
+ type WebSocketCtor = new (url: string) => WebSocketLike;
222
+ interface WebSocketLike {
223
+ readyState: number;
224
+ bufferedAmount?: number;
225
+ onopen: ((ev: unknown) => void) | null;
226
+ onmessage: ((ev: { data: unknown }) => void) | null;
227
+ onclose: ((ev: unknown) => void) | null;
228
+ onerror: ((ev: unknown) => void) | null;
229
+ send(data: string): void;
230
+ close(code?: number, reason?: string): void;
231
+ }
232
+
233
+ export interface NetHandlers<S, A, C> {
234
+ role: (e: RoleChange<S, C>) => void;
235
+ join: (p: Peer) => void;
236
+ leave: (p: { id: string; seat: number | null; why: string }) => void;
237
+ input: (f: InputFrame<A>) => void;
238
+ event: (e: NetEvent) => void;
239
+ snapshot: (s: Snapshot<S>) => void;
240
+ /** Seated, not host: my body's control changed (reset, taken, given back). */
241
+ control: (e: ControlChange<S>) => void;
242
+ /** A keyed state value changed (null: deleted). Fires for every key on welcome and promotion too. */
243
+ state: (e: { k: string; d: unknown }) => void;
244
+ round: (r: RoundInfo) => void;
245
+ roster: (slots: Slot[]) => void;
246
+ status: (connected: boolean) => void;
247
+ }
248
+
249
+ export interface Netplay<S = unknown, A = unknown, C = unknown> {
250
+ readonly role: Role;
251
+ readonly isHost: boolean;
252
+ /** This browser's player seat, or null (a screen, a full room, or offline). */
253
+ readonly seat: number | null;
254
+ readonly spectator: boolean;
255
+ readonly offline: boolean;
256
+ readonly connected: boolean;
257
+ /** Seated replica: true while my browser moves my body; false while the host does (host movement, or taken). */
258
+ readonly owned: boolean;
259
+ readonly id: string | null;
260
+ readonly name: string;
261
+ readonly colour: number;
262
+ readonly device: Device;
263
+ readonly host: HostRef | null;
264
+ readonly peers: ReadonlyMap<string, Peer>;
265
+ readonly roundInfo: RoundInfo | null;
266
+ readonly slots: Slot[] | null;
267
+ /** Resolves with the first role (welcome, or offline fallback). */
268
+ readonly ready: Promise<RoleChange<S, C>>;
269
+ on<K extends keyof NetHandlers<S, A, C>>(kind: K, fn: NetHandlers<S, A, C>[K]): () => void;
270
+ /** Server time in ms (relay clock). Offline: Date.now(). */
271
+ now(): number;
272
+ /** Host: true when the next `snapshot()` would be sent. Build the state only then. */
273
+ snapshotDue(): boolean;
274
+ /** Host: broadcast fast state (live bodies, projectiles); throttled to snapshotHz (1 Hz while alone). */
275
+ snapshot(d: S, tick?: number, force?: boolean): boolean;
276
+ /** Host: send a checkpoint now (normally automatic via `options.checkpoint`). */
277
+ checkpointNow(): void;
278
+ /** Host: announce a round phase (start / end with results). Relay keeps the latest for joiners and the shell. */
279
+ round(r: RoundInfo): void;
280
+ /** Host: announce slots (humans + bots). Relay keeps the latest for joiners and the shell. */
281
+ roster(slots: Slot[]): void;
282
+ /** Host: set slow keyed state (claims, doors, scores board); `null` deletes. Sent only when it changed. */
283
+ state(key: string, d: unknown): boolean;
284
+ /** Everyone: the latest value of a keyed state entry. */
285
+ stateOf<T = unknown>(key: string): T | undefined;
286
+ stateKeys(): string[];
287
+ /** Player (non-host): own avatar state (or intent) + held buttons, every frame. Batched; press edges sent at once. */
288
+ input(a: A, held?: Iterable<string> | Record<string, boolean>): void;
289
+ /** Player: an explicit press edge (for buttons you do not report as held). */
290
+ press(id: string): void;
291
+ /** Event. Host: to everyone, or to one seat (number) or one peer id (string, e.g. a screen). Others: to the host. */
292
+ send(kind: string, data?: unknown, to?: number | string): void;
293
+ /** Host: latest input frame from a seat (intents, buttons) whatever the body's control. */
294
+ inputOf(seat: number): InputFrame<A> | null;
295
+ /** Host: the owner's avatar state, or null when the body is not owner-driven or the frame predates the last reset. */
296
+ avatar(seat: number): A | null;
297
+ /** Host: press edges from a seat since the last call (cleared on read). */
298
+ takePresses(seat: number): Record<string, number>;
299
+ /** Host: a seat's control entry (`taken`: the host is driving it, whatever the movement mode). */
300
+ control(seat: number): Control & { taken: boolean };
301
+ /** Host: I moved this body myself (spawn, respawn, takeover, anti-cheat): the owner adopts my position. */
302
+ reset(seat: number): number;
303
+ /** Host: drive this body myself (knockback, carry, stun). With `ms`, it is given back by itself after that long. */
304
+ take(seat: number, ms?: number): void;
305
+ /** Host: hand a taken body back to the room's movement mode where it now is (bumps rs). */
306
+ give(seat: number): void;
307
+ /** Seated replica: my input frames the host had not acknowledged in the newest snapshot, oldest first. */
308
+ pending(): PendingInput<A>[];
309
+ /** Interpolation pair for rendering remote entities. */
310
+ sample(delayMs?: number): Sample<S> | null;
311
+ latest(): Snapshot<S> | null;
312
+ stats(): NetStats;
313
+ expose(probes: Probes): void;
314
+ close(): void;
315
+ }
316
+
317
+ /* ------------------------------------------------------------------ helpers */
318
+
319
+ export const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
320
+ export function lerpAngle(a: number, b: number, t: number): number {
321
+ let d = (b - a) % (Math.PI * 2);
322
+ if (d > Math.PI) d -= Math.PI * 2;
323
+ if (d < -Math.PI) d += Math.PI * 2;
324
+ return a + d * t;
325
+ }
326
+ /** Round to `places` decimals — the cheapest snapshot compression there is. */
327
+ export const q = (v: number, places = 2): number => {
328
+ const m = 10 ** places;
329
+ return Math.round(v * m) / m;
330
+ };
331
+ /**
332
+ * Host: bound a client-owned move. Returns the point at most `maxDist` from `from` toward `to`, and `over`: how far
333
+ * the claim was beyond the bound (Infinity for a non-number). Call every host frame with
334
+ * `maxDist = maxSpeed * dt * 1.3 + slack`; a legitimate avatar catches up within a frame or two, a teleport crawls.
335
+ */
336
+ export function capMove(from: Vec, to: Vec, maxDist: number): { x: number; y: number; z?: number; over: number } {
337
+ const hasZ = typeof from.z === 'number' && typeof to.z === 'number';
338
+ if (!Number.isFinite(to.x) || !Number.isFinite(to.y) || (hasZ && !Number.isFinite(to.z as number))) {
339
+ return { x: from.x, y: from.y, ...(hasZ ? { z: from.z as number } : {}), over: Infinity };
340
+ }
341
+ const dx = to.x - from.x; const dy = to.y - from.y; const dz = hasZ ? (to.z as number) - (from.z as number) : 0;
342
+ const d = Math.hypot(dx, dy, dz);
343
+ if (d <= maxDist) return { x: to.x, y: to.y, ...(hasZ ? { z: to.z as number } : {}), over: 0 };
344
+ const k = maxDist / d;
345
+ return { x: from.x + dx * k, y: from.y + dy * k, ...(hasZ ? { z: (from.z as number) + dz * k } : {}), over: d - maxDist };
346
+ }
347
+
348
+ /** Short edge <= 540 CSS px is a phone (the web shell's own rule). */
349
+ export function guessDevice(): Device {
350
+ try {
351
+ const asked = new URLSearchParams(location.search).get('hand');
352
+ if (asked === 'phone' || asked === 'desk') return asked;
353
+ return Math.min(innerWidth, innerHeight) <= 540 ? 'phone' : 'desk';
354
+ } catch { return 'desk'; }
355
+ }
356
+
357
+ /* ------------------------------------------------------------------- Roster */
358
+
359
+ export interface RosterOptions {
360
+ /** Slots always filled (humans + bots). */
361
+ min: number;
362
+ /** Hard cap on slots. */
363
+ max: number;
364
+ botName?: (slot: number) => string;
365
+ }
366
+
367
+ /**
368
+ * Host-side bookkeeping for bots that yield their slot to an arriving human.
369
+ *
370
+ * A round always has at least `min` bodies. A human who arrives takes back the
371
+ * slot they held last if it is still a bot (a reload lands in the same body),
372
+ * else the lowest bot slot, inheriting that body where it stands; only when no
373
+ * bot is left is a new slot added (up to `max`). A human who leaves turns back
374
+ * into a bot in the same slot, so the round never loses a body mid-play.
375
+ * `trim()` drops surplus bots between rounds.
376
+ */
377
+ export class Roster {
378
+ slots: Slot[] = [];
379
+ readonly min: number;
380
+ readonly max: number;
381
+ readonly botName: (slot: number) => string;
382
+ /** seat → the slot it held when it last left */
383
+ private readonly lastSlot = new Map<number, number>();
384
+
385
+ constructor(opts: RosterOptions) {
386
+ this.min = Math.max(0, opts.min | 0);
387
+ this.max = Math.max(this.min, opts.max | 0);
388
+ this.botName = opts.botName ?? ((slot: number) => `Bot ${slot + 1}`);
389
+ this.fill();
390
+ }
391
+
392
+ static from(slots: readonly Slot[], opts: RosterOptions): Roster {
393
+ const r = new Roster(opts);
394
+ r.slots = slots.map((s) => ({ slot: s.slot, seat: s.seat, name: s.name, bot: s.bot }));
395
+ r.fill();
396
+ return r;
397
+ }
398
+
399
+ fill(): void {
400
+ while (this.slots.length < this.min) {
401
+ const slot = this.nextSlotId();
402
+ this.slots.push({ slot, seat: null, name: this.botName(slot), bot: true });
403
+ }
404
+ this.slots.sort((a, b) => a.slot - b.slot);
405
+ }
406
+
407
+ private nextSlotId(): number {
408
+ let id = 0;
409
+ const used = new Set(this.slots.map((s) => s.slot));
410
+ while (used.has(id)) id += 1;
411
+ return id;
412
+ }
413
+
414
+ bySeat(seat: number): Slot | undefined { return this.slots.find((s) => s.seat === seat && !s.bot); }
415
+ humans(): Slot[] { return this.slots.filter((s) => !s.bot); }
416
+ bots(): Slot[] { return this.slots.filter((s) => s.bot); }
417
+
418
+ /** A human takes a slot. Returns the slot, whether it yielded a bot, or null (full: spectate). */
419
+ claim(seat: number, name: string): { slot: Slot; yielded: boolean; added: boolean } | null {
420
+ const mine = this.bySeat(seat);
421
+ if (mine) { mine.name = name || mine.name; return { slot: mine, yielded: false, added: false }; }
422
+ const last = this.lastSlot.get(seat);
423
+ const bot = (last !== undefined ? this.slots.find((s) => s.slot === last && s.bot) : undefined) ?? this.slots.find((s) => s.bot);
424
+ if (bot) {
425
+ bot.bot = false; bot.seat = seat; bot.name = name || `Player ${seat + 1}`;
426
+ return { slot: bot, yielded: true, added: false };
427
+ }
428
+ if (this.slots.length >= this.max) return null;
429
+ const slot = this.nextSlotId();
430
+ const s: Slot = { slot, seat, name: name || `Player ${seat + 1}`, bot: false };
431
+ this.slots.push(s);
432
+ this.slots.sort((a, b) => a.slot - b.slot);
433
+ return { slot: s, yielded: false, added: true };
434
+ }
435
+
436
+ /** A human leaves: their slot becomes a bot where it stands. */
437
+ release(seat: number): Slot | null {
438
+ const s = this.bySeat(seat);
439
+ if (!s) return null;
440
+ this.lastSlot.set(seat, s.slot);
441
+ s.bot = true; s.seat = null; s.name = this.botName(s.slot);
442
+ return s;
443
+ }
444
+
445
+ /** Between rounds: remove bots beyond `min` slots. Returns the removed slot ids. */
446
+ trim(): number[] {
447
+ const removed: number[] = [];
448
+ for (let i = this.slots.length - 1; i >= 0 && this.slots.length > this.min; i -= 1) {
449
+ const s = this.slots[i];
450
+ if (s && s.bot) { removed.push(s.slot); this.slots.splice(i, 1); }
451
+ }
452
+ return removed;
453
+ }
454
+
455
+ /** After a promotion: make the roster agree with who is actually connected. */
456
+ reconcile(peers: Iterable<{ seat: number | null; name: string }>): { claimed: Slot[]; released: Slot[] } {
457
+ const here = new Map<number, string>();
458
+ for (const p of peers) if (p.seat !== null && p.seat !== undefined) here.set(p.seat, p.name);
459
+ const released: Slot[] = [];
460
+ const claimed: Slot[] = [];
461
+ for (const s of this.humans()) {
462
+ if (s.seat !== null && !here.has(s.seat)) { const r = this.release(s.seat); if (r) released.push(r); }
463
+ }
464
+ for (const [seat, name] of here) {
465
+ if (this.bySeat(seat)) continue;
466
+ const c = this.claim(seat, name);
467
+ if (c) claimed.push(c.slot);
468
+ }
469
+ this.fill();
470
+ return { claimed, released };
471
+ }
472
+
473
+ toJSON(): Slot[] { return this.slots.map((s) => ({ ...s })); }
474
+ }
475
+
476
+ /* --------------------------------------------------------------- the client */
477
+
478
+ const LADDER = [250, 500, 1000, 2000, 4000];
479
+ /** Relay errors after which reconnecting would only repeat the refusal. */
480
+ const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many', 'flood']);
481
+
482
+ function heldList(held: Iterable<string> | Record<string, boolean> | undefined): string[] {
483
+ if (!held) return [];
484
+ if (typeof (held as Iterable<string>)[Symbol.iterator] === 'function') return [...(held as Iterable<string>)];
485
+ const out: string[] = [];
486
+ for (const [k, v] of Object.entries(held as Record<string, boolean>)) if (v) out.push(k);
487
+ return out;
488
+ }
489
+
490
+ /** A rolling per-second counter. */
491
+ function rate() {
492
+ const stamps: number[] = [];
493
+ return {
494
+ hit(now: number) { stamps.push(now); while (stamps.length && (stamps[0] as number) < now - 1000) stamps.shift(); },
495
+ hz(now: number) { while (stamps.length && (stamps[0] as number) < now - 1000) stamps.shift(); return stamps.length; },
496
+ };
497
+ }
498
+
499
+ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: NetplayOptions<C> = {}): Netplay<S, A, C> {
500
+ const g = globalThis as unknown as {
501
+ HOMIE_NET?: NetConfig; WebSocket?: WebSocketCtor; document?: Document; addEventListener?: typeof addEventListener;
502
+ parent?: Window; __homieNet?: unknown;
503
+ };
504
+ const cfg: NetConfig | null = opts.config === undefined ? (g.HOMIE_NET ?? null) : opts.config;
505
+ const WS: WebSocketCtor | undefined = opts.WebSocketImpl ?? g.WebSocket;
506
+ const snapshotMs = 1000 / Math.max(1, Math.min(30, opts.snapshotHz ?? 20));
507
+ const inputMs = 1000 / Math.max(1, Math.min(30, opts.inputHz ?? 20));
508
+ const checkpointMs = Math.max(250, opts.checkpointMs ?? 1000);
509
+ const connectTimeoutMs = opts.connectTimeoutMs ?? 4000;
510
+ const staleMs = Math.max(2500, opts.staleMs ?? 6000);
511
+ const device: Device = cfg?.device ?? guessDevice();
512
+ const want: Want = opts.want ?? cfg?.want ?? 'play';
513
+ const canHost = opts.canHost ?? true;
514
+ const defaultOwn = (opts.movement ?? 'owner') === 'owner';
515
+
516
+ // Where parent notifications go: the shell page around a framed game.
517
+ const post: ((m: Record<string, unknown>) => void) | null = opts.post !== undefined ? opts.post
518
+ : (() => {
519
+ try {
520
+ if (g.parent && g.parent !== (globalThis as unknown)) {
521
+ const p = g.parent;
522
+ return (m: Record<string, unknown>) => { try { p.postMessage({ t: 'homie-net', ...m }, '*'); } catch { /* gone */ } };
523
+ }
524
+ } catch { /* cross-origin parent access is fine for postMessage; anything else: no parent */ }
525
+ return null;
526
+ })();
527
+
528
+ const handlers: { [K in keyof NetHandlers<S, A, C>]: Set<NetHandlers<S, A, C>[K]> } = {
529
+ role: new Set(), join: new Set(), leave: new Set(), input: new Set(), event: new Set(),
530
+ snapshot: new Set(), control: new Set(), state: new Set(), round: new Set(), roster: new Set(), status: new Set(),
531
+ };
532
+ const emit = <K extends keyof NetHandlers<S, A, C>>(kind: K, arg: Parameters<NetHandlers<S, A, C>[K]>[0]): void => {
533
+ for (const fn of [...handlers[kind]]) {
534
+ try { (fn as (x: unknown) => void)(arg); } catch (err) { console.warn(`[netplay] ${String(kind)} handler`, err); }
535
+ }
536
+ };
537
+
538
+ // ---------------------------------------------------------------- state
539
+ let role: Role = 'replica';
540
+ let roleKnown = false;
541
+ let seat: number | null = null;
542
+ let id: string | null = null;
543
+ let token: string | undefined = cfg?.token;
544
+ let name = cfg?.name ?? '';
545
+ let colour = 0;
546
+ let host: HostRef | null = null;
547
+ let offline = !cfg || !cfg.url || !WS;
548
+ let connected = false;
549
+ let closed = false;
550
+ let ws: WebSocketLike | null = null;
551
+ let attempt = 0;
552
+ let reconnects = 0;
553
+ let promotions = 0;
554
+ let lastMsgAt = 0;
555
+ let waitingVisible = false;
556
+ const peers = new Map<string, Peer>();
557
+ let roundInfo: RoundInfo | null = null;
558
+ let slots: Slot[] | null = null;
559
+ let lastCkpt: Checkpoint<C> | null = null;
560
+
561
+ // keyed slow state
562
+ const stateMap = new Map<string, unknown>();
563
+ const stateSent = new Map<string, string>();
564
+
565
+ // body control: the host's table, and this replica's own entry
566
+ const ctl = new Map<number, { rs: number; own: boolean; taken: boolean; ack: number; giveAt: number }>();
567
+ let mine = { rs: 0, own: defaultOwn, ack: 0 };
568
+
569
+ // clock
570
+ let offset = 0;
571
+ let rtt: number | null = null;
572
+ const samples: { rtt: number; off: number }[] = [];
573
+ const pingsOut = new Map<number, number>();
574
+
575
+ // snapshots
576
+ const buf: Snapshot<S>[] = [];
577
+ let lastSnapSentAt = -Infinity;
578
+ let tick = 0;
579
+ let iv = snapshotMs;
580
+ let lastArrival = 0;
581
+ let lastArrivalSt = 0;
582
+ const ages: number[] = [];
583
+ let ageP90 = 0;
584
+ let delaySmooth = 0;
585
+ let rebase = false;
586
+ let rejectedSnaps = 0;
587
+ let sampleFrames = 0;
588
+ let starvedFrames = 0;
589
+ let starvedPct = 0;
590
+ let starvedWindowAt = 0;
591
+ let lastSnapBytes = 0;
592
+ let maxSnapBytes = 0;
593
+ let lastSnapInfo: { k: number; st: number; from: number | null; at: number } | null = null;
594
+ const snapIn = rate();
595
+ const snapOut = rate();
596
+ const inputIn = rate();
597
+ const inputOut = rate();
598
+ const bytesIn: { at: number; n: number }[] = [];
599
+ const bytesOut: { at: number; n: number }[] = [];
600
+
601
+ // inputs
602
+ let inSeq = 0;
603
+ let lastInputSentAt = -Infinity;
604
+ let pendingA: A | undefined;
605
+ let pendingHeld: string[] = [];
606
+ let prevHeld = new Set<string>();
607
+ let pendingPresses: Record<string, number> = {};
608
+ let inputTimer: ReturnType<typeof setTimeout> | null = null;
609
+ const sentHist: { q: number; a: A; h: string[]; at: number; r: number }[] = [];
610
+ const inputs = new Map<number, InputFrame<A>>();
611
+ const presses = new Map<number, Record<string, number>>();
612
+
613
+ let probes: Probes = {};
614
+
615
+ const wall = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
616
+ const now = (): number => (offline ? Date.now() : Date.now() + offset);
617
+
618
+ let resolveReady: (e: RoleChange<S, C>) => void = () => {};
619
+ const ready = new Promise<RoleChange<S, C>>((res) => { resolveReady = res; });
620
+
621
+ const countBytes = (list: { at: number; n: number }[], n: number): void => {
622
+ const t = wall();
623
+ list.push({ at: t, n });
624
+ while (list.length && (list[0] as { at: number }).at < t - 1000) list.shift();
625
+ };
626
+ const perSecond = (list: { at: number; n: number }[]): number => {
627
+ const t = wall();
628
+ while (list.length && (list[0] as { at: number }).at < t - 1000) list.shift();
629
+ return list.reduce((s, x) => s + x.n, 0);
630
+ };
631
+
632
+ function raw(msg: Record<string, unknown>): boolean {
633
+ if (!ws || ws.readyState !== 1) return false;
634
+ const text = JSON.stringify(msg);
635
+ try { ws.send(text); } catch { return false; }
636
+ countBytes(bytesOut, text.length);
637
+ return true;
638
+ }
639
+
640
+ /* ------------------------------------------------------------ body control (host) */
641
+ function ctlOf(s: number): { rs: number; own: boolean; taken: boolean; ack: number; giveAt: number } {
642
+ let e = ctl.get(s);
643
+ if (!e) { e = { rs: 1, own: defaultOwn, taken: false, ack: 0, giveAt: 0 }; ctl.set(s, e); }
644
+ return e;
645
+ }
646
+ /** Give back: the body returns to the room's movement mode, and rs bumps so the owner restarts from here. */
647
+ function giveBack(e: { rs: number; own: boolean; taken: boolean; giveAt: number }): void { e.taken = false; e.own = defaultOwn; e.giveAt = 0; e.rs += 1; }
648
+ function sweepCtl(): void {
649
+ const t = wall();
650
+ for (const e of ctl.values()) if (e.taken && e.giveAt && t >= e.giveAt) giveBack(e);
651
+ }
652
+ function ctlWire(): ControlWire[] {
653
+ sweepCtl();
654
+ const here = new Set<number>();
655
+ for (const p of peers.values()) if (p.seat !== null) here.add(p.seat);
656
+ const out: ControlWire[] = [];
657
+ for (const [s, e] of ctl) if (here.has(s)) out.push([s, e.rs, e.own ? 1 : 0, e.ack]);
658
+ return out;
659
+ }
660
+ /** A new host rebuilds the table from the checkpoint, then the newer snapshot. A body taken mid-knockback is given back. */
661
+ function adoptCtl(ckpt: Checkpoint<C> | null, snap: Snapshot<S> | null): void {
662
+ ctl.clear();
663
+ for (const src of [ckpt?.c, snap?.c]) {
664
+ if (!Array.isArray(src)) continue;
665
+ for (const [s, rs, own] of src) {
666
+ // A body the old host had taken (mid-knockback) goes back to the room's mode, with a reset.
667
+ const wasTaken = (own === 1) !== defaultOwn;
668
+ ctl.set(s, { rs: wasTaken ? rs + 1 : rs, own: defaultOwn, taken: false, ack: 0, giveAt: 0 });
669
+ }
670
+ }
671
+ }
672
+
673
+ /* ------------------------------------------------------------ keyed state */
674
+ function applyState(k: string, d: unknown): void {
675
+ if (d === null || d === undefined) { if (!stateMap.has(k)) return; stateMap.delete(k); emit('state', { k, d: null }); return; }
676
+ stateMap.set(k, d);
677
+ emit('state', { k, d });
678
+ }
679
+ function replaceState(next: unknown): void {
680
+ if (!next || typeof next !== 'object') return;
681
+ const obj = next as Record<string, unknown>;
682
+ for (const k of [...stateMap.keys()]) if (!(k in obj)) applyState(k, null);
683
+ for (const [k, d] of Object.entries(obj)) if (JSON.stringify(stateMap.get(k)) !== JSON.stringify(d)) applyState(k, d);
684
+ }
685
+ function primeStateSent(): void {
686
+ stateSent.clear();
687
+ for (const [k, d] of stateMap) stateSent.set(k, JSON.stringify(d));
688
+ }
689
+
690
+ function setRole(next: Role, why: string, extra: Partial<RoleChange<S, C>> = {}): void {
691
+ const prev = roleKnown ? role : null;
692
+ const was = role;
693
+ role = next;
694
+ roleKnown = true;
695
+ // Promoted = became host of a round that is already running somewhere: the relay handed over its
696
+ // last checkpoint and/or snapshot, and the game must restore from them instead of starting fresh.
697
+ const promoted = next === 'host' && (prev === null || was !== 'host') && Boolean(extra.ckpt || extra.snap);
698
+ const demoted = prev === 'host' && next !== 'host';
699
+ if (promoted) promotions += 1;
700
+ const e: RoleChange<S, C> = {
701
+ role: next, prev, why, promoted, demoted,
702
+ ckpt: extra.ckpt ?? null, snap: extra.snap ?? null, round: extra.round ?? roundInfo, roster: extra.roster ?? slots,
703
+ peers: [...peers.values()],
704
+ };
705
+ if (next === 'host') {
706
+ inputs.clear(); presses.clear();
707
+ if (was !== 'host' || prev === null) { adoptCtl(extra.ckpt ?? null, extra.snap ?? null); primeStateSent(); }
708
+ } else {
709
+ mine = { rs: 0, own: defaultOwn, ack: 0 };
710
+ sentHist.length = 0;
711
+ }
712
+ post?.({ what: 'role', role: next, prev, why, seat });
713
+ resolveReady(e);
714
+ emit('role', e);
715
+ notifyStats(true);
716
+ }
717
+
718
+ /** The newest snapshot this browser holds, if recent: a promoted host's restore when the relay has none. */
719
+ function localSnap(): Snapshot<S> | null {
720
+ const s = buf[buf.length - 1];
721
+ return s && now() - s.st < 5000 ? s : null;
722
+ }
723
+
724
+ // ---------------------------------------------------------------- wire in
725
+ function onMessage(data: unknown): void {
726
+ lastMsgAt = wall();
727
+ const text = typeof data === 'string' ? data : String(data);
728
+ countBytes(bytesIn, text.length);
729
+ let m: Record<string, unknown>;
730
+ try { m = JSON.parse(text) as Record<string, unknown>; } catch { return; }
731
+ if (!m || typeof m['t'] !== 'string') return;
732
+ switch (m['t']) {
733
+ case 'welcome': return onWelcome(m);
734
+ case 'snap': return onSnap(m, text.length);
735
+ case 'in': return onInput(m);
736
+ case 'ev': emit('event', { k: String(m['k'] ?? ''), d: m['d'], from: typeof m['from'] === 'number' ? m['from'] : null, ...(typeof m['id'] === 'string' ? { id: m['id'] } : {}) }); return;
737
+ case 'state': if (typeof m['k'] === 'string') applyState(m['k'], m['d']); return;
738
+ case 'pong': return onPong(m);
739
+ case 'join': {
740
+ const p = m['peer'] as Peer | undefined;
741
+ if (p && typeof p.id === 'string') { peers.set(p.id, p); if (role === 'host' && p.seat !== null) ctlOf(p.seat); emit('join', p); }
742
+ return;
743
+ }
744
+ case 'leave': {
745
+ const pid = String(m['id'] ?? '');
746
+ const p = peers.get(pid);
747
+ peers.delete(pid);
748
+ const s = typeof m['seat'] === 'number' ? m['seat'] : (p?.seat ?? null);
749
+ if (s !== null) { inputs.delete(s); presses.delete(s); }
750
+ emit('leave', { id: pid, seat: s, why: String(m['why'] ?? 'closed') });
751
+ return;
752
+ }
753
+ case 'round': {
754
+ roundInfo = m['round'] as RoundInfo;
755
+ post?.({ what: 'round', round: roundInfo });
756
+ emit('round', roundInfo);
757
+ return;
758
+ }
759
+ case 'roster': {
760
+ slots = (m['slots'] as Slot[]) ?? null;
761
+ post?.({ what: 'roster', slots });
762
+ if (slots) emit('roster', slots);
763
+ return;
764
+ }
765
+ case 'host': {
766
+ host = (m['host'] as HostRef | null) ?? null;
767
+ rebase = true; // the next snapshot is on the new host's timeline
768
+ for (const p of peers.values()) p.role = host && p.id === host.id ? 'host' : (p.seat === null ? 'screen' : 'replica');
769
+ notifyStats(true);
770
+ return;
771
+ }
772
+ case 'seat': {
773
+ // A seat freed up and this spectator, who wanted to play, has it now.
774
+ seat = typeof m['seat'] === 'number' ? m['seat'] : null;
775
+ token = typeof m['token'] === 'string' ? m['token'] : token;
776
+ name = typeof m['name'] === 'string' ? m['name'] : name;
777
+ colour = typeof m['colour'] === 'number' ? m['colour'] : colour;
778
+ if (id) { const p = peers.get(id); if (p) { p.seat = seat; p.name = name; p.colour = colour; } }
779
+ post?.({ what: 'token', token, seat, room: cfg?.room ?? null });
780
+ setRole(m['role'] === 'host' ? 'host' : 'replica', 'seated');
781
+ return;
782
+ }
783
+ case 'role': {
784
+ const next = m['role'] as Role;
785
+ host = (m['host'] as HostRef | null) ?? host;
786
+ rebase = true;
787
+ if (Array.isArray(m['peers'])) { peers.clear(); for (const p of m['peers'] as Peer[]) peers.set(p.id, p); }
788
+ if (m['round']) roundInfo = m['round'] as RoundInfo;
789
+ if (m['roster']) slots = m['roster'] as Slot[];
790
+ if (m['state']) replaceState(m['state']);
791
+ const ckpt = (m['ckpt'] as Checkpoint<C> | null) ?? null;
792
+ let snap = (m['snap'] as Snapshot<S> | null) ?? null;
793
+ if (next === 'host' && !snap) snap = localSnap();
794
+ if (ckpt) lastCkpt = ckpt;
795
+ setRole(next, String(m['why'] ?? 'relay'), { ckpt, snap, round: roundInfo, roster: slots });
796
+ if (next === 'host') { lastSnapSentAt = -Infinity; if (snap) tick = Math.max(tick, snap.k); }
797
+ return;
798
+ }
799
+ case 'error': {
800
+ const code = String(m['code'] ?? '');
801
+ if (code === 'rate' || code === 'state-full') { warnOnce(`${code}:${String(m['of'] ?? '')}`, m['message']); return; }
802
+ console.warn('[netplay] relay refused:', code, m['message']);
803
+ // The same seat opened in another tab, a full room, a version the relay does not speak: reconnecting
804
+ // would repeat the refusal (or, for 'replaced', make the two tabs evict each other for ever).
805
+ if (FINAL_ERRORS.has(code)) {
806
+ closed = true;
807
+ post?.({ what: 'closed', why: code });
808
+ }
809
+ return;
810
+ }
811
+ default: return;
812
+ }
813
+ }
814
+ const warned = new Map<string, number>();
815
+ function warnOnce(key: string, msg: unknown): void {
816
+ const t = wall();
817
+ if (t - (warned.get(key) ?? -Infinity) < 10_000) return;
818
+ warned.set(key, t);
819
+ console.warn('[netplay]', msg);
820
+ }
821
+
822
+ function onWelcome(m: Record<string, unknown>): void {
823
+ clearTimeout(connectTimer);
824
+ const next = m['role'] as Role;
825
+ const wasOffline = offline;
826
+ // Reconnected (a network blip, or a relay restart) and still the host: my rules state is the live one.
827
+ const continuing = roleKnown && role === 'host' && next === 'host' && !wasOffline;
828
+ id = String(m['id']);
829
+ seat = typeof m['seat'] === 'number' ? m['seat'] : null;
830
+ token = typeof m['token'] === 'string' ? m['token'] : token;
831
+ name = typeof m['name'] === 'string' ? m['name'] : name;
832
+ colour = typeof m['colour'] === 'number' ? m['colour'] : colour;
833
+ host = (m['host'] as HostRef | null) ?? null;
834
+ peers.clear();
835
+ if (Array.isArray(m['peers'])) for (const p of m['peers'] as Peer[]) peers.set(p.id, p);
836
+ if (typeof m['st'] === 'number' && rtt === null) offset = (m['st'] as number) - Date.now();
837
+ const ckpt = (m['ckpt'] as Checkpoint<C> | null) ?? null;
838
+ let snap = (m['snap'] as Snapshot<S> | null) ?? null;
839
+ if (!continuing) {
840
+ roundInfo = (m['round'] as RoundInfo | null) ?? roundInfo;
841
+ slots = (m['roster'] as Slot[] | null) ?? slots;
842
+ replaceState(m['state']);
843
+ }
844
+ rebase = true;
845
+ if (snap && next !== 'host') acceptSnap(snap, JSON.stringify(snap).length, false);
846
+ if (next === 'host' && !snap && !continuing) snap = localSnap();
847
+ offline = false;
848
+ connected = true;
849
+ attempt = 0;
850
+ emit('status', true);
851
+ post?.({ what: 'token', token, seat, room: cfg?.room ?? null });
852
+ // First welcome, a reconnect, or coming back from an offline fallback: only a CHANGE is a role event,
853
+ // except the very first, which always is (the game waits on it).
854
+ if (!roleKnown || next !== role || wasOffline) {
855
+ setRole(next, wasOffline && roleKnown ? 'reconnected' : String(m['why'] ?? 'welcome'), { ckpt, snap, round: roundInfo, roster: slots });
856
+ if (next === 'host' && snap) tick = Math.max(tick, snap.k);
857
+ }
858
+ if (continuing) reannounce();
859
+ ping(); setTimeout(ping, 120); setTimeout(ping, 260);
860
+ }
861
+
862
+ /** A host that reconnected (the relay may have restarted): hand the relay the round, roster, state and a checkpoint. */
863
+ function reannounce(): void {
864
+ if (roundInfo) raw({ t: 'round', round: roundInfo });
865
+ if (slots) raw({ t: 'roster', slots });
866
+ for (const [k, d] of stateMap) raw({ t: 'state', k, d });
867
+ primeStateSent();
868
+ sendCheckpoint();
869
+ }
870
+
871
+ /** Buffer a snapshot. `live`: it came off the wire just now (its age is a transit sample). */
872
+ function acceptSnap(s: Snapshot<S>, bytes: number, live: boolean): boolean {
873
+ const t = now();
874
+ // A snapshot stamped far in the future would hold the buffer for good (the relay clamps st too).
875
+ if (!Number.isFinite(s.st) || s.st > t + 1000) { rejectedSnaps += 1; return false; }
876
+ if (rebase) {
877
+ // New host, new timeline: its clock may sit a little behind the old one's. Keep older frames to hold on.
878
+ while (buf.length && (buf[buf.length - 1] as Snapshot<S>).st >= s.st) buf.pop();
879
+ rebase = false;
880
+ lastArrival = 0;
881
+ }
882
+ const newest = buf[buf.length - 1];
883
+ if (newest && s.st <= newest.st) return false;
884
+ const arrival = wall();
885
+ if (live) {
886
+ const gap = lastArrival ? arrival - lastArrival : Infinity;
887
+ if (lastArrival) {
888
+ const stGap = s.st - lastArrivalSt;
889
+ if (stGap > 0 && stGap < 1000) iv = iv * 0.9 + stGap * 0.1;
890
+ }
891
+ lastArrival = arrival;
892
+ lastArrivalSt = s.st;
893
+ // A snapshot handled in the same burst as the one before it waited behind this page's own stall (a GC, a
894
+ // long frame), not on the network: its age says nothing about transit, and the page was not drawing anyway.
895
+ if (gap >= iv * 0.25) {
896
+ ages.push(t - s.st);
897
+ if (ages.length > 40) ages.shift();
898
+ const sorted = [...ages].sort((x, y) => x - y);
899
+ ageP90 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.9))] as number;
900
+ // Render far enough back that the next snapshot is normally here before we need it: its age, plus one
901
+ // interval. Rise up to 6 ms a snapshot (a hold is visible); fall 6% of the excess a snapshot, at least 3 ms
902
+ // (boot-time jank clears in about a second, as a brief time-warp nobody sees).
903
+ const target = Math.max(50, Math.min(400, ageP90 + iv * 1.2 + 4));
904
+ if (!delaySmooth || ages.length <= 3) delaySmooth = target;
905
+ else if (target > delaySmooth) delaySmooth += Math.min(6, target - delaySmooth);
906
+ else delaySmooth -= Math.min(delaySmooth - target, Math.max(3, (delaySmooth - target) * 0.06));
907
+ }
908
+ }
909
+ lastSnapBytes = bytes;
910
+ maxSnapBytes = Math.max(maxSnapBytes, bytes);
911
+ buf.push(s);
912
+ lastSnapInfo = { k: s.k, st: s.st, from: s.from ?? null, at: Date.now() };
913
+ const cutoff = s.st - 1500;
914
+ while (buf.length > 2 && (buf[0] as Snapshot<S>).st < cutoff) buf.shift();
915
+ if (buf.length > 64) buf.splice(0, buf.length - 64);
916
+ if (live) snapIn.hit(arrival);
917
+ controlFrom(s);
918
+ return true;
919
+ }
920
+
921
+ /** Seated replica: read my entry of the control table in the newest snapshot. */
922
+ function controlFrom(s: Snapshot<S>): void {
923
+ if (seat === null || role === 'host' || !Array.isArray(s.c)) return;
924
+ const e = s.c.find((x) => x[0] === seat);
925
+ if (!e) return;
926
+ const [, rs, own, ack] = e;
927
+ const reset = rs !== mine.rs;
928
+ const ownB = own === 1;
929
+ const changed = reset || ownB !== mine.own;
930
+ mine = { rs, own: ownB, ack };
931
+ if (reset) sentHist.length = 0;
932
+ else while (sentHist.length && (sentHist[0] as { q: number }).q <= ack) sentHist.shift();
933
+ if (changed) emit('control', { seat, rs, own: ownB, ack, reset, snap: s });
934
+ }
935
+
936
+ function onSnap(m: Record<string, unknown>, bytes: number): void {
937
+ if (role === 'host') return; // a stale frame from a previous host
938
+ 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 };
939
+ if (Array.isArray(m['c'])) s.c = m['c'] as ControlWire[];
940
+ if (acceptSnap(s, bytes, true)) emit('snapshot', s);
941
+ }
942
+
943
+ function onInput(m: Record<string, unknown>): void {
944
+ if (role !== 'host') return;
945
+ const from = m['from'];
946
+ if (typeof from !== 'number') return;
947
+ const p: Record<string, number> = {};
948
+ if (m['p'] && typeof m['p'] === 'object') {
949
+ for (const [k, v] of Object.entries(m['p'] as Record<string, unknown>)) p[k] = Math.max(0, Math.min(8, Math.floor(Number(v) || 0)));
950
+ }
951
+ const f: InputFrame<A> = {
952
+ q: Number(m['q']) || 0, a: m['a'] as A,
953
+ h: Array.isArray(m['h']) ? (m['h'] as unknown[]).map(String) : [],
954
+ p, from, r: typeof m['r'] === 'number' ? m['r'] : null,
955
+ };
956
+ const prev = inputs.get(from);
957
+ if (prev && f.q <= prev.q && f.q > 0) return; // out of order
958
+ inputs.set(from, f);
959
+ ctlOf(from).ack = f.q;
960
+ const acc = presses.get(from) ?? {};
961
+ for (const [k, v] of Object.entries(f.p)) acc[k] = Math.min(64, (acc[k] ?? 0) + v);
962
+ presses.set(from, acc);
963
+ inputIn.hit(wall());
964
+ emit('input', f);
965
+ }
966
+
967
+ function onPong(m: Record<string, unknown>): void {
968
+ const c = Number(m['c']);
969
+ const st = Number(m['st']);
970
+ const sent = pingsOut.get(c);
971
+ if (sent === undefined || !Number.isFinite(st)) return;
972
+ pingsOut.delete(c);
973
+ const recv = Date.now();
974
+ const r = recv - sent;
975
+ const off = st + r / 2 - recv;
976
+ samples.push({ rtt: r, off });
977
+ if (samples.length > 8) samples.shift();
978
+ const best = samples.reduce((a, b) => (b.rtt < a.rtt ? b : a));
979
+ rtt = r;
980
+ // Slew small corrections, jump big ones: a clock that jumps backwards makes interpolation stutter.
981
+ const d = best.off - offset;
982
+ offset = Math.abs(d) > 100 || samples.length <= 3 ? best.off : offset + Math.max(-5, Math.min(5, d));
983
+ notifyStats(false);
984
+ }
985
+
986
+ // ---------------------------------------------------------------- wire out
987
+ let pingTimer: ReturnType<typeof setInterval> | null = null;
988
+ let ckptTimer: ReturnType<typeof setInterval> | null = null;
989
+ let statsTimer: ReturnType<typeof setInterval> | null = null;
990
+ let connectTimer: ReturnType<typeof setTimeout> | undefined;
991
+ let lastStatsPost = 0;
992
+
993
+ function hidden(): boolean { try { return Boolean(g.document?.hidden); } catch { return false; } }
994
+ function ping(): void {
995
+ if (!connected) return;
996
+ // A socket that delivered nothing (not even a pong) for staleMs is half-open: drop it and reconnect.
997
+ if (ws && lastMsgAt && wall() - lastMsgAt > staleMs) { lost(ws, 'stale'); return; }
998
+ const c = Date.now();
999
+ pingsOut.set(c, c);
1000
+ if (pingsOut.size > 16) { const first = pingsOut.keys().next().value; if (first !== undefined) pingsOut.delete(first); }
1001
+ raw({ t: 'ping', c, hid: hidden() });
1002
+ }
1003
+
1004
+ function sendCheckpoint(): void {
1005
+ if (role !== 'host' || !opts.checkpoint || offline || !connected) return;
1006
+ let d: C;
1007
+ try { d = opts.checkpoint(); } catch (err) { console.warn('[netplay] checkpoint()', err); return; }
1008
+ const c: Checkpoint<C> = { k: tick, st: now(), d, c: ctlWire() };
1009
+ lastCkpt = c;
1010
+ raw({ t: 'ckpt', k: c.k, st: c.st, d: c.d, c: c.c });
1011
+ }
1012
+
1013
+ function flushInput(): void {
1014
+ inputTimer = null;
1015
+ if (role === 'host' || seat === null || offline || !connected || pendingA === undefined) return;
1016
+ inSeq += 1;
1017
+ const msg: Record<string, unknown> = { t: 'in', q: inSeq, a: pendingA, h: pendingHeld, r: mine.rs };
1018
+ if (Object.keys(pendingPresses).length) msg['p'] = pendingPresses;
1019
+ if (raw(msg)) {
1020
+ inputOut.hit(wall()); pendingPresses = {};
1021
+ sentHist.push({ q: inSeq, a: pendingA, h: pendingHeld, at: wall(), r: mine.rs });
1022
+ if (sentHist.length > 64) sentHist.shift();
1023
+ }
1024
+ lastInputSentAt = wall();
1025
+ }
1026
+
1027
+ function scheduleInput(urgent: boolean): void {
1028
+ const since = wall() - lastInputSentAt;
1029
+ const wait = urgent ? Math.max(0, 16 - since) : Math.max(0, inputMs - since);
1030
+ if (wait <= 0) { if (inputTimer) { clearTimeout(inputTimer); inputTimer = null; } flushInput(); return; }
1031
+ if (inputTimer) return;
1032
+ inputTimer = setTimeout(flushInput, wait);
1033
+ }
1034
+
1035
+ function statsNow(): NetStats {
1036
+ const t = wall();
1037
+ return {
1038
+ role, seat, host, connected, offline, rtt, offset: Math.round(offset),
1039
+ snapHzIn: snapIn.hz(t), snapHzOut: snapOut.hz(t), inputHzIn: inputIn.hz(t), inputHzOut: inputOut.hz(t),
1040
+ lastSnapBytes, maxSnapBytes, bytesInPerS: perSecond(bytesIn), bytesOutPerS: perSecond(bytesOut),
1041
+ interpDelay: Math.round(interpDelay()), snapAgeP90: Math.round(ageP90), starvedPct: +starvedPct.toFixed(3), rejectedSnaps,
1042
+ owned: api.owned, pending: sentHist.length, stateKeys: stateMap.size,
1043
+ peers: peers.size, reconnects, promotions, round: roundInfo ? roundInfo.n : null,
1044
+ };
1045
+ }
1046
+ function notifyStats(force: boolean): void {
1047
+ if (!post) return;
1048
+ const t = wall();
1049
+ if (!force && t - lastStatsPost < 450) return;
1050
+ lastStatsPost = t;
1051
+ post({ what: 'stats', stats: statsNow(), name, colour });
1052
+ }
1053
+
1054
+ function interpDelay(): number {
1055
+ if (opts.interpDelayMs !== undefined) return opts.interpDelayMs;
1056
+ return delaySmooth || Math.max(50, iv * 1.6 + 8);
1057
+ }
1058
+
1059
+ // ---------------------------------------------------------------- connect
1060
+ function goOfflineHost(why: string): void {
1061
+ offline = true;
1062
+ connected = false;
1063
+ seat = null;
1064
+ if (!roleKnown || role !== 'host') setRole('host', why);
1065
+ }
1066
+
1067
+ function lost(sock: WebSocketLike, why: string): void {
1068
+ if (ws !== sock) return;
1069
+ ws = null;
1070
+ sock.onclose = null; sock.onmessage = null; sock.onerror = null; sock.onopen = null;
1071
+ if (why !== 'closed') { try { sock.close(4000, why); } catch { /* gone */ } }
1072
+ const was = connected;
1073
+ connected = false;
1074
+ if (was) emit('status', false);
1075
+ notifyStats(true);
1076
+ retry();
1077
+ }
1078
+
1079
+ function open(): void {
1080
+ if (closed || !cfg || !WS || ws) return;
1081
+ let sock: WebSocketLike;
1082
+ try { sock = new WS(cfg.url); } catch { retry(); return; }
1083
+ ws = sock;
1084
+ sock.onopen = () => {
1085
+ lastMsgAt = wall();
1086
+ raw({ t: 'hello', v: NETPLAY_VERSION, token, name: name || undefined, device, want, canHost, game: opts.game, max: opts.maxPlayers });
1087
+ };
1088
+ sock.onmessage = (ev) => onMessage(ev.data);
1089
+ sock.onclose = () => lost(sock, 'closed');
1090
+ // Browsers follow an error with close; Node's WebSocket, on a refused connection, never does.
1091
+ sock.onerror = () => lost(sock, 'error');
1092
+ }
1093
+ function retry(): void {
1094
+ if (closed) return;
1095
+ // A hidden tab does not play and its timers crawl: reconnect when it is looked at again.
1096
+ if (hidden()) { waitingVisible = true; return; }
1097
+ const wait = LADDER[Math.min(attempt, LADDER.length - 1)] as number;
1098
+ attempt += 1;
1099
+ reconnects += 1;
1100
+ setTimeout(open, wait);
1101
+ }
1102
+
1103
+ if (offline) {
1104
+ // No shell (a plain file, a dev server): the game is its own host, offline.
1105
+ queueMicrotask(() => goOfflineHost('offline'));
1106
+ } else {
1107
+ post?.({ what: 'attached', v: NETPLAY_VERSION });
1108
+ open();
1109
+ // A relay that never answers must not leave a game on a black screen.
1110
+ connectTimer = setTimeout(() => { if (!roleKnown) goOfflineHost('relay-timeout'); }, connectTimeoutMs);
1111
+ pingTimer = setInterval(ping, 2000);
1112
+ ckptTimer = setInterval(sendCheckpoint, checkpointMs);
1113
+ statsTimer = setInterval(() => notifyStats(true), 500);
1114
+ try {
1115
+ g.document?.addEventListener('visibilitychange', () => {
1116
+ if (!hidden() && waitingVisible) { waitingVisible = false; attempt = 0; open(); return; }
1117
+ if (!connected) return;
1118
+ if (hidden()) {
1119
+ // A hidden phone tab loses its timers and rAF: hand the round on before it stalls.
1120
+ if (role === 'host' && peers.size > 1) { sendCheckpoint(); raw({ t: 'yield' }); }
1121
+ ping();
1122
+ } else ping();
1123
+ });
1124
+ g.addEventListener?.('pagehide', () => { if (role === 'host') sendCheckpoint(); raw({ t: 'bye' }); });
1125
+ } catch { /* not a browser */ }
1126
+ }
1127
+
1128
+ // ---------------------------------------------------------------- API
1129
+ const api: Netplay<S, A, C> = {
1130
+ get role() { return role; },
1131
+ get isHost() { return role === 'host'; },
1132
+ get seat() { return seat; },
1133
+ get spectator() { return seat === null && !offline; },
1134
+ get offline() { return offline; },
1135
+ get connected() { return connected; },
1136
+ get owned() { return role === 'host' || offline || seat === null ? true : mine.own; },
1137
+ get id() { return id; },
1138
+ get name() { return name; },
1139
+ get colour() { return colour; },
1140
+ get device() { return device; },
1141
+ get host() { return host; },
1142
+ get peers() { return peers; },
1143
+ get roundInfo() { return roundInfo; },
1144
+ get slots() { return slots; },
1145
+ ready,
1146
+ on(kind, fn) {
1147
+ (handlers[kind] as Set<unknown>).add(fn);
1148
+ return () => { (handlers[kind] as Set<unknown>).delete(fn); };
1149
+ },
1150
+ now,
1151
+ snapshotDue() {
1152
+ if (role !== 'host' || !connected || offline) return false;
1153
+ // Alone: 1 Hz, so a joiner's welcome carries a recent world. With company: snapshotHz.
1154
+ return wall() - lastSnapSentAt >= (peers.size > 1 ? snapshotMs : 1000) - 2;
1155
+ },
1156
+ snapshot(d, k, force = false) {
1157
+ if (role !== 'host' || offline || !connected) return false;
1158
+ if (!force && wall() - lastSnapSentAt < (peers.size > 1 ? snapshotMs : 1000) - 2) return false;
1159
+ tick = k ?? tick + 1;
1160
+ const st = now();
1161
+ const c = ctlWire();
1162
+ const text = JSON.stringify(c.length ? { t: 'snap', k: tick, st, d, c } : { t: 'snap', k: tick, st, d });
1163
+ if (!ws || ws.readyState !== 1) return false;
1164
+ // A congested socket drops a snapshot rather than queueing a stale one.
1165
+ if ((ws.bufferedAmount ?? 0) > 256 * 1024) return false;
1166
+ try { ws.send(text); } catch { return false; }
1167
+ countBytes(bytesOut, text.length);
1168
+ lastSnapSentAt = wall();
1169
+ lastSnapBytes = text.length;
1170
+ maxSnapBytes = Math.max(maxSnapBytes, text.length);
1171
+ if (text.length > 8192) warnOnce('snap-size', `snapshot is ${text.length} B; the budget is 8 KB (move slow state to net.state())`);
1172
+ snapOut.hit(wall());
1173
+ return true;
1174
+ },
1175
+ checkpointNow: sendCheckpoint,
1176
+ round(r) {
1177
+ roundInfo = r;
1178
+ post?.({ what: 'round', round: r });
1179
+ if (role === 'host') raw({ t: 'round', round: r });
1180
+ emit('round', r);
1181
+ },
1182
+ roster(s) {
1183
+ slots = s.map((x) => ({ slot: x.slot, seat: x.seat, name: x.name, bot: x.bot }));
1184
+ post?.({ what: 'roster', slots });
1185
+ if (role === 'host') raw({ t: 'roster', slots });
1186
+ },
1187
+ state(key, d) {
1188
+ if (role !== 'host' && !offline) return false;
1189
+ const text = JSON.stringify(d ?? null);
1190
+ if (stateSent.get(key) === text) return false;
1191
+ stateSent.set(key, text);
1192
+ if (d === null || d === undefined) stateMap.delete(key); else stateMap.set(key, d);
1193
+ if (text.length > 6000) warnOnce(`state-size:${key}`, `state '${key}' is ${text.length} B; split it (8 KB cap per key)`);
1194
+ if (!offline) raw({ t: 'state', k: key, d: d ?? null });
1195
+ return true;
1196
+ },
1197
+ stateOf<T = unknown>(key: string) { return stateMap.get(key) as T | undefined; },
1198
+ stateKeys() { return [...stateMap.keys()]; },
1199
+ input(a, held) {
1200
+ pendingA = a;
1201
+ const list = heldList(held);
1202
+ let edge = false;
1203
+ for (const h of list) if (!prevHeld.has(h)) { pendingPresses[h] = (pendingPresses[h] ?? 0) + 1; edge = true; }
1204
+ prevHeld = new Set(list);
1205
+ pendingHeld = list;
1206
+ if (role === 'host' || seat === null || offline || !connected) { pendingPresses = {}; return; }
1207
+ scheduleInput(edge);
1208
+ },
1209
+ press(idp) {
1210
+ if (role === 'host' || seat === null || offline || !connected) return;
1211
+ pendingPresses[idp] = (pendingPresses[idp] ?? 0) + 1;
1212
+ scheduleInput(true);
1213
+ },
1214
+ send(kind, data, to) {
1215
+ if (offline) return;
1216
+ const m: Record<string, unknown> = { t: 'ev', k: kind, d: data ?? null };
1217
+ if (role === 'host' && (typeof to === 'number' || typeof to === 'string')) m['to'] = to;
1218
+ raw(m);
1219
+ },
1220
+ inputOf(s) { return inputs.get(s) ?? null; },
1221
+ avatar(s) {
1222
+ const f = inputs.get(s);
1223
+ if (!f) return null;
1224
+ sweepCtl();
1225
+ const e = ctlOf(s);
1226
+ return e.own && f.r === e.rs ? f.a : null;
1227
+ },
1228
+ takePresses(s) { const p = presses.get(s) ?? {}; presses.delete(s); return p; },
1229
+ control(s) { sweepCtl(); const e = ctlOf(s); return { seat: s, rs: e.rs, own: e.own, taken: e.taken, ack: e.ack }; },
1230
+ reset(s) { const e = ctlOf(s); e.rs += 1; return e.rs; },
1231
+ take(s, ms) { const e = ctlOf(s); e.own = false; e.taken = true; e.giveAt = ms && ms > 0 ? wall() + ms : 0; },
1232
+ give(s) { const e = ctlOf(s); if (e.taken) giveBack(e); },
1233
+ pending() {
1234
+ const out: PendingInput<A>[] = [];
1235
+ for (let i = 0; i < sentHist.length; i += 1) {
1236
+ const f = sentHist[i] as { q: number; a: A; h: string[]; at: number; r: number };
1237
+ if (f.r !== mine.rs || f.q <= mine.ack) continue;
1238
+ const next = sentHist[i + 1];
1239
+ out.push({ q: f.q, a: f.a, h: f.h, at: f.at, dt: Math.max(0, (next ? next.at : wall()) - f.at) });
1240
+ }
1241
+ return out;
1242
+ },
1243
+ sample(delayMs) {
1244
+ if (!buf.length) return null;
1245
+ const renderT = now() - (delayMs ?? interpDelay());
1246
+ const newest = buf[buf.length - 1] as Snapshot<S>;
1247
+ const oldest = buf[0] as Snapshot<S>;
1248
+ const t = wall();
1249
+ if (!starvedWindowAt) starvedWindowAt = t;
1250
+ if (t - starvedWindowAt >= 2000) { starvedPct = sampleFrames ? starvedFrames / sampleFrames : 0; sampleFrames = 0; starvedFrames = 0; starvedWindowAt = t; }
1251
+ sampleFrames += 1;
1252
+ if (renderT >= newest.st) {
1253
+ const over = renderT - newest.st;
1254
+ if (over > 0) {
1255
+ starvedFrames += 1;
1256
+ // An underrun: grow the buffer by exactly the overrun (up to 30 ms a frame). The picture was holding on
1257
+ // the newest snapshot anyway, so nothing jumps back, and the next snapshots continue instead of catching up.
1258
+ if (delayMs === undefined && opts.interpDelayMs === undefined && delaySmooth) delaySmooth = Math.min(400, delaySmooth + Math.min(30, over));
1259
+ }
1260
+ return { a: newest, b: newest, alpha: 1, renderT, starved: over };
1261
+ }
1262
+ if (renderT <= oldest.st) return { a: oldest, b: oldest, alpha: 0, renderT, starved: 0 };
1263
+ for (let i = buf.length - 1; i > 0; i -= 1) {
1264
+ const a = buf[i - 1] as Snapshot<S>;
1265
+ const b = buf[i] as Snapshot<S>;
1266
+ if (a.st <= renderT && renderT <= b.st) {
1267
+ const span = b.st - a.st;
1268
+ return { a, b, alpha: span > 0 ? (renderT - a.st) / span : 1, renderT, starved: 0 };
1269
+ }
1270
+ }
1271
+ return { a: newest, b: newest, alpha: 1, renderT, starved: 0 };
1272
+ },
1273
+ latest() { return buf.length ? buf[buf.length - 1] as Snapshot<S> : null; },
1274
+ stats: statsNow,
1275
+ expose(p) { probes = { ...probes, ...p }; },
1276
+ close() {
1277
+ closed = true;
1278
+ if (pingTimer) clearInterval(pingTimer);
1279
+ if (ckptTimer) clearInterval(ckptTimer);
1280
+ if (statsTimer) clearInterval(statsTimer);
1281
+ clearTimeout(connectTimer);
1282
+ try { raw({ t: 'bye' }); ws?.close(1000, 'bye'); } catch { /* gone */ }
1283
+ },
1284
+ };
1285
+
1286
+ // Harness / console surface. Counters and probes, never controls.
1287
+ try {
1288
+ g.__homieNet = {
1289
+ get role() { return role; },
1290
+ get seat() { return seat; },
1291
+ get offline() { return offline; },
1292
+ get connected() { return connected; },
1293
+ get owned() { return api.owned; },
1294
+ get host() { return host; },
1295
+ get peers() { return [...peers.values()]; },
1296
+ get round() { return roundInfo; },
1297
+ get roster() { return slots; },
1298
+ get state() { return Object.fromEntries(stateMap); },
1299
+ get lastCheckpoint() { return lastCkpt ? { k: lastCkpt.k, st: lastCkpt.st } : null; },
1300
+ /** The newest snapshot this browser received: tick, server time, sender seat, local Date.now() of arrival. */
1301
+ get lastSnapshot() { return lastSnapInfo; },
1302
+ stats: statsNow,
1303
+ now,
1304
+ get probe() { return probes; },
1305
+ };
1306
+ } catch { /* frozen global */ }
1307
+
1308
+ return api;
1309
+ }