import { instanceChild } from '../lib/builders.mjs'; import { same } from '../lib/harness.mjs'; /** * Multi-selection and placed containers: what the scene *holds* (the container object alone) against * what the editor *lists* (content resolved from the template), and deviations kept per instance. */ export async function containers(t) { const { saved, check, sleep, evaluate, json, has, shot, click, clickText, drag, dragAsset, locate, press, typeText, undo, mutate, select, state, object, objects, storedObjects, asset, world, play, stop, api, sceneOf, navigate, projectId, findIcon, } = t; const center = await json('__gm.viewportCenter()'); /** Types a value into an inspector field, which runs the real command behind that row. */ const setField = async (label, value) => { const row = await locate(label); await click(row.x + 160, row.y); await press('a', { ctrl: true }); await typeText(String(value)); await press('Enter'); }; const notice = async () => { await evaluate("__gm.state.notice = ''"); return async () => (await state()).notice; }; const selectObject = async (id) => { await select({ kind: 'object', holderId: 'scene1', objectId: id, objectIds: [id] }); }; // 9a. Reset both crates to known spots, then Ctrl-click the second row in the hierarchy: // both are selected, the inspector shows the multi view. await mutate(` const objs = __gm.state.activeScene.objects; const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2'); o1.position = { x: -150, y: 0, z: 0 }; o1.parentId = ''; o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = ''; objs.find(o => o.id === 'oc2').overrides = { width: 140 }; __gm.state.select(null)`); await clickText('Red crate'); const blueRow = await locate('Blue crate'); await click(blueRow.x, blueRow.y, 'left', { ctrl: true }); let s = await state(); check( 'Ctrl-click in the hierarchy adds to the selection', s.selection?.kind === 'object' && same(s.selection.objectIds, ['o1', 'o2']) && s.selection.objectId === 'o2', JSON.stringify(s.selection), ); check('inspector shows the multi-selection', await has('2 OBJECTS SELECTED')); await shot('23-multi-selected'); await click(blueRow.x, blueRow.y, 'left', { ctrl: true }); s = await state(); check( 'Ctrl-click again removes the object from the selection', s.selection?.kind === 'object' && same(s.selection.objectIds, ['o1']), JSON.stringify(s.selection), ); // 9b. Rubber band on empty space around the red crate (world x −230…−90, y −50…50): picks // the objects whose position lies inside – the crate and its collider, not the Mover // (at y = 110), not the camera (at the origin). await drag(center.x - 230, center.y - 50, center.x - 90, center.y + 50); s = await state(); check( 'rubber-band selection picks the objects inside it', s.selection?.kind === 'object' && same([...s.selection.objectIds].sort(), ['o1', 'oc1']), JSON.stringify(s.selection), ); await shot('25-marquee'); // 9c. Dragging one of several selected objects moves all of them as one undo step. const redScreen = await json(`__gm.objectScreen('o1')`); await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] }); await drag(redScreen.x, redScreen.y + 20, redScreen.x + 50, redScreen.y + 20); check( 'dragging a multi-selection moves every selected object', (await object('o1')).position.x === -100 && (await object('o2')).position.x === 170, JSON.stringify({ o1: (await object('o1')).position, o2: (await object('o2')).position }), ); await undo(); check( 'one undo restores both', (await object('o1')).position.x === -150 && (await object('o2')).position.x === 120, ); // 9d. Delete with a multi-selection removes all of them (with subtrees) as one step. const listedBeforeDelete = (await objects()).length; await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] }); await press('Delete'); check( 'Delete removes the whole multi-selection with its subtrees', (await objects()).length === listedBeforeDelete - 5, ); await undo(); check('undo restores all of them', (await objects()).length === listedBeforeDelete); // 9e. Create container: both crates, their colliders and the Mover *leave the scene*; one stored // container object stands in their stead and the five are resolved from its template. const assetsBefore = (await json('__gm.state.project.assets')).length; const storedBefore = (await storedObjects()).length; const listedBefore = (await objects()).length; await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] }); await clickText('Create container'); s = await state(); const instanceA = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null; const containerAsset = instanceA ? await asset(instanceA.assetId) : null; const storedAfter = (await storedObjects()).length; const listedAfter = (await objects()).length; check( 'Create container adds one container asset and one placed container, selected', containerAsset?.type === 'container' && s.selection.objectIds.length === 1 && (await json('__gm.state.project.assets')).length === assetsBefore + 1, JSON.stringify({ instanceA, type: containerAsset?.type }), ); check( 'the container sits at the centre of the selection', instanceA && instanceA.parentId === '' && same(instanceA.position, { x: -15, y: -40, z: 0 }), JSON.stringify(instanceA?.position), ); const swallowed = ['o1', 'o2', 'o4', 'oc1', 'oc2']; const storedIds = (await storedObjects()).map((o) => o.id); check( 'the members left the scene: five objects out, one container object in', storedAfter === storedBefore - 4 && !swallowed.some((id) => storedIds.includes(id)), JSON.stringify({ storedBefore, storedAfter, storedIds }), ); check( 'the template holds the whole subtree (2 crates, 2 colliders, the Mover)', containerAsset?.objects.length === 5 && containerAsset.objects.filter((o) => o.parentId === '').length === 2, JSON.stringify(containerAsset?.objects.map((o) => [o.assetId, o.parentId === '' ? 'root' : 'child'])), ); // The two crate templates, told apart by where the centring put them. const roots = containerAsset.objects.filter((o) => o.parentId === ''); const redTemplate = roots.find((o) => o.position.x === -135); const contentOf = (instanceId) => containerAsset.objects.map((o) => instanceChild(instanceId, o.id)); const redA = instanceChild(instanceA.id, redTemplate.id); const redAPath = redTemplate.id; check( 'the editor lists the content again, under ids derived from the template', listedAfter === listedBefore + 1 && same( (await objects()).filter((o) => contentOf(instanceA.id).includes(o.id)).map((o) => o.id), contentOf(instanceA.id), ), JSON.stringify({ listedBefore, listedAfter, derived: contentOf(instanceA.id) }), ); const redResolved = await object(redA); check( 'a resolved object carries the template values and hangs below the container', redResolved && redResolved.parentId === instanceA.id && redResolved.name === 'Red crate' && redResolved.position.x === -135 && redResolved.position.y === 40, JSON.stringify(redResolved), ); const orderC = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())'); const cAt = orderC.indexOf(instanceA.id); check( 'hierarchy lists the container with its resolved content indented below', cAt >= 0 && same(orderC.slice(cAt), [instanceA.id, ...contentOf(instanceA.id)]), JSON.stringify(orderC), ); check( 'container symbol is drawn in the overlay layer', (await evaluate(`__gm.layerOf('${instanceA.id}')`)) === 'overlay', ); await shot('24-container-created'); // 9f. Dragging the container symbol in the viewport moves the whole group; undo restores it. const cScreen = await json(`__gm.objectScreen('${instanceA.id}')`); await drag(cScreen.x, cScreen.y, cScreen.x + 100, cScreen.y); check( 'dragging the container moves the group (its content keeps the template positions)', (await object(instanceA.id)).position.x === 85 && (await object(redA)).position.x === -135, JSON.stringify({ container: (await object(instanceA.id)).position }), ); await undo(); check('undo moves the container back', (await object(instanceA.id)).position.x === -15); // 9g. Placing the container asset again adds exactly ONE stored object – and five more that // the editor lists. Against the old copying model the stored count would jump by six. const spot = await json('__gm.viewportCenter()'); await dragAsset(containerAsset.id, spot.x + 90, spot.y + 60); const instanceB = (await storedObjects()).find( (o) => o.assetId === containerAsset.id && o.id !== instanceA.id, ); const redB = instanceB && instanceChild(instanceB.id, redTemplate.id); check( 'placing a container stores one object and lists its whole content', Boolean(instanceB) && (await storedObjects()).length === storedAfter + 1 && (await objects()).length === listedAfter + 1 + containerAsset.objects.length, JSON.stringify({ stored: (await storedObjects()).length, listed: (await objects()).length, expected: listedAfter + 6, }), ); check( 'the second instance shows the same content at the same template positions', (await object(redB)).position.x === -135 && (await object(redB)).position.y === 40, JSON.stringify((await object(redB))?.position), ); check( 'a fresh instance carries no deviations at all', same(instanceB.instanceEdits, {}), JSON.stringify(instanceB.instanceEdits), ); await shot('26-container-placed'); // 9h. A value edited on one instance is kept on that instance and nowhere else: the scene // grows by no object, the deviation lands in `instanceEdits` under the template path. await selectObject(redB); check('the inspector names the container the object comes from', await has(containerAsset.name)); await setField('X', -100); const editedB = (await storedObjects()).find((o) => o.id === instanceB.id); check( 'an edit on resolved content is stored as an instance deviation, not as a new object', (await storedObjects()).length === storedAfter + 1 && editedB.instanceEdits[redAPath]?.position.x === -100 && (await object(redB)).position.x === -100, JSON.stringify(editedB.instanceEdits), ); check( 'the other instance is untouched by it', (await object(redA)).position.x === -135, JSON.stringify((await object(redA)).position), ); // 9i. Editing the template reaches every instance that has not been touched there – and only // those. The untouched axis of the edited object follows along with the rest. const setTemplate = (body) => mutate( `const tpl = __gm.state.project.assets.find(a => a.id === '${containerAsset.id}') .objects.find(o => o.id === '${redAPath}'); ${body}`, ); await setTemplate(`tpl.position.y = 55; tpl.name = 'Template crate'; tpl.overrides.color = 0x00ff00`); check( 'a template edit reaches every instance at once', (await object(redA)).position.y === 55 && (await object(redB)).position.y === 55 && (await object(redA)).name === 'Template crate' && (await object(redB)).name === 'Template crate' && (await object(redA)).overrides.color === 0x00ff00, JSON.stringify({ a: await object(redA), b: await object(redB) }), ); await setTemplate(`tpl.position.x = -200`); check( 'but it does not overwrite the instance that was moved on its own', (await object(redA)).position.x === -200 && (await object(redB)).position.x === -100, JSON.stringify({ a: (await object(redA)).position.x, b: (await object(redB)).position.x }), ); await setTemplate( `tpl.position.x = -135; tpl.position.y = 40; tpl.name = 'Red crate'; delete tpl.overrides.color`, ); check( 'putting the template back takes the untouched instance back with it', (await object(redA)).position.x === -135 && (await object(redA)).name === 'Red crate', ); // 9j. Structure is the template's business: resolved content cannot be removed, re-parented, // given a collider or turned into a container of its own. // The hierarchy menu is the only way to a collider, and a disabled entry must not even reach // the command: the command refuses too, but with a notice. const colliderFromMenu = async (id) => { await selectObject(id); const row = await json(`__gm.hierarchyRow(${JSON.stringify(id)})`); const before = (await objects()).length; const noticeNow = await notice(); await click(row.x, row.y, 'right'); await clickText('Add collider'); await press('Escape'); return { added: (await objects()).length - before, notice: await noticeNow() }; }; const refused = JSON.stringify({ added: 0, notice: '' }); const fromContent = await colliderFromMenu(redA); check( 'the menu offers no collider for resolved content', JSON.stringify(fromContent) === refused, JSON.stringify(fromContent), ); const fromInstance = await colliderFromMenu(instanceA.id); check( 'nor for the placed container itself', JSON.stringify(fromInstance) === refused, JSON.stringify(fromInstance), ); const plainId = await json(`(() => { const p = __gm.state.project; const sprite = __gm.state.activeScene.objects.find( (o) => p.assets.find((a) => a.id === o.assetId)?.type === 'sprite', ); return sprite ? sprite.id : null; })()`); await selectObject(plainId); check('the inspector has no collider button of its own', !(await has('+ Add collider'))); const fromPlain = await colliderFromMenu(plainId); check( 'while a plain sprite still gets one from the menu', fromPlain.added === 1, JSON.stringify(fromPlain), ); await undo(); await selectObject(redA); let noticeAfter = await notice(); await press('Delete'); check( 'removing resolved content is refused with a notice', (await noticeAfter()).includes('container template') && Boolean(await object(redA)), await noticeAfter(), ); noticeAfter = await notice(); await press('g', { ctrl: true }); check( 'making a container out of resolved content is refused too', (await noticeAfter()).includes('container template'), await noticeAfter(), ); const parentRow = await json("__gm.inspectorField('Parent')"); await click(parentRow.x, parentRow.y); check( 'the Parent picker does not even open for resolved content', same(await json('__gm.overlayTexts()'), []), JSON.stringify(await json('__gm.overlayTexts()')), ); await press('Escape'); noticeAfter = await notice(); // Rows are addressed by id, not by their text: both instances read "Red crate" by now. const rowAt = async (id) => { const p = await json(`__gm.hierarchyChevron(${JSON.stringify(id)})`); if (!p) throw new Error(`no hierarchy row for ${id}`); return { x: p.x + 60, y: p.y }; }; const redARow = await rowAt(redA); const otherRow = await rowAt(instanceB.id); await drag(redARow.x, redARow.y, otherRow.x, otherRow.y); check( 're-parenting resolved content by dragging its row is refused', (await noticeAfter()).includes('container template') && (await object(redA)).parentId === instanceA.id, `${await noticeAfter()} / parent ${(await object(redA)).parentId}`, ); await shot('26b-structure-refused'); // 9k. Unpack: the content is written into the scene as ordinary objects under the very same // ids, the instance stops following the template – and now its structure *can* be changed. await dragAsset(containerAsset.id, spot.x - 90, spot.y + 60); const instanceC = (await storedObjects()).find( (o) => o.assetId === containerAsset.id && o.id !== instanceA.id && o.id !== instanceB.id, ); const redC = instanceChild(instanceC.id, redTemplate.id); const listedBeforeUnpack = (await objects()).length; const storedBeforeUnpack = (await storedObjects()).length; await selectObject(instanceC.id); check( 'the inspector shows what the placed container follows', await has('Follows a template of 5 object(s).'), ); await clickText('Unpack'); check( 'Unpack writes the content into the scene, keeping the ids and the listing', (await storedObjects()).length === storedBeforeUnpack + 5 && (await objects()).length === listedBeforeUnpack && (await storedObjects()).some((o) => o.id === redC), JSON.stringify({ stored: (await storedObjects()).length, listed: (await objects()).length, before: { stored: storedBeforeUnpack, listed: listedBeforeUnpack }, }), ); await selectObject(instanceC.id); check( 'and the inspector stops offering the unpack, there is nothing left to follow', !(await has('Unpack')) && !(await has('Follows a template of 5 object(s).')), ); await setTemplate(`tpl.position.x = -300`); check( 'an unpacked instance no longer follows the template', (await object(redC)).position.x === -135 && (await object(redA)).position.x === -300, JSON.stringify({ c: (await object(redC)).position.x, a: (await object(redA)).position.x }), ); await setTemplate(`tpl.position.x = -135`); await selectObject(redC); // Unpacking keeps the derived ids, so an id that merely *looks* like content of a template must // not freeze the picker any more – the inspector has to read the holder, not the id. const unpackedParentRow = await json("__gm.inspectorField('Parent')"); await click(unpackedParentRow.x, unpackedParentRow.y); check( 'unpacked content offers the Parent picker like any ordinary object', (await json('__gm.overlayTexts()')).includes('None'), JSON.stringify(await json('__gm.overlayTexts()')), ); await press('Escape'); noticeAfter = await notice(); const redCRow = await rowAt(redC); await click(redCRow.x, redCRow.y, 'right'); await clickText('Unparent (move to top level)'); check( 'unpacked content can be re-parented, which was refused while it followed the template', (await object(redC)).parentId === '' && (await noticeAfter()) === '', `${await noticeAfter()} / parent "${(await object(redC)).parentId}"`, ); noticeAfter = await notice(); await selectObject(redC); await press('Delete'); check( 'and it can be removed – the refusal was about following a template, not about the id', !(await object(redC)) && (await noticeAfter()) === '', `${await noticeAfter()}`, ); await selectObject(instanceC.id); await press('Delete'); check( 'removing the unpacked container takes the rest of its content with it', (await storedObjects()).length === storedBeforeUnpack - 1, JSON.stringify({ now: (await storedObjects()).length, was: storedBeforeUnpack }), ); // 9l. Spawning a container at runtime spells it out the same way Unpack does, under the same // derived ids – and Stop throws the whole run away with it. const spawnParam = (assetId) => mutate( `__gm.state.project.assets.find(a => a.id === 'spawner').graph.nodes.find(n => n.id === 's-spawn').params.asset = ${JSON.stringify(assetId)}`, ); await spawnParam(containerAsset.id); const beforeSpawn = await objects(); await play(); await press('Space'); const fresh = (await objects()).filter((o) => !beforeSpawn.some((b) => b.id === o.id)); const spawnedRoot = fresh.find((o) => o.parentId === ''); check( 'Spawn of a container asset creates the container object plus its content', fresh.length === 6 && spawnedRoot?.assetId === containerAsset.id && fresh.filter((o) => o.id.startsWith(`${spawnedRoot.id}~`)).length === 5, JSON.stringify(fresh.map((o) => ({ id: o.id, assetId: o.assetId }))), ); const spawnWorld = await world(); check( 'the spawned instance lives in the world and its name reached the Log node', spawnWorld.entities.length === beforeSpawn.length + 6 && spawnWorld.log.includes(containerAsset.name), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log.slice(-1) }), ); await stop(); check('stop discards the spawned instance', (await objects()).length === beforeSpawn.length); await spawnParam('crate'); // 9m. Apply / revert on resolved content. The red crate of instance B was moved on x, the one // of instance A still matches the template; apply writes the value into the template and A // follows, reset drops the deviation so B follows again. const templateRed = async () => (await asset(containerAsset.id)).objects.find((o) => o.id === redAPath); await selectObject(redA); check( 'no apply/revert while the instance matches its template', !(await findIcon('apply', false)) && !(await findIcon('reset', false)), ); await selectObject(redB); const applyButton = await findIcon('apply', false); check( 'a moved instance shows apply and revert on its X row', Boolean(applyButton) && (await findIcon('reset', false)), ); await shot('27-apply-buttons'); await click(applyButton.x, applyButton.y); check( 'apply writes x into the template and the untouched instance follows', (await templateRed()).position.x === -100 && (await object(redA)).position.x === -100, JSON.stringify({ template: (await templateRed()).position, a: (await object(redA)).position }), ); check('apply hides the buttons again', !(await findIcon('apply', false))); await undo(); check( 'undo restores template and instances', (await templateRed()).position.x === -135 && (await object(redA)).position.x === -135, ); const revertButton = await findIcon('reset', false); await click(revertButton.x, revertButton.y); const revertedB = (await storedObjects()).find((o) => o.id === instanceB.id); check( 'revert drops the deviation, so the instance follows the template again', (await object(redB)).position.x === -135 && revertedB.instanceEdits[redAPath] === undefined, JSON.stringify(revertedB.instanceEdits), ); // 9n. An override on resolved content deviates from two bases at once – the template it // follows and, one step further up, the asset – so apply offers a menu. Reset never goes // past the nearest base, so it stays a single button. await selectObject(redB); await setField('Width', 77); const editedWidth = (await storedObjects()).find((o) => o.id === instanceB.id); check( 'an override on resolved content is kept as an instance deviation', editedWidth.instanceEdits[redAPath]?.overrides.width === 77 && (await object(redB)).overrides.width === 77 && (await object(redA)).overrides.width === undefined, JSON.stringify(editedWidth.instanceEdits), ); const applyMenu = await findIcon('apply', true); check('an overridden property on an instance shows the apply menu button', Boolean(applyMenu)); check( 'reset stays a single button – inside a container it only ever goes to the template', !(await findIcon('reset', true)), ); await click(applyMenu.x, applyMenu.y); check( 'the menu offers container and asset', (await has('Apply to container')) && (await has('Apply to asset')), ); await shot('28-apply-menu'); await clickText('Apply to asset'); check( 'Apply to asset writes the value into the asset and drops the deviation', (await asset('crate')).props.width === 77 && (await object(redB)).overrides.width === undefined, JSON.stringify((await asset('crate')).props), ); await undo(); check( 'undo restores asset and deviation', (await asset('crate')).props.width === 100 && (await object(redB)).overrides.width === 77, ); await click(applyMenu.x, applyMenu.y); await clickText('Apply to container'); check( 'Apply to container writes the override into the template, so every instance shows it', (await templateRed()).overrides.width === 77 && (await object(redA)).overrides.width === 77, JSON.stringify({ template: (await templateRed()).overrides, a: (await object(redA)).overrides }), ); // 9o. redB matches its template again (both 77) and has nothing of its own left to reset – // but the value still differs from the asset (100), which apply can still reach past it. await sleep(200); check( 'with the template carrying the value there is nothing left to reset, only an apply upwards', (await findIcon('apply', false)) && !(await findIcon('apply', true)) && !(await findIcon('reset', false)), ); await shot('28b-single-apply'); const revertedEdits = (await storedObjects()).find((o) => o.id === instanceB.id); check( 'and the instance stores no deviation for it any more', revertedEdits.instanceEdits[redAPath] === undefined, JSON.stringify(revertedEdits.instanceEdits), ); await mutate( `delete __gm.state.project.assets.find(a => a.id === '${containerAsset.id}') .objects.find(o => o.id === '${redAPath}').overrides.width`, ); // 9p. Save and load: an instance is a container object plus its deviations, nothing else. // The content must be nowhere in the saved scene, and reloading must resolve it back. await selectObject(redB); await setField('Y', 12); await saved(); const savedScene = sceneOf(await api(`projects/${projectId}`)); const savedInstance = savedScene.objects.find((o) => o.id === instanceB.id); check( 'the saved scene holds the container object and its deviations, not its content', Boolean(savedInstance) && savedInstance.instanceEdits[redAPath]?.position.y === 12 && !savedScene.objects.some((o) => o.id === redB), JSON.stringify({ edits: savedInstance?.instanceEdits, stored: savedScene.objects.length }), ); const listedBeforeReload = (await objects()).length; await navigate(); await sleep(500); check( 'a reloaded project resolves the same content again, deviations included', (await objects()).length === listedBeforeReload && (await object(redB)).position.y === 12 && (await object(redA)).position.y === 40, JSON.stringify({ listed: (await objects()).length, expected: listedBeforeReload, b: (await object(redB))?.position, }), ); }