@antha/multiplayer-p2p-lock-step 0.21.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.
@@ -34,7 +34,7 @@ export declare function isMultiplayerRoomConnected({ multiplayerP2pLockStep, }:
34
34
  *
35
35
  * @category Internal
36
36
  */
37
- 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>, {
38
38
  acceptConnection: true;
39
39
  debugMultiplayer: true;
40
40
  frameDuration: true;
@@ -60,6 +60,32 @@ export type AnthaMultiplayerP2pLockStepOptions<MultiplayerPacket extends JsonCom
60
60
  state: Partial<State>;
61
61
  }>) => MaybePromise<number | undefined>;
62
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
+ };
63
89
  /** Applies an individual action from within a frame event. */
64
90
  handlePacket: (params: Readonly<{
65
91
  packet: Readonly<MultiplayerFramePacket<MultiplayerPacket>>;
@@ -84,4 +110,4 @@ export type AnthaMultiplayerP2pLockStepOptions<MultiplayerPacket extends JsonCom
84
110
  *
85
111
  * @category Main
86
112
  */
87
- 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,20 +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 ||
28
- options.runFrameUpdate ||
29
- options.desyncCheck ||
30
- options.handleClientStatus
31
+ trigger: shouldHandleFrames || options.handleClientStatus
31
32
  ? {
32
33
  event: [
33
- ...(options.handlePacket || options.runFrameUpdate || options.desyncCheck
34
- ? [MultiplayerControllerFrameEvent]
34
+ ...(shouldHandleFrames
35
+ ? [
36
+ MultiplayerControllerFrameEvent,
37
+ ]
38
+ : []),
39
+ ...(options.stateSync
40
+ ? [
41
+ MultiplayerControllerStateSyncEvent,
42
+ ]
35
43
  : []),
36
44
  ...(options.handleClientStatus
37
45
  ? [MultiplayerControllerClientStatusEvent]
@@ -54,7 +62,9 @@ export function createAnthaMultiplayerP2pLockStepMod(options = {}) {
54
62
  acceptConnection: options.acceptConnection,
55
63
  debugMultiplayer: state.debugMultiplayer,
56
64
  desyncCheckInterval: options.desyncCheck?.interval,
65
+ enableStateSync: !!options.stateSync,
57
66
  frameDuration: options.frameDuration,
67
+ resyncOnDesync: options.stateSync?.resyncOnDesync,
58
68
  }),
59
69
  connectionState: emptyApiAndRoomConnectionState,
60
70
  };
@@ -82,8 +92,19 @@ export function createAnthaMultiplayerP2pLockStepMod(options = {}) {
82
92
  });
83
93
  return;
84
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
+ }
85
105
  else if (event instanceof MultiplayerControllerFrameEvent &&
86
- (options.handlePacket || options.runFrameUpdate || options.desyncCheck)) {
106
+ shouldHandleFrames &&
107
+ !state.multiplayerP2pLockStep.multiplayerController.awaitingStateSync) {
87
108
  state.multiplayerP2pLockStep.multiplayerController.checkStateHash(event);
88
109
  if (options.handlePacket) {
89
110
  for (const detail of event.detail.packets) {
@@ -123,6 +144,11 @@ export function createAnthaMultiplayerP2pLockStepMod(options = {}) {
123
144
  }),
124
145
  });
125
146
  }
147
+ if (event.detail.shouldSendStateSync && options.stateSync) {
148
+ state.multiplayerP2pLockStep.multiplayerController.sendStateSync(await options.stateSync.createStateSync({
149
+ state,
150
+ }));
151
+ }
126
152
  }
127
153
  });
128
154
  }
@@ -9,7 +9,8 @@ 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
16
  * A single action within a {@link MultiplayerFrame}.
@@ -38,6 +39,12 @@ export type MultiplayerFrame<MultiplayerPacket extends JsonCompatibleValue> = {
38
39
  * {@link P2pLockStepMultiplayerController.reportStateHash} right after applying it.
39
40
  */
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;
41
48
  }>;
42
49
  /**
43
50
  * Message exchanged by p2p-lock-step clients.
@@ -65,7 +72,13 @@ export type P2pLockStepMessage<MultiplayerPacket extends JsonCompatibleValue> =
65
72
  shouldReportNextFrameHash: boolean;
66
73
  /** The host's state hash from right after the most recent desync check frame. */
