import { edge, node } from '../lib/builders.mjs'; import { LANES, scaffoldBehavior } from './lib/scaffold.mjs'; /** * Pausing freezes world and clock and drops input instead of replaying it on resume. Brings its own * self-drifting box, since the fixture's Mover only moves on arrow keys and a pause would look like idling. */ const DRIFT_PER_SECOND = 100; const KEY_LOG = 'arrow down'; /** * Two independent things in one graph: `On update → Move` drifts the owner right without any * input, and `On key down → Log` leaves a mark for every *edge* the world saw – which is what a * pause has to swallow. */ const pauseGraph = { nodes: [ node('p-update', 'OnUpdate', 0, 0), node('p-owner', 'Owner', 0, 120), node('p-move', 'Move', 340, 0, { dx: 1, scale: DRIFT_PER_SECOND }), node('p-key', 'OnKeyDown', 0, 260, { key: 'ArrowRight' }), node('p-log', 'Log', 340, 260, { message: KEY_LOG }), ], edges: [ edge('p-e1', 'p-update', 'exec', 'p-move', 'exec'), edge('p-e2', 'p-update', 'dt', 'p-move', 'dt'), edge('p-e3', 'p-owner', 'object', 'p-move', 'object'), edge('p-e4', 'p-key', 'exec', 'p-log', 'exec'), ], }; export async function pause(t) { const { check, sleep, json, has, shot, click, clickText, press, entity, world, stop, select, evaluate, asset, } = t; const playInfo = () => json('__gm.playInfo()'); const boxX = async () => (await entity('p-box'))?.x ?? NaN; const keyHits = async () => (await world()).log.filter((line) => line.includes(KEY_LOG)).length; // 18a. A box with a drifting behavior below it, written into the model directly: what is under // test is the pause, not another way of building a graph. const rig = await scaffoldBehavior(t, { assetId: 'pauser', name: 'Pauser', graph: pauseGraph, boxId: 'p-box', boxName: 'Pause box', logicId: 'p-logic', y: LANES.pause, }); await t.play(); const running = await playInfo(); const beforeDrift = await boxX(); await sleep(500); const drifted = await boxX(); // The precondition of everything below: without movement, "it stood still" is trivially true. check( 'the box drifts on its own while playing, with nothing held down', running.playing === true && running.paused === false && drifted > beforeDrift + 20, `x ${beforeDrift} → ${drifted}, ${JSON.stringify(running)}`, ); // 18b. The toolbar button holds it. Both the clock and the box have to stop – a world that keeps // ticking while the picture happens to look the same would pass a position check alone. await clickText('❚❚ Pause'); await sleep(200); const held = await playInfo(); const heldX = await boxX(); await sleep(1000); const stillHeld = await playInfo(); check( 'pausing stops the world where it is: the box and the clock both stand still', held.paused === true && stillHeld.playing === true && Math.abs((await boxX()) - heldX) < 0.001 && Math.abs(stillHeld.time - held.time) < 0.05, `x ${heldX} → ${await boxX()}, clock ${held.time.toFixed(2)} → ${stillHeld.time.toFixed(2)} over 1 s`, ); // `locate` throws on a missing label, so the button that must be *gone* is asked for through // the hook, which answers with null. check( 'the button offers to resume while it is held, and no longer to pause', (await has('▶ Resume')) === true && (await has('❚❚ Pause')) === false, `resume=${await has('▶ Resume')} pause=${await has('❚❚ Pause')}`, ); await shot('36-paused'); // 18c. Keys pressed while it is held are dropped, not queued: resuming must not fire an edge for // each of them. The count before proves the graph reacts at all. const hitsBefore = await keyHits(); await press('ArrowRight'); await sleep(1000); const hitsWhilePaused = await keyHits(); await press('ArrowRight'); await press('ArrowRight'); await sleep(200); // 18d. Ctrl+Enter resumes, the same shortcut that started play. await press('Enter', { ctrl: true }); await sleep(400); const resumed = await playInfo(); const resumedX = await boxX(); check( 'resuming lets it move on from where it stopped, without catching up the held time', resumed.paused === false && resumedX > heldX && // A second of pause plus the pressing above: catching up would show as a jump far beyond // what the resumed run itself had time for. resumedX < heldX + DRIFT_PER_SECOND * 1.2, `x ${heldX} → ${resumedX}, clock ${stillHeld.time.toFixed(2)} → ${resumed.time.toFixed(2)}`, ); const hitsAfter = await keyHits(); check( 'a key pressed while paused is swallowed instead of firing on resume', hitsWhilePaused === hitsBefore && hitsAfter === hitsBefore, `${hitsBefore} hits before, ${hitsWhilePaused} while held, ${hitsAfter} after resuming`, ); // Without this the check above would also pass on a graph that never logs anything at all. await press('ArrowRight'); await sleep(300); check( 'the same key does fire once the game is running again', (await keyHits()) === hitsBefore + 1, `${hitsBefore} → ${await keyHits()}`, ); // 18e. A hold must never outlive its play: Esc stops from a paused game, and the next run starts // moving. A pause left standing would leave the editor unable to run anything. await press('Enter', { ctrl: true }); await sleep(200); check('Ctrl+Enter holds it again', (await playInfo()).paused === true); await stop(); const afterStop = await playInfo(); await t.play(); await sleep(500); const restartedX = await boxX(); await sleep(400); check( 'stopping clears the hold, so the next play runs', afterStop.playing === false && afterStop.paused === false && (await playInfo()).paused === false && (await boxX()) > restartedX, `after stop ${JSON.stringify(afterStop)}, x ${restartedX} → ${await boxX()}`, ); // 18f. The graph stays editable while the game runs, with the mouse as in edit mode: a click // selects the Move node, Delete removes it, and the running world stops drifting the box. await select({ kind: 'asset', assetId: 'pauser' }); await evaluate(`__gm.showTab('graph')`); await sleep(150); const outputs = (id) => json(`__gm.graph.nodeOutputs(${JSON.stringify(id)})`); const live = { owner: await outputs('p-owner'), update: await outputs('p-update'), move: await outputs('p-move'), }; check( 'while playing, each output shows the value it last carried in front of its name', live.owner[0] === 'Pause box object' && /^0\.\d{3} {2}dt$/.test(live.update[1]) && live.move[1] === 'false blocked x', JSON.stringify(live), ); await shot('36b-graph-live-values'); const beadsOn = async (edgeId) => (await json('__gm.graph.beads()')).filter((b) => b.edgeId === edgeId).map((b) => b.progress); const chain = await beadsOn('p-e1'); const gaps = chain.slice(1).map((p, i) => chain[i] - p); check( 'an exec edge firing every frame carries an evenly spaced chain of beads', chain.length >= 1 && chain.length <= 3 && gaps.every((g) => Math.abs(g - 0.5) < 0.1), JSON.stringify(chain), ); check( 'data edges carry no beads', (await beadsOn('p-e2')).length === 0 && (await beadsOn('p-e3')).length === 0, ); const beforeKey = await beadsOn('p-e4'); await press('ArrowRight'); await sleep(80); const onKey = await beadsOn('p-e4'); await sleep(500); const afterKey = await beadsOn('p-e4'); check( 'a single key press sends exactly one bead, and it arrives', beforeKey.length === 0 && onKey.length === 1 && afterKey.length === 0, JSON.stringify({ beforeKey, onKey, afterKey }), ); const movePos = await json(`__gm.graph.nodeAt('p-move')`); await click(movePos.x, movePos.y); await sleep(100); check( 'a click selects a node while playing', JSON.stringify(await json('__gm.graph.selected()')) === '["p-move"]', JSON.stringify(await json('__gm.graph.selected()')), ); const area = await json('__gm.graph.canvasRect()'); await click(area.x + area.w - 15, area.y + area.h - 15, 'right'); await sleep(100); check('right-clicking the canvas opens the node palette while playing', await has('Search nodes…')); await press('Escape'); await sleep(100); check( 'Escape closes the palette and leaves the game running', !(await has('Search nodes…')) && (await playInfo()).playing === true, ); await click(movePos.x, movePos.y); await press('Delete'); await sleep(150); const stoppedAt = await boxX(); await sleep(500); check( 'deleting the Move node while playing stops the drift on the next frames', !(await asset('pauser')).graph.nodes.some((n) => n.id === 'p-move') && Math.abs((await boxX()) - stoppedAt) < 0.001, `x ${stoppedAt} → ${await boxX()}`, ); await stop(); await sleep(200); check( 'stopping takes the values off the nodes', JSON.stringify(await outputs('p-owner')) === '["object"]', JSON.stringify(await outputs('p-owner')), ); check( 'stopping discards the graph edit with the rest of the run', (await asset('pauser')).graph.nodes.some((n) => n.id === 'p-move'), JSON.stringify((await asset('pauser')).graph.nodes.map((n) => n.id)), ); // 18g. Play discarded its copy, so the box is back at the position the model holds. const left = await rig.remove(); check( 'the step leaves nothing of its own behind', left.objects === 0 && left.assets === 0, JSON.stringify(left), ); }