@colyseus/schema 5.0.11 → 5.0.13

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 (62) hide show
  1. package/build/Metadata.d.ts +20 -12
  2. package/build/annotations.d.ts +23 -10
  3. package/build/codegen/cli.cjs +615 -204
  4. package/build/codegen/cli.cjs.map +1 -1
  5. package/build/codegen/languages/dart.d.ts +20 -0
  6. package/build/codegen/types.d.ts +20 -0
  7. package/build/decoder/Resync.d.ts +3 -3
  8. package/build/encoder/ChangeTree.d.ts +25 -9
  9. package/build/encoder/EncodeDescriptor.d.ts +11 -12
  10. package/build/encoder/StateView.d.ts +26 -2
  11. package/build/encoder/changeTree/inheritedFlags.d.ts +1 -1
  12. package/build/encoder/changeTree/parentChain.d.ts +9 -0
  13. package/build/encoder/streaming.d.ts +7 -0
  14. package/build/index.cjs +449 -233
  15. package/build/index.cjs.map +1 -1
  16. package/build/index.d.ts +1 -1
  17. package/build/index.js +449 -233
  18. package/build/index.mjs +448 -232
  19. package/build/index.mjs.map +1 -1
  20. package/build/types/builder.d.ts +31 -22
  21. package/build/types/custom/ArraySchema.d.ts +17 -0
  22. package/build/types/custom/StreamSchema.d.ts +1 -1
  23. package/build/types/symbols.d.ts +4 -10
  24. package/package.json +1 -1
  25. package/src/Metadata.ts +58 -31
  26. package/src/annotations.ts +56 -32
  27. package/src/codegen/api.ts +2 -1
  28. package/src/codegen/languages/c.ts +21 -3
  29. package/src/codegen/languages/csharp.ts +7 -1
  30. package/src/codegen/languages/dart.ts +274 -0
  31. package/src/codegen/languages/haxe.ts +7 -1
  32. package/src/codegen/languages/lua.ts +16 -4
  33. package/src/codegen/languages/ts.ts +5 -0
  34. package/src/codegen/parser.ts +97 -3
  35. package/src/codegen/types.ts +24 -0
  36. package/src/decoder/Resync.ts +8 -8
  37. package/src/encoder/ChangeRecorder.ts +1 -1
  38. package/src/encoder/ChangeTree.ts +46 -26
  39. package/src/encoder/EncodeDescriptor.ts +17 -38
  40. package/src/encoder/EncodeOperation.ts +3 -1
  41. package/src/encoder/Encoder.ts +97 -21
  42. package/src/encoder/Root.ts +18 -20
  43. package/src/encoder/StateView.ts +102 -12
  44. package/src/encoder/changeTree/inheritedFlags.ts +10 -10
  45. package/src/encoder/changeTree/liveIteration.ts +9 -9
  46. package/src/encoder/changeTree/parentChain.ts +29 -0
  47. package/src/encoder/streaming.ts +8 -0
  48. package/src/encoding/spec.ts +1 -1
  49. package/src/index.ts +2 -2
  50. package/src/types/builder.ts +35 -31
  51. package/src/types/custom/ArraySchema.ts +40 -1
  52. package/src/types/custom/StreamSchema.ts +1 -1
  53. package/src/types/symbols.ts +4 -11
  54. package/src/bench_bloat.ts +0 -173
  55. package/src/bench_churn.ts +0 -121
  56. package/src/bench_decode.ts +0 -221
  57. package/src/bench_decode_mem.ts +0 -165
  58. package/src/bench_encode.ts +0 -108
  59. package/src/bench_init.ts +0 -150
  60. package/src/bench_static.ts +0 -109
  61. package/src/bench_stream.ts +0 -295
  62. package/src/bench_view_cmp.ts +0 -142
