adaptorex
Version:
Connect all your live interactive storytelling devices and software
1,301 lines (1,165 loc) • 55.8 kB
JavaScript
/**
* 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: