From d80d5cce26241c1f7b6a355d8aa111a9883ea789 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sun, 4 Oct 2026 18:15:56 +0200 Subject: [PATCH] Let a Start input or a variable name the state machine it carries A sub-behavior that gets its machine through a Start input had no state ports on Is in state, Set state and the other state nodes. Their ports came only from an inline pick or a State machine literal, so a machine handed in from outside left them empty. - A statemachine port of a Start node can now name a machine (DeclaredPort.machine). The inspector shows the pick next to the port name. SetPortMachineCommand sets it with undo. - machineIdOf follows an edge from such a port to that machine, so the state nodes it feeds offer its states. - A Run behavior node has the machine picked in advance on that input. - A different pick there is allowed, but the node warns, because the called graph asks for the states of its own machine. - A statemachine variable of a Variables node can name a machine the same way, picked next to the variable's row. - The inspector signature of a node now includes the picked machine. Before, the pick changed the ports but the dropdown kept its text until the node was selected again. - A local machine handed in still starts in no state every call. That rule from #229 is unchanged. Tests: - tests/core/start-machine.test.ts (7): ports with and without a machine and undo, save and load, the preset value moving a shared machine through the call, the warning, a port of another kind, the inspector signature, a Variables node. - Counter checks: without the new branch in machineIdOf 2 tests fail, without the machine in the signature 1 test fails. - Gate on melektaus-atx: 239 files, 3599 tests, CHECK EXIT 0. Co-Authored-By: Claude Opus 5.5 --- src/core/commands/portCommands.ts | 42 ++++++++ src/core/graph/Graph.ts | 5 + src/core/graph/nested.ts | 17 ++- src/core/graph/stateMachine.ts | 10 +- src/core/store/serialize.ts | 6 +- src/editor/inspector/nodeSection.ts | 11 +- src/editor/inspector/portSection.ts | 44 +++++++- tests/core/start-machine.test.ts | 159 ++++++++++++++++++++++++++++ 8 files changed, 284 insertions(+), 10 deletions(-) create mode 100644 tests/core/start-machine.test.ts diff --git a/src/core/commands/portCommands.ts b/src/core/commands/portCommands.ts index 556ed873..fa564539 100644 --- a/src/core/commands/portCommands.ts +++ b/src/core/commands/portCommands.ts @@ -279,6 +279,48 @@ export class SetPortWriteCommand implements Command { } } +/** + * Says which machine a `statemachine` port carries – an input of a start node, or a variable a + * Variables node reads; empty says none. Edges on the states of the one before stay where they are + * and hang there, as when a machine loses a state. + */ +export class SetPortMachineCommand implements Command { + readonly label: string; + private was: string | undefined; + + constructor( + private readonly assetId: string, + private readonly nodeId: string, + private readonly key: string, + private readonly machine: string, + ) { + this.label = `Set machine of ${key}`; + } + + execute(project: Project): void { + const node = requireNode(project, this.assetId, this.nodeId); + if (!isEntryTerminal(node.type) && !declaresVariablePorts(node.type)) + throw new CommandError('Only a start node or a Variables node carries a machine'); + const port = (node.ports ?? []).find((p) => p.key === this.key); + if (!port) throw new CommandError(`Port "${this.key}" not found`); + if (port.kind !== 'statemachine') throw new CommandError(`"${this.key}" is not a state machine`); + this.was = port.machine; + this.mark(node, this.machine); + } + + undo(project: Project): void { + this.mark(requireNode(project, this.assetId, this.nodeId), this.was ?? ''); + } + + private mark(node: GraphNode, machine: string): void { + node.ports = (node.ports ?? []).map((port) => { + if (port.key !== this.key) return port; + const { machine: _, ...plain } = port; + return machine ? { ...plain, machine } : plain; + }); + } +} + /** Rewrites the edges and inline values pointing at the port too, in every graph that has any. */ export class RenamePortCommand extends RenameCommand { constructor( diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index ff99e2ac..5a37296d 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -55,6 +55,11 @@ export interface DeclaredPort { kind: DeclaredPortKind; /** On a node declaring `both` sides: the port is an input too, so the node writes it. */ write?: true; + /** + * On a `statemachine` port: the machine it carries. The state nodes it feeds take their states + * from it. On a start node, a node running the behavior has it picked in advance as well. + */ + machine?: string; } export function declaredPortType(kind: DeclaredPortKind): PortType { diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index dda57783..55821f40 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -156,8 +156,9 @@ export function isFlowPort(port: DeclaredPort): boolean { */ export function portDefOf(port: DeclaredPort): PortDef { const def: PortDef = { key: port.key, label: port.key, type: declaredPortType(port.kind) }; + // The machine a start node's input carries is what the field offers before anyone picks. return isTaggedKind(port.kind) && port.kind !== 'object' - ? { ...def, field: flatDef(port.key, port.kind) } + ? { ...def, field: flatDef(port.key, port.kind, { value: port.machine }) } : def; } @@ -360,6 +361,20 @@ export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): const program = lookup.behavior(id); if (!program) return 'That behavior is gone'; if (callEntries(program.graph).length === 0) return 'No start or On start node – does nothing'; + return otherMachineProblem(node, program.graph, lookup); +} + +/** + * A machine picked here other than the one the start node's input carries: the called graph asks for + * the states of its own, so every one of them would read as never entered. + */ +function otherMachineProblem(node: GraphNode, graph: Graph, lookup: GraphLookup): string | null { + for (const port of terminalNode(graph, BEHAVIOR_START)?.ports ?? []) { + const picked = textOf(node.params[port.key] ?? ''); + if (port.kind !== 'statemachine' || !port.machine || !picked || picked === port.machine) continue; + const machine = lookup.machine(port.machine); + return `"${port.key}" expects ${machine ? `"${machine.name}"` : 'a machine that is gone'}`; + } return null; } diff --git a/src/core/graph/stateMachine.ts b/src/core/graph/stateMachine.ts index f08eb425..720350fa 100644 --- a/src/core/graph/stateMachine.ts +++ b/src/core/graph/stateMachine.ts @@ -127,15 +127,17 @@ export const machinePort = (): PortDef => ({ }); /** - * The machine asset a node's `machine` input names while the graph is drawn: the inline pick, or the - * `State machine` literal an edge brings in. A port is derived from the graph, not from a running - * value, so a variable or a list item feeding the input yields nothing. + * The machine asset a node's `machine` input names while the graph is drawn: the inline pick, the + * `State machine` literal an edge brings in, or the machine a declared port says it carries – an + * input of a behavior's start node or a variable of a Variables node. A port is derived from the + * graph, not from a running value, so anything else feeding the input yields nothing. */ export function machineIdOf({ node, graph }: PortSource): string { const edge = edgesInto(graph, node.id, MACHINE_PORT)[0]; const source = edge && graph.nodes.find((n) => n.id === edge.from.node); if (!edge) return textOf(node.params[MACHINE_PORT] ?? ''); - return source?.type === STATE_MACHINE ? textOf(source.params.value ?? '') : ''; + if (source?.type === STATE_MACHINE) return textOf(source.params.value ?? ''); + return source?.ports?.find((p) => p.key === edge.from.port)?.machine ?? ''; } /** What that machine is, looked up – null where the input names none, or none that exists. */ diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index d313f63f..9dbfac39 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -480,7 +480,11 @@ function normalizePorts(raw: unknown, fixed: readonly string[], writable: boolea const kind = (DECLARED_PORT_KINDS as readonly string[]).includes(String(p.kind)) ? (p.kind as DeclaredPortKind) : 'number'; - ports.push(writable && p.write === true ? { key, kind, write: true } : { key, kind }); + const port: DeclaredPort = writable && p.write === true ? { key, kind, write: true } : { key, kind }; + // A machine that is gone is kept, as a struct type is: the port then offers no states. + if (kind === 'statemachine' && typeof p.machine === 'string' && p.machine !== '') + port.machine = p.machine; + ports.push(port); } return ports; } diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts index b54cec10..2b89c1df 100644 --- a/src/editor/inspector/nodeSection.ts +++ b/src/editor/inspector/nodeSection.ts @@ -25,7 +25,7 @@ import { } from '../../core/graph'; import type { GraphAsset } from '../../core/model/Asset'; import { graphLookup } from '../../core/model/graphLookup'; -import { findGraphAsset, type Project } from '../../core/model/Project'; +import { findAssetOf, findGraphAsset, type Project } from '../../core/model/Project'; import { type PropertyValue, textOf } from '../../core/model/Properties'; import { isListNode, @@ -62,7 +62,14 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string) .filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0) .map((p) => p.key) : []; - const declared = (node.ports ?? []).map((p) => `${p.key}:${p.kind}${p.write ? ':set' : ''}`).join(','); + // The machine a port carries is drawn as the picker's text, its name included. + const machineOf = (id: string) => findAssetOf(project, id, 'statemachine')?.name ?? ''; + const declared = (node.ports ?? []) + .map( + (p) => + `${p.key}:${p.kind}${p.write ? ':set' : ''}${p.machine ? `:${p.machine}:${machineOf(p.machine)}` : ''}`, + ) + .join(','); // They decide which rows the inspector builds, and two behaviors may share a name in the title. const target = `${textOf(node.params[OBJECT_TYPE_PARAM] ?? '')}:${textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '')}`; // The transition rows and the picked name are drawn, not filled in by `refreshNodeSection`, so diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index d9618d5c..c1607b37 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -6,6 +6,7 @@ import { RemovePortCommand, RenamePortCommand, RepointPortCommand, + SetPortMachineCommand, SetPortWriteCommand, } from '../../core/commands/portCommands'; import { @@ -32,6 +33,7 @@ import { import { isPrivateVariable } from '../../core/model/Asset'; import { findAssetOf, findGraphAsset, type Project } from '../../core/model/Project'; import { textOf } from '../../core/model/Properties'; +import { assetRefChoices, NO_ASSET_LABEL } from '../../core/properties/assetRef'; import { DECLARED_PORT_KINDS, VARIABLE_KINDS } from '../../core/properties/kinds'; import { allShaderParamRows, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema'; import { @@ -187,7 +189,12 @@ function buildPickedPortSection( else if (ports.length === 0) addNote(host, `No ${plural} picked yet. Each one adds a port.`); for (const port of ports) { - const toggle = writeToggle(host, assetId, node, port); + // A machine a variable holds is a running value; which one its states come from is picked here. + const carries = noun === 'Variable' && port.kind === 'statemachine'; + const toggle = [ + ...(carries ? [machinePick(host, assetId, node, port)] : []), + ...writeToggle(host, assetId, node, port), + ]; if (custom) { portRow(host, assetId, node, port, portNameField(host, assetId, node, port), toggle); continue; @@ -266,7 +273,14 @@ export function buildPortSection( ); const blocks = ports.map((port) => - portRow(host, assetId, node, port, portNameField(host, assetId, node, port)), + portRow( + host, + assetId, + node, + port, + portNameField(host, assetId, node, port), + onStart && port.kind === 'statemachine' ? [machinePick(host, assetId, node, port)] : [], + ), ); sortBlocks(blocks, (from, to) => host.state.execute(new MovePortCommand(assetId, node.id, from, to))); } @@ -280,6 +294,32 @@ function portNameField(host: InspectorHost, assetId: string, node: GraphNode, po }); } +/** + * Which machine a start node's input or a Variables node's variable carries: what the state nodes + * behind it offer, and for a start node what a node running the behavior has picked before anyone + * picks there. + */ +function machinePick(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): Dropdown { + const project = host.state.project; + const machine = port.machine ? findAssetOf(project, port.machine, 'statemachine') : undefined; + const gone = port.machine !== undefined && machine === undefined; + const pick = new Dropdown(machine?.name ?? (gone ? 'Gone' : NO_ASSET_LABEL), { + variant: gone ? 'danger' : 'default', + }); + const set = (id: string) => host.state.execute(new SetPortMachineCommand(assetId, node.id, port.key, id)); + pick.onClick = () => { + const items: MenuItem[] = [ + { label: NO_ASSET_LABEL, onSelect: () => set('') }, + ...assetRefChoices(project, ['statemachine']).map((a) => ({ + label: a.name, + onSelect: () => set(a.id), + })), + ]; + new Menu(items).openBelow(pick); + }; + return pick; +} + function writeToggle(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): Widget[] { const box = new Checkbox(port.write === true); box.onChange = (on) => host.state.execute(new SetPortWriteCommand(assetId, node.id, port.key, on)); diff --git a/tests/core/start-machine.test.ts b/tests/core/start-machine.test.ts new file mode 100644 index 00000000..768480f4 --- /dev/null +++ b/tests/core/start-machine.test.ts @@ -0,0 +1,159 @@ +/** + * A `statemachine` input of a behavior's start node can say which machine it carries. The state + * nodes it feeds then offer that machine's states, and a node running the behavior has it picked + * in advance – a different pick there is a problem on that node. A variable a Variables node reads + * can say the same. + */ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { AddNodeCommand } from '../../src/core/commands/graphCommands'; +import { AddPortCommand, SetPortMachineCommand } from '../../src/core/commands/portCommands'; +import { AddVariableCommand } from '../../src/core/commands/variableCommands'; +import { + BEHAVIOR_PARAM, + BEHAVIOR_START, + callProblem, + GraphInterpreter, + inlineValue, + MACHINE_PORT, + resolveDef, + RUN_BEHAVIOR, + STATE, + STATE_NAME_PARAM, +} from '../../src/core/graph'; +import { graphLookup } from '../../src/core/model/graphLookup'; +import { findAssetOf } from '../../src/core/model/Project'; +import { nodeSignature } from '../../src/editor/inspector/nodeSection'; +import { + fakeContext, + fire, + graphEditor, + makeBehavior, + makeProject, + type ProjectFixture, + roundTrip, +} from '../helpers'; + +function makeMachine(f: ProjectFixture, name: string, states: readonly string[]) { + const create = new CreateAssetCommand('statemachine', name); + f.stack.execute(create); + const ids = states.map((state) => { + const add = new AddNodeCommand(create.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: state }); + f.stack.execute(add); + return add.node.id; + }); + return { id: create.asset.id, ids }; +} + +/** "Inner": its start hands `machine` to an `Is in state` and to a `Set state`. */ +function build(f: ProjectFixture) { + const doors = makeMachine(f, 'Doors', ['Open', 'Closed']); + const lights = makeMachine(f, 'Lights', ['On']); + const inner = makeBehavior(f, 'Inner'); + const edit = graphEditor(f, inner); + const start = edit.addNode(BEHAVIOR_START); + f.stack.execute(new AddPortCommand(inner.id, start.id, { key: 'machine', kind: 'statemachine' })); + const isIn = edit.addNode('IsInState'); + const set = edit.addNode('SetState'); + edit.wire([ + [start, 'machine', isIn, MACHINE_PORT], + [start, 'machine', set, MACHINE_PORT], + ]); + const ports = (node = isIn) => + resolveDef(node, inner.graph, graphLookup(f.project))!.outputs.map((p) => p.label); + return { doors, lights, inner, start, isIn, set, ports }; +} + +describe('a machine carried by a start node input', () => { + it('gives the nodes it feeds the states of that machine, and none without one', () => { + const f = makeProject(); + const { doors, inner, start, ports } = build(f); + expect(ports()).toEqual([]); + f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'machine', doors.id)); + expect(ports()).toEqual(['Open', 'Closed']); + f.stack.undo(); + expect(ports()).toEqual([]); + expect(start.ports).toEqual([{ key: 'machine', kind: 'statemachine' }]); + }); + + it('survives a save and load', () => { + const f = makeProject(); + const { doors, inner, start } = build(f); + f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'machine', doors.id)); + const loaded = findAssetOf(roundTrip(f.project), inner.id, 'behavior')!; + const port = loaded.graph.nodes.find((n) => n.id === start.id)!.ports; + expect(port).toEqual([{ key: 'machine', kind: 'statemachine', machine: doors.id }]); + }); + + it('is picked in advance on a node running the behavior, and moves that machine', () => { + const f = makeProject(); + const { doors, inner, start, set } = build(f); + f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'machine', doors.id)); + graphEditor(f, inner).connect(start, 'exec', set, doors.ids[1]!); + const outer = makeBehavior(f, 'Outer'); + const edit = graphEditor(f, outer); + const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: inner.id }); + edit.connect(edit.addNode('OnStart'), 'exec', call, 'exec'); + const lookup = graphLookup(f.project); + const input = resolveDef(call, outer.graph, lookup)!.inputs.find((p) => p.key === 'machine')!; + expect(inlineValue(call, input)).toBe(doors.id); + // Shared, so the move outlives the call and can be read here. + const ctx = fakeContext({ shared: { variables: {}, states: { [doors.id]: '' } } }); + fire(new GraphInterpreter(outer.graph, lookup), 'OnStart', ctx); + expect(ctx.shared.states[doors.id]).toBe(doors.ids[1]); + }); + + it('warns on a node that picks a different machine, not on one picking the same', () => { + const f = makeProject(); + const { doors, lights, inner, start } = build(f); + f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'machine', doors.id)); + const outer = makeBehavior(f, 'Outer'); + const edit = graphEditor(f, outer); + const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: inner.id }); + const lookup = () => graphLookup(f.project); + expect(callProblem(call, lookup(), outer.id)).toBeNull(); + call.params.machine = lights.id; + expect(callProblem(call, lookup(), outer.id)).toBe('"machine" expects "Doors"'); + call.params.machine = doors.id; + expect(callProblem(call, lookup(), outer.id)).toBeNull(); + }); + + it('is refused on a port of another kind', () => { + const f = makeProject(); + const { doors, inner, start } = build(f); + f.stack.execute(new AddPortCommand(inner.id, start.id, { key: 'speed', kind: 'number' })); + expect(() => f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'speed', doors.id))).toThrow( + '"speed" is not a state machine', + ); + }); + + it('rebuilds the inspector of the start node when it changes', () => { + const f = makeProject(); + const { doors, lights, inner, start } = build(f); + const before = nodeSignature(f.project, inner.id, start.id); + f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'machine', doors.id)); + const picked = nodeSignature(f.project, inner.id, start.id); + expect(picked).not.toBe(before); + f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'machine', lights.id)); + expect(nodeSignature(f.project, inner.id, start.id)).not.toBe(picked); + }); +}); + +describe('a machine carried by a variable of a Variables node', () => { + it('gives the nodes it feeds the states of that machine, and none without one', () => { + const f = makeProject(); + const doors = makeMachine(f, 'Doors', ['Open', 'Closed']); + const behavior = makeBehavior(f, 'Door'); + f.stack.execute(new AddVariableCommand(behavior.id, 'door', 'statemachine')); + const edit = graphEditor(f, behavior); + const read = edit.variables(['door']); + const isIn = edit.addNode('IsInState'); + edit.connect(read, 'door', isIn, MACHINE_PORT); + const ports = () => resolveDef(isIn, behavior.graph, graphLookup(f.project))!.outputs.map((p) => p.label); + expect(ports()).toEqual([]); + f.stack.execute(new SetPortMachineCommand(behavior.id, read.id, 'door', doors.id)); + expect(ports()).toEqual(['Open', 'Closed']); + f.stack.undo(); + expect(ports()).toEqual([]); + }); +});