@colyseus/schema 5.0.5 → 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 +4 -1
  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 +25 -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 +12 -2
  56. package/src/input/InputDecoder.ts +11 -5
  57. package/src/input/InputEncoder.ts +85 -126
  58. package/src/types/HelperTypes.ts +30 -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,3 +1,4 @@
1
+ import { $resyncPrune } from "./symbols.js";
1
2
  import type { PrimitiveType, RawPrimitiveType } from "../annotations.js";
2
3
  import type { Schema } from "../Schema.js";
3
4
  import type { ArraySchema } from "./custom/ArraySchema.js";
@@ -12,6 +13,8 @@ export interface Collection<K = any, V = any, IT = V> {
12
13
  [Symbol.iterator](): IterableIterator<IT>;
13
14
  forEach(callback: Function): void;
14
15
  entries(): IterableIterator<[K, V]>;
16
+ /** See {@link $resyncPrune} — every collection kind must declare its resync-sweep semantics. */
17
+ [$resyncPrune](visited: Set<number | string>, prune: (value: V, identity: number | string) => void, keep: (value: V) => void): void;
15
18
  }
16
19
  export type InferValueType<T> = T extends FieldBuilder<infer V> ? V : T extends "string" ? string : T extends "number" ? number : T extends "int8" ? number : T extends "uint8" ? number : T extends "int16" ? number : T extends "uint16" ? number : T extends "int32" ? number : T extends "uint32" ? number : T extends "int64" ? number : T extends "uint64" ? number : T extends "float32" ? number : T extends "float64" ? number : T extends "boolean" ? boolean : T extends {
17
20
  type: infer ChildType extends PrimitiveType;
@@ -97,6 +100,28 @@ export type ToJSON<T> = NonFunctionProps<{
97
100
  } & {
98
101
  [K in ToJSONOptionalKeys<T>]?: ToJSONField<Exclude<T[K], undefined>>;
99
102
  }>;
103
+ /**
104
+ * The plain DATA shape of a Schema instance type `T`: its synchronized fields
105
+ * with all `Schema` machinery stripped (`assign`, `clone`, `toJSON`, the
106
+ * change-tracking state, the internal symbol keys, …), so a plain object literal
107
+ * satisfies it. Field types — including narrowed primitives like
108
+ * `t.int8<-1 | 0 | 1>()` — are preserved exactly.
109
+ *
110
+ * Use it to type code that operates on schema-shaped *plain objects* rather than
111
+ * decoded instances: deterministic simulation / physics steps, synthesized or
112
+ * buffered input commands, plain DTOs, etc.
113
+ *
114
+ * ```ts
115
+ * function applyInput(state: Player, cmd: Data<MoveInput>) { … }
116
+ * applyInput(player, { moveX: 1, jump: false, dt }); // plain literal — OK
117
+ * ```
118
+ *
119
+ * Unlike {@link ToJSON} (a recursive *serialization* shape), this is a flat
120
+ * structural projection: nested Schema / collection fields keep their instance
121
+ * types, and it does not retain the non-method `Schema` members that `ToJSON`'s
122
+ * `NonFunctionProps` pass leaves behind.
123
+ */
124
+ export type Data<T> = Omit<T, keyof Schema>;
100
125
  export type IsNever<T> = [T] extends [never] ? true : false;
101
126
  /**
102
127
  * Type helper for .assign() method - allows assigning values in a flexible way
@@ -7,6 +7,7 @@ import type { Schema } from "../Schema.js";
7
7
  import type { DefinitionType, RawPrimitiveType } from "../annotations.js";
8
8
  import type { InferValueType, Constructor } from "./HelperTypes.js";
9
9
  import { $builder } from "./symbols.js";
10
+ import { type QuantizeOptions } from "./quantize.js";
10
11
  /**
11
12
  * Internal record produced by FieldBuilder#toDefinition() and consumed by schema().
12
13
  */
@@ -70,8 +71,23 @@ export declare class FieldBuilder<T = unknown, HasDefault extends boolean = fals
70
71
  private _noSync;
71
72
  private _streamPriority;
72
73
  constructor(type: DefinitionType);
73
- /** Provide a default value for this field. */
74
- default(value: T): FieldBuilder<T, true, IsOptional>;
74
+ /**
75
+ * Provide a default value for this field.
76
+ *
77
+ * Pass a **factory function** `() => T` to build a FRESH value per instance
78
+ * (invoked once per construction) instead of sharing a single default — the
79
+ * clean way to default a ref to a plain custom class, or any field that must
80
+ * not share a mutable default across instances:
81
+ *
82
+ * ```ts
83
+ * acc: t.ref(GunAccuracy).noSync().default(() => new GunAccuracy()),
84
+ * ```
85
+ *
86
+ * Schema fields are never function-typed, so a function is always treated as a
87
+ * factory. A non-function value is shared (and cloned per instance if it is
88
+ * clone-able, e.g. a Schema/collection).
89
+ */
90
+ default(value: T | (() => T)): FieldBuilder<T, true, IsOptional>;
75
91
  /** Tag this field with a view tag (DEFAULT_VIEW_TAG when called without arg). */
76
92
  view(tag?: number): this;
77
93
  /** Mark this field as owned (encoder-side ownership filtering). */
@@ -217,8 +233,20 @@ type RefHasDefault<C> = C extends {
217
233
  };
218
234
  } ? (P extends readonly [] ? true : false) : true;
