adaptorex
Version:
Connect all your live interactive storytelling devices and software
1,083 lines (977 loc) • 36.2 kB
JavaScript
/**
* Enable logics in adaptor:ex Editor. Listen for events or data changes, switch based on conditions etc.
*
* logic is a core plugin and will be included with every game
*
* @requires plugin
* @requires events
* @requires functions
*
* @module logic/logic
* @copyright Lasse Marburg 2021
* @license MIT
*/
const plugin = require('../plugin.js')
var schema = require('./schema.json')
const events = require('events')
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor
/** @typedef {import('../../game.js').Game} Game */
/** @typedef {import('../../session.js').Session} Session */
/** @typedef {import('../../types').AdaptorAction} Action */
/**
* extend states with switch, listener and event options
*
* @class Logic
* @extends {plugin.Plugin}
*/
class Logic extends plugin.Plugin {
constructor() {
super(schema)
this.name = "logic"
this.core = true
this.listener = []
this.event = new events.EventEmitter()
this.event.setMaxListeners(50)
}
/**
* install change stream listeners for all custom collections and the session collection
*
* @param {*} config - game variables, functions, eventemitter etc.
* @param {Database} config.game.db - game database connector instance
* @returns {Object} current logic plugin schema
*/
setup(config, game) {
super.setup(config, game)
this.schema["game"] = this.game.name
this.addActionTemplates()
this.runtime_collections = []
this.runtime_collections = this.game.setup.collections.slice()
this.runtime_collections.push("sessions")
for(let collection of this.runtime_collections) {
if(!this.game.db.hasOwnProperty(collection)) {
log.error(this.name, collection + " not found")
break
}
let stream = this.game.db[collection].changes({ fullDocument: 'updateLookup' })
let change_listener = (change) => {
if(change.operationType != 'delete' && change.operationType != 'invalidate')
{
if(change.operationType == 'update') {
this.event.emit("change", {document:change.fullDocument, updated:change.updateDescription.updatedFields, removed:change.updateDescription.removedFields, collection:this.game.db[collection]})
//log.debug(this.name, change.updateDescription.updatedFields, change.updateDescription.removedFields)
} else {
this.event.emit("change", {document:change.fullDocument, collection:this.game.db[collection]})
}
}
}
stream.on("change", change_listener)
}
this.schema.definitions.function.enum = Object.keys(this.game.getFunctions())
this.game.status.on("functions_update", async functions => {
this.schema.definitions.function.enum = Object.keys(functions)
await this.schemaUpdate(this.schema)
})
return this.schema
}
addActionTemplates() {
this.addConditionTemplate("onChange")
this.addConditionTemplate("switch")
this.addTemplate("iterate", (payload, action) => {
let body = []
if(payload.repeat) {
body.push({text: "Iterate elements repeatedly", next: payload.next})
} else {
body.push({text: "Iterate once with each element", next: payload.next})
}
if(payload.reverse) {
body[0].text += " in reverse order"
}
return { title:`logic ${action.name}`, subtitle: `For '${payload.variable}' of ${payload.with}`, body:body}
})
this.addConditionTemplate("onEvent", {
value_name: "message",
subtitle_insertion:"`Wait for '${payload.event}' from ${payload.from || 'current session'}`"
})
this.addTemplate("function", (payload, action) => {
let body = []
let args = ""
if(payload.arguments && payload.arguments.length) {
args = payload.arguments.toString()
}
/*
if(payload.arguments) {
body.push({ text: `Args: ${payload.arguments.toString()}` })
};
*/
if(Array.isArray(payload.next)) {
for(let i = 0; i < payload.next.length; i++) {
body.push({text:`next: ${i} then ${payload.next[i]}`, next:payload.next[i]})
}
}
return { title:`logic ${action.name}`, subtitle:`Call ${payload.function}(${args})`, body:body}
})
}
/**
* generator function that collects all property names the schema defines in sorted strings.
* nested properties are appended to each other using dot notation.
* e.g.: "first.second.third"
*
* @param {Object} obj-
* @param {string} [previous=""]
*
* @returns {Array} - list of property path strings
*/
* schemaToPath(obj, previous = "") {
for (const [key, value] of Object.entries(obj)) {
if (value.hasOwnProperty("properties")) {
if (previous) yield* this.schemaToPath(value, previous + "." + key)
else yield* this.schemaToPath(value, key)
} else if (key == "properties") {
yield* this.schemaToPath(value, previous);
} else if (typeof value == "object" && !Array.isArray(value)){
if (previous) yield previous + "." + key
else yield key
}
}
}
/**
* @type {Action}
*
* dispatches next based on condition.
*
* If there is more then one condition matching the upmost condition matches.
*
* If no condition matches else is dispatched if it was defined
*
* @returns {Promise<string|undefined>} next state or undefined if none of the conditions matched and no else was defined
*/
async switch(data, session) {
if(data.hasOwnProperty("if")) {
for(let if_condition of data["if"]) {
let condition = new Condition(if_condition, session, this.game)
let match = await condition.match()
if(match) {
return match.next
}
}
if (data.hasOwnProperty("else")) {
if(data.else.hasOwnProperty("next")) {
return data.else.next
} else {
session.log.warn("else is missing a 'next' property")
}
} else {
session.log.debug("no match and no else was defined")
}
}
}
/**
* @type {Action}
*
* Get next element of array, collection reference or object (key / value) and store it to new variable
*/
async iterate(data, session) {
let reference = {}
let value
let with_collection = false
if(/^\[\[.*\]\]$/.test(data.with)) {
reference = await session.variables.getVariableReference(data.with)
if (reference.field) {
value = await session.variables.getValue(reference.variable)
} else {
with_collection = true
}
} else {
value = await session.variables.review(data.with)
value = session.variables.parseJSON(value)
}
if(!with_collection) {
let element
if(!isNaN(parseInt(value))) {
let index = await this.getIndex(data.reverse, data.repeat, session, value)
if(index < 0) {
return
}
element = index+1
} else if(Array.isArray(value) || typeof value === "string") {
let index = await this.getIndex(data.reverse, data.repeat, session, value.length)
if(index < 0) {
return
}
element = value[index]
} else if(typeof value === "object") {
let entries = Object.entries(value)
let index = await this.getIndex(data.reverse, data.repeat, session, entries.length)
if(index < 0) {
return
}
element = {key: entries[index][0], value:entries[index][1]}
} else {
throw new adaptor.InvalidError(`Can not iterate with value ${value} of type ${typeof value}`)
}
await session.variables.set(data.variable, element)
session.log(`Use next element ${JSON.stringify(element)} as "${data.variable}"`)
} else {
let items = await this.game.db[reference.collection].find(reference.query)
items = session.variables.sortReferencedDocuments(items, reference.query)
let index = await this.getIndex(data.reverse, data.repeat, session, items.length)
if(index < 0) {
return
}
let next_item = items[index]
await session.createReference(reference["collection"], {_id:next_item._id}, data["variable"])
session.log(`Use next ${reference.collection} item ${next_item.name || next_item._id} as "${data.variable}"`)
}
return data.next
}
/**
* Manages an index counter for iterating over a collection of elements.
* If the index is higher then the number of elements or lower then 0 then it will return -1
* If repeat is enabled restart with first element if last element was reached in previous iteration
* If reverse is enabled iterate in reverse order
* If action_data contains ended = true index will be reset.
*
* @param {boolean} reverse - wether to iterate in reverse order and to step index from highest (max) to lowest (0)
* @param {Session} session - session API
* @param {boolean} repeat - if enabled restart with first element if last element was reached in previous iteration
* @param {number} max_elements - total number of items or elements (e.g. length of array)
* @returns {Promise<number>} - current index value. -1 if index is higher then number of elements or lower then 0
*/
async getIndex(reverse, repeat, session, max_elements) {
let action_data = await session.variables.load()
let result = -1
if(!max_elements) {
action_data = {
total: 0,
index: 0,
loop: 0
}
} else if(!action_data || action_data.ended) {
action_data = {
total: max_elements,
loop: 1
}
if(reverse) {
action_data["index"] = max_elements -1
} else {
action_data["index"] = 0
}
result = action_data.index
} else if(reverse) {
action_data.total = max_elements
action_data.index --
if(action_data.index >= 0) {
result = action_data.index
} else if (repeat) {
action_data.loop ++
action_data.index = max_elements -1
result = max_elements -1
} else {
action_data.index = 0
}
} else {
action_data.total = max_elements
action_data.index ++
if(action_data.index < max_elements) {
result = action_data.index
} else if (repeat) {
action_data.loop ++
action_data.index = 0
result = 0
} else {
action_data.index = max_elements - 1
}
}
if(result < 0) {
action_data.ended = true
} else {
action_data.ended = false
}
await session.variables.store(action_data, true)
return result
}
/**
* @type {Action}
*
* Listen for changes of a database value/variable
*
* returns next state if there is an instant match.
* Otherwise database change listener will be installed and cancel function is returned.
*
*/
async onChange(data, session) {
let listener = new ChangeListener(data, session, this.event, this.game)
let match = await listener.review()
if(match) {
listener.cancel()
return match.next
}
return listener.cancel.bind(listener)
}
/**
* @type {Action}
*/
async onEvent(data, session) {
let listener = new EventListener(data, session, this.game)
await listener.init()
return listener.cancel.bind(listener)
}
/**
* @type {Action}
*/
async dispatchEvent(data, session) {
let event_source = session
if(data.hasOwnProperty("source")) {
event_source = await session.variables.getItem(data.source)
}
if(!event_source) {
throw new adaptor.InvalidError(`Can not dispatch event ${data.name}. ${data.source} is not a valid item`)
}
if(!event_source.hasOwnProperty("event")) {
event_source.event = new events.EventEmitter()
}
session.log(`dispatch event '${data.name}' from ${event_source.name || data.source} `)
data.message = session.variables.parseJSON(data.message)
this.dispatch(data["name"], data["message"], event_source.event)
}
/**
* emit game or session event.
*
* @param {string} event
* @param {*} payload
* @param {EventEmitter} eventemitter
*/
dispatch(event, payload, eventemitter) {
if(event) {
eventemitter.emit(event, payload)
}
}
/**
* Call a default or a custom function from the games functions
*
* @param {*} data
* @param {*} session
*/
async function(data, session) {
let functions = this.game.getFunctions()
if(typeof functions[data.function] === "function") {
session.log("call function " + data.function + " with args " + data['arguments'])
if(Array.isArray(data.arguments)) {
data.arguments.forEach((arg, i) => {
data.arguments[i] = session.variables.parseJSON(arg)
})
} else {
data.arguments = session.variables.parseJSON(data.arguments)
}
let result = await functions[data.function](data['arguments'], {session:session, game:this.game})
await session.variables.store(result)
if(data.hasOwnProperty("next")) {
if(result.hasOwnProperty("next")) {
if(data.next[result.next]) {
return data.next[result.next]
} else {
throw new adaptor.InvalidError(`Can not dispatch next from function ${data.function}. No next defined at position ${result.next}`)
}
}
}
} else {
throw new adaptor.InvalidError(data.function + " is not a valid function")
}
}
}
class Condition {
/**
* check different types of conditions and dispatch next on a match
*
* @param {Object} properties - Condition properties
* @param {*} properties.value - The value to review condition against
* @param {string} properties.next - Next state value that will be returned on a matching condition
* @param {string} [properties.respond] - Immediate response on match
* @param {boolean} properties.case_sensitive - Wether or not to only allow case sensitive matching
* @param {import("../../session").Session} session - Session context object
* @param {import("../../game").Game} game - Game context object
*/
constructor(properties, session, game) {
this.session = session
this.game = game
this.value = properties["value"]
this.next = properties["next"]
this.respond = properties["respond"]
this.case_sensitive = properties["case_sensitive"] || false
if(properties.hasOwnProperty("equals")) {
this.condition = properties.equals
this.survey = this.equals
this.name = "equals"
} else if(properties.hasOwnProperty("lessThan")) {
this.condition = properties.lessThan
this.survey = this.lessThan
this.name = "less than"
} else if(properties.hasOwnProperty("greaterThan")) {
this.condition = properties.greaterThan
this.survey = this.greaterThan
this.name = "greater than"
} else if(properties.hasOwnProperty("contains")) {
this.condition = properties.contains
this.survey = this.contains
this.name = "contains"
} else if(properties.hasOwnProperty("regex")) {
this.condition = properties.regex
this.survey = this.regex
this.name = "regex"
} else if(properties.hasOwnProperty("javascript")) {
this.condition = properties.javascript
this.survey = this.javascript
this.name = "javascript"
} else if (properties.hasOwnProperty("find")) {
this.condition = properties.find
this.survey = this.query
this.name = "query"
}
}
/**
* trigger next state based on match in one of:
* - equals
* - contains
* - regex
* - query
*
* @param {Object} data - properties that resolve that would return match under certain conditions
* @param {Object} variables - state context instance of session Variables Object
*
* @returns {Promise} returns a string defining the next state that condition wants to dispatch or undefined it there is no match
*/
match() {
return new Promise((resolve, reject) => {
this.evaluate()
.then(result => {
if(Array.isArray(result.condition)) {
if(result.condition) {
log.trace(this.name, "compare condition '" + result.condition.join(',').substring(0, 30) + "'... with value '" + result.value + "'. case sensitive: " + this.case_sensitive)
}
} else if (typeof result.condition === "string") {
log.trace(this.name, "compare condition '" + result.condition.substring(0, 30) + "' with value '" + result.value + "'. case sensitive: " + this.case_sensitive)
} else {
log.trace(this.name, "compare condition '" + JSON.stringify(result.condition) + "' with value '" + result.value + "'. case sensitive: " + this.case_sensitive)
}
return this.survey(result.value, result.condition)
})
.then(result => {
return resolve(this.feedback(result))
})
.catch(error => {
return reject(error)
})
})
}
/**
* Get survey result. Maybe next state and or respond or undefined
*
* Is separated from `match()` because messenger `DialogueCondition` requires a more sophisticated handling.
*
* @param {Object|string|undefined} result - matching value returned by survey function
* @returns {{next:string, respond:string, match:string|Object}|undefined} - next state to dispatch or undefined
*/
feedback(result) {
if(typeof result !== "undefined") {
if(this.respond) {
log.debug(this.name, `condition matches '${result}'. Next: ${this.next}, Respond: ${this.respond}`)
this.session.variables.store({match:result})
return {next: this.next, respond: this.respond, match: result}
}
if(this.next) {
log.debug(this.name, `condition matches '${result}'. Next: ${this.next}`)
this.session.variables.store({match:result})
return {next: this.next, match: result}
}
log.warn(this.name, "no next and no response defined")
}
return undefined
}
/**
* review value / condition pair to dereference variables if any
* Merge arrays in conditions if its an array of arrays (flat)
* convert undefined value to 'undefined' string to allow matching 'undefined' variables except for javascript condition
*
* @returns {Promise} condition and value property with replaced variables if any
*/
async evaluate() {
if(this.name == "query") {
this.condition.query = await this.session.variables.review(this.condition.query)
} else if(Array.isArray(this.condition)) {
let review_conditions = this.condition.map(condition => {
return this.session.variables.review(condition)
})
this.condition = await Promise.all(review_conditions)
this.condition = this.condition.flat()
this.condition = this.condition.map(condition => {
return this.cast(condition)
})
} else {
this.condition = await this.session.variables.review(this.condition)
this.condition = this.cast(this.condition)
}
if(this.hasOwnProperty("value")) {
let review_value = await this.session.variables.review(this.value)
this.value = this.cast(review_value)
return {condition:this.condition, value:this.value}
} else {
return {condition:this.condition}
}
}
/**
* cast to string except for javascript condition
*
* convert undefined to 'undefined' string to allow matching 'undefined' variables
* @param {*} data
* @returns {*} converted data
*/
cast(data) {
if(typeof data === "string" || this.name == "javascript"){
return data
} else if (typeof data === "undefined") {
return 'undefined'
} else if (typeof data.toString === "function") {
return data.toString()
}
}
/**
* Convert to upper case if condition is not set to be case sensitive
*
* @param {string} val - value to upper case if not case sensitive
* @returns {string} value or value to upper case
*/
caseSensitivity(val) {
if(!this.case_sensitive) {
return val.toUpperCase()
}
return val
}
/**
* check value against condition
* if value matches condition exactly or
* if condition is empty string or
* if one item in condition array matches value exactly, return value
*
* else return undefined
*
* @returns {Promise} the value if it matched the condition or undefined
*/
equals(value, condition) {
return new Promise((resolve, reject) => {
if(typeof condition === "string") {
if(this.caseSensitivity(value) == this.caseSensitivity(condition) || condition == "") {
return resolve(value)
}
} else if(Array.isArray(condition)) {
let upper = condition.map(cond => {return this.caseSensitivity(cond)})
if(upper.includes(this.caseSensitivity(value))) {
return resolve(value)
}
} else {
log.warn(this.name, "can not resolve condition because condition type is neither string nor array")
}
return resolve(undefined)
})
}
/**
* check value against condition
* if value is Number and less then condition return value
*/
lessThan(value, condition) {
return new Promise((resolve, reject) => {
if(!isNaN(value)) {
if(!isNaN(condition)) {
if(condition == "") {
log.warn(this.name, "empty condition string")
return resolve(undefined)
} else if(value == "") {
log.warn(this.name, "empty value string")
return resolve(undefined)
}
if(Number(value) < Number(condition)) {
return resolve(value)
}
} else if(Array.isArray(condition)) {
for(let cond of condition) {
if(!isNaN(cond)) {
if(condition == "") {
log.warn(this.name, "contains empty condition string")
} else if(value == "") {
log.warn(this.name, "contains empty value string")
} else if(Number(value) < Number(cond)) {
return resolve(value)
}
} else {
log.warn(this.name, cond + " is not a Number")
}
}
} else {
log.warn(this.name, "can not resolve because condition " + condition +" is neither Number nor Array")
return resolve(undefined)
}
} else {
log.warn(this.name, "can not resolve because value " + value + " is neither Number nor Array")
return resolve(undefined)
}
return resolve(undefined)
})
}
/**
* check value against condition
* if value is Number and greater then condition return value
*/
greaterThan(value, condition) {
return new Promise((resolve, reject) => {
if(!isNaN(value)) {
if(!isNaN(condition)) {
if(condition == "") {
log.warn(this.name, "empty condition string")
return resolve(undefined)
} else if(value == "") {
log.warn(this.name, "empty value string")
return resolve(undefined)
}
if(Number(value) > Number(condition)) {
return resolve(value)
}
} else if(Array.isArray(condition)) {
for(let cond of condition) {
if(!isNaN(cond)) {
if(condition == "") {
log.warn(this.name, "contains empty condition string")
} else if(value == "") {
log.warn(this.name, "contains empty value string")
} else if(Number(value) > Number(cond)) {
return resolve(value)
}
} else {
log.warn(this.name, cond + " is not a Number")
}
}
} else {
log.warn(this.name, "can not resolve because condition " + condition +" is neither Number nor Array")
return resolve(undefined)
}
} else {
log.warn(this.name, "can not resolve because value " + value + " is neither Number nor Array")
return resolve(undefined)
}
return resolve(undefined)
})
}
/**
* check value against condition
* if condition is contained in value or
* if condition is empty string or
* if one item in condition array is contained in value return condition that matched the value
*
* else return undefined
*
* @returns {Promise} condition that matched the value or undefined
*/
contains(value, condition) {
return new Promise((resolve, reject) => {
if(typeof condition === "string") {
if(this.caseSensitivity(value).indexOf(this.caseSensitivity(condition)) >= 0) {
if(condition == '') {
return resolve(value)
}
return resolve(condition)
}
} else if(Array.isArray(condition)) {
for(let cond of condition) {
if(this.caseSensitivity(value).indexOf(this.caseSensitivity(cond)) >= 0) {
return resolve(cond)
}
}
} else {
log.warn(this.name, "can not resolve condition because condition type is neither string nor array")
}
return resolve(undefined)
})
}
/**
* check value against regex
* if regex matches, resolve with first match
* if condition is an array, it iterates over all conditions and resolves if/once any of them matches
* if case_sensitive is "false" or not defined the "i" flag will be added
* regex returns after first match (not g / global)
*
* @returns {Promise} part of value that was matching the regex. If none did, undefined
*/
regex(value, condition) {
return new Promise((resolve, reject) => {
let flags = "i"
if(this.case_sensitive) {
flags = ""
}
if(typeof condition === "string") {
let res = value.match(new RegExp(condition, flags))
if(res) {
return resolve(res[0])
}
} else if(Array.isArray(condition)) {
for(let cond of condition) {
let res = value.match(new RegExp(cond, flags))
if(res) {
return resolve(res[0])
}
}
} else {
log.warn(this.name, "can not resolve condition because condition type is neither string nor array")
}
return resolve(undefined)
})
}
/**
* Call javascript snippet using AsyncFunction constructor https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncFunction
*
* @param {*} value - first and only argument for js function
* @param {*} condition - javascript snippet that will be executed in function body
* @returns {*} the return value of the function script (undefined if nothing is returned)
*/
async javascript(value, condition) {
try {
var anonymous_function = new AsyncFunction('value','session','game', condition)
} catch (error) {
throw new adaptor.InvalidError('Failed to compile javascript condition\n' + error.message)
}
try {
var return_val = await anonymous_function(value, this.session, this.game)
} catch (error) {
throw new adaptor.InvalidError('Failed to execute javascript condition\n' + error.message)
}
return return_val
}
/**
* if path and value are defined, true is returned if values are equal.
* without path and value, true is returned if query returns any document in collection
*
* @param {String} condition.collection - what collection to search in
* @param {Object} condition.query - mongo find query.
* @param {String} condition.path - '.' noted path to address distinct property in document returned by query
* @param {String} value - if condition contains path, compare value with what is in document at path
*/
query(value, condition) {
return new Promise((resolve, reject) => {
if(!this.game.db.hasOwnProperty(condition.collection)) {
return reject("Collection " + condition.collection + " not found.")
}
if(!condition.hasOwnProperty("query")) {
return reject("Query property missing.")
}
let find_query = this.session.variables.parseQuery(condition.query)
this.game.db[condition.collection].find(find_query)
.then(result => {
if(!result) {
return resolve(undefined)
}
log.trace(this.name, "matches if " + JSON.stringify(find_query) + " in collection " + condition.collection + " returns 1 or more Documents")
if(result.length > 0) {
return resolve(result[0])
} else {
return resolve(undefined)
}
})
.catch(error => {
return reject(error)
})
})
}
}
/**
* creates listener for changes to custom database collections.
*
* A match immediately cancels and deactivates (active=false) the listener.
* it is not unlikely that changes to a collection are made at practically the same moment,
* so there may be unresolved promises in the pipeline even though the event listener was canceled.
* Thats why any output after a first match is blocked so there are no multiple next states.
*
* @param {Object} data - listen action data
* @session
*/
class ChangeListener {
constructor(data, session, event, game) {
Object.assign(this, data)
this.active = true
this.session = session
this.game = game
this.event = event
this.rev = (change) => {
this.review(change)
.then(result => {
if(result) {
if(this.active) {
this.active = false
session.next(result.next)
} else {
session.log("tried to dispatch next state but was already canceled")
}
}
})
.catch(error => {
session.log.error(error)
})
}
this.event.on("change", this.rev)
session.log.info(`${session.action.name} installed.`)
}
/**
* run match functions to check the changed collections for listener conditions
*
* begins with: ignore changes if change results from storing a listener match in the current state
* listener does not cancel quick enough to ignore this change so it would otherwise trigger twice
*
* @todo make a more sophisticated pre check what documents or changes to take into account
* this might also make the above store ignore obsolete
*/
review(change) {
return new Promise((resolve, reject) => {
if(change) {
if(change.collection.name == "sessions") {
if(change.hasOwnProperty("updated")) {
if(change.updated.hasOwnProperty(this.session.state.name + '.' + this.session.action.name)) {
// this.session.log.info("IGNORE UPDATE", change.updated)
return resolve(undefined)
}
}
}
}
if(this.hasOwnProperty("if")) {
let condition_promises = []
for(let if_condition of this["if"]) {
let condition = new Condition(if_condition, this.session, this.game)
condition_promises.push(condition.match())
}
Promise.all(condition_promises)
.then(result => {
for(let match of result) {
if(match) {
return resolve(match)
}
}
return resolve(undefined)
})
.catch(error => {
return reject(error)
})
}
})
}
/**
* remove local db change event listener.
* reset active flag to suppress multiple triggers of this same event.
*/
cancel() {
this.active = false
this.event.removeListener("change", this.rev)
this.session.log.debug("closed. " + this.event.listenerCount("change") + " listeners left open")
}
}
/**
* Listen for events from data items, plugin items, game or session and evaluate conditions regarding the event and it's data load.
*/
class EventListener {
/**
* @param {Object} properties - properties as set by level designer
* @param {string} properties.event - event name
* @param {array} properties.if - list of conditions to check against (@see {@link Condition} class)
* @param {Object} properties.else - if event was dispatched but no condition matched
* @param {string} properties.else.next - state to dispatch on else
* @param {Session} session - session context instance
* @param {Game} game - game context instance
*/
constructor(properties, session, game) {
// Allows for adaptor v1 On Event to work
if(properties.hasOwnProperty("then")) {
properties["if"] = properties.then["if"]
properties["next"] = properties.then["next"]
}
Object.assign(this, properties)
/** @type {Session} */
this.session = session
/** @type {Game} */
this.game = game
}
async init() {
if(this.hasOwnProperty("from")) {
this.event_source = await this.session.variables.getItem(this.from)
} else {
this.event_source = this.session
}
if(!this.event_source) {
throw new adaptor.NotFoundError(`${this.from} does not resolve a variable Item`)
}
if(!this.event_source.hasOwnProperty("event")) {
this.event_source.event = new events.EventEmitter()
}
if(this.hasOwnProperty("event")) {
this.event = await this.session.variables.review(this.event)
this.listenerCallback = this.session.getCallback(this.review.bind(this))
this.event_source.event.on(this.event, this.listenerCallback)
this.session.log.info(`Listening on '${this.event}' event from ${this.event_source.name || this.from}`)
} else {
this.session.log.error("Can not register Event listener. No Event name provided.")
}
}
/**
* Check for condition matches with the event payload
*
* If event listener condition has field property, match condition against this field in payload.
*
* dispatch next state without condition check wherever 'if' property is missing
*
* @param {*} payload - event payload message provided with the event. Might be anything (including undefined/null)
*
* @returns {Promise<boolean>} - true if next state was triggered by else option
*/
async review(payload) {
await this.session.variables.store({message:payload, name:this.event})
if(this.hasOwnProperty("if") && Array.isArray(this['if'])) {
for(let if_condition of this["if"]) {
if(if_condition["field"]) {
if(typeof payload !== 'object') {
this.session.log(`'${this.event}' message is not of type object`)
continue
}
if_condition['value'] = adaptor.getPath(payload, if_condition.field)
if(typeof if_condition.value === 'undefined') {
this.session.log(`'${this.event}' message has no field ${if_condition.field}`)
continue
}
} else {
if_condition['value'] = payload
}
let condition = new Condition(if_condition, this.session, this.game)
let match = await condition.match()
if(match) {
this.session.next(match.next)
return
}
}
}
if (this.hasOwnProperty("else")) {
if(this.else.next) {
this.session.next(this.else.next)
return true
} else {
this.session.log.warn("'next' missing in else to handle event: " + this.event)
}
}
if (this.hasOwnProperty("next")) {
this.session.next(this.next)
return true
}
this.session.log(`no match on event '${this.event}' and no else or next was defined`)
}
/**
* remove game and session event listener
*
*/
cancel() {
this.event_source.event.removeListener(this.event, this.listenerCallback)
this.session.log(`Stop waiting for '${this.event}' Event. ${this.event_source.event.listenerCount(this.event)} '${this.event}' listeners left open for event source ${this.event_source.name || this.from}`)
}
}
module.exports.Plugin = Logic
module.exports.Condition = Condition