Let a node work out its own ports
Ports that vary per node already existed, but only for the three nested-behavior
types, and the rule for them sat as a switch on type names in nested.ts – the
one place in the project that knew node types by name. It dissolves into two
optional contracts on NodeDef, so a type carries its own rule:
declaresPorts the node holds an editable list, shown as a section in the
inspector – behavior start/end, and now Pack/Expand
derivePorts the node computes them from its settings – Sequence from a
count, Run behavior from the behavior it names
Sequence gains an Outputs setting (2 to 26). Its keys stay a, b, c and count on
with d, e, so the edges of the sequences already out there survive.
A param can now take a port away, and the edge on it would stay: it draws as
nothing yet survives every save, and comes back to life if the port returns.
SetNodeParamCommand takes those edges with it, which also closes the same leak
that switching a Run behavior node's target has always had.
The port editor addresses its node by id rather than by type, so two Pack nodes
can live in one graph, and it calls the rows Fields when they are not a
behavior's signature.
This commit is contained in:
1 parent
2cf788eedd
commit
b6a609ae24
23 files changed
+438
-98
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<GraphNode> {
|
||||
private orphans: Removed<GraphEdge>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly nodeId: string,
|
||||
@@ -125,6 +132,27 @@ export class SetNodeParamCommand extends SetBagValueCommand<GraphNode> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -3,6 +3,7 @@ import './nodes';
|
||||
|
||||
export * from './dropTarget';
|
||||
export * from './Graph';
|
||||
export * from './lookup';
|
||||
export * from './nested';
|
||||
export * from './NodeContext';
|
||||
export * from './NodeRegistry';
|
||||
|
||||
@@ -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;
|
||||
+15
-28
@@ -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 ?? [])],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) } }),
|
||||
};
|
||||
@@ -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<string, FieldValue> = {};
|
||||
for (const [field, value] of Object.entries(inputs)) fields[field] = asField(value);
|
||||
return { outputs: { value: fields } };
|
||||
},
|
||||
};
|
||||
@@ -17,5 +17,6 @@ export const BehaviorEnd: NodeDef = {
|
||||
inputs: [exec()],
|
||||
outputs: [],
|
||||
params: [],
|
||||
declaresPorts: 'inputs',
|
||||
run: (_ctx, inputs) => ({ outputs: inputs }),
|
||||
};
|
||||
@@ -16,4 +16,5 @@ export const BehaviorStart: NodeDef = {
|
||||
inputs: [],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
declaresPorts: 'outputs',
|
||||
};
|
||||
@@ -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',
|
||||
}),
|
||||
};
|
||||
@@ -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)),
|
||||
}),
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<DeclaredPortKind, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -46,7 +46,11 @@ const CASES: Record<string, Case[]> = {
|
||||
{ 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<string, Case[]> = {
|
||||
{ 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 } },
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user