151 lines
7.0 KiB
TypeScript
151 lines
7.0 KiB
TypeScript
import { textOf } from '../model/Properties';
|
||
import { flatDef } from '../properties/PropertySchema';
|
||
import { edgesInto, type Graph, type GraphEdge, type GraphNode, type PortType } from './Graph';
|
||
import type { MachineState, MachineTransition, StateMachine } from './lookup';
|
||
import type { NodeDef, PortDef, PortSource } from './NodeRegistry';
|
||
|
||
export const STATE = 'State';
|
||
export const STATE_MACHINE = 'StateMachine';
|
||
export const STATE_TRANSITION = 'StateTransition';
|
||
export const STATE_NAME_PARAM = 'name';
|
||
/** The input naming the machine on `State branch` and `Set state`. */
|
||
export const MACHINE_PORT = 'machine';
|
||
/** The setting naming the transition on `State transition`. */
|
||
export const TRANSITION_PARAM = 'transition';
|
||
/** The two ports a transition hangs on. A port is the only anchor an edge has – without one it
|
||
* cannot be drawn, selected or undone – so a state carries these, and the editor never draws
|
||
* them: a transition joins whole states, not sockets. */
|
||
export const STATE_IN = 'in';
|
||
export const STATE_OUT = 'out';
|
||
|
||
/**
|
||
* One state of a state machine: a node whose name is its title. Its graph is the machine asset's,
|
||
* never a behavior's – the palette leaves the `state` category out.
|
||
*/
|
||
export const State: NodeDef = {
|
||
type: STATE,
|
||
label: 'State',
|
||
category: 'state',
|
||
description: 'One state of a state machine.',
|
||
inputs: [{ key: STATE_IN, label: '', type: 'exec' }],
|
||
outputs: [{ key: STATE_OUT, label: '', type: 'exec' }],
|
||
params: [flatDef(STATE_NAME_PARAM, 'text', { label: 'Name', value: 'State' })],
|
||
transitionEdges: true,
|
||
derivePorts: ({ node }) => ({ label: stateName(node) }),
|
||
};
|
||
|
||
export function isStateNode(node: GraphNode): boolean {
|
||
return node.type === STATE;
|
||
}
|
||
|
||
export function stateName(node: GraphNode): string {
|
||
return textOf(node.params[STATE_NAME_PARAM] ?? '') || State.label;
|
||
}
|
||
|
||
/** The states of a machine's graph in node order – what its ports and the runtime name. */
|
||
export function statesOf(graph: Graph): MachineState[] {
|
||
return graph.nodes.filter(isStateNode).map((node) => ({ id: node.id, name: stateName(node) }));
|
||
}
|
||
|
||
/**
|
||
* The state a machine starts in, as far as its graph agrees: the marked id when a state of that id
|
||
* is still there, else none. A start pointing at a deleted state is not an error to repair – it
|
||
* reads as "no start", which is exactly what the machine had before anyone marked one.
|
||
*/
|
||
export function startStateOf(graph: Graph, start: string | undefined): string {
|
||
return start && graph.nodes.some((n) => n.id === start && isStateNode(n)) ? start : '';
|
||
}
|
||
|
||
/** The transitions of a machine's graph: every edge between two states, in edge order. */
|
||
export function transitionsOf(graph: Graph): MachineTransition[] {
|
||
const states = new Set(graph.nodes.filter(isStateNode).map((n) => n.id));
|
||
return graph.edges
|
||
.filter((e) => states.has(e.from.node) && states.has(e.to.node))
|
||
.map((e) => ({ from: e.from.node, to: e.to.node, name: e.label ?? '' }));
|
||
}
|
||
|
||
/**
|
||
* Where `State transition` moves from `current`: along the transition of that name leaving it. The
|
||
* name is matched against the current state alone, so one node asking for "alarm" moves every state
|
||
* that has an "alarm". Nothing happens when the state has no such transition, or two of the name –
|
||
* with nothing drawn to pick between them, choosing either would be a guess the graph never made.
|
||
*/
|
||
export function transitionTo(machine: StateMachine, current: string, name: string): string {
|
||
const out = machine.transitions.filter((t) => t.from === current && t.name === name);
|
||
const targets = [...new Set(out.map((t) => t.to))];
|
||
return targets.length === 1 ? targets[0]! : '';
|
||
}
|
||
|
||
/**
|
||
* The state a text names: its id where one matches, else the state of that name. A name two states
|
||
* share answers nothing – the same rule `transitionTo` follows, and for the same reason: with
|
||
* nothing in the graph to pick between them, either choice would be a guess. `''` for no match,
|
||
* which is what a node reports as "not found" rather than moving the machine somewhere arbitrary.
|
||
*/
|
||
export function stateByName(machine: StateMachine | null, text: string): string {
|
||
if (!machine || !text) return '';
|
||
if (machine.states.some((s) => s.id === text)) return text;
|
||
const named = machine.states.filter((s) => s.name === text);
|
||
return named.length === 1 ? named[0]!.id : '';
|
||
}
|
||
|
||
/** The names a machine's transitions carry, deduped and in edge order – what a node may ask for. */
|
||
export function transitionNames(machine: StateMachine | null): string[] {
|
||
return [...new Set((machine?.transitions ?? []).map((t) => t.name).filter((name) => name))];
|
||
}
|
||
|
||
/** What a state placed from the palette is called: "State 3" for the third. */
|
||
export function nextStateName(graph: Graph): string {
|
||
return `${State.label} ${statesOf(graph).length + 1}`;
|
||
}
|
||
|
||
/** Transition edges joining the same two states in the same direction as `edge`, itself left out. */
|
||
export function parallelTransitions(graph: Graph, edge: GraphEdge): GraphEdge[] {
|
||
return graph.edges.filter(
|
||
(e) => e.id !== edge.id && e.from.node === edge.from.node && e.to.node === edge.to.node,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* What a further transition between the same two states is called: "Transition 3" for the third,
|
||
* counting up past any name already leaving that state – two of one name out of a state would
|
||
* both be dead, and two unnamed ones between the same pair could not be told apart.
|
||
*/
|
||
export function nextTransitionName(graph: Graph, edge: GraphEdge): string {
|
||
const taken = new Set(graph.edges.filter((e) => e.from.node === edge.from.node).map((e) => e.label));
|
||
let n = parallelTransitions(graph, edge).length + 1;
|
||
while (taken.has(`Transition ${n}`)) n++;
|
||
return `Transition ${n}`;
|
||
}
|
||
|
||
/** The `machine` input; unconnected, it shows the machine picker. */
|
||
export const machinePort = (): PortDef => ({
|
||
key: MACHINE_PORT,
|
||
label: 'machine',
|
||
type: 'string',
|
||
field: flatDef(MACHINE_PORT, 'statemachine', { label: 'Machine' }),
|
||
});
|
||
|
||
/**
|
||
* The machine asset a node's `machine` input names while the graph is drawn: the inline pick, or the
|
||
* `State machine` literal an edge brings in. A port is derived from the graph, not from a running
|
||
* value, so a variable or a list item feeding the input yields nothing.
|
||
*/
|
||
export function machineIdOf({ node, graph }: PortSource): string {
|
||
const edge = edgesInto(graph, node.id, MACHINE_PORT)[0];
|
||
const source = edge && graph.nodes.find((n) => n.id === edge.from.node);
|
||
if (!edge) return textOf(node.params[MACHINE_PORT] ?? '');
|
||
return source?.type === STATE_MACHINE ? textOf(source.params.value ?? '') : '';
|
||
}
|
||
|
||
/** What that machine is, looked up – null where the input names none, or none that exists. */
|
||
export function machineOf(source: PortSource): StateMachine | null {
|
||
const id = machineIdOf(source);
|
||
return id ? source.lookup.machine(id) : null;
|
||
}
|
||
|
||
/** One port per state, keyed by the state's id – a rename keeps every edge. */
|
||
export function statePorts(machine: StateMachine | null, type: PortType = 'exec'): PortDef[] {
|
||
return (machine?.states ?? []).map((state) => ({ key: state.id, label: state.name, type }));
|
||
}
|