@antha/multiplayer-p2p-lock-step 0.19.0 → 0.21.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.
@@ -1,6 +1,7 @@
1
1
  import { type ModExecuteParams } from '@antha/engine';
2
2
  import { type ApiAndRoomConnectionState, MultiplayerControllerClientStatusEvent } from '@antha/multiplayer-core';
3
3
  import { type JsonCompatibleValue, type MaybePromise, type PartialWithUndefined, type SelectFrom } from '@augment-vir/common';
4
+ import { type AnyDuration } from 'date-vir';
4
5
  import { MultiplayerControllerFrameEvent, type MultiplayerFramePacket, P2pLockStepMultiplayerController, type P2pLockStepMultiplayerControllerParams } from './p2p-lock-step-multiplayer-controller.js';
5
6
  /**
6
7
  * Engine state added by the p2p-lock-step multiplayer mod.
@@ -39,6 +40,26 @@ export type AnthaMultiplayerP2pLockStepOptions<MultiplayerPacket extends JsonCom
39
40
  frameDuration: true;
40
41
  gameId: true;
41
42
  }>> & PartialWithUndefined<{
43
+ /**
44
+ * Enables automatic desync checks. Every `interval`, each peer hashes its state with
45
+ * `createStateHash` right after applying the same frame, and the host sends its hash to
46
+ * clients with a later frame. A client whose hash doesn't match logs a warning and emits
47
+ * `MultiplayerControllerDesyncEvent` (which is forwarded to the engine). Handle that event
48
+ * to recover from the desync.
49
+ *
50
+ * @default no desync checks
51
+ */
52
+ desyncCheck: {
53
+ interval: AnyDuration;
54
+ /**
55
+ * Hashes the state that must match across peers, for example with `hashObject` from
56
+ * `@antha/util`. Return `undefined` to skip reporting for this check, such as before a
57
+ * joining peer has received its initial state.
58
+ */
59
+ createStateHash: (params: Readonly<{
60
+ state: Partial<State>;
61
+ }>) => MaybePromise<number | undefined>;
62
+ };
42
63
  /** Applies an individual action from within a frame event. */
