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

+65 -16
View File
@@ -17,6 +17,7 @@ import {
type StructFieldDef,
variableDef,
} from '../properties/PropertySchema';
import { valueAt, withValueAt } from '../properties/valuePath';
import { type Removed, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError, RenameCommand } from './Command';
import { renameVariablePorts } from './portCommands';
@@ -189,7 +190,10 @@ export class SetVariableKeptCommand implements Command {
const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _';
const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _';
/** Where declared fields live: on a struct variable. A list item carries its own shape instead. */
/**
* Where declared fields live: on a struct variable, or on a struct field of one, however deep
* (#239). A list item carries its own shape instead.
*/
interface FieldOwner {
fields(): readonly StructFieldDef[];
setFields(next: readonly StructFieldDef[]): void;
@@ -199,15 +203,50 @@ function variableOf(project: Project, assetId: string, key: string): PropertyDef
return requireVariableOwner(project, assetId).variables.find((v) => v.key === key);
}
function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner {
/** `path` names the struct fields from the variable down to the struct whose fields are meant. */
function requireFieldOwner(
project: Project,
assetId: string,
key: string,
path: readonly string[],
): FieldOwner {
const def = variableOf(project, assetId, key);
if (def?.kind !== 'struct') throw new CommandError(`Variable "${key}" has no fields`);
return { fields: () => def.fields, setFields: (next) => (def.fields = next) };
if (fieldsAt(def.fields, path) === undefined)
throw new CommandError(`Field "${path.join('.')}" has no fields`);
return {
fields: () => fieldsAt(def.fields, path) ?? [],
setFields: (next) => (def.fields = withFieldsAt(def.fields, path, next)),
};
}
function fieldsAt(
fields: readonly StructFieldDef[],
path: readonly string[],
): readonly StructFieldDef[] | undefined {
if (path.length === 0) return fields;
const field = fields.find((f) => f.key === path[0]);
return field?.kind === 'struct' ? fieldsAt(field.fields, path.slice(1)) : undefined;
}
/** The declaration with the fields at `path` replaced, rebuilt on the way down. */
function withFieldsAt(
fields: readonly StructFieldDef[],
path: readonly string[],
next: readonly StructFieldDef[],
): readonly StructFieldDef[] {
if (path.length === 0) return next;
return fields.map((f) =>
f.key === path[0] && f.kind === 'struct'
? { ...f, fields: withFieldsAt(f.fields, path.slice(1), next) }
: f,
);
}
/**
* One struct-carrying value of a variable (its declared default, the behavior's own, an override); a
* field change hits all of them at once so none keeps a stale shape. `id` pairs a site up on undo.
* With a `path` the site is the struct that deep inside the value.
*/
interface StructSite {
id: string;
@@ -215,15 +254,19 @@ interface StructSite {
write(value: StructValue): void;
}
function structSites(project: Project, assetId: string, key: string): StructSite[] {
function structSites(project: Project, assetId: string, key: string, path: readonly string[]): StructSite[] {
const structIn = (value: PropertyValue | undefined): StructValue => {
const at = valueAt(value, path);
return isStruct(at) ? at : {};
};
return (
valueHolders(project, assetId, key)
// An override the object does not have stays absent: writing one here would invent a deviation.
.filter((h) => !h.optional || isStruct(h.read()))
.map((holder) => ({
id: holder.id,
read: () => (isStruct(holder.read()) ? (holder.read() as StructValue) : {}),
write: (value: StructValue) => holder.write(value),
read: () => structIn(holder.read()),
write: (value: StructValue) => holder.write(withValueAt(holder.read(), path, value) ?? value),
}))
);
}
@@ -276,28 +319,30 @@ function renameField(value: StructValue, from: string, to: string): StructValue
export class AddStructFieldCommand implements Command {
readonly label: string;
/** `path` names the struct the field is added to: none for the variable itself. */
constructor(
private readonly assetId: string,
private readonly key: string,
private readonly field: StructFieldDef,
private readonly path: readonly string[] = [],
) {
this.label = `Add field ${field.key}`;
}
execute(project: Project): void {
const owner = requireFieldOwner(project, this.assetId, this.key);
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
if (!isValidFieldName(this.field.key)) throw new CommandError(INVALID_FIELD);
if (owner.fields().some((f) => f.key === this.field.key))
throw new CommandError(`Field "${this.field.key}" already exists`);
owner.setFields([...owner.fields(), { ...this.field }]);
for (const site of structSites(project, this.assetId, this.key))
for (const site of structSites(project, this.assetId, this.key, this.path))
site.write({ ...site.read(), [this.field.key]: emptyFieldValue(this.field) });
}
undo(project: Project): void {
const owner = requireFieldOwner(project, this.assetId, this.key);
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
owner.setFields(owner.fields().filter((f) => f.key !== this.field.key));
for (const site of structSites(project, this.assetId, this.key))
for (const site of structSites(project, this.assetId, this.key, this.path))
site.write(withoutField(site.read(), this.field.key));
}
}
@@ -307,21 +352,23 @@ export class RemoveStructFieldCommand implements Command {
readonly label: string;
private removed?: { field: Removed<StructFieldDef>; values: Map<string, FieldValue> };
/** `path` names the struct the field is taken from: none for the variable itself. */
constructor(
private readonly assetId: string,
private readonly key: string,
private readonly field: string,
private readonly path: readonly string[] = [],
) {
this.label = `Remove field ${field}`;
}
execute(project: Project): void {
const owner = requireFieldOwner(project, this.assetId, this.key);
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
const fields = [...owner.fields()];
const field = removeFirst(fields, (f) => f.key === this.field);
if (!field) throw new CommandError(`Field "${this.field}" not found`);
const values = new Map<string, FieldValue>();
for (const site of structSites(project, this.assetId, this.key)) {
for (const site of structSites(project, this.assetId, this.key, this.path)) {
const value = site.read();
const item = value[this.field];
if (item !== undefined) values.set(site.id, item);
@@ -333,11 +380,11 @@ export class RemoveStructFieldCommand implements Command {
undo(project: Project): void {
if (!this.removed) return;
const owner = requireFieldOwner(project, this.assetId, this.key);
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
const fields = [...owner.fields()];
restoreAt(fields, [this.removed.field]);
owner.setFields(fields);
for (const site of structSites(project, this.assetId, this.key)) {
for (const site of structSites(project, this.assetId, this.key, this.path)) {
const value = this.removed.values.get(site.id);
if (value !== undefined) site.write({ ...site.read(), [this.field]: value });
}
@@ -345,24 +392,26 @@ export class RemoveStructFieldCommand implements Command {
}
export class RenameStructFieldCommand extends RenameCommand {
/** `path` names the struct the field belongs to: none for the variable itself. */
constructor(
private readonly assetId: string,
private readonly key: string,
from: string,
to: string,
private readonly path: readonly string[] = [],
) {
super(`Rename field ${from}`, from, to);
}
protected rename(project: Project, from: string, to: string): void {
const owner = requireFieldOwner(project, this.assetId, this.key);
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
if (from === to) return;
if (!isValidFieldName(to)) throw new CommandError(INVALID_FIELD);
const fields = owner.fields();
if (!fields.some((f) => f.key === from)) throw new CommandError(`Field "${from}" not found`);
if (fields.some((f) => f.key === to)) throw new CommandError(`Field "${to}" already exists`);
owner.setFields(fields.map((f) => (f.key === from ? { ...f, key: to } : f)));
for (const site of structSites(project, this.assetId, this.key))
for (const site of structSites(project, this.assetId, this.key, this.path))
site.write(renameField(site.read(), from, to));
}
}