67
74
  stateHash: number;
68
- }>);
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
+ };
69
82
  /**
70
83
  * Each {@link P2pLockStepMessage} variant, keyed by its message type.
71
84
  *
@@ -107,6 +120,23 @@ export type P2pLockStepMultiplayerControllerParams<Action extends JsonCompatible
107
120
  acceptConnection?: ((connectingClientId: ClientId, multiplayerController: P2pLockStepMultiplayerController<Action>) => MaybePromise<boolean>) | undefined;
108
121
  /** Enables verbose multiplayer debug logs. */
109
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;
110
140
  /**
111
141
  * The duration between desync check frames, rounded to a whole number of frames. Ignored when
112
142
  * `frameDuration` is zero, because then frames only run manually. Every peer's frame event for
@@ -172,12 +202,40 @@ declare const MultiplayerControllerDesyncEvent_base: (new (eventInitDict: {
172
202
  */
173
203
  export declare class MultiplayerControllerDesyncEvent extends MultiplayerControllerDesyncEvent_base {
174
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 {
232
+ }
175
233
  /**
176
234
  * All events emitted by this controller.
177
235
  *
178
236
  * @category Internal
179
237
  */
180
- export type AllP2pLockStepMultiplayerControllerEvents<MultiplayerPacket extends JsonCompatibleValue> = MultiplayerControllerFrameEvent<MultiplayerPacket> | MultiplayerControllerDesyncEvent | MultiplayerControllerRoomListEvent | MultiplayerControllerClientStatusEvent | MultiplayerControllerConnectionEvent;
238
+ export type AllP2pLockStepMultiplayerControllerEvents<MultiplayerPacket extends JsonCompatibleValue> = MultiplayerControllerFrameEvent<MultiplayerPacket> | MultiplayerControllerDesyncEvent | MultiplayerControllerStateSyncEvent | MultiplayerControllerRoomListEvent | MultiplayerControllerClientStatusEvent | MultiplayerControllerConnectionEvent;
181
239
  /**
182
240
  * Listener callback for p2p-lock-step frame events.
183
241
  *
@@ -197,11 +255,13 @@ export declare class P2pLockStepMultiplayerController<MultiplayerPacket extends
197
255
  static readonly events: {
198
256
  MultiplayerControllerDesyncEvent: typeof MultiplayerControllerDesyncEvent;
199
257
  MultiplayerControllerFrameEvent: typeof MultiplayerControllerFrameEvent;
258
+ MultiplayerControllerStateSyncEvent: typeof MultiplayerControllerStateSyncEvent;
200
259
  };
201
260
  /** All events emitted by this controller. */
202
261
  readonly events: {
203
262
  MultiplayerControllerDesyncEvent: typeof MultiplayerControllerDesyncEvent;
204
263
  MultiplayerControllerFrameEvent: typeof MultiplayerControllerFrameEvent;
264
+ MultiplayerControllerStateSyncEvent: typeof MultiplayerControllerStateSyncEvent;
205
265
  };
206
266
  static readonly knownErrors: {
207
267
  RoomRejectionError: typeof RoomRejectionError;
@@ -228,6 +288,17 @@ export declare class P2pLockStepMultiplayerController<MultiplayerPacket extends
228
288
  protected nextFrameStateHash: number | undefined;
229
289
  /** On clients, this client's state hash from the most recent check frame. */
230
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;
231
302
  protected joiningRoom: boolean;
232
303
  protected lastFpsCalculation: {
233
304
  timestamp: number;
@@ -312,6 +383,22 @@ export declare class P2pLockStepMultiplayerController<MultiplayerPacket extends
312
383
  * emits {@link MultiplayerControllerDesyncEvent} if they differ.
313
384
  */
314
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;
315
402
  /** Detects if this controller is the room host or not. */
316
403
  isHost(): boolean;
317
404
  /** Detects if this controller is connected to a room or not. */
@@ -333,7 +420,10 @@ export declare class P2pLockStepMultiplayerController<MultiplayerPacket extends
333
420
  protected attachMultiplayerRoomConnection(roomConnection: Readonly<MultiplayerRoomConnection<P2pLockStepMessage<MultiplayerPacket>>>): void;
334
421
  /** Restart frame production if this client is promoted after losing its previous host. */
335
422
  protected handleNewHost(clientId: ClientId): void;
336
- /** 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
+ */
337
427
  protected syncNewMember(clientId: ClientId): void;
338
428
  /**
339
429
  * Per message type, whether only the host or only member clients handle it and how. Messages
@@ -355,6 +445,10 @@ export declare class P2pLockStepMultiplayerController<MultiplayerPacket extends
355
445
  * {@link P2pLockStepMultiplayerController.messageHandlers}.
356
446
  */
357
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;
358
452
  /** Forget pending state hashes, such as when frames restart under a new host or room. */
359
453
  protected resetDesyncCheck(): void;
360
454
  /** Recalculate the current data-flow FPS from completed frames. */
@@ -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.
@@ -31,6 +32,15 @@ export class MultiplayerControllerFrameEvent extends defineTypedCustomEvent()('m
31
32
  */
32
33
  export class MultiplayerControllerDesyncEvent extends defineTypedCustomEvent()('multiplayer-controller-desync') {
33
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
+ }
34
44
  const defaultFrameDuration = {
35
45
  milliseconds: 10,
36
46
  };
@@ -47,6 +57,7 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
47
57
  static events = {
48
58
  MultiplayerControllerDesyncEvent,
49
59
  MultiplayerControllerFrameEvent,
60
+ MultiplayerControllerStateSyncEvent,
50
61
  };
51
62
  /** All events emitted by this controller. */
52
63
  events = P2pLockStepMultiplayerController.events;
@@ -73,6 +84,17 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
73
84
  nextFrameStateHash;
74
85
  /** On clients, this client's state hash from the most recent check frame. */
75
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;
76
98
  joiningRoom = false;
77
99
  lastFpsCalculation = {
78
100
  timestamp: 0,
@@ -183,6 +205,7 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
183
205
  throw new Error('Cannot start singleplayer with a connection already present.');
184
206
  }
185
207
  this.debugLog('starting singleplayer connection');
208
+ this.resetStateSync();
186
209
  this.singleplayer = true;
187
210
  this.finishFrame();
188
211
  this.dispatch(new MultiplayerControllerConnectionEvent({
@@ -236,7 +259,10 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
236
259
  * `undefined` when there's no state to hash yet, which skips this check.
237
260
  */
238
261
  reportStateHash({ frameEvent, stateHash, }) {
239
- if (!this.isHost()) {
262
+ if (this.awaitingStateSync) {
263
+ return;
264
+ }
265
+ else if (!this.isHost()) {
240
266
  this.localStateHash = stateHash;
241
267
  }
242
268
  else if (frameEvent === this.latestCheckFrameEvent) {
@@ -250,7 +276,8 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
250
276
  * emits {@link MultiplayerControllerDesyncEvent} if they differ.
251
277
  */
252
278
  checkStateHash(frameEvent) {
253
- if (frameEvent.detail.hostStateHash == undefined ||
279
+ if (this.awaitingStateSync ||
280
+ frameEvent.detail.hostStateHash == undefined ||
254
281
  this.localStateHash == undefined ||
255
282
  frameEvent.detail.hostStateHash === this.localStateHash) {
256
283
  return;
@@ -263,6 +290,55 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
263
290
  this.dispatch(new MultiplayerControllerDesyncEvent({
264
291
  detail: desync,
265
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();
266
342
  }
267
343
  /** Detects if this controller is the room host or not. */
268
344
  isHost() {
@@ -293,6 +369,7 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
293
369
  room,
294
370
  });
295
371
  this.resetDesyncCheck();
372
+ this.resetStateSync();
296
373
  if (previousRoomConnection) {
297
374
  globalThis.clearTimeout(this.timeoutId);
298
375
  this.clientsResponded = {};
@@ -304,6 +381,7 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
304
381
  this.frameTickReady = true;
305
382
  }
306
383
  this.singleplayer = false;
384
+ this.awaitingStateSync = !!this.params.enableStateSync && !roomConnection.isHost();
307
385
  this.attachMultiplayerRoomConnection(roomConnection);
308
386
  this.debugLog(`attached p2p-lock-step connection; client=${this.getClientId() || 'unknown'} host=${this.isHost()} connected=${this.isConnected()}`);
309
387
  }
@@ -335,6 +413,7 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
335
413
  }
336
414
  this.debugLog(`leaving room '${this.roomId || 'unknown'}'`);
337
415
  globalThis.clearTimeout(this.timeoutId);
416
+ this.resetStateSync();
338
417
  this.roomConnection = undefined;
339
418
  this.singleplayer = false;
340
419
  this.roomController.leaveRoom();
@@ -390,13 +469,20 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
390
469
  globalThis.clearTimeout(this.timeoutId);
391
470
  this.clientsResponded = {};
392
471
  this.resetDesyncCheck();
472
+ this.resetStateSync();
393
473
  this.frameTickReady = true;
394
474
  this.finishFrame();
395
475
  }
396
- /** 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
+ */
397
480
  syncNewMember(clientId) {
398
481
  this.debugLog(`syncNewMember called for ${clientId}; host=${this.isHost()}`);
399
- if (this.roomConnection && this.isHost()) {
482
+ if (this.params.enableStateSync) {
483
+ this.queueStateSync(clientId);
484
+ }
485
+ else if (this.roomConnection && this.isHost()) {
400
486
  this.roomConnection.sendToOnlyOneClient(clientId, {
401
487
  type: P2pLockStepMessageType.Frame,
402
488
  packets: [],
@@ -442,7 +528,14 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
442
528
  sourceClientId: this.clientId,
443
529
  type: P2pLockStepMessageType.Actions,
444
530
  });
445
- if (!message.isSynchronizationFrame) {
531
+ if (message.stateSync !== undefined) {
532
+ this.dispatch(new MultiplayerControllerStateSyncEvent({
533
+ detail: {
534
+ stateSync: message.stateSync,
535
+ },
536
+ }));
537
+ }
538
+ else if (!message.isSynchronizationFrame) {
446
539
  this.calculateFps();
447
540
  this.dispatch(new MultiplayerControllerFrameEvent({
448
541
  detail: {
@@ -454,6 +547,12 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
454
547
  }
455
548
  },
456
549
  },
550
+ [P2pLockStepMessageType.StateSyncRequest]: {
551
+ isForHost: true,
552
+ handle: ({ sourceClientId }) => {
553
+ this.queueStateSync(sourceClientId);
554
+ },
555
+ },
457
556
  };
458
557
  /**
459
558
  * Route a received message to its handler in
@@ -474,6 +573,25 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
474
573
  });
475
574
  }
476
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
+ }
477
595
  /** Forget pending state hashes, such as when frames restart under a new host or room. */
478
596
  resetDesyncCheck() {
479
597
  this.latestCheckFrameEvent = undefined;
@@ -508,6 +626,12 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
508
626
  const shouldReportNextFrameHash = !!this.desyncCheckFrameInterval &&
509
627
  !(this.producedFrameCount % this.desyncCheckFrameInterval) &&
510
628
  this.getAllClientIds().length > 1;
629
+ const shouldSendStateSync = !!this.stateSyncRequestClientIds.length;
630
+ this.stateSyncFrameClientIds = [
631
+ ...this.stateSyncFrameClientIds,
632
+ ...this.stateSyncRequestClientIds,
633
+ ];
634
+ this.stateSyncRequestClientIds = [];
511
635
  this.roomConnection?.sendMessage({
512
636
  type: P2pLockStepMessageType.Frame,
513
637
  packets: currentFrameActions,
@@ -522,6 +646,9 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
522
646
  detail: {
523
647
  packets: currentFrameActions,
524
648
  shouldReportNextFrameHash,
649
+ ...(shouldSendStateSync && {
650
+ shouldSendStateSync,
651
+ }),
525
652
  },
526
653
  });
527
654
  if (shouldReportNextFrameHash) {
@@ -547,8 +674,8 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
547
674
  this.roomConnection?.getConnectedClientIds().every((clientId) => {
548
675
  return this.clientsResponded[clientId];
549
676
  });
550
- if (!this.frameTickReady || !clientsReady) {
551
- 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}`);
552
679
  return;
553
680
  }
554
681
  this.finishFrame();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antha/multiplayer-p2p-lock-step",
3
- "version": "0.21.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.21.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.21.0"
53
+ "@antha/multiplayer-core": "^0.22.0"
54
54
  },
55
55
  "engines": {
56
56
  "node": ">=22"