Replace the five variable nodes with one Variables node

A single node now lists any number of variables. Every entry is an output
that reads the current value whenever it is pulled; an entry switched to
Set is also an input and is written when the flow arrives. Exec pins only
exist while at least one entry writes, and all inputs are read before any
write, so one node can swap two variables.

Get/Set variable, Get/Set variables and Variable are gone. Old project
files are converted on load: each old node becomes a Variables node with
the same variables, edges and typed values.

A written port without a typed value now shows the zero of its kind on
the node body, matching what the inspector shows and what the run writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 16:56:24 +02:00
1 parent 854ae2e756
commit 7c63c884ef
40 files changed
+895 -590

No files matched your search

+2 -2
View File
@@ -111,12 +111,12 @@ describe('a struct travelling through a nested behavior', () => {
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' });
const set = outer.variables(['result'], { write: true });
outer.wire([
[onStart, 'exec', call, 'exec'],
[pack, 'value', call, 'piece'],
[call, 'exec', set, 'exec'],
[call, 'total', set, 'value'],
[call, 'total', set, 'result'],
]);
// One edge into the call, where two numbers would need two.
+4 -4
View File
@@ -93,13 +93,13 @@ describe('the enabled switch', () => {
const update = edit.addNode('OnUpdate');
const one = edit.addNode('Number', { value: 1 });
const add = edit.addNode('Add');
const get = edit.addNode('GetVariable', { variable: 'ticks' });
const set = edit.addNode('SetVariable', { variable: 'ticks' });
const get = edit.variables(['ticks']);
const set = edit.variables(['ticks'], { write: true });
edit.wire([
[update, 'exec', set, 'exec'],
[get, 'value', add, 'a'],
[get, 'ticks', add, 'a'],
[one, 'value', add, 'b'],
[add, 'result', set, 'value'],
[add, 'result', set, 'ticks'],
[start, 'exec', set, 'exec'],
]);
const counter = place(f, behavior.id, { x: 0, y: 0 });
+25 -25
View File
@@ -44,13 +44,13 @@ function withMover<F extends ProjectFixture>(fixture: F) {
const edit = graphEditor(fixture, behavior);
const held = edit.addNode('OnKeyHeld', { key: 'ArrowRight' });
const owner = edit.addNode('Owner');
const speed = edit.addNode('GetVariable', { variable: 'speed' });
const speed = edit.variables(['speed']);
const mul = edit.addNode('Multiply');
const move = edit.addNode('Move');
edit.wire([
[held, 'exec', move, 'exec'],
[owner, 'object', move, 'object'],
[speed, 'value', mul, 'a'],
[speed, 'speed', mul, 'a'],
[held, 'dt', mul, 'b'],
[mul, 'result', move, 'dx'],
]);
@@ -137,7 +137,7 @@ describe('GraphInterpreter', () => {
const update = edit.addNode('OnUpdate');
const owner = edit.addNode('Owner');
const axis = edit.addNode('AxisInput');
const speed = edit.addNode('GetVariable', { variable: 'speed' });
const speed = edit.variables(['speed']);
const move = edit.addNode('Move');
edit.wire([
[update, 'exec', move, 'exec'],
@@ -145,7 +145,7 @@ describe('GraphInterpreter', () => {
[owner, 'object', move, 'object'],
[axis, 'x', move, 'dx'],
[axis, 'y', move, 'dy'],
[speed, 'value', move, 'scale'],
[speed, 'speed', move, 'scale'],
]);
expect(behavior.graph.nodes).toHaveLength(5);
@@ -196,20 +196,20 @@ describe('GraphInterpreter', () => {
it('follows Branch, writes variables and logs', () => {
const { behavior, edit } = emptyBehavior(['count']);
const start = edit.addNode('OnStart');
const get = edit.addNode('GetVariable', { variable: 'count' });
const get = edit.variables(['count']);
const cmp = edit.addNode('Compare', { op: '<', b: 3 });
const branch = edit.addNode('Branch');
const add = edit.addNode('Add', { b: 1 });
const set = edit.addNode('SetVariable', { variable: 'count' });
const set = edit.variables(['count'], { write: true });
const logThen = edit.addNode('Log', { message: 'then' });
const logElse = edit.addNode('Log', { message: 'else' });
edit.wire([
[start, 'exec', branch, 'exec'],
[get, 'value', cmp, 'a'],
[get, 'count', cmp, 'a'],
[cmp, 'result', branch, 'condition'],
[branch, 'then', set, 'exec'],
[get, 'value', add, 'a'],
[add, 'result', set, 'value'],
[get, 'count', add, 'a'],
[add, 'result', set, 'count'],
[set, 'exec', logThen, 'exec'],
[branch, 'else', logElse, 'exec'],
]);
@@ -301,15 +301,15 @@ describe('Repeat', () => {
const { edit } = fixture;
const start = edit.addNode('OnStart');
const repeat = edit.addNode('Repeat', { times });
const get = edit.addNode('GetVariable', { variable: 'count' });
const get = edit.variables(['count']);
const add = edit.addNode('Add');
const set = edit.addNode('SetVariable', { variable: 'count' });
const set = edit.variables(['count'], { write: true });
edit.wire([
[start, 'exec', repeat, 'exec'],
[repeat, 'body', set, 'exec'],
[get, 'value', add, 'a'],
[get, 'count', add, 'a'],
[repeat, 'index', add, 'b'],
[add, 'result', set, 'value'],
[add, 'result', set, 'count'],
]);
return { ...fixture, nodes: { repeat, set } };
}
@@ -343,15 +343,15 @@ describe('Repeat', () => {
const start = edit.addNode('OnStart');
const outer = edit.addNode('Repeat', { times: 3 });
const inner = edit.addNode('Repeat', { times: 4 });
const get = edit.addNode('GetVariable', { variable: 'count' });
const get = edit.variables(['count']);
const add = edit.addNode('Add', { b: 1 });
const set = edit.addNode('SetVariable', { variable: 'count' });
const set = edit.variables(['count'], { write: true });
edit.wire([
[start, 'exec', outer, 'exec'],
[outer, 'body', inner, 'exec'],
[inner, 'body', set, 'exec'],
[get, 'value', add, 'a'],
[add, 'result', set, 'value'],
[get, 'count', add, 'a'],
[add, 'result', set, 'count'],
]);
const ctx = fakeContext({ variables: { count: 0 } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
@@ -362,19 +362,19 @@ describe('Repeat', () => {
const { behavior, edit } = emptyBehavior(['count']);
const start = edit.addNode('OnStart');
const repeat = edit.addNode('Repeat', { times: 3 });
const get = edit.addNode('GetVariable', { variable: 'count' });
const get = edit.variables(['count']);
const add = edit.addNode('Add', { b: 1 });
const set = edit.addNode('SetVariable', { variable: 'count' });
const after = edit.addNode('SetVariable', { variable: 'count' });
const set = edit.variables(['count'], { write: true });
const after = edit.variables(['count'], { write: true });
const afterAdd = edit.addNode('Multiply', { b: 10 });
edit.wire([
[start, 'exec', repeat, 'exec'],
[repeat, 'body', set, 'exec'],
[get, 'value', add, 'a'],
[add, 'result', set, 'value'],
[get, 'count', add, 'a'],
[add, 'result', set, 'count'],
[repeat, 'done', after, 'exec'],
[get, 'value', afterAdd, 'a'],
[afterAdd, 'result', after, 'value'],
[get, 'count', afterAdd, 'a'],
[afterAdd, 'result', after, 'count'],
]);
const ctx = fakeContext({ variables: { count: 0 } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
@@ -447,7 +447,7 @@ describe('variable commands', () => {
expect(behavior.variables[0]!.key).toBe('velocity');
expect(behavior.props).toEqual({ enabled: true, runInEditor: false, velocity: 0 });
expect(object(logic).overrides).toEqual({ velocity: 5 });
expect(findNode(behavior.graph, nodes.speed.id)!.params.variable).toBe('velocity');
expect(findNode(behavior.graph, nodes.speed.id)!.ports).toEqual([{ key: 'velocity', kind: 'number' }]);
stack.undo();
expect(behavior.variables[0]!.key).toBe('speed');
expect(object(logic).overrides).toEqual({ speed: 5 });
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest';
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
import { GraphInterpreter } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
import { findAsset } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { fakeContext, makeBehavior, makeProject } from '../helpers';
type RawNode = { id: string; type: string; params?: Record<string, unknown>; ports?: unknown[] };
type RawEdge = [from: string, fromPort: string, to: string, toPort: string];
/** A saved project whose behavior holds `nodes` and `edges` exactly as an older editor wrote them. */
function loadOld(nodes: RawNode[], edges: RawEdge[], variables = ['px', 'py', 'sum']) {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', variables);
fixture.stack.execute(new AddVariableCommand(behavior.id, 'piece', 'struct'));
fixture.stack.execute(new AddVariableCommand(behavior.id, 'flag', 'boolean'));
const raw = serializeProject(fixture.project) as { assets: Record<string, unknown>[] };
raw.assets.find((a) => a.id === behavior.id)!.graph = {
nodes: nodes.map((n) => ({ x: 0, y: 0, params: {}, ...n })),
edges: edges.map(([from, fromPort, to, toPort], i) => ({
id: `e${i}`,
from: { node: from, port: fromPort },
to: { node: to, port: toPort },
})),
};
const project = deserializeProject(raw);
return { project, behavior: findAsset(project, behavior.id) as BehaviorAsset };
}
const node = (behavior: BehaviorAsset, id: string) => behavior.graph.nodes.find((n) => n.id === id)!;
const edges = (behavior: BehaviorAsset) =>
behavior.graph.edges.map((e) => `${e.from.node}.${e.from.port}>${e.to.node}.${e.to.port}`);
describe('variable nodes of an older file', () => {
it('load as Variables nodes that compute what the old graph computed', () => {
const { project, behavior } = loadOld(
[
{ id: 'start', type: 'OnStart' },
{ id: 'set', type: 'SetVariables', params: { px: 7 }, ports: [{ key: 'px', kind: 'number' }] },
{ id: 'y', type: 'SetVariable', params: { variable: 'py', value: 9 } },
{ id: 'get', type: 'GetVariable', params: { variable: 'px' } },
{ id: 'both', type: 'GetVariables', ports: [{ key: 'py', kind: 'number' }] },
{ id: 'add', type: 'Add' },
{ id: 'sum', type: 'Variable', params: { variable: 'sum' } },
],
[
['start', 'exec', 'set', 'exec'],
['set', 'exec', 'y', 'exec'],
['y', 'exec', 'sum', 'exec'],
['get', 'value', 'add', 'a'],
['both', 'py', 'add', 'b'],
['add', 'result', 'sum', 'value'],
],
);
expect(behavior.graph.nodes.map((n) => n.type)).toEqual([
'OnStart',
'Variables',
'Variables',
'Variables',
'Variables',
'Add',
'Variables',
]);
expect(edges(behavior)).toEqual([
'start.exec>set.exec',
'set.exec>y.exec',
'y.exec>sum.exec',
'get.px>add.a',
'both.py>add.b',
'add.result>sum.sum',
]);
const ctx = fakeContext({ variables: { px: 0, py: 0, sum: 0 } });
new GraphInterpreter(behavior.graph, behaviorLookup(project)).fire('OnStart', ctx);
expect(ctx.variables).toEqual({ px: 7, py: 9, sum: 16 });
});
it('keep whether they wrote, their typed-in value, and the kind of the variable', () => {
const { behavior } = loadOld(
[
{ id: 'get', type: 'GetVariable', params: { variable: 'flag' } },
{ id: 'set', type: 'SetVariable', params: { variable: 'px', value: 3 } },
{ id: 'plural', type: 'SetVariables', params: { py: 4 }, ports: [{ key: 'py', kind: 'number' }] },
{ id: 'gone', type: 'Variable', params: { variable: 'nope' } },
],
[],
);
expect(node(behavior, 'get').ports).toEqual([{ key: 'flag', kind: 'boolean' }]);
expect(node(behavior, 'set')).toMatchObject({
params: { px: 3 },
ports: [{ key: 'px', kind: 'number', write: true }],
});
expect(node(behavior, 'plural')).toMatchObject({
params: { py: 4 },
ports: [{ key: 'py', kind: 'number', write: true }],
});
// A variable the behavior does not declare keeps its port, as text; the inspector marks it.
expect(node(behavior, 'gone').ports).toEqual([{ key: 'nope', kind: 'text', write: true }]);
expect(node(behavior, 'get').params).toEqual({});
});
it('give a struct variable a struct port, so an Expand can still take it apart', () => {
const { behavior } = loadOld([{ id: 'get', type: 'GetVariable', params: { variable: 'piece' } }], []);
expect(node(behavior, 'get').ports).toEqual([{ key: 'piece', kind: 'struct' }]);
});
it('drop every edge of a node that never had a variable picked, flow included', () => {
const { behavior } = loadOld(
[
{ id: 'start', type: 'OnStart' },
{ id: 'empty', type: 'SetVariable' },
{ id: 'add', type: 'Add' },
],
[
['start', 'exec', 'empty', 'exec'],
['add', 'result', 'empty', 'value'],
],
);
expect(node(behavior, 'empty').ports).toBeUndefined();
expect(edges(behavior)).toEqual([]);
});
it('are left alone once they are Variables nodes', () => {
const { project, behavior } = loadOld(
[{ id: 'get', type: 'GetVariable', params: { variable: 'px' } }],
[],
);
const again = findAsset(deserializeProject(serializeProject(project)), behavior.id) as BehaviorAsset;
expect(again.graph).toEqual(behavior.graph);
});
});
+11 -11
View File
@@ -45,11 +45,11 @@ function callerOf(fixture: ProjectFixture, callee: BehaviorAsset, input: number)
const edit = graphEditor(fixture, asset);
const start = edit.addNode('OnStart');
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, in1: input });
const set = edit.addNode('SetVariable', { variable: 'result' });
const set = edit.variables(['result'], { write: true });
edit.wire([
[start, 'exec', call, 'exec'],
[call, 'exec', set, 'exec'],
[call, 'out1', set, 'value'],
[call, 'out1', set, 'result'],
]);
return asset;
}
@@ -87,13 +87,13 @@ describe('a behavior used as a node', () => {
const start = edit.addNode(BEHAVIOR_START);
const end = edit.addNode(BEHAVIOR_END);
fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'out1', kind: 'number' }));
const get = edit.addNode('GetVariable', { variable: 'count' });
const get = edit.variables(['count']);
const plus = edit.addNode('Add', { b: 1 });
const set = edit.addNode('SetVariable', { variable: 'count' });
const set = edit.variables(['count'], { write: true });
edit.wire([
[start, 'exec', set, 'exec'],
[get, 'value', plus, 'a'],
[plus, 'result', set, 'value'],
[get, 'count', plus, 'a'],
[plus, 'result', set, 'count'],
[set, 'exec', end, 'exec'],
[plus, 'result', end, 'out1'],
]);
@@ -103,12 +103,12 @@ describe('a behavior used as a node', () => {
const onStart = cEdit.addNode('OnStart');
const first = cEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
const second = cEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
const store = cEdit.addNode('SetVariable', { variable: 'result' });
const store = cEdit.variables(['result'], { write: true });
cEdit.wire([
[onStart, 'exec', first, 'exec'],
[first, 'exec', second, 'exec'],
[second, 'exec', store, 'exec'],
[second, 'out1', store, 'value'],
[second, 'out1', store, 'result'],
]);
const { ctx } = runOnStart(fixture, caller, { result: 0, count: 500 });
@@ -135,13 +135,13 @@ describe('a behavior used as a node', () => {
const edit = graphEditor(fixture, caller);
const onStart = edit.addNode('OnStart');
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
const set = edit.addNode('SetVariable', { variable: 'result' });
const set = edit.variables(['result'], { write: true });
const mine = edit.addNode('Number', { value: 7 });
edit.wire([
[onStart, 'exec', call, 'exec'],
// Read *after* the call, and from the caller's own node – the point of the test.
[call, 'exec', set, 'exec'],
[mine, 'value', set, 'value'],
[mine, 'value', set, 'result'],
]);
// Ids are unique in practice; forcing a collision is the only way to show the frames are real.
@@ -171,7 +171,7 @@ describe('a behavior used as a node', () => {
const edit = graphEditor(fixture, caller);
const start = edit.addNode('OnStart');
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: target });
const set = edit.addNode('SetVariable', { variable: 'result' });
const set = edit.variables(['result'], { write: true });
edit.wire([
[start, 'exec', call, 'exec'],
// The flow carries on past the call, which is what keeps a dangling reference harmless.
+8 -50
View File
@@ -106,29 +106,18 @@ const CASES: Record<string, Case[]> = {
// variables
GetVariable: [
{ name: 'reads a variable', params: { variable: 'speed' }, outputs: { value: 100 } },
{ name: 'an unknown variable reads as null', params: { variable: 'nope' }, outputs: { value: null } },
{ name: 'no variable chosen reads as null', outputs: { value: null } },
],
GetVariables: [
// Reading needs the interpreter, which asks `read` at the moment: see variable-node.test.ts.
Variables: [
{
name: 'reads one output per variable it names',
name: 'writes the variables switched to Set, each keeping its declared type',
ports: [
{ key: 'speed', kind: 'number' },
{ key: 'nope', kind: 'number' },
{ key: 'speed', kind: 'number', write: true },
{ key: 'lives', kind: 'number' },
],
outputs: { speed: 100, nope: null },
},
{ name: 'without a variable it reads nothing', outputs: {} },
],
SetVariables: [
{
name: 'writes every variable it names, each keeping its declared type',
ports: [{ key: 'speed', kind: 'number' }],
inputs: { speed: '250' },
inputs: { speed: '250', lives: 9 },
context: { variables: { speed: 100, lives: 3 } },
next: 'exec',
after: (ctx) => expect(ctx.variables.speed).toBe(250),
after: (ctx) => expect(ctx.variables).toEqual({ speed: 250, lives: 3 }),
},
{
name: 'without a variable it writes nothing',
@@ -136,37 +125,6 @@ const CASES: Record<string, Case[]> = {
after: (ctx) => expect(ctx.variables).toEqual({ speed: 100 }),
},
],
SetVariable: [
{
name: 'writes a variable, keeping the declared type',
inputs: { value: '250' },
params: { variable: 'speed' },
next: 'exec',
after: (ctx) => expect(ctx.variables.speed).toBe(250),
},
{
name: 'no variable chosen writes nothing',
inputs: { value: 5 },
next: 'exec',
after: (ctx) => expect(ctx.variables).toEqual({ speed: 100 }),
},
],
// Reading without a flow needs the interpreter: see variable-node.test.ts.
Variable: [
{
name: 'writes its variable when the flow arrives, keeping the declared type',
inputs: { value: '250' },
params: { variable: 'speed' },
next: 'exec',
after: (ctx) => expect(ctx.variables.speed).toBe(250),
},
{
name: 'no variable chosen writes nothing',
inputs: { value: 5 },
next: 'exec',
after: (ctx) => expect(ctx.variables).toEqual({ speed: 100 }),
},
],
// references and properties
+4 -4
View File
@@ -19,15 +19,15 @@ describe('game-flow nodes', () => {
// On click → clicks = clicks + 1
const edit = graphEditor(f, behavior);
const click = edit.addNode('OnClick');
const get = edit.addNode('GetVariable', { variable: 'clicks' });
const get = edit.variables(['clicks']);
const one = edit.addNode('Number', { value: 1 });
const add = edit.addNode('Add');
const set = edit.addNode('SetVariable', { variable: 'clicks' });
const set = edit.variables(['clicks'], { write: true });
edit.wire([
[click, 'exec', set, 'exec'],
[get, 'value', add, 'a'],
[get, 'clicks', add, 'a'],
[one, 'value', add, 'b'],
[add, 'result', set, 'value'],
[add, 'result', set, 'clicks'],
]);
const button = place(f, f.spriteId, { x: 0, y: 0 });
const logic = place(f, behavior.id, { x: 0, y: 0 }, button);
+7 -2
View File
@@ -30,7 +30,7 @@ function typedIn(params: Record<string, number> = {}) {
const self = edit.addNode('Self');
const set = edit.addNode(SET_PROPERTIES, params);
const get = edit.addNode(GET_PROPERTIES);
const store = edit.addNode('SetVariable', { variable: 'seen' });
const store = edit.variables(['seen'], { write: true });
for (const key of ['width', 'color'])
fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key, kind: 'number' }));
fixture.stack.execute(new AddPortCommand(behavior.id, get.id, { key: 'width', kind: 'number' }));
@@ -39,7 +39,7 @@ function typedIn(params: Record<string, number> = {}) {
[self, 'object', set, 'object'],
[set, 'exec', store, 'exec'],
[self, 'object', get, 'object'],
[get, 'width', store, 'value'],
[get, 'width', store, 'seen'],
]);
return { fixture, behavior, set, get };
}
@@ -70,6 +70,11 @@ describe('a node that reads or writes several properties at once', () => {
expect(propertyPort({ key: 'width', kind: 'text' }).field).toBeUndefined();
});
it('gives a custom name the zero it writes untyped, so the node shows a value', () => {
expect(propertyPort({ key: 'speed', kind: 'number' }).default).toBe(0);
expect(propertyPort({ key: 'ready', kind: 'boolean' }).default).toBe(false);
});
it('keeps the fixed object input its own, so no declared port can take its key', () => {
const { fixture, behavior, set, get } = typedIn();
const add = (nodeId: string) =>
+202 -45
View File
@@ -1,61 +1,69 @@
import { describe, expect, it } from 'vitest';
import { RenameVariableCommand } from '../../src/core/commands/variableCommands';
import { GraphInterpreter, resolveDef } from '../../src/core/graph';
import { RemovePortCommand, SetPortWriteCommand } from '../../src/core/commands/portCommands';
import { AddVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
import { type GraphNode, GraphInterpreter, inlineValue, resolveDef } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers';
function setup() {
function setup(variables = ['count', 'copy']) {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', ['count', 'copy']);
const behavior = makeBehavior(fixture, 'B', variables);
return { fixture, behavior, edit: graphEditor(fixture, behavior) };
}
function run(behavior: BehaviorAsset, fixture: ReturnType<typeof makeProject>, count = 5) {
const ctx = fakeContext({ variables: { count, copy: 0 } });
function run(
behavior: BehaviorAsset,
fixture: ReturnType<typeof makeProject>,
variables: Record<string, number> = { count: 5, copy: 0 },
) {
const ctx = fakeContext({ variables: { ...variables } });
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
return ctx.variables;
}
const titleOf = (behavior: BehaviorAsset, index: number) =>
resolveDef(behavior.graph.nodes[index]!, behavior.graph, () => null)!.label;
const ports = (behavior: BehaviorAsset, node: GraphNode, side: 'inputs' | 'outputs') =>
resolveDef(node, behavior.graph, () => null)![side].map((p) => p.key);
describe('the Variable node', () => {
const edgeCount = (behavior: BehaviorAsset) => behavior.graph.edges.length;
describe('the Variables node', () => {
it('can be read without a flow ever reaching it', () => {
const { fixture, behavior, edit } = setup();
const start = edit.addNode('OnStart');
const variable = edit.addNode('Variable', { variable: 'count' });
const copy = edit.addNode('SetVariable', { variable: 'copy' });
const read = edit.variables(['count']);
const store = edit.variables(['copy'], { write: true });
edit.wire([
[start, 'exec', copy, 'exec'],
[variable, 'value', copy, 'value'],
[start, 'exec', store, 'exec'],
[read, 'count', store, 'copy'],
]);
expect(run(behavior, fixture)).toEqual({ count: 5, copy: 5 });
});
it('writes its input when the flow arrives, unconnected as well as through an edge', () => {
const { fixture, behavior, edit } = setup();
it('writes the variables switched to Set, typed in or through an edge, and leaves the rest', () => {
const { fixture, behavior, edit } = setup(['count', 'copy', 'kept']);
const start = edit.addNode('OnStart');
const inline = edit.addNode('Variable', { variable: 'count', value: 7 });
const number = edit.addNode('Number', { value: 3 });
const wired = edit.addNode('Variable', { variable: 'copy' });
const node = edit.variables(['count', 'copy', 'kept'], {
write: ['count', 'copy'],
params: { count: 7 },
});
edit.wire([
[start, 'exec', inline, 'exec'],
[inline, 'exec', wired, 'exec'],
[number, 'value', wired, 'value'],
[start, 'exec', node, 'exec'],
[number, 'value', node, 'copy'],
]);
expect(run(behavior, fixture)).toEqual({ count: 7, copy: 3 });
expect(run(behavior, fixture, { count: 5, copy: 0, kept: 4 })).toEqual({ count: 7, copy: 3, kept: 4 });
});
it('counts up through its own output: count = count + 1', () => {
const { fixture, behavior, edit } = setup();
const start = edit.addNode('OnStart');
const variable = edit.addNode('Variable', { variable: 'count' });
const count = edit.variables(['count'], { write: true });
const add = edit.addNode('Add', { b: 1 });
edit.wire([
[start, 'exec', variable, 'exec'],
[variable, 'value', add, 'a'],
[add, 'result', variable, 'value'],
[start, 'exec', count, 'exec'],
[count, 'count', add, 'a'],
[add, 'result', count, 'count'],
]);
expect(run(behavior, fixture).count).toBe(6);
});
@@ -63,40 +71,189 @@ describe('the Variable node', () => {
it('is read at the moment it is asked, so it sees a later write by another node', () => {
const { fixture, behavior, edit } = setup();
const start = edit.addNode('OnStart');
const variable = edit.addNode('Variable', { variable: 'count', value: 7 });
const overwrite = edit.addNode('SetVariable', { variable: 'count', value: 9 });
const copy = edit.addNode('SetVariable', { variable: 'copy' });
const count = edit.variables(['count'], { write: true, params: { count: 7 } });
const overwrite = edit.variables(['count'], { write: true, params: { count: 9 } });
const copy = edit.variables(['copy'], { write: true });
edit.wire([
[start, 'exec', variable, 'exec'],
[variable, 'exec', overwrite, 'exec'],
[start, 'exec', count, 'exec'],
[count, 'exec', overwrite, 'exec'],
[overwrite, 'exec', copy, 'exec'],
[variable, 'value', copy, 'value'],
[count, 'count', copy, 'copy'],
]);
expect(run(behavior, fixture)).toEqual({ count: 9, copy: 9 });
});
/** `Get variables` answered once per pulse; one node read on both sides of a write gave the old value twice. */
it('reaches across a write of one of its variables', () => {
const { fixture, behavior, edit } = setup(['px', 'before', 'after']);
const start = edit.addNode('OnStart');
const read = edit.variables(['px']);
const before = edit.variables(['before'], { write: true });
const write = edit.variables(['px'], { write: true, params: { px: 7 } });
const after = edit.variables(['after'], { write: true });
edit.wire([
[start, 'exec', before, 'exec'],
[before, 'exec', write, 'exec'],
[write, 'exec', after, 'exec'],
[read, 'px', before, 'before'],
[read, 'px', after, 'after'],
]);
expect(run(behavior, fixture, { px: 1, before: 0, after: 0 })).toEqual({ px: 7, before: 1, after: 7 });
});
it('reads every input before it writes any, so one node swaps two variables', () => {
const { fixture, behavior, edit } = setup(['a', 'b']);
const start = edit.addNode('OnStart');
const read = edit.variables(['a', 'b']);
const swap = edit.variables(['a', 'b'], { write: true });
edit.wire([
[start, 'exec', swap, 'exec'],
[read, 'b', swap, 'a'],
[read, 'a', swap, 'b'],
]);
expect(run(behavior, fixture, { a: 1, b: 2 })).toEqual({ a: 2, b: 1 });
});
it('carries the value it writes when nothing is typed, so the node can show it', () => {
const { fixture, behavior, edit } = setup();
for (const [key, kind] of [
['label', 'text'],
['on', 'boolean'],
['items', 'list'],
] as const)
fixture.stack.execute(new AddVariableCommand(behavior.id, key, kind));
const start = edit.addNode('OnStart');
const node = edit.variables(['count', 'label', 'on', 'items'], { write: true, params: { label: 'hi' } });
edit.connect(start, 'exec', node, 'exec');
const carried = resolveDef(node, behavior.graph, () => null)!
.inputs.filter((p) => p.type !== 'exec')
.map((p) => [p.key, inlineValue(node, p)]);
expect(Object.fromEntries(carried)).toEqual({ count: 0, label: 'hi', on: false, items: [] });
expect(run(behavior, fixture).count).toBe(0);
});
it('has flow pins only while one of its variables is set', () => {
const { behavior, edit } = setup();
const read = edit.variables(['count', 'copy']);
const mixed = edit.variables(['count', 'copy'], { write: ['copy'] });
expect([ports(behavior, read, 'inputs'), ports(behavior, read, 'outputs')]).toEqual([
[],
['count', 'copy'],
]);
expect([ports(behavior, mixed, 'inputs'), ports(behavior, mixed, 'outputs')]).toEqual([
['exec', 'copy'],
['exec', 'count', 'copy'],
]);
});
});
describe('variable node titles', () => {
it('name the picked variable, and keep the plain title until one is picked', () => {
describe('switching Set on a Variables node', () => {
function wired() {
const { fixture, behavior, edit } = setup();
const start = edit.addNode('OnStart');
const number = edit.addNode('Number', { value: 3 });
const node = edit.variables(['count', 'copy'], { write: true, params: { count: 7 } });
const next = edit.variables(['copy'], { write: true });
edit.wire([
[start, 'exec', node, 'exec'],
[node, 'exec', next, 'exec'],
[number, 'value', node, 'copy'],
[node, 'count', next, 'copy'],
]);
return { fixture, behavior, node };
}
it('off takes the input with its edge and typed value, and undo brings both back', () => {
const { fixture, behavior, node } = wired();
const edges = structuredClone(behavior.graph.edges);
fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'copy', false));
expect(ports(behavior, node, 'inputs')).toEqual(['exec', 'count']);
expect(edgeCount(behavior)).toBe(3);
fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'count', false));
expect(node.params.count).toBeUndefined();
fixture.stack.undo();
fixture.stack.undo();
expect(node.params.count).toBe(7);
expect(behavior.graph.edges).toEqual(edges);
});
it('off on the last one takes the flow pins and their edges along, and undo restores the order', () => {
const { fixture, behavior, node } = wired();
const edges = structuredClone(behavior.graph.edges);
fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'copy', false));
fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'count', false));
expect(ports(behavior, node, 'inputs')).toEqual([]);
expect(ports(behavior, node, 'outputs')).toEqual(['count', 'copy']);
// Only the read of `count` is left: the flow in and out of the node went with its pins.
expect(behavior.graph.edges.map((e) => `${e.from.port}>${e.to.port}`)).toEqual(['count>copy']);
fixture.stack.undo();
fixture.stack.undo();
expect(behavior.graph.edges).toEqual(edges);
});
/** Edges of both pins and of the flow, interleaved in the list: undo has to put each back in its place. */
function interleaved() {
const { fixture, behavior, edit } = setup();
const start = edit.addNode('OnStart');
const number = edit.addNode('Number', { value: 3 });
const node = edit.variables(['count'], { write: true });
const log = edit.addNode('Log');
edit.wire([
[start, 'exec', node, 'exec'],
[node, 'count', log, 'message'],
[number, 'value', node, 'count'],
[node, 'exec', log, 'exec'],
]);
return { fixture, behavior, node, edges: structuredClone(behavior.graph.edges) };
}
it('off on the only one takes its edge and the flow in one step, and undo restores the order', () => {
const { fixture, behavior, node, edges } = interleaved();
fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'count', false));
expect(behavior.graph.edges.map((e) => e.to.port)).toEqual(['message']);
fixture.stack.undo();
expect(behavior.graph.edges).toEqual(edges);
});
it('removing the last written variable takes both its pins and the flow, and undo restores the order', () => {
const { fixture, behavior, node, edges } = interleaved();
fixture.stack.execute(new RemovePortCommand(behavior.id, node.id, 'count'));
expect(edgeCount(behavior)).toBe(0);
fixture.stack.undo();
expect(behavior.graph.edges).toEqual(edges);
});
it('on is undone to off, not to whatever the opposite of the request is', () => {
const { fixture, behavior, edit } = setup();
const node = edit.variables(['count'], { write: true });
fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'count', true));
fixture.stack.undo();
expect(node.ports?.[0]?.write).toBe(true);
});
});
describe('Variables node titles', () => {
const titleOf = (behavior: BehaviorAsset, node: GraphNode) =>
resolveDef(node, behavior.graph, () => null)!.label;
it('name a single variable, and stay plain for none or several', () => {
const { behavior, edit } = setup();
edit.addNode('GetVariable', { variable: 'count' });
edit.addNode('SetVariable', { variable: 'count' });
edit.addNode('Variable', { variable: 'count' });
edit.addNode('GetVariable');
edit.addNode('Variable');
expect([0, 1, 2, 3, 4].map((i) => titleOf(behavior, i))).toEqual([
'Get count',
'Set count',
const one = edit.variables(['count'], { write: true });
const none = edit.variables([]);
const two = edit.variables(['count', 'copy']);
expect([one, none, two].map((node) => titleOf(behavior, node))).toEqual([
'count',
'Get variable',
'Variable',
'Variables',
'Variables',
]);
});
it('follow a renamed variable', () => {
const { fixture, behavior, edit } = setup();
edit.addNode('Variable', { variable: 'count' });
const node = edit.variables(['count']);
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'count', 'score'));
expect(titleOf(behavior, 0)).toBe('score');
expect(titleOf(behavior, node)).toBe('score');
});
});
+24 -85
View File
@@ -1,7 +1,7 @@
/**
* Get/Set variables: the two nodes whose declared ports are variables of the behavior around them,
* rather than names of their own. What that costs is the wiring – a port has to follow the variable
* it names when that is renamed or swapped, and take its edges along.
* The Variables node declares variables of the behavior around it as ports, rather than names of
* its own. What that costs is the wiring – a port has to follow the variable it names when that is
* renamed or swapped, and take its edges along, on the input side as well as on the output side.
*/
import { describe, expect, it } from 'vitest';
import { AddPortCommand, RepointPortCommand } from '../../src/core/commands/portCommands';
@@ -11,13 +11,7 @@ import {
RemoveVariableCommand,
RenameVariableCommand,
} from '../../src/core/commands/variableCommands';
import {
GET_VARIABLES,
type GraphNode,
GraphInterpreter,
resolveDef,
SET_VARIABLES,
} from '../../src/core/graph';
import { type GraphNode, GraphInterpreter, resolveDef, VARIABLES } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
@@ -28,21 +22,18 @@ function bothWays() {
const behavior = makeBehavior(fixture, 'B', ['px', 'py', 'sum']);
const edit = graphEditor(fixture, behavior);
const start = edit.addNode('OnStart');
const set = edit.addNode(SET_VARIABLES, { px: 7, py: 9 });
const get = edit.addNode(GET_VARIABLES);
const set = edit.variables(['px', 'py'], { write: true, params: { px: 7, py: 9 } });
const get = edit.variables(['px', 'py']);
const add = edit.addNode('Add');
const store = edit.addNode('SetVariable', { variable: 'sum' });
for (const node of [set, get])
for (const key of ['px', 'py'])
fixture.stack.execute(new AddPortCommand(behavior.id, node.id, { key, kind: 'number' }));
const store = edit.variables(['sum'], { write: true });
edit.wire([
[start, 'exec', set, 'exec'],
[set, 'exec', store, 'exec'],
[get, 'px', add, 'a'],
[get, 'py', add, 'b'],
[add, 'result', store, 'value'],
[add, 'result', store, 'sum'],
]);
return { fixture, behavior, set, get, edit };
return { fixture, behavior, set, get, store, edit };
}
function run(
@@ -70,17 +61,20 @@ describe('a node that reads or writes several variables at once', () => {
fixture.stack.execute(new AddVariableCommand(behavior.id, 'alive', 'boolean'));
fixture.stack.execute(new AddPortCommand(behavior.id, get.id, { key: 'alive', kind: 'boolean' }));
expect(portKeys(behavior, get, 'outputs')).toEqual(['px:number', 'py:number', 'alive:boolean']);
// The exec port is the node's own; only the variables come from the declaration.
expect(portKeys(behavior, set, 'inputs')).toEqual(['exec:exec', 'px:number', 'py:number']);
});
it('follows a renamed variable, edge and all', () => {
const { fixture, behavior, get } = bothWays();
it('follows a renamed variable, edges and typed-in value on both sides', () => {
const { fixture, behavior, get, set, store } = bothWays();
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'px', 'column'));
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'sum', 'total'));
expect((get.ports ?? []).map((p) => p.key)).toEqual(['column', 'py']);
expect(set.params).toEqual({ column: 7, py: 9 });
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'column')).toBe(true);
expect(run(behavior, fixture, { column: 0, py: 0, sum: 0 })).toEqual({ column: 7, py: 9, sum: 16 });
expect(behavior.graph.edges.some((e) => e.to.node === store.id && e.to.port === 'total')).toBe(true);
expect(run(behavior, fixture, { column: 0, py: 0, total: 0 })).toEqual({ column: 7, py: 9, total: 16 });
fixture.stack.undo();
fixture.stack.undo();
expect((get.ports ?? []).map((p) => p.key)).toEqual(['px', 'py']);
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
@@ -112,55 +106,21 @@ describe('a node that reads or writes several variables at once', () => {
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
});
/**
* The rule that decides how far a `Get variables` may reach: a data node answers once per pulse
* and that answer is kept, so a single node cannot straddle a write of one of its variables – the
* later reader would be served the value from before it. A second node is the way across.
*/
it('answers once per pulse, so a reader past a write needs a node of its own', () => {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', ['px', 'py', 'sum', 'fresh']);
const edit = graphEditor(fixture, behavior);
const start = edit.addNode('OnStart');
const early = edit.addNode(GET_VARIABLES);
const late = edit.addNode(GET_VARIABLES);
const write = edit.addNode(SET_VARIABLES, { px: 7 });
const before = edit.addNode('SetVariable', { variable: 'sum' });
const after = edit.addNode('SetVariable', { variable: 'py' });
const again = edit.addNode('SetVariable', { variable: 'fresh' });
for (const node of [early, late, write])
fixture.stack.execute(new AddPortCommand(behavior.id, node.id, { key: 'px', kind: 'number' }));
edit.wire([
[start, 'exec', before, 'exec'],
[before, 'exec', write, 'exec'],
[write, 'exec', after, 'exec'],
[after, 'exec', again, 'exec'],
[early, 'px', before, 'value'],
[early, 'px', after, 'value'],
[late, 'px', again, 'value'],
]);
// `py` is the point: the same node read past the write still answers 1, while `late` sees 7.
expect(run(behavior, fixture, { px: 1, py: 0, sum: 0, fresh: 0 })).toEqual({
px: 7,
py: 1,
sum: 1,
fresh: 7,
});
});
it('survives a save and load, which is what makes the pick more than a session', () => {
const { fixture, behavior } = bothWays();
const loaded = roundTrip(fixture.project);
const asset = loaded.assets.find((a) => a.id === behavior.id) as BehaviorAsset;
const set = asset.graph.nodes.find((n) => n.type === SET_VARIABLES)!;
expect((set.ports ?? []).map((p) => `${p.key}:${p.kind}`)).toEqual(['px:number', 'py:number']);
const set = asset.graph.nodes.find((n) => n.type === VARIABLES && n.params.px === 7)!;
expect(set.ports).toEqual([
{ key: 'px', kind: 'number', write: true },
{ key: 'py', kind: 'number', write: true },
]);
expect(run(asset, { project: loaded, stack: fixture.stack })).toEqual({ px: 7, py: 9, sum: 16 });
});
/**
* A struct port writes the way the singular node does: the bundle it is handed is merged in, so a
* `Pack` of one field leaves the rest standing. Tetris leans on this – each strand of `Next move`
* packs only the field it changes and lets the plural node keep the other three.
* The bundle a struct port is handed is merged in, so a `Pack` of one field leaves the rest
* standing. Tetris leans on this: each strand of `Next move` packs only the field it changes.
*/
it('merges a partial bundle into a struct variable instead of replacing it', () => {
const fixture = makeProject();
@@ -170,9 +130,8 @@ describe('a node that reads or writes several variables at once', () => {
fixture.stack.execute(new AddStructFieldCommand(behavior.id, 'piece', { key, kind: 'number' }));
const edit = graphEditor(fixture, behavior);
const start = edit.addNode('OnStart');
const set = edit.addNode(SET_VARIABLES);
const set = edit.variables(['piece'], { write: true });
const pack = edit.addNode('PackStruct', { rot: 2 });
fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key: 'piece', kind: 'struct' }));
fixture.stack.execute(new AddPortCommand(behavior.id, pack.id, { key: 'rot', kind: 'number' }));
edit.wire([
[start, 'exec', set, 'exec'],
@@ -183,24 +142,4 @@ describe('a node that reads or writes several variables at once', () => {
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
expect(ctx.variables.piece).toEqual({ shape: 3, rot: 2 });
});
/**
* The other half of how far a plural node may reach: it collects every input before it writes any
* of them, so an input fed from a variable the same node writes sees the value from before.
*/
it('reads every input before it writes any, so a plural set cannot feed itself', () => {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', ['px', 'py']);
const edit = graphEditor(fixture, behavior);
const start = edit.addNode('OnStart');
const read = edit.addNode('GetVariable', { variable: 'px' });
const set = edit.addNode(SET_VARIABLES, { px: 7 });
for (const key of ['px', 'py'])
fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key, kind: 'number' }));
edit.wire([
[start, 'exec', set, 'exec'],
[read, 'value', set, 'py'],
]);
expect(run(behavior, fixture, { px: 1, py: 0 })).toEqual({ px: 7, py: 1 });
});
});