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

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1406 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1407 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+8 -5
View File
@@ -1,7 +1,7 @@
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph'; import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
import { type GraphLookup, NO_LOOKUP } from './lookup'; import { type GraphLookup, NO_LOOKUP } from './lookup';
import { BEHAVIOR_END, callEntries, resolveDef, terminalNode } from './nested'; import { BEHAVIOR_END, callEntries, resolveDef, terminalNode } from './nested';
import type { PropertyBag } from '../model/Properties'; import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { NodeContext } from './NodeContext'; import type { NodeContext } from './NodeContext';
import { import {
type CallResult, type CallResult,
@@ -12,9 +12,8 @@ import {
type PortDef, type PortDef,
type RepeatResult, type RepeatResult,
} from './NodeRegistry'; } from './NodeRegistry';
import { carryObjectSlots } from './objectSlots';
import { machineIdOf } from './stateMachine'; import { machineIdOf } from './stateMachine';
import type { Value } from './values'; import { isObjectPlace, type Value } from './values';
/** How much work one pulse may do. A Repeat over a 10×20 board is 200 rounds, so this is generous. */ /** How much work one pulse may do. A Repeat over a 10×20 board is 200 rounds, so this is generous. */
const MAX_STEPS_PER_FIRE = 100_000; const MAX_STEPS_PER_FIRE = 100_000;
@@ -57,12 +56,16 @@ export function resumedVariables(
const variables: PropertyBag = {}; const variables: PropertyBag = {};
for (const [key, value] of Object.entries(defaults)) { for (const [key, value] of Object.entries(defaults)) {
const was = kept.includes(key) ? left?.[key] : undefined; const was = kept.includes(key) ? left?.[key] : undefined;
variables[key] = was !== undefined && typeof was === typeof value ? was : value; variables[key] = was !== undefined && sameSort(was, value) ? was : value;
} }
if (left) carryObjectSlots(left, variables, kept);
return variables; return variables;
} }
/** Whether `was` may stand where `value` is declared – an object's place counts as a sort of its own. */
function sameSort(was: PropertyValue, value: PropertyValue): boolean {
return typeof was === typeof value && isObjectPlace(was) === isObjectPlace(value);
}
/** /**
* Runs one behavior graph for one instance. An event node starts a *pulse*; the interpreter follows * Runs one behavior graph for one instance. An event node starts a *pulse*; the interpreter follows
* exec edges and pulls data inputs from the connected output, evaluating pure data nodes lazily and * exec edges and pulls data inputs from the connected output, evaluating pure data nodes lazily and
+4 -4
View File
@@ -1,11 +1,11 @@
import { isList, type PropertyBag, type PropertyValue } from '../model/Properties'; import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../model/Properties';
import { isKindWith, plainKindOf } from '../properties/kinds'; import { isKindWith, plainKindOf } from '../properties/kinds';
import { flatDef, type PropertyDef } from '../properties/PropertySchema'; import { flatDef, type PropertyDef } from '../properties/PropertySchema';
import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph'; import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph';
import type { GraphLookup } from './lookup'; import type { GraphLookup } from './lookup';
import type { NodeContext } from './NodeContext'; import type { NodeContext } from './NodeContext';
import type { SharedPortGroup } from './portTypes'; import type { SharedPortGroup } from './portTypes';
import { asList, storedList, type Value } from './values'; import { asList, namedValue, type Value } from './values';
export interface PortDef { export interface PortDef {
key: string; key: string;
@@ -192,9 +192,9 @@ export function withInlineDefault(port: PortDef): PortDef {
export function inlineInputDef(port: PortDef): PropertyDef { export function inlineInputDef(port: PortDef): PropertyDef {
if (port.field) return { ...port.field, key: port.key, label: port.label }; if (port.field) return { ...port.field, key: port.key, label: port.label };
const kind = port.type === 'any' ? 'text' : plainKindOf(port.type); const kind = port.type === 'any' ? 'text' : plainKindOf(port.type);
// A declared default is a stored value, never a live handle (see `storedList`). // A declared default is a stored value, never a live handle.
if (kind === 'list') if (kind === 'list')
return { key: port.key, label: port.label, kind, default: storedList(asList(port.default)) }; return { key: port.key, label: port.label, kind, default: namedValue(asList(port.default)) as ListValue };
const flat = kind !== undefined && isKindWith('field', kind) ? kind : 'number'; const flat = kind !== undefined && isKindWith('field', kind) ? kind : 'number';
const def = flatDef(port.key, flat, { label: port.label, value: port.default }); const def = flatDef(port.key, flat, { label: port.label, value: port.default });
return def.kind === 'number' ? { ...def, step: 1 } : def; return def.kind === 'number' ? { ...def, step: 1 } : def;
+73
View File
@@ -0,0 +1,73 @@
import {
isList,
isStruct,
NO_OBJECT,
type PropertyBag,
type PropertyValue,
tagOf,
} from '../model/Properties';
import type { EntityHandle } from './NodeContext';
import type { PortDef } from './NodeRegistry';
import { isEntity, isObjectPlace, type Value, writeTyped } from './values';
/**
* During a run a bag may hold live objects – in an object variable, a struct field, a list item. None
* of it may leave the run: `World.syncToModel` writes a behavior's changed values into the scene, a
* save is written as JSON, the inspector and the test hooks show what they are handed. Each of those
* ways out passes the value through here, where every object becomes the empty one (`NO_OBJECT`).
*/
export function withoutObjects<T extends PropertyValue | undefined>(value: T): T {
return mapObjects(value, () => NO_OBJECT) as T;
}
export function bagWithoutObjects(bag: PropertyBag): PropertyBag {
const out: PropertyBag = {};
for (const [key, value] of Object.entries(bag)) out[key] = withoutObjects(value);
return out;
}
/**
* A bag's value as a graph reads it: an object destroyed since it was written is gone, so a graph
* never goes on acting on one that has left the game. Alone it reads as nothing (`null`), inside a
* list or struct as the empty object.
*/
export function liveValue(
value: PropertyValue | undefined,
isAlive: (target: EntityHandle) => boolean,
): Value {
if (value === undefined || tagOf(value) === 'object') return null;
if (isEntity(value)) return isAlive(value) ? value : null;
return mapObjects(value, (held) => (isAlive(held) ? held : NO_OBJECT)) as Value;
}
/** What `outputs` read of a bag, each value live as `liveValue` says. */
export function readLive(
bag: PropertyBag,
outputs: readonly PortDef[],
isAlive: (target: EntityHandle) => boolean,
): Record<string, Value> {
const values: Record<string, Value> = {};
for (const port of outputs) if (port.key in bag) values[port.key] = liveValue(bag[port.key], isAlive);
return values;
}
/** Writes what arrives on `port` into the bag, typed as the slot is; an object port always makes an object's place. */
export function writePort(bag: PropertyBag, port: PortDef, value: Value | undefined): void {
if (port.type === 'object' && !isObjectPlace(bag[port.key])) bag[port.key] = NO_OBJECT;
writeTyped(bag, port.key, value);
}
/** `value` with each object replaced by what `swap` says; untouched parts keep their identity. */
function mapObjects(value: unknown, swap: (held: EntityHandle) => unknown): unknown {
if (isEntity(value as Value)) return swap(value as EntityHandle);
if (isList(value)) {
const items = value.map((item) => mapObjects(item, swap));
return items.some((item, i) => item !== value[i]) ? items : value;
}
if (isStruct(value) && tagOf(value) === undefined) {
const entries = Object.entries(value);
const mapped = entries.map(([key, field]) => [key, mapObjects(field, swap)] as const);
return mapped.some(([, field], i) => field !== entries[i]![1]) ? Object.fromEntries(mapped) : value;
}
return value;
}
+7 -2
View File
@@ -110,10 +110,15 @@ export function isTerminalNode(node: GraphNode): boolean {
return isTerminalType(node.type); return isTerminalType(node.type);
} }
/** A tagged kind is one the value cannot show, so its port carries the field that edits it. */ /**
* A tagged kind is one the value cannot show, so its port carries the field that edits it – except
* an object, which nothing can type in.
*/
export function portDefOf(port: DeclaredPort): PortDef { export function portDefOf(port: DeclaredPort): PortDef {
const def: PortDef = { key: port.key, label: port.key, type: declaredPortType(port.kind) }; const def: PortDef = { key: port.key, label: port.key, type: declaredPortType(port.kind) };
return isTaggedKind(port.kind) ? { ...def, field: flatDef(port.key, port.kind) } : def; return isTaggedKind(port.kind) && port.kind !== 'object'
? { ...def, field: flatDef(port.key, port.kind) }
: def;
} }
/** The ports the start (or end) node of `graph` declares, as seen from a node calling it. */ /** The ports the start (or end) node of `graph` declares, as seen from a node calling it. */
+6 -2
View File
@@ -1,5 +1,5 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { asStruct } from '../../values'; import { asStruct, unplaced, type Value } from '../../values';
import { struct } from '../shared'; import { struct } from '../shared';
/** /**
@@ -19,5 +19,9 @@ export const ExpandStruct: NodeDef = {
params: [], params: [],
declaresPorts: 'outputs', declaresPorts: 'outputs',
// A declared output the struct has no field for is simply absent from the cache, and reads as empty. // A declared output the struct has no field for is simply absent from the cache, and reads as empty.
run: (_ctx, inputs) => ({ outputs: { ...asStruct(inputs.value) } }), run: (_ctx, inputs) => {
const outputs: Record<string, Value> = {};
for (const [key, field] of Object.entries(asStruct(inputs.value))) outputs[key] = unplaced(field) ?? null;
return { outputs };
},
}; };
+2 -1
View File
@@ -1,6 +1,6 @@
import { untag } from '../../../model/Properties'; import { untag } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { asList, asNumber, isEntity } from '../../values'; import { asList, asNumber, isEntity, unplaced } from '../../values';
import { any, int, list } from '../shared'; import { any, int, list } from '../shared';
export const ItemAt: NodeDef = { export const ItemAt: NodeDef = {
@@ -18,6 +18,7 @@ export const ItemAt: NodeDef = {
// An object comes out as the object it is, not as its name: that is the point of a list of // An object comes out as the object it is, not as its name: that is the point of a list of
// objects, as `Raycast all` hands one out (#157). // objects, as `Raycast all` hands one out (#157).
if (isEntity(item)) return { outputs: { item } }; if (isEntity(item)) return { outputs: { item } };
if (unplaced(item) === null) return { outputs: { item: null } };
return { outputs: { item: untag(item) ?? 0 } }; return { outputs: { item: untag(item) ?? 0 } };
}, },
params: [], params: [],
+3 -4
View File
@@ -6,7 +6,7 @@ import {
propertyPort, propertyPort,
propertyTargetOf, propertyTargetOf,
} from '../../propertyPorts'; } from '../../propertyPorts';
import { readWithObjects, writeWithObjects } from '../../objectSlots'; import { readLive, writePort } from '../../liveObjects';
import { asEntity } from '../../values'; import { asEntity } from '../../values';
import { exec, obj } from '../shared'; import { exec, obj } from '../shared';
@@ -28,16 +28,15 @@ export const Properties: NodeDef = {
const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {}; const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {};
return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'properties') } : shape; return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'properties') } : shape;
}, },
// A behavior's variables are its props, so an object variable is read from its slot there too.
read: (ctx, _params, input, def) => { read: (ctx, _params, input, def) => {
const target = asEntity(input('object')); const target = asEntity(input('object'));
return target ? readWithObjects(target.props, def.outputs, ctx.isAlive) : {}; return target ? readLive(target.props, def.outputs, ctx.isAlive) : {};
}, },
run: (_ctx, inputs, _params, def) => { run: (_ctx, inputs, _params, def) => {
const target = asEntity(inputs.object); const target = asEntity(inputs.object);
if (target) if (target)
for (const port of def.inputs) for (const port of def.inputs)
if (port.type !== 'exec' && port.key !== 'object') writeWithObjects(target.props, port, inputs[port.key]); if (port.type !== 'exec' && port.key !== 'object') writePort(target.props, port, inputs[port.key]);
return { next: 'exec' }; return { next: 'exec' };
}, },
}; };
+3 -3
View File
@@ -1,6 +1,6 @@
import { collapsedTitle, portDefOf, VARIABLES, VARIABLES_SETTING, variablesSourceOf } from '../../nested'; import { collapsedTitle, portDefOf, VARIABLES, VARIABLES_SETTING, variablesSourceOf } from '../../nested';
import { type NodeDef, withInlineDefault } from '../../NodeRegistry'; import { type NodeDef, withInlineDefault } from '../../NodeRegistry';
import { readWithObjects, writeWithObjects } from '../../objectSlots'; import { readLive, writePort } from '../../liveObjects';
import { variableBag } from '../../sharedState'; import { variableBag } from '../../sharedState';
import { exec } from '../shared'; import { exec } from '../shared';
@@ -26,10 +26,10 @@ export const Variables: NodeDef = {
return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'variables') } : shape; return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'variables') } : shape;
}, },
read: (ctx, params, _input, def) => read: (ctx, params, _input, def) =>
readWithObjects(variableBag(ctx, variablesSourceOf(params)), def.outputs, ctx.isAlive), readLive(variableBag(ctx, variablesSourceOf(params)), def.outputs, ctx.isAlive),
run: (ctx, inputs, params, def) => { run: (ctx, inputs, params, def) => {
const bag = variableBag(ctx, variablesSourceOf(params)); const bag = variableBag(ctx, variablesSourceOf(params));
for (const port of def.inputs) if (port.type !== 'exec') writeWithObjects(bag, port, inputs[port.key]); for (const port of def.inputs) if (port.type !== 'exec') writePort(bag, port, inputs[port.key]);
return { next: 'exec' }; return { next: 'exec' };
}, },
}; };
-67
View File
@@ -1,67 +0,0 @@
import type { PropertyBag } from '../model/Properties';
import type { EntityHandle } from './NodeContext';
import type { PortDef } from './NodeRegistry';
import { asEntity, type Value, writeTyped } from './values';
/**
* The objects the object variables of a bag hold, beside the bag rather than in it: what sits in a
* bag reaches the scene (`World.syncToModel` writes a behavior's changed values back as overrides),
* the saves and the inspector, and none of them can keep a live handle. Keyed weakly by the bag, so
* a run that ends takes its objects along.
*/
const slots = new WeakMap<PropertyBag, Map<string, EntityHandle>>();
/**
* The object the variable holds, null for none – or for one destroyed since it was written, so a
* graph never goes on acting on an object that has left the game.
*/
export function readObjectSlot(
bag: PropertyBag,
key: string,
isAlive: (target: EntityHandle) => boolean,
): EntityHandle | null {
const held = slots.get(bag)?.get(key);
if (!held) return null;
if (isAlive(held)) return held;
slots.get(bag)!.delete(key);
return null;
}
export function writeObjectSlot(bag: PropertyBag, key: string, target: EntityHandle | null): void {
let held = slots.get(bag);
if (!target) {
held?.delete(key);
return;
}
if (!held) slots.set(bag, (held = new Map<string, EntityHandle>()));
held.set(key, target);
}
/** Hands the objects `keys` hold in `from` on to `to` – a kept variable of the next call (#229). */
export function carryObjectSlots(from: PropertyBag, to: PropertyBag, keys: readonly string[]): void {
const held = slots.get(from);
if (!held) return;
for (const key of keys) {
const target = held.get(key);
if (target && key in to) writeObjectSlot(to, key, target);
}
}
/** The bag as a graph reads it through `outputs`: an object port answers with the object held now. */
export function readWithObjects(
bag: PropertyBag,
outputs: readonly PortDef[],
isAlive: (target: EntityHandle) => boolean,
): Record<string, Value> {
const objects = outputs.filter((port) => port.type === 'object');
if (objects.length === 0) return bag;
const values: Record<string, Value> = { ...bag };
for (const port of objects) values[port.key] = readObjectSlot(bag, port.key, isAlive);
return values;
}
/** Writes one value arriving on `port`: an object into its slot, anything else typed into the bag. */
export function writeWithObjects(bag: PropertyBag, port: PortDef, value: Value | undefined): void {
if (port.type === 'object') writeObjectSlot(bag, port.key, asEntity(value));
else writeTyped(bag, port.key, value);
}
+3 -1
View File
@@ -3,6 +3,7 @@ import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { Project } from '../model/Project'; import type { Project } from '../model/Project';
import { sharedScopeOf } from '../model/Scope'; import { sharedScopeOf } from '../model/Scope';
import { coerceValue } from '../properties/PropertySchema'; import { coerceValue } from '../properties/PropertySchema';
import { withoutObjects } from './liveObjects';
/** What a save area holds: the values of each persistent Variables asset, keyed by asset id. */ /** What a save area holds: the values of each persistent Variables asset, keyed by asset id. */
export type SavedValues = Record<string, PropertyBag>; export type SavedValues = Record<string, PropertyBag>;
@@ -54,7 +55,8 @@ export function savedSnapshot(project: Project, variables: Record<string, Proper
const bag = variables[asset.id]; const bag = variables[asset.id];
if (!bag) continue; if (!bag) continue;
const values: PropertyBag = {}; const values: PropertyBag = {};
for (const def of asset.variables) if (def.key in bag) values[def.key] = bag[def.key]!; // No object outlives the run, so a save keeps the empty one in its place.
for (const def of asset.variables) if (def.key in bag) values[def.key] = withoutObjects(bag[def.key]!);
saved[asset.id] = values; saved[asset.id] = values;
} }
return saved; return saved;
+56 -12
View File
@@ -5,7 +5,10 @@ import {
type ListValue, type ListValue,
type PropertyBag, type PropertyBag,
type PropertyValue, type PropertyValue,
NO_OBJECT,
type StructValue, type StructValue,
type TaggedItem,
tagOf,
textOf, textOf,
booleanOf, booleanOf,
} from '../model/Properties'; } 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 * 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 * what lets `Raycast all` hand out the objects it met (#157).
* PropertyBag: everything written into one goes through `storedList`.
*/ */
export type ValueList = readonly (number | string | boolean | StructValue | EntityHandle)[]; 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 (isValueList(value)) return value;
if (value === null || value === undefined) return []; if (value === null || value === undefined) return [];
if (isEntity(value)) return [value]; 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]; 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 { * A handle as a run's bag holds it. Types say a bag is what a file holds, and at rest that is
return items.map(asListItem); * 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. */ /** 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); 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 { 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 { 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. */ /** Entities read as their name, so a handle can be logged or joined into text directly. */
export function asString(value: Value | undefined): string { export function asString(value: Value | undefined): string {
if (value === null) return ''; if (value === null) return '';
if (isEntity(value)) return value.name; // A handle, at the top or in a list or struct, would otherwise be printed as what it is made of.
// A handle among the items would otherwise be printed as the object it is made of. if (isObjectPlace(value)) return isEntity(value) ? value.name : '';
return textOf(isValueList(value) ? storedList(value) : value); 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. */ /** `value` converted to the type the slot already holds, so a number stays a number. */
function asTypeOf(current: PropertyValue | undefined, value: Value | undefined): PropertyValue { 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 (isStruct(current)) return mergeStruct(current, value);
if (typeof current === 'number') return asNumber(value); if (typeof current === 'number') return asNumber(value);
if (typeof current === 'boolean') return asBoolean(value); if (typeof current === 'boolean') return asBoolean(value);
+13
View File
@@ -44,6 +44,13 @@ export function tagged(kind: TaggedKind, value: number | string): TaggedItem {
return { [TAG + kind]: value } as TaggedItem; return { [TAG + kind]: value } as TaggedItem;
} }
/**
* What an object's place holds at rest – a variable, a struct field, a list item alike. Always
* nothing, since an object exists only while the game runs, but tagged, so a run tells the place
* apart from an empty text (`graph/liveObjects.ts`).
*/
export const NO_OBJECT: TaggedItem = tagged('object', '');
/** A tagged item as what it wraps; anything else as itself. */ /** A tagged item as what it wraps; anything else as itself. */
export function untag(value: PropertyValue | undefined): PropertyValue | undefined { export function untag(value: PropertyValue | undefined): PropertyValue | undefined {
const tag = tagOf(value); const tag = tagOf(value);
@@ -88,6 +95,9 @@ export function colorOf(value: PropertyValue, fallback: number): number {
/** Values are compared by content, because two equal lists (or structs) are never the same object. */ /** 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 { export function sameValue(a: PropertyValue | undefined, b: PropertyValue | undefined): boolean {
if (a === b) return true;
// A live handle (a run's bag may hold one, branded by a symbol) is only ever itself.
if (isBranded(a) || isBranded(b)) return false;
if (isList(a) || isList(b)) { if (isList(a) || isList(b)) {
if (!isList(a) || !isList(b) || a.length !== b.length) return false; if (!isList(a) || !isList(b) || a.length !== b.length) return false;
return a.every((item, i) => sameValue(item, b[i])); return a.every((item, i) => sameValue(item, b[i]));
@@ -100,6 +110,9 @@ export function sameValue(a: PropertyValue | undefined, b: PropertyValue | undef
return a === b; return a === b;
} }
const isBranded = (value: unknown): boolean =>
isStruct(value) && Object.getOwnPropertySymbols(value).length > 0;
/** `undefined` removes the key, which is how an undo of "no value" works. */ /** `undefined` removes the key, which is how an undo of "no value" works. */
export function setBagValue(bag: PropertyBag, key: string, value: PropertyValue | undefined): void { export function setBagValue(bag: PropertyBag, key: string, value: PropertyValue | undefined): void {
if (value === undefined) delete bag[key]; if (value === undefined) delete bag[key];
+10 -12
View File
@@ -7,6 +7,7 @@ import {
type ListValue, type ListValue,
type PropertyBag, type PropertyBag,
type PropertyValue, type PropertyValue,
NO_OBJECT,
type StructValue, type StructValue,
textOf, textOf,
} from '../model/Properties'; } from '../model/Properties';
@@ -86,12 +87,13 @@ type Extras<K extends FlatKind> = K extends 'number'
: unknown; : unknown;
/** /**
* The definition of a flat kind: its default is a value of that kind, as the table says. * The definition of a flat kind: its default is a value of that kind, as the table says. Not an
* object's: that default is no value of the kind but its tagged place (`ObjectDef`).
* `shownWith` names a switch on the same bag; the inspector shows the row only while it is on * `shownWith` names a switch on the same bag; the inspector shows the row only while it is on
* (see `isRowShown`), everything else – graph, defaults, files – always has it. * (see `isRowShown`), everything else – graph, defaults, files – always has it.
*/ */
export type FlatDef<K extends FlatKind = FlatKind> = { export type FlatDef<K extends FlatKind = FlatKind> = {
[P in K]: { [P in Exclude<K, 'object'>]: {
key: string; key: string;
label: string; label: string;
kind: P; kind: P;
@@ -100,7 +102,7 @@ export type FlatDef<K extends FlatKind = FlatKind> = {
initial?: ValueOf<P>; initial?: ValueOf<P>;
shownWith?: string; shownWith?: string;
} & Extras<P>; } & Extras<P>;
}[K]; }[Exclude<K, 'object'>];
/** The value a new asset starts with, which is also what Reset goes back to. */ /** The value a new asset starts with, which is also what Reset goes back to. */
export function initialOf(def: PropertyDef): PropertyValue { export function initialOf(def: PropertyDef): PropertyValue {
@@ -161,9 +163,7 @@ export type PropertyDef =
| FlatDef | FlatDef
/** /**
* A list declares nothing about its items: each one carries its own kind (see `listItems.ts`), * A list declares nothing about its items: each one carries its own kind (see `listItems.ts`),
* and the graph types a list as a list, so one `list` port fits them all. There is no `object` * and the graph types a list as a list, so one `list` port fits them all.
* 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 } | { key: string; label: string; kind: 'list'; default: ListValue }
/** /**
@@ -177,10 +177,7 @@ export type PropertyDef =
default: StructValue; default: StructValue;
fields: readonly StructFieldDef[]; fields: readonly StructFieldDef[];
} }
/** /** An object variable holds a live handle during a run, and nothing at rest (`NO_OBJECT`). */
* An object variable holds a live handle, which no file and no save can keep: the bag carries only
* this empty placeholder, and the handle sits beside it for the run (see `graph/objectSlots.ts`).
*/
| ObjectDef; | ObjectDef;
export interface ObjectDef { export interface ObjectDef {
@@ -190,8 +187,7 @@ export interface ObjectDef {
default: typeof NO_OBJECT; default: typeof NO_OBJECT;
} }
/** What an object variable stores – always nothing, since an object exists only while the game runs. */ export { NO_OBJECT };
export const NO_OBJECT = '';
export interface PrimitiveFieldDef { export interface PrimitiveFieldDef {
key: string; key: string;
@@ -769,6 +765,7 @@ export function variableDef(
/** The empty value of a kind, so a field that is not there yet still has one. */ /** The empty value of a kind, so a field that is not there yet still has one. */
export function emptyFieldValue(field: StructFieldDef): FieldValue { export function emptyFieldValue(field: StructFieldDef): FieldValue {
if (field.kind === 'object') return NO_OBJECT;
return field.kind === 'list' ? [] : emptyOf(field.kind); return field.kind === 'list' ? [] : emptyOf(field.kind);
} }
@@ -811,5 +808,6 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
/** A struct field as its declaration promises. A list field takes only a list – see `ListFieldDef`. */ /** A struct field as its declaration promises. A list field takes only a list – see `ListFieldDef`. */
export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): FieldValue { export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): FieldValue {
if (field.kind === 'object') return NO_OBJECT;
return field.kind === 'list' ? flatListOf(value) : coerceField(value, field.kind); return field.kind === 'list' ? flatListOf(value) : coerceField(value, field.kind);
} }
+15 -2
View File
@@ -158,8 +158,21 @@ export const KINDS = [
field: true, field: true,
port: true, port: true,
}, },
/** A variable of it holds a live object for the run only; nothing of it is stored (`graph/objectSlots.ts`). */ /**
{ kind: 'object', label: 'Object', travels: 'object', variable: true, port: true }, * A live object, held for the run only. At rest – in a file, a save, the inspector – it is always
* the empty one, tagged wherever it sits so a run can tell its place from a text (`NO_OBJECT`).
*/
{
kind: 'object',
label: 'Object',
travels: 'object',
empty: '',
coerce: () => '',
tagged: true,
variable: true,
field: true,
port: true,
},
{ kind: 'list', label: 'List', travels: 'list', variable: true, port: true }, { kind: 'list', label: 'List', travels: 'list', variable: true, port: true },
{ kind: 'struct', label: 'Struct', travels: 'struct', variable: true, port: true }, { kind: 'struct', label: 'Struct', travels: 'struct', variable: true, port: true },
// Schema-only kinds: a setting has them, nothing a user declares does. // Schema-only kinds: a setting has them, nothing a user declares does.
+16 -2
View File
@@ -4,6 +4,7 @@
* from the runtime copy `state.enterPlay()` swaps in and `exitPlay()` throws away. * from the runtime copy `state.enterPlay()` swaps in and `exitPlay()` throws away.
*/ */
import { emptySharedState, type SharedState, worldPositionOf } from '../core/graph'; import { emptySharedState, type SharedState, worldPositionOf } from '../core/graph';
import { namedValue } from '../core/graph/values';
import { import {
addGameSystems, addGameSystems,
type BehaviorSystem, type BehaviorSystem,
@@ -259,6 +260,7 @@ export class PlaySession {
if (this.host.stateMachineEditor.visible) this.host.stateMachineEditor.canvas.redraw(); if (this.host.stateMachineEditor.visible) this.host.stateMachineEditor.canvas.redraw();
} }
/** The check scripts get the run as JSON, so an object a bag holds is handed over as its name. */
snapshot(): WorldSnapshot | null { snapshot(): WorldSnapshot | null {
const world = this.world; const world = this.world;
if (!world) return null; if (!world) return null;
@@ -274,12 +276,24 @@ export class PlaySession {
worldX: w.x, worldX: w.x,
worldY: w.y, worldY: w.y,
parent: e.parent?.objectId ?? '', parent: e.parent?.objectId ?? '',
props: { ...e.props }, props: namedBag(e.props),
animations: structuredClone(e.animations ?? []), animations: structuredClone(e.animations ?? []),
}; };
}), }),
shared: this.behaviors?.shared ?? emptySharedState(), shared: namedShared(this.behaviors?.shared ?? emptySharedState()),
log: [...world.log], log: [...world.log],
}; };
} }
} }
function namedBag(bag: PropertyBag): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(bag)) out[key] = namedValue(value);
return out;
}
function namedShared(shared: SharedState): SharedState {
const variables: Record<string, PropertyBag> = {};
for (const [id, bag] of Object.entries(shared.variables)) variables[id] = namedBag(bag) as PropertyBag;
return { variables, states: shared.states };
}
+2 -1
View File
@@ -6,6 +6,7 @@ import {
SetVariablesPersistentCommand, SetVariablesPersistentCommand,
SetVariablesScopeCommand, SetVariablesScopeCommand,
} from '../../core/commands/assetCommands'; } from '../../core/commands/assetCommands';
import { bagWithoutObjects } from '../../core/graph/liveObjects';
import { isPersistent } from '../../core/graph/savedValues'; import { isPersistent } from '../../core/graph/savedValues';
import { nextFieldName, nextVariableName } from '../../core/commands/naming'; import { nextFieldName, nextVariableName } from '../../core/commands/naming';
import { import {
@@ -179,7 +180,7 @@ function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef):
export function shownValues(host: InspectorHost, asset: Asset): PropertyBag { export function shownValues(host: InspectorHost, asset: Asset): PropertyBag {
const values = resolveAssetProps(host.state.project, asset); const values = resolveAssetProps(host.state.project, asset);
const live = asset.type === 'variables' ? host.liveVariables(asset.id) : null; const live = asset.type === 'variables' ? host.liveVariables(asset.id) : null;
return live ? { ...values, ...live } : values; return live ? { ...values, ...bagWithoutObjects(live) } : values;
} }
/** Declared variables, each with its declaration editable – plus an "add" menu. */ /** Declared variables, each with its declaration editable – plus an "add" menu. */
+11 -4
View File
@@ -1,7 +1,8 @@
import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from '../core/graph'; import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from '../core/graph';
import { newId } from '../core/ids'; import { newId } from '../core/ids';
import type { Asset, TilemapAsset } from '../core/model/Asset'; import type { Asset, TilemapAsset } from '../core/model/Asset';
import type { PropertyBag, Vec2 } from '../core/model/Properties'; import { withoutObjects } from '../core/graph/liveObjects';
import { type PropertyBag, sameValue, type Vec2 } from '../core/model/Properties';
import { isPlaceableType } from '../core/model/AssetTypes'; import { isPlaceableType } from '../core/model/AssetTypes';
import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance'; import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance';
import { treeOrder } from '../core/model/hierarchy'; import { treeOrder } from '../core/model/hierarchy';
@@ -245,7 +246,9 @@ export class World {
entity.z = object.position.z; entity.z = object.position.z;
const resolved = resolveSceneObjectProps(this.project, asset, object); const resolved = resolveSceneObjectProps(this.project, asset, object);
for (const key of Object.keys(entity.props)) if (!(key in resolved)) delete entity.props[key]; for (const key of Object.keys(entity.props)) if (!(key in resolved)) delete entity.props[key];
Object.assign(entity.props, resolved); // The scene only ever got the objects' empty places, so a value that differs by them alone stays.
for (const [key, value] of Object.entries(resolved))
if (!sameValue(value, withoutObjects(entity.props[key]))) entity.props[key] = value;
return entity; return entity;
} }
@@ -305,14 +308,18 @@ export class World {
const p = object.position; const p = object.position;
const moved = p.x !== entity.x || p.y !== entity.y || p.z !== entity.z; const moved = p.x !== entity.x || p.y !== entity.y || p.z !== entity.z;
const base = resolveSceneObjectProps(this.project, asset, object); const base = resolveSceneObjectProps(this.project, asset, object);
const drifted = Object.keys(entity.props).filter((k) => k in base && base[k] !== entity.props[k]); // What a run holds of an object stays in the run; the scene gets the empty one.
const drifted = Object.keys(entity.props).filter(
(k) =>
k in base && base[k] !== entity.props[k] && !sameValue(base[k], withoutObjects(entity.props[k])),
);
if (!moved && drifted.length === 0) continue; if (!moved && drifted.length === 0) continue;
// Only now, because a ref for resolved content costs a resolve of its own – and because a // Only now, because a ref for resolved content costs a resolve of its own – and because a
// direct write to such an object would land in the freshly built copy and be lost. // direct write to such an object would land in the freshly built copy and be lost.
const ref = objectRef(this.project, scene, object.id); const ref = objectRef(this.project, scene, object.id);
if (!ref) continue; if (!ref) continue;
if (moved) ref.setPosition({ x: entity.x, y: entity.y, z: entity.z }); if (moved) ref.setPosition({ x: entity.x, y: entity.y, z: entity.z });
for (const key of drifted) ref.setOverride(key, entity.props[key]); for (const key of drifted) ref.setOverride(key, withoutObjects(entity.props[key]));
values = true; values = true;
} }
return { structure: spawned || scene.objects.length !== before, values }; return { structure: spawned || scene.objects.length !== before, values };
+12 -3
View File
@@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest';
import { AddVariableCommand } from '../../src/core/commands/variableCommands'; import { AddVariableCommand } from '../../src/core/commands/variableCommands';
import { declaredPortType, portDefOf } from '../../src/core/graph'; import { declaredPortType, portDefOf } from '../../src/core/graph';
import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
import { tagOf, untag } from '../../src/core/model/Properties'; import { NO_OBJECT, tagOf, untag } from '../../src/core/model/Properties';
import { findAsset } from '../../src/core/model/Project'; import { findAsset } from '../../src/core/model/Project';
import { import {
coerceField, coerceField,
@@ -29,7 +29,8 @@ import { itemKindOf, listItemOf, makeItem } from '../../src/core/properties/list
import { deserializeProject } from '../../src/core/store/serialize'; import { deserializeProject } from '../../src/core/store/serialize';
import { makeBehavior, makeProject, roundTrip } from '../helpers'; import { makeBehavior, makeProject, roundTrip } from '../helpers';
const TAGGED = FIELD_KINDS.filter(isTaggedKind); /** The tagged kinds that hold a value; an object's place is always empty at rest (see below). */
const TAGGED = FIELD_KINDS.filter((kind) => isTaggedKind(kind) && kind !== 'object');
/** A value of the type a tagged kind travels as, distinct from the kind's empty value. */ /** A value of the type a tagged kind travels as, distinct from the kind's empty value. */
const sampleOf = (kind: (typeof TAGGED)[number]) => (KIND[kind].travels === 'number' ? 0xff0000 : 'walk-up'); const sampleOf = (kind: (typeof TAGGED)[number]) => (KIND[kind].travels === 'number' ? 0xff0000 : 'walk-up');
@@ -80,6 +81,12 @@ describe('a tagged kind', () => {
} }
}); });
it('holds nothing but its place when it is an object, whatever it is handed', () => {
for (const value of ['Crate', 3, undefined]) expect(makeItem('object', value)).toEqual(NO_OBJECT);
expect(itemKindOf(NO_OBJECT)).toBe('object');
expect(listItemOf(NO_OBJECT)).toEqual(NO_OBJECT);
});
it('survives a save and load in a list variable and in a node parameter', () => { it('survives a save and load in a list variable and in a node parameter', () => {
for (const kind of TAGGED) { for (const kind of TAGGED) {
const item = makeItem(kind, sampleOf(kind)); const item = makeItem(kind, sampleOf(kind));
@@ -110,7 +117,9 @@ describe('a declared port', () => {
it('travels as the table says, and carries the field that edits it only when tagged', () => { it('travels as the table says, and carries the field that edits it only when tagged', () => {
for (const kind of DECLARED_PORT_KINDS) { for (const kind of DECLARED_PORT_KINDS) {
expect(declaredPortType(kind), kind).toBe(KIND[kind].travels); expect(declaredPortType(kind), kind).toBe(KIND[kind].travels);
expect(portDefOf({ key: 'p', kind }).field?.kind, kind).toBe(isTaggedKind(kind) ? kind : undefined); // An object has nothing to type in, so its port has no field.
const edited = isTaggedKind(kind) && kind !== 'object';
expect(portDefOf({ key: 'p', kind }).field?.kind, kind).toBe(edited ? kind : undefined);
} }
}); });
}); });
+6 -8
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { AddVariableCommand } from '../../src/core/commands/variableCommands'; import { AddVariableCommand } from '../../src/core/commands/variableCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { type PropertyBag, sameValue, tagged, textOf } from '../../src/core/model/Properties'; import { NO_OBJECT, type PropertyBag, sameValue, tagged, textOf } from '../../src/core/model/Properties';
import { findAsset, objectHolders, type Project } from '../../src/core/model/Project'; import { findAsset, objectHolders, type Project } from '../../src/core/model/Project';
import { deserializeProject } from '../../src/core/store/serialize'; import { deserializeProject } from '../../src/core/store/serialize';
import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema'; import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
@@ -14,10 +14,10 @@ import {
asNumber, asNumber,
asString, asString,
asStruct, asStruct,
storedList,
type Value, type Value,
writeTyped, writeTyped,
} from '../../src/core/graph'; } from '../../src/core/graph';
import { bagWithoutObjects } from '../../src/core/graph/liveObjects';
import { IndexOf } from '../../src/core/graph/nodes/data/IndexOf'; import { IndexOf } from '../../src/core/graph/nodes/data/IndexOf';
import { NO_LOOKUP } from '../../src/core/graph/lookup'; import { NO_LOOKUP } from '../../src/core/graph/lookup';
import { fakeContext, fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers'; import { fakeContext, fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
@@ -112,15 +112,13 @@ describe('a list carrying objects (#157)', () => {
it('hands the objects on as they are, and a lone object reads as a list of one', () => { it('hands the objects on as they are, and a lone object reads as a list of one', () => {
expect(asList([hero, crate])).toEqual([hero, crate]); expect(asList([hero, crate])).toEqual([hero, crate]);
expect(asEntity(asList(hero)[0])).toBe(hero); expect(asEntity(asList(hero)[0])).toBe(hero);
expect(storedList([hero, 3, crate])).toEqual(['Hero', 3, 'Crate']);
}); });
it('never lets a live object into a stored value', () => { it('keeps its objects in a run’s bag, and lets none of them out of it', () => {
// The one door into a PropertyBag: whatever arrives is narrowed to what a stored list may hold.
const bag: PropertyBag = { seen: ['x'] }; const bag: PropertyBag = { seen: ['x'] };
writeTyped(bag, 'seen', [hero, crate]); writeTyped(bag, 'seen', [hero, 3, crate]);
expect(bag.seen).toEqual(['Hero', 'Crate']); expect(bag.seen).toEqual([hero, 3, crate]);
expect(JSON.parse(JSON.stringify(bag))).toEqual({ seen: ['Hero', 'Crate'] }); expect(JSON.parse(JSON.stringify(bagWithoutObjects(bag)))).toEqual({ seen: [NO_OBJECT, 3, NO_OBJECT] });
}); });
it('reads as text by the names of its objects, not by what they are made of', () => { it('reads as text by the names of its objects, not by what they are made of', () => {
+4 -3
View File
@@ -85,6 +85,7 @@ const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y
const emitTarget = fakeEntity('Sparks'); const emitTarget = fakeEntity('Sparks');
/** Their own entity each: the animation nodes leave players on the object they are handed. */ /** Their own entity each: the animation nodes leave players on the object they are handed. */
const dancer = fakeEntity('Dancer'); const dancer = fakeEntity('Dancer');
const packed = fakeEntity('Player');
const painted = fakeHandle({ width: 100, text: '' }); const painted = fakeHandle({ width: 100, text: '' });
type PhysicsDouble = PhysicsControl & { calls: unknown[][] }; type PhysicsDouble = PhysicsControl & { calls: unknown[][] };
@@ -668,9 +669,9 @@ const CASES: Record<string, Case[]> = {
}, },
{ name: 'a node with no fields yet packs nothing', outputs: { value: {} } }, { name: 'a node with no fields yet packs nothing', outputs: { value: {} } },
{ {
name: 'an entity cannot be stored in a bundle, so it packs as its name', name: 'an entity packs as the object it is, so a struct can hold one',
inputs: { who: fakeEntity('Player') }, inputs: { who: packed },
outputs: { value: { who: 'Player' } }, outputs: { value: { who: packed } as never },
}, },
], ],
ExpandStruct: [ ExpandStruct: [
+237 -52
View File
@@ -1,18 +1,34 @@
/** /**
* An object as a variable: a spawned object written into one is there on the next frame, a * Objects in variables, struct fields and list items: a spawned object written into one is there on
* destroyed one reads as nothing, and no handle ever reaches the scene, the file or a save. * the next frame, a destroyed one reads as nothing, and no handle ever leaves the run – not into the
* scene, the file, a save or what the editor is shown.
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { SetVariablesPersistentCommand } from '../../src/core/commands/assetCommands'; import { SetVariablesPersistentCommand } from '../../src/core/commands/assetCommands';
import { AddVariableCommand } from '../../src/core/commands/variableCommands'; import { AddPortsCommand } from '../../src/core/commands/portCommands';
import { resumedVariables, type SaveArea, type SavedValues, VARIABLES_PARAM } from '../../src/core/graph'; import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
import type { EntityHandle } from '../../src/core/graph/NodeContext'; import {
import { readObjectSlot, writeObjectSlot } from '../../src/core/graph/objectSlots'; asString,
resumedVariables,
type SaveArea,
type SavedValues,
VARIABLES_PARAM,
writeTyped,
} from '../../src/core/graph';
import { bagWithoutObjects, liveValue } from '../../src/core/graph/liveObjects';
import type { BehaviorAsset } from '../../src/core/model/Asset'; import type { BehaviorAsset } from '../../src/core/model/Asset';
import { NO_OBJECT, type PropertyBag } from '../../src/core/model/Properties';
import { findScene } from '../../src/core/model/Project'; import { findScene } from '../../src/core/model/Project';
import { isKindWith, VARIABLE_KINDS } from '../../src/core/properties/kinds'; import {
isKindWith,
LIST_ITEM_KINDS,
STRUCT_FIELD_KINDS,
VARIABLE_KINDS,
} from '../../src/core/properties/kinds';
import { makeItem } from '../../src/core/properties/listItems';
import { serializeProject } from '../../src/core/store/serialize'; import { serializeProject } from '../../src/core/store/serialize';
import { import {
fakeEntity,
graphEditor, graphEditor,
makeBehavior, makeBehavior,
makeSpriteScene, makeSpriteScene,
@@ -43,26 +59,99 @@ function spawner(f: SceneFixture): BehaviorAsset {
return behavior; return behavior;
} }
/** Spawns a Crate on start into the field `target` of the struct `held`; logs that field on every update. */
function structHolder(f: SceneFixture): BehaviorAsset {
const behavior = makeBehavior(f, 'Holder');
f.stack.execute(new AddVariableCommand(behavior.id, 'held', 'struct'));
f.stack.execute(new AddStructFieldCommand(behavior.id, 'held', { key: 'target', kind: 'object' }));
f.stack.execute(new AddStructFieldCommand(behavior.id, 'held', { key: 'count', kind: 'number' }));
const edit = graphEditor(f, behavior);
const spawn = edit.addNode('Spawn', { asset: f.spriteId });
const pack = edit.addNode('PackStruct');
f.stack.execute(new AddPortsCommand(behavior.id, pack.id, [{ key: 'target', kind: 'object' }]));
const set = edit.variables(['held'], { write: true });
const get = edit.variables(['held']);
const expand = edit.addNode('ExpandStruct');
f.stack.execute(new AddPortsCommand(behavior.id, expand.id, [{ key: 'target', kind: 'object' }]));
const log = edit.addNode('Log');
edit.wire([
[edit.addNode('OnStart'), 'exec', spawn, 'exec'],
[spawn, 'exec', set, 'exec'],
[spawn, 'object', pack, 'target'],
[pack, 'value', set, 'held'],
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
[get, 'held', expand, 'value'],
[expand, 'target', log, 'message'],
]);
place(f, behavior.id, { x: 0, y: 0 });
return behavior;
}
/** Spawns a Crate on start into the list `crates`; logs its first item on every update. */
function listHolder(f: SceneFixture): BehaviorAsset {
const behavior = makeBehavior(f, 'Keeper');
f.stack.execute(new AddVariableCommand(behavior.id, 'crates', 'list'));
const edit = graphEditor(f, behavior);
const spawn = edit.addNode('Spawn', { asset: f.spriteId });
const one = edit.addNode('Fill', { count: 1 });
const set = edit.variables(['crates'], { write: true });
const get = edit.variables(['crates']);
const first = edit.addNode('ItemAt', { index: 0 });
const log = edit.addNode('Log');
edit.wire([
[edit.addNode('OnStart'), 'exec', spawn, 'exec'],
[spawn, 'exec', set, 'exec'],
[spawn, 'object', one, 'value'],
[one, 'result', set, 'crates'],
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
[get, 'crates', first, 'list'],
[first, 'item', log, 'message'],
]);
place(f, behavior.id, { x: 0, y: 0 });
return behavior;
}
const crates = (f: SceneFixture, run: ReturnType<typeof startWorld>) => const crates = (f: SceneFixture, run: ReturnType<typeof startWorld>) =>
run.world.entities.filter((e) => e.assetId === f.spriteId); run.world.entities.filter((e) => e.assetId === f.spriteId);
describe('the object kind as a variable', () => { const instanceOf = (run: ReturnType<typeof startWorld>, behavior: BehaviorAsset) =>
it('can be declared, but has no field and no list item', () => { run.world.entities.find((e) => e.assetId === behavior.id)!;
const placedOf = (f: SceneFixture, behavior: BehaviorAsset) =>
findScene(f.project, f.sceneId)!.objects.find((o) => o.assetId === behavior.id)!;
describe('the object kind', () => {
it('can be a variable, a struct field and a list item', () => {
expect(VARIABLE_KINDS).toContain('object'); expect(VARIABLE_KINDS).toContain('object');
expect(isKindWith('field', 'object')).toBe(false); expect(STRUCT_FIELD_KINDS).toContain('object');
expect(LIST_ITEM_KINDS).toContain('object');
expect(isKindWith('tagged', 'object')).toBe(true);
}); });
it('is stored as its declaration only, with nothing as its value', () => { it('is stored as the empty object wherever it sits, whatever the file says', () => {
const f = makeSpriteScene('Crate'); const f = makeSpriteScene('Crate');
const behavior = makeBehavior(f, 'Holder'); const behavior = makeBehavior(f, 'Holder');
f.stack.execute(new AddVariableCommand(behavior.id, 'target', 'object')); f.stack.execute(new AddVariableCommand(behavior.id, 'target', 'object'));
f.stack.execute(new AddVariableCommand(behavior.id, 'held', 'struct'));
f.stack.execute(new AddStructFieldCommand(behavior.id, 'held', { key: 'who', kind: 'object' }));
f.stack.execute(new AddVariableCommand(behavior.id, 'crowd', 'list'));
behavior.props.crowd = [makeItem('object', 'Crate'), 3];
const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id) as BehaviorAsset; const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id) as BehaviorAsset;
expect(loaded.variables.find((v) => v.key === 'target')).toMatchObject({ kind: 'object', default: '' }); expect(loaded.variables.find((v) => v.key === 'target')).toMatchObject({
kind: 'object',
default: NO_OBJECT,
});
expect(loaded.props).toMatchObject({
target: NO_OBJECT,
held: { who: NO_OBJECT },
crowd: [NO_OBJECT, 3],
});
expect(NO_OBJECT).toEqual({ $object: '' });
}); });
}); });
describe('an object variable in a running game', () => { describe('an object held by a running game', () => {
it('still holds the spawned object on the next frames', () => { it('stays in a variable on the next frames', () => {
const f = makeSpriteScene('Crate'); const f = makeSpriteScene('Crate');
spawner(f); spawner(f);
const run = startWorld(f, { behaviors: true }); const run = startWorld(f, { behaviors: true });
@@ -71,9 +160,33 @@ describe('an object variable in a running game', () => {
expect(run.world.log.slice(-2)).toEqual(['Crate', 'Crate']); expect(run.world.log.slice(-2)).toEqual(['Crate', 'Crate']);
}); });
it('reads as nothing once the object is destroyed', () => { it('stays in a struct field on the next frames, beside the other fields', () => {
const f = makeSpriteScene('Crate'); const f = makeSpriteScene('Crate');
spawner(f); const behavior = structHolder(f);
const run = startWorld(f, { behaviors: true });
for (let i = 0; i < 3; i++) run.frame();
expect(run.world.log.slice(-2)).toEqual(['Crate', 'Crate']);
const held = instanceOf(run, behavior).props.held as unknown as Record<string, unknown>;
expect(held.target).toBe(crates(f, run)[0]);
expect(held.count).toBe(0);
});
it('stays in a list on the next frames', () => {
const f = makeSpriteScene('Crate');
const behavior = listHolder(f);
const run = startWorld(f, { behaviors: true });
for (let i = 0; i < 3; i++) run.frame();
expect(run.world.log.slice(-2)).toEqual(['Crate', 'Crate']);
expect(instanceOf(run, behavior).props.crates).toEqual([crates(f, run)[0]]);
});
it.each([
['a variable', spawner],
['a struct field', structHolder],
['a list item', listHolder],
])('reads as nothing from %s once the object is destroyed', (_where, build) => {
const f = makeSpriteScene('Crate');
build(f);
const run = startWorld(f, { behaviors: true }); const run = startWorld(f, { behaviors: true });
run.frame(); run.frame();
run.world.remove(crates(f, run)[0]!); run.world.remove(crates(f, run)[0]!);
@@ -101,20 +214,6 @@ describe('an object variable in a running game', () => {
expect([alive, run.world.log.at(-1)]).toEqual(['false', 'true']); expect([alive, run.world.log.at(-1)]).toEqual(['false', 'true']);
}); });
it('never reaches the behavior’s bag, the scene or the file', () => {
const f = makeSpriteScene('Crate');
const behavior = spawner(f);
const run = startWorld(f, { behaviors: true });
run.frame();
run.frame();
run.world.syncToModel();
const instance = run.world.entities.find((e) => e.assetId === behavior.id)!;
expect(instance.props.spawned).toBe('');
const placed = findScene(f.project, f.sceneId)!.objects.find((o) => o.assetId === behavior.id)!;
expect('spawned' in placed.overrides).toBe(false);
expect(() => JSON.stringify(serializeProject(f.project))).not.toThrow();
});
it('is read and written on another behavior through Properties', () => { it('is read and written on another behavior through Properties', () => {
const f = makeSpriteScene('Crate'); const f = makeSpriteScene('Crate');
const holder = makeBehavior(f, 'Holder'); const holder = makeBehavior(f, 'Holder');
@@ -150,12 +249,92 @@ describe('an object variable in a running game', () => {
}); });
}); });
describe('an object variable in a persistent Variables asset', () => { describe('no object leaves the run', () => {
/** On start: log what `target` holds, then spawn a Crate into it and count `score` up. */ it.each([
['a variable', spawner, 'spawned', undefined],
['a struct field', structHolder, 'held', undefined],
// One item more than declared is a real difference – the scene learns the item, not the object.
['a list item', listHolder, 'crates', [NO_OBJECT]],
])('writes nothing of %s into the scene or the file', (_where, build, key, override) => {
const f = makeSpriteScene('Crate');
const behavior = build(f);
const run = startWorld(f, { behaviors: true });
run.frame();
run.frame();
run.world.syncToModel();
expect(asString(instanceOf(run, behavior).props[key] as never)).toContain('Crate');
expect(placedOf(f, behavior).overrides[key]).toEqual(override);
expect(() => JSON.stringify(serializeProject(f.project))).not.toThrow();
});
it('keeps the other fields of a struct flowing into the scene, with the object emptied', () => {
const f = makeSpriteScene('Crate');
const behavior = structHolder(f);
const run = startWorld(f, { behaviors: true });
run.frame();
const live = instanceOf(run, behavior).props;
live.held = { ...(live.held as object), count: 5 };
run.world.syncToModel();
expect(placedOf(f, behavior).overrides.held).toEqual({ target: NO_OBJECT, count: 5 });
});
it('keeps what a run holds when the model is read in again (editor run)', () => {
const f = makeSpriteScene('Crate');
const behavior = structHolder(f);
const run = startWorld(f, { behaviors: true });
run.frame();
run.world.syncToModel();
run.world.syncFromModel();
run.frame();
expect(run.world.log.at(-1)).toBe('Crate');
expect((instanceOf(run, behavior).props.held as unknown as Record<string, unknown>).target).toBe(
crates(f, run)[0],
);
});
it('empties objects however deep they sit, and leaves everything else as it is', () => {
const crate = fakeEntity('Crate');
const bag = {
target: crate,
held: { who: crate, n: 1 },
crowd: [crate, 2, { who: crate }],
plain: [1, 2],
} as unknown as PropertyBag;
const out = bagWithoutObjects(bag);
expect(out).toEqual({
target: NO_OBJECT,
held: { who: NO_OBJECT, n: 1 },
crowd: [NO_OBJECT, 2, { who: NO_OBJECT }],
plain: [1, 2],
});
expect(out.plain).toBe(bag.plain);
expect(JSON.parse(JSON.stringify(out))).toEqual(out);
});
it('reads a destroyed object as nothing alone, as the empty object inside a list or struct', () => {
const crate = fakeEntity('Crate');
const dead = () => false;
expect(liveValue(crate as never, dead)).toBeNull();
expect(liveValue(NO_OBJECT, () => true)).toBeNull();
expect(liveValue([crate, 1] as never, dead)).toEqual([NO_OBJECT, 1]);
expect(liveValue({ who: crate } as never, dead)).toEqual({ who: NO_OBJECT });
expect(liveValue([crate] as never, () => true)).toEqual([crate]);
});
it('names the objects of a list or struct when it reads as text', () => {
const crate = fakeEntity('Crate');
expect(asString({ who: crate, n: 1 } as never)).toBe('who: Crate, n: 1');
expect(asString([{ who: crate }] as never)).toBe('who: Crate');
});
});
describe('an object in a persistent Variables asset', () => {
/** On start: log what `target` holds, then spawn a Crate into it and into `crowd`, and count `score` up. */
function persistent() { function persistent() {
const f = makeSpriteScene('Crate'); const f = makeSpriteScene('Crate');
const shared = makeVariables(f, 'Progress', ['score']); const shared = makeVariables(f, 'Progress', ['score']);
f.stack.execute(new AddVariableCommand(shared.id, 'target', 'object')); f.stack.execute(new AddVariableCommand(shared.id, 'target', 'object'));
f.stack.execute(new AddVariableCommand(shared.id, 'crowd', 'list'));
f.stack.execute(new SetVariablesPersistentCommand(shared.id, true)); f.stack.execute(new SetVariablesPersistentCommand(shared.id, true));
const behavior = makeBehavior(f, 'Spawner'); const behavior = makeBehavior(f, 'Spawner');
const edit = graphEditor(f, behavior); const edit = graphEditor(f, behavior);
@@ -164,13 +343,16 @@ describe('an object variable in a persistent Variables asset', () => {
const log = edit.addNode('Log'); const log = edit.addNode('Log');
const spawn = edit.addNode('Spawn', { asset: f.spriteId }); const spawn = edit.addNode('Spawn', { asset: f.spriteId });
const add = edit.addNode('Add', { b: 1 }); const add = edit.addNode('Add', { b: 1 });
const write = edit.variables(['score', 'target'], { write: true, params }); const one = edit.addNode('Fill', { count: 1 });
const write = edit.variables(['score', 'target', 'crowd'], { write: true, params });
edit.wire([ edit.wire([
[edit.addNode('OnStart'), 'exec', log, 'exec'], [edit.addNode('OnStart'), 'exec', log, 'exec'],
[read, 'target', log, 'message'], [read, 'target', log, 'message'],
[log, 'exec', spawn, 'exec'], [log, 'exec', spawn, 'exec'],
[spawn, 'exec', write, 'exec'], [spawn, 'exec', write, 'exec'],
[spawn, 'object', write, 'target'], [spawn, 'object', write, 'target'],
[spawn, 'object', one, 'value'],
[one, 'result', write, 'crowd'],
[read, 'score', add, 'a'], [read, 'score', add, 'a'],
[add, 'result', write, 'score'], [add, 'result', write, 'score'],
]); ]);
@@ -185,9 +367,10 @@ describe('an object variable in a persistent Variables asset', () => {
const first = startWorld(f, { behaviors: true, saves: area }); const first = startWorld(f, { behaviors: true, saves: area });
first.frame(); first.frame();
expect(first.world.log).toEqual(['']); expect(first.world.log).toEqual(['']);
const held = readObjectSlot(first.behaviors!.shared.variables[shared.id]!, 'target', () => true); const live = first.behaviors!.shared.variables[shared.id]!;
expect(held?.name).toBe('Crate'); expect(asString(live.target as never)).toBe('Crate');
expect(saved).toEqual({ [shared.id]: { score: 1, target: '' } }); expect(asString(live.crowd as never)).toBe('Crate');
expect(saved).toEqual({ [shared.id]: { score: 1, target: NO_OBJECT, crowd: [NO_OBJECT] } });
const second = startWorld(f, { behaviors: true, saves: area }); const second = startWorld(f, { behaviors: true, saves: area });
second.frame(); second.frame();
expect(second.world.log).toEqual(['']); expect(second.world.log).toEqual(['']);
@@ -195,23 +378,25 @@ describe('an object variable in a persistent Variables asset', () => {
}); });
}); });
describe('object slots', () => { describe('a kept call (#229)', () => {
const handle = (name: string) => ({ name }) as unknown as EntityHandle; it('hands a kept object on to the next call, and only a kept one', () => {
const crate = fakeEntity('Crate');
it('hand a kept object on to the next call, and only a kept one', () => { const left: PropertyBag = { target: NO_OBJECT, other: NO_OBJECT, held: { who: NO_OBJECT } };
const left = { target: '', other: '' }; writeTyped(left, 'target', crate);
const crate = handle('Crate'); writeTyped(left, 'other', crate);
writeObjectSlot(left, 'target', crate); writeTyped(left, 'held', { who: crate } as never);
writeObjectSlot(left, 'other', crate); const defaults = { target: NO_OBJECT, other: NO_OBJECT, held: { who: NO_OBJECT } };
const next = resumedVariables({ target: '', other: '' }, left, ['target']); const next = resumedVariables(defaults, left, ['target', 'held']);
expect(readObjectSlot(next, 'target', () => true)).toBe(crate); expect(next.target).toBe(crate);
expect(readObjectSlot(next, 'other', () => true)).toBeNull(); expect(next.other).toBe(NO_OBJECT);
expect((next.held as unknown as Record<string, unknown>).who).toBe(crate);
}); });
it('forget an object that is gone, even when it would come back under the same id', () => { it('does not resume an object where the declaration now says text, or the other way round', () => {
const bag = { target: '' }; const crate = fakeEntity('Crate');
writeObjectSlot(bag, 'target', handle('Crate')); const left: PropertyBag = { a: NO_OBJECT, b: '', c: { x: 1 } };
expect(readObjectSlot(bag, 'target', () => false)).toBeNull(); writeTyped(left, 'a', crate);
expect(readObjectSlot(bag, 'target', () => true)).toBeNull(); const next = resumedVariables({ a: '', b: NO_OBJECT, c: NO_OBJECT }, left, ['a', 'b', 'c']);
expect(next).toEqual({ a: '', b: NO_OBJECT, c: NO_OBJECT });
}); });
}); });
+15 -2
View File
@@ -108,8 +108,8 @@ export async function listTab(t) {
'a list of its own offers every kind, the struct included', 'a list of its own offers every kind, the struct included',
(await json('__gm.list.info()')).offers.join(',') === (await json('__gm.list.info()')).offers.join(',') ===
// In the order of the kind table (`core/properties/kinds.ts`) – a new kind is a row there, // In the order of the kind table (`core/properties/kinds.ts`) – a new kind is a row there,
// and `animation` (#97) is the newest of them. // and `asset` (#237) and `object` (#238) are the newest of them.
'number,text,boolean,color,texture,statemachine,sound,scene,animation,struct', 'number,text,boolean,color,texture,statemachine,sound,scene,animation,asset,object,struct',
JSON.stringify((await json('__gm.list.info()')).offers), JSON.stringify((await json('__gm.list.info()')).offers),
); );
await pickKind('Text'); await pickKind('Text');
@@ -123,6 +123,19 @@ export async function listTab(t) {
); );
await undo(); await undo();
check('undo removes it again', (await json('__gm.list.info()')).items.length === 3); check('undo removes it again', (await json('__gm.list.info()')).items.length === 3);
// 21d-ii. An object item holds nothing in the file – only its place, filled while a game runs.
await clickText('+ Item');
await pickKind('Object');
await sleep(200);
info = await json('__gm.list.info()');
check(
'an Object item is added as the empty object',
info.kinds.join(',') === 'number,number,number,object' && info.items[3] === '',
JSON.stringify(info),
);
await shot('03a-object-item');
await undo();
await shot('03-rows'); await shot('03-rows');
// 21e. Back on the scene tab the button carries the current count. // 21e. Back on the scene tab the button carries the current count.
+3 -2
View File
@@ -319,14 +319,15 @@ export async function variableNodes(t) {
); );
// 23k. An object variable: declared from the menu like any other, but with nothing to type – it is // 23k. An object variable: declared from the menu like any other, but with nothing to type – it is
// set by a graph while the game runs, so the row says (null) and the file keeps no value. // set by a graph while the game runs, so the row says (null) and the file keeps only the
// empty object's place.
await t.select({ kind: 'asset', assetId: 'vp-logic' }); await t.select({ kind: 'asset', assetId: 'vp-logic' });
await clickLast('+ Variable'); await clickLast('+ Variable');
await clickLast('Object'); await clickLast('Object');
const declared = (await asset('vp-logic')).variables.at(-1); const declared = (await asset('vp-logic')).variables.at(-1);
check( check(
'an Object variable is declared from the menu and stores nothing as its value', 'an Object variable is declared from the menu and stores nothing as its value',
declared?.kind === 'object' && declared.default === '' && (await has('(null)')), declared?.kind === 'object' && declared.default?.$object === '' && (await has('(null)')),
JSON.stringify(declared), JSON.stringify(declared),
); );
await shot('06-object-variable'); await shot('06-object-variable');