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