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 <noreply@anthropic.com>
This commit is contained in:
1 parent
3f12a77aea
commit
d80d5cce26
8 files changed
+284
-10
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user