@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
|
|
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
|
|
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:
|
|
31
|
+
trigger: shouldHandleFrames || options.handleClientStatus
|
|
28
32
|
? {
|
|
29
33
|
event: [
|
|
30
|
-
...(
|
|
31
|
-
? [
|
|
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
|
-
|
|
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
|
-
*
|
|
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:
|
|
93
|
-
constructor(eventInitDict: TypedCustomEventInit<
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
231
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
414
|
-
detail:
|
|
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.
|
|
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.
|
|
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.
|
|
53
|
+
"@antha/multiplayer-core": "^0.22.0"
|
|
54
54
|
},
|
|
55
55
|
"engines": {
|
|
56
56
|
"node": ">=22"
|