UNPKG

@colyseus/schema

Version:

Binary state serializer with delta encoding for games

669 lines (563 loc) 25 kB
import "./symbol.shim"; import { Schema } from './Schema'; import { ArraySchema } from './types/custom/ArraySchema'; import { MapSchema } from './types/custom/MapSchema'; import { getNormalizedType, 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 { InferValueType, InferSchemaInstanceType, AssignableProps, IsNever } from "./types/HelperTypes"; import { CollectionSchema } from "./types/custom/CollectionSchema"; import { 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: any): any { TypeContext.register(constructor as typeof Schema); 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.`); } // Normalize type (enum/collection/etc) type = getNormalizedType(type); // 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 = 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 (this: Schema) { return this[fieldCached as keyof Schema]; }, set: function (this: Schema, value: any) { const previousValue = this[fieldCached as keyof Schema] ?? 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); } // // automaticallty transform Array into SetSchema // if (complexTypeKlass.constructor === SetSchema && !(value instanceof SetSchema)) { // value = new SetSchema(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 as typeof Schema)[$track](changeTree, fieldIndex, OPERATION.DELETE_AND_ADD); } else { (this.constructor as typeof Schema)[$track](changeTree, fieldIndex, OPERATION.ADD); } // // call setParent() recursively for this and its child // structures. // value[$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 as keyof Schema] = 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; } // Helper type to extract InitProps from initialize method // Supports both single object parameter and multiple parameters // If no initialize method is specified, use AssignableProps for field initialization type ExtractInitProps<T> = T extends { initialize: (...args: infer P) => void } ? P extends readonly [] ? never : P extends readonly [infer First] ? First extends object ? First : P : P : T extends Definition ? AssignableProps<InferSchemaInstanceType<T>> : never; // Helper type to determine if InitProps should be required type IsInitPropsRequired<T> = T extends { initialize: (props: any) => void } ? true : T extends { initialize: (...args: infer P) => void } ? P extends readonly [] ? false : true : false; export interface SchemaWithExtends<T extends Definition, P extends typeof Schema, > { extends: <T2 extends Definition = Definition>( fields: T2 & ThisType<InferSchemaInstanceType<T & T2>>, name?: string ) => SchemaWithExtendsConstructor<T & T2, ExtractInitProps<T2>, P>; } /** * Get the type of the schema defined via `schema({...})` method. * * @example * const Entity = schema({ * x: "number", * y: "number", * }); * type Entity = SchemaType<typeof Entity>; */ export type SchemaType<T extends {'~type': any}> = T['~type']; export interface SchemaWithExtendsConstructor< T extends Definition, InitProps, P extends typeof Schema > extends SchemaWithExtends<T, P> { '~type': InferSchemaInstanceType<T>; new (...args: [InitProps] extends [never] ? [] : InitProps extends readonly any[] ? InitProps : IsInitPropsRequired<T> extends true ? [InitProps] : [InitProps?]): InferSchemaInstanceType<T> & InstanceType<P>; prototype: InferSchemaInstanceType<T> & InstanceType<P> & { initialize(...args: [InitProps] extends [never] ? [] : InitProps extends readonly any[] ? InitProps : [InitProps]): void; }; } export function schema< T extends Record<string, DefinitionType>, P extends typeof Schema = typeof Schema >( fieldsAndMethods: T & ThisType<InferSchemaInstanceType<T>>, name?: string, inherits: P = Schema as P ): SchemaWithExtendsConstructor<T, ExtractInitProps<T>, P> { const fields: any = {}; const methods: any = {}; const defaultValues: any = {}; const viewTagFields: any = {}; for (let fieldName in fieldsAndMethods) { const value: any = fieldsAndMethods[fieldName] as DefinitionType; if (typeof (value) === "object") { if (value['view'] !== undefined) { viewTagFields[fieldName] = (typeof (value['view']) === "boolean") ? DEFAULT_VIEW_TAG : value['view']; } fields[fieldName] = getNormalizedType(value); // If no explicit default provided, handle automatic instantiation for collection types if (!Object.prototype.hasOwnProperty.call(value, 'default')) { // TODO: remove Array.isArray() check. Use ['array'] !== undefined only. if (Array.isArray(value) || value['array'] !== undefined) { // Collection: Array → new ArraySchema() defaultValues[fieldName] = new ArraySchema(); } else if (value['map'] !== undefined) { // Collection: Map → new MapSchema() defaultValues[fieldName] = new MapSchema(); } else if (value['collection'] !== undefined) { // Collection: Collection → new CollectionSchema() defaultValues[fieldName] = new CollectionSchema(); } else if (value['set'] !== undefined) { // Collection: Set → new SetSchema() defaultValues[fieldName] = new SetSchema(); } else if (value['type'] !== undefined && Schema.is(value['type'])) { // Direct Schema type: Type → new Type() defaultValues[fieldName] = new value['type'](); } } else { defaultValues[fieldName] = value['default']; } } else if (typeof (value) === "function") { if (Schema.is(value)) { // Direct Schema type: Type → new Type() defaultValues[fieldName] = new value(); fields[fieldName] = getNormalizedType(value); } else { methods[fieldName] = value; } } else { fields[fieldName] = getNormalizedType(value); } } const getDefaultValues = () => { const defaults: any = {}; // use current class default values for (const fieldName in defaultValues) { const defaultValue = defaultValues[fieldName]; if (defaultValue && typeof defaultValue.clone === 'function') { // complex, cloneable values, e.g. Schema, ArraySchema, MapSchema, CollectionSchema, SetSchema defaults[fieldName] = defaultValue.clone(); } else { // primitives and non-cloneable values defaults[fieldName] = defaultValue; } } return defaults; }; /** @codegen-ignore */ const klass = Metadata.setFields<any>(class extends (inherits as any) { constructor(...args: any[]) { super(Object.assign({}, getDefaultValues(), args[0] || {})); // call initialize method if (methods.initialize && typeof methods.initialize === 'function') { methods.initialize.apply(this, args); } } }, fields) as SchemaWithExtendsConstructor<T, ExtractInitProps<T>, P>; // Store the getDefaultValues function on the class for inheritance (klass as any)._getDefaultValues = getDefaultValues; // Add methods to the prototype Object.assign(klass.prototype, methods); for (let fieldName in viewTagFields) { view(viewTagFields[fieldName])(klass.prototype, fieldName); } if (name) { Object.defineProperty(klass, "name", { value: name }); } klass.extends = <T2 extends Definition = Definition>(fields: T2, name?: string) => schema<T2>(fields, name, klass as any) as SchemaWithExtendsConstructor<T & T2, ExtractInitProps<T2>, P>; return klass; }