import { blockProblem, findMode, MATCH_INPUT, matchOf, searchOutcome, stopsAtHit } from './findBlock'; import type { Graph, GraphNode } from './Graph'; import { type GraphCache, type GraphIndex, searchingIndex } from './graphIndex'; import { type GraphLookup, NO_LOOKUP } from './lookup'; import { BEHAVIOR_END, callEntries, terminalNode } from './nested'; import { fieldValue, splitField, withSplitValues } from './portSplits'; 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 { asBoolean, 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; index: GraphIndex; outputCache: Map>; dataCache: 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, /** Without one the graph is searched every time, so it may change between two pulses. */ private readonly cache: GraphCache | null = null, ) { this.frame = this.emptyFrame(graph); } private indexOf(graph: Graph): GraphIndex { return this.cache ? this.cache.indexOf(graph, this.lookup) : searchingIndex(graph, this.lookup); } private emptyFrame(graph: Graph): Frame { return { graph, index: this.indexOf(graph), outputCache: new Map(), dataCache: new Map() }; } eventNodes(type: string): readonly GraphNode[] { return this.indexOf(this.graph).ofType(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.pulse(node, ctx, outputs, port); fired++; } return fired; } /** Runs the flow of one event node, whichever it is; `fire` is this for every node of a type. */ pulse(node: GraphNode, ctx: NodeContext, outputs: Record = {}, port = 'exec'): void { this.frame = this.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); } /** * The `On state entered` / `On state exited` nodes watching one machine. 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. */ stateEventNodes(type: string, machineId: string): GraphNode[] { if (!machineId) return []; return this.eventNodes(type).filter( (node) => machineIdOf({ node, graph: this.graph, lookup: this.lookup }) === machineId, ); } private follow(node: GraphNode, port: string, ctx: NodeContext, depth: number): void { for (const edge of this.frame.index.outOf(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); if (result.find) this.search(node, result.find, 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 = this.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. The stop input is read the same way, after the * round's outputs are set, so a condition built on `item` asks about the element at hand. */ private loop(node: GraphNode, repeat: RepeatResult, ctx: NodeContext, depth: number): void { const outputs = this.frame.outputCache.get(node.id) ?? {}; const stopPort = repeat.stopWhen ? this.def(node)?.inputs.find((p) => p.key === repeat.stopWhen) : undefined; let stopped = false; 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 }; if (repeat.items) roundOutputs[repeat.items.port] = repeat.items.values[round] ?? null; this.frame.outputCache.set(node.id, roundOutputs); this.record(node.id, roundOutputs); if (stopPort && asBoolean(this.inputValue(node, stopPort, ctx))) { stopped = true; break; } this.follow(node, repeat.port, ctx, depth); } this.frame.dataCache.clear(); if (!repeat.stopped) return; const last = this.frame.outputCache.get(node.id) ?? outputs; const after = stopped ? last : { ...last, ...repeat.exhausted }; this.frame.outputCache.set(node.id, { ...after, [repeat.stopped]: stopped }); } /** * A Find's rounds: its `item` and `index` are set to one element after the other, and the Match * closing the block reads its test anew each time, the way a loop's stop input is read. Its mode * says whether the first true one ends the search; the Match then holds the outcome, and the flow * goes on from there. */ private search(find: GraphNode, values: readonly Value[], ctx: NodeContext, depth: number): void { const match = matchOf(this.frame.graph, find.id); const test = match && this.def(match)?.inputs.find((p) => p.key === MATCH_INPUT); if (!match || !test) { ctx.log(`Find: ${blockProblem(this.frame.graph, find) ?? 'no Match closes it'}`); return; } const mode = findMode(find); const hits: number[] = []; for (let round = 0; round < values.length; 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 = { item: values[round] ?? null, index: round }; this.frame.outputCache.set(find.id, roundOutputs); this.record(find.id, roundOutputs); if (asBoolean(this.inputValue(match, test, ctx))) { hits.push(round); if (stopsAtHit(mode)) break; } } this.frame.dataCache.clear(); const outcome = searchOutcome(mode, values, hits); this.frame.outputCache.set(find.id, outcome.find); this.frame.outputCache.set(match.id, outcome.match); this.trace?.onNode(match.id); this.record(match.id, outcome.match); this.follow(match, outcome.next, ctx, depth); } /** 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 = this.frame.index.into(node.id, port.key)[0]; if (!edge) return inlineValue(node, port); const source = this.node(edge.from.node); const split = source && splitField(source, edge.from.port); if (!split) return this.readOutput(edge.from.node, edge.from.port, ctx); return fieldValue(this.readOutput(edge.from.node, split.split.port, ctx), split.field); } /** 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 { if (!this.trace?.onValues) return; const node = this.node(nodeId); this.trace.onValues(this.frame.graph, nodeId, node ? withSplitValues(node, values) : values); } /** Without split field ports: a node produces its wholes, and `inputValue` picks the fields. */ private def(node: GraphNode) { return this.frame.index.def(node); } private node(id: string): GraphNode | undefined { return this.frame.index.node(id); } }