@colyseus/schema 5.0.6 → 5.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/build/Reflection.d.ts +53 -0
  2. package/build/Schema.d.ts +26 -1
  3. package/build/annotations.d.ts +8 -1
  4. package/build/codegen/cli.cjs +74 -64
  5. package/build/codegen/cli.cjs.map +1 -1
  6. package/build/codegen/types.d.ts +0 -1
  7. package/build/decoder/Decoder.d.ts +28 -0
  8. package/build/decoder/Resync.d.ts +61 -0
  9. package/build/encoder/ChangeTree.d.ts +15 -0
  10. package/build/encoder/Encoder.d.ts +13 -0
  11. package/build/encoder/Pool.d.ts +62 -0
  12. package/build/encoder/Root.d.ts +5 -4
  13. package/build/encoder/StateView.d.ts +12 -3
  14. package/build/index.cjs +1192 -284
  15. package/build/index.cjs.map +1 -1
  16. package/build/index.d.ts +3 -0
  17. package/build/index.js +1192 -284
  18. package/build/index.mjs +1189 -285
  19. package/build/index.mjs.map +1 -1
  20. package/build/input/InputDecoder.d.ts +9 -4
  21. package/build/input/InputEncoder.d.ts +44 -53
  22. package/build/input/index.cjs +102 -7321
  23. package/build/input/index.cjs.map +1 -1
  24. package/build/input/index.mjs +97 -7316
  25. package/build/input/index.mjs.map +1 -1
  26. package/build/types/HelperTypes.d.ts +3 -0
  27. package/build/types/builder.d.ts +57 -4
  28. package/build/types/custom/ArraySchema.d.ts +11 -1
  29. package/build/types/custom/CollectionSchema.d.ts +9 -1
  30. package/build/types/custom/MapSchema.d.ts +9 -1
  31. package/build/types/custom/SetSchema.d.ts +9 -1
  32. package/build/types/custom/StreamSchema.d.ts +2 -1
  33. package/build/types/quantize.d.ts +102 -0
  34. package/build/types/symbols.d.ts +15 -0
  35. package/package.json +8 -1
  36. package/src/Metadata.ts +34 -9
  37. package/src/Reflection.ts +49 -11
  38. package/src/Schema.ts +64 -2
  39. package/src/annotations.ts +133 -51
  40. package/src/bench_churn.ts +121 -0
  41. package/src/codegen/languages/haxe.ts +0 -16
  42. package/src/codegen/parser.ts +29 -11
  43. package/src/codegen/types.ts +45 -63
  44. package/src/decoder/DecodeOperation.ts +46 -4
  45. package/src/decoder/Decoder.ts +48 -0
  46. package/src/decoder/ReferenceTracker.ts +9 -5
  47. package/src/decoder/Resync.ts +170 -0
  48. package/src/encoder/ChangeTree.ts +59 -0
  49. package/src/encoder/Encoder.ts +53 -12
  50. package/src/encoder/Pool.ts +90 -0
  51. package/src/encoder/Root.ts +22 -32
  52. package/src/encoder/StateView.ts +101 -50
  53. package/src/encoder/changeTree/liveIteration.ts +4 -0
  54. package/src/encoder/changeTree/treeAttachment.ts +28 -24
  55. package/src/index.ts +11 -1
  56. package/src/input/InputDecoder.ts +11 -5
  57. package/src/input/InputEncoder.ts +85 -126
  58. package/src/types/HelperTypes.ts +7 -0
  59. package/src/types/TypeContext.ts +7 -0
  60. package/src/types/builder.ts +62 -5
  61. package/src/types/custom/ArraySchema.ts +70 -12
  62. package/src/types/custom/CollectionSchema.ts +36 -1
  63. package/src/types/custom/MapSchema.ts +50 -1
  64. package/src/types/custom/SetSchema.ts +36 -1
  65. package/src/types/custom/StreamSchema.ts +7 -0
  66. package/src/types/quantize.ts +173 -0
  67. package/src/types/symbols.ts +16 -0
  68. package/build/encoder/RefIdAllocator.d.ts +0 -35
  69. package/src/encoder/RefIdAllocator.ts +0 -68
package/build/index.mjs CHANGED
@@ -57,6 +57,13 @@ const $decoder = "~decoder";
57
57
  const $filter = "~filter";
58
58
  const $getByIndex = "~getByIndex";
59
59
  const $deleteByIndex = "~deleteByIndex";
60
+ /**
61
+ * Resync-sweep hook (see decoder/Resync.ts): prune every entry the rejoin
62
+ * snapshot did not visit. Each collection owns its storage-specific
63
+ * bookkeeping (journal pruning, compaction, item indexes); the generic
64
+ * DELETE/ref/callback bookkeeping arrives via the `prune`/`keep` callbacks.
65
+ */
66
+ const $resyncPrune = "~resyncPrune";
60
67
  /**
61
68
  * Used to hold ChangeTree instances whitin the structures.
62
69
  *
@@ -81,6 +88,14 @@ const $proxyTarget = Symbol.for("$proxyTarget");
81
88
  * (Discards changes for next serialization)
82
89
  */
83
90
  const $onEncodeEnd = '~onEncodeEnd';
91
+ /**
92
+ * Optional "reset" method on every poolable Ref (Schema + collections).
93
+ * Empties the instance's backing store and recycles its ChangeTree WITHOUT
94
+ * emitting any wire op, and recurses into ref-type children — so the instance
95
+ * can be returned to a SchemaPool and reused, avoiding the cost of `new`.
96
+ * See encoder/Pool.ts and Schema.reset().
97
+ */
98
+ const $reset = "~reset";
84
99
  /**
85
100
  * When decoding, this method is called after the instance is fully decoded
86
101
  */
@@ -806,6 +821,92 @@ function streamDropView(s, viewId) {
806
821
  st.sentByView.delete(viewId);
807
822
  }
808
823
 
