UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

1,301 lines (1,165 loc) 55.8 kB
/** * Sessions are level instances, so to speak the live version of a level. * There may be 0 - n sessions for each level. * * Sessions can be created using the API, as default level for plugins or from inside another session. * * @requires events * * @requires topic * @requires variables * * @module session * @copyright Lasse Marburg 2021 * @license MIT */ const events = require('events') const var_module = require('./variables.js') const topic = require('./topic.js') const lodash = require('lodash') /** @typedef {import('./variables').Variables} Variables */ /** @typedef {import('./mongo').Document} Document */ /** * @typedef {Object} State * @property {string} id - unique identifier that was auto generated when the state was added * @property {string} name - name of state as defined in editor * @property {Array<string>} path - list of path names depending on previous states */ /** * @typedef {Object} Action * @property {string} id - auto generated unique identifier * @property {string} name - auto generated name of action (action type appended with '_' and index, e.g.: "set_1") * @property {string} action - original action type name * @property {string} plugin - plugin name the action is part of * @property {('run'|'listen')} mode - if action will finish during state init (run) or persist waiting for input (listen) * @property {Object} schema - action form schema */ /** * @typedef {Object} ListenerProperties * @property {string} id - auto generated unique identifier * @property {string} _id - unique identifier of the corresponding database document id * @property {string} name - auto generated name of action (action type appended with '_' and index, e.g.: "set_1") * @property {string} action - original action type name * @property {string} plugin - plugin name the action is part of * @property {State} state - Data of state the listener is embedded with * @property {Date} date - Date and time when listener has been started * @property {string} session - Session database document id (_id property) * @property {Array<string>} path - list of path names depending on previous states * @property {Object} payload - listener action payload data * @property {('active'|'stalled'|'muted')} status - wether listener is actively ("active") reacting on incoming events or "muted" and stacking incoming events. Listener is "stalled" if it just dispatched a next state to prevent race condition. If "stalled" listener will also queue incoming events if it is persistent * @property {boolean} persistent - Wether listener will be kept alive and muted on state change * @property {integer} [max_queue_length] - Maximum number of entries in queue. Older elements are removed if max queue length is exceeded * @property {Array<array>} [queue] - List of incoming events whilst listener was in muted state * * @typedef {ListenerProperties & ListenerCallbacks} Listener */ /** * @typedef {Object} ListenerCallbacks * @property {Function} [callback] - Callback function that is called for each incoming event * @property {Function} [cancel] - Whenever a listener is canceled by the session, the cancel callback is called * @property {Function} [mute] - Whenever a persistent listener is deactivated, the mute callback is called * @property {Function} [unmute] - Whenever a persistent, muted listener is reactivated, the unmute callback is called */ /** * Create or resume session based on level * for resumed sessions restore listeners * for new sessions dispatch START state * * based on the specifications and states of a level * forwards actions to plugins that are addressed in a state. * stores, recalls and cancels plugin listeners */ class Session { /** * @param {Object} data - database content of stored sessions in moment of game resume or build * @param {import('./game').Game} game */ constructor(data, game) { Object.assign(this, data) /** Access to game properties and functions * @type {import('./game').Game} * @private */ this.game = game /** Database document connector for this session * @type {Document} */ this.doc = this.game.db.sessions.document({ "_id": data._id }) /** Database document connector for level this session is based on * @type {Document} */ this.leveldoc = this.game.db.levels.document({ "_id": data.level }) /** local store of reference names to make finding references easier * @type {Array} */ this.references = [] /** Will be set true on session quit. Allows to prevent pending processes from triggering subsequent actions or states * @type {boolean} */ this.canceled = false /** Names of collections whose documents can be referenced * @type {Array<string>} */ this.reference_collections = this.game.setup.collections.slice() this.reference_collections.push("sessions") this.reference_collections.push("levels") for(let plugin_collection of this.game.listPluginCollections()) { this.reference_collections.push(plugin_collection.collection) } this.game.status.on("collection_added", collection => { this.reference_collections.push(collection.collection) }) this.game.status.on("collection_removed", collection_name => { this.reference_collections.splice(this.reference_collections.indexOf(collection_name), 1) }) if (!data.name) { /** unique name of session. Either provided by user or automatically assigned random string * @type {string} * */ this.name = this._id.toString() } this.log = log.getContextLog(`${this.game.name} ${this.name}`) /** * holds cues while they dispatched and installing their listener * only first item is being dispatched and then removes itself * * items can be string or query addressing one state * * @type {Array<string|object>} */ this.state_stack = [] /** * list of listener objects that are pending. * Correlates with listener DB Collection but also contains plugin specific js objects * * @type {Array<Listener>} */ this.active_listeners = [] /** event emitter for relevant session incidents or changes * @type {events.EventEmitter} */ this.status = new events.EventEmitter() /** event emitter instance is forwarded to plugins on state dispatch to interact with this session * @type {events.EventEmitter} */ this.event = new events.EventEmitter() // Catch error events by default and not terminate the node server // see: https://nodejs.org/api/events.html#error-events this.event.on("error", msg => { // this.log.error(msg) }) } /** * @typedef {Object} SessionArgument * @property {string} [type] - the arguments data type or collection * @property {string} [collection] - (Deprecated. Use type instead) the arguments collection * @property {*} value - The arguments data value or a query reference if type was defined * @property {Object<string,*>} query - (Deprecated. Use value instead) Query reference if type or collection was defined */ /** * Assign arguments tht are forwarded on session launch. Use config arguments default values * if no respective argument has been provided. * * @param {Object<string, SessionArgument>} args - Arguments forwarded on session launch */ async setup(args) { if (Array.isArray(args)) { throw new adaptor.InvalidError(`Level Arguments can not be of type array.`) } if (!this["config"]) { this["config"] = { "arguments": {} } this.log.warn("no config defined for " + this.level_name) } else if (!this.config.hasOwnProperty("arguments")) { this.config["arguments"] = {} } for(let arg in this.config["arguments"]) { if(!args.hasOwnProperty(arg)) { args[arg] = this.config["arguments"][arg] } else if(!args[arg].hasOwnProperty("type") && !args[arg].hasOwnProperty("collection")) { if(this.config["arguments"][arg].hasOwnProperty("type")) { args[arg]["type"] = this.config["arguments"][arg].type } else if(this.config["arguments"][arg].hasOwnProperty("collection")) { args[arg]["type"] = this.config["arguments"][arg].collection } } else if(!args[arg].hasOwnProperty("value") && !args[arg].hasOwnProperty("query")) { if(this.config["arguments"][arg].hasOwnProperty("value")) { args[arg]["value"] = this.config["arguments"][arg].value } else if(this.config["arguments"][arg].hasOwnProperty("query")) { args[arg]["value"] = this.config["arguments"][arg].query } } } let promises = [] for(let arg in args) { if(args[arg].hasOwnProperty("query")) { args[arg].value = args[arg].query } if(args[arg].hasOwnProperty("collection")) { args[arg].type = args[arg].collection } if(args[arg].type) { switch(args[arg].type) { case "number": let value = parseFloat(args[arg].value) if (isNaN(value)) { this.invalidArgError(arg, "number") } else { args[arg].value = value } promises.push(this.setArgumentValue(arg, args[arg].value)) break case "string": if (typeof args[arg].value !== "string") { this.invalidArgError(arg, "string") } promises.push(this.setArgumentValue(arg, args[arg].value)) break case "object": args[arg].value = var_module.Variables.parseJSON(args[arg].value) if(typeof args[arg].value !== "object" || Array.isArray(args[arg].value)) { this.invalidArgError(arg, "object") } promises.push(this.setArgumentValue(arg, args[arg].value)) break case "array": args[arg].value = var_module.Variables.parseJSON(args[arg].value) if(!Array.isArray(args[arg].value)) { this.invalidArgError(arg, "array") } promises.push(this.setArgumentValue(arg, args[arg].value)) break default: if(!args[arg].value) { this.log.warn(`Session Argument "${arg}" was not assigned and has no default value`) break } promises.push(this.createReference(args[arg].type, args[arg].value, arg)) break } } else { args[arg].value = var_module.Variables.parseJSON(args[arg].value) promises.push(this.setArgumentValue(arg, args[arg].value)) } } await Promise.all(promises) this.event.emit("start", { name: this.name, level: this.level, level_name: this.level_name }) this.log.info(`Created Session ${this.name} (${this._id}) based on level ${this.level_name}.`) } async setArgumentValue(arg, value) { let update = {} update["variables." + arg] = value return this.game.topics.session.edit({_id:this._id}, "set", update, {user: {login:this.name}}) } invalidArgError(arg, type) { throw new adaptor.InvalidError(`Level Argument ${arg} is not of type ${type}.`) } /** * load all references to local array * * (re)execute and enqueue all listeners and make sure listener payload data is forwarded as object clone, not original. * */ async reload() { await this.loadAllReferences() let listeners = await this.game.db.listeners.find({ session: this._id }) if (listeners.length) { for (let listener of listeners) { this.game.functions = this.game.getFunctions() let variables = new var_module.Variables(this.getAPI(listener.state, {name:listener.name, plugin:listener.plugin, action: listener.action}), this.game) let exec_result = await this.executeAction(listener, listener.state, variables, new Date(listener.date)) if (typeof exec_result === "string") { this.log.debug(`Restored listener ${listener.action} ${listener.name} dispatches next state ${exec_result}`) await this.game.db.listeners.delete({_id:listener._id}) this.next(exec_result, listener.id) } else if (typeof exec_result === "function") { this.enqueue(listener, {cancel: exec_result}) } else if(typeof exec_result === "object") { this.enqueue(listener, exec_result) } } this.log.info("Restore " + listeners.length + " listeners") } } /** * API to access session properties and functions. Also contains the action and state context data for the action that requests the session API. * * @param {State} state - data of state that was dispatched now * @param {Action} action - data of action that was or will be executed now * @param {Date} [date] - date when action was executed originally. Defaults to now * @returns {import('./types').SessionInterface} access to properties and functions of session */ getAPI(state, action, date=adaptor.now()) { return { _id:this._id, name:this.name, date:date, reference_collections:this.reference_collections, references:this.references, createReference: (coll, query, ref, options) => this.createReference(coll, query, ref, options), getCallback: this.getCallback, getListener: (action_id) => this.getListener(action_id), next: (next_state) => this.next(next_state, action.id), splitPath: (state, path) => this.splitPath(state, path, action.id), joinPath: (path) => this.joinPath(path), errorHandling: (error, state) => this.errorHandling(error, state), event: this.event, status:this.status, log: log.getContextLog(`${this.game.name} ${this.name} ${state.name} ${action.name}`), state: state, action: action, level:{name:this.level_name,_id:this.level}, } } /** * Creates a reference in the specified collection based on the provided query and reference name. * * @async * @param {string} collection - The name of the collection where the reference will be created. * @param {Object<string,*>} query - The query object to find documents in the collection. * @param {string} reference - The name of the reference to be created. * @param {Object} [options={multiple: true}] - Additional options for querying and creating the reference. * @param {boolean} [options.multiple=true] - Determines whether multiple documents can be referenced. * @param {Object} [options.sort] - Specifies the sort order of the query results. * @param {number} [options.limit] - Limits the number of query results. * @param {number} [options.skip] - Skips the specified number of query results. * @throws {adaptor.InvalidError} Throws an error if the collection does not exist in the database. * @returns {Promise<void>} Resolves when the reference creation process is complete. */ async createReference(collection, query, reference, options = {multiple: true}) { if (!this.game.db.hasOwnProperty(collection)) { throw new adaptor.InvalidError(`Can not create reference for ${reference} argument. Collection ${collection} doesn't exist.`) } if (Array.isArray(query)) { query = query[0]// {name:{$in:query}} } query = var_module.Variables.parseQuery(query) options = this.game.db[collection].queryOptions(query, options) this.log.debug("creating reference " + reference + " in " + collection + ":") this.log.debug(query) let removed_count = await this.removeReference(reference) if (removed_count) { this.log.debug("replace " + removed_count + " references to " + reference) } let count = 0 if(options.sort || options.limit || options.skip) { if(!options.multiple) { options.limit = 1 } options.project = {_id: 1} let docs = await this.game.db[collection].find(query, options) count = docs.length let update_promises = docs.map((doc, i) => { return this.game.db[collection].push(doc, { sessions: { _id: this._id, reference: reference, index: i } }) }) await Promise.all(update_promises) } else { let result = await this.game.db[collection].push(query, { sessions: { _id: this._id, reference: reference } }, {multiple: options.multiple}) count = result.matchedCount } if(count > 0) { // replace existing local reference or add a new one let replace = false this.references = this.references.map(r => { if(r.name == reference) { replace = true return { collection: collection, query: query, name: reference } } else { return r } }) if(!replace) { this.references.push({ collection: collection, query: query, name: reference }) } this.log.info(count + " item(s) referenced as " + reference + " in " + collection) } else { this.log.warn(collection + "." + reference + " is currently not referencing any document") } } /** * search for all references in all custom collections and arrange them in one object. * * references are arranged like this: * * {reference1_name:[{document1},{document2},...], reference2_name:[{document1},...]} * * @return {Promise} - resolves with document containing all referenced documents */ getAllReferences() { return new Promise((resolve, reject) => { let get_references_promises = this.reference_collections.map(coll => { return this.game.db[coll].find({ 'sessions._id': this._id }) }) let references = {} Promise.all(get_references_promises) .then(result => { for (let documents of result) { for (let doc of documents) { for (let session of doc.sessions) { if (this.game.db.sessions.compareIDs(session._id, this._id)) { if (!references.hasOwnProperty(session.reference)) { references[session.reference] = [] } references[session.reference].push(doc) } } } } return resolve(references) }) .catch(err => { return reject(err) }) }) } /** * load References in all collections * * @returns {Promise<undefined>} */ loadAllReferences() { return new Promise((resolve, reject) => { let load_references_promises = this.reference_collections.map(coll => { return this.loadReferences(coll) }) Promise.all(load_references_promises) .then(result => { return resolve() }) .catch(err => { return reject(err) }) }) } /** * find referenced documents in collection and collect reference names. * * append found references to list of reference names/collections `this.references` * * @param {string} collection name of existing data collection * @returns {Promise} resolves once all references are collected */ loadReferences(collection) { return new Promise((resolve, reject) => { this.game.db[collection].find({ "sessions._id": this._id }) .then(result => { if (result.length) { for (let ref of result) { for (let session_ref of ref.sessions) { if (this.game.db[collection].compareIDs(session_ref._id, this._id)) { let duplicate = false for (let r of this.references) { if (r.name == session_ref.reference) { duplicate = true } } if (!duplicate) { this.references.push({ collection: collection, name: session_ref.reference }) this.log.debug("load reference " + session_ref.reference + " from " + collection) } } } } } return resolve() }) .catch(err => { return reject(err) }) }) } /** * Remove reference to document(s) * * @param {string} reference - name of reference (E.g. PlayerA) * @param {string} [collection] - name of collection the reference is in. Remove in all collections if none * * @returns {Promise} - resolves with count of documents where the reference was removed */ removeReference(reference, collection) { return new Promise((resolve, reject) => { let query = { sessions: { $elemMatch: { _id: this._id, reference: reference } } } if (collection) { this.removeReferenceByQuery(collection, query, reference) .then(result => { return resolve(result) }) .catch(err => { return reject(err) }) } else { let rem_ref_promises = this.reference_collections.map((coll, index) => { return this.removeReferenceByQuery(coll, query, reference) }) Promise.all(rem_ref_promises) .then(result => { const sum = result.reduce((partialSum, a) => partialSum + a, 0) return resolve(sum) }) .catch(err => { return reject(err) }) } }) } /** * Remove reference to document(s) using mongo style query. * * @param {string} collection - name of collection where reference should be removed * @param {string|Object} query - mongo style find query leading to documents in collection. * @param {string} [reference] - the reference name. If not provided, all references for the respective document will be removed * * @returns {Promise} - resolves with count of documents where the reference was removed */ removeReferenceByQuery(collection, query, reference) { return new Promise((resolve, reject) => { if (!this.game.db.hasOwnProperty(collection)) { this.log.error("can not remove reference. No collection " + collection) return reject("can not remove reference. No collection " + collection) } if (Array.isArray(query)) { query = query[0]// {name:{$in:query}} } query = var_module.Variables.parseQuery(query) let remove = { sessions: { _id: this._id } } if(reference) { remove.sessions["reference"] = reference } this.game.db[collection].remove(query, remove) .then(res => { if(res.modifiedCount > 0) { this.log.debug(res.modifiedCount + ' reference(s) removed in ' + collection) } return resolve(res.modifiedCount) }) .catch(err => { return reject(err) }) }) } /** * Dispatch a state by name or id query * * Execute all run and listen actions in the respective state * * If an action returns a string value, all further actions are skipped and the next state is dispatched instead. * * before actions are executed, listeners of previous states are canceled following these rules: * - cancel all listeners whose _path property array is contained in this cues path property array * - cancel all listeners whose _path property array contains this cues path property array * uses $all and https://docs.mongodb.com/manual/reference/operator/aggregation/setIsSubset/ * * @param {Object|string} state_query - state name or other string type property * @param {string} state_query.id - state id to identify state * @param {string} state_query.name - state name to identify state * @returns */ async dispatchState(state_query) { if(this.canceled) { this.log.info(`Will not dispatch state ${JSON.stringify(state_query)}. Session has already been canceled.`) return } let state = {} try { if (typeof state_query === 'string') { state_query = { name: state_query } } // Find state in level document let level = await this.leveldoc.get() if (level.states) { if (state_query.id) { if (level.states[state_query.id]) { state = level.states[state_query.id] state['id'] = state_query.id } } else { get_state: for(let l_state in level.states) { for (let field in state_query) { if (state_query[field] == level.states[l_state][field]) { state = level.states[l_state] state['id'] = l_state break get_state; } } } } } else { throw new adaptor.InvalidError(`level ${level.name} is missing states field`) } if (adaptor.isEmpty(state)) { throw new adaptor.NotFoundError(`level ${level.name} has no state ${JSON.stringify(state_query)}`) } // cancel open listeners on path if (!state.hasOwnProperty('path')) { state["path"] = ["main"] } else if (!state.path.length) { state["path"].push("main") } await this.joinPath(state.path) this.log.info(`---------------- ${state.path[state.path.length - 1]} ${state.name} ----------------`) this.event.emit('next_state', state.name) // Store state in session document history and emit state dispatched status event let updt = {} updt['state_data.' + state.name] = { date: adaptor.now() } await this.doc.update({$set:updt, $push:{'history':{name:state.name, id:state.id, path:state.path, date:adaptor.now()}}}) this.status.emit('state_begin', state.name) let listeners = [] // call listen actions that are supposed to be called before run actions (error action) if (state.listen && state.listen.length) { for (let action_id of state.listen) { /** @type {Action} */ let action = level.actions[action_id] let plugin = this.game.getPlugin(action.plugin) if (!plugin) throw new adaptor.NotFoundError(`Can not execute ${action.action} action because ${action.plugin} plugin is not enabled`) action.schema = plugin.schema.actions[action.action] action.id = action_id if(action.schema.adaptorExecutionOrder == "before_run") { await this.installListener(action, state, level) } else { listeners.push(action) } } } // call run actions if (state.run) { for (let action_id of state.run) { if(this.canceled) { this.log.info(`Skip subsequent actions since session has been canceled during state execution.`) return } if (!level.actions[action_id]) throw new adaptor.InvalidError(`level ${level.name} state ${state.name} is missing run action ${action_id}`) let action = level.actions[action_id] let plugin = this.game.getPlugin(action.plugin) if (!plugin) { throw new adaptor.NotFoundError(`Can not execute ${action.action} action because ${action.plugin} plugin is not enabled`) } action.schema = plugin.schema.actions[action.action] action.id = action_id let variables = new var_module.Variables(this.getAPI({ id: state.id, name: state.name, path: state.path }, {name: action.name, action: action.action, plugin: action.plugin}), this.game) if(action.schema.hasOwnProperty("resolveAdaptorVariables") && !action.schema.resolveAdaptorVariables) { await this.resolveActionPayload(action.payload, level.contents, this.content) } else { await this.resolveActionPayload(action.payload, level.contents, this.content, variables) } let result = await this.executeAction(action, state, variables, adaptor.now()) if (typeof result === "string") { this.log.debug(`${action.action} ${action.name} dispatches next state ${result}`) this.next(result, action.id) return this.step() } } } // call listen actions if(listeners.length) { for(let listener of listeners) { await this.installListener(listener, state, level) } this.status.emit('state_listening', state.name) } this.step() } catch (error) { if(!state.id) { this.log.error(`failed to dispatch state ${state_query.name || JSON.stringify(state_query)}. State could not be found.`) } else { this.log.error(`failed to dispatch state ${state_query.name || JSON.stringify(state_query)}`) this.errorHandling(error, state) } this.step() } } errorHandling(error, state) { let state_essentials = {id: state.id, name: state.name} if (error instanceof adaptor.AdaptorError) { this.log.error(`${error.name}: ${error.message}`) this.event.emit("error", {name:error.name, message:error.message, type:"AdaptorError",state:state_essentials}) } else if (error.name && error.message && error.stack) { this.log.error(error) this.event.emit("error", {name:error.name, type:"Error", message:error.message, stack:error.stack, state:state_essentials}) } else { this.log.error(error) if(typeof error === "object") { error.state = state_essentials } this.event.emit("error", error) } if(error.cause) { this.log.error(`Reason: ${error.cause}`) } } /** * If listener is persistent, forward queued messages to listener callback * Otherwise resolve variables (optional), execute action and goto next or store listener. * * @param {Action} action - the listeners action data * @param {State} state - the state, the listener is inside * @param {Object} level - the level data of current session */ async installListener(action, state, level) { /** * @type {Listener|undefined} * potential reactivated persistent listener item * */ let listener = this.getListener(action.id) if(listener) { if(level.modified_at > listener.date && !lodash.isEqual(action.payload, listener.payload)) { this.log.debug(`Persistent listener action ${action.name} in state ${state.name} has been modified. Listener will be reinstalled and ${listener.queue.length} listener queue items are being discarded.`) await this.cancel({id: action.id},{mute:false}) } else { listener.status = "active" if(typeof listener.unmute === "function") { listener.unmute() } await this.game.db.listeners.set({_id:listener._id}, {status:"active"}) while(listener.queue.length && listener.status == "active") { const s = listener.queue.shift() if(Array.isArray(s)) { await listener.callback(...s) } else { this.next(s, listener.id) } } return this.step() } } let variables = new var_module.Variables(this.getAPI({ id: state.id, name: state.name }, {name: action.name, action: action.action, plugin: action.plugin}), this.game) if(action.schema.hasOwnProperty("resolveAdaptorVariables") && !action.schema.resolveAdaptorVariables) { await this.resolveActionPayload(action.payload, level.contents, this.content) } else { await this.resolveActionPayload(action.payload, level.contents, this.content, variables) } action.status = "active" action.queue = [] if(action.payload.keep_listening && action.payload.keep_listening.enabled) { if(typeof action.payload.keep_listening.max_queue_length === "number" && action.payload.keep_listening.max_queue_length < 1) { throw new adaptor.InvalidError(`minimum value for 'max queue length' is 1`) } action.persistent = true action.max_queue_length = action.payload.keep_listening.max_queue_length } let result = await this.executeAction(action, state, variables, adaptor.now()) if (typeof result === "string") { this.log.debug(`${action.action} ${action.name} dispatches next state ${result}`) this.next(result, action.id) return this.step() } else if (typeof result === "function") { await this.storeListener(action, state, {cancel: result}) } else if (typeof result === "object") { await this.storeListener(action, state, result) } else { this.log.warn(`Listener ${action.name} in state ${state.name} did not return a state name or a cancel function`) } } /** * forward state information to plugin responsible for action * also forward objects that enable the plugin to identify and address this session * * action payload data as entered by user is forwarded to plugin as deep clone object to prevent changes to the original action data. * * @param {Object} action.payload - action data that is being forwarded * @param {Object} action.plugin - name of plugin module the state properties shall be forwarded to * @param {Object} action.schema - schema the action payload is based upon * @param {Object} state - origin state object * @param {Array} state.path - path the origin state is in * @param {string} date - current date or if state is redispatched due to server restart, this is the original date when it was dispatched * * @return {Promise<string|Function|undefined>} - may be a state name string, a cancel function or undefined */ async executeAction(action, state, variables, date) { let plugin = this.game.getPlugin(action.plugin) if (!plugin) { throw new adaptor.InvalidError(`Could not run ${action.plugin} ${action.action}. Plugin ${action.plugin} is not installed.`) } if(action.state) { // delete action.state } let session_interface = this.getAPI(state, action, date) session_interface.variables = variables this.log.debug(`run ${action.plugin} ${action.action} with payload ${JSON.stringify(action.payload)}`) if (typeof plugin[action.action] !== "function") { throw new Error(`${action.plugin} plugin has no function to run ${action.action} action`) } return plugin[action.action](lodash.cloneDeep(action.payload), session_interface) } /** * store listener local and in database. * * listener properties that are not stored: `queue` (stored but not updated), `callback` and `cancel` * * listeners that were stored are retriggered on session restart * * all listeners are stored as single documents in the games "listeners" Collection * * a timestamp is added. For mongodb ObjectID also contains time data, but it seems it is only as accurate as 1s * * @param {Action} action - listener action data * @param {State} state - state the listener originates from * @param {ListenerCallbacks} callbacks - whatever the plugin module returned after installing the listener */ async storeListener(action, state, callbacks) { /** @type {ListenerProperties} */ let store = { date: adaptor.now(), state: state, path: state.path, session: this._id } Object.assign(store, action) delete store.schema let result = await this.game.db.listeners.insert(store) store['_id'] = result.insertedId this.enqueue(store, callbacks) return result } /** * stack local instance of an installed listener including its cancel function that was returned by plugin * @param {ListenerProperties} listener - listener object * @param {ListenerCallbacks} callbacks - callback functions that will be attached to the listener item * @private */ enqueue(listener, callbacks) { if(callbacks.cancel) { listener['cancel'] = callbacks.cancel } if(callbacks.mute) { listener['mute'] = callbacks.mute } if(callbacks.unmute) { listener['unmute'] = callbacks.unmute } this.active_listeners.push(listener) } /** * Wrap a plugins listener action to * - enable persistent (keep alive) listener functionality * - properly catch and forward callback errors * * This Function is not supposed to be used in the session scope but in the scope of Session.getAPI. * * @param {Function} listener_callback - Listener callback function with plugins listener action queries * @returns {Function} - Wrapped listener callback function */ getCallback(listener_callback) { this.log.debug("Wrap Callback for " + this.action.name + " " + this.state.name) this.action.callback = async (...args) => { try { let listener = this.getListener(this.action.id) if(!listener) { this.log.trace(`Incoming call with args "${JSON.stringify(args)}" in action ${this.action.name} (${this.state.name}) can not be used since listener has not been established or has been canceled and removed.`) return } this.log.debug(`Call listener from ${listener.name}. Listener is ${listener.status}`) if(listener.status == "active") { return await listener_callback(...args) } else { if(listener.max_queue_length && listener.queue.length >= listener.max_queue_length) { const removed_element = listener.queue.shift() this.log.debug(`Listener maximum queue size of ${listener.max_queue_length} exceeded. Remove oldest element ${JSON.stringify(removed_element)}`) } listener.queue.push(args) this.log.debug(`Add event to queue. Current queue has ${listener.queue.length} elements`) this.status.emit("queueing") } } catch (error) { this.log.error(`An error occurred during callback of listener action ${this.action.name} in state ${this.state.name}`) this.errorHandling(error, this.state) } } return this.action.callback } /** * Get listener from current open listeners. * * @param {String} action_id - The action ID corresponding to the listener id * @returns {Listener|undefined} - Listener Item from active listeners that matches the given action id. undefined if no listener was found. */ getListener(action_id) { return this.active_listeners.find(elem => elem.id == action_id) } /** * Iterate through the whole payload Object and search for string elements. * * Resolve content references with proper content dependent on content type. * If undefined, content type defaults to first content found in content object. * * Resolve variables if variables instance is not undefined. * * @param {*} payload - action payload as defined in state * @param {Object} contents - level contents * @param {string} content - this sessions content type (e.g. "de" or "en") * @param {Object} [variables] - variables instance referring to action context */ async resolveActionPayload(payload, contents, content, variables) { if (typeof payload === 'object') { for (const field in payload) { if (typeof payload[field] === 'string') { if (payload[field].indexOf('_content_') >= 0) { let content_id = payload[field] if (contents[content_id]) { if (!content) { payload[field] = Object.values(contents[content_id])[0] } else if (contents[content_id][content]) { payload[field] = contents[content_id][content] } else { throw new adaptor.InvalidError(`missing content type ${content} in element ${content_id}`) } } else { throw new adaptor.InvalidError(`missing content element ${content_id}`) } } if (variables) { payload[field] = await variables.review(payload[field]) } continue } await this.resolveActionPayload(payload[field], contents, content, variables) } } } /** * Check for open listeners and collect their data * * @returns {Array<ListenerProperties>} - list of of currently running listeners */ getListeners() { let listeners = [] this.active_listeners.forEach(listener => { listeners.push((({ callback, cancel, mute, unmute, ...o }) => o)(listener)) }) return listeners } /** * Get states that where called most recently per path. Independent if they have active listeners or not. * * To figure out which of the current states have open listeners use `getListeners` * * @param {Object} options.latest - add latest called state if it is not yet recorded in session history * @param {Object} options.session_doc - provide session document to get sessions current states * @returns {Promise<array} - list of history entries that where called most recently in the respective path */ async getCurrentStates({session_doc, latest}) { if(!session_doc) { session_doc = await this.doc.get() } let history = session_doc.history if(latest) { history.push(latest) } let current_states = {} for(let i = 0; i < history.length; i++) { for(let current_state in current_states) { let path_contains = current_states[current_state].path.every(path_elem => { return history[i].path.includes(path_elem) }) let path_iscontained = history[i].path.every(path_elem => { return current_states[current_state].path.includes(path_elem) }) // this.log.info(history[i].name + " is contained: " + path_iscontained + ". contains " + path_contains) if(path_iscontained || path_contains) { delete current_states[current_state] } } current_states[history[i].id] = history[i] } return Object.values(current_states) } /** * is called once a state finished doing his stuff * foremost state in stack is removed. * If there is another state in the stack already its being called */ step() { //log.info("BEFORE STEP", this.state_stack) this.state_stack.shift() if (this.state_stack.length > 0) { this.dispatchState(this.state_stack[0]) } //log.info("AFTER STEP", this.state_stack) } /** * Trigger state dispatch. * * run state immediate if there is no other state in the stack. * Anyways enqueue it in stack. The stack is required in case a listener is firing before the state itself is done processing all actions. * * If source is a queued listener, the listener is "stalled" and can not trigger another next until it has been reactivated (see dispatchState) * * @param {Object<string, string>|string} state_query - query that points towards a valid state in the sessions level document * @param {string} source - action id or similar information about the source that called next */ next(state_query, source) { /** @type {Listener|undefined} */ let listener = this.getListener(source) if(listener) { if(listener.status == "stalled") { this.log.debug(`Listener ${source} is "stalled" and is prevented from dispatching further next states.`) if(listener.persistent) { listener.queue.push(state_query) } return } else { listener.status = "stalled" } } this.log.debug(source + " trigger NEXT state " + JSON.stringify(state_query)) //log.info("BEFORE NEXT", this.state_stack) if (this.state_stack.length == 0) { this.dispatchState(state_query) } this.state_stack.push(state_query) //log.info("AFTER NEXT", this.state_stack) } /** * create a new path that runs parallel to the existing paths * * dispatch first state for path. Cues path lists are established in webclient. * * @param {string} init_cue - state that is triggered initially with path creation * @param {string} [name] - to identify path in flow */ splitPath(init_state, name, action_id) { this.log.info("Split new path " + name + " and dispatch state " + init_state) this.next(init_state, action_id) } /** * cancel all listeners for path * * cancel all listener where state path is in listener 'path' (path of origin state) or listener path is in state path * * @param {Array<string>} path - path list representing path to be joined */ async joinPath(path) { if (this.game.db.type == "mongodb") { await this.cancel({ $or: [{ path: { $all: path } }, { $expr: { $setIsSubset: ["$path", path] } }] }, {mute:true}) } else if (this.game.db.type == "nedb") { await this.cancelForNeDB(path) // NeDB is missing some query operators. cancelForNeDB is the workaround. } } samePath(pathA, pathB) { // pathA contains pathB if(pathB.every(b => { return pathA.includes(b) })) { return true } // pathB contains pathA if(pathA.every(a => { return pathB.includes(a) })) { return true } return false } /** * cancel listener based on state path when using NeDB. Finds all of sessions listener and uses Array.every to compare arrays. * * nedb does neither support $all nor $expr operator * https://github.com/louischatriot/nedb/issues/278 * this would cover 1 half of cancel query ($all): * let query = { $or:[{$and:[]}] } * for(let path of state.path) { * query["$or"][0]["$and"].push({_path:path}) * } * But so far I didn't come up with a query for the second half ($expr/$setIsSubset) * @param {Array} path - current state path list * @returns {Promise} - latest call is cancel() which returns array of removed listeners. */ cancelForNeDB(path) { return new Promise((resolve, reject) => { let canceled_listener = [] this.game.db.listeners.find({ "session": this._id }) .then(result => { for (let res of result) { if (this.samePath(res.path, path)) { canceled_listener.push({ _id: res._id }) } } return this.cancel({ $or: canceled_listener }, {mute:true}) }) .then(result => { return resolve(result) }) .catch(err => { return reject(err) }) }) } /** * removes all listeners that are matching the query and are assigned to the session. * * If listener is not persistent: * - Call the listeners cancel function * - Remove listener from active_listeners array * - Remove listener from listeners collection * * Otherwise `mute` listener and set it to be inactive * * @param {Object} query - Query that matches listeners that are supposed to be canceled * @param {Object} options * @param {boolean} options.mute - Set to true to mute persistent listeners instead of cancel them. If set to false even persistent listeners are being canceled. * @returns {Promise<Array<Listener>>} array of deleted listener objects. Empty array if query didn't return listener. Resolves once all cancel functions returned. */ async cancel(query, {mute}) { if (typeof query === 'string') { query = { "state.name": query } } let full_query = { session: this._id } if(mute) { full_query["status"] = {$ne: "muted"} } Object.assign(full_query, query) let listener_documents = await this.game.db.listeners.find(full_query) if (listener_documents.length <= 0) { return } if(mute) { await this.game.db.listeners.set(Object.assign({persistent: true}, full_query), {status:"muted"}) await this.game.db.listeners.delete(Object.assign({persistent: {$ne: true}}, full_query)) } else { await this.game.db.listeners.delete(full_query) } let cancel_promises = [] let canceled_items = [] listener_documents.forEach( listener => { let index = this.active_listeners.findIndex(elem => this.game.db.listeners.compareIDs(elem._id, listener._id)) if (index >= 0) { /** @type {Listener} */ let l = this.active_listeners[index] canceled_items.push(l) if(l.persistent && mute) { this.log.debug(`Mute listener ${l.plugin} ${l.name} (${l.status}) in state ${l.state.name}, path: ${l.path}. id: ${l._id}`) l.status = "muted" if (typeof l.mute === "function") { cancel_promises.push(Promise.resolve(l.mute())) } } else { this.log.debug(`Cancel listener ${l.plugin} ${l.name} (${l.status}) in state ${l.state.name}, path: ${l.path}. id: ${l._id}`) let plugin = this.game.getPlugin(l.plugin) if (typeof l.cancel === "function") { cancel_promises.push(Promise.resolve(l.cancel())) } else if (typeof plugin.cancel === "function") { cancel_promises.push(Promise.resolve(plugin.cancel(l))) } else { throw new Error("Error when removing " + l.plugin + "." + l.action + " " + l._id + ". No cancel function defined") } this.active_listeners.splice(index, 1)[0] } } else { throw new Error("Couldn't remove " + listener.plugin + "." + listener.action + " " + listener._id + ". listener exists in database only.") } }) await Promise.all(cancel_promises) return canceled_items } /** * cancel all listeners and remove all references to this session (player etc.) * * @returns {Promise} name and level */ quit() { this.canceled = true return new Promise((resolve, reject) => { this.cancel({}, {mute: false}) .then(res => { let removed_references = this.reference_collections.map((coll, index) => { return this.removeReferenceByQuery(coll, { sessions: { $exists: