Files
feuerwerk/src/core/graph/values.ts
T
adminandClaude Opus 5.5 0c5ac32b59
game-maker / gate (push) Successful in 3m52s
game-maker / suite (push) Successful in 10m24s
Let structs and lists nest to any depth (#239)
Structs and lists were capped at two fixed levels (list of structs,
struct with a list field). Anything deeper was dropped silently on
load, in node settings and when written to a variable. The cap came
from an early design choice, not from a technical need, so it is
lifted completely instead of adding a third fixed level.

- One kind list NESTED_KINDS replaces ListItemKind and
  StructFieldKind. The flat special cases in listItems.ts are gone,
  every value goes through one recursive path.
- A struct inside a struct is declared, like a top-level struct.
  Values coming in over an edge are brought into that shape. Struct
  items in lists still take their fields from the value.
- New valuePath.ts addresses any nested spot by keys and indices.
  ListSlot.field is now ListSlot.path.
- Field commands (add, rename, remove) take the path to the struct.
  They change declaration, own value and all overrides in one undo
  step, as before on the top level.
- Inspector: "+ Field" offers Struct and List on every level. A
  nested struct is a header row with its own "+ Field", its fields
  are indented one step further.
- List tab: a list item shows "n items" and opens one level deeper.
  The title shows the path, e.g. "Listy · nums · [3]".
- Pack in Pack now writes a struct into a struct field instead of
  text. Objects are kept at any depth and stripped at every exit.
- A list edit in a struct field without an existing object override
  now starts from the shown value instead of an empty struct.
- Old projects load unchanged. Save, load and save again gives the
  same result.

Tests:
- ./check.sh: 172 files, 2498 tests, CHECK EXIT 0
- e2e 21 + 22: ALL PASSED, 90 checks (new 21d-iii, 22f-ii,
  21d and 22f updated)
- 20 new unit tests in nested-values.test.ts and
  nested-list-slot.test.ts, three tests that pinned the old cap
  turned around
- 6 counter-probes, each fails 1 to 8 tests, originals restored
  and checked with cmp
- Not covered: a nested struct inside a struct item in the list
  tab has no e2e click

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:33:20 +02:00

214 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type FieldValue,
isList,
isStruct,
type ListValue,
type PropertyBag,
type PropertyValue,
NO_OBJECT,
type StructValue,
type TaggedItem,
tagOf,
textOf,
booleanOf,
} from '../model/Properties';
import { ENTITY_HANDLE, type EntityHandle } from './NodeContext';
/**
* A list travelling along a data edge. Unlike a stored one it may hold live object handles, which is
* what lets `Raycast all` hand out the objects it met (#157). Items nest like stored ones (#239); an
* object deeper down is held the way a bag holds one (`heldObject`).
*/
export type ValueList = readonly (PropertyValue | EntityHandle)[];
/** A value travelling along a data edge. Objects are live entity handles (play mode only). */
export type Value = PropertyValue | EntityHandle | ValueList | null;
export function isEntity(value: Value | undefined): value is EntityHandle {
return typeof value === 'object' && value !== null && ENTITY_HANDLE in value;
}
/**
* Whether two numbers are the same up to float noise (#224): a relative 1e-9, never less than 1e-9
* absolute. `time - late` lands 1e-14 off a whole second, and after hours of summed `dt` the error
* still stays far below this, while 6 and 6.001 remain apart.
*/
export function nearlyEqual(a: number, b: number): boolean {
if (a === b) return true;
return Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
}
/** A list of anything a data edge carries – a handle among the items is what `isList` cannot say. */
function isValueList(value: Value | undefined): value is ValueList {
return Array.isArray(value);
}
export function asNumber(value: Value | undefined): number {
if (typeof value === 'number') return value;
if (typeof value === 'boolean') return value ? 1 : 0;
if (typeof value === 'string') {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
if (isList(value)) return value.length;
// A bundle counts as its parts, the same way a list counts as its items.
if (isStructValue(value)) return Object.keys(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): ValueList {
if (isValueList(value)) return value;
if (value === null || value === undefined) return [];
if (isEntity(value)) return [value];
if (isStructValue(value)) return Object.values(value).map((field) => asValueItem(field as Value));
return [value];
}
/**
* A handle as a run's bag holds it. Types say a bag is what a file holds, and at rest that is
* true: every way out of a run goes through `withoutObjects` (`liveObjects.ts`).
*/
export function heldObject(handle: EntityHandle): TaggedItem {
return handle as unknown as TaggedItem;
}
/** What an object's place in a bag takes: the object that arrives, or the empty one. */
function objectPlaceOf(value: Value | undefined): TaggedItem {
return isEntity(value) ? heldObject(value) : NO_OBJECT;
}
/** Whether a slot of a run's bag is an object's place: holding one, or holding none (`NO_OBJECT`). */
export function isObjectPlace(value: unknown): boolean {
return isEntity(value as Value) || tagOf(value) === 'object';
}
/** A value taken out of a list or struct: the empty object's place reads as no object at all. */
export function unplaced(value: Value | undefined): Value | undefined {
return tagOf(value) === 'object' ? null : value;
}
/** A list as a run's bag holds it: an object stays the object, however deep it sits. */
function heldList(items: ValueList): ListValue {
return items.map(asField);
}
/** `value` with every object in it named instead – how a value reads as text, however deep it sits. */
export function namedValue(value: Value | undefined): PropertyValue | undefined {
if (value === null || value === undefined) return undefined;
if (isEntity(value)) return value.name;
if (isValueList(value)) return value.map((item) => namedValue(item) as ListValue[number]);
if (isStructValue(value) && tagOf(value) === undefined) {
const fields: Record<string, FieldValue> = {};
for (const [key, field] of Object.entries(value)) fields[key] = namedValue(field) as FieldValue;
return fields;
}
return value;
}
/** A value narrowed to what a *travelling* list may hold: unlike `asField`, a handle stays one. */
export function asValueItem(value: Value | undefined): ValueList[number] {
return isEntity(value) ? value : asField(value);
}
/**
* A value as a struct field or list item holds it in a run's bag: lists and structs stay what they
* are at any depth (#239), an object stays the object, a missing value becomes 0.
*/
export function asField(value: Value | undefined): FieldValue {
if (isEntity(value)) return heldObject(value);
if (isValueList(value)) return heldList(value);
if (isStructValue(value)) return heldStruct(value);
return asPrimitive(value);
}
/** A struct as a run's bag holds it: each field as `asField` says, a tagged item as itself. */
function heldStruct(value: StructValue): StructValue {
if (tagOf(value) !== undefined) return value;
const fields: Record<string, FieldValue> = {};
for (const [key, field] of Object.entries(value)) fields[key] = asField(field);
return fields;
}
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 {
return booleanOf(value);
}
/** 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) return '';
// A handle, at the top or in a list or struct, would otherwise be printed as what it is made of.
if (isObjectPlace(value)) return isEntity(value) ? value.name : '';
return textOf(namedValue(value));
}
/**
* How much counts as empty: every empty value of its kind, only the ones that carry nothing (0 and
* false are values then), or nothing but a missing one.
*/
export type EmptyMode = 'any' | 'nothing' | 'null';
/**
* Whether a value is the empty one of its kind: no object, 0, '', false, an empty list, or a struct
* whose fields are all empty – which is how a position (0, 0) arrives. `mode` narrows that down.
*/
export function isEmptyValue(value: Value | undefined, mode: EmptyMode = 'any'): boolean {
if (value === null || value === undefined) return true;
if (mode === 'null') return false;
if (isEntity(value)) return false;
if (isValueList(value)) return value.length === 0;
if (isStructValue(value)) return Object.values(value).every((field) => isEmptyValue(field, mode));
if (mode === 'nothing') return value === '';
return value === 0 || value === '' || value === false;
}
export function asEntity(value: Value | undefined): EntityHandle | null {
return isEntity(value) ? value : null;
}
/** A struct value, or the empty one: a number has no fields to become, so nothing is invented. */
export function asStruct(value: Value | undefined): StructValue {
return isStructValue(value) ? value : {};
}
function isStructValue(value: Value | undefined): value is StructValue {
return !isEntity(value) && isStruct(value);
}
/**
* A struct slot keeps the fields it was declared with: what arrives fills the ones it names and
* the rest stay put. That is what lets one node set some values of a bundle without erasing it.
*/
function mergeStruct(current: StructValue, value: Value | undefined): StructValue {
const incoming = asStruct(value);
const merged: Record<string, PropertyValue> = {};
for (const [field, own] of Object.entries(current))
merged[field] = field in incoming ? asTypeOf(own, incoming[field]) : own;
return merged;
}
/** `value` converted to the type the slot already holds, so a number stays a number. */
function asTypeOf(current: PropertyValue | undefined, value: Value | undefined): PropertyValue {
// First: an object's place is a struct to the eye, and one holding a handle all the more.
if (isObjectPlace(current)) return objectPlaceOf(value);
if (isList(current)) return heldList(asList(value));
if (isStruct(current)) return mergeStruct(current, value);
if (typeof current === 'number') return asNumber(value);
if (typeof current === 'boolean') return asBoolean(value);
return asString(value);
}
/**
* Writes `value` into `bag[key]` converted to the type the slot currently holds, so a number
* variable stays a number no matter what arrives on an `any` input.
*/
export function writeTyped(bag: PropertyBag, key: string, value: Value | undefined): void {
bag[key] = asTypeOf(bag[key], value);
}