Let structs and lists nest to any depth (#239)
game-maker / gate (push) Successful in 3m52s
game-maker / suite (push) Successful in 10m24s

Structs and lists were capped at two fixed levels (list of structs,
struct with a list field). Anything deeper was dropped silently on
load, in node settings and when written to a variable. The cap came
from an early design choice, not from a technical need, so it is
lifted completely instead of adding a third fixed level.

- One kind list NESTED_KINDS replaces ListItemKind and
  StructFieldKind. The flat special cases in listItems.ts are gone,
  every value goes through one recursive path.
- A struct inside a struct is declared, like a top-level struct.
  Values coming in over an edge are brought into that shape. Struct
  items in lists still take their fields from the value.
- New valuePath.ts addresses any nested spot by keys and indices.
  ListSlot.field is now ListSlot.path.
- Field commands (add, rename, remove) take the path to the struct.
  They change declaration, own value and all overrides in one undo
  step, as before on the top level.
- Inspector: "+ Field" offers Struct and List on every level. A
  nested struct is a header row with its own "+ Field", its fields
  are indented one step further.
- List tab: a list item shows "n items" and opens one level deeper.
  The title shows the path, e.g. "Listy · nums · [3]".
- Pack in Pack now writes a struct into a struct field instead of
  text. Objects are kept at any depth and stripped at every exit.
- A list edit in a struct field without an existing object override
  now starts from the shown value instead of an empty struct.
- Old projects load unchanged. Save, load and save again gives the
  same result.

Tests:
- ./check.sh: 172 files, 2498 tests, CHECK EXIT 0
- e2e 21 + 22: ALL PASSED, 90 checks (new 21d-iii, 22f-ii,
  21d and 22f updated)
- 20 new unit tests in nested-values.test.ts and
  nested-list-slot.test.ts, three tests that pinned the old cap
  turned around
- 6 counter-probes, each fails 1 to 8 tests, originals restored
  and checked with cmp
- Not covered: a nested struct inside a struct item in the list
  tab has no e2e click

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-26 22:33:20 +02:00
1 parent 483c34bb5c
commit 0c5ac32b59
29 files changed
+1046 -437

No files matched your search

+228
View File
@@ -0,0 +1,228 @@
/**
* Structs and lists nest to any depth (#239): a struct field may be a struct with a declaration of
* its own, a list item a list, and nothing deeper is dropped – not on load, not on the way through
* a graph. What was stored under the old bound still loads as it was.
*/
import { describe, expect, it } from 'vitest';
import { AddPortsCommand } from '../../src/core/commands/portCommands';
import {
AddStructFieldCommand,
AddVariableCommand,
RemoveStructFieldCommand,
RenameStructFieldCommand,
} from '../../src/core/commands/variableCommands';
import { writeTyped } from '../../src/core/graph';
import { liveValue, withoutObjects } from '../../src/core/graph/liveObjects';
import { asField, heldObject, type Value } from '../../src/core/graph/values';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import {
NO_OBJECT,
type PropertyBag,
type PropertyValue,
sameValue,
tagged,
} from '../../src/core/model/Properties';
import { variableDef, type StructFieldDef } from '../../src/core/properties/PropertySchema';
import { valueAt, withValueAt } from '../../src/core/properties/valuePath';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import {
fakeEntity,
graphEditor,
makeBehavior,
makeSpriteScene,
place,
roundTrip,
type SceneFixture,
startWorld,
} from '../helpers';
/** `enemy` = { hp, loot: { item, drops } }, `loot` declared inside the struct. */
function enemyOn(f: SceneFixture): BehaviorAsset {
const behavior = makeBehavior(f, 'Enemy');
const id = behavior.id;
const run = (command: Parameters<typeof f.stack.execute>[0]) => f.stack.execute(command);
run(new AddVariableCommand(id, 'enemy', 'struct'));
run(new AddStructFieldCommand(id, 'enemy', { key: 'hp', kind: 'number' }));
run(new AddStructFieldCommand(id, 'enemy', { key: 'loot', kind: 'struct', fields: [] }));
run(new AddStructFieldCommand(id, 'enemy', { key: 'item', kind: 'text' }, ['loot']));
run(new AddStructFieldCommand(id, 'enemy', { key: 'drops', kind: 'list' }, ['loot']));
return behavior;
}
const declared = (behavior: BehaviorAsset) => behavior.variables.find((v) => v.key === 'enemy')!;
describe('a struct field that is a struct', () => {
it('declares its own fields, and every value takes that shape at once', () => {
const f = makeSpriteScene();
const behavior = enemyOn(f);
expect(declared(behavior)).toMatchObject({
fields: [
{ key: 'hp', kind: 'number' },
{
key: 'loot',
kind: 'struct',
fields: [
{ key: 'item', kind: 'text' },
{ key: 'drops', kind: 'list' },
],
},
],
});
const empty = { hp: 0, loot: { item: '', drops: [] } };
expect(declared(behavior).default).toEqual(empty);
expect(behavior.props.enemy).toEqual(empty);
});
it('brings what arrives into the declared shape, however deep', () => {
const f = makeSpriteScene();
const fields = (declared(enemyOn(f)) as { fields: readonly StructFieldDef[] }).fields;
const def = variableDef('e', 'struct', { hp: '5', loot: { item: 3, extra: 1 }, other: 2 }, fields);
expect(def.default).toEqual({ hp: 5, loot: { item: '3', drops: [] } });
});
it('keeps declaration and a deep value through save and load', () => {
const f = makeSpriteScene();
const behavior = enemyOn(f);
const deep = {
hp: 3,
loot: { item: 'gem', drops: [{ name: 'a', tags: ['x', [1, tagged('color', 0xff0000)]] }, [[2]]] },
};
behavior.props.enemy = deep;
const back = roundTrip(f.project).assets.find((a) => a.id === behavior.id) as BehaviorAsset;
expect(back.props.enemy).toEqual(deep);
expect(declared(back)).toEqual(declared(behavior));
});
it('adds, renames and removes a nested field in the declaration and every value, undo included', () => {
const f = makeSpriteScene();
const behavior = enemyOn(f);
behavior.props.enemy = { hp: 1, loot: { item: 'gem', drops: [7] } };
f.stack.execute(new RenameStructFieldCommand(behavior.id, 'enemy', 'item', 'name', ['loot']));
expect(behavior.props.enemy).toEqual({ hp: 1, loot: { name: 'gem', drops: [7] } });
f.stack.execute(new RemoveStructFieldCommand(behavior.id, 'enemy', 'drops', ['loot']));
expect(behavior.props.enemy).toEqual({ hp: 1, loot: { name: 'gem' } });
expect((declared(behavior) as { fields: readonly StructFieldDef[] }).fields).toEqual([
{ key: 'hp', kind: 'number' },
{ key: 'loot', kind: 'struct', fields: [{ key: 'name', kind: 'text' }] },
]);
f.stack.undo();
expect(behavior.props.enemy).toEqual({ hp: 1, loot: { name: 'gem', drops: [7] } });
f.stack.undo();
expect(behavior.props.enemy).toEqual({ hp: 1, loot: { item: 'gem', drops: [7] } });
});
it('refuses a path that leads to no struct', () => {
const f = makeSpriteScene();
const behavior = enemyOn(f);
expect(() =>
f.stack.execute(new AddStructFieldCommand(behavior.id, 'enemy', { key: 'x', kind: 'number' }, ['hp'])),
).toThrow('Field "hp" has no fields');
expect(behavior.props.enemy).toEqual({ hp: 0, loot: { item: '', drops: [] } });
});
});
describe('stored values from before the bound was lifted', () => {
it('load exactly as they were', () => {
const f = makeSpriteScene();
const behavior = makeBehavior(f, 'Old');
f.stack.execute(new AddVariableCommand(behavior.id, 'rows', 'list'));
f.stack.execute(new AddVariableCommand(behavior.id, 'route', 'struct'));
f.stack.execute(new AddStructFieldCommand(behavior.id, 'route', { key: 'name', kind: 'text' }));
f.stack.execute(new AddStructFieldCommand(behavior.id, 'route', { key: 'stops', kind: 'list' }));
behavior.props.rows = [{ x: 1, c: tagged('color', 5) }, 2, 'a', tagged('texture', 't')];
behavior.props.route = { name: 'n', stops: [1, tagged('color', 3)] };
const stored = serializeProject(f.project);
expect(serializeProject(deserializeProject(stored))).toEqual(stored);
});
});
describe('a nested value in a running game', () => {
/** Pack(item) into Pack(loot) into `enemy`; on every update Expand twice and log the item. */
function packer(f: SceneFixture): BehaviorAsset {
const behavior = enemyOn(f);
const edit = graphEditor(f, behavior);
const inner = edit.addNode('PackStruct');
f.stack.execute(new AddPortsCommand(behavior.id, inner.id, [{ key: 'item', kind: 'text' }]));
inner.params.item = 'gem';
const outer = edit.addNode('PackStruct');
f.stack.execute(new AddPortsCommand(behavior.id, outer.id, [{ key: 'loot', kind: 'struct' }]));
const set = edit.variables(['enemy'], { write: true });
const get = edit.variables(['enemy']);
const first = edit.addNode('ExpandStruct');
f.stack.execute(new AddPortsCommand(behavior.id, first.id, [{ key: 'loot', kind: 'struct' }]));
const second = edit.addNode('ExpandStruct');
f.stack.execute(new AddPortsCommand(behavior.id, second.id, [{ key: 'item', kind: 'text' }]));
const log = edit.addNode('Log');
edit.wire([
[edit.addNode('OnStart'), 'exec', set, 'exec'],
[inner, 'value', outer, 'loot'],
[outer, 'value', set, 'enemy'],
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
[get, 'enemy', first, 'value'],
[first, 'loot', second, 'value'],
[second, 'item', log, 'message'],
]);
place(f, behavior.id, { x: 0, y: 0 });
return behavior;
}
it('is written level by level and taken apart the same way', () => {
const f = makeSpriteScene();
const behavior = packer(f);
const run = startWorld(f, { behaviors: true });
run.frame();
run.frame();
expect(run.world.log.at(-1)).toBe('gem');
// Merged into the declared shape: the fields nobody wrote keep their values.
const instance = run.world.entities.find((e) => e.assetId === behavior.id)!;
expect(instance.props.enemy).toEqual({ hp: 0, loot: { item: 'gem', drops: [] } });
});
it('keeps a list in a list and a struct in a struct when written into a variable', () => {
const bag: PropertyBag = { grid: [], nest: { a: { b: 0 } } };
writeTyped(bag, 'grid', [
[1, 2],
[3, [4]],
]);
writeTyped(bag, 'nest', { a: { b: 5, c: 1 } });
expect(bag.grid).toEqual([
[1, 2],
[3, [4]],
]);
expect(bag.nest).toEqual({ a: { b: 5 } });
});
it('holds an object at any depth, and lets none of them out', () => {
const crate = fakeEntity('Crate');
const held = asField([{ who: { at: [crate] } }] as unknown as Value);
expect(valueAt(held, [0, 'who', 'at', 0])).toBe(heldObject(crate));
expect(withoutObjects(held)).toEqual([{ who: { at: [NO_OBJECT] } }]);
expect(liveValue(held, () => false)).toEqual([{ who: { at: [NO_OBJECT] } }]);
expect(liveValue(held, () => true)).toEqual(held);
});
});
describe('a path into a nested value', () => {
const value: PropertyValue = { a: [1, { b: [2, 3] }] };
it('reads what sits there, and nothing where a step finds nothing', () => {
expect(valueAt(value, ['a', 1, 'b', 0])).toBe(2);
expect(valueAt(value, ['a', 5])).toBeUndefined();
expect(valueAt(value, ['a', 'b'])).toBeUndefined();
});
it('writes a copy, leaving the value it started from alone', () => {
const next = withValueAt(value, ['a', 1, 'b', 1], 9);
expect(next).toEqual({ a: [1, { b: [2, 9] }] });
expect(value).toEqual({ a: [1, { b: [2, 3] }] });
expect(withValueAt(value, ['a', 7], 9)).toBe(value);
});
it('compares nested values by content', () => {
expect(sameValue({ a: [1, { b: [2] }] }, { a: [1, { b: [2] }] })).toBe(true);
expect(sameValue({ a: [1, { b: [2] }] }, { a: [1, { b: [3] }] })).toBe(false);
});
});