Files
feuerwerk/tests/core/object-variable.test.ts
T
adminandClaude Opus 5.5 7979b8ff19
game-maker / gate (push) Successful in 3m28s
game-maker / suite (push) Successful in 10m14s
Let structs and lists hold objects across behavior runs (#238)
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>
2026-09-26 21:12:44 +02:00

403 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { 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,
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,
makeVariables,
place,
roundTrip,
type SceneFixture,
startWorld,
} from '../helpers';
/** Spawns a Crate on start into `spawned`, and logs what `spawned` holds on every update. */
function spawner(f: SceneFixture): BehaviorAsset {
const behavior = makeBehavior(f, 'Spawner');
f.stack.execute(new AddVariableCommand(behavior.id, 'spawned', 'object'));
const edit = graphEditor(f, behavior);
const spawn = edit.addNode('Spawn', { asset: f.spriteId });
const set = edit.variables(['spawned'], { write: true });
const get = edit.variables(['spawned']);
const log = edit.addNode('Log');
edit.wire([
[edit.addNode('OnStart'), 'exec', spawn, 'exec'],
[spawn, 'exec', set, 'exec'],
[spawn, 'object', set, 'spawned'],
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
[get, 'spawned', log, 'message'],
]);
place(f, behavior.id, { x: 0, y: 0 });
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>) =>
run.world.entities.filter((e) => e.assetId === f.spriteId);
const instanceOf = (run: ReturnType<typeof startWorld>, 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(STRUCT_FIELD_KINDS).toContain('object');
expect(LIST_ITEM_KINDS).toContain('object');
expect(isKindWith('tagged', 'object')).toBe(true);
});
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: 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 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 });
for (let i = 0; i < 3; i++) run.frame();
expect(crates(f, run)).toHaveLength(1);
expect(run.world.log.slice(-2)).toEqual(['Crate', 'Crate']);
});
it('stays in a struct field on the next frames, beside the other fields', () => {
const f = makeSpriteScene('Crate');
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 });
run.frame();
run.world.remove(crates(f, run)[0]!);
run.frame();
expect(run.world.log.at(-1)).toBe('');
});
it('tells "gone" by Equals against an empty input', () => {
const f = makeSpriteScene('Crate');
const behavior = spawner(f);
const edit = graphEditor(f, behavior);
const get = edit.variables(['spawned']);
const gone = edit.addNode('Equals');
const log = edit.addNode('Log');
edit.wire([
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
[get, 'spawned', gone, 'a'],
[gone, 'result', log, 'message'],
]);
const run = startWorld(f, { behaviors: true });
run.frame();
const alive = run.world.log.at(-1);
run.world.remove(crates(f, run)[0]!);
run.frame();
expect([alive, run.world.log.at(-1)]).toEqual(['false', 'true']);
});
it('is read and written on another behavior through Properties', () => {
const f = makeSpriteScene('Crate');
const holder = makeBehavior(f, 'Holder');
f.stack.execute(new AddVariableCommand(holder.id, 'target', 'object'));
const writer = makeBehavior(f, 'Writer');
const edit = graphEditor(f, writer);
const find = edit.addNode('ObjectByName', { name: 'Holder' });
const crate = edit.addNode('ObjectByName', { name: 'Crate' });
const set = edit.properties([{ key: 'target', kind: 'object', write: true }], {
objectType: 'behavior',
objectBehavior: holder.id,
});
const get = edit.properties([{ key: 'target', kind: 'object' }], {
objectType: 'behavior',
objectBehavior: holder.id,
});
const log = edit.addNode('Log');
edit.wire([
[edit.addNode('OnStart'), 'exec', set, 'exec'],
[find, 'object', set, 'object'],
[crate, 'object', set, 'target'],
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
[find, 'object', get, 'object'],
[get, 'target', log, 'message'],
]);
place(f, holder.id, { x: 0, y: 0 });
place(f, writer.id, { x: 0, y: 0 });
place(f, f.spriteId, { x: 0, y: 0 });
const run = startWorld(f, { behaviors: true });
run.frame();
run.frame();
expect(run.world.log.at(-1)).toBe('Crate');
});
});
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<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() {
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);
const params = { [VARIABLES_PARAM]: shared.id };
const read = edit.variables(['score', 'target'], { params });
const log = edit.addNode('Log');
const spawn = edit.addNode('Spawn', { asset: f.spriteId });
const add = edit.addNode('Add', { b: 1 });
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'],
]);
place(f, behavior.id, { x: 0, y: 0 });
return { f, shared };
}
it('is saved as nothing and starts the next run at nothing, beside the kept values', () => {
const { f, shared } = persistent();
let saved: SavedValues = {};
const area: SaveArea = { read: () => structuredClone(saved), write: (s) => (saved = structuredClone(s)) };
const first = startWorld(f, { behaviors: true, saves: area });
first.frame();
expect(first.world.log).toEqual(['']);
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(['']);
expect(second.behaviors!.shared.variables[shared.id]!.score).toBe(2);
});
});
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<string, unknown>).who).toBe(crate);
});
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 });
});
});