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

+4 -4
View File
@@ -11,7 +11,7 @@ import {
inlineInputs,
resolveDef,
} from '../../src/core/graph';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
import { graphLookup } from '../../src/core/model/graphLookup';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
@@ -23,7 +23,7 @@ function sequenceFixture() {
}
const portsOf = (behavior: BehaviorAsset, node: GraphNode, fixture: ProjectFixture) =>
resolveDef(node, behavior.graph, behaviorLookup(fixture.project))!.outputs.map((p) => p.key);
resolveDef(node, behavior.graph, graphLookup(fixture.project))!.outputs.map((p) => p.key);
const edgeKeys = (behavior: BehaviorAsset) =>
behavior.graph.edges.map((e) => `${e.from.port}→${e.to.port}`).sort();
@@ -76,7 +76,7 @@ describe('a Sequence sized by what is plugged into it', () => {
edit.connect(node, port, edit.addNode('Log', { message: port.toUpperCase() }), 'exec');
const ctx = fakeContext();
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
expect(ctx.logs).toEqual(['A', 'B']);
});
});
@@ -122,7 +122,7 @@ describe('a struct travelling through a nested behavior', () => {
// One edge into the call, where two numbers would need two.
expect(caller.graph.edges.filter((e) => e.to.node === call.id && e.to.port !== 'exec')).toHaveLength(1);
const ctx = fakeContext({ variables: { result: 0 } });
new GraphInterpreter(caller.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
new GraphInterpreter(caller.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
expect(ctx.variables.result).toBe(42);
});
});
+11 -10
View File
@@ -19,7 +19,7 @@ import {
dropTargetPort,
findNode,
firstPortFor,
NO_BEHAVIORS,
NO_LOOKUP,
type PortType,
portsCompatible,
} from '../../src/core/graph';
@@ -72,7 +72,8 @@ describe('node registry', () => {
const keys = [...def.inputs, ...def.outputs].map((p) => p.key);
expect(new Set(def.inputs.map((p) => p.key)).size).toBe(def.inputs.length);
expect(new Set(def.outputs.map((p) => p.key)).size).toBe(def.outputs.length);
if (def.category === 'event') expect(def.inputs).toHaveLength(0);
// An event is seeded by the runtime, a state is a name in a machine: neither runs.
if (def.category === 'event' || def.category === 'state') expect(def.inputs).toHaveLength(0);
else expect(def.run).toBeTypeOf('function');
expect(keys.every((k) => k.length > 0)).toBe(true);
}
@@ -522,7 +523,7 @@ describe('dropping an edge on a node', () => {
it('picks the first compatible input', () => {
const { graph, number, clamp } = fixtureWithClamp();
const from = { nodeId: number.id, port: 'value', isOutput: true };
expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)).toEqual({
expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)).toEqual({
nodeId: clamp.id,
port: 'value',
isOutput: false,
@@ -534,7 +535,7 @@ describe('dropping an edge on a node', () => {
const other = edit.addNode('Number');
edit.connect(other, 'value', clamp, 'value');
const from = { nodeId: number.id, port: 'value', isOutput: true };
expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)?.port).toBe('min');
expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)?.port).toBe('min');
});
it('falls back to the last compatible input once every one is taken', () => {
@@ -542,7 +543,7 @@ describe('dropping an edge on a node', () => {
for (const port of ['value', 'min', 'max']) edit.connect(edit.addNode('Number'), 'value', clamp, port);
const from = { nodeId: number.id, port: 'value', isOutput: true };
// Not free, but the drop still connects – it replaces the edge, as dropping on the port would.
expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)?.port).toBe('max');
expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)?.port).toBe('max');
});
it('keeps offering an exec input, which is never exclusive', () => {
@@ -553,26 +554,26 @@ describe('dropping an edge on a node', () => {
const move = edit.addNode('SetPosition');
edit.connect(edit.addNode('OnKeyHeld'), 'exec', move, 'exec');
const from = { nodeId: held.id, port: 'exec', isOutput: true };
expect(dropTargetPort(behavior.graph, from, move.id, NO_BEHAVIORS)?.port).toBe('exec');
expect(dropTargetPort(behavior.graph, from, move.id, NO_LOOKUP)?.port).toBe('exec');
});
it('refuses a node that offers nothing compatible, and the node itself', () => {
const { graph, edit, number, clamp } = fixtureWithClamp();
const boolean = edit.addNode('Boolean');
const from = { nodeId: number.id, port: 'value', isOutput: true };
expect(dropTargetPort(graph, from, boolean.id, NO_BEHAVIORS)).toBeNull();
expect(dropTargetPort(graph, from, number.id, NO_BEHAVIORS)).toBeNull();
expect(dropTargetPort(graph, from, boolean.id, NO_LOOKUP)).toBeNull();
expect(dropTargetPort(graph, from, number.id, NO_LOOKUP)).toBeNull();
// A pair that is already wired is not offered again either.
edit.connect(number, 'value', clamp, 'value');
edit.connect(number, 'value', clamp, 'min');
edit.connect(number, 'value', clamp, 'max');
expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)).toBeNull();
expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)).toBeNull();
});
it('resolves a drag started at an input to an output', () => {
const { graph, number, clamp } = fixtureWithClamp();
const from = { nodeId: clamp.id, port: 'value', isOutput: false };
expect(dropTargetPort(graph, from, number.id, NO_BEHAVIORS)).toEqual({
expect(dropTargetPort(graph, from, number.id, NO_LOOKUP)).toEqual({
nodeId: number.id,
port: 'value',
isOutput: true,
+1
View File
@@ -124,6 +124,7 @@ describe('the literal nodes', () => {
['Boolean', 'boolean'],
['String', 'text'],
['Texture', 'texture'],
['StateMachine', 'statemachine'],
['List', 'list'],
] as const) {
const def = BUILT_IN_NODES.find((d) => d.type === type)!;
+3 -3
View File
@@ -11,7 +11,7 @@ import {
type TraceListener,
type Value,
} from '../../src/core/graph';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
import { graphLookup } from '../../src/core/model/graphLookup';
import { BehaviorSystem } from '../../src/runtime/systems/BehaviorSystem';
import { Input } from '../../src/runtime/systems/Input';
import { World } from '../../src/runtime/World';
@@ -44,7 +44,7 @@ describe('live values', () => {
[add, 'result', count, 'count'],
]);
const { seen, listener } = recorder();
const interpreter = new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project));
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project));
interpreter.trace = listener;
const ctx = fakeContext({ variables: { count: 5, other: 9 } });
interpreter.fire('OnStart', ctx);
@@ -75,7 +75,7 @@ describe('live values', () => {
const call = outer.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, in1: 21 });
outer.connect(onStart, 'exec', call, 'exec');
const { seen, listener } = recorder();
const interpreter = new GraphInterpreter(caller.graph, behaviorLookup(fixture.project));
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
interpreter.trace = listener;
interpreter.fire('OnStart', fakeContext());
+8 -8
View File
@@ -15,7 +15,7 @@ import {
terminalNode,
} 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 { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
@@ -55,7 +55,7 @@ function callerOf(fixture: ProjectFixture, callee: BehaviorAsset, input: number)
}
function runOnStart(fixture: ProjectFixture, asset: BehaviorAsset, variables: Record<string, number> = {}) {
const interpreter = new GraphInterpreter(asset.graph, behaviorLookup(fixture.project));
const interpreter = new GraphInterpreter(asset.graph, graphLookup(fixture.project));
const ctx = fakeContext({ variables });
interpreter.fire('OnStart', ctx);
return { ctx, interpreter };
@@ -73,7 +73,7 @@ describe('a behavior used as a node', () => {
const callee = doubler(fixture);
const caller = callerOf(fixture, callee, 1);
const call = caller.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!;
const def = resolveDef(call, caller.graph, behaviorLookup(fixture.project))!;
const def = resolveDef(call, caller.graph, graphLookup(fixture.project))!;
expect(def.inputs.map((p) => p.key)).toEqual(['exec', 'in1']);
expect(def.outputs.map((p) => p.key)).toEqual(['exec', 'out1']);
// Named after the behavior it runs: a graph of calls is unreadable as a row of "Run behavior".
@@ -185,7 +185,7 @@ describe('a behavior used as a node', () => {
describe('a call that cannot do anything says so', () => {
it('names each of the four silent cases, and stays quiet about a healthy call', () => {
const fixture = makeProject();
const lookup = () => behaviorLookup(fixture.project);
const lookup = () => graphLookup(fixture.project);
const callee = doubler(fixture);
const caller = makeBehavior(fixture, 'Caller');
const edit = graphEditor(fixture, caller);
@@ -214,11 +214,11 @@ describe('a call that cannot do anything says so', () => {
const start = graphEditor(fixture, callee).addNode(BEHAVIOR_START);
const caller = makeBehavior(fixture, 'Caller');
const call = graphEditor(fixture, caller).addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
const signature = () => portSignature(call, caller.graph, behaviorLookup(fixture.project), caller.id);
const signature = () => portSignature(call, caller.graph, graphLookup(fixture.project), caller.id);
const healthy = signature();
fixture.stack.execute(new RemoveNodeCommand(callee.id, start.id));
expect(resolveDef(call, caller.graph, behaviorLookup(fixture.project))!.label).toBe('Bare');
expect(resolveDef(call, caller.graph, graphLookup(fixture.project))!.label).toBe('Bare');
expect(signature()).not.toBe(healthy);
});
@@ -239,7 +239,7 @@ describe('a call that cannot do anything says so', () => {
]);
// A missing end node is not a defect, so nothing is reported.
expect(callProblem(call, behaviorLookup(fixture.project), caller.id)).toBe(null);
expect(callProblem(call, graphLookup(fixture.project), caller.id)).toBe(null);
// "Done" is the nested flow running out: the sub graph is finished before the caller goes on.
expect(runOnStart(fixture, caller).ctx.logs).toEqual(['sub', 'after']);
});
@@ -293,7 +293,7 @@ describe('declared ports', () => {
const reloaded = loaded.assets.find((a) => a.id === callee.id) as BehaviorAsset;
expect(terminalNode(reloaded.graph, BEHAVIOR_START)?.ports).toEqual([{ key: 'in1', kind: 'number' }]);
const caller = loaded.assets.find((a) => a.name === 'Caller') as BehaviorAsset;
const interpreter = new GraphInterpreter(caller.graph, behaviorLookup(loaded));
const interpreter = new GraphInterpreter(caller.graph, graphLookup(loaded));
const ctx = fakeContext({ variables: { result: 0 } });
interpreter.fire('OnStart', ctx);
expect(ctx.variables.result).toBe(12);
+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 } },
+7 -7
View File
@@ -11,14 +11,14 @@ import {
type DeclaredPort,
GraphInterpreter,
inlineInputDef,
NO_BEHAVIORS,
NO_LOOKUP,
OBJECT_BEHAVIOR_PARAM,
OBJECT_TYPE_PARAM,
propertyPort,
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 type { PropertyBag } from '../../src/core/model/Properties';
import { fakeContext, fakeEntity, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
@@ -46,7 +46,7 @@ function onSelf(ports: DeclaredPort[], params: PropertyBag = SPRITE) {
function run(behavior: BehaviorAsset, fixture: ReturnType<typeof makeProject>, props: PropertyBag) {
const self = { ...fakeEntity('self'), props: { ...props } };
const ctx = fakeContext({ self, variables: { seen: 0 } });
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
return { props: self.props, seen: ctx.variables.seen };
}
@@ -80,7 +80,7 @@ describe('a node that reads and writes properties of an object', () => {
[add, 'result', coins, 'coins'],
]);
const self_ = { ...fakeEntity('self'), props: { coins: 5 } };
const interpreter = new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project));
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project));
for (let i = 0; i < 2; i++) interpreter.fire('OnStart', fakeContext({ self: self_ }));
expect(self_.props.coins).toBe(7);
});
@@ -108,7 +108,7 @@ describe('a node that reads and writes properties of an object', () => {
it('has flow pins only while a property is set, and they sit above the object input', () => {
const { behavior, props } = onSelf([set('width'), { key: 'height', kind: 'number' }]);
const keys = () => {
const def = resolveDef(props, behavior.graph, NO_BEHAVIORS)!;
const def = resolveDef(props, behavior.graph, NO_LOOKUP)!;
return { inputs: def.inputs.map((p) => p.key), outputs: def.outputs.map((p) => p.key) };
};
expect(keys()).toEqual({ inputs: ['exec', 'object', 'width'], outputs: ['exec', 'width', 'height'] });
@@ -147,7 +147,7 @@ describe('what a port carries, by the type of the node', () => {
const edit = graphEditor(fixture, makeBehavior(fixture, 'B'));
const hidden = (params: PropertyBag) => {
const node = edit.properties([], params);
return resolveDef(node, { nodes: [node], edges: [] }, NO_BEHAVIORS)!.hiddenParams?.(node.params);
return resolveDef(node, { nodes: [node], edges: [] }, NO_LOOKUP)!.hiddenParams?.(node.params);
};
expect(hidden(SPRITE)).toEqual([OBJECT_BEHAVIOR_PARAM]);
expect(hidden({})).toEqual([OBJECT_BEHAVIOR_PARAM]);
@@ -166,7 +166,7 @@ describe('what a port carries, by the type of the node', () => {
edit.properties([width], SPRITE),
edit.properties([], SPRITE),
];
const titles = () => nodes.map((node) => resolveDef(node, behavior.graph, NO_BEHAVIORS)!.label);
const titles = () => nodes.map((node) => resolveDef(node, behavior.graph, NO_LOOKUP)!.label);
expect(new Set(titles())).toEqual(new Set(['Properties']));
fixture.stack.execute(
new CollapseNodesCommand(
+291
View File
@@ -0,0 +1,291 @@
/**
* A state machine is an asset whose graph holds one `State` node per state. A behavior reads it
* through `State branch` (one exec output per state) and moves it through `Set state` (one exec
* input per state); the current state lives with the behavior instance, like its variables.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand, ConnectCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
import {
BEHAVIOR_PARAM,
BEHAVIOR_START,
GraphInterpreter,
MACHINE_PORT,
nextStateName,
resolveDef,
STATE,
STATE_NAME_PARAM,
statesOf,
} from '../../src/core/graph';
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
import type { StateMachineAsset } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import { graphLookup } from '../../src/core/model/graphLookup';
import { findAsset, findAssetOf } from '../../src/core/model/Project';
import { deserializeProject } from '../../src/core/store/serialize';
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
import {
fakeContext,
graphEditor,
makeBehavior,
makeProject,
makeSpriteScene,
place,
type ProjectFixture,
roundTrip,
startWorld,
} from '../helpers';
/** A machine "Doors" with the states Open and Closed, added the way the tab does it. */
function makeMachine(fixture: ProjectFixture, names = ['Open', 'Closed']) {
const create = new CreateAssetCommand('statemachine', 'Doors');
fixture.stack.execute(create);
const ids = names.map((name) => {
const add = new AddNodeCommand(create.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: name });
fixture.stack.execute(add);
return add.node.id;
});
return {
id: create.asset.id,
asset: () => findAssetOf(fixture.project, create.asset.id, 'statemachine')!,
ids,
};
}
describe('a state machine asset', () => {
it('is created empty, is not placeable, and takes states as nodes', () => {
const f = makeProject();
const machine = makeMachine(f);
expect(isPlaceableType('statemachine')).toBe(false);
expect(statesOf(machine.asset().graph)).toEqual([
{ id: machine.ids[0], name: 'Open' },
{ id: machine.ids[1], name: 'Closed' },
]);
f.stack.undo();
expect(statesOf(machine.asset().graph).map((s) => s.name)).toEqual(['Open']);
});
it('names a state by its setting, and falls back to "State" for an empty name', () => {
const f = makeProject();
const machine = makeMachine(f, ['Open']);
f.stack.execute(new SetNodeParamCommand(machine.id, machine.ids[0]!, STATE_NAME_PARAM, ''));
expect(statesOf(machine.asset().graph)[0]?.name).toBe('State');
const node = machine.asset().graph.nodes[0]!;
expect(resolveDef(node, machine.asset().graph, graphLookup(f.project))?.label).toBe('State');
});
it('survives a save and load, keeping only its states', () => {
const f = makeProject();
const machine = makeMachine(f);
const loaded = findAssetOf(roundTrip(f.project), machine.id, 'statemachine');
expect(loaded && statesOf(loaded.graph)).toEqual(statesOf(machine.asset().graph));
// A node of any other type has no business in a machine and is dropped on load.
const smuggled = deserializeProject({
id: 'p',
assets: [
{
id: 'm',
type: 'statemachine',
name: 'M',
graph: {
nodes: [
{ id: 's', type: STATE, x: 0, y: 0, params: { name: 'Idle' } },
{ id: 'n', type: 'Number', x: 0, y: 0, params: { value: 1 } },
],
edges: [],
},
},
],
});
const asset = findAsset(smuggled, 'm') as StateMachineAsset;
expect(asset.graph.nodes.map((n) => n.type)).toEqual([STATE]);
});
it('keeps its State node out of every behavior palette: it is registered, not built in', () => {
expect(BUILT_IN_NODES.some((d) => d.type === STATE)).toBe(false);
expect(BUILT_IN_NODES.some((d) => d.category === 'state')).toBe(false);
});
it('offers one palette entry, a State named by its number', () => {
const f = makeProject();
const machine = makeMachine(f);
const entries = paletteEntries(f.project, machine.asset());
expect(entries.map((e) => e.def.type)).toEqual([STATE]);
expect(entries[0]?.params).toEqual({ [STATE_NAME_PARAM]: 'State 3' });
expect(nextStateName(machine.asset().graph)).toBe('State 3');
f.stack.undo();
expect(nextStateName(machine.asset().graph)).toBe('State 2');
});
it('is a kind of variable holding the asset id', () => {
const f = makeProject();
const machine = makeMachine(f);
const behavior = makeBehavior(f, 'B');
f.stack.execute(new AddVariableCommand(behavior.id, 'doors', 'statemachine'));
findAsset(f.project, behavior.id)!.props.doors = machine.id;
const back = findAssetOf(roundTrip(f.project), behavior.id, 'behavior');
expect(back?.variables.map((v) => `${v.key}:${v.kind}`)).toEqual(['doors:statemachine']);
expect(back?.props.doors).toBe(machine.id);
});
});
describe('the state ports of State branch and Set state', () => {
const setup = () => {
const f = makeProject();
const machine = makeMachine(f);
const behavior = makeBehavior(f, 'B');
const edit = graphEditor(f, behavior);
const ports = (node: { id: string }, side: 'inputs' | 'outputs') =>
resolveDef(
behavior.graph.nodes.find((n) => n.id === node.id)!,
behavior.graph,
graphLookup(f.project),
)!
[side].filter((p) => p.type === 'exec')
.map((p) => `${p.key}:${p.label}`);
return { f, machine, behavior, edit, ports };
};
it('are one exec port per state, keyed by the state id and labelled by its name', () => {
const { machine, edit, ports } = setup();
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
const expected = [`${machine.ids[0]}:Open`, `${machine.ids[1]}:Closed`];
expect(ports(branch, 'outputs')).toEqual(expected);
expect(ports(set, 'inputs')).toEqual(expected);
});
it('follow a State machine literal wired into the machine input', () => {
const { machine, edit, ports } = setup();
const literal = edit.addNode('StateMachine', { value: machine.id });
const branch = edit.addNode('StateBranch');
expect(ports(branch, 'outputs')).toEqual([]);
edit.connect(literal, 'value', branch, MACHINE_PORT);
expect(ports(branch, 'outputs')).toEqual([`${machine.ids[0]}:Open`, `${machine.ids[1]}:Closed`]);
});
it('cannot follow a variable: a port is derived from the graph, not from a running value', () => {
const { f, machine, behavior, edit, ports } = setup();
f.stack.execute(new AddVariableCommand(behavior.id, 'doors', 'statemachine'));
findAsset(f.project, behavior.id)!.props.doors = machine.id;
const variables = edit.variables(['doors']);
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
edit.connect(variables, 'doors', branch, MACHINE_PORT);
expect(ports(branch, 'outputs')).toEqual([]);
// Only the State machine literal is followed – a text holding the very same id is not.
const text = edit.addNode('String', { value: machine.id });
edit.connect(text, 'value', branch, MACHINE_PORT);
expect(ports(branch, 'outputs')).toEqual([]);
});
it('follow a rename, and lose the edges of a state that is deleted', () => {
const { f, machine, behavior, edit, ports } = setup();
const start = edit.addNode('OnStart');
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
edit.connect(start, 'exec', set, machine.ids[0]!);
f.stack.execute(new SetNodeParamCommand(machine.id, machine.ids[0]!, STATE_NAME_PARAM, 'Ajar'));
expect(ports(set, 'inputs')[0]).toBe(`${machine.ids[0]}:Ajar`);
expect(behavior.graph.edges).toHaveLength(1);
// Picking another machine orphans the edge, as any param that decides the ports does.
f.stack.execute(new SetNodeParamCommand(behavior.id, set.id, MACHINE_PORT, ''));
expect(behavior.graph.edges).toHaveLength(0);
f.stack.undo();
expect(behavior.graph.edges).toHaveLength(1);
});
});
describe('a running state machine', () => {
/** OnStart sets Open; OnUpdate branches: Open logs "open" and sets Closed, Closed logs "closed". */
function build(f: ProjectFixture, machineId: string, [open, closed]: readonly string[]) {
const behavior = makeBehavior(f, 'Door');
const edit = graphEditor(f, behavior);
const start = edit.addNode('OnStart');
const setOpen = edit.addNode('SetState', { [MACHINE_PORT]: machineId });
const update = edit.addNode('OnUpdate');
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machineId });
const logOpen = edit.addNode('Log', { message: 'open' });
const setClosed = edit.addNode('SetState', { [MACHINE_PORT]: machineId });
const logClosed = edit.addNode('Log', { message: 'closed' });
edit.wire([
[start, 'exec', setOpen, open!],
[update, 'exec', branch, 'exec'],
[branch, open!, logOpen, 'exec'],
[logOpen, 'exec', setClosed, closed!],
[branch, closed!, logClosed, 'exec'],
]);
return behavior;
}
it('is in no state until Set state ran, then follows the state it was put in', () => {
const f = makeProject();
const machine = makeMachine(f);
const behavior = build(f, machine.id, machine.ids);
const ctx = fakeContext();
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project));
interpreter.fire('OnUpdate', ctx);
expect(ctx.logs).toEqual([]);
interpreter.fire('OnStart', ctx);
expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] });
interpreter.fire('OnUpdate', ctx);
interpreter.fire('OnUpdate', ctx);
expect(ctx.logs).toEqual(['open', 'closed']);
expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] });
});
it('keeps one state per behavior instance', () => {
const f = makeSpriteScene();
const machine = makeMachine(f);
const behavior = build(f, machine.id, machine.ids);
place(f, behavior.id, { x: 0, y: 0 });
place(f, behavior.id, { x: 10, y: 0 });
const started = startWorld(f, { behaviors: true });
started.frame();
// Shared state would let the second instance find the door the first one just closed.
expect(started.world.log).toEqual(['open', 'open']);
started.frame();
expect(started.world.log).toEqual(['open', 'open', 'closed', 'closed']);
});
it('starts a called behavior in no state, whatever the caller set', () => {
const f = makeProject();
const machine = makeMachine(f);
const inner = makeBehavior(f, 'Inner');
const innerEdit = graphEditor(f, inner);
const innerStart = innerEdit.addNode(BEHAVIOR_START);
const branch = innerEdit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
const log = innerEdit.addNode('Log', { message: 'inner saw open' });
innerEdit.wire([
[innerStart, 'exec', branch, 'exec'],
[branch, machine.ids[0]!, log, 'exec'],
]);
const outer = makeBehavior(f, 'Outer');
const edit = graphEditor(f, outer);
const start = edit.addNode('OnStart');
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
const call = edit.addNode('RunBehavior', { [BEHAVIOR_PARAM]: inner.id });
edit.wire([
[start, 'exec', set, machine.ids[0]!],
[set, 'exec', call, 'exec'],
]);
const ctx = fakeContext();
new GraphInterpreter(outer.graph, graphLookup(f.project)).fire('OnStart', ctx);
expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] });
expect(ctx.logs).toEqual([]);
});
it('refuses a connection to a state port the machine does not have', () => {
const f = makeProject();
const machine = makeMachine(f);
const behavior = makeBehavior(f, 'B');
const edit = graphEditor(f, behavior);
const start = edit.addNode('OnStart');
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
expect(() =>
f.stack.execute(
new ConnectCommand(behavior.id, { node: start.id, port: 'exec' }, { node: set.id, port: 'nope' }),
),
).toThrow('Port not found');
});
});
+2 -2
View File
@@ -8,7 +8,7 @@ import { AddPortCommand, AddPortsCommand } from '../../src/core/commands/portCom
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
import { declaredPortType, missingFieldPorts, resolveDef, structShapeOf } 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 type { StructFieldDef } from '../../src/core/properties/PropertySchema';
import { graphEditor, makeBehavior, makeProject } from '../helpers';
@@ -96,7 +96,7 @@ describe('seeding a node from that shape', () => {
const ports = missingFieldPorts(pack, structShapeOf(varOf(behavior, 'piece')) ?? []);
stack.execute(new AddPortsCommand(behavior.id, pack.id, ports));
const inputs = resolveDef(pack, behavior.graph, behaviorLookup(fixture.project))!.inputs;
const inputs = resolveDef(pack, behavior.graph, graphLookup(fixture.project))!.inputs;
expect(inputs.map((p) => `${p.key}:${p.type}`)).toEqual([
'shape:number',
'name:string',
+2 -1
View File
@@ -104,11 +104,12 @@ describe('the literal nodes', () => {
/** The palette and the kind picker say the same word: a text is "Text", nowhere "String". */
it('are labelled by the kind they hand out', () => {
const label = (type: string) => BUILT_IN_NODES.find((d) => d.type === type)?.label;
expect(['Number', 'Boolean', 'String', 'Texture', 'List'].map(label)).toEqual([
expect(['Number', 'Boolean', 'String', 'Texture', 'StateMachine', 'List'].map(label)).toEqual([
'Number',
'Boolean',
'Text',
'Texture',
'State machine',
'List',
]);
});
+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();
+5 -5
View File
@@ -11,9 +11,9 @@ import {
RemoveVariableCommand,
RenameVariableCommand,
} from '../../src/core/commands/variableCommands';
import { type GraphNode, GraphInterpreter, resolveDef, VARIABLES } from '../../src/core/graph';
import { type GraphNode, GraphInterpreter, NO_LOOKUP, resolveDef, VARIABLES } 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, roundTrip } from '../helpers';
/** `px` and `py` are set in one node and read back in another, their sum landing in `sum`. */
@@ -41,14 +41,14 @@ function run(
fixture: ReturnType<typeof makeProject>,
variables: Record<string, number> = { px: 0, py: 0, sum: 0 },
) {
const interpreter = new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project));
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project));
const ctx = fakeContext({ variables });
interpreter.fire('OnStart', ctx);
return ctx.variables;
}
const portKeys = (behavior: BehaviorAsset, node: GraphNode, side: 'inputs' | 'outputs') =>
resolveDef(node, behavior.graph, () => null)![side].map((p) => `${p.key}:${p.type}`);
resolveDef(node, behavior.graph, NO_LOOKUP)![side].map((p) => `${p.key}:${p.type}`);
describe('a node that reads or writes several variables at once', () => {
it('writes each variable it names and reads each one back', () => {
@@ -139,7 +139,7 @@ describe('a node that reads or writes several variables at once', () => {
]);
const ctx = fakeContext({ variables: { piece: { shape: 3, rot: 0 } } });
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
expect(ctx.variables.piece).toEqual({ shape: 3, rot: 2 });
});
});
+3 -1
View File
@@ -181,6 +181,7 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
self: fakeEntity('self'),
owner: fakeEntity('owner'),
variables: { speed: 100 },
states: {},
time: 0,
dt: 0.5,
logs,
@@ -213,9 +214,10 @@ export function runDef(
ctx: NodeContext,
inputs: Record<string, Value>,
params: PropertyBag = {},
entry = 'exec',
): RunResult {
const def = requireNodeDef(type);
return def.run!(ctx, inputs, params, def);
return def.run!(ctx, inputs, params, def, entry);
}
/** Runs one node definition in isolation and returns its data outputs. */