/** * An object's place names another object of the same scene (#258): picked on a placed object, * stored as its id, the object itself once the game runs – and inside a container, each placement's * own copy of it. */ import { describe, expect, it } from 'vitest'; import { CreateContainerCommand, UnpackContainerCommand } from '../../src/core/commands/containerCommands'; import { PasteObjectsCommand } from '../../src/core/commands/pasteCommands'; import { AddPortsCommand } from '../../src/core/commands/portCommands'; import { PlaceAssetCommand, RemoveSceneObjectsCommand, RenameSceneObjectCommand, SetOverrideCommand, SetParentCommand, } from '../../src/core/commands/sceneObjectCommands'; import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands'; import type { BehaviorAsset, ContainerAsset } from '../../src/core/model/Asset'; import { copyObjects } from '../../src/core/model/clipboard'; import { instanceChildId } from '../../src/core/model/instance'; import { objectChoices, objectReference, referencedId } from '../../src/core/model/objectRefs'; import { NO_OBJECT } from '../../src/core/model/Properties'; import { findAsset } from '../../src/core/model/Project'; import { makeItem } from '../../src/core/properties/listItems'; import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema'; import { graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, type SceneFixture, startWorld, } from '../helpers'; /** A behavior whose object variable `target` is logged on every update. */ function watcher(f: SceneFixture): BehaviorAsset { const behavior = makeBehavior(f, 'Watcher'); f.stack.execute(new AddVariableCommand(behavior.id, 'target', 'object')); const edit = graphEditor(f, behavior); const log = edit.addNode('Log'); edit.wire([ [edit.addNode('OnUpdate'), 'exec', log, 'exec'], [edit.variables(['target']), 'target', log, 'message'], ]); return behavior; } /** A Watcher and a box called "Crate" in the scene, the Watcher pointed at the box. */ function watching() { const f = makeSpriteScene(); const behavior = watcher(f); const watcherId = place(f, behavior.id, { x: 0, y: 0 }); const crate = place(f, f.spriteId, { x: 10, y: 0 }); f.stack.execute(new RenameSceneObjectCommand(f.sceneId, crate, 'Crate')); f.stack.execute(new SetOverrideCommand(f.sceneId, watcherId, 'target', objectReference(crate))); return { ...f, behavior, watcherId, crate }; } describe('a picked object at rest', () => { it('is kept by the override, the file, a struct field and a list item', () => { const f = watching(); expect(f.object(f.watcherId).overrides.target).toEqual({ $object: f.crate }); const loaded = roundTrip(f.project); const scene = loaded.assets.find((a) => a.id === f.sceneId) as { objects: { id: string; overrides: object }[]; }; expect(scene.objects.find((o) => o.id === f.watcherId)!.overrides).toEqual({ target: { $object: f.crate }, }); const def = variableDef('held', 'struct', undefined, [{ key: 'who', kind: 'object' }]); expect(coerceValue(def, { who: objectReference('x') })).toEqual({ who: { $object: 'x' } }); expect(makeItem('object', objectReference('x'))).toEqual({ $object: 'x' }); }); it('is nothing for anything that names no object', () => { const def = variableDef('target', 'object'); expect(coerceValue(def, 'Crate')).toEqual(NO_OBJECT); expect(coerceValue(def, objectReference(''))).toEqual(NO_OBJECT); expect(referencedId('Crate')).toBe(''); }); }); describe('a picked object in a running game', () => { it('is the object it names, and nothing once that is destroyed', () => { const f = watching(); const run = startWorld(f, { behaviors: true }); run.frame(); expect(run.world.log.at(-1)).toBe('Crate'); run.world.remove(run.world.findByObjectId(f.crate)!); run.frame(); expect(run.world.log.at(-1)).toBe(''); }); it('is the object inside a struct field and a list item too', () => { const f = makeSpriteScene(); const behavior = makeBehavior(f, 'Holder'); 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')); const edit = graphEditor(f, behavior); const expand = edit.addNode('ExpandStruct'); f.stack.execute(new AddPortsCommand(behavior.id, expand.id, [{ key: 'who', kind: 'object' }])); const first = edit.addNode('ItemAt', { index: 0 }); const join = edit.addNode('Join'); const log = edit.addNode('Log'); const get = edit.variables(['held', 'crowd']); edit.wire([ [edit.addNode('OnUpdate'), 'exec', log, 'exec'], [get, 'held', expand, 'value'], [get, 'crowd', first, 'list'], [expand, 'who', join, 'a'], [first, 'item', join, 'b'], [join, 'text', log, 'message'], ]); const holder = place(f, behavior.id, { x: 0, y: 0 }); const crate = place(f, f.spriteId, { x: 0, y: 0 }); const barrel = place(f, f.spriteId, { x: 0, y: 0 }); f.stack.execute(new RenameSceneObjectCommand(f.sceneId, crate, 'Crate')); f.stack.execute(new RenameSceneObjectCommand(f.sceneId, barrel, 'Barrel')); f.stack.execute(new SetOverrideCommand(f.sceneId, holder, 'held', { who: objectReference(crate) })); f.stack.execute(new SetOverrideCommand(f.sceneId, holder, 'crowd', [objectReference(barrel)])); const run = startWorld(f, { behaviors: true }); run.frame(); expect(run.world.log.at(-1)).toBe('CrateBarrel'); }); it('stays in the scene when a graph puts another object into the place (editor run)', () => { const f = watching(); const edit = graphEditor(f, f.behavior); const spawn = edit.addNode('Spawn', { asset: f.spriteId }); const set = edit.variables(['target'], { write: true }); edit.wire([ [edit.addNode('OnStart'), 'exec', spawn, 'exec'], [spawn, 'exec', set, 'exec'], [spawn, 'object', set, 'target'], ]); const run = startWorld(f, { behaviors: true }); run.frame(); run.world.syncToModel(); run.world.syncFromModel(); run.frame(); expect(run.world.log.at(-1)).toBe('Box'); expect(f.object(f.watcherId).overrides.target).toEqual({ $object: f.crate }); }); it('follows a pick made while the game runs', () => { const f = watching(); const barrel = place(f, f.spriteId, { x: 0, y: 0 }); f.stack.execute(new RenameSceneObjectCommand(f.sceneId, barrel, 'Barrel')); const run = startWorld(f, { behaviors: true }); run.frame(); f.stack.execute(new SetOverrideCommand(f.sceneId, f.watcherId, 'target', objectReference(barrel))); run.world.syncFromModel(); run.frame(); expect(run.world.log.slice(-2)).toEqual(['Crate', 'Barrel']); }); }); describe('a picked object inside a container', () => { /** The Watcher and its Crate made into a container, placed a second time beside the first. */ function contained() { const f = watching(); const create = new CreateContainerCommand(f.sceneId, [f.watcherId, f.crate]); f.stack.execute(create); const second = new PlaceAssetCommand(f.sceneId, create.assetId, { x: 500, y: 0 }); f.stack.execute(second); const asset = findAsset(f.project, create.assetId) as ContainerAsset; const [tWatcher, tCrate] = asset.objects.map((o) => o.id) as [string, string]; return { ...f, create, asset, tWatcher, tCrate, first: create.objectId, second: second.objectId }; } it('names the template copy, and each placement reaches its own', () => { const f = contained(); expect(f.asset.objects[0]!.overrides.target).toEqual({ $object: f.tCrate }); const run = startWorld(f); for (const placed of [f.first, f.second]) { const watcher = run.world.findByObjectId(instanceChildId(placed, f.tWatcher))!; const found = run.world.findReferenced(watcher, f.tCrate); expect(found?.objectId).toBe(instanceChildId(placed, f.tCrate)); } }); it('offers the content of its own placement, named as the template names it', () => { const f = contained(); const choices = objectChoices(f.project, f.sceneOf(), instanceChildId(f.second, f.tWatcher)); expect(choices.map((c) => [c.ref, c.depth])).toEqual([ [f.tWatcher, 0], [f.tCrate, 0], ]); }); it('points a reference from outside at what the placed container shows', () => { const f = watching(); const other = place(f, f.behavior.id, { x: 0, y: 0 }); f.stack.execute(new SetOverrideCommand(f.sceneId, other, 'target', objectReference(f.crate))); const create = new CreateContainerCommand(f.sceneId, [f.crate]); f.stack.execute(create); const tCrate = (findAsset(f.project, create.assetId) as ContainerAsset).objects[0]!.id; expect(f.object(other).overrides.target).toEqual({ $object: instanceChildId(create.objectId, tCrate) }); f.stack.undo(); expect(f.object(other).overrides.target).toEqual({ $object: f.crate }); }); it('is spelled out as the copy it names when the container is unpacked', () => { const f = contained(); f.stack.execute(new UnpackContainerCommand(f.sceneId, f.second)); const watcher = f.object(instanceChildId(f.second, f.tWatcher)); expect(watcher.overrides.target).toEqual({ $object: instanceChildId(f.second, f.tCrate) }); }); }); describe('the objects of a scene', () => { it('are offered in hierarchy order, the object itself included', () => { const f = watching(); const lid = place(f, f.spriteId, { x: 0, y: 0 }); f.stack.execute(new SetParentCommand(f.sceneId, lid, f.crate)); f.stack.execute(new RenameSceneObjectCommand(f.sceneId, lid, 'Lid')); const choices = objectChoices(f.project, f.sceneOf(), f.watcherId); expect(choices.map((c) => [c.label, c.depth])).toEqual([ ['Watcher', 0], ['Crate', 0], ['Lid', 1], ]); expect(choices[1]!.ref).toBe(f.crate); }); }); describe('removing and pasting', () => { it('drops a reference to a removed object, and undo brings it back', () => { const f = watching(); f.stack.execute(new RemoveSceneObjectsCommand(f.sceneId, [f.crate])); expect(f.object(f.watcherId).overrides.target).toEqual(NO_OBJECT); f.stack.undo(); expect(f.object(f.watcherId).overrides.target).toEqual({ $object: f.crate }); }); it('points a pasted copy at the copy of what it named, and leaves one out of the copy alone', () => { const f = watching(); const both = copyObjects(f.project, f.sceneOf(), [f.watcherId, f.crate])!; const pasteBoth = new PasteObjectsCommand(f.sceneId, both, {}, { x: 5, y: 0 }); f.stack.execute(pasteBoth); const [watcherCopy, crateCopy] = pasteBoth.rootIds as [string, string]; expect(f.object(watcherCopy).overrides.target).toEqual({ $object: crateCopy }); const alone = copyObjects(f.project, f.sceneOf(), [f.watcherId])!; const pasteAlone = new PasteObjectsCommand(f.sceneId, alone, {}, { x: 5, y: 0 }); f.stack.execute(pasteAlone); expect(f.object(pasteAlone.rootIds[0]!).overrides.target).toEqual({ $object: f.crate }); }); });