feat: add actions
This commit is contained in:
parent
0d377ba222
commit
8f3fd1337b
2 changed files with 185 additions and 89 deletions
21
fsm.test.ts
21
fsm.test.ts
|
@ -208,3 +208,24 @@ Deno.test("should reuse one builder for many entities", () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("should trigger builded transition actions", async () => {
|
||||||
|
const states = ["locked", "unlocked"] as const;
|
||||||
|
const [locked, unlocked] = states;
|
||||||
|
|
||||||
|
const sm = new fsm.StateMachineBuilder()
|
||||||
|
.withStates([locked, unlocked])
|
||||||
|
.withTransitions([
|
||||||
|
[locked, { coin: unlocked }],
|
||||||
|
[unlocked, { push: locked }],
|
||||||
|
])
|
||||||
|
.build(locked);
|
||||||
|
|
||||||
|
const [lockedState, unlockedState] = sm[fsm._states];
|
||||||
|
|
||||||
|
assertEquals(await sm.trigger("coin", {}), unlockedState);
|
||||||
|
assertEquals(await sm.trigger("coin", {}), unlockedState);
|
||||||
|
|
||||||
|
assertEquals(await sm.trigger("push", {}), lockedState);
|
||||||
|
assertEquals(await sm.trigger("push", {}), lockedState);
|
||||||
|
});
|
||||||
|
|
253
fsm.ts
253
fsm.ts
|
@ -1,64 +1,104 @@
|
||||||
type StateTransitions<Context, StateName extends string> = WeakMap<
|
type StateTransitions<Ctx, SN extends string> = WeakMap<
|
||||||
State<Context, StateName>,
|
State<Ctx, SN>,
|
||||||
WeakSet<State<Context, StateName>>
|
WeakSet<State<Ctx, SN>>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type StateOrName<Context, StateName extends string> =
|
type StateOrName<Ctx, SN extends string> =
|
||||||
| State<Context, StateName>
|
| State<Ctx, SN>
|
||||||
| StateName;
|
| SN;
|
||||||
|
|
||||||
|
type SourceTransitions<SN extends string> = Array<[SN, Array<SN>]>;
|
||||||
|
type SourceNamedTransitions<SN extends string> = Array<
|
||||||
|
[SN, Record<string, SN>]
|
||||||
|
>;
|
||||||
|
type SourceActions<SN extends string> = Record<string, Array<[SN, SN]>>;
|
||||||
|
|
||||||
export const _states = Symbol("states");
|
export const _states = Symbol("states");
|
||||||
export const _stateTransitions = Symbol("state transitions");
|
const _transitions = Symbol("transitions");
|
||||||
export const _prevState = Symbol("previous state");
|
const _actions = Symbol("actions");
|
||||||
export const _currState = Symbol("current state");
|
const _prevState = Symbol("previous state");
|
||||||
|
const _currState = Symbol("current state");
|
||||||
|
|
||||||
export class StateMachineBuilder<Context, StateName extends string = string> {
|
export class StateMachineBuilder<Ctx, SN extends string = string> {
|
||||||
[_states]: Map<StateName, Events<Context, StateName>>;
|
[_states]: Map<SN, Events<Ctx, SN>>;
|
||||||
|
|
||||||
[_stateTransitions]: Array<[StateName, Array<StateName>]> | undefined;
|
[_transitions]: SourceTransitions<SN> | undefined;
|
||||||
|
|
||||||
|
[_actions]: SourceActions<SN> | undefined;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this[_states] = new Map();
|
this[_states] = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
withTransitions(transitions: Array<[StateName, Array<StateName>]>) {
|
withTransitions(
|
||||||
this[_stateTransitions] = transitions;
|
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;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
withStates(names: StateName[], actions?: Events<Context, StateName>) {
|
withStates(names: SN[], actions?: Events<Ctx, SN>) {
|
||||||
names.forEach((name) => this.addStateUnchecked(name, actions));
|
names.forEach((name) => this.addStateUnchecked(name, actions));
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
withState(name: StateName, actions?: Events<Context, StateName>) {
|
withState(name: SN, actions?: Events<Ctx, SN>) {
|
||||||
this.addStateUnchecked(name, actions);
|
this.addStateUnchecked(name, actions);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
private addStateUnchecked(
|
private addStateUnchecked(
|
||||||
name: StateName,
|
name: SN,
|
||||||
actions?: Events<Context, StateName>,
|
actions?: Events<Ctx, SN>,
|
||||||
) {
|
) {
|
||||||
const oldActions = this[_states].get(name);
|
const oldActions = this[_states].get(name);
|
||||||
return this[_states].set(name, { ...oldActions, ...actions });
|
return this[_states].set(name, { ...oldActions, ...actions });
|
||||||
}
|
}
|
||||||
|
|
||||||
build(currentStateName: StateName) {
|
build(currentStateName: SN) {
|
||||||
const states = this.buildStates();
|
const states = this.buildStates();
|
||||||
const transitions = this.buildTransitions(states);
|
const transitions = this.buildTransitions(states);
|
||||||
|
const actions = this.buildActions(states);
|
||||||
const currState = validStateFromName(states, currentStateName);
|
const currState = validStateFromName(states, currentStateName);
|
||||||
return new StateMachine<Context, StateName>(states, transitions, currState);
|
return new StateMachine<Ctx, SN>(currState, states, {
|
||||||
|
transitions,
|
||||||
|
actions,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildStates() {
|
private buildStates() {
|
||||||
return Array.from(this[_states].entries()).map((params) =>
|
return Array.from(this[_states].entries())
|
||||||
new State(...params)
|
.map((params) => new State(...params));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildTransitions(states: State<Context, StateName>[]) {
|
private buildTransitions(states: State<Ctx, SN>[]) {
|
||||||
const sourceTransitions = this[_stateTransitions] || [];
|
const sourceTransitions = this[_transitions];
|
||||||
|
if (!sourceTransitions) return undefined;
|
||||||
|
|
||||||
return new WeakMap(
|
return new WeakMap(
|
||||||
sourceTransitions.map(([from, toStates]) => [
|
sourceTransitions.map(([from, toStates]) => [
|
||||||
|
@ -67,32 +107,63 @@ export class StateMachineBuilder<Context, StateName extends string = string> {
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 class StateMachine<Context, StateName extends string = string> {
|
interface StateMachineOpts<Ctx, SN extends string> {
|
||||||
[_states]: State<Context, StateName>[];
|
transitions?: StateTransitions<Ctx, SN>;
|
||||||
|
actions?: Actions<Ctx, SN>;
|
||||||
|
}
|
||||||
|
|
||||||
[_stateTransitions]: StateTransitions<Context, StateName>;
|
type Actions<Ctx, SN extends string> = Map<
|
||||||
|
string,
|
||||||
|
Array<[State<Ctx, SN>, State<Ctx, SN>]>
|
||||||
|
>;
|
||||||
|
|
||||||
[_prevState]: State<Context, StateName> | undefined;
|
export class StateMachine<Ctx, SN extends string = string> {
|
||||||
|
[_states]: State<Ctx, SN>[];
|
||||||
|
|
||||||
[_currState]: State<Context, StateName>;
|
[_transitions]: StateTransitions<Ctx, SN>;
|
||||||
|
|
||||||
|
[_actions]: Actions<Ctx, SN>;
|
||||||
|
|
||||||
|
[_prevState]: State<Ctx, SN> | undefined;
|
||||||
|
|
||||||
|
[_currState]: State<Ctx, SN>;
|
||||||
|
|
||||||
|
get currentState() {
|
||||||
|
return this[_currState];
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
states: State<Context, StateName>[],
|
currentState: State<Ctx, SN>,
|
||||||
transitions: StateTransitions<Context, StateName>,
|
states: State<Ctx, SN>[],
|
||||||
currentState: State<Context, StateName>,
|
{ transitions, actions }: StateMachineOpts<Ctx, SN>,
|
||||||
) {
|
) {
|
||||||
this[_states] = states;
|
|
||||||
this[_stateTransitions] = transitions;
|
|
||||||
this[_currState] = currentState;
|
this[_currState] = currentState;
|
||||||
|
this[_states] = states;
|
||||||
|
this[_transitions] = transitions || new Map();
|
||||||
|
this[_actions] = actions || new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
async tryChangeState(
|
async tryChangeState(
|
||||||
state: StateOrName<Context, StateName>,
|
state: StateOrName<Ctx, SN>,
|
||||||
context: Context,
|
context: Ctx,
|
||||||
) {
|
) {
|
||||||
const fromState = validState<Context, StateName>(this[_currState]);
|
const fromState = validState<Ctx, SN>(this.currentState);
|
||||||
const toState = validNormalizedState(this[_states], state);
|
const toState = validNormalizedState(this[_states], state);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -112,64 +183,76 @@ export class StateMachine<Context, StateName extends string = string> {
|
||||||
return this[_currState];
|
return this[_currState];
|
||||||
}
|
}
|
||||||
|
|
||||||
maybeChangeState(state: StateOrName<Context, StateName>, context: Context) {
|
maybeChangeState(state: StateOrName<Ctx, SN>, context: Ctx) {
|
||||||
return this.tryChangeState(state, context).catch(() => null);
|
return this.tryChangeState(state, context).catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
hasTransition(to: StateOrName<Context, StateName>) {
|
hasTransition(to: StateOrName<Ctx, SN>) {
|
||||||
return hasTransition(
|
return hasTransition(
|
||||||
this[_stateTransitions],
|
this[_transitions],
|
||||||
this[_currState],
|
this[_currState],
|
||||||
validNormalizedState(this[_states], to),
|
validNormalizedState(this[_states], to),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
allowedTransitionStates() {
|
allowedTransitionStates() {
|
||||||
const fromState = validState(this[_currState]);
|
|
||||||
return this[_states].filter(
|
return this[_states].filter(
|
||||||
hasTransition.bind(null, this[_stateTransitions], fromState),
|
hasTransition.bind(null, this[_transitions], this[_currState]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
allowedTransitionStateNames() {
|
allowedTransitionStateNames() {
|
||||||
return this.allowedTransitionStates().map(String);
|
return this.allowedTransitionStates().map(String);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const _stateName = Symbol("state name");
|
const _stateName = Symbol("state name");
|
||||||
const _stateEvents = Symbol("state events");
|
const _stateEvents = Symbol("state events");
|
||||||
|
|
||||||
interface Events<Context, StateName extends string> {
|
interface Events<Ctx, SN extends string> {
|
||||||
beforeExit?(
|
beforeExit?(
|
||||||
fromState: State<Context, StateName>,
|
fromState: State<Ctx, SN>,
|
||||||
toState: State<Context, StateName>,
|
toState: State<Ctx, SN>,
|
||||||
context: Context,
|
context: Ctx,
|
||||||
): boolean;
|
): boolean;
|
||||||
onEntry?(
|
onEntry?(
|
||||||
fromState: State<Context, StateName>,
|
fromState: State<Ctx, SN>,
|
||||||
toState: State<Context, StateName>,
|
toState: State<Ctx, SN>,
|
||||||
context: Context,
|
context: Ctx,
|
||||||
): Promise<void> | void;
|
): Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class State<Context, StateName extends string = string> {
|
export class State<Ctx, SN extends string = string> {
|
||||||
[_stateEvents]: Events<Context, StateName>;
|
[_stateEvents]: Events<Ctx, SN>;
|
||||||
|
|
||||||
[_stateName]: StateName;
|
[_stateName]: SN;
|
||||||
|
|
||||||
get name(): StateName {
|
get name(): SN {
|
||||||
return this[_stateName];
|
return this[_stateName];
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(name: StateName, events: Events<Context, StateName> = {}) {
|
constructor(name: SN, events: Events<Ctx, SN> = {}) {
|
||||||
this[_stateName] = name;
|
this[_stateName] = name;
|
||||||
this[_stateEvents] = events;
|
this[_stateEvents] = events;
|
||||||
}
|
}
|
||||||
|
|
||||||
async entry(
|
async entry(
|
||||||
fromState: State<Context, StateName>,
|
fromState: State<Ctx, SN>,
|
||||||
toState: State<Context, StateName>,
|
toState: State<Ctx, SN>,
|
||||||
context: Context,
|
context: Ctx,
|
||||||
) {
|
) {
|
||||||
const event = this[_stateEvents].onEntry;
|
const event = this[_stateEvents].onEntry;
|
||||||
if (isFn(event)) {
|
if (isFn(event)) {
|
||||||
|
@ -177,11 +260,7 @@ export class State<Context, StateName extends string = string> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exit(
|
exit(fromState: State<Ctx, SN>, toState: State<Ctx, SN>, context: Ctx) {
|
||||||
fromState: State<Context, StateName>,
|
|
||||||
toState: State<Context, StateName>,
|
|
||||||
context: Context,
|
|
||||||
) {
|
|
||||||
const event = this[_stateEvents].beforeExit;
|
const event = this[_stateEvents].beforeExit;
|
||||||
return isFn(event) ? event(fromState, toState, context) : true;
|
return isFn(event) ? event(fromState, toState, context) : true;
|
||||||
}
|
}
|
||||||
|
@ -195,53 +274,49 @@ export class State<Context, StateName extends string = string> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validNormalizedState<Context, StateName extends string>(
|
function validNormalizedState<Ctx, SN extends string>(
|
||||||
states: State<Context, StateName>[],
|
states: State<Ctx, SN>[],
|
||||||
state: StateOrName<Context, StateName>,
|
state: StateOrName<Ctx, SN>,
|
||||||
) {
|
) {
|
||||||
return validState<Context, StateName>(normalizeState(states, state));
|
return validState<Ctx, SN>(normalizeState(states, state));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeState<Context, StateName extends string>(
|
function normalizeState<Ctx, SN extends string>(
|
||||||
states: State<Context, StateName>[],
|
states: State<Ctx, SN>[],
|
||||||
state: StateOrName<Context, StateName>,
|
state: StateOrName<Ctx, SN>,
|
||||||
): State<Context, StateName> | undefined {
|
): State<Ctx, SN> | undefined {
|
||||||
return isStr(state) ? stateFromName(states, state) : state;
|
return isStr(state) ? stateFromName(states, state) : state;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validStateFromName<Context, StateName extends string>(
|
function validStateFromName<Ctx, SN extends string>(
|
||||||
states: State<Context, StateName>[],
|
states: State<Ctx, SN>[],
|
||||||
name: StateName,
|
name: SN,
|
||||||
) {
|
) {
|
||||||
return validState<Context, StateName>(stateFromName(states, name));
|
return validState<Ctx, SN>(stateFromName(states, name));
|
||||||
}
|
}
|
||||||
|
|
||||||
function stateFromName<Context, StateName extends string>(
|
function stateFromName<Ctx, SN extends string>(
|
||||||
states: State<Context, StateName>[],
|
states: State<Ctx, SN>[],
|
||||||
name: StateName,
|
name: SN,
|
||||||
) {
|
) {
|
||||||
return states.find((state) => state.name === name);
|
return states.find((state) => state.name === name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validState<Context, StateName extends string>(
|
function validState<Ctx, SN extends string>(val: unknown): State<Ctx, SN> {
|
||||||
val: unknown,
|
if (!isState<Ctx, SN>(val)) {
|
||||||
): State<Context, StateName> {
|
|
||||||
if (!isState<Context, StateName>(val)) {
|
|
||||||
throw new TypeError("an instance of State class is expected");
|
throw new TypeError("an instance of State class is expected");
|
||||||
}
|
}
|
||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isState<Context, StateName extends string>(
|
function isState<Ctx, SN extends string>(val: unknown): val is State<Ctx, SN> {
|
||||||
val: unknown,
|
|
||||||
): val is State<Context, StateName> {
|
|
||||||
return val instanceof State;
|
return val instanceof State;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasTransition<Context, StateName extends string>(
|
function hasTransition<Ctx, SN extends string>(
|
||||||
transitions: StateTransitions<Context, StateName>,
|
transitions: StateTransitions<Ctx, SN>,
|
||||||
from: State<Context, StateName>,
|
from: State<Ctx, SN>,
|
||||||
to: State<Context, StateName>,
|
to: State<Ctx, SN>,
|
||||||
) {
|
) {
|
||||||
return transitions.get(from)?.has(to) || false;
|
return transitions.get(from)?.has(to) || false;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue