Finish the struct and list leftovers: inline lists, list fields, item kinds

An unconnected list input is a button into the List tab instead of a number
field; a struct field can hold a list, so ListSlot carries an optional field
name and the three owner cases only say what sits at a key; and a list
variable picks its item kind in the inspector (marker doubles as the chooser),
so a list of structs no longer has to be born from a project file.

The declaration keeps the storable shapes finite: a struct field may be a list,
but that list holds primitives only, so list->struct->primitive and
struct->list->primitive are the two deepest forms.

check 674, e2e 483, jump-run 24, Tetris 19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-10 16:36:15 +02:00
1 parent fe3c63c48c
commit 10d030376a
18 files changed
+984 -184

No files matched your search

+171 -42
View File
@@ -1,6 +1,8 @@
import {
type FieldValue,
isList,
isStruct,
type ListValue,
type PropertyBag,
type PropertyValue,
type StructValue,
@@ -8,10 +10,13 @@ import {
import { overrideSites } from '../model/instance';
import type { Project } from '../model/Project';
import {
emptyField,
coerceValue,
emptyFieldValue,
isCommonProp,
type ListItemKind,
type PrimitiveFieldDef,
primitiveFieldsOf,
type PropertyDef,
type StructDef,
type StructFieldDef,
type VariableKind,
variableDef,
@@ -151,16 +156,36 @@ export function isValidFieldName(name: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
}
function requireStruct(project: Project, assetId: string, key: string): StructDef {
/** Where declared fields live: on a struct variable, or on the struct items of a list variable. */
interface FieldOwner {
/** True for a list of structs, where a field may not itself be a list (`ListFieldDef`). */
readonly inList: boolean;
fields(): readonly StructFieldDef[];
setFields(next: readonly StructFieldDef[]): void;
}
function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner {
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key);
if (!def || def.kind !== 'struct') throw new CommandError(`Variable "${key}" is not a struct`);
return def;
if (def?.kind === 'struct')
return {
inList: false,
fields: () => def.fields,
setFields: (next) => (def.fields = next),
};
if (def?.kind === 'list' && def.items === 'struct')
return {
inList: true,
fields: () => def.fields ?? [],
setFields: (next) => (def.fields = primitiveFieldsOf(next)),
};
throw new CommandError(`Variable "${key}" has no fields`);
}
/**
* Every value of one struct variable: the declared default, the behavior's own, and each instance
* override. A field is added to or taken from all of them at once, so no value keeps a shape its
* declaration no longer promises. The `id` is what pairs a site up again when the change is undone.
* Every value of one variable that carries a struct: the declared default, the behavior's own, and
* each instance override – and for a list of structs, every item of each of those. A field is added
* to or taken from all of them at once, so no value keeps a shape its declaration no longer
* promises. The `id` is what pairs a site up again when the change is undone.
*/
interface StructSite {
id: string;
@@ -169,35 +194,68 @@ interface StructSite {
}
function structSites(project: Project, assetId: string, key: string): StructSite[] {
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key);
const holders = valueHolders(project, assetId, key).filter(
// An override the object does not have stays absent: writing one here would invent a deviation.
(h) => !h.optional || (def?.kind === 'list' ? isList(h.read()) : isStruct(h.read())),
);
const sites: StructSite[] = [];
for (const holder of holders) {
if (def?.kind !== 'list') {
sites.push({
id: holder.id,
read: () => (isStruct(holder.read()) ? (holder.read() as StructValue) : {}),
write: (value) => holder.write(value),
});
continue;
}
const list = holder.read();
if (!isList(list)) continue;
for (let index = 0; index < list.length; index++) {
sites.push({
id: `${holder.id}#${index}`,
read: () => {
const item = (holder.read() as ListValue)[index];
return isStruct(item) ? item : {};
},
write: (value) =>
holder.write((holder.read() as ListValue).map((item, at) => (at === index ? value : item))),
});
}
}
return sites;
}
/** Everywhere one variable's value is kept: its declared default, the behavior's own, the overrides. */
interface ValueHolder {
id: string;
/** An override may simply not be there; the declared default and the behavior's own always are. */
optional: boolean;
read(): PropertyValue | undefined;
write(value: PropertyValue): void;
}
function valueHolders(project: Project, assetId: string, key: string): ValueHolder[] {
const asset = requireBehavior(project, assetId);
const def = asset.variables.find((v) => v.key === key);
const sites: StructSite[] = [];
if (def?.kind === 'struct')
sites.push({
const holders: ValueHolder[] = [];
if (def)
holders.push({
id: 'declared',
optional: false,
read: () => def.default,
write: (value) => {
def.default = value;
},
});
sites.push(bagSite('asset', asset.props, key));
holders.push(bagHolder('asset', false, asset.props, key));
for (const site of overrideSites(project, (id) => id === assetId))
// An override the object does not have stays absent: writing one here would invent a deviation.
if (isStruct(site.bag[key])) sites.push(bagSite(`${site.object.id}/${site.path}`, site.bag, key));
return sites;
holders.push(bagHolder(`${site.object.id}/${site.path}`, true, site.bag, key));
return holders;
}
function bagSite(id: string, bag: PropertyBag, key: string): StructSite {
return {
id,
read: () => {
const value = bag[key];
return isStruct(value) ? value : {};
},
write: (value) => {
bag[key] = value;
},
};
function bagHolder(id: string, optional: boolean, bag: PropertyBag, key: string): ValueHolder {
return { id, optional, read: () => bag[key], write: (value: PropertyValue) => (bag[key] = value) };
}
function withoutField(value: StructValue, key: string): StructValue {
@@ -225,18 +283,20 @@ export class AddStructFieldCommand implements Command {
}
execute(project: Project): void {
const def = requireStruct(project, this.assetId, this.key);
const owner = requireFieldOwner(project, this.assetId, this.key);
if (!isValidFieldName(this.field.key)) throw new CommandError(INVALID_FIELD);
if (def.fields.some((f) => f.key === this.field.key))
if (owner.fields().some((f) => f.key === this.field.key))
throw new CommandError(`Field "${this.field.key}" already exists`);
def.fields = [...def.fields, { ...this.field }];
if (owner.inList && this.field.kind === 'list')
throw new CommandError('A list field cannot sit in the items of a list');
owner.setFields([...owner.fields(), { ...this.field }]);
for (const site of structSites(project, this.assetId, this.key))
site.write({ ...site.read(), [this.field.key]: emptyField(this.field.kind) });
site.write({ ...site.read(), [this.field.key]: emptyFieldValue(this.field) });
}
undo(project: Project): void {
const def = requireStruct(project, this.assetId, this.key);
def.fields = def.fields.filter((f) => f.key !== this.field.key);
const owner = requireFieldOwner(project, this.assetId, this.key);
owner.setFields(owner.fields().filter((f) => f.key !== this.field.key));
for (const site of structSites(project, this.assetId, this.key))
site.write(withoutField(site.read(), this.field.key));
}
@@ -256,8 +316,8 @@ export class RemoveStructFieldCommand implements Command {
}
execute(project: Project): void {
const def = requireStruct(project, this.assetId, this.key);
const fields = [...def.fields];
const owner = requireFieldOwner(project, this.assetId, this.key);
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>();
@@ -267,16 +327,16 @@ export class RemoveStructFieldCommand implements Command {
if (item !== undefined) values.set(site.id, item);
site.write(withoutField(value, this.field));
}
def.fields = fields;
owner.setFields(fields);
this.removed = { field, values };
}
undo(project: Project): void {
if (!this.removed) return;
const def = requireStruct(project, this.assetId, this.key);
const fields = [...def.fields];
const owner = requireFieldOwner(project, this.assetId, this.key);
const fields = [...owner.fields()];
restoreAt(fields, [this.removed.field]);
def.fields = fields;
owner.setFields(fields);
for (const site of structSites(project, this.assetId, this.key)) {
const value = this.removed.values.get(site.id);
if (value !== undefined) site.write({ ...site.read(), [this.field]: value });
@@ -305,13 +365,82 @@ export class RenameStructFieldCommand implements Command {
}
private rename(project: Project, from: string, to: string): void {
const def = requireStruct(project, this.assetId, this.key);
const owner = requireFieldOwner(project, this.assetId, this.key);
if (from === to) return;
if (!isValidFieldName(to)) throw new CommandError(INVALID_FIELD);
if (!def.fields.some((f) => f.key === from)) throw new CommandError(`Field "${from}" not found`);
if (def.fields.some((f) => f.key === to)) throw new CommandError(`Field "${to}" already exists`);
def.fields = def.fields.map((f) => (f.key === from ? { ...f, key: to } : f));
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))
site.write(renameField(site.read(), from, to));
}
}
/**
* Changes what a list variable holds. The declaration is the only place that says it, so every
* value has to follow at once – each is run through `coerceValue` against the new declaration,
* which is the same conversion an item edited in the List tab goes through.
*/
export class SetListItemsCommand implements Command {
readonly label: string;
private before?: {
items: ListItemKind;
fields?: readonly PrimitiveFieldDef[];
values: Map<string, PropertyValue>;
};
constructor(
private readonly assetId: string,
private readonly key: string,
private readonly items: ListItemKind,
) {
this.label = `Set ${key} items`;
}
execute(project: Project): void {
const def = requireListDef(project, this.assetId, this.key);
if (def.items === this.items)
throw new CommandError(`Variable "${this.key}" already holds ${this.items}`);
const values = new Map<string, PropertyValue>();
for (const holder of valueHolders(project, this.assetId, this.key)) {
const value = holder.read();
if (value !== undefined) values.set(holder.id, value);
}
this.before = { items: def.items, fields: def.fields, values };
def.items = this.items;
// Fields belong to struct items alone: kept while leaving so an undo has them back.
if (this.items === 'struct' && !def.fields) def.fields = [];
this.recoerce(project);
}
undo(project: Project): void {
if (!this.before) return;
const def = requireListDef(project, this.assetId, this.key);
def.items = this.before.items;
if (this.before.fields) def.fields = this.before.fields;
else delete def.fields;
for (const holder of valueHolders(project, this.assetId, this.key)) {
const value = this.before.values.get(holder.id);
if (value !== undefined) holder.write(value);
}
}
private recoerce(project: Project): void {
const def = requireListDef(project, this.assetId, this.key);
for (const holder of valueHolders(project, this.assetId, this.key)) {
const value = holder.read();
if (value !== undefined) holder.write(coerceValue(def, value));
}
}
}
function requireListDef(
project: Project,
assetId: string,
key: string,
): Extract<PropertyDef, { kind: 'list' }> {
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key);
if (def?.kind !== 'list') throw new CommandError(`Variable "${key}" is not a list`);
return def;
}