@colyseus/core 0.18.0 → 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 +3 -7
- package/build/MatchMaker.cjs.map +2 -2
- package/build/MatchMaker.mjs +4 -8
- package/build/MatchMaker.mjs.map +2 -2
- package/build/Protocol.cjs +3 -3
- package/build/Protocol.cjs.map +1 -1
- package/build/Protocol.d.ts +3 -3
- package/build/Protocol.mjs +3 -3
- package/build/Protocol.mjs.map +1 -1
- 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 +280 -281
- package/build/Room.cjs.map +3 -3
- package/build/Room.d.ts +185 -81
- package/build/Room.mjs +286 -286
- package/build/Room.mjs.map +2 -2
- 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/Server.cjs +88 -31
- package/build/Server.cjs.map +2 -2
- package/build/Server.d.ts +36 -0
- package/build/Server.mjs +88 -31
- package/build/Server.mjs.map +2 -2
- package/build/Transport.cjs +26 -0
- package/build/Transport.cjs.map +2 -2
- package/build/Transport.d.ts +69 -2
- package/build/Transport.mjs +25 -0
- 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 +19 -2
- package/build/index.cjs.map +2 -2
- package/build/index.d.ts +7 -4
- package/build/index.mjs +17 -4
- package/build/index.mjs.map +2 -2
- package/build/input/InputBuffer.cjs +439 -16
- package/build/input/InputBuffer.cjs.map +2 -2
- package/build/input/InputBuffer.d.ts +186 -96
- package/build/input/InputBuffer.mjs +436 -15
- package/build/input/InputBuffer.mjs.map +2 -2
- 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/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/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 +6 -1
- package/build/router/index.cjs.map +2 -2
- package/build/router/index.mjs +6 -1
- package/build/router/index.mjs.map +2 -2
- package/build/router/node.cjs +37 -2
- package/build/router/node.cjs.map +2 -2
- package/build/router/node.d.ts +10 -0
- package/build/router/node.mjs +35 -1
- package/build/router/node.mjs.map +2 -2
- 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/Env.cjs +8 -4
- package/build/utils/Env.cjs.map +3 -3
- package/build/utils/Env.mjs +8 -4
- package/build/utils/Env.mjs.map +2 -2
- 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 +11 -11
- package/src/MatchMaker.ts +7 -9
- package/src/Protocol.ts +3 -3
- package/src/Rewind.ts +572 -0
- package/src/Room.ts +409 -448
- package/src/RoomMessages.ts +342 -0
- package/src/Server.ts +112 -38
- package/src/Transport.ts +97 -2
- package/src/errors/RoomExceptions.ts +18 -4
- package/src/index.ts +13 -2
- package/src/input/InputBuffer.ts +464 -107
- package/src/input/RoomInput.ts +337 -0
- package/src/input/types.ts +503 -0
- package/src/rooms/QueueRoom.ts +1 -1
- package/src/router/default_routes.ts +6 -1
- package/src/router/index.ts +6 -1
- package/src/router/node.ts +44 -0
- 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/Env.ts +12 -4
- package/src/utils/Utils.ts +9 -0
package/src/Room.ts
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
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';
|
|
6
8
|
|
|
7
|
-
/**
|
|
8
|
-
* Module-level cache of `Reflection.encode` output keyed by input
|
|
9
|
-
* constructor — pays the encoding cost once per Room class regardless of
|
|
10
|
-
* room instance count. WeakMap so unused classes can be GC'd.
|
|
11
|
-
*/
|
|
12
|
-
const _inputReflectionCache = new WeakMap<Function, Uint8Array>();
|
|
13
9
|
import { ClockTimer as Clock } from '@colyseus/timer';
|
|
14
10
|
|
|
15
11
|
import { EventEmitter } from 'events';
|
|
@@ -24,26 +20,26 @@ import { SchemaSerializer } from './serializer/SchemaSerializer.ts';
|
|
|
24
20
|
|
|
25
21
|
import { getMessageBytes } from './Protocol.ts';
|
|
26
22
|
import { type Type, Deferred, generateId, wrapTryCatch } from './utils/Utils.ts';
|
|
27
|
-
import { createNanoEvents } from './utils/nanoevents.ts';
|
|
28
23
|
import { isDevMode } from './utils/DevMode.ts';
|
|
29
24
|
|
|
30
25
|
import { debugAndPrintError, debugMatchMaking, debugMessage } from './Debug.ts';
|
|
31
26
|
import { ServerError } from './errors/ServerError.ts';
|
|
32
27
|
import { ClientState, type AuthContext, type Client, type ClientPrivate, ClientArray, type ISendOptions, type MessageArgs } from './Transport.ts';
|
|
33
|
-
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';
|
|
34
29
|
|
|
35
|
-
import {
|
|
30
|
+
import { type StandardSchemaV1 } from './utils/StandardSchema.ts';
|
|
36
31
|
import * as matchMaker from './MatchMaker.ts';
|
|
37
32
|
|
|
38
33
|
import {
|
|
39
34
|
CloseCode,
|
|
40
35
|
ErrorCode,
|
|
41
|
-
HandshakeSection,
|
|
42
36
|
Protocol,
|
|
43
|
-
|
|
37
|
+
PROTOCOL_CODE_MASK,
|
|
38
|
+
PROTOCOL_MODIFIER_MASK,
|
|
44
39
|
type MessageHandlerWithFormat as SharedMessageHandlerWithFormat,
|
|
45
40
|
type MessageHandler as SharedMessageHandler,
|
|
46
41
|
type Messages as SharedMessages,
|
|
42
|
+
type MessageContext,
|
|
47
43
|
} from '@colyseus/shared-types';
|
|
48
44
|
|
|
49
45
|
import {
|
|
@@ -62,23 +58,49 @@ const DEFAULT_PATCH_RATE = 1000 / 20; // 20fps (50ms)
|
|
|
62
58
|
const DEFAULT_SIMULATION_INTERVAL = 1000 / 60; // 60fps (16.66ms)
|
|
63
59
|
const noneSerializer = new NoneSerializer();
|
|
64
60
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
function toResponseError(e: any): { name: string; message: string; code?: any } {
|
|
69
|
-
if (e instanceof Error) {
|
|
70
|
-
const code = (e as any).code;
|
|
71
|
-
return (code !== undefined)
|
|
72
|
-
? { name: e.name, message: e.message, code }
|
|
73
|
-
: { name: e.name, message: e.message };
|
|
74
|
-
}
|
|
75
|
-
return { name: "Error", message: String(e) };
|
|
76
|
-
}
|
|
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 });
|
|
77
64
|
|
|
78
65
|
export const DEFAULT_SEAT_RESERVATION_TIME = Number(process.env.COLYSEUS_SEAT_RESERVATION_TIME || 15);
|
|
79
66
|
|
|
80
67
|
export type SimulationCallback = (deltaTime: number) => void;
|
|
81
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
|
+
|
|
82
104
|
export interface RoomOptions {
|
|
83
105
|
state?: object;
|
|
84
106
|
metadata?: any;
|
|
@@ -134,7 +156,7 @@ export type Messages<This extends Room> = SharedMessages<This, Client>;
|
|
|
134
156
|
*/
|
|
135
157
|
export function validate<T extends StandardSchemaV1, This = any>(
|
|
136
158
|
format: T,
|
|
137
|
-
handler: (this: This, client: Client, message: StandardSchemaV1.InferOutput<T
|
|
159
|
+
handler: (this: This, client: Client, message: StandardSchemaV1.InferOutput<T>, ctx: MessageContext) => unknown
|
|
138
160
|
): MessageHandlerWithFormat<T, This> {
|
|
139
161
|
return { format, handler };
|
|
140
162
|
}
|
|
@@ -301,6 +323,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
301
323
|
private _reconnectionAttempts: { [reconnectionToken: string]: Deferred } = {};
|
|
302
324
|
|
|
303
325
|
public messages?: Messages<any>;
|
|
326
|
+
/** @internal Message-routing layer: handler registry + user-message decode/dispatch. */
|
|
327
|
+
#_messages = new RoomMessages(this);
|
|
304
328
|
|
|
305
329
|
/**
|
|
306
330
|
* Room plugins, keyed by an operator-chosen handle. Each plugin
|
|
@@ -343,27 +367,24 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
343
367
|
*/
|
|
344
368
|
static __pluginLayout?: PluginLayout | null;
|
|
345
369
|
|
|
346
|
-
|
|
347
|
-
|
|
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; }
|
|
348
374
|
|
|
349
|
-
private
|
|
350
|
-
'__no_message_handler': (client: ExtractRoomClient<T>, messageType: string | number, _: unknown) => {
|
|
351
|
-
const errorMessage = `room onMessage for "${messageType}" not registered.`;
|
|
352
|
-
debugMessage(`${errorMessage} (roomId: ${this.roomId})`);
|
|
353
|
-
|
|
354
|
-
if (isDevMode) {
|
|
355
|
-
// send error code to client in development mode
|
|
356
|
-
client.error(ErrorCode.INVALID_PAYLOAD, errorMessage);
|
|
375
|
+
private _serializer: Serializer<ExtractRoomState<T>> = noneSerializer;
|
|
357
376
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
};
|
|
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> = [];
|
|
364
381
|
|
|
365
|
-
|
|
366
|
-
|
|
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 }> = [];
|
|
367
388
|
|
|
368
389
|
private _simulationInterval: NodeJS.Timeout;
|
|
369
390
|
|
|
@@ -589,83 +610,83 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
589
610
|
public onLeave?(client: ExtractRoomClient<T>, code?: number): void | Promise<any>;
|
|
590
611
|
|
|
591
612
|
/**
|
|
592
|
-
* Per-client input accessor.
|
|
593
|
-
* each tick to read
|
|
594
|
-
*
|
|
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.)
|
|
595
619
|
*
|
|
596
620
|
* @example
|
|
597
621
|
* ```typescript
|
|
598
622
|
* class FpsRoom extends Room<{ input: MoveInput }> {
|
|
599
|
-
*
|
|
623
|
+
* inputs = this.defineInput(MoveInput);
|
|
600
624
|
*
|
|
601
625
|
* onCreate() {
|
|
602
|
-
* this.
|
|
603
|
-
* for (const
|
|
604
|
-
* const input
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
* // const snapshot = input.at(this.clock.ticks);
|
|
608
|
-
* // for (const snapshot of input.drain()) ...
|
|
609
|
-
* }
|
|
610
|
-
* }, 1000 / 30);
|
|
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);
|
|
611
631
|
* }
|
|
612
632
|
* }
|
|
613
633
|
* ```
|
|
614
634
|
*/
|
|
615
|
-
public
|
|
635
|
+
public inputs?: InputAPI<ExtractRoomInput<T>>;
|
|
616
636
|
|
|
617
637
|
/**
|
|
618
|
-
*
|
|
619
|
-
*
|
|
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}.
|
|
620
642
|
*/
|
|
621
|
-
private
|
|
643
|
+
private _inputController?: RoomInput;
|
|
622
644
|
|
|
623
645
|
/**
|
|
624
646
|
* Declare the input schema and configuration in a single line. Returns the
|
|
625
|
-
*
|
|
626
|
-
* `this.
|
|
647
|
+
* input API that gets assigned to `this.inputs` — call
|
|
648
|
+
* `this.inputs.get(sessionId)` per tick to consume.
|
|
627
649
|
*
|
|
628
650
|
* ```typescript
|
|
629
651
|
* class FpsRoom extends Room<{ input: MoveInput }> {
|
|
630
|
-
*
|
|
652
|
+
* inputs = this.defineInput(MoveInput, {
|
|
631
653
|
* seqField: "tick", // typed: only numeric fields of MoveInput
|
|
632
654
|
* bufferMaxSize: 64,
|
|
633
655
|
* });
|
|
634
656
|
*
|
|
635
|
-
* // …or without options —
|
|
636
|
-
* //
|
|
657
|
+
* // …or without options — no seq dedupe, bufferMaxSize: 32:
|
|
658
|
+
* // inputs = this.defineInput(MoveInput);
|
|
637
659
|
* }
|
|
638
660
|
* ```
|
|
639
661
|
*
|
|
640
662
|
* **Defaults** when `opts` (or individual fields) are omitted:
|
|
641
|
-
* - `seqField`:
|
|
642
|
-
*
|
|
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.
|
|
643
667
|
* - `bufferMaxSize`: `32` — enables per-client snapshot buffering for
|
|
644
|
-
* `
|
|
645
|
-
* buffering (the `.latest`
|
|
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.
|
|
646
678
|
*/
|
|
647
|
-
protected defineInput<
|
|
679
|
+
protected defineInput<
|
|
680
|
+
C extends new () => any,
|
|
681
|
+
O extends DefineInputOptions<InstanceType<C>> = DefineInputOptions<InstanceType<C>>,
|
|
682
|
+
>(
|
|
648
683
|
type: C,
|
|
649
|
-
opts?:
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
this.
|
|
655
|
-
ctor: type,
|
|
656
|
-
seqField: opts?.seqField ?? "seq",
|
|
657
|
-
bufferMaxSize: opts?.bufferMaxSize ?? 32,
|
|
658
|
-
};
|
|
659
|
-
if (!_inputReflectionCache.has(type)) {
|
|
660
|
-
// Reflection.encode walks the schema's TypeContext via a one-shot
|
|
661
|
-
// Encoder around a throwaway instance; the bytes are SDK-deserializable
|
|
662
|
-
// back into a constructor through Reflection.decode.
|
|
663
|
-
_inputReflectionCache.set(type, Reflection.encode(new Encoder(new type())));
|
|
664
|
-
}
|
|
665
|
-
return ((sessionId: string): InputAccessor<InstanceType<C>> => {
|
|
666
|
-
const c = this.clients.getById(sessionId) as unknown as ClientPrivate | undefined;
|
|
667
|
-
return (c?._inputAccessor as InputAccessor<InstanceType<C>>) ?? NO_OP_INPUT_ACCESSOR;
|
|
668
|
-
});
|
|
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);
|
|
669
690
|
}
|
|
670
691
|
|
|
671
692
|
/**
|
|
@@ -681,7 +702,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
681
702
|
* - `onMessage`
|
|
682
703
|
* - `onAuth` / `onJoin` / `onLeave` / `onCreate` / `onDispose`
|
|
683
704
|
* - `clock.setTimeout` / `clock.setInterval`
|
|
684
|
-
* - `
|
|
705
|
+
* - `setTimestep` / `setFixedTimestep`
|
|
685
706
|
*
|
|
686
707
|
* (Experimental: this feature is subject to change in the future - we're currently getting feedback to improve it)
|
|
687
708
|
*/
|
|
@@ -783,7 +804,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
783
804
|
|
|
784
805
|
} else if (typeof(reconnectionToken) === "string") {
|
|
785
806
|
// potentially a stale client reference, so a reconnection attempt is possible.
|
|
786
|
-
return this.clients.
|
|
807
|
+
return this.clients.get(sessionId)?.reconnectionToken === reconnectionToken;
|
|
787
808
|
}
|
|
788
809
|
|
|
789
810
|
return false;
|
|
@@ -807,22 +828,29 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
807
828
|
}
|
|
808
829
|
|
|
809
830
|
/**
|
|
810
|
-
*
|
|
811
|
-
*
|
|
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.
|
|
812
835
|
*
|
|
813
836
|
* @default 16.6ms (60fps)
|
|
814
837
|
*
|
|
815
|
-
* @param onTickCallback -
|
|
816
|
-
*
|
|
817
|
-
* @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.
|
|
818
841
|
*/
|
|
819
|
-
public
|
|
820
|
-
// 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
|
|
821
844
|
if (this._simulationInterval) { clearInterval(this._simulationInterval); }
|
|
822
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
|
+
|
|
823
851
|
if (onTickCallback) {
|
|
824
852
|
if (this.onUncaughtException !== undefined) {
|
|
825
|
-
onTickCallback = wrapTryCatch(onTickCallback, this.onUncaughtException.bind(this),
|
|
853
|
+
onTickCallback = wrapTryCatch(onTickCallback, this.onUncaughtException.bind(this), TimestepException, 'setTimestep');
|
|
826
854
|
}
|
|
827
855
|
|
|
828
856
|
this._simulationInterval = setInterval(() => {
|
|
@@ -833,49 +861,179 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
833
861
|
}
|
|
834
862
|
|
|
835
863
|
/**
|
|
836
|
-
*
|
|
837
|
-
*
|
|
838
|
-
*
|
|
839
|
-
|
|
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.
|
|
840
880
|
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
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}.
|
|
843
884
|
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
847
|
-
*
|
|
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).
|
|
848
896
|
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
*
|
|
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 });
|
|
858
924
|
* }
|
|
859
925
|
* ```
|
|
860
926
|
*/
|
|
861
|
-
public
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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();
|
|
871
983
|
}
|
|
872
984
|
|
|
873
985
|
/**
|
|
874
|
-
*
|
|
875
|
-
*
|
|
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).
|
|
876
1007
|
*/
|
|
877
|
-
public
|
|
878
|
-
|
|
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
|
+
}
|
|
879
1037
|
|
|
880
1038
|
/**
|
|
881
1039
|
* @deprecated Use `.patchRate=` instead.
|
|
@@ -1106,12 +1264,6 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1106
1264
|
...args: MessageArgs<ExtractRoomClient<T>['~messages'][K], IBroadcastOptions>
|
|
1107
1265
|
) {
|
|
1108
1266
|
const [message, options] = args;
|
|
1109
|
-
if (options && options.afterNextPatch) {
|
|
1110
|
-
delete options.afterNextPatch;
|
|
1111
|
-
this._afterNextPatchQueue.push(['broadcast', [type, ...args]]);
|
|
1112
|
-
return;
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
1267
|
this.broadcastMessageType(type, message, options);
|
|
1116
1268
|
}
|
|
1117
1269
|
|
|
@@ -1119,12 +1271,6 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1119
1271
|
* Broadcast bytes (UInt8Arrays) to a particular room
|
|
1120
1272
|
*/
|
|
1121
1273
|
public broadcastBytes(type: string | number, message: Uint8Array, options: IBroadcastOptions) {
|
|
1122
|
-
if (options && options.afterNextPatch) {
|
|
1123
|
-
delete options.afterNextPatch;
|
|
1124
|
-
this._afterNextPatchQueue.push(['broadcastBytes', arguments]);
|
|
1125
|
-
return;
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
1274
|
this.broadcastMessageType(type as string, message, options);
|
|
1129
1275
|
}
|
|
1130
1276
|
|
|
@@ -1144,10 +1290,33 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1144
1290
|
return false;
|
|
1145
1291
|
}
|
|
1146
1292
|
|
|
1147
|
-
|
|
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();
|
|
1148
1307
|
|
|
1149
|
-
//
|
|
1150
|
-
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
|
+
}
|
|
1151
1320
|
|
|
1152
1321
|
return hasChanges;
|
|
1153
1322
|
}
|
|
@@ -1182,43 +1351,19 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1182
1351
|
);
|
|
1183
1352
|
public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(
|
|
1184
1353
|
messageType: string | number,
|
|
1185
|
-
callback: (client: C, message: T) =>
|
|
1354
|
+
callback: (client: C, message: T, ctx: MessageContext) => unknown,
|
|
1186
1355
|
);
|
|
1187
1356
|
public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(
|
|
1188
1357
|
messageType: string | number,
|
|
1189
1358
|
validationSchema: StandardSchemaV1<T>,
|
|
1190
|
-
callback: (client: C, message: T) =>
|
|
1359
|
+
callback: (client: C, message: T, ctx: MessageContext) => unknown,
|
|
1191
1360
|
);
|
|
1192
1361
|
public onMessage<T = any>(
|
|
1193
1362
|
_messageType: '*' | string | number,
|
|
1194
1363
|
_validationSchema: StandardSchemaV1<T> | ((...args: any[]) => void),
|
|
1195
1364
|
_callback?: (...args: any[]) => void,
|
|
1196
1365
|
) {
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
const validationSchema = (typeof _callback === 'function')
|
|
1200
|
-
? _validationSchema as StandardSchemaV1<T>
|
|
1201
|
-
: undefined;
|
|
1202
|
-
|
|
1203
|
-
const callback = (validationSchema === undefined)
|
|
1204
|
-
? _validationSchema as (...args: any[]) => void
|
|
1205
|
-
: _callback;
|
|
1206
|
-
|
|
1207
|
-
const removeListener = this.onMessageEvents.on(messageType, (this.onUncaughtException !== undefined)
|
|
1208
|
-
? wrapTryCatch(callback, this.onUncaughtException.bind(this), OnMessageException, 'onMessage', false, _messageType)
|
|
1209
|
-
: callback);
|
|
1210
|
-
|
|
1211
|
-
if (validationSchema !== undefined) {
|
|
1212
|
-
this.onMessageValidators[messageType] = validationSchema;
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
// returns a method to unbind the callback
|
|
1216
|
-
return () => {
|
|
1217
|
-
removeListener();
|
|
1218
|
-
if (this.onMessageEvents.events[messageType].length === 0) {
|
|
1219
|
-
delete this.onMessageValidators[messageType];
|
|
1220
|
-
}
|
|
1221
|
-
};
|
|
1366
|
+
return this.#_messages.on(_messageType, _validationSchema as any, _callback);
|
|
1222
1367
|
}
|
|
1223
1368
|
|
|
1224
1369
|
public onMessageBytes<T = any, C extends Client = ExtractRoomClient<T>>(
|
|
@@ -1237,21 +1382,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1237
1382
|
_validationSchema: StandardSchemaV1<T> | ((...args: any[]) => void),
|
|
1238
1383
|
_callback?: (...args: any[]) => void,
|
|
1239
1384
|
) {
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
const validationSchema = (typeof _callback === 'function')
|
|
1243
|
-
? _validationSchema as StandardSchemaV1<T>
|
|
1244
|
-
: undefined;
|
|
1245
|
-
|
|
1246
|
-
const callback = (validationSchema === undefined)
|
|
1247
|
-
? _validationSchema as (...args: any[]) => void
|
|
1248
|
-
: _callback;
|
|
1249
|
-
|
|
1250
|
-
if (validationSchema !== undefined) {
|
|
1251
|
-
return this.onMessage(messageType, validationSchema as any, callback as any);
|
|
1252
|
-
} else {
|
|
1253
|
-
return this.onMessage(messageType, callback as any);
|
|
1254
|
-
}
|
|
1385
|
+
return this.#_messages.on(`_$b${_messageType}`, _validationSchema as any, _callback);
|
|
1255
1386
|
}
|
|
1256
1387
|
|
|
1257
1388
|
// ---------------------------------------------------------------------------
|
|
@@ -1426,18 +1557,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1426
1557
|
// (each new reconnection receives a new reconnection token)
|
|
1427
1558
|
client.reconnectionToken = generateId();
|
|
1428
1559
|
|
|
1429
|
-
// Allocate per-client input
|
|
1430
|
-
|
|
1431
|
-
if (this.inputOptions !== undefined) {
|
|
1432
|
-
client._input = new this.inputOptions.ctor();
|
|
1433
|
-
client._inputDecoder = new InputDecoder(client._input);
|
|
1434
|
-
// Buffer is opt-in via bufferMaxSize > 0 (rollback / lockstep).
|
|
1435
|
-
const maxSize = this.inputOptions.bufferMaxSize;
|
|
1436
|
-
if (maxSize > 0) {
|
|
1437
|
-
client._inputBuffer = new InputBufferImpl(maxSize, this.inputOptions.seqField);
|
|
1438
|
-
}
|
|
1439
|
-
client._inputAccessor = new InputAccessorImpl(client);
|
|
1440
|
-
}
|
|
1560
|
+
// Allocate per-client input state early so onJoin can read inputs.get(sid).latest.
|
|
1561
|
+
this._inputController?.allocate(client);
|
|
1441
1562
|
|
|
1442
1563
|
if (this._reservedSeatTimeouts[sessionId]) {
|
|
1443
1564
|
clearTimeout(this._reservedSeatTimeouts[sessionId]);
|
|
@@ -1457,7 +1578,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1457
1578
|
if (
|
|
1458
1579
|
this._reservedSeats[sessionId] === undefined &&
|
|
1459
1580
|
connectionOptions?.reconnectionToken &&
|
|
1460
|
-
this.clients.
|
|
1581
|
+
this.clients.get(sessionId)?.reconnectionToken === connectionOptions.reconnectionToken
|
|
1461
1582
|
) {
|
|
1462
1583
|
debugMatchMaking('attempting to reconnect client with a stale previous connection - sessionId: \'%s\', roomId: \'%s\'', client.sessionId, this.roomId);
|
|
1463
1584
|
this._reconnectionAttempts[connectionOptions.reconnectionToken] = new Deferred();
|
|
@@ -1496,8 +1617,10 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1496
1617
|
this._reservedSeats[sessionId][2] = true; // flag seat reservation as "consumed"
|
|
1497
1618
|
debugMatchMaking('consuming seat reservation, sessionId: \'%s\' (roomId: %s)', client.sessionId, this.roomId);
|
|
1498
1619
|
|
|
1499
|
-
//
|
|
1500
|
-
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;
|
|
1501
1624
|
|
|
1502
1625
|
// add temporary callback to keep track of disconnections during `onJoin`.
|
|
1503
1626
|
client.ref['onleave'] = (_) => client.state = ClientState.LEAVING;
|
|
@@ -1507,6 +1630,9 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1507
1630
|
const reconnectionToken = connectionOptions?.reconnectionToken;
|
|
1508
1631
|
if (reconnectionToken && this._reconnections[reconnectionToken]?.[0] === sessionId) {
|
|
1509
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);
|
|
1510
1636
|
|
|
1511
1637
|
//
|
|
1512
1638
|
// await for reconnection:
|
|
@@ -1572,6 +1698,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1572
1698
|
}
|
|
1573
1699
|
|
|
1574
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);
|
|
1575
1703
|
|
|
1576
1704
|
//
|
|
1577
1705
|
// Flag sessionId as non-enumarable so hasReachedMaxClients() doesn't count it
|
|
@@ -1625,16 +1753,10 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1625
1753
|
// allow client to send messages after onJoin has succeeded.
|
|
1626
1754
|
client.ref.on('message', this._onMessage.bind(this, client));
|
|
1627
1755
|
|
|
1628
|
-
//
|
|
1629
|
-
//
|
|
1630
|
-
//
|
|
1631
|
-
|
|
1632
|
-
if (!connectionOptions?.skipHandshake && this.inputOptions !== undefined) {
|
|
1633
|
-
const inputBytes = _inputReflectionCache.get(this.inputOptions.ctor);
|
|
1634
|
-
if (inputBytes !== undefined) {
|
|
1635
|
-
extraSections = [{ tag: HandshakeSection.INPUT_REFLECTION, bytes: inputBytes }];
|
|
1636
|
-
}
|
|
1637
|
-
}
|
|
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();
|
|
1638
1760
|
|
|
1639
1761
|
// confirm room id that matches the room name requested to join
|
|
1640
1762
|
client.raw(getMessageBytes[Protocol.JOIN_ROOM](
|
|
@@ -1786,46 +1908,67 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1786
1908
|
: [options.except]
|
|
1787
1909
|
: undefined;
|
|
1788
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;
|
|
1789
1924
|
let numClients = this.clients.length;
|
|
1790
1925
|
while (numClients--) {
|
|
1791
1926
|
const client = this.clients[numClients];
|
|
1792
|
-
|
|
1793
1927
|
if (!except || !except.includes(client)) {
|
|
1794
|
-
client.enqueueRaw(encodedMessage);
|
|
1928
|
+
client.enqueueRaw(encodedMessage, sendOpts);
|
|
1795
1929
|
}
|
|
1796
1930
|
}
|
|
1797
1931
|
}
|
|
1798
1932
|
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
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;
|
|
1804
1948
|
}
|
|
1805
1949
|
|
|
1806
1950
|
private sendFullState(client: Client): void {
|
|
1807
|
-
client.raw(this._serializer.getFullState(
|
|
1951
|
+
client.raw(this._serializer.getFullState(
|
|
1952
|
+
client,
|
|
1953
|
+
this._inputController !== undefined ? { sNow: this.clock.elapsedTime } : undefined,
|
|
1954
|
+
));
|
|
1808
1955
|
}
|
|
1809
1956
|
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
}
|
|
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;
|
|
1823
1969
|
}
|
|
1824
|
-
|
|
1825
|
-
// new messages may have been added in the meantime,
|
|
1826
|
-
// let's splice the ones that have been processed
|
|
1827
|
-
this._afterNextPatchQueue.splice(0, length);
|
|
1828
1970
|
}
|
|
1971
|
+
dirty.length = 0;
|
|
1829
1972
|
}
|
|
1830
1973
|
|
|
1831
1974
|
private async _reserveSeat(
|
|
@@ -1872,6 +2015,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1872
2015
|
delete this._reconnections[devModeReconnectionToken];
|
|
1873
2016
|
delete this._reservedSeats[sessionId];
|
|
1874
2017
|
delete this._reservedSeatTimeouts[sessionId];
|
|
2018
|
+
// devMode reconnection bypasses #_onAfterLeave — drop the input accessor here.
|
|
2019
|
+
this._inputController?.release(sessionId);
|
|
1875
2020
|
|
|
1876
2021
|
if (!allowReconnection) {
|
|
1877
2022
|
await this.#_decrementClientCount();
|
|
@@ -1939,7 +2084,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1939
2084
|
}
|
|
1940
2085
|
|
|
1941
2086
|
if (this._autoDisposeTimeout) {
|
|
1942
|
-
|
|
2087
|
+
clearTimeout(this._autoDisposeTimeout);
|
|
1943
2088
|
this._autoDisposeTimeout = undefined;
|
|
1944
2089
|
}
|
|
1945
2090
|
|
|
@@ -1947,24 +2092,10 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1947
2092
|
this.clock.clear();
|
|
1948
2093
|
this.clock.stop();
|
|
1949
2094
|
|
|
1950
|
-
|
|
1951
|
-
|
|
2095
|
+
// drop any input accessors still held for in-flight reconnections
|
|
2096
|
+
this._inputController?.dispose();
|
|
1952
2097
|
|
|
1953
|
-
|
|
1954
|
-
* After the decoder has mutated `client._input`, push a clone into the
|
|
1955
|
-
* per-client buffer (when buffering is enabled). Honors
|
|
1956
|
-
* `inputOptions.seqField` for dedupe of redundant frames.
|
|
1957
|
-
*/
|
|
1958
|
-
#captureInput(client: ClientPrivate) {
|
|
1959
|
-
const buf = client._inputBuffer;
|
|
1960
|
-
if (!buf) { return; } // no consumer registered — skip the clone allocation
|
|
1961
|
-
const inst = client._input!;
|
|
1962
|
-
const seqField = this.inputOptions?.seqField;
|
|
1963
|
-
if (seqField !== undefined) {
|
|
1964
|
-
const value = (inst as any)[seqField] as number;
|
|
1965
|
-
if (typeof value === 'number' && !buf.accept(value)) { return; }
|
|
1966
|
-
}
|
|
1967
|
-
buf.push(inst.clone() as any);
|
|
2098
|
+
return await (userReturnData || Promise.resolve());
|
|
1968
2099
|
}
|
|
1969
2100
|
|
|
1970
2101
|
private _onMessage(client: ExtractRoomClient<T> & ClientPrivate, buffer: Buffer) {
|
|
@@ -1987,153 +2118,25 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1987
2118
|
}
|
|
1988
2119
|
|
|
1989
2120
|
const it: Iterator = { offset: 1 };
|
|
1990
|
-
|
|
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;
|
|
1991
2125
|
|
|
1992
2126
|
if (code === Protocol.ROOM_DATA) {
|
|
1993
|
-
|
|
1994
|
-
? decode.string(buffer, it)
|
|
1995
|
-
: decode.number(buffer, it);
|
|
1996
|
-
|
|
1997
|
-
let message;
|
|
1998
|
-
try {
|
|
1999
|
-
message = (buffer.byteLength > it.offset)
|
|
2000
|
-
? unpack(buffer.subarray(it.offset, buffer.byteLength))
|
|
2001
|
-
: undefined;
|
|
2002
|
-
debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId);
|
|
2003
|
-
|
|
2004
|
-
// custom message validation
|
|
2005
|
-
if (this.onMessageValidators[messageType] !== undefined) {
|
|
2006
|
-
message = standardValidate(this.onMessageValidators[messageType], message);
|
|
2007
|
-
}
|
|
2008
|
-
|
|
2009
|
-
} catch (e: any) {
|
|
2010
|
-
debugAndPrintError(e);
|
|
2011
|
-
client.leave(CloseCode.WITH_ERROR);
|
|
2012
|
-
return;
|
|
2013
|
-
}
|
|
2014
|
-
|
|
2015
|
-
if (this.onMessageEvents.events[messageType]) {
|
|
2016
|
-
this.onMessageEvents.emit(messageType as string, client, message);
|
|
2017
|
-
|
|
2018
|
-
} else if (this.onMessageEvents.events['*']) {
|
|
2019
|
-
this.onMessageEvents.emit('*', client, messageType, message);
|
|
2020
|
-
|
|
2021
|
-
} else {
|
|
2022
|
-
this.onMessageFallbacks['__no_message_handler'](client, messageType, message);
|
|
2023
|
-
}
|
|
2127
|
+
this.#_messages.onData(client, buffer, it);
|
|
2024
2128
|
|
|
2025
2129
|
} else if (code === Protocol.ROOM_REQUEST) {
|
|
2026
|
-
|
|
2027
|
-
// ROOM_DATA message — the only difference is the client opted in to a
|
|
2028
|
-
// reply (by passing a callback / using `room.request()`), so the wire
|
|
2029
|
-
// carries a `requestId` we must echo back. The handler's return value
|
|
2030
|
-
// (awaited) becomes the response payload.
|
|
2031
|
-
const requestId = decode.number(buffer, it);
|
|
2032
|
-
|
|
2033
|
-
const messageType = (decode.stringCheck(buffer, it))
|
|
2034
|
-
? decode.string(buffer, it)
|
|
2035
|
-
: decode.number(buffer, it);
|
|
2036
|
-
|
|
2037
|
-
let message;
|
|
2038
|
-
try {
|
|
2039
|
-
message = (buffer.byteLength > it.offset)
|
|
2040
|
-
? unpack(buffer.subarray(it.offset, buffer.byteLength))
|
|
2041
|
-
: undefined;
|
|
2042
|
-
debugMessage("request #%d: '%s' -> %j (roomId: %s)", requestId, messageType, message, this.roomId);
|
|
2043
|
-
|
|
2044
|
-
// custom message validation (shared with the ROOM_DATA path)
|
|
2045
|
-
if (this.onMessageValidators[messageType] !== undefined) {
|
|
2046
|
-
message = standardValidate(this.onMessageValidators[messageType], message);
|
|
2047
|
-
}
|
|
2048
|
-
|
|
2049
|
-
} catch (e: any) {
|
|
2050
|
-
// Reply with an error so the client's pending request settles instead
|
|
2051
|
-
// of timing out. (A plain ROOM_DATA would drop the client here, but a
|
|
2052
|
-
// request has a caller waiting on the other end.)
|
|
2053
|
-
debugAndPrintError(e);
|
|
2054
|
-
this.#replyToRequest(client, requestId, ResponseStatus.ERROR, toResponseError(e));
|
|
2055
|
-
return;
|
|
2056
|
-
}
|
|
2057
|
-
|
|
2058
|
-
// A request is answered by the FIRST handler registered for its type.
|
|
2059
|
-
// `onMessageEvents.emit` would run every handler and discard returns, so
|
|
2060
|
-
// we invoke directly to capture the value. Wildcard ('*') handlers are
|
|
2061
|
-
// not eligible — their (client, type, message) shape has no response
|
|
2062
|
-
// contract — so a type with only a wildcard handler gets `no_handler`.
|
|
2063
|
-
const handler = this.onMessageEvents.events[messageType as string]?.[0];
|
|
2064
|
-
|
|
2065
|
-
if (handler === undefined) {
|
|
2066
|
-
this.#replyToRequest(client, requestId, ResponseStatus.ERROR, {
|
|
2067
|
-
name: "no_handler",
|
|
2068
|
-
message: `room "${this.roomName}" has no onMessage("${messageType}") handler to answer this request.`,
|
|
2069
|
-
});
|
|
2070
|
-
return;
|
|
2071
|
-
}
|
|
2072
|
-
|
|
2073
|
-
// `Promise.resolve().then(...)` normalizes sync throws and async
|
|
2074
|
-
// rejections into the same rejection path. When `onUncaughtException`
|
|
2075
|
-
// is configured the handler is wrapped (see `onMessage`) and swallows
|
|
2076
|
-
// its own errors, so the request resolves with `undefined` and the
|
|
2077
|
-
// exception is reported there instead of as an ERROR response.
|
|
2078
|
-
Promise.resolve().then(() => handler(client, message)).then(
|
|
2079
|
-
(response) => this.#replyToRequest(client, requestId, ResponseStatus.OK, response),
|
|
2080
|
-
(e) => {
|
|
2081
|
-
debugAndPrintError(e);
|
|
2082
|
-
this.#replyToRequest(client, requestId, ResponseStatus.ERROR, toResponseError(e));
|
|
2083
|
-
},
|
|
2084
|
-
);
|
|
2130
|
+
this.#_messages.onRequest(client, buffer, it);
|
|
2085
2131
|
|
|
2086
2132
|
} else if (code === Protocol.ROOM_DATA_BYTES) {
|
|
2087
|
-
|
|
2088
|
-
? decode.string(buffer, it)
|
|
2089
|
-
: decode.number(buffer, it);
|
|
2090
|
-
|
|
2091
|
-
let message: any = buffer.subarray(it.offset, buffer.byteLength);
|
|
2092
|
-
debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId);
|
|
2093
|
-
|
|
2094
|
-
const bytesMessageType = `_$b${messageType}`;
|
|
2095
|
-
|
|
2096
|
-
// custom message validation
|
|
2097
|
-
try {
|
|
2098
|
-
if (this.onMessageValidators[bytesMessageType] !== undefined) {
|
|
2099
|
-
message = standardValidate(this.onMessageValidators[bytesMessageType], message);
|
|
2100
|
-
}
|
|
2101
|
-
} catch (e: any) {
|
|
2102
|
-
debugAndPrintError(e);
|
|
2103
|
-
client.leave(CloseCode.WITH_ERROR);
|
|
2104
|
-
return;
|
|
2105
|
-
}
|
|
2106
|
-
|
|
2107
|
-
if (this.onMessageEvents.events[bytesMessageType]) {
|
|
2108
|
-
this.onMessageEvents.emit(bytesMessageType, client, message);
|
|
2109
|
-
|
|
2110
|
-
} else if (this.onMessageEvents.events['*']) {
|
|
2111
|
-
this.onMessageEvents.emit('*', client, messageType, message);
|
|
2112
|
-
|
|
2113
|
-
} else {
|
|
2114
|
-
this.onMessageFallbacks['__no_message_handler'](client, messageType, message);
|
|
2115
|
-
}
|
|
2133
|
+
this.#_messages.onDataBytes(client, buffer, it);
|
|
2116
2134
|
|
|
2117
2135
|
} else if (code === Protocol.ROOM_INPUT_RELIABLE) {
|
|
2118
|
-
|
|
2119
|
-
try {
|
|
2120
|
-
client._inputDecoder.decode(buffer.subarray(1));
|
|
2121
|
-
} catch (e: any) {
|
|
2122
|
-
debugAndPrintError(e);
|
|
2123
|
-
return;
|
|
2124
|
-
}
|
|
2125
|
-
this.#captureInput(client);
|
|
2126
|
-
}
|
|
2136
|
+
this._inputController?.decodeReliable(client, buffer, it, modifiers);
|
|
2127
2137
|
|
|
2128
2138
|
} else if (code === Protocol.ROOM_INPUT_UNRELIABLE) {
|
|
2129
|
-
|
|
2130
|
-
try {
|
|
2131
|
-
client._inputDecoder.decodeAll(buffer.subarray(1), () => this.#captureInput(client));
|
|
2132
|
-
} catch (e: any) {
|
|
2133
|
-
debugAndPrintError(e);
|
|
2134
|
-
return;
|
|
2135
|
-
}
|
|
2136
|
-
}
|
|
2139
|
+
this._inputController?.decodeUnreliable(client, buffer, modifiers);
|
|
2137
2140
|
|
|
2138
2141
|
} else if (code === Protocol.JOIN_ROOM && client.state === ClientState.JOINING) {
|
|
2139
2142
|
// join room has been acknowledged by the client
|
|
@@ -2183,6 +2186,10 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
2183
2186
|
return;
|
|
2184
2187
|
}
|
|
2185
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
|
+
|
|
2186
2193
|
if (method) {
|
|
2187
2194
|
debugMatchMaking(`${method.name}, sessionId: \'%s\' (close code: %d, roomId: %s)`, client.sessionId, code, this.roomId);
|
|
2188
2195
|
|
|
@@ -2223,6 +2230,9 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
2223
2230
|
|
|
2224
2231
|
// trigger 'leave' only if seat reservation has been fully consumed
|
|
2225
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);
|
|
2226
2236
|
this._events.emit('leave', client, willDispose);
|
|
2227
2237
|
}
|
|
2228
2238
|
}
|
|
@@ -2309,53 +2319,4 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
2309
2319
|
}
|
|
2310
2320
|
}
|
|
2311
2321
|
|
|
2312
|
-
}
|
|
2313
|
-
|
|
2314
|
-
/**
|
|
2315
|
-
* (WIP) Alternative, method-based room definition.
|
|
2316
|
-
* We should be able to define
|
|
2317
|
-
*/
|
|
2318
|
-
|
|
2319
|
-
type RoomLifecycleMethods =
|
|
2320
|
-
| 'messages'
|
|
2321
|
-
| 'onCreate'
|
|
2322
|
-
| 'onJoin'
|
|
2323
|
-
| 'onLeave'
|
|
2324
|
-
| 'onDispose'
|
|
2325
|
-
| 'onCacheRoom'
|
|
2326
|
-
| 'onRestoreRoom'
|
|
2327
|
-
| 'onDrop'
|
|
2328
|
-
| 'onReconnect'
|
|
2329
|
-
| 'onUncaughtException'
|
|
2330
|
-
| 'onAuth'
|
|
2331
|
-
| 'onBeforeShutdown'
|
|
2332
|
-
| 'onBeforePatch';
|
|
2333
|
-
|
|
2334
|
-
type DefineRoomOptions<T extends RoomOptions = RoomOptions> =
|
|
2335
|
-
Partial<Pick<Room<T>, RoomLifecycleMethods>> &
|
|
2336
|
-
{ state?: ExtractRoomState<T> | (() => ExtractRoomState<T>); } &
|
|
2337
|
-
ThisType<Exclude<Room<T>, RoomLifecycleMethods>> &
|
|
2338
|
-
ThisType<Room<T>>
|
|
2339
|
-
;
|
|
2340
|
-
|
|
2341
|
-
export function room<T>(options: DefineRoomOptions<T>) {
|
|
2342
|
-
class _ extends Room<T> {
|
|
2343
|
-
messages = options.messages;
|
|
2344
|
-
|
|
2345
|
-
constructor() {
|
|
2346
|
-
super();
|
|
2347
|
-
if (options.state && typeof options.state === 'function') {
|
|
2348
|
-
this.state = options.state();
|
|
2349
|
-
}
|
|
2350
|
-
}
|
|
2351
|
-
}
|
|
2352
|
-
|
|
2353
|
-
// Copy all methods to the prototype
|
|
2354
|
-
for (const key in options) {
|
|
2355
|
-
if (typeof options[key] === 'function') {
|
|
2356
|
-
_.prototype[key] = options[key];
|
|
2357
|
-
}
|
|
2358
|
-
}
|
|
2359
|
-
|
|
2360
|
-
return _ as typeof Room<T>;
|
|
2361
2322
|
}
|