Let structs and lists hold objects across behavior runs (#238)
game-maker / gate (push) Successful in 3m28s
game-maker / suite (push) Successful in 10m14s

An object could only live in a dedicated object variable, kept in slots
beside the variable bag. Struct fields and list items could not hold
one, so a spawned object packed into a struct or list was lost. This
replaces the slots with one mechanism for all three places.

- "No object" has one resting form everywhere: {"$object": ""}. A
  running game can tell an object slot from an empty text that way.
- During play the object handle lives directly in the value, also
  nested in structs and lists. objectSlots.ts and the special paths for
  Keep and Properties are gone.
- liveObjects.ts strips objects to (null) at every exit: overrides the
  editor run writes back to the scene, saved values of persistent
  Variables assets, live values in the inspector, and the world
  snapshot of the test hooks (objects as names there).
- A destroyed object reads as (null) through isAlive, alone, in a
  struct field or as a list item.
- "+ Field" and "+ Item" offer Object. The inspector and the list tab
  show a fixed (null) row without input.
- Lists from Raycast all stay objects when written to a variable.
  Before they turned into names.
- A struct that only changes by an object writes no override. A list
  that grows by an object writes [(null)], its length really changed.

Found on the way: CI at 98211a8 failed only in e2e 21d. #237 added
asset to the field kinds without updating that fixed expectation. 21d
now expects ...,animation,asset,object,struct.

No project on the server has object variables, so no old data is
affected.

Tests:
- ./check.sh: 169 files, 2475 tests, CHECK EXIT 0.
- e2e 6, 21, 23, 32: ALL PASSED, 213 checks.
- object-variable.test.ts rewritten: variable, struct field and list
  item over several frames, destroyed objects, nothing in scene, file
  or save, editor run reload, Keep.
- New e2e 21d-ii adds an Object item by click. 21d and 23k updated.
- 7 counter-probes, each fails 1 to 9 tests. Originals back via cmp.
- Not tested on its own: stripping of inspector live values and the
  names in the test snapshot, the "+ Field -> Object" menu by click.
- e2e check total 1407, only calculated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-26 21:12:44 +02:00
1 parent 98211a8d54
commit 7979b8ff19
24 files changed
+510 -193

No files matched your search

+56 -12
View File
@@ -5,7 +5,10 @@ import {
type ListValue,
type PropertyBag,
type PropertyValue,
NO_OBJECT,
type StructValue,
type TaggedItem,
tagOf,
textOf,
booleanOf,
} from '../model/Properties';
@@ -13,8 +16,7 @@ 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). Nothing of the sort reaches a
* PropertyBag: everything written into one goes through `storedList`.
* what lets `Raycast all` hand out the objects it met (#157).
*/
export type ValueList = readonly (number | string | boolean | StructValue | EntityHandle)[];
@@ -58,13 +60,49 @@ 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(asListItem);
if (isStructValue(value)) return Object.values(value).map((field) => asValueItem(field as Value));
return [value];
}
/** A list as a PropertyBag may hold it: an object handle becomes its name, since nothing stored is live. */
export function storedList(items: ValueList): ListValue {
return items.map(asListItem);
/**
* 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. */
function heldList(items: ValueList): ListValue {
return items.map((item) => (isEntity(item) ? heldObject(item) : asListItem(item)));
}
/** `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 `asListItem`, a handle stays one. */
@@ -78,9 +116,13 @@ function asListItem(value: Value | undefined): number | string | boolean | Struc
return asPrimitive(value);
}
/** A value narrowed to what a struct field may hold: a list stays a list, anything deeper flattens. */
/**
* A value narrowed to what a struct field may hold: a list stays a list and an object the object,
* anything deeper flattens.
*/
export function asField(value: Value | undefined): FieldValue {
return isList(value) ? value : asPrimitive(value);
if (isEntity(value)) return heldObject(value);
return isValueList(value) ? heldList(value) : asPrimitive(value);
}
function asPrimitive(value: Value | undefined): number | string | boolean {
@@ -96,9 +138,9 @@ 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) return '';
if (isEntity(value)) return value.name;
// A handle among the items would otherwise be printed as the object it is made of.
return textOf(isValueList(value) ? storedList(value) : value);
// 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));
}
/**
@@ -148,7 +190,9 @@ function mergeStruct(current: StructValue, value: Value | undefined): StructValu
/** `value` converted to the type the slot already holds, so a number stays a number. */
function asTypeOf(current: PropertyValue | undefined, value: Value | undefined): PropertyValue {
if (isList(current)) return storedList(asList(value));
// 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);