@colyseus/core 0.18.0 → 0.18.2

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.
Files changed (129) hide show
  1. package/build/MatchMaker.cjs +3 -7
  2. package/build/MatchMaker.cjs.map +2 -2
  3. package/build/MatchMaker.mjs +4 -8
  4. package/build/MatchMaker.mjs.map +2 -2
  5. package/build/Protocol.cjs +3 -3
  6. package/build/Protocol.cjs.map +1 -1
  7. package/build/Protocol.d.ts +3 -3
  8. package/build/Protocol.mjs +3 -3
  9. package/build/Protocol.mjs.map +1 -1
  10. package/build/Rewind.cjs +385 -0
  11. package/build/Rewind.cjs.map +7 -0
  12. package/build/Rewind.d.ts +291 -0
  13. package/build/Rewind.mjs +359 -0
  14. package/build/Rewind.mjs.map +7 -0
  15. package/build/Room.cjs +280 -281
  16. package/build/Room.cjs.map +3 -3
  17. package/build/Room.d.ts +185 -81
  18. package/build/Room.mjs +286 -286
  19. package/build/Room.mjs.map +2 -2
  20. package/build/RoomMessages.cjs +265 -0
  21. package/build/RoomMessages.cjs.map +7 -0
  22. package/build/RoomMessages.d.ts +49 -0
  23. package/build/RoomMessages.mjs +240 -0
  24. package/build/RoomMessages.mjs.map +7 -0
  25. package/build/Server.cjs +88 -31
  26. package/build/Server.cjs.map +2 -2
  27. package/build/Server.d.ts +36 -0
  28. package/build/Server.mjs +88 -31
  29. package/build/Server.mjs.map +2 -2
  30. package/build/Transport.cjs +26 -0
  31. package/build/Transport.cjs.map +2 -2
  32. package/build/Transport.d.ts +70 -2
  33. package/build/Transport.mjs +25 -0
  34. package/build/Transport.mjs.map +2 -2
  35. package/build/errors/RoomExceptions.cjs +7 -4
  36. package/build/errors/RoomExceptions.cjs.map +2 -2
  37. package/build/errors/RoomExceptions.d.ts +29 -17
  38. package/build/errors/RoomExceptions.mjs +5 -3
  39. package/build/errors/RoomExceptions.mjs.map +2 -2
  40. package/build/index.cjs +19 -2
  41. package/build/index.cjs.map +2 -2
  42. package/build/index.d.ts +7 -4
  43. package/build/index.mjs +17 -4
  44. package/build/index.mjs.map +2 -2
  45. package/build/input/InputBuffer.cjs +465 -16
  46. package/build/input/InputBuffer.cjs.map +2 -2
  47. package/build/input/InputBuffer.d.ts +195 -95
  48. package/build/input/InputBuffer.mjs +461 -15
  49. package/build/input/InputBuffer.mjs.map +2 -2
  50. package/build/input/RoomInput.cjs +307 -0
  51. package/build/input/RoomInput.cjs.map +7 -0
  52. package/build/input/RoomInput.d.ts +91 -0
  53. package/build/input/RoomInput.mjs +289 -0
  54. package/build/input/RoomInput.mjs.map +7 -0
  55. package/build/input/types.cjs +18 -0
  56. package/build/input/types.cjs.map +7 -0
  57. package/build/input/types.d.ts +476 -0
  58. package/build/input/types.mjs +0 -0
  59. package/build/input/types.mjs.map +7 -0
  60. package/build/presence/LocalPresence.cjs.map +2 -2
  61. package/build/presence/LocalPresence.d.ts +1 -1
  62. package/build/presence/LocalPresence.mjs.map +2 -2
  63. package/build/rooms/QueueRoom.cjs +1 -1
  64. package/build/rooms/QueueRoom.cjs.map +2 -2
  65. package/build/rooms/QueueRoom.mjs +1 -1
  66. package/build/rooms/QueueRoom.mjs.map +2 -2
  67. package/build/router/default_routes.cjs +4 -1
  68. package/build/router/default_routes.cjs.map +2 -2
  69. package/build/router/default_routes.mjs +4 -1
  70. package/build/router/default_routes.mjs.map +2 -2
  71. package/build/router/index.cjs +19 -7
  72. package/build/router/index.cjs.map +2 -2
  73. package/build/router/index.mjs +19 -7
  74. package/build/router/index.mjs.map +2 -2
  75. package/build/router/node.cjs +37 -2
  76. package/build/router/node.cjs.map +2 -2
  77. package/build/router/node.d.ts +10 -0
  78. package/build/router/node.mjs +35 -1
  79. package/build/router/node.mjs.map +2 -2
  80. package/build/serializer/NoneSerializer.cjs +2 -2
  81. package/build/serializer/NoneSerializer.cjs.map +2 -2
  82. package/build/serializer/NoneSerializer.d.ts +3 -3
  83. package/build/serializer/NoneSerializer.mjs +2 -2
  84. package/build/serializer/NoneSerializer.mjs.map +2 -2
  85. package/build/serializer/SchemaSerializer.cjs +44 -14
  86. package/build/serializer/SchemaSerializer.cjs.map +2 -2
  87. package/build/serializer/SchemaSerializer.d.ts +3 -3
  88. package/build/serializer/SchemaSerializer.mjs +46 -16
  89. package/build/serializer/SchemaSerializer.mjs.map +2 -2
  90. package/build/serializer/Serializer.cjs.map +1 -1
  91. package/build/serializer/Serializer.d.ts +12 -2
  92. package/build/utils/DevMode.cjs +14 -14
  93. package/build/utils/DevMode.cjs.map +2 -2
  94. package/build/utils/DevMode.d.ts +0 -2
  95. package/build/utils/DevMode.mjs +14 -12
  96. package/build/utils/DevMode.mjs.map +2 -2
  97. package/build/utils/Env.cjs +8 -4
  98. package/build/utils/Env.cjs.map +3 -3
  99. package/build/utils/Env.mjs +8 -4
  100. package/build/utils/Env.mjs.map +2 -2
  101. package/build/utils/Utils.cjs +3 -0
  102. package/build/utils/Utils.cjs.map +2 -2
  103. package/build/utils/Utils.d.ts +8 -0
  104. package/build/utils/Utils.mjs +2 -0
  105. package/build/utils/Utils.mjs.map +2 -2
  106. package/package.json +15 -12
  107. package/src/MatchMaker.ts +7 -9
  108. package/src/Protocol.ts +3 -3
  109. package/src/Rewind.ts +572 -0
  110. package/src/Room.ts +409 -448
  111. package/src/RoomMessages.ts +342 -0
  112. package/src/Server.ts +112 -38
  113. package/src/Transport.ts +98 -2
  114. package/src/errors/RoomExceptions.ts +36 -22
  115. package/src/index.ts +13 -2
  116. package/src/input/InputBuffer.ts +494 -105
  117. package/src/input/RoomInput.ts +342 -0
  118. package/src/input/types.ts +503 -0
  119. package/src/presence/LocalPresence.ts +1 -1
  120. package/src/rooms/QueueRoom.ts +1 -1
  121. package/src/router/default_routes.ts +6 -1
  122. package/src/router/index.ts +32 -14
  123. package/src/router/node.ts +44 -0
  124. package/src/serializer/NoneSerializer.ts +3 -3
  125. package/src/serializer/SchemaSerializer.ts +110 -19
  126. package/src/serializer/Serializer.ts +13 -2
  127. package/src/utils/DevMode.ts +18 -13
  128. package/src/utils/Env.ts +12 -4
  129. package/src/utils/Utils.ts +9 -0
