import 'reflect-metadata'; export interface MachineState { } /** * An activation condition, takes two arguments and must return true for the associated action to fire. */ export type ActivationCond = (state: Readonly, machine: EventMachine) => boolean; /** * An activation action, takes two arguments and will only be executed during a tick * when the associated conditon returns true. */ export type ActivationAction = (state: Readonly, machine: EventMachine) => Partial | undefined; /** @internal */ const programMetadataKey = Symbol('when-program'); /** @internal */ function getAllMethods(object: any) { let current = object; let props: string[] = []; do { props.push(...Object.getOwnPropertyNames(current)); } while (current = Object.getPrototypeOf(current)); return Array.from(new Set(props.map(p => typeof object[p] === 'function' ? object[p] : null) .filter(p => p !== null))); } /** * The HistoryManager class manages the state history of a program. */ export class HistoryManager { private _records: S[] = []; private _tick: number = 0; private _nextState: Partial; private _maxHistory: number = Infinity; /** * Constrctor with an initial state. * @param {S} _initialState The initial program state. */ constructor(protected readonly _initialState: S) { this._nextState = _initialState; this._nextTick(); } /** * Get the current tick number. * @returns {number} */ get tick() { return this._tick; } /** * Limit the number of recorded history states. * @param {number} limit The maximum number of states to keep. */ limit(limit: number) { if (limit < 0) return; if (limit < this._maxHistory) { // trim back the record history. this._records.splice(0, this._records.length - limit); } this._maxHistory = limit; } /** * Rewind time by `n` ticks, the rest of the currently executing tick will be aborted. * A partial state can be passed as the second argument to mutate the rewound state. * @param {number} n The number of ticks to rewind, defaults to Infinity. * @param {Partial} mutate Any mutations to apply to the state after rewinding. */ rewind(n: number = Infinity, mutate?: Partial) { if (n <= this._maxHistory && Number.isFinite(n)) { this._records.splice(n, this.records.length - n); this._tick -= n; } else { this._records.splice(0, this._records.length); this._tick = 0; this._records.push(this._initialState); } if (mutate) { this._records[this._records.length - 1] = Object.assign(Object.create(null), this.currentState, mutate); } this._resetTick(); } /** * Clears the state history. Rewinds to the beginning, and the rest of the current tick will be aborted. */ clear() { this.rewind(Infinity); } /** * Returns the entire state history. * @returns {ReadonlyArray} */ get records(): ReadonlyArray { return this._records; } /** * Returns the current state. * @returns {Partial} */ get currentState(): Readonly { return this.records[this.records.length - 1] || this._initialState; } /** * Returns the next state being updated. * @returns {Partial} */ get nextState(): Readonly> { return this._nextState; } protected _resetTick() { if (this._records.length) this._nextState = Object.assign(Object.create(null), this.records[this.records.length - 1]); else this._nextState = Object.assign(Object.create(null), this._initialState); } /** @internal */ _mutateTick(p: Partial) { return Object.assign(this._nextState, p); } /** @internal */ _nextTick() { const nextState = this.nextState as Readonly; this._records.push(nextState); if (this._records.length > this._maxHistory) { this._records.splice(this._maxHistory, this._records.length - this._maxHistory); } this._resetTick(); this._tick++; } } /** * Your state machine should inherit the `EventMachine` class. */ export class EventMachine { /** * The active state machine program. * @type {Map} * @private */ private _program: Map, ActivationAction> = new Map(); /** * * @type {HistoryManager} * @private */ private _history = new HistoryManager(this._initialState); private _exitState?: Readonly; /** * Constructor, requires an initial state. * @param {S} _initialState */ constructor(protected readonly _initialState: S) { const properties = getAllMethods(this); for (let m of properties) { if (Reflect.hasMetadata(programMetadataKey, m)) { const cond = Reflect.getMetadata(programMetadataKey, m); this._program.set(cond, m as any); } } } /** * Returns the history manager object. * @returns {HistoryManager} */ get history() { return this._history; } /** * The state at program exit. Returns `undefined` unless the program has ended. * @returns {Readonly | undefined} */ get exitState() { return this._exitState; } /** * Advance a single tick and return. * @returns {number} Number of actions fired during this tick. */ step() { if (this._exitState) return false; let fired = 0; const current = this._history.currentState as Readonly; for (let [cond, body] of this._program) { if (cond.call(this, current, this)) { const newState = body.call(this, current, this); if (this._exitState) break; if (newState) { this._history._mutateTick(newState); } fired++; } } this._history._nextTick(); return fired; } /** * A blocking call that evaluates the state machine until it exits. * @param {boolean} forever * @returns {Readonly} */ run(forever: boolean = true) { while (!this._exitState) this.step(); return this._exitState; } /** * Resets the state machine to the initial state. * @param {S} initialState (optional) Restart with a different initial state. */ reset(initialState: S = this._initialState) { this._exitState = undefined; this.history.rewind(Infinity, initialState); } /** * Call this from any action to signal program completion. * @param {Readonly} exitState The exit state to return from .run. */ exit(exitState?: Readonly) { if (!this._exitState) this._exitState = exitState || this._history.currentState as Readonly; } } /** * A TypeScript decorator to declare a method as an action with an attached a condition. * @param {ActivationCond | boolean} cond The condition to check on every tick. */ export function when(cond: ActivationCond | boolean): MethodDecorator { if (typeof cond === 'boolean') cond = ((v: boolean) => () => v)(cond); return function (type: T, _methodName: string | symbol, descriptor: PropertyDescriptor) { Reflect.defineMetadata(programMetadataKey, cond, descriptor.value); return descriptor; }; }