@colyseus/schema 5.0.6 → 5.0.9

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 +16 -1
  4. package/build/codegen/cli.cjs +103 -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 +1211 -284
  15. package/build/index.cjs.map +1 -1
  16. package/build/index.d.ts +4 -1
  17. package/build/index.js +1211 -284
  18. package/build/index.mjs +1207 -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 +157 -51
  40. package/src/bench_churn.ts +121 -0
  41. package/src/codegen/languages/haxe.ts +0 -16
  42. package/src/codegen/parser.ts +69 -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 +12 -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/build/index.cjs CHANGED
@@ -59,6 +59,13 @@ const $decoder = "~decoder";
59
59
  const $filter = "~filter";
60
60
  const $getByIndex = "~getByIndex";
61
61
  const $deleteByIndex = "~deleteByIndex";
62
+ /**
63
+ * Resync-sweep hook (see decoder/Resync.ts): prune every entry the rejoin
64
+ * snapshot did not visit. Each collection owns its storage-specific
65
+ * bookkeeping (journal pruning, compaction, item indexes); the generic
66
+ * DELETE/ref/callback bookkeeping arrives via the `prune`/`keep` callbacks.
67
+ */
68
+ const $resyncPrune = "~resyncPrune";
62
69
  /**
63
70
  * Used to hold ChangeTree instances whitin the structures.
64
71
  *
@@ -83,6 +90,14 @@ const $proxyTarget = Symbol.for("$proxyTarget");
83
90
  * (Discards changes for next serialization)
84
91
  */
85
92
  const $onEncodeEnd = '~onEncodeEnd';
93
+ /**
94
+ * Optional "reset" method on every poolable Ref (Schema + collections).
95
+ * Empties the instance's backing store and recycles its ChangeTree WITHOUT
96
+ * emitting any wire op, and recurses into ref-type children — so the instance
97
+ * can be returned to a SchemaPool and reused, avoiding the cost of `new`.
98
+ * See encoder/Pool.ts and Schema.reset().
99
+ */
100
+ const $reset = "~reset";
86
101
  /**
87
102
  * When decoding, this method is called after the instance is fully decoded
88
103
  */
@@ -808,6 +823,92 @@ function streamDropView(s, viewId) {
808
823
  st.sentByView.delete(viewId);
809
824
  }
810
825
 
