adaptorex
Version:
Connect all your live interactive storytelling devices and software
1,477 lines (1,323 loc) • 48.5 kB
JavaScript
/**
* 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;