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:
1 parent
9d62dfea7b
commit
f60358c951
32 files changed
+1083
-253
No files matched your search
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Fill } from '../../src/core/graph/nodes/data/Fill';
|
||||
import { IndexOf } from '../../src/core/graph/nodes/data/IndexOf';
|
||||
import { ItemAt } from '../../src/core/graph/nodes/data/ItemAt';
|
||||
import { SetItem } from '../../src/core/graph/nodes/data/SetItem';
|
||||
import { sameValue, textOf } from '../../src/core/model/Properties';
|
||||
import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
|
||||
import { makeBehavior, makeSpriteScene, roundTrip } from '../helpers';
|
||||
|
||||
const XY = [
|
||||
{ key: 'x', kind: 'number' },
|
||||
{ key: 'y', kind: 'number' },
|
||||
] as const;
|
||||
|
||||
const cells = (...pairs: [number, number][]) => pairs.map(([x, y]) => ({ x, y }));
|
||||
|
||||
/** A `list` variable whose items are structs, declared the way a seeder writes it. */
|
||||
const shapesDef = (value: unknown) => variableDef('shapes', 'list', value as never, 'struct', XY);
|
||||
|
||||
/** The node interface only needs `run`; nothing here reads the context. */
|
||||
const run = (def: { run?: unknown }, inputs: Record<string, unknown>) =>
|
||||
(def.run as (ctx: unknown, i: Record<string, unknown>) => { outputs: Record<string, unknown> })({}, inputs)
|
||||
.outputs;
|
||||
|
||||
describe('a list whose items are structs', () => {
|
||||
it('forces every item into the declared shape, filling and dropping fields', () => {
|
||||
const def = shapesDef([{ x: 1, y: 2 }, { x: 3 }, { x: 4, y: 5, z: 9 }]);
|
||||
expect(def.default).toEqual([
|
||||
{ x: 1, y: 2 },
|
||||
{ x: 3, y: 0 },
|
||||
{ x: 4, y: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('coerces the field values themselves, so text out of a file becomes numbers', () => {
|
||||
expect(coerceValue(shapesDef([]), [{ x: '3', y: '4' }])).toEqual([{ x: 3, y: 4 }]);
|
||||
});
|
||||
|
||||
it('makes a non-struct item into the empty shape rather than dropping the item', () => {
|
||||
expect(coerceValue(shapesDef([]), [7, { x: 1, y: 2 }])).toEqual([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('survives a save and load with its items intact', () => {
|
||||
const f = makeSpriteScene();
|
||||
const behavior = makeBehavior(f, 'B');
|
||||
const asset = f.project.assets.find((a) => a.id === behavior.id);
|
||||
if (asset?.type !== 'behavior') throw new Error('behavior expected');
|
||||
asset.variables = [shapesDef(cells([0, 1], [1, 1]))];
|
||||
asset.props.shapes = cells([2, 3], [4, 5]);
|
||||
|
||||
const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id);
|
||||
if (loaded?.type !== 'behavior') throw new Error('behavior expected');
|
||||
const def = loaded.variables.find((v) => v.key === 'shapes');
|
||||
expect(def?.kind === 'list' ? def.items : '').toBe('struct');
|
||||
expect(def?.kind === 'list' ? def.fields : []).toEqual([...XY]);
|
||||
expect(loaded.props.shapes).toEqual(cells([2, 3], [4, 5]));
|
||||
});
|
||||
|
||||
it('compares by content, so an unchanged list is not reported as an override', () => {
|
||||
expect(sameValue(cells([1, 2]), cells([1, 2]))).toBe(true);
|
||||
expect(sameValue(cells([1, 2]), cells([1, 3]))).toBe(false);
|
||||
expect(sameValue(cells([1, 2]), cells([1, 2], [3, 4]))).toBe(false);
|
||||
});
|
||||
|
||||
it('reads as its items rather than as "[object Object]"', () => {
|
||||
expect(textOf(cells([1, 2], [3, 4]))).toBe('x: 1, y: 2, x: 3, y: 4');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Node params have no schema to coerce against, so the loader alone decides how deep a value may
|
||||
* be. The bound has to be the model's own: a list of structs of primitives, and no further.
|
||||
*/
|
||||
describe('an untyped slot on load', () => {
|
||||
const paramsAfterLoad = (params: Record<string, unknown>) => {
|
||||
const f = makeSpriteScene();
|
||||
const behavior = makeBehavior(f, 'B');
|
||||
const asset = f.project.assets.find((a) => a.id === behavior.id);
|
||||
if (asset?.type !== 'behavior') throw new Error('behavior expected');
|
||||
asset.graph.nodes.push({ id: 'n1', type: 'List', x: 0, y: 0, params: params as never });
|
||||
|
||||
const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id);
|
||||
if (loaded?.type !== 'behavior') throw new Error('behavior expected');
|
||||
return loaded.graph.nodes.find((n) => n.id === 'n1')?.params ?? {};
|
||||
};
|
||||
|
||||
it('keeps a list of structs', () => {
|
||||
expect(paramsAfterLoad({ items: cells([1, 2], [3, 4]) }).items).toEqual(cells([1, 2], [3, 4]));
|
||||
});
|
||||
|
||||
it('still keeps a struct holding a flat list, which it always did', () => {
|
||||
expect(paramsAfterLoad({ v: { tags: ['a', 'b'], n: 1 } }).v).toEqual({ tags: ['a', 'b'], n: 1 });
|
||||
});
|
||||
|
||||
it('drops a struct inside a struct, which no declaration can ask for', () => {
|
||||
expect(paramsAfterLoad({ v: { inner: { x: 1 }, n: 1 } }).v).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
it('drops a list inside a list item, the level below what a field may hold', () => {
|
||||
expect(paramsAfterLoad({ items: [{ deep: [1, 2], x: 1 }] }).items).toEqual([{ x: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the list nodes with struct items', () => {
|
||||
it('hands a whole item out of the list', () => {
|
||||
expect(run(ItemAt, { list: cells([1, 2], [3, 4]), index: 1 })).toEqual({ item: { x: 3, y: 4 } });
|
||||
});
|
||||
|
||||
it('puts a struct back into a list instead of turning it into text', () => {
|
||||
expect(run(SetItem, { list: cells([1, 2], [3, 4]), index: 0, value: { x: 9, y: 9 } })).toEqual({
|
||||
result: cells([9, 9], [3, 4]),
|
||||
});
|
||||
});
|
||||
|
||||
it('finds a struct by its content, since two equal structs are never the same object', () => {
|
||||
expect(run(IndexOf, { list: cells([1, 2], [3, 4]), value: { x: 3, y: 4 } })).toEqual({ index: 1 });
|
||||
expect(run(IndexOf, { list: cells([1, 2]), value: { x: 9, y: 9 } })).toEqual({ index: -1 });
|
||||
});
|
||||
|
||||
it('fills a list with a struct', () => {
|
||||
expect(run(Fill, { value: { x: 1, y: 2 }, count: 2 })).toEqual({ result: cells([1, 2], [1, 2]) });
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,16 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||||
import { type NodeContext, type RunResult, requireNodeDef, type Value } from '../../src/core/graph';
|
||||
import {
|
||||
createEmptyGraph,
|
||||
type DeclaredPort,
|
||||
NO_BEHAVIORS,
|
||||
type NodeContext,
|
||||
requireNodeDef,
|
||||
resolveDef,
|
||||
type RunResult,
|
||||
type Value,
|
||||
} from '../../src/core/graph';
|
||||
import type { PropertyBag } from '../../src/core/model/Properties';
|
||||
import { fakeContext, type FakeContext, fakeEntity, fakeHandle, NO_POINTER, runDef } from '../helpers';
|
||||
|
||||
@@ -14,6 +23,8 @@ interface Case {
|
||||
inputs?: Record<string, Value>;
|
||||
params?: PropertyBag;
|
||||
context?: Partial<NodeContext>;
|
||||
/** Ports the node declares for itself – for the types whose ports are variables it names. */
|
||||
ports?: DeclaredPort[];
|
||||
/** Expected data outputs, compared exactly. */
|
||||
outputs?: Record<string, Value>;
|
||||
/** Exec outputs the node would have grown in a graph – only for types whose ports are derived. */
|
||||
@@ -30,7 +41,8 @@ interface Case {
|
||||
|
||||
const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => {
|
||||
const ctx = fakeContext(c.context);
|
||||
const registered = requireNodeDef(type);
|
||||
const node = { id: 'n', type, x: 0, y: 0, params: c.params ?? {}, ports: c.ports };
|
||||
const registered = c.ports ? resolveDef(node, createEmptyGraph(), NO_BEHAVIORS)! : requireNodeDef(type);
|
||||
const grown = (c.derivedOutputs ?? []).map((key) => ({ key, label: key, type: 'exec' as const }));
|
||||
const def = grown.length > 0 ? { ...registered, outputs: [...registered.outputs, ...grown] } : registered;
|
||||
return { result: def.run!(ctx, c.inputs ?? {}, c.params ?? {}, def), ctx };
|
||||
@@ -98,6 +110,31 @@ const CASES: Record<string, Case[]> = {
|
||||
{ name: 'an unknown variable reads as null', params: { variable: 'nope' }, outputs: { value: null } },
|
||||
{ name: 'no variable chosen reads as null', outputs: { value: null } },
|
||||
],
|
||||
GetVariables: [
|
||||
{
|
||||
name: 'reads one output per variable it names',
|
||||
ports: [
|
||||
{ key: 'speed', kind: 'number' },
|
||||
{ key: 'nope', 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' },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.variables.speed).toBe(250),
|
||||
},
|
||||
{
|
||||
name: 'without a variable it writes nothing',
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.variables).toEqual({ speed: 100 }),
|
||||
},
|
||||
],
|
||||
SetVariable: [
|
||||
{
|
||||
name: 'writes a variable, keeping the declared type',
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user