@antha/multiplayer-p2p-lock-step 0.16.0 → 0.18.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
- import { type ApiAndRoomConnectionState } from '@antha/multiplayer-core';
2
- import { type JsonCompatibleValue, type PartialWithUndefined, type SelectFrom } from '@augment-vir/common';
3
- import { P2pLockStepMultiplayerController, type P2pLockStepMultiplayerControllerParams } from './p2p-lock-step-multiplayer-controller.js';
1
+ import { type ModExecuteParams } from '@antha/engine';
2
+ import { type ApiAndRoomConnectionState, MultiplayerControllerClientStatusEvent } from '@antha/multiplayer-core';
3
+ import { type JsonCompatibleValue, type MaybePromise, type PartialWithUndefined, type SelectFrom } from '@augment-vir/common';
4
+ import { MultiplayerControllerFrameEvent, type MultiplayerFramePacket, P2pLockStepMultiplayerController, type P2pLockStepMultiplayerControllerParams } from './p2p-lock-step-multiplayer-controller.js';
4
5
  /**
5
6
  * Engine state added by the p2p-lock-step multiplayer mod.
6
7
  *
@@ -9,6 +10,8 @@ import { P2pLockStepMultiplayerController, type P2pLockStepMultiplayerController
9
10
  export type AnthaMultiplayerP2pLockStepState<MultiplayerPacket extends JsonCompatibleValue = any> = {
10
11
  /** Enables verbose multiplayer debug logs. */
11
12
  debugMultiplayer?: boolean | undefined;
13
+ /** Number of completed lock-step frame updates. */
14
+ multiplayerLockstepTick: number;
12
15
  /** P2p-lock-step controller state. */
13
16
  multiplayerP2pLockStep: {
14
17
  /** Multiplayer controller used to drive singleplayer or multiplayer frame sync. */
@@ -17,19 +20,47 @@ export type AnthaMultiplayerP2pLockStepState<MultiplayerPacket extends JsonCompa
17
20
  connectionState: ApiAndRoomConnectionState;
18
21
  };
19
22
  };
23
+ /**
24
+ * Indicates whether a p2p-lock-step multiplayer room is currently connected.
25
+ *
26
+ * @category Util
27
+ */
28
+ export declare function isMultiplayerRoomConnected({ multiplayerP2pLockStep, }: Readonly<Partial<SelectFrom<AnthaMultiplayerP2pLockStepState, {
29
+ multiplayerP2pLockStep: true;
30
+ }>>>): boolean;
20
31
  /**
21
32
  * Options for {@link createAnthaMultiplayerP2pLockStepMod}.
22
33
  *
23
34
  * @category Internal
24
35
  */
25
- export type AnthaMultiplayerP2pLockStepOptions<MultiplayerPacket extends JsonCompatibleValue = any> = PartialWithUndefined<SelectFrom<P2pLockStepMultiplayerControllerParams<MultiplayerPacket>, {
36
+ export type AnthaMultiplayerP2pLockStepOptions<MultiplayerPacket extends JsonCompatibleValue = any, State extends AnthaMultiplayerP2pLockStepState<MultiplayerPacket> = AnthaMultiplayerP2pLockStepState<MultiplayerPacket>> = PartialWithUndefined<SelectFrom<P2pLockStepMultiplayerControllerParams<MultiplayerPacket>, {
26
37
  acceptConnection: true;
27
38
  debugMultiplayer: true;
39
+ frameDuration: true;
28
40
  gameId: true;
29
- }>>;
41
+ }>> & PartialWithUndefined<{
42
+ /** Applies an individual action from within a frame event. */
43
+ handlePacket: (params: Readonly<{
44
+ packet: Readonly<MultiplayerFramePacket<MultiplayerPacket>>;
45
+ multiplayerController: P2pLockStepMultiplayerController<MultiplayerPacket>;
46
+ state: Partial<State>;
47
+ }>) => MaybePromise<void>;
48
+ /** Called after all actions in a frame have been applied, in singleplayer and multiplayer. */
49
+ runFrameUpdate: (params: Readonly<{
50
+ multiplayerFrameEvent: MultiplayerControllerFrameEvent<MultiplayerPacket>;
51
+ } & ModExecuteParams<State>>) => MaybePromise<void>;
52
+ /** Handles a client or host status change. */
53
+ handleClientStatus: (params: Readonly<{
54
+ event: Readonly<MultiplayerControllerClientStatusEvent>;
55
+ multiplayerController: P2pLockStepMultiplayerController<MultiplayerPacket>;
56
+ state: Partial<State>;
57
+ }>) => MaybePromise<void>;
58
+ }>;
30
59
  /**
31
- * Create the engine mod that owns p2p-lock-step multiplayer state.
60
+ * Create the engine mod that owns p2p-lock-step multiplayer state. When `handlePacket` and
61
+ * `runFrameUpdate` are provided, the mod also applies every lock-step frame serially, preserving
62
+ * every frame when the engine batches multiple frame events into one tick.
32
63
  *
33
64
  * @category Main
34
65
  */
