@noya-app/noya-multiplayer-react 0.1.89 → 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,1180 @@
1
+ import { afterEach, describe, expect, it, jest } from "bun:test";
2
+ import { ConnectionEvent, WebSocketConnection } from "../WebSocketConnection";
3
+
4
+ const OriginalWebSocket = globalThis.WebSocket;
5
+ const OriginalRequestAnimationFrame = globalThis.requestAnimationFrame;
6
+ const OriginalCancelAnimationFrame = globalThis.cancelAnimationFrame;
7
+
8
+ class MockWebSocket {
9
+ static CONNECTING = 0;
10
+ static OPEN = 1;
11
+ static CLOSING = 2;
12
+ static CLOSED = 3;
13
+ static instances: MockWebSocket[] = [];
14
+
15
+ readyState = MockWebSocket.CONNECTING;
16
+ sent: string[] = [];
17
+ closeCount = 0;
18
+ onopen: ((event: Event) => void) | null = null;
19
+ onmessage: ((event: MessageEvent) => void) | null = null;
20
+ onclose: ((event: CloseEvent) => void) | null = null;
21
+ onerror: ((event: Event) => void) | null = null;
22
+
23
+ constructor(readonly url = "") {
24
+ MockWebSocket.instances.push(this);
25
+ }
26
+
27
+ open() {
28
+ this.readyState = MockWebSocket.OPEN;
29
+ this.onopen?.(new Event("open"));
30
+ }
31
+
32
+ receive(message: object) {
33
+ this.onmessage?.(
34
+ new MessageEvent("message", { data: JSON.stringify(message) })
35
+ );
36
+ }
37
+
38
+ send(data: string) {
39
+ this.sent.push(data);
40
+ }
41
+
42
+ close() {
43
+ if (this.readyState === MockWebSocket.CLOSED) return;
44
+
45
+ this.closeCount += 1;
46
+ this.readyState = MockWebSocket.CLOSED;
47
+ this.onclose?.(new CloseEvent("close"));
48
+ }
49
+ }
50
+
51
+ function installAnimationFrameController() {
52
+ let nextHandle = 1;
53
+ const callbacks = new Map<number, FrameRequestCallback>();
54
+ globalThis.requestAnimationFrame = (callback) => {
55
+ const handle = nextHandle++;
56
+ callbacks.set(handle, callback);
57
+ return handle;
58
+ };
59
+ globalThis.cancelAnimationFrame = (handle) => {
60
+ callbacks.delete(handle);
61
+ };
62
+
63
+ return {
64
+ flush() {
65
+ const pending = Array.from(callbacks.values());
66
+ callbacks.clear();
67
+ for (const callback of pending) callback(Date.now());
68
+ },
69
+ };
70
+ }
71
+
72
+ afterEach(() => {
73
+ jest.useRealTimers();
74
+ globalThis.WebSocket = OriginalWebSocket;
75
+ globalThis.requestAnimationFrame = OriginalRequestAnimationFrame;
76
+ globalThis.cancelAnimationFrame = OriginalCancelAnimationFrame;
77
+ MockWebSocket.instances = [];
78
+ });
79
+
80
+ describe("WebSocketConnection heartbeat recovery", () => {
81
+ it("sends typed pings and treats pongs as receive progress", () => {
82
+ jest.useFakeTimers({ now: 0 });
83
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
84
+ const events: ConnectionEvent<{ value: number }>[] = [];
85
+ const connection = new WebSocketConnection<{ value: number }>(
86
+ new URL("wss://example.com"),
87
+ {
88
+ heartbeatIntervalMs: 100,
89
+ silentTimeoutMs: 250,
90
+ onConnectionEvent: (event) => events.push(event),
91
+ }
92
+ );
93
+
94
+ connection.connect();
95
+ const socket = MockWebSocket.instances[0];
96
+ socket.open();
97
+ jest.advanceTimersByTime(100);
98
+
99
+ const ping = JSON.parse(socket.sent[0]);
100
+ expect(ping).toMatchObject({ type: "ping" });
101
+ expect(ping.id).toStartWith("heartbeat-");
102
+ expect(ping.clientSentAt).toBe(100);
103
+
104
+ const pong = {
105
+ type: "pong",
106
+ id: ping.id,
107
+ clientSentAt: ping.clientSentAt,
108
+ serverReceivedAt: 100,
109
+ serverSentAt: 100,
110
+ };
111
+ socket.receive(pong);
112
+ jest.advanceTimersByTime(249);
113
+
114
+ expect(socket.closeCount).toBe(0);
115
+ expect(events).toContainEqual({
116
+ type: "receive",
117
+ message: pong,
118
+ });
119
+
120
+ connection.close();
121
+ });
122
+
123
+ it("forces a reconnect after an open socket stays silent", () => {
124
+ jest.useFakeTimers({ now: 0 });
125
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
126
+ const connection = new WebSocketConnection<{ value: number }>(
127
+ new URL("wss://example.com"),
128
+ {
129
+ heartbeatIntervalMs: 100,
130
+ silentTimeoutMs: 250,
131
+ }
132
+ );
133
+
134
+ connection.connect();
135
+ const first = MockWebSocket.instances[0];
136
+ first.open();
137
+ jest.advanceTimersByTime(250);
138
+
139
+ expect(first.closeCount).toBe(1);
140
+
141
+ jest.advanceTimersByTime(1_000);
142
+ expect(MockWebSocket.instances).toHaveLength(2);
143
+
144
+ const second = MockWebSocket.instances[1];
145
+ second.open();
146
+ jest.advanceTimersByTime(100);
147
+ expect(JSON.parse(second.sent[0])).toMatchObject({ type: "ping" });
148
+
149
+ connection.close();
150
+ });
151
+
152
+ it("cleans up heartbeat and watchdog timers on shutdown", () => {
153
+ jest.useFakeTimers({ now: 0 });
154
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
155
+ const connection = new WebSocketConnection<{ value: number }>(
156
+ new URL("wss://example.com"),
157
+ {
158
+ heartbeatIntervalMs: 100,
159
+ silentTimeoutMs: 250,
160
+ }
161
+ );
162
+
163
+ connection.connect();
164
+ const socket = MockWebSocket.instances[0];
165
+ socket.open();
166
+ connection.close();
167
+ jest.advanceTimersByTime(10_000);
168
+
169
+ expect(socket.sent).toEqual([]);
170
+ expect(socket.closeCount).toBe(1);
171
+ expect(MockWebSocket.instances).toHaveLength(1);
172
+ });
173
+
174
+ it("stays quiet without a negotiated clock config, including after frames", () => {
175
+ jest.useFakeTimers({ now: 0 });
176
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
177
+ const animationFrames = installAnimationFrameController();
178
+ const connection = new WebSocketConnection<{ value: number }>(
179
+ new URL("wss://example.com"),
180
+ {
181
+ heartbeatIntervalMs: 0,
182
+ silentTimeoutMs: 0,
183
+ }
184
+ );
185
+ connection.connect();
186
+ const socket = MockWebSocket.instances[0];
187
+ socket.open();
188
+
189
+ socket.receive({
190
+ type: "simulationFrame",
191
+ frame: {
192
+ scriptId: "simulation",
193
+ sequence: 1,
194
+ state: { value: 1 },
195
+ serverInvocation: { invocation: 1, timestamp: 0 },
196
+ },
197
+ });
198
+ animationFrames.flush();
199
+ socket.sent = [];
200
+
201
+ jest.advanceTimersByTime(500);
202
+ expect(socket.sent).toEqual([]);
203
+ expect(connection.getSimulationFrameDiagnostics()).toMatchObject({
204
+ simulationClockPulsesActive: false,
205
+ });
206
+ connection.close();
207
+ });
208
+
209
+ it("enables and disables clock pulses only from negotiated config", () => {
210
+ jest.useFakeTimers({ now: 0 });
211
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
212
+ const connection = new WebSocketConnection<{ value: number }>(
213
+ new URL("wss://example.com"),
214
+ {
215
+ heartbeatIntervalMs: 0,
216
+ silentTimeoutMs: 0,
217
+ }
218
+ );
219
+ connection.connect();
220
+ const socket = MockWebSocket.instances[0];
221
+ socket.open();
222
+ socket.receive({
223
+ type: "simulationClockConfig",
224
+ enabled: true,
225
+ intervalMs: 50,
226
+ });
227
+ jest.advanceTimersByTime(100);
228
+ expect(socket.sent.map((message) => JSON.parse(message))).toEqual([
229
+ { type: "simulationClockPulse" },
230
+ { type: "simulationClockPulse" },
231
+ ]);
232
+
233
+ socket.receive({ type: "simulationClockConfig", enabled: false });
234
+ jest.advanceTimersByTime(500);
235
+ expect(socket.sent).toHaveLength(2);
236
+ expect(connection.getSimulationFrameDiagnostics()).toMatchObject({
237
+ simulationClockPulsesActive: false,
238
+ });
239
+ connection.close();
240
+ });
241
+
242
+ it("uses the negotiated secondary socket, falls back on failure, and resumes after reconnect", () => {
243
+ jest.useFakeTimers({ now: 0 });
244
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
245
+ const connection = new WebSocketConnection<{ value: number }>(
246
+ new URL("wss://example.com/m?token=private-token"),
247
+ {
248
+ heartbeatIntervalMs: 0,
249
+ silentTimeoutMs: 0,
250
+ }
251
+ );
252
+ connection.connect();
253
+ const main = MockWebSocket.instances[0];
254
+ main.open();
255
+ main.receive({
256
+ type: "simulationClockConfig",
257
+ enabled: true,
258
+ intervalMs: 50,
259
+ pulsePath: "/m/pulse",
260
+ });
261
+
262
+ const firstPulse = MockWebSocket.instances[1];
263
+ expect(firstPulse.url).toBe(
264
+ "wss://example.com/m/pulse?token=private-token"
265
+ );
266
+ jest.advanceTimersByTime(50);
267
+ expect(main.sent).toEqual([]);
268
+ firstPulse.open();
269
+ jest.advanceTimersByTime(100);
270
+ expect(firstPulse.sent.map((message) => JSON.parse(message))).toEqual([
271
+ { type: "simulationClockPulse" },
272
+ { type: "simulationClockPulse" },
273
+ ]);
274
+ expect(main.sent).toEqual([]);
275
+
276
+ firstPulse.close();
277
+ jest.advanceTimersByTime(50);
278
+ expect(main.sent.map((message) => JSON.parse(message))).toEqual([
279
+ { type: "simulationClockPulse" },
280
+ ]);
281
+ jest.advanceTimersByTime(950);
282
+ const secondPulse = MockWebSocket.instances[2];
283
+ secondPulse.open();
284
+ main.sent = [];
285
+ jest.advanceTimersByTime(50);
286
+ expect(secondPulse.sent.map((message) => JSON.parse(message))).toEqual([
287
+ { type: "simulationClockPulse" },
288
+ ]);
289
+ expect(main.sent).toEqual([]);
290
+
291
+ main.receive({ type: "simulationClockConfig", enabled: false });
292
+ expect(secondPulse.closeCount).toBe(1);
293
+ jest.advanceTimersByTime(500);
294
+ expect(secondPulse.sent).toHaveLength(1);
295
+ connection.close();
296
+ });
297
+
298
+ it("closes the secondary pulse socket on main silence and permanent close", () => {
299
+ jest.useFakeTimers({ now: 0 });
300
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
301
+ const connection = new WebSocketConnection<{ value: number }>(
302
+ new URL("wss://example.com/m?token=private-token"),
303
+ {
304
+ heartbeatIntervalMs: 0,
305
+ silentTimeoutMs: 125,
306
+ }
307
+ );
308
+ connection.connect();
309
+ const main = MockWebSocket.instances[0];
310
+ main.open();
311
+ main.receive({
312
+ type: "simulationClockConfig",
313
+ enabled: true,
314
+ intervalMs: 50,
315
+ pulsePath: "/m/pulse",
316
+ });
317
+ const pulse = MockWebSocket.instances[1];
318
+ pulse.open();
319
+ jest.advanceTimersByTime(125);
320
+ expect(main.closeCount).toBe(1);
321
+ expect(pulse.closeCount).toBe(1);
322
+
323
+ connection.close();
324
+ jest.advanceTimersByTime(500);
325
+ expect(pulse.sent).toHaveLength(2);
326
+ });
327
+
328
+ it("cleans up negotiated clock pulses on silence and close", () => {
329
+ jest.useFakeTimers({ now: 0 });
330
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
331
+ const connection = new WebSocketConnection<{ value: number }>(
332
+ new URL("wss://example.com"),
333
+ {
334
+ heartbeatIntervalMs: 0,
335
+ silentTimeoutMs: 125,
336
+ }
337
+ );
338
+ connection.connect();
339
+ const socket = MockWebSocket.instances[0];
340
+ socket.open();
341
+ socket.receive({
342
+ type: "simulationClockConfig",
343
+ enabled: true,
344
+ intervalMs: 50,
345
+ });
346
+ jest.advanceTimersByTime(125);
347
+ expect(socket.closeCount).toBe(1);
348
+ expect(connection.getSimulationFrameDiagnostics()).toMatchObject({
349
+ simulationClockPulsesActive: false,
350
+ });
351
+
352
+ connection.close();
353
+ jest.advanceTimersByTime(500);
354
+ expect(connection.getSimulationFrameDiagnostics()).toMatchObject({
355
+ simulationClockPulsesActive: false,
356
+ });
357
+ });
358
+
359
+ it("coalesces burst simulation frames to the latest sequence", () => {
360
+ jest.useFakeTimers({ now: 0 });
361
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
362
+ globalThis.requestAnimationFrame =
363
+ undefined as unknown as typeof requestAnimationFrame;
364
+ const events: ConnectionEvent<{ value: number }>[] = [];
365
+ const connection = new WebSocketConnection<{ value: number }>(
366
+ new URL("wss://example.com"),
367
+ {
368
+ heartbeatIntervalMs: 0,
369
+ silentTimeoutMs: 0,
370
+ onConnectionEvent: (event) => events.push(event),
371
+ }
372
+ );
373
+ connection.connect();
374
+ const socket = MockWebSocket.instances[0];
375
+ socket.open();
376
+ for (const sequence of [2, 1]) {
377
+ socket.receive({
378
+ type: "simulationFrame",
379
+ frame: {
380
+ scriptId: "simulation",
381
+ sequence,
382
+ state: { value: sequence },
383
+ serverInvocation: {
384
+ scriptId: "simulation",
385
+ invocation: sequence,
386
+ timestamp: sequence * 100,
387
+ },
388
+ },
389
+ });
390
+ }
391
+ expect(socket.sent).toEqual([]);
392
+ expect(events.filter((event) => event.type === "receive")).toHaveLength(0);
393
+ jest.runOnlyPendingTimers();
394
+
395
+ expect(socket.sent.map((message) => JSON.parse(message))).toEqual([
396
+ {
397
+ type: "clientMessageBatch",
398
+ receipt: {
399
+ type: "simulationFrameReceipt",
400
+ scriptId: "simulation",
401
+ sequence: 2,
402
+ },
403
+ messages: [],
404
+ },
405
+ ]);
406
+ const received = events.filter(
407
+ (event): event is Extract<typeof event, { type: "receive" }> =>
408
+ event.type === "receive"
409
+ );
410
+ expect(received).toHaveLength(1);
411
+ expect(received[0].message).toMatchObject({
412
+ type: "simulationFrame",
413
+ frame: { sequence: 2, state: { value: 2 } },
414
+ });
415
+ expect(connection.getSimulationFrameDiagnostics()).toEqual({
416
+ pending: 0,
417
+ dropped: 1,
418
+ pendingReceipts: 0,
419
+ queuedRealtimeInputs: 0,
420
+ replayRealtimeInputs: 0,
421
+ unconfirmedRealtimeBatches: 0,
422
+ realtimeBatchesSent: 1,
423
+ realtimeInputsSent: 0,
424
+ realtimeWatchdogArmed: false,
425
+ simulationClockPulsesActive: false,
426
+ });
427
+ connection.close();
428
+ });
429
+
430
+ it("keeps outer reconnect resumption monotonic while acknowledging rewound frames", () => {
431
+ jest.useFakeTimers({ now: 0 });
432
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
433
+ globalThis.requestAnimationFrame =
434
+ undefined as unknown as typeof requestAnimationFrame;
435
+ type State = {
436
+ simulation: { tick: number };
437
+ $game: { startedAt: number; simulationTick: number };
438
+ };
439
+ const received: Array<
440
+ Extract<ConnectionEvent<State>, { type: "receive" }>["message"]
441
+ > = [];
442
+ const connection = new WebSocketConnection<State>(
443
+ new URL("wss://example.com"),
444
+ {
445
+ heartbeatIntervalMs: 0,
446
+ silentTimeoutMs: 0,
447
+ onConnectionEvent: (event) => {
448
+ if (event.type === "receive") received.push(event.message);
449
+ },
450
+ }
451
+ );
452
+ const state = (tick: number): State => ({
453
+ simulation: { tick },
454
+ $game: { startedAt: 1, simulationTick: 0 },
455
+ });
456
+ const frame = (sequence: number) => ({
457
+ type: "simulationFrame" as const,
458
+ frame: {
459
+ scriptId: "snakes-realtime",
460
+ sequence,
461
+ state: state(sequence),
462
+ serverInvocation: {
463
+ scriptId: "snakes-realtime",
464
+ invocation: sequence,
465
+ timestamp: sequence * 100,
466
+ },
467
+ },
468
+ });
469
+
470
+ connection.connect();
471
+ const first = MockWebSocket.instances[0];
472
+ first.open();
473
+ first.receive(frame(10));
474
+ jest.runOnlyPendingTimers();
475
+ first.close();
476
+ jest.advanceTimersByTime(1_000);
477
+
478
+ const second = MockWebSocket.instances[1];
479
+ second.open();
480
+ second.receive({ type: "object", object: state(8) });
481
+ second.receive(frame(8));
482
+ jest.runOnlyPendingTimers();
483
+ second.receive(frame(9));
484
+ jest.runOnlyPendingTimers();
485
+ second.receive(frame(11));
486
+ jest.runOnlyPendingTimers();
487
+
488
+ expect(
489
+ received.map((message) =>
490
+ message.type === "simulationFrame"
491
+ ? ["frame", message.frame.sequence]
492
+ : [message.type]
493
+ )
494
+ ).toEqual([
495
+ ["frame", 10],
496
+ ["frame", 11],
497
+ ]);
498
+ expect(
499
+ second.sent
500
+ .map((message) => JSON.parse(message))
501
+ .filter((message) => message.receipt)
502
+ .map((message) => message.receipt.sequence)
503
+ ).toEqual([8, 9, 11]);
504
+ connection.close();
505
+ });
506
+
507
+ it("acknowledges each script before independently coalescing its frame", () => {
508
+ jest.useFakeTimers({ now: 0 });
509
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
510
+ globalThis.requestAnimationFrame =
511
+ undefined as unknown as typeof requestAnimationFrame;
512
+ const events: ConnectionEvent<{ value: number }>[] = [];
513
+ const connection = new WebSocketConnection<{ value: number }>(
514
+ new URL("wss://example.com"),
515
+ {
516
+ heartbeatIntervalMs: 0,
517
+ silentTimeoutMs: 0,
518
+ onConnectionEvent: (event) => events.push(event),
519
+ }
520
+ );
521
+ connection.connect();
522
+ const socket = MockWebSocket.instances[0];
523
+ socket.open();
524
+
525
+ for (const [scriptId, sequence] of [
526
+ ["movement", 3],
527
+ ["particles", 8],
528
+ ["movement", 4],
529
+ ] as const) {
530
+ socket.receive({
531
+ type: "simulationFrame",
532
+ frame: {
533
+ scriptId,
534
+ sequence,
535
+ state: { value: sequence },
536
+ serverInvocation: {
537
+ scriptId,
538
+ invocation: sequence,
539
+ timestamp: sequence * 100,
540
+ },
541
+ },
542
+ });
543
+ }
544
+
545
+ expect(socket.sent).toEqual([]);
546
+ expect(events.filter((event) => event.type === "receive")).toHaveLength(0);
547
+
548
+ jest.runOnlyPendingTimers();
549
+ expect(socket.sent.map((message) => JSON.parse(message))).toEqual([
550
+ {
551
+ type: "clientMessageBatch",
552
+ receipt: {
553
+ type: "simulationFrameReceipt",
554
+ scriptId: "movement",
555
+ sequence: 4,
556
+ },
557
+ messages: [],
558
+ },
559
+ {
560
+ type: "clientMessageBatch",
561
+ receipt: {
562
+ type: "simulationFrameReceipt",
563
+ scriptId: "particles",
564
+ sequence: 8,
565
+ },
566
+ messages: [],
567
+ },
568
+ ]);
569
+ const receivedFrames = events
570
+ .filter(
571
+ (
572
+ event
573
+ ): event is Extract<
574
+ ConnectionEvent<{ value: number }>,
575
+ { type: "receive" }
576
+ > => event.type === "receive"
577
+ )
578
+ .map((event) => event.message)
579
+ .filter((message) => message.type === "simulationFrame")
580
+ .map((message) => [message.frame.scriptId, message.frame.sequence]);
581
+ expect(receivedFrames).toEqual([
582
+ ["movement", 4],
583
+ ["particles", 8],
584
+ ]);
585
+ connection.close();
586
+ });
587
+
588
+ it("flushes pending frames before later non-frame messages", () => {
589
+ jest.useFakeTimers({ now: 0 });
590
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
591
+ globalThis.requestAnimationFrame =
592
+ undefined as unknown as typeof requestAnimationFrame;
593
+ const cancelAnimationFrame = jest.fn((_handle: number) => {});
594
+ globalThis.cancelAnimationFrame = cancelAnimationFrame;
595
+ const events: ConnectionEvent<{ value: number }>[] = [];
596
+ const connection = new WebSocketConnection<{ value: number }>(
597
+ new URL("wss://example.com"),
598
+ {
599
+ heartbeatIntervalMs: 0,
600
+ silentTimeoutMs: 0,
601
+ onConnectionEvent: (event) => events.push(event),
602
+ }
603
+ );
604
+ connection.connect();
605
+ const socket = MockWebSocket.instances[0];
606
+ socket.open();
607
+ socket.receive({
608
+ type: "simulationFrame",
609
+ frame: {
610
+ scriptId: "simulation",
611
+ sequence: 1,
612
+ state: { value: 1 },
613
+ serverInvocation: {
614
+ scriptId: "simulation",
615
+ invocation: 1,
616
+ timestamp: 100,
617
+ },
618
+ },
619
+ });
620
+ socket.receive({
621
+ type: "acceptPatch",
622
+ id: "lifecycle",
623
+ patches: [{ op: "replace", path: ["value"], value: 2 }],
624
+ });
625
+
626
+ const received = events
627
+ .filter(
628
+ (event): event is Extract<typeof event, { type: "receive" }> =>
629
+ event.type === "receive"
630
+ )
631
+ .map((event) => event.message.type);
632
+ expect(received).toEqual(["simulationFrame", "acceptPatch"]);
633
+ expect(cancelAnimationFrame).not.toHaveBeenCalled();
634
+ expect(jest.getTimerCount()).toBe(0);
635
+ jest.runOnlyPendingTimers();
636
+ expect(events.filter((event) => event.type === "receive")).toHaveLength(2);
637
+ connection.close();
638
+ });
639
+
640
+ for (const inputRateHz of [0, 5, 10, 30]) {
641
+ it(`preserves injected 10 Hz frame cadence and ordered ${inputRateHz} Hz input batching`, () => {
642
+ jest.useFakeTimers({ now: 0 });
643
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
644
+ const animationFrames = installAnimationFrameController();
645
+ const deliveryTimes: number[] = [];
646
+ const injectionTimes: number[] = [];
647
+ const connection = new WebSocketConnection<{ value: number }>(
648
+ new URL("wss://example.com"),
649
+ {
650
+ heartbeatIntervalMs: 0,
651
+ silentTimeoutMs: 0,
652
+ realtimeInputWatchdogMs: 200,
653
+ onConnectionEvent: (event) => {
654
+ if (
655
+ event.type === "receive" &&
656
+ event.message.type === "simulationFrame"
657
+ ) {
658
+ deliveryTimes.push(Date.now());
659
+ }
660
+ },
661
+ }
662
+ );
663
+ connection.connect();
664
+ const socket = MockWebSocket.instances[0];
665
+ socket.open();
666
+ let inputSequence = 0;
667
+ const inputIntervalMs =
668
+ inputRateHz === 0 ? Number.POSITIVE_INFINITY : 1000 / inputRateHz;
669
+ let nextInputAt = 0;
670
+ let inspectedBatchCount = 0;
671
+
672
+ for (let now = 0; now <= 1_000; now += 1) {
673
+ if (now > 0) jest.advanceTimersByTime(1);
674
+ if (inputRateHz > 0 && now >= nextInputAt) {
675
+ connection.send({
676
+ type: "enqueueInput",
677
+ queue: "noya.game.realtime",
678
+ transport: "realtime",
679
+ payload: {
680
+ streamId: "movement",
681
+ sequence: inputSequence,
682
+ input: { dx: 1 },
683
+ },
684
+ });
685
+ inputSequence += 1;
686
+ nextInputAt += inputIntervalMs;
687
+ }
688
+ if (now % 100 === 0) {
689
+ const sequence = now / 100;
690
+ injectionTimes.push(Date.now());
691
+ socket.receive({
692
+ type: "simulationFrame",
693
+ frame: {
694
+ scriptId: "simulation",
695
+ sequence,
696
+ state: { value: sequence },
697
+ serverInvocation: {
698
+ scriptId: "simulation",
699
+ invocation: sequence,
700
+ timestamp: now,
701
+ },
702
+ },
703
+ });
704
+ animationFrames.flush();
705
+ for (
706
+ ;
707
+ inspectedBatchCount < socket.sent.length;
708
+ inspectedBatchCount += 1
709
+ ) {
710
+ const batch = JSON.parse(socket.sent[inspectedBatchCount]);
711
+ if (!batch.inputBatch) continue;
712
+ socket.receive({
713
+ type: "realtimeInputReceipt",
714
+ ...batch.inputBatch,
715
+ });
716
+ }
717
+ }
718
+ }
719
+
720
+ const batches = socket.sent.map((message) => JSON.parse(message));
721
+ const inputSequences = batches.flatMap((batch) =>
722
+ batch.messages.map(
723
+ (message: { payload: { sequence: number } }) =>
724
+ message.payload.sequence
725
+ )
726
+ );
727
+ expect(batches).toHaveLength(11);
728
+ expect(batches.every((batch) => batch.receipt)).toBe(true);
729
+ expect(inputSequences).toEqual(
730
+ Array.from({ length: inputSequence }, (_, index) => index)
731
+ );
732
+ expect(
733
+ deliveryTimes.map((time, index) => time - injectionTimes[index])
734
+ ).toEqual(Array(11).fill(0));
735
+ expect(
736
+ deliveryTimes.slice(1).map((time, index) => time - deliveryTimes[index])
737
+ ).toEqual(
738
+ injectionTimes
739
+ .slice(1)
740
+ .map((time, index) => time - injectionTimes[index])
741
+ );
742
+ connection.close();
743
+ });
744
+ }
745
+
746
+ it("batches 30 Hz input into frame-rate upstream events under a three-frame server window", () => {
747
+ jest.useFakeTimers({ now: 0 });
748
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
749
+ const animationFrames = installAnimationFrameController();
750
+ const connection = new WebSocketConnection<{ value: number }>(
751
+ new URL("wss://example.com"),
752
+ {
753
+ heartbeatIntervalMs: 0,
754
+ silentTimeoutMs: 0,
755
+ realtimeInputWatchdogMs: 200,
756
+ }
757
+ );
758
+ connection.connect();
759
+ const socket = MockWebSocket.instances[0];
760
+ socket.open();
761
+
762
+ const serverWindowSize = 3;
763
+ const inFlightFrames: number[] = [];
764
+ let pendingFrame: number | undefined;
765
+ let maxInFlightFrames = 0;
766
+ let processedUpstreamEvents = 0;
767
+ let nextUpstreamEvent = 0;
768
+ let ingestedInputBatchSequence = -1;
769
+ const ingestedInputSequences: number[] = [];
770
+ const frameReceiptSequences: number[] = [];
771
+ const cumulativeInputReceipts: number[] = [];
772
+
773
+ const deliverFrame = (sequence: number) => {
774
+ inFlightFrames.push(sequence);
775
+ maxInFlightFrames = Math.max(maxInFlightFrames, inFlightFrames.length);
776
+ socket.receive({
777
+ type: "simulationFrame",
778
+ frame: {
779
+ scriptId: "simulation",
780
+ sequence,
781
+ state: { value: sequence },
782
+ serverInvocation: {
783
+ scriptId: "simulation",
784
+ invocation: sequence,
785
+ timestamp: sequence * 100,
786
+ },
787
+ },
788
+ });
789
+ };
790
+ const publishFrame = (sequence: number) => {
791
+ if (inFlightFrames.length < serverWindowSize) {
792
+ deliverFrame(sequence);
793
+ } else {
794
+ pendingFrame = sequence;
795
+ }
796
+ };
797
+ const processUpstreamEvents = () => {
798
+ do {
799
+ while (nextUpstreamEvent < socket.sent.length) {
800
+ const batch = JSON.parse(socket.sent[nextUpstreamEvent++]);
801
+ processedUpstreamEvents += 1;
802
+ if (batch.receipt) {
803
+ frameReceiptSequences.push(batch.receipt.sequence);
804
+ const acknowledgedIndex = inFlightFrames.indexOf(
805
+ batch.receipt.sequence
806
+ );
807
+ if (acknowledgedIndex >= 0) {
808
+ inFlightFrames.splice(0, acknowledgedIndex + 1);
809
+ }
810
+ if (
811
+ pendingFrame !== undefined &&
812
+ inFlightFrames.length < serverWindowSize
813
+ ) {
814
+ const sequence = pendingFrame;
815
+ pendingFrame = undefined;
816
+ deliverFrame(sequence);
817
+ }
818
+ }
819
+ if (batch.inputBatch) {
820
+ expect(batch.inputBatch.sequence).toBe(
821
+ ingestedInputBatchSequence + 1
822
+ );
823
+ ingestedInputBatchSequence = batch.inputBatch.sequence;
824
+ ingestedInputSequences.push(
825
+ ...batch.messages.map(
826
+ (message: { payload: { sequence: number } }) =>
827
+ message.payload.sequence
828
+ )
829
+ );
830
+ cumulativeInputReceipts.push(ingestedInputBatchSequence);
831
+ socket.receive({
832
+ type: "realtimeInputReceipt",
833
+ sessionId: batch.inputBatch.sessionId,
834
+ sequence: ingestedInputBatchSequence,
835
+ });
836
+ }
837
+ }
838
+ animationFrames.flush();
839
+ } while (nextUpstreamEvent < socket.sent.length);
840
+ };
841
+
842
+ let generatedInputCount = 0;
843
+ let nextInputAt = 0;
844
+ for (let now = 0; now <= 1_200; now += 1) {
845
+ if (now > 0) jest.advanceTimersByTime(1);
846
+ if (now <= 1_000 && now >= nextInputAt) {
847
+ connection.send({
848
+ type: "enqueueInput",
849
+ queue: "noya.game.realtime",
850
+ transport: "realtime",
851
+ payload: {
852
+ streamId: "movement",
853
+ sequence: generatedInputCount,
854
+ input: { dx: 1 },
855
+ },
856
+ });
857
+ generatedInputCount += 1;
858
+ nextInputAt += 1000 / 30;
859
+ }
860
+ if (now <= 1_000 && now % 100 === 0) {
861
+ publishFrame(now / 100);
862
+ animationFrames.flush();
863
+ }
864
+ if (now % 300 === 0) {
865
+ processUpstreamEvents();
866
+ }
867
+ }
868
+
869
+ expect(maxInFlightFrames).toBe(serverWindowSize);
870
+ expect(processedUpstreamEvents).toBeLessThanOrEqual(13);
871
+ expect(processedUpstreamEvents).toBeGreaterThanOrEqual(10);
872
+ expect(ingestedInputSequences).toEqual(
873
+ Array.from({ length: generatedInputCount }, (_, index) => index)
874
+ );
875
+ expect(frameReceiptSequences.at(-1)).toBe(10);
876
+ expect(cumulativeInputReceipts).toEqual(
877
+ Array.from(
878
+ { length: ingestedInputBatchSequence + 1 },
879
+ (_, index) => index
880
+ )
881
+ );
882
+ connection.close();
883
+ });
884
+
885
+ it("uses a low-rate watchdog when no simulation frame arrives", () => {
886
+ jest.useFakeTimers({ now: 0 });
887
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
888
+ const connection = new WebSocketConnection<{ value: number }>(
889
+ new URL("wss://example.com"),
890
+ {
891
+ heartbeatIntervalMs: 0,
892
+ silentTimeoutMs: 0,
893
+ realtimeInputWatchdogMs: 200,
894
+ }
895
+ );
896
+ connection.connect();
897
+ const socket = MockWebSocket.instances[0];
898
+ socket.open();
899
+ for (const sequence of [0, 1, 2]) {
900
+ connection.send({
901
+ type: "enqueueInput",
902
+ queue: "noya.game.realtime",
903
+ transport: "realtime",
904
+ payload: {
905
+ streamId: "movement",
906
+ sequence,
907
+ input: { dx: sequence },
908
+ },
909
+ });
910
+ }
911
+
912
+ jest.advanceTimersByTime(199);
913
+ expect(socket.sent).toEqual([]);
914
+ jest.advanceTimersByTime(1);
915
+ expect(socket.sent.map((message) => JSON.parse(message))).toEqual([
916
+ {
917
+ type: "clientMessageBatch",
918
+ inputBatch: {
919
+ sessionId: expect.any(String),
920
+ sequence: 0,
921
+ },
922
+ messages: [
923
+ expect.objectContaining({
924
+ payload: expect.objectContaining({ sequence: 0 }),
925
+ }),
926
+ expect.objectContaining({
927
+ payload: expect.objectContaining({ sequence: 1 }),
928
+ }),
929
+ expect.objectContaining({
930
+ payload: expect.objectContaining({ sequence: 2 }),
931
+ }),
932
+ ],
933
+ },
934
+ ]);
935
+ connection.close();
936
+ });
937
+
938
+ it("replays a batch when disconnect happens immediately after send", () => {
939
+ jest.useFakeTimers({ now: 0 });
940
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
941
+ const connection = new WebSocketConnection<{ value: number }>(
942
+ new URL("wss://example.com"),
943
+ {
944
+ heartbeatIntervalMs: 0,
945
+ silentTimeoutMs: 0,
946
+ realtimeInputWatchdogMs: 200,
947
+ }
948
+ );
949
+ connection.connect();
950
+ const first = MockWebSocket.instances[0];
951
+ first.open();
952
+ connection.send({
953
+ type: "enqueueInput",
954
+ queue: "noya.game.realtime",
955
+ transport: "realtime",
956
+ payload: { streamId: "movement", sequence: 0, input: { dx: 1 } },
957
+ });
958
+ jest.advanceTimersByTime(200);
959
+ const originalBatch = JSON.parse(first.sent[0]);
960
+ first.close();
961
+ jest.advanceTimersByTime(1_000);
962
+
963
+ const second = MockWebSocket.instances[1];
964
+ second.open();
965
+ expect(second.sent.map((message) => JSON.parse(message))).toEqual([
966
+ originalBatch,
967
+ ]);
968
+ expect(
969
+ second.sent
970
+ .map((message) => JSON.parse(message))
971
+ .flatMap((batch) => batch.messages)
972
+ .map((message) => message.payload.sequence)
973
+ ).toEqual([0]);
974
+ connection.close();
975
+ });
976
+
977
+ it("does not treat a pre-existing in-flight frame as input ingestion", () => {
978
+ jest.useFakeTimers({ now: 0 });
979
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
980
+ const animationFrames = installAnimationFrameController();
981
+ const connection = new WebSocketConnection<{ value: number }>(
982
+ new URL("wss://example.com"),
983
+ {
984
+ heartbeatIntervalMs: 0,
985
+ silentTimeoutMs: 0,
986
+ realtimeInputWatchdogMs: 200,
987
+ }
988
+ );
989
+ connection.connect();
990
+ const first = MockWebSocket.instances[0];
991
+ first.open();
992
+ first.receive({
993
+ type: "simulationFrame",
994
+ frame: {
995
+ scriptId: "simulation",
996
+ sequence: 7,
997
+ state: { value: 7 },
998
+ serverInvocation: {
999
+ scriptId: "simulation",
1000
+ invocation: 7,
1001
+ timestamp: 0,
1002
+ },
1003
+ },
1004
+ });
1005
+ connection.send({
1006
+ type: "enqueueInput",
1007
+ queue: "noya.game.realtime",
1008
+ transport: "realtime",
1009
+ payload: { streamId: "movement", sequence: 0, input: { dx: 1 } },
1010
+ });
1011
+ animationFrames.flush();
1012
+ expect(
1013
+ connection.getSimulationFrameDiagnostics().replayRealtimeInputs
1014
+ ).toBe(1);
1015
+ first.close();
1016
+ jest.advanceTimersByTime(1_000);
1017
+
1018
+ const second = MockWebSocket.instances[1];
1019
+ second.open();
1020
+ expect(
1021
+ second.sent
1022
+ .map((message) => JSON.parse(message))
1023
+ .flatMap((batch) => batch.messages)
1024
+ .map((message) => message.payload.sequence)
1025
+ ).toEqual([0]);
1026
+ connection.close();
1027
+ });
1028
+
1029
+ it("drops replay history only after cumulative ingestion confirmation", () => {
1030
+ jest.useFakeTimers({ now: 0 });
1031
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
1032
+ const connection = new WebSocketConnection<{ value: number }>(
1033
+ new URL("wss://example.com"),
1034
+ {
1035
+ heartbeatIntervalMs: 0,
1036
+ silentTimeoutMs: 0,
1037
+ realtimeInputWatchdogMs: 200,
1038
+ }
1039
+ );
1040
+ connection.connect();
1041
+ const first = MockWebSocket.instances[0];
1042
+ first.open();
1043
+ connection.send({
1044
+ type: "enqueueInput",
1045
+ queue: "noya.game.realtime",
1046
+ transport: "realtime",
1047
+ payload: { streamId: "movement", sequence: 0, input: { dx: 1 } },
1048
+ });
1049
+ jest.advanceTimersByTime(200);
1050
+ const sentBatch = JSON.parse(first.sent[0]);
1051
+ first.receive({
1052
+ type: "realtimeInputReceipt",
1053
+ ...sentBatch.inputBatch,
1054
+ });
1055
+ expect(connection.getSimulationFrameDiagnostics()).toMatchObject({
1056
+ replayRealtimeInputs: 0,
1057
+ unconfirmedRealtimeBatches: 0,
1058
+ });
1059
+ first.close();
1060
+ jest.advanceTimersByTime(1_000);
1061
+
1062
+ const second = MockWebSocket.instances[1];
1063
+ second.open();
1064
+ expect(second.sent).toEqual([]);
1065
+ connection.close();
1066
+ });
1067
+
1068
+ it("bounds unconfirmed replay batches without introducing input gaps", () => {
1069
+ jest.useFakeTimers({ now: 0 });
1070
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
1071
+ const animationFrames = installAnimationFrameController();
1072
+ const connection = new WebSocketConnection<{ value: number }>(
1073
+ new URL("wss://example.com"),
1074
+ {
1075
+ heartbeatIntervalMs: 0,
1076
+ silentTimeoutMs: 0,
1077
+ realtimeInputWatchdogMs: 200,
1078
+ }
1079
+ );
1080
+ connection.connect();
1081
+ const socket = MockWebSocket.instances[0];
1082
+ socket.open();
1083
+ const receiveFrame = (sequence: number) => {
1084
+ socket.receive({
1085
+ type: "simulationFrame",
1086
+ frame: {
1087
+ scriptId: "simulation",
1088
+ sequence,
1089
+ state: { value: sequence },
1090
+ serverInvocation: {
1091
+ scriptId: "simulation",
1092
+ invocation: sequence,
1093
+ timestamp: sequence * 100,
1094
+ },
1095
+ },
1096
+ });
1097
+ animationFrames.flush();
1098
+ };
1099
+
1100
+ for (const sequence of [0, 1, 2, 3]) {
1101
+ connection.send({
1102
+ type: "enqueueInput",
1103
+ queue: "noya.game.realtime",
1104
+ transport: "realtime",
1105
+ payload: {
1106
+ streamId: "movement",
1107
+ sequence,
1108
+ input: { dx: sequence },
1109
+ },
1110
+ });
1111
+ receiveFrame(sequence);
1112
+ }
1113
+ expect(connection.getSimulationFrameDiagnostics()).toMatchObject({
1114
+ queuedRealtimeInputs: 1,
1115
+ replayRealtimeInputs: 3,
1116
+ unconfirmedRealtimeBatches: 3,
1117
+ });
1118
+
1119
+ const inputBatches = socket.sent
1120
+ .map((message) => JSON.parse(message))
1121
+ .filter((batch) => batch.inputBatch);
1122
+ socket.receive({
1123
+ type: "realtimeInputReceipt",
1124
+ sessionId: inputBatches[0].inputBatch.sessionId,
1125
+ sequence: 1,
1126
+ });
1127
+ receiveFrame(4);
1128
+
1129
+ const sentSequences = socket.sent
1130
+ .map((message) => JSON.parse(message))
1131
+ .flatMap((batch) => batch.messages)
1132
+ .map((message) => message.payload.sequence);
1133
+ expect(sentSequences).toEqual([0, 1, 2, 3]);
1134
+ expect(connection.getSimulationFrameDiagnostics()).toMatchObject({
1135
+ queuedRealtimeInputs: 0,
1136
+ replayRealtimeInputs: 2,
1137
+ unconfirmedRealtimeBatches: 2,
1138
+ });
1139
+ connection.close();
1140
+ });
1141
+
1142
+ it("keeps discrete and game-action inputs immediate", () => {
1143
+ jest.useFakeTimers({ now: 0 });
1144
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
1145
+ const connection = new WebSocketConnection<{ value: number }>(
1146
+ new URL("wss://example.com"),
1147
+ {
1148
+ heartbeatIntervalMs: 0,
1149
+ silentTimeoutMs: 0,
1150
+ }
1151
+ );
1152
+ connection.connect();
1153
+ const socket = MockWebSocket.instances[0];
1154
+ socket.open();
1155
+ connection.send({
1156
+ type: "enqueueInput",
1157
+ queue: "controller",
1158
+ payload: { delta: 1 },
1159
+ });
1160
+ connection.send({
1161
+ type: "enqueueInput",
1162
+ queue: "noya.game.actions",
1163
+ payload: { actionId: "move-1", action: { type: "move" } },
1164
+ });
1165
+
1166
+ expect(socket.sent.map((message) => JSON.parse(message))).toEqual([
1167
+ {
1168
+ type: "enqueueInput",
1169
+ queue: "controller",
1170
+ payload: { delta: 1 },
1171
+ },
1172
+ {
1173
+ type: "enqueueInput",
1174
+ queue: "noya.game.actions",
1175
+ payload: { actionId: "move-1", action: { type: "move" } },
1176
+ },
1177
+ ]);
1178
+ connection.close();
1179
+ });
1180
+ });