A state machine is an asset whose graph holds one `State` node per state (no ports, name as title), edited in a new "State machine" tab that reuses the graph canvas: right-click places "State n", snap toggle shared with the Behavior tab via SnapToggle. `statemachine` is a value kind like texture (port, literal node, variable, struct field, tagged list item). Behaviors use it through `State branch` (one exec output per state) and `Set state` (one exec input per state; the interpreter now hands `run` the entry port). States live per behavior instance in NodeContext.states, with no initial state. Ports derive from the inline picker or a wired `State machine` literal; BehaviorLookup became GraphLookup with behavior(id) and machine(id). The Graph tab is labelled "Behavior" (key unchanged). Unit tests (state-machine, kinds, catalog, ports), e2e step 26 and README added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
92 lines
3.8 KiB
TypeScript
92 lines
3.8 KiB
TypeScript
import { edgesInto, findNode, type Graph, type PortType, portsCompatible } from './Graph';
|
||
import type { GraphLookup } from './lookup';
|
||
import { resolveDef } from './nested';
|
||
import type { NodeDef } from './NodeRegistry';
|
||
|
||
/** One end of an edge being dragged: a port on a node, seen from its own side. */
|
||
export interface PortSide {
|
||
nodeId: string;
|
||
port: string;
|
||
isOutput: boolean;
|
||
}
|
||
|
||
export function portTypeOf(graph: Graph, side: PortSide, lookup: GraphLookup): PortType | undefined {
|
||
const node = findNode(graph, side.nodeId);
|
||
const def = node && resolveDef(node, graph, lookup);
|
||
const list = side.isOutput ? def?.outputs : def?.inputs;
|
||
return list?.find((p) => p.key === side.port)?.type;
|
||
}
|
||
|
||
/** Whether a drag from `from` may land on `to`, i.e. whether ConnectCommand would accept it. */
|
||
export function canConnect(graph: Graph, from: PortSide, to: PortSide, lookup: GraphLookup): boolean {
|
||
if (from.isOutput === to.isOutput || from.nodeId === to.nodeId) return false;
|
||
const fromType = portTypeOf(graph, from, lookup);
|
||
const toType = portTypeOf(graph, to, lookup);
|
||
if (fromType === undefined || toType === undefined) return false;
|
||
const [out, inp] = from.isOutput ? [fromType, toType] : [toType, fromType];
|
||
return portsCompatible(out, inp) && !linked(graph, from, to);
|
||
}
|
||
|
||
/**
|
||
* Which port a drag from `from` lands on when it is dropped on node `nodeId` rather than on a port:
|
||
* the first free compatible one, or – when none is free – the last compatible one, whose edge is
|
||
* then replaced. Returns null when the node offers nothing to connect to.
|
||
*/
|
||
export function dropTargetPort(
|
||
graph: Graph,
|
||
from: PortSide,
|
||
nodeId: string,
|
||
lookup: GraphLookup,
|
||
): PortSide | null {
|
||
if (nodeId === from.nodeId) return null;
|
||
const node = findNode(graph, nodeId);
|
||
const def = node && resolveDef(node, graph, lookup);
|
||
const fromType = portTypeOf(graph, from, lookup);
|
||
if (!def || fromType === undefined) return null;
|
||
const candidates = compatiblePorts(def, fromType, from.isOutput)
|
||
.map((p) => ({ side: { nodeId, port: p.key, isOutput: !from.isOutput }, type: p.type }))
|
||
.filter((c) => !linked(graph, from, c.side));
|
||
const free = candidates.find((c) => isFree(graph, c.side, c.type));
|
||
return (free ?? candidates.at(-1))?.side ?? null;
|
||
}
|
||
|
||
/**
|
||
* Whether a node of this type has any port a drag from `fromType` could land on – the filter for
|
||
* the palette that opens when an edge is dropped into empty space.
|
||
*/
|
||
export function defAcceptsFrom(def: NodeDef, fromType: PortType, fromIsOutput: boolean): boolean {
|
||
return compatiblePorts(def, fromType, fromIsOutput).length > 0;
|
||
}
|
||
|
||
/**
|
||
* The port a *fresh* node of this type offers such a drag. Every port of a node that is not in the
|
||
* graph yet is free, so the first compatible one is what `dropTargetPort` would pick for it.
|
||
*/
|
||
export function firstPortFor(def: NodeDef, fromType: PortType, fromIsOutput: boolean): string | null {
|
||
return compatiblePorts(def, fromType, fromIsOutput)[0]?.key ?? null;
|
||
}
|
||
|
||
function compatiblePorts(def: NodeDef, fromType: PortType, fromIsOutput: boolean) {
|
||
const ports = fromIsOutput ? def.inputs : def.outputs;
|
||
return ports.filter((p) =>
|
||
fromIsOutput ? portsCompatible(fromType, p.type) : portsCompatible(p.type, fromType),
|
||
);
|
||
}
|
||
|
||
/** Only a data input is exclusive – exec inputs and every output take any number of edges. */
|
||
function isFree(graph: Graph, side: PortSide, type: PortType): boolean {
|
||
if (side.isOutput || type === 'exec') return true;
|
||
return edgesInto(graph, side.nodeId, side.port).length === 0;
|
||
}
|
||
|
||
function linked(graph: Graph, a: PortSide, b: PortSide): boolean {
|
||
const [out, inp] = a.isOutput ? [a, b] : [b, a];
|
||
return graph.edges.some(
|
||
(e) =>
|
||
e.from.node === out.nodeId &&
|
||
e.from.port === out.port &&
|
||
e.to.node === inp.nodeId &&
|
||
e.to.port === inp.port,
|
||
);
|
||
}
|