@antha/multiplayer-p2p-lock-step 0.20.0 → 0.22.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.
@@ -33,12 +34,58 @@ export declare function isMultiplayerRoomConnected({ multiplayerP2pLockStep, }:
33
34
  *
34
35
  * @category Internal
35
36
  */
36
- export type AnthaMultiplayerP2pLockStepOptions<MultiplayerPacket extends JsonCompatibleValue = any, State extends AnthaMultiplayerP2pLockStepState<MultiplayerPacket> = AnthaMultiplayerP2pLockStepState<MultiplayerPacket>> = PartialWithUndefined<SelectFrom<P2pLockStepMultiplayerControllerParams<MultiplayerPacket>, {
37
+ export type AnthaMultiplayerP2pLockStepOptions<MultiplayerPacket extends JsonCompatibleValue = any, State extends AnthaMultiplayerP2pLockStepState<MultiplayerPacket> = AnthaMultiplayerP2pLockStepState<MultiplayerPacket>, StateSync extends JsonCompatibleValue = JsonCompatibleValue> = PartialWithUndefined<SelectFrom<P2pLockStepMultiplayerControllerParams<MultiplayerPacket>, {
37
38
  acceptConnection: true;
38
39
  debugMultiplayer: true;
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
+ };
63
+ /**
64
+ * Sends the host's state to each peer that joins, so games don't need their own sync
65
+ * packets. A joining peer skips every frame until its state is loaded, and desync checks
66
+ * skip it until then too. The host pauses frames while `createStateSync` runs.
67
+ *
68
+ * @default joining peers receive no state
69
+ */
70
+ stateSync: {
71
+ /** Called on the host, right after applying a frame, to capture the state to send. */
72
+ createStateSync: (params: Readonly<{
73
+ state: Partial<State>;
74
+ }>) => MaybePromise<StateSync>;
75
+ /** Called on a joining (or resyncing) peer to replace its state with the host's. */
76
+ loadStateSync: (params: Readonly<{
77
+ stateSync: StateSync;
78
+ multiplayerController: P2pLockStepMultiplayerController<MultiplayerPacket>;
79
+ state: Partial<State>;
80
+ }>) => MaybePromise<void>;
81
+ /**
82
+ * When `desyncCheck` is also set, a peer that detects a desync reloads the host's state
83
+ * (after `MultiplayerControllerDesyncEvent` is emitted).
84
+ *
85
+ * @default desyncs are only reported
86
+ */
87
+ resyncOnDesync?: boolean | undefined;
88
+ };
42
89
  /** Applies an individual action from within a frame event. */
43
90
  handlePacket: (params: Readonly<{
44
91
  packet: Readonly<MultiplayerFramePacket<MultiplayerPacket>>;
@@ -63,4 +110,4 @@ export type AnthaMultiplayerP2pLockStepOptions<MultiplayerPacket extends JsonCom
63
110
  *
64
111
  * @category Main
65
112
  */
66
- export declare function createAnthaMultiplayerP2pLockStepMod<const MultiplayerPacket extends JsonCompatibleValue = any, State extends AnthaMultiplayerP2pLockStepState<MultiplayerPacket> = AnthaMultiplayerP2pLockStepState<MultiplayerPacket>>(options?: Readonly<AnthaMultiplayerP2pLockStepOptions<MultiplayerPacket, NoInfer<State>>>): import("@antha/engine").AnthaMod<NoInfer<State>>;
113
+ export declare function createAnthaMultiplayerP2pLockStepMod<const MultiplayerPacket extends JsonCompatibleValue = any, State extends AnthaMultiplayerP2pLockStepState<MultiplayerPacket> = AnthaMultiplayerP2pLockStepState<MultiplayerPacket>, StateSync extends JsonCompatibleValue = JsonCompatibleValue>(options?: Readonly<AnthaMultiplayerP2pLockStepOptions<MultiplayerPacket, NoInfer<State>, NoInfer<StateSync>>>): import("@antha/engine").AnthaMod<NoInfer<State>>;
@@ -1,7 +1,7 @@
1
1
  import { defineAnthaMod, ModExecutionTriggerType } from '@antha/engine';
2
2
  import { emptyApiAndRoomConnectionState, MultiplayerControllerClientStatusEvent, MultiplayerControllerConnectionEvent, } from '@antha/multiplayer-core';
3
3
  import { awaitedBlockingMap, log, } from '@augment-vir/common';
4
- import { MultiplayerControllerFrameEvent, P2pLockStepMultiplayerController, } from './p2p-lock-step-multiplayer-controller.js';
4
+ import { MultiplayerControllerFrameEvent, MultiplayerControllerStateSyncEvent, P2pLockStepMultiplayerController, } from './p2p-lock-step-multiplayer-controller.js';
5
5
  /**
6
6
  * Indicates whether a p2p-lock-step multiplayer room is currently connected.
7
7
  *
@@ -18,17 +18,28 @@ export function isMultiplayerRoomConnected({ multiplayerP2pLockStep, }) {
18
18
  * @category Main
19
19
  */
20
20
  export function createAnthaMultiplayerP2pLockStepMod(options = {}) {
21
+ const shouldHandleFrames = !!(options.handlePacket ||
22
+ options.runFrameUpdate ||
23
+ options.desyncCheck ||
24
+ options.stateSync);
21
25
  return defineAnthaMod({
22
26
  modName: 'antha-multiplayer-p2p-lock-step',
23
27
  initState: {
24
28
  debugMultiplayer: options.debugMultiplayer,
25
29
  multiplayerLockstepTick: 0,
26
30
  },
27
- trigger: options.handlePacket || options.runFrameUpdate || options.handleClientStatus
31
+ trigger: shouldHandleFrames || options.handleClientStatus
28
32
  ? {
29
33
  event: [
30
- ...(options.handlePacket || options.runFrameUpdate
31
- ? [MultiplayerControllerFrameEvent]
34
+ ...(shouldHandleFrames
35
+ ? [
36
+ MultiplayerControllerFrameEvent,
37
+ ]
38
+ : []),
39
+ ...(options.stateSync
40
+ ? [
41
+ MultiplayerControllerStateSyncEvent,
42
+ ]
32
43
  : []),
33
44
  ...(options.handleClientStatus
34
45
  ? [MultiplayerControllerClientStatusEvent]
@@ -50,7 +61,10 @@ export function createAnthaMultiplayerP2pLockStepMod(options = {}) {
50
61
  gameId: options.gameId || 'antha',
51
62
  acceptConnection: options.acceptConnection,
52
63
  debugMultiplayer: state.debugMultiplayer,
64
+ desyncCheckInterval: options.desyncCheck?.interval,
65
+ enableStateSync: !!options.stateSync,
53
66
  frameDuration: options.frameDuration,
67
+ resyncOnDesync: options.stateSync?.resyncOnDesync,
54
68
  }),
55
69
  connectionState: emptyApiAndRoomConnectionState,
56
70
  };
@@ -78,10 +92,22 @@ export function createAnthaMultiplayerP2pLockStepMod(options = {}) {
78
92
  });
79
93
  return;
80
94
  }
95
+ else if (event instanceof MultiplayerControllerStateSyncEvent) {
96
+ await options.stateSync?.loadStateSync({
97
+ stateSync: event.detail
98
+ .stateSync,
99
+ multiplayerController: state.multiplayerP2pLockStep.multiplayerController,
100
+ state,
101
+ });
102
+ state.multiplayerP2pLockStep.multiplayerController.finishStateSync();
103
+ return;
104
+ }
81
105
  else if (event instanceof MultiplayerControllerFrameEvent &&
82
- (options.handlePacket || options.runFrameUpdate)) {
106
+ shouldHandleFrames &&
107
+ !state.multiplayerP2pLockStep.multiplayerController.awaitingStateSync) {
108
+ state.multiplayerP2pLockStep.multiplayerController.checkStateHash(event);
83
109
  if (options.handlePacket) {
84
- for (const detail of event.detail) {
110
+ for (const detail of event.detail.packets) {
85
111
  await options.handlePacket({
86
112
  packet: detail,
87
113
  multiplayerController: state.multiplayerP2pLockStep.multiplayerController,
@@ -110,6 +136,19 @@ export function createAnthaMultiplayerP2pLockStepMod(options = {}) {
110
136
  multiplayerFrameEvent: event,
111
137
  ticksSinceLastExecute: 1,
112
138
  });
139
+ if (event.detail.shouldReportNextFrameHash) {
140
+ state.multiplayerP2pLockStep.multiplayerController.reportStateHash({
141
+ frameEvent: event,
142
+ stateHash: await options.desyncCheck?.createStateHash({
143
+ state,
144
+ }),
145
+ });
146
+ }
147
+ if (event.detail.shouldSendStateSync && options.stateSync) {
148
+ state.multiplayerP2pLockStep.multiplayerController.sendStateSync(await options.stateSync.createStateSync({
149
+ state,
150
+ }));
151
+ }
113
152
  }
114
153
  });
115
154
  }
@@ -9,10 +9,11 @@ import { ListenTarget, type RemoveListenerCallback, type TypedCustomEventInit }
9
9
  */
10
10
  export declare enum P2pLockStepMessageType {
11
11
  Actions = "actions",
12
- Frame = "frame"
12
+ Frame = "frame",
13
+ StateSyncRequest = "state-sync-request"
13
14
  }
14
15
  /**
15
- * Data received from {@link MultiplayerControllerFrameEvent}.
16
+ * A single action within a {@link MultiplayerFrame}.
16
17
  *
17
18
  * @category Internal
18
19
  */
@@ -20,6 +21,31 @@ export type MultiplayerFramePacket<MultiplayerPacket extends JsonCompatibleValue
20
21
  packet: MultiplayerPacket;
21
22
  sourceClientId: ClientId;
22
23
  };
24
+ /**
25
+ * Data received from {@link MultiplayerControllerFrameEvent}.
26
+ *
27
+ * @category Internal
28
+ */
29
+ export type MultiplayerFrame<MultiplayerPacket extends JsonCompatibleValue> = {
30
+ packets: ReadonlyArray<MultiplayerFramePacket<MultiplayerPacket>>;
31
+ } & PartialWithUndefined<{
32
+ /**
33
+ * On clients, the host's state hash from right after the most recent check frame. Pass this
34
+ * frame's event to {@link P2pLockStepMultiplayerController.checkStateHash} before applying it.
35
+ */
36
+ hostStateHash: number;
37
+ /**
38
+ * Whether to pass this frame's event and a state hash to
39
+ * {@link P2pLockStepMultiplayerController.reportStateHash} right after applying it.
40
+ */
41
+ shouldReportNextFrameHash: boolean;
42
+ /**
43
+ * On the host, whether to pass the state from right after applying this frame to
44
+ * {@link P2pLockStepMultiplayerController.sendStateSync}. The host produces no more frames until
45
+ * it does.
46
+ */
47
+ shouldSendStateSync: boolean;
48
+ }>;
23
49
  /**
24
50
  * Message exchanged by p2p-lock-step clients.
25
51
  *
@@ -39,7 +65,39 @@ export type P2pLockStepMessage<MultiplayerPacket extends JsonCompatibleValue> =
39
65
  } & PartialWithUndefined<{
40
66
  /** Whether this frame is meant for syncing a new client. */
41
67
  isSynchronizationFrame: boolean;
42
- }>);
68
+ /**
69
+ * Whether every peer should hash its state right after applying this frame, for a later
70
+ * desync check.
71
+ */
72
+ shouldReportNextFrameHash: boolean;
73
+ /** The host's state hash from right after the most recent desync check frame. */
74
+ stateHash: number;
75
+ /** On synchronization frames, the host's state for the receiving client to load. */
76
+ stateSync: JsonCompatibleValue;
77
+ }>)
78
+ /** Sent from a child client to the host to ask for the host's current state. */
79
+ | {
80
+ type: P2pLockStepMessageType.StateSyncRequest;
81
+ };
82
+ /**
83
+ * Each {@link P2pLockStepMessage} variant, keyed by its message type.
84
+ *
85
+ * @category Internal
86
+ */
87
+ export type P2pLockStepMessageByType<MultiplayerPacket extends JsonCompatibleValue> = {
88
+ [Type in P2pLockStepMessageType]: Readonly<Extract<P2pLockStepMessage<MultiplayerPacket>, {
89
+ type: Type;
90
+ }>>;
91
+ };
92
+ /**
93
+ * Data received from {@link MultiplayerControllerDesyncEvent}.
94
+ *
95
+ * @category Internal
96
+ */
97
+ export type MultiplayerDesync = {
98
+ hostStateHash: number;
99
+ localStateHash: number;
100
+ };
43
101
  /**
44
102
  * Constructor parameters for {@link P2pLockStepMultiplayerController}.
45
103
  *
@@ -62,6 +120,36 @@ export type P2pLockStepMultiplayerControllerParams<Action extends JsonCompatible
62
120
  acceptConnection?: ((connectingClientId: ClientId, multiplayerController: P2pLockStepMultiplayerController<Action>) => MaybePromise<boolean>) | undefined;
63
121
  /** Enables verbose multiplayer debug logs. */
64
122
  debugMultiplayer?: boolean | undefined;
123
+ /**
124
+ * Sends the host's state to each client that joins. When a client joins, the host's next frame
125
+ * event has `shouldSendStateSync` set, and the host produces no more frames until that state is
126
+ * passed to {@link P2pLockStepMultiplayerController.sendStateSync}. The joining client emits
127
+ * {@link MultiplayerControllerStateSyncEvent} with that state, and ignores every frame before it
128
+ * (see {@link P2pLockStepMultiplayerController.awaitingStateSync}).
129
+ *
130
+ * @default joining clients receive no state
131
+ */
132
+ enableStateSync?: boolean | undefined;
133
+ /**
134
+ * When `enableStateSync` is also set, a client that detects a desync asks the host for its
135
+ * state with {@link P2pLockStepMultiplayerController.requestStateSync}.
136
+ *
137
+ * @default desyncs are only reported
138
+ */
139
+ resyncOnDesync?: boolean | undefined;
140
+ /**
141
+ * The duration between desync check frames, rounded to a whole number of frames. Ignored when
142
+ * `frameDuration` is zero, because then frames only run manually. Every peer's frame event for
143
+ * a check frame has `shouldReportNextFrameHash` set: pass the state hash from right after
144
+ * applying that frame to {@link P2pLockStepMultiplayerController.reportStateHash}. The host
145
+ * sends its hash with the next frame it produces, and clients compare it against their own hash
146
+ * with {@link P2pLockStepMultiplayerController.checkStateHash} before applying that frame.
147
+ * Frames are never held for a hash: if the host doesn't report one before its next check frame,
148
+ * that check is skipped.
149
+ *
150
+ * @default no desync checks
151
+ */
152
+ desyncCheckInterval?: AnyDuration | undefined;
65
153
  /**
66
154
  * The duration between each frame. This should probably always be smaller than your supported
67
155
  * render frame duration.
@@ -89,15 +177,65 @@ declare const MultiplayerControllerFrameEvent_base: (new (eventInitDict: {
89
177
  * @category Events
90
178
  */
91
179
  export declare class MultiplayerControllerFrameEvent<MultiplayerPacket extends JsonCompatibleValue> extends MultiplayerControllerFrameEvent_base {
92
- detail: ReadonlyArray<MultiplayerFramePacket<MultiplayerPacket>>;
93
- constructor(eventInitDict: TypedCustomEventInit<ReadonlyArray<MultiplayerFramePacket<MultiplayerPacket>>>);
180
+ detail: Readonly<MultiplayerFrame<MultiplayerPacket>>;
181
+ constructor(eventInitDict: TypedCustomEventInit<Readonly<MultiplayerFrame<MultiplayerPacket>>>);
182
+ }
183
+ declare const MultiplayerControllerDesyncEvent_base: (new (eventInitDict: {
184
+ bubbles?: boolean;
185
+ cancelable?: boolean;
186
+ composed?: boolean;
187
+ detail: Readonly<MultiplayerDesync>;
188
+ }) => import("typed-event-target").TypedCustomEvent<Readonly<MultiplayerDesync>, "multiplayer-controller-desync">) & Pick<{
189
+ new (type: string, eventInitDict?: EventInit): Event;
190
+ prototype: Event;
191
+ readonly NONE: 0;
192
+ readonly CAPTURING_PHASE: 1;
193
+ readonly AT_TARGET: 2;
194
+ readonly BUBBLING_PHASE: 3;
195
+ }, "prototype" | "NONE" | "CAPTURING_PHASE" | "AT_TARGET" | "BUBBLING_PHASE"> & Pick<import("typed-event-target").TypedCustomEvent<Readonly<MultiplayerDesync>, "multiplayer-controller-desync">, "type">;
196
+ /**
197
+ * This is fired on a client when its state hash from a check frame does not match the host's. The
198
+ * host's state is no more correct than the client's, so this only says that the two disagree.
199
+ * Nothing else is done about the desync: handle it however your game needs to.
200
+ *
201
+ * @category Events
202
+ */
203
+ export declare class MultiplayerControllerDesyncEvent extends MultiplayerControllerDesyncEvent_base {
204
+ }
205
+ declare const MultiplayerControllerStateSyncEvent_base: (new (eventInitDict: {
206
+ bubbles?: boolean;
207
+ cancelable?: boolean;
208
+ composed?: boolean;
209
+ detail: Readonly<{
210
+ stateSync: JsonCompatibleValue;
211
+ }>;
212
+ }) => import("typed-event-target").TypedCustomEvent<Readonly<{
213
+ stateSync: JsonCompatibleValue;
214
+ }>, "multiplayer-controller-state-sync">) & Pick<{
215
+ new (type: string, eventInitDict?: EventInit): Event;
216
+ prototype: Event;
217
+ readonly NONE: 0;
218
+ readonly CAPTURING_PHASE: 1;
219
+ readonly AT_TARGET: 2;
220
+ readonly BUBBLING_PHASE: 3;
221
+ }, "prototype" | "NONE" | "CAPTURING_PHASE" | "AT_TARGET" | "BUBBLING_PHASE"> & Pick<import("typed-event-target").TypedCustomEvent<Readonly<{
222
+ stateSync: JsonCompatibleValue;
223
+ }>, "multiplayer-controller-state-sync">, "type">;
224
+ /**
225
+ * This is fired on a client when it receives the host's state, when it joins or after it calls
226
+ * {@link P2pLockStepMultiplayerController.requestStateSync}. Load the state, then call
227
+ * {@link P2pLockStepMultiplayerController.finishStateSync} before applying any later frame.
228
+ *
229
+ * @category Events
230
+ */
231
+ export declare class MultiplayerControllerStateSyncEvent extends MultiplayerControllerStateSyncEvent_base {
94
232
  }
95
233
  /**
96
234
  * All events emitted by this controller.
97
235
  *
98
236
  * @category Internal
99
237
  */
100
- export type AllP2pLockStepMultiplayerControllerEvents<MultiplayerPacket extends JsonCompatibleValue> = MultiplayerControllerFrameEvent<MultiplayerPacket> | MultiplayerControllerRoomListEvent | MultiplayerControllerClientStatusEvent | MultiplayerControllerConnectionEvent;
238
+ export type AllP2pLockStepMultiplayerControllerEvents<MultiplayerPacket extends JsonCompatibleValue> = MultiplayerControllerFrameEvent<MultiplayerPacket> | MultiplayerControllerDesyncEvent | MultiplayerControllerStateSyncEvent | MultiplayerControllerRoomListEvent | MultiplayerControllerClientStatusEvent | MultiplayerControllerConnectionEvent;
101
239
  /**
102
240
  * Listener callback for p2p-lock-step frame events.
103
241
  *
@@ -115,11 +253,15 @@ export declare class P2pLockStepMultiplayerController<MultiplayerPacket extends
115
253
  readonly currentFps: number;
116
254
  /** All events emitted by this controller. */
117
255
  static readonly events: {
256
+ MultiplayerControllerDesyncEvent: typeof MultiplayerControllerDesyncEvent;
118
257
  MultiplayerControllerFrameEvent: typeof MultiplayerControllerFrameEvent;
258
+ MultiplayerControllerStateSyncEvent: typeof MultiplayerControllerStateSyncEvent;
119
259
  };
120
260
  /** All events emitted by this controller. */
121
261
  readonly events: {
262
+ MultiplayerControllerDesyncEvent: typeof MultiplayerControllerDesyncEvent;
122
263
  MultiplayerControllerFrameEvent: typeof MultiplayerControllerFrameEvent;
264
+ MultiplayerControllerStateSyncEvent: typeof MultiplayerControllerStateSyncEvent;
123
265
  };
124
266
  static readonly knownErrors: {
125
267
  RoomRejectionError: typeof RoomRejectionError;
@@ -136,6 +278,27 @@ export declare class P2pLockStepMultiplayerController<MultiplayerPacket extends
136
278
  protected timeoutId: ReturnType<typeof globalThis.setTimeout> | undefined;
137
279
  protected frameTickReady: boolean;
138
280
  frameMs: number;
281
+ /** On the host, the number of frames produced. */
282
+ protected producedFrameCount: number;
283
+ /** Number of frames between desync checks, or `undefined` when checks are disabled. */
284
+ protected readonly desyncCheckFrameInterval: number | undefined;
285
+ /** On the host, the most recent check frame event, the only one whose report is still sent. */
286
+ protected latestCheckFrameEvent: MultiplayerControllerFrameEvent<MultiplayerPacket> | undefined;
287
+ /** On the host, the state hash to send with the next frame. */
288
+ protected nextFrameStateHash: number | undefined;
289
+ /** On clients, this client's state hash from the most recent check frame. */
290
+ protected localStateHash: number | undefined;
291
+ /** On the host, clients whose state sync will be requested by the next frame. */
292
+ protected stateSyncRequestClientIds: ClientId[];
293
+ /** On the host, clients waiting for {@link P2pLockStepMultiplayerController.sendStateSync}. */
294
+ protected stateSyncFrameClientIds: ClientId[];
295
+ /**
296
+ * Whether this client is waiting for the host's state, after joining a room with
297
+ * `enableStateSync` set or after {@link P2pLockStepMultiplayerController.requestStateSync}.
298
+ * Frame events received while waiting should not be applied: the host's state already includes
299
+ * them. Desync checks are skipped while waiting.
300
+ */
301
+ awaitingStateSync: boolean;
139
302
  protected joiningRoom: boolean;
140
303
  protected lastFpsCalculation: {
141
304
  timestamp: number;
@@ -204,6 +367,38 @@ export declare class P2pLockStepMultiplayerController<MultiplayerPacket extends
204
367
  getFps(): number;
205
368
  /** Fire an action. This will be sent to all clients in the room so they can process it. */
206
369
  act(actions: MultiplayerPacket | ReadonlyArray<MultiplayerPacket>): void;
370
+ /**
371
+ * Call on every peer right after applying a frame event whose `shouldReportNextFrameHash` is
372
+ * set. The host sends the hash with its next frame, unless a newer check frame has already been
373
+ * sent. Clients keep the hash for {@link P2pLockStepMultiplayerController.checkStateHash}. Pass
374
+ * `undefined` when there's no state to hash yet, which skips this check.
375
+ */
376
+ reportStateHash({ frameEvent, stateHash, }: Readonly<{
377
+ frameEvent: Readonly<MultiplayerControllerFrameEvent<MultiplayerPacket>>;
378
+ stateHash: number | undefined;
379
+ }>): void;
380
+ /**
381
+ * Call on clients right before applying a frame event. When the event carries the host's state
382
+ * hash, this compares it against this client's latest reported hash, then logs a warning and
383
+ * emits {@link MultiplayerControllerDesyncEvent} if they differ.
384
+ */
385
+ checkStateHash(frameEvent: Readonly<MultiplayerControllerFrameEvent<MultiplayerPacket>>): void;
386
+ /**
387
+ * On clients, asks the host for its current state, which arrives as a
388
+ * {@link MultiplayerControllerStateSyncEvent}. Requires `enableStateSync`. Does nothing on the
389
+ * host or while already waiting.
390
+ */
391
+ requestStateSync(): void;
392
+ /**
393
+ * Call on clients right after loading the state from a
394
+ * {@link MultiplayerControllerStateSyncEvent}, so that later frames are applied again.
395
+ */
396
+ finishStateSync(): void;
397
+ /**
398
+ * Call on the host right after applying a frame event whose `shouldSendStateSync` is set. Sends
399
+ * the state to every client waiting for it, then resumes frame production.
400
+ */
401
+ sendStateSync(stateSync: JsonCompatibleValue): void;
207
402
  /** Detects if this controller is the room host or not. */
208
403
  isHost(): boolean;
209
404
  /** Detects if this controller is connected to a room or not. */
@@ -225,10 +420,37 @@ export declare class P2pLockStepMultiplayerController<MultiplayerPacket extends
225
420
  protected attachMultiplayerRoomConnection(roomConnection: Readonly<MultiplayerRoomConnection<P2pLockStepMessage<MultiplayerPacket>>>): void;
226
421
  /** Restart frame production if this client is promoted after losing its previous host. */
227
422
  protected handleNewHost(clientId: ClientId): void;
228
- /** Send an empty frame to a newly connected member so it can join the frame flow. */
423
+ /**
424
+ * Send an empty frame to a newly connected member so it can join the frame flow, or queue a
425
+ * state sync for it when `enableStateSync` is set.
426
+ */
229
427
  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;
428
+ /**
429
+ * Per message type, whether only the host or only member clients handle it and how. Messages
430
+ * received by the wrong side are ignored.
431
+ */
432
+ protected readonly messageHandlers: {
433
+ [Type in P2pLockStepMessageType]: {
434
+ /** Whether only the host should handle this message. */
435
+ isForHost: boolean;
436
+ /** Handles the message. */
437
+ handle: (params: Readonly<{
438
+ message: P2pLockStepMessageByType<MultiplayerPacket>[Type];
439
+ sourceClientId: ClientId;
440
+ }>) => void;
441
+ };
442
+ };
443
+ /**
444
+ * Route a received message to its handler in
445
+ * {@link P2pLockStepMultiplayerController.messageHandlers}.
446
+ */
447
+ protected handleReceivedMessage<Type extends P2pLockStepMessageType>(sourceClientId: ClientId, message: P2pLockStepMessageByType<MultiplayerPacket>[Type]): void;
448
+ /** On the host, queue a state sync for a client if it isn't already queued. */
449
+ protected queueStateSync(clientId: ClientId): void;
450
+ /** Forget pending state syncs, such as when frames restart under a new host or room. */
451
+ protected resetStateSync(): void;
452
+ /** Forget pending state hashes, such as when frames restart under a new host or room. */
453
+ protected resetDesyncCheck(): void;
232
454
  /** Recalculate the current data-flow FPS from completed frames. */
233
455
  protected calculateFps(): void;
234
456
  /** Complete the current frame and schedule the next automatic frame when configured. */
@@ -11,6 +11,7 @@ export var P2pLockStepMessageType;
11
11
  (function (P2pLockStepMessageType) {
12
12
  P2pLockStepMessageType["Actions"] = "actions";
13
13
  P2pLockStepMessageType["Frame"] = "frame";
14
+ P2pLockStepMessageType["StateSyncRequest"] = "state-sync-request";
14
15
  })(P2pLockStepMessageType || (P2pLockStepMessageType = {}));
15
16
  /**
16
17
  * This is fired whenever a new p2p-lock-step frame is received from the host client.
@@ -22,6 +23,24 @@ export class MultiplayerControllerFrameEvent extends defineTypedCustomEvent()('m
22
23
  super(eventInitDict);
23
24
  }
24
25
  }
26
+ /**
27
+ * This is fired on a client when its state hash from a check frame does not match the host's. The
28
+ * host's state is no more correct than the client's, so this only says that the two disagree.
29
+ * Nothing else is done about the desync: handle it however your game needs to.
30
+ *
31
+ * @category Events
32
+ */
33
+ export class MultiplayerControllerDesyncEvent extends defineTypedCustomEvent()('multiplayer-controller-desync') {
34
+ }
35
+ /**
36
+ * This is fired on a client when it receives the host's state, when it joins or after it calls
37
+ * {@link P2pLockStepMultiplayerController.requestStateSync}. Load the state, then call
38
+ * {@link P2pLockStepMultiplayerController.finishStateSync} before applying any later frame.
39
+ *
40
+ * @category Events
41
+ */
42
+ export class MultiplayerControllerStateSyncEvent extends defineTypedCustomEvent()('multiplayer-controller-state-sync') {
43
+ }
25
44
  const defaultFrameDuration = {
26
45
  milliseconds: 10,
27
46
  };
@@ -36,7 +55,9 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
36
55
  currentFps = 0;
37
56
  /** All events emitted by this controller. */
38
57
  static events = {
58
+ MultiplayerControllerDesyncEvent,
39
59
  MultiplayerControllerFrameEvent,
60
+ MultiplayerControllerStateSyncEvent,
40
61
  };
41
62
  /** All events emitted by this controller. */
42
63
  events = P2pLockStepMultiplayerController.events;
@@ -53,6 +74,27 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
53
74
  timeoutId;
54
75
  frameTickReady = true;
55
76
  frameMs;
77
+ /** On the host, the number of frames produced. */
78
+ producedFrameCount = 0;
79
+ /** Number of frames between desync checks, or `undefined` when checks are disabled. */
80
+ desyncCheckFrameInterval;
81
+ /** On the host, the most recent check frame event, the only one whose report is still sent. */
82
+ latestCheckFrameEvent;
83
+ /** On the host, the state hash to send with the next frame. */
84
+ nextFrameStateHash;
85
+ /** On clients, this client's state hash from the most recent check frame. */
86
+ localStateHash;
87
+ /** On the host, clients whose state sync will be requested by the next frame. */
88
+ stateSyncRequestClientIds = [];
89
+ /** On the host, clients waiting for {@link P2pLockStepMultiplayerController.sendStateSync}. */
90
+ stateSyncFrameClientIds = [];
91
+ /**
92
+ * Whether this client is waiting for the host's state, after joining a room with
93
+ * `enableStateSync` set or after {@link P2pLockStepMultiplayerController.requestStateSync}.
94
+ * Frame events received while waiting should not be applied: the host's state already includes
95
+ * them. Desync checks are skipped while waiting.
96
+ */
97
+ awaitingStateSync = false;
56
98
  joiningRoom = false;
57
99
  lastFpsCalculation = {
58
100
  timestamp: 0,
@@ -67,6 +109,12 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
67
109
  this.frameMs = convertDuration(frameDuration, {
68
110
  milliseconds: true,
69
111
  }).milliseconds;
112
+ this.desyncCheckFrameInterval =
113
+ params.desyncCheckInterval && this.frameMs
114
+ ? Math.max(1, Math.round(convertDuration(params.desyncCheckInterval, {
115
+ milliseconds: true,
116
+ }).milliseconds / this.frameMs))
117
+ : undefined;
70
118
  this.roomController = new MultiplayerRoomController({
71
119
  gameId: params.gameId,
72
120
  clientId: this.localClientId,
@@ -157,6 +205,7 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
157
205
  throw new Error('Cannot start singleplayer with a connection already present.');
158
206
  }
159
207
  this.debugLog('starting singleplayer connection');
208
+ this.resetStateSync();
160
209
  this.singleplayer = true;
161
210
  this.finishFrame();
162
211
  this.dispatch(new MultiplayerControllerConnectionEvent({
@@ -203,6 +252,94 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
203
252
  }),
204
253
  ];
205
254
  }
255
+ /**
256
+ * Call on every peer right after applying a frame event whose `shouldReportNextFrameHash` is
257
+ * set. The host sends the hash with its next frame, unless a newer check frame has already been
258
+ * sent. Clients keep the hash for {@link P2pLockStepMultiplayerController.checkStateHash}. Pass
259
+ * `undefined` when there's no state to hash yet, which skips this check.
260
+ */
261
+ reportStateHash({ frameEvent, stateHash, }) {
262
+ if (this.awaitingStateSync) {
263
+ return;
264
+ }
265
+ else if (!this.isHost()) {
266
+ this.localStateHash = stateHash;
267
+ }
268
+ else if (frameEvent === this.latestCheckFrameEvent) {
269
+ this.latestCheckFrameEvent = undefined;
270
+ this.nextFrameStateHash = stateHash;
271
+ }
272
+ }
273
+ /**
274
+ * Call on clients right before applying a frame event. When the event carries the host's state
275
+ * hash, this compares it against this client's latest reported hash, then logs a warning and
276
+ * emits {@link MultiplayerControllerDesyncEvent} if they differ.
277
+ */
278
+ checkStateHash(frameEvent) {
279
+ if (this.awaitingStateSync ||
280
+ frameEvent.detail.hostStateHash == undefined ||
281
+ this.localStateHash == undefined ||
282
+ frameEvent.detail.hostStateHash === this.localStateHash) {
283
+ return;
284
+ }
285
+ const desync = {
286
+ hostStateHash: frameEvent.detail.hostStateHash,
287
+ localStateHash: this.localStateHash,
288
+ };
289
+ log.warning(`[multiplayer] Desync detected: local state hash ${desync.localStateHash} does not match host state hash ${desync.hostStateHash}.`);
290
+ this.dispatch(new MultiplayerControllerDesyncEvent({
291
+ detail: desync,
292
+ }));
293
+ if (this.params.resyncOnDesync) {
294
+ this.requestStateSync();
295
+ }
296
+ }
297
+ /**
298
+ * On clients, asks the host for its current state, which arrives as a
299
+ * {@link MultiplayerControllerStateSyncEvent}. Requires `enableStateSync`. Does nothing on the
300
+ * host or while already waiting.
301
+ */
302
+ requestStateSync() {
303
+ if (!this.params.enableStateSync || this.isHost() || this.awaitingStateSync) {
304
+ return;
305
+ }
306
+ this.debugLog('requesting state sync from host');
307
+ this.awaitingStateSync = true;
308
+ this.localStateHash = undefined;
309
+ this.roomConnection?.sendMessage({
310
+ type: P2pLockStepMessageType.StateSyncRequest,
311
+ });
312
+ }
313
+ /**
314
+ * Call on clients right after loading the state from a
315
+ * {@link MultiplayerControllerStateSyncEvent}, so that later frames are applied again.
316
+ */
317
+ finishStateSync() {
318
+ this.awaitingStateSync = false;
319
+ this.localStateHash = undefined;
320
+ }
321
+ /**
322
+ * Call on the host right after applying a frame event whose `shouldSendStateSync` is set. Sends
323
+ * the state to every client waiting for it, then resumes frame production.
324
+ */
325
+ sendStateSync(stateSync) {
326
+ const connectedClientIds = this.getConnectedClientIds();
327
+ this.stateSyncFrameClientIds
328
+ .filter((clientId) => {
329
+ return connectedClientIds.includes(clientId);
330
+ })
331
+ .forEach((clientId) => {
332
+ this.debugLog(`sending state sync to ${clientId}`);
333
+ this.roomConnection?.sendToOnlyOneClient(clientId, {
334
+ type: P2pLockStepMessageType.Frame,
335
+ packets: [],
336
+ isSynchronizationFrame: true,
337
+ stateSync,
338
+ });
339
+ });
340
+ this.stateSyncFrameClientIds = [];
341
+ this.maybeFinishFrame();
342
+ }
206
343
  /** Detects if this controller is the room host or not. */
207
344
  isHost() {
208
345
  return this.singleplayer || this.roomConnection?.isHost() || false;
@@ -231,6 +368,8 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
231
368
  previousRoomConnection,
232
369
  room,
233
370
  });
371
+ this.resetDesyncCheck();
372
+ this.resetStateSync();
234
373
  if (previousRoomConnection) {
235
374
  globalThis.clearTimeout(this.timeoutId);
236
375
  this.clientsResponded = {};
@@ -242,6 +381,7 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
242
381
  this.frameTickReady = true;
243
382
  }
244
383
  this.singleplayer = false;
384
+ this.awaitingStateSync = !!this.params.enableStateSync && !roomConnection.isHost();
245
385
  this.attachMultiplayerRoomConnection(roomConnection);
246
386
  this.debugLog(`attached p2p-lock-step connection; client=${this.getClientId() || 'unknown'} host=${this.isHost()} connected=${this.isConnected()}`);
247
387
  }
@@ -273,6 +413,7 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
273
413
  }
274
414
  this.debugLog(`leaving room '${this.roomId || 'unknown'}'`);
275
415
  globalThis.clearTimeout(this.timeoutId);
416
+ this.resetStateSync();
276
417
  this.roomConnection = undefined;
277
418
  this.singleplayer = false;
278
419
  this.roomController.leaveRoom();
@@ -327,13 +468,21 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
327
468
  }
328
469
  globalThis.clearTimeout(this.timeoutId);
329
470
  this.clientsResponded = {};
471
+ this.resetDesyncCheck();
472
+ this.resetStateSync();
330
473
  this.frameTickReady = true;
331
474
  this.finishFrame();
332
475
  }
333
- /** Send an empty frame to a newly connected member so it can join the frame flow. */
476
+ /**
477
+ * Send an empty frame to a newly connected member so it can join the frame flow, or queue a
478
+ * state sync for it when `enableStateSync` is set.
479
+ */
334
480
  syncNewMember(clientId) {
335
481
  this.debugLog(`syncNewMember called for ${clientId}; host=${this.isHost()}`);
336
- if (this.roomConnection && this.isHost()) {
482
+ if (this.params.enableStateSync) {
483
+ this.queueStateSync(clientId);
484
+ }
485
+ else if (this.roomConnection && this.isHost()) {
337
486
  this.roomConnection.sendToOnlyOneClient(clientId, {
338
487
  type: P2pLockStepMessageType.Frame,
339
488
  packets: [],
@@ -341,49 +490,114 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
341
490
  });
342
491
  }
343
492
  }
344
- /** Apply received member actions on the host or received frames on member clients. */
493
+ /**
494
+ * Per message type, whether only the host or only member clients handle it and how. Messages
495
+ * received by the wrong side are ignored.
496
+ */
497
+ messageHandlers = {
498
+ [P2pLockStepMessageType.Actions]: {
499
+ isForHost: true,
500
+ handle: ({ message, sourceClientId }) => {
501
+ this.debugLog(`host received ${message.actions.length} actions from ${sourceClientId}`);
502
+ this.clientsResponded = {
503
+ ...this.clientsResponded,
504
+ [sourceClientId]: true,
505
+ };
506
+ this.frameActions = [
507
+ ...this.frameActions,
508
+ ...message.actions.map((packet) => {
509
+ return {
510
+ sourceClientId,
511
+ packet,
512
+ };
513
+ }),
514
+ ];
515
+ this.maybeFinishFrame();
516
+ },
517
+ },
518
+ [P2pLockStepMessageType.Frame]: {
519
+ isForHost: false,
520
+ handle: ({ message }) => {
521
+ this.debugLog(`member received frame with ${message.packets.length} actions; sending ${this.frameActions.length} local actions back to host`);
522
+ const currentFrameActions = this.frameActions;
523
+ this.frameActions = [];
524
+ this.roomConnection?.sendMessage({
525
+ actions: currentFrameActions.map(({ packet }) => {
526
+ return packet;
527
+ }),
528
+ sourceClientId: this.clientId,
529
+ type: P2pLockStepMessageType.Actions,
530
+ });
531
+ if (message.stateSync !== undefined) {
532
+ this.dispatch(new MultiplayerControllerStateSyncEvent({
533
+ detail: {
534
+ stateSync: message.stateSync,
535
+ },
536
+ }));
537
+ }
538
+ else if (!message.isSynchronizationFrame) {
539
+ this.calculateFps();
540
+ this.dispatch(new MultiplayerControllerFrameEvent({
541
+ detail: {
542
+ packets: message.packets,
543
+ hostStateHash: message.stateHash,
544
+ shouldReportNextFrameHash: message.shouldReportNextFrameHash,
545
+ },
546
+ }));
547
+ }
548
+ },
549
+ },
550
+ [P2pLockStepMessageType.StateSyncRequest]: {
551
+ isForHost: true,
552
+ handle: ({ sourceClientId }) => {
553
+ this.queueStateSync(sourceClientId);
554
+ },
555
+ },
556
+ };
557
+ /**
558
+ * Route a received message to its handler in
559
+ * {@link P2pLockStepMultiplayerController.messageHandlers}.
560
+ */
345
561
  handleReceivedMessage(sourceClientId, message) {
346
562
  this.debugLog(`received lock-step message from ${sourceClientId}: type=${message.type} host=${this.isHost()}`);
347
563
  if (!this.roomConnection) {
348
564
  this.debugLog('ignored message because no room connection is attached');
349
565
  return;
350
566
  }
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,
567
+ /** Indexing by `message.type` directly loses the link between the handler and `message`. */
568
+ const type = message.type;
569
+ if (this.messageHandlers[type].isForHost === this.isHost()) {
570
+ this.messageHandlers[type].handle({
571
+ message,
572
+ sourceClientId,
378
573
  });
379
- if (!message.isSynchronizationFrame) {
380
- this.calculateFps();
381
- this.dispatch(new MultiplayerControllerFrameEvent({
382
- detail: message.packets,
383
- }));
384
- }
385
574
  }
386
575
  }
576
+ /** On the host, queue a state sync for a client if it isn't already queued. */
577
+ queueStateSync(clientId) {
578
+ if (!this.isHost() ||
579
+ this.stateSyncRequestClientIds.includes(clientId) ||
580
+ this.stateSyncFrameClientIds.includes(clientId)) {
581
+ return;
582
+ }
583
+ this.debugLog(`queueing state sync for ${clientId}`);
584
+ this.stateSyncRequestClientIds = [
585
+ ...this.stateSyncRequestClientIds,
586
+ clientId,
587
+ ];
588
+ }
589
+ /** Forget pending state syncs, such as when frames restart under a new host or room. */
590
+ resetStateSync() {
591
+ this.stateSyncRequestClientIds = [];
592
+ this.stateSyncFrameClientIds = [];
593
+ this.awaitingStateSync = false;
594
+ }
595
+ /** Forget pending state hashes, such as when frames restart under a new host or room. */
596
+ resetDesyncCheck() {
597
+ this.latestCheckFrameEvent = undefined;
598
+ this.nextFrameStateHash = undefined;
599
+ this.localStateHash = undefined;
600
+ }
387
601
  /** Recalculate the current data-flow FPS from completed frames. */
388
602
  calculateFps() {
389
603
  const now = Date.now();
@@ -405,14 +619,42 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
405
619
  /** Complete the current frame and schedule the next automatic frame when configured. */
406
620
  finishFrame() {
407
621
  const currentFrameActions = this.frameActions;
622
+ const stateHash = this.nextFrameStateHash;
408
623
  this.frameActions = [];
624
+ this.nextFrameStateHash = undefined;
625
+ this.producedFrameCount++;
626
+ const shouldReportNextFrameHash = !!this.desyncCheckFrameInterval &&
627
+ !(this.producedFrameCount % this.desyncCheckFrameInterval) &&
628
+ this.getAllClientIds().length > 1;
629
+ const shouldSendStateSync = !!this.stateSyncRequestClientIds.length;
630
+ this.stateSyncFrameClientIds = [
631
+ ...this.stateSyncFrameClientIds,
632
+ ...this.stateSyncRequestClientIds,
633
+ ];
634
+ this.stateSyncRequestClientIds = [];
409
635
  this.roomConnection?.sendMessage({
410
636
  type: P2pLockStepMessageType.Frame,
411
637
  packets: currentFrameActions,
638
+ ...(shouldReportNextFrameHash && {
639
+ shouldReportNextFrameHash,
640
+ }),
641
+ ...(stateHash != undefined && {
642
+ stateHash,
643
+ }),
412
644
  });
413
- this.dispatch(new MultiplayerControllerFrameEvent({
414
- detail: currentFrameActions,
415
- }));
645
+ const frameEvent = new MultiplayerControllerFrameEvent({
646
+ detail: {
647
+ packets: currentFrameActions,
648
+ shouldReportNextFrameHash,
649
+ ...(shouldSendStateSync && {
650
+ shouldSendStateSync,
651
+ }),
652
+ },
653
+ });
654
+ if (shouldReportNextFrameHash) {
655
+ this.latestCheckFrameEvent = frameEvent;
656
+ }
657
+ this.dispatch(frameEvent);
416
658
  this.frameTickReady = false;
417
659
  this.calculateFps();
418
660
  if (this.frameMs) {
@@ -432,8 +674,8 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
432
674
  this.roomConnection?.getConnectedClientIds().every((clientId) => {
433
675
  return this.clientsResponded[clientId];
434
676
  });
435
- if (!this.frameTickReady || !clientsReady) {
436
- this.debugLog(`maybeFinishFrame waiting: frameTickReady=${this.frameTickReady} clientsReady=${!!clientsReady}`);
677
+ if (!this.frameTickReady || !clientsReady || this.stateSyncFrameClientIds.length) {
678
+ this.debugLog(`maybeFinishFrame waiting: frameTickReady=${this.frameTickReady} clientsReady=${!!clientsReady} stateSyncs=${this.stateSyncFrameClientIds.length}`);
437
679
  return;
438
680
  }
439
681
  this.finishFrame();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antha/multiplayer-p2p-lock-step",
3
- "version": "0.20.0",
3
+ "version": "0.22.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.20.0",
45
+ "@antha/multiplayer-core": "^0.22.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.20.0"
53
+ "@antha/multiplayer-core": "^0.22.0"
54
54
  },
55
55
  "engines": {
56
56
  "node": ">=22"