@colyseus/core 0.17.0 → 0.17.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.
@@ -0,0 +1,425 @@
1
+ import { Room, type Client, matchMaker, type IRoomCache, debugMatchMaking, ServerError, ErrorCode, isDevMode, } from "@colyseus/core";
2
+
3
+ export interface RankedQueueOptions {
4
+ /**
5
+ * number of players on each match
6
+ */
7
+ maxPlayers?: number;
8
+
9
+ /**
10
+ * name of the room to create
11
+ */
12
+ matchRoomName: string;
13
+
14
+ /**
15
+ * after these cycles, create a match with a bot
16
+ */
17
+ maxWaitingCycles?: number;
18
+
19
+ /**
20
+ * after this time, try to fit this client with a not-so-compatible group
21
+ */
22
+ maxWaitingCyclesForPriority?: number;
23
+
24
+ /**
25
+ * If set, teams must have the same size to be matched together
26
+ */
27
+ maxTeamSize?: number;
28
+
29
+ /**
30
+ * If `allowIncompleteGroups` is true, players inside an unmatched group (that
31
+ * did not reached `maxPlayers`, and `maxWaitingCycles` has been
32
+ * reached) will be matched together. Your room should fill the remaining
33
+ * spots with "bots" on this case.
34
+ */
35
+ allowIncompleteGroups?: boolean;
36
+
37
+ /**
38
+ * Comparison function for matching clients to groups
39
+ * Returns true if the client is compatible with the group
40
+ */
41
+ compare?: (client: ClientQueueData, matchGroup: MatchGroup) => boolean;
42
+
43
+ /**
44
+ *
45
+ * When onGroupReady is set, the "roomNameToCreate" option is ignored.
46
+ */
47
+ onGroupReady?: (this: RankedQueueRoom, group: MatchGroup) => Promise<IRoomCache>;
48
+ }
49
+
50
+ export interface MatchGroup {
51
+ averageRank: number;
52
+ clients: Array<Client<{ userData: ClientQueueData }>>,
53
+ ready?: boolean;
54
+ confirmed?: number;
55
+ }
56
+
57
+ export interface MatchTeam {
58
+ averageRank: number;
59
+ clients: Array<Client<{ userData: ClientQueueData }>>,
60
+ teamId: string | symbol;
61
+ }
62
+
63
+ export interface ClientQueueData {
64
+ /**
65
+ * Rank of the client
66
+ */
67
+ rank: number;
68
+
69
+ /**
70
+ * Timestamp of when the client entered the queue
71
+ */
72
+ currentCycle?: number;
73
+
74
+ /**
75
+ * Optional: if matching with a team, the team ID
76
+ */
77
+ teamId?: string;
78
+
79
+ /**
80
+ * Additional options passed by the client when joining the room
81
+ */
82
+ options?: any;
83
+
84
+ /**
85
+ * Match group the client is currently in
86
+ */
87
+ group?: MatchGroup;
88
+
89
+ /**
90
+ * Whether the client has confirmed the connection to the room
91
+ */
92
+ confirmed?: boolean;
93
+
94
+ /**
95
+ * Whether the client should be prioritized in the queue
96
+ * (e.g. for players that are waiting for a long time)
97
+ */
98
+ highPriority?: boolean;
99
+
100
+ /**
101
+ * The last number of clients in the queue sent to the client
102
+ */
103
+ lastQueueClientCount?: number;
104
+ }
105
+
106
+ const DEFAULT_TEAM = Symbol("$default_team");
107
+ const DEFAULT_COMPARE = (client: ClientQueueData, matchGroup: MatchGroup) => {
108
+ const diff = Math.abs(client.rank - matchGroup.averageRank);
109
+ const diffRatio = (diff / matchGroup.averageRank);
110
+ // If diff ratio is too high, create a new match group
111
+ return (diff < 10 || diffRatio <= 2);
112
+ }
113
+
114
+ export class RankedQueueRoom extends Room {
115
+ maxPlayers = 4;
116
+ maxTeamSize: number;
117
+ allowIncompleteGroups: boolean = false;
118
+
119
+ maxWaitingCycles = 15;
120
+ maxWaitingCyclesForPriority?: number = 10;
121
+
122
+ /**
123
+ * Evaluate groups for each client at interval
124
+ */
125
+ cycleTickInterval = 1000;
126
+
127
+ /**
128
+ * Groups of players per iteration
129
+ */
130
+ groups: MatchGroup[] = [];
131
+ highPriorityGroups: MatchGroup[] = [];
132
+
133
+ matchRoomName: string;
134
+
135
+ protected compare = DEFAULT_COMPARE;
136
+ protected onGroupReady = (group: MatchGroup) => matchMaker.createRoom(this.matchRoomName, {});
137
+
138
+ messages = {
139
+ confirm: (client: Client, _: unknown) => {
140
+ const queueData = client.userData;
141
+
142
+ if (queueData && queueData.group && typeof (queueData.group.confirmed) === "number") {
143
+ queueData.confirmed = true;
144
+ queueData.group.confirmed++;
145
+ client.leave();
146
+ }
147
+ },
148
+ }
149
+
150
+ onCreate(options: RankedQueueOptions) {
151
+ if (typeof(options.maxWaitingCycles) === "number") {
152
+ this.maxWaitingCycles = options.maxWaitingCycles;
153
+ }
154
+
155
+ if (typeof(options.maxPlayers) === "number") {
156
+ this.maxPlayers = options.maxPlayers;
157
+ }
158
+
159
+ if (typeof(options.maxTeamSize) === "number") {
160
+ this.maxTeamSize = options.maxTeamSize;
161
+ }
162
+
163
+ if (typeof(options.allowIncompleteGroups) !== "undefined") {
164
+ this.allowIncompleteGroups = options.allowIncompleteGroups;
165
+ }
166
+
167
+ if (typeof(options.compare) === "function") {
168
+ this.compare = options.compare;
169
+ }
170
+
171
+ if (typeof(options.onGroupReady) === "function") {
172
+ this.onGroupReady = options.onGroupReady;
173
+ }
174
+
175
+ if (options.matchRoomName) {
176
+ this.matchRoomName = options.matchRoomName;
177
+
178
+ } else {
179
+ throw new ServerError(ErrorCode.APPLICATION_ERROR, "RankedQueueRoom: 'matchRoomName' option is required.");
180
+ }
181
+
182
+ debugMatchMaking("RankedQueueRoom#onCreate() maxPlayers: %d, maxWaitingCycles: %d, maxTeamSize: %d, allowIncompleteGroups: %d, roomNameToCreate: %s", this.maxPlayers, this.maxWaitingCycles, this.maxTeamSize, this.allowIncompleteGroups, this.matchRoomName);
183
+
184
+ /**
185
+ * Redistribute clients into groups at every interval
186
+ */
187
+ this.setSimulationInterval(() => this.reassignMatchGroups(), this.cycleTickInterval);
188
+ }
189
+
190
+ onJoin(client: Client, options: any, auth?: unknown) {
191
+ this.addToQueue(client, {
192
+ rank: options.rank,
193
+ teamId: options.teamId,
194
+ options,
195
+ });
196
+ }
197
+
198
+ addToQueue(client: Client, queueData: ClientQueueData) {
199
+ if (queueData.currentCycle === undefined) {
200
+ queueData.currentCycle = 0;
201
+ }
202
+ client.userData = queueData;
203
+
204
+ // FIXME: reassign groups upon joining [?] (without incrementing cycle count)
205
+ client.send("clients", 1);
206
+ }
207
+
208
+ createMatchGroup() {
209
+ const group: MatchGroup = { clients: [], averageRank: 0 };
210
+ this.groups.push(group);
211
+ return group;
212
+ }
213
+
214
+ reassignMatchGroups() {
215
+ // Re-set all groups
216
+ this.groups.length = 0;
217
+ this.highPriorityGroups.length = 0;
218
+
219
+ const sortedClients = (this.clients)
220
+ .filter((client) => {
221
+ // Filter out:
222
+ // - clients that are not in the queue
223
+ // - clients that are already in a "ready" group
224
+ return (
225
+ client.userData &&
226
+ client.userData.group?.ready !== true
227
+ );
228
+ })
229
+ .sort((a, b) => {
230
+ //
231
+ // Sort by rank ascending
232
+ //
233
+ return a.userData.rank - b.userData.rank;
234
+ });
235
+
236
+ //
237
+ // The room either distribute by teams or by clients
238
+ //
239
+ if (typeof(this.maxTeamSize) === "number") {
240
+ this.redistributeTeams(sortedClients);
241
+
242
+ } else {
243
+ this.redistributeClients(sortedClients);
244
+ }
245
+
246
+ this.evaluateHighPriorityGroups();
247
+ this.processGroupsReady();
248
+ }
249
+
250
+ redistributeTeams(sortedClients: Client<{ userData: ClientQueueData }>[]) {
251
+ const teamsByID: { [teamId: string | symbol]: MatchTeam } = {};
252
+
253
+ sortedClients.forEach((client) => {
254
+ const teamId = client.userData.teamId || DEFAULT_TEAM;
255
+
256
+ // Create a new team if it doesn't exist
257
+ if (!teamsByID[teamId]) {
258
+ teamsByID[teamId] = { teamId: teamId, clients: [], averageRank: 0, };
259
+ }
260
+
261
+ teamsByID[teamId].averageRank += client.userData.rank;
262
+ teamsByID[teamId].clients.push(client);
263
+ });
264
+
265
+ // Calculate average rank for each team
266
+ let teams = Object.values(teamsByID).map((team) => {
267
+ team.averageRank /= team.clients.length;
268
+ return team;
269
+ }).sort((a, b) => {
270
+ // Sort by average rank ascending
271
+ return a.averageRank - b.averageRank;
272
+ });
273
+
274
+ // Iterate over teams multiple times until all clients are assigned to a group
275
+ do {
276
+ let currentGroup: MatchGroup = this.createMatchGroup();
277
+ teams = teams.filter((team) => {
278
+ // Remove clients from the team and add them to the current group
279
+ const totalRank = team.averageRank * team.clients.length;
280
+
281
+ // currentGroup.averageRank = (currentGroup.averageRank === undefined)
282
+ // ? team.averageRank
283
+ // : (currentGroup.averageRank + team.averageRank) / ;
284
+ currentGroup = this.redistributeClients(team.clients.splice(0, this.maxTeamSize), currentGroup, totalRank);
285
+
286
+ if (team.clients.length >= this.maxTeamSize) {
287
+ // team still has enough clients to form a group
288
+ return true;
289
+ }
290
+
291
+ // increment cycle count for all clients in the team
292
+ team.clients.forEach((client) => client.userData.currentCycle++);
293
+
294
+ return false;
295
+ });
296
+ } while (teams.length >= 2);
297
+ }
298
+
299
+ redistributeClients(
300
+ sortedClients: Client<{ userData: ClientQueueData }>[],
301
+ currentGroup: MatchGroup = this.createMatchGroup(),
302
+ totalRank: number = 0,
303
+ ) {
304
+ for (let i = 0, l = sortedClients.length; i < l; i++) {
305
+ const client = sortedClients[i];
306
+ const userData = client.userData;
307
+ const currentCycle = userData.currentCycle++;
308
+
309
+ if (currentGroup.averageRank > 0) {
310
+ if (
311
+ !this.compare(userData, currentGroup) &&
312
+ !userData.highPriority
313
+ ) {
314
+ currentGroup = this.createMatchGroup();
315
+ totalRank = 0;
316
+ }
317
+ }
318
+
319
+ userData.group = currentGroup;
320
+ currentGroup.clients.push(client);
321
+
322
+ totalRank += userData.rank;
323
+ currentGroup.averageRank = totalRank / currentGroup.clients.length;
324
+
325
+ // Enough players in the group, mark it as ready!
326
+ if (currentGroup.clients.length === this.maxPlayers) {
327
+ currentGroup.ready = true;
328
+ currentGroup = this.createMatchGroup();
329
+ totalRank = 0;
330
+ continue;
331
+ }
332
+
333
+ if (currentCycle >= this.maxWaitingCycles && this.allowIncompleteGroups) {
334
+ /**
335
+ * Match long-waiting clients with bots
336
+ */
337
+ if (this.highPriorityGroups.indexOf(currentGroup) === -1) {
338
+ this.highPriorityGroups.push(currentGroup);
339
+ }
340
+
341
+ } else if (
342
+ this.maxWaitingCyclesForPriority !== undefined &&
343
+ currentCycle >= this.maxWaitingCyclesForPriority
344
+ ) {
345
+ /**
346
+ * Force this client to join a group, even if rank is incompatible
347
+ */
348
+ userData.highPriority = true;
349
+ }
350
+ }
351
+
352
+ return currentGroup;
353
+ }
354
+
355
+ evaluateHighPriorityGroups() {
356
+ /**
357
+ * Evaluate groups with high priority clients
358
+ */
359
+ this.highPriorityGroups.forEach((group) => {
360
+ group.ready = group.clients.every((c) => {
361
+ // Give new clients another chance to join a group that is not "high priority"
362
+ return c.userData?.currentCycle > 1;
363
+ // return c.userData?.currentCycle >= this.maxWaitingCycles;
364
+ });
365
+ });
366
+ }
367
+
368
+ processGroupsReady() {
369
+ this.groups.forEach(async (group) => {
370
+ if (group.ready) {
371
+ group.confirmed = 0;
372
+
373
+ try {
374
+ /**
375
+ * Create room instance in the server.
376
+ */
377
+ const room = await this.onGroupReady.call(this, group);
378
+
379
+ /**
380
+ * Reserve a seat for each client in the group.
381
+ * (If one fails, force all clients to leave, re-queueing is up to the client-side logic)
382
+ */
383
+ await matchMaker.reserveMultipleSeatsFor(
384
+ room,
385
+ group.clients.map((client) => ({
386
+ sessionId: client.sessionId,
387
+ options: client.userData.options,
388
+ auth: client.auth,
389
+ })),
390
+ );
391
+
392
+ /**
393
+ * Send room data for new WebSocket connection!
394
+ */
395
+ group.clients.forEach((client, i) => {
396
+ client.send("seat", matchMaker.buildSeatReservation(room, client.sessionId));
397
+ });
398
+
399
+ } catch (e: any) {
400
+ //
401
+ // If creating a room, or reserving a seat failed - fail all clients
402
+ // Whether the clients retry or not is up to the client-side logic
403
+ //
404
+ group.clients.forEach(client => client.leave(1011, e.message));
405
+ }
406
+
407
+ } else {
408
+ /**
409
+ * Notify clients within the group on how many players are in the queue
410
+ */
411
+ group.clients.forEach((client) => {
412
+ //
413
+ // avoid sending the same number of clients to the client if it hasn't changed
414
+ //
415
+ const queueClientCount = group.clients.length;
416
+ if (client.userData.lastQueueClientCount !== queueClientCount) {
417
+ client.userData.lastQueueClientCount = queueClientCount;
418
+ client.send("clients", queueClientCount);
419
+ }
420
+ });
421
+ }
422
+ });
423
+ }
424
+
425
+ }
@@ -0,0 +1,90 @@
1
+ import { defineTypes, MapSchema, Schema } from '@colyseus/schema';
2
+
3
+ import { Room } from '../Room.ts';
4
+ import type { Client } from '../Transport.ts';
5
+ import { CloseCode } from '../Protocol.ts';
6
+
7
+ class Player extends Schema {
8
+ public connected: boolean;
9
+ public name: string;
10
+ public sessionId: string;
11
+ }
12
+ defineTypes(Player, {
13
+ connected: 'boolean',
14
+ name: 'string',
15
+ sessionId: 'string',
16
+ });
17
+
18
+ class State extends Schema {
19
+ public players = new MapSchema<Player>();
20
+ }
21
+ defineTypes(State, {
22
+ players: { map: Player },
23
+ });
24
+
25
+ /**
26
+ * client.joinOrCreate("relayroom", {
27
+ * maxClients: 10,
28
+ * allowReconnectionTime: 20
29
+ * });
30
+ */
31
+
32
+ export class RelayRoom extends Room {
33
+ public state = new State();
34
+ public allowReconnectionTime: number = 0;
35
+
36
+ public onCreate(options: Partial<{
37
+ maxClients: number,
38
+ allowReconnectionTime: number,
39
+ metadata: any,
40
+ }>) {
41
+ if (options.maxClients) {
42
+ this.maxClients = options.maxClients;
43
+ }
44
+
45
+ if (options.allowReconnectionTime) {
46
+ this.allowReconnectionTime = Math.min(options.allowReconnectionTime, 40);
47
+ }
48
+
49
+ if (options.metadata) {
50
+ this.setMetadata(options.metadata);
51
+ }
52
+
53
+ this.onMessage('*', (client: Client, type: string | number, message: any) => {
54
+ this.broadcast(type, [client.sessionId, message], { except: client });
55
+ });
56
+ }
57
+
58
+ public onJoin(client: Client, options: any = {}) {
59
+ const player = new Player();
60
+
61
+ player.connected = true;
62
+ player.sessionId = client.sessionId;
63
+
64
+ if (options.name) {
65
+ player.name = options.name;
66
+ }
67
+
68
+ this.state.players.set(client.sessionId, player);
69
+ }
70
+
71
+ public async onLeave(client: Client, code: number) {
72
+ if (this.allowReconnectionTime > 0) {
73
+ const player = this.state.players.get(client.sessionId);
74
+ player.connected = false;
75
+
76
+ try {
77
+ if (code === CloseCode.CONSENTED) {
78
+ throw new Error('consented leave');
79
+ }
80
+
81
+ await this.allowReconnection(client, this.allowReconnectionTime);
82
+ player.connected = true;
83
+
84
+ } catch (e) {
85
+ this.state.players.delete(client.sessionId);
86
+ }
87
+ }
88
+ }
89
+
90
+ }
@@ -0,0 +1,58 @@
1
+ import { createEndpoint, createRouter } from "@colyseus/better-call";
2
+ import * as matchMaker from "../MatchMaker.ts";
3
+ import { getBearerToken } from "../utils/Utils.ts";
4
+
5
+ export const postMatchmakeMethod = createEndpoint("/matchmake/:method/:roomName", { method: "POST" }, async (ctx) => {
6
+ // do not accept matchmaking requests if already shutting down
7
+ if (matchMaker.state === matchMaker.MatchMakerState.SHUTTING_DOWN) {
8
+ throw ctx.error(503);
9
+ }
10
+
11
+ const requestHeaders = ctx.request.headers;
12
+ const headers = Object.assign(
13
+ {},
14
+ matchMaker.controller.DEFAULT_CORS_HEADERS,
15
+ matchMaker.controller.getCorsHeaders(requestHeaders)
16
+ );
17
+
18
+ const method = ctx.params.method;
19
+ const roomName = ctx.params.roomName;
20
+
21
+ Object.entries(headers).forEach(([key, value]) => {
22
+ ctx.setHeader(key, value);
23
+ })
24
+ ctx.setHeader('Content-Type', 'application/json');
25
+
26
+ try {
27
+ const clientOptions = ctx.body;
28
+ const response = await matchMaker.controller.invokeMethod(
29
+ method,
30
+ roomName,
31
+ clientOptions,
32
+ {
33
+ token: getBearerToken(ctx.request.headers.get('authorization')),
34
+ headers: ctx.request.headers,
35
+ ip: requestHeaders.get('x-forwarded-for') ?? requestHeaders.get('x-client-ip') ?? requestHeaders.get('x-real-ip'),
36
+ req: ctx.request as any,
37
+ },
38
+ );
39
+
40
+ //
41
+ // TODO: respond with protocol, if available
42
+ //
43
+ // // specify protocol, if available.
44
+ // if (this.transport.protocol !== undefined) {
45
+ // response.protocol = this.transport.protocol;
46
+ // }
47
+
48
+ return response;
49
+
50
+ } catch (e: any) {
51
+ throw ctx.error(e.code, { code: e.code, error: e.message, });
52
+ }
53
+
54
+ });
55
+
56
+ export function getDefaultRouter() {
57
+ return createRouter({ postMatchmakeMethod });
58
+ }
@@ -0,0 +1,43 @@
1
+ import type { Server } from "http";
2
+ import { type Endpoint, type Router, type RouterConfig, createRouter as createBetterCallRouter, createEndpoint } from "@colyseus/better-call";
3
+ import { toNodeHandler } from "@colyseus/better-call/node";
4
+
5
+ export {
6
+ createEndpoint,
7
+ createMiddleware,
8
+ createInternalContext,
9
+ } from "@colyseus/better-call";
10
+
11
+ export { type Router, toNodeHandler };
12
+
13
+ export function bindRouterToServer(server: Server, router: Router) {
14
+ // check if the server is bound to an express app
15
+ const expressApp: any = server.listeners('request').find((listener: Function) =>
16
+ listener.name === "app" && listener['mountpath'] === '/');
17
+
18
+ if (expressApp) {
19
+ // bind the router to the express app
20
+ expressApp.use(toNodeHandler(router.handler));
21
+
22
+ } else {
23
+ // otherwise, bind the router to the http server
24
+ server.on('request', toNodeHandler(router.handler));
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Do not use this directly. This is used internally by `@colyseus/playground`.
30
+ * TODO: refactor. Avoid using globals.
31
+ * @internal
32
+ */
33
+ export let __globalEndpoints: Record<string, Endpoint>;
34
+
35
+ export function createRouter<
36
+ E extends Record<string, Endpoint>,
37
+ Config extends RouterConfig
38
+ >(endpoints: E, config?: Config) {
39
+ // TODO: refactor. Avoid using globals.
40
+ __globalEndpoints = endpoints;
41
+
42
+ return createBetterCallRouter({ ...endpoints, }, config);
43
+ }
@@ -0,0 +1,16 @@
1
+ import type { Client } from '../Transport.ts';
2
+ import type { Serializer } from './Serializer.ts';
3
+
4
+ export class NoneSerializer<T= any> implements Serializer<T> {
5
+ public id: string = 'none';
6
+
7
+ public reset(data: any) {}
8
+
9
+ public getFullState(client?: Client) {
10
+ return null;
11
+ }
12
+
13
+ public applyPatches(clients: Client[], state: T): boolean {
14
+ return false;
15
+ }
16
+ }