219
235
  interface RefFactory {
220
- <C extends Constructor<Schema>>(ctor: C): FieldBuilder<InstanceType<C>, RefHasDefault<C>, false>;
236
+ <C extends Constructor>(ctor: C): FieldBuilder<InstanceType<C>, RefHasDefault<C>, false>;
221
237
  }
238
+ /**
239
+ * A bounded float carried on the wire as a fixed-width unsigned integer. App code
240
+ * reads/writes the FLOAT; the wire carries the quantized int and the field only
241
+ * ever yields `dequant(q)`, so client predict and server sim read the same value
242
+ * (no full-precision path to leak ⇒ no shot misprediction). See
243
+ * {@link QuantizeOptions} for the precision/wire trade-offs.
244
+ *
245
+ * yaw: t.quantized({ min: 0, max: TWO_PI, mode: "wrap" }), // 16-bit
246
+ * pitch: t.quantized({ min: -PITCH_LIMIT, max: PITCH_LIMIT }), // clamp (default)
247
+ * throttle: t.quantized({ min: 0, max: 1, bits: 8 }), // 1 byte
248
+ */
249
+ declare function quantizedFactory(opts: QuantizeOptions): FieldBuilder<number>;
222
250
  export declare const t: Readonly<{
223
251
  string: PrimitiveFactory<string>;
224
252
  number: PrimitiveFactory<number>;
@@ -235,12 +263,37 @@ export declare const t: Readonly<{
235
263
  float64: PrimitiveFactory<number>;
236
264
  bigint64: PrimitiveFactory<bigint>;
237
265
  biguint64: PrimitiveFactory<bigint>;
238
- /** Reference to a Schema subtype. `t.array(Item)` usually reads better, but this is available when a plain ref is needed. */
266
+ /**
267
+ * Reference to a Schema subtype — `t.array(Item)` usually reads better, but
268
+ * this is available when a plain ref is needed.
269
+ *
270
+ * The target may also be a **non-Schema custom class**. A synced ref still
271
+ * requires it to be encodable — a `Schema` subclass, or a class retrofitted
272
+ * with `Metadata.setFields(...)` (both carry `[Symbol.metadata]`); a bare
273
+ * custom class is rejected at `schema()` time. A `.noSync()` (local-only)
274
+ * field accepts ANY zero-arg class and auto-instantiates one per parent.
275
+ */
239
276
  ref: RefFactory;
240
277
  array: ArrayFactory;
241
278
  map: MapFactory;
242
279
  set: SetFactory;
243
280
  collection: CollectionFactory;
244
281
  stream: StreamFactory;
282
+ /**
283
+ * A bounded float quantized to a fixed-width unsigned int on the wire — half
284
+ * (or a quarter) the bytes of a `float32` at a precision you pick. The field
285
+ * reads/writes the float and only ever yields `dequant(q)`. {@see QuantizeOptions}
286
+ */
287
+ quantized: typeof quantizedFactory;
288
+ /**
289
+ * Sugar for a full-circle wrapping angle in radians:
290
+ * `t.quantized({ min: 0, max: 2π, mode: "wrap", bits })` (default 16-bit,
291
+ * ~0.0055°/step). Any input angle is range-reduced into `[0, 2π)`. Render note:
292
+ * lerp interpolated remotes shortest-arc (`attach({ angle: true })`) — the
293
+ * wrap fixes the WIRE seam, not interpolation (see {@link QuantizeOptions.mode}).
294
+ */
295
+ angle: (opts?: {
296
+ bits?: 8 | 16 | 32;
297
+ }) => FieldBuilder<number, false, false>;
245
298
  }>;
246
299
  export {};
@@ -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 } from "../../encoder/ChangeTree.js";
4
4
  import { OPERATION } from "../../encoding/spec.js";
