@colyseus/schema 5.0.6 → 5.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/Reflection.d.ts +53 -0
- package/build/Schema.d.ts +26 -1
- package/build/annotations.d.ts +8 -1
- package/build/codegen/cli.cjs +74 -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 +1192 -284
- package/build/index.cjs.map +1 -1
- package/build/index.d.ts +3 -0
- package/build/index.js +1192 -284
- package/build/index.mjs +1189 -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 +133 -51
- package/src/bench_churn.ts +121 -0
- package/src/codegen/languages/haxe.ts +0 -16
- package/src/codegen/parser.ts +29 -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 +11 -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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $changes, $childType, $decoder, $deleteByIndex, $onEncodeEnd, $encoder, $filter, $getByIndex, $onDecodeEnd, $proxyTarget, $refId } from "../symbols.js";
|
|
1
|
+
import { $changes, $childType, $decoder, $deleteByIndex, $onEncodeEnd, $encoder, $filter, $getByIndex, $onDecodeEnd, $proxyTarget, $refId, $reset, $resyncPrune } from "../symbols.js";
|
|
2
2
|
import type { Schema } from "../../Schema.js";
|
|
3
3
|
import { type IRef, ChangeTree, installUntrackedChangeTree } from "../../encoder/ChangeTree.js";
|
|
4
4
|
import { OPERATION } from "../../encoding/spec.js";
|
|
@@ -120,6 +120,8 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
|
|
|
120
120
|
protected tmpItems: V[] = [];
|
|
121
121
|
protected deletedIndexes: boolean[] = [];
|
|
122
122
|
protected isMovingItems = false;
|
|
123
|
+
/** Decode-side: `items` has holes (delete or gap-write) — `$onDecodeEnd` must compact. */
|
|
124
|
+
protected _needsCompaction = false;
|
|
123
125
|
|
|
124
126
|
static [$encoder] = encodeArray;
|
|
125
127
|
static [$decoder] = decodeArray;
|
|
@@ -136,10 +138,11 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
|
|
|
136
138
|
* - Then, the encoder iterates over all "owned" properties per instance and encodes them.
|
|
137
139
|
*/
|
|
138
140
|
static [$filter] (ref: ArraySchema, index: number, view: StateView) {
|
|
141
|
+
if (!view) return true; // must stay first — encodeAll hits this per element
|
|
142
|
+
const self = ref[$proxyTarget] ?? ref; // ref arrives proxied — skip traps below
|
|
139
143
|
return (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
view.isChangeTreeVisible(ref['tmpItems'][index]?.[$changes])
|
|
144
|
+
typeof (self[$childType]) === "string" ||
|
|
145
|
+
view.isChangeTreeVisible(self['tmpItems'][index]?.[$changes])
|
|
143
146
|
);
|
|
144
147
|
}
|
|
145
148
|
|
|
@@ -194,6 +197,7 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
|
|
|
194
197
|
// staged-snapshot path in `$getByIndex`, `$onEncodeEnd`, etc.). The
|
|
195
198
|
// decoder reads from `items` directly and never maintains them.
|
|
196
199
|
self.isMovingItems = false;
|
|
200
|
+
self._needsCompaction = false;
|
|
197
201
|
self[$childType] = undefined;
|
|
198
202
|
self[$proxyTarget] = self;
|
|
199
203
|
|
|
@@ -344,6 +348,9 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
|
|
|
344
348
|
this.items[index] = value;
|
|
345
349
|
|
|
346
350
|
} else {
|
|
351
|
+
if (index > this.items.length) {
|
|
352
|
+
this._needsCompaction = true; // gap-write (filtered/out-of-order ADD) leaves holes
|
|
353
|
+
}
|
|
347
354
|
this.items[index] = value;
|
|
348
355
|
}
|
|
349
356
|
}
|
|
@@ -370,6 +377,27 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
|
|
|
370
377
|
self.tmpItems.length = 0;
|
|
371
378
|
}
|
|
372
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Pool reset: empty this array and recycle its ChangeTree WITHOUT recording
|
|
382
|
+
* any wire op (the parent field's ADD/DELETE owns the wire). Recurses into
|
|
383
|
+
* ref-type children. Called by Schema.reset when a pooled entity has an
|
|
384
|
+
* array field. The instance must already be detached from the encoder.
|
|
385
|
+
*/
|
|
386
|
+
[$reset]() {
|
|
387
|
+
const self = this[$proxyTarget] ?? this;
|
|
388
|
+
const changeTree = self[$changes];
|
|
389
|
+
if (changeTree.isStreamCollection) {
|
|
390
|
+
throw new Error(`@colyseus/schema: cannot reset a streamed ArraySchema (pooling not supported).`);
|
|
391
|
+
}
|
|
392
|
+
const items = self.items;
|
|
393
|
+
for (let i = 0; i < items.length; i++) (items[i] as any)?.[$reset]?.();
|
|
394
|
+
self.items.length = 0;
|
|
395
|
+
self.tmpItems.length = 0;
|
|
396
|
+
self.deletedIndexes.length = 0;
|
|
397
|
+
changeTree.recycle();
|
|
398
|
+
self[$refId] = undefined; // assign (not delete) to avoid V8 dictionary-mode deopt
|
|
399
|
+
}
|
|
400
|
+
|
|
373
401
|
/**
|
|
374
402
|
* Combines two or more arrays.
|
|
375
403
|
* @param items Additional items to add to the end of array1.
|
|
@@ -847,24 +875,54 @@ export class ArraySchema<V = any> implements Array<V>, Collection<number, V>, IR
|
|
|
847
875
|
* `$deleteByIndex` below.
|
|
848
876
|
*/
|
|
849
877
|
[$getByIndex](index: number, isEncodeAll: boolean = false): any {
|
|
878
|
+
const self = this[$proxyTarget] ?? this; // called via Proxy — one trap here beats one per field read
|
|
850
879
|
return (isEncodeAll)
|
|
851
|
-
?
|
|
852
|
-
:
|
|
853
|
-
?
|
|
854
|
-
:
|
|
880
|
+
? self.items[index]
|
|
881
|
+
: self.deletedIndexes[index]
|
|
882
|
+
? self.items[index]
|
|
883
|
+
: self.tmpItems[index] || self.items[index];
|
|
855
884
|
}
|
|
856
885
|
|
|
857
886
|
[$deleteByIndex](index: number): void {
|
|
858
|
-
this
|
|
887
|
+
const self = this[$proxyTarget] ?? this;
|
|
888
|
+
self.items[index] = undefined;
|
|
889
|
+
self._needsCompaction = true;
|
|
859
890
|
}
|
|
860
891
|
|
|
861
892
|
protected [$onEncodeEnd]() {
|
|
862
|
-
|
|
863
|
-
|
|
893
|
+
const self = this[$proxyTarget] ?? this;
|
|
894
|
+
self.tmpItems = self.items.slice();
|
|
895
|
+
self.deletedIndexes.length = 0;
|
|
864
896
|
}
|
|
865
897
|
|
|
866
898
|
protected [$onDecodeEnd]() {
|
|
867
|
-
|
|
899
|
+
const self = this[$proxyTarget] ?? this;
|
|
900
|
+
if (self._needsCompaction) {
|
|
901
|
+
self._needsCompaction = false;
|
|
902
|
+
self.items = self.items.filter((item) => item !== undefined);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
[$resyncPrune](
|
|
907
|
+
visited: Set<number | string>,
|
|
908
|
+
prune: (value: V, identity: number | string) => void,
|
|
909
|
+
keep: (value: V) => void,
|
|
910
|
+
): void {
|
|
911
|
+
// `items` is hole-free here: the decode loop's $onDecodeEnd already
|
|
912
|
+
// ran, and a full-sync emits dense ADDs (no DELETEs, no gap-writes)
|
|
913
|
+
// so no compaction happened mid-decode. Visited indexes may still be
|
|
914
|
+
// sparse (ADD_BY_REFID resolves to the current client-side index).
|
|
915
|
+
const self = this[$proxyTarget] ?? this;
|
|
916
|
+
const items = self.items;
|
|
917
|
+
let removed = false;
|
|
918
|
+
for (let i = 0; i < items.length; i++) {
|
|
919
|
+
const value = items[i];
|
|
920
|
+
if (visited.has(i)) { keep(value); continue; }
|
|
921
|
+
removed = true;
|
|
922
|
+
prune(value, i);
|
|
923
|
+
self[$deleteByIndex](i);
|
|
924
|
+
}
|
|
925
|
+
if (removed) { self[$onDecodeEnd](); } // compact the holes
|
|
868
926
|
}
|
|
869
927
|
|
|
870
928
|
toArray() {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $changes, $childType, $decoder, $deleteByIndex, $encoder, $filter, $getByIndex, $onEncodeEnd, $refId } from "../symbols.js";
|
|
1
|
+
import { $changes, $childType, $decoder, $deleteByIndex, $encoder, $filter, $getByIndex, $onEncodeEnd, $refId, $reset, $resyncPrune } from "../symbols.js";
|
|
2
2
|
import { ChangeTree, installUntrackedChangeTree, type IRef } from "../../encoder/ChangeTree.js";
|
|
3
3
|
import { OPERATION } from "../../encoding/spec.js";
|
|
4
4
|
import { registerType } from "../registry.js";
|
|
@@ -195,6 +195,25 @@ export class CollectionSchema<V=any> implements Collection<K, V>, IRef {
|
|
|
195
195
|
changeTree.operation(OPERATION.CLEAR);
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Pool reset: empty this collection and recycle its ChangeTree WITHOUT
|
|
200
|
+
* recording any wire op (the parent field's ADD/DELETE owns the wire).
|
|
201
|
+
* Recurses into ref-type children. Called by Schema.reset when a pooled
|
|
202
|
+
* entity has a collection field. Must already be detached from the encoder.
|
|
203
|
+
*/
|
|
204
|
+
[$reset]() {
|
|
205
|
+
const changeTree = this[$changes];
|
|
206
|
+
if (changeTree.isStreamCollection) {
|
|
207
|
+
throw new Error(`@colyseus/schema: cannot reset a streamed CollectionSchema (pooling not supported).`);
|
|
208
|
+
}
|
|
209
|
+
this.$items.forEach((value: any) => value?.[$reset]?.());
|
|
210
|
+
this.$items.clear();
|
|
211
|
+
this.deletedItems = {};
|
|
212
|
+
this.$refId = 0; // reset the monotonic index counter (field, not the symbol)
|
|
213
|
+
changeTree.recycle();
|
|
214
|
+
this[$refId] = undefined; // drop encoder ref identity by assign (not delete: avoids dict-mode deopt)
|
|
215
|
+
}
|
|
216
|
+
|
|
198
217
|
has (value: V): boolean {
|
|
199
218
|
return Array.from(this.$items.values()).some((v) => v === value);
|
|
200
219
|
}
|
|
@@ -244,6 +263,22 @@ export class CollectionSchema<V=any> implements Collection<K, V>, IRef {
|
|
|
244
263
|
this.$items.delete(index);
|
|
245
264
|
}
|
|
246
265
|
|
|
266
|
+
[$resyncPrune](
|
|
267
|
+
visited: Set<number | string>,
|
|
268
|
+
prune: (value: V, identity: number | string) => void,
|
|
269
|
+
keep: (value: V) => void,
|
|
270
|
+
): void {
|
|
271
|
+
let toDelete: number[] | null = null;
|
|
272
|
+
this.$items.forEach((value, index) => {
|
|
273
|
+
if (visited.has(index)) { keep(value); return; }
|
|
274
|
+
(toDelete ??= []).push(index);
|
|
275
|
+
prune(value, index);
|
|
276
|
+
});
|
|
277
|
+
if (toDelete !== null) {
|
|
278
|
+
for (let i = 0; i < toDelete.length; i++) { this[$deleteByIndex](toDelete[i]); }
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
247
282
|
protected [$onEncodeEnd]() {
|
|
248
283
|
for (const key in this.deletedItems) { delete this.deletedItems[key]; }
|
|
249
284
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $changes, $childType, $decoder, $deleteByIndex, $onEncodeEnd, $encoder, $filter, $getByIndex, $refId } from "../symbols.js";
|
|
1
|
+
import { $changes, $childType, $decoder, $deleteByIndex, $onEncodeEnd, $encoder, $filter, $getByIndex, $refId, $reset, $resyncPrune } from "../symbols.js";
|
|
2
2
|
import { ChangeTree, installUntrackedChangeTree, IRef } from "../../encoder/ChangeTree.js";
|
|
3
3
|
import { OPERATION } from "../../encoding/spec.js";
|
|
4
4
|
import { registerType } from "../registry.js";
|
|
@@ -283,6 +283,25 @@ export class MapSchema<V=any, K extends string = string> implements Map<K, V>, C
|
|
|
283
283
|
changeTree.operation(OPERATION.CLEAR);
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Pool reset: empty this map and recycle its ChangeTree WITHOUT recording
|
|
288
|
+
* any wire op (the parent field's ADD/DELETE owns the wire). Recurses into
|
|
289
|
+
* ref-type children. Called by Schema.reset when a pooled entity has a
|
|
290
|
+
* map field. The instance must already be detached from the encoder.
|
|
291
|
+
*/
|
|
292
|
+
[$reset]() {
|
|
293
|
+
const changeTree = this[$changes];
|
|
294
|
+
if (changeTree.isStreamCollection) {
|
|
295
|
+
throw new Error(`@colyseus/schema: cannot reset a streamed MapSchema (pooling not supported).`);
|
|
296
|
+
}
|
|
297
|
+
// reset ref-type children first (primitives optional-chain away)
|
|
298
|
+
this.$items.forEach((value: any) => value?.[$reset]?.());
|
|
299
|
+
this.$items.clear();
|
|
300
|
+
this.journal.reset();
|
|
301
|
+
changeTree.recycle();
|
|
302
|
+
this[$refId] = undefined; // assign (not delete) to avoid V8 dictionary-mode deopt
|
|
303
|
+
}
|
|
304
|
+
|
|
286
305
|
has (key: K) {
|
|
287
306
|
return this.$items.has(key);
|
|
288
307
|
}
|
|
@@ -334,6 +353,36 @@ export class MapSchema<V=any, K extends string = string> implements Map<K, V>, C
|
|
|
334
353
|
}
|
|
335
354
|
}
|
|
336
355
|
|
|
356
|
+
[$resyncPrune](
|
|
357
|
+
visited: Set<number | string>,
|
|
358
|
+
prune: (value: V, identity: number | string) => void,
|
|
359
|
+
keep: (value: V) => void,
|
|
360
|
+
): void {
|
|
361
|
+
// maps prune by string key, NOT wire index — the decoder-side
|
|
362
|
+
// journal never evicts stale index→key mappings on re-indexing.
|
|
363
|
+
let deletedKeys: Set<string> | null = null;
|
|
364
|
+
this.$items.forEach((value, key) => {
|
|
365
|
+
if (visited.has(key)) { keep(value); return; }
|
|
366
|
+
(deletedKeys ??= new Set()).add(key);
|
|
367
|
+
prune(value, key);
|
|
368
|
+
});
|
|
369
|
+
if (deletedKeys !== null) {
|
|
370
|
+
deletedKeys.forEach((key) => {
|
|
371
|
+
this.$items.delete(key as K);
|
|
372
|
+
delete this.journal.indexByKey[key];
|
|
373
|
+
});
|
|
374
|
+
// drop index→key mappings of swept keys — including stale ones
|
|
375
|
+
// left behind by re-indexing.
|
|
376
|
+
const staleIndexes: number[] = [];
|
|
377
|
+
this.journal.keyByIndex.forEach((key, index) => {
|
|
378
|
+
if (deletedKeys!.has(key as unknown as string)) { staleIndexes.push(index); }
|
|
379
|
+
});
|
|
380
|
+
for (let i = 0; i < staleIndexes.length; i++) {
|
|
381
|
+
this.journal.keyByIndex.delete(staleIndexes[i]);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
337
386
|
protected [$onEncodeEnd]() {
|
|
338
387
|
this.journal.cleanupAfterEncode();
|
|
339
388
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { OPERATION } from "../../encoding/spec.js";
|
|
2
2
|
import { registerType } from "../registry.js";
|
|
3
|
-
import { $changes, $childType, $decoder, $deleteByIndex, $encoder, $filter, $getByIndex, $onEncodeEnd, $refId } from "../symbols.js";
|
|
3
|
+
import { $changes, $childType, $decoder, $deleteByIndex, $encoder, $filter, $getByIndex, $onEncodeEnd, $refId, $reset, $resyncPrune } from "../symbols.js";
|
|
4
4
|
import { Collection } from "../HelperTypes.js";
|
|
5
5
|
import { ChangeTree, installUntrackedChangeTree, type IRef } from "../../encoder/ChangeTree.js";
|
|
6
6
|
import { encodeIndexedEntry } from "../../encoder/EncodeOperation.js";
|
|
@@ -195,6 +195,25 @@ export class SetSchema<V=any> implements Collection<number, V>, IRef {
|
|
|
195
195
|
changeTree.operation(OPERATION.CLEAR);
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Pool reset: empty this set and recycle its ChangeTree WITHOUT recording
|
|
200
|
+
* any wire op (the parent field's ADD/DELETE owns the wire). Recurses into
|
|
201
|
+
* ref-type children. Called by Schema.reset when a pooled entity has a set
|
|
202
|
+
* field. The instance must already be detached from the encoder.
|
|
203
|
+
*/
|
|
204
|
+
[$reset]() {
|
|
205
|
+
const changeTree = this[$changes];
|
|
206
|
+
if (changeTree.isStreamCollection) {
|
|
207
|
+
throw new Error(`@colyseus/schema: cannot reset a streamed SetSchema (pooling not supported).`);
|
|
208
|
+
}
|
|
209
|
+
this.$items.forEach((value: any) => value?.[$reset]?.());
|
|
210
|
+
this.$items.clear();
|
|
211
|
+
this.deletedItems = {};
|
|
212
|
+
this.$refId = 0; // reset the monotonic index counter (field, not the symbol)
|
|
213
|
+
changeTree.recycle();
|
|
214
|
+
this[$refId] = undefined; // drop encoder ref identity by assign (not delete: avoids dict-mode deopt)
|
|
215
|
+
}
|
|
216
|
+
|
|
198
217
|
has (value: V): boolean {
|
|
199
218
|
const values = this.$items.values();
|
|
200
219
|
|
|
@@ -257,6 +276,22 @@ export class SetSchema<V=any> implements Collection<number, V>, IRef {
|
|
|
257
276
|
this.$items.delete(index);
|
|
258
277
|
}
|
|
259
278
|
|
|
279
|
+
[$resyncPrune](
|
|
280
|
+
visited: Set<number | string>,
|
|
281
|
+
prune: (value: V, identity: number | string) => void,
|
|
282
|
+
keep: (value: V) => void,
|
|
283
|
+
): void {
|
|
284
|
+
let toDelete: number[] | null = null;
|
|
285
|
+
this.$items.forEach((value, index) => {
|
|
286
|
+
if (visited.has(index)) { keep(value); return; }
|
|
287
|
+
(toDelete ??= []).push(index);
|
|
288
|
+
prune(value, index);
|
|
289
|
+
});
|
|
290
|
+
if (toDelete !== null) {
|
|
291
|
+
for (let i = 0; i < toDelete.length; i++) { this[$deleteByIndex](toDelete[i]); }
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
260
295
|
protected [$onEncodeEnd]() {
|
|
261
296
|
for (const key in this.deletedItems) { delete this.deletedItems[key]; }
|
|
262
297
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
$getByIndex,
|
|
11
11
|
$onEncodeEnd,
|
|
12
12
|
$refId,
|
|
13
|
+
$resyncPrune,
|
|
13
14
|
} from "../symbols.js";
|
|
14
15
|
import { ChangeTree, installUntrackedChangeTree, type IRef } from "../../encoder/ChangeTree.js";
|
|
15
16
|
import { encodeIndexedEntry } from "../../encoder/EncodeOperation.js";
|
|
@@ -266,6 +267,12 @@ export class StreamSchema<V = any> implements IRef {
|
|
|
266
267
|
}
|
|
267
268
|
}
|
|
268
269
|
|
|
270
|
+
[$resyncPrune](): void {
|
|
271
|
+
// Stream contents are delivered by the trickle/priority pass, NOT
|
|
272
|
+
// by full-sync (encodeAll carries none of them) — a snapshot is not
|
|
273
|
+
// authoritative for streams, so absence ≠ deleted. Never prune.
|
|
274
|
+
}
|
|
275
|
+
|
|
269
276
|
protected [$onEncodeEnd](): void {
|
|
270
277
|
// No per-tick cleanup: pending/sent state spans encode ticks by design.
|
|
271
278
|
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { encode } from "../encoding/encode.js";
|
|
2
|
+
import { decode } from "../encoding/decode.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `t.quantized()` — a bounded float encoded as a fixed-width unsigned integer.
|
|
6
|
+
*
|
|
7
|
+
* A float that changes every frame within a known range (look angles, a
|
|
8
|
+
* normalized scalar, a unit-vector component) costs a full `float32` (4 value
|
|
9
|
+
* bytes). Mapping it onto `uint8`/`uint16`/`uint32` halves or quarters that at a
|
|
10
|
+
* precision you choose — e.g. a yaw on `uint16` is ~0.0055°/step, finer than any
|
|
11
|
+
* mouse, for 2 bytes instead of 4.
|
|
12
|
+
*
|
|
13
|
+
* Quantization is LOSSY, but lossy IDENTICALLY on both peers: the field only ever
|
|
14
|
+
* yields `dequant(q)`, so client prediction and server simulation read the SAME
|
|
15
|
+
* value and a predicted hitscan ray bit-matches the server's — the lossiness is
|
|
16
|
+
* vs. the original float, never between the two peers (see the reconciler note in
|
|
17
|
+
* `predict/reconciler.ts`).
|
|
18
|
+
*
|
|
19
|
+
* Portable by construction (a C/C#/Lua client must reproduce it bit-for-bit):
|
|
20
|
+
* rounding is explicit `floor(x + 0.5)` — NOT the language-default round, which
|
|
21
|
+
* disagrees on the `.5` case (JS half-up, C# banker's, C half-away) — and a
|
|
22
|
+
* wrapping range is reduced in the FLOAT domain BEFORE the integer step, so there
|
|
23
|
+
* is no huge-double→int cast (UB in C, throws in C#). All math is float64.
|
|
24
|
+
*/
|
|
25
|
+
export interface QuantizeOptions {
|
|
26
|
+
/** Inclusive lower bound of the value domain. */
|
|
27
|
+
min: number;
|
|
28
|
+
/** Upper bound of the value domain (inclusive when clamped, exclusive when wrapping). */
|
|
29
|
+
max: number;
|
|
30
|
+
/**
|
|
31
|
+
* Wire width in bits — a typed union, NOT a free integer: schema fields are
|
|
32
|
+
* byte-aligned, so only 8/16/32 change the wire (`bits: 13` would still occupy
|
|
33
|
+
* 2 bytes and mislead). Default 16.
|
|
34
|
+
*/
|
|
35
|
+
bits?: 8 | 16 | 32;
|
|
36
|
+
/**
|
|
37
|
+
* Is the value CYCLIC (lives on a circle) or BOUNDED (has hard walls)? That is
|
|
38
|
+
* the whole choice — the wire size is identical either way.
|
|
39
|
+
*
|
|
40
|
+
* `"clamp"` (default) — for a BOUNDED value with hard ends (pitch, a health bar,
|
|
41
|
+
* a 0–1 throttle). A UNORM over `[min, max]` INCLUSIVE: both endpoints are exact
|
|
42
|
+
* + distinct, and out-of-range inputs CLAMP to the nearest end (`0` / `2^bits − 1`).
|
|
43
|
+
* Pitch past the limit stops AT the limit.
|
|
44
|
+
*
|
|
45
|
+
* `"wrap"` — for a CYCLIC value where `min` and `max` are the SAME point (an
|
|
46
|
+
* angle/heading, compass bearing, hue, phase). `[min, max)` over `2^bits` steps
|
|
47
|
+
* with the top folding onto the bottom; any input is range-reduced modulo
|
|
48
|
+
* `(max − min)` first, so a large accumulated or negative angle maps into the
|
|
49
|
+
* domain (yaw keeps spinning, never jams). `min` is exact; `max ≡ min`.
|
|
50
|
+
*
|
|
51
|
+
* Decision rule: does one step past the top return you to the bottom as the SAME
|
|
52
|
+
* physical thing? → `"wrap"`. Is the top a wall you can't cross? → `"clamp"`.
|
|
53
|
+
* Choosing wrong is a real bug: a clamped heading JAMS at the 0/2π seam; a wrapped
|
|
54
|
+
* pitch FLIPS the camera the instant you look a hair too far up.
|
|
55
|
+
*
|
|
56
|
+
* NB (cyclic fields): `"wrap"` fixes the WIRE seam, not rendering — a wrapped
|
|
57
|
+
* field that's interpolated must ALSO be lerped shortest-arc (the SDK predict
|
|
58
|
+
* `attach({ angle: true })`), or it unwinds the long way across the `0 ↔ max` seam.
|
|
59
|
+
*/
|
|
60
|
+
mode?: "clamp" | "wrap";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolved, plain-data quantization descriptor stored on the field metadata as
|
|
65
|
+
* `{ quantized: QuantizeDescriptor }`. Both encode and decode derive `scale` from
|
|
66
|
+
* the same fields, so the two sides — and a future cross-language port — agree by
|
|
67
|
+
* construction. Serializable (no functions) so it can ride schema reflection.
|
|
68
|
+
*/
|
|
69
|
+
export interface QuantizeDescriptor {
|
|
70
|
+
min: number;
|
|
71
|
+
max: number;
|
|
72
|
+
bits: 8 | 16 | 32;
|
|
73
|
+
wrap: boolean;
|
|
74
|
+
/** Wire codec name — `"uint8" | "uint16" | "uint32"`, derived from `bits`. */
|
|
75
|
+
wire: "uint8" | "uint16" | "uint32";
|
|
76
|
+
/** `max − min`, the domain width. */
|
|
77
|
+
range: number;
|
|
78
|
+
/**
|
|
79
|
+
* The integer span used for the scale:
|
|
80
|
+
* - wrapping: `2^bits` (the number of steps; the top step folds onto 0).
|
|
81
|
+
* - clamped: `2^bits − 1` (endpoints inclusive, so the max maps to it).
|
|
82
|
+
*/
|
|
83
|
+
span: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const WIRE_BY_BITS: Record<number, "uint8" | "uint16" | "uint32"> = {
|
|
87
|
+
8: "uint8",
|
|
88
|
+
16: "uint16",
|
|
89
|
+
32: "uint32",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Validate options and precompute the wire codec + scale span. */
|
|
93
|
+
export function resolveQuantize(opts: QuantizeOptions): QuantizeDescriptor {
|
|
94
|
+
if (opts == null || typeof opts !== "object") {
|
|
95
|
+
throw new Error("t.quantized(): options object with { min, max } is required.");
|
|
96
|
+
}
|
|
97
|
+
const { min, max } = opts;
|
|
98
|
+
if (typeof min !== "number" || typeof max !== "number" || !(max > min)
|
|
99
|
+
|| !Number.isFinite(min) || !Number.isFinite(max)) {
|
|
100
|
+
throw new Error(`t.quantized(): require finite min < max (got min=${min}, max=${max}).`);
|
|
101
|
+
}
|
|
102
|
+
const bits = opts.bits ?? 16;
|
|
103
|
+
if (bits !== 8 && bits !== 16 && bits !== 32) {
|
|
104
|
+
throw new Error(`t.quantized(): bits must be 8, 16 or 32 (got ${bits}).`);
|
|
105
|
+
}
|
|
106
|
+
const mode = opts.mode ?? "clamp";
|
|
107
|
+
if (mode !== "clamp" && mode !== "wrap") {
|
|
108
|
+
throw new Error(`t.quantized(): mode must be "clamp" or "wrap" (got ${JSON.stringify(mode)}).`);
|
|
109
|
+
}
|
|
110
|
+
const wrap = mode === "wrap"; // descriptor + wire stay boolean; `mode` is the public knob
|
|
111
|
+
const steps = Math.pow(2, bits);
|
|
112
|
+
return {
|
|
113
|
+
min,
|
|
114
|
+
max,
|
|
115
|
+
bits,
|
|
116
|
+
wrap,
|
|
117
|
+
wire: WIRE_BY_BITS[bits],
|
|
118
|
+
range: max - min,
|
|
119
|
+
// wrapping spreads `2^bits` steps across [min,max) (top ≡ bottom); clamped
|
|
120
|
+
// maps the endpoints onto `0` and `2^bits − 1` inclusive.
|
|
121
|
+
span: wrap ? steps : steps - 1,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Type guard for the `{ quantized: QuantizeDescriptor }` field-type shape. */
|
|
126
|
+
export function isQuantizedType(type: any): type is { quantized: QuantizeDescriptor } {
|
|
127
|
+
return type !== null && typeof type === "object" && (type as any).quantized !== undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Float → unsigned integer. Rounding is explicit half-up `floor(x + 0.5)`;
|
|
132
|
+
* wrapping ranges are reduced in the float domain first (no huge→int cast).
|
|
133
|
+
*/
|
|
134
|
+
export function quantize(desc: QuantizeDescriptor, value: number): number {
|
|
135
|
+
if (desc.wrap) {
|
|
136
|
+
// Non-finite can't be range-reduced (Inf % range = NaN); NaN would flow to
|
|
137
|
+
// the wire as garbage while the local instance kept NaN — a silent peer
|
|
138
|
+
// divergence. Pin to q=0 (= min): garbage in, deterministic out, both agree.
|
|
139
|
+
if (!Number.isFinite(value)) return 0;
|
|
140
|
+
const range = desc.range;
|
|
141
|
+
// float-domain range reduction → [0, range)
|
|
142
|
+
let a = (value - desc.min) % range;
|
|
143
|
+
if (a < 0) a += range;
|
|
144
|
+
const steps = desc.span; // 2^bits
|
|
145
|
+
// `% steps` (not `& mask`) so bits=32 doesn't overflow JS's int32 bitwise.
|
|
146
|
+
return Math.floor((a / range) * steps + 0.5) % steps;
|
|
147
|
+
}
|
|
148
|
+
if (value !== value) return 0; // NaN → min (±Inf clamps naturally below)
|
|
149
|
+
const v = value < desc.min ? desc.min : value > desc.max ? desc.max : value;
|
|
150
|
+
return Math.floor(((v - desc.min) / desc.range) * desc.span + 0.5);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Unsigned integer → float. Correctly-rounded mul/div ⇒ bit-identical across languages. */
|
|
154
|
+
export function dequantize(desc: QuantizeDescriptor, q: number): number {
|
|
155
|
+
return desc.min + (q / desc.span) * desc.range;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Pre-baked encoder for a quantized field: quantize the (snapped) float held on
|
|
160
|
+
* the instance, then write it with the field's unsigned-int wire codec. Stored in
|
|
161
|
+
* `metadata[$encoders]` so the encode hot path reaches it via the fast lane,
|
|
162
|
+
* exactly like the pre-computed primitive encoders.
|
|
163
|
+
*/
|
|
164
|
+
export function makeQuantizedEncoder(desc: QuantizeDescriptor) {
|
|
165
|
+
const writeWire = (encode as any)[desc.wire] as (bytes: any, value: number, it: any) => void;
|
|
166
|
+
return (bytes: any, value: number, it: any) => writeWire(bytes, quantize(desc, value), it);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Decode a quantized field: read the unsigned-int wire value, then dequantize. */
|
|
170
|
+
export function decodeQuantized(desc: QuantizeDescriptor, bytes: any, it: any): number {
|
|
171
|
+
const readWire = (decode as any)[desc.wire] as (bytes: any, it: any) => number;
|
|
172
|
+
return dequantize(desc, readWire(bytes, it));
|
|
173
|
+
}
|
package/src/types/symbols.ts
CHANGED
|
@@ -46,6 +46,13 @@ export const $filter = "~filter";
|
|
|
46
46
|
|
|
47
47
|
export const $getByIndex = "~getByIndex";
|
|
48
48
|
export const $deleteByIndex = "~deleteByIndex";
|
|
49
|
+
/**
|
|
50
|
+
* Resync-sweep hook (see decoder/Resync.ts): prune every entry the rejoin
|
|
51
|
+
* snapshot did not visit. Each collection owns its storage-specific
|
|
52
|
+
* bookkeeping (journal pruning, compaction, item indexes); the generic
|
|
53
|
+
* DELETE/ref/callback bookkeeping arrives via the `prune`/`keep` callbacks.
|
|
54
|
+
*/
|
|
55
|
+
export const $resyncPrune = "~resyncPrune";
|
|
49
56
|
|
|
50
57
|
/**
|
|
51
58
|
* Used to hold ChangeTree instances whitin the structures.
|
|
@@ -75,6 +82,15 @@ export const $proxyTarget: unique symbol = Symbol.for("$proxyTarget");
|
|
|
75
82
|
*/
|
|
76
83
|
export const $onEncodeEnd = '~onEncodeEnd';
|
|
77
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Optional "reset" method on every poolable Ref (Schema + collections).
|
|
87
|
+
* Empties the instance's backing store and recycles its ChangeTree WITHOUT
|
|
88
|
+
* emitting any wire op, and recurses into ref-type children — so the instance
|
|
89
|
+
* can be returned to a SchemaPool and reused, avoiding the cost of `new`.
|
|
90
|
+
* See encoder/Pool.ts and Schema.reset().
|
|
91
|
+
*/
|
|
92
|
+
export const $reset = "~reset";
|
|
93
|
+
|
|
78
94
|
/**
|
|
79
95
|
* When decoding, this method is called after the instance is fully decoded
|
|
80
96
|
*/
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Allocates monotonically-increasing refIds with a reuse pool.
|
|
3
|
-
*
|
|
4
|
-
* `acquire()` pops from the free pool when available, otherwise bumps a
|
|
5
|
-
* counter. `release()` queues a refId for reuse; the id doesn't become
|
|
6
|
-
* acquirable until `flushReleases()` runs — the one-tick defer is what
|
|
7
|
-
* lets the encoder guarantee a DELETE for the old instance reaches the
|
|
8
|
-
* wire before the refId is handed to a new one.
|
|
9
|
-
*
|
|
10
|
-
* `reclaim()` handles "resurrection": a ref that was released but whose
|
|
11
|
-
* JS instance is still alive can be re-added to the tree, in which case
|
|
12
|
-
* the encoder must pull the refId back out of the pool before it's
|
|
13
|
-
* handed to an unrelated instance.
|
|
14
|
-
*/
|
|
15
|
-
export declare class RefIdAllocator {
|
|
16
|
-
protected nextUniqueId: number;
|
|
17
|
-
private _free;
|
|
18
|
-
private _pending;
|
|
19
|
-
private _pooled;
|
|
20
|
-
constructor(startRefId?: number);
|
|
21
|
-
acquire(): number;
|
|
22
|
-
release(refId: number): void;
|
|
23
|
-
isPooled(refId: number): boolean;
|
|
24
|
-
/**
|
|
25
|
-
* Remove a refId from the pool. Called when a ref whose refId was
|
|
26
|
-
* released is being resurrected. O(n) scan of the relevant array,
|
|
27
|
-
* but resurrection is rare.
|
|
28
|
-
*/
|
|
29
|
-
reclaim(refId: number): void;
|
|
30
|
-
/**
|
|
31
|
-
* Promote this tick's releases into the acquirable set. Called from
|
|
32
|
-
* `Encoder.discardChanges()` — never mid-encode.
|
|
33
|
-
*/
|
|
34
|
-
flushReleases(): void;
|
|
35
|
-
}
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Allocates monotonically-increasing refIds with a reuse pool.
|
|
3
|
-
*
|
|
4
|
-
* `acquire()` pops from the free pool when available, otherwise bumps a
|
|
5
|
-
* counter. `release()` queues a refId for reuse; the id doesn't become
|
|
6
|
-
* acquirable until `flushReleases()` runs — the one-tick defer is what
|
|
7
|
-
* lets the encoder guarantee a DELETE for the old instance reaches the
|
|
8
|
-
* wire before the refId is handed to a new one.
|
|
9
|
-
*
|
|
10
|
-
* `reclaim()` handles "resurrection": a ref that was released but whose
|
|
11
|
-
* JS instance is still alive can be re-added to the tree, in which case
|
|
12
|
-
* the encoder must pull the refId back out of the pool before it's
|
|
13
|
-
* handed to an unrelated instance.
|
|
14
|
-
*/
|
|
15
|
-
export class RefIdAllocator {
|
|
16
|
-
protected nextUniqueId: number;
|
|
17
|
-
|
|
18
|
-
private _free: number[] = [];
|
|
19
|
-
private _pending: number[] = [];
|
|
20
|
-
private _pooled: Set<number> = new Set();
|
|
21
|
-
|
|
22
|
-
constructor(startRefId: number = 0) {
|
|
23
|
-
this.nextUniqueId = startRefId;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
acquire(): number {
|
|
27
|
-
if (this._free.length > 0) {
|
|
28
|
-
const id = this._free.pop()!;
|
|
29
|
-
this._pooled.delete(id);
|
|
30
|
-
return id;
|
|
31
|
-
}
|
|
32
|
-
return this.nextUniqueId++;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
release(refId: number): void {
|
|
36
|
-
this._pending.push(refId);
|
|
37
|
-
this._pooled.add(refId);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
isPooled(refId: number): boolean {
|
|
41
|
-
return this._pooled.has(refId);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Remove a refId from the pool. Called when a ref whose refId was
|
|
46
|
-
* released is being resurrected. O(n) scan of the relevant array,
|
|
47
|
-
* but resurrection is rare.
|
|
48
|
-
*/
|
|
49
|
-
reclaim(refId: number): void {
|
|
50
|
-
if (!this._pooled.delete(refId)) return;
|
|
51
|
-
let i = this._free.indexOf(refId);
|
|
52
|
-
if (i !== -1) { this._free.splice(i, 1); return; }
|
|
53
|
-
i = this._pending.indexOf(refId);
|
|
54
|
-
if (i !== -1) { this._pending.splice(i, 1); }
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Promote this tick's releases into the acquirable set. Called from
|
|
59
|
-
* `Encoder.discardChanges()` — never mid-encode.
|
|
60
|
-
*/
|
|
61
|
-
flushReleases(): void {
|
|
62
|
-
const pending = this._pending;
|
|
63
|
-
if (pending.length === 0) return;
|
|
64
|
-
const free = this._free;
|
|
65
|
-
for (let i = 0; i < pending.length; i++) free.push(pending[i]);
|
|
66
|
-
pending.length = 0;
|
|
67
|
-
}
|
|
68
|
-
}
|