diff --git a/package.json b/package.json index f634848..ba4ca9f 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "typescript": "^5.5.2" }, "scripts": { - "prettier": "prettier --write \"**/*.{ts,md,json,js,cjs}\"", + "prettier": "prettier --write \"**/*.{ts,json,js,cjs}\"", "build": "tsc && node build.mjs", "prepack": "yarn build", "test": "mocha --require esbuild-register --extension js,ts,cjs,mjs tests", @@ -61,7 +61,8 @@ }, "prettier": { "semi": false, - "tabWidth": 4 + "tabWidth": 4, + "trailingComma": "es5" }, "publishConfig": { "access": "public" diff --git a/src/handleEvent.ts b/src/handleEvent.ts index 59a3c66..8684346 100644 --- a/src/handleEvent.ts +++ b/src/handleEvent.ts @@ -16,220 +16,228 @@ import { handleActions, test } from "./index" import { deepClone, findNamedChild, set } from "./utils" -import { - HandleEventOptions, - HandleEventReturn, - InStateEventHandler, - StateMachineLike, -} from "./types" +import { CATObject, HandleEventOptions, HandleEventReturn, StateMachineLike } from "./types" +import { getLogger } from "./logging" /** - * This function simulates an event happening, as if in game. - * Given a state machine definition, the event, and a few other things, you can inspect the results. - * - * @param definition The state machine definition. - * @param context The current context of the state machine. - * @param event The event object. - * @param options Various other settings and details needed for the implementation. - * @returns The state machine and related data after performing the operation. + * Run conditions, actions, and transitions. */ -export function handleEvent( +function runCAT( + handler: CATObject, definition: StateMachineLike>, - context: Partial, + newContext: Partial, event: Event, options: HandleEventOptions, ): HandleEventReturn> { - const log = options.logger || (() => {}) - - const { eventName, currentState = "Start" } = options - - // (current state object - reduces code duplication) - let csObject = definition.States?.[currentState] + const log = getLogger() + // do we need to check conditions? + const shouldCheckConditions = !!handler.Condition + + // does the event handler have any keys that look like actions? + // IOI sometimes puts the actions along-side keys like Transition and Condition, + // yet both still apply + const irregularEventKeys = Object.keys(handler).filter((k) => + k.includes("$"), + ) + const hasIrregularEventKeys = irregularEventKeys.length > 0 + + // do we need to perform actions? + const shouldPerformActions = !!handler.Actions || hasIrregularEventKeys + + // do we need to perform a transition? + const shouldPerformTransition = !!handler.Transition + + const constantKeys = Object.keys(definition.Constants || {}) + + let conditionResult = true + + if (shouldCheckConditions) { + conditionResult = test( + handler.Condition, + { + ...(newContext || {}), + ...(options.contractId && { + ContractId: options.contractId, + }), + ...(definition.Constants || {}), + Value: event, + }, + { + pushUniqueAction(reference, item) { + const referenceArray = findNamedChild( + reference, + newContext, + true, + ) + item = findNamedChild(item, newContext, false) + log( + "action", + `Running pushUniqueAction on ${reference} with ${item}`, + ) - if (!csObject || (!csObject?.[eventName] && !csObject?.$timer)) { - log( - "disregard-event", - `SM in state ${currentState} disregarding ${eventName}`, - ) - // we are here because either: - // - we have no handler for the current state - // - in this particular state, the state machine doesn't care about the current event - return { - context: context, - state: currentState, - } - } + if (!Array.isArray(referenceArray)) { + return false + } - const hasTimerState = !!csObject.$timer + if (referenceArray.includes(item)) { + return false + } - // ensure no circular references are present, and that this won't update the param by accident - let newContext = deepClone(context) + referenceArray.push(item) - const doEventHandler = (handler: InStateEventHandler) => { - // do we need to check conditions? - const shouldCheckConditions = !!handler.Condition + set(newContext, reference, referenceArray) - // does the event handler have any keys that look like actions? - // IOI sometimes puts the actions along-side keys like Transition and Condition, - // yet both still apply - const irregularEventKeys = Object.keys(handler).filter((k) => - k.includes("$"), + return true + }, + timers: options.timers, + eventTimestamp: options.timestamp, + }, ) - const hasIrregularEventKeys = irregularEventKeys.length > 0 - - // do we need to perform actions? - const shouldPerformActions = !!handler.Actions || hasIrregularEventKeys + } - // do we need to perform a transition? - const shouldPerformTransition = !!handler.Transition + if (conditionResult && shouldPerformActions) { + let Actions = handler.Actions || [] - const constantKeys = Object.keys(definition.Constants || {}) + if (!Array.isArray(Actions)) { + Actions = [Actions] + } - let conditionResult = true + if (hasIrregularEventKeys) { + ;(Actions).push( + ...irregularEventKeys.map((key) => { + return { [key]: handler[key] } + }), + ) + } - if (shouldCheckConditions) { - conditionResult = test( - handler.Condition, - { - ...(newContext || {}), - ...(options.contractId && { - ContractId: options.contractId, - }), - ...(definition.Constants || {}), - Value: event, - }, - { - pushUniqueAction(reference, item) { - const referenceArray = findNamedChild( - reference, - newContext, - true, - ) - item = findNamedChild(item, newContext, false) - log( - "action", - `Running pushUniqueAction on ${reference} with ${item}`, - ) - - if (!Array.isArray(referenceArray)) { - return false - } - - if (referenceArray.includes(item)) { - return false - } - - referenceArray.push(item) - - set(newContext, reference, referenceArray) - - return true + for (const actionSet of Actions as unknown[]) { + for (const action of Object.keys(actionSet)) { + newContext = handleActions( + { + [action]: actionSet[action], }, - logger: log, - timers: options.timers, - eventTimestamp: options.timestamp, - }, - ) + { + ...newContext, + ...(definition.Constants || {}), + ...(options.contractId && { + ContractId: options.contractId, + }), + Value: event, + }, + { + originalContext: definition.Context ?? {}, + }, + ) + } } - if (conditionResult && shouldPerformActions) { - let Actions = handler.Actions || [] + // drop this specific event's value + if (newContext["Value"]) { + // @ts-expect-error + delete newContext.Value + } - if (!Array.isArray(Actions)) { - Actions = [Actions] - } + // drop this specific event's ContractId + if (newContext["ContractId"]) { + // @ts-expect-error + delete newContext.ContractId + } - if (hasIrregularEventKeys) { - ;(Actions).push( - ...irregularEventKeys.map((key) => { - return { [key]: handler[key] } - }), - ) - } + // drop the constants + for (const constantKey of constantKeys) { + delete newContext[constantKey] + } + } - for (const actionSet of Actions as unknown[]) { - for (const action of Object.keys(actionSet)) { - newContext = handleActions( - { - [action]: actionSet[action], - }, - { - ...newContext, - ...(definition.Constants || {}), - ...(options.contractId && { - ContractId: options.contractId, - }), - Value: event, - }, - { - originalContext: definition.Context ?? {}, - }, - ) - } - } + let state = options.currentState - // drop this specific event's value - if (newContext.hasOwnProperty("Value")) { - // @ts-expect-error - delete newContext.Value - } + if (conditionResult && shouldPerformTransition) { + state = handler.Transition - // drop this specific event's ContractId - if (newContext.hasOwnProperty("ContractId")) { - // @ts-expect-error - delete newContext.ContractId - } + log( + "transition", + `${options.currentState} is performing a transition to ${state} - running its "-" event`, + ) - // drop the constants - for (const constantKey of constantKeys) { - delete newContext[constantKey] + // When transitioning, we have to reset all timers. + // Since this is pass-by-reference, we have to modify the existing array! + if (options.timers) { + while (options.timers.length > 0) { + options.timers.pop() } } - let state = currentState + return handleEvent( + definition, + newContext, + {}, + { + eventName: "-", + currentState: state, + timers: options.timers, + timestamp: options.timestamp, + contractId: options.contractId, + }, + ) + } - if (conditionResult && shouldPerformTransition) { - state = handler.Transition + return { + context: newContext, + state, + } +} - log( - "transition", - `${currentState} is performing a transition to ${state} - running its "-" event`, - ) +/** + * This function simulates an event happening, as if in game. + * Given a state machine definition, the event, and a few other things, you can inspect the results. + * + * @param definition The state machine definition. + * @param context The current context of the state machine. + * @param event The event object. + * @param options Various other settings and details needed for the implementation. + * @returns The state machine and related data after performing the operation. + */ +export function handleEvent( + definition: StateMachineLike>, + context: Partial, + event: Event, + options: HandleEventOptions, +): HandleEventReturn> { + const log = getLogger() + const { eventName, currentState = "Start" } = options - // When transitioning, we have to reset all timers. - // Since this is pass-by-reference, we have to modify the existing array! - if (options.timers) { - while (options.timers.length > 0) { - options.timers.pop() - } - } + // (current state object - reduces code duplication) + let eventToCatsMap = definition.States?.[currentState] - return handleEvent( - definition, - newContext, - {}, - { - eventName: "-", - currentState: state, - logger: log, - timers: options.timers, - timestamp: options.timestamp, - contractId: options.contractId, - }, - ) - } + const hasPreExecState = !!Object.keys(eventToCatsMap || {}).find((key) => + key.startsWith("$"), + ) + const hasEventState = !!eventToCatsMap?.[eventName] + if (!eventToCatsMap || (!hasEventState && !hasPreExecState)) { + log( + "disregard-event", + `SM in state ${currentState} disregarding ${eventName}`, + ) + // we are here because either: + // - we have no handler for the current state + // - in this particular state, the state machine doesn't care about the current event return { - context: newContext, - state, + context: context, + state: currentState, } } - type EHArray = InStateEventHandler[] + // ensure no circular references are present, and that this won't update the param by accident + let newContext = deepClone(context) + + type CATList = CATObject[] - const doEventHandlers = (eventHandlers: EHArray) => { + function runCATsUntilCompletionOrTransition( + eventHandlers: CATList, + ): HandleEventReturn> | undefined { for (const handler of eventHandlers) { - const out = doEventHandler(handler) + const out = runCAT(handler, definition, newContext, event, options) newContext = out.context @@ -245,28 +253,31 @@ export function handleEvent( return undefined } - let eventHandlers = csObject[eventName] + let cats = eventToCatsMap[eventName] - if (!Array.isArray(eventHandlers)) { + if (!Array.isArray(cats)) { // if we don't have a handler for the current event, but we do for the timer, it produces [undefined] - eventHandlers = [eventHandlers].filter(Boolean) + cats = [cats].filter(Boolean) } - if (hasTimerState) { - const timerState = csObject.$timer - const timerEventHandlers: EHArray = [] + // Handle timers/anything that starts with $ because they need to run first. + for (const preExecStateName of Object.keys(eventToCatsMap).filter((k) => + k.startsWith("$"), + )) { + const preExecState = eventToCatsMap[preExecStateName] + const preExecHandlers: CATList = [] - // Timers will always have to be handled first. + // Timers/pre-execs will always have to be handled first. // An expired timer might transition to another state and that has to happen as soon as possible. - if (Array.isArray(timerState)) { - timerEventHandlers.unshift(...timerState) + if (Array.isArray(preExecState)) { + preExecHandlers.unshift(...preExecState) } else { - timerEventHandlers.unshift(timerState) + preExecHandlers.unshift(preExecState) } // Timers are a special snowflake, if they cause a state transition we have to continue processing normal events. // Since the handlers don't know what they are processing and to prevent constantly checking for timers, we just run them separately. - const timerResult = doEventHandlers(timerEventHandlers) + const timerResult = runCATsUntilCompletionOrTransition(preExecHandlers) // If the timer resulted in a state transition, we have to replay the current event again. if (timerResult) { @@ -281,7 +292,7 @@ export function handleEvent( } } - const result = doEventHandlers(eventHandlers) + const result = runCATsUntilCompletionOrTransition(cats) if (result) { return result diff --git a/src/index.ts b/src/index.ts index c20c8a0..d142063 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { handleEvent } from "./handleEvent" import { HandleActionsOptions, TestOptions } from "./types" import { deepClone, findNamedChild, set } from "./utils" import { handleArrayLogic } from "./arrayHandling" +import { getLogger, setLogger } from "./logging" /** * Recursively evaluate a value or object. @@ -49,9 +50,8 @@ export function test>( return realTest(input, context, { findNamedChild: opts.findNamedChild || findNamedChild, ...opts, - _path: opts._path || "ROOTOBJ", + _path: opts._path || "Root", _currentLoopDepth: 0, - logger: opts.logger || (() => {}), }) } @@ -60,8 +60,13 @@ export function test>( * The benefit of using this is that it's a single, inline call, instead of 4 * lines per call. */ -function testWithPath(input: any, context, options: TestOptions, name: string) { - return realTest(input, context, { +function testWithPath( + input: any, + context: Context, + options: TestOptions, + name: string, +): boolean | Context { + return realTest(input, context, { ...options, _path: `${options._path}.${name}`, }) @@ -72,7 +77,7 @@ function realTest( variables: Variables, options: TestOptions, ): Variables | boolean { - const log = options.logger + const log = getLogger() log("visit", `Visiting ${options._path}`) @@ -259,7 +264,7 @@ function realTest( variables, options, "$contains[1]", - ) + ) as string if (typeof first === "string") { return first.includes(second) @@ -280,7 +285,7 @@ function realTest( export type RealTestFunc = typeof realTest /** - * Handles a group of action nodes (a.k.a. side-effect nodes). + * Handles a group of action nodes. * Actions will modify the context, which will then be returned. * * @param input The actions to take. @@ -463,5 +468,5 @@ export function handleActions( return context } -export { handleEvent } +export { handleEvent, setLogger } export * from "./types" diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 0000000..0d4e8bc --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 The Peacock Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * A function that logs a message. + */ +export type LogFunction = (category: string, message: string) => void + +let logger: LogFunction | undefined = (category: string, message: string) => { + console.log(`[${category}] ${message}`) +} + +export function setLogger(newLogger: LogFunction) { + logger = newLogger +} + +/** + * @internal + */ +export function getLogger(): LogFunction { + return logger +} diff --git a/src/types.ts b/src/types.ts index 8c9069d..d3caa82 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,18 +16,6 @@ import { findNamedChild } from "./utils" -/** - * A function that logs a message. - */ -export type LogFunction = (category: string, message: string) => void - -interface LoggingProvider { - /** - * The logging implementation. - */ - logger: LogFunction -} - /** * A game timestamp is a number of seconds (with milliseconds as decimals) since the start of a contract. */ @@ -54,7 +42,7 @@ export type Timer = { /** * Options that are passed to {@link handleEvent}. */ -export interface HandleEventOptions extends Partial { +export interface HandleEventOptions { /** * The event's name. */ @@ -92,9 +80,9 @@ export interface HandleEventReturn { } /** - * @internal + * CAT - condition, actions, transition. */ -export interface InStateEventHandler { +export type CATObject = { Condition?: unknown | unknown[] Actions?: unknown | unknown[] Transition?: string @@ -105,7 +93,7 @@ export interface InStateEventHandler { * A state machine, in a minimal form. * Context and Constants are generic, so they can be typed by library consumers. */ -export interface StateMachineLike { +export interface StateMachineLike { /** * The globals. */ @@ -122,14 +110,16 @@ export interface StateMachineLike { * We may need this in the future. */ Scope?: string - /** - * Mapping of state name to mapping of event name to handler. - */ States: { [stateName: string]: { - [eventName: string]: InStateEventHandler | InStateEventHandler[] - $timer?: InStateEventHandler | InStateEventHandler[] - ["-"]?: InStateEventHandler | InStateEventHandler[] + /** + * If eventName starts with $, it will be run before any other handlers. + */ + [eventName: string]: CATObject | CATObject[] + /** + * Special: run on every event. + */ + ["-"]?: CATObject | CATObject[] } } } @@ -139,7 +129,7 @@ export interface StateMachineLike { */ export type FindNamedChildFunc = typeof findNamedChild -export interface TestOptions extends LoggingProvider { +export interface TestOptions { /** * The findNamedChild function that should be used for resolution of * variables. diff --git a/src/utils.ts b/src/utils.ts index 03d7780..c64114e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -103,14 +103,13 @@ export function set(obj: any, keys: string | string[], val: any): void { * @param reference The reference to the target as a string. * @param variables The object that may contain the target. * @param forWriting true if this reference is being written to. - * @returns The value if found, or the reference if it wasn't / - * something went wrong. + * @returns The value if found, or the reference if it wasn't / something went wrong. */ export function findNamedChild( reference: string, variables: any, - forWriting = false, -): any { + forWriting = false +): boolean | string | number | any { if (typeof reference !== "string") { return reference } diff --git a/tests/timers.spec.ts b/tests/timers.spec.ts index bb9c058..d243877 100644 --- a/tests/timers.spec.ts +++ b/tests/timers.spec.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { test, Timer } from "../src" +import { setLogger, test, Timer } from "../src" import assert from "assert" import callSpy from "call-spy" @@ -34,6 +34,10 @@ const data = { } describe("$after", () => { + afterEach(() => { + setLogger(console.log) + }) + it("returns false with no timer array specified", () => { const [sm, vars] = data.After1 @@ -52,7 +56,9 @@ describe("$after", () => { } }) - assert.strictEqual(test(sm, vars, { timers: [], logger }), false) + setLogger(logger) + + assert.strictEqual(test(sm, vars, { timers: [] }), false) assert.strictEqual(loggerCallDetails.called, true) })