State machines: asset, tab, State branch and Set state nodes (#51)

A state machine is an asset whose graph holds one `State` node per state
(no ports, name as title), edited in a new "State machine" tab that reuses
the graph canvas: right-click places "State n", snap toggle shared with the
Behavior tab via SnapToggle. `statemachine` is a value kind like texture
(port, literal node, variable, struct field, tagged list item).

Behaviors use it through `State branch` (one exec output per state) and
`Set state` (one exec input per state; the interpreter now hands `run` the
entry port). States live per behavior instance in NodeContext.states, with
no initial state. Ports derive from the inline picker or a wired
`State machine` literal; BehaviorLookup became GraphLookup with
behavior(id) and machine(id).

The Graph tab is labelled "Behavior" (key unchanged). Unit tests
(state-machine, kinds, catalog, ports), e2e step 26 and README added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-12 18:00:23 +02:00
1 parent ce42d16657
commit e7d82bd1a9
60 files changed
+1269 -213

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
import { textOf } from '../model/Properties';
import { flatDef } from '../properties/PropertySchema';
import { edgesInto, type Graph, type GraphNode } from './Graph';
import type { MachineState, StateMachine } from './lookup';
import type { NodeDef, PortDef, PortSource } from './NodeRegistry';
export const STATE = 'State';
export const STATE_MACHINE = 'StateMachine';
export const STATE_NAME_PARAM = 'name';
/** The input naming the machine on `State branch` and `Set state`. */
export const MACHINE_PORT = 'machine';
/**
* One state of a state machine: a node with no ports 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: [],
outputs: [],
params: [flatDef(STATE_NAME_PARAM, 'text', { label: 'Name', value: 'State' })],
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) }));
}
/** 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}`;
}
/** 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 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 no state ports.
*/
export function machineOf({ node, graph, lookup }: PortSource): StateMachine | null {
const edge = edgesInto(graph, node.id, MACHINE_PORT)[0];
const source = edge && graph.nodes.find((n) => n.id === edge.from.node);
const id = edge
? source?.type === STATE_MACHINE
? textOf(source.params.value ?? '')
: ''
: textOf(node.params[MACHINE_PORT] ?? '');
return id ? lookup.machine(id) : null;
}
/** One exec port per state, keyed by the state's id – a rename keeps every edge. */
export function statePorts(machine: StateMachine | null): PortDef[] {
return (machine?.states ?? []).map((state) => ({ key: state.id, label: state.name, type: 'exec' }));
}