Files
feuerwerk/tests/core/state-machine.test.ts
T
rootandClaude Fable 5.1 e7d82bd1a9 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>
2026-09-12 18:00:23 +02:00

292 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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');
});
});