From 2cf788eedd0c7bfc473a24fd6c5511121654dc89 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 10 Sep 2026 08:14:18 +0200 Subject: [PATCH] Let a value be a bundle of named fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/graph/Graph.ts | 9 +-- src/core/graph/NodeContext.ts | 8 +++ src/core/graph/NodeRegistry.ts | 7 +- src/core/graph/nested.ts | 4 +- src/core/graph/nodes/action/SetProperty.ts | 3 +- src/core/graph/nodes/action/Spawn.ts | 3 +- src/core/graph/nodes/data/AxisInput.ts | 3 +- src/core/graph/nodes/data/Compare.ts | 3 +- src/core/graph/nodes/data/GetProperty.ts | 3 +- src/core/graph/nodes/data/IsAsset.ts | 3 +- src/core/graph/nodes/data/KeyIsDown.ts | 3 +- src/core/graph/nodes/shared.ts | 1 + src/core/graph/nodes/variable/GetVariable.ts | 3 +- src/core/graph/nodes/variable/SetVariable.ts | 3 +- src/core/graph/values.ts | 73 ++++++++++++++++---- src/core/model/Properties.ts | 37 +++++++++- src/core/model/texture.ts | 6 +- src/core/properties/PropertySchema.ts | 25 ++++--- src/core/properties/bounds.ts | 6 +- src/core/store/serialize.ts | 23 ++++-- src/editor/graph/GraphCanvas.ts | 3 +- src/editor/graph/NodeView.ts | 8 ++- src/editor/inspector/fieldFactory.ts | 20 +++--- src/editor/inspector/portSection.ts | 2 + src/editor/texture/TextureCanvas.ts | 4 +- src/editor/viewport/WorldViewport.ts | 4 +- src/runtime/Entity.ts | 3 + src/runtime/World.ts | 3 +- src/runtime/systems/BehaviorSystem.ts | 6 +- src/ui/Theme.ts | 1 + tests/core/graph.test.ts | 9 +++ tests/core/list-value.test.ts | 45 +++++++++++- tests/core/walk.test.ts | 27 +++++++- tests/helpers.ts | 5 +- 34 files changed, 284 insertions(+), 82 deletions(-) diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index 5516be77..1ffbdaa8 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -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' | 'list' | 'any'; +export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'list' | 'struct' | 'any'; export interface PortRef { node: string; @@ -19,8 +19,9 @@ export interface GraphNode { y: number; params: PropertyBag; /** - * Ports the node declares itself, on top of the fixed ones of its type. Only the behavior - * start/end nodes carry this – they are what gives a nested behavior its signature. + * Ports the node declares itself, on top of the fixed ones of its type; the types that have + * them say so with `NodeDef.declaresPorts`. A behavior's start/end node is what gives the + * behavior its signature; an Expand/Pack node is what gives a struct its fields. */ ports?: DeclaredPort[]; } @@ -31,7 +32,7 @@ export interface DeclaredPort { kind: DeclaredPortKind; } -export const DECLARED_PORT_KINDS = ['number', 'boolean', 'text', 'object', 'list'] as const; +export const DECLARED_PORT_KINDS = ['number', 'boolean', 'text', 'object', 'list', 'struct'] as const; export type DeclaredPortKind = (typeof DECLARED_PORT_KINDS)[number]; export function declaredPortType(kind: DeclaredPortKind): PortType { diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 1b010d98..509bc77b 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -2,11 +2,19 @@ import type { PropertyBag } from '../model/Properties'; import { sumAlongChain } from '../model/walk'; import { enabledAlongChain } from '../properties/enabled'; +/** + * Marks a live entity apart from a struct value: both reach a data edge as an object. A symbol + * rather than a field, so no struct field name can impersonate it – and it never serializes, + * which is right for a thing that exists only while the game runs. + */ +export const ENTITY_HANDLE: unique symbol = Symbol('entity handle'); + /** * What a graph sees of a running entity; the runtime's Entity satisfies this shape. Mutating * `x`/`y`/`props` is how actions take effect. */ export interface EntityHandle { + readonly [ENTITY_HANDLE]: true; id: string; assetId: string; name: string; diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 946895a1..a87c2291 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -93,9 +93,12 @@ export function hasExecInput(def: NodeDef): boolean { return def.inputs.some((p) => p.type === 'exec'); } -/** Data inputs that may carry an inline value while unconnected. */ +/** + * Data inputs that may carry an inline value while unconnected. An object is a live handle and a + * struct is assembled from its parts, so neither has a value that could be typed on one line. + */ export function inlineInputs(def: NodeDef): PortDef[] { - return def.inputs.filter((p) => p.type !== 'exec' && p.type !== 'object'); + return def.inputs.filter((p) => p.type !== 'exec' && p.type !== 'object' && p.type !== 'struct'); } export function inlineInputDef(port: PortDef): PropertyDef { diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index 3e85b649..14ab8ae3 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -1,4 +1,4 @@ -import type { PropertyBag } from '../model/Properties'; +import { type PropertyBag, textOf } from '../model/Properties'; import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph'; import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry'; @@ -74,7 +74,7 @@ export function resolveDef(node: GraphNode, lookup: BehaviorLookup): NodeDef | u */ export function callProblem(node: GraphNode, lookup: BehaviorLookup, ownerId = ''): string | null { if (node.type !== RUN_BEHAVIOR) return null; - const id = String(node.params[BEHAVIOR_PARAM] ?? ''); + const id = textOf(node.params[BEHAVIOR_PARAM] ?? ''); if (!id) return 'No behavior picked'; if (id === ownerId) return 'A behavior cannot run itself'; const program = lookup(id); diff --git a/src/core/graph/nodes/action/SetProperty.ts b/src/core/graph/nodes/action/SetProperty.ts index ce360dd3..670782d8 100644 --- a/src/core/graph/nodes/action/SetProperty.ts +++ b/src/core/graph/nodes/action/SetProperty.ts @@ -2,6 +2,7 @@ import type { NodeDef } from '../../NodeRegistry'; import { asEntity, writeTyped } from '../../values'; import { any, exec, obj } from '../shared'; +import { textOf } from '../../../model/Properties'; export const SetProperty: NodeDef = { type: 'SetProperty', label: 'Set property', @@ -12,7 +13,7 @@ export const SetProperty: NodeDef = { params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }], run: (_ctx, inputs, params) => { const target = asEntity(inputs.object); - const key = String(params.property ?? ''); + const key = textOf(params.property ?? ''); if (target && key) writeTyped(target.props, key, inputs.value); return { next: 'exec' }; }, diff --git a/src/core/graph/nodes/action/Spawn.ts b/src/core/graph/nodes/action/Spawn.ts index e95ce35a..66bc8795 100644 --- a/src/core/graph/nodes/action/Spawn.ts +++ b/src/core/graph/nodes/action/Spawn.ts @@ -2,6 +2,7 @@ import type { NodeDef } from '../../NodeRegistry'; import { asEntity, asNumber } from '../../values'; import { assetParam, exec, num, obj } from '../shared'; +import { textOf } from '../../../model/Properties'; export const Spawn: NodeDef = { type: 'Spawn', label: 'Spawn', @@ -13,7 +14,7 @@ export const Spawn: NodeDef = { outputs: [exec(), obj('object')], params: [assetParam], run: (ctx, inputs, params) => { - const assetId = String(params.asset ?? ''); + const assetId = textOf(params.asset ?? ''); const position = { x: asNumber(inputs.x), y: asNumber(inputs.y), z: asNumber(inputs.z) }; const spawned = assetId ? ctx.spawn(assetId, position, asEntity(inputs.parent)) : null; if (!spawned) ctx.log(assetId ? `Spawn: asset ${assetId} not found` : 'Spawn: no asset selected'); diff --git a/src/core/graph/nodes/data/AxisInput.ts b/src/core/graph/nodes/data/AxisInput.ts index e67792a4..b8a89a43 100644 --- a/src/core/graph/nodes/data/AxisInput.ts +++ b/src/core/graph/nodes/data/AxisInput.ts @@ -1,6 +1,7 @@ import type { NodeDef } from '../../NodeRegistry'; import { bool, normalize, num } from '../shared'; +import { textOf } from '../../../model/Properties'; export const AxisInput: NodeDef = { type: 'AxisInput', label: 'Axis input', @@ -15,7 +16,7 @@ export const AxisInput: NodeDef = { { key: 'down', label: 'Down', kind: 'key', default: 'ArrowDown' }, ], run: (ctx, _inputs, params) => { - const held = (key: string, fallback: string) => (ctx.isKeyDown(String(params[key] ?? fallback)) ? 1 : 0); + const held = (key: string, fallback: string) => (ctx.isKeyDown(textOf(params[key] ?? fallback)) ? 1 : 0); const dx = held('right', 'ArrowRight') - held('left', 'ArrowLeft'); const dy = held('down', 'ArrowDown') - held('up', 'ArrowUp'); const { x, y, length } = normalize(dx, dy); diff --git a/src/core/graph/nodes/data/Compare.ts b/src/core/graph/nodes/data/Compare.ts index 3c0fed45..c84c931a 100644 --- a/src/core/graph/nodes/data/Compare.ts +++ b/src/core/graph/nodes/data/Compare.ts @@ -2,6 +2,7 @@ import type { NodeDef } from '../../NodeRegistry'; import { asNumber } from '../../values'; import { bool, num } from '../shared'; +import { textOf } from '../../../model/Properties'; const OPERATORS: Record boolean> = { '==': (a, b) => a === b, '!=': (a, b) => a !== b, @@ -30,7 +31,7 @@ export const Compare: NodeDef = { run: (_ctx, inputs, params) => { // The fallback matches the param's declared default, so a node with no setting behaves // like a freshly created one. - const compare = OPERATORS[String(params.op)] ?? OPERATORS['<']!; + const compare = OPERATORS[textOf(params.op)] ?? OPERATORS['<']!; return { outputs: { result: compare(asNumber(inputs.a), asNumber(inputs.b)) } }; }, }; diff --git a/src/core/graph/nodes/data/GetProperty.ts b/src/core/graph/nodes/data/GetProperty.ts index 1f6254be..cbafec2f 100644 --- a/src/core/graph/nodes/data/GetProperty.ts +++ b/src/core/graph/nodes/data/GetProperty.ts @@ -2,6 +2,7 @@ import type { NodeDef } from '../../NodeRegistry'; import { asEntity } from '../../values'; import { any, obj } from '../shared'; +import { textOf } from '../../../model/Properties'; export const GetProperty: NodeDef = { type: 'GetProperty', label: 'Get property', @@ -11,6 +12,6 @@ export const GetProperty: NodeDef = { outputs: [any('value')], params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }], run: (_ctx, inputs, params) => ({ - outputs: { value: asEntity(inputs.object)?.props[String(params.property ?? '')] ?? null }, + outputs: { value: asEntity(inputs.object)?.props[textOf(params.property ?? '')] ?? null }, }), }; diff --git a/src/core/graph/nodes/data/IsAsset.ts b/src/core/graph/nodes/data/IsAsset.ts index 7ac18662..50de7843 100644 --- a/src/core/graph/nodes/data/IsAsset.ts +++ b/src/core/graph/nodes/data/IsAsset.ts @@ -2,6 +2,7 @@ import type { NodeDef } from '../../NodeRegistry'; import { asEntity } from '../../values'; import { assetParam, bool, obj } from '../shared'; +import { textOf } from '../../../model/Properties'; export const IsAsset: NodeDef = { type: 'IsAsset', label: 'Is asset', @@ -11,7 +12,7 @@ export const IsAsset: NodeDef = { outputs: [bool('result')], params: [assetParam], run: (_ctx, inputs, params) => { - const assetId = String(params.asset ?? ''); + const assetId = textOf(params.asset ?? ''); return { outputs: { result: Boolean(assetId) && asEntity(inputs.object)?.assetId === assetId } }; }, }; diff --git a/src/core/graph/nodes/data/KeyIsDown.ts b/src/core/graph/nodes/data/KeyIsDown.ts index 471a113b..fa6f6bf1 100644 --- a/src/core/graph/nodes/data/KeyIsDown.ts +++ b/src/core/graph/nodes/data/KeyIsDown.ts @@ -1,6 +1,7 @@ import type { NodeDef } from '../../NodeRegistry'; import { bool, keyParam } from '../shared'; +import { textOf } from '../../../model/Properties'; export const KeyIsDown: NodeDef = { type: 'KeyIsDown', label: 'Key is down', @@ -9,5 +10,5 @@ export const KeyIsDown: NodeDef = { inputs: [], outputs: [bool('down')], params: [keyParam], - run: (ctx, _inputs, params) => ({ outputs: { down: ctx.isKeyDown(String(params.key ?? '')) } }), + run: (ctx, _inputs, params) => ({ outputs: { down: ctx.isKeyDown(textOf(params.key ?? '')) } }), }; diff --git a/src/core/graph/nodes/shared.ts b/src/core/graph/nodes/shared.ts index b519e2f8..58b8a00a 100644 --- a/src/core/graph/nodes/shared.ts +++ b/src/core/graph/nodes/shared.ts @@ -9,6 +9,7 @@ export const bool = (key: string, label = key): PortDef => ({ key, label, type: 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 struct = (key: string, label = key): PortDef => ({ key, label, type: 'struct' }); export const any = (key: string, label = key): PortDef => ({ key, label, type: 'any' }); export const withDefault = (port: PortDef, value: PropertyValue): PortDef => ({ ...port, default: value }); diff --git a/src/core/graph/nodes/variable/GetVariable.ts b/src/core/graph/nodes/variable/GetVariable.ts index 33bfb09a..a9f4b971 100644 --- a/src/core/graph/nodes/variable/GetVariable.ts +++ b/src/core/graph/nodes/variable/GetVariable.ts @@ -1,6 +1,7 @@ import type { NodeDef } from '../../NodeRegistry'; import { any, variableParam } from '../shared'; +import { textOf } from '../../../model/Properties'; export const GetVariable: NodeDef = { type: 'GetVariable', label: 'Get variable', @@ -10,6 +11,6 @@ export const GetVariable: NodeDef = { outputs: [any('value')], params: [variableParam], run: (ctx, _inputs, params) => ({ - outputs: { value: ctx.variables[String(params.variable ?? '')] ?? null }, + outputs: { value: ctx.variables[textOf(params.variable ?? '')] ?? null }, }), }; diff --git a/src/core/graph/nodes/variable/SetVariable.ts b/src/core/graph/nodes/variable/SetVariable.ts index 1cc9f060..10f3f5c3 100644 --- a/src/core/graph/nodes/variable/SetVariable.ts +++ b/src/core/graph/nodes/variable/SetVariable.ts @@ -2,6 +2,7 @@ import type { NodeDef } from '../../NodeRegistry'; import { writeTyped } from '../../values'; import { any, exec, variableParam } from '../shared'; +import { textOf } from '../../../model/Properties'; export const SetVariable: NodeDef = { type: 'SetVariable', label: 'Set variable', @@ -11,7 +12,7 @@ export const SetVariable: NodeDef = { outputs: [exec()], params: [variableParam], run: (ctx, inputs, params) => { - const key = String(params.variable ?? ''); + const key = textOf(params.variable ?? ''); if (key) writeTyped(ctx.variables, key, inputs.value); return { next: 'exec' }; }, diff --git a/src/core/graph/values.ts b/src/core/graph/values.ts index 50ffc96b..b624d324 100644 --- a/src/core/graph/values.ts +++ b/src/core/graph/values.ts @@ -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 = {}; + 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); } diff --git a/src/core/model/Properties.ts b/src/core/model/Properties.ts index ba80d54b..52dfdad7 100644 --- a/src/core/model/Properties.ts +++ b/src/core/model/Properties.ts @@ -4,19 +4,52 @@ */ export type ListValue = readonly (number | string | boolean)[]; +/** What a struct field may hold: no field is itself a struct, for the same reason a list is flat. */ +export type FieldValue = number | string | boolean | ListValue; + +/** Several named values travelling as one, so a bundle needs one port instead of one per part. */ +export type StructValue = { readonly [field: string]: FieldValue }; + /** Colors are stored as 0xRRGGBB numbers. */ -export type PropertyValue = number | string | boolean | ListValue; +export type PropertyValue = FieldValue | StructValue; export type PropertyBag = Record; 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. */ +/** + * Model-level only: a live entity handle is an object too, and the graph tells the two apart by + * the handle's brand (see `values.ts`). Nothing in a PropertyBag is ever a handle. + */ +export const isStruct = (value: unknown): value is StructValue => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * A property value as text. Nothing may hand a PropertyValue to `String()` directly: a struct would + * come out as "[object Object]" and a list without separators. + */ +export function textOf(value: PropertyValue | undefined): string { + if (value === undefined) return ''; + if (isList(value)) return value.join(', '); + if (isStruct(value)) + return Object.entries(value) + .map(([field, item]) => `${field}: ${textOf(item)}`) + .join(', '); + return String(value); +} + +/** Values are compared by content, because two equal lists (or structs) are never the same object. */ 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]); } + 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; } diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts index 96884663..1c679939 100644 --- a/src/core/model/texture.ts +++ b/src/core/model/texture.ts @@ -1,5 +1,5 @@ import type { TextureAsset, TextureFrame } from './Asset'; -import type { PropertyBag } from './Properties'; +import { type PropertyBag, textOf } from './Properties'; import { findAsset, type Project } from './Project'; /** Mirrors `TextureStore::isValidName` on the server, where the name is derived from the bytes. */ @@ -56,7 +56,7 @@ export function textureRefIds(asset: TextureAsset): string[] { } export function resolveTexture(project: Project, props: PropertyBag): TextureRef | null { - const id = String(props.texture ?? ''); + const id = textOf(props.texture ?? ''); if (!id) return null; const asset = findAsset(project, id); if (asset) return asset.type === 'texture' ? refFor(asset, null) : null; @@ -68,7 +68,7 @@ function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | n if (!isTextureFile(asset.file)) return null; return { file: asset.file, - smooth: String(asset.props.filter ?? 'linear') !== 'nearest', + smooth: textOf(asset.props.filter ?? 'linear') !== 'nearest', rect: frame ? { x: frame.x, y: frame.y, w: frame.w, h: frame.h } : null, sourceWidth: asset.width, sourceHeight: asset.height, diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index de1ad463..a76f0bc3 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -1,6 +1,13 @@ import type { Asset } from '../model/Asset'; import { type AssetType, isPlaceableType } from '../model/AssetTypes'; -import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../model/Properties'; +import { + type FieldValue, + isList, + type ListValue, + type PropertyBag, + type PropertyValue, + textOf, +} from '../model/Properties'; /** The inspector is generated from these entries, so a new property is one line in a schema below. */ export type PropertyDef = @@ -176,7 +183,7 @@ export function variableDef( case 'boolean': return { key, label: key, kind, default: Boolean(defaultValue ?? false) }; case 'text': - return { key, label: key, kind, default: defaultValue === undefined ? '' : String(defaultValue) }; + return { key, label: key, kind, default: defaultValue === undefined ? '' : textOf(defaultValue) }; case 'list': return { key, @@ -189,7 +196,7 @@ export function variableDef( } /** Clamps / coerces a raw value so it satisfies the definition. */ -export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue { +export function coerceValue(def: PropertyDef, value: PropertyValue): FieldValue { switch (def.kind) { case 'number': { let n = typeof value === 'number' ? value : Number(value); @@ -199,7 +206,7 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal return n; } case 'color': { - const n = typeof value === 'number' ? value : parseInt(String(value).replace('#', ''), 16); + const n = typeof value === 'number' ? value : parseInt(textOf(value).replace('#', ''), 16); return Number.isFinite(n) ? Math.max(0, Math.min(0xffffff, Math.floor(n))) : def.default; } case 'boolean': @@ -208,9 +215,9 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal case 'key': case 'asset': case 'texture': - return String(value); + return textOf(value); case 'enum': { - const s = String(value); + const s = textOf(value); return def.options.some((o) => o.value === s) ? s : def.default; } case 'list': @@ -232,15 +239,15 @@ export function listFromText(text: string, items: ListItemKind): ListValue { } export function coerceListItem(value: PropertyValue, items: ListItemKind): number | string | boolean { - if (items === 'text') return isList(value) ? value.join(', ') : String(value); + if (items === 'text') return isList(value) ? value.join(', ') : 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. - const s = String(value).trim().toLowerCase(); + const s = textOf(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); + const c = typeof value === 'number' ? value : parseInt(textOf(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); diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index 8dbafc22..b42ec73a 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -3,7 +3,7 @@ * "Add collider", so all three agree on how big a thing is. */ import type { AssetType } from '../model/AssetTypes'; -import type { PropertyBag, Vec2 } from '../model/Properties'; +import { type PropertyBag, textOf, type Vec2 } from '../model/Properties'; import { cameraFrame } from './camera'; import { colliderSize } from './collider'; import { defaultsFor } from './PropertySchema'; @@ -21,7 +21,7 @@ const LINE_HEIGHT = 1.25; /** Estimated from the content and font size, without measuring the text. */ export function textSize(props: PropertyBag): { w: number; h: number } { const size = Math.max(1, Number(props.fontSize ?? TEXT_DEFAULTS.fontSize)); - const lines = String(props.text ?? TEXT_DEFAULTS.text).split('\n'); + const lines = textOf(props.text ?? TEXT_DEFAULTS.text).split('\n'); const longest = lines.reduce((max, line) => Math.max(max, line.length), 0); return { w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH), @@ -31,7 +31,7 @@ export function textSize(props: PropertyBag): { w: number; h: number } { /** 0 = left edge on the position, 0.5 = centred, 1 = right. */ export function textAnchor(props: PropertyBag): number { - const align = String(props.align ?? TEXT_DEFAULTS.align); + const align = textOf(props.align ?? TEXT_DEFAULTS.align); return align === 'left' ? 0 : align === 'right' ? 1 : 0.5; } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index f3ce9f3b..6ed484d8 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -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 { PropertyBag, PropertyValue } from '../model/Properties'; +import type { FieldValue, PropertyBag, PropertyValue } from '../model/Properties'; import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project'; import type { SceneObject } from '../model/SceneObject'; import { isTextureFile, normalizeFrameRect } from '../model/texture'; @@ -209,10 +209,11 @@ function normalizeGraph(raw: unknown): Graph { const type = typeof n.type === 'string' ? n.type : ''; if (!id || nodeIds.has(id) || !nodeDef(type)) continue; nodeIds.add(id); + // Params carry both settings and inline input values, so there is no schema to coerce against. const params: PropertyBag = {}; for (const [key, value] of Object.entries(asRecord(n.params))) { - if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') - params[key] = value; + const stored = asStoredValue(value); + if (stored !== undefined) params[key] = stored; } const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params }; const ports = normalizePorts(n.ports); @@ -296,10 +297,22 @@ function asRecord(value: unknown): Record { } /** - * 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. + * A value from an untyped slot (node params, instance edits). Lists and structs are kept but + * 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; + const fields: Record = {}; + for (const [key, item] of Object.entries(value)) { + const stored = asFieldValue(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( diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index cf4609b4..57548b3d 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -1,4 +1,5 @@ import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; +import { textOf } from '../../core/model/Properties'; import type { Command } from '../../core/commands/Command'; import { CompoundCommand } from '../../core/commands/CompoundCommand'; import { @@ -380,7 +381,7 @@ export class GraphCanvas extends Widget { /** Selecting the asset is what opens it: the graph tab follows the selection. */ private openCalledBehavior(node: GraphNode): boolean { if (node.type !== RUN_BEHAVIOR) return false; - const id = String(node.params[BEHAVIOR_PARAM] ?? ''); + const id = textOf(node.params[BEHAVIOR_PARAM] ?? ''); const asset = id ? findAsset(this.state.project, id) : undefined; if (asset?.type !== 'behavior') return false; this.state.select({ kind: 'asset', assetId: id }); diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index ca2a20bf..e067012f 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -14,7 +14,7 @@ import { } from '../../core/graph'; import type { BehaviorAsset } from '../../core/model/Asset'; import type { Project } from '../../core/model/Project'; -import { isList, type PropertyValue } from '../../core/model/Properties'; +import { isList, type PropertyValue, textOf } 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'; @@ -60,6 +60,8 @@ export function portColor(type: PortType): number { return c.portObject; case 'list': return c.portList; + case 'struct': + return c.portStruct; case 'any': return c.portAny; } @@ -296,8 +298,8 @@ function formatParam( project: Project | null, ): string { if (value === undefined || value === '') return '—'; - if (param.kind === 'key') return keyLabel(String(value)); - if (param.kind === 'asset') return assetRefLabel(project, String(value)); + if (param.kind === 'key') return keyLabel(textOf(value)); + if (param.kind === 'asset') return assetRefLabel(project, textOf(value)); if ( param.kind === 'enum' && param.key === 'variable' && diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 41f33bc1..a5e7f2cc 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -1,4 +1,4 @@ -import { isList, type PropertyValue } from '../../core/model/Properties'; +import { isList, type PropertyValue, textOf } from '../../core/model/Properties'; import type { Project } from '../../core/model/Project'; import { assetChoiceLabel, @@ -187,8 +187,8 @@ export function createField( return { widget: field, grow: true, setValue: (v) => field.setNumber(Number(v)) }; } case 'text': { - const field = new TextField(String(value), { onInput: handlers.onInput, onChange: handlers.onChange }); - return { widget: field, grow: true, setValue: (v) => field.setValue(String(v)) }; + const field = new TextField(textOf(value), { onInput: handlers.onInput, onChange: handlers.onChange }); + return { widget: field, grow: true, setValue: (v) => field.setValue(textOf(v)) }; } case 'color': { const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange }); @@ -210,7 +210,7 @@ export function createField( } case 'enum': { const labelOf = (v: PropertyValue) => - def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—'); + def.options.find((o) => o.value === textOf(v))?.label ?? (textOf(v) || '—'); const button = new Dropdown(labelOf(value)); button.onClick = () => { new Menu( @@ -220,13 +220,13 @@ export function createField( return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) }; } case 'key': { - const button = new KeyCaptureButton(String(value), (key) => handlers.onChange(key)); - return { widget: button, grow: true, setValue: (v) => button.setKey(String(v)) }; + const button = new KeyCaptureButton(textOf(value), (key) => handlers.onChange(key)); + return { widget: button, grow: true, setValue: (v) => button.setKey(textOf(v)) }; } case 'asset': { // The current id is kept here rather than read back off the label: the label is a name, // two assets may share one, and a dangling reference has no name at all. - let current = String(value); + let current = textOf(value); const button = new Dropdown(assetRefLabel(ctx.project(), current)); button.onClick = () => { const mark = (id: string, text: string) => (id === current ? `● ${text}` : text); @@ -246,7 +246,7 @@ export function createField( widget: button, grow: true, setValue: (v) => { - current = String(v); + current = textOf(v); button.setText(assetRefLabel(ctx.project(), current)); }, }; @@ -254,7 +254,7 @@ export function createField( case 'texture': { // Kept for the same reason as in the asset case, and more urgently: a frame's label // repeats its sheet's name, so several entries can read identically. - let current = String(value); + let current = textOf(value); const button = new Dropdown(textureRefLabel(ctx.project(), current)); button.onClick = () => { const mark = (id: string, text: string) => (id === current ? `● ${text}` : text); @@ -272,7 +272,7 @@ export function createField( widget: button, grow: true, setValue: (v) => { - current = String(v); + current = textOf(v); button.setText(textureRefLabel(ctx.project(), current)); }, }; diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index edbf36c4..a4bc150c 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -14,6 +14,7 @@ const KIND_LABELS: Record = { text: 'Text', object: 'Object', list: 'List', + struct: 'Struct', }; const KIND_MARKS: Record = { @@ -22,6 +23,7 @@ const KIND_MARKS: Record = { text: 'Aa', object: '◇', list: '≡', + struct: '⊞', }; /** diff --git a/src/editor/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts index 1c172969..9ee7039f 100644 --- a/src/editor/texture/TextureCanvas.ts +++ b/src/editor/texture/TextureCanvas.ts @@ -8,7 +8,7 @@ import { } from 'pixi.js'; import { AddFrameCommand, SetFrameRectCommand } from '../../core/commands/frameCommands'; import type { TextureAsset, TextureFrame } from '../../core/model/Asset'; -import type { Vec2 } from '../../core/model/Properties'; +import { textOf, type Vec2 } from '../../core/model/Properties'; import { findAsset } from '../../core/model/Project'; import { type FrameRect, MIN_FRAME_SIZE, normalizeFrameRect } from '../../core/model/texture'; import { type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui'; @@ -335,7 +335,7 @@ export class TextureCanvas extends Widget { private pictureOf(asset: TextureAsset) { return textureLibrary.get({ file: asset.file, - smooth: String(asset.props.filter ?? 'linear') !== 'nearest', + smooth: textOf(asset.props.filter ?? 'linear') !== 'nearest', rect: null, sourceWidth: asset.width, sourceHeight: asset.height, diff --git a/src/editor/viewport/WorldViewport.ts b/src/editor/viewport/WorldViewport.ts index 47b494c9..ca9a93d4 100644 --- a/src/editor/viewport/WorldViewport.ts +++ b/src/editor/viewport/WorldViewport.ts @@ -1,6 +1,6 @@ import { Container, type FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js'; import { isSymbolType } from '../../core/model/AssetTypes'; -import { sortByZ, type Vec2 } from '../../core/model/Properties'; +import { sortByZ, type Vec2, textOf } from '../../core/model/Properties'; import { footprintOf, textAnchor } from '../../core/properties/bounds'; import { rectAround, rectsIntersect } from '../../core/properties/camera'; import type { EditorState } from '../state'; @@ -213,7 +213,7 @@ export abstract class WorldViewport extends Widget { } text.visible = true; text.position.set(item.x, item.y); - text.text = String(item.props.text ?? ''); + text.text = textOf(item.props.text ?? ''); const size = Math.max(1, Number(item.props.fontSize ?? 32)); if (text.style.fontSize !== size) text.style.fontSize = size; const color = Number(item.props.color ?? 0xffffff); diff --git a/src/runtime/Entity.ts b/src/runtime/Entity.ts index 72b483b6..3771f496 100644 --- a/src/runtime/Entity.ts +++ b/src/runtime/Entity.ts @@ -1,3 +1,4 @@ +import type { ENTITY_HANDLE } from '../core/graph/NodeContext'; import type { AssetType } from '../core/model/AssetTypes'; import type { PropertyBag } from '../core/model/Properties'; @@ -8,6 +9,8 @@ import type { PropertyBag } from '../core/model/Properties'; * editor panels can show and edit them while playing. */ export interface Entity { + /** What tells a graph this object is a live entity and not a struct value. */ + readonly [ENTITY_HANDLE]: true; id: string; /** Scene object this entity was instantiated from (for selection highlighting). */ objectId: string; diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 18332200..6b81e414 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -1,4 +1,4 @@ -import { isHandleEnabled, type PointerState, worldPositionOf } from '../core/graph'; +import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldPositionOf } from '../core/graph'; import { newId } from '../core/ids'; import type { Asset } from '../core/model/Asset'; import { isPlaceableType } from '../core/model/AssetTypes'; @@ -118,6 +118,7 @@ export class World { let entity = this.findByObjectId(object.id); if (!entity) { entity = { + [ENTITY_HANDLE]: true, id: `e-${object.id}`, objectId: object.id, assetId: asset.id, diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index c5e7526e..231d7591 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -9,7 +9,7 @@ import { } from '../../core/graph'; import { stepKeyRepeat } from '../../core/graph/keyRepeat'; import { behaviorLookup } from '../../core/model/behaviorPrograms'; -import type { PropertyBag } from '../../core/model/Properties'; +import { type PropertyBag, textOf } from '../../core/model/Properties'; import { findAsset, type Project } from '../../core/model/Project'; import type { Entity } from '../Entity'; import type { World } from '../World'; @@ -95,7 +95,7 @@ export class BehaviorSystem implements System { update(world: World, dt: number): void { this.syncInstances(world); - const keyOf = (node: { params: PropertyBag }) => String(node.params.key ?? ''); + const keyOf = (node: { params: PropertyBag }) => textOf(node.params.key ?? ''); for (const instance of this.instances) { if (!world.entities.includes(instance.entity)) continue; // destroyed during play if (!isHandleEnabled(instance.entity)) continue; // switched off (itself or a parent) @@ -136,7 +136,7 @@ export class BehaviorSystem implements System { * in `nodeState`, so two nodes on the same key repeat on their own schedules. */ private fireKeyDown(i: GraphInterpreter, ctx: NodeContext, node: GraphNode, dt: number): void { - const key = String(node.params.key ?? ''); + const key = textOf(node.params.key ?? ''); const stored = i.nodeState.get(node.id); const { state, fires } = stepKeyRepeat( stored ? { held: stored.held ?? 0, repeats: stored.repeats ?? 0 } : null, diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 9764550c..3d70bdbb 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -59,6 +59,7 @@ export const Theme = { portString: 0xe6a8ff, portObject: 0x56ccf2, portList: 0xf2a75c, + portStruct: 0xd4c25a, portAny: 0xbdbdbd, /** Ring around the port a running edge drag would connect to. */ portTarget: 0xffffff, diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index 36c1865d..c1dcbab8 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -486,6 +486,15 @@ describe('behavior serialization', () => { expect(cleaned.graph.nodes.length).toBe(behavior.graph.nodes.length); expect(cleaned.graph.edges.length).toBe(behavior.graph.edges.length); }); + + it('keeps a node param that is a whole list or a struct, not just a single value', () => { + const { project, behavior, edit } = emptyBehavior(); + const list = edit.addNode('List', { value: [3, 1, 4] }); + const struct = edit.addNode('Number', { bundle: { x: 2, tags: ['a'] } }); + const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset; + expect(findNode(loaded.graph, list.id)!.params.value).toEqual([3, 1, 4]); + expect(findNode(loaded.graph, struct.id)!.params.bundle).toEqual({ x: 2, tags: ['a'] }); + }); }); describe('unknown node types', () => { diff --git a/tests/core/list-value.test.ts b/tests/core/list-value.test.ts index 90f08855..e505e92c 100644 --- a/tests/core/list-value.test.ts +++ b/tests/core/list-value.test.ts @@ -1,12 +1,13 @@ 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 { sameValue, textOf } 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'; +import { asEntity, asStruct, writeTyped } from '../../src/core/graph'; +import { fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers'; const findObject = (project: Project, id: string) => objectHolders(project) @@ -94,3 +95,43 @@ describe('a list as a value', () => { expect(coerceValue(def, ['1', 2])).toEqual([1, 2]); }); }); + +describe('a struct as a value', () => { + it('compares by content, one level deep, and tells a different shape apart', () => { + expect(sameValue({ x: 1, tags: ['a'] }, { x: 1, tags: ['a'] })).toBe(true); + expect(sameValue({ x: 1, tags: ['a'] }, { x: 1, tags: ['b'] })).toBe(false); + expect(sameValue({ x: 1 }, { x: 1, y: 2 })).toBe(false); + expect(sameValue({ x: 1 }, [1])).toBe(false); + }); + + it('reads as text field by field, never as "[object Object]"', () => { + expect(textOf({ x: 1, tags: ['a', 'b'] })).toBe('x: 1, tags: a, b'); + }); + + it('is not a live entity, and a live entity is not one', () => { + const handle = fakeEntity('Player'); + expect(asStruct(handle)).toEqual({}); + expect(asStruct({ x: 1 })).toEqual({ x: 1 }); + expect(asEntity({ x: 1 })).toBeNull(); + expect(asEntity(handle)).toBe(handle); + }); + + /** What lets one node set some values of a bundle without erasing the rest of it. */ + it('keeps the fields of the slot it is written into, filling only the ones that arrive', () => { + const bag = { piece: { x: 1, y: 2, name: 'T' } }; + writeTyped(bag, 'piece', { y: 9, gone: 5 }); + expect(bag.piece).toEqual({ x: 1, y: 9, name: 'T' }); + }); + + it('converts each field to the type that field already held', () => { + const bag = { piece: { count: 0, on: false, label: '' } }; + writeTyped(bag, 'piece', { count: '7', on: 'yes', label: 42 }); + expect(bag.piece).toEqual({ count: 7, on: true, label: '42' }); + }); + + it('a non-struct written into a struct slot leaves the declared fields standing', () => { + const bag = { piece: { x: 1 } }; + writeTyped(bag, 'piece', 5); + expect(bag.piece).toEqual({ x: 1 }); + }); +}); diff --git a/tests/core/walk.test.ts b/tests/core/walk.test.ts index cfd0d09d..5a9fe5d6 100644 --- a/tests/core/walk.test.ts +++ b/tests/core/walk.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { AddColliderCommand } from '../../src/core/commands/colliderCommands'; -import { type EntityHandle, worldPositionOf } from '../../src/core/graph'; +import { ENTITY_HANDLE, type EntityHandle, worldPositionOf } from '../../src/core/graph'; import { hasParentCycle, parentChain } from '../../src/core/model/walk'; import { makeSpriteScene, place, startWorld } from '../helpers'; @@ -41,8 +41,29 @@ describe('parentChain', () => { describe('cycle-safe walks in the runtime', () => { it('world position of a cyclic chain terminates and counts each ancestor once', () => { - const a: EntityHandle = { id: 'a', assetId: 'x', name: 'a', x: 1, y: 10, z: 0, props: {}, parent: null }; - const b: EntityHandle = { id: 'b', assetId: 'x', name: 'b', x: 2, y: 20, z: 0, props: {}, parent: a }; + const brand = { [ENTITY_HANDLE]: true } as const; + const a: EntityHandle = { + ...brand, + id: 'a', + assetId: 'x', + name: 'a', + x: 1, + y: 10, + z: 0, + props: {}, + parent: null, + }; + const b: EntityHandle = { + ...brand, + id: 'b', + assetId: 'x', + name: 'b', + x: 2, + y: 20, + z: 0, + props: {}, + parent: a, + }; a.parent = b; expect(worldPositionOf(b)).toEqual({ x: 3, y: 30, z: 0 }); }); diff --git a/tests/helpers.ts b/tests/helpers.ts index ecb1546f..a9471cbc 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -4,6 +4,7 @@ import { AddNodeCommand, ConnectCommand } from '../src/core/commands/graphComman import { PlaceAssetCommand } from '../src/core/commands/sceneObjectCommands'; import { AddVariableCommand } from '../src/core/commands/variableCommands'; import { + ENTITY_HANDLE, type EntityHandle, type GraphNode, type NodeContext, @@ -132,12 +133,12 @@ export const NO_POINTER: PointerState = { /** A top-level entity handle named after itself, with empty props. */ export function fakeEntity(name: string, x = 0, assetId = `asset-${name}`): EntityHandle { - return { id: name, assetId, name, x, y: 0, z: 0, props: {}, parent: null }; + return { [ENTITY_HANDLE]: true, id: name, assetId, name, x, y: 0, z: 0, props: {}, parent: null }; } /** An entity handle "h" carrying the given props, optionally below a parent. */ export function fakeHandle(props: PropertyBag, parent: EntityHandle | null = null): EntityHandle { - return { id: 'h', assetId: 'a', name: 'h', x: 0, y: 0, z: 0, props, parent }; + return { [ENTITY_HANDLE]: true, id: 'h', assetId: 'a', name: 'h', x: 0, y: 0, z: 0, props, parent }; } export type FakeContext = NodeContext & { logs: string[]; stopped: boolean };