@colyseus/schema 5.0.6 → 5.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/build/Reflection.d.ts +53 -0
  2. package/build/Schema.d.ts +26 -1
  3. package/build/annotations.d.ts +8 -1
  4. package/build/codegen/cli.cjs +74 -64
  5. package/build/codegen/cli.cjs.map +1 -1
  6. package/build/codegen/types.d.ts +0 -1
  7. package/build/decoder/Decoder.d.ts +28 -0
  8. package/build/decoder/Resync.d.ts +61 -0
  9. package/build/encoder/ChangeTree.d.ts +15 -0
  10. package/build/encoder/Encoder.d.ts +13 -0
  11. package/build/encoder/Pool.d.ts +62 -0
  12. package/build/encoder/Root.d.ts +5 -4
  13. package/build/encoder/StateView.d.ts +12 -3
  14. package/build/index.cjs +1192 -284
  15. package/build/index.cjs.map +1 -1
  16. package/build/index.d.ts +3 -0
  17. package/build/index.js +1192 -284
  18. package/build/index.mjs +1189 -285
  19. package/build/index.mjs.map +1 -1
  20. package/build/input/InputDecoder.d.ts +9 -4
  21. package/build/input/InputEncoder.d.ts +44 -53
  22. package/build/input/index.cjs +102 -7321
  23. package/build/input/index.cjs.map +1 -1
  24. package/build/input/index.mjs +97 -7316
  25. package/build/input/index.mjs.map +1 -1
  26. package/build/types/HelperTypes.d.ts +3 -0
  27. package/build/types/builder.d.ts +57 -4
  28. package/build/types/custom/ArraySchema.d.ts +11 -1
  29. package/build/types/custom/CollectionSchema.d.ts +9 -1
  30. package/build/types/custom/MapSchema.d.ts +9 -1
  31. package/build/types/custom/SetSchema.d.ts +9 -1
  32. package/build/types/custom/StreamSchema.d.ts +2 -1
  33. package/build/types/quantize.d.ts +102 -0
  34. package/build/types/symbols.d.ts +15 -0
  35. package/package.json +8 -1
  36. package/src/Metadata.ts +34 -9
  37. package/src/Reflection.ts +49 -11
  38. package/src/Schema.ts +64 -2
  39. package/src/annotations.ts +133 -51
  40. package/src/bench_churn.ts +121 -0
  41. package/src/codegen/languages/haxe.ts +0 -16
  42. package/src/codegen/parser.ts +29 -11
  43. package/src/codegen/types.ts +45 -63
  44. package/src/decoder/DecodeOperation.ts +46 -4
  45. package/src/decoder/Decoder.ts +48 -0
  46. package/src/decoder/ReferenceTracker.ts +9 -5
  47. package/src/decoder/Resync.ts +170 -0
  48. package/src/encoder/ChangeTree.ts +59 -0
  49. package/src/encoder/Encoder.ts +53 -12
  50. package/src/encoder/Pool.ts +90 -0
  51. package/src/encoder/Root.ts +22 -32
  52. package/src/encoder/StateView.ts +101 -50
  53. package/src/encoder/changeTree/liveIteration.ts +4 -0
  54. package/src/encoder/changeTree/treeAttachment.ts +28 -24
  55. package/src/index.ts +11 -1
  56. package/src/input/InputDecoder.ts +11 -5
  57. package/src/input/InputEncoder.ts +85 -126
  58. package/src/types/HelperTypes.ts +7 -0
  59. package/src/types/TypeContext.ts +7 -0
  60. package/src/types/builder.ts +62 -5
  61. package/src/types/custom/ArraySchema.ts +70 -12
  62. package/src/types/custom/CollectionSchema.ts +36 -1
  63. package/src/types/custom/MapSchema.ts +50 -1
  64. package/src/types/custom/SetSchema.ts +36 -1
  65. package/src/types/custom/StreamSchema.ts +7 -0
  66. package/src/types/quantize.ts +173 -0
  67. package/src/types/symbols.ts +16 -0
  68. package/build/encoder/RefIdAllocator.d.ts +0 -35
  69. package/src/encoder/RefIdAllocator.ts +0 -68
@@ -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
- !view ||
141
- typeof (ref[$childType]) === "string" ||
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
- ? this.items[index]
852
- : this.deletedIndexes[index]
853
- ? this.items[index]
854
- : this.tmpItems[index] || this.items[index];
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.items[index] = undefined;
887
+ const self = this[$proxyTarget] ?? this;
888
+ self.items[index] = undefined;
889
+ self._needsCompaction = true;
859
890
  }
860
891
 
861
892
  protected [$onEncodeEnd]() {
862
- this.tmpItems = this.items.slice();
863
- this.deletedIndexes.length = 0;
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
- this.items = this.items.filter((item) => item !== undefined);
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
+ }
@@ -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
- }