824
+ const WIRE_BY_BITS = {
825
+ 8: "uint8",
826
+ 16: "uint16",
827
+ 32: "uint32",
828
+ };
829
+ /** Validate options and precompute the wire codec + scale span. */
830
+ function resolveQuantize(opts) {
831
+ if (opts == null || typeof opts !== "object") {
832
+ throw new Error("t.quantized(): options object with { min, max } is required.");
833
+ }
834
+ const { min, max } = opts;
835
+ if (typeof min !== "number" || typeof max !== "number" || !(max > min)
836
+ || !Number.isFinite(min) || !Number.isFinite(max)) {
837
+ throw new Error(`t.quantized(): require finite min < max (got min=${min}, max=${max}).`);
838
+ }
839
+ const bits = opts.bits ?? 16;
840
+ if (bits !== 8 && bits !== 16 && bits !== 32) {
841
+ throw new Error(`t.quantized(): bits must be 8, 16 or 32 (got ${bits}).`);
842
+ }
843
+ const mode = opts.mode ?? "clamp";
844
+ if (mode !== "clamp" && mode !== "wrap") {
845
+ throw new Error(`t.quantized(): mode must be "clamp" or "wrap" (got ${JSON.stringify(mode)}).`);
846
+ }
847
+ const wrap = mode === "wrap"; // descriptor + wire stay boolean; `mode` is the public knob
848
+ const steps = Math.pow(2, bits);
849
+ return {
850
+ min,
851
+ max,
852
+ bits,
853
+ wrap,
854
+ wire: WIRE_BY_BITS[bits],
855
+ range: max - min,
856
+ // wrapping spreads `2^bits` steps across [min,max) (top ≡ bottom); clamped
857
+ // maps the endpoints onto `0` and `2^bits − 1` inclusive.
858
+ span: wrap ? steps : steps - 1,
859
+ };
860
+ }
861
+ /** Type guard for the `{ quantized: QuantizeDescriptor }` field-type shape. */
862
+ function isQuantizedType(type) {
863
+ return type !== null && typeof type === "object" && type.quantized !== undefined;
864
+ }
865
+ /**
866
+ * Float → unsigned integer. Rounding is explicit half-up `floor(x + 0.5)`;
867
+ * wrapping ranges are reduced in the float domain first (no huge→int cast).
868
+ */
869
+ function quantize(desc, value) {
870
+ if (desc.wrap) {
871
+ // Non-finite can't be range-reduced (Inf % range = NaN); NaN would flow to
872
+ // the wire as garbage while the local instance kept NaN — a silent peer
873
+ // divergence. Pin to q=0 (= min): garbage in, deterministic out, both agree.
874
+ if (!Number.isFinite(value))
875
+ return 0;
876
+ const range = desc.range;
877
+ // float-domain range reduction → [0, range)
878
+ let a = (value - desc.min) % range;
879
+ if (a < 0)
880
+ a += range;
881
+ const steps = desc.span; // 2^bits
882
+ // `% steps` (not `& mask`) so bits=32 doesn't overflow JS's int32 bitwise.
883
+ return Math.floor((a / range) * steps + 0.5) % steps;
884
+ }
885
+ if (value !== value)
886
+ return 0; // NaN → min (±Inf clamps naturally below)
887
+ const v = value < desc.min ? desc.min : value > desc.max ? desc.max : value;
888
+ return Math.floor(((v - desc.min) / desc.range) * desc.span + 0.5);
889
+ }
890
+ /** Unsigned integer → float. Correctly-rounded mul/div ⇒ bit-identical across languages. */
891
+ function dequantize(desc, q) {
892
+ return desc.min + (q / desc.span) * desc.range;
893
+ }
894
+ /**
895
+ * Pre-baked encoder for a quantized field: quantize the (snapped) float held on
896
+ * the instance, then write it with the field's unsigned-int wire codec. Stored in
897
+ * `metadata[$encoders]` so the encode hot path reaches it via the fast lane,
898
+ * exactly like the pre-computed primitive encoders.
899
+ */
900
+ function makeQuantizedEncoder(desc) {
901
+ const writeWire = encode[desc.wire];
902
+ return (bytes, value, it) => writeWire(bytes, quantize(desc, value), it);
903
+ }
904
+ /** Decode a quantized field: read the unsigned-int wire value, then dequantize. */
905
+ function decodeQuantized(desc, bytes, it) {
906
+ const readWire = decode[desc.wire];
907
+ return dequantize(desc, readWire(bytes, it));
908
+ }
909
+
809
910
  class TypeContext {
810
911
  types = {};
811
912
  schemas = new Map();
@@ -908,6 +1009,11 @@ class TypeContext {
908
1009
  if (typeof (fieldType) === "string") {
909
1010
  continue;
910
1011
  }
1012
+ // Quantized fields are scalar — their object `type` only carries the
1013
+ // descriptor, there's no child Schema to discover.
1014
+ if (isQuantizedType(fieldType)) {
1015
+ continue;
1016
+ }
911
1017
  if (typeof (fieldType) === "function") {
912
1018
  this.discoverTypes(fieldType, klass, index, parentHasViewTag || fieldHasViewTag);
913
1019
  }
@@ -976,6 +1082,14 @@ function getNormalizedType(type) {
976
1082
  if (Array.isArray(type)) {
977
1083
  return { array: getNormalizedType(type[0]) };
978
1084
  }
1085
+ else if (isQuantizedType(type)) {
1086
+ // `{ quantized: ... }` — a scalar wire type, NOT a collection/ref. Resolve
1087
+ // raw options (the `@type({quantized:{min,max}})` path) once; an already-
1088
+ // resolved descriptor (the `t.quantized()` builder path) passes through.
1089
+ return (typeof type.quantized.wire === "string")
1090
+ ? type
1091
+ : { quantized: resolveQuantize(type.quantized) };
1092
+ }
979
1093
  else if (typeof (type['type']) !== "undefined") {
980
1094
  return type['type'];
981
1095
  }
@@ -1054,8 +1168,9 @@ const Metadata = {
1054
1168
  enumerable: false,
1055
1169
  configurable: true,
1056
1170
  });
1057
- // if child Ref/complex type, add to -4
1058
- if (typeof (metadata[index].type) !== "string") {
1171
+ // if child Ref/complex type, add to -4. Quantized fields are scalar (their
1172
+ // `type` is an object only to carry the descriptor) — not refs, so skip them.
1173
+ if (typeof (metadata[index].type) !== "string" && !isQuantizedType(metadata[index].type)) {
1059
1174
  if (metadata[$refTypeFieldIndexes] === undefined) {
1060
1175
  Object.defineProperty(metadata, $refTypeFieldIndexes, {
1061
1176
  value: [],
@@ -1117,10 +1232,25 @@ const Metadata = {
1117
1232
  });
1118
1233
  }
1119
1234
  metadata[$viewFieldIndexes].push(index);
1120
- if (!metadata[$fieldIndexesByViewTag][tag]) {
1121
- metadata[$fieldIndexesByViewTag][tag] = [];
1235
+ // Populate $fieldIndexesByViewTag: for a bitmask tag, register the field
1236
+ // index under each individual set bit so that view.add(obj, Tag.ONE) finds
1237
+ // fields tagged @view(Tag.ONE|Tag.TWO).
1238
+ // Negative tags (i.e. DEFAULT_VIEW_TAG = -1) are stored as-is.
1239
+ if (tag < 0) {
1240
+ if (!metadata[$fieldIndexesByViewTag][tag]) {
1241
+ metadata[$fieldIndexesByViewTag][tag] = [];
1242
+ }
1243
+ metadata[$fieldIndexesByViewTag][tag].push(index);
1244
+ }
1245
+ else {
1246
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
1247
+ const bit = bits & (-bits); // isolate lowest set bit
1248
+ if (!metadata[$fieldIndexesByViewTag][bit]) {
1249
+ metadata[$fieldIndexesByViewTag][bit] = [];
1250
+ }
1251
+ metadata[$fieldIndexesByViewTag][bit].push(index);
1252
+ }
1122
1253
  }
1123
- metadata[$fieldIndexesByViewTag][tag].push(index);
1124
1254
  },
1125
1255
  setUnreliable(metadata, fieldName) {
1126
1256
  const index = metadata[fieldName];
@@ -1226,8 +1356,8 @@ const Metadata = {
1226
1356
  if (metadata[$descriptors][fieldName]) {
1227
1357
  Object.defineProperty(target.prototype, fieldName, metadata[$descriptors][fieldName]);
1228
1358
  }
1229
- // Pre-compute encoder function for primitive types.
1230
- if (typeof normalized === "string") {
1359
+ // Pre-compute encoder function for primitive + quantized types.
1360
+ if (typeof normalized === "string" || isQuantizedType(normalized)) {
1231
1361
  if (!metadata[$encoders]) {
1232
1362
  Object.defineProperty(metadata, $encoders, {
1233
1363
  value: [],
@@ -1236,7 +1366,9 @@ const Metadata = {
1236
1366
  writable: true,
1237
1367
  });
1238
1368
  }
1239
- metadata[$encoders][fieldIndex] = encode[normalized];
1369
+ metadata[$encoders][fieldIndex] = (typeof normalized === "string")
1370
+ ? encode[normalized]
1371
+ : makeQuantizedEncoder(normalized.quantized);
1240
1372
  }
1241
1373
  },
1242
1374
  setFields(target, fields) {
@@ -1882,6 +2014,8 @@ function forEachLiveWithCtx(tree, ctx, cb) {
1882
2014
  const ref = tree.refTarget;
1883
2015
  if (ref[$childType] !== undefined) {
1884
2016
  // Collection inheriting @transient from parent field: skip entirely.
2017
+ // The resync sweep (decoder/Resync.ts) relies on this: a collection
2018
+ // absent from full-sync output is never pruned client-side.
1885
2019
  if (tree.isTransient)
1886
2020
  return;
1887
2021
  // Collection types: dispatch by shape.
@@ -1911,6 +2045,8 @@ function forEachLiveWithCtx(tree, ctx, cb) {
1911
2045
  // Schema: walk declared fields. `null` is treated as absent —
1912
2046
  // the setter records a DELETE when a field is set to null or
1913
2047
  // undefined, so it should not appear in full-sync output.
2048
+ // (@transient skips below matter to the resync sweep — see
2049
+ // decoder/Resync.ts: absent-from-payload means never pruned.)
1914
2050
  //
1915
2051
  // Read names from the per-class descriptor's parallel array —
1916
2052
  // saves the `metadata[i]` (per-field obj) + `.name` chain on
@@ -2174,6 +2310,12 @@ function propagateNewChildToSubscribers(parentTree, childIndex, childRef, root)
2174
2310
  }
2175
2311
  }
2176
2312
 
2313
+ /**
2314
+ * Tree-attachment helpers: setRoot / setParent + child-iteration recursion.
2315
+ * Hot path: every new Schema/Collection instance attached to the root
2316
+ * goes through here, which is why the recursive walk uses a hoisted
2317
+ * callback + ctx-pool instead of per-call closures.
2318
+ */
2177
2319
  function setRoot(tree, root) {
2178
2320
  tree.root = root;
2179
2321
  const isNewChangeTree = root.add(tree);
@@ -2226,30 +2368,10 @@ function setParent(tree, parent, root, parentIndex) {
2226
2368
  }
2227
2369
  }
2228
2370
  function forEachChild(tree, callback) {
2229
- //
2230
- // assign same parent on child structures
2231
- //
2232
- if (tree.ref[$childType]) {
2233
- if (typeof (tree.ref[$childType]) !== "string") {
2234
- // MapSchema / ArraySchema, etc.
2235
- for (const [key, value] of tree.ref.entries()) {
2236
- if (!value) {
2237
- continue;
2238
- } // sparse arrays can have undefined values
2239
- callback(value[$changes], tree.ref._collectionIndexes?.[key] ?? key);
2240
- }
2241
- }
2242
- }
2243
- else {
2244
- const names = tree.encDescriptor.names;
2245
- for (const index of tree.metadata?.[$refTypeFieldIndexes] ?? []) {
2246
- const value = tree.ref[names[index]];
2247
- if (!value) {
2248
- continue;
2249
- }
2250
- callback(value[$changes], index);
2251
- }
2252
- }
2371
+ forEachChildWithCtx(tree, callback, _forEachChildTrampoline);
2372
+ }
2373
+ function _forEachChildTrampoline(cb, change, at) {
2374
+ cb(change, at);
2253
2375
  }
2254
2376
  /**
2255
2377
  * Closure-free variant of {@link forEachChild}. Hot setRoot / setParent
@@ -2268,12 +2390,35 @@ function forEachChildWithCtx(tree, ctx, callback) {
2268
2390
  const ref = tree.refTarget;
2269
2391
  if (ref[$childType]) {
2270
2392
  if (typeof ref[$childType] !== "string") {
2271
- const collectionIndexes = ref._collectionIndexes;
2272
- for (const [key, value] of ref.entries()) {
2273
- if (!value) {
2274
- continue;
2393
+ const items = ref.items;
2394
+ if (items !== undefined) {
2395
+ // ArraySchema (raw target): dense index loop — the previous
2396
+ // `for..of entries()` allocated an iterator + a [key, value]
2397
+ // pair array per child (top-10 allocation site in the
2398
+ // stateview and deep-nested heap profiles).
2399
+ for (let i = 0, len = items.length; i < len; i++) {
2400
+ const value = items[i];
2401
+ if (!value) {
2402
+ continue;
2403
+ } // sparse arrays can have undefined values
2404
+ callback(ctx, value[$changes], i);
2405
+ }
2406
+ }
2407
+ else {
2408
+ // Map-backed collections (MapSchema/SetSchema/CollectionSchema/
2409
+ // StreamSchema all store `$items: Map`): keys() loop skips the
2410
+ // per-child [key, value] pair arrays of entries(), with no
2411
+ // closure either (a forEach closure showed up as a GC
2412
+ // regression on the construct bench).
2413
+ const $items = ref.$items;
2414
+ const collectionIndexes = ref._collectionIndexes;
2415
+ for (const key of $items.keys()) {
2416
+ const value = $items.get(key);
2417
+ if (!value) {
2418
+ continue;
2419
+ }
2420
+ callback(ctx, value[$changes], collectionIndexes?.[key] ?? key);
2275
2421
  }
2276
- callback(ctx, value[$changes], collectionIndexes?.[key] ?? key);
2277
2422
  }
2278
2423
  }
2279
2424
  }
@@ -2358,6 +2503,12 @@ const IS_UNRELIABLE = 8, IS_TRANSIENT = 16, IS_STATIC = 32;
2358
2503
  // `t.map(X).stream()` / `t.set(X).stream()` route through the same
2359
2504
  // emission machinery.
2360
2505
  const IS_STREAM_COLLECTION = 64;
2506
+ // Set by `recycle()` (Schema.reset / pooling): the tree's values are live
2507
+ // but its dirty buckets were cleared, so `Root.add` must re-stage every
2508
+ // populated field as ADD when the instance re-enters a tree. Without this,
2509
+ // only fields assigned after `pool.acquire()` would reach the wire — the
2510
+ // retained ones (constructor-initialized children) would never be encoded.
2511
+ const NEEDS_RESTAGE = 128;
2361
2512
  /**
2362
2513
  * Flags a child inherits from its parent's own transitive state via
2363
2514
  * `checkInheritedFlags`. Read as a bitwise mask so the inheritance step
@@ -2470,6 +2621,8 @@ class ChangeTree {
2470
2621
  set isStatic(v) { this.flags = v ? (this.flags | IS_STATIC) : (this.flags & ~IS_STATIC); }
2471
2622
  get isStreamCollection() { return (this.flags & IS_STREAM_COLLECTION) !== 0; }
2472
2623
  set isStreamCollection(v) { this.flags = v ? (this.flags | IS_STREAM_COLLECTION) : (this.flags & ~IS_STREAM_COLLECTION); }
2624
+ get needsRestage() { return (this.flags & NEEDS_RESTAGE) !== 0; }
2625
+ set needsRestage(v) { this.flags = v ? (this.flags | NEEDS_RESTAGE) : (this.flags & ~NEEDS_RESTAGE); }
2473
2626
  // True iff tree inherits `isFiltered` OR its Schema class declares any
2474
2627
  // @view-tagged fields. StateView.addParentOf uses this to decide whether
2475
2628
  // a parent must be included in a view's bootstrap. Reads the class-level
@@ -2753,6 +2906,48 @@ class ChangeTree {
2753
2906
  if (this.collPureOps !== undefined)
2754
2907
  this.collPureOps.length = 0;
2755
2908
  }
2909
+ /**
2910
+ * Full reset to construction defaults so the owning ref can be returned to
2911
+ * a pool and reused for a different logical entity (see encoder/Pool.ts +
2912
+ * Schema.reset). Unlike `reset()` / `endEncode()` (which only clear the
2913
+ * dirty bucket for the next encode), this also drops parent links, queue
2914
+ * nodes and per-view bitmaps, and re-arms IS_NEW.
2915
+ *
2916
+ * Precondition: the tree must already be detached from the encoder
2917
+ * (`root === undefined`) — i.e. the ref was removed from its parent
2918
+ * collection/field, which `Root.remove` does before this runs.
2919
+ */
2920
+ recycle() {
2921
+ if (this.root !== undefined) {
2922
+ throw new Error(`@colyseus/schema: cannot recycle an attached ChangeTree ` +
2923
+ `(${this.ref?.constructor?.name}). Remove the instance from its ` +
2924
+ `parent collection before releasing it to a pool.`);
2925
+ }
2926
+ // dirty/ops buckets (Schema: dirtyLow/High + ops; Collection: collDirty/collPureOps)
2927
+ this.reset();
2928
+ // keep the recorder object allocated (re-alloc is the cost we avoid), clear contents
2929
+ this.unreliableRecorder?.reset();
2930
+ // back to a freshly-constructed tree: IS_NEW, no inherited flags
2931
+ // (FILTERED/TRANSIENT/STATIC/STREAM are re-derived on the next setParent).
2932
+ // NEEDS_RESTAGE makes the next Root.add re-stage retained field values.
2933
+ this.flags = IS_NEW | NEEDS_RESTAGE;
2934
+ this._fullSyncGen = 0;
2935
+ // drop parent links — Root.remove clears `root` and the CHILDREN's
2936
+ // parent links, but leaves this tree's own parentRef dangling.
2937
+ this.parentRef = undefined;
2938
+ this._parentIndex = undefined;
2939
+ this.extraParents = undefined;
2940
+ // queue nodes (already nulled by Root.remove's queue removal; defensive)
2941
+ this.changesNode = undefined;
2942
+ this.unreliableChangesNode = undefined;
2943
+ this.paused = false;
2944
+ // per-view visibility lives on the tree (NOT keyed by refId), so a
2945
+ // recycled tree must not inherit its previous life's view membership.
2946
+ this.visibleViews = undefined;
2947
+ this.invisibleViews = undefined;
2948
+ this.tagViews = undefined;
2949
+ this.subscribedViews = undefined;
2950
+ }
2756
2951
  shift(shiftIndex) {
2757
2952
  if (this._isSchema)
2758
2953
  throw new Error("ChangeTree (Schema): shift is not supported");
@@ -3208,6 +3403,165 @@ const encodeArray = function (encoder, bytes, changeTree, field, operation, it,
3208
3403
  encodeValue(encoder, bytes, type, value, operation, it);
3209
3404
  };
3210
3405
 
3406
+ /**
3407
+ * Resync ("full-snapshot reconciliation") support for {@link Decoder.decodeResync}.
3408
+ *
3409
+ * A rejoin snapshot is authoritative for everything it contains — but the
3410
+ * plain decode path is additive: entries deleted (or hidden by a view)
3411
+ * while the client was off the wire survive as ghosts. This module owns the
3412
+ * generic reconciliation algorithm:
3413
+ *
3414
+ * - during the decode walk, the collection DecodeOperation functions report
3415
+ * every entry the payload touches ({@link resyncRecordVisit}) and every
3416
+ * collection that appears at all ({@link resyncMarkPresent});
3417
+ * - after the walk, {@link resyncSweep} removes whatever was never reported,
3418
+ * through the same DELETE bookkeeping the regular decode path uses
3419
+ * (DataChange DELETE → onRemove; removeRef → GC).
3420
+ *
3421
+ * Storage-specific pruning (journal upkeep, array compaction, stream
3422
+ * exemption) lives on each collection class as `[$resyncPrune]` — declared
3423
+ * on the `Collection` interface, so every collection kind must state its
3424
+ * own sweep semantics.
3425
+ *
3426
+ * All entry points are guarded by `decoder.resyncVisited !== null` at the
3427
+ * call sites — the normal decode path never pays for any of this.
3428
+ */
3429
+ /**
3430
+ * Record that the current structure's entry at `identity` (map string key /
3431
+ * element index) appeared in the payload — even when its value is unchanged
3432
+ * (`allChanges` cannot serve as this record: its pushes are guarded by
3433
+ * `previousValue !== value`, so unchanged entries would look unvisited).
3434
+ *
3435
+ * Also releases a replaced occupant: full-sync emits plain ADD (never
3436
+ * DELETE_AND_ADD), so an entry whose instance changed while this client was
3437
+ * off the wire would otherwise leak its previous ref (no onRemove, never
3438
+ * GC'd). This release is correct ONLY under a full snapshot — a live patch's
3439
+ * plain ADD over a different instance can be a positional rewrite (array
3440
+ * shift/unshift) where the occupant *moved* and is still alive; a snapshot
3441
+ * re-adds moved instances elsewhere, so the refcounts balance.
3442
+ */
3443
+ function resyncTouchEntry(decoder, ref, operation, identity, previousValue, value, allChanges) {
3444
+ const visited = decoder.resyncVisited;
3445
+ let set = visited.get(decoder.currentRefId);
3446
+ if (set === undefined) {
3447
+ visited.set(decoder.currentRefId, set = new Set());
3448
+ }
3449
+ set.add(identity);
3450
+ if (previousValue !== undefined && operation === OPERATION.ADD && previousValue !== value) {
3451
+ const previousRefId = previousValue[$refId];
3452
+ if (previousRefId !== undefined) {
3453
+ decoder.root.removeRef(previousRefId);
3454
+ allChanges?.push({
3455
+ ref,
3456
+ refId: decoder.currentRefId,
3457
+ op: OPERATION.DELETE,
3458
+ dynamicIndex: identity,
3459
+ value: undefined,
3460
+ previousValue,
3461
+ });
3462
+ }
3463
+ }
3464
+ }
3465
+ /**
3466
+ * Mark a collection as present in the payload — even with zero entries.
3467
+ * The sweep only prunes collections reported here: absence means "not part
3468
+ * of full-sync" (@transient, view-invisible), where pruning would destroy
3469
+ * live data. Reflected clients have no @transient metadata, so payload
3470
+ * presence is the only reliable signal.
3471
+ */
3472
+ function resyncMarkPresent(decoder, refId) {
3473
+ const visited = decoder.resyncVisited;
3474
+ if (!visited.has(refId)) {
3475
+ visited.set(refId, new Set());
3476
+ }
3477
+ }
3478
+ /**
3479
+ * Post-decode phase of {@link Decoder.decodeResync}: remove every collection
3480
+ * entry the snapshot did not visit.
3481
+ *
3482
+ * Walks the tree from the root — NOT `root.refs` — for three reasons:
3483
+ * `@transient` fields are never part of a snapshot and must be left alone;
3484
+ * entries of subtrees removed by the sweep itself are left to the GC's
3485
+ * transitive walk (sweeping them directly would double-decrement shared
3486
+ * children); and collections the snapshot never mentions (emptied
3487
+ * server-side) are still reachable and get pruned.
3488
+ */
3489
+ function resyncSweep(decoder, allChanges) {
3490
+ if (decoder.resyncDamaged) {
3491
+ console.warn("@colyseus/schema: resync sweep skipped — parts of the payload could not be decoded. " +
3492
+ "Stale entries may persist until the next resync.");
3493
+ return;
3494
+ }
3495
+ sweepSchema(decoder, decoder.state, new Set(), allChanges);
3496
+ }
3497
+ function sweepSchema(decoder, ref, seen, allChanges) {
3498
+ const refId = ref[$refId];
3499
+ if (refId === undefined || seen.has(refId)) {
3500
+ return;
3501
+ }
3502
+ seen.add(refId);
3503
+ const metadata = ref.constructor[Symbol.metadata];
3504
+ const refIndexes = metadata?.[$refTypeFieldIndexes];
3505
+ if (refIndexes === undefined) {
3506
+ return;
3507
+ }
3508
+ const transient = metadata[$transientFieldIndexes];
3509
+ for (let i = 0; i < refIndexes.length; i++) {
3510
+ const fieldIndex = refIndexes[i];
3511
+ // @transient fields are never in a snapshot — leave them alone.
3512
+ if (transient !== undefined && transient.includes(fieldIndex)) {
3513
+ continue;
3514
+ }
3515
+ const field = metadata[fieldIndex];
3516
+ const value = ref[field.name];
3517
+ if (!value) {
3518
+ continue;
3519
+ }
3520
+ if (Schema.is(field.type)) {
3521
+ sweepSchema(decoder, value, seen, allChanges);
3522
+ }
3523
+ else {
3524
+ sweepCollection(decoder, value, seen, allChanges);
3525
+ }
3526
+ }
3527
+ }
3528
+ function sweepCollection(decoder, coll, seen, allChanges) {
3529
+ const tgt = coll[$proxyTarget] ?? coll;
3530
+ const refId = tgt[$refId];
3531
+ if (refId === undefined || seen.has(refId)) {
3532
+ return;
3533
+ }
3534
+ seen.add(refId);
3535
+ // `undefined` = the collection never appeared in the payload at all
3536
+ // (not even as its parent's field op) — it is not part of full-sync
3537
+ // (@transient, view-invisible) and must be left alone. An empty Set
3538
+ // means "present with zero entries" → prune everything.
3539
+ const visited = decoder.resyncVisited.get(refId);
3540
+ if (visited === undefined) {
3541
+ return;
3542
+ }
3543
+ const $root = decoder.root;
3544
+ tgt[$resyncPrune](visited, (value, identity) => {
3545
+ allChanges?.push({
3546
+ ref: coll,
3547
+ refId,
3548
+ op: OPERATION.DELETE,
3549
+ dynamicIndex: identity,
3550
+ value: undefined,
3551
+ previousValue: value,
3552
+ });
3553
+ const childRefId = value?.[$refId];
3554
+ if (childRefId !== undefined) {
3555
+ $root.removeRef(childRefId);
3556
+ }
3557
+ }, (value) => {
3558
+ // recurse so nested collections of retained entries sweep too
3559
+ if (Schema.isSchema(value)) {
3560
+ sweepSchema(decoder, value, seen, allChanges);
3561
+ }
3562
+ });
3563
+ }
3564
+
3211
3565
  const DEFINITION_MISMATCH = -1;
3212
3566
  /**
3213
3567
  * Collection-kind discriminator declared on each collection class as
@@ -3265,6 +3619,12 @@ function decodeValue(decoder, operation, ref, index, previousValue, type, bytes,
3265
3619
  //
3266
3620
  value = decode[type](bytes, it);
3267
3621
  }
3622
+ else if (isQuantizedType(type)) {
3623
+ // Quantized scalar: read the unsigned-int wire value, then dequantize so
3624
+ // the instance holds (and yields) the wire-exact float — `decodeSchema-
3625
+ // Operation` writes it through the snapping setter (idempotent here).
3626
+ value = decodeQuantized(type.quantized, bytes, it);
3627
+ }
3268
3628
  else if (Schema.is(type)) {
3269
3629
  const refId = decode.number(bytes, it);
3270
3630
  value = $root.refs.get(refId);
@@ -3281,6 +3641,10 @@ function decodeValue(decoder, operation, ref, index, previousValue, type, bytes,
3281
3641
  else {
3282
3642
  const typeDef = getType(Object.keys(type)[0]);
3283
3643
  const refId = decode.number(bytes, it);
3644
+ // resync bookkeeping — see Resync.ts
3645
+ if (decoder.resyncVisited !== null) {
3646
+ resyncMarkPresent(decoder, refId);
3647
+ }
3284
3648
  // `initializeForDecoder` is a static on every registered collection
3285
3649
  // class — it does `Object.create(Class.prototype)` + the class-
3286
3650
  // field init + assigns an untracked `$changes` directly. Keeps
@@ -3293,17 +3657,25 @@ function decodeValue(decoder, operation, ref, index, previousValue, type, bytes,
3293
3657
  if (previousValue) {
3294
3658
  let previousRefId = previousValue[$refId];
3295
3659
  if (previousRefId !== undefined && refId !== previousRefId) {
3660
+ // Collection field replaced by a different instance.
3296
3661
  //
3297
- // enqueue onRemove if structure has been replaced.
3298
- //
3662
+ // Don't decrement children here: GC (`garbageCollectDeletedRefs`)
3663
+ // removes them once the previous collection's refId hits zero.
3664
+ // Doing it here too would double-decrement a *shared* child and
3665
+ // drop it while still referenced ("refId not found").
3666
+ if ((operation & OPERATION.DELETE) !== OPERATION.DELETE) {
3667
+ // Replacement not tagged DELETE (e.g. pending ADD not upgraded
3668
+ // to DELETE_AND_ADD), so the previous refId wasn't decremented
3669
+ // above. Release it here, else it never gets GC'd (leak).
3670
+ $root.removeRef(previousRefId);
3671
+ }
3672
+ // enqueue onRemove callbacks for the previous collection's children.
3299
3673
  const entries = previousValue.entries();
3300
3674
  let iter;
3301
3675
  while ((iter = entries.next()) && !iter.done) {
3302
3676
  const [key, value] = iter.value;
3303
- // if value is a schema, remove its reference
3304
3677
  if (typeof (value) === "object") {
3305
3678
  previousRefId = value[$refId];
3306
- $root.removeRef(previousRefId);
3307
3679
  }
3308
3680
  allChanges?.push({
3309
3681
  ref: previousValue,
@@ -3395,6 +3767,10 @@ const decodeKeyValueOperation = function (decoder, bytes, it, ref, allChanges) {
3395
3767
  }
3396
3768
  const previousValue = tgt[$getByIndex](index);
3397
3769
  const value = decodeValue(decoder, operation, ref, index, previousValue, type, bytes, it, allChanges);
3770
+ // resync bookkeeping — see Resync.ts
3771
+ if (decoder.resyncVisited !== null) {
3772
+ resyncTouchEntry(decoder, ref, operation, dynamicIndex, previousValue, value, allChanges);
3773
+ }
3398
3774
  if (value !== null && value !== undefined) {
3399
3775
  switch (kind) {
3400
3776
  case CollectionKind.Map:
@@ -3471,6 +3847,16 @@ const decodeArray = function (decoder, bytes, it, ref, allChanges) {
3471
3847
  // TODO: refactor here, try to follow same flow as below
3472
3848
  const refId = decode.number(bytes, it);
3473
3849
  const previousValue = decoder.root.refs.get(refId);
3850
+ // Decrement the removed child's ref-count so it can be garbage
3851
+ // collected — this refId-based branch returns early and never reaches
3852
+ // decodeValue(), so it must do the same DELETE bookkeeping itself.
3853
+ // Without this the refId leaks; a later encoder reuse then aliases a
3854
+ // different type → "field not defined" / "definition mismatch"
3855
+ // (surfaces under StateView when a filtered ArraySchema element is
3856
+ // spliced while its parent moves through view membership churn).
3857
+ if (previousValue !== undefined) {
3858
+ decoder.root.removeRef(refId);
3859
+ }
3474
3860
  index = tgt.findIndex((value) => value === previousValue);
3475
3861
  tgt[$deleteByIndex](index);
3476
3862
  allChanges?.push({
@@ -3505,6 +3891,12 @@ const decodeArray = function (decoder, bytes, it, ref, allChanges) {
3505
3891
  // maintain). The decoder's authoritative state is `items`.
3506
3892
  const previousValue = tgt.items[index];
3507
3893
  const value = decodeValue(decoder, operation, ref, index, previousValue, type, bytes, it, allChanges);
3894
+ // resync bookkeeping — see Resync.ts. `index` is the RESOLVED position
3895
+ // (ADD_BY_REFID lands on the instance's current client-side index), so
3896
+ // visited entries form a sparse set.
3897
+ if (decoder.resyncVisited !== null) {
3898
+ resyncTouchEntry(decoder, ref, operation, index, previousValue, value, allChanges);
3899
+ }
3508
3900
  if (value !== null && value !== undefined &&
3509
3901
  value !== previousValue // avoid setting same value twice (if index === 0 it will result in a "unshift" for ArraySchema)
3510
3902
  ) {
@@ -3630,6 +4022,8 @@ class ArraySchema {
3630
4022
  tmpItems = [];
3631
4023
  deletedIndexes = [];
3632
4024
  isMovingItems = false;
4025
+ /** Decode-side: `items` has holes (delete or gap-write) — `$onDecodeEnd` must compact. */
4026
+ _needsCompaction = false;
3633
4027
  static [$encoder] = encodeArray;
3634
4028
  static [$decoder] = decodeArray;
3635
4029
  /** Integer tag read by `decodeKeyValueOperation` — see `CollectionKind`. */
@@ -3644,9 +4038,11 @@ class ArraySchema {
3644
4038
  * - Then, the encoder iterates over all "owned" properties per instance and encodes them.
3645
4039
  */
3646
4040
  static [$filter](ref, index, view) {
3647
- return (!view ||
3648
- typeof (ref[$childType]) === "string" ||
3649
- view.isChangeTreeVisible(ref['tmpItems'][index]?.[$changes]));
4041
+ if (!view)
4042
+ return true; // must stay first — encodeAll hits this per element
4043
+ const self = ref[$proxyTarget] ?? ref; // ref arrives proxied — skip traps below
4044
+ return (typeof (self[$childType]) === "string" ||
4045
+ view.isChangeTreeVisible(self['tmpItems'][index]?.[$changes]));
3650
4046
  }
3651
4047
  static is(type) {
3652
4048
  return (
@@ -3690,6 +4086,7 @@ class ArraySchema {
3690
4086
  // staged-snapshot path in `$getByIndex`, `$onEncodeEnd`, etc.). The
3691
4087
  // decoder reads from `items` directly and never maintains them.
3692
4088
  self.isMovingItems = false;
4089
+ self._needsCompaction = false;
3693
4090
  self[$childType] = undefined;
3694
4091
  self[$proxyTarget] = self;
3695
4092
  const proxy = new Proxy(self, ARRAY_PROXY_HANDLER);
@@ -3817,6 +4214,9 @@ class ArraySchema {
3817
4214
  this.items[index] = value;
3818
4215
  }
3819
4216
  else {
4217
+ if (index > this.items.length) {
4218
+ this._needsCompaction = true; // gap-write (filtered/out-of-order ADD) leaves holes
4219
+ }
3820
4220
  this.items[index] = value;
3821
4221
  }
3822
4222
  }
@@ -3837,6 +4237,27 @@ class ArraySchema {
3837
4237
  self.items.length = 0;
3838
4238
  self.tmpItems.length = 0;
3839
4239
  }
4240
+ /**
4241
+ * Pool reset: empty this array and recycle its ChangeTree WITHOUT recording
4242
+ * any wire op (the parent field's ADD/DELETE owns the wire). Recurses into
4243
+ * ref-type children. Called by Schema.reset when a pooled entity has an
4244
+ * array field. The instance must already be detached from the encoder.
4245
+ */
4246
+ [$reset]() {
4247
+ const self = this[$proxyTarget] ?? this;
4248
+ const changeTree = self[$changes];
4249
+ if (changeTree.isStreamCollection) {
4250
+ throw new Error(`@colyseus/schema: cannot reset a streamed ArraySchema (pooling not supported).`);
4251
+ }
4252
+ const items = self.items;
4253
+ for (let i = 0; i < items.length; i++)
4254
+ items[i]?.[$reset]?.();
4255
+ self.items.length = 0;
4256
+ self.tmpItems.length = 0;
4257
+ self.deletedIndexes.length = 0;
4258
+ changeTree.recycle();
4259
+ self[$refId] = undefined; // assign (not delete) to avoid V8 dictionary-mode deopt
4260
+ }
3840
4261
  /**
3841
4262
  * Combines two or more arrays.
3842
4263
  * @param items Additional items to add to the end of array1.
@@ -4236,21 +4657,51 @@ class ArraySchema {
4236
4657
  * `$deleteByIndex` below.
4237
4658
  */
4238
4659
  [$getByIndex](index, isEncodeAll = false) {
4660
+ const self = this[$proxyTarget] ?? this; // called via Proxy — one trap here beats one per field read
4239
4661
  return (isEncodeAll)
4240
- ? this.items[index]
4241
- : this.deletedIndexes[index]
4242
- ? this.items[index]
4243
- : this.tmpItems[index] || this.items[index];
4662
+ ? self.items[index]
4663
+ : self.deletedIndexes[index]
4664
+ ? self.items[index]
4665
+ : self.tmpItems[index] || self.items[index];
4244
4666
  }
4245
4667
  [$deleteByIndex](index) {
4246
- this.items[index] = undefined;
4668
+ const self = this[$proxyTarget] ?? this;
4669
+ self.items[index] = undefined;
4670
+ self._needsCompaction = true;
4247
4671
  }
4248
4672
  [$onEncodeEnd]() {
4249
- this.tmpItems = this.items.slice();
4250
- this.deletedIndexes.length = 0;
4673
+ const self = this[$proxyTarget] ?? this;
4674
+ self.tmpItems = self.items.slice();
4675
+ self.deletedIndexes.length = 0;
4251
4676
  }
4252
4677
  [$onDecodeEnd]() {
4253
- this.items = this.items.filter((item) => item !== undefined);
4678
+ const self = this[$proxyTarget] ?? this;
4679
+ if (self._needsCompaction) {
4680
+ self._needsCompaction = false;
4681
+ self.items = self.items.filter((item) => item !== undefined);
4682
+ }
4683
+ }
4684
+ [$resyncPrune](visited, prune, keep) {
4685
+ // `items` is hole-free here: the decode loop's $onDecodeEnd already
4686
+ // ran, and a full-sync emits dense ADDs (no DELETEs, no gap-writes)
4687
+ // so no compaction happened mid-decode. Visited indexes may still be
4688
+ // sparse (ADD_BY_REFID resolves to the current client-side index).
4689
+ const self = this[$proxyTarget] ?? this;
4690
+ const items = self.items;
4691
+ let removed = false;
4692
+ for (let i = 0; i < items.length; i++) {
4693
+ const value = items[i];
4694
+ if (visited.has(i)) {
4695
+ keep(value);
4696
+ continue;
4697
+ }
4698
+ removed = true;
4699
+ prune(value, i);
4700
+ self[$deleteByIndex](i);
4701
+ }
4702
+ if (removed) {
4703
+ self[$onDecodeEnd]();
4704
+ } // compact the holes
4254
4705
  }
4255
4706
  toArray() {
4256
4707
  return this.items.slice(0);
@@ -4617,6 +5068,24 @@ class MapSchema {
4617
5068
  this.$items.clear();
4618
5069
  changeTree.operation(OPERATION.CLEAR);
4619
5070
  }
5071
+ /**
5072
+ * Pool reset: empty this map and recycle its ChangeTree WITHOUT recording
5073
+ * any wire op (the parent field's ADD/DELETE owns the wire). Recurses into
5074
+ * ref-type children. Called by Schema.reset when a pooled entity has a
5075
+ * map field. The instance must already be detached from the encoder.
5076
+ */
5077
+ [$reset]() {
5078
+ const changeTree = this[$changes];
5079
+ if (changeTree.isStreamCollection) {
5080
+ throw new Error(`@colyseus/schema: cannot reset a streamed MapSchema (pooling not supported).`);
5081
+ }
5082
+ // reset ref-type children first (primitives optional-chain away)
5083
+ this.$items.forEach((value) => value?.[$reset]?.());
5084
+ this.$items.clear();
5085
+ this.journal.reset();
5086
+ changeTree.recycle();
5087
+ this[$refId] = undefined; // assign (not delete) to avoid V8 dictionary-mode deopt
5088
+ }
4620
5089
  has(key) {
4621
5090
  return this.$items.has(key);
4622
5091
  }
@@ -4657,6 +5126,36 @@ class MapSchema {
4657
5126
  this.journal.keyByIndex.delete(index);
4658
5127
  }
4659
5128
  }
5129
+ [$resyncPrune](visited, prune, keep) {
5130
+ // maps prune by string key, NOT wire index — the decoder-side
5131
+ // journal never evicts stale index→key mappings on re-indexing.
5132
+ let deletedKeys = null;
5133
+ this.$items.forEach((value, key) => {
5134
+ if (visited.has(key)) {
5135
+ keep(value);
5136
+ return;
5137
+ }
5138
+ (deletedKeys ??= new Set()).add(key);
5139
+ prune(value, key);
5140
+ });
5141
+ if (deletedKeys !== null) {
5142
+ deletedKeys.forEach((key) => {
5143
+ this.$items.delete(key);
5144
+ delete this.journal.indexByKey[key];
5145
+ });
5146
+ // drop index→key mappings of swept keys — including stale ones
5147
+ // left behind by re-indexing.
5148
+ const staleIndexes = [];
5149
+ this.journal.keyByIndex.forEach((key, index) => {
5150
+ if (deletedKeys.has(key)) {
5151
+ staleIndexes.push(index);
5152
+ }
5153
+ });
5154
+ for (let i = 0; i < staleIndexes.length; i++) {
5155
+ this.journal.keyByIndex.delete(staleIndexes[i]);
5156
+ }
5157
+ }
5158
+ }
4660
5159
  [$onEncodeEnd]() {
4661
5160
  this.journal.cleanupAfterEncode();
4662
5161
  }
@@ -4849,6 +5348,24 @@ class CollectionSchema {
4849
5348
  this.$items.clear();
4850
5349
  changeTree.operation(OPERATION.CLEAR);
4851
5350
  }
5351
+ /**
5352
+ * Pool reset: empty this collection and recycle its ChangeTree WITHOUT
5353
+ * recording any wire op (the parent field's ADD/DELETE owns the wire).
5354
+ * Recurses into ref-type children. Called by Schema.reset when a pooled
5355
+ * entity has a collection field. Must already be detached from the encoder.
5356
+ */
5357
+ [$reset]() {
5358
+ const changeTree = this[$changes];
5359
+ if (changeTree.isStreamCollection) {
5360
+ throw new Error(`@colyseus/schema: cannot reset a streamed CollectionSchema (pooling not supported).`);
5361
+ }
5362
+ this.$items.forEach((value) => value?.[$reset]?.());
5363
+ this.$items.clear();
5364
+ this.deletedItems = {};
5365
+ this.$refId = 0; // reset the monotonic index counter (field, not the symbol)
5366
+ changeTree.recycle();
5367
+ this[$refId] = undefined; // drop encoder ref identity by assign (not delete: avoids dict-mode deopt)
5368
+ }
4852
5369
  has(value) {
4853
5370
  return Array.from(this.$items.values()).some((v) => v === value);
4854
5371
  }
@@ -4887,6 +5404,22 @@ class CollectionSchema {
4887
5404
  [$deleteByIndex](index) {
4888
5405
  this.$items.delete(index);
4889
5406
  }
5407
+ [$resyncPrune](visited, prune, keep) {
5408
+ let toDelete = null;
5409
+ this.$items.forEach((value, index) => {
5410
+ if (visited.has(index)) {
5411
+ keep(value);
5412
+ return;
5413
+ }
5414
+ (toDelete ??= []).push(index);
5415
+ prune(value, index);
5416
+ });
5417
+ if (toDelete !== null) {
5418
+ for (let i = 0; i < toDelete.length; i++) {
5419
+ this[$deleteByIndex](toDelete[i]);
5420
+ }
5421
+ }
5422
+ }
4890
5423
  [$onEncodeEnd]() {
4891
5424
  for (const key in this.deletedItems) {
4892
5425
  delete this.deletedItems[key];
@@ -5088,6 +5621,24 @@ class SetSchema {
5088
5621
  this.$items.clear();
5089
5622
  changeTree.operation(OPERATION.CLEAR);
5090
5623
  }
5624
+ /**
5625
+ * Pool reset: empty this set and recycle its ChangeTree WITHOUT recording
5626
+ * any wire op (the parent field's ADD/DELETE owns the wire). Recurses into
5627
+ * ref-type children. Called by Schema.reset when a pooled entity has a set
5628
+ * field. The instance must already be detached from the encoder.
5629
+ */
5630
+ [$reset]() {
5631
+ const changeTree = this[$changes];
5632
+ if (changeTree.isStreamCollection) {
5633
+ throw new Error(`@colyseus/schema: cannot reset a streamed SetSchema (pooling not supported).`);
5634
+ }
5635
+ this.$items.forEach((value) => value?.[$reset]?.());
5636
+ this.$items.clear();
5637
+ this.deletedItems = {};
5638
+ this.$refId = 0; // reset the monotonic index counter (field, not the symbol)
5639
+ changeTree.recycle();
5640
+ this[$refId] = undefined; // drop encoder ref identity by assign (not delete: avoids dict-mode deopt)
5641
+ }
5091
5642
  has(value) {
5092
5643
  const values = this.$items.values();
5093
5644
  let has = false;
@@ -5138,6 +5689,22 @@ class SetSchema {
5138
5689
  [$deleteByIndex](index) {
5139
5690
  this.$items.delete(index);
5140
5691
  }
5692
+ [$resyncPrune](visited, prune, keep) {
5693
+ let toDelete = null;
5694
+ this.$items.forEach((value, index) => {
5695
+ if (visited.has(index)) {
5696
+ keep(value);
5697
+ return;
5698
+ }
5699
+ (toDelete ??= []).push(index);
5700
+ prune(value, index);
5701
+ });
5702
+ if (toDelete !== null) {
5703
+ for (let i = 0; i < toDelete.length; i++) {
5704
+ this[$deleteByIndex](toDelete[i]);
5705
+ }
5706
+ }
5707
+ }
5141
5708
  [$onEncodeEnd]() {
5142
5709
  for (const key in this.deletedItems) {
5143
5710
  delete this.deletedItems[key];
@@ -5399,6 +5966,11 @@ class StreamSchema {
5399
5966
  this.$items.delete(index);
5400
5967
  }
5401
5968
  }
5969
+ [$resyncPrune]() {
5970
+ // Stream contents are delivered by the trickle/priority pass, NOT
5971
+ // by full-sync (encodeAll carries none of them) — a snapshot is not
5972
+ // authoritative for streams, so absence ≠ deleted. Never prune.
5973
+ }
5402
5974
  [$onEncodeEnd]() {
5403
5975
  // No per-tick cleanup: pending/sent state spans encode ticks by design.
5404
5976
  }
@@ -5479,7 +6051,22 @@ class FieldBuilder {
5479
6051
  constructor(type) {
5480
6052
  this._type = type;
5481
6053
  }
5482
- /** Provide a default value for this field. */
6054
+ /**
6055
+ * Provide a default value for this field.
6056
+ *
6057
+ * Pass a **factory function** `() => T` to build a FRESH value per instance
6058
+ * (invoked once per construction) instead of sharing a single default — the
6059
+ * clean way to default a ref to a plain custom class, or any field that must
6060
+ * not share a mutable default across instances:
6061
+ *
6062
+ * ```ts
6063
+ * acc: t.ref(GunAccuracy).noSync().default(() => new GunAccuracy()),
6064
+ * ```
6065
+ *
6066
+ * Schema fields are never function-typed, so a function is always treated as a
6067
+ * factory. A non-function value is shared (and cloned per instance if it is
6068
+ * clone-able, e.g. a Schema/collection).
6069
+ */
5483
6070
  default(value) {
5484
6071
  this._default = value;
5485
6072
  this._hasDefault = true;
@@ -5650,6 +6237,20 @@ const streamFactory = ((child) => {
5650
6237
  return b;
5651
6238
  });
5652
6239
  const refFactory = ((ctor) => new FieldBuilder(ctor));
6240
+ /**
6241
+ * A bounded float carried on the wire as a fixed-width unsigned integer. App code
6242
+ * reads/writes the FLOAT; the wire carries the quantized int and the field only
6243
+ * ever yields `dequant(q)`, so client predict and server sim read the same value
6244
+ * (no full-precision path to leak ⇒ no shot misprediction). See
6245
+ * {@link QuantizeOptions} for the precision/wire trade-offs.
6246
+ *
6247
+ * yaw: t.quantized({ min: 0, max: TWO_PI, mode: "wrap" }), // 16-bit
6248
+ * pitch: t.quantized({ min: -PITCH_LIMIT, max: PITCH_LIMIT }), // clamp (default)
6249
+ * throttle: t.quantized({ min: 0, max: 1, bits: 8 }), // 1 byte
6250
+ */
6251
+ function quantizedFactory(opts) {
6252
+ return new FieldBuilder({ quantized: resolveQuantize(opts) });
6253
+ }
5653
6254
  const t = Object.freeze({
5654
6255
  // Primitives
5655
6256
  string: primitive("string"),
@@ -5667,16 +6268,46 @@ const t = Object.freeze({
5667
6268
  float64: primitive("float64"),
5668
6269
  bigint64: primitive("bigint64"),
5669
6270
  biguint64: primitive("biguint64"),
5670
- /** Reference to a Schema subtype. `t.array(Item)` usually reads better, but this is available when a plain ref is needed. */
6271
+ /**
6272
+ * Reference to a Schema subtype — `t.array(Item)` usually reads better, but
6273
+ * this is available when a plain ref is needed.
6274
+ *
6275
+ * The target may also be a **non-Schema custom class**. A synced ref still
6276
+ * requires it to be encodable — a `Schema` subclass, or a class retrofitted
6277
+ * with `Metadata.setFields(...)` (both carry `[Symbol.metadata]`); a bare
6278
+ * custom class is rejected at `schema()` time. A `.noSync()` (local-only)
6279
+ * field accepts ANY zero-arg class and auto-instantiates one per parent.
6280
+ */
5671
6281
  ref: refFactory,
5672
6282
  array: arrayFactory,
5673
6283
  map: mapFactory,
5674
6284
  set: setFactory,
5675
6285
  collection: collectionFactory,
5676
6286
  stream: streamFactory,
6287
+ /**
6288
+ * A bounded float quantized to a fixed-width unsigned int on the wire — half
6289
+ * (or a quarter) the bytes of a `float32` at a precision you pick. The field
6290
+ * reads/writes the float and only ever yields `dequant(q)`. {@see QuantizeOptions}
6291
+ */
6292
+ quantized: quantizedFactory,
6293
+ /**
6294
+ * Sugar for a full-circle wrapping angle in radians:
6295
+ * `t.quantized({ min: 0, max: 2π, mode: "wrap", bits })` (default 16-bit,
6296
+ * ~0.0055°/step). Any input angle is range-reduced into `[0, 2π)`. Render note:
6297
+ * lerp interpolated remotes shortest-arc (`attach({ angle: true })`) — the
6298
+ * wrap fixes the WIRE seam, not interpolation (see {@link QuantizeOptions.mode}).
6299
+ */
6300
+ angle: (opts) => quantizedFactory({ min: 0, max: Math.PI * 2, mode: "wrap", bits: opts?.bits ?? 16 }),
5677
6301
  });
5678
6302
 
5679
6303
  const DEFAULT_VIEW_TAG = -1;
6304
+ /**
6305
+ * Class decorator that registers a `@type`-style Schema class with the
6306
+ * TypeContext (required for reflection / cross-language codegen).
6307
+ *
6308
+ * @entity
6309
+ * class Player extends Schema { ... }
6310
+ */
5680
6311
  function entity(constructor) {
5681
6312
  TypeContext.register(constructor);
5682
6313
  return constructor;
@@ -5908,8 +6539,8 @@ function type(type, options) {
5908
6539
  if (metadata[$descriptors][field]) {
5909
6540
  Object.defineProperty(target, field, metadata[$descriptors][field]);
5910
6541
  }
5911
- // Pre-compute encoder function for primitive types.
5912
- if (typeof type === "string") {
6542
+ // Pre-compute encoder function for primitive + quantized types.
6543
+ if (typeof type === "string" || isQuantizedType(type)) {
5913
6544
  if (!metadata[$encoders]) {
5914
6545
  Object.defineProperty(metadata, $encoders, {
5915
6546
  value: [],
@@ -5918,7 +6549,9 @@ function type(type, options) {
5918
6549
  writable: true,
5919
6550
  });
5920
6551
  }
5921
- metadata[$encoders][fieldIndex] = encode[type];
6552
+ metadata[$encoders][fieldIndex] = (typeof type === "string")
6553
+ ? encode[type]
6554
+ : makeQuantizedEncoder(type.quantized);
5922
6555
  }
5923
6556
  };
5924
6557
  }
@@ -6029,6 +6662,38 @@ function makeCollectionSetter(_fieldName, fieldIndex, type, complexTypeKlass) {
6029
6662
  values[fieldIndex] = value;
6030
6663
  };
6031
6664
  }
6665
+ /**
6666
+ * Setter for a `t.quantized()` field. SNAPS the assigned float to the wire-exact
6667
+ * value (`dequant(quant(x))`) on write, so the stored value — and every read,
6668
+ * including the reconciler's live step off the staged input — is identical to
6669
+ * what the server decodes off the wire. This is the half that kills the
6670
+ * predict-from-the-wrong-value footgun; the encoder re-quantizes the snapped
6671
+ * value at send (a lossless round-trip). Change tracking keys on the SNAPPED
6672
+ * value, so a sub-step jitter that quantizes to the same integer emits no delta.
6673
+ */
6674
+ function makeQuantizedSetter(fieldName, fieldIndex, desc) {
6675
+ return function (value) {
6676
+ const values = this[$values];
6677
+ const previousValue = values[fieldIndex];
6678
+ if (value !== undefined && value !== null) {
6679
+ if (typeof value !== "number") {
6680
+ throw new EncodeSchemaError(`a 'number' was expected, but '${JSON.stringify(value)}' was provided in ${this.constructor.name}#${fieldName}`);
6681
+ }
6682
+ value = dequantize(desc, quantize(desc, value)); // snap to wire-exact
6683
+ if (value === previousValue)
6684
+ return;
6685
+ this.constructor[$track](this[$changes], fieldIndex, OPERATION.ADD);
6686
+ }
6687
+ else {
6688
+ if (value === previousValue)
6689
+ return; // undefined === undefined
6690
+ if (previousValue !== undefined && previousValue !== null) {
6691
+ this[$changes].delete(fieldIndex);
6692
+ }
6693
+ }
6694
+ values[fieldIndex] = value;
6695
+ };
6696
+ }
6032
6697
  function getPropertyDescriptor(fieldName, fieldIndex, type, complexTypeKlass) {
6033
6698
  let setter;
6034
6699
  if (complexTypeKlass) {
@@ -6037,10 +6702,15 @@ function getPropertyDescriptor(fieldName, fieldIndex, type, complexTypeKlass) {
6037
6702
  else if (typeof type === "string") {
6038
6703
  setter = makePrimitiveSetter(fieldName, fieldIndex, type);
6039
6704
  }
6705
+ else if (isQuantizedType(type)) {
6706
+ setter = makeQuantizedSetter(fieldName, fieldIndex, type.quantized);
6707
+ }
6040
6708
  else {
6041
6709
  setter = makeSchemaRefSetter(fieldName, fieldIndex, type);
6042
6710
  }
6043
6711
  return {
6712
+ // Quantized stores the already-snapped float, so the getter is the plain
6713
+ // $values read — the field yields dequant(q) with no per-read math.
6044
6714
  get: function () { return this[$values][fieldIndex]; },
6045
6715
  set: setter,
6046
6716
  enumerable: true,
@@ -6076,31 +6746,32 @@ function deprecated(throws = true) {
6076
6746
  };
6077
6747
  }
6078
6748
  /**
6079
- * Produce the auto-instantiated construction default for a builder type
6080
- * (empty collection or zero-arg Schema ref), or `undefined` when the type
6081
- * has no auto-default. Shared by synced and `.noSync()` field handling.
6749
+ * Build a per-construction factory for a builder type's auto-instantiated
6750
+ * default (empty collection or zero-arg Schema ref), or `undefined` when the
6751
+ * type has no auto-default. Returning a factory lets each construction `new` a
6752
+ * fresh value directly instead of cloning a shared prototype instance.
6082
6753
  */
6083
- function autoInstantiateDefault(rawType) {
6754
+ function makeAutoDefaultFactory(rawType) {
6084
6755
  if (rawType && typeof rawType === "object") {
6085
6756
  if (rawType.array !== undefined) {
6086
- return new ArraySchema();
6757
+ return () => new ArraySchema();
6087
6758
  }
6088
6759
  if (rawType.map !== undefined) {
6089
- return new MapSchema();
6760
+ return () => new MapSchema();
6090
6761
  }
6091
6762
  if (rawType.set !== undefined) {
6092
- return new SetSchema();
6763
+ return () => new SetSchema();
6093
6764
  }
6094
6765
  if (rawType.collection !== undefined) {
6095
- return new CollectionSchema();
6766
+ return () => new CollectionSchema();
6096
6767
  }
6097
6768
  if (rawType.stream !== undefined) {
6098
- return new StreamSchema();
6769
+ return () => new StreamSchema();
6099
6770
  }
6100
6771
  }
6101
6772
  else if (typeof rawType === "function" && Schema.is(rawType)) {
6102
6773
  if (!rawType.prototype.initialize || rawType.prototype.initialize.length === 0) {
6103
- return new rawType();
6774
+ return () => new rawType();
6104
6775
  }
6105
6776
  }
6106
6777
  return undefined;
@@ -6127,7 +6798,39 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6127
6798
  }
6128
6799
  const fields = {};
6129
6800
  const methods = {};
6801
+ // Two buckets, both keyed by field name and applied at construction:
6802
+ // - `defaultValues`: static values copied as-is (shared reference).
6803
+ // - `defaultFactories`: invoked per construction for a fresh value —
6804
+ // `.default(fn)`, clone-able defaults, and auto-instantiated collections/refs.
6130
6805
  const defaultValues = {};
6806
+ const defaultFactories = {};
6807
+ // Decide once (at definition time) how each `.default(v)` materializes per
6808
+ // construction: a function is a factory; a clone-able value clones fresh;
6809
+ // anything else is a shared static value.
6810
+ const assignDefault = (field, value) => {
6811
+ if (typeof value === "function") {
6812
+ defaultFactories[field] = value;
6813
+ }
6814
+ else if (value && typeof value.clone === "function") {
6815
+ defaultFactories[field] = () => value.clone();
6816
+ }
6817
+ else {
6818
+ defaultValues[field] = value;
6819
+ }
6820
+ };
6821
+ // Seed a field's construction default: explicit `.default(v)`, else the
6822
+ // auto-instantiated empty collection / zero-arg ref (skipped for `.optional()`).
6823
+ const seedDefault = (field, def) => {
6824
+ if (def.hasDefault) {
6825
+ assignDefault(field, def.default);
6826
+ }
6827
+ else if (!def.optional) {
6828
+ const factory = makeAutoDefaultFactory(def.type);
6829
+ if (factory) {
6830
+ defaultFactories[field] = factory;
6831
+ }
6832
+ }
6833
+ };
6131
6834
  const viewTagFields = {};
6132
6835
  const ownedFields = [];
6133
6836
  const unreliableFields = [];
@@ -6151,18 +6854,16 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6151
6854
  `together with a sync-only modifier (.view/.owned/.unreliable/.transient/.static/.stream). ` +
6152
6855
  `A local-only field cannot be synchronized.`);
6153
6856
  }
6154
- if (def.hasDefault) {
6155
- defaultValues[fieldName] = def.default;
6156
- }
6157
- else if (!def.optional) {
6158
- const autoDefault = autoInstantiateDefault(def.type);
6159
- if (autoDefault !== undefined) {
6160
- defaultValues[fieldName] = autoDefault;
6161
- }
6162
- }
6857
+ seedDefault(fieldName, def);
6163
6858
  continue;
6164
6859
  }
6165
- fields[fieldName] = getNormalizedType(def.type);
6860
+ const normalizedType = getNormalizedType(def.type);
6861
+ // A synced ref must be encodable (a Schema, or Metadata.setFields()'d) — reject a bare class.
6862
+ if (typeof normalizedType === "function" && !Schema.is(normalizedType)) {
6863
+ throw new Error(`schema(${name ? `'${name}'` : ""}): field '${fieldName}' is a synced ref to non-Schema ` +
6864
+ `class '${normalizedType.name || "(anonymous)"}' — use .noSync(), or Metadata.setFields().`);
6865
+ }
6866
+ fields[fieldName] = normalizedType;
6166
6867
  if (def.view !== undefined) {
6167
6868
  viewTagFields[fieldName] = def.view;
6168
6869
  }
@@ -6190,24 +6891,14 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6190
6891
  if (def.optional) {
6191
6892
  optionalFields.push(fieldName);
6192
6893
  }
6193
- if (def.hasDefault) {
6194
- defaultValues[fieldName] = def.default;
6195
- }
6196
- else if (!def.optional) {
6197
- // Auto-instantiate collection/Schema defaults when none is provided.
6198
- // `.optional()` opts out — field starts as undefined.
6199
- const autoDefault = autoInstantiateDefault(def.type);
6200
- if (autoDefault !== undefined) {
6201
- defaultValues[fieldName] = autoDefault;
6202
- }
6203
- }
6894
+ seedDefault(fieldName, def);
6204
6895
  }
6205
6896
  else if (typeof value === "function") {
6206
6897
  if (Schema.is(value)) {
6207
6898
  // Convenience: allow a bare Schema subclass (equivalent to `t.ref(Class)`).
6208
6899
  fields[fieldName] = getNormalizedType(value);
6209
6900
  if (!value.prototype.initialize || value.prototype.initialize.length === 0) {
6210
- defaultValues[fieldName] = new value();
6901
+ defaultFactories[fieldName] = () => new value();
6211
6902
  }
6212
6903
  }
6213
6904
  else {
@@ -6219,17 +6910,19 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6219
6910
  `Schema subclass, or method (got ${typeof value}).`);
6220
6911
  }
6221
6912
  }
6222
- const getDefaultValues = () => {
6223
- const defaults = {};
6913
+ // Write construction defaults onto `target` — either the instance directly
6914
+ // (no-args fast path) or a throwaway object that gets merged with props.
6915
+ const applyDefaults = (target) => {
6224
6916
  for (const fieldName in defaultValues) {
6225
- const defaultValue = defaultValues[fieldName];
6226
- if (defaultValue && typeof defaultValue.clone === "function") {
6227
- defaults[fieldName] = defaultValue.clone();
6228
- }
6229
- else {
6230
- defaults[fieldName] = defaultValue;
6231
- }
6917
+ target[fieldName] = defaultValues[fieldName];
6918
+ }
6919
+ for (const fieldName in defaultFactories) {
6920
+ target[fieldName] = defaultFactories[fieldName]();
6232
6921
  }
6922
+ };
6923
+ const getDefaultValues = () => {
6924
+ const defaults = {};
6925
+ applyDefaults(defaults);
6233
6926
  return defaults;
6234
6927
  };
6235
6928
  const getParentProps = (props) => {
@@ -6242,18 +6935,26 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
6242
6935
  }
6243
6936
  return parentProps;
6244
6937
  };
6938
+ const hasInitialize = typeof methods.initialize === "function";
6245
6939
  /** @codegen-ignore */
6246
6940
  const klass = Metadata.setFields(class extends inherits {
6247
6941
  constructor(...args) {
6248
- if (methods.initialize && typeof methods.initialize === "function") {
6249
- super(Object.assign({}, getDefaultValues(), getParentProps(args[0] || {})));
6250
- // Only call initialize() on the exact target class, not parents.
6251
- if (new.target === klass) {
6252
- methods.initialize.apply(this, args);
6253
- }
6942
+ const props = args[0];
6943
+ if (props === undefined) {
6944
+ // No-args: write defaults straight onto the instance skips the
6945
+ // throwaway defaults object + Object.assign + assignProps walk.
6946
+ super();
6947
+ applyDefaults(this);
6254
6948
  }
6255
6949
  else {
6256
- super(Object.assign({}, getDefaultValues(), args[0] || {}));
6950
+ // With props: merge into the fresh defaults object in place (no
6951
+ // extra `{}` target); the super chain runs assignProps once. An
6952
+ // `initialize()` owns the schema fields, so only parent props flow up.
6953
+ super(Object.assign(getDefaultValues(), hasInitialize ? getParentProps(props) : props));
6954
+ }
6955
+ // Only call initialize() on the exact target class, not parents.
6956
+ if (hasInitialize && new.target === klass) {
6957
+ methods.initialize.apply(this, args);
6257
6958
  }
6258
6959
  }
6259
6960
  }, fields);
@@ -6380,6 +7081,60 @@ class Schema {
6380
7081
  inst[$values] = [];
6381
7082
  return inst;
6382
7083
  }
7084
+ /**
7085
+ * Reset a DETACHED instance to construction defaults so it can be returned
7086
+ * to a {@link SchemaPool} and reused, avoiding the cost of `new`. Recurses
7087
+ * into ref-type fields (child Schemas / collections).
7088
+ *
7089
+ * Preconditions (enforced):
7090
+ * - The instance must be tracked (encoder-side), not a decoder mirror.
7091
+ * - The instance must NOT be shared across multiple parents.
7092
+ * - The instance must already be removed from its parent collection/field
7093
+ * (so the encoder detached it: `root === undefined`).
7094
+ *
7095
+ * NOTE: primitive field values are NOT reset to class defaults — re-assign
7096
+ * the fields you care about when you reuse the instance (standard
7097
+ * object-pool discipline).
7098
+ */
7099
+ static reset(instance) {
7100
+ const changeTree = instance?.[$changes];
7101
+ // Only tracked (encoder-side) instances are poolable. Decoder-side
7102
+ // instances carry an UntrackedChangeTree, which has no recycle().
7103
+ if (changeTree === undefined || typeof changeTree.recycle !== "function") {
7104
+ throw new Error(`@colyseus/schema: Schema.reset() requires a tracked (encoder-side) instance.`);
7105
+ }
7106
+ // Instances reachable through more than one parent are unsafe to pool:
7107
+ // another owner may still hold this instance.
7108
+ if (changeTree.extraParents !== undefined) {
7109
+ throw new Error(`@colyseus/schema: cannot reset a shared instance (${instance.constructor.name}) with multiple parents.`);
7110
+ }
7111
+ instance[$reset]();
7112
+ }
7113
+ /**
7114
+ * Per-instance reset primitive (the recursive worker behind
7115
+ * {@link Schema.reset}). Resets ref-type children first (depth-first),
7116
+ * then recycles this instance's ChangeTree and drops its `$refId` so a
7117
+ * re-add is assigned a fresh refId exactly like a freshly constructed
7118
+ * instance. Dropping `$refId` is what makes instance reuse
7119
+ * wire-format-identical to `new T()`.
7120
+ */
7121
+ [$reset]() {
7122
+ const metadata = this.constructor[Symbol.metadata];
7123
+ const refIndexes = metadata?.[$refTypeFieldIndexes] ?? [];
7124
+ const values = this[$values];
7125
+ for (let i = 0; i < refIndexes.length; i++) {
7126
+ const child = values[refIndexes[i]];
7127
+ // ref fields hold a child Schema or collection (both implement
7128
+ // [$reset]); skip undefined/null. Optional-chain is a cheap guard.
7129
+ child?.[$reset]?.();
7130
+ }
7131
+ this[$changes].recycle();
7132
+ // Clear the refId by ASSIGNMENT (not `delete`): `delete` would force the
7133
+ // instance into V8 dictionary mode, making release() as expensive as the
7134
+ // construction it saves. `=== undefined` in Root.add still assigns a fresh
7135
+ // refId exactly like a freshly-constructed instance.
7136
+ this[$refId] = undefined;
7137
+ }
6383
7138
  /**
6384
7139
  * Check whether `type` describes a Schema *class* (a subclass
6385
7140
  * constructor carrying `Symbol.metadata`, as installed by `@type`).
@@ -6442,7 +7197,8 @@ class Schema {
6442
7197
  return view.isChangeTreeVisible(ref[$changes]);
6443
7198
  }
6444
7199
  else {
6445
- // view pass: custom tag
7200
+ // view pass: custom tag (bitmask) — field's stored mask matches
7201
+ // if it shares any bit with a tag this view was add()ed with.
6446
7202
  return view.hasTagOnTree(ref[$changes], tag);
6447
7203
  }
6448
7204
  }
@@ -6747,83 +7503,18 @@ class Schema {
6747
7503
  }
6748
7504
  }
6749
7505
 
6750
- /**
6751
- * Allocates monotonically-increasing refIds with a reuse pool.
6752
- *
6753
- * `acquire()` pops from the free pool when available, otherwise bumps a
6754
- * counter. `release()` queues a refId for reuse; the id doesn't become
6755
- * acquirable until `flushReleases()` runs — the one-tick defer is what
6756
- * lets the encoder guarantee a DELETE for the old instance reaches the
6757
- * wire before the refId is handed to a new one.
6758
- *
6759
- * `reclaim()` handles "resurrection": a ref that was released but whose
6760
- * JS instance is still alive can be re-added to the tree, in which case
6761
- * the encoder must pull the refId back out of the pool before it's
6762
- * handed to an unrelated instance.
6763
- */
6764
- class RefIdAllocator {
6765
- nextUniqueId;
6766
- _free = [];
6767
- _pending = [];
6768
- _pooled = new Set();
6769
- constructor(startRefId = 0) {
6770
- this.nextUniqueId = startRefId;
6771
- }
6772
- acquire() {
6773
- if (this._free.length > 0) {
6774
- const id = this._free.pop();
6775
- this._pooled.delete(id);
6776
- return id;
6777
- }
6778
- return this.nextUniqueId++;
6779
- }
6780
- release(refId) {
6781
- this._pending.push(refId);
6782
- this._pooled.add(refId);
6783
- }
6784
- isPooled(refId) {
6785
- return this._pooled.has(refId);
6786
- }
6787
- /**
6788
- * Remove a refId from the pool. Called when a ref whose refId was
6789
- * released is being resurrected. O(n) scan of the relevant array,
6790
- * but resurrection is rare.
6791
- */
6792
- reclaim(refId) {
6793
- if (!this._pooled.delete(refId))
6794
- return;
6795
- let i = this._free.indexOf(refId);
6796
- if (i !== -1) {
6797
- this._free.splice(i, 1);
6798
- return;
6799
- }
6800
- i = this._pending.indexOf(refId);
6801
- if (i !== -1) {
6802
- this._pending.splice(i, 1);
6803
- }
6804
- }
6805
- /**
6806
- * Promote this tick's releases into the acquirable set. Called from
6807
- * `Encoder.discardChanges()` — never mid-encode.
6808
- */
6809
- flushReleases() {
6810
- const pending = this._pending;
6811
- if (pending.length === 0)
6812
- return;
6813
- const free = this._free;
6814
- for (let i = 0; i < pending.length; i++)
6815
- free.push(pending[i]);
6816
- pending.length = 0;
6817
- }
6818
- }
6819
-
7506
+ // Reused across Root.add calls — defineProperty is unavoidable ($refId must
7507
+ // stay non-enumerable for deepStrictEqual) but the descriptor literal isn't.
7508
+ const $refIdDescriptor$1 = { value: 0, enumerable: false, writable: true };
6820
7509
  class Root {
6821
7510
  types;
6822
7511
  /**
6823
- * Allocates and recycles refIds. See `RefIdAllocator` for the reuse
6824
- * pool semantics (one-tick defer + resurrection).
7512
+ * Monotonic refId counter. RefIds are never recycled — a refId is a
7513
+ * stable identity for the lifetime of the room, so a client that
7514
+ * missed DELETEs (reconnect) can never see an old id rebound to a
7515
+ * different instance. DevMode reads/writes this across HMR cycles.
6825
7516
  */
6826
- refIds;
7517
+ nextUniqueId = 0;
6827
7518
  refCount = {};
6828
7519
  changeTrees = {};
6829
7520
  /**
@@ -6916,7 +7607,7 @@ class Root {
6916
7607
  }
6917
7608
  constructor(types, startRefId = 0) {
6918
7609
  this.types = types;
6919
- this.refIds = new RefIdAllocator(startRefId);
7610
+ this.nextUniqueId = startRefId;
6920
7611
  }
6921
7612
  add(changeTree) {
6922
7613
  const ref = changeTree.ref;
@@ -6925,31 +7616,27 @@ class Root {
6925
7616
  // *enumerable* own Symbols, so we keep defineProperty(enumerable:false)
6926
7617
  // to keep $refId hidden from deep-equal comparisons in tests.
6927
7618
  if (ref[$refId] === undefined) {
6928
- Object.defineProperty(ref, $refId, {
6929
- value: this.refIds.acquire(),
6930
- enumerable: false,
6931
- writable: true
6932
- });
7619
+ $refIdDescriptor$1.value = this.nextUniqueId++;
7620
+ Object.defineProperty(ref, $refId, $refIdDescriptor$1);
6933
7621
  }
6934
7622
  const refId = ref[$refId];
6935
7623
  const isNewChangeTree = (this.changeTrees[refId] === undefined);
6936
7624
  if (isNewChangeTree) {
6937
7625
  this.changeTrees[refId] = changeTree;
6938
7626
  }
6939
- // Resurrection path: a ref whose refId is still queued for reuse
6940
- // is being re-added. Pull the refId out of the pool before it gets
6941
- // handed out to someone else.
6942
- if (this.refIds.isPooled(refId)) {
6943
- this.refIds.reclaim(refId);
6944
- }
6945
7627
  const previousRefCount = this.refCount[refId];
6946
- if (previousRefCount === 0) {
7628
+ if (previousRefCount === 0 || changeTree.needsRestage) {
6947
7629
  //
6948
- // When a ChangeTree is re-added, it means that it was previously
6949
- // removed. Re-stage every currently-populated non-transient index
6950
- // as a fresh ADD in the matching dirty bucket so the next encode
6951
- // re-emits it on the correct channel.
7630
+ // Re-stage every currently-populated non-transient index as a
7631
+ // fresh ADD in the matching dirty bucket so the next encode
7632
+ // re-emits it on the correct channel. Two triggers:
7633
+ // - refCount 0: a previously-removed tree re-added under the
7634
+ // same refId (its ops were consumed by an earlier encode).
7635
+ // - NEEDS_RESTAGE: a `Schema.reset` instance re-entering under
7636
+ // a fresh refId (reset cleared the buckets; its retained
7637
+ // values would otherwise never be encoded).
6952
7638
  //
7639
+ changeTree.needsRestage = false;
6953
7640
  changeTree.forEachLive((fieldIndex) => {
6954
7641
  if (changeTree.isFieldUnreliable(fieldIndex)) {
6955
7642
  changeTree.ensureUnreliableRecorder().record(fieldIndex, OPERATION.ADD);
@@ -6982,15 +7669,6 @@ class Root {
6982
7669
  this.removeFromQueue(changeTree);
6983
7670
  this.removeFromUnreliableQueue(changeTree);
6984
7671
  this.refCount[refId] = 0;
6985
- // Return refId to the reuse pool (deferred to end-of-tick via
6986
- // the allocator's pending set). Stream collections are excluded
6987
- // because their per-view delivery bookkeeping is harder to
6988
- // audit for reuse safety and the savings there are negligible.
6989
- // If the ref is later resurrected, `add()` evicts the refId
6990
- // from the pool before it's handed to another instance.
6991
- if (!changeTree.isStreamCollection) {
6992
- this.refIds.release(refId);
6993
- }
6994
7672
  changeTree.forEachChild((child, _) => {
6995
7673
  if (child.removeParent(changeTree.ref)) {
6996
7674
  if ((child.parentRef === undefined || // no parent, remove it
@@ -7163,6 +7841,12 @@ function ensureStructSwitch(ctx) {
7163
7841
  * Module-level adapter for `forEachLiveWithCtx`. Full-sync emits every live
7164
7842
  * field as ADD, so we re-enter `encodeChangeCb` with that fixed op — keeps
7165
7843
  * the callback closure-free across the entire DFS walk.
7844
+ *
7845
+ * The resync sweep (decoder/Resync.ts) depends on this shape: full-sync
7846
+ * output is dense plain ADDs — no DELETEs, no gap-writes (so decoding it
7847
+ * never compacts arrays mid-walk), and replaced occupants arrive as plain
7848
+ * ADD (the sweep's touch hook releases them). Changing full-sync emission
7849
+ * means revisiting the sweep.
7166
7850
  */
7167
7851
  function encodeFullSyncCb(ctx, fieldIndex) {
7168
7852
  encodeChangeCb(ctx, fieldIndex, OPERATION.ADD);
@@ -7268,7 +7952,18 @@ function concatBytes(a, b) {
7268
7952
  return result;
7269
7953
  }
7270
7954
  class Encoder {
7271
- static BUFFER_SIZE = 8 * 1024; // 8KB
7955
+ /**
7956
+ * Per-encoder shared output buffer size. The encoder auto-grows on
7957
+ * overflow and logs a one-time warning suggesting a higher value, so
7958
+ * the default just needs to comfortably cover typical room state.
7959
+ *
7960
+ * Sized to fit ~100 items in a `MapSchema<{x,y,z}>` keyed by
7961
+ * `nanoid(9)` (~4.5 KB worst-case full encode, float64-heavy) with
7962
+ * ~3-4× headroom for surrounding state (player list, world refs,
7963
+ * etc.). Raise per app via `Encoder.BUFFER_SIZE = N * 1024` before
7964
+ * constructing any Encoder.
7965
+ */
7966
+ static BUFFER_SIZE = 16 * 1024;
7272
7967
  sharedBuffer = new Uint8Array(Encoder.BUFFER_SIZE);
7273
7968
  context;
7274
7969
  state;
@@ -7372,7 +8067,12 @@ class Encoder {
7372
8067
  }
7373
8068
  if (it.offset > buffer.byteLength) {
7374
8069
  buffer = this._resizeBuffer(buffer, it.offset);
7375
- return this._encodeChannel({ offset: initialOffset }, view, buffer, initialOffset, unreliable);
8070
+ // Reuse `it` (reset its offset) instead of a fresh iterator so the
8071
+ // caller's `it.offset` ends at the true final offset. A fresh one
8072
+ // strands `it.offset` at the overflow value and corrupts the next
8073
+ // view's region in a multi-view encode.
8074
+ it.offset = initialOffset;
8075
+ return this._encodeChannel(it, view, buffer, initialOffset, unreliable);
7376
8076
  }
7377
8077
  return buffer.subarray(0, it.offset);
7378
8078
  }
@@ -7404,7 +8104,8 @@ class Encoder {
7404
8104
  _fullSyncWalk(ctx, rootChangeTree);
7405
8105
  if (it.offset > buffer.byteLength) {
7406
8106
  buffer = this._resizeBuffer(buffer, it.offset);
7407
- return this.encodeFullSync({ offset: initialOffset }, buffer, emitFiltered, view);
8107
+ it.offset = initialOffset; // reuse `it` so the caller's offset stays accurate
8108
+ return this.encodeFullSync(it, buffer, emitFiltered, view, initialOffset);
7408
8109
  }
7409
8110
  return buffer.subarray(0, it.offset);
7410
8111
  }
@@ -7427,9 +8128,24 @@ class Encoder {
7427
8128
  }
7428
8129
  encodeAllView(view, sharedOffset, it, bytes = this.sharedBuffer) {
7429
8130
  const viewOffset = it.offset;
7430
- this.encodeFullSync(it, bytes, /* emitFiltered */ true, view, viewOffset);
8131
+ // encodeFullSync() may reallocate the buffer on overflow — keep its
8132
+ // return, not the stale `bytes`, or the concat below reads a dead buffer.
8133
+ bytes = this.encodeFullSync(it, bytes, /* emitFiltered */ true, view, viewOffset);
7431
8134
  return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
7432
8135
  }
8136
+ /** Grow `buffer` to keep BUFFER_SIZE free bytes past `offset`, preserving `[0, offset)`. */
8137
+ ensureCapacity(buffer, offset) {
8138
+ if (offset + Encoder.BUFFER_SIZE <= buffer.byteLength) {
8139
+ return buffer;
8140
+ }
8141
+ const size = Math.ceil((offset + Encoder.BUFFER_SIZE) / Encoder.BUFFER_SIZE) * Encoder.BUFFER_SIZE;
8142
+ const grown = new Uint8Array(size);
8143
+ grown.set(buffer.subarray(0, offset));
8144
+ if (buffer === this.sharedBuffer) {
8145
+ this.sharedBuffer = grown;
8146
+ }
8147
+ return grown;
8148
+ }
7433
8149
  encodeView(view, sharedOffset, it, bytes = this.sharedBuffer) {
7434
8150
  const viewOffset = it.offset;
7435
8151
  // Stream priority pass: drain up to `maxPerTick` per-view entries
@@ -7467,6 +8183,9 @@ class Encoder {
7467
8183
  // `[$getByIndex]` lookup that runs once per change.
7468
8184
  const ref = changeTree.ref;
7469
8185
  const refTarget = changeTree.refTarget;
8186
+ // These writes are unguarded and unrecoverable (view.changes is cleared
8187
+ // below), so unlike encode() they can't re-encode on overflow — grow ahead.
8188
+ bytes = this.ensureCapacity(bytes, it.offset);
7470
8189
  bytes[it.offset++] = SWITCH_TO_STRUCTURE & 255;
7471
8190
  encode.number(bytes, ref[$refId], it);
7472
8191
  // Iterate entries directly — the inner Map gives us the (index, op)
@@ -7485,9 +8204,14 @@ class Encoder {
7485
8204
  // (to allow re-using StateView's for multiple clients)
7486
8205
  //
7487
8206
  view.changes.clear();
7488
- // per-tick view-scoped pass: walks the same `changes` queue as the
8207
+ // Per-tick view-scoped pass: walks the same `changes` queue as the
7489
8208
  // shared pass, but `encodeChangeCb` emits only filtered fields.
7490
- this.encode(it, view, bytes, viewOffset);
8209
+ // encode() may reallocate the buffer on overflow — keep its return,
8210
+ // not the stale `bytes`. Anchor the re-encode at the current offset
8211
+ // (the default `initialOffset = it.offset`), NOT `viewOffset`: a
8212
+ // resize must not clobber the view.changes already written at
8213
+ // [viewOffset, it.offset).
8214
+ bytes = this.encode(it, view, bytes);
7491
8215
  return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
7492
8216
  }
7493
8217
  /**
@@ -7499,7 +8223,9 @@ class Encoder {
7499
8223
  */
7500
8224
  encodeUnreliableView(view, sharedOffset, it, bytes = this.sharedBuffer) {
7501
8225
  const viewOffset = it.offset;
7502
- this.encodeUnreliable(it, view, bytes, viewOffset);
8226
+ // Capture the return: a resize on overflow reallocates the buffer, and
8227
+ // the concat below must read from the live one, not the stale `bytes`.
8228
+ bytes = this.encodeUnreliable(it, view, bytes, viewOffset);
7503
8229
  return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
7504
8230
  }
7505
8231
  /**
@@ -7725,10 +8451,6 @@ class Encoder {
7725
8451
  }
7726
8452
  list.next = undefined;
7727
8453
  list.tail = undefined;
7728
- // End-of-tick: refIds released during this tick have now had their
7729
- // DELETEs emitted through `encode()` / `encodeView()`, so they are
7730
- // safe to recycle on the next tick.
7731
- root.refIds.flushReleases();
7732
8454
  }
7733
8455
  discardUnreliableChanges() {
7734
8456
  const list = this.root.unreliableChanges;
@@ -7782,6 +8504,8 @@ class DecodingWarning extends Error {
7782
8504
  this.name = "DecodingWarning";
7783
8505
  }
7784
8506
  }
8507
+ // Reused across addRef calls — saves a descriptor object per decoded ref.
8508
+ const $refIdDescriptor = { value: 0, enumerable: false, writable: true };
7785
8509
  class ReferenceTracker {
7786
8510
  //
7787
8511
  // Relation of refId => Schema structure
@@ -7802,11 +8526,13 @@ class ReferenceTracker {
7802
8526
  // on decoded instances, which WOULD walk enumerable Symbol-keyed
7803
8527
  // properties and include `$refId` in the comparison. Keep the
7804
8528
  // descriptor dance for semantic compatibility.
7805
- Object.defineProperty(ref, $refId, {
7806
- value: refId,
7807
- enumerable: false,
7808
- writable: true
7809
- });
8529
+ if (ref[$refId] === undefined) {
8530
+ $refIdDescriptor.value = refId;
8531
+ Object.defineProperty(ref, $refId, $refIdDescriptor);
8532
+ }
8533
+ else if (ref[$refId] !== refId) {
8534
+ ref[$refId] = refId; // property exists (writable) — plain write keeps flags
8535
+ }
7810
8536
  if (incrementCount) {
7811
8537
  this.refCount[refId] = (this.refCount[refId] || 0) + 1;
7812
8538
  }
@@ -7920,6 +8646,18 @@ class Decoder {
7920
8646
  root;
7921
8647
  currentRefId = 0;
7922
8648
  triggerChanges;
8649
+ /**
8650
+ * @internal Non-null only while a `decodeResync()` walk is in progress:
8651
+ * collection refId → entry identities the payload visited (map string
8652
+ * keys; array/set/collection/stream indexes). Written by the collection
8653
+ * DecodeOperation functions, read by the post-decode sweep.
8654
+ */
8655
+ resyncVisited = null;
8656
+ /**
8657
+ * @internal Set when a structure had to be skipped during a resync
8658
+ * decode — visited data is incomplete, so the sweep must not delete.
8659
+ */
8660
+ resyncDamaged = false;
7923
8661
  constructor(root, context) {
7924
8662
  this.setState(root);
7925
8663
  this.context = context || new TypeContext(root.constructor);
@@ -7984,6 +8722,12 @@ class Decoder {
7984
8722
  // currently hooks it; the dual call sites stay as-is rather
7985
8723
  // than being extracted into a helper for a one-line body.
7986
8724
  ref[$onDecodeEnd]?.();
8725
+ // resync mode: prune everything the snapshot didn't visit. Runs
8726
+ // before triggerChanges (DELETE changes fire onRemove with the real
8727
+ // previousValue) and before GC (removeRef feeds deletedRefs).
8728
+ if (this.resyncVisited !== null) {
8729
+ resyncSweep(this, allChanges);
8730
+ }
7987
8731
  // trigger changes
7988
8732
  if (allChanges !== null)
7989
8733
  this.triggerChanges?.(allChanges);
@@ -7991,7 +8735,37 @@ class Decoder {
7991
8735
  $root.garbageCollectDeletedRefs();
7992
8736
  return allChanges;
7993
8737
  }
8738
+ /**
8739
+ * Full-snapshot reconciliation ("resync") decode.
8740
+ *
8741
+ * Behaves exactly like {@link decode}, plus: every collection entry the
8742
+ * payload does NOT mention is removed through the regular DELETE path —
8743
+ * `onRemove` callbacks fire with the real previous value and released
8744
+ * refs are garbage-collected. Use it to apply a rejoin/reconnect full
8745
+ * state over an existing decoded tree: DELETEs that happened while the
8746
+ * client was off the wire are reconciled as if they had been received,
8747
+ * while surviving entries keep their instance identity and callbacks.
8748
+ *
8749
+ * ONLY valid for full-snapshot payloads (`encodeAll` / `encodeAllView`
8750
+ * output). Calling it on an incremental patch would prune everything
8751
+ * the patch doesn't touch.
8752
+ */
8753
+ decodeResync(bytes, it = { offset: 0 }) {
8754
+ this.resyncVisited = new Map();
8755
+ this.resyncDamaged = false;
8756
+ try {
8757
+ return this.decode(bytes, it);
8758
+ }
8759
+ finally {
8760
+ this.resyncVisited = null;
8761
+ }
8762
+ }
7994
8763
  skipCurrentStructure(bytes, it, totalBytes) {
8764
+ // A skipped range can swallow other structures' ops (their ADDs are
8765
+ // never applied), so resync visited data is no longer trustworthy.
8766
+ if (this.resyncVisited !== null) {
8767
+ this.resyncDamaged = true;
8768
+ }
7995
8769
  //
7996
8770
  // keep skipping next bytes until reaches a known structure
7997
8771
  // by local decoder.
@@ -8041,10 +8815,27 @@ class Decoder {
8041
8815
  /**
8042
8816
  * Reflection
8043
8817
  */
8818
+ /**
8819
+ * `t.quantized()` field descriptor as it rides the reflection handshake —
8820
+ * schema-typed (bit-exact float64 bounds), NOT a string grammar, so every
8821
+ * language port decodes it with the schema decoder it already has.
8822
+ */
8823
+ const QuantizedDescriptor = schema({
8824
+ min: t.float64(),
8825
+ max: t.float64(),
8826
+ bits: t.uint8(),
8827
+ mode: t.uint8(), // 0 = clamp, 1 = wrap
8828
+ }, "QuantizedDescriptor");
8044
8829
  const ReflectionField = schema({
8045
8830
  name: t.string(),
8046
8831
  type: t.string(),
8047
8832
  referencedType: t.number(),
8833
+ /** Primitive child of a collection (`array`/`map`/... of "string" etc.) —
8834
+ * its own slot, replacing the legacy `"array:string"` colon packing. */
8835
+ childPrimitive: t.string(),
8836
+ /** Set only on `t.quantized()` fields (`.optional()` — no auto-instantiated
8837
+ * default; its absence is the "not quantized" signal on decode). */
8838
+ quantized: t.ref(QuantizedDescriptor).optional(),
8048
8839
  }, "ReflectionField");
8049
8840
  const ReflectionType = schema({
8050
8841
  id: t.number(),
@@ -8114,6 +8905,18 @@ Reflection.encode = function (encoder, it = { offset: 0 }) {
8114
8905
  if (typeof (field.type) === "string") {
8115
8906
  fieldType = field.type;
8116
8907
  }
8908
+ else if (isQuantizedType(field.type)) {
8909
+ // Params ride as a schema-typed descriptor (bit-exact float64) —
8910
+ // no string grammar for the peer (or a language port) to parse.
8911
+ const d = field.type.quantized;
8912
+ fieldType = "quantized";
8913
+ const desc = new QuantizedDescriptor();
8914
+ desc.min = d.min;
8915
+ desc.max = d.max;
8916
+ desc.bits = d.bits;
8917
+ desc.mode = d.wrap ? 1 : 0;
8918
+ reflectionField.quantized = desc;
8919
+ }
8117
8920
  else {
8118
8921
  let childTypeSchema;
8119
8922
  //
@@ -8126,7 +8929,8 @@ Reflection.encode = function (encoder, it = { offset: 0 }) {
8126
8929
  else {
8127
8930
  fieldType = Object.keys(field.type)[0];
8128
8931
  if (typeof (field.type[fieldType]) === "string") {
8129
- fieldType += ":" + field.type[fieldType]; // array:string
8932
+ // primitive child gets its own slot (was packed as "array:string")
8933
+ reflectionField.childPrimitive = field.type[fieldType];
8130
8934
  }
8131
8935
  else {
8132
8936
  childTypeSchema = field.type[fieldType];
@@ -8167,15 +8971,20 @@ Reflection.decode = function (bytes, it) {
8167
8971
  const addFields = (metadata, reflectionType, parentFieldIndex) => {
8168
8972
  reflectionType.fields.forEach((field, i) => {
8169
8973
  const fieldIndex = parentFieldIndex + i;
8170
- if (field.referencedType !== undefined) {
8171
- let fieldType = field.type;
8172
- let refType = typeContext.get(field.referencedType);
8173
- // map or array of primitive type (-1)
8174
- if (!refType) {
8175
- const typeInfo = field.type.split(":");
8176
- fieldType = typeInfo[0];
8177
- refType = typeInfo[1]; // string
8178
- }
8974
+ if (field.quantized !== undefined) {
8975
+ // Schema-typed descriptor → resolved codec (validation stays in
8976
+ // resolveQuantize, same as the builder path).
8977
+ const q = field.quantized;
8978
+ Metadata.addField(metadata, fieldIndex, field.name, {
8979
+ quantized: resolveQuantize({ min: q.min, max: q.max, bits: q.bits, mode: q.mode === 1 ? "wrap" : "clamp" }),
8980
+ });
8981
+ }
8982
+ else if (field.referencedType !== undefined) {
8983
+ const fieldType = field.type;
8984
+ // Schema child by type id; a primitive child (referencedType -1)
8985
+ // rides its own childPrimitive slot.
8986
+ const refType = typeContext.get(field.referencedType)
8987
+ ?? field.childPrimitive;
8179
8988
  if (fieldType === "ref") {
8180
8989
  Metadata.addField(metadata, fieldIndex, field.name, refType);
8181
8990
  }
@@ -8858,6 +9667,48 @@ const Callbacks = {
8858
9667
  }
8859
9668
  };
8860
9669
 
9670
+ /**
9671
+ * Object pool for Schema instances. Exported as a **type only** — construct one
9672
+ * via {@link createPool}, which is the public entry point. The class is public
9673
+ * for typing (`SchemaPool<Entity>` annotations) and `instanceof` checks.
9674
+ */
9675
+ class SchemaPool {
9676
+ _free = [];
9677
+ _factory;
9678
+ _maxSize;
9679
+ constructor(factory, opts = {}) {
9680
+ this._factory = factory;
9681
+ this._maxSize = opts.maxSize ?? Infinity;
9682
+ const preallocate = opts.preallocate ?? 0;
9683
+ for (let i = 0; i < preallocate; i++) {
9684
+ this._free.push(factory());
9685
+ }
9686
+ }
9687
+ /** Pop a pre-reset free instance, or construct a fresh one. */
9688
+ acquire() {
9689
+ return this._free.length > 0 ? this._free.pop() : this._factory();
9690
+ }
9691
+ /**
9692
+ * Reset `instance` to construction defaults and return it to the pool.
9693
+ * PRECONDITION: the instance must already be detached from the state tree
9694
+ * (removed from its parent collection/field, so the encoder released it).
9695
+ */
9696
+ release(instance) {
9697
+ Schema.reset(instance);
9698
+ if (this._free.length < this._maxSize) {
9699
+ this._free.push(instance);
9700
+ }
9701
+ }
9702
+ /** Number of instances currently available for reuse. */
9703
+ get size() {
9704
+ return this._free.length;
9705
+ }
9706
+ }
9707
+ /** Convenience factory: `createPool(Entity, { preallocate: 64 })`. */
9708
+ function createPool(ctor, opts = {}) {
9709
+ return new SchemaPool(() => new ctor(), opts);
9710
+ }
9711
+
8861
9712
  /**
8862
9713
  * Clear the bit for `(slot, bit)` on every ChangeTree in `root`. Called
8863
9714
  * from `dispose()` and from the FinalizationRegistry callback so a view's
@@ -9060,42 +9911,66 @@ class StateView {
9060
9911
  // `tags: WeakMap<ChangeTree, Set<number>>` storage. Hot read site is
9061
9912
  // `Schema.ts` filter check — `hasTagOnTree` is O(1) bitwise.
9062
9913
  // ──────────────────────────────────────────────────────────────────
9063
- /** True iff this view has `tag` associated with `tree`. */
9914
+ /**
9915
+ * True iff this view shares at least one tag bit with `tree`.
9916
+ *
9917
+ * `tagViews` is keyed by individual power-of-two bits (custom tags must
9918
+ * be powers of two; `@view(A|B)` field masks are decomposed on store).
9919
+ * A field whose mask is `tag` is visible if the view was `add()`ed with
9920
+ * any overlapping bit — so we walk `tag`'s set bits and return on the
9921
+ * first match. Passing DEFAULT_VIEW_TAG (-1, all bits) answers "does
9922
+ * this view hold ANY custom tag on the tree".
9923
+ */
9064
9924
  hasTagOnTree(tree, tag) {
9065
9925
  const map = tree.tagViews;
9066
9926
  if (map === undefined)
9067
9927
  return false;
9068
- const arr = map.get(tag);
9069
9928
  const slot = this._slot;
9070
- return arr !== undefined && slot < arr.length && (arr[slot] & this._bit) !== 0;
9929
+ const bit = this._bit;
9930
+ for (let bits = tag; bits !== 0; bits &= bits - 1) {
9931
+ const arr = map.get(bits & -bits); // isolate lowest set bit
9932
+ if (arr !== undefined && slot < arr.length && (arr[slot] & bit) !== 0)
9933
+ return true;
9934
+ }
9935
+ return false;
9071
9936
  }
9072
- /** Mark `tree` as carrying `tag` for this view. */
9937
+ /** Mark `tree` as carrying `tag` (each of its bits) for this view. */
9073
9938
  addTag(tree, tag) {
9939
+ // DEFAULT_VIEW_TAG visibility lives in `visibleViews`, not here.
9940
+ if (tag === DEFAULT_VIEW_TAG)
9941
+ return;
9074
9942
  let map = tree.tagViews;
9075
9943
  if (map === undefined) {
9076
9944
  map = tree.tagViews = new Map();
9077
9945
  }
9078
- let arr = map.get(tag);
9079
- if (arr === undefined) {
9080
- arr = [];
9081
- map.set(tag, arr);
9082
- }
9083
9946
  const slot = this._slot;
9084
- while (arr.length <= slot)
9085
- arr.push(0);
9086
- arr[slot] |= this._bit;
9947
+ const bit = this._bit;
9948
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
9949
+ const b = bits & -bits; // isolate lowest set bit
9950
+ let arr = map.get(b);
9951
+ if (arr === undefined) {
9952
+ arr = [];
9953
+ map.set(b, arr);
9954
+ }
9955
+ while (arr.length <= slot)
9956
+ arr.push(0);
9957
+ arr[slot] |= bit;
9958
+ }
9087
9959
  }
9088
- /** Clear this view's `tag` bit on `tree`. */
9960
+ /** Clear each of `tag`'s bits for this view on `tree`. */
9089
9961
  removeTag(tree, tag) {
9962
+ if (tag === DEFAULT_VIEW_TAG)
9963
+ return;
9090
9964
  const map = tree.tagViews;
9091
9965
  if (map === undefined)
9092
9966
  return;
9093
- const arr = map.get(tag);
9094
- if (arr === undefined)
9095
- return;
9096
9967
  const slot = this._slot;
9097
- if (slot < arr.length)
9098
- arr[slot] &= ~this._bit;
9968
+ const clearMask = ~this._bit;
9969
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
9970
+ const arr = map.get(bits & -bits);
9971
+ if (arr !== undefined && slot < arr.length)
9972
+ arr[slot] &= clearMask;
9973
+ }
9099
9974
  }
9100
9975
  /** Clear ALL tag bits this view holds on `tree` (used when the per-tag isn't known). */
9101
9976
  removeAllTagsOnTree(tree) {
@@ -9227,10 +10102,16 @@ class StateView {
9227
10102
  // direct array index instead of a per-field-object hop.
9228
10103
  const tags = changeTree.encDescriptor.tags;
9229
10104
  changeTree.forEachChild((change, index) => {
9230
- // Do not ADD children that don't have the same tag
10105
+ // Do not ADD children whose field tag shares no bit with `tag`.
10106
+ // DEFAULT_VIEW_TAG fields are visible to all clients; custom-tag
10107
+ // fields only when bits overlap, and never to default-tag clients.
9231
10108
  const fieldTag = tags[index];
9232
- if (fieldTag !== undefined && fieldTag !== tag) {
9233
- return;
10109
+ if (fieldTag !== undefined) {
10110
+ const tagMatch = fieldTag === DEFAULT_VIEW_TAG ||
10111
+ (tag !== DEFAULT_VIEW_TAG && (fieldTag & tag) !== 0);
10112
+ if (!tagMatch) {
10113
+ return;
10114
+ }
9234
10115
  }
9235
10116
  if (this.add(change.ref, tag, false)) {
9236
10117
  isChildAdded = true;
@@ -9239,12 +10120,19 @@ class StateView {
9239
10120
  // set tag
9240
10121
  if (tag !== DEFAULT_VIEW_TAG) {
9241
10122
  this.addTag(changeTree, tag);
9242
- // Ref: add tagged properties
9243
- metadata?.[$fieldIndexesByViewTag]?.[tag]?.forEach((index) => {
9244
- if (changeTree.getChange(index) !== OPERATION.DELETE) {
9245
- changes.set(index, OPERATION.ADD);
10123
+ // Ref: add tagged properties. `$fieldIndexesByViewTag` is keyed
10124
+ // per-bit, so a combined add-tag (`view.add(obj, A|B)`) must look
10125
+ // up each set bit to force-ADD every field that shares a bit.
10126
+ const byTag = metadata?.[$fieldIndexesByViewTag];
10127
+ if (byTag !== undefined) {
10128
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
10129
+ byTag[bits & -bits]?.forEach((index) => {
10130
+ if (changeTree.getChange(index) !== OPERATION.DELETE) {
10131
+ changes.set(index, OPERATION.ADD);
10132
+ }
10133
+ });
9246
10134
  }
9247
- });
10135
+ }
9248
10136
  }
9249
10137
  else if (!changeTree.isNew || isChildAdded) {
9250
10138
  // new structures will be added as part of .encode() call, no need to force it to .encodeView()
@@ -9260,7 +10148,8 @@ class StateView {
9260
10148
  const tagAtIndex = tags[index];
9261
10149
  if (isInvisible || // if "invisible", include all
9262
10150
  tagAtIndex === undefined || // "all change" with no tag
9263
- tagAtIndex === tag // tagged property
10151
+ tagAtIndex === DEFAULT_VIEW_TAG || // visible to all clients
10152
+ (tag !== DEFAULT_VIEW_TAG && (tagAtIndex & tag) !== 0) // tag bits overlap
9264
10153
  ) {
9265
10154
  changes.set(index, OPERATION.ADD);
9266
10155
  isChildAdded = true;
@@ -9290,8 +10179,12 @@ class StateView {
9290
10179
  const tags = tree.encDescriptor.tags;
9291
10180
  tree.forEachChild((child, index) => {
9292
10181
  const fieldTag = tags[index];
9293
- if (fieldTag !== undefined && fieldTag !== tag)
9294
- return;
10182
+ if (fieldTag !== undefined) {
10183
+ const tagMatch = fieldTag === DEFAULT_VIEW_TAG ||
10184
+ (tag !== DEFAULT_VIEW_TAG && (fieldTag & tag) !== 0);
10185
+ if (!tagMatch)
10186
+ return;
10187
+ }
9295
10188
  if (child.isNew) {
9296
10189
  this.markVisible(child);
9297
10190
  this._markSubtreeVisible(child, tag);
@@ -9307,20 +10200,25 @@ class StateView {
9307
10200
  if (!this.isVisible(changeTree)) {
9308
10201
  // view must have all "changeTree" parent tree
9309
10202
  this.markVisible(changeTree);
9310
- // Recurse all the way to the root regardless of whether the
9311
- // parent is filtered. Walking the full chain is what makes
9312
- // `view.changes` topologically ordered by construction any
9313
- // filtered ancestor up the chain is touched here, before the
9314
- // descendant's entry. The actual entry-write below is gated
9315
- // on `hasFilteredFields` so non-filtered ancestors don't
9316
- // emit redundant wire bytes (the decoder already knows them
9317
- // via the shared encode pass). Marking them visible is
9318
- // still useful: it makes this short-circuit fire on the next
9319
- // `view.add` instead of re-walking the chain.
9320
- const parentChangeTree = changeTree.parent?.[$changes];
9321
- if (parentChangeTree) {
9322
- this.addParentOf(changeTree, tag);
9323
- }
10203
+ }
10204
+ // Recurse all the way to the root REGARDLESS of whether this parent
10205
+ // is already visible. Walking the full chain keeps `view.changes`
10206
+ // topologically ordered by construction (ancestors touched before
10207
+ // the descendant's entry), and crucially — re-queues the ancestor
10208
+ // binding ops every time: visibility bits are per-VIEW, but the ADD
10209
+ // ops they once queued are consumed per-ENCODE. With a shared view,
10210
+ // an earlier encode (for other clients) or a dropped backlog leaves
10211
+ // an already-visible ancestor whose binding a late-attached client
10212
+ // never received its filtered-container refId would then arrive
10213
+ // unbound ("refId not found"). Re-writing the entry ops is cheap
10214
+ // (Map.set dedupes within a patch) and decodes as a no-op for
10215
+ // clients that already hold the refs. The entry-write below is
10216
+ // still gated on `hasFilteredFields` so non-filtered ancestors
10217
+ // don't emit redundant wire bytes (the decoder already knows them
10218
+ // via the shared encode pass).
10219
+ const parentChangeTree = changeTree.parent?.[$changes];
10220
+ if (parentChangeTree) {
10221
+ this.addParentOf(changeTree, tag);
9324
10222
  }
9325
10223
  // Skip the entry-write for non-filtered ancestors: their refIds
9326
10224
  // are already known to the decoder through the shared pass, and
@@ -9486,19 +10384,25 @@ class StateView {
9486
10384
  }
9487
10385
  }
9488
10386
  else {
9489
- // delete only tagged properties
10387
+ // delete only tagged properties. `$fieldIndexesByViewTag` is
10388
+ // keyed per-bit, so a combined tag iterates each set bit.
9490
10389
  const names = changeTree.encDescriptor.names;
9491
- metadata?.[$fieldIndexesByViewTag][tag].forEach((index) => {
9492
- changes.set(index, OPERATION.DELETE);
9493
- // Remove child structures from visible set
9494
- const value = changeTree.ref[names[index]];
9495
- if (value?.[$changes]) {
9496
- this.unmarkVisible(value[$changes]);
9497
- this._recursiveDeleteVisibleChangeTree(value[$changes]);
10390
+ const byTag = metadata?.[$fieldIndexesByViewTag];
10391
+ if (byTag !== undefined) {
10392
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
10393
+ byTag[bits & -bits]?.forEach((index) => {
10394
+ changes.set(index, OPERATION.DELETE);
10395
+ // Remove child structures from visible set
10396
+ const value = changeTree.ref[names[index]];
10397
+ if (value?.[$changes]) {
10398
+ this.unmarkVisible(value[$changes]);
10399
+ this._recursiveDeleteVisibleChangeTree(value[$changes]);
10400
+ }
10401
+ });
9498
10402
  }
9499
- });
10403
+ }
9500
10404
  }
9501
- // remove tag bit for this view
10405
+ // remove tag bits for this view
9502
10406
  if (tag === undefined) {
9503
10407
  this.removeAllTagsOnTree(changeTree);
9504
10408
  }
@@ -9668,5 +10572,5 @@ registerType("array", { constructor: ArraySchema });
9668
10572
  registerType("set", { constructor: SetSchema });
9669
10573
  registerType("collection", { constructor: CollectionSchema, });
9670
10574
 
9671
- export { $changes, $childType, $decoder, $deleteByIndex, $encoder, $filter, $getByIndex, $refId, $track, ArraySchema, Callbacks, ChangeTree, CollectionSchema, Decoder, Encoder, FieldBuilder, MapSchema, Metadata, OPERATION, Reflection, ReflectionField, ReflectionType, Root, Schema, SetSchema, StateCallbackStrategy, StateView, StreamSchema, TypeContext, decode, decodeKeyValueOperation, decodeSchemaOperation, defineCustomTypes, deprecated, dumpChanges, encode, encodeArray, encodeIndexedEntry, encodeKeyValueOperation, encodeMapEntry, encodeSchemaOperation, entity, getDecoderStateCallbacks, getRawChangesCallback, isBuilder, owned, registerType, schema, t, transient, type, unreliable, view };
10575
+ export { $changes, $childType, $decoder, $deleteByIndex, $encoder, $filter, $getByIndex, $numFields, $refId, $track, $values, ArraySchema, Callbacks, ChangeTree, CollectionSchema, Decoder, Encoder, FieldBuilder, MapSchema, Metadata, OPERATION, Reflection, ReflectionField, ReflectionType, Root, Schema, SetSchema, StateCallbackStrategy, StateView, StreamSchema, TypeContext, createPool, decode, decodeKeyValueOperation, decodeSchemaOperation, defineCustomTypes, deprecated, dumpChanges, encode, encodeArray, encodeIndexedEntry, encodeKeyValueOperation, encodeMapEntry, encodeSchemaOperation, entity, getDecoderStateCallbacks, getEncodeDescriptor, getRawChangesCallback, isBuilder, owned, registerType, schema, t, transient, type, unreliable, view };
9672
10576
  //# sourceMappingURL=index.mjs.map