@antha/multiplayer-core 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 +21 -1
- package/dist/multiplayer-api/multiplayer-controller.d.ts +9 -2
- package/dist/multiplayer-api/multiplayer-controller.js +4 -3
- package/dist/room-handler-server/multiplayer-room-handler.js +25 -8
- package/dist/webrtc/webrtc-controller.d.ts +6 -4
- package/dist/webrtc/webrtc-controller.js +2 -0
- package/dist/webrtc/webrtc-multiplayer-controller.d.ts +105 -13
- package/dist/webrtc/webrtc-multiplayer-controller.js +73 -27
- package/package.json +13 -14
package/README.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
1
|
# @antha/multiplayer-core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This package provides the room, signaling, WebRTC, and identifier utilities behind the [Antha game engine](https://www.npmjs.com/package/@antha/engine)'s peer-to-peer multiplayer features. Use it directly to build a custom multiplayer strategy, or use one of the strategy packages for a ready-made approach.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm i @antha/multiplayer-core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
<!-- example-link: src/readme-examples/creating-multiplayer-client.example.ts -->
|
|
14
|
+
|
|
15
|
+
```TypeScript
|
|
16
|
+
import {createMultiplayerApiClient, createMultiplayerId} from '@antha/multiplayer-core';
|
|
17
|
+
|
|
18
|
+
const roomId = createMultiplayerId.room();
|
|
19
|
+
const multiplayerApiClient = await createMultiplayerApiClient({
|
|
20
|
+
backendOrigin: 'http://localhost:9348',
|
|
21
|
+
portScanOptions: false,
|
|
22
|
+
});
|
|
23
|
+
```
|
|
@@ -75,6 +75,13 @@ export type MultiplayerRoomControllerParams<Message extends JsonCompatibleValue>
|
|
|
75
75
|
*/
|
|
76
76
|
gameId: string;
|
|
77
77
|
} & PartialWithUndefined<{
|
|
78
|
+
/** Reuse an existing local client id when establishing a multiplayer connection. */
|
|
79
|
+
clientId: ClientId;
|
|
80
|
+
/**
|
|
81
|
+
* Prepare a newly connected room before it replaces the current room. Throwing keeps the
|
|
82
|
+
* current room connected and discards the candidate connection.
|
|
83
|
+
*/
|
|
84
|
+
prepareConnection: (connection: Readonly<MultiplayerRoomConnection<Message>>) => MaybePromise<void>;
|
|
78
85
|
/**
|
|
79
86
|
* This is fired when a WebRTC peer attempts to connect to the host client (this will only be
|
|
80
87
|
* fired if your client is the host). Return `true` to accept the connection. Return `false` to
|
|
@@ -388,8 +395,8 @@ export declare class MultiplayerRoomController<Message extends JsonCompatibleVal
|
|
|
388
395
|
getAllClientIds(): ClientId[];
|
|
389
396
|
constructor(params: MultiplayerRoomControllerParams<Message>);
|
|
390
397
|
/**
|
|
391
|
-
*
|
|
392
|
-
* {@link MultiplayerRoomController.
|
|
398
|
+
* Initialize multiplayer API access. This does not open a room or start host pings; those begin
|
|
399
|
+
* when {@link MultiplayerRoomController.joinOrCreateRoom} establishes a room connection.
|
|
393
400
|
*/
|
|
394
401
|
initMultiplayer(params: Readonly<MultiplayerInitParams>): Promise<void>;
|
|
395
402
|
/**
|
|
@@ -145,8 +145,8 @@ export class MultiplayerRoomController extends ListenTarget {
|
|
|
145
145
|
this.params = params;
|
|
146
146
|
}
|
|
147
147
|
/**
|
|
148
|
-
*
|
|
149
|
-
* {@link MultiplayerRoomController.
|
|
148
|
+
* Initialize multiplayer API access. This does not open a room or start host pings; those begin
|
|
149
|
+
* when {@link MultiplayerRoomController.joinOrCreateRoom} establishes a room connection.
|
|
150
150
|
*/
|
|
151
151
|
async initMultiplayer(params) {
|
|
152
152
|
if (this.currentConnection) {
|
|
@@ -233,7 +233,7 @@ export class MultiplayerRoomController extends ListenTarget {
|
|
|
233
233
|
this.updateConnectionState({
|
|
234
234
|
room: MultiplayerConnectionState.Connecting,
|
|
235
235
|
});
|
|
236
|
-
const currentConnection = new WebrtcMultiplayerController(this.params.gameId, this.multiplayerApiClient, this.multiplayerParams.stunServerUrls || [], room,
|
|
236
|
+
const currentConnection = new WebrtcMultiplayerController(this.params.gameId, this.multiplayerApiClient, this.multiplayerParams.stunServerUrls || [], room, this.params.clientId, this.params.acceptConnection
|
|
237
237
|
? (data) => {
|
|
238
238
|
return this.params.acceptConnection?.(data.connectingClientId, this) ?? true;
|
|
239
239
|
}
|
|
@@ -259,6 +259,7 @@ export class MultiplayerRoomController extends ListenTarget {
|
|
|
259
259
|
};
|
|
260
260
|
});
|
|
261
261
|
if (connectionResult.connected) {
|
|
262
|
+
await this.params.prepareConnection?.(currentConnection);
|
|
262
263
|
this.currentConnection = currentConnection;
|
|
263
264
|
previousConnection?.destroy();
|
|
264
265
|
makeWritable(this).roomId = room.roomId;
|
|
@@ -34,6 +34,7 @@ export function createMultiplayerRoomHandler(options) {
|
|
|
34
34
|
},
|
|
35
35
|
/** Get the client-friendly room listing for a game. */
|
|
36
36
|
getRoomsForFetching(gameId) {
|
|
37
|
+
updateRoomsForFetching(gameId, state);
|
|
37
38
|
return state.roomsForFetching[gameId] || {};
|
|
38
39
|
},
|
|
39
40
|
/** Force an immediate update of the rooms-for-fetching cache for a game. */
|
|
@@ -177,20 +178,36 @@ function processQueueItem(state, { message, transport, gameId, }) {
|
|
|
177
178
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
178
179
|
}
|
|
179
180
|
else if (message.type === MultiplayerWebSocketMessageType.HostPing) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
room
|
|
183
|
-
room.roomPassword = message.roomPassword;
|
|
184
|
-
room.lastHostPingTimestamp = Date.now();
|
|
185
|
-
updateRoomsForFetching(gameId, state);
|
|
186
|
-
}
|
|
187
|
-
else {
|
|
181
|
+
const existingRoom = state.rooms[gameId]?.[message.roomId];
|
|
182
|
+
if (existingRoom && existingRoom.hostClient.clientSecret !== message.clientSecret) {
|
|
183
|
+
/** A client that isn't the room's host is trying to ping it. */
|
|
188
184
|
transport.send({
|
|
189
185
|
messageId: message.messageId,
|
|
190
186
|
type: MultiplayerWebSocketMessageType.Error,
|
|
191
187
|
errorMessage: 'Invalid room to ping.',
|
|
192
188
|
});
|
|
193
189
|
}
|
|
190
|
+
else {
|
|
191
|
+
/** Refresh the room, re-establishing it if needed. */
|
|
192
|
+
const pingedRoom = existingRoom ?? {
|
|
193
|
+
clientsAwaitingAnswer: {},
|
|
194
|
+
hostClient: currentClient,
|
|
195
|
+
roomId: message.roomId,
|
|
196
|
+
roomName: message.roomName,
|
|
197
|
+
roomPassword: message.roomPassword,
|
|
198
|
+
clientCount: message.clientCount,
|
|
199
|
+
lastHostPingTimestamp: Date.now(),
|
|
200
|
+
};
|
|
201
|
+
pingedRoom.hostClient = currentClient;
|
|
202
|
+
pingedRoom.clientCount = message.clientCount;
|
|
203
|
+
pingedRoom.roomName = message.roomName;
|
|
204
|
+
pingedRoom.roomPassword = message.roomPassword;
|
|
205
|
+
pingedRoom.lastHostPingTimestamp = Date.now();
|
|
206
|
+
getOrSet(state.rooms, gameId, () => {
|
|
207
|
+
return {};
|
|
208
|
+
})[pingedRoom.roomId] = pingedRoom;
|
|
209
|
+
updateRoomsForFetching(gameId, state);
|
|
210
|
+
}
|
|
194
211
|
}
|
|
195
212
|
else {
|
|
196
213
|
transport.send({
|
|
@@ -78,8 +78,8 @@ export type WebrtcEvents<MessageData extends JsonCompatibleValue> = WebrtcMessag
|
|
|
78
78
|
*/
|
|
79
79
|
export declare class WebrtcController<MessageData extends JsonCompatibleValue> extends ListenTarget<WebrtcEvents<MessageData>> {
|
|
80
80
|
readonly clientId: ClientId;
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
protected dataChannel: undefined | Readonly<RTCDataChannel>;
|
|
82
|
+
protected connection: undefined | Readonly<RTCPeerConnection>;
|
|
83
83
|
/** Indicates whether the WebRTC connection is live or not. */
|
|
84
84
|
readonly isConnected: boolean;
|
|
85
85
|
constructor(clientId: ClientId);
|
|
@@ -98,7 +98,9 @@ export declare class WebrtcController<MessageData extends JsonCompatibleValue> e
|
|
|
98
98
|
*/
|
|
99
99
|
sendMessage(data: Readonly<MessageData>): void;
|
|
100
100
|
destroy(): void;
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
/** Attach connection and message handling to a WebRTC data channel. */
|
|
102
|
+
protected handleDataChannel(dataChannel: Readonly<RTCDataChannel>): void;
|
|
103
|
+
/** Create a peer connection and wait for ICE candidate gathering to finish. */
|
|
104
|
+
protected createConnection(stunServerUrls: ReadonlyArray<string>): Promise<void>;
|
|
103
105
|
}
|
|
104
106
|
export {};
|
|
@@ -120,6 +120,7 @@ export class WebrtcController extends ListenTarget {
|
|
|
120
120
|
this.connection?.close();
|
|
121
121
|
super.destroy();
|
|
122
122
|
}
|
|
123
|
+
/** Attach connection and message handling to a WebRTC data channel. */
|
|
123
124
|
handleDataChannel(dataChannel) {
|
|
124
125
|
this.dataChannel?.close();
|
|
125
126
|
this.dataChannel = dataChannel;
|
|
@@ -144,6 +145,7 @@ export class WebrtcController extends ListenTarget {
|
|
|
144
145
|
}));
|
|
145
146
|
});
|
|
146
147
|
}
|
|
148
|
+
/** Create a peer connection and wait for ICE candidate gathering to finish. */
|
|
147
149
|
createConnection(stunServerUrls) {
|
|
148
150
|
if (this.connection) {
|
|
149
151
|
throw new Error('Connection already created!');
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { type JsonCompatibleValue, type MaybePromise, type PartialWithUndefined } from '@augment-vir/common';
|
|
2
|
-
import { type
|
|
1
|
+
import { type JsonCompatibleValue, type MaybePromise, type PartialWithUndefined, PromiseQueue, type RequireExactlyOne } from '@augment-vir/common';
|
|
2
|
+
import { type ClientWebSocket } from '@rest-vir/api';
|
|
3
3
|
import { ListenTarget } from 'typed-event-target';
|
|
4
|
-
import { type MultiplayerConnectClientMessage } from '../multiplayer-api/multiplayer-api.js';
|
|
4
|
+
import { type MultiplayerConnectClientMessage, multiplayerConnectWebSocket } from '../multiplayer-api/multiplayer-api.js';
|
|
5
5
|
import { type MultiplayerApiClient } from '../multiplayer-api/multiplayer-client.js';
|
|
6
6
|
import { type ClientId } from '../multiplayer-id.js';
|
|
7
7
|
import { MultiplayerWebSocketMessageType } from './web-rtc-communication.js';
|
|
8
|
+
import { WebrtcController } from './webrtc-controller.js';
|
|
8
9
|
declare const WebrtcMultiplayerMessageEvent_base: (new (eventInitDict: {
|
|
9
10
|
bubbles?: boolean;
|
|
10
11
|
cancelable?: boolean;
|
|
@@ -169,8 +170,8 @@ export type ShouldAllowConnectionCheck<Controller> = (data: {
|
|
|
169
170
|
* @category Internal
|
|
170
171
|
*/
|
|
171
172
|
export declare class WebrtcMultiplayerController<MessageData extends JsonCompatibleValue = any> extends ListenTarget<WebrtcMultiplayerMessageEvent<MessageData> | WebrtcMultiplayerConnectionUpdateEvent> {
|
|
172
|
-
|
|
173
|
-
|
|
173
|
+
protected readonly gameId: string;
|
|
174
|
+
protected readonly multiplayerApiClient: Readonly<MultiplayerApiClient>;
|
|
174
175
|
readonly stunServerUrls: ReadonlyArray<string>;
|
|
175
176
|
readonly multiplayerRoom: Readonly<RoomInput>;
|
|
176
177
|
/** The randomized client id for this controller and client. */
|
|
@@ -182,17 +183,20 @@ export declare class WebrtcMultiplayerController<MessageData extends JsonCompati
|
|
|
182
183
|
*
|
|
183
184
|
* @default accept all connections
|
|
184
185
|
*/
|
|
185
|
-
|
|
186
|
+
protected readonly shouldAllowConnectionCheck: ShouldAllowConnectionCheck<WebrtcMultiplayerController<MessageData>>;
|
|
186
187
|
readonly hostClientId: ClientId | undefined;
|
|
187
188
|
/**
|
|
188
189
|
* Connections between multiple WebRTC peers.
|
|
189
190
|
*
|
|
190
191
|
* A connection with the current client's id is the init connection.
|
|
191
192
|
*/
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
193
|
+
protected connections: Record<ClientId, WebrtcController<MessageData>>;
|
|
194
|
+
protected webSocket: ClientWebSocket<typeof multiplayerConnectWebSocket> | undefined;
|
|
195
|
+
protected readonly clientSecret: string;
|
|
195
196
|
readonly isDestroyed: boolean;
|
|
197
|
+
protected hostPingTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
198
|
+
protected reconnectPromise: Promise<void> | undefined;
|
|
199
|
+
protected isCleaningUpConnection: boolean;
|
|
196
200
|
constructor(gameId: string, multiplayerApiClient: Readonly<MultiplayerApiClient>, stunServerUrls: ReadonlyArray<string>, multiplayerRoom: Readonly<RoomInput>,
|
|
197
201
|
/** The randomized client id for this controller and client. */
|
|
198
202
|
clientId?: ClientId,
|
|
@@ -255,9 +259,97 @@ export declare class WebrtcMultiplayerController<MessageData extends JsonCompati
|
|
|
255
259
|
* if the WebRTC connections already exist).
|
|
256
260
|
*/
|
|
257
261
|
initConnection(): Promise<boolean>;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
+
/** Send the current room client count to the multiplayer server and schedule the next update. */
|
|
263
|
+
protected sendHostPing(): void;
|
|
264
|
+
/** Reconnect this room member after it loses its connection to the host. */
|
|
265
|
+
protected reconnectAfterHostLoss(): void;
|
|
266
|
+
protected connectionQueue: PromiseQueue;
|
|
267
|
+
/** Create or reuse the WebSocket used for multiplayer room signaling. */
|
|
268
|
+
protected setupWebSocket(): Promise<ClientWebSocket<Readonly<{
|
|
269
|
+
path: "/connect";
|
|
270
|
+
clientMessage: import("object-shape-tester").Shape<import("@sinclair/typebox").TUnion<(import("@sinclair/typebox").TUnsafe<{
|
|
271
|
+
clientId: ClientId;
|
|
272
|
+
roomId: import("../multiplayer-id.js").RoomId;
|
|
273
|
+
roomName: string;
|
|
274
|
+
} & {
|
|
275
|
+
messageId: import("../multiplayer-id.js").SocketMessageId;
|
|
276
|
+
} & {
|
|
277
|
+
type: MultiplayerWebSocketMessageType.Answer;
|
|
278
|
+
data: {
|
|
279
|
+
type: MultiplayerWebSocketMessageType.Answer;
|
|
280
|
+
sdp: string;
|
|
281
|
+
} | {
|
|
282
|
+
rejected: true;
|
|
283
|
+
};
|
|
284
|
+
}> | import("@sinclair/typebox").TUnsafe<{
|
|
285
|
+
clientId: ClientId;
|
|
286
|
+
roomId: import("../multiplayer-id.js").RoomId;
|
|
287
|
+
roomName: string;
|
|
288
|
+
} & {
|
|
289
|
+
messageId: import("../multiplayer-id.js").SocketMessageId;
|
|
290
|
+
} & {
|
|
291
|
+
type: MultiplayerWebSocketMessageType.HostPing;
|
|
292
|
+
clientSecret: string;
|
|
293
|
+
clientCount: number;
|
|
294
|
+
roomPassword: string;
|
|
295
|
+
}> | import("@sinclair/typebox").TUnsafe<{
|
|
296
|
+
messageId: import("../multiplayer-id.js").SocketMessageId;
|
|
297
|
+
} & {
|
|
298
|
+
clientId: ClientId;
|
|
299
|
+
roomId: import("../multiplayer-id.js").RoomId;
|
|
300
|
+
roomName: string;
|
|
301
|
+
} & {
|
|
302
|
+
type: MultiplayerWebSocketMessageType.Offer;
|
|
303
|
+
data: {
|
|
304
|
+
type: MultiplayerWebSocketMessageType.Offer;
|
|
305
|
+
sdp: string;
|
|
306
|
+
};
|
|
307
|
+
} & {
|
|
308
|
+
clientSecret: string;
|
|
309
|
+
roomPassword: string;
|
|
310
|
+
}>)[]>>;
|
|
311
|
+
hostMessage: import("object-shape-tester").Shape<import("@sinclair/typebox").TUnion<(import("@sinclair/typebox").TUnsafe<{
|
|
312
|
+
clientId: ClientId;
|
|
313
|
+
roomId: import("../multiplayer-id.js").RoomId;
|
|
314
|
+
roomName: string;
|
|
315
|
+
} & {
|
|
316
|
+
messageId: import("../multiplayer-id.js").SocketMessageId;
|
|
317
|
+
} & {
|
|
318
|
+
type: MultiplayerWebSocketMessageType.Answer;
|
|
319
|
+
data: {
|
|
320
|
+
type: MultiplayerWebSocketMessageType.Answer;
|
|
321
|
+
sdp: string;
|
|
322
|
+
} | {
|
|
323
|
+
rejected: true;
|
|
324
|
+
};
|
|
325
|
+
}> | import("@sinclair/typebox").TUnsafe<{
|
|
326
|
+
clientId: ClientId;
|
|
327
|
+
roomId: import("../multiplayer-id.js").RoomId;
|
|
328
|
+
roomName: string;
|
|
329
|
+
} & {
|
|
330
|
+
messageId: import("../multiplayer-id.js").SocketMessageId;
|
|
331
|
+
} & {
|
|
332
|
+
type: MultiplayerWebSocketMessageType.Offer;
|
|
333
|
+
data: {
|
|
334
|
+
type: MultiplayerWebSocketMessageType.Offer;
|
|
335
|
+
sdp: string;
|
|
336
|
+
};
|
|
337
|
+
}> | import("@sinclair/typebox").TUnsafe<{
|
|
338
|
+
messageId: import("../multiplayer-id.js").SocketMessageId;
|
|
339
|
+
} & {
|
|
340
|
+
type: MultiplayerWebSocketMessageType.OfferResult;
|
|
341
|
+
hostClientId: ClientId;
|
|
342
|
+
}> | import("@sinclair/typebox").TUnsafe<{
|
|
343
|
+
messageId: import("../multiplayer-id.js").SocketMessageId;
|
|
344
|
+
} & {
|
|
345
|
+
type: MultiplayerWebSocketMessageType.Error;
|
|
346
|
+
errorMessage: string;
|
|
347
|
+
}>)[]>>;
|
|
348
|
+
searchParams: {
|
|
349
|
+
readonly gameId: import("object-shape-tester").Shape<import("@sinclair/typebox").TTuple<[import("@sinclair/typebox").TString]>>;
|
|
350
|
+
};
|
|
351
|
+
}>>>;
|
|
352
|
+
/** Create and track a WebRTC connection for the given client. */
|
|
353
|
+
protected createNewConnection(clientId: ClientId): WebrtcController<MessageData>;
|
|
262
354
|
}
|
|
263
355
|
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { assert, waitUntil } from '@augment-vir/assert';
|
|
2
|
-
import { PromiseQueue, ensureErrorAndPrependMessage, extractErrorMessage, filterMap, getObjectTypedValues, log, makeWritable, mergeDefinedProperties, randomString, removeDuplicates, stringify, } from '@augment-vir/common';
|
|
2
|
+
import { PromiseQueue, ensureErrorAndPrependMessage, extractErrorMessage, filterMap, getObjectTypedValues, log, makeWritable, mergeDefinedProperties, randomString, removeDuplicates, retry, stringify, } from '@augment-vir/common';
|
|
3
3
|
import { ListenTarget, defineTypedCustomEvent } from 'typed-event-target';
|
|
4
4
|
import { multiplayerConnectWebSocket, } from '../multiplayer-api/multiplayer-api.js';
|
|
5
5
|
import { createMultiplayerId } from '../multiplayer-id.js';
|
|
@@ -65,6 +65,9 @@ export class WebrtcMultiplayerController extends ListenTarget {
|
|
|
65
65
|
webSocket;
|
|
66
66
|
clientSecret = randomString(32);
|
|
67
67
|
isDestroyed = false;
|
|
68
|
+
hostPingTimeoutId;
|
|
69
|
+
reconnectPromise;
|
|
70
|
+
isCleaningUpConnection = false;
|
|
68
71
|
constructor(gameId, multiplayerApiClient, stunServerUrls, multiplayerRoom,
|
|
69
72
|
/** The randomized client id for this controller and client. */
|
|
70
73
|
clientId = createMultiplayerId.client(),
|
|
@@ -144,6 +147,8 @@ export class WebrtcMultiplayerController extends ListenTarget {
|
|
|
144
147
|
/** Destroy this controller and clean everything up. */
|
|
145
148
|
destroy() {
|
|
146
149
|
makeWritable(this).isDestroyed = true;
|
|
150
|
+
globalThis.clearTimeout(this.hostPingTimeoutId);
|
|
151
|
+
this.hostPingTimeoutId = undefined;
|
|
147
152
|
Object.values(this.connections).forEach((connection) => connection.destroy());
|
|
148
153
|
void this.webSocket?.close();
|
|
149
154
|
this.connectionQueue.destroy();
|
|
@@ -197,31 +202,50 @@ export class WebrtcMultiplayerController extends ListenTarget {
|
|
|
197
202
|
// connections already exist
|
|
198
203
|
return false;
|
|
199
204
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
205
|
+
try {
|
|
206
|
+
const newConnection = this.createNewConnection(this.clientId);
|
|
207
|
+
const newOffer = await newConnection.createOffer(this.stunServerUrls);
|
|
208
|
+
const webSocket = await this.setupWebSocket();
|
|
209
|
+
const reply = await webSocket.sendAndWaitForReply({
|
|
210
|
+
message: {
|
|
211
|
+
messageId: createMultiplayerId.socketMessage(),
|
|
212
|
+
type: MultiplayerWebSocketMessageType.Offer,
|
|
213
|
+
clientId: this.clientId,
|
|
214
|
+
clientSecret: this.clientSecret,
|
|
215
|
+
data: newOffer,
|
|
216
|
+
...this.multiplayerRoom,
|
|
217
|
+
},
|
|
218
|
+
replyCheck(message) {
|
|
219
|
+
return message.type === MultiplayerWebSocketMessageType.OfferResult;
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
assert.strictEquals(reply.type, MultiplayerWebSocketMessageType.OfferResult);
|
|
223
|
+
/**
|
|
224
|
+
* `hostClientId` will be set by the already attached listener. We just need to wait
|
|
225
|
+
* until it does, because we need to know who the host is before calling
|
|
226
|
+
* `sendHostPing`.
|
|
227
|
+
*/
|
|
228
|
+
await waitUntil.isDefined(() => this.hostClientId);
|
|
229
|
+
this.sendHostPing();
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
this.isCleaningUpConnection = true;
|
|
234
|
+
try {
|
|
235
|
+
this.connections[this.clientId]?.destroy();
|
|
236
|
+
delete this.connections[this.clientId];
|
|
237
|
+
globalThis.clearTimeout(this.hostPingTimeoutId);
|
|
238
|
+
this.hostPingTimeoutId = undefined;
|
|
239
|
+
await this.webSocket?.close();
|
|
240
|
+
this.webSocket = undefined;
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
this.isCleaningUpConnection = false;
|
|
244
|
+
}
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
224
247
|
}
|
|
248
|
+
/** Send the current room client count to the multiplayer server and schedule the next update. */
|
|
225
249
|
sendHostPing() {
|
|
226
250
|
if (this.isHost() && this.webSocket) {
|
|
227
251
|
this.webSocket.send({
|
|
@@ -232,10 +256,29 @@ export class WebrtcMultiplayerController extends ListenTarget {
|
|
|
232
256
|
clientSecret: this.clientSecret,
|
|
233
257
|
...this.multiplayerRoom,
|
|
234
258
|
});
|
|
235
|
-
setTimeout(() => this.sendHostPing(), 1000);
|
|
259
|
+
this.hostPingTimeoutId = setTimeout(() => this.sendHostPing(), 1000);
|
|
236
260
|
}
|
|
237
261
|
}
|
|
262
|
+
/** Reconnect this room member after it loses its connection to the host. */
|
|
263
|
+
reconnectAfterHostLoss() {
|
|
264
|
+
if (this.isDestroyed || this.reconnectPromise || this.isCleaningUpConnection) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
this.reconnectPromise = retry(3, () => this.initConnection(), {
|
|
268
|
+
interval: {
|
|
269
|
+
seconds: 1,
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
.then(() => undefined)
|
|
273
|
+
.catch((error) => {
|
|
274
|
+
log.warning(ensureErrorAndPrependMessage(error, 'Failed to reconnect to the multiplayer room.'));
|
|
275
|
+
})
|
|
276
|
+
.finally(() => {
|
|
277
|
+
this.reconnectPromise = undefined;
|
|
278
|
+
});
|
|
279
|
+
}
|
|
238
280
|
connectionQueue = new PromiseQueue();
|
|
281
|
+
/** Create or reuse the WebSocket used for multiplayer room signaling. */
|
|
239
282
|
async setupWebSocket() {
|
|
240
283
|
if (this.webSocket &&
|
|
241
284
|
(this.webSocket.readyState === WebSocket.OPEN ||
|
|
@@ -342,12 +385,15 @@ export class WebrtcMultiplayerController extends ListenTarget {
|
|
|
342
385
|
},
|
|
343
386
|
close: () => {
|
|
344
387
|
this.webSocket = undefined;
|
|
388
|
+
globalThis.clearTimeout(this.hostPingTimeoutId);
|
|
389
|
+
this.hostPingTimeoutId = undefined;
|
|
345
390
|
},
|
|
346
391
|
},
|
|
347
392
|
});
|
|
348
393
|
this.webSocket = webSocket;
|
|
349
394
|
return webSocket;
|
|
350
395
|
}
|
|
396
|
+
/** Create and track a WebRTC connection for the given client. */
|
|
351
397
|
createNewConnection(clientId) {
|
|
352
398
|
const newController = new WebrtcController(clientId);
|
|
353
399
|
this.connections[clientId] = newController;
|
|
@@ -382,7 +428,7 @@ export class WebrtcMultiplayerController extends ListenTarget {
|
|
|
382
428
|
* If this member client has lost connection to its host, we've got to get it
|
|
383
429
|
* back!
|
|
384
430
|
*/
|
|
385
|
-
|
|
431
|
+
this.reconnectAfterHostLoss();
|
|
386
432
|
}
|
|
387
433
|
}
|
|
388
434
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antha/multiplayer-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Core functionalities for Antha multiplayer mods.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vir",
|
|
@@ -36,21 +36,20 @@
|
|
|
36
36
|
"test:docs": "virmator docs check"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@augment-vir/assert": "^
|
|
40
|
-
"@augment-vir/common": "^
|
|
41
|
-
"@rest-vir/api": "^2.
|
|
42
|
-
"@sinclair/typebox": "^0.34.
|
|
43
|
-
"date-vir": "^
|
|
44
|
-
"object-shape-tester": "^6.14.
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"url-vir": "^2.1.9"
|
|
39
|
+
"@augment-vir/assert": "^32.2.2",
|
|
40
|
+
"@augment-vir/common": "^32.2.2",
|
|
41
|
+
"@rest-vir/api": "^2.3.1",
|
|
42
|
+
"@sinclair/typebox": "^0.34.52",
|
|
43
|
+
"date-vir": "^9.0.0",
|
|
44
|
+
"object-shape-tester": "^6.14.1",
|
|
45
|
+
"typed-event-target": "^4.3.3",
|
|
46
|
+
"url-vir": "^2.2.1"
|
|
48
47
|
},
|
|
49
48
|
"devDependencies": {
|
|
50
|
-
"@augment-vir/test": "^
|
|
51
|
-
"@web/dev-server-esbuild": "^
|
|
52
|
-
"@web/test-runner": "^0.
|
|
53
|
-
"@web/test-runner-playwright": "^0.
|
|
49
|
+
"@augment-vir/test": "^32.2.2",
|
|
50
|
+
"@web/dev-server-esbuild": "^2.0.0",
|
|
51
|
+
"@web/test-runner": "^1.0.0",
|
|
52
|
+
"@web/test-runner-playwright": "^1.0.0",
|
|
54
53
|
"istanbul-smart-text-reporter": "^1.1.5"
|
|
55
54
|
},
|
|
56
55
|
"engines": {
|