@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noya-app/noya-multiplayer-react",
3
- "version": "0.1.89",
3
+ "version": "0.2.0",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -11,7 +11,7 @@
11
11
  "dev": "npm run build -- --watch"
12
12
  },
13
13
  "dependencies": {
14
- "@noya-app/state-manager": "0.2.0",
14
+ "@noya-app/state-manager": "0.3.0",
15
15
  "@noya-app/emitter": "0.1.0",
16
16
  "@noya-app/observable": "0.1.12",
17
17
  "@noya-app/task-runner": "0.1.7",
@@ -1,38 +1,122 @@
1
1
  import {
2
+ ClientMessageBatch,
3
+ ClientTransportMessage,
2
4
  ClientToServerMessage,
3
- MultiplayerStateManagerError,
5
+ ConnectionEvent as StateManagerConnectionEvent,
6
+ EnqueueInputMessage,
7
+ MAX_CLIENT_MESSAGE_BATCH_SIZE,
8
+ MAX_REALTIME_INPUT_BATCHES_IN_FLIGHT,
4
9
  ReconnectingWebSocket,
5
10
  ReconnectingWebSocketState,
6
11
  ServerToClientMessage,
12
+ SimulationFrameReceiptMessage,
7
13
  UserActivityDetector,
8
14
  } from "@noya-app/state-manager";
9
15
 
10
- export type ConnectionEvent<State> =
11
- | {
12
- type: "stateChange";
13
- state: ReconnectingWebSocketState;
14
- }
15
- | {
16
- type: "send";
17
- message: ClientToServerMessage<State>;
18
- }
19
- | {
20
- type: "receive";
21
- message: ServerToClientMessage<State>;
22
- }
23
- | {
24
- type: "error";
25
- error: MultiplayerStateManagerError;
26
- };
16
+ export type ConnectionEvent<State> = StateManagerConnectionEvent<State>;
27
17
 
28
18
  export type ConnectionOptions<State> = {
29
19
  debug?: boolean;
20
+ heartbeatIntervalMs?: number;
21
+ silentTimeoutMs?: number;
22
+ realtimeInputWatchdogMs?: number;
30
23
  onConnectionEvent?: (event: ConnectionEvent<State>) => void;
31
24
  };
32
25
 
26
+ type SimulationResumeCursor = {
27
+ sequence: number;
28
+ sessionIdentity?: string;
29
+ };
30
+
31
+ function simulationSessionIdentity(state: unknown) {
32
+ if (!state || typeof state !== "object" || Array.isArray(state)) return;
33
+ const game = Reflect.get(state, "$game");
34
+ if (!game || typeof game !== "object" || Array.isArray(game)) return;
35
+ const startedAt = Reflect.get(game, "startedAt");
36
+ if (
37
+ startedAt === null ||
38
+ typeof startedAt === "string" ||
39
+ (typeof startedAt === "number" && Number.isFinite(startedAt))
40
+ ) {
41
+ return `startedAt:${typeof startedAt}:${String(startedAt)}`;
42
+ }
43
+ }
44
+
45
+ function simulationStateSequence(state: unknown) {
46
+ if (!state || typeof state !== "object" || Array.isArray(state)) return;
47
+ const simulation = Reflect.get(state, "simulation");
48
+ const simulationTick =
49
+ simulation && typeof simulation === "object"
50
+ ? Reflect.get(simulation, "tick")
51
+ : undefined;
52
+ if (Number.isSafeInteger(simulationTick) && (simulationTick as number) >= 0) {
53
+ return simulationTick as number;
54
+ }
55
+ const game = Reflect.get(state, "$game");
56
+ const gameTick =
57
+ game && typeof game === "object"
58
+ ? Reflect.get(game, "simulationTick")
59
+ : undefined;
60
+ return Number.isSafeInteger(gameTick) && (gameTick as number) >= 0
61
+ ? (gameTick as number)
62
+ : undefined;
63
+ }
64
+
65
+ function sameSimulationSession(
66
+ left: SimulationResumeCursor,
67
+ right: SimulationResumeCursor
68
+ ) {
69
+ return (
70
+ left.sessionIdentity === undefined ||
71
+ right.sessionIdentity === undefined ||
72
+ left.sessionIdentity === right.sessionIdentity
73
+ );
74
+ }
75
+
76
+ function createRealtimeInputSessionId() {
77
+ return typeof crypto !== "undefined" &&
78
+ typeof crypto.randomUUID === "function"
79
+ ? crypto.randomUUID()
80
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
81
+ }
82
+
33
83
  export class WebSocketConnection<State> {
34
84
  private ws: ReconnectingWebSocket;
35
85
  private closedForever = false;
86
+ private heartbeatIntervalHandle?: ReturnType<typeof setInterval>;
87
+ private silentTimeoutHandle?: ReturnType<typeof setTimeout>;
88
+ private simulationClockPulseIntervalHandle?: ReturnType<typeof setInterval>;
89
+ private simulationClockPulseSocket?: ReconnectingWebSocket;
90
+ private simulationClockDirectFailed = false;
91
+ private heartbeatSequence = 0;
92
+ private pendingSimulationFrames = new Map<
93
+ string,
94
+ Extract<ServerToClientMessage<State>, { type: "simulationFrame" }>
95
+ >();
96
+ private frameFlushHandle?: number;
97
+ private frameFlushScheduler?: "animationFrame" | "timeout";
98
+ private droppedSimulationFrames = 0;
99
+ private pendingFrameReceipts = new Map<
100
+ string,
101
+ SimulationFrameReceiptMessage
102
+ >();
103
+ private deliveredSimulationFrameCursors = new Map<
104
+ string,
105
+ SimulationResumeCursor
106
+ >();
107
+ private queuedRealtimeInputs: EnqueueInputMessage[] = [];
108
+ /**
109
+ * A batch stays here until the server explicitly confirms ingestion.
110
+ * Reconnects resend these exact batches, so a frame that was already in
111
+ * flight can never create a hole in an input stream.
112
+ */
113
+ private unconfirmedRealtimeBatches: ClientMessageBatch[] = [];
114
+ private lastSentRealtimeSequences = new Map<string, number>();
115
+ private readonly realtimeInputSessionId = createRealtimeInputSessionId();
116
+ private nextRealtimeInputBatchSequence = 0;
117
+ private realtimeInputWatchdogHandle?: ReturnType<typeof setTimeout>;
118
+ private realtimeBatchesSent = 0;
119
+ private realtimeInputsSent = 0;
36
120
 
37
121
  constructor(
38
122
  private url: URL,
@@ -56,6 +140,10 @@ export class WebSocketConnection<State> {
56
140
  private handleOpen = () => {
57
141
  if (this.closedForever) return;
58
142
 
143
+ this.startHeartbeat();
144
+ this.replayUnconfirmedRealtimeBatches();
145
+ this.armRealtimeInputWatchdog();
146
+
59
147
  if (this.options.debug) {
60
148
  console.info("ws connected");
61
149
  }
@@ -69,19 +157,151 @@ export class WebSocketConnection<State> {
69
157
  private handleMessage = (event: MessageEvent) => {
70
158
  if (this.closedForever) return;
71
159
 
160
+ this.armSilentTimeout();
161
+
72
162
  if (this.options.debug) {
73
163
  console.info("ws receiving message ", event.data);
74
164
  }
75
165
 
76
166
  const parsed = JSON.parse(event.data) as ServerToClientMessage<State>;
167
+ if (parsed.type === "simulationClockConfig") {
168
+ this.applySimulationClockConfig(parsed);
169
+ return;
170
+ }
171
+ if (parsed.type === "simulationFrame") {
172
+ const cursor = {
173
+ sequence: parsed.frame.sequence,
174
+ sessionIdentity: simulationSessionIdentity(parsed.frame.state),
175
+ };
176
+ const receipt: SimulationFrameReceiptMessage = {
177
+ type: "simulationFrameReceipt",
178
+ scriptId: parsed.frame.scriptId,
179
+ sequence: parsed.frame.sequence,
180
+ };
181
+ const pendingReceipt = this.pendingFrameReceipts.get(
182
+ parsed.frame.scriptId
183
+ );
184
+ if (!pendingReceipt || receipt.sequence > pendingReceipt.sequence) {
185
+ this.pendingFrameReceipts.set(parsed.frame.scriptId, receipt);
186
+ }
187
+ const delivered = this.deliveredSimulationFrameCursors.get(
188
+ parsed.frame.scriptId
189
+ );
190
+ const pending = this.pendingSimulationFrames.get(parsed.frame.scriptId);
191
+ const pendingCursor = pending
192
+ ? {
193
+ sequence: pending.frame.sequence,
194
+ sessionIdentity: simulationSessionIdentity(pending.frame.state),
195
+ }
196
+ : undefined;
197
+ if (
198
+ (delivered &&
199
+ sameSimulationSession(delivered, cursor) &&
200
+ cursor.sequence <= delivered.sequence) ||
201
+ (pendingCursor &&
202
+ sameSimulationSession(pendingCursor, cursor) &&
203
+ cursor.sequence <= pendingCursor.sequence)
204
+ ) {
205
+ this.droppedSimulationFrames += 1;
206
+ this.scheduleSimulationFrameFlush();
207
+ return;
208
+ }
209
+ if (pending) this.droppedSimulationFrames += 1;
210
+ this.pendingSimulationFrames.set(parsed.frame.scriptId, parsed);
211
+ this.scheduleSimulationFrameFlush();
212
+ return;
213
+ }
214
+ if (
215
+ parsed.type === "object" &&
216
+ this.shouldSuppressNonMonotonicObject(parsed.object)
217
+ ) {
218
+ return;
219
+ }
220
+
221
+ if (parsed.type === "realtimeInputReceipt") {
222
+ this.confirmRealtimeInputBatches(parsed.sessionId, parsed.sequence);
223
+ return;
224
+ }
225
+ this.flushSimulationFrames();
226
+ this.emitReceivedMessage(parsed);
227
+ };
77
228
 
229
+ private emitReceivedMessage(message: ServerToClientMessage<State>) {
78
230
  this.options.onConnectionEvent?.({
79
231
  type: "receive",
80
- message: parsed,
232
+ message,
81
233
  });
82
- };
234
+ }
235
+
236
+ private scheduleSimulationFrameFlush() {
237
+ if (this.frameFlushHandle !== undefined) return;
238
+ const flush = () => {
239
+ this.frameFlushHandle = undefined;
240
+ this.frameFlushScheduler = undefined;
241
+ this.flushSimulationFrames();
242
+ };
243
+ if (typeof requestAnimationFrame === "function") {
244
+ this.frameFlushScheduler = "animationFrame";
245
+ this.frameFlushHandle = requestAnimationFrame(flush);
246
+ } else {
247
+ this.frameFlushScheduler = "timeout";
248
+ this.frameFlushHandle = setTimeout(flush, 0) as unknown as number;
249
+ }
250
+ }
251
+
252
+ private flushSimulationFrames() {
253
+ this.cancelSimulationFrameFlush();
254
+ this.flushPendingFrameReceipts();
255
+ const frames = Array.from(this.pendingSimulationFrames.values());
256
+ this.pendingSimulationFrames.clear();
257
+ for (const frame of frames) {
258
+ this.deliveredSimulationFrameCursors.set(frame.frame.scriptId, {
259
+ sequence: frame.frame.sequence,
260
+ sessionIdentity: simulationSessionIdentity(frame.frame.state),
261
+ });
262
+ this.emitReceivedMessage(frame);
263
+ }
264
+ }
265
+
266
+ private shouldSuppressNonMonotonicObject(state: State) {
267
+ const sequence = simulationStateSequence(state);
268
+ if (sequence === undefined) return false;
269
+ const cursor = {
270
+ sequence,
271
+ sessionIdentity: simulationSessionIdentity(state),
272
+ };
273
+ const previous = Array.from(this.deliveredSimulationFrameCursors.values());
274
+ const comparable = previous.filter((entry) =>
275
+ sameSimulationSession(entry, cursor)
276
+ );
277
+ if (
278
+ cursor.sessionIdentity !== undefined &&
279
+ previous.some((entry) => entry.sessionIdentity !== undefined) &&
280
+ comparable.length === 0
281
+ ) {
282
+ this.deliveredSimulationFrameCursors.clear();
283
+ return false;
284
+ }
285
+ return comparable.some((entry) => entry.sequence > cursor.sequence);
286
+ }
287
+
288
+ private cancelSimulationFrameFlush() {
289
+ if (this.frameFlushHandle === undefined) return;
290
+
291
+ if (this.frameFlushScheduler === "animationFrame") {
292
+ cancelAnimationFrame(this.frameFlushHandle);
293
+ } else {
294
+ clearTimeout(this.frameFlushHandle);
295
+ }
296
+ this.frameFlushHandle = undefined;
297
+ this.frameFlushScheduler = undefined;
298
+ }
83
299
 
84
300
  private handleClose = () => {
301
+ this.stopHeartbeat();
302
+ this.stopSimulationClockPulses();
303
+ this.stopRealtimeInputWatchdog();
304
+
85
305
  if (this.closedForever) return;
86
306
 
87
307
  if (this.options.debug) {
@@ -94,9 +314,180 @@ export class WebSocketConnection<State> {
94
314
  });
95
315
  };
96
316
 
317
+ private startHeartbeat() {
318
+ this.stopHeartbeat();
319
+ this.armSilentTimeout();
320
+
321
+ const intervalMs = this.options.heartbeatIntervalMs ?? 15_000;
322
+ if (intervalMs <= 0) return;
323
+
324
+ this.heartbeatIntervalHandle = setInterval(() => {
325
+ if (this.closedForever || this.ws.state !== "OPEN") return;
326
+
327
+ this.send({
328
+ type: "ping",
329
+ id: `heartbeat-${Date.now()}-${++this.heartbeatSequence}`,
330
+ clientSentAt: Date.now(),
331
+ });
332
+ }, intervalMs);
333
+ }
334
+
335
+ private armSilentTimeout() {
336
+ if (this.silentTimeoutHandle !== undefined) {
337
+ clearTimeout(this.silentTimeoutHandle);
338
+ this.silentTimeoutHandle = undefined;
339
+ }
340
+
341
+ const timeoutMs = this.options.silentTimeoutMs ?? 45_000;
342
+ if (timeoutMs <= 0 || this.closedForever || this.ws.state !== "OPEN")
343
+ return;
344
+
345
+ this.silentTimeoutHandle = setTimeout(() => {
346
+ this.silentTimeoutHandle = undefined;
347
+ if (this.closedForever || this.ws.state !== "OPEN") return;
348
+
349
+ this.stopSimulationClockPulses();
350
+ this.stopHeartbeat();
351
+ this.ws.reconnect();
352
+ }, timeoutMs);
353
+ }
354
+
355
+ private stopHeartbeat() {
356
+ if (this.heartbeatIntervalHandle !== undefined) {
357
+ clearInterval(this.heartbeatIntervalHandle);
358
+ this.heartbeatIntervalHandle = undefined;
359
+ }
360
+ if (this.silentTimeoutHandle !== undefined) {
361
+ clearTimeout(this.silentTimeoutHandle);
362
+ this.silentTimeoutHandle = undefined;
363
+ }
364
+ this.cancelSimulationFrameFlush();
365
+ this.pendingSimulationFrames.clear();
366
+ this.pendingFrameReceipts.clear();
367
+ }
368
+
369
+ private applySimulationClockConfig(
370
+ config: Extract<
371
+ ServerToClientMessage<State>,
372
+ { type: "simulationClockConfig" }
373
+ >
374
+ ) {
375
+ this.stopSimulationClockPulses();
376
+ if (
377
+ config.enabled !== true ||
378
+ !Number.isFinite(config.intervalMs) ||
379
+ config.intervalMs <= 0 ||
380
+ this.closedForever ||
381
+ this.ws.state !== "OPEN"
382
+ ) {
383
+ return;
384
+ }
385
+
386
+ const intervalMs = Math.max(1, Math.floor(config.intervalMs));
387
+ if (config.pulsePath !== undefined) {
388
+ this.openSimulationClockPulseSocket(config.pulsePath);
389
+ }
390
+ this.simulationClockPulseIntervalHandle = setInterval(() => {
391
+ if (this.closedForever || this.ws.state !== "OPEN") return;
392
+ const pulse = JSON.stringify({ type: "simulationClockPulse" });
393
+ if (this.simulationClockPulseSocket?.state === "OPEN") {
394
+ this.simulationClockPulseSocket.send(pulse);
395
+ return;
396
+ }
397
+ if (
398
+ this.simulationClockPulseSocket &&
399
+ !this.simulationClockDirectFailed
400
+ ) {
401
+ return;
402
+ }
403
+ this.sendTransportMessage({ type: "simulationClockPulse" });
404
+ }, intervalMs);
405
+ }
406
+
407
+ private openSimulationClockPulseSocket(pulsePath: string) {
408
+ if (
409
+ !pulsePath.startsWith("/") ||
410
+ pulsePath.includes("?") ||
411
+ pulsePath.includes("#")
412
+ ) {
413
+ this.simulationClockDirectFailed = true;
414
+ return;
415
+ }
416
+ const pulseUrl = new URL(this.url);
417
+ pulseUrl.pathname = pulsePath;
418
+ pulseUrl.hash = "";
419
+ const socket = new ReconnectingWebSocket({
420
+ onopen: () => {
421
+ if (this.simulationClockPulseSocket !== socket) return;
422
+ this.simulationClockDirectFailed = false;
423
+ },
424
+ onclose: () => {
425
+ if (this.simulationClockPulseSocket !== socket) return;
426
+ this.simulationClockDirectFailed = true;
427
+ },
428
+ onerror: () => {
429
+ if (this.simulationClockPulseSocket !== socket) return;
430
+ this.simulationClockDirectFailed = true;
431
+ },
432
+ });
433
+ this.simulationClockPulseSocket = socket;
434
+ this.simulationClockDirectFailed = false;
435
+ try {
436
+ socket.connect(pulseUrl.toString());
437
+ } catch {
438
+ this.simulationClockPulseSocket = undefined;
439
+ this.simulationClockDirectFailed = true;
440
+ socket.shutdown();
441
+ }
442
+ }
443
+
444
+ private stopSimulationClockPulses() {
445
+ if (this.simulationClockPulseIntervalHandle !== undefined) {
446
+ clearInterval(this.simulationClockPulseIntervalHandle);
447
+ this.simulationClockPulseIntervalHandle = undefined;
448
+ }
449
+ const socket = this.simulationClockPulseSocket;
450
+ this.simulationClockPulseSocket = undefined;
451
+ this.simulationClockDirectFailed = false;
452
+ socket?.shutdown();
453
+ }
454
+
455
+ getSimulationFrameDiagnostics() {
456
+ return {
457
+ pending: this.pendingSimulationFrames.size,
458
+ dropped: this.droppedSimulationFrames,
459
+ pendingReceipts: this.pendingFrameReceipts.size,
460
+ queuedRealtimeInputs: this.queuedRealtimeInputs.length,
461
+ replayRealtimeInputs: this.unconfirmedRealtimeBatches.reduce(
462
+ (count, batch) => count + batch.messages.length,
463
+ 0
464
+ ),
465
+ unconfirmedRealtimeBatches: this.unconfirmedRealtimeBatches.length,
466
+ realtimeBatchesSent: this.realtimeBatchesSent,
467
+ realtimeInputsSent: this.realtimeInputsSent,
468
+ realtimeWatchdogArmed: this.realtimeInputWatchdogHandle !== undefined,
469
+ simulationClockPulsesActive:
470
+ this.simulationClockPulseIntervalHandle !== undefined,
471
+ };
472
+ }
473
+
97
474
  send(message: ClientToServerMessage<State>) {
98
475
  if (this.closedForever) return;
99
476
 
477
+ if (
478
+ message.type === "enqueueInput" &&
479
+ message.transport === "realtime" &&
480
+ this.getRealtimeInputSequence(message)
481
+ ) {
482
+ this.queuedRealtimeInputs.push(message);
483
+ this.armRealtimeInputWatchdog();
484
+ return;
485
+ }
486
+
487
+ this.sendTransportMessage(message);
488
+ }
489
+
490
+ private sendTransportMessage(message: ClientTransportMessage<State>) {
100
491
  if (this.options.debug) {
101
492
  console.info("ws sending message", message);
102
493
  }
@@ -109,10 +500,155 @@ export class WebSocketConnection<State> {
109
500
  this.ws.send(JSON.stringify(message));
110
501
  }
111
502
 
503
+ private getRealtimeInputSequence(message: EnqueueInputMessage) {
504
+ const payload = message.payload;
505
+ if (!payload || typeof payload !== "object") return;
506
+ const streamId = Reflect.get(payload, "streamId");
507
+ const sequence = Reflect.get(payload, "sequence");
508
+ if (
509
+ typeof streamId !== "string" ||
510
+ streamId.length === 0 ||
511
+ !Number.isInteger(sequence) ||
512
+ (sequence as number) < 0
513
+ ) {
514
+ return;
515
+ }
516
+ return {
517
+ key: `${message.queue}\u0000${streamId}`,
518
+ sequence: sequence as number,
519
+ };
520
+ }
521
+
522
+ private getContiguousRealtimeInputCount() {
523
+ const sequences = new Map(this.lastSentRealtimeSequences);
524
+ let count = 0;
525
+ for (const message of this.queuedRealtimeInputs) {
526
+ if (count >= MAX_CLIENT_MESSAGE_BATCH_SIZE) break;
527
+ const input = this.getRealtimeInputSequence(message);
528
+ if (!input) break;
529
+ const previous = sequences.get(input.key);
530
+ if (input.sequence !== (previous === undefined ? 0 : previous + 1)) {
531
+ break;
532
+ }
533
+ sequences.set(input.key, input.sequence);
534
+ count += 1;
535
+ }
536
+ return { count, sequences };
537
+ }
538
+
539
+ private flushRealtimeBatch(receipt?: SimulationFrameReceiptMessage) {
540
+ if (this.closedForever || this.ws.state !== "OPEN") return false;
541
+ const canSendInputBatch =
542
+ this.unconfirmedRealtimeBatches.length <
543
+ MAX_REALTIME_INPUT_BATCHES_IN_FLIGHT;
544
+ const contiguous = this.getContiguousRealtimeInputCount();
545
+ const messages = canSendInputBatch
546
+ ? this.queuedRealtimeInputs.slice(0, contiguous.count)
547
+ : [];
548
+ if (!receipt && messages.length === 0) return false;
549
+
550
+ const batch: ClientMessageBatch = {
551
+ type: "clientMessageBatch",
552
+ ...(receipt && { receipt }),
553
+ ...(messages.length > 0 && {
554
+ inputBatch: {
555
+ sessionId: this.realtimeInputSessionId,
556
+ sequence: this.nextRealtimeInputBatchSequence,
557
+ },
558
+ }),
559
+ messages,
560
+ };
561
+ this.sendTransportMessage(batch);
562
+ if (messages.length > 0) {
563
+ this.queuedRealtimeInputs.splice(0, messages.length);
564
+ this.lastSentRealtimeSequences = contiguous.sequences;
565
+ this.unconfirmedRealtimeBatches.push(batch);
566
+ this.nextRealtimeInputBatchSequence += 1;
567
+ this.realtimeInputsSent += messages.length;
568
+ }
569
+ this.realtimeBatchesSent += 1;
570
+ return true;
571
+ }
572
+
573
+ private flushPendingFrameReceipts() {
574
+ for (const [scriptId, receipt] of Array.from(
575
+ this.pendingFrameReceipts.entries()
576
+ )) {
577
+ if (!this.flushRealtimeBatch(receipt)) continue;
578
+ if (this.pendingFrameReceipts.get(scriptId) === receipt) {
579
+ this.pendingFrameReceipts.delete(scriptId);
580
+ }
581
+ }
582
+ if (this.queuedRealtimeInputs.length > 0) {
583
+ this.armRealtimeInputWatchdog();
584
+ } else {
585
+ this.stopRealtimeInputWatchdog();
586
+ }
587
+ }
588
+
589
+ private armRealtimeInputWatchdog() {
590
+ if (
591
+ this.realtimeInputWatchdogHandle !== undefined ||
592
+ this.queuedRealtimeInputs.length === 0 ||
593
+ this.closedForever ||
594
+ this.ws.state !== "OPEN"
595
+ ) {
596
+ return;
597
+ }
598
+ const configuredDelay = this.options.realtimeInputWatchdogMs;
599
+ const delay =
600
+ configuredDelay !== undefined && Number.isFinite(configuredDelay)
601
+ ? Math.max(1, Math.floor(configuredDelay))
602
+ : 200;
603
+ this.realtimeInputWatchdogHandle = setTimeout(() => {
604
+ this.realtimeInputWatchdogHandle = undefined;
605
+ this.flushRealtimeBatch();
606
+ if (this.queuedRealtimeInputs.length > 0) {
607
+ this.armRealtimeInputWatchdog();
608
+ }
609
+ }, delay);
610
+ }
611
+
612
+ private stopRealtimeInputWatchdog() {
613
+ if (this.realtimeInputWatchdogHandle === undefined) return;
614
+ clearTimeout(this.realtimeInputWatchdogHandle);
615
+ this.realtimeInputWatchdogHandle = undefined;
616
+ }
617
+
618
+ private replayUnconfirmedRealtimeBatches() {
619
+ if (this.closedForever || this.ws.state !== "OPEN") return;
620
+ for (const batch of this.unconfirmedRealtimeBatches) {
621
+ this.sendTransportMessage(batch);
622
+ }
623
+ }
624
+
625
+ private confirmRealtimeInputBatches(sessionId: string, sequence: number) {
626
+ if (
627
+ sessionId !== this.realtimeInputSessionId ||
628
+ !Number.isInteger(sequence) ||
629
+ sequence < 0
630
+ ) {
631
+ return;
632
+ }
633
+ this.unconfirmedRealtimeBatches = this.unconfirmedRealtimeBatches.filter(
634
+ (batch) => !batch.inputBatch || batch.inputBatch.sequence > sequence
635
+ );
636
+ if (this.queuedRealtimeInputs.length > 0) {
637
+ this.armRealtimeInputWatchdog();
638
+ }
639
+ }
640
+
112
641
  close() {
113
642
  if (this.closedForever) return;
114
643
 
115
644
  this.closedForever = true;
645
+ this.stopHeartbeat();
646
+ this.stopSimulationClockPulses();
647
+ this.stopRealtimeInputWatchdog();
648
+ this.queuedRealtimeInputs = [];
649
+ this.unconfirmedRealtimeBatches = [];
650
+ this.lastSentRealtimeSequences.clear();
651
+ this.deliveredSimulationFrameCursors.clear();
116
652
  this.ws.shutdown();
117
653
 
118
654
  // remove all listeners