UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

1,558 lines (1,382 loc) 45.2 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") /** @typedef {import('./mongo').Collection} DatabaseCollection */ 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, has_archive: 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) }) }) this.router.post("/:entity/revision", (req, res) => { if ( !this.valid( req.body, openapi.components.schemas.level.properties.revision, res, 400 ) ) return this.createRevision(req.params.entity, req.body, req) .then((result) => { if (result) { res.send(result) } }) .catch((err) => { this.errorHandling(err, res) }) }) this.router.get("/:entity/revision", (req, res) => { if (!this.valid(req.query, this.schema.query, res, 400)) return let options = this.getOptions(req) this.findRevisions(req.params.entity, req.query, options) .then((result) => { if (result) { res.send(result) } }) .catch((err) => { this.errorHandling(err, res) }) }) this.router.post("/:entity/revision/_delete", (req, res) => { let options = this.getOptions(req) this.findAndDeleteRevisions(req.params.entity, req.query, options) .then((result) => { if (result) { res.send(result) } }) .catch((err) => { this.errorHandling(err, res) }) }) this.router.get("/:entity/revision/:id", (req, res) => { this.getRevision(req.params.entity, parseInt(req.params.id)) .then((result) => { if (result) { res.send(result) } }) .catch((err) => { this.errorHandling(err, res) }) }) this.router.delete("/:entity/revision/:id", (req, res) => { this.deleteRevision(req.params.entity, parseInt(req.params.id)) .then((result) => { if (result) { res.send(result) } }) .catch((err) => { this.errorHandling(err, res) }) }) this.router.post("/:entity/revision/:id/_restore", (req, res) => { this.restoreRevision(req.params.entity, parseInt(req.params.id), req) .then((result) => { if (result) { res.send(result) } }) .catch((err) => { this.errorHandling(err, res) }) }) } /** * Setup the topic, including adding the history collection and creating level clients. * * @returns {Promise<void>} Resolves when the setup is complete. */ async setup() { await super.setup() const history_coll_name = this.coll_name + "_history" if (!this.game.db.hasOwnProperty(history_coll_name)) { await this.game.db.addCollection(history_coll_name) } /** @type {DatabaseCollection} */ this.history_collection = this.game.db[history_coll_name] 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 archive(id) { let level = await this.get(id) try { await this.game.deleteSession({ level: level._id }) } catch (error) { if (error instanceof adaptor.NotFoundError) { this.log.debug("No session found to delete") } else { throw error } } let delete_revisions_result = await this.findAndDeleteRevisions( level._id, {} ) this.log.info(delete_revisions_result) let result = await super.archive(id) if (result) { this.removeClient(level._id) } return result } async restore(id, origin) { let result = await super.restore(id, origin) this.addClient({ _id: result.created_id }) return result } async createRevision(level_id, data, origin) { let level_doc = await this.get(level_id) let duplicate = await this.history_collection.find({ "revision.original_level_id": level_doc._id, "revision.name": data.name }) if (duplicate.length) { throw new adaptor.DuplicateError( `Revision with name "${data.name}" already exists for level "${level_doc.name}"` ) } if (!data) { data = {} } let latest_doc = await this.history_collection.find( { "revision.original_level_id": level_doc._id }, { sort: { "revision.id": -1 }, limit: 1 } ) if (!latest_doc.length) { data.id = 1 } else { data.id = latest_doc[0].revision.id + 1 } data.created_at = adaptor.now() data.created_by = origin.user.login data.original_level_id = level_doc._id if (!data.name) { data.name = data.created_by + "_" + data.id } level_doc["revision"] = data delete level_doc._id let insert = await this.history_collection.insert(level_doc) data.created_id = insert.insertedId if (origin.originalUrl) { data["created_path"] = origin.originalUrl + "/" + data.id } return data } async getRevision(level_id, revision_id) { let level_doc = await this.get(level_id) let index_query = { "revision.original_level_id": level_doc._id, "revision.id": revision_id } let docs = await this.history_collection.find(index_query) if (!docs.length) { throw new adaptor.NotFoundError( `Revision ${revision_id} for level ${level_id} was not found` ) } return docs[0] } async findRevisions(level_id, query, options) { let level_doc = await this.get(level_id) for (let key in query) { query["revision." + key] = query[key] delete query[key] } query["revision.original_level_id"] = level_doc._id if (options) { for (let opt in options.sort) { options.sort["revision." + opt] = options.sort[opt] delete options.sort[opt] } } let docs = await this.history_collection.find(query, options) return docs } async restoreRevision(level_id, revision_id, origin) { let revision_doc = await this.getRevision(level_id, revision_id) let original_id = revision_doc.revision.original_level_id delete revision_doc.revision delete revision_doc._id await this.createRevision( level_id, { description: `Before restoring revision ${revision_id} by ${origin.user.login}` }, origin ) await this.collection.replace(this.getIndexQuery(level_id), revision_doc) revision_doc._id = original_id this.broadcast( this.level_clients[original_id], "revision_restored", revision_doc, origin ) return revision_doc } async deleteRevision(level_id, revision_id) { let revision_doc = await this.getRevision(level_id, revision_id) let response = await this.history_collection.delete({ _id: revision_doc._id }) if (response == 0) { return } else { return response + " revisions deleted for level " + level_id } } async findAndDeleteRevisions(level_id, query, options) { let del_revisions = await this.findRevisions(level_id, query, options) if (!del_revisions.length) { return "0 revisions deleted for level " + level_id } let response = await this.history_collection.delete({ _id: { $in: del_revisions.map((d) => d._id) } }) return response + " revisions deleted for level " + level_id } /** * 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() } } } /** * Creates a socket.io client for each level in the collection. * * @return {Promise} Resolves when all clients have been created. */ 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}]]`) } } } } command(input) { if (input.length <= 1) { super.command(input) return } switch (input[1]) { case "revision": case "revisions": case "history": if (input.length == 2) { this.findRevisions(input[0], {}, { sort: { created_at: 1 } }) .then((result) => { result.forEach((doc, i) => { log.info( doc.revision.id, `${doc.revision.name} (${doc.revision.description || "no description"}) created ${Input.since(doc.revision.created_at)}, by ${doc.revision.created_by}` ) }) }) .catch((error) => this.log.error(error.message)) } else if (input.length == 3) { if (input[2] == "count") { this.findRevisions(input[0], {}).then((result) => { this.log.info(`${input[0]} has ${result.length} revision(s)`) }) } else if (input[2] == "create") { this.createRevision( input[0], {}, { user: { login: adaptor.userInfo().username } } ) .then((res) => this.log.info( `Created revision ${res.id} (${res.name}) for ${input[0]}` ) ) .catch((error) => this.log.error(error.message)) } else if (input[2] == "clear") { Input.confirmation("delete", `all revisions for ${input[0]}`, () => this.findAndDeleteRevisions(input[0], {}) ) } else if (input[2] == "list") { this.findRevisions(input[0], {}, { sort: { created_at: 1 } }).then( (result) => { result.forEach((doc, i) => { log.info( doc.revision.id, `${doc.revision.name} (${doc.revision.description} || "no description") created ${Input.since(doc.revision.created_at)}, by ${doc.revision.created_by}` ) }) } ) } else { this.log.info( "No such level history command '" + input[2] + "' or wrong number of arguments" ) } } else if (input.length == 4) { if (input[2] == "create") { this.createRevision( input[0], { name: input[3] }, { user: { login: adaptor.userInfo().username } } ) .then((res) => this.log.info( `Created revision ${res.id} (${res.name}) for ${input[0]}` ) ) .catch((error) => this.log.error(error.message)) } else if (input[2] == "clear") { Input.confirmation( "delete", `all except the ${input[3]} latest revisions for ${input[0]}`, () => this.findAndDeleteRevisions( input[0], {}, { sort: { id: -1 }, skip: input[3] } ) ) } else if (input[2] == "delete") { Input.confirmation( "delete", `revision ${input[3]} for ${input[0]}`, () => this.deleteRevision(input[0], parseInt(input[3])) ) } else if (input[2] == "restore") { Input.confirmation( "restore", `revision ${input[3]} for ${input[0]}`, () => this.restoreRevision(input[0], parseInt(input[3]), { user: { login: adaptor.userInfo().username } }) ) } else { this.log.info( "No such level history command '" + input[2] + "' or wrong number of arguments" ) } } break default: super.command(input) break } } } 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) { 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.broadcast(this.level_clients[level._id], "state_update", data, req) 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) { 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.broadcast(this.level_clients[level._id], "state_update", data, req) 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.broadcast( this.level_clients[level_doc[0]._id], "state_deleted", { id: id }, req ) 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)) { if (!latest_actions[action]) { throw new adaptor.InvalidError( `Try to add new action ${action} in level ${this.original_level.name} but there is no corresponding action payload. Please reload the editor content.` ) } 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 }