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

+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 });
});
});