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>
401 lines
17 KiB
TypeScript
401 lines
17 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, 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);
|
||
}
|
||
}
|