Let a variable hold an object across behavior runs
game-maker / gate (push) Successful in 3m15s
game-maker / suite (push) Failing after 9m2s

A spawned object was only reachable in the run that spawned it. The
Spawn output could not go into a variable because "object" was a
port-only kind, so the next run had no way back to the instance.

- "Object" is now a variable kind. The reference lives for the run
  only. The file stores the declaration and an empty value.
- The live reference sits in slots beside the variable bag
  (graph/objectSlots.ts), not in it. The bag is the behavior's props,
  and syncToModel would have written an object into the scene.
- A destroyed object, or one from a restarted or left scene, reads as
  nothing. A dead reference is dropped from its slot.
- Keep (#229) hands the slots of kept variables on to the next call
  of a sub-behavior.
- Properties of kind Behavior reads and writes another behavior's
  object variable through the same slots.
- The inspector shows "(null)" for it and offers no input.
- A persistent Variables asset with an object variable shows a
  warning under "Persistent". Saved values never keep the object,
  every run starts it at (null).

Objects inside lists and struct fields are left out on purpose.
Emptiness is checked with Equals against an empty b. A node "Is
valid" does not exist yet.

The test helper graphEditor().variables() now also reads the kinds
of a Variables asset a node points to.

Tests:
- ./check.sh: 169 files, 2463 tests, CHECK EXIT 0
- e2e 6 + 23: ALL PASSED, 146 checks (new 23k, 2 checks)
- e2e 32: ALL PASSED, 18 checks (new 32f-ii)
- 10 unit tests in tests/core/object-variable.test.ts, one of them
  runs a persistent asset over two runs
- 5 counter-probes (value in bag, isAlive always true, no Keep
  hand-over, Properties without slots, object in saved values) each
  fail their test
- e2e check total 1406, computed only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-26 20:15:16 +02:00
1 parent 923baf2557
commit 98211a8d54
16 files changed
+375 -11

No files matched your search

+217
View File
@@ -0,0 +1,217 @@
/**
* 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.
*/
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 type { BehaviorAsset } from '../../src/core/model/Asset';
import { findScene } from '../../src/core/model/Project';
import { isKindWith, VARIABLE_KINDS } from '../../src/core/properties/kinds';
import { serializeProject } from '../../src/core/store/serialize';
import {
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;
}
const crates = (f: SceneFixture, run: ReturnType<typeof startWorld>) =>
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', () => {
expect(VARIABLE_KINDS).toContain('object');
expect(isKindWith('field', 'object')).toBe(false);
});
it('is stored as its declaration only, with nothing as its value', () => {
const f = makeSpriteScene('Crate');
const behavior = makeBehavior(f, 'Holder');
f.stack.execute(new AddVariableCommand(behavior.id, 'target', 'object'));
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: '' });
});
});
describe('an object variable in a running game', () => {
it('still holds the spawned object 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('reads as nothing once the object is destroyed', () => {
const f = makeSpriteScene('Crate');
spawner(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('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');
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('an object variable in a persistent Variables asset', () => {
/** On start: log what `target` holds, then spawn a Crate into it 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 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 write = edit.variables(['score', 'target'], { 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'],
[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 held = readObjectSlot(first.behaviors!.shared.variables[shared.id]!, 'target', () => true);
expect(held?.name).toBe('Crate');
expect(saved).toEqual({ [shared.id]: { score: 1, target: '' } });
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('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();
});
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();
});
});