import { type PropertyBag, textOf } from '../model/Properties'; import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph'; 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 BEHAVIOR_PARAM = 'behavior'; /** What running another behavior's graph in place needs: its nodes, its variable defaults, its name. */ export interface BehaviorProgram { name: string; graph: Graph; defaults: PropertyBag; } export type BehaviorLookup = (assetId: string) => BehaviorProgram | null; export const NO_BEHAVIORS: BehaviorLookup = () => null; /** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */ export function isValidPortName(name: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && name !== 'exec'; } /** 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); } export function portsOf(port: DeclaredPort): PortDef { return { key: port.key, label: port.key, type: declaredPortType(port.kind) }; } function declaredOf(graph: Graph | undefined, type: string): PortDef[] { const node = graph && terminalNode(graph, type); return (node?.ports ?? []).map(portsOf); } /** * The node definition as it applies to *this* node. Identical to the registered one for every fixed * type; the three nested-behavior types grow ports from a declaration instead – the start and end * node from their own, the call node from those of the behavior it names. */ export function resolveDef(node: GraphNode, lookup: BehaviorLookup): NodeDef | undefined { const def = nodeDef(node.type); if (!def) return undefined; const own = (node.ports ?? []).map(portsOf); if (node.type === BEHAVIOR_START) return { ...def, outputs: [...def.outputs, ...own] }; if (node.type === BEHAVIOR_END) return { ...def, inputs: [...def.inputs, ...own] }; if (node.type !== RUN_BEHAVIOR) return def; const program = lookup(String(node.params[BEHAVIOR_PARAM] ?? '')); return { ...def, // The graph reads as what it does, not as a row of identical "Run behavior" boxes. label: program ? program.name : def.label, inputs: [...def.inputs, ...declaredOf(program?.graph, BEHAVIOR_START)], outputs: [...def.outputs, ...declaredOf(program?.graph, BEHAVIOR_END)], }; } /** * 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: BehaviorLookup, 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(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, lookup: BehaviorLookup, ownerId = ''): string { const def = resolveDef(node, 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) ?? '' }`; }