@antha/multiplayer-p2p-authoritative-host 0.0.8 → 0.1.1
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.
package/README.md
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
1
|
# @antha/multiplayer-p2p-authoritative-host
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This package provides a pre-built [Antha game engine](https://www.npmjs.com/package/@antha/engine) mod for peer-to-peer multiplayer where the room host owns the canonical game state. Clients submit inputs to the host, and the host publishes updated state snapshots to the room.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm i @antha/multiplayer-p2p-authoritative-host
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
<!-- example-link: src/readme-examples/creating-authoritative-host.example.ts -->
|
|
14
|
+
|
|
15
|
+
```TypeScript
|
|
16
|
+
import {AnthaEngine, defineAnthaMod} from '@antha/engine';
|
|
17
|
+
import {
|
|
18
|
+
type AnthaMultiplayerP2pAuthoritativeHostState,
|
|
19
|
+
createAnthaMultiplayerP2pAuthoritativeHostMod,
|
|
20
|
+
} from '@antha/multiplayer-p2p-authoritative-host';
|
|
21
|
+
|
|
22
|
+
type GameState = AnthaMultiplayerP2pAuthoritativeHostState<number, number>;
|
|
23
|
+
|
|
24
|
+
const engine = new AnthaEngine<GameState>({
|
|
25
|
+
mods: [
|
|
26
|
+
createAnthaMultiplayerP2pAuthoritativeHostMod<number, number>({
|
|
27
|
+
gameId: 'counter-game',
|
|
28
|
+
createInitialState() {
|
|
29
|
+
return 0;
|
|
30
|
+
},
|
|
31
|
+
applyInput({input, state}) {
|
|
32
|
+
return state + input;
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
defineAnthaMod<GameState>({
|
|
36
|
+
modName: 'game-logic',
|
|
37
|
+
execute({state}) {
|
|
38
|
+
const controller = state.multiplayerP2pAuthoritativeHost?.multiplayerController;
|
|
39
|
+
|
|
40
|
+
if (!controller) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!controller.isConnected()) {
|
|
44
|
+
controller.startSingleplayer();
|
|
45
|
+
controller.act(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return `Score: ${controller.getState()}`;
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
engine.startLoop();
|
|
55
|
+
```
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ApiAndRoomConnectionState, type ClientId, ControllerClientEvent, ControllerConnectionEvent, ControllerRoomListEvent, type ControllerRoomListListener, type MultiplayerInitParams, type MultiplayerRoomConnection, MultiplayerRoomController, type RoomInput, RoomRejectionError } from '@antha/multiplayer-core';
|
|
1
|
+
import { type ApiAndRoomConnectionState, type ClientId, ControllerClientEvent, ControllerConnectionEvent, ControllerRoomListEvent, type ControllerRoomListListener, type MultiplayerInitParams, type MultiplayerRoomConnection, MultiplayerRoomController, type RoomInput, RoomRejectionError, type SocketMessageId } from '@antha/multiplayer-core';
|
|
2
2
|
import { type JsonCompatibleValue, type MaybePromise, type PartialWithUndefined } from '@augment-vir/common';
|
|
3
3
|
import { ListenTarget, type RemoveListenerCallback, type TypedCustomEventInit } from 'typed-event-target';
|
|
4
4
|
/**
|
|
@@ -8,6 +8,7 @@ import { ListenTarget, type RemoveListenerCallback, type TypedCustomEventInit }
|
|
|
8
8
|
*/
|
|
9
9
|
export declare enum P2pAuthoritativeHostMessageType {
|
|
10
10
|
Input = "input",
|
|
11
|
+
StateRequest = "state-request",
|
|
11
12
|
StateSnapshot = "state-snapshot"
|
|
12
13
|
}
|
|
13
14
|
/**
|
|
@@ -18,7 +19,10 @@ export declare enum P2pAuthoritativeHostMessageType {
|
|
|
18
19
|
export type P2pAuthoritativeHostStateSnapshot<State extends JsonCompatibleValue> = {
|
|
19
20
|
sequence: number;
|
|
20
21
|
state: State;
|
|
21
|
-
}
|
|
22
|
+
} & PartialWithUndefined<{
|
|
23
|
+
/** Identifies the state request that this snapshot fulfills. */
|
|
24
|
+
stateSyncId: SocketMessageId;
|
|
25
|
+
}>;
|
|
22
26
|
/**
|
|
23
27
|
* Data received from {@link ControllerStateEvent}.
|
|
24
28
|
*
|
|
@@ -36,6 +40,9 @@ export type StateEventDetail<Input extends JsonCompatibleValue, State extends Js
|
|
|
36
40
|
export type P2pAuthoritativeHostMessage<Input extends JsonCompatibleValue, State extends JsonCompatibleValue> = {
|
|
37
41
|
type: P2pAuthoritativeHostMessageType.Input;
|
|
38
42
|
input: Input;
|
|
43
|
+
} | {
|
|
44
|
+
type: P2pAuthoritativeHostMessageType.StateRequest;
|
|
45
|
+
stateSyncId: SocketMessageId;
|
|
39
46
|
} | ({
|
|
40
47
|
type: P2pAuthoritativeHostMessageType.StateSnapshot;
|
|
41
48
|
} & StateEventDetail<Input, State>);
|
|
@@ -141,6 +148,7 @@ export declare class P2pAuthoritativeHostMultiplayerController<Input extends Jso
|
|
|
141
148
|
protected roomConnection: MultiplayerRoomConnection<P2pAuthoritativeHostMessage<Input, State>> | undefined;
|
|
142
149
|
protected currentState: State;
|
|
143
150
|
protected currentSequence: number;
|
|
151
|
+
protected pendingStateSyncId: SocketMessageId | undefined;
|
|
144
152
|
protected singleplayer: boolean;
|
|
145
153
|
constructor(params: P2pAuthoritativeHostMultiplayerControllerParams<Input, State>);
|
|
146
154
|
/** Current p2p-authoritative-host connection, exposed for compatibility checks. */
|
|
@@ -192,9 +200,9 @@ export declare class P2pAuthoritativeHostMultiplayerController<Input extends Jso
|
|
|
192
200
|
getAllClientIds(): ClientId[];
|
|
193
201
|
/** Get the latest local state view. */
|
|
194
202
|
getState(): State;
|
|
195
|
-
/**
|
|
203
|
+
/** Initialize multiplayer API access without opening a room or starting host pings. */
|
|
196
204
|
initMultiplayer(params: Readonly<MultiplayerInitParams>): Promise<void>;
|
|
197
|
-
/** Start
|
|
205
|
+
/** Start local play without contacting the multiplayer API. This can later open into a room. */
|
|
198
206
|
startSingleplayer(): void;
|
|
199
207
|
/** Send or apply a local input. */
|
|
200
208
|
act(input: Readonly<Input>): void;
|
|
@@ -208,21 +216,18 @@ export declare class P2pAuthoritativeHostMultiplayerController<Input extends Jso
|
|
|
208
216
|
destroy(): void;
|
|
209
217
|
/** Join or create a room. */
|
|
210
218
|
joinOrCreateRoom(room: Readonly<RoomInput>): Promise<void>;
|
|
211
|
-
/** Join through the core room controller
|
|
212
|
-
protected joinRoom(
|
|
213
|
-
previousRoomConnection: MultiplayerRoomConnection<P2pAuthoritativeHostMessage<Input, State>> | undefined;
|
|
214
|
-
room: Readonly<RoomInput>;
|
|
215
|
-
}>): Promise<MultiplayerRoomConnection<P2pAuthoritativeHostMessage<Input, State>>>;
|
|
219
|
+
/** Join through the core room controller after its candidate connection is state-synchronized. */
|
|
220
|
+
protected joinRoom(room: Readonly<RoomInput>): Promise<MultiplayerRoomConnection<P2pAuthoritativeHostMessage<Input, State>>>;
|
|
216
221
|
/** Leave the current room or single player connection. */
|
|
217
222
|
leaveRoom(): void;
|
|
218
223
|
/** Forward core room-controller events into this state-sync controller. */
|
|
219
224
|
protected listenToRoomController(): void;
|
|
220
225
|
/** Attach an established room transport and publish the current state view. */
|
|
221
226
|
protected attachMultiplayerRoomConnection(roomConnection: Readonly<MultiplayerRoomConnection<P2pAuthoritativeHostMessage<Input, State>>>): void;
|
|
222
|
-
/** Send the latest authoritative state to a newly connected member. */
|
|
223
|
-
protected syncNewMember(clientId: ClientId): void;
|
|
224
227
|
/** Apply received inputs on the host or received state snapshots on member clients. */
|
|
225
228
|
protected handleReceivedMessage(sourceClientId: ClientId, message: Readonly<P2pAuthoritativeHostMessage<Input, State>>): void;
|
|
229
|
+
/** Attach and synchronize a candidate room before the core controller commits to it. */
|
|
230
|
+
protected prepareRoomConnection(roomConnection: Readonly<MultiplayerRoomConnection<P2pAuthoritativeHostMessage<Input, State>>>): Promise<void>;
|
|
226
231
|
/** Validate and apply an input against the current authoritative state. */
|
|
227
232
|
protected applyInput({ clientId, input, }: Readonly<{
|
|
228
233
|
clientId: ClientId;
|
|
@@ -241,7 +246,7 @@ export declare class P2pAuthoritativeHostMultiplayerController<Input extends Jso
|
|
|
241
246
|
/** Broadcast a state snapshot when the local client is the host. */
|
|
242
247
|
protected sendStateSnapshot(detail: Readonly<StateEventDetail<Input, State>>): void;
|
|
243
248
|
/** Create a network message from state event detail. */
|
|
244
|
-
protected createStateSnapshotMessage(detail: Readonly<StateEventDetail<Input, State
|
|
249
|
+
protected createStateSnapshotMessage(detail: Readonly<StateEventDetail<Input, State>>, stateSyncId?: SocketMessageId | undefined): P2pAuthoritativeHostMessage<Input, State>;
|
|
245
250
|
/** Create the local state event detail for the current sequence and state. */
|
|
246
251
|
protected createStateEventDetail(source?: Readonly<PartialWithUndefined<{
|
|
247
252
|
clientId: ClientId;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ControllerClientEvent, ControllerConnectionEvent, ControllerMessageEvent, ControllerRoomListEvent, createMultiplayerId, emptyApiAndRoomConnectionState, MultiplayerConnectionState, MultiplayerRoomController, RoomRejectionError, } from '@antha/multiplayer-core';
|
|
2
|
+
import { assertWrap, waitUntil } from '@augment-vir/assert';
|
|
2
3
|
import { defineTypedCustomEvent, ListenTarget, } from 'typed-event-target';
|
|
3
4
|
/**
|
|
4
5
|
* Message type for {@link P2pAuthoritativeHostMessage}.
|
|
@@ -8,6 +9,7 @@ import { defineTypedCustomEvent, ListenTarget, } from 'typed-event-target';
|
|
|
8
9
|
export var P2pAuthoritativeHostMessageType;
|
|
9
10
|
(function (P2pAuthoritativeHostMessageType) {
|
|
10
11
|
P2pAuthoritativeHostMessageType["Input"] = "input";
|
|
12
|
+
P2pAuthoritativeHostMessageType["StateRequest"] = "state-request";
|
|
11
13
|
P2pAuthoritativeHostMessageType["StateSnapshot"] = "state-snapshot";
|
|
12
14
|
})(P2pAuthoritativeHostMessageType || (P2pAuthoritativeHostMessageType = {}));
|
|
13
15
|
/**
|
|
@@ -43,6 +45,7 @@ export class P2pAuthoritativeHostMultiplayerController extends ListenTarget {
|
|
|
43
45
|
roomConnection;
|
|
44
46
|
currentState;
|
|
45
47
|
currentSequence = 0;
|
|
48
|
+
pendingStateSyncId;
|
|
46
49
|
singleplayer = false;
|
|
47
50
|
constructor(params) {
|
|
48
51
|
super();
|
|
@@ -50,6 +53,10 @@ export class P2pAuthoritativeHostMultiplayerController extends ListenTarget {
|
|
|
50
53
|
this.currentState = params.createInitialState();
|
|
51
54
|
this.roomController = new MultiplayerRoomController({
|
|
52
55
|
gameId: params.gameId,
|
|
56
|
+
clientId: this.localClientId,
|
|
57
|
+
prepareConnection: async (connection) => {
|
|
58
|
+
await this.prepareRoomConnection(connection);
|
|
59
|
+
},
|
|
53
60
|
acceptConnection: params.acceptConnection
|
|
54
61
|
? (connectingClientId) => {
|
|
55
62
|
return params.acceptConnection?.(connectingClientId, this) ?? true;
|
|
@@ -128,11 +135,11 @@ export class P2pAuthoritativeHostMultiplayerController extends ListenTarget {
|
|
|
128
135
|
getState() {
|
|
129
136
|
return this.currentState;
|
|
130
137
|
}
|
|
131
|
-
/**
|
|
138
|
+
/** Initialize multiplayer API access without opening a room or starting host pings. */
|
|
132
139
|
async initMultiplayer(params) {
|
|
133
140
|
await this.roomController.initMultiplayer(params);
|
|
134
141
|
}
|
|
135
|
-
/** Start
|
|
142
|
+
/** Start local play without contacting the multiplayer API. This can later open into a room. */
|
|
136
143
|
startSingleplayer() {
|
|
137
144
|
if (this.currentConnection) {
|
|
138
145
|
throw new Error('Cannot start singleplayer with a connection already present.');
|
|
@@ -191,29 +198,16 @@ export class P2pAuthoritativeHostMultiplayerController extends ListenTarget {
|
|
|
191
198
|
}
|
|
192
199
|
/** Join or create a room. */
|
|
193
200
|
async joinOrCreateRoom(room) {
|
|
194
|
-
|
|
195
|
-
throw new Error('Cannot join room: connection already established.');
|
|
196
|
-
}
|
|
197
|
-
const previousRoomConnection = this.roomConnection;
|
|
198
|
-
const roomConnection = await this.joinRoom({
|
|
199
|
-
previousRoomConnection,
|
|
200
|
-
room,
|
|
201
|
-
});
|
|
201
|
+
const roomConnection = await this.joinRoom(room);
|
|
202
202
|
this.attachMultiplayerRoomConnection(roomConnection);
|
|
203
203
|
}
|
|
204
|
-
/** Join through the core room controller
|
|
205
|
-
async joinRoom(
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
throw new Error('Cannot start p2p-authoritative-host multiplayer: room connection is missing.');
|
|
210
|
-
}
|
|
211
|
-
return this.roomController.currentConnection;
|
|
212
|
-
}
|
|
213
|
-
catch (error) {
|
|
214
|
-
this.roomConnection = previousRoomConnection;
|
|
215
|
-
throw error;
|
|
204
|
+
/** Join through the core room controller after its candidate connection is state-synchronized. */
|
|
205
|
+
async joinRoom(room) {
|
|
206
|
+
await this.roomController.joinOrCreateRoom(room);
|
|
207
|
+
if (!this.roomController.currentConnection) {
|
|
208
|
+
throw new Error('Cannot start p2p-authoritative-host multiplayer: room connection is missing.');
|
|
216
209
|
}
|
|
210
|
+
return this.roomController.currentConnection;
|
|
217
211
|
}
|
|
218
212
|
/** Leave the current room or single player connection. */
|
|
219
213
|
leaveRoom() {
|
|
@@ -233,9 +227,6 @@ export class P2pAuthoritativeHostMultiplayerController extends ListenTarget {
|
|
|
233
227
|
this.dispatch(event);
|
|
234
228
|
});
|
|
235
229
|
this.roomController.listen(ControllerClientEvent, (event) => {
|
|
236
|
-
if ('newMember' in event.detail) {
|
|
237
|
-
this.syncNewMember(event.detail.newMember);
|
|
238
|
-
}
|
|
239
230
|
this.dispatch(event);
|
|
240
231
|
});
|
|
241
232
|
this.roomController.listen((ControllerMessageEvent), (event) => {
|
|
@@ -250,29 +241,68 @@ export class P2pAuthoritativeHostMultiplayerController extends ListenTarget {
|
|
|
250
241
|
this.sendStateSnapshot(this.createStateEventDetail());
|
|
251
242
|
}
|
|
252
243
|
}
|
|
253
|
-
/** Send the latest authoritative state to a newly connected member. */
|
|
254
|
-
syncNewMember(clientId) {
|
|
255
|
-
if (this.roomConnection && this.isHost()) {
|
|
256
|
-
this.roomConnection.sendToOnlyOneClient(clientId, this.createStateSnapshotMessage(this.createStateEventDetail()));
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
244
|
/** Apply received inputs on the host or received state snapshots on member clients. */
|
|
260
245
|
handleReceivedMessage(sourceClientId, message) {
|
|
261
246
|
if (!this.roomConnection) {
|
|
262
247
|
return;
|
|
263
248
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
249
|
+
const messageHandlers = {
|
|
250
|
+
[P2pAuthoritativeHostMessageType.Input]: () => {
|
|
251
|
+
if (this.isHost() && 'input' in message) {
|
|
252
|
+
this.applyInput({
|
|
253
|
+
clientId: sourceClientId,
|
|
254
|
+
input: assertWrap.isDefined(message.input),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
[P2pAuthoritativeHostMessageType.StateRequest]: () => {
|
|
259
|
+
if (this.isHost() && 'stateSyncId' in message) {
|
|
260
|
+
this.roomConnection?.sendToOnlyOneClient(sourceClientId, this.createStateSnapshotMessage(this.createStateEventDetail(), assertWrap.isDefined(message.stateSyncId)));
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
[P2pAuthoritativeHostMessageType.StateSnapshot]: () => {
|
|
264
|
+
if (!this.isHost() && 'state' in message) {
|
|
265
|
+
const fulfillsPendingSync = this.pendingStateSyncId != undefined &&
|
|
266
|
+
message.stateSyncId === this.pendingStateSyncId;
|
|
267
|
+
if (fulfillsPendingSync || message.sequence >= this.currentSequence) {
|
|
268
|
+
this.currentSequence = message.sequence;
|
|
269
|
+
this.currentState = message.state;
|
|
270
|
+
this.dispatchState(message);
|
|
271
|
+
if (fulfillsPendingSync) {
|
|
272
|
+
this.pendingStateSyncId = undefined;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
messageHandlers[message.type]();
|
|
279
|
+
}
|
|
280
|
+
/** Attach and synchronize a candidate room before the core controller commits to it. */
|
|
281
|
+
async prepareRoomConnection(roomConnection) {
|
|
282
|
+
const previousRoomConnection = this.roomConnection;
|
|
283
|
+
const previousState = this.currentState;
|
|
284
|
+
const previousSequence = this.currentSequence;
|
|
285
|
+
const wasSingleplayer = this.singleplayer;
|
|
286
|
+
this.roomConnection = roomConnection;
|
|
287
|
+
this.singleplayer = false;
|
|
288
|
+
try {
|
|
289
|
+
if (!roomConnection.isHost()) {
|
|
290
|
+
const stateSyncId = createMultiplayerId.socketMessage();
|
|
291
|
+
this.pendingStateSyncId = stateSyncId;
|
|
292
|
+
roomConnection.sendMessage({
|
|
293
|
+
type: P2pAuthoritativeHostMessageType.StateRequest,
|
|
294
|
+
stateSyncId,
|
|
295
|
+
});
|
|
296
|
+
await waitUntil.isTrue(() => this.pendingStateSyncId !== stateSyncId);
|
|
297
|
+
}
|
|
269
298
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
this.
|
|
274
|
-
this.
|
|
275
|
-
this.
|
|
299
|
+
catch (error) {
|
|
300
|
+
this.pendingStateSyncId = undefined;
|
|
301
|
+
this.roomConnection = previousRoomConnection;
|
|
302
|
+
this.currentState = previousState;
|
|
303
|
+
this.currentSequence = previousSequence;
|
|
304
|
+
this.singleplayer = wasSingleplayer;
|
|
305
|
+
throw error;
|
|
276
306
|
}
|
|
277
307
|
}
|
|
278
308
|
/** Validate and apply an input against the current authoritative state. */
|
|
@@ -326,10 +356,13 @@ export class P2pAuthoritativeHostMultiplayerController extends ListenTarget {
|
|
|
326
356
|
}
|
|
327
357
|
}
|
|
328
358
|
/** Create a network message from state event detail. */
|
|
329
|
-
createStateSnapshotMessage(detail) {
|
|
359
|
+
createStateSnapshotMessage(detail, stateSyncId) {
|
|
330
360
|
return {
|
|
331
361
|
...detail,
|
|
332
362
|
type: P2pAuthoritativeHostMessageType.StateSnapshot,
|
|
363
|
+
...(stateSyncId && {
|
|
364
|
+
stateSyncId,
|
|
365
|
+
}),
|
|
333
366
|
};
|
|
334
367
|
}
|
|
335
368
|
/** Create the local state event detail for the current sequence and state. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antha/multiplayer-p2p-authoritative-host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "P2P authoritative-host multiplayer strategy for the Antha engine.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vir",
|
|
@@ -36,19 +36,19 @@
|
|
|
36
36
|
"test:docs": "virmator docs check"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@augment-vir/
|
|
40
|
-
"
|
|
39
|
+
"@augment-vir/assert": "^32.2.2",
|
|
40
|
+
"@augment-vir/common": "^32.2.2",
|
|
41
|
+
"typed-event-target": "^4.3.3"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
|
-
"@augment-vir/
|
|
44
|
-
"@
|
|
45
|
-
"@web/
|
|
46
|
-
"@web/test-runner": "^0.
|
|
47
|
-
"@web/test-runner-playwright": "^0.11.1",
|
|
44
|
+
"@augment-vir/test": "^32.2.2",
|
|
45
|
+
"@web/dev-server-esbuild": "^2.0.0",
|
|
46
|
+
"@web/test-runner": "^1.0.0",
|
|
47
|
+
"@web/test-runner-playwright": "^1.0.0",
|
|
48
48
|
"istanbul-smart-text-reporter": "^1.1.5"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@antha/multiplayer-core": "^0.
|
|
51
|
+
"@antha/multiplayer-core": "^0.1.1"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=22"
|