adaptorex
Version:
Connect all your live interactive storytelling devices and software
1,272 lines (1,169 loc) • 38.7 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"
}
if (payload.onDone) {
body.push({
text: `On Done: ${payload.onDone.next}`,
next: payload.onDone.next
})
}
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 this.nextOnDone(data)
}
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 this.nextOnDone(data)
}
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 this.nextOnDone(data)
}
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 this.nextOnDone(data)
}
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
}
nextOnDone(data) {
if (data.onDone && data.onDone.next && data.onDone.next.length > 0) {
return data.onDone.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