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>
146 lines
7.1 KiB
TypeScript
146 lines
7.1 KiB
TypeScript
/**
|
||
* The Variables node declares variables of the behavior around it as ports, rather than names of
|
||
* its own. What that costs is the wiring – a port has to follow the variable it names when that is
|
||
* renamed or swapped, and take its edges along, on the input side as well as on the output side.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { AddPortCommand, RepointPortCommand } from '../../src/core/commands/portCommands';
|
||
import {
|
||
AddStructFieldCommand,
|
||
AddVariableCommand,
|
||
RemoveVariableCommand,
|
||
RenameVariableCommand,
|
||
} from '../../src/core/commands/variableCommands';
|
||
import { type GraphNode, GraphInterpreter, NO_LOOKUP, resolveDef, VARIABLES } from '../../src/core/graph';
|
||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||
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`. */
|
||
function bothWays() {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture, 'B', ['px', 'py', 'sum']);
|
||
const edit = graphEditor(fixture, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.variables(['px', 'py'], { write: true, params: { px: 7, py: 9 } });
|
||
const get = edit.variables(['px', 'py']);
|
||
const add = edit.addNode('Add');
|
||
const store = edit.variables(['sum'], { write: true });
|
||
edit.wire([
|
||
[start, 'exec', set, 'exec'],
|
||
[set, 'exec', store, 'exec'],
|
||
[get, 'px', add, 'a'],
|
||
[get, 'py', add, 'b'],
|
||
[add, 'result', store, 'sum'],
|
||
]);
|
||
return { fixture, behavior, set, get, store, edit };
|
||
}
|
||
|
||
function run(
|
||
behavior: BehaviorAsset,
|
||
fixture: ReturnType<typeof makeProject>,
|
||
variables: Record<string, number> = { px: 0, py: 0, sum: 0 },
|
||
) {
|
||
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, 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', () => {
|
||
const { fixture, behavior } = bothWays();
|
||
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
|
||
});
|
||
|
||
it('takes the port type from the variable, so a number output stays a number', () => {
|
||
const { fixture, behavior, get, set } = bothWays();
|
||
fixture.stack.execute(new AddVariableCommand(behavior.id, 'alive', 'boolean'));
|
||
fixture.stack.execute(new AddPortCommand(behavior.id, get.id, { key: 'alive', kind: 'boolean' }));
|
||
expect(portKeys(behavior, get, 'outputs')).toEqual(['px:number', 'py:number', 'alive:boolean']);
|
||
expect(portKeys(behavior, set, 'inputs')).toEqual(['exec:exec', 'px:number', 'py:number']);
|
||
});
|
||
|
||
it('follows a renamed variable, edges and typed-in value on both sides', () => {
|
||
const { fixture, behavior, get, set, store } = bothWays();
|
||
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'px', 'column'));
|
||
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'sum', 'total'));
|
||
expect((get.ports ?? []).map((p) => p.key)).toEqual(['column', 'py']);
|
||
expect(set.params).toEqual({ column: 7, py: 9 });
|
||
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'column')).toBe(true);
|
||
expect(behavior.graph.edges.some((e) => e.to.node === store.id && e.to.port === 'total')).toBe(true);
|
||
expect(run(behavior, fixture, { column: 0, py: 0, total: 0 })).toEqual({ column: 7, py: 9, total: 16 });
|
||
|
||
fixture.stack.undo();
|
||
fixture.stack.undo();
|
||
expect((get.ports ?? []).map((p) => p.key)).toEqual(['px', 'py']);
|
||
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
|
||
});
|
||
|
||
it('keeps the edge when a port is pointed at another variable', () => {
|
||
const { fixture, behavior, get } = bothWays();
|
||
fixture.stack.execute(new AddVariableCommand(behavior.id, 'alive', 'boolean'));
|
||
fixture.stack.execute(
|
||
new RepointPortCommand(behavior.id, get.id, 'px', { key: 'alive', kind: 'boolean' }),
|
||
);
|
||
expect(portKeys(behavior, get, 'outputs')).toEqual(['alive:boolean', 'py:number']);
|
||
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'alive')).toBe(true);
|
||
|
||
fixture.stack.undo();
|
||
expect(portKeys(behavior, get, 'outputs')).toEqual(['px:number', 'py:number']);
|
||
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'px')).toBe(true);
|
||
});
|
||
|
||
it('keeps a port whose variable was removed, so undoing the removal restores the graph', () => {
|
||
const { fixture, behavior, get } = bothWays();
|
||
fixture.stack.execute(new RemoveVariableCommand(behavior.id, 'px'));
|
||
// Left standing rather than dropped: taking the port would have to take its edge too, and
|
||
// undo could not put either back. The inspector marks it as naming no variable.
|
||
expect((get.ports ?? []).map((p) => p.key)).toEqual(['px', 'py']);
|
||
|
||
fixture.stack.undo();
|
||
expect(behavior.variables.map((v) => v.key)).toEqual(['px', 'py', 'sum']);
|
||
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
|
||
});
|
||
|
||
it('survives a save and load, which is what makes the pick more than a session', () => {
|
||
const { fixture, behavior } = bothWays();
|
||
const loaded = roundTrip(fixture.project);
|
||
const asset = loaded.assets.find((a) => a.id === behavior.id) as BehaviorAsset;
|
||
const set = asset.graph.nodes.find((n) => n.type === VARIABLES && n.params.px === 7)!;
|
||
expect(set.ports).toEqual([
|
||
{ key: 'px', kind: 'number', write: true },
|
||
{ key: 'py', kind: 'number', write: true },
|
||
]);
|
||
expect(run(asset, { project: loaded, stack: fixture.stack })).toEqual({ px: 7, py: 9, sum: 16 });
|
||
});
|
||
|
||
/**
|
||
* The bundle a struct port is handed is merged in, so a `Pack` of one field leaves the rest
|
||
* standing. Tetris leans on this: each strand of `Next move` packs only the field it changes.
|
||
*/
|
||
it('merges a partial bundle into a struct variable instead of replacing it', () => {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture, 'B');
|
||
fixture.stack.execute(new AddVariableCommand(behavior.id, 'piece', 'struct'));
|
||
for (const key of ['shape', 'rot'])
|
||
fixture.stack.execute(new AddStructFieldCommand(behavior.id, 'piece', { key, kind: 'number' }));
|
||
const edit = graphEditor(fixture, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.variables(['piece'], { write: true });
|
||
const pack = edit.addNode('PackStruct', { rot: 2 });
|
||
fixture.stack.execute(new AddPortCommand(behavior.id, pack.id, { key: 'rot', kind: 'number' }));
|
||
edit.wire([
|
||
[start, 'exec', set, 'exec'],
|
||
[pack, 'value', set, 'piece'],
|
||
]);
|
||
|
||
const ctx = fakeContext({ variables: { piece: { shape: 3, rot: 0 } } });
|
||
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
|
||
expect(ctx.variables.piece).toEqual({ shape: 3, rot: 2 });
|
||
});
|
||
});
|