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,9 +2,9 @@ import { describe, expect, it } from 'vitest';
import { CollapseNodesCommand } from '../../src/core/commands/graphCommands';
import { RemovePortCommand, SetPortWriteCommand } from '../../src/core/commands/portCommands';
import { AddVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
import { type GraphNode, GraphInterpreter, inlineValue, resolveDef } from '../../src/core/graph';
import { type GraphNode, GraphInterpreter, inlineValue, NO_LOOKUP, resolveDef } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
import { graphLookup } from '../../src/core/model/graphLookup';
import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers';
function setup(variables = ['count', 'copy']) {
@@ -19,12 +19,12 @@ function run(
variables: Record<string, number> = { count: 5, copy: 0 },
) {
const ctx = fakeContext({ variables: { ...variables } });
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
return ctx.variables;
}
const ports = (behavior: BehaviorAsset, node: GraphNode, side: 'inputs' | 'outputs') =>
resolveDef(node, behavior.graph, () => null)![side].map((p) => p.key);
resolveDef(node, behavior.graph, NO_LOOKUP)![side].map((p) => p.key);
const edgeCount = (behavior: BehaviorAsset) => behavior.graph.edges.length;
@@ -126,7 +126,7 @@ describe('the Variables node', () => {
const start = edit.addNode('OnStart');
const node = edit.variables(['count', 'label', 'on', 'items'], { write: true, params: { label: 'hi' } });
edit.connect(start, 'exec', node, 'exec');
const carried = resolveDef(node, behavior.graph, () => null)!
const carried = resolveDef(node, behavior.graph, NO_LOOKUP)!
.inputs.filter((p) => p.type !== 'exec')
.map((p) => [p.key, inlineValue(node, p)]);
expect(Object.fromEntries(carried)).toEqual({ count: 0, label: 'hi', on: false, items: [] });
@@ -237,7 +237,7 @@ describe('switching Set on a Variables node', () => {
describe('Variables node titles', () => {
const titleOf = (behavior: BehaviorAsset, node: GraphNode) =>
resolveDef(node, behavior.graph, () => null)!.label;
resolveDef(node, behavior.graph, NO_LOOKUP)!.label;
it('follow a renamed variable once collapsed', () => {
const { fixture, behavior, edit } = setup();