@colyseus/schema 4.0.20 → 5.0.1

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 (96) hide show
  1. package/README.md +2 -0
  2. package/build/Metadata.d.ts +56 -2
  3. package/build/Reflection.d.ts +28 -34
  4. package/build/Schema.d.ts +70 -9
  5. package/build/annotations.d.ts +64 -17
  6. package/build/codegen/cli.cjs +84 -67
  7. package/build/codegen/cli.cjs.map +1 -1
  8. package/build/decoder/DecodeOperation.d.ts +48 -5
  9. package/build/decoder/Decoder.d.ts +2 -2
  10. package/build/decoder/strategy/Callbacks.d.ts +1 -1
  11. package/build/encoder/ChangeRecorder.d.ts +107 -0
  12. package/build/encoder/ChangeTree.d.ts +218 -69
  13. package/build/encoder/EncodeDescriptor.d.ts +63 -0
  14. package/build/encoder/EncodeOperation.d.ts +25 -2
  15. package/build/encoder/Encoder.d.ts +59 -3
  16. package/build/encoder/MapJournal.d.ts +62 -0
  17. package/build/encoder/RefIdAllocator.d.ts +35 -0
  18. package/build/encoder/Root.d.ts +94 -13
  19. package/build/encoder/StateView.d.ts +116 -8
  20. package/build/encoder/changeTree/inheritedFlags.d.ts +34 -0
  21. package/build/encoder/changeTree/liveIteration.d.ts +3 -0
  22. package/build/encoder/changeTree/parentChain.d.ts +24 -0
  23. package/build/encoder/changeTree/treeAttachment.d.ts +13 -0
  24. package/build/encoder/streaming.d.ts +73 -0
  25. package/build/encoder/subscriptions.d.ts +25 -0
  26. package/build/index.cjs +5258 -1549
  27. package/build/index.cjs.map +1 -1
  28. package/build/index.d.ts +7 -3
  29. package/build/index.js +5258 -1549
  30. package/build/index.mjs +5249 -1549
  31. package/build/index.mjs.map +1 -1
  32. package/build/input/InputDecoder.d.ts +32 -0
  33. package/build/input/InputEncoder.d.ts +117 -0
  34. package/build/input/index.cjs +7453 -0
  35. package/build/input/index.cjs.map +1 -0
  36. package/build/input/index.d.ts +3 -0
  37. package/build/input/index.mjs +7450 -0
  38. package/build/input/index.mjs.map +1 -0
  39. package/build/types/HelperTypes.d.ts +67 -9
  40. package/build/types/TypeContext.d.ts +9 -0
  41. package/build/types/builder.d.ts +192 -0
  42. package/build/types/custom/ArraySchema.d.ts +25 -4
  43. package/build/types/custom/CollectionSchema.d.ts +30 -2
  44. package/build/types/custom/MapSchema.d.ts +52 -3
  45. package/build/types/custom/SetSchema.d.ts +32 -2
  46. package/build/types/custom/StreamSchema.d.ts +114 -0
  47. package/build/types/symbols.d.ts +48 -5
  48. package/package.json +9 -3
  49. package/src/Metadata.ts +259 -31
  50. package/src/Reflection.ts +15 -13
  51. package/src/Schema.ts +176 -134
  52. package/src/annotations.ts +365 -252
  53. package/src/bench_bloat.ts +173 -0
  54. package/src/bench_decode.ts +221 -0
  55. package/src/bench_decode_mem.ts +165 -0
  56. package/src/bench_encode.ts +108 -0
  57. package/src/bench_init.ts +150 -0
  58. package/src/bench_static.ts +109 -0
  59. package/src/bench_stream.ts +295 -0
  60. package/src/bench_view_cmp.ts +142 -0
  61. package/src/codegen/languages/csharp.ts +0 -24
  62. package/src/codegen/parser.ts +83 -61
  63. package/src/decoder/DecodeOperation.ts +168 -63
  64. package/src/decoder/Decoder.ts +20 -10
  65. package/src/decoder/ReferenceTracker.ts +4 -0
  66. package/src/decoder/strategy/Callbacks.ts +30 -26
  67. package/src/decoder/strategy/getDecoderStateCallbacks.ts +16 -13
  68. package/src/encoder/ChangeRecorder.ts +276 -0
  69. package/src/encoder/ChangeTree.ts +674 -519
  70. package/src/encoder/EncodeDescriptor.ts +213 -0
  71. package/src/encoder/EncodeOperation.ts +107 -65
  72. package/src/encoder/Encoder.ts +630 -119
  73. package/src/encoder/MapJournal.ts +124 -0
  74. package/src/encoder/RefIdAllocator.ts +68 -0
  75. package/src/encoder/Root.ts +247 -120
  76. package/src/encoder/StateView.ts +592 -121
  77. package/src/encoder/changeTree/inheritedFlags.ts +217 -0
  78. package/src/encoder/changeTree/liveIteration.ts +74 -0
  79. package/src/encoder/changeTree/parentChain.ts +131 -0
  80. package/src/encoder/changeTree/treeAttachment.ts +171 -0
  81. package/src/encoder/streaming.ts +232 -0
  82. package/src/encoder/subscriptions.ts +71 -0
  83. package/src/index.ts +15 -3
  84. package/src/input/InputDecoder.ts +57 -0
  85. package/src/input/InputEncoder.ts +303 -0
  86. package/src/input/index.ts +3 -0
  87. package/src/types/HelperTypes.ts +121 -24
  88. package/src/types/TypeContext.ts +14 -2
  89. package/src/types/builder.ts +331 -0
  90. package/src/types/custom/ArraySchema.ts +210 -197
  91. package/src/types/custom/CollectionSchema.ts +115 -35
  92. package/src/types/custom/MapSchema.ts +162 -58
  93. package/src/types/custom/SetSchema.ts +128 -39
  94. package/src/types/custom/StreamSchema.ts +310 -0
  95. package/src/types/symbols.ts +93 -6
  96. package/src/utils.ts +4 -6
@@ -1,6 +1,7 @@
1
1
  import type { Schema } from "../Schema.js";
