@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.
Files changed (125) 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 +69 -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 +15 -3
  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 +439 -16
  46. package/build/input/InputBuffer.cjs.map +2 -2
  47. package/build/input/InputBuffer.d.ts +186 -96
  48. package/build/input/InputBuffer.mjs +436 -15
  49. package/build/input/InputBuffer.mjs.map +2 -2
  50. package/build/input/RoomInput.cjs +306 -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 +287 -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/rooms/QueueRoom.cjs +1 -1
  61. package/build/rooms/QueueRoom.cjs.map +2 -2
  62. package/build/rooms/QueueRoom.mjs +1 -1
  63. package/build/rooms/QueueRoom.mjs.map +2 -2
  64. package/build/router/default_routes.cjs +4 -1
  65. package/build/router/default_routes.cjs.map +2 -2
  66. package/build/router/default_routes.mjs +4 -1
  67. package/build/router/default_routes.mjs.map +2 -2
  68. package/build/router/index.cjs +6 -1
  69. package/build/router/index.cjs.map +2 -2
  70. package/build/router/index.mjs +6 -1
  71. package/build/router/index.mjs.map +2 -2
  72. package/build/router/node.cjs +37 -2
  73. package/build/router/node.cjs.map +2 -2
  74. package/build/router/node.d.ts +10 -0
  75. package/build/router/node.mjs +35 -1
  76. package/build/router/node.mjs.map +2 -2
  77. package/build/serializer/NoneSerializer.cjs +2 -2
  78. package/build/serializer/NoneSerializer.cjs.map +2 -2
  79. package/build/serializer/NoneSerializer.d.ts +3 -3
  80. package/build/serializer/NoneSerializer.mjs +2 -2
  81. package/build/serializer/NoneSerializer.mjs.map +2 -2
  82. package/build/serializer/SchemaSerializer.cjs +44 -14
  83. package/build/serializer/SchemaSerializer.cjs.map +2 -2
  84. package/build/serializer/SchemaSerializer.d.ts +3 -3
  85. package/build/serializer/SchemaSerializer.mjs +46 -16
  86. package/build/serializer/SchemaSerializer.mjs.map +2 -2
  87. package/build/serializer/Serializer.cjs.map +1 -1
  88. package/build/serializer/Serializer.d.ts +12 -2
  89. package/build/utils/DevMode.cjs +14 -14
  90. package/build/utils/DevMode.cjs.map +2 -2
  91. package/build/utils/DevMode.d.ts +0 -2
  92. package/build/utils/DevMode.mjs +14 -12
  93. package/build/utils/DevMode.mjs.map +2 -2
  94. package/build/utils/Env.cjs +8 -4
  95. package/build/utils/Env.cjs.map +3 -3
  96. package/build/utils/Env.mjs +8 -4
  97. package/build/utils/Env.mjs.map +2 -2
  98. package/build/utils/Utils.cjs +3 -0
  99. package/build/utils/Utils.cjs.map +2 -2
  100. package/build/utils/Utils.d.ts +8 -0
  101. package/build/utils/Utils.mjs +2 -0
  102. package/build/utils/Utils.mjs.map +2 -2
  103. package/package.json +11 -11
  104. package/src/MatchMaker.ts +7 -9
  105. package/src/Protocol.ts +3 -3
  106. package/src/Rewind.ts +572 -0
  107. package/src/Room.ts +409 -448
  108. package/src/RoomMessages.ts +342 -0
  109. package/src/Server.ts +112 -38
  110. package/src/Transport.ts +97 -2
  111. package/src/errors/RoomExceptions.ts +18 -4
  112. package/src/index.ts +13 -2
  113. package/src/input/InputBuffer.ts +464 -107
  114. package/src/input/RoomInput.ts +337 -0
  115. package/src/input/types.ts +503 -0
  116. package/src/rooms/QueueRoom.ts +1 -1
  117. package/src/router/default_routes.ts +6 -1
  118. package/src/router/index.ts +6 -1
  119. package/src/router/node.ts +44 -0
  120. package/src/serializer/NoneSerializer.ts +3 -3
  121. package/src/serializer/SchemaSerializer.ts +110 -19
  122. package/src/serializer/Serializer.ts +13 -2
  123. package/src/utils/DevMode.ts +18 -13
  124. package/src/utils/Env.ts +12 -4
  125. package/src/utils/Utils.ts +9 -0
