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

+51 -4
View File
@@ -139,12 +139,13 @@ export async function structVariable(t) {
const info = await json('__gm.list.info()');
check('whose title names the field inside the bundle', info.title.endsWith('· field1'), info.title);
// The depth the stored value goes ends here: this list sits in a struct field, so a struct item
// would be dropped by the next write. It is left out of the menu rather than doing nothing.
// Nothing bounds the depth any more (#239): a list in a struct field offers what any list does.
await clickText('+ Item');
check(
'the menu of a list in a struct field leaves the struct kind out',
(await find('Number')) !== null && (await find('Struct')) === null,
'the menu of a list in a struct field offers every kind, the struct included',
(await find('Number')) !== null &&
(await find('Struct')) !== null &&
(await json('__gm.list.info()')).offers.includes('list'),
JSON.stringify((await json('__gm.list.info()')).offers),
);
await pickKind('Number');
@@ -165,6 +166,52 @@ export async function structVariable(t) {
await mutate('__gm.state.openList(null)');
// 22f-ii. A field may be a struct again (#239): a head row with a `+ Field` of its own, and its
// fields one indent step further in. Both menus with real clicks.
const declaredLast = () =>
json("__gm.state.project.assets.find((a) => a.id === 'structy').variables[0].fields.at(-1)");
await clickText('+ Field');
await pickKind('Struct');
await sleep(250);
check(
'+ Field ▸ Struct gives the bundle an empty struct',
JSON.stringify((await piece()).field1) === '{}',
JSON.stringify(await piece()),
);
// The nested `+ Field` is drawn after the head's, so it is the last one.
const nestedAdd = await json(`__gm.locate('+ Field', true)`);
const headAdd = await json(`__gm.locate('+ Field')`);
check(
'and that struct has its own + Field',
nestedAdd.y > headAdd.y,
JSON.stringify({ nestedAdd, headAdd }),
);
await click(nestedAdd.x, nestedAdd.y);
await pickKind('Number');
await sleep(250);
check(
'which adds a field one level down, in the value',
JSON.stringify(await piece()) === '{"column":30,"tag":"tee","field1":{"field1":0}}',
JSON.stringify(await piece()),
);
check(
'and in the declaration',
JSON.stringify(await declaredLast()) ===
'{"key":"field1","kind":"struct","fields":[{"key":"field1","kind":"number"}]}',
JSON.stringify(await declaredLast()),
);
const outerName = await json(`__gm.locate('field1')`);
const innerName = await json(`__gm.locate('field1', true)`);
check(
'its field sits below it, one indent step further in',
innerName.y > outerName.y && innerName.x > outerName.x,
JSON.stringify({ outerName, innerName }),
);
await shot('03b-nested-struct');
await undo();
await undo();
check('undo takes both back out', !('field1' in (await piece())), JSON.stringify(await piece()));
// 22g. The same bundle on a placed object. An override is sparse – it starts out unset – while a
// field edit rebuilds the whole bundle, so the untouched fields have to come from what the row
// shows (the asset's values) rather than from the empty form of the declaration.