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) ?? '' }`; }