@colyseus/core 0.17.44 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/MatchMaker.cjs +22 -13
- package/build/MatchMaker.cjs.map +2 -2
- package/build/MatchMaker.d.ts +10 -0
- package/build/MatchMaker.mjs +22 -14
- package/build/MatchMaker.mjs.map +2 -2
- package/build/Protocol.cjs +102 -37
- package/build/Protocol.cjs.map +2 -2
- package/build/Protocol.d.ts +33 -2
- package/build/Protocol.mjs +102 -37
- package/build/Protocol.mjs.map +2 -2
- package/build/Rewind.cjs +385 -0
- package/build/Rewind.cjs.map +7 -0
- package/build/Rewind.d.ts +291 -0
- package/build/Rewind.mjs +359 -0
- package/build/Rewind.mjs.map +7 -0
- package/build/Room.cjs +425 -149
- package/build/Room.cjs.map +3 -3
- package/build/Room.d.ts +314 -27
- package/build/Room.mjs +434 -152
- package/build/Room.mjs.map +3 -3
- package/build/RoomMessages.cjs +265 -0
- package/build/RoomMessages.cjs.map +7 -0
- package/build/RoomMessages.d.ts +49 -0
- package/build/RoomMessages.mjs +240 -0
- package/build/RoomMessages.mjs.map +7 -0
- package/build/RoomPlugin.cjs +252 -0
- package/build/RoomPlugin.cjs.map +7 -0
- package/build/RoomPlugin.d.ts +271 -0
- package/build/RoomPlugin.mjs +220 -0
- package/build/RoomPlugin.mjs.map +7 -0
- package/build/Server.cjs +49 -11
- package/build/Server.cjs.map +2 -2
- package/build/Server.d.ts +26 -0
- package/build/Server.mjs +50 -12
- package/build/Server.mjs.map +2 -2
- package/build/Transport.cjs +64 -2
- package/build/Transport.cjs.map +2 -2
- package/build/Transport.d.ts +108 -5
- package/build/Transport.mjs +63 -2
- package/build/Transport.mjs.map +2 -2
- package/build/errors/RoomExceptions.cjs +7 -4
- package/build/errors/RoomExceptions.cjs.map +2 -2
- package/build/errors/RoomExceptions.d.ts +15 -3
- package/build/errors/RoomExceptions.mjs +5 -3
- package/build/errors/RoomExceptions.mjs.map +2 -2
- package/build/index.cjs +30 -4
- package/build/index.cjs.map +2 -2
- package/build/index.d.ts +9 -5
- package/build/index.mjs +29 -6
- package/build/index.mjs.map +2 -2
- package/build/input/InputBuffer.cjs +536 -0
- package/build/input/InputBuffer.cjs.map +7 -0
- package/build/input/InputBuffer.d.ts +226 -0
- package/build/input/InputBuffer.mjs +507 -0
- package/build/input/InputBuffer.mjs.map +7 -0
- package/build/input/RoomInput.cjs +306 -0
- package/build/input/RoomInput.cjs.map +7 -0
- package/build/input/RoomInput.d.ts +91 -0
- package/build/input/RoomInput.mjs +287 -0
- package/build/input/RoomInput.mjs.map +7 -0
- package/build/input/types.cjs +18 -0
- package/build/input/types.cjs.map +7 -0
- package/build/input/types.d.ts +476 -0
- package/build/input/types.mjs +0 -0
- package/build/input/types.mjs.map +7 -0
- package/build/internal.cjs +61 -0
- package/build/internal.cjs.map +7 -0
- package/build/internal.d.ts +9 -0
- package/build/internal.mjs +29 -0
- package/build/internal.mjs.map +7 -0
- package/build/matchmaker/LocalDriver/LocalDriver.cjs +13 -0
- package/build/matchmaker/LocalDriver/LocalDriver.cjs.map +2 -2
- package/build/matchmaker/LocalDriver/LocalDriver.d.ts +1 -0
- package/build/matchmaker/LocalDriver/LocalDriver.mjs +13 -0
- package/build/matchmaker/LocalDriver/LocalDriver.mjs.map +2 -2
- package/build/matchmaker/driver.cjs.map +1 -1
- package/build/matchmaker/driver.d.ts +12 -0
- package/build/matchmaker/driver.mjs.map +1 -1
- package/build/presence/LocalPresence.d.ts +1 -1
- package/build/rooms/LobbyRoom.cjs +8 -10
- package/build/rooms/LobbyRoom.cjs.map +2 -2
- package/build/rooms/LobbyRoom.d.ts +4 -3
- package/build/rooms/LobbyRoom.mjs +8 -10
- package/build/rooms/LobbyRoom.mjs.map +2 -2
- package/build/rooms/QueueRoom.cjs +1 -1
- package/build/rooms/QueueRoom.cjs.map +2 -2
- package/build/rooms/QueueRoom.mjs +1 -1
- package/build/rooms/QueueRoom.mjs.map +2 -2
- package/build/rooms/RelayRoom.cjs +12 -16
- package/build/rooms/RelayRoom.cjs.map +2 -2
- package/build/rooms/RelayRoom.d.ts +32 -11
- package/build/rooms/RelayRoom.mjs +10 -16
- package/build/rooms/RelayRoom.mjs.map +2 -2
- package/build/router/default_routes.cjs +4 -1
- package/build/router/default_routes.cjs.map +2 -2
- package/build/router/default_routes.mjs +4 -1
- package/build/router/default_routes.mjs.map +2 -2
- package/build/router/index.cjs +71 -5
- package/build/router/index.cjs.map +2 -2
- package/build/router/index.d.ts +30 -6
- package/build/router/index.mjs +72 -7
- package/build/router/index.mjs.map +3 -3
- package/build/serializer/NoneSerializer.cjs +2 -2
- package/build/serializer/NoneSerializer.cjs.map +2 -2
- package/build/serializer/NoneSerializer.d.ts +3 -3
- package/build/serializer/NoneSerializer.mjs +2 -2
- package/build/serializer/NoneSerializer.mjs.map +2 -2
- package/build/serializer/SchemaSerializer.cjs +44 -14
- package/build/serializer/SchemaSerializer.cjs.map +2 -2
- package/build/serializer/SchemaSerializer.d.ts +3 -3
- package/build/serializer/SchemaSerializer.mjs +46 -16
- package/build/serializer/SchemaSerializer.mjs.map +2 -2
- package/build/serializer/Serializer.cjs.map +1 -1
- package/build/serializer/Serializer.d.ts +12 -2
- package/build/utils/DevMode.cjs +14 -14
- package/build/utils/DevMode.cjs.map +2 -2
- package/build/utils/DevMode.d.ts +0 -2
- package/build/utils/DevMode.mjs +14 -12
- package/build/utils/DevMode.mjs.map +2 -2
- package/build/utils/UserSessionIndex.cjs +162 -0
- package/build/utils/UserSessionIndex.cjs.map +7 -0
- package/build/utils/UserSessionIndex.d.ts +166 -0
- package/build/utils/UserSessionIndex.mjs +130 -0
- package/build/utils/UserSessionIndex.mjs.map +7 -0
- package/build/utils/Utils.cjs +3 -0
- package/build/utils/Utils.cjs.map +2 -2
- package/build/utils/Utils.d.ts +8 -0
- package/build/utils/Utils.mjs +2 -0
- package/build/utils/Utils.mjs.map +2 -2
- package/package.json +19 -14
- package/src/MatchMaker.ts +47 -15
- package/src/Protocol.ts +130 -59
- package/src/Rewind.ts +572 -0
- package/src/Room.ts +649 -235
- package/src/RoomMessages.ts +342 -0
- package/src/RoomPlugin.ts +563 -0
- package/src/Server.ts +82 -15
- package/src/Transport.ts +172 -9
- package/src/errors/RoomExceptions.ts +18 -4
- package/src/index.ts +23 -3
- package/src/input/InputBuffer.ts +549 -0
- package/src/input/RoomInput.ts +337 -0
- package/src/input/types.ts +503 -0
- package/src/internal.ts +46 -0
- package/src/matchmaker/LocalDriver/LocalDriver.ts +10 -0
- package/src/matchmaker/driver.ts +13 -0
- package/src/rooms/LobbyRoom.ts +12 -8
- package/src/rooms/QueueRoom.ts +1 -1
- package/src/rooms/RelayRoom.ts +9 -15
- package/src/router/default_routes.ts +6 -1
- package/src/router/index.ts +117 -11
- package/src/serializer/NoneSerializer.ts +3 -3
- package/src/serializer/SchemaSerializer.ts +110 -19
- package/src/serializer/Serializer.ts +13 -2
- package/src/utils/DevMode.ts +18 -13
- package/src/utils/UserSessionIndex.ts +311 -0
- package/src/utils/Utils.ts +9 -0
package/src/Room.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import { unpack } from '@colyseus/msgpackr';
|
|
2
1
|
import { decode, type Iterator, $changes } from '@colyseus/schema';
|
|
2
|
+
import { validateSubSteps } from './input/InputBuffer.ts';
|
|
3
|
+
import type { InputAPI, DefineInputOptions, IdleDeclared } from './input/types.ts';
|
|
4
|
+
import { RoomInput } from './input/RoomInput.ts';
|
|
5
|
+
import { RoomMessages } from './RoomMessages.ts';
|
|
6
|
+
import { Rewind, type RewindOptions } from './Rewind.ts';
|
|
7
|
+
export { type InputAccessor, type InputAPI, type NormalizedInputOptions, type ConsumeOptions, type IdleInput, type IdleContext, type SanitizeInput, type NumericFieldsOf, type DefineInputOptions, type IdleDeclared } from './input/types.ts';
|
|
8
|
+
|
|
3
9
|
import { ClockTimer as Clock } from '@colyseus/timer';
|
|
4
10
|
|
|
5
11
|
import { EventEmitter } from 'events';
|
|
@@ -14,44 +20,108 @@ import { SchemaSerializer } from './serializer/SchemaSerializer.ts';
|
|
|
14
20
|
|
|
15
21
|
import { getMessageBytes } from './Protocol.ts';
|
|
16
22
|
import { type Type, Deferred, generateId, wrapTryCatch } from './utils/Utils.ts';
|
|
17
|
-
import { createNanoEvents } from './utils/nanoevents.ts';
|
|
18
23
|
import { isDevMode } from './utils/DevMode.ts';
|
|
19
24
|
|
|
20
25
|
import { debugAndPrintError, debugMatchMaking, debugMessage } from './Debug.ts';
|
|
21
26
|
import { ServerError } from './errors/ServerError.ts';
|
|
22
27
|
import { ClientState, type AuthContext, type Client, type ClientPrivate, ClientArray, type ISendOptions, type MessageArgs } from './Transport.ts';
|
|
23
|
-
import { type RoomMethodName, OnAuthException, OnCreateException, OnDisposeException, OnDropException, OnJoinException, OnLeaveException,
|
|
28
|
+
import { type RoomMethodName, OnAuthException, OnCreateException, OnDisposeException, OnDropException, OnJoinException, OnLeaveException, OnReconnectException, type RoomException, TimestepException, TimedEventException } from './errors/RoomExceptions.ts';
|
|
24
29
|
|
|
25
|
-
import {
|
|
30
|
+
import { type StandardSchemaV1 } from './utils/StandardSchema.ts';
|
|
26
31
|
import * as matchMaker from './MatchMaker.ts';
|
|
27
32
|
|
|
28
33
|
import {
|
|
29
34
|
CloseCode,
|
|
30
35
|
ErrorCode,
|
|
31
36
|
Protocol,
|
|
37
|
+
PROTOCOL_CODE_MASK,
|
|
38
|
+
PROTOCOL_MODIFIER_MASK,
|
|
32
39
|
type MessageHandlerWithFormat as SharedMessageHandlerWithFormat,
|
|
33
40
|
type MessageHandler as SharedMessageHandler,
|
|
34
41
|
type Messages as SharedMessages,
|
|
42
|
+
type MessageContext,
|
|
35
43
|
} from '@colyseus/shared-types';
|
|
36
44
|
|
|
45
|
+
import {
|
|
46
|
+
RoomPlugin,
|
|
47
|
+
setupRoomPlugins,
|
|
48
|
+
type PluginLayout,
|
|
49
|
+
} from './RoomPlugin.ts';
|
|
50
|
+
export {
|
|
51
|
+
RoomPlugin,
|
|
52
|
+
definePlugins,
|
|
53
|
+
attachToTestRoom,
|
|
54
|
+
type RoomPluginOrder,
|
|
55
|
+
} from './RoomPlugin.ts';
|
|
56
|
+
|
|
37
57
|
const DEFAULT_PATCH_RATE = 1000 / 20; // 20fps (50ms)
|
|
38
58
|
const DEFAULT_SIMULATION_INTERVAL = 1000 / 60; // 60fps (16.66ms)
|
|
39
59
|
const noneSerializer = new NoneSerializer();
|
|
40
60
|
|
|
61
|
+
/** Shared `enqueueRaw` options routing a frame onto `_pendingFrames` to ride the
|
|
62
|
+
* next patch. Frozen + shared (read-only) → zero per-call allocation. */
|
|
63
|
+
const AFTER_PATCH_OPTS = Object.freeze({ afterNextPatch: true });
|
|
64
|
+
|
|
41
65
|
export const DEFAULT_SEAT_RESERVATION_TIME = Number(process.env.COLYSEUS_SEAT_RESERVATION_TIME || 15);
|
|
42
66
|
|
|
43
67
|
export type SimulationCallback = (deltaTime: number) => void;
|
|
44
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Per-step context passed to a {@link Room.setFixedTimestep} callback. Carries
|
|
71
|
+
* ONLY the fixed simulation step — never wall-clock/measured time — so feeding a
|
|
72
|
+
* jittery delta into deterministic sim math is unrepresentable. The same fixed
|
|
73
|
+
* `dt` is advertised to clients so prediction integrates identically. (The
|
|
74
|
+
* client's step context additionally carries an `isReplay` flag for rollback
|
|
75
|
+
* re-simulation; the server is authoritative and never replays, so it has none.)
|
|
76
|
+
*/
|
|
77
|
+
export interface StepContext {
|
|
78
|
+
/** Fixed step in SECONDS (`1/tickRate`) — the dt to integrate one step with. */
|
|
79
|
+
readonly dt: number;
|
|
80
|
+
/** Fixed step in MILLISECONDS (`1000/tickRate`). */
|
|
81
|
+
readonly dtMs: number;
|
|
82
|
+
/** Monotonic index of the fixed step being simulated. */
|
|
83
|
+
readonly tick: number;
|
|
84
|
+
/**
|
|
85
|
+
* Physics sub-steps per fixed step (≥ 1; `1` unless declared via
|
|
86
|
+
* `setFixedTimestep(..., { subSteps })`). Run your engine `subSteps` times at
|
|
87
|
+
* {@link subDt} inside each step — `for (let i = 0; i < ctx.subSteps; i++)
|
|
88
|
+
* world.step(ctx.subDt)` — to integrate physics at `tickRate * subSteps` Hz
|
|
89
|
+
* while inputs flow at `tickRate`. The same numbers are cascaded to predicting
|
|
90
|
+
* clients (their step context carries identical `subSteps`/`subDt`), so the
|
|
91
|
+
* sub-stepped trajectory replays bit-identically.
|
|
92
|
+
*/
|
|
93
|
+
readonly subSteps: number;
|
|
94
|
+
/** Physics sub-step in SECONDS (`dt / subSteps`); equals {@link dt} when
|
|
95
|
+
* `subSteps` is 1, so the loop above is valid for every room. */
|
|
96
|
+
readonly subDt: number;
|
|
97
|
+
/** Physics sub-step in MILLISECONDS (`dtMs / subSteps`). */
|
|
98
|
+
readonly subDtMs: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Fixed-timestep simulation callback. @see Room.setFixedTimestep */
|
|
102
|
+
export type FixedTimestepCallback = (ctx: StepContext) => void;
|
|
103
|
+
|
|
45
104
|
export interface RoomOptions {
|
|
46
105
|
state?: object;
|
|
47
106
|
metadata?: any;
|
|
48
107
|
client?: Client;
|
|
108
|
+
/**
|
|
109
|
+
* Schema class for client→server input packets. When set, the Room
|
|
110
|
+
* allocates one instance per joining client and binds an InputDecoder.
|
|
111
|
+
* Must be a flat Schema (primitive fields only — see InputEncoder docs).
|
|
112
|
+
*
|
|
113
|
+
* Typed loosely (no `Schema` constraint) to avoid type-identity clashes
|
|
114
|
+
* when the user's app loads a different copy of `@colyseus/schema` than
|
|
115
|
+
* `@colyseus/core` does. Runtime validation happens via the encoder.
|
|
116
|
+
*/
|
|
117
|
+
input?: any;
|
|
49
118
|
}
|
|
50
119
|
|
|
51
120
|
// Helper types to extract individual properties from RoomOptions
|
|
52
121
|
export type ExtractRoomState<T> = T extends { state?: infer S extends object } ? S : any;
|
|
53
122
|
export type ExtractRoomMetadata<T> = T extends { metadata?: infer M } ? M : any;
|
|
54
123
|
export type ExtractRoomClient<T> = T extends { client?: infer C extends Client } ? C : Client;
|
|
124
|
+
export type ExtractRoomInput<T> = T extends { input?: infer I } ? I : never;
|
|
55
125
|
|
|
56
126
|
export interface IBroadcastOptions extends ISendOptions {
|
|
57
127
|
except?: Client | Client[];
|
|
@@ -86,7 +156,7 @@ export type Messages<This extends Room> = SharedMessages<This, Client>;
|
|
|
86
156
|
*/
|
|
87
157
|
export function validate<T extends StandardSchemaV1, This = any>(
|
|
88
158
|
format: T,
|
|
89
|
-
handler: (this: This, client: Client, message: StandardSchemaV1.InferOutput<T
|
|
159
|
+
handler: (this: This, client: Client, message: StandardSchemaV1.InferOutput<T>, ctx: MessageContext) => unknown
|
|
90
160
|
): MessageHandlerWithFormat<T, This> {
|
|
91
161
|
return { format, handler };
|
|
92
162
|
}
|
|
@@ -253,28 +323,68 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
253
323
|
private _reconnectionAttempts: { [reconnectionToken: string]: Deferred } = {};
|
|
254
324
|
|
|
255
325
|
public messages?: Messages<any>;
|
|
326
|
+
/** @internal Message-routing layer: handler registry + user-message decode/dispatch. */
|
|
327
|
+
#_messages = new RoomMessages(this);
|
|
256
328
|
|
|
257
|
-
|
|
258
|
-
|
|
329
|
+
/**
|
|
330
|
+
* Room plugins, keyed by an operator-chosen handle. Each plugin
|
|
331
|
+
* contributes any subset of: declarative message handlers (merged
|
|
332
|
+
* into `this.messages`), lifecycle hooks (composed with the room's
|
|
333
|
+
* own), and public methods callable via `this.plugins.<key>.X()`.
|
|
334
|
+
*
|
|
335
|
+
* The framework walks this record once per Room subclass to compute
|
|
336
|
+
* the lifecycle/message layout and install hook wrappers on the
|
|
337
|
+
* class prototype; subsequent constructs reuse the cached layout
|
|
338
|
+
* and just inject `.room` + merge messages.
|
|
339
|
+
*
|
|
340
|
+
* Use `definePlugins({...})` so TypeScript preserves each plugin's
|
|
341
|
+
* literal instance type. Frozen after `__init`.
|
|
342
|
+
*/
|
|
343
|
+
public plugins?: any;
|
|
259
344
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
345
|
+
/**
|
|
346
|
+
* Auto-included plugin instances pulled in via `static
|
|
347
|
+
* dependencies` declarations on user-registered plugins. Kept
|
|
348
|
+
* separate from `this.plugins` so the user's typed view doesn't
|
|
349
|
+
* gain framework-managed keys. Sentinel-keyed (`__dep:<ClassName>`)
|
|
350
|
+
* so the hook wrappers can route lookups to the right map.
|
|
351
|
+
*
|
|
352
|
+
* @internal
|
|
353
|
+
*/
|
|
354
|
+
public _autoPlugins?: Record<string, RoomPlugin<any>>;
|
|
264
355
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
356
|
+
/**
|
|
357
|
+
* Layout cache populated on the FIRST construction of each Room
|
|
358
|
+
* subclass. Holds the precomputed hook participation order + message
|
|
359
|
+
* key → plugin key mapping. Stored on the constructor (a static field)
|
|
360
|
+
* so all instances of the same class share it.
|
|
361
|
+
*
|
|
362
|
+
* `null` is a sentinel meaning "no plugins on this class" — distinct
|
|
363
|
+
* from `undefined` ("not yet computed") so we don't re-walk an empty
|
|
364
|
+
* plugin record on every construct.
|
|
365
|
+
*
|
|
366
|
+
* @internal
|
|
367
|
+
*/
|
|
368
|
+
static __pluginLayout?: PluginLayout | null;
|
|
268
369
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
};
|
|
370
|
+
// Re-expose the registry for @colyseus/playground introspection and
|
|
371
|
+
// @colyseus/testing handler-swapping (both reach in via bracket access).
|
|
372
|
+
private get onMessageEvents() { return this.#_messages.events; }
|
|
373
|
+
private get onMessageValidators() { return this.#_messages.validators; }
|
|
275
374
|
|
|
276
375
|
private _serializer: Serializer<ExtractRoomState<T>> = noneSerializer;
|
|
277
|
-
|
|
376
|
+
|
|
377
|
+
/** Clients that staged `afterNextPatch` frames since the last patch, so the
|
|
378
|
+
* post-patch flush iterates only these — never the full client list. Shared by
|
|
379
|
+
* reference onto each client at join. Reset each `broadcastPatch`. */
|
|
380
|
+
#pendingFrameClients: Array<Client & ClientPrivate> = [];
|
|
381
|
+
|
|
382
|
+
/** `broadcast(..., { afterNextPatch })` in NON-timed rooms, sent as a SHARED
|
|
383
|
+
* frame right after the next patch (one encode, N sends). Non-timed patches are
|
|
384
|
+
* themselves a shared buffer, so a per-client copy would force N× allocation in
|
|
385
|
+
* large rooms — sharing avoids that. TIMED rooms instead stage the broadcast onto
|
|
386
|
+
* each client's `_pendingFrames`. Drained each `broadcastPatch`. */
|
|
387
|
+
#afterPatchBroadcasts: Array<{ bytes: Uint8Array; except: Client[] | undefined }> = [];
|
|
278
388
|
|
|
279
389
|
private _simulationInterval: NodeJS.Timeout;
|
|
280
390
|
|
|
@@ -379,6 +489,15 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
379
489
|
this.state = this.#_state;
|
|
380
490
|
}
|
|
381
491
|
|
|
492
|
+
// Wire room plugins from the instance-level `this.plugins` record.
|
|
493
|
+
// The heavy lifting (conflict detection, hook participation, hook
|
|
494
|
+
// wrapping on the prototype) runs once per class — see
|
|
495
|
+
// `setupRoomPlugins` in `./RoomPlugin.ts`. Per-instance: set
|
|
496
|
+
// `plugin.room = this`, instantiate auto-deps, merge messages.
|
|
497
|
+
if (this.plugins !== undefined) {
|
|
498
|
+
setupRoomPlugins(this);
|
|
499
|
+
}
|
|
500
|
+
|
|
382
501
|
// Bind messages to the room
|
|
383
502
|
if (this.messages !== undefined) {
|
|
384
503
|
|
|
@@ -405,6 +524,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
405
524
|
this.clock.start();
|
|
406
525
|
}
|
|
407
526
|
|
|
527
|
+
|
|
408
528
|
/**
|
|
409
529
|
* The name of the room you provided as first argument for `gameServer.define()`.
|
|
410
530
|
*
|
|
@@ -489,6 +609,86 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
489
609
|
*/
|
|
490
610
|
public onLeave?(client: ExtractRoomClient<T>, code?: number): void | Promise<any>;
|
|
491
611
|
|
|
612
|
+
/**
|
|
613
|
+
* Per-client input accessor. Assign it `= this.defineInput(...)`, then call
|
|
614
|
+
* `this.inputs.get(sessionId)` each tick to read that client's buffered input
|
|
615
|
+
* *stream* — iterate it directly, or call `.next()` / `.drain()` / `.latest`.
|
|
616
|
+
*
|
|
617
|
+
* (In the `Room<{ input: I }>` generic, `input` is the input *type* of one
|
|
618
|
+
* frame; `this.inputs` is the per-client accessor over the stream of them.)
|
|
619
|
+
*
|
|
620
|
+
* @example
|
|
621
|
+
* ```typescript
|
|
622
|
+
* class FpsRoom extends Room<{ input: MoveInput }> {
|
|
623
|
+
* inputs = this.defineInput(MoveInput);
|
|
624
|
+
*
|
|
625
|
+
* onCreate() {
|
|
626
|
+
* this.setFixedTimestep((ctx) => {
|
|
627
|
+
* for (const [sid, p] of this.state.players)
|
|
628
|
+
* for (const input of this.inputs.get(sid)) // consume one-by-one
|
|
629
|
+
* applyInput(p, input, ctx.dt);
|
|
630
|
+
* }, 30);
|
|
631
|
+
* }
|
|
632
|
+
* }
|
|
633
|
+
* ```
|
|
634
|
+
*/
|
|
635
|
+
public inputs?: InputAPI<ExtractRoomInput<T>>;
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* @internal The per-room input subsystem (options, accessor registry, wire
|
|
639
|
+
* stamp mode, encode/decode/handshake). Created lazily on the first
|
|
640
|
+
* {@link defineInput} call — rooms without inputs allocate none of it, and its
|
|
641
|
+
* presence is what enables client-timed state messages. See {@link RoomInput}.
|
|
642
|
+
*/
|
|
643
|
+
private _inputController?: RoomInput;
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Declare the input schema and configuration in a single line. Returns the
|
|
647
|
+
* input API that gets assigned to `this.inputs` — call
|
|
648
|
+
* `this.inputs.get(sessionId)` per tick to consume.
|
|
649
|
+
*
|
|
650
|
+
* ```typescript
|
|
651
|
+
* class FpsRoom extends Room<{ input: MoveInput }> {
|
|
652
|
+
* inputs = this.defineInput(MoveInput, {
|
|
653
|
+
* seqField: "tick", // typed: only numeric fields of MoveInput
|
|
654
|
+
* bufferMaxSize: 64,
|
|
655
|
+
* });
|
|
656
|
+
*
|
|
657
|
+
* // …or without options — no seq dedupe, bufferMaxSize: 32:
|
|
658
|
+
* // inputs = this.defineInput(MoveInput);
|
|
659
|
+
* }
|
|
660
|
+
* ```
|
|
661
|
+
*
|
|
662
|
+
* **Defaults** when `opts` (or individual fields) are omitted:
|
|
663
|
+
* - `seqField`: unset — dedupe and `this.inputs.get(sessionId).at(value)` lookup are
|
|
664
|
+
* OPT-IN (lockstep / rollback). Name a monotonic numeric field here to enable
|
|
665
|
+
* them; redundant frames (`input[seqField]` ≤ the last seen) are then dropped.
|
|
666
|
+
* Leave unset for reliable, in-order channels where every frame is unique.
|
|
667
|
+
* - `bufferMaxSize`: `32` — enables per-client snapshot buffering for
|
|
668
|
+
* `this.inputs.get(sessionId)` iteration / `.consume() / .drain() / .next() /
|
|
669
|
+
* .take() / .peek() / .at()`. Set to `0` to disable buffering (the `.latest`
|
|
670
|
+
* read still works).
|
|
671
|
+
*
|
|
672
|
+
* **Consuming the buffer** — `for (const inp of this.inputs.get(sessionId))` (sugar
|
|
673
|
+
* for `.consume()`) is the per-entity loop: it consumes one at a time, so
|
|
674
|
+
* lag-comp `renderTime` tracks each input. `next()` (take exactly one, ack +1)
|
|
675
|
+
* suits a shared physics world stepped once for everyone; `drain()` returns the
|
|
676
|
+
* whole pending set as an array. See {@link InputAccessor} for the full
|
|
677
|
+
* per-entity-vs-shared-world guidance and why the choice affects the reconcile ack.
|
|
678
|
+
*/
|
|
679
|
+
protected defineInput<
|
|
680
|
+
C extends new () => any,
|
|
681
|
+
O extends DefineInputOptions<InstanceType<C>> = DefineInputOptions<InstanceType<C>>,
|
|
682
|
+
>(
|
|
683
|
+
type: C,
|
|
684
|
+
opts?: O,
|
|
685
|
+
): InputAPI<InstanceType<C>, IdleDeclared<O, InstanceType<C>>> {
|
|
686
|
+
// Lazily spun up on first call; its presence is the "real-time room" gate
|
|
687
|
+
// (enables client-timed state). Rooms that never call this pay nothing.
|
|
688
|
+
this._inputController ??= new RoomInput(this);
|
|
689
|
+
return this._inputController.define<C, O>(type, opts);
|
|
690
|
+
}
|
|
691
|
+
|
|
492
692
|
/**
|
|
493
693
|
* This method is called when the room is disposed.
|
|
494
694
|
*/
|
|
@@ -502,7 +702,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
502
702
|
* - `onMessage`
|
|
503
703
|
* - `onAuth` / `onJoin` / `onLeave` / `onCreate` / `onDispose`
|
|
504
704
|
* - `clock.setTimeout` / `clock.setInterval`
|
|
505
|
-
* - `
|
|
705
|
+
* - `setTimestep` / `setFixedTimestep`
|
|
506
706
|
*
|
|
507
707
|
* (Experimental: this feature is subject to change in the future - we're currently getting feedback to improve it)
|
|
508
708
|
*/
|
|
@@ -604,7 +804,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
604
804
|
|
|
605
805
|
} else if (typeof(reconnectionToken) === "string") {
|
|
606
806
|
// potentially a stale client reference, so a reconnection attempt is possible.
|
|
607
|
-
return this.clients.
|
|
807
|
+
return this.clients.get(sessionId)?.reconnectionToken === reconnectionToken;
|
|
608
808
|
}
|
|
609
809
|
|
|
610
810
|
return false;
|
|
@@ -628,22 +828,29 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
628
828
|
}
|
|
629
829
|
|
|
630
830
|
/**
|
|
631
|
-
*
|
|
632
|
-
*
|
|
831
|
+
* Set the room's game loop. `onTickCallback` runs every `delay` ms and receives
|
|
832
|
+
* the MEASURED wall-clock delta since the previous tick (a VARIABLE timestep).
|
|
833
|
+
* For deterministic, prediction-friendly simulation prefer
|
|
834
|
+
* {@link Room.setFixedTimestep}, which advances a fixed step via an accumulator.
|
|
633
835
|
*
|
|
634
836
|
* @default 16.6ms (60fps)
|
|
635
837
|
*
|
|
636
|
-
* @param onTickCallback -
|
|
637
|
-
*
|
|
638
|
-
* @param delay - Interval
|
|
838
|
+
* @param onTickCallback - Your physics / world update — a good place to mutate
|
|
839
|
+
* room state. Receives the measured delta (`this.clock.deltaTime`).
|
|
840
|
+
* @param delay - Interval between ticks in milliseconds.
|
|
639
841
|
*/
|
|
640
|
-
public
|
|
641
|
-
// clear previous
|
|
842
|
+
public setTimestep(onTickCallback?: SimulationCallback, delay: number = DEFAULT_SIMULATION_INTERVAL): void {
|
|
843
|
+
// clear previous loop in case it was set more than once
|
|
642
844
|
if (this._simulationInterval) { clearInterval(this._simulationInterval); }
|
|
643
845
|
|
|
846
|
+
// Advertise this loop's rate to predicting clients unless set explicitly.
|
|
847
|
+
if (onTickCallback && this._inputController && this._inputController.options.tickRate === undefined) {
|
|
848
|
+
this._inputController.options.tickRate = Math.round(1000 / delay);
|
|
849
|
+
}
|
|
850
|
+
|
|
644
851
|
if (onTickCallback) {
|
|
645
852
|
if (this.onUncaughtException !== undefined) {
|
|
646
|
-
onTickCallback = wrapTryCatch(onTickCallback, this.onUncaughtException.bind(this),
|
|
853
|
+
onTickCallback = wrapTryCatch(onTickCallback, this.onUncaughtException.bind(this), TimestepException, 'setTimestep');
|
|
647
854
|
}
|
|
648
855
|
|
|
649
856
|
this._simulationInterval = setInterval(() => {
|
|
@@ -653,6 +860,181 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
653
860
|
}
|
|
654
861
|
}
|
|
655
862
|
|
|
863
|
+
/**
|
|
864
|
+
* @deprecated Renamed to {@link Room.setTimestep} (which pairs with
|
|
865
|
+
* {@link Room.setFixedTimestep}). Kept for backwards compatibility — forwards
|
|
866
|
+
* to `setTimestep` unchanged.
|
|
867
|
+
*/
|
|
868
|
+
public setSimulationInterval(onTickCallback?: SimulationCallback, delay: number = DEFAULT_SIMULATION_INTERVAL): void {
|
|
869
|
+
this.setTimestep(onTickCallback, delay);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Fixed-timestep game loop with a framework-owned accumulator — the right
|
|
874
|
+
* default for prediction/rollback. Unlike {@link setTimestep} (which
|
|
875
|
+
* hands you the *measured* wall-clock delta), this runs `step` a whole number
|
|
876
|
+
* of times per real frame so each step advances by the SAME fixed
|
|
877
|
+
* `dt = 1/tickRate`; the measured delta only decides HOW MANY steps run. The
|
|
878
|
+
* fixed dt is delivered via {@link StepContext}, so the jittery wall-clock
|
|
879
|
+
* delta can't leak into deterministic simulation.
|
|
880
|
+
*
|
|
881
|
+
* `tickRate` is also the SINGLE SOURCE of the simulation rate: it's advertised
|
|
882
|
+
* to predicting clients via the join handshake (they predict at the matching
|
|
883
|
+
* `dt`), so don't also pass `tickRate` to {@link defineInput}.
|
|
884
|
+
*
|
|
885
|
+
* **`tickRate` couples three rates** — by design, one input == one fixed step
|
|
886
|
+
* == one server tick, so lowering `tickRate` to save bandwidth also lowers
|
|
887
|
+
* the simulation rate. To keep high-fidelity physics on a lower network rate,
|
|
888
|
+
* pass `{ subSteps: N }`: one input still drives one fixed step, but you
|
|
889
|
+
* integrate `N` engine sub-steps of `ctx.subDt` (= `ctx.dt / N`) inside it —
|
|
890
|
+
* physics at `tickRate * N` Hz, inputs at `tickRate`/sec. The same
|
|
891
|
+
* `subSteps`/`subDt` are cascaded to predicting clients (via the join
|
|
892
|
+
* handshake, onto their reconciler's step context), so client replay
|
|
893
|
+
* reproduces the sub-stepped trajectory exactly. Render interpolation already
|
|
894
|
+
* smooths above the step rate — most games don't need this; reach for it when
|
|
895
|
+
* the *simulation* needs the extra Hz (fast projectiles, stacking, tunneling).
|
|
896
|
+
*
|
|
897
|
+
* On a hitch the accumulator runs at most a few catch-up steps then drops the
|
|
898
|
+
* backlog (no spiral of death). Lag-comp is recorded once per real frame.
|
|
899
|
+
*
|
|
900
|
+
* **Consuming input inside the step** — how you consume each client's buffer
|
|
901
|
+
* depends on who integrates (see {@link InputAccessor}):
|
|
902
|
+
* - *Per-entity* (each body integrates itself): `for (const cmd of
|
|
903
|
+
* this.inputs.get(sid)) applyInput(player, cmd, ctx.dt)` — N inputs =
|
|
904
|
+
* N sub-integrations, ack lands on the newest applied, and `renderTime`
|
|
905
|
+
* tracks each input (lag comp stays exact per step).
|
|
906
|
+
* - *Shared world* (one solver step advances every body): consume exactly one
|
|
907
|
+
* input per entity per step with `this.inputs.get(sid).next()` (or `take(n)` +
|
|
908
|
+
* sub-step), then `world.step()` once. Draining all and applying only the
|
|
909
|
+
* latest would jump the reconcile ack past inputs you never simulated.
|
|
910
|
+
*
|
|
911
|
+
* @param step - Called once per fixed step with a {@link StepContext}.
|
|
912
|
+
* @param tickRate - Simulation rate in **Hz**. Defaults to 60.
|
|
913
|
+
* @param opts - `subSteps`: physics sub-steps per fixed step (integer ≥ 1,
|
|
914
|
+
* default 1) — see above.
|
|
915
|
+
*
|
|
916
|
+
* @example
|
|
917
|
+
* ```ts
|
|
918
|
+
* onCreate() {
|
|
919
|
+
* // 30 inputs/sec on the wire, physics integrated at 60 Hz:
|
|
920
|
+
* this.setFixedTimestep((ctx) => {
|
|
921
|
+
* this.applyInputs(ctx); // consume ONE input per client per step
|
|
922
|
+
* for (let i = 0; i < ctx.subSteps; i++) this.world.step(ctx.subDt);
|
|
923
|
+
* }, 30, { subSteps: 2 });
|
|
924
|
+
* }
|
|
925
|
+
* ```
|
|
926
|
+
*/
|
|
927
|
+
public setFixedTimestep(step: FixedTimestepCallback, tickRate: number = Math.round(1000 / DEFAULT_SIMULATION_INTERVAL), opts?: { subSteps?: number }): void {
|
|
928
|
+
if (this._simulationInterval) { clearInterval(this._simulationInterval); }
|
|
929
|
+
|
|
930
|
+
const stepMs = 1000 / tickRate;
|
|
931
|
+
const stepSeconds = 1 / tickRate;
|
|
932
|
+
// Explicit option wins; else an earlier defineInput({ subSteps }) declaration.
|
|
933
|
+
const subSteps = validateSubSteps(opts?.subSteps, 'setFixedTimestep')
|
|
934
|
+
?? this._inputController?.options.subSteps ?? 1;
|
|
935
|
+
|
|
936
|
+
// Single source of the fixed rate (and sub-step count): advertise both to
|
|
937
|
+
// predicting clients so N and dt can't drift between the two sides.
|
|
938
|
+
if (this._inputController) {
|
|
939
|
+
this._inputController.options.tickRate = tickRate;
|
|
940
|
+
this._inputController.options.subSteps = subSteps;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
let cb = step;
|
|
944
|
+
if (this.onUncaughtException !== undefined) {
|
|
945
|
+
cb = wrapTryCatch(step, this.onUncaughtException.bind(this), TimestepException, 'setFixedTimestep');
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
let acc = 0;
|
|
949
|
+
let tick = 0;
|
|
950
|
+
// Reused per-step context — no per-step allocation in the hot loop.
|
|
951
|
+
// subDt = stepSeconds/subSteps: same expression as the client handle's
|
|
952
|
+
// subStepSeconds, so the per-sub-step dt is bit-identical on both sides.
|
|
953
|
+
const ctx = {
|
|
954
|
+
dt: stepSeconds, dtMs: stepMs, tick: 0,
|
|
955
|
+
subSteps, subDt: stepSeconds / subSteps, subDtMs: stepMs / subSteps,
|
|
956
|
+
};
|
|
957
|
+
const MAX_CATCHUP_STEPS = 5;
|
|
958
|
+
|
|
959
|
+
this._simulationInterval = setInterval(() => {
|
|
960
|
+
this.clock.tick();
|
|
961
|
+
acc += this.clock.deltaTime;
|
|
962
|
+
|
|
963
|
+
// Run a whole number of FIXED steps to consume the measured time.
|
|
964
|
+
let ran = 0;
|
|
965
|
+
while (acc >= stepMs && ran < MAX_CATCHUP_STEPS) {
|
|
966
|
+
acc -= stepMs;
|
|
967
|
+
ctx.tick = tick++;
|
|
968
|
+
cb(ctx);
|
|
969
|
+
ran++;
|
|
970
|
+
}
|
|
971
|
+
if (ran === MAX_CATCHUP_STEPS) { acc = 0; } // hitch: drop backlog, don't spiral
|
|
972
|
+
}, stepMs);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** Server-side lag compensation, lazily created. @see allowRewindState */
|
|
976
|
+
#rewind?: Rewind;
|
|
977
|
+
|
|
978
|
+
/** @internal The rewind attachments' timeline mode (snapshot/reckon), or
|
|
979
|
+
* `undefined` when no rewind is configured. {@link RoomInput} reads it to
|
|
980
|
+
* derive the wire input stamp mode (snapshot → renderTime, reckon → reckonTime). */
|
|
981
|
+
_timelineMode(): { snapshot?: boolean; reckon?: boolean } | undefined {
|
|
982
|
+
return this.#rewind?.timelineMode();
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Enable server-side lag compensation: returns a {@link Rewind} that records the
|
|
987
|
+
* position history of the entities you attach and automatically snapshots them
|
|
988
|
+
* on each broadcast (the patchRate cadence). Attach the collections to rewind,
|
|
989
|
+
* then read past positions (at a client's renderTime) in your hit tests.
|
|
990
|
+
*
|
|
991
|
+
* @example
|
|
992
|
+
* ```ts
|
|
993
|
+
* const rewind = this.allowRewindState({ maxRewindMs: 500 });
|
|
994
|
+
* rewind.attachAll(this.state.enemies, { fields: ["x", "y"] });
|
|
995
|
+
* // in a hit test — rewind to where the SHOOTER saw the world:
|
|
996
|
+
* const seen = rewind.lastSeenBy(shooterSessionId); // needs this.defineInput(...)
|
|
997
|
+
* const seenX = seen.value(enemy, "x"), seenY = seen.value(enemy, "y");
|
|
998
|
+
* ```
|
|
999
|
+
*
|
|
1000
|
+
* Per-client stamps auto-enable from the `attachAll` `mode` of the groups you
|
|
1001
|
+
* rewind — no `renderTime` flag. The default auto-record fires on each
|
|
1002
|
+
* broadcast, snapshotting exactly what the client receives — so the rewind
|
|
1003
|
+
* reproduces the client's interpolation and hits stay exact even when the
|
|
1004
|
+
* broadcast rate differs from the sim rate (`patchRate ≠ timestep`). Call
|
|
1005
|
+
* `rewind.record()` yourself during a tick to take over that cadence (you then
|
|
1006
|
+
* own correctness against your own broadcast rate).
|
|
1007
|
+
*/
|
|
1008
|
+
public allowRewindState(opts?: RewindOptions): Rewind {
|
|
1009
|
+
this.#rewind = Rewind.get(this, opts);
|
|
1010
|
+
// Resolve `rewind.lastSeenBy(sid)` through the framework-owned input API.
|
|
1011
|
+
// Lazy: read at call time, so field-init order between `inputs` and `rewind`
|
|
1012
|
+
// doesn't matter. A missing input API fails loudly; a client that hasn't
|
|
1013
|
+
// stamped yet legitimately reads 0 → live fallback (NOT a config error).
|
|
1014
|
+
this.#rewind.bindRenderTime((sessionId) => {
|
|
1015
|
+
const api = this._inputController?.api;
|
|
1016
|
+
if (api === undefined) {
|
|
1017
|
+
throw new Error(
|
|
1018
|
+
"rewind.lastSeenBy() found no input API. Declare " +
|
|
1019
|
+
"`inputs = this.defineInput(YourInput)`, or use " +
|
|
1020
|
+
"rewind.at(time) with a render time you track yourself.",
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
return api.get(sessionId).renderTime;
|
|
1024
|
+
});
|
|
1025
|
+
// Direct reckon-display stamp: mode:"reckon" groups read at exactly the
|
|
1026
|
+
// instant the client displayed them — immune to its RTT-estimation error.
|
|
1027
|
+
// RAW (not the accessor's resolved getter): _aim's midpoint fallback must
|
|
1028
|
+
// see 0 for unstamped clients.
|
|
1029
|
+
this.#rewind.bindReckonTime((sessionId) =>
|
|
1030
|
+
this._inputController?.rawReckonTime(sessionId) ?? 0,
|
|
1031
|
+
);
|
|
1032
|
+
// Anchor the reckon midpoint FALLBACK at the true processing instant (the
|
|
1033
|
+
// record timestamp is one tick stale by hit-test time — see bindNow).
|
|
1034
|
+
this.#rewind.bindNow(() => this.clock.elapsedTime);
|
|
1035
|
+
return this.#rewind;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
656
1038
|
/**
|
|
657
1039
|
* @deprecated Use `.patchRate=` instead.
|
|
658
1040
|
*/
|
|
@@ -671,21 +1053,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
671
1053
|
this._serializer = serializer;
|
|
672
1054
|
}
|
|
673
1055
|
|
|
674
|
-
public async setMetadata(meta:
|
|
675
|
-
|
|
676
|
-
this._listing.metadata = meta as ExtractRoomMetadata<T>;
|
|
677
|
-
|
|
678
|
-
} else {
|
|
679
|
-
for (const field in meta) {
|
|
680
|
-
if (!meta.hasOwnProperty(field)) { continue; }
|
|
681
|
-
this._listing.metadata[field] = meta[field];
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
// `MongooseDriver` workaround: persit metadata mutations
|
|
685
|
-
if ('markModified' in this._listing) {
|
|
686
|
-
(this._listing as any).markModified('metadata');
|
|
687
|
-
}
|
|
688
|
-
}
|
|
1056
|
+
public async setMetadata(meta: ExtractRoomMetadata<T>, persist: boolean = true) {
|
|
1057
|
+
this._listing.metadata = meta;
|
|
689
1058
|
|
|
690
1059
|
if (persist && this._internalState === RoomInternalState.CREATED) {
|
|
691
1060
|
await matchMaker.driver.persist(this._listing);
|
|
@@ -735,7 +1104,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
735
1104
|
*
|
|
736
1105
|
* @example
|
|
737
1106
|
* ```typescript
|
|
738
|
-
* //
|
|
1107
|
+
* // Merging with existing metadata: spread `this.metadata` yourself.
|
|
1108
|
+
* // `metadata` is always REPLACED (not merged) by setMatchmaking()/setMetadata().
|
|
739
1109
|
* await this.setMatchmaking({
|
|
740
1110
|
* metadata: { ...this.metadata, round: this.metadata.round + 1 }
|
|
741
1111
|
* });
|
|
@@ -894,12 +1264,6 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
894
1264
|
...args: MessageArgs<ExtractRoomClient<T>['~messages'][K], IBroadcastOptions>
|
|
895
1265
|
) {
|
|
896
1266
|
const [message, options] = args;
|
|
897
|
-
if (options && options.afterNextPatch) {
|
|
898
|
-
delete options.afterNextPatch;
|
|
899
|
-
this._afterNextPatchQueue.push(['broadcast', [type, ...args]]);
|
|
900
|
-
return;
|
|
901
|
-
}
|
|
902
|
-
|
|
903
1267
|
this.broadcastMessageType(type, message, options);
|
|
904
1268
|
}
|
|
905
1269
|
|
|
@@ -907,12 +1271,6 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
907
1271
|
* Broadcast bytes (UInt8Arrays) to a particular room
|
|
908
1272
|
*/
|
|
909
1273
|
public broadcastBytes(type: string | number, message: Uint8Array, options: IBroadcastOptions) {
|
|
910
|
-
if (options && options.afterNextPatch) {
|
|
911
|
-
delete options.afterNextPatch;
|
|
912
|
-
this._afterNextPatchQueue.push(['broadcastBytes', arguments]);
|
|
913
|
-
return;
|
|
914
|
-
}
|
|
915
|
-
|
|
916
1274
|
this.broadcastMessageType(type as string, message, options);
|
|
917
1275
|
}
|
|
918
1276
|
|
|
@@ -932,10 +1290,33 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
932
1290
|
return false;
|
|
933
1291
|
}
|
|
934
1292
|
|
|
935
|
-
|
|
1293
|
+
// When `defineInput()` was called, hand the serializer a fresh `sNow`
|
|
1294
|
+
// each tick. The per-client `lastTReceived` is read off the client at
|
|
1295
|
+
// encode time inside `applyPatches`.
|
|
1296
|
+
const sNow = this.clock.elapsedTime;
|
|
1297
|
+
const hasChanges = this._serializer.applyPatches(
|
|
1298
|
+
this.clients,
|
|
1299
|
+
this.state,
|
|
1300
|
+
this._inputController !== undefined ? { sNow } : undefined,
|
|
1301
|
+
);
|
|
1302
|
+
|
|
1303
|
+
// Deliver any per-client `afterNextPatch` frames as standalone frames right
|
|
1304
|
+
// after the patch (never coalesced into it). Iterates only the clients that
|
|
1305
|
+
// staged frames this cycle (`#pendingFrameClients`), never the full list.
|
|
1306
|
+
this._flushPendingClientFrames();
|
|
936
1307
|
|
|
937
|
-
//
|
|
938
|
-
this.
|
|
1308
|
+
// After-patch broadcasts ride here as shared frames (one buffer → N sends).
|
|
1309
|
+
this._flushAfterPatchBroadcasts();
|
|
1310
|
+
|
|
1311
|
+
// Lag-comp: record the snapshot the client just received, on the broadcast
|
|
1312
|
+
// cadence and stamped with the SAME `sNow` the frame carries — so valueAt()
|
|
1313
|
+
// reproduces the client's interpolation over the IDENTICAL pair (exact hits
|
|
1314
|
+
// at any patchRate). Idempotent per `sNow`: a manual record() this tick wins,
|
|
1315
|
+
// and a patchRate faster than the sim simply dedups back to per-tick.
|
|
1316
|
+
const rw = this.#rewind;
|
|
1317
|
+
if (rw !== undefined && rw.lastRecordedAt !== sNow) {
|
|
1318
|
+
rw.record(sNow, this.#_patchRate || undefined);
|
|
1319
|
+
}
|
|
939
1320
|
|
|
940
1321
|
return hasChanges;
|
|
941
1322
|
}
|
|
@@ -970,43 +1351,19 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
970
1351
|
);
|
|
971
1352
|
public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(
|
|
972
1353
|
messageType: string | number,
|
|
973
|
-
callback: (client: C, message: T) =>
|
|
1354
|
+
callback: (client: C, message: T, ctx: MessageContext) => unknown,
|
|
974
1355
|
);
|
|
975
1356
|
public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(
|
|
976
1357
|
messageType: string | number,
|
|
977
1358
|
validationSchema: StandardSchemaV1<T>,
|
|
978
|
-
callback: (client: C, message: T) =>
|
|
1359
|
+
callback: (client: C, message: T, ctx: MessageContext) => unknown,
|
|
979
1360
|
);
|
|
980
1361
|
public onMessage<T = any>(
|
|
981
1362
|
_messageType: '*' | string | number,
|
|
982
1363
|
_validationSchema: StandardSchemaV1<T> | ((...args: any[]) => void),
|
|
983
1364
|
_callback?: (...args: any[]) => void,
|
|
984
1365
|
) {
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
const validationSchema = (typeof _callback === 'function')
|
|
988
|
-
? _validationSchema as StandardSchemaV1<T>
|
|
989
|
-
: undefined;
|
|
990
|
-
|
|
991
|
-
const callback = (validationSchema === undefined)
|
|
992
|
-
? _validationSchema as (...args: any[]) => void
|
|
993
|
-
: _callback;
|
|
994
|
-
|
|
995
|
-
const removeListener = this.onMessageEvents.on(messageType, (this.onUncaughtException !== undefined)
|
|
996
|
-
? wrapTryCatch(callback, this.onUncaughtException.bind(this), OnMessageException, 'onMessage', false, _messageType)
|
|
997
|
-
: callback);
|
|
998
|
-
|
|
999
|
-
if (validationSchema !== undefined) {
|
|
1000
|
-
this.onMessageValidators[messageType] = validationSchema;
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
// returns a method to unbind the callback
|
|
1004
|
-
return () => {
|
|
1005
|
-
removeListener();
|
|
1006
|
-
if (this.onMessageEvents.events[messageType].length === 0) {
|
|
1007
|
-
delete this.onMessageValidators[messageType];
|
|
1008
|
-
}
|
|
1009
|
-
};
|
|
1366
|
+
return this.#_messages.on(_messageType, _validationSchema as any, _callback);
|
|
1010
1367
|
}
|
|
1011
1368
|
|
|
1012
1369
|
public onMessageBytes<T = any, C extends Client = ExtractRoomClient<T>>(
|
|
@@ -1025,20 +1382,117 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1025
1382
|
_validationSchema: StandardSchemaV1<T> | ((...args: any[]) => void),
|
|
1026
1383
|
_callback?: (...args: any[]) => void,
|
|
1027
1384
|
) {
|
|
1028
|
-
|
|
1385
|
+
return this.#_messages.on(`_$b${_messageType}`, _validationSchema as any, _callback);
|
|
1386
|
+
}
|
|
1029
1387
|
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1388
|
+
// ---------------------------------------------------------------------------
|
|
1389
|
+
// Operator API — used by @colyseus/admin (and monitor in due course)
|
|
1390
|
+
// through `remoteRoomCall(roomId, methodName)`. Marked `@internal` because
|
|
1391
|
+
// they're framework-tooling primitives, not part of the game-code surface.
|
|
1392
|
+
// ---------------------------------------------------------------------------
|
|
1033
1393
|
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1394
|
+
/**
|
|
1395
|
+
* Snapshot the room's live state for an inspector / admin UI. Includes:
|
|
1396
|
+
*
|
|
1397
|
+
* roomId, name, maxClients, locked, elapsedTime (ms),
|
|
1398
|
+
* metadata, clients (sessionId + per-client elapsed + userId when set),
|
|
1399
|
+
* state (the schema/json the SDK would see),
|
|
1400
|
+
* stateSize (bytes of the encoded full state, or 0 when no serializer).
|
|
1401
|
+
*
|
|
1402
|
+
* The payload is intentionally plain JSON — `remoteRoomCall` serializes
|
|
1403
|
+
* the return value across process boundaries.
|
|
1404
|
+
*
|
|
1405
|
+
* @internal Operator-only. Game code should not call this.
|
|
1406
|
+
*/
|
|
1407
|
+
public getInspectorView(): {
|
|
1408
|
+
roomId: string;
|
|
1409
|
+
name: string;
|
|
1410
|
+
clients: number;
|
|
1411
|
+
maxClients: number;
|
|
1412
|
+
locked: boolean;
|
|
1413
|
+
elapsedTime: number;
|
|
1414
|
+
metadata: any;
|
|
1415
|
+
clientList: Array<{
|
|
1416
|
+
sessionId: string;
|
|
1417
|
+
userId: string | null;
|
|
1418
|
+
userEmail: string | null;
|
|
1419
|
+
elapsedTime: number;
|
|
1420
|
+
}>;
|
|
1421
|
+
state: any;
|
|
1422
|
+
stateSize: number;
|
|
1423
|
+
} {
|
|
1424
|
+
const elapsed = this.clock.elapsedTime;
|
|
1425
|
+
return {
|
|
1426
|
+
roomId: this.roomId,
|
|
1427
|
+
name: this.roomName,
|
|
1428
|
+
clients: this.clients.length,
|
|
1429
|
+
maxClients: this.maxClients,
|
|
1430
|
+
locked: this.#_locked,
|
|
1431
|
+
elapsedTime: elapsed,
|
|
1432
|
+
metadata: this.metadata ?? null,
|
|
1433
|
+
// Cast through `unknown`: `ExtractRoomClient<T>` vs `Client & ClientPrivate`
|
|
1434
|
+
// don't structurally overlap (the user's `client` generic may carry a
|
|
1435
|
+
// narrower userData/auth shape), but the runtime objects we walk here
|
|
1436
|
+
// always have the private join-time field. The narrow per-property
|
|
1437
|
+
// `(c as any)` reads below keep the cast scoped.
|
|
1438
|
+
//
|
|
1439
|
+
// userId / userEmail read straight off `client.auth` — the JWT
|
|
1440
|
+
// payload @colyseus/auth's default onAuth decodes carries both
|
|
1441
|
+
// when the user has them on file. Saves the admin a per-client
|
|
1442
|
+
// database round-trip; falls back to null when the client signed
|
|
1443
|
+
// in anonymously (or with a custom onAuth that returns a shape
|
|
1444
|
+
// without those fields).
|
|
1445
|
+
clientList: (this.clients as unknown as ReadonlyArray<Client & ClientPrivate>).map((c) => {
|
|
1446
|
+
const auth = (c as any).auth;
|
|
1447
|
+
return {
|
|
1448
|
+
sessionId: c.sessionId,
|
|
1449
|
+
userId: ((c as any).userId ?? auth?.id) ?? null,
|
|
1450
|
+
userEmail: auth?.email ?? null,
|
|
1451
|
+
elapsedTime: elapsed - ((c as any)._joinedAt ?? elapsed),
|
|
1452
|
+
};
|
|
1453
|
+
}),
|
|
1454
|
+
state: this.state ?? null,
|
|
1455
|
+
stateSize: this.#_inspectorStateSize(),
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1037
1458
|
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1459
|
+
/**
|
|
1460
|
+
* Force-disconnect a single client by sessionId. No-op when the client
|
|
1461
|
+
* isn't connected (idempotent — the caller doesn't need to race-check).
|
|
1462
|
+
*
|
|
1463
|
+
* @internal Operator-only. Game code disconnects clients by calling
|
|
1464
|
+
* `.leave()` on the Client object directly.
|
|
1465
|
+
*/
|
|
1466
|
+
public kickClient(sessionId: string, closeCode: number = CloseCode.CONSENTED, reason?: string): void {
|
|
1467
|
+
// Same `unknown` indirection as in `getInspectorView` above —
|
|
1468
|
+
// ExtractRoomClient<T> doesn't structurally include ClientPrivate.
|
|
1469
|
+
for (const client of this.clients as unknown as ReadonlyArray<Client & ClientPrivate>) {
|
|
1470
|
+
if (client.sessionId === sessionId) {
|
|
1471
|
+
this.#_forciblyCloseClient(client as ExtractRoomClient<T> & ClientPrivate, closeCode, reason);
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* Best-effort byte size of the current full state. Falls back to `0`
|
|
1479
|
+
* when the room has no serializer or the serializer can't produce a
|
|
1480
|
+
* payload (raw rooms, very-early-onCreate, etc.). The serializer
|
|
1481
|
+
* detection mirrors `@colyseus/monitor`'s — we read whichever buffer
|
|
1482
|
+
* is available across schema v2 and v3.
|
|
1483
|
+
*/
|
|
1484
|
+
#_inspectorStateSize(): number {
|
|
1485
|
+
const ser = this._serializer as any;
|
|
1486
|
+
if (!ser) { return 0; }
|
|
1487
|
+
const hasState = ser.encoder || ser.state;
|
|
1488
|
+
if (!hasState) { return 0; }
|
|
1489
|
+
try {
|
|
1490
|
+
const full = ser.getFullState?.();
|
|
1491
|
+
if (!full) { return 0; }
|
|
1492
|
+
// Buffer / Uint8Array have `byteLength`; raw arrays have `length`.
|
|
1493
|
+
return (full as any).byteLength ?? (full as any).length ?? 0;
|
|
1494
|
+
} catch {
|
|
1495
|
+
return 0;
|
|
1042
1496
|
}
|
|
1043
1497
|
}
|
|
1044
1498
|
|
|
@@ -1103,6 +1557,9 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1103
1557
|
// (each new reconnection receives a new reconnection token)
|
|
1104
1558
|
client.reconnectionToken = generateId();
|
|
1105
1559
|
|
|
1560
|
+
// Allocate per-client input state early so onJoin can read inputs.get(sid).latest.
|
|
1561
|
+
this._inputController?.allocate(client);
|
|
1562
|
+
|
|
1106
1563
|
if (this._reservedSeatTimeouts[sessionId]) {
|
|
1107
1564
|
clearTimeout(this._reservedSeatTimeouts[sessionId]);
|
|
1108
1565
|
delete this._reservedSeatTimeouts[sessionId];
|
|
@@ -1121,7 +1578,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1121
1578
|
if (
|
|
1122
1579
|
this._reservedSeats[sessionId] === undefined &&
|
|
1123
1580
|
connectionOptions?.reconnectionToken &&
|
|
1124
|
-
this.clients.
|
|
1581
|
+
this.clients.get(sessionId)?.reconnectionToken === connectionOptions.reconnectionToken
|
|
1125
1582
|
) {
|
|
1126
1583
|
debugMatchMaking('attempting to reconnect client with a stale previous connection - sessionId: \'%s\', roomId: \'%s\'', client.sessionId, this.roomId);
|
|
1127
1584
|
this._reconnectionAttempts[connectionOptions.reconnectionToken] = new Deferred();
|
|
@@ -1160,8 +1617,10 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1160
1617
|
this._reservedSeats[sessionId][2] = true; // flag seat reservation as "consumed"
|
|
1161
1618
|
debugMatchMaking('consuming seat reservation, sessionId: \'%s\' (roomId: %s)', client.sessionId, this.roomId);
|
|
1162
1619
|
|
|
1163
|
-
//
|
|
1164
|
-
client.
|
|
1620
|
+
// Share the after-patch flush list so a client can announce staged frames
|
|
1621
|
+
// without the flush scanning every client. `_pendingFrames` are delivered as
|
|
1622
|
+
// standalone frames right after the patch by `_flushPendingClientFrames`.
|
|
1623
|
+
(client as Client & ClientPrivate)._pendingFrameClients = this.#pendingFrameClients;
|
|
1165
1624
|
|
|
1166
1625
|
// add temporary callback to keep track of disconnections during `onJoin`.
|
|
1167
1626
|
client.ref['onleave'] = (_) => client.state = ClientState.LEAVING;
|
|
@@ -1171,6 +1630,9 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1171
1630
|
const reconnectionToken = connectionOptions?.reconnectionToken;
|
|
1172
1631
|
if (reconnectionToken && this._reconnections[reconnectionToken]?.[0] === sessionId) {
|
|
1173
1632
|
this.clients.push(client);
|
|
1633
|
+
// After push (no leak on a failed join), before onReconnect; overwrites
|
|
1634
|
+
// the dropped session's stale entry.
|
|
1635
|
+
this._inputController?.register(sessionId, client);
|
|
1174
1636
|
|
|
1175
1637
|
//
|
|
1176
1638
|
// await for reconnection:
|
|
@@ -1236,6 +1698,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1236
1698
|
}
|
|
1237
1699
|
|
|
1238
1700
|
this.clients.push(client);
|
|
1701
|
+
// After push (no leak on a failed join), before onJoin (resolves inputs.get there).
|
|
1702
|
+
this._inputController?.register(sessionId, client);
|
|
1239
1703
|
|
|
1240
1704
|
//
|
|
1241
1705
|
// Flag sessionId as non-enumarable so hasReachedMaxClients() doesn't count it
|
|
@@ -1289,6 +1753,11 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1289
1753
|
// allow client to send messages after onJoin has succeeded.
|
|
1290
1754
|
client.ref.on('message', this._onMessage.bind(this, client));
|
|
1291
1755
|
|
|
1756
|
+
// NOT gated by skipHandshake: that flag means "client already has the STATE
|
|
1757
|
+
// schema", but these carry input config the client can't derive locally
|
|
1758
|
+
// (re-parsing on reconnect is idempotent).
|
|
1759
|
+
const extraSections = this._inputController?.handshakeSections();
|
|
1760
|
+
|
|
1292
1761
|
// confirm room id that matches the room name requested to join
|
|
1293
1762
|
client.raw(getMessageBytes[Protocol.JOIN_ROOM](
|
|
1294
1763
|
client.reconnectionToken,
|
|
@@ -1300,6 +1769,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1300
1769
|
(connectionOptions?.skipHandshake)
|
|
1301
1770
|
? undefined
|
|
1302
1771
|
: this._serializer.handshake && this._serializer.handshake(),
|
|
1772
|
+
extraSections,
|
|
1303
1773
|
));
|
|
1304
1774
|
}
|
|
1305
1775
|
}
|
|
@@ -1404,7 +1874,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1404
1874
|
// switching Wi-Fi), as the original connection may still be open while a
|
|
1405
1875
|
// new reconnection attempt is being made.
|
|
1406
1876
|
//
|
|
1407
|
-
if (this._reconnectionAttempts[reconnectionToken]) {
|
|
1877
|
+
if (this._reconnectionAttempts[reconnectionToken] !== undefined) {
|
|
1408
1878
|
debugMatchMaking('resolving reconnection attempt for client - sessionId: \'%s\', roomId: \'%s\'', sessionId, this.roomId);
|
|
1409
1879
|
this._reconnectionAttempts[reconnectionToken].resolve(true);
|
|
1410
1880
|
}
|
|
@@ -1438,39 +1908,67 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1438
1908
|
: [options.except]
|
|
1439
1909
|
: undefined;
|
|
1440
1910
|
|
|
1911
|
+
// `afterNextPatch` in a NON-timed room: the patch is a SHARED buffer, so
|
|
1912
|
+
// coalescing would force N per-client copies. Keep this one shared buffer and
|
|
1913
|
+
// ride it right after the patch (see #afterPatchBroadcasts). `except` is
|
|
1914
|
+
// captured now; recipients resolve at drain time.
|
|
1915
|
+
if (options.afterNextPatch && this._inputController === undefined) {
|
|
1916
|
+
this.#afterPatchBroadcasts.push({ bytes: encodedMessage, except });
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// Otherwise one loop: a TIMED-room `afterNextPatch` broadcast stages onto each
|
|
1921
|
+
// recipient's `_pendingFrames` (flushed right after the patch); an immediate
|
|
1922
|
+
// broadcast sends now.
|
|
1923
|
+
const sendOpts = options.afterNextPatch ? AFTER_PATCH_OPTS : undefined;
|
|
1441
1924
|
let numClients = this.clients.length;
|
|
1442
1925
|
while (numClients--) {
|
|
1443
1926
|
const client = this.clients[numClients];
|
|
1444
|
-
|
|
1445
1927
|
if (!except || !except.includes(client)) {
|
|
1446
|
-
client.enqueueRaw(encodedMessage);
|
|
1928
|
+
client.enqueueRaw(encodedMessage, sendOpts);
|
|
1447
1929
|
}
|
|
1448
1930
|
}
|
|
1449
1931
|
}
|
|
1450
1932
|
|
|
1451
|
-
|
|
1452
|
-
|
|
1933
|
+
/** Drain `#afterPatchBroadcasts` right after the patch — each as ONE shared
|
|
1934
|
+
* buffer fanned out to its (non-excepted) recipients. `enqueueRaw` handles
|
|
1935
|
+
* JOINED (send now) vs still-joining (buffer until JOIN) per client. */
|
|
1936
|
+
private _flushAfterPatchBroadcasts() {
|
|
1937
|
+
const queued = this.#afterPatchBroadcasts;
|
|
1938
|
+
if (queued.length === 0) { return; }
|
|
1939
|
+
for (let i = 0; i < queued.length; i++) {
|
|
1940
|
+
const { bytes, except } = queued[i];
|
|
1941
|
+
let numClients = this.clients.length;
|
|
1942
|
+
while (numClients--) {
|
|
1943
|
+
const client = this.clients[numClients];
|
|
1944
|
+
if (!except || !except.includes(client)) { client.enqueueRaw(bytes); }
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
queued.length = 0;
|
|
1453
1948
|
}
|
|
1454
1949
|
|
|
1455
|
-
private
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
if (target === "broadcast") {
|
|
1463
|
-
this.broadcast.apply(this, args as any);
|
|
1950
|
+
private sendFullState(client: Client): void {
|
|
1951
|
+
client.raw(this._serializer.getFullState(
|
|
1952
|
+
client,
|
|
1953
|
+
this._inputController !== undefined ? { sNow: this.clock.elapsedTime } : undefined,
|
|
1954
|
+
));
|
|
1955
|
+
}
|
|
1464
1956
|
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1957
|
+
/** Send each client's staged `afterNextPatch` frames as standalone frames right
|
|
1958
|
+
* after the patch, then reset the tracker. Iterates only the clients that staged
|
|
1959
|
+
* frames this cycle (`#pendingFrameClients`), never the full client list; empty
|
|
1960
|
+
* (→ O(1)) on a tick where nothing was staged. */
|
|
1961
|
+
private _flushPendingClientFrames() {
|
|
1962
|
+
const dirty = this.#pendingFrameClients;
|
|
1963
|
+
if (dirty.length === 0) { return; }
|
|
1964
|
+
for (let i = 0; i < dirty.length; i++) {
|
|
1965
|
+
const frames = dirty[i]._pendingFrames;
|
|
1966
|
+
if (frames !== undefined && frames.length > 0) {
|
|
1967
|
+
for (let j = 0; j < frames.length; j++) { dirty[i].raw(frames[j]); }
|
|
1968
|
+
frames.length = 0;
|
|
1468
1969
|
}
|
|
1469
|
-
|
|
1470
|
-
// new messages may have been added in the meantime,
|
|
1471
|
-
// let's splice the ones that have been processed
|
|
1472
|
-
this._afterNextPatchQueue.splice(0, length);
|
|
1473
1970
|
}
|
|
1971
|
+
dirty.length = 0;
|
|
1474
1972
|
}
|
|
1475
1973
|
|
|
1476
1974
|
private async _reserveSeat(
|
|
@@ -1517,6 +2015,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1517
2015
|
delete this._reconnections[devModeReconnectionToken];
|
|
1518
2016
|
delete this._reservedSeats[sessionId];
|
|
1519
2017
|
delete this._reservedSeatTimeouts[sessionId];
|
|
2018
|
+
// devMode reconnection bypasses #_onAfterLeave — drop the input accessor here.
|
|
2019
|
+
this._inputController?.release(sessionId);
|
|
1520
2020
|
|
|
1521
2021
|
if (!allowReconnection) {
|
|
1522
2022
|
await this.#_decrementClientCount();
|
|
@@ -1584,7 +2084,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1584
2084
|
}
|
|
1585
2085
|
|
|
1586
2086
|
if (this._autoDisposeTimeout) {
|
|
1587
|
-
|
|
2087
|
+
clearTimeout(this._autoDisposeTimeout);
|
|
1588
2088
|
this._autoDisposeTimeout = undefined;
|
|
1589
2089
|
}
|
|
1590
2090
|
|
|
@@ -1592,6 +2092,9 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1592
2092
|
this.clock.clear();
|
|
1593
2093
|
this.clock.stop();
|
|
1594
2094
|
|
|
2095
|
+
// drop any input accessors still held for in-flight reconnections
|
|
2096
|
+
this._inputController?.dispose();
|
|
2097
|
+
|
|
1595
2098
|
return await (userReturnData || Promise.resolve());
|
|
1596
2099
|
}
|
|
1597
2100
|
|
|
@@ -1615,71 +2118,25 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1615
2118
|
}
|
|
1616
2119
|
|
|
1617
2120
|
const it: Iterator = { offset: 1 };
|
|
1618
|
-
|
|
2121
|
+
// Strip modifier bits (e.g. ProtocolModifier.TIMED on a render-time input)
|
|
2122
|
+
// before dispatch; existing client→server codes set no modifiers.
|
|
2123
|
+
const code = buffer[0] & PROTOCOL_CODE_MASK;
|
|
2124
|
+
const modifiers = buffer[0] & PROTOCOL_MODIFIER_MASK;
|
|
1619
2125
|
|
|
1620
2126
|
if (code === Protocol.ROOM_DATA) {
|
|
1621
|
-
|
|
1622
|
-
? decode.string(buffer, it)
|
|
1623
|
-
: decode.number(buffer, it);
|
|
2127
|
+
this.#_messages.onData(client, buffer, it);
|
|
1624
2128
|
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
message = (buffer.byteLength > it.offset)
|
|
1628
|
-
? unpack(buffer.subarray(it.offset, buffer.byteLength))
|
|
1629
|
-
: undefined;
|
|
1630
|
-
debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId);
|
|
1631
|
-
|
|
1632
|
-
// custom message validation
|
|
1633
|
-
if (this.onMessageValidators[messageType] !== undefined) {
|
|
1634
|
-
message = standardValidate(this.onMessageValidators[messageType], message);
|
|
1635
|
-
}
|
|
1636
|
-
|
|
1637
|
-
} catch (e: any) {
|
|
1638
|
-
debugAndPrintError(e);
|
|
1639
|
-
client.leave(CloseCode.WITH_ERROR);
|
|
1640
|
-
return;
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
if (this.onMessageEvents.events[messageType]) {
|
|
1644
|
-
this.onMessageEvents.emit(messageType as string, client, message);
|
|
1645
|
-
|
|
1646
|
-
} else if (this.onMessageEvents.events['*']) {
|
|
1647
|
-
this.onMessageEvents.emit('*', client, messageType, message);
|
|
1648
|
-
|
|
1649
|
-
} else {
|
|
1650
|
-
this.onMessageFallbacks['__no_message_handler'](client, messageType, message);
|
|
1651
|
-
}
|
|
2129
|
+
} else if (code === Protocol.ROOM_REQUEST) {
|
|
2130
|
+
this.#_messages.onRequest(client, buffer, it);
|
|
1652
2131
|
|
|
1653
2132
|
} else if (code === Protocol.ROOM_DATA_BYTES) {
|
|
1654
|
-
|
|
1655
|
-
? decode.string(buffer, it)
|
|
1656
|
-
: decode.number(buffer, it);
|
|
1657
|
-
|
|
1658
|
-
let message: any = buffer.subarray(it.offset, buffer.byteLength);
|
|
1659
|
-
debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId);
|
|
2133
|
+
this.#_messages.onDataBytes(client, buffer, it);
|
|
1660
2134
|
|
|
1661
|
-
|
|
2135
|
+
} else if (code === Protocol.ROOM_INPUT_RELIABLE) {
|
|
2136
|
+
this._inputController?.decodeReliable(client, buffer, it, modifiers);
|
|
1662
2137
|
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
if (this.onMessageValidators[bytesMessageType] !== undefined) {
|
|
1666
|
-
message = standardValidate(this.onMessageValidators[bytesMessageType], message);
|
|
1667
|
-
}
|
|
1668
|
-
} catch (e: any) {
|
|
1669
|
-
debugAndPrintError(e);
|
|
1670
|
-
client.leave(CloseCode.WITH_ERROR);
|
|
1671
|
-
return;
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
if (this.onMessageEvents.events[bytesMessageType]) {
|
|
1675
|
-
this.onMessageEvents.emit(bytesMessageType, client, message);
|
|
1676
|
-
|
|
1677
|
-
} else if (this.onMessageEvents.events['*']) {
|
|
1678
|
-
this.onMessageEvents.emit('*', client, messageType, message);
|
|
1679
|
-
|
|
1680
|
-
} else {
|
|
1681
|
-
this.onMessageFallbacks['__no_message_handler'](client, messageType, message);
|
|
1682
|
-
}
|
|
2138
|
+
} else if (code === Protocol.ROOM_INPUT_UNRELIABLE) {
|
|
2139
|
+
this._inputController?.decodeUnreliable(client, buffer, modifiers);
|
|
1683
2140
|
|
|
1684
2141
|
} else if (code === Protocol.JOIN_ROOM && client.state === ClientState.JOINING) {
|
|
1685
2142
|
// join room has been acknowledged by the client
|
|
@@ -1705,7 +2162,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1705
2162
|
}
|
|
1706
2163
|
}
|
|
1707
2164
|
|
|
1708
|
-
#_forciblyCloseClient(client: ExtractRoomClient<T> & ClientPrivate, closeCode: number) {
|
|
2165
|
+
#_forciblyCloseClient(client: ExtractRoomClient<T> & ClientPrivate, closeCode: number, reason?: string) {
|
|
1709
2166
|
// stop receiving messages from this client
|
|
1710
2167
|
client.ref.removeAllListeners('message');
|
|
1711
2168
|
|
|
@@ -1713,7 +2170,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1713
2170
|
client.ref.removeListener('close', client.ref['onleave']);
|
|
1714
2171
|
|
|
1715
2172
|
// only effectively close connection when "onLeave" is fulfilled
|
|
1716
|
-
this._onLeave(client, closeCode).then(() => client.leave(closeCode));
|
|
2173
|
+
this._onLeave(client, closeCode).then(() => (client as any).leave(closeCode, reason));
|
|
1717
2174
|
}
|
|
1718
2175
|
|
|
1719
2176
|
private async _onLeave(client: ExtractRoomClient<T>, code?: number): Promise<any> {
|
|
@@ -1729,6 +2186,10 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1729
2186
|
return;
|
|
1730
2187
|
}
|
|
1731
2188
|
|
|
2189
|
+
// Freeze the seat: a held (reconnecting) session idles from the first tick
|
|
2190
|
+
// instead of replaying last-known moves.
|
|
2191
|
+
this._inputController?.freeze(client as unknown as ClientPrivate);
|
|
2192
|
+
|
|
1732
2193
|
if (method) {
|
|
1733
2194
|
debugMatchMaking(`${method.name}, sessionId: \'%s\' (close code: %d, roomId: %s)`, client.sessionId, code, this.roomId);
|
|
1734
2195
|
|
|
@@ -1769,9 +2230,11 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1769
2230
|
|
|
1770
2231
|
// trigger 'leave' only if seat reservation has been fully consumed
|
|
1771
2232
|
if (this._reservedSeats[client.sessionId] === undefined) {
|
|
2233
|
+
// Session fully gone — drop its accessor. No identity check needed: a
|
|
2234
|
+
// successful reconnect skips this finalizer, so the entry is always this client's.
|
|
2235
|
+
this._inputController?.release(client.sessionId);
|
|
1772
2236
|
this._events.emit('leave', client, willDispose);
|
|
1773
2237
|
}
|
|
1774
|
-
|
|
1775
2238
|
}
|
|
1776
2239
|
|
|
1777
2240
|
async #_incrementClientCount() {
|
|
@@ -1856,53 +2319,4 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1856
2319
|
}
|
|
1857
2320
|
}
|
|
1858
2321
|
|
|
1859
|
-
}
|
|
1860
|
-
|
|
1861
|
-
/**
|
|
1862
|
-
* (WIP) Alternative, method-based room definition.
|
|
1863
|
-
* We should be able to define
|
|
1864
|
-
*/
|
|
1865
|
-
|
|
1866
|
-
type RoomLifecycleMethods =
|
|
1867
|
-
| 'messages'
|
|
1868
|
-
| 'onCreate'
|
|
1869
|
-
| 'onJoin'
|
|
1870
|
-
| 'onLeave'
|
|
1871
|
-
| 'onDispose'
|
|
1872
|
-
| 'onCacheRoom'
|
|
1873
|
-
| 'onRestoreRoom'
|
|
1874
|
-
| 'onDrop'
|
|
1875
|
-
| 'onReconnect'
|
|
1876
|
-
| 'onUncaughtException'
|
|
1877
|
-
| 'onAuth'
|
|
1878
|
-
| 'onBeforeShutdown'
|
|
1879
|
-
| 'onBeforePatch';
|
|
1880
|
-
|
|
1881
|
-
type DefineRoomOptions<T extends RoomOptions = RoomOptions> =
|
|
1882
|
-
Partial<Pick<Room<T>, RoomLifecycleMethods>> &
|
|
1883
|
-
{ state?: ExtractRoomState<T> | (() => ExtractRoomState<T>); } &
|
|
1884
|
-
ThisType<Exclude<Room<T>, RoomLifecycleMethods>> &
|
|
1885
|
-
ThisType<Room<T>>
|
|
1886
|
-
;
|
|
1887
|
-
|
|
1888
|
-
export function room<T>(options: DefineRoomOptions<T>) {
|
|
1889
|
-
class _ extends Room<T> {
|
|
1890
|
-
messages = options.messages;
|
|
1891
|
-
|
|
1892
|
-
constructor() {
|
|
1893
|
-
super();
|
|
1894
|
-
if (options.state && typeof options.state === 'function') {
|
|
1895
|
-
this.state = options.state();
|
|
1896
|
-
}
|
|
1897
|
-
}
|
|
1898
|
-
}
|
|
1899
|
-
|
|
1900
|
-
// Copy all methods to the prototype
|
|
1901
|
-
for (const key in options) {
|
|
1902
|
-
if (typeof options[key] === 'function') {
|
|
1903
|
-
_.prototype[key] = options[key];
|
|
1904
|
-
}
|
|
1905
|
-
}
|
|
1906
|
-
|
|
1907
|
-
return _ as typeof Room<T>;
|
|
1908
2322
|
}
|