@colyseus/schema 5.0.6 → 5.0.8

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 (69) hide show
  1. package/build/Reflection.d.ts +53 -0
  2. package/build/Schema.d.ts +26 -1
  3. package/build/annotations.d.ts +8 -1
  4. package/build/codegen/cli.cjs +74 -64
  5. package/build/codegen/cli.cjs.map +1 -1
  6. package/build/codegen/types.d.ts +0 -1
  7. package/build/decoder/Decoder.d.ts +28 -0
  8. package/build/decoder/Resync.d.ts +61 -0
  9. package/build/encoder/ChangeTree.d.ts +15 -0
  10. package/build/encoder/Encoder.d.ts +13 -0
  11. package/build/encoder/Pool.d.ts +62 -0
  12. package/build/encoder/Root.d.ts +5 -4
  13. package/build/encoder/StateView.d.ts +12 -3
  14. package/build/index.cjs +1192 -284
  15. package/build/index.cjs.map +1 -1
  16. package/build/index.d.ts +3 -0
  17. package/build/index.js +1192 -284
  18. package/build/index.mjs +1189 -285
  19. package/build/index.mjs.map +1 -1
  20. package/build/input/InputDecoder.d.ts +9 -4
  21. package/build/input/InputEncoder.d.ts +44 -53
  22. package/build/input/index.cjs +102 -7321
  23. package/build/input/index.cjs.map +1 -1
  24. package/build/input/index.mjs +97 -7316
  25. package/build/input/index.mjs.map +1 -1
  26. package/build/types/HelperTypes.d.ts +3 -0
  27. package/build/types/builder.d.ts +57 -4
  28. package/build/types/custom/ArraySchema.d.ts +11 -1
  29. package/build/types/custom/CollectionSchema.d.ts +9 -1
  30. package/build/types/custom/MapSchema.d.ts +9 -1
  31. package/build/types/custom/SetSchema.d.ts +9 -1
  32. package/build/types/custom/StreamSchema.d.ts +2 -1
  33. package/build/types/quantize.d.ts +102 -0
  34. package/build/types/symbols.d.ts +15 -0
  35. package/package.json +8 -1
  36. package/src/Metadata.ts +34 -9
  37. package/src/Reflection.ts +49 -11
  38. package/src/Schema.ts +64 -2
  39. package/src/annotations.ts +133 -51
  40. package/src/bench_churn.ts +121 -0
  41. package/src/codegen/languages/haxe.ts +0 -16
  42. package/src/codegen/parser.ts +29 -11
  43. package/src/codegen/types.ts +45 -63
  44. package/src/decoder/DecodeOperation.ts +46 -4
  45. package/src/decoder/Decoder.ts +48 -0
  46. package/src/decoder/ReferenceTracker.ts +9 -5
  47. package/src/decoder/Resync.ts +170 -0
  48. package/src/encoder/ChangeTree.ts +59 -0
  49. package/src/encoder/Encoder.ts +53 -12
  50. package/src/encoder/Pool.ts +90 -0
  51. package/src/encoder/Root.ts +22 -32
  52. package/src/encoder/StateView.ts +101 -50
  53. package/src/encoder/changeTree/liveIteration.ts +4 -0
  54. package/src/encoder/changeTree/treeAttachment.ts +28 -24
  55. package/src/index.ts +11 -1
  56. package/src/input/InputDecoder.ts +11 -5
  57. package/src/input/InputEncoder.ts +85 -126
  58. package/src/types/HelperTypes.ts +7 -0
  59. package/src/types/TypeContext.ts +7 -0
  60. package/src/types/builder.ts +62 -5
  61. package/src/types/custom/ArraySchema.ts +70 -12
  62. package/src/types/custom/CollectionSchema.ts +36 -1
  63. package/src/types/custom/MapSchema.ts +50 -1
  64. package/src/types/custom/SetSchema.ts +36 -1
  65. package/src/types/custom/StreamSchema.ts +7 -0
  66. package/src/types/quantize.ts +173 -0
  67. package/src/types/symbols.ts +16 -0
  68. package/build/encoder/RefIdAllocator.d.ts +0 -35
  69. package/src/encoder/RefIdAllocator.ts +0 -68
