import { edge, node, port } from '../lib/builders.mjs'; /** * A behavior used as a node inside another behavior's graph: the ports it grows from the other * graph's start/end nodes, the call itself while playing, and declaring a port across the two. * * The step brings its own box and its own two behaviors; everything it adds is removed at the end. */ const SCALE = 100; /** `in1` → ×2 → `out1`, entered at the start node and left at the end node. */ const doubleGraph = { nodes: [ node('nb-start', 'BehaviorStart', 0, 0, {}, [port('in1', 'number')]), node('nb-times', 'Multiply', 240, 60, { b: 2 }), node('nb-end', 'BehaviorEnd', 520, 0, {}, [port('out1', 'number')]), ], edges: [ edge('nb-e1', 'nb-start', 'exec', 'nb-end', 'exec'), edge('nb-e2', 'nb-start', 'in1', 'nb-times', 'a'), edge('nb-e3', 'nb-times', 'result', 'nb-end', 'out1'), ], }; /** * `On update → Run(Double, in1 = 1) → Move(owner, dx = out1)`. The caller does no arithmetic of * its own: the speed it moves at *is* the value the nested behavior handed back. */ const callerGraph = { nodes: [ node('nb-update', 'OnUpdate', 0, 0), node('nb-owner', 'Owner', 0, 160), node('nb-call', 'RunBehavior', 260, 0, { behavior: 'nb-double', in1: 1 }), node('nb-move', 'Move', 560, 0, { scale: SCALE }), ], edges: [ edge('nb-c1', 'nb-update', 'exec', 'nb-call', 'exec'), edge('nb-c2', 'nb-call', 'exec', 'nb-move', 'exec'), edge('nb-c3', 'nb-call', 'out1', 'nb-move', 'dx'), edge('nb-c4', 'nb-update', 'dt', 'nb-move', 'dt'), edge('nb-c5', 'nb-owner', 'object', 'nb-move', 'object'), ], }; export async function nestedBehavior(t) { const { check, sleep, evaluate, json, shot, click, clickText, typeText, locate, press, undo, object, entity, asset, mutate, stop, } = t; const portAt = (nodeId, key, isOutput) => json(`__gm.graph.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`); const startPorts = async () => (await asset('nb-double')).graph.nodes.find((n) => n.id === 'nb-start').ports ?? []; // 19a. Two behaviors and a box wearing the calling one. Written into the model directly: what // is under test is the call, not another way of drawing a graph. await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; const proto = scene.objects[0]; const mover = p.assets.find((a) => a.id === 'mover'); const behavior = (id, name, graph) => { const a = structuredClone(mover); Object.assign(a, { id, name, props: { enabled: true, runInEditor: false }, variables: [], graph }); p.assets.push(a); }; behavior('nb-double', 'Double', ${JSON.stringify(doubleGraph)}); behavior('nb-caller', 'Caller', ${JSON.stringify(callerGraph)}); const make = (id, assetId, parentId, name) => { const o = structuredClone(proto); Object.assign(o, { id, assetId, name, position: { x: 0, y: 700, z: 0 }, parentId, overrides: {}, instanceEdits: {}, }); return o; }; scene.objects.push(make('nb-box', 'crate', '', 'Nested box'), make('nb-logic', 'nb-caller', 'nb-box', 'Caller')); `); await sleep(400); // 19b. The call really runs, and the value really comes back: the box moves at dx × scale, and // dx is nothing the caller holds – it is what the nested graph returned. Without the result // dx would be 0 and the box would not move at all. const boxX = async () => (await object('nb-box')).position.x; const idleX = await boxX(); await t.play(); await sleep(300); const before = (await entity('nb-box')).x; await sleep(1000); const after = (await entity('nb-box')).x; const speed = after - before; check( 'a behavior used as a node runs, and what it returns is what drives the caller', speed > SCALE * 1.4 && speed < SCALE * 2.6, `moved ${speed.toFixed(1)} px in ~1 s – 2 × ${SCALE} means the returned value arrived, 0 would mean nothing came back`, ); await stop(); await sleep(300); check( 'the stored object is untouched by the run, as in any play session', (await boxX()) === idleX, `x=${await boxX()}`, ); // 19c. The node in the graph carries the *other* behavior's ports and name – neither is part of // its own type. await evaluate( `__gm.state.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] })`, ); await sleep(300); const inPort = await portAt('nb-call', 'in1', false); const outPort = await portAt('nb-call', 'out1', true); check( 'the calling node grows the ports the other behavior declares', Boolean(inPort) && Boolean(outPort) && outPort.x > inPort.x, JSON.stringify({ inPort, outPort }), ); const title = await evaluate(`__gm.graph.nodeLabel('nb-call')`); check( 'and it is drawn under that behavior\'s name, not as "Run behavior"', title === 'Double', `title=${JSON.stringify(title)}`, ); await shot('37-nested-behavior'); // 19d. Declaring a port on one behavior reaches the node in the other one, live. This is the // check that a port list derived from another graph is really derived and not copied. check('the calling node has no second input yet', !(await portAt('nb-call', 'in2', false))); await evaluate( `__gm.state.select({ kind: 'node', assetId: 'nb-double', nodeId: 'nb-start', nodeIds: ['nb-start'] })`, ); await sleep(300); const addButton = await locate('+ Input'); check('the start node offers its inputs in the inspector', Boolean(addButton), JSON.stringify(addButton)); await shot('38-behavior-ports'); await click(addButton.x, addButton.y); await sleep(200); await clickText('Number'); await sleep(300); const declared = await startPorts(); check( 'adding an input declares it on the start node', declared.length === 2 && declared[1].key === 'in2' && declared[1].kind === 'number', JSON.stringify(declared), ); await evaluate( `__gm.state.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] })`, ); await sleep(300); check( 'and the node in the other graph grew that input without being touched', Boolean(await portAt('nb-call', 'in2', false)), ); // 19e. Undo takes the port back, and with it the port on the calling node. await undo(); await sleep(300); const afterUndo = await startPorts(); check( 'undo removes it in both graphs', afterUndo.length === 1 && !(await portAt('nb-call', 'in2', false)), `${JSON.stringify(afterUndo)}`, ); // 19f. The palette offers a behavior under its own name, and picking it leaves a node that // already names it – one pick, not a pick plus a dropdown. ("Double" also labels the asset // in the list on the left, so the proof is the node that appears, not the row.) const nodeIds = async () => (await asset('nb-caller')).graph.nodes.map((n) => n.id); const idsBefore = await nodeIds(); const centre = await json('__gm.graph.center()'); await click(centre.x + 250, centre.y + 250, 'right'); await sleep(150); check('node palette opens', Boolean(await locate('Search nodes…'))); await typeText('Double'); await sleep(200); // Enter takes the first match. NOT a click on the label: "Double" also names the asset in the // list on the left, and that is what a click by text would find first. await press('Enter'); await sleep(300); const added = (await asset('nb-caller')).graph.nodes.find((n) => !idsBefore.includes(n.id)); check( 'picking a behavior in the palette drops in a node already set to it', (await nodeIds()).length === idsBefore.length + 1 && added?.type === 'RunBehavior' && added?.params.behavior === 'nb-double', JSON.stringify(added && { type: added.type, params: added.params }), ); await undo(); await sleep(200); // 19g. Clean up: the two behaviors and the box are gone again. await evaluate(`__gm.state.select(null)`); await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; p.assets = p.assets.filter((a) => a.id !== 'nb-double' && a.id !== 'nb-caller'); scene.objects = scene.objects.filter((o) => o.id !== 'nb-box' && o.id !== 'nb-logic'); `); await sleep(300); }