43
64
  handlePacket: (params: Readonly<{
44
65
  packet: Readonly<MultiplayerFramePacket<MultiplayerPacket>>;
@@ -24,10 +24,13 @@ export function createAnthaMultiplayerP2pLockStepMod(options = {}) {
24
24
  debugMultiplayer: options.debugMultiplayer,
25
25
  multiplayerLockstepTick: 0,
26
26
  },
27
- trigger: options.handlePacket || options.runFrameUpdate || options.handleClientStatus
27
+ trigger: options.handlePacket ||
28
+ options.runFrameUpdate ||
29
+ options.desyncCheck ||
30
+ options.handleClientStatus
28
31
  ? {
29
32
  event: [
30
- ...(options.handlePacket || options.runFrameUpdate
33
+ ...(options.handlePacket || options.runFrameUpdate || options.desyncCheck
31
34
  ? [MultiplayerControllerFrameEvent]
32
35
  : []),
33
36
  ...(options.handleClientStatus
@@ -50,6 +53,7 @@ export function createAnthaMultiplayerP2pLockStepMod(options = {}) {
50
53
  gameId: options.gameId || 'antha',
51
54
  acceptConnection: options.acceptConnection,
52
55
  debugMultiplayer: state.debugMultiplayer,
56
+ desyncCheckInterval: options.desyncCheck?.interval,
53
57
  frameDuration: options.frameDuration,
54
58
  }),
55
59
  connectionState: emptyApiAndRoomConnectionState,
@@ -79,9 +83,10 @@ export function createAnthaMultiplayerP2pLockStepMod(options = {}) {
79
83
  return;
80
84
  }
81
85
  else if (event instanceof MultiplayerControllerFrameEvent &&
82
- (options.handlePacket || options.runFrameUpdate)) {
86
+ (options.handlePacket || options.runFrameUpdate || options.desyncCheck)) {
87
+ state.multiplayerP2pLockStep.multiplayerController.checkStateHash(event);
83
88
  if (options.handlePacket) {
84
- for (const detail of event.detail) {
89
+ for (const detail of event.detail.packets) {
85
90
  await options.handlePacket({
86
91
  packet: detail,
87
92
  multiplayerController: state.multiplayerP2pLockStep.multiplayerController,
@@ -110,6 +115,14 @@ export function createAnthaMultiplayerP2pLockStepMod(options = {}) {
110
115
  multiplayerFrameEvent: event,
111
116
  ticksSinceLastExecute: 1,
112
117
  });
118
+ if (event.detail.shouldReportNextFrameHash) {
119
+ state.multiplayerP2pLockStep.multiplayerController.reportStateHash({
120
+ frameEvent: event,
121
+ stateHash: await options.desyncCheck?.createStateHash({
122
+ state,
123
+ }),
124
+ });
125
+ }
113
126
  }
114
127
  });
115
128
  }
@@ -12,7 +12,7 @@ export declare enum P2pLockStepMessageType {
12
12
  Frame = "frame"
13
13
  }
14
14
  /**
15
- * Data received from {@link MultiplayerControllerFrameEvent}.
15
+ * A single action within a {@link MultiplayerFrame}.
16
16
  *
17
17
  * @category Internal
18
18
  */
@@ -20,6 +20,25 @@ export type MultiplayerFramePacket<MultiplayerPacket extends JsonCompatibleValue
20
20
  packet: MultiplayerPacket;
21
21
  sourceClientId: ClientId;
22
22
  };
23
+ /**
24
+ * Data received from {@link MultiplayerControllerFrameEvent}.
25
+ *
26
+ * @category Internal
27
+ */
28
+ export type MultiplayerFrame<MultiplayerPacket extends JsonCompatibleValue> = {
29
+ packets: ReadonlyArray<MultiplayerFramePacket<MultiplayerPacket>>;
30
+ } & PartialWithUndefined<{
31
+ /**
32
+ * On clients, the host's state hash from right after the most recent check frame. Pass this
33
+ * frame's event to {@link P2pLockStepMultiplayerController.checkStateHash} before applying it.
34
+ */
35
+ hostStateHash: number;
36
+ /**
37
+ * Whether to pass this frame's event and a state hash to
38
+ * {@link P2pLockStepMultiplayerController.reportStateHash} right after applying it.
39
+ */
40
+ shouldReportNextFrameHash: boolean;
41
+ }>;
23
42
  /**
24
43
  * Message exchanged by p2p-lock-step clients.
25
44
  *
@@ -39,7 +58,33 @@ export type P2pLockStepMessage<MultiplayerPacket extends JsonCompatibleValue> =
39
58
  } & PartialWithUndefined<{
40
59
  /** Whether this frame is meant for syncing a new client. */
41
60
  isSynchronizationFrame: boolean;
61
+ /**
62
+ * Whether every peer should hash its state right after applying this frame, for a later
63
+ * desync check.
64
+ */
65
+ shouldReportNextFrameHash: boolean;
66
+ /** The host's state hash from right after the most recent desync check frame. */
67
+ stateHash: number;
42
68
  }>);
69
+ /**
70
+ * Each {@link P2pLockStepMessage} variant, keyed by its message type.
71
+ *
72
+ * @category Internal
73
+ */
74
+ export type P2pLockStepMessageByType<MultiplayerPacket extends JsonCompatibleValue> = {
75
+ [Type in P2pLockStepMessageType]: Readonly<Extract<P2pLockStepMessage<MultiplayerPacket>, {
76
+ type: Type;
77
+ }>>;
78
+ };
79
+ /**
80
+ * Data received from {@link MultiplayerControllerDesyncEvent}.
81
+ *
82
+ * @category Internal
83
+ */
84
+ export type MultiplayerDesync = {
85
+ hostStateHash: number;
86
+ localStateHash: number;
87
+ };
43
88
  /**
44
89
  * Constructor parameters for {@link P2pLockStepMultiplayerController}.
45
90
  *
@@ -62,6 +107,19 @@ export type P2pLockStepMultiplayerControllerParams<Action extends JsonCompatible
62
107
  acceptConnection?: ((connectingClientId: ClientId, multiplayerController: P2pLockStepMultiplayerController<Action>) => MaybePromise<boolean>) | undefined;
63
108
  /** Enables verbose multiplayer debug logs. */
64
109
  debugMultiplayer?: boolean | undefined;
110
+ /**
111
+ * The duration between desync check frames, rounded to a whole number of frames. Ignored when
112
+ * `frameDuration` is zero, because then frames only run manually. Every peer's frame event for
113
+ * a check frame has `shouldReportNextFrameHash` set: pass the state hash from right after
114
+ * applying that frame to {@link P2pLockStepMultiplayerController.reportStateHash}. The host
115
+ * sends its hash with the next frame it produces, and clients compare it against their own hash
116
+ * with {@link P2pLockStepMultiplayerController.checkStateHash} before applying that frame.
117
+ * Frames are never held for a hash: if the host doesn't report one before its next check frame,
118
+ * that check is skipped.
119
+ *
120
+ * @default no desync checks
121
+ */
122
+ desyncCheckInterval?: AnyDuration | undefined;
65
123
  /**
66
124
  * The duration between each frame. This should probably always be smaller than your supported
67
125
  * render frame duration.
@@ -89,15 +147,37 @@ declare const MultiplayerControllerFrameEvent_base: (new (eventInitDict: {
89
147
  * @category Events
90
148
  */
91
149
  export declare class MultiplayerControllerFrameEvent<MultiplayerPacket extends JsonCompatibleValue> extends MultiplayerControllerFrameEvent_base {
92
- detail: ReadonlyArray<MultiplayerFramePacket<MultiplayerPacket>>;
93
- constructor(eventInitDict: TypedCustomEventInit<ReadonlyArray<MultiplayerFramePacket<MultiplayerPacket>>>);
150
+ detail: Readonly<MultiplayerFrame<MultiplayerPacket>>;
151
+ constructor(eventInitDict: TypedCustomEventInit<Readonly<MultiplayerFrame<MultiplayerPacket>>>);
152
+ }
153
+ declare const MultiplayerControllerDesyncEvent_base: (new (eventInitDict: {
154
+ bubbles?: boolean;
155
+ cancelable?: boolean;
156
+ composed?: boolean;
157
+ detail: Readonly<MultiplayerDesync>;
158
+ }) => import("typed-event-target").TypedCustomEvent<Readonly<MultiplayerDesync>, "multiplayer-controller-desync">) & Pick<{
159
+ new (type: string, eventInitDict?: EventInit): Event;
160
+ prototype: Event;
161
+ readonly NONE: 0;
162
+ readonly CAPTURING_PHASE: 1;
163
+ readonly AT_TARGET: 2;
164
+ readonly BUBBLING_PHASE: 3;
165
+ }, "prototype" | "NONE" | "CAPTURING_PHASE" | "AT_TARGET" | "BUBBLING_PHASE"> & Pick<import("typed-event-target").TypedCustomEvent<Readonly<MultiplayerDesync>, "multiplayer-controller-desync">, "type">;
166
+ /**
167
+ * This is fired on a client when its state hash from a check frame does not match the host's. The
168
+ * host's state is no more correct than the client's, so this only says that the two disagree.
169
+ * Nothing else is done about the desync: handle it however your game needs to.
170
+ *
171
+ * @category Events
172
+ */
173
+ export declare class MultiplayerControllerDesyncEvent extends MultiplayerControllerDesyncEvent_base {
94
174
  }
95
175
  /**
96
176
  * All events emitted by this controller.
97
177
  *
98
178
  * @category Internal
99
179
  */
100
- export type AllP2pLockStepMultiplayerControllerEvents<MultiplayerPacket extends JsonCompatibleValue> = MultiplayerControllerFrameEvent<MultiplayerPacket> | MultiplayerControllerRoomListEvent | MultiplayerControllerClientStatusEvent | MultiplayerControllerConnectionEvent;
180
+ export type AllP2pLockStepMultiplayerControllerEvents<MultiplayerPacket extends JsonCompatibleValue> = MultiplayerControllerFrameEvent<MultiplayerPacket> | MultiplayerControllerDesyncEvent | MultiplayerControllerRoomListEvent | MultiplayerControllerClientStatusEvent | MultiplayerControllerConnectionEvent;
101
181
  /**
102
182
  * Listener callback for p2p-lock-step frame events.
103
183
  *
@@ -115,10 +195,12 @@ export declare class P2pLockStepMultiplayerController<MultiplayerPacket extends
115
195
  readonly currentFps: number;
116
196
  /** All events emitted by this controller. */
117
197
  static readonly events: {
198
+ MultiplayerControllerDesyncEvent: typeof MultiplayerControllerDesyncEvent;
118
199
  MultiplayerControllerFrameEvent: typeof MultiplayerControllerFrameEvent;
119
200
  };
120
201
  /** All events emitted by this controller. */
121
202
  readonly events: {
203
+ MultiplayerControllerDesyncEvent: typeof MultiplayerControllerDesyncEvent;
122
204
  MultiplayerControllerFrameEvent: typeof MultiplayerControllerFrameEvent;
123
205
  };
124
206
  static readonly knownErrors: {
@@ -136,6 +218,16 @@ export declare class P2pLockStepMultiplayerController<MultiplayerPacket extends
136
218
  protected timeoutId: ReturnType<typeof globalThis.setTimeout> | undefined;
137
219
  protected frameTickReady: boolean;
138
220
  frameMs: number;
221
+ /** On the host, the number of frames produced. */
222
+ protected producedFrameCount: number;
223
+ /** Number of frames between desync checks, or `undefined` when checks are disabled. */
224
+ protected readonly desyncCheckFrameInterval: number | undefined;
225
+ /** On the host, the most recent check frame event, the only one whose report is still sent. */
226
+ protected latestCheckFrameEvent: MultiplayerControllerFrameEvent<MultiplayerPacket> | undefined;
227
+ /** On the host, the state hash to send with the next frame. */
228
+ protected nextFrameStateHash: number | undefined;
229
+ /** On clients, this client's state hash from the most recent check frame. */
230
+ protected localStateHash: number | undefined;
139
231
  protected joiningRoom: boolean;
140
232
  protected lastFpsCalculation: {
141
233
  timestamp: number;
@@ -204,6 +296,22 @@ export declare class P2pLockStepMultiplayerController<MultiplayerPacket extends
204
296
  getFps(): number;
205
297
  /** Fire an action. This will be sent to all clients in the room so they can process it. */
206
298
  act(actions: MultiplayerPacket | ReadonlyArray<MultiplayerPacket>): void;
299
+ /**
300
+ * Call on every peer right after applying a frame event whose `shouldReportNextFrameHash` is
301
+ * set. The host sends the hash with its next frame, unless a newer check frame has already been
302
+ * sent. Clients keep the hash for {@link P2pLockStepMultiplayerController.checkStateHash}. Pass
303
+ * `undefined` when there's no state to hash yet, which skips this check.
304
+ */
305
+ reportStateHash({ frameEvent, stateHash, }: Readonly<{
306
+ frameEvent: Readonly<MultiplayerControllerFrameEvent<MultiplayerPacket>>;
307
+ stateHash: number | undefined;
308
+ }>): void;
309
+ /**
310
+ * Call on clients right before applying a frame event. When the event carries the host's state
311
+ * hash, this compares it against this client's latest reported hash, then logs a warning and
312
+ * emits {@link MultiplayerControllerDesyncEvent} if they differ.
313
+ */
314
+ checkStateHash(frameEvent: Readonly<MultiplayerControllerFrameEvent<MultiplayerPacket>>): void;
207
315
  /** Detects if this controller is the room host or not. */
208
316
  isHost(): boolean;
209
317
  /** Detects if this controller is connected to a room or not. */
@@ -227,8 +335,28 @@ export declare class P2pLockStepMultiplayerController<MultiplayerPacket extends
227
335
  protected handleNewHost(clientId: ClientId): void;
228
336
  /** Send an empty frame to a newly connected member so it can join the frame flow. */
229
337
  protected syncNewMember(clientId: ClientId): void;
230
- /** Apply received member actions on the host or received frames on member clients. */
231
- protected handleReceivedMessage(sourceClientId: ClientId, message: Readonly<P2pLockStepMessage<MultiplayerPacket>>): void;
338
+ /**
339
+ * Per message type, whether only the host or only member clients handle it and how. Messages
340
+ * received by the wrong side are ignored.
341
+ */
342
+ protected readonly messageHandlers: {
343
+ [Type in P2pLockStepMessageType]: {
344
+ /** Whether only the host should handle this message. */
345
+ isForHost: boolean;
346
+ /** Handles the message. */
347
+ handle: (params: Readonly<{
348
+ message: P2pLockStepMessageByType<MultiplayerPacket>[Type];
349
+ sourceClientId: ClientId;
350
+ }>) => void;
351
+ };
352
+ };
353
+ /**
354
+ * Route a received message to its handler in
355
+ * {@link P2pLockStepMultiplayerController.messageHandlers}.
356
+ */
357
+ protected handleReceivedMessage<Type extends P2pLockStepMessageType>(sourceClientId: ClientId, message: P2pLockStepMessageByType<MultiplayerPacket>[Type]): void;
358
+ /** Forget pending state hashes, such as when frames restart under a new host or room. */
359
+ protected resetDesyncCheck(): void;
232
360
  /** Recalculate the current data-flow FPS from completed frames. */
233
361
  protected calculateFps(): void;
234
362
  /** Complete the current frame and schedule the next automatic frame when configured. */
@@ -22,6 +22,15 @@ export class MultiplayerControllerFrameEvent extends defineTypedCustomEvent()('m
22
22
  super(eventInitDict);
23
23
  }
24
24
  }
25
+ /**
26
+ * This is fired on a client when its state hash from a check frame does not match the host's. The
27
+ * host's state is no more correct than the client's, so this only says that the two disagree.
28
+ * Nothing else is done about the desync: handle it however your game needs to.
29
+ *
30
+ * @category Events
31
+ */
32
+ export class MultiplayerControllerDesyncEvent extends defineTypedCustomEvent()('multiplayer-controller-desync') {
33
+ }
25
34
  const defaultFrameDuration = {
26
35
  milliseconds: 10,
27
36
  };
@@ -36,6 +45,7 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
36
45
  currentFps = 0;
37
46
  /** All events emitted by this controller. */
38
47
  static events = {
48
+ MultiplayerControllerDesyncEvent,
39
49
  MultiplayerControllerFrameEvent,
40
50
  };
41
51
  /** All events emitted by this controller. */
@@ -53,6 +63,16 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
53
63
  timeoutId;
54
64
  frameTickReady = true;
55
65
  frameMs;
66
+ /** On the host, the number of frames produced. */
67
+ producedFrameCount = 0;
68
+ /** Number of frames between desync checks, or `undefined` when checks are disabled. */
69
+ desyncCheckFrameInterval;
70
+ /** On the host, the most recent check frame event, the only one whose report is still sent. */
71
+ latestCheckFrameEvent;
72
+ /** On the host, the state hash to send with the next frame. */
73
+ nextFrameStateHash;
74
+ /** On clients, this client's state hash from the most recent check frame. */
75
+ localStateHash;
56
76
  joiningRoom = false;
57
77
  lastFpsCalculation = {
58
78
  timestamp: 0,
@@ -67,6 +87,12 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
67
87
  this.frameMs = convertDuration(frameDuration, {
68
88
  milliseconds: true,
69
89
  }).milliseconds;
90
+ this.desyncCheckFrameInterval =
91
+ params.desyncCheckInterval && this.frameMs
92
+ ? Math.max(1, Math.round(convertDuration(params.desyncCheckInterval, {
93
+ milliseconds: true,
94
+ }).milliseconds / this.frameMs))
95
+ : undefined;
70
96
  this.roomController = new MultiplayerRoomController({
71
97
  gameId: params.gameId,
72
98
  clientId: this.localClientId,
@@ -203,6 +229,41 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
203
229
  }),
204
230
  ];
205
231
  }
232
+ /**
233
+ * Call on every peer right after applying a frame event whose `shouldReportNextFrameHash` is
234
+ * set. The host sends the hash with its next frame, unless a newer check frame has already been
235
+ * sent. Clients keep the hash for {@link P2pLockStepMultiplayerController.checkStateHash}. Pass
236
+ * `undefined` when there's no state to hash yet, which skips this check.
237
+ */
238
+ reportStateHash({ frameEvent, stateHash, }) {
239
+ if (!this.isHost()) {
240
+ this.localStateHash = stateHash;
241
+ }
242
+ else if (frameEvent === this.latestCheckFrameEvent) {
243
+ this.latestCheckFrameEvent = undefined;
244
+ this.nextFrameStateHash = stateHash;
245
+ }
246
+ }
247
+ /**
248
+ * Call on clients right before applying a frame event. When the event carries the host's state
249
+ * hash, this compares it against this client's latest reported hash, then logs a warning and
250
+ * emits {@link MultiplayerControllerDesyncEvent} if they differ.
251
+ */
252
+ checkStateHash(frameEvent) {
253
+ if (frameEvent.detail.hostStateHash == undefined ||
254
+ this.localStateHash == undefined ||
255
+ frameEvent.detail.hostStateHash === this.localStateHash) {
256
+ return;
257
+ }
258
+ const desync = {
259
+ hostStateHash: frameEvent.detail.hostStateHash,
260
+ localStateHash: this.localStateHash,
261
+ };
262
+ log.warning(`[multiplayer] Desync detected: local state hash ${desync.localStateHash} does not match host state hash ${desync.hostStateHash}.`);
263
+ this.dispatch(new MultiplayerControllerDesyncEvent({
264
+ detail: desync,
265
+ }));
266
+ }
206
267
  /** Detects if this controller is the room host or not. */
207
268
  isHost() {
208
269
  return this.singleplayer || this.roomConnection?.isHost() || false;
@@ -231,6 +292,7 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
231
292
  previousRoomConnection,
232
293
  room,
233
294
  });
295
+ this.resetDesyncCheck();
234
296
  if (previousRoomConnection) {
235
297
  globalThis.clearTimeout(this.timeoutId);
236
298
  this.clientsResponded = {};
@@ -327,6 +389,7 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
327
389
  }
328
390
  globalThis.clearTimeout(this.timeoutId);
329
391
  this.clientsResponded = {};
392
+ this.resetDesyncCheck();
330
393
  this.frameTickReady = true;
331
394
  this.finishFrame();
332
395
  }
@@ -341,49 +404,82 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
341
404
  });
