Files
feuerwerk/src/core/graph/stateMachine.ts
T

151 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }));
}