From 1135205d69fb450d950957b1f560f7e3a722571d Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Wed, 16 Sep 2026 09:08:57 +0200 Subject: [PATCH] A machine that arrives as a value can still be commanded, so a name may name its state (#58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/core/graph/nodes/flow/SetStateByName.ts | 29 +++++++++++ src/core/graph/nodes/flow/StateBranch.ts | 23 ++++++-- src/core/graph/nodes/index.ts | 2 + src/core/graph/stateMachine.ts | 13 +++++ tests/core/node-catalog.test.ts | 58 +++++++++++++++++++-- tests/core/state-machine.test.ts | 56 ++++++++++++++++++-- tools/e2e/26-state-machine.mjs | 51 +++++++++++++++++- 7 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 src/core/graph/nodes/flow/SetStateByName.ts diff --git a/src/core/graph/nodes/flow/SetStateByName.ts b/src/core/graph/nodes/flow/SetStateByName.ts new file mode 100644 index 00000000..d4b70b27 --- /dev/null +++ b/src/core/graph/nodes/flow/SetStateByName.ts @@ -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' }; + }, +}; diff --git a/src/core/graph/nodes/flow/StateBranch.ts b/src/core/graph/nodes/flow/StateBranch.ts index ec7407c9..13d37908 100644 --- a/src/core/graph/nodes/flow/StateBranch.ts +++ b/src/core/graph/nodes/flow/StateBranch.ts @@ -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 }; }, }; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 4269dc5e..59d394fd 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -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, diff --git a/src/core/graph/stateMachine.ts b/src/core/graph/stateMachine.ts index f2830f7f..d0158e5f 100644 --- a/src/core/graph/stateMachine.ts +++ b/src/core/graph/stateMachine.ts @@ -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))]; diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index c5ad504a..e58127e2 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -122,7 +122,12 @@ const CASES: Record = { }, ], 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 = { 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: [ diff --git a/tests/core/state-machine.test.ts b/tests/core/state-machine.test.ts index fd168f84..98d028be 100644 --- a/tests/core/state-machine.test.ts +++ b/tests/core/state-machine.test.ts @@ -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); diff --git a/tools/e2e/26-state-machine.mjs b/tools/e2e/26-state-machine.mjs index 16758c0d..042ece96 100644 --- a/tools/e2e/26-state-machine.mjs +++ b/tools/e2e/26-state-machine.mjs @@ -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(`