An object could only live in a dedicated object variable, kept in slots
beside the variable bag. Struct fields and list items could not hold
one, so a spawned object packed into a struct or list was lost. This
replaces the slots with one mechanism for all three places.
- "No object" has one resting form everywhere: {"$object": ""}. A
running game can tell an object slot from an empty text that way.
- During play the object handle lives directly in the value, also
nested in structs and lists. objectSlots.ts and the special paths for
Keep and Properties are gone.
- liveObjects.ts strips objects to (null) at every exit: overrides the
editor run writes back to the scene, saved values of persistent
Variables assets, live values in the inspector, and the world
snapshot of the test hooks (objects as names there).
- A destroyed object reads as (null) through isAlive, alone, in a
struct field or as a list item.
- "+ Field" and "+ Item" offer Object. The inspector and the list tab
show a fixed (null) row without input.
- Lists from Raycast all stay objects when written to a variable.
Before they turned into names.
- A struct that only changes by an object writes no override. A list
that grows by an object writes [(null)], its length really changed.
Found on the way: CI at 98211a8 failed only in e2e 21d. #237 added
asset to the field kinds without updating that fixed expectation. 21d
now expects ...,animation,asset,object,struct.
No project on the server has object variables, so no old data is
affected.
Tests:
- ./check.sh: 169 files, 2475 tests, CHECK EXIT 0.
- e2e 6, 21, 23, 32: ALL PASSED, 213 checks.
- object-variable.test.ts rewritten: variable, struct field and list
item over several frames, destroyed objects, nothing in scene, file
or save, editor run reload, Keep.
- New e2e 21d-ii adds an Object item by click. 21d and 23k updated.
- 7 counter-probes, each fails 1 to 9 tests. Originals back via cmp.
- Not tested on its own: stripping of inspector live values and the
names in the test snapshot, the "+ Field -> Object" menu by click.
- e2e check total 1407, only calculated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
|
||
import { type GraphLookup, NO_LOOKUP } from './lookup';
|
||
import { BEHAVIOR_END, callEntries, resolveDef, terminalNode } from './nested';
|
||
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 { 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;
|
||
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() };
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
) {
|
||
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.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);
|
||
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, 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);
|
||
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 = 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.
|
||
*/
|
||
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);
|
||
}
|
||
}
|