@@ -14,6 +14,8 @@ export declare class ArraySchema<V = any> implements Array<V>, Collection<number
14
14
  protected tmpItems: V[];
15
15
  protected deletedIndexes: boolean[];
16
16
  protected isMovingItems: boolean;
17
+ /** Decode-side: `items` has holes (delete or gap-write) — `$onDecodeEnd` must compact. */
18
+ protected _needsCompaction: boolean;
17
19
  static [$encoder]: import("../../encoder/EncodeOperation.js").EncodeOperation<any>;
18
20
  static [$decoder]: import("../../decoder/DecodeOperation.js").DecodeOperation<any>;
19
21
  /** Integer tag read by `decodeKeyValueOperation` — see `CollectionKind`. */
@@ -57,6 +59,13 @@ export declare class ArraySchema<V = any> implements Array<V>, Collection<number
57
59
  protected $deleteAt(index: number, operation?: OPERATION): void;
58
60
  protected $setAt(index: number, value: V, operation: OPERATION): void;
59
61
  clear(): void;
62
+ /**
63
+ * Pool reset: empty this array and recycle its ChangeTree WITHOUT recording
64
+ * any wire op (the parent field's ADD/DELETE owns the wire). Recurses into
65
+ * ref-type children. Called by Schema.reset when a pooled entity has an
66
+ * array field. The instance must already be detached from the encoder.
67
+ */
68
+ [$reset](): void;
60
69
  /**
61
70
  * Combines two or more arrays.
62
71
  * @param items Additional items to add to the end of array1.
@@ -285,6 +294,7 @@ export declare class ArraySchema<V = any> implements Array<V>, Collection<number
285
294
  [$deleteByIndex](index: number): void;
286
295
  protected [$onEncodeEnd](): void;
287
296
  protected [$onDecodeEnd](): void;
297
+ [$resyncPrune](visited: Set<number | string>, prune: (value: V, identity: number | string) => void, keep: (value: V) => void): void;
288
298
  toArray(): V[];
289
299
  toJSON(): any[];
290
300
  clone(isDecoding?: boolean): ArraySchema<V>;
@@ -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, type IRef } from "../../encoder/ChangeTree.js";
3
3
  import { Collection } from "../HelperTypes.js";
4
4
  import { type StreamableState } from "../../encoder/streaming.js";
@@ -55,6 +55,13 @@ export declare class CollectionSchema<V = any> implements Collection<K, V>, IRef
55
55
  entries(): MapIterator<[number, V]>;
56
56
  delete(item: V): boolean;
57
57
  clear(): void;
58
+ /**
59
+ * Pool reset: empty this collection and recycle its ChangeTree WITHOUT
60
+ * recording any wire op (the parent field's ADD/DELETE owns the wire).
61
+ * Recurses into ref-type children. Called by Schema.reset when a pooled
62
+ * entity has a collection field. Must already be detached from the encoder.
63
+ */
64
+ [$reset](): void;
58
65
  has(value: V): boolean;
59
66
  forEach(callbackfn: (value: V, key: K, collection: CollectionSchema<V>) => void): void;
60
67
  values(): MapIterator<V>;
@@ -69,6 +76,7 @@ export declare class CollectionSchema<V = any> implements Collection<K, V>, IRef
69
76
  protected getIndex(index: number): number;
70
77
  [$getByIndex](index: number): any;
71
78
  [$deleteByIndex](index: number): void;
79
+ [$resyncPrune](visited: Set<number | string>, prune: (value: V, identity: number | string) => void, keep: (value: V) => void): void;
72
80
  protected [$onEncodeEnd](): void;
73
81
  _dropView(viewId: number): void;
74
82
  _unregister(): void;
@@ -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, IRef } from "../../encoder/ChangeTree.js";
3
3
  import { Collection } from "../HelperTypes.js";
4
4
  import { MapJournal } from "../../encoder/MapJournal.js";
@@ -78,6 +78,13 @@ export declare class MapSchema<V = any, K extends string = string> implements Ma
78
78
  get(key: K): V | undefined;
79
79
  delete(key: K): boolean;
80
80
  clear(): void;
81
+ /**
82
+ * Pool reset: empty this map and recycle its ChangeTree WITHOUT recording
83
+ * any wire op (the parent field's ADD/DELETE owns the wire). Recurses into
84
+ * ref-type children. Called by Schema.reset when a pooled entity has a
85
+ * map field. The instance must already be detached from the encoder.
86
+ */
87
+ [$reset](): void;
81
88
  has(key: K): boolean;
