/** * Struct assets as types (#253), with the real mouse: a Struct asset made from the + menu and its * fields declared in the inspector, a behavior's struct variable typed by it, an `Expand` pointed at * it through its Type setting – then a field of the asset renamed, and the new name has to show up * in the variable, in a placed object's override and on the `Expand` port with its edge. "Make * struct type" turns a struct's own fields into an asset; deleting the asset leaves the fields. * * Everything it adds carries an `st54` id or is an asset it made, and goes again at the end. */ import { inView, MAKE_OBJECT } from './lib/scaffold.mjs'; export async function structTypes(t) { const { check, json, evaluate, mutate, click, clickText, press, typeText, sleep, settle, shot, select, undo, } = t; const inOverlay = async (text) => { const box = await json('__gm.overlayRect()'); const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? []; return ( hits.find((h) => h.x >= box.x && h.x <= box.x + box.w && h.y >= box.y && h.y <= box.y + box.h) ?? null ); }; const press_ = async (at, what) => { if (!at) throw new Error(`nothing to click: ${what}`); await click(at.x, at.y); await sleep(150); }; /** A menu entry, always the last drawn: the panels behind a menu carry the same words. */ const pick = async (label) => press_(await json(`__gm.locate(${JSON.stringify(label)}, true)`), label); /** * Types into the text field showing `text`, replacing it. The rightmost hit: the inspector is the * right-hand panel, and an open graph shows the same names on its ports. */ const retype = async (text, next) => { const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? []; await press_(hits.sort((a, b) => b.x - a.x)[0], text); await press('a', { ctrl: true }); await typeText(next); await press('Enter'); await sleep(150); }; const asset = (id) => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}) ?? null`); const chest = () => asset('st54-chest'); const variable = async (key) => (await chest()).variables.find((v) => v.key === key); const override = () => json("__gm.state.activeScene.objects.find((o) => o.id === 'st54-obj')?.overrides.drop ?? null"); const expand = async () => (await chest()).graph.nodes.find((n) => n.id === 'st54-expand'); const edgeFrom = async () => (await chest()).graph.edges.find((e) => e.id === 'st54-edge')?.from.port ?? null; // A behavior with two structs holding their own fields, an Expand and a Log in its graph, and one // placed object overriding `drop`. await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; p.assets.push({ id: 'st54-chest', type: 'behavior', name: 'Chest 54', props: { enabled: true, runInEditor: false, drop: { Title: 'Gem' }, bag: { Gold: 5 } }, variables: [ { key: 'drop', label: 'drop', kind: 'struct', default: { Title: '' }, fields: [{ key: 'Title', kind: 'text' }] }, { key: 'bag', label: 'bag', kind: 'struct', default: { Gold: 0 }, fields: [{ key: 'Gold', kind: 'number' }] }, ], graph: { nodes: [ { id: 'st54-expand', type: 'ExpandStruct', x: 0, y: 0, params: {} }, { id: 'st54-log', type: 'Log', x: 240, y: 0, params: {} }, ], edges: [], }, }); ${MAKE_OBJECT} const placed = make('st54-obj', 'st54-chest', '', 400, 'Chest 54'); placed.overrides = { drop: { Title: 'Ruby' } }; scene.objects.push(placed); `); // 54a. The + menu makes a Struct; its inspector declares fields as a Variables asset declares // variables, and each field has a default. await t.showTab('scene'); await clickText('+'); await clickText('Logics'); const offered = (await json('__gm.menuRows()')) ?? []; // Should the submenu outgrow a short canvas, Struct is scrolled to; the lowest hit between the // box's sides is the entry, not the tab above it. await inView( t, '__gm.overlayRect()', `(() => { const box = __gm.overlayRect(); const hits = (__gm.locateAll('Struct') ?? []).filter( (hit) => hit.x >= box.x && hit.x <= box.x + box.w && hit.y >= box.y, ); return hits.sort((a, b) => a.y - b.y).at(-1) ?? null; })()`, ); const entry = await inOverlay('Struct'); check('the + menu offers Struct', offered.includes('Struct') && Boolean(entry), JSON.stringify(offered)); if (!entry) return; await press_(entry, 'Struct'); await settle(); const lootId = ( await json("__gm.state.project.assets.filter((a) => a.type === 'struct').map((a) => a.id)") ).at(-1); await mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(lootId)}).name = 'Loot 54'`); const row = await json(`__gm.assetRow(${JSON.stringify(lootId)})`); await press_(row, 'the Loot 54 row'); check( 'a new Struct selects itself and says it has no fields', (await t.has('FIELDS')) && (await t.has('+ Field')) && (await t.has('No fields yet.')), ); await clickText('+ Field'); await pick('Text'); await retype('field1', 'Title'); await clickText('+ Field'); await pick('Number'); await retype('field1', 'Amount'); await press_(await json("__gm.inspectorField('Title')"), 'Title value'); await typeText('Coin'); await press('Enter'); await press_(await json("__gm.inspectorField('Amount')"), 'Amount value'); await press('a', { ctrl: true }); await typeText('7'); await press('Enter'); await sleep(150); const loot = await asset(lootId); check( '+ Field, a new name and a value give the struct its fields and their defaults', JSON.stringify(loot.variables.map((v) => `${v.key}:${v.kind}`)) === '["Title:text","Amount:number"]' && JSON.stringify(loot.props) === '{"Title":"Coin","Amount":7}', JSON.stringify({ variables: loot.variables.map((v) => v.key), props: loot.props }), ); await shot('01-struct-fields'); // 54b. A struct variable picks the type under its head. It takes the type's fields, keeps what // fits of its value and cannot grow fields of its own any more. await select({ kind: 'asset', assetId: 'st54-chest' }); await sleep(150); const addsBefore = ((await json("__gm.locateAll('+ Field')")) ?? []).length; const typeRows = ((await json("__gm.locateAll('Own fields')")) ?? []).sort((a, b) => a.y - b.y); check( 'each struct variable has a Type row reading "Own fields"', typeRows.length === 2, JSON.stringify(typeRows), ); await press_(typeRows[0], 'Type of drop'); await pick('Loot 54'); const drop = await variable('drop'); const props = (await chest()).props; check( 'picking Loot 54 gives drop its fields, keeps "Gem" and fills Amount with the default 7', drop.structType === lootId && JSON.stringify(drop.fields.map((f) => f.key)) === '["Title","Amount"]' && JSON.stringify(props.drop) === '{"Title":"Gem","Amount":7}', JSON.stringify({ drop, value: props.drop }), ); check( 'and the placed override takes the shape as well', JSON.stringify(await override()) === '{"Title":"Ruby","Amount":7}', JSON.stringify(await override()), ); const addsAfter = ((await json("__gm.locateAll('+ Field')")) ?? []).length; check( 'a typed struct offers no + Field, its row names the type', addsBefore === 2 && addsAfter === 1 && (await t.has('Loot 54')), JSON.stringify({ addsBefore, addsAfter }), ); await shot('02-typed-variable'); // 54c. Expand's Type setting points it at the asset: its ports are the fields. await select({ kind: 'node', assetId: 'st54-chest', nodeId: 'st54-expand' }); await sleep(150); await press_(await json("__gm.inspectorField('Type')"), 'Type of Expand'); // The asset picker names the kind after each asset. await pick('Loot 54 (struct)'); const ports = (await expand()).ports ?? []; check( 'Expand with the type has one output per field, and its fields are not edited there', JSON.stringify(ports.map((p) => p.key)) === '["Title","Amount"]' && (await t.has('The fields of Loot 54. Change them there.')) && !(await t.has('+ Field')), JSON.stringify(ports), ); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === 'st54-chest').graph; graph.edges.push({ id: 'st54-edge', from: { node: 'st54-expand', port: 'Amount' }, to: { node: 'st54-log', port: 'message' } }); `); await shot('03-typed-expand'); // 54d. Renaming a field on the asset reaches every place of the type in one step, values kept. await select({ kind: 'asset', assetId: lootId }); await sleep(150); await retype('Amount', 'Count'); const renamed = { fields: (await variable('drop')).fields.map((f) => f.key), value: (await chest()).props.drop, override: await override(), ports: ((await expand()).ports ?? []).map((p) => p.key), edge: await edgeFrom(), }; check( 'renaming Amount to Count reaches the variable, its value, the override, the port and its edge', JSON.stringify(renamed) === JSON.stringify({ fields: ['Title', 'Count'], value: { Title: 'Gem', Count: 7 }, override: { Title: 'Ruby', Count: 7 }, ports: ['Title', 'Count'], edge: 'Count', }), JSON.stringify(renamed), ); await undo(); const back = { fields: (await variable('drop')).fields.map((f) => f.key), value: (await chest()).props.drop, override: await override(), edge: await edgeFrom(), }; check( 'one undo takes the rename back everywhere', JSON.stringify(back) === JSON.stringify({ fields: ['Title', 'Amount'], value: { Title: 'Gem', Amount: 7 }, override: { Title: 'Ruby', Amount: 7 }, edge: 'Amount', }), JSON.stringify(back), ); // 54e. "Make struct type" turns the fields bag holds by itself into a Struct asset. await select({ kind: 'asset', assetId: 'st54-chest' }); await sleep(150); await press_(await json("__gm.locate('Own fields')"), 'Type of bag'); await pick('Make struct type'); const bag = await variable('bag'); const made = await asset(bag.structType ?? ''); check( 'Make struct type makes a Struct asset of bag, its value becoming the defaults', made?.type === 'struct' && made.name === 'bag' && JSON.stringify(made.props) === '{"Gold":5}' && JSON.stringify((await chest()).props.bag) === '{"Gold":5}', JSON.stringify({ bag, made }), ); // 54f. Deleting the type asks first and says what happens; the places keep the fields. const lootRow = await json(`__gm.assetRow(${JSON.stringify(lootId)})`); await click(lootRow.x, lootRow.y, 'right'); await sleep(150); await pick('Delete…'); const detail = '1 struct(s) and 1 node(s) use it and keep its fields as their own.'; check('the delete dialog counts the places using the type', await t.has(detail)); await shot('04-delete-dialog'); await pick('Delete'); const kept = await variable('drop'); check( 'after deleting, drop keeps Title and Amount and its value', (await asset(lootId)) === null && JSON.stringify(kept.fields.map((f) => f.key)) === '["Title","Amount"]' && JSON.stringify((await chest()).props.drop) === '{"Title":"Gem","Amount":7}', JSON.stringify(kept), ); await select({ kind: 'asset', assetId: 'st54-chest' }); await sleep(150); check( 'and its Type row reads "Own fields" again, with + Field back', ((await json("__gm.locateAll('+ Field')")) ?? []).length === 1 && (await t.has('Own fields')), ); // Clean up. await evaluate('__gm.state.select(null)'); await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => o.id !== 'st54-obj'); const made = ${JSON.stringify(made?.id ?? '')}; p.assets = p.assets.filter((a) => a.id !== 'st54-chest' && a.id !== made && a.id !== ${JSON.stringify(lootId)}); `); }