2
2
  import { TypeContext } from "../types/TypeContext.js";
3
- import { $changes, $encoder, $filter, $getByIndex, $refId } from "../types/symbols.js";
3
+ import { $changes, $getByIndex, $refId } from "../types/symbols.js";
4
+ import { Metadata } from "../Metadata.js";
4
5
 
5
6
  import { encode } from "../encoding/encode.js";
6
7
  import type { Iterator } from "../encoding/decode.js";
@@ -9,8 +10,187 @@ import { OPERATION, SWITCH_TO_STRUCTURE, TYPE_ID } from '../encoding/spec.js';
9
10
  import { Root } from "./Root.js";
10
11
 
11
12
  import type { StateView } from "./StateView.js";
12
- import type { ChangeSetName, ChangeTree, ChangeTreeList, ChangeTreeNode } from "./ChangeTree.js";
13
- import { createChangeTreeList } from "./ChangeTree.js";
13
+ import type { ChangeTree, ChangeTreeList, ChangeTreeNode } from "./ChangeTree.js";
14
+ import type { EncodeOperation } from "./EncodeOperation.js";
15
+ import { forEachLiveWithCtx as _forEachLiveWithCtx } from "./changeTree/liveIteration.js";
16
+ import { forEachChildWithCtx as _forEachChildWithCtx } from "./changeTree/treeAttachment.js";
17
+
18
+ /**
19
+ * Reusable context passed to the recorder's forEachWithCtx to iterate changes
20
+ * without allocating a closure per ChangeTree. All fields are (re)assigned
21
+ * inside the main encode loop before each `forEachWithCtx` call.
22
+ */
23
+ interface EncodeCtx {
24
+ self: Encoder;
25
+ buffer: Uint8Array;
26
+ it: Iterator;
27
+ changeTree: ChangeTree;
28
+ ref: any;
29
+ encoder: EncodeOperation;
30
+ filter: ((ref: any, index: number, view?: StateView) => boolean) | undefined;
31
+ metadata: any;
32
+ view: StateView | undefined;
33
+ isEncodeAll: boolean;
34
+ hasView: boolean;
35
+
36
+ /**
37
+ * Per-tree flags, reset before each `forEachWithCtx` call. The per-field
38
+ * filter decision (`emitFiltered` == `treeIsFiltered || metadata[i].tag`)
39
+ * matches `ChangeTree.change()`'s routing rule exactly.
40
+ */
41
+ treeIsFiltered: boolean;
42
+ isSchema: boolean;
43
+ emitFiltered: boolean;
44
+
45
+ /**
46
+ * Bitmask: bit i set iff field i has a @view tag. Lets the per-field
47
+ * filter check be a single bitwise op instead of a metadata[i]?.tag chase.
48
+ * Always 0 for collection trees.
49
+ */
50
+ filterBitmask: number;
51
+
52
+ /**
53
+ * Current walk's visit stamp. `_fullSyncWalk` compares it against each
54
+ * tree's `_fullSyncGen` on entry: match means "already visited by
55
+ * this walk, skip"; mismatch means "first visit, stamp and recurse".
56
+ * Rewritten per walk by `encodeFullSync` before kicking off the DFS.
57
+ */
58
+ gen: number;
59
+ /** Initial buffer offset at encodeFullSync entry — used by the walker to
60
+ * decide whether to emit SWITCH_TO_STRUCTURE for the root tree. */
61
+ initialOffset: number;
62
+ rootChangeTree: ChangeTree;
63
+
64
+ /**
65
+ * Lazy structure-switch state. The switch header is emitted right before
66
+ * the first field of a tree actually passes the filter, so trees that
67
+ * contribute zero bytes in a given pass don't leave orphaned headers.
68
+ */
69
+ structSwitchEmitted: boolean;
70
+ isRootTree: boolean;
71
+ shouldEmitSwitch: boolean;
72
+ }
73
+
74
+
75
+ /**
76
+ * Emit the lazy structure-switch header (SWITCH_TO_STRUCTURE + refId) for
77
+ * the current tree if it hasn't been emitted yet in this pass.
78
+ */
79
+ function ensureStructSwitch(ctx: EncodeCtx): void {
80
+ if (ctx.structSwitchEmitted) return;
81
+ if (ctx.shouldEmitSwitch) {
82
+ ctx.buffer[ctx.it.offset++] = SWITCH_TO_STRUCTURE & 255;
83
+ encode.number(ctx.buffer, ctx.ref[$refId], ctx.it);
84
+ }
85
+ ctx.structSwitchEmitted = true;
86
+ }
87
+
88
+ /**
89
+ * Module-level adapter for `forEachLiveWithCtx`. Full-sync emits every live
90
+ * field as ADD, so we re-enter `encodeChangeCb` with that fixed op — keeps
91
+ * the callback closure-free across the entire DFS walk.
92
+ */
93
+ function encodeFullSyncCb(ctx: EncodeCtx, fieldIndex: number): void {
94
+ encodeChangeCb(ctx, fieldIndex, OPERATION.ADD);
95
+ }
96
+
97
+ /**
98
+ * Structural DFS walker for `encodeFullSync`. Hoisted to module scope so
99
+ * the recursion allocates no per-tree closures — `_fullSyncWalkChildCb`
100
+ * captures nothing and is handed to `forEachChildWithCtx` once.
101
+ *
102
+ * The stamp check at the top (`tree._fullSyncGen === ctx.gen`) is how we
103
+ * skip shared refs that are reachable through more than one parent. On
104
+ * first visit the tree's stamp differs from the walk's current `ctx.gen`;
105
+ * we write `ctx.gen` onto the tree and recurse. Any later reach of the
106
+ * same tree during the SAME walk will find matching stamps and bail.
107
+ * Next walk bumps `ctx.gen`, so every tree starts out stale again.
108
+ */
109
+ function _fullSyncWalk(ctx: EncodeCtx, changeTree: ChangeTree): void {
110
+ if (changeTree._fullSyncGen === ctx.gen) return;
111
+ changeTree._fullSyncGen = ctx.gen;
112
+
113
+ // Visibility gate: when a view is active, a non-visible tree contributes
114
+ // nothing itself but we still recurse so descendants (possibly added to
115
+ // the view explicitly) are reachable.
116
+ let visibleHere = true;
117
+ if (ctx.hasView) {
118
+ const view = ctx.view!;
119
+ if (!view.isChangeTreeVisible(changeTree)) {
120
+ view.markInvisible(changeTree);
121
+ visibleHere = false;
122
+ } else {
123
+ view.unmarkInvisible(changeTree);
124
+ }
125
+ }
126
+
127
+ if (visibleHere) {
128
+ const desc = changeTree.encDescriptor;
129
+ ctx.changeTree = changeTree;
130
+ ctx.ref = changeTree.ref;
131
+ ctx.encoder = desc.encoder;
132
+ ctx.filter = desc.filter;
133
+ ctx.metadata = desc.metadata;
134
+ ctx.treeIsFiltered = changeTree.isFiltered;
135
+ ctx.isSchema = desc.isSchema;
136
+ ctx.filterBitmask = desc.filterBitmask;
137
+ ctx.structSwitchEmitted = false;
138
+ ctx.shouldEmitSwitch = (ctx.hasView || ctx.it.offset > ctx.initialOffset || changeTree !== ctx.rootChangeTree);
139
+
140
+ // Call the module function directly — the `forEachLiveWithCtx`
141
+ // method on ChangeTree is a pass-through that V8 doesn't inline
142
+ // under the polymorphism the encoder sees (Schema + every
143
+ // collection class share the method slot). Direct call saves the
144
+ // dispatched frame.
145
+ _forEachLiveWithCtx(changeTree, ctx, encodeFullSyncCb);
146
+ }
147
+
148
+ _forEachChildWithCtx(changeTree, ctx, _fullSyncWalkChildCb);
149
+ }
150
+
151
+ /**
152
+ * Child-iteration callback for `_fullSyncWalk`. Module-level + closure-free:
153
+ * just re-enters `_fullSyncWalk` on each child. Replaces a per-tree
154
+ * `(child, _) => walk(child)` closure that used to allocate 2.5M times in
155
+ * `encodeAll(5000 entities) x 500 iterations`.
156
+ */
157
+ function _fullSyncWalkChildCb(ctx: EncodeCtx, child: ChangeTree, _index: any): void {
158
+ _fullSyncWalk(ctx, child);
159
+ }
160
+
161
+ /**
162
+ * Pure (non-capturing) callback for recorder.forEachWithCtx. Module-level so
163
+ * V8 never needs to allocate a fresh function per tree. Decides per-field
164
+ * whether to emit based on the unified filter rule, then defers to the
165
+ * per-type encode function.
166
+ */
167
+ function encodeChangeCb(ctx: EncodeCtx, fieldIndex: number, op: OPERATION): void {
168
+ if (fieldIndex < 0) {
169
+ // Pure op (CLEAR/REVERSE): encoded as a single byte. Always emitted
170
+ // for the pass that matches the tree's filter classification —
171
+ // collections route pure ops to their single dirty bucket.
172
+ if (ctx.treeIsFiltered !== ctx.emitFiltered) return;
173
+ ensureStructSwitch(ctx);
174
+ ctx.buffer[ctx.it.offset++] = Math.abs(fieldIndex) & 255;
175
+ return;
176
+ }
177
+
178
+ // Per-field filter decision (same rule as ChangeTree.change()):
179
+ // a field is filtered iff the tree inherits isFiltered OR the field
180
+ // itself carries a @view tag. Schema trees check via the precomputed
181
+ // bitmask; collection trees inherit tree-level (bitmask is 0).
182
+ const fieldFiltered = ctx.isSchema
183
+ ? (ctx.treeIsFiltered || (ctx.filterBitmask & (1 << fieldIndex)) !== 0)
184
+ : ctx.treeIsFiltered;
185
+ if (fieldFiltered !== ctx.emitFiltered) return;
186
+
187
+ const operation = ctx.isEncodeAll ? OPERATION.ADD : op;
188
+ if (operation === undefined) return;
189
+ if (ctx.filter !== undefined && !ctx.filter(ctx.ref, fieldIndex, ctx.view)) return;
190
+
191
+ ensureStructSwitch(ctx);
192
+ ctx.encoder(ctx.self, ctx.buffer, ctx.changeTree, fieldIndex, operation, ctx.it, ctx.isEncodeAll, ctx.hasView, ctx.metadata);
193
+ }
14
194
 
