UNPKG

@colyseus/schema

Version:

Binary state serializer with delta encoding for games

387 lines (324 loc) 14.3 kB
import { ChangeTree, IndexedOperations, Ref } from "./ChangeTree.js"; import { $changes, $fieldIndexesByViewTag, $refId, $viewFieldIndexes } from "../types/symbols.js"; import { DEFAULT_VIEW_TAG } from "../annotations.js"; import { OPERATION } from "../encoding/spec.js"; import { Metadata } from "../Metadata.js"; import { spliceOne } from "../types/utils.js"; import type { Schema } from "../Schema.js"; export function createView(iterable: boolean = false) { return new StateView(iterable); } export class StateView { /** * Iterable list of items that are visible to this view * (Available only if constructed with `iterable: true`) */ items: Ref[]; /** * List of ChangeTree's that are visible to this view */ visible: WeakSet<ChangeTree> = new WeakSet<ChangeTree>(); /** * List of ChangeTree's that are invisible to this view */ invisible: WeakSet<ChangeTree> = new WeakSet<ChangeTree>(); tags?: WeakMap<ChangeTree, Set<number>>; // TODO: use bit manipulation instead of Set<number> () /** * Manual "ADD" operations for changes per ChangeTree, specific to this view. * (This is used to force encoding a property, even if it was not changed) */ changes = new Map<number, IndexedOperations>(); /** * Set when an operation may have left `changes` out of topological * order (a parent that needs to be encoded before its descendants is * positioned after them in the Map). `Encoder.encodeView` consults * this flag and only runs the topo-ordering pass when it's true, * skipping the work in the common case where insertion order already * coincides with topo order. * * Only `remove()` can break the invariant: it writes entries that * bypass `addParentOf`'s deepest-ancestor-first ordering. Everything * else (including multi-parent re-adds) preserves order by * construction. Reset to false at the end of each encodeView pass * (when `changes` is cleared). */ changesOutOfOrder: boolean = false; constructor(public iterable: boolean = false) { if (iterable) { this.items = []; } } /** * Get the IndexedOperations entry for `refId`, creating one if missing. * * Map insertion order alone doesn't guarantee parent-before-child * iteration in all cases (a `view.remove()` followed by `view.add()` * can put a child entry into the Map before its newly-visible * ancestor). The wire-order invariant (parent SWITCH_TO_STRUCTURE * before any of its children's) is enforced at encode time by * `Encoder.encodeView` via a topological pass over `view.changes`. */ protected touchChanges(refId: number): IndexedOperations { let entry = this.changes.get(refId); if (entry === undefined) { entry = {}; this.changes.set(refId, entry); } return entry; } // TODO: allow to set multiple tags at once add(obj: Ref, tag: number = DEFAULT_VIEW_TAG, checkIncludeParent: boolean = true) { const changeTree: ChangeTree = obj?.[$changes]; const parentChangeTree = changeTree.parent; if (!changeTree) { console.warn("StateView#add(), invalid object:", obj); return false; } else if ( !parentChangeTree && obj[$refId] !== 0 // allow root object ) { /** * TODO: can we avoid this? * * When the "parent" structure has the @view() tag, it is currently * not possible to identify it has to be added to the view as well * (this.addParentOf() is not called). */ throw new Error( `Cannot add a detached instance to the StateView. Make sure to assign the "${changeTree.ref.constructor.name}" instance to the state before calling view.add()` ); } // FIXME: ArraySchema/MapSchema do not have metadata const metadata: Metadata = (obj.constructor as typeof Schema)[Symbol.metadata]; this.visible.add(changeTree); // add to iterable list (only the explicitly added items) if (this.iterable && checkIncludeParent) { this.items.push(obj); } // add parent ChangeTree's // - if it was invisible to this view // - if it were previously filtered out if (checkIncludeParent && parentChangeTree) { this.addParentOf(changeTree, tag); } // FIXME / OPTIMIZE: do not add if no changes are needed const changes = this.touchChanges(obj[$refId]); let isChildAdded = false; // // Add children of this ChangeTree first. // If successful, we must link the current ChangeTree to the child. // changeTree.forEachChild((change, index) => { // Do not ADD children that don't have the same tag if ( metadata && metadata[index].tag !== undefined && metadata[index].tag !== tag ) { return; } if (this.add(change.ref, tag, false)) { isChildAdded = true; } }); // set tag if (tag !== DEFAULT_VIEW_TAG) { if (!this.tags) { this.tags = new WeakMap<ChangeTree, Set<number>>(); } let tags: Set<number>; if (!this.tags.has(changeTree)) { tags = new Set<number>(); this.tags.set(changeTree, tags); } else { tags = this.tags.get(changeTree); } tags.add(tag); // Ref: add tagged properties metadata?.[$fieldIndexesByViewTag]?.[tag]?.forEach((index) => { if (changeTree.getChange(index) !== OPERATION.DELETE) { changes[index] = OPERATION.ADD; } }); } else if (!changeTree.isNew || isChildAdded) { // new structures will be added as part of .encode() call, no need to force it to .encodeView() const changeSet = (changeTree.filteredChanges !== undefined) ? changeTree.allFilteredChanges : changeTree.allChanges; const isInvisible = this.invisible.has(changeTree); for (let i = 0, len = changeSet.operations.length; i < len; i++) { const index = changeSet.operations[i]; if (index === undefined) { continue; } // skip "undefined" indexes const op = changeTree.indexedOperations[index] ?? OPERATION.ADD; const tagAtIndex = metadata?.[index].tag; if ( op !== OPERATION.DELETE && ( isInvisible || // if "invisible", include all tagAtIndex === undefined || // "all change" with no tag tagAtIndex === tag // tagged property ) ) { changes[index] = op; isChildAdded = true; // FIXME: assign only once } } } return isChildAdded; } protected addParentOf(childChangeTree: ChangeTree, tag: number) { const changeTree = childChangeTree.parent[$changes]; const parentIndex = childChangeTree.parentIndex; if (!this.visible.has(changeTree)) { // view must have all "changeTree" parent tree this.visible.add(changeTree); // add parent's parent const parentChangeTree: ChangeTree = changeTree.parent?.[$changes]; if (parentChangeTree && (parentChangeTree.filteredChanges !== undefined)) { this.addParentOf(changeTree, tag); } // // parent is already available, no need to add it! // if (!this.invisible.has(changeTree)) { return; } } // add parent's tag properties if (changeTree.getChange(parentIndex) !== OPERATION.DELETE) { const changes = this.touchChanges(changeTree.ref[$refId]); if (!this.tags) { this.tags = new WeakMap<ChangeTree, Set<number>>(); } let tags: Set<number>; if (!this.tags.has(changeTree)) { tags = new Set<number>(); this.tags.set(changeTree, tags); } else { tags = this.tags.get(changeTree); } tags.add(tag); changes[parentIndex] = OPERATION.ADD; } } remove(obj: Ref, tag?: number): this; // hide _isClear parameter from public API remove(obj: Ref, tag?: number, _isClear?: boolean): this; remove(obj: Ref, tag: number = DEFAULT_VIEW_TAG, _isClear: boolean = false): this { const changeTree: ChangeTree = obj[$changes]; if (!changeTree) { console.warn("StateView#remove(), invalid object:", obj); return this; } // remove() bypasses addParentOf's ordering guarantee — flag the // changeset as potentially out of topological order. this.changesOutOfOrder = true; this.visible.delete(changeTree); // remove from iterable list if ( this.iterable && !_isClear // no need to remove during clear(), as it will be cleared entirely ) { spliceOne(this.items, this.items.indexOf(obj)); } const ref = changeTree.ref; const metadata: Metadata = ref.constructor[Symbol.metadata]; // ArraySchema/MapSchema do not have metadata const refId = ref[$refId]; if (tag === DEFAULT_VIEW_TAG) { // parent is collection (Map/Array) const parent = changeTree.parent; if (parent && !Metadata.isValidInstance(parent) && changeTree.isFiltered) { const parentChanges = this.touchChanges(parent[$refId]); if (parentChanges[changeTree.parentIndex] === OPERATION.ADD) { // // SAME PATCH ADD + REMOVE: // The 'changes' of deleted structure should be ignored. // this.changes.delete(refId); } // DELETE / DELETE BY REF ID parentChanges[changeTree.parentIndex] = OPERATION.DELETE; // Remove child schema from visible set this._recursiveDeleteVisibleChangeTree(changeTree); } else { // delete all "tagged" properties. const changes = this.touchChanges(refId); metadata?.[$viewFieldIndexes]?.forEach((index) => { changes[index] = OPERATION.DELETE; // Remove child structures of @view() fields from visible set. // (They were added during view.add() via forEachChild) const value = changeTree.ref[metadata[index].name as keyof Ref]; if (value?.[$changes]) { this.visible.delete(value[$changes]); this._recursiveDeleteVisibleChangeTree(value[$changes]); } }); } } else { // delete only tagged properties const changes = this.touchChanges(refId); metadata?.[$fieldIndexesByViewTag][tag].forEach((index) => { changes[index] = OPERATION.DELETE; // Remove child structures from visible set const value = changeTree.ref[metadata[index].name as keyof Ref]; if (value?.[$changes]) { this.visible.delete(value[$changes]); this._recursiveDeleteVisibleChangeTree(value[$changes]); } }); } // remove tag if (this.tags && this.tags.has(changeTree)) { const tags = this.tags.get(changeTree); if (tag === undefined) { // delete all tags this.tags.delete(changeTree); } else { // delete specific tag tags.delete(tag); // if tag set is empty, delete it entirely if (tags.size === 0) { this.tags.delete(changeTree); } } } return this; } has(obj: Ref) { return this.visible.has(obj[$changes]); } hasTag(ob: Ref, tag: number = DEFAULT_VIEW_TAG) { const tags = this.tags?.get(ob[$changes]); return tags?.has(tag) ?? false; } clear() { if (!this.iterable) { throw new Error("StateView#clear() is only available for iterable StateView's. Use StateView(iterable: true) constructor."); } for (let i = 0, l = this.items.length; i < l; i++) { this.remove(this.items[i], DEFAULT_VIEW_TAG, true); } // clear items array this.items.length = 0; } isChangeTreeVisible(changeTree: ChangeTree) { let isVisible = this.visible.has(changeTree); // // TODO: avoid checking for parent visibility, most of the time it's not needed // See test case: 'should not be required to manually call view.add() items to child arrays without @view() tag' // if (!isVisible && changeTree.isVisibilitySharedWithParent){ // console.log("CHECK AGAINST PARENT...", { // ref: changeTree.ref.constructor.name, // refId: changeTree.ref[$refId], // parent: changeTree.parent.constructor.name, // }); if (this.visible.has(changeTree.parent[$changes])) { this.visible.add(changeTree); isVisible = true; } } return isVisible; } protected _recursiveDeleteVisibleChangeTree(changeTree: ChangeTree) { changeTree.forEachChild((childChangeTree) => { this.visible.delete(childChangeTree); this._recursiveDeleteVisibleChangeTree(childChangeTree); }); } }