import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph'; import { type GraphLookup, NO_LOOKUP } from './lookup'; import { BEHAVIOR_END, callEntries, resolveDef, terminalNode } from './nested'; import type { PropertyBag, PropertyValue } from '../model/Properties'; import type { NodeContext } from './NodeContext'; import { type CallResult, hasExecInput, inlineValue, isEventNode, type NodeDef, type PortDef, type RepeatResult, } from './NodeRegistry'; import { machineIdOf } from './stateMachine'; import { isObjectPlace, 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() }; } /** * The variables a call starts with: today's declarations at their defaults, except that a kept one * resumes at the value the last call from the same site left – unless that value is gone or no * longer of the declared sort (#229). */ export function resumedVariables( defaults: PropertyBag, left: PropertyBag | undefined, kept: readonly string[], ): PropertyBag { const variables: PropertyBag = {}; for (const [key, value] of Object.entries(defaults)) { const was = kept.includes(key) ? left?.[key] : undefined; variables[key] = was !== undefined && sameSort(was, value) ? was : value; } return variables; } /** Whether `was` may stand where `value` is declared – an object's place counts as a sort of its own. */ function sameSort(was: PropertyValue, value: PropertyValue): boolean { return typeof was === typeof value && isObjectPlace(was) === isObjectPlace(value); } /** * 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(); /** What calls left in their variables, by call site: the chain of `Run behavior` node ids that led there. */ readonly keptCalls = new Map(); private frame: Frame; private callStack: string[] = []; private callSites: 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.callSites = []; 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, node.id, 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 (or, lacking one, its `On start` nodes) 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 – and the variables it keeps, which wait for the next call * from the same site. */ private call(call: CallResult, site: string, 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 entries = callEntries(program.graph); if (entries.length === 0) return {}; const sites = [...this.callSites, site].join('/'); const kept = program.kept ?? []; const variables = resumedVariables(program.defaults, this.keptCalls.get(sites), kept); if (kept.length > 0) this.keptCalls.set(sites, variables); const outer = this.frame; this.frame = emptyFrame(program.graph); for (const entry of entries) { this.frame.outputCache.set(entry.id, { ...call.inputs }); this.record(entry.id, call.inputs); } this.callStack.push(call.behaviorId); this.callSites.push(site); this.calledBehaviors.add(call.behaviorId); // Fresh states every call, and nothing of the caller's variables or states. 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. const inner: NodeContext = { ...ctx, variables, states: {}, stateChanged: (machineId, from, to) => { if (machineId in ctx.shared.states) ctx.stateChanged(machineId, from, to); }, }; try { for (const entry of entries) { this.trace?.onNode(entry.id); this.follow(entry, 'exec', inner, depth + 1); } const end = terminalNode(program.graph, BEHAVIOR_END); return (end && this.frame.outputCache.get(end.id)) ?? {}; } finally { this.callStack.pop(); this.callSites.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); } }