@tamgl/colyseus-schema
Version:
Binary state serializer with delta encoding for games
631 lines (525 loc) • 22 kB
text/typescript
import { OPERATION } from "../encoding/spec";
import { Schema } from "../Schema";
import { $changes, $childType, $decoder, $onEncodeEnd, $encoder, $getByIndex, $refTypeFieldIndexes, $viewFieldIndexes } 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 type Ref = Schema
| ArraySchema
| MapSchema
| CollectionSchema
| SetSchema;
export type ChangeSetName = "changes"
| "allChanges"
| "filteredChanges"
| "allFilteredChanges";
export interface IndexedOperations {
[index: number]: OPERATION;
}
export interface ChangeSet {
// field index -> operation index
indexes: { [index: number]: number };
operations: number[];
queueRootIndex?: number; // index of ChangeTree structure in `root.changes` or `root.filteredChanges`
}
function createChangeSet(): ChangeSet {
return { indexes: {}, operations: [] };
}
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];
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];
}
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 function enqueueChangeTree(
root: Root,
changeTree: ChangeTree,
changeSet: 'changes' | 'filteredChanges' | 'allFilteredChanges',
queueRootIndex = changeTree[changeSet].queueRootIndex
) {
if (!root) {
// skip
return;
} else if (root[changeSet][queueRootIndex] !== changeTree) {
changeTree[changeSet].queueRootIndex = root[changeSet].push(changeTree) - 1;
}
}
export class ChangeTree<T extends Ref=any> {
ref: T;
refId: number;
root?: Root;
parent?: Ref;
parentIndex?: number;
/**
* 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;
//
// Does this structure have "filters" declared?
//
const metadata = ref.constructor[Symbol.metadata];
if (metadata?.[$viewFieldIndexes]) {
this.allFilteredChanges = { indexes: {}, operations: [] };
this.filteredChanges = { indexes: {}, operations: [] };
}
}
setRoot(root: Root) {
this.root = root;
this.checkIsFiltered(this.parent, this.parentIndex);
//
// TODO: refactor and possibly unify .setRoot() and .setParent()
//
// Recursively set root on child structures
const metadata: Metadata = this.ref.constructor[Symbol.metadata];
if (metadata) {
metadata[$refTypeFieldIndexes]?.forEach((index) => {
const field = metadata[index as any as number];
const changeTree: ChangeTree = this.ref[field.name]?.[$changes];
if (changeTree) {
if (changeTree.root !== root) {
changeTree.setRoot(root);
} else {
root.add(changeTree); // increment refCount
}
}
});
} else if (this.ref[$childType] && typeof(this.ref[$childType]) !== "string") {
// MapSchema / ArraySchema, etc.
(this.ref as MapSchema).forEach((value, key) => {
const changeTree: ChangeTree = value[$changes];
if (changeTree.root !== root) {
changeTree.setRoot(root);
} else {
root.add(changeTree); // increment refCount
}
});
}
}
setParent(
parent: Ref,
root?: Root,
parentIndex?: number,
) {
this.parent = parent;
this.parentIndex = parentIndex;
// avoid setting parents with empty `root`
if (!root) { return; }
// skip if parent is already set
if (root !== this.root) {
this.root = root;
this.checkIsFiltered(parent, parentIndex);
} else {
root.add(this);
}
// assign same parent on child structures
const metadata: Metadata = this.ref.constructor[Symbol.metadata];
if (metadata) {
metadata[$refTypeFieldIndexes]?.forEach((index) => {
const field = metadata[index as any as number];
const changeTree: ChangeTree = this.ref[field.name]?.[$changes];
if (changeTree && changeTree.root !== root) {
changeTree.setParent(this.ref, root, index);
}
});
} else if (this.ref[$childType] && typeof(this.ref[$childType]) !== "string") {
// MapSchema / ArraySchema, etc.
(this.ref as MapSchema).forEach((value, key) => {
const changeTree: ChangeTree = value[$changes];
if (changeTree.root !== root) {
changeTree.setParent(this.ref, root, this.indexes[key] ?? key);
}
});
}
}
forEachChild(callback: (change: ChangeTree, atIndex: number) => void) {
//
// assign same parent on child structures
//
const metadata: Metadata = this.ref.constructor[Symbol.metadata];
if (metadata) {
metadata[$refTypeFieldIndexes]?.forEach((index) => {
const field = metadata[index as any as number];
const value = this.ref[field.name];
if (value) {
callback(value[$changes], index);
}
});
} else if (this.ref[$childType] && typeof(this.ref[$childType]) !== "string") {
// MapSchema / ArraySchema, etc.
(this.ref as MapSchema).forEach((value, key) => {
callback(value[$changes], this.indexes[key] ?? key);
});
}
}
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);
enqueueChangeTree(this.root, this, 'filteredChanges');
} else {
this.changes.operations.push(-op);
enqueueChangeTree(this.root, this, 'changes');
}
}
change(index: number, operation: OPERATION = OPERATION.ADD) {
const metadata = this.ref.constructor[Symbol.metadata] as Metadata;
const isFiltered = this.isFiltered || (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) {
enqueueChangeTree(this.root, this, 'filteredChanges');
enqueueChangeTree(this.root, this, 'allFilteredChanges');
}
} else {
setOperationAtIndex(this.allChanges, index);
enqueueChangeTree(this.root, this, 'changes');
}
}
shiftChangeIndexes(shiftIndex: number) {
//
// Used only during:
//
// - ArraySchema#unshift()
//
const changeSet = (this.isFiltered)
? this.filteredChanges
: this.changes;
const newIndexedOperations = {};
const newIndexes = {};
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 = {};
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);
enqueueChangeTree(this.root, this, 'filteredChanges');
} else {
setOperationAtIndex(this.allChanges, allChangesIndex);
setOperationAtIndex(this.changes, index);
enqueueChangeTree(this.root, this, 'changes');
}
}
getType(index?: number) {
if (Metadata.isValidInstance(this.ref)) {
const metadata = this.ref.constructor[Symbol.metadata] as Metadata;
return metadata[index].type;
} else {
//
// Get the child type from parent structure.
// - ["string"] => "string"
// - { map: "string" } => "string"
// - { set: "string" } => "string"
//
return this.ref[$childType];
}
}
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[$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);
enqueueChangeTree(this.root, this, 'filteredChanges');
} else {
enqueueChangeTree(this.root, this, 'changes');
}
return previousValue;
}
endEncode(changeSetName: ChangeSetName) {
this.indexedOperations = {};
// clear changeset
this[changeSetName].indexes = {};
this[changeSetName].operations.length = 0;
this[changeSetName].queueRootIndex = undefined;
// ArraySchema and MapSchema have a custom "encode end" method
this.ref[$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[$onEncodeEnd]?.();
this.indexedOperations = {};
this.changes.indexes = {};
this.changes.operations.length = 0;
this.changes.queueRootIndex = undefined;
if (this.filteredChanges !== undefined) {
this.filteredChanges.indexes = {};
this.filteredChanges.operations.length = 0;
this.filteredChanges.queueRootIndex = undefined;
}
if (discardAll) {
this.allChanges.indexes = {};
this.allChanges.operations.length = 0;
if (this.allFilteredChanges !== undefined) {
this.allFilteredChanges.indexes = {};
this.allFilteredChanges.operations.length = 0;
}
}
}
/**
* 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();
}
ensureRefId() {
// skip if refId is already set.
if (this.refId !== undefined) {
return;
}
this.refId = this.root.getNextUniqueId();
}
get changed() {
return (Object.entries(this.indexedOperations).length > 0);
}
protected checkIsFiltered(parent: Ref, parentIndex: number) {
const isNewChangeTree = this.root.add(this);
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) {
enqueueChangeTree(this.root, this, 'filteredChanges');
if (isNewChangeTree) {
this.root.allFilteredChanges.push(this);
}
}
}
if (!this.isFiltered) {
enqueueChangeTree(this.root, this, 'changes');
if (isNewChangeTree) {
this.root.allChanges.push(this);
}
}
}
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[$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();
}
}
}
}