342
405
  }
343
406
  }
344
- /** Apply received member actions on the host or received frames on member clients. */
407
+ /**
408
+ * Per message type, whether only the host or only member clients handle it and how. Messages
409
+ * received by the wrong side are ignored.
410
+ */
411
+ messageHandlers = {
412
+ [P2pLockStepMessageType.Actions]: {
413
+ isForHost: true,
414
+ handle: ({ message, sourceClientId }) => {
415
+ this.debugLog(`host received ${message.actions.length} actions from ${sourceClientId}`);
416
+ this.clientsResponded = {
417
+ ...this.clientsResponded,
418
+ [sourceClientId]: true,
419
+ };
420
+ this.frameActions = [
421
+ ...this.frameActions,
422
+ ...message.actions.map((packet) => {
423
+ return {
424
+ sourceClientId,
425
+ packet,
426
+ };
427
+ }),
428
+ ];
429
+ this.maybeFinishFrame();
430
+ },
431
+ },
432
+ [P2pLockStepMessageType.Frame]: {
433
+ isForHost: false,
434
+ handle: ({ message }) => {
435
+ this.debugLog(`member received frame with ${message.packets.length} actions; sending ${this.frameActions.length} local actions back to host`);
436
+ const currentFrameActions = this.frameActions;
437
+ this.frameActions = [];
438
+ this.roomConnection?.sendMessage({
439
+ actions: currentFrameActions.map(({ packet }) => {
440
+ return packet;
441
+ }),
442
+ sourceClientId: this.clientId,
443
+ type: P2pLockStepMessageType.Actions,
444
+ });
445
+ if (!message.isSynchronizationFrame) {
446
+ this.calculateFps();
447
+ this.dispatch(new MultiplayerControllerFrameEvent({
448
+ detail: {
449
+ packets: message.packets,
450
+ hostStateHash: message.stateHash,
451
+ shouldReportNextFrameHash: message.shouldReportNextFrameHash,
452
+ },
453
+ }));
454
+ }
455
+ },
456
+ },
457
+ };
458
+ /**
459
+ * Route a received message to its handler in
460
+ * {@link P2pLockStepMultiplayerController.messageHandlers}.
461
+ */
345
462
  handleReceivedMessage(sourceClientId, message) {
346
463
  this.debugLog(`received lock-step message from ${sourceClientId}: type=${message.type} host=${this.isHost()}`);
347
464
  if (!this.roomConnection) {
348
465
  this.debugLog('ignored message because no room connection is attached');
349
466
  return;
350
467
  }
351
- if (this.isHost() && message.type === P2pLockStepMessageType.Actions) {
352
- this.debugLog(`host received ${message.actions.length} actions from ${sourceClientId}`);
353
- this.clientsResponded = {
354
- ...this.clientsResponded,
355
- [sourceClientId]: true,
356
- };
357
- this.frameActions = [
358
- ...this.frameActions,
359
- ...message.actions.map((packet) => {
360
- return {
361
- sourceClientId,
362
- packet,
363
- };
364
- }),
365
- ];
366
- this.maybeFinishFrame();
367
- }
368
- else if (!this.isHost() && message.type === P2pLockStepMessageType.Frame) {
369
- this.debugLog(`member received frame with ${message.packets.length} actions; sending ${this.frameActions.length} local actions back to host`);
370
- const currentFrameActions = this.frameActions;
371
- this.frameActions = [];
372
- this.roomConnection.sendMessage({
373
- actions: currentFrameActions.map(({ packet }) => {
374
- return packet;
375
- }),
376
- sourceClientId: this.clientId,
377
- type: P2pLockStepMessageType.Actions,
468
+ /** Indexing by `message.type` directly loses the link between the handler and `message`. */
469
+ const type = message.type;
470
+ if (this.messageHandlers[type].isForHost === this.isHost()) {
471
+ this.messageHandlers[type].handle({
472
+ message,
473
+ sourceClientId,
378
474
  });
379
- if (!message.isSynchronizationFrame) {
380
- this.calculateFps();
381
- this.dispatch(new MultiplayerControllerFrameEvent({
382
- detail: message.packets,
383
- }));
384
- }
385
475
  }
386
476
  }
477
+ /** Forget pending state hashes, such as when frames restart under a new host or room. */
478
+ resetDesyncCheck() {
479
+ this.latestCheckFrameEvent = undefined;
480
+ this.nextFrameStateHash = undefined;
481
+ this.localStateHash = undefined;
482
+ }
387
483
  /** Recalculate the current data-flow FPS from completed frames. */
388
484
  calculateFps() {
389
485
  const now = Date.now();
@@ -405,14 +501,33 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
405
501
  /** Complete the current frame and schedule the next automatic frame when configured. */
406
502
  finishFrame() {
407
503
  const currentFrameActions = this.frameActions;
504
+ const stateHash = this.nextFrameStateHash;
408
505
  this.frameActions = [];
506
+ this.nextFrameStateHash = undefined;
507
+ this.producedFrameCount++;
508
+ const shouldReportNextFrameHash = !!this.desyncCheckFrameInterval &&
509
+ !(this.producedFrameCount % this.desyncCheckFrameInterval) &&
510
+ this.getAllClientIds().length > 1;
409
511
  this.roomConnection?.sendMessage({
410
512
  type: P2pLockStepMessageType.Frame,
411
513
  packets: currentFrameActions,
514
+ ...(shouldReportNextFrameHash && {
515
+ shouldReportNextFrameHash,
516
+ }),
517
+ ...(stateHash != undefined && {
518
+ stateHash,
519
+ }),
412
520
  });
413
- this.dispatch(new MultiplayerControllerFrameEvent({
414
- detail: currentFrameActions,
415
- }));
521
+ const frameEvent = new MultiplayerControllerFrameEvent({
522
+ detail: {
523
+ packets: currentFrameActions,
524
+ shouldReportNextFrameHash,
525
+ },
526
+ });
527
+ if (shouldReportNextFrameHash) {
528
+ this.latestCheckFrameEvent = frameEvent;
529
+ }
530
+ this.dispatch(frameEvent);
416
531
  this.frameTickReady = false;
417
532
  this.calculateFps();
418
533
  if (this.frameMs) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antha/multiplayer-p2p-lock-step",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Multiplayer mod for the Antha engine.",
5
5
  "keywords": [
6
6
  "vir",
@@ -42,7 +42,7 @@
42
42
  "typed-event-target": "^4.3.3"
43
43
  },
44
44
  "devDependencies": {
45
- "@antha/multiplayer-core": "^0.19.0",
45
+ "@antha/multiplayer-core": "^0.21.0",
46
46
  "@augment-vir/test": "^32.3.0",
47
47
  "@web/dev-server-esbuild": "^2.0.0",
48
48
  "@web/test-runner": "^1.0.0",
@@ -50,7 +50,7 @@
50
50
  "istanbul-smart-text-reporter": "^1.1.5"
51
51
  },
52
52
  "peerDependencies": {
53
- "@antha/multiplayer-core": "^0.19.0"
53
+ "@antha/multiplayer-core": "^0.21.0"
54
54
  },
55
55
  "engines": {
56
56
  "node": ">=22"