@colyseus/core 0.18.4 → 0.18.6
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/Debug.cjs +1 -0
- package/build/Debug.cjs.map +1 -1
- package/build/IPC.cjs +1 -0
- package/build/IPC.cjs.map +1 -1
- package/build/Logger.cjs +1 -0
- package/build/Logger.cjs.map +1 -1
- package/build/MatchMaker.cjs +11 -3
- package/build/MatchMaker.cjs.map +2 -2
- package/build/MatchMaker.mjs +10 -3
- package/build/MatchMaker.mjs.map +2 -2
- package/build/Protocol.cjs +1 -0
- package/build/Protocol.cjs.map +1 -1
- package/build/Rewind.cjs +1 -0
- package/build/Rewind.cjs.map +1 -1
- package/build/Room.cjs +102 -0
- package/build/Room.cjs.map +2 -2
- package/build/Room.d.ts +57 -0
- package/build/Room.mjs +101 -0
- package/build/Room.mjs.map +2 -2
- package/build/RoomMessages.cjs +1 -0
- package/build/RoomMessages.cjs.map +1 -1
- package/build/RoomPlugin.cjs +1 -0
- package/build/RoomPlugin.cjs.map +1 -1
- package/build/Server.cjs +1 -0
- package/build/Server.cjs.map +1 -1
- package/build/Stats.cjs +1 -0
- package/build/Stats.cjs.map +1 -1
- package/build/Transport.cjs +39 -0
- package/build/Transport.cjs.map +2 -2
- package/build/Transport.d.ts +66 -1
- package/build/Transport.mjs +37 -1
- package/build/Transport.mjs.map +2 -2
- package/build/errors/RoomExceptions.cjs +1 -0
- package/build/errors/RoomExceptions.cjs.map +1 -1
- package/build/errors/SeatReservationError.cjs +1 -0
- package/build/errors/SeatReservationError.cjs.map +1 -1
- package/build/errors/ServerError.cjs +1 -0
- package/build/errors/ServerError.cjs.map +1 -1
- package/build/index.cjs +5 -0
- package/build/index.cjs.map +2 -2
- package/build/index.d.ts +1 -1
- package/build/index.mjs +3 -1
- package/build/index.mjs.map +2 -2
- package/build/input/InputBuffer.cjs +1 -0
- package/build/input/InputBuffer.cjs.map +1 -1
- package/build/input/RoomInput.cjs +64 -3
- package/build/input/RoomInput.cjs.map +2 -2
- package/build/input/RoomInput.d.ts +16 -2
- package/build/input/RoomInput.mjs +63 -3
- package/build/input/RoomInput.mjs.map +2 -2
- package/build/input/types.cjs +1 -0
- package/build/input/types.cjs.map +1 -1
- package/build/internal.cjs +1 -0
- package/build/internal.cjs.map +1 -1
- package/build/matchmaker/Lobby.cjs +1 -0
- package/build/matchmaker/Lobby.cjs.map +1 -1
- package/build/matchmaker/LocalDriver/LocalDriver.cjs +1 -0
- package/build/matchmaker/LocalDriver/LocalDriver.cjs.map +1 -1
- package/build/matchmaker/LocalDriver/Query.cjs +1 -0
- package/build/matchmaker/LocalDriver/Query.cjs.map +1 -1
- package/build/matchmaker/RegisteredHandler.cjs +1 -0
- package/build/matchmaker/RegisteredHandler.cjs.map +1 -1
- package/build/matchmaker/controller.cjs +1 -0
- package/build/matchmaker/controller.cjs.map +1 -1
- package/build/matchmaker/driver.cjs +1 -0
- package/build/matchmaker/driver.cjs.map +2 -2
- package/build/matchmaker/driver.d.ts +4 -2
- package/build/matchmaker/driver.mjs.map +1 -1
- package/build/presence/LocalPresence.cjs +1 -0
- package/build/presence/LocalPresence.cjs.map +1 -1
- package/build/presence/Presence.cjs +1 -0
- package/build/presence/Presence.cjs.map +1 -1
- package/build/rooms/LobbyRoom.cjs +1 -0
- package/build/rooms/LobbyRoom.cjs.map +1 -1
- package/build/rooms/QueueRoom.cjs +1 -0
- package/build/rooms/QueueRoom.cjs.map +1 -1
- package/build/rooms/RelayRoom.cjs +1 -0
- package/build/rooms/RelayRoom.cjs.map +1 -1
- package/build/router/default_routes.cjs +2 -7
- package/build/router/default_routes.cjs.map +2 -2
- package/build/router/default_routes.mjs +2 -8
- package/build/router/default_routes.mjs.map +2 -2
- package/build/router/index.cjs +48 -1
- package/build/router/index.cjs.map +3 -3
- package/build/router/index.d.ts +6 -2
- package/build/router/index.mjs +47 -1
- package/build/router/index.mjs.map +2 -2
- package/build/router/node.cjs +1 -0
- package/build/router/node.cjs.map +1 -1
- package/build/serializer/NoneSerializer.cjs +1 -0
- package/build/serializer/NoneSerializer.cjs.map +1 -1
- package/build/serializer/SchemaSerializer.cjs +98 -0
- package/build/serializer/SchemaSerializer.cjs.map +2 -2
- package/build/serializer/SchemaSerializer.d.ts +25 -0
- package/build/serializer/SchemaSerializer.mjs +98 -1
- package/build/serializer/SchemaSerializer.mjs.map +2 -2
- package/build/serializer/Serializer.cjs +1 -0
- package/build/serializer/Serializer.cjs.map +2 -2
- package/build/serializer/Serializer.d.ts +15 -0
- package/build/utils/DevMode.cjs +1 -0
- package/build/utils/DevMode.cjs.map +1 -1
- package/build/utils/Env.cjs +1 -0
- package/build/utils/Env.cjs.map +1 -1
- package/build/utils/StandardSchema.cjs +1 -0
- package/build/utils/StandardSchema.cjs.map +1 -1
- package/build/utils/UserSessionIndex.cjs +1 -0
- package/build/utils/UserSessionIndex.cjs.map +1 -1
- package/build/utils/Utils.cjs +1 -0
- package/build/utils/Utils.cjs.map +1 -1
- package/build/utils/nanoevents.cjs +1 -0
- package/build/utils/nanoevents.cjs.map +1 -1
- package/package.json +5 -5
- package/src/MatchMaker.ts +20 -3
- package/src/Room.ts +122 -0
- package/src/Transport.ts +132 -4
- package/src/index.ts +1 -1
- package/src/input/RoomInput.ts +76 -3
- package/src/matchmaker/driver.ts +4 -2
- package/src/router/default_routes.ts +2 -8
- package/src/router/index.ts +72 -3
- package/src/serializer/SchemaSerializer.ts +146 -1
- package/src/serializer/Serializer.ts +17 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// packages/core/src/serializer/SchemaSerializer.ts
|
|
2
2
|
import { Protocol, ProtocolModifier } from "@colyseus/shared-types";
|
|
3
3
|
import { ClientState } from "../Transport.mjs";
|
|
4
|
-
import { encode, Encoder, dumpChanges, Reflection } from "@colyseus/schema";
|
|
4
|
+
import { $numFields, encode, Encoder, dumpChanges, Metadata, Reflection } from "@colyseus/schema";
|
|
5
5
|
import { debugPatch } from "../Debug.mjs";
|
|
6
|
+
import { logger } from "../Logger.mjs";
|
|
6
7
|
var TIMED_PREFIX_SIZE = 8;
|
|
7
8
|
function buildTimedFrame(encodedBody, sNow, inputSeq) {
|
|
8
9
|
const schemaPayload = encodedBody.subarray(1);
|
|
@@ -24,6 +25,24 @@ function sendClientFrame(client, encodedBody, timing) {
|
|
|
24
25
|
client.raw(encodedBody);
|
|
25
26
|
}
|
|
26
27
|
}
|
|
28
|
+
var UNRELIABLE_HEADER_SIZE = 2;
|
|
29
|
+
function stateHasUnreliableFields(context) {
|
|
30
|
+
for (const klass of context.schemas.keys()) {
|
|
31
|
+
const metadata = klass[Symbol.metadata];
|
|
32
|
+
if (metadata === void 0) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
for (let i = 0; i <= metadata[$numFields]; i++) {
|
|
36
|
+
if (Metadata.hasUnreliableAtIndex(metadata, i)) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
function canReceiveUnreliable(client) {
|
|
44
|
+
return client.state === ClientState.JOINED && client.rawUnreliable !== void 0;
|
|
45
|
+
}
|
|
27
46
|
var SHARED_VIEW = {};
|
|
28
47
|
var SchemaSerializer = class {
|
|
29
48
|
constructor() {
|
|
@@ -34,13 +53,24 @@ var SchemaSerializer = class {
|
|
|
34
53
|
// TODO: make this optional. allocating a new buffer for each room may not be always necessary.
|
|
35
54
|
this.fullEncodeBuffer = new Uint8Array(Encoder.BUFFER_SIZE);
|
|
36
55
|
this.sharedOffsetCache = { offset: 0 };
|
|
56
|
+
/** @see {@link Serializer.hasUnreliableFields} */
|
|
57
|
+
this.hasUnreliableFields = false;
|
|
58
|
+
/** Room-wide counter stamped into every unreliable frame so the client can
|
|
59
|
+
* drop reordered datagrams. Wraps at 65536 — see {@link ProtocolModifier.UNRELIABLE}. */
|
|
60
|
+
this.unreliableSeq = 0;
|
|
61
|
+
/** The `@unreliable`-with-no-datagram-transport warning fires once per room. */
|
|
62
|
+
this.warnedNoUnreliableTransport = false;
|
|
37
63
|
}
|
|
38
64
|
reset(newState) {
|
|
39
65
|
this.encoder = new Encoder(newState);
|
|
40
66
|
this.hasFilters = this.encoder.context.hasFilters;
|
|
67
|
+
this.hasUnreliableFields = stateHasUnreliableFields(this.encoder.context);
|
|
41
68
|
this.fullEncodeBuffer[0] = Protocol.ROOM_STATE;
|
|
42
69
|
if (this.hasFilters) {
|
|
43
70
|
this.encodedViews = /* @__PURE__ */ new Map();
|
|
71
|
+
if (this.hasUnreliableFields) {
|
|
72
|
+
this.encodedUnreliableViews = /* @__PURE__ */ new Map();
|
|
73
|
+
}
|
|
44
74
|
}
|
|
45
75
|
}
|
|
46
76
|
getFullState(client, timing) {
|
|
@@ -137,6 +167,73 @@ var SchemaSerializer = class {
|
|
|
137
167
|
}
|
|
138
168
|
return true;
|
|
139
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Encode and send the UNRELIABLE channel — the `@unreliable` fields, which
|
|
172
|
+
* `applyPatches` never emits. Frames go out via {@link Client.rawUnreliable}
|
|
173
|
+
* (a WebTransport datagram); clients whose transport has no unreliable
|
|
174
|
+
* channel are skipped entirely.
|
|
175
|
+
*
|
|
176
|
+
* Runs on its own cadence, independent of `applyPatches`. Only ever called on
|
|
177
|
+
* rooms where {@link hasUnreliableFields} is true.
|
|
178
|
+
*/
|
|
179
|
+
applyUnreliablePatches(clients) {
|
|
180
|
+
if (!this.encoder.hasUnreliableChanges) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
this.needFullEncode = true;
|
|
184
|
+
if (!clients.some(canReceiveUnreliable)) {
|
|
185
|
+
this.warnNoUnreliableTransport(clients);
|
|
186
|
+
this.encoder.discardUnreliableChanges();
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
this.unreliableSeq = this.unreliableSeq + 1 & 65535;
|
|
190
|
+
const it = { offset: 1 + UNRELIABLE_HEADER_SIZE };
|
|
191
|
+
this.encoder.sharedBuffer[0] = Protocol.ROOM_STATE_PATCH | ProtocolModifier.UNRELIABLE;
|
|
192
|
+
encode.uint16(this.encoder.sharedBuffer, this.unreliableSeq, { offset: 1 });
|
|
193
|
+
const encodedChanges = this.encoder.encodeUnreliable(it);
|
|
194
|
+
let numClients = clients.length;
|
|
195
|
+
if (!this.hasFilters) {
|
|
196
|
+
while (numClients--) {
|
|
197
|
+
const client = clients[numClients];
|
|
198
|
+
if (canReceiveUnreliable(client)) {
|
|
199
|
+
client.rawUnreliable(encodedChanges);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
const sharedOffset = it.offset;
|
|
204
|
+
while (numClients--) {
|
|
205
|
+
const client = clients[numClients];
|
|
206
|
+
if (!canReceiveUnreliable(client)) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const view = client.view || SHARED_VIEW;
|
|
210
|
+
let encodedView = this.encodedUnreliableViews.get(view);
|
|
211
|
+
if (encodedView === void 0) {
|
|
212
|
+
encodedView = view === SHARED_VIEW ? encodedChanges : this.encoder.encodeUnreliableView(client.view, sharedOffset, it);
|
|
213
|
+
this.encodedUnreliableViews.set(view, encodedView);
|
|
214
|
+
}
|
|
215
|
+
client.rawUnreliable(encodedView);
|
|
216
|
+
}
|
|
217
|
+
this.encodedUnreliableViews.clear();
|
|
218
|
+
}
|
|
219
|
+
this.encoder.discardUnreliableChanges();
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The state declares `@unreliable` fields but not one connected client can
|
|
224
|
+
* receive them — every WebSocket transport lacks a datagram channel. Those
|
|
225
|
+
* fields keep their join-time value and never update, which is silent
|
|
226
|
+
* otherwise. Warns once per room.
|
|
227
|
+
*/
|
|
228
|
+
warnNoUnreliableTransport(clients) {
|
|
229
|
+
if (this.warnedNoUnreliableTransport || clients.length === 0) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
this.warnedNoUnreliableTransport = true;
|
|
233
|
+
logger.warn(
|
|
234
|
+
"@colyseus/core: state has @unreliable fields, but no connected client's transport provides an unreliable channel \u2014 those fields will not be patched. Use @colyseus/h3-transport (WebTransport) to enable them."
|
|
235
|
+
);
|
|
236
|
+
}
|
|
140
237
|
handshake() {
|
|
141
238
|
if (!this.handshakeCache) {
|
|
142
239
|
this.handshakeCache = this.encoder.state && Reflection.encode(this.encoder);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/serializer/SchemaSerializer.ts"],
|
|
4
|
-
"sourcesContent": ["import { Protocol, ProtocolModifier } from '@colyseus/shared-types';\n\nimport type { PatchTimingContext, Serializer } from './Serializer.ts';\nimport { type Client, type ClientPrivate, ClientState } from '../Transport.ts';\n\nimport { type Iterator, encode, Encoder, dumpChanges, Reflection, Schema, StateView } from '@colyseus/schema';\nimport { debugPatch } from '../Debug.ts';\n\n/**\n * Size of the {@link ProtocolModifier.TIMED} prefix that follows the\n * protocol byte:\n * `[uint32 sNow][uint32 inputSeq]`\n *\n * - `sNow` \u2014 server clock as **milliseconds since room start**\n * (`room.clock.elapsedTime`), NOT raw `performance.now()`. A portable,\n * language-agnostic integer (C/C#/Lua have no `performance.now()`); the\n * server's own time-keyed logic uses the SAME `clock.elapsedTime` so the\n * client's reconstructed `serverNow()` stays in phase. Wraps at u32\n * (~49.7 days uptime) \u2014 irrelevant per session. Drives the client clock\n * offset / `serverNow()`.\n * - `inputSeq` \u2014 seq VALUE of the last input CONSUMED into the authoritative\n * state (the input buffer's `ackSeq`, i.e. last-PROCESSED input). Reliable:\n * equals the consumed count (inputs are sequenced implicitly by receive\n * order). Unreliable: the framework wire seq, so a fully-dropped input\n * doesn't make the ack lag the client's sent seq by the lost count. This\n * single number is the canonical reconciliation ack (\"the sequence number\n * of the last input the server processed\"); the client prunes its\n * pending-input buffer against it AND derives RTT from its round-trip\n * (`now \u2212 sendTime(inputSeq)`). No separate received-seq / received-time \u2014\n * RTT-from-the-processed-ack is round-trip-inclusive, which is the standard.\n */\nconst TIMED_PREFIX_SIZE = 8;\n\n/**\n * Construct a per-recipient TIMED buffer:\n * `[code|TIMED][sNow][inputSeq][...schema bytes]`\n *\n * `encodedBody` is the shared encoded buffer whose byte 0 already holds the\n * base protocol code (ROOM_STATE or ROOM_STATE_PATCH); bytes 1.. are the\n * schema payload. We construct a fresh per-client Buffer so the transport\n * never sees a buffer that might mutate between queue and send.\n *\n * Uses the schema `encode.*` helpers to keep the wire layout symmetric with\n * the SDK's `decode.*`-driven reader.\n */\nfunction buildTimedFrame(\n encodedBody: Uint8Array,\n sNow: number,\n inputSeq: number,\n): Buffer {\n const schemaPayload = encodedBody.subarray(1);\n const out = Buffer.allocUnsafe(1 + TIMED_PREFIX_SIZE + schemaPayload.length);\n out[0] = encodedBody[0] | ProtocolModifier.TIMED;\n const it: Iterator = { offset: 1 };\n encode.uint32(out, sNow >>> 0, it); // ms since room start (clock.elapsedTime)\n encode.uint32(out, inputSeq >>> 0, it);\n out.set(schemaPayload, it.offset);\n return out;\n}\n\n/** Seq VALUE of the last input consumed from the client's buffer \u2014 the\n * reconciliation ack (and RTT correlation key). Reliable: equals the consumed\n * count; unreliable: the framework wire seq (so packet loss doesn't skew it).\n * 0 when the room doesn't buffer input. */\nfunction clientProcessedInputSeq(client: Client): number {\n return (client as Client & ClientPrivate)._inputBuffer?.ackSeq ?? 0;\n}\n\n/**\n * Send a per-recipient patch frame: a TIMED-prefixed buffer when `timing` is set,\n * else the shared `encodedBody` passed straight through (no per-client copy). Any\n * `afterNextPatch` frames staged on `client._pendingFrames` are NOT coalesced here\n * \u2014 they go out standalone right after the patch via `Room._flushPendingClientFrames`.\n */\nfunction sendClientFrame(client: Client, encodedBody: Uint8Array, timing?: PatchTimingContext): void {\n if (timing) {\n client.raw(buildTimedFrame(encodedBody, timing.sNow, clientProcessedInputSeq(client)));\n } else {\n client.raw(encodedBody);\n }\n}\n\nconst SHARED_VIEW = {};\n\nexport class SchemaSerializer<T extends Schema> implements Serializer<T> {\n public id = 'schema';\n\n protected encoder: Encoder<T>;\n protected hasFilters: boolean = false;\n\n protected handshakeCache: Uint8Array;\n\n // flag to avoid re-encoding full state if no changes were made\n protected needFullEncode: boolean = true;\n\n // TODO: make this optional. allocating a new buffer for each room may not be always necessary.\n protected fullEncodeBuffer: Uint8Array = new Uint8Array(Encoder.BUFFER_SIZE);\n protected fullEncodeCache: Uint8Array;\n protected sharedOffsetCache: Iterator = { offset: 0 };\n\n protected encodedViews: Map<StateView | typeof SHARED_VIEW, Uint8Array>;\n\n public reset(newState: T & Schema) {\n this.encoder = new Encoder(newState);\n this.hasFilters = this.encoder.context.hasFilters;\n\n // cache ROOM_STATE byte as part of the encoded buffer\n this.fullEncodeBuffer[0] = Protocol.ROOM_STATE;\n\n if (this.hasFilters) {\n this.encodedViews = new Map();\n }\n }\n\n public getFullState(client?: Client, timing?: PatchTimingContext) {\n if (this.needFullEncode || this.encoder.root.changes.next !== undefined) {\n this.sharedOffsetCache = { offset: 1 };\n this.fullEncodeCache = this.encoder.encodeAll(this.sharedOffsetCache, this.fullEncodeBuffer);\n this.needFullEncode = false;\n }\n\n const body = (this.hasFilters && client?.view)\n ? this.encoder.encodeAllView(\n client.view,\n this.sharedOffsetCache.offset,\n { ...this.sharedOffsetCache },\n this.fullEncodeBuffer\n )\n : this.fullEncodeCache;\n\n if (timing && client) {\n return buildTimedFrame(body, timing.sNow, clientProcessedInputSeq(client));\n }\n return body;\n }\n\n public applyPatches(clients: Client[], _state?: unknown, timing?: PatchTimingContext) {\n let numClients = clients.length;\n\n if (numClients === 0) {\n if (this.encoder.hasChanges) {\n // if there are changes but no clients, we need to encode full state on next patch\n this.needFullEncode = true;\n }\n // skip patching and clear changes\n this.encoder.discardChanges();\n return false;\n }\n\n if (!this.encoder.hasChanges) {\n\n // check if views have changes (manual add() or remove() items)\n if (this.hasFilters) {\n //\n // FIXME: refactor this to avoid duplicating code.\n //\n // it's probably better to have 2 different 'applyPatches' methods.\n // (one for handling state with filters, and another for handling state without filters)\n //\n const clientsWithViewChange = clients.filter((client) => {\n return client.state === ClientState.JOINED && client.view?.changes.size > 0\n });\n\n if (clientsWithViewChange.length > 0) {\n const it: Iterator = { offset: 1 };\n\n const sharedOffset = it.offset;\n this.encoder.sharedBuffer[0] = Protocol.ROOM_STATE_PATCH;\n\n clientsWithViewChange.forEach((client) => {\n const encodedView = this.encoder.encodeView(client.view, sharedOffset, it);\n sendClientFrame(client, encodedView, timing);\n });\n }\n }\n\n // Heartbeat: `defineInput()` rooms still need periodic timing samples\n // to keep client clock offset / RTT estimates fresh during quiet\n // ticks. Emit a TIMED-prefixed ROOM_STATE_PATCH with an empty schema\n // body \u2014 1 byte protocol + 8 bytes prefix.\n // No-op for rooms without `defineInput()` (timing is undefined).\n if (timing) {\n const heartbeatBody = Buffer.from([Protocol.ROOM_STATE_PATCH]);\n for (let i = 0; i < clients.length; i++) {\n const client = clients[i];\n if (client.state !== ClientState.JOINED) continue;\n sendClientFrame(client, heartbeatBody, timing);\n }\n }\n\n // skip patching state if:\n // - no clients are connected\n // - no changes were made\n // - no \"filtered changes\" were made when using filters\n return false;\n }\n\n this.needFullEncode = true;\n\n // dump changes for patch debugging\n if (debugPatch.enabled) {\n (debugPatch as any).dumpChanges = dumpChanges(this.encoder.state);\n }\n\n // get patch bytes\n const it: Iterator = { offset: 1 };\n this.encoder.sharedBuffer[0] = Protocol.ROOM_STATE_PATCH;\n\n // encode changes once, for all clients\n const encodedChanges = this.encoder.encode(it);\n\n if (!this.hasFilters) {\n while (numClients--) {\n const client = clients[numClients];\n\n //\n // FIXME: avoid this check for each client\n //\n if (client.state !== ClientState.JOINED) {\n continue;\n }\n\n sendClientFrame(client, encodedChanges, timing);\n }\n\n } else {\n // cache shared offset\n const sharedOffset = it.offset;\n\n // encode state multiple times, for each client\n while (numClients--) {\n const client = clients[numClients];\n\n //\n // FIXME: avoid this check for each client\n //\n if (client.state !== ClientState.JOINED) {\n continue;\n }\n\n const view = client.view || SHARED_VIEW;\n\n let encodedView = this.encodedViews.get(view);\n\n // allow to pass the same encoded view for multiple clients\n if (encodedView === undefined) {\n encodedView = (view === SHARED_VIEW)\n ? encodedChanges\n : this.encoder.encodeView(client.view, sharedOffset, it);\n this.encodedViews.set(view, encodedView);\n }\n\n sendClientFrame(client, encodedView, timing);\n }\n\n // clear views\n this.encodedViews.clear();\n }\n\n // discard changes after sending\n this.encoder.discardChanges();\n\n // debug patches\n if (debugPatch.enabled) {\n debugPatch(\n '%d bytes sent to %d clients, %j',\n encodedChanges.length,\n clients.length,\n (debugPatch as any).dumpChanges,\n );\n }\n\n return true;\n }\n\n public handshake() {\n /**\n * Cache handshake to avoid encoding it for each client joining\n */\n if (!this.handshakeCache) {\n //\n // TODO: re-use handshake buffer for all rooms of same type (?)\n //\n this.handshakeCache = (this.encoder.state && Reflection.encode(this.encoder));\n }\n\n return this.handshakeCache;\n }\n\n}\n"],
|
|
5
|
-
"mappings": ";AAAA,SAAS,UAAU,wBAAwB;AAG3C,SAA0C,mBAAmB;AAE7D,SAAwB,QAAQ,SAAS,aAAa,
|
|
4
|
+
"sourcesContent": ["import { Protocol, ProtocolModifier } from '@colyseus/shared-types';\n\nimport type { PatchTimingContext, Serializer } from './Serializer.ts';\nimport { type Client, type ClientPrivate, ClientState } from '../Transport.ts';\n\nimport { type Iterator, $numFields, encode, Encoder, dumpChanges, Metadata, Reflection, Schema, StateView, TypeContext } from '@colyseus/schema';\nimport { debugPatch } from '../Debug.ts';\nimport { logger } from '../Logger.ts';\n\n/**\n * Size of the {@link ProtocolModifier.TIMED} prefix that follows the\n * protocol byte:\n * `[uint32 sNow][uint32 inputSeq]`\n *\n * - `sNow` \u2014 server clock as **milliseconds since room start**\n * (`room.clock.elapsedTime`), NOT raw `performance.now()`. A portable,\n * language-agnostic integer (C/C#/Lua have no `performance.now()`); the\n * server's own time-keyed logic uses the SAME `clock.elapsedTime` so the\n * client's reconstructed `serverNow()` stays in phase. Wraps at u32\n * (~49.7 days uptime) \u2014 irrelevant per session. Drives the client clock\n * offset / `serverNow()`.\n * - `inputSeq` \u2014 seq VALUE of the last input CONSUMED into the authoritative\n * state (the input buffer's `ackSeq`, i.e. last-PROCESSED input). Reliable:\n * equals the consumed count (inputs are sequenced implicitly by receive\n * order). Unreliable: the framework wire seq, so a fully-dropped input\n * doesn't make the ack lag the client's sent seq by the lost count. This\n * single number is the canonical reconciliation ack (\"the sequence number\n * of the last input the server processed\"); the client prunes its\n * pending-input buffer against it AND derives RTT from its round-trip\n * (`now \u2212 sendTime(inputSeq)`). No separate received-seq / received-time \u2014\n * RTT-from-the-processed-ack is round-trip-inclusive, which is the standard.\n */\nconst TIMED_PREFIX_SIZE = 8;\n\n/**\n * Construct a per-recipient TIMED buffer:\n * `[code|TIMED][sNow][inputSeq][...schema bytes]`\n *\n * `encodedBody` is the shared encoded buffer whose byte 0 already holds the\n * base protocol code (ROOM_STATE or ROOM_STATE_PATCH); bytes 1.. are the\n * schema payload. We construct a fresh per-client Buffer so the transport\n * never sees a buffer that might mutate between queue and send.\n *\n * Uses the schema `encode.*` helpers to keep the wire layout symmetric with\n * the SDK's `decode.*`-driven reader.\n */\nfunction buildTimedFrame(\n encodedBody: Uint8Array,\n sNow: number,\n inputSeq: number,\n): Buffer {\n const schemaPayload = encodedBody.subarray(1);\n const out = Buffer.allocUnsafe(1 + TIMED_PREFIX_SIZE + schemaPayload.length);\n out[0] = encodedBody[0] | ProtocolModifier.TIMED;\n const it: Iterator = { offset: 1 };\n encode.uint32(out, sNow >>> 0, it); // ms since room start (clock.elapsedTime)\n encode.uint32(out, inputSeq >>> 0, it);\n out.set(schemaPayload, it.offset);\n return out;\n}\n\n/** Seq VALUE of the last input consumed from the client's buffer \u2014 the\n * reconciliation ack (and RTT correlation key). Reliable: equals the consumed\n * count; unreliable: the framework wire seq (so packet loss doesn't skew it).\n * 0 when the room doesn't buffer input. */\nfunction clientProcessedInputSeq(client: Client): number {\n return (client as Client & ClientPrivate)._inputBuffer?.ackSeq ?? 0;\n}\n\n/**\n * Send a per-recipient patch frame: a TIMED-prefixed buffer when `timing` is set,\n * else the shared `encodedBody` passed straight through (no per-client copy). Any\n * `afterNextPatch` frames staged on `client._pendingFrames` are NOT coalesced here\n * \u2014 they go out standalone right after the patch via `Room._flushPendingClientFrames`.\n */\nfunction sendClientFrame(client: Client, encodedBody: Uint8Array, timing?: PatchTimingContext): void {\n if (timing) {\n client.raw(buildTimedFrame(encodedBody, timing.sNow, clientProcessedInputSeq(client)));\n } else {\n client.raw(encodedBody);\n }\n}\n\n/**\n * Size of the {@link ProtocolModifier.UNRELIABLE} header that follows the\n * protocol byte: `[uint16 seq]`. See {@link ProtocolModifier.UNRELIABLE} for\n * the wrap-safe freshness rule the client applies to `seq`.\n */\nconst UNRELIABLE_HEADER_SIZE = 2;\n\n/**\n * Does any Schema class reachable from the state root declare an `@unreliable`\n * field? Walked once per room, in {@link SchemaSerializer.reset} \u2014 the result\n * gates whether the unreliable channel is wired up at all, so rooms that don't\n * use it never reach any of the code below.\n */\nfunction stateHasUnreliableFields(context: TypeContext): boolean {\n for (const klass of context.schemas.keys()) {\n const metadata = klass[Symbol.metadata];\n if (metadata === undefined) { continue; }\n\n // `$numFields` is the last index, inclusive (matches `Metadata.getFields`).\n for (let i = 0; i <= metadata[$numFields]; i++) {\n if (Metadata.hasUnreliableAtIndex(metadata, i)) { return true; }\n }\n }\n return false;\n}\n\n/** Can this client receive the unreliable channel right now? Its transport\n * exposing {@link Client.rawUnreliable} IS the capability check. */\nfunction canReceiveUnreliable(client: Client): boolean {\n return client.state === ClientState.JOINED && client.rawUnreliable !== undefined;\n}\n\nconst SHARED_VIEW = {};\n\nexport class SchemaSerializer<T extends Schema> implements Serializer<T> {\n public id = 'schema';\n\n protected encoder: Encoder<T>;\n protected hasFilters: boolean = false;\n\n protected handshakeCache: Uint8Array;\n\n // flag to avoid re-encoding full state if no changes were made\n protected needFullEncode: boolean = true;\n\n // TODO: make this optional. allocating a new buffer for each room may not be always necessary.\n protected fullEncodeBuffer: Uint8Array = new Uint8Array(Encoder.BUFFER_SIZE);\n protected fullEncodeCache: Uint8Array;\n protected sharedOffsetCache: Iterator = { offset: 0 };\n\n protected encodedViews: Map<StateView | typeof SHARED_VIEW, Uint8Array>;\n\n /** @see {@link Serializer.hasUnreliableFields} */\n public hasUnreliableFields: boolean = false;\n\n /** Room-wide counter stamped into every unreliable frame so the client can\n * drop reordered datagrams. Wraps at 65536 \u2014 see {@link ProtocolModifier.UNRELIABLE}. */\n protected unreliableSeq: number = 0;\n\n protected encodedUnreliableViews: Map<StateView | typeof SHARED_VIEW, Uint8Array>;\n\n /** The `@unreliable`-with-no-datagram-transport warning fires once per room. */\n protected warnedNoUnreliableTransport = false;\n\n public reset(newState: T & Schema) {\n this.encoder = new Encoder(newState);\n this.hasFilters = this.encoder.context.hasFilters;\n this.hasUnreliableFields = stateHasUnreliableFields(this.encoder.context);\n\n // cache ROOM_STATE byte as part of the encoded buffer\n this.fullEncodeBuffer[0] = Protocol.ROOM_STATE;\n\n if (this.hasFilters) {\n this.encodedViews = new Map();\n\n if (this.hasUnreliableFields) {\n // Separate from `encodedViews`: the two channels encode on independent\n // cadences, so a reliable tick's cache must never be served to one.\n this.encodedUnreliableViews = new Map();\n }\n }\n }\n\n public getFullState(client?: Client, timing?: PatchTimingContext) {\n if (this.needFullEncode || this.encoder.root.changes.next !== undefined) {\n this.sharedOffsetCache = { offset: 1 };\n this.fullEncodeCache = this.encoder.encodeAll(this.sharedOffsetCache, this.fullEncodeBuffer);\n this.needFullEncode = false;\n }\n\n const body = (this.hasFilters && client?.view)\n ? this.encoder.encodeAllView(\n client.view,\n this.sharedOffsetCache.offset,\n { ...this.sharedOffsetCache },\n this.fullEncodeBuffer\n )\n : this.fullEncodeCache;\n\n if (timing && client) {\n return buildTimedFrame(body, timing.sNow, clientProcessedInputSeq(client));\n }\n return body;\n }\n\n public applyPatches(clients: Client[], _state?: unknown, timing?: PatchTimingContext) {\n let numClients = clients.length;\n\n if (numClients === 0) {\n if (this.encoder.hasChanges) {\n // if there are changes but no clients, we need to encode full state on next patch\n this.needFullEncode = true;\n }\n // skip patching and clear changes\n this.encoder.discardChanges();\n return false;\n }\n\n if (!this.encoder.hasChanges) {\n\n // check if views have changes (manual add() or remove() items)\n if (this.hasFilters) {\n //\n // FIXME: refactor this to avoid duplicating code.\n //\n // it's probably better to have 2 different 'applyPatches' methods.\n // (one for handling state with filters, and another for handling state without filters)\n //\n const clientsWithViewChange = clients.filter((client) => {\n return client.state === ClientState.JOINED && client.view?.changes.size > 0\n });\n\n if (clientsWithViewChange.length > 0) {\n const it: Iterator = { offset: 1 };\n\n const sharedOffset = it.offset;\n this.encoder.sharedBuffer[0] = Protocol.ROOM_STATE_PATCH;\n\n clientsWithViewChange.forEach((client) => {\n const encodedView = this.encoder.encodeView(client.view, sharedOffset, it);\n sendClientFrame(client, encodedView, timing);\n });\n }\n }\n\n // Heartbeat: `defineInput()` rooms still need periodic timing samples\n // to keep client clock offset / RTT estimates fresh during quiet\n // ticks. Emit a TIMED-prefixed ROOM_STATE_PATCH with an empty schema\n // body \u2014 1 byte protocol + 8 bytes prefix.\n // No-op for rooms without `defineInput()` (timing is undefined).\n if (timing) {\n const heartbeatBody = Buffer.from([Protocol.ROOM_STATE_PATCH]);\n for (let i = 0; i < clients.length; i++) {\n const client = clients[i];\n if (client.state !== ClientState.JOINED) continue;\n sendClientFrame(client, heartbeatBody, timing);\n }\n }\n\n // skip patching state if:\n // - no clients are connected\n // - no changes were made\n // - no \"filtered changes\" were made when using filters\n return false;\n }\n\n this.needFullEncode = true;\n\n // dump changes for patch debugging\n if (debugPatch.enabled) {\n (debugPatch as any).dumpChanges = dumpChanges(this.encoder.state);\n }\n\n // get patch bytes\n const it: Iterator = { offset: 1 };\n this.encoder.sharedBuffer[0] = Protocol.ROOM_STATE_PATCH;\n\n // encode changes once, for all clients\n const encodedChanges = this.encoder.encode(it);\n\n if (!this.hasFilters) {\n while (numClients--) {\n const client = clients[numClients];\n\n //\n // FIXME: avoid this check for each client\n //\n if (client.state !== ClientState.JOINED) {\n continue;\n }\n\n sendClientFrame(client, encodedChanges, timing);\n }\n\n } else {\n // cache shared offset\n const sharedOffset = it.offset;\n\n // encode state multiple times, for each client\n while (numClients--) {\n const client = clients[numClients];\n\n //\n // FIXME: avoid this check for each client\n //\n if (client.state !== ClientState.JOINED) {\n continue;\n }\n\n const view = client.view || SHARED_VIEW;\n\n let encodedView = this.encodedViews.get(view);\n\n // allow to pass the same encoded view for multiple clients\n if (encodedView === undefined) {\n encodedView = (view === SHARED_VIEW)\n ? encodedChanges\n : this.encoder.encodeView(client.view, sharedOffset, it);\n this.encodedViews.set(view, encodedView);\n }\n\n sendClientFrame(client, encodedView, timing);\n }\n\n // clear views\n this.encodedViews.clear();\n }\n\n // discard changes after sending\n this.encoder.discardChanges();\n\n // debug patches\n if (debugPatch.enabled) {\n debugPatch(\n '%d bytes sent to %d clients, %j',\n encodedChanges.length,\n clients.length,\n (debugPatch as any).dumpChanges,\n );\n }\n\n return true;\n }\n\n /**\n * Encode and send the UNRELIABLE channel \u2014 the `@unreliable` fields, which\n * `applyPatches` never emits. Frames go out via {@link Client.rawUnreliable}\n * (a WebTransport datagram); clients whose transport has no unreliable\n * channel are skipped entirely.\n *\n * Runs on its own cadence, independent of `applyPatches`. Only ever called on\n * rooms where {@link hasUnreliableFields} is true.\n */\n public applyUnreliablePatches(clients: Client[]): boolean {\n if (!this.encoder.hasUnreliableChanges) { return false; }\n\n // These mutations are visible to `encodeAll`, so the cached full state is\n // stale \u2014 even though `root.changes` (the reliable queue) is empty and\n // `getFullState`'s own guard would therefore miss it.\n this.needFullEncode = true;\n\n if (!clients.some(canReceiveUnreliable)) {\n this.warnNoUnreliableTransport(clients);\n // Drain regardless: the queue is not self-clearing, and an un-discarded\n // tick would grow it without bound.\n this.encoder.discardUnreliableChanges();\n return false;\n }\n\n this.unreliableSeq = (this.unreliableSeq + 1) & 0xFFFF;\n\n // Stamp the header before encoding, as the reliable path does. Safe across\n // a buffer overflow: `Encoder._resizeBuffer` copies the old bytes forward.\n const it: Iterator = { offset: 1 + UNRELIABLE_HEADER_SIZE };\n this.encoder.sharedBuffer[0] = Protocol.ROOM_STATE_PATCH | ProtocolModifier.UNRELIABLE;\n encode.uint16(this.encoder.sharedBuffer, this.unreliableSeq, { offset: 1 });\n\n // encode changes once, for all clients\n const encodedChanges = this.encoder.encodeUnreliable(it);\n\n let numClients = clients.length;\n\n if (!this.hasFilters) {\n while (numClients--) {\n const client = clients[numClients];\n if (canReceiveUnreliable(client)) { client.rawUnreliable!(encodedChanges); }\n }\n\n } else {\n // cache shared offset\n const sharedOffset = it.offset;\n\n while (numClients--) {\n const client = clients[numClients];\n if (!canReceiveUnreliable(client)) { continue; }\n\n const view = client.view || SHARED_VIEW;\n\n let encodedView = this.encodedUnreliableViews.get(view);\n\n // allow to pass the same encoded view for multiple clients\n if (encodedView === undefined) {\n encodedView = (view === SHARED_VIEW)\n ? encodedChanges\n : this.encoder.encodeUnreliableView(client.view, sharedOffset, it);\n this.encodedUnreliableViews.set(view, encodedView);\n }\n\n client.rawUnreliable!(encodedView);\n }\n\n // clear views\n this.encodedUnreliableViews.clear();\n }\n\n // discard changes after sending\n this.encoder.discardUnreliableChanges();\n\n return true;\n }\n\n /**\n * The state declares `@unreliable` fields but not one connected client can\n * receive them \u2014 every WebSocket transport lacks a datagram channel. Those\n * fields keep their join-time value and never update, which is silent\n * otherwise. Warns once per room.\n */\n private warnNoUnreliableTransport(clients: Client[]) {\n if (this.warnedNoUnreliableTransport || clients.length === 0) { return; }\n this.warnedNoUnreliableTransport = true;\n logger.warn(\n \"@colyseus/core: state has @unreliable fields, but no connected client's transport\" +\n \" provides an unreliable channel \u2014 those fields will not be patched.\" +\n \" Use @colyseus/h3-transport (WebTransport) to enable them.\"\n );\n }\n\n public handshake() {\n /**\n * Cache handshake to avoid encoding it for each client joining\n */\n if (!this.handshakeCache) {\n //\n // TODO: re-use handshake buffer for all rooms of same type (?)\n //\n this.handshakeCache = (this.encoder.state && Reflection.encode(this.encoder));\n }\n\n return this.handshakeCache;\n }\n\n}\n"],
|
|
5
|
+
"mappings": ";AAAA,SAAS,UAAU,wBAAwB;AAG3C,SAA0C,mBAAmB;AAE7D,SAAwB,YAAY,QAAQ,SAAS,aAAa,UAAU,kBAAkD;AAC9H,SAAS,kBAAkB;AAC3B,SAAS,cAAc;AAyBvB,IAAM,oBAAoB;AAc1B,SAAS,gBACP,aACA,MACA,UACQ;AACR,QAAM,gBAAgB,YAAY,SAAS,CAAC;AAC5C,QAAM,MAAM,OAAO,YAAY,IAAI,oBAAoB,cAAc,MAAM;AAC3E,MAAI,CAAC,IAAI,YAAY,CAAC,IAAI,iBAAiB;AAC3C,QAAM,KAAe,EAAE,QAAQ,EAAE;AACjC,SAAO,OAAO,KAAK,SAAS,GAAG,EAAE;AACjC,SAAO,OAAO,KAAK,aAAa,GAAG,EAAE;AACrC,MAAI,IAAI,eAAe,GAAG,MAAM;AAChC,SAAO;AACT;AAMA,SAAS,wBAAwB,QAAwB;AACvD,SAAQ,OAAkC,cAAc,UAAU;AACpE;AAQA,SAAS,gBAAgB,QAAgB,aAAyB,QAAmC;AACnG,MAAI,QAAQ;AACV,WAAO,IAAI,gBAAgB,aAAa,OAAO,MAAM,wBAAwB,MAAM,CAAC,CAAC;AAAA,EACvF,OAAO;AACL,WAAO,IAAI,WAAW;AAAA,EACxB;AACF;AAOA,IAAM,yBAAyB;AAQ/B,SAAS,yBAAyB,SAA+B;AAC/D,aAAW,SAAS,QAAQ,QAAQ,KAAK,GAAG;AAC1C,UAAM,WAAW,MAAM,OAAO,QAAQ;AACtC,QAAI,aAAa,QAAW;AAAE;AAAA,IAAU;AAGxC,aAAS,IAAI,GAAG,KAAK,SAAS,UAAU,GAAG,KAAK;AAC9C,UAAI,SAAS,qBAAqB,UAAU,CAAC,GAAG;AAAE,eAAO;AAAA,MAAM;AAAA,IACjE;AAAA,EACF;AACA,SAAO;AACT;AAIA,SAAS,qBAAqB,QAAyB;AACrD,SAAO,OAAO,UAAU,YAAY,UAAU,OAAO,kBAAkB;AACzE;AAEA,IAAM,cAAc,CAAC;AAEd,IAAM,mBAAN,MAAkE;AAAA,EAAlE;AACL,SAAO,KAAK;AAGZ,SAAU,aAAsB;AAKhC;AAAA,SAAU,iBAA0B;AAGpC;AAAA,SAAU,mBAA+B,IAAI,WAAW,QAAQ,WAAW;AAE3E,SAAU,oBAA8B,EAAE,QAAQ,EAAE;AAKpD;AAAA,SAAO,sBAA+B;AAItC;AAAA;AAAA,SAAU,gBAAwB;AAKlC;AAAA,SAAU,8BAA8B;AAAA;AAAA,EAEjC,MAAM,UAAsB;AACjC,SAAK,UAAU,IAAI,QAAQ,QAAQ;AACnC,SAAK,aAAa,KAAK,QAAQ,QAAQ;AACvC,SAAK,sBAAsB,yBAAyB,KAAK,QAAQ,OAAO;AAGxE,SAAK,iBAAiB,CAAC,IAAI,SAAS;AAEpC,QAAI,KAAK,YAAY;AACnB,WAAK,eAAe,oBAAI,IAAI;AAE5B,UAAI,KAAK,qBAAqB;AAG5B,aAAK,yBAAyB,oBAAI,IAAI;AAAA,MACxC;AAAA,IACF;AAAA,EACF;AAAA,EAEO,aAAa,QAAiB,QAA6B;AAChE,QAAI,KAAK,kBAAkB,KAAK,QAAQ,KAAK,QAAQ,SAAS,QAAW;AACvE,WAAK,oBAAoB,EAAE,QAAQ,EAAE;AACrC,WAAK,kBAAkB,KAAK,QAAQ,UAAU,KAAK,mBAAmB,KAAK,gBAAgB;AAC3F,WAAK,iBAAiB;AAAA,IACxB;AAEA,UAAM,OAAQ,KAAK,cAAc,QAAQ,OACrC,KAAK,QAAQ;AAAA,MACX,OAAO;AAAA,MACP,KAAK,kBAAkB;AAAA,MACvB,EAAE,GAAG,KAAK,kBAAkB;AAAA,MAC5B,KAAK;AAAA,IACP,IACA,KAAK;AAET,QAAI,UAAU,QAAQ;AACpB,aAAO,gBAAgB,MAAM,OAAO,MAAM,wBAAwB,MAAM,CAAC;AAAA,IAC3E;AACA,WAAO;AAAA,EACT;AAAA,EAEO,aAAa,SAAmB,QAAkB,QAA6B;AACpF,QAAI,aAAa,QAAQ;AAEzB,QAAI,eAAe,GAAG;AACpB,UAAI,KAAK,QAAQ,YAAY;AAE3B,aAAK,iBAAiB;AAAA,MACxB;AAEA,WAAK,QAAQ,eAAe;AAC5B,aAAO;AAAA,IACT;AAEA,QAAI,CAAC,KAAK,QAAQ,YAAY;AAG5B,UAAI,KAAK,YAAY;AAOnB,cAAM,wBAAwB,QAAQ,OAAO,CAAC,WAAW;AACvD,iBAAO,OAAO,UAAU,YAAY,UAAU,OAAO,MAAM,QAAQ,OAAO;AAAA,QAC5E,CAAC;AAED,YAAI,sBAAsB,SAAS,GAAG;AACpC,gBAAMA,MAAe,EAAE,QAAQ,EAAE;AAEjC,gBAAM,eAAeA,IAAG;AACxB,eAAK,QAAQ,aAAa,CAAC,IAAI,SAAS;AAExC,gCAAsB,QAAQ,CAAC,WAAW;AACxC,kBAAM,cAAc,KAAK,QAAQ,WAAW,OAAO,MAAM,cAAcA,GAAE;AACzE,4BAAgB,QAAQ,aAAa,MAAM;AAAA,UAC7C,CAAC;AAAA,QACH;AAAA,MACF;AAOA,UAAI,QAAQ;AACV,cAAM,gBAAgB,OAAO,KAAK,CAAC,SAAS,gBAAgB,CAAC;AAC7D,iBAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACvC,gBAAM,SAAS,QAAQ,CAAC;AACxB,cAAI,OAAO,UAAU,YAAY,OAAQ;AACzC,0BAAgB,QAAQ,eAAe,MAAM;AAAA,QAC/C;AAAA,MACF;AAMA,aAAO;AAAA,IACT;AAEA,SAAK,iBAAiB;AAGtB,QAAI,WAAW,SAAS;AACtB,MAAC,WAAmB,cAAc,YAAY,KAAK,QAAQ,KAAK;AAAA,IAClE;AAGA,UAAM,KAAe,EAAE,QAAQ,EAAE;AACjC,SAAK,QAAQ,aAAa,CAAC,IAAI,SAAS;AAGxC,UAAM,iBAAiB,KAAK,QAAQ,OAAO,EAAE;AAE7C,QAAI,CAAC,KAAK,YAAY;AACpB,aAAO,cAAc;AACnB,cAAM,SAAS,QAAQ,UAAU;AAKjC,YAAI,OAAO,UAAU,YAAY,QAAQ;AACvC;AAAA,QACF;AAEA,wBAAgB,QAAQ,gBAAgB,MAAM;AAAA,MAChD;AAAA,IAEF,OAAO;AAEL,YAAM,eAAe,GAAG;AAGxB,aAAO,cAAc;AACnB,cAAM,SAAS,QAAQ,UAAU;AAKjC,YAAI,OAAO,UAAU,YAAY,QAAQ;AACvC;AAAA,QACF;AAEA,cAAM,OAAO,OAAO,QAAQ;AAE5B,YAAI,cAAc,KAAK,aAAa,IAAI,IAAI;AAG5C,YAAI,gBAAgB,QAAW;AAC7B,wBAAe,SAAS,cACpB,iBACA,KAAK,QAAQ,WAAW,OAAO,MAAM,cAAc,EAAE;AACzD,eAAK,aAAa,IAAI,MAAM,WAAW;AAAA,QACzC;AAEA,wBAAgB,QAAQ,aAAa,MAAM;AAAA,MAC7C;AAGA,WAAK,aAAa,MAAM;AAAA,IAC1B;AAGA,SAAK,QAAQ,eAAe;AAG5B,QAAI,WAAW,SAAS;AACtB;AAAA,QACE;AAAA,QACA,eAAe;AAAA,QACf,QAAQ;AAAA,QACP,WAAmB;AAAA,MACtB;AAAA,IACF;AAEA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWO,uBAAuB,SAA4B;AACxD,QAAI,CAAC,KAAK,QAAQ,sBAAsB;AAAE,aAAO;AAAA,IAAO;AAKxD,SAAK,iBAAiB;AAEtB,QAAI,CAAC,QAAQ,KAAK,oBAAoB,GAAG;AACvC,WAAK,0BAA0B,OAAO;AAGtC,WAAK,QAAQ,yBAAyB;AACtC,aAAO;AAAA,IACT;AAEA,SAAK,gBAAiB,KAAK,gBAAgB,IAAK;AAIhD,UAAM,KAAe,EAAE,QAAQ,IAAI,uBAAuB;AAC1D,SAAK,QAAQ,aAAa,CAAC,IAAI,SAAS,mBAAmB,iBAAiB;AAC5E,WAAO,OAAO,KAAK,QAAQ,cAAc,KAAK,eAAe,EAAE,QAAQ,EAAE,CAAC;AAG1E,UAAM,iBAAiB,KAAK,QAAQ,iBAAiB,EAAE;AAEvD,QAAI,aAAa,QAAQ;AAEzB,QAAI,CAAC,KAAK,YAAY;AACpB,aAAO,cAAc;AACnB,cAAM,SAAS,QAAQ,UAAU;AACjC,YAAI,qBAAqB,MAAM,GAAG;AAAE,iBAAO,cAAe,cAAc;AAAA,QAAG;AAAA,MAC7E;AAAA,IAEF,OAAO;AAEL,YAAM,eAAe,GAAG;AAExB,aAAO,cAAc;AACnB,cAAM,SAAS,QAAQ,UAAU;AACjC,YAAI,CAAC,qBAAqB,MAAM,GAAG;AAAE;AAAA,QAAU;AAE/C,cAAM,OAAO,OAAO,QAAQ;AAE5B,YAAI,cAAc,KAAK,uBAAuB,IAAI,IAAI;AAGtD,YAAI,gBAAgB,QAAW;AAC7B,wBAAe,SAAS,cACpB,iBACA,KAAK,QAAQ,qBAAqB,OAAO,MAAM,cAAc,EAAE;AACnE,eAAK,uBAAuB,IAAI,MAAM,WAAW;AAAA,QACnD;AAEA,eAAO,cAAe,WAAW;AAAA,MACnC;AAGA,WAAK,uBAAuB,MAAM;AAAA,IACpC;AAGA,SAAK,QAAQ,yBAAyB;AAEtC,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQQ,0BAA0B,SAAmB;AACnD,QAAI,KAAK,+BAA+B,QAAQ,WAAW,GAAG;AAAE;AAAA,IAAQ;AACxE,SAAK,8BAA8B;AACnC,WAAO;AAAA,MACL;AAAA,IAGF;AAAA,EACF;AAAA,EAEO,YAAY;AAIjB,QAAI,CAAC,KAAK,gBAAgB;AAIxB,WAAK,iBAAkB,KAAK,QAAQ,SAAS,WAAW,OAAO,KAAK,OAAO;AAAA,IAC7E;AAEA,WAAO,KAAK;AAAA,EACd;AAEF;",
|
|
6
6
|
"names": ["it"]
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/serializer/Serializer.ts"],
|
|
4
|
-
"sourcesContent": ["import type { Client } from '../Transport.ts';\n\n/**\n * Per-tick timing context, populated by the Room when\n * {@link Room.defineInput} was called. Its presence makes the serializer emit\n * a {@link ProtocolModifier.TIMED} prefix on every state/patch frame \u2014 the\n * gate is per-room (defineInput), not a per-client capability.\n */\nexport interface PatchTimingContext {\n /** Server clock as ms since room start (`clock.elapsedTime`) at patch time. */\n sNow: number;\n}\n\nexport interface Serializer<T> {\n id: string;\n reset(data: any): void;\n getFullState(client?: Client, timing?: PatchTimingContext): Uint8Array;\n applyPatches(clients: Client[], state: T, timing?: PatchTimingContext): boolean;\n handshake?(): Uint8Array;\n}"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import type { Client } from '../Transport.ts';\n\n/**\n * Per-tick timing context, populated by the Room when\n * {@link Room.defineInput} was called. Its presence makes the serializer emit\n * a {@link ProtocolModifier.TIMED} prefix on every state/patch frame \u2014 the\n * gate is per-room (defineInput), not a per-client capability.\n */\nexport interface PatchTimingContext {\n /** Server clock as ms since room start (`clock.elapsedTime`) at patch time. */\n sNow: number;\n}\n\nexport interface Serializer<T> {\n id: string;\n reset(data: any): void;\n getFullState(client?: Client, timing?: PatchTimingContext): Uint8Array;\n applyPatches(clients: Client[], state: T, timing?: PatchTimingContext): boolean;\n handshake?(): Uint8Array;\n\n /**\n * Whether the state declares at least one `@unreliable` field. Computed once\n * in {@link Serializer.reset}; the Room reads it once to decide whether to arm\n * the unreliable flush at all. Never consulted on the tick path \u2014 a room that\n * doesn't use the channel must not pay for it.\n */\n hasUnreliableFields?: boolean;\n\n /**\n * Encode and send the UNRELIABLE channel to every client whose transport\n * exposes {@link Client.rawUnreliable}. Only called on rooms where\n * {@link Serializer.hasUnreliableFields} is true.\n *\n * Runs on its own cadence, independent of {@link Serializer.applyPatches}.\n */\n applyUnreliablePatches?(clients: Client[]): boolean;\n}"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;AAAA;AAAA;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -15,4 +15,19 @@ export interface Serializer<T> {
|
|
|
15
15
|
getFullState(client?: Client, timing?: PatchTimingContext): Uint8Array;
|
|
16
16
|
applyPatches(clients: Client[], state: T, timing?: PatchTimingContext): boolean;
|
|
17
17
|
handshake?(): Uint8Array;
|
|
18
|
+
/**
|
|
19
|
+
* Whether the state declares at least one `@unreliable` field. Computed once
|
|
20
|
+
* in {@link Serializer.reset}; the Room reads it once to decide whether to arm
|
|
21
|
+
* the unreliable flush at all. Never consulted on the tick path — a room that
|
|
22
|
+
* doesn't use the channel must not pay for it.
|
|
23
|
+
*/
|
|
24
|
+
hasUnreliableFields?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Encode and send the UNRELIABLE channel to every client whose transport
|
|
27
|
+
* exposes {@link Client.rawUnreliable}. Only called on rooms where
|
|
28
|
+
* {@link Serializer.hasUnreliableFields} is true.
|
|
29
|
+
*
|
|
30
|
+
* Runs on its own cadence, independent of {@link Serializer.applyPatches}.
|
|
31
|
+
*/
|
|
32
|
+
applyUnreliablePatches?(clients: Client[]): boolean;
|
|
18
33
|
}
|
package/build/utils/DevMode.cjs
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/utils/DevMode.ts"],
|
|
4
4
|
"sourcesContent": ["import fs from 'fs';\nimport path from 'path';\nimport { type Schema, MapSchema, ArraySchema, SetSchema, CollectionSchema, $childType, $changes } from '@colyseus/schema';\nimport { logger } from '../Logger.ts';\nimport { debugAndPrintError, debugDevMode } from '../Debug.ts';\nimport { getLocalRoomById, handleCreateRoom, presence, remoteRoomCall } from '../MatchMaker.ts';\nimport type { Room } from '../Room.ts';\n\nconst DEVMODE_CACHE_FILE_PATH = path.resolve(\".devmode.json\");\n\nexport let isDevMode: boolean = false;\n\nexport function hasDevModeCache() {\n return fs.existsSync(DEVMODE_CACHE_FILE_PATH);\n}\n\nexport function getDevModeCache() {\n return JSON.parse(fs.readFileSync(DEVMODE_CACHE_FILE_PATH, 'utf8')) || {};\n}\n\nexport function writeDevModeCache(cache: any) {\n fs.writeFileSync(DEVMODE_CACHE_FILE_PATH, JSON.stringify(cache, null, 2), 'utf8');\n}\n\nexport function setDevMode(bool: boolean) {\n isDevMode = bool;\n}\n\nexport async function reloadFromCache() {\n const roomHistoryList = Object.entries(await presence.hgetall(getRoomRestoreListKey()));\n debugDevMode(\"rooms to restore: %i\", roomHistoryList.length);\n\n for (const [roomId, value] of roomHistoryList) {\n let roomHistory: any;\n let recreatedRoom: Room;\n let recreatedRoomListing: any;\n\n try {\n roomHistory = JSON.parse(value);\n debugDevMode(\"restoring room %s (%s)\", roomHistory.roomName, roomId);\n\n recreatedRoomListing = await handleCreateRoom(roomHistory.roomName, roomHistory.clientOptions, roomId);\n recreatedRoom = getLocalRoomById(recreatedRoomListing.roomId);\n\n } catch (e: any) {\n // unrestorable entry (e.g. room type renamed/removed) \u2014 drop it\n // instead of wedging the boot\n debugAndPrintError(`\u274C couldn't restore room '${roomId}':\\n${e.stack}`);\n await presence.hdel(getRoomRestoreListKey(), roomId);\n continue;\n }\n\n // Restore previous state\n if (roomHistory.hasOwnProperty(\"state\")) {\n try {\n const rawState = JSON.parse(roomHistory.state);\n logger.debug(`\uD83D\uDCCB room '${roomId}' state =>`, rawState);\n\n (recreatedRoom.state as Schema).restore(rawState);\n\n // Restore the encoder's nextUniqueId so refIds increase\n // monotonically across HMR cycles. Without this, restore()\n // always produces the same refIds (0,1,2,3...) and onJoin()\n // always assigns the same next refIds (4,5...), causing the\n // client decoder to reuse stale instances on the 2nd+ cycle.\n if (roomHistory.nextRefId !== undefined) {\n const encoderRoot = recreatedRoom.state[$changes]?.root;\n if (encoderRoot && roomHistory.nextRefId > encoderRoot['nextUniqueId']) {\n encoderRoot['nextUniqueId'] = roomHistory.nextRefId;\n }\n }\n } catch (e: any) {\n debugAndPrintError(`\u274C couldn't restore room '${roomId}' state:\\n${e.stack}`);\n }\n }\n\n // Reserve seats for clients from cached history.\n // Skip entries without a reconnectionToken \u2014 these are stale\n // seats from allowReconnection() where the client already left\n // (e.g. page refresh). Restoring them would block room disposal.\n if (roomHistory.clients) {\n for (const clientData of roomHistory.clients) {\n const { sessionId, reconnectionToken } = clientData;\n if (!reconnectionToken) { continue; }\n // TODO: need to restore each client's StateView as well\n await remoteRoomCall(recreatedRoomListing.roomId, '_reserveSeat', [sessionId, {}, {}, recreatedRoom.seatReservationTimeout, false, reconnectionToken]);\n }\n }\n\n // call `onRestoreRoom` with custom 'cache'd property.\n recreatedRoom.onRestoreRoom?.(roomHistory[\"cache\"]);\n\n logger.debug(`\uD83D\uDD04 room '${roomId}' has been restored with ${roomHistory.clients?.length || 0} reserved seats: ${roomHistory.clients?.map((c: any) => c.sessionId).join(\", \")}`);\n }\n\n if (roomHistoryList.length > 0) {\n logger.debug(\"\u2705\", roomHistoryList.length, \"room(s) have been restored.\");\n }\n}\n\nexport async function cacheRoomHistory(rooms: { [roomId: string]: Room }) {\n for (const room of Object.values(rooms)) {\n const roomHistoryResult = await presence.hget(getRoomRestoreListKey(), room.roomId);\n if (roomHistoryResult) {\n try {\n const roomHistory = JSON.parse(roomHistoryResult);\n\n // custom cache method\n roomHistory[\"cache\"] = room.onCacheRoom?.();\n\n // encode state\n debugDevMode(\"caching room %s (%s)\", room.roomName, room.roomId);\n\n if (room.state) {\n roomHistory[\"state\"] = JSON.stringify(room.state);\n\n // Cache the encoder's nextUniqueId so it can be restored.\n // This ensures refIds increase monotonically across HMR cycles,\n // preventing the client decoder from reusing stale refs that\n // happen to have the same refId as newly created instances.\n const encoderRoot = room.state[$changes]?.root;\n if (encoderRoot) {\n roomHistory[\"nextRefId\"] = encoderRoot['nextUniqueId'];\n }\n }\n\n // cache active clients with their reconnection tokens\n // TODO: need to cache each client's StateView as well\n const activeClients = room.clients.map((client) => ({\n sessionId: client.sessionId,\n reconnectionToken: client.reconnectionToken,\n }));\n\n // collect active client sessionIds to avoid duplicates\n const activeSessionIds = new Set(activeClients.map((c) => c.sessionId));\n\n // also cache reserved seats (they don't have reconnectionTokens yet)\n // filter out reserved seats that are already active clients (from devMode reconnection)\n const reservedSeats = Object.keys(room['_reservedSeats'])\n .filter((sessionId) => !activeSessionIds.has(sessionId))\n .map((sessionId) => ({\n sessionId,\n reconnectionToken: undefined,\n }));\n\n roomHistory[\"clients\"] = activeClients.concat(reservedSeats);\n\n await presence.hset(getRoomRestoreListKey(), room.roomId, JSON.stringify(roomHistory));\n\n // Rewrite updated room history\n logger.debug(`\uD83D\uDCBE caching room '${room.roomId}' (clients: ${room.clients.length}, has state: ${roomHistory[\"state\"] !== undefined ? \"yes\" : \"no\"})`);\n\n } catch (e: any) {\n debugAndPrintError(`\u274C couldn't cache room '${room.roomId}', due to:\\n${e.stack}`);\n }\n }\n }\n}\n\nexport function getRoomRestoreListKey() {\n return 'roomhistory';\n}\n"],
|
|
5
|
-
"mappings": "
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["path", "fs"]
|
|
7
7
|
}
|
package/build/utils/Env.cjs
CHANGED
package/build/utils/Env.cjs.map
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/utils/Env.ts"],
|
|
4
4
|
"sourcesContent": ["import os from 'os';\nimport { LocalPresence } from '../presence/LocalPresence.ts';\nimport { LocalDriver, type MatchMakerDriver } from '../matchmaker/LocalDriver/LocalDriver.ts';\nimport { logger } from '../Logger.ts';\nimport type { Presence } from '../presence/Presence.ts';\n\nexport function isColyseusCloud(): boolean {\n return process.env.COLYSEUS_CLOUD !== undefined;\n}\n\n// On Colyseus Cloud, only opt into Redis when there's more than one CPU\n// (multi-process deploys) or when REDIS_URI is explicitly provided.\n// Single-CPU instances without REDIS_URI keep using Local presence/driver.\nfunction shouldUseRedisOnCloud(): boolean {\n return os.cpus().length > 1 || process.env.REDIS_URI !== undefined;\n}\n\nexport async function getDefaultPresence(): Promise<Presence> {\n if (isColyseusCloud() && shouldUseRedisOnCloud()) {\n try {\n const RedisPresence = await import('@colyseus/redis-presence');\n return new RedisPresence.RedisPresence(process.env.REDIS_URI);\n } catch (e) {\n console.error(e);\n logger.warn(\"\");\n logger.warn(\"\u274C could not initialize RedisPresence.\");\n logger.warn(\"\uD83D\uDC49 npm install --save @colyseus/redis-presence\");\n logger.warn(\"\");\n }\n }\n return new LocalPresence();\n}\n\nexport async function getDefaultDriver(): Promise<MatchMakerDriver> {\n if (isColyseusCloud() && shouldUseRedisOnCloud()) {\n try {\n const RedisDriver = await import('@colyseus/redis-driver');\n return new RedisDriver.RedisDriver(process.env.REDIS_URI);\n } catch (e) {\n console.error(e);\n logger.warn(\"\");\n logger.warn(\"\u274C could not initialize RedisDriver.\");\n logger.warn(\"\uD83D\uDC49 npm install --save @colyseus/redis-driver\");\n logger.warn(\"\");\n }\n }\n return new LocalDriver();\n}\n\nexport function getDefaultPublicAddress(): string | undefined {\n if (isColyseusCloud()) {\n let port = 2567;\n\n //\n // Multiple processes: Use NODE_APP_INSTANCE to play nicely with pm2\n //\n port += Number(process.env.NODE_APP_INSTANCE || \"0\");\n\n return process.env.SUBDOMAIN + \".\" + process.env.SERVER_NAME + \"/\" + port;\n\n } else {\n return undefined;\n }\n}"],
|
|
5
|
-
"mappings": "
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,gBAAe;AACf,2BAA8B;AAC9B,yBAAmD;AACnD,oBAAuB;AAGhB,SAAS,kBAA2B;AACzC,SAAO,QAAQ,IAAI,mBAAmB;AACxC;AAKA,SAAS,wBAAiC;AACxC,SAAO,UAAAA,QAAG,KAAK,EAAE,SAAS,KAAK,QAAQ,IAAI,cAAc;AAC3D;AAEA,eAAsB,qBAAwC;AAC5D,MAAI,gBAAgB,KAAK,sBAAsB,GAAG;AAChD,QAAI;AACF,YAAM,gBAAgB,MAAM,OAAO,0BAA0B;AAC7D,aAAO,IAAI,cAAc,cAAc,QAAQ,IAAI,SAAS;AAAA,IAC9D,SAAS,GAAG;AACV,cAAQ,MAAM,CAAC;AACf,2BAAO,KAAK,EAAE;AACd,2BAAO,KAAK,4CAAuC;AACnD,2BAAO,KAAK,uDAAgD;AAC5D,2BAAO,KAAK,EAAE;AAAA,IAChB;AAAA,EACF;AACA,SAAO,IAAI,mCAAc;AAC3B;AAEA,eAAsB,mBAA8C;AAClE,MAAI,gBAAgB,KAAK,sBAAsB,GAAG;AAChD,QAAI;AACF,YAAM,cAAc,MAAM,OAAO,wBAAwB;AACzD,aAAO,IAAI,YAAY,YAAY,QAAQ,IAAI,SAAS;AAAA,IAC1D,SAAS,GAAG;AACV,cAAQ,MAAM,CAAC;AACf,2BAAO,KAAK,EAAE;AACd,2BAAO,KAAK,0CAAqC;AACjD,2BAAO,KAAK,qDAA8C;AAC1D,2BAAO,KAAK,EAAE;AAAA,IAChB;AAAA,EACF;AACA,SAAO,IAAI,+BAAY;AACzB;AAEO,SAAS,0BAA8C;AAC5D,MAAI,gBAAgB,GAAG;AACrB,QAAI,OAAO;AAKX,YAAQ,OAAO,QAAQ,IAAI,qBAAqB,GAAG;AAEnD,WAAO,QAAQ,IAAI,YAAY,MAAM,QAAQ,IAAI,cAAc,MAAM;AAAA,EAEvE,OAAO;AACL,WAAO;AAAA,EACT;AACF;",
|
|
6
6
|
"names": ["os"]
|
|
7
7
|
}
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/utils/StandardSchema.ts"],
|
|
4
4
|
"sourcesContent": ["import type { StandardSchemaV1 } from '@standard-schema/spec';\nexport type { StandardSchemaV1 };\n\nexport function standardValidate<T extends StandardSchemaV1>(\n schema: T,\n input: StandardSchemaV1.InferInput<T>\n): StandardSchemaV1.InferOutput<T> {\n let result = schema['~standard'].validate(input);\n\n if (result instanceof Promise) {\n throw new Error('Schema validation must be synchronous');\n }\n\n // if the `issues` field exists, the validation failed\n if (result.issues) {\n throw new Error(JSON.stringify(result.issues, null, 2));\n }\n\n return (result as StandardSchemaV1.SuccessResult<StandardSchemaV1.InferOutput<T>>).value;\n}"],
|
|
5
|
-
"mappings": "
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAGO,SAAS,iBACZ,QACA,OAC+B;AAC/B,MAAI,SAAS,OAAO,WAAW,EAAE,SAAS,KAAK;AAE/C,MAAI,kBAAkB,SAAS;AAC3B,UAAM,IAAI,MAAM,uCAAuC;AAAA,EAC3D;AAGA,MAAI,OAAO,QAAQ;AACf,UAAM,IAAI,MAAM,KAAK,UAAU,OAAO,QAAQ,MAAM,CAAC,CAAC;AAAA,EAC1D;AAEA,SAAQ,OAA2E;AACvF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/utils/UserSessionIndex.ts"],
|
|
4
4
|
"sourcesContent": ["/**\n * User \u2192 active sessions index: \"which active rooms is user X in?\"\n *\n * Without this, answering that question would require fanning out\n * `getInspectorView()` across every running room \u2014 O(rooms) per lookup.\n * Instead, each Room writes a small Presence hash entry on `_onJoin` and\n * removes it on `_onAfterLeave` / dispose. The admin endpoint reads the\n * hash with a single `hgetall`, then reconciles against the matchmaker's\n * live room listing to drop stale entries left behind by hard crashes\n * (no `onLeave` ran).\n *\n * Hash schema:\n * key: colyseus:user-rooms:{userId}\n * field: sessionId\n * value: JSON `{ roomId, roomName, joinedAt }` (joinedAt is unix ms)\n *\n * Anonymous clients (no userId resolvable) are skipped \u2014 the index is\n * for forensic / support workflows, not anonymous traffic. A Room can\n * also opt out wholesale by setting `trackUserSessions = false` (e.g.\n * a high-volume relay room that doesn't want to pay the Presence\n * write per join).\n *\n * Per-Room state (which sessionIds have an entry, and under which userId)\n * lives in a module-level WeakMap rather than as a field on Room itself.\n * Two reasons:\n *\n * 1. Room.ts stays thin \u2014 it only knows about three call points\n * (`trackRoomJoin`, `releaseRoomLeave`, `sweepRoomDispose`) and\n * doesn't have to carry an extra Map field or two private helper\n * methods solely for this concern.\n * 2. The WeakMap is GC-tied to the Room \u2014 when a Room instance is\n * collected, its entries vanish automatically. No explicit teardown\n * hook needed beyond the dispose sweep that drains Presence.\n *\n * @internal\n */\nimport type { Presence } from '../presence/Presence.ts';\n\nexport const USER_ROOMS_KEY_PREFIX = 'colyseus:user-rooms:';\n\nexport function userRoomsKey(userId: string): string {\n return USER_ROOMS_KEY_PREFIX + userId;\n}\n\n/**\n * What's serialized into the Presence hash value (sessionId is the\n * hash field key, not part of the body). Internal write-side shape.\n */\nexport interface UserRoomEntry {\n roomId: string;\n roomName: string;\n joinedAt: number;\n}\n\n/**\n * Public read-side shape returned by `listUserSessions`: a parsed\n * `UserRoomEntry` plus its sessionId, optionally enriched with\n * `processId` when reconcile against the matchmaker was on.\n */\nexport interface UserSessionInfo extends UserRoomEntry {\n sessionId: string;\n /**\n * Process hosting the room, per the matchmaker. Populated only\n * when `listUserSessions` was called with `reconcile: true` AND\n * the room is still in the matchmaker roster.\n */\n processId?: string;\n}\n\n/**\n * Structural subset of `Room` needed by the index. Lets this module\n * avoid importing `Room` (which would create a cycle) while still\n * staying typed at the call site.\n */\ninterface InspectorRoomShape {\n roomId: string;\n roomName: string;\n presence: Presence;\n}\n\n/**\n * Structural subset of a Client we read at join/leave. `userId` and\n * `auth` are both optional \u2014 the index simply skips clients without\n * either, which is the correct \"anonymous traffic doesn't show up\n * in support tooling\" behavior.\n */\ninterface InspectorClientShape {\n sessionId: string;\n userId?: string;\n auth?: { id?: string } | null;\n}\n\n/**\n * sessionId \u2192 userId for clients currently registered in the index,\n * scoped per Room. WeakMap-keyed so a forgotten Room takes its tracking\n * map with it.\n */\nconst tracked = new WeakMap<InspectorRoomShape, Map<string, string>>();\n\nfunction getTrackingMap(room: InspectorRoomShape): Map<string, string> {\n let map = tracked.get(room);\n if (!map) {\n map = new Map();\n tracked.set(room, map);\n }\n return map;\n}\n\nfunction resolveUserId(client: InspectorClientShape): string | undefined {\n return client.userId ?? client.auth?.id;\n}\n\n/**\n * Best-effort: write the join entry. Errors are swallowed because the\n * index is observability metadata \u2014 a Presence outage shouldn't reject\n * a player's join. Exposed as a pure helper for tests + the admin\n * endpoint; `trackRoomJoin` is the Room-flavored entrypoint.\n */\nexport async function trackUserSession(\n presence: Presence,\n userId: string,\n sessionId: string,\n entry: UserRoomEntry,\n): Promise<void> {\n try {\n await presence.hset(userRoomsKey(userId), sessionId, JSON.stringify(entry));\n } catch {\n // intentional: see fn-doc\n }\n}\n\n/**\n * Best-effort: remove the join entry. Errors are swallowed so a Presence\n * blip doesn't bubble into `_onAfterLeave` / `_dispose`.\n */\nexport async function releaseUserSession(\n presence: Presence,\n userId: string,\n sessionId: string,\n): Promise<void> {\n try {\n await presence.hdel(userRoomsKey(userId), sessionId);\n } catch {\n // intentional: see fn-doc\n }\n}\n\n/**\n * Record `client`'s join under `room` in the reverse index. No-op for\n * clients with no resolvable userId (anonymous), and when the room\n * carries no presence (shouldn't happen \u2014 defensive for unit tests).\n *\n * Fire-and-forget against Presence; the in-memory tracking map updates\n * synchronously so a follow-up `releaseRoomLeave` always finds the\n * right userId even if the Presence write is still in flight.\n */\nexport function trackRoomJoin(room: InspectorRoomShape, client: InspectorClientShape): void {\n if (!room.presence) { return; }\n const userId = resolveUserId(client);\n if (!userId) { return; }\n getTrackingMap(room).set(client.sessionId, userId);\n const entry: UserRoomEntry = {\n roomId: room.roomId,\n roomName: room.roomName,\n joinedAt: Date.now(),\n };\n void trackUserSession(room.presence, userId, client.sessionId, entry);\n}\n\n/**\n * Drop `client`'s entry from the reverse index. Idempotent \u2014 no-op\n * when the client wasn't tracked (anonymous, or tracking failed at\n * join time).\n */\nexport function releaseRoomLeave(room: InspectorRoomShape, client: InspectorClientShape): void {\n if (!room.presence) { return; }\n const map = tracked.get(room);\n const userId = map?.get(client.sessionId);\n if (!userId || !map) { return; }\n map.delete(client.sessionId);\n void releaseUserSession(room.presence, userId, client.sessionId);\n}\n\n/**\n * Sweep any still-tracked sessions for `room`. Called during dispose to\n * cover the case where `disconnect()` races the per-client `_onAfterLeave`\n * path \u2014 the read-side reconcile handles cross-process crash recovery,\n * but this is the cheap deterministic cleanup for a clean local dispose.\n *\n * Awaits the pending `hdel`s so the caller can sequence against \"the\n * index is now coherent\" \u2014 the dispose path uses that ordering.\n */\nexport async function sweepRoomDispose(room: InspectorRoomShape): Promise<void> {\n if (!room.presence) { return; }\n const map = tracked.get(room);\n if (!map || map.size === 0) { return; }\n const pending: Promise<void>[] = [];\n for (const [sessionId, userId] of map) {\n pending.push(releaseUserSession(room.presence, userId, sessionId));\n }\n map.clear();\n await Promise.all(pending);\n}\n\n/**\n * Minimal shape of a matchmaker room record needed for reconcile \u2014\n * keeps this module decoupled from the matchmaker / driver types.\n * `matchMaker.query()`'s actual return (`IRoomCache[]`) is structurally\n * a supertype of this, so callers can pass `matchMaker.query` directly.\n */\ninterface MatchmakerRoomLike {\n roomId: string;\n processId?: string;\n}\n\nexport interface ListUserSessionsOptions {\n /**\n * Drop entries whose `roomId` is no longer in the matchmaker roster\n * (the index can lag a crashed process). When `true`, the returned\n * entries also carry `processId` from the live room record.\n *\n * Off by default \u2014 most callers (kick everyone, count) don't need it\n * and the extra `matchMaker.query` round-trip isn't free.\n */\n reconcile?: boolean;\n\n /**\n * Fire-and-forget `hdel` for stale entries \u2014 those dropped by\n * reconcile (matchmaker doesn't know the room anymore) plus any\n * with corrupt JSON. Lets read endpoints self-heal the index on\n * each call. No-op when `reconcile` is `false`.\n */\n removeStale?: boolean;\n}\n\n/**\n * Read the user \u2192 active sessions index. Pure helper \u2014 the\n * `Presence` + matchmaker batch lookup are injected so this module\n * stays free of matchmaker imports (and so unit tests can drive it\n * with fake deps).\n *\n * Wire-op count per call:\n * - 1 HGETALL on the user's hash (always).\n * - 1 batch room lookup when `reconcile: true` AND there are\n * entries to verify; skipped otherwise.\n *\n * Bounded at 2 wire ops regardless of the user's session count.\n */\nexport async function listUserSessions(\n presence: Presence,\n findRooms: (roomIds: string[]) => Promise<Map<string, MatchmakerRoomLike>>,\n userId: string,\n options: ListUserSessionsOptions = {},\n): Promise<UserSessionInfo[]> {\n const reconcile = options.reconcile === true;\n const removeStale = reconcile && options.removeStale === true;\n\n let raw: Record<string, string>;\n try {\n raw = await presence.hgetall(userRoomsKey(userId));\n } catch {\n // Presence outage \u2014 observability shouldn't bring down the caller.\n return [];\n }\n const fields = Object.keys(raw);\n if (fields.length === 0) { return []; }\n\n const staleSessions: string[] = [];\n const parsed: Array<{ sessionId: string; entry: UserRoomEntry }> = [];\n for (const sessionId of fields) {\n try {\n parsed.push({ sessionId, entry: JSON.parse(raw[sessionId]) as UserRoomEntry });\n } catch {\n // Corrupt JSON \u2014 index drift. Removable even without reconcile.\n staleSessions.push(sessionId);\n }\n }\n\n if (!reconcile) {\n return parsed.map(({ sessionId, entry }) => ({ sessionId, ...entry }));\n }\n\n // One batch lookup for the K roomIds we care about. K = entries\n // surviving the JSON.parse stage, not cluster size.\n const live = parsed.length > 0\n ? await findRooms(parsed.map((p) => p.entry.roomId))\n : new Map<string, MatchmakerRoomLike>();\n\n const result: UserSessionInfo[] = [];\n for (const { sessionId, entry } of parsed) {\n const room = live.get(entry.roomId);\n if (!room) {\n // Matchmaker doesn't know this roomId anymore \u2014 stale entry\n // from a crashed process. Drop it (and remove if requested).\n staleSessions.push(sessionId);\n continue;\n }\n const info: UserSessionInfo = { sessionId, ...entry };\n if (room.processId !== undefined) { info.processId = room.processId; }\n result.push(info);\n }\n\n if (removeStale && staleSessions.length > 0) {\n const key = userRoomsKey(userId);\n void Promise.all(\n staleSessions.map((s) => presence.hdel(key, s)),\n ).catch(() => { /* presence outage, swallow */ });\n }\n\n return result;\n}\n"],
|
|
5
|
-
"mappings": "
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/build/utils/Utils.cjs
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/utils/Utils.ts"],
|
|
4
4
|
"sourcesContent": ["import { nanoid } from 'nanoid';\nimport { type RoomException, type RoomMethodName } from '../errors/RoomExceptions.ts';\n\nimport { debugAndPrintError, debugMatchMaking } from '../Debug.ts';\n\nexport type Type<T> = new (...args: any[]) => T;\nexport type MethodName<T> = string & {\n [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never\n}[keyof T];\n\n/**\n * Utility type that extracts the return type of a method or the type of a property\n * from a given class/object type.\n *\n * - If the key is a method, returns the awaited return type of that method\n * - If the key is a property, returns the type of that property\n */\nexport type ExtractMethodOrPropertyType<\n TClass,\n TKey extends keyof TClass\n> = TClass[TKey] extends (...args: any[]) => infer R\n ? Awaited<R>\n : TClass[TKey];\n\n/**\n * Return type of `remoteRoomCall()`.\n *\n * Resolves to the method's awaited return type (or the property's type) when\n * the method name was captured as a literal type. Falls back to `any` when it\n * wasn't \u2014 e.g. `remoteRoomCall<MyRoom>(...)` with only the room type given:\n * TypeScript applies the `TMethod` default instead of inferring the literal\n * once an explicit type argument list is present (microsoft/TypeScript#26242).\n * Pass both type arguments (`remoteRoomCall<MyRoom, 'myMethod'>`) for a\n * precise return type.\n */\nexport type RemoteRoomCallReturn<\n TRoom,\n TMethod extends keyof TRoom\n> = keyof TRoom extends TMethod\n ? any\n : ExtractMethodOrPropertyType<TRoom, TMethod>;\n\n// remote room call timeouts\nexport const REMOTE_ROOM_SHORT_TIMEOUT = Number(process.env.COLYSEUS_PRESENCE_SHORT_TIMEOUT || 2000);\nexport const MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME = Number(process.env.COLYSEUS_MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME || 0.5);\n\n/**\n * The well-known `Symbol.metadata` (TC39 decorator metadata), falling back to the\n * registered symbol on runtimes that don't expose it globally yet. `@colyseus/schema`\n * stores each class's field metadata under this key \u2014 read it via\n * `instance.constructor[$METADATA]`. Shared so every reader resolves the IDENTICAL\n * symbol (InputBuffer's field-name walk, Rewind's field-index lookup).\n */\nexport const $METADATA: symbol = (Symbol as { metadata?: symbol }).metadata ?? Symbol.for(\"Symbol.metadata\");\n\nexport function generateId(length: number = 9) {\n return nanoid(length);\n}\n\nexport function getBearerToken(authHeader: string) {\n return (authHeader && authHeader.startsWith(\"Bearer \") && authHeader.substring(7, authHeader.length)) || undefined;\n}\n\n// nodemon sends SIGUSR2 before reloading\n// (https://github.com/remy/nodemon#controlling-shutdown-of-your-script)\n//\nconst signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGUSR2'];\n\nexport function registerGracefulShutdown(callback: (err?: Error) => void) {\n /**\n * Gracefully shutdown on uncaught errors\n */\n process.on('uncaughtException', (err) => {\n debugAndPrintError(err);\n callback(err);\n });\n\n signals.forEach((signal) =>\n process.once(signal, () => callback()));\n}\n\nexport function retry<T = any>(\n cb: Function,\n maxRetries: number = 3,\n errorWhiteList: any[] = [],\n retries: number = 0,\n) {\n return new Promise<T>((resolve, reject) => {\n cb()\n .then(resolve)\n .catch((e: any) => {\n if (\n errorWhiteList.indexOf(e.constructor) !== -1 &&\n retries++ < maxRetries\n ) {\n setTimeout(() => {\n debugMatchMaking(\"retrying due to error (error: %s, retries: %s, maxRetries: %s)\", e.message, retries, maxRetries);\n retry<T>(cb, maxRetries, errorWhiteList, retries).\n then(resolve).\n catch((e2) => reject(e2));\n }, Math.floor(Math.random() * Math.pow(2, retries) * 400));\n\n } else {\n reject(e);\n }\n });\n });\n}\n\nexport function spliceOne(arr: any[], index: number): boolean {\n // manually splice availableRooms array\n // http://jsperf.com/manual-splice\n if (index === -1 || index >= arr.length) {\n return false;\n }\n\n const len = arr.length - 1;\n for (let i = index; i < len; i++) {\n arr[i] = arr[i + 1];\n }\n\n arr.length = len;\n return true;\n}\n\nexport class Deferred<T = any> {\n public promise: Promise<T>;\n\n public resolve: Function;\n public reject: Function;\n\n constructor(promise?: Promise<T>) {\n this.promise = promise ?? new Promise<T>((resolve, reject) => {\n this.resolve = resolve;\n this.reject = reject;\n });\n }\n\n public then(onFulfilled?: (value: T) => any, onRejected?: (reason: any) => any) {\n return this.promise.then(onFulfilled, onRejected);\n }\n\n public catch(func: (value: any) => any) {\n return this.promise.catch(func);\n }\n\n static reject (reason?: any) {\n return new Deferred(Promise.reject(reason));\n }\n\n static resolve<T = any>(value?: T) {\n return new Deferred<T>(Promise.resolve(value));\n }\n\n}\n\nexport function merge(a: any, ...objs: any[]): any {\n for (let i = 0, len = objs.length; i < len; i++) {\n const b = objs[i];\n for (const key in b) {\n if (b.hasOwnProperty(key)) {\n a[key] = b[key];\n }\n }\n }\n return a;\n}\n\nexport function wrapTryCatch(\n method: Function,\n onError: (error: RoomException, methodName: RoomMethodName) => void,\n exceptionClass: Type<RoomException>,\n methodName: RoomMethodName,\n rethrow: boolean = false,\n ...additionalErrorArgs: any[]\n) {\n return (...args: any[]) => {\n try {\n const result = method(...args);\n if (typeof (result?.catch) === \"function\") {\n return result.catch((e: Error) => {\n onError(new exceptionClass(e, e.message, ...args, ...additionalErrorArgs), methodName);\n if (rethrow) { throw e; }\n });\n }\n return result;\n } catch (e: any) {\n onError(new exceptionClass(e, e.message, ...args, ...additionalErrorArgs), methodName);\n if (rethrow) { throw e; }\n }\n };\n}\n\n/**\n * Dynamically import a module using either require() or import()\n * based on the current module system (CJS vs ESM).\n *\n * This avoids double-loading packages when running in mixed ESM/CJS environments.\n * Errors are silently caught - await the promise and handle errors at usage site.\n */\nexport function dynamicImport<T = any>(moduleName: string): Promise<T> {\n // __dirname exists in CJS but not in ESM\n if (\n typeof __dirname !== 'undefined' &&\n // @ts-ignore\n typeof (Bun) === 'undefined' // prevent bun from loading CJS modules\n ) {\n // CJS context - use require()\n try {\n return Promise.resolve(require(moduleName));\n } catch (e: any) {\n // If the error is not a MODULE_NOT_FOUND error, reject with the error.\n if (e.code !== 'MODULE_NOT_FOUND') {\n return Promise.reject(e);\n }\n return Promise.resolve(undefined);\n }\n } else {\n // ESM context - use import()\n const promise = import(/* @vite-ignore */ moduleName);\n promise.catch(() => {}); // prevent unhandled rejection warnings\n return promise;\n }\n}\n"],
|
|
5
|
-
"mappings": "
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/utils/nanoevents.ts"],
|
|
4
4
|
"sourcesContent": ["export const createNanoEvents = () => ({\n emit(event: string, ...args: any[]) {\n for (\n let callbacks = this.events[event] || [],\n i = 0,\n length = callbacks.length;\n i < length;\n i++\n ) {\n callbacks[i](...args)\n }\n },\n // null-prototype: event names are client-supplied (message types), and on a\n // plain object \"__proto__\" / \"constructor\" / \"toString\" resolve to inherited\n // members instead of missing (colyseus/colyseus#951)\n events: Object.create(null) as { [event: string]: Array<(...args: any[]) => void> },\n on(event: string, cb: (...args: any[]) => void) {\n ;(this.events[event] ||= []).push(cb)\n return () => {\n this.events[event] = this.events[event]?.filter(i => cb !== i)\n }\n }\n })"],
|
|
5
|
-
"mappings": "
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAO,IAAM,mBAAmB,OAAO;AAAA,EACnC,KAAK,UAAkB,MAAa;AAClC,aACM,YAAY,KAAK,OAAO,KAAK,KAAK,CAAC,GACrC,IAAI,GACJ,SAAS,UAAU,QACrB,IAAI,QACJ,KACA;AACA,gBAAU,CAAC,EAAE,GAAG,IAAI;AAAA,IACtB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAIA,QAAQ,uBAAO,OAAO,IAAI;AAAA,EAC1B,GAAG,OAAe,IAA8B;AAC9C;AAAC,KAAC,KAAK,OAAO,KAAK,MAAM,CAAC,GAAG,KAAK,EAAE;AACpC,WAAO,MAAM;AACX,WAAK,OAAO,KAAK,IAAI,KAAK,OAAO,KAAK,GAAG,OAAO,OAAK,OAAO,CAAC;AAAA,IAC/D;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colyseus/core",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.6",
|
|
4
4
|
"description": "Multiplayer Framework for Node.js.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"input": "./src/index.ts",
|
|
@@ -59,17 +59,17 @@
|
|
|
59
59
|
"@colyseus/schema": "^5.0.8",
|
|
60
60
|
"express": "^5.0.0",
|
|
61
61
|
"vitest": "^3.1.1",
|
|
62
|
-
"@colyseus/redis-driver": "^0.18.
|
|
63
|
-
"@colyseus/
|
|
64
|
-
"@colyseus/
|
|
62
|
+
"@colyseus/redis-driver": "^0.18.2",
|
|
63
|
+
"@colyseus/tools": "^0.18.2",
|
|
64
|
+
"@colyseus/redis-presence": "^0.18.2"
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
67
|
"@colyseus/schema": "^5.0.8",
|
|
68
68
|
"@pm2/io": "^6.1.0",
|
|
69
69
|
"express": "^4.16.0 || ^5.0.0",
|
|
70
70
|
"zod": "^4.1.12",
|
|
71
|
-
"@colyseus/auth": "^0.18.1",
|
|
72
71
|
"@colyseus/better-call": "^1.3.1",
|
|
72
|
+
"@colyseus/auth": "^0.18.1",
|
|
73
73
|
"@colyseus/ws-transport": "^0.18.1"
|
|
74
74
|
},
|
|
75
75
|
"peerDependenciesMeta": {
|
package/src/MatchMaker.ts
CHANGED
|
@@ -37,6 +37,16 @@ const handlers: {[id: string]: RegisteredHandler} = Object.create(null);
|
|
|
37
37
|
const rooms: {[roomId: string]: Room} = {};
|
|
38
38
|
const events = new EventEmitter();
|
|
39
39
|
|
|
40
|
+
// A room isn't gone when 'dispose' fires — that only *starts* `Room.#_dispose()`,
|
|
41
|
+
// which awaits the user's async `onDispose()`. `roomCount` is decremented there,
|
|
42
|
+
// so this covers the window until 'disconnect' (emitted once `#_dispose()`
|
|
43
|
+
// settles). Invariant: roomCount + disposingRoomCount = rooms not yet fully gone.
|
|
44
|
+
let disposingRoomCount = 0;
|
|
45
|
+
|
|
46
|
+
function hasActiveRooms() {
|
|
47
|
+
return stats.local.roomCount > 0 || disposingRoomCount > 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
export let publicAddress: string;
|
|
41
51
|
export let processId: string;
|
|
42
52
|
export let presence: Presence;
|
|
@@ -103,6 +113,7 @@ export async function setup(
|
|
|
103
113
|
publicAddress = _publicAddress || getDefaultPublicAddress();
|
|
104
114
|
|
|
105
115
|
stats.reset(false);
|
|
116
|
+
disposingRoomCount = 0;
|
|
106
117
|
|
|
107
118
|
// ensure processId is set
|
|
108
119
|
if (!processId) { processId = generateId(); }
|
|
@@ -648,11 +659,14 @@ export async function handleCreateRoom(roomName: string, clientOptions: ClientOp
|
|
|
648
659
|
room['_events'].removeAllListeners('metadata-change');
|
|
649
660
|
}
|
|
650
661
|
|
|
662
|
+
// this room's `onDispose()` has settled
|
|
663
|
+
disposingRoomCount--;
|
|
664
|
+
|
|
651
665
|
//
|
|
652
666
|
// emit "no active rooms" event when there are no more rooms in this process
|
|
653
667
|
// (used during graceful shutdown)
|
|
654
668
|
//
|
|
655
|
-
if (
|
|
669
|
+
if (!hasActiveRooms()) {
|
|
656
670
|
events.emit('no-active-rooms');
|
|
657
671
|
}
|
|
658
672
|
});
|
|
@@ -713,7 +727,7 @@ async function lockAndDisposeAll(): Promise<any> {
|
|
|
713
727
|
}
|
|
714
728
|
|
|
715
729
|
const noActiveRooms = new Deferred();
|
|
716
|
-
if (
|
|
730
|
+
if (!hasActiveRooms()) {
|
|
717
731
|
// no active rooms to dispose
|
|
718
732
|
noActiveRooms.resolve();
|
|
719
733
|
|
|
@@ -820,7 +834,7 @@ export async function hotReload(): Promise<void> {
|
|
|
820
834
|
|
|
821
835
|
// Lock all rooms and trigger default onBeforeShutdown (dev mode impl).
|
|
822
836
|
const noActiveRooms = new Deferred();
|
|
823
|
-
if (
|
|
837
|
+
if (!hasActiveRooms()) {
|
|
824
838
|
noActiveRooms.resolve();
|
|
825
839
|
} else {
|
|
826
840
|
events.once('no-active-rooms', () => noActiveRooms.resolve());
|
|
@@ -1205,6 +1219,9 @@ async function disposeRoom(roomName: string, room: Room) {
|
|
|
1205
1219
|
driver.remove(room['_listing'].roomId);
|
|
1206
1220
|
stats.local.roomCount--;
|
|
1207
1221
|
|
|
1222
|
+
// `onDispose()` is still pending — released on 'disconnect'
|
|
1223
|
+
disposingRoomCount++;
|
|
1224
|
+
|
|
1208
1225
|
// decrease amount of rooms this process is handling
|
|
1209
1226
|
if (state !== MatchMakerState.SHUTTING_DOWN) {
|
|
1210
1227
|
stats.persist();
|