/** * 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) => 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(); }); });