adaptorex
Version:
Connect all your live interactive storytelling devices and software
1,552 lines (1,403 loc) • 74.9 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 */
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