@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.js
CHANGED
|
@@ -63,6 +63,13 @@
|
|
|
63
63
|
const $filter = "~filter";
|
|
64
64
|
const $getByIndex = "~getByIndex";
|
|
65
65
|
const $deleteByIndex = "~deleteByIndex";
|
|
66
|
+
/**
|
|
67
|
+
* Resync-sweep hook (see decoder/Resync.ts): prune every entry the rejoin
|
|
68
|
+
* snapshot did not visit. Each collection owns its storage-specific
|
|
69
|
+
* bookkeeping (journal pruning, compaction, item indexes); the generic
|
|
70
|
+
* DELETE/ref/callback bookkeeping arrives via the `prune`/`keep` callbacks.
|
|
71
|
+
*/
|
|
72
|
+
const $resyncPrune = "~resyncPrune";
|
|
66
73
|
/**
|
|
67
74
|
* Used to hold ChangeTree instances whitin the structures.
|
|
68
75
|
*
|
|
@@ -87,6 +94,14 @@
|
|
|
87
94
|
* (Discards changes for next serialization)
|
|
88
95
|
*/
|
|
89
96
|
const $onEncodeEnd = '~onEncodeEnd';
|
|
97
|
+
/**
|
|
98
|
+
* Optional "reset" method on every poolable Ref (Schema + collections).
|
|
99
|
+
* Empties the instance's backing store and recycles its ChangeTree WITHOUT
|
|
100
|
+
* emitting any wire op, and recurses into ref-type children — so the instance
|
|
101
|
+
* can be returned to a SchemaPool and reused, avoiding the cost of `new`.
|
|
102
|
+
* See encoder/Pool.ts and Schema.reset().
|
|
103
|
+
*/
|
|
104
|
+
const $reset = "~reset";
|
|
90
105
|
/**
|
|
91
106
|
* When decoding, this method is called after the instance is fully decoded
|
|
92
107
|
*/
|
|
@@ -812,6 +827,92 @@
|
|
|
812
827
|
st.sentByView.delete(viewId);
|
|
813
828
|
}
|
|
814
829
|
|
|
830
|
+
const WIRE_BY_BITS = {
|
|
831
|
+
8: "uint8",
|
|
832
|
+
16: "uint16",
|
|
833
|
+
32: "uint32",
|
|
834
|
+
};
|
|
835
|
+
/** Validate options and precompute the wire codec + scale span. */
|
|
836
|
+
function resolveQuantize(opts) {
|
|
837
|
+
if (opts == null || typeof opts !== "object") {
|
|
838
|
+
throw new Error("t.quantized(): options object with { min, max } is required.");
|
|
839
|
+
}
|
|
840
|
+
const { min, max } = opts;
|
|
841
|
+
if (typeof min !== "number" || typeof max !== "number" || !(max > min)
|
|
842
|
+
|| !Number.isFinite(min) || !Number.isFinite(max)) {
|
|
843
|
+
throw new Error(`t.quantized(): require finite min < max (got min=${min}, max=${max}).`);
|
|
844
|
+
}
|
|
845
|
+
const bits = opts.bits ?? 16;
|
|
846
|
+
if (bits !== 8 && bits !== 16 && bits !== 32) {
|
|
847
|
+
throw new Error(`t.quantized(): bits must be 8, 16 or 32 (got ${bits}).`);
|
|
848
|
+
}
|
|
849
|
+
const mode = opts.mode ?? "clamp";
|
|
850
|
+
if (mode !== "clamp" && mode !== "wrap") {
|
|
851
|
+
throw new Error(`t.quantized(): mode must be "clamp" or "wrap" (got ${JSON.stringify(mode)}).`);
|
|
852
|
+
}
|
|
853
|
+
const wrap = mode === "wrap"; // descriptor + wire stay boolean; `mode` is the public knob
|
|
854
|
+
const steps = Math.pow(2, bits);
|
|
855
|
+
return {
|
|
856
|
+
min,
|
|
857
|
+
max,
|
|
858
|
+
bits,
|
|
859
|
+
wrap,
|
|
860
|
+
wire: WIRE_BY_BITS[bits],
|
|
861
|
+
range: max - min,
|
|
862
|
+
// wrapping spreads `2^bits` steps across [min,max) (top ≡ bottom); clamped
|
|
863
|
+
// maps the endpoints onto `0` and `2^bits − 1` inclusive.
|
|
864
|
+
span: wrap ? steps : steps - 1,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
/** Type guard for the `{ quantized: QuantizeDescriptor }` field-type shape. */
|
|
868
|
+
function isQuantizedType(type) {
|
|
869
|
+
return type !== null && typeof type === "object" && type.quantized !== undefined;
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Float → unsigned integer. Rounding is explicit half-up `floor(x + 0.5)`;
|
|
873
|
+
* wrapping ranges are reduced in the float domain first (no huge→int cast).
|
|
874
|
+
*/
|
|
875
|
+
function quantize(desc, value) {
|
|
876
|
+
if (desc.wrap) {
|
|
877
|
+
// Non-finite can't be range-reduced (Inf % range = NaN); NaN would flow to
|
|
878
|
+
// the wire as garbage while the local instance kept NaN — a silent peer
|
|
879
|
+
// divergence. Pin to q=0 (= min): garbage in, deterministic out, both agree.
|
|
880
|
+
if (!Number.isFinite(value))
|
|
881
|
+
return 0;
|
|
882
|
+
const range = desc.range;
|
|
883
|
+
// float-domain range reduction → [0, range)
|
|
884
|
+
let a = (value - desc.min) % range;
|
|
885
|
+
if (a < 0)
|
|
886
|
+
a += range;
|
|
887
|
+
const steps = desc.span; // 2^bits
|
|
888
|
+
// `% steps` (not `& mask`) so bits=32 doesn't overflow JS's int32 bitwise.
|
|
889
|
+
return Math.floor((a / range) * steps + 0.5) % steps;
|
|
890
|
+
}
|
|
891
|
+
if (value !== value)
|
|
892
|
+
return 0; // NaN → min (±Inf clamps naturally below)
|
|
893
|
+
const v = value < desc.min ? desc.min : value > desc.max ? desc.max : value;
|
|
894
|
+
return Math.floor(((v - desc.min) / desc.range) * desc.span + 0.5);
|
|
895
|
+
}
|
|
896
|
+
/** Unsigned integer → float. Correctly-rounded mul/div ⇒ bit-identical across languages. */
|
|
897
|
+
function dequantize(desc, q) {
|
|
898
|
+
return desc.min + (q / desc.span) * desc.range;
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Pre-baked encoder for a quantized field: quantize the (snapped) float held on
|
|
902
|
+
* the instance, then write it with the field's unsigned-int wire codec. Stored in
|
|
903
|
+
* `metadata[$encoders]` so the encode hot path reaches it via the fast lane,
|
|
904
|
+
* exactly like the pre-computed primitive encoders.
|
|
905
|
+
*/
|
|
906
|
+
function makeQuantizedEncoder(desc) {
|
|
907
|
+
const writeWire = encode[desc.wire];
|
|
908
|
+
return (bytes, value, it) => writeWire(bytes, quantize(desc, value), it);
|
|
909
|
+
}
|
|
910
|
+
/** Decode a quantized field: read the unsigned-int wire value, then dequantize. */
|
|
911
|
+
function decodeQuantized(desc, bytes, it) {
|
|
912
|
+
const readWire = decode[desc.wire];
|
|
913
|
+
return dequantize(desc, readWire(bytes, it));
|
|
914
|
+
}
|
|
915
|
+
|
|
815
916
|
class TypeContext {
|
|
816
917
|
types = {};
|
|
817
918
|
schemas = new Map();
|
|
@@ -914,6 +1015,11 @@
|
|
|
914
1015
|
if (typeof (fieldType) === "string") {
|
|
915
1016
|
continue;
|
|
916
1017
|
}
|
|
1018
|
+
// Quantized fields are scalar — their object `type` only carries the
|
|
1019
|
+
// descriptor, there's no child Schema to discover.
|
|
1020
|
+
if (isQuantizedType(fieldType)) {
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
917
1023
|
if (typeof (fieldType) === "function") {
|
|
918
1024
|
this.discoverTypes(fieldType, klass, index, parentHasViewTag || fieldHasViewTag);
|
|
919
1025
|
}
|
|
@@ -982,6 +1088,14 @@
|
|
|
982
1088
|
if (Array.isArray(type)) {
|
|
983
1089
|
return { array: getNormalizedType(type[0]) };
|
|
984
1090
|
}
|
|
1091
|
+
else if (isQuantizedType(type)) {
|
|
1092
|
+
// `{ quantized: ... }` — a scalar wire type, NOT a collection/ref. Resolve
|
|
1093
|
+
// raw options (the `@type({quantized:{min,max}})` path) once; an already-
|
|
1094
|
+
// resolved descriptor (the `t.quantized()` builder path) passes through.
|
|
1095
|
+
return (typeof type.quantized.wire === "string")
|
|
1096
|
+
? type
|
|
1097
|
+
: { quantized: resolveQuantize(type.quantized) };
|
|
1098
|
+
}
|
|
985
1099
|
else if (typeof (type['type']) !== "undefined") {
|
|
986
1100
|
return type['type'];
|
|
987
1101
|
}
|
|
@@ -1060,8 +1174,9 @@
|
|
|
1060
1174
|
enumerable: false,
|
|
1061
1175
|
configurable: true,
|
|
1062
1176
|
});
|
|
1063
|
-
// if child Ref/complex type, add to -4
|
|
1064
|
-
|
|
1177
|
+
// if child Ref/complex type, add to -4. Quantized fields are scalar (their
|
|
1178
|
+
// `type` is an object only to carry the descriptor) — not refs, so skip them.
|
|
1179
|
+
if (typeof (metadata[index].type) !== "string" && !isQuantizedType(metadata[index].type)) {
|
|
1065
1180
|
if (metadata[$refTypeFieldIndexes] === undefined) {
|
|
1066
1181
|
Object.defineProperty(metadata, $refTypeFieldIndexes, {
|
|
1067
1182
|
value: [],
|
|
@@ -1123,10 +1238,25 @@
|
|
|
1123
1238
|
});
|
|
1124
1239
|
}
|
|
1125
1240
|
metadata[$viewFieldIndexes].push(index);
|
|
1126
|
-
|
|
1127
|
-
|
|
1241
|
+
// Populate $fieldIndexesByViewTag: for a bitmask tag, register the field
|
|
1242
|
+
// index under each individual set bit so that view.add(obj, Tag.ONE) finds
|
|
1243
|
+
// fields tagged @view(Tag.ONE|Tag.TWO).
|
|
1244
|
+
// Negative tags (i.e. DEFAULT_VIEW_TAG = -1) are stored as-is.
|
|
1245
|
+
if (tag < 0) {
|
|
1246
|
+
if (!metadata[$fieldIndexesByViewTag][tag]) {
|
|
1247
|
+
metadata[$fieldIndexesByViewTag][tag] = [];
|
|
1248
|
+
}
|
|
1249
|
+
metadata[$fieldIndexesByViewTag][tag].push(index);
|
|
1250
|
+
}
|
|
1251
|
+
else {
|
|
1252
|
+
for (let bits = tag; bits > 0; bits &= bits - 1) {
|
|
1253
|
+
const bit = bits & (-bits); // isolate lowest set bit
|
|
1254
|
+
if (!metadata[$fieldIndexesByViewTag][bit]) {
|
|
1255
|
+
metadata[$fieldIndexesByViewTag][bit] = [];
|
|
1256
|
+
}
|
|
1257
|
+
metadata[$fieldIndexesByViewTag][bit].push(index);
|
|
1258
|
+
}
|
|
1128
1259
|
}
|
|
1129
|
-
metadata[$fieldIndexesByViewTag][tag].push(index);
|
|
1130
1260
|
},
|
|
1131
1261
|
setUnreliable(metadata, fieldName) {
|
|
1132
1262
|
const index = metadata[fieldName];
|
|
@@ -1232,8 +1362,8 @@
|
|
|
1232
1362
|
if (metadata[$descriptors][fieldName]) {
|
|
1233
1363
|
Object.defineProperty(target.prototype, fieldName, metadata[$descriptors][fieldName]);
|
|
1234
1364
|
}
|
|
1235
|
-
// Pre-compute encoder function for primitive types.
|
|
1236
|
-
if (typeof normalized === "string") {
|
|
1365
|
+
// Pre-compute encoder function for primitive + quantized types.
|
|
1366
|
+
if (typeof normalized === "string" || isQuantizedType(normalized)) {
|
|
1237
1367
|
if (!metadata[$encoders]) {
|
|
1238
1368
|
Object.defineProperty(metadata, $encoders, {
|
|
1239
1369
|
value: [],
|
|
@@ -1242,7 +1372,9 @@
|
|
|
1242
1372
|
writable: true,
|
|
1243
1373
|
});
|
|
1244
1374
|
}
|
|
1245
|
-
metadata[$encoders][fieldIndex] =
|
|
1375
|
+
metadata[$encoders][fieldIndex] = (typeof normalized === "string")
|
|
1376
|
+
? encode[normalized]
|
|
1377
|
+
: makeQuantizedEncoder(normalized.quantized);
|
|
1246
1378
|
}
|
|
1247
1379
|
},
|
|
1248
1380
|
setFields(target, fields) {
|
|
@@ -1888,6 +2020,8 @@
|
|
|
1888
2020
|
const ref = tree.refTarget;
|
|
1889
2021
|
if (ref[$childType] !== undefined) {
|
|
1890
2022
|
// Collection inheriting @transient from parent field: skip entirely.
|
|
2023
|
+
// The resync sweep (decoder/Resync.ts) relies on this: a collection
|
|
2024
|
+
// absent from full-sync output is never pruned client-side.
|
|
1891
2025
|
if (tree.isTransient)
|
|
1892
2026
|
return;
|
|
1893
2027
|
// Collection types: dispatch by shape.
|
|
@@ -1917,6 +2051,8 @@
|
|
|
1917
2051
|
// Schema: walk declared fields. `null` is treated as absent —
|
|
1918
2052
|
// the setter records a DELETE when a field is set to null or
|
|
1919
2053
|
// undefined, so it should not appear in full-sync output.
|
|
2054
|
+
// (@transient skips below matter to the resync sweep — see
|
|
2055
|
+
// decoder/Resync.ts: absent-from-payload means never pruned.)
|
|
1920
2056
|
//
|
|
1921
2057
|
// Read names from the per-class descriptor's parallel array —
|
|
1922
2058
|
// saves the `metadata[i]` (per-field obj) + `.name` chain on
|
|
@@ -2180,6 +2316,12 @@
|
|
|
2180
2316
|
}
|
|
2181
2317
|
}
|
|
2182
2318
|
|
|
2319
|
+
/**
|
|
2320
|
+
* Tree-attachment helpers: setRoot / setParent + child-iteration recursion.
|
|
2321
|
+
* Hot path: every new Schema/Collection instance attached to the root
|
|
2322
|
+
* goes through here, which is why the recursive walk uses a hoisted
|
|
2323
|
+
* callback + ctx-pool instead of per-call closures.
|
|
2324
|
+
*/
|
|
2183
2325
|
function setRoot(tree, root) {
|
|
2184
2326
|
tree.root = root;
|
|
2185
2327
|
const isNewChangeTree = root.add(tree);
|
|
@@ -2232,30 +2374,10 @@
|
|
|
2232
2374
|
}
|
|
2233
2375
|
}
|
|
2234
2376
|
function forEachChild(tree, callback) {
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
if (typeof (tree.ref[$childType]) !== "string") {
|
|
2240
|
-
// MapSchema / ArraySchema, etc.
|
|
2241
|
-
for (const [key, value] of tree.ref.entries()) {
|
|
2242
|
-
if (!value) {
|
|
2243
|
-
continue;
|
|
2244
|
-
} // sparse arrays can have undefined values
|
|
2245
|
-
callback(value[$changes], tree.ref._collectionIndexes?.[key] ?? key);
|
|
2246
|
-
}
|
|
2247
|
-
}
|
|
2248
|
-
}
|
|
2249
|
-
else {
|
|
2250
|
-
const names = tree.encDescriptor.names;
|
|
2251
|
-
for (const index of tree.metadata?.[$refTypeFieldIndexes] ?? []) {
|
|
2252
|
-
const value = tree.ref[names[index]];
|
|
2253
|
-
if (!value) {
|
|
2254
|
-
continue;
|
|
2255
|
-
}
|
|
2256
|
-
callback(value[$changes], index);
|
|
2257
|
-
}
|
|
2258
|
-
}
|
|
2377
|
+
forEachChildWithCtx(tree, callback, _forEachChildTrampoline);
|
|
2378
|
+
}
|
|
2379
|
+
function _forEachChildTrampoline(cb, change, at) {
|
|
2380
|
+
cb(change, at);
|
|
2259
2381
|
}
|
|
2260
2382
|
/**
|
|
2261
2383
|
* Closure-free variant of {@link forEachChild}. Hot setRoot / setParent
|
|
@@ -2274,12 +2396,35 @@
|
|
|
2274
2396
|
const ref = tree.refTarget;
|
|
2275
2397
|
if (ref[$childType]) {
|
|
2276
2398
|
if (typeof ref[$childType] !== "string") {
|
|
2277
|
-
const
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2399
|
+
const items = ref.items;
|
|
2400
|
+
if (items !== undefined) {
|
|
2401
|
+
// ArraySchema (raw target): dense index loop — the previous
|
|
2402
|
+
// `for..of entries()` allocated an iterator + a [key, value]
|
|
2403
|
+
// pair array per child (top-10 allocation site in the
|
|
2404
|
+
// stateview and deep-nested heap profiles).
|
|
2405
|
+
for (let i = 0, len = items.length; i < len; i++) {
|
|
2406
|
+
const value = items[i];
|
|
2407
|
+
if (!value) {
|
|
2408
|
+
continue;
|
|
2409
|
+
} // sparse arrays can have undefined values
|
|
2410
|
+
callback(ctx, value[$changes], i);
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
else {
|
|
2414
|
+
// Map-backed collections (MapSchema/SetSchema/CollectionSchema/
|
|
2415
|
+
// StreamSchema all store `$items: Map`): keys() loop skips the
|
|
2416
|
+
// per-child [key, value] pair arrays of entries(), with no
|
|
2417
|
+
// closure either (a forEach closure showed up as a GC
|
|
2418
|
+
// regression on the construct bench).
|
|
2419
|
+
const $items = ref.$items;
|
|
2420
|
+
const collectionIndexes = ref._collectionIndexes;
|
|
2421
|
+
for (const key of $items.keys()) {
|
|
2422
|
+
const value = $items.get(key);
|
|
2423
|
+
if (!value) {
|
|
2424
|
+
continue;
|
|
2425
|
+
}
|
|
2426
|
+
callback(ctx, value[$changes], collectionIndexes?.[key] ?? key);
|
|
2281
2427
|
}
|
|
2282
|
-
callback(ctx, value[$changes], collectionIndexes?.[key] ?? key);
|
|
2283
2428
|
}
|
|
2284
2429
|
}
|
|
2285
2430
|
}
|
|
@@ -2364,6 +2509,12 @@
|
|
|
2364
2509
|
// `t.map(X).stream()` / `t.set(X).stream()` route through the same
|
|
2365
2510
|
// emission machinery.
|
|
2366
2511
|
const IS_STREAM_COLLECTION = 64;
|
|
2512
|
+
// Set by `recycle()` (Schema.reset / pooling): the tree's values are live
|
|
2513
|
+
// but its dirty buckets were cleared, so `Root.add` must re-stage every
|
|
2514
|
+
// populated field as ADD when the instance re-enters a tree. Without this,
|
|
2515
|
+
// only fields assigned after `pool.acquire()` would reach the wire — the
|
|
2516
|
+
// retained ones (constructor-initialized children) would never be encoded.
|
|
2517
|
+
const NEEDS_RESTAGE = 128;
|
|
2367
2518
|
/**
|
|
2368
2519
|
* Flags a child inherits from its parent's own transitive state via
|
|
2369
2520
|
* `checkInheritedFlags`. Read as a bitwise mask so the inheritance step
|
|
@@ -2476,6 +2627,8 @@
|
|
|
2476
2627
|
set isStatic(v) { this.flags = v ? (this.flags | IS_STATIC) : (this.flags & ~IS_STATIC); }
|
|
2477
2628
|
get isStreamCollection() { return (this.flags & IS_STREAM_COLLECTION) !== 0; }
|
|
2478
2629
|
set isStreamCollection(v) { this.flags = v ? (this.flags | IS_STREAM_COLLECTION) : (this.flags & ~IS_STREAM_COLLECTION); }
|
|
2630
|
+
get needsRestage() { return (this.flags & NEEDS_RESTAGE) !== 0; }
|
|
2631
|
+
set needsRestage(v) { this.flags = v ? (this.flags | NEEDS_RESTAGE) : (this.flags & ~NEEDS_RESTAGE); }
|
|
2479
2632
|
// True iff tree inherits `isFiltered` OR its Schema class declares any
|
|
2480
2633
|
// @view-tagged fields. StateView.addParentOf uses this to decide whether
|
|
2481
2634
|
// a parent must be included in a view's bootstrap. Reads the class-level
|
|
@@ -2759,6 +2912,48 @@
|
|
|
2759
2912
|
if (this.collPureOps !== undefined)
|
|
2760
2913
|
this.collPureOps.length = 0;
|
|
2761
2914
|
}
|
|
2915
|
+
/**
|
|
2916
|
+
* Full reset to construction defaults so the owning ref can be returned to
|
|
2917
|
+
* a pool and reused for a different logical entity (see encoder/Pool.ts +
|
|
2918
|
+
* Schema.reset). Unlike `reset()` / `endEncode()` (which only clear the
|
|
2919
|
+
* dirty bucket for the next encode), this also drops parent links, queue
|
|
2920
|
+
* nodes and per-view bitmaps, and re-arms IS_NEW.
|
|
2921
|
+
*
|
|
2922
|
+
* Precondition: the tree must already be detached from the encoder
|
|
2923
|
+
* (`root === undefined`) — i.e. the ref was removed from its parent
|
|
2924
|
+
* collection/field, which `Root.remove` does before this runs.
|
|
2925
|
+
*/
|
|
2926
|
+
recycle() {
|
|
2927
|
+
if (this.root !== undefined) {
|
|
2928
|
+
throw new Error(`@colyseus/schema: cannot recycle an attached ChangeTree ` +
|
|
2929
|
+
`(${this.ref?.constructor?.name}). Remove the instance from its ` +
|
|
2930
|
+
`parent collection before releasing it to a pool.`);
|
|
2931
|
+
}
|
|
2932
|
+
// dirty/ops buckets (Schema: dirtyLow/High + ops; Collection: collDirty/collPureOps)
|
|
2933
|
+
this.reset();
|
|
2934
|
+
// keep the recorder object allocated (re-alloc is the cost we avoid), clear contents
|
|
2935
|
+
this.unreliableRecorder?.reset();
|
|
2936
|
+
// back to a freshly-constructed tree: IS_NEW, no inherited flags
|
|
2937
|
+
// (FILTERED/TRANSIENT/STATIC/STREAM are re-derived on the next setParent).
|
|
2938
|
+
// NEEDS_RESTAGE makes the next Root.add re-stage retained field values.
|
|
2939
|
+
this.flags = IS_NEW | NEEDS_RESTAGE;
|
|
2940
|
+
this._fullSyncGen = 0;
|
|
2941
|
+
// drop parent links — Root.remove clears `root` and the CHILDREN's
|
|
2942
|
+
// parent links, but leaves this tree's own parentRef dangling.
|
|
2943
|
+
this.parentRef = undefined;
|
|
2944
|
+
this._parentIndex = undefined;
|
|
2945
|
+
this.extraParents = undefined;
|
|
2946
|
+
// queue nodes (already nulled by Root.remove's queue removal; defensive)
|
|
2947
|
+
this.changesNode = undefined;
|
|
2948
|
+
this.unreliableChangesNode = undefined;
|
|
2949
|
+
this.paused = false;
|
|
2950
|
+
// per-view visibility lives on the tree (NOT keyed by refId), so a
|
|
2951
|
+
// recycled tree must not inherit its previous life's view membership.
|
|
2952
|
+
this.visibleViews = undefined;
|
|
2953
|
+
this.invisibleViews = undefined;
|
|
2954
|
+
this.tagViews = undefined;
|
|
2955
|
+
this.subscribedViews = undefined;
|
|
2956
|
+
}
|
|
2762
2957
|
shift(shiftIndex) {
|
|
2763
2958
|
if (this._isSchema)
|
|
2764
2959
|
throw new Error("ChangeTree (Schema): shift is not supported");
|
|
@@ -3214,6 +3409,165 @@
|
|
|
3214
3409
|
encodeValue(encoder, bytes, type, value, operation, it);
|
|
3215
3410
|
};
|
|
3216
3411
|
|
|
3412
|
+
/**
|
|
3413
|
+
* Resync ("full-snapshot reconciliation") support for {@link Decoder.decodeResync}.
|
|
3414
|
+
*
|
|
3415
|
+
* A rejoin snapshot is authoritative for everything it contains — but the
|
|
3416
|
+
* plain decode path is additive: entries deleted (or hidden by a view)
|
|
3417
|
+
* while the client was off the wire survive as ghosts. This module owns the
|
|
3418
|
+
* generic reconciliation algorithm:
|
|
3419
|
+
*
|
|
3420
|
+
* - during the decode walk, the collection DecodeOperation functions report
|
|
3421
|
+
* every entry the payload touches ({@link resyncRecordVisit}) and every
|
|
3422
|
+
* collection that appears at all ({@link resyncMarkPresent});
|
|
3423
|
+
* - after the walk, {@link resyncSweep} removes whatever was never reported,
|
|
3424
|
+
* through the same DELETE bookkeeping the regular decode path uses
|
|
3425
|
+
* (DataChange DELETE → onRemove; removeRef → GC).
|
|
3426
|
+
*
|
|
3427
|
+
* Storage-specific pruning (journal upkeep, array compaction, stream
|
|
3428
|
+
* exemption) lives on each collection class as `[$resyncPrune]` — declared
|
|
3429
|
+
* on the `Collection` interface, so every collection kind must state its
|
|
3430
|
+
* own sweep semantics.
|
|
3431
|
+
*
|
|
3432
|
+
* All entry points are guarded by `decoder.resyncVisited !== null` at the
|
|
3433
|
+
* call sites — the normal decode path never pays for any of this.
|
|
3434
|
+
*/
|
|
3435
|
+
/**
|
|
3436
|
+
* Record that the current structure's entry at `identity` (map string key /
|
|
3437
|
+
* element index) appeared in the payload — even when its value is unchanged
|
|
3438
|
+
* (`allChanges` cannot serve as this record: its pushes are guarded by
|
|
3439
|
+
* `previousValue !== value`, so unchanged entries would look unvisited).
|
|
3440
|
+
*
|
|
3441
|
+
* Also releases a replaced occupant: full-sync emits plain ADD (never
|
|
3442
|
+
* DELETE_AND_ADD), so an entry whose instance changed while this client was
|
|
3443
|
+
* off the wire would otherwise leak its previous ref (no onRemove, never
|
|
3444
|
+
* GC'd). This release is correct ONLY under a full snapshot — a live patch's
|
|
3445
|
+
* plain ADD over a different instance can be a positional rewrite (array
|
|
3446
|
+
* shift/unshift) where the occupant *moved* and is still alive; a snapshot
|
|
3447
|
+
* re-adds moved instances elsewhere, so the refcounts balance.
|
|
3448
|
+
*/
|
|
3449
|
+
function resyncTouchEntry(decoder, ref, operation, identity, previousValue, value, allChanges) {
|
|
3450
|
+
const visited = decoder.resyncVisited;
|
|
3451
|
+
let set = visited.get(decoder.currentRefId);
|
|
3452
|
+
if (set === undefined) {
|
|
3453
|
+
visited.set(decoder.currentRefId, set = new Set());
|
|
3454
|
+
}
|
|
3455
|
+
set.add(identity);
|
|
3456
|
+
if (previousValue !== undefined && operation === exports.OPERATION.ADD && previousValue !== value) {
|
|
3457
|
+
const previousRefId = previousValue[$refId];
|
|
3458
|
+
if (previousRefId !== undefined) {
|
|
3459
|
+
decoder.root.removeRef(previousRefId);
|
|
3460
|
+
allChanges?.push({
|
|
3461
|
+
ref,
|
|
3462
|
+
refId: decoder.currentRefId,
|
|
3463
|
+
op: exports.OPERATION.DELETE,
|
|
3464
|
+
dynamicIndex: identity,
|
|
3465
|
+
value: undefined,
|
|
3466
|
+
previousValue,
|
|
3467
|
+
});
|
|
3468
|
+
}
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
/**
|
|
3472
|
+
* Mark a collection as present in the payload — even with zero entries.
|
|
3473
|
+
* The sweep only prunes collections reported here: absence means "not part
|
|
3474
|
+
* of full-sync" (@transient, view-invisible), where pruning would destroy
|
|
3475
|
+
* live data. Reflected clients have no @transient metadata, so payload
|
|
3476
|
+
* presence is the only reliable signal.
|
|
3477
|
+
*/
|
|
3478
|
+
function resyncMarkPresent(decoder, refId) {
|
|
3479
|
+
const visited = decoder.resyncVisited;
|
|
3480
|
+
if (!visited.has(refId)) {
|
|
3481
|
+
visited.set(refId, new Set());
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
/**
|
|
3485
|
+
* Post-decode phase of {@link Decoder.decodeResync}: remove every collection
|
|
3486
|
+
* entry the snapshot did not visit.
|
|
3487
|
+
*
|
|
3488
|
+
* Walks the tree from the root — NOT `root.refs` — for three reasons:
|
|
3489
|
+
* `@transient` fields are never part of a snapshot and must be left alone;
|
|
3490
|
+
* entries of subtrees removed by the sweep itself are left to the GC's
|
|
3491
|
+
* transitive walk (sweeping them directly would double-decrement shared
|
|
3492
|
+
* children); and collections the snapshot never mentions (emptied
|
|
3493
|
+
* server-side) are still reachable and get pruned.
|
|
3494
|
+
*/
|
|
3495
|
+
function resyncSweep(decoder, allChanges) {
|
|
3496
|
+
if (decoder.resyncDamaged) {
|
|
3497
|
+
console.warn("@colyseus/schema: resync sweep skipped — parts of the payload could not be decoded. " +
|
|
3498
|
+
"Stale entries may persist until the next resync.");
|
|
3499
|
+
return;
|
|
3500
|
+
}
|
|
3501
|
+
sweepSchema(decoder, decoder.state, new Set(), allChanges);
|
|
3502
|
+
}
|
|
3503
|
+
function sweepSchema(decoder, ref, seen, allChanges) {
|
|
3504
|
+
const refId = ref[$refId];
|
|
3505
|
+
if (refId === undefined || seen.has(refId)) {
|
|
3506
|
+
return;
|
|
3507
|
+
}
|
|
3508
|
+
seen.add(refId);
|
|
3509
|
+
const metadata = ref.constructor[Symbol.metadata];
|
|
3510
|
+
const refIndexes = metadata?.[$refTypeFieldIndexes];
|
|
3511
|
+
if (refIndexes === undefined) {
|
|
3512
|
+
return;
|
|
3513
|
+
}
|
|
3514
|
+
const transient = metadata[$transientFieldIndexes];
|
|
3515
|
+
for (let i = 0; i < refIndexes.length; i++) {
|
|
3516
|
+
const fieldIndex = refIndexes[i];
|
|
3517
|
+
// @transient fields are never in a snapshot — leave them alone.
|
|
3518
|
+
if (transient !== undefined && transient.includes(fieldIndex)) {
|
|
3519
|
+
continue;
|
|
3520
|
+
}
|
|
3521
|
+
const field = metadata[fieldIndex];
|
|
3522
|
+
const value = ref[field.name];
|
|
3523
|
+
if (!value) {
|
|
3524
|
+
continue;
|
|
3525
|
+
}
|
|
3526
|
+
if (Schema.is(field.type)) {
|
|
3527
|
+
sweepSchema(decoder, value, seen, allChanges);
|
|
3528
|
+
}
|
|
3529
|
+
else {
|
|
3530
|
+
sweepCollection(decoder, value, seen, allChanges);
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
}
|
|
3534
|
+
function sweepCollection(decoder, coll, seen, allChanges) {
|
|
3535
|
+
const tgt = coll[$proxyTarget] ?? coll;
|
|
3536
|
+
const refId = tgt[$refId];
|
|
3537
|
+
if (refId === undefined || seen.has(refId)) {
|
|
3538
|
+
return;
|
|
3539
|
+
}
|
|
3540
|
+
seen.add(refId);
|
|
3541
|
+
// `undefined` = the collection never appeared in the payload at all
|
|
3542
|
+
// (not even as its parent's field op) — it is not part of full-sync
|
|
3543
|
+
// (@transient, view-invisible) and must be left alone. An empty Set
|
|
3544
|
+
// means "present with zero entries" → prune everything.
|
|
3545
|
+
const visited = decoder.resyncVisited.get(refId);
|
|
3546
|
+
if (visited === undefined) {
|
|
3547
|
+
return;
|
|
3548
|
+
}
|
|
3549
|
+
const $root = decoder.root;
|
|
3550
|
+
tgt[$resyncPrune](visited, (value, identity) => {
|
|
3551
|
+
allChanges?.push({
|
|
3552
|
+
ref: coll,
|
|
3553
|
+
refId,
|
|
3554
|
+
op: exports.OPERATION.DELETE,
|
|
3555
|
+
dynamicIndex: identity,
|
|
3556
|
+
value: undefined,
|
|
3557
|
+
previousValue: value,
|
|
3558
|
+
});
|
|
3559
|
+
const childRefId = value?.[$refId];
|
|
3560
|
+
if (childRefId !== undefined) {
|
|
3561
|
+
$root.removeRef(childRefId);
|
|
3562
|
+
}
|
|
3563
|
+
}, (value) => {
|
|
3564
|
+
// recurse so nested collections of retained entries sweep too
|
|
3565
|
+
if (Schema.isSchema(value)) {
|
|
3566
|
+
sweepSchema(decoder, value, seen, allChanges);
|
|
3567
|
+
}
|
|
3568
|
+
});
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3217
3571
|
const DEFINITION_MISMATCH = -1;
|
|
3218
3572
|
/**
|
|
3219
3573
|
* Collection-kind discriminator declared on each collection class as
|
|
@@ -3271,6 +3625,12 @@
|
|
|
3271
3625
|
//
|
|
3272
3626
|
value = decode[type](bytes, it);
|
|
3273
3627
|
}
|
|
3628
|
+
else if (isQuantizedType(type)) {
|
|
3629
|
+
// Quantized scalar: read the unsigned-int wire value, then dequantize so
|
|
3630
|
+
// the instance holds (and yields) the wire-exact float — `decodeSchema-
|
|
3631
|
+
// Operation` writes it through the snapping setter (idempotent here).
|
|
3632
|
+
value = decodeQuantized(type.quantized, bytes, it);
|
|
3633
|
+
}
|
|
3274
3634
|
else if (Schema.is(type)) {
|
|
3275
3635
|
const refId = decode.number(bytes, it);
|
|
3276
3636
|
value = $root.refs.get(refId);
|
|
@@ -3287,6 +3647,10 @@
|
|
|
3287
3647
|
else {
|
|
3288
3648
|
const typeDef = getType(Object.keys(type)[0]);
|
|
3289
3649
|
const refId = decode.number(bytes, it);
|
|
3650
|
+
// resync bookkeeping — see Resync.ts
|
|
3651
|
+
if (decoder.resyncVisited !== null) {
|
|
3652
|
+
resyncMarkPresent(decoder, refId);
|
|
3653
|
+
}
|
|
3290
3654
|
// `initializeForDecoder` is a static on every registered collection
|
|
3291
3655
|
// class — it does `Object.create(Class.prototype)` + the class-
|
|
3292
3656
|
// field init + assigns an untracked `$changes` directly. Keeps
|
|
@@ -3299,17 +3663,25 @@
|
|
|
3299
3663
|
if (previousValue) {
|
|
3300
3664
|
let previousRefId = previousValue[$refId];
|
|
3301
3665
|
if (previousRefId !== undefined && refId !== previousRefId) {
|
|
3666
|
+
// Collection field replaced by a different instance.
|
|
3302
3667
|
//
|
|
3303
|
-
//
|
|
3304
|
-
//
|
|
3668
|
+
// Don't decrement children here: GC (`garbageCollectDeletedRefs`)
|
|
3669
|
+
// removes them once the previous collection's refId hits zero.
|
|
3670
|
+
// Doing it here too would double-decrement a *shared* child and
|
|
3671
|
+
// drop it while still referenced ("refId not found").
|
|
3672
|
+
if ((operation & exports.OPERATION.DELETE) !== exports.OPERATION.DELETE) {
|
|
3673
|
+
// Replacement not tagged DELETE (e.g. pending ADD not upgraded
|
|
3674
|
+
// to DELETE_AND_ADD), so the previous refId wasn't decremented
|
|
3675
|
+
// above. Release it here, else it never gets GC'd (leak).
|
|
3676
|
+
$root.removeRef(previousRefId);
|
|
3677
|
+
}
|
|
3678
|
+
// enqueue onRemove callbacks for the previous collection's children.
|
|
3305
3679
|
const entries = previousValue.entries();
|
|
3306
3680
|
let iter;
|
|
3307
3681
|
while ((iter = entries.next()) && !iter.done) {
|
|
3308
3682
|
const [key, value] = iter.value;
|
|
3309
|
-
// if value is a schema, remove its reference
|
|
3310
3683
|
if (typeof (value) === "object") {
|
|
3311
3684
|
previousRefId = value[$refId];
|
|
3312
|
-
$root.removeRef(previousRefId);
|
|
3313
3685
|
}
|
|
3314
3686
|
allChanges?.push({
|
|
3315
3687
|
ref: previousValue,
|
|
@@ -3401,6 +3773,10 @@
|
|
|
3401
3773
|
}
|
|
3402
3774
|
const previousValue = tgt[$getByIndex](index);
|
|
3403
3775
|
const value = decodeValue(decoder, operation, ref, index, previousValue, type, bytes, it, allChanges);
|
|
3776
|
+
// resync bookkeeping — see Resync.ts
|
|
3777
|
+
if (decoder.resyncVisited !== null) {
|
|
3778
|
+
resyncTouchEntry(decoder, ref, operation, dynamicIndex, previousValue, value, allChanges);
|
|
3779
|
+
}
|
|
3404
3780
|
if (value !== null && value !== undefined) {
|
|
3405
3781
|
switch (kind) {
|
|
3406
3782
|
case CollectionKind.Map:
|
|
@@ -3477,6 +3853,16 @@
|
|
|
3477
3853
|
// TODO: refactor here, try to follow same flow as below
|
|
3478
3854
|
const refId = decode.number(bytes, it);
|
|
3479
3855
|
const previousValue = decoder.root.refs.get(refId);
|
|
3856
|
+
// Decrement the removed child's ref-count so it can be garbage
|
|
3857
|
+
// collected — this refId-based branch returns early and never reaches
|
|
3858
|
+
// decodeValue(), so it must do the same DELETE bookkeeping itself.
|
|
3859
|
+
// Without this the refId leaks; a later encoder reuse then aliases a
|
|
3860
|
+
// different type → "field not defined" / "definition mismatch"
|
|
3861
|
+
// (surfaces under StateView when a filtered ArraySchema element is
|
|
3862
|
+
// spliced while its parent moves through view membership churn).
|
|
3863
|
+
if (previousValue !== undefined) {
|
|
3864
|
+
decoder.root.removeRef(refId);
|
|
3865
|
+
}
|
|
3480
3866
|
index = tgt.findIndex((value) => value === previousValue);
|
|
3481
3867
|
tgt[$deleteByIndex](index);
|
|
3482
3868
|
allChanges?.push({
|
|
@@ -3511,6 +3897,12 @@
|
|
|
3511
3897
|
// maintain). The decoder's authoritative state is `items`.
|
|
3512
3898
|
const previousValue = tgt.items[index];
|
|
3513
3899
|
const value = decodeValue(decoder, operation, ref, index, previousValue, type, bytes, it, allChanges);
|
|
3900
|
+
// resync bookkeeping — see Resync.ts. `index` is the RESOLVED position
|
|
3901
|
+
// (ADD_BY_REFID lands on the instance's current client-side index), so
|
|
3902
|
+
// visited entries form a sparse set.
|
|
3903
|
+
if (decoder.resyncVisited !== null) {
|
|
3904
|
+
resyncTouchEntry(decoder, ref, operation, index, previousValue, value, allChanges);
|
|
3905
|
+
}
|
|
3514
3906
|
if (value !== null && value !== undefined &&
|
|
3515
3907
|
value !== previousValue // avoid setting same value twice (if index === 0 it will result in a "unshift" for ArraySchema)
|
|
3516
3908
|
) {
|
|
@@ -3636,6 +4028,8 @@
|
|
|
3636
4028
|
tmpItems = [];
|
|
3637
4029
|
deletedIndexes = [];
|
|
3638
4030
|
isMovingItems = false;
|
|
4031
|
+
/** Decode-side: `items` has holes (delete or gap-write) — `$onDecodeEnd` must compact. */
|
|
4032
|
+
_needsCompaction = false;
|
|
3639
4033
|
static [$encoder] = encodeArray;
|
|
3640
4034
|
static [$decoder] = decodeArray;
|
|
3641
4035
|
/** Integer tag read by `decodeKeyValueOperation` — see `CollectionKind`. */
|
|
@@ -3650,9 +4044,11 @@
|
|
|
3650
4044
|
* - Then, the encoder iterates over all "owned" properties per instance and encodes them.
|
|
3651
4045
|
*/
|
|
3652
4046
|
static [$filter](ref, index, view) {
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
4047
|
+
if (!view)
|
|
4048
|
+
return true; // must stay first — encodeAll hits this per element
|
|
4049
|
+
const self = ref[$proxyTarget] ?? ref; // ref arrives proxied — skip traps below
|
|
4050
|
+
return (typeof (self[$childType]) === "string" ||
|
|
4051
|
+
view.isChangeTreeVisible(self['tmpItems'][index]?.[$changes]));
|
|
3656
4052
|
}
|
|
3657
4053
|
static is(type) {
|
|
3658
4054
|
return (
|
|
@@ -3696,6 +4092,7 @@
|
|
|
3696
4092
|
// staged-snapshot path in `$getByIndex`, `$onEncodeEnd`, etc.). The
|
|
3697
4093
|
// decoder reads from `items` directly and never maintains them.
|
|
3698
4094
|
self.isMovingItems = false;
|
|
4095
|
+
self._needsCompaction = false;
|
|
3699
4096
|
self[$childType] = undefined;
|
|
3700
4097
|
self[$proxyTarget] = self;
|
|
3701
4098
|
const proxy = new Proxy(self, ARRAY_PROXY_HANDLER);
|
|
@@ -3823,6 +4220,9 @@
|
|
|
3823
4220
|
this.items[index] = value;
|
|
3824
4221
|
}
|
|
3825
4222
|
else {
|
|
4223
|
+
if (index > this.items.length) {
|
|
4224
|
+
this._needsCompaction = true; // gap-write (filtered/out-of-order ADD) leaves holes
|
|
4225
|
+
}
|
|
3826
4226
|
this.items[index] = value;
|
|
3827
4227
|
}
|
|
3828
4228
|
}
|
|
@@ -3843,6 +4243,27 @@
|
|
|
3843
4243
|
self.items.length = 0;
|
|
3844
4244
|
self.tmpItems.length = 0;
|
|
3845
4245
|
}
|
|
4246
|
+
/**
|
|
4247
|
+
* Pool reset: empty this array and recycle its ChangeTree WITHOUT recording
|
|
4248
|
+
* any wire op (the parent field's ADD/DELETE owns the wire). Recurses into
|
|
4249
|
+
* ref-type children. Called by Schema.reset when a pooled entity has an
|
|
4250
|
+
* array field. The instance must already be detached from the encoder.
|
|
4251
|
+
*/
|
|
4252
|
+
[$reset]() {
|
|
4253
|
+
const self = this[$proxyTarget] ?? this;
|
|
4254
|
+
const changeTree = self[$changes];
|
|
4255
|
+
if (changeTree.isStreamCollection) {
|
|
4256
|
+
throw new Error(`@colyseus/schema: cannot reset a streamed ArraySchema (pooling not supported).`);
|
|
4257
|
+
}
|
|
4258
|
+
const items = self.items;
|
|
4259
|
+
for (let i = 0; i < items.length; i++)
|
|
4260
|
+
items[i]?.[$reset]?.();
|
|
4261
|
+
self.items.length = 0;
|
|
4262
|
+
self.tmpItems.length = 0;
|
|
4263
|
+
self.deletedIndexes.length = 0;
|
|
4264
|
+
changeTree.recycle();
|
|
4265
|
+
self[$refId] = undefined; // assign (not delete) to avoid V8 dictionary-mode deopt
|
|
4266
|
+
}
|
|
3846
4267
|
/**
|
|
3847
4268
|
* Combines two or more arrays.
|
|
3848
4269
|
* @param items Additional items to add to the end of array1.
|
|
@@ -4242,21 +4663,51 @@
|
|
|
4242
4663
|
* `$deleteByIndex` below.
|
|
4243
4664
|
*/
|
|
4244
4665
|
[$getByIndex](index, isEncodeAll = false) {
|
|
4666
|
+
const self = this[$proxyTarget] ?? this; // called via Proxy — one trap here beats one per field read
|
|
4245
4667
|
return (isEncodeAll)
|
|
4246
|
-
?
|
|
4247
|
-
:
|
|
4248
|
-
?
|
|
4249
|
-
:
|
|
4668
|
+
? self.items[index]
|
|
4669
|
+
: self.deletedIndexes[index]
|
|
4670
|
+
? self.items[index]
|
|
4671
|
+
: self.tmpItems[index] || self.items[index];
|
|
4250
4672
|
}
|
|
4251
4673
|
[$deleteByIndex](index) {
|
|
4252
|
-
this
|
|
4674
|
+
const self = this[$proxyTarget] ?? this;
|
|
4675
|
+
self.items[index] = undefined;
|
|
4676
|
+
self._needsCompaction = true;
|
|
4253
4677
|
}
|
|
4254
4678
|
[$onEncodeEnd]() {
|
|
4255
|
-
|
|
4256
|
-
|
|
4679
|
+
const self = this[$proxyTarget] ?? this;
|
|
4680
|
+
self.tmpItems = self.items.slice();
|
|
4681
|
+
self.deletedIndexes.length = 0;
|
|
4257
4682
|
}
|
|
4258
4683
|
[$onDecodeEnd]() {
|
|
4259
|
-
|
|
4684
|
+
const self = this[$proxyTarget] ?? this;
|
|
4685
|
+
if (self._needsCompaction) {
|
|
4686
|
+
self._needsCompaction = false;
|
|
4687
|
+
self.items = self.items.filter((item) => item !== undefined);
|
|
4688
|
+
}
|
|
4689
|
+
}
|
|
4690
|
+
[$resyncPrune](visited, prune, keep) {
|
|
4691
|
+
// `items` is hole-free here: the decode loop's $onDecodeEnd already
|
|
4692
|
+
// ran, and a full-sync emits dense ADDs (no DELETEs, no gap-writes)
|
|
4693
|
+
// so no compaction happened mid-decode. Visited indexes may still be
|
|
4694
|
+
// sparse (ADD_BY_REFID resolves to the current client-side index).
|
|
4695
|
+
const self = this[$proxyTarget] ?? this;
|
|
4696
|
+
const items = self.items;
|
|
4697
|
+
let removed = false;
|
|
4698
|
+
for (let i = 0; i < items.length; i++) {
|
|
4699
|
+
const value = items[i];
|
|
4700
|
+
if (visited.has(i)) {
|
|
4701
|
+
keep(value);
|
|
4702
|
+
continue;
|
|
4703
|
+
}
|
|
4704
|
+
removed = true;
|
|
4705
|
+
prune(value, i);
|
|
4706
|
+
self[$deleteByIndex](i);
|
|
4707
|
+
}
|
|
4708
|
+
if (removed) {
|
|
4709
|
+
self[$onDecodeEnd]();
|
|
4710
|
+
} // compact the holes
|
|
4260
4711
|
}
|
|
4261
4712
|
toArray() {
|
|
4262
4713
|
return this.items.slice(0);
|
|
@@ -4623,6 +5074,24 @@
|
|
|
4623
5074
|
this.$items.clear();
|
|
4624
5075
|
changeTree.operation(exports.OPERATION.CLEAR);
|
|
4625
5076
|
}
|
|
5077
|
+
/**
|
|
5078
|
+
* Pool reset: empty this map and recycle its ChangeTree WITHOUT recording
|
|
5079
|
+
* any wire op (the parent field's ADD/DELETE owns the wire). Recurses into
|
|
5080
|
+
* ref-type children. Called by Schema.reset when a pooled entity has a
|
|
5081
|
+
* map field. The instance must already be detached from the encoder.
|
|
5082
|
+
*/
|
|
5083
|
+
[$reset]() {
|
|
5084
|
+
const changeTree = this[$changes];
|
|
5085
|
+
if (changeTree.isStreamCollection) {
|
|
5086
|
+
throw new Error(`@colyseus/schema: cannot reset a streamed MapSchema (pooling not supported).`);
|
|
5087
|
+
}
|
|
5088
|
+
// reset ref-type children first (primitives optional-chain away)
|
|
5089
|
+
this.$items.forEach((value) => value?.[$reset]?.());
|
|
5090
|
+
this.$items.clear();
|
|
5091
|
+
this.journal.reset();
|
|
5092
|
+
changeTree.recycle();
|
|
5093
|
+
this[$refId] = undefined; // assign (not delete) to avoid V8 dictionary-mode deopt
|
|
5094
|
+
}
|
|
4626
5095
|
has(key) {
|
|
4627
5096
|
return this.$items.has(key);
|
|
4628
5097
|
}
|
|
@@ -4663,6 +5132,36 @@
|
|
|
4663
5132
|
this.journal.keyByIndex.delete(index);
|
|
4664
5133
|
}
|
|
4665
5134
|
}
|
|
5135
|
+
[$resyncPrune](visited, prune, keep) {
|
|
5136
|
+
// maps prune by string key, NOT wire index — the decoder-side
|
|
5137
|
+
// journal never evicts stale index→key mappings on re-indexing.
|
|
5138
|
+
let deletedKeys = null;
|
|
5139
|
+
this.$items.forEach((value, key) => {
|
|
5140
|
+
if (visited.has(key)) {
|
|
5141
|
+
keep(value);
|
|
5142
|
+
return;
|
|
5143
|
+
}
|
|
5144
|
+
(deletedKeys ??= new Set()).add(key);
|
|
5145
|
+
prune(value, key);
|
|
5146
|
+
});
|
|
5147
|
+
if (deletedKeys !== null) {
|
|
5148
|
+
deletedKeys.forEach((key) => {
|
|
5149
|
+
this.$items.delete(key);
|
|
5150
|
+
delete this.journal.indexByKey[key];
|
|
5151
|
+
});
|
|
5152
|
+
// drop index→key mappings of swept keys — including stale ones
|
|
5153
|
+
// left behind by re-indexing.
|
|
5154
|
+
const staleIndexes = [];
|
|
5155
|
+
this.journal.keyByIndex.forEach((key, index) => {
|
|
5156
|
+
if (deletedKeys.has(key)) {
|
|
5157
|
+
staleIndexes.push(index);
|
|
5158
|
+
}
|
|
5159
|
+
});
|
|
5160
|
+
for (let i = 0; i < staleIndexes.length; i++) {
|
|
5161
|
+
this.journal.keyByIndex.delete(staleIndexes[i]);
|
|
5162
|
+
}
|
|
5163
|
+
}
|
|
5164
|
+
}
|
|
4666
5165
|
[$onEncodeEnd]() {
|
|
4667
5166
|
this.journal.cleanupAfterEncode();
|
|
4668
5167
|
}
|
|
@@ -4855,6 +5354,24 @@
|
|
|
4855
5354
|
this.$items.clear();
|
|
4856
5355
|
changeTree.operation(exports.OPERATION.CLEAR);
|
|
4857
5356
|
}
|
|
5357
|
+
/**
|
|
5358
|
+
* Pool reset: empty this collection and recycle its ChangeTree WITHOUT
|
|
5359
|
+
* recording any wire op (the parent field's ADD/DELETE owns the wire).
|
|
5360
|
+
* Recurses into ref-type children. Called by Schema.reset when a pooled
|
|
5361
|
+
* entity has a collection field. Must already be detached from the encoder.
|
|
5362
|
+
*/
|
|
5363
|
+
[$reset]() {
|
|
5364
|
+
const changeTree = this[$changes];
|
|
5365
|
+
if (changeTree.isStreamCollection) {
|
|
5366
|
+
throw new Error(`@colyseus/schema: cannot reset a streamed CollectionSchema (pooling not supported).`);
|
|
5367
|
+
}
|
|
5368
|
+
this.$items.forEach((value) => value?.[$reset]?.());
|
|
5369
|
+
this.$items.clear();
|
|
5370
|
+
this.deletedItems = {};
|
|
5371
|
+
this.$refId = 0; // reset the monotonic index counter (field, not the symbol)
|
|
5372
|
+
changeTree.recycle();
|
|
5373
|
+
this[$refId] = undefined; // drop encoder ref identity by assign (not delete: avoids dict-mode deopt)
|
|
5374
|
+
}
|
|
4858
5375
|
has(value) {
|
|
4859
5376
|
return Array.from(this.$items.values()).some((v) => v === value);
|
|
4860
5377
|
}
|
|
@@ -4893,6 +5410,22 @@
|
|
|
4893
5410
|
[$deleteByIndex](index) {
|
|
4894
5411
|
this.$items.delete(index);
|
|
4895
5412
|
}
|
|
5413
|
+
[$resyncPrune](visited, prune, keep) {
|
|
5414
|
+
let toDelete = null;
|
|
5415
|
+
this.$items.forEach((value, index) => {
|
|
5416
|
+
if (visited.has(index)) {
|
|
5417
|
+
keep(value);
|
|
5418
|
+
return;
|
|
5419
|
+
}
|
|
5420
|
+
(toDelete ??= []).push(index);
|
|
5421
|
+
prune(value, index);
|
|
5422
|
+
});
|
|
5423
|
+
if (toDelete !== null) {
|
|
5424
|
+
for (let i = 0; i < toDelete.length; i++) {
|
|
5425
|
+
this[$deleteByIndex](toDelete[i]);
|
|
5426
|
+
}
|
|
5427
|
+
}
|
|
5428
|
+
}
|
|
4896
5429
|
[$onEncodeEnd]() {
|
|
4897
5430
|
for (const key in this.deletedItems) {
|
|
4898
5431
|
delete this.deletedItems[key];
|
|
@@ -5094,6 +5627,24 @@
|
|
|
5094
5627
|
this.$items.clear();
|
|
5095
5628
|
changeTree.operation(exports.OPERATION.CLEAR);
|
|
5096
5629
|
}
|
|
5630
|
+
/**
|
|
5631
|
+
* Pool reset: empty this set and recycle its ChangeTree WITHOUT recording
|
|
5632
|
+
* any wire op (the parent field's ADD/DELETE owns the wire). Recurses into
|
|
5633
|
+
* ref-type children. Called by Schema.reset when a pooled entity has a set
|
|
5634
|
+
* field. The instance must already be detached from the encoder.
|
|
5635
|
+
*/
|
|
5636
|
+
[$reset]() {
|
|
5637
|
+
const changeTree = this[$changes];
|
|
5638
|
+
if (changeTree.isStreamCollection) {
|
|
5639
|
+
throw new Error(`@colyseus/schema: cannot reset a streamed SetSchema (pooling not supported).`);
|
|
5640
|
+
}
|
|
5641
|
+
this.$items.forEach((value) => value?.[$reset]?.());
|
|
5642
|
+
this.$items.clear();
|
|
5643
|
+
this.deletedItems = {};
|
|
5644
|
+
this.$refId = 0; // reset the monotonic index counter (field, not the symbol)
|
|
5645
|
+
changeTree.recycle();
|
|
5646
|
+
this[$refId] = undefined; // drop encoder ref identity by assign (not delete: avoids dict-mode deopt)
|
|
5647
|
+
}
|
|
5097
5648
|
has(value) {
|
|
5098
5649
|
const values = this.$items.values();
|
|
5099
5650
|
let has = false;
|
|
@@ -5144,6 +5695,22 @@
|
|
|
5144
5695
|
[$deleteByIndex](index) {
|
|
5145
5696
|
this.$items.delete(index);
|
|
5146
5697
|
}
|
|
5698
|
+
[$resyncPrune](visited, prune, keep) {
|
|
5699
|
+
let toDelete = null;
|
|
5700
|
+
this.$items.forEach((value, index) => {
|
|
5701
|
+
if (visited.has(index)) {
|
|
5702
|
+
keep(value);
|
|
5703
|
+
return;
|
|
5704
|
+
}
|
|
5705
|
+
(toDelete ??= []).push(index);
|
|
5706
|
+
prune(value, index);
|
|
5707
|
+
});
|
|
5708
|
+
if (toDelete !== null) {
|
|
5709
|
+
for (let i = 0; i < toDelete.length; i++) {
|
|
5710
|
+
this[$deleteByIndex](toDelete[i]);
|
|
5711
|
+
}
|
|
5712
|
+
}
|
|
5713
|
+
}
|
|
5147
5714
|
[$onEncodeEnd]() {
|
|
5148
5715
|
for (const key in this.deletedItems) {
|
|
5149
5716
|
delete this.deletedItems[key];
|
|
@@ -5405,6 +5972,11 @@
|
|
|
5405
5972
|
this.$items.delete(index);
|
|
5406
5973
|
}
|
|
5407
5974
|
}
|
|
5975
|
+
[$resyncPrune]() {
|
|
5976
|
+
// Stream contents are delivered by the trickle/priority pass, NOT
|
|
5977
|
+
// by full-sync (encodeAll carries none of them) — a snapshot is not
|
|
5978
|
+
// authoritative for streams, so absence ≠ deleted. Never prune.
|
|
5979
|
+
}
|
|
5408
5980
|
[$onEncodeEnd]() {
|
|
5409
5981
|
// No per-tick cleanup: pending/sent state spans encode ticks by design.
|
|
5410
5982
|
}
|
|
@@ -5485,7 +6057,22 @@
|
|
|
5485
6057
|
constructor(type) {
|
|
5486
6058
|
this._type = type;
|
|
5487
6059
|
}
|
|
5488
|
-
/**
|
|
6060
|
+
/**
|
|
6061
|
+
* Provide a default value for this field.
|
|
6062
|
+
*
|
|
6063
|
+
* Pass a **factory function** `() => T` to build a FRESH value per instance
|
|
6064
|
+
* (invoked once per construction) instead of sharing a single default — the
|
|
6065
|
+
* clean way to default a ref to a plain custom class, or any field that must
|
|
6066
|
+
* not share a mutable default across instances:
|
|
6067
|
+
*
|
|
6068
|
+
* ```ts
|
|
6069
|
+
* acc: t.ref(GunAccuracy).noSync().default(() => new GunAccuracy()),
|
|
6070
|
+
* ```
|
|
6071
|
+
*
|
|
6072
|
+
* Schema fields are never function-typed, so a function is always treated as a
|
|
6073
|
+
* factory. A non-function value is shared (and cloned per instance if it is
|
|
6074
|
+
* clone-able, e.g. a Schema/collection).
|
|
6075
|
+
*/
|
|
5489
6076
|
default(value) {
|
|
5490
6077
|
this._default = value;
|
|
5491
6078
|
this._hasDefault = true;
|
|
@@ -5656,6 +6243,20 @@
|
|
|
5656
6243
|
return b;
|
|
5657
6244
|
});
|
|
5658
6245
|
const refFactory = ((ctor) => new FieldBuilder(ctor));
|
|
6246
|
+
/**
|
|
6247
|
+
* A bounded float carried on the wire as a fixed-width unsigned integer. App code
|
|
6248
|
+
* reads/writes the FLOAT; the wire carries the quantized int and the field only
|
|
6249
|
+
* ever yields `dequant(q)`, so client predict and server sim read the same value
|
|
6250
|
+
* (no full-precision path to leak ⇒ no shot misprediction). See
|
|
6251
|
+
* {@link QuantizeOptions} for the precision/wire trade-offs.
|
|
6252
|
+
*
|
|
6253
|
+
* yaw: t.quantized({ min: 0, max: TWO_PI, mode: "wrap" }), // 16-bit
|
|
6254
|
+
* pitch: t.quantized({ min: -PITCH_LIMIT, max: PITCH_LIMIT }), // clamp (default)
|
|
6255
|
+
* throttle: t.quantized({ min: 0, max: 1, bits: 8 }), // 1 byte
|
|
6256
|
+
*/
|
|
6257
|
+
function quantizedFactory(opts) {
|
|
6258
|
+
return new FieldBuilder({ quantized: resolveQuantize(opts) });
|
|
6259
|
+
}
|
|
5659
6260
|
const t = Object.freeze({
|
|
5660
6261
|
// Primitives
|
|
5661
6262
|
string: primitive("string"),
|
|
@@ -5673,16 +6274,46 @@
|
|
|
5673
6274
|
float64: primitive("float64"),
|
|
5674
6275
|
bigint64: primitive("bigint64"),
|
|
5675
6276
|
biguint64: primitive("biguint64"),
|
|
5676
|
-
/**
|
|
6277
|
+
/**
|
|
6278
|
+
* Reference to a Schema subtype — `t.array(Item)` usually reads better, but
|
|
6279
|
+
* this is available when a plain ref is needed.
|
|
6280
|
+
*
|
|
6281
|
+
* The target may also be a **non-Schema custom class**. A synced ref still
|
|
6282
|
+
* requires it to be encodable — a `Schema` subclass, or a class retrofitted
|
|
6283
|
+
* with `Metadata.setFields(...)` (both carry `[Symbol.metadata]`); a bare
|
|
6284
|
+
* custom class is rejected at `schema()` time. A `.noSync()` (local-only)
|
|
6285
|
+
* field accepts ANY zero-arg class and auto-instantiates one per parent.
|
|
6286
|
+
*/
|
|
5677
6287
|
ref: refFactory,
|
|
5678
6288
|
array: arrayFactory,
|
|
5679
6289
|
map: mapFactory,
|
|
5680
6290
|
set: setFactory,
|
|
5681
6291
|
collection: collectionFactory,
|
|
5682
6292
|
stream: streamFactory,
|
|
6293
|
+
/**
|
|
6294
|
+
* A bounded float quantized to a fixed-width unsigned int on the wire — half
|
|
6295
|
+
* (or a quarter) the bytes of a `float32` at a precision you pick. The field
|
|
6296
|
+
* reads/writes the float and only ever yields `dequant(q)`. {@see QuantizeOptions}
|
|
6297
|
+
*/
|
|
6298
|
+
quantized: quantizedFactory,
|
|
6299
|
+
/**
|
|
6300
|
+
* Sugar for a full-circle wrapping angle in radians:
|
|
6301
|
+
* `t.quantized({ min: 0, max: 2π, mode: "wrap", bits })` (default 16-bit,
|
|
6302
|
+
* ~0.0055°/step). Any input angle is range-reduced into `[0, 2π)`. Render note:
|
|
6303
|
+
* lerp interpolated remotes shortest-arc (`attach({ angle: true })`) — the
|
|
6304
|
+
* wrap fixes the WIRE seam, not interpolation (see {@link QuantizeOptions.mode}).
|
|
6305
|
+
*/
|
|
6306
|
+
angle: (opts) => quantizedFactory({ min: 0, max: Math.PI * 2, mode: "wrap", bits: opts?.bits ?? 16 }),
|
|
5683
6307
|
});
|
|
5684
6308
|
|
|
5685
6309
|
const DEFAULT_VIEW_TAG = -1;
|
|
6310
|
+
/**
|
|
6311
|
+
* Class decorator that registers a `@type`-style Schema class with the
|
|
6312
|
+
* TypeContext (required for reflection / cross-language codegen).
|
|
6313
|
+
*
|
|
6314
|
+
* @entity
|
|
6315
|
+
* class Player extends Schema { ... }
|
|
6316
|
+
*/
|
|
5686
6317
|
function entity(constructor) {
|
|
5687
6318
|
TypeContext.register(constructor);
|
|
5688
6319
|
return constructor;
|
|
@@ -5914,8 +6545,8 @@
|
|
|
5914
6545
|
if (metadata[$descriptors][field]) {
|
|
5915
6546
|
Object.defineProperty(target, field, metadata[$descriptors][field]);
|
|
5916
6547
|
}
|
|
5917
|
-
// Pre-compute encoder function for primitive types.
|
|
5918
|
-
if (typeof type === "string") {
|
|
6548
|
+
// Pre-compute encoder function for primitive + quantized types.
|
|
6549
|
+
if (typeof type === "string" || isQuantizedType(type)) {
|
|
5919
6550
|
if (!metadata[$encoders]) {
|
|
5920
6551
|
Object.defineProperty(metadata, $encoders, {
|
|
5921
6552
|
value: [],
|
|
@@ -5924,7 +6555,9 @@
|
|
|
5924
6555
|
writable: true,
|
|
5925
6556
|
});
|
|
5926
6557
|
}
|
|
5927
|
-
metadata[$encoders][fieldIndex] =
|
|
6558
|
+
metadata[$encoders][fieldIndex] = (typeof type === "string")
|
|
6559
|
+
? encode[type]
|
|
6560
|
+
: makeQuantizedEncoder(type.quantized);
|
|
5928
6561
|
}
|
|
5929
6562
|
};
|
|
5930
6563
|
}
|
|
@@ -6035,6 +6668,38 @@
|
|
|
6035
6668
|
values[fieldIndex] = value;
|
|
6036
6669
|
};
|
|
6037
6670
|
}
|
|
6671
|
+
/**
|
|
6672
|
+
* Setter for a `t.quantized()` field. SNAPS the assigned float to the wire-exact
|
|
6673
|
+
* value (`dequant(quant(x))`) on write, so the stored value — and every read,
|
|
6674
|
+
* including the reconciler's live step off the staged input — is identical to
|
|
6675
|
+
* what the server decodes off the wire. This is the half that kills the
|
|
6676
|
+
* predict-from-the-wrong-value footgun; the encoder re-quantizes the snapped
|
|
6677
|
+
* value at send (a lossless round-trip). Change tracking keys on the SNAPPED
|
|
6678
|
+
* value, so a sub-step jitter that quantizes to the same integer emits no delta.
|
|
6679
|
+
*/
|
|
6680
|
+
function makeQuantizedSetter(fieldName, fieldIndex, desc) {
|
|
6681
|
+
return function (value) {
|
|
6682
|
+
const values = this[$values];
|
|
6683
|
+
const previousValue = values[fieldIndex];
|
|
6684
|
+
if (value !== undefined && value !== null) {
|
|
6685
|
+
if (typeof value !== "number") {
|
|
6686
|
+
throw new EncodeSchemaError(`a 'number' was expected, but '${JSON.stringify(value)}' was provided in ${this.constructor.name}#${fieldName}`);
|
|
6687
|
+
}
|
|
6688
|
+
value = dequantize(desc, quantize(desc, value)); // snap to wire-exact
|
|
6689
|
+
if (value === previousValue)
|
|
6690
|
+
return;
|
|
6691
|
+
this.constructor[$track](this[$changes], fieldIndex, exports.OPERATION.ADD);
|
|
6692
|
+
}
|
|
6693
|
+
else {
|
|
6694
|
+
if (value === previousValue)
|
|
6695
|
+
return; // undefined === undefined
|
|
6696
|
+
if (previousValue !== undefined && previousValue !== null) {
|
|
6697
|
+
this[$changes].delete(fieldIndex);
|
|
6698
|
+
}
|
|
6699
|
+
}
|
|
6700
|
+
values[fieldIndex] = value;
|
|
6701
|
+
};
|
|
6702
|
+
}
|
|
6038
6703
|
function getPropertyDescriptor(fieldName, fieldIndex, type, complexTypeKlass) {
|
|
6039
6704
|
let setter;
|
|
6040
6705
|
if (complexTypeKlass) {
|
|
@@ -6043,10 +6708,15 @@
|
|
|
6043
6708
|
else if (typeof type === "string") {
|
|
6044
6709
|
setter = makePrimitiveSetter(fieldName, fieldIndex, type);
|
|
6045
6710
|
}
|
|
6711
|
+
else if (isQuantizedType(type)) {
|
|
6712
|
+
setter = makeQuantizedSetter(fieldName, fieldIndex, type.quantized);
|
|
6713
|
+
}
|
|
6046
6714
|
else {
|
|
6047
6715
|
setter = makeSchemaRefSetter(fieldName, fieldIndex, type);
|
|
6048
6716
|
}
|
|
6049
6717
|
return {
|
|
6718
|
+
// Quantized stores the already-snapped float, so the getter is the plain
|
|
6719
|
+
// $values read — the field yields dequant(q) with no per-read math.
|
|
6050
6720
|
get: function () { return this[$values][fieldIndex]; },
|
|
6051
6721
|
set: setter,
|
|
6052
6722
|
enumerable: true,
|
|
@@ -6081,32 +6751,51 @@
|
|
|
6081
6751
|
});
|
|
6082
6752
|
};
|
|
6083
6753
|
}
|
|
6754
|
+
let defineTypesWarned = false;
|
|
6755
|
+
/**
|
|
6756
|
+
* Adds synchronizable fields to an existing `Schema` subclass — the pre-5.0
|
|
6757
|
+
* helper for plain JavaScript users.
|
|
6758
|
+
*
|
|
6759
|
+
* @deprecated Use `schema()` with `t.*` field builders instead:
|
|
6760
|
+
* https://docs.colyseus.io/state/schema
|
|
6761
|
+
*/
|
|
6762
|
+
function defineTypes(target, fields, options) {
|
|
6763
|
+
if (!defineTypesWarned) {
|
|
6764
|
+
defineTypesWarned = true;
|
|
6765
|
+
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");
|
|
6766
|
+
}
|
|
6767
|
+
for (let field in fields) {
|
|
6768
|
+
type(fields[field], options)(target.prototype, field);
|
|
6769
|
+
}
|
|
6770
|
+
return target;
|
|
6771
|
+
}
|
|
6084
6772
|
/**
|
|
6085
|
-
*
|
|
6086
|
-
* (empty collection or zero-arg Schema ref), or `undefined` when the
|
|
6087
|
-
* has no auto-default.
|
|
6773
|
+
* Build a per-construction factory for a builder type's auto-instantiated
|
|
6774
|
+
* default (empty collection or zero-arg Schema ref), or `undefined` when the
|
|
6775
|
+
* type has no auto-default. Returning a factory lets each construction `new` a
|
|
6776
|
+
* fresh value directly instead of cloning a shared prototype instance.
|
|
6088
6777
|
*/
|
|
6089
|
-
function
|
|
6778
|
+
function makeAutoDefaultFactory(rawType) {
|
|
6090
6779
|
if (rawType && typeof rawType === "object") {
|
|
6091
6780
|
if (rawType.array !== undefined) {
|
|
6092
|
-
return new ArraySchema();
|
|
6781
|
+
return () => new ArraySchema();
|
|
6093
6782
|
}
|
|
6094
6783
|
if (rawType.map !== undefined) {
|
|
6095
|
-
return new MapSchema();
|
|
6784
|
+
return () => new MapSchema();
|
|
6096
6785
|
}
|
|
6097
6786
|
if (rawType.set !== undefined) {
|
|
6098
|
-
return new SetSchema();
|
|
6787
|
+
return () => new SetSchema();
|
|
6099
6788
|
}
|
|
6100
6789
|
if (rawType.collection !== undefined) {
|
|
6101
|
-
return new CollectionSchema();
|
|
6790
|
+
return () => new CollectionSchema();
|
|
6102
6791
|
}
|
|
6103
6792
|
if (rawType.stream !== undefined) {
|
|
6104
|
-
return new StreamSchema();
|
|
6793
|
+
return () => new StreamSchema();
|
|
6105
6794
|
}
|
|
6106
6795
|
}
|
|
6107
6796
|
else if (typeof rawType === "function" && Schema.is(rawType)) {
|
|
6108
6797
|
if (!rawType.prototype.initialize || rawType.prototype.initialize.length === 0) {
|
|
6109
|
-
return new rawType();
|
|
6798
|
+
return () => new rawType();
|
|
6110
6799
|
}
|
|
6111
6800
|
}
|
|
6112
6801
|
return undefined;
|
|
@@ -6133,7 +6822,39 @@
|
|
|
6133
6822
|
}
|
|
6134
6823
|
const fields = {};
|
|
6135
6824
|
const methods = {};
|
|
6825
|
+
// Two buckets, both keyed by field name and applied at construction:
|
|
6826
|
+
// - `defaultValues`: static values copied as-is (shared reference).
|
|
6827
|
+
// - `defaultFactories`: invoked per construction for a fresh value —
|
|
6828
|
+
// `.default(fn)`, clone-able defaults, and auto-instantiated collections/refs.
|
|
6136
6829
|
const defaultValues = {};
|
|
6830
|
+
const defaultFactories = {};
|
|
6831
|
+
// Decide once (at definition time) how each `.default(v)` materializes per
|
|
6832
|
+
// construction: a function is a factory; a clone-able value clones fresh;
|
|
6833
|
+
// anything else is a shared static value.
|
|
6834
|
+
const assignDefault = (field, value) => {
|
|
6835
|
+
if (typeof value === "function") {
|
|
6836
|
+
defaultFactories[field] = value;
|
|
6837
|
+
}
|
|
6838
|
+
else if (value && typeof value.clone === "function") {
|
|
6839
|
+
defaultFactories[field] = () => value.clone();
|
|
6840
|
+
}
|
|
6841
|
+
else {
|
|
6842
|
+
defaultValues[field] = value;
|
|
6843
|
+
}
|
|
6844
|
+
};
|
|
6845
|
+
// Seed a field's construction default: explicit `.default(v)`, else the
|
|
6846
|
+
// auto-instantiated empty collection / zero-arg ref (skipped for `.optional()`).
|
|
6847
|
+
const seedDefault = (field, def) => {
|
|
6848
|
+
if (def.hasDefault) {
|
|
6849
|
+
assignDefault(field, def.default);
|
|
6850
|
+
}
|
|
6851
|
+
else if (!def.optional) {
|
|
6852
|
+
const factory = makeAutoDefaultFactory(def.type);
|
|
6853
|
+
if (factory) {
|
|
6854
|
+
defaultFactories[field] = factory;
|
|
6855
|
+
}
|
|
6856
|
+
}
|
|
6857
|
+
};
|
|
6137
6858
|
const viewTagFields = {};
|
|
6138
6859
|
const ownedFields = [];
|
|
6139
6860
|
const unreliableFields = [];
|
|
@@ -6157,18 +6878,16 @@
|
|
|
6157
6878
|
`together with a sync-only modifier (.view/.owned/.unreliable/.transient/.static/.stream). ` +
|
|
6158
6879
|
`A local-only field cannot be synchronized.`);
|
|
6159
6880
|
}
|
|
6160
|
-
|
|
6161
|
-
defaultValues[fieldName] = def.default;
|
|
6162
|
-
}
|
|
6163
|
-
else if (!def.optional) {
|
|
6164
|
-
const autoDefault = autoInstantiateDefault(def.type);
|
|
6165
|
-
if (autoDefault !== undefined) {
|
|
6166
|
-
defaultValues[fieldName] = autoDefault;
|
|
6167
|
-
}
|
|
6168
|
-
}
|
|
6881
|
+
seedDefault(fieldName, def);
|
|
6169
6882
|
continue;
|
|
6170
6883
|
}
|
|
6171
|
-
|
|
6884
|
+
const normalizedType = getNormalizedType(def.type);
|
|
6885
|
+
// A synced ref must be encodable (a Schema, or Metadata.setFields()'d) — reject a bare class.
|
|
6886
|
+
if (typeof normalizedType === "function" && !Schema.is(normalizedType)) {
|
|
6887
|
+
throw new Error(`schema(${name ? `'${name}'` : ""}): field '${fieldName}' is a synced ref to non-Schema ` +
|
|
6888
|
+
`class '${normalizedType.name || "(anonymous)"}' — use .noSync(), or Metadata.setFields().`);
|
|
6889
|
+
}
|
|
6890
|
+
fields[fieldName] = normalizedType;
|
|
6172
6891
|
if (def.view !== undefined) {
|
|
6173
6892
|
viewTagFields[fieldName] = def.view;
|
|
6174
6893
|
}
|
|
@@ -6196,24 +6915,14 @@
|
|
|
6196
6915
|
if (def.optional) {
|
|
6197
6916
|
optionalFields.push(fieldName);
|
|
6198
6917
|
}
|
|
6199
|
-
|
|
6200
|
-
defaultValues[fieldName] = def.default;
|
|
6201
|
-
}
|
|
6202
|
-
else if (!def.optional) {
|
|
6203
|
-
// Auto-instantiate collection/Schema defaults when none is provided.
|
|
6204
|
-
// `.optional()` opts out — field starts as undefined.
|
|
6205
|
-
const autoDefault = autoInstantiateDefault(def.type);
|
|
6206
|
-
if (autoDefault !== undefined) {
|
|
6207
|
-
defaultValues[fieldName] = autoDefault;
|
|
6208
|
-
}
|
|
6209
|
-
}
|
|
6918
|
+
seedDefault(fieldName, def);
|
|
6210
6919
|
}
|
|
6211
6920
|
else if (typeof value === "function") {
|
|
6212
6921
|
if (Schema.is(value)) {
|
|
6213
6922
|
// Convenience: allow a bare Schema subclass (equivalent to `t.ref(Class)`).
|
|
6214
6923
|
fields[fieldName] = getNormalizedType(value);
|
|
6215
6924
|
if (!value.prototype.initialize || value.prototype.initialize.length === 0) {
|
|
6216
|
-
|
|
6925
|
+
defaultFactories[fieldName] = () => new value();
|
|
6217
6926
|
}
|
|
6218
6927
|
}
|
|
6219
6928
|
else {
|
|
@@ -6225,17 +6934,19 @@
|
|
|
6225
6934
|
`Schema subclass, or method (got ${typeof value}).`);
|
|
6226
6935
|
}
|
|
6227
6936
|
}
|
|
6228
|
-
|
|
6229
|
-
|
|
6937
|
+
// Write construction defaults onto `target` — either the instance directly
|
|
6938
|
+
// (no-args fast path) or a throwaway object that gets merged with props.
|
|
6939
|
+
const applyDefaults = (target) => {
|
|
6230
6940
|
for (const fieldName in defaultValues) {
|
|
6231
|
-
|
|
6232
|
-
if (defaultValue && typeof defaultValue.clone === "function") {
|
|
6233
|
-
defaults[fieldName] = defaultValue.clone();
|
|
6234
|
-
}
|
|
6235
|
-
else {
|
|
6236
|
-
defaults[fieldName] = defaultValue;
|
|
6237
|
-
}
|
|
6941
|
+
target[fieldName] = defaultValues[fieldName];
|
|
6238
6942
|
}
|
|
6943
|
+
for (const fieldName in defaultFactories) {
|
|
6944
|
+
target[fieldName] = defaultFactories[fieldName]();
|
|
6945
|
+
}
|
|
6946
|
+
};
|
|
6947
|
+
const getDefaultValues = () => {
|
|
6948
|
+
const defaults = {};
|
|
6949
|
+
applyDefaults(defaults);
|
|
6239
6950
|
return defaults;
|
|
6240
6951
|
};
|
|
6241
6952
|
const getParentProps = (props) => {
|
|
@@ -6248,18 +6959,26 @@
|
|
|
6248
6959
|
}
|
|
6249
6960
|
return parentProps;
|
|
6250
6961
|
};
|
|
6962
|
+
const hasInitialize = typeof methods.initialize === "function";
|
|
6251
6963
|
/** @codegen-ignore */
|
|
6252
6964
|
const klass = Metadata.setFields(class extends inherits {
|
|
6253
6965
|
constructor(...args) {
|
|
6254
|
-
|
|
6255
|
-
|
|
6256
|
-
//
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6966
|
+
const props = args[0];
|
|
6967
|
+
if (props === undefined) {
|
|
6968
|
+
// No-args: write defaults straight onto the instance — skips the
|
|
6969
|
+
// throwaway defaults object + Object.assign + assignProps walk.
|
|
6970
|
+
super();
|
|
6971
|
+
applyDefaults(this);
|
|
6260
6972
|
}
|
|
6261
6973
|
else {
|
|
6262
|
-
|
|
6974
|
+
// With props: merge into the fresh defaults object in place (no
|
|
6975
|
+
// extra `{}` target); the super chain runs assignProps once. An
|
|
6976
|
+
// `initialize()` owns the schema fields, so only parent props flow up.
|
|
6977
|
+
super(Object.assign(getDefaultValues(), hasInitialize ? getParentProps(props) : props));
|
|
6978
|
+
}
|
|
6979
|
+
// Only call initialize() on the exact target class, not parents.
|
|
6980
|
+
if (hasInitialize && new.target === klass) {
|
|
6981
|
+
methods.initialize.apply(this, args);
|
|
6263
6982
|
}
|
|
6264
6983
|
}
|
|
6265
6984
|
}, fields);
|
|
@@ -6386,6 +7105,60 @@
|
|
|
6386
7105
|
inst[$values] = [];
|
|
6387
7106
|
return inst;
|
|
6388
7107
|
}
|
|
7108
|
+
/**
|
|
7109
|
+
* Reset a DETACHED instance to construction defaults so it can be returned
|
|
7110
|
+
* to a {@link SchemaPool} and reused, avoiding the cost of `new`. Recurses
|
|
7111
|
+
* into ref-type fields (child Schemas / collections).
|
|
7112
|
+
*
|
|
7113
|
+
* Preconditions (enforced):
|
|
7114
|
+
* - The instance must be tracked (encoder-side), not a decoder mirror.
|
|
7115
|
+
* - The instance must NOT be shared across multiple parents.
|
|
7116
|
+
* - The instance must already be removed from its parent collection/field
|
|
7117
|
+
* (so the encoder detached it: `root === undefined`).
|
|
7118
|
+
*
|
|
7119
|
+
* NOTE: primitive field values are NOT reset to class defaults — re-assign
|
|
7120
|
+
* the fields you care about when you reuse the instance (standard
|
|
7121
|
+
* object-pool discipline).
|
|
7122
|
+
*/
|
|
7123
|
+
static reset(instance) {
|
|
7124
|
+
const changeTree = instance?.[$changes];
|
|
7125
|
+
// Only tracked (encoder-side) instances are poolable. Decoder-side
|
|
7126
|
+
// instances carry an UntrackedChangeTree, which has no recycle().
|
|
7127
|
+
if (changeTree === undefined || typeof changeTree.recycle !== "function") {
|
|
7128
|
+
throw new Error(`@colyseus/schema: Schema.reset() requires a tracked (encoder-side) instance.`);
|
|
7129
|
+
}
|
|
7130
|
+
// Instances reachable through more than one parent are unsafe to pool:
|
|
7131
|
+
// another owner may still hold this instance.
|
|
7132
|
+
if (changeTree.extraParents !== undefined) {
|
|
7133
|
+
throw new Error(`@colyseus/schema: cannot reset a shared instance (${instance.constructor.name}) with multiple parents.`);
|
|
7134
|
+
}
|
|
7135
|
+
instance[$reset]();
|
|
7136
|
+
}
|
|
7137
|
+
/**
|
|
7138
|
+
* Per-instance reset primitive (the recursive worker behind
|
|
7139
|
+
* {@link Schema.reset}). Resets ref-type children first (depth-first),
|
|
7140
|
+
* then recycles this instance's ChangeTree and drops its `$refId` so a
|
|
7141
|
+
* re-add is assigned a fresh refId exactly like a freshly constructed
|
|
7142
|
+
* instance. Dropping `$refId` is what makes instance reuse
|
|
7143
|
+
* wire-format-identical to `new T()`.
|
|
7144
|
+
*/
|
|
7145
|
+
[$reset]() {
|
|
7146
|
+
const metadata = this.constructor[Symbol.metadata];
|
|
7147
|
+
const refIndexes = metadata?.[$refTypeFieldIndexes] ?? [];
|
|
7148
|
+
const values = this[$values];
|
|
7149
|
+
for (let i = 0; i < refIndexes.length; i++) {
|
|
7150
|
+
const child = values[refIndexes[i]];
|
|
7151
|
+
// ref fields hold a child Schema or collection (both implement
|
|
7152
|
+
// [$reset]); skip undefined/null. Optional-chain is a cheap guard.
|
|
7153
|
+
child?.[$reset]?.();
|
|
7154
|
+
}
|
|
7155
|
+
this[$changes].recycle();
|
|
7156
|
+
// Clear the refId by ASSIGNMENT (not `delete`): `delete` would force the
|
|
7157
|
+
// instance into V8 dictionary mode, making release() as expensive as the
|
|
7158
|
+
// construction it saves. `=== undefined` in Root.add still assigns a fresh
|
|
7159
|
+
// refId exactly like a freshly-constructed instance.
|
|
7160
|
+
this[$refId] = undefined;
|
|
7161
|
+
}
|
|
6389
7162
|
/**
|
|
6390
7163
|
* Check whether `type` describes a Schema *class* (a subclass
|
|
6391
7164
|
* constructor carrying `Symbol.metadata`, as installed by `@type`).
|
|
@@ -6448,7 +7221,8 @@
|
|
|
6448
7221
|
return view.isChangeTreeVisible(ref[$changes]);
|
|
6449
7222
|
}
|
|
6450
7223
|
else {
|
|
6451
|
-
// view pass: custom tag
|
|
7224
|
+
// view pass: custom tag (bitmask) — field's stored mask matches
|
|
7225
|
+
// if it shares any bit with a tag this view was add()ed with.
|
|
6452
7226
|
return view.hasTagOnTree(ref[$changes], tag);
|
|
6453
7227
|
}
|
|
6454
7228
|
}
|
|
@@ -6753,83 +7527,18 @@
|
|
|
6753
7527
|
}
|
|
6754
7528
|
}
|
|
6755
7529
|
|
|
6756
|
-
|
|
6757
|
-
|
|
6758
|
-
|
|
6759
|
-
* `acquire()` pops from the free pool when available, otherwise bumps a
|
|
6760
|
-
* counter. `release()` queues a refId for reuse; the id doesn't become
|
|
6761
|
-
* acquirable until `flushReleases()` runs — the one-tick defer is what
|
|
6762
|
-
* lets the encoder guarantee a DELETE for the old instance reaches the
|
|
6763
|
-
* wire before the refId is handed to a new one.
|
|
6764
|
-
*
|
|
6765
|
-
* `reclaim()` handles "resurrection": a ref that was released but whose
|
|
6766
|
-
* JS instance is still alive can be re-added to the tree, in which case
|
|
6767
|
-
* the encoder must pull the refId back out of the pool before it's
|
|
6768
|
-
* handed to an unrelated instance.
|
|
6769
|
-
*/
|
|
6770
|
-
class RefIdAllocator {
|
|
6771
|
-
nextUniqueId;
|
|
6772
|
-
_free = [];
|
|
6773
|
-
_pending = [];
|
|
6774
|
-
_pooled = new Set();
|
|
6775
|
-
constructor(startRefId = 0) {
|
|
6776
|
-
this.nextUniqueId = startRefId;
|
|
6777
|
-
}
|
|
6778
|
-
acquire() {
|
|
6779
|
-
if (this._free.length > 0) {
|
|
6780
|
-
const id = this._free.pop();
|
|
6781
|
-
this._pooled.delete(id);
|
|
6782
|
-
return id;
|
|
6783
|
-
}
|
|
6784
|
-
return this.nextUniqueId++;
|
|
6785
|
-
}
|
|
6786
|
-
release(refId) {
|
|
6787
|
-
this._pending.push(refId);
|
|
6788
|
-
this._pooled.add(refId);
|
|
6789
|
-
}
|
|
6790
|
-
isPooled(refId) {
|
|
6791
|
-
return this._pooled.has(refId);
|
|
6792
|
-
}
|
|
6793
|
-
/**
|
|
6794
|
-
* Remove a refId from the pool. Called when a ref whose refId was
|
|
6795
|
-
* released is being resurrected. O(n) scan of the relevant array,
|
|
6796
|
-
* but resurrection is rare.
|
|
6797
|
-
*/
|
|
6798
|
-
reclaim(refId) {
|
|
6799
|
-
if (!this._pooled.delete(refId))
|
|
6800
|
-
return;
|
|
6801
|
-
let i = this._free.indexOf(refId);
|
|
6802
|
-
if (i !== -1) {
|
|
6803
|
-
this._free.splice(i, 1);
|
|
6804
|
-
return;
|
|
6805
|
-
}
|
|
6806
|
-
i = this._pending.indexOf(refId);
|
|
6807
|
-
if (i !== -1) {
|
|
6808
|
-
this._pending.splice(i, 1);
|
|
6809
|
-
}
|
|
6810
|
-
}
|
|
6811
|
-
/**
|
|
6812
|
-
* Promote this tick's releases into the acquirable set. Called from
|
|
6813
|
-
* `Encoder.discardChanges()` — never mid-encode.
|
|
6814
|
-
*/
|
|
6815
|
-
flushReleases() {
|
|
6816
|
-
const pending = this._pending;
|
|
6817
|
-
if (pending.length === 0)
|
|
6818
|
-
return;
|
|
6819
|
-
const free = this._free;
|
|
6820
|
-
for (let i = 0; i < pending.length; i++)
|
|
6821
|
-
free.push(pending[i]);
|
|
6822
|
-
pending.length = 0;
|
|
6823
|
-
}
|
|
6824
|
-
}
|
|
6825
|
-
|
|
7530
|
+
// Reused across Root.add calls — defineProperty is unavoidable ($refId must
|
|
7531
|
+
// stay non-enumerable for deepStrictEqual) but the descriptor literal isn't.
|
|
7532
|
+
const $refIdDescriptor$1 = { value: 0, enumerable: false, writable: true };
|
|
6826
7533
|
class Root {
|
|
6827
7534
|
types;
|
|
6828
7535
|
/**
|
|
6829
|
-
*
|
|
6830
|
-
*
|
|
7536
|
+
* Monotonic refId counter. RefIds are never recycled — a refId is a
|
|
7537
|
+
* stable identity for the lifetime of the room, so a client that
|
|
7538
|
+
* missed DELETEs (reconnect) can never see an old id rebound to a
|
|
7539
|
+
* different instance. DevMode reads/writes this across HMR cycles.
|
|
6831
7540
|
*/
|
|
6832
|
-
|
|
7541
|
+
nextUniqueId = 0;
|
|
6833
7542
|
refCount = {};
|
|
6834
7543
|
changeTrees = {};
|
|
6835
7544
|
/**
|
|
@@ -6922,7 +7631,7 @@
|
|
|
6922
7631
|
}
|
|
6923
7632
|
constructor(types, startRefId = 0) {
|
|
6924
7633
|
this.types = types;
|
|
6925
|
-
this.
|
|
7634
|
+
this.nextUniqueId = startRefId;
|
|
6926
7635
|
}
|
|
6927
7636
|
add(changeTree) {
|
|
6928
7637
|
const ref = changeTree.ref;
|
|
@@ -6931,31 +7640,27 @@
|
|
|
6931
7640
|
// *enumerable* own Symbols, so we keep defineProperty(enumerable:false)
|
|
6932
7641
|
// to keep $refId hidden from deep-equal comparisons in tests.
|
|
6933
7642
|
if (ref[$refId] === undefined) {
|
|
6934
|
-
|
|
6935
|
-
|
|
6936
|
-
enumerable: false,
|
|
6937
|
-
writable: true
|
|
6938
|
-
});
|
|
7643
|
+
$refIdDescriptor$1.value = this.nextUniqueId++;
|
|
7644
|
+
Object.defineProperty(ref, $refId, $refIdDescriptor$1);
|
|
6939
7645
|
}
|
|
6940
7646
|
const refId = ref[$refId];
|
|
6941
7647
|
const isNewChangeTree = (this.changeTrees[refId] === undefined);
|
|
6942
7648
|
if (isNewChangeTree) {
|
|
6943
7649
|
this.changeTrees[refId] = changeTree;
|
|
6944
7650
|
}
|
|
6945
|
-
// Resurrection path: a ref whose refId is still queued for reuse
|
|
6946
|
-
// is being re-added. Pull the refId out of the pool before it gets
|
|
6947
|
-
// handed out to someone else.
|
|
6948
|
-
if (this.refIds.isPooled(refId)) {
|
|
6949
|
-
this.refIds.reclaim(refId);
|
|
6950
|
-
}
|
|
6951
7651
|
const previousRefCount = this.refCount[refId];
|
|
6952
|
-
if (previousRefCount === 0) {
|
|
7652
|
+
if (previousRefCount === 0 || changeTree.needsRestage) {
|
|
6953
7653
|
//
|
|
6954
|
-
//
|
|
6955
|
-
//
|
|
6956
|
-
//
|
|
6957
|
-
//
|
|
7654
|
+
// Re-stage every currently-populated non-transient index as a
|
|
7655
|
+
// fresh ADD in the matching dirty bucket so the next encode
|
|
7656
|
+
// re-emits it on the correct channel. Two triggers:
|
|
7657
|
+
// - refCount 0: a previously-removed tree re-added under the
|
|
7658
|
+
// same refId (its ops were consumed by an earlier encode).
|
|
7659
|
+
// - NEEDS_RESTAGE: a `Schema.reset` instance re-entering under
|
|
7660
|
+
// a fresh refId (reset cleared the buckets; its retained
|
|
7661
|
+
// values would otherwise never be encoded).
|
|
6958
7662
|
//
|
|
7663
|
+
changeTree.needsRestage = false;
|
|
6959
7664
|
changeTree.forEachLive((fieldIndex) => {
|
|
6960
7665
|
if (changeTree.isFieldUnreliable(fieldIndex)) {
|
|
6961
7666
|
changeTree.ensureUnreliableRecorder().record(fieldIndex, exports.OPERATION.ADD);
|
|
@@ -6988,15 +7693,6 @@
|
|
|
6988
7693
|
this.removeFromQueue(changeTree);
|
|
6989
7694
|
this.removeFromUnreliableQueue(changeTree);
|
|
6990
7695
|
this.refCount[refId] = 0;
|
|
6991
|
-
// Return refId to the reuse pool (deferred to end-of-tick via
|
|
6992
|
-
// the allocator's pending set). Stream collections are excluded
|
|
6993
|
-
// because their per-view delivery bookkeeping is harder to
|
|
6994
|
-
// audit for reuse safety and the savings there are negligible.
|
|
6995
|
-
// If the ref is later resurrected, `add()` evicts the refId
|
|
6996
|
-
// from the pool before it's handed to another instance.
|
|
6997
|
-
if (!changeTree.isStreamCollection) {
|
|
6998
|
-
this.refIds.release(refId);
|
|
6999
|
-
}
|
|
7000
7696
|
changeTree.forEachChild((child, _) => {
|
|
7001
7697
|
if (child.removeParent(changeTree.ref)) {
|
|
7002
7698
|
if ((child.parentRef === undefined || // no parent, remove it
|
|
@@ -7169,6 +7865,12 @@
|
|
|
7169
7865
|
* Module-level adapter for `forEachLiveWithCtx`. Full-sync emits every live
|
|
7170
7866
|
* field as ADD, so we re-enter `encodeChangeCb` with that fixed op — keeps
|
|
7171
7867
|
* the callback closure-free across the entire DFS walk.
|
|
7868
|
+
*
|
|
7869
|
+
* The resync sweep (decoder/Resync.ts) depends on this shape: full-sync
|
|
7870
|
+
* output is dense plain ADDs — no DELETEs, no gap-writes (so decoding it
|
|
7871
|
+
* never compacts arrays mid-walk), and replaced occupants arrive as plain
|
|
7872
|
+
* ADD (the sweep's touch hook releases them). Changing full-sync emission
|
|
7873
|
+
* means revisiting the sweep.
|
|
7172
7874
|
*/
|
|
7173
7875
|
function encodeFullSyncCb(ctx, fieldIndex) {
|
|
7174
7876
|
encodeChangeCb(ctx, fieldIndex, exports.OPERATION.ADD);
|
|
@@ -7274,7 +7976,18 @@
|
|
|
7274
7976
|
return result;
|
|
7275
7977
|
}
|
|
7276
7978
|
class Encoder {
|
|
7277
|
-
|
|
7979
|
+
/**
|
|
7980
|
+
* Per-encoder shared output buffer size. The encoder auto-grows on
|
|
7981
|
+
* overflow and logs a one-time warning suggesting a higher value, so
|
|
7982
|
+
* the default just needs to comfortably cover typical room state.
|
|
7983
|
+
*
|
|
7984
|
+
* Sized to fit ~100 items in a `MapSchema<{x,y,z}>` keyed by
|
|
7985
|
+
* `nanoid(9)` (~4.5 KB worst-case full encode, float64-heavy) with
|
|
7986
|
+
* ~3-4× headroom for surrounding state (player list, world refs,
|
|
7987
|
+
* etc.). Raise per app via `Encoder.BUFFER_SIZE = N * 1024` before
|
|
7988
|
+
* constructing any Encoder.
|
|
7989
|
+
*/
|
|
7990
|
+
static BUFFER_SIZE = 16 * 1024;
|
|
7278
7991
|
sharedBuffer = new Uint8Array(Encoder.BUFFER_SIZE);
|
|
7279
7992
|
context;
|
|
7280
7993
|
state;
|
|
@@ -7378,7 +8091,12 @@
|
|
|
7378
8091
|
}
|
|
7379
8092
|
if (it.offset > buffer.byteLength) {
|
|
7380
8093
|
buffer = this._resizeBuffer(buffer, it.offset);
|
|
7381
|
-
|
|
8094
|
+
// Reuse `it` (reset its offset) instead of a fresh iterator so the
|
|
8095
|
+
// caller's `it.offset` ends at the true final offset. A fresh one
|
|
8096
|
+
// strands `it.offset` at the overflow value and corrupts the next
|
|
8097
|
+
// view's region in a multi-view encode.
|
|
8098
|
+
it.offset = initialOffset;
|
|
8099
|
+
return this._encodeChannel(it, view, buffer, initialOffset, unreliable);
|
|
7382
8100
|
}
|
|
7383
8101
|
return buffer.subarray(0, it.offset);
|
|
7384
8102
|
}
|
|
@@ -7410,7 +8128,8 @@
|
|
|
7410
8128
|
_fullSyncWalk(ctx, rootChangeTree);
|
|
7411
8129
|
if (it.offset > buffer.byteLength) {
|
|
7412
8130
|
buffer = this._resizeBuffer(buffer, it.offset);
|
|
7413
|
-
|
|
8131
|
+
it.offset = initialOffset; // reuse `it` so the caller's offset stays accurate
|
|
8132
|
+
return this.encodeFullSync(it, buffer, emitFiltered, view, initialOffset);
|
|
7414
8133
|
}
|
|
7415
8134
|
return buffer.subarray(0, it.offset);
|
|
7416
8135
|
}
|
|
@@ -7433,9 +8152,24 @@
|
|
|
7433
8152
|
}
|
|
7434
8153
|
encodeAllView(view, sharedOffset, it, bytes = this.sharedBuffer) {
|
|
7435
8154
|
const viewOffset = it.offset;
|
|
7436
|
-
|
|
8155
|
+
// encodeFullSync() may reallocate the buffer on overflow — keep its
|
|
8156
|
+
// return, not the stale `bytes`, or the concat below reads a dead buffer.
|
|
8157
|
+
bytes = this.encodeFullSync(it, bytes, /* emitFiltered */ true, view, viewOffset);
|
|
7437
8158
|
return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
|
|
7438
8159
|
}
|
|
8160
|
+
/** Grow `buffer` to keep BUFFER_SIZE free bytes past `offset`, preserving `[0, offset)`. */
|
|
8161
|
+
ensureCapacity(buffer, offset) {
|
|
8162
|
+
if (offset + Encoder.BUFFER_SIZE <= buffer.byteLength) {
|
|
8163
|
+
return buffer;
|
|
8164
|
+
}
|
|
8165
|
+
const size = Math.ceil((offset + Encoder.BUFFER_SIZE) / Encoder.BUFFER_SIZE) * Encoder.BUFFER_SIZE;
|
|
8166
|
+
const grown = new Uint8Array(size);
|
|
8167
|
+
grown.set(buffer.subarray(0, offset));
|
|
8168
|
+
if (buffer === this.sharedBuffer) {
|
|
8169
|
+
this.sharedBuffer = grown;
|
|
8170
|
+
}
|
|
8171
|
+
return grown;
|
|
8172
|
+
}
|
|
7439
8173
|
encodeView(view, sharedOffset, it, bytes = this.sharedBuffer) {
|
|
7440
8174
|
const viewOffset = it.offset;
|
|
7441
8175
|
// Stream priority pass: drain up to `maxPerTick` per-view entries
|
|
@@ -7473,6 +8207,9 @@
|
|
|
7473
8207
|
// `[$getByIndex]` lookup that runs once per change.
|
|
7474
8208
|
const ref = changeTree.ref;
|
|
7475
8209
|
const refTarget = changeTree.refTarget;
|
|
8210
|
+
// These writes are unguarded and unrecoverable (view.changes is cleared
|
|
8211
|
+
// below), so unlike encode() they can't re-encode on overflow — grow ahead.
|
|
8212
|
+
bytes = this.ensureCapacity(bytes, it.offset);
|
|
7476
8213
|
bytes[it.offset++] = SWITCH_TO_STRUCTURE & 255;
|
|
7477
8214
|
encode.number(bytes, ref[$refId], it);
|
|
7478
8215
|
// Iterate entries directly — the inner Map gives us the (index, op)
|
|
@@ -7491,9 +8228,14 @@
|
|
|
7491
8228
|
// (to allow re-using StateView's for multiple clients)
|
|
7492
8229
|
//
|
|
7493
8230
|
view.changes.clear();
|
|
7494
|
-
//
|
|
8231
|
+
// Per-tick view-scoped pass: walks the same `changes` queue as the
|
|
7495
8232
|
// shared pass, but `encodeChangeCb` emits only filtered fields.
|
|
7496
|
-
|
|
8233
|
+
// encode() may reallocate the buffer on overflow — keep its return,
|
|
8234
|
+
// not the stale `bytes`. Anchor the re-encode at the current offset
|
|
8235
|
+
// (the default `initialOffset = it.offset`), NOT `viewOffset`: a
|
|
8236
|
+
// resize must not clobber the view.changes already written at
|
|
8237
|
+
// [viewOffset, it.offset).
|
|
8238
|
+
bytes = this.encode(it, view, bytes);
|
|
7497
8239
|
return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
|
|
7498
8240
|
}
|
|
7499
8241
|
/**
|
|
@@ -7505,7 +8247,9 @@
|
|
|
7505
8247
|
*/
|
|
7506
8248
|
encodeUnreliableView(view, sharedOffset, it, bytes = this.sharedBuffer) {
|
|
7507
8249
|
const viewOffset = it.offset;
|
|
7508
|
-
|
|
8250
|
+
// Capture the return: a resize on overflow reallocates the buffer, and
|
|
8251
|
+
// the concat below must read from the live one, not the stale `bytes`.
|
|
8252
|
+
bytes = this.encodeUnreliable(it, view, bytes, viewOffset);
|
|
7509
8253
|
return concatBytes(bytes.subarray(0, sharedOffset), bytes.subarray(viewOffset, it.offset));
|
|
7510
8254
|
}
|
|
7511
8255
|
/**
|
|
@@ -7731,10 +8475,6 @@
|
|
|
7731
8475
|
}
|
|
7732
8476
|
list.next = undefined;
|
|
7733
8477
|
list.tail = undefined;
|
|
7734
|
-
// End-of-tick: refIds released during this tick have now had their
|
|
7735
|
-
// DELETEs emitted through `encode()` / `encodeView()`, so they are
|
|
7736
|
-
// safe to recycle on the next tick.
|
|
7737
|
-
root.refIds.flushReleases();
|
|
7738
8478
|
}
|
|
7739
8479
|
discardUnreliableChanges() {
|
|
7740
8480
|
const list = this.root.unreliableChanges;
|
|
@@ -7788,6 +8528,8 @@
|
|
|
7788
8528
|
this.name = "DecodingWarning";
|
|
7789
8529
|
}
|
|
7790
8530
|
}
|
|
8531
|
+
// Reused across addRef calls — saves a descriptor object per decoded ref.
|
|
8532
|
+
const $refIdDescriptor = { value: 0, enumerable: false, writable: true };
|
|
7791
8533
|
class ReferenceTracker {
|
|
7792
8534
|
//
|
|
7793
8535
|
// Relation of refId => Schema structure
|
|
@@ -7808,11 +8550,13 @@
|
|
|
7808
8550
|
// on decoded instances, which WOULD walk enumerable Symbol-keyed
|
|
7809
8551
|
// properties and include `$refId` in the comparison. Keep the
|
|
7810
8552
|
// descriptor dance for semantic compatibility.
|
|
7811
|
-
|
|
7812
|
-
value
|
|
7813
|
-
|
|
7814
|
-
|
|
7815
|
-
|
|
8553
|
+
if (ref[$refId] === undefined) {
|
|
8554
|
+
$refIdDescriptor.value = refId;
|
|
8555
|
+
Object.defineProperty(ref, $refId, $refIdDescriptor);
|
|
8556
|
+
}
|
|
8557
|
+
else if (ref[$refId] !== refId) {
|
|
8558
|
+
ref[$refId] = refId; // property exists (writable) — plain write keeps flags
|
|
8559
|
+
}
|
|
7816
8560
|
if (incrementCount) {
|
|
7817
8561
|
this.refCount[refId] = (this.refCount[refId] || 0) + 1;
|
|
7818
8562
|
}
|
|
@@ -7926,6 +8670,18 @@
|
|
|
7926
8670
|
root;
|
|
7927
8671
|
currentRefId = 0;
|
|
7928
8672
|
triggerChanges;
|
|
8673
|
+
/**
|
|
8674
|
+
* @internal Non-null only while a `decodeResync()` walk is in progress:
|
|
8675
|
+
* collection refId → entry identities the payload visited (map string
|
|
8676
|
+
* keys; array/set/collection/stream indexes). Written by the collection
|
|
8677
|
+
* DecodeOperation functions, read by the post-decode sweep.
|
|
8678
|
+
*/
|
|
8679
|
+
resyncVisited = null;
|
|
8680
|
+
/**
|
|
8681
|
+
* @internal Set when a structure had to be skipped during a resync
|
|
8682
|
+
* decode — visited data is incomplete, so the sweep must not delete.
|
|
8683
|
+
*/
|
|
8684
|
+
resyncDamaged = false;
|
|
7929
8685
|
constructor(root, context) {
|
|
7930
8686
|
this.setState(root);
|
|
7931
8687
|
this.context = context || new TypeContext(root.constructor);
|
|
@@ -7990,6 +8746,12 @@
|
|
|
7990
8746
|
// currently hooks it; the dual call sites stay as-is rather
|
|
7991
8747
|
// than being extracted into a helper for a one-line body.
|
|
7992
8748
|
ref[$onDecodeEnd]?.();
|
|
8749
|
+
// resync mode: prune everything the snapshot didn't visit. Runs
|
|
8750
|
+
// before triggerChanges (DELETE changes fire onRemove with the real
|
|
8751
|
+
// previousValue) and before GC (removeRef feeds deletedRefs).
|
|
8752
|
+
if (this.resyncVisited !== null) {
|
|
8753
|
+
resyncSweep(this, allChanges);
|
|
8754
|
+
}
|
|
7993
8755
|
// trigger changes
|
|
7994
8756
|
if (allChanges !== null)
|
|
7995
8757
|
this.triggerChanges?.(allChanges);
|
|
@@ -7997,7 +8759,37 @@
|
|
|
7997
8759
|
$root.garbageCollectDeletedRefs();
|
|
7998
8760
|
return allChanges;
|
|
7999
8761
|
}
|
|
8762
|
+
/**
|
|
8763
|
+
* Full-snapshot reconciliation ("resync") decode.
|
|
8764
|
+
*
|
|
8765
|
+
* Behaves exactly like {@link decode}, plus: every collection entry the
|
|
8766
|
+
* payload does NOT mention is removed through the regular DELETE path —
|
|
8767
|
+
* `onRemove` callbacks fire with the real previous value and released
|
|
8768
|
+
* refs are garbage-collected. Use it to apply a rejoin/reconnect full
|
|
8769
|
+
* state over an existing decoded tree: DELETEs that happened while the
|
|
8770
|
+
* client was off the wire are reconciled as if they had been received,
|
|
8771
|
+
* while surviving entries keep their instance identity and callbacks.
|
|
8772
|
+
*
|
|
8773
|
+
* ONLY valid for full-snapshot payloads (`encodeAll` / `encodeAllView`
|
|
8774
|
+
* output). Calling it on an incremental patch would prune everything
|
|
8775
|
+
* the patch doesn't touch.
|
|
8776
|
+
*/
|
|
8777
|
+
decodeResync(bytes, it = { offset: 0 }) {
|
|
8778
|
+
this.resyncVisited = new Map();
|
|
8779
|
+
this.resyncDamaged = false;
|
|
8780
|
+
try {
|
|
8781
|
+
return this.decode(bytes, it);
|
|
8782
|
+
}
|
|
8783
|
+
finally {
|
|
8784
|
+
this.resyncVisited = null;
|
|
8785
|
+
}
|
|
8786
|
+
}
|
|
8000
8787
|
skipCurrentStructure(bytes, it, totalBytes) {
|
|
8788
|
+
// A skipped range can swallow other structures' ops (their ADDs are
|
|
8789
|
+
// never applied), so resync visited data is no longer trustworthy.
|
|
8790
|
+
if (this.resyncVisited !== null) {
|
|
8791
|
+
this.resyncDamaged = true;
|
|
8792
|
+
}
|
|
8001
8793
|
//
|
|
8002
8794
|
// keep skipping next bytes until reaches a known structure
|
|
8003
8795
|
// by local decoder.
|
|
@@ -8047,10 +8839,27 @@
|
|
|
8047
8839
|
/**
|
|
8048
8840
|
* Reflection
|
|
8049
8841
|
*/
|
|
8842
|
+
/**
|
|
8843
|
+
* `t.quantized()` field descriptor as it rides the reflection handshake —
|
|
8844
|
+
* schema-typed (bit-exact float64 bounds), NOT a string grammar, so every
|
|
8845
|
+
* language port decodes it with the schema decoder it already has.
|
|
8846
|
+
*/
|
|
8847
|
+
const QuantizedDescriptor = schema({
|
|
8848
|
+
min: t.float64(),
|
|
8849
|
+
max: t.float64(),
|
|
8850
|
+
bits: t.uint8(),
|
|
8851
|
+
mode: t.uint8(), // 0 = clamp, 1 = wrap
|
|
8852
|
+
}, "QuantizedDescriptor");
|
|
8050
8853
|
const ReflectionField = schema({
|
|
8051
8854
|
name: t.string(),
|
|
8052
8855
|
type: t.string(),
|
|
8053
8856
|
referencedType: t.number(),
|
|
8857
|
+
/** Primitive child of a collection (`array`/`map`/... of "string" etc.) —
|
|
8858
|
+
* its own slot, replacing the legacy `"array:string"` colon packing. */
|
|
8859
|
+
childPrimitive: t.string(),
|
|
8860
|
+
/** Set only on `t.quantized()` fields (`.optional()` — no auto-instantiated
|
|
8861
|
+
* default; its absence is the "not quantized" signal on decode). */
|
|
8862
|
+
quantized: t.ref(QuantizedDescriptor).optional(),
|
|
8054
8863
|
}, "ReflectionField");
|
|
8055
8864
|
const ReflectionType = schema({
|
|
8056
8865
|
id: t.number(),
|
|
@@ -8120,6 +8929,18 @@
|
|
|
8120
8929
|
if (typeof (field.type) === "string") {
|
|
8121
8930
|
fieldType = field.type;
|
|
8122
8931
|
}
|
|
8932
|
+
else if (isQuantizedType(field.type)) {
|
|
8933
|
+
// Params ride as a schema-typed descriptor (bit-exact float64) —
|
|
8934
|
+
// no string grammar for the peer (or a language port) to parse.
|
|
8935
|
+
const d = field.type.quantized;
|
|
8936
|
+
fieldType = "quantized";
|
|
8937
|
+
const desc = new QuantizedDescriptor();
|
|
8938
|
+
desc.min = d.min;
|
|
8939
|
+
desc.max = d.max;
|
|
8940
|
+
desc.bits = d.bits;
|
|
8941
|
+
desc.mode = d.wrap ? 1 : 0;
|
|
8942
|
+
reflectionField.quantized = desc;
|
|
8943
|
+
}
|
|
8123
8944
|
else {
|
|
8124
8945
|
let childTypeSchema;
|
|
8125
8946
|
//
|
|
@@ -8132,7 +8953,8 @@
|
|
|
8132
8953
|
else {
|
|
8133
8954
|
fieldType = Object.keys(field.type)[0];
|
|
8134
8955
|
if (typeof (field.type[fieldType]) === "string") {
|
|
8135
|
-
|
|
8956
|
+
// primitive child gets its own slot (was packed as "array:string")
|
|
8957
|
+
reflectionField.childPrimitive = field.type[fieldType];
|
|
8136
8958
|
}
|
|
8137
8959
|
else {
|
|
8138
8960
|
childTypeSchema = field.type[fieldType];
|
|
@@ -8173,15 +8995,20 @@
|
|
|
8173
8995
|
const addFields = (metadata, reflectionType, parentFieldIndex) => {
|
|
8174
8996
|
reflectionType.fields.forEach((field, i) => {
|
|
8175
8997
|
const fieldIndex = parentFieldIndex + i;
|
|
8176
|
-
if (field.
|
|
8177
|
-
|
|
8178
|
-
|
|
8179
|
-
|
|
8180
|
-
|
|
8181
|
-
|
|
8182
|
-
|
|
8183
|
-
|
|
8184
|
-
|
|
8998
|
+
if (field.quantized !== undefined) {
|
|
8999
|
+
// Schema-typed descriptor → resolved codec (validation stays in
|
|
9000
|
+
// resolveQuantize, same as the builder path).
|
|
9001
|
+
const q = field.quantized;
|
|
9002
|
+
Metadata.addField(metadata, fieldIndex, field.name, {
|
|
9003
|
+
quantized: resolveQuantize({ min: q.min, max: q.max, bits: q.bits, mode: q.mode === 1 ? "wrap" : "clamp" }),
|
|
9004
|
+
});
|
|
9005
|
+
}
|
|
9006
|
+
else if (field.referencedType !== undefined) {
|
|
9007
|
+
const fieldType = field.type;
|
|
9008
|
+
// Schema child by type id; a primitive child (referencedType -1)
|
|
9009
|
+
// rides its own childPrimitive slot.
|
|
9010
|
+
const refType = typeContext.get(field.referencedType)
|
|
9011
|
+
?? field.childPrimitive;
|
|
8185
9012
|
if (fieldType === "ref") {
|
|
8186
9013
|
Metadata.addField(metadata, fieldIndex, field.name, refType);
|
|
8187
9014
|
}
|
|
@@ -8864,6 +9691,48 @@
|
|
|
8864
9691
|
}
|
|
8865
9692
|
};
|
|
8866
9693
|
|
|
9694
|
+
/**
|
|
9695
|
+
* Object pool for Schema instances. Exported as a **type only** — construct one
|
|
9696
|
+
* via {@link createPool}, which is the public entry point. The class is public
|
|
9697
|
+
* for typing (`SchemaPool<Entity>` annotations) and `instanceof` checks.
|
|
9698
|
+
*/
|
|
9699
|
+
class SchemaPool {
|
|
9700
|
+
_free = [];
|
|
9701
|
+
_factory;
|
|
9702
|
+
_maxSize;
|
|
9703
|
+
constructor(factory, opts = {}) {
|
|
9704
|
+
this._factory = factory;
|
|
9705
|
+
this._maxSize = opts.maxSize ?? Infinity;
|
|
9706
|
+
const preallocate = opts.preallocate ?? 0;
|
|
9707
|
+
for (let i = 0; i < preallocate; i++) {
|
|
9708
|
+
this._free.push(factory());
|
|
9709
|
+
}
|
|
9710
|
+
}
|
|
9711
|
+
/** Pop a pre-reset free instance, or construct a fresh one. */
|
|
9712
|
+
acquire() {
|
|
9713
|
+
return this._free.length > 0 ? this._free.pop() : this._factory();
|
|
9714
|
+
}
|
|
9715
|
+
/**
|
|
9716
|
+
* Reset `instance` to construction defaults and return it to the pool.
|
|
9717
|
+
* PRECONDITION: the instance must already be detached from the state tree
|
|
9718
|
+
* (removed from its parent collection/field, so the encoder released it).
|
|
9719
|
+
*/
|
|
9720
|
+
release(instance) {
|
|
9721
|
+
Schema.reset(instance);
|
|
9722
|
+
if (this._free.length < this._maxSize) {
|
|
9723
|
+
this._free.push(instance);
|
|
9724
|
+
}
|
|
9725
|
+
}
|
|
9726
|
+
/** Number of instances currently available for reuse. */
|
|
9727
|
+
get size() {
|
|
9728
|
+
return this._free.length;
|
|
9729
|
+
}
|
|
9730
|
+
}
|
|
9731
|
+
/** Convenience factory: `createPool(Entity, { preallocate: 64 })`. */
|
|
9732
|
+
function createPool(ctor, opts = {}) {
|
|
9733
|
+
return new SchemaPool(() => new ctor(), opts);
|
|
9734
|
+
}
|
|
9735
|
+
|
|
8867
9736
|
/**
|
|
8868
9737
|
* Clear the bit for `(slot, bit)` on every ChangeTree in `root`. Called
|
|
8869
9738
|
* from `dispose()` and from the FinalizationRegistry callback so a view's
|
|
@@ -9066,42 +9935,66 @@
|
|
|
9066
9935
|
// `tags: WeakMap<ChangeTree, Set<number>>` storage. Hot read site is
|
|
9067
9936
|
// `Schema.ts` filter check — `hasTagOnTree` is O(1) bitwise.
|
|
9068
9937
|
// ──────────────────────────────────────────────────────────────────
|
|
9069
|
-
/**
|
|
9938
|
+
/**
|
|
9939
|
+
* True iff this view shares at least one tag bit with `tree`.
|
|
9940
|
+
*
|
|
9941
|
+
* `tagViews` is keyed by individual power-of-two bits (custom tags must
|
|
9942
|
+
* be powers of two; `@view(A|B)` field masks are decomposed on store).
|
|
9943
|
+
* A field whose mask is `tag` is visible if the view was `add()`ed with
|
|
9944
|
+
* any overlapping bit — so we walk `tag`'s set bits and return on the
|
|
9945
|
+
* first match. Passing DEFAULT_VIEW_TAG (-1, all bits) answers "does
|
|
9946
|
+
* this view hold ANY custom tag on the tree".
|
|
9947
|
+
*/
|
|
9070
9948
|
hasTagOnTree(tree, tag) {
|
|
9071
9949
|
const map = tree.tagViews;
|
|
9072
9950
|
if (map === undefined)
|
|
9073
9951
|
return false;
|
|
9074
|
-
const arr = map.get(tag);
|
|
9075
9952
|
const slot = this._slot;
|
|
9076
|
-
|
|
9953
|
+
const bit = this._bit;
|
|
9954
|
+
for (let bits = tag; bits !== 0; bits &= bits - 1) {
|
|
9955
|
+
const arr = map.get(bits & -bits); // isolate lowest set bit
|
|
9956
|
+
if (arr !== undefined && slot < arr.length && (arr[slot] & bit) !== 0)
|
|
9957
|
+
return true;
|
|
9958
|
+
}
|
|
9959
|
+
return false;
|
|
9077
9960
|
}
|
|
9078
|
-
/** Mark `tree` as carrying `tag` for this view. */
|
|
9961
|
+
/** Mark `tree` as carrying `tag` (each of its bits) for this view. */
|
|
9079
9962
|
addTag(tree, tag) {
|
|
9963
|
+
// DEFAULT_VIEW_TAG visibility lives in `visibleViews`, not here.
|
|
9964
|
+
if (tag === DEFAULT_VIEW_TAG)
|
|
9965
|
+
return;
|
|
9080
9966
|
let map = tree.tagViews;
|
|
9081
9967
|
if (map === undefined) {
|
|
9082
9968
|
map = tree.tagViews = new Map();
|
|
9083
9969
|
}
|
|
9084
|
-
let arr = map.get(tag);
|
|
9085
|
-
if (arr === undefined) {
|
|
9086
|
-
arr = [];
|
|
9087
|
-
map.set(tag, arr);
|
|
9088
|
-
}
|
|
9089
9970
|
const slot = this._slot;
|
|
9090
|
-
|
|
9091
|
-
|
|
9092
|
-
|
|
9971
|
+
const bit = this._bit;
|
|
9972
|
+
for (let bits = tag; bits > 0; bits &= bits - 1) {
|
|
9973
|
+
const b = bits & -bits; // isolate lowest set bit
|
|
9974
|
+
let arr = map.get(b);
|
|
9975
|
+
if (arr === undefined) {
|
|
9976
|
+
arr = [];
|
|
9977
|
+
map.set(b, arr);
|
|
9978
|
+
}
|
|
9979
|
+
while (arr.length <= slot)
|
|
9980
|
+
arr.push(0);
|
|
9981
|
+
arr[slot] |= bit;
|
|
9982
|
+
}
|
|
9093
9983
|
}
|
|
9094
|
-
/** Clear
|
|
9984
|
+
/** Clear each of `tag`'s bits for this view on `tree`. */
|
|
9095
9985
|
removeTag(tree, tag) {
|
|
9986
|
+
if (tag === DEFAULT_VIEW_TAG)
|
|
9987
|
+
return;
|
|
9096
9988
|
const map = tree.tagViews;
|
|
9097
9989
|
if (map === undefined)
|
|
9098
9990
|
return;
|
|
9099
|
-
const arr = map.get(tag);
|
|
9100
|
-
if (arr === undefined)
|
|
9101
|
-
return;
|
|
9102
9991
|
const slot = this._slot;
|
|
9103
|
-
|
|
9104
|
-
|
|
9992
|
+
const clearMask = ~this._bit;
|
|
9993
|
+
for (let bits = tag; bits > 0; bits &= bits - 1) {
|
|
9994
|
+
const arr = map.get(bits & -bits);
|
|
9995
|
+
if (arr !== undefined && slot < arr.length)
|
|
9996
|
+
arr[slot] &= clearMask;
|
|
9997
|
+
}
|
|
9105
9998
|
}
|
|
9106
9999
|
/** Clear ALL tag bits this view holds on `tree` (used when the per-tag isn't known). */
|
|
9107
10000
|
removeAllTagsOnTree(tree) {
|
|
@@ -9233,10 +10126,16 @@
|
|
|
9233
10126
|
// direct array index instead of a per-field-object hop.
|
|
9234
10127
|
const tags = changeTree.encDescriptor.tags;
|
|
9235
10128
|
changeTree.forEachChild((change, index) => {
|
|
9236
|
-
// Do not ADD children
|
|
10129
|
+
// Do not ADD children whose field tag shares no bit with `tag`.
|
|
10130
|
+
// DEFAULT_VIEW_TAG fields are visible to all clients; custom-tag
|
|
10131
|
+
// fields only when bits overlap, and never to default-tag clients.
|
|
9237
10132
|
const fieldTag = tags[index];
|
|
9238
|
-
if (fieldTag !== undefined
|
|
9239
|
-
|
|
10133
|
+
if (fieldTag !== undefined) {
|
|
10134
|
+
const tagMatch = fieldTag === DEFAULT_VIEW_TAG ||
|
|
10135
|
+
(tag !== DEFAULT_VIEW_TAG && (fieldTag & tag) !== 0);
|
|
10136
|
+
if (!tagMatch) {
|
|
10137
|
+
return;
|
|
10138
|
+
}
|
|
9240
10139
|
}
|
|
9241
10140
|
if (this.add(change.ref, tag, false)) {
|
|
9242
10141
|
isChildAdded = true;
|
|
@@ -9245,12 +10144,19 @@
|
|
|
9245
10144
|
// set tag
|
|
9246
10145
|
if (tag !== DEFAULT_VIEW_TAG) {
|
|
9247
10146
|
this.addTag(changeTree, tag);
|
|
9248
|
-
// Ref: add tagged properties
|
|
9249
|
-
|
|
9250
|
-
|
|
9251
|
-
|
|
10147
|
+
// Ref: add tagged properties. `$fieldIndexesByViewTag` is keyed
|
|
10148
|
+
// per-bit, so a combined add-tag (`view.add(obj, A|B)`) must look
|
|
10149
|
+
// up each set bit to force-ADD every field that shares a bit.
|
|
10150
|
+
const byTag = metadata?.[$fieldIndexesByViewTag];
|
|
10151
|
+
if (byTag !== undefined) {
|
|
10152
|
+
for (let bits = tag; bits > 0; bits &= bits - 1) {
|
|
10153
|
+
byTag[bits & -bits]?.forEach((index) => {
|
|
10154
|
+
if (changeTree.getChange(index) !== exports.OPERATION.DELETE) {
|
|
10155
|
+
changes.set(index, exports.OPERATION.ADD);
|
|
10156
|
+
}
|
|
10157
|
+
});
|
|
9252
10158
|
}
|
|
9253
|
-
}
|
|
10159
|
+
}
|
|
9254
10160
|
}
|
|
9255
10161
|
else if (!changeTree.isNew || isChildAdded) {
|
|
9256
10162
|
// new structures will be added as part of .encode() call, no need to force it to .encodeView()
|
|
@@ -9266,7 +10172,8 @@
|
|
|
9266
10172
|
const tagAtIndex = tags[index];
|
|
9267
10173
|
if (isInvisible || // if "invisible", include all
|
|
9268
10174
|
tagAtIndex === undefined || // "all change" with no tag
|
|
9269
|
-
tagAtIndex ===
|
|
10175
|
+
tagAtIndex === DEFAULT_VIEW_TAG || // visible to all clients
|
|
10176
|
+
(tag !== DEFAULT_VIEW_TAG && (tagAtIndex & tag) !== 0) // tag bits overlap
|
|
9270
10177
|
) {
|
|
9271
10178
|
changes.set(index, exports.OPERATION.ADD);
|
|
9272
10179
|
isChildAdded = true;
|
|
@@ -9296,8 +10203,12 @@
|
|
|
9296
10203
|
const tags = tree.encDescriptor.tags;
|
|
9297
10204
|
tree.forEachChild((child, index) => {
|
|
9298
10205
|
const fieldTag = tags[index];
|
|
9299
|
-
if (fieldTag !== undefined
|
|
9300
|
-
|
|
10206
|
+
if (fieldTag !== undefined) {
|
|
10207
|
+
const tagMatch = fieldTag === DEFAULT_VIEW_TAG ||
|
|
10208
|
+
(tag !== DEFAULT_VIEW_TAG && (fieldTag & tag) !== 0);
|
|
10209
|
+
if (!tagMatch)
|
|
10210
|
+
return;
|
|
10211
|
+
}
|
|
9301
10212
|
if (child.isNew) {
|
|
9302
10213
|
this.markVisible(child);
|
|
9303
10214
|
this._markSubtreeVisible(child, tag);
|
|
@@ -9313,20 +10224,25 @@
|
|
|
9313
10224
|
if (!this.isVisible(changeTree)) {
|
|
9314
10225
|
// view must have all "changeTree" parent tree
|
|
9315
10226
|
this.markVisible(changeTree);
|
|
9316
|
-
|
|
9317
|
-
|
|
9318
|
-
|
|
9319
|
-
|
|
9320
|
-
|
|
9321
|
-
|
|
9322
|
-
|
|
9323
|
-
|
|
9324
|
-
|
|
9325
|
-
|
|
9326
|
-
|
|
9327
|
-
|
|
9328
|
-
|
|
9329
|
-
|
|
10227
|
+
}
|
|
10228
|
+
// Recurse all the way to the root REGARDLESS of whether this parent
|
|
10229
|
+
// is already visible. Walking the full chain keeps `view.changes`
|
|
10230
|
+
// topologically ordered by construction (ancestors touched before
|
|
10231
|
+
// the descendant's entry), and — crucially — re-queues the ancestor
|
|
10232
|
+
// binding ops every time: visibility bits are per-VIEW, but the ADD
|
|
10233
|
+
// ops they once queued are consumed per-ENCODE. With a shared view,
|
|
10234
|
+
// an earlier encode (for other clients) or a dropped backlog leaves
|
|
10235
|
+
// an already-visible ancestor whose binding a late-attached client
|
|
10236
|
+
// never received — its filtered-container refId would then arrive
|
|
10237
|
+
// unbound ("refId not found"). Re-writing the entry ops is cheap
|
|
10238
|
+
// (Map.set dedupes within a patch) and decodes as a no-op for
|
|
10239
|
+
// clients that already hold the refs. The entry-write below is
|
|
10240
|
+
// still gated on `hasFilteredFields` so non-filtered ancestors
|
|
10241
|
+
// don't emit redundant wire bytes (the decoder already knows them
|
|
10242
|
+
// via the shared encode pass).
|
|
10243
|
+
const parentChangeTree = changeTree.parent?.[$changes];
|
|
10244
|
+
if (parentChangeTree) {
|
|
10245
|
+
this.addParentOf(changeTree, tag);
|
|
9330
10246
|
}
|
|
9331
10247
|
// Skip the entry-write for non-filtered ancestors: their refIds
|
|
9332
10248
|
// are already known to the decoder through the shared pass, and
|
|
@@ -9492,19 +10408,25 @@
|
|
|
9492
10408
|
}
|
|
9493
10409
|
}
|
|
9494
10410
|
else {
|
|
9495
|
-
// delete only tagged properties
|
|
10411
|
+
// delete only tagged properties. `$fieldIndexesByViewTag` is
|
|
10412
|
+
// keyed per-bit, so a combined tag iterates each set bit.
|
|
9496
10413
|
const names = changeTree.encDescriptor.names;
|
|
9497
|
-
metadata?.[$fieldIndexesByViewTag]
|
|
9498
|
-
|
|
9499
|
-
|
|
9500
|
-
|
|
9501
|
-
|
|
9502
|
-
|
|
9503
|
-
|
|
10414
|
+
const byTag = metadata?.[$fieldIndexesByViewTag];
|
|
10415
|
+
if (byTag !== undefined) {
|
|
10416
|
+
for (let bits = tag; bits > 0; bits &= bits - 1) {
|
|
10417
|
+
byTag[bits & -bits]?.forEach((index) => {
|
|
10418
|
+
changes.set(index, exports.OPERATION.DELETE);
|
|
10419
|
+
// Remove child structures from visible set
|
|
10420
|
+
const value = changeTree.ref[names[index]];
|
|
10421
|
+
if (value?.[$changes]) {
|
|
10422
|
+
this.unmarkVisible(value[$changes]);
|
|
10423
|
+
this._recursiveDeleteVisibleChangeTree(value[$changes]);
|
|
10424
|
+
}
|
|
10425
|
+
});
|
|
9504
10426
|
}
|
|
9505
|
-
}
|
|
10427
|
+
}
|
|
9506
10428
|
}
|
|
9507
|
-
// remove tag
|
|
10429
|
+
// remove tag bits for this view
|
|
9508
10430
|
if (tag === undefined) {
|
|
9509
10431
|
this.removeAllTagsOnTree(changeTree);
|
|
9510
10432
|
}
|
|
@@ -9681,8 +10603,10 @@
|
|
|
9681
10603
|
exports.$encoder = $encoder;
|
|
9682
10604
|
exports.$filter = $filter;
|
|
9683
10605
|
exports.$getByIndex = $getByIndex;
|
|
10606
|
+
exports.$numFields = $numFields;
|
|
9684
10607
|
exports.$refId = $refId;
|
|
9685
10608
|
exports.$track = $track;
|
|
10609
|
+
exports.$values = $values;
|
|
9686
10610
|
exports.ArraySchema = ArraySchema;
|
|
9687
10611
|
exports.Callbacks = Callbacks;
|
|
9688
10612
|
exports.ChangeTree = ChangeTree;
|
|
@@ -9702,10 +10626,12 @@
|
|
|
9702
10626
|
exports.StateView = StateView;
|
|
9703
10627
|
exports.StreamSchema = StreamSchema;
|
|
9704
10628
|
exports.TypeContext = TypeContext;
|
|
10629
|
+
exports.createPool = createPool;
|
|
9705
10630
|
exports.decode = decode;
|
|
9706
10631
|
exports.decodeKeyValueOperation = decodeKeyValueOperation;
|
|
9707
10632
|
exports.decodeSchemaOperation = decodeSchemaOperation;
|
|
9708
10633
|
exports.defineCustomTypes = defineCustomTypes;
|
|
10634
|
+
exports.defineTypes = defineTypes;
|
|
9709
10635
|
exports.deprecated = deprecated;
|
|
9710
10636
|
exports.dumpChanges = dumpChanges;
|
|
9711
10637
|
exports.encode = encode;
|
|
@@ -9716,6 +10642,7 @@
|
|
|
9716
10642
|
exports.encodeSchemaOperation = encodeSchemaOperation;
|
|
9717
10643
|
exports.entity = entity;
|
|
9718
10644
|
exports.getDecoderStateCallbacks = getDecoderStateCallbacks;
|
|
10645
|
+
exports.getEncodeDescriptor = getEncodeDescriptor;
|
|
9719
10646
|
exports.getRawChangesCallback = getRawChangesCallback;
|
|
9720
10647
|
exports.isBuilder = isBuilder;
|
|
9721
10648
|
exports.owned = owned;
|