/** * 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, NESTED_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) => run.world.entities.filter((e) => e.assetId === f.spriteId); 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(NESTED_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; 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).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 = { isAlive: () => false, referenced: () => null }; const alive = { isAlive: () => true, referenced: () => null }; expect(liveValue(crate as never, dead)).toBeNull(); expect(liveValue(NO_OBJECT, alive)).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, alive)).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).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 }); }); });