adaptorex
Version:
Connect all your live interactive storytelling devices and software
391 lines (347 loc) • 11 kB
JavaScript
/**
* Allows to control adaptor:ex basic functionalities.
*
* control is a core plugin and will be included with every game
*
* @requires plugin
* @requires medley
*
* @module control/control
* @copyright Lasse Marburg 2022
* @license MIT
*/
const plugin = require("../plugin.js")
const schema = require("./schema.json")
const { Condition } = require("../logic/logic.js")
/** @typedef {import('../../types').AdaptorAction} Action */
/**
* control this or remote sessions
*
*
* @class SessionControl
* @extends {plugin.Plugin}
*/
class SessionControl extends plugin.Plugin {
constructor() {
super(schema)
this.core = true
}
/**
* @param {Object} config
* @returns {Object} - schema
*/
setup(config, game) {
super.setup(config, game)
this.addTemplate("split", (payload, action) => {
const title = `control ${action.name}`
const subtitle = `Split up ${payload.length} new paths`
const body = []
for (let path of payload) {
body.push({ text: `${path.name}`, next: path.next })
}
return { title, subtitle, body }
})
this.addTemplate("join", (payload, action) => {
const title = `control ${action.name}`
const subtitle = `Join ${payload.length} paths`
const body = []
for (let path of payload) {
body.push({ text: path })
}
return { title, subtitle, body }
})
this.addTemplate("launch", (payload, action) => {
const title = `control ${action.name}`
let subtitle = `Launch ${payload.level}`
const body = []
if (payload.content) {
subtitle += `(${payload.content})`
}
if (payload.reference) {
subtitle += ` as ${payload.reference}`
}
if (payload.name) {
body.push({ text: `name: ${payload.name}` })
}
if (payload.arguments) {
body.push({ text: `Level Arguments:` })
for (let arg of payload.arguments) {
body.push({ text: `${arg.name} = ${arg.value}` })
}
}
return { title, subtitle, body }
})
this.addConditionTemplate("onError", {
value_name: "error message",
subtitle_insertion:
"`Catch ${payload.type} in this ${payload.from || 'state'}`"
})
this.log = function (data, session) {
session.log[data.level](data.message)
}
return this.schema
}
/**
* Cue next state
*
* @param {string} data - state name to cue next
* @param {Object} session - session context variables and functions
* @returns state name to cue next
*/
next(data, session) {
if (!data || !data.next) {
throw new adaptor.NotFoundError(`Next action is missing 'next' value.`)
}
return data.next
}
/**
* Empty listener action with the purpose to show at what state a manual operation might be necessary.
*
* @param {string} data - cue text and state name to cue manually
* @param {Object} session - session context variables and functions
* @returns {function} - empty 'cancel' function
*/
onCue(data, session) {
session.log(
`Wait for manual operation to goto '${data.next}' on cue '${data.cue}'`
)
return () => {}
}
/**
* quit this session
*
* The function will also be called on game quit which is smething different and will not ave any effect here.
*
* @param {boolean|undefined} data - undefined/true
* @param {Object} session - session context variables and functions
*/
async quit(data, session) {
if (session) {
await this.game.cancelSession({ _id: session._id })
}
}
/**
* call session splitPath function to split a new path. Create uid pathname if name is not provided.
* @param {Object} data - path action properties next and name
* @param {Object} session - session context variables and functions
*/
split(data, session) {
if (Array.isArray(data)) {
for (let split of data) {
if (split.hasOwnProperty("name")) {
session.splitPath(split.next, split.name)
} else {
session.splitPath(split.next, adaptor.createId())
}
}
} else {
throw new Error("split path requires an array of paths to be split.")
}
}
/**
* cancel a specific path
*
* This action does not have any functionality. Join adds the specified paths to the path list.
* This way they get canceled once the state is cued.
*
* @param {Object} data - join action properties
* @param {Array} data.path - list of origin paths
* @param {*} session - session context variables and functions
*/
async join(data, session) {
// await session.joinPath(data) // not required
}
/**
* create a new session
*
* @param {Object} data - launch action properties
* @param {string} data.level - level the session will be based on
* @param {Object} [data.arguments] - arguments to forward to new session
* @param {string} [data.name] - name for new session document
* @param {string} [data.reference] - name for reference of new session inside origin session
* @param {Object} session - session context variables and functions
*/
async launch(data, session) {
data.level = await session.variables.review(data.level)
data.name = await session.variables.review(data.name)
data.content = await session.variables.review(data.content)
if (!data.level) {
throw new adaptor.InvalidError(
`Could not launch session. Level is undefined or not a valid level name.`
)
}
let args = {}
let arg_promises = []
if (Array.isArray(data["arguments"])) {
arg_promises = data["arguments"].map(async (arg) => {
args[arg.name] = await this.resolveArgument(arg.value, session)
})
} else if (typeof data["arguments"] === "object") {
arg_promises = Object.entries(data["arguments"]).map(async (arg) => {
args[arg[0]] = await this.resolveArgument(`[[${arg[1]}]]`, session)
}) // Make level arguments compatible with before v.2.7
}
await Promise.all(arg_promises)
var new_session = await this.game.createSession(
{
level: data.level,
arguments: args,
name: data.name,
content: data.content
},
{ login: session.name + " launch session action" }
)
await session.variables.store({
_id: new_session._id,
name: new_session.name
})
if (data.hasOwnProperty("reference")) {
await session.createReference(
"sessions",
{ _id: new_session._id },
data.reference
)
}
}
async resolveArgument(arg_value, session) {
if (/^\[\[.*\]\]$/.test(arg_value)) {
let reference = await session.variables.getVariableReference(arg_value)
if (reference.field) {
let value = await session.variables.getValue(reference.variable)
return { value: value }
} else {
return reference
}
} else {
let value = await session.variables.review(arg_value)
value = session.variables.parseJSON(value)
return { value: value }
}
}
/**
* Cancel one or more sessions based on session name, reference or query
*
* @type {Action}
*/
async cancel(data, session) {
if (!data.session) {
throw new adaptor.InvalidError(
`Can not cancel session. The session value is empty.`
)
}
if (
typeof data.session === "object" &&
data.session.hasOwnProperty("_id")
) {
session.log(
`Cancel session ${data.session.name || ""} ${data.session._id}`
)
data.session = { _id: data.session._id }
} else if (
typeof data.session === "string" &&
!/.*[{}:].*/.test(data.session) &&
!this.game.findSession("name", data.session)
) {
let session_ref = await session.variables.getReference(data.session)
if (session_ref.topic != "session") {
throw new adaptor.InvalidError(
`Can not cancel session. ${data.session} is not a session. ${data.session} turns out to be a ${session_ref.topic} item.`
)
}
if (session_ref.field) {
throw new adaptor.NotFoundError(
`Can not cancel session. ${data.session} doesn't resolve an active session.`
)
} else {
session.log(`Cancel session ${JSON.stringify(session_ref.query)}`)
data.session = session_ref.query
}
} else {
data.session = session.variables.parseQuery(data.session)
session.log(`Cancel session ${JSON.stringify(data.session)}`)
}
try {
await this.game.cancelSession(data.session)
} catch (error) {
if (error instanceof adaptor.NotFoundError) {
session.log.error(error)
} else {
throw error
}
}
}
/**
* Listen for error pipeline and GoTo next if condition matches
*
* @type {Action}
*/
async onError(data, session) {
let listener = new ErrorListener(data, session, this.game)
// await listener.init()
return listener.cancel.bind(listener)
}
}
class ErrorListener {
constructor(properties, session, game) {
Object.assign(this, properties)
/** @type {Session} */
this.session = session
/** @type {Game} */
this.game = game
this.rev = async (error) => {
try {
await this.review(error)
} catch (error) {
session.log.error(
"An Error occurred while trying to catch another error."
)
session.log.error(error)
}
}
this.session.event.on("error", this.rev)
this.session.log.info(
`Listening for '${this.type}' errors in '${this.from}'`
)
}
async init() {}
async review(error) {
if (this.from == "state" && error.state.id != this.session.state.id) {
return
}
if (
error.name == this.type ||
error.type == this.type ||
this.type == "Error"
) {
await this.session.variables.store(error)
if (this.hasOwnProperty("if") && Array.isArray(this["if"])) {
for (let if_condition of this["if"]) {
if_condition["value"] = error.message
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 error: " + error.message
)
}
}
}
}
cancel() {
this.session.event.removeListener("error", this.rev)
this.session.log(
`Stop waiting for error events. ${this.session.event.listenerCount("error") - 1} error listeners left open in session ${this.session.name}`
)
}
}
module.exports.Plugin = SessionControl