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>
370 lines
16 KiB
TypeScript
370 lines
16 KiB
TypeScript
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);
|
||
}
|
||
}
|