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

@@ -0,0 +1,29 @@
import type { NodeDef } from '../../NodeRegistry';
import { setState } from '../../sharedState';
import { machinePort, stateByName } from '../../stateMachine';
import { asString } from '../../values';
import { exec, str } from '../shared';
/**
* What `Set state` cannot do: a machine that arrives as a value. Ports are derived from the graph,
* so a machine coming from a variable or a list item has no state inputs at all – this node names
* the state instead of wiring it. Nothing is found is a way out of its own, not silence.
*/
export const SetStateByName: NodeDef = {
type: 'SetStateByName',
label: 'Set state by name',
category: 'action',
description:
'Puts the machine into the state of that id or name, then continues along "exec". A name no ' +
'state answers to – or two states share – continues along "not found" and moves nothing.',
inputs: [exec(), machinePort(), str('state')],
outputs: [exec(), exec('notFound', 'not found')],
params: [],
run: (ctx, inputs, _params, _def, _entry, lookup) => {
const machineId = asString(inputs.machine);
const state = stateByName(machineId ? lookup.machine(machineId) : null, asString(inputs.state));
if (!state) return { next: 'notFound' };
setState(ctx, machineId, state);
return { next: 'exec' };
},
};
+18 -5
View File
@@ -4,20 +4,33 @@ import { machineOf, machinePort, statePorts } from '../../stateMachine';
import { asString } from '../../values';
import { exec } from '../shared';
/** No state set yet, or a state of another machine: the flow ends here, along no output. */
/** The way out for everything no state output covers. A state id is ten characters, so no state
* can claim this key – the guard below is what makes that true rather than likely. */
const ELSE = 'else';
/**
* No state set yet, a state of another machine, or a machine that arrived as a value and therefore
* has no state outputs at all: the flow leaves along `else`. A branch nobody can see taken is the
* hardest kind to debug, so this node always continues somewhere.
*/
export const StateBranch: NodeDef = {
type: 'StateBranch',
label: 'State branch',
category: 'flow',
description:
'Continues along the output of the state the machine is in. A machine with no start state is ' +
'in no state until "Set state" has run, and nothing continues.',
'Continues along the output of the state the machine is in, and along "else" while it is in ' +
'none – or while the machine comes from a value, where the states are unknown to the graph.',
inputs: [exec(), machinePort()],
// Derived rather than fixed, so it sits below the states instead of above them – the way out of a
// branch belongs under what it is an exception to, as it does on `Branch`.
outputs: [],
params: [],
derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }),
derivePorts: (source) => {
const states = statePorts(machineOf(source));
return { outputs: [...states, ...(states.some((p) => p.key === ELSE) ? [] : [exec(ELSE, ELSE)])] };
},
run: (ctx, inputs, _params, def) => {
const current = currentState(ctx, asString(inputs.machine));
return def.outputs.some((p) => p.key === current) ? { next: current } : {};
return { next: def.outputs.some((p) => p.key === current) ? current : ELSE };
},
};
+2
View File
@@ -97,6 +97,7 @@ import { Repeat } from './flow/Repeat';
import { RunBehavior } from './flow/RunBehavior';
import { Sequence } from './flow/Sequence';
import { SetState } from './flow/SetState';
import { SetStateByName } from './flow/SetStateByName';
import { StateBranch } from './flow/StateBranch';
import { StateTransition } from './flow/StateTransition';
import { PARTICLE_NODES } from '../../particles/nodes';
@@ -132,6 +133,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
RunBehavior,
StateBranch,
SetState,
SetStateByName,
StateTransition,
CurrentState,
IsInState,
+13
View File
@@ -76,6 +76,19 @@ export function transitionTo(machine: StateMachine, current: string, name: strin
return targets.length === 1 ? targets[0]! : '';
}
/**
* The state a text names: its id where one matches, else the state of that name. A name two states
* share answers nothing – the same rule `transitionTo` follows, and for the same reason: with
* nothing in the graph to pick between them, either choice would be a guess. `''` for no match,
* which is what a node reports as "not found" rather than moving the machine somewhere arbitrary.
*/
export function stateByName(machine: StateMachine | null, text: string): string {
if (!machine || !text) return '';
if (machine.states.some((s) => s.id === text)) return text;
const named = machine.states.filter((s) => s.name === text);
return named.length === 1 ? named[0]!.id : '';
}
/** The names a machine's transitions carry, deduped and in edge order – what a node may ask for. */
export function transitionNames(machine: StateMachine | null): string[] {
return [...new Set((machine?.transitions ?? []).map((t) => t.name).filter((name) => name))];
+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);
+50 -1
View File
@@ -503,7 +503,13 @@ export async function stateMachine(t) {
a.graph.nodes.push(${JSON.stringify(node('sm-fresh', 'StateBranch', 300, 520))});
`);
await selectNode('sm-fresh');
check('without a machine a State branch has no state outputs', (await outputsOf('sm-fresh')).length === 0);
// "else" is the one output it always has: a branch that continues nowhere cannot be seen.
const freshOutputs = await outputsOf('sm-fresh');
check(
'without a machine a State branch has no state outputs, only else',
freshOutputs.map((line) => line.trim()).join() === 'else',
JSON.stringify(freshOutputs),
);
// The row is named after the port, as the node body names it.
const machineField = await json("__gm.inspectorField('machine', true)");
await click(machineField.x, machineField.y);
@@ -579,6 +585,49 @@ export async function stateMachine(t) {
);
await shot('04-ghost-port');
// 26j. A machine that arrives as a value has no state ports at all: a text holding its id is not
// followed, on purpose. `Set state by name` names the state instead, and the branch leaves
// through `else` – the two halves of #58, measured in a running game.
await mutate(`
const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic');
b.graph.nodes.push(
${JSON.stringify(node('sm-id', 'String', 0, 700, { value: made.id }))},
${JSON.stringify(node('sm-byname', 'SetStateByName', 300, 700, { state: nameOf(first) }))},
${JSON.stringify(node('sm-dyn', 'StateBranch', 600, 700))},
${JSON.stringify(node('sm-elselog', 'Log', 900, 700, { message: 'else taken' }))},
${JSON.stringify(node('sm-miss', 'SetStateByName', 1200, 700, { state: 'Nope' }))},
${JSON.stringify(node('sm-nflog', 'Log', 1500, 700, { message: 'no such state' }))},
);
b.graph.edges = b.graph.edges.filter((e) => e.id !== 'sm-e1');
b.graph.edges.push(
${JSON.stringify(edge('sm-d1', 'sm-id', 'value', 'sm-byname', 'machine'))},
${JSON.stringify(edge('sm-d2', 'sm-id', 'value', 'sm-dyn', 'machine'))},
${JSON.stringify(edge('sm-d3', 'sm-id', 'value', 'sm-miss', 'machine'))},
${JSON.stringify(edge('sm-d4', 'sm-start', 'exec', 'sm-byname', 'exec'))},
${JSON.stringify(edge('sm-d5', 'sm-byname', 'exec', 'sm-dyn', 'exec'))},
${JSON.stringify(edge('sm-d6', 'sm-dyn', 'else', 'sm-elselog', 'exec'))},
${JSON.stringify(edge('sm-d7', 'sm-elselog', 'exec', 'sm-miss', 'exec'))},
${JSON.stringify(edge('sm-d8', 'sm-miss', 'notFound', 'sm-nflog', 'exec'))},
);
`);
await selectNode('sm-dyn');
const dynOutputs = await outputsOf('sm-dyn');
check(
'a machine coming from a value grows no state outputs, so else is the whole branch',
dynOutputs.map((line) => line.trim()).join() === 'else',
JSON.stringify(dynOutputs),
);
await play();
await sleep(600);
const byName = (await world())?.log ?? [];
check(
'Set state by name moves a machine the graph cannot name, and the static branch sees it',
byName.includes('first state') && byName.includes('else taken') && byName.includes('no such state'),
JSON.stringify([...new Set(byName)]),
);
await stop();
await shot('05-by-name');
// 26h. Everything the step made goes away again.
const left = await rig.remove();
await mutate(`