const debug = require('debug')('@avocado/entity:traits'); import without from 'lodash.without'; import * as I from 'immutable'; import {Resource} from '@avocado/resource'; import {hasTrait, lookupTrait, registerTrait} from './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 { constructor(entity) { this.actions_PRIVATE = {}; this.entity_PRIVATE = entity; this.hooks_PRIVATE = {}; this.properties_PRIVATE = {}; this.state_PRIVATE = I.Map(); this.traits_PRIVATE = {}; } acceptStateChange(change) { for (const type in change) { let instance = this.traits_PRIVATE[type]; // New trait requested? if (!this.traits_PRIVATE[type]) { // Doesn't exist? if (!hasTrait(type)) { continue; } this.addTrait(type, change[type]); instance = this.traits_PRIVATE[type]; } else { // Accept state. instance.acceptStateChange(change[type]); } this._setInstanceState(type, instance); } } allInstances() { return this.traits_PRIVATE; } allTypes() { return Object.keys(this.traits_PRIVATE); } 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_PRIVATE, params, state); // Attach listeners. const listeners = instance.listeners(); for (const eventName in listeners) { this.entity_PRIVATE.on( `${eventName}.trait-${type}`, listeners[eventName] ); } // Proxy actions. const actions = instance.actions(); for (const key in actions) { this.actions_PRIVATE[key] = actions[key]; } // Register hook listeners. const hooks = instance.hooks(); for (const key in hooks) { this.hooks_PRIVATE[key] = this.hooks_PRIVATE[key] || []; this.hooks_PRIVATE[key].push({ fn: hooks[key], type: Trait.type(), }); } // Proxy properties. const properties = enumerateProperties(Trait.prototype); for (const key in properties) { properties[key].instance = instance; this.properties_PRIVATE[key] = properties[key]; } // Let the Trait know that it has initialized. instance.hasInitialized(); // Add state. this._setInstanceState(type, instance); // Track trait. this.traits_PRIVATE[type] = instance; this.entity_PRIVATE.emit('traitAdded', 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.actions_PRIVATE) { return this.actions_PRIVATE[property]; } if (property in this.properties_PRIVATE) { const instance = this.properties_PRIVATE[property].instance; if (!this.properties_PRIVATE[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.actions_PRIVATE) { return true; } if (property in this.properties_PRIVATE) { return true; } return false; } hasTrait(type) { return type in this.traits_PRIVATE; } hydrate() { const promises = []; for (const type in this.traits_PRIVATE) { const instance = this.traits_PRIVATE[type]; promises.push(instance.hydrate()); } return Promise.all(promises); } instance(type) { return this.traits_PRIVATE[type]; } invokeHook(hook, ...args) { const results = {}; if (!(hook in this.hooks_PRIVATE)) { return results; } for (const {fn, type} of this.hooks_PRIVATE[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; } 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_PRIVATE[type]; const actions = instance.actions(); for (const key in actions) { delete this.actions_PRIVATE[key]; } const hooks = instance.hooks(); for (const key in hooks) { delete this.hooks_PRIVATE[key]; } const Trait = lookupTrait(type); const properties = enumerateProperties(Trait.prototype); for (const key in properties) { delete this.properties_PRIVATE[key]; } this.entity_PRIVATE.off(`.trait-${type}`); instance.destroy(); this.state_PRIVATE = this.state_PRIVATE.delete(type); delete this.traits_PRIVATE[type]; } removeTraits(types) { types.forEach((type) => this.removeTrait(type)); } _setInstanceState(type, instance) { const state = this.state_PRIVATE; let map = state.has(type) ? state.get(type) : I.Map(); map = map.set('state', instance.state); map = map.set('params', instance.params); this.state_PRIVATE = this.state_PRIVATE.set(type, map); } setProperty(property, value, receiver) { if (property in this.properties_PRIVATE) { const instance = this.properties_PRIVATE[property].instance; const type = instance.constructor.type(); if (!this.properties_PRIVATE[property].set) { throw new ReferenceError( `Property '${property}' from Trait '${type}' has no setter` ); } instance[property] = value; this._setInstanceState(type, instance); return true; } return false; } state() { return this.state_PRIVATE; } toJSON() { const json = {}; for (const type in this.traits_PRIVATE) { json[type] = this.traits_PRIVATE[type].toJSON(); } return json; } }