Give a list one struct type so every item keeps its shape (#254)
game-maker / gate (push) Successful in 3m28s
game-maker / suite (push) Successful in 10m38s

A list of structs used to let every item carry its own fields. Adding
an item meant typing the same fields again, and nothing kept the items
in step with the struct asset from #253. A list can now name a struct
type for its items, and the asset keeps them in shape.

- A list variable and a list field get the row Items with Mixed, every
  struct asset and Make struct type. Make struct type collects every
  field any item has, so no item loses a field.
- A typed item carries its type under the key `$struct`
  (ITEM_TYPE_KEY). listItems does not count it as a field.
- A list that holds anything but structs refuses a type, also when
  the offending item sits in an override. The console names the list
  and the count, and the list stays as it was.
- In a typed list `+ Item` adds an item with the defaults, without the
  kind menu. Field names are locked, values stay editable.
- The rows of a typed item take their kind from the struct asset
  (declaredKind in InlineList.ts). An asset field shows the asset
  picker instead of a text field, as reported in the issue.
- A struct item in a mixed list gets its own Type row. Switching a
  list back to Mixed keeps the type on each item.
- syncStructTypes brings marked items into shape and carries renames
  into them, also inside overrides and nested lists. One undo step.
- The delete dialog of a struct asset also counts typed lists.
- The game never sees the key. World and sharedState strip it with
  withoutItemTypes and bagWithoutItemTypes, so a struct stays a
  plain object at runtime.
- Add item and Set item get the setting Type. With a type they only
  accept a struct with exactly its field names (fitsType in
  SetItem.ts). Anything else is left out and logged.
- The item indent from #252 is back to its old width, the narrow one
  squeezed the value fields.

Not done: writing a variable does not check the type, and the List
node offers no type.

Found on the way: a test helper in list-of-structs.test.ts called
nodes without params. It now passes the same arguments as the
interpreter.

Tests:
- ./check.sh: 183 files, 2679 tests, CHECK EXIT 0
- e2e GM_STEPS=1,21,22,32,54,55: ALL PASSED, 150 checks
- e2e GM_STEPS=1-6,21,22,32,54,55 before the follow-up: 404 checks
- New: tests/core/typed-lists.test.ts, e2e module 55 with 13 checks
- Counter-checks bite in unit and browser, originals restored by cmp

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-28 07:23:00 +02:00
1 parent 61bbb96a96
commit b0d0868f55
25 files changed
+1446 -86

No files matched your search

+145 -1
View File
@@ -7,17 +7,29 @@ import type { GraphNode } from '../graph/Graph';
import { STRUCT_TYPE_PARAM } from '../graph/nested';
import type { StructAsset } from '../model/Asset';
import { nextAssetName } from './naming';
import { isStruct, type PropertyValue } from '../model/Properties';
import {
isList,
isStruct,
itemTypeOf,
type ListValue,
type PropertyValue,
type StructValue,
tagOf,
} from '../model/Properties';
import type { Project } from '../model/Project';
import {
containsType,
type FieldRename,
isTypedNode,
listDeclAt,
setListItemType,
setStructType,
structAssetOf,
structAt,
syncStructTypes,
variableValues,
} from '../model/structTypes';
import { structItemFields } from '../properties/listItems';
import {
coerceValue,
type PropertyDef,
@@ -127,6 +139,7 @@ function variableOfField(field: StructFieldDef, value: PropertyValue | undefined
);
if (value !== undefined) def.default = coerceValue(def, value);
if (def.kind === 'struct' && field.kind === 'struct' && field.structType) def.structType = field.structType;
if (def.kind === 'list' && field.kind === 'list' && field.itemType) def.itemType = field.itemType;
return def;
}
@@ -176,3 +189,134 @@ export class MakeStructTypeCommand implements Command {
if (this.asset) project.assets = removeById(project.assets, this.asset.id);
}
}
/** A list variable, or a list field inside a struct variable – `path` leads to the field (#254). */
export interface ListTarget {
ownerId: string;
key: string;
path: readonly string[];
}
function requireListDecl(project: Project, target: ListTarget) {
const owner = requireVariableOwner(project, target.ownerId);
const def = owner.variables.find((v) => v.key === target.key);
const decl = def && listDeclAt(def, target.path);
if (!def || !decl) throw new CommandError(`"${[target.key, ...target.path].join('.')}" is no list`);
return { owner, def, decl };
}
/** Every list the target holds anywhere – its default, the owner's value, each override. */
function listsOf(project: Project, target: ListTarget): ListValue[] {
const { owner, def } = requireListDecl(project, target);
return variableValues(project, owner, def).flatMap((value) => {
const list = valueAt(value, target.path);
return isList(list) ? [list] : [];
});
}
const isStructItem = (item: PropertyValue): boolean => isStruct(item) && tagOf(item) === undefined;
/**
* A list holding anything but structs cannot take a type: which of its items would become what is
* not for the editor to guess (#254). The refusal says so, in the console like any refused edit.
*/
function requireStructItems(project: Project, target: ListTarget): ListValue[] {
const lists = listsOf(project, target);
const others = lists.reduce((n, list) => n + list.filter((item) => !isStructItem(item)).length, 0);
if (others > 0) {
const name = [target.key, ...target.path].join('.');
const what = others === 1 ? '1 item that is' : `${others} items that are`;
throw new CommandError(`${name} holds ${what} no struct – only a list of structs can take a type`);
}
return lists;
}
/**
* Makes every item of a list one Struct asset, or with `''` a mixed list again. Items keep their
* values where the type has the field; a mixed list whose items keep their marks loses nothing.
*/
export class SetListItemTypeCommand implements Command {
readonly label: string;
private rec = new Recorder();
constructor(
private readonly target: ListTarget,
private readonly typeId: string,
) {
this.label = typeId ? 'Set item type' : 'Mixed items';
}
execute(project: Project): void {
if (this.typeId && !structAssetOf(project, this.typeId)) throw new CommandError('That struct is gone');
const { def } = requireListDecl(project, this.target);
if (this.typeId) requireStructItems(project, this.target);
this.rec = new Recorder();
setListItemType(def, this.target.path, this.typeId, this.rec);
syncStructTypes(project, this.rec);
}
undo(): void {
this.rec.revert();
}
}
/**
* Turns the shape of a list's items into a Struct asset and types the list with it. Every field any
* item has is in it, at the value the first item holding it has – so no item loses a field.
*/
export class MakeListItemTypeCommand implements Command {
readonly label = 'Make struct type';
private asset?: StructAsset;
private rec = new Recorder();
constructor(private readonly target: ListTarget) {}
get assetId(): string {
return this.asset?.id ?? '';
}
execute(project: Project): void {
const { def, decl } = requireListDecl(project, this.target);
if (decl.itemType && structAssetOf(project, decl.itemType))
throw new CommandError('This list has a type already');
const lists = requireStructItems(project, this.target);
if (!this.asset) {
const items = lists.flat().filter(isStruct);
const values: Record<string, PropertyValue> = {};
const fields: StructFieldDef[] = [];
for (const item of items)
for (const field of itemFieldDefs(item))
if (!(field.key in values)) {
values[field.key] = item[field.key]!;
fields.push(field);
}
const variables = fields.map((f) => variableOfField(f, values[f.key]));
const props = Object.fromEntries(variables.map((v) => [v.key, v.default]));
const name = nextAssetName(project, this.target.path.at(-1) ?? this.target.key);
this.asset = { id: newId(), type: 'struct', name, props, variables };
}
project.assets.push(this.asset);
this.rec = new Recorder();
setListItemType(def, this.target.path, this.asset.id, this.rec);
syncStructTypes(project, this.rec);
}
undo(project: Project): void {
this.rec.revert();
if (this.asset) project.assets = removeById(project.assets, this.asset.id);
}
}
/** The fields a struct item has, as a declaration: a struct in it declares its own again. */
function itemFieldDefs(item: StructValue): StructFieldDef[] {
return structItemFields(item).map(({ key, kind }): StructFieldDef => {
const value = item[key];
if (kind === 'struct' && isStruct(value)) {
const typeId = itemTypeOf(value);
return { key, kind, fields: itemFieldDefs(value), ...(typeId ? { structType: typeId } : {}) };
}
if (kind === 'struct') return { key, kind, fields: [] };
if (kind === 'list') return { key, kind };
return { key, kind };
});
}