diff --git a/README.md b/README.md index 0be7cbba..1e3583e8 100644 --- a/README.md +++ b/README.md @@ -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: ```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 node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index e4fae962..6787dc5d 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -1,7 +1,7 @@ import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph'; import { type GraphLookup, NO_LOOKUP } from './lookup'; 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 CallResult, @@ -12,9 +12,8 @@ import { type PortDef, type RepeatResult, } from './NodeRegistry'; -import { carryObjectSlots } from './objectSlots'; 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. */ const MAX_STEPS_PER_FIRE = 100_000; @@ -57,12 +56,16 @@ export function resumedVariables( const variables: PropertyBag = {}; for (const [key, value] of Object.entries(defaults)) { 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; } +/** 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 * exec edges and pulls data inputs from the connected output, evaluating pure data nodes lazily and diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 6a69293e..a76d9ce1 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -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 { flatDef, type PropertyDef } from '../properties/PropertySchema'; import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph'; import type { GraphLookup } from './lookup'; import type { NodeContext } from './NodeContext'; import type { SharedPortGroup } from './portTypes'; -import { asList, storedList, type Value } from './values'; +import { asList, namedValue, type Value } from './values'; export interface PortDef { key: string; @@ -192,9 +192,9 @@ export function withInlineDefault(port: PortDef): PortDef { export function inlineInputDef(port: PortDef): PropertyDef { if (port.field) return { ...port.field, key: port.key, label: port.label }; 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') - 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 def = flatDef(port.key, flat, { label: port.label, value: port.default }); return def.kind === 'number' ? { ...def, step: 1 } : def; diff --git a/src/core/graph/liveObjects.ts b/src/core/graph/liveObjects.ts new file mode 100644 index 00000000..b7453af8 --- /dev/null +++ b/src/core/graph/liveObjects.ts @@ -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(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 { + const values: Record = {}; + 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; +} diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index 964b868f..22ff7e51 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -110,10 +110,15 @@ export function isTerminalNode(node: GraphNode): boolean { 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 { 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. */ diff --git a/src/core/graph/nodes/data/ExpandStruct.ts b/src/core/graph/nodes/data/ExpandStruct.ts index 0987fb0c..2d15178c 100644 --- a/src/core/graph/nodes/data/ExpandStruct.ts +++ b/src/core/graph/nodes/data/ExpandStruct.ts @@ -1,5 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; -import { asStruct } from '../../values'; +import { asStruct, unplaced, type Value } from '../../values'; import { struct } from '../shared'; /** @@ -19,5 +19,9 @@ export const ExpandStruct: NodeDef = { params: [], declaresPorts: 'outputs', // 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 = {}; + for (const [key, field] of Object.entries(asStruct(inputs.value))) outputs[key] = unplaced(field) ?? null; + return { outputs }; + }, }; diff --git a/src/core/graph/nodes/data/ItemAt.ts b/src/core/graph/nodes/data/ItemAt.ts index 305a74fc..dd31cb67 100644 --- a/src/core/graph/nodes/data/ItemAt.ts +++ b/src/core/graph/nodes/data/ItemAt.ts @@ -1,6 +1,6 @@ import { untag } from '../../../model/Properties'; import type { NodeDef } from '../../NodeRegistry'; -import { asList, asNumber, isEntity } from '../../values'; +import { asList, asNumber, isEntity, unplaced } from '../../values'; import { any, int, list } from '../shared'; 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 // objects, as `Raycast all` hands one out (#157). if (isEntity(item)) return { outputs: { item } }; + if (unplaced(item) === null) return { outputs: { item: null } }; return { outputs: { item: untag(item) ?? 0 } }; }, params: [], diff --git a/src/core/graph/nodes/data/Properties.ts b/src/core/graph/nodes/data/Properties.ts index 2d78edca..482ee4eb 100644 --- a/src/core/graph/nodes/data/Properties.ts +++ b/src/core/graph/nodes/data/Properties.ts @@ -6,7 +6,7 @@ import { propertyPort, propertyTargetOf, } from '../../propertyPorts'; -import { readWithObjects, writeWithObjects } from '../../objectSlots'; +import { readLive, writePort } from '../../liveObjects'; import { asEntity } from '../../values'; import { exec, obj } from '../shared'; @@ -28,16 +28,15 @@ export const Properties: NodeDef = { const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {}; 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) => { 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) => { const target = asEntity(inputs.object); if (target) 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' }; }, }; diff --git a/src/core/graph/nodes/variable/Variables.ts b/src/core/graph/nodes/variable/Variables.ts index b076af75..00af54ad 100644 --- a/src/core/graph/nodes/variable/Variables.ts +++ b/src/core/graph/nodes/variable/Variables.ts @@ -1,6 +1,6 @@ import { collapsedTitle, portDefOf, VARIABLES, VARIABLES_SETTING, variablesSourceOf } from '../../nested'; import { type NodeDef, withInlineDefault } from '../../NodeRegistry'; -import { readWithObjects, writeWithObjects } from '../../objectSlots'; +import { readLive, writePort } from '../../liveObjects'; import { variableBag } from '../../sharedState'; import { exec } from '../shared'; @@ -26,10 +26,10 @@ export const Variables: NodeDef = { return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'variables') } : shape; }, 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) => { 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' }; }, }; diff --git a/src/core/graph/objectSlots.ts b/src/core/graph/objectSlots.ts deleted file mode 100644 index bb291592..00000000 --- a/src/core/graph/objectSlots.ts +++ /dev/null @@ -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>(); - -/** - * 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())); - 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 { - const objects = outputs.filter((port) => port.type === 'object'); - if (objects.length === 0) return bag; - const values: Record = { ...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); -} diff --git a/src/core/graph/savedValues.ts b/src/core/graph/savedValues.ts index 7032a9d4..fef5c9b9 100644 --- a/src/core/graph/savedValues.ts +++ b/src/core/graph/savedValues.ts @@ -3,6 +3,7 @@ import type { PropertyBag, PropertyValue } from '../model/Properties'; import type { Project } from '../model/Project'; import { sharedScopeOf } from '../model/Scope'; 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. */ export type SavedValues = Record; @@ -54,7 +55,8 @@ export function savedSnapshot(project: Project, variables: Record 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 = {}; + 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); diff --git a/src/core/model/Properties.ts b/src/core/model/Properties.ts index bb21c2fb..58583b51 100644 --- a/src/core/model/Properties.ts +++ b/src/core/model/Properties.ts @@ -44,6 +44,13 @@ export function tagged(kind: TaggedKind, value: number | string): 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. */ export function untag(value: PropertyValue | undefined): PropertyValue | undefined { 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. */ 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) || a.length !== b.length) return false; 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; } +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. */ export function setBagValue(bag: PropertyBag, key: string, value: PropertyValue | undefined): void { if (value === undefined) delete bag[key]; diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index bf3bd7b2..238d37bd 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -7,6 +7,7 @@ import { type ListValue, type PropertyBag, type PropertyValue, + NO_OBJECT, type StructValue, textOf, } from '../model/Properties'; @@ -86,12 +87,13 @@ type Extras = K extends 'number' : 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 * (see `isRowShown`), everything else – graph, defaults, files – always has it. */ export type FlatDef = { - [P in K]: { + [P in Exclude]: { key: string; label: string; kind: P; @@ -100,7 +102,7 @@ export type FlatDef = { initial?: ValueOf

; shownWith?: string; } & Extras

; -}[K]; +}[Exclude]; /** The value a new asset starts with, which is also what Reset goes back to. */ export function initialOf(def: PropertyDef): PropertyValue { @@ -161,9 +163,7 @@ export type PropertyDef = | FlatDef /** * 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` - * item kind: a list is stored in the project file, and an object is a live handle that no reload - * survives. + * and the graph types a list as a list, so one `list` port fits them all. */ | { key: string; label: string; kind: 'list'; default: ListValue } /** @@ -177,10 +177,7 @@ export type PropertyDef = default: StructValue; fields: readonly StructFieldDef[]; } - /** - * 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`). - */ + /** An object variable holds a live handle during a run, and nothing at rest (`NO_OBJECT`). */ | ObjectDef; export interface ObjectDef { @@ -190,8 +187,7 @@ export interface ObjectDef { default: typeof NO_OBJECT; } -/** What an object variable stores – always nothing, since an object exists only while the game runs. */ -export const NO_OBJECT = ''; +export { NO_OBJECT }; export interface PrimitiveFieldDef { 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. */ export function emptyFieldValue(field: StructFieldDef): FieldValue { + if (field.kind === 'object') return NO_OBJECT; 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`. */ 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); } diff --git a/src/core/properties/kinds.ts b/src/core/properties/kinds.ts index 337f67d5..93a99534 100644 --- a/src/core/properties/kinds.ts +++ b/src/core/properties/kinds.ts @@ -158,8 +158,21 @@ export const KINDS = [ field: 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: 'struct', label: 'Struct', travels: 'struct', variable: true, port: true }, // Schema-only kinds: a setting has them, nothing a user declares does. diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index 7df7f12d..8b2a3faf 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -4,6 +4,7 @@ * from the runtime copy `state.enterPlay()` swaps in and `exitPlay()` throws away. */ import { emptySharedState, type SharedState, worldPositionOf } from '../core/graph'; +import { namedValue } from '../core/graph/values'; import { addGameSystems, type BehaviorSystem, @@ -259,6 +260,7 @@ export class PlaySession { 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 { const world = this.world; if (!world) return null; @@ -274,12 +276,24 @@ export class PlaySession { worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', - props: { ...e.props }, + props: namedBag(e.props), animations: structuredClone(e.animations ?? []), }; }), - shared: this.behaviors?.shared ?? emptySharedState(), + shared: namedShared(this.behaviors?.shared ?? emptySharedState()), log: [...world.log], }; } } + +function namedBag(bag: PropertyBag): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(bag)) out[key] = namedValue(value); + return out; +} + +function namedShared(shared: SharedState): SharedState { + const variables: Record = {}; + for (const [id, bag] of Object.entries(shared.variables)) variables[id] = namedBag(bag) as PropertyBag; + return { variables, states: shared.states }; +} diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 53a6b65a..c508e805 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -6,6 +6,7 @@ import { SetVariablesPersistentCommand, SetVariablesScopeCommand, } from '../../core/commands/assetCommands'; +import { bagWithoutObjects } from '../../core/graph/liveObjects'; import { isPersistent } from '../../core/graph/savedValues'; import { nextFieldName, nextVariableName } from '../../core/commands/naming'; import { @@ -179,7 +180,7 @@ function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): export function shownValues(host: InspectorHost, asset: Asset): PropertyBag { const values = resolveAssetProps(host.state.project, asset); 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. */ diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 4cdf16dc..798c56fe 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -1,7 +1,8 @@ import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from '../core/graph'; import { newId } from '../core/ids'; 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 { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance'; import { treeOrder } from '../core/model/hierarchy'; @@ -245,7 +246,9 @@ export class World { entity.z = object.position.z; const resolved = resolveSceneObjectProps(this.project, asset, object); 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; } @@ -305,14 +308,18 @@ export class World { const p = object.position; const moved = p.x !== entity.x || p.y !== entity.y || p.z !== entity.z; 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; // 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. const ref = objectRef(this.project, scene, object.id); if (!ref) continue; 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; } return { structure: spawned || scene.objects.length !== before, values }; diff --git a/tests/core/kinds.test.ts b/tests/core/kinds.test.ts index 14d309f4..f54a8c29 100644 --- a/tests/core/kinds.test.ts +++ b/tests/core/kinds.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest'; import { AddVariableCommand } from '../../src/core/commands/variableCommands'; import { declaredPortType, portDefOf } from '../../src/core/graph'; 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 { coerceField, @@ -29,7 +29,8 @@ import { itemKindOf, listItemOf, makeItem } from '../../src/core/properties/list import { deserializeProject } from '../../src/core/store/serialize'; 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. */ 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', () => { for (const kind of TAGGED) { 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', () => { for (const kind of DECLARED_PORT_KINDS) { 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); } }); }); diff --git a/tests/core/list-value.test.ts b/tests/core/list-value.test.ts index 3b6a6a6a..6715f36d 100644 --- a/tests/core/list-value.test.ts +++ b/tests/core/list-value.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { AddVariableCommand } from '../../src/core/commands/variableCommands'; 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 { deserializeProject } from '../../src/core/store/serialize'; import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema'; @@ -14,10 +14,10 @@ import { asNumber, asString, asStruct, - storedList, type Value, writeTyped, } from '../../src/core/graph'; +import { bagWithoutObjects } from '../../src/core/graph/liveObjects'; import { IndexOf } from '../../src/core/graph/nodes/data/IndexOf'; import { NO_LOOKUP } from '../../src/core/graph/lookup'; 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', () => { expect(asList([hero, crate])).toEqual([hero, crate]); 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', () => { - // The one door into a PropertyBag: whatever arrives is narrowed to what a stored list may hold. + it('keeps its objects in a run’s bag, and lets none of them out of it', () => { const bag: PropertyBag = { seen: ['x'] }; - writeTyped(bag, 'seen', [hero, crate]); - expect(bag.seen).toEqual(['Hero', 'Crate']); - expect(JSON.parse(JSON.stringify(bag))).toEqual({ seen: ['Hero', 'Crate'] }); + writeTyped(bag, 'seen', [hero, 3, crate]); + expect(bag.seen).toEqual([hero, 3, 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', () => { diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index eb9e2ab2..5abab46d 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -85,6 +85,7 @@ const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y const emitTarget = fakeEntity('Sparks'); /** Their own entity each: the animation nodes leave players on the object they are handed. */ const dancer = fakeEntity('Dancer'); +const packed = fakeEntity('Player'); const painted = fakeHandle({ width: 100, text: '' }); type PhysicsDouble = PhysicsControl & { calls: unknown[][] }; @@ -668,9 +669,9 @@ const CASES: Record = { }, { 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', - inputs: { who: fakeEntity('Player') }, - outputs: { value: { who: 'Player' } }, + name: 'an entity packs as the object it is, so a struct can hold one', + inputs: { who: packed }, + outputs: { value: { who: packed } as never }, }, ], ExpandStruct: [ diff --git a/tests/core/object-variable.test.ts b/tests/core/object-variable.test.ts index 38a25dd0..a924fdd5 100644 --- a/tests/core/object-variable.test.ts +++ b/tests/core/object-variable.test.ts @@ -1,18 +1,34 @@ /** - * An object as a variable: a spawned object written into one is there on the next frame, a - * destroyed one reads as nothing, and no handle ever reaches the scene, the file or a save. + * Objects in variables, struct fields and list items: a spawned object written into one is there on + * 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 { SetVariablesPersistentCommand } from '../../src/core/commands/assetCommands'; -import { AddVariableCommand } from '../../src/core/commands/variableCommands'; -import { resumedVariables, type SaveArea, type SavedValues, VARIABLES_PARAM } from '../../src/core/graph'; -import type { EntityHandle } from '../../src/core/graph/NodeContext'; -import { readObjectSlot, writeObjectSlot } from '../../src/core/graph/objectSlots'; +import { AddPortsCommand } from '../../src/core/commands/portCommands'; +import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands'; +import { + 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 { NO_OBJECT, type PropertyBag } from '../../src/core/model/Properties'; 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 { + fakeEntity, graphEditor, makeBehavior, makeSpriteScene, @@ -43,26 +59,99 @@ function spawner(f: SceneFixture): BehaviorAsset { 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) => run.world.entities.filter((e) => e.assetId === f.spriteId); -describe('the object kind as a variable', () => { - it('can be declared, but has no field and no list item', () => { +const instanceOf = (run: ReturnType, behavior: BehaviorAsset) => + 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(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 behavior = makeBehavior(f, 'Holder'); 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; - 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', () => { - it('still holds the spawned object on the next frames', () => { +describe('an object held by a running game', () => { + it('stays in a variable on the next frames', () => { const f = makeSpriteScene('Crate'); spawner(f); 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']); }); - 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'); - 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; + 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 }); run.frame(); 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']); }); - 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', () => { const f = makeSpriteScene('Crate'); 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', () => { - /** On start: log what `target` holds, then spawn a Crate into it and count `score` up. */ +describe('no object leaves the run', () => { + 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).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() { const f = makeSpriteScene('Crate'); const shared = makeVariables(f, 'Progress', ['score']); 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)); const behavior = makeBehavior(f, 'Spawner'); const edit = graphEditor(f, behavior); @@ -164,13 +343,16 @@ describe('an object variable in a persistent Variables asset', () => { const log = edit.addNode('Log'); const spawn = edit.addNode('Spawn', { asset: f.spriteId }); 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.addNode('OnStart'), 'exec', log, 'exec'], [read, 'target', log, 'message'], [log, 'exec', spawn, 'exec'], [spawn, 'exec', write, 'exec'], [spawn, 'object', write, 'target'], + [spawn, 'object', one, 'value'], + [one, 'result', write, 'crowd'], [read, 'score', add, 'a'], [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 }); first.frame(); expect(first.world.log).toEqual(['']); - const held = readObjectSlot(first.behaviors!.shared.variables[shared.id]!, 'target', () => true); - expect(held?.name).toBe('Crate'); - expect(saved).toEqual({ [shared.id]: { score: 1, target: '' } }); + const live = first.behaviors!.shared.variables[shared.id]!; + expect(asString(live.target as never)).toBe('Crate'); + 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 }); second.frame(); expect(second.world.log).toEqual(['']); @@ -195,23 +378,25 @@ describe('an object variable in a persistent Variables asset', () => { }); }); -describe('object slots', () => { - const handle = (name: string) => ({ name }) as unknown as EntityHandle; - - it('hand a kept object on to the next call, and only a kept one', () => { - const left = { target: '', other: '' }; - const crate = handle('Crate'); - writeObjectSlot(left, 'target', crate); - writeObjectSlot(left, 'other', crate); - const next = resumedVariables({ target: '', other: '' }, left, ['target']); - expect(readObjectSlot(next, 'target', () => true)).toBe(crate); - expect(readObjectSlot(next, 'other', () => true)).toBeNull(); +describe('a kept call (#229)', () => { + it('hands a kept object on to the next call, and only a kept one', () => { + const crate = fakeEntity('Crate'); + const left: PropertyBag = { target: NO_OBJECT, other: NO_OBJECT, held: { who: NO_OBJECT } }; + writeTyped(left, 'target', crate); + writeTyped(left, 'other', crate); + writeTyped(left, 'held', { who: crate } as never); + const defaults = { target: NO_OBJECT, other: NO_OBJECT, held: { who: NO_OBJECT } }; + const next = resumedVariables(defaults, left, ['target', 'held']); + expect(next.target).toBe(crate); + expect(next.other).toBe(NO_OBJECT); + expect((next.held as unknown as Record).who).toBe(crate); }); - it('forget an object that is gone, even when it would come back under the same id', () => { - const bag = { target: '' }; - writeObjectSlot(bag, 'target', handle('Crate')); - expect(readObjectSlot(bag, 'target', () => false)).toBeNull(); - expect(readObjectSlot(bag, 'target', () => true)).toBeNull(); + it('does not resume an object where the declaration now says text, or the other way round', () => { + const crate = fakeEntity('Crate'); + const left: PropertyBag = { a: NO_OBJECT, b: '', c: { x: 1 } }; + writeTyped(left, 'a', crate); + const next = resumedVariables({ a: '', b: NO_OBJECT, c: NO_OBJECT }, left, ['a', 'b', 'c']); + expect(next).toEqual({ a: '', b: NO_OBJECT, c: NO_OBJECT }); }); }); diff --git a/tools/e2e/21-list.mjs b/tools/e2e/21-list.mjs index 1f84affe..41387459 100644 --- a/tools/e2e/21-list.mjs +++ b/tools/e2e/21-list.mjs @@ -108,8 +108,8 @@ export async function listTab(t) { 'a list of its own offers every kind, the struct included', (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, - // and `animation` (#97) is the newest of them. - 'number,text,boolean,color,texture,statemachine,sound,scene,animation,struct', + // and `asset` (#237) and `object` (#238) are the newest of them. + 'number,text,boolean,color,texture,statemachine,sound,scene,animation,asset,object,struct', JSON.stringify((await json('__gm.list.info()')).offers), ); await pickKind('Text'); @@ -123,6 +123,19 @@ export async function listTab(t) { ); await undo(); 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'); // 21e. Back on the scene tab the button carries the current count. diff --git a/tools/e2e/23-variable-nodes.mjs b/tools/e2e/23-variable-nodes.mjs index 6982843b..c756edd1 100644 --- a/tools/e2e/23-variable-nodes.mjs +++ b/tools/e2e/23-variable-nodes.mjs @@ -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 - // 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 clickLast('+ Variable'); await clickLast('Object'); const declared = (await asset('vp-logic')).variables.at(-1); check( '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), ); await shot('06-object-variable');