Files
feuerwerk/src/core/graph/GraphInterpreter.ts
T
adminandClaude Opus 5.5 a7e793f6de Keep many spawned behaviors from eating the frame
A scene with 200 spawned gold pickups, each with a Pickup behavior of
97 nodes, dropped the game to about 14 fps. The graphs themselves ran
only a dozen nodes per pickup and frame. The cost was the lookup: for
every data input the interpreter searched all edges and all nodes of
the graph linearly and computed the port shape of the node again.

- New module core/graph/graphIndex.ts builds tables once per graph:
  nodes by id, edges by node and port, node types. Port shapes are
  cached as well.
- BehaviorSystem shares one cache between all its instances and
  clears it in syncFromModel. A graph changed during a run applies
  from the next frame on, as before.
- An interpreter without a cache, as in most tests, searches as
  before.

Measured on the scene Performance test (headless per frame, browser
fps on a throwaway copy of the project):

- Headless: about 21 ms down to about 3.6 ms per frame.
- Browser, about 505 entities: 13.6 fps up to 23 fps.
- Browser, about 320 entities: 32.3 fps up to 48 fps.
- After the fix behaviors take about 15 % of the browser profile.
  The rest is the editor mirroring the game into the model and
  rebuilding its panels, which is a separate change.

Tests:

- ./check.sh: 192 files, 2786 tests, CHECK EXIT 0.
- Full e2e run (local): ALL PASSED, 1551 checks.
- tests/core/graph-index.test.ts: 2 tests. The tables return the
  same as the search, in the same edge order. A graph changed during
  a run applies from the next frame.
- Counter-check: without clearing the cache in syncFromModel the
  second test fails with ['before'].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 10:36:10 +02:00

370 lines
16 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 { 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<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;
index: GraphIndex;
outputCache: Map<string, Record<string, Value>>;
dataCache: Map<string, Record<string, Value>>;
}
/**
* 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<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>();
/** What calls left in their variables, by call site: the chain of `Run behavior` node ids that led there. */
readonly keptCalls = new Map<string, PropertyBag>();
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<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.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<string, Value> = {}, 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<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 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<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 = 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<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 {
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);
}
}