35
- export declare function createAnthaMultiplayerP2pLockStepMod<const MultiplayerPacket extends JsonCompatibleValue = any>(options?: Readonly<AnthaMultiplayerP2pLockStepOptions<MultiplayerPacket>>): import("@antha/engine").AnthaMod<NoInfer<AnthaMultiplayerP2pLockStepState<NoInfer<MultiplayerPacket>>>>;
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>>;
@@ -1,19 +1,48 @@
1
- import { defineAnthaMod } from '@antha/engine';
2
- import { emptyApiAndRoomConnectionState, MultiplayerControllerConnectionEvent, } from '@antha/multiplayer-core';
3
- import { log, } from '@augment-vir/common';
4
- import { P2pLockStepMultiplayerController, } from './p2p-lock-step-multiplayer-controller.js';
1
+ import { defineAnthaMod, ModExecutionTriggerType } from '@antha/engine';
2
+ import { emptyApiAndRoomConnectionState, MultiplayerControllerClientStatusEvent, MultiplayerControllerConnectionEvent, } from '@antha/multiplayer-core';
3
+ import { awaitedBlockingMap, log, } from '@augment-vir/common';
4
+ import { MultiplayerControllerFrameEvent, P2pLockStepMultiplayerController, } from './p2p-lock-step-multiplayer-controller.js';
5
5
  /**
6
- * Create the engine mod that owns p2p-lock-step multiplayer state.
6
+ * Indicates whether a p2p-lock-step multiplayer room is currently connected.
7
+ *
8
+ * @category Util
9
+ */
10
+ export function isMultiplayerRoomConnected({ multiplayerP2pLockStep, }) {
11
+ return !!multiplayerP2pLockStep?.multiplayerController.roomId;
12
+ }
13
+ /**
14
+ * Create the engine mod that owns p2p-lock-step multiplayer state. When `handlePacket` and
15
+ * `runFrameUpdate` are provided, the mod also applies every lock-step frame serially, preserving
16
+ * every frame when the engine batches multiple frame events into one tick.
7
17
  *
8
18
  * @category Main
9
19
  */
