const debug = require('debug')('@avocado/entity:traits'); import without from 'lodash.without'; import * as I from 'immutable'; import {Resource} from '@avocado/resource'; import {Synchronized} from '@avocado/state'; import {hasTrait, lookupTrait, registerTrait} from './trait-registry'; function enumerateProperties(prototype) { const result = {}; do { Object.getOwnPropertyNames(prototype).forEach((property) => { const descriptor = Object.getOwnPropertyDescriptor(prototype, property); if (typeof descriptor.get === 'function') { result[property] = result[property] || {}; result[property].get = true; } if (typeof descriptor.set === 'function') { result[property] = result[property] || {}; result[property].set = true; } }); } while (Object.prototype !== (prototype = Object.getPrototypeOf(prototype))); return result; } export class Traits extends Synchronized { constructor(entity) { super(); this._methods = {}; this._entity = entity; this._hooks = {}; this._properties = {}; this._traits = {}; entity.once('destroyed', () => { this.removeAllTraits(); }); } allInstances() { return this._traits; } allTypes() { return Object.keys(this._traits); } addTrait(type, json = {}) { if (this.hasTrait(type)) { debug(`Tried to add trait "${type}" when it already exists!`); return; } if (!hasTrait(type)) { debug(`Tried to add trait "${type}" which isn't registered!`); return; } const Trait = lookupTrait(type); // Ensure dependencies. const dependencies = Trait.dependencies(); const allTypes = this.allTypes(); const lacking = without(dependencies, ...allTypes); if (lacking.length > 0) { debug( `Tried to add trait "${type}" but lack one or more dependents: "${ lacking.join('", "') }"!` ); return; } // Instantiate. const {params, state} = json; const instance = new Trait(this._entity, params, state); // Proxy properties. const properties = enumerateProperties(Trait.prototype); for (const key in properties) { properties[key].instance = instance; this._properties[key] = properties[key]; } // Let the Trait do its initialization. instance.initialize(); // Attach listeners. const listeners = instance.listeners(); for (const eventName in listeners) { this._entity.on( `${eventName}.trait-${type}`, listeners[eventName] ); } // Proxy methods. const methods = instance.methods(); for (const key in methods) { this._methods[key] = methods[key]; } // Register hook listeners. const hooks = instance.hooks(); for (const key in hooks) { this._hooks[key] = this._hooks[key] || []; this._hooks[key].push({ fn: hooks[key], type: Trait.type(), }); } // Add state. this._setInstanceState(type, instance); // Track trait. this._traits[type] = instance; this._entity.emit('traitAdded', type, instance); } addTraits(traits) { const allTypes = this.allTypes(); for (const type in traits) { this.addTrait(type, traits[type]); } } fromJSON(json) { this.addTraits(json); return this; } getProperty(property) { if (property in this._methods) { return this._methods[property]; } if (property in this._properties) { const instance = this._properties[property].instance; if (!this._properties[property].get) { const type = instance.constructor.type(); throw new ReferenceError( `Property '${property}' from Trait '${type}' has no getter` ); } return instance[property]; } } hasProperty(property) { if (property in this._methods) { return true; } if (property in this._properties) { return true; } return false; } hasTrait(type) { return type in this._traits; } hydrate() { const promises = []; for (const type in this._traits) { const instance = this._traits[type]; promises.push(instance.hydrate()); } return Promise.all(promises); } instance(type) { return this._traits[type]; } invokeHook(hook, ...args) { const results = {}; if (!(hook in this._hooks)) { return results; } for (const {fn, type} of this._hooks[hook]) { results[type] = fn(...args); } return results; } invokeHookFlat(hook, ...args) { const invokeResults = this.invokeHook(hook, ...args); const results = []; for (const type in invokeResults) { results.push(invokeResults[type]); } return results; } patchStateStep(type, step) { let instance = this._traits[type]; // New trait requested? if (!this._traits[type]) { // Doesn't exist? if (!hasTrait(type)) { return; } this.addTrait(type, step.value); instance = this._traits[type]; } else { // Accept state. instance.patchState([step]); } this._setInstanceState(type, instance); } removeAllTraits() { const types = this.allTypes(); this.removeTraits(types); } removeTrait(type) { if (!this.hasTrait(type)) { debug(`Tried to remove trait "${type}" when it doesn't exist!`); return; } const instance = this._traits[type]; instance.destroy(); const methods = instance.methods(); for (const key in methods) { delete this._methods[key]; } const hooks = instance.hooks(); for (const key in hooks) { delete this._hooks[key]; } const Trait = lookupTrait(type); const properties = enumerateProperties(Trait.prototype); for (const key in properties) { delete this._properties[key]; } this._entity.off(`.trait-${type}`); this.state = this.state.delete(type); delete this._traits[type]; } removeTraits(types) { types.forEach((type) => this.removeTrait(type)); } _setInstanceState(type, instance) { const state = this.state; let map = state.has(type) ? state.get(type) : I.Map(); map = map.set('state', instance.state); map = map.set('params', instance.params); this.state = this.state.set(type, map); } setProperty(property, value, receiver) { if (property in this._properties) { const instance = this._properties[property].instance; const type = instance.constructor.type(); if (!this._properties[property].set) { throw new ReferenceError( `Property '${property}' from Trait '${type}' has no setter` ); } instance[property] = value; this._setInstanceState(type, instance); return true; } return false; } toJSON() { const json = {}; for (const type in this._traits) { json[type] = this._traits[type].toJSON(); } return json; } }