Fix references into nested placements and on asset delete (#285, #286)

Two findings from #269. A reference from a container into a placement
nested in it was dead from the start, and deleting an asset left
references to the objects it took along showing "(missing)".

- A reference is stored the way its holder shows the object. In a
  container tab that is q~x for content of a nested placement q.
- referenceCandidates translates it when the container is placed: in
  placement r, q~x becomes r~q/x. pathOfId and idOfPath in instance.ts
  do the mapping, objectChoices offers the same spelling the other way.
  The rule holds at any depth.
- Storing q/x as proposed in #285 was dropped. The container tab would
  then have had to translate its own ids.
- No migration. All projects and snapshots hold three references, none
  of them with ~ or /.
- Create container lets a reference to content of a member placement
  follow it. It was left dead before.
- Paste lets such a reference point into the copy. It kept pointing at
  the original before. Both go through the new renamedReference.
- Delete asset records what every holder shows before the cascade and
  clears every reference to an object that is gone afterwards, with
  dropReferencesToGone from #269. The row shows None, undo restores
  the references (#286).

Tests:
- Gate: 198 files, 2882 tests (2873 + 9), exit 0
- Unit object-references: 26 passed (17 + 9)
- e2e 1-17,57: 566 checks passed, 57 has +57i and +57j
- Six unit counter-probes fail between 1 and 3 tests each
- The browser counter-probe fails 57i and 57j as described in the
  issues
- e2e total 1609 is computed, not measured in a full run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 07:28:49 +02:00
1 parent 214218d1b2
commit 5e21d473a4
9 files changed
+295 -17

No files matched your search

+1 -1
View File
@@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1607 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1609 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+8 -1
View File
@@ -15,6 +15,7 @@ import { isFontFile } from '../model/font';
import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart'; import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart';
import { isSoundFile } from '../model/sound'; import { isSoundFile } from '../model/sound';
import { removeSubtrees } from '../model/hierarchy'; import { removeSubtrees } from '../model/hierarchy';
import { dropReferencesToGone, shownObjectIds } from '../model/objectRefs';
import type { PropertyBag, PropertyValue } from '../model/Properties'; import type { PropertyBag, PropertyValue } from '../model/Properties';
import { objectHolders, type Project } from '../model/Project'; import { objectHolders, type Project } from '../model/Project';
import { import {
@@ -30,6 +31,7 @@ import { isTextureFile } from '../model/texture';
import { cellsWithout } from '../model/tilemap'; import { cellsWithout } from '../model/tilemap';
import { coerceValue, defaultsFor, initialOf, initialPropsFor } from '../properties/PropertySchema'; import { coerceValue, defaultsFor, initialOf, initialPropsFor } from '../properties/PropertySchema';
import { type Removed, removeById, removeFirst, restoreAt } from '../util/list'; import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
import { Recorder } from '../util/recorder';
import { type Command, CommandError } from './Command'; import { type Command, CommandError } from './Command';
import { requireAsset, requirePropDef, requireStateMachine, requireVariablesAsset } from './require'; import { requireAsset, requirePropDef, requireStateMachine, requireVariablesAsset } from './require';
import { SetBagValueCommand } from './SetBagValueCommand'; import { SetBagValueCommand } from './SetBagValueCommand';
@@ -212,13 +214,15 @@ export class CreateSoundCommand extends ImportFileCommand<SoundAsset> {
/** /**
* Deletes an asset and, as a cascade, every scene object referencing it, together with the objects * Deletes an asset and, as a cascade, every scene object referencing it, together with the objects
* parented below those – in container templates as well as in scenes. A tile takes the cells it is * parented below those – in container templates as well as in scenes. A tile takes the cells it is
* painted on along (#268), which would otherwise stay in the file and draw nothing. * painted on along (#268), which would otherwise stay in the file and draw nothing. A reference to
* an object that goes is emptied, as when the object is removed by itself (#286).
*/ */
export class DeleteAssetCommand implements Command { export class DeleteAssetCommand implements Command {
readonly label = 'Delete asset'; readonly label = 'Delete asset';
private removed?: Removed<Asset>; private removed?: Removed<Asset>;
private removedObjects: { holderId: string; removed: Removed<SceneObject>[] }[] = []; private removedObjects: { holderId: string; removed: Removed<SceneObject>[] }[] = [];
private emptied: { layer: TilemapLayer; cells: Record<string, string> }[] = []; private emptied: { layer: TilemapLayer; cells: Record<string, string> }[] = [];
private readonly references = new Recorder();
private previousActiveScene = ''; private previousActiveScene = '';
constructor(private readonly assetId: string) {} constructor(private readonly assetId: string) {}
@@ -227,6 +231,7 @@ export class DeleteAssetCommand implements Command {
const asset = requireAsset(project, this.assetId); const asset = requireAsset(project, this.assetId);
this.removedObjects = []; this.removedObjects = [];
this.emptied = []; this.emptied = [];
const before = shownObjectIds(project);
if (asset.type === 'tile') if (asset.type === 'tile')
for (const map of project.assets) { for (const map of project.assets) {
if (map.type !== 'tilemap') continue; if (map.type !== 'tilemap') continue;
@@ -245,6 +250,7 @@ export class DeleteAssetCommand implements Command {
this.removedObjects.push({ holderId: holder.id, removed: removeSubtrees(holder, roots) }); this.removedObjects.push({ holderId: holder.id, removed: removeSubtrees(holder, roots) });
} }
this.removed = removeFirst(project.assets, (a) => a.id === this.assetId); this.removed = removeFirst(project.assets, (a) => a.id === this.assetId);
dropReferencesToGone(project, before, this.references);
this.previousActiveScene = project.activeSceneId; this.previousActiveScene = project.activeSceneId;
if (project.activeSceneId === this.assetId) { if (project.activeSceneId === this.assetId) {
const next = project.assets.find((a) => a.type === 'scene'); const next = project.assets.find((a) => a.type === 'scene');
@@ -254,6 +260,7 @@ export class DeleteAssetCommand implements Command {
undo(project: Project): void { undo(project: Project): void {
if (!this.removed) return; if (!this.removed) return;
this.references.revert();
restoreAt(project.assets, [this.removed]); restoreAt(project.assets, [this.removed]);
const holders = new Map(objectHolders(project).map((h) => [h.id, h])); const holders = new Map(objectHolders(project).map((h) => [h.id, h]));
for (const entry of this.removedObjects) { for (const entry of this.removedObjects) {
+12 -5
View File
@@ -1,8 +1,14 @@
import { newId } from '../ids'; import { newId } from '../ids';
import type { ContainerAsset } from '../model/Asset'; import type { ContainerAsset } from '../model/Asset';
import { findObject, removeSubtrees, subtreeIds, subtreeRoots, treeOrder } from '../model/hierarchy'; import { findObject, removeSubtrees, subtreeIds, subtreeRoots, treeOrder } from '../model/hierarchy';
import { instanceChildId, type InstanceEdit, isLiveInstance, materializeInstance } from '../model/instance'; import {
import { anchorReferences, swapReferences } from '../model/objectRefs'; instanceChildId,
type InstanceEdit,
isLiveInstance,
materializeInstance,
pathOfId,
} from '../model/instance';
import { anchorReferences, renamedReference, swapReferences } from '../model/objectRefs';
import { apply, invert, multiply, translation } from '../model/matrix'; import { apply, invert, multiply, translation } from '../model/matrix';
import type { Position } from '../model/Properties'; import type { Position } from '../model/Properties';
import { findAsset, findObjectHolder, type Project } from '../model/Project'; import { findAsset, findObjectHolder, type Project } from '../model/Project';
@@ -114,12 +120,13 @@ export class CreateContainerCommand implements Command {
if (!containerParentId && groupId) container.groupId = groupId; if (!containerParentId && groupId) container.groupId = groupId;
this.removed = removeSubtrees(holder, [...roots]); this.removed = removeSubtrees(holder, [...roots]);
// A reference to a member follows it (#258): inside the template to its template id, from the // A reference to a member follows it (#258): inside the template to its template id, from the
// rest of the holder to what the placed container shows of it. // rest of the holder to what the placed container shows of it – content of a member placement
const templateIdOf = (ref: string) => templateIds.get(ref); // included (#285).
const templateIdOf = (ref: string) => renamedReference(ref, templateIds);
swapReferences(asset.objects, templateIdOf, this.references); swapReferences(asset.objects, templateIdOf, this.references);
const shown = (ref: string) => { const shown = (ref: string) => {
const moved = templateIdOf(ref); const moved = templateIdOf(ref);
return moved && instanceChildId(this.objectId, moved); return moved && instanceChildId(this.objectId, pathOfId(moved));
}; };
swapReferences(holder.objects, shown, this.references); swapReferences(holder.objects, shown, this.references);
project.assets.push(asset); project.assets.push(asset);
+2 -2
View File
@@ -5,7 +5,7 @@ import { newId } from '../ids';
import type { GraphClip, ObjectClip } from '../model/clipboard'; import type { GraphClip, ObjectClip } from '../model/clipboard';
import { findGroup } from '../model/hierarchy'; import { findGroup } from '../model/hierarchy';
import { isLiveInstance } from '../model/instance'; import { isLiveInstance } from '../model/instance';
import { swapReferences } from '../model/objectRefs'; import { renamedReference, swapReferences } from '../model/objectRefs';
import { Recorder } from '../util/recorder'; import { Recorder } from '../util/recorder';
import { placementRejection } from '../model/placement'; import { placementRejection } from '../model/placement';
import type { Vec2 } from '../model/Properties'; import type { Vec2 } from '../model/Properties';
@@ -79,7 +79,7 @@ export class PasteObjectsCommand implements Command {
return copy; return copy;
}); });
// A reference among the copied objects points at the copy; one out of them stays (#258). // A reference among the copied objects points at the copy; one out of them stays (#258).
swapReferences(pasted, (ref) => this.ids.get(ref), new Recorder()); swapReferences(pasted, (ref) => renamedReference(ref, this.ids), new Recorder());
holder.objects.push(...pasted); holder.objects.push(...pasted);
} }
+10
View File
@@ -105,6 +105,16 @@ export function splitInstanceId(id: string): { instanceId: string; path: string
return { instanceId: id.slice(0, at), path: id.slice(at + 1) }; return { instanceId: id.slice(0, at), path: id.slice(at + 1) };
} }
/** The template path an id of a holder's own view stands for once the holder is placed: `q~x` → `q/x`. */
export function pathOfId(id: string): string {
return id.replace(ID_SEP, PATH_SEP);
}
/** The id a holder's own view gives the object at a template path: `q/x` → `q~x`. */
export function idOfPath(path: string): string {
return path.replace(PATH_SEP, ID_SEP);
}
/** /**
* Whether the object is only resolved from a template rather than stored in the holder. The * Whether the object is only resolved from a template rather than stored in the holder. The
* question is where it lives, not what its id looks like: unpacking keeps the derived id – so the * question is where it lives, not what its id looks like: unpacking keeps the derived id – so the
+28 -5
View File
@@ -1,11 +1,21 @@
/** /**
* An object's place names another object of the same scene by its id (#258): `{"$object": "<id>"}`, * An object's place names another object of the same scene by its id (#258): `{"$object": "<id>"}`,
* empty for none. Inside a container the id is the one the template gives it, so every placement of * empty for none. Inside a container the id is the one the container's own view gives it, so every
* the container points at its own copy – which is why a reference is looked up from where it sits. * placement of the container points at its own copy – which is why a reference is looked up from
* where it sits. Content of a placement nested in the container is `q~x` there and `r~q/x` in its
* placement r (#285).
*/ */
import { findAsset, type ObjectHolder, objectHolders, type Project } from './Project'; import { findAsset, type ObjectHolder, objectHolders, type Project } from './Project';
import { treeOrder, type ObjectTree } from './hierarchy'; import { treeOrder, type ObjectTree } from './hierarchy';
import { instanceChildId, instanceTargetOf, isEmptyEdit, resolveObjects } from './instance'; import {
idOfPath,
instanceChildId,
instanceTargetOf,
isEmptyEdit,
pathOfId,
resolveObjects,
splitInstanceId,
} from './instance';
import { import {
mapNested, mapNested,
type PropertyBag, type PropertyBag,
@@ -45,7 +55,19 @@ function scopeOf(objectId: string): string {
export function referenceCandidates(fromId: string, ref: string): string[] { export function referenceCandidates(fromId: string, ref: string): string[] {
if (!ref) return []; if (!ref) return [];
const scope = scopeOf(fromId); const scope = scopeOf(fromId);
return scope ? [scope + ref, ref] : [ref]; return scope ? [scope + pathOfId(ref), ref] : [ref];
}
/**
* `ref` once the objects in `ids` are renamed: the object itself, or content of a placement that is
* renamed – the copy of the placement shows the same content. Undefined when it stays as it is.
*/
export function renamedReference(ref: string, ids: ReadonlyMap<string, string>): string | undefined {
const own = ids.get(ref);
if (own) return own;
const split = splitInstanceId(ref);
const placement = split && ids.get(split.instanceId);
return placement && instanceChildId(placement, split.path);
} }
export interface ObjectChoice { export interface ObjectChoice {
@@ -70,7 +92,8 @@ export function objectChoices(project: Project, holder: ObjectTree, objectId: st
for (const { object, depth } of treeOrder({ ...holder, objects })) { for (const { object, depth } of treeOrder({ ...holder, objects })) {
if (!object.id.startsWith(scope)) continue; if (!object.id.startsWith(scope)) continue;
const label = objectName(object, findAsset(project, object.assetId)); const label = objectName(object, findAsset(project, object.assetId));
choices.push({ ref: object.id.slice(scope.length), objectId: object.id, label, depth }); const ref = scope ? idOfPath(object.id.slice(scope.length)) : object.id;
choices.push({ ref, objectId: object.id, label, depth });
} }
if (!scope) return choices; if (!scope) return choices;
// The container's own content sits one level below it in the tree. // The container's own content sits one level below it in the tree.
+133 -2
View File
@@ -4,7 +4,7 @@
* own copy of it. * own copy of it.
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/assetCommands';
import { CreateContainerCommand, UnpackContainerCommand } from '../../src/core/commands/containerCommands'; import { CreateContainerCommand, UnpackContainerCommand } from '../../src/core/commands/containerCommands';
import { PasteObjectsCommand } from '../../src/core/commands/pasteCommands'; import { PasteObjectsCommand } from '../../src/core/commands/pasteCommands';
import { AddPortsCommand } from '../../src/core/commands/portCommands'; import { AddPortsCommand } from '../../src/core/commands/portCommands';
@@ -19,7 +19,12 @@ import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/comman
import type { BehaviorAsset, ContainerAsset } from '../../src/core/model/Asset'; import type { BehaviorAsset, ContainerAsset } from '../../src/core/model/Asset';
import { copyObjects } from '../../src/core/model/clipboard'; import { copyObjects } from '../../src/core/model/clipboard';
import { instanceChildId, resolvedHolder } from '../../src/core/model/instance'; import { instanceChildId, resolvedHolder } from '../../src/core/model/instance';
import { objectChoices, objectReference, referencedId } from '../../src/core/model/objectRefs'; import {
objectChoices,
objectReference,
referenceCandidates,
referencedId,
} from '../../src/core/model/objectRefs';
import { NO_OBJECT } from '../../src/core/model/Properties'; import { NO_OBJECT } from '../../src/core/model/Properties';
import { findAsset, findObjectHolder } from '../../src/core/model/Project'; import { findAsset, findObjectHolder } from '../../src/core/model/Project';
import { makeItem } from '../../src/core/properties/listItems'; import { makeItem } from '../../src/core/properties/listItems';
@@ -355,4 +360,130 @@ describe('removing an object from a container template (#269)', () => {
expect(targetOf(f, outerAsset.id, instanceChildId(inner, f.tWatcher))).toEqual({ $object: f.tCrate }); expect(targetOf(f, outerAsset.id, instanceChildId(inner, f.tWatcher))).toEqual({ $object: f.tCrate });
expect(targetOf(f, f.level2, nestedWatcher)).toEqual({ $object: f.tBarrel }); 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);
});
}); });
+100 -1
View File
@@ -10,6 +10,9 @@
* Variables tab it goes to the scene view and comes back with Take. * Variables tab it goes to the scene view and comes back with Take.
* *
* Removing an object in the Container tab empties a reference a placement of it holds (#269). * Removing an object in the Container tab empties a reference a placement of it holds (#269).
* Inside a container the Aim can pick content of a placement nested there, and the container's
* placement in the scene reaches it (#285). Deleting that nested container's asset empties the
* reference again (#286).
* *
* Everything it adds carries an `or57` id and goes again at the end. * Everything it adds carries an `or57` id and goes again at the end.
*/ */
@@ -340,6 +343,102 @@ export async function objectReferences(t) {
JSON.stringify({ rowBefore, tabThen, templateLeft, emptied, rowAfter, restored }), JSON.stringify({ rowBefore, tabThen, templateLeft, emptied, rowAfter, restored }),
); );
// 57i. Outer 57 holds an Aim and a placement of Inner 57 (#285). In the Container tab the Aim
// picks Inner's Nest 57, stored as Outer's own view names it. Outer's placement in the scene
// shows that name on its Aim, and a run reads the copy of Nest 57 there.
await t.showTab('scene');
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
const sprite = p.assets.find((a) => a.type === 'sprite');
p.assets.push({
id: 'or57-inner',
type: 'container',
name: 'Inner 57',
props: {},
objects: [make('or57-i-nest', sprite.id, '', 0, 'Nest 57')],
});
p.assets.push({
id: 'or57-outer',
type: 'container',
name: 'Outer 57',
props: {},
objects: [
make('or57-o-aim', 'or57-aim', '', 0, 'Aim 57'),
make('or57-o-inner', 'or57-inner', '', 50, 'Inner 57'),
],
});
scene.objects.push(make('or57-outer-placed', 'or57-outer', '', ${LANES.nestedReference}, 'Outer 57'));
`);
const outerTarget = () =>
json(
"__gm.state.project.assets.find((a) => a.id === 'or57-outer').objects.find((o) => o.id === 'or57-o-aim')?.overrides.target ?? null",
);
const nestName = JSON.stringify({ $object: 'or57-o-inner~or57-i-nest' });
await select({ kind: 'object', holderId: 'or57-outer', objectId: 'or57-o-aim' });
await sleep(200);
const outerTab = await json('__gm.tab()');
await press_(await json("__gm.inspectorField('target')"), 'the target field in the Container tab');
const inOuter = (await json('__gm.overlayTexts()')) ?? [];
// Nest 57 sits one level below Inner 57, so the menu indents it.
const nestEntry = inOuter.find((text) => text.trim() === 'Nest 57');
await press_(await json(`__gm.locate(${JSON.stringify(nestEntry)}, true)`), 'Nest 57 in the menu');
const stored = await outerTarget();
await t.showTab('scene');
const nestedAim = { kind: 'object', holderId: sceneId, objectId: 'or57-outer-placed~or57-o-aim' };
await select(nestedAim);
await sleep(200);
const nestedRow = { nest: await inInspector('Nest 57'), missing: await inInspector('(missing)') };
await shot('05-nested-reference');
await evaluate('__gm.state.select(null)');
const runFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await stop();
const nestedLogged = t.consoleLog
.slice(runFrom)
.filter((line) => line.startsWith('[behavior Aim 57]'))
.map((line) => line.split(']').pop().trim());
check(
'a pick in the Container tab reaches into a nested placement, and the placement in the scene shows and reads it',
outerTab === 'container' &&
nestEntry !== undefined &&
JSON.stringify(stored) === nestName &&
nestedRow.nest &&
!nestedRow.missing &&
nestedLogged.includes('Nest 57'),
JSON.stringify({ outerTab, inOuter, stored, nestedRow, nestedLogged }),
);
// 57j. Deleting Inner 57 in the asset list (#286) takes its placement out of Outer 57 and empties
// the Aim's reference to Nest 57 with it: the row says None, not (missing). One undo brings
// both back.
const innerRow = await json("__gm.assetRow('or57-inner')");
if (!innerRow) throw new Error('Inner 57 is not in the asset list');
await click(innerRow.x, innerRow.y, 'right');
await sleep(150);
await press_(await json("__gm.locate('Delete…', true)"), 'Delete… in the row menu');
await press_(await json("__gm.locate('Delete', true)"), 'Delete in the dialog');
const innerLeft = await json("__gm.state.project.assets.some((a) => a.id === 'or57-inner')");
const cleared = await outerTarget();
await select(nestedAim);
await sleep(200);
const clearedRow = await targetRow();
await shot('06-asset-deleted');
await undo();
await sleep(150);
const reinstated = await outerTarget();
check(
'deleting an asset empties a reference to an object it took along, undo brings it back',
!innerLeft &&
JSON.stringify(cleared) === JSON.stringify({ $object: '' }) &&
clearedRow.none &&
!clearedRow.missing &&
JSON.stringify(reinstated) === nestName,
JSON.stringify({ innerLeft, cleared, clearedRow, reinstated }),
);
// Clean up. Back to the scene view first: removing Aim 57 closes 57g's Variables tab, and the // Clean up. Back to the scene view first: removing Aim 57 closes 57g's Variables tab, and the
// editor then shows whatever document earlier steps left open, not the scene view. // editor then shows whatever document earlier steps left open, not the scene view.
await t.showTab('scene'); await t.showTab('scene');
@@ -348,6 +447,6 @@ export async function objectReferences(t) {
const p = __gm.state.project; const p = __gm.state.project;
const scene = __gm.state.activeScene; const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('or57-')); scene.objects = scene.objects.filter((o) => !o.id.startsWith('or57-'));
p.assets = p.assets.filter((a) => a.id !== 'or57-aim' && a.id !== 'or57-box'); p.assets = p.assets.filter((a) => !a.id.startsWith('or57-'));
`); `);
} }
+1
View File
@@ -29,6 +29,7 @@ export const LANES = {
pixelArt: -5400, pixelArt: -5400,
eyedropper: -5800, eyedropper: -5800,
containerReference: -6200, containerReference: -6200,
nestedReference: -6600,
}; };
/** /**