package/src/index.ts CHANGED
@@ -79,6 +79,7 @@ export { getRawChangesCallback } from "./decoder/strategy/RawChanges.js";
79
79
 
80
80
  export { Encoder } from "./encoder/Encoder.js";
81
81
  export { Root } from "./encoder/Root.js";
82
+ export { createPool, type SchemaPool, type PoolOptions } from "./encoder/Pool.js";
82
83
  export { encodeSchemaOperation, encodeArray, encodeKeyValueOperation, encodeMapEntry, encodeIndexedEntry } from "./encoder/EncodeOperation.js";
83
84
  export { ChangeTree, type Ref, type IRef } from "./encoder/ChangeTree.js";
84
85
  export { StateView } from "./encoder/StateView.js";
@@ -86,4 +87,13 @@ export { StateView } from "./encoder/StateView.js";
86
87
  export { Decoder } from "./decoder/Decoder.js";
87
88
  export { decodeSchemaOperation, decodeKeyValueOperation } from "./decoder/DecodeOperation.js";
88
89
 
89
- export { OPERATION } from "./encoding/spec.js";
90
+ export { OPERATION } from "./encoding/spec.js";
91
+
92
+ // Re-exported for `@colyseus/schema/input` — that subpath bundle is built
93
+ // as a thin wrapper that imports identity-bearing modules from here at
94
+ // runtime, so it needs `getEncodeDescriptor` available on the public surface.
95
+ export { getEncodeDescriptor, type EncodeDescriptor } from "./encoder/EncodeDescriptor.js";
96
+
97
+ // Symbols used by InputEncoder/InputDecoder via the runtime-externalized
98
+ // `@colyseus/schema` import in `build/input/index.mjs`.
99
+ export { $numFields, $values } from "./types/symbols.js";
@@ -34,21 +34,27 @@ export class InputDecoder<T extends Schema = any> {
34
34
  /**
35
35
  * Walk a multi-input (unreliable) packet, decoding each length-framed
36
36
  * input into the bound instance in order and invoking `onInput` after
37
- * each decode. `onInput` receives the bound instance itself reads
38
- * must be synchronous; downstream code should apply the input to game
39
- * state, not retain the reference.
37
+ * each decode. `onInput` receives the bound instance and the framework
38
+ * **seq** of that input (decoded from the packet's base seq + position) —
39
+ * the receiver dedupes the redundancy ring on it (monotonic: drop seq ≤
40
+ * last seen). Reads of the instance must be synchronous; downstream code
41
+ * should apply the input to game state, not retain the reference.
42
+ *
43
+ * Packet layout: `[baseSeq][len][body]…[len][body]` (oldest→newest);
44
+ * slot *i* has `seq = baseSeq + i`.
40
45
  *
41
46
  * Returns the number of inputs decoded.
42
47
  */
43
- decodeAll(bytes: Uint8Array, onInput: (instance: T, index: number) => void): number {
48
+ decodeAll(bytes: Uint8Array, onInput: (instance: T, seq: number) => void): number {
44
49
  const it = this._it;
45
50
  it.offset = 0;
51
+ const baseSeq = decode.number(bytes, it); // packet-level base seq (oldest slot)
46
52
  let count = 0;
47
53
  while (it.offset < bytes.length) {
48
54
  const len = decode.number(bytes, it);
49
55
  const end = it.offset + len;
50
56
  this._decoder.decode(bytes.subarray(it.offset, end));
51
- onInput(this.instance, count);
57
+ onInput(this.instance, baseSeq + count); // slot seq by position
52
58
  it.offset = end;
53
59
  count++;
54
60
  }
@@ -1,4 +1,3 @@
1
- import { OPERATION } from "../encoding/spec.js";
2
1
  import { $numFields, $values, $changes } from "../types/symbols.js";
3
2
  import { encode } from "../encoding/encode.js";
4
3
  import { Encoder } from "../encoder/Encoder.js";
@@ -26,31 +25,6 @@ export interface InputEncoderOptions {
26
25
  * reliable mode (always exactly one input per packet).
27
26
  */
28
27
  historySize?: number;
29
-
30
- /**
31
- * When `true`, `encode()` emits only fields that changed since the
32
- * previous call. First call (or first after `reset()`) still emits
33
- * a full snapshot since there's no baseline to diff against.
34
- *
35
- * **Reliable mode:** returns an empty `Uint8Array` when nothing
36
- * changed — caller can skip sending.
37
- *
38
- * **Unreliable mode:** no-change ticks don't push a new ring slot
39
- * (avoids bloating the ring with empties); the existing ring is
40
- * still re-emitted for redundancy. Wire ops use absolute values so
41
- * cross-packet re-application is per-field idempotent.
42
- *
43
- * Decoder side is unchanged in either case: `(index|ADD)` wire ops
44
- * apply to the bound instance; fields absent from a packet stay at
45
- * their previously decoded value.
46
- */
47
- delta?: boolean;
48
-
49
- /**
50
- * Override the reliable-mode output buffer. Default: 256 bytes,
51
- * auto-grown on overflow.
52
- */
53
- buffer?: Uint8Array;
54
28
  }
55
29
 
56
30
  const DEFAULT_SLOT_SIZE = 256;
@@ -60,17 +34,23 @@ const LENGTH_PREFIX_WORST_CASE = 5; // varint max for a uint32 length.
60
34
  * Bound single-struct encoder for client→server input packets. Holds a
61
35
  * reference to a Schema instance and produces wire-compatible bytes.
62
36
  *
63
- * **Reliable mode** emits one snapshot per `encode()`. The bytes decode
64
- * cleanly through the standard {@link Decoder}.
37
+ * Always delta-encodes: each `encode()` emits only the fields that changed
38
+ * since the previous call (via the setter-populated ChangeTree dirty set),
39
+ * wrapping a standard {@link Encoder}. The first call (or first after
40
+ * {@link reset}) emits a full snapshot, since there's no baseline to diff
41
+ * against.
65
42
  *
66
- * **Unreliable mode** pushes each snapshot onto a ring buffer of size
67
- * `historySize` and emits the last N snapshots in one packet, each
68
- * framed with a varint length prefix. Use {@link InputDecoder.decodeAll}
69
- * to walk the framed packet on the receiving end.
43
+ * **Reliable mode** emits one delta per `encode()` or an empty
44
+ * `Uint8Array` when nothing changed (the caller decides whether to send a
45
+ * body-less keep-alive or skip the tick). The bytes decode cleanly through
46
+ * the standard {@link Decoder}.
70
47
  *
71
- * **Delta** (`delta: true`, any mode) wraps a standard {@link Encoder}
72
- * and emits only fields that changed since the last call, via the
73
- * setter-populated ChangeTree dirty set.
48
+ * **Unreliable mode** pushes each delta onto a ring buffer of size
49
+ * `historySize` and emits the last N in one packet, each framed with a
50
+ * varint length prefix. A no-change tick still pushes an (empty,
51
+ * carry-forward) slot so every tick gets a consecutive framework seq. Use
52
+ * {@link InputDecoder.decodeAll} to walk the framed packet. Wire ops use
53
+ * absolute values, so re-applying a redundant slot is per-field idempotent.
74
54
  *
75
55
  * Flat primitive fields only. Nested Schema / collection fields throw
76
56
  * at construction.
@@ -79,32 +59,30 @@ export class InputEncoder<T extends Schema = any> {
79
59
  readonly instance: T;
80
60
  readonly mode: InputMode;
81
61
  readonly historySize: number;
82
- readonly delta: boolean;
83
62
 
84
63
  private readonly _desc: EncodeDescriptor;
85
64
  private readonly _numFields: number;
86
65
 
87
- // Reliable-mode output + its iterator. Auto-grown on overflow, so
88
- // not `readonly` — `_encodeFull()` may swap in a larger buffer.
89
- private _buffer: Uint8Array;
90
- private readonly _it = { offset: 0 };
91
-
92
- // Unreliable-mode ring state. `_outBuffer` is where the concatenated
93
- // packet lives; `_slots` / `_slotLens` hold each snapshot's bytes.
66
+ // Unreliable-mode ring: `_slots`/`_slotLens` hold each snapshot; `_outBuffer` holds the concatenated packet.
94
67
  private _slots?: Uint8Array[];
95
68
  private readonly _slotLens?: number[];
96
69
  private _slotHead: number = 0;
97
70
  private _slotCount: number = 0;
98
71
  private _outBuffer?: Uint8Array;
72
+ // Monotonic per-tick input seq (unreliable only): ++ per pushed slot. The
73
+ // packet carries the OLDEST slot's seq once; the decoder derives each slot's
74
+ // seq by position (slots are consecutive — every tick pushes). This is the
75
+ // framework-owned seq that drives server-side dedupe of the redundancy ring
76
+ // WITHOUT the user adding a seq field. Monotonic across reset() so a
77
+ // reconnect that reuses the server buffer doesn't replay already-seen seqs.
78
+ private _seq: number = 0;
99
79
 
100
- // Delta-mode delegate. Setter `$track` calls populate its
101
- // ChangeTree; `encode()` drains dirty fields into its buffer.
102
- private readonly _encoder?: Encoder<T>;
80
+ // Delta delegate; setters populate its ChangeTree, `encode()` drains dirty fields.
81
+ private readonly _encoder: Encoder<T>;
103
82
 
104
83
  constructor(instance: T, options: InputEncoderOptions = {}) {
105
84
  this.instance = instance;
106
85
  this.mode = options.mode ?? "reliable";
107
- this.delta = options.delta ?? false;
108
86
  this.historySize = this.mode === "unreliable"
109
87
  ? Math.max(1, options.historySize ?? 3)
110
88
  : 1;
@@ -124,8 +102,6 @@ export class InputEncoder<T extends Schema = any> {
124
102
  }
125
103
  }
126
104
 
127
- this._buffer = options.buffer ?? new Uint8Array(DEFAULT_SLOT_SIZE);
128
-
129
105
  if (this.mode === "unreliable") {
130
106
  this._slots = new Array(this.historySize);
131
107
  this._slotLens = new Array(this.historySize).fill(0);
@@ -137,113 +113,93 @@ export class InputEncoder<T extends Schema = any> {
137
113
  );
138
114
  }
139
115
 
140
- if (this.delta) {
141
- // Delegate to the standard Encoder it attaches a Root and
142
- // drains the setter-populated dirty set on each encode().
143
- // Tracking stays enabled so setters keep populating it.
144
- this._encoder = new Encoder<T>(instance);
145
- } else {
146
- // Full mode reads `$values` directly; setter-side tracking
147
- // is pure overhead, so pause it.
148
- instance.pauseTracking();
149
- }
116
+ // Wrap a standard Encoder; keep tracking on so setters keep populating
117
+ // the dirty set we drain each encode().
118
+ this._encoder = new Encoder<T>(instance);
150
119
  }
151
120
 
152
121
  /**
153
- * Encode the bound instance. Returns a subarray of an internal
122
+ * The framework input seq of the most recently encoded tick (unreliable
123
+ * mode): monotonic, ++ per `encode()`, kept across {@link reset}. `0` in
124
+ * reliable mode (which sequences inputs implicitly by message count). The
125
+ * client keys its reconciliation replay ring by this so the server's
126
+ * seq-value ack lines up across packet loss.
127
+ */
128
+ get seq(): number { return this._seq; }
129
+
130
+ /**
131
+ * Encode the bound instance's delta. Returns a subarray of an internal
154
132
  * buffer — copy if retaining across calls.
155
133
  *
156
- * Output shape by configuration:
157
- * - `reliable` + full: one snapshot's worth of bytes.
158
- * - `reliable` + delta: only changed fields, or empty when nothing
159
- * changed.
160
- * - `unreliable` + full: ring of last `historySize` snapshots,
161
- * length-framed per slot.
162
- * - `unreliable` + delta: ring of last `historySize` deltas. No-
163
- * change ticks don't push a new slot but still re-emit the ring.
164
- * Empty only until the first change has been pushed.
134
+ * Output shape by mode:
135
+ * - `reliable`: only changed fields, or empty when nothing changed.
136
+ * - `unreliable`: ring of the last `historySize` deltas, length-framed
137
+ * per slot. A no-change tick pushes an empty (carry-forward) slot but
138
+ * still re-emits the ring. Empty only until the first slot is pushed.
165
139
  *
166
140
  * Buffers auto-grow on overflow; a one-time `console.warn` is
167
141
  * emitted the first time it happens.
168
142
  */
169
143
  encode(): Uint8Array {
170
- const blob = this.delta ? this._produceDelta() : this._produceFull();
144
+ const blob = this._produceDelta();
171
145
  return this.mode === "reliable" ? blob : this._pushAndEmitRing(blob);
172
146
  }
173
147
 
174
148
  /**
175
149
  * Reset the encoder's internal state:
176
- * - Unreliable mode: drops the ring buffer.
177
- * - Delta mode: re-marks every currently populated field as dirty,
178
- * so the next `encode()` emits a fresh full snapshot.
150
+ * - Drops the unreliable ring buffer.
151
+ * - Re-marks every currently populated field as dirty, so the next
152
+ * `encode()` emits a fresh full snapshot.
179
153
  *
180
154
  * Useful on disconnect / reconnect / scene transitions.
181
155
  */
182
156
  reset(): void {
183
157
  this._slotHead = 0;
184
158
  this._slotCount = 0;
185
- if (this.delta) {
186
- this._encoder!.discardChanges();
187
- const tree = (this.instance as any)[$changes];
188
- const values = (this.instance as any)[$values];
189
- for (let i = 0; i <= this._numFields; i++) {
190
- if (values[i] === undefined || values[i] === null) continue;
191
- tree.markDirty(i);
192
- }
159
+ this._encoder.discardChanges();
160
+ const tree = this.instance[$changes];
161
+ const values = this.instance[$values];
162
+ for (let i = 0; i <= this._numFields; i++) {
163
+ if (values[i] === undefined || values[i] === null) continue;
164
+ tree.markDirty(i);
193
165
  }
194
166
  }
195
167
 
168
+ /**
169
+ * Copy the bound instance's field values into `target` (a same-type instance)
170
+ * in place — no allocation, no `Object.keys` (cf. `Schema#assign`) and no
171
+ * `clone()`. For buffering a snapshot of the just-sent input into a reused
172
+ * slot (e.g. a client reconciliation/replay ring) without the transport
173
+ * having to reach into schema internals. The codec owns this because it owns
174
+ * the field representation. The in-place / alloc-free cousin of `clone()`,
175
+ * and the inverse direction of `Schema#assign(source)`.
176
+ */
177
+ copyInto(target: T): void {
178
+ const src = (this.instance as any)[$values];
179
+ const dst = (target as any)[$values];
180
+ for (let i = 0; i <= this._numFields; i++) dst[i] = src[i];
181
+ }
182
+
196
183
  // ────────────────────────────────────────────────────────────────────
197
- // Blob producersreturn a single snapshot (or delta) of the
198
- // current instance. Caller routes by mode.
184
+ // Delta producerone diff of the instance; caller routes by mode.
199
185
  // ────────────────────────────────────────────────────────────────────
200
186
 
201
- /** Write every populated primitive field into `_buffer`. */
202
- private _produceFull(): Uint8Array {
203
- let buf = this._buffer;
204
- const it = this._it;
205
- this._writeFields(buf, it);
206
- if (it.offset > buf.byteLength) {
207
- buf = this._buffer = InputEncoder._grow(buf, it.offset, "reliable encode");
208
- this._writeFields(buf, it);
209
- }
210
- return buf.subarray(0, it.offset);
211
- }
212
-
213
187
  /** Delegate to the wrapped Encoder, then clear its dirty set. */
214
188
  private _produceDelta(): Uint8Array {
215
- const bytes = this._encoder!.encode();
216
- this._encoder!.discardChanges();
189
+ const bytes = this._encoder.encode();
190
+ this._encoder.discardChanges();
217
191
  return bytes;
218
192
  }
219
193
 
220
- /** Emit every populated field as `(index|ADD)` + value. */
221
- private _writeFields(buf: Uint8Array, it: { offset: number }): void {
222
- const values = (this.instance as any)[$values];
223
- const encoders = this._desc.encoders;
224
- it.offset = 0;
225
- for (let i = 0; i <= this._numFields; i++) {
226
- const value = values[i];
227
- if (value === undefined || value === null) continue;
228
- buf[it.offset++] = (i | OPERATION.ADD) & 255;
229
- (encoders[i] as (b: Uint8Array, v: any, it: any) => void)(buf, value, it);
230
- }
231
- }
232
-
233
194
  // ────────────────────────────────────────────────────────────────────
234
- // Ring — push blob to the current slot, concat (oldest newest)
235
- // into the output buffer, return the framed packet.
195
+ // Ring — push blob to current slot, concat oldest→newest, return framed packet.
236
196
  // ────────────────────────────────────────────────────────────────────
237
197
 
238
198
  private _pushAndEmitRing(blob: Uint8Array): Uint8Array {
239
- // Empty blob = no-change delta tick. Skip the push but re-emit
240
- // the existing ring (redundancy). Empty ring empty output.
241
- if (blob.length === 0) {
242
- if (this._slotCount === 0) return this._outBuffer!.subarray(0, 0);
243
- return this._emitRing();
244
- }
245
-
246
- // Copy blob into the current slot, growing it if needed.
199
+ // Push a slot EVERY tick (even an empty delta) so ring seqs stay
200
+ // consecutive: the packet carries ONE base seq and the decoder derives
201
+ // each slot's seq by position. An empty slot decodes as carry-forward.
202
+ this._seq++;
247
203
  let slot = this._slots![this._slotHead];
248
204
  if (blob.length > slot.byteLength) {
249
205
  slot = this._slots![this._slotHead] = InputEncoder._grow(
@@ -259,8 +215,12 @@ export class InputEncoder<T extends Schema = any> {
259
215
  }
260
216
 
261
217
  private _emitRing(): Uint8Array {
262
- // Upper bound: sum of slot byte counts + per-slot varint length.
263
- let needed = 0;
218
+ // Packet layout: [baseSeq][len][slot]…[len][slot] (oldest→newest).
219
+ // baseSeq = the oldest slot's seq; consecutive slots ⇒ slot i has seq baseSeq + i.
220
+ const baseSeq = this._seq - this._slotCount + 1;
221
+
222
+ // Upper bound: baseSeq varint + sum of slot byte counts + per-slot varint length.
223
+ let needed = LENGTH_PREFIX_WORST_CASE;
264
224
  for (let i = 0; i < this._slotCount; i++) {
265
225
  needed += this._slotLens![i] + LENGTH_PREFIX_WORST_CASE;
266
226
  }
@@ -271,6 +231,7 @@ export class InputEncoder<T extends Schema = any> {
271
231
  }
272
232
 
273
233
  const outIt = { offset: 0 };
234
+ encode.number(out, baseSeq, outIt); // packet-level base seq
274
235
  const oldest = (this._slotHead - this._slotCount + this.historySize) % this.historySize;
275
236
  for (let i = 0; i < this._slotCount; i++) {
276
237
  const idx = (oldest + i) % this.historySize;
@@ -283,9 +244,7 @@ export class InputEncoder<T extends Schema = any> {
283
244
  }
284
245
 
285
246
  // ────────────────────────────────────────────────────────────────────
286
- // Buffer growth. Uint8Array writes past `byteLength` silently drop
287
- // but `it.offset` still advances — callers detect overflow via the
288
- // `offset > byteLength` check and re-encode into the grown buffer.
247
+ // Buffer growth. Writes past `byteLength` silently drop but `it.offset` still advances, so callers detect overflow via `offset > byteLength` and re-encode into the grown buffer.
289
248
  // ────────────────────────────────────────────────────────────────────
290
249
 
291
250
  private static _warned = false;
@@ -295,7 +254,7 @@ export class InputEncoder<T extends Schema = any> {
295
254
  InputEncoder._warned = true;
296
255
  console.warn(
297
256
  `@colyseus/schema/input: InputEncoder buffer overflow in ${where}. ` +
298
- `Growing to ${newSize} bytes. Pass a larger { buffer } option to avoid this at runtime.`
257
+ `Growing to ${newSize} bytes.`
299
258
  );
300
259
  }
301
260
  return new Uint8Array(newSize);
@@ -1,3 +1,4 @@
1
+ import { $resyncPrune } from "./symbols.js";
1
2
  import type { Definition, DefinitionType, PrimitiveType, RawPrimitiveType } from "../annotations.js";
2
3
  import type { Schema } from "../Schema.js";
3
4
  import type { ArraySchema } from "./custom/ArraySchema.js";
@@ -20,6 +21,12 @@ export interface Collection<K = any, V = any, IT = V> {
20
21
  [Symbol.iterator](): IterableIterator<IT>;
21
22
  forEach(callback: Function): void;
22
23
  entries(): IterableIterator<[K, V]>;
24
+ /** See {@link $resyncPrune} — every collection kind must declare its resync-sweep semantics. */
25
+ [$resyncPrune](
26
+ visited: Set<number | string>,
27
+ prune: (value: V, identity: number | string) => void,
28
+ keep: (value: V) => void,
29
+ ): void;
23
30
  }
24
31
 
25
32
  export type InferValueType<T> =
@@ -1,6 +1,7 @@
1
1
  import { Metadata } from "../Metadata.js";
2
2
  import { Schema } from "../Schema.js";
3
3
  import { $streamFieldIndexes, $viewFieldIndexes } from "./symbols.js";
4
+ import { isQuantizedType } from "./quantize.js";
4
5
 
5
6
  export class TypeContext {
6
7
  types: { [id: number]: typeof Schema; } = {};
@@ -125,6 +126,12 @@ export class TypeContext {
125
126
  continue;
126
127
  }
127
128
 
129
+ // Quantized fields are scalar — their object `type` only carries the
130
+ // descriptor, there's no child Schema to discover.
131
+ if (isQuantizedType(fieldType)) {
132
+ continue;
133
+ }
134
+
128
135
  if (typeof (fieldType) === "function") {
129
136
  this.discoverTypes(fieldType as typeof Schema, klass, index, parentHasViewTag || fieldHasViewTag);
130
137
 
@@ -8,6 +8,7 @@ import type { DefinitionType, RawPrimitiveType } from "../annotations.js";
8
8
  import type { InferValueType, Constructor } from "./HelperTypes.js";
9
9
  import { $builder } from "./symbols.js";
10
10
  import { ARRAY_STREAM_NOT_SUPPORTED } from "../encoder/streaming.js";
11
+ import { resolveQuantize, type QuantizeOptions } from "./quantize.js";
11
12
 
12
13
  type CollectionKind = "array" | "map" | "set" | "collection";
13
14
 
@@ -89,8 +90,23 @@ export class FieldBuilder<
89
90
  this._type = type;
90
91
  }
91
92
 
92
- /** Provide a default value for this field. */
93
- default(value: T): FieldBuilder<T, true, IsOptional> {
93
+ /**
94
+ * Provide a default value for this field.
95
+ *
96
+ * Pass a **factory function** `() => T` to build a FRESH value per instance
97
+ * (invoked once per construction) instead of sharing a single default — the
98
+ * clean way to default a ref to a plain custom class, or any field that must
99
+ * not share a mutable default across instances:
100
+ *
101
+ * ```ts
102
+ * acc: t.ref(GunAccuracy).noSync().default(() => new GunAccuracy()),
103
+ * ```
104
+ *
105
+ * Schema fields are never function-typed, so a function is always treated as a
106
+ * factory. A non-function value is shared (and cloned per instance if it is
107
+ * clone-able, e.g. a Schema/collection).
108
+ */
109
+ default(value: T | (() => T)): FieldBuilder<T, true, IsOptional> {
94
110
  this._default = value;
95
111
  this._hasDefault = true;
96
112
  return this as unknown as FieldBuilder<T, true, IsOptional>;
@@ -359,12 +375,27 @@ type RefHasDefault<C> =
359
375
  : true;
360
376
 
361
377
  interface RefFactory {
362
- <C extends Constructor<Schema>>(ctor: C): FieldBuilder<InstanceType<C>, RefHasDefault<C>, false>;
378
+ <C extends Constructor>(ctor: C): FieldBuilder<InstanceType<C>, RefHasDefault<C>, false>;
363
379
  }
364
380
 
365
- const refFactory: RefFactory = (<C extends Constructor<Schema>>(ctor: C) =>
381
+ const refFactory: RefFactory = (<C extends Constructor>(ctor: C) =>
366
382
  new FieldBuilder<InstanceType<C>>(ctor as unknown as DefinitionType)) as RefFactory;
367
383
 
384
+ /**
385
+ * A bounded float carried on the wire as a fixed-width unsigned integer. App code
386
+ * reads/writes the FLOAT; the wire carries the quantized int and the field only
387
+ * ever yields `dequant(q)`, so client predict and server sim read the same value
388
+ * (no full-precision path to leak ⇒ no shot misprediction). See
389
+ * {@link QuantizeOptions} for the precision/wire trade-offs.
390
+ *
391
+ * yaw: t.quantized({ min: 0, max: TWO_PI, mode: "wrap" }), // 16-bit
392
+ * pitch: t.quantized({ min: -PITCH_LIMIT, max: PITCH_LIMIT }), // clamp (default)
393
+ * throttle: t.quantized({ min: 0, max: 1, bits: 8 }), // 1 byte
394
+ */
395
+ function quantizedFactory(opts: QuantizeOptions): FieldBuilder<number> {
396
+ return new FieldBuilder<number>({ quantized: resolveQuantize(opts) } as unknown as DefinitionType);
397
+ }
398
+
368
399
  export const t = Object.freeze({
369
400
  // Primitives
370
401
  string: primitive<string>("string"),
@@ -383,11 +414,37 @@ export const t = Object.freeze({
383
414
  bigint64: primitive<bigint>("bigint64"),
384
415
  biguint64: primitive<bigint>("biguint64"),
385
416
 
386
- /** Reference to a Schema subtype. `t.array(Item)` usually reads better, but this is available when a plain ref is needed. */
417
+ /**
418
+ * Reference to a Schema subtype — `t.array(Item)` usually reads better, but
419
+ * this is available when a plain ref is needed.
420
+ *
421
+ * The target may also be a **non-Schema custom class**. A synced ref still
422
+ * requires it to be encodable — a `Schema` subclass, or a class retrofitted
423
+ * with `Metadata.setFields(...)` (both carry `[Symbol.metadata]`); a bare
424
+ * custom class is rejected at `schema()` time. A `.noSync()` (local-only)
425
+ * field accepts ANY zero-arg class and auto-instantiates one per parent.
426
+ */
387
427
  ref: refFactory,
388
428
  array: arrayFactory,
389
429
  map: mapFactory,
390
430
  set: setFactory,
391
431
  collection: collectionFactory,
392
432
  stream: streamFactory,
433
+
434
+ /**
435
+ * A bounded float quantized to a fixed-width unsigned int on the wire — half
436
+ * (or a quarter) the bytes of a `float32` at a precision you pick. The field
437
+ * reads/writes the float and only ever yields `dequant(q)`. {@see QuantizeOptions}
438
+ */
439
+ quantized: quantizedFactory,
440
+
441
+ /**
442
+ * Sugar for a full-circle wrapping angle in radians:
443
+ * `t.quantized({ min: 0, max: 2π, mode: "wrap", bits })` (default 16-bit,
444
+ * ~0.0055°/step). Any input angle is range-reduced into `[0, 2π)`. Render note:
445
+ * lerp interpolated remotes shortest-arc (`attach({ angle: true })`) — the
446
+ * wrap fixes the WIRE seam, not interpolation (see {@link QuantizeOptions.mode}).
447
+ */
448
+ angle: (opts?: { bits?: 8 | 16 | 32 }) =>
449
+ quantizedFactory({ min: 0, max: Math.PI * 2, mode: "wrap", bits: opts?.bits ?? 16 }),
393
450
  });