@colyseus/core 0.17.42 → 0.18.0
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 +19 -6
- package/build/MatchMaker.cjs.map +2 -2
- package/build/MatchMaker.d.ts +10 -0
- package/build/MatchMaker.mjs +18 -6
- 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/Room.cjs +296 -19
- package/build/Room.cjs.map +3 -3
- package/build/Room.d.ts +186 -3
- package/build/Room.mjs +303 -21
- package/build/Room.mjs.map +3 -3
- 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 +40 -7
- package/build/Server.cjs.map +2 -2
- package/build/Server.d.ts +25 -0
- package/build/Server.mjs +41 -8
- package/build/Server.mjs.map +2 -2
- package/build/Transport.cjs +38 -2
- package/build/Transport.cjs.map +2 -2
- package/build/Transport.d.ts +40 -4
- package/build/Transport.mjs +38 -2
- package/build/Transport.mjs.map +2 -2
- package/build/index.cjs +11 -2
- package/build/index.cjs.map +2 -2
- package/build/index.d.ts +2 -1
- package/build/index.mjs +12 -2
- package/build/index.mjs.map +2 -2
- package/build/input/InputBuffer.cjs +113 -0
- package/build/input/InputBuffer.cjs.map +7 -0
- package/build/input/InputBuffer.d.ts +136 -0
- package/build/input/InputBuffer.mjs +86 -0
- package/build/input/InputBuffer.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/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/index.cjs +65 -4
- package/build/router/index.cjs.map +2 -2
- package/build/router/index.d.ts +30 -6
- package/build/router/index.mjs +66 -6
- package/build/router/index.mjs.map +3 -3
- 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/package.json +19 -14
- package/src/MatchMaker.ts +40 -6
- package/src/Protocol.ts +130 -59
- package/src/Room.ts +475 -22
- package/src/RoomPlugin.ts +563 -0
- package/src/Server.ts +72 -11
- package/src/Transport.ts +76 -8
- package/src/index.ts +10 -1
- package/src/input/InputBuffer.ts +192 -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/RelayRoom.ts +9 -15
- package/src/router/index.ts +112 -11
- package/src/utils/UserSessionIndex.ts +311 -0
package/src/Room.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import { unpack } from '
|
|
2
|
-
import { decode, type Iterator, $changes } from '@colyseus/schema';
|
|
1
|
+
import { unpack } from 'msgpackr';
|
|
2
|
+
import { decode, Encoder, Reflection, type Iterator, $changes } from '@colyseus/schema';
|
|
3
|
+
import { InputDecoder } from '@colyseus/schema/input';
|
|
4
|
+
import { type InputAccessor, type InputAPI, type InputOptions, type NumericFieldsOf, InputAccessorImpl, InputBufferImpl, NO_OP_INPUT_ACCESSOR } from './input/InputBuffer.ts';
|
|
5
|
+
export { type InputAccessor, type InputAPI, type InputOptions, type NumericFieldsOf } from './input/InputBuffer.ts';
|
|
6
|
+
|
|
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>();
|
|
3
13
|
import { ClockTimer as Clock } from '@colyseus/timer';
|
|
4
14
|
|
|
5
15
|
import { EventEmitter } from 'events';
|
|
@@ -28,16 +38,43 @@ import * as matchMaker from './MatchMaker.ts';
|
|
|
28
38
|
import {
|
|
29
39
|
CloseCode,
|
|
30
40
|
ErrorCode,
|
|
41
|
+
HandshakeSection,
|
|
31
42
|
Protocol,
|
|
43
|
+
ResponseStatus,
|
|
32
44
|
type MessageHandlerWithFormat as SharedMessageHandlerWithFormat,
|
|
33
45
|
type MessageHandler as SharedMessageHandler,
|
|
34
46
|
type Messages as SharedMessages,
|
|
35
47
|
} from '@colyseus/shared-types';
|
|
36
48
|
|
|
49
|
+
import {
|
|
50
|
+
RoomPlugin,
|
|
51
|
+
setupRoomPlugins,
|
|
52
|
+
type PluginLayout,
|
|
53
|
+
} from './RoomPlugin.ts';
|
|
54
|
+
export {
|
|
55
|
+
RoomPlugin,
|
|
56
|
+
definePlugins,
|
|
57
|
+
attachToTestRoom,
|
|
58
|
+
type RoomPluginOrder,
|
|
59
|
+
} from './RoomPlugin.ts';
|
|
60
|
+
|
|
37
61
|
const DEFAULT_PATCH_RATE = 1000 / 20; // 20fps (50ms)
|
|
38
62
|
const DEFAULT_SIMULATION_INTERVAL = 1000 / 60; // 60fps (16.66ms)
|
|
39
63
|
const noneSerializer = new NoneSerializer();
|
|
40
64
|
|
|
65
|
+
// Shape an Error (or thrown value) into a plain, msgpack-friendly object for a
|
|
66
|
+
// ROOM_RESPONSE error payload. Only `name`/`message`/`code` cross the wire —
|
|
67
|
+
// stacks stay on the server.
|
|
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
|
+
}
|
|
77
|
+
|
|
41
78
|
export const DEFAULT_SEAT_RESERVATION_TIME = Number(process.env.COLYSEUS_SEAT_RESERVATION_TIME || 15);
|
|
42
79
|
|
|
43
80
|
export type SimulationCallback = (deltaTime: number) => void;
|
|
@@ -46,12 +83,23 @@ export interface RoomOptions {
|
|
|
46
83
|
state?: object;
|
|
47
84
|
metadata?: any;
|
|
48
85
|
client?: Client;
|
|
86
|
+
/**
|
|
87
|
+
* Schema class for client→server input packets. When set, the Room
|
|
88
|
+
* allocates one instance per joining client and binds an InputDecoder.
|
|
89
|
+
* Must be a flat Schema (primitive fields only — see InputEncoder docs).
|
|
90
|
+
*
|
|
91
|
+
* Typed loosely (no `Schema` constraint) to avoid type-identity clashes
|
|
92
|
+
* when the user's app loads a different copy of `@colyseus/schema` than
|
|
93
|
+
* `@colyseus/core` does. Runtime validation happens via the encoder.
|
|
94
|
+
*/
|
|
95
|
+
input?: any;
|
|
49
96
|
}
|
|
50
97
|
|
|
51
98
|
// Helper types to extract individual properties from RoomOptions
|
|
52
99
|
export type ExtractRoomState<T> = T extends { state?: infer S extends object } ? S : any;
|
|
53
100
|
export type ExtractRoomMetadata<T> = T extends { metadata?: infer M } ? M : any;
|
|
54
101
|
export type ExtractRoomClient<T> = T extends { client?: infer C extends Client } ? C : Client;
|
|
102
|
+
export type ExtractRoomInput<T> = T extends { input?: infer I } ? I : never;
|
|
55
103
|
|
|
56
104
|
export interface IBroadcastOptions extends ISendOptions {
|
|
57
105
|
except?: Client | Client[];
|
|
@@ -254,6 +302,47 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
254
302
|
|
|
255
303
|
public messages?: Messages<any>;
|
|
256
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Room plugins, keyed by an operator-chosen handle. Each plugin
|
|
307
|
+
* contributes any subset of: declarative message handlers (merged
|
|
308
|
+
* into `this.messages`), lifecycle hooks (composed with the room's
|
|
309
|
+
* own), and public methods callable via `this.plugins.<key>.X()`.
|
|
310
|
+
*
|
|
311
|
+
* The framework walks this record once per Room subclass to compute
|
|
312
|
+
* the lifecycle/message layout and install hook wrappers on the
|
|
313
|
+
* class prototype; subsequent constructs reuse the cached layout
|
|
314
|
+
* and just inject `.room` + merge messages.
|
|
315
|
+
*
|
|
316
|
+
* Use `definePlugins({...})` so TypeScript preserves each plugin's
|
|
317
|
+
* literal instance type. Frozen after `__init`.
|
|
318
|
+
*/
|
|
319
|
+
public plugins?: any;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Auto-included plugin instances pulled in via `static
|
|
323
|
+
* dependencies` declarations on user-registered plugins. Kept
|
|
324
|
+
* separate from `this.plugins` so the user's typed view doesn't
|
|
325
|
+
* gain framework-managed keys. Sentinel-keyed (`__dep:<ClassName>`)
|
|
326
|
+
* so the hook wrappers can route lookups to the right map.
|
|
327
|
+
*
|
|
328
|
+
* @internal
|
|
329
|
+
*/
|
|
330
|
+
public _autoPlugins?: Record<string, RoomPlugin<any>>;
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Layout cache populated on the FIRST construction of each Room
|
|
334
|
+
* subclass. Holds the precomputed hook participation order + message
|
|
335
|
+
* key → plugin key mapping. Stored on the constructor (a static field)
|
|
336
|
+
* so all instances of the same class share it.
|
|
337
|
+
*
|
|
338
|
+
* `null` is a sentinel meaning "no plugins on this class" — distinct
|
|
339
|
+
* from `undefined` ("not yet computed") so we don't re-walk an empty
|
|
340
|
+
* plugin record on every construct.
|
|
341
|
+
*
|
|
342
|
+
* @internal
|
|
343
|
+
*/
|
|
344
|
+
static __pluginLayout?: PluginLayout | null;
|
|
345
|
+
|
|
257
346
|
private onMessageEvents = createNanoEvents();
|
|
258
347
|
private onMessageValidators: {[message: string]: StandardSchemaV1} = {};
|
|
259
348
|
|
|
@@ -379,6 +468,15 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
379
468
|
this.state = this.#_state;
|
|
380
469
|
}
|
|
381
470
|
|
|
471
|
+
// Wire room plugins from the instance-level `this.plugins` record.
|
|
472
|
+
// The heavy lifting (conflict detection, hook participation, hook
|
|
473
|
+
// wrapping on the prototype) runs once per class — see
|
|
474
|
+
// `setupRoomPlugins` in `./RoomPlugin.ts`. Per-instance: set
|
|
475
|
+
// `plugin.room = this`, instantiate auto-deps, merge messages.
|
|
476
|
+
if (this.plugins !== undefined) {
|
|
477
|
+
setupRoomPlugins(this);
|
|
478
|
+
}
|
|
479
|
+
|
|
382
480
|
// Bind messages to the room
|
|
383
481
|
if (this.messages !== undefined) {
|
|
384
482
|
|
|
@@ -405,6 +503,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
405
503
|
this.clock.start();
|
|
406
504
|
}
|
|
407
505
|
|
|
506
|
+
|
|
408
507
|
/**
|
|
409
508
|
* The name of the room you provided as first argument for `gameServer.define()`.
|
|
410
509
|
*
|
|
@@ -489,6 +588,86 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
489
588
|
*/
|
|
490
589
|
public onLeave?(client: ExtractRoomClient<T>, code?: number): void | Promise<any>;
|
|
491
590
|
|
|
591
|
+
/**
|
|
592
|
+
* Per-client input accessor. Set by `defineInput()`. Call `room.input(sessionId)`
|
|
593
|
+
* each tick to read the latest decoded input and/or the buffered snapshot ring
|
|
594
|
+
* for that client.
|
|
595
|
+
*
|
|
596
|
+
* @example
|
|
597
|
+
* ```typescript
|
|
598
|
+
* class FpsRoom extends Room<{ input: MoveInput }> {
|
|
599
|
+
* input = this.defineInput(MoveInput);
|
|
600
|
+
*
|
|
601
|
+
* onCreate() {
|
|
602
|
+
* this.setSimulationInterval(() => {
|
|
603
|
+
* for (const c of this.clients) {
|
|
604
|
+
* const input = this.input(c.sessionId);
|
|
605
|
+
* if (input.latest) this.apply(c, input.latest);
|
|
606
|
+
* // for rollback / lockstep:
|
|
607
|
+
* // const snapshot = input.at(this.clock.ticks);
|
|
608
|
+
* // for (const snapshot of input.drain()) ...
|
|
609
|
+
* }
|
|
610
|
+
* }, 1000 / 30);
|
|
611
|
+
* }
|
|
612
|
+
* }
|
|
613
|
+
* ```
|
|
614
|
+
*/
|
|
615
|
+
public input?: InputAPI<ExtractRoomInput<T>>;
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Input configuration. Set via {@link defineInput} only.
|
|
619
|
+
* @internal
|
|
620
|
+
*/
|
|
621
|
+
private inputOptions?: InputOptions;
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Declare the input schema and configuration in a single line. Returns the
|
|
625
|
+
* callable accessor that gets assigned to `this.input` — call
|
|
626
|
+
* `this.input(sessionId)` per tick to consume.
|
|
627
|
+
*
|
|
628
|
+
* ```typescript
|
|
629
|
+
* class FpsRoom extends Room<{ input: MoveInput }> {
|
|
630
|
+
* input = this.defineInput(MoveInput, {
|
|
631
|
+
* seqField: "tick", // typed: only numeric fields of MoveInput
|
|
632
|
+
* bufferMaxSize: 64,
|
|
633
|
+
* });
|
|
634
|
+
*
|
|
635
|
+
* // …or without options — defaults to seqField: "seq", bufferMaxSize: 32:
|
|
636
|
+
* // input = this.defineInput(MoveInput);
|
|
637
|
+
* }
|
|
638
|
+
* ```
|
|
639
|
+
*
|
|
640
|
+
* **Defaults** when `opts` (or individual fields) are omitted:
|
|
641
|
+
* - `seqField`: `"seq"` — framework dedupes by `input.seq` if the schema has
|
|
642
|
+
* such a field. Schemas without it gracefully skip dedupe.
|
|
643
|
+
* - `bufferMaxSize`: `32` — enables per-client snapshot buffering for
|
|
644
|
+
* `room.input(sessionId).drain() / .peek() / .at()`. Set to `0` to disable
|
|
645
|
+
* buffering (the `.latest` read still works).
|
|
646
|
+
*/
|
|
647
|
+
protected defineInput<C extends new () => any>(
|
|
648
|
+
type: C,
|
|
649
|
+
opts?: {
|
|
650
|
+
seqField?: NumericFieldsOf<InstanceType<C>>;
|
|
651
|
+
bufferMaxSize?: number;
|
|
652
|
+
},
|
|
653
|
+
): InputAPI<InstanceType<C>> {
|
|
654
|
+
this.inputOptions = {
|
|
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
|
+
});
|
|
669
|
+
}
|
|
670
|
+
|
|
492
671
|
/**
|
|
493
672
|
* This method is called when the room is disposed.
|
|
494
673
|
*/
|
|
@@ -653,6 +832,51 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
653
832
|
}
|
|
654
833
|
}
|
|
655
834
|
|
|
835
|
+
/**
|
|
836
|
+
* Run a fixed-rate simulation tagged with a monotonic server tick number.
|
|
837
|
+
* Combine with `room.input(sessionId).at(tick)` to retrieve each client's
|
|
838
|
+
* input *for a specific tick* — the building block for lockstep / rollback
|
|
839
|
+
* netcode.
|
|
840
|
+
*
|
|
841
|
+
* Replaces any previous {@link setSimulationInterval}. The current tick is
|
|
842
|
+
* exposed via {@link tick}.
|
|
843
|
+
*
|
|
844
|
+
* @example
|
|
845
|
+
* ```typescript
|
|
846
|
+
* class LockstepRoom extends Room<{ input: MoveInput }> {
|
|
847
|
+
* input = this.defineInput(MoveInput, { seqField: "tick", bufferMaxSize: 64 });
|
|
848
|
+
*
|
|
849
|
+
* onCreate() {
|
|
850
|
+
* this.setTickedSimulation((tick, dt) => {
|
|
851
|
+
* for (const c of this.clients) {
|
|
852
|
+
* const snapshot = this.input(c.sessionId).at(tick);
|
|
853
|
+
* if (snapshot) this.apply(c, snapshot);
|
|
854
|
+
* // else: predict, freeze, etc. — game-level decision
|
|
855
|
+
* }
|
|
856
|
+
* }, 1000 / 60);
|
|
857
|
+
* }
|
|
858
|
+
* }
|
|
859
|
+
* ```
|
|
860
|
+
*/
|
|
861
|
+
public setTickedSimulation(
|
|
862
|
+
onTickCallback: (tick: number, deltaTime: number) => void,
|
|
863
|
+
delay: number = DEFAULT_SIMULATION_INTERVAL,
|
|
864
|
+
startTick: number = 0,
|
|
865
|
+
): void {
|
|
866
|
+
this.#_tick = startTick;
|
|
867
|
+
this.setSimulationInterval((dt) => {
|
|
868
|
+
onTickCallback(this.#_tick, dt);
|
|
869
|
+
this.#_tick++;
|
|
870
|
+
}, delay);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Current server tick. Incremented by {@link setTickedSimulation} after each
|
|
875
|
+
* tick callback returns. Returns 0 when no ticked simulation is running.
|
|
876
|
+
*/
|
|
877
|
+
public get tick(): number { return this.#_tick; }
|
|
878
|
+
#_tick: number = 0;
|
|
879
|
+
|
|
656
880
|
/**
|
|
657
881
|
* @deprecated Use `.patchRate=` instead.
|
|
658
882
|
*/
|
|
@@ -671,21 +895,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
671
895
|
this._serializer = serializer;
|
|
672
896
|
}
|
|
673
897
|
|
|
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
|
-
}
|
|
898
|
+
public async setMetadata(meta: ExtractRoomMetadata<T>, persist: boolean = true) {
|
|
899
|
+
this._listing.metadata = meta;
|
|
689
900
|
|
|
690
901
|
if (persist && this._internalState === RoomInternalState.CREATED) {
|
|
691
902
|
await matchMaker.driver.persist(this._listing);
|
|
@@ -735,7 +946,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
735
946
|
*
|
|
736
947
|
* @example
|
|
737
948
|
* ```typescript
|
|
738
|
-
* //
|
|
949
|
+
* // Merging with existing metadata: spread `this.metadata` yourself.
|
|
950
|
+
* // `metadata` is always REPLACED (not merged) by setMatchmaking()/setMetadata().
|
|
739
951
|
* await this.setMatchmaking({
|
|
740
952
|
* metadata: { ...this.metadata, round: this.metadata.round + 1 }
|
|
741
953
|
* });
|
|
@@ -1042,6 +1254,117 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1042
1254
|
}
|
|
1043
1255
|
}
|
|
1044
1256
|
|
|
1257
|
+
// ---------------------------------------------------------------------------
|
|
1258
|
+
// Operator API — used by @colyseus/admin (and monitor in due course)
|
|
1259
|
+
// through `remoteRoomCall(roomId, methodName)`. Marked `@internal` because
|
|
1260
|
+
// they're framework-tooling primitives, not part of the game-code surface.
|
|
1261
|
+
// ---------------------------------------------------------------------------
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Snapshot the room's live state for an inspector / admin UI. Includes:
|
|
1265
|
+
*
|
|
1266
|
+
* roomId, name, maxClients, locked, elapsedTime (ms),
|
|
1267
|
+
* metadata, clients (sessionId + per-client elapsed + userId when set),
|
|
1268
|
+
* state (the schema/json the SDK would see),
|
|
1269
|
+
* stateSize (bytes of the encoded full state, or 0 when no serializer).
|
|
1270
|
+
*
|
|
1271
|
+
* The payload is intentionally plain JSON — `remoteRoomCall` serializes
|
|
1272
|
+
* the return value across process boundaries.
|
|
1273
|
+
*
|
|
1274
|
+
* @internal Operator-only. Game code should not call this.
|
|
1275
|
+
*/
|
|
1276
|
+
public getInspectorView(): {
|
|
1277
|
+
roomId: string;
|
|
1278
|
+
name: string;
|
|
1279
|
+
clients: number;
|
|
1280
|
+
maxClients: number;
|
|
1281
|
+
locked: boolean;
|
|
1282
|
+
elapsedTime: number;
|
|
1283
|
+
metadata: any;
|
|
1284
|
+
clientList: Array<{
|
|
1285
|
+
sessionId: string;
|
|
1286
|
+
userId: string | null;
|
|
1287
|
+
userEmail: string | null;
|
|
1288
|
+
elapsedTime: number;
|
|
1289
|
+
}>;
|
|
1290
|
+
state: any;
|
|
1291
|
+
stateSize: number;
|
|
1292
|
+
} {
|
|
1293
|
+
const elapsed = this.clock.elapsedTime;
|
|
1294
|
+
return {
|
|
1295
|
+
roomId: this.roomId,
|
|
1296
|
+
name: this.roomName,
|
|
1297
|
+
clients: this.clients.length,
|
|
1298
|
+
maxClients: this.maxClients,
|
|
1299
|
+
locked: this.#_locked,
|
|
1300
|
+
elapsedTime: elapsed,
|
|
1301
|
+
metadata: this.metadata ?? null,
|
|
1302
|
+
// Cast through `unknown`: `ExtractRoomClient<T>` vs `Client & ClientPrivate`
|
|
1303
|
+
// don't structurally overlap (the user's `client` generic may carry a
|
|
1304
|
+
// narrower userData/auth shape), but the runtime objects we walk here
|
|
1305
|
+
// always have the private join-time field. The narrow per-property
|
|
1306
|
+
// `(c as any)` reads below keep the cast scoped.
|
|
1307
|
+
//
|
|
1308
|
+
// userId / userEmail read straight off `client.auth` — the JWT
|
|
1309
|
+
// payload @colyseus/auth's default onAuth decodes carries both
|
|
1310
|
+
// when the user has them on file. Saves the admin a per-client
|
|
1311
|
+
// database round-trip; falls back to null when the client signed
|
|
1312
|
+
// in anonymously (or with a custom onAuth that returns a shape
|
|
1313
|
+
// without those fields).
|
|
1314
|
+
clientList: (this.clients as unknown as ReadonlyArray<Client & ClientPrivate>).map((c) => {
|
|
1315
|
+
const auth = (c as any).auth;
|
|
1316
|
+
return {
|
|
1317
|
+
sessionId: c.sessionId,
|
|
1318
|
+
userId: ((c as any).userId ?? auth?.id) ?? null,
|
|
1319
|
+
userEmail: auth?.email ?? null,
|
|
1320
|
+
elapsedTime: elapsed - ((c as any)._joinedAt ?? elapsed),
|
|
1321
|
+
};
|
|
1322
|
+
}),
|
|
1323
|
+
state: this.state ?? null,
|
|
1324
|
+
stateSize: this.#_inspectorStateSize(),
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* Force-disconnect a single client by sessionId. No-op when the client
|
|
1330
|
+
* isn't connected (idempotent — the caller doesn't need to race-check).
|
|
1331
|
+
*
|
|
1332
|
+
* @internal Operator-only. Game code disconnects clients by calling
|
|
1333
|
+
* `.leave()` on the Client object directly.
|
|
1334
|
+
*/
|
|
1335
|
+
public kickClient(sessionId: string, closeCode: number = CloseCode.CONSENTED, reason?: string): void {
|
|
1336
|
+
// Same `unknown` indirection as in `getInspectorView` above —
|
|
1337
|
+
// ExtractRoomClient<T> doesn't structurally include ClientPrivate.
|
|
1338
|
+
for (const client of this.clients as unknown as ReadonlyArray<Client & ClientPrivate>) {
|
|
1339
|
+
if (client.sessionId === sessionId) {
|
|
1340
|
+
this.#_forciblyCloseClient(client as ExtractRoomClient<T> & ClientPrivate, closeCode, reason);
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* Best-effort byte size of the current full state. Falls back to `0`
|
|
1348
|
+
* when the room has no serializer or the serializer can't produce a
|
|
1349
|
+
* payload (raw rooms, very-early-onCreate, etc.). The serializer
|
|
1350
|
+
* detection mirrors `@colyseus/monitor`'s — we read whichever buffer
|
|
1351
|
+
* is available across schema v2 and v3.
|
|
1352
|
+
*/
|
|
1353
|
+
#_inspectorStateSize(): number {
|
|
1354
|
+
const ser = this._serializer as any;
|
|
1355
|
+
if (!ser) { return 0; }
|
|
1356
|
+
const hasState = ser.encoder || ser.state;
|
|
1357
|
+
if (!hasState) { return 0; }
|
|
1358
|
+
try {
|
|
1359
|
+
const full = ser.getFullState?.();
|
|
1360
|
+
if (!full) { return 0; }
|
|
1361
|
+
// Buffer / Uint8Array have `byteLength`; raw arrays have `length`.
|
|
1362
|
+
return (full as any).byteLength ?? (full as any).length ?? 0;
|
|
1363
|
+
} catch {
|
|
1364
|
+
return 0;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1045
1368
|
/**
|
|
1046
1369
|
* Disconnect all connected clients, and then dispose the room.
|
|
1047
1370
|
*
|
|
@@ -1103,6 +1426,19 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1103
1426
|
// (each new reconnection receives a new reconnection token)
|
|
1104
1427
|
client.reconnectionToken = generateId();
|
|
1105
1428
|
|
|
1429
|
+
// Allocate per-client input instance + decoder if the Room called `defineInput()`.
|
|
1430
|
+
// Done early so onJoin can reference room.input(sessionId).latest.
|
|
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
|
+
}
|
|
1441
|
+
|
|
1106
1442
|
if (this._reservedSeatTimeouts[sessionId]) {
|
|
1107
1443
|
clearTimeout(this._reservedSeatTimeouts[sessionId]);
|
|
1108
1444
|
delete this._reservedSeatTimeouts[sessionId];
|
|
@@ -1289,6 +1625,17 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1289
1625
|
// allow client to send messages after onJoin has succeeded.
|
|
1290
1626
|
client.ref.on('message', this._onMessage.bind(this, client));
|
|
1291
1627
|
|
|
1628
|
+
// Append input reflection as a tagged section when the room declared
|
|
1629
|
+
// input. The SDK uses these bytes to materialize a constructor for
|
|
1630
|
+
// `conn.input()` calls that don't pass an explicit `type`.
|
|
1631
|
+
let extraSections: Array<{ tag: number; bytes: Uint8Array }> | undefined;
|
|
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
|
+
}
|
|
1638
|
+
|
|
1292
1639
|
// confirm room id that matches the room name requested to join
|
|
1293
1640
|
client.raw(getMessageBytes[Protocol.JOIN_ROOM](
|
|
1294
1641
|
client.reconnectionToken,
|
|
@@ -1300,6 +1647,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1300
1647
|
(connectionOptions?.skipHandshake)
|
|
1301
1648
|
? undefined
|
|
1302
1649
|
: this._serializer.handshake && this._serializer.handshake(),
|
|
1650
|
+
extraSections,
|
|
1303
1651
|
));
|
|
1304
1652
|
}
|
|
1305
1653
|
}
|
|
@@ -1404,7 +1752,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1404
1752
|
// switching Wi-Fi), as the original connection may still be open while a
|
|
1405
1753
|
// new reconnection attempt is being made.
|
|
1406
1754
|
//
|
|
1407
|
-
if (this._reconnectionAttempts[reconnectionToken]) {
|
|
1755
|
+
if (this._reconnectionAttempts[reconnectionToken] !== undefined) {
|
|
1408
1756
|
debugMatchMaking('resolving reconnection attempt for client - sessionId: \'%s\', roomId: \'%s\'', sessionId, this.roomId);
|
|
1409
1757
|
this._reconnectionAttempts[reconnectionToken].resolve(true);
|
|
1410
1758
|
}
|
|
@@ -1448,6 +1796,13 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1448
1796
|
}
|
|
1449
1797
|
}
|
|
1450
1798
|
|
|
1799
|
+
// Encode and enqueue a ROOM_RESPONSE for a client request. If the client has
|
|
1800
|
+
// already left, `enqueueRaw` is a no-op — no response is needed.
|
|
1801
|
+
#replyToRequest(client: Client, requestId: number, status: ResponseStatus, payload?: any) {
|
|
1802
|
+
debugMessage("response #%d: status=%d -> %j (roomId: %s)", requestId, status, payload, this.roomId);
|
|
1803
|
+
client.enqueueRaw(getMessageBytes[Protocol.ROOM_RESPONSE](requestId, status, payload));
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1451
1806
|
private sendFullState(client: Client): void {
|
|
1452
1807
|
client.raw(this._serializer.getFullState(client));
|
|
1453
1808
|
}
|
|
@@ -1595,6 +1950,23 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1595
1950
|
return await (userReturnData || Promise.resolve());
|
|
1596
1951
|
}
|
|
1597
1952
|
|
|
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);
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1598
1970
|
private _onMessage(client: ExtractRoomClient<T> & ClientPrivate, buffer: Buffer) {
|
|
1599
1971
|
// skip if client is on LEAVING state.
|
|
1600
1972
|
if (client.state === ClientState.LEAVING) { return; }
|
|
@@ -1650,6 +2022,67 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1650
2022
|
this.onMessageFallbacks['__no_message_handler'](client, messageType, message);
|
|
1651
2023
|
}
|
|
1652
2024
|
|
|
2025
|
+
} else if (code === Protocol.ROOM_REQUEST) {
|
|
2026
|
+
// A request reuses the same `onMessage(type, ...)` handlers as a plain
|
|
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
|
+
);
|
|
2085
|
+
|
|
1653
2086
|
} else if (code === Protocol.ROOM_DATA_BYTES) {
|
|
1654
2087
|
const messageType = (decode.stringCheck(buffer, it))
|
|
1655
2088
|
? decode.string(buffer, it)
|
|
@@ -1681,6 +2114,27 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1681
2114
|
this.onMessageFallbacks['__no_message_handler'](client, messageType, message);
|
|
1682
2115
|
}
|
|
1683
2116
|
|
|
2117
|
+
} else if (code === Protocol.ROOM_INPUT_RELIABLE) {
|
|
2118
|
+
if (client._inputDecoder) {
|
|
2119
|
+
try {
|
|
2120
|
+
client._inputDecoder.decode(buffer.subarray(1));
|
|
2121
|
+
} catch (e: any) {
|
|
2122
|
+
debugAndPrintError(e);
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
this.#captureInput(client);
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
} else if (code === Protocol.ROOM_INPUT_UNRELIABLE) {
|
|
2129
|
+
if (client._inputDecoder) {
|
|
2130
|
+
try {
|
|
2131
|
+
client._inputDecoder.decodeAll(buffer.subarray(1), () => this.#captureInput(client));
|
|
2132
|
+
} catch (e: any) {
|
|
2133
|
+
debugAndPrintError(e);
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
1684
2138
|
} else if (code === Protocol.JOIN_ROOM && client.state === ClientState.JOINING) {
|
|
1685
2139
|
// join room has been acknowledged by the client
|
|
1686
2140
|
client.state = ClientState.JOINED;
|
|
@@ -1705,7 +2159,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1705
2159
|
}
|
|
1706
2160
|
}
|
|
1707
2161
|
|
|
1708
|
-
#_forciblyCloseClient(client: ExtractRoomClient<T> & ClientPrivate, closeCode: number) {
|
|
2162
|
+
#_forciblyCloseClient(client: ExtractRoomClient<T> & ClientPrivate, closeCode: number, reason?: string) {
|
|
1709
2163
|
// stop receiving messages from this client
|
|
1710
2164
|
client.ref.removeAllListeners('message');
|
|
1711
2165
|
|
|
@@ -1713,7 +2167,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1713
2167
|
client.ref.removeListener('close', client.ref['onleave']);
|
|
1714
2168
|
|
|
1715
2169
|
// only effectively close connection when "onLeave" is fulfilled
|
|
1716
|
-
this._onLeave(client, closeCode).then(() => client.leave(closeCode));
|
|
2170
|
+
this._onLeave(client, closeCode).then(() => (client as any).leave(closeCode, reason));
|
|
1717
2171
|
}
|
|
1718
2172
|
|
|
1719
2173
|
private async _onLeave(client: ExtractRoomClient<T>, code?: number): Promise<any> {
|
|
@@ -1771,7 +2225,6 @@ export class Room<T extends RoomOptions = RoomOptions> {
|
|
|
1771
2225
|
if (this._reservedSeats[client.sessionId] === undefined) {
|
|
1772
2226
|
this._events.emit('leave', client, willDispose);
|
|
1773
2227
|
}
|
|
1774
|
-
|
|
1775
2228
|
}
|
|
1776
2229
|
|
|
1777
2230
|
async #_incrementClientCount() {
|