15
195
  function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
16
196
  const result = new Uint8Array(a.length + b.length);
@@ -28,7 +208,7 @@ export class Encoder<T extends Schema = any> {
28
208
 
29
209
  root: Root;
30
210
 
31
- constructor(state: T) {
211
+ constructor(state: T, root?: Root) {
32
212
  //
33
213
  // Use .cache() here to avoid re-creating a new context for every new room instance.
34
214
  //
@@ -36,14 +216,9 @@ export class Encoder<T extends Schema = any> {
36
216
  // schemas - which would lead to memory leaks
37
217
  //
38
218
  this.context = TypeContext.cache(state.constructor as typeof Schema);
39
- this.root = new Root(this.context);
219
+ this.root = root ?? new Root(this.context);
40
220
 
41
221
  this.setState(state);
42
-
43
- // console.log(">>>>>>>>>>>>>>>> Encoder types");
44
- // this.context.schemas.forEach((id, schema) => {
45
- // console.log("type:", id, schema.name, Object.keys(schema[Symbol.metadata]));
46
- // });
47
222
  }
48
223
 
49
224
  protected setState(state: T) {
@@ -51,118 +226,190 @@ export class Encoder<T extends Schema = any> {
51
226
  this.state[$changes].setRoot(this.root);
52
227
  }
53
228
 
229
+ private _encodeCtx: EncodeCtx = {
230
+ self: undefined!, buffer: undefined!, it: undefined!, changeTree: undefined!,
231
+ ref: undefined, encoder: undefined!, filter: undefined, metadata: undefined,
232
+ view: undefined, isEncodeAll: false, hasView: false,
233
+ treeIsFiltered: false, isSchema: false, emitFiltered: false,
234
+ filterBitmask: 0,
235
+ structSwitchEmitted: false, isRootTree: false, shouldEmitSwitch: false,
236
+ gen: 0, initialOffset: 0, rootChangeTree: undefined!,
237
+ };
238
+
239
+ /**
240
+ * Monotonic counter bumped at the start of every `encodeFullSync`
241
+ * call. The new value is copied to `ctx.gen` and stamped into every
242
+ * tree the walk touches (`tree._fullSyncGen = ctx.gen`); subsequent
243
+ * revisits of the same tree detect the equality and return early.
244
+ */
245
+ private _fullSyncGen: number = 0;
246
+
54
247
  encode(
55
248
  it: Iterator = { offset: 0 },
56
249
  view?: StateView,
57
250
  buffer: Uint8Array = this.sharedBuffer,
58
- changeSetName: ChangeSetName = "changes",
59
- isEncodeAll = changeSetName === "allChanges",
60
- initialOffset = it.offset // cache current offset in case we need to resize the buffer
251
+ initialOffset = it.offset
252
+ ): Uint8Array {
253
+ return this._encodeChannel(it, view, buffer, initialOffset, /* unreliable */ false);
254
+ }
255
+
256
+ /**
257
+ * Per-tick encode of the UNRELIABLE channel. Walks `root.unreliableChanges`
258
+ * and emits each tree's `unreliableRecorder`. Safe to call at a different
259
+ * cadence than `encode()` (e.g. 60Hz vs 20Hz) — the two channels are
260
+ * fully independent.
261
+ */
262
+ encodeUnreliable(
263
+ it: Iterator = { offset: 0 },
264
+ view?: StateView,
265
+ buffer: Uint8Array = this.sharedBuffer,
266
+ initialOffset = it.offset
267
+ ): Uint8Array {
268
+ return this._encodeChannel(it, view, buffer, initialOffset, /* unreliable */ true);
269
+ }
270
+
271
+ private _encodeChannel(
272
+ it: Iterator,
273
+ view: StateView | undefined,
274
+ buffer: Uint8Array,
275
+ initialOffset: number,
276
+ unreliable: boolean,
61
277
  ): Uint8Array {
62
278
  const hasView = (view !== undefined);
63
279
  const rootChangeTree = this.state[$changes];
64
280
 
65
- let current: ChangeTreeList | ChangeTreeNode = this.root[changeSetName];
281
+ const ctx = this._encodeCtx;
282
+ ctx.self = this;
283
+ ctx.buffer = buffer;
284
+ ctx.it = it;
285
+ ctx.view = view;
286
+ ctx.isEncodeAll = false;
287
+ ctx.hasView = hasView;
288
+ // Shared pass (no view): emit unfiltered fields. View pass: emit
289
+ // filtered fields only. Fields on the other side of the split are
290
+ // skipped inside encodeChangeCb.
291
+ ctx.emitFiltered = hasView;
292
+
293
+ const queue: ChangeTreeList = unreliable ? this.root.unreliableChanges : this.root.changes;
294
+ let current: ChangeTreeList | ChangeTreeNode = queue;
66
295
 
67
296
  while (current = current.next) {
68
297
  const changeTree = (current as ChangeTreeNode).changeTree;
69
298
 
70
299
  if (hasView) {
71
300
  if (!view.isChangeTreeVisible(changeTree)) {
72
- // console.log("MARK AS INVISIBLE:", { ref: changeTree.ref.constructor.name, refId: changeTree.ref[$refId], raw: changeTree.ref.toJSON() });
73
- view.invisible.add(changeTree);
74
- continue; // skip this change tree
301
+ view.markInvisible(changeTree);
302
+ continue;
75
303
  }
76
- view.invisible.delete(changeTree); // remove from invisible list
304
+ view.unmarkInvisible(changeTree);
77
305
  }
78
306
 
79
- const changeSet = changeTree[changeSetName];
80
- const ref = changeTree.ref;
81
-
82
- // TODO: avoid iterating over change tree if no changes were made
83
- const numChanges = changeSet.operations.length;
84
- if (numChanges === 0) { continue; }
307
+ const recorder = unreliable ? changeTree.unreliableRecorder : changeTree;
308
+ if (!recorder || !recorder.has()) { continue; }
309
+
310
+ const desc = changeTree.encDescriptor;
311
+ ctx.changeTree = changeTree;
312
+ ctx.ref = changeTree.ref;
313
+ ctx.encoder = desc.encoder;
314
+ ctx.filter = desc.filter;
315
+ ctx.metadata = desc.metadata;
316
+ ctx.treeIsFiltered = changeTree.isFiltered;
317
+ ctx.isSchema = desc.isSchema;
318
+ ctx.filterBitmask = desc.filterBitmask;
319
+ ctx.structSwitchEmitted = false;
320
+ ctx.isRootTree = (changeTree === rootChangeTree);
321
+ // Root's struct switch is skipped at the very start of the shared
322
+ // pass (matches the legacy wire protocol). In view pass or after
323
+ // the first emission, always emit the switch.
324
+ ctx.shouldEmitSwitch = (hasView || it.offset > initialOffset || !ctx.isRootTree);
325
+
326
+ recorder.forEachWithCtx(ctx, encodeChangeCb);
327
+ }
85
328
 
86
- const ctor = ref.constructor;
87
- const encoder = ctor[$encoder];
88
- const filter = ctor[$filter];
89
- const metadata = ctor[Symbol.metadata];
329
+ // Broadcast-mode stream emission runs after the main loop (state /
330
+ // parent refs are already on the wire, so stream ADD ops can
331
+ // reference element refIds safely). Reliable shared pass only;
332
+ // skipped when any StateView is registered (priority pass in
333
+ // `encodeView` owns emission in that mode).
334
+ if (!unreliable && !hasView && this.root.activeViews.size === 0 && this.root.streamTrees.size > 0) {
335
+ this._emitStreamBroadcast(buffer, it);
336
+ }
90
337
 
91
- // skip root `refId` if it's the first change tree
92
- // (unless it "hasView", which will need to revisit the root)
93
- if (hasView || it.offset > initialOffset || changeTree !== rootChangeTree) {
94
- buffer[it.offset++] = SWITCH_TO_STRUCTURE & 255;
95
- encode.number(buffer, ref[$refId], it);
96
- }
338
+ if (it.offset > buffer.byteLength) {
339
+ buffer = this._resizeBuffer(buffer, it.offset);
340
+ return this._encodeChannel({ offset: initialOffset }, view, buffer, initialOffset, unreliable);
341
+ }
97
342
 
98
- for (let j = 0; j < numChanges; j++) {
99
- const fieldIndex = changeSet.operations[j];
343
+ return buffer.subarray(0, it.offset);
344
+ }
100
345
 
101
- if (fieldIndex < 0) {
102
- // "pure" operation without fieldIndex (e.g. CLEAR, REVERSE, etc.)
103
- // encode and continue early - no need to reach $filter check
104
- buffer[it.offset++] = Math.abs(fieldIndex) & 255;
105
- continue;
106
- }
346
+ /**
347
+ * Structural DFS walker for full-sync (encodeAll / encodeAllView).
348
+ * Visits each ChangeTree in DFS preorder starting from the state root,
349
+ * emitting ADD operations for every currently-populated index via
350
+ * {@link ChangeTree.forEachLive}.
351
+ */
352
+ private encodeFullSync(
353
+ it: Iterator,
354
+ buffer: Uint8Array,
355
+ emitFiltered: boolean,
356
+ view?: StateView,
357
+ initialOffset: number = it.offset
358
+ ): Uint8Array {
359
+ const hasView = (view !== undefined);
360
+ const rootChangeTree = this.state[$changes];
107
361
 
108
- const operation = (isEncodeAll)
109
- ? OPERATION.ADD
110
- : changeTree.indexedOperations[fieldIndex];
111
-
112
- //
113
- // first pass (encodeAll), identify "filtered" operations without encoding them
114
- // they will be encoded per client, based on their view.
115
- //
116
- // TODO: how can we optimize filtering out "encode all" operations?
117
- // TODO: avoid checking if no view tags were defined
118
- //
119
- if (fieldIndex === undefined || operation === undefined || (filter && !filter(ref, fieldIndex, view))) {
120
- // console.log("ADD AS INVISIBLE:", fieldIndex, changeTree.ref.constructor.name)
121
- // view?.invisible.add(changeTree);
122
- continue;
123
- }
362
+ const ctx = this._encodeCtx;
363
+ ctx.self = this;
364
+ ctx.buffer = buffer;
365
+ ctx.it = it;
366
+ ctx.view = view;
367
+ ctx.isEncodeAll = true;
368
+ ctx.hasView = hasView;
369
+ ctx.emitFiltered = emitFiltered;
370
+
371
+ // Bump the generation counter and carry the new value on `ctx` so
372
+ // the recursive walker can stamp every tree it visits. Any tree
373
+ // still holding the previous walk's stamp is treated as unvisited
374
+ // on first reach, and stamped; a second reach (shared ref via
375
+ // multiple parents) sees the match and bails.
376
+ ctx.gen = ++this._fullSyncGen;
377
+ ctx.initialOffset = initialOffset;
378
+ ctx.rootChangeTree = rootChangeTree;
379
+ _fullSyncWalk(ctx, rootChangeTree);
124
380
 
125
- encoder(this, buffer, changeTree, fieldIndex, operation, it, isEncodeAll, hasView, metadata);
126
- }
381
+ if (it.offset > buffer.byteLength) {
382
+ buffer = this._resizeBuffer(buffer, it.offset);
383
+ return this.encodeFullSync({ offset: initialOffset }, buffer, emitFiltered, view);
127
384
  }
128
385
 
129
- if (it.offset > buffer.byteLength) {
130
- // we can assume that n + 1 BUFFER_SIZE will suffice given that we are likely done with encoding at this point
131
- // multiples of BUFFER_SIZE are faster to allocate than arbitrary sizes
132
- const newSize = Math.ceil(it.offset / Encoder.BUFFER_SIZE) * Encoder.BUFFER_SIZE;
386
+ return buffer.subarray(0, it.offset);
387
+ }
388
+
389
+ private _resizeBuffer(buffer: Uint8Array, usedOffset: number): Uint8Array {
390
+ const newSize = Math.ceil(usedOffset / Encoder.BUFFER_SIZE) * Encoder.BUFFER_SIZE;
133
391
 
134
- console.warn(`@colyseus/schema buffer overflow. Encoded state is higher than default BUFFER_SIZE. Use the following to increase default BUFFER_SIZE:
392
+ console.warn(`@colyseus/schema buffer overflow. Encoded state is higher than default BUFFER_SIZE. Use the following to increase default BUFFER_SIZE:
135
393
 
136
394
  import { Encoder } from "@colyseus/schema";
137
395
  Encoder.BUFFER_SIZE = ${Math.round(newSize / 1024)} * 1024; // ${Math.round(newSize / 1024)} KB
138
396
  `);
139
397
 
140
- //
141
- // resize buffer and re-encode (TODO: can we avoid re-encoding here?)
142
- // -> No we probably can't unless we catch the need for resize before encoding which is likely more computationally expensive than resizing on demand
143
- //
144
- const newBuffer = new Uint8Array(newSize);
145
- newBuffer.set(buffer); // copy previous encoding steps beyond the initialOffset
146
- buffer = newBuffer;
147
-
148
- // assign resized buffer to local sharedBuffer
149
- if (buffer === this.sharedBuffer) {
150
- this.sharedBuffer = buffer;
151
- }
152
-
153
- return this.encode({ offset: initialOffset }, view, buffer, changeSetName, isEncodeAll);
398
+ const newBuffer = new Uint8Array(newSize);
399
+ newBuffer.set(buffer);
154
400
 
155
- } else {
156
-
157
- return buffer.subarray(0, it.offset);
401
+ if (buffer === this.sharedBuffer) {
402
+ this.sharedBuffer = newBuffer;
158
403
  }
404
+
405
+ return newBuffer;
159
406
  }
160
407
 
161
408
  encodeAll(
162
409
  it: Iterator = { offset: 0 },
163
410
  buffer: Uint8Array = this.sharedBuffer
164
411
  ) {
165
- return this.encode(it, undefined, buffer, "allChanges", true);
412
+ return this.encodeFullSync(it, buffer, /* emitFiltered */ false);
166
413
  }
167
414
 
168
415
  encodeAllView(
@@ -173,8 +420,7 @@ export class Encoder<T extends Schema = any> {
173
420
  ) {
174
421
  const viewOffset = it.offset;
175
422
 
176
- // try to encode "filtered" changes
177
- this.encode(it, view, bytes, "allFilteredChanges", true, viewOffset);
423
+ this.encodeFullSync(it, bytes, /* emitFiltered */ true, view, viewOffset);
178
424
 
179
425
  return concatBytes(
180
426
  bytes.subarray(0, sharedOffset),
@@ -190,41 +436,47 @@ export class Encoder<T extends Schema = any> {
190
436
  ) {
191
437
  const viewOffset = it.offset;
192
438
 
193
- // encode visibility changes (add/remove for this view)
439
+ // Stream priority pass: drain up to `maxPerTick` per-view entries
440
+ // from every registered stream before draining view.changes. Each
441
+ // selected element is passed to `view.add()` which populates
442
+ // view.changes with the stream-link ADD + element-field ADDs.
443
+ this._emitStreamPriority(view);
444
+
445
+ // encode visibility-triggered changes collected by view.add()
194
446
  for (const [refId, changes] of view.changes) {
195
447
  const changeTree: ChangeTree = this.root.changeTrees[refId];
196
448
 
197
449
  if (changeTree === undefined) {
198
450
  // detached instance, remove from view and skip.
199
- // console.log("detached instance, remove from view and skip.", refId);
200
451
  view.changes.delete(refId);
201
452
  continue;
202
453
  }
203
454
 
204
- const keys = Object.keys(changes);
205
- if (keys.length === 0) {
206
- // FIXME: avoid having empty changes if no changes were made
207
- // console.log("changes.size === 0, skip", refId, changeTree.ref.constructor.name);
455
+ if (changes.size === 0) {
208
456
  continue;
209
457
  }
210
458
 
459
+ const desc = changeTree.encDescriptor;
460
+ const encoder = desc.encoder;
461
+ const metadata = desc.metadata;
462
+ // `ref` → user-facing identity (Proxy on ArraySchema), used for
463
+ // `[$refId]`. `refTarget` → raw instance, used for the hot
464
+ // `[$getByIndex]` lookup that runs once per change.
211
465
  const ref = changeTree.ref;
212
-
213
- const ctor = ref.constructor;
214
- const encoder = ctor[$encoder];
215
- const metadata = ctor[Symbol.metadata];
466
+ const refTarget = changeTree.refTarget;
216
467
 
217
468
  bytes[it.offset++] = SWITCH_TO_STRUCTURE & 255;
218
469
  encode.number(bytes, ref[$refId], it);
219
470
 
220
- for (let i = 0, numChanges = keys.length; i < numChanges; i++) {
221
- const index = Number(keys[i]);
222
- // workaround when using view.add() on item that has been deleted from state (see test "adding to view item that has been removed from state")
223
- const value = changeTree.ref[$getByIndex](index);
224
- const operation = (value !== undefined && changes[index]) || OPERATION.DELETE;
471
+ // Iterate entries directly the inner Map gives us the (index, op)
472
+ // pair without an intermediate keys array or Number() parse.
473
+ for (const [index, op] of changes) {
474
+ // workaround when using view.add() on item that has been deleted from state
475
+ // (see test "adding to view item that has been removed from state")
476
+ const value = refTarget[$getByIndex](index);
477
+ const operation = (value !== undefined && op) || OPERATION.DELETE;
225
478
 
226
- // isEncodeAll = false
227
- // hasView = true
479
+ // isEncodeAll = false, hasView = true
228
480
  encoder(this, bytes, changeTree, index, operation, it, false, true, metadata);
229
481
  }
230
482
  }
@@ -233,11 +485,11 @@ export class Encoder<T extends Schema = any> {
233
485
  // TODO: only clear view changes after all views are encoded
234
486
  // (to allow re-using StateView's for multiple clients)
235
487
  //
236
- // clear "view" changes after encoding
237
488
  view.changes.clear();
238
489
 
239
- // try to encode "filtered" changes
240
- this.encode(it, view, bytes, "filteredChanges", false, viewOffset);
490
+ // per-tick view-scoped pass: walks the same `changes` queue as the
491
+ // shared pass, but `encodeChangeCb` emits only filtered fields.
492
+ this.encode(it, view, bytes, viewOffset);
241
493
 
242
494
  return concatBytes(
243
495
  bytes.subarray(0, sharedOffset),
@@ -245,22 +497,280 @@ export class Encoder<T extends Schema = any> {
245
497
  );
246
498
  }
247
499
 
500
+ /**
501
+ * Per-view unreliable encode. Walks `root.unreliableChanges` and emits
502
+ * only filtered fields visible to this view. Unlike `encodeView`, this
503
+ * doesn't emit `view.changes` entries — those are used only for
504
+ * reliable view bootstrap (membership ADDs) and are consumed by
505
+ * `encodeView` on the reliable channel.
506
+ */
507
+ encodeUnreliableView(
508
+ view: StateView,
509
+ sharedOffset: number,
510
+ it: Iterator,
511
+ bytes: Uint8Array = this.sharedBuffer
512
+ ) {
513
+ const viewOffset = it.offset;
514
+
515
+ this.encodeUnreliable(it, view, bytes, viewOffset);
516
+
517
+ return concatBytes(
518
+ bytes.subarray(0, sharedOffset),
519
+ bytes.subarray(viewOffset, it.offset)
520
+ );
521
+ }
522
+
523
+ /**
524
+ * Broadcast-mode counterpart to `_emitStreamPriority`. Runs when NO
525
+ * StateViews are registered — streams fall back to broadcast mode
526
+ * where up to `maxPerTick` pending ADDs per stream emit to ALL clients
527
+ * each shared tick. DELETEs always flush (no cap).
528
+ *
529
+ * Emits directly to the shared-encode buffer: stream & element trees
530
+ * are `isFiltered=true` so the main loop would otherwise skip them.
531
+ * Runs AFTER the main loop so state / parent refs are already encoded
532
+ * — stream ADD ops reference element refIds, which must be decodable.
533
+ */
534
+ private _emitStreamBroadcast(buffer: Uint8Array, it: Iterator): void {
535
+ const streams = this.root.streamTrees;
536
+ for (const stream of streams) {
537
+ const s: any = stream;
538
+ const tree: ChangeTree = s[$changes];
539
+ // Stream is registered with Root but not yet assigned a refId
540
+ // (e.g. created but never attached to state). Skip.
541
+ const streamRefId = s[$refId];
542
+ if (streamRefId === undefined) continue;
543
+
544
+ // `inheritedFlags.ensureStreamState` allocates `_stream` the
545
+ // moment the tree picks up `isStreamCollection` — Root only
546
+ // tracks trees that reached that point, so `_stream` is
547
+ // guaranteed defined here.
548
+ const st = s._stream!;
549
+ const deletes: Set<number> = st.broadcastDeletes;
550
+ const pending: Set<number> = st.broadcastPending;
551
+ const sent: Set<number> = st.sentBroadcast;
552
+ const hasDeletes = deletes.size > 0;
553
+ const hasAdds = pending.size > 0;
554
+
555
+ const desc = tree.encDescriptor;
556
+ const streamEncoder = desc.encoder;
557
+ const streamMetadata = desc.metadata;
558
+
559
+ // Emit stream ADD/DELETE ops for this tick, if any.
560
+ if (hasDeletes || hasAdds) {
561
+ buffer[it.offset++] = SWITCH_TO_STRUCTURE & 255;
562
+ encode.number(buffer, streamRefId, it);
563
+
564
+ // DELETEs first (flush all).
565
+ if (hasDeletes) {
566
+ for (const pos of deletes) {
567
+ streamEncoder(this, buffer, tree, pos, OPERATION.DELETE, it, false, false, streamMetadata);
568
+ }
569
+ deletes.clear();
570
+ }
571
+
572
+ // ADDs up to maxPerTick.
573
+ const max: number = st.maxPerTick;
574
+ const emittedElements: any[] = [];
575
+ let count = 0;
576
+ const toDelete: number[] = [];
577
+ for (const pos of pending) {
578
+ if (count >= max) break;
579
+ // `$getByIndex` works for any streamable collection:
580
+ // StreamSchema (Map<number, V>) and MapSchema (string-keyed
581
+ // via journal index) both route through the same symbol.
582
+ const element = s[$getByIndex](pos);
583
+ if (element === undefined) {
584
+ toDelete.push(pos);
585
+ continue;
586
+ }
587
+ streamEncoder(this, buffer, tree, pos, OPERATION.ADD, it, false, false, streamMetadata);
588
+ sent.add(pos);
589
+ emittedElements.push(element);
590
+ toDelete.push(pos);
591
+ count++;
592
+ }
593
+ for (const pos of toDelete) pending.delete(pos);
594
+
595
+ // Emit each element's full state — forEachLive walks populated
596
+ // fields structurally, mirroring encodeAllView's bootstrap.
597
+ // Covers both static elements (dirty state was reset by
598
+ // inheritedFlags' becameStatic branch) and non-static (still
599
+ // has dirty state but the main loop skipped them because
600
+ // they're filtered).
601
+ for (const element of emittedElements) {
602
+ const elTree: ChangeTree | undefined = element[$changes];
603
+ if (elTree === undefined) continue;
604
+ const elRefId = element[$refId];
605
+ if (elRefId === undefined) continue;
606
+
607
+ buffer[it.offset++] = SWITCH_TO_STRUCTURE & 255;
608
+ encode.number(buffer, elRefId, it);
609
+
610
+ const elDesc = elTree.encDescriptor;
611
+ const elEncoder = elDesc.encoder;
612
+ const elMetadata = elDesc.metadata;
613
+ elTree.forEachLive((idx: number) => {
614
+ // @unreliable fields ship on the unreliable channel only.
615
+ if (Metadata.hasUnreliableAtIndex(elMetadata, idx)) return;
616
+ elEncoder(this, buffer, elTree, idx, OPERATION.ADD, it, false, false, elMetadata);
617
+ });
618
+ }
619
+ }
620
+
621
+ // Emit mutation updates for already-sent elements. Element
622
+ // trees are `isFiltered=true` (inherited from stream field),
623
+ // so the main loop skips them. We pick up their dirty state
624
+ // here so broadcast mode sees post-send field mutations.
625
+ for (const pos of sent) {
626
+ const element = s[$getByIndex](pos);
627
+ if (element === undefined) continue;
628
+ const elTree: ChangeTree | undefined = element[$changes];
629
+ if (elTree === undefined || !elTree.has()) continue;
630
+
631
+ const elRefId = element[$refId];
632
+ if (elRefId === undefined) continue;
633
+
634
+ buffer[it.offset++] = SWITCH_TO_STRUCTURE & 255;
635
+ encode.number(buffer, elRefId, it);
636
+
637
+ const elDesc = elTree.encDescriptor;
638
+ const elEncoder = elDesc.encoder;
639
+ const elMetadata = elDesc.metadata;
640
+ elTree.forEach((idx: number, op: OPERATION) => {
641
+ if (idx < 0) return; // pure ops (collection only)
642
+ if (Metadata.hasUnreliableAtIndex(elMetadata, idx)) return;
643
+ elEncoder(this, buffer, elTree, idx, op, it, false, false, elMetadata);
644
+ });
645
+ }
646
+ }
647
+ }
648
+
649
+ /**
650
+ * Walk every registered stream, pick up to `maxPerTick` positions from
651
+ * this view's pending backlog (priority-sorted when the view supplies a
652
+ * `streamPriority` callback), and hand each element to `view.add()`.
653
+ * `view.add()` seeds `view.changes` so the subsequent drain emits both
654
+ * the stream-link (position → refId) and the element's field data.
655
+ *
656
+ * Designed to run at the very top of `encodeView`, BEFORE the
657
+ * view.changes drain loop.
658
+ */
659
+ private _emitStreamPriority(view: StateView): void {
660
+ const streams = this.root.streamTrees;
661
+ if (streams.size === 0) return;
662
+
663
+ const viewId = view.id;
664
+
665
+ for (const stream of streams) {
666
+ const s: any = stream;
667
+ // Guaranteed non-undefined: `inheritedFlags.ensureStreamState`
668
+ // runs before Root.registerStream.
669
+ const st = s._stream!;
670
+ const pending: Set<number> | undefined = st.pendingByView.get(viewId);
671
+ if (pending === undefined || pending.size === 0) continue;
672
+
673
+ // Per-stream priority callback: declared at schema time (via
674
+ // `t.stream(X).priority(fn)` or the decorator form) and seeded
675
+ // into `_stream.priority` when the stream was attached. Users
676
+ // can also override per-instance by assigning to the setter.
677
+ const priority = st.priority;
678
+
679
+ // Materialize pending into an array so we can sort + slice.
680
+ // Small sets (typical: tens to low hundreds) — allocation is
681
+ // negligible compared to the priority sort and element walk.
682
+ const positions: number[] = [];
683
+ for (const p of pending) positions.push(p);
684
+
685
+ if (priority !== undefined) {
686
+ // Use the symbol-keyed accessor so Map/Set/Stream all route
687
+ // through the same lookup regardless of $items layout.
688
+ positions.sort(
689
+ (a: number, b: number) => priority(view, s[$getByIndex](b)) - priority(view, s[$getByIndex](a)),
690
+ );
691
+ }
692
+
693
+ const max = st.maxPerTick;
694
+ const count = Math.min(positions.length, max);
695
+
696
+ let sent: Set<number> | undefined = st.sentByView.get(viewId);
697
+ if (sent === undefined) {
698
+ sent = new Set();
699
+ st.sentByView.set(viewId, sent);
700
+ }
701
+
702
+ for (let i = 0; i < count; i++) {
703
+ const pos = positions[i];
704
+ const element = s[$getByIndex](pos);
705
+ if (element === undefined) {
706
+ // Element was removed after being queued but before emit.
707
+ pending.delete(pos);
708
+ continue;
709
+ }
710
+ // `_addImmediate` force-ships the element through view.changes
711
+ // (markVisible + addParentOf + forEachChild recursion) WITHOUT
712
+ // routing stream elements back into pending — we're already
713
+ // draining pending here, so the normal `add()` path would
714
+ // infinite-loop. addParentOf seeds
715
+ // `view.changes[stream.refId][pos] = ADD` (stream-link emit).
716
+ view._addImmediate(element);
717
+ // Force-seed element fields even when view.add skipped
718
+ // forEachLive (isNew && !isChildAdded). Matches the
719
+ // bootstrap emission encodeAllView does for filtered
720
+ // refs. `@unreliable` fields are excluded — they ship
721
+ // on the unreliable channel and force-seeding here
722
+ // would leak onto the reliable view pass.
723
+ const elTree = element[$changes];
724
+ if (elTree !== undefined) {
725
+ const elRefId = element[$refId];
726
+ let elChanges = view.changes.get(elRefId);
727
+ if (elChanges === undefined) {
728
+ elChanges = new Map();
729
+ view.changes.set(elRefId, elChanges);
730
+ }
731
+ const elMetadata = elTree.metadata;
732
+ elTree.forEachLive((index: number) => {
733
+ if (Metadata.hasUnreliableAtIndex(elMetadata, index)) return;
734
+ elChanges!.set(index, OPERATION.ADD);
735
+ });
736
+ }
737
+ pending.delete(pos);
738
+ sent.add(pos);
739
+ }
740
+ }
741
+ }
742
+
248
743
  discardChanges() {
249
- // discard shared changes
250
- let current = this.root.changes.next;
744
+ const list = this.root.changes;
745
+ let current = list.next;
746
+ const root = this.root;
251
747
  while (current) {
252
- current.changeTree.endEncode('changes');
253
- current = current.next;
748
+ const next = current.next;
749
+ current.changeTree.endEncode(); // clears changesNode internally
750
+ root.releaseNode(current);
751
+ current = next;
254
752
  }
255
- this.root.changes = createChangeTreeList();
753
+ list.next = undefined;
754
+ list.tail = undefined;
755
+
756
+ // End-of-tick: refIds released during this tick have now had their
757
+ // DELETEs emitted through `encode()` / `encodeView()`, so they are
758
+ // safe to recycle on the next tick.
759
+ root.refIds.flushReleases();
760
+ }
256
761
 
257
- // discard filtered changes
258
- current = this.root.filteredChanges.next;
762
+ discardUnreliableChanges() {
763
+ const list = this.root.unreliableChanges;
764
+ let current = list.next;
765
+ const root = this.root;
259
766
  while (current) {
260
- current.changeTree.endEncode('filteredChanges');
261
- current = current.next;
767
+ const next = current.next;
768
+ current.changeTree.endEncodeUnreliable(); // clears unreliableChangesNode internally
769
+ root.releaseNode(current);
770
+ current = next;
262
771
  }
263
- this.root.filteredChanges = createChangeTreeList();
772
+ list.next = undefined;
773
+ list.tail = undefined;
264
774
  }
265
775
 
266
776
  tryEncodeTypeId(
@@ -284,9 +794,10 @@ export class Encoder<T extends Schema = any> {
284
794
  }
285
795
 
286
796
  get hasChanges() {
287
- return (
288
- this.root.changes.next !== undefined ||
289
- this.root.filteredChanges.next !== undefined
290
- );
797
+ return this.root.changes.next !== undefined;
798
+ }
799
+
800
+ get hasUnreliableChanges() {
801
+ return this.root.unreliableChanges.next !== undefined;
291
802
  }
292
803
  }