UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

1,477 lines (1,323 loc) 48.5 kB
/** * allows index.js to create Game Object instances * * @requires events * * @requires file * @requires session * @requires topic * @requires errors * * @module game * @copyright Lasse Marburg 2021 * @license MIT */ const events = require('events') const express = require("express") const file = require("./file.js") const session = require("./session.js") const topic = require("./topic.js") const level_topic = require("./level.js") const {PluginTopic} = require("./plugins/plugin.js") const adaptor_functions = require("./functions.js") const path = require("path") /** @typedef {import('./session').Session} Session */ /** @typedef {import('./topic').Topic} Topic */ /** @typedef {import('./plugins/plugin').Plugin} Plugin */ /** @typedef {import('./mongo').Database} Database */ /** objects that define what plugins and collections are part of which game template. All other templates inherit the default plugins and collections */ const templates = { "default": { "plugins": ["logic", "data", "time", "control"], "collections": ["plugins", "levels", "sessions", "listeners", "setup"] }, "basic": { "plugins": [], "collections": ["players"] }, "installation": { "plugins": [], "collections": ["players"] }, "multiplayer": { "plugins": [], "collections": ["players"] }, "open-world": { "plugins": [], "collections": ["players", "places"] } } /** * @namespace socket.io * @import {Namespace} from "socket.io" */ /** * Main Framework class that inherits sessions and plugins and serves editor webpage * * host global (inter session game wide) events * * @class */ class Game { /** * @param {Object} preferences - config the game is based on * @param {Database} preferences.db - database connection to game database * @param {express.Router} app - Express app router. Routes to /{game} * @param {express.Router} api - Express app router for API requests. Routes to /api/game/{game} * @param {Object} client - io socket Namespace Connections */ constructor(preferences, app, api, client) { Object.assign(this, preferences) /** @type {express.Router} */ this.app = app /** @type {express.Router} @private */ this.api = api /** dictionary of game topics * @type {Object<string, Topic>} */ this.topics = {} /** list of sessions * @type {Array<Session>} @private */ this.sessions = [] /** counts how many sessions were created since game started to allow individual session names * @type {number} @private */ this.session_count = 0 /** dictionary of enabled plugins * @type {PluginTopic} */ this.topics.plugin /** dictionary of available custom and adaptor functions * @type {Object<string, Function} */ this.functions = {} /** event emitter for relevant game incidents or changes * @type {events.EventEmitter} */ this.status = new events.EventEmitter() /** dictionary of DB change stream listeners. The stream listener for each collection * is stored under its respective name. * @type {Object} */ this.changes = {} /** Indicates if game is done loading * @type boolean */ this.ready = false /** setup information about this game collections and plugins * @property {Array<string>} collections - List of collection names that where added to the game * @property {Array<string>} plugins - List of plugin names that where added to the game */ this.setup = { collections: [], plugins: [] } /** eventemitter instance is forwarded to plugins on creation to interact with other sessions * @type {events.EventEmitter} */ this.event = new events.EventEmitter() /** * @namespace socket.io * @import {Namespace} from "socket.io" */ /** socket namespace connection to editor * @type {Namespace} * @private */ this.client = client this.client.on('connect', socket => { log.trace(this.name, `Editor socket connected. User: ${socket.request.user.login}, id: ${socket.id}`) socket.on('disconnect', () => { log.trace(this.name, `Editor socket disconnected. User: ${socket.request.user.login}, id: ${socket.id}`) }) }) /** socket namespace connection to updates on plugin and data/collection items * @type {Namespace} * @private */ this.items_client = adaptor.io.of(`/game/${this.name}/live/items`) file.watchDir(this.files, async (e, file) => { this.client.emit('media', this.getFiles()) }) this.loadFunctions() } /** * Get game properties and functions may be accessed from outside the game class * * @returns {Game} - public game properties and functions */ getAPI() { return { name: this.name, event: this.event, changes: this.changes, status: this.status, data: this.data, files: this.files, url: this.url, files_url: this.files_url, setup: this.setup, topics: this.topics, db: this.db, app: this.app, ready: () => this.ready, addTopic: (topic, name, route) => this.addTopic(topic, name, route), getFilePath: file => this.getFilePath(file), getFileURL: file => this.getFileURL(file), getFunctions: () => this.getFunctions(), addDataCollection: name => this.addDataCollection(name), addPluginCollection: (name, plugin) => this.addPluginCollection(name, plugin), listPluginCollections: () => this.topics.plugin.listCollections(), deleteSession: query => this.deleteSession(query), createSession: (args, user) => this.createSession(args, user), getSession: (id) => this.getSession(id), findSession: (key, property) => this.findSession(key, property), getWebhookURLs: (append_path) => this.getWebhookURLs(append_path), findPlugin: (key, value) => this.findPlugin(key, value), getPlugin: (name) => this.getPlugin(name) } } /** * @returns {Object} game setup Object */ async getSetup() { let setup = await this.db.setup.find({}, { project: { _id: 0 } }) return setup[0] } /** * modify game setup properties * adapts local setup object to changes * * @param {Object} data - setup data to be updated * @returns {Promise<Object>} changed true if game setup has changed */ async editSetup(data) { let updateResponse = await this.setup_doc.set(data) this.setup = await this.setup_doc.get() if (updateResponse.matchedCount == 0) { throw new Error(this.name + " is missing a setup document") } else if (updateResponse.modifiedCount == 0) { return { changed: false } } return { changed: true } } /** * create a new (empty) default Game * - assign database * - create collections based on template. Collections in default template are system collections and do not create a default API topic. * - add plugins based on template * - store setup * * Templates might be a deprecated feature. There is no interesting difference between templates. Current web client constellation will always create new games with "basic" template * * @param {Database} database - refers to database with this.name * @param {string} template - template name, the game build is based on */ build(database, template) { return new Promise((resolve, reject) => { this.db = database let all_collections = [] let all_plugins = [] if (templates.hasOwnProperty(template)) { this.setup = templates[template] this.setup["created_at"] = adaptor.now() this.setup["created_with"] = adaptor.info.version all_collections.push(...templates.default.collections) all_collections.push(...templates[template].collections) all_plugins.push(...templates.default.plugins) all_plugins.push(...templates[template].plugins) } else { throw new Error(template + " is not a valid game template") } let create_collections = all_collections.map((coll, index) => { return this.db.addCollection(coll) }) Promise.all(create_collections) .then(result => { return this.db.setup.insert(this.setup) }) .then(result => { this.setup_doc = this.db.setup.document({}) return this.createTopics() }) .then(result => { let add_plugins = all_plugins.map((plugin, index) => { return this.topics.plugin.create({name:plugin}, { user: { login: adaptor.userInfo().username } }) }) return Promise.all(add_plugins) }) .then(result => { this.status.emit("plugins_loaded") file.mkdir(this.files + '/examples') file.cp(path.join(__dirname, '/examples/files'), this.files + '/examples') log.info(this.name, "game created") this.ready = true return resolve({status:"built",issues:[],errors:[]}) }) .catch(error => { return reject(error) }) }) } /** * resume game, based on existing data on database * - enable plugins stored in plugins collection * - load plugins that were added previously * - add local session objects list based on session Collection entries * - create API topics * * @param {Database} database - refers to database with this.name */ async resume(database) { this.db = database log.info(this.name, "Reload plugins and sessions ...") let issues = [] let errors = [] for (let coll of templates.default.collections) { if (!this.db.hasOwnProperty(coll)) { log.error("Game data is incomplete: " + coll + " collection missing. Create new.") this.db.addCollection(coll) } } this.setup_doc = this.db.setup.document({}) this.setup = await this.setup_doc.get() await this.createTopics() try { let load_plugins = await this.topics.plugin.reload() if(load_plugins.issues && load_plugins.issues.length) { issues = issues.concat(load_plugins.issues) } } catch (error) { log.error(this.name, `Unexpected error while trying to load plugins`) log.error(this.name, error) errors.push(new Error(`Unexpected error while trying to load plugins`, {cause:error})) } this.status.emit("plugins_loaded") let sessions = await this.db.sessions.find({}) try { await this.loadSessions(sessions) } catch (error) { log.error(this.name, `Unexpected error while trying to add session`) log.error(this.name, error) errors.push(new Error(`Unexpected error while trying to add session`, {cause:error})) } this.ready = true return {status:"reloaded",issues:issues,errors:errors} } loadSessions(sessions) { return new Promise((resolve, reject) => { let load_sessions = sessions.map((session, index) => { let new_session = this.addSession(session) return new_session.reload() }) return resolve(Promise.all(load_sessions)) }) } /** * * @param {Topic} topic - The topic instance * @param {*} name - The name the topic will be available via Game Interface * @param {*} route - The API route fro the topic */ addTopic(topic, name, route) { this.api.use(route, topic.router) this.topics[name] = topic } /** * establish API to Game and DB connection for * - level * - plugins * - sessions * - data collections */ async createTopics() { const openapi = adaptor.openapi let topic_setups = [] let level = new level_topic.Level(openapi, { name: this.name, getCollectionSchema: (collection) => this.getCollectionSchema(collection), getActionVariables: () => this.topics.plugin.getActionVariables(), getFunctions: this.getFunctions.bind(this), getAllPluginSetups: async () => { var plugins = await this.db.plugins.find({}) return await this.topics.plugin.getSetups(plugins) }, db: this.db, changes: this.changes, client: this.client }) await level.setup() let state = new level_topic.State(openapi, this.getAPI(), level) topic_setups.push(state.setup()) file.watchDir(this.data + '/functions', (e, file) => { log.debug(this.name, `Change in functions directory: ${e} ${file}`) this.loadFunctions() level.updateVariables() }) this.sessions_client = adaptor.io.of(`/game/${this.name}/live/sessions`) let session = new topic.Topic({ 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", "EDIT"], client: this.sessions_client }, this.getAPI()) topic_setups.push(session.setup()) let plugin = new PluginTopic(this.getAPI(), this.client, this.items_client, openapi) topic_setups.push(plugin.setup()) let collection = new topic.Topic({ name: "collection", coll_name: undefined, schema: { query: {}, path: openapi.components.parameters.collection.schema, body: openapi.components.schemas.collection }, main_properties: [], methods: ["POST", "DELETE"] }, this.getAPI()) topic_setups.push(collection.setup()) let items = new topic.Topic({ name: "items", coll_name: undefined, schema: { query: {}, path: openapi.components.parameters.collection.schema, body: openapi.components.schemas.item }, main_properties: [], cast_query_types_default: true, methods: ["GET"] }, this.getAPI()) topic_setups.push(items.setup()) session.addCustomOperator('next', (id, data) => { let sess = this.findSession("_id", id) if (!sess) { sess = this.findSession("name", id) } if (!sess) { throw new adaptor.NotFoundError(`could not find session ${id}`) } sess.next(data, 'user interaction') return { "next": "dispatched", "date": adaptor.now() } }) session.create = async (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'] } } session.get = async (id) => { if (this.db.validId(id)) { var query = { $or: [{ _id: id }, { name: id }] } } else { var query = { name: id } } let session_data = await this.db.sessions.find(query) if (session_data.length) { let session_instance = this.getSession(session_data[0]._id) if (!session_instance) { throw new Error("session " + session_data[0]._id + " data exists but has no running instance") } let session_references = await session_instance.getAllReferences() session_data[0]["references"] = session_references return session_data[0] } else { return undefined } } session.getMany = async (query, options) => { var session_data = await this.db.sessions.find(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 } session.delete = async (id) => { if (this.db.validId(id)) { var query = { $or: [{ _id: id }, { name: id }] } } else { var query = { name: id } } return this.deleteSession(query) } session.changed = async (session_id) => { let sessions = await session.getMany({ _id: session_id }) if (!sessions.length) { return } session.client.emit('changed', sessions) } collection.create = async (data) => { return this.addDataCollection(data["name"], true) } collection.delete = async (name) => { return this.removeCollection(name) } this.items_client.on('connect', socket => { log.trace(this.name, "items update socket connected " + socket.id) }) /** * Items endpoint will search through all plugin and data collections. * * Adds context information to each item document. * * Sort and limit options are prepended to the database find query. * * @param {Object} query - mongo style find query * @param {*} options - game API GET options like sort, project, limit or skip * @returns {Array<object>} list of documents that match query */ items.getMany = async (query, options) => { let return_items = [] let selection = {} selection.type = query.type selection.collection = query.collection selection.plugin = query.plugin delete query.type delete query.collection if (!selection.type || selection.type == "data") { for (let data_collection of this.setup.collections) { if (!selection.collection || selection.collection == data_collection) { let coll_items = await this.topics[data_collection].getMany(query) coll_items.forEach(item => { item.collection = data_collection item.type = "data" }) return_items = return_items.concat(coll_items) } } } delete query.plugin if (!selection.type || selection.type == "plugin" || selection.plugin) { for (let coll of this.topics.plugin.listCollections()) { let plugin = coll.plugin if (!selection.collection && !selection.plugin || selection.collection == coll.name || selection.plugin == plugin) { let coll_items = await this.topics[coll.collection].getMany(query) coll_items.forEach(item => { item.collection = coll.name item.type = "plugin" item.plugin = plugin }) return_items = return_items.concat(coll_items) } } } if (options.sort) { let sortby = Object.keys(options.sort)[0] let direction = Object.values(options.sort)[0] return_items.sort((a, b) => (a[sortby] > b[sortby]) ? direction : -direction) } if (options.limit || options.skip) { if (options.limit == 0) { options.limit = undefined } return_items = return_items.slice(options.skip || 0, options.limit + (options.skip || 0)) } return return_items } this.api.use('/level', level.router) this.api.use('/level/:level/state', state.router) this.api.use('/session', session.router) this.api.use('/plugin', plugin.router) this.api.use('/collection', collection.router) this.api.use('/items', items.router) Object.assign(this.topics, { level: level, plugin: plugin, session: session, collection: collection, items: items }) for (let coll of this.setup.collections) { this.addDataTopic(coll) topic_setups.push(this.topics[coll].setup()) } await Promise.all(topic_setups) log.debug(this.name, `${Object.keys(this.topics).length} topics added: ${Object.keys(this.topics)}`) } /** * close topic and delete topic object from game * * @param {string} topic - topic name */ removeTopic(topic) { this.topics[topic].close() delete this.topics[topic] } /** * Get a summary of the basic game elements in this game. * * @returns {Object} level, plugins, sessions and collections that are part of this game */ async getElements() { let setup = await this.getSetup() let level = await this.topics.level.preview() let plugins = await this.topics.plugin.preview() let data_collections = setup.collections let sessions = await this.topics.session.preview() let actions = await this.topics.plugin.getActionsPreview({}) return { name: this.name, setup: setup, level: level, plugins: plugins, actions: actions, collections: data_collections, sessions: sessions } } /** * Get a list off all known URLs through which you can access a webhook that is part of this game. * * @param {string} append_path - path to be appended to each known webhook URL (please prepend '/') * @returns {array<{name:string,url:string}>} - List of known webhook URLS for the appended path to the game url */ getWebhookURLs(append_path) { return adaptor.webhooks.map(webhook => { let extended_webhook = Object.assign({}, webhook) extended_webhook.url = `${webhook.url}/${this.name}${append_path}` return extended_webhook }) } async getActions(query, keep_references) { return this.topics.plugin.getActions(query, keep_references) } /** * Get a complete level schema for levels in this game. * Each of this games level must validate against the resulting schema. * Action schemas from active plugins are merged into level actions schema. * * @returns level schema as of this moment. Depends on plugins and their settings */ async getLevelSchema() { let level_schema = adaptor.deepClone(adaptor.openapi.components.schemas.level) level_schema.properties.actions.additionalProperties = { oneOf: [] } let actions = await this.getActions({}) for (let action of actions) { level_schema.properties.actions.additionalProperties.oneOf.push(action.schema) } return level_schema } /** * writes current game data to file * creates folder for game and for each collection. * * @param {string} [path='./export'] - folder to export game files into. Will be created if it doesnt exist. * @param {array} [exp_coll] - what collections to export. ALl collections are exported if not provided */ export(path, exp_coll) { return new Promise((resolve, reject) => { if (!path) { path = './export' } if (!exp_coll) { this.db.listCollections() .then(result => { this.writeCollections(path, result) }) } else { this.writeCollections(path, exp_coll) } }) } /** * follows export function * * @param {string} path - folder to export game to * @param {array} collections - list of collection names */ writeCollections(path, collections) { let get_collections = collections.map((coll, index) => { if (this.db.hasOwnProperty(coll)) { return this.db[coll].find({}) } else { throw new Error(this.name + " has no collection with name " + coll) } }) Promise.all(get_collections, path) .then(res => { file.mkdir(path) file.mkdir(path + '/' + this.name) for (let i = 0; i < res.length; i++) { file.mkdir(path + '/' + this.name + '/' + collections[i]) for (let doc of res[i]) { file.saveJSON(doc, path + '/' + this.name + '/' + collections[i] + '/' + doc.name + '.json') } } }) .catch(err => { log.error(this.name, err) }) } /** * get the correct file path for a file or path that is inside the game files * Will return unchanged if file is an absolute path or if it is a web url (has leading 'http://' or 'https://') * * @param {string} file - the file name or path relative to game files directory * @returns {string} file path relative to game files path */ getFilePath(file) { if (file.startsWith("http://") || file.startsWith("https://")) { return file } if (path.isAbsolute(file)) { return file } return path.join(this.files, file) } /** * get the correct URL for a file or path that is inside the game files * Will return unchanged if file is a web url (has leading 'http://' or 'https://') * * Will throw InvalidError if file is an absolute path * * @param {string} file - the file name or path relative to game files directory * @returns {string} file URL based on adaptor network settings */ getFileURL(file) { if (file.startsWith("http://") || file.startsWith("https://")) { return file } if (path.isAbsolute(file)) { throw new adaptor.InvalidError(`Can not get file URL with absolute path ${file}. File needs to be in ${this.files} directory.`) } return new URL(file, this.files_url + '/') } /** * get a list of all files in the game files directory * * @returns {Promise<array>} - list of files with path relative to game directory */ getFiles() { return this.getPlugin("data").collectFiles(this.files) } /** * Get plugin by plugin name * @param {string} name * @returns {Plugin} */ getPlugin(name) { return this.topics.plugin.plugins[name] } findPlugin(key, value) { return this.topics.plugin.find(key, value) } loadFunctions() { let functions_path = path.join(this.data, 'functions') let directories = file.ls(functions_path, "directories") let files = file.ls(functions_path) for(let dir of directories) { if(dir != "node_modules") { let nested_files = file.ls(path.join(functions_path, dir)).map(f => {return path.join(dir, f)}) files = files.concat(nested_files) } } files = files.filter(file => { if(file.endsWith(".js")) { return true } }) this.functions = Object.assign({}, adaptor_functions) for (let file of files) { let filename = path.join(process.cwd(), this.data, 'functions', file) delete require.cache[require.resolve(filename)] try { const functions_module = require(filename) Object.assign(this.functions, functions_module) } catch (error) { log.error(this.name, "Failed to load user generated functions file " + filename) log.error(this.name, error) delete require.cache[require.resolve(filename)] } } this.status.emit('functions_update', this.functions) } /** * Get all adaptor functions. * * * @returns {Object<function>} - all the games functions from game data */ getFunctions() { return this.functions } /** * Creates a document schema for a data collection from specifications in plugins * and from existing documents. * * @param {string} collection - name of data collection * @returns {Promise<object>} JSON Schema for documents in the collection */ async getCollectionSchema(collection) { let properties = {"name": {"type": "string"}} let data_variables = await this.db.plugins.distinct('schema.data_variables.' + collection, {}) for (let variable of data_variables) { Object.assign(properties, variable) } if (this.db[collection]) { let documents = await this.db[collection].find({}) for (let document of documents) { for (let prop in document) { let type = this.getPropertyType(document[prop]) if (properties[prop] && typeof properties[prop].type === "string" && type != properties[prop].type) { properties[prop].type = [properties[prop].type, type] } else if (properties[prop] && Array.isArray(properties[prop].type) && !properties[prop].type.includes(type)) { properties[prop].type.push(type) } else if (!properties[prop]) { properties[prop] = { type: type } } if (["_id", "created_at", "created_by", "created_with", "modified_at", "modified_by", "modified_with", "sessions"].includes(prop)) { properties[prop].readonly = true properties[prop].options = { "hidden": true} } } } } else { log.warn(this.name, "Problem when creating collection schema for '" + collection + "'. No such collection in Database") } return { type: "object", title: " ", options: { "disable_edit_json":true }, properties: properties } } /** * Get data type differentiating between object and array * * @param {*} value - value of any type * @returns {string} data type of value: object, number, string, boolean, array or undefined */ getPropertyType(value) { if (Array.isArray(value)) { return "array" } else { return typeof value } } /** * add new data collection to database and store it in setup * * add new topic to enable remote access to the collection * * @param {string} coll_name - data collection name * @param {boolean} error_on_duplicate - Throw an error if collection already exists and was already added to game * * @returns {Promise<Object>} db_collection_created true if there wasn't already a collection with coll_name in Database */ async addDataCollection(coll_name, error_on_duplicate) { if (templates.default.collections.includes(coll_name)) { throw new adaptor.ForbiddenError(`name '${coll_name}' is reserved and can not be used for data collections`) } let db_coll_created = false if (!this.db.hasOwnProperty(coll_name)) { await this.db.addCollection(coll_name) db_coll_created = true } if (!this.topics[coll_name]) { this.addDataTopic(coll_name) await this.topics[coll_name].setup() } else { if (!db_coll_created && error_on_duplicate) { throw new adaptor.DuplicateError(`collection ${coll_name} already exists`) } } if (!this.setup.collections.includes(coll_name)) { await this.setup_doc.push({ 'collections': coll_name }) this.setup.collections.push(coll_name) log.info(this.name, "collection added " + coll_name) this.status.emit('collection_added', { name: coll_name, collection: coll_name, db_collection_exists: db_coll_created, type: "data" }) } return { created_id: coll_name, db_collection_created: db_coll_created } } async getDataCollectionSchemas() { let collection_schemas = {} for (let collection of this.setup.collections) { collection_schemas[collection] = await this.getCollectionSchema(collection) } return collection_schemas } /** * Create a new topic to enable access to the database via API * * @param {string} coll_name */ addDataTopic(coll_name) { this.topics[coll_name] = new topic.Topic({ name: coll_name, coll_name:coll_name, data_type: "data", client: this.items_client, changes: true, schema: { query: {}, path: { type: "string" }, body: {} }, main_properties: [], cast_query_types_default: true, methods: ["POST", "GET", "PUT", "EDIT", "DELETE"] }, this.getAPI()) this.api.use(`/collection/${coll_name}`, this.topics[coll_name].router) } /** * close collection API topic * remove data collection from game database * remove collection from setup * * @param {string} coll_name - data collection name * * @returns {Promise} "collection removed" if removed. If collection didn't exist returns undefined */ removeCollection(coll_name) { return new Promise((resolve, reject) => { if (this.setup.collections.includes(coll_name)) { this.removeTopic(coll_name) this.db.dropCollection(coll_name) .then(result => { this.setup.collections = this.setup.collections.filter(coll => { return coll != coll_name }) return this.setup_doc.remove({ "collections": coll_name }) }) .then(result => { log.info(this.name, "Data collection removed: " + coll_name) this.status.emit('collection_removed', coll_name) return resolve("collection removed") }) .catch(error => { log.error(this.name, error) return reject(error) }) } else { log.warn(this.name, "Can not remove " + coll_name + ". No such custom collection") return resolve() } }) } /** * 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.Session(data, this.getAPI()) this.sessions.push(new_session) new_session.status.on('state_begin', e => { this.topics.session.changed(data._id) }) new_session.status.on('state_listening', e => { this.topics.session.changed(data._id) }) new_session.status.on('queueing', e => { this.topics.session.changed(data._id) }) return new_session } /** * 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 */ createSession(properties, user) { return new Promise((resolve, reject) => { if (properties['name']) { if (this.findSession("name", properties.name)) { return reject(new adaptor.DuplicateError(`session with name ${properties.name} already exists`)) } } else { properties['name'] = "session_" + adaptor.createId() } let level_query = properties.level if (typeof properties.level === 'string') { if (this.db.validId(properties.level)) { level_query = { $or: [{ _id: properties.level }, { name: properties.level }] } } else { level_query = { name: properties.level } } } let new_session var session_data = {} this.db.levels.find(level_query) .then(lvldata => { if (lvldata.length) { session_data = { "name": properties.name, "level": lvldata[0]._id, "level_name": lvldata[0].name, "config": lvldata[0].config, "content": properties.content, "history": [], "created_at": adaptor.now(), "created_by": user.login, "created_with":adaptor.info.version } return this.db.sessions.insert(session_data) } else { throw new adaptor.NotFoundError(`couldn't create session based on ${JSON.stringify(level_query)}. Level not found`) } }) .then(result => { session_data['_id'] = result.insertedId new_session = this.addSession(session_data) return new_session.setup(properties["arguments"]) }) .then(result => { new_session.next({ name: "START" }, "create session") return this.db.levels.update(level_query, { $inc: { instances: 1 } }) }) .then(result => { return resolve(new_session) }) .catch(error => { if (session_data['_id']) { this.deleteSession({ _id: session_data['_id'] }) } return reject(error) }) }) } /** * delete session with same name first, then create * * @returns {Promise} forwarded createSession Promise result */ overwriteSession(args) { return new Promise((resolve, reject) => { this.deleteSession(args.name) .then(res => { return this.createSession(args) }) .then(res => { return resolve(res) }) .catch(err => { return reject(err) }) }) } /** * * Quit the respective sessions and their API topics * * Remove session objects and their database entry. * * 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 deleteSession(query) { if (typeof query === 'string') { query = { "name": query } } let removed_documents = await this.db.sessions.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.db.levels.update({ "_id": session.level }, { $inc: { instances: -1 } }) for (let i = this.sessions.length - 1; i >= 0; --i) { if (this.db.sessions.compareIDs(this.sessions[i]._id, session._id)) { this.sessions.splice(i, 1) } } log.info(this.name, "deleted session " + session.name + " based on level " + session.level_name) document_ids.push(document._id) } this.topics.session.documentsRemoved(document_ids) return true } else if (adaptor.isEmpty(query)) { log.info(this.name, "No session to delete") return } else { throw new adaptor.NotFoundError(`session ${JSON.stringify(query)} could not be found`) } } /** * 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.db.sessions.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 } /** * find sessions by query in session collection and return corresponding session object * * @param {Object} query - mongo find query * * @return {Promise} - resolves with Session Object on match. Otherwise undefined. */ querySession(query) { return new Promise((resolve, reject) => { if (typeof query === 'string') { query = { "name": query } } this.db.sessions.find(query) .then(result => { for (let res of result) { return resolve(this.getSession(res._id)) } return resolve(undefined) }) }) } /** * react on std input addressing this game * */ command(input) { switch (input[0]) { case "sessions": this.sessions.forEach((session, index) => { if (this.db.hasOwnProperty("players")) { this.db.players.find({ 'sessions._id': session._id }) .then(result => { let player = "" for (let plyr of result) { player += plyr.name + ", " } log.info(index + "", session.name + " lvl:" + session.level_name + " player:" + player + " Since: " + Math.round((new Date(Date.now() - session.created_at)) / 60000) + " min.") }) .catch(error => { log.error(this.name, error) }) } else { log.info(index + "", session._id + " lvl:" + session.level_name + " Since: " + Math.round((new Date(Date.now() - session.created_at)) / 60000) + " min.") } }) break case "launch": case "create": case "createsession": if (input.length == 2) { this.createSession({ level: input[1] }, { login: adaptor.userInfo().username }) .catch(err => { log.error(this.name, err.stack) }) } else if (input.length > 2) { let i_args = input.slice(3) let args = [] for (let a of i_args) { args.push({ collection: "players", query: { name: a } }) } log.info(this.name, "launch session with player") log.info(this.name, args) this.createSession({ level: input[1], name: input[2], arguments: args }, { login: adaptor.userInfo().username }) .catch(err => { log.error(this.name, err) }) } else { log.warn(this.name, "create session takes only 1 or 2 arguments.") } break case "del": case "delete": case "cancel": let query if (input.length == 2) { if (isNaN(input[1])) { if (input[1] == 'all') { query = {} } else { query = { "name": input[1] } } } else if (Number(input[1]) < this.sessions.length) { query = { "_id": this.sessions[Number(input[1])]._id } } else { log.error(this.name, " session index " + input[1] + " not in range") } } else if (input.length == 3) { query = {} query[input[1]] = input[2] } else { log.warn(this.name, "session cancel requires name, index or a key value pair to identify the session to be canceled. Use 'all' to cancel all sessions.") } if (query) { this.deleteSession(query) .catch(err => { log.error(this.name, err) }) } break case "level": case "levels": this.topics.level.getMany({}) .then(result => { result.forEach((level, i) => { log.info(i+1, `${level.name} (${level._id}) created: ${new Intl.DateTimeFormat(undefined, {dateStyle: "short", timeStyle: "short"}).format(level.created_at)} modified: ${new Intl.DateTimeFormat(undefined, {dateStyle: "short", timeStyle: "short"}).format(level.modified_at)}`) }) }) break case "plugins": case "plugin": if (input[1] == "add") { this.topics.plugin.create({ name: input[2] }, { user: { login: adaptor.userInfo().username } }) .then(result => { log.info(this.name, "Plugin added: " + input[2]) }) .catch(err => { switch (err.name) { case 'Error': log.error(this.name, err.message) break default: log.error(this.name, err) } }) } else if (input[1] == "remove") { Input.confirmation("remove", input[2], this.topics.plugin.delete.bind(this.topics.plugin)) } else if (input[1] == "settings") { for (let plugin in this.topics.plugin.plugins) { log.info(0, plugin + ", " + JSON.stringify(this.getPlugin(plugin).settings)) } } else if (input[1] == "list") { this.topics.plugin.showStatus() } else { log.info(0, `Please use one of these commands: |cmd |description |---------------------|-------------- |list |list all plugins |add [plugin] |activate plugin |remove [plugin] |deactivate plugin and delete all items` ) } break case "collection": if (input.length > 1) { switch (input[1]) { case "list": for (let coll of this.setup.collections) { log.info(0, coll) } break case "create": this.addDataCollection(input[2], true) break case "delete": Input.confirmation("remove", input[2], this.removeCollection.bind(this)) break default: log.warn(this.name, "unknown collection command " + input[1]) } } else { log.info(this.name, "wrong number of arguments") } break case "import": if (this.db.hasOwnProperty(input[1])) { this.db[input[1]].import(input[2]) } else { log.error(this.name, input[1] + " is not a database collection") } break case "export": if (input.length == 1) { this.export() } else if (input.length == 2) { this.export(input[1]) } else { this.export(input[1], input.slice(2)) } break default: let session = this.findSession("name", input[0]) if (session) { input.shift() session.command(input) } else if (this.getPlugin(input[0])) { let plugin_name = input.shift() let plugin = this.getPlugin(plugin_name) if (typeof plugin.command === "function") { plugin.command(input) } else { log.info(0, plugin_name + " doesn't take std input commands") } } else if (!isNaN(input[0])) { if (this.sessions.length > input[0] && input[0] >= 0) { this.sessions[input.shift()].command(input) } else { log.error(this.name, " session index " + input[0] + " not in range") } } else { if (this.sessions.length == 1) { this.sessions[0].command(input) } else { log.info(this.name, "No such session, plugin or command '" + input[0] + "'") } } break } } /** * cancel all listeners * call quit function on all plugins that have one. * call close function on all topics * * @returns {Promise} - resolves once all plugins and topics quit */ quit() { return new Promise((resolve, reject) => { this.status.removeAllListeners() this.event.removeAllListeners() for (let change_listener in this.changes) { this.changes[change_listener].removeAllListeners() } let promises = [] for (let t in this.topics) { promises.push(this.topics[t].close()) } Promise.all(promises) .then((result) => { return resolve() }).catch((err) => { return reject(err) }) }) } /** * delete game database * * @todo maybe make backup */ async delete() { await this.db.drop() } /** * quit game * @todo feedback to client that all game windows should be closed or notified */ async unload() { //this.client.emit() //this.editor.disconnect() return this.quit() } } /** Game Object */ module.exports.Game = Game;