10
20
  export function createAnthaMultiplayerP2pLockStepMod(options = {}) {
11
21
  return defineAnthaMod({
12
22
  modName: 'antha-multiplayer-p2p-lock-step',
13
- execute({ state }) {
14
- if (options.debugMultiplayer == undefined) {
15
- state.debugMultiplayer = options.debugMultiplayer;
23
+ initState: {
24
+ debugMultiplayer: options.debugMultiplayer,
25
+ multiplayerLockstepTick: 0,
26
+ },
27
+ trigger: options.handlePacket || options.runFrameUpdate || options.handleClientStatus
28
+ ? {
29
+ event: [
30
+ ...(options.handlePacket || options.runFrameUpdate
31
+ ? [MultiplayerControllerFrameEvent]
32
+ : []),
33
+ ...(options.handleClientStatus
34
+ ? [MultiplayerControllerClientStatusEvent]
35
+ : []),
36
+ ],
37
+ executeImmediately: true,
16
38
  }
39
+ : undefined,
40
+ cleanup({ state }) {
41
+ log.if(!!state.debugMultiplayer).faint('[multiplayer] cleaning up p2p-lock-step mod');
42
+ state.multiplayerP2pLockStep?.multiplayerController.destroy();
43
+ },
44
+ async execute(executeParams) {
45
+ const { engine, state } = executeParams;
17
46
  if (!state.multiplayerP2pLockStep) {
18
47
  log.if(!!state.debugMultiplayer).faint('[multiplayer] creating p2p-lock-step mod state');
19
48
  state.multiplayerP2pLockStep = {
@@ -21,7 +50,7 @@ export function createAnthaMultiplayerP2pLockStepMod(options = {}) {
21
50
  gameId: options.gameId || 'antha',
22
51
  acceptConnection: options.acceptConnection,
23
52
  debugMultiplayer: state.debugMultiplayer,
24
- frameDuration: undefined,
53
+ frameDuration: options.frameDuration,
25
54
  }),
26
55
  connectionState: emptyApiAndRoomConnectionState,
27
56
  };
@@ -32,11 +61,58 @@ export function createAnthaMultiplayerP2pLockStepMod(options = {}) {
32
61
  log.if(!!state.debugMultiplayer).faint(`[multiplayer] mod connection state updated: api=${String(newConnectionState.api)} room=${String(newConnectionState.room)}`);
33
62
  state.multiplayerP2pLockStep.connectionState = newConnectionState;
34
63
  });
64
+ state.multiplayerP2pLockStep.multiplayerController.listenToAll((event) => {
65
+ engine.dispatch(event);
66
+ });
67
+ }
68
+ if (executeParams.executionTrigger.type === ModExecutionTriggerType.Event) {
69
+ await awaitedBlockingMap(executeParams.executionTrigger.events, async (event) => {
70
+ if (!state.multiplayerP2pLockStep?.multiplayerController) {
71
+ return;
72
+ }
73
+ else if (event instanceof MultiplayerControllerClientStatusEvent) {
74
+ await options.handleClientStatus?.({
75
+ event,
76
+ multiplayerController: state.multiplayerP2pLockStep.multiplayerController,
77
+ state,
78
+ });
79
+ return;
80
+ }
81
+ else if (event instanceof MultiplayerControllerFrameEvent &&
82
+ (options.handlePacket || options.runFrameUpdate)) {
83
+ if (options.handlePacket) {
84
+ for (const detail of event.detail) {
85
+ await options.handlePacket({
86
+ packet: detail,
87
+ multiplayerController: state.multiplayerP2pLockStep.multiplayerController,
88
+ state,
89
+ });
90
+ }
91
+ }
92
+ state.multiplayerLockstepTick = (state.multiplayerLockstepTick || 0) + 1;
93
+ await options.runFrameUpdate?.({
94
+ ...executeParams,
95
+ currentTick: state.multiplayerLockstepTick,
96
+ executionTrigger: {
97
+ events: [
98
+ event,
99
+ ],
100
+ type: ModExecutionTriggerType.Event,
101
+ },
102
+ /**
103
+ * This is a fixed duration so that clients don't get out of sync with
104
+ * each other. If a client is ever executing multiple events per
105
+ * execution, that means they got behind somehow, so we should still
106
+ * keep this duration fixed so that they run the same calculations as
107
+ * everyone else while catching up.
108
+ */
109
+ msSinceLastExecute: state.multiplayerP2pLockStep.multiplayerController.frameMs,
110
+ multiplayerFrameEvent: event,
111
+ ticksSinceLastExecute: 1,
112
+ });
113
+ }
114
+ });
35
115
  }
36
- },
37
- cleanup({ state }) {
38
- log.if(!!state.debugMultiplayer).faint('[multiplayer] cleaning up p2p-lock-step mod');
39
- state.multiplayerP2pLockStep?.multiplayerController.destroy();
40
116
  },
41
117
  });
42
118
  }
@@ -1,4 +1,4 @@
1
- import { type ApiAndRoomConnectionState, type ClientId, MultiplayerControllerClientEvent, MultiplayerControllerConnectionEvent, MultiplayerControllerRoomListEvent, type MultiplayerControllerRoomListListener, type MultiplayerInitParams, type MultiplayerRoomConnection, MultiplayerRoomController, type RoomInput, RoomRejectionError } from '@antha/multiplayer-core';
1
+ import { type ApiAndRoomConnectionState, type ClientId, MultiplayerControllerClientStatusEvent, MultiplayerControllerConnectionEvent, MultiplayerControllerRoomListEvent, type MultiplayerControllerRoomListListener, type MultiplayerInitParams, type MultiplayerRoomConnection, MultiplayerRoomController, type RoomInput, RoomRejectionError } from '@antha/multiplayer-core';
2
2
  import { type JsonCompatibleValue, type MaybePromise, type PartialWithUndefined } from '@augment-vir/common';
3
3
  import { type AnyDuration } from 'date-vir';
4
4
  import { ListenTarget, type RemoveListenerCallback, type TypedCustomEventInit } from 'typed-event-target';
