Files
feuerwerk/src/core/graph/GraphInterpreter.ts
T
adminandClaude Opus 5 d3563897d8
game-maker / suite (push) Failing after 2m22s
game-maker / gate (push) Successful in 2m53s
Two compilers that walk a graph the same way walk it through one emitter (#85)
Refactoring round two: the particle and shader compilers share emitGraph.ts (cycle detection,
duplicate names, emit order) and keep only their rules. FontLibrary and TextureLibrary sit on
FileLibrary (base URL, loading/failed bookkeeping, listeners); SoundLibrary keeps its own
contract. The three Create*Command share ImportFileCommand, the three renames share
RenameCommand - class names stay. bandBounds() in framing.ts and portNameField() in
portSection.ts replace in-file clones, and three comments that told history now state the rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:28:28 +02:00

265 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, Value>): 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<string, Record<string, Value>>;
dataCache: Map<string, Record<string, Value>>;
}
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<string, Record<string, number>>();
/** Assets run as a node so far – the graph editor highlights them from this instance's trace. */
readonly calledBehaviors = new Set<string>();
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<string, Value> = {},
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<string, Value> {
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<string, Value> {
const inputs: Record<string, Value> = {};
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<string, Value> {
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<string, Value> = {};
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<string, Value>): 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);
}
}