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

+66
View File
@@ -1,6 +1,7 @@
import {
BEHAVIOR_PARAM,
BEHAVIOR_START,
declaresVariablePorts,
type DeclaredPort,
findNode,
type Graph,
@@ -190,6 +191,71 @@ export class RenamePortCommand implements Command {
}
}
/** Renames one declared port on one node, taking its edges and its typed-in value along. */
function renamePortOn(graph: Graph, node: GraphNode, from: string, to: string): void {
const ports = node.ports ?? [];
if (from === to || !ports.some((p) => p.key === from)) return;
const site: PortSite = {
graph,
nodeId: node.id,
asInput: requireNodeDef(node.type).declaresPorts === 'inputs',
};
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
for (const edge of graph.edges.filter(edgesOnPort(site, from))) {
if (site.asInput) edge.to.port = to;
else edge.from.port = to;
}
if (site.asInput) renameParam(site, from, to);
}
/**
* Points one declared port at another variable. Key and kind change together, because on a
* Get/Set variables node the kind is the variable's rather than a choice of its own; the edges
* hanging on the port come along, so correcting a pick is not a rewiring.
*/
export class SetVariablePortCommand implements Command {
readonly label: string;
private previous?: DeclaredPort;
constructor(
private readonly assetId: string,
private readonly nodeId: string,
private readonly from: string,
private readonly port: DeclaredPort,
) {
this.label = `Point ${from} at ${port.key}`;
}
execute(project: Project): void {
const node = requirePortNode(project, this.assetId, this.nodeId);
const ports = node.ports ?? [];
const current = ports.find((p) => p.key === this.from);
if (!current) throw new CommandError(`Port "${this.from}" not found`);
if (this.from !== this.port.key && ports.some((p) => p.key === this.port.key))
throw new CommandError(`"${this.port.key}" already exists`);
this.previous = { ...current };
this.swap(project, node, this.from, this.port);
}
undo(project: Project): void {
if (!this.previous) return;
this.swap(project, requirePortNode(project, this.assetId, this.nodeId), this.port.key, this.previous);
}
private swap(project: Project, node: GraphNode, from: string, to: DeclaredPort): void {
renamePortOn(requireBehavior(project, this.assetId).graph, node, from, to.key);
node.ports = (node.ports ?? []).map((p) => (p.key === to.key ? { ...p, kind: to.kind } : p));
}
}
/**
* Follows a renamed variable into the nodes that name it as a port. Their edges and typed-in
* values move with it – the port key *is* the variable name there.
*/
export function renameVariablePorts(graph: Graph, from: string, to: string): void {
for (const node of graph.nodes) if (declaresVariablePorts(node.type)) renamePortOn(graph, node, from, to);
}
/** "in1", "in2", … – the first port name the terminal node does not use yet. */
export function nextPortName(node: GraphNode, base: string): string {
const taken = new Set((node.ports ?? []).map((p) => p.key));
+2
View File
@@ -18,6 +18,7 @@ import {
} from '../properties/PropertySchema';
import { type Removed, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { renameVariablePorts } from './portCommands';
import { requireBehavior } from './require';
/** The variable's default value is written to `props` as well. */
@@ -133,6 +134,7 @@ export class RenameVariableCommand implements Command {
}
// Every node that picks a variable stores it under the param key `variable`.
for (const node of asset.graph.nodes) if (node.params.variable === from) node.params.variable = to;
renameVariablePorts(asset.graph, from, to);
}
}
+11
View File
@@ -6,9 +6,20 @@ import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
export const BEHAVIOR_START = 'BehaviorStart';
export const BEHAVIOR_END = 'BehaviorEnd';
export const RUN_BEHAVIOR = 'RunBehavior';
export const GET_VARIABLES = 'GetVariables';
export const SET_VARIABLES = 'SetVariables';
export const BEHAVIOR_PARAM = 'behavior';
/**
* Nodes whose declared ports are not names of their own but variables of the behavior around them.
* Renaming a variable therefore has to reach them, and the inspector picks their ports from a list
* instead of letting them be typed.
*/
export function declaresVariablePorts(type: string): boolean {
return type === GET_VARIABLES || type === SET_VARIABLES;
}
/** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */
export function isValidPortName(name: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && name !== 'exec';
+6 -3
View File
@@ -1,3 +1,4 @@
import { sameValue } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asListItem } from '../../values';
import { any, list, num } from '../shared';
@@ -10,7 +11,9 @@ export const IndexOf: NodeDef = {
inputs: [list('list'), any('value')],
outputs: [num('index')],
params: [],
run: (_ctx, inputs) => ({
outputs: { index: asList(inputs.list).indexOf(asListItem(inputs.value)) },
}),
// Compared by content, not by identity: two equal structs are never the same object.
run: (_ctx, inputs) => {
const wanted = asListItem(inputs.value);
return { outputs: { index: asList(inputs.list).findIndex((item) => sameValue(item, wanted)) } };
},
};
+4
View File
@@ -82,7 +82,9 @@ import { Repeat } from './flow/Repeat';
import { RunBehavior } from './flow/RunBehavior';
import { Sequence } from './flow/Sequence';
import { GetVariable } from './variable/GetVariable';
import { GetVariables } from './variable/GetVariables';
import { SetVariable } from './variable/SetVariable';
import { SetVariables } from './variable/SetVariables';
/**
* Importing this module registers the built-in nodes. The palette sorts by category and label, so
@@ -109,7 +111,9 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
BehaviorEnd,
// variables
GetVariable,
GetVariables,
SetVariable,
SetVariables,
// data: references and properties
Self,
Owner,
@@ -0,0 +1,24 @@
import type { NodeDef } from '../../NodeRegistry';
import { GET_VARIABLES } from '../../nested';
import type { Value } from '../../values';
/**
* The plural of Get variable: one output per variable picked, so a graph reading a handful of them
* is one node instead of a column of five. The ports are declared on the node, the way Expand
* declares the fields of a struct.
*/
export const GetVariables: NodeDef = {
type: GET_VARIABLES,
label: 'Get variables',
category: 'variable',
description: "Reads several of this behavior's variables at once, one output per variable.",
inputs: [],
outputs: [],
params: [],
declaresPorts: 'outputs',
run: (ctx, _inputs, _params, def) => {
const outputs: Record<string, Value> = {};
for (const port of def.outputs) outputs[port.key] = ctx.variables[port.key] ?? null;
return { outputs };
},
};
@@ -0,0 +1,25 @@
import type { NodeDef } from '../../NodeRegistry';
import { SET_VARIABLES } from '../../nested';
import { writeTyped } from '../../values';
import { exec } from '../shared';
/**
* The plural of Set variable: one input per variable picked. An input left unconnected writes the
* value typed on the node, exactly as it does on the singular node – the node writes every variable
* it lists, every time it runs.
*/
export const SetVariables: NodeDef = {
type: SET_VARIABLES,
label: 'Set variables',
category: 'variable',
description: "Writes several of this behavior's variables at once, one input per variable.",
inputs: [exec()],
outputs: [exec()],
params: [],
declaresPorts: 'inputs',
run: (ctx, inputs, _params, def) => {
for (const port of def.inputs)
if (port.type !== 'exec') writeTyped(ctx.variables, port.key, inputs[port.key]);
return { next: 'exec' };
},
};
+11 -6
View File
@@ -39,16 +39,21 @@ export function asList(value: Value | undefined): ListValue {
return [value];
}
/** A value narrowed to what a list may hold: handles and nested lists have no stored form. */
export function asListItem(value: Value | undefined): number | string | boolean {
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') return value;
if (value === null || value === undefined) return 0;
return asString(value);
/** A value narrowed to what a list may hold: a struct stays one, handles and lists flatten. */
export function asListItem(value: Value | undefined): number | string | boolean | StructValue {
if (isStructValue(value)) return value;
return asPrimitive(value);
}
/** A value narrowed to what a struct field may hold: a list stays a list, anything deeper flattens. */
export function asField(value: Value | undefined): FieldValue {
return isList(value) ? value : asListItem(value);
return isList(value) ? value : asPrimitive(value);
}
function asPrimitive(value: Value | undefined): number | string | boolean {
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') return value;
if (value === null || value === undefined) return 0;
return asString(value);
}
export function asBoolean(value: Value | undefined): boolean {
+8 -7
View File
@@ -1,10 +1,12 @@
/**
* A list is deliberately flat – a list of lists would need a nested editor and a deep compare on
* every override, and nothing so far wants one: a board is addressed as `row · width + column`.
* A list holds primitives or structs. Depth stops there and it stops in the declaration, not by
* convention: a list says what its items are (`ListItemKind`, struct included), a struct field
* says what it holds (`FieldKind`, primitives only). So `list → struct → primitive` is the deepest
* a stored value goes, which is what keeps the deep compare and the editor finite.
*/
export type ListValue = readonly (number | string | boolean)[];
export type ListValue = readonly (number | string | boolean | StructValue)[];
/** What a struct field may hold: no field is itself a struct, for the same reason a list is flat. */
/** What a struct field may hold. A field is never itself a struct – see `ListValue`. */
export type FieldValue = number | string | boolean | ListValue;
/** Several named values travelling as one, so a bundle needs one port instead of one per part. */
@@ -30,7 +32,7 @@ export const isStruct = (value: unknown): value is StructValue =>
*/
export function textOf(value: PropertyValue | undefined): string {
if (value === undefined) return '';
if (isList(value)) return value.join(', ');
if (isList(value)) return value.map((item) => textOf(item)).join(', ');
if (isStruct(value))
return Object.entries(value)
.map(([field, item]) => `${field}: ${textOf(item)}`)
@@ -42,12 +44,11 @@ export function textOf(value: PropertyValue | undefined): string {
export function sameValue(a: PropertyValue | undefined, b: PropertyValue | undefined): boolean {
if (isList(a) || isList(b)) {
if (!isList(a) || !isList(b) || a.length !== b.length) return false;
return a.every((item, i) => item === b[i]);
return a.every((item, i) => sameValue(item, b[i]));
}
if (isStruct(a) || isStruct(b)) {
if (!isStruct(a) || !isStruct(b)) return false;
const keys = Object.keys(a);
// Fields are never structs, so this recurses exactly one level deep.
return keys.length === Object.keys(b).length && keys.every((key) => sameValue(a[key], b[key]));
}
return a === b;
+40 -13
View File
@@ -49,20 +49,32 @@ export type PropertyDef =
* 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 }
| {
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 StructFieldDef[];
}
/**
* `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`).
*/
| { key: string; label: string; kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[] };
export const LIST_ITEM_KINDS = ['number', 'text', 'boolean', 'color'] as const;
/** What a struct field holds. No `struct`: that is the rule that bounds the nesting depth. */
export const FIELD_KINDS = ['number', 'text', 'boolean', 'color'] as const;
export type FieldKind = (typeof FIELD_KINDS)[number];
/** What a list holds – a field's kinds plus `struct`, so a list of bundles is one declaration. */
export const LIST_ITEM_KINDS = [...FIELD_KINDS, 'struct'] as const;
export type ListItemKind = (typeof LIST_ITEM_KINDS)[number];
/** A field holds what a list item holds: a field that were itself a struct is what `FieldValue` rules out. */
export interface StructFieldDef {
key: string;
kind: ListItemKind;
kind: FieldKind;
}
export type StructDef = Extract<PropertyDef, { kind: 'struct' }>;
@@ -200,14 +212,19 @@ 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':
case 'list': {
const item = items ?? 'number';
const shape = item === 'struct' ? (fields ?? []) : undefined;
const raw = isList(defaultValue) ? defaultValue : [];
return {
key,
label: key,
kind,
default: isList(defaultValue) ? defaultValue : [],
items: items ?? 'number',
default: raw.map((entry) => coerceListItem(entry, item, shape)),
items: item,
...(shape ? { fields: shape } : {}),
};
}
case 'struct': {
const declared = fields ?? [];
return { key, label: key, kind, fields: declared, default: structOf(declared, defaultValue) };
@@ -216,14 +233,14 @@ export function variableDef(
}
/** The empty value of a kind, so a field that is not there yet still has one. */
const EMPTY_FIELD: Record<ListItemKind, number | string | boolean> = {
const EMPTY_FIELD: Record<FieldKind, number | string | boolean> = {
number: 0,
text: '',
boolean: false,
color: 0,
};
export function emptyField(kind: ListItemKind): number | string | boolean {
export function emptyField(kind: FieldKind): number | string | boolean {
return EMPTY_FIELD[kind];
}
@@ -236,7 +253,7 @@ export function structOf(fields: readonly StructFieldDef[], from: PropertyValue
const value: Record<string, FieldValue> = {};
for (const field of fields)
value[field.key] =
field.key in source ? coerceListItem(source[field.key]!, field.kind) : emptyField(field.kind);
field.key in source ? coerceField(source[field.key]!, field.kind) : emptyField(field.kind);
return value;
}
@@ -266,14 +283,24 @@ 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.default;
return isList(value) ? value.map((item) => coerceListItem(item, def.items, def.fields)) : def.default;
case 'struct':
return structOf(def.fields, value);
}
}
export function coerceListItem(value: PropertyValue, items: ListItemKind): number | string | boolean {
if (items === 'text') return isList(value) ? value.join(', ') : textOf(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);
}
/** 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') {
if (typeof value === 'boolean') return value;
// A list is typed as text, so the spelling has to count: Boolean('false') would be true.
+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;