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>
139 lines
5.9 KiB
TypeScript
139 lines
5.9 KiB
TypeScript
import { textOf } from '../model/Properties';
|
||
import { isTaggedKind } from '../properties/kinds';
|
||
import { flatDef, isValidFieldName } from '../properties/PropertySchema';
|
||
import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph';
|
||
import type { GraphLookup } from './lookup';
|
||
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
|
||
|
||
export const BEHAVIOR_START = 'BehaviorStart';
|
||
export const BEHAVIOR_END = 'BehaviorEnd';
|
||
export const RUN_BEHAVIOR = 'RunBehavior';
|
||
export const VARIABLES = 'Variables';
|
||
export const PROPERTIES = 'Properties';
|
||
|
||
export const BEHAVIOR_PARAM = 'behavior';
|
||
|
||
/**
|
||
* Nodes whose declared ports are not names of their own but variables of the behavior around them.
|
||
* Renaming a variable therefore has to reach them, and the inspector picks their ports from a list
|
||
* instead of letting them be typed.
|
||
*/
|
||
export function declaresVariablePorts(type: string): boolean {
|
||
return type === VARIABLES;
|
||
}
|
||
|
||
/** Nodes whose declared ports are properties of the object they are handed. */
|
||
export function declaresPropertyPorts(type: string): boolean {
|
||
return type === PROPERTIES;
|
||
}
|
||
|
||
/** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */
|
||
export function isValidPortName(name: string): boolean {
|
||
return isValidFieldName(name) && name !== 'exec';
|
||
}
|
||
|
||
/**
|
||
* Keys a declared port cannot take: the node's fixed ports on the side it declares (`object`), and
|
||
* its settings, which share the params bag with the typed-in values of its inputs.
|
||
*/
|
||
export function fixedPortKeys(def: NodeDef): string[] {
|
||
const side = def.declaresPorts;
|
||
const fixed =
|
||
side === 'inputs' ? def.inputs : side === 'outputs' ? def.outputs : [...def.inputs, ...def.outputs];
|
||
return [...fixed.map((p) => p.key), ...def.params.map((p) => p.key)];
|
||
}
|
||
|
||
export function declaredOn(
|
||
def: NodeDef,
|
||
ports: readonly DeclaredPort[],
|
||
side: 'inputs' | 'outputs',
|
||
): DeclaredPort[] {
|
||
if (def.declaresPorts === 'both') return side === 'outputs' ? [...ports] : ports.filter((p) => p.write);
|
||
return def.declaresPorts === side ? [...ports] : [];
|
||
}
|
||
|
||
/** What a collapsed Variables or Properties node does, since its ports are out of sight. */
|
||
export function collapsedTitle(ports: readonly DeclaredPort[], noun: string): string {
|
||
const set = ports.filter((p) => p.write);
|
||
const get = ports.filter((p) => !p.write);
|
||
if (set.length > 1) return `Set ${set.length} ${noun}`;
|
||
if (get.length > 1) return `Get ${get.length} ${noun}`;
|
||
if (set[0]) return `Set ${set[0].key}`;
|
||
if (get[0]) return `Get ${get[0].key}`;
|
||
return `No ${noun}`;
|
||
}
|
||
|
||
/** The single start (or end) node of a graph; a second one is ignored. */
|
||
export function terminalNode(graph: Graph, type: string): GraphNode | undefined {
|
||
return graph.nodes.find((n) => n.type === type);
|
||
}
|
||
|
||
export function isTerminalType(type: string): boolean {
|
||
return type === BEHAVIOR_START || type === BEHAVIOR_END;
|
||
}
|
||
|
||
export function isTerminalNode(node: GraphNode): boolean {
|
||
return isTerminalType(node.type);
|
||
}
|
||
|
||
/** A tagged kind is one the value cannot show, so its port carries the field that edits it. */
|
||
export function portDefOf(port: DeclaredPort): PortDef {
|
||
const def: PortDef = { key: port.key, label: port.key, type: declaredPortType(port.kind) };
|
||
return isTaggedKind(port.kind) ? { ...def, field: flatDef(port.key, port.kind) } : def;
|
||
}
|
||
|
||
/** The ports the start (or end) node of `graph` declares, as seen from a node calling it. */
|
||
export function declaredPorts(graph: Graph | undefined, type: string): PortDef[] {
|
||
const node = graph && terminalNode(graph, type);
|
||
return (node?.ports ?? []).map(portDefOf);
|
||
}
|
||
|
||
/**
|
||
* The node definition as it applies to *this* node: identical to the registered one, unless the
|
||
* type declares ports per node (`declaresPorts`) or computes them (`derivePorts`).
|
||
*/
|
||
export function resolveDef(node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef | undefined {
|
||
const def = nodeDef(node.type);
|
||
if (!def || (!def.declaresPorts && !def.derivePorts)) return def;
|
||
const own = (side: 'inputs' | 'outputs') =>
|
||
declaredOn(def, node.ports ?? [], side).map((p) =>
|
||
def.declaredPort ? def.declaredPort(p, node) : portDefOf(p),
|
||
);
|
||
const grown = def.derivePorts?.({ node, graph, lookup }) ?? {};
|
||
const side = (key: 'inputs' | 'outputs') => execFirst([...def[key], ...(grown[key] ?? []), ...own(key)]);
|
||
return { ...def, label: grown.label ?? def.label, inputs: side('inputs'), outputs: side('outputs') };
|
||
}
|
||
|
||
/** A derived exec pin belongs at the top, above the fixed data ports too. */
|
||
function execFirst(ports: PortDef[]): PortDef[] {
|
||
return [...ports.filter((p) => p.type === 'exec'), ...ports.filter((p) => p.type !== 'exec')];
|
||
}
|
||
|
||
/**
|
||
* Why this call cannot do anything, or null if it can. A call is the one node that depends on a
|
||
* *different* graph, so it is the one node that can look perfectly healthy and still be dead.
|
||
*/
|
||
export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): string | null {
|
||
if (node.type !== RUN_BEHAVIOR) return null;
|
||
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
|
||
if (!id) return 'No behavior picked';
|
||
if (id === ownerId) return 'A behavior cannot run itself';
|
||
const program = lookup.behavior(id);
|
||
if (!program) return 'That behavior is gone';
|
||
if (!terminalNode(program.graph, BEHAVIOR_START)) return 'No start node – does nothing';
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Everything that changes how a node looks, as one string. Views and the inspector rebuild when it
|
||
* changes – a port list that is derived rather than fixed has no other way of being noticed.
|
||
*/
|
||
export function portSignature(node: GraphNode, graph: Graph, lookup: GraphLookup, ownerId = ''): string {
|
||
const def = resolveDef(node, graph, lookup);
|
||
if (!def) return 'missing';
|
||
const list = (ports: PortDef[]) => ports.map((p) => `${p.key}:${p.type}`).join(',');
|
||
return `${node.type}|${def.label}|${list(def.inputs)}|${list(def.outputs)}|${
|
||
callProblem(node, lookup, ownerId) ?? ''
|
||
}`;
|
||
}
|