@@ -16,12 +16,11 @@ export interface BuilderDefinition {
16
16
  default?: any;
17
17
  hasDefault: boolean;
18
18
  view?: number;
19
- owned?: boolean;
20
19
  unreliable?: boolean;
21
- transient?: boolean;
20
+ patchOnly?: boolean;
22
21
  deprecated?: boolean;
23
22
  deprecatedThrows?: boolean;
24
- static?: boolean;
23
+ fullStateOnly?: boolean;
25
24
  stream?: boolean;
26
25
  optional?: boolean;
27
26
  /** Local-only field: typed + initialized, but never registered for sync. */
@@ -60,12 +59,11 @@ export declare class FieldBuilder<T = unknown, HasDefault extends boolean = fals
60
59
  private _default;
61
60
  private _hasDefault;
62
61
  private _view;
63
- private _owned;
64
62
  private _unreliable;
65
- private _transient;
63
+ private _patchOnly;
66
64
  private _deprecated;
67
65
  private _deprecatedThrows;
68
- private _static;
66
+ private _fullStateOnly;
69
67
  private _stream;
70
68
  private _optional;
71
69
  private _noSync;
@@ -90,28 +88,36 @@ export declare class FieldBuilder<T = unknown, HasDefault extends boolean = fals
90
88
  default(value: T | (() => T)): FieldBuilder<T, true, IsOptional>;
91
89
  /** Tag this field with a view tag (DEFAULT_VIEW_TAG when called without arg). */
92
90
  view(tag?: number): this;
93
- /** Mark this field as owned (encoder-side ownership filtering). */
94
- owned(): this;
95
91
  /**
96
92
  * Mark this field as unreliable — tick patches emit it on the unreliable
97
93
  * transport channel. Still persisted to full-sync snapshots unless also
98
- * tagged with `.transient()`.
94
+ * tagged with `.patchOnly()`. Primitive fields only.
95
+ *
96
+ * The field's FIRST value still travels the reliable channel, as part of
97
+ * the owning instance's ADD; only later mutations become unreliable. A
98
+ * decoder cannot apply a write to a ref it has not been told about, so a
99
+ * value emitted ahead of that ADD would be dropped — and lost for good if
100
+ * the field is never written again.
99
101
  */
100
102
  unreliable(): this;
101
103
  /**
102
- * Mark this field as transientNOT persisted to full-sync snapshots
103
- * (`encodeAll` / `encodeAllView`). Late-joining clients see the field
104
- * only after its next mutation is emitted on a tick patch. Orthogonal
105
- * to `.unreliable()`.
104
+ * Deliver this field on tick patches ONLY it is never written to a
105
+ * full-state sync (`encodeAll` / `encodeAllView`). Late-joining clients
106
+ * see the field only after its next mutation is emitted on a patch.
107
+ * The mirror of `.fullStateOnly()`, and orthogonal to `.unreliable()`.
106
108
  */
107
- transient(): this;
109
+ patchOnly(): this;
108
110
  /**
109
- * Mark this field as static.
110
- * - Primitive / Schema fields: synchronized once, skips change tracking.
111
- * - Stream fields (`t.stream(X).static()`): child elements are frozen
112
- * after add post-add field mutations on elements become no-ops.
111
+ * Deliver this field in the full state sync ONLY (`encodeAll` /
112
+ * `encodeAllView`) it never enters a tick patch. A client receives it
113
+ * on join (and again on a resync); writes after that are not tracked.
114
+ * The mirror of `.patchOnly()`.
115
+ *
116
+ * The field itself is NOT frozen — it stays mutable server-side, only
117
+ * its propagation stops. On a stream field (`t.stream(X).fullStateOnly()`)
118
+ * the same rule applies per element: post-add mutations are no-ops.
113
119
  */
114
- static(): this;
120
+ fullStateOnly(): this;
115
121
  /**
116
122
  * Mark this field as **local-only** — it is typed and initialized on the
117
123
  * instance (so `.default()` and the inferred instance type still apply),
@@ -121,8 +127,8 @@ export declare class FieldBuilder<T = unknown, HasDefault extends boolean = fals
121
127
  * Useful for server-side scratch state, per-peer UI state, or values you
122
128
  * want on the class for typing convenience without paying any sync cost.
123
129
  *
124
- * Mutually exclusive with the sync-only modifiers (`.view()`, `.owned()`,
125
- * `.unreliable()`, `.transient()`, `.static()`, `.stream()`) — combining
130
+ * Mutually exclusive with the sync-only modifiers (`.view()`,
131
+ * `.unreliable()`, `.patchOnly()`, `.fullStateOnly()`, `.stream()`) — combining
126
132
  * them throws at `schema()` time.
127
133
  *
128
134
  * ```ts
@@ -154,9 +160,12 @@ export declare class FieldBuilder<T = unknown, HasDefault extends boolean = fals
154
160
  * higher return values emit first. Does nothing in broadcast mode
155
161
  * (shared `encode()` drains FIFO). Only meaningful on stream fields.
156
162
  *
163
+ * `StateView` carries no position of its own — attach whatever the
164
+ * callback needs to sort by (`view` is loosely typed for this).
165
+ *
157
166
  * ```ts
158
167
  * t.stream(Enemy).priority((view, enemy) =>
159
- * -dist2(view.anchor, enemy)
168
+ * -((enemy.x - view.x) ** 2 + (enemy.y - view.y) ** 2)
160
169
  * )
161
170
  * ```
162
171
  */
@@ -63,6 +63,23 @@ export declare class ArraySchema<V = any> implements Array<V>, Collection<number
63
63
  * land on the wrong wire slots.
64
64
  */
65
65
  protected $wireIndex(index: number): number;
66
+ /**
67
+ * Re-point children at their wire slot. `ChangeTree._parentIndex` caches
68
+ * the slot a child holds in `tmpItems`, and StateView addresses per-view
69
+ * ADD/DELETE with it — so a reorder that leaves it behind aims those ops
70
+ * at whichever element inherited the slot (issue #231).
71
+ *
72
+ * The filter check is a correctness boundary, not a tunable: StateView is
73
+ * the only reader and reaches the index only through a filtered array
74
+ * (`addParentOf` bails on `hasFilteredFields`, `remove` on the child's
75
+ * `isFiltered`). Everything else stops at the flag read instead of walking
76
+ * its children every tick.
77
+ *
78
+ * Callers name the lowest slot that moved as `from`. Compaction cannot, so
79
+ * it hands over the pre-compaction layout as `staged` and the unchanged
80
+ * prefix is skipped instead. Either way tail churn walks nothing.
81
+ */
82
+ protected $reindexChildren(from: number, staged?: V[]): void;
66
83
  protected $changeAt(index: number, value: V): number | undefined;
67
84
  protected $deleteAt(index: number, operation?: OPERATION): void;
68
85
  protected $setAt(index: number, value: V, operation: OPERATION): void;
@@ -11,7 +11,7 @@ import type { Schema } from "../../Schema.js";
11
11
  * per-client and drained in priority order (callback on StateView) up to
12
12
  * `maxPerTick` per encode pass. Field mutations on already-sent elements
13
13
  * propagate through the normal reliable channel without consuming the
14
- * per-tick budget. Chain `.static()` on the field builder to suppress
14
+ * per-tick budget. Chain `.fullStateOnly()` on the field builder to suppress
15
15
  * post-add mutation tracking entirely.
16
16
  */
17
17
  export declare class StreamSchema<V = any> implements IRef {
@@ -66,16 +66,10 @@ export declare const $builder = "~builder";
66
66
  * Metadata
67
67
  */
68
68
  export declare const $descriptors = "~descriptors";
69
- /**
70
- * Per-class bitmask: bit i set iff field i carries a @view tag.
71
- * Lazily computed from $viewFieldIndexes on first encode pass.
72
- * Skips the per-field metadata[i].tag property chase in the hot encode loop.
73
- */
74
- export declare const $filterBitmask = "~__filterBitmask";
75
69
  /**
76
70
  * Cached per-class encode descriptor: bundles encoder fn, filter fn,
77
- * metadata, isSchema flag, and filterBitmask into one object stashed on
78
- * the constructor. Replaces 5 separate per-tree property chases /
71
+ * metadata, isSchema flag and the per-field arrays into one object stashed
72
+ * on the constructor. Replaces several separate per-tree property chases /
79
73
  * function calls in the encode loop with a single property load.
80
74
  */
81
75
  export declare const $encodeDescriptor = "~__encodeDescriptor";
@@ -85,7 +79,7 @@ export declare const $refTypeFieldIndexes = "~__refTypeFieldIndexes";
85
79
  export declare const $viewFieldIndexes = "~__viewFieldIndexes";
86
80
  export declare const $fieldIndexesByViewTag = "$__fieldIndexesByViewTag";
87
81
  export declare const $unreliableFieldIndexes = "~__unreliableFieldIndexes";
88
- export declare const $transientFieldIndexes = "~__transientFieldIndexes";
89
- export declare const $staticFieldIndexes = "~__staticFieldIndexes";
82
+ export declare const $patchOnlyFieldIndexes = "~__patchOnlyFieldIndexes";
83
+ export declare const $fullStateOnlyFieldIndexes = "~__fullStateOnlyFieldIndexes";
90
84
  export declare const $streamFieldIndexes = "~__streamFieldIndexes";
91
85
  export declare const $streamPriorities = "~__streamPriorities";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colyseus/schema",
3
- "version": "5.0.11",
3
+ "version": "5.0.13",
4
4
  "description": "Binary state serializer with delta encoding for games",
5
5
  "type": "module",
6
6
  "bin": {
package/src/Metadata.ts CHANGED
@@ -1,22 +1,31 @@
1
1
  import { DefinitionType, getPropertyDescriptor } from "./annotations.js";
2
2
  import { Schema } from "./Schema.js";
3
3
  import { getType, registeredTypes, TypeDefinition } from "./types/registry.js";
4
- import { $decoder, $descriptors, $encoder, $encoders, $fieldIndexesByViewTag, $numFields, $refTypeFieldIndexes, $staticFieldIndexes, $streamFieldIndexes, $streamPriorities, $track, $transientFieldIndexes, $unreliableFieldIndexes, $viewFieldIndexes } from "./types/symbols.js";
4
+ import { $decoder, $descriptors, $encoder, $encoders, $fieldIndexesByViewTag, $numFields, $refTypeFieldIndexes, $fullStateOnlyFieldIndexes, $streamFieldIndexes, $streamPriorities, $track, $patchOnlyFieldIndexes, $unreliableFieldIndexes, $viewFieldIndexes } from "./types/symbols.js";
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
8
  import { isQuantizedType, makeQuantizedEncoder, resolveQuantize } from "./types/quantize.js";
9
9
 
10
+ /**
11
+ * Field indexes ride in the low 6 bits of the operation byte
12
+ * (`(index | operation) & 255`), which leaves room for 0..63. Index 63 is
13
+ * given up: `DELETE_AND_ADD | 63` is 255, the same byte the decoder claims
14
+ * as SWITCH_TO_STRUCTURE before any field decoder sees it. Every nullable
15
+ * field can produce that operation (delete-then-set in one tick merges to
16
+ * DELETE_AND_ADD), so the slot is unusable rather than partly usable.
17
+ */
18
+ export const MAX_FIELDS = 63;
19
+
10
20
  export type MetadataField = {
11
21
  type: DefinitionType,
12
22
  name: string,
13
23
  index: number,
14
24
  tag?: number,
15
25
  unreliable?: boolean,
16
- transient?: boolean,
26
+ patchOnly?: boolean,
17
27
  deprecated?: boolean,
18
- owned?: boolean,
19
- static?: boolean,
28
+ fullStateOnly?: boolean,
20
29
  stream?: boolean,
21
30
  optional?: boolean,
22
31
  };
@@ -27,8 +36,8 @@ export type Metadata =
27
36
  { [$fieldIndexesByViewTag]: {[tag: number]: number[]}; } & // field indexes by "view" tag
28
37
  { [$refTypeFieldIndexes]: number[]; } & // all field indexes containing Ref types (Schema, ArraySchema, MapSchema, etc)
29
38
  { [$unreliableFieldIndexes]: number[]; } & // all field indexes tagged with @unreliable
30
- { [$transientFieldIndexes]: number[]; } & // all field indexes tagged with @transient (not persisted to snapshots)
31
- { [$staticFieldIndexes]: number[]; } & // all field indexes tagged with @static (not tracked after assignment)
39
+ { [$patchOnlyFieldIndexes]: number[]; } & // all field indexes tagged with @patchOnly (not persisted to snapshots)
40
+ { [$fullStateOnlyFieldIndexes]: number[]; } & // all field indexes tagged @fullStateOnly / .fullStateOnly() (not tracked after assignment)
32
41
  { [$streamFieldIndexes]: number[]; } & // all field indexes holding a t.stream(...) collection
33
42
  { [$streamPriorities]: { [field: number]: (view: any, element: any) => number }; } & // per-stream-field priority callback declared at schema definition time
34
43
  { [$encoders]: Array<(bytes: Uint8Array, value: any, it: any) => void>; } & // pre-computed encoder fn per primitive field
@@ -107,12 +116,14 @@ function isTSEnum(_enum: any) {
107
116
  export const Metadata = {
108
117
 
109
118
  addField(metadata: any, index: number, name: string, type: DefinitionType, descriptor?: PropertyDescriptor) {
110
- if (index > 64) {
111
- throw new Error(`Can't define field '${name}'.\nSchema instances may only have up to 64 fields.`);
119
+ // `index` is 0-based, so 62 is the last usable slot — see MAX_FIELDS
120
+ // for why 63 is off limits.
121
+ if (index >= MAX_FIELDS) {
122
+ throw new Error(`Can't define field '${name}'.\nSchema instances may only have up to ${MAX_FIELDS} fields.`);
112
123
  }
113
124
 
114
125
  metadata[index] = Object.assign(
115
- metadata[index] || {}, // avoid overwriting previous field metadata (@owned / @deprecated)
126
+ metadata[index] || {}, // avoid overwriting previous field metadata (@deprecated / @unreliable)
116
127
  {
117
128
  type: getNormalizedType(type),
118
129
  index,
@@ -278,34 +289,50 @@ export const Metadata = {
278
289
  metadata[$unreliableFieldIndexes].push(index);
279
290
  },
280
291
 
281
- setTransient(metadata: Metadata, fieldName: string) {
292
+ setPatchOnly(metadata: Metadata, fieldName: string) {
282
293
  const index = metadata[fieldName];
283
- metadata[index].transient = true;
294
+ // patchOnly + fullStateOnly are the only two delivery channels —
295
+ // excluding a field from both would silently never reach a client.
296
+ // (The builder validates earlier; this guards the decorator path.)
297
+ if (metadata[index].fullStateOnly) {
298
+ throw new Error(
299
+ `field "${fieldName}" cannot be both patchOnly and fullStateOnly — ` +
300
+ `those are the only two delivery channels, so the field would never reach a client.`
301
+ );
302
+ }
303
+ metadata[index].patchOnly = true;
284
304
 
285
- if (!metadata[$transientFieldIndexes]) {
286
- Object.defineProperty(metadata, $transientFieldIndexes, {
305
+ if (!metadata[$patchOnlyFieldIndexes]) {
306
+ Object.defineProperty(metadata, $patchOnlyFieldIndexes, {
287
307
  value: [],
288
308
  enumerable: false,
289
309
  configurable: true,
290
310
  writable: true,
291
311
  });
292
312
  }
293
- metadata[$transientFieldIndexes].push(index);
313
+ metadata[$patchOnlyFieldIndexes].push(index);
294
314
  },
295
315
 
296
- setStatic(metadata: Metadata, fieldName: string) {
316
+ setFullStateOnly(metadata: Metadata, fieldName: string) {
297
317
  const index = metadata[fieldName];
298
- metadata[index].static = true;
318
+ // Mirror of the guard in setPatchOnly — covers both decorator orders.
319
+ if (metadata[index].patchOnly) {
320
+ throw new Error(
321
+ `field "${fieldName}" cannot be both patchOnly and fullStateOnly — ` +
322
+ `those are the only two delivery channels, so the field would never reach a client.`
323
+ );
324
+ }
325
+ metadata[index].fullStateOnly = true;
299
326
 
300
- if (!metadata[$staticFieldIndexes]) {
301
- Object.defineProperty(metadata, $staticFieldIndexes, {
327
+ if (!metadata[$fullStateOnlyFieldIndexes]) {
328
+ Object.defineProperty(metadata, $fullStateOnlyFieldIndexes, {
302
329
  value: [],
303
330
  enumerable: false,
304
331
  configurable: true,
305
332
  writable: true,
306
333
  });
307
334
  }
308
- metadata[$staticFieldIndexes].push(index);
335
+ metadata[$fullStateOnlyFieldIndexes].push(index);
309
336
  },
310
337
 
311
338
  setStream(metadata: Metadata, fieldName: string) {
@@ -503,20 +530,20 @@ export const Metadata = {
503
530
  });
504
531
  }
505
532
 
506
- // $transientFieldIndexes
507
- if (parentMetadata[$transientFieldIndexes] !== undefined) {
508
- Object.defineProperty(metadata, $transientFieldIndexes, {
509
- value: [...parentMetadata[$transientFieldIndexes]],
533
+ // $patchOnlyFieldIndexes
534
+ if (parentMetadata[$patchOnlyFieldIndexes] !== undefined) {
535
+ Object.defineProperty(metadata, $patchOnlyFieldIndexes, {
536
+ value: [...parentMetadata[$patchOnlyFieldIndexes]],
510
537
  enumerable: false,
511
538
  configurable: true,
512
539
  writable: true,
513
540
  });
514
541
  }
515
542
 
516
- // $staticFieldIndexes
517
- if (parentMetadata[$staticFieldIndexes] !== undefined) {
518
- Object.defineProperty(metadata, $staticFieldIndexes, {
519
- value: [...parentMetadata[$staticFieldIndexes]],
543
+ // $fullStateOnlyFieldIndexes
544
+ if (parentMetadata[$fullStateOnlyFieldIndexes] !== undefined) {
545
+ Object.defineProperty(metadata, $fullStateOnlyFieldIndexes, {
546
+ value: [...parentMetadata[$fullStateOnlyFieldIndexes]],
520
547
  enumerable: false,
521
548
  configurable: true,
522
549
  writable: true,
@@ -586,12 +613,12 @@ export const Metadata = {
586
613
  return metadata?.[$unreliableFieldIndexes]?.includes(index);
587
614
  },
588
615
 
589
- hasTransientAtIndex(metadata: Metadata, index: number) {
590
- return metadata?.[$transientFieldIndexes]?.includes(index);
616
+ hasPatchOnlyAtIndex(metadata: Metadata, index: number) {
617
+ return metadata?.[$patchOnlyFieldIndexes]?.includes(index);
591
618
  },
592
619
 
593
- hasStaticAtIndex(metadata: Metadata, index: number) {
594
- return metadata?.[$staticFieldIndexes]?.includes(index);
620
+ hasFullStateOnlyAtIndex(metadata: Metadata, index: number) {
621
+ return metadata?.[$fullStateOnlyFieldIndexes]?.includes(index);
595
622
  },
596
623
 
597
624
  hasStreamAtIndex(metadata: Metadata, index: number) {
@@ -37,12 +37,12 @@ export type PrimitiveType = RawPrimitiveType | typeof Schema | object;
37
37
  // TODO: infer "default" value type correctly.
38
38
  export type DefinitionType<T extends PrimitiveType = PrimitiveType> = T
39
39
  | T[]
40
- | { type: T, default?: InferValueType<T>, view?: boolean | number, sync?: boolean, owned?: boolean }
41
- | { array: T, default?: ArraySchema<InferValueType<T>>, view?: boolean | number, sync?: boolean, owned?: boolean }
42
- | { map: T, default?: MapSchema<InferValueType<T>>, view?: boolean | number, sync?: boolean, owned?: boolean }
43
- | { collection: T, default?: CollectionSchema<InferValueType<T>>, view?: boolean | number, sync?: boolean, owned?: boolean }
44
- | { set: T, default?: SetSchema<InferValueType<T>>, view?: boolean | number, sync?: boolean, owned?: boolean }
45
- | { stream: T, default?: StreamSchema<InferValueType<T>>, view?: boolean | number, sync?: boolean, owned?: boolean, priority?: (view: any, element: InferValueType<T>) => number };
40
+ | { type: T, default?: InferValueType<T>, view?: boolean | number, sync?: boolean }
41
+ | { array: T, default?: ArraySchema<InferValueType<T>>, view?: boolean | number, sync?: boolean }
42
+ | { map: T, default?: MapSchema<InferValueType<T>>, view?: boolean | number, sync?: boolean }
43
+ | { collection: T, default?: CollectionSchema<InferValueType<T>>, view?: boolean | number, sync?: boolean }
44
+ | { set: T, default?: SetSchema<InferValueType<T>>, view?: boolean | number, sync?: boolean }
45
+ | { stream: T, default?: StreamSchema<InferValueType<T>>, view?: boolean | number, sync?: boolean, priority?: (view: any, element: InferValueType<T>) => number };
46
46
 
47
47
  export type Definition = { [field: string]: DefinitionType };
48
48
 
@@ -237,27 +237,46 @@ export function view<T> (tag: number = DEFAULT_VIEW_TAG) {
237
237
  }
238
238
  }
239
239
 
240
- export function owned<T> (target: T, field: string) {
241
- const metadata = Metadata.initialize(target.constructor as typeof Schema);
242
- metadata[metadata[field]].owned = true;
243
- }
244
-
240
+ /**
241
+ * `@unreliable` route a field onto the unreliable transport channel, so a
242
+ * dropped update costs one stale value instead of stalling the ordered stream
243
+ * behind a retransmit. Primitive fields only (see `Metadata.setUnreliable`).
244
+ *
245
+ * The field's FIRST value still travels the reliable channel, as part of the
246
+ * owning instance's ADD; only later mutations become unreliable. A decoder
247
+ * cannot apply a write to a ref it has not been told about, so a value emitted
248
+ * ahead of that ADD would be dropped — and lost for good if the field is never
249
+ * written again.
250
+ */
245
251
  export function unreliable<T> (target: T, field: string) {
246
252
  const metadata = Metadata.initialize(target.constructor as typeof Schema);
247
253
  Metadata.setUnreliable(metadata, field);
248
254
  }
249
255
 
250
256
  /**
251
- * @transient — mark a field as not persisted to snapshots (encodeAll /
252
- * encodeAllView). Transient fields are still emitted on per-tick patches
257
+ * @patchOnly — mark a field as not persisted to snapshots (encodeAll /
258
+ * encodeAllView). PatchOnly fields are still emitted on per-tick patches
253
259
  * (reliable or unreliable), but late-joining clients won't see them until
254
260
  * the next mutation.
255
261
  *
256
262
  * Orthogonal to @unreliable: a field can be either, both, or neither.
257
263
  */
258
- export function transient<T> (target: T, field: string) {
264
+ export function patchOnly<T> (target: T, field: string) {
259
265
  const metadata = Metadata.initialize(target.constructor as typeof Schema);
260
- Metadata.setTransient(metadata, field);
266
+ Metadata.setPatchOnly(metadata, field);
267
+ }
268
+
269
+ /**
270
+ * @fullStateOnly — mark a field as delivered in the full state sync only
271
+ * (encodeAll / encodeAllView), never on per-tick patches. Writes after a
272
+ * client has joined are not propagated to it — populate these fields
273
+ * before clients connect (e.g. during onCreate).
274
+ *
275
+ * The exact mirror of @patchOnly — the two are mutually exclusive.
276
+ */
277
+ export function fullStateOnly<T> (target: T, field: string) {
278
+ const metadata = Metadata.initialize(target.constructor as typeof Schema);
279
+ Metadata.setFullStateOnly(metadata, field);
261
280
  }
262
281
 
263
282
  export function type (
@@ -772,11 +791,10 @@ export function schema<
772
791
  };
773
792
 
774
793
  const viewTagFields: { [field: string]: number } = {};
775
- const ownedFields: string[] = [];
776
794
  const unreliableFields: string[] = [];
777
- const transientFields: string[] = [];
795
+ const patchOnlyFields: string[] = [];
778
796
  const deprecatedFields: { [field: string]: boolean } = {};
779
- const staticFields: string[] = [];
797
+ const fullStateOnlyFields: string[] = [];
780
798
  const streamFields: string[] = [];
781
799
  const streamPriorityFields: { [field: string]: (view: any, element: any) => number } = {};
782
800
  const optionalFields: string[] = [];
@@ -791,11 +809,11 @@ export function schema<
791
809
  // Local-only field: skip metadata registration entirely so it is
792
810
  // never encoded/decoded, but still seed its construction default
793
811
  // (honoring `.default()` and collection/ref auto-instantiation).
794
- if (def.view !== undefined || def.owned || def.unreliable ||
795
- def.transient || def.static || def.stream) {
812
+ if (def.view !== undefined || def.unreliable ||
813
+ def.patchOnly || def.fullStateOnly || def.stream) {
796
814
  throw new Error(
797
815
  `schema(${name ? `'${name}'` : ""}): field '${fieldName}' uses .noSync() ` +
798
- `together with a sync-only modifier (.view/.owned/.unreliable/.transient/.static/.stream). ` +
816
+ `together with a sync-only modifier (.view/.unreliable/.patchOnly/.fullStateOnly/.stream). ` +
799
817
  `A local-only field cannot be synchronized.`
800
818
  );
801
819
  }
@@ -803,6 +821,16 @@ export function schema<
803
821
  continue;
804
822
  }
805
823
 
824
+ // The two delivery channels are exhaustive: excluding a field from
825
+ // both leaves it with nowhere to go — a silent .noSync().
826
+ if (def.patchOnly && def.fullStateOnly) {
827
+ throw new Error(
828
+ `schema(${name ? `'${name}'` : ""}): field '${fieldName}' uses .patchOnly() ` +
829
+ `together with .fullStateOnly(). Those are the only two delivery channels, ` +
830
+ `so the field would never reach a client — use .noSync() if that is intended.`
831
+ );
832
+ }
833
+
806
834
  const normalizedType = getNormalizedType(def.type);
807
835
  // A synced ref must be encodable (a Schema, or Metadata.setFields()'d) — reject a bare class.
808
836
  if (typeof normalizedType === "function" && !Schema.is(normalizedType)) {
@@ -814,11 +842,10 @@ export function schema<
814
842
  fields[fieldName] = normalizedType;
815
843
 
816
844
  if (def.view !== undefined) { viewTagFields[fieldName] = def.view; }
817
- if (def.owned) { ownedFields.push(fieldName); }
818
845
  if (def.unreliable) { unreliableFields.push(fieldName); }
819
- if (def.transient) { transientFields.push(fieldName); }
846
+ if (def.patchOnly) { patchOnlyFields.push(fieldName); }
820
847
  if (def.deprecated) { deprecatedFields[fieldName] = def.deprecatedThrows; }
821
- if (def.static) { staticFields.push(fieldName); }
848
+ if (def.fullStateOnly) { fullStateOnlyFields.push(fieldName); }
822
849
  if (def.stream) { streamFields.push(fieldName); }
823
850
  if (def.streamPriority !== undefined) { streamPriorityFields[fieldName] = def.streamPriority; }
824
851
  if (def.optional) { optionalFields.push(fieldName); }
@@ -903,23 +930,20 @@ export function schema<
903
930
  for (const fieldName in viewTagFields) {
904
931
  view(viewTagFields[fieldName])(klass.prototype, fieldName);
905
932
  }
906
- for (const fieldName of ownedFields) {
907
- owned(klass.prototype, fieldName);
908
- }
909
933
  for (const fieldName of unreliableFields) {
910
934
  unreliable(klass.prototype, fieldName);
911
935
  }
912
- for (const fieldName of transientFields) {
913
- transient(klass.prototype, fieldName);
936
+ for (const fieldName of patchOnlyFields) {
937
+ patchOnly(klass.prototype, fieldName);
914
938
  }
915
939
  for (const fieldName in deprecatedFields) {
916
940
  deprecated(deprecatedFields[fieldName])(klass.prototype, fieldName);
917
941
  }
918
942
 
919
- if (staticFields.length > 0 || streamFields.length > 0) {
943
+ if (fullStateOnlyFields.length > 0 || streamFields.length > 0) {
920
944
  const metadata = (klass as any)[Symbol.metadata] as Metadata;
921
- for (const fieldName of staticFields) {
922
- Metadata.setStatic(metadata, fieldName);
945
+ for (const fieldName of fullStateOnlyFields) {
946
+ Metadata.setFullStateOnly(metadata, fieldName);
923
947
  }
924
948
  for (const fieldName of streamFields) {
925
949
  Metadata.setStream(metadata, fieldName);
@@ -14,8 +14,9 @@ import * as java from "./languages/java.js";
14
14
  import * as lua from "./languages/lua.js";
15
15
  import * as c from "./languages/c.js";
16
16
  import * as gdscript from "./languages/gdscript.js";
17
+ import * as dart from "./languages/dart.js";
17
18
 
18
- export const generators: Record<string, any> = { csharp, cpp, haxe, ts, js, java, lua, c, gdscript, };
19
+ export const generators: Record<string, any> = { csharp, cpp, haxe, ts, js, java, lua, c, gdscript, dart, };
19
20
 
20
21
  export interface GenerateOptions {
21
22
  files: string[],
@@ -1,4 +1,4 @@
1
- import { Class, Property, File, getCommentHeader, getInheritanceTree, Context } from "../types.js";
1
+ import { Class, Property, File, getCommentHeader, getInheritanceTree, Context, resolveQuantized } from "../types.js";
2
2
  import { GenerateOptions } from "../api.js";
3
3
 
4
4
  export const name = "C";
@@ -20,6 +20,7 @@ const typeMaps: { [key: string]: string } = {
20
20
  "uint64": "uint64_t",
21
21
  "float32": "float",
22
22
  "float64": "double",
23
+ "quantized": "double",
23
24
  };
24
25
 
25
26
  /**
@@ -42,6 +43,7 @@ const fieldTypeMaps: { [key: string]: string } = {
42
43
  "ref": "COLYSEUS_FIELD_REF",
43
44
  "array": "COLYSEUS_FIELD_ARRAY",
44
45
  "map": "COLYSEUS_FIELD_MAP",
46
+ "quantized": "COLYSEUS_FIELD_QUANTIZED",
45
47
  };
46
48
 
47
49
  const COMMON_INCLUDES = `#include "colyseus/schema/types.h"
@@ -202,11 +204,22 @@ function generateFieldsArray(klass: Class, typeName: string, snakeName: string,
202
204
  return `static const colyseus_field_t ${snakeName}_fields[] = {};`;
203
205
  }
204
206
 
207
+ // one pre-resolved static descriptor per quantized field
208
+ const descriptors = allProperties
209
+ .filter(prop => prop.quantized)
210
+ .map(prop => {
211
+ const q = prop.quantized;
212
+ const { range, span } = resolveQuantized(q);
213
+ return `static const colyseus_quantized_descriptor_t ${snakeName}_${prop.name}_quantized = {${q.min}, ${q.max}, ${range}, ${span}, ${q.bits}, ${q.wrap}};`;
214
+ });
215
+
205
216
  const fields = allProperties.map((prop, i) => {
206
217
  const fieldType = getFieldType(prop);
207
218
  const typeString = getFieldTypeString(prop);
208
219
 
209
220
  let vtableRef = "NULL";
221
+ let childPrimitiveRef = "NULL";
222
+ let quantizedRef = "NULL";
210
223
 
211
224
  if (prop.type === "ref" && prop.childType && !typeMaps[prop.childType]) {
212
225
  const childSnake = toSnakeCase(prop.childType);
@@ -214,12 +227,17 @@ function generateFieldsArray(klass: Class, typeName: string, snakeName: string,
214
227
  } else if ((prop.type === "array" || prop.type === "map") && prop.childType && !typeMaps[prop.childType]) {
215
228
  const childSnake = toSnakeCase(prop.childType);
216
229
  vtableRef = `&${childSnake}_vtable`;
230
+ } else if ((prop.type === "array" || prop.type === "map") && prop.childType) {
231
+ // collection of primitives — the decoder strcmp()s this to pick the reader
232
+ childPrimitiveRef = `"${prop.childType}"`;
233
+ } else if (prop.quantized) {
234
+ quantizedRef = `&${snakeName}_${prop.name}_quantized`;
217
235
  }
218
236
 
219
- return ` {${prop.index}, "${prop.name}", ${fieldType}, "${typeString}", offsetof(${typeName}, ${prop.name}), ${vtableRef}, NULL}`;
237
+ return ` {${prop.index}, "${prop.name}", ${fieldType}, "${typeString}", offsetof(${typeName}, ${prop.name}), ${vtableRef}, ${childPrimitiveRef}, ${quantizedRef}}`;
220
238
  }).join(",\n");
221
239
 
222
- return `static const colyseus_field_t ${snakeName}_fields[] = {
240
+ return `${descriptors.length ? descriptors.join("\n") + "\n\n" : ""}static const colyseus_field_t ${snakeName}_fields[] = {
223
241
  ${fields}
224
242
  };`;
225
243
  }
@@ -191,7 +191,13 @@ function generateProperty(prop: Property, indent: string = "") {
191
191
  let langType: string;
192
192
  let initializer = "";
193
193
 
194
- if (prop.childType) {
194
+ if (prop.quantized) {
195
+ const q = prop.quantized;
196
+ typeArgs += `, QuantizeMin = ${q.min}, QuantizeMax = ${q.max}, QuantizeBits = ${q.bits}, QuantizeWrap = ${q.wrap}`;
197
+ langType = "double";
198
+ initializer = "default(double)";
199
+
200
+ } else if (prop.childType) {
195
201
  const isUpcaseFirst = prop.childType.match(/^[A-Z]/);
196
202
 
197
203
  langType = getType(prop);