@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
- A multiplayer strategy for Antha where the room host owns canonical state and peers send inputs to that host.
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
- /** Start multiplayer mode. This delegates API connectivity and room polling to multiplayer core. */
203
+ /** Initialize multiplayer API access without opening a room or starting host pings. */
196
204
  initMultiplayer(params: Readonly<MultiplayerInitParams>): Promise<void>;
197
- /** Start singleplayer mode. */
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 while preserving the wrapper connection on failure. */
212
- protected joinRoom({ previousRoomConnection, room, }: Readonly<{
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>>): P2pAuthoritativeHostMessage<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
- /** Start multiplayer mode. This delegates API connectivity and room polling to multiplayer core. */
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 singleplayer mode. */
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
- if (this.singleplayer) {
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 while preserving the wrapper connection on failure. */
205
- async joinRoom({ previousRoomConnection, room, }) {
206
- try {
207
- await this.roomController.joinOrCreateRoom(room);
208
- if (!this.roomController.currentConnection) {
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
- if (this.isHost() && message.type === P2pAuthoritativeHostMessageType.Input) {
265
- this.applyInput({
266
- clientId: sourceClientId,
267
- input: message.input,
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
- else if (!this.isHost() &&
271
- message.type === P2pAuthoritativeHostMessageType.StateSnapshot &&
272
- message.sequence >= this.currentSequence) {
273
- this.currentSequence = message.sequence;
274
- this.currentState = message.state;
275
- this.dispatchState(message);
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.0.8",
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/common": "^31.73.1",
40
- "typed-event-target": "^4.3.1"
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/assert": "^31.73.1",
44
- "@augment-vir/test": "^31.73.1",
45
- "@web/dev-server-esbuild": "^1.0.5",
46
- "@web/test-runner": "^0.20.2",
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.0.8"
51
+ "@antha/multiplayer-core": "^0.1.1"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=22"