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 { easingProblem } from './nodes/easing'; 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'; /** The two ends of a shader function (#303) and the node calling one, see `core/shader/functions`. */ export const SHADER_FUNCTION_INPUT = 'ShaderFunctionInput'; export const SHADER_FUNCTION_OUTPUT = 'ShaderFunctionOutput'; export const SHADER_CALL = 'ShaderCall'; /** The two ends of a sound FX graph and the behavior node naming its parameters (#311). */ export const SOUND_FX_INPUT = 'SoundFxInput'; export const SOUND_FX_OUTPUT = 'SoundFxOutput'; export const SOUND_FX_PARAMETERS = 'SoundFxParameters'; const PLAY_SOUND = 'PlaySound'; /** * The nodes of an instrument's graph that are its own (#331), and the behavior node naming its * parameters. The effects it shares with a sound FX keep their types. */ export const INSTRUMENT_NOTES = 'InstrumentNotes'; export const INSTRUMENT_SAMPLE = 'InstrumentSample'; export const INSTRUMENT_SOUND_FX = 'InstrumentSoundFx'; export const INSTRUMENT_PARAMETERS = 'InstrumentParameters'; export const INSTRUMENT_OUTPUT = 'InstrumentOutput'; export const INSTRUMENT_PARAMETER_VALUES = 'InstrumentParameterValues'; const PLAY_NOTE = 'PlayNote'; export const BEHAVIOR_PARAM = 'behavior'; export const FUNCTION_PARAM = 'function'; export const VARIABLES_PARAM = 'variablesAsset'; export const SOUND_FX_PARAM = 'soundFxAsset'; /** Whose parameters a `Sound FX parameters` node sets. */ export const SOUND_FX_SETTING: PropertyDef = { key: SOUND_FX_PARAM, label: 'Sound FX', kind: 'asset', default: '', assetTypes: ['soundfx'], }; /** Whose parameters an `Instrument parameters` node sets: the same key, another kind of asset. */ export const INSTRUMENT_SETTING: PropertyDef = { ...SOUND_FX_SETTING, label: 'Instrument', assetTypes: ['instrument'], }; /** The sound FX – or the instrument (#331) – whose parameters a node names. */ export function soundFxSourceOf(params: PropertyBag): string { return textOf(params[SOUND_FX_PARAM] ?? ''); } /** 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; } /** Nodes whose declared ports are parameters of the sound FX or the instrument they name. */ export function declaresSoundFxPorts(type: string): boolean { return type === SOUND_FX_PARAMETERS || type === INSTRUMENT_PARAMETER_VALUES; } /** The node declaring the parameters of a sound FX or an instrument, each with a default. */ export function declaresParameters(type: string): boolean { return type === SOUND_FX_INPUT || type === INSTRUMENT_PARAMETERS; } /** 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); } /** A graph has one of each: its ports are those of every node calling the graph. */ export function isTerminalType(type: string): boolean { return ( type === BEHAVIOR_START || type === BEHAVIOR_END || type === SHADER_FUNCTION_INPUT || type === SHADER_FUNCTION_OUTPUT || type === SOUND_FX_INPUT || type === SOUND_FX_OUTPUT || type === INSTRUMENT_PARAMETERS || type === INSTRUMENT_OUTPUT ); } /** The terminal whose ports a caller passes in, rather than takes out. */ export function isEntryTerminal(type: string): boolean { return type === BEHAVIOR_START || type === SHADER_FUNCTION_INPUT || type === SOUND_FX_INPUT; } 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) }; // The machine a start node's input carries is what the field offers before anyone picks. return isTaggedKind(port.kind) && port.kind !== 'object' ? { ...def, field: flatDef(port.key, port.kind, { value: port.machine }) } : 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` or a `Set sound FX parameters` 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 === SHADER_CALL) return functionCallProblem(node, lookup, ownerId); if (declaresSoundFxPorts(node.type)) return soundFxParametersProblem(node, lookup); if (node.type === INSTRUMENT_SOUND_FX) return nestedFxProblem(node, lookup); if (node.type !== RUN_BEHAVIOR) return easingProblem(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 otherMachineProblem(node, program.graph, lookup); } /** * A machine picked here other than the one the start node's input carries: the called graph asks for * the states of its own, so every one of them would read as never entered. */ function otherMachineProblem(node: GraphNode, graph: Graph, lookup: GraphLookup): string | null { for (const port of terminalNode(graph, BEHAVIOR_START)?.ports ?? []) { const picked = textOf(node.params[port.key] ?? ''); if (port.kind !== 'statemachine' || !port.machine || !picked || picked === port.machine) continue; const machine = lookup.machine(port.machine); return `"${port.key}" expects ${machine ? `"${machine.name}"` : 'a machine that is gone'}`; } 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; } /** Why a shader function's call has nothing to set in its place (#303). */ function functionCallProblem(node: GraphNode, lookup: GraphLookup, ownerId: string): string | null { const id = textOf(node.params[FUNCTION_PARAM] ?? ''); if (!id) return 'No function picked'; if (!lookup.shaderFunction(id)) return 'That function is gone'; if (ownerId && callsFunction(lookup, id, ownerId)) return 'A function cannot call itself'; return null; } function soundFxParametersProblem(node: GraphNode, lookup: GraphLookup): string | null { const noun = node.type === INSTRUMENT_PARAMETER_VALUES ? 'instrument' : 'sound FX'; const id = soundFxSourceOf(node.params); if (!id) return `No ${noun} picked`; return lookup.soundFx(id) ? null : `That ${noun} is gone`; } /** A Sound FX node in an instrument (#331) without its FX lets the sound through untouched. */ function nestedFxProblem(node: GraphNode, lookup: GraphLookup): string | null { const id = textOf(node.params.fx ?? ''); if (!id) return 'No sound FX picked'; return lookup.soundFx(id) ? null : 'That sound FX is gone'; } /** * A `Play sound` whose parameters come from a node naming another sound FX than the one it plays: * the names that FX does not know are left out, so the values would go nowhere. Only said where both * are picked in place – one coming over an edge may be either. */ export function soundFxParamsMismatch(node: GraphNode, graph: Graph): string | null { const playsNote = node.type === PLAY_NOTE; if (node.type !== PLAY_SOUND && !playsNote) return null; const pick = playsNote ? 'instrument' : 'fx'; if (edgesInto(graph, node.id, pick).length > 0) return null; const played = textOf(node.params[pick] ?? ''); const edge = edgesInto(graph, node.id, 'fxParams')[0]; const from = edge && findNode(graph, edge.from.node); if (!played || !from || !declaresSoundFxPorts(from.type)) return null; const named = soundFxSourceOf(from.params); if (!named || named === played) return null; return playsNote ? 'The parameters are for another instrument' : 'The parameters are for another sound FX'; } /** The ids the nodes of one type in that graph name in `param`, picked or not found alike. */ function namedBy(graph: Graph, type: string, param: string): string[] { return graph.nodes .filter((n) => n.type === type) .map((n) => textOf(n.params[param] ?? '')) .filter((id) => id !== ''); } /** Whether `from` is `target` or leads to it through `next`, however many steps lie between. */ function reaches( from: string, target: string, next: (id: string) => string[], seen = new Set(), ): boolean { if (from === target) return true; if (seen.has(from)) return false; seen.add(from); return next(from).some((id) => reaches(id, target, next, seen)); } /** The functions the calls in that graph name, picked or not found alike. */ export function calledFunctions(graph: Graph): string[] { return namedBy(graph, SHADER_CALL, FUNCTION_PARAM); } /** Whether `from` is `target` or calls it, however many functions lie between. */ export function callsFunction(lookup: GraphLookup, from: string, target: string): boolean { return reaches(from, target, (id) => { const called = lookup.shaderFunction(id); return called ? calledFunctions(called.graph) : []; }); } /** Whether `from` is `target` or hears it, however many behaviors lie between. */ function listensTo(lookup: GraphLookup, from: string, target: string): boolean { return reaches(from, target, (id) => { const program = lookup.behavior(id); return program ? namedBy(program.graph, ON_BEHAVIOR, BEHAVIOR_PARAM) : []; }); } /** * 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) ?? '' }`; }