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:
1 parent
214218d1b2
commit
5e21d473a4
9 files changed
+295
-17
No files matched your search
@@ -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:
|
||||
|
||||
```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
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -15,6 +15,7 @@ import { isFontFile } from '../model/font';
|
||||
import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart';
|
||||
import { isSoundFile } from '../model/sound';
|
||||
import { removeSubtrees } from '../model/hierarchy';
|
||||
import { dropReferencesToGone, shownObjectIds } from '../model/objectRefs';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import { objectHolders, type Project } from '../model/Project';
|
||||
import {
|
||||
@@ -30,6 +31,7 @@ import { isTextureFile } from '../model/texture';
|
||||
import { cellsWithout } from '../model/tilemap';
|
||||
import { coerceValue, defaultsFor, initialOf, initialPropsFor } from '../properties/PropertySchema';
|
||||
import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
|
||||
import { Recorder } from '../util/recorder';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset, requirePropDef, requireStateMachine, requireVariablesAsset } from './require';
|
||||
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
|
||||
* 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 {
|
||||
readonly label = 'Delete asset';
|
||||
private removed?: Removed<Asset>;
|
||||
private removedObjects: { holderId: string; removed: Removed<SceneObject>[] }[] = [];
|
||||
private emptied: { layer: TilemapLayer; cells: Record<string, string> }[] = [];
|
||||
private readonly references = new Recorder();
|
||||
private previousActiveScene = '';
|
||||
|
||||
constructor(private readonly assetId: string) {}
|
||||
@@ -227,6 +231,7 @@ export class DeleteAssetCommand implements Command {
|
||||
const asset = requireAsset(project, this.assetId);
|
||||
this.removedObjects = [];
|
||||
this.emptied = [];
|
||||
const before = shownObjectIds(project);
|
||||
if (asset.type === 'tile')
|
||||
for (const map of project.assets) {
|
||||
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.removed = removeFirst(project.assets, (a) => a.id === this.assetId);
|
||||
dropReferencesToGone(project, before, this.references);
|
||||
this.previousActiveScene = project.activeSceneId;
|
||||
if (project.activeSceneId === this.assetId) {
|
||||
const next = project.assets.find((a) => a.type === 'scene');
|
||||
@@ -254,6 +260,7 @@ export class DeleteAssetCommand implements Command {
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
this.references.revert();
|
||||
restoreAt(project.assets, [this.removed]);
|
||||
const holders = new Map(objectHolders(project).map((h) => [h.id, h]));
|
||||
for (const entry of this.removedObjects) {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { newId } from '../ids';
|
||||
import type { ContainerAsset } from '../model/Asset';
|
||||
import { findObject, removeSubtrees, subtreeIds, subtreeRoots, treeOrder } from '../model/hierarchy';
|
||||
import { instanceChildId, type InstanceEdit, isLiveInstance, materializeInstance } from '../model/instance';
|
||||
import { anchorReferences, swapReferences } from '../model/objectRefs';
|
||||
import {
|
||||
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 type { Position } from '../model/Properties';
|
||||
import { findAsset, findObjectHolder, type Project } from '../model/Project';
|
||||
@@ -114,12 +120,13 @@ export class CreateContainerCommand implements Command {
|
||||
if (!containerParentId && groupId) container.groupId = groupId;
|
||||
this.removed = removeSubtrees(holder, [...roots]);
|
||||
// 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.
|
||||
const templateIdOf = (ref: string) => templateIds.get(ref);
|
||||
// rest of the holder to what the placed container shows of it – content of a member placement
|
||||
// included (#285).
|
||||
const templateIdOf = (ref: string) => renamedReference(ref, templateIds);
|
||||
swapReferences(asset.objects, templateIdOf, this.references);
|
||||
const shown = (ref: string) => {
|
||||
const moved = templateIdOf(ref);
|
||||
return moved && instanceChildId(this.objectId, moved);
|
||||
return moved && instanceChildId(this.objectId, pathOfId(moved));
|
||||
};
|
||||
swapReferences(holder.objects, shown, this.references);
|
||||
project.assets.push(asset);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { newId } from '../ids';
|
||||
import type { GraphClip, ObjectClip } from '../model/clipboard';
|
||||
import { findGroup } from '../model/hierarchy';
|
||||
import { isLiveInstance } from '../model/instance';
|
||||
import { swapReferences } from '../model/objectRefs';
|
||||
import { renamedReference, swapReferences } from '../model/objectRefs';
|
||||
import { Recorder } from '../util/recorder';
|
||||
import { placementRejection } from '../model/placement';
|
||||
import type { Vec2 } from '../model/Properties';
|
||||
@@ -79,7 +79,7 @@ export class PasteObjectsCommand implements Command {
|
||||
return copy;
|
||||
});
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,16 @@ export function splitInstanceId(id: string): { instanceId: string; path: string
|
||||
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
|
||||
* question is where it lives, not what its id looks like: unpacking keeps the derived id – so the
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
/**
|
||||
* 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
|
||||
* the container points at its own copy – which is why a reference is looked up from where it sits.
|
||||
* empty for none. Inside a container the id is the one the container's own view gives it, so every
|
||||
* 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 { treeOrder, type ObjectTree } from './hierarchy';
|
||||
import { instanceChildId, instanceTargetOf, isEmptyEdit, resolveObjects } from './instance';
|
||||
import {
|
||||
idOfPath,
|
||||
instanceChildId,
|
||||
instanceTargetOf,
|
||||
isEmptyEdit,
|
||||
pathOfId,
|
||||
resolveObjects,
|
||||
splitInstanceId,
|
||||
} from './instance';
|
||||
import {
|
||||
mapNested,
|
||||
type PropertyBag,
|
||||
@@ -45,7 +55,19 @@ function scopeOf(objectId: string): string {
|
||||
export function referenceCandidates(fromId: string, ref: string): string[] {
|
||||
if (!ref) return [];
|
||||
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 {
|
||||
@@ -70,7 +92,8 @@ export function objectChoices(project: Project, holder: ObjectTree, objectId: st
|
||||
for (const { object, depth } of treeOrder({ ...holder, objects })) {
|
||||
if (!object.id.startsWith(scope)) continue;
|
||||
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;
|
||||
// The container's own content sits one level below it in the tree.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* own copy of it.
|
||||
*/
|
||||
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 { PasteObjectsCommand } from '../../src/core/commands/pasteCommands';
|
||||
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 { copyObjects } from '../../src/core/model/clipboard';
|
||||
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 { findAsset, findObjectHolder } from '../../src/core/model/Project';
|
||||
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, 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);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,9 @@
|
||||
* 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).
|
||||
* 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.
|
||||
*/
|
||||
@@ -340,6 +343,102 @@ export async function objectReferences(t) {
|
||||
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
|
||||
// editor then shows whatever document earlier steps left open, not the scene view.
|
||||
await t.showTab('scene');
|
||||
@@ -348,6 +447,6 @@ export async function objectReferences(t) {
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
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-'));
|
||||
`);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export const LANES = {
|
||||
pixelArt: -5400,
|
||||
eyedropper: -5800,
|
||||
containerReference: -6200,
|
||||
nestedReference: -6600,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user