@@ -0,0 +1,291 @@
1
+ import { type MapSchema, type ArraySchema, type SetSchema } from "@colyseus/schema";
2
+ /** Keys of T whose value type is `number` — the only rewindable fields. */
3
+ type NumericKeys<T> = {
4
+ [K in keyof T]-?: T[K] extends number ? K : never;
5
+ }[keyof T] & string;
6
+ /** A Colyseus collection whose element type is `E`. Inferring `E` from the live
7
+ * collection the caller passes is what lets `fields` narrow with zero state-type
8
+ * plumbing. */
9
+ type Collection<E> = MapSchema<E> | ArraySchema<E> | SetSchema<E>;
10
+ /** Options for {@link Rewind.get} / {@link Room.allowRewindState}. */
11
+ export interface RewindOptions {
12
+ /** Default rewind window (ms) for attaches that don't pass their own — sizes the
13
+ * per-entity history ring. Per-attach `maxRewindMs` overrides it. Default 500. */
14
+ maxRewindMs?: number;
15
+ }
16
+ /** The lag-comp technique an attach-group rewinds to — the TIMELINE the client
17
+ * displays those targets on. `"snapshot"` (default): the renderTime stamp (what
18
+ * a lerping/damped client shows, behind real time). `"reckon"`: the reckonTime
19
+ * stamp (what a forward-extrapolated client shows, ≈ serverNow). Declared at
20
+ * {@link Rewind.attachAll}, not on the schema. */
21
+ export type RewindMode = "snapshot" | "reckon";
22
+ /** How `valueAt` reconstructs a value between recorded samples: `linear` interp
23
+ * (continuous motion) or `step` = hold the last sample (discrete motion). */
24
+ type Interp = "linear" | "step";
25
+ /**
26
+ * A read-only view of the tracked world at ONE past time — the rewound state a
27
+ * hit test reads instead of live positions. Created by {@link Rewind.at} /
28
+ * {@link Rewind.lastSeenBy}, which bake in the `maxRewindMs` clamp and the
29
+ * live-fallback so callers never re-implement either.
30
+ *
31
+ * The view assumes NOTHING about your schema's field names — you name the
32
+ * fields (and therefore the shape) at the call site:
33
+ *
34
+ * ```ts
35
+ * const seen = rewind.lastSeenBy(shooterSessionId);
36
+ * // read fields directly…
37
+ * overlaps(bullet, seen.value(target, "x"), seen.value(target, "y"));
38
+ * // …or batch them into an object shaped by YOUR field list:
39
+ * const pos = seen.read(target, ["x", "y"]); // { x, y }
40
+ * seen.read(enemy, ["x", "y"], this.seenScratch); // fill + return a reused scratch
41
+ * ```
42
+ *
43
+ * By default `at`/`lastSeenBy` re-aim and return the Rewind's own internal
44
+ * view — the usual "one view at a time, read it right away" flow is zero-alloc
45
+ * AND zero-setup (the server is single-threaded; nothing interleaves a
46
+ * synchronous read). Need more than one view alive at once (compare two
47
+ * shooters' perspectives, A/B a rewind window)? Pass your own instance as
48
+ * their `out` — it is re-aimed and returned instead of the shared one:
49
+ *
50
+ * ```ts
51
+ * const a = rewind.lastSeenBy(shooterA); // shared default view
52
+ * const b = rewind.lastSeenBy(shooterB, new RewindView()); // independent second view
53
+ * ```
54
+ *
55
+ * Don't store a view across calls or ticks: the default is re-aimed by the
56
+ * next `at`/`lastSeenBy`, and ANY view's clamp was computed against
57
+ * `lastRecordedAt`, so it goes stale at the next `record()` regardless.
58
+ */
59
+ export declare class RewindView {
60
+ /** Bound (and re-bound) by {@link _retarget} — a bare `new RewindView()` is
61
+ * un-aimed scratch until it first passes through at()/lastSeenBy(). */
62
+ private rewind?;
63
+ private _time;
64
+ private _reckonTime;
65
+ /** The clamped server-time (ms) this view reads at — after the maxRewindMs
66
+ * clamp and the live (newest-sample) fallback. Mostly for logging/debug. */
67
+ get time(): number;
68
+ /** The clamped server-time (ms) `mode: "reckon"` groups read at — the
69
+ * client's reconstructed simulation instant, not the raw stamp (see
70
+ * {@link Rewind.at}). Always a resolved instant: a clamped direct stamp,
71
+ * or the midpoint/live reconstruction when unstamped. `0` only before the
72
+ * first `record()`. */
73
+ get reckonTime(): number;
74
+ /** @internal Aim at a rewind + clamped times ({@link Rewind.at} owns the clamps). */
75
+ _retarget(rewind: Rewind, at: number, reckonAt: number): this;
76
+ /** Rewound value of a numeric `field` on `entity` (live if it isn't tracked). */
77
+ value<T extends object>(entity: T, field: NumericKeys<T>): number;
78
+ /**
79
+ * Batch {@link value} reads: the `fields` YOU list define the result's shape
80
+ * (`Record<field, number>`). Pass `out` to fill (and return) a reused scratch
81
+ * instead of allocating — its properties beyond `fields` are left untouched,
82
+ * so a scratch can carry extra context (an `alive` flag, say).
83
+ */
84
+ read<T extends object, F extends NumericKeys<T>, O extends Record<F, number> = Record<F, number>>(entity: T, fields: readonly F[], out?: O): O;
85
+ }
86
+ /**
87
+ * Per-attach options for {@link Rewind.attachAll} / {@link Rewind.attach}.
88
+ */
89
+ interface AttachOptions<E> {
90
+ /** Numeric fields to record per tick. An array applies to every type in the
91
+ * collection (fields a type lacks are skipped — they read live); a per-entity
92
+ * fn picks the list per entity, mirroring `interpolate`'s fn form. */
93
+ fields: readonly NumericKeys<E>[] | ((entity: E) => readonly NumericKeys<E>[]);
94
+ maxRewindMs?: number;
95
+ /** How `valueAt` reconstructs between samples (a mode or a per-entity fn,
96
+ * default `linear`) — use `step` for discrete motion (e.g. teleporters). */
97
+ interpolate?: Interp | ((entity: E) => Interp);
98
+ /**
99
+ * The lag-comp TIMELINE these fields rewind to — must match how the CLIENT
100
+ * displays this target ("what you see is what you hit"):
101
+ * - `"snapshot"` (default): rewind to the client's `renderTime` — for
102
+ * targets it shows INTERPOLATED (Predict `lerp`/`damped`), behind real
103
+ * time by its interp buffer + rtt/2.
104
+ * - `"reckon"`: rewind to the client's `reckonTime` (≈ its serverNow) — for
105
+ * targets it forward-extrapolates (Predict `reckon`).
106
+ * Attach the same collection twice with disjoint `fields` to mix techniques.
107
+ * The room ships exactly the stamp(s) its attached groups need (see the
108
+ * handshake derivation in `Room`).
109
+ */
110
+ mode?: RewindMode;
111
+ }
112
+ /**
113
+ * Server-side lag compensation — the dual of the client's `Predict`. Where
114
+ * `Predict` forward-reckons entities it RECEIVES, `Rewind` records the recent
115
+ * positions of entities it OWNS and rewinds field reads to a past (client
116
+ * render) time, so a hit test judges against where the client actually SAW an
117
+ * entity — not where it has slid to ~RTT later.
118
+ *
119
+ * Prefer {@link Room.allowRewindState}, which creates a `Rewind` and records it
120
+ * automatically each simulation tick:
121
+ *
122
+ * @example
123
+ * ```ts
124
+ * onCreate() {
125
+ * const rewind = this.allowRewindState({ maxRewindMs: 500 });
126
+ * rewind.attachAll(this.state.enemies, { fields: ["x", "y"] }); // fields ← Enemy's numeric keys
127
+ * this.setTimestep((dt) => { ...move enemies... }, 1000 / 30);
128
+ * // framework calls rewind.record() after each tick.
129
+ * }
130
+ * // in your hit test, with the client's renderTime:
131
+ * const seenX = rewind.valueAt(enemy, renderTime, "x");
132
+ * ```
133
+ *
134
+ * `attachAll` takes the live collection (not a string key), so the element type
135
+ * — and the legal `fields` / `valueAt` field names — are inferred with no
136
+ * state-type generic. Entities are keyed by object identity — removed entities
137
+ * and their history are reclaimed automatically.
138
+ *
139
+ * The rewind TIMELINE is declared per attach-group via `attachAll(coll, { mode })`:
140
+ * `"snapshot"` (default) reads at the client's `renderTime` stamp, `"reckon"` at
141
+ * its `reckonTime` (see {@link at}). Not-attached = not rewound (reads are live)
142
+ * — there is no "none". The same collection may be attached more than once with
143
+ * disjoint `fields` to put different fields on different timelines. The room
144
+ * ships exactly the stamp(s) these groups need (handshake derivation reads
145
+ * {@link timelineMode}); the client declares its display mode independently.
146
+ */
147
+ export declare class Rewind {
148
+ private static readonly byRoom;
149
+ /** One `Rewind` per room (idempotent). `room` is only the cache key. */
150
+ static get(room: object, opts?: RewindOptions): Rewind;
151
+ private readonly groups;
152
+ private readonly defaultMaxRewindMs;
153
+ private _sampleIntervalMs;
154
+ private _lastRecordedAt;
155
+ /** Resolves a client's auto-stamped render time (set by `Room.allowRewindState`
156
+ * when a `mode:"snapshot"` group is attached); powers {@link lastSeenBy}. */
157
+ private _renderTimeOf?;
158
+ /** Resolves a client's auto-stamped reckon-display instant (its serverNow
159
+ * estimate at input-sample time). When present (> 0), {@link lastSeenBy}
160
+ * reads `mode:"reckon"` groups AT this stamp directly — exact regardless
161
+ * of the client's clock/RTT-estimation error (it displayed f(est), we read
162
+ * f(est)) — instead of reconstructing it via the midpoint. */
163
+ private _reckonTimeOf?;
164
+ /** Resolves the CURRENT server time (set by `Room.allowRewindState`) — the
165
+ * reckon midpoint's "now" anchor in {@link at}. Without it, the anchor falls
166
+ * back to `lastRecordedAt`, which is one tick stale at hit-test time. */
167
+ private _nowOf?;
168
+ /** Default view returned by at/lastSeenBy when no `out` is given — one per
169
+ * Rewind, re-aimed per call (zero alloc, zero userland setup). */
170
+ private readonly _view;
171
+ private constructor();
172
+ /** The default rewind window (ms). Use it to bound a hit test:
173
+ * `Math.max(renderTime, now - rewind.maxRewindMs)`. */
174
+ get maxRewindMs(): number;
175
+ /** Server time of the last {@link record} (or -1). The auto-record skips a tick
176
+ * whose time was already recorded manually — your `record()` wins. */
177
+ get lastRecordedAt(): number;
178
+ /** Track every entity in a collection (Map/Array/Set schema). `fields` narrows
179
+ * to its element's numeric fields. `interpolate` (a mode or a per-entity fn,
180
+ * default `linear`) picks how `valueAt` reconstructs between samples — use
181
+ * `step` for discrete-motion entities (e.g. teleporters). `mode` picks the
182
+ * lag-comp timeline (default `"snapshot"`; see {@link AttachOptions}). */
183
+ attachAll<E extends object>(collection: Collection<E>, opts: AttachOptions<E>): this;
184
+ /** Track a single entity (e.g. a boss). `fields` narrows to its numeric fields. */
185
+ attach<E extends object>(instance: E, opts: AttachOptions<E>): this;
186
+ /**
187
+ * Snapshot every tracked entity at `now`. Call once per tick, AFTER they move —
188
+ * {@link Room.allowRewindState} does this for you. `sampleIntervalMs` (the gap
189
+ * between records) sizes the history rings; the framework passes the sim
190
+ * interval. Calling this yourself during a tick suppresses that tick's
191
+ * auto-record (see {@link lastRecordedAt}).
192
+ */
193
+ record(now: number, sampleIntervalMs?: number): void;
194
+ /** Cold path — first time an entity is seen by `record()` for a given group.
195
+ * Resolves the field→`$values` indices (cached per constructor for array-form
196
+ * `fields`; fn-form resolves per entity) and bakes in the group's `mode`. */
197
+ private createHistory;
198
+ /** Keep only the fields this entity's type declares (unknown → read live). */
199
+ private resolveFields;
200
+ /**
201
+ * `instance`'s `field` value at past `time` (interpolated from history). Falls
202
+ * back to the live value when the entity has no history yet, or the field
203
+ * isn't tracked.
204
+ */
205
+ valueAt<T extends object>(instance: T, time: number, field: NumericKeys<T>): number;
206
+ /**
207
+ * Which timeline(s) the attached groups rewind to — `reckon` true iff any
208
+ * group is `mode:"reckon"`, `snapshot` true iff any is `mode:"snapshot"`. The
209
+ * room reads this once (groups are declared in `onCreate`, before any join) to
210
+ * pick which stamp(s) the client must ship: reckon-only / render-only / both /
211
+ * none. @internal
212
+ */
213
+ timelineMode(): {
214
+ reckon: boolean;
215
+ snapshot: boolean;
216
+ };
217
+ /**
218
+ * @internal Wire a resolver from sessionId → that client's auto-stamped
219
+ * `renderTime`. Called by {@link Room.allowRewindState} so {@link lastSeenBy}
220
+ * works for `mode:"snapshot"` groups; auto-enabled by attaching one.
221
+ */
222
+ bindRenderTime(resolver: (sessionId: string) => number): void;
223
+ /**
224
+ * @internal Wire a resolver for the CURRENT server time (ms, same epoch as
225
+ * `record` times). Called by {@link Room.allowRewindState}; standalone users
226
+ * (tests/harnesses) should bind their own. Anchors the reckon midpoint in
227
+ * {@link at} at the true processing instant — the `lastRecordedAt` fallback
228
+ * is one tick stale by hit-test time, biasing the aim ~half a tick early:
229
+ * imperceptible on slow horizontal motion, but enough to flip knife-edge
230
+ * stomp/hit verdicts on fast vertical movers (a bobbing jumper).
231
+ */
232
+ bindNow(resolver: () => number): void;
233
+ /**
234
+ * @internal Wire a resolver from sessionId → that client's auto-stamped
235
+ * reckon-display instant (`input(sid).reckonTime`). Called by
236
+ * {@link Room.allowRewindState}. With it, {@link lastSeenBy} aims
237
+ * `mode:"reckon"` groups at the EXACT instant the client displayed them
238
+ * (immune to its RTT-estimation error); without it (or while the stamp is
239
+ * still 0), the midpoint reconstruction in {@link at} is the fallback.
240
+ */
241
+ bindReckonTime(resolver: (sessionId: string) => number): void;
242
+ /**
243
+ * A {@link RewindView} of the tracked world at `time` (an acting client's render
244
+ * time), with the two things every hit test re-implements baked in:
245
+ * - **clamp** to `[lastRecordedAt − maxRewindMs, lastRecordedAt]` — an
246
+ * anti-spoof / clock-skew bound (a client can't rewind arbitrarily far), and
247
+ * - **live fallback**: `time <= 0` (the client's clock hasn't synced) → the
248
+ * newest recorded sample (≈ current position).
249
+ *
250
+ * Sugar over {@link valueAt}; pass the render time yourself (e.g. from
251
+ * `input(sid).renderTime`, or a value stored on the entity). For the common
252
+ * "rewind to a specific client's view" case use {@link lastSeenBy}.
253
+ *
254
+ * Re-aims and returns this Rewind's internal view by default — zero alloc,
255
+ * nothing to declare. Pass `out` to aim a view of your own instead; needed
256
+ * only to hold several views at once — see the {@link RewindView} doc.
257
+ */
258
+ at(time: number, out?: RewindView): RewindView;
259
+ /**
260
+ * Shared aiming: clamp the snapshot-timeline `time`, and resolve the reckon
261
+ * timeline either from the DIRECT `reckonStamp` (the instant the client's
262
+ * forward-reckoned entities were displayed at — exact, immune to the
263
+ * client's RTT-estimation error) or, when absent (0), by reconstruction.
264
+ */
265
+ private _aim;
266
+ /**
267
+ * A {@link RewindView} of the world as session `sessionId` LAST saw it —
268
+ * resolves the render time stamped on that client's most recently CONSUMED
269
+ * input (hence "last": under a multi-frame `drain()` it is the newest frame's
270
+ * stamp) and hands off to {@link at}. This is the "what you see is what you
271
+ * hit" path:
272
+ *
273
+ * ```ts
274
+ * const seen = this.rewind.lastSeenBy(shooterSessionId);
275
+ * const hit = overlaps(bullet, seen.value(target, "x"), seen.value(target, "y"));
276
+ * ```
277
+ *
278
+ * Requires the room's framework input (`this.defineInput(Input)`) — the
279
+ * per-client stamp auto-enables from the `attachAll` `mode` of the groups you
280
+ * rewind (declaration order vs `allowRewindState` doesn't matter). A client
281
+ * that merely hasn't stamped yet (clock still syncing, unknown sessionId) is
282
+ * NOT an error — it reads 0 and the view falls back to live. Use
283
+ * {@link at} with an explicit time if you stamp render times yourself.
284
+ *
285
+ * Re-aims and returns this Rewind's internal view by default — zero alloc,
286
+ * nothing to declare. Pass `out` to aim a view of your own instead; needed
287
+ * only to hold several views at once — see the {@link RewindView} doc.
288
+ */
289
+ lastSeenBy(sessionId: string, out?: RewindView): RewindView;
290
+ }
291
+ export {};
@@ -0,0 +1,359 @@
1
+ // packages/core/src/Rewind.ts
2
+ import { $values } from "@colyseus/schema";
3
+ import { $METADATA } from "./utils/Utils.mjs";
4
+ var DEFAULT_MAX_REWIND_MS = 500;
5
+ var DEFAULT_SAMPLE_INTERVAL_MS = 1e3 / 60;
6
+ var $HISTORY = /* @__PURE__ */ Symbol("rewind.history");
7
+ function fieldIndexOf(instance, field) {
8
+ const md = instance.constructor[$METADATA];
9
+ const idx = md?.[field];
10
+ return typeof idx === "number" ? idx : -1;
11
+ }
12
+ var EntityHistory = class {
13
+ constructor(fields, fieldIdx, maxRewindMs, sampleIntervalMs, step, reckoned, groupId) {
14
+ // hold (vs lerp) between samples
15
+ this.head = 0;
16
+ // next write slot
17
+ this.count = 0;
18
+ this.fields = fields;
19
+ this.fieldIdx = fieldIdx;
20
+ this.step = step;
21
+ this.reckoned = reckoned;
22
+ this.groupId = groupId;
23
+ this.cap = Math.max(2, Math.ceil(maxRewindMs / sampleIntervalMs) + 4);
24
+ this.t = new Float64Array(this.cap);
25
+ this.cols = fields.map(() => new Float64Array(this.cap));
26
+ }
27
+ /** `values` = the entity's dense `$values` array. Direct array reads — no
28
+ * per-field megamorphic accessor. */
29
+ record(time, values) {
30
+ const fieldIdx = this.fieldIdx;
31
+ this.t[this.head] = time;
32
+ for (let f = 0; f < fieldIdx.length; f++) this.cols[f][this.head] = values[fieldIdx[f]];
33
+ this.head = (this.head + 1) % this.cap;
34
+ if (this.count < this.cap) this.count++;
35
+ }
36
+ /** Interpolated value of column `col` at `time`, clamped to the retained range. */
37
+ valueAt(time, col) {
38
+ const c = this.cols[col];
39
+ const oldest = (this.head - this.count + this.cap) % this.cap;
40
+ if (time <= this.t[oldest]) return c[oldest];
41
+ const newest = (this.head - 1 + this.cap) % this.cap;
42
+ if (time >= this.t[newest]) return c[newest];
43
+ let prev = oldest;
44
+ for (let i = 1; i < this.count; i++) {
45
+ const idx = (oldest + i) % this.cap;
46
+ if (this.t[idx] >= time) {
47
+ if (this.step) return c[prev];
48
+ const t0 = this.t[prev], t1 = this.t[idx];
49
+ const a = t1 > t0 ? (time - t0) / (t1 - t0) : 0;
50
+ return c[prev] + (c[idx] - c[prev]) * a;
51
+ }
52
+ prev = idx;
53
+ }
54
+ return c[newest];
55
+ }
56
+ };
57
+ function historyForField(instance, field) {
58
+ const arr = instance[$HISTORY];
59
+ if (arr === void 0) return void 0;
60
+ for (let i = 0; i < arr.length; i++) {
61
+ if (arr[i].fields.indexOf(field) >= 0) return arr[i];
62
+ }
63
+ return void 0;
64
+ }
65
+ var RewindView = class {
66
+ constructor() {
67
+ this._time = 0;
68
+ this._reckonTime = 0;
69
+ }
70
+ /** The clamped server-time (ms) this view reads at — after the maxRewindMs
71
+ * clamp and the live (newest-sample) fallback. Mostly for logging/debug. */
72
+ get time() {
73
+ return this._time;
74
+ }
75
+ /** The clamped server-time (ms) `mode: "reckon"` groups read at — the
76
+ * client's reconstructed simulation instant, not the raw stamp (see
77
+ * {@link Rewind.at}). Always a resolved instant: a clamped direct stamp,
78
+ * or the midpoint/live reconstruction when unstamped. `0` only before the
79
+ * first `record()`. */
80
+ get reckonTime() {
81
+ return this._reckonTime;
82
+ }
83
+ /** @internal Aim at a rewind + clamped times ({@link Rewind.at} owns the clamps). */
84
+ _retarget(rewind, at, reckonAt) {
85
+ this.rewind = rewind;
86
+ this._time = at;
87
+ this._reckonTime = reckonAt;
88
+ return this;
89
+ }
90
+ /** Rewound value of a numeric `field` on `entity` (live if it isn't tracked). */
91
+ value(entity, field) {
92
+ if (this.rewind === void 0) {
93
+ throw new Error("RewindView is not aimed \u2014 obtain it from rewind.at()/lastSeenBy(), or pass it to them as `out`.");
94
+ }
95
+ const h = historyForField(entity, field);
96
+ if (h === void 0) return entity[field];
97
+ return h.valueAt(h.reckoned ? this._reckonTime : this._time, h.fields.indexOf(field));
98
+ }
99
+ /**
100
+ * Batch {@link value} reads: the `fields` YOU list define the result's shape
101
+ * (`Record<field, number>`). Pass `out` to fill (and return) a reused scratch
102
+ * instead of allocating — its properties beyond `fields` are left untouched,
103
+ * so a scratch can carry extra context (an `alive` flag, say).
104
+ */
105
+ read(entity, fields, out) {
106
+ const o = out ?? {};
107
+ for (let i = 0; i < fields.length; i++) o[fields[i]] = this.value(entity, fields[i]);
108
+ return o;
109
+ }
110
+ };
111
+ var Rewind = class _Rewind {
112
+ constructor(defaultMaxRewindMs) {
113
+ this.groups = [];
114
+ this._sampleIntervalMs = DEFAULT_SAMPLE_INTERVAL_MS;
115
+ this._lastRecordedAt = -1;
116
+ /** Default view returned by at/lastSeenBy when no `out` is given — one per
117
+ * Rewind, re-aimed per call (zero alloc, zero userland setup). */
118
+ this._view = new RewindView();
119
+ this.defaultMaxRewindMs = defaultMaxRewindMs;
120
+ }
121
+ static {
122
+ this.byRoom = /* @__PURE__ */ new WeakMap();
123
+ }
124
+ /** One `Rewind` per room (idempotent). `room` is only the cache key. */
125
+ static get(room, opts) {
126
+ let r = _Rewind.byRoom.get(room);
127
+ if (r === void 0) {
128
+ r = new _Rewind(opts?.maxRewindMs ?? DEFAULT_MAX_REWIND_MS);
129
+ _Rewind.byRoom.set(room, r);
130
+ }
131
+ return r;
132
+ }
133
+ /** The default rewind window (ms). Use it to bound a hit test:
134
+ * `Math.max(renderTime, now - rewind.maxRewindMs)`. */
135
+ get maxRewindMs() {
136
+ return this.defaultMaxRewindMs;
137
+ }
138
+ /** Server time of the last {@link record} (or -1). The auto-record skips a tick
139
+ * whose time was already recorded manually — your `record()` wins. */
140
+ get lastRecordedAt() {
141
+ return this._lastRecordedAt;
142
+ }
143
+ /** Track every entity in a collection (Map/Array/Set schema). `fields` narrows
144
+ * to its element's numeric fields. `interpolate` (a mode or a per-entity fn,
145
+ * default `linear`) picks how `valueAt` reconstructs between samples — use
146
+ * `step` for discrete-motion entities (e.g. teleporters). `mode` picks the
147
+ * lag-comp timeline (default `"snapshot"`; see {@link AttachOptions}). */
148
+ attachAll(collection, opts) {
149
+ const c = collection;
150
+ this.groups.push({ entities: () => c.values(), fields: opts.fields, resolvedByCtor: /* @__PURE__ */ new Map(), maxRewindMs: opts.maxRewindMs ?? this.defaultMaxRewindMs, interpolate: opts.interpolate ?? "linear", reckoned: opts.mode === "reckon", groupId: this.groups.length });
151
+ return this;
152
+ }
153
+ /** Track a single entity (e.g. a boss). `fields` narrows to its numeric fields. */
154
+ attach(instance, opts) {
155
+ const one = [instance];
156
+ this.groups.push({ entities: () => one, fields: opts.fields, resolvedByCtor: /* @__PURE__ */ new Map(), maxRewindMs: opts.maxRewindMs ?? this.defaultMaxRewindMs, interpolate: opts.interpolate ?? "linear", reckoned: opts.mode === "reckon", groupId: this.groups.length });
157
+ return this;
158
+ }
159
+ /**
160
+ * Snapshot every tracked entity at `now`. Call once per tick, AFTER they move —
161
+ * {@link Room.allowRewindState} does this for you. `sampleIntervalMs` (the gap
162
+ * between records) sizes the history rings; the framework passes the sim
163
+ * interval. Calling this yourself during a tick suppresses that tick's
164
+ * auto-record (see {@link lastRecordedAt}).
165
+ */
166
+ record(now, sampleIntervalMs) {
167
+ if (sampleIntervalMs !== void 0 && sampleIntervalMs > 0) this._sampleIntervalMs = sampleIntervalMs;
168
+ this._lastRecordedAt = now;
169
+ for (const g of this.groups) {
170
+ for (const e of g.entities()) {
171
+ const t = e;
172
+ let arr = t[$HISTORY];
173
+ if (arr === void 0) {
174
+ arr = [];
175
+ t[$HISTORY] = arr;
176
+ }
177
+ let h;
178
+ for (let i = 0; i < arr.length; i++) {
179
+ if (arr[i].groupId === g.groupId) {
180
+ h = arr[i];
181
+ break;
182
+ }
183
+ }
184
+ if (h === void 0) {
185
+ h = this.createHistory(g, e);
186
+ arr.push(h);
187
+ }
188
+ h.record(now, t[$values]);
189
+ }
190
+ }
191
+ }
192
+ /** Cold path — first time an entity is seen by `record()` for a given group.
193
+ * Resolves the field→`$values` indices (cached per constructor for array-form
194
+ * `fields`; fn-form resolves per entity) and bakes in the group's `mode`. */
195
+ createHistory(g, e) {
196
+ let rf;
197
+ if (typeof g.fields === "function") {
198
+ rf = this.resolveFields(e, g.fields(e));
199
+ } else {
200
+ let cached = g.resolvedByCtor.get(e.constructor);
201
+ if (cached === void 0) {
202
+ cached = this.resolveFields(e, g.fields);
203
+ g.resolvedByCtor.set(e.constructor, cached);
204
+ }
205
+ rf = cached;
206
+ }
207
+ const interp = typeof g.interpolate === "function" ? g.interpolate(e) : g.interpolate;
208
+ return new EntityHistory(rf.fields, rf.idx, g.maxRewindMs, this._sampleIntervalMs, interp === "step", g.reckoned, g.groupId);
209
+ }
210
+ /** Keep only the fields this entity's type declares (unknown → read live). */
211
+ resolveFields(e, names) {
212
+ const fields = [];
213
+ const idx = [];
214
+ for (const name of names) {
215
+ const i = fieldIndexOf(e, name);
216
+ if (i >= 0) {
217
+ fields.push(name);
218
+ idx.push(i);
219
+ }
220
+ }
221
+ return { fields, idx };
222
+ }
223
+ /**
224
+ * `instance`'s `field` value at past `time` (interpolated from history). Falls
225
+ * back to the live value when the entity has no history yet, or the field
226
+ * isn't tracked.
227
+ */
228
+ valueAt(instance, time, field) {
229
+ const h = historyForField(instance, field);
230
+ if (h === void 0) return instance[field];
231
+ return h.valueAt(time, h.fields.indexOf(field));
232
+ }
233
+ /**
234
+ * Which timeline(s) the attached groups rewind to — `reckon` true iff any
235
+ * group is `mode:"reckon"`, `snapshot` true iff any is `mode:"snapshot"`. The
236
+ * room reads this once (groups are declared in `onCreate`, before any join) to
237
+ * pick which stamp(s) the client must ship: reckon-only / render-only / both /
238
+ * none. @internal
239
+ */
240
+ timelineMode() {
241
+ let reckon = false, snapshot = false;
242
+ for (const g of this.groups) {
243
+ if (g.reckoned) reckon = true;
244
+ else snapshot = true;
245
+ }
246
+ return { reckon, snapshot };
247
+ }
248
+ /**
249
+ * @internal Wire a resolver from sessionId → that client's auto-stamped
250
+ * `renderTime`. Called by {@link Room.allowRewindState} so {@link lastSeenBy}
251
+ * works for `mode:"snapshot"` groups; auto-enabled by attaching one.
252
+ */
253
+ bindRenderTime(resolver) {
254
+ this._renderTimeOf = resolver;
255
+ }
256
+ /**
257
+ * @internal Wire a resolver for the CURRENT server time (ms, same epoch as
258
+ * `record` times). Called by {@link Room.allowRewindState}; standalone users
259
+ * (tests/harnesses) should bind their own. Anchors the reckon midpoint in
260
+ * {@link at} at the true processing instant — the `lastRecordedAt` fallback
261
+ * is one tick stale by hit-test time, biasing the aim ~half a tick early:
262
+ * imperceptible on slow horizontal motion, but enough to flip knife-edge
263
+ * stomp/hit verdicts on fast vertical movers (a bobbing jumper).
264
+ */
265
+ bindNow(resolver) {
266
+ this._nowOf = resolver;
267
+ }
268
+ /**
269
+ * @internal Wire a resolver from sessionId → that client's auto-stamped
270
+ * reckon-display instant (`input(sid).reckonTime`). Called by
271
+ * {@link Room.allowRewindState}. With it, {@link lastSeenBy} aims
272
+ * `mode:"reckon"` groups at the EXACT instant the client displayed them
273
+ * (immune to its RTT-estimation error); without it (or while the stamp is
274
+ * still 0), the midpoint reconstruction in {@link at} is the fallback.
275
+ */
276
+ bindReckonTime(resolver) {
277
+ this._reckonTimeOf = resolver;
278
+ }
279
+ /**
280
+ * A {@link RewindView} of the tracked world at `time` (an acting client's render
281
+ * time), with the two things every hit test re-implements baked in:
282
+ * - **clamp** to `[lastRecordedAt − maxRewindMs, lastRecordedAt]` — an
283
+ * anti-spoof / clock-skew bound (a client can't rewind arbitrarily far), and
284
+ * - **live fallback**: `time <= 0` (the client's clock hasn't synced) → the
285
+ * newest recorded sample (≈ current position).
286
+ *
287
+ * Sugar over {@link valueAt}; pass the render time yourself (e.g. from
288
+ * `input(sid).renderTime`, or a value stored on the entity). For the common
289
+ * "rewind to a specific client's view" case use {@link lastSeenBy}.
290
+ *
291
+ * Re-aims and returns this Rewind's internal view by default — zero alloc,
292
+ * nothing to declare. Pass `out` to aim a view of your own instead; needed
293
+ * only to hold several views at once — see the {@link RewindView} doc.
294
+ */
295
+ at(time, out) {
296
+ return this._aim(time, 0, out);
297
+ }
298
+ /**
299
+ * Shared aiming: clamp the snapshot-timeline `time`, and resolve the reckon
300
+ * timeline either from the DIRECT `reckonStamp` (the instant the client's
301
+ * forward-reckoned entities were displayed at — exact, immune to the
302
+ * client's RTT-estimation error) or, when absent (0), by reconstruction.
303
+ */
304
+ _aim(time, reckonStamp, out) {
305
+ const newest = this._lastRecordedAt;
306
+ const oldest = newest - this.defaultMaxRewindMs;
307
+ const at = time <= 0 ? newest : time < oldest ? oldest : time > newest ? newest : time;
308
+ let reckonAt;
309
+ if (reckonStamp > 0) {
310
+ reckonAt = reckonStamp < oldest ? oldest : reckonStamp > newest ? newest : reckonStamp;
311
+ } else if (time <= 0) {
312
+ reckonAt = newest;
313
+ } else {
314
+ const anchor = this._nowOf !== void 0 ? this._nowOf() : newest;
315
+ const mid = (time + anchor) / 2;
316
+ reckonAt = mid < oldest ? oldest : mid > newest ? newest : mid;
317
+ }
318
+ return (out ?? this._view)._retarget(this, at, reckonAt);
319
+ }
320
+ /**
321
+ * A {@link RewindView} of the world as session `sessionId` LAST saw it —
322
+ * resolves the render time stamped on that client's most recently CONSUMED
323
+ * input (hence "last": under a multi-frame `drain()` it is the newest frame's
324
+ * stamp) and hands off to {@link at}. This is the "what you see is what you
325
+ * hit" path:
326
+ *
327
+ * ```ts
328
+ * const seen = this.rewind.lastSeenBy(shooterSessionId);
329
+ * const hit = overlaps(bullet, seen.value(target, "x"), seen.value(target, "y"));
330
+ * ```
331
+ *
332
+ * Requires the room's framework input (`this.defineInput(Input)`) — the
333
+ * per-client stamp auto-enables from the `attachAll` `mode` of the groups you
334
+ * rewind (declaration order vs `allowRewindState` doesn't matter). A client
335
+ * that merely hasn't stamped yet (clock still syncing, unknown sessionId) is
336
+ * NOT an error — it reads 0 and the view falls back to live. Use
337
+ * {@link at} with an explicit time if you stamp render times yourself.
338
+ *
339
+ * Re-aims and returns this Rewind's internal view by default — zero alloc,
340
+ * nothing to declare. Pass `out` to aim a view of your own instead; needed
341
+ * only to hold several views at once — see the {@link RewindView} doc.
342
+ */
343
+ lastSeenBy(sessionId, out) {
344
+ if (this._renderTimeOf === void 0) {
345
+ throw new Error(
346
+ "Rewind.lastSeenBy(sessionId) needs the framework input API. Declare `this.defineInput(Input)` (stamps auto-enable from your attachAll `mode`), or call `at(time)` and pass the render time yourself."
347
+ );
348
+ }
349
+ return this._aim(
350
+ this._renderTimeOf(sessionId),
351
+ this._reckonTimeOf !== void 0 ? this._reckonTimeOf(sessionId) : 0,
352
+ out
353
+ );
354
+ }
355
+ };
356
+ export {
357
+ Rewind,
358
+ RewindView
359
+ };