diff --git a/README.md b/README.md index 2130d58b..dbf7bfbf 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ shown on the node and editable in the inspector. `Substring`, `Find text`, `Equals`, lists `List` (a constant), `Fill` (one value repeated – how an empty board is made), `Count`, `Item at`, `Set item` (a *new* list, the one that came in is left alone), `Sub list`, - `Join lists`, `Index of`, + `Join lists`, `Index of`, structs `Pack`/`Expand`, `Length`, `Distance`, `Normalize`, `Axis input` (four keys → a normalized direction, so diagonals are not faster), `Compare`, `And/Or/Not`, `Key is down`, `Mouse position`, `Mouse button`, `Is hovered`, `Is touching` · @@ -125,6 +125,23 @@ shown on the node and editable in the inspector. (ports, settings, `run`), listed in `nodes/index.ts`; palette, ports, validation and inspector fields are generated from it. A port may declare a `default` for its inline value (e.g. `Move.scale = 1`), shown on the node and in the inspector. +- **Ports that are not fixed by the type**: a `NodeDef` may say `declaresPorts` (the node carries + its own list, edited in the inspector) or `derivePorts` (the node computes them from its + settings). `resolveDef(node, lookup)` is what applies both, so the canvas and the inspector see + a definition that fits *this* node. `Sequence` counts its outputs from an **Outputs** setting + (2…26, keys `a`, `b`, `c`, … – the old three keep their edges), `Run behavior` takes its ports + from the behavior it names, and a behavior's `Start`/`End` node declares them. Turning a number + down, or pointing a call at a different behavior, **takes the edges of the ports that go away** + and gives them back on undo: an edge on a port that no longer exists draws as nothing but + survives every save, and would come back to life once the port returns. +- **Structs**: several named values travelling as one, so a bundle needs one edge instead of one + per part. `Pack` declares its fields as inputs, `Expand` reads them back out as outputs, and a + behavior port may be declared `struct` – which is how a bundle crosses a call. Fields are + primitives or lists, never another struct, for the same reason a list is flat. A live entity is + an object on a data edge too, so it carries a `Symbol` brand (`ENTITY_HANDLE`) that no struct + field name can impersonate; nothing may hand a property value to `String()` (`textOf` instead), + or a struct would read as `[object Object]`. A struct port has no inline value (its parts come + from a `Pack`), and compatibility is per *kind*, not per shape – any struct fits any struct port. - **Loops**: `Repeat` runs its `body` output a number of times, counting `index` up from 0, and continues at `done`. Data nodes are cached for the length of one event pulse, but a loop round drops that cache, so an expression built on `index` really changes with it. That is what makes diff --git a/src/core/commands/graphCommands.ts b/src/core/commands/graphCommands.ts index 7b843a15..ffebeb30 100644 --- a/src/core/commands/graphCommands.ts +++ b/src/core/commands/graphCommands.ts @@ -114,8 +114,15 @@ export class MoveNodeCommand implements Command { } } -/** Sets a node param or the inline value of an unconnected data input. */ +/** + * Sets a node param or the inline value of an unconnected data input. A param can decide how many + * ports the node has, so this takes the edges of ports the new value does away with: an edge on a + * port that is gone draws as nothing but survives every save, and comes back to life if the port + * ever returns. + */ export class SetNodeParamCommand extends SetBagValueCommand { + private orphans: Removed[] = []; + constructor( private readonly assetId: string, private readonly nodeId: string, @@ -125,6 +132,27 @@ export class SetNodeParamCommand extends SetBagValueCommand { super(key, `Set ${key}`); } + override execute(project: Project): void { + super.execute(project); + const graph = requireBehavior(project, this.assetId).graph; + const node = requireNode(project, this.assetId, this.nodeId); + const def = resolveDef(node, behaviorLookup(project)); + if (!def) return; + const outputs = new Set(def.outputs.map((p) => p.key)); + const inputs = new Set(def.inputs.map((p) => p.key)); + this.orphans = removeWhere( + graph.edges, + (e) => + (e.from.node === this.nodeId && !outputs.has(e.from.port)) || + (e.to.node === this.nodeId && !inputs.has(e.to.port)), + ); + } + + override undo(project: Project): void { + super.undo(project); + restoreAt(requireBehavior(project, this.assetId).graph.edges, this.orphans); + } + protected override resolveTarget(project: Project): GraphNode { return requireNode(project, this.assetId, this.nodeId); } diff --git a/src/core/commands/portCommands.ts b/src/core/commands/portCommands.ts index 2d3d4870..af3df5ab 100644 --- a/src/core/commands/portCommands.ts +++ b/src/core/commands/portCommands.ts @@ -2,12 +2,14 @@ import { BEHAVIOR_PARAM, BEHAVIOR_START, type DeclaredPort, + findNode, type Graph, type GraphEdge, type GraphNode, + isTerminalNode, isValidPortName, + requireNodeDef, RUN_BEHAVIOR, - terminalNode, } from '../graph'; import type { PropertyValue } from '../model/Properties'; import type { Project } from '../model/Project'; @@ -15,7 +17,6 @@ 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; @@ -24,20 +25,20 @@ interface PortSite { } /** - * 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. + * Where a change to `node`'s declaration has to be mirrored. For most nodes that is the node + * itself. A behavior's terminal is the exception: its ports are also ports of every node calling + * the behavior – facing the other way, because what a start node hands out the caller passes in. */ -function portSites(project: Project, assetId: string, terminalType: string): PortSite[] { +function portSites(project: Project, assetId: string, node: GraphNode): 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 }); + const asInput = requireNodeDef(node.type).declaresPorts === 'inputs'; + const sites: PortSite[] = [{ graph: asset.graph, nodeId: node.id, asInput }]; + if (!isTerminalNode(node)) return sites; 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 }); + for (const caller of other.graph.nodes) { + if (caller.type !== RUN_BEHAVIOR || caller.params[BEHAVIOR_PARAM] !== assetId) continue; + sites.push({ graph: other.graph, nodeId: caller.id, asInput: node.type === BEHAVIOR_START }); } } return sites; @@ -50,9 +51,10 @@ function edgesOnPort(site: PortSite, key: string): (edge: GraphEdge) => boolean : 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'); +/** The node whose declaration is being edited; a terminal is addressed by id like any other. */ +function requirePortNode(project: Project, assetId: string, nodeId: string): GraphNode { + const node = findNode(requireBehavior(project, assetId).graph, nodeId); + if (!node) throw new CommandError(`Node ${nodeId} not found`); return node; } @@ -75,14 +77,14 @@ export class AddPortCommand implements Command { constructor( private readonly assetId: string, - private readonly terminalType: string, + private readonly nodeId: string, private readonly port: DeclaredPort, ) { this.label = `Add ${port.key}`; } execute(project: Project): void { - const node = requireTerminal(project, this.assetId, this.terminalType); + const node = requirePortNode(project, this.assetId, this.nodeId); 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`); @@ -90,7 +92,7 @@ export class AddPortCommand implements Command { } undo(project: Project): void { - const node = requireTerminal(project, this.assetId, this.terminalType); + const node = requirePortNode(project, this.assetId, this.nodeId); node.ports = (node.ports ?? []).filter((p) => p.key !== this.port.key); } } @@ -104,15 +106,15 @@ export class RemovePortCommand implements Command { constructor( private readonly assetId: string, - private readonly terminalType: string, + private readonly nodeId: 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 node = requirePortNode(project, this.assetId, this.nodeId); + const sites = portSites(project, this.assetId, node); const ports = [...(node.ports ?? [])]; const removed = removeFirst(ports, (p) => p.key === this.key); if (!removed) throw new CommandError(`Port "${this.key}" not found`); @@ -134,7 +136,7 @@ export class RemovePortCommand implements Command { undo(project: Project): void { if (!this.removed) return; - const node = requireTerminal(project, this.assetId, this.terminalType); + const node = requirePortNode(project, this.assetId, this.nodeId); const ports = [...(node.ports ?? [])]; restoreAt(ports, [this.removed]); node.ports = ports; @@ -152,7 +154,7 @@ export class RenamePortCommand implements Command { constructor( private readonly assetId: string, - private readonly terminalType: string, + private readonly nodeId: string, private readonly from: string, private readonly to: string, ) { @@ -169,8 +171,8 @@ export class RenamePortCommand implements Command { 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); + const node = requirePortNode(project, this.assetId, this.nodeId); + const sites = portSites(project, this.assetId, node); 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`); diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index bbfac775..5a0452a6 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -1,12 +1,6 @@ import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph'; -import { - BEHAVIOR_END, - BEHAVIOR_START, - type BehaviorLookup, - NO_BEHAVIORS, - resolveDef, - terminalNode, -} from './nested'; +import { type BehaviorLookup, NO_BEHAVIORS } from './lookup'; +import { BEHAVIOR_END, BEHAVIOR_START, resolveDef, terminalNode } from './nested'; import type { NodeContext } from './NodeContext'; import { type CallResult, hasExecInput, inlineValue, isEventNode, type RepeatResult } from './NodeRegistry'; import type { Value } from './values'; diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index a87c2291..5bb42c17 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -1,6 +1,7 @@ import type { PropertyBag, PropertyValue } from '../model/Properties'; import type { PropertyDef } from '../properties/PropertySchema'; import type { PortType } from './Graph'; +import type { BehaviorLookup } from './lookup'; import type { NodeContext } from './NodeContext'; import { asBoolean, asNumber, asString, type Value } from './values'; @@ -41,6 +42,19 @@ export interface RunResult { call?: CallResult; } +/** What a node may compute its ports from: its own settings, and the behaviors around it. */ +export interface PortSource { + params: PropertyBag; + behaviors: BehaviorLookup; +} + +/** Ports a single node has on top of the fixed ones of its type, and a label naming what it does. */ +export interface DerivedShape { + label?: string; + inputs?: PortDef[]; + outputs?: PortDef[]; +} + /** Palette, port rendering, connection validation and inspector fields are all derived from this. */ export interface NodeDef { type: string; @@ -56,6 +70,16 @@ export interface NodeDef { * still offers them. Without it a switched-off repeat is two rows of "0" on every key node. */ hiddenParams?(params: PropertyBag): string[]; + /** + * Which side the ports this node declares for itself (`GraphNode.ports`) appear on. Setting it + * is what gives a node the port editor in the inspector. + */ + declaresPorts?: 'inputs' | 'outputs'; + /** + * Ports this node computes beyond the fixed and the declared ones – how many outputs a Sequence + * offers, which values the behavior a call node names takes. `resolveDef` applies it. + */ + derivePorts?(source: PortSource): DerivedShape; /** * Pure data nodes are evaluated on demand when a downstream input needs their value; exec nodes * run when the flow reaches them. Event nodes have no `run` – the interpreter seeds their outputs. diff --git a/src/core/graph/dropTarget.ts b/src/core/graph/dropTarget.ts index 6f6c7c3a..7c88e1a5 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 BehaviorLookup, resolveDef } from './nested'; +import type { BehaviorLookup } from './lookup'; +import { 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. */ diff --git a/src/core/graph/index.ts b/src/core/graph/index.ts index 897121eb..59de1228 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 './lookup'; export * from './nested'; export * from './NodeContext'; export * from './NodeRegistry'; diff --git a/src/core/graph/lookup.ts b/src/core/graph/lookup.ts new file mode 100644 index 00000000..54590ebf --- /dev/null +++ b/src/core/graph/lookup.ts @@ -0,0 +1,13 @@ +import type { PropertyBag } from '../model/Properties'; +import type { Graph } from './Graph'; + +/** 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; diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index 14ab8ae3..64e2c44d 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -1,5 +1,6 @@ -import { type PropertyBag, textOf } from '../model/Properties'; +import { textOf } from '../model/Properties'; import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph'; +import type { BehaviorLookup } from './lookup'; import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry'; export const BEHAVIOR_START = 'BehaviorStart'; @@ -8,17 +9,6 @@ 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'; @@ -37,34 +27,31 @@ export function isTerminalNode(node: GraphNode): boolean { return isTerminalType(node.type); } -export function portsOf(port: DeclaredPort): PortDef { +export function portDefOf(port: DeclaredPort): PortDef { return { key: port.key, label: port.key, type: declaredPortType(port.kind) }; } -function declaredOf(graph: Graph | undefined, type: string): PortDef[] { +/** The ports the start (or end) node of `graph` declares, as seen from a node calling it. */ +export function declaredPorts(graph: Graph | undefined, type: string): PortDef[] { const node = graph && terminalNode(graph, type); - return (node?.ports ?? []).map(portsOf); + return (node?.ports ?? []).map(portDefOf); } /** - * 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. + * The node definition as it applies to *this* node: identical to the registered one, unless the + * type declares ports per node (`declaresPorts`) or computes them (`derivePorts`). */ 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] ?? '')); + if (!def || (!def.declaresPorts && !def.derivePorts)) return def; + const own = def.declaresPorts ? (node.ports ?? []).map(portDefOf) : []; + const side = (which: 'inputs' | 'outputs') => (def.declaresPorts === which ? own : []); + const grown = def.derivePorts?.({ params: node.params, behaviors: lookup }) ?? {}; 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)], + label: grown.label ?? def.label, + inputs: [...def.inputs, ...side('inputs'), ...(grown.inputs ?? [])], + outputs: [...def.outputs, ...side('outputs'), ...(grown.outputs ?? [])], }; } diff --git a/src/core/graph/nodes/data/ExpandStruct.ts b/src/core/graph/nodes/data/ExpandStruct.ts new file mode 100644 index 00000000..0987fb0c --- /dev/null +++ b/src/core/graph/nodes/data/ExpandStruct.ts @@ -0,0 +1,23 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asStruct } from '../../values'; +import { struct } from '../shared'; + +/** + * Takes a bundle apart into one output per field. The fields are declared on the node itself, the + * same way a behavior's start node declares what it takes – which is what makes its ports follow + * the shape instead of being fixed by the type. + */ +export const ExpandStruct: NodeDef = { + type: 'ExpandStruct', + label: 'Expand', + category: 'data', + description: + 'Reads the fields of a struct, one output per field. A field the struct does not carry ' + + 'reads as empty.', + inputs: [struct('value')], + outputs: [], + params: [], + declaresPorts: 'outputs', + // A declared output the struct has no field for is simply absent from the cache, and reads as empty. + run: (_ctx, inputs) => ({ outputs: { ...asStruct(inputs.value) } }), +}; diff --git a/src/core/graph/nodes/data/PackStruct.ts b/src/core/graph/nodes/data/PackStruct.ts new file mode 100644 index 00000000..c14423f0 --- /dev/null +++ b/src/core/graph/nodes/data/PackStruct.ts @@ -0,0 +1,24 @@ +import type { FieldValue } from '../../../model/Properties'; +import type { NodeDef } from '../../NodeRegistry'; +import { asField } from '../../values'; +import { struct } from '../shared'; + +/** + * Bundles its declared inputs into one struct, so a group of values travels along a single edge + * instead of one edge per part. The counterpart of Expand. + */ +export const PackStruct: NodeDef = { + type: 'PackStruct', + label: 'Pack', + category: 'data', + description: 'Bundles named values into one struct, one input per field.', + inputs: [], + outputs: [struct('value')], + params: [], + declaresPorts: 'inputs', + run: (_ctx, inputs) => { + const fields: Record = {}; + for (const [field, value] of Object.entries(inputs)) fields[field] = asField(value); + return { outputs: { value: fields } }; + }, +}; diff --git a/src/core/graph/nodes/flow/BehaviorEnd.ts b/src/core/graph/nodes/flow/BehaviorEnd.ts index e91a1732..94377f7e 100644 --- a/src/core/graph/nodes/flow/BehaviorEnd.ts +++ b/src/core/graph/nodes/flow/BehaviorEnd.ts @@ -17,5 +17,6 @@ export const BehaviorEnd: NodeDef = { inputs: [exec()], outputs: [], params: [], + declaresPorts: 'inputs', run: (_ctx, inputs) => ({ outputs: inputs }), }; diff --git a/src/core/graph/nodes/flow/BehaviorStart.ts b/src/core/graph/nodes/flow/BehaviorStart.ts index 7f6ecd52..16908e28 100644 --- a/src/core/graph/nodes/flow/BehaviorStart.ts +++ b/src/core/graph/nodes/flow/BehaviorStart.ts @@ -16,4 +16,5 @@ export const BehaviorStart: NodeDef = { inputs: [], outputs: [exec()], params: [], + declaresPorts: 'outputs', }; diff --git a/src/core/graph/nodes/flow/RunBehavior.ts b/src/core/graph/nodes/flow/RunBehavior.ts index 1dfd5a28..a5e6a5a9 100644 --- a/src/core/graph/nodes/flow/RunBehavior.ts +++ b/src/core/graph/nodes/flow/RunBehavior.ts @@ -1,11 +1,12 @@ import type { NodeDef } from '../../NodeRegistry'; -import { BEHAVIOR_PARAM, RUN_BEHAVIOR } from '../../nested'; +import { textOf } from '../../../model/Properties'; +import { BEHAVIOR_END, BEHAVIOR_PARAM, BEHAVIOR_START, declaredPorts, 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. + * Runs another behavior's graph in place, from its start node to its end node. Its ports are those + * the named behavior declares; the interpreter does the call, because only it can hold a second + * graph's pulse. */ export const RunBehavior: NodeDef = { type: RUN_BEHAVIOR, @@ -17,8 +18,17 @@ export const RunBehavior: NodeDef = { inputs: [exec()], outputs: [exec()], params: [{ key: BEHAVIOR_PARAM, label: 'Behavior', kind: 'asset', default: '', assetTypes: ['behavior'] }], + derivePorts: (source) => { + const program = source.behaviors(textOf(source.params[BEHAVIOR_PARAM] ?? '')); + return { + // The graph reads as what it does, not as a row of identical "Run behavior" boxes. + label: program?.name, + inputs: declaredPorts(program?.graph, BEHAVIOR_START), + outputs: declaredPorts(program?.graph, BEHAVIOR_END), + }; + }, run: (_ctx, inputs, params) => ({ - call: { behaviorId: String(params[BEHAVIOR_PARAM] ?? ''), inputs }, + call: { behaviorId: textOf(params[BEHAVIOR_PARAM] ?? ''), inputs }, next: 'exec', }), }; diff --git a/src/core/graph/nodes/flow/Sequence.ts b/src/core/graph/nodes/flow/Sequence.ts index d7611a53..6c23a292 100644 --- a/src/core/graph/nodes/flow/Sequence.ts +++ b/src/core/graph/nodes/flow/Sequence.ts @@ -1,13 +1,45 @@ +import type { PropertyBag } from '../../../model/Properties'; import type { NodeDef } from '../../NodeRegistry'; +import { asNumber } from '../../values'; import { exec } from '../shared'; +const COUNT = 'count'; +/** Below two outputs a Sequence is just a link, so two are fixed and the rest are derived. */ +const FIXED = 2; +/** One letter per output, so `a` stays `a` – the alphabet is what sets the ceiling. */ +const MAX = 26; + +export function sequenceKey(index: number): string { + return String.fromCharCode(97 + index); +} + +export function sequenceCount(params: PropertyBag): number { + const count = Math.round(asNumber(params[COUNT] ?? 3)); + return Math.max(FIXED, Math.min(MAX, count)); +} + +function outputAt(index: number) { + return exec(sequenceKey(index), String(index + 1)); +} + +/** + * The keys are `a`, `b`, `c`, … rather than `out1`…`outN`: those three are what every Sequence + * saved so far names its outputs, and renaming them would drop the edges hanging on them. + */ export const Sequence: NodeDef = { type: 'Sequence', label: 'Sequence', category: 'flow', description: 'Runs its outputs one after another.', inputs: [exec()], - outputs: [exec('a', '1'), exec('b', '2'), exec('c', '3')], - params: [], - run: () => ({ next: ['a', 'b', 'c'] }), + outputs: [outputAt(0), outputAt(1)], + params: [ + { key: COUNT, label: 'Outputs', kind: 'number', default: 3, min: FIXED, max: MAX, step: 1, decimals: 0 }, + ], + derivePorts: ({ params }) => ({ + outputs: Array.from({ length: sequenceCount(params) - FIXED }, (_, i) => outputAt(FIXED + i)), + }), + run: (_ctx, _inputs, params) => ({ + next: Array.from({ length: sequenceCount(params) }, (_, i) => sequenceKey(i)), + }), }; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index f7b831b0..5afa9583 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -44,6 +44,7 @@ import { NumberLiteral } from './data/Number'; import { ObjectByName } from './data/ObjectByName'; import { Or } from './data/Or'; import { Owner } from './data/Owner'; +import { PackStruct } from './data/PackStruct'; import { Parent } from './data/Parent'; import { Random } from './data/Random'; import { Round } from './data/Round'; @@ -57,6 +58,7 @@ import { ToNumber } from './data/ToNumber'; import { ToText } from './data/ToText'; import { Concat } from './data/Concat'; import { Count } from './data/Count'; +import { ExpandStruct } from './data/ExpandStruct'; import { Fill } from './data/Fill'; import { IndexOf } from './data/IndexOf'; import { ItemAt } from './data/ItemAt'; @@ -157,6 +159,9 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ SubList, Concat, IndexOf, + // data: structs + PackStruct, + ExpandStruct, // data: vectors and input Length, Distance, diff --git a/src/core/model/behaviorPrograms.ts b/src/core/model/behaviorPrograms.ts index bd964dda..79ae2c83 100644 --- a/src/core/model/behaviorPrograms.ts +++ b/src/core/model/behaviorPrograms.ts @@ -1,4 +1,4 @@ -import type { BehaviorLookup } from '../graph/nested'; +import type { BehaviorLookup } from '../graph/lookup'; import { resolveAssetProps } from '../properties/resolve'; import { findAsset, type Project } from './Project'; diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts index 9aa5ab30..834e25e4 100644 --- a/src/editor/inspector/nodeSection.ts +++ b/src/editor/inspector/nodeSection.ts @@ -5,7 +5,6 @@ import { type GraphNode, inlineInputDef, inlineInputs, - isTerminalNode, portSignature, resolveDef, } from '../../core/graph'; @@ -51,7 +50,7 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s addNote(host, def.description); const problem = callProblem(node, behaviorLookup(host.state.project), assetId); if (problem) addNote(host, problem, 'warning'); - if (isTerminalNode(node)) buildPortSection(host, assetId, node); + if (def.declaresPorts) buildPortSection(host, assetId, node); if (def.params.length > 0) addTitle(host, 'SETTINGS'); for (const param of def.params) { diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index a4bc150c..de7a8b6f 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -4,7 +4,13 @@ import { RemovePortCommand, RenamePortCommand, } from '../../core/commands/portCommands'; -import { BEHAVIOR_START, DECLARED_PORT_KINDS, type DeclaredPortKind, type GraphNode } from '../../core/graph'; +import { + BEHAVIOR_START, + DECLARED_PORT_KINDS, + type DeclaredPortKind, + type GraphNode, + isTerminalNode, +} from '../../core/graph'; import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui'; import { addNote, type InspectorHost } from './host'; @@ -27,12 +33,14 @@ const KIND_MARKS: Record = { }; /** - * 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. + * The ports a node declares for itself: the signature of a behavior seen as a node, or the fields + * of a struct. A start node's are named from the caller's side – what it hands into the graph is + * an *input* of the behavior – which is why the side comes from the node type, not from here. */ export function buildPortSection(host: InspectorHost, assetId: string, node: GraphNode): void { const onStart = node.type === BEHAVIOR_START; - const noun = onStart ? 'Input' : 'Output'; + const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field'; + const base = noun === 'Field' ? 'field' : onStart ? 'in' : 'out'; const header = new HStack({ gap: Theme.gap, align: 'center' }); header.add(new Label(`${noun.toUpperCase()}S`, { style: 'title' }), 1); @@ -41,12 +49,7 @@ export function buildPortSection(host: InspectorHost, assetId: string, node: Gra 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, - }), - ), + host.state.execute(new AddPortCommand(assetId, node.id, { key: nextPortName(node, base), kind })), })); new Menu(items).openBelow(add); }; @@ -57,9 +60,11 @@ export function buildPortSection(host: InspectorHost, assetId: string, node: Gra 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.', + noun === 'Field' + ? 'No fields yet. Every field added here becomes a port of the struct.' + : 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) { @@ -72,7 +77,7 @@ export function buildPortSection(host: InspectorHost, assetId: string, node: Gra new TextField(port.key, { width: 90, onChange: (next) => - host.state.execute(new RenamePortCommand(assetId, node.type, port.key, next.trim())), + host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())), }), 1, ); @@ -81,7 +86,7 @@ export function buildPortSection(host: InspectorHost, assetId: string, node: Gra new Button('×', { variant: 'ghost', width: Theme.controlHeight, - onClick: () => host.state.execute(new RemovePortCommand(assetId, node.type, port.key)), + onClick: () => host.state.execute(new RemovePortCommand(assetId, node.id, port.key)), }), ); host.body.add(row); diff --git a/tests/core/dynamic-ports.test.ts b/tests/core/dynamic-ports.test.ts new file mode 100644 index 00000000..e4030b5a --- /dev/null +++ b/tests/core/dynamic-ports.test.ts @@ -0,0 +1,144 @@ +/** Ports a node grows from its own settings (`NodeDef.derivePorts`), and what happens to the edges when it shrinks. */ +import { describe, expect, it } from 'vitest'; +import { AddPortCommand } from '../../src/core/commands/portCommands'; +import { SetNodeParamCommand } from '../../src/core/commands/graphCommands'; +import { + BEHAVIOR_END, + BEHAVIOR_START, + findNode, + type GraphNode, + GraphInterpreter, + inlineInputs, + requireNodeDef, + resolveDef, +} from '../../src/core/graph'; +import { behaviorLookup } from '../../src/core/model/behaviorPrograms'; +import type { BehaviorAsset } from '../../src/core/model/Asset'; +import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; + +function sequenceFixture() { + const fixture = makeProject(); + const behavior = makeBehavior(fixture, 'B'); + const edit = graphEditor(fixture, behavior); + return { ...fixture, behavior, edit }; +} + +const portsOf = (fixture: ProjectFixture, node: GraphNode) => + resolveDef(node, behaviorLookup(fixture.project))!.outputs.map((p) => p.key); + +const edgeKeys = (behavior: BehaviorAsset) => + behavior.graph.edges.map((e) => `${e.from.port}→${e.to.port}`).sort(); + +describe('a Sequence sized by its count', () => { + it('offers three outputs by default and grows one port per step', () => { + const { stack, project, behavior, edit } = sequenceFixture(); + const node = edit.addNode('Sequence'); + expect(portsOf({ project, stack }, node)).toEqual(['a', 'b', 'c']); + stack.execute(new SetNodeParamCommand(behavior.id, node.id, 'count', 6)); + expect(portsOf({ project, stack }, findNode(behavior.graph, node.id)!)).toEqual([ + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + ]); + }); + + it('never falls below two outputs, because one output is a plain link', () => { + const { stack, project, behavior, edit } = sequenceFixture(); + const node = edit.addNode('Sequence'); + stack.execute(new SetNodeParamCommand(behavior.id, node.id, 'count', 1)); + expect(portsOf({ project, stack }, findNode(behavior.graph, node.id)!)).toEqual(['a', 'b']); + }); + + it('takes the edges of the outputs it drops, and gives them back on undo', () => { + const { stack, behavior, edit } = sequenceFixture(); + const node = edit.addNode('Sequence', { count: 4 }); + edit.wire(['a', 'b', 'c', 'd'].map((port) => [node, port, edit.addNode('Log'), 'exec'] as const)); + stack.execute(new SetNodeParamCommand(behavior.id, node.id, 'count', 2)); + expect(edgeKeys(behavior)).toEqual(['a→exec', 'b→exec']); + stack.undo(); + expect(edgeKeys(behavior)).toEqual(['a→exec', 'b→exec', 'c→exec', 'd→exec']); + }); +}); + +/** + * The point of a struct: a bundle crosses a behavior boundary on ONE edge. Tetris carries the same + * state today as eight parallel ports wired by hand, which is the pain this replaces. + */ +describe('a struct travelling through a nested behavior', () => { + it('goes in as one port and comes back out as its parts', () => { + const fixture = makeProject(); + + const callee = makeBehavior(fixture, 'Sum piece'); + const inner = graphEditor(fixture, callee); + const start = inner.addNode(BEHAVIOR_START); + const end = inner.addNode(BEHAVIOR_END); + fixture.stack.execute(new AddPortCommand(callee.id, start.id, { key: 'piece', kind: 'struct' })); + fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'total', kind: 'number' })); + const expand = inner.addNode('ExpandStruct'); + for (const key of ['x', 'y']) + fixture.stack.execute(new AddPortCommand(callee.id, expand.id, { key, kind: 'number' })); + const add = inner.addNode('Add'); + inner.wire([ + [start, 'exec', end, 'exec'], + [start, 'piece', expand, 'value'], + [expand, 'x', add, 'a'], + [expand, 'y', add, 'b'], + [add, 'result', end, 'total'], + ]); + + const caller = makeBehavior(fixture, 'Caller', ['result']); + const outer = graphEditor(fixture, caller); + const pack = outer.addNode('PackStruct', { x: 30, y: 12 }); + for (const key of ['x', 'y']) + fixture.stack.execute(new AddPortCommand(caller.id, pack.id, { key, kind: 'number' })); + const onStart = outer.addNode('OnStart'); + const call = outer.addNode('RunBehavior', { behavior: callee.id }); + const set = outer.addNode('SetVariable', { variable: 'result' }); + outer.wire([ + [onStart, 'exec', call, 'exec'], + [pack, 'value', call, 'piece'], + [call, 'exec', set, 'exec'], + [call, 'total', set, 'value'], + ]); + + // One edge into the call, where two numbers would need two. + expect(caller.graph.edges.filter((e) => e.to.node === call.id && e.to.port !== 'exec')).toHaveLength(1); + const ctx = fakeContext({ variables: { result: 0 } }); + new GraphInterpreter(caller.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx); + expect(ctx.variables.result).toBe(42); + }); +}); + +describe('a struct input', () => { + it('is offered no inline field, because a bundle cannot be typed on one line', () => { + const def = requireNodeDef('ExpandStruct'); + expect(def.inputs.map((p) => p.type)).toEqual(['struct']); + expect(inlineInputs(def)).toEqual([]); + }); +}); + +describe('a Run behavior node whose behavior changes', () => { + it('drops the edges of the ports the new behavior does not have', () => { + const fixture = makeProject(); + const called = makeBehavior(fixture, 'Called'); + const other = makeBehavior(fixture, 'Other'); + const start = graphEditor(fixture, called).addNode(BEHAVIOR_START); + fixture.stack.execute(new AddPortCommand(called.id, start.id, { key: 'power', kind: 'number' })); + + const caller = makeBehavior(fixture, 'Caller'); + const edit = graphEditor(fixture, caller); + const call = edit.addNode('RunBehavior', { behavior: called.id }); + const number = edit.addNode('Number', { value: 3 }); + edit.connect(number, 'value', call, 'power'); + expect(edgeKeys(caller)).toEqual(['value→power']); + + fixture.stack.execute(new SetNodeParamCommand(caller.id, call.id, 'behavior', other.id)); + expect(edgeKeys(caller)).toEqual([]); + fixture.stack.undo(); + expect(edgeKeys(caller)).toEqual(['value→power']); + expect(start.ports).toEqual([{ key: 'power', kind: 'number' }]); + }); +}); diff --git a/tests/core/nested-behavior.test.ts b/tests/core/nested-behavior.test.ts index ee2c9dc4..95260d3b 100644 --- a/tests/core/nested-behavior.test.ts +++ b/tests/core/nested-behavior.test.ts @@ -28,8 +28,8 @@ function doubler(fixture: ProjectFixture, name = 'Double'): BehaviorAsset { 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' })); + fixture.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'in1', kind: 'number' })); + fixture.stack.execute(new AddPortCommand(asset.id, end.id, { key: 'out1', kind: 'number' })); const times = edit.addNode('Multiply', { b: 2 }); edit.wire([ [start, 'exec', end, 'exec'], @@ -86,7 +86,7 @@ describe('a behavior used as a node', () => { 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' })); + fixture.stack.execute(new AddPortCommand(callee.id, end.id, { 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' }); @@ -124,7 +124,7 @@ describe('a behavior used as a node', () => { 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' })); + fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'out1', kind: 'number' })); const theirs = cEdit.addNode('Number', { value: 99 }); cEdit.wire([ [start, 'exec', end, 'exec'], @@ -250,7 +250,9 @@ describe('declared ports', () => { const fixture = makeProject(); const callee = doubler(fixture); const caller = callerOf(fixture, callee, 3); - fixture.stack.execute(new RenamePortCommand(callee.id, BEHAVIOR_START, 'in1', 'factor')); + fixture.stack.execute( + new RenamePortCommand(callee.id, terminalNode(callee.graph, BEHAVIOR_START)!.id, '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); @@ -264,7 +266,9 @@ describe('declared ports', () => { 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')); + fixture.stack.execute( + new RemovePortCommand(callee.id, terminalNode(callee.graph, BEHAVIOR_END)!.id, 'out1'), + ); expect(callee.graph.edges.length).toBe(edgesBefore.callee - 1); expect(caller.graph.edges.length).toBe(edgesBefore.caller - 1); fixture.stack.undo(); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 7db7d1ee..9645e54d 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -46,7 +46,11 @@ const CASES: Record = { { name: 'a non-empty string is true', inputs: { condition: 'yes' }, next: 'then' }, { name: '"false" as text is false', inputs: { condition: 'false' }, next: 'else' }, ], - Sequence: [{ name: 'runs all three outputs in order', next: ['a', 'b', 'c'] }], + Sequence: [ + { name: 'runs all three outputs in order', next: ['a', 'b', 'c'] }, + { name: 'a raised count runs that many', params: { count: 5 }, next: ['a', 'b', 'c', 'd', 'e'] }, + { name: 'two is the floor: one output is a plain link', params: { count: 0 }, next: ['a', 'b'] }, + ], BehaviorEnd: [ { name: 'hands its inputs back out, which is where the caller reads the result', @@ -340,6 +344,27 @@ const CASES: Record = { { name: 'one list after the other', inputs: { a: [1], b: [2, 3] }, outputs: { result: [1, 2, 3] } }, { name: 'a lone value joins as one item', inputs: { a: [1], b: 2 }, outputs: { result: [1, 2] } }, ], + PackStruct: [ + { + name: 'bundles its declared inputs into one value', + inputs: { x: 2, tags: ['a', 'b'] }, + outputs: { value: { x: 2, tags: ['a', 'b'] } }, + }, + { name: 'a node with no fields yet packs nothing', outputs: { value: {} } }, + { + name: 'an entity cannot be stored in a bundle, so it packs as its name', + inputs: { who: fakeEntity('Player') }, + outputs: { value: { who: 'Player' } }, + }, + ], + ExpandStruct: [ + { + name: 'reads the fields back out, one output each', + inputs: { value: { x: 2, tags: ['a'] } }, + outputs: { x: 2, tags: ['a'] }, + }, + { name: 'a value that is not a bundle has no fields', inputs: { value: 7 }, outputs: {} }, + ], IndexOf: [ { name: 'finds where a value sits', inputs: { list: [7, 8, 9], value: 8 }, outputs: { index: 1 } }, { name: 'a value that is not in there is -1', inputs: { list: [7], value: 8 }, outputs: { index: -1 } }, diff --git a/tools/lib/builders.mjs b/tools/lib/builders.mjs index 36a63b97..c904ddaf 100644 --- a/tools/lib/builders.mjs +++ b/tools/lib/builders.mjs @@ -33,7 +33,7 @@ export const edge = (id, from, fromPort, to, toPort) => ({ to: { node: to, port: toPort }, }); -/** A graph node; `params` holds settings and inline input values alike. `ports` only on start/end. */ +/** A graph node; `params` holds settings and inline input values alike. `ports` only on the types that declare them. */ export const node = (id, type, x, y, params = {}, ports) => ({ id, type,