dcl-npc-toolkit-ai-version
Version:
A collection of tools for creating Non-Player-Characters (NPCs). These are capable of having conversations with the player, and play different animations. AI usage is added atop of it
1,039 lines (848 loc) • 37.3 kB
text/typescript
import { SWITCH_TO_STRUCTURE, TYPE_ID, OPERATION } from './spec';
import { ClientWithSessionId, PrimitiveType, Context, SchemaDefinition, DefinitionType } from "./annotations";
import * as encode from "./encoding/encode";
import * as decode from "./encoding/decode";
import type { Iterator } from "./encoding/decode"; // dts-bundle-generator
import { ArraySchema } from "./types/ArraySchema";
import { MapSchema } from "./types/MapSchema";
import { CollectionSchema } from './types/CollectionSchema';
import { SetSchema } from './types/SetSchema';
import { ChangeTree, Ref, ChangeOperation } from "./changes/ChangeTree";
import { NonFunctionPropNames, ToJSON } from './types/HelperTypes';
import { ClientState } from './filters';
import { getType } from './types/typeRegistry';
import { ReferenceTracker } from './changes/ReferenceTracker';
import { addCallback, spliceOne } from './types/utils';
export interface DataChange<T=any,F=string> {
refId: number,
op: OPERATION,
field: F;
dynamicIndex?: number | string;
value: T;
previousValue: T;
}
export interface SchemaDecoderCallbacks<TValue=any, TKey=any> {
$callbacks: { [operation: number]: Array<(item: TValue, key: TKey) => void> };
onAdd(callback: (item: any, key: any) => void, ignoreExisting?: boolean): () => void;
onRemove(callback: (item: any, key: any) => void): () => void;
onChange(callback: (item: any, key: any) => void): () => void;
clone(decoding?: boolean): SchemaDecoderCallbacks;
clear(changes?: DataChange[]);
decode?(byte, it: Iterator);
}
class EncodeSchemaError extends Error {}
function assertType(value: any, type: string, klass: Schema, field: string | number) {
let typeofTarget: string;
let allowNull: boolean = false;
switch (type) {
case "number":
case "int8":
case "uint8":
case "int16":
case "uint16":
case "int32":
case "uint32":
case "int64":
case "uint64":
case "float32":
case "float64":
typeofTarget = "number";
if (isNaN(value)) {
console.log(`trying to encode "NaN" in ${klass.constructor.name}#${field}`);
}
break;
case "string":
typeofTarget = "string";
allowNull = true;
break;
case "boolean":
// boolean is always encoded as true/false based on truthiness
return;
}
if (typeof (value) !== typeofTarget && (!allowNull || (allowNull && value !== null))) {
let foundValue = `'${JSON.stringify(value)}'${(value && value.constructor && ` (${value.constructor.name})`) || ''}`;
throw new EncodeSchemaError(`a '${typeofTarget}' was expected, but ${foundValue} was provided in ${klass.constructor.name}#${field}`);
}
}
function assertInstanceType(
value: Schema,
type: typeof Schema
| typeof ArraySchema
| typeof MapSchema
| typeof CollectionSchema
| typeof SetSchema,
klass: Schema,
field: string | number,
) {
if (!(value instanceof type)) {
throw new EncodeSchemaError(`a '${type.name}' was expected, but '${(value as any).constructor.name}' was provided in ${klass.constructor.name}#${field}`);
}
}
function encodePrimitiveType(
type: PrimitiveType,
bytes: number[],
value: any,
klass: Schema,
field: string | number,
) {
assertType(value, type as string, klass, field);
const encodeFunc = encode[type as string];
if (encodeFunc) {
encodeFunc(bytes, value);
} else {
throw new EncodeSchemaError(`a '${type}' was expected, but ${value} was provided in ${klass.constructor.name}#${field}`);
}
}
function decodePrimitiveType (type: string, bytes: number[], it: Iterator) {
return decode[type as string](bytes, it);
}
/**
* Schema encoder / decoder
*/
export abstract class Schema {
static _typeid: number;
static _context: Context;
static _definition: SchemaDefinition = SchemaDefinition.create();
static onError(e) {
console.error(e);
}
static is(type: DefinitionType) {
return (
type['_definition'] &&
type['_definition'].schema !== undefined
);
}
protected $changes: ChangeTree;
// TODO: refactor. this feature needs to be ported to other languages with potentially different API
// protected $listeners: { [field: string]: Array<(value: any, previousValue: any) => void> };
protected $callbacks: { [op: number]: Array<Function> };
public onChange(callback: () => void): () => void {
return addCallback((this.$callbacks || (this.$callbacks = {})), OPERATION.REPLACE, callback);
}
public onRemove(callback: () => void): () => void {
return addCallback((this.$callbacks || (this.$callbacks = {})), OPERATION.DELETE, callback);
}
// allow inherited classes to have a constructor
constructor(...args: any[]) {
// fix enumerability of fields for end-user
Object.defineProperties(this, {
$changes: {
value: new ChangeTree(this, undefined, new ReferenceTracker()),
enumerable: false,
writable: true
},
// $listeners: {
// value: undefined,
// enumerable: false,
// writable: true
// },
$callbacks: {
value: undefined,
enumerable: false,
writable: true
},
});
const descriptors = this._definition.descriptors;
if (descriptors) {
Object.defineProperties(this, descriptors);
}
//
// Assign initial values
//
if (args[0]) {
this.assign(args[0]);
}
}
public assign(
props: { [prop in NonFunctionPropNames<this>]?: this[prop] } | ToJSON<this>,
) {
Object.assign(this, props);
return this;
}
protected get _definition () { return (this.constructor as typeof Schema)._definition; }
/**
* (Server-side): Flag a property to be encoded for the next patch.
* @param instance Schema instance
* @param property string representing the property name, or number representing the index of the property.
* @param operation OPERATION to perform (detected automatically)
*/
public setDirty<K extends NonFunctionPropNames<this>>(property: K | number, operation?: OPERATION) {
this.$changes.change(property as any, operation);
}
/**
* Client-side: listen for changes on property.
* @param prop the property name
* @param callback callback to be triggered on property change
* @param immediate trigger immediatelly if property has been already set.
*/
public listen<K extends NonFunctionPropNames<this>>(
prop: K,
callback: (value: this[K], previousValue: this[K]) => void,
immediate: boolean = true,
) {
if (!this.$callbacks) { this.$callbacks = {}; }
if (!this.$callbacks[prop as string]) { this.$callbacks[prop as string] = []; }
this.$callbacks[prop as string].push(callback);
if (immediate && this[prop] !== undefined) {
callback(this[prop], undefined);
}
// return un-register callback.
return () => spliceOne(this.$callbacks[prop as string], this.$callbacks[prop as string].indexOf(callback));
}
decode(
bytes: number[],
it: Iterator = { offset: 0 },
ref: Ref = this,
) {
const allChanges: DataChange[] = [];
const $root = this.$changes.root;
const totalBytes = bytes.length;
let refId: number = 0;
$root.refs.set(refId, this);
while (it.offset < totalBytes) {
let byte = bytes[it.offset++];
if (byte == SWITCH_TO_STRUCTURE) {
refId = decode.number(bytes, it);
const nextRef = $root.refs.get(refId) as Schema;
//
// Trying to access a reference that haven't been decoded yet.
//
if (!nextRef) { throw new Error(`"refId" not found: ${refId}`); }
ref = nextRef;
continue;
}
const changeTree: ChangeTree = ref['$changes'];
const isSchema = (ref['_definition'] !== undefined);
const operation = (isSchema)
? (byte >> 6) << 6 // "compressed" index + operation
: byte; // "uncompressed" index + operation (array/map items)
if (operation === OPERATION.CLEAR) {
//
// TODO: refactor me!
// The `.clear()` method is calling `$root.removeRef(refId)` for
// each item inside this collection
//
(ref as SchemaDecoderCallbacks).clear(allChanges);
continue;
}
const fieldIndex = (isSchema)
? byte % (operation || 255) // if "REPLACE" operation (0), use 255
: decode.number(bytes, it);
const fieldName = (isSchema)
? (ref['_definition'].fieldsByIndex[fieldIndex])
: "";
let type = changeTree.getType(fieldIndex);
let value: any;
let previousValue: any;
let dynamicIndex: number | string;
if (!isSchema) {
previousValue = ref['getByIndex'](fieldIndex);
if ((operation & OPERATION.ADD) === OPERATION.ADD) { // ADD or DELETE_AND_ADD
dynamicIndex = (ref instanceof MapSchema)
? decode.string(bytes, it)
: fieldIndex;
ref['setIndex'](fieldIndex, dynamicIndex);
} else {
// here
dynamicIndex = ref['getIndex'](fieldIndex);
}
} else {
previousValue = ref[`_${fieldName}`];
}
//
// Delete operations
//
if ((operation & OPERATION.DELETE) === OPERATION.DELETE)
{
if (operation !== OPERATION.DELETE_AND_ADD) {
ref['deleteByIndex'](fieldIndex);
}
// Flag `refId` for garbage collection.
if (previousValue && previousValue['$changes']) {
$root.removeRef(previousValue['$changes'].refId);
}
value = null;
}
if (fieldName === undefined) {
console.warn("@colyseus/schema: definition mismatch");
//
// keep skipping next bytes until reaches a known structure
// by local decoder.
//
const nextIterator: Iterator = { offset: it.offset };
while (it.offset < totalBytes) {
if (decode.switchStructureCheck(bytes, it)) {
nextIterator.offset = it.offset + 1;
if ($root.refs.has(decode.number(bytes, nextIterator))) {
break;
}
}
it.offset++;
}
continue;
} else if (operation === OPERATION.DELETE) {
//
// FIXME: refactor me.
// Don't do anything.
//
} else if (Schema.is(type)) {
const refId = decode.number(bytes, it);
value = $root.refs.get(refId);
if (operation !== OPERATION.REPLACE) {
const childType = this.getSchemaType(bytes, it, type);
if (!value) {
value = this.createTypeInstance(childType);
value.$changes.refId = refId;
if (previousValue) {
value.$callbacks = previousValue.$callbacks;
// value.$listeners = previousValue.$listeners;
if (
previousValue['$changes'].refId &&
refId !== previousValue['$changes'].refId
) {
$root.removeRef(previousValue['$changes'].refId);
}
}
}
$root.addRef(refId, value, (value !== previousValue));
}
} else if (typeof(type) === "string") {
//
// primitive value (number, string, boolean, etc)
//
value = decodePrimitiveType(type as string, bytes, it);
} else {
const typeDef = getType(Object.keys(type)[0]);
const refId = decode.number(bytes, it);
const valueRef: SchemaDecoderCallbacks = ($root.refs.has(refId))
? previousValue || $root.refs.get(refId)
: new typeDef.constructor();
value = valueRef.clone(true);
value.$changes.refId = refId;
// preserve schema callbacks
if (previousValue) {
value['$callbacks'] = previousValue['$callbacks'];
if (
previousValue['$changes'].refId &&
refId !== previousValue['$changes'].refId
) {
$root.removeRef(previousValue['$changes'].refId);
//
// Trigger onRemove if structure has been replaced.
//
const entries: IterableIterator<[any, any]> = previousValue.entries();
let iter: IteratorResult<[any, any]>;
while ((iter = entries.next()) && !iter.done) {
const [key, value] = iter.value;
allChanges.push({
refId,
op: OPERATION.DELETE,
field: key,
value: undefined,
previousValue: value,
});
}
}
}
$root.addRef(refId, value, (valueRef !== previousValue));
}
if (
value !== null &&
value !== undefined
) {
if (value['$changes']) {
value['$changes'].setParent(
changeTree.ref,
changeTree.root,
fieldIndex,
);
}
if (ref instanceof Schema) {
ref[fieldName] = value;
// ref[`_${fieldName}`] = value;
} else if (ref instanceof MapSchema) {
// const key = ref['$indexes'].get(field);
const key = dynamicIndex as string;
// ref.set(key, value);
ref['$items'].set(key, value);
ref['$changes'].allChanges.add(fieldIndex);
} else if (ref instanceof ArraySchema) {
// const key = ref['$indexes'][field];
// console.log("SETTING FOR ArraySchema =>", { field, key, value });
// ref[key] = value;
ref.setAt(fieldIndex, value);
} else if (ref instanceof CollectionSchema) {
const index = ref.add(value);
ref['setIndex'](fieldIndex, index);
} else if (ref instanceof SetSchema) {
const index = ref.add(value);
if (index !== false) {
ref['setIndex'](fieldIndex, index);
}
}
}
if (previousValue !== value) {
allChanges.push({
refId,
op: operation,
field: fieldName,
dynamicIndex,
value,
previousValue,
});
}
}
this._triggerChanges(allChanges);
// drop references of unused schemas
$root.garbageCollectDeletedRefs();
return allChanges;
}
encode(
encodeAll = false,
bytes: number[] = [],
useFilters: boolean = false,
) {
const rootChangeTree = this.$changes;
const refIdsVisited = new WeakSet<ChangeTree>();
const changeTrees: ChangeTree[] = [rootChangeTree];
let numChangeTrees = 1;
for (let i = 0; i < numChangeTrees; i++) {
const changeTree = changeTrees[i];
const ref = changeTree.ref;
const isSchema = (ref instanceof Schema);
// Generate unique refId for the ChangeTree.
changeTree.ensureRefId();
// mark this ChangeTree as visited.
refIdsVisited.add(changeTree);
// root `refId` is skipped.
if (
changeTree !== rootChangeTree &&
(changeTree.changed || encodeAll)
) {
encode.uint8(bytes, SWITCH_TO_STRUCTURE);
encode.number(bytes, changeTree.refId);
}
const changes: ChangeOperation[] | number[] = (encodeAll)
? Array.from(changeTree.allChanges)
: Array.from(changeTree.changes.values());
for (let j = 0, cl = changes.length; j < cl; j++) {
const operation: ChangeOperation = (encodeAll)
? { op: OPERATION.ADD, index: changes[j] as number }
: changes[j] as ChangeOperation;
const fieldIndex = operation.index;
const field = (isSchema)
? ref['_definition'].fieldsByIndex && ref['_definition'].fieldsByIndex[fieldIndex]
: fieldIndex;
// cache begin index if `useFilters`
const beginIndex = bytes.length;
// encode field index + operation
if (operation.op !== OPERATION.TOUCH) {
if (isSchema) {
//
// Compress `fieldIndex` + `operation` into a single byte.
// This adds a limitaion of 64 fields per Schema structure
//
encode.uint8(bytes, (fieldIndex | operation.op));
} else {
encode.uint8(bytes, operation.op);
// custom operations
if (operation.op === OPERATION.CLEAR) {
continue;
}
// indexed operations
encode.number(bytes, fieldIndex);
}
}
//
// encode "alias" for dynamic fields (maps)
//
if (
!isSchema &&
(operation.op & OPERATION.ADD) == OPERATION.ADD // ADD or DELETE_AND_ADD
) {
if (ref instanceof MapSchema) {
//
// MapSchema dynamic key
//
const dynamicIndex = changeTree.ref['$indexes'].get(fieldIndex);
encode.string(bytes, dynamicIndex);
}
}
if (operation.op === OPERATION.DELETE) {
//
// TODO: delete from filter cache data.
//
// if (useFilters) {
// delete changeTree.caches[fieldIndex];
// }
continue;
}
// const type = changeTree.childType || ref._schema[field];
const type = changeTree.getType(fieldIndex);
// const type = changeTree.getType(fieldIndex);
const value = changeTree.getValue(fieldIndex);
// Enqueue ChangeTree to be visited
if (
value &&
value['$changes'] &&
!refIdsVisited.has(value['$changes'])
) {
changeTrees.push(value['$changes']);
value['$changes'].ensureRefId();
numChangeTrees++;
}
if (operation.op === OPERATION.TOUCH) {
continue;
}
if (Schema.is(type)) {
assertInstanceType(value, type as typeof Schema, ref as Schema, field);
//
// Encode refId for this instance.
// The actual instance is going to be encoded on next `changeTree` iteration.
//
encode.number(bytes, value.$changes.refId);
// Try to encode inherited TYPE_ID if it's an ADD operation.
if ((operation.op & OPERATION.ADD) === OPERATION.ADD) {
this.tryEncodeTypeId(bytes, type as typeof Schema, value.constructor as typeof Schema);
}
} else if (typeof(type) === "string") {
//
// Primitive values
//
encodePrimitiveType(type as PrimitiveType, bytes, value, ref as Schema, field);
} else {
//
// Custom type (MapSchema, ArraySchema, etc)
//
const definition = getType(Object.keys(type)[0]);
//
// ensure a ArraySchema has been provided
//
assertInstanceType(ref[`_${field}`], definition.constructor, ref as Schema, field);
//
// Encode refId for this instance.
// The actual instance is going to be encoded on next `changeTree` iteration.
//
encode.number(bytes, value.$changes.refId);
}
if (useFilters) {
// cache begin / end index
changeTree.cache(fieldIndex as number, bytes.slice(beginIndex));
}
}
if (!encodeAll && !useFilters) {
changeTree.discard();
}
}
return bytes;
}
encodeAll (useFilters?: boolean) {
return this.encode(true, [], useFilters);
}
applyFilters(client: ClientWithSessionId, encodeAll: boolean = false) {
const root = this;
const refIdsDissallowed = new Set<number>();
const $filterState = ClientState.get(client);
const changeTrees = [this.$changes];
let numChangeTrees = 1;
let filteredBytes: number[] = [];
for (let i = 0; i < numChangeTrees; i++) {
const changeTree = changeTrees[i];
if (refIdsDissallowed.has(changeTree.refId)) {
// console.log("REFID IS NOT ALLOWED. SKIP.", { refId: changeTree.refId })
continue;
}
const ref = changeTree.ref as Ref;
const isSchema: boolean = ref instanceof Schema;
encode.uint8(filteredBytes, SWITCH_TO_STRUCTURE);
encode.number(filteredBytes, changeTree.refId);
const clientHasRefId = $filterState.refIds.has(changeTree);
const isEncodeAll = (encodeAll || !clientHasRefId);
// console.log("REF:", ref.constructor.name);
// console.log("Encode all?", isEncodeAll);
//
// include `changeTree` on list of known refIds by this client.
//
$filterState.addRefId(changeTree);
const containerIndexes = $filterState.containerIndexes.get(changeTree)
const changes = (isEncodeAll)
? Array.from(changeTree.allChanges)
: Array.from(changeTree.changes.values());
//
// WORKAROUND: tries to re-evaluate previously not included @filter() attributes
// - see "DELETE a field of Schema" test case.
//
if (
!encodeAll &&
isSchema &&
(ref as Schema)._definition.indexesWithFilters
) {
const indexesWithFilters = (ref as Schema)._definition.indexesWithFilters;
indexesWithFilters.forEach(indexWithFilter => {
if (
!containerIndexes.has(indexWithFilter) &&
changeTree.allChanges.has(indexWithFilter)
) {
if (isEncodeAll) {
changes.push(indexWithFilter as any);
} else {
changes.push({ op: OPERATION.ADD, index: indexWithFilter, } as any);
}
}
});
}
for (let j = 0, cl = changes.length; j < cl; j++) {
const change: ChangeOperation = (isEncodeAll)
? { op: OPERATION.ADD, index: changes[j] as number }
: changes[j] as ChangeOperation;
// custom operations
if (change.op === OPERATION.CLEAR) {
encode.uint8(filteredBytes, change.op);
continue;
}
const fieldIndex = change.index;
//
// Deleting fields: encode the operation + field index
//
if (change.op === OPERATION.DELETE) {
//
// DELETE operations also need to go through filtering.
//
// TODO: cache the previous value so we can access the value (primitive or `refId`)
// (check against `$filterState.refIds`)
//
if (isSchema) {
encode.uint8(filteredBytes, change.op | fieldIndex);
} else {
encode.uint8(filteredBytes, change.op);
encode.number(filteredBytes, fieldIndex);
}
continue;
}
// indexed operation
const value = changeTree.getValue(fieldIndex);
const type = changeTree.getType(fieldIndex);
if (isSchema) {
// Is a Schema!
const filter = (
(ref as Schema)._definition.filters &&
(ref as Schema)._definition.filters[fieldIndex]
);
if (filter && !filter.call(ref, client, value, root)) {
if (value && value['$changes']) {
refIdsDissallowed.add(value['$changes'].refId);;
}
continue;
}
} else {
// Is a collection! (map, array, etc.)
const parent = changeTree.parent as Ref;
const filter = changeTree.getChildrenFilter();
if (filter && !filter.call(parent, client, ref['$indexes'].get(fieldIndex), value, root)) {
if (value && value['$changes']) {
refIdsDissallowed.add(value['$changes'].refId);
}
continue;
}
}
// visit child ChangeTree on further iteration.
if (value['$changes']) {
changeTrees.push(value['$changes']);
numChangeTrees++;
}
//
// Copy cached bytes
//
if (change.op !== OPERATION.TOUCH) {
//
// TODO: refactor me!
//
if (change.op === OPERATION.ADD || isSchema) {
//
// use cached bytes directly if is from Schema type.
//
filteredBytes.push.apply(filteredBytes, changeTree.caches[fieldIndex] ?? []);
containerIndexes.add(fieldIndex);
} else {
if (containerIndexes.has(fieldIndex)) {
//
// use cached bytes if already has the field
//
filteredBytes.push.apply(filteredBytes, changeTree.caches[fieldIndex] ?? []);
} else {
//
// force ADD operation if field is not known by this client.
//
containerIndexes.add(fieldIndex);
encode.uint8(filteredBytes, OPERATION.ADD);
encode.number(filteredBytes, fieldIndex);
if (ref instanceof MapSchema) {
//
// MapSchema dynamic key
//
const dynamicIndex = changeTree.ref['$indexes'].get(fieldIndex);
encode.string(filteredBytes, dynamicIndex);
}
if (value['$changes']) {
encode.number(filteredBytes, value['$changes'].refId);
} else {
// "encodePrimitiveType" without type checking.
// the type checking has been done on the first .encode() call.
encode[type as string](filteredBytes, value);
}
}
}
} else if (value['$changes'] && !isSchema) {
//
// TODO:
// - track ADD/REPLACE/DELETE instances on `$filterState`
// - do NOT always encode dynamicIndex for MapSchema.
// (If client already has that key, only the first index is necessary.)
//
encode.uint8(filteredBytes, OPERATION.ADD);
encode.number(filteredBytes, fieldIndex);
if (ref instanceof MapSchema) {
//
// MapSchema dynamic key
//
const dynamicIndex = changeTree.ref['$indexes'].get(fieldIndex);
encode.string(filteredBytes, dynamicIndex);
}
encode.number(filteredBytes, value['$changes'].refId);
}
};
}
return filteredBytes;
}
clone (): this {
const cloned = new ((this as any).constructor);
const schema = this._definition.schema;
for (let field in schema) {
if (
typeof (this[field]) === "object" &&
typeof (this[field]?.clone) === "function"
) {
// deep clone
cloned[field] = this[field].clone();
} else {
// primitive values
cloned[field] = this[field];
}
}
return cloned;
}
toJSON () {
const schema = this._definition.schema;
const deprecated = this._definition.deprecated;
const obj: unknown = {};
for (let field in schema) {
if (!deprecated[field] && this[field] !== null && typeof (this[field]) !== "undefined") {
obj[field] = (typeof (this[field]['toJSON']) === "function")
? this[field]['toJSON']()
: this[`_${field}`];
}
}
return obj as ToJSON<typeof this>;
}
discardAllChanges() {
this.$changes.discardAll();
}
protected getByIndex(index: number) {
return this[this._definition.fieldsByIndex[index]];
}
protected deleteByIndex(index: number) {
this[this._definition.fieldsByIndex[index]] = undefined;
}
private tryEncodeTypeId (bytes: number[], type: typeof Schema, targetType: typeof Schema) {
if (type._typeid !== targetType._typeid) {
encode.uint8(bytes, TYPE_ID);
encode.number(bytes, targetType._typeid);
}
}
private getSchemaType(bytes: number[], it: Iterator, defaultType: typeof Schema): typeof Schema {
let type: typeof Schema;
if (bytes[it.offset] === TYPE_ID) {
it.offset++;
type = (this.constructor as typeof Schema)._context.get(decode.number(bytes, it));
}
return type || defaultType;
}
private createTypeInstance (type: typeof Schema): Schema {
let instance: Schema = new (type as any)();
// assign root on $changes
instance.$changes.root = this.$changes.root;
return instance;
}
private _triggerChanges(changes: DataChange[]) {
const uniqueRefIds = new Set<number>();
const $refs = this.$changes.root.refs;
for (let i = 0; i < changes.length; i++) {
const change = changes[i];
const refId = change.refId;
const ref = $refs.get(refId);
const $callbacks: Schema['$callbacks'] | SchemaDecoderCallbacks['$callbacks'] = ref['$callbacks'];
//
// trigger onRemove on child structure.
//
if (
(change.op & OPERATION.DELETE) === OPERATION.DELETE &&
change.previousValue instanceof Schema
) {
change.previousValue['$callbacks']?.[OPERATION.DELETE]?.forEach(callback => callback());
}
// no callbacks defined, skip this structure!
if (!$callbacks) { continue; }
if (ref instanceof Schema) {
if (!uniqueRefIds.has(refId)) {
try {
// trigger onChange
($callbacks as Schema['$callbacks'])?.[OPERATION.REPLACE]?.forEach(callback =>
callback());
} catch (e) {
Schema.onError(e);
}
}
try {
if ($callbacks.hasOwnProperty(change.field)) {
$callbacks[change.field]?.forEach((callback) =>
callback(change.value, change.previousValue));
}
} catch (e) {
Schema.onError(e);
}
} else {
// is a collection of items
if (change.op === OPERATION.ADD && change.previousValue === undefined) {
// triger onAdd
$callbacks[OPERATION.ADD]?.forEach(callback =>
callback(change.value, change.dynamicIndex ?? change.field));
} else if (change.op === OPERATION.DELETE) {
//
// FIXME: `previousValue` should always be available.
// ADD + DELETE operations are still encoding DELETE operation.
//
if (change.previousValue !== undefined) {
// triger onRemove
$callbacks[OPERATION.DELETE]?.forEach(callback =>
callback(change.previousValue, change.dynamicIndex ?? change.field));
}
} else if (change.op === OPERATION.DELETE_AND_ADD) {
// triger onRemove
if (change.previousValue !== undefined) {
$callbacks[OPERATION.DELETE]?.forEach(callback =>
callback(change.previousValue, change.dynamicIndex ?? change.field));
}
// triger onAdd
$callbacks[OPERATION.ADD]?.forEach(callback =>
callback(change.value, change.dynamicIndex ?? change.field));
}
// trigger onChange
if (change.value !== change.previousValue) {
$callbacks[OPERATION.REPLACE]?.forEach(callback =>
callback(change.value, change.dynamicIndex ?? change.field));
}
}
uniqueRefIds.add(refId);
}
}
}