Let a list hold structs, and read or write variables in bundles

A list item may now be a struct. The depth is bounded in the declaration
rather than by a comment: FieldKind (number|text|boolean|color) says what a
struct field holds, ListItemKind adds struct on top and says what a list
holds, so list -> struct -> primitive is the deepest storable shape and
sameValue stays finite. A struct item renders inline in the List tab, which
is why it needs no path in the slot address.

Four paths silently assumed a list item was a primitive: sameValue compared
items with ===, textOf would have printed [object Object], asListItem turned
a struct into text on Set item and Fill, and Index of used indexOf, which is
reference equality and could never have found a struct.

Tetris now runs on bundles. shapes is 112 {x, y} structs instead of 224 loose
numbers, piece and the candidate are structs, so Next move hands back a whole
candidate and each strand packs only the field it changes, since mergeStruct
leaves the rest standing. Declared behavior ports drop 49 -> 35, the master's
variables 18 -> 13, its graph 84/94 -> 69/76 nodes/edges.

Get variables and Set variables declare their ports from a list of variable
names, the way Pack and Expand already do, so one node can read or write
several at once. Renaming a variable rewires them, because there the port key
is the variable name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-10 14:01:56 +02:00
1 parent 9d62dfea7b
commit f60358c951
32 files changed
+1083 -253

No files matched your search

+27 -17
View File
@@ -13,7 +13,7 @@ import {
import type { Asset, TextureFrame } from '../model/Asset';
import { isAssetType } from '../model/AssetTypes';
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
import type { FieldValue, PropertyBag, PropertyValue } from '../model/Properties';
import type { FieldValue, PropertyBag, PropertyValue, StructValue } from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { isTextureFile, normalizeFrameRect } from '../model/texture';
@@ -21,6 +21,8 @@ import { hasParentCycle } from '../model/walk';
import {
coerceValue,
COMMON_PROPS,
FIELD_KINDS,
type FieldKind,
isCommonProp,
LIST_ITEM_KINDS,
type ListItemKind,
@@ -192,9 +194,9 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] {
const f = asRecord(item);
const key = typeof f.key === 'string' ? f.key : '';
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key)) continue;
if (!(LIST_ITEM_KINDS as readonly string[]).includes(String(f.kind))) continue;
if (!(FIELD_KINDS as readonly string[]).includes(String(f.kind))) continue;
seen.add(key);
fields.push({ key, kind: f.kind as ListItemKind });
fields.push({ key, kind: f.kind as FieldKind });
}
return fields;
}
@@ -317,27 +319,35 @@ function asRecord(value: unknown): Record<string, unknown> {
* flattened one level, so nothing deeper than the model allows can enter behind the type's back.
*/
function asStoredValue(value: unknown): PropertyValue | undefined {
const field = asFieldValue(value);
if (field !== undefined) return field;
if (!value || typeof value !== 'object') return undefined;
if (isPrimitive(value)) return value;
if (Array.isArray(value)) return value.map(asStoredItem).filter((item) => item !== undefined);
return asStoredStruct(value, asStoredField);
}
const isPrimitive = (value: unknown): value is number | string | boolean =>
typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean';
/** A list item: a primitive or a struct of primitives. */
const asStoredItem = (value: unknown): number | string | boolean | StructValue | undefined =>
isPrimitive(value) ? value : asStoredStruct(value, (item) => (isPrimitive(item) ? item : undefined));
/** A struct field: a primitive or a flat list. A field that is itself a struct is dropped. */
const asStoredField = (value: unknown): FieldValue | undefined =>
isPrimitive(value) ? value : Array.isArray(value) ? value.filter(isPrimitive) : undefined;
function asStoredStruct(
value: unknown,
ofField: (item: unknown) => FieldValue | undefined,
): StructValue | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const fields: Record<string, FieldValue> = {};
for (const [key, item] of Object.entries(value)) {
const stored = asFieldValue(item);
const stored = ofField(item);
if (stored !== undefined) fields[key] = stored;
}
return fields;
}
function asFieldValue(value: unknown): FieldValue | undefined {
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') return value;
if (Array.isArray(value))
return value.filter(
(item): item is number | string | boolean =>
typeof item === 'number' || typeof item === 'string' || typeof item === 'boolean',
);
return undefined;
}
function toNumber(value: unknown): number {
const n = Number(value);
return Number.isFinite(n) ? n : 0;