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
@@ -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 } },
|
||||
|
||||
Reference in new issue
Block a user