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:
1 parent
ce42d16657
commit
e7d82bd1a9
60 files changed
+1269
-213
No files matched your search
@@ -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 } },
|
||||
|
||||
Reference in new issue
Block a user