/** * 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 { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/assetCommands'; 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, resolvedHolder } from '../../src/core/model/instance'; import { objectChoices, objectReference, referenceCandidates, referencedId, } from '../../src/core/model/objectRefs'; import { NO_OBJECT } from '../../src/core/model/Properties'; import { findAsset, findObjectHolder } 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 }); }); }); describe('removing an object from a container template (#269)', () => { /** * Watcher, Crate and Barrel made into a container whose Watcher points at nothing, placed in * "Level" and in "Level 2", each placement's Watcher pointed at its own Crate. */ function placedTwice() { const f = watching(); const barrel = place(f, f.spriteId, { x: 20, y: 0 }); const create = new CreateContainerCommand(f.sceneId, [f.watcherId, f.crate, barrel]); f.stack.execute(create); const asset = findAsset(f.project, create.assetId) as ContainerAsset; const [tWatcher, tCrate, tBarrel] = asset.objects.map((o) => o.id) as [string, string, string]; f.stack.execute(new SetOverrideCommand(asset.id, tWatcher, 'target', NO_OBJECT)); const level2 = new CreateAssetCommand('scene', 'Level 2'); f.stack.execute(level2); const other = new PlaceAssetCommand(level2.asset.id, asset.id, { x: 0, y: 0 }); f.stack.execute(other); const placements = [ [f.sceneId, create.objectId], [level2.asset.id, other.objectId], ] as const; for (const [sceneId, placed] of placements) f.stack.execute( new SetOverrideCommand(sceneId, instanceChildId(placed, tWatcher), 'target', objectReference(tCrate)), ); return { ...f, asset, tWatcher, tCrate, tBarrel, level2: level2.asset.id, placements }; } /** What the inspector shows as `target` on the object, container content resolved. */ function targetOf(f: SceneFixture, holderId: string, objectId: string) { const holder = resolvedHolder(f.project, findObjectHolder(f.project, holderId)!); return holder.objects.find((o) => o.id === objectId)!.overrides.target; } /** The edit a placement holds for its Watcher. */ function editOf(f: ReturnType, sceneId: string, placed: string) { const holder = findObjectHolder(f.project, sceneId)!; return holder.objects.find((o) => o.id === placed)!.instanceEdits[f.tWatcher]; } it('empties the placements pointing at it in every scene, and undo brings them back', () => { const f = placedTwice(); f.stack.execute(new RemoveSceneObjectsCommand(f.asset.id, [f.tCrate])); for (const [sceneId, placed] of f.placements) { expect(targetOf(f, sceneId, instanceChildId(placed, f.tWatcher))).toEqual(NO_OBJECT); // None is what the template says too, so the placement follows it again. expect(editOf(f, sceneId, placed)).toBeUndefined(); } f.stack.undo(); for (const [sceneId, placed] of f.placements) { expect(targetOf(f, sceneId, instanceChildId(placed, f.tWatcher))).toEqual({ $object: f.tCrate }); expect(editOf(f, sceneId, placed)?.overrides.target).toEqual({ $object: f.tCrate }); } }); it('leaves a placement at None where the template names something else', () => { const f = placedTwice(); f.stack.execute(new SetOverrideCommand(f.asset.id, f.tWatcher, 'target', objectReference(f.tBarrel))); f.stack.execute(new RemoveSceneObjectsCommand(f.asset.id, [f.tCrate])); const [sceneId, placed] = f.placements[0]; expect(targetOf(f, sceneId, instanceChildId(placed, f.tWatcher))).toEqual(NO_OBJECT); expect(editOf(f, sceneId, placed)?.overrides.target).toEqual(NO_OBJECT); }); it('empties a reference from outside the placement, and leaves one to what stays alone', () => { const f = placedTwice(); const [, placed] = f.placements[0]; const toCrate = place(f, f.behavior.id, { x: 0, y: 0 }); const toBarrel = place(f, f.behavior.id, { x: 0, y: 0 }); const crate = objectReference(instanceChildId(placed, f.tCrate)); const barrel = objectReference(instanceChildId(placed, f.tBarrel)); f.stack.execute(new SetOverrideCommand(f.sceneId, toCrate, 'target', crate)); f.stack.execute(new SetOverrideCommand(f.sceneId, toBarrel, 'target', barrel)); f.stack.execute(new RemoveSceneObjectsCommand(f.asset.id, [f.tCrate])); expect(f.object(toCrate).overrides.target).toEqual(NO_OBJECT); expect(f.object(toBarrel).overrides.target).toEqual(barrel); f.stack.undo(); expect(f.object(toCrate).overrides.target).toEqual(crate); }); it('reaches placements nested in another container, template and scene alike', () => { const f = placedTwice(); const [, placed] = f.placements[0]; // The placement in "Level" becomes the content of Outer and takes its edit into Outer's template. const outer = new CreateContainerCommand(f.sceneId, [placed]); f.stack.execute(outer); const outerAsset = findAsset(f.project, outer.assetId) as ContainerAsset; const inner = outerAsset.objects[0]!.id; const outerPlaced = new PlaceAssetCommand(f.level2, outerAsset.id, { x: 0, y: 0 }); f.stack.execute(outerPlaced); const nestedWatcher = instanceChildId(outerPlaced.objectId, `${inner}/${f.tWatcher}`); f.stack.execute(new SetOverrideCommand(f.level2, nestedWatcher, 'target', objectReference(f.tBarrel))); f.stack.execute(new RemoveSceneObjectsCommand(f.asset.id, [f.tCrate])); expect(targetOf(f, outerAsset.id, instanceChildId(inner, f.tWatcher))).toEqual(NO_OBJECT); expect(targetOf(f, f.level2, nestedWatcher)).toEqual({ $object: f.tBarrel }); f.stack.execute(new RemoveSceneObjectsCommand(f.asset.id, [f.tBarrel])); expect(targetOf(f, f.level2, nestedWatcher)).toEqual(NO_OBJECT); f.stack.undo(); f.stack.undo(); expect(targetOf(f, outerAsset.id, instanceChildId(inner, f.tWatcher))).toEqual({ $object: f.tCrate }); expect(targetOf(f, f.level2, nestedWatcher)).toEqual({ $object: f.tBarrel }); }); it('does the same when the asset of the object is deleted (#286)', () => { const f = placedTwice(); f.stack.execute(new DeleteAssetCommand(f.spriteId)); for (const [sceneId, placed] of f.placements) { expect(targetOf(f, sceneId, instanceChildId(placed, f.tWatcher))).toEqual(NO_OBJECT); expect(editOf(f, sceneId, placed)).toBeUndefined(); } f.stack.undo(); for (const [sceneId, placed] of f.placements) expect(editOf(f, sceneId, placed)?.overrides.target).toEqual({ $object: f.tCrate }); }); }); describe('a reference into a placement nested in a container (#285)', () => { /** * The Crate made into "Inner", then the Watcher and that placement into "Outer", placed a second * time. A Lookout outside points at the Crate as well. */ function nested() { const f = watching(); const lookout = place(f, f.behavior.id, { x: 0, y: 0 }); f.stack.execute(new SetOverrideCommand(f.sceneId, lookout, 'target', objectReference(f.crate))); const inner = new CreateContainerCommand(f.sceneId, [f.crate]); f.stack.execute(inner); const innerPlaced = inner.objectId; const outer = new CreateContainerCommand(f.sceneId, [f.watcherId, innerPlaced]); f.stack.execute(outer); const second = new PlaceAssetCommand(f.sceneId, outer.assetId, { x: 500, y: 0 }); f.stack.execute(second); const asset = findAsset(f.project, outer.assetId) as ContainerAsset; const tWatcher = asset.objects.find((o) => o.assetId === f.behavior.id)!.id; const tInner = asset.objects.find((o) => o.assetId === inner.assetId)!.id; const tCrate = (findAsset(f.project, inner.assetId) as ContainerAsset).objects[0]!.id; /** The Crate as Outer's own view names it. */ const crate = instanceChildId(tInner, tCrate); /** The Crate's copy in a placement of Outer. */ const copyIn = (placed: string) => instanceChildId(placed, `${tInner}/${tCrate}`); const placements = [outer.objectId, second.objectId] as const; return { ...f, lookout, inner, innerPlaced, asset, tWatcher, crate, copyIn, placements }; } it('is picked in the container as its view names it, and every placement reaches its own copy', () => { const f = nested(); f.stack.execute(new SetOverrideCommand(f.asset.id, f.tWatcher, 'target', NO_OBJECT)); const choice = objectChoices(f.project, f.asset, f.tWatcher).find((c) => c.label === 'Crate')!; expect(choice).toMatchObject({ ref: f.crate, objectId: f.crate }); f.stack.execute(new SetOverrideCommand(f.asset.id, f.tWatcher, 'target', objectReference(choice.ref))); const run = startWorld(f); for (const placed of f.placements) { const watcher = run.world.findByObjectId(instanceChildId(placed, f.tWatcher))!; expect(run.world.findReferenced(watcher, f.crate)?.objectId).toBe(f.copyIn(placed)); } }); it('is shown as the Crate on a placement, and offered there with the same name', () => { const f = nested(); const [, placed] = f.placements; const choices = objectChoices(f.project, f.sceneOf(), instanceChildId(placed, f.tWatcher)); expect(choices.find((c) => c.ref === f.crate)).toMatchObject({ objectId: f.copyIn(placed), label: 'Crate', }); }); it('follows its object when the placement around it becomes container content', () => { const f = nested(); expect(f.asset.objects.find((o) => o.id === f.tWatcher)!.overrides.target).toEqual({ $object: f.crate }); expect(f.object(f.lookout).overrides.target).toEqual({ $object: f.copyIn(f.placements[0]) }); const run = startWorld(f); const lookout = run.world.findByObjectId(f.lookout)!; expect(run.world.findReferenced(lookout, f.copyIn(f.placements[0]))?.objectId).toBe( f.copyIn(f.placements[0]), ); }); it('is spelled out as the copy it names when the container is unpacked', () => { const f = nested(); const [, placed] = f.placements; f.stack.execute(new UnpackContainerCommand(f.sceneId, placed)); expect(f.object(instanceChildId(placed, f.tWatcher)).overrides.target).toEqual({ $object: f.copyIn(placed), }); }); it('reaches content nested deeper as well, from content of a placement', () => { expect(referenceCandidates('r~q/x', 'p~z')).toEqual(['r~q/p/z', 'p~z']); expect(referenceCandidates('r~x', 'p~z/y')).toEqual(['r~p/z/y', 'p~z/y']); }); it('points a pasted copy at the content of the copied placement', () => { const f = watching(); const inner = new CreateContainerCommand(f.sceneId, [f.crate]); f.stack.execute(inner); const tCrate = (findAsset(f.project, inner.assetId) as ContainerAsset).objects[0]!.id; const both = copyObjects(f.project, f.sceneOf(), [f.watcherId, inner.objectId])!; const paste = new PasteObjectsCommand(f.sceneId, both, {}, { x: 5, y: 0 }); f.stack.execute(paste); const copies = paste.rootIds.map((id) => f.object(id)); const watcherCopy = copies.find((o) => o.assetId === f.behavior.id)!; const innerCopy = copies.find((o) => o.assetId === inner.assetId)!; expect(watcherCopy.overrides.target).toEqual({ $object: instanceChildId(innerCopy.id, tCrate) }); }); }); describe('deleting an asset (#286)', () => { it('empties a reference to an object placed from it, and undo brings both back', () => { const f = watching(); f.stack.execute(new DeleteAssetCommand(f.spriteId)); expect(f.object(f.watcherId).overrides.target).toEqual(NO_OBJECT); f.stack.undo(); expect(f.object(f.watcherId).overrides.target).toEqual({ $object: f.crate }); expect(f.object(f.crate).name).toBe('Crate'); }); it('empties a reference into a placement of a deleted container', () => { const f = watching(); const create = new CreateContainerCommand(f.sceneId, [f.crate]); f.stack.execute(create); const named = f.object(f.watcherId).overrides.target; expect(referencedId(named)).toContain(create.objectId); f.stack.execute(new DeleteAssetCommand(create.assetId)); expect(f.object(f.watcherId).overrides.target).toEqual(NO_OBJECT); f.stack.undo(); expect(f.object(f.watcherId).overrides.target).toEqual(named); }); });