@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
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the room input subsystem: the option shapes accepted by
|
|
3
|
+
* `Room.defineInput()`, the per-client {@link InputAccessor} / room-wide
|
|
4
|
+
* {@link InputAPI} surfaces, and the internal normalized options. Runtime
|
|
5
|
+
* lives in `InputBuffer.ts`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Names of fields on `I` whose values are `number` — used by
|
|
10
|
+
* `Room.defineInput()` to constrain `seqField` to actually-numeric fields
|
|
11
|
+
* on the input schema. Filters out booleans, strings, methods, etc.
|
|
12
|
+
*/
|
|
13
|
+
export type NumericFieldsOf<I> = {
|
|
14
|
+
[K in keyof I]: I[K] extends number ? (K extends string ? K : never) : never;
|
|
15
|
+
}[keyof I];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Context handed to the `idle` callback form — see {@link IdleInput}. A reused
|
|
19
|
+
* per-client scratch: read it inside the callback, don't store it. It carries
|
|
20
|
+
* MECHANISM only — derived judgments (e.g. liveness) stay in userland; look the
|
|
21
|
+
* client up via `this.clients.get(ctx.sessionId)` when your policy needs it.
|
|
22
|
+
*/
|
|
23
|
+
export interface IdleContext<I> {
|
|
24
|
+
/** Last decoded input (`undefined` before the client's first). */
|
|
25
|
+
latest: I | undefined;
|
|
26
|
+
sessionId: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What to synthesize when the buffer is empty (declared room-wide at
|
|
31
|
+
* `defineInput({ idle })`, or per-call via {@link ConsumeOptions.idle}):
|
|
32
|
+
* - `true` — pure schema defaults.
|
|
33
|
+
* - `Partial<I>` — defaults overlaid with these fields. A full schema instance
|
|
34
|
+
* (e.g. {@link InputAccessor.latest}) also works — fields are copied BY NAME,
|
|
35
|
+
* so its prototype accessors are read correctly.
|
|
36
|
+
* - callback — invoked LAZILY (only on an actually-empty tick) with an
|
|
37
|
+
* {@link IdleContext}, returning either of the above. The right form when the
|
|
38
|
+
* overrides take work to compute (entity lookups, held-button carry-over).
|
|
39
|
+
*
|
|
40
|
+
* ⚠ Do NOT build overrides by spreading a schema instance (`{ ...latest, x }`):
|
|
41
|
+
* schema fields live on the prototype, so the spread copies NOTHING of them.
|
|
42
|
+
* Return `latest` itself, or name the fields (`{ x, plant: !!latest?.plant }`).
|
|
43
|
+
*/
|
|
44
|
+
export type IdleInput<I> = true | Partial<I> | ((ctx: IdleContext<I>) => true | Partial<I>);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Options for {@link InputAccessor.drain} / {@link InputAccessor.next}.
|
|
48
|
+
* `idle` overrides the room-level `defineInput({ idle })` policy for this call
|
|
49
|
+
* (see {@link IdleInput}); pass `false` to suppress it (force skip behavior).
|
|
50
|
+
*/
|
|
51
|
+
export interface ConsumeOptions<I> {
|
|
52
|
+
idle?: IdleInput<I> | false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Input sanitization, declared at `defineInput({ sanitize })` — never trust the
|
|
57
|
+
* wire. Applied to each decoded frame IN PLACE, before anything reads it
|
|
58
|
+
* (`latest`, the buffer, the `idle` callback's ctx):
|
|
59
|
+
* - **Map form** — per-field `[min, max]` range clamps with NaN-safe semantics:
|
|
60
|
+
* `NaN` (both comparisons false) lands on `min`, closing the classic
|
|
61
|
+
* `Math.min(NaN, …)` poisoning hole.
|
|
62
|
+
* - **Callback form** — arbitrary in-place fix-up (wrap an angle, enforce a
|
|
63
|
+
* cross-field rule) for anything beyond ranges.
|
|
64
|
+
*
|
|
65
|
+
* Sanitizers MODIFY, they never reject — a malformed value becomes a legal one
|
|
66
|
+
* instead of dropping the frame. Must not touch the `seqField` (it runs before
|
|
67
|
+
* dedupe). Semantic validation ("slot must name an owned weapon") stays in
|
|
68
|
+
* your sim — this handles value domains, not game rules.
|
|
69
|
+
*/
|
|
70
|
+
export type SanitizeInput<I> =
|
|
71
|
+
| Partial<Record<NumericFieldsOf<I>, readonly [number, number]>>
|
|
72
|
+
| ((input: I) => void);
|
|
73
|
+
|
|
74
|
+
/** Options for `Room.defineInput()`. The user-facing twin of the internal
|
|
75
|
+
* normalized {@link NormalizedInputOptions}. */
|
|
76
|
+
export interface DefineInputOptions<I = any> {
|
|
77
|
+
seqField?: NumericFieldsOf<I>;
|
|
78
|
+
bufferMaxSize?: number;
|
|
79
|
+
/**
|
|
80
|
+
* Input sanitization — never trust the wire. Applied IN PLACE to every
|
|
81
|
+
* decoded frame before anything reads it (`latest`, the buffer, the `idle`
|
|
82
|
+
* ctx). Map form = per-field `[min, max]` clamps with NaN-safe semantics
|
|
83
|
+
* (NaN → min — closes the `Math.min(NaN, …)` poisoning hole); callback form
|
|
84
|
+
* = arbitrary in-place fix-up. Sanitizers MODIFY, never reject. See
|
|
85
|
+
* {@link SanitizeInput}.
|
|
86
|
+
*
|
|
87
|
+
* ```ts
|
|
88
|
+
* sanitize: { moveF: [-1, 1], pitch: [-PITCH_LIMIT, PITCH_LIMIT], dt: [0, MAX_DT] },
|
|
89
|
+
* // or: sanitize: (f) => { f.angle = wrapAngle(f.angle); },
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
sanitize?: SanitizeInput<I>;
|
|
93
|
+
/**
|
|
94
|
+
* Room-level absence policy: when a tick has no buffered input, bare
|
|
95
|
+
* `drain()` / `next()` synthesize ONE "idle" frame from it — the schema's
|
|
96
|
+
* defaults overlaid with the policy's overrides — so the sim loop needs no
|
|
97
|
+
* empty-branch. Prefer the callback form, invoked lazily (only on
|
|
98
|
+
* actually-empty ticks) with an {@link IdleContext} (`latest` + `sessionId`):
|
|
99
|
+
*
|
|
100
|
+
* ```ts
|
|
101
|
+
* idle: ({ latest, sessionId }) => {
|
|
102
|
+
* const p = this.state.players.get(sessionId); // closes over the room
|
|
103
|
+
* return p ? { yaw: p.yaw, plant: !!latest?.plant } : true;
|
|
104
|
+
* }
|
|
105
|
+
* ```
|
|
106
|
+
*
|
|
107
|
+
* Not declaring it keeps the skip behavior (`drain()` → `[]`). Per-call
|
|
108
|
+
* `{ idle }` overrides this default; `{ idle: false }` suppresses it.
|
|
109
|
+
* Synthesized frames never advance the reconcile ack or `renderTime`.
|
|
110
|
+
*/
|
|
111
|
+
idle?: IdleInput<I>;
|
|
112
|
+
/**
|
|
113
|
+
* Fixed step rate in **Hz** cascaded to the client via the join handshake;
|
|
114
|
+
* it predicts at dt = 1/tickRate for deterministic rollback. Defaults to
|
|
115
|
+
* the `setTimestep` rate — pass this only when the prediction
|
|
116
|
+
* step differs from it. NOTE: a *rate*, not an interval — `1000/30` is the
|
|
117
|
+
* step in ms, a classic mistake; use {@link stepMs} for that. Superseded by
|
|
118
|
+
* {@link stepMs} / {@link stepSeconds} when those are given.
|
|
119
|
+
*/
|
|
120
|
+
tickRate?: number;
|
|
121
|
+
/**
|
|
122
|
+
* Fixed step as a duration in **milliseconds** — the unit-safe alternative
|
|
123
|
+
* to {@link tickRate} (`stepMs: 1000/30` is unambiguous where
|
|
124
|
+
* `tickRate: 1000/30` is a bug). Normalized to the canonical Hz value
|
|
125
|
+
* (`1000/stepMs`). Takes precedence over `tickRate`.
|
|
126
|
+
*/
|
|
127
|
+
stepMs?: number;
|
|
128
|
+
/**
|
|
129
|
+
* Fixed step as a duration in **seconds** (e.g. `1/30`). Normalized to the
|
|
130
|
+
* canonical Hz value (`1/stepSeconds`). Highest precedence.
|
|
131
|
+
*/
|
|
132
|
+
stepSeconds?: number;
|
|
133
|
+
/**
|
|
134
|
+
* Physics sub-steps per input tick (integer ≥ 1, default 1) — decouples the
|
|
135
|
+
* PHYSICS rate from the input/network rate. One input still drives exactly
|
|
136
|
+
* one fixed step (the replay invariant), but the simulation integrates
|
|
137
|
+
* `subSteps` engine steps of `stepSeconds/subSteps` inside it, identically
|
|
138
|
+
* on client and server — physics at `tickRate * subSteps` Hz while sending
|
|
139
|
+
* `tickRate` inputs/sec. Cascaded to clients via the join handshake;
|
|
140
|
+
* both sides read the derived numbers off their step context
|
|
141
|
+
* (`ctx.subSteps` / `ctx.subDt`) so N and dt can't drift apart.
|
|
142
|
+
* Usually declared via {@link Room.setFixedTimestep}'s `subSteps` option
|
|
143
|
+
* instead — pass it here only when the room runs its own loop.
|
|
144
|
+
*/
|
|
145
|
+
subSteps?: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** `true` when the defineInput opts declared an `idle` policy — narrows the
|
|
149
|
+
* returned {@link InputAPI} so bare `next()` types non-optional `I`.
|
|
150
|
+
* @internal — exported for {@link RoomInput.define}. */
|
|
151
|
+
export type IdleDeclared<O, I> = O extends { idle: IdleInput<I> } ? true : false;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Internal: input configuration captured by `Room.defineInput()` — the
|
|
155
|
+
* normalized form of {@link DefineInputOptions} (step declarations resolved to
|
|
156
|
+
* Hz, sanitizer compiled). The schema constructor is stored here so the
|
|
157
|
+
* runtime doesn't need to know it through the public `room.inputs` (the
|
|
158
|
+
* `.get(sessionId)` accessor).
|
|
159
|
+
*
|
|
160
|
+
* @internal
|
|
161
|
+
*/
|
|
162
|
+
export interface NormalizedInputOptions {
|
|
163
|
+
/**
|
|
164
|
+
* Schema constructor used to allocate per-client input instances on join.
|
|
165
|
+
* Captured by `defineInput()` from its `type` argument.
|
|
166
|
+
*
|
|
167
|
+
* Typed loosely (`new () => any`) to sidestep type-identity issues across
|
|
168
|
+
* duplicate `@colyseus/schema` installs; the runtime calls
|
|
169
|
+
* `instance.clone()` and friends, which match by shape.
|
|
170
|
+
*/
|
|
171
|
+
ctor: new () => any;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Name of a monotonically-increasing numeric field on the input schema used
|
|
175
|
+
* to order and dedupe incoming frames. Unset by default (opt-in) — dedupe and
|
|
176
|
+
* `.at()` lookup are off unless you name a field here. When set, the framework:
|
|
177
|
+
* - Drops redundant frames (`input[seqField]` ≤ the last-seen value are
|
|
178
|
+
* discarded before they enter the buffer) — the unreliable-mode
|
|
179
|
+
* ring-redundancy pattern.
|
|
180
|
+
* - Powers `room.inputs.get(sessionId).at(value)` lookups.
|
|
181
|
+
*
|
|
182
|
+
* Despite the name, "seq" here is broader than an integer counter — any
|
|
183
|
+
* monotonic numeric field works:
|
|
184
|
+
* - **Sequence counter** (`"seq"`, `"tick"`, `"frame"`) — typical for
|
|
185
|
+
* lockstep / rollback netcode.
|
|
186
|
+
* - **Timestamp** (`"timestamp"`, milliseconds or seconds) — useful for
|
|
187
|
+
* variable-rate clients, lag compensation, hit registration (a float-seconds
|
|
188
|
+
* timestamp stamped on each saved move is the common shape).
|
|
189
|
+
*
|
|
190
|
+
* Whichever you use, the field must increase monotonically across frames
|
|
191
|
+
* for dedupe to work.
|
|
192
|
+
*/
|
|
193
|
+
seqField?: string;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* > 0 enables per-client buffering of cloned snapshots — required for
|
|
197
|
+
* `room.inputs.get(sessionId).drain() / .peek() / .at()` to return populated
|
|
198
|
+
* data. Oldest drops on overflow. Set to `0` to disable (`.latest` still
|
|
199
|
+
* works).
|
|
200
|
+
*/
|
|
201
|
+
bufferMaxSize: number;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Fixed step rate (Hz) advertised to clients via the join handshake; they
|
|
205
|
+
* predict at dt = 1/tickRate. Set explicitly via `defineInput`, or derived
|
|
206
|
+
* from `setTimestep`. Unset = not advertised.
|
|
207
|
+
*/
|
|
208
|
+
tickRate?: number;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Physics sub-steps per input tick (integer ≥ 1) advertised to clients via
|
|
212
|
+
* the join handshake — the simulation integrates `subSteps` engine steps of
|
|
213
|
+
* `(1/tickRate)/subSteps` per input on BOTH sides, so physics runs at
|
|
214
|
+
* `tickRate * subSteps` Hz on a `tickRate` input/network rate. Set via
|
|
215
|
+
* `setFixedTimestep(..., { subSteps })` (or `defineInput`). Unset/1 = not
|
|
216
|
+
* advertised (input rate == physics rate).
|
|
217
|
+
*/
|
|
218
|
+
subSteps?: number;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Room-level absence policy: bare `drain()` / `next()` synthesize one idle
|
|
222
|
+
* frame from it when a tick has no input. See {@link IdleInput}.
|
|
223
|
+
*/
|
|
224
|
+
idle?: IdleInput<any>;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* COMPILED sanitizer (see {@link SanitizeInput} / `compileSanitizer`) —
|
|
228
|
+
* applied in place to each decoded frame before it becomes visible to
|
|
229
|
+
* `latest` / the buffer / the idle ctx.
|
|
230
|
+
*/
|
|
231
|
+
sanitize?: (instance: any) => void;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Per-client input accessor returned by `room.inputs.get(sessionId)`. Combines the
|
|
236
|
+
* latest decoded instance with the (optional) snapshot ring buffer.
|
|
237
|
+
*
|
|
238
|
+
* - {@link latest} — the bound Schema instance, mutated in place by the
|
|
239
|
+
* decoder. Cheapest read; use when only the most recent state matters.
|
|
240
|
+
* - {@link consume} / `for (const inp of accessor)` / {@link drain} /
|
|
241
|
+
* {@link next} / {@link take} / {@link peek} / {@link at} — populated when
|
|
242
|
+
* `defineInput()` was called with `bufferMaxSize > 0` (default 32). Use for
|
|
243
|
+
* rollback netcode / lockstep where every frame matters.
|
|
244
|
+
*
|
|
245
|
+
* **Per-entity vs shared world** — pick the consume primitive by who integrates:
|
|
246
|
+
* - **Per-entity** (each body integrates itself): iterate the accessor —
|
|
247
|
+
* `for (const inp of this.inputs.get(sid))` (sugar for {@link consume}) — and
|
|
248
|
+
* sub-integrate one per input. N inputs = N steps for that player, ack lands
|
|
249
|
+
* on the newest applied. The clean default. Iterating consumes ONE AT A TIME,
|
|
250
|
+
* so {@link renderTime} tracks each input (lag comp stays exact per step) and
|
|
251
|
+
* `break` leaves the rest buffered. {@link drain} returns the same set as an
|
|
252
|
+
* array (retainable), but reports only the newest input's {@link renderTime}.
|
|
253
|
+
* - **Shared world** (one solver step advances every body together): you can't
|
|
254
|
+
* replay N inputs as N world steps without over-stepping everyone else, so
|
|
255
|
+
* {@link next} exactly one input per entity per tick (or {@link take} a bounded
|
|
256
|
+
* few and sub-step the solver per input). The ack then === inputs actually
|
|
257
|
+
* simulated; `drain()`-then-apply-latest would silently jump the ack past
|
|
258
|
+
* inputs the server never simulated, snapping the client's reconciler.
|
|
259
|
+
*
|
|
260
|
+
* Returned for unknown sessionIds and rooms without `defineInput()` is a
|
|
261
|
+
* frozen no-op accessor (latest=undefined, drain/next/take/peek=[]/undefined,
|
|
262
|
+
* at=undefined, size=0, clear=no-op).
|
|
263
|
+
*
|
|
264
|
+
* `Idle` is `true` when the room declared `defineInput({ idle })` — it narrows
|
|
265
|
+
* {@link next} to non-optional `I` (bare calls always yield a frame).
|
|
266
|
+
*/
|
|
267
|
+
export interface InputAccessor<I = any, Idle extends boolean = false> {
|
|
268
|
+
/** Latest decoded input. `undefined` when unknown sessionId or no input declared. */
|
|
269
|
+
readonly latest: I | undefined;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Find the buffered snapshot whose `[seqField]` equals `value`. The field
|
|
273
|
+
* name is the Room's `defineInput()` `seqField`. Linear scan — cheap for
|
|
274
|
+
* typical buffer sizes; not intended for very large rings. Returns
|
|
275
|
+
* `undefined` when no match is buffered (or `seqField` isn't configured).
|
|
276
|
+
*
|
|
277
|
+
* Useful for tick-aligned retrieval (lockstep, rollback).
|
|
278
|
+
*/
|
|
279
|
+
at(value: number): I | undefined;
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The blessed **per-entity** loop: consume each pending input oldest → newest,
|
|
283
|
+
* ONE AT A TIME. Unlike {@link drain} (which reports only the newest input's
|
|
284
|
+
* stamps up front), iterating updates {@link renderTime}/{@link reckonTime} and
|
|
285
|
+
* advances {@link consumedCount} per yielded input — so per-step lag comp
|
|
286
|
+
* rewinds to the exact instant of the input being applied:
|
|
287
|
+
*
|
|
288
|
+
* ```ts
|
|
289
|
+
* for (const inp of this.inputs.get(sid)) { // sugar: accessor is iterable
|
|
290
|
+
* applyInput(p, inp, dt);
|
|
291
|
+
* this.collide(sid, p); // renderTime === THIS input's stamp
|
|
292
|
+
* if (!p.alive) break; // break leaves the rest buffered
|
|
293
|
+
* }
|
|
294
|
+
* ```
|
|
295
|
+
*
|
|
296
|
+
* When the buffer is empty and an `idle` policy is in effect (room-level, or
|
|
297
|
+
* per-call `opts.idle`), yields EXACTLY ONE idle frame (not consumed: no ack
|
|
298
|
+
* bump, no {@link renderTime} change, {@link wasIdle} === true) then stops. No
|
|
299
|
+
* policy + empty → zero iterations. Unknown sessionId / `bufferMaxSize: 0` →
|
|
300
|
+
* empty. `for (const inp of accessor)` is `consume()` with no opts.
|
|
301
|
+
*
|
|
302
|
+
* The returned iterator is POOLED (reused per call, allocation-free). For
|
|
303
|
+
* `for..of` / spread / `Array.from` this is invisible. If you drive it by hand,
|
|
304
|
+
* iterate it once to completion (or call `.return()` / `break`) before the next
|
|
305
|
+
* `consume()`, and read each `.next()` result before the next — don't retain or
|
|
306
|
+
* compare result objects. (Nesting two `consume()` loops over the SAME channel
|
|
307
|
+
* is handled safely but is meaningless — both share one cursor.)
|
|
308
|
+
*/
|
|
309
|
+
consume(opts?: ConsumeOptions<I>): IterableIterator<I>;
|
|
310
|
+
|
|
311
|
+
/** Iterate pending (and/or one idle) inputs — sugar for {@link consume}(). */
|
|
312
|
+
[Symbol.iterator](): IterableIterator<I>;
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Take everything buffered (oldest → newest) and clear. Snapshots are safe to retain.
|
|
316
|
+
*
|
|
317
|
+
* With a room-level `defineInput({ idle })` policy the result is TOTAL: an
|
|
318
|
+
* empty tick yields one synthesized "idle" frame instead of `[]`, so the sim
|
|
319
|
+
* loop needs no empty-branch (gravity still integrates, action guards
|
|
320
|
+
* naturally no-op on default values). The synthesized frame is the schema's
|
|
321
|
+
* DEFAULTS overlaid with the policy's overrides (see {@link IdleInput}):
|
|
322
|
+
*
|
|
323
|
+
* ```ts
|
|
324
|
+
* // declared once at defineInput({ idle: (ctx) => ({ ... }) }); then simply:
|
|
325
|
+
* for (const f of this.inputs.get(sid).drain()) {
|
|
326
|
+
* stepPlayer(p, f, world); // ≥1 frame, always
|
|
327
|
+
* if (f.fire) tryFire(...); // idle frame: fire=false → no-op
|
|
328
|
+
* }
|
|
329
|
+
* ```
|
|
330
|
+
*
|
|
331
|
+
* Pass `{ idle }` to override the room policy for this call, or
|
|
332
|
+
* `{ idle: false }` to suppress it.
|
|
333
|
+
*
|
|
334
|
+
* The idle frame is NOT a consumed input: it advances neither the reconcile
|
|
335
|
+
* ack (`consumedCount`) nor {@link renderTime}. It is ONE reused instance per
|
|
336
|
+
* client, refilled on each synthesis — read it within the tick, don't store
|
|
337
|
+
* it. No synthesis for unknown sessionIds or `bufferMaxSize: 0`.
|
|
338
|
+
*/
|
|
339
|
+
drain(opts?: ConsumeOptions<I>): I[];
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Consume the single oldest buffered input and advance the reconcile ack by
|
|
343
|
+
* exactly one — the complement to {@link peek}. Returns `undefined`
|
|
344
|
+
* when the buffer is empty.
|
|
345
|
+
*
|
|
346
|
+
* Use this in a **shared-world** loop where one solver step advances every
|
|
347
|
+
* entity together: you can't apply N-inputs-per-player as N world steps without
|
|
348
|
+
* over-stepping the others, so consume one input per entity per fixed step
|
|
349
|
+
* instead. {@link drain} (consume all, ack jumps to newest) is correct only
|
|
350
|
+
* when each entity integrates ITSELF — see the {@link InputAccessor} docs.
|
|
351
|
+
*
|
|
352
|
+
* `consumedCount`/{@link renderTime} stay exact: the ack reflects only the
|
|
353
|
+
* inputs you actually simulated, and `renderTime` is the stamp of THIS input.
|
|
354
|
+
*
|
|
355
|
+
* With a room-level `defineInput({ idle })` policy (or a per-call `{ idle }`
|
|
356
|
+
* override) an empty tick returns a synthesized frame instead of `undefined`
|
|
357
|
+
* — same contract as {@link drain}'s. A "held key keeps moving through a
|
|
358
|
+
* packet gap" loop:
|
|
359
|
+
*
|
|
360
|
+
* @example
|
|
361
|
+
* ```ts
|
|
362
|
+
* // declared once:
|
|
363
|
+
* inputs = this.defineInput(InputSchema, {
|
|
364
|
+
* idle: ({ latest }) => latest ?? true, // empty tick → last input verbatim
|
|
365
|
+
* });
|
|
366
|
+
* // per tick:
|
|
367
|
+
* this.setFixedTimestep(() => {
|
|
368
|
+
* for (const [sid, body] of bodies) {
|
|
369
|
+
* applyInputToBody(body, this.inputs.get(sid).next()); // typed I — never undefined
|
|
370
|
+
* }
|
|
371
|
+
* world.step(); // one step for everyone
|
|
372
|
+
* }, 30);
|
|
373
|
+
* ```
|
|
374
|
+
*/
|
|
375
|
+
next(): Idle extends true ? I : I | undefined;
|
|
376
|
+
next(opts: { idle: IdleInput<I> }): I;
|
|
377
|
+
next(opts?: ConsumeOptions<I>): I | undefined;
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Consume up to `n` oldest buffered inputs (oldest → newest) and advance the
|
|
381
|
+
* reconcile ack by the count actually taken. Returns fewer than `n` (or `[]`)
|
|
382
|
+
* when the buffer holds fewer. Use for shared-world **sub-stepping** — apply
|
|
383
|
+
* each taken input as its own solver sub-step within one tick, bounding the
|
|
384
|
+
* sub-steps per tick. {@link next} is the `take(1)` shorthand.
|
|
385
|
+
*/
|
|
386
|
+
take(n: number): I[];
|
|
387
|
+
|
|
388
|
+
/** Read everything buffered without consuming. */
|
|
389
|
+
peek(): I[];
|
|
390
|
+
|
|
391
|
+
/** Number of snapshots currently buffered. */
|
|
392
|
+
readonly size: number;
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Cumulative count of this client's inputs CONSUMED so far — the reconcile ack
|
|
396
|
+
* echoed back to the client (its `lastProcessed`). Advances by one per
|
|
397
|
+
* {@link next}, by the count taken per {@link take}/{@link drain}, by one per
|
|
398
|
+
* yield while iterating {@link consume}, and on overflow-drop; a synthesized
|
|
399
|
+
* idle frame NEVER advances it. Monotonic, so it doubles as a server-side input
|
|
400
|
+
* seq — gate per-input actions off it (e.g. a fire cooldown counted in inputs)
|
|
401
|
+
* instead of hand-rolling a counter. `0` before the first consume / for the
|
|
402
|
+
* no-op accessor.
|
|
403
|
+
*/
|
|
404
|
+
readonly consumedCount: number;
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* `true` when the most recently produced frame was a synthesized idle (vs a
|
|
408
|
+
* real consumed input) — the "skip work on idle" guard. Set per consume
|
|
409
|
+
* call/yield: `true` after a {@link next}/{@link consume}/{@link drain} that
|
|
410
|
+
* fell back to the `idle` policy on an empty buffer, `false` after a real
|
|
411
|
+
* input. Always `false` for {@link take} (never synthesizes) and the no-op
|
|
412
|
+
* accessor. Reads meaningfully right after the consume that produced the frame.
|
|
413
|
+
*/
|
|
414
|
+
readonly wasIdle: boolean;
|
|
415
|
+
|
|
416
|
+
/** Drop all buffered snapshots (also resets the dedupe tracker). */
|
|
417
|
+
clear(): void;
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Server-clock render timestamp (ms since room start) of the most recently
|
|
421
|
+
* consumed input — the time the client was rendering the world at when it
|
|
422
|
+
* issued that input. Tracks the consume primitive: {@link drain}/{@link take}
|
|
423
|
+
* report the NEWEST input they consumed; {@link next} and each
|
|
424
|
+
* {@link consume}/`for..of` yield report THAT one input, so per-input loops
|
|
425
|
+
* rewind to the exact instant of the input simulated.
|
|
426
|
+
* `0` until the first render-time-stamped input is consumed. Populated only
|
|
427
|
+
* when the room rewinds a `mode:"snapshot"` group (which auto-enables the
|
|
428
|
+
* renderTime stamp) and `bufferMaxSize > 0`. Usually you don't read this
|
|
429
|
+
* directly — pass the
|
|
430
|
+
* sessionId to `rewind.lastSeenBy(sessionId)` (which resolves this value, clamps
|
|
431
|
+
* it, and falls back to live) for lag-compensated "what you see is what you
|
|
432
|
+
* hit" hit registration; use this getter only for a custom rewind time.
|
|
433
|
+
*/
|
|
434
|
+
readonly renderTime: number;
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Reckon-display stamp (server-clock ms) of the most recently consumed input
|
|
438
|
+
* — the client's serverNow ESTIMATE when it sampled that input, i.e. the
|
|
439
|
+
* exact instant its forward-reckoned (`mode:"reckon"`) entities were
|
|
440
|
+
* displayed at. Stamping the display instant DIRECTLY makes the rewind read
|
|
441
|
+
* immune to the client's RTT-estimation error: the client displayed
|
|
442
|
+
* `f(serverNow_est)` and the server reads `f(serverNow_est)` — the same
|
|
443
|
+
* index into the same recorded timeline, so estimation error cancels (the
|
|
444
|
+
* `maxRewindMs` clamp still bounds spoofing). Same consume semantics as
|
|
445
|
+
* {@link renderTime}; consumed automatically by `rewind.lastSeenBy()` —
|
|
446
|
+
* rarely read directly.
|
|
447
|
+
*
|
|
448
|
+
* Always a usable instant: when no reckon-stamped input has been consumed
|
|
449
|
+
* yet (lag-comp off, client clock unsynced, nothing consumed), resolves to
|
|
450
|
+
* the room clock's current `elapsedTime` — the fallback rooms previously
|
|
451
|
+
* hand-wrote as `reckonTime > 0 ? reckonTime : now`. `rewind.lastSeenBy()`
|
|
452
|
+
* internally reads the raw stamp, so its midpoint reconstruction still
|
|
453
|
+
* engages for unstamped clients. The no-op accessor (unknown session / no
|
|
454
|
+
* `defineInput()`) returns `0`.
|
|
455
|
+
*/
|
|
456
|
+
readonly reckonTime: number;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Returned by `Room.defineInput()`. Assign it to `this.inputs`, then call
|
|
461
|
+
* `room.inputs.get(sessionId)` per tick to read each client's input stream —
|
|
462
|
+
* the same per-session lookup verb as `room.clients.get(sessionId)`.
|
|
463
|
+
*
|
|
464
|
+
* The fixed-step metadata lives HERE (one per room), not on the per-client
|
|
465
|
+
* {@link InputAccessor} — these values are identical for every client, so they'd
|
|
466
|
+
* be pure duplication per connection. `.get()` keeps the accessor and this
|
|
467
|
+
* metadata cleanly separated.
|
|
468
|
+
*/
|
|
469
|
+
export type InputAPI<I = any, Idle extends boolean = false> = {
|
|
470
|
+
/**
|
|
471
|
+
* The input accessor for `sessionId` — the per-client stream you iterate /
|
|
472
|
+
* `.next()` / `.drain()`. Returns a frozen no-op accessor for unknown sessions.
|
|
473
|
+
* Mirrors `room.clients.get(sessionId)`.
|
|
474
|
+
*/
|
|
475
|
+
get(sessionId: string): InputAccessor<I, Idle>;
|
|
476
|
+
/**
|
|
477
|
+
* Server-advertised fixed step rate in **Hz** — from `defineInput`'s
|
|
478
|
+
* `tickRate`/`stepMs`/`stepSeconds`, or derived from `setTimestep` —
|
|
479
|
+
* the same value cascaded to predicting clients. `undefined` when no fixed
|
|
480
|
+
* step is advertised.
|
|
481
|
+
*/
|
|
482
|
+
readonly tickRate?: number;
|
|
483
|
+
/**
|
|
484
|
+
* The fixed step as **seconds** (`1/tickRate`): the dt to integrate one input
|
|
485
|
+
* with, bit-identical to the client's prediction dt. Pass it to your physics
|
|
486
|
+
* step (`applyInput(p, cmd, level, room.inputs.stepSeconds)`) so server and
|
|
487
|
+
* client share one timestep instead of each keeping a constant that can drift.
|
|
488
|
+
* `undefined` when no rate is advertised.
|
|
489
|
+
*/
|
|
490
|
+
readonly stepSeconds?: number;
|
|
491
|
+
/** The fixed step as **milliseconds** (`1000/tickRate`). `undefined` when no rate. */
|
|
492
|
+
readonly stepMs?: number;
|
|
493
|
+
/** Physics sub-steps per input tick (≥ 1; `1` unless declared via
|
|
494
|
+
* `setFixedTimestep(..., { subSteps })` / `defineInput`). */
|
|
495
|
+
readonly subSteps: number;
|
|
496
|
+
/** The physics sub-step as **seconds** (`stepSeconds / subSteps`) — the engine
|
|
497
|
+
* dt when sub-stepping; equals {@link stepSeconds} when `subSteps` is 1.
|
|
498
|
+
* `undefined` when no rate is advertised. */
|
|
499
|
+
readonly subStepSeconds?: number;
|
|
500
|
+
/** The physics sub-step as **milliseconds** (`stepMs / subSteps`). `undefined`
|
|
501
|
+
* when no rate is advertised. */
|
|
502
|
+
readonly subStepMs?: number;
|
|
503
|
+
};
|
package/src/rooms/QueueRoom.ts
CHANGED
|
@@ -202,7 +202,7 @@ export class QueueRoom extends Room {
|
|
|
202
202
|
/**
|
|
203
203
|
* Redistribute clients into groups at every interval
|
|
204
204
|
*/
|
|
205
|
-
this.
|
|
205
|
+
this.setTimestep(() => this.reassignMatchGroups(), this.cycleTickInterval);
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
onJoin(client: QueueClient, options: any, auth?: unknown) {
|
|
@@ -36,6 +36,10 @@ export const postMatchmakeMethod = createEndpoint("/matchmake/:method/:roomName"
|
|
|
36
36
|
if (transport.protocol !== undefined) {
|
|
37
37
|
response.protocol = transport.protocol;
|
|
38
38
|
}
|
|
39
|
+
// WebTransport (h3): client pins the self-signed cert via serverCertificateHashes.
|
|
40
|
+
if (transport.fingerprint !== undefined) {
|
|
41
|
+
response.fingerprint = transport.fingerprint;
|
|
42
|
+
}
|
|
39
43
|
|
|
40
44
|
const json = JSON.stringify(response);
|
|
41
45
|
|
|
@@ -57,5 +61,6 @@ export const postMatchmakeMethod = createEndpoint("/matchmake/:method/:roomName"
|
|
|
57
61
|
});
|
|
58
62
|
|
|
59
63
|
export function getDefaultRouter() {
|
|
60
|
-
|
|
64
|
+
// no public docs page — the docs surface is playground's gated /__apidocs
|
|
65
|
+
return createRouter({ postMatchmakeMethod }, { openapi: { disabled: true } });
|
|
61
66
|
}
|
package/src/router/index.ts
CHANGED
|
@@ -125,7 +125,12 @@ export function createRouter<
|
|
|
125
125
|
E extends Record<string, Endpoint>,
|
|
126
126
|
Config extends RouterConfig
|
|
127
127
|
>(endpoints: E, config: Config = {} as Config) {
|
|
128
|
-
return createBetterCallRouter({ ...endpoints },
|
|
128
|
+
return createBetterCallRouter({ ...endpoints }, {
|
|
129
|
+
// better-call's /api/reference page dumps the full API surface
|
|
130
|
+
// unauthenticated — opt back in by passing `openapi` explicitly.
|
|
131
|
+
openapi: { disabled: true },
|
|
132
|
+
...config,
|
|
133
|
+
});
|
|
129
134
|
}
|
|
130
135
|
|
|
131
136
|
export interface BasicAuthOptions {
|
package/src/router/node.ts
CHANGED
|
@@ -25,6 +25,50 @@ function readBody(req: http.IncomingMessage): Promise<any> {
|
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Buffer incoming request bodies and expose them as `req.body` before the
|
|
30
|
+
* server's existing "request" listeners run.
|
|
31
|
+
*
|
|
32
|
+
* Needed when the HTTP server is consumed via `export default` (e.g. on Vercel)
|
|
33
|
+
* rather than `listen()`: the matchmaking router reads the body from `req.body`
|
|
34
|
+
* when present, otherwise from a lazy request stream that does not drain in that
|
|
35
|
+
* mode — which would stall matchmaking POSTs.
|
|
36
|
+
*/
|
|
37
|
+
export function prereadRequestBodies(server: http.Server) {
|
|
38
|
+
type WithBody = http.IncomingMessage & { body?: unknown };
|
|
39
|
+
const listeners = server.listeners('request') as Array<(req: http.IncomingMessage, res: http.ServerResponse) => void>;
|
|
40
|
+
const run = (req: http.IncomingMessage, res: http.ServerResponse) => {
|
|
41
|
+
for (const listener of listeners) { listener.call(server, req, res); }
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
server.removeAllListeners('request');
|
|
45
|
+
server.on('request', (req: WithBody, res) => {
|
|
46
|
+
const method = req.method ?? 'GET';
|
|
47
|
+
const needsBody =
|
|
48
|
+
method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS' &&
|
|
49
|
+
req.body === undefined &&
|
|
50
|
+
Number(req.headers['content-length']) > 0;
|
|
51
|
+
|
|
52
|
+
if (!needsBody) {
|
|
53
|
+
run(req, res);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const chunks: Buffer[] = [];
|
|
58
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
59
|
+
req.on('end', () => {
|
|
60
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
61
|
+
if ((req.headers['content-type'] || '').includes('application/json')) {
|
|
62
|
+
try { req.body = JSON.parse(raw || '{}'); } catch { req.body = raw; }
|
|
63
|
+
} else {
|
|
64
|
+
req.body = raw;
|
|
65
|
+
}
|
|
66
|
+
run(req, res);
|
|
67
|
+
});
|
|
68
|
+
req.on('error', () => run(req, res));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
28
72
|
function getCorsHeaders(req: http.IncomingMessage, headers?: Headers): Record<string, string> {
|
|
29
73
|
return {
|
|
30
74
|
...matchMaker.controller.DEFAULT_CORS_HEADERS,
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import type { Client } from '../Transport.ts';
|
|
2
|
-
import type { Serializer } from './Serializer.ts';
|
|
2
|
+
import type { PatchTimingContext, Serializer } from './Serializer.ts';
|
|
3
3
|
|
|
4
4
|
export class NoneSerializer<T= any> implements Serializer<T> {
|
|
5
5
|
public id: string = 'none';
|
|
6
6
|
|
|
7
7
|
public reset(data: any) {}
|
|
8
8
|
|
|
9
|
-
public getFullState(
|
|
9
|
+
public getFullState(_client?: Client, _timing?: PatchTimingContext) {
|
|
10
10
|
return null;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
public applyPatches(
|
|
13
|
+
public applyPatches(_clients: Client[], _state: T, _timing?: PatchTimingContext): boolean {
|
|
14
14
|
return false;
|
|
15
15
|
}
|
|
16
16
|
}
|