@@ -1,125 +1,305 @@
1
1
  import type { ClientPrivate } from '../Transport.ts';
2
+ import { $METADATA } from '../utils/Utils.ts';
2
3
 
3
- /**
4
- * Names of fields on `I` whose values are `number` — used by
5
- * `Room.defineInput()` to constrain `seqField` to actually-numeric fields
6
- * on the input schema. Filters out booleans, strings, methods, etc.
7
- */
8
- export type NumericFieldsOf<I> = {
9
- [K in keyof I]: I[K] extends number ? (K extends string ? K : never) : never;
10
- }[keyof I];
4
+ import type {
5
+ ConsumeOptions, IdleContext, IdleInput, InputAccessor, SanitizeInput,
6
+ } from './types.ts';
11
7
 
12
8
  /**
13
- * Internal: input configuration captured by `Room.defineInput()`. The schema
14
- * constructor is stored here so the runtime doesn't need to know it through
15
- * the public `room.input` (which is now a callable accessor).
16
- *
17
- * @internal
9
+ * @internal Validate a `subSteps` count. Throws on a fractional/non-positive
10
+ * value: both sides loop `subSteps` times at `dt/subSteps`, so anything but a
11
+ * whole number is a determinism bug, not a tunable. Shared by `defineInput`
12
+ * and `setFixedTimestep`.
18
13
  */
