jssm
Version:
A Javascript finite state machine (FSM) with a terse DSL and a simple API. Most FSMs are one-liners. Fast, easy, powerful, well tested, typed with TypeScript, and visualizations. MIT License.
1,443 lines (1,439 loc) • 58 kB
JavaScript
// whargarbl lots of these return arrays could/should be sets
import { reduce as reduce_to_639 } from 'reduce-to-639-1';
import { seq, weighted_rand_select, weighted_sample_select, histograph, weighted_histo_key, array_box_if_string, hook_name, named_hook_name } from './jssm_util';
import { shapes, gviz_shapes, named_colors } from './jssm_constants';
import { parse } from './jssm-dot';
import { version } from './version'; // replaced from package.js in build
import { JssmError } from './jssm_error';
/* eslint-disable complexity */
/*********
*
* Return the direction of an arrow - `right`, `left`, or `both`.
*
* ```typescript
* import { arrow_direction } from 'jssm';
*
* arrow_direction('->'); // 'right'
* arrow_direction('<~=>'); // 'both'
* ```
*
* @param arrow The arrow to be evaluated
*
*/
function arrow_direction(arrow) {
switch (String(arrow)) {
case '->':
case '→':
case '=>':
case '⇒':
case '~>':
case '↛':
return 'right';
case '<-':
case '←':
case '<=':
case '⇐':
case '<~':
case '↚':
return 'left';
case '<->':
case '↔':
case '<-=>':
case '←⇒':
case '←=>':
case '<-⇒':
case '<-~>':
case '←↛':
case '←~>':
case '<-↛':
case '<=>':
case '⇔':
case '<=->':
case '⇐→':
case '⇐->':
case '<=→':
case '<=~>':
case '⇐↛':
case '⇐~>':
case '<=↛':
case '<~>':
case '↮':
case '<~->':
case '↚→':
case '↚->':
case '<~→':
case '<~=>':
case '↚⇒':
case '↚=>':
case '<~⇒':
return 'both';
default:
throw new JssmError(undefined, `arrow_direction: unknown arrow type ${arrow}`);
}
}
/* eslint-enable complexity */
/* eslint-disable complexity */
/*********
*
* Return the direction of an arrow - `right`, `left`, or `both`.
*
* ```typescript
* import { arrow_left_kind } from 'jssm';
*
* arrow_left_kind('<-'); // 'legal'
* arrow_left_kind('<='); // 'main'
* arrow_left_kind('<~'); // 'forced'
* arrow_left_kind('<->'); // 'legal'
* arrow_left_kind('->'); // 'none'
* ```
*
* @param arrow The arrow to be evaluated
*
*/
function arrow_left_kind(arrow) {
switch (String(arrow)) {
case '->':
case '→':
case '=>':
case '⇒':
case '~>':
case '↛':
return 'none';
case '<-':
case '←':
case '<->':
case '↔':
case '<-=>':
case '←⇒':
case '<-~>':
case '←↛':
return 'legal';
case '<=':
case '⇐':
case '<=>':
case '⇔':
case '<=->':
case '⇐→':
case '<=~>':
case '⇐↛':
return 'main';
case '<~':
case '↚':
case '<~>':
case '↮':
case '<~->':
case '↚→':
case '<~=>':
case '↚⇒':
return 'forced';
default:
throw new JssmError(undefined, `arrow_direction: unknown arrow type ${arrow}`);
}
}
/* eslint-enable complexity */
/* eslint-disable complexity */
/*********
*
* Return the direction of an arrow - `right`, `left`, or `both`.
*
* ```typescript
* import { arrow_left_kind } from 'jssm';
*
* arrow_left_kind('->'); // 'legal'
* arrow_left_kind('=>'); // 'main'
* arrow_left_kind('~>'); // 'forced'
* arrow_left_kind('<->'); // 'legal'
* arrow_left_kind('<-'); // 'none'
* ```
*
* @param arrow The arrow to be evaluated
*
*/
function arrow_right_kind(arrow) {
switch (String(arrow)) {
case '<-':
case '←':
case '<=':
case '⇐':
case '<~':
case '↚':
return 'none';
case '->':
case '→':
case '<->':
case '↔':
case '<=->':
case '⇐→':
case '<~->':
case '↚→':
return 'legal';
case '=>':
case '⇒':
case '<=>':
case '⇔':
case '<-=>':
case '←⇒':
case '<~=>':
case '↚⇒':
return 'main';
case '~>':
case '↛':
case '<~>':
case '↮':
case '<-~>':
case '←↛':
case '<=~>':
case '⇐↛':
return 'forced';
default:
throw new JssmError(undefined, `arrow_direction: unknown arrow type ${arrow}`);
}
}
/* eslint-enable complexity */
/*********
*
* Internal method meant to perform factory assembly of an edge. Not meant for
* external use.
*
* @internal
*
* @typeparam mDT The type of the machine data member; usually omitted
*
*/
// TODO add at-param to docblock
function makeTransition(this_se, from, to, isRight, _wasList, _wasIndex) {
const kind = isRight ? arrow_right_kind(this_se.kind) : arrow_left_kind(this_se.kind), edge = {
from,
to,
kind,
forced_only: kind === 'forced',
main_path: kind === 'main'
};
// if ((wasList !== undefined) && (wasIndex === undefined)) { throw new JssmError(undefined, `Must have an index if transition was in a list"); }
// if ((wasIndex !== undefined) && (wasList === undefined)) { throw new JssmError(undefined, `Must be in a list if transition has an index"); }
/*
if (typeof edge.to === 'object') {
if (edge.to.key === 'cycle') {
if (wasList === undefined) { throw new JssmError(undefined, "Must have a waslist if a to is type cycle"); }
const nextIndex = wrapBy(wasIndex, edge.to.value, wasList.length);
edge.to = wasList[nextIndex];
}
}
*/
const action = isRight ? 'r_action' : 'l_action', probability = isRight ? 'r_probability' : 'l_probability';
if (this_se[action]) {
edge.action = this_se[action];
}
if (this_se[probability]) {
edge.probability = this_se[probability];
}
return edge;
}
/*********
*
* This method wraps the parser call that comes from the peg grammar,
* {@link parse}. Generally neither this nor that should be used directly
* unless you mean to develop plugins or extensions for the machine.
*
* Parses the intermediate representation of a compiled string down to a
* machine configuration object. If you're using this (probably don't,) you're
* probably also using {@link compile} and {@link Machine.constructor}.
*
* ```typescript
* import { parse, compile, Machine } from 'jssm';
*
* const intermediate = wrap_parse('a -> b;', {});
* // [ {key:'transition', from:'a', se:{kind:'->',to:'b'}} ]
*
* const cfg = compile(intermediate);
* // { start_states:['a'], transitions: [{ from:'a', to:'b', kind:'legal', forced_only:false, main_path:false }] }
*
* const machine = new Machine(cfg);
* // Machine { _instance_name: undefined, _state: 'a', ...
* ```
*
* This method is mostly for plugin and intermediate tool authors, or people
* who need to work with the machine's intermediate representation.
*
* # Hey!
*
* Most people looking at this want either the `sm` operator or method `from`,
* which perform all the steps in the chain. The library's author mostly uses
* operator `sm`, and mostly falls back to `.from` when needing to parse
* strings dynamically instead of from template literals.
*
* Operator {@link sm}:
*
* ```typescript
* import { sm } from 'jssm';
*
* const switch = sm`on <=> off;`;
* ```
*
* Method {@link from}:
*
* ```typescript
* import * as jssm from 'jssm';
*
* const toggle = jssm.from('up <=> down;');
* ```
*
* `wrap_parse` itself is an internal convenience method for alting out an
* object as the options call. Not generally meant for external use.
*
* @param input The FSL code to be evaluated
*
* @param options Things to control about the instance
*
*/
function wrap_parse(input, options) {
return parse(input, options || {});
}
/*********
*
* Internal method performing one step in compiling rules for transitions. Not
* generally meant for external use.
*
* @internal
*
* @typeparam mDT The type of the machine data member; usually omitted
*
*/
function compile_rule_transition_step(acc, from, to, this_se, next_se) {
const edges = [];
const uFrom = (Array.isArray(from) ? from : [from]), uTo = (Array.isArray(to) ? to : [to]);
uFrom.map((f) => {
uTo.map((t) => {
const right = makeTransition(this_se, f, t, true);
if (right.kind !== 'none') {
edges.push(right);
}
const left = makeTransition(this_se, t, f, false);
if (left.kind !== 'none') {
edges.push(left);
}
});
});
const new_acc = acc.concat(edges);
if (next_se) {
return compile_rule_transition_step(new_acc, to, next_se.to, next_se, next_se.se);
}
else {
return new_acc;
}
}
/*********
*
* Internal method performing one step in compiling rules for transitions. Not
* generally meant for external use.
*
* @internal
*
*/
function compile_rule_handle_transition(rule) {
return compile_rule_transition_step([], rule.from, rule.se.to, rule.se, rule.se.se);
}
/*********
*
* Internal method performing one step in compiling rules for transitions. Not
* generally meant for external use.
*
* @internal
*
*/
function compile_rule_handler(rule) {
if (rule.key === 'transition') {
return { agg_as: 'transition', val: compile_rule_handle_transition(rule) };
}
if (rule.key === 'machine_language') {
return { agg_as: 'machine_language', val: reduce_to_639(rule.value) };
}
if (rule.key === 'state_declaration') {
if (!rule.name) {
throw new JssmError(undefined, 'State declarations must have a name');
}
return { agg_as: 'state_declaration', val: { state: rule.name, declarations: rule.value } };
}
if (['arrange_declaration', 'arrange_start_declaration',
'arrange_end_declaration'].includes(rule.key)) {
return { agg_as: rule.key, val: [rule.value] };
}
const tautologies = [
'graph_layout', 'start_states', 'end_states', 'machine_name', 'machine_version',
'machine_comment', 'machine_author', 'machine_contributor', 'machine_definition',
'machine_reference', 'machine_license', 'fsl_version', 'state_config', 'theme',
'flow', 'dot_preamble'
];
if (tautologies.includes(rule.key)) {
return { agg_as: rule.key, val: rule.value };
}
throw new JssmError(undefined, `compile_rule_handler: Unknown rule: ${JSON.stringify(rule)}`);
}
/*********
*
* Compile a machine's JSON intermediate representation to a config object. If
* you're using this (probably don't,) you're probably also using
* {@link parse} to get the IR, and the object constructor
* {@link Machine.construct} to turn the config object into a workable machine.
*
* ```typescript
* import { parse, compile, Machine } from 'jssm';
*
* const intermediate = parse('a -> b;');
* // [ {key:'transition', from:'a', se:{kind:'->',to:'b'}} ]
*
* const cfg = compile(intermediate);
* // { start_states:['a'], transitions: [{ from:'a', to:'b', kind:'legal', forced_only:false, main_path:false }] }
*
* const machine = new Machine(cfg);
* // Machine { _instance_name: undefined, _state: 'a', ...
* ```
*
* This method is mostly for plugin and intermediate tool authors, or people
* who need to work with the machine's intermediate representation.
*
* # Hey!
*
* Most people looking at this want either the `sm` operator or method `from`,
* which perform all the steps in the chain. The library's author mostly uses
* operator `sm`, and mostly falls back to `.from` when needing to parse
* strings dynamically instead of from template literals.
*
* Operator {@link sm}:
*
* ```typescript
* import { sm } from 'jssm';
*
* const switch = sm`on <=> off;`;
* ```
*
* Method {@link from}:
*
* ```typescript
* import * as jssm from 'jssm';
*
* const toggle = jssm.from('up <=> down;');
* ```
*
* @typeparam mDT The type of the machine data member; usually omitted
*
* @param tree The parse tree to be boiled down into a machine config
*
*/
function compile(tree) {
const results = {
graph_layout: [],
transition: [],
start_states: [],
end_states: [],
state_config: [],
state_declaration: [],
fsl_version: [],
machine_author: [],
machine_comment: [],
machine_contributor: [],
machine_definition: [],
machine_language: [],
machine_license: [],
machine_name: [],
machine_reference: [],
theme: [],
flow: [],
dot_preamble: [],
arrange_declaration: [],
arrange_start_declaration: [],
arrange_end_declaration: [],
machine_version: []
};
tree.map((tr) => {
const rule = compile_rule_handler(tr), agg_as = rule.agg_as, val = rule.val; // TODO FIXME no any
results[agg_as] = results[agg_as].concat(val);
});
const assembled_transitions = [].concat(...results['transition']);
const result_cfg = {
start_states: results.start_states.length ? results.start_states : [assembled_transitions[0].from],
transitions: assembled_transitions
};
const oneOnlyKeys = [
'graph_layout', 'machine_name', 'machine_version', 'machine_comment',
'fsl_version', 'machine_license', 'machine_definition', 'machine_language',
'theme', 'flow', 'dot_preamble'
];
oneOnlyKeys.map((oneOnlyKey) => {
if (results[oneOnlyKey].length > 1) {
throw new JssmError(undefined, `May only have one ${oneOnlyKey} statement maximum: ${JSON.stringify(results[oneOnlyKey])}`);
}
else {
if (results[oneOnlyKey].length) {
result_cfg[oneOnlyKey] = results[oneOnlyKey][0];
}
}
});
['arrange_declaration', 'arrange_start_declaration', 'arrange_end_declaration',
'machine_author', 'machine_contributor', 'machine_reference', 'state_declaration'].map((multiKey) => {
if (results[multiKey].length) {
result_cfg[multiKey] = results[multiKey];
}
});
return result_cfg;
}
/*********
*
* An internal convenience wrapper for parsing then compiling a machine string.
* Not generally meant for external use. Please see {@link compile} or
* {@link sm}.
*
* @typeparam mDT The type of the machine data member; usually omitted
*
* @param plan The FSL code to be evaluated and built into a machine config
*
*/
function make(plan) {
return compile(wrap_parse(plan));
}
/*********
*
* An internal method meant to take a series of declarations and fold them into
* a single multi-faceted declaration, in the process of building a state. Not
* generally meant for external use.
*
* @internal
*
*/
function transfer_state_properties(state_decl) {
state_decl.declarations.map((d) => {
switch (d.key) {
case 'shape':
state_decl.shape = d.value;
break;
case 'color':
state_decl.color = d.value;
break;
case 'corners':
state_decl.corners = d.value;
break;
case 'linestyle':
state_decl.linestyle = d.value;
break;
case 'text-color':
state_decl.textColor = d.value;
break;
case 'background-color':
state_decl.backgroundColor = d.value;
break;
case 'border-color':
state_decl.borderColor = d.value;
break;
default: throw new JssmError(undefined, `Unknown state property: '${JSON.stringify(d)}'`);
}
});
return state_decl;
}
// TODO add a lotta docblock here
class Machine {
// whargarbl this badly needs to be broken up, monolith master
constructor({ start_states, complete = [], transitions, machine_author, machine_comment, machine_contributor, machine_definition, machine_language, machine_license, machine_name, machine_version, state_declaration, fsl_version, dot_preamble = undefined, arrange_declaration = [], arrange_start_declaration = [], arrange_end_declaration = [], theme = 'default', flow = 'down', graph_layout = 'dot', instance_name, data }) {
this._instance_name = instance_name;
this._state = start_states[0];
this._states = new Map();
this._state_declarations = new Map();
this._edges = [];
this._edge_map = new Map();
this._named_transitions = new Map();
this._actions = new Map();
this._reverse_actions = new Map();
this._reverse_action_targets = new Map(); // todo
this._machine_author = array_box_if_string(machine_author);
this._machine_comment = machine_comment;
this._machine_contributor = array_box_if_string(machine_contributor);
this._machine_definition = machine_definition;
this._machine_language = machine_language;
this._machine_license = machine_license;
this._machine_name = machine_name;
this._machine_version = machine_version;
this._raw_state_declaration = state_declaration || [];
this._fsl_version = fsl_version;
this._arrange_declaration = arrange_declaration;
this._arrange_start_declaration = arrange_start_declaration;
this._arrange_end_declaration = arrange_end_declaration;
this._dot_preamble = dot_preamble;
this._theme = theme;
this._flow = flow;
this._graph_layout = graph_layout;
this._has_hooks = false;
this._has_basic_hooks = false;
this._has_named_hooks = false;
this._has_entry_hooks = false;
this._has_exit_hooks = false;
this._has_global_action_hooks = false;
this._has_transition_hooks = true;
// no need for a boolean has any transition hook, as it's one or nothing, so just test that for undefinedness
this._hooks = new Map();
this._named_hooks = new Map();
this._entry_hooks = new Map();
this._exit_hooks = new Map();
this._global_action_hooks = new Map();
this._any_action_hook = undefined;
this._standard_transition_hook = undefined;
this._main_transition_hook = undefined;
this._forced_transition_hook = undefined;
this._any_transition_hook = undefined;
this._standard_transition_hook = undefined;
this._data = data;
if (state_declaration) {
state_declaration.map((state_decl) => {
if (this._state_declarations.has(state_decl.state)) { // no repeats
throw new JssmError(this, `Added the same state declaration twice: ${JSON.stringify(state_decl.state)}`);
}
this._state_declarations.set(state_decl.state, transfer_state_properties(state_decl));
});
}
transitions.map((tr) => {
if (tr.from === undefined) {
throw new JssmError(this, `transition must define 'from': ${JSON.stringify(tr)}`);
}
if (tr.to === undefined) {
throw new JssmError(this, `transition must define 'to': ${JSON.stringify(tr)}`);
}
// get the cursors. what a mess
const cursor_from = this._states.get(tr.from)
|| { name: tr.from, from: [], to: [], complete: complete.includes(tr.from) };
if (!(this._states.has(tr.from))) {
this._new_state(cursor_from);
}
const cursor_to = this._states.get(tr.to)
|| { name: tr.to, from: [], to: [], complete: complete.includes(tr.to) };
if (!(this._states.has(tr.to))) {
this._new_state(cursor_to);
}
// guard against existing connections being re-added
if (cursor_from.to.includes(tr.to)) {
throw new JssmError(this, `already has ${JSON.stringify(tr.from)} to ${JSON.stringify(tr.to)}`);
}
else {
cursor_from.to.push(tr.to);
cursor_to.from.push(tr.from);
}
// add the edge; note its id
this._edges.push(tr);
const thisEdgeId = this._edges.length - 1;
// guard against repeating a transition name
if (tr.name) {
if (this._named_transitions.has(tr.name)) {
throw new JssmError(this, `named transition "${JSON.stringify(tr.name)}" already created`);
}
else {
this._named_transitions.set(tr.name, thisEdgeId);
}
}
// set up the mapping, so that edges can be looked up by endpoint pairs
const from_mapping = this._edge_map.get(tr.from) || new Map();
if (!(this._edge_map.has(tr.from))) {
this._edge_map.set(tr.from, from_mapping);
}
// const to_mapping = from_mapping.get(tr.to);
from_mapping.set(tr.to, thisEdgeId); // already checked that this mapping doesn't exist, above
// set up the action mapping, so that actions can be looked up by origin
if (tr.action) {
// forward mapping first by action name
let actionMap = this._actions.get(tr.action);
if (!(actionMap)) {
actionMap = new Map();
this._actions.set(tr.action, actionMap);
}
if (actionMap.has(tr.from)) {
throw new JssmError(this, `action ${JSON.stringify(tr.action)} already attached to origin ${JSON.stringify(tr.from)}`);
}
else {
actionMap.set(tr.from, thisEdgeId);
}
// reverse mapping first by state origin name
let rActionMap = this._reverse_actions.get(tr.from);
if (!(rActionMap)) {
rActionMap = new Map();
this._reverse_actions.set(tr.from, rActionMap);
}
// no need to test for reverse mapping pre-presence;
// forward mapping already covers collisions
rActionMap.set(tr.action, thisEdgeId);
// reverse mapping first by state target name
if (!(this._reverse_action_targets.has(tr.to))) {
this._reverse_action_targets.set(tr.to, new Map());
}
/* todo comeback
fundamental problem is roActionMap needs to be a multimap
const roActionMap = this._reverse_action_targets.get(tr.to); // wasteful - already did has - refactor
if (roActionMap) {
if (roActionMap.has(tr.action)) {
throw new JssmError(this, `ro-action ${tr.to} already attached to action ${tr.action}`);
} else {
roActionMap.set(tr.action, thisEdgeId);
}
} else {
throw new JssmError(this, `should be impossible - flow doesn\'t know .set precedes .get yet again. severe error?');
}
*/
}
});
}
/********
*
* Internal method for fabricating states. Not meant for external use.
*
* @internal
*
*/
_new_state(state_config) {
if (this._states.has(state_config.name)) {
throw new JssmError(this, `state ${JSON.stringify(state_config.name)} already exists`);
}
this._states.set(state_config.name, state_config);
return state_config.name;
}
/*********
*
* Get the current state of a machine.
*
* ```typescript
* import * as jssm from 'jssm';
*
* const switch = jssm.from('on <=> off;');
* console.log( switch.state() ); // 'on'
*
* switch.transition('off');
* console.log( switch.state() ); // 'off'
* ```
*
* @typeparam mDT The type of the machine data member; usually omitted
*
*/
state() {
return this._state;
}
/* whargarbl todo major
when we reimplement this, reintroduce this change to the is_final call
is_changing(): boolean {
return true; // todo whargarbl
}
*/
/*********
*
* Get the current data of a machine.
*
* ```typescript
* import * as jssm from 'jssm';
*
* const switch = jssm.from('on <=> off;', {data: 1});
* console.log( switch.data() ); // 1
* ```
*
* @typeparam mDT The type of the machine data member; usually omitted
*
*/
data() {
return this._data;
}
/* whargarbl todo major
when we reimplement this, reintroduce this change to the is_final call
is_changing(): boolean {
return true; // todo whargarbl
}
*/
/********
*
* Check whether a given state is final (either has no exits or is marked
* `complete`.)
*
* ```typescript
* import { sm, state_is_final } from 'jssm';
*
* const final_test = sm`first -> second;`;
*
* console.log( final_test.state_is_final('first') ); // false
* console.log( final_test.state_is_final('second') ); // true
* ```
*
* @typeparam mDT The type of the machine data member; usually omitted
*
* @param whichState The name of the state to check for finality
*
*/
state_is_final(whichState) {
return ((this.state_is_terminal(whichState)) && (this.state_is_complete(whichState)));
}
/********
*
* Check whether the current state is final (either has no exits or is marked
* `complete`.)
*
* ```typescript
* import { sm, state_is_final } from 'jssm';
*
* const final_test = sm`first -> second;`;
*
* console.log( final_test.is_final() ); // false
* state.transition('second');
* console.log( final_test.is_final() ); // true
* ```
*
* @typeparam mDT The type of the machine data member; usually omitted
*
*/
is_final() {
// return ((!this.is_changing()) && this.state_is_final(this.state()));
return this.state_is_final(this.state());
}
graph_layout() {
return this._graph_layout;
}
dot_preamble() {
return this._dot_preamble;
}
machine_author() {
return this._machine_author;
}
machine_comment() {
return this._machine_comment;
}
machine_contributor() {
return this._machine_contributor;
}
machine_definition() {
return this._machine_definition;
}
machine_language() {
return this._machine_language;
}
machine_license() {
return this._machine_license;
}
machine_name() {
return this._machine_name;
}
machine_version() {
return this._machine_version;
}
raw_state_declarations() {
return this._raw_state_declaration;
}
state_declaration(which) {
return this._state_declarations.get(which);
}
state_declarations() {
return this._state_declarations;
}
fsl_version() {
return this._fsl_version;
}
machine_state() {
return {
internal_state_impl_version: 1,
actions: this._actions,
edge_map: this._edge_map,
edges: this._edges,
named_transitions: this._named_transitions,
reverse_actions: this._reverse_actions,
// reverse_action_targets : this._reverse_action_targets,
state: this._state,
states: this._states
};
}
/*
load_machine_state(): boolean {
return false; // todo whargarbl
}
*/
/*********
*
* List all the states known by the machine. Please note that the order of
* these states is not guaranteed.
*
* ```typescript
* import * as jssm from 'jssm';
*
* const switch = jssm.from('on <=> off;');
* console.log( switch.states() ); // ['on', 'off']
* ```
*
* @typeparam mDT The type of the machine data member; usually omitted
*
*/
states() {
return Array.from(this._states.keys());
}
state_for(whichState) {
const state = this._states.get(whichState);
if (state) {
return state;
}
else {
throw new JssmError(this, 'No such state', { requested_state: whichState });
}
}
/*********
*
* Check whether the machine knows a given state.
*
* ```typescript
* import * as jssm from 'jssm';
*
* const switch = jssm.from('on <=> off;');
*
* console.log( switch.has_state('off') ); // true
* console.log( switch.has_state('dance') ); // false
* ```
*
* @typeparam mDT The type of the machine data member; usually omitted
*
* @param whichState The state to be checked for extance
*
*/
has_state(whichState) {
return this._states.get(whichState) !== undefined;
}
/*********
*
* Lists all edges of a machine.
*
* ```typescript
* import { sm } from 'jssm';
*
* const lswitch = sm`on 'toggle' <=> 'toggle' off;`;
*
* lswitch.list_edges();
* [
* {
* from: 'on',
* to: 'off',
* kind: 'main',
* forced_only: false,
* main_path: true,
* action: 'toggle'
* },
* {
* from: 'off',
* to: 'on',
* kind: 'main',
* forced_only: false,
* main_path: true,
* action: 'toggle'
* }
* ]
* ```
*
* @typeparam mDT The type of the machine data member; usually omitted
*
*/
list_edges() {
return this._edges;
}
list_named_transitions() {
return this._named_transitions;
}
list_actions() {
return Array.from(this._actions.keys());
}
theme() {
return this._theme; // constructor sets this to "default" otherwise
}
flow() {
return this._flow;
}
get_transition_by_state_names(from, to) {
const emg = this._edge_map.get(from);
if (emg) {
return emg.get(to);
}
else {
return undefined;
}
}
lookup_transition_for(from, to) {
const id = this.get_transition_by_state_names(from, to);
return ((id === undefined) || (id === null)) ? undefined : this._edges[id];
}
/********
*
* List all transitions attached to the current state, sorted by entrance and
* exit. The order of each sublist is not defined. A node could appear in
* both lists.
*
* ```typescript
* import { sm } from 'jssm';
*
* const light = sm`red 'next' -> green 'next' -> yellow 'next' -> red; [red yellow green] 'shutdown' ~> off 'start' -> red;`;
*
* light.state(); // 'red'
* light.list_transitions(); // { entrances: [ 'yellow', 'off' ], exits: [ 'green', 'off' ] }
* ```
*
* @typeparam mDT The type of the machine data member; usually omitted
*
* @param whichState The state whose transitions to have listed
*
*/
list_transitions(whichState = this.state()) {
return { entrances: this.list_entrances(whichState), exits: this.list_exits(whichState) };
}
/********
*
* List all entrances attached to the current state. Please note that the
* order of the list is not defined.
*
* ```typescript
* import { sm } from 'jssm';
*
* const light = sm`red 'next' -> green 'next' -> yellow 'next' -> red; [red yellow green] 'shutdown' ~> off 'start' -> red;`;
*
* light.state(); // 'red'
* light.list_entrances(); // [ 'yellow', 'off' ]
* ```
*
* @typeparam mDT The type of the machine data member; usually omitted
*
* @param whichState The state whose entrances to have listed
*
*/
list_entrances(whichState = this.state()) {
return (this._states.get(whichState)
|| { from: undefined }).from
|| [];
}
/********
*
* List all exits attached to the current state. Please note that the order
* of the list is not defined.
*
* ```typescript
* import { sm } from 'jssm';
*
* const light = sm`red 'next' -> green 'next' -> yellow 'next' -> red; [red yellow green] 'shutdown' ~> off 'start' -> red;`;
*
* light.state(); // 'red'
* light.list_exits(); // [ 'green', 'off' ]
* ```
*
* @typeparam mDT The type of the machine data member; usually omitted
*
* @param whichState The state whose exits to have listed
*
*/
list_exits(whichState = this.state()) {
return (this._states.get(whichState)
|| { to: undefined }).to
|| [];
}
probable_exits_for(whichState) {
const wstate = this._states.get(whichState);
if (!(wstate)) {
throw new JssmError(this, `No such state ${JSON.stringify(whichState)} in probable_exits_for`);
}
const wstate_to = wstate.to, wtf = wstate_to
.map((ws) => this.lookup_transition_for(this.state(), ws))
.filter(Boolean);
return wtf;
}
probabilistic_transition() {
const selected = weighted_rand_select(this.probable_exits_for(this.state()));
return this.transition(selected.to);
}
probabilistic_walk(n) {
return seq(n)
.map(() => {
const state_was = this.state();
this.probabilistic_transition();
return state_was;
})
.concat([this.state()]);
}
probabilistic_histo_walk(n) {
return histograph(this.probabilistic_walk(n));
}
/********
*
* List all actions available from this state. Please note that the order of
* the actions is not guaranteed.
*
* ```typescript
* import { sm } from 'jssm';
*
* const machine = sm`
* red 'next' -> green 'next' -> yellow 'next' -> red;
* [red yellow green] 'shutdown' ~> off 'start' -> red;
* `;
*
* console.log( machine.state() ); // logs 'red'
* console.log( machine.actions() ); // logs ['next', 'shutdown']
*
* machine.action('next'); // true
* console.log( machine.state() ); // logs 'green'
* console.log( machine.actions() ); // logs ['next', 'shutdown']
*
* machine.action('shutdown'); // true
* console.log( machine.state() ); // logs 'off'
* console.log( machine.actions() ); // logs ['start']
*
* machine.action('start'); // true
* console.log( machine.state() ); // logs 'red'
* console.log( machine.actions() ); // logs ['next', 'shutdown']
* ```
*
* @typeparam mDT The type of the machine data member; usually omitted
*
* @param whichState The state whose actions to have listed
*
*/
actions(whichState = this.state()) {
const wstate = this._reverse_actions.get(whichState);
if (wstate) {
return Array.from(wstate.keys());
}
else {
throw new JssmError(this, `No such state ${JSON.stringify(whichState)}`);
}
}
/********
*
* List all states that have a specific action attached. Please note that
* the order of the states is not guaranteed.
*
* ```typescript
* import { sm } from 'jssm';
*
* const machine = sm`
* red 'next' -> green 'next' -> yellow 'next' -> red;
* [red yellow green] 'shutdown' ~> off 'start' -> red;
* `;
*
* console.log( machine.list_states_having_action('next') ); // ['red', 'green', 'yellow']
* console.log( machine.list_states_having_action('start') ); // ['off']
* ```
*
* @typeparam mDT The type of the machine data member; usually omitted
*
* @param whichState The action to be checked for associated states
*
*/
list_states_having_action(whichState) {
const wstate = this._actions.get(whichState);
if (wstate) {
return Array.from(wstate.keys());
}
else {
throw new JssmError(this, `No such state ${JSON.stringify(whichState)}`);
}
}
// comeback
/*
list_entrance_actions(whichState: mNT = this.state() ) : Array<mNT> {
return [... (this._reverse_action_targets.get(whichState) || new Map()).values()] // wasteful
.map( (edgeId:any) => (this._edges[edgeId] : any)) // whargarbl burn out any
.filter( (o:any) => o.to === whichState)
.map( filtered => filtered.from );
}
*/
list_exit_actions(whichState = this.state()) {
const ra_base = this._reverse_actions.get(whichState);
if (!(ra_base)) {
throw new JssmError(this, `No such state ${JSON.stringify(whichState)}`);
}
return Array.from(ra_base.values())
.map((edgeId) => this._edges[edgeId])
.filter((o) => o.from === whichState)
.map((filtered) => filtered.action);
}
probable_action_exits(whichState = this.state()) {
const ra_base = this._reverse_actions.get(whichState);
if (!(ra_base)) {
throw new JssmError(this, `No such state ${JSON.stringify(whichState)}`);
}
return Array.from(ra_base.values())
.map((edgeId) => this._edges[edgeId])
.filter((o) => o.from === whichState)
.map((filtered) => ({
action: filtered.action,
probability: filtered.probability
}));
}
// TODO FIXME test that is_unenterable on non-state throws
is_unenterable(whichState) {
if (!(this.has_state(whichState))) {
throw new JssmError(this, `No such state ${whichState}`);
}
return this.list_entrances(whichState).length === 0;
}
has_unenterables() {
return this.states().some((x) => this.is_unenterable(x));
}
is_terminal() {
return this.state_is_terminal(this.state());
}
// TODO FIXME test that state_is_terminal on non-state throws
state_is_terminal(whichState) {
if (!(this.has_state(whichState))) {
throw new JssmError(this, `No such state ${whichState}`);
}
return this.list_exits(whichState).length === 0;
}
has_terminals() {
return this.states().some((x) => this.state_is_terminal(x));
}
is_complete() {
return this.state_is_complete(this.state());
}
state_is_complete(whichState) {
const wstate = this._states.get(whichState);
if (wstate) {
return wstate.complete;
}
else {
throw new JssmError(this, `No such state ${JSON.stringify(whichState)}`);
}
}
has_completes() {
return this.states().some((x) => this.state_is_complete(x));
}
// basic toolable hook call. convenience wrappers will follow, like
// hook(from, to, handler) and exit_hook(from, handler) and etc
set_hook(HookDesc) {
switch (HookDesc.kind) {
case 'hook':
this._hooks.set(hook_name(HookDesc.from, HookDesc.to), HookDesc.handler);
this._has_hooks = true;
this._has_basic_hooks = true;
break;
case 'named':
this._named_hooks.set(named_hook_name(HookDesc.from, HookDesc.to, HookDesc.action), HookDesc.handler);
this._has_hooks = true;
this._has_named_hooks = true;
break;
case 'global action':
this._global_action_hooks.set(HookDesc.action, HookDesc.handler);
this._has_hooks = true;
this._has_global_action_hooks = true;
break;
case 'any action':
this._any_action_hook = HookDesc.handler;
this._has_hooks = true;
break;
case 'standard transition':
this._standard_transition_hook = HookDesc.handler;
this._has_transition_hooks = true;
this._has_hooks = true;
break;
case 'main transition':
this._main_transition_hook = HookDesc.handler;
this._has_transition_hooks = true;
this._has_hooks = true;
break;
case 'forced transition':
this._forced_transition_hook = HookDesc.handler;
this._has_transition_hooks = true;
this._has_hooks = true;
break;
case 'any transition':
this._any_transition_hook = HookDesc.handler;
this._has_hooks = true;
break;
case 'entry':
this._entry_hooks.set(HookDesc.to, HookDesc.handler);
this._has_hooks = true;
this._has_entry_hooks = true;
break;
case 'exit':
this._exit_hooks.set(HookDesc.from, HookDesc.handler);
this._has_hooks = true;
this._has_exit_hooks = true;
break;
default:
throw new JssmError(this, `Unknown hook type ${HookDesc.kind}, should be impossible`);
}
}
hook(from, to, handler) {
this.set_hook({ kind: 'hook', from, to, handler });
return this;
}
hook_action(from, to, action, handler) {
this.set_hook({ kind: 'named', from, to, action, handler });
return this;
}
hook_global_action(action, handler) {
this.set_hook({ kind: 'global action', action, handler });
return this;
}
hook_any_action(handler) {
this.set_hook({ kind: 'any action', handler });
return this;
}
hook_standard_transition(handler) {
this.set_hook({ kind: 'standard transition', handler });
return this;
}
hook_main_transition(handler) {
this.set_hook({ kind: 'main transition', handler });
return this;
}
hook_forced_transition(handler) {
this.set_hook({ kind: 'forced transition', handler });
return this;
}
hook_any_transition(handler) {
this.set_hook({ kind: 'any transition', handler });
return this;
}
hook_entry(to, handler) {
this.set_hook({ kind: 'entry', to, handler });
return this;
}
hook_exit(from, handler) {
this.set_hook({ kind: 'exit', from, handler });
return this;
}
// remove_hook(HookDesc: HookDescription) {
// throw new JssmError(this, 'TODO: Should remove hook here');
// }
edges_between(from, to) {
return this._edges.filter(edge => ((edge.from === from) && (edge.to === to)));
}
transition_impl(newStateOrAction, newData, wasForced, wasAction) {
// TODO the forced-ness behavior needs to be cleaned up a lot here
// TODO all the callbacks are wrong on forced, action, etc
let valid = false, trans_type, newState, fromAction = undefined;
if (wasForced) {
if (this.valid_force_transition(newStateOrAction, newData)) {
valid = true;
trans_type = 'forced';
newState = newStateOrAction;
}
}
else if (wasAction) {
if (this.valid_action(newStateOrAction, newData)) {
const edge = this.current_action_edge_for(newStateOrAction);
valid = true;
trans_type = edge.kind;
newState = edge.to;
fromAction = newStateOrAction;
}
}
else {
if (this.valid_transition(newStateOrAction, newData)) {
if (this._has_transition_hooks) {
trans_type = this.edges_between(this._state, newStateOrAction)[0].kind; // TODO this won't do the right thing if various edges have different types
}
valid = true;
newState = newStateOrAction;
}
}
if (valid) {
if (this._has_hooks) {
const hook_args = {
data: this._data,
action: fromAction,
from: this._state,
to: newState,
forced: wasForced
};
function update_fields(res) {
if (res.hasOwnProperty('data')) {
hook_args.data = res.data;
data_changed = true;
}
}
let data_changed = false;
if (wasAction) {
// 1. any action hook
const outcome = abstract_hook_step(this._any_action_hook, hook_args);
if (outcome.pass === false) {
return false;
}
update_fields(outcome);
// 2. global specific action hook
const outcome2 = abstract_hook_step(this._global_action_hooks.get(newStateOrAction), hook_args);
if (outcome2.pass === false) {
return false;
}
update_fields(outcome2);
}
// 3. any transition hook
if (this._any_transition_hook !== undefined) {
const outcome = abstract_hook_step(this._any_transition_hook, hook_args);
if (outcome.pass === false) {
return false;
}
update_fields(outcome);
}
// 4. exit hook
if (this._has_exit_hooks) {
const outcome = abstract_hook_step(this._exit_hooks.get(this._state), hook_args);
if (outcome.pass === false) {
return false;
}
update_fields(outcome);
}
// 5. named transition / action hook
if (this._has_named_hooks) {
if (wasAction) {
const nhn = named_hook_name(this._state, newState, newStateOrAction), outcome = abstract_hook_step(this._named_hooks.get(nhn), hook_args);
if (outcome.pass === false) {
return false;
}
update_fields(outcome);
}
}
// 6. regular hook
if (this._has_basic_hooks) {
const hn = hook_name(this._state, newState), outcome = abstract_hook_step(this._hooks.get(hn), hook_args);
if (outcome.pass === false) {
return false;
}
update_fields(outcome);
}
// 7. edge type hook
// 7a. standard transition hook
if (trans_type === 'legal') {
const outcome = abstract_hook_step(this._standard_transition_hook, hook_args);
if (outcome.pass === false) {
return false;
}
update_fields(outcome);
}
// 7b. main type hook
if (trans_type === 'main') {
const outcome = abstract_hook_step(this._main_transition_hook, hook_args);
if (outcome.pass === false) {
return false;
}
update_fields(outcome);
}
// 7c. forced transition hook
if (trans_type === 'forced') {
const outcome = abstract_hook_step(this._forced_transition_hook, hook_args);
if (outcome.pass === false) {
return false;
}
update_fields(outcome);
}
// 8. entry hook
if (this._has_entry_hooks) {
const outcome = abstract_hook_step(this._entry_hooks.get(newState), hook_args);
if (outcome.pass === false) {
return false;
}
update_fields(outcome);
}
// all hooks passed! let's now establish the result
this._state = newState;
if (data_changed) {
this._data = hook_args.data;
}
return true;
// or without hooks
}
else {
this._state = newState;
return true;
}
// not valid
}
else {
retu