UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

979 lines (845 loc) 36.7 kB
/** * Provide access to and serve updates for level data. * * @requires topic * * @module game * @copyright Lasse Marburg 2021 * @license MIT */ const topic = require("./topic.js") class Level extends topic.Topic { constructor(openapi, game) { super({ name:"level", coll_name:"levels", schema:{query:openapi.paths['/game/{game}/level'].get.parameters, path:openapi.components.parameters.level.schema, body:openapi.components.schemas.level}, default:openapi.components.schemas.level.default, main_properties:["name","config"], methods:["POST","GET","PUT","EDIT","DELETE"], uniqueNames: true, client:game.client, changes:true }, game) this.level_clients = {} this.router.get('/:entity/variables', (req, res) => { if (!this.valid(req.params.entity, this.schema.path, res, 400)) return this.getVariables(req.params.entity) .then(result => { if (result) { res.send(result) } else { res.status(404).send({name:"NotFoundError", message:`${this.name} ${req.params.entity} was not found`}) } }) .catch(err => { this.errorHandling(err, res) }) }) } async setup() { await super.setup() await this.createClients() } async create(data, req) { let result = await super.create(data, req) this.addClient({_id:result.created_id}) return result } async edit(id, operator, data, req) { let changes = await super.edit(id, operator, data, req) let args_changed = false if(typeof data === "object" && data !== null) { for(let d in data) { if(d.includes('arguments')) { args_changed = true } } } if(changes.changed && args_changed) { let level = await this.get(id) this.level_clients[level._id].emit('variables', await this.getVariables(level._id)) } return changes } async delete(id) { let level = await this.get(id) let result = await super.delete(id) if(result) { this.removeClient(level._id) } return result } /** * Publish level preview if any main property (name or config) changed. * * @param {Object} change */ documentChanges(change) { if(change.operationType == "update" && change.hasOwnProperty("updateDescription") && change.updateDescription.hasOwnProperty("updatedFields")) { if(Object.keys(change.updateDescription.updatedFields).some(field => this.main_properties.includes(field))) { this.publish() } } } async createClients() { let level_docs = await this.collection.find({}) for(let level of level_docs) { this.addClient(level) } } addClient(level) { let client = adaptor.io.of(`/game/${this.game.name}/level/${level._id}`) client.use(adaptor.auth.authenticateSocket.bind(adaptor.auth)) client.on('connection', async socket => { let user = socket.request.user.login log.trace(this.log_name, `User session ${user} ${socket.id} joins level ` + level._id) const all_sockets = await client.fetchSockets() // Emit initial info about other user sessions in this level let state_busy_info = [] for (const sock of all_sockets) { if(sock.id != socket.id) { socket.emit('user_joined', this.getUserSession(sock)) for (const room of sock.rooms) { if (room !== sock.id && !state_busy_info.includes(room)) { socket.emit('state_busy', {state:room, user_session:this.getUserSession(sock)}) state_busy_info.push(room) } } } } socket.broadcast.emit('user_joined', this.getUserSession(socket)) socket.on('enter_state', async state => { log.trace(this.log_name, socket.id + " " + user + " enter state " + state) this.leaveStates(socket, client) const ids = Array.from(await client.in(state).allSockets()) socket.join(state) if(!ids.length) { socket.broadcast.emit('state_busy', {state:state, user_session:this.getUserSession(socket)}) } }) socket.on('leave_state', () => { this.leaveStates(socket, client) }) socket.on('user_interaction', data => { socket.broadcast.emit('user_interaction', {user_session:this.getUserSession(socket), payload:data}) }) socket.on('disconnecting', reason => { this.leaveStates(socket, client) socket.broadcast.emit('user_left', this.getUserSession(socket)) }) }) this.level_clients[level._id] = client log.debug(this.log_name, `Level client namespace added: ${client.name}`) } getUserSession(socket) { return {user:socket.request.user.login, socket:socket.id} } async leaveStates(socket, client) { for (const room of socket.rooms) { if (room !== socket.id) { socket.leave(room) log.trace(this.log_name, `${socket.request.user.login} ${socket.id} leaves state ${room}`) const ids = Array.from(await client.in(room).allSockets()) if(!ids.length) { socket.broadcast.emit("state_clear", room) } else { socket.to(ids[0]).emit("state_clear", room) socket.emit("state_busy", {state:room, user_session:{socket:ids[0]}}) } } } } removeClient(level_id) { let client = this.level_clients[level_id] log.debug(this.log_name, `Remove Level client namespace: ${client.name}`) client.disconnectSockets(true) delete this.level_clients[level_id] } /** * Push variables update for all level clients */ async updateVariables() { for (let client in this.level_clients) { let variables = await this.getVariables(client) this.level_clients[client].emit('variables', variables) } } /** * Collect all variables that are accessible in level context * Separated in Categories: * - level_attributes * - level_arguments * - plugin_items * - action_data * - functions * * Format for each variable is '[[Path.to.variable]]' * * @param {string} level - level name or _id * @returns {Promise<object>} variables useable in level context */ async getVariables(level) { let level_doc = await this.get(level) if(!level_doc) { throw new adaptor.NotFoundError(`level ${level} was not found`) } let variables = { level_attributes:[], level_arguments:[], plugin_items:[], action_data: [], functions:[] } // Level Attributes variables.level_attributes.push('[[level.name]]','[[level._id]]','[[level.instances]]') for(let attribute in level_doc.config) { if(attribute == "arguments") { continue } variables.level_attributes.push(`[[level.${attribute}]]`) } // Level Arguments for(let arg in level_doc.config.arguments) { variables.level_arguments.push(`[[${arg}]]`) let arg_coll = level_doc.config.arguments[arg]['type'] || level_doc.config.arguments[arg]['collection'] if(["string","number","array","object"].includes(arg_coll)) { continue } if(arg_coll) { let data_item = {} data_item[arg] = await this.game.getCollectionSchema(arg_coll) this.schemaToVariable(data_item[arg], variables.level_arguments, arg) } } // Plugin Items let plugin_setups = await this.game.getAllPluginSetups() for(let plugin of plugin_setups) { if(plugin.items) { variables.plugin_items.push(`[[${plugin.name}]]`) for(let coll in plugin.items) { if(plugin.items[coll].items.length) { variables.plugin_items.push(`[[${plugin.name}.${coll}]]`) for(let item of plugin.items[coll].items) { variables.plugin_items.push(`[[${plugin.name}.${coll}.${item.name}]]`) for(let prop in item) { if(!["created_at","created_by","created_with","modified_at","modified_by","modified_with"].includes(prop)) variables.plugin_items.push(`[[${plugin.name}.${coll}.${item.name}.${prop}]]`) } } } } } } // Action Data let action_variables = await this.game.getActionVariables() variables.action_data = this.getActionData(level_doc, action_variables) // Functions let functions = this.game.getFunctions() variables.functions.push(`[[functions]]`) for(let funct in functions) { variables.functions.push(`[[functions.${funct}()]]`) } return variables } getActionData(level_doc, action_variables) { let action_data = ['[[state]]'] for(let state in level_doc.states) { //action_data.push(`[[state.${level_doc.states[state].name}.date]]`) let index = action_data.length for(let run of level_doc.states[state].run) { if(level_doc.actions[run]) { this.mergeActionData(level_doc.actions[run], level_doc.states[state], action_variables, action_data) } } for(let listen of level_doc.states[state].listen) { if(level_doc.actions[listen]) { this.mergeActionData(level_doc.actions[listen], level_doc.states[state], action_variables, action_data) } } if(index < action_data.length) { action_data.splice(index, 0, `[[state.${level_doc.states[state].name}]]`) } } return action_data } schemaToVariable(schema, paths=[], path) { if(schema.type == 'object' && schema.properties) { for (const property in schema.properties) { let sub_path = "" if(path) { sub_path = `${path}.${property}` } else { sub_path = property } paths.push(`[[${sub_path}]]`) this.schemaToVariable(schema.properties[property], paths, sub_path) } } } mergeActionData(action, state, action_variables, action_data, hasActionData) { if(action_variables.hasOwnProperty(action['plugin'])) { if(action_variables[action.plugin].hasOwnProperty(action.action)) { action_data.push(`[[state.${state.name}.${action.name}]]`) for(let data in action_variables[action.plugin][action.action]) { action_data.push(`[[state.${state.name}.${action.name}.${data}]]`) } } } } } class State extends topic.Topic { constructor(openapi, game, level) { super({ name:"state", coll_name:"levels", schema:{query:{}, path:openapi.components.parameters.state.schema, body:openapi.components.schemas.level}, main_properties:[], methods:["POST","PUT","DELETE"] }, game) this.getVariables = level.getVariables.bind(level) this.level_clients = level.level_clients } /** * Update or create states and their corresponding actions and contents * * Emits update message to connected clients in the respective level. * Emits updated variables to connected clients if actions where added or removed * * @param {Object} data - state data elements * @param {Object} data.states - states that should be created or updated * @param {Object} data.actions - corresponding actions that should be created or updated * @param {Object} data.states - corresponding contents that should be created or updated * @param {Object} req - express api request object * @returns {Promise<object>} - changes as returned by Topic.reportChanges() function */ async create(data, req) { if(!data.states) { throw new adaptor.InvalidError('Can not create state. states property missing') } else if(!Object.keys(data.states).length) { throw new adaptor.InvalidError('Can not create state. No state in level states') } let query = this.getIndexQuery(req.params.level) let level_docs = await this.collection.find(query) let level = level_docs[0] let update = new UpdateQuery(level) for(let state in data.states) { update.stateUpdate(state, data.states[state], data.actions, data.contents) } if(adaptor.isEmpty(update.query)) { this.log.trace(`No changes applied on update request`) return {changed: false} } this.log.trace(`Update state ${JSON.stringify(update.query)}`) let updatedLevel = await this.collection.findOneAndUpdate(query, update.query, {returnDocument:"after"}) let changes = await this.reportChanges(query, update, updatedLevel, req.user) if(changes.changed) { // log.debug(this.log_name, `${this.level_clients[level._id].name} Emit to all except room ${Object.keys(data.states)[0]}`) // this.level_clients[level._id].except(Object.keys(data.states)[0]).emit('state_update', data) if(changes.path_update) { for(let state in data.states) { if(changes.path_update[state]) { data.states[state].path = changes.path_update[state].path } else { data.states[state].path = [] } } } this.level_clients[level._id].emit('state_update', data) if(!adaptor.isEmpty(update.changes.inserted.actions) || update.changes.removed.actions.length || !adaptor.isEmpty(update.changes.inserted.states)) { this.level_clients[level._id].emit('variables', await this.getVariables(level._id)) } } return changes } /** * Update a state and it's corresponding actions and contents * * Emits update message to connected clients in the respective level. * Emits updated variables to connected clients if actions where added or removed * * @param {string} id - state id * @param {Object} data - state data elements * @param {Object} data.state - state element that should be created or updated * @param {Object} data.actions - corresponding actions that should be created or updated * @param {Object} data.contents - corresponding contents that should be created or updated * @param {Object} req - express api request object * @returns {Promise<object>} - changes as returned by Topic.reportChanges() function */ async update(id, data, req) { let query = this.getIndexQuery(req.params.level) let level_docs = await this.collection.find(query) let level = level_docs[0] let update = new UpdateQuery(level) update.stateUpdate(id, data.state, data.actions, data.contents) if(adaptor.isEmpty(update.query)) { this.log.trace(`No changes applied on update request`) return {changed: false} } let updatedLevel = await this.collection.findOneAndUpdate(query, update.query, {returnDocument:"after"}) let changes = await this.reportChanges(query, update, updatedLevel, req.user) if(changes.changed) { // this.level_clients[level._id].except(data.state).emit('state_update', data) data["states"] = {} data.states[id] = data.state delete data.state if(changes.path_update) { if(changes.path_update[id]) { data.states[id].path = changes.path_update[id].path } else { data.states[id].path = [] } } this.level_clients[level._id].emit('state_update', data) if(!adaptor.isEmpty(update.changes.inserted.actions) || update.changes.removed.actions.length || !adaptor.isEmpty(update.changes.inserted.states)) { this.level_clients[level._id].emit('variables', await this.getVariables(level._id)) } } return changes } /** * Delete a state and it's corresponding actions and contents * * Emits update message to connected clients in the respective level. * Emits updated variables to connected clients * * @param {string} id - state id * @param {Object} req - express api request object * @returns {Promise<object>} - changes as returned by Topic.reportChanges() function */ async delete(id, req) { let unset = {} unset['states.' + id] = "" let query = this.getIndexQuery(req.params.level) let level_doc = await this.collection.find(query) if(!level_doc.length) { throw new adaptor.NotFoundError(`level not found ${JSON.stringify(query)}`) } if(!level_doc[0].states[id]) { throw new adaptor.NotFoundError(`state ${id} not found in level ${JSON.stringify(query)}`) } let update = new UpdateQuery(level_doc[0]) if(level_doc[0].states[id].run) { for(let action of level_doc[0].states[id].run) { if(level_doc[0].actions[action]) { unset['actions.' + action] = "" let content_ids = [] update.getContentIds(level_doc[0].actions[action].payload, content_ids) for(let content_id of content_ids) { unset['contents.' + content_id] = "" } } } } if(level_doc[0].states[id].listen) { for(let action of level_doc[0].states[id].listen) { if(level_doc[0].actions[action]) { unset['actions.' + action] = "" let content_ids = [] update.getContentIds(level_doc[0].actions[action].payload, content_ids) for(let content_id of content_ids) { unset['contents.' + content_id] = "" } } } } let updatedLevel = await this.collection.findOneAndUpdate(query, {$unset:unset}, {returnDocument:"after"}) update.path_update_required = true let changes = await this.reportChanges(query, update, updatedLevel, req.user) // this.level_clients[level._id].except(data.state).emit('state_update', data) this.level_clients[level_doc[0]._id].emit('state_deleted', {id:id}) this.level_clients[level_doc[0]._id].emit('variables', await this.getVariables(level_doc[0]._id)) return changes } /** * Add update information to document * * update state paths if any action was added, removed or updated * * @todo use update object to filter if path update is necessary or could be skipped * * @param {Object} query * @param {UpdateQuery} update * @param {Object} level * @param {Object} user * @returns {Object} */ async reportChanges(query, update, level, user) { let changes = {} changes['modified_at'] = adaptor.now() changes['modified_by'] = user.login changes['modified_with'] = adaptor.info.version Object.assign(changes, update.changes) if(update.path_update_required) { let pathUpdate = {} let pathDocumentUpdate = {} let pathUpdater = new PathUpdater(level) pathUpdate = pathUpdater.getPaths() let pathsChanged = false for(let state in level.states) { if(!pathUpdate[state]) { if(level.states[state].path && level.states[state].path.length) { pathDocumentUpdate['states.' + state + '.path'] = [] pathsChanged = true } continue } pathDocumentUpdate['states.' + state + '.path'] = pathUpdate[state].path if(!level.states[state].path) { pathsChanged = true continue } if(level.states[state].path.length !== pathUpdate[state].path.length) { pathsChanged = true } if (!pathUpdate[state].path.every((val, index) => val === level.states[state].path[index])) { pathsChanged = true } } if(pathsChanged) { let existing_paths = pathUpdater.getExistingPaths() pathUpdate['paths'] = existing_paths pathDocumentUpdate['paths'] = existing_paths this.level_clients[level._id].emit('path_update', pathUpdate) await this.collection.set(query, Object.assign(changes, pathDocumentUpdate)) changes['path_update'] = pathUpdate this.log.trace("Paths have changed. Current paths: " + JSON.stringify(existing_paths)) } else { await this.collection.set(query, changes) } } else { await this.collection.set(query, changes) } changes['changed'] = true return changes } } class PathUpdater { constructor(level) { this.level = level this.pathRecord = {} } /** * Make path for each state based on current level version * * @returns {Object} - state items with current path field: `{state_0:{path:[start,sidequest]}}` */ getPaths() { this.pathUpdate() return this.pathRecord } getState(state_name) { if(!this.level.states) { return undefined } for(let state in this.level.states) { if(this.level.states[state].name == state_name) { this.level.states[state].id = state return this.level.states[state] } } return undefined } addPath(state_id, path) { if(!this.pathRecord[state_id]) { this.pathRecord[state_id] = {path:[]} } if (Array.isArray(path)) { this.pathRecord[state_id].path = [...new Set([...this.pathRecord[state_id].path, ...path])] return true } else if (typeof path === 'string') { if (this.pathRecord[state_id].path.indexOf(path) === -1) { this.pathRecord[state_id].path.push(path) return true } } return false } /** * Get all path lists that exist. Always call after 'getPaths' * * @returns {array<array>} list of all path combination lists that exist in this setup */ getExistingPaths() { let existingPaths = [] Object.values(this.pathRecord).forEach(state => { let exists = existingPaths.some(path_list => { return state.path.every(path => { if(path_list.includes(path)) { return true } }) }) if(!exists) { existingPaths.push(state.path) } }) return existingPaths } getNext(obj = this.data, keyword = "next", result = []) { for (let key in obj) { let val = obj[key] if (key == keyword) { if(Array.isArray(val)) { result.push(...val) } else { result.push(val) } } if (val !== null && typeof (val) == 'object') { this.getNext(val, keyword, result) // traverse deeper in obj } } return result } /* * State Path lists follow these rules * * a) First State (START) has the fixed entry 'start' in it's list * b) States extend the path list of any preceding state that is connected via next * c) If a state has more than one preceding state it obtains the merged path list of it's preceding states. Duplicate path entries are omitted. * d) Though if the (shorter) path list of a preceding state is fully contained in a path list of another preceding state the longer path list will not be added to the current path. This prevents recursive paths whenever states connect to their ancesters. * e) If a state contains a join action the path list that is supposed to be joined/closed will be merged to the current states path list (see c) * f) If a preceding state contains a split action, a new unused entry is added to the states path list. * g) All unconnected States will not get a path entry. * * original rules version in german: * a) Der erste Node (START) hat einen Pfad eintrag in der Liste (main) * b) Nodes erben jeweils die Pfad Liste des Vorangehenden nodes, der per next angeschlossen ist * c) Hat ein Node mehrere Vorgänger erhält er die zusammengeführten Pfad listen der Vorgänger. Gleiche Einträge werden zusammengefasst, sodass es keine Multiplikate in der Liste gibt (merge) * d) Wenn eine kürzere Liste der Vorgänger Nodes in einer längeren Liste eines Vorgänger nodes enthalten ist, wird die längere Liste dem Node Pfad nicht hinzugefügt. Das verhindert rekursive Pfade, wenn Nodes auf vorgänger nodes weisen. * e) Enthält ein Node join, wird die Pfad Liste des node der beendet werden soll in die Pfadliste aufgenommen wie in c). * f) Enthält ein vorangehender Node split, wird ein neuer, bisher ungenutzter Eintrag zur Pfad liste hinzugefügt. Ggf. kann der Eintrag in split spezifiziert werden. */ pathUpdate(parent=this.getState('START'), visited = []) { if (!parent) return log.error("State Update", "no parent to update path") if (parent.name == 'START') { this.addPath(parent.id, 'start') // Regel a) // parent.path = ['start'] } let actions = parent.run.concat(parent.listen) // check if current state (parent) has "join" or "split" action // from parent walk through every child and its children and transmit paths actions.forEach(action_id => { let action = this.level.actions[action_id] if(action.action == "join") { this.addPath(parent.id, action.payload) // Regel e) return } if(action.action == "split") { action.payload.forEach(split => { const child = this.getState(split.next) if (child) this.addPath(child.id, split.name) // Regel b) }) } const ancestors = [...visited] // Teil von Regel d) for (const name of [...new Set(this.getNext(action.payload, "next"))]) { visited = [...ancestors] // Teil von Regel d). overwrite visited when we jump back from children of child const child = this.getState(name) if (child && child.name != parent.name && !ancestors.includes(name)) { // Regel d) this.addPath(child.id, this.pathRecord[parent.id].path) // Regel c) visited.push(name) this.pathUpdate(child, visited) } } }) } } class UpdateQuery { constructor(level) { this.original_level = level this.query = {} /** * indicates if any changes will be applied * @type {boolean} */ this.changed = false /** * @typedef {Object} LevelElements * @property {Object} states - affected states * @property {Object} actions - affected actions * @property {Object} contents - affected contents */ /** * Description of changes the update query results in. * @typedef {Object} Changes * @property {LevelElements} inserted - Elements that have been newly created * @property {LevelElements} updated - Elements that have been changed * @property {{states: Array<string>, actions: Array<string>, contents: Array<string>}} removed - Elements that have been removed */ /** @type {Changes} */ this.changes = { inserted: { states:{},actions:{},contents:{} }, updated: { states:{},actions:{},contents:{} }, removed: { states:[],actions:[],contents:[] } } /** * Indicates if changes will be applied that might require a modification * of state path entries * @type {boolean} */ this.path_update_required = false } replace(type, id, data, original, path_affecting_property) { if(original) { for(let prop in data) { if(!prop[data] && JSON.stringify(data[prop]) === JSON.stringify(original[prop])) { continue } if(!this.changes.updated[type][id]) { this.changes.updated[type][id] = {} } if(prop === path_affecting_property) { this.path_update_required = true } this.changes.updated[type][id][prop] = data[prop] } if(!this.changes.updated[type][id]) { return } } else { this.changes.updated[type][id] = data } if(!this.query.$set) { this.query['$set'] = {} } // this.changes.updated[type][id] = data this.changed = true this.query.$set[type + '.' + id] = data } add(type, id, data, set_path_update_required) { if(set_path_update_required) { this.path_update_required = true } if(!this.query.$set) { this.query['$set'] = {} } this.changed = true this.changes.inserted[type][id] = data this.query.$set[type + '.' + id] = data } remove(type, id, set_path_update_required) { if(set_path_update_required) { this.path_update_required = true } if(!this.query.$unset) { this.query['$unset'] = {} } this.changed = true this.changes.removed[type].push(id) this.query.$unset[type + '.' + id] = "" } /** * compare new state with state in original level * * @param {string} state_id - id of state that shall be updated * @param {Object} state - new state object * @param {Object} actions - new state actions * @param {Object} contents - new state contents */ stateUpdate(state_id, state, actions, contents) { if(!this.original_level.states) { this.original_level['states'] = {} this.add('states', state_id, state, true) } else if (!this.original_level.states[state_id]) { this.original_level.states[state_id] = {run:[], listen:[]} this.add('states', state_id, state, true) } else { this.replace('states', state_id, state, this.original_level.states[state_id], "name") } if(Array.isArray(this.original_level.states[state_id].run)) { if(!Array.isArray(state.run)) { state.run = [] } this.compareActions(this.original_level.actions, actions, this.original_level.states[state_id].run, state.run, contents) } if(Array.isArray(this.original_level.states[state_id].listen)) { if(!Array.isArray(state.listen)) { state.listen = [] } this.compareActions(this.original_level.actions, actions, this.original_level.states[state_id].listen, state.listen, contents) } return this.query } /** * compare new state actions with state actions in original level * * @param {Object} original_actions - Actions from original, pre update level * @param {Object} latest_actions - Actions from latest, post update state * @param {Array<string>} original - list of actions in original state * @param {Array<string>} latest - list of actions in new state * @param {Object} latest_contents - new contents */ compareActions(original_actions, latest_actions, original, latest, latest_contents) { original.forEach(action => { if(!latest.includes(action)) { this.remove('actions', action, true) this.compareContents(original_actions[action], {}, latest_contents) } else if (latest_actions[action]) { this.replace('actions', action, latest_actions[action], original_actions[action], "payload") this.compareContents(original_actions[action], latest_actions[action], latest_contents) } }) latest.forEach(action => { if(!original.includes(action)) { this.add('actions', action, latest_actions[action], true) } }) } /** * compare original and new action version and add or remove contents accordingly * * @param {Object} original - original action * @param {Object} latest - new action * @param {Object} contents - new or latest version of contents */ compareContents(original, latest, contents) { let original_contents = [] this.getContentIds(original.payload, original_contents) let latest_contents = [] this.getContentIds(latest.payload, latest_contents) original_contents.forEach(content => { if(!latest_contents.includes(content)) { this.remove('contents', content) } else if (contents[content]) { this.replace('contents', content, contents[content]) } }) latest_contents.forEach(content => { if(!original_contents.includes(content)) { this.add('contents', content, contents[content]) } }) } /** * iterate over action properties to find contents * recursive function * * @param {Object} action - action payload * @param {Array} content_ids - list of content ids */ getContentIds(action, content_ids=[]) { if (typeof action === 'object') { for (const field in action) { if (typeof action[field] === 'string') { if(action[field].indexOf('_content_') >= 0) { content_ids.push(action[field]) } } this.getContentIds(action[field], content_ids) } } } } module.exports = { Level: Level, State: State }