826
+ const WIRE_BY_BITS = {
827
+ 8: "uint8",
828
+ 16: "uint16",
829
+ 32: "uint32",
830
+ };
831
+ /** Validate options and precompute the wire codec + scale span. */
832
+ function resolveQuantize(opts) {
833
+ if (opts == null || typeof opts !== "object") {
834
+ throw new Error("t.quantized(): options object with { min, max } is required.");
835
+ }
836
+ const { min, max } = opts;
837
+ if (typeof min !== "number" || typeof max !== "number" || !(max > min)
838
+ || !Number.isFinite(min) || !Number.isFinite(max)) {
839
+ throw new Error(`t.quantized(): require finite min < max (got min=${min}, max=${max}).`);
840
+ }
841
+ const bits = opts.bits ?? 16;
842
+ if (bits !== 8 && bits !== 16 && bits !== 32) {
843
+ throw new Error(`t.quantized(): bits must be 8, 16 or 32 (got ${bits}).`);
844
+ }
845
+ const mode = opts.mode ?? "clamp";
846
+ if (mode !== "clamp" && mode !== "wrap") {
847
+ throw new Error(`t.quantized(): mode must be "clamp" or "wrap" (got ${JSON.stringify(mode)}).`);
848
+ }
849
+ const wrap = mode === "wrap"; // descriptor + wire stay boolean; `mode` is the public knob
850
+ const steps = Math.pow(2, bits);
851
+ return {
852
+ min,
853
+ max,
854
+ bits,
855
+ wrap,
856
+ wire: WIRE_BY_BITS[bits],
857
+ range: max - min,
858
+ // wrapping spreads `2^bits` steps across [min,max) (top ≡ bottom); clamped
859
+ // maps the endpoints onto `0` and `2^bits − 1` inclusive.
860
+ span: wrap ? steps : steps - 1,
861
+ };
862
+ }
863
+ /** Type guard for the `{ quantized: QuantizeDescriptor }` field-type shape. */
864
+ function isQuantizedType(type) {
865
+ return type !== null && typeof type === "object" && type.quantized !== undefined;
866
+ }
867
+ /**
868
+ * Float → unsigned integer. Rounding is explicit half-up `floor(x + 0.5)`;
869
+ * wrapping ranges are reduced in the float domain first (no huge→int cast).
870
+ */
871
+ function quantize(desc, value) {
872
+ if (desc.wrap) {
873
+ // Non-finite can't be range-reduced (Inf % range = NaN); NaN would flow to
874
+ // the wire as garbage while the local instance kept NaN — a silent peer
875
+ // divergence. Pin to q=0 (= min): garbage in, deterministic out, both agree.
876
+ if (!Number.isFinite(value))
877
+ return 0;
878
+ const range = desc.range;
879
+ // float-domain range reduction → [0, range)
880
+ let a = (value - desc.min) % range;
881
+ if (a < 0)
882
+ a += range;
883
+ const steps = desc.span; // 2^bits
884
+ // `% steps` (not `& mask`) so bits=32 doesn't overflow JS's int32 bitwise.
885
+ return Math.floor((a / range) * steps + 0.5) % steps;
886
+ }
887
+ if (value !== value)
888
+ return 0; // NaN → min (±Inf clamps naturally below)
889
+ const v = value < desc.min ? desc.min : value > desc.max ? desc.max : value;
890
+ return Math.floor(((v - desc.min) / desc.range) * desc.span + 0.5);
891
+ }
892
+ /** Unsigned integer → float. Correctly-rounded mul/div ⇒ bit-identical across languages. */
893
+ function dequantize(desc, q) {
894
+ return desc.min + (q / desc.span) * desc.range;
895
+ }
896
+ /**
897
+ * Pre-baked encoder for a quantized field: quantize the (snapped) float held on
898
+ * the instance, then write it with the field's unsigned-int wire codec. Stored in
899
+ * `metadata[$encoders]` so the encode hot path reaches it via the fast lane,
900
+ * exactly like the pre-computed primitive encoders.
901
+ */
902
+ function makeQuantizedEncoder(desc) {
903
+ const writeWire = encode[desc.wire];
904
+ return (bytes, value, it) => writeWire(bytes, quantize(desc, value), it);
905
+ }
906
+ /** Decode a quantized field: read the unsigned-int wire value, then dequantize. */
907
+ function decodeQuantized(desc, bytes, it) {
908
+ const readWire = decode[desc.wire];
909
+ return dequantize(desc, readWire(bytes, it));
910
+ }
911
+
811
912
  class TypeContext {
812
913
  types = {};
813
914
  schemas = new Map();
@@ -910,6 +1011,11 @@ class TypeContext {
910
1011
  if (typeof (fieldType) === "string") {
911
1012
  continue;
912
1013
  }
1014
+ // Quantized fields are scalar — their object `type` only carries the
1015
+ // descriptor, there's no child Schema to discover.
1016
+ if (isQuantizedType(fieldType)) {
1017
+ continue;
1018
+ }
913
1019
  if (typeof (fieldType) === "function") {
914
1020
  this.discoverTypes(fieldType, klass, index, parentHasViewTag || fieldHasViewTag);
915
1021
  }
@@ -978,6 +1084,14 @@ function getNormalizedType(type) {
978
1084
  if (Array.isArray(type)) {
979
1085
  return { array: getNormalizedType(type[0]) };
980
1086
  }
1087
+ else if (isQuantizedType(type)) {
1088
+ // `{ quantized: ... }` — a scalar wire type, NOT a collection/ref. Resolve
1089
+ // raw options (the `@type({quantized:{min,max}})` path) once; an already-
1090
+ // resolved descriptor (the `t.quantized()` builder path) passes through.
1091
+ return (typeof type.quantized.wire === "string")
1092
+ ? type
1093
+ : { quantized: resolveQuantize(type.quantized) };
1094
+ }
981
1095
  else if (typeof (type['type']) !== "undefined") {
982
1096
  return type['type'];
983
1097
  }
@@ -1056,8 +1170,9 @@ const Metadata = {
1056
1170
  enumerable: false,
1057
1171
  configurable: true,
1058
1172
  });
1059
- // if child Ref/complex type, add to -4
1060
- if (typeof (metadata[index].type) !== "string") {
1173
+ // if child Ref/complex type, add to -4. Quantized fields are scalar (their
1174
+ // `type` is an object only to carry the descriptor) — not refs, so skip them.
1175
+ if (typeof (metadata[index].type) !== "string" && !isQuantizedType(metadata[index].type)) {
1061
1176
  if (metadata[$refTypeFieldIndexes] === undefined) {
1062
1177
  Object.defineProperty(metadata, $refTypeFieldIndexes, {
1063
1178
  value: [],
@@ -1119,10 +1234,25 @@ const Metadata = {
1119
1234
  });
1120
1235
  }
1121
1236
  metadata[$viewFieldIndexes].push(index);
1122
- if (!metadata[$fieldIndexesByViewTag][tag]) {
1123
- metadata[$fieldIndexesByViewTag][tag] = [];
1237
+ // Populate $fieldIndexesByViewTag: for a bitmask tag, register the field
1238
+ // index under each individual set bit so that view.add(obj, Tag.ONE) finds
1239
+ // fields tagged @view(Tag.ONE|Tag.TWO).
1240
+ // Negative tags (i.e. DEFAULT_VIEW_TAG = -1) are stored as-is.
1241
+ if (tag < 0) {
1242
+ if (!metadata[$fieldIndexesByViewTag][tag]) {
1243
+ metadata[$fieldIndexesByViewTag][tag] = [];
1244
+ }
1245
+ metadata[$fieldIndexesByViewTag][tag].push(index);
1246
+ }
1247
+ else {
1248
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
1249
+ const bit = bits & (-bits); // isolate lowest set bit
1250
+ if (!metadata[$fieldIndexesByViewTag][bit]) {
1251
+ metadata[$fieldIndexesByViewTag][bit] = [];
1252
+ }
1253
+ metadata[$fieldIndexesByViewTag][bit].push(index);
1254
+ }
1124
1255
  }
1125
- metadata[$fieldIndexesByViewTag][tag].push(index);
1126
1256
  },
1127
1257
  setUnreliable(metadata, fieldName) {
1128
1258
  const index = metadata[fieldName];
@@ -1228,8 +1358,8 @@ const Metadata = {
1228
1358
  if (metadata[$descriptors][fieldName]) {
1229
1359
  Object.defineProperty(target.prototype, fieldName, metadata[$descriptors][fieldName]);
1230
1360
  }
1231
- // Pre-compute encoder function for primitive types.
1232
- if (typeof normalized === "string") {
1361
+ // Pre-compute encoder function for primitive + quantized types.
1362
+ if (typeof normalized === "string" || isQuantizedType(normalized)) {
1233
1363
  if (!metadata[$encoders]) {
1234
1364
  Object.defineProperty(metadata, $encoders, {
1235
1365
  value: [],
@@ -1238,7 +1368,9 @@ const Metadata = {
1238
1368
  writable: true,
1239
1369
  });
1240
1370
  }
1241
- metadata[$encoders][fieldIndex] = encode[normalized];
1371
+ metadata[$encoders][fieldIndex] = (typeof normalized === "string")
1372
+ ? encode[normalized]
1373
+ : makeQuantizedEncoder(normalized.quantized);
1242
1374
  }
1243
1375
  },
1244
1376
  setFields(target, fields) {
@@ -1884,6 +2016,8 @@ function forEachLiveWithCtx(tree, ctx, cb) {
1884
2016
  const ref = tree.refTarget;
1885
2017
  if (ref[$childType] !== undefined) {
1886
2018
  // Collection inheriting @transient from parent field: skip entirely.
2019
+ // The resync sweep (decoder/Resync.ts) relies on this: a collection
2020
+ // absent from full-sync output is never pruned client-side.
1887
2021
  if (tree.isTransient)
1888
2022
  return;
1889
2023
  // Collection types: dispatch by shape.
@@ -1913,6 +2047,8 @@ function forEachLiveWithCtx(tree, ctx, cb) {
1913
2047
  // Schema: walk declared fields. `null` is treated as absent —
1914
2048
  // the setter records a DELETE when a field is set to null or
1915
2049
  // undefined, so it should not appear in full-sync output.
2050
+ // (@transient skips below matter to the resync sweep — see
2051
+ // decoder/Resync.ts: absent-from-payload means never pruned.)
1916
2052
  //
1917
2053
  // Read names from the per-class descriptor's parallel array —
1918
2054
  // saves the `metadata[i]` (per-field obj) + `.name` chain on
@@ -2176,6 +2312,12 @@ function propagateNewChildToSubscribers(parentTree, childIndex, childRef, root)
2176
2312
  }
2177
2313
  }
2178
2314
 
2315
+ /**
2316
+ * Tree-attachment helpers: setRoot / setParent + child-iteration recursion.
2317
+ * Hot path: every new Schema/Collection instance attached to the root
2318
+ * goes through here, which is why the recursive walk uses a hoisted
2319
+ * callback + ctx-pool instead of per-call closures.
2320
+ */
2179
2321
  function setRoot(tree, root) {
2180
2322
  tree.root = root;
2181
2323
  const isNewChangeTree = root.add(tree);
@@ -2228,30 +2370,10 @@ function setParent(tree, parent, root, parentIndex) {
2228
2370
  }
2229
2371
  }
2230
2372
  function forEachChild(tree, callback) {
2231
- //
2232
- // assign same parent on child structures
2233
- //
2234
- if (tree.ref[$childType]) {
2235
- if (typeof (tree.ref[$childType]) !== "string") {
2236
- // MapSchema / ArraySchema, etc.
2237
- for (const [key, value] of tree.ref.entries()) {
2238
- if (!value) {
2239
- continue;
2240
- } // sparse arrays can have undefined values
2241
- callback(value[$changes], tree.ref._collectionIndexes?.[key] ?? key);
2242
- }
2243
- }
2244
- }
2245
- else {
2246
- const names = tree.encDescriptor.names;
2247
- for (const index of tree.metadata?.[$refTypeFieldIndexes] ?? []) {
2248
- const value = tree.ref[names[index]];
2249
- if (!value) {
2250
- continue;
2251
- }
2252
- callback(value[$changes], index);
2253
- }
2254
- }
2373
+ forEachChildWithCtx(tree, callback, _forEachChildTrampoline);
2374
+ }
2375
+ function _forEachChildTrampoline(cb, change, at) {
2376
+ cb(change, at);
2255
2377
  }
2256
2378
  /**
2257
2379
  * Closure-free variant of {@link forEachChild}. Hot setRoot / setParent
@@ -2270,12 +2392,35 @@ function forEachChildWithCtx(tree, ctx, callback) {
2270
2392
  const ref = tree.refTarget;
2271
2393
  if (ref[$childType]) {
2272
2394
  if (typeof ref[$childType] !== "string") {
2273
- const collectionIndexes = ref._collectionIndexes;
2274
- for (const [key, value] of ref.entries()) {
2275
- if (!value) {
2276
- continue;
2395
+ const items = ref.items;
2396
+ if (items !== undefined) {
2397
+ // ArraySchema (raw target): dense index loop — the previous
2398
+ // `for..of entries()` allocated an iterator + a [key, value]
2399
+ // pair array per child (top-10 allocation site in the
2400
+ // stateview and deep-nested heap profiles).
2401
+ for (let i = 0, len = items.length; i < len; i++) {
2402
+ const value = items[i];
2403
+ if (!value) {
2404
+ continue;
2405
+ } // sparse arrays can have undefined values
2406
+ callback(ctx, value[$changes], i);
2407
+ }
2408
+ }
2409
+ else {
2410
+ // Map-backed collections (MapSchema/SetSchema/CollectionSchema/
2411
+ // StreamSchema all store `$items: Map`): keys() loop skips the
2412
+ // per-child [key, value] pair arrays of entries(), with no
2413
+ // closure either (a forEach closure showed up as a GC
2414
+ // regression on the construct bench).
2415
+ const $items = ref.$items;
2416
+ const collectionIndexes = ref._collectionIndexes;
2417
+ for (const key of $items.keys()) {
2418
+ const value = $items.get(key);
2419
+ if (!value) {
2420
+ continue;
2421
+ }
2422
+ callback(ctx, value[$changes], collectionIndexes?.[key] ?? key);
2277
2423
  }
2278
- callback(ctx, value[$changes], collectionIndexes?.[key] ?? key);
2279
2424
  }
2280
2425
  }
2281
2426
  }
@@ -2360,6 +2505,12 @@ const IS_UNRELIABLE = 8, IS_TRANSIENT = 16, IS_STATIC = 32;
2360
2505
  // `t.map(X).stream()` / `t.set(X).stream()` route through the same
2361
2506
  // emission machinery.
2362
2507
  const IS_STREAM_COLLECTION = 64;
2508
+ // Set by `recycle()` (Schema.reset / pooling): the tree's values are live
2509
+ // but its dirty buckets were cleared, so `Root.add` must re-stage every
2510
+ // populated field as ADD when the instance re-enters a tree. Without this,
2511
+ // only fields assigned after `pool.acquire()` would reach the wire — the
2512
+ // retained ones (constructor-initialized children) would never be encoded.
2513
+ const NEEDS_RESTAGE = 128;
2363
2514
  /**
2364
2515
  * Flags a child inherits from its parent's own transitive state via
2365
2516
  * `checkInheritedFlags`. Read as a bitwise mask so the inheritance step
@@ -2472,6 +2623,8 @@ class ChangeTree {
2472
2623
  set isStatic(v) { this.flags = v ? (this.flags | IS_STATIC) : (this.flags & ~IS_STATIC); }
2473
2624
  get isStreamCollection() { return (this.flags & IS_STREAM_COLLECTION) !== 0; }
2474
2625
  set isStreamCollection(v) { this.flags = v ? (this.flags | IS_STREAM_COLLECTION) : (this.flags & ~IS_STREAM_COLLECTION); }
2626
+ get needsRestage() { return (this.flags & NEEDS_RESTAGE) !== 0; }
2627
+ set needsRestage(v) { this.flags = v ? (this.flags | NEEDS_RESTAGE) : (this.flags & ~NEEDS_RESTAGE); }
2475
2628
  // True iff tree inherits `isFiltered` OR its Schema class declares any
2476
2629
  // @view-tagged fields. StateView.addParentOf uses this to decide whether
2477
2630
  // a parent must be included in a view's bootstrap. Reads the class-level
@@ -2755,6 +2908,48 @@ class ChangeTree {
2755
2908
  if (this.collPureOps !== undefined)
2756
2909
  this.collPureOps.length = 0;
2757
2910
  }
2911
+ /**
2912
+ * Full reset to construction defaults so the owning ref can be returned to
2913
+ * a pool and reused for a different logical entity (see encoder/Pool.ts +
2914
+ * Schema.reset). Unlike `reset()` / `endEncode()` (which only clear the
2915
+ * dirty bucket for the next encode), this also drops parent links, queue
2916
+ * nodes and per-view bitmaps, and re-arms IS_NEW.
2917
+ *
2918
+ * Precondition: the tree must already be detached from the encoder
2919
+ * (`root === undefined`) — i.e. the ref was removed from its parent
2920
+ * collection/field, which `Root.remove` does before this runs.
2921
+ */
2922
+ recycle() {
2923
+ if (this.root !== undefined) {
2924
+ throw new Error(`@colyseus/schema: cannot recycle an attached ChangeTree ` +
2925
+ `(${this.ref?.constructor?.name}). Remove the instance from its ` +
2926
+ `parent collection before releasing it to a pool.`);
2927
+ }
2928
+ // dirty/ops buckets (Schema: dirtyLow/High + ops; Collection: collDirty/collPureOps)
2929
+ this.reset();
2930
+ // keep the recorder object allocated (re-alloc is the cost we avoid), clear contents
2931
+ this.unreliableRecorder?.reset();
2932
+ // back to a freshly-constructed tree: IS_NEW, no inherited flags
2933
+ // (FILTERED/TRANSIENT/STATIC/STREAM are re-derived on the next setParent).
2934
+ // NEEDS_RESTAGE makes the next Root.add re-stage retained field values.
2935
+ this.flags = IS_NEW | NEEDS_RESTAGE;
2936
+ this._fullSyncGen = 0;
2937
+ // drop parent links — Root.remove clears `root` and the CHILDREN's
2938
+ // parent links, but leaves this tree's own parentRef dangling.
2939
+ this.parentRef = undefined;
2940
+ this._parentIndex = undefined;
2941
+ this.extraParents = undefined;
2942
+ // queue nodes (already nulled by Root.remove's queue removal; defensive)
2943
+ this.changesNode = undefined;
2944
+ this.unreliableChangesNode = undefined;
2945
+ this.paused = false;
2946
+ // per-view visibility lives on the tree (NOT keyed by refId), so a
2947
+ // recycled tree must not inherit its previous life's view membership.
2948
+ this.visibleViews = undefined;
2949
+ this.invisibleViews = undefined;
2950
+ this.tagViews = undefined;
2951
+ this.subscribedViews = undefined;
2952
+ }
2758
2953
  shift(shiftIndex) {
2759
2954
  if (this._isSchema)
2760
2955
  throw new Error("ChangeTree (Schema): shift is not supported");
@@ -3210,6 +3405,165 @@ const encodeArray = function (encoder, bytes, changeTree, field, operation, it,
3210
3405
  encodeValue(encoder, bytes, type, value, operation, it);
3211
3406
  };
3212
3407
 
3408
+ /**
3409
+ * Resync ("full-snapshot reconciliation") support for {@link Decoder.decodeResync}.
3410
+ *
3411
+ * A rejoin snapshot is authoritative for everything it contains — but the
3412
+ * plain decode path is additive: entries deleted (or hidden by a view)
3413
+ * while the client was off the wire survive as ghosts. This module owns the
3414
+ * generic reconciliation algorithm:
3415
+ *
3416
+ * - during the decode walk, the collection DecodeOperation functions report
3417
+ * every entry the payload touches ({@link resyncRecordVisit}) and every
3418
+ * collection that appears at all ({@link resyncMarkPresent});
3419
+ * - after the walk, {@link resyncSweep} removes whatever was never reported,
3420
+ * through the same DELETE bookkeeping the regular decode path uses
3421
+ * (DataChange DELETE → onRemove; removeRef → GC).
3422
+ *
3423
+ * Storage-specific pruning (journal upkeep, array compaction, stream
3424
+ * exemption) lives on each collection class as `[$resyncPrune]` — declared
3425
+ * on the `Collection` interface, so every collection kind must state its
3426
+ * own sweep semantics.
3427
+ *
3428
+ * All entry points are guarded by `decoder.resyncVisited !== null` at the
3429
+ * call sites — the normal decode path never pays for any of this.
3430
+ */
3431
+ /**
3432
+ * Record that the current structure's entry at `identity` (map string key /
3433
+ * element index) appeared in the payload — even when its value is unchanged
3434
+ * (`allChanges` cannot serve as this record: its pushes are guarded by
3435
+ * `previousValue !== value`, so unchanged entries would look unvisited).
3436
+ *
3437
+ * Also releases a replaced occupant: full-sync emits plain ADD (never
3438
+ * DELETE_AND_ADD), so an entry whose instance changed while this client was
3439
+ * off the wire would otherwise leak its previous ref (no onRemove, never
3440
+ * GC'd). This release is correct ONLY under a full snapshot — a live patch's
3441
+ * plain ADD over a different instance can be a positional rewrite (array
3442
+ * shift/unshift) where the occupant *moved* and is still alive; a snapshot
3443
+ * re-adds moved instances elsewhere, so the refcounts balance.
3444
+ */
3445
+ function resyncTouchEntry(decoder, ref, operation, identity, previousValue, value, allChanges) {
3446
+ const visited = decoder.resyncVisited;
3447
+ let set = visited.get(decoder.currentRefId);
3448
+ if (set === undefined) {
3449
+ visited.set(decoder.currentRefId, set = new Set());
3450
+ }
3451
+ set.add(identity);
3452
+ if (previousValue !== undefined && operation === exports.OPERATION.ADD && previousValue !== value) {
3453
+ const previousRefId = previousValue[$refId];
3454
+ if (previousRefId !== undefined) {
3455
+ decoder.root.removeRef(previousRefId);
3456
+ allChanges?.push({
3457
+ ref,
3458
+ refId: decoder.currentRefId,
3459
+ op: exports.OPERATION.DELETE,
3460
+ dynamicIndex: identity,
3461
+ value: undefined,
3462
+ previousValue,
3463
+ });
3464
+ }
3465
+ }
3466
+ }
3467
+ /**
3468
+ * Mark a collection as present in the payload — even with zero entries.
3469
+ * The sweep only prunes collections reported here: absence means "not part
3470
+ * of full-sync" (@transient, view-invisible), where pruning would destroy
3471
+ * live data. Reflected clients have no @transient metadata, so payload
3472
+ * presence is the only reliable signal.
3473
+ */
3474
+ function resyncMarkPresent(decoder, refId) {
3475
+ const visited = decoder.resyncVisited;
3476
+ if (!visited.has(refId)) {
3477
+ visited.set(refId, new Set());
3478
+ }
3479
+ }
3480
+ /**
3481
+ * Post-decode phase of {@link Decoder.decodeResync}: remove every collection
3482
+ * entry the snapshot did not visit.
3483
+ *
3484
+ * Walks the tree from the root — NOT `root.refs` — for three reasons:
3485
+ * `@transient` fields are never part of a snapshot and must be left alone;
3486
+ * entries of subtrees removed by the sweep itself are left to the GC's
3487
+ * transitive walk (sweeping them directly would double-decrement shared
3488
+ * children); and collections the snapshot never mentions (emptied
3489
+ * server-side) are still reachable and get pruned.
3490
+ */
3491
+ function resyncSweep(decoder, allChanges) {
3492
+ if (decoder.resyncDamaged) {
3493
+ console.warn("@colyseus/schema: resync sweep skipped — parts of the payload could not be decoded. " +
3494
+ "Stale entries may persist until the next resync.");
3495
+ return;
3496
+ }
3497
+ sweepSchema(decoder, decoder.state, new Set(), allChanges);
3498
+ }
3499
+ function sweepSchema(decoder, ref, seen, allChanges) {
3500
+ const refId = ref[$refId];
3501
+ if (refId === undefined || seen.has(refId)) {
3502
+ return;
3503
+ }
3504
+ seen.add(refId);
3505
+ const metadata = ref.constructor[Symbol.metadata];
3506
+ const refIndexes = metadata?.[$refTypeFieldIndexes];
3507
+ if (refIndexes === undefined) {
3508
+ return;
3509
+ }
3510
+ const transient = metadata[$transientFieldIndexes];
3511
+ for (let i = 0; i < refIndexes.length; i++) {
3512
+ const fieldIndex = refIndexes[i];
3513
+ // @transient fields are never in a snapshot — leave them alone.
3514
+ if (transient !== undefined && transient.includes(fieldIndex)) {
3515
+ continue;
3516
+ }
3517
+ const field = metadata[fieldIndex];
3518
+ const value = ref[field.name];
3519
+ if (!value) {
3520
+ continue;
3521
+ }
3522
+ if (Schema.is(field.type)) {
3523
+ sweepSchema(decoder, value, seen, allChanges);
3524
+ }
3525
+ else {
3526
+ sweepCollection(decoder, value, seen, allChanges);
3527
+ }
3528
+ }
3529
+ }
3530
+ function sweepCollection(decoder, coll, seen, allChanges) {
3531
+ const tgt = coll[$proxyTarget] ?? coll;
3532
+ const refId = tgt[$refId];
3533
+ if (refId === undefined || seen.has(refId)) {
3534
+ return;
3535
+ }
3536
+ seen.add(refId);
3537
+ // `undefined` = the collection never appeared in the payload at all
3538
+ // (not even as its parent's field op) — it is not part of full-sync
3539
+ // (@transient, view-invisible) and must be left alone. An empty Set
3540
+ // means "present with zero entries" → prune everything.
3541
+ const visited = decoder.resyncVisited.get(refId);
3542
+ if (visited === undefined) {
3543
+ return;
3544
+ }
3545
+ const $root = decoder.root;
3546
+ tgt[$resyncPrune](visited, (value, identity) => {
3547
+ allChanges?.push({
3548
+ ref: coll,
3549
+ refId,
3550
+ op: exports.OPERATION.DELETE,
3551
+ dynamicIndex: identity,
3552
+ value: undefined,
3553
+ previousValue: value,
3554
+ });
3555
+ const childRefId = value?.[$refId];
3556
+ if (childRefId !== undefined) {
3557
+ $root.removeRef(childRefId);
3558
+ }
3559
+ }, (value) => {
3560
+ // recurse so nested collections of retained entries sweep too
3561
+ if (Schema.isSchema(value)) {
3562
+ sweepSchema(decoder, value, seen, allChanges);
3563
+ }
3564
+ });
3565
+ }
3566
+
3213
3567
  const DEFINITION_MISMATCH = -1;
3214
3568
  /**
3215
3569
  * Collection-kind discriminator declared on each collection class as
@@ -3267,6 +3621,12 @@ function decodeValue(decoder, operation, ref, index, previousValue, type, bytes,
3267
3621
  //
3268
3622
  value = decode[type](bytes, it);
3269
3623
  }
3624
+ else if (isQuantizedType(type)) {
3625
+ // Quantized scalar: read the unsigned-int wire value, then dequantize so
3626
+ // the instance holds (and yields) the wire-exact float — `decodeSchema-
3627
+ // Operation` writes it through the snapping setter (idempotent here).
3628
+ value = decodeQuantized(type.quantized, bytes, it);
3629
+ }
3270
3630
  else if (Schema.is(type)) {
3271
3631
  const refId = decode.number(bytes, it);
3272
3632
  value = $root.refs.get(refId);
@@ -3283,6 +3643,10 @@ function decodeValue(decoder, operation, ref, index, previousValue, type, bytes,
3283
3643
  else {
3284
3644
  const typeDef = getType(Object.keys(type)[0]);
3285
3645
  const refId = decode.number(bytes, it);
3646
+ // resync bookkeeping — see Resync.ts
3647
+ if (decoder.resyncVisited !== null) {
3648
+ resyncMarkPresent(decoder, refId);
3649
+ }
3286
3650
  // `initializeForDecoder` is a static on every registered collection
3287
3651
  // class — it does `Object.create(Class.prototype)` + the class-
3288
3652
  // field init + assigns an untracked `$changes` directly. Keeps
@@ -3295,17 +3659,25 @@ function decodeValue(decoder, operation, ref, index, previousValue, type, bytes,
3295
3659
  if (previousValue) {
3296
3660
  let previousRefId = previousValue[$refId];
3297
3661
  if (previousRefId !== undefined && refId !== previousRefId) {
3662
+ // Collection field replaced by a different instance.
3298
3663
  //
3299
- // enqueue onRemove if structure has been replaced.
3300
- //
3664
+ // Don't decrement children here: GC (`garbageCollectDeletedRefs`)
3665
+ // removes them once the previous collection's refId hits zero.
3666
+ // Doing it here too would double-decrement a *shared* child and
3667
+ // drop it while still referenced ("refId not found").
3668
+ if ((operation & exports.OPERATION.DELETE) !== exports.OPERATION.DELETE) {
3669
+ // Replacement not tagged DELETE (e.g. pending ADD not upgraded
3670
+ // to DELETE_AND_ADD), so the previous refId wasn't decremented
3671
+ // above. Release it here, else it never gets GC'd (leak).
3672
+ $root.removeRef(previousRefId);
3673
+ }
3674
+ // enqueue onRemove callbacks for the previous collection's children.
3301
3675
  const entries = previousValue.entries();
3302
3676
  let iter;
3303
3677
  while ((iter = entries.next()) && !iter.done) {
3304
3678
  const [key, value] = iter.value;
3305
- // if value is a schema, remove its reference
3306
3679
  if (typeof (value) === "object") {
3307
3680
  previousRefId = value[$refId];
3308
- $root.removeRef(previousRefId);
3309
3681
  }
3310
3682
  allChanges?.push({
3311
3683
  ref: previousValue,
@@ -3397,6 +3769,10 @@ const decodeKeyValueOperation = function (decoder, bytes, it, ref, allChanges) {
3397
3769
  }
3398
3770
  const previousValue = tgt[$getByIndex](index);
3399
3771
  const value = decodeValue(decoder, operation, ref, index, previousValue, type, bytes, it, allChanges);
3772
+ // resync bookkeeping — see Resync.ts
3773
+ if (decoder.resyncVisited !== null) {
3774
+ resyncTouchEntry(decoder, ref, operation, dynamicIndex, previousValue, value, allChanges);
3775
+ }
3400
3776
  if (value !== null && value !== undefined) {
3401
3777
  switch (kind) {
3402
3778
  case CollectionKind.Map:
@@ -3473,6 +3849,16 @@ const decodeArray = function (decoder, bytes, it, ref, allChanges) {
3473
3849
  // TODO: refactor here, try to follow same flow as below
3474
3850
  const refId = decode.number(bytes, it);
3475
3851
  const previousValue = decoder.root.refs.get(refId);
3852
+ // Decrement the removed child's ref-count so it can be garbage
3853
+ // collected — this refId-based branch returns early and never reaches
3854
+ // decodeValue(), so it must do the same DELETE bookkeeping itself.
3855
+ // Without this the refId leaks; a later encoder reuse then aliases a
3856
+ // different type → "field not defined" / "definition mismatch"
3857
+ // (surfaces under StateView when a filtered ArraySchema element is
3858
+ // spliced while its parent moves through view membership churn).
3859
+ if (previousValue !== undefined) {
3860
+ decoder.root.removeRef(refId);
3861
+ }
3476
3862
  index = tgt.findIndex((value) => value === previousValue);
3477
3863
  tgt[$deleteByIndex](index);
3478
3864
  allChanges?.push({
@@ -3507,6 +3893,12 @@ const decodeArray = function (decoder, bytes, it, ref, allChanges) {
3507
3893
  // maintain). The decoder's authoritative state is `items`.
3508
3894
  const previousValue = tgt.items[index];
3509
3895
  const value = decodeValue(decoder, operation, ref, index, previousValue, type, bytes, it, allChanges);
3896
+ // resync bookkeeping — see Resync.ts. `index` is the RESOLVED position
3897
+ // (ADD_BY_REFID lands on the instance's current client-side index), so
3898
+ // visited entries form a sparse set.
3899
+ if (decoder.resyncVisited !== null) {
3900
+ resyncTouchEntry(decoder, ref, operation, index, previousValue, value, allChanges);
3901
+ }
3510
3902
  if (value !== null && value !== undefined &&
3511
3903
  value !== previousValue // avoid setting same value twice (if index === 0 it will result in a "unshift" for ArraySchema)
3512
3904
  ) {
@@ -3632,6 +4024,8 @@ class ArraySchema {
3632
4024
  tmpItems = [];
3633
4025
  deletedIndexes = [];
3634
4026
  isMovingItems = false;
4027
+ /** Decode-side: `items` has holes (delete or gap-write) — `$onDecodeEnd` must compact. */
4028
+ _needsCompaction = false;
3635
4029
  static [$encoder] = encodeArray;
3636
4030
  static [$decoder] = decodeArray;
3637
4031
  /** Integer tag read by `decodeKeyValueOperation` — see `CollectionKind`. */
@@ -3646,9 +4040,11 @@ class ArraySchema {
3646
4040
  * - Then, the encoder iterates over all "owned" properties per instance and encodes them.
3647
4041
  */
3648
4042
  static [$filter](ref, index, view) {
3649
- return (!view ||
3650
- typeof (ref[$childType]) === "string" ||
3651
- view.isChangeTreeVisible(ref['tmpItems'][index]?.[$changes]));
4043
+ if (!view)
4044
+ return true; // must stay first — encodeAll hits this per element
4045
+ const self = ref[$proxyTarget] ?? ref; // ref arrives proxied — skip traps below
4046
+ return (typeof (self[$childType]) === "string" ||
4047
+ view.isChangeTreeVisible(self['tmpItems'][index]?.[$changes]));
3652
4048
  }
3653
4049
  static is(type) {
3654
4050
  return (
@@ -3692,6 +4088,7 @@ class ArraySchema {
3692
4088
  // staged-snapshot path in `$getByIndex`, `$onEncodeEnd`, etc.). The
3693
4089
  // decoder reads from `items` directly and never maintains them.
3694
4090
  self.isMovingItems = false;
4091
+ self._needsCompaction = false;
3695
4092
  self[$childType] = undefined;
3696
4093
  self[$proxyTarget] = self;
3697
4094
  const proxy = new Proxy(self, ARRAY_PROXY_HANDLER);
@@ -3819,6 +4216,9 @@ class ArraySchema {
3819
4216
  this.items[index] = value;
3820
4217
  }
3821
4218
  else {
4219
+ if (index > this.items.length) {
4220
+ this._needsCompaction = true; // gap-write (filtered/out-of-order ADD) leaves holes
4221
+ }
3822
4222
  this.items[index] = value;
3823
4223
  }
3824
4224
  }
@@ -3839,6 +4239,27 @@ class ArraySchema {
3839
4239
  self.items.length = 0;
3840
4240
  self.tmpItems.length = 0;
3841
4241
  }
4242
+ /**
4243
+ * Pool reset: empty this array and recycle its ChangeTree WITHOUT recording
4244
+ * any wire op (the parent field's ADD/DELETE owns the wire). Recurses into
4245
+ * ref-type children. Called by Schema.reset when a pooled entity has an
4246
+ * array field. The instance must already be detached from the encoder.
4247
+ */
4248
+ [$reset]() {
4249
+ const self = this[$proxyTarget] ?? this;
4250
+ const changeTree = self[$changes];
4251
+ if (changeTree.isStreamCollection) {
4252
+ throw new Error(`@colyseus/schema: cannot reset a streamed ArraySchema (pooling not supported).`);
4253
+ }
4254
+ const items = self.items;
4255
+ for (let i = 0; i < items.length; i++)
4256
+ items[i]?.[$reset]?.();
4257
+ self.items.length = 0;
4258
+ self.tmpItems.length = 0;
4259
+ self.deletedIndexes.length = 0;
4260
+ changeTree.recycle();
4261
+ self[$refId] = undefined; // assign (not delete) to avoid V8 dictionary-mode deopt
4262
+ }
3842
4263
  /**
3843
4264
  * Combines two or more arrays.
3844
4265
  * @param items Additional items to add to the end of array1.
@@ -4238,21 +4659,51 @@ class ArraySchema {
4238
4659
  * `$deleteByIndex` below.
4239
4660
  */
4240
4661
  [$getByIndex](index, isEncodeAll = false) {
4662
+ const self = this[$proxyTarget] ?? this; // called via Proxy — one trap here beats one per field read
4241
4663
  return (isEncodeAll)
4242
- ? this.items[index]
4243
- : this.deletedIndexes[index]
4244
- ? this.items[index]
4245
- : this.tmpItems[index] || this.items[index];
4664
+ ? self.items[index]
4665
+ : self.deletedIndexes[index]
4666
+ ? self.items[index]
4667
+ : self.tmpItems[index] || self.items[index];
4246
4668
  }
4247
4669
  [$deleteByIndex](index) {
4248
- this.items[index] = undefined;
4670
+ const self = this[$proxyTarget] ?? this;
4671
+ self.items[index] = undefined;
4672
+ self._needsCompaction = true;
4249
4673
  }
4250
4674
  [$onEncodeEnd]() {
4251
- this.tmpItems = this.items.slice();
4252
- this.deletedIndexes.length = 0;
4675
+ const self = this[$proxyTarget] ?? this;
4676
+ self.tmpItems = self.items.slice();
4677
+ self.deletedIndexes.length = 0;
4253
4678
  }
4254
4679
  [$onDecodeEnd]() {
4255
- this.items = this.items.filter((item) => item !== undefined);
4680
+ const self = this[$proxyTarget] ?? this;
4681
+ if (self._needsCompaction) {
4682
+ self._needsCompaction = false;
4683
+ self.items = self.items.filter((item) => item !== undefined);
4684
+ }
4685
+ }
4686
+ [$resyncPrune](visited, prune, keep) {
4687
+ // `items` is hole-free here: the decode loop's $onDecodeEnd already
4688
+ // ran, and a full-sync emits dense ADDs (no DELETEs, no gap-writes)
4689
+ // so no compaction happened mid-decode. Visited indexes may still be
4690
+ // sparse (ADD_BY_REFID resolves to the current client-side index).
4691
+ const self = this[$proxyTarget] ?? this;
4692
+ const items = self.items;
4693
+ let removed = false;
4694
+ for (let i = 0; i < items.length; i++) {
4695
+ const value = items[i];
4696
+ if (visited.has(i)) {
4697
+ keep(value);
4698
+ continue;
4699
+ }
4700
+ removed = true;
4701
+ prune(value, i);
4702
+ self[$deleteByIndex](i);
4703
+ }
4704
+ if (removed) {
4705
+ self[$onDecodeEnd]();
4706
+ } // compact the holes
4256
4707
  }
4257
4708
  toArray() {
4258
4709
  return this.items.slice(0);
@@ -4619,6 +5070,24 @@ class MapSchema {
4619
5070
  this.$items.clear();
4620
5071
  changeTree.operation(exports.OPERATION.CLEAR);
4621
5072
  }
5073
+ /**
5074
+ * Pool reset: empty this map and recycle its ChangeTree WITHOUT recording
5075
+ * any wire op (the parent field's ADD/DELETE owns the wire). Recurses into
5076
+ * ref-type children. Called by Schema.reset when a pooled entity has a
5077
+ * map field. The instance must already be detached from the encoder.
5078
+ */
5079
+ [$reset]() {
5080
+ const changeTree = this[$changes];
5081
+ if (changeTree.isStreamCollection) {
5082
+ throw new Error(`@colyseus/schema: cannot reset a streamed MapSchema (pooling not supported).`);
5083
+ }
5084
+ // reset ref-type children first (primitives optional-chain away)
5085
+ this.$items.forEach((value) => value?.[$reset]?.());
5086
+ this.$items.clear();
5087
+ this.journal.reset();
5088
+ changeTree.recycle();
5089
+ this[$refId] = undefined; // assign (not delete) to avoid V8 dictionary-mode deopt
5090
+ }
4622
5091
  has(key) {
4623
5092
  return this.$items.has(key);
4624
5093
  }
@@ -4659,6 +5128,36 @@ class MapSchema {
4659
5128
  this.journal.keyByIndex.delete(index);
4660
5129
  }
4661
5130
  }
5131
+ [$resyncPrune](visited, prune, keep) {
5132
+ // maps prune by string key, NOT wire index — the decoder-side
5133
+ // journal never evicts stale index→key mappings on re-indexing.
5134
+ let deletedKeys = null;
5135
+ this.$items.forEach((value, key) => {
5136
+ if (visited.has(key)) {
5137
+ keep(value);
5138
+ return;
5139
+ }
5140
+ (deletedKeys ??= new Set()).add(key);
5141
+ prune(value, key);
5142
+ });
5143
+ if (deletedKeys !== null) {
5144
+ deletedKeys.forEach((key) => {
5145
+ this.$items.delete(key);
5146
+ delete this.journal.indexByKey[key];
5147
+ });
5148
+ // drop index→key mappings of swept keys — including stale ones
5149
+ // left behind by re-indexing.
5150
+ const staleIndexes = [];
5151
+ this.journal.keyByIndex.forEach((key, index) => {
5152
+ if (deletedKeys.has(key)) {
5153
+ staleIndexes.push(index);
5154
+ }
5155
+ });
5156
+ for (let i = 0; i < staleIndexes.length; i++) {
5157
+ this.journal.keyByIndex.delete(staleIndexes[i]);
5158
+ }
5159
+ }
5160
+ }
4662
5161
  [$onEncodeEnd]() {
4663
5162
  this.journal.cleanupAfterEncode();
4664
5163
  }
@@ -4851,6 +5350,24 @@ class CollectionSchema {
4851
5350
  this.$items.clear();
4852
5351
  changeTree.operation(exports.OPERATION.CLEAR);
4853
5352
  }
5353
+ /**
5354
+ * Pool reset: empty this collection and recycle its ChangeTree WITHOUT
5355
+ * recording any wire op (the parent field's ADD/DELETE owns the wire).
5356
+ * Recurses into ref-type children. Called by Schema.reset when a pooled
5357
+ * entity has a collection field. Must already be detached from the encoder.
5358
+ */
5359
+ [$reset]() {
5360
+ const changeTree = this[$changes];
5361
+ if (changeTree.isStreamCollection) {
5362
+ throw new Error(`@colyseus/schema: cannot reset a streamed CollectionSchema (pooling not supported).`);
5363
+ }
5364
+ this.$items.forEach((value) => value?.[$reset]?.());
5365
+ this.$items.clear();
5366
+ this.deletedItems = {};
5367
+ this.$refId = 0; // reset the monotonic index counter (field, not the symbol)
5368
+ changeTree.recycle();
5369
+ this[$refId] = undefined; // drop encoder ref identity by assign (not delete: avoids dict-mode deopt)
5370
+ }
4854
5371
  has(value) {
4855
5372
  return Array.from(this.$items.values()).some((v) => v === value);
4856
5373
  }
@@ -4889,6 +5406,22 @@ class CollectionSchema {
4889
5406
  [$deleteByIndex](index) {
4890
5407
  this.$items.delete(index);
4891
5408
  }
5409
+ [$resyncPrune](visited, prune, keep) {
5410
+ let toDelete = null;
5411
+ this.$items.forEach((value, index) => {
5412
+ if (visited.has(index)) {
5413
+ keep(value);
5414
+ return;
5415
+ }
5416
+ (toDelete ??= []).push(index);
5417
+ prune(value, index);
5418
+ });
5419
+ if (toDelete !== null) {
5420
+ for (let i = 0; i < toDelete.length; i++) {
5421
+ this[$deleteByIndex](toDelete[i]);
5422
+ }
5423
+ }
5424
+ }
4892
5425
  [$onEncodeEnd]() {
4893
5426
  for (const key in this.deletedItems) {
4894
5427
  delete this.deletedItems[key];
@@ -5090,6 +5623,24 @@ class SetSchema {
5090
5623
  this.$items.clear();
5091
5624
  changeTree.operation(exports.OPERATION.CLEAR);
5092
5625
  }
5626
+ /**
5627
+ * Pool reset: empty this set and recycle its ChangeTree WITHOUT recording
5628
+ * any wire op (the parent field's ADD/DELETE owns the wire). Recurses into
5629
+ * ref-type children. Called by Schema.reset when a pooled entity has a set
5630
+ * field. The instance must already be detached from the encoder.
5631
+ */
5632
+ [$reset]() {
5633
+ const changeTree = this[$changes];
5634
+ if (changeTree.isStreamCollection) {
5635
+ throw new Error(`@colyseus/schema: cannot reset a streamed SetSchema (pooling not supported).`);
5636
+ }
5637
+ this.$items.forEach((value) => value?.[$reset]?.());
5638
+ this.$items.clear();
5639
+ this.deletedItems = {};
5640
+ this.$refId = 0; // reset the monotonic index counter (field, not the symbol)
5641
+ changeTree.recycle();
5642
+ this[$refId] = undefined; // drop encoder ref identity by assign (not delete: avoids dict-mode deopt)
5643
+ }
5093
5644
  has(value) {
5094
5645
  const values = this.$items.values();
5095
5646
  let has = false;
@@ -5140,6 +5691,22 @@ class SetSchema {
5140
5691
  [$deleteByIndex](index) {
5141
5692
  this.$items.delete(index);
5142
5693
  }
5694
+ [$resyncPrune](visited, prune, keep) {
5695
+ let toDelete = null;
5696
+ this.$items.forEach((value, index) => {
5697
+ if (visited.has(index)) {
5698
+ keep(value);
5699
+ return;
5700
+ }
5701
+ (toDelete ??= []).push(index);
5702
+ prune(value, index);
5703
+ });
5704
+ if (toDelete !== null) {
5705
+ for (let i = 0; i < toDelete.length; i++) {
5706
+ this[$deleteByIndex](toDelete[i]);
5707
+ }
5708
+ }
5709
+ }
5143
5710
  [$onEncodeEnd]() {
5144
5711
  for (const key in this.deletedItems) {
5145
5712
  delete this.deletedItems[key];
@@ -5401,6 +5968,11 @@ class StreamSchema {
5401
5968
  this.$items.delete(index);
5402
5969
  }
5403
5970
  }
5971
+ [$resyncPrune]() {
5972
+ // Stream contents are delivered by the trickle/priority pass, NOT
5973
+ // by full-sync (encodeAll carries none of them) — a snapshot is not
5974
+ // authoritative for streams, so absence ≠ deleted. Never prune.
5975
+ }
5404
5976
  [$onEncodeEnd]() {
5405
5977
  // No per-tick cleanup: pending/sent state spans encode ticks by design.
5406
5978
  }
@@ -5481,7 +6053,22 @@ class FieldBuilder {
5481
6053
  constructor(type) {
5482
6054
  this._type = type;
5483
6055
  }
5484
- /** Provide a default value for this field. */
6056
+ /**
6057
+ * Provide a default value for this field.
6058
+ *
6059
+ * Pass a **factory function** `() => T` to build a FRESH value per instance
6060
+ * (invoked once per construction) instead of sharing a single default — the
6061
+ * clean way to default a ref to a plain custom class, or any field that must
6062
+ * not share a mutable default across instances:
6063
+ *
6064
+ * ```ts
6065
+ * acc: t.ref(GunAccuracy).noSync().default(() => new GunAccuracy()),
6066
+ * ```
6067
+ *
6068
+ * Schema fields are never function-typed, so a function is always treated as a
6069
+ * factory. A non-function value is shared (and cloned per instance if it is
6070
+ * clone-able, e.g. a Schema/collection).
6071
+ */
5485
6072
  default(value) {
5486
6073
  this._default = value;
5487
6074
  this._hasDefault = true;
@@ -5652,6 +6239,20 @@ const streamFactory = ((child) => {
5652
6239
  return b;
5653
6240
  });
5654
6241
  const refFactory = ((ctor) => new FieldBuilder(ctor));
6242
+ /**
6243
+ * A bounded float carried on the wire as a fixed-width unsigned integer. App code
6244
+ * reads/writes the FLOAT; the wire carries the quantized int and the field only
6245
+ * ever yields `dequant(q)`, so client predict and server sim read the same value
6246
+ * (no full-precision path to leak ⇒ no shot misprediction). See
6247
+ * {@link QuantizeOptions} for the precision/wire trade-offs.
6248
+ *
6249
+ * yaw: t.quantized({ min: 0, max: TWO_PI, mode: "wrap" }), // 16-bit
6250
+ * pitch: t.quantized({ min: -PITCH_LIMIT, max: PITCH_LIMIT }), // clamp (default)
6251
+ * throttle: t.quantized({ min: 0, max: 1, bits: 8 }), // 1 byte
6252
+ */
6253
+ function quantizedFactory(opts) {
6254
+ return new FieldBuilder({ quantized: resolveQuantize(opts) });
6255
+ }
5655
6256
  const t = Object.freeze({
5656
6257
  // Primitives
5657
6258
  string: primitive("string"),
@@ -5669,16 +6270,46 @@ const t = Object.freeze({
5669
6270
  float64: primitive("float64"),
5670
6271
  bigint64: primitive("bigint64"),
5671
6272
  biguint64: primitive("biguint64"),
5672
- /** Reference to a Schema subtype. `t.array(Item)` usually reads better, but this is available when a plain ref is needed. */
6273
+ /**
6274
+ * Reference to a Schema subtype — `t.array(Item)` usually reads better, but
6275
+ * this is available when a plain ref is needed.
6276
+ *
6277
+ * The target may also be a **non-Schema custom class**. A synced ref still
6278
+ * requires it to be encodable — a `Schema` subclass, or a class retrofitted
6279
+ * with `Metadata.setFields(...)` (both carry `[Symbol.metadata]`); a bare
6280
+ * custom class is rejected at `schema()` time. A `.noSync()` (local-only)
6281
+ * field accepts ANY zero-arg class and auto-instantiates one per parent.
6282
+ */
5673
6283
  ref: refFactory,
5674
6284
  array: arrayFactory,
5675
6285
  map: mapFactory,
5676
6286
  set: setFactory,
5677
6287
  collection: collectionFactory,
5678
6288
  stream: streamFactory,
6289
+ /**
6290
+ * A bounded float quantized to a fixed-width unsigned int on the wire — half
6291
+ * (or a quarter) the bytes of a `float32` at a precision you pick. The field
6292
+ * reads/writes the float and only ever yields `dequant(q)`. {@see QuantizeOptions}
6293
+ */
6294
+ quantized: quantizedFactory,
6295
+ /**
6296
+ * Sugar for a full-circle wrapping angle in radians:
6297
+ * `t.quantized({ min: 0, max: 2π, mode: "wrap", bits })` (default 16-bit,
6298
+ * ~0.0055°/step). Any input angle is range-reduced into `[0, 2π)`. Render note:
6299
+ * lerp interpolated remotes shortest-arc (`attach({ angle: true })`) — the
6300
+ * wrap fixes the WIRE seam, not interpolation (see {@link QuantizeOptions.mode}).
6301
+ */
6302
+ angle: (opts) => quantizedFactory({ min: 0, max: Math.PI * 2, mode: "wrap", bits: opts?.bits ?? 16 }),
5679
6303
  });
5680
6304
 
5681
6305
  const DEFAULT_VIEW_TAG = -1;
6306
+ /**
6307
+ * Class decorator that registers a `@type`-style Schema class with the
6308
+ * TypeContext (required for reflection / cross-language codegen).
6309
+ *
6310
+ * @entity
6311
+ * class Player extends Schema { ... }
6312
+ */
5682
6313
  function entity(constructor) {
5683
6314
  TypeContext.register(constructor);
5684
6315
  return constructor;
@@ -5910,8 +6541,8 @@ function type(type, options) {
5910
6541
  if (metadata[$descriptors][field]) {
5911
6542
  Object.defineProperty(target, field, metadata[$descriptors][field]);
5912
6543
  }
5913
- // Pre-compute encoder function for primitive types.
5914
- if (typeof type === "string") {
6544
+ // Pre-compute encoder function for primitive + quantized types.
6545
+ if (typeof type === "string" || isQuantizedType(type)) {
5915
6546
  if (!metadata[$encoders]) {
5916
6547
  Object.defineProperty(metadata, $encoders, {
5917
6548
  value: [],
@@ -5920,7 +6551,9 @@ function type(type, options) {
5920
6551
  writable: true,
5921
6552
  });
5922
6553
  }
5923
- metadata[$encoders][fieldIndex] = encode[type];
6554
+ metadata[$encoders][fieldIndex] = (typeof type === "string")
6555
+ ? encode[type]
6556
+ : makeQuantizedEncoder(type.quantized);
5924
6557
  }
5925
6558
  };
5926
6559
  }
@@ -6031,6 +6664,38 @@ function makeCollectionSetter(_fieldName, fieldIndex, type, complexTypeKlass) {
6031
6664
  values[fieldIndex] = value;
6032
6665
  };
6033
6666
  }
6667
+ /**
6668
+ * Setter for a `t.quantized()` field. SNAPS the assigned float to the wire-exact
6669
+ * value (`dequant(quant(x))`) on write, so the stored value — and every read,
6670
+ * including the reconciler's live step off the staged input — is identical to
6671
+ * what the server decodes off the wire. This is the half that kills the
6672
+ * predict-from-the-wrong-value footgun; the encoder re-quantizes the snapped
6673
+ * value at send (a lossless round-trip). Change tracking keys on the SNAPPED
6674
+ * value, so a sub-step jitter that quantizes to the same integer emits no delta.
6675
+ */
6676
+ function makeQuantizedSetter(fieldName, fieldIndex, desc) {
6677
+ return function (value) {
6678
+ const values = this[$values];
6679
+ const previousValue = values[fieldIndex];
6680
+ if (value !== undefined && value !== null) {
6681
+ if (typeof value !== "number") {
6682
+ throw new EncodeSchemaError(`a 'number' was expected, but '${JSON.stringify(value)}' was provided in ${this.constructor.name}#${fieldName}`);
6683
+ }
6684
+ value = dequantize(desc, quantize(desc, value)); // snap to wire-exact
6685
+ if (value === previousValue)
6686
+ return;
6687
+ this.constructor[$track](this[$changes], fieldIndex, exports.OPERATION.ADD);
6688
+ }
6689
+ else {
6690
+ if (value === previousValue)
6691
+ return; // undefined === undefined
6692
+ if (previousValue !== undefined && previousValue !== null) {
6693
+ this[$changes].delete(fieldIndex);
6694
+ }
6695
+ }
6696
+ values[fieldIndex] = value;
6697
+ };
6698
+ }
6034
6699
  function getPropertyDescriptor(fieldName, fieldIndex, type, complexTypeKlass) {
6035
6700
  let setter;
6036
6701
  if (complexTypeKlass) {
@@ -6039,10 +6704,15 @@ function getPropertyDescriptor(fieldName, fieldIndex, type, complexTypeKlass) {
6039
6704
  else if (typeof type === "string") {
6040
6705
  setter = makePrimitiveSetter(fieldName, fieldIndex, type);
6041
6706
  }
6707
+ else if (isQuantizedType(type)) {
6708
+ setter = makeQuantizedSetter(fieldName, fieldIndex, type.quantized);
6709
+ }
6042
6710
  else {
6043
6711
  setter = makeSchemaRefSetter(fieldName, fieldIndex, type);
6044
6712
  }
6045
6713
  return {
6714
+ // Quantized stores the already-snapped float, so the getter is the plain
6715
+ // $values read — the field yields dequant(q) with no per-read math.
6046
6716
  get: function () { return this[$values][fieldIndex]; },
6047
6717
  set: setter,
6048
6718
  enumerable: true,
@@ -6077,32 +6747,51 @@ function deprecated(throws = true) {
6077
6747
  });
6078
6748
  };
6079
6749
  }
6750
+ let defineTypesWarned = false;
6751
+ /**
6752
+ * Adds synchronizable fields to an existing `Schema` subclass — the pre-5.0
6753
+ * helper for plain JavaScript users.
6754
+ *
6755
+ * @deprecated Use `schema()` with `t.*` field builders instead:
6756
+ * https://docs.colyseus.io/state/schema
6757
+ */
6758
+ function defineTypes(target, fields, options) {
6759
+ if (!defineTypesWarned) {
6760
+ defineTypesWarned = true;
6761
+ console.warn("@colyseus/schema: defineTypes() is deprecated and will be removed in a future release. Use schema() with t.* field builders instead → https://docs.colyseus.io/state/schema");
6762
+ }
6763
+ for (let field in fields) {
6764
+ type(fields[field], options)(target.prototype, field);
6765
+ }
6766
+ return target;
6767
+ }
6080
6768
  /**
6081
- * Produce the auto-instantiated construction default for a builder type
6082
- * (empty collection or zero-arg Schema ref), or `undefined` when the type
6083
- * has no auto-default. Shared by synced and `.noSync()` field handling.
6769
+ * Build a per-construction factory for a builder type's auto-instantiated
6770
+ * default (empty collection or zero-arg Schema ref), or `undefined` when the
6771
+ * type has no auto-default. Returning a factory lets each construction `new` a
6772
+ * fresh value directly instead of cloning a shared prototype instance.
6084
6773
  */
6085
- function autoInstantiateDefault(rawType) {
6774
+ function makeAutoDefaultFactory(rawType) {
6086
6775
  if (rawType && typeof rawType === "object") {
6087
6776
  if (rawType.array !== undefined) {
6088
- return new ArraySchema();
6777
+ return () => new ArraySchema();
6089
6778
  }
6090
6779
  if (rawType.map !== undefined) {
6091
- return new MapSchema();
6780
+ return () => new MapSchema();
6092
6781
  }
6093
6782
  if (rawType.set !== undefined) {
6094
- return new SetSchema();
6783
+ return () => new SetSchema();
6095
6784
  }
6096
6785
  if (rawType.collection !== undefined) {
6097
- return new CollectionSchema();
6786
+ return () => new CollectionSchema();
6098
6787
  }
6099
6788
  if (rawType.stream !== undefined) {
6100
- return new StreamSchema();
6789
+ return () => new StreamSchema();
6101
6790
  }
6102
6791
  }
6103
6792
  else if (typeof rawType === "function" && Schema.is(rawType)) {
6104
6793
  if (!rawType.prototype.initialize || rawType.prototype.initialize.length === 0) {
6105
- return new rawType();
6794
+ return () => new rawType();
6106
6795
  }
6107
6796
  }
6108
6797
  return undefined;
@@ -6129,7 +6818,39 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6129
6818
  }
6130
6819
  const fields = {};
6131
6820
  const methods = {};
6821
+ // Two buckets, both keyed by field name and applied at construction:
6822
+ // - `defaultValues`: static values copied as-is (shared reference).
6823
+ // - `defaultFactories`: invoked per construction for a fresh value —
6824
+ // `.default(fn)`, clone-able defaults, and auto-instantiated collections/refs.
6132
6825
  const defaultValues = {};
6826
+ const defaultFactories = {};
6827
+ // Decide once (at definition time) how each `.default(v)` materializes per
6828
+ // construction: a function is a factory; a clone-able value clones fresh;
6829
+ // anything else is a shared static value.
6830
+ const assignDefault = (field, value) => {
6831
+ if (typeof value === "function") {
6832
+ defaultFactories[field] = value;
6833
+ }
6834
+ else if (value && typeof value.clone === "function") {
6835
+ defaultFactories[field] = () => value.clone();
6836
+ }
6837
+ else {
6838
+ defaultValues[field] = value;
6839
+ }
6840
+ };
6841
+ // Seed a field's construction default: explicit `.default(v)`, else the
6842
+ // auto-instantiated empty collection / zero-arg ref (skipped for `.optional()`).
6843
+ const seedDefault = (field, def) => {
6844
+ if (def.hasDefault) {
6845
+ assignDefault(field, def.default);
6846
+ }
6847
+ else if (!def.optional) {
6848
+ const factory = makeAutoDefaultFactory(def.type);
6849
+ if (factory) {
6850
+ defaultFactories[field] = factory;
6851
+ }
6852
+ }
6853
+ };
6133
6854
  const viewTagFields = {};
6134
6855
  const ownedFields = [];
6135
6856
  const unreliableFields = [];
@@ -6153,18 +6874,16 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6153
6874
  `together with a sync-only modifier (.view/.owned/.unreliable/.transient/.static/.stream). ` +
6154
6875
  `A local-only field cannot be synchronized.`);
6155
6876
  }
6156
- if (def.hasDefault) {
6157
- defaultValues[fieldName] = def.default;
6158
- }
6159
- else if (!def.optional) {
6160
- const autoDefault = autoInstantiateDefault(def.type);
6161
- if (autoDefault !== undefined) {
6162
- defaultValues[fieldName] = autoDefault;
6163
- }
6164
- }
6877
+ seedDefault(fieldName, def);
6165
6878
  continue;
6166
6879
  }
6167
- fields[fieldName] = getNormalizedType(def.type);
6880
+ const normalizedType = getNormalizedType(def.type);
6881
+ // A synced ref must be encodable (a Schema, or Metadata.setFields()'d) — reject a bare class.
6882
+ if (typeof normalizedType === "function" && !Schema.is(normalizedType)) {
6883
+ throw new Error(`schema(${name ? `'${name}'` : ""}): field '${fieldName}' is a synced ref to non-Schema ` +
6884
+ `class '${normalizedType.name || "(anonymous)"}' — use .noSync(), or Metadata.setFields().`);
6885
+ }
6886
+ fields[fieldName] = normalizedType;
6168
6887
  if (def.view !== undefined) {
6169
6888
  viewTagFields[fieldName] = def.view;
6170
6889
  }
@@ -6192,24 +6911,14 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6192
6911
  if (def.optional) {
6193
6912
  optionalFields.push(fieldName);
6194
6913
  }
6195
- if (def.hasDefault) {
6196
- defaultValues[fieldName] = def.default;
6197
- }
6198
- else if (!def.optional) {
6199
- // Auto-instantiate collection/Schema defaults when none is provided.
6200
- // `.optional()` opts out — field starts as undefined.
6201
- const autoDefault = autoInstantiateDefault(def.type);
6202
- if (autoDefault !== undefined) {
6203
- defaultValues[fieldName] = autoDefault;
6204
- }
6205
- }
6914
+ seedDefault(fieldName, def);
6206
6915
  }
6207
6916
  else if (typeof value === "function") {
6208
6917
  if (Schema.is(value)) {
6209
6918
  // Convenience: allow a bare Schema subclass (equivalent to `t.ref(Class)`).
6210
6919
  fields[fieldName] = getNormalizedType(value);
6211
6920
  if (!value.prototype.initialize || value.prototype.initialize.length === 0) {
6212
- defaultValues[fieldName] = new value();
6921
+ defaultFactories[fieldName] = () => new value();
6213
6922
  }
6214
6923
  }
6215
6924
  else {
@@ -6221,17 +6930,19 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6221
6930
  `Schema subclass, or method (got ${typeof value}).`);
6222
6931
  }
6223
6932
  }
6224
- const getDefaultValues = () => {
6225
- const defaults = {};
6933
+ // Write construction defaults onto `target` — either the instance directly
6934
+ // (no-args fast path) or a throwaway object that gets merged with props.
6935
+ const applyDefaults = (target) => {
6226
6936
  for (const fieldName in defaultValues) {
6227
- const defaultValue = defaultValues[fieldName];
6228
- if (defaultValue && typeof defaultValue.clone === "function") {
6229
- defaults[fieldName] = defaultValue.clone();
6230
- }
6231
- else {
6232
- defaults[fieldName] = defaultValue;
6233
- }
6937
+ target[fieldName] = defaultValues[fieldName];
6234
6938
  }
6939
+ for (const fieldName in defaultFactories) {
6940
+ target[fieldName] = defaultFactories[fieldName]();
6941
+ }
6942
+ };
6943
+ const getDefaultValues = () => {
6944
+ const defaults = {};
6945
+ applyDefaults(defaults);
6235
6946
  return defaults;
6236
6947
  };
6237
6948
  const getParentProps = (props) => {
@@ -6244,18 +6955,26 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6244
6955
  }
6245
6956
  return parentProps;
6246
6957
  };
6958
+ const hasInitialize = typeof methods.initialize === "function";
6247
6959
  /** @codegen-ignore */
6248
6960
  const klass = Metadata.setFields(class extends inherits {
6249
6961
  constructor(...args) {
6250
- if (methods.initialize && typeof methods.initialize === "function") {
6251
- super(Object.assign({}, getDefaultValues(), getParentProps(args[0] || {})));
6252
- // Only call initialize() on the exact target class, not parents.
6253
- if (new.target === klass) {
6254
- methods.initialize.apply(this, args);
6255
- }
6962
+ const props = args[0];
6963
+ if (props === undefined) {
6964
+ // No-args: write defaults straight onto the instance skips the
6965
+ // throwaway defaults object + Object.assign + assignProps walk.
6966
+ super();
6967
+ applyDefaults(this);
6256
6968
  }
6257
6969
  else {
6258
- super(Object.assign({}, getDefaultValues(), args[0] || {}));
6970
+ // With props: merge into the fresh defaults object in place (no
6971
+ // extra `{}` target); the super chain runs assignProps once. An
6972
+ // `initialize()` owns the schema fields, so only parent props flow up.
6973
+ super(Object.assign(getDefaultValues(), hasInitialize ? getParentProps(props) : props));
6974
+ }
6975
+ // Only call initialize() on the exact target class, not parents.
6976
+ if (hasInitialize && new.target === klass) {
6977
+ methods.initialize.apply(this, args);
6259
6978
  }
6260
6979
  }
6261
6980
  }, fields);
@@ -6382,6 +7101,60 @@ class Schema {
6382
7101
  inst[$values] = [];
6383
7102
  return inst;
6384
7103
  }
7104
+ /**
7105
+ * Reset a DETACHED instance to construction defaults so it can be returned
7106
+ * to a {@link SchemaPool} and reused, avoiding the cost of `new`. Recurses
7107
+ * into ref-type fields (child Schemas / collections).
7108
+ *
7109
+ * Preconditions (enforced):
7110
+ * - The instance must be tracked (encoder-side), not a decoder mirror.
7111
+ * - The instance must NOT be shared across multiple parents.
7112
+ * - The instance must already be removed from its parent collection/field
7113
+ * (so the encoder detached it: `root === undefined`).
7114
+ *
7115
+ * NOTE: primitive field values are NOT reset to class defaults — re-assign
7116
+ * the fields you care about when you reuse the instance (standard
7117
+ * object-pool discipline).
7118
+ */
7119
+ static reset(instance) {
7120
+ const changeTree = instance?.[$changes];
7121
+ // Only tracked (encoder-side) instances are poolable. Decoder-side
7122
+ // instances carry an UntrackedChangeTree, which has no recycle().
7123
+ if (changeTree === undefined || typeof changeTree.recycle !== "function") {
7124
+ throw new Error(`@colyseus/schema: Schema.reset() requires a tracked (encoder-side) instance.`);
7125
+ }
7126
+ // Instances reachable through more than one parent are unsafe to pool:
7127
+ // another owner may still hold this instance.
7128
+ if (changeTree.extraParents !== undefined) {
7129
+ throw new Error(`@colyseus/schema: cannot reset a shared instance (${instance.constructor.name}) with multiple parents.`);
7130
+ }
7131
+ instance[$reset]();
7132
+ }
7133
+ /**
7134
+ * Per-instance reset primitive (the recursive worker behind
7135
+ * {@link Schema.reset}). Resets ref-type children first (depth-first),
7136
+ * then recycles this instance's ChangeTree and drops its `$refId` so a
7137
+ * re-add is assigned a fresh refId exactly like a freshly constructed
7138
+ * instance. Dropping `$refId` is what makes instance reuse
7139
+ * wire-format-identical to `new T()`.
7140
+ */
7141
+ [$reset]() {
7142
+ const metadata = this.constructor[Symbol.metadata];
7143
+ const refIndexes = metadata?.[$refTypeFieldIndexes] ?? [];
7144
+ const values = this[$values];
7145
+ for (let i = 0; i < refIndexes.length; i++) {
7146
+ const child = values[refIndexes[i]];
7147
+ // ref fields hold a child Schema or collection (both implement
7148
+ // [$reset]); skip undefined/null. Optional-chain is a cheap guard.
7149
+ child?.[$reset]?.();
7150
+ }
7151
+ this[$changes].recycle();
7152
+ // Clear the refId by ASSIGNMENT (not `delete`): `delete` would force the
7153
+ // instance into V8 dictionary mode, making release() as expensive as the
7154
+ // construction it saves. `=== undefined` in Root.add still assigns a fresh
7155
+ // refId exactly like a freshly-constructed instance.
7156
+ this[$refId] = undefined;
7157
+ }
6385
7158
  /**
6386
7159
  * Check whether `type` describes a Schema *class* (a subclass
6387
7160
  * constructor carrying `Symbol.metadata`, as installed by `@type`).
@@ -6444,7 +7217,8 @@ class Schema {
6444
7217
  return view.isChangeTreeVisible(ref[$changes]);
6445
7218
  }
6446
7219
  else {
6447
- // view pass: custom tag
7220
+ // view pass: custom tag (bitmask) — field's stored mask matches
7221
+ // if it shares any bit with a tag this view was add()ed with.
6448
7222
  return view.hasTagOnTree(ref[$changes], tag);
6449
7223
  }
6450
7224
  }
@@ -6749,83 +7523,18 @@ class Schema {
6749
7523
  }
6750
7524
  }
6751
7525
 
6752
- /**
6753
- * Allocates monotonically-increasing refIds with a reuse pool.
6754
- *
6755
- * `acquire()` pops from the free pool when available, otherwise bumps a
6756
- * counter. `release()` queues a refId for reuse; the id doesn't become
6757
- * acquirable until `flushReleases()` runs — the one-tick defer is what
6758
- * lets the encoder guarantee a DELETE for the old instance reaches the
6759
- * wire before the refId is handed to a new one.
6760
- *
6761
- * `reclaim()` handles "resurrection": a ref that was released but whose
6762
- * JS instance is still alive can be re-added to the tree, in which case
6763
- * the encoder must pull the refId back out of the pool before it's
6764
- * handed to an unrelated instance.
6765
- */
6766
- class RefIdAllocator {
6767
- nextUniqueId;
6768
- _free = [];
6769
- _pending = [];
6770
- _pooled = new Set();
6771
- constructor(startRefId = 0) {
6772
- this.nextUniqueId = startRefId;
6773
- }
6774
- acquire() {
6775
- if (this._free.length > 0) {
6776
- const id = this._free.pop();
6777
- this._pooled.delete(id);
6778
- return id;
6779
- }
6780
- return this.nextUniqueId++;
6781
- }
6782
- release(refId) {
6783
- this._pending.push(refId);
6784
- this._pooled.add(refId);
6785
- }
6786
- isPooled(refId) {
6787
- return this._pooled.has(refId);
6788
- }
6789
- /**
6790
- * Remove a refId from the pool. Called when a ref whose refId was
6791
- * released is being resurrected. O(n) scan of the relevant array,
6792
- * but resurrection is rare.
6793
- */
6794
- reclaim(refId) {
6795
- if (!this._pooled.delete(refId))
6796
- return;
6797
- let i = this._free.indexOf(refId);
6798
- if (i !== -1) {
6799
- this._free.splice(i, 1);
6800
- return;
6801
- }
6802
- i = this._pending.indexOf(refId);
6803
- if (i !== -1) {
6804
- this._pending.splice(i, 1);
6805
- }
6806
- }
6807
- /**
6808
- * Promote this tick's releases into the acquirable set. Called from
6809
- * `Encoder.discardChanges()` — never mid-encode.
6810
- */
6811
- flushReleases() {
6812
- const pending = this._pending;
6813
- if (pending.length === 0)
6814
- return;
6815
- const free = this._free;
6816
- for (let i = 0; i < pending.length; i++)
6817
- free.push(pending[i]);
6818
- pending.length = 0;
6819
- }
6820
- }
6821
-
7526
+ // Reused across Root.add calls — defineProperty is unavoidable ($refId must
7527
+ // stay non-enumerable for deepStrictEqual) but the descriptor literal isn't.
7528
+ const $refIdDescriptor$1 = { value: 0, enumerable: false, writable: true };
6822
7529
  class Root {
6823
7530
  types;
6824
7531
  /**
6825
- * Allocates and recycles refIds. See `RefIdAllocator` for the reuse
6826
- * pool semantics (one-tick defer + resurrection).
7532
+ * Monotonic refId counter. RefIds are never recycled — a refId is a
7533
+ * stable identity for the lifetime of the room, so a client that
7534
+ * missed DELETEs (reconnect) can never see an old id rebound to a
7535
+ * different instance. DevMode reads/writes this across HMR cycles.
6827
7536
  */
6828
- refIds;
7537
+ nextUniqueId = 0;
6829
7538
  refCount = {};
6830
7539
  changeTrees = {};
6831
7540
  /**
@@ -6918,7 +7627,7 @@ class Root {
6918
7627
  }
6919
7628
  constructor(types, startRefId = 0) {
6920
7629
  this.types = types;
6921
- this.refIds = new RefIdAllocator(startRefId);
7630
+ this.nextUniqueId = startRefId;
6922
7631
  }
6923
7632
  add(changeTree) {
6924
7633
  const ref = changeTree.ref;
@@ -6927,31 +7636,27 @@ class Root {
6927
7636
  // *enumerable* own Symbols, so we keep defineProperty(enumerable:false)
6928
7637
  // to keep $refId hidden from deep-equal comparisons in tests.
6929
7638
  if (ref[$refId] === undefined) {
6930
- Object.defineProperty(ref, $refId, {
6931
- value: this.refIds.acquire(),
6932
- enumerable: false,
6933
- writable: true
6934
- });
7639
+ $refIdDescriptor$1.value = this.nextUniqueId++;
7640
+ Object.defineProperty(ref, $refId, $refIdDescriptor$1);
6935
7641
  }
6936
7642
  const refId = ref[$refId];
6937
7643
  const isNewChangeTree = (this.changeTrees[refId] === undefined);
6938
7644
  if (isNewChangeTree) {
6939
7645
  this.changeTrees[refId] = changeTree;
6940
7646
  }
6941
- // Resurrection path: a ref whose refId is still queued for reuse
6942
- // is being re-added. Pull the refId out of the pool before it gets
6943
- // handed out to someone else.
6944
- if (this.refIds.isPooled(refId)) {
6945
- this.refIds.reclaim(refId);
6946
- }
6947
7647
  const previousRefCount = this.refCount[refId];
6948
- if (previousRefCount === 0) {
7648
+ if (previousRefCount === 0 || changeTree.needsRestage) {
6949
7649
  //
6950
- // When a ChangeTree is re-added, it means that it was previously
6951
- // removed. Re-stage every currently-populated non-transient index
6952
- // as a fresh ADD in the matching dirty bucket so the next encode
6953
- // re-emits it on the correct channel.
7650
+ // Re-stage every currently-populated non-transient index as a
7651
+ // fresh ADD in the matching dirty bucket so the next encode
7652
+ // re-emits it on the correct channel. Two triggers:
7653
+ // - refCount 0: a previously-removed tree re-added under the
7654
+ // same refId (its ops were consumed by an earlier encode).
7655
+ // - NEEDS_RESTAGE: a `Schema.reset` instance re-entering under
7656
+ // a fresh refId (reset cleared the buckets; its retained
7657
+ // values would otherwise never be encoded).
6954
7658
  //
7659
+ changeTree.needsRestage = false;
6955
7660
  changeTree.forEachLive((fieldIndex) => {
6956
7661
  if (changeTree.isFieldUnreliable(fieldIndex)) {
6957
7662
  changeTree.ensureUnreliableRecorder().record(fieldIndex, exports.OPERATION.ADD);
@@ -6984,15 +7689,6 @@ class Root {
6984
7689
  this.removeFromQueue(changeTree);
6985
7690
  this.removeFromUnreliableQueue(changeTree);
6986
7691
  this.refCount[refId] = 0;
6987
- // Return refId to the reuse pool (deferred to end-of-tick via
6988
- // the allocator's pending set). Stream collections are excluded
6989
- // because their per-view delivery bookkeeping is harder to
6990
- // audit for reuse safety and the savings there are negligible.
6991
- // If the ref is later resurrected, `add()` evicts the refId
6992
- // from the pool before it's handed to another instance.
6993
- if (!changeTree.isStreamCollection) {
6994
- this.refIds.release(refId);
6995
- }
6996
7692
  changeTree.forEachChild((child, _) => {
6997
7693
  if (child.removeParent(changeTree.ref)) {
6998
7694
  if ((child.parentRef === undefined || // no parent, remove it
@@ -7165,6 +7861,12 @@ function ensureStructSwitch(ctx) {
7165
7861
  * Module-level adapter for `forEachLiveWithCtx`. Full-sync emits every live
7166
7862
  * field as ADD, so we re-enter `encodeChangeCb` with that fixed op — keeps
7167
7863
  * the callback closure-free across the entire DFS walk.
7864
+ *
7865
+ * The resync sweep (decoder/Resync.ts) depends on this shape: full-sync
7866
+ * output is dense plain ADDs — no DELETEs, no gap-writes (so decoding it
7867
+ * never compacts arrays mid-walk), and replaced occupants arrive as plain
7868
+ * ADD (the sweep's touch hook releases them). Changing full-sync emission
7869
+ * means revisiting the sweep.
7168
7870
  */
7169
7871
  function encodeFullSyncCb(ctx, fieldIndex) {
7170
7872
  encodeChangeCb(ctx, fieldIndex, exports.OPERATION.ADD);
@@ -7270,7 +7972,18 @@ function concatBytes(a, b) {
7270
7972
  return result;
7271
7973
  }
7272
7974
  class Encoder {
7273
- static BUFFER_SIZE = 8 * 1024; // 8KB
7975
+ /**
7976
+ * Per-encoder shared output buffer size. The encoder auto-grows on
7977
+ * overflow and logs a one-time warning suggesting a higher value, so
7978
+ * the default just needs to comfortably cover typical room state.
7979
+ *
7980
+ * Sized to fit ~100 items in a `MapSchema<{x,y,z}>` keyed by
7981
+ * `nanoid(9)` (~4.5 KB worst-case full encode, float64-heavy) with
7982
+ * ~3-4× headroom for surrounding state (player list, world refs,
7983
+ * etc.). Raise per app via `Encoder.BUFFER_SIZE = N * 1024` before
7984
+ * constructing any Encoder.
7985
+ */
7986
+ static BUFFER_SIZE = 16 * 1024;
7274
7987
  sharedBuffer = new Uint8Array(Encoder.BUFFER_SIZE);
7275
7988
  context;
7276
7989
  state;
@@ -7374,7 +8087,12 @@ class Encoder {
7374
8087
  }
7375
8088
  if (it.offset > buffer.byteLength) {
7376
8089
  buffer = this._resizeBuffer(buffer, it.offset);
7377
- return this._encodeChannel({ offset: initialOffset }, view, buffer, initialOffset, unreliable);
8090
+ // Reuse `it` (reset its offset) instead of a fresh iterator so the
8091
+ // caller's `it.offset` ends at the true final offset. A fresh one
8092
+ // strands `it.offset` at the overflow value and corrupts the next
8093
+ // view's region in a multi-view encode.
8094
+ it.offset = initialOffset;
8095
+ return this._encodeChannel(it, view, buffer, initialOffset, unreliable);
7378
8096
  }
7379
8097
  return buffer.subarray(0, it.offset);
7380
8098
  }
@@ -7406,7 +8124,8 @@ class Encoder {
7406
8124
  _fullSyncWalk(ctx, rootChangeTree);
7407
8125
  if (it.offset > buffer.byteLength) {
7408
8126
  buffer = this._resizeBuffer(buffer, it.offset);
7409
- return this.encodeFullSync({ offset: initialOffset }, buffer, emitFiltered, view);
8127
+ it.offset = initialOffset; // reuse `it` so the caller's offset stays accurate
8128
+ return this.encodeFullSync(it, buffer, emitFiltered, view, initialOffset);
7410
8129
  }
7411
8130
  return buffer.subarray(0, it.offset);
7412
8131
  }
@@ -7429,9 +8148,24 @@ class Encoder {
7429
8148
  }
7430
8149
  encodeAllView(view, sharedOffset, it, bytes = this.sharedBuffer) {
7431
8150
  const viewOffset = it.offset;
7432
- this.encodeFullSync(it, bytes, /* emitFiltered */ true, view, viewOffset);
8151
+ // encodeFullSync() may reallocate the buffer on overflow — keep its
8152
+ // return, not the stale `bytes`, or the concat below reads a dead buffer.
8153
+ bytes = this.encodeFullSync(it, bytes, /* emitFiltered */ true, view, viewOffset);
7433
8154
  return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
7434
8155
  }
8156
+ /** Grow `buffer` to keep BUFFER_SIZE free bytes past `offset`, preserving `[0, offset)`. */
8157
+ ensureCapacity(buffer, offset) {
8158
+ if (offset + Encoder.BUFFER_SIZE <= buffer.byteLength) {
8159
+ return buffer;
8160
+ }
8161
+ const size = Math.ceil((offset + Encoder.BUFFER_SIZE) / Encoder.BUFFER_SIZE) * Encoder.BUFFER_SIZE;
8162
+ const grown = new Uint8Array(size);
8163
+ grown.set(buffer.subarray(0, offset));
8164
+ if (buffer === this.sharedBuffer) {
8165
+ this.sharedBuffer = grown;
8166
+ }
8167
+ return grown;
8168
+ }
7435
8169
  encodeView(view, sharedOffset, it, bytes = this.sharedBuffer) {
7436
8170
  const viewOffset = it.offset;
7437
8171
  // Stream priority pass: drain up to `maxPerTick` per-view entries
@@ -7469,6 +8203,9 @@ class Encoder {
7469
8203
  // `[$getByIndex]` lookup that runs once per change.
7470
8204
  const ref = changeTree.ref;
7471
8205
  const refTarget = changeTree.refTarget;
8206
+ // These writes are unguarded and unrecoverable (view.changes is cleared
8207
+ // below), so unlike encode() they can't re-encode on overflow — grow ahead.
8208
+ bytes = this.ensureCapacity(bytes, it.offset);
7472
8209
  bytes[it.offset++] = SWITCH_TO_STRUCTURE & 255;
7473
8210
  encode.number(bytes, ref[$refId], it);
7474
8211
  // Iterate entries directly — the inner Map gives us the (index, op)
@@ -7487,9 +8224,14 @@ class Encoder {
7487
8224
  // (to allow re-using StateView's for multiple clients)
7488
8225
  //
7489
8226
  view.changes.clear();
7490
- // per-tick view-scoped pass: walks the same `changes` queue as the
8227
+ // Per-tick view-scoped pass: walks the same `changes` queue as the
7491
8228
  // shared pass, but `encodeChangeCb` emits only filtered fields.
7492
- this.encode(it, view, bytes, viewOffset);
8229
+ // encode() may reallocate the buffer on overflow — keep its return,
8230
+ // not the stale `bytes`. Anchor the re-encode at the current offset
8231
+ // (the default `initialOffset = it.offset`), NOT `viewOffset`: a
8232
+ // resize must not clobber the view.changes already written at
8233
+ // [viewOffset, it.offset).
8234
+ bytes = this.encode(it, view, bytes);
7493
8235
  return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
7494
8236
  }
7495
8237
  /**
@@ -7501,7 +8243,9 @@ class Encoder {
7501
8243
  */
7502
8244
  encodeUnreliableView(view, sharedOffset, it, bytes = this.sharedBuffer) {
7503
8245
  const viewOffset = it.offset;
7504
- this.encodeUnreliable(it, view, bytes, viewOffset);
8246
+ // Capture the return: a resize on overflow reallocates the buffer, and
8247
+ // the concat below must read from the live one, not the stale `bytes`.
8248
+ bytes = this.encodeUnreliable(it, view, bytes, viewOffset);
7505
8249
  return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
7506
8250
  }
7507
8251
  /**
@@ -7727,10 +8471,6 @@ class Encoder {
7727
8471
  }
7728
8472
  list.next = undefined;
7729
8473
  list.tail = undefined;
7730
- // End-of-tick: refIds released during this tick have now had their
7731
- // DELETEs emitted through `encode()` / `encodeView()`, so they are
7732
- // safe to recycle on the next tick.
7733
- root.refIds.flushReleases();
7734
8474
  }
7735
8475
  discardUnreliableChanges() {
7736
8476
  const list = this.root.unreliableChanges;
@@ -7784,6 +8524,8 @@ class DecodingWarning extends Error {
7784
8524
  this.name = "DecodingWarning";
7785
8525
  }
7786
8526
  }
8527
+ // Reused across addRef calls — saves a descriptor object per decoded ref.
8528
+ const $refIdDescriptor = { value: 0, enumerable: false, writable: true };
7787
8529
  class ReferenceTracker {
7788
8530
  //
7789
8531
  // Relation of refId => Schema structure
@@ -7804,11 +8546,13 @@ class ReferenceTracker {
7804
8546
  // on decoded instances, which WOULD walk enumerable Symbol-keyed
7805
8547
  // properties and include `$refId` in the comparison. Keep the
7806
8548
  // descriptor dance for semantic compatibility.
7807
- Object.defineProperty(ref, $refId, {
7808
- value: refId,
7809
- enumerable: false,
7810
- writable: true
7811
- });
8549
+ if (ref[$refId] === undefined) {
8550
+ $refIdDescriptor.value = refId;
8551
+ Object.defineProperty(ref, $refId, $refIdDescriptor);
8552
+ }
8553
+ else if (ref[$refId] !== refId) {
8554
+ ref[$refId] = refId; // property exists (writable) — plain write keeps flags
8555
+ }
7812
8556
  if (incrementCount) {
7813
8557
  this.refCount[refId] = (this.refCount[refId] || 0) + 1;
7814
8558
  }
@@ -7922,6 +8666,18 @@ class Decoder {
7922
8666
  root;
7923
8667
  currentRefId = 0;
7924
8668
  triggerChanges;
8669
+ /**
8670
+ * @internal Non-null only while a `decodeResync()` walk is in progress:
8671
+ * collection refId → entry identities the payload visited (map string
8672
+ * keys; array/set/collection/stream indexes). Written by the collection
8673
+ * DecodeOperation functions, read by the post-decode sweep.
8674
+ */
8675
+ resyncVisited = null;
8676
+ /**
8677
+ * @internal Set when a structure had to be skipped during a resync
8678
+ * decode — visited data is incomplete, so the sweep must not delete.
8679
+ */
8680
+ resyncDamaged = false;
7925
8681
  constructor(root, context) {
7926
8682
  this.setState(root);
7927
8683
  this.context = context || new TypeContext(root.constructor);
@@ -7986,6 +8742,12 @@ class Decoder {
7986
8742
  // currently hooks it; the dual call sites stay as-is rather
7987
8743
  // than being extracted into a helper for a one-line body.
7988
8744
  ref[$onDecodeEnd]?.();
8745
+ // resync mode: prune everything the snapshot didn't visit. Runs
8746
+ // before triggerChanges (DELETE changes fire onRemove with the real
8747
+ // previousValue) and before GC (removeRef feeds deletedRefs).
8748
+ if (this.resyncVisited !== null) {
8749
+ resyncSweep(this, allChanges);
8750
+ }
7989
8751
  // trigger changes
7990
8752
  if (allChanges !== null)
7991
8753
  this.triggerChanges?.(allChanges);
@@ -7993,7 +8755,37 @@ class Decoder {
7993
8755
  $root.garbageCollectDeletedRefs();
7994
8756
  return allChanges;
7995
8757
  }
8758
+ /**
8759
+ * Full-snapshot reconciliation ("resync") decode.
8760
+ *
8761
+ * Behaves exactly like {@link decode}, plus: every collection entry the
8762
+ * payload does NOT mention is removed through the regular DELETE path —
8763
+ * `onRemove` callbacks fire with the real previous value and released
8764
+ * refs are garbage-collected. Use it to apply a rejoin/reconnect full
8765
+ * state over an existing decoded tree: DELETEs that happened while the
8766
+ * client was off the wire are reconciled as if they had been received,
8767
+ * while surviving entries keep their instance identity and callbacks.
8768
+ *
8769
+ * ONLY valid for full-snapshot payloads (`encodeAll` / `encodeAllView`
8770
+ * output). Calling it on an incremental patch would prune everything
8771
+ * the patch doesn't touch.
8772
+ */
8773
+ decodeResync(bytes, it = { offset: 0 }) {
8774
+ this.resyncVisited = new Map();
8775
+ this.resyncDamaged = false;
8776
+ try {
8777
+ return this.decode(bytes, it);
8778
+ }
8779
+ finally {
8780
+ this.resyncVisited = null;
8781
+ }
8782
+ }
7996
8783
  skipCurrentStructure(bytes, it, totalBytes) {
8784
+ // A skipped range can swallow other structures' ops (their ADDs are
8785
+ // never applied), so resync visited data is no longer trustworthy.
8786
+ if (this.resyncVisited !== null) {
8787
+ this.resyncDamaged = true;
8788
+ }
7997
8789
  //
7998
8790
  // keep skipping next bytes until reaches a known structure
7999
8791
  // by local decoder.
@@ -8043,10 +8835,27 @@ class Decoder {
8043
8835
  /**
8044
8836
  * Reflection
8045
8837
  */
8838
+ /**
8839
+ * `t.quantized()` field descriptor as it rides the reflection handshake —
8840
+ * schema-typed (bit-exact float64 bounds), NOT a string grammar, so every
8841
+ * language port decodes it with the schema decoder it already has.
8842
+ */
8843
+ const QuantizedDescriptor = schema({
8844
+ min: t.float64(),
8845
+ max: t.float64(),
8846
+ bits: t.uint8(),
8847
+ mode: t.uint8(), // 0 = clamp, 1 = wrap
8848
+ }, "QuantizedDescriptor");
8046
8849
  const ReflectionField = schema({
8047
8850
  name: t.string(),
8048
8851
  type: t.string(),
8049
8852
  referencedType: t.number(),
8853
+ /** Primitive child of a collection (`array`/`map`/... of "string" etc.) —
8854
+ * its own slot, replacing the legacy `"array:string"` colon packing. */
8855
+ childPrimitive: t.string(),
8856
+ /** Set only on `t.quantized()` fields (`.optional()` — no auto-instantiated
8857
+ * default; its absence is the "not quantized" signal on decode). */
8858
+ quantized: t.ref(QuantizedDescriptor).optional(),
8050
8859
  }, "ReflectionField");
8051
8860
  const ReflectionType = schema({
8052
8861
  id: t.number(),
@@ -8116,6 +8925,18 @@ Reflection.encode = function (encoder, it = { offset: 0 }) {
8116
8925
  if (typeof (field.type) === "string") {
8117
8926
  fieldType = field.type;
8118
8927
  }
8928
+ else if (isQuantizedType(field.type)) {
8929
+ // Params ride as a schema-typed descriptor (bit-exact float64) —
8930
+ // no string grammar for the peer (or a language port) to parse.
8931
+ const d = field.type.quantized;
8932
+ fieldType = "quantized";
8933
+ const desc = new QuantizedDescriptor();
8934
+ desc.min = d.min;
8935
+ desc.max = d.max;
8936
+ desc.bits = d.bits;
8937
+ desc.mode = d.wrap ? 1 : 0;
8938
+ reflectionField.quantized = desc;
8939
+ }
8119
8940
  else {
8120
8941
  let childTypeSchema;
8121
8942
  //
@@ -8128,7 +8949,8 @@ Reflection.encode = function (encoder, it = { offset: 0 }) {
8128
8949
  else {
8129
8950
  fieldType = Object.keys(field.type)[0];
8130
8951
  if (typeof (field.type[fieldType]) === "string") {
8131
- fieldType += ":" + field.type[fieldType]; // array:string
8952
+ // primitive child gets its own slot (was packed as "array:string")
8953
+ reflectionField.childPrimitive = field.type[fieldType];
8132
8954
  }
8133
8955
  else {
8134
8956
  childTypeSchema = field.type[fieldType];
@@ -8169,15 +8991,20 @@ Reflection.decode = function (bytes, it) {
8169
8991
  const addFields = (metadata, reflectionType, parentFieldIndex) => {
8170
8992
  reflectionType.fields.forEach((field, i) => {
8171
8993
  const fieldIndex = parentFieldIndex + i;
8172
- if (field.referencedType !== undefined) {
8173
- let fieldType = field.type;
8174
- let refType = typeContext.get(field.referencedType);
8175
- // map or array of primitive type (-1)
8176
- if (!refType) {
8177
- const typeInfo = field.type.split(":");
8178
- fieldType = typeInfo[0];
8179
- refType = typeInfo[1]; // string
8180
- }
8994
+ if (field.quantized !== undefined) {
8995
+ // Schema-typed descriptor → resolved codec (validation stays in
8996
+ // resolveQuantize, same as the builder path).
8997
+ const q = field.quantized;
8998
+ Metadata.addField(metadata, fieldIndex, field.name, {
8999
+ quantized: resolveQuantize({ min: q.min, max: q.max, bits: q.bits, mode: q.mode === 1 ? "wrap" : "clamp" }),
9000
+ });
9001
+ }
9002
+ else if (field.referencedType !== undefined) {
9003
+ const fieldType = field.type;
9004
+ // Schema child by type id; a primitive child (referencedType -1)
9005
+ // rides its own childPrimitive slot.
9006
+ const refType = typeContext.get(field.referencedType)
9007
+ ?? field.childPrimitive;
8181
9008
  if (fieldType === "ref") {
8182
9009
  Metadata.addField(metadata, fieldIndex, field.name, refType);
8183
9010
  }
@@ -8860,6 +9687,48 @@ const Callbacks = {
8860
9687
  }
8861
9688
  };
8862
9689
 
9690
+ /**
9691
+ * Object pool for Schema instances. Exported as a **type only** — construct one
9692
+ * via {@link createPool}, which is the public entry point. The class is public
9693
+ * for typing (`SchemaPool<Entity>` annotations) and `instanceof` checks.
9694
+ */
9695
+ class SchemaPool {
9696
+ _free = [];
9697
+ _factory;
9698
+ _maxSize;
9699
+ constructor(factory, opts = {}) {
9700
+ this._factory = factory;
9701
+ this._maxSize = opts.maxSize ?? Infinity;
9702
+ const preallocate = opts.preallocate ?? 0;
9703
+ for (let i = 0; i < preallocate; i++) {
9704
+ this._free.push(factory());
9705
+ }
9706
+ }
9707
+ /** Pop a pre-reset free instance, or construct a fresh one. */
9708
+ acquire() {
9709
+ return this._free.length > 0 ? this._free.pop() : this._factory();
9710
+ }
9711
+ /**
9712
+ * Reset `instance` to construction defaults and return it to the pool.
9713
+ * PRECONDITION: the instance must already be detached from the state tree
9714
+ * (removed from its parent collection/field, so the encoder released it).
9715
+ */
9716
+ release(instance) {
9717
+ Schema.reset(instance);
9718
+ if (this._free.length < this._maxSize) {
9719
+ this._free.push(instance);
9720
+ }
9721
+ }
9722
+ /** Number of instances currently available for reuse. */
9723
+ get size() {
9724
+ return this._free.length;
9725
+ }
9726
+ }
9727
+ /** Convenience factory: `createPool(Entity, { preallocate: 64 })`. */
9728
+ function createPool(ctor, opts = {}) {
9729
+ return new SchemaPool(() => new ctor(), opts);
9730
+ }
9731
+
8863
9732
  /**
8864
9733
  * Clear the bit for `(slot, bit)` on every ChangeTree in `root`. Called
8865
9734
  * from `dispose()` and from the FinalizationRegistry callback so a view's
@@ -9062,42 +9931,66 @@ class StateView {
9062
9931
  // `tags: WeakMap<ChangeTree, Set<number>>` storage. Hot read site is
9063
9932
  // `Schema.ts` filter check — `hasTagOnTree` is O(1) bitwise.
9064
9933
  // ──────────────────────────────────────────────────────────────────
9065
- /** True iff this view has `tag` associated with `tree`. */
9934
+ /**
9935
+ * True iff this view shares at least one tag bit with `tree`.
9936
+ *
9937
+ * `tagViews` is keyed by individual power-of-two bits (custom tags must
9938
+ * be powers of two; `@view(A|B)` field masks are decomposed on store).
9939
+ * A field whose mask is `tag` is visible if the view was `add()`ed with
9940
+ * any overlapping bit — so we walk `tag`'s set bits and return on the
9941
+ * first match. Passing DEFAULT_VIEW_TAG (-1, all bits) answers "does
9942
+ * this view hold ANY custom tag on the tree".
9943
+ */
9066
9944
  hasTagOnTree(tree, tag) {
9067
9945
  const map = tree.tagViews;
9068
9946
  if (map === undefined)
9069
9947
  return false;
9070
- const arr = map.get(tag);
9071
9948
  const slot = this._slot;
9072
- return arr !== undefined && slot < arr.length && (arr[slot] & this._bit) !== 0;
9949
+ const bit = this._bit;
9950
+ for (let bits = tag; bits !== 0; bits &= bits - 1) {
9951
+ const arr = map.get(bits & -bits); // isolate lowest set bit
9952
+ if (arr !== undefined && slot < arr.length && (arr[slot] & bit) !== 0)
9953
+ return true;
9954
+ }
9955
+ return false;
9073
9956
  }
9074
- /** Mark `tree` as carrying `tag` for this view. */
9957
+ /** Mark `tree` as carrying `tag` (each of its bits) for this view. */
9075
9958
  addTag(tree, tag) {
9959
+ // DEFAULT_VIEW_TAG visibility lives in `visibleViews`, not here.
9960
+ if (tag === DEFAULT_VIEW_TAG)
9961
+ return;
9076
9962
  let map = tree.tagViews;
9077
9963
  if (map === undefined) {
9078
9964
  map = tree.tagViews = new Map();
9079
9965
  }
9080
- let arr = map.get(tag);
9081
- if (arr === undefined) {
9082
- arr = [];
9083
- map.set(tag, arr);
9084
- }
9085
9966
  const slot = this._slot;
9086
- while (arr.length <= slot)
9087
- arr.push(0);
9088
- arr[slot] |= this._bit;
9967
+ const bit = this._bit;
9968
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
9969
+ const b = bits & -bits; // isolate lowest set bit
9970
+ let arr = map.get(b);
9971
+ if (arr === undefined) {
9972
+ arr = [];
9973
+ map.set(b, arr);
9974
+ }
9975
+ while (arr.length <= slot)
9976
+ arr.push(0);
9977
+ arr[slot] |= bit;
9978
+ }
9089
9979
  }
9090
- /** Clear this view's `tag` bit on `tree`. */
9980
+ /** Clear each of `tag`'s bits for this view on `tree`. */
9091
9981
  removeTag(tree, tag) {
9982
+ if (tag === DEFAULT_VIEW_TAG)
9983
+ return;
9092
9984
  const map = tree.tagViews;
9093
9985
  if (map === undefined)
9094
9986
  return;
9095
- const arr = map.get(tag);
9096
- if (arr === undefined)
9097
- return;
9098
9987
  const slot = this._slot;
9099
- if (slot < arr.length)
9100
- arr[slot] &= ~this._bit;
9988
+ const clearMask = ~this._bit;
9989
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
9990
+ const arr = map.get(bits & -bits);
9991
+ if (arr !== undefined && slot < arr.length)
9992
+ arr[slot] &= clearMask;
9993
+ }
9101
9994
  }
9102
9995
  /** Clear ALL tag bits this view holds on `tree` (used when the per-tag isn't known). */
9103
9996
  removeAllTagsOnTree(tree) {
@@ -9229,10 +10122,16 @@ class StateView {
9229
10122
  // direct array index instead of a per-field-object hop.
9230
10123
  const tags = changeTree.encDescriptor.tags;
9231
10124
  changeTree.forEachChild((change, index) => {
9232
- // Do not ADD children that don't have the same tag
10125
+ // Do not ADD children whose field tag shares no bit with `tag`.
10126
+ // DEFAULT_VIEW_TAG fields are visible to all clients; custom-tag
10127
+ // fields only when bits overlap, and never to default-tag clients.
9233
10128
  const fieldTag = tags[index];
9234
- if (fieldTag !== undefined && fieldTag !== tag) {
9235
- return;
10129
+ if (fieldTag !== undefined) {
10130
+ const tagMatch = fieldTag === DEFAULT_VIEW_TAG ||
10131
+ (tag !== DEFAULT_VIEW_TAG && (fieldTag & tag) !== 0);
10132
+ if (!tagMatch) {
10133
+ return;
10134
+ }
9236
10135
  }
9237
10136
  if (this.add(change.ref, tag, false)) {
9238
10137
  isChildAdded = true;
@@ -9241,12 +10140,19 @@ class StateView {
9241
10140
  // set tag
9242
10141
  if (tag !== DEFAULT_VIEW_TAG) {
9243
10142
  this.addTag(changeTree, tag);
9244
- // Ref: add tagged properties
9245
- metadata?.[$fieldIndexesByViewTag]?.[tag]?.forEach((index) => {
9246
- if (changeTree.getChange(index) !== exports.OPERATION.DELETE) {
9247
- changes.set(index, exports.OPERATION.ADD);
10143
+ // Ref: add tagged properties. `$fieldIndexesByViewTag` is keyed
10144
+ // per-bit, so a combined add-tag (`view.add(obj, A|B)`) must look
10145
+ // up each set bit to force-ADD every field that shares a bit.
10146
+ const byTag = metadata?.[$fieldIndexesByViewTag];
10147
+ if (byTag !== undefined) {
10148
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
10149
+ byTag[bits & -bits]?.forEach((index) => {
10150
+ if (changeTree.getChange(index) !== exports.OPERATION.DELETE) {
10151
+ changes.set(index, exports.OPERATION.ADD);
10152
+ }
10153
+ });
9248
10154
  }
9249
- });
10155
+ }
9250
10156
  }
9251
10157
  else if (!changeTree.isNew || isChildAdded) {
9252
10158
  // new structures will be added as part of .encode() call, no need to force it to .encodeView()
@@ -9262,7 +10168,8 @@ class StateView {
9262
10168
  const tagAtIndex = tags[index];
9263
10169
  if (isInvisible || // if "invisible", include all
9264
10170
  tagAtIndex === undefined || // "all change" with no tag
9265
- tagAtIndex === tag // tagged property
10171
+ tagAtIndex === DEFAULT_VIEW_TAG || // visible to all clients
10172
+ (tag !== DEFAULT_VIEW_TAG && (tagAtIndex & tag) !== 0) // tag bits overlap
9266
10173
  ) {
9267
10174
  changes.set(index, exports.OPERATION.ADD);
9268
10175
  isChildAdded = true;
@@ -9292,8 +10199,12 @@ class StateView {
9292
10199
  const tags = tree.encDescriptor.tags;
9293
10200
  tree.forEachChild((child, index) => {
9294
10201
  const fieldTag = tags[index];
9295
- if (fieldTag !== undefined && fieldTag !== tag)
9296
- return;
10202
+ if (fieldTag !== undefined) {
10203
+ const tagMatch = fieldTag === DEFAULT_VIEW_TAG ||
10204
+ (tag !== DEFAULT_VIEW_TAG && (fieldTag & tag) !== 0);
10205
+ if (!tagMatch)
10206
+ return;
10207
+ }
9297
10208
  if (child.isNew) {
9298
10209
  this.markVisible(child);
9299
10210
  this._markSubtreeVisible(child, tag);
@@ -9309,20 +10220,25 @@ class StateView {
9309
10220
  if (!this.isVisible(changeTree)) {
9310
10221
  // view must have all "changeTree" parent tree
9311
10222
  this.markVisible(changeTree);
9312
- // Recurse all the way to the root regardless of whether the
9313
- // parent is filtered. Walking the full chain is what makes
9314
- // `view.changes` topologically ordered by construction any
9315
- // filtered ancestor up the chain is touched here, before the
9316
- // descendant's entry. The actual entry-write below is gated
9317
- // on `hasFilteredFields` so non-filtered ancestors don't
9318
- // emit redundant wire bytes (the decoder already knows them
9319
- // via the shared encode pass). Marking them visible is
9320
- // still useful: it makes this short-circuit fire on the next
9321
- // `view.add` instead of re-walking the chain.
9322
- const parentChangeTree = changeTree.parent?.[$changes];
9323
- if (parentChangeTree) {
9324
- this.addParentOf(changeTree, tag);
9325
- }
10223
+ }
10224
+ // Recurse all the way to the root REGARDLESS of whether this parent
10225
+ // is already visible. Walking the full chain keeps `view.changes`
10226
+ // topologically ordered by construction (ancestors touched before
10227
+ // the descendant's entry), and crucially — re-queues the ancestor
10228
+ // binding ops every time: visibility bits are per-VIEW, but the ADD
10229
+ // ops they once queued are consumed per-ENCODE. With a shared view,
10230
+ // an earlier encode (for other clients) or a dropped backlog leaves
10231
+ // an already-visible ancestor whose binding a late-attached client
10232
+ // never received its filtered-container refId would then arrive
10233
+ // unbound ("refId not found"). Re-writing the entry ops is cheap
10234
+ // (Map.set dedupes within a patch) and decodes as a no-op for
10235
+ // clients that already hold the refs. The entry-write below is
10236
+ // still gated on `hasFilteredFields` so non-filtered ancestors
10237
+ // don't emit redundant wire bytes (the decoder already knows them
10238
+ // via the shared encode pass).
10239
+ const parentChangeTree = changeTree.parent?.[$changes];
10240
+ if (parentChangeTree) {
10241
+ this.addParentOf(changeTree, tag);
9326
10242
  }
9327
10243
  // Skip the entry-write for non-filtered ancestors: their refIds
9328
10244
  // are already known to the decoder through the shared pass, and
@@ -9488,19 +10404,25 @@ class StateView {
9488
10404
  }
9489
10405
  }
9490
10406
  else {
9491
- // delete only tagged properties
10407
+ // delete only tagged properties. `$fieldIndexesByViewTag` is
10408
+ // keyed per-bit, so a combined tag iterates each set bit.
9492
10409
  const names = changeTree.encDescriptor.names;
9493
- metadata?.[$fieldIndexesByViewTag][tag].forEach((index) => {
9494
- changes.set(index, exports.OPERATION.DELETE);
9495
- // Remove child structures from visible set
9496
- const value = changeTree.ref[names[index]];
9497
- if (value?.[$changes]) {
9498
- this.unmarkVisible(value[$changes]);
9499
- this._recursiveDeleteVisibleChangeTree(value[$changes]);
10410
+ const byTag = metadata?.[$fieldIndexesByViewTag];
10411
+ if (byTag !== undefined) {
10412
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
10413
+ byTag[bits & -bits]?.forEach((index) => {
10414
+ changes.set(index, exports.OPERATION.DELETE);
10415
+ // Remove child structures from visible set
10416
+ const value = changeTree.ref[names[index]];
10417
+ if (value?.[$changes]) {
10418
+ this.unmarkVisible(value[$changes]);
10419
+ this._recursiveDeleteVisibleChangeTree(value[$changes]);
10420
+ }
10421
+ });
9500
10422
  }
9501
- });
10423
+ }
9502
10424
  }
9503
- // remove tag bit for this view
10425
+ // remove tag bits for this view
9504
10426
  if (tag === undefined) {
9505
10427
  this.removeAllTagsOnTree(changeTree);
9506
10428
  }
@@ -9677,8 +10599,10 @@ exports.$deleteByIndex = $deleteByIndex;
9677
10599
  exports.$encoder = $encoder;
9678
10600
  exports.$filter = $filter;
9679
10601
  exports.$getByIndex = $getByIndex;
10602
+ exports.$numFields = $numFields;
9680
10603
  exports.$refId = $refId;
9681
10604
  exports.$track = $track;
10605
+ exports.$values = $values;
9682
10606
  exports.ArraySchema = ArraySchema;
9683
10607
  exports.Callbacks = Callbacks;
9684
10608
  exports.ChangeTree = ChangeTree;
@@ -9698,10 +10622,12 @@ exports.StateCallbackStrategy = StateCallbackStrategy;
9698
10622
  exports.StateView = StateView;
9699
10623
  exports.StreamSchema = StreamSchema;
9700
10624
  exports.TypeContext = TypeContext;
10625
+ exports.createPool = createPool;
9701
10626
  exports.decode = decode;
9702
10627
  exports.decodeKeyValueOperation = decodeKeyValueOperation;
9703
10628
  exports.decodeSchemaOperation = decodeSchemaOperation;
9704
10629
  exports.defineCustomTypes = defineCustomTypes;
10630
+ exports.defineTypes = defineTypes;
9705
10631
  exports.deprecated = deprecated;
9706
10632
  exports.dumpChanges = dumpChanges;
9707
10633
  exports.encode = encode;
@@ -9712,6 +10638,7 @@ exports.encodeMapEntry = encodeMapEntry;
9712
10638
  exports.encodeSchemaOperation = encodeSchemaOperation;
9713
10639
  exports.entity = entity;
9714
10640
  exports.getDecoderStateCallbacks = getDecoderStateCallbacks;
10641
+ exports.getEncodeDescriptor = getEncodeDescriptor;
9715
10642
  exports.getRawChangesCallback = getRawChangesCallback;
9716
10643
  exports.isBuilder = isBuilder;
9717
10644
  exports.owned = owned;