@colyseus/schema
Version:
Binary state serializer with delta encoding for games
387 lines (324 loc) • 14.3 kB
text/typescript
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);
});
}
}