@colyseus/schema 5.0.6 → 5.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/Reflection.d.ts +53 -0
- package/build/Schema.d.ts +26 -1
- package/build/annotations.d.ts +16 -1
- package/build/codegen/cli.cjs +103 -64
- package/build/codegen/cli.cjs.map +1 -1
- package/build/codegen/types.d.ts +0 -1
- package/build/decoder/Decoder.d.ts +28 -0
- package/build/decoder/Resync.d.ts +61 -0
- package/build/encoder/ChangeTree.d.ts +15 -0
- package/build/encoder/Encoder.d.ts +13 -0
- package/build/encoder/Pool.d.ts +62 -0
- package/build/encoder/Root.d.ts +5 -4
- package/build/encoder/StateView.d.ts +12 -3
- package/build/index.cjs +1211 -284
- package/build/index.cjs.map +1 -1
- package/build/index.d.ts +4 -1
- package/build/index.js +1211 -284
- package/build/index.mjs +1207 -285
- package/build/index.mjs.map +1 -1
- package/build/input/InputDecoder.d.ts +9 -4
- package/build/input/InputEncoder.d.ts +44 -53
- package/build/input/index.cjs +102 -7321
- package/build/input/index.cjs.map +1 -1
- package/build/input/index.mjs +97 -7316
- package/build/input/index.mjs.map +1 -1
- package/build/types/HelperTypes.d.ts +3 -0
- package/build/types/builder.d.ts +57 -4
- package/build/types/custom/ArraySchema.d.ts +11 -1
- package/build/types/custom/CollectionSchema.d.ts +9 -1
- package/build/types/custom/MapSchema.d.ts +9 -1
- package/build/types/custom/SetSchema.d.ts +9 -1
- package/build/types/custom/StreamSchema.d.ts +2 -1
- package/build/types/quantize.d.ts +102 -0
- package/build/types/symbols.d.ts +15 -0
- package/package.json +8 -1
- package/src/Metadata.ts +34 -9
- package/src/Reflection.ts +49 -11
- package/src/Schema.ts +64 -2
- package/src/annotations.ts +157 -51
- package/src/bench_churn.ts +121 -0
- package/src/codegen/languages/haxe.ts +0 -16
- package/src/codegen/parser.ts +69 -11
- package/src/codegen/types.ts +45 -63
- package/src/decoder/DecodeOperation.ts +46 -4
- package/src/decoder/Decoder.ts +48 -0
- package/src/decoder/ReferenceTracker.ts +9 -5
- package/src/decoder/Resync.ts +170 -0
- package/src/encoder/ChangeTree.ts +59 -0
- package/src/encoder/Encoder.ts +53 -12
- package/src/encoder/Pool.ts +90 -0
- package/src/encoder/Root.ts +22 -32
- package/src/encoder/StateView.ts +101 -50
- package/src/encoder/changeTree/liveIteration.ts +4 -0
- package/src/encoder/changeTree/treeAttachment.ts +28 -24
- package/src/index.ts +12 -1
- package/src/input/InputDecoder.ts +11 -5
- package/src/input/InputEncoder.ts +85 -126
- package/src/types/HelperTypes.ts +7 -0
- package/src/types/TypeContext.ts +7 -0
- package/src/types/builder.ts +62 -5
- package/src/types/custom/ArraySchema.ts +70 -12
- package/src/types/custom/CollectionSchema.ts +36 -1
- package/src/types/custom/MapSchema.ts +50 -1
- package/src/types/custom/SetSchema.ts +36 -1
- package/src/types/custom/StreamSchema.ts +7 -0
- package/src/types/quantize.ts +173 -0
- package/src/types/symbols.ts +16 -0
- package/build/encoder/RefIdAllocator.d.ts +0 -35
- 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
|
-
|
|
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
|
-
|
|
1121
|
-
|
|
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] =
|
|
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
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
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
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
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
|
-
?
|
|
4241
|
-
:
|
|
4242
|
-
?
|
|
4243
|
-
:
|
|
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
|
|
4668
|
+
const self = this[$proxyTarget] ?? this;
|
|
4669
|
+
self.items[index] = undefined;
|
|
4670
|
+
self._needsCompaction = true;
|
|
4247
4671
|
}
|
|
4248
4672
|
[$onEncodeEnd]() {
|
|
4249
|
-
|
|
4250
|
-
|
|
4673
|
+
const self = this[$proxyTarget] ?? this;
|
|
4674
|
+
self.tmpItems = self.items.slice();
|
|
4675
|
+
self.deletedIndexes.length = 0;
|
|
4251
4676
|
}
|
|
4252
4677
|
[$onDecodeEnd]() {
|
|
4253
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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] =
|
|
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,
|
|
@@ -6075,32 +6745,51 @@ function deprecated(throws = true) {
|
|
|
6075
6745
|
});
|
|
6076
6746
|
};
|
|
6077
6747
|
}
|
|
6748
|
+
let defineTypesWarned = false;
|
|
6749
|
+
/**
|
|
6750
|
+
* Adds synchronizable fields to an existing `Schema` subclass — the pre-5.0
|
|
6751
|
+
* helper for plain JavaScript users.
|
|
6752
|
+
*
|
|
6753
|
+
* @deprecated Use `schema()` with `t.*` field builders instead:
|
|
6754
|
+
* https://docs.colyseus.io/state/schema
|
|
6755
|
+
*/
|
|
6756
|
+
function defineTypes(target, fields, options) {
|
|
6757
|
+
if (!defineTypesWarned) {
|
|
6758
|
+
defineTypesWarned = true;
|
|
6759
|
+
console.warn("@colyseus/schema: defineTypes() is deprecated and will be removed in a future release. Use schema() with t.* field builders instead → https://docs.colyseus.io/state/schema");
|
|
6760
|
+
}
|
|
6761
|
+
for (let field in fields) {
|
|
6762
|
+
type(fields[field], options)(target.prototype, field);
|
|
6763
|
+
}
|
|
6764
|
+
return target;
|
|
6765
|
+
}
|
|
6078
6766
|
/**
|
|
6079
|
-
*
|
|
6080
|
-
* (empty collection or zero-arg Schema ref), or `undefined` when the
|
|
6081
|
-
* has no auto-default.
|
|
6767
|
+
* Build a per-construction factory for a builder type's auto-instantiated
|
|
6768
|
+
* default (empty collection or zero-arg Schema ref), or `undefined` when the
|
|
6769
|
+
* type has no auto-default. Returning a factory lets each construction `new` a
|
|
6770
|
+
* fresh value directly instead of cloning a shared prototype instance.
|
|
6082
6771
|
*/
|
|
6083
|
-
function
|
|
6772
|
+
function makeAutoDefaultFactory(rawType) {
|
|
6084
6773
|
if (rawType && typeof rawType === "object") {
|
|
6085
6774
|
if (rawType.array !== undefined) {
|
|
6086
|
-
return new ArraySchema();
|
|
6775
|
+
return () => new ArraySchema();
|
|
6087
6776
|
}
|
|
6088
6777
|
if (rawType.map !== undefined) {
|
|
6089
|
-
return new MapSchema();
|
|
6778
|
+
return () => new MapSchema();
|
|
6090
6779
|
}
|
|
6091
6780
|
if (rawType.set !== undefined) {
|
|
6092
|
-
return new SetSchema();
|
|
6781
|
+
return () => new SetSchema();
|
|
6093
6782
|
}
|
|
6094
6783
|
if (rawType.collection !== undefined) {
|
|
6095
|
-
return new CollectionSchema();
|
|
6784
|
+
return () => new CollectionSchema();
|
|
6096
6785
|
}
|
|
6097
6786
|
if (rawType.stream !== undefined) {
|
|
6098
|
-
return new StreamSchema();
|
|
6787
|
+
return () => new StreamSchema();
|
|
6099
6788
|
}
|
|
6100
6789
|
}
|
|
6101
6790
|
else if (typeof rawType === "function" && Schema.is(rawType)) {
|
|
6102
6791
|
if (!rawType.prototype.initialize || rawType.prototype.initialize.length === 0) {
|
|
6103
|
-
return new rawType();
|
|
6792
|
+
return () => new rawType();
|
|
6104
6793
|
}
|
|
6105
6794
|
}
|
|
6106
6795
|
return undefined;
|
|
@@ -6127,7 +6816,39 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
|
|
|
6127
6816
|
}
|
|
6128
6817
|
const fields = {};
|
|
6129
6818
|
const methods = {};
|
|
6819
|
+
// Two buckets, both keyed by field name and applied at construction:
|
|
6820
|
+
// - `defaultValues`: static values copied as-is (shared reference).
|
|
6821
|
+
// - `defaultFactories`: invoked per construction for a fresh value —
|
|
6822
|
+
// `.default(fn)`, clone-able defaults, and auto-instantiated collections/refs.
|
|
6130
6823
|
const defaultValues = {};
|
|
6824
|
+
const defaultFactories = {};
|
|
6825
|
+
// Decide once (at definition time) how each `.default(v)` materializes per
|
|
6826
|
+
// construction: a function is a factory; a clone-able value clones fresh;
|
|
6827
|
+
// anything else is a shared static value.
|
|
6828
|
+
const assignDefault = (field, value) => {
|
|
6829
|
+
if (typeof value === "function") {
|
|
6830
|
+
defaultFactories[field] = value;
|
|
6831
|
+
}
|
|
6832
|
+
else if (value && typeof value.clone === "function") {
|
|
6833
|
+
defaultFactories[field] = () => value.clone();
|
|
6834
|
+
}
|
|
6835
|
+
else {
|
|
6836
|
+
defaultValues[field] = value;
|
|
6837
|
+
}
|
|
6838
|
+
};
|
|
6839
|
+
// Seed a field's construction default: explicit `.default(v)`, else the
|
|
6840
|
+
// auto-instantiated empty collection / zero-arg ref (skipped for `.optional()`).
|
|
6841
|
+
const seedDefault = (field, def) => {
|
|
6842
|
+
if (def.hasDefault) {
|
|
6843
|
+
assignDefault(field, def.default);
|
|
6844
|
+
}
|
|
6845
|
+
else if (!def.optional) {
|
|
6846
|
+
const factory = makeAutoDefaultFactory(def.type);
|
|
6847
|
+
if (factory) {
|
|
6848
|
+
defaultFactories[field] = factory;
|
|
6849
|
+
}
|
|
6850
|
+
}
|
|
6851
|
+
};
|
|
6131
6852
|
const viewTagFields = {};
|
|
6132
6853
|
const ownedFields = [];
|
|
6133
6854
|
const unreliableFields = [];
|
|
@@ -6151,18 +6872,16 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
|
|
|
6151
6872
|
`together with a sync-only modifier (.view/.owned/.unreliable/.transient/.static/.stream). ` +
|
|
6152
6873
|
`A local-only field cannot be synchronized.`);
|
|
6153
6874
|
}
|
|
6154
|
-
|
|
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
|
-
}
|
|
6875
|
+
seedDefault(fieldName, def);
|
|
6163
6876
|
continue;
|
|
6164
6877
|
}
|
|
6165
|
-
|
|
6878
|
+
const normalizedType = getNormalizedType(def.type);
|
|
6879
|
+
// A synced ref must be encodable (a Schema, or Metadata.setFields()'d) — reject a bare class.
|
|
6880
|
+
if (typeof normalizedType === "function" && !Schema.is(normalizedType)) {
|
|
6881
|
+
throw new Error(`schema(${name ? `'${name}'` : ""}): field '${fieldName}' is a synced ref to non-Schema ` +
|
|
6882
|
+
`class '${normalizedType.name || "(anonymous)"}' — use .noSync(), or Metadata.setFields().`);
|
|
6883
|
+
}
|
|
6884
|
+
fields[fieldName] = normalizedType;
|
|
6166
6885
|
if (def.view !== undefined) {
|
|
6167
6886
|
viewTagFields[fieldName] = def.view;
|
|
6168
6887
|
}
|
|
@@ -6190,24 +6909,14 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
|
|
|
6190
6909
|
if (def.optional) {
|
|
6191
6910
|
optionalFields.push(fieldName);
|
|
6192
6911
|
}
|
|
6193
|
-
|
|
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
|
-
}
|
|
6912
|
+
seedDefault(fieldName, def);
|
|
6204
6913
|
}
|
|
6205
6914
|
else if (typeof value === "function") {
|
|
6206
6915
|
if (Schema.is(value)) {
|
|
6207
6916
|
// Convenience: allow a bare Schema subclass (equivalent to `t.ref(Class)`).
|
|
6208
6917
|
fields[fieldName] = getNormalizedType(value);
|
|
6209
6918
|
if (!value.prototype.initialize || value.prototype.initialize.length === 0) {
|
|
6210
|
-
|
|
6919
|
+
defaultFactories[fieldName] = () => new value();
|
|
6211
6920
|
}
|
|
6212
6921
|
}
|
|
6213
6922
|
else {
|
|
@@ -6219,17 +6928,19 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
|
|
|
6219
6928
|
`Schema subclass, or method (got ${typeof value}).`);
|
|
6220
6929
|
}
|
|
6221
6930
|
}
|
|
6222
|
-
|
|
6223
|
-
|
|
6931
|
+
// Write construction defaults onto `target` — either the instance directly
|
|
6932
|
+
// (no-args fast path) or a throwaway object that gets merged with props.
|
|
6933
|
+
const applyDefaults = (target) => {
|
|
6224
6934
|
for (const fieldName in defaultValues) {
|
|
6225
|
-
|
|
6226
|
-
if (defaultValue && typeof defaultValue.clone === "function") {
|
|
6227
|
-
defaults[fieldName] = defaultValue.clone();
|
|
6228
|
-
}
|
|
6229
|
-
else {
|
|
6230
|
-
defaults[fieldName] = defaultValue;
|
|
6231
|
-
}
|
|
6935
|
+
target[fieldName] = defaultValues[fieldName];
|
|
6232
6936
|
}
|
|
6937
|
+
for (const fieldName in defaultFactories) {
|
|
6938
|
+
target[fieldName] = defaultFactories[fieldName]();
|
|
6939
|
+
}
|
|
6940
|
+
};
|
|
6941
|
+
const getDefaultValues = () => {
|
|
6942
|
+
const defaults = {};
|
|
6943
|
+
applyDefaults(defaults);
|
|
6233
6944
|
return defaults;
|
|
6234
6945
|
};
|
|
6235
6946
|
const getParentProps = (props) => {
|
|
@@ -6242,18 +6953,26 @@ function schema(fieldsAndMethods, name, inherits = Schema) {
|
|
|
6242
6953
|
}
|
|
6243
6954
|
return parentProps;
|
|
6244
6955
|
};
|
|
6956
|
+
const hasInitialize = typeof methods.initialize === "function";
|
|
6245
6957
|
/** @codegen-ignore */
|
|
6246
6958
|
const klass = Metadata.setFields(class extends inherits {
|
|
6247
6959
|
constructor(...args) {
|
|
6248
|
-
|
|
6249
|
-
|
|
6250
|
-
//
|
|
6251
|
-
|
|
6252
|
-
|
|
6253
|
-
|
|
6960
|
+
const props = args[0];
|
|
6961
|
+
if (props === undefined) {
|
|
6962
|
+
// No-args: write defaults straight onto the instance — skips the
|
|
6963
|
+
// throwaway defaults object + Object.assign + assignProps walk.
|
|
6964
|
+
super();
|
|
6965
|
+
applyDefaults(this);
|
|
6254
6966
|
}
|
|
6255
6967
|
else {
|
|
6256
|
-
|
|
6968
|
+
// With props: merge into the fresh defaults object in place (no
|
|
6969
|
+
// extra `{}` target); the super chain runs assignProps once. An
|
|
6970
|
+
// `initialize()` owns the schema fields, so only parent props flow up.
|
|
6971
|
+
super(Object.assign(getDefaultValues(), hasInitialize ? getParentProps(props) : props));
|
|
6972
|
+
}
|
|
6973
|
+
// Only call initialize() on the exact target class, not parents.
|
|
6974
|
+
if (hasInitialize && new.target === klass) {
|
|
6975
|
+
methods.initialize.apply(this, args);
|
|
6257
6976
|
}
|
|
6258
6977
|
}
|
|
6259
6978
|
}, fields);
|
|
@@ -6380,6 +7099,60 @@ class Schema {
|
|
|
6380
7099
|
inst[$values] = [];
|
|
6381
7100
|
return inst;
|
|
6382
7101
|
}
|
|
7102
|
+
/**
|
|
7103
|
+
* Reset a DETACHED instance to construction defaults so it can be returned
|
|
7104
|
+
* to a {@link SchemaPool} and reused, avoiding the cost of `new`. Recurses
|
|
7105
|
+
* into ref-type fields (child Schemas / collections).
|
|
7106
|
+
*
|
|
7107
|
+
* Preconditions (enforced):
|
|
7108
|
+
* - The instance must be tracked (encoder-side), not a decoder mirror.
|
|
7109
|
+
* - The instance must NOT be shared across multiple parents.
|
|
7110
|
+
* - The instance must already be removed from its parent collection/field
|
|
7111
|
+
* (so the encoder detached it: `root === undefined`).
|
|
7112
|
+
*
|
|
7113
|
+
* NOTE: primitive field values are NOT reset to class defaults — re-assign
|
|
7114
|
+
* the fields you care about when you reuse the instance (standard
|
|
7115
|
+
* object-pool discipline).
|
|
7116
|
+
*/
|
|
7117
|
+
static reset(instance) {
|
|
7118
|
+
const changeTree = instance?.[$changes];
|
|
7119
|
+
// Only tracked (encoder-side) instances are poolable. Decoder-side
|
|
7120
|
+
// instances carry an UntrackedChangeTree, which has no recycle().
|
|
7121
|
+
if (changeTree === undefined || typeof changeTree.recycle !== "function") {
|
|
7122
|
+
throw new Error(`@colyseus/schema: Schema.reset() requires a tracked (encoder-side) instance.`);
|
|
7123
|
+
}
|
|
7124
|
+
// Instances reachable through more than one parent are unsafe to pool:
|
|
7125
|
+
// another owner may still hold this instance.
|
|
7126
|
+
if (changeTree.extraParents !== undefined) {
|
|
7127
|
+
throw new Error(`@colyseus/schema: cannot reset a shared instance (${instance.constructor.name}) with multiple parents.`);
|
|
7128
|
+
}
|
|
7129
|
+
instance[$reset]();
|
|
7130
|
+
}
|
|
7131
|
+
/**
|
|
7132
|
+
* Per-instance reset primitive (the recursive worker behind
|
|
7133
|
+
* {@link Schema.reset}). Resets ref-type children first (depth-first),
|
|
7134
|
+
* then recycles this instance's ChangeTree and drops its `$refId` so a
|
|
7135
|
+
* re-add is assigned a fresh refId exactly like a freshly constructed
|
|
7136
|
+
* instance. Dropping `$refId` is what makes instance reuse
|
|
7137
|
+
* wire-format-identical to `new T()`.
|
|
7138
|
+
*/
|
|
7139
|
+
[$reset]() {
|
|
7140
|
+
const metadata = this.constructor[Symbol.metadata];
|
|
7141
|
+
const refIndexes = metadata?.[$refTypeFieldIndexes] ?? [];
|
|
7142
|
+
const values = this[$values];
|
|
7143
|
+
for (let i = 0; i < refIndexes.length; i++) {
|
|
7144
|
+
const child = values[refIndexes[i]];
|
|
7145
|
+
// ref fields hold a child Schema or collection (both implement
|
|
7146
|
+
// [$reset]); skip undefined/null. Optional-chain is a cheap guard.
|
|
7147
|
+
child?.[$reset]?.();
|
|
7148
|
+
}
|
|
7149
|
+
this[$changes].recycle();
|
|
7150
|
+
// Clear the refId by ASSIGNMENT (not `delete`): `delete` would force the
|
|
7151
|
+
// instance into V8 dictionary mode, making release() as expensive as the
|
|
7152
|
+
// construction it saves. `=== undefined` in Root.add still assigns a fresh
|
|
7153
|
+
// refId exactly like a freshly-constructed instance.
|
|
7154
|
+
this[$refId] = undefined;
|
|
7155
|
+
}
|
|
6383
7156
|
/**
|
|
6384
7157
|
* Check whether `type` describes a Schema *class* (a subclass
|
|
6385
7158
|
* constructor carrying `Symbol.metadata`, as installed by `@type`).
|
|
@@ -6442,7 +7215,8 @@ class Schema {
|
|
|
6442
7215
|
return view.isChangeTreeVisible(ref[$changes]);
|
|
6443
7216
|
}
|
|
6444
7217
|
else {
|
|
6445
|
-
// view pass: custom tag
|
|
7218
|
+
// view pass: custom tag (bitmask) — field's stored mask matches
|
|
7219
|
+
// if it shares any bit with a tag this view was add()ed with.
|
|
6446
7220
|
return view.hasTagOnTree(ref[$changes], tag);
|
|
6447
7221
|
}
|
|
6448
7222
|
}
|
|
@@ -6747,83 +7521,18 @@ class Schema {
|
|
|
6747
7521
|
}
|
|
6748
7522
|
}
|
|
6749
7523
|
|
|
6750
|
-
|
|
6751
|
-
|
|
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
|
-
|
|
7524
|
+
// Reused across Root.add calls — defineProperty is unavoidable ($refId must
|
|
7525
|
+
// stay non-enumerable for deepStrictEqual) but the descriptor literal isn't.
|
|
7526
|
+
const $refIdDescriptor$1 = { value: 0, enumerable: false, writable: true };
|
|
6820
7527
|
class Root {
|
|
6821
7528
|
types;
|
|
6822
7529
|
/**
|
|
6823
|
-
*
|
|
6824
|
-
*
|
|
7530
|
+
* Monotonic refId counter. RefIds are never recycled — a refId is a
|
|
7531
|
+
* stable identity for the lifetime of the room, so a client that
|
|
7532
|
+
* missed DELETEs (reconnect) can never see an old id rebound to a
|
|
7533
|
+
* different instance. DevMode reads/writes this across HMR cycles.
|
|
6825
7534
|
*/
|
|
6826
|
-
|
|
7535
|
+
nextUniqueId = 0;
|
|
6827
7536
|
refCount = {};
|
|
6828
7537
|
changeTrees = {};
|
|
6829
7538
|
/**
|
|
@@ -6916,7 +7625,7 @@ class Root {
|
|
|
6916
7625
|
}
|
|
6917
7626
|
constructor(types, startRefId = 0) {
|
|
6918
7627
|
this.types = types;
|
|
6919
|
-
this.
|
|
7628
|
+
this.nextUniqueId = startRefId;
|
|
6920
7629
|
}
|
|
6921
7630
|
add(changeTree) {
|
|
6922
7631
|
const ref = changeTree.ref;
|
|
@@ -6925,31 +7634,27 @@ class Root {
|
|
|
6925
7634
|
// *enumerable* own Symbols, so we keep defineProperty(enumerable:false)
|
|
6926
7635
|
// to keep $refId hidden from deep-equal comparisons in tests.
|
|
6927
7636
|
if (ref[$refId] === undefined) {
|
|
6928
|
-
|
|
6929
|
-
|
|
6930
|
-
enumerable: false,
|
|
6931
|
-
writable: true
|
|
6932
|
-
});
|
|
7637
|
+
$refIdDescriptor$1.value = this.nextUniqueId++;
|
|
7638
|
+
Object.defineProperty(ref, $refId, $refIdDescriptor$1);
|
|
6933
7639
|
}
|
|
6934
7640
|
const refId = ref[$refId];
|
|
6935
7641
|
const isNewChangeTree = (this.changeTrees[refId] === undefined);
|
|
6936
7642
|
if (isNewChangeTree) {
|
|
6937
7643
|
this.changeTrees[refId] = changeTree;
|
|
6938
7644
|
}
|
|
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
7645
|
const previousRefCount = this.refCount[refId];
|
|
6946
|
-
if (previousRefCount === 0) {
|
|
7646
|
+
if (previousRefCount === 0 || changeTree.needsRestage) {
|
|
6947
7647
|
//
|
|
6948
|
-
//
|
|
6949
|
-
//
|
|
6950
|
-
//
|
|
6951
|
-
//
|
|
7648
|
+
// Re-stage every currently-populated non-transient index as a
|
|
7649
|
+
// fresh ADD in the matching dirty bucket so the next encode
|
|
7650
|
+
// re-emits it on the correct channel. Two triggers:
|
|
7651
|
+
// - refCount 0: a previously-removed tree re-added under the
|
|
7652
|
+
// same refId (its ops were consumed by an earlier encode).
|
|
7653
|
+
// - NEEDS_RESTAGE: a `Schema.reset` instance re-entering under
|
|
7654
|
+
// a fresh refId (reset cleared the buckets; its retained
|
|
7655
|
+
// values would otherwise never be encoded).
|
|
6952
7656
|
//
|
|
7657
|
+
changeTree.needsRestage = false;
|
|
6953
7658
|
changeTree.forEachLive((fieldIndex) => {
|
|
6954
7659
|
if (changeTree.isFieldUnreliable(fieldIndex)) {
|
|
6955
7660
|
changeTree.ensureUnreliableRecorder().record(fieldIndex, OPERATION.ADD);
|
|
@@ -6982,15 +7687,6 @@ class Root {
|
|
|
6982
7687
|
this.removeFromQueue(changeTree);
|
|
6983
7688
|
this.removeFromUnreliableQueue(changeTree);
|
|
6984
7689
|
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
7690
|
changeTree.forEachChild((child, _) => {
|
|
6995
7691
|
if (child.removeParent(changeTree.ref)) {
|
|
6996
7692
|
if ((child.parentRef === undefined || // no parent, remove it
|
|
@@ -7163,6 +7859,12 @@ function ensureStructSwitch(ctx) {
|
|
|
7163
7859
|
* Module-level adapter for `forEachLiveWithCtx`. Full-sync emits every live
|
|
7164
7860
|
* field as ADD, so we re-enter `encodeChangeCb` with that fixed op — keeps
|
|
7165
7861
|
* the callback closure-free across the entire DFS walk.
|
|
7862
|
+
*
|
|
7863
|
+
* The resync sweep (decoder/Resync.ts) depends on this shape: full-sync
|
|
7864
|
+
* output is dense plain ADDs — no DELETEs, no gap-writes (so decoding it
|
|
7865
|
+
* never compacts arrays mid-walk), and replaced occupants arrive as plain
|
|
7866
|
+
* ADD (the sweep's touch hook releases them). Changing full-sync emission
|
|
7867
|
+
* means revisiting the sweep.
|
|
7166
7868
|
*/
|
|
7167
7869
|
function encodeFullSyncCb(ctx, fieldIndex) {
|
|
7168
7870
|
encodeChangeCb(ctx, fieldIndex, OPERATION.ADD);
|
|
@@ -7268,7 +7970,18 @@ function concatBytes(a, b) {
|
|
|
7268
7970
|
return result;
|
|
7269
7971
|
}
|
|
7270
7972
|
class Encoder {
|
|
7271
|
-
|
|
7973
|
+
/**
|
|
7974
|
+
* Per-encoder shared output buffer size. The encoder auto-grows on
|
|
7975
|
+
* overflow and logs a one-time warning suggesting a higher value, so
|
|
7976
|
+
* the default just needs to comfortably cover typical room state.
|
|
7977
|
+
*
|
|
7978
|
+
* Sized to fit ~100 items in a `MapSchema<{x,y,z}>` keyed by
|
|
7979
|
+
* `nanoid(9)` (~4.5 KB worst-case full encode, float64-heavy) with
|
|
7980
|
+
* ~3-4× headroom for surrounding state (player list, world refs,
|
|
7981
|
+
* etc.). Raise per app via `Encoder.BUFFER_SIZE = N * 1024` before
|
|
7982
|
+
* constructing any Encoder.
|
|
7983
|
+
*/
|
|
7984
|
+
static BUFFER_SIZE = 16 * 1024;
|
|
7272
7985
|
sharedBuffer = new Uint8Array(Encoder.BUFFER_SIZE);
|
|
7273
7986
|
context;
|
|
7274
7987
|
state;
|
|
@@ -7372,7 +8085,12 @@ class Encoder {
|
|
|
7372
8085
|
}
|
|
7373
8086
|
if (it.offset > buffer.byteLength) {
|
|
7374
8087
|
buffer = this._resizeBuffer(buffer, it.offset);
|
|
7375
|
-
|
|
8088
|
+
// Reuse `it` (reset its offset) instead of a fresh iterator so the
|
|
8089
|
+
// caller's `it.offset` ends at the true final offset. A fresh one
|
|
8090
|
+
// strands `it.offset` at the overflow value and corrupts the next
|
|
8091
|
+
// view's region in a multi-view encode.
|
|
8092
|
+
it.offset = initialOffset;
|
|
8093
|
+
return this._encodeChannel(it, view, buffer, initialOffset, unreliable);
|
|
7376
8094
|
}
|
|
7377
8095
|
return buffer.subarray(0, it.offset);
|
|
7378
8096
|
}
|
|
@@ -7404,7 +8122,8 @@ class Encoder {
|
|
|
7404
8122
|
_fullSyncWalk(ctx, rootChangeTree);
|
|
7405
8123
|
if (it.offset > buffer.byteLength) {
|
|
7406
8124
|
buffer = this._resizeBuffer(buffer, it.offset);
|
|
7407
|
-
|
|
8125
|
+
it.offset = initialOffset; // reuse `it` so the caller's offset stays accurate
|
|
8126
|
+
return this.encodeFullSync(it, buffer, emitFiltered, view, initialOffset);
|
|
7408
8127
|
}
|
|
7409
8128
|
return buffer.subarray(0, it.offset);
|
|
7410
8129
|
}
|
|
@@ -7427,9 +8146,24 @@ class Encoder {
|
|
|
7427
8146
|
}
|
|
7428
8147
|
encodeAllView(view, sharedOffset, it, bytes = this.sharedBuffer) {
|
|
7429
8148
|
const viewOffset = it.offset;
|
|
7430
|
-
|
|
8149
|
+
// encodeFullSync() may reallocate the buffer on overflow — keep its
|
|
8150
|
+
// return, not the stale `bytes`, or the concat below reads a dead buffer.
|
|
8151
|
+
bytes = this.encodeFullSync(it, bytes, /* emitFiltered */ true, view, viewOffset);
|
|
7431
8152
|
return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
|
|
7432
8153
|
}
|
|
8154
|
+
/** Grow `buffer` to keep BUFFER_SIZE free bytes past `offset`, preserving `[0, offset)`. */
|
|
8155
|
+
ensureCapacity(buffer, offset) {
|
|
8156
|
+
if (offset + Encoder.BUFFER_SIZE <= buffer.byteLength) {
|
|
8157
|
+
return buffer;
|
|
8158
|
+
}
|
|
8159
|
+
const size = Math.ceil((offset + Encoder.BUFFER_SIZE) / Encoder.BUFFER_SIZE) * Encoder.BUFFER_SIZE;
|
|
8160
|
+
const grown = new Uint8Array(size);
|
|
8161
|
+
grown.set(buffer.subarray(0, offset));
|
|
8162
|
+
if (buffer === this.sharedBuffer) {
|
|
8163
|
+
this.sharedBuffer = grown;
|
|
8164
|
+
}
|
|
8165
|
+
return grown;
|
|
8166
|
+
}
|
|
7433
8167
|
encodeView(view, sharedOffset, it, bytes = this.sharedBuffer) {
|
|
7434
8168
|
const viewOffset = it.offset;
|
|
7435
8169
|
// Stream priority pass: drain up to `maxPerTick` per-view entries
|
|
@@ -7467,6 +8201,9 @@ class Encoder {
|
|
|
7467
8201
|
// `[$getByIndex]` lookup that runs once per change.
|
|
7468
8202
|
const ref = changeTree.ref;
|
|
7469
8203
|
const refTarget = changeTree.refTarget;
|
|
8204
|
+
// These writes are unguarded and unrecoverable (view.changes is cleared
|
|
8205
|
+
// below), so unlike encode() they can't re-encode on overflow — grow ahead.
|
|
8206
|
+
bytes = this.ensureCapacity(bytes, it.offset);
|
|
7470
8207
|
bytes[it.offset++] = SWITCH_TO_STRUCTURE & 255;
|
|
7471
8208
|
encode.number(bytes, ref[$refId], it);
|
|
7472
8209
|
// Iterate entries directly — the inner Map gives us the (index, op)
|
|
@@ -7485,9 +8222,14 @@ class Encoder {
|
|
|
7485
8222
|
// (to allow re-using StateView's for multiple clients)
|
|
7486
8223
|
//
|
|
7487
8224
|
view.changes.clear();
|
|
7488
|
-
//
|
|
8225
|
+
// Per-tick view-scoped pass: walks the same `changes` queue as the
|
|
7489
8226
|
// shared pass, but `encodeChangeCb` emits only filtered fields.
|
|
7490
|
-
|
|
8227
|
+
// encode() may reallocate the buffer on overflow — keep its return,
|
|
8228
|
+
// not the stale `bytes`. Anchor the re-encode at the current offset
|
|
8229
|
+
// (the default `initialOffset = it.offset`), NOT `viewOffset`: a
|
|
8230
|
+
// resize must not clobber the view.changes already written at
|
|
8231
|
+
// [viewOffset, it.offset).
|
|
8232
|
+
bytes = this.encode(it, view, bytes);
|
|
7491
8233
|
return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
|
|
7492
8234
|
}
|
|
7493
8235
|
/**
|
|
@@ -7499,7 +8241,9 @@ class Encoder {
|
|
|
7499
8241
|
*/
|
|
7500
8242
|
encodeUnreliableView(view, sharedOffset, it, bytes = this.sharedBuffer) {
|
|
7501
8243
|
const viewOffset = it.offset;
|
|
7502
|
-
|
|
8244
|
+
// Capture the return: a resize on overflow reallocates the buffer, and
|
|
8245
|
+
// the concat below must read from the live one, not the stale `bytes`.
|
|
8246
|
+
bytes = this.encodeUnreliable(it, view, bytes, viewOffset);
|
|
7503
8247
|
return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
|
|
7504
8248
|
}
|
|
7505
8249
|
/**
|
|
@@ -7725,10 +8469,6 @@ class Encoder {
|
|
|
7725
8469
|
}
|
|
7726
8470
|
list.next = undefined;
|
|
7727
8471
|
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
8472
|
}
|
|
7733
8473
|
discardUnreliableChanges() {
|
|
7734
8474
|
const list = this.root.unreliableChanges;
|
|
@@ -7782,6 +8522,8 @@ class DecodingWarning extends Error {
|
|
|
7782
8522
|
this.name = "DecodingWarning";
|
|
7783
8523
|
}
|
|
7784
8524
|
}
|
|
8525
|
+
// Reused across addRef calls — saves a descriptor object per decoded ref.
|
|
8526
|
+
const $refIdDescriptor = { value: 0, enumerable: false, writable: true };
|
|
7785
8527
|
class ReferenceTracker {
|
|
7786
8528
|
//
|
|
7787
8529
|
// Relation of refId => Schema structure
|
|
@@ -7802,11 +8544,13 @@ class ReferenceTracker {
|
|
|
7802
8544
|
// on decoded instances, which WOULD walk enumerable Symbol-keyed
|
|
7803
8545
|
// properties and include `$refId` in the comparison. Keep the
|
|
7804
8546
|
// descriptor dance for semantic compatibility.
|
|
7805
|
-
|
|
7806
|
-
value
|
|
7807
|
-
|
|
7808
|
-
|
|
7809
|
-
|
|
8547
|
+
if (ref[$refId] === undefined) {
|
|
8548
|
+
$refIdDescriptor.value = refId;
|
|
8549
|
+
Object.defineProperty(ref, $refId, $refIdDescriptor);
|
|
8550
|
+
}
|
|
8551
|
+
else if (ref[$refId] !== refId) {
|
|
8552
|
+
ref[$refId] = refId; // property exists (writable) — plain write keeps flags
|
|
8553
|
+
}
|
|
7810
8554
|
if (incrementCount) {
|
|
7811
8555
|
this.refCount[refId] = (this.refCount[refId] || 0) + 1;
|
|
7812
8556
|
}
|
|
@@ -7920,6 +8664,18 @@ class Decoder {
|
|
|
7920
8664
|
root;
|
|
7921
8665
|
currentRefId = 0;
|
|
7922
8666
|
triggerChanges;
|
|
8667
|
+
/**
|
|
8668
|
+
* @internal Non-null only while a `decodeResync()` walk is in progress:
|
|
8669
|
+
* collection refId → entry identities the payload visited (map string
|
|
8670
|
+
* keys; array/set/collection/stream indexes). Written by the collection
|
|
8671
|
+
* DecodeOperation functions, read by the post-decode sweep.
|
|
8672
|
+
*/
|
|
8673
|
+
resyncVisited = null;
|
|
8674
|
+
/**
|
|
8675
|
+
* @internal Set when a structure had to be skipped during a resync
|
|
8676
|
+
* decode — visited data is incomplete, so the sweep must not delete.
|
|
8677
|
+
*/
|
|
8678
|
+
resyncDamaged = false;
|
|
7923
8679
|
constructor(root, context) {
|
|
7924
8680
|
this.setState(root);
|
|
7925
8681
|
this.context = context || new TypeContext(root.constructor);
|
|
@@ -7984,6 +8740,12 @@ class Decoder {
|
|
|
7984
8740
|
// currently hooks it; the dual call sites stay as-is rather
|
|
7985
8741
|
// than being extracted into a helper for a one-line body.
|
|
7986
8742
|
ref[$onDecodeEnd]?.();
|
|
8743
|
+
// resync mode: prune everything the snapshot didn't visit. Runs
|
|
8744
|
+
// before triggerChanges (DELETE changes fire onRemove with the real
|
|
8745
|
+
// previousValue) and before GC (removeRef feeds deletedRefs).
|
|
8746
|
+
if (this.resyncVisited !== null) {
|
|
8747
|
+
resyncSweep(this, allChanges);
|
|
8748
|
+
}
|
|
7987
8749
|
// trigger changes
|
|
7988
8750
|
if (allChanges !== null)
|
|
7989
8751
|
this.triggerChanges?.(allChanges);
|
|
@@ -7991,7 +8753,37 @@ class Decoder {
|
|
|
7991
8753
|
$root.garbageCollectDeletedRefs();
|
|
7992
8754
|
return allChanges;
|
|
7993
8755
|
}
|
|
8756
|
+
/**
|
|
8757
|
+
* Full-snapshot reconciliation ("resync") decode.
|
|
8758
|
+
*
|
|
8759
|
+
* Behaves exactly like {@link decode}, plus: every collection entry the
|
|
8760
|
+
* payload does NOT mention is removed through the regular DELETE path —
|
|
8761
|
+
* `onRemove` callbacks fire with the real previous value and released
|
|
8762
|
+
* refs are garbage-collected. Use it to apply a rejoin/reconnect full
|
|
8763
|
+
* state over an existing decoded tree: DELETEs that happened while the
|
|
8764
|
+
* client was off the wire are reconciled as if they had been received,
|
|
8765
|
+
* while surviving entries keep their instance identity and callbacks.
|
|
8766
|
+
*
|
|
8767
|
+
* ONLY valid for full-snapshot payloads (`encodeAll` / `encodeAllView`
|
|
8768
|
+
* output). Calling it on an incremental patch would prune everything
|
|
8769
|
+
* the patch doesn't touch.
|
|
8770
|
+
*/
|
|
8771
|
+
decodeResync(bytes, it = { offset: 0 }) {
|
|
8772
|
+
this.resyncVisited = new Map();
|
|
8773
|
+
this.resyncDamaged = false;
|
|
8774
|
+
try {
|
|
8775
|
+
return this.decode(bytes, it);
|
|
8776
|
+
}
|
|
8777
|
+
finally {
|
|
8778
|
+
this.resyncVisited = null;
|
|
8779
|
+
}
|
|
8780
|
+
}
|
|
7994
8781
|
skipCurrentStructure(bytes, it, totalBytes) {
|
|
8782
|
+
// A skipped range can swallow other structures' ops (their ADDs are
|
|
8783
|
+
// never applied), so resync visited data is no longer trustworthy.
|
|
8784
|
+
if (this.resyncVisited !== null) {
|
|
8785
|
+
this.resyncDamaged = true;
|
|
8786
|
+
}
|
|
7995
8787
|
//
|
|
7996
8788
|
// keep skipping next bytes until reaches a known structure
|
|
7997
8789
|
// by local decoder.
|
|
@@ -8041,10 +8833,27 @@ class Decoder {
|
|
|
8041
8833
|
/**
|
|
8042
8834
|
* Reflection
|
|
8043
8835
|
*/
|
|
8836
|
+
/**
|
|
8837
|
+
* `t.quantized()` field descriptor as it rides the reflection handshake —
|
|
8838
|
+
* schema-typed (bit-exact float64 bounds), NOT a string grammar, so every
|
|
8839
|
+
* language port decodes it with the schema decoder it already has.
|
|
8840
|
+
*/
|
|
8841
|
+
const QuantizedDescriptor = schema({
|
|
8842
|
+
min: t.float64(),
|
|
8843
|
+
max: t.float64(),
|
|
8844
|
+
bits: t.uint8(),
|
|
8845
|
+
mode: t.uint8(), // 0 = clamp, 1 = wrap
|
|
8846
|
+
}, "QuantizedDescriptor");
|
|
8044
8847
|
const ReflectionField = schema({
|
|
8045
8848
|
name: t.string(),
|
|
8046
8849
|
type: t.string(),
|
|
8047
8850
|
referencedType: t.number(),
|
|
8851
|
+
/** Primitive child of a collection (`array`/`map`/... of "string" etc.) —
|
|
8852
|
+
* its own slot, replacing the legacy `"array:string"` colon packing. */
|
|
8853
|
+
childPrimitive: t.string(),
|
|
8854
|
+
/** Set only on `t.quantized()` fields (`.optional()` — no auto-instantiated
|
|
8855
|
+
* default; its absence is the "not quantized" signal on decode). */
|
|
8856
|
+
quantized: t.ref(QuantizedDescriptor).optional(),
|
|
8048
8857
|
}, "ReflectionField");
|
|
8049
8858
|
const ReflectionType = schema({
|
|
8050
8859
|
id: t.number(),
|
|
@@ -8114,6 +8923,18 @@ Reflection.encode = function (encoder, it = { offset: 0 }) {
|
|
|
8114
8923
|
if (typeof (field.type) === "string") {
|
|
8115
8924
|
fieldType = field.type;
|
|
8116
8925
|
}
|
|
8926
|
+
else if (isQuantizedType(field.type)) {
|
|
8927
|
+
// Params ride as a schema-typed descriptor (bit-exact float64) —
|
|
8928
|
+
// no string grammar for the peer (or a language port) to parse.
|
|
8929
|
+
const d = field.type.quantized;
|
|
8930
|
+
fieldType = "quantized";
|
|
8931
|
+
const desc = new QuantizedDescriptor();
|
|
8932
|
+
desc.min = d.min;
|
|
8933
|
+
desc.max = d.max;
|
|
8934
|
+
desc.bits = d.bits;
|
|
8935
|
+
desc.mode = d.wrap ? 1 : 0;
|
|
8936
|
+
reflectionField.quantized = desc;
|
|
8937
|
+
}
|
|
8117
8938
|
else {
|
|
8118
8939
|
let childTypeSchema;
|
|
8119
8940
|
//
|
|
@@ -8126,7 +8947,8 @@ Reflection.encode = function (encoder, it = { offset: 0 }) {
|
|
|
8126
8947
|
else {
|
|
8127
8948
|
fieldType = Object.keys(field.type)[0];
|
|
8128
8949
|
if (typeof (field.type[fieldType]) === "string") {
|
|
8129
|
-
|
|
8950
|
+
// primitive child gets its own slot (was packed as "array:string")
|
|
8951
|
+
reflectionField.childPrimitive = field.type[fieldType];
|
|
8130
8952
|
}
|
|
8131
8953
|
else {
|
|
8132
8954
|
childTypeSchema = field.type[fieldType];
|
|
@@ -8167,15 +8989,20 @@ Reflection.decode = function (bytes, it) {
|
|
|
8167
8989
|
const addFields = (metadata, reflectionType, parentFieldIndex) => {
|
|
8168
8990
|
reflectionType.fields.forEach((field, i) => {
|
|
8169
8991
|
const fieldIndex = parentFieldIndex + i;
|
|
8170
|
-
if (field.
|
|
8171
|
-
|
|
8172
|
-
|
|
8173
|
-
|
|
8174
|
-
|
|
8175
|
-
|
|
8176
|
-
|
|
8177
|
-
|
|
8178
|
-
|
|
8992
|
+
if (field.quantized !== undefined) {
|
|
8993
|
+
// Schema-typed descriptor → resolved codec (validation stays in
|
|
8994
|
+
// resolveQuantize, same as the builder path).
|
|
8995
|
+
const q = field.quantized;
|
|
8996
|
+
Metadata.addField(metadata, fieldIndex, field.name, {
|
|
8997
|
+
quantized: resolveQuantize({ min: q.min, max: q.max, bits: q.bits, mode: q.mode === 1 ? "wrap" : "clamp" }),
|
|
8998
|
+
});
|
|
8999
|
+
}
|
|
9000
|
+
else if (field.referencedType !== undefined) {
|
|
9001
|
+
const fieldType = field.type;
|
|
9002
|
+
// Schema child by type id; a primitive child (referencedType -1)
|
|
9003
|
+
// rides its own childPrimitive slot.
|
|
9004
|
+
const refType = typeContext.get(field.referencedType)
|
|
9005
|
+
?? field.childPrimitive;
|
|
8179
9006
|
if (fieldType === "ref") {
|
|
8180
9007
|
Metadata.addField(metadata, fieldIndex, field.name, refType);
|
|
8181
9008
|
}
|
|
@@ -8858,6 +9685,48 @@ const Callbacks = {
|
|
|
8858
9685
|
}
|
|
8859
9686
|
};
|
|
8860
9687
|
|
|
9688
|
+
/**
|
|
9689
|
+
* Object pool for Schema instances. Exported as a **type only** — construct one
|
|
9690
|
+
* via {@link createPool}, which is the public entry point. The class is public
|
|
9691
|
+
* for typing (`SchemaPool<Entity>` annotations) and `instanceof` checks.
|
|
9692
|
+
*/
|
|
9693
|
+
class SchemaPool {
|
|
9694
|
+
_free = [];
|
|
9695
|
+
_factory;
|
|
9696
|
+
_maxSize;
|
|
9697
|
+
constructor(factory, opts = {}) {
|
|
9698
|
+
this._factory = factory;
|
|
9699
|
+
this._maxSize = opts.maxSize ?? Infinity;
|
|
9700
|
+
const preallocate = opts.preallocate ?? 0;
|
|
9701
|
+
for (let i = 0; i < preallocate; i++) {
|
|
9702
|
+
this._free.push(factory());
|
|
9703
|
+
}
|
|
9704
|
+
}
|
|
9705
|
+
/** Pop a pre-reset free instance, or construct a fresh one. */
|
|
9706
|
+
acquire() {
|
|
9707
|
+
return this._free.length > 0 ? this._free.pop() : this._factory();
|
|
9708
|
+
}
|
|
9709
|
+
/**
|
|
9710
|
+
* Reset `instance` to construction defaults and return it to the pool.
|
|
9711
|
+
* PRECONDITION: the instance must already be detached from the state tree
|
|
9712
|
+
* (removed from its parent collection/field, so the encoder released it).
|
|
9713
|
+
*/
|
|
9714
|
+
release(instance) {
|
|
9715
|
+
Schema.reset(instance);
|
|
9716
|
+
if (this._free.length < this._maxSize) {
|
|
9717
|
+
this._free.push(instance);
|
|
9718
|
+
}
|
|
9719
|
+
}
|
|
9720
|
+
/** Number of instances currently available for reuse. */
|
|
9721
|
+
get size() {
|
|
9722
|
+
return this._free.length;
|
|
9723
|
+
}
|
|
9724
|
+
}
|
|
9725
|
+
/** Convenience factory: `createPool(Entity, { preallocate: 64 })`. */
|
|
9726
|
+
function createPool(ctor, opts = {}) {
|
|
9727
|
+
return new SchemaPool(() => new ctor(), opts);
|
|
9728
|
+
}
|
|
9729
|
+
|
|
8861
9730
|
/**
|
|
8862
9731
|
* Clear the bit for `(slot, bit)` on every ChangeTree in `root`. Called
|
|
8863
9732
|
* from `dispose()` and from the FinalizationRegistry callback so a view's
|
|
@@ -9060,42 +9929,66 @@ class StateView {
|
|
|
9060
9929
|
// `tags: WeakMap<ChangeTree, Set<number>>` storage. Hot read site is
|
|
9061
9930
|
// `Schema.ts` filter check — `hasTagOnTree` is O(1) bitwise.
|
|
9062
9931
|
// ──────────────────────────────────────────────────────────────────
|
|
9063
|
-
/**
|
|
9932
|
+
/**
|
|
9933
|
+
* True iff this view shares at least one tag bit with `tree`.
|
|
9934
|
+
*
|
|
9935
|
+
* `tagViews` is keyed by individual power-of-two bits (custom tags must
|
|
9936
|
+
* be powers of two; `@view(A|B)` field masks are decomposed on store).
|
|
9937
|
+
* A field whose mask is `tag` is visible if the view was `add()`ed with
|
|
9938
|
+
* any overlapping bit — so we walk `tag`'s set bits and return on the
|
|
9939
|
+
* first match. Passing DEFAULT_VIEW_TAG (-1, all bits) answers "does
|
|
9940
|
+
* this view hold ANY custom tag on the tree".
|
|
9941
|
+
*/
|
|
9064
9942
|
hasTagOnTree(tree, tag) {
|
|
9065
9943
|
const map = tree.tagViews;
|
|
9066
9944
|
if (map === undefined)
|
|
9067
9945
|
return false;
|
|
9068
|
-
const arr = map.get(tag);
|
|
9069
9946
|
const slot = this._slot;
|
|
9070
|
-
|
|
9947
|
+
const bit = this._bit;
|
|
9948
|
+
for (let bits = tag; bits !== 0; bits &= bits - 1) {
|
|
9949
|
+
const arr = map.get(bits & -bits); // isolate lowest set bit
|
|
9950
|
+
if (arr !== undefined && slot < arr.length && (arr[slot] & bit) !== 0)
|
|
9951
|
+
return true;
|
|
9952
|
+
}
|
|
9953
|
+
return false;
|
|
9071
9954
|
}
|
|
9072
|
-
/** Mark `tree` as carrying `tag` for this view. */
|
|
9955
|
+
/** Mark `tree` as carrying `tag` (each of its bits) for this view. */
|
|
9073
9956
|
addTag(tree, tag) {
|
|
9957
|
+
// DEFAULT_VIEW_TAG visibility lives in `visibleViews`, not here.
|
|
9958
|
+
if (tag === DEFAULT_VIEW_TAG)
|
|
9959
|
+
return;
|
|
9074
9960
|
let map = tree.tagViews;
|
|
9075
9961
|
if (map === undefined) {
|
|
9076
9962
|
map = tree.tagViews = new Map();
|
|
9077
9963
|
}
|
|
9078
|
-
let arr = map.get(tag);
|
|
9079
|
-
if (arr === undefined) {
|
|
9080
|
-
arr = [];
|
|
9081
|
-
map.set(tag, arr);
|
|
9082
|
-
}
|
|
9083
9964
|
const slot = this._slot;
|
|
9084
|
-
|
|
9085
|
-
|
|
9086
|
-
|
|
9965
|
+
const bit = this._bit;
|
|
9966
|
+
for (let bits = tag; bits > 0; bits &= bits - 1) {
|
|
9967
|
+
const b = bits & -bits; // isolate lowest set bit
|
|
9968
|
+
let arr = map.get(b);
|
|
9969
|
+
if (arr === undefined) {
|
|
9970
|
+
arr = [];
|
|
9971
|
+
map.set(b, arr);
|
|
9972
|
+
}
|
|
9973
|
+
while (arr.length <= slot)
|
|
9974
|
+
arr.push(0);
|
|
9975
|
+
arr[slot] |= bit;
|
|
9976
|
+
}
|
|
9087
9977
|
}
|
|
9088
|
-
/** Clear
|
|
9978
|
+
/** Clear each of `tag`'s bits for this view on `tree`. */
|
|
9089
9979
|
removeTag(tree, tag) {
|
|
9980
|
+
if (tag === DEFAULT_VIEW_TAG)
|
|
9981
|
+
return;
|
|
9090
9982
|
const map = tree.tagViews;
|
|
9091
9983
|
if (map === undefined)
|
|
9092
9984
|
return;
|
|
9093
|
-
const arr = map.get(tag);
|
|
9094
|
-
if (arr === undefined)
|
|
9095
|
-
return;
|
|
9096
9985
|
const slot = this._slot;
|
|
9097
|
-
|
|
9098
|
-
|
|
9986
|
+
const clearMask = ~this._bit;
|
|
9987
|
+
for (let bits = tag; bits > 0; bits &= bits - 1) {
|
|
9988
|
+
const arr = map.get(bits & -bits);
|
|
9989
|
+
if (arr !== undefined && slot < arr.length)
|
|
9990
|
+
arr[slot] &= clearMask;
|
|
9991
|
+
}
|
|
9099
9992
|
}
|
|
9100
9993
|
/** Clear ALL tag bits this view holds on `tree` (used when the per-tag isn't known). */
|
|
9101
9994
|
removeAllTagsOnTree(tree) {
|
|
@@ -9227,10 +10120,16 @@ class StateView {
|
|
|
9227
10120
|
// direct array index instead of a per-field-object hop.
|
|
9228
10121
|
const tags = changeTree.encDescriptor.tags;
|
|
9229
10122
|
changeTree.forEachChild((change, index) => {
|
|
9230
|
-
// Do not ADD children
|
|
10123
|
+
// Do not ADD children whose field tag shares no bit with `tag`.
|
|
10124
|
+
// DEFAULT_VIEW_TAG fields are visible to all clients; custom-tag
|
|
10125
|
+
// fields only when bits overlap, and never to default-tag clients.
|
|
9231
10126
|
const fieldTag = tags[index];
|
|
9232
|
-
if (fieldTag !== undefined
|
|
9233
|
-
|
|
10127
|
+
if (fieldTag !== undefined) {
|
|
10128
|
+
const tagMatch = fieldTag === DEFAULT_VIEW_TAG ||
|
|
10129
|
+
(tag !== DEFAULT_VIEW_TAG && (fieldTag & tag) !== 0);
|
|
10130
|
+
if (!tagMatch) {
|
|
10131
|
+
return;
|
|
10132
|
+
}
|
|
9234
10133
|
}
|
|
9235
10134
|
if (this.add(change.ref, tag, false)) {
|
|
9236
10135
|
isChildAdded = true;
|
|
@@ -9239,12 +10138,19 @@ class StateView {
|
|
|
9239
10138
|
// set tag
|
|
9240
10139
|
if (tag !== DEFAULT_VIEW_TAG) {
|
|
9241
10140
|
this.addTag(changeTree, tag);
|
|
9242
|
-
// Ref: add tagged properties
|
|
9243
|
-
|
|
9244
|
-
|
|
9245
|
-
|
|
10141
|
+
// Ref: add tagged properties. `$fieldIndexesByViewTag` is keyed
|
|
10142
|
+
// per-bit, so a combined add-tag (`view.add(obj, A|B)`) must look
|
|
10143
|
+
// up each set bit to force-ADD every field that shares a bit.
|
|
10144
|
+
const byTag = metadata?.[$fieldIndexesByViewTag];
|
|
10145
|
+
if (byTag !== undefined) {
|
|
10146
|
+
for (let bits = tag; bits > 0; bits &= bits - 1) {
|
|
10147
|
+
byTag[bits & -bits]?.forEach((index) => {
|
|
10148
|
+
if (changeTree.getChange(index) !== OPERATION.DELETE) {
|
|
10149
|
+
changes.set(index, OPERATION.ADD);
|
|
10150
|
+
}
|
|
10151
|
+
});
|
|
9246
10152
|
}
|
|
9247
|
-
}
|
|
10153
|
+
}
|
|
9248
10154
|
}
|
|
9249
10155
|
else if (!changeTree.isNew || isChildAdded) {
|
|
9250
10156
|
// new structures will be added as part of .encode() call, no need to force it to .encodeView()
|
|
@@ -9260,7 +10166,8 @@ class StateView {
|
|
|
9260
10166
|
const tagAtIndex = tags[index];
|
|
9261
10167
|
if (isInvisible || // if "invisible", include all
|
|
9262
10168
|
tagAtIndex === undefined || // "all change" with no tag
|
|
9263
|
-
tagAtIndex ===
|
|
10169
|
+
tagAtIndex === DEFAULT_VIEW_TAG || // visible to all clients
|
|
10170
|
+
(tag !== DEFAULT_VIEW_TAG && (tagAtIndex & tag) !== 0) // tag bits overlap
|
|
9264
10171
|
) {
|
|
9265
10172
|
changes.set(index, OPERATION.ADD);
|
|
9266
10173
|
isChildAdded = true;
|
|
@@ -9290,8 +10197,12 @@ class StateView {
|
|
|
9290
10197
|
const tags = tree.encDescriptor.tags;
|
|
9291
10198
|
tree.forEachChild((child, index) => {
|
|
9292
10199
|
const fieldTag = tags[index];
|
|
9293
|
-
if (fieldTag !== undefined
|
|
9294
|
-
|
|
10200
|
+
if (fieldTag !== undefined) {
|
|
10201
|
+
const tagMatch = fieldTag === DEFAULT_VIEW_TAG ||
|
|
10202
|
+
(tag !== DEFAULT_VIEW_TAG && (fieldTag & tag) !== 0);
|
|
10203
|
+
if (!tagMatch)
|
|
10204
|
+
return;
|
|
10205
|
+
}
|
|
9295
10206
|
if (child.isNew) {
|
|
9296
10207
|
this.markVisible(child);
|
|
9297
10208
|
this._markSubtreeVisible(child, tag);
|
|
@@ -9307,20 +10218,25 @@ class StateView {
|
|
|
9307
10218
|
if (!this.isVisible(changeTree)) {
|
|
9308
10219
|
// view must have all "changeTree" parent tree
|
|
9309
10220
|
this.markVisible(changeTree);
|
|
9310
|
-
|
|
9311
|
-
|
|
9312
|
-
|
|
9313
|
-
|
|
9314
|
-
|
|
9315
|
-
|
|
9316
|
-
|
|
9317
|
-
|
|
9318
|
-
|
|
9319
|
-
|
|
9320
|
-
|
|
9321
|
-
|
|
9322
|
-
|
|
9323
|
-
|
|
10221
|
+
}
|
|
10222
|
+
// Recurse all the way to the root REGARDLESS of whether this parent
|
|
10223
|
+
// is already visible. Walking the full chain keeps `view.changes`
|
|
10224
|
+
// topologically ordered by construction (ancestors touched before
|
|
10225
|
+
// the descendant's entry), and — crucially — re-queues the ancestor
|
|
10226
|
+
// binding ops every time: visibility bits are per-VIEW, but the ADD
|
|
10227
|
+
// ops they once queued are consumed per-ENCODE. With a shared view,
|
|
10228
|
+
// an earlier encode (for other clients) or a dropped backlog leaves
|
|
10229
|
+
// an already-visible ancestor whose binding a late-attached client
|
|
10230
|
+
// never received — its filtered-container refId would then arrive
|
|
10231
|
+
// unbound ("refId not found"). Re-writing the entry ops is cheap
|
|
10232
|
+
// (Map.set dedupes within a patch) and decodes as a no-op for
|
|
10233
|
+
// clients that already hold the refs. The entry-write below is
|
|
10234
|
+
// still gated on `hasFilteredFields` so non-filtered ancestors
|
|
10235
|
+
// don't emit redundant wire bytes (the decoder already knows them
|
|
10236
|
+
// via the shared encode pass).
|
|
10237
|
+
const parentChangeTree = changeTree.parent?.[$changes];
|
|
10238
|
+
if (parentChangeTree) {
|
|
10239
|
+
this.addParentOf(changeTree, tag);
|
|
9324
10240
|
}
|
|
9325
10241
|
// Skip the entry-write for non-filtered ancestors: their refIds
|
|
9326
10242
|
// are already known to the decoder through the shared pass, and
|
|
@@ -9486,19 +10402,25 @@ class StateView {
|
|
|
9486
10402
|
}
|
|
9487
10403
|
}
|
|
9488
10404
|
else {
|
|
9489
|
-
// delete only tagged properties
|
|
10405
|
+
// delete only tagged properties. `$fieldIndexesByViewTag` is
|
|
10406
|
+
// keyed per-bit, so a combined tag iterates each set bit.
|
|
9490
10407
|
const names = changeTree.encDescriptor.names;
|
|
9491
|
-
metadata?.[$fieldIndexesByViewTag]
|
|
9492
|
-
|
|
9493
|
-
|
|
9494
|
-
|
|
9495
|
-
|
|
9496
|
-
|
|
9497
|
-
|
|
10408
|
+
const byTag = metadata?.[$fieldIndexesByViewTag];
|
|
10409
|
+
if (byTag !== undefined) {
|
|
10410
|
+
for (let bits = tag; bits > 0; bits &= bits - 1) {
|
|
10411
|
+
byTag[bits & -bits]?.forEach((index) => {
|
|
10412
|
+
changes.set(index, OPERATION.DELETE);
|
|
10413
|
+
// Remove child structures from visible set
|
|
10414
|
+
const value = changeTree.ref[names[index]];
|
|
10415
|
+
if (value?.[$changes]) {
|
|
10416
|
+
this.unmarkVisible(value[$changes]);
|
|
10417
|
+
this._recursiveDeleteVisibleChangeTree(value[$changes]);
|
|
10418
|
+
}
|
|
10419
|
+
});
|
|
9498
10420
|
}
|
|
9499
|
-
}
|
|
10421
|
+
}
|
|
9500
10422
|
}
|
|
9501
|
-
// remove tag
|
|
10423
|
+
// remove tag bits for this view
|
|
9502
10424
|
if (tag === undefined) {
|
|
9503
10425
|
this.removeAllTagsOnTree(changeTree);
|
|
9504
10426
|
}
|
|
@@ -9668,5 +10590,5 @@ registerType("array", { constructor: ArraySchema });
|
|
|
9668
10590
|
registerType("set", { constructor: SetSchema });
|
|
9669
10591
|
registerType("collection", { constructor: CollectionSchema, });
|
|
9670
10592
|
|
|
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 };
|
|
10593
|
+
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, defineTypes, deprecated, dumpChanges, encode, encodeArray, encodeIndexedEntry, encodeKeyValueOperation, encodeMapEntry, encodeSchemaOperation, entity, getDecoderStateCallbacks, getEncodeDescriptor, getRawChangesCallback, isBuilder, owned, registerType, schema, t, transient, type, unreliable, view };
|
|
9672
10594
|
//# sourceMappingURL=index.mjs.map
|