@antha/multiplayer-core 0.0.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/LICENSE-CC0 +121 -0
- package/LICENSE-MIT +21 -0
- package/README.md +3 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/multiplayer-api/errors.d.ts +13 -0
- package/dist/multiplayer-api/errors.js +15 -0
- package/dist/multiplayer-api/multiplayer-api.d.ts +215 -0
- package/dist/multiplayer-api/multiplayer-api.js +123 -0
- package/dist/multiplayer-api/multiplayer-client.d.ts +25 -0
- package/dist/multiplayer-api/multiplayer-client.js +18 -0
- package/dist/multiplayer-api/multiplayer-controller.d.ts +417 -0
- package/dist/multiplayer-api/multiplayer-controller.js +331 -0
- package/dist/multiplayer-api/multiplayer-socket-messages.d.ts +141 -0
- package/dist/multiplayer-api/multiplayer-socket-messages.js +114 -0
- package/dist/multiplayer-id.d.ts +59 -0
- package/dist/multiplayer-id.js +38 -0
- package/dist/room-handler-server/mock-room-handler-server-api-client.d.ts +47 -0
- package/dist/room-handler-server/mock-room-handler-server-api-client.js +107 -0
- package/dist/room-handler-server/multiplayer-room-handler.d.ts +121 -0
- package/dist/room-handler-server/multiplayer-room-handler.js +216 -0
- package/dist/webrtc/web-rtc-communication.d.ts +64 -0
- package/dist/webrtc/web-rtc-communication.js +54 -0
- package/dist/webrtc/webrtc-controller.d.ts +104 -0
- package/dist/webrtc/webrtc-controller.js +170 -0
- package/dist/webrtc/webrtc-multiplayer-controller.d.ts +263 -0
- package/dist/webrtc/webrtc-multiplayer-controller.js +397 -0
- package/package.json +62 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type PartialWithUndefined } from '@augment-vir/common';
|
|
2
|
+
import { type MultiplayerClientRooms } from '../multiplayer-api/multiplayer-api.js';
|
|
3
|
+
/**
|
|
4
|
+
* Creates a mock multiplayer API client that can be used without running a backend server. The
|
|
5
|
+
* returned API client mocks all HTTP endpoints and WebSocket connections.
|
|
6
|
+
*
|
|
7
|
+
* - If a client connects to a room that doesn't exist, the room is created and the client becomes the
|
|
8
|
+
* host.
|
|
9
|
+
* - If a client connects to a room that already exists, the offer is forwarded to the host and the
|
|
10
|
+
* host's answer is relayed back to the connecting client.
|
|
11
|
+
*
|
|
12
|
+
* This is useful for demos, testing, and development scenarios where you want the multiplayer code
|
|
13
|
+
* path to work without a real backend.
|
|
14
|
+
*
|
|
15
|
+
* Use the returned mock API client with lower-level multiplayer controllers.
|
|
16
|
+
*
|
|
17
|
+
* @category Main
|
|
18
|
+
* @example
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* import {createNewRoom, createMockRoomHandlerServerApiClient} from '@antha/multiplayer-core';
|
|
22
|
+
* import {P2pLockStepMultiplayerController} from '@antha/multiplayer-p2p-lock-step';
|
|
23
|
+
*
|
|
24
|
+
* const mockApiClient = createMockRoomHandlerServerApiClient();
|
|
25
|
+
* const p2pLockStep = new P2pLockStepMultiplayerController({
|
|
26
|
+
* gameId: 'my-game',
|
|
27
|
+
* frameDuration: {milliseconds: 16},
|
|
28
|
+
* });
|
|
29
|
+
* await p2pLockStep.initMultiplayer({
|
|
30
|
+
* backendOrigin: mockApiClient.baseUrl,
|
|
31
|
+
* multiplayerApiClient: mockApiClient,
|
|
32
|
+
* });
|
|
33
|
+
* await p2pLockStep.joinOrCreateRoom(
|
|
34
|
+
* createNewRoom({
|
|
35
|
+
* roomName: 'My Room',
|
|
36
|
+
* }),
|
|
37
|
+
* );
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export declare function createMockRoomHandlerServerApiClient(options?: Readonly<PartialWithUndefined<{
|
|
41
|
+
/** Mock rooms to return from the `/rooms` endpoint. */
|
|
42
|
+
rooms: Readonly<MultiplayerClientRooms>;
|
|
43
|
+
}>>): import("@rest-vir/api").RestVirClient<{
|
|
44
|
+
apiName: string;
|
|
45
|
+
endpoints: Readonly<Record<"/" | "/health" | "/rooms", Readonly<import("@rest-vir/api").EndpointDefinition>>>;
|
|
46
|
+
webSockets: Readonly<Record<"/connect", Readonly<import("@rest-vir/api").WebSocketDefinition>>>;
|
|
47
|
+
}>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createMockHost, HttpMethod, HttpStatus } from '@rest-vir/api';
|
|
2
|
+
import { multiplayerApi, multiplayerConnectWebSocket, multiplayerHealthEndpoint, multiplayerRoomsEndpoint, multiplayerRootEndpoint, } from '../multiplayer-api/multiplayer-api.js';
|
|
3
|
+
import { createMultiplayerRoomHandler } from './multiplayer-room-handler.js';
|
|
4
|
+
/**
|
|
5
|
+
* Creates a mock multiplayer API client that can be used without running a backend server. The
|
|
6
|
+
* returned API client mocks all HTTP endpoints and WebSocket connections.
|
|
7
|
+
*
|
|
8
|
+
* - If a client connects to a room that doesn't exist, the room is created and the client becomes the
|
|
9
|
+
* host.
|
|
10
|
+
* - If a client connects to a room that already exists, the offer is forwarded to the host and the
|
|
11
|
+
* host's answer is relayed back to the connecting client.
|
|
12
|
+
*
|
|
13
|
+
* This is useful for demos, testing, and development scenarios where you want the multiplayer code
|
|
14
|
+
* path to work without a real backend.
|
|
15
|
+
*
|
|
16
|
+
* Use the returned mock API client with lower-level multiplayer controllers.
|
|
17
|
+
*
|
|
18
|
+
* @category Main
|
|
19
|
+
* @example
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* import {createNewRoom, createMockRoomHandlerServerApiClient} from '@antha/multiplayer-core';
|
|
23
|
+
* import {P2pLockStepMultiplayerController} from '@antha/multiplayer-p2p-lock-step';
|
|
24
|
+
*
|
|
25
|
+
* const mockApiClient = createMockRoomHandlerServerApiClient();
|
|
26
|
+
* const p2pLockStep = new P2pLockStepMultiplayerController({
|
|
27
|
+
* gameId: 'my-game',
|
|
28
|
+
* frameDuration: {milliseconds: 16},
|
|
29
|
+
* });
|
|
30
|
+
* await p2pLockStep.initMultiplayer({
|
|
31
|
+
* backendOrigin: mockApiClient.baseUrl,
|
|
32
|
+
* multiplayerApiClient: mockApiClient,
|
|
33
|
+
* });
|
|
34
|
+
* await p2pLockStep.joinOrCreateRoom(
|
|
35
|
+
* createNewRoom({
|
|
36
|
+
* roomName: 'My Room',
|
|
37
|
+
* }),
|
|
38
|
+
* );
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function createMockRoomHandlerServerApiClient(options) {
|
|
42
|
+
const defaultGameId = 'mock';
|
|
43
|
+
const roomHandler = createMultiplayerRoomHandler({
|
|
44
|
+
disablePeriodicCleanup: true,
|
|
45
|
+
});
|
|
46
|
+
/** Seed any initially provided rooms into the fetching cache. */
|
|
47
|
+
if (options?.rooms) {
|
|
48
|
+
Object.assign(roomHandler.state.roomsForFetching, {
|
|
49
|
+
[defaultGameId]: {
|
|
50
|
+
...options.rooms,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return createMockHost(multiplayerApi, {
|
|
55
|
+
endpoints: {
|
|
56
|
+
[multiplayerRootEndpoint.path]: {
|
|
57
|
+
[HttpMethod.Get]() {
|
|
58
|
+
return {
|
|
59
|
+
[HttpStatus.Ok]: {
|
|
60
|
+
responseData: 'ok',
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
[multiplayerHealthEndpoint.path]: {
|
|
66
|
+
[HttpMethod.Get]() {
|
|
67
|
+
return {
|
|
68
|
+
[HttpStatus.Ok]: {
|
|
69
|
+
responseData: 'ok',
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
[multiplayerRoomsEndpoint.path]: {
|
|
75
|
+
[HttpMethod.Get]() {
|
|
76
|
+
return {
|
|
77
|
+
[HttpStatus.Ok]: {
|
|
78
|
+
responseData: roomHandler.getRoomsForFetching(defaultGameId),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
webSockets: {
|
|
85
|
+
[multiplayerConnectWebSocket.path]: {
|
|
86
|
+
message({ message, webSocket }) {
|
|
87
|
+
roomHandler.enqueueMessage({
|
|
88
|
+
gameId: defaultGameId,
|
|
89
|
+
message,
|
|
90
|
+
transport: {
|
|
91
|
+
send(hostMessage) {
|
|
92
|
+
webSocket.send(hostMessage);
|
|
93
|
+
},
|
|
94
|
+
get readyState() {
|
|
95
|
+
return webSocket.readyState;
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
roomHandler.processQueue();
|
|
100
|
+
},
|
|
101
|
+
close() {
|
|
102
|
+
roomHandler.updateRoomsForFetching(defaultGameId);
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { type PartialWithUndefined } from '@augment-vir/common';
|
|
2
|
+
import { CommonWebSocketState } from '@rest-vir/api';
|
|
3
|
+
import { type MultiplayerClientRoom, type MultiplayerClientRooms, type MultiplayerConnectClientMessage, type MultiplayerConnectHostMessage } from '../multiplayer-api/multiplayer-api.js';
|
|
4
|
+
import { type ClientId, type RoomId } from '../multiplayer-id.js';
|
|
5
|
+
/**
|
|
6
|
+
* A transport-agnostic client handle that the room handler uses to send messages and check
|
|
7
|
+
* connection state. Both real server WebSockets and mock WebSockets implement this.
|
|
8
|
+
*
|
|
9
|
+
* @category Internal
|
|
10
|
+
*/
|
|
11
|
+
export type MultiplayerTransportClient = {
|
|
12
|
+
/** Send a signaling message to the connected transport client. */
|
|
13
|
+
send: (message: MultiplayerConnectHostMessage) => void;
|
|
14
|
+
/** Current WebSocket-compatible connection state. */
|
|
15
|
+
readyState: CommonWebSocketState;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* An individual multiplayer client tracked by the room handler.
|
|
19
|
+
*
|
|
20
|
+
* @category Internal
|
|
21
|
+
*/
|
|
22
|
+
export type RoomHandlerClient = {
|
|
23
|
+
clientId: ClientId;
|
|
24
|
+
clientSecret: string;
|
|
25
|
+
transport: MultiplayerTransportClient;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* A multiplayer room managed by the room handler.
|
|
29
|
+
*
|
|
30
|
+
* @category Internal
|
|
31
|
+
*/
|
|
32
|
+
export type RoomHandlerRoom = {
|
|
33
|
+
clientsAwaitingAnswer: Record<ClientId, RoomHandlerClient>;
|
|
34
|
+
hostClient: RoomHandlerClient;
|
|
35
|
+
clientCount: number;
|
|
36
|
+
roomPassword: string;
|
|
37
|
+
lastHostPingTimestamp: number;
|
|
38
|
+
} & Pick<MultiplayerClientRoom, 'roomName' | 'roomId'>;
|
|
39
|
+
/**
|
|
40
|
+
* A collection of rooms grouped by game id.
|
|
41
|
+
*
|
|
42
|
+
* @category Internal
|
|
43
|
+
*/
|
|
44
|
+
export type RoomHandlerRooms = {
|
|
45
|
+
[GameId in string]: Record<RoomId, RoomHandlerRoom>;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Logger interface for the room handler.
|
|
49
|
+
*
|
|
50
|
+
* @category Internal
|
|
51
|
+
*/
|
|
52
|
+
export type RoomHandlerLogger = {
|
|
53
|
+
/** Log informational room handler messages. */
|
|
54
|
+
info: (...args: ReadonlyArray<unknown>) => void;
|
|
55
|
+
/** Log room handler errors. */
|
|
56
|
+
error: (error: Error) => void;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* The internal state for the multiplayer room handler.
|
|
60
|
+
*
|
|
61
|
+
* @category Internal
|
|
62
|
+
*/
|
|
63
|
+
export type MultiplayerRoomHandlerState = {
|
|
64
|
+
rooms: RoomHandlerRooms;
|
|
65
|
+
messageQueue: {
|
|
66
|
+
gameId: string;
|
|
67
|
+
transport: MultiplayerTransportClient;
|
|
68
|
+
message: MultiplayerConnectClientMessage;
|
|
69
|
+
}[];
|
|
70
|
+
isProcessingQueue: boolean;
|
|
71
|
+
updateRoomsIntervalId: ReturnType<typeof setInterval> | undefined;
|
|
72
|
+
/**
|
|
73
|
+
* Precomputed client-friendly room listing, updated on an interval for real servers or on
|
|
74
|
+
* demand for mocks.
|
|
75
|
+
*/
|
|
76
|
+
roomsForFetching: {
|
|
77
|
+
[GameId in string]: MultiplayerClientRooms;
|
|
78
|
+
};
|
|
79
|
+
logger: RoomHandlerLogger;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Options for creating a {@link MultiplayerRoomHandler}.
|
|
83
|
+
*
|
|
84
|
+
* @category Internal
|
|
85
|
+
*/
|
|
86
|
+
export type MultiplayerRoomHandlerOptions = PartialWithUndefined<{
|
|
87
|
+
logger: RoomHandlerLogger;
|
|
88
|
+
/**
|
|
89
|
+
* When true, disables the periodic interval that cleans up stale rooms and updates
|
|
90
|
+
* `roomsForFetching`. This is useful for mock/frontend environments where the interval is
|
|
91
|
+
* unnecessary.
|
|
92
|
+
*/
|
|
93
|
+
disablePeriodicCleanup: boolean;
|
|
94
|
+
}>;
|
|
95
|
+
/**
|
|
96
|
+
* Creates a transport-agnostic multiplayer room handler. This contains the core room management and
|
|
97
|
+
* message routing logic shared between the real server and mock/frontend implementations.
|
|
98
|
+
*
|
|
99
|
+
* @category Internal
|
|
100
|
+
*/
|
|
101
|
+
export declare function createMultiplayerRoomHandler(options?: Readonly<MultiplayerRoomHandlerOptions>): {
|
|
102
|
+
state: MultiplayerRoomHandlerState;
|
|
103
|
+
/** Enqueue a message without immediately processing the queue. */
|
|
104
|
+
enqueueMessage(params: {
|
|
105
|
+
gameId: string;
|
|
106
|
+
transport: MultiplayerTransportClient;
|
|
107
|
+
message: MultiplayerConnectClientMessage;
|
|
108
|
+
}): void;
|
|
109
|
+
/** Process all queued messages. Safe to call multiple times; re-entrant calls are no-ops. */
|
|
110
|
+
processQueue(): void;
|
|
111
|
+
/** Get the client-friendly room listing for a game. */
|
|
112
|
+
getRoomsForFetching(gameId: string): MultiplayerClientRooms;
|
|
113
|
+
/** Force an immediate update of the rooms-for-fetching cache for a game. */
|
|
114
|
+
updateRoomsForFetching(gameId: string): void;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* The return type of {@link createMultiplayerRoomHandler}.
|
|
118
|
+
*
|
|
119
|
+
* @category Internal
|
|
120
|
+
*/
|
|
121
|
+
export type MultiplayerRoomHandler = ReturnType<typeof createMultiplayerRoomHandler>;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { getOrSet, mapObjectValues, omitObjectKeys, stringify, } from '@augment-vir/common';
|
|
2
|
+
import { CommonWebSocketState } from '@rest-vir/api';
|
|
3
|
+
import { convertDuration } from 'date-vir';
|
|
4
|
+
import { createMultiplayerId } from '../multiplayer-id.js';
|
|
5
|
+
import { MultiplayerWebSocketMessageType } from '../webrtc/web-rtc-communication.js';
|
|
6
|
+
const silentLogger = {
|
|
7
|
+
info() { },
|
|
8
|
+
error() { },
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Creates a transport-agnostic multiplayer room handler. This contains the core room management and
|
|
12
|
+
* message routing logic shared between the real server and mock/frontend implementations.
|
|
13
|
+
*
|
|
14
|
+
* @category Internal
|
|
15
|
+
*/
|
|
16
|
+
export function createMultiplayerRoomHandler(options) {
|
|
17
|
+
const state = {
|
|
18
|
+
rooms: {},
|
|
19
|
+
messageQueue: [],
|
|
20
|
+
isProcessingQueue: false,
|
|
21
|
+
updateRoomsIntervalId: undefined,
|
|
22
|
+
roomsForFetching: {},
|
|
23
|
+
logger: options?.logger || silentLogger,
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
state,
|
|
27
|
+
/** Enqueue a message without immediately processing the queue. */
|
|
28
|
+
enqueueMessage(params) {
|
|
29
|
+
state.messageQueue.push(params);
|
|
30
|
+
},
|
|
31
|
+
/** Process all queued messages. Safe to call multiple times; re-entrant calls are no-ops. */
|
|
32
|
+
processQueue() {
|
|
33
|
+
processQueue(state, options?.disablePeriodicCleanup || false);
|
|
34
|
+
},
|
|
35
|
+
/** Get the client-friendly room listing for a game. */
|
|
36
|
+
getRoomsForFetching(gameId) {
|
|
37
|
+
return state.roomsForFetching[gameId] || {};
|
|
38
|
+
},
|
|
39
|
+
/** Force an immediate update of the rooms-for-fetching cache for a game. */
|
|
40
|
+
updateRoomsForFetching(gameId) {
|
|
41
|
+
updateRoomsForFetching(gameId, state);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const updateRoomsForFetchingIntervalDuration = convertDuration({
|
|
46
|
+
seconds: 5,
|
|
47
|
+
}, {
|
|
48
|
+
milliseconds: true,
|
|
49
|
+
});
|
|
50
|
+
function updateRoomsForFetchingOnInterval(state) {
|
|
51
|
+
if (state.updateRoomsIntervalId) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
state.updateRoomsIntervalId = setInterval(() => {
|
|
55
|
+
Object.keys(state.rooms).forEach((gameId) => updateRoomsForFetching(gameId, state));
|
|
56
|
+
if (!Object.keys(state.rooms).length) {
|
|
57
|
+
clearInterval(state.updateRoomsIntervalId);
|
|
58
|
+
state.updateRoomsIntervalId = undefined;
|
|
59
|
+
}
|
|
60
|
+
}, updateRoomsForFetchingIntervalDuration.milliseconds);
|
|
61
|
+
}
|
|
62
|
+
function updateRoomsForFetching(gameId, state) {
|
|
63
|
+
const gameRooms = state.rooms[gameId];
|
|
64
|
+
if (!gameRooms) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
Object.values(gameRooms).forEach((room) => {
|
|
68
|
+
if (
|
|
69
|
+
/** Delete a room if its host is no longer active. */
|
|
70
|
+
room.hostClient.transport.readyState !== CommonWebSocketState.Open ||
|
|
71
|
+
/** Delete a room if the host reports no active room clients. */
|
|
72
|
+
room.clientCount <= 0 ||
|
|
73
|
+
/** Delete a room if it has had no updates from the host for two cycles. */
|
|
74
|
+
room.lastHostPingTimestamp <=
|
|
75
|
+
Date.now() - updateRoomsForFetchingIntervalDuration.milliseconds * 2) {
|
|
76
|
+
delete gameRooms[room.roomId];
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
if (!Object.keys(gameRooms).length) {
|
|
80
|
+
delete state.rooms[gameId];
|
|
81
|
+
delete state.roomsForFetching[gameId];
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
state.roomsForFetching[gameId] = mapObjectValues(gameRooms, (roomId, room) => {
|
|
85
|
+
return {
|
|
86
|
+
clientCount: room.clientCount,
|
|
87
|
+
hasRoomPassword: !!room.roomPassword,
|
|
88
|
+
roomId,
|
|
89
|
+
roomName: room.roomName,
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function processQueueItem(state, { message, transport, gameId, }) {
|
|
94
|
+
const room = state.rooms[gameId]?.[message.roomId]?.hostClient.transport.readyState ===
|
|
95
|
+
CommonWebSocketState.Open
|
|
96
|
+
? state.rooms[gameId][message.roomId]
|
|
97
|
+
: undefined;
|
|
98
|
+
const currentClient = {
|
|
99
|
+
clientId: message.clientId,
|
|
100
|
+
transport,
|
|
101
|
+
clientSecret: 'clientSecret' in message ? message.clientSecret : '',
|
|
102
|
+
};
|
|
103
|
+
if (message.type === MultiplayerWebSocketMessageType.Offer) {
|
|
104
|
+
if (room) {
|
|
105
|
+
/** The client is connecting to an existing room with a valid host. */
|
|
106
|
+
if (room.roomPassword && message.roomPassword !== room.roomPassword) {
|
|
107
|
+
transport.send({
|
|
108
|
+
messageId: message.messageId,
|
|
109
|
+
type: MultiplayerWebSocketMessageType.Error,
|
|
110
|
+
errorMessage: 'Invalid password.',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
state.logger.info(`Sending offer to host ${room.hostClient.clientId} in room ${room.roomName} (${room.roomId})`);
|
|
115
|
+
room.clientsAwaitingAnswer[currentClient.clientId] = currentClient;
|
|
116
|
+
room.hostClient.transport.send(omitObjectKeys(message, [
|
|
117
|
+
'clientSecret',
|
|
118
|
+
'roomPassword',
|
|
119
|
+
]));
|
|
120
|
+
transport.send({
|
|
121
|
+
messageId: message.messageId,
|
|
122
|
+
type: MultiplayerWebSocketMessageType.OfferResult,
|
|
123
|
+
hostClientId: room.hostClient.clientId,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
/**
|
|
129
|
+
* The client is either creating a new room or joining a room that just died (so create
|
|
130
|
+
* a new one).
|
|
131
|
+
*/
|
|
132
|
+
const newRoom = {
|
|
133
|
+
clientsAwaitingAnswer: {},
|
|
134
|
+
hostClient: currentClient,
|
|
135
|
+
roomName: message.roomName,
|
|
136
|
+
roomId: message.roomId,
|
|
137
|
+
roomPassword: message.roomPassword,
|
|
138
|
+
clientCount: 1,
|
|
139
|
+
lastHostPingTimestamp: Date.now(),
|
|
140
|
+
};
|
|
141
|
+
state.logger.info(`Creating new room '${newRoom.roomName}' with id '${newRoom.roomId}' and host '${currentClient.clientId}'`);
|
|
142
|
+
getOrSet(state.rooms, gameId, () => {
|
|
143
|
+
return {};
|
|
144
|
+
})[newRoom.roomId] = newRoom;
|
|
145
|
+
updateRoomsForFetching(gameId, state);
|
|
146
|
+
transport.send({
|
|
147
|
+
messageId: message.messageId,
|
|
148
|
+
type: MultiplayerWebSocketMessageType.OfferResult,
|
|
149
|
+
hostClientId: newRoom.hostClient.clientId,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
else if (message.type === MultiplayerWebSocketMessageType.Answer) {
|
|
154
|
+
/** The host client is sending an answer to one of its clients. */
|
|
155
|
+
state.logger.info(`Received answer from ${message.clientId}`);
|
|
156
|
+
const client = room?.clientsAwaitingAnswer[message.clientId];
|
|
157
|
+
if (client && client.transport.readyState === CommonWebSocketState.Open) {
|
|
158
|
+
/**
|
|
159
|
+
* Now that we're sending an answer to this client, we can remove it from the list of
|
|
160
|
+
* clients that are waiting for answers.
|
|
161
|
+
*/
|
|
162
|
+
delete room.clientsAwaitingAnswer[message.clientId];
|
|
163
|
+
state.logger.info(`Sending answer to host ${message.clientId} in room ${room.roomName} (${room.roomId}).`);
|
|
164
|
+
state.logger.info(`Sending answer to ${client.clientId}`);
|
|
165
|
+
client.transport.send(message);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
const errorMessage = `No client found waiting for an answer by id ${message.clientId}`;
|
|
169
|
+
state.logger.error(new Error(errorMessage));
|
|
170
|
+
transport.send({
|
|
171
|
+
messageId: message.messageId,
|
|
172
|
+
type: MultiplayerWebSocketMessageType.Error,
|
|
173
|
+
errorMessage,
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
178
|
+
}
|
|
179
|
+
else if (message.type === MultiplayerWebSocketMessageType.HostPing) {
|
|
180
|
+
if (room && room.hostClient.clientSecret === message.clientSecret) {
|
|
181
|
+
room.clientCount = message.clientCount;
|
|
182
|
+
room.roomName = message.roomName;
|
|
183
|
+
room.roomPassword = message.roomPassword;
|
|
184
|
+
room.lastHostPingTimestamp = Date.now();
|
|
185
|
+
updateRoomsForFetching(gameId, state);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
transport.send({
|
|
189
|
+
messageId: message.messageId,
|
|
190
|
+
type: MultiplayerWebSocketMessageType.Error,
|
|
191
|
+
errorMessage: 'Invalid room to ping.',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
transport.send({
|
|
197
|
+
messageId: createMultiplayerId.socketMessage(),
|
|
198
|
+
type: MultiplayerWebSocketMessageType.Error,
|
|
199
|
+
errorMessage: `Invalid message: ${stringify(message)}`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function processQueue(state, disablePeriodicCleanup) {
|
|
204
|
+
if (state.isProcessingQueue) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
state.isProcessingQueue = true;
|
|
208
|
+
if (!disablePeriodicCleanup) {
|
|
209
|
+
updateRoomsForFetchingOnInterval(state);
|
|
210
|
+
}
|
|
211
|
+
let nextItem;
|
|
212
|
+
while ((nextItem = state.messageQueue.shift())) {
|
|
213
|
+
processQueueItem(state, nextItem);
|
|
214
|
+
}
|
|
215
|
+
state.isProcessingQueue = false;
|
|
216
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* All possible message types sent to and from the game-vir multiplayer server. These are used to
|
|
3
|
+
* establish WebRTC connections.
|
|
4
|
+
*
|
|
5
|
+
* @category Internal
|
|
6
|
+
*/
|
|
7
|
+
export declare enum MultiplayerWebSocketMessageType {
|
|
8
|
+
/**
|
|
9
|
+
* Sent to the multiplayer server from a client when they want to connect. The offer is then
|
|
10
|
+
* forwarded to the host client.
|
|
11
|
+
*
|
|
12
|
+
* If a host has not been set yet, then this client becomes the host.
|
|
13
|
+
*/
|
|
14
|
+
Offer = "offer",
|
|
15
|
+
/**
|
|
16
|
+
* Sent to the multiplayer server from a host client (in response to an offer), then forwarded
|
|
17
|
+
* to the client.
|
|
18
|
+
*
|
|
19
|
+
* If the offer was rejected, this will contain significantly less data.
|
|
20
|
+
*/
|
|
21
|
+
Answer = "answer",
|
|
22
|
+
/**
|
|
23
|
+
* Sent from the multiplayer server to a client WebSocket after their offer has been processed
|
|
24
|
+
* to instruct the client on what kind of client they are (member or host).
|
|
25
|
+
*/
|
|
26
|
+
OfferResult = "offer-result",
|
|
27
|
+
/**
|
|
28
|
+
* A message sent to the multiplayer server from the host client to keep the room info up to
|
|
29
|
+
* date. This will be sent repeatedly on an interval.
|
|
30
|
+
*/
|
|
31
|
+
HostPing = "host-ping",
|
|
32
|
+
/** An error message. */
|
|
33
|
+
Error = "error"
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Shape definition for {@link WebrtcAnswer}.
|
|
37
|
+
*
|
|
38
|
+
* @category Internal
|
|
39
|
+
*/
|
|
40
|
+
export declare const webrtcAnswerShape: import("object-shape-tester").Shape<{
|
|
41
|
+
type: import("object-shape-tester").Shape<import("@sinclair/typebox").TUnsafe<MultiplayerWebSocketMessageType.Answer>>;
|
|
42
|
+
sdp: string;
|
|
43
|
+
}>;
|
|
44
|
+
/**
|
|
45
|
+
* WebRTC handshake answer data.
|
|
46
|
+
*
|
|
47
|
+
* @category Internal
|
|
48
|
+
*/
|
|
49
|
+
export type WebrtcAnswer = typeof webrtcAnswerShape.runtimeType;
|
|
50
|
+
/**
|
|
51
|
+
* Shape definition for {@link WebrtcOffer}.
|
|
52
|
+
*
|
|
53
|
+
* @category Internal
|
|
54
|
+
*/
|
|
55
|
+
export declare const webrtcOfferShape: import("object-shape-tester").Shape<{
|
|
56
|
+
type: import("object-shape-tester").Shape<import("@sinclair/typebox").TUnsafe<MultiplayerWebSocketMessageType.Offer>>;
|
|
57
|
+
sdp: string;
|
|
58
|
+
}>;
|
|
59
|
+
/**
|
|
60
|
+
* WebRTC handshake offer data.
|
|
61
|
+
*
|
|
62
|
+
* @category Internal
|
|
63
|
+
*/
|
|
64
|
+
export type WebrtcOffer = typeof webrtcOfferShape.runtimeType;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { defineShape, exactShape } from 'object-shape-tester';
|
|
2
|
+
/**
|
|
3
|
+
* All possible message types sent to and from the game-vir multiplayer server. These are used to
|
|
4
|
+
* establish WebRTC connections.
|
|
5
|
+
*
|
|
6
|
+
* @category Internal
|
|
7
|
+
*/
|
|
8
|
+
export var MultiplayerWebSocketMessageType;
|
|
9
|
+
(function (MultiplayerWebSocketMessageType) {
|
|
10
|
+
/**
|
|
11
|
+
* Sent to the multiplayer server from a client when they want to connect. The offer is then
|
|
12
|
+
* forwarded to the host client.
|
|
13
|
+
*
|
|
14
|
+
* If a host has not been set yet, then this client becomes the host.
|
|
15
|
+
*/
|
|
16
|
+
MultiplayerWebSocketMessageType["Offer"] = "offer";
|
|
17
|
+
/**
|
|
18
|
+
* Sent to the multiplayer server from a host client (in response to an offer), then forwarded
|
|
19
|
+
* to the client.
|
|
20
|
+
*
|
|
21
|
+
* If the offer was rejected, this will contain significantly less data.
|
|
22
|
+
*/
|
|
23
|
+
MultiplayerWebSocketMessageType["Answer"] = "answer";
|
|
24
|
+
/**
|
|
25
|
+
* Sent from the multiplayer server to a client WebSocket after their offer has been processed
|
|
26
|
+
* to instruct the client on what kind of client they are (member or host).
|
|
27
|
+
*/
|
|
28
|
+
MultiplayerWebSocketMessageType["OfferResult"] = "offer-result";
|
|
29
|
+
/**
|
|
30
|
+
* A message sent to the multiplayer server from the host client to keep the room info up to
|
|
31
|
+
* date. This will be sent repeatedly on an interval.
|
|
32
|
+
*/
|
|
33
|
+
MultiplayerWebSocketMessageType["HostPing"] = "host-ping";
|
|
34
|
+
/** An error message. */
|
|
35
|
+
MultiplayerWebSocketMessageType["Error"] = "error";
|
|
36
|
+
})(MultiplayerWebSocketMessageType || (MultiplayerWebSocketMessageType = {}));
|
|
37
|
+
/**
|
|
38
|
+
* Shape definition for {@link WebrtcAnswer}.
|
|
39
|
+
*
|
|
40
|
+
* @category Internal
|
|
41
|
+
*/
|
|
42
|
+
export const webrtcAnswerShape = defineShape({
|
|
43
|
+
type: exactShape(MultiplayerWebSocketMessageType.Answer),
|
|
44
|
+
sdp: '',
|
|
45
|
+
});
|
|
46
|
+
/**
|
|
47
|
+
* Shape definition for {@link WebrtcOffer}.
|
|
48
|
+
*
|
|
49
|
+
* @category Internal
|
|
50
|
+
*/
|
|
51
|
+
export const webrtcOfferShape = defineShape({
|
|
52
|
+
type: exactShape(MultiplayerWebSocketMessageType.Offer),
|
|
53
|
+
sdp: '',
|
|
54
|
+
});
|