@colyseus/schema 4.0.21 → 4.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colyseus/schema",
3
- "version": "4.0.21",
3
+ "version": "4.0.22",
4
4
  "description": "Binary state serializer with delta encoding for games",
5
5
  "type": "module",
6
6
  "bin": {
@@ -190,8 +190,26 @@ export class Encoder<T extends Schema = any> {
190
190
  ) {
191
191
  const viewOffset = it.offset;
192
192
 
193
- // encode visibility changes (add/remove for this view)
194
- for (const [refId, changes] of view.changes) {
193
+ //
194
+ // Iterate `view.changes` in topological order so a refId is never
195
+ // SWITCH_TO_STRUCTURE'd before an earlier op has introduced it on
196
+ // the decoder. Map insertion order alone isn't sufficient: a
197
+ // sequence like view.remove(child) → view.add(child) on a child
198
+ // whose ancestor wasn't yet visible can put the child entry into
199
+ // the Map before its newly-visible ancestor.
200
+ //
201
+ // Hot-path optimization: `view.add` preserves topo order by
202
+ // construction (addParentOf walks deepest-ancestor-first before
203
+ // touching the obj's own entry). Only `view.remove` can leave the
204
+ // Map dirty. `StateView.changesOutOfOrder` tracks this so most
205
+ // encodes can iterate `view.changes` directly, paying nothing.
206
+ //
207
+ const orderedRefIds: Iterable<number> = view.changesOutOfOrder
208
+ ? this.topoOrderViewChanges(view)
209
+ : view.changes.keys();
210
+
211
+ for (const refId of orderedRefIds) {
212
+ const changes = view.changes.get(refId);
195
213
  const changeTree: ChangeTree = this.root.changeTrees[refId];
196
214
 
197
215
  if (changeTree === undefined) {
@@ -235,6 +253,7 @@ export class Encoder<T extends Schema = any> {
235
253
  //
236
254
  // clear "view" changes after encoding
237
255
  view.changes.clear();
256
+ view.changesOutOfOrder = false;
238
257
 
239
258
  // try to encode "filtered" changes
240
259
  this.encode(it, view, bytes, "filteredChanges", false, viewOffset);
@@ -245,6 +264,52 @@ export class Encoder<T extends Schema = any> {
245
264
  );
246
265
  }
247
266
 
267
+ /**
268
+ * Produce a topological ordering of `view.changes` keys so each refId
269
+ * is preceded by any ancestor that's also in the same view's changeset.
270
+ *
271
+ * The wire stream uses SWITCH_TO_STRUCTURE pointers; if a child is
272
+ * encoded before any earlier op has introduced its refId on the
273
+ * decoder, decode fails with "refId not found". An entry's refId can
274
+ * only be introduced by an ADD on one of its ancestors — so any
275
+ * ancestor that itself appears in this view's pending changes must
276
+ * be encoded first.
277
+ *
278
+ * Implementation: DFS post-order over the parent chain. The `visited`
279
+ * Set guards against duplicates; cycles are not expected in a
280
+ * well-formed parent chain but the visited check is a cheap safety
281
+ * net. Cost is O(n × d) for n entries with parent-chain depth d.
282
+ */
283
+ protected topoOrderViewChanges(view: StateView): number[] {
284
+ const result: number[] = [];
285
+ const visited = new Set<number>();
286
+
287
+ const visit = (refId: number) => {
288
+ if (visited.has(refId)) { return; }
289
+ visited.add(refId);
290
+
291
+ const changeTree = this.root.changeTrees[refId];
292
+ if (changeTree !== undefined) {
293
+ let chain = changeTree.parentChain;
294
+ while (chain) {
295
+ const parentRefId = chain.ref[$refId];
296
+ if (parentRefId !== undefined && view.changes.has(parentRefId)) {
297
+ visit(parentRefId);
298
+ }
299
+ chain = chain.next;
300
+ }
301
+ }
302
+
303
+ result.push(refId);
304
+ };
305
+
306
+ for (const refId of view.changes.keys()) {
307
+ visit(refId);
308
+ }
309
+
310
+ return result;
311
+ }
312
+
248
313
  discardChanges() {
249
314
  // discard shared changes
250
315
  let current = this.root.changes.next;
@@ -35,12 +35,47 @@ export class StateView {
35
35
  */
36
36
  changes = new Map<number, IndexedOperations>();
37
37
 
38
+ /**
39
+ * Set when an operation may have left `changes` out of topological
40
+ * order (a parent that needs to be encoded before its descendants is
41
+ * positioned after them in the Map). `Encoder.encodeView` consults
42
+ * this flag and only runs the topo-ordering pass when it's true,
43
+ * skipping the work in the common case where insertion order already
44
+ * coincides with topo order.
45
+ *
46
+ * Only `remove()` can break the invariant: it writes entries that
47
+ * bypass `addParentOf`'s deepest-ancestor-first ordering. Everything
48
+ * else (including multi-parent re-adds) preserves order by
49
+ * construction. Reset to false at the end of each encodeView pass
50
+ * (when `changes` is cleared).
51
+ */
52
+ changesOutOfOrder: boolean = false;
53
+
38
54
  constructor(public iterable: boolean = false) {
39
55
  if (iterable) {
40
56
  this.items = [];
41
57
  }
42
58
  }
43
59
 
60
+ /**
61
+ * Get the IndexedOperations entry for `refId`, creating one if missing.
62
+ *
63
+ * Map insertion order alone doesn't guarantee parent-before-child
64
+ * iteration in all cases (a `view.remove()` followed by `view.add()`
65
+ * can put a child entry into the Map before its newly-visible
66
+ * ancestor). The wire-order invariant (parent SWITCH_TO_STRUCTURE
67
+ * before any of its children's) is enforced at encode time by
68
+ * `Encoder.encodeView` via a topological pass over `view.changes`.
69
+ */
70
+ protected touchChanges(refId: number): IndexedOperations {
71
+ let entry = this.changes.get(refId);
72
+ if (entry === undefined) {
73
+ entry = {};
74
+ this.changes.set(refId, entry);
75
+ }
76
+ return entry;
77
+ }
78
+
44
79
  // TODO: allow to set multiple tags at once
45
80
  add(obj: Ref, tag: number = DEFAULT_VIEW_TAG, checkIncludeParent: boolean = true) {
46
81
  const changeTree: ChangeTree = obj?.[$changes];
@@ -82,12 +117,8 @@ export class StateView {
82
117
  this.addParentOf(changeTree, tag);
83
118
  }
84
119
 
85
- let changes = this.changes.get(obj[$refId]);
86
- if (changes === undefined) {
87
- changes = {};
88
- // FIXME / OPTIMIZE: do not add if no changes are needed
89
- this.changes.set(obj[$refId], changes);
90
- }
120
+ // FIXME / OPTIMIZE: do not add if no changes are needed
121
+ const changes = this.touchChanges(obj[$refId]);
91
122
 
92
123
  let isChildAdded = false;
93
124
 
@@ -182,11 +213,7 @@ export class StateView {
182
213
 
183
214
  // add parent's tag properties
184
215
  if (changeTree.getChange(parentIndex) !== OPERATION.DELETE) {
185
- let changes = this.changes.get(changeTree.ref[$refId]);
186
- if (changes === undefined) {
187
- changes = {};
188
- this.changes.set(changeTree.ref[$refId], changes);
189
- }
216
+ const changes = this.touchChanges(changeTree.ref[$refId]);
190
217
 
191
218
  if (!this.tags) {
192
219
  this.tags = new WeakMap<ChangeTree, Set<number>>();
@@ -214,6 +241,10 @@ export class StateView {
214
241
  return this;
215
242
  }
216
243
 
244
+ // remove() bypasses addParentOf's ordering guarantee — flag the
245
+ // changeset as potentially out of topological order.
246
+ this.changesOutOfOrder = true;
247
+
217
248
  this.visible.delete(changeTree);
218
249
 
219
250
  // remove from iterable list
@@ -229,23 +260,13 @@ export class StateView {
229
260
 
230
261
  const refId = ref[$refId];
231
262
 
232
- let changes = this.changes.get(refId);
233
- if (changes === undefined) {
234
- changes = {};
235
- this.changes.set(refId, changes);
236
- }
237
-
238
263
  if (tag === DEFAULT_VIEW_TAG) {
239
264
  // parent is collection (Map/Array)
240
265
  const parent = changeTree.parent;
241
266
  if (parent && !Metadata.isValidInstance(parent) && changeTree.isFiltered) {
242
- const parentRefId = parent[$refId];
243
- let changes = this.changes.get(parentRefId);
244
- if (changes === undefined) {
245
- changes = {};
246
- this.changes.set(parentRefId, changes);
267
+ const parentChanges = this.touchChanges(parent[$refId]);
247
268
 
248
- } else if (changes[changeTree.parentIndex] === OPERATION.ADD) {
269
+ if (parentChanges[changeTree.parentIndex] === OPERATION.ADD) {
249
270
  //
250
271
  // SAME PATCH ADD + REMOVE:
251
272
  // The 'changes' of deleted structure should be ignored.
@@ -254,13 +275,14 @@ export class StateView {
254
275
  }
255
276
 
256
277
  // DELETE / DELETE BY REF ID
257
- changes[changeTree.parentIndex] = OPERATION.DELETE;
278
+ parentChanges[changeTree.parentIndex] = OPERATION.DELETE;
258
279
 
259
280
  // Remove child schema from visible set
260
281
  this._recursiveDeleteVisibleChangeTree(changeTree);
261
282
 
262
283
  } else {
263
284
  // delete all "tagged" properties.
285
+ const changes = this.touchChanges(refId);
264
286
  metadata?.[$viewFieldIndexes]?.forEach((index) => {
265
287
  changes[index] = OPERATION.DELETE;
266
288
 
@@ -276,6 +298,7 @@ export class StateView {
276
298
 
277
299
  } else {
278
300
  // delete only tagged properties
301
+ const changes = this.touchChanges(refId);
279
302
  metadata?.[$fieldIndexesByViewTag][tag].forEach((index) => {
280
303
  changes[index] = OPERATION.DELETE;
281
304