Files
feuerwerk/src/core/graph/GraphInterpreter.ts
T
adminandClaude Opus 5.5 ed7872c893 Let a called behavior share the caller's local state machines
A local state machine started over in every Run behavior call, so a
state set in a sub-behavior was gone by the next call. A character
controller split into sub-behaviors could therefore never remember
that it was attacking. Scene or global scope would keep the state, but
then every character shares it.

- A call now runs on the states of the instance that runs it, however
  deep. A state one call sets is still set in the next one, and the
  call begins in the start state like the instance does.
- A move made inside a call is reported like one the caller made. The
  caller's On state entered and On state exited hear it, the event
  nodes of the called graph stay silent as before.
- Two objects running the same behavior still hold a state each.
- This reverses the rule from #229 that local machines of a called
  behavior are not kept. Keep stays a switch for variables only.

Tests:
- Two unit tests that pinned the old rule now expect the shared state.
- New world test: a sub-behavior closes a door per object, the
  caller's events hear it, and the next call finds it closed.
- Counter-check: with the old call context the 3 tests fail.
- Gate on melektaus-atx: 239 files, 3600 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:38:06 +02:00

401 lines
17 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, BEHAVIOR_START, callEntries } 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 flow input this graph's end node was reached by last, once it was reached at all (#294). */
ended?: string;
}
/** How a graph was left: through which flow input of its end node, and with what. */
export interface Ending {
port: string;
values: 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;
/**
* What the start node hands out while this graph runs on its own events and not as a call: the
* inputs of the `On behavior` node listening to it (#294). Set by whoever sends the pulses.
*/
given: Record<string, Value> | 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() };
}
/** A frame of the graph itself, its start node holding what was given from outside. */
private ownFrame(): Frame {
const frame = this.emptyFrame(this.graph);
const start = this.given ? frame.index.ofType(BEHAVIOR_START)[0] : undefined;
if (start && this.given) frame.outputCache.set(start.id, { ...this.given });
return frame;
}
eventNodes(type: string): readonly GraphNode[] {
return this.indexOf(this.graph).ofType(type);
}
/**
* Runs the flow of one event node, whichever it is. `port` is the exec output the pulse leaves
* through – a state event names one.
*/
pulse(node: GraphNode, ctx: NodeContext, outputs: Record<string, Value> = {}, port = 'exec'): void {
this.frame = this.ownFrame();
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);
}
/**
* A data input of an event node, read ahead of its pulse: what decides whether the event concerns
* the node at all (#160). Null for an input the node does not have.
*/
eventInput(node: GraphNode, key: string, ctx: NodeContext): Value {
this.frame = this.ownFrame();
this.steps = 0;
const port = this.def(node)?.inputs.find((p) => p.key === key);
return port ? this.inputValue(node, port, ctx) : null;
}
/** Every data input of an event node, read ahead of a pulse like `eventInput`. */
eventInputs(node: GraphNode, ctx: NodeContext): Record<string, Value> {
this.frame = this.ownFrame();
this.steps = 0;
return this.collectInputs(node, ctx);
}
/** How the last pulse left this graph, or null when it never reached the end node. */
get ending(): Ending | null {
return this.endingOf(this.frame);
}
private endingOf(frame: Frame): Ending | null {
if (frame.ended === undefined) return null;
const end = frame.index.ofType(BEHAVIOR_END)[0];
return { port: frame.ended, values: (end && frame.outputCache.get(end.id)) ?? {} };
}
/**
* 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) : null;
const produced = { ...result.outputs, ...returned?.values };
if (node.type === BEHAVIOR_END) this.frame.ended = entry;
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];
// The way out the called graph took comes first, so `exec` reads as "and then" (#294).
const ways = returned && returned.port !== 'exec' ? [returned.port, ...next] : next;
for (const port of ways) 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 how it was left; null when it never got there. 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): Ending | null {
const program = this.lookup.behavior(call.behaviorId);
if (!program) return null;
if (this.callStack.includes(call.behaviorId)) throw new Error(`Behavior "${program.name}" runs itself`);
const entries = callEntries(program.graph);
if (entries.length === 0) return null;
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);
// Nothing of the caller's variables, but its local machines: a local machine belongs to the
// running instance however deep the call, so what a call sets is still set on the next one,
// and the move is heard like one the caller made itself.
const inner: NodeContext = { ...ctx, variables };
try {
for (const entry of entries) {
this.trace?.onNode(entry.id);
this.follow(entry, call.entry, inner, depth + 1);
}
return this.endingOf(this.frame);
} 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);
}
}