import { type PropertyBag, textOf } from '../model/Properties'; import { isTaggedKind } from '../properties/kinds'; import { flatDef, isValidFieldName, type PropertyDef } from '../properties/PropertySchema'; import { type DeclaredPort, declaredPortType, edgesInto, findNode, type Graph, type GraphEdge, type GraphNode, type PortType, } from './Graph'; import type { GraphLookup } from './lookup'; import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry'; import { lerpProblem } from './nodes/data/Lerp'; import { withSplits } from './portSplits'; import { agreedType, type SharedPortGroup } from './portTypes'; export const BEHAVIOR_START = 'BehaviorStart'; export const BEHAVIOR_END = 'BehaviorEnd'; export const RUN_BEHAVIOR = 'RunBehavior'; export const ON_BEHAVIOR = 'OnBehavior'; export const VARIABLES = 'Variables'; export const PROPERTIES = 'Properties'; const ON_START = 'OnStart'; export const BEHAVIOR_PARAM = 'behavior'; export const VARIABLES_PARAM = 'variablesAsset'; /** Whose declarations a `Variables` node reads; empty is the behavior the node sits in. */ export const VARIABLES_SETTING: PropertyDef = { key: VARIABLES_PARAM, label: 'Variables', kind: 'asset', default: '', assetTypes: ['variables'], }; export const STRUCT_TYPE_PARAM = 'structType'; /** The Struct asset whose fields a `Pack` or `Expand` takes as its ports (#253); empty is by hand. */ export const STRUCT_TYPE_SETTING: PropertyDef = { key: STRUCT_TYPE_PARAM, label: 'Type', kind: 'asset', default: '', assetTypes: ['struct'], }; export function variablesSourceOf(params: PropertyBag): string { return textOf(params[VARIABLES_PARAM] ?? ''); } /** * 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)]; } 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); } /** * Where a call into `graph` begins: its start node, or – for a behavior that has none – every * `On start` in it, in graph order, as when the behavior starts on its own (#230). */ export function callEntries(graph: Graph): GraphNode[] { const start = terminalNode(graph, BEHAVIOR_START); return start ? [start] : graph.nodes.filter((n) => n.type === ON_START); } export function isTerminalType(type: string): boolean { return type === BEHAVIOR_START || type === BEHAVIOR_END; } export function isTerminalNode(node: GraphNode): boolean { return isTerminalType(node.type); } /** The two nodes that stand for another behavior: the one running it, the one listening to it (#294). */ export function namesBehavior(type: string): boolean { return type === RUN_BEHAVIOR || type === ON_BEHAVIOR; } /** A further flow port of a start or end node, next to its fixed `exec` (#294). */ export function isFlowPort(port: DeclaredPort): boolean { return port.kind === 'flow'; } /** * A tagged kind is one the value cannot show, so its port carries the field that edits it – except * an object, which nothing can type in. */ export function portDefOf(port: DeclaredPort): PortDef { const def: PortDef = { key: port.key, label: port.key, type: declaredPortType(port.kind) }; return isTaggedKind(port.kind) && port.kind !== 'object' ? { ...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 = shapedDef(node, graph, lookup, newTrail()); return def && splitDef(def, node); } function splitDef(def: NodeDef, node: GraphNode): NodeDef { return withSplits(def, node, (key, field) => ({ ...portDefOf(field), key, label: key })); } /** * The definition a run works with: `resolveDef` without the field ports of split outputs. The node * produces its wholes as always; reading a field port picks the field out of one (`splitField`). */ export function runDef(node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef | undefined { return shapedDef(node, graph, lookup, newTrail()); } /** * One question about a shared group, followed through the nodes of that kind in front of it (#210). * `open` holds the nodes still being asked: reaching one again is a data cycle, and it then reports * the `any` it was registered with, which ends the circle. `done` keeps each answer, so a node * feeding both inputs of the next is asked once and not once per path. */ interface Trail { open: Set; done: Map; } function newTrail(): Trail { return { open: new Set(), done: new Map() }; } function shapedDef(node: GraphNode, graph: Graph, lookup: GraphLookup, trail: Trail): NodeDef | undefined { const def = nodeDef(node.type); if (!def) return undefined; const shared = def.sharedType; if (!shared || trail.open.has(node.id)) return ownShape(def, node, graph, lookup); const known = trail.done.get(node.id); if (known) return known; trail.open.add(node.id); const typed = withSharedType(ownShape(def, node, graph, lookup), shared, node, graph, lookup, trail); trail.open.delete(node.id); trail.done.set(node.id, typed); return typed; } /** The ports of this node before any shared group has its say. */ function ownShape(def: NodeDef, node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef { if (!def.declaresPorts && !def.derivePorts) return def; const own = (side: 'inputs' | 'outputs') => declaredOn(def, node.ports ?? [], side).map((p) => def.declaredPort ? def.declaredPort(p, node, graph, lookup) : portDefOf(p), ); const grown = def.derivePorts?.({ node, graph, lookup }) ?? {}; const side = (key: 'inputs' | 'outputs') => { const ports = [...def[key], ...(grown[key] ?? []), ...own(key)]; return grown.inOrder ? ports : execFirst(ports); }; return { ...def, label: grown.label ?? def.label, inputs: side('inputs'), outputs: side('outputs'), }; } /** The group's outputs, retyped to what its inputs agree on; `any` leaves the definition alone. */ function withSharedType( def: NodeDef, group: SharedPortGroup, node: GraphNode, graph: Graph, lookup: GraphLookup, trail: Trail, ): NodeDef { const agree = group.agree ?? agreedType; const type = agree(group.inputs.map((key) => feedingType(node, key, graph, lookup, trail))); if (type === 'any') return def; return { ...def, outputs: def.outputs.map((p) => (group.outputs.includes(p.key) ? { ...p, type } : p)), }; } /** * What the edge into that input carries, or undefined while nothing is connected to it: the type * the feeding node resolves to, which is what the edge itself is drawn in. */ function feedingType( node: GraphNode, key: string, graph: Graph, lookup: GraphLookup, trail: Trail, ): PortType | undefined { const edge = edgesInto(graph, node.id, key)[0]; if (!edge) return undefined; const from = findNode(graph, edge.from.node); const def = from && shapedDef(from, graph, lookup, trail); return def && splitDef(def, from).outputs.find((p) => p.key === edge.from.port)?.type; } /** * `any` inputs painted as what flows into them: an unconnected one stays grey, a connected one takes * the colour of its own edge, so a group feeding two different types is visible as two colours. * Only `paintType` is set – the port's `type` stays `any`, so `canConnect` keeps taking everything. */ function paintedInputs(node: GraphNode, graph: Graph, def: NodeDef, lookup: GraphLookup): PortDef[] | null { if (!def.inputs.some((port) => port.type === 'any' && !port.ghost)) return null; const trail = newTrail(); return def.inputs.map((port) => { if (port.type !== 'any' || port.ghost) return port; const fed = feedingType(node, port.key, graph, lookup, trail); return fed === undefined || fed === 'any' || fed === 'exec' ? port : { ...port, paintType: fed }; }); } /** 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')]; } /** One end of an edge naming a key its node no longer has. */ interface GhostEnd { edge: GraphEdge; key: string; isOutput: boolean; } /** Read from the edge list, not from the ports: what is looked for is exactly what has no port. */ function ghostEnds(node: GraphNode, graph: Graph, def: NodeDef): GhostEnd[] { const inputs = new Set(def.inputs.map((p) => p.key)); const outputs = new Set(def.outputs.map((p) => p.key)); const ends: GhostEnd[] = []; for (const edge of graph.edges) { if (edge.to.node === node.id && !inputs.has(edge.to.port)) ends.push({ edge, key: edge.to.port, isOutput: false }); if (edge.from.node === node.id && !outputs.has(edge.from.port)) ends.push({ edge, key: edge.from.port, isOutput: true }); } return ends; } /** `any` because the type the key once had is exactly what is no longer known. */ function ghostPort(key: string): PortDef { return { key, label: key, type: 'any', inline: false, ghost: true }; } /** * The definition as the canvas draws it: the real ports with their pin colours (`paintedInputs`), * then the keys only edges still name. The ghosts go last and skip `execFirst`, so no real row moves * because of one – and none of this reaches `resolveDef`, which the interpreter, the compilers and * `ConnectCommand` read as the truth. */ export function drawnDef(node: GraphNode, graph: Graph, def: NodeDef, lookup: GraphLookup): NodeDef { const painted = paintedInputs(node, graph, def, lookup); const ends = ghostEnds(node, graph, def); if (ends.length === 0) return painted ? { ...def, inputs: painted } : def; const side = (isOutput: boolean) => [...new Set(ends.filter((e) => e.isOutput === isOutput).map((e) => e.key))].map(ghostPort); return { ...def, inputs: [...(painted ?? def.inputs), ...side(false)], outputs: [...def.outputs, ...side(true)], }; } /** Why a node warns although its own settings are sound: edges left hanging on keys that are gone. */ export function ghostProblem(node: GraphNode, graph: Graph, def: NodeDef): string | null { const count = ghostEnds(node, graph, def).length; if (count === 0) return null; return count === 1 ? '1 edge on a port that is gone' : `${count} edges on ports that are gone`; } /** * 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. A * `Lerp` depends on another asset as well, the curve it eases along (#247), and says so here too. */ export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): string | null { if (node.type === ON_BEHAVIOR) return listenProblem(node, lookup, ownerId); if (node.type !== RUN_BEHAVIOR) return lerpProblem(node, lookup); 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 (callEntries(program.graph).length === 0) return 'No start or On start node – does nothing'; return null; } /** Why an `On behavior` never fires: it hears nothing, itself, or a graph without an end to reach. */ function listenProblem(node: GraphNode, lookup: GraphLookup, ownerId: string): string | null { const id = textOf(node.params[BEHAVIOR_PARAM] ?? ''); if (!id) return 'No behavior picked'; const program = lookup.behavior(id); if (!program) return 'That behavior is gone'; if (ownerId && listensTo(lookup, id, ownerId)) return 'A behavior cannot listen to itself'; if (!terminalNode(program.graph, BEHAVIOR_END)) return 'No End node – never fires'; return null; } /** The behaviors the `On behavior` nodes of that graph name, picked or not found alike. */ export function heardBehaviors(graph: Graph): string[] { return graph.nodes .filter((n) => n.type === ON_BEHAVIOR) .map((n) => textOf(n.params[BEHAVIOR_PARAM] ?? '')) .filter((id) => id !== ''); } /** Whether `from` is `target` or hears it, however many behaviors lie between. */ export function listensTo( lookup: GraphLookup, from: string, target: string, seen: Set = new Set(), ): boolean { if (from === target) return true; if (seen.has(from)) return false; seen.add(from); const program = lookup.behavior(from); return program ? heardBehaviors(program.graph).some((id) => listensTo(lookup, id, target, seen)) : false; } /** * 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) ?? '' }`; }