import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph'; import { type GraphLookup, NO_LOOKUP } from './lookup'; import { BEHAVIOR_END, BEHAVIOR_START, resolveDef, terminalNode } from './nested'; import type { NodeContext } from './NodeContext'; import { type CallResult, hasExecInput, inlineValue, isEventNode, type NodeDef, type PortDef, type RepeatResult, } from './NodeRegistry'; import { machineIdOf } from './stateMachine'; import type { Value } from './values'; /** How much work one pulse may do. A Repeat over a 10×20 board is 200 rounds, so this is generous. */ const MAX_STEPS_PER_FIRE = 100_000; /** * How far exec edges may nest. The interpreter recurses along them, so this is what keeps a flow * feeding back into itself off the call stack – the step budget cannot, since a legitimate pulse * may run tens of thousands of steps. */ const MAX_DEPTH = 1000; export interface TraceListener { onEdge(edgeId: string): void; onNode(nodeId: string): void; /** What a node's outputs held when it last produced them, in the graph it belongs to. */ onValues?(graph: Graph, nodeId: string, values: Record): void; } /** One graph's pulse state. A nested behavior runs in its own – node ids are unique per graph only. */ interface Frame { graph: Graph; outputCache: Map>; dataCache: Map>; } function emptyFrame(graph: Graph): Frame { return { graph, outputCache: new Map(), dataCache: new Map() }; } /** * Runs one behavior graph for one instance. An event node starts a *pulse*; the interpreter follows * exec edges and pulls data inputs from the connected output, evaluating pure data nodes lazily and * reading exec nodes' outputs from the pulse cache. */ export class GraphInterpreter { /** Per-node state that persists across pulses (timer accumulators etc.). */ readonly nodeState = new Map>(); /** Assets run as a node so far – the graph editor highlights them from this instance's trace. */ readonly calledBehaviors = new Set(); private frame: Frame; private callStack: string[] = []; private steps = 0; trace: TraceListener | null = null; constructor( readonly graph: Graph, private readonly lookup: GraphLookup = NO_LOOKUP, ) { this.frame = emptyFrame(graph); } eventNodes(type: string): GraphNode[] { return this.graph.nodes.filter((n) => n.type === type); } fire( type: string, ctx: NodeContext, outputs: Record = {}, filter?: (node: GraphNode) => boolean, /** Which of the event node's exec outputs the pulse leaves through – a state event names one. */ port = 'exec', ): number { let fired = 0; for (const node of this.eventNodes(type)) { if (filter && !filter(node)) continue; this.frame = emptyFrame(this.graph); this.callStack = []; this.steps = 0; this.frame.outputCache.set(node.id, { ...outputs }); this.trace?.onNode(node.id); this.record(node.id, outputs); this.follow(node, port, ctx, 0); fired++; } return fired; } /** * `On state entered` / `On state exited` for one move: only the nodes watching that machine, and * out of the one output the state stands for. A node whose machine input carries a running value * rather than a pick has no state outputs at all, so it is never among them. */ fireStateEvent(type: string, ctx: NodeContext, machineId: string, stateId: string): number { if (!machineId || !stateId) return 0; return this.fire( type, ctx, {}, (node) => machineIdOf({ node, graph: this.graph, lookup: this.lookup }) === machineId, stateId, ); } private follow(node: GraphNode, port: string, ctx: NodeContext, depth: number): void { for (const edge of edgesOutOf(this.frame.graph, node.id, port)) { const target = this.node(edge.to.node); if (!target) continue; this.trace?.onEdge(edge.id); this.execute(target, ctx, depth + 1, edge.to.port); } } private execute(node: GraphNode, ctx: NodeContext, depth: number, entry: string): void { if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (endless loop?)'); if (depth > MAX_DEPTH) throw new Error('Behavior graph nested too deeply (a flow feeding back on itself?)'); const def = this.def(node); if (!def?.run) return; const inputs = this.collectInputs(node, ctx); const result = def.run(ctx, inputs, node.params, def, entry, this.lookup); const returned = result.call ? this.call(result.call, ctx, depth) : {}; const produced = { ...result.outputs, ...returned }; if (!def.read) this.frame.outputCache.set(node.id, produced); this.trace?.onNode(node.id); if (this.trace?.onValues) this.record(node.id, def.read ? this.readAll(node, def, ctx) : produced); if (result.repeat) this.loop(node, result.repeat, ctx, depth); const next = result.next === undefined ? [] : Array.isArray(result.next) ? result.next : [result.next]; for (const port of next) this.follow(node, port, ctx, depth); } /** * Runs another behavior from its start node to its end node and returns what the end node was * handed. The nested graph gets its own frame and a fresh copy of its variable defaults, so it is * a call and not an inlining: nothing of it survives the return but the values it hands back. */ private call(call: CallResult, ctx: NodeContext, depth: number): Record { const program = this.lookup.behavior(call.behaviorId); if (!program) return {}; if (this.callStack.includes(call.behaviorId)) throw new Error(`Behavior "${program.name}" runs itself`); const start = terminalNode(program.graph, BEHAVIOR_START); if (!start) return {}; const outer = this.frame; this.frame = emptyFrame(program.graph); this.frame.outputCache.set(start.id, { ...call.inputs }); this.record(start.id, call.inputs); this.callStack.push(call.behaviorId); this.calledBehaviors.add(call.behaviorId); try { this.trace?.onNode(start.id); // Fresh variables and fresh states alike: a call keeps nothing, and sees nothing of the // caller's. What it does to a local machine therefore dies with the call and is not reported; // a `global` machine is the run's, so moving that one is a move like any other. this.follow( start, 'exec', { ...ctx, variables: { ...program.defaults }, states: {}, stateChanged: (machineId, from, to) => { if (machineId in ctx.shared.states) ctx.stateChanged(machineId, from, to); }, }, depth + 1, ); const end = terminalNode(program.graph, BEHAVIOR_END); return (end && this.frame.outputCache.get(end.id)) ?? {}; } finally { this.callStack.pop(); this.frame = outer; } } /** * One round per count, the loop node's `index` output carrying the round number. Data nodes are * cached for the whole pulse, so the round has to drop that cache first – otherwise every round * would read the values the first one computed. */ private loop(node: GraphNode, repeat: RepeatResult, ctx: NodeContext, depth: number): void { const outputs = this.frame.outputCache.get(node.id) ?? {}; for (let round = 0; round < repeat.times; round++) { if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (endless loop?)'); this.frame.dataCache.clear(); const roundOutputs = { ...outputs, [repeat.index]: round }; this.frame.outputCache.set(node.id, roundOutputs); this.record(node.id, roundOutputs); this.follow(node, repeat.port, ctx, depth); } this.frame.dataCache.clear(); } /** Resolves every data input: connected → pulled from the source, else the inline param. */ private collectInputs(node: GraphNode, ctx: NodeContext): Record { const inputs: Record = {}; for (const port of this.def(node)?.inputs ?? []) if (port.type !== 'exec') inputs[port.key] = this.inputValue(node, port, ctx); return inputs; } private inputValue(node: GraphNode, port: PortDef, ctx: NodeContext): Value { const edge = edgesInto(this.frame.graph, node.id, port.key)[0]; return edge ? this.readOutput(edge.from.node, edge.from.port, ctx) : inlineValue(node, port); } /** Value of one output port: from the pulse cache, or by evaluating a pure data node. */ private readOutput(nodeId: string, port: string, ctx: NodeContext): Value { const cached = this.frame.outputCache.get(nodeId) ?? this.frame.dataCache.get(nodeId); if (cached) return cached[port] ?? null; const source = this.node(nodeId); if (!source) return null; const def = this.def(source); if (!def) return null; if (def.read) { this.trace?.onNode(nodeId); const values = this.readAll(source, def, ctx); this.record(nodeId, values); return values[port] ?? null; } // Exec nodes that have not run in this pulse have no value yet. if (isEventNode(def) || hasExecInput(def) || !def.run) return null; if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (data cycle?)'); // Mark before evaluating so a data cycle reads null instead of recursing forever. this.frame.dataCache.set(nodeId, {}); const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params, def, '', this.lookup).outputs ?? {}; this.frame.dataCache.set(nodeId, outputs); this.trace?.onNode(nodeId); this.record(nodeId, outputs); return outputs[port] ?? null; } private readAll(node: GraphNode, def: NodeDef, ctx: NodeContext): Record { const input = (key: string): Value => { const found = def.inputs.find((p) => p.key === key); return found ? this.inputValue(node, found, ctx) : null; }; // `read` may hand back a whole bag (the Variables node returns the object's variables, live). const all = def.read?.(ctx, node.params, input, def, this.lookup) ?? {}; const own: Record = {}; for (const port of def.outputs) if (port.key in all) own[port.key] = all[port.key] ?? null; return own; } private record(nodeId: string, values: Record): void { this.trace?.onValues?.(this.frame.graph, nodeId, values); } private def(node: GraphNode) { return resolveDef(node, this.frame.graph, this.lookup); } private node(id: string): GraphNode | undefined { return this.frame.graph.nodes.find((n) => n.id === id); } }