Drop the per-list item declaration; every item carries its own kind

A list no longer says what its items are. ListDef.items, ListDef.fields,
ListFieldDef.items and SetListItemsCommand are gone; the single seam is
src/core/properties/listItems.ts. JSON already tells number, text, boolean
and struct apart, so only two kinds need a tag: {"$color": n} and the new
{"$texture": id}. Field names cannot start with "$", so a tag never
collides with a struct field. On a data link a texture item arrives as
its bare id; the graph has no images.

List tab: the mark in front of each row is the kind picker, "+ Item"
asks for the kind, both are ordinary value edits (one undo step). A list
inside a struct field only offers the flat kinds because the depth rule
forbids a struct item there (ListAccess.kinds); the menu used to offer
it and silently do nothing.

Legacy lists that declared items: 'color' are tagged on load.

Actor demo: the four walk animations and four standing frames live in
two list variables of texture items indexed by facing (Item at), so the
graph can be rebuilt in the editor. The stand frames get their grid ids
back; 44 nodes instead of 46.

Also: the user's edits to on-start-during-play.md and the new note
reseed-because-of-list.md.

Verified: check.sh 794 tests, full e2e 631 checks, smokes 24/19/20.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-12 13:35:02 +02:00
1 parent e147dd6d21
commit 11c900c998
34 files changed
+1218 -640

No files matched your search

