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

+17 -11
View File
@@ -11,8 +11,9 @@ import {
portSignature,
resolveDef,
} from '../../core/graph';
import { behaviorLookup } from '../../core/model/behaviorPrograms';
import { findAssetOf, type Project } from '../../core/model/Project';
import type { GraphAsset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup';
import { findGraphAsset, type Project } from '../../core/model/Project';
import { textOf } from '../../core/model/Properties';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Label } from '../../ui';
@@ -24,11 +25,11 @@ import { currentNode, nodeParamValue } from './valueSpecs';
/** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */
export function nodeSignature(project: Project, assetId: string, nodeId: string): string {
const asset = findAssetOf(project, assetId, 'behavior');
const asset = findGraphAsset(project, assetId);
if (!asset) return 'missing';
const node = asset.graph.nodes.find((n) => n.id === nodeId);
if (!node) return 'missing';
const lookup = behaviorLookup(project);
const lookup = graphLookup(project);
const def = resolveDef(node, asset.graph, lookup);
const connected = def
? inlineInputs(def)
@@ -38,23 +39,28 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
const declared = (node.ports ?? []).map((p) => `${p.key}:${p.kind}${p.write ? ':set' : ''}`).join(',');
// They decide which rows the inspector builds, and two behaviors may share a name in the title.
const target = `${textOf(node.params[OBJECT_TYPE_PARAM] ?? '')}:${textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '')}`;
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(',')}|${asset.variables
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(',')}|${variablesOf(
asset,
)
.map((v) => v.key)
.join(',')}|${target}`;
}
/** A state machine declares no variables; its one node type has none to name. */
const variablesOf = (asset: GraphAsset): PropertyDef[] => (asset.type === 'behavior' ? asset.variables : []);
/** Description, settings (params) and the inline values of unconnected data inputs. */
export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: string): void {
const asset = findAssetOf(host.state.project, assetId, 'behavior');
const asset = findGraphAsset(host.state.project, assetId);
if (!asset) return;
const node = asset.graph.nodes.find((n) => n.id === nodeId);
const def = node && resolveDef(node, asset.graph, behaviorLookup(host.state.project));
const def = node && resolveDef(node, asset.graph, graphLookup(host.state.project));
if (!node || !def) return;
addTitle(host, `${def.category.toUpperCase()} NODE`);
host.body.add(new Label(def.label, { style: 'bodyBold' }));
addNote(host, def.description);
const problem = callProblem(node, behaviorLookup(host.state.project), assetId);
const problem = callProblem(node, graphLookup(host.state.project), assetId);
if (problem) addNote(host, problem, 'warning');
const addSetting = (param: PropertyDef) => {
const row = createFieldRow(
@@ -67,9 +73,9 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
host.body.add(row.row);
};
const ownSettings = declaresPropertyPorts(node.type);
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, asset.variables);
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, variablesOf(asset));
else if (ownSettings) buildPropertyPortSection(host, assetId, node, addSetting);
else if (def.declaresPorts) buildPortSection(host, assetId, node, asset.variables);
else if (def.declaresPorts) buildPortSection(host, assetId, node, variablesOf(asset));
if (!ownSettings && def.params.length > 0) {
addTitle(host, 'SETTINGS');
@@ -94,7 +100,7 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
/** Pushes the node's current params into the rows. False when the node is gone (rebuild). */
export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId: string): boolean {
const found = currentNode(host.state.project, assetId, nodeId);
const def = found && resolveDef(found.node, found.graph, behaviorLookup(host.state.project));
const def = found && resolveDef(found.node, found.graph, graphLookup(host.state.project));
if (!found || !def) return false;
for (const [key, row] of host.fields) {
const [scope, name] = key.split(':') as ['param' | 'input', string];