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>
292 lines
12 KiB
TypeScript
292 lines
12 KiB
TypeScript
/**
|
||
* 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');
|
||
});
|
||
});
|