82
89
  forEach(callbackfn: (value: V, key: K, map: Map<K, V>) => void): void;
83
90
  entries(): MapIterator<[K, V]>;
@@ -92,6 +99,7 @@ export declare class MapSchema<V = any, K extends string = string> implements Ma
92
99
  protected getIndex(index: number): K;
93
100
  [$getByIndex](index: number): V | undefined;
94
101
  [$deleteByIndex](index: number): void;
102
+ [$resyncPrune](visited: Set<number | string>, prune: (value: V, identity: number | string) => void, keep: (value: V) => void): void;
95
103
  protected [$onEncodeEnd](): void;
96
104
  _dropView(viewId: number): void;
97
105
  _unregister(): void;
@@ -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 { Collection } from "../HelperTypes.js";
3
3
  import { ChangeTree, type IRef } from "../../encoder/ChangeTree.js";
4
4
  import { type StreamableState } from "../../encoder/streaming.js";
@@ -55,6 +55,13 @@ export declare class SetSchema<V = any> implements Collection<number, V>, IRef {
55
55
  entries(): MapIterator<[number, V]>;
56
56
  delete(item: V): boolean;
57
57
  clear(): void;
58
+ /**
59
+ * Pool reset: empty this set and recycle its ChangeTree WITHOUT recording
60
+ * any wire op (the parent field's ADD/DELETE owns the wire). Recurses into
61
+ * ref-type children. Called by Schema.reset when a pooled entity has a set
62
+ * field. The instance must already be detached from the encoder.
63
+ */
64
+ [$reset](): void;
58
65
  has(value: V): boolean;
59
66
  forEach(callbackfn: (value: V, key: number, collection: SetSchema<V>) => void): void;
60
67
  values(): MapIterator<V>;
@@ -69,6 +76,7 @@ export declare class SetSchema<V = any> implements Collection<number, V>, IRef {
69
76
  protected getIndex(index: number): number;
70
77
  [$getByIndex](index: number): any;
71
78
  [$deleteByIndex](index: number): void;
79
+ [$resyncPrune](visited: Set<number | string>, prune: (value: V, identity: number | string) => void, keep: (value: V) => void): void;
72
80
  protected [$onEncodeEnd](): void;
73
81
  _dropView(viewId: number): void;
74
82
  _unregister(): void;
@@ -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, $resyncPrune } from "../symbols.js";
2
2
  import { ChangeTree, type IRef } from "../../encoder/ChangeTree.js";
3
3
  import { type StreamableState } from "../../encoder/streaming.js";
4
4
  import type { StateView } from "../../encoder/StateView.js";
@@ -104,6 +104,7 @@ export declare class StreamSchema<V = any> implements IRef {
104
104
  protected getIndex(index: number): number;
105
105
  [$getByIndex](index: number): V;
106
106
  [$deleteByIndex](index: number): void;
107
+ [$resyncPrune](): void;
107
108
  protected [$onEncodeEnd](): void;
108
109
  toArray(): V[];
109
110
  toJSON(): any[];
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `t.quantized()` — a bounded float encoded as a fixed-width unsigned integer.
3
+ *
4
+ * A float that changes every frame within a known range (look angles, a
5
+ * normalized scalar, a unit-vector component) costs a full `float32` (4 value
6
+ * bytes). Mapping it onto `uint8`/`uint16`/`uint32` halves or quarters that at a
7
+ * precision you choose — e.g. a yaw on `uint16` is ~0.0055°/step, finer than any
8
+ * mouse, for 2 bytes instead of 4.
9
+ *
10
+ * Quantization is LOSSY, but lossy IDENTICALLY on both peers: the field only ever
11
+ * yields `dequant(q)`, so client prediction and server simulation read the SAME
12
+ * value and a predicted hitscan ray bit-matches the server's — the lossiness is
13
+ * vs. the original float, never between the two peers (see the reconciler note in
14
+ * `predict/reconciler.ts`).
15
+ *
16
+ * Portable by construction (a C/C#/Lua client must reproduce it bit-for-bit):
17
+ * rounding is explicit `floor(x + 0.5)` — NOT the language-default round, which
18
+ * disagrees on the `.5` case (JS half-up, C# banker's, C half-away) — and a
19
+ * wrapping range is reduced in the FLOAT domain BEFORE the integer step, so there
20
+ * is no huge-double→int cast (UB in C, throws in C#). All math is float64.
21
+ */
22
+ export interface QuantizeOptions {
23
+ /** Inclusive lower bound of the value domain. */
24
+ min: number;
25
+ /** Upper bound of the value domain (inclusive when clamped, exclusive when wrapping). */
26
+ max: number;
27
+ /**
28
+ * Wire width in bits — a typed union, NOT a free integer: schema fields are
29
+ * byte-aligned, so only 8/16/32 change the wire (`bits: 13` would still occupy
30
+ * 2 bytes and mislead). Default 16.
31
+ */
32
+ bits?: 8 | 16 | 32;
33
+ /**
34
+ * Is the value CYCLIC (lives on a circle) or BOUNDED (has hard walls)? That is
35
+ * the whole choice — the wire size is identical either way.
36
+ *
37
+ * `"clamp"` (default) — for a BOUNDED value with hard ends (pitch, a health bar,
38
+ * a 0–1 throttle). A UNORM over `[min, max]` INCLUSIVE: both endpoints are exact
39
+ * + distinct, and out-of-range inputs CLAMP to the nearest end (`0` / `2^bits − 1`).
40
+ * Pitch past the limit stops AT the limit.
41
+ *
42
+ * `"wrap"` — for a CYCLIC value where `min` and `max` are the SAME point (an
43
+ * angle/heading, compass bearing, hue, phase). `[min, max)` over `2^bits` steps
44
+ * with the top folding onto the bottom; any input is range-reduced modulo
45
+ * `(max − min)` first, so a large accumulated or negative angle maps into the
46
+ * domain (yaw keeps spinning, never jams). `min` is exact; `max ≡ min`.
47
+ *
48
+ * Decision rule: does one step past the top return you to the bottom as the SAME
49
+ * physical thing? → `"wrap"`. Is the top a wall you can't cross? → `"clamp"`.
50
+ * Choosing wrong is a real bug: a clamped heading JAMS at the 0/2π seam; a wrapped
51
+ * pitch FLIPS the camera the instant you look a hair too far up.
52
+ *
53
+ * NB (cyclic fields): `"wrap"` fixes the WIRE seam, not rendering — a wrapped
54
+ * field that's interpolated must ALSO be lerped shortest-arc (the SDK predict
55
+ * `attach({ angle: true })`), or it unwinds the long way across the `0 ↔ max` seam.
56
+ */
57
+ mode?: "clamp" | "wrap";
58
+ }
59
+ /**
60
+ * Resolved, plain-data quantization descriptor stored on the field metadata as
61
+ * `{ quantized: QuantizeDescriptor }`. Both encode and decode derive `scale` from
62
+ * the same fields, so the two sides — and a future cross-language port — agree by
63
+ * construction. Serializable (no functions) so it can ride schema reflection.
64
+ */
65
+ export interface QuantizeDescriptor {
66
+ min: number;
67
+ max: number;
68
+ bits: 8 | 16 | 32;
69
+ wrap: boolean;
70
+ /** Wire codec name — `"uint8" | "uint16" | "uint32"`, derived from `bits`. */
71
+ wire: "uint8" | "uint16" | "uint32";
72
+ /** `max − min`, the domain width. */
73
+ range: number;
74
+ /**
75
+ * The integer span used for the scale:
76
+ * - wrapping: `2^bits` (the number of steps; the top step folds onto 0).
77
+ * - clamped: `2^bits − 1` (endpoints inclusive, so the max maps to it).
78
+ */
79
+ span: number;
80
+ }
81
+ /** Validate options and precompute the wire codec + scale span. */
82
+ export declare function resolveQuantize(opts: QuantizeOptions): QuantizeDescriptor;
83
+ /** Type guard for the `{ quantized: QuantizeDescriptor }` field-type shape. */
84
+ export declare function isQuantizedType(type: any): type is {
85
+ quantized: QuantizeDescriptor;
86
+ };
87
+ /**
88
+ * Float → unsigned integer. Rounding is explicit half-up `floor(x + 0.5)`;
89
+ * wrapping ranges are reduced in the float domain first (no huge→int cast).
90
+ */
91
+ export declare function quantize(desc: QuantizeDescriptor, value: number): number;
92
+ /** Unsigned integer → float. Correctly-rounded mul/div ⇒ bit-identical across languages. */
93
+ export declare function dequantize(desc: QuantizeDescriptor, q: number): number;
94
+ /**
95
+ * Pre-baked encoder for a quantized field: quantize the (snapped) float held on
96
+ * the instance, then write it with the field's unsigned-int wire codec. Stored in
97
+ * `metadata[$encoders]` so the encode hot path reaches it via the fast lane,
98
+ * exactly like the pre-computed primitive encoders.
99
+ */
100
+ export declare function makeQuantizedEncoder(desc: QuantizeDescriptor): (bytes: any, value: number, it: any) => void;
101
+ /** Decode a quantized field: read the unsigned-int wire value, then dequantize. */
102
+ export declare function decodeQuantized(desc: QuantizeDescriptor, bytes: any, it: any): number;
@@ -5,6 +5,13 @@ export declare const $decoder = "~decoder";
5
5
  export declare const $filter = "~filter";
6
6
  export declare const $getByIndex = "~getByIndex";
7
7
  export declare const $deleteByIndex = "~deleteByIndex";
8
+ /**
9
+ * Resync-sweep hook (see decoder/Resync.ts): prune every entry the rejoin
10
+ * snapshot did not visit. Each collection owns its storage-specific
11
+ * bookkeeping (journal pruning, compaction, item indexes); the generic
12
+ * DELETE/ref/callback bookkeeping arrives via the `prune`/`keep` callbacks.
13
+ */
14
+ export declare const $resyncPrune = "~resyncPrune";
8
15
  /**
9
16
  * Used to hold ChangeTree instances whitin the structures.
10
17
  *
@@ -29,6 +36,14 @@ export declare const $proxyTarget: unique symbol;
29
36
  * (Discards changes for next serialization)
30
37
  */
31
38
  export declare const $onEncodeEnd = "~onEncodeEnd";
39
+ /**
40
+ * Optional "reset" method on every poolable Ref (Schema + collections).
41
+ * Empties the instance's backing store and recycles its ChangeTree WITHOUT
42
+ * emitting any wire op, and recurses into ref-type children — so the instance
43
+ * can be returned to a SchemaPool and reused, avoiding the cost of `new`.
44
+ * See encoder/Pool.ts and Schema.reset().
45
+ */
46
+ export declare const $reset = "~reset";
32
47
  /**
33
48
  * When decoding, this method is called after the instance is fully decoded
34
49
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colyseus/schema",
3
- "version": "5.0.5",
3
+ "version": "5.0.8",
4
4
  "description": "Binary state serializer with delta encoding for games",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,10 +8,17 @@
8
8
  "schema-debug": "bin/schema-debug"
9
9
  },
10
10
  "scripts": {
11
+ "bench": "node bench/run.mjs",
12
+ "bench:compare": "node bench/run.mjs --compare",
13
+ "bench:snapshot": "bash bench/snapshot-build.sh",
14
+ "bench:gate": "node bench/run.mjs --assert --samples 3 --filter gate",
15
+ "profile:cpu": "node bench/profile.mjs --cpu",
16
+ "profile:heap": "node bench/profile.mjs --heap",
11
17
  "build": "tsc -p tsconfig.build.json && rollup -c rollup.config.mjs",
12
18
  "watch": "tsc -p tsconfig.build.json -w",
13
19
  "typecheck:build": "tsc -p tsconfig.build.json --noEmit",
14
20
  "test": "tsx --tsconfig tsconfig.test.json ./node_modules/mocha/bin/mocha.js \"test/*.test.ts\" \"test/**/*.test.ts\"",
21
+ "test:exports": "node test/verify-exports.mjs",
15
22
  "typecheck": "tsc -p tsconfig.test.json",
16
23
  "coverage": "c8 npm run test",
17
24
  "generate-test-1": "bin/schema-codegen test-external/PrimitiveTypes.ts --namespace SchemaTest.PrimitiveTypes --output ../colyseus-unity-sdk/Assets/Colyseus/Tests/Editor/ColyseusTests/Schema/PrimitiveTypes",
package/src/Metadata.ts CHANGED
@@ -5,6 +5,7 @@ import { $decoder, $descriptors, $encoder, $encoders, $fieldIndexesByViewTag, $n
5
5
  import { ARRAY_STREAM_NOT_SUPPORTED } from "./encoder/streaming.js";
6
6
  import { encode } from "./encoding/encode.js";
7
7
  import { TypeContext } from "./types/TypeContext.js";
8
+ import { isQuantizedType, makeQuantizedEncoder, resolveQuantize } from "./types/quantize.js";
8
9
 
9
10
  export type MetadataField = {
10
11
  type: DefinitionType,
@@ -54,6 +55,14 @@ export function getNormalizedType(type: any): DefinitionType {
54
55
  if (Array.isArray(type)) {
55
56
  return { array: getNormalizedType(type[0]) };
56
57
 
58
+ } else if (isQuantizedType(type)) {
59
+ // `{ quantized: ... }` — a scalar wire type, NOT a collection/ref. Resolve
60
+ // raw options (the `@type({quantized:{min,max}})` path) once; an already-
61
+ // resolved descriptor (the `t.quantized()` builder path) passes through.
62
+ return (typeof (type.quantized as any).wire === "string")
63
+ ? type
64
+ : { quantized: resolveQuantize(type.quantized as any) } as any;
65
+
57
66
  } else if (typeof (type['type']) !== "undefined") {
58
67
  return type['type'];
59
68
 
@@ -146,8 +155,9 @@ export const Metadata = {
146
155
  configurable: true,
147
156
  });
148
157
 
149
- // if child Ref/complex type, add to -4
150
- if (typeof (metadata[index].type) !== "string") {
158
+ // if child Ref/complex type, add to -4. Quantized fields are scalar (their
159
+ // `type` is an object only to carry the descriptor) — not refs, so skip them.
160
+ if (typeof (metadata[index].type) !== "string" && !isQuantizedType(metadata[index].type)) {
151
161
  if (metadata[$refTypeFieldIndexes] === undefined) {
152
162
  Object.defineProperty(metadata, $refTypeFieldIndexes, {
153
163
  value: [],
@@ -216,11 +226,24 @@ export const Metadata = {
216
226
 
217
227
  metadata[$viewFieldIndexes].push(index);
218
228
 
219
- if (!metadata[$fieldIndexesByViewTag][tag]) {
220
- metadata[$fieldIndexesByViewTag][tag] = [];
229
+ // Populate $fieldIndexesByViewTag: for a bitmask tag, register the field
230
+ // index under each individual set bit so that view.add(obj, Tag.ONE) finds
231
+ // fields tagged @view(Tag.ONE|Tag.TWO).
232
+ // Negative tags (i.e. DEFAULT_VIEW_TAG = -1) are stored as-is.
233
+ if (tag < 0) {
234
+ if (!metadata[$fieldIndexesByViewTag][tag]) {
235
+ metadata[$fieldIndexesByViewTag][tag] = [];
236
+ }
237
+ metadata[$fieldIndexesByViewTag][tag].push(index);
238
+ } else {
239
+ for (let bits = tag; bits > 0; bits &= bits - 1) {
240
+ const bit = bits & (-bits); // isolate lowest set bit
241
+ if (!metadata[$fieldIndexesByViewTag][bit]) {
242
+ metadata[$fieldIndexesByViewTag][bit] = [];
243
+ }
244
+ metadata[$fieldIndexesByViewTag][bit].push(index);
245
+ }
221
246
  }
222
-
223
- metadata[$fieldIndexesByViewTag][tag].push(index);
224
247
  },
225
248
 
226
249
  setUnreliable(metadata: Metadata, fieldName: string) {
@@ -358,8 +381,8 @@ export const Metadata = {
358
381
  Object.defineProperty(target.prototype, fieldName, metadata[$descriptors][fieldName]);
359
382
  }
360
383
 
361
- // Pre-compute encoder function for primitive types.
362
- if (typeof normalized === "string") {
384
+ // Pre-compute encoder function for primitive + quantized types.
385
+ if (typeof normalized === "string" || isQuantizedType(normalized)) {
363
386
  if (!metadata[$encoders]) {
364
387
  Object.defineProperty(metadata, $encoders, {
365
388
  value: [],
@@ -368,7 +391,9 @@ export const Metadata = {
368
391
  writable: true,
369
392
  });
370
393
  }
371
- metadata[$encoders][fieldIndex] = (encode as any)[normalized];
394
+ metadata[$encoders][fieldIndex] = (typeof normalized === "string")
395
+ ? (encode as any)[normalized]
396
+ : makeQuantizedEncoder((normalized as any).quantized);
372
397
  }
373
398
  },
374
399
 
package/src/Reflection.ts CHANGED
@@ -8,6 +8,7 @@ import { Schema } from "./Schema.js";
8
8
  import { t, FieldBuilder } from "./types/builder.js";
9
9
  import { ArraySchema } from "./types/custom/ArraySchema.js";
10
10
  import { $encodeDescriptor, $numFields } from "./types/symbols.js";
11
+ import { isQuantizedType, resolveQuantize } from "./types/quantize.js";
11
12
 
12
13
  /**
13
14
  * Static methods available on Reflection
@@ -52,10 +53,30 @@ interface ReflectionStatic {
52
53
  /**
53
54
  * Reflection
54
55
  */
56
+
57
+ /**
58
+ * `t.quantized()` field descriptor as it rides the reflection handshake —
59
+ * schema-typed (bit-exact float64 bounds), NOT a string grammar, so every
60
+ * language port decodes it with the schema decoder it already has.
61
+ */
62
+ export const QuantizedDescriptor = schema({
63
+ min: t.float64(),
64
+ max: t.float64(),
65
+ bits: t.uint8(),
66
+ mode: t.uint8(), // 0 = clamp, 1 = wrap
67
+ }, "QuantizedDescriptor");
68
+ export type QuantizedDescriptor = SchemaType<typeof QuantizedDescriptor>;
69
+
55
70
  export const ReflectionField = schema({
56
71
  name: t.string(),
57
72
  type: t.string(),
58
73
  referencedType: t.number(),
74
+ /** Primitive child of a collection (`array`/`map`/... of "string" etc.) —
75
+ * its own slot, replacing the legacy `"array:string"` colon packing. */
76
+ childPrimitive: t.string(),
77
+ /** Set only on `t.quantized()` fields (`.optional()` — no auto-instantiated
78
+ * default; its absence is the "not quantized" signal on decode). */
79
+ quantized: t.ref(QuantizedDescriptor).optional(),
59
80
  }, "ReflectionField");
60
81
  export type ReflectionField = SchemaType<typeof ReflectionField>;
61
82
 
@@ -147,6 +168,18 @@ Reflection.encode = function (encoder: Encoder, it: Iterator = { offset: 0 }) {
147
168
  if (typeof (field.type) === "string") {
148
169
  fieldType = field.type;
149
170
 
171
+ } else if (isQuantizedType(field.type)) {
172
+ // Params ride as a schema-typed descriptor (bit-exact float64) —
173
+ // no string grammar for the peer (or a language port) to parse.
174
+ const d = field.type.quantized;
175
+ fieldType = "quantized";
176
+ const desc = new QuantizedDescriptor();
177
+ desc.min = d.min;
178
+ desc.max = d.max;
179
+ desc.bits = d.bits;
180
+ desc.mode = d.wrap ? 1 : 0;
181
+ reflectionField.quantized = desc;
182
+
150
183
  } else {
151
184
  let childTypeSchema: typeof Schema;
152
185
 
@@ -161,7 +194,8 @@ Reflection.encode = function (encoder: Encoder, it: Iterator = { offset: 0 }) {
161
194
  fieldType = Object.keys(field.type)[0];
162
195
 
163
196
  if (typeof (field.type[fieldType as keyof typeof field.type]) === "string") {
164
- fieldType += ":" + field.type[fieldType as keyof typeof field.type]; // array:string
197
+ // primitive child gets its own slot (was packed as "array:string")
198
+ reflectionField.childPrimitive = field.type[fieldType as keyof typeof field.type] as string;
165
199
 
166
200
  } else {
167
201
  childTypeSchema = field.type[fieldType as keyof typeof field.type];
@@ -215,16 +249,20 @@ Reflection.decode = function <T extends Schema = Schema>(bytes: Uint8Array, it?:
215
249
  reflectionType.fields.forEach((field, i) => {
216
250
  const fieldIndex = parentFieldIndex + i;
217
251
 
218
- if (field.referencedType !== undefined) {
219
- let fieldType = field.type;
220
- let refType: PrimitiveType = typeContext.get(field.referencedType);
221
-
222
- // map or array of primitive type (-1)
223
- if (!refType) {
224
- const typeInfo = field.type.split(":");
225
- fieldType = typeInfo[0];
226
- refType = typeInfo[1] as PrimitiveType; // string
227
- }
252
+ if (field.quantized !== undefined) {
253
+ // Schema-typed descriptor → resolved codec (validation stays in
254
+ // resolveQuantize, same as the builder path).
255
+ const q = field.quantized;
256
+ Metadata.addField(metadata, fieldIndex, field.name, {
257
+ quantized: resolveQuantize({ min: q.min, max: q.max, bits: q.bits as 8 | 16 | 32, mode: q.mode === 1 ? "wrap" : "clamp" }),
258
+ } as any);
259
+
260
+ } else if (field.referencedType !== undefined) {
261
+ const fieldType = field.type;
262
+ // Schema child by type id; a primitive child (referencedType -1)
263
+ // rides its own childPrimitive slot.
264
+ const refType: PrimitiveType = typeContext.get(field.referencedType)
265
+ ?? field.childPrimitive as PrimitiveType;
228
266
 
229
267
  if (fieldType === "ref") {
230
268
  Metadata.addField(metadata, fieldIndex, field.name, refType);