Steps 18 and 20 both hand-rolled the same object-plus-behavior scaffold, down to lane offsets that nothing kept distinct. Step 19 claimed in a comment that it cleaned up after itself but never checked it – the only one of the four that did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
162 lines
6.4 KiB
JavaScript
162 lines
6.4 KiB
JavaScript
import { edge, node } from '../lib/builders.mjs';
|
||
import { LANES, scaffoldBehavior } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* Pausing and resuming play: the world stops where it is, the clock stops with it, and input that
|
||
* arrives while it is held is dropped instead of firing all at once on resume.
|
||
*
|
||
* The step brings its own box and its own behavior. The fixture's Mover needs the arrow keys to
|
||
* move at all, so a hold would be indistinguishable from simply not pressing anything; this one
|
||
* drifts on its own. Everything it adds is removed again at the end.
|
||
*/
|
||
|
||
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, shot, clickText, press, entity, world, stop } = t;
|
||
const playInfo = () => json('__gm.playInfo()');
|
||
const boxX = async () => (await entity('p-box')).x;
|
||
const keyHits = async () => (await world()).log.filter((line) => line.includes(KEY_LOG)).length;
|
||
|
||
// 19a. 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)}`,
|
||
);
|
||
|
||
// 19b. 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.
|
||
const labelShown = (text) => json(`__gm.locate(${JSON.stringify(text)}) !== null`);
|
||
check(
|
||
'the button offers to resume while it is held, and no longer to pause',
|
||
(await labelShown('▶ Resume')) === true && (await labelShown('❚❚ Pause')) === false,
|
||
`resume=${await labelShown('▶ Resume')} pause=${await labelShown('❚❚ Pause')}`,
|
||
);
|
||
await shot('36-paused');
|
||
|
||
// 19c. 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);
|
||
|
||
// 19d. 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()}`,
|
||
);
|
||
|
||
// 19e. 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()}`,
|
||
);
|
||
await stop();
|
||
await sleep(200);
|
||
|
||
// 19f. 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),
|
||
);
|
||
}
|