import { edge, node } from '../lib/builders.mjs'; import { LANES, scaffoldBehavior } from './lib/scaffold.mjs'; /** * State machines: an asset made from the + menu, its states edited as nodes in the State machine * tab, read by a `State branch` and moved by a `Set state` in a behavior. The step removes * everything it made. */ export async function stateMachine(t) { const { check, sleep, json, evaluate, shot, mutate, press, click, clickText, locate, has, doubleClick, drag, } = t; const { typeText, undo, redo, world, play, stop } = t; const machines = () => json(`__gm.state.project.assets .filter((a) => a.type === 'statemachine') .map((a) => ({ id: a.id, name: a.name, states: a.graph.nodes.map((n) => ({ id: n.id, name: n.params.name, x: n.x, y: n.y })) }))`); const states = async () => (await machines())[0]?.states ?? []; /** The overlay is drawn last, so a menu entry wins over a tab button or a row saying the same. */ const clickLast = async (text) => { const p = await locate(text, true); await click(p.x, p.y); await sleep(200); }; const stateLabel = (id) => json(`__gm.stateMachine.nodeLabel(${JSON.stringify(id)})`); const selection = () => json('__gm.state.selection'); const outputsOf = async (nodeId) => (await json(`__gm.graph.nodeOutputs(${JSON.stringify(nodeId)})`)) ?? []; const inputsOf = async (nodeId) => (await json(`__gm.graph.nodeInputs(${JSON.stringify(nodeId)})`)) ?? []; const selectNode = async (nodeId) => { await evaluate( `__gm.state.select({ kind: 'node', assetId: 'sm-logic', nodeId: ${JSON.stringify(nodeId)}, nodeIds: [${JSON.stringify(nodeId)}] })`, ); await sleep(400); }; await press('Escape'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('__gm.state.select(null)'); await sleep(200); // 26a. The + menu creates a state machine; selected, it opens its own tab. await clickText('+'); await sleep(200); await clickLast('State machine'); await sleep(400); const made = (await machines())[0]; check( 'the + menu creates a state machine asset', Boolean(made) && (await machines()).length === 1, JSON.stringify(await machines()), ); const picked = await selection(); check( 'the new machine is selected and its tab opens on it', picked?.kind === 'asset' && picked.assetId === made?.id && (await json('__gm.tab()')) === 'statemachine' && (await json('__gm.stateMachine.current()')) === made?.id, JSON.stringify({ picked, tab: await json('__gm.tab()'), current: await json('__gm.stateMachine.current()'), }), ); check( 'the tab button has room for its label', (await json("__gm.shownLabel('State machine')")) === 'State machine', String(await json("__gm.shownLabel('State machine')")), ); // 26b. Right-click places a state where the pointer is, numbered and selected; no header button. const canvas = await json('__gm.stateMachine.canvasRect()'); const addState = async (dy) => { await click(canvas.x + 80, canvas.y + dy, 'right'); await sleep(300); await clickLast('State'); await sleep(300); }; await addState(60); await addState(220); const two = await states(); check( 'right-click → State adds a state each time, named by its number', two.map((s) => s.name).join(',') === 'State 1,State 2', JSON.stringify(two), ); check('the tab header has no + State button', !(await has('+ State'))); check( 'the new state is selected and its node shows its name', (await selection())?.nodeId === two[1]?.id && (await stateLabel(two[1]?.id)) === 'State 2', JSON.stringify({ selection: await selection(), label: await stateLabel(two[1]?.id) }), ); const rects = await Promise.all( two.map((s) => json(`__gm.stateMachine.nodeRect(${JSON.stringify(s.id)})`)), ); check( 'each state sits where its right-click was, the second below the first', rects[0] && rects[1] && rects[0].y + rects[0].h <= rects[1].y, JSON.stringify(rects), ); await shot('01-two-states'); // 26b2. The header's Snap checkbox is the graph's: on, a dragged state lands on the 24 px grid. const GRID = 24; const stateXY = async (id) => (await states()).find((s) => s.id === id) ?? { x: NaN, y: NaN }; check('snapping is off until it is asked for', (await json('__gm.stateMachine.snapping()')) === false); const snapLabel = await locate('Snap'); const preSnap = JSON.stringify(await states()); // The checkbox sits right of the "Snap" label; locate() gives the label's text centre. await click(snapLabel.x + 30, snapLabel.y); await sleep(150); check( 'the Snap toggle switches it on without moving a state', (await json('__gm.stateMachine.snapping()')) === true && JSON.stringify(await states()) === preSnap, JSON.stringify(await states()), ); // The selected state: dragging another would move the selection off it, and 26c renames the selected one. const dragged = two[1]?.id; const from = await json(`__gm.stateMachine.nodeAt(${JSON.stringify(dragged)})`); const wasAt = await stateXY(dragged); await drag(from.x, from.y, from.x + 50, from.y + 50); await sleep(200); const nowAt = await stateXY(dragged); check( 'a dragged state lands on the grid, not where the pointer let go', nowAt.x % GRID === 0 && nowAt.y % GRID === 0 && (nowAt.x !== wasAt.x || nowAt.y !== wasAt.y), `${wasAt.x},${wasAt.y} → ${nowAt.x},${nowAt.y}`, ); await undo(); const backAt = await stateXY(dragged); check( 'one undo takes the snapped drag back', backAt.x === wasAt.x && backAt.y === wasAt.y, `${backAt.x},${backAt.y}`, ); await click(snapLabel.x + 30, snapLabel.y); await sleep(150); check('the toggle switches snapping off again', (await json('__gm.stateMachine.snapping()')) === false); // 26c. The inspector renames the selected state; the node follows. const nameField = await json("__gm.inspectorField('Name', true)"); check('the inspector shows a Name field for the state', Boolean(nameField), JSON.stringify(nameField)); await doubleClick(nameField.x, nameField.y); await typeText('Open'); await press('Enter'); await sleep(300); const renamed = (await states())[1]; check( 'typing in the Name field renames the state, on the node too', renamed?.name === 'Open' && (await stateLabel(renamed?.id)) === 'Open', JSON.stringify({ renamed, label: await stateLabel(renamed?.id) }), ); // 26d. Undo and redo walk the rename and the additions back and forth. await undo(); const afterOne = (await states()).map((s) => s.name).join(','); await undo(); const afterTwo = (await states()).map((s) => s.name).join(','); await redo(); await redo(); const back = (await states()).map((s) => s.name).join(','); check( 'undo takes the rename and then the state back, redo brings both', afterOne === 'State 1,State 2' && afterTwo === 'State 1' && back === 'State 1,Open', JSON.stringify({ afterOne, afterTwo, back }), ); // 26e. The canvas palette offers a state and nothing else. await click(canvas.x + canvas.w - 40, canvas.y + canvas.h - 40, 'right'); await sleep(300); const offered = await json('__gm.overlayTexts()'); check( 'right-click offers a State and no behavior node', offered.includes('State') && !offered.some((text) => /branch|Set state|Number|On start/i.test(text)), JSON.stringify(offered), ); await press('Escape'); await sleep(200); // 26f. A behavior reads the machine: Set state on start, State branch every frame. const [first, second] = (await states()).map((s) => s.id); const rig = await scaffoldBehavior(t, { assetId: 'sm-logic', name: 'Doors logic', boxId: 'sm-box', boxName: 'State box', logicId: 'sm-brain', y: LANES.stateMachine, graph: { nodes: [ node('sm-start', 'OnStart', 0, 0), node('sm-set', 'SetState', 300, 0, { machine: made.id }), node('sm-update', 'OnUpdate', 0, 240), node('sm-branch', 'StateBranch', 300, 240, { machine: made.id }), node('sm-log', 'Log', 600, 200, { message: 'first state' }), node('sm-log2', 'Log', 600, 360, { message: 'second state' }), ], edges: [ edge('sm-e1', 'sm-start', 'exec', 'sm-set', first), edge('sm-e2', 'sm-update', 'exec', 'sm-branch', 'exec'), edge('sm-e3', 'sm-branch', first, 'sm-log', 'exec'), edge('sm-e4', 'sm-branch', second, 'sm-log2', 'exec'), ], }, }); await selectNode('sm-branch'); check( 'selecting a behavior node opens the Behavior tab on that behavior', (await json('__gm.tab()')) === 'graph' && (await json('__gm.graph.current()')) === 'sm-logic', JSON.stringify({ tab: await json('__gm.tab()'), current: await json('__gm.graph.current()') }), ); const branchOutputs = await outputsOf('sm-branch'); const setInputs = await inputsOf('sm-set'); const names = (lines) => lines.map((line) => line.trim()); check( 'State branch grows one output per state, named after it', names(branchOutputs).includes('State 1') && names(branchOutputs).includes('Open'), JSON.stringify(branchOutputs), ); check( 'Set state grows one input per state, named after it', names(setInputs).includes('State 1') && names(setInputs).includes('Open'), JSON.stringify(setInputs), ); await shot('02-behavior-ports'); await play(); await sleep(600); const log = (await world())?.log ?? []; check( 'in play the branch follows the state Set state put the machine in', log.includes('first state') && !log.includes('second state'), JSON.stringify([...new Set(log)]), ); await stop(); // 26g. An unconnected machine input shows a picker; picking a machine grows the state ports. await mutate(` const a = __gm.state.project.assets.find((x) => x.id === 'sm-logic'); a.graph.nodes.push(${JSON.stringify(node('sm-fresh', 'StateBranch', 300, 520))}); `); await selectNode('sm-fresh'); check('without a machine a State branch has no state outputs', (await outputsOf('sm-fresh')).length === 0); // The row is named after the port, as the node body names it. const machineField = await json("__gm.inspectorField('machine', true)"); await click(machineField.x, machineField.y); await sleep(300); const choices = await json('__gm.overlayTexts()'); check( 'the Machine picker offers the machine by name', choices.includes(made.name), JSON.stringify(choices), ); await clickLast(made.name); await sleep(400); check( 'picking it grows the state outputs', names(await outputsOf('sm-fresh')).includes('Open'), JSON.stringify(await outputsOf('sm-fresh')), ); await shot('03-machine-picked'); // 26h. Everything the step made goes away again. const left = await rig.remove(); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== ${JSON.stringify(made.id)}); `); await evaluate('__gm.state.select(null)'); await sleep(300); check( 'the step leaves nothing behind', left.objects === 0 && left.assets === 0 && (await machines()).length === 0, JSON.stringify({ left, machines: await machines() }), ); }