UNPKG

@tamgl/colyseus-schema

Version:

Binary state serializer with delta encoding for games

541 lines (457 loc) 20.2 kB
import "./symbol.shim"; import { Schema } from './Schema'; import { ArraySchema } from './types/custom/ArraySchema'; import { MapSchema } from './types/custom/MapSchema'; import { Metadata } from "./Metadata"; import { $changes, $childType, $descriptors, $numFields, $track } from "./types/symbols"; import { TypeDefinition, getType } from "./types/registry"; import { OPERATION } from "./encoding/spec"; import { TypeContext } from "./types/TypeContext"; import { assertInstanceType, assertType } from "./encoding/assert"; import type { Ref } from "./encoder/ChangeTree"; import type { DefinedSchemaType, InferValueType } from "./types/HelperTypes"; import type { CollectionSchema } from "./types/custom/CollectionSchema"; import type { SetSchema } from "./types/custom/SetSchema"; export type RawPrimitiveType = "string" | "number" | "boolean" | "int8" | "uint8" | "int16" | "uint16" | "int32" | "uint32" | "int64" | "uint64" | "float32" | "float64" | "bigint64" | "biguint64"; export type PrimitiveType = RawPrimitiveType | typeof Schema | object; // TODO: infer "default" value type correctly. export type DefinitionType<T extends PrimitiveType = PrimitiveType> = T | T[] | { type: T, default?: InferValueType<T>, view?: boolean | number } | { array: T, default?: ArraySchema<InferValueType<T>>, view?: boolean | number } | { map: T, default?: MapSchema<InferValueType<T>>, view?: boolean | number } | { collection: T, default?: CollectionSchema<InferValueType<T>>, view?: boolean | number } | { set: T, default?: SetSchema<InferValueType<T>>, view?: boolean | number }; export type Definition = { [field: string]: DefinitionType }; export interface TypeOptions { manual?: boolean, } export const DEFAULT_VIEW_TAG = -1; export function entity(constructor) { TypeContext.register(constructor); return constructor; } /** * [See documentation](https://docs.colyseus.io/state/schema/) * * Annotate a Schema property to be serializeable. * \@type()'d fields are automatically flagged as "dirty" for the next patch. * * @example Standard usage, with automatic change tracking. * ``` * \@type("string") propertyName: string; * ``` * * @example You can provide the "manual" option if you'd like to manually control your patches via .setDirty(). * ``` * \@type("string", { manual: true }) * ``` */ // export function type(type: DefinitionType, options?: TypeOptions) { // return function ({ get, set }, context: ClassAccessorDecoratorContext): ClassAccessorDecoratorResult<Schema, any> { // if (context.kind !== "accessor") { // throw new Error("@type() is only supported for class accessor properties"); // } // const field = context.name.toString(); // // // // detect index for this field, considering inheritance // // // const parent = Object.getPrototypeOf(context.metadata); // let fieldIndex: number = context.metadata[$numFields] // current structure already has fields defined // ?? (parent && parent[$numFields]) // parent structure has fields defined // ?? -1; // no fields defined // fieldIndex++; // if ( // !parent && // the parent already initializes the `$changes` property // !Metadata.hasFields(context.metadata) // ) { // context.addInitializer(function (this: Ref) { // Object.defineProperty(this, $changes, { // value: new ChangeTree(this), // enumerable: false, // writable: true // }); // }); // } // Metadata.addField(context.metadata, fieldIndex, field, type); // const isArray = ArraySchema.is(type); // const isMap = !isArray && MapSchema.is(type); // // if (options && options.manual) { // // // do not declare getter/setter descriptor // // definition.descriptors[field] = { // // enumerable: true, // // configurable: true, // // writable: true, // // }; // // return; // // } // return { // init(value) { // // TODO: may need to convert ArraySchema/MapSchema here // // do not flag change if value is undefined. // if (value !== undefined) { // this[$changes].change(fieldIndex); // // automaticallty transform Array into ArraySchema // if (isArray) { // if (!(value instanceof ArraySchema)) { // value = new ArraySchema(...value); // } // value[$childType] = Object.values(type)[0]; // } // // automaticallty transform Map into MapSchema // if (isMap) { // if (!(value instanceof MapSchema)) { // value = new MapSchema(value); // } // value[$childType] = Object.values(type)[0]; // } // // try to turn provided structure into a Proxy // if (value['$proxy'] === undefined) { // if (isMap) { // value = getMapProxy(value); // } // } // } // return value; // }, // get() { // return get.call(this); // }, // set(value: any) { // /** // * Create Proxy for array or map items // */ // // skip if value is the same as cached. // if (value === get.call(this)) { // return; // } // if ( // value !== undefined && // value !== null // ) { // // automaticallty transform Array into ArraySchema // if (isArray) { // if (!(value instanceof ArraySchema)) { // value = new ArraySchema(...value); // } // value[$childType] = Object.values(type)[0]; // } // // automaticallty transform Map into MapSchema // if (isMap) { // if (!(value instanceof MapSchema)) { // value = new MapSchema(value); // } // value[$childType] = Object.values(type)[0]; // } // // try to turn provided structure into a Proxy // if (value['$proxy'] === undefined) { // if (isMap) { // value = getMapProxy(value); // } // } // // flag the change for encoding. // this[$changes].change(fieldIndex); // // // // call setParent() recursively for this and its child // // structures. // // // if (value[$changes]) { // value[$changes].setParent( // this, // this[$changes].root, // Metadata.getIndex(context.metadata, field), // ); // } // } else if (get.call(this)) { // // // // Setting a field to `null` or `undefined` will delete it. // // // this[$changes].delete(field); // } // set.call(this, value); // }, // }; // } // } export function view<T> (tag: number = DEFAULT_VIEW_TAG) { return function(target: T, fieldName: string) { const constructor = target.constructor as typeof Schema; const parentClass = Object.getPrototypeOf(constructor); const parentMetadata = parentClass[Symbol.metadata]; // TODO: use Metadata.initialize() const metadata: Metadata = (constructor[Symbol.metadata] ??= Object.assign({}, constructor[Symbol.metadata], parentMetadata ?? Object.create(null))); // const fieldIndex = metadata[fieldName]; // if (!metadata[fieldIndex]) { // // // // detect index for this field, considering inheritance // // // metadata[fieldIndex] = { // type: undefined, // index: (metadata[$numFields] // current structure already has fields defined // ?? (parentMetadata && parentMetadata[$numFields]) // parent structure has fields defined // ?? -1) + 1 // no fields defined // } // } Metadata.setTag(metadata, fieldName, tag); } } export function unreliable<T> (target: T, field: string) { // // FIXME: the following block of code is repeated across `@type()`, `@deprecated()` and `@unreliable()` decorators. // const constructor = target.constructor as typeof Schema; const parentClass = Object.getPrototypeOf(constructor); const parentMetadata = parentClass[Symbol.metadata]; // TODO: use Metadata.initialize() const metadata: Metadata = (constructor[Symbol.metadata] ??= Object.assign({}, constructor[Symbol.metadata], parentMetadata ?? Object.create(null))); // if (!metadata[field]) { // // // // detect index for this field, considering inheritance // // // metadata[field] = { // type: undefined, // index: (metadata[$numFields] // current structure already has fields defined // ?? (parentMetadata && parentMetadata[$numFields]) // parent structure has fields defined // ?? -1) + 1 // no fields defined // } // } // add owned flag to the field metadata[metadata[field]].unreliable = true; } export function type ( type: DefinitionType, options?: TypeOptions ): PropertyDecorator { return function (target: typeof Schema, field: string) { const constructor = target.constructor as typeof Schema; if (!type) { throw new Error(`${constructor.name}: @type() reference provided for "${field}" is undefined. Make sure you don't have any circular dependencies.`); } // for inheritance support TypeContext.register(constructor); const parentClass = Object.getPrototypeOf(constructor); const parentMetadata = parentClass[Symbol.metadata]; const metadata = Metadata.initialize(constructor); let fieldIndex: number = metadata[field]; /** * skip if descriptor already exists for this field (`@deprecated()`) */ if (metadata[fieldIndex] !== undefined) { if (metadata[fieldIndex].deprecated) { // do not create accessors for deprecated properties. return; } else if (metadata[fieldIndex].type !== undefined) { // trying to define same property multiple times across inheritance. // https://github.com/colyseus/colyseus-unity3d/issues/131#issuecomment-814308572 try { throw new Error(`@colyseus/schema: Duplicate '${field}' definition on '${constructor.name}'.\nCheck @type() annotation`); } catch (e) { const definitionAtLine = e.stack.split("\n")[4].trim(); throw new Error(`${e.message} ${definitionAtLine}`); } } } else { // // detect index for this field, considering inheritance // fieldIndex = metadata[$numFields] // current structure already has fields defined ?? (parentMetadata && parentMetadata[$numFields]) // parent structure has fields defined ?? -1; // no fields defined fieldIndex++; } if (options && options.manual) { Metadata.addField( metadata, fieldIndex, field, type, { // do not declare getter/setter descriptor enumerable: true, configurable: true, writable: true, } ); } else { const complexTypeKlass = (Array.isArray(type)) ? getType("array") : (typeof(Object.keys(type)[0]) === "string") && getType(Object.keys(type)[0]); const childType = (complexTypeKlass) ? Object.values(type)[0] : type; Metadata.addField( metadata, fieldIndex, field, type, getPropertyDescriptor(`_${field}`, fieldIndex, childType, complexTypeKlass) ); } } } export function getPropertyDescriptor( fieldCached: string, fieldIndex: number, type: DefinitionType, complexTypeKlass: TypeDefinition, ) { return { get: function () { return this[fieldCached]; }, set: function (this: Schema, value: any) { const previousValue = this[fieldCached] ?? undefined; // skip if value is the same as cached. if (value === previousValue) { return; } if ( value !== undefined && value !== null ) { if (complexTypeKlass) { // automaticallty transform Array into ArraySchema if (complexTypeKlass.constructor === ArraySchema && !(value instanceof ArraySchema)) { value = new ArraySchema(...value); } // automaticallty transform Map into MapSchema if (complexTypeKlass.constructor === MapSchema && !(value instanceof MapSchema)) { value = new MapSchema(value); } value[$childType] = type; } else if (typeof (type) !== "string") { assertInstanceType(value, type as typeof Schema, this, fieldCached.substring(1)); } else { assertType(value, type, this, fieldCached.substring(1)); } const changeTree = this[$changes]; // // Replacing existing "ref", remove it from root. // if (previousValue !== undefined && previousValue[$changes]) { changeTree.root?.remove(previousValue[$changes]); this.constructor[$track](changeTree, fieldIndex, OPERATION.DELETE_AND_ADD); } else { this.constructor[$track](changeTree, fieldIndex, OPERATION.ADD); } // // call setParent() recursively for this and its child // structures. // (value as Ref)[$changes]?.setParent(this, changeTree.root, fieldIndex); } else if (previousValue !== undefined) { // // Setting a field to `null` or `undefined` will delete it. // this[$changes].delete(fieldIndex); } this[fieldCached] = value; }, enumerable: true, configurable: true }; } /** * `@deprecated()` flag a field as deprecated. * The previous `@type()` annotation should remain along with this one. */ export function deprecated(throws: boolean = true): PropertyDecorator { return function (klass: typeof Schema, field: string) { // // FIXME: the following block of code is repeated across `@type()`, `@deprecated()` and `@unreliable()` decorators. // const constructor = klass.constructor as typeof Schema; const parentClass = Object.getPrototypeOf(constructor); const parentMetadata = parentClass[Symbol.metadata]; const metadata: Metadata = (constructor[Symbol.metadata] ??= Object.assign({}, constructor[Symbol.metadata], parentMetadata ?? Object.create(null))); const fieldIndex = metadata[field]; // if (!metadata[field]) { // // // // detect index for this field, considering inheritance // // // metadata[field] = { // type: undefined, // index: (metadata[$numFields] // current structure already has fields defined // ?? (parentMetadata && parentMetadata[$numFields]) // parent structure has fields defined // ?? -1) + 1 // no fields defined // } // } metadata[fieldIndex].deprecated = true; if (throws) { metadata[$descriptors] ??= {}; metadata[$descriptors][field] = { get: function () { throw new Error(`${field} is deprecated.`); }, set: function (this: Schema, value: any) { /* throw new Error(`${field} is deprecated.`); */ }, enumerable: false, configurable: true }; } // flag metadata[field] as non-enumerable Object.defineProperty(metadata, fieldIndex, { value: metadata[fieldIndex], enumerable: false, configurable: true }); } } export function defineTypes( target: typeof Schema, fields: Definition, options?: TypeOptions ) { for (let field in fields) { type(fields[field], options)(target.prototype, field); } return target; } export interface SchemaWithExtends<T extends Definition, P extends typeof Schema> extends DefinedSchemaType<T, P> { extends: <T2 extends Definition>( fields: T2, name?: string ) => SchemaWithExtends<T & T2, typeof this>; } export function schema<T extends Definition, P extends typeof Schema = typeof Schema>( fields: T, name?: string, inherits: P = Schema as P ): SchemaWithExtends<T, P> { const defaultValues: any = {}; const viewTagFields: any = {}; for (let fieldName in fields) { const field = fields[fieldName] as DefinitionType; if (typeof (field) === "object") { if (field['default'] !== undefined) { defaultValues[fieldName] = field['default']; } if (field['view'] !== undefined) { viewTagFields[fieldName] = (typeof (field['view']) === "boolean") ? DEFAULT_VIEW_TAG : field['view']; } } } const klass = Metadata.setFields<any>(class extends inherits { constructor (...args: any[]) { args[0] = Object.assign({}, defaultValues, args[0]); super(...args); } }, fields) as SchemaWithExtends<T, P>; for (let fieldName in viewTagFields) { view(viewTagFields[fieldName])(klass.prototype, fieldName); } if (name) { Object.defineProperty(klass, "name", { value: name }); } klass.extends = (fields, name) => schema(fields, name, klass); return klass; }