From 6702ff8abe56091253fa89525babd70c299861af Mon Sep 17 00:00:00 2001 From: root Date: Wed, 9 Sep 2026 20:01:49 +0200 Subject: [PATCH] Let a behavior be a node in another graph A behavior that has a Start and an End node can now be dropped into any other graph as a node of its own. What is declared on the start node becomes the inputs of that node, what hangs on the end node its outputs; the port list is derived from the sub graph, not copied, so declaring a port in one graph grows the node in the other without touching it. The node carries the behavior's name and the palette lists behaviors by name, so a call is one grab instead of a node plus a dropdown. An embedded behavior has no object and therefore no place for variables: every call starts on the asset defaults and communicates only through its ports. That is the conservative direction - state can still be added later, and shared state already has a home in a behavior object in the scene. Node ids are unique per graph only, so each call gets its own pulse cache; without that, two graphs would collide. Renaming a port used to silently drop the value typed on the calling node. It is carried along now, project wide, like the edges. --- README.md | 15 +- src/core/commands/graphCommands.ts | 14 +- src/core/commands/portCommands.ts | 195 +++++++++++++++++ src/core/graph/Graph.ts | 18 ++ src/core/graph/GraphInterpreter.ts | 105 +++++++-- src/core/graph/NodeRegistry.ts | 8 + src/core/graph/dropTarget.ts | 24 +- src/core/graph/index.ts | 1 + src/core/graph/nested.ts | 80 +++++++ src/core/graph/nodes/flow/BehaviorEnd.ts | 21 ++ src/core/graph/nodes/flow/BehaviorStart.ts | 19 ++ src/core/graph/nodes/flow/RunBehavior.ts | 24 ++ src/core/graph/nodes/index.ts | 6 + src/core/model/behaviorPrograms.ts | 15 ++ src/core/store/serialize.ts | 30 ++- src/editor/debugHook.ts | 1 + src/editor/graph/GraphCanvas.ts | 66 ++++-- src/editor/graph/NodePalette.ts | 34 +-- src/editor/graph/NodeView.ts | 32 ++- src/editor/graph/paletteEntries.ts | 46 ++++ src/editor/inspector/nodeSection.ts | 30 ++- src/editor/inspector/portSection.ts | 85 ++++++++ src/runtime/systems/BehaviorSystem.ts | 10 +- tests/core/graph.test.ts | 17 +- tests/core/nested-behavior.test.ts | 241 +++++++++++++++++++++ tests/core/node-catalog.test.ts | 26 +++ todo-notes/done/nested-behavior.md | 104 +++++++++ todo-notes/nested-behavior.md | 10 - tools/e2e/19-nested-behavior.mjs | 216 ++++++++++++++++++ tools/e2e/index.mjs | 2 + tools/lib/builders.mjs | 14 +- 31 files changed, 1402 insertions(+), 107 deletions(-) create mode 100644 src/core/commands/portCommands.ts create mode 100644 src/core/graph/nested.ts create mode 100644 src/core/graph/nodes/flow/BehaviorEnd.ts create mode 100644 src/core/graph/nodes/flow/BehaviorStart.ts create mode 100644 src/core/graph/nodes/flow/RunBehavior.ts create mode 100644 src/core/model/behaviorPrograms.ts create mode 100644 src/editor/graph/paletteEntries.ts create mode 100644 src/editor/inspector/portSection.ts create mode 100644 tests/core/nested-behavior.test.ts create mode 100644 todo-notes/done/nested-behavior.md delete mode 100644 todo-notes/nested-behavior.md create mode 100644 tools/e2e/19-nested-behavior.mjs diff --git a/README.md b/README.md index e0ff8e3e..10086bcf 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ shown on the node and editable in the inspector. viewport). The `Owner` node returns it; `Self` returns the behavior itself; `Parent` walks up from any object. - **Node set** (`src/core/graph/nodes/`, one file per node): events `On start`, `On update`, `On key down/up/held`, - `On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none) · flow `Branch`, `Sequence`, `Repeat` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`, + `On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none), `Start` · flow `Branch`, `Sequence`, `Repeat`, `Run behavior`, `End` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`, `Is asset` (is this object an instance of asset X – how you tell what you collided with), `Get/Set variable`, `Get/Set property`, `Get position` (local and world), `Is enabled`, literals, `Add/Subtract/Multiply/Divide`, `Min/Max/Abs/Sqrt/Clamp`, `Floor/Ceil/Round`, `Modulo`, `Random`, `Time`, `To text`, `To number`, `Join text`, @@ -117,6 +117,17 @@ shown on the node and editable in the inspector. `Object by name` taking the name the round put together (`"c" + index`). Two guards, not one: a pulse may take 100 000 steps (a loop legitimately runs long), but exec edges may only nest 1000 deep – that is what catches a flow feeding back into itself before the call stack does. +- **A behavior as a node** (nesting): a behavior that has a **Start** and an **End** node can be + run as a step of another behavior's graph. Every input declared on the start node becomes an + input port of the calling node, every output declared on the end node one of its outputs; the + node is drawn under the behavior's own name, and the palette lists it there too, so a call is + one pick. Declaring a port keeps both sides in step – renaming carries the typed-in value along, + removing takes the edges hanging on it in *every* graph that calls the behavior. A call is a + **call, not an inlining**: the nested graph runs in its own pulse cache with a fresh copy of its + variable defaults, so values travel in and out through the ports and nothing else. That makes a + call repeatable and keeps a nested behavior free of hidden state – for state, use a behavior + object in the scene (see *Cross-object state* below). A behavior that would run itself raises an + error instead of looping; there is one start and one end node per behavior. - **Cross-object state**: a behavior's variables *are* the properties of its object, so another graph reads and writes them with `Object by name` → `Get property` / `Set property`. That is how the example game keeps one coin counter for the whole run. @@ -496,7 +507,7 @@ npm run format # Prettier over src, tests and tools npm run check # typecheck + lint + format:check + tests, what CI would run npm run build # writes dist/ and regenerates index.html export GM_REMOTE=user@host # where the browser runs; see below -node tools/seed-e2e.mjs && node tools/e2e.mjs # 368 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 379 editor checks, screenshots in /tmp/gm-e2e-*.png node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs # 17 checks: the Tetris example, played end to end ``` diff --git a/src/core/commands/graphCommands.ts b/src/core/commands/graphCommands.ts index 8954d7f1..1a394833 100644 --- a/src/core/commands/graphCommands.ts +++ b/src/core/commands/graphCommands.ts @@ -6,7 +6,9 @@ import { type GraphNode, portsCompatible, } from '../graph/Graph'; +import { isTerminalType, resolveDef, terminalNode } from '../graph/nested'; import { nodeDef } from '../graph/NodeRegistry'; +import { behaviorLookup } from '../model/behaviorPrograms'; import { newId } from '../ids'; import type { PropertyBag, PropertyValue } from '../model/Properties'; import type { Project } from '../model/Project'; @@ -42,7 +44,12 @@ export class AddNodeCommand implements Command { execute(project: Project): void { requireNodeDef(this.node.type); - requireBehavior(project, this.assetId).graph.nodes.push(this.node); + const graph = requireBehavior(project, this.assetId).graph; + // The two terminals give the behavior its signature; a second one of either would make + // "which ports does this behavior have" ambiguous. + if (isTerminalType(this.node.type) && terminalNode(graph, this.node.type)) + throw new CommandError(`This behavior already has a ${nodeDef(this.node.type)?.label} node`); + graph.nodes.push(this.node); } undo(project: Project): void { @@ -153,8 +160,9 @@ export class ConnectCommand implements Command { const toNode = findNode(graph, this.edge.to.node); if (!fromNode || !toNode) throw new CommandError('Node not found'); if (fromNode === toNode) throw new CommandError('A node cannot connect to itself'); - const out = requireNodeDef(fromNode.type).outputs.find((p) => p.key === this.edge.from.port); - const inp = requireNodeDef(toNode.type).inputs.find((p) => p.key === this.edge.to.port); + const lookup = behaviorLookup(project); + const out = resolveDef(fromNode, lookup)?.outputs.find((p) => p.key === this.edge.from.port); + const inp = resolveDef(toNode, lookup)?.inputs.find((p) => p.key === this.edge.to.port); if (!out || !inp) throw new CommandError('Port not found'); if (!portsCompatible(out.type, inp.type)) throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`); diff --git a/src/core/commands/portCommands.ts b/src/core/commands/portCommands.ts new file mode 100644 index 00000000..2d3d4870 --- /dev/null +++ b/src/core/commands/portCommands.ts @@ -0,0 +1,195 @@ +import { + BEHAVIOR_PARAM, + BEHAVIOR_START, + type DeclaredPort, + type Graph, + type GraphEdge, + type GraphNode, + isValidPortName, + RUN_BEHAVIOR, + terminalNode, +} from '../graph'; +import type { PropertyValue } from '../model/Properties'; +import type { Project } from '../model/Project'; +import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list'; +import { type Command, CommandError } from './Command'; +import { requireBehavior } from './require'; + +/** Where a declared port shows up: on the terminal node itself, and on every node calling it. */ +interface PortSite { + graph: Graph; + nodeId: string; + /** Whether the port is an input there – the terminal and the call node face opposite ways. */ + asInput: boolean; +} + +/** + * A start node's port is an output there and an input on the calling node; an end node's port the + * other way round. Every site has to be kept in step, or an edge would hang on a port that is gone. + */ +function portSites(project: Project, assetId: string, terminalType: string): PortSite[] { + const asset = requireBehavior(project, assetId); + const sites: PortSite[] = []; + const terminal = terminalNode(asset.graph, terminalType); + const onStart = terminalType === BEHAVIOR_START; + if (terminal) sites.push({ graph: asset.graph, nodeId: terminal.id, asInput: !onStart }); + for (const other of project.assets) { + if (other.type !== 'behavior') continue; + for (const node of other.graph.nodes) { + if (node.type !== RUN_BEHAVIOR || node.params[BEHAVIOR_PARAM] !== assetId) continue; + sites.push({ graph: other.graph, nodeId: node.id, asInput: onStart }); + } + } + return sites; +} + +function edgesOnPort(site: PortSite, key: string): (edge: GraphEdge) => boolean { + return (edge) => + site.asInput + ? edge.to.node === site.nodeId && edge.to.port === key + : edge.from.node === site.nodeId && edge.from.port === key; +} + +function requireTerminal(project: Project, assetId: string, terminalType: string): GraphNode { + const node = terminalNode(requireBehavior(project, assetId).graph, terminalType); + if (!node) throw new CommandError('The behavior has no start or end node'); + return node; +} + +function nodeAt(site: PortSite): GraphNode | undefined { + return site.graph.nodes.find((n) => n.id === site.nodeId); +} + +function renameParam(site: PortSite, from: string, to: string): void { + const node = nodeAt(site); + const value = node?.params[from]; + if (!node || value === undefined) return; + node.params[to] = value; + delete node.params[from]; +} + +const INVALID_NAME = 'Port names must start with a letter and contain only letters, digits or _'; + +export class AddPortCommand implements Command { + readonly label: string; + + constructor( + private readonly assetId: string, + private readonly terminalType: string, + private readonly port: DeclaredPort, + ) { + this.label = `Add ${port.key}`; + } + + execute(project: Project): void { + const node = requireTerminal(project, this.assetId, this.terminalType); + if (!isValidPortName(this.port.key)) throw new CommandError(INVALID_NAME); + if ((node.ports ?? []).some((p) => p.key === this.port.key)) + throw new CommandError(`"${this.port.key}" already exists`); + node.ports = [...(node.ports ?? []), { ...this.port }]; + } + + undo(project: Project): void { + const node = requireTerminal(project, this.assetId, this.terminalType); + node.ports = (node.ports ?? []).filter((p) => p.key !== this.port.key); + } +} + +/** Takes the edges hanging on the port with it – in this graph and in every calling one. */ +export class RemovePortCommand implements Command { + readonly label: string; + private removed?: Removed; + private edges: { graph: Graph; entries: Removed[] }[] = []; + private values: { site: PortSite; value: PropertyValue }[] = []; + + constructor( + private readonly assetId: string, + private readonly terminalType: string, + private readonly key: string, + ) { + this.label = `Remove ${key}`; + } + + execute(project: Project): void { + const sites = portSites(project, this.assetId, this.terminalType); + const node = requireTerminal(project, this.assetId, this.terminalType); + const ports = [...(node.ports ?? [])]; + const removed = removeFirst(ports, (p) => p.key === this.key); + if (!removed) throw new CommandError(`Port "${this.key}" not found`); + this.removed = removed; + node.ports = ports; + this.edges = sites.map((site) => ({ + graph: site.graph, + entries: removeWhere(site.graph.edges, edgesOnPort(site, this.key)), + })); + this.values = []; + for (const site of sites) { + const target = site.asInput ? nodeAt(site) : undefined; + const value = target?.params[this.key]; + if (!target || value === undefined) continue; + this.values.push({ site, value }); + delete target.params[this.key]; + } + } + + undo(project: Project): void { + if (!this.removed) return; + const node = requireTerminal(project, this.assetId, this.terminalType); + const ports = [...(node.ports ?? [])]; + restoreAt(ports, [this.removed]); + node.ports = ports; + for (const { graph, entries } of this.edges) restoreAt(graph.edges, entries); + for (const { site, value } of this.values) { + const target = nodeAt(site); + if (target) target.params[this.key] = value; + } + } +} + +/** Rewrites the edges and inline values pointing at the port too, in every graph that has any. */ +export class RenamePortCommand implements Command { + readonly label: string; + + constructor( + private readonly assetId: string, + private readonly terminalType: string, + private readonly from: string, + private readonly to: string, + ) { + this.label = `Rename ${from}`; + } + + execute(project: Project): void { + this.rename(project, this.from, this.to); + } + + undo(project: Project): void { + this.rename(project, this.to, this.from); + } + + private rename(project: Project, from: string, to: string): void { + if (from === to) return; + const sites = portSites(project, this.assetId, this.terminalType); + const node = requireTerminal(project, this.assetId, this.terminalType); + if (!isValidPortName(to)) throw new CommandError(INVALID_NAME); + const ports = node.ports ?? []; + if (!ports.some((p) => p.key === from)) throw new CommandError(`Port "${from}" not found`); + if (ports.some((p) => p.key === to)) throw new CommandError(`"${to}" already exists`); + node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p)); + for (const site of sites) { + for (const edge of site.graph.edges.filter(edgesOnPort(site, from))) { + if (site.asInput) edge.to.port = to; + else edge.from.port = to; + } + // An input keeps its typed-in value under the port key, so a rename has to carry it along + // or the value silently falls back to the default. + if (site.asInput) renameParam(site, from, to); + } + } +} + +/** "in1", "in2", … – the first port name the terminal node does not use yet. */ +export function nextPortName(node: GraphNode, base: string): string { + const taken = new Set((node.ports ?? []).map((p) => p.key)); + for (let i = 1; ; i++) if (!taken.has(`${base}${i}`)) return `${base}${i}`; +} diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index f79ce475..64960d8c 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -18,6 +18,24 @@ export interface GraphNode { x: number; y: number; params: PropertyBag; + /** + * Ports the node declares itself, on top of the fixed ones of its type. Only the behavior + * start/end nodes carry this – they are what gives a nested behavior its signature. + */ + ports?: DeclaredPort[]; +} + +/** A port a start/end node declares. `kind` maps to the PortType of the same name (`text` → string). */ +export interface DeclaredPort { + key: string; + kind: DeclaredPortKind; +} + +export const DECLARED_PORT_KINDS = ['number', 'boolean', 'text', 'object'] as const; +export type DeclaredPortKind = (typeof DECLARED_PORT_KINDS)[number]; + +export function declaredPortType(kind: DeclaredPortKind): PortType { + return kind === 'text' ? 'string' : kind; } export interface GraphEdge { diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index d4da6140..bbfac775 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -1,6 +1,14 @@ import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph'; +import { + BEHAVIOR_END, + BEHAVIOR_START, + type BehaviorLookup, + NO_BEHAVIORS, + resolveDef, + terminalNode, +} from './nested'; import type { NodeContext } from './NodeContext'; -import { hasExecInput, inlineValue, isEventNode, type RepeatResult, requireNodeDef } from './NodeRegistry'; +import { type CallResult, hasExecInput, inlineValue, isEventNode, type RepeatResult } from './NodeRegistry'; import 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. */ @@ -18,6 +26,17 @@ export interface TraceListener { onNode(nodeId: string): 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>; + dataCache: Map>; +} + +function emptyFrame(graph: Graph): Frame { + return { graph, outputCache: new Map(), dataCache: new Map() }; +} + /** * 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 @@ -26,13 +45,19 @@ export interface TraceListener { export class GraphInterpreter { /** Per-node state that persists across pulses (timer accumulators etc.). */ readonly nodeState = new Map>(); - private outputCache = new Map>(); - /** Kept apart from `outputCache` because a loop round has to forget exactly these again. */ - private dataCache = new Map>(); + /** Assets run as a node so far – the graph editor highlights them from this instance's trace. */ + readonly calledBehaviors = new Set(); + private frame: Frame; + private callStack: string[] = []; private steps = 0; trace: TraceListener | null = null; - constructor(readonly graph: Graph) {} + constructor( + readonly graph: Graph, + private readonly behaviors: BehaviorLookup = NO_BEHAVIORS, + ) { + this.frame = emptyFrame(graph); + } eventNodes(type: string): GraphNode[] { return this.graph.nodes.filter((n) => n.type === type); @@ -48,10 +73,10 @@ export class GraphInterpreter { let fired = 0; for (const node of this.eventNodes(type)) { if (filter && !filter(node)) continue; - this.outputCache = new Map(); - this.dataCache = new Map(); + this.frame = emptyFrame(this.graph); + this.callStack = []; this.steps = 0; - this.outputCache.set(node.id, { ...outputs }); + this.frame.outputCache.set(node.id, { ...outputs }); this.trace?.onNode(node.id); this.follow(node, 'exec', ctx, 0); fired++; @@ -60,7 +85,7 @@ export class GraphInterpreter { } private follow(node: GraphNode, port: string, ctx: NodeContext, depth: number): void { - for (const edge of edgesOutOf(this.graph, node.id, port)) { + 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); @@ -73,41 +98,68 @@ export class GraphInterpreter { 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 = requireNodeDef(node.type); - if (!def.run) return; + const def = this.def(node); + if (!def?.run) return; const inputs = this.collectInputs(node, ctx); const result = def.run(ctx, inputs, node.params); - this.outputCache.set(node.id, result.outputs ?? {}); + const returned = result.call ? this.call(result.call, ctx, depth) : {}; + this.frame.outputCache.set(node.id, { ...result.outputs, ...returned }); this.trace?.onNode(node.id); 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 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. + */ + private call(call: CallResult, ctx: NodeContext, depth: number): Record { + const program = this.behaviors(call.behaviorId); + if (!program) return {}; + if (this.callStack.includes(call.behaviorId)) throw new Error(`Behavior "${program.name}" runs itself`); + const start = terminalNode(program.graph, BEHAVIOR_START); + if (!start) return {}; + const outer = this.frame; + this.frame = emptyFrame(program.graph); + this.frame.outputCache.set(start.id, { ...call.inputs }); + this.callStack.push(call.behaviorId); + this.calledBehaviors.add(call.behaviorId); + try { + this.trace?.onNode(start.id); + this.follow(start, 'exec', { ...ctx, variables: { ...program.defaults } }, depth + 1); + const end = terminalNode(program.graph, BEHAVIOR_END); + return (end && this.frame.outputCache.get(end.id)) ?? {}; + } finally { + this.callStack.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.outputCache.get(node.id) ?? {}; + 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.dataCache.clear(); - this.outputCache.set(node.id, { ...outputs, [repeat.index]: round }); + this.frame.dataCache.clear(); + this.frame.outputCache.set(node.id, { ...outputs, [repeat.index]: round }); this.follow(node, repeat.port, ctx, depth); } - this.dataCache.clear(); + this.frame.dataCache.clear(); } /** Resolves every data input: connected → pulled from the source, else the inline param. */ private collectInputs(node: GraphNode, ctx: NodeContext): Record { - const def = requireNodeDef(node.type); const inputs: Record = {}; - for (const port of def.inputs) { + for (const port of this.def(node)?.inputs ?? []) { if (port.type === 'exec') continue; - const edge = edgesInto(this.graph, node.id, port.key)[0]; + const edge = edgesInto(this.frame.graph, node.id, port.key)[0]; if (edge) inputs[port.key] = this.readOutput(edge.from.node, edge.from.port, ctx); else inputs[port.key] = inlineValue(node, port); } @@ -116,24 +168,29 @@ export class GraphInterpreter { /** 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.outputCache.get(nodeId) ?? this.dataCache.get(nodeId); + 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 = requireNodeDef(source.type); + const def = this.def(source); + if (!def) return 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.dataCache.set(nodeId, {}); + this.frame.dataCache.set(nodeId, {}); const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params).outputs ?? {}; - this.dataCache.set(nodeId, outputs); + this.frame.dataCache.set(nodeId, outputs); this.trace?.onNode(nodeId); return outputs[port] ?? null; } + private def(node: GraphNode) { + return resolveDef(node, this.behaviors); + } + private node(id: string): GraphNode | undefined { - return this.graph.nodes.find((n) => n.id === id); + return this.frame.graph.nodes.find((n) => n.id === id); } } diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 008c4c1e..8c733974 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -24,6 +24,12 @@ export interface RepeatResult { times: number; } +/** How a node asks for another behavior's graph to be run in place, `inputs` seeding its start node. */ +export interface CallResult { + behaviorId: string; + inputs: Record; +} + export interface RunResult { /** Cached for the rest of the pulse. */ outputs?: Record; @@ -31,6 +37,8 @@ export interface RunResult { next?: string | string[]; /** Runs `port` that many times before continuing along `next`. */ repeat?: RepeatResult; + /** Runs another behavior before continuing along `next`; its results join `outputs`. */ + call?: CallResult; } /** Palette, port rendering, connection validation and inspector fields are all derived from this. */ diff --git a/src/core/graph/dropTarget.ts b/src/core/graph/dropTarget.ts index 2e1889cf..6f6c7c3a 100644 --- a/src/core/graph/dropTarget.ts +++ b/src/core/graph/dropTarget.ts @@ -1,5 +1,6 @@ import { edgesInto, findNode, type Graph, type PortType, portsCompatible } from './Graph'; -import { type NodeDef, nodeDef } from './NodeRegistry'; +import { type BehaviorLookup, resolveDef } from './nested'; +import type { NodeDef } from './NodeRegistry'; /** One end of an edge being dragged: a port on a node, seen from its own side. */ export interface PortSide { @@ -8,18 +9,18 @@ export interface PortSide { isOutput: boolean; } -export function portTypeOf(graph: Graph, side: PortSide): PortType | undefined { +export function portTypeOf(graph: Graph, side: PortSide, lookup: BehaviorLookup): PortType | undefined { const node = findNode(graph, side.nodeId); - const def = node && nodeDef(node.type); + const def = node && resolveDef(node, lookup); const list = side.isOutput ? def?.outputs : def?.inputs; return list?.find((p) => p.key === side.port)?.type; } /** Whether a drag from `from` may land on `to`, i.e. whether ConnectCommand would accept it. */ -export function canConnect(graph: Graph, from: PortSide, to: PortSide): boolean { +export function canConnect(graph: Graph, from: PortSide, to: PortSide, lookup: BehaviorLookup): boolean { if (from.isOutput === to.isOutput || from.nodeId === to.nodeId) return false; - const fromType = portTypeOf(graph, from); - const toType = portTypeOf(graph, to); + const fromType = portTypeOf(graph, from, lookup); + const toType = portTypeOf(graph, to, lookup); if (fromType === undefined || toType === undefined) return false; const [out, inp] = from.isOutput ? [fromType, toType] : [toType, fromType]; return portsCompatible(out, inp) && !linked(graph, from, to); @@ -30,11 +31,16 @@ export function canConnect(graph: Graph, from: PortSide, to: PortSide): boolean * the first free compatible one, or – when none is free – the last compatible one, whose edge is * then replaced. Returns null when the node offers nothing to connect to. */ -export function dropTargetPort(graph: Graph, from: PortSide, nodeId: string): PortSide | null { +export function dropTargetPort( + graph: Graph, + from: PortSide, + nodeId: string, + lookup: BehaviorLookup, +): PortSide | null { if (nodeId === from.nodeId) return null; const node = findNode(graph, nodeId); - const def = node && nodeDef(node.type); - const fromType = portTypeOf(graph, from); + const def = node && resolveDef(node, lookup); + const fromType = portTypeOf(graph, from, lookup); if (!def || fromType === undefined) return null; const candidates = compatiblePorts(def, fromType, from.isOutput) .map((p) => ({ side: { nodeId, port: p.key, isOutput: !from.isOutput }, type: p.type })) diff --git a/src/core/graph/index.ts b/src/core/graph/index.ts index c0a3fec2..897121eb 100644 --- a/src/core/graph/index.ts +++ b/src/core/graph/index.ts @@ -3,6 +3,7 @@ import './nodes'; export * from './dropTarget'; export * from './Graph'; +export * from './nested'; export * from './NodeContext'; export * from './NodeRegistry'; export * from './values'; diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts new file mode 100644 index 00000000..2c187193 --- /dev/null +++ b/src/core/graph/nested.ts @@ -0,0 +1,80 @@ +import type { PropertyBag } from '../model/Properties'; +import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph'; +import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry'; + +export const BEHAVIOR_START = 'BehaviorStart'; +export const BEHAVIOR_END = 'BehaviorEnd'; +export const RUN_BEHAVIOR = 'RunBehavior'; + +export const BEHAVIOR_PARAM = 'behavior'; + +/** What running another behavior's graph in place needs: its nodes, its variable defaults, its name. */ +export interface BehaviorProgram { + name: string; + graph: Graph; + defaults: PropertyBag; +} + +export type BehaviorLookup = (assetId: string) => BehaviorProgram | null; + +export const NO_BEHAVIORS: BehaviorLookup = () => null; + +/** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */ +export function isValidPortName(name: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && name !== 'exec'; +} + +/** The single start (or end) node of a graph; a second one is ignored. */ +export function terminalNode(graph: Graph, type: string): GraphNode | undefined { + return graph.nodes.find((n) => n.type === type); +} + +export function isTerminalType(type: string): boolean { + return type === BEHAVIOR_START || type === BEHAVIOR_END; +} + +export function isTerminalNode(node: GraphNode): boolean { + return isTerminalType(node.type); +} + +export function portsOf(port: DeclaredPort): PortDef { + return { key: port.key, label: port.key, type: declaredPortType(port.kind) }; +} + +function declaredOf(graph: Graph | undefined, type: string): PortDef[] { + const node = graph && terminalNode(graph, type); + return (node?.ports ?? []).map(portsOf); +} + +/** + * The node definition as it applies to *this* node. Identical to the registered one for every fixed + * type; the three nested-behavior types grow ports from a declaration instead – the start and end + * node from their own, the call node from those of the behavior it names. + */ +export function resolveDef(node: GraphNode, lookup: BehaviorLookup): NodeDef | undefined { + const def = nodeDef(node.type); + if (!def) return undefined; + const own = (node.ports ?? []).map(portsOf); + if (node.type === BEHAVIOR_START) return { ...def, outputs: [...def.outputs, ...own] }; + if (node.type === BEHAVIOR_END) return { ...def, inputs: [...def.inputs, ...own] }; + if (node.type !== RUN_BEHAVIOR) return def; + const program = lookup(String(node.params[BEHAVIOR_PARAM] ?? '')); + return { + ...def, + // The graph reads as what it does, not as a row of identical "Run behavior" boxes. + label: program ? program.name : def.label, + inputs: [...def.inputs, ...declaredOf(program?.graph, BEHAVIOR_START)], + outputs: [...def.outputs, ...declaredOf(program?.graph, BEHAVIOR_END)], + }; +} + +/** + * Everything that changes a node's ports, as one string. Views and the inspector rebuild when it + * changes – a port list that is derived rather than fixed has no other way of being noticed. + */ +export function portSignature(node: GraphNode, lookup: BehaviorLookup): string { + const def = resolveDef(node, lookup); + if (!def) return 'missing'; + const list = (ports: PortDef[]) => ports.map((p) => `${p.key}:${p.type}`).join(','); + return `${node.type}|${def.label}|${list(def.inputs)}|${list(def.outputs)}`; +} diff --git a/src/core/graph/nodes/flow/BehaviorEnd.ts b/src/core/graph/nodes/flow/BehaviorEnd.ts new file mode 100644 index 00000000..e91a1732 --- /dev/null +++ b/src/core/graph/nodes/flow/BehaviorEnd.ts @@ -0,0 +1,21 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { BEHAVIOR_END } from '../../nested'; +import { exec } from '../shared'; + +/** + * The exit of a behavior used as a node: what arrives at its declared inputs leaves the calling + * node as output. Handing the inputs straight back out is what parks the result in the pulse + * cache, where the caller reads it once the flow has returned. + */ +export const BehaviorEnd: NodeDef = { + type: BEHAVIOR_END, + label: 'End', + category: 'flow', + description: + 'Ends this behavior and hands its results back to the graph that ran it. Every output declared ' + + 'here becomes an output port on that node.', + inputs: [exec()], + outputs: [], + params: [], + run: (_ctx, inputs) => ({ outputs: inputs }), +}; diff --git a/src/core/graph/nodes/flow/BehaviorStart.ts b/src/core/graph/nodes/flow/BehaviorStart.ts new file mode 100644 index 00000000..7f6ecd52 --- /dev/null +++ b/src/core/graph/nodes/flow/BehaviorStart.ts @@ -0,0 +1,19 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { BEHAVIOR_START } from '../../nested'; +import { exec } from '../shared'; + +/** + * The entry point of a behavior used as a node. Its declared outputs are the inputs the calling + * graph passes in; the interpreter seeds them, so it has no `run` – like an event node. + */ +export const BehaviorStart: NodeDef = { + type: BEHAVIOR_START, + label: 'Start', + category: 'event', + description: + 'Where this behavior begins when another graph runs it as a node. Every input declared here ' + + 'becomes an input port on that node.', + inputs: [], + outputs: [exec()], + params: [], +}; diff --git a/src/core/graph/nodes/flow/RunBehavior.ts b/src/core/graph/nodes/flow/RunBehavior.ts new file mode 100644 index 00000000..1dfd5a28 --- /dev/null +++ b/src/core/graph/nodes/flow/RunBehavior.ts @@ -0,0 +1,24 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { BEHAVIOR_PARAM, RUN_BEHAVIOR } from '../../nested'; +import { exec } from '../shared'; + +/** + * Runs another behavior's graph in place, from its start node to its end node. The ports come from + * those two nodes (see `nested.ts`); the interpreter does the call, because only it can hold a + * second graph's pulse. + */ +export const RunBehavior: NodeDef = { + type: RUN_BEHAVIOR, + label: 'Run behavior', + category: 'flow', + description: + 'Runs another behavior as a step of this one, passing values in and taking results out. The ' + + 'behavior keeps no state between calls: its variables start at their defaults every time.', + inputs: [exec()], + outputs: [exec()], + params: [{ key: BEHAVIOR_PARAM, label: 'Behavior', kind: 'asset', default: '', assetTypes: ['behavior'] }], + run: (_ctx, inputs, params) => ({ + call: { behaviorId: String(params[BEHAVIOR_PARAM] ?? ''), inputs }, + next: 'exec', + }), +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 9cb58f0d..a1203982 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -65,8 +65,11 @@ import { OnKeyUp } from './events/OnKeyUp'; import { OnStart } from './events/OnStart'; import { OnTimer } from './events/OnTimer'; import { OnUpdate } from './events/OnUpdate'; +import { BehaviorEnd } from './flow/BehaviorEnd'; +import { BehaviorStart } from './flow/BehaviorStart'; import { Branch } from './flow/Branch'; import { Repeat } from './flow/Repeat'; +import { RunBehavior } from './flow/RunBehavior'; import { Sequence } from './flow/Sequence'; import { GetVariable } from './variable/GetVariable'; import { SetVariable } from './variable/SetVariable'; @@ -87,10 +90,13 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ OnCollisionStay, OnCollisionExit, OnClick, + BehaviorStart, // flow Branch, Sequence, Repeat, + RunBehavior, + BehaviorEnd, // variables GetVariable, SetVariable, diff --git a/src/core/model/behaviorPrograms.ts b/src/core/model/behaviorPrograms.ts new file mode 100644 index 00000000..bd964dda --- /dev/null +++ b/src/core/model/behaviorPrograms.ts @@ -0,0 +1,15 @@ +import type { BehaviorLookup } from '../graph/nested'; +import { resolveAssetProps } from '../properties/resolve'; +import { findAsset, type Project } from './Project'; + +/** + * What a `Run behavior` node needs from the project. `defaults` are the asset's variable values; + * every call copies them, so a nested behavior never carries state from one call to the next. + */ +export function behaviorLookup(project: Project): BehaviorLookup { + return (assetId) => { + const asset = findAsset(project, assetId); + if (!asset || asset.type !== 'behavior') return null; + return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(asset) }; + }; +} diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 071495d9..8eef2a22 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -1,5 +1,15 @@ // Importing the graph package index is what registers the built-in nodes. -import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, nodeDef } from '../graph'; +import { + createEmptyGraph, + DECLARED_PORT_KINDS, + type DeclaredPort, + type DeclaredPortKind, + type Graph, + type GraphEdge, + type GraphNode, + isValidPortName, + nodeDef, +} from '../graph'; import type { Asset, TextureFrame } from '../model/Asset'; import { isAssetType } from '../model/AssetTypes'; import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance'; @@ -166,6 +176,22 @@ function normalizeVariables(raw: unknown): PropertyDef[] { return defs; } +function normalizePorts(raw: unknown): DeclaredPort[] { + const ports: DeclaredPort[] = []; + const seen = new Set(); + for (const item of Array.isArray(raw) ? raw : []) { + const p = asRecord(item); + const key = typeof p.key === 'string' ? p.key : ''; + if (!isValidPortName(key) || seen.has(key)) continue; + seen.add(key); + const kind = (DECLARED_PORT_KINDS as readonly string[]).includes(String(p.kind)) + ? (p.kind as DeclaredPortKind) + : 'number'; + ports.push({ key, kind }); + } + return ports; +} + function normalizeGraph(raw: unknown): Graph { const g = asRecord(raw); const graph = createEmptyGraph(); @@ -182,6 +208,8 @@ function normalizeGraph(raw: unknown): Graph { params[key] = value; } const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params }; + const ports = normalizePorts(n.ports); + if (ports.length > 0) node.ports = ports; graph.nodes.push(node); } const edgeIds = new Set(); diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index b7750fb5..bca7e1bd 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -184,6 +184,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E portAt: (nodeId: string, port: string, isOutput: boolean) => graphEditor.canvas.portGlobal(nodeId, port, isOutput), nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId), + nodeLabel: (nodeId: string) => graphEditor.canvas.nodeLabel(nodeId), /** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */ nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId), canvasRect: () => graphEditor.canvas.canvasRect(), diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index d7d86ae3..a128711a 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -16,14 +16,15 @@ import { firstPortFor, type GraphEdge, type GraphNode, - type NodeDef, - nodeDef, + type BehaviorLookup, type PortSide, type PortType, portsCompatible, portTypeOf, + resolveDef, } from '../../core/graph'; import type { BehaviorAsset } from '../../core/model/Asset'; +import { behaviorLookup } from '../../core/model/behaviorPrograms'; import { findAsset } from '../../core/model/Project'; import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui'; @@ -31,6 +32,7 @@ import { removeNodes } from '../actions'; import { type EditorState, nodeSelection } from '../state'; import { type Box, boxesInBand, frameBoxes } from './framing'; import { NodePalette } from './NodePalette'; +import { type PaletteEntry, paletteEntries } from './paletteEntries'; import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView'; const GRID_STEP = 24; @@ -140,8 +142,8 @@ export class GraphCanvas extends Widget { addNodeFrom(anchor: Widget): void { if (!this.assetId || this.state.mode !== 'edit') return; const centre = this.screenToGraph(this.w / 2, this.h / 2); - new NodePalette((def: NodeDef) => - this.addNode(def, { x: centre.x - NODE_WIDTH / 2, y: centre.y - NODE_HEIGHT_ESTIMATE / 2 }), + new NodePalette(this.entries(), (entry) => + this.addNode(entry, { x: centre.x - NODE_WIDTH / 2, y: centre.y - NODE_HEIGHT_ESTIMATE / 2 }), ).openBelow(anchor); } @@ -151,6 +153,10 @@ export class GraphCanvas extends Widget { // --- model binding ---------------------------------------------------------------------- + private behaviors(): BehaviorLookup { + return behaviorLookup(this.state.project); + } + private asset(): BehaviorAsset | null { const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined; return asset && asset.type === 'behavior' ? asset : null; @@ -168,8 +174,9 @@ export class GraphCanvas extends Widget { return; } const seen = new Set(); + const lookup = behaviorLookup(this.state.project); for (const node of asset.graph.nodes) { - const def = nodeDef(node.type); + const def = resolveDef(node, lookup); if (!def) continue; seen.add(node.id); let view = this.views.get(node.id); @@ -180,7 +187,7 @@ export class GraphCanvas extends Widget { this.views.set(node.id, view); this.nodeLayer.addChild(view); } - view.sync(node, asset.graph, asset, this.state.project); + view.sync(node, asset.graph, asset, this.state.project, def); } for (const [id, view] of this.views) { if (!seen.has(id)) { @@ -425,13 +432,14 @@ export class GraphCanvas extends Widget { ): void { const graph = this.asset()?.graph; const from = drag.from; - const fromType = graph && portTypeOf(graph, from); + const fromType = graph && portTypeOf(graph, from, this.behaviors()); if (!graph || fromType === undefined) return; const local = this.toLocal({ x: globalX, y: globalY }); const at = this.screenToGraph(local.x, local.y); this.pendingDrop = drag; const palette = new NodePalette( - (def: NodeDef) => this.addConnectedNode(def, from, fromType, at), + this.entries(), + (entry) => this.addConnectedNode(entry, from, fromType, at), (def) => defAcceptsFrom(def, fromType, from.isOutput), ); palette.onClose = () => { @@ -449,10 +457,11 @@ export class GraphCanvas extends Widget { const graph = this.asset()?.graph; const from = this.connecting?.from; if (!graph || !from) return null; + const lookup = this.behaviors(); const hit = this.portUnder(globalX, globalY); - if (hit) return canConnect(graph, from, hit) ? hit : null; + if (hit) return canConnect(graph, from, hit, lookup) ? hit : null; const node = this.nodeUnder(globalX, globalY); - return node ? dropTargetPort(graph, from, node) : null; + return node ? dropTargetPort(graph, from, node, lookup) : null; } private portUnder(globalX: number, globalY: number): PortSide | null { @@ -483,25 +492,42 @@ export class GraphCanvas extends Widget { // --- menus ------------------------------------------------------------------------------- private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void { - new NodePalette((def: NodeDef) => this.addNode(def, at)).open(globalX, globalY); + new NodePalette(this.entries(), (entry) => this.addNode(entry, at)).open(globalX, globalY); } - private addNode(def: NodeDef, at: { x: number; y: number }): void { + private entries(): PaletteEntry[] { + const asset = this.asset(); + return asset ? paletteEntries(this.state.project, asset) : []; + } + + private addNode(entry: PaletteEntry, at: { x: number; y: number }): void { if (!this.assetId) return; - const command = new AddNodeCommand(this.assetId, def.type, Math.round(at.x), Math.round(at.y)); + const command = new AddNodeCommand( + this.assetId, + entry.def.type, + Math.round(at.x), + Math.round(at.y), + entry.params, + ); if (this.state.execute(command)) this.state.select(nodeSelection(this.assetId, [command.node.id])); } /** Adding and wiring go in as one command, so a single undo takes the whole gesture back. */ private addConnectedNode( - def: NodeDef, + entry: PaletteEntry, from: PortSide, fromType: PortType, at: { x: number; y: number }, ): void { if (!this.assetId) return; - const add = new AddNodeCommand(this.assetId, def.type, Math.round(at.x), Math.round(at.y)); - const port = firstPortFor(def, fromType, from.isOutput); + const add = new AddNodeCommand( + this.assetId, + entry.def.type, + Math.round(at.x), + Math.round(at.y), + entry.params, + ); + const port = firstPortFor(entry.def, fromType, from.isOutput); const to = { nodeId: add.node.id, port: port ?? '', isOutput: !from.isOutput }; const [out, inp] = from.isOutput ? [from, to] : [to, from]; const commands: Command[] = [add]; @@ -712,6 +738,14 @@ export class GraphCanvas extends Widget { }; } + /** + * The title a node is drawn with. Worth reaching for from outside because a nested-behavior node + * takes its title from the behavior it runs – "which node is this" is otherwise only a guess. + */ + nodeLabel(nodeId: string): string | null { + return this.views.get(nodeId)?.def.label ?? null; + } + /** Global screen position of a node's title bar centre. */ nodeGlobal(nodeId: string): { x: number; y: number } | null { const view = this.views.get(nodeId); diff --git a/src/editor/graph/NodePalette.ts b/src/editor/graph/NodePalette.ts index a836b192..13b0f7bb 100644 --- a/src/editor/graph/NodePalette.ts +++ b/src/editor/graph/NodePalette.ts @@ -1,5 +1,6 @@ -import { allNodeDefs, type NodeCategory, type NodeDef } from '../../core/graph'; +import type { NodeCategory, NodeDef } from '../../core/graph'; import { ListView, Popup, ScrollContainer, TextField, Theme, ui } from '../../ui'; +import type { PaletteEntry } from './paletteEntries'; const CATEGORY_ORDER: NodeCategory[] = ['event', 'flow', 'variable', 'data', 'action']; @@ -8,7 +9,7 @@ export class NodePalette extends Popup { private readonly search: TextField; private readonly list = new ListView(); private readonly scroll = new ScrollContainer(); - private filtered: NodeDef[] = []; + private filtered: PaletteEntry[] = []; /** Guards against a second pick per opening (e.g. Enter and a row click racing). */ private picked = false; @@ -17,7 +18,8 @@ export class NodePalette extends Popup { * could actually connect to. */ constructor( - private readonly onPick: (def: NodeDef) => void, + private readonly entries: PaletteEntry[], + private readonly onPick: (entry: PaletteEntry) => void, private readonly accepts: (def: NodeDef) => boolean = () => true, ) { super({ padding: 4, gap: 4 }); @@ -39,8 +41,8 @@ export class NodePalette extends Popup { this.list.rowHeight = Theme.rowHeight - 2; this.list.emptyText = 'No matching node'; this.list.onSelect = (id) => { - const def = this.filtered.find((d) => d.type === id); - if (def) this.pick(def); + const entry = this.filtered.find((e) => e.id === id); + if (entry) this.pick(entry); }; this.refresh(); } @@ -53,28 +55,28 @@ export class NodePalette extends Popup { private refresh(): void { const query = this.search.value.trim().toLowerCase(); - this.filtered = allNodeDefs() - .filter(this.accepts) + this.filtered = this.entries + .filter((e) => this.accepts(e.def)) .filter( - (d) => + ({ def }) => !query || - d.label.toLowerCase().includes(query) || - d.type.toLowerCase().includes(query) || - d.description.toLowerCase().includes(query), + def.label.toLowerCase().includes(query) || + def.type.toLowerCase().includes(query) || + def.description.toLowerCase().includes(query), ) .sort( (a, b) => - CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) || - a.label.localeCompare(b.label), + CATEGORY_ORDER.indexOf(a.def.category) - CATEGORY_ORDER.indexOf(b.def.category) || + a.def.label.localeCompare(b.def.label), ); - this.list.setItems(this.filtered.map((d) => ({ id: d.type, label: d.label, hint: d.category }))); + this.list.setItems(this.filtered.map((e) => ({ id: e.id, label: e.def.label, hint: e.hint }))); this.scroll.relayoutContent(); } - private pick(def: NodeDef): void { + private pick(entry: PaletteEntry): void { if (this.picked) return; this.picked = true; this.close(); - this.onPick(def); + this.onPick(entry); } } diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index 39c168e7..1d1726c2 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -81,14 +81,21 @@ export class NodeView extends Widget { /** Data inputs that currently have a connection (their inline value is hidden). */ private connected = new Set(); + /** + * Not fixed at construction: a nested-behavior node's ports come from another graph, so the + * definition that applies to this node can change without the node itself changing. + */ + def: NodeDef; + constructor( readonly node: GraphNode, - readonly def: NodeDef, + def: NodeDef, ) { super(); + this.def = def; this.cursor = 'grab'; this.addChild(this.box, this.ports); - this.sync(node, { nodes: [node], edges: [] }, null, null); + this.sync(node, { nodes: [node], edges: [] }, null, null, def); } /** Ports sit on the left/right edges; widen the hit area so they can be grabbed. */ @@ -98,10 +105,23 @@ export class NodeView extends Widget { } /** `project` resolves asset references (Spawn, Is asset). */ - sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null, project: Project | null): void { - this.paramLines = this.def.params.map( - (p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`, - ); + sync( + node: GraphNode, + graph: Graph, + asset: BehaviorAsset | null, + project: Project | null, + def: NodeDef, + ): void { + this.def = def; + this.paramLines = this.def.params + .map((p) => ({ + label: p.label, + value: formatParam(p, node.params[p.key] ?? p.default, asset, project), + })) + // A setting the title already states is not worth a row: a nested-behavior node is titled + // with the behavior it runs, so "Behavior: Double" under "Double" says nothing new. + .filter((p) => p.value !== this.def.label) + .map((p) => `${p.label}: ${p.value}`); this.connected = new Set( inlineInputs(this.def) .filter((p) => edgesInto(graph, node.id, p.key).length > 0) diff --git a/src/editor/graph/paletteEntries.ts b/src/editor/graph/paletteEntries.ts new file mode 100644 index 00000000..1887dc8b --- /dev/null +++ b/src/editor/graph/paletteEntries.ts @@ -0,0 +1,46 @@ +import { + allNodeDefs, + BEHAVIOR_PARAM, + isTerminalType, + type NodeDef, + resolveDef, + RUN_BEHAVIOR, + terminalNode, +} from '../../core/graph'; +import type { BehaviorAsset } from '../../core/model/Asset'; +import { behaviorLookup } from '../../core/model/behaviorPrograms'; +import type { PropertyBag } from '../../core/model/Properties'; +import type { Project } from '../../core/model/Project'; + +/** + * One row of the palette. `def` is the definition the node would actually have once placed, so the + * drop filter and the port it lands on are right for a behavior entry too. + */ +export interface PaletteEntry { + id: string; + hint: string; + def: NodeDef; + params: PropertyBag; +} + +/** + * The node types offerable in `asset`'s graph. Every other behavior appears under its own name + * instead of behind a generic "Run behavior"; the two terminals drop out once the graph has them. + */ +export function paletteEntries(project: Project, asset: BehaviorAsset): PaletteEntry[] { + const entries: PaletteEntry[] = []; + for (const def of allNodeDefs()) { + if (def.type === RUN_BEHAVIOR) continue; + if (isTerminalType(def.type) && terminalNode(asset.graph, def.type)) continue; + entries.push({ id: def.type, hint: def.category, def, params: {} }); + } + const lookup = behaviorLookup(project); + for (const other of project.assets) { + // A behavior running itself is an endless loop, so it is not on offer. + if (other.type !== 'behavior' || other.id === asset.id) continue; + const params = { [BEHAVIOR_PARAM]: other.id }; + const def = resolveDef({ id: '', type: RUN_BEHAVIOR, x: 0, y: 0, params }, lookup); + if (def) entries.push({ id: `${RUN_BEHAVIOR}:${other.id}`, hint: 'behavior', def, params }); + } + return entries; +} diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts index 68dfc738..f6635e5a 100644 --- a/src/editor/inspector/nodeSection.ts +++ b/src/editor/inspector/nodeSection.ts @@ -1,26 +1,40 @@ import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands'; -import { edgesInto, type GraphNode, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph'; +import { + edgesInto, + type GraphNode, + inlineInputDef, + inlineInputs, + isTerminalNode, + portSignature, + resolveDef, +} from '../../core/graph'; import type { Asset } from '../../core/model/Asset'; +import { behaviorLookup } from '../../core/model/behaviorPrograms'; import { findAsset, type Project } from '../../core/model/Project'; import type { PropertyDef } from '../../core/properties/PropertySchema'; import { Button, Label } from '../../ui'; import { createFieldRow, type FieldHandlers } from './fieldFactory'; import { addGap, addNote, addTitle, type InspectorHost } from './host'; +import { buildPortSection } from './portSection'; import { previewingHandlers } from './previewHandlers'; -/** Structure-defining facts of a node selection: type, connected inputs, declared variables. */ +/** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */ export function nodeSignature(project: Project, assetId: string, nodeId: string): string { const asset = findAsset(project, assetId); if (!asset || asset.type !== 'behavior') return 'missing'; const node = asset.graph.nodes.find((n) => n.id === nodeId); if (!node) return 'missing'; - const def = nodeDef(node.type); + const lookup = behaviorLookup(project); + const def = resolveDef(node, lookup); const connected = def ? inlineInputs(def) .filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0) .map((p) => p.key) : []; - return `${node.type}|${connected.join(',')}|${asset.variables.map((v) => v.key).join(',')}`; + const declared = (node.ports ?? []).map((p) => `${p.key}:${p.kind}`).join(','); + return `${portSignature(node, lookup)}|${declared}|${connected.join(',')}|${asset.variables + .map((v) => v.key) + .join(',')}`; } /** Description, settings (params) and the inline values of unconnected data inputs. */ @@ -28,12 +42,13 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s const asset = findAsset(host.state.project, assetId); if (!asset || asset.type !== 'behavior') return; const node = asset.graph.nodes.find((n) => n.id === nodeId); - const def = node && nodeDef(node.type); + const def = node && resolveDef(node, behaviorLookup(host.state.project)); if (!node || !def) return; addTitle(host, `${def.category.toUpperCase()} NODE`); host.body.add(new Label(def.label, { style: 'bodyBold' })); addNote(host, def.description); + if (isTerminalNode(node)) buildPortSection(host, assetId, node); if (def.params.length > 0) addTitle(host, 'SETTINGS'); for (const param of def.params) { @@ -74,14 +89,15 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s /** Pushes the node's current params into the rows. False when the node is gone (rebuild). */ export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId: string): boolean { const node = currentNode(host.state.project, assetId, nodeId); - const def = node && nodeDef(node.type); + const def = node && resolveDef(node, behaviorLookup(host.state.project)); if (!node || !def) return false; for (const [key, row] of host.fields) { const [scope, name] = key.split(':') as ['param' | 'input', string]; + const port = def.inputs.find((p) => p.key === name); const fallback = scope === 'param' ? def.params.find((p) => p.key === name)?.default - : inlineInputDef(def.inputs.find((p) => p.key === name)!).default; + : port && inlineInputDef(port).default; row.setValue(node.params[name] ?? fallback ?? ''); } return true; diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts new file mode 100644 index 00000000..1fc79728 --- /dev/null +++ b/src/editor/inspector/portSection.ts @@ -0,0 +1,85 @@ +import { + AddPortCommand, + nextPortName, + RemovePortCommand, + RenamePortCommand, +} from '../../core/commands/portCommands'; +import { BEHAVIOR_START, DECLARED_PORT_KINDS, type DeclaredPortKind, type GraphNode } from '../../core/graph'; +import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui'; +import { addNote, type InspectorHost } from './host'; + +const KIND_LABELS: Record = { + number: 'Number', + boolean: 'Boolean', + text: 'Text', + object: 'Object', +}; + +const KIND_MARKS: Record = { + number: '#', + boolean: '✓', + text: 'Aa', + object: '◇', +}; + +/** + * The ports a start or end node declares – the signature of the behavior seen as a node. Named from + * the caller's side: what the start node hands into the graph is an *input* of the behavior. + */ +export function buildPortSection(host: InspectorHost, assetId: string, node: GraphNode): void { + const onStart = node.type === BEHAVIOR_START; + const noun = onStart ? 'Input' : 'Output'; + + const header = new HStack({ gap: Theme.gap, align: 'center' }); + header.add(new Label(`${noun.toUpperCase()}S`, { style: 'title' }), 1); + const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' }); + add.onClick = () => { + const items = DECLARED_PORT_KINDS.map((kind) => ({ + label: KIND_LABELS[kind], + onSelect: () => + host.state.execute( + new AddPortCommand(assetId, node.type, { + key: nextPortName(node, onStart ? 'in' : 'out'), + kind, + }), + ), + })); + new Menu(items).openBelow(add); + }; + header.add(add); + host.body.add(header); + + const ports = node.ports ?? []; + if (ports.length === 0) + addNote( + host, + onStart + ? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior.' + : 'No outputs yet. Every output added here becomes a result of running this behavior.', + ); + + for (const port of ports) { + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.minHeight = Theme.rowHeight; + const mark = new Label(KIND_MARKS[port.kind], { style: 'small', align: 'center' }); + mark.minWidth = 18; + row.add(mark); + row.add( + new TextField(port.key, { + width: 90, + onChange: (next) => + host.state.execute(new RenamePortCommand(assetId, node.type, port.key, next.trim())), + }), + 1, + ); + row.add(new Spacer()); + row.add( + new Button('×', { + variant: 'ghost', + width: Theme.controlHeight, + onClick: () => host.state.execute(new RemovePortCommand(assetId, node.type, port.key)), + }), + ); + host.body.add(row); + } +} diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index dac43b67..36eb50ec 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -5,6 +5,7 @@ import { type NodeContext, type TraceListener, } from '../../core/graph'; +import { behaviorLookup } from '../../core/model/behaviorPrograms'; import type { PropertyBag } from '../../core/model/Properties'; import { findAsset, type Project } from '../../core/model/Project'; import type { Entity } from '../Entity'; @@ -78,7 +79,7 @@ export class BehaviorSystem implements System { const asset = findAsset(this.project, entity.assetId); if (!asset || asset.type !== 'behavior') continue; const trace: Trace = { edges: new Map(), nodes: new Map() }; - const interpreter = new GraphInterpreter(asset.graph); + const interpreter = new GraphInterpreter(asset.graph, behaviorLookup(this.project)); interpreter.trace = { onEdge: (id) => trace.edges.set(id, world.time), onNode: (id) => trace.nodes.set(id, world.time), @@ -153,14 +154,17 @@ export class BehaviorSystem implements System { return this.instances.map((i) => i.entity.objectId); } - /** Live highlight data for the graph editor: merged over all instances of the asset. */ + /** + * Live highlight data for the graph editor: merged over all instances of the asset – and over + * those that ran it as a node, which is the only way a nested behavior's graph ever lights up. + */ traceFor(assetId: string): Trace { const merged: Trace = { edges: new Map(), nodes: new Map() }; const latest = (into: Map, from: Map) => { for (const [id, t] of from) into.set(id, Math.max(t, into.get(id) ?? -Infinity)); }; for (const instance of this.instances) { - if (instance.entity.assetId !== assetId) continue; + if (instance.entity.assetId !== assetId && !instance.interpreter.calledBehaviors.has(assetId)) continue; latest(merged.edges, instance.trace.edges); latest(merged.nodes, instance.trace.nodes); } diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index b6e1a791..36c1865d 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -18,6 +18,7 @@ import { dropTargetPort, findNode, firstPortFor, + NO_BEHAVIORS, type PortType, portsCompatible, requireNodeDef, @@ -512,7 +513,7 @@ describe('dropping an edge on a node', () => { it('picks the first compatible input', () => { const { graph, number, clamp } = fixtureWithClamp(); const from = { nodeId: number.id, port: 'value', isOutput: true }; - expect(dropTargetPort(graph, from, clamp.id)).toEqual({ + expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)).toEqual({ nodeId: clamp.id, port: 'value', isOutput: false, @@ -524,7 +525,7 @@ describe('dropping an edge on a node', () => { const other = edit.addNode('Number'); edit.connect(other, 'value', clamp, 'value'); const from = { nodeId: number.id, port: 'value', isOutput: true }; - expect(dropTargetPort(graph, from, clamp.id)?.port).toBe('min'); + expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)?.port).toBe('min'); }); it('falls back to the last compatible input once every one is taken', () => { @@ -532,7 +533,7 @@ describe('dropping an edge on a node', () => { for (const port of ['value', 'min', 'max']) edit.connect(edit.addNode('Number'), 'value', clamp, port); const from = { nodeId: number.id, port: 'value', isOutput: true }; // Not free, but the drop still connects – it replaces the edge, as dropping on the port would. - expect(dropTargetPort(graph, from, clamp.id)?.port).toBe('max'); + expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)?.port).toBe('max'); }); it('keeps offering an exec input, which is never exclusive', () => { @@ -543,26 +544,26 @@ describe('dropping an edge on a node', () => { const move = edit.addNode('SetPosition'); edit.connect(edit.addNode('OnKeyHeld'), 'exec', move, 'exec'); const from = { nodeId: held.id, port: 'exec', isOutput: true }; - expect(dropTargetPort(behavior.graph, from, move.id)?.port).toBe('exec'); + expect(dropTargetPort(behavior.graph, from, move.id, NO_BEHAVIORS)?.port).toBe('exec'); }); it('refuses a node that offers nothing compatible, and the node itself', () => { const { graph, edit, number, clamp } = fixtureWithClamp(); const boolean = edit.addNode('Boolean'); const from = { nodeId: number.id, port: 'value', isOutput: true }; - expect(dropTargetPort(graph, from, boolean.id)).toBeNull(); - expect(dropTargetPort(graph, from, number.id)).toBeNull(); + expect(dropTargetPort(graph, from, boolean.id, NO_BEHAVIORS)).toBeNull(); + expect(dropTargetPort(graph, from, number.id, NO_BEHAVIORS)).toBeNull(); // A pair that is already wired is not offered again either. edit.connect(number, 'value', clamp, 'value'); edit.connect(number, 'value', clamp, 'min'); edit.connect(number, 'value', clamp, 'max'); - expect(dropTargetPort(graph, from, clamp.id)).toBeNull(); + expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)).toBeNull(); }); it('resolves a drag started at an input to an output', () => { const { graph, number, clamp } = fixtureWithClamp(); const from = { nodeId: clamp.id, port: 'value', isOutput: false }; - expect(dropTargetPort(graph, from, number.id)).toEqual({ + expect(dropTargetPort(graph, from, number.id, NO_BEHAVIORS)).toEqual({ nodeId: number.id, port: 'value', isOutput: true, diff --git a/tests/core/nested-behavior.test.ts b/tests/core/nested-behavior.test.ts new file mode 100644 index 00000000..3fee81df --- /dev/null +++ b/tests/core/nested-behavior.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest'; +import { CommandError } from '../../src/core/commands/Command'; +import { AddNodeCommand } from '../../src/core/commands/graphCommands'; +import { AddPortCommand, RemovePortCommand, RenamePortCommand } from '../../src/core/commands/portCommands'; +import { + BEHAVIOR_END, + BEHAVIOR_PARAM, + BEHAVIOR_START, + type Graph, + GraphInterpreter, + RUN_BEHAVIOR, + resolveDef, + terminalNode, +} from '../../src/core/graph'; +import type { BehaviorAsset } from '../../src/core/model/Asset'; +import { behaviorLookup } from '../../src/core/model/behaviorPrograms'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; + +/** + * A behavior that doubles what it is given: `in1` → Multiply by 2 → `out1`, reached through its + * start and end node. Small on purpose – what the tests are about is the call, not the arithmetic. + */ +function doubler(fixture: ProjectFixture, name = 'Double'): BehaviorAsset { + const asset = makeBehavior(fixture, name); + const edit = graphEditor(fixture, asset); + const start = edit.addNode(BEHAVIOR_START); + const end = edit.addNode(BEHAVIOR_END); + fixture.stack.execute(new AddPortCommand(asset.id, BEHAVIOR_START, { key: 'in1', kind: 'number' })); + fixture.stack.execute(new AddPortCommand(asset.id, BEHAVIOR_END, { key: 'out1', kind: 'number' })); + const times = edit.addNode('Multiply', { b: 2 }); + edit.wire([ + [start, 'exec', end, 'exec'], + [start, 'in1', times, 'a'], + [times, 'result', end, 'out1'], + ]); + return asset; +} + +/** A caller that runs `callee` on start with `input` and stores the result in its own `result` var. */ +function callerOf(fixture: ProjectFixture, callee: BehaviorAsset, input: number): BehaviorAsset { + const asset = makeBehavior(fixture, 'Caller', ['result']); + const edit = graphEditor(fixture, asset); + const start = edit.addNode('OnStart'); + const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, in1: input }); + const set = edit.addNode('SetVariable', { variable: 'result' }); + edit.wire([ + [start, 'exec', call, 'exec'], + [call, 'exec', set, 'exec'], + [call, 'out1', set, 'value'], + ]); + return asset; +} + +function runOnStart(fixture: ProjectFixture, asset: BehaviorAsset, variables: Record = {}) { + const interpreter = new GraphInterpreter(asset.graph, behaviorLookup(fixture.project)); + const ctx = fakeContext({ variables }); + interpreter.fire('OnStart', ctx); + return { ctx, interpreter }; +} + +describe('a behavior used as a node', () => { + it('runs the other graph in place and hands its result back', () => { + const fixture = makeProject(); + const caller = callerOf(fixture, doubler(fixture), 21); + expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(42); + }); + + it('takes its ports from the other behavior, not from its own type', () => { + const fixture = makeProject(); + const callee = doubler(fixture); + const caller = callerOf(fixture, callee, 1); + const call = caller.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!; + const def = resolveDef(call, behaviorLookup(fixture.project))!; + expect(def.inputs.map((p) => p.key)).toEqual(['exec', 'in1']); + expect(def.outputs.map((p) => p.key)).toEqual(['exec', 'out1']); + // Named after the behavior it runs: a graph of calls is unreadable as a row of "Run behavior". + expect(def.label).toBe('Double'); + }); + + it('keeps its variables to itself: the caller’s are untouched and nothing survives the call', () => { + const fixture = makeProject(); + const callee = makeBehavior(fixture, 'Counter', ['count']); + const edit = graphEditor(fixture, callee); + const start = edit.addNode(BEHAVIOR_START); + const end = edit.addNode(BEHAVIOR_END); + fixture.stack.execute(new AddPortCommand(callee.id, BEHAVIOR_END, { key: 'out1', kind: 'number' })); + const get = edit.addNode('GetVariable', { variable: 'count' }); + const plus = edit.addNode('Add', { b: 1 }); + const set = edit.addNode('SetVariable', { variable: 'count' }); + edit.wire([ + [start, 'exec', set, 'exec'], + [get, 'value', plus, 'a'], + [plus, 'result', set, 'value'], + [set, 'exec', end, 'exec'], + [plus, 'result', end, 'out1'], + ]); + + const caller = makeBehavior(fixture, 'Caller', ['result', 'count']); + const cEdit = graphEditor(fixture, caller); + const onStart = cEdit.addNode('OnStart'); + const first = cEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id }); + const second = cEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id }); + const store = cEdit.addNode('SetVariable', { variable: 'result' }); + cEdit.wire([ + [onStart, 'exec', first, 'exec'], + [first, 'exec', second, 'exec'], + [second, 'exec', store, 'exec'], + [second, 'out1', store, 'value'], + ]); + + const { ctx } = runOnStart(fixture, caller, { result: 0, count: 500 }); + // 1, not 2: the second call starts from the asset default again, not from what the first left. + expect(ctx.variables.result).toBe(1); + // The caller has a `count` of its own – the nested run must not have written into it. + expect(ctx.variables.count).toBe(500); + }); + + it('runs the nested graph in its own frame, so equal node ids cannot collide', () => { + const fixture = makeProject(); + const callee = makeBehavior(fixture, 'Ninetynine'); + const cEdit = graphEditor(fixture, callee); + const start = cEdit.addNode(BEHAVIOR_START); + const end = cEdit.addNode(BEHAVIOR_END); + fixture.stack.execute(new AddPortCommand(callee.id, BEHAVIOR_END, { key: 'out1', kind: 'number' })); + const theirs = cEdit.addNode('Number', { value: 99 }); + cEdit.wire([ + [start, 'exec', end, 'exec'], + [theirs, 'value', end, 'out1'], + ]); + + const caller = makeBehavior(fixture, 'Caller', ['result']); + const edit = graphEditor(fixture, caller); + const onStart = edit.addNode('OnStart'); + const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id }); + const set = edit.addNode('SetVariable', { variable: 'result' }); + const mine = edit.addNode('Number', { value: 7 }); + edit.wire([ + [onStart, 'exec', call, 'exec'], + // Read *after* the call, and from the caller's own node – the point of the test. + [call, 'exec', set, 'exec'], + [mine, 'value', set, 'value'], + ]); + + // Ids are unique in practice; forcing a collision is the only way to show the frames are real. + // Both nodes have a `value` output, so a shared cache would hand the caller the nested 99. + const shared = 'same-id'; + retarget(callee.graph, theirs.id, shared); + retarget(caller.graph, mine.id, shared); + expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(7); + }); + + it('refuses to run itself rather than looping forever', () => { + const fixture = makeProject(); + const asset = doubler(fixture, 'Loop'); + const edit = graphEditor(fixture, asset); + const start = terminalNode(asset.graph, BEHAVIOR_START)!; + const self = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: asset.id }); + edit.connect(start, 'exec', self, 'exec'); + const caller = callerOf(fixture, asset, 1); + expect(() => runOnStart(fixture, caller, { result: 0 })).toThrow(/runs itself/); + }); + + it('a call into nothing is quiet: no behavior chosen, or one without a start node', () => { + const fixture = makeProject(); + const empty = makeBehavior(fixture, 'Empty'); + for (const target of ['', empty.id]) { + const caller = makeBehavior(fixture, `C${target}`, ['result']); + const edit = graphEditor(fixture, caller); + const start = edit.addNode('OnStart'); + const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: target }); + const set = edit.addNode('SetVariable', { variable: 'result' }); + edit.wire([ + [start, 'exec', call, 'exec'], + // The flow carries on past the call, which is what keeps a dangling reference harmless. + [call, 'exec', set, 'exec'], + ]); + expect(runOnStart(fixture, caller, { result: 7 }).ctx.variables.result).toBe(0); + } + }); +}); + +describe('declared ports', () => { + it('renaming one rewrites the edges in this graph and in every calling one', () => { + const fixture = makeProject(); + const callee = doubler(fixture); + const caller = callerOf(fixture, callee, 3); + fixture.stack.execute(new RenamePortCommand(callee.id, BEHAVIOR_START, 'in1', 'factor')); + const start = terminalNode(callee.graph, BEHAVIOR_START)!; + expect(start.ports).toEqual([{ key: 'factor', kind: 'number' }]); + expect(callee.graph.edges.some((e) => e.from.port === 'factor')).toBe(true); + // The value typed into the calling node moves with the port; losing it would be silent. + expect(caller.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!.params).toMatchObject({ factor: 3 }); + expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(6); + }); + + it('removing one takes the edges hanging on it, in both graphs, and undo brings them back', () => { + const fixture = makeProject(); + const callee = doubler(fixture); + const caller = callerOf(fixture, callee, 4); + const edgesBefore = { callee: callee.graph.edges.length, caller: caller.graph.edges.length }; + fixture.stack.execute(new RemovePortCommand(callee.id, BEHAVIOR_END, 'out1')); + expect(callee.graph.edges.length).toBe(edgesBefore.callee - 1); + expect(caller.graph.edges.length).toBe(edgesBefore.caller - 1); + fixture.stack.undo(); + expect(callee.graph.edges.length).toBe(edgesBefore.callee); + expect(caller.graph.edges.length).toBe(edgesBefore.caller); + expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(8); + }); + + it('survives saving and loading', () => { + const fixture = makeProject(); + const callee = doubler(fixture); + callerOf(fixture, callee, 6); + const loaded = deserializeProject(JSON.parse(JSON.stringify(serializeProject(fixture.project)))); + const reloaded = loaded.assets.find((a) => a.id === callee.id) as BehaviorAsset; + expect(terminalNode(reloaded.graph, BEHAVIOR_START)?.ports).toEqual([{ key: 'in1', kind: 'number' }]); + const caller = loaded.assets.find((a) => a.name === 'Caller') as BehaviorAsset; + const interpreter = new GraphInterpreter(caller.graph, behaviorLookup(loaded)); + const ctx = fakeContext({ variables: { result: 0 } }); + interpreter.fire('OnStart', ctx); + expect(ctx.variables.result).toBe(12); + }); + + it('a behavior has one start and one end node, never two', () => { + const fixture = makeProject(); + const asset = doubler(fixture); + for (const type of [BEHAVIOR_START, BEHAVIOR_END]) { + expect(() => fixture.stack.execute(new AddNodeCommand(asset.id, type, 0, 0))).toThrow(CommandError); + } + }); +}); + +/** Gives a node a different id, edges included – used to force an id collision across graphs. */ +function retarget(graph: Graph, from: string, to: string): void { + graph.nodes.find((n) => n.id === from)!.id = to; + for (const edge of graph.edges) { + if (edge.from.node === from) edge.from.node = to; + if (edge.to.node === from) edge.to.node = to; + } +} diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 85291028..eae414c7 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -20,6 +20,8 @@ interface Case { next?: string | string[] | null; /** Expected loop request; `null` asserts the node does not loop. */ repeat?: RunResult['repeat'] | null; + /** Expected nested-behavior call; `null` asserts the node calls nothing. */ + call?: RunResult['call'] | null; /** Assertions on what the node did to the context or to the handles it was given. */ after?(ctx: FakeContext): void; } @@ -45,6 +47,28 @@ const CASES: Record = { { name: '"false" as text is false', inputs: { condition: 'false' }, next: 'else' }, ], Sequence: [{ name: 'runs all three outputs in order', next: ['a', 'b', 'c'] }], + BehaviorEnd: [ + { + name: 'hands its inputs back out, which is where the caller reads the result', + inputs: { hit: true, damage: 7 }, + outputs: { hit: true, damage: 7 }, + next: null, + }, + ], + RunBehavior: [ + { + name: 'asks for the named behavior to run, passing its inputs on', + inputs: { power: 3 }, + params: { behavior: 'b1' }, + call: { behaviorId: 'b1', inputs: { power: 3 } }, + next: 'exec', + }, + { + name: 'without a behavior chosen it still continues, calling nothing that exists', + call: { behaviorId: '', inputs: {} }, + next: 'exec', + }, + ], Repeat: [ { name: 'asks for as many body rounds as it was given, then continues at done', @@ -534,6 +558,7 @@ function eqNamesakes(): Record { * seeds their outputs. The value names the test that drives each one. */ const EVENT_NODES: Record = { + BehaviorStart: 'nested-behavior.test.ts', OnStart: 'graph.test.ts', OnUpdate: 'graph.test.ts', OnKeyDown: 'behavior-events.test.ts', @@ -590,6 +615,7 @@ for (const [type, cases] of Object.entries(CASES)) { if (c.outputs) expect(result.outputs ?? {}).toEqual(c.outputs); if (c.next !== undefined) expect(result.next ?? null).toEqual(c.next); if (c.repeat !== undefined) expect(result.repeat ?? null).toEqual(c.repeat); + if (c.call !== undefined) expect(result.call ?? null).toEqual(c.call); c.after?.(ctx); }); } diff --git a/todo-notes/done/nested-behavior.md b/todo-notes/done/nested-behavior.md new file mode 100644 index 00000000..2c58159b --- /dev/null +++ b/todo-notes/done/nested-behavior.md @@ -0,0 +1,104 @@ +## Nested Behavior / Behavior als Node in Graph + +Was damit gemeint ist? +=> Ein Behavior Asset als Node innerhalb eines Graph + +Grundsätzlich soll so ein Behavior Node im Graf mit einem Execution Flow ausgestattet sein, sprich wir brauchen einen Start/End Node: +- Start Node ist ähnlich wie ein Event Node mit exec output port und dort soll man auch ouput "variablen" festlegen können (quasi wie variablen) +- End Node ähnlich, hat als Input ein exec port + inputs die als output gelten, wenn man es als node in einen anderen graphen eines behavior assets einbindet. + +wenn alles verstanden, direkt loslegen, ansonsten warte ich auf rückfragen :) + +--- + +## Umgesetzt + +Verstanden war alles — gebaut ist genau die beschriebene Form. Drei neue Node-Typen: + +| | | +|---|---| +| **`Start`** | exec-Ausgang plus die Eingaenge, die das Behavior von aussen bekommt. Was hier deklariert wird, ist ein **Eingang des aufrufenden Nodes** | +| **`End`** | exec-Eingang plus die Werte, die zurueckgehen. Was hier deklariert wird, ist ein **Ausgang des aufrufenden Nodes** | +| **`Run behavior`** | der Aufruf selbst. Seine Ports gibt es nicht in seiner Typdefinition — sie werden aus Start- und End-Node des Ziels **abgeleitet**, bei jedem Zeichnen neu | + +Deklariert wird im Inspector des Start-/End-Nodes: `+ Input` bzw. `+ Output`, Art waehlbar +(Number, Boolean, Text, Object), Name direkt editierbar, `×` zum Entfernen — dieselbe Bauform +wie die Variablen eines Behaviors. + +**Der Node traegt den Namen des Behaviors**, nicht "Run behavior", und die Palette listet +Behaviors ebenfalls namentlich. Ein Aufruf ist damit ein Griff statt Node einfuegen + Dropdown. + +## Die eine Entscheidung, die du mir ueberlassen hast + +Ein eingebettetes Behavior hat kein Objekt in der Szene — also auch keinen Ort fuer seine +Variablen. Ich habe **(A) reine Funktion** gewaehlt: jeder Aufruf startet die Variablen auf den +Asset-Defaults, rein geht es ueber den Start-Node, raus ueber den End-Node. + +Der Grund ist dein eigener naechster Schritt: du willst den Tetris-Graphen zerlegen. Dabei +sollen die Teile *rechnen*, waehrend der Zustand (`grid`, `pieceX`, …) im Haupt-Behavior +bleibt — genau das, was du mit "kann man ueber input/output loesen" gemeint hast. (A) ist +ausserdem die konservative Wahl: daraus laesst sich spaeter Zustand machen, umgekehrt nicht, +ohne bestehende Graphen zu brechen. + +**Die Tuer ist nicht zu:** Zustand gibt es weiterhin ueber ein Behavior-Objekt in der Szene +(`Object by name` → `Get/Set property`) — der Weg, den dein Jump and Run fuer den Punktestand +schon geht. Lokale Variablen im eingebetteten Graphen funktionieren als Zwischenspeicher +innerhalb eines Aufrufs unveraendert; sie werden nur am Ende verworfen. + +## Was ich selbst entschieden habe + +| | | +|---|---| +| **Ein Start, ein End** | ein zweiter machte "welche Ports hat dieses Behavior" mehrdeutig. Der Command lehnt ihn ab, die Palette bietet ihn nicht mehr an | +| **Selbstaufruf = Fehler** | mit Namen im Log (`Behavior "X" runs itself`), statt still in die Tiefenbegrenzung zu laufen. Die Palette bietet das eigene Behavior gar nicht erst an | +| **`self`/`owner` bleiben** | der eingebettete Graph sieht dasselbe Objekt wie der Aufrufer — sonst waeren `Self`/`Owner` darin wertlos | +| **Toter Verweis ist still** | ein Node ohne gewaehltes Behavior (oder eines ohne Start-Node) tut nichts und **laesst den Fluss weiterlaufen**, statt den Lauf abzubrechen | + +## Zwei Dinge, die der Bau erzwungen hat + +**Eigener Pulse-Cache pro Aufruf.** Node-Ids sind nur *pro Graph* eindeutig. Liefe der +eingebettete Graph im Cache des Aufrufers, koennte er dessen Werte ueberschreiben. Der +Interpreter arbeitet deshalb auf **Frames** — der Aufruf schiebt einen neuen auf und nimmt ihn +danach wieder weg. Dafuer gibt es einen eigenen Test, der eine Id-Kollision erzwingt; ohne +Frames liefert er 99 statt 7. + +**Ports aufraeumen ueber Graphgrenzen.** Einen Port umzubenennen oder zu entfernen betrifft +nicht nur den eigenen Graphen, sondern **jeden anderen, der das Behavior aufruft**. Beides +raeumt dort mit auf — und ein Rename nimmt den eingetippten Inline-Wert mit, sonst faellt er +still auf den Default zurueck. Das ist mir erst beim Gegenpruefen aufgefallen: der Test stand +auf 0 statt auf 6. + +**Live-Highlight:** der Trace eines laufenden Behaviors deckt jetzt auch die Graphen ab, die es +als Node aufruft — sonst bliebe ein eingebettetes Behavior beim Zusehen dunkel. + +## Belegt + +**Zehn Unit-Tests** (`tests/core/nested-behavior.test.ts`) und **elf E2E-Checks** in Modul 19. +Der staerkste ist der erste: das aufrufende Behavior rechnet **nichts** selbst — die +Geschwindigkeit, mit der die Kiste laeuft, *ist* der zurueckgegebene Wert. + +``` +PASS a behavior used as a node runs, and what it returns is what drives the caller + – moved 200.0 px in ~1 s – 2 × 100 means the returned value arrived, 0 would mean nothing came back +PASS the calling node grows the ports the other behavior declares +PASS and it is drawn under that behavior's name, not as "Run behavior" – title="Double" +PASS adding an input declares it on the start node – [{"key":"in1",…},{"key":"in2",…}] +PASS and the node in the other graph grew that input without being touched +PASS undo removes it in both graphs – [{"key":"in1","kind":"number"}] +PASS picking a behavior in the palette drops in a node already set to it + – {"type":"RunBehavior","params":{"behavior":"nb-double"}} +``` + +`200.0` statt `100` beweist, dass der **verdoppelte** Wert ankam, nicht bloss irgendeiner; +`0` waere "nichts kam zurueck". Die drei Port-Checks in Folge sind der Nachweis, dass die +Portliste wirklich *abgeleitet* und nicht kopiert ist: deklariert wird im einen Graphen, +gewachsen ist der Node im anderen, ohne dass er angefasst wurde. + +Gegenprobe gefahren: ohne die frische Variablenkopie und ohne die Selbstaufruf-Sperre fallen +zwei Unit-Tests; ohne getrennte Frames faellt der Kollisionstest (`expected 99 to be 7`) — der +war beim ersten Versuch faelschlich gruen und musste geschaerft werden, bevor er etwas taugte. + +| | | +|---|---| +| volle E2E-Suite, remote | **ALL PASSED – 379 Checks**, 0 FAIL | +| `npm run check` lokal | gruen, **524 Tests** | diff --git a/todo-notes/nested-behavior.md b/todo-notes/nested-behavior.md deleted file mode 100644 index ce42b4f8..00000000 --- a/todo-notes/nested-behavior.md +++ /dev/null @@ -1,10 +0,0 @@ -## Nested Behavior / Behavior als Node in Graph - -Was damit gemeint ist? -=> Ein Behavior Asset als Node innerhalb eines Graph - -Grundsätzlich soll so ein Behavior Node im Graf mit einem Execution Flow ausgestattet sein, sprich wir brauchen einen Start/End Node: -- Start Node ist ähnlich wie ein Event Node mit exec output port und dort soll man auch ouput "variablen" festlegen können (quasi wie variablen) -- End Node ähnlich, hat als Input ein exec port + inputs die als output gelten, wenn man es als node in einen anderen graphen eines behavior assets einbindet. - -wenn alles verstanden, direkt loslegen, ansonsten warte ich auf rückfragen :) diff --git a/tools/e2e/19-nested-behavior.mjs b/tools/e2e/19-nested-behavior.mjs new file mode 100644 index 00000000..03fcfcfd --- /dev/null +++ b/tools/e2e/19-nested-behavior.mjs @@ -0,0 +1,216 @@ +import { edge, node, port } from '../lib/builders.mjs'; + +/** + * A behavior used as a node inside another behavior's graph: the ports it grows from the other + * graph's start/end nodes, the call itself while playing, and declaring a port across the two. + * + * The step brings its own box and its own two behaviors; everything it adds is removed at the end. + */ + +const SCALE = 100; + +/** `in1` → ×2 → `out1`, entered at the start node and left at the end node. */ +const doubleGraph = { + nodes: [ + node('nb-start', 'BehaviorStart', 0, 0, {}, [port('in1', 'number')]), + node('nb-times', 'Multiply', 240, 60, { b: 2 }), + node('nb-end', 'BehaviorEnd', 520, 0, {}, [port('out1', 'number')]), + ], + edges: [ + edge('nb-e1', 'nb-start', 'exec', 'nb-end', 'exec'), + edge('nb-e2', 'nb-start', 'in1', 'nb-times', 'a'), + edge('nb-e3', 'nb-times', 'result', 'nb-end', 'out1'), + ], +}; + +/** + * `On update → Run(Double, in1 = 1) → Move(owner, dx = out1)`. The caller does no arithmetic of + * its own: the speed it moves at *is* the value the nested behavior handed back. + */ +const callerGraph = { + nodes: [ + node('nb-update', 'OnUpdate', 0, 0), + node('nb-owner', 'Owner', 0, 160), + node('nb-call', 'RunBehavior', 260, 0, { behavior: 'nb-double', in1: 1 }), + node('nb-move', 'Move', 560, 0, { scale: SCALE }), + ], + edges: [ + edge('nb-c1', 'nb-update', 'exec', 'nb-call', 'exec'), + edge('nb-c2', 'nb-call', 'exec', 'nb-move', 'exec'), + edge('nb-c3', 'nb-call', 'out1', 'nb-move', 'dx'), + edge('nb-c4', 'nb-update', 'dt', 'nb-move', 'dt'), + edge('nb-c5', 'nb-owner', 'object', 'nb-move', 'object'), + ], +}; + +export async function nestedBehavior(t) { + const { + check, + sleep, + evaluate, + json, + shot, + click, + clickText, + typeText, + locate, + press, + undo, + object, + entity, + asset, + mutate, + stop, + } = t; + const portAt = (nodeId, key, isOutput) => + json(`__gm.graph.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`); + const startPorts = async () => + (await asset('nb-double')).graph.nodes.find((n) => n.id === 'nb-start').ports ?? []; + + // 19a. Two behaviors and a box wearing the calling one. Written into the model directly: what + // is under test is the call, not another way of drawing a graph. + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + const proto = scene.objects[0]; + const mover = p.assets.find((a) => a.id === 'mover'); + const behavior = (id, name, graph) => { + const a = structuredClone(mover); + Object.assign(a, { id, name, props: { enabled: true, runInEditor: false }, variables: [], graph }); + p.assets.push(a); + }; + behavior('nb-double', 'Double', ${JSON.stringify(doubleGraph)}); + behavior('nb-caller', 'Caller', ${JSON.stringify(callerGraph)}); + const make = (id, assetId, parentId, name) => { + const o = structuredClone(proto); + Object.assign(o, { + id, assetId, name, + position: { x: 0, y: 700, z: 0 }, + parentId, overrides: {}, instanceEdits: {}, + }); + return o; + }; + scene.objects.push(make('nb-box', 'crate', '', 'Nested box'), make('nb-logic', 'nb-caller', 'nb-box', 'Caller')); + `); + await sleep(400); + + // 19b. The call really runs, and the value really comes back: the box moves at dx × scale, and + // dx is nothing the caller holds – it is what the nested graph returned. Without the result + // dx would be 0 and the box would not move at all. + const boxX = async () => (await object('nb-box')).position.x; + const idleX = await boxX(); + await t.play(); + await sleep(300); + const before = (await entity('nb-box')).x; + await sleep(1000); + const after = (await entity('nb-box')).x; + const speed = after - before; + check( + 'a behavior used as a node runs, and what it returns is what drives the caller', + speed > SCALE * 1.4 && speed < SCALE * 2.6, + `moved ${speed.toFixed(1)} px in ~1 s – 2 × ${SCALE} means the returned value arrived, 0 would mean nothing came back`, + ); + await stop(); + await sleep(300); + check( + 'the stored object is untouched by the run, as in any play session', + (await boxX()) === idleX, + `x=${await boxX()}`, + ); + + // 19c. The node in the graph carries the *other* behavior's ports and name – neither is part of + // its own type. + await evaluate( + `__gm.state.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] })`, + ); + await sleep(300); + const inPort = await portAt('nb-call', 'in1', false); + const outPort = await portAt('nb-call', 'out1', true); + check( + 'the calling node grows the ports the other behavior declares', + Boolean(inPort) && Boolean(outPort) && outPort.x > inPort.x, + JSON.stringify({ inPort, outPort }), + ); + const title = await evaluate(`__gm.graph.nodeLabel('nb-call')`); + check( + 'and it is drawn under that behavior\'s name, not as "Run behavior"', + title === 'Double', + `title=${JSON.stringify(title)}`, + ); + await shot('37-nested-behavior'); + + // 19d. Declaring a port on one behavior reaches the node in the other one, live. This is the + // check that a port list derived from another graph is really derived and not copied. + check('the calling node has no second input yet', !(await portAt('nb-call', 'in2', false))); + await evaluate( + `__gm.state.select({ kind: 'node', assetId: 'nb-double', nodeId: 'nb-start', nodeIds: ['nb-start'] })`, + ); + await sleep(300); + const addButton = await locate('+ Input'); + check('the start node offers its inputs in the inspector', Boolean(addButton), JSON.stringify(addButton)); + await shot('38-behavior-ports'); + await click(addButton.x, addButton.y); + await sleep(200); + await clickText('Number'); + await sleep(300); + const declared = await startPorts(); + check( + 'adding an input declares it on the start node', + declared.length === 2 && declared[1].key === 'in2' && declared[1].kind === 'number', + JSON.stringify(declared), + ); + await evaluate( + `__gm.state.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] })`, + ); + await sleep(300); + check( + 'and the node in the other graph grew that input without being touched', + Boolean(await portAt('nb-call', 'in2', false)), + ); + + // 19e. Undo takes the port back, and with it the port on the calling node. + await undo(); + await sleep(300); + const afterUndo = await startPorts(); + check( + 'undo removes it in both graphs', + afterUndo.length === 1 && !(await portAt('nb-call', 'in2', false)), + `${JSON.stringify(afterUndo)}`, + ); + + // 19f. The palette offers a behavior under its own name, and picking it leaves a node that + // already names it – one pick, not a pick plus a dropdown. ("Double" also labels the asset + // in the list on the left, so the proof is the node that appears, not the row.) + const nodeIds = async () => (await asset('nb-caller')).graph.nodes.map((n) => n.id); + const idsBefore = await nodeIds(); + const centre = await json('__gm.graph.center()'); + await click(centre.x + 250, centre.y + 250, 'right'); + await sleep(150); + check('node palette opens', Boolean(await locate('Search nodes…'))); + await typeText('Double'); + await sleep(200); + // Enter takes the first match. NOT a click on the label: "Double" also names the asset in the + // list on the left, and that is what a click by text would find first. + await press('Enter'); + await sleep(300); + const added = (await asset('nb-caller')).graph.nodes.find((n) => !idsBefore.includes(n.id)); + check( + 'picking a behavior in the palette drops in a node already set to it', + (await nodeIds()).length === idsBefore.length + 1 && + added?.type === 'RunBehavior' && + added?.params.behavior === 'nb-double', + JSON.stringify(added && { type: added.type, params: added.params }), + ); + await undo(); + await sleep(200); + + // 19g. Clean up: the two behaviors and the box are gone again. + await evaluate(`__gm.state.select(null)`); + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + p.assets = p.assets.filter((a) => a.id !== 'nb-double' && a.id !== 'nb-caller'); + scene.objects = scene.objects.filter((o) => o.id !== 'nb-box' && o.id !== 'nb-logic'); + `); + await sleep(300); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 31eaef95..a9f3ac45 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -21,6 +21,7 @@ import { aspect } from './15-aspect.mjs'; import { containerTab } from './16-container.mjs'; import { editorRun } from './17-editor-run.mjs'; import { pause } from './18-pause.mjs'; +import { nestedBehavior } from './19-nested-behavior.mjs'; export const STEPS = [ hierarchy, @@ -41,4 +42,5 @@ export const STEPS = [ containerTab, editorRun, pause, + nestedBehavior, ]; diff --git a/tools/lib/builders.mjs b/tools/lib/builders.mjs index 3c64dcf9..871bec5e 100644 --- a/tools/lib/builders.mjs +++ b/tools/lib/builders.mjs @@ -33,8 +33,18 @@ export const edge = (id, from, fromPort, to, toPort) => ({ to: { node: to, port: toPort }, }); -/** A graph node; `params` holds settings and inline input values alike. */ -export const node = (id, type, x, y, params = {}) => ({ id, type, x, y, params }); +/** A graph node; `params` holds settings and inline input values alike. `ports` only on start/end. */ +export const node = (id, type, x, y, params = {}, ports) => ({ + id, + type, + x, + y, + params, + ...(ports ? { ports } : {}), +}); + +/** A port a behavior's start or end node declares. */ +export const port = (key, kind) => ({ key, kind }); /** A behavior variable declaration. */ export const variable = (key, kind, value) => ({ key, label: key, kind, default: value });