@colyseus/schema
Version:
Binary state serializer with delta encoding for games
697 lines (586 loc) • 23 kB
text/typescript
import { OPERATION } from "../encoding/spec";
import { Schema } from "../Schema";
import { $changes, $childType, $decoder, $onEncodeEnd, $encoder, $getByIndex, $refTypeFieldIndexes, $viewFieldIndexes, type $deleteByIndex } from "../types/symbols";
import type { MapSchema } from "../types/custom/MapSchema";
import type { ArraySchema } from "../types/custom/ArraySchema";
import type { CollectionSchema } from "../types/custom/CollectionSchema";
import type { SetSchema } from "../types/custom/SetSchema";
import { Root } from "./Root";
import { Metadata } from "../Metadata";
import type { EncodeOperation } from "./EncodeOperation";
import type { DecodeOperation } from "../decoder/DecodeOperation";
declare global {
interface Object {
// FIXME: not a good practice to extend globals here
[$changes]?: ChangeTree;
[$encoder]?: EncodeOperation,
[$decoder]?: DecodeOperation,
}
}
export interface IRef {
[$changes]?: ChangeTree;
[$getByIndex]?: (index: number, isEncodeAll?: boolean) => any;
[$deleteByIndex]?: (index: number) => void;
}
export type Ref = Schema
| ArraySchema
| MapSchema
| CollectionSchema
| SetSchema;
export type ChangeSetName = "changes"
| "allChanges"
| "filteredChanges"
| "allFilteredChanges";
export interface IndexedOperations {
[index: number]: OPERATION;
}
// Linked list node for change trees
export interface ChangeTreeNode {
changeTree: ChangeTree;
next?: ChangeTreeNode;
prev?: ChangeTreeNode;
position: number; // Cached position in the linked list for O(1) lookup
}
// Linked list for change trees
export interface ChangeTreeList {
next?: ChangeTreeNode;
tail?: ChangeTreeNode;
}
export interface ChangeSet {
// field index -> operation index
indexes: { [index: number]: number };
operations: number[];
queueRootNode?: ChangeTreeNode; // direct reference to ChangeTreeNode in the linked list
}
function createChangeSet(queueRootNode?: ChangeTreeNode): ChangeSet {
return { indexes: {}, operations: [], queueRootNode };
}
// Linked list helper functions
export function createChangeTreeList(): ChangeTreeList {
return { next: undefined, tail: undefined };
}
export function setOperationAtIndex(changeSet: ChangeSet, index: number) {
const operationsIndex = changeSet.indexes[index];
if (operationsIndex === undefined) {
changeSet.indexes[index] = changeSet.operations.push(index) - 1;
} else {
changeSet.operations[operationsIndex] = index;
}
}
export function deleteOperationAtIndex(changeSet: ChangeSet, index: number | string) {
let operationsIndex = changeSet.indexes[index as any as number];
if (operationsIndex === undefined) {
//
// if index is not found, we need to find the last operation
// FIXME: this is not very efficient
//
// > See "should allow consecutive splices (same place)" tests
//
operationsIndex = Object.values(changeSet.indexes).at(-1);
index = Object.entries(changeSet.indexes).find(([_, value]) => value === operationsIndex)?.[0];
}
changeSet.operations[operationsIndex] = undefined;
delete changeSet.indexes[index as any as number];
}
export function debugChangeSet(label: string, changeSet: ChangeSet) {
let indexes: string[] = [];
let operations: string[] = [];
for (const index in changeSet.indexes) {
indexes.push(`\t${index} => [${changeSet.indexes[index]}]`);
}
for (let i = 0; i < changeSet.operations.length; i++) {
const index = changeSet.operations[i];
if (index !== undefined) {
operations.push(`\t[${i}] => ${index}`);
}
}
console.log(`${label} =>\nindexes (${Object.keys(changeSet.indexes).length}) {`);
console.log(indexes.join("\n"), "\n}");
console.log(`operations (${changeSet.operations.filter(op => op !== undefined).length}) {`);
console.log(operations.join("\n"), "\n}");
}
export interface ParentChain {
ref: Ref;
index: number;
next?: ParentChain;
}
export class ChangeTree<T extends Ref = any> {
ref: T;
refId: number;
metadata: Metadata;
root?: Root;
parentChain?: ParentChain; // Linked list for tracking parents
/**
* Whether this structure is parent of a filtered structure.
*/
isFiltered: boolean = false;
isVisibilitySharedWithParent?: boolean; // See test case: 'should not be required to manually call view.add() items to child arrays without @view() tag'
indexedOperations: IndexedOperations = {};
//
// TODO:
// try storing the index + operation per item.
// example: 1024 & 1025 => ADD, 1026 => DELETE
//
// => https://chatgpt.com/share/67107d0c-bc20-8004-8583-83b17dd7c196
//
changes: ChangeSet = { indexes: {}, operations: [] };
allChanges: ChangeSet = { indexes: {}, operations: [] };
filteredChanges: ChangeSet;
allFilteredChanges: ChangeSet;
indexes: { [index: string]: any }; // TODO: remove this, only used by MapSchema/SetSchema/CollectionSchema (`encodeKeyValueOperation`)
/**
* Is this a new instance? Used on ArraySchema to determine OPERATION.MOVE_AND_ADD operation.
*/
isNew = true;
constructor(ref: T) {
this.ref = ref;
this.metadata = (ref.constructor as typeof Schema)[Symbol.metadata];
//
// Does this structure have "filters" declared?
//
if (this.metadata?.[$viewFieldIndexes]) {
this.allFilteredChanges = { indexes: {}, operations: [] };
this.filteredChanges = { indexes: {}, operations: [] };
}
}
setRoot(root: Root) {
this.root = root;
const isNewChangeTree = this.root.add(this);
this.checkIsFiltered(this.parent, this.parentIndex, isNewChangeTree);
// Recursively set root on child structures
if (isNewChangeTree) {
this.forEachChild((child, _) => {
if (child.root !== root) {
child.setRoot(root);
} else {
root.add(child); // increment refCount
}
});
}
}
setParent(
parent: Ref,
root?: Root,
parentIndex?: number,
) {
this.addParent(parent, parentIndex);
// avoid setting parents with empty `root`
if (!root) { return; }
const isNewChangeTree = root.add(this);
// skip if parent is already set
if (root !== this.root) {
this.root = root;
this.checkIsFiltered(parent, parentIndex, isNewChangeTree);
}
// assign same parent on child structures
if (isNewChangeTree) {
//
// assign same parent on child structures
//
this.forEachChild((child, index) => {
if (child.root === root) {
//
// re-assigning a child of the same root, move it next to parent
// so encoding order is preserved
//
root.add(child);
root.moveNextToParent(child);
return;
}
child.setParent(this.ref, root, index);
});
}
}
forEachChild(callback: (change: ChangeTree, at: any) => void) {
//
// assign same parent on child structures
//
if ((this.ref as any)[$childType]) {
if (typeof ((this.ref as any)[$childType]) !== "string") {
// MapSchema / ArraySchema, etc.
for (const [key, value] of (this.ref as MapSchema).entries()) {
if (!value) { continue; } // sparse arrays can have undefined values
callback(value[$changes], this.indexes?.[key] ?? key);
};
}
} else {
for (const index of this.metadata?.[$refTypeFieldIndexes] ?? []) {
const field = this.metadata[index as any as number];
const value = this.ref[field.name as keyof Ref];
if (!value) { continue; }
callback(value[$changes], index);
}
}
}
operation(op: OPERATION) {
// operations without index use negative values to represent them
// this is checked during .encode() time.
if (this.filteredChanges !== undefined) {
this.filteredChanges.operations.push(-op);
this.root?.enqueueChangeTree(this, 'filteredChanges');
} else {
this.changes.operations.push(-op);
this.root?.enqueueChangeTree(this, 'changes');
}
}
change(index: number, operation: OPERATION = OPERATION.ADD) {
const isFiltered = this.isFiltered || (this.metadata?.[index]?.tag !== undefined);
const changeSet = (isFiltered)
? this.filteredChanges
: this.changes;
const previousOperation = this.indexedOperations[index];
if (!previousOperation || previousOperation === OPERATION.DELETE) {
const op = (!previousOperation)
? operation
: (previousOperation === OPERATION.DELETE)
? OPERATION.DELETE_AND_ADD
: operation
//
// TODO: are DELETE operations being encoded as ADD here ??
//
this.indexedOperations[index] = op;
}
setOperationAtIndex(changeSet, index);
if (isFiltered) {
setOperationAtIndex(this.allFilteredChanges, index);
if (this.root) {
this.root.enqueueChangeTree(this, 'filteredChanges');
this.root.enqueueChangeTree(this, 'allFilteredChanges');
}
} else {
setOperationAtIndex(this.allChanges, index);
this.root?.enqueueChangeTree(this, 'changes');
}
}
shiftChangeIndexes(shiftIndex: number) {
//
// Used only during:
//
// - ArraySchema#unshift()
//
const changeSet = (this.isFiltered)
? this.filteredChanges
: this.changes;
const newIndexedOperations: any = {};
const newIndexes: { [index: number]: number } = {};
for (const index in this.indexedOperations) {
newIndexedOperations[Number(index) + shiftIndex] = this.indexedOperations[index];
newIndexes[Number(index) + shiftIndex] = changeSet.indexes[index];
}
this.indexedOperations = newIndexedOperations;
changeSet.indexes = newIndexes;
changeSet.operations = changeSet.operations.map((index) => index + shiftIndex);
}
shiftAllChangeIndexes(shiftIndex: number, startIndex: number = 0) {
//
// Used only during:
//
// - ArraySchema#splice()
//
if (this.filteredChanges !== undefined) {
this._shiftAllChangeIndexes(shiftIndex, startIndex, this.allFilteredChanges);
this._shiftAllChangeIndexes(shiftIndex, startIndex, this.allChanges);
} else {
this._shiftAllChangeIndexes(shiftIndex, startIndex, this.allChanges);
}
}
private _shiftAllChangeIndexes(shiftIndex: number, startIndex: number = 0, changeSet: ChangeSet) {
const newIndexes: { [index: number]: number } = {};
let newKey = 0;
for (const key in changeSet.indexes) {
newIndexes[newKey++] = changeSet.indexes[key];
}
changeSet.indexes = newIndexes;
for (let i = 0; i < changeSet.operations.length; i++) {
const index = changeSet.operations[i];
if (index > startIndex) {
changeSet.operations[i] = index + shiftIndex;
}
}
}
indexedOperation(index: number, operation: OPERATION, allChangesIndex: number = index) {
this.indexedOperations[index] = operation;
if (this.filteredChanges !== undefined) {
setOperationAtIndex(this.allFilteredChanges, allChangesIndex);
setOperationAtIndex(this.filteredChanges, index);
this.root?.enqueueChangeTree(this, 'filteredChanges');
} else {
setOperationAtIndex(this.allChanges, allChangesIndex);
setOperationAtIndex(this.changes, index);
this.root?.enqueueChangeTree(this, 'changes');
}
}
getType(index?: number) {
return (
//
// Get the child type from parent structure.
// - ["string"] => "string"
// - { map: "string" } => "string"
// - { set: "string" } => "string"
//
(this.ref as any)[$childType] || // ArraySchema | MapSchema | SetSchema | CollectionSchema
this.metadata[index].type // Schema
);
}
getChange(index: number) {
return this.indexedOperations[index];
}
//
// used during `.encode()`
//
getValue(index: number, isEncodeAll: boolean = false) {
//
// `isEncodeAll` param is only used by ArraySchema
//
return (this.ref as any)[$getByIndex](index, isEncodeAll);
}
delete(index: number, operation?: OPERATION, allChangesIndex = index) {
if (index === undefined) {
try {
throw new Error(`/schema ${this.ref.constructor.name}: trying to delete non-existing index '${index}'`);
} catch (e) {
console.warn(e);
}
return;
}
const changeSet = (this.filteredChanges !== undefined)
? this.filteredChanges
: this.changes;
this.indexedOperations[index] = operation ?? OPERATION.DELETE;
setOperationAtIndex(changeSet, index);
deleteOperationAtIndex(this.allChanges, allChangesIndex);
const previousValue = this.getValue(index);
// remove `root` reference
if (previousValue && previousValue[$changes]) {
//
// FIXME: this.root is "undefined"
//
// This method is being called at decoding time when a DELETE operation is found.
//
// - This is due to using the concrete Schema class at decoding time.
// - "Reflected" structures do not have this problem.
//
// (The property descriptors should NOT be used at decoding time. only at encoding time.)
//
this.root?.remove(previousValue[$changes]);
}
//
// FIXME: this is looking a ugly and repeated
//
if (this.filteredChanges !== undefined) {
deleteOperationAtIndex(this.allFilteredChanges, allChangesIndex);
this.root?.enqueueChangeTree(this, 'filteredChanges');
} else {
this.root?.enqueueChangeTree(this, 'changes');
}
return previousValue;
}
endEncode(changeSetName: ChangeSetName) {
this.indexedOperations = {};
// clear changeset
this[changeSetName] = createChangeSet();
// ArraySchema and MapSchema have a custom "encode end" method
(this.ref as any)[$onEncodeEnd]?.();
// Not a new instance anymore
this.isNew = false;
}
discard(discardAll: boolean = false) {
//
// > MapSchema:
// Remove cached key to ensure ADD operations is unsed instead of
// REPLACE in case same key is used on next patches.
//
(this.ref as any)[$onEncodeEnd]?.();
this.indexedOperations = {};
this.changes = createChangeSet(this.changes.queueRootNode);
if (this.filteredChanges !== undefined) {
this.filteredChanges = createChangeSet(this.filteredChanges.queueRootNode);
}
if (discardAll) {
// preserve queueRootNode references
this.allChanges = createChangeSet(this.allChanges.queueRootNode);
if (this.allFilteredChanges !== undefined) {
this.allFilteredChanges = createChangeSet(this.allFilteredChanges.queueRootNode);
}
}
}
/**
* Recursively discard all changes from this, and child structures.
* (Used in tests only)
*/
discardAll() {
const keys = Object.keys(this.indexedOperations);
for (let i = 0, len = keys.length; i < len; i++) {
const value = this.getValue(Number(keys[i]));
if (value && value[$changes]) {
value[$changes].discardAll();
}
}
this.discard();
}
get changed() {
return (Object.entries(this.indexedOperations).length > 0);
}
protected checkIsFiltered(parent: Ref, parentIndex: number, isNewChangeTree: boolean) {
if (this.root.types.hasFilters) {
//
// At Schema initialization, the "root" structure might not be available
// yet, as it only does once the "Encoder" has been set up.
//
// So the "parent" may be already set without a "root".
//
this._checkFilteredByParent(parent, parentIndex);
if (this.filteredChanges !== undefined) {
this.root?.enqueueChangeTree(this, 'filteredChanges');
if (isNewChangeTree) {
this.root?.enqueueChangeTree(this, 'allFilteredChanges');
}
}
}
if (!this.isFiltered) {
this.root?.enqueueChangeTree(this, 'changes');
if (isNewChangeTree) {
this.root?.enqueueChangeTree(this, 'allChanges');
}
}
}
protected _checkFilteredByParent(parent: Ref, parentIndex: number) {
// skip if parent is not set
if (!parent) { return; }
//
// ArraySchema | MapSchema - get the child type
// (if refType is typeof string, the parentFiltered[key] below will always be invalid)
//
const refType = Metadata.isValidInstance(this.ref)
? this.ref.constructor
: (this.ref as any)[$childType];
let parentChangeTree: ChangeTree;
let parentIsCollection = !Metadata.isValidInstance(parent);
if (parentIsCollection) {
parentChangeTree = parent[$changes];
parent = parentChangeTree.parent;
parentIndex = parentChangeTree.parentIndex;
} else {
parentChangeTree = parent[$changes]
}
const parentConstructor = parent.constructor as typeof Schema;
let key = `${this.root.types.getTypeId(refType as typeof Schema)}`;
if (parentConstructor) {
key += `-${this.root.types.schemas.get(parentConstructor)}`;
}
key += `-${parentIndex}`;
const fieldHasViewTag = Metadata.hasViewTagAtIndex(parentConstructor?.[Symbol.metadata], parentIndex);
this.isFiltered = parent[$changes].isFiltered // in case parent is already filtered
|| this.root.types.parentFiltered[key]
|| fieldHasViewTag;
//
// "isFiltered" may not be imedialely available during `change()` due to the instance not being attached to the root yet.
// when it's available, we need to enqueue the "changes" changeset into the "filteredChanges" changeset.
//
if (this.isFiltered) {
this.isVisibilitySharedWithParent = (
parentChangeTree.isFiltered &&
typeof (refType) !== "string" &&
!fieldHasViewTag &&
parentIsCollection
);
if (!this.filteredChanges) {
this.filteredChanges = createChangeSet();
this.allFilteredChanges = createChangeSet();
}
if (this.changes.operations.length > 0) {
this.changes.operations.forEach((index) =>
setOperationAtIndex(this.filteredChanges, index));
this.allChanges.operations.forEach((index) =>
setOperationAtIndex(this.allFilteredChanges, index));
this.changes = createChangeSet();
this.allChanges = createChangeSet();
}
}
}
/**
* Get the immediate parent
*/
get parent(): Ref | undefined {
return this.parentChain?.ref;
}
/**
* Get the immediate parent index
*/
get parentIndex(): number | undefined {
return this.parentChain?.index;
}
/**
* Add a parent to the chain
*/
addParent(parent: Ref, index: number) {
// Check if this parent already exists in the chain
if (this.hasParent((p, _) => p[$changes] === parent[$changes])) {
// if (this.hasParent((p, i) => p[$changes] === parent[$changes] && i === index)) {
this.parentChain.index = index;
return;
}
this.parentChain = {
ref: parent,
index,
next: this.parentChain
};
}
/**
* Remove a parent from the chain
* @param parent - The parent to remove
* @returns true if parent was removed
*/
removeParent(parent: Ref = this.parent): boolean {
let current = this.parentChain;
let previous = null;
while (current) {
//
// FIXME: it is required to check against `$changes` here because
// ArraySchema is instance of Proxy
//
if (current.ref[$changes] === parent[$changes]) {
if (previous) {
previous.next = current.next;
} else {
this.parentChain = current.next;
}
return true;
}
previous = current;
current = current.next;
}
return this.parentChain === undefined;
}
/**
* Find a specific parent in the chain
*/
findParent(predicate: (parent: Ref, index: number) => boolean): ParentChain | undefined {
let current = this.parentChain;
while (current) {
if (predicate(current.ref, current.index)) {
return current;
}
current = current.next;
}
return undefined;
}
/**
* Check if this ChangeTree has a specific parent
*/
hasParent(predicate: (parent: Ref, index: number) => boolean): boolean {
return this.findParent(predicate) !== undefined;
}
/**
* Get all parents as an array (for debugging/testing)
*/
getAllParents(): Array<{ ref: Ref, index: number }> {
const parents: Array<{ ref: Ref, index: number }> = [];
let current = this.parentChain;
while (current) {
parents.push({ ref: current.ref, index: current.index });
current = current.next;
}
return parents;
}
}