import { edge, node } from '../lib/builders.mjs'; import { LANES, pickAssetType, 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, state, evaluate, shot, mutate, press, click, clickText, locate, has, doubleClick, drag, mouse, showTab, } = t; const { settle, 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); }; 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 t.select({ kind: 'node', assetId: 'sm-logic', nodeId, nodeIds: [nodeId] }); }; await press('Escape'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('__gm.state.select(null)'); // 26a. The + menu creates a state machine; selected, it opens its own tab. await clickText('+'); await pickAssetType(t, 'State machine'); 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 clickLast('State'); }; 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 magnet 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 magnet = await json('__gm.stateMachine.snapButton()'); const preSnap = JSON.stringify(await states()); await click(magnet.x, magnet.y); check( 'the magnet 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); 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(magnet.x, magnet.y); check('the magnet 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'); 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'); 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'); // 26e2. A transition is an ordinary edge, but it hangs on ports the editor never draws: a // transition joins two whole states, so there is no socket to aim at and none to grab. const [first, second] = (await states()).map((s) => s.id); const outPin = await json(`__gm.stateMachine.portAt(${JSON.stringify(first)}, 'out', true)`); const inPin = await json(`__gm.stateMachine.portAt(${JSON.stringify(second)}, 'in', false)`); check( 'every state still carries the out and in port an edge needs to exist at all', outPin !== null && inPin !== null, JSON.stringify({ outPin, inPin }), ); const grabbable = async (id, port, isOutput) => json(`__gm.stateMachine.portGrabbable(${JSON.stringify(id)}, ${JSON.stringify(port)}, ${isOutput})`); check( 'but neither can be grabbed, because neither is drawn', (await grabbable(first, 'out', true)) === false && (await grabbable(second, 'in', false)) === false, ); const transitions = () => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).graph.edges`); const centreOfState = async (id) => { const r = await json(`__gm.stateMachine.nodeRect(${JSON.stringify(id)})`); return { x: r.x + r.w / 2, y: r.y + r.h / 2 }; }; const makeTransition = async (fromId, toId) => { const from = await centreOfState(fromId); await click(from.x, from.y, 'right'); await clickLast('Make transition'); const to = await centreOfState(toId); await click(to.x, to.y); }; // The right-click way in, since there is no pin to drag from any more. const firstCentre = await centreOfState(first); await click(firstCentre.x, firstCentre.y, 'right'); check( "a state's menu offers Make transition", (await json('__gm.overlayTexts()')).includes('Make transition'), JSON.stringify(await json('__gm.overlayTexts()')), ); await clickLast('Make transition'); check( 'armed, the canvas says which click it is waiting for', (await json('__gm.stateMachine.hint()')).includes('Click the state'), JSON.stringify(await json('__gm.stateMachine.hint()')), ); check( 'and the line to the pointer is already on screen', (await json('__gm.stateMachine.danglingEdge()')) === true, ); await shot('05a-armed'); await press('Escape'); check( 'Escape drops the attempt, drawing nothing', (await transitions()).length === 0 && (await json('__gm.stateMachine.danglingEdge()')) === false, ); await makeTransition(first, second); const drawn = await transitions(); check( 'clicking the other state connects them', drawn.length === 1 && drawn[0]?.from.node === first && drawn[0]?.to.node === second, JSON.stringify(drawn), ); check( 'the transition is really stroked, not dropped for want of an anchor', (await json(`__gm.stateMachine.edgeDrawn(${JSON.stringify(drawn[0]?.id)})`)) === true, ); await undo(); check('undo takes the transition back', (await transitions()).length === 0); await redo(); check('redo puts it back', (await transitions()).length === 1); await shot('05-transition'); // 26e3. Naming a transition, from the list the inspector shows on the state it leaves. const edgeId = (await transitions())[0]?.id; await t.select({ kind: 'node', assetId: made.id, nodeId: first, nodeIds: [first] }); check( 'the inspector lists the transitions of the selected state, both ways round', (await has('TRANSITIONS OUT')) && (await has('TRANSITIONS IN')), ); await evaluate(`__gm.stateMachine.nameTransition(${JSON.stringify(edgeId)}, 'alarm')`); await settle(); check( 'a named transition carries the name on the edge itself', (await transitions())[0]?.label === 'alarm', JSON.stringify(await transitions()), ); check( 'and the name is drawn on the arrow, not only listed in the panel', (await json(`__gm.stateMachine.transitionLabel(${JSON.stringify(edgeId)})`)) === 'alarm', ); // The field is only findable once it holds text, so the rename is what proves it is really a field. const field = await locate('alarm', true); // Double-click, so the name is selected and replaced – a single click only parks the caret in it. await doubleClick(field.x, field.y); await typeText('lost'); await press('Enter'); check( 'and the panel row is a real field: typing in it renames the transition', (await transitions())[0]?.label === 'lost', JSON.stringify(await transitions()), ); await evaluate(`__gm.stateMachine.nameTransition(${JSON.stringify(edgeId)}, 'alarm')`); await settle(); await shot('05b-named'); // 26e4. Both ways round between the same pair: the two arrows must not lie on top of each other. await makeTransition(second, first); const both = await transitions(); check('a transition back is allowed alongside the one there', both.length === 2, JSON.stringify(both)); const drawnBoth = await Promise.all( both.map((e) => json(`__gm.stateMachine.edgeDrawn(${JSON.stringify(e.id)})`)), ); check('both arrows are stroked', drawnBoth.every(Boolean), JSON.stringify(drawnBoth)); await shot('05c-both-ways'); await undo(); // 26e5. (#163) The click that lands on the state the transition started from makes one back into // it – a loop over the node, which re-enters the state when it is taken. const beforeLoop = (await transitions()).length; await makeTransition(first, first); const looped = await transitions(); const loop = looped.find((e) => e.from.node === first && e.to.node === first); check( 'clicking the state the transition started from makes one back into itself', looped.length === beforeLoop + 1 && loop !== undefined, JSON.stringify(looped), ); await evaluate(`__gm.stateMachine.nameTransition(${JSON.stringify(loop?.id)}, 'again')`); await settle(); const loopLabel = await json(`__gm.stateMachine.transitionLabelAt(${JSON.stringify(loop?.id)})`); const firstRect = await json(`__gm.stateMachine.nodeRect(${JSON.stringify(first)})`); check( 'it is stroked as a loop over the state: its name sits above the node, within its width', (await json(`__gm.stateMachine.edgeDrawn(${JSON.stringify(loop?.id)})`)) === true && loopLabel !== null && loopLabel.y < firstRect.y && loopLabel.x > firstRect.x && loopLabel.x < firstRect.x + firstRect.w, JSON.stringify({ loopLabel, firstRect }), ); await shot('05d-self'); await undo(); await undo(); check('undo takes the loop back', (await transitions()).length === beforeLoop); // 26e6. (#162) A second transition between the same pair, in the same direction: it gets a name // of its own, is drawn beside the first, and cannot be renamed into the first one's name. await makeTransition(first, second); const pair = (await transitions()).filter((e) => e.from.node === first && e.to.node === second); const extra = pair.find((e) => e.label !== 'alarm'); check( 'a second transition between the same states is made, under a name of its own', pair.length === 2 && extra?.label === 'Transition 2', JSON.stringify(pair), ); const pairLabels = await Promise.all( pair.map((e) => json(`__gm.stateMachine.transitionLabelAt(${JSON.stringify(e.id)})`)), ); check( 'both are stroked, their names side by side rather than on top of each other', pairLabels.every((l) => l !== null) && !( pairLabels[0].x < pairLabels[1].x + pairLabels[1].w && pairLabels[1].x < pairLabels[0].x + pairLabels[0].w && pairLabels[0].y < pairLabels[1].y + pairLabels[1].h && pairLabels[1].y < pairLabels[0].y + pairLabels[0].h ), JSON.stringify(pairLabels), ); await shot('05e-parallel'); await evaluate("__gm.state.notice = ''"); const extraField = await locate('Transition 2', true); await doubleClick(extraField.x, extraField.y); await typeText('alarm'); await press('Enter'); check( "renaming it into the first one's name is refused with a notice", (await transitions()).find((e) => e.id === extra?.id)?.label === 'Transition 2' && (await json('__gm.state.notice')).includes('already called "alarm"'), JSON.stringify({ edges: await transitions(), notice: await json('__gm.state.notice') }), ); await undo(); check( 'undo takes the second one back and leaves the first', (await transitions()).filter((e) => e.from.node === first && e.to.node === second).length === 1, ); // 26e7. (#289) The arrow itself is a handle: a click picks the transition and the inspector shows // it, Delete takes exactly that one, and an undo brings it back. const alarm = (await transitions()).find((e) => e.from.node === first && e.to.node === second); const arrow = await json(`__gm.stateMachine.edgeAt(${JSON.stringify(alarm?.id)})`); const beforePick = (await transitions()).length; await click(arrow.x, arrow.y); const arrowSelection = (await state()).selection; await shot('05f-picked'); check( 'clicking a transition picks it: the inspector shows it with its name and no button of its own', arrowSelection?.kind === 'edge' && arrowSelection.edgeId === alarm?.id && JSON.stringify(await json('__gm.stateMachine.pickedEdges()')) === JSON.stringify([alarm?.id]) && (await has('TRANSITION')) && !(await has('Delete transition')), JSON.stringify({ arrowSelection, drawn: await json('__gm.stateMachine.pickedEdges()') }), ); await press('Delete'); const afterDelete = await transitions(); await undo(); check( 'Delete takes the picked transition alone, and undo brings it back', afterDelete.length === beforePick - 1 && !afterDelete.some((e) => e.id === alarm?.id) && (await transitions()).some((e) => e.id === alarm?.id && e.label === 'alarm'), JSON.stringify({ beforePick, afterDelete }), ); // 26f. A behavior reads the machine: Set state on start, State branch every frame. 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(); // 26f2. What a named transition is for at runtime: State transition takes the step called "alarm". await mutate(` const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic'); b.graph.nodes.push({ id: 'sm-next', type: 'StateTransition', x: 600, y: 0, params: { machine: ${JSON.stringify(made.id)}, transition: 'alarm' }, }); b.graph.edges.push({ id: 'sm-e5', from: { node: 'sm-set', port: 'exec' }, to: { node: 'sm-next', port: 'exec' }, }); `); await play(); await sleep(600); const moved = (await world())?.log ?? []; check( 'State transition follows the transition of that name, so the branch takes the state it points at', moved.includes('second state') && !moved.includes('first state'), JSON.stringify([...new Set(moved)]), ); await stop(); // A name the current state does not carry moves nothing – the machine stays where it was put. await mutate(` const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic'); b.graph.nodes.find((n) => n.id === 'sm-next').params.transition = 'no such transition'; `); await play(); await sleep(600); const stayed = (await world())?.log ?? []; check( 'a name the state does not carry leaves the machine where it was', stayed.includes('first state') && !stayed.includes('second state'), JSON.stringify([...new Set(stayed)]), ); await stop(); await mutate(` const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic'); b.graph.nodes.find((n) => n.id === 'sm-next').params.transition = 'alarm'; `); // 26f3. A start state: marked from the state's own menu, worn as a ring, entered by every // instance before anything reads the machine – and heard as `On state entered`. await showTab('statemachine'); const startOf = () => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).start ?? ''`); const ringOn = (id) => json(`__gm.stateMachine.startRing(${JSON.stringify(id)})`); const marks = () => json('__gm.stateMachine.stateMarks()'); const startCentre = await centreOfState(first); await click(startCentre.x, startCentre.y, 'right'); const stateMenu = await json('__gm.overlayTexts()'); check( 'a state offers to become the start state', stateMenu.includes('Set as start state'), JSON.stringify(stateMenu), ); await clickLast('Set as start state'); check( 'the mark is kept on the asset and drawn as a ring on that state alone', (await startOf()) === first && (await ringOn(first)) === true && (await ringOn(second)) === false, JSON.stringify({ start: await startOf(), first: await ringOn(first), second: await ringOn(second) }), ); await shot('05-start-state'); // Nothing sets the machine any more: what the branch follows can only be the start state. await mutate(` const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic'); b.graph.edges = b.graph.edges.filter((e) => e.id !== 'sm-e1'); b.graph.nodes.push( ${JSON.stringify(node('sm-entered', 'OnStateEntered', 900, 520, { machine: made.id }))}, ${JSON.stringify(node('sm-entered-log', 'Log', 1200, 520, { message: 'entered the start' }))}, ); b.graph.edges.push(${JSON.stringify(edge('sm-e6', 'sm-entered', first, 'sm-entered-log', 'exec'))}); `); await play(); await sleep(600); const started = (await world())?.log ?? []; check( 'every instance begins in the start state, with no Set state anywhere', started.includes('first state') && !started.includes('second state'), JSON.stringify([...new Set(started)]), ); check( 'and hears On state entered for it', started.filter((line) => line === 'entered the start').length === 1, JSON.stringify(started.filter((line) => line === 'entered the start')), ); // 26f4. The tab shows where the run is: weakly for any instance, strongly for the selected one. await showTab('statemachine'); await evaluate('__gm.state.select(null)'); const loose = await marks(); check( 'while playing, the state the machine is in is marked', loose[first] === 'held' && loose[second] === undefined, JSON.stringify(loose), ); await evaluate( "__gm.state.select({ kind: 'object', holderId: __gm.state.activeScene.id, objectId: 'sm-box', objectIds: ['sm-box'] })", ); const picked2 = await marks(); check( "selecting the object makes its instance's state the strong one", picked2[first] === 'current', JSON.stringify(picked2), ); await shot('06-live-state'); await stop(); await showTab('statemachine'); check( 'nothing is marked once the run is over', Object.keys(await marks()).length === 0, JSON.stringify(await marks()), ); // 26f5. The same entry takes the mark off again, and the machine is back to starting nowhere. await evaluate('__gm.state.select(null)'); const againCentre = await centreOfState(first); await click(againCentre.x, againCentre.y, 'right'); const onMenu = await json('__gm.overlayTexts()'); check( 'the same place offers to take the mark off again', onMenu.includes('Remove start state') && !onMenu.includes('Set as start state'), JSON.stringify(onMenu), ); await clickLast('Remove start state'); check( 'the mark and its ring are gone', (await startOf()) === '' && (await ringOn(first)) === false, JSON.stringify({ start: await startOf(), ring: await ringOn(first) }), ); await play(); await sleep(600); const nowhere = (await world())?.log ?? []; check( 'without a start state the branch follows nothing again', !nowhere.includes('first state') && !nowhere.includes('entered the start'), JSON.stringify([...new Set(nowhere)]), ); await stop(); // Back to the graph the later sections expect: the set on start, and no state watcher. await mutate(` const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic'); b.graph.nodes = b.graph.nodes.filter((n) => n.id !== 'sm-entered' && n.id !== 'sm-entered-log'); b.graph.edges = b.graph.edges.filter((e) => e.id !== 'sm-e6'); b.graph.edges.push(${JSON.stringify(edge('sm-e1', 'sm-start', 'exec', 'sm-set', first))}); `); // 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'); // "else" is the one output it always has: a branch that continues nowhere cannot be seen. const freshOutputs = await outputsOf('sm-fresh'); check( 'without a machine a State branch has no state outputs, only else', freshOutputs.map((line) => line.trim()).join() === 'else', JSON.stringify(freshOutputs), ); // 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); 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); 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'); // 26i. A deleted state leaves its edge hanging; the port comes back as a ghost so it can be seen. const named = await states(); const nameOf = (id) => named.find((s) => s.id === id)?.name; check( 'before the deletion the edge to the second state is drawn', (await json("__gm.graph.edgeDrawn('sm-e4')")) === true, ); await mutate(` const m = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); m.graph.nodes = m.graph.nodes.filter((n) => n.id !== ${JSON.stringify(second)}); `); const ghosted = await outputsOf('sm-branch'); check( 'the deleted state leaves a row named after its key, and takes its name away', names(ghosted).includes(second) && !names(ghosted).includes(nameOf(second)), JSON.stringify({ ghosted, was: nameOf(second) }), ); check( 'the edge on it is drawn again instead of vanishing', (await json("__gm.graph.edgeDrawn('sm-e4')")) === true, ); check( 'the node warns about it', (await json("__gm.graph.nodeProblem('sm-branch')")) === '1 edge on a port that is gone', JSON.stringify(await json("__gm.graph.nodeProblem('sm-branch')")), ); check( 'the ghost pin can be reached, so the edge has a right-click handle', (await json(`__gm.graph.portAt('sm-branch', ${JSON.stringify(second)}, true)`)) !== null, ); const pin = await json(`__gm.graph.portAt('sm-branch', ${JSON.stringify(second)}, true)`); const before = await json("__gm.graph.nodeRect('sm-branch')"); const links = () => json('__gm.state.project.assets.find((a) => a.id === "sm-logic").graph.edges.length'); const linksBefore = await links(); // Measured mid-drag: after the release nothing would show either way, because a ghost has no type // and `openPaletteForDrop` gives up on that. The rubber band is the only visible difference. const empty = await json('__gm.graph.emptySpot()'); await mouse('mouseMoved', pin.x, pin.y); await mouse('mousePressed', pin.x, pin.y, 'left', {}); await mouse('mouseMoved', empty.x, empty.y, 'left', { buttons: 1 }); await sleep(150); const dangling = await json('__gm.graph.danglingEdge()'); await mouse('mouseReleased', empty.x, empty.y, 'left', {}); await sleep(250); const after = await json("__gm.graph.nodeRect('sm-branch')"); check( 'pressing a ghost pin starts no rubber band, adds no link, and leaves the node where it was', dangling === false && (await links()) === linksBefore && after.x === before.x && after.y === before.y, JSON.stringify({ dangling, linksBefore, links: await links(), moved: after.x - before.x }), ); check( 'the state that is left keeps its edge and its name', (await json("__gm.graph.edgeDrawn('sm-e3')")) === true && names(await outputsOf('sm-branch')).includes(nameOf(first)), JSON.stringify({ lines: await outputsOf('sm-branch'), kept: nameOf(first) }), ); await shot('04-ghost-port'); // 26j. A machine that arrives as a value has no state ports at all: a text holding its id is not // followed, on purpose. `Set state by name` names the state instead, and the branch leaves // through `else` – the two halves of #58, measured in a running game. await mutate(` const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic'); b.graph.nodes.push( ${JSON.stringify(node('sm-id', 'String', 0, 700, { value: made.id }))}, ${JSON.stringify(node('sm-byname', 'SetStateByName', 300, 700, { state: nameOf(first) }))}, ${JSON.stringify(node('sm-dyn', 'StateBranch', 600, 700))}, ${JSON.stringify(node('sm-elselog', 'Log', 900, 700, { message: 'else taken' }))}, ${JSON.stringify(node('sm-miss', 'SetStateByName', 1200, 700, { state: 'Nope' }))}, ${JSON.stringify(node('sm-nflog', 'Log', 1500, 700, { message: 'no such state' }))}, ); b.graph.edges = b.graph.edges.filter((e) => e.id !== 'sm-e1'); b.graph.edges.push( ${JSON.stringify(edge('sm-d1', 'sm-id', 'value', 'sm-byname', 'machine'))}, ${JSON.stringify(edge('sm-d2', 'sm-id', 'value', 'sm-dyn', 'machine'))}, ${JSON.stringify(edge('sm-d3', 'sm-id', 'value', 'sm-miss', 'machine'))}, ${JSON.stringify(edge('sm-d4', 'sm-start', 'exec', 'sm-byname', 'exec'))}, ${JSON.stringify(edge('sm-d5', 'sm-byname', 'exec', 'sm-dyn', 'exec'))}, ${JSON.stringify(edge('sm-d6', 'sm-dyn', 'else', 'sm-elselog', 'exec'))}, ${JSON.stringify(edge('sm-d7', 'sm-elselog', 'exec', 'sm-miss', 'exec'))}, ${JSON.stringify(edge('sm-d8', 'sm-miss', 'notFound', 'sm-nflog', 'exec'))}, ); `); await selectNode('sm-dyn'); const dynOutputs = await outputsOf('sm-dyn'); check( 'a machine coming from a value grows no state outputs, so else is the whole branch', dynOutputs.map((line) => line.trim()).join() === 'else', JSON.stringify(dynOutputs), ); await play(); await sleep(600); const byName = (await world())?.log ?? []; check( 'Set state by name moves a machine the graph cannot name, and the static branch sees it', byName.includes('first state') && byName.includes('else taken') && byName.includes('no such state'), JSON.stringify([...new Set(byName)]), ); await stop(); await shot('05-by-name'); // 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)'); check( 'the step leaves nothing behind', left.objects === 0 && left.assets === 0 && (await machines()).length === 0, JSON.stringify({ left, machines: await machines() }), ); }