@@ -16,7 +16,7 @@ export declare enum P2pLockStepMessageType {
16
16
  *
17
17
  * @category Internal
18
18
  */
19
- export type FrameEventDetail<MultiplayerPacket extends JsonCompatibleValue> = {
19
+ export type MultiplayerFramePacket<MultiplayerPacket extends JsonCompatibleValue> = {
20
20
  packet: MultiplayerPacket;
21
21
  sourceClientId: ClientId;
22
22
  };
@@ -33,10 +33,13 @@ export type P2pLockStepMessage<MultiplayerPacket extends JsonCompatibleValue> =
33
33
  actions: MultiplayerPacket[];
34
34
  }
35
35
  /** Sent from the host to clients when a frame is ready. */
36
- | {
36
+ | ({
37
37
  type: P2pLockStepMessageType.Frame;
38
- actions: FrameEventDetail<MultiplayerPacket>[];
39
- };
38
+ packets: MultiplayerFramePacket<MultiplayerPacket>[];
39
+ } & PartialWithUndefined<{
40
+ /** Whether this frame is meant for syncing a new client. */
41
+ isSynchronizationFrame: boolean;
42
+ }>);
40
43
  /**
41
44
  * Constructor parameters for {@link P2pLockStepMultiplayerController}.
42
45
  *
@@ -86,15 +89,15 @@ declare const MultiplayerControllerFrameEvent_base: (new (eventInitDict: {
86
89
  * @category Events
87
90
  */
88
91
  export declare class MultiplayerControllerFrameEvent<MultiplayerPacket extends JsonCompatibleValue> extends MultiplayerControllerFrameEvent_base {
89
- detail: ReadonlyArray<FrameEventDetail<MultiplayerPacket>>;
90
- constructor(eventInitDict: TypedCustomEventInit<ReadonlyArray<FrameEventDetail<MultiplayerPacket>>>);
92
+ detail: ReadonlyArray<MultiplayerFramePacket<MultiplayerPacket>>;
93
+ constructor(eventInitDict: TypedCustomEventInit<ReadonlyArray<MultiplayerFramePacket<MultiplayerPacket>>>);
91
94
  }
92
95
  /**
93
96
  * All events emitted by this controller.
94
97
  *
95
98
  * @category Internal
96
99
  */
97
- export type AllP2pLockStepMultiplayerControllerEvents<MultiplayerPacket extends JsonCompatibleValue> = MultiplayerControllerFrameEvent<MultiplayerPacket> | MultiplayerControllerRoomListEvent | MultiplayerControllerClientEvent | MultiplayerControllerConnectionEvent;
100
+ export type AllP2pLockStepMultiplayerControllerEvents<MultiplayerPacket extends JsonCompatibleValue> = MultiplayerControllerFrameEvent<MultiplayerPacket> | MultiplayerControllerRoomListEvent | MultiplayerControllerClientStatusEvent | MultiplayerControllerConnectionEvent;
98
101
  /**
99
102
  * Listener callback for p2p-lock-step frame events.
100
103
  *
@@ -129,10 +132,10 @@ export declare class P2pLockStepMultiplayerController<MultiplayerPacket extends
129
132
  protected readonly localClientId: ClientId;
130
133
  protected roomConnection: MultiplayerRoomConnection<P2pLockStepMessage<MultiplayerPacket>> | undefined;
131
134
  protected clientsResponded: Record<ClientId, boolean>;
132
- protected frameActions: FrameEventDetail<MultiplayerPacket>[];
135
+ protected frameActions: MultiplayerFramePacket<MultiplayerPacket>[];
133
136
  protected timeoutId: ReturnType<typeof globalThis.setTimeout> | undefined;
134
137
  protected frameTickReady: boolean;
135
- protected frameMs: number | undefined;
138
+ frameMs: number;
136
139
  protected joiningRoom: boolean;
137
140
  protected lastFpsCalculation: {
138
141
  timestamp: number;
@@ -1,4 +1,4 @@
1
- import { createMultiplayerId, emptyApiAndRoomConnectionState, MultiplayerConnectionState, MultiplayerControllerClientEvent, MultiplayerControllerConnectionEvent, MultiplayerControllerMessageEvent, MultiplayerControllerRoomListEvent, MultiplayerRoomController, RoomRejectionError, } from '@antha/multiplayer-core';
1
+ import { createMultiplayerId, emptyApiAndRoomConnectionState, MultiplayerConnectionState, MultiplayerControllerClientStatusEvent, MultiplayerControllerConnectionEvent, MultiplayerControllerMessageEvent, MultiplayerControllerRoomListEvent, MultiplayerRoomController, RoomRejectionError, } from '@antha/multiplayer-core';
2
2
  import { ensureArray, log, makeWritable, } from '@augment-vir/common';
3
3
  import { convertDuration } from 'date-vir';
4
4
  import { defineTypedCustomEvent, ListenTarget, } from 'typed-event-target';
@@ -174,9 +174,6 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
174
174
  */
175
175
  runFrame(actions) {
176
176
  this.debugLog(`runFrame called with ${actions?.length || 0} actions`);
177
- if (this.frameMs != undefined) {
178
- throw new Error('Cannot manually run frame when frameDuration has been set.');
179
- }
180
177
  if (actions) {
181
178
  this.act(actions);
182
179
  }
@@ -289,7 +286,7 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
289
286
  this.debugLog(`connection event received: api=${String(event.detail.api)} room=${String(event.detail.room)}`);
290
287
  this.dispatch(event);
291
288
  });
