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

+6 -6
View File
@@ -2,7 +2,7 @@ import { textOf } from '../model/Properties';
import { isTaggedKind } from '../properties/kinds';
import { flatDef, isValidFieldName } from '../properties/PropertySchema';
import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph';
import type { BehaviorLookup } from './lookup';
import type { GraphLookup } from './lookup';
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
export const BEHAVIOR_START = 'BehaviorStart';
@@ -92,14 +92,14 @@ export function declaredPorts(graph: Graph | undefined, type: string): PortDef[]
* The node definition as it applies to *this* node: identical to the registered one, unless the
* type declares ports per node (`declaresPorts`) or computes them (`derivePorts`).
*/
export function resolveDef(node: GraphNode, graph: Graph, lookup: BehaviorLookup): NodeDef | undefined {
export function resolveDef(node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef | undefined {
const def = nodeDef(node.type);
if (!def || (!def.declaresPorts && !def.derivePorts)) return def;
const own = (side: 'inputs' | 'outputs') =>
declaredOn(def, node.ports ?? [], side).map((p) =>
def.declaredPort ? def.declaredPort(p, node) : portDefOf(p),
);
const grown = def.derivePorts?.({ node, graph, behaviors: lookup }) ?? {};
const grown = def.derivePorts?.({ node, graph, lookup }) ?? {};
const side = (key: 'inputs' | 'outputs') => execFirst([...def[key], ...(grown[key] ?? []), ...own(key)]);
return { ...def, label: grown.label ?? def.label, inputs: side('inputs'), outputs: side('outputs') };
}
@@ -113,12 +113,12 @@ function execFirst(ports: PortDef[]): PortDef[] {
* Why this call cannot do anything, or null if it can. A call is the one node that depends on a
* *different* graph, so it is the one node that can look perfectly healthy and still be dead.
*/
export function callProblem(node: GraphNode, lookup: BehaviorLookup, ownerId = ''): string | null {
export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): string | null {
if (node.type !== RUN_BEHAVIOR) return null;
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
if (!id) return 'No behavior picked';
if (id === ownerId) return 'A behavior cannot run itself';
const program = lookup(id);
const program = lookup.behavior(id);
if (!program) return 'That behavior is gone';
if (!terminalNode(program.graph, BEHAVIOR_START)) return 'No start node – does nothing';
return null;
@@ -128,7 +128,7 @@ export function callProblem(node: GraphNode, lookup: BehaviorLookup, ownerId = '
* Everything that changes how a node looks, as one string. Views and the inspector rebuild when it
* changes – a port list that is derived rather than fixed has no other way of being noticed.
*/
export function portSignature(node: GraphNode, graph: Graph, lookup: BehaviorLookup, ownerId = ''): string {
export function portSignature(node: GraphNode, graph: Graph, lookup: GraphLookup, ownerId = ''): string {
const def = resolveDef(node, graph, lookup);
if (!def) return 'missing';
const list = (ports: PortDef[]) => ports.map((p) => `${p.key}:${p.type}`).join(',');