it-fsm/fsm.ts

355 lines
9.2 KiB
TypeScript
Raw Normal View History

2022-06-21 16:56:49 +03:00
/**
2022-11-17 18:49:50 +03:00
* Copyright (C) 2019, Dmitriy Pleshevskiy <dmitriy@pleshevski.ru>
2022-06-21 16:56:49 +03:00
*
* it-fsm is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* it-fsm is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with it-fsm. If not, see <https://www.gnu.org/licenses/>.
*/
export type StateTransitions<Ctx, SN extends string> = WeakMap<
2021-08-23 11:12:08 +03:00
State<Ctx, SN>,
WeakSet<State<Ctx, SN>>
2021-08-20 01:32:01 +03:00
>;
export type StateOrName<Ctx, SN extends string> =
2021-08-23 11:12:08 +03:00
| State<Ctx, SN>
| SN;
export type SourceTransitions<SN extends string> = Array<[SN, Array<SN>]>;
export type SourceNamedTransitions<SN extends string> = Array<
2021-08-23 11:12:08 +03:00
[SN, Record<string, SN>]
>;
export type SourceActions<SN extends string> = Record<string, Array<[SN, SN]>>;
2021-08-20 11:43:31 +03:00
2021-08-20 01:32:01 +03:00
export const _states = Symbol("states");
export const _transitions = Symbol("transitions");
export const _actions = Symbol("actions");
export const _prevState = Symbol("previous state");
export const _currState = Symbol("current state");
2021-08-20 01:32:01 +03:00
2021-08-23 11:12:08 +03:00
export class StateMachineBuilder<Ctx, SN extends string = string> {
[_states]: Map<SN, Events<Ctx, SN>>;
2021-08-20 01:32:01 +03:00
2021-08-23 11:12:08 +03:00
[_transitions]: SourceTransitions<SN> | undefined;
[_actions]: SourceActions<SN> | undefined;
2021-08-20 01:32:01 +03:00
constructor() {
this[_states] = new Map();
}
2021-08-23 11:12:08 +03:00
withTransitions(
sourceTransitions: SourceTransitions<SN> | SourceNamedTransitions<SN>,
) {
const [t, a] =
(sourceTransitions as Array<[SN, Array<SN> | Record<string, SN>]>)
.reduce(
([transitions, actions], [fromState, sources]) => {
const toStates = Array.isArray(sources)
? sources
: Object.values(sources);
transitions.push([fromState, toStates]);
if (!Array.isArray(sources)) {
Object.entries(sources).forEach(([actionName, toState]) => {
const actionTransitions = actions[actionName] || [];
actions[actionName] = [
...actionTransitions,
[fromState, toState],
];
});
}
return [transitions, actions];
},
[[], {}] as [SourceTransitions<SN>, SourceActions<SN>],
);
this[_transitions] = t;
this[_actions] = a;
2021-08-20 01:32:01 +03:00
return this;
}
2021-08-23 11:12:08 +03:00
withStates(names: SN[], actions?: Events<Ctx, SN>) {
2021-08-20 01:32:01 +03:00
names.forEach((name) => this.addStateUnchecked(name, actions));
return this;
}
2021-08-23 11:12:08 +03:00
withState(name: SN, actions?: Events<Ctx, SN>) {
2021-08-20 01:32:01 +03:00
this.addStateUnchecked(name, actions);
return this;
}
private addStateUnchecked(
2021-08-23 11:12:08 +03:00
name: SN,
actions?: Events<Ctx, SN>,
) {
2021-08-20 01:32:01 +03:00
const oldActions = this[_states].get(name);
return this[_states].set(name, { ...oldActions, ...actions });
}
2021-08-23 11:12:08 +03:00
build(currentStateName: SN) {
2021-08-20 01:32:01 +03:00
const states = this.buildStates();
const transitions = this.buildTransitions(states);
2021-08-23 11:12:08 +03:00
const actions = this.buildActions(states);
2021-08-20 01:32:01 +03:00
const currState = validStateFromName(states, currentStateName);
2021-08-23 11:12:08 +03:00
return new StateMachine<Ctx, SN>(currState, states, {
transitions,
actions,
});
2021-08-20 01:32:01 +03:00
}
private buildStates() {
2021-08-23 11:12:08 +03:00
return Array.from(this[_states].entries())
.map((params) => new State(...params));
2021-08-20 01:32:01 +03:00
}
2021-08-23 11:12:08 +03:00
private buildTransitions(states: State<Ctx, SN>[]) {
const sourceTransitions = this[_transitions];
if (!sourceTransitions) return undefined;
2021-08-20 01:32:01 +03:00
return new WeakMap(
sourceTransitions.map(([from, toStates]) => [
validStateFromName(states, from),
new WeakSet(toStates.map(validStateFromName.bind(null, states))),
]),
);
}
2021-08-23 11:12:08 +03:00
private buildActions(states: State<Ctx, SN>[]): Actions<Ctx, SN> | undefined {
const actions = this[_actions];
if (!actions) return undefined;
return new Map(
Object.entries(actions).map(([actionName, variants]) => [
actionName,
variants.map(([fromState, toState]) => [
validStateFromName(states, fromState),
validStateFromName(states, toState),
]),
]),
);
}
}
export interface StateMachineOpts<Ctx, SN extends string> {
2021-08-23 11:12:08 +03:00
transitions?: StateTransitions<Ctx, SN>;
actions?: Actions<Ctx, SN>;
2021-08-20 01:32:01 +03:00
}
export type Actions<Ctx, SN extends string> = Map<
2021-08-23 11:12:08 +03:00
string,
Array<[State<Ctx, SN>, State<Ctx, SN>]>
>;
export class StateMachine<Ctx, SN extends string = string> {
[_states]: State<Ctx, SN>[];
2021-08-20 01:32:01 +03:00
2021-08-23 11:12:08 +03:00
[_transitions]: StateTransitions<Ctx, SN>;
2021-08-20 01:32:01 +03:00
2021-08-23 11:12:08 +03:00
[_actions]: Actions<Ctx, SN>;
2021-08-20 01:32:01 +03:00
2021-08-23 11:12:08 +03:00
[_prevState]: State<Ctx, SN> | undefined;
[_currState]: State<Ctx, SN>;
get currentState() {
return this[_currState];
}
2021-08-20 01:32:01 +03:00
constructor(
2021-08-23 11:12:08 +03:00
currentState: State<Ctx, SN>,
states: State<Ctx, SN>[],
{ transitions, actions }: StateMachineOpts<Ctx, SN>,
2021-08-20 01:32:01 +03:00
) {
this[_currState] = currentState;
2021-08-23 11:12:08 +03:00
this[_states] = states;
this[_transitions] = transitions || new Map();
this[_actions] = actions || new Map();
2021-08-20 01:32:01 +03:00
}
async tryChangeState(
2021-08-23 11:12:08 +03:00
state: StateOrName<Ctx, SN>,
context: Ctx,
) {
2021-08-23 11:12:08 +03:00
const fromState = validState<Ctx, SN>(this.currentState);
const toState = validNormalizedState(this[_states], state);
2021-08-20 01:32:01 +03:00
if (
!this.hasTransition(toState) ||
!fromState.exit(fromState, toState, context)
) {
throw new FsmError(
`cannot change state from "${fromState.name}" to "${toState.name}"`,
);
}
await toState.entry(fromState, toState, context);
this[_prevState] = fromState;
this[_currState] = toState;
return this[_currState];
2021-08-20 01:32:01 +03:00
}
2021-08-23 11:12:08 +03:00
maybeChangeState(state: StateOrName<Ctx, SN>, context: Ctx) {
return this.tryChangeState(state, context).catch(() => null);
}
2021-08-23 11:12:08 +03:00
hasTransition(to: StateOrName<Ctx, SN>) {
2021-08-20 01:32:01 +03:00
return hasTransition(
2021-08-23 11:12:08 +03:00
this[_transitions],
2021-08-20 01:32:01 +03:00
this[_currState],
2021-08-20 11:43:31 +03:00
validNormalizedState(this[_states], to),
2021-08-20 01:32:01 +03:00
);
}
allowedTransitionStates() {
return this[_states].filter(
2021-08-23 11:12:08 +03:00
hasTransition.bind(null, this[_transitions], this[_currState]),
2021-08-20 01:32:01 +03:00
);
}
allowedTransitionStateNames() {
return this.allowedTransitionStates().map(String);
}
2021-08-23 11:12:08 +03:00
trigger(actionName: string, context: Ctx) {
const currState = this[_currState];
const variants = this[_actions]?.get(actionName);
if (!variants) return currState;
const [, toState] =
variants.find(([fromState]) => fromState === currState) || [];
if (!toState) return currState;
return this.tryChangeState(toState, context);
}
2021-08-20 01:32:01 +03:00
}
export const _stateName = Symbol("state name");
export const _stateEvents = Symbol("state events");
2021-08-20 01:32:01 +03:00
export interface Events<Ctx, SN extends string> {
2021-08-20 01:32:01 +03:00
beforeExit?(
2021-08-23 11:12:08 +03:00
fromState: State<Ctx, SN>,
toState: State<Ctx, SN>,
context: Ctx,
2021-08-20 01:32:01 +03:00
): boolean;
onEntry?(
2021-08-23 11:12:08 +03:00
fromState: State<Ctx, SN>,
toState: State<Ctx, SN>,
context: Ctx,
2021-08-20 01:32:01 +03:00
): Promise<void> | void;
}
2021-08-23 11:12:08 +03:00
export class State<Ctx, SN extends string = string> {
[_stateEvents]: Events<Ctx, SN>;
2021-08-20 01:32:01 +03:00
2021-08-23 11:12:08 +03:00
[_stateName]: SN;
2021-08-20 01:32:01 +03:00
2021-08-23 11:12:08 +03:00
get name(): SN {
2021-08-20 01:32:01 +03:00
return this[_stateName];
}
2021-08-23 11:12:08 +03:00
constructor(name: SN, events: Events<Ctx, SN> = {}) {
2021-08-20 01:32:01 +03:00
this[_stateName] = name;
this[_stateEvents] = events;
2021-08-20 01:32:01 +03:00
}
async entry(
2021-08-23 11:12:08 +03:00
fromState: State<Ctx, SN>,
toState: State<Ctx, SN>,
context: Ctx,
2021-08-20 01:32:01 +03:00
) {
const event = this[_stateEvents].onEntry;
if (isFn(event)) {
await event(fromState, toState, context);
2021-08-20 01:32:01 +03:00
}
}
2021-08-23 11:12:08 +03:00
exit(fromState: State<Ctx, SN>, toState: State<Ctx, SN>, context: Ctx) {
const event = this[_stateEvents].beforeExit;
return isFn(event) ? event(fromState, toState, context) : true;
2021-08-20 01:32:01 +03:00
}
toString() {
return this.name;
}
toJSON() {
return this.toString();
}
}
export function validNormalizedState<Ctx, SN extends string>(
2021-08-23 11:12:08 +03:00
states: State<Ctx, SN>[],
state: StateOrName<Ctx, SN>,
2021-08-20 11:43:31 +03:00
) {
2021-08-23 11:12:08 +03:00
return validState<Ctx, SN>(normalizeState(states, state));
2021-08-20 01:32:01 +03:00
}
export function normalizeState<Ctx, SN extends string>(
2021-08-23 11:12:08 +03:00
states: State<Ctx, SN>[],
state: StateOrName<Ctx, SN>,
): State<Ctx, SN> | undefined {
2021-08-20 01:32:01 +03:00
return isStr(state) ? stateFromName(states, state) : state;
}
export function validStateFromName<Ctx, SN extends string>(
2021-08-23 11:12:08 +03:00
states: State<Ctx, SN>[],
name: SN,
2021-08-20 11:43:31 +03:00
) {
2021-08-23 11:12:08 +03:00
return validState<Ctx, SN>(stateFromName(states, name));
2021-08-20 11:43:31 +03:00
}
export function stateFromName<Ctx, SN extends string>(
2021-08-23 11:12:08 +03:00
states: State<Ctx, SN>[],
name: SN,
) {
2021-08-20 11:43:31 +03:00
return states.find((state) => state.name === name);
}
export function validState<Ctx, SN extends string>(
val: unknown,
): State<Ctx, SN> {
2021-08-23 11:12:08 +03:00
if (!isState<Ctx, SN>(val)) {
2021-08-20 01:32:01 +03:00
throw new TypeError("an instance of State class is expected");
}
return val;
}
export function isState<Ctx, SN extends string>(
val: unknown,
): val is State<Ctx, SN> {
2021-08-20 01:32:01 +03:00
return val instanceof State;
}
2021-08-23 11:12:08 +03:00
function hasTransition<Ctx, SN extends string>(
transitions: StateTransitions<Ctx, SN>,
from: State<Ctx, SN>,
to: State<Ctx, SN>,
2021-08-20 01:32:01 +03:00
) {
return transitions.get(from)?.has(to) || false;
}
function isStr(val: unknown): val is string {
return typeof val === "string";
}
// deno-lint-ignore ban-types
function isFn(val: unknown): val is Function {
return typeof val === "function";
}
export class FsmError extends Error {}