Let a variable hold a list

A variable could only be a number, a text or a yes/no, so every set of values had
to be smuggled through a string - which is where Tetris got its unreadable
"03066326048763714" colours from. A list is now a value of its own, with an item
kind (number, text, yes/no, colour) that decides how the inspector reads the one
comma-separated line it is typed as, and eight nodes to work with: List, Fill,
Count, Item at, Set item, Sub list, Join lists, Index of.

Two traps came with it. An array is typeof 'object', so every place that told a
value from an entity had to learn the difference, or a list would have been
passed around as a game object. And a value was compared by identity in three
places - with lists that marks everything as an overridden property after each
reload, so those comparisons now compare contents.

There is no list of objects: a list is written to the project file and an object
is a live handle that no reload survives. A set of objects stays a list of names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-09 21:26:31 +02:00
1 parent c6f907b4e0
commit a9c3111377
25 files changed
+469 -25

No files matched your search

+2 -2
View File
@@ -1,7 +1,7 @@
import type { PropertyBag } from '../model/Properties';
/** `exec` carries control flow, the others values; `any` accepts every value type. */
export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'any';
export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'list' | 'any';
export interface PortRef {
node: string;
@@ -31,7 +31,7 @@ export interface DeclaredPort {
kind: DeclaredPortKind;
}
export const DECLARED_PORT_KINDS = ['number', 'boolean', 'text', 'object'] as const;
export const DECLARED_PORT_KINDS = ['number', 'boolean', 'text', 'object', 'list'] as const;
export type DeclaredPortKind = (typeof DECLARED_PORT_KINDS)[number];
export function declaredPortType(kind: DeclaredPortKind): PortType {
+14
View File
@@ -0,0 +1,14 @@
import type { NodeDef } from '../../NodeRegistry';
import { asList } from '../../values';
import { list } from '../shared';
export const Concat: NodeDef = {
type: 'Concat',
label: 'Join lists',
category: 'data',
description: 'Two lists one after the other. A single value counts as a list of one.',
inputs: [list('a'), list('b')],
outputs: [list('result')],
params: [],
run: (_ctx, inputs) => ({ outputs: { result: [...asList(inputs.a), ...asList(inputs.b)] } }),
};
+14
View File
@@ -0,0 +1,14 @@
import type { NodeDef } from '../../NodeRegistry';
import { asList } from '../../values';
import { list, num } from '../shared';
export const Count: NodeDef = {
type: 'Count',
label: 'Count',
category: 'data',
description: 'How many items a list holds.',
inputs: [list('list')],
outputs: [num('count')],
params: [],
run: (_ctx, inputs) => ({ outputs: { count: asList(inputs.list).length } }),
};
+20
View File
@@ -0,0 +1,20 @@
import type { NodeDef } from '../../NodeRegistry';
import { asListItem, asNumber } from '../../values';
import { any, list, num, withDefault } from '../shared';
/** Same order of magnitude as the interpreter's step limit – a typo in `count` must not eat memory. */
const MAX_ITEMS = 100_000;
export const Fill: NodeDef = {
type: 'Fill',
label: 'Fill',
category: 'data',
description: 'A new list of the same value repeated – how an empty board is made.',
inputs: [any('value'), withDefault(num('count'), 1)],
outputs: [list('result')],
params: [],
run: (_ctx, inputs) => {
const count = Math.min(MAX_ITEMS, Math.max(0, Math.floor(asNumber(inputs.count))));
return { outputs: { result: new Array(count).fill(asListItem(inputs.value)) } };
},
};
+16
View File
@@ -0,0 +1,16 @@
import type { NodeDef } from '../../NodeRegistry';
import { asList, asListItem } from '../../values';
import { any, list, num } from '../shared';
export const IndexOf: NodeDef = {
type: 'IndexOf',
label: 'Index of',
category: 'data',
description: 'Where a value sits in a list, or -1 if it is not in there – how "is this row full" is asked.',
inputs: [list('list'), any('value')],
outputs: [num('index')],
params: [],
run: (_ctx, inputs) => ({
outputs: { index: asList(inputs.list).indexOf(asListItem(inputs.value)) },
}),
};
+18
View File
@@ -0,0 +1,18 @@
import type { NodeDef } from '../../NodeRegistry';
import { asList, asNumber } from '../../values';
import { any, list, num } from '../shared';
export const ItemAt: NodeDef = {
type: 'ItemAt',
label: 'Item at',
category: 'data',
description: 'The item at an index, counting from 0. Out of range gives 0.',
inputs: [list('list'), num('index')],
outputs: [any('item')],
run: (_ctx, inputs) => {
const items = asList(inputs.list);
const i = Math.floor(asNumber(inputs.index));
return { outputs: { item: items[i] ?? 0 } };
},
params: [],
};
+14
View File
@@ -0,0 +1,14 @@
import type { NodeDef } from '../../NodeRegistry';
import { asList } from '../../values';
import { list } from '../shared';
export const ListLiteral: NodeDef = {
type: 'List',
label: 'List',
category: 'data',
description: 'A constant list, typed as one comma-separated line.',
inputs: [],
outputs: [list('value')],
params: [{ key: 'value', label: 'Value', kind: 'list', default: [], items: 'number' }],
run: (_ctx, _inputs, params) => ({ outputs: { value: asList(params.value) } }),
};
+21
View File
@@ -0,0 +1,21 @@
import type { NodeDef } from '../../NodeRegistry';
import { asList, asListItem, asNumber } from '../../values';
import { any, list, num } from '../shared';
export const SetItem: NodeDef = {
type: 'SetItem',
label: 'Set item',
category: 'data',
description: 'The same list with one item replaced. The list that came in is left alone.',
inputs: [list('list'), num('index'), any('value')],
outputs: [list('result')],
run: (_ctx, inputs) => {
const items = asList(inputs.list);
const i = Math.floor(asNumber(inputs.index));
if (i < 0 || i >= items.length) return { outputs: { result: items } };
const next = [...items];
next[i] = asListItem(inputs.value);
return { outputs: { result: next } };
},
params: [],
};
+19
View File
@@ -0,0 +1,19 @@
import type { NodeDef } from '../../NodeRegistry';
import { asList, asNumber } from '../../values';
import { list, num, withDefault } from '../shared';
export const SubList: NodeDef = {
type: 'SubList',
label: 'Sub list',
category: 'data',
description: 'A part of a list: "count" items starting at "start".',
inputs: [list('list'), num('start'), withDefault(num('count'), 1)],
outputs: [list('result')],
params: [],
run: (_ctx, inputs) => {
const items = asList(inputs.list);
const start = Math.max(0, Math.floor(asNumber(inputs.start)));
const count = Math.max(0, Math.floor(asNumber(inputs.count)));
return { outputs: { result: items.slice(start, start + count) } };
},
};
+17
View File
@@ -55,6 +55,14 @@ import { Subtract } from './data/Subtract';
import { Time } from './data/Time';
import { ToNumber } from './data/ToNumber';
import { ToText } from './data/ToText';
import { Concat } from './data/Concat';
import { Count } from './data/Count';
import { Fill } from './data/Fill';
import { IndexOf } from './data/IndexOf';
import { ItemAt } from './data/ItemAt';
import { ListLiteral } from './data/ListLiteral';
import { SetItem } from './data/SetItem';
import { SubList } from './data/SubList';
import { OnClick } from './events/OnClick';
import { OnCollisionEnter } from './events/OnCollisionEnter';
import { OnCollisionExit } from './events/OnCollisionExit';
@@ -140,6 +148,15 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
And,
Or,
Not,
// data: lists
ListLiteral,
Fill,
Count,
ItemAt,
SetItem,
SubList,
Concat,
IndexOf,
// data: vectors and input
Length,
Distance,
+1
View File
@@ -10,6 +10,7 @@ export const num = (key: string, label = key): PortDef => ({ key, label, type: '
export const bool = (key: string, label = key): PortDef => ({ key, label, type: 'boolean' });
export const str = (key: string, label = key): PortDef => ({ key, label, type: 'string' });
export const obj = (key: string, label = key): PortDef => ({ key, label, type: 'object' });
export const list = (key: string, label = key): PortDef => ({ key, label, type: 'list' });
export const any = (key: string, label = key): PortDef => ({ key, label, type: 'any' });
export const withDefault = (port: PortDef, value: PropertyValue): PortDef => ({ ...port, default: value });
+21 -3
View File
@@ -1,4 +1,4 @@
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../model/Properties';
import type { EntityHandle } from './NodeContext';
/** A value travelling along a data edge. Objects are live entity handles (play mode only). */
@@ -11,9 +11,24 @@ export function asNumber(value: Value | undefined): number {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
if (isList(value)) return value.length;
return 0;
}
/** Anything that is not a list reads as a one-item list, so a lone value can feed a list input. */
export function asList(value: Value | undefined): ListValue {
if (isList(value)) return value;
if (value === null || value === undefined) return [];
return typeof value === 'object' ? [value.name] : [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);
}
export function asBoolean(value: Value | undefined): boolean {
if (typeof value === 'string') return value !== '' && value !== 'false' && value !== '0';
return Boolean(value);
@@ -22,12 +37,14 @@ export function asBoolean(value: Value | undefined): boolean {
/** Entities read as their name, so a handle can be logged or joined into text directly. */
export function asString(value: Value | undefined): string {
if (value === null || value === undefined) return '';
if (isList(value)) return value.join(', ');
if (typeof value === 'object') return value.name;
return String(value);
}
export function asEntity(value: Value | undefined): EntityHandle | null {
return value && typeof value === 'object' ? value : null;
if (!value || typeof value !== 'object' || isList(value)) return null;
return value;
}
/**
@@ -36,7 +53,8 @@ export function asEntity(value: Value | undefined): EntityHandle | null {
*/
export function writeTyped(bag: PropertyBag, key: string, value: Value | undefined): void {
const current = bag[key];
if (typeof current === 'number') bag[key] = asNumber(value);
if (isList(current)) bag[key] = asList(value);
else if (typeof current === 'number') bag[key] = asNumber(value);
else if (typeof current === 'boolean') bag[key] = asBoolean(value);
else bag[key] = asString(value);
}
+18 -1
View File
@@ -1,8 +1,25 @@
/**
* 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`.
*/
export type ListValue = readonly (number | string | boolean)[];
/** Colors are stored as 0xRRGGBB numbers. */
export type PropertyValue = number | string | boolean;
export type PropertyValue = number | string | boolean | ListValue;
export type PropertyBag = Record<string, PropertyValue>;
export const isList = (value: unknown): value is ListValue => Array.isArray(value);
/** Values are compared by content, because two equal lists are never the same array. */
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 === b;
}
/** `undefined` removes the key, which is how an undo of "no value" works. */
export function setBagValue(bag: PropertyBag, key: string, value: PropertyValue | undefined): void {
if (value === undefined) delete bag[key];
+2 -1
View File
@@ -15,6 +15,7 @@ import {
type Position,
type PropertyBag,
type PropertyValue,
sameValue,
setBagValue,
type Vec2,
} from './Properties';
@@ -312,7 +313,7 @@ function instanceRef(target: InstanceTarget, object: SceneObject): ObjectRef {
}),
setOverride: (key, value) =>
write((edit) => {
if (value === target.base.overrides[key]) delete edit.overrides[key];
if (sameValue(value, target.base.overrides[key])) delete edit.overrides[key];
else setBagValue(edit.overrides, key, value);
}),
};
+57 -4
View File
@@ -1,6 +1,6 @@
import type { Asset } from '../model/Asset';
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../model/Properties';
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
export type PropertyDef =
@@ -34,11 +34,20 @@ export type PropertyDef =
* The id of a texture asset or of one of its frames. Its own kind rather than an `asset`
* reference, because a frame is not an asset and the picker has to offer both. May dangle.
*/
| { key: string; label: string; kind: 'texture'; default: string };
| { 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.
*/
| { key: string; label: string; kind: 'list'; default: ListValue; items: ListItemKind };
export const LIST_ITEM_KINDS = ['number', 'text', 'boolean', 'color'] as const;
export type ListItemKind = (typeof LIST_ITEM_KINDS)[number];
export type PropertySchema = readonly PropertyDef[];
export const VARIABLE_KINDS = ['number', 'boolean', 'text'] as const;
export const VARIABLE_KINDS = ['number', 'boolean', 'text', 'list'] as const;
export type VariableKind = (typeof VARIABLE_KINDS)[number];
/**
@@ -155,7 +164,12 @@ export function defaultsOf(schema: PropertySchema): PropertyBag {
return bag;
}
export function variableDef(key: string, kind: VariableKind, defaultValue?: PropertyValue): PropertyDef {
export function variableDef(
key: string,
kind: VariableKind,
defaultValue?: PropertyValue,
items?: ListItemKind,
): PropertyDef {
switch (kind) {
case 'number':
return { key, label: key, kind, default: typeof defaultValue === 'number' ? defaultValue : 0 };
@@ -163,6 +177,14 @@ export function variableDef(key: string, kind: VariableKind, defaultValue?: Prop
return { key, label: key, kind, default: Boolean(defaultValue ?? false) };
case 'text':
return { key, label: key, kind, default: defaultValue === undefined ? '' : String(defaultValue) };
case 'list':
return {
key,
label: key,
kind,
default: isList(defaultValue) ? defaultValue : [],
items: items ?? 'number',
};
}
}
@@ -191,5 +213,36 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
const s = String(value);
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;
}
}
/** Colors read back as `#rrggbb` so what the inspector shows can be typed in again unchanged. */
export function listToText(list: ListValue, items: ListItemKind): string {
return list
.map((v) => (items === 'color' ? `#${Number(v).toString(16).padStart(6, '0')}` : String(v)))
.join(', ');
}
export function listFromText(text: string, items: ListItemKind): ListValue {
const trimmed = text.trim();
if (trimmed === '') return [];
return trimmed.split(',').map((part) => coerceListItem(part.trim(), items));
}
export function coerceListItem(value: PropertyValue, items: ListItemKind): number | string | boolean {
if (items === 'text') return isList(value) ? value.join(', ') : String(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.
const s = String(value).trim().toLowerCase();
return !(s === '' || s === 'false' || s === '0' || s === 'no');
}
if (items === 'color') {
const c = typeof value === 'number' ? value : parseInt(String(value).replace('#', ''), 16);
return Number.isFinite(c) ? Math.max(0, Math.min(0xffffff, Math.floor(c))) : 0;
}
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : 0;
}
+4 -4
View File
@@ -1,5 +1,5 @@
import type { Asset } from '../model/Asset';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { type PropertyBag, type PropertyValue, sameValue } from '../model/Properties';
import type { SceneObject } from '../model/SceneObject';
import { propDef, schemaForAsset } from './PropertySchema';
@@ -35,7 +35,7 @@ export function isModified(asset: Asset, key: string): boolean {
const def = propDef(asset, key);
if (!def) return false;
const own = asset.props[key];
return own !== undefined && own !== def.default;
return own !== undefined && !sameValue(own, def.default);
}
/** The value the object would have without its override for `key`. */
@@ -46,7 +46,7 @@ export function inheritedValue(asset: Asset, key: string): PropertyValue | undef
/** An override that happens to carry the asset's own value does not count as a deviation. */
export function deviatesFromAsset(object: SceneObject, asset: Asset, key: string): boolean {
const own = object.overrides[key];
return own !== undefined && own !== inheritedValue(asset, key);
return own !== undefined && !sameValue(own, inheritedValue(asset, key));
}
/**
@@ -54,5 +54,5 @@ export function deviatesFromAsset(object: SceneObject, asset: Asset, key: string
* "neither overrides" means equal even when the asset value changed since.
*/
export function deviatesFromTemplate(object: SceneObject, base: SceneObject, key: string): boolean {
return object.overrides[key] !== base.overrides[key];
return !sameValue(object.overrides[key], base.overrides[key]);
}
+24 -3
View File
@@ -22,6 +22,8 @@ import {
coerceValue,
COMMON_PROPS,
isCommonProp,
LIST_ITEM_KINDS,
type ListItemKind,
type PropertyDef,
type PropertySchema,
schemaFor,
@@ -150,8 +152,10 @@ function normalizeInstanceEdits(raw: unknown): Record<string, InstanceEdit> {
const offset = asRecord(e.editorOffset);
if (typeof offset.x === 'number' || typeof offset.y === 'number')
edit.editorOffset = { x: toNumber(offset.x), y: toNumber(offset.y) };
for (const [key, v] of Object.entries(asRecord(e.overrides)))
if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean') edit.overrides[key] = v;
for (const [key, v] of Object.entries(asRecord(e.overrides))) {
const value = asStoredValue(v);
if (value !== undefined) edit.overrides[key] = value;
}
if (!isEmptyEdit(edit)) edits[path] = edit;
}
return edits;
@@ -169,7 +173,10 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
// Common property keys are reserved – a variable of that name would shadow the switch.
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key) || isCommonProp(key)) continue;
seen.add(key);
const def = variableDef(key, kind);
const items = (LIST_ITEM_KINDS as readonly string[]).includes(String(v.items))
? (v.items as ListItemKind)
: undefined;
const def = variableDef(key, kind, undefined, items);
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue);
defs.push(def);
}
@@ -287,6 +294,20 @@ function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
}
/**
* A value from an untyped slot (instance edits carry no schema to coerce against). Lists are kept
* but flattened away one level, so nothing nested can enter the model behind the type's back.
*/
function asStoredValue(value: unknown): PropertyValue | 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;
+10 -2
View File
@@ -14,7 +14,7 @@ import {
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import type { Project } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import { isList, type PropertyValue } from '../../core/model/Properties';
import { assetRefLabel } from '../../core/properties/assetRef';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { formatNumber, Theme, textStyle, type TextStyleToken, truncate, Widget } from '../../ui';
@@ -58,6 +58,8 @@ export function portColor(type: PortType): number {
return c.portString;
case 'object':
return c.portObject;
case 'list':
return c.portList;
case 'any':
return c.portAny;
}
@@ -295,7 +297,7 @@ function formatParam(
asset &&
!asset.variables.some((v) => v.key === value)
)
return `${value}?`;
return `${asString(value)}?`;
return formatValue(value);
}
@@ -303,5 +305,11 @@ function formatValue(value: Value): string {
// Whole numbers stay bare to keep nodes narrow; fractions match the field that edits them.
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : formatNumber(value);
if (typeof value === 'boolean') return value ? 'true' : 'false';
// A list can hold hundreds of items – shown whole it would stretch the node across the graph.
if (isList(value)) {
if (value.length === 0) return 'empty';
const head = value.slice(0, 4).map(formatValue).join(', ');
return value.length > 4 ? `${head}, … (${value.length})` : head;
}
return asString(value);
}
+3 -1
View File
@@ -29,13 +29,15 @@ const VARIABLE_KIND_LABELS: Record<VariableKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
list: 'List',
};
// Keyed by the wide `PropertyDef['kind']` because `variables` is a `PropertyDef[]`, but only the
// three VariableKinds ever reach it – the '?' fallback is a safety net, not a case to fill in.
// VariableKinds ever reach it – the '?' fallback is a safety net, not a case to fill in.
const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
number: '#',
boolean: '✓',
text: 'Aa',
list: '≡',
};
/** Structure-defining facts of an asset selection: the schema keys and kinds. */
+11 -2
View File
@@ -1,4 +1,4 @@
import type { PropertyValue } from '../../core/model/Properties';
import { isList, type PropertyValue } from '../../core/model/Properties';
import type { Project } from '../../core/model/Project';
import {
assetChoiceLabel,
@@ -6,7 +6,7 @@ import {
assetRefLabel,
NO_ASSET_LABEL,
} from '../../core/properties/assetRef';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { listFromText, listToText, type PropertyDef } from '../../core/properties/PropertySchema';
import { NO_TEXTURE_LABEL, textureRefChoices, textureRefLabel } from '../../core/properties/textureRef';
import {
Button,
@@ -194,6 +194,15 @@ export function createField(
const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange });
return { widget: field, grow: true, setValue: (v) => field.setValue(Number(v)) };
}
case 'list': {
// Typed as one comma-separated line rather than a row per item: a board is hundreds of
// entries, and a growing list of fields would push everything else out of the inspector.
const asText = (v: PropertyValue) => listToText(isList(v) ? v : [], def.items);
const field = new TextField(asText(value), {
onChange: (text) => handlers.onChange(listFromText(String(text), def.items)),
});
return { widget: field, grow: true, setValue: (v) => field.setValue(asText(v)) };
}
case 'boolean': {
const field = new Checkbox(Boolean(value));
field.onChange = handlers.onChange;
+2
View File
@@ -13,6 +13,7 @@ const KIND_LABELS: Record<DeclaredPortKind, string> = {
boolean: 'Boolean',
text: 'Text',
object: 'Object',
list: 'List',
};
const KIND_MARKS: Record<DeclaredPortKind, string> = {
@@ -20,6 +21,7 @@ const KIND_MARKS: Record<DeclaredPortKind, string> = {
boolean: '✓',
text: 'Aa',
object: '◇',
list: '≡',
};
/**
+1
View File
@@ -58,6 +58,7 @@ export const Theme = {
portBoolean: 0xe57373,
portString: 0xe6a8ff,
portObject: 0x56ccf2,
portList: 0xf2a75c,
portAny: 0xbdbdbd,
/** Ring around the port a running edge drag would connect to. */
portTarget: 0xffffff,
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { sameValue } from '../../src/core/model/Properties';
import { findAsset, objectHolders, type Project } from '../../src/core/model/Project';
import { deserializeProject } from '../../src/core/store/serialize';
import { coerceValue, listFromText, listToText, variableDef } from '../../src/core/properties/PropertySchema';
import { deviatesFromAsset } from '../../src/core/properties/resolve';
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
const findObject = (project: Project, id: string) =>
objectHolders(project)
.flatMap((holder) => holder.objects)
.find((o) => o.id === id);
describe('a list as a value', () => {
it('survives saving and loading – with its items, not as a string', () => {
const f = makeSpriteScene();
const behavior = makeBehavior(f, 'B');
f.stack.execute(new AddVariableCommand(behavior.id, 'grid', 'list'));
const object = place(f, behavior.id, { x: 0, y: 0 });
f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'grid', [1, 0, 2]));
const loaded = roundTrip(f.project);
const stored = findObject(loaded, object)?.overrides.grid;
expect(stored).toEqual([1, 0, 2]);
});
it('survives inside a container instance too, where no schema is there to coerce it', () => {
// Instance edits are read from an untyped slot: the old filter kept number/string/boolean and
// dropped everything else, which would have swallowed the list without a word.
const loaded = deserializeProject(
{
name: 'P',
activeSceneId: 's1',
assets: [
{
id: 's1',
type: 'scene',
objects: [
{
id: 'i1',
assetId: 'c1',
position: {},
instanceEdits: { b1: { overrides: { grid: [3, 4] } } },
},
],
},
{ id: 'c1', type: 'container', objects: [] },
],
},
'p1',
);
expect(findObject(loaded, 'i1')?.instanceEdits.b1?.overrides.grid).toEqual([3, 4]);
});
it('is compared by its items, so reloading does not turn every list into an override', () => {
// The same items in a fresh array: a reference compare would call this a deviation on every load.
expect(sameValue([1, 2, 3], [1, 2, 3])).toBe(true);
expect(sameValue([1, 2, 3], [1, 2])).toBe(false);
expect(sameValue([1, 2, 3], [1, 9, 3])).toBe(false);
});
it('an override that only repeats the default is not one', () => {
const f = makeSpriteScene();
const behavior = makeBehavior(f, 'B');
f.stack.execute(new AddVariableCommand(behavior.id, 'row', 'list'));
const asset = findAsset(f.project, behavior.id)!;
asset.props.row = [1, 2];
const object = place(f, behavior.id, { x: 0, y: 0 });
f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'row', [1, 2]));
const placed = f.object(object);
expect(deviatesFromAsset(placed, asset, 'row')).toBe(false);
f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'row', [1, 3]));
expect(deviatesFromAsset(placed, asset, 'row')).toBe(true);
});
it('reads and writes as one comma-separated line, item kind by item kind', () => {
expect(listFromText('1, 2, 3', 'number')).toEqual([1, 2, 3]);
expect(listFromText('', 'number')).toEqual([]);
expect(listFromText('a, b', 'text')).toEqual(['a', 'b']);
// Spelling has to count here: Boolean('false') would be true.
expect(listFromText('true, false, 0', 'boolean')).toEqual([true, false, false]);
expect(listFromText('#ff0000, #00ff00', 'color')).toEqual([0xff0000, 0x00ff00]);
expect(listToText([0xff0000], 'color')).toBe('#ff0000');
expect(listToText([1, 2], 'number')).toBe('1, 2');
});
it('a value that is not a list falls back to the default rather than becoming one', () => {
const def = variableDef('grid', 'list', [7], 'number');
expect(coerceValue(def, 'nonsense')).toEqual([7]);
expect(coerceValue(def, ['1', 2])).toEqual([1, 2]);
});
});
+56
View File
@@ -289,6 +289,62 @@ const CASES: Record<string, Case[]> = {
outputs: { text: 'b' },
},
],
// --- lists -----------------------------------------------------------------------------------
List: [
{ name: 'hands out the list it was given', params: { value: [1, 2, 3] }, outputs: { value: [1, 2, 3] } },
{ name: 'an unset list is empty, not missing', outputs: { value: [] } },
],
Fill: [
{ name: 'repeats one value', inputs: { value: 0, count: 4 }, outputs: { result: [0, 0, 0, 0] } },
{ name: 'a negative count gives nothing', inputs: { value: 1, count: -3 }, outputs: { result: [] } },
],
Count: [
{ name: 'counts the items', inputs: { list: ['a', 'b'] }, outputs: { count: 2 } },
{ name: 'a lone value counts as one', inputs: { list: 7 }, outputs: { count: 1 } },
],
ItemAt: [
{ name: 'reads by index, counting from 0', inputs: { list: [4, 5, 6], index: 1 }, outputs: { item: 5 } },
{ name: 'past the end is 0, not a crash', inputs: { list: [4], index: 9 }, outputs: { item: 0 } },
],
SetItem: [
{
name: 'replaces one item',
inputs: { list: [1, 2, 3], index: 1, value: 9 },
outputs: { result: [1, 9, 3] },
},
{
name: 'leaves the list it was given untouched – a graph may hold it elsewhere',
inputs: { list: Object.freeze([1, 2, 3]), index: 0, value: 9 },
outputs: { result: [9, 2, 3] },
},
{
name: 'an index outside the list changes nothing',
inputs: { list: [1, 2], index: 5, value: 9 },
outputs: { result: [1, 2] },
},
],
SubList: [
{
name: 'takes count items from start',
inputs: { list: [1, 2, 3, 4], start: 1, count: 2 },
outputs: { result: [2, 3] },
},
{
name: 'asking past the end gives what is there',
inputs: { list: [1, 2], start: 1, count: 9 },
outputs: { result: [2] },
},
],
Concat: [
{ name: 'one list after the other', inputs: { a: [1], b: [2, 3] }, outputs: { result: [1, 2, 3] } },
{ name: 'a lone value joins as one item', inputs: { a: [1], b: 2 }, outputs: { result: [1, 2] } },
],
IndexOf: [
{ name: 'finds where a value sits', inputs: { list: [7, 8, 9], value: 8 }, outputs: { index: 1 } },
{ name: 'a value that is not in there is -1', inputs: { list: [7], value: 8 }, outputs: { index: -1 } },
],
FindText: [
{
name: 'finds where the search first appears',
+8 -2
View File
@@ -46,5 +46,11 @@ export const node = (id, type, x, y, params = {}, ports) => ({
/** A port a behavior's start or end node declares. */
export const port = (key, kind) => ({ key, kind });
/** A behavior variable declaration. */
export const variable = (key, kind, value) => ({ key, label: key, kind, default: value });
/** A behavior variable declaration. `items` is the item kind of a `list`, ignored otherwise. */
export const variable = (key, kind, value, items) => ({
key,
label: key,
kind,
default: value,
...(items ? { items } : {}),
});