Let a value be a bundle of named fields

PropertyValue was number | string | boolean | list. A struct joins it, which
costs two things the typechecker found rather than I did.

An object on a data edge used to mean a live entity, so asEntity would have
taken a struct for one and asString would have read .name off it. EntityHandle
now carries a symbol brand – a symbol so no field name can impersonate it, and
it never serializes, which suits a thing that exists only while the game runs.
It is built in exactly one place.

String(value) would have written a struct as [object Object] at 39 call sites.
They go through textOf(), which is identical for primitives.

normalizeGraph dropped everything but primitives from node params, so the List
node lost its value on every save ([3,1,4] round-tripped to undefined).
Variables were spared: they have a schema, params have none.
This commit is contained in:
root committed 2026-09-10 08:14:18 +02:00
1 parent 3b3f374e10
commit 2cf788eedd
34 files changed
+284 -82

No files matched your search

+59 -14
View File
@@ -1,9 +1,22 @@
import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../model/Properties';
import type { EntityHandle } from './NodeContext';
import {
type FieldValue,
isList,
isStruct,
type ListValue,
type PropertyBag,
type PropertyValue,
type StructValue,
textOf,
} from '../model/Properties';
import { ENTITY_HANDLE, type EntityHandle } from './NodeContext';
/** A value travelling along a data edge. Objects are live entity handles (play mode only). */
export type Value = PropertyValue | EntityHandle | null;
export function isEntity(value: Value | undefined): value is EntityHandle {
return typeof value === 'object' && value !== null && ENTITY_HANDLE in value;
}
export function asNumber(value: Value | undefined): number {
if (typeof value === 'number') return value;
if (typeof value === 'boolean') return value ? 1 : 0;
@@ -12,6 +25,8 @@ export function asNumber(value: Value | undefined): number {
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;
}
@@ -19,7 +34,9 @@ export function asNumber(value: Value | undefined): number {
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];
if (isEntity(value)) return [value.name];
if (isStructValue(value)) return Object.values(value).map(asListItem);
return [value];
}
/** A value narrowed to what a list may hold: handles and nested lists have no stored form. */
@@ -29,6 +46,11 @@ export function asListItem(value: Value | undefined): number | string | boolean
return asString(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);
}
export function asBoolean(value: Value | undefined): boolean {
if (typeof value === 'string') return value !== '' && value !== 'false' && value !== '0';
return Boolean(value);
@@ -36,15 +58,42 @@ 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);
if (value === null) return '';
return isEntity(value) ? value.name : textOf(value);
}
export function asEntity(value: Value | undefined): EntityHandle | null {
if (!value || typeof value !== 'object' || isList(value)) return null;
return value;
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.
*/
export 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 as StructValue;
}
/** `value` converted to the type the slot already holds, so a number stays a number. */
export function asTypeOf(current: PropertyValue | undefined, value: Value | undefined): PropertyValue {
if (isList(current)) return 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);
}
/**
@@ -52,9 +101,5 @@ export function asEntity(value: Value | undefined): EntityHandle | null {
* variable stays a number no matter what arrives on an `any` input.
*/
export function writeTyped(bag: PropertyBag, key: string, value: Value | undefined): void {
const current = bag[key];
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);
bag[key] = asTypeOf(bag[key], value);
}