+14 -55
View File
@@ -12,6 +12,7 @@ import {
booleanOf,
colorOf,
} from '../model/Properties';
import { coerceField, flatListOf, listItemOf } from './listItems';
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
export type PropertyDef =
@@ -47,19 +48,12 @@ export type PropertyDef =
*/
| { key: string; label: string; kind: 'texture'; default: string }
/**
* `items` only decides how the inspector reads and writes the line; the graph types a list as a
* list, so one `list` port fits them all. There is no `object` item kind: a list is stored in the
* project file, and an object is a live handle that no reload survives.
* A list declares nothing about its items: each one carries its own kind (see `listItems.ts`),
* and the graph types a list as a list, so one `list` port fits them all. There is no `object`
* item kind: a list is stored in the project file, and an object is a live handle that no reload
* survives.
*/
| {
key: string;
label: string;
kind: 'list';
default: ListValue;
items: ListItemKind;
/** The shape of every item, and only for `items: 'struct'` – a list of structs is homogeneous. */
fields?: readonly PrimitiveFieldDef[];
}
| { key: string; label: string; kind: 'list'; default: ListValue }
/**
* `fields` is the declaration; the value carries exactly those keys, which is what lets a struct
* keep its shape while single fields are written (`mergeStruct`).
@@ -70,8 +64,8 @@ export type PropertyDef =
export const FIELD_KINDS = ['number', 'text', 'boolean', 'color'] as const;
export type FieldKind = (typeof FIELD_KINDS)[number];
/** What a list holds – a primitive plus `struct`, so a list of bundles is one declaration. */
export const LIST_ITEM_KINDS = [...FIELD_KINDS, 'struct'] as const;
/** What one item of a list may be – a primitive plus `struct`, so a bundle can sit in a list. */
export const LIST_ITEM_KINDS = [...FIELD_KINDS, 'texture', 'struct'] as const;
export type ListItemKind = (typeof LIST_ITEM_KINDS)[number];
/** What a field of a struct property holds – a primitive plus `list`. */
@@ -83,11 +77,10 @@ export interface PrimitiveFieldDef {
kind: FieldKind;
}
/** A list held by a struct field; its items are primitives only, which keeps storable shapes finite. */
/** A list held by a struct field. Its items declare nothing either, but they stay flat. */
export interface ListFieldDef {
key: string;
kind: 'list';
items: FieldKind;
}
export type StructFieldDef = PrimitiveFieldDef | ListFieldDef;
@@ -242,7 +235,6 @@ export function variableDef(
key: string,
kind: VariableKind,
defaultValue?: PropertyValue,
items?: ListItemKind,
fields?: readonly StructFieldDef[],
): PropertyDef {
switch (kind) {
@@ -252,19 +244,8 @@ export function variableDef(
return { key, label: key, kind, default: Boolean(defaultValue ?? false) };
case 'text':
return { key, label: key, kind, default: defaultValue === undefined ? '' : textOf(defaultValue) };
case 'list': {
const item = items ?? 'number';
const shape = item === 'struct' ? primitiveFieldsOf(fields ?? []) : undefined;
const raw = isList(defaultValue) ? defaultValue : [];
return {
key,
label: key,
kind,
default: raw.map((entry) => coerceListItem(entry, item, shape)),
items: item,
...(shape ? { fields: shape } : {}),
};
}
case 'list':
return { key, label: key, kind, default: (isList(defaultValue) ? defaultValue : []).map(listItemOf) };
case 'struct': {
const declared = fields ?? [];
return { key, label: key, kind, fields: declared, default: structOf(declared, defaultValue) };
@@ -272,10 +253,6 @@ export function variableDef(
}
}
export function primitiveFieldsOf(fields: readonly StructFieldDef[]): PrimitiveFieldDef[] {
return fields.filter((f): f is PrimitiveFieldDef => f.kind !== 'list');
}
/** The empty value of a kind, so a field that is not there yet still has one. */
const EMPTY_FIELD: Record<FieldKind, number | string | boolean> = {
number: 0,
@@ -324,32 +301,14 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
return def.options.some((o) => o.value === s) ? s : def.default;
}
case 'list':
return isList(value) ? value.map((item) => coerceListItem(item, def.items, def.fields)) : def.default;
// Each item keeps the kind it arrived as; there is no declaration to force it into.
return isList(value) ? value.map(listItemOf) : def.default;
case 'struct':
return structOf(def.fields, value);
}
}
/** `fields` is only read for `items: 'struct'`, where it is the shape every item is forced into. */
export function coerceListItem(
value: PropertyValue,
items: ListItemKind,
fields?: readonly StructFieldDef[],
): number | string | boolean | StructValue {
return items === 'struct' ? structOf(fields ?? [], value) : coerceField(value, items);
}
/** A struct field as its declaration promises. A list field takes only a list – see `ListFieldDef`. */
export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): FieldValue {
if (field.kind !== 'list') return coerceField(value, field.kind);
return isList(value) ? value.map((item) => coerceField(item, field.items)) : [];
}
/** The primitive half of `coerceListItem`, split off because a struct field is never a struct. */
export function coerceField(value: PropertyValue, items: FieldKind): number | string | boolean {
if (items === 'text') return textOf(value);
if (items === 'boolean') return typeof value === 'boolean' ? value : booleanOf(textOf(value));
if (items === 'color') return colorOf(value, 0);
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : 0;
return field.kind === 'list' ? flatListOf(value) : coerceField(value, field.kind);
}
+136
View File
@@ -0,0 +1,136 @@
/**
* A list declares nothing about what it holds: every item carries its own kind, read back off the
* value. JSON already says what a number, a text, a boolean and a struct are, so only a color and a
* texture need a tag (`COLOR_TAG`, `TEXTURE_TAG`) – which keeps every list ever stored readable.
*
* Depth is still capped the way the declarations used to cap it: a struct item holds flat items,
* and so does a list inside a struct field. A texture is a list item only; in a flat position it
* keeps its id as text rather than vanishing.
*/
import {
booleanOf,
type ColorItem,
colorItem,
colorItemValue,
colorOf,
type FieldValue,
isColorItem,
isList,
isStruct,
isTextureItem,
type PropertyValue,
type StructValue,
textOf,
textureItem,
textureItemValue,
} from '../model/Properties';
import type { FieldKind, ListItemKind } from './PropertySchema';
/** What a list holds. Identical to `ListValue[number]`, named for the places that mean one item. */
export type ListItem = number | string | boolean | StructValue;
/** What a flat position holds: no struct except a tagged color, so the depth stays capped. */
export type FlatItem = number | string | boolean | ColorItem;
/** The kind an item is, taken from the item itself – there is nowhere else it could come from. */
export function itemKindOf(item: PropertyValue | undefined): ListItemKind {
if (isColorItem(item)) return 'color';
if (isTextureItem(item)) return 'texture';
if (isStruct(item) || isList(item)) return 'struct';
if (typeof item === 'string') return 'text';
if (typeof item === 'boolean') return 'boolean';
return 'number';
}
/** Flat positions hold no struct and no texture, so either of them reads as its text. */
export function fieldKindOf(value: PropertyValue | undefined): FieldKind {
const kind = itemKindOf(value);
return kind === 'struct' || kind === 'texture' ? 'text' : kind;
}
/** What the widgets and the graph see: a tag is what it wraps, everything else is itself. */
export function itemValue(item: PropertyValue | undefined): PropertyValue | undefined {
if (isColorItem(item)) return colorItemValue(item);
if (isTextureItem(item)) return textureItemValue(item);
return item;
}
/** `value` as an item of `kind`, which is how a row changes what it holds. */
export function makeItem(kind: ListItemKind, value: PropertyValue | undefined): ListItem {
switch (kind) {
case 'color':
// Through `itemValue`: a tagged color handed to `colorOf` would be read as hex text.
return colorItem(colorOf(itemValue(value) ?? 0, 0));
case 'texture':
return textureItem(textOf(itemValue(value)));
case 'struct':
return structItemOf(value);
default:
return coerceField(itemValue(value), kind);
}
}
/** `value` as an item of the kind the slot already holds, so writing a color keeps it a color. */
export function itemLike(current: PropertyValue | undefined, value: PropertyValue | undefined): ListItem {
return makeItem(itemKindOf(current), value);
}
/** `value` as a flat item of `kind` – a struct item's field, where a struct itself cannot go. */
export function makeFlatItem(kind: FieldKind, value: PropertyValue | undefined): FlatItem {
return kind === 'color'
? colorItem(colorOf(itemValue(value) ?? 0, 0))
: coerceField(itemValue(value), kind);
}
/** A flat slot (a struct item's field, an item of a list in a struct field) keeps its kind too. */
export function fieldLike(current: PropertyValue | undefined, value: PropertyValue | undefined): FlatItem {
return makeFlatItem(fieldKindOf(current), value);
}
/** Any value as a valid item; the one coercion a list value goes through. */
export function listItemOf(value: PropertyValue | undefined): ListItem {
return makeItem(itemKindOf(value), value);
}
/**
* A flat position holds a primitive or a tagged color. Anything deeper is dropped rather than
* flattened – the same bound the declarations used to draw, and the one the store writes.
*/
export function flatItemOf(value: PropertyValue | undefined): FlatItem | undefined {
if (isColorItem(value)) return colorItem(colorItemValue(value));
if (isTextureItem(value)) return textureItemValue(value);
if (isList(value) || isStruct(value)) return undefined;
return coerceField(value, fieldKindOf(value));
}
/** A struct item keeps the fields it carries, each as the kind its own value says. */
export function structItemOf(value: PropertyValue | undefined): StructValue {
if (!isStruct(value) || isColorItem(value) || isTextureItem(value)) return {};
const fields: Record<string, FieldValue> = {};
for (const [key, item] of Object.entries(value)) {
const flat = flatItemOf(item);
if (flat !== undefined) fields[key] = flat;
}
return fields;
}
/** The items of a list sitting in a struct field: flat ones only. */
export function flatListOf(value: PropertyValue | undefined): readonly FlatItem[] {
if (!isList(value)) return [];
return value.map(flatItemOf).filter((item): item is FlatItem => item !== undefined);
}
/** The shape a struct item has right now: its keys in order, each with the kind of its value. */
export function structItemFields(item: PropertyValue | undefined): { key: string; kind: FieldKind }[] {
if (!isStruct(item) || isColorItem(item) || isTextureItem(item)) return [];
return Object.entries(item).map(([key, value]) => ({ key, kind: fieldKindOf(value) }));
}
/** A primitive as one of the four kinds. The spelling of a text counts, see `booleanOf`. */
export function coerceField(value: PropertyValue | undefined, kind: FieldKind): number | string | boolean {
if (kind === 'text') return textOf(value);
if (kind === 'boolean') return typeof value === 'boolean' ? value : booleanOf(textOf(value));
if (kind === 'color') return colorOf(value ?? 0, 0);
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : 0;
}