292
- this.roomController.listen(MultiplayerControllerClientEvent, (event) => {
289
+ this.roomController.listen(MultiplayerControllerClientStatusEvent, (event) => {
293
290
  this.debugLog(`client event received: ${JSON.stringify(event.detail)}`);
294
291
  if ('newMember' in event.detail) {
295
292
  this.syncNewMember(event.detail.newMember);
@@ -339,7 +336,8 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
339
336
  if (this.roomConnection && this.isHost()) {
340
337
  this.roomConnection.sendToOnlyOneClient(clientId, {
341
338
  type: P2pLockStepMessageType.Frame,
342
- actions: [],
339
+ packets: [],
340
+ isSynchronizationFrame: true,
343
341
  });
344
342
  }
345
343
  }
@@ -368,10 +366,9 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
368
366
  this.maybeFinishFrame();
369
367
  }
370
368
  else if (!this.isHost() && message.type === P2pLockStepMessageType.Frame) {
371
- this.debugLog(`member received frame with ${message.actions.length} actions; sending ${this.frameActions.length} local actions back to host`);
369
+ this.debugLog(`member received frame with ${message.packets.length} actions; sending ${this.frameActions.length} local actions back to host`);
372
370
  const currentFrameActions = this.frameActions;
373
371
  this.frameActions = [];
374
- this.calculateFps();
375
372
  this.roomConnection.sendMessage({
376
373
  actions: currentFrameActions.map(({ packet }) => {
377
374
  return packet;
@@ -379,9 +376,12 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
379
376
  sourceClientId: this.clientId,
380
377
  type: P2pLockStepMessageType.Actions,
381
378
  });
382
- this.dispatch(new MultiplayerControllerFrameEvent({
383
- detail: message.actions,
384
- }));
379
+ if (!message.isSynchronizationFrame) {
380
+ this.calculateFps();
381
+ this.dispatch(new MultiplayerControllerFrameEvent({
382
+ detail: message.packets,
383
+ }));
384
+ }
385
385
  }
386
386
  }
387
387
  /** Recalculate the current data-flow FPS from completed frames. */
@@ -408,7 +408,7 @@ export class P2pLockStepMultiplayerController extends ListenTarget {
408
408
  this.frameActions = [];
409
409
  this.roomConnection?.sendMessage({
410
410
  type: P2pLockStepMessageType.Frame,
411
- actions: currentFrameActions,
411
+ packets: currentFrameActions,
412
412
  });
413
413
  this.dispatch(new MultiplayerControllerFrameEvent({
414
414
  detail: currentFrameActions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antha/multiplayer-p2p-lock-step",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Multiplayer mod for the Antha engine.",
5
5
  "keywords": [
6
6
  "vir",
@@ -42,6 +42,7 @@
42
42
  "typed-event-target": "^4.3.3"
43
43
  },
44
44
  "devDependencies": {
45
+ "@antha/multiplayer-core": "^0.18.0",
45
46
  "@augment-vir/test": "^32.3.0",
46
47
  "@web/dev-server-esbuild": "^2.0.0",
47
48
  "@web/test-runner": "^1.0.0",
@@ -49,7 +50,7 @@
49
50
  "istanbul-smart-text-reporter": "^1.1.5"
50
51
  },
51
52
  "peerDependencies": {
52
- "@antha/multiplayer-core": "^0.16.0"
53
+ "@antha/multiplayer-core": "^0.18.0"
53
54
  },
54
55
  "engines": {
55
56
  "node": ">=22"