UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

1,552 lines (1,403 loc) 74.9 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 */ class SessionTopic extends topic.Topic { constructor(game, openapi) { super( { name: "session", coll_name: "sessions", schema: { query: openapi.paths["/game/{game}/session"].get.parameters, path: openapi.components.parameters.session.schema, body: openapi.components.schemas.session }, main_properties: ["name", "level", "level_name", "game"], methods: ["POST", "GET", "DELETE"], client: adaptor.io .of(`/game/${game.name}/live/sessions`) .use(adaptor.auth.authenticateSocket.bind(adaptor.auth)), has_archive: true }, game ) /** list of session instances * @type {Array<Session>} @private */ this.sessions = [] this.router.post("/:entity/_cancel", (req, res) => { this.log.info(`cancel session command received for ${req.params.entity}`) if (!this.valid(req.params.entity, this.schema.path, res, 400)) return this.cancel(req.params.entity) .then((result) => { res.send(result) }) .catch((err) => { this.errorHandling(err, res) }) }) this.router.post("/:entity/_next", (req, res) => { if (!this.valid(req.params.entity, this.schema.path, res, 400)) return //if (!this.valid(req.body, openapi.paths['/game/{game}/session/{session}/_next'].post.requestBody.content.application/json.schema, res, 400)) return try { res.send(this.next(req.params.entity, req.body)) } catch (err) { this.errorHandling(err, res) } }) } /** * loads all sessions from database and initializes them * * @return {Promise<Array<Session>>} an array of session objects */ async reload() { let session_docs = await this.collection.find({}) let load_sessions = session_docs.map((session, index) => { let new_session = this.addSession(session) return new_session.reload() }) return Promise.all(load_sessions) } async create(data, req) { let new_session = await this.createSession(data, req.user) return { created_id: new_session["_id"], created_at: new_session["created_at"], created_by: new_session["created_by"], created_with: new_session["created_with"] } } async get(id, options = {}) { if (options.archived) { return super.get(id, options) } let session_data = await super.get(id, options) let session_instance = this.getSession(session_data._id) if (!session_instance) { throw new Error( "session " + session_data._id + " data exists but has no running instance" ) } let session_references = await session_instance.getAllReferences() session_data["references"] = session_references session_data["current_states"] = await session_instance.getCurrentStates({ session_doc: session_data }) session_data["current_listeners"] = session_instance.getListeners() return session_data } async getMany(query, options = {}) { if (options.archived) { return super.getMany(query, options) } let session_data = await super.getMany(query, options) for (let session_doc of session_data) { let session_instance = this.getSession(session_doc._id) if (!session_instance) { throw new Error( "session " + session_doc._id + " data exists but has no running instance" ) } session_doc["references"] = await session_instance.getAllReferences() session_doc["current_states"] = await session_instance.getCurrentStates({ session_doc: session_doc }) session_doc["current_listeners"] = session_instance.getListeners() // remove history and state data since it is unlimited and might leet to extensive data transfer delete session_doc.history // delete session_doc.state_data } return session_data } /** * synonym for {@link cancel} */ async archive(id) { return this.cancel(id) } /** * Cancel and archive the specified session(s). * * @param {string|Object} id - session _id, name or query * @returns {Promise<string>} number of sessions cancelled and archived */ async cancel(id) { let response = await this.cancelSession(this.getIndexQuery(id)) return response + " sessions cancelled and archived" } async restore(id, origin) { let result = await super.restore(id, origin) let session_doc = await super.get(id) try { let new_session = this.addSession(session_doc) await new_session.reload() } catch (error) { await this.cancel({ _id: session_doc._id }) throw error } this.documentsChanged([await this.get(id)]) return result } async delete(id) { return this.deleteSession(this.getIndexQuery(id)) } next(id, data) { let session = this.findSession("_id", id) if (!session) { session = this.findSession("name", id) } if (!session) { throw new adaptor.NotFoundError(`could not find session ${id}`) } session.next(data, "user interaction") return { next: "dispatched", date: adaptor.now() } } async changed(session_id) { let sessions = await this.getMany({ _id: session_id }) if (!sessions.length) { return } this.client.emit("changed", sessions) } /** * launch a new session based on level * * - fetch level data * - create entry in session collection * - create session class instance with level data and db document instance * - optional: assign arguments to session once its created * - dispatch START state * - socket emit to web editor that a session was created * * @param {Object|string} properties.level - find query object or name of level the session is based on * @param {string} [properties.name] - session name. Has to be unique otherwise createSession rejects. Defaults to unique id * @param {Object} [properties.arguments] - object of arguments that are assigned to the session [session createReference function]{@link Session#createReference } * @param {string} [properties.content] - specify content type (e.g. the language) * @param {Object} user - user data of user that creates session * * @returns {Promise<Session>} Create information (created_id, created_at, created_by, created_with) if successful. Returns "exists" if session with this name already exists */ async createSession(properties, user) { if (properties["name"]) { if (this.findSession("name", properties.name)) { throw new adaptor.DuplicateError( `session with name ${properties.name} already exists` ) } } else { properties["name"] = "session_" + adaptor.createId() } let level_query = this.getIndexQuery(properties.level) let new_session var session_data = {} let level_data = await this.game.db.levels.find(level_query) if (!level_data.length) { throw new adaptor.NotFoundError( `couldn't create session based on ${JSON.stringify(level_query)}. Level not found` ) } session_data = { name: properties.name, level: level_data[0]._id, level_name: level_data[0].name, config: level_data[0].config, content: properties.content, history: [], created_at: adaptor.now(), created_by: user.login, created_with: adaptor.info.version } let inserted = await this.collection.insert(session_data) session_data["_id"] = inserted.insertedId try { new_session = this.addSession(session_data) await new_session.setup(properties["arguments"]) new_session.next({ name: "START" }, "create session") await this.game.db.levels.update(level_query, { $inc: { instances: 1 } }) return new_session } catch (error) { await this.delete(session_data["_id"]) throw error } } /** * make session based on existing db entry * route webview for session control * * store new session in games session array * * install listener for state dispatched events to publish to client * * @param {Object} data - session relevant data to make it work * * @returns {Session} - the newly created session Instance. */ addSession(data) { if (data.name) { var name_convert_spaces = data.name.replace(/ /g, "%20") } else { var name_convert_spaces = data.level + "_" + this.session_count this.session_count++ data.name = name_convert_spaces } /** @type {Session} */ let new_session = new Session(data, this.game) this.sessions.push(new_session) new_session.status.on("state_begin", (e) => { this.changed(data._id) }) new_session.status.on("state_listening", (e) => { this.changed(data._id) }) new_session.status.on("queueing", (e) => { this.changed(data._id) }) return new_session } /** * iterate through local list of sessions and look for matching _id * * @param {string|mongodb.ObjectID} id - id matching the session _id property * * @return {session.Session|undefined} - Session Object on match. Otherwise undefined. */ getSession(id) { for (let i = 0; i < this.sessions.length; i++) { if (this.collection.compareIDs(this.sessions[i]._id, id)) { return this.sessions[i] } } log.warn(this.name, "no session matching id " + id) return undefined } /** * iterate through local list of sessions and look for matching key * * @param {string} key - key to look up * @param {*} property - property to match * * @return {session.Session|undefined} - Session Object on match. Otherwise undefined. */ findSession(key, property) { for (let i = 0; i < this.sessions.length; i++) { if (this.sessions[i][key] == property) { return this.sessions[i] } } return undefined } /** * Delete session from archive. * * If session has ot been archived, cancel the respective session and delete it. * * @param {Object|string} query - mongo find query * * @returns {Promise<string|undefined>} returns count of deleted documents or undefined if none was deleted */ async deleteSession(query) { if (this.has_archive) { let response = await this.archive_collection.delete(query) if (response > 0) { return response + " sessions permanently deleted from archive" } } let response = await this.cancelSession(query, false) return response + " sessions permanently deleted" } /** * Quit the respective sessions and their API topics * * Remove session objects and their database entry and move them to archive collection. * * Publish removed sessions ids via socket. * * @param {Object|string} query - query to find sessions that should be removed. If its a string its queried by 'name' property * * @returns {Promise} true if sessions were found and deleted. NotFoundError if no session could not be found */ async cancelSession(query, archive = true) { if (typeof query === "string") { query = { name: query } } this.log.info(`canceling sessions based on ${JSON.stringify(query)}`) let removed_documents = await this.collection.slice(query) if (removed_documents.length) { let document_ids = [] for (let document of removed_documents) { let session = this.getSession(document._id) await session.quit() await this.game.db.levels.update( { _id: session.level }, { $inc: { instances: -1 } } ) for (let i = this.sessions.length - 1; i >= 0; --i) { if (this.collection.compareIDs(this.sessions[i]._id, session._id)) { this.sessions.splice(i, 1) } } if (archive) { document.archived = true document.archived_at = adaptor.now() await this.archive_collection.insert(document) this.log.info( "canceled and archived session " + session.name + " based on level " + session.level_name ) } else { this.log.info( "canceled and deleted session " + session.name + " based on level " + session.level_name ) } document_ids.push(document._id) } this.documentsRemoved(document_ids) return removed_documents.length } else if (adaptor.isEmpty(query)) { this.log.info("No session to delete") return 0 } else { throw new adaptor.NotFoundError( `session ${JSON.stringify(query)} could not be found` ) } } async getCLQuery(input) { if (input.length == 2) { if (isNaN(input[1])) { if (input[1] == "all") { return {} } else { return input[1] } } else { let docs = await this.getMany({}) if (Number(input[1]) < docs.length) { return docs[Number(input[1])]._id } else { this.log.error("index " + input[1] + " not in range") return } } } else if (input.length == 3) { let query = {} query[input[1]] = input[2] return query } else { this.log.error("wrong number of arguments") return } } command(input) { if (!input.length) { this.logDocuments( {}, (doc) => `level: ${doc.level_name}, references: ${Object.entries( doc.references ) .map((ref) => `${ref[0]} (${ref[1][0].name || ref[1][0]._id})`) .join(", ")}` ) return } switch (input[0]) { case "list": this.logDocuments( {}, (doc) => `level: ${doc.level_name}, references: ${Object.entries( doc.references ) .map((ref) => `${ref[0]} (${ref[1][0].name || ref[1][0]._id})`) .join(", ")}` ) break case "archive": if (input.length == 1) { this.logDocuments( { archived: true }, (doc) => `archived ${Input.since(doc.archived_at)}, level: ${doc.level_name}` ) } else { super.command(input) } break case "cancel": this.getCLQuery(input) .then((query) => { if (query) { Input.confirmation("cancel", JSON.stringify(query), () => this.cancel(query) ) } }) .catch((error) => this.log.error(error.message)) break case "launch": case "create": if (input.length >= 2) { let i_args = [] let args = {} let session_name if (input.length % 2) { session_name = input[2] i_args = input.slice(3) } else { i_args = input.slice(2) } for (let i = 0; i < i_args.length; i += 2) { args[i_args[i]] = { value: i_args[i + 1] } } if (session_name || Object.keys(args).length) { this.log.info( `Launch session based on level ${input[1]} with name "${session_name ? session_name : "auto generated"}" and arguments ${JSON.stringify(args)}` ) } else { this.log.info(`Launch session based on level ${input[1]}`) } this.createSession( { level: input[1], name: session_name, arguments: args }, { login: adaptor.userInfo().username } ).catch((err) => { this.log.error(err.message) }) } else { this.log.warn("create session requires at least 1 argument.") } break default: let session = this.findSession("name", input[0]) if (session) { input.shift() session.command(input) return } else if (!isNaN(input[0])) { if (this.sessions.length > input[0] && input[0] >= 0) { this.sessions[input.shift()].command(input) } else { this.log.error("session index " + input[0] + " not in range") } } super.command(input) break } } } /** * @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 /** Database document connector for level this session is based on * @type {Document} */ this.leveldoc /** 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) { await this.loadDocuments() 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.loadDocuments() 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") } } /** * create reference to session and level documents in database * * if either of the documents does not exist, an error is thrown. * * @throws {adaptor.NotFoundError} if session document or level document does not exist */ async loadDocuments() { try { this.doc = await this.game.db.sessions.document({ _id: this._id }) } catch (error) { if (error instanceof adaptor.NotFoundError) { throw new adaptor.NotFoundError( `Failed to load session ${this.name}. Session document ${this._id} does not exist.` ) } throw error } try { this.leveldoc = await this.game.db.levels.document({ _id: this.level }) } catch (error) { if (error instanceof adaptor.NotFoundError) { throw new adaptor.NotFoundError( `Failed to load session ${this.name}. Level ${this.level_name} (${this.level}) the session is based on, does not exist.` ) } throw error } } /** * 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") { l