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

+59 -5
View File
@@ -8,7 +8,7 @@ import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
import {
createEmptyGraph,
type DeclaredPort,
NO_BEHAVIORS,
NO_LOOKUP,
type NodeContext,
resolveDef,
type RunResult,
@@ -29,6 +29,9 @@ interface Case {
outputs?: Record<string, Value>;
/** Exec outputs the node would have grown in a graph – only for types whose ports are derived. */
derivedOutputs?: string[];
/** Exec inputs grown the same way, and the one the pulse arrives through (`exec` unless said). */
derivedInputs?: string[];
entry?: string;
/** Expected exec continuation; `null` asserts the flow ends here. */
next?: string | string[] | null;
/** Expected loop request; `null` asserts the node does not loop. */
@@ -42,10 +45,14 @@ interface Case {
const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => {
const ctx = fakeContext(c.context);
const node = { id: 'n', type, x: 0, y: 0, params: c.params ?? {}, ports: c.ports };
const registered = c.ports ? resolveDef(node, createEmptyGraph(), NO_BEHAVIORS)! : requireNodeDef(type);
const grown = (c.derivedOutputs ?? []).map((key) => ({ key, label: key, type: 'exec' as const }));
const def = grown.length > 0 ? { ...registered, outputs: [...registered.outputs, ...grown] } : registered;
return { result: def.run!(ctx, c.inputs ?? {}, c.params ?? {}, def), ctx };
const registered = c.ports ? resolveDef(node, createEmptyGraph(), NO_LOOKUP)! : requireNodeDef(type);
const execPorts = (keys: string[] = []) => keys.map((key) => ({ key, label: key, type: 'exec' as const }));
const def = {
...registered,
inputs: [...execPorts(c.derivedInputs), ...registered.inputs],
outputs: [...registered.outputs, ...execPorts(c.derivedOutputs)],
};
return { result: def.run!(ctx, c.inputs ?? {}, c.params ?? {}, def, c.entry ?? 'exec'), ctx };
};
const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y });
@@ -88,6 +95,49 @@ const CASES: Record<string, Case[]> = {
next: 'exec',
},
],
StateBranch: [
{ name: 'in no state yet, the flow ends', inputs: { machine: 'm' }, derivedOutputs: ['s1'], next: null },
{
name: 'follows the output of the current state',
inputs: { machine: 'm' },
context: { states: { m: 's1' } },
derivedOutputs: ['s1', 's2'],
next: 's1',
},
{
name: 'a state that is not among its outputs (another machine, a deleted state) ends the flow',
inputs: { machine: 'm' },
context: { states: { m: 'gone' } },
derivedOutputs: ['s1'],
next: null,
},
],
SetState: [
{
name: 'puts the machine into the state whose input the flow arrived through',
inputs: { machine: 'm' },
derivedInputs: ['s1', 's2'],
entry: 's2',
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({ m: 's2' }),
},
{
name: 'arriving through no state input changes nothing',
inputs: { machine: 'm' },
derivedInputs: ['s1'],
entry: 'exec',
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({}),
},
{
name: 'without a machine there is nothing to set',
inputs: { machine: '' },
derivedInputs: ['s1'],
entry: 's1',
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({}),
},
],
Repeat: [
{
name: 'asks for as many body rounds as it was given, then continues at done',
@@ -227,6 +277,10 @@ const CASES: Record<string, Case[]> = {
{ name: 'outputs the id it was set to', params: { value: 'fr-0-1' }, outputs: { value: 'fr-0-1' } },
{ name: 'unset is no picture', outputs: { value: '' } },
],
StateMachine: [
{ name: 'outputs the id it was set to', params: { value: 'sm-1' }, outputs: { value: 'sm-1' } },
{ name: 'unset is no machine', outputs: { value: '' } },
],
Add: [
{ name: 'adds', inputs: { a: 2, b: 3 }, outputs: { result: 5 } },
{ name: 'numeric text is coerced', inputs: { a: '2', b: 3 }, outputs: { result: 5 } },