A machine that arrives as a value can still be commanded, so a name may name its state (#58)
game-maker / suite (push) Successful in 6m12s

Set state and State branch derived their ports from the graph, so a machine
that came out of a variable or a list had no ports at all: nothing to set,
nowhere to branch. It could be passed around but not operated.

stateByName() resolves id first, then name; a name two states carry answers
with nothing, the same rule transitionTo already follows for two transitions
of one name — without something in the graph that decides between them, any
pick would be a guess.

Set state by name takes the machine and a text, and has a second exec output
not found, so a typo is not swallowed in silence. State branch gains a
derived else that fires whenever no state output matches — no state set, a
state of another machine, or a machine that arrives as a value and therefore
has no state outputs at all. A flow that enters State branch now always
leaves it somewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-16 09:08:57 +02:00
1 parent 7e5003b590
commit 1135205d69
7 files changed
+218 -14

No files matched your search

+55 -3
View File
@@ -122,7 +122,12 @@ const CASES: Record<string, Case[]> = {
},
],
StateBranch: [
{ name: 'in no state yet, the flow ends', inputs: { machine: 'm' }, derivedOutputs: ['s1'], next: null },
{
name: 'in no state yet, the flow leaves along else',
inputs: { machine: 'm' },
derivedOutputs: ['s1'],
next: 'else',
},
{
name: 'follows the output of the current state',
inputs: { machine: 'm' },
@@ -131,11 +136,58 @@ const CASES: Record<string, Case[]> = {
next: 's1',
},
{
name: 'a state that is not among its outputs (another machine, a deleted state) ends the flow',
name: 'a state that is not among its outputs (another machine, a deleted state) takes else',
inputs: { machine: 'm' },
context: { states: { m: 'gone' } },
derivedOutputs: ['s1'],
next: null,
next: 'else',
},
{
// The point of #58: a machine arriving as a value has no state outputs at all.
name: 'a machine the graph cannot name has no state outputs, so else is the whole node',
inputs: { machine: 'm' },
context: { states: { m: 's1' } },
next: 'else',
},
],
SetStateByName: [
{
name: 'moves the machine to the state of that name',
inputs: { machine: 'm', state: 'Open' },
lookup: machineWithStates([{ id: 's1', name: 'Open' }]),
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({ m: 's1' }),
},
{
name: 'a state id names the same state, so a rename cannot break a graph that used the id',
inputs: { machine: 'm', state: 's1' },
lookup: machineWithStates([{ id: 's1', name: 'Open' }]),
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({ m: 's1' }),
},
{
name: 'a name no state answers to moves nothing and takes "not found"',
inputs: { machine: 'm', state: 'Ajar' },
lookup: machineWithStates([{ id: 's1', name: 'Open' }]),
next: 'notFound',
after: (ctx) => expect(ctx.states).toEqual({}),
},
{
name: 'a name two states share is no answer: the graph never picked between them',
inputs: { machine: 'm', state: 'Open' },
lookup: machineWithStates([
{ id: 's1', name: 'Open' },
{ id: 's2', name: 'Open' },
]),
next: 'notFound',
after: (ctx) => expect(ctx.states).toEqual({}),
},
{
name: 'without a machine there is nothing to set',
inputs: { machine: '', state: 'Open' },
lookup: machineWithStates([{ id: 's1', name: 'Open' }]),
next: 'notFound',
after: (ctx) => expect(ctx.states).toEqual({}),
},
],
SetState: [
+51 -5
View File
@@ -169,7 +169,8 @@ describe('the state ports of State branch and Set state', () => {
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);
// The branch carries one more: the way out for everything no state covers.
expect(ports(branch, 'outputs')).toEqual([...expected, 'else:else']);
expect(ports(set, 'inputs')).toEqual(expected);
});
@@ -177,9 +178,13 @@ describe('the state ports of State branch and Set state', () => {
const { machine, edit, ports } = setup();
const literal = edit.addNode('StateMachine', { value: machine.id });
const branch = edit.addNode('StateBranch');
expect(ports(branch, 'outputs')).toEqual([]);
expect(ports(branch, 'outputs')).toEqual(['else:else']);
edit.connect(literal, 'value', branch, MACHINE_PORT);
expect(ports(branch, 'outputs')).toEqual([`${machine.ids[0]}:Open`, `${machine.ids[1]}:Closed`]);
expect(ports(branch, 'outputs')).toEqual([
`${machine.ids[0]}:Open`,
`${machine.ids[1]}:Closed`,
'else:else',
]);
});
it('cannot follow a variable: a port is derived from the graph, not from a running value', () => {
@@ -188,12 +193,17 @@ describe('the state ports of State branch and Set state', () => {
findAsset(f.project, behavior.id)!.props.doors = machine.id;
const variables = edit.variables(['doors']);
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
edit.connect(variables, 'doors', branch, MACHINE_PORT);
expect(ports(branch, 'outputs')).toEqual([]);
edit.connect(variables, 'doors', set, MACHINE_PORT);
// `else` is all that is left of the branch, and Set state has nothing at all – which is why
// "Set state by name" exists: it names the state instead of wiring it.
expect(ports(branch, 'outputs')).toEqual(['else:else']);
expect(ports(set, 'inputs')).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([]);
expect(ports(branch, 'outputs')).toEqual(['else:else']);
});
it('follow a rename, and lose the edges of a state that is deleted', () => {
@@ -291,6 +301,42 @@ describe('a running state machine', () => {
expect(ctx.logs).toEqual([]);
});
it('is driven by name where the machine arrives as a value, and branches through else', () => {
const f = makeProject();
const machine = makeMachine(f);
const behavior = makeBehavior(f, 'Door');
f.stack.execute(new AddVariableCommand(behavior.id, 'doors', 'statemachine'));
findAsset(f.project, behavior.id)!.props.doors = machine.id;
const edit = graphEditor(f, behavior);
const variables = edit.variables(['doors']);
const start = edit.addNode('OnStart');
const set = edit.addNode('SetStateByName', { state: 'Closed' });
const missing = edit.addNode('SetStateByName', { state: 'Ajar' });
const logMissing = edit.addNode('Log', { message: 'no such state' });
const update = edit.addNode('OnUpdate');
const branch = edit.addNode('StateBranch');
const logElse = edit.addNode('Log', { message: 'unknown to the graph' });
edit.wire([
[variables, 'doors', set, MACHINE_PORT],
[variables, 'doors', missing, MACHINE_PORT],
[variables, 'doors', branch, MACHINE_PORT],
[start, 'exec', set, 'exec'],
[set, 'exec', missing, 'exec'],
[missing, 'notFound', logMissing, 'exec'],
[update, 'exec', branch, 'exec'],
[branch, 'else', logElse, 'exec'],
]);
const ctx = fakeContext({ variables: { doors: machine.id } });
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project));
interpreter.fire('OnStart', ctx);
// The name moved the machine; the one nothing answers to took "not found" instead of ending.
expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] });
expect(ctx.logs).toEqual(['no such state']);
// The branch has no state outputs at all here, so even a state that is set leaves through else.
interpreter.fire('OnUpdate', ctx);
expect(ctx.logs).toEqual(['no such state', 'unknown to the graph']);
});
it('refuses a connection to a state port the machine does not have', () => {
const f = makeProject();
const machine = makeMachine(f);