adaptorex
Version:
Connect all your live interactive storytelling devices and software
982 lines (887 loc) • 36.5 kB
JavaScript
/**
* Get and Set variables and items in the current session and state
*
* provides functions to find and replace variables and to update or remove variables and store the changes to the database
*
* @requires json5
*
* @module variables
* @copyright Lasse Marburg 2022
* @license MIT
*/
const JSON5 = require('json5')
/** @typedef {import('./types').SessionInterface} Session */
/** @typedef {import('./types').Game} Game */
/**
* resolve variables using session references.
*
* Each Variable instance is assigned to an action context of a certain state in a session
*/
class Variables {
/**
* @param {Session} session
* @param {Game} game
*/
constructor(session, game) {
/**
* @type {Session}
* @private
*/
this.session = session
/**
* @type {Game}
* @private
*/
this.game = game
this.name = session.level.name + " " + session.name + " " + this.session.state.name + " " + this.session.action.plugin + " " + this.session.action.name
}
/**
* Write update to local session state data. The data will be accessible via state and action name.
*
* Top level object properties will be appended to existing properties on subsequent store calls
*
* If you want the action data to persist if the action is triggered again, you can set persistent=true.
* You can get persistent action data with load()
*
* @param {*} data - state data to be stored by action
* @param {boolean} persistent - wether to store a copy of the action data that is not cleared if action is retriggered
*
* @returns {Promise} mongodb update result
*/
store(data, persistent) {
return new Promise((resolve, reject) => {
let updt = {}
let properties = ['state_data']
if(persistent) {
properties.push('persistent_state_data')
}
for(let property of properties) {
let path = property + '.' + this.session.state.name + '.' + this.session.action.name
if(adaptor.isObject(data)) {
for(let d in data) {
updt[path + '.' + d] = data[d]
}
} else {
updt[path] = data
}
}
this.game.db.sessions.set({_id:this.session._id}, updt)
.then(result => {
return resolve(result)
})
.catch(error => {
return reject(new Error("couldn't store data for " + this.session.action.name + "." + this.session.state.name + " in session " + this.session.name, {cause: error}))
})
})
}
/**
* Load current action data that has been saved with store() function
*
* @returns {Promise} - Action data object as saved using store()
*/
async load() {
let result = await this.game.db.sessions.find({_id:this.session._id})
if(result.length) {
let action_data = this.getPath(result[0], 'persistent_state_data.' + this.session.state.name + '.' + this.session.action.name)
if(action_data) {
return action_data
} else {
action_data = this.getPath(result[0], 'state_data.' + this.session.state.name + '.' + this.session.action.name)
return action_data
}
}
}
/**
* Set a variable to a new value.
*
* If variable references an item you can also update multiple fields if you provide an object as value.
*
* @param {string} variable - variable reference (e.g. Player.name or [[local_var]])
* @param {*} value - the data that will be stored to the variable
*/
async set(variable, value, options) {
await this.edit("set", variable, value, options)
}
/**
* Append value to a variable list.
*
* @param {string} variable - variable reference (e.g. Player.name or [[local_var]])
* @param {*} value - the data that will be appended to the variable list
*/
async push(variable, value, options) {
await this.editArray("push", variable, value, options)
}
/**
* Append value to a variable list if it does not contain the same value (avoid duplicates).
*
* @param {string} variable - variable reference (e.g. Player.name or [[local_var]])
* @param {*} value - the data that will be appended to the variable list
*/
async add(variable, value, options) {
await this.editArray("add", variable, value, options)
}
/**
* Remove value from variable list.
*
* @param {string} variable - variable reference (e.g. Player.name or [[local_var]])
* @param {*} value - the data that will be removed from the variable list
*/
async pull(variable, value, options) {
await this.editArray("pull", variable, value, options)
}
/**
* Make an array update. if value is an array the arrays will be merged not inserted.
*
* @param {string} operator - Array update operator pull, push or add
* @param {string} variable - variable reference (e.g. Player.name or [[local_var]])
* @param {*} value - value the array will be updated with
*/
async editArray(operator, variable, value, options) {
let modifier = "$each"
if(operator == "pull") {
modifier = "$in"
}
if(Array.isArray(value)) {
if(value.length == 1) {
await this.edit(operator, variable, value[0], options)
} else {
options.modifier = modifier
await this.edit(operator, variable, value, options)
}
} else {
await this.edit(operator, variable, value, options)
}
}
/**
* Make a DB update operation through the associated API Topic.
*
* @param {string} operator - mongo db update operator (e.g. $set)
* @param {string} variable - the adaptor variable that will be updated
* @param {any} value - value the variable will be updated with
* @param {object} [options] - optional update options
* @param {boolean} [options.multiple=true] - allow to edit multiple docs
* @param {string} [options.modifier] - optional update modifier
*/
async edit(operator, variable, value, {multiple=true, modifier} = { multiple: true}) {
if(typeof variable !== "string") {
throw new Error("Can not edit. Variable Reference has to be string type")
}
let reference = await this.getVariableReference(variable)
value = await this.review(value)
value = this.parseJSON(value)
let update = {}
if (modifier && reference.field) {
update[reference.field] = {}
update[reference.field][modifier] = value
} else if (reference.field) {
update[reference.field] = value
} else if (typeof value === "object") {
update = value
} else {
throw new adaptor.InvalidError("Can not update " + variable + ": not a valid variable.")
}
let result
if(multiple) {
result = await this.game.topics[reference.topic].edit(reference.query, operator, update, {user: {login:this.name}})
} else {
result = await this.game.topics[reference.topic].editOne(reference.query, operator, update, {user: {login:this.name}})
}
if(result) {
this.session.log(`${operator} value '${value}' to/from variable '${reference.variable}' in '${reference.collection}'. ${result.changed} item(s) changed.`)
return result
}
throw new adaptor.NotFoundError(`${operator} failed. Could not find document with ${JSON.stringify(reference.query)} in ${reference.collection}`)
}
/**
* Make an update operation to a variable item document. Maybe any kind of mongodb update query
*
* @param {string} variable - variable reference that references an item (e.g. Player or [[chats.MyChat]])
* @param {*} data - Mongodb style update query
* @param {object} [options] - optional update options
* @param {boolean} [options.multiple=true] - allow to update multiple docs
*/
async update(variable, data, {multiple=true} = {multiple: true}) {
let reference = await this.getVariableReference(variable)
if(!multiple) {
let document = await this.game.topics[reference.topic].collection.find(reference.query)
reference.query = {_id:document[0]._id}
}
data = await this.review(data)
let update = this.parseJSON(data, true)
let response = await this.game.topics[reference.topic].collection.update(reference.query, update)
let result = await this.game.topics[reference.topic].reportChanges(response, reference.query, {login:this.name})
if(!result) {
throw new adaptor.NotFoundError(`update failed. Could not find document with ${JSON.stringify(reference.query)} in ${reference.topic}.`)
} else if(!result.changed) {
this.session.log(`No changes made when updating ${variable}.`)
} else {
this.session.log(result.changed + " item(s) updated with " + data + " in " + reference.topic + " query: " + JSON.stringify(reference.query))
}
}
/**
* Create a new item document.
*
* Use reference to make it more easy to access it in the current session.
*
* @param {string} collection - Database collection the item will be created in
* @param {Object} variables - The Data that will be inserted
* @param {*} [reference] - Name of reference inside the current session
* @returns {Promise<object>} Result of insert operation contains created_id, created_at and created_by
*/
async create(collection, variables, reference) {
variables = await this.findAndReplace(variables, true)
if(!this.game.topics[collection]) {
throw new adaptor.NotFoundError(collection + " data collection or topic not found.")
}
let result = await this.game.topics[collection].create(variables, {user: {login:this.name}})
if(reference) {
reference = await this.review(reference)
await this.session.createReference(collection, {_id:result.created_id}, reference)
}
this.session.log(`Created new ${collection} item ${variables.name}. id: ${result.created_id}`)
return result
}
/**
* Remove variable from item document or delete the whole document.
*
* @param {string} variable
* @param {object} [options] - optional update options
* @param {boolean} [options.multiple] - allow to delete multiple docs
*/
async delete(variable, {multiple=true} = {multiple: true}) {
let reference = await this.getVariableReference(variable)
if(reference.field) {
let update_query = {}
update_query[reference.field] = ""
let result
if(multiple) {
result = await this.game.topics[reference.topic].edit(reference.query, "remove", update_query, {user: {login:this.name}})
} else {
result = await this.game.topics[reference.topic].editOne(reference.query, "remove", update_query, {user: {login:this.name}})
}
if(!result) {
this.session.log.warn(`Could not remove ${reference.field}. ${reference.source} does not reference any exiting Item.`)
} else if(!result.changed) {
this.session.log(`Could not remove ${reference.field} from ${reference.source}. ${reference.field} does not exist.`)
} else {
this.session.log(`Removed variable ${reference.field} from ${reference.source} in ${result.changed} Item(s).`)
}
return result
} else {
let result = await this.game.topics[reference.topic].delete(reference.query, {multiple: multiple})
if(result) {
this.session.log(`Removed Item ${variable} from ${reference.collection} collection. ${result}`)
} else {
this.session.log.warn(`Could not remove ${variable} from ${reference.collection} collection. Item not found.`)
}
return result
}
}
/**
* Get document based on variable reference or query
*
* @example
* // returns document that references to the current session as "teamcaptain"
* get("teamcaptain")
*
* @param {string|Object} variable - value to check against being a reference variable or db query
* @param {boolean} [return_empty=false] - get() rejects promise (throw error) if no document can be resolved. If return_empty is set to true, get() resolves undefined instead
*
* @returns {Promise} - resolves with (first) referred document. rejects if no document could be found.
*/
get(variable, return_empty) {
return new Promise((resolve, reject) => {
try {
var ref = this.getReference(variable)
} catch (error) {
if(return_empty) {
return resolve()
} else {
return reject("reference couldn't be resolved: " + variable + "\n" + error)
}
}
if(this.game.db.hasOwnProperty(ref.collection)) {
this.game.db[ref.collection].find(ref.query)
.then(result => {
if(result.length) {
result = this.sortReferencedDocuments(result, ref.query)
return resolve(result[0])
} else if(return_empty) {
return resolve()
} else {
return reject("no document found with reference: " + variable)
}
})
.catch(error => {
return reject(error)
})
} else {
return reject(`Can not get variable ${variable} collection ${ref.collection} does not exist`)
}
})
}
/**
* Get documents based on variable reference or query
*
* @param {*} variable - value to check against. Can be a reference variable or db query
* @param {boolean} [return_empty=false] - An error is thrown if no document can be resolved. If return_empty is set to true, getMany() resolves with empty array instead.
* @returns {Promise<array>} - list of all documents referred to by variable.
*/
async getMany(variable, return_empty) {
let ref = await this.getVariableReference(variable)
let documents = await this.game.db[ref.collection].find(ref.query)
if(!documents.length && !return_empty) {
throw new adaptor.NotFoundError(`Could not find any items in collection ${ref.collection} with ${variable}`)
}
documents = this.sortReferencedDocuments(documents, ref.query)
return documents
}
/**
* Get the item instance that is bound to a variable object.
*
* Could return a Data Item, Plugin Item, a Session or the Game API Object itself
*
* variable reference should always be explicit, since getItem will, other than get, getMany and getValue, ignore sort option for variable references
*
* @param {*} variable - string reference to variable object.
*
* @returns {Promise<object>} - returns the class instance object of an item, session or the current game
*/
async getItem(variable) {
let reference = await this.getVariableReference(variable)
if(reference.topic == "game") {
return this.game
}
if(this.game.topics.hasOwnProperty(reference.topic)) {
if(reference.topic == "session") {
let doc = await this.game.db.sessions.find(reference.query)
return this.game.getSession(doc[0]._id)
}
if(reference.topic == "plugin") {
let doc = await this.game.db.plugins.find(reference.query)
return this.game.findPlugin("_id", doc[0]._id)
}
return this.game.topics[reference.topic].queryItem(reference.query)
} else {
throw new adaptor.NotFoundError(`Can not get Item ${variable} collection ${reference.collection} does not exist`)
}
}
/**
* @typedef VariableReference
* @property {string} collection - name of database collection the variable is referring to.
* @property {string} topic - name of game api topic the variable is referring to. Similar to 'collection' but night differ.
* @property {Object<string, any} query - find query to get the document that is or contains the variable value
* @property {string} [field] - the property inside the document that contains the referenced value
* @property {string} source - a descriptive name for the variable origin to use in log or other feedback context
* @property {string} [variable] - the original variable the reference is based on
*
* @example
* {"collection":"levels", "topic":""level", "query":{"_id":"abcd1234"}, field:"config.comment", source:"level attributes", "variable":"level.comment"}
*/
/**
* Create reference to database document .
*
* First part of string path indicates collection and query of reference and may be a:
*
* - level argument like `Player`
* - the `state` keyword
* - the `action` keyword
* - the `level` keyword
* - a plugin item reference `<plugin>.<collection>.<item name or query string>` eg `telegram.client.MyClient`
* - a data item reference `<collection>.<item name or query string>` e.g. `player.MyPlayer`
*
* All segments that might append the above define the field of the value inside the referenced document.
*
* If first part of path string is none of the above, the referenced document defaults to `variables` property in the current session document (local variable)
*
* `level` keyword will always refer to 'config' property inside level document except for the name, _id or instances field.
*
* @param {string} path - dot notated path string
*
* @returns {VariableReference} collection name, topic name, Mongo style find query and value field: `{collection:<collection>,topic:<topic>,query:<query>,field:<field>,source:<source name>}`
*/
getReference(path) {
let segments = path.match(/\{.*\}|[^\.]+/g) // /\{[^\{^\}]+\}|[^\.]+/g <= regex until 13.02.2024
function getField(depth) {
let field_index = 0
for(let i = 0; i < depth; i++) {
field_index += segments[i].length+1
}
return path.slice(field_index)
}
// Level Argument or Loaded Item
for(let ref of this.session.references) {
if(ref.name == segments[0]) {
let query = {
sessions: {
$elemMatch: {
_id: this.session._id,
reference: segments[0]
}
}
}
let topic = ref.collection
if(ref.collection == "sessions") {
topic = "session"
}
return {collection:ref.collection, topic:topic, query:query, field:getField(1), source:segments[0]}
}
}
// State Data
if(segments[0] == "state") {
let field = 'state_data'
if(segments.length > 1) {
field += '.' + getField(1)
}
return {collection:"sessions",topic:"session",query:{_id:this.session._id}, field:field, source:"states"}
}
// Local State/Action Data
if(segments[0] == "action") {
let field = 'state_data.' + this.session.state.name + '.' + this.session.action.name
if(segments.length > 1) {
field += '.' + getField(1)
}
return {collection:"sessions",topic:"session",query:{_id:this.session._id}, field:field, source:"states"}
}
// Current Level Document or Attribute
if(segments[0] == "level") {
let field
if(segments.length > 1) {
let attr = getField(1)
if(!["name","_id","instances"].includes(attr)) {
field = 'config.' + attr
} else {
field = attr
}
}
return {collection:"levels", topic:"level", query:{_id:this.session.level._id}, field:field, source:"level attributes"}
}
// Any Level Document or Attribute
if(segments[0] == "levels") {
if(segments.length < 2) {
throw new adaptor.InvalidError("Levels reference is missing level name or query.")
}
let field
if(segments.length > 2) {
let attr = getField(2)
if(!["name","_id","instances"].includes(attr)) {
field = 'config.' + attr
} else {
field = attr
}
}
return {collection:"levels", topic:"level", query:this.parseQuery(segments[1]), field:field, source:"levels attributes"}
}
// Plugins
if(this.game.getPlugin(segments[0])) {
// Plugin Item
if(segments.length >= 2 && this.game.db[segments[0] + "_" + segments[1]]) {
if(segments.length < 3) {
throw new adaptor.InvalidError("Plugin item reference is missing item name or query.")
}
let coll_name = segments[0] + "_" + segments[1]
return {collection:coll_name, topic:coll_name, query:this.parseQuery(segments[2]), field: getField(3), source:segments[0] + "." + segments[1]}
}
// Plugin
return {collection: "plugins", topic:"plugin", query:{name: segments[0]}, field:getField(1), source:segments[0]}
}
// Data Item
if(this.game.setup.collections.includes(segments[0])) {
if(segments.length < 2) {
throw new adaptor.InvalidError("Data item reference is missing item name or query.")
}
return {collection:segments[0], topic:segments[0], query:this.parseQuery(segments[1]), field: getField(2), source:segments[0] + "." + segments[1]}
}
// Game Setup Document
if(segments[0] == "game") {
return {collection: "setup", topic:"game", query:{}, field:getField(1), source:"game"}
}
// Local Session variable
return {collection: "sessions", topic:"session", query:{_id:this.session._id}, field:'variables.' + path, source:"session"}
}
/**
* Get variable reference (see getReference) independent of square brackets `[[]]`.
*
* Encapsulating square brackets are ignored.
*
* If there are square brackets in midst of the string they are resolved using variables.review()
*
* @param {string} variable - an adaptor variable reference string
*
* @returns {Promise<VariableReference>} - the DB reference to the respective variable: {collection:<collection>,topic:<topic>,query:<query>,field:<field>,source:<source name>, variable:<original_variable>}
*/
async getVariableReference(variable) {
variable = variable.replace(/(^\[\[)(.*)(\]\]$)/, '$2') // remove [[ at beginning and ]] at end of string if both.
variable = await this.review(variable)
let reference = this.getReference(variable)
reference["variable"] = variable
return reference
}
/**
* Sort Data and Plugin Item references based on the (optional) 'sessions.index' property that
* has been assigned to the documents when the reference was created (using sort option)
*
* If you want to make sure references that have been resolved using getReference or getVariableReference are in the correct sort order
* you need to call this function with the documents that have been received using the respective query and collection.
*
* Will return the original documents in their original sort order if they do not have a 'session.index' property
*
* @param {Array} docs - list of collection documents (data or plugin items)
* @param {object} query - respective mongodb query that was used to find the docs
* @returns {Object} the original documents in the correct sort order based on their 'session.index' property (if any)
*/
sortReferencedDocuments(docs, query) {
if(!query || !query.hasOwnProperty('sessions')) {
return docs
}
return docs.sort((a, b) => {
let index_a = this.getReferenceIndex(a, query)
let index_b = this.getReferenceIndex(b, query)
if(index_a < index_b) {
return -1
}
if(index_a > index_b) {
return 1
}
})
}
/**
* Get the index property ('session.index') that defines the documents position of the sort order of a reference.
*
* @param {Object} doc - collection document
* @param {*} query - query that was used to find the document
* @returns
*/
getReferenceIndex(doc, query) {
let ref = doc.sessions.find(session => {
return (session.reference == query.sessions.$elemMatch.reference && this.game.db.sessions.compareIDs(session._id, query.sessions.$elemMatch._id))
})
if(!ref) {
throw new Error(`Could not sort entries for ${JSON.stringify(doc)} with query ${JSON.stringify(query)}`)
}
return ref.index
}
/**
* Use JSON5 parse to convert to valid query object.
*
* Convert regex `/regexpr/igm` to {"$regex":"regexpr","$options":"igm"} so it matches JSON syntax and will be recognized by mongo db.
*
* If query string is not json (no curly brackets `{ }` or colons `:`) return name property query like so:
* `{name: <query string>}`
*
* @param {string} query
* @returns {*} - Valid json object query or, if query is not of type string, the original value
*/
static parseQuery(query) {
if(typeof query === "string") {
if(/.*[{}:].*/.test(query)) {
let done = false
while(!done) {
let hasReg = query.search(/: *\/[^\/]*\/[ ,igm]*$/)
if(hasReg >= 0) {
let regStart = query.indexOf('/', hasReg + 1)
let regEnd = query.indexOf('/', regStart + 1)
if(regEnd >= 0) {
let front = query.substring(0, regStart)
let regex = query.substring(regStart+1, regEnd)
let rear = query.substring(regEnd+1,query.length)
let regOptions = rear.match(/^[igm]+/)
if(regOptions) {
query = `${front}{"$regex":"${regex}","$options":"${regOptions[0]}"}${rear.substring(regOptions[0].length, query.length)}`
} else {
query = `${front}{"$regex":"${regex}"}${rear}`
}
} else {
done = true
}
} else {
done = true
}
}
return this.parseJSON(query, true)
} else {
return {name: query}
}
} else {
return query
}
}
/**
* Use JSON5 parse to convert to valid query object.
*
* If query string is not json (no curly brackets `{ }` or colons `:`) return name property query like so:
* `{name: <query string>}`
*
* @param {string} query
* @returns {Object} - valid json object query
*/
parseQuery(query) {
return Variables.parseQuery(query)
}
/**
* Use JSON5 to parse json formatted string to js object.
* Only convert if starts and ends with curly brackets
* Use `force_json=true` to add encapsulating curly brackets '{}' if missing and try to convert any string that contains at least 1 colon `:`
*
* @param {string} json_string - string to be converted to js object
* @param {boolean} [force_json] - try to convert even if string is not encapsulated by curly brackets '{}'. Will allow anything as json that contains at least 1 colon `:`
* @returns {*} either converted JS object or the input argument if it is not of type string or does not begin and end with curly bracket '{' (exception see `force_json`)
*/
static parseJSON(json_string, force_json) {
if(typeof json_string === "string") {
if(!['{','[','"'].includes(json_string.trim()[0])) {
if(force_json) {
json_string = '{' + json_string + '}'
} else {
return json_string
}
}
try {
let json = JSON5.parse(json_string)
return json
} catch(err) {
if(err instanceof SyntaxError) {
throw new adaptor.InvalidError('syntax error in json string "' + json_string + '"', {cause:err})
} else {
throw(err)
}
}
} else {
return json_string
}
}
/**
* Use JSON5 to parse json formatted string to js object.
* Only convert if starts and ends with curly brackets
* Use `force_json=true` to add encapsulating curly brackets '{}' if missing and try to convert any string that contains at least 1 colon `:`
*
* @param {string} json_string - string to be converted to js object
* @param {boolean} [force_json] - try to convert even if string is not encapsulated by curly brackets '{}'. Will allow anything as json that contains at least 1 colon `:`
* @returns {*} either converted JS object or the input argument if it is not of type string or does not begin and end with curly bracket '{' (exception see `force_json`)
*/
parseJSON(json_string, force_json) {
return Variables.parseJSON(json_string, force_json)
}
/**
* address object property with '.' separated string
* Might be handy especially when working with Mongodb and nested document find queries.
* Mongo uses [dot notation]{@link https://docs.mongodb.com/manual/core/document/#document-dot-notation}
* from:
* https://stackoverflow.com/questions/6491463/accessing-nested-javascript-objects-with-string-key/6491621#6491621
*
* @param {Object} o - some Object
* @param {string} s - some string that finds nested properties with '.' notation
*/
getPath(o, s) {
s = s.replace(/\[(\w+)\]/g, '.$1'); // convert indexes to properties
s = s.replace(/^\./, ''); // strip a leading dot
var a = s.split('.');
for (var i = 0, n = a.length; i < n; ++i) {
var k = a[i];
if (k in o) {
o = o[k];
} else {
return;
}
}
return o;
}
/**
* Resolve dot notated string path to the value it references.
*
* 1. Variables
*
* Uses variables.getReference to find the value behind a level variable, plugin item, data item, local variable or state variable
*
* 2. Functions
*
* Call a function from game functions.
*
* `function.<function_name>(<arg1>,<arg2>,...)`
*
* function args are comma separated
* @param {string} path - dot notated path
*
* @returns {Promise} the value, document or function return value. else undefined
*/
async getValue(path) {
if(typeof path === "string") {
let first = path.split('.', 1)[0]
// Resolve Function
if(first == "functions") {
let second = path.slice(first.length+1)
let function_name = second.substring(0, second.indexOf('('))
let function_args = second.substring(second.indexOf('(')+1, second.indexOf(')'))
log.debug(this.name, "resolve function " + function_name + " with args: " + function_args)
let functions = this.game.getFunctions()
if(typeof functions[function_name] === "function") {
function_args = function_args.split(',')
for(let i = 0; i < function_args.length; i++) {
function_args[i] = function_args[i].trim()
if((function_args[i][0] == "'" && function_args[i][function_args[i].length-1] == "'") || (function_args[i][0] == '"' && function_args[i][function_args[i].length-1] == '"')) {
function_args[i] = function_args[i].slice(1, -1)
} else if(!isNaN(function_args[i])) {
function_args[i] = Number(function_args[i])
} else {
function_args[i] = await this.getValue(function_args[i])
}
}
try {
let session = Object.assign(this.session, {variables:this})
let result = await functions[function_name](function_args, {game:this.game, session:session})
return result
} catch (error) {
log.error(this.name, error)
throw new Error(`Failed to execute javascript function ${function_name}: \n${error.stack}`)
}
} else {
throw new Error(`No such function ${second} at ${this.name}`)
}
} else {
// Resolve Variable
let ref = this.getReference(path)
let result = await this.game.db[ref.collection].find(ref.query)
if(result.length) {
result = this.sortReferencedDocuments(result, ref.query)
if(ref.field) {
var value = this.getPath(result[0], ref.field)
} else {
var value = result[0]
}
if(typeof value === 'undefined') {
this.session.log.warn(ref.field + " could not be found in " + ref.source)
}
return value
} else {
log.error(this.name, "Variable reference '" + path + "' could not be resolved")
return
}
}
} else {
throw new adaptor.InvalidError("variable query from path only allows string path.")
}
}
/**
* find variables and replace them with value from database. Takes different types:
* - String: review string
* - Array: walk through all elements of array and review them
* @todo will only replace variables in strings on first array level
* - Object: Iterate through (nested) object and parse [[]] variables using review function
*
* @param {Object|array|string} element - js Object. May be string or array
* @param {boolean} parseJSON - optionally try to parse json for each string that starts with curly brackets '{'
*
* @returns {Promise} Element with replaced variables
*/
findAndReplace(element, parseJSON) {
return new Promise((resolve, reject) => {
let type = ""
let review_promises = []
if(typeof element === "string") {
review_promises.push(this.review(element))
type = "string"
} else if(Array.isArray(element)) {
for(let elem of element) {
review_promises.push(this.review(elem))
}
type = "array"
} else {
review_promises.push(this.reviewObject(element, parseJSON))
type = "object"
}
Promise.all(review_promises)
.then(result => {
switch (type) {
case "string":
if(parseJSON) {
result[0] = this.parseJSON(result[0])
}
return resolve(result[0])
case "array":
return resolve(result)
case "object":
return resolve(element)
}
})
.catch(error => {
return reject(error)
})
})
}
/**
* Walk through (nested) object properties and check for variables and replace them with current value
*
* Optional: try and parse any string that starts with curly bracket `{` to json.
*
* @param {Object} payload - Object to be reviewed. No effect if its not an object
* @param {boolean} parseJSON - wether or not to try and parse string to json if it starts with curly bracket `{`
*/
async reviewObject(payload, parseJSON) {
if (typeof payload === 'object') {
for (const field in payload) {
if (typeof payload[field] === 'string') {
payload[field] = await this.review(payload[field])
if(parseJSON) {
payload[field] = this.parseJSON(payload[field])
}
continue
} else {
await this.reviewObject(payload[field], parseJSON)
}
}
}
}
/**
* check string for `[[ ]]` and replace the content in case.
* if `[[ ]]` embraces the whole string, it resolves to its original data type of the variable and will not be parsed to string in case!
*
* if review is supposed to check anything thats not a string it returns the argument itself
*
* @example
* // resolve player variables using the player reference, a database query and the level reference.
* this.review("your score is: [[Player.score]] and your phone number is [[player.{'friends.good':'tom'}.phone_number]] and your level is [[level.name]]")
* .then(res => {
* log.info("VARIABLES EXAMPLE", res)
* })
*
*
* @param {string} str - string that might contain replaceable values. Other datatypes are allowed and simply returned the way they came in.
*
* @returns {Promise} the string with replaced variables.
*/
review(str) {
return new Promise((resolve, reject) => {
if(typeof str === "string") {
var regex = /\[\[([\+\w\u00C0-\u017F\.\,\-\{\}\(\)\$\:\'\"\\\s\/\*]+)\]\]/g
var matches = str.match(regex)
if(!matches) {
return resolve(str)
}
let values = matches.map( (match, index) => {
return this.getValue(match.slice(2,-2))
})
Promise.all(values)
.then(result => {
if(matches[0] == str) {
return resolve(result[0])
}
var i = -1
let replaced_str = str.replace(regex, (tag, match) => {
i++
return result[i]
})
return resolve(replaced_str)
})
.catch(err => {
if(err instanceof adaptor.AdaptorError) {
return reject(err)
}
return reject(new Error(`Failed to resolve variables in "${str.substring(0, 35)}"`, { cause: err }))
})
} else {
return resolve(str)
}
})
}
}
module.exports.Variables = Variables