19
- export interface InputOptions {
20
- /**
21
- * Schema constructor used to allocate per-client input instances on join.
22
- * Captured by `defineInput()` from its `type` argument.
23
- *
24
- * Typed loosely (`new () => any`) to sidestep type-identity issues across
25
- * duplicate `@colyseus/schema` installs; the runtime calls
26
- * `instance.clone()` and friends, which match by shape.
27
- */
28
- ctor: new () => any;
29
-
30
- /**
31
- * Name of a monotonically-increasing numeric field on the input schema used
32
- * to order and dedupe incoming frames. When set, the framework:
33
- * - Drops redundant frames (`input[seqField]` ≤ the last-seen value are
34
- * discarded before they enter the buffer). Matches the unreliable-mode
35
- * ring-redundancy pattern out of the box.
36
- * - Powers `room.input(sessionId).at(value)` lookups.
37
- *
38
- * Despite the name, "seq" here is broader than an integer counter — any
39
- * monotonic numeric field works:
40
- * - **Sequence counter** (`"seq"`, `"tick"`, `"frame"`) — typical for
41
- * lockstep / rollback netcode (Photon Quantum, GGPO).
42
- * - **Timestamp** (`"timestamp"`, milliseconds or seconds) — useful for
43
- * variable-rate clients, lag compensation, hit registration (Unreal CMC
44
- * uses float-seconds timestamps via `FSavedMove_Character.TimeStamp`).
45
- *
46
- * Whichever you use, the field must increase monotonically across frames
47
- * for dedupe to work.
48
- */
49
- seqField?: string;
50
-
51
- /**
52
- * > 0 enables per-client buffering of cloned snapshots — required for
53
- * `room.input(sessionId).drain() / .peek() / .at()` to return populated
54
- * data. Oldest drops on overflow. Set to `0` to disable (`.latest` still
55
- * works).
56
- */
57
- bufferMaxSize: number;
14
+ export function validateSubSteps(subSteps: number | undefined, source: string): number | undefined {
15
+ if (subSteps === undefined) { return undefined; }
16
+ if (!Number.isInteger(subSteps) || subSteps < 1) {
17
+ throw new Error(`[${source}] subSteps must be an integer >= 1 (got ${subSteps}).`);
18
+ }
19
+ return subSteps;
58
20
  }
59
21
 
60
22
  /**
61
- * Per-client input accessor returned by `room.input(sessionId)`. Combines the
62
- * latest decoded instance with the (optional) snapshot ring buffer.
63
- *
64
- * - {@link latest} — the bound Schema instance, mutated in place by the
65
- * decoder. Cheapest read; use when only the most recent state matters.
66
- * - {@link drain} / {@link peek} / {@link at} — populated when
67
- * `defineInput()` was called with `bufferMaxSize > 0` (default 32).
68
- * Use for rollback netcode / lockstep where every frame matters.
69
- *
70
- * Returned for unknown sessionIds and rooms without `defineInput()` is a
71
- * frozen no-op accessor (latest=undefined, drain/peek=[], at=undefined,
72
- * size=0, clear=no-op).
23
+ * @internal Compile a {@link SanitizeInput} spec into the per-frame function the
24
+ * decode path applies. The map form precompiles to dense min/max arrays walked
25
+ * with the NaN-safe branch clamp; the callback form passes through.
73
26
  */
74
- export interface InputAccessor<I = any> {
75
- /** Latest decoded input. `undefined` when unknown sessionId or no input declared. */
76
- readonly latest: I | undefined;
77
-
78
- /**
79
- * Find the buffered snapshot whose `[seqField]` equals `value`. The field
80
- * name is the Room's `defineInput()` `seqField`. Linear scan cheap for
81
- * typical buffer sizes; not intended for very large rings. Returns
82
- * `undefined` when no match is buffered (or `seqField` isn't configured).
83
- *
84
- * Useful for tick-aligned retrieval (lockstep, rollback).
85
- */
86
- at(value: number): I | undefined;
87
-
88
- /** Take everything buffered (oldest newest) and clear. Snapshots are safe to retain. */
89
- drain(): I[];
90
-
91
- /** Read everything buffered without consuming. */
92
- peek(): I[];
93
-
94
- /** Number of snapshots currently buffered. */
95
- readonly size: number;
27
+ export function compileSanitizer<I>(spec: SanitizeInput<I>): (input: I) => void {
28
+ if (typeof spec === 'function') { return spec; }
29
+ const names = Object.keys(spec);
30
+ const mins = new Float64Array(names.length);
31
+ const maxs = new Float64Array(names.length);
32
+ for (let i = 0; i < names.length; i++) {
33
+ const range = (spec as Record<string, readonly [number, number]>)[names[i]]!;
34
+ mins[i] = range[0];
35
+ maxs[i] = range[1];
36
+ }
37
+ return (input: I) => {
38
+ const inst = input as Record<string, number>;
39
+ for (let i = 0; i < names.length; i++) {
40
+ const v = inst[names[i]];
41
+ // Never-received field: no input to sanitize clamping would INVENT
42
+ // one (delta-encoding clients legitimately never transmit fields they
43
+ // never assigned; the range floor turned "no input" into "held stick").
44
+ if (v === undefined) { continue; }
45
+ // NaN-safe: NaN fails both comparisons → clamp floor.
46
+ inst[names[i]] = v >= mins[i] ? (v <= maxs[i] ? v : maxs[i]) : mins[i];
47
+ }
48
+ };
49
+ }
96
50
 
97
- /** Drop all buffered snapshots (also resets the dedupe tracker). */
98
- clear(): void;
51
+ /** Field names of an input schema ctor, in declaration order (indices are dense
52
+ * from 0 in the metadata). Resolved ONCE per buffer (cold path). */
53
+ function fieldNamesOf(ctor: new () => any): string[] {
54
+ const md = (ctor as any)[$METADATA] as Record<number, { name?: string }> | undefined;
55
+ const names: string[] = [];
56
+ if (md) {
57
+ for (let i = 0; ; i++) {
58
+ const f = md[i];
59
+ if (!f || typeof f.name !== "string") break;
60
+ names.push(f.name);
61
+ }
62
+ }
63
+ return names;
99
64
  }
100
65
 
101
66
  /**
102
- * Callable returned by `Room.defineInput()`. Assign it to `this.input` and
103
- * call `room.input(sessionId)` per tick to read each client's latest input
104
- * and/or buffered snapshots.
67
+ * @internal Seed every declared field that has no construction default with
68
+ * its wire-neutral zero value (0 / false / ""; quantized: 0 clamped into the
69
+ * descriptor's range). The input contract is "the server holds the last
70
+ * received value" — before anything is received, that value is the ZERO of
71
+ * the field's type, never `undefined`: a sim would NaN-propagate it and a
72
+ * range sanitizer would clamp it into a phantom input. Clients only transmit
73
+ * fields they assign, so an untouched field stays absent on the wire forever.
105
74
  */
106
- export type InputAPI<I = any> = (sessionId: string) => InputAccessor<I>;
75
+ export function seedInputZeroValues(instance: any, ctor: new () => any): void {
76
+ const md = (ctor as any)[$METADATA] as
77
+ Record<number, { name?: string, type?: unknown }> | undefined;
78
+ if (!md) { return; }
79
+ for (let i = 0; ; i++) {
80
+ const f = md[i];
81
+ if (!f || typeof f.name !== "string") break;
82
+ if (instance[f.name] !== undefined) { continue; } // .default() wins
83
+ const type = f.type;
84
+ if (typeof type === "string") {
85
+ instance[f.name] = type === "string" ? "" : type === "boolean" ? false : 0;
86
+ } else if (type !== null && typeof type === "object" && "quantized" in (type as object)) {
87
+ const q = (type as { quantized: { min: number, max: number } }).quantized;
88
+ instance[f.name] = q.min > 0 ? q.min : q.max < 0 ? q.max : 0;
89
+ }
90
+ }
91
+ }
92
+
93
+ /** Reclaim the consumed prefix once the read cursor passes this many items —
94
+ * bounds the parallel arrays' slack to O(THRESHOLD) between compactions while
95
+ * keeping per-consume work O(1) (no `shift()` re-index on every input). */
96
+ const COMPACT_THRESHOLD = 32;
97
+
98
+ /** Shared zero-state "already done" iterator — returned by `consume()` on an
99
+ * empty buffer with no idle policy, and by the no-op accessor. Frozen + a frozen
100
+ * result, so it's allocation-free and safe to share across all callers. */
101
+ const DONE_RESULT: IteratorResult<any> = Object.freeze({ value: undefined, done: true });
102
+ const DONE_ITERATOR: IterableIterator<any> = Object.freeze({
103
+ [Symbol.iterator]() { return this; },
104
+ next() { return DONE_RESULT; },
105
+ });
107
106
 
108
107
  /** @internal */
109
108
  export class InputBufferImpl<I = any> {
109
+ /** Pending snapshots, indexed `[_head, _items.length)`. Consumed inputs are
110
+ * NOT spliced off per-read — `_head` advances and the prefix is reclaimed in
111
+ * bulk by {@link compact} (a `shift()` per read is O(n), so a burst would be
112
+ * O(n²)). `_renderTimes`/`_reckonTimes` stay parallel to `_items`. */
110
113
  private _items: I[] = [];
114
+ /** Read cursor: index of the oldest UNCONSUMED input (== `_items.length` when empty). */
115
+ private _head = 0;
111
116
  private _lastSeq: number = -Infinity;
112
117
  private readonly _maxSize: number;
113
118
  private readonly _seqField: string | undefined;
114
119
 
115
- constructor(maxSize: number, seqField: string | undefined) {
120
+ /**
121
+ * Cumulative count of inputs CONSUMED from this buffer (via any consume
122
+ * primitive — {@link next}/{@link take}/{@link drain}/{@link consume} — plus
123
+ * {@link clear} and overflow). Consumed = "removed from the pending set",
124
+ * whether applied to state or discarded. The server echoes this in the TIMED
125
+ * prefix as the reconciliation ack: how many of the client's inputs are
126
+ * reflected in (or finished influencing) the authoritative state, which lags
127
+ * the receive counter by inputs still buffered. Distinct from
128
+ * `_receivedInputCount` (receive-time, for RTT), which leads the state.
129
+ */
130
+ consumedCount = 0;
131
+
132
+ /** Render times parallel to `_items` (server-clock ms; `0` when unset). */
133
+ private _renderTimes: number[] = [];
134
+ /** Render time of the most recently drained input (see {@link renderTime}). */
135
+ private _lastRenderTime = 0;
136
+ /** Reckon-display stamps parallel to `_items` — the client's serverNow
137
+ * estimate at input-sample time, i.e. the instant its forward-reckoned
138
+ * entities were displayed at (server-clock ms; `0` when unset). */
139
+ private _reckonTimes: number[] = [];
140
+ /** Reckon stamp of the most recently consumed input (see {@link reckonTime}). */
141
+ private _lastReckonTime = 0;
142
+ /** Framework wire seq per buffered input (parallel to `_items`) — UNRELIABLE
143
+ * ONLY, lazily created on the first seq-carrying push. Reliable inputs are
144
+ * contiguous, so their consumed-seq-value is always exactly {@link consumedCount}
145
+ * and needs no parallel array; `undefined` here marks that (the common, default
146
+ * path pays nothing). A buffer is single-mode, so this is either never created
147
+ * (reliable) or created once and kept parallel (unreliable). */
148
+ private _seqs?: number[];
149
+ /** Seq VALUE of the last consumed input — the reconciliation ack echoed to the
150
+ * client (see {@link ackSeq}). Only meaningful when {@link _seqs} is tracked
151
+ * (unreliable); reliable's ack falls back to {@link consumedCount}. */
152
+ private _lastConsumedSeq = 0;
153
+ /** Whether the most recently produced frame was a synthesized idle (see {@link wasIdle}). */
154
+ private _lastWasIdle = false;
155
+
156
+ /** Reused {@link consume} iterator (lazily minted) + its per-call state, so a
157
+ * per-tick loop allocates no generator frame. `_iterRemaining` snapshots the
158
+ * COUNT to walk — a count, not an index: mid-pass mutations (`next()`/`take()`/
159
+ * `clear()`/compaction) shift indices but can only shorten a count-based pass,
160
+ * never point it past the tail. `_iterIdle` is an optional trailing synthesized
161
+ * frame; `_iterActive` guards against a nested consume() clobbering the shared
162
+ * `_iterRes`. */
163
+ private _iter?: IterableIterator<I>;
164
+ private _iterRemaining = 0;
165
+ private _iterActive = false;
166
+ private _iterIdle?: I;
167
+ private readonly _iterRes: { value: I | undefined; done: boolean } = { value: undefined, done: false };
168
+
169
+ /** Input schema ctor — mints the reused idle frame; idle is off without it. */
170
+ private readonly _ctor?: new () => I;
171
+ /** Client slice this buffer belongs to (the live object is `Client & ClientPrivate`)
172
+ * — feeds the idle callback's ctx (latest + sessionId). */
173
+ private readonly _client?: Pick<ClientPrivate, '_input'> & { sessionId: string };
174
+ /** Room-level idle policy (`defineInput({ idle })`) — the bare-call default. */
175
+ private readonly _roomIdle?: IdleInput<I>;
176
+ /** Reused synthesized idle frame + the schema's field names/defaults (lazy). */
177
+ private _idle?: I;
178
+ private _fieldNames?: string[];
179
+ private _defaults?: unknown[];
180
+ /** Reused ctx for the idle callback form (see {@link IdleContext}). */
181
+ private readonly _idleCtx: IdleContext<I> = { latest: undefined, sessionId: "" };
182
+
183
+ constructor(maxSize: number, seqField: string | undefined, ctor?: new () => I, client?: Pick<ClientPrivate, '_input'> & { sessionId: string }, idle?: IdleInput<I>) {
116
184
  this._maxSize = maxSize;
117
185
  this._seqField = seqField;
186
+ this._ctor = ctor;
187
+ this._client = client;
188
+ this._roomIdle = idle;
189
+ }
190
+
191
+ /** The effective idle policy for one consume call: per-call `false` suppresses,
192
+ * per-call value overrides, else the room-level default (or none). */
193
+ private effectiveIdle(opts?: ConsumeOptions<I>): IdleInput<I> | undefined {
194
+ return opts?.idle === false ? undefined : (opts?.idle ?? this._roomIdle);
195
+ }
196
+
197
+ /** Resolve an {@link IdleInput} to overrides — invokes the callback form
198
+ * LAZILY, here at synthesis time (the buffer is known to be empty). */
199
+ private resolveIdle(idle: IdleInput<I>): true | Partial<I> {
200
+ if (typeof idle !== "function") return idle;
201
+ this._idleCtx.latest = this._client?._input as I | undefined;
202
+ this._idleCtx.sessionId = this._client?.sessionId ?? "";
203
+ return idle(this._idleCtx) ?? true;
204
+ }
205
+
206
+ /**
207
+ * The synthesized "no input this tick" frame: schema defaults overlaid with
208
+ * `overrides` (`true` = none). Copies BY FIELD NAME from the schema metadata —
209
+ * schema fields are prototype accessors (no own props), so `Object.assign`
210
+ * can't source from a schema instance; the name walk reads getters, letting
211
+ * `overrides` be a plain partial OR a schema instance (e.g. `latest`).
212
+ * ONE reused instance — refilled per call, never advances the ack.
213
+ */
214
+ private idleFrame(overrides: true | Partial<I>): I {
215
+ if (this._idle === undefined) {
216
+ // Lazy mint (cold): the fresh instance doubles as the defaults source.
217
+ this._idle = new this._ctor!();
218
+ seedInputZeroValues(this._idle, this._ctor!);
219
+ this._fieldNames = fieldNamesOf(this._ctor!);
220
+ const fresh = this._idle as Record<string, unknown>;
221
+ this._defaults = this._fieldNames.map((n) => fresh[n]);
222
+ }
223
+ const idle = this._idle as Record<string, unknown>;
224
+ const names = this._fieldNames!;
225
+ const defaults = this._defaults!;
226
+ const ov = overrides === true ? undefined : (overrides as Record<string, unknown>);
227
+ for (let i = 0; i < names.length; i++) {
228
+ const v = ov?.[names[i]];
229
+ idle[names[i]] = v !== undefined ? v : defaults[i];
230
+ }
231
+ return this._idle;
232
+ }
233
+
234
+ /** The idle frame to synthesize on an empty tick (room policy or per-call
235
+ * `opts.idle`), or `undefined` when no policy is in effect. Reuses the single
236
+ * idle instance; never advances the ack. Callers set {@link wasIdle}. */
237
+ private idleFrameOnEmpty(opts?: ConsumeOptions<I>): I | undefined {
238
+ const idle = this._ctor !== undefined ? this.effectiveIdle(opts) : undefined;
239
+ return idle === undefined ? undefined : this.idleFrame(this.resolveIdle(idle));
118
240
  }
119
241
 
120
- push(snapshot: I): void {
242
+ /** Consume the input at the cursor: advance cursor + ack, stamp THIS input's
243
+ * render/reckon times, clear {@link wasIdle}. The shared per-input step behind
244
+ * {@link next}, {@link consume}'s iterator, and the re-entrant fallback. */
245
+ private stepHead(): I {
246
+ const i = this._head;
247
+ this._lastWasIdle = false;
248
+ this._lastRenderTime = this._renderTimes[i];
249
+ this._lastReckonTime = this._reckonTimes[i];
250
+ if (this._seqs !== undefined) { this._lastConsumedSeq = this._seqs[i]; }
251
+ this._head = i + 1;
252
+ this.consumedCount++;
253
+ return this._items[i];
254
+ }
255
+
256
+ /** Drop ALL slots, reusing the backing arrays' capacity — the zero-GC reset for
257
+ * the consume/next path ({@link compact}/{@link clear}). `drain()` deliberately
258
+ * does NOT use this (it returns an array, so fresh `[]` is faster there). */
259
+ private truncate(): void {
260
+ this._items.length = 0;
261
+ this._renderTimes.length = 0;
262
+ this._reckonTimes.length = 0;
263
+ if (this._seqs !== undefined) { this._seqs.length = 0; }
264
+ this._head = 0;
265
+ }
266
+
267
+ /**
268
+ * Append a decoded input snapshot. `seq` is the framework wire seq (unreliable);
269
+ * omit it for reliable inputs, which get an implicit monotonic receive count so
270
+ * {@link ackSeq} stays well-defined in both modes.
271
+ */
272
+ push(snapshot: I, renderTime: number = 0, reckonTime: number = 0, seq?: number): void {
121
273
  this._items.push(snapshot);
122
- if (this._items.length > this._maxSize) { this._items.shift(); }
274
+ this._renderTimes.push(renderTime);
275
+ this._reckonTimes.push(reckonTime);
276
+ // Track the wire seq only for unreliable (seq provided); reliable derives its
277
+ // ack from consumedCount, so it never allocates/maintains this array.
278
+ if (seq !== undefined) { (this._seqs ??= []).push(seq); }
279
+ // Overflow drops the oldest unconsumed input; count it consumed so the ack
280
+ // still advances past it. Advance the cursor (don't shift) — keeps it O(1).
281
+ if (this.size > this._maxSize) {
282
+ if (this._seqs !== undefined) { this._lastConsumedSeq = this._seqs[this._head]; } // dropped oldest counts as acked
283
+ this._head++;
284
+ this.consumedCount++;
285
+ this.compact();
286
+ }
287
+ }
288
+
289
+ /** Reclaim the consumed prefix `[0, _head)`. Free when fully drained (reuse the
290
+ * arrays); otherwise splice only once the cursor passes {@link COMPACT_THRESHOLD},
291
+ * so the amortized per-input cost stays O(1). */
292
+ private compact(): void {
293
+ if (this._head === 0) { return; }
294
+ if (this._head >= this._items.length) {
295
+ this.truncate();
296
+ } else if (this._head >= COMPACT_THRESHOLD) {
297
+ this._items.splice(0, this._head);
298
+ this._renderTimes.splice(0, this._head);
299
+ this._reckonTimes.splice(0, this._head);
300
+ if (this._seqs !== undefined) { this._seqs.splice(0, this._head); }
301
+ this._head = 0;
302
+ }
123
303
  }
124
304
 
125
305
  /** Returns true if `value` hasn't been seen, and updates the last-seen marker. */
@@ -129,31 +309,205 @@ export class InputBufferImpl<I = any> {
129
309
  return true;
130
310
  }
131
311
 
132
- drain(): I[] {
133
- const out = this._items;
312
+ drain(opts?: ConsumeOptions<I>): I[] {
313
+ // Empty: synthesize one idle frame (NOT consumed — no ack bump, stamps
314
+ // untouched) when a policy is in effect, else [].
315
+ if (this.size === 0) {
316
+ const idle = this.idleFrameOnEmpty(opts);
317
+ this._lastWasIdle = idle !== undefined;
318
+ return idle !== undefined ? [idle] : [];
319
+ }
320
+ this._lastWasIdle = false;
321
+ const last = this._items.length - 1;
322
+ this._lastRenderTime = this._renderTimes[last]; // drain reports the NEWEST stamps
323
+ this._lastReckonTime = this._reckonTimes[last];
324
+ if (this._seqs !== undefined) { this._lastConsumedSeq = this._seqs[last]; }
325
+ // Hand off the backing array untouched when nothing was partially consumed
326
+ // (O(1) — the caller may retain it); else copy out the unconsumed tail. Fresh
327
+ // arrays here, NOT truncate(): drain already allocates the returned array, and
328
+ // V8 makes `= []` + refill cheaper than `length = 0` reuse (~1.8× at small N),
329
+ // so the speed wins and the 3 tiny empties are negligible next to the return.
330
+ const out = this._head === 0 ? this._items : this._items.slice(this._head);
331
+ this.consumedCount += out.length;
134
332
  this._items = [];
333
+ this._renderTimes = [];
334
+ this._reckonTimes = [];
335
+ if (this._seqs !== undefined) { this._seqs = []; }
336
+ this._head = 0;
337
+ return out;
338
+ }
339
+
340
+ /** Consume the single oldest input (ack advances by one); `undefined` if
341
+ * empty — or the synthesized idle frame when an idle policy is in effect. */
342
+ next(opts?: ConsumeOptions<I>): I | undefined {
343
+ if (this.size === 0) {
344
+ const idle = this.idleFrameOnEmpty(opts);
345
+ this._lastWasIdle = idle !== undefined;
346
+ return idle;
347
+ }
348
+ const item = this.stepHead();
349
+ this.compact();
350
+ return item;
351
+ }
352
+
353
+ /** Consume up to `n` oldest inputs (ack advances by the count actually taken). */
354
+ take(n: number): I[] {
355
+ this._lastWasIdle = false; // take never synthesizes idle
356
+ const avail = this.size;
357
+ if (n <= 0 || avail === 0) { return []; }
358
+ const count = Math.min(n, avail);
359
+ const start = this._head;
360
+ const out = this._items.slice(start, start + count);
361
+ this._lastRenderTime = this._renderTimes[start + count - 1]; // newest taken input's stamps
362
+ this._lastReckonTime = this._reckonTimes[start + count - 1];
363
+ if (this._seqs !== undefined) { this._lastConsumedSeq = this._seqs[start + count - 1]; }
364
+ this._head += count;
365
+ this.consumedCount += count;
366
+ this.compact();
135
367
  return out;
136
368
  }
137
369
 
370
+ /**
371
+ * Iterate pending inputs one at a time (per-yield ack + stamp updates), or
372
+ * exactly one synthesized idle frame on an empty buffer with a policy in
373
+ * effect. See {@link InputAccessor.consume}.
374
+ *
375
+ * Returns a POOLED iterator (reused across calls — no generator frame, no
376
+ * per-call allocation) that consumes at most the pending count snapshotted at
377
+ * the call — never past the live tail — plus one optional trailing idle frame.
378
+ * The cursor advances per `next()`, so `break` (which calls `return()`)
379
+ * leaves the rest buffered and compacts. A nested
380
+ * `consume()` of the SAME buffer while one is live falls back to a fresh
381
+ * generator so the pooled `_iterRes` isn't clobbered (nesting is meaningless —
382
+ * both share the cursor — but it must not corrupt).
383
+ */
384
+ consume(opts?: ConsumeOptions<I>): IterableIterator<I> {
385
+ const empty = this.size === 0;
386
+ const idle = empty ? this.idleFrameOnEmpty(opts) : undefined;
387
+ if (empty && idle === undefined) { this._lastWasIdle = false; return DONE_ITERATOR as IterableIterator<I>; }
388
+ // A nested consume() of this same buffer would clobber the pooled iterator's
389
+ // shared state → hand the rare case a fresh generator instead.
390
+ if (this._iterActive) { return this.consumeGen(idle); }
391
+ this._iterActive = true;
392
+ this._iterIdle = idle; // one trailing idle frame, or undefined
393
+ this._iterRemaining = this.size; // count snapshot — a (hypothetical) mid-loop push isn't consumed this pass
394
+ return this.ensureIter();
395
+ }
396
+
397
+ [Symbol.iterator](): IterableIterator<I> {
398
+ return this.consume();
399
+ }
400
+
401
+ /** Lazily mint the reused iterator. Closures over `this` so it reads the
402
+ * private cursor directly; one `_iterRes` is reused — read each result before
403
+ * the next `next()`, as `for..of` / spread / `Array.from` all do. */
404
+ private ensureIter(): IterableIterator<I> {
405
+ if (this._iter !== undefined) { return this._iter; }
406
+ const self = this;
407
+ const res = this._iterRes;
408
+ const finish = (): IteratorResult<I> => {
409
+ self.compact();
410
+ self._iterActive = false;
411
+ self._iterRemaining = 0;
412
+ self._iterIdle = undefined;
413
+ res.value = undefined;
414
+ res.done = true;
415
+ return res as IteratorResult<I>;
416
+ };
417
+ this._iter = {
418
+ [Symbol.iterator]() { return this; },
419
+ next(): IteratorResult<I> {
420
+ // `size > 0` re-checks the live tail: a mid-loop next()/take()/clear()
421
+ // can only shorten the pass, never make it read past the end.
422
+ if (self._iterRemaining > 0 && self.size > 0) { // a buffered input
423
+ self._iterRemaining--;
424
+ res.value = self.stepHead();
425
+ res.done = false;
426
+ return res as IteratorResult<I>;
427
+ }
428
+ if (self._iterIdle !== undefined) { // one trailing idle frame
429
+ self._lastWasIdle = true;
430
+ res.value = self._iterIdle;
431
+ self._iterIdle = undefined;
432
+ res.done = false;
433
+ return res as IteratorResult<I>;
434
+ }
435
+ return finish();
436
+ },
437
+ return(): IteratorResult<I> { return finish(); }, // `break` → compact + release
438
+ };
439
+ return this._iter;
440
+ }
441
+
442
+ /** Re-entrant fallback: a fresh generator for a nested `consume()` of this same
443
+ * buffer (the pooled iterator is single-active). Same accounting + compaction. */
444
+ private *consumeGen(idleFrame: I | undefined): IterableIterator<I> {
445
+ let remaining = this.size; // count-based walk, same reason as the pooled pass
446
+ try {
447
+ while (remaining-- > 0 && this.size > 0) { yield this.stepHead(); }
448
+ if (idleFrame !== undefined) { this._lastWasIdle = true; yield idleFrame; }
449
+ } finally {
450
+ this.compact();
451
+ }
452
+ }
453
+
138
454
  peek(): I[] {
139
- return this._items.slice();
455
+ return this._items.slice(this._head);
140
456
  }
141
457
 
142
458
  at(value: number): I | undefined {
143
459
  if (this._seqField === undefined) { return undefined; }
144
- for (let i = 0; i < this._items.length; i++) {
460
+ for (let i = this._head; i < this._items.length; i++) {
145
461
  if ((this._items[i] as any)[this._seqField] === value) { return this._items[i]; }
146
462
  }
147
463
  return undefined;
148
464
  }
149
465
 
150
466
  get size(): number {
151
- return this._items.length;
467
+ return this._items.length - this._head;
468
+ }
469
+
470
+ /** Render time (server-clock ms) of the most recently drained input; `0`
471
+ * until the first render-time-stamped input is drained. */
472
+ get renderTime(): number {
473
+ return this._lastRenderTime;
474
+ }
475
+
476
+ /** Reckon-display stamp of the most recently consumed input — the client's
477
+ * serverNow estimate when it sampled that input, i.e. the EXACT instant its
478
+ * forward-reckoned entities were displayed at. Same consume semantics as
479
+ * {@link renderTime}. RAW: stays `0` until stamped — the resolved,
480
+ * always-usable value is the accessor's `reckonTime`
481
+ * (`room.inputs.get(sid)`); `rewind.lastSeenBy()`'s midpoint-reconstruction
482
+ * fallback depends on this raw `0`. */
483
+ get reckonTime(): number {
484
+ return this._lastReckonTime;
485
+ }
486
+
487
+ /** Seq VALUE of the last consumed input — the reconciliation ack sent to the
488
+ * client. Reliable (no wire seq tracked): falls back to {@link consumedCount}
489
+ * for free. Unreliable: the framework wire seq, so a fully-dropped input doesn't
490
+ * make the ack lag the client's sent seq by the lost count. `0` before the first
491
+ * consume. */
492
+ get ackSeq(): number {
493
+ return this._seqs !== undefined ? this._lastConsumedSeq : this.consumedCount;
494
+ }
495
+
496
+ /** Whether the most recently produced frame was a synthesized idle (see {@link wasIdle}). */
497
+ get wasIdle(): boolean {
498
+ return this._lastWasIdle;
152
499
  }
153
500
 
154
501
  clear(): void {
155
- this._items.length = 0;
502
+ // Cleared inputs count as consumed: advance both the count and the seq-value ack
503
+ // past them so the client's pending set drains (capture the newest seq before truncate).
504
+ if (this._seqs !== undefined && this._items.length > 0) { this._lastConsumedSeq = this._seqs[this._items.length - 1]; }
505
+ this.consumedCount += this.size;
506
+ this.truncate();
156
507
  this._lastSeq = -Infinity;
508
+ this._iterActive = false; // recovery hatch if a consume() iterator was abandoned unclosed
509
+ this._iterRemaining = 0; // an abandoned iterator must not consume post-clear pushes
510
+ this._iterIdle = undefined;
157
511
  }
158
512
  }
159
513
 
@@ -161,23 +515,50 @@ export class InputBufferImpl<I = any> {
161
515
  * Default per-client accessor. Reads `_input` and `_inputBuffer` off the
162
516
  * client at access time — both are nullable until the room declares input
163
517
  * via `defineInput()`. Cached as `client._inputAccessor` at join, so
164
- * `room.input(sessionId)` is a Map lookup + property read.
518
+ * `room.inputs.get(sessionId)` is a Map lookup + property read.
165
519
  *
166
520
  * @internal
167
521
  */
168
522
  export class InputAccessorImpl<I = any> implements InputAccessor<I> {
169
523
  private _client: ClientPrivate;
170
- constructor(client: ClientPrivate) { this._client = client; }
524
+ /** Current room time (`clock.elapsedTime`) resolves the {@link reckonTime}
525
+ * fallback. Threaded by `RoomInput.allocate()`; optional so bare
526
+ * constructions (tests) keep the raw 0. */
527
+ private _nowOf?: () => number;
528
+ constructor(client: ClientPrivate, nowOf?: () => number) {
529
+ this._client = client;
530
+ this._nowOf = nowOf;
531
+ }
171
532
  get latest(): I | undefined { return this._client._input as I | undefined; }
172
533
  at(value: number): I | undefined { return this._client._inputBuffer?.at(value) as I | undefined; }
173
- drain(): I[] { return (this._client._inputBuffer?.drain() ?? []) as I[]; }
534
+ consume(opts?: ConsumeOptions<I>): IterableIterator<I> {
535
+ return (this._client._inputBuffer?.consume(opts) ?? DONE_ITERATOR) as IterableIterator<I>;
536
+ }
537
+ [Symbol.iterator](): IterableIterator<I> { return this.consume(); }
538
+ drain(opts?: ConsumeOptions<I>): I[] { return (this._client._inputBuffer?.drain(opts) ?? []) as I[]; }
539
+ next(): I | undefined;
540
+ next(opts: { idle: IdleInput<I> }): I;
541
+ next(opts?: ConsumeOptions<I>): I | undefined { return this._client._inputBuffer?.next(opts) as I | undefined; }
542
+ take(n: number): I[] { return (this._client._inputBuffer?.take(n) ?? []) as I[]; }
174
543
  peek(): I[] { return (this._client._inputBuffer?.peek() ?? []) as I[]; }
175
544
  get size(): number { return this._client._inputBuffer?.size ?? 0; }
545
+ get consumedCount(): number { return this._client._inputBuffer?.consumedCount ?? 0; }
546
+ get wasIdle(): boolean { return this._client._inputBuffer?.wasIdle ?? false; }
176
547
  clear(): void { this._client._inputBuffer?.clear(); }
548
+ get renderTime(): number { return this._client._inputBuffer?.renderTime ?? 0; }
549
+ /** @internal RAW reckon stamp (0 until stamped). The rewind binding reads
550
+ * THIS, not the resolved getter below: `Rewind._aim`'s midpoint fallback
551
+ * must still see 0 for unstamped clients (a resolved value would silently
552
+ * take the direct-stamp clamp path). */
553
+ get rawReckonTime(): number { return this._client._inputBuffer?.reckonTime ?? 0; }
554
+ get reckonTime(): number {
555
+ const raw = this._client._inputBuffer?.reckonTime ?? 0;
556
+ return raw > 0 ? raw : this._nowOf !== undefined ? this._nowOf() : 0;
557
+ }
177
558
  }
178
559
 
179
560
  /**
180
- * Returned by `room.input(sessionId)` for unknown sessions and for rooms
561
+ * Returned by `room.inputs.get(sessionId)` for unknown sessions and for rooms
181
562
  * that didn't call `defineInput()`.
182
563
  *
183
564
  * @internal
@@ -185,8 +566,16 @@ export class InputAccessorImpl<I = any> implements InputAccessor<I> {
185
566
  export const NO_OP_INPUT_ACCESSOR: InputAccessor<any> = Object.freeze({
186
567
  latest: undefined,
187
568
  at: () => undefined,
569
+ consume: () => DONE_ITERATOR,
570
+ [Symbol.iterator]: () => DONE_ITERATOR,
188
571
  drain: () => [],
572
+ next: () => undefined,
573
+ take: () => [],
189
574
  peek: () => [],
190
575
  size: 0,
576
+ consumedCount: 0,
577
+ wasIdle: false,
191
578
  clear: () => {},
579
+ renderTime: 0,
580
+ reckonTime: 0, // stays 0 — an unknown session / input-less room has no clock to resolve against
192
581
  });