@colyseus/core 0.17.0 → 0.17.2
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/build/rooms/RankedQueueRoom.js.map +2 -2
- package/build/rooms/RankedQueueRoom.mjs.map +2 -2
- package/package.json +7 -6
- package/src/Debug.ts +37 -0
- package/src/IPC.ts +124 -0
- package/src/Logger.ts +30 -0
- package/src/MatchMaker.ts +1119 -0
- package/src/Protocol.ts +160 -0
- package/src/Room.ts +1797 -0
- package/src/Server.ts +325 -0
- package/src/Stats.ts +107 -0
- package/src/Transport.ts +207 -0
- package/src/errors/RoomExceptions.ts +141 -0
- package/src/errors/SeatReservationError.ts +5 -0
- package/src/errors/ServerError.ts +17 -0
- package/src/index.ts +81 -0
- package/src/matchmaker/Lobby.ts +68 -0
- package/src/matchmaker/LocalDriver/LocalDriver.ts +92 -0
- package/src/matchmaker/LocalDriver/Query.ts +94 -0
- package/src/matchmaker/RegisteredHandler.ts +172 -0
- package/src/matchmaker/controller.ts +64 -0
- package/src/matchmaker/driver.ts +191 -0
- package/src/presence/LocalPresence.ts +331 -0
- package/src/presence/Presence.ts +263 -0
- package/src/rooms/LobbyRoom.ts +135 -0
- package/src/rooms/RankedQueueRoom.ts +425 -0
- package/src/rooms/RelayRoom.ts +90 -0
- package/src/router/default_routes.ts +58 -0
- package/src/router/index.ts +43 -0
- package/src/serializer/NoneSerializer.ts +16 -0
- package/src/serializer/SchemaSerializer.ts +194 -0
- package/src/serializer/SchemaSerializerDebug.ts +148 -0
- package/src/serializer/Serializer.ts +9 -0
- package/src/utils/DevMode.ts +133 -0
- package/src/utils/StandardSchema.ts +20 -0
- package/src/utils/Utils.ts +169 -0
- package/src/utils/nanoevents.ts +20 -0
package/src/Room.ts
ADDED
|
@@ -0,0 +1,1797 @@
|
|
|
1
|
+
import { unpack } from '@colyseus/msgpackr';
|
|
2
|
+
import { decode, type Iterator, $changes } from '@colyseus/schema';
|
|
3
|
+
import { ClockTimer as Clock } from '@colyseus/timer';
|
|
4
|
+
|
|
5
|
+
import { EventEmitter } from 'events';
|
|
6
|
+
import { logger } from './Logger.ts';
|
|
7
|
+
|
|
8
|
+
import type { Presence } from './presence/Presence.ts';
|
|
9
|
+
import type { Serializer } from './serializer/Serializer.ts';
|
|
10
|
+
import type { IRoomCache } from './matchmaker/driver.ts';
|
|
11
|
+
|
|
12
|
+
import { NoneSerializer } from './serializer/NoneSerializer.ts';
|
|
13
|
+
import { SchemaSerializer } from './serializer/SchemaSerializer.ts';
|
|
14
|
+
|
|
15
|
+
import { CloseCode, ErrorCode, getMessageBytes, Protocol } from './Protocol.ts';
|
|
16
|
+
import { type Type, Deferred, generateId, wrapTryCatch } from './utils/Utils.ts';
|
|
17
|
+
import { createNanoEvents } from './utils/nanoevents.ts';
|
|
18
|
+
import { isDevMode } from './utils/DevMode.ts';
|
|
19
|
+
|
|
20
|
+
import { debugAndPrintError, debugMatchMaking, debugMessage } from './Debug.ts';
|
|
21
|
+
import { ServerError } from './errors/ServerError.ts';
|
|
22
|
+
import { ClientState, type AuthContext, type Client, type ClientPrivate, ClientArray, type ISendOptions, type MessageArgs } from './Transport.ts';
|
|
23
|
+
import { type RoomMethodName, OnAuthException, OnCreateException, OnDisposeException, OnJoinException, OnLeaveException, OnMessageException, type RoomException, SimulationIntervalException, TimedEventException } from './errors/RoomExceptions.ts';
|
|
24
|
+
|
|
25
|
+
import { standardValidate, type StandardSchemaV1 } from './utils/StandardSchema.ts';
|
|
26
|
+
import { matchMaker } from '@colyseus/core';
|
|
27
|
+
|
|
28
|
+
const DEFAULT_PATCH_RATE = 1000 / 20; // 20fps (50ms)
|
|
29
|
+
const DEFAULT_SIMULATION_INTERVAL = 1000 / 60; // 60fps (16.66ms)
|
|
30
|
+
const noneSerializer = new NoneSerializer();
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_SEAT_RESERVATION_TIME = Number(process.env.COLYSEUS_SEAT_RESERVATION_TIME || 15);
|
|
33
|
+
|
|
34
|
+
export type SimulationCallback = (deltaTime: number) => void;
|
|
35
|
+
|
|
36
|
+
export interface RoomOptions {
|
|
37
|
+
state?: object;
|
|
38
|
+
metadata?: any;
|
|
39
|
+
client?: Client;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Helper types to extract individual properties from RoomOptions
|
|
43
|
+
export type ExtractRoomState<T> = T extends { state?: infer S extends object } ? S : any;
|
|
44
|
+
export type ExtractRoomMetadata<T> = T extends { metadata?: infer M } ? M : any;
|
|
45
|
+
export type ExtractRoomClient<T> = T extends { client?: infer C extends Client } ? C : Client;
|
|
46
|
+
|
|
47
|
+
export interface IBroadcastOptions extends ISendOptions {
|
|
48
|
+
except?: Client | Client[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Message handler with automatic type inference from format schema.
|
|
53
|
+
* When a format is provided, the message type is automatically inferred from the schema.
|
|
54
|
+
*/
|
|
55
|
+
export type MessageHandlerWithFormat<T extends StandardSchemaV1 = any, This = any> = {
|
|
56
|
+
format: T;
|
|
57
|
+
handler: (this: This, client: Client, message: StandardSchemaV1.InferOutput<T>) => void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type MessageHandler<This = any> =
|
|
61
|
+
| ((this: This, client: Client, message: any) => void)
|
|
62
|
+
| MessageHandlerWithFormat<any, This>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Extract the message payload type from a message handler.
|
|
66
|
+
* Works with both function handlers and format handlers.
|
|
67
|
+
*
|
|
68
|
+
* (Imported from @colyseus/sdk, not used in the server-side)
|
|
69
|
+
*/
|
|
70
|
+
export type ExtractMessageType<T> =
|
|
71
|
+
T extends { format: infer Format extends StandardSchemaV1; handler: any }
|
|
72
|
+
? StandardSchemaV1.InferOutput<Format>
|
|
73
|
+
: T extends (this: any, client: any, message: infer Message) => void
|
|
74
|
+
? Message
|
|
75
|
+
: any;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A map of message types to message handlers.
|
|
79
|
+
*/
|
|
80
|
+
export type Messages<This extends Room> = Record<string, MessageHandler<This>>;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Helper function to create a validated message handler with automatic type inference.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```typescript
|
|
87
|
+
* messages = {
|
|
88
|
+
* move: validate(z.object({ x: z.number(), y: z.number() }), (client, message) => {
|
|
89
|
+
* // message.x and message.y are automatically typed as numbers
|
|
90
|
+
* console.log(message.x, message.y);
|
|
91
|
+
* })
|
|
92
|
+
* }
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
export function validate<T extends StandardSchemaV1, This = any>(
|
|
96
|
+
format: T,
|
|
97
|
+
handler: (this: This, client: Client, message: StandardSchemaV1.InferOutput<T>) => void
|
|
98
|
+
): MessageHandlerWithFormat<T, This> {
|
|
99
|
+
return { format, handler };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const RoomInternalState = {
|
|
103
|
+
CREATING: 0,
|
|
104
|
+
CREATED: 1,
|
|
105
|
+
DISPOSING: 2,
|
|
106
|
+
} as const;
|
|
107
|
+
export type RoomInternalState = (typeof RoomInternalState)[keyof typeof RoomInternalState];
|
|
108
|
+
|
|
109
|
+
export type OnCreateOptions<T extends Type<Room>> = Parameters<NonNullable<InstanceType<T>['onCreate']>>[0];
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A Room class is meant to implement a game session, and/or serve as the communication channel
|
|
113
|
+
* between a group of clients.
|
|
114
|
+
*
|
|
115
|
+
* - Rooms are created on demand during matchmaking by default
|
|
116
|
+
* - Room classes must be exposed using `.define()`
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```typescript
|
|
120
|
+
* class MyRoom extends Room<{
|
|
121
|
+
* state: MyState,
|
|
122
|
+
* metadata: { difficulty: string },
|
|
123
|
+
* client: MyClient
|
|
124
|
+
* }> {
|
|
125
|
+
* // ...
|
|
126
|
+
* }
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
export class Room<T extends RoomOptions = RoomOptions> {
|
|
130
|
+
'~client': ExtractRoomClient<T>;
|
|
131
|
+
'~state': ExtractRoomState<T>;
|
|
132
|
+
'~metadata': ExtractRoomMetadata<T>;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* This property will change on these situations:
|
|
136
|
+
* - The maximum number of allowed clients has been reached (`maxClients`)
|
|
137
|
+
* - You manually locked, or unlocked the room using lock() or `unlock()`.
|
|
138
|
+
*
|
|
139
|
+
* @readonly
|
|
140
|
+
*/
|
|
141
|
+
public get locked() {
|
|
142
|
+
return this.#_locked;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get the room's matchmaking metadata.
|
|
147
|
+
*/
|
|
148
|
+
public get metadata(): ExtractRoomMetadata<T> {
|
|
149
|
+
return this._listing.metadata;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Set the room's matchmaking metadata.
|
|
154
|
+
*
|
|
155
|
+
* **Note**: This setter does NOT automatically persist. Use `setMatchmaking()` for automatic persistence.
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```typescript
|
|
159
|
+
* class MyRoom extends Room<{ metadata: { difficulty: string; rating: number } }> {
|
|
160
|
+
* async onCreate() {
|
|
161
|
+
* this.metadata = { difficulty: "hard", rating: 1500 };
|
|
162
|
+
* }
|
|
163
|
+
* }
|
|
164
|
+
* ```
|
|
165
|
+
*/
|
|
166
|
+
public set metadata(meta: ExtractRoomMetadata<T>) {
|
|
167
|
+
if (this._internalState !== RoomInternalState.CREATING) {
|
|
168
|
+
// prevent user from setting metadata after room has been created.
|
|
169
|
+
throw new ServerError(ErrorCode.APPLICATION_ERROR, "'metadata' can only be manually set during onCreate(). Use setMatchmaking() instead.");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this._listing.metadata = meta;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The room listing cache for matchmaking.
|
|
177
|
+
* @internal
|
|
178
|
+
*/
|
|
179
|
+
private _listing: IRoomCache<ExtractRoomMetadata<T>>;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Timing events tied to the room instance.
|
|
183
|
+
* Intervals and timeouts are cleared when the room is disposed.
|
|
184
|
+
*/
|
|
185
|
+
public clock: Clock = new Clock();
|
|
186
|
+
|
|
187
|
+
#_roomId: string;
|
|
188
|
+
#_roomName: string;
|
|
189
|
+
#_onLeaveConcurrent: number = 0; // number of onLeave calls in progress
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Maximum number of clients allowed to connect into the room. When room reaches this limit,
|
|
193
|
+
* it is locked automatically. Unless the room was explicitly locked by you via `lock()` method,
|
|
194
|
+
* the room will be unlocked as soon as a client disconnects from it.
|
|
195
|
+
*/
|
|
196
|
+
public maxClients: number = Infinity;
|
|
197
|
+
#_maxClientsReached: boolean = false;
|
|
198
|
+
#_maxClients: number;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Automatically dispose the room when last client disconnects.
|
|
202
|
+
*
|
|
203
|
+
* @default true
|
|
204
|
+
*/
|
|
205
|
+
public autoDispose: boolean = true;
|
|
206
|
+
#_autoDispose: boolean;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Frequency to send the room state to connected clients, in milliseconds.
|
|
210
|
+
*
|
|
211
|
+
* @default 50ms (20fps)
|
|
212
|
+
*/
|
|
213
|
+
public patchRate: number | null = DEFAULT_PATCH_RATE;
|
|
214
|
+
#_patchRate: number;
|
|
215
|
+
#_patchInterval: NodeJS.Timeout;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Maximum number of messages a client can send to the server per second.
|
|
219
|
+
* If a client sends more messages than this, it will be disconnected.
|
|
220
|
+
*
|
|
221
|
+
* @default 60
|
|
222
|
+
*/
|
|
223
|
+
public maxMessagesPerSecond: number = 60;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The state instance you provided to `setState()`.
|
|
227
|
+
*/
|
|
228
|
+
public state: ExtractRoomState<T>;
|
|
229
|
+
#_state: ExtractRoomState<T>;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The presence instance. Check Presence API for more details.
|
|
233
|
+
*
|
|
234
|
+
* @see [Presence API](https://docs.colyseus.io/server/presence)
|
|
235
|
+
*/
|
|
236
|
+
public presence: Presence;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The array of connected clients.
|
|
240
|
+
*
|
|
241
|
+
* @see [Client instance](https://docs.colyseus.io/room#client)
|
|
242
|
+
*/
|
|
243
|
+
public clients: ClientArray<ExtractRoomClient<T>> = new ClientArray();
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Set the number of seconds a room can wait for a client to effectively join the room.
|
|
247
|
+
* You should consider how long your `onAuth()` will have to wait for setting a different seat reservation time.
|
|
248
|
+
* The default value is 15 seconds. You may set the `COLYSEUS_SEAT_RESERVATION_TIME`
|
|
249
|
+
* environment variable if you'd like to change the seat reservation time globally.
|
|
250
|
+
*
|
|
251
|
+
* @default 15 seconds
|
|
252
|
+
*/
|
|
253
|
+
public seatReservationTimeout: number = DEFAULT_SEAT_RESERVATION_TIME;
|
|
254
|
+
|
|
255
|
+
private _events = new EventEmitter();
|
|
256
|
+
|
|
257
|
+
private _reservedSeats: { [sessionId: string]: [any, any, boolean?, boolean?] } = {};
|
|
258
|
+
private _reservedSeatTimeouts: { [sessionId: string]: NodeJS.Timeout } = {};
|
|
259
|
+
|
|
260
|
+
private _reconnections: { [reconnectionToken: string]: [string, Deferred] } = {};
|
|
261
|
+
|
|
262
|
+
public messages?: Messages<any>;
|
|
263
|
+
|
|
264
|
+
private onMessageEvents = createNanoEvents();
|
|
265
|
+
private onMessageValidators: {[message: string]: StandardSchemaV1} = {};
|
|
266
|
+
|
|
267
|
+
private onMessageFallbacks = {
|
|
268
|
+
'__no_message_handler': (client: ExtractRoomClient<T>, messageType: string | number, _: unknown) => {
|
|
269
|
+
const errorMessage = `room onMessage for "${messageType}" not registered.`;
|
|
270
|
+
debugMessage(`${errorMessage} (roomId: ${this.roomId})`);
|
|
271
|
+
|
|
272
|
+
if (isDevMode) {
|
|
273
|
+
// send error code to client in development mode
|
|
274
|
+
client.error(ErrorCode.INVALID_PAYLOAD, errorMessage);
|
|
275
|
+
|
|
276
|
+
} else {
|
|
277
|
+
// immediately close the connection in production
|
|
278
|
+
client.leave(CloseCode.WITH_ERROR, errorMessage);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
private _serializer: Serializer<ExtractRoomState<T>> = noneSerializer;
|
|
284
|
+
private _afterNextPatchQueue: Array<[string | number | ExtractRoomClient<T>, ArrayLike<any>]> = [];
|
|
285
|
+
|
|
286
|
+
private _simulationInterval: NodeJS.Timeout;
|
|
287
|
+
|
|
288
|
+
private _internalState: RoomInternalState = RoomInternalState.CREATING;
|
|
289
|
+
|
|
290
|
+
private _lockedExplicitly: boolean = false;
|
|
291
|
+
#_locked: boolean = false;
|
|
292
|
+
|
|
293
|
+
// this timeout prevents rooms that are created by one process, but no client
|
|
294
|
+
// ever had success joining into it on the specified interval.
|
|
295
|
+
private _autoDisposeTimeout: NodeJS.Timeout;
|
|
296
|
+
|
|
297
|
+
constructor() {
|
|
298
|
+
this._events.once('dispose', () => {
|
|
299
|
+
this.#_dispose()
|
|
300
|
+
.catch((e) => debugAndPrintError(`onDispose error: ${(e && e.stack || e.message || e || 'promise rejected')} (roomId: ${this.roomId})`))
|
|
301
|
+
.finally(() => this._events.emit('disconnect'));
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* If `onUncaughtException` is defined, it will automatically catch exceptions
|
|
306
|
+
*/
|
|
307
|
+
if (this.onUncaughtException !== undefined) {
|
|
308
|
+
this.#registerUncaughtExceptionHandlers();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* This method is called by the MatchMaker before onCreate()
|
|
314
|
+
* @internal
|
|
315
|
+
*/
|
|
316
|
+
private __init() {
|
|
317
|
+
this.#_state = this.state;
|
|
318
|
+
this.#_autoDispose = this.autoDispose;
|
|
319
|
+
this.#_patchRate = this.patchRate;
|
|
320
|
+
this.#_maxClients = this.maxClients;
|
|
321
|
+
|
|
322
|
+
Object.defineProperties(this, {
|
|
323
|
+
state: {
|
|
324
|
+
enumerable: true,
|
|
325
|
+
get: () => this.#_state,
|
|
326
|
+
set: (newState: ExtractRoomState<T>) => {
|
|
327
|
+
if (newState?.constructor[Symbol.metadata] !== undefined || newState[$changes] !== undefined) {
|
|
328
|
+
this.setSerializer(new SchemaSerializer());
|
|
329
|
+
} else if ('_definition' in newState) {
|
|
330
|
+
throw new Error("@colyseus/schema v2 compatibility currently missing (reach out if you need it)");
|
|
331
|
+
} else if ($changes === undefined) {
|
|
332
|
+
throw new Error("Multiple @colyseus/schema versions detected. Please make sure you don't have multiple versions of @colyseus/schema installed.");
|
|
333
|
+
}
|
|
334
|
+
this._serializer.reset(newState);
|
|
335
|
+
this.#_state = newState;
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
maxClients: {
|
|
340
|
+
enumerable: true,
|
|
341
|
+
get: () => this.#_maxClients,
|
|
342
|
+
set: (value: number) => {
|
|
343
|
+
this.setMatchmaking({ maxClients: value });
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
autoDispose: {
|
|
348
|
+
enumerable: true,
|
|
349
|
+
get: () => this.#_autoDispose,
|
|
350
|
+
set: (value: boolean) => {
|
|
351
|
+
if (
|
|
352
|
+
value !== this.#_autoDispose &&
|
|
353
|
+
this._internalState !== RoomInternalState.DISPOSING
|
|
354
|
+
) {
|
|
355
|
+
this.#_autoDispose = value;
|
|
356
|
+
this.resetAutoDisposeTimeout();
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
patchRate: {
|
|
362
|
+
enumerable: true,
|
|
363
|
+
get: () => this.#_patchRate,
|
|
364
|
+
set: (milliseconds: number) => {
|
|
365
|
+
this.#_patchRate = milliseconds;
|
|
366
|
+
// clear previous interval in case called setPatchRate more than once
|
|
367
|
+
if (this.#_patchInterval) {
|
|
368
|
+
clearInterval(this.#_patchInterval);
|
|
369
|
+
this.#_patchInterval = undefined;
|
|
370
|
+
}
|
|
371
|
+
if (milliseconds !== null && milliseconds !== 0) {
|
|
372
|
+
this.#_patchInterval = setInterval(() => this.broadcastPatch(), milliseconds);
|
|
373
|
+
} else if (!this._simulationInterval) {
|
|
374
|
+
// When patchRate and no simulation interval are both set to 0, tick the clock to keep timers working
|
|
375
|
+
this.#_patchInterval = setInterval(() => this.clock.tick(), DEFAULT_SIMULATION_INTERVAL);
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// set patch interval, now with the setter
|
|
382
|
+
this.patchRate = this.#_patchRate;
|
|
383
|
+
|
|
384
|
+
// set state, now with the setter
|
|
385
|
+
if (this.#_state) {
|
|
386
|
+
this.state = this.#_state;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Bind messages to the room
|
|
390
|
+
if (this.messages !== undefined) {
|
|
391
|
+
Object.entries(this.messages).forEach(([messageType, callback]) => {
|
|
392
|
+
if (typeof callback === 'function') {
|
|
393
|
+
// Direct handler function - bind to room instance
|
|
394
|
+
this.onMessage(messageType, callback.bind(this));
|
|
395
|
+
} else {
|
|
396
|
+
// Object with format and handler - bind handler to room instance
|
|
397
|
+
this.onMessage(messageType, callback.format, callback.handler.bind(this));
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// set default _autoDisposeTimeout
|
|
403
|
+
this.resetAutoDisposeTimeout(this.seatReservationTimeout);
|
|
404
|
+
|
|
405
|
+
this.clock.start();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The name of the room you provided as first argument for `gameServer.define()`.
|
|
410
|
+
*
|
|
411
|
+
* @returns roomName string
|
|
412
|
+
*/
|
|
413
|
+
public get roomName() { return this.#_roomName; }
|
|
414
|
+
/**
|
|
415
|
+
* Setting the name of the room. Overwriting this property is restricted.
|
|
416
|
+
*
|
|
417
|
+
* @param roomName
|
|
418
|
+
*/
|
|
419
|
+
public set roomName(roomName: string) {
|
|
420
|
+
if (this.#_roomName) {
|
|
421
|
+
// prevent user from setting roomName after it has been defined.
|
|
422
|
+
throw new ServerError(ErrorCode.APPLICATION_ERROR, "'roomName' cannot be overwritten.");
|
|
423
|
+
}
|
|
424
|
+
this.#_roomName = roomName;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* A unique, auto-generated, 9-character-long id of the room.
|
|
429
|
+
* You may replace `this.roomId` during `onCreate()`.
|
|
430
|
+
*
|
|
431
|
+
* @returns roomId string
|
|
432
|
+
*/
|
|
433
|
+
public get roomId() { return this.#_roomId; }
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Setting the roomId, is restricted in room lifetime except upon room creation.
|
|
437
|
+
*
|
|
438
|
+
* @param roomId
|
|
439
|
+
* @returns roomId string
|
|
440
|
+
*/
|
|
441
|
+
public set roomId(roomId: string) {
|
|
442
|
+
if (this._internalState !== RoomInternalState.CREATING && !isDevMode) {
|
|
443
|
+
// prevent user from setting roomId after room has been created.
|
|
444
|
+
throw new ServerError(ErrorCode.APPLICATION_ERROR, "'roomId' can only be overridden upon room creation.");
|
|
445
|
+
}
|
|
446
|
+
this.#_roomId = roomId;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Optional abstract methods
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* This method is called before the latest version of the room's state is broadcasted to all clients.
|
|
453
|
+
*/
|
|
454
|
+
public onBeforePatch?(state: ExtractRoomState<T>): void | Promise<any>;
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* This method is called when the room is created.
|
|
458
|
+
* @param options - The options passed to the room when it is created.
|
|
459
|
+
*/
|
|
460
|
+
public onCreate?(options: any): void | Promise<any>;
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* This method is called when a client joins the room.
|
|
464
|
+
* @param client - The client that joined the room.
|
|
465
|
+
* @param options - The options passed to the client when it joined the room.
|
|
466
|
+
*/
|
|
467
|
+
public onJoin?(client: ExtractRoomClient<T>, options?: any): void | Promise<any>;
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* This method is called when a client leaves the room without consent.
|
|
471
|
+
* You may allow the client to reconnect by calling `allowReconnection` within this method.
|
|
472
|
+
*
|
|
473
|
+
* @param client - The client that was dropped from the room.
|
|
474
|
+
* @param code - The close code of the leave event.
|
|
475
|
+
*/
|
|
476
|
+
public onDrop?(client: ExtractRoomClient<T>, code?: number): void | Promise<any>;
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* This method is called when a client reconnects to the room.
|
|
480
|
+
* @param client - The client that reconnected to the room.
|
|
481
|
+
*/
|
|
482
|
+
public onReconnect?(client: ExtractRoomClient<T>): void | Promise<any>;
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* This method is called when a client effectively leaves the room.
|
|
486
|
+
* @param client - The client that left the room.
|
|
487
|
+
* @param code - The close code of the leave event.
|
|
488
|
+
*/
|
|
489
|
+
public onLeave?(client: ExtractRoomClient<T>, code?: number): void | Promise<any>;
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* This method is called when the room is disposed.
|
|
493
|
+
*/
|
|
494
|
+
public onDispose?(): void | Promise<any>;
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Define a custom exception handler.
|
|
498
|
+
* If defined, all lifecycle hooks will be wrapped by try/catch, and the exception will be forwarded to this method.
|
|
499
|
+
*
|
|
500
|
+
* These methods will be wrapped by try/catch:
|
|
501
|
+
* - `onMessage`
|
|
502
|
+
* - `onAuth` / `onJoin` / `onLeave` / `onCreate` / `onDispose`
|
|
503
|
+
* - `clock.setTimeout` / `clock.setInterval`
|
|
504
|
+
* - `setSimulationInterval`
|
|
505
|
+
*
|
|
506
|
+
* (Experimental: this feature is subject to change in the future - we're currently getting feedback to improve it)
|
|
507
|
+
*/
|
|
508
|
+
public onUncaughtException?(error: RoomException, methodName: RoomMethodName): void;
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* This method is called before onJoin() - this is where you should authenticate the client
|
|
512
|
+
* @param client - The client that is authenticating.
|
|
513
|
+
* @param options - The options passed to the client when it is authenticating.
|
|
514
|
+
* @param context - The authentication context, including the token and the client's IP address.
|
|
515
|
+
* @returns The authentication result.
|
|
516
|
+
*
|
|
517
|
+
* @example
|
|
518
|
+
* ```typescript
|
|
519
|
+
* return {
|
|
520
|
+
* userId: 123,
|
|
521
|
+
* username: "John Doe",
|
|
522
|
+
* email: "john.doe@example.com",
|
|
523
|
+
* };
|
|
524
|
+
* ```
|
|
525
|
+
*/
|
|
526
|
+
public onAuth(
|
|
527
|
+
client: Client,
|
|
528
|
+
options: any,
|
|
529
|
+
context: AuthContext
|
|
530
|
+
): any | Promise<any> {
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
static async onAuth(
|
|
535
|
+
token: string,
|
|
536
|
+
options: any,
|
|
537
|
+
context: AuthContext
|
|
538
|
+
): Promise<unknown> {
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* This method is called during graceful shutdown of the server process
|
|
544
|
+
* You may override this method to dispose the room in your own way.
|
|
545
|
+
*
|
|
546
|
+
* Once process reaches room count of 0, the room process will be terminated.
|
|
547
|
+
*/
|
|
548
|
+
public onBeforeShutdown() {
|
|
549
|
+
this.disconnect(
|
|
550
|
+
(isDevMode)
|
|
551
|
+
? CloseCode.DEVMODE_RESTART
|
|
552
|
+
: CloseCode.SERVER_SHUTDOWN
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* devMode: When `devMode` is enabled, `onCacheRoom` method is called during
|
|
558
|
+
* graceful shutdown.
|
|
559
|
+
*
|
|
560
|
+
* Implement this method to return custom data to be cached. `onRestoreRoom`
|
|
561
|
+
* will be called with the data returned by `onCacheRoom`
|
|
562
|
+
*/
|
|
563
|
+
public onCacheRoom?(): any;
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* devMode: When `devMode` is enabled, `onRestoreRoom` method is called during
|
|
567
|
+
* process startup, with the data returned by the `onCacheRoom` method.
|
|
568
|
+
*/
|
|
569
|
+
public onRestoreRoom?(cached?: any): void;
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Returns whether the sum of connected clients and reserved seats exceeds maximum number of clients.
|
|
573
|
+
*
|
|
574
|
+
* @returns boolean
|
|
575
|
+
*/
|
|
576
|
+
public hasReachedMaxClients(): boolean {
|
|
577
|
+
return (
|
|
578
|
+
(this.clients.length + Object.keys(this._reservedSeats).length) >= this.#_maxClients ||
|
|
579
|
+
this._internalState === RoomInternalState.DISPOSING
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* @deprecated Use `seatReservationTimeout=` instead.
|
|
585
|
+
*/
|
|
586
|
+
public setSeatReservationTime(seconds: number) {
|
|
587
|
+
console.warn(`DEPRECATED: .setSeatReservationTime(${seconds}) is deprecated. Assign a .seatReservationTimeout property value instead.`);
|
|
588
|
+
this.seatReservationTimeout = seconds;
|
|
589
|
+
return this;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
public hasReservedSeat(sessionId: string, reconnectionToken?: string): boolean {
|
|
593
|
+
const reservedSeat = this._reservedSeats[sessionId];
|
|
594
|
+
|
|
595
|
+
// seat reservation not found / expired
|
|
596
|
+
if (reservedSeat === undefined) {
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (reservedSeat[3]) {
|
|
601
|
+
// reconnection
|
|
602
|
+
return (reconnectionToken && this._reconnections[reconnectionToken]?.[0] === sessionId);
|
|
603
|
+
|
|
604
|
+
} else {
|
|
605
|
+
// seat reservation not consumed
|
|
606
|
+
return reservedSeat[2] === false;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
public checkReconnectionToken(reconnectionToken: string) {
|
|
611
|
+
const sessionId = this._reconnections[reconnectionToken]?.[0];
|
|
612
|
+
const reservedSeat = this._reservedSeats[sessionId];
|
|
613
|
+
|
|
614
|
+
if (reservedSeat && reservedSeat[3]) {
|
|
615
|
+
return sessionId;
|
|
616
|
+
|
|
617
|
+
} else {
|
|
618
|
+
return undefined;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* (Optional) Set a simulation interval that can change the state of the game.
|
|
624
|
+
* The simulation interval is your game loop.
|
|
625
|
+
*
|
|
626
|
+
* @default 16.6ms (60fps)
|
|
627
|
+
*
|
|
628
|
+
* @param onTickCallback - You can implement your physics or world updates here!
|
|
629
|
+
* This is a good place to update the room state.
|
|
630
|
+
* @param delay - Interval delay on executing `onTickCallback` in milliseconds.
|
|
631
|
+
*/
|
|
632
|
+
public setSimulationInterval(onTickCallback?: SimulationCallback, delay: number = DEFAULT_SIMULATION_INTERVAL): void {
|
|
633
|
+
// clear previous interval in case called setSimulationInterval more than once
|
|
634
|
+
if (this._simulationInterval) { clearInterval(this._simulationInterval); }
|
|
635
|
+
|
|
636
|
+
if (onTickCallback) {
|
|
637
|
+
if (this.onUncaughtException !== undefined) {
|
|
638
|
+
onTickCallback = wrapTryCatch(onTickCallback, this.onUncaughtException.bind(this), SimulationIntervalException, 'setSimulationInterval');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
this._simulationInterval = setInterval(() => {
|
|
642
|
+
this.clock.tick();
|
|
643
|
+
onTickCallback(this.clock.deltaTime);
|
|
644
|
+
}, delay);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* @deprecated Use `.patchRate=` instead.
|
|
650
|
+
*/
|
|
651
|
+
public setPatchRate(milliseconds: number | null): void {
|
|
652
|
+
this.patchRate = milliseconds;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* @deprecated Use `.state =` instead.
|
|
657
|
+
*/
|
|
658
|
+
public setState(newState: ExtractRoomState<T>) {
|
|
659
|
+
this.state = newState;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
public setSerializer(serializer: Serializer<ExtractRoomState<T>>) {
|
|
663
|
+
this._serializer = serializer;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
public async setMetadata(meta: Partial<ExtractRoomMetadata<T>>, persist: boolean = true) {
|
|
667
|
+
if (!this._listing.metadata) {
|
|
668
|
+
this._listing.metadata = meta as ExtractRoomMetadata<T>;
|
|
669
|
+
|
|
670
|
+
} else {
|
|
671
|
+
for (const field in meta) {
|
|
672
|
+
if (!meta.hasOwnProperty(field)) { continue; }
|
|
673
|
+
this._listing.metadata[field] = meta[field];
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// `MongooseDriver` workaround: persit metadata mutations
|
|
677
|
+
if ('markModified' in this._listing) {
|
|
678
|
+
(this._listing as any).markModified('metadata');
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (persist && this._internalState === RoomInternalState.CREATED) {
|
|
683
|
+
await matchMaker.driver.persist(this._listing);
|
|
684
|
+
|
|
685
|
+
// emit metadata-change event to update lobby listing
|
|
686
|
+
this._events.emit('metadata-change');
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
public async setPrivate(bool: boolean = true, persist: boolean = true) {
|
|
691
|
+
if (this._listing.private === bool) return;
|
|
692
|
+
|
|
693
|
+
this._listing.private = bool;
|
|
694
|
+
|
|
695
|
+
if (persist && this._internalState === RoomInternalState.CREATED) {
|
|
696
|
+
await matchMaker.driver.persist(this._listing);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// emit visibility-change event to update lobby listing
|
|
700
|
+
this._events.emit('visibility-change', bool);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Update multiple matchmaking/listing properties at once with a single persist operation.
|
|
705
|
+
* This is the recommended way to update room listing properties.
|
|
706
|
+
*
|
|
707
|
+
* @param updates - Object containing the properties to update
|
|
708
|
+
*
|
|
709
|
+
* @example
|
|
710
|
+
* ```typescript
|
|
711
|
+
* // Update multiple properties at once
|
|
712
|
+
* await this.setMatchmaking({
|
|
713
|
+
* metadata: { difficulty: "hard", rating: 1500 },
|
|
714
|
+
* private: true,
|
|
715
|
+
* locked: true,
|
|
716
|
+
* maxClients: 10
|
|
717
|
+
* });
|
|
718
|
+
* ```
|
|
719
|
+
*
|
|
720
|
+
* @example
|
|
721
|
+
* ```typescript
|
|
722
|
+
* // Update only metadata
|
|
723
|
+
* await this.setMatchmaking({
|
|
724
|
+
* metadata: { status: "in_progress" }
|
|
725
|
+
* });
|
|
726
|
+
* ```
|
|
727
|
+
*
|
|
728
|
+
* @example
|
|
729
|
+
* ```typescript
|
|
730
|
+
* // Partial metadata update (merges with existing)
|
|
731
|
+
* await this.setMatchmaking({
|
|
732
|
+
* metadata: { ...this.metadata, round: this.metadata.round + 1 }
|
|
733
|
+
* });
|
|
734
|
+
* ```
|
|
735
|
+
*/
|
|
736
|
+
public async setMatchmaking(updates: {
|
|
737
|
+
metadata?: ExtractRoomMetadata<T>;
|
|
738
|
+
private?: boolean;
|
|
739
|
+
locked?: boolean;
|
|
740
|
+
maxClients?: number;
|
|
741
|
+
unlisted?: boolean;
|
|
742
|
+
[key: string]: any;
|
|
743
|
+
}) {
|
|
744
|
+
for (const key in updates) {
|
|
745
|
+
if (!updates.hasOwnProperty(key)) { continue; }
|
|
746
|
+
|
|
747
|
+
switch (key) {
|
|
748
|
+
case 'metadata': {
|
|
749
|
+
this.setMetadata(updates.metadata, false);
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
case 'private': {
|
|
754
|
+
this.setPrivate(updates.private, false);
|
|
755
|
+
break;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
case 'locked': {
|
|
759
|
+
if (updates[key]) {
|
|
760
|
+
// @ts-ignore
|
|
761
|
+
this.lock.call(this, true);
|
|
762
|
+
this._lockedExplicitly = true;
|
|
763
|
+
} else {
|
|
764
|
+
// @ts-ignore
|
|
765
|
+
this.unlock.call(this, true);
|
|
766
|
+
this._lockedExplicitly = false;
|
|
767
|
+
}
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
case 'maxClients': {
|
|
772
|
+
this.#_maxClients = updates.maxClients;
|
|
773
|
+
this._listing.maxClients = updates.maxClients;
|
|
774
|
+
|
|
775
|
+
const hasReachedMaxClients = this.hasReachedMaxClients();
|
|
776
|
+
|
|
777
|
+
// unlock room if maxClients has been increased
|
|
778
|
+
if (!this._lockedExplicitly && this.#_maxClientsReached && !hasReachedMaxClients) {
|
|
779
|
+
this.#_maxClientsReached = false;
|
|
780
|
+
this.#_locked = false;
|
|
781
|
+
this._listing.locked = false;
|
|
782
|
+
updates.locked = false;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// lock room if maxClients has been decreased
|
|
786
|
+
if (hasReachedMaxClients) {
|
|
787
|
+
this.#_maxClientsReached = true;
|
|
788
|
+
this.#_locked = true;
|
|
789
|
+
this._listing.locked = true;
|
|
790
|
+
updates.locked = true;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
break;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
case 'clients': {
|
|
797
|
+
console.warn("setMatchmaking() does not allow updating 'clients' property.");
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
default: {
|
|
802
|
+
// Allow any other listing properties to be updated
|
|
803
|
+
this._listing[key] = updates[key];
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Only persist if room is not CREATING
|
|
810
|
+
if (this._internalState === RoomInternalState.CREATED) {
|
|
811
|
+
await matchMaker.driver.update(this._listing, { $set: updates });
|
|
812
|
+
|
|
813
|
+
// emit metadata-change event to update lobby listing
|
|
814
|
+
this._events.emit('metadata-change');
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Lock the room. This prevents new clients from joining this room.
|
|
820
|
+
*/
|
|
821
|
+
public async lock() {
|
|
822
|
+
// rooms locked internally aren't explicit locks.
|
|
823
|
+
this._lockedExplicitly = (arguments[0] === undefined);
|
|
824
|
+
|
|
825
|
+
// skip if already locked.
|
|
826
|
+
if (this.#_locked) { return; }
|
|
827
|
+
|
|
828
|
+
this.#_locked = true;
|
|
829
|
+
|
|
830
|
+
// Only persist if this is an explicit lock/unlock
|
|
831
|
+
if (this._lockedExplicitly) {
|
|
832
|
+
await matchMaker.driver.update(this._listing, {
|
|
833
|
+
$set: { locked: this.#_locked },
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
this._events.emit('lock');
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Unlock the room. This allows new clients to join this room, if maxClients is not reached.
|
|
842
|
+
*/
|
|
843
|
+
public async unlock() {
|
|
844
|
+
// only internal usage passes arguments to this function.
|
|
845
|
+
if (arguments[0] === undefined) {
|
|
846
|
+
this._lockedExplicitly = false;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// skip if already locked
|
|
850
|
+
if (!this.#_locked) { return; }
|
|
851
|
+
|
|
852
|
+
this.#_locked = false;
|
|
853
|
+
|
|
854
|
+
// Only persist if this is an explicit lock/unlock
|
|
855
|
+
if (arguments[0] === undefined) {
|
|
856
|
+
await matchMaker.driver.update(this._listing, {
|
|
857
|
+
$set: { locked: this.#_locked },
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
this._events.emit('unlock');
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* @deprecated Use `client.send(...)` instead.
|
|
866
|
+
*/
|
|
867
|
+
public send(client: Client, type: string | number, message: any, options?: ISendOptions): void;
|
|
868
|
+
public send(client: Client, messageOrType: any, messageOrOptions?: any | ISendOptions, options?: ISendOptions): void {
|
|
869
|
+
logger.warn('DEPRECATION WARNING: use client.send(...) instead of this.send(client, ...)');
|
|
870
|
+
client.send(messageOrType, messageOrOptions, options);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Broadcast a message to all connected clients.
|
|
875
|
+
* @param type - The type of the message.
|
|
876
|
+
* @param message - The message to broadcast.
|
|
877
|
+
* @param options - The options for the broadcast.
|
|
878
|
+
*
|
|
879
|
+
* @example
|
|
880
|
+
* ```typescript
|
|
881
|
+
* this.broadcast('message', { message: 'Hello, world!' });
|
|
882
|
+
* ```
|
|
883
|
+
*/
|
|
884
|
+
public broadcast<K extends keyof ExtractRoomClient<T>['~messages'] & string | number>(
|
|
885
|
+
type: K,
|
|
886
|
+
...args: MessageArgs<ExtractRoomClient<T>['~messages'][K], IBroadcastOptions>
|
|
887
|
+
) {
|
|
888
|
+
const [message, options] = args;
|
|
889
|
+
if (options && options.afterNextPatch) {
|
|
890
|
+
delete options.afterNextPatch;
|
|
891
|
+
this._afterNextPatchQueue.push(['broadcast', [type, ...args]]);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
this.broadcastMessageType(type, message, options);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Broadcast bytes (UInt8Arrays) to a particular room
|
|
900
|
+
*/
|
|
901
|
+
public broadcastBytes(type: string | number, message: Uint8Array, options: IBroadcastOptions) {
|
|
902
|
+
if (options && options.afterNextPatch) {
|
|
903
|
+
delete options.afterNextPatch;
|
|
904
|
+
this._afterNextPatchQueue.push(['broadcastBytes', arguments]);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
this.broadcastMessageType(type as string, message, options);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Checks whether mutations have occurred in the state, and broadcast them to all connected clients.
|
|
913
|
+
*/
|
|
914
|
+
public broadcastPatch() {
|
|
915
|
+
if (this.onBeforePatch) {
|
|
916
|
+
this.onBeforePatch(this.state);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (!this._simulationInterval) {
|
|
920
|
+
this.clock.tick();
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (!this.state) {
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const hasChanges = this._serializer.applyPatches(this.clients, this.state);
|
|
928
|
+
|
|
929
|
+
// broadcast messages enqueued for "after patch"
|
|
930
|
+
this._dequeueAfterPatchMessages();
|
|
931
|
+
|
|
932
|
+
return hasChanges;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Register a message handler for a specific message type.
|
|
937
|
+
* This method is used to handle messages sent by clients to the room.
|
|
938
|
+
* @param messageType - The type of the message.
|
|
939
|
+
* @param callback - The callback to call when the message is received.
|
|
940
|
+
* @returns A function to unbind the callback.
|
|
941
|
+
*
|
|
942
|
+
* @example
|
|
943
|
+
* ```typescript
|
|
944
|
+
* this.onMessage('message', (client, message) => {
|
|
945
|
+
* console.log(message);
|
|
946
|
+
* });
|
|
947
|
+
* ```
|
|
948
|
+
*
|
|
949
|
+
* @example
|
|
950
|
+
* ```typescript
|
|
951
|
+
* const unbind = this.onMessage('message', (client, message) => {
|
|
952
|
+
* console.log(message);
|
|
953
|
+
* });
|
|
954
|
+
*
|
|
955
|
+
* // Unbind the callback when no longer needed
|
|
956
|
+
* unbind();
|
|
957
|
+
* ```
|
|
958
|
+
*/
|
|
959
|
+
public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(
|
|
960
|
+
// public onMessage<T = any, C extends Client = TClient>(
|
|
961
|
+
messageType: '*',
|
|
962
|
+
callback: (client: C, type: string | number, message: T) => void
|
|
963
|
+
);
|
|
964
|
+
public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(
|
|
965
|
+
// public onMessage<T = any, C extends Client = TClient>(
|
|
966
|
+
messageType: string | number,
|
|
967
|
+
callback: (client: C, message: T) => void,
|
|
968
|
+
);
|
|
969
|
+
public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(
|
|
970
|
+
// public onMessage<T = any, C extends Client = TClient>(
|
|
971
|
+
messageType: string | number,
|
|
972
|
+
validationSchema: StandardSchemaV1<T>,
|
|
973
|
+
callback: (client: C, message: T) => void,
|
|
974
|
+
);
|
|
975
|
+
public onMessage<T = any>(
|
|
976
|
+
_messageType: '*' | string | number,
|
|
977
|
+
_validationSchema: StandardSchemaV1<T> | ((...args: any[]) => void),
|
|
978
|
+
_callback?: (...args: any[]) => void,
|
|
979
|
+
) {
|
|
980
|
+
const messageType = _messageType.toString();
|
|
981
|
+
|
|
982
|
+
const validationSchema = (typeof _callback === 'function')
|
|
983
|
+
? _validationSchema as StandardSchemaV1<T>
|
|
984
|
+
: undefined;
|
|
985
|
+
|
|
986
|
+
const callback = (validationSchema === undefined)
|
|
987
|
+
? _validationSchema as (...args: any[]) => void
|
|
988
|
+
: _callback;
|
|
989
|
+
|
|
990
|
+
const removeListener = this.onMessageEvents.on(messageType, (this.onUncaughtException !== undefined)
|
|
991
|
+
? wrapTryCatch(callback, this.onUncaughtException.bind(this), OnMessageException, 'onMessage', false, _messageType)
|
|
992
|
+
: callback);
|
|
993
|
+
|
|
994
|
+
if (validationSchema !== undefined) {
|
|
995
|
+
this.onMessageValidators[messageType] = validationSchema;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// returns a method to unbind the callback
|
|
999
|
+
return () => {
|
|
1000
|
+
removeListener();
|
|
1001
|
+
if (this.onMessageEvents.events[messageType].length === 0) {
|
|
1002
|
+
delete this.onMessageValidators[messageType];
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
public onMessageBytes<T = any, C extends Client = ExtractRoomClient<T>>(
|
|
1008
|
+
// public onMessageBytes<T = any, C extends Client = TClient>(
|
|
1009
|
+
messageType: string | number,
|
|
1010
|
+
callback: (client: C, message: T) => void,
|
|
1011
|
+
);
|
|
1012
|
+
public onMessageBytes<T = any, C extends Client = ExtractRoomClient<T>>(
|
|
1013
|
+
// public onMessageBytes<T = any, C extends Client = TClient>(
|
|
1014
|
+
messageType: string | number,
|
|
1015
|
+
validationSchema: StandardSchemaV1<T>,
|
|
1016
|
+
callback: (client: C, message: T) => void,
|
|
1017
|
+
);
|
|
1018
|
+
public onMessageBytes<T = any>(
|
|
1019
|
+
_messageType: string | number,
|
|
1020
|
+
_validationSchema: StandardSchemaV1<T> | ((...args: any[]) => void),
|
|
1021
|
+
_callback?: (...args: any[]) => void,
|
|
1022
|
+
) {
|
|
1023
|
+
const messageType = `_$b${_messageType}`;
|
|
1024
|
+
|
|
1025
|
+
const validationSchema = (typeof _callback === 'function')
|
|
1026
|
+
? _validationSchema as StandardSchemaV1<T>
|
|
1027
|
+
: undefined;
|
|
1028
|
+
|
|
1029
|
+
const callback = (validationSchema === undefined)
|
|
1030
|
+
? _validationSchema as (...args: any[]) => void
|
|
1031
|
+
: _callback;
|
|
1032
|
+
|
|
1033
|
+
if (validationSchema !== undefined) {
|
|
1034
|
+
return this.onMessage(messageType, validationSchema as any, callback as any);
|
|
1035
|
+
} else {
|
|
1036
|
+
return this.onMessage(messageType, callback as any);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Disconnect all connected clients, and then dispose the room.
|
|
1042
|
+
*
|
|
1043
|
+
* @param closeCode WebSocket close code (default = 4000, which is a "consented leave")
|
|
1044
|
+
* @returns Promise<void>
|
|
1045
|
+
*/
|
|
1046
|
+
public disconnect(closeCode: number = CloseCode.CONSENTED): Promise<any> {
|
|
1047
|
+
// skip if already disposing
|
|
1048
|
+
if (this._internalState === RoomInternalState.DISPOSING) {
|
|
1049
|
+
return Promise.resolve(`disconnect() ignored: room (${this.roomId}) is already disposing.`);
|
|
1050
|
+
|
|
1051
|
+
} else if (this._internalState === RoomInternalState.CREATING) {
|
|
1052
|
+
throw new Error("cannot disconnect during onCreate()");
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
this._internalState = RoomInternalState.DISPOSING;
|
|
1056
|
+
matchMaker.driver.remove(this._listing.roomId);
|
|
1057
|
+
|
|
1058
|
+
this.#_autoDispose = true;
|
|
1059
|
+
|
|
1060
|
+
const delayedDisconnection = new Promise<void>((resolve) =>
|
|
1061
|
+
this._events.once('disconnect', () => resolve()));
|
|
1062
|
+
|
|
1063
|
+
// reject pending reconnections
|
|
1064
|
+
for (const [_, reconnection] of Object.values(this._reconnections)) {
|
|
1065
|
+
reconnection.reject(new Error("disconnecting"));
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
let numClients = this.clients.length;
|
|
1069
|
+
if (numClients > 0) {
|
|
1070
|
+
// clients may have `async onLeave`, room will be disposed after they're fulfilled
|
|
1071
|
+
while (numClients--) {
|
|
1072
|
+
this.#_forciblyCloseClient(this.clients[numClients] as ExtractRoomClient<T> & ClientPrivate, closeCode);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
} else {
|
|
1076
|
+
// no clients connected, dispose immediately.
|
|
1077
|
+
this._events.emit('dispose');
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
return delayedDisconnection;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
private async _onJoin(
|
|
1084
|
+
client: ExtractRoomClient<T> & ClientPrivate,
|
|
1085
|
+
authContext: AuthContext,
|
|
1086
|
+
connectionOptions?: { reconnectionToken?: string, skipHandshake?: boolean }
|
|
1087
|
+
) {
|
|
1088
|
+
const sessionId = client.sessionId;
|
|
1089
|
+
|
|
1090
|
+
// generate unique private reconnection token
|
|
1091
|
+
client.reconnectionToken = generateId();
|
|
1092
|
+
|
|
1093
|
+
if (this._reservedSeatTimeouts[sessionId]) {
|
|
1094
|
+
clearTimeout(this._reservedSeatTimeouts[sessionId]);
|
|
1095
|
+
delete this._reservedSeatTimeouts[sessionId];
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// clear auto-dispose timeout.
|
|
1099
|
+
if (this._autoDisposeTimeout) {
|
|
1100
|
+
clearTimeout(this._autoDisposeTimeout);
|
|
1101
|
+
this._autoDisposeTimeout = undefined;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// get seat reservation options and clear it
|
|
1105
|
+
const [joinOptions, authData, isConsumed, isWaitingReconnection] = this._reservedSeats[sessionId];
|
|
1106
|
+
|
|
1107
|
+
//
|
|
1108
|
+
// TODO: remove this check on 1.0.0
|
|
1109
|
+
// - the seat reservation is used to keep track of number of clients and their pending seats (see `hasReachedMaxClients`)
|
|
1110
|
+
// - when we fully migrate to static onAuth(), the seat reservation can be removed immediately here
|
|
1111
|
+
// - if async onAuth() is in use, the seat reservation is removed after onAuth() is fulfilled.
|
|
1112
|
+
// - mark reservation as "consumed"
|
|
1113
|
+
//
|
|
1114
|
+
if (isConsumed) {
|
|
1115
|
+
throw new ServerError(ErrorCode.MATCHMAKE_EXPIRED, "already consumed");
|
|
1116
|
+
}
|
|
1117
|
+
this._reservedSeats[sessionId][2] = true; // flag seat reservation as "consumed"
|
|
1118
|
+
debugMatchMaking('consuming seat reservation, sessionId: \'%s\' (roomId: %s)', client.sessionId, this.roomId);
|
|
1119
|
+
|
|
1120
|
+
// share "after next patch queue" reference with every client.
|
|
1121
|
+
client._afterNextPatchQueue = this._afterNextPatchQueue;
|
|
1122
|
+
|
|
1123
|
+
// add temporary callback to keep track of disconnections during `onJoin`.
|
|
1124
|
+
client.ref['onleave'] = (_) => client.state = ClientState.LEAVING;
|
|
1125
|
+
client.ref.once('close', client.ref['onleave']);
|
|
1126
|
+
|
|
1127
|
+
if (isWaitingReconnection) {
|
|
1128
|
+
const reconnectionToken = connectionOptions?.reconnectionToken;
|
|
1129
|
+
if (reconnectionToken && this._reconnections[reconnectionToken]?.[0] === sessionId) {
|
|
1130
|
+
this.clients.push(client);
|
|
1131
|
+
//
|
|
1132
|
+
// await for reconnection:
|
|
1133
|
+
// (end user may customize the reconnection token at this step)
|
|
1134
|
+
//
|
|
1135
|
+
await this._reconnections[reconnectionToken]?.[1].resolve(client);
|
|
1136
|
+
|
|
1137
|
+
if (this.onReconnect) {
|
|
1138
|
+
await this.onReconnect(client);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
} else {
|
|
1142
|
+
const errorMessage = (process.env.NODE_ENV === 'production')
|
|
1143
|
+
? "already consumed" // trick possible fraudsters...
|
|
1144
|
+
: "bad reconnection token" // ...or developers
|
|
1145
|
+
throw new ServerError(ErrorCode.MATCHMAKE_EXPIRED, errorMessage);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
} else {
|
|
1149
|
+
try {
|
|
1150
|
+
if (authData) {
|
|
1151
|
+
client.auth = authData;
|
|
1152
|
+
|
|
1153
|
+
} else if (this.onAuth !== Room.prototype.onAuth) {
|
|
1154
|
+
try {
|
|
1155
|
+
client.auth = await this.onAuth(client, joinOptions, authContext);
|
|
1156
|
+
|
|
1157
|
+
if (!client.auth) {
|
|
1158
|
+
throw new ServerError(ErrorCode.AUTH_FAILED, 'onAuth failed');
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
} catch (e) {
|
|
1162
|
+
// remove seat reservation
|
|
1163
|
+
delete this._reservedSeats[sessionId];
|
|
1164
|
+
await this.#_decrementClientCount();
|
|
1165
|
+
throw e;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
//
|
|
1170
|
+
// On async onAuth, client may have been disconnected.
|
|
1171
|
+
//
|
|
1172
|
+
if (client.state === ClientState.LEAVING) {
|
|
1173
|
+
throw new ServerError(CloseCode.WITH_ERROR, 'already disconnected');
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
this.clients.push(client);
|
|
1177
|
+
|
|
1178
|
+
//
|
|
1179
|
+
// Flag sessionId as non-enumarable so hasReachedMaxClients() doesn't count it
|
|
1180
|
+
// (https://github.com/colyseus/colyseus/issues/726)
|
|
1181
|
+
//
|
|
1182
|
+
Object.defineProperty(this._reservedSeats, sessionId, {
|
|
1183
|
+
value: this._reservedSeats[sessionId],
|
|
1184
|
+
enumerable: false,
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
if (this.onJoin) {
|
|
1188
|
+
await this.onJoin(client, joinOptions);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// @ts-ignore: client left during `onJoin`, call _onLeave immediately.
|
|
1192
|
+
if (client.state === ClientState.LEAVING) {
|
|
1193
|
+
throw new ServerError(ErrorCode.MATCHMAKE_UNHANDLED, "early_leave");
|
|
1194
|
+
|
|
1195
|
+
} else {
|
|
1196
|
+
// remove seat reservation
|
|
1197
|
+
delete this._reservedSeats[sessionId];
|
|
1198
|
+
|
|
1199
|
+
// emit 'join' to room handler
|
|
1200
|
+
this._events.emit('join', client);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
} catch (e: any) {
|
|
1204
|
+
await this._onLeave(client, CloseCode.WITH_ERROR);
|
|
1205
|
+
|
|
1206
|
+
// remove seat reservation
|
|
1207
|
+
delete this._reservedSeats[sessionId];
|
|
1208
|
+
|
|
1209
|
+
// make sure an error code is provided.
|
|
1210
|
+
if (!e.code) {
|
|
1211
|
+
e.code = ErrorCode.APPLICATION_ERROR;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
throw e;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// state might already be ClientState.LEAVING here
|
|
1219
|
+
if (client.state === ClientState.JOINING) {
|
|
1220
|
+
client.ref.removeListener('close', client.ref['onleave']);
|
|
1221
|
+
|
|
1222
|
+
// only bind _onLeave after onJoin has been successful
|
|
1223
|
+
client.ref['onleave'] = this._onLeave.bind(this, client);
|
|
1224
|
+
client.ref.once('close', client.ref['onleave']);
|
|
1225
|
+
|
|
1226
|
+
// allow client to send messages after onJoin has succeeded.
|
|
1227
|
+
client.ref.on('message', this._onMessage.bind(this, client));
|
|
1228
|
+
|
|
1229
|
+
// confirm room id that matches the room name requested to join
|
|
1230
|
+
client.raw(getMessageBytes[Protocol.JOIN_ROOM](
|
|
1231
|
+
client.reconnectionToken,
|
|
1232
|
+
this._serializer.id,
|
|
1233
|
+
/**
|
|
1234
|
+
* if skipHandshake is true, we don't need to send the handshake
|
|
1235
|
+
* (in case client already has handshake data)
|
|
1236
|
+
*/
|
|
1237
|
+
(connectionOptions?.skipHandshake)
|
|
1238
|
+
? undefined
|
|
1239
|
+
: this._serializer.handshake && this._serializer.handshake(),
|
|
1240
|
+
));
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Allow the specified client to reconnect into the room. Must be used inside `onLeave()` method.
|
|
1246
|
+
* If seconds is provided, the reconnection is going to be cancelled after the provided amount of seconds.
|
|
1247
|
+
*
|
|
1248
|
+
* @param client - The client that is allowed to reconnect into the room.
|
|
1249
|
+
* @param seconds - The time in seconds that the client is allowed to reconnect into the room.
|
|
1250
|
+
*
|
|
1251
|
+
* @returns Deferred<Client> - The differed is a promise like type.
|
|
1252
|
+
* This type can forcibly reject the promise by calling `.reject()`.
|
|
1253
|
+
*
|
|
1254
|
+
* @example
|
|
1255
|
+
* ```typescript
|
|
1256
|
+
* onDrop(client: Client, code: CloseCode) {
|
|
1257
|
+
* // Allow the client to reconnect into the room with a 15 seconds timeout.
|
|
1258
|
+
* this.allowReconnection(client, 15);
|
|
1259
|
+
* }
|
|
1260
|
+
* ```
|
|
1261
|
+
*/
|
|
1262
|
+
public allowReconnection(previousClient: Client, seconds: number | "manual"): Deferred<Client> {
|
|
1263
|
+
//
|
|
1264
|
+
// Return rejected promise if client has never fully JOINED.
|
|
1265
|
+
//
|
|
1266
|
+
// (having `_enqueuedMessages !== undefined` means that the client has never been at "ClientState.JOINED" state)
|
|
1267
|
+
//
|
|
1268
|
+
if ((previousClient as unknown as ClientPrivate)._enqueuedMessages !== undefined) {
|
|
1269
|
+
// @ts-ignore
|
|
1270
|
+
return Promise.reject(new Error("not joined"));
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
if (seconds === undefined) { // TODO: remove this check
|
|
1274
|
+
console.warn("DEPRECATED: allowReconnection() requires a second argument. Using \"manual\" mode.");
|
|
1275
|
+
seconds = "manual";
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
if (seconds === "manual") {
|
|
1279
|
+
seconds = Infinity;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
if (this._internalState === RoomInternalState.DISPOSING) {
|
|
1283
|
+
// @ts-ignore
|
|
1284
|
+
return Promise.reject(new Error("disposing"));
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const sessionId = previousClient.sessionId;
|
|
1288
|
+
const reconnectionToken = previousClient.reconnectionToken;
|
|
1289
|
+
|
|
1290
|
+
this._reserveSeat(sessionId, true, previousClient.auth, seconds, true);
|
|
1291
|
+
|
|
1292
|
+
// keep reconnection reference in case the user reconnects into this room.
|
|
1293
|
+
const reconnection = new Deferred<Client & ClientPrivate>();
|
|
1294
|
+
this._reconnections[reconnectionToken] = [sessionId, reconnection];
|
|
1295
|
+
|
|
1296
|
+
if (seconds !== Infinity) {
|
|
1297
|
+
// expire seat reservation after timeout
|
|
1298
|
+
this._reservedSeatTimeouts[sessionId] = setTimeout(() =>
|
|
1299
|
+
reconnection.reject(false), seconds * 1000);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const cleanup = () => {
|
|
1303
|
+
delete this._reconnections[reconnectionToken];
|
|
1304
|
+
delete this._reservedSeats[sessionId];
|
|
1305
|
+
delete this._reservedSeatTimeouts[sessionId];
|
|
1306
|
+
};
|
|
1307
|
+
|
|
1308
|
+
reconnection.then((newClient) => {
|
|
1309
|
+
newClient.auth = previousClient.auth;
|
|
1310
|
+
newClient.userData = previousClient.userData;
|
|
1311
|
+
newClient.view = previousClient.view;
|
|
1312
|
+
|
|
1313
|
+
// for convenience: populate previous client reference with new client
|
|
1314
|
+
previousClient.state = ClientState.RECONNECTED;
|
|
1315
|
+
previousClient.ref = newClient.ref;
|
|
1316
|
+
previousClient.reconnectionToken = newClient.reconnectionToken;
|
|
1317
|
+
clearTimeout(this._reservedSeatTimeouts[sessionId]);
|
|
1318
|
+
|
|
1319
|
+
}, () => {
|
|
1320
|
+
this.resetAutoDisposeTimeout();
|
|
1321
|
+
|
|
1322
|
+
}).finally(() => {
|
|
1323
|
+
cleanup();
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
return reconnection;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
private resetAutoDisposeTimeout(timeoutInSeconds: number = 1) {
|
|
1330
|
+
clearTimeout(this._autoDisposeTimeout);
|
|
1331
|
+
|
|
1332
|
+
if (!this.#_autoDispose) {
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
this._autoDisposeTimeout = setTimeout(() => {
|
|
1337
|
+
this._autoDisposeTimeout = undefined;
|
|
1338
|
+
this.#_disposeIfEmpty();
|
|
1339
|
+
}, timeoutInSeconds * 1000);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
private broadcastMessageType(type: number | string, message?: any | Uint8Array, options: IBroadcastOptions = {}) {
|
|
1343
|
+
debugMessage("broadcast: %O (roomId: %s)", message, this.roomId);
|
|
1344
|
+
|
|
1345
|
+
const encodedMessage = (message instanceof Uint8Array)
|
|
1346
|
+
? getMessageBytes.raw(Protocol.ROOM_DATA_BYTES, type, undefined, message)
|
|
1347
|
+
: getMessageBytes.raw(Protocol.ROOM_DATA, type, message)
|
|
1348
|
+
|
|
1349
|
+
const except = (typeof (options.except) !== "undefined")
|
|
1350
|
+
? Array.isArray(options.except)
|
|
1351
|
+
? options.except
|
|
1352
|
+
: [options.except]
|
|
1353
|
+
: undefined;
|
|
1354
|
+
|
|
1355
|
+
let numClients = this.clients.length;
|
|
1356
|
+
while (numClients--) {
|
|
1357
|
+
const client = this.clients[numClients];
|
|
1358
|
+
|
|
1359
|
+
if (!except || !except.includes(client)) {
|
|
1360
|
+
client.enqueueRaw(encodedMessage);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
private sendFullState(client: Client): void {
|
|
1366
|
+
client.raw(this._serializer.getFullState(client));
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
private _dequeueAfterPatchMessages() {
|
|
1370
|
+
const length = this._afterNextPatchQueue.length;
|
|
1371
|
+
|
|
1372
|
+
if (length > 0) {
|
|
1373
|
+
for (let i = 0; i < length; i++) {
|
|
1374
|
+
const [target, args] = this._afterNextPatchQueue[i];
|
|
1375
|
+
|
|
1376
|
+
if (target === "broadcast") {
|
|
1377
|
+
this.broadcast.apply(this, args as any);
|
|
1378
|
+
|
|
1379
|
+
} else {
|
|
1380
|
+
(target as Client).raw.apply(target, args as any);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// new messages may have been added in the meantime,
|
|
1385
|
+
// let's splice the ones that have been processed
|
|
1386
|
+
this._afterNextPatchQueue.splice(0, length);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
private async _reserveSeat(
|
|
1391
|
+
sessionId: string,
|
|
1392
|
+
joinOptions: any = true,
|
|
1393
|
+
authData: any = undefined,
|
|
1394
|
+
seconds: number = this.seatReservationTimeout,
|
|
1395
|
+
allowReconnection: boolean = false,
|
|
1396
|
+
devModeReconnectionToken?: string,
|
|
1397
|
+
) {
|
|
1398
|
+
if (!allowReconnection && this.hasReachedMaxClients()) {
|
|
1399
|
+
return false;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
debugMatchMaking(
|
|
1403
|
+
'reserving seat. sessionId: \'%s\', roomId: \'%s\', processId: \'%s\'',
|
|
1404
|
+
sessionId, this.roomId, matchMaker.processId,
|
|
1405
|
+
);
|
|
1406
|
+
|
|
1407
|
+
this._reservedSeats[sessionId] = [joinOptions, authData, false, allowReconnection];
|
|
1408
|
+
|
|
1409
|
+
if (!allowReconnection) {
|
|
1410
|
+
await this.#_incrementClientCount();
|
|
1411
|
+
|
|
1412
|
+
this._reservedSeatTimeouts[sessionId] = setTimeout(async () => {
|
|
1413
|
+
delete this._reservedSeats[sessionId];
|
|
1414
|
+
delete this._reservedSeatTimeouts[sessionId];
|
|
1415
|
+
await this.#_decrementClientCount();
|
|
1416
|
+
}, seconds * 1000);
|
|
1417
|
+
|
|
1418
|
+
this.resetAutoDisposeTimeout(seconds);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
//
|
|
1422
|
+
// TODO: isDevMode workaround to allow players to reconnect on devMode
|
|
1423
|
+
//
|
|
1424
|
+
if (devModeReconnectionToken) {
|
|
1425
|
+
// Set up reconnection token mapping
|
|
1426
|
+
const reconnection = new Deferred<Client & ClientPrivate>();
|
|
1427
|
+
this._reconnections[devModeReconnectionToken] = [sessionId, reconnection];
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
return true;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
private async _reserveMultipleSeats(
|
|
1434
|
+
multipleSessionIds: string[],
|
|
1435
|
+
multipleJoinOptions: any = true,
|
|
1436
|
+
multipleAuthData: any = undefined,
|
|
1437
|
+
seconds: number = this.seatReservationTimeout,
|
|
1438
|
+
) {
|
|
1439
|
+
let promises: Promise<boolean>[] = [];
|
|
1440
|
+
|
|
1441
|
+
for (let i = 0; i < multipleSessionIds.length; i++) {
|
|
1442
|
+
promises.push(this._reserveSeat(multipleSessionIds[i], multipleJoinOptions[i], multipleAuthData[i], seconds));
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
return await Promise.all(promises);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
#_disposeIfEmpty() {
|
|
1449
|
+
const willDispose = (
|
|
1450
|
+
this.#_onLeaveConcurrent === 0 && // no "onLeave" calls in progress
|
|
1451
|
+
this.#_autoDispose &&
|
|
1452
|
+
this._autoDisposeTimeout === undefined &&
|
|
1453
|
+
this.clients.length === 0 &&
|
|
1454
|
+
Object.keys(this._reservedSeats).length === 0
|
|
1455
|
+
);
|
|
1456
|
+
|
|
1457
|
+
if (willDispose) {
|
|
1458
|
+
this._events.emit('dispose');
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
return willDispose;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
async #_dispose(): Promise<any> {
|
|
1465
|
+
this._internalState = RoomInternalState.DISPOSING;
|
|
1466
|
+
|
|
1467
|
+
// If the room is still CREATING, the roomId is not yet set.
|
|
1468
|
+
if (this._listing?.roomId !== undefined) {
|
|
1469
|
+
await matchMaker.driver.remove(this._listing.roomId);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
let userReturnData;
|
|
1473
|
+
if (this.onDispose) {
|
|
1474
|
+
userReturnData = this.onDispose();
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
if (this.#_patchInterval) {
|
|
1478
|
+
clearInterval(this.#_patchInterval);
|
|
1479
|
+
this.#_patchInterval = undefined;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
if (this._simulationInterval) {
|
|
1483
|
+
clearInterval(this._simulationInterval);
|
|
1484
|
+
this._simulationInterval = undefined;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
if (this._autoDisposeTimeout) {
|
|
1488
|
+
clearInterval(this._autoDisposeTimeout);
|
|
1489
|
+
this._autoDisposeTimeout = undefined;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// clear all timeouts/intervals + force to stop ticking
|
|
1493
|
+
this.clock.clear();
|
|
1494
|
+
this.clock.stop();
|
|
1495
|
+
|
|
1496
|
+
return await (userReturnData || Promise.resolve());
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
private _onMessage(client: ExtractRoomClient<T> & ClientPrivate, buffer: Buffer) {
|
|
1500
|
+
// skip if client is on LEAVING state.
|
|
1501
|
+
if (client.state === ClientState.LEAVING) { return; }
|
|
1502
|
+
|
|
1503
|
+
if (!buffer) {
|
|
1504
|
+
debugAndPrintError(`${this.roomName} (roomId: ${this.roomId}), couldn't decode message: ${buffer}`);
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// reset message count every second
|
|
1509
|
+
if (this.clock.currentTime - client._lastMessageTime >= 1000) {
|
|
1510
|
+
client._numMessagesLastSecond = 0;
|
|
1511
|
+
client._lastMessageTime = this.clock.currentTime;
|
|
1512
|
+
} else if (++client._numMessagesLastSecond > this.maxMessagesPerSecond) {
|
|
1513
|
+
// drop client if it sends more messages than the maximum allowed per second
|
|
1514
|
+
return this.#_forciblyCloseClient(client, CloseCode.WITH_ERROR);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
const it: Iterator = { offset: 1 };
|
|
1518
|
+
const code = buffer[0];
|
|
1519
|
+
|
|
1520
|
+
if (code === Protocol.ROOM_DATA) {
|
|
1521
|
+
const messageType = (decode.stringCheck(buffer, it))
|
|
1522
|
+
? decode.string(buffer, it)
|
|
1523
|
+
: decode.number(buffer, it);
|
|
1524
|
+
|
|
1525
|
+
let message;
|
|
1526
|
+
try {
|
|
1527
|
+
message = (buffer.byteLength > it.offset)
|
|
1528
|
+
? unpack(buffer.subarray(it.offset, buffer.byteLength))
|
|
1529
|
+
: undefined;
|
|
1530
|
+
debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId);
|
|
1531
|
+
|
|
1532
|
+
// custom message validation
|
|
1533
|
+
if (this.onMessageValidators[messageType] !== undefined) {
|
|
1534
|
+
message = standardValidate(this.onMessageValidators[messageType], message);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
} catch (e: any) {
|
|
1538
|
+
debugAndPrintError(e);
|
|
1539
|
+
client.leave(CloseCode.WITH_ERROR);
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
if (this.onMessageEvents.events[messageType]) {
|
|
1544
|
+
this.onMessageEvents.emit(messageType as string, client, message);
|
|
1545
|
+
|
|
1546
|
+
} else if (this.onMessageEvents.events['*']) {
|
|
1547
|
+
this.onMessageEvents.emit('*', client, messageType, message);
|
|
1548
|
+
|
|
1549
|
+
} else {
|
|
1550
|
+
this.onMessageFallbacks['__no_message_handler'](client, messageType, message);
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
} else if (code === Protocol.ROOM_DATA_BYTES) {
|
|
1554
|
+
const messageType = (decode.stringCheck(buffer, it))
|
|
1555
|
+
? decode.string(buffer, it)
|
|
1556
|
+
: decode.number(buffer, it);
|
|
1557
|
+
|
|
1558
|
+
let message: any = buffer.subarray(it.offset, buffer.byteLength);
|
|
1559
|
+
debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId);
|
|
1560
|
+
|
|
1561
|
+
const bytesMessageType = `_$b${messageType}`;
|
|
1562
|
+
|
|
1563
|
+
// custom message validation
|
|
1564
|
+
try {
|
|
1565
|
+
if (this.onMessageValidators[bytesMessageType] !== undefined) {
|
|
1566
|
+
message = standardValidate(this.onMessageValidators[bytesMessageType], message);
|
|
1567
|
+
}
|
|
1568
|
+
} catch (e: any) {
|
|
1569
|
+
debugAndPrintError(e);
|
|
1570
|
+
client.leave(CloseCode.WITH_ERROR);
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
if (this.onMessageEvents.events[bytesMessageType]) {
|
|
1575
|
+
this.onMessageEvents.emit(bytesMessageType, client, message);
|
|
1576
|
+
|
|
1577
|
+
} else if (this.onMessageEvents.events['*']) {
|
|
1578
|
+
this.onMessageEvents.emit('*', client, messageType, message);
|
|
1579
|
+
|
|
1580
|
+
} else {
|
|
1581
|
+
this.onMessageFallbacks['__no_message_handler'](client, messageType, message);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
} else if (code === Protocol.JOIN_ROOM && client.state === ClientState.JOINING) {
|
|
1585
|
+
// join room has been acknowledged by the client
|
|
1586
|
+
client.state = ClientState.JOINED;
|
|
1587
|
+
client._joinedAt = this.clock.elapsedTime;
|
|
1588
|
+
|
|
1589
|
+
// send current state when new client joins the room
|
|
1590
|
+
if (this.state) {
|
|
1591
|
+
this.sendFullState(client);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// dequeue messages sent before client has joined effectively (on user-defined `onJoin`)
|
|
1595
|
+
if (client._enqueuedMessages.length > 0) {
|
|
1596
|
+
client._enqueuedMessages.forEach((enqueued) => client.raw(enqueued));
|
|
1597
|
+
}
|
|
1598
|
+
delete client._enqueuedMessages;
|
|
1599
|
+
|
|
1600
|
+
} else if (code === Protocol.PING) {
|
|
1601
|
+
client.raw(getMessageBytes[Protocol.PING]());
|
|
1602
|
+
|
|
1603
|
+
} else if (code === Protocol.LEAVE_ROOM) {
|
|
1604
|
+
this.#_forciblyCloseClient(client, CloseCode.CONSENTED);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
#_forciblyCloseClient(client: ExtractRoomClient<T> & ClientPrivate, closeCode: number) {
|
|
1609
|
+
// stop receiving messages from this client
|
|
1610
|
+
client.ref.removeAllListeners('message');
|
|
1611
|
+
|
|
1612
|
+
// prevent "onLeave" from being called twice if player asks to leave
|
|
1613
|
+
client.ref.removeListener('close', client.ref['onleave']);
|
|
1614
|
+
|
|
1615
|
+
// only effectively close connection when "onLeave" is fulfilled
|
|
1616
|
+
this._onLeave(client, closeCode).then(() => client.leave(closeCode));
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
private async _onLeave(client: ExtractRoomClient<T>, code?: number): Promise<any> {
|
|
1620
|
+
// call 'onLeave' method only if the client has been successfully accepted.
|
|
1621
|
+
client.state = ClientState.LEAVING;
|
|
1622
|
+
|
|
1623
|
+
if (!this.clients.delete(client)) {
|
|
1624
|
+
// skip if client already left the room
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
const method = (code === CloseCode.CONSENTED)
|
|
1629
|
+
? this.onLeave
|
|
1630
|
+
: (this.onDrop || this.onLeave);
|
|
1631
|
+
|
|
1632
|
+
if (method) {
|
|
1633
|
+
debugMatchMaking(`${method.name}, sessionId: \'%s\' (close code: %d, roomId: %s)`, client.sessionId, code, this.roomId);
|
|
1634
|
+
|
|
1635
|
+
try {
|
|
1636
|
+
this.#_onLeaveConcurrent++;
|
|
1637
|
+
await method.call(this, client, code);
|
|
1638
|
+
|
|
1639
|
+
} catch (e: any) {
|
|
1640
|
+
debugAndPrintError(`${method.name} error: ${(e && e.message || e || 'promise rejected')} (roomId: ${this.roomId})`);
|
|
1641
|
+
|
|
1642
|
+
} finally {
|
|
1643
|
+
this.#_onLeaveConcurrent--;
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// check for manual "reconnection" flow
|
|
1648
|
+
if (this._reconnections[client.reconnectionToken]) {
|
|
1649
|
+
this._reconnections[client.reconnectionToken][1].catch(async () => {
|
|
1650
|
+
await this.#_onAfterLeave(client, code, method === this.onDrop);
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
// @ts-ignore (client.state may be modified at onLeave())
|
|
1654
|
+
} else if (client.state !== ClientState.RECONNECTED) {
|
|
1655
|
+
await this.#_onAfterLeave(client, code, method === this.onDrop);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
async #_onAfterLeave(client: ExtractRoomClient<T>, code?: number, isDrop: boolean = false) {
|
|
1660
|
+
if (isDrop && this.onLeave) {
|
|
1661
|
+
await this.onLeave(client, code);
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// try to dispose immediately if client reconnection isn't set up.
|
|
1665
|
+
const willDispose = await this.#_decrementClientCount();
|
|
1666
|
+
|
|
1667
|
+
// trigger 'leave' only if seat reservation has been fully consumed
|
|
1668
|
+
if (this._reservedSeats[client.sessionId] === undefined) {
|
|
1669
|
+
this._events.emit('leave', client, willDispose);
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
async #_incrementClientCount() {
|
|
1675
|
+
// lock automatically when maxClients is reached
|
|
1676
|
+
if (!this.#_locked && this.hasReachedMaxClients()) {
|
|
1677
|
+
this.#_maxClientsReached = true;
|
|
1678
|
+
|
|
1679
|
+
// @ts-ignore
|
|
1680
|
+
this.lock.call(this, true);
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
await matchMaker.driver.update(this._listing, {
|
|
1684
|
+
$inc: { clients: 1 },
|
|
1685
|
+
$set: { locked: this.#_locked },
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
async #_decrementClientCount() {
|
|
1690
|
+
const willDispose = this.#_disposeIfEmpty();
|
|
1691
|
+
|
|
1692
|
+
if (this._internalState === RoomInternalState.DISPOSING) {
|
|
1693
|
+
return true;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// unlock if room is available for new connections
|
|
1697
|
+
if (!willDispose) {
|
|
1698
|
+
if (this.#_maxClientsReached && !this._lockedExplicitly) {
|
|
1699
|
+
this.#_maxClientsReached = false;
|
|
1700
|
+
|
|
1701
|
+
// @ts-ignore
|
|
1702
|
+
this.unlock.call(this, true);
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
// update room listing cache
|
|
1706
|
+
await matchMaker.driver.update(this._listing, {
|
|
1707
|
+
$inc: { clients: -1 },
|
|
1708
|
+
$set: { locked: this.#_locked },
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
return willDispose;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
#registerUncaughtExceptionHandlers() {
|
|
1716
|
+
const onUncaughtException = this.onUncaughtException.bind(this);
|
|
1717
|
+
const originalSetTimeout = this.clock.setTimeout;
|
|
1718
|
+
this.clock.setTimeout = (cb, timeout, ...args) => {
|
|
1719
|
+
return originalSetTimeout.call(this.clock, wrapTryCatch(cb, onUncaughtException, TimedEventException, 'setTimeout'), timeout, ...args);
|
|
1720
|
+
};
|
|
1721
|
+
|
|
1722
|
+
const originalSetInterval = this.clock.setInterval;
|
|
1723
|
+
this.clock.setInterval = (cb, timeout, ...args) => {
|
|
1724
|
+
return originalSetInterval.call(this.clock, wrapTryCatch(cb, onUncaughtException, TimedEventException, 'setInterval'), timeout, ...args);
|
|
1725
|
+
};
|
|
1726
|
+
|
|
1727
|
+
if (this.onCreate !== undefined) {
|
|
1728
|
+
this.onCreate = wrapTryCatch(this.onCreate.bind(this), onUncaughtException, OnCreateException, 'onCreate', true);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
if (this.onAuth !== undefined) {
|
|
1732
|
+
this.onAuth = wrapTryCatch(this.onAuth.bind(this), onUncaughtException, OnAuthException, 'onAuth', true);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
if (this.onJoin !== undefined) {
|
|
1736
|
+
this.onJoin = wrapTryCatch(this.onJoin.bind(this), onUncaughtException, OnJoinException, 'onJoin', true);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
if (this.onLeave !== undefined) {
|
|
1740
|
+
this.onLeave = wrapTryCatch(this.onLeave.bind(this), onUncaughtException, OnLeaveException, 'onLeave', true);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
if (this.onDispose !== undefined) {
|
|
1744
|
+
this.onDispose = wrapTryCatch(this.onDispose.bind(this), onUncaughtException, OnDisposeException, 'onDispose');
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* (WIP) Alternative, method-based room definition.
|
|
1752
|
+
* We should be able to define
|
|
1753
|
+
*/
|
|
1754
|
+
|
|
1755
|
+
type RoomLifecycleMethods =
|
|
1756
|
+
| 'messages'
|
|
1757
|
+
| 'onCreate'
|
|
1758
|
+
| 'onJoin'
|
|
1759
|
+
| 'onLeave'
|
|
1760
|
+
| 'onDispose'
|
|
1761
|
+
| 'onCacheRoom'
|
|
1762
|
+
| 'onRestoreRoom'
|
|
1763
|
+
| 'onDrop'
|
|
1764
|
+
| 'onReconnect'
|
|
1765
|
+
| 'onUncaughtException'
|
|
1766
|
+
| 'onAuth'
|
|
1767
|
+
| 'onBeforeShutdown'
|
|
1768
|
+
| 'onBeforePatch';
|
|
1769
|
+
|
|
1770
|
+
type DefineRoomOptions<T extends RoomOptions = RoomOptions> =
|
|
1771
|
+
Partial<Pick<Room<T>, RoomLifecycleMethods>> &
|
|
1772
|
+
{ state?: ExtractRoomState<T> | (() => ExtractRoomState<T>); } &
|
|
1773
|
+
ThisType<Exclude<Room<T>, RoomLifecycleMethods>> &
|
|
1774
|
+
ThisType<Room<T>>
|
|
1775
|
+
;
|
|
1776
|
+
|
|
1777
|
+
export function room<T>(options: DefineRoomOptions<T>) {
|
|
1778
|
+
class _ extends Room<T> {
|
|
1779
|
+
messages = options.messages;
|
|
1780
|
+
|
|
1781
|
+
constructor() {
|
|
1782
|
+
super();
|
|
1783
|
+
if (options.state && typeof options.state === 'function') {
|
|
1784
|
+
this.state = options.state();
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
// Copy all methods to the prototype
|
|
1790
|
+
for (const key in options) {
|
|
1791
|
+
if (typeof options[key] === 'function') {
|
|
1792
|
+
_.prototype[key] = options[key];
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
return _ as typeof Room<T>;
|
|
1797
|
+
}
|