Let a list hold structs, and read or write variables in bundles

A list item may now be a struct. The depth is bounded in the declaration
rather than by a comment: FieldKind (number|text|boolean|color) says what a
struct field holds, ListItemKind adds struct on top and says what a list
holds, so list -> struct -> primitive is the deepest storable shape and
sameValue stays finite. A struct item renders inline in the List tab, which
is why it needs no path in the slot address.

Four paths silently assumed a list item was a primitive: sameValue compared
items with ===, textOf would have printed [object Object], asListItem turned
a struct into text on Set item and Fill, and Index of used indexOf, which is
reference equality and could never have found a struct.

Tetris now runs on bundles. shapes is 112 {x, y} structs instead of 224 loose
numbers, piece and the candidate are structs, so Next move hands back a whole
candidate and each strand packs only the field it changes, since mergeStruct
leaves the rest standing. Declared behavior ports drop 49 -> 35, the master's
variables 18 -> 13, its graph 84/94 -> 69/76 nodes/edges.

Get variables and Set variables declare their ports from a list of variable
names, the way Pack and Expand already do, so one node can read or write
several at once. Renaming a variable rewires them, because there the port key
is the variable name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-10 14:01:56 +02:00
1 parent 9d62dfea7b
commit f60358c951
32 files changed
+1083 -253

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
/**
* 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.
*/
import { describe, expect, it } from 'vitest';
import { AddPortCommand, SetVariablePortCommand } from '../../src/core/commands/portCommands';
import {
AddVariableCommand,
RemoveVariableCommand,
RenameVariableCommand,
} from '../../src/core/commands/variableCommands';
import {
GET_VARIABLES,
type GraphNode,
GraphInterpreter,
resolveDef,
SET_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';
/** `px` and `py` are set in one node and read back in another, their sum landing in `sum`. */
function bothWays() {
const fixture = makeProject();
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 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' }));
edit.wire([
[start, 'exec', set, 'exec'],
[set, 'exec', store, 'exec'],
[get, 'px', add, 'a'],
[get, 'py', add, 'b'],
[add, 'result', store, 'value'],
]);
return { fixture, behavior, set, get, edit };
}
function run(
behavior: BehaviorAsset,
fixture: ReturnType<typeof makeProject>,
variables: Record<string, number> = { px: 0, py: 0, sum: 0 },
) {
const interpreter = new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project));
const ctx = fakeContext({ variables });
interpreter.fire('OnStart', ctx);
return ctx.variables;
}
const portKeys = (behavior: BehaviorAsset, node: GraphNode, side: 'inputs' | 'outputs') =>
resolveDef(node, behavior.graph, () => null)![side].map((p) => `${p.key}:${p.type}`);
describe('a node that reads or writes several variables at once', () => {
it('writes each variable it names and reads each one back', () => {
const { fixture, behavior } = bothWays();
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
});
it('takes the port type from the variable, so a number output stays a number', () => {
const { fixture, behavior, get, set } = bothWays();
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();
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'px', 'column'));
expect((get.ports ?? []).map((p) => p.key)).toEqual(['column', 'py']);
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 });
fixture.stack.undo();
expect((get.ports ?? []).map((p) => p.key)).toEqual(['px', 'py']);
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
});
it('keeps the edge when a port is pointed at another variable', () => {
const { fixture, behavior, get } = bothWays();
fixture.stack.execute(new AddVariableCommand(behavior.id, 'alive', 'boolean'));
fixture.stack.execute(
new SetVariablePortCommand(behavior.id, get.id, 'px', { key: 'alive', kind: 'boolean' }),
);
expect(portKeys(behavior, get, 'outputs')).toEqual(['alive:boolean', 'py:number']);
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'alive')).toBe(true);
fixture.stack.undo();
expect(portKeys(behavior, get, 'outputs')).toEqual(['px:number', 'py:number']);
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'px')).toBe(true);
});
it('keeps a port whose variable was removed, so undoing the removal restores the graph', () => {
const { fixture, behavior, get } = bothWays();
fixture.stack.execute(new RemoveVariableCommand(behavior.id, 'px'));
// Left standing rather than dropped: taking the port would have to take its edge too, and
// undo could not put either back. The inspector marks it as naming no variable.
expect((get.ports ?? []).map((p) => p.key)).toEqual(['px', 'py']);
fixture.stack.undo();
expect(behavior.variables.map((v) => v.key)).toEqual(['px', 'py', 'sum']);
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
});
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']);
expect(run(asset, { project: loaded, stack: fixture.stack })).toEqual({ px: 7, py: 9, sum: 16 });
});
});