oosmos
Version:
A Hierarchical State Machine Class
388 lines (300 loc) • 11.9 kB
text/typescript
/*\
|*| The MIT License (MIT)
|*|
|*| Copyright (c) 2016 Mark J Glenn and OOSMOS, LLC
|*|
|*| Permission is hereby granted, free of charge, to any person obtaining a copy
|*| of this software and associated documentation files (the "Software"), to deal
|*| in the Software without restriction, including without limitation the rights
|*| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|*| copies of the Software, and to permit persons to whom the Software is
|*| furnished to do so, subject to the following conditions:
|*|
|*| The above copyright notice and this permission notice shall be included in all
|*| copies or substantial portions of the Software.
|*|
|*| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|*| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|*| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|*| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|*| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|*| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|*| SOFTWARE.
\*/
export interface iState {
ENTER?: () => void;
EXIT?: () => void;
TIMEOUT?: () => void;
COMPOSITE?: iComposite | (() => iComposite);
[EventString: string]: (() => void) /* Use 'any' only to avoid assignment error. */ | any;
// Private
DOTPATH?: string;
}
export interface iComposite {
DEFAULT?: string;
[StateName: string]: iState | (() => iState) /* Use 'any' only to avoid assignment error. */ | any;
}
export class StateMachine {
private m_ROOT: iState;
private m_State: iState;
private m_Timeouts: { [StateName: string]: number } = {};
private m_Interval: number;
private m_EventSourceState: iState;
private m_DotPath2State: {[DotStateName: string]: iState} = {};
private m_DebugMode: boolean = false;
private m_InBrowser: boolean = typeof(window) !== 'undefined';
private m_DebugID: string;
private m_LinesOut: number = 0;
private m_MaxLinesOut: number;
private m_ScrollIntoView: boolean;
constructor(Composite: iComposite) {
this.m_ROOT = { COMPOSITE: Composite };
}
private InstrumentStateMachine() {
let StateStack: string[] = [];
function InstrumentComposite(Composite: iComposite) {
let StateName: string;
for (StateName in Composite) {
if (StateName === 'DEFAULT') {
continue;
}
if (typeof(Composite[StateName]) === 'function') {
Composite[StateName] = (<() => iComposite> Composite[StateName])();
}
InstrumentState.call(this, Composite[StateName], StateName);
}
//
// If there is only one state in the composite, set the
// DEFAULT so the user doesn't have to.
//
if (Object.keys(Composite).length === 1) {
Composite.DEFAULT = Object.keys(Composite)[0];
} else {
if (!Composite.DEFAULT) {
this.Alert('You must specify a DEFAULT if there are more than one state in the composite.');
}
}
}
function InstrumentState(State: iState, StateName: string) {
if (State.ENTER && typeof(State.ENTER) !== 'function') {
this.Alert('ENTER must be a function.');
}
if (State.EXIT && typeof(State.EXIT) !== 'function') {
this.Alert('EXIT must be a function.');
}
if (typeof(State.COMPOSITE) === 'function') {
State.COMPOSITE = (<() => iComposite> State.COMPOSITE)();
}
StateStack.push(StateName);
State.DOTPATH = StateStack.join('.');
this.m_DotPath2State[State.DOTPATH] = State;
if (State.COMPOSITE) {
InstrumentComposite.call(this, <iComposite> State.COMPOSITE);
}
StateStack.pop();
}
InstrumentState.call(this, this.m_ROOT, 'ROOT');
};
private StripROOT(StateName: string) {
return StateName.replace('ROOT.', '');
}
private EnterDefaultStates(Composite: iComposite) {
this.m_State = Composite[Composite.DEFAULT];
this.DebugPrint('==> ' + this.StripROOT(this.m_State.DOTPATH));
if (this.m_State.ENTER) {
this.m_State.ENTER.call(this);
}
if (this.m_State.COMPOSITE) {
this.EnterDefaultStates.call(this, this.m_State.COMPOSITE);
}
}
private CalculateLCA(StringA: string, StringB: string): string {
const A = StringA.split('.');
const B = StringB.split('.');
const Iterations = Math.min(A.length, B.length);
let Return: string[] = [];
for (let I = 0; I < Iterations && A[I] === B[I]; I++) {
Return.push(A[I]);
}
return Return.join('.');
}
public Transition(To: string, ...Args: any[]) {
if (this.m_EventSourceState === undefined) {
this.m_EventSourceState = this.m_State;
}
To = 'ROOT.' + To;
this.DebugPrint('TRANSITION: ' + this.StripROOT(this.m_EventSourceState.DOTPATH) + ' -> ' + this.StripROOT(To));
let LCA = this.CalculateLCA(this.m_EventSourceState.DOTPATH, To);
//
// Self-transition is a special case.
//
if (To === LCA) {
let A = LCA.split('.');
A.splice(-1, 1); // Remove last element, in place.
LCA = A.join('.');
}
let ArgArray = Array.prototype.splice.call(arguments, 1);
function EnterStates(FromState: string, ToState: string) {
if (FromState === ToState) {
return;
}
const FromArray = FromState.split('.');
const ToSuffix = ToState.replace(FromState + '.', '');
const ToArray = ToSuffix.split('.');
let StatePath: string;
do {
FromArray.push(ToArray.shift());
StatePath = FromArray.join('.');
this.m_State = this.m_DotPath2State[StatePath];
this.DebugPrint('--> ' + this.StripROOT(StatePath));
if (this.m_State.ENTER) {
this.m_State.ENTER.apply(this, ArgArray);
}
} while (StatePath !== ToState);
}
function ExitStates(ToState: string, FromState: string) {
let FromArray = FromState.split('.');
while (ToState !== FromState) {
this.m_State = this.m_DotPath2State[FromState];
this.DebugPrint(' ' + this.StripROOT(FromState) + '-->');
if (this.m_State.EXIT) {
this.m_State.EXIT.call(this);
}
if (this.m_Timeouts[this.m_State.DOTPATH]) {
this.DebugPrint('Delete Timeout: ' + this.m_State.DOTPATH + ' ' + this.m_Timeouts[this.m_State.DOTPATH]);
delete this.m_Timeouts[this.m_State.DOTPATH];
}
FromArray.splice(-1, 1); // Remove last item, in place.
FromState = FromArray.join('.');
}
}
ExitStates.call(this, LCA, this.m_State.DOTPATH);
EnterStates.call(this, LCA, To);
if (this.m_DotPath2State[this.m_State.DOTPATH].COMPOSITE) {
this.EnterDefaultStates.call(this, this.m_DotPath2State[this.m_State.DOTPATH].COMPOSITE);
}
this.m_EventSourceState = undefined;
}
public Start(): StateMachine {
this.InstrumentStateMachine();
this.EnterDefaultStates.call(this, this.m_ROOT.COMPOSITE);
return this;
}
public Restart() {
this.m_State = {};
this.m_Timeouts = {};
this.m_Interval = undefined;
this.m_EventSourceState = {};
this.m_DotPath2State = {};
if (this.m_InBrowser) {
document.getElementById(this.m_DebugID).innerHTML = '';
this.m_LinesOut = 0;
}
this.Start();
}
public IsIn(StateDotPath: string) {
StateDotPath = 'ROOT.' + StateDotPath;
if (StateDotPath === this.m_State.DOTPATH) {
return true;
}
const Beginning = StateDotPath + '.';
return this.m_State.DOTPATH.substr(0, Beginning.length) === Beginning;
}
public Event(EventString: string, ...Args: any[]): void {
const CandidateStatePath = this.m_State.DOTPATH.split('.');
while (CandidateStatePath.length > 0) {
const CandidateStateDotPath = CandidateStatePath.join('.');
const CandidateState = this.m_DotPath2State[CandidateStateDotPath];
if (EventString in CandidateState) {
this.DebugPrint('EVENT: ' + EventString + ' sent to ' + this.StripROOT(this.m_State.DOTPATH));
const EventFunc = <() => void> CandidateState[EventString];
if (EventFunc) {
const ArgArray = Array.prototype.splice.call(arguments, 1);
this.m_EventSourceState = CandidateState;
EventFunc.apply(this, ArgArray);
this.m_EventSourceState = undefined;
}
return;
}
CandidateStatePath.splice(-1, 1); // Remove last element
}
this.DebugPrint('EVENT: ' + EventString + '. No handler from ' + this.StripROOT(this.m_State.DOTPATH));
}
public SetTimeoutSeconds(TimeoutSeconds: number) {
this.m_Timeouts[this.m_State.DOTPATH] = TimeoutSeconds;
if (this.m_Interval === undefined) {
let IntervalTick = () => {
for (const StateDotPath in this.m_Timeouts) {
this.m_Timeouts[StateDotPath] -= 1;
if (this.m_Timeouts[StateDotPath] <= 0) {
const State = this.m_DotPath2State[StateDotPath];
this.m_EventSourceState = this.m_State;
this.DebugPrint('Delete Timeout: ' + this.m_State.DOTPATH + ' ' + this.m_Timeouts[StateDotPath]);
delete this.m_Timeouts[StateDotPath];
if (State.TIMEOUT) {
State.TIMEOUT.call(this);
}
}
}
};
this.m_Interval = setInterval(IntervalTick, 1000);
}
this.DebugPrint('SetTimeoutSeconds:' + this.m_State.DOTPATH + ' ' + TimeoutSeconds);
}
public DebugPrint(Message: string) {
if (this.m_DebugMode) {
this.Print(Message);
}
}
public SetDebug(DebugMode: boolean, DebugID?: string, MaxLinesOut?: number, ScrollIntoView?: boolean) {
if (typeof(DebugMode) !== 'boolean') {
this.Alert('First argument of SetDebug must be a boolean value. Defaulting to false.');
DebugMode = false;
}
this.m_DebugMode = DebugMode;
if (this.m_InBrowser) {
if (DebugID === undefined) {
this.Alert('DebugID must be set to the ID of a <div> element.');
return;
}
this.m_DebugID = DebugID;
this.m_MaxLinesOut = MaxLinesOut || 200;
this.m_ScrollIntoView = ScrollIntoView || false;
}
}
public Print(Message: string) {
if (!this.m_InBrowser) {
console.log(Message);
return;
}
const DebugDIV = document.getElementById(this.m_DebugID);
const TextDIV = document.createElement('div');
const Text = document.createTextNode(Message);
TextDIV.appendChild(Text);
DebugDIV.appendChild(TextDIV);
function IsVisible(Element: HTMLElement) {
const Rect = Element.getBoundingClientRect();
const ViewHeight = Math.max(document.documentElement.clientHeight, window.innerHeight);
return !(Rect.bottom < 0 || Rect.top - ViewHeight >= 0);
}
if (this.m_ScrollIntoView && IsVisible(DebugDIV)) {
TextDIV.scrollIntoView(false);
}
this.m_LinesOut += 1;
if (this.m_LinesOut > this.m_MaxLinesOut) {
DebugDIV.removeChild(DebugDIV.childNodes[0]);
}
}
public Assert(Condition: boolean, Message: string) {
if (!Condition) {
this.Alert(Message || 'Assertion failed');
}
}
public Alert(Message: string) {
if (this.m_InBrowser) {
window.alert(Message);
} else {
console.log(Message);
}
}
}