Skip to content

Instantly share code, notes, and snippets.

@voodooattack
Last active September 18, 2018 05:44
Show Gist options
  • Save voodooattack/ce7e4429091fa45df8e2aeb6bec8a702 to your computer and use it in GitHub Desktop.
Save voodooattack/ce7e4429091fa45df8e2aeb6bec8a702 to your computer and use it in GitHub Desktop.
when-ts: first draft
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 extends MachineState> =
(state: Readonly<State>, machine: EventMachine<State>) => 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 extends MachineState> =
(state: Readonly<State>, machine: EventMachine<State>) => Partial<State> | 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<S extends MachineState> {
private _records: S[] = [];
private _tick: number = 0;
private _nextState: Partial<S>;
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<S extends MachineState>} mutate Any mutations to apply to the state after rewinding.
*/
rewind(n: number = Infinity, mutate?: Partial<S>) {
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<S extends MachineState>}
*/
get records(): ReadonlyArray<S> {
return this._records;
}
/**
* Returns the current state.
* @returns {Partial<S extends MachineState>}
*/
get currentState(): Readonly<S> {
return this.records[this.records.length - 1] || this._initialState;
}
/**
* Returns the next state being updated.
* @returns {Partial<S extends MachineState>}
*/
get nextState(): Readonly<Partial<S>> {
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<S>) {
return Object.assign(this._nextState, p);
}
/** @internal */
_nextTick() {
const nextState = this.nextState as Readonly<S>;
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<YourStateInterface>` class.
*/
export class EventMachine<S extends MachineState> {
/**
* The active state machine program.
* @type {Map}
* @private
*/
private _program: Map<ActivationCond<S>, ActivationAction<S>> = new Map();
/**
*
* @type {HistoryManager<S extends MachineState>}
* @private
*/
private _history = new HistoryManager<S>(this._initialState);
private _exitState?: Readonly<S>;
/**
* 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<S extends MachineState>}
*/
get history() {
return this._history;
}
/**
* The state at program exit. Returns `undefined` unless the program has ended.
* @returns {Readonly<S extends MachineState> | 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<S>;
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<S extends MachineState>}
*/
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<S extends MachineState>} exitState The exit state to return from .run.
*/
exit(exitState?: Readonly<S>) {
if (!this._exitState)
this._exitState = exitState || this._history.currentState as Readonly<S>;
}
}
/**
* A TypeScript decorator to declare a method as an action with an attached a condition.
* @param {ActivationCond<S extends MachineState> | boolean} cond The condition to check on every tick.
*/
export function when<S extends MachineState = any>(cond: ActivationCond<S> | boolean): MethodDecorator {
if (typeof cond === 'boolean')
cond = ((v: boolean) => () => v)(cond);
return function <T>(type: T, _methodName: string | symbol, descriptor: PropertyDescriptor) {
Reflect.defineMetadata(programMetadataKey, cond, descriptor.value);
return descriptor;
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment