diff --git a/tools/e2e/18-pause.mjs b/tools/e2e/18-pause.mjs index eb6324c3..376a20f9 100644 --- a/tools/e2e/18-pause.mjs +++ b/tools/e2e/18-pause.mjs @@ -1,4 +1,5 @@ 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 @@ -34,42 +35,22 @@ const pauseGraph = { }; export async function pause(t) { - const { check, sleep, json, shot, clickText, press, entity, world, mutate, stop } = 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. - await mutate(` - const p = __gm.state.project; - const scene = __gm.state.activeScene; - const proto = scene.objects[0]; - const asset = structuredClone(p.assets.find((a) => a.id === 'mover')); - Object.assign(asset, { - id: 'pauser', - name: 'Pauser', - props: { enabled: true, runInEditor: false }, - variables: [], - graph: ${JSON.stringify(pauseGraph)}, - }); - p.assets.push(asset); - const make = (id, assetId, parentId, y) => { - const o = structuredClone(proto); - Object.assign(o, { - id, - assetId, - name: id === 'p-box' ? 'Pause box' : 'Pauser', - position: { x: 0, y, z: 0 }, - parentId, - overrides: {}, - instanceEdits: {}, - }); - return o; - }; - scene.objects.push(make('p-box', 'crate', '', 300), make('p-logic', 'pauser', 'p-box', 0)); - `); - await sleep(400); + 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(); @@ -171,15 +152,10 @@ export async function pause(t) { await sleep(200); // 19f. Play discarded its copy, so the box is back at the position the model holds. - await mutate(` - const p = __gm.state.project; - const scene = __gm.state.activeScene; - scene.objects = scene.objects.filter((o) => o.id !== 'p-box' && o.id !== 'p-logic'); - p.assets = p.assets.filter((a) => a.id !== 'pauser'); - `); - await sleep(300); + const left = await rig.remove(); check( 'the step leaves nothing of its own behind', - (await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('p-')).length`)) === 0, + left.objects === 0 && left.assets === 0, + JSON.stringify(left), ); } diff --git a/tools/e2e/19-nested-behavior.mjs b/tools/e2e/19-nested-behavior.mjs index 44154b70..6640466d 100644 --- a/tools/e2e/19-nested-behavior.mjs +++ b/tools/e2e/19-nested-behavior.mjs @@ -297,4 +297,13 @@ export async function nestedBehavior(t) { scene.objects = scene.objects.filter((o) => o.id !== 'nb-box' && o.id !== 'nb-logic'); `); await sleep(300); + const left = await json(`{ + objects: __gm.sceneObjects().filter((o) => o.id.startsWith('nb-')).length, + assets: __gm.state.project.assets.filter((a) => a.id.startsWith('nb-')).length, + }`); + check( + 'the step leaves nothing of its own behind', + left.objects === 0 && left.assets === 0, + JSON.stringify(left), + ); } diff --git a/tools/e2e/20-key-repeat.mjs b/tools/e2e/20-key-repeat.mjs index 333fbd88..e9d04d3e 100644 --- a/tools/e2e/20-key-repeat.mjs +++ b/tools/e2e/20-key-repeat.mjs @@ -1,4 +1,5 @@ import { edge, node } from '../lib/builders.mjs'; +import { LANES, scaffoldBehavior } from './lib/scaffold.mjs'; /** * The auto-repeat of `On key down`: a held key fires again after a delay and then on its own @@ -32,7 +33,7 @@ const repeatGraph = { }; export async function keyRepeat(t) { - const { check, sleep, json, shot, click, holdKey, world, mutate, stop, select, typeText } = t; + const { check, sleep, json, shot, click, holdKey, world, stop, select, typeText } = t; /** Both counts out of one read of the world, so the two are always of the same moment. */ const hits = async () => { const log = (await world()).log; @@ -42,35 +43,15 @@ export async function keyRepeat(t) { // 20a. A box with the two-node behavior below it, written into the model directly: what is under // test is the repeat, not another way of building a graph. - await mutate(` - const p = __gm.state.project; - const scene = __gm.state.activeScene; - const proto = scene.objects[0]; - const a = structuredClone(p.assets.find((x) => x.id === 'mover')); - Object.assign(a, { - id: 'repeater', - name: 'Repeater', - props: { enabled: true, runInEditor: false }, - variables: [], - graph: ${JSON.stringify(repeatGraph)}, - }); - p.assets.push(a); - const make = (id, assetId, parentId, y) => { - const o = structuredClone(proto); - Object.assign(o, { - id, - assetId, - name: id === 'r-box' ? 'Repeat box' : 'Repeater', - position: { x: 0, y, z: 0 }, - parentId, - overrides: {}, - instanceEdits: {}, - }); - return o; - }; - scene.objects.push(make('r-box', 'crate', '', -300), make('r-logic', 'repeater', 'r-box', 0)); - `); - await sleep(400); + const rig = await scaffoldBehavior(t, { + assetId: 'repeater', + name: 'Repeater', + graph: repeatGraph, + boxId: 'r-box', + boxName: 'Repeat box', + logicId: 'r-logic', + y: LANES.keyRepeat, + }); await t.play(); await sleep(300); @@ -154,15 +135,10 @@ export async function keyRepeat(t) { await stop(); await sleep(300); - await mutate(` - const p = __gm.state.project; - const scene = __gm.state.activeScene; - scene.objects = scene.objects.filter((o) => o.id !== 'r-box' && o.id !== 'r-logic'); - p.assets = p.assets.filter((a) => a.id !== 'repeater'); - `); - await sleep(300); + const left = await rig.remove(); check( 'the step leaves nothing of its own behind', - (await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('r-')).length`)) === 0, + left.objects === 0 && left.assets === 0, + JSON.stringify(left), ); } diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs new file mode 100644 index 00000000..43cfb130 --- /dev/null +++ b/tools/e2e/lib/scaffold.mjs @@ -0,0 +1,102 @@ +/** + * Steps 17–20 each need the same throwaway rig in the fixture scene: a behavior asset cloned off + * `mover` carrying the step's own graph, a crate to hold it, and a logic child that runs it. + * Spelled out per step it drifted four ways – `push` vs `unshift`, three different `make` + * signatures, and y-offsets nothing kept apart. + */ + +/** + * Where each step parks its box. The fixture scene is shared, so two steps at the same y would + * overlap and each would fail for reasons belonging to the other. + */ +export const LANES = { + editorRun: 400, + editorRunContainer: 800, + editorRunEdits: 1200, + pause: 300, + nested: 700, + keyRepeat: -300, +}; + +/** + * Source for a `make(id, assetId, parentId, y, name)` factory, to paste inside a `mutate()` + * string. It clones `scene.objects[0]`, so a new object carries whatever shape the fixture has. + */ +export const MAKE_OBJECT = ` + const make = (id, assetId, parentId, y, name = '') => { + const o = structuredClone(scene.objects[0]); + Object.assign(o, { + id, + assetId, + name, + position: { x: 0, y, z: 0 }, + parentId, + overrides: {}, + instanceEdits: {}, + }); + return o; + };`; + +/** + * Builds the rig and hands back the ids it created, plus `remove()` which takes them out again + * and returns what is left – so a step's cleanup cannot fall out of step with what it made. + * + * `front` puts the asset at the head of the project list: the asset panel is long by this point + * in the suite, and a row appended at the end scrolls out of reach of `dragAsset`. + */ +export async function scaffoldBehavior(t, options) { + const { + assetId, + name, + graph, + boxId, + boxName = 'Box', + logicId, + y, + front = false, + props = { enabled: true, runInEditor: false }, + } = options; + + await t.mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + const asset = structuredClone(p.assets.find((a) => a.id === 'mover')); + Object.assign(asset, { + id: ${JSON.stringify(assetId)}, + name: ${JSON.stringify(name)}, + props: ${JSON.stringify(props)}, + variables: [], + graph: ${JSON.stringify(graph)}, + }); + p.assets.${front ? 'unshift' : 'push'}(asset); + ${MAKE_OBJECT} + scene.objects.push( + make(${JSON.stringify(boxId)}, 'crate', '', ${y}, ${JSON.stringify(boxName)}), + make(${JSON.stringify(logicId)}, ${JSON.stringify(assetId)}, ${JSON.stringify(boxId)}, 0, ${JSON.stringify(name)}), + ); + `); + await t.sleep(400); + + return { + assetId, + boxId, + logicId, + /** Removes everything this rig added and reports what survived (should be all zeroes). */ + async remove() { + await t.evaluate('__gm.state.select(null)'); + await t.mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + const objects = ${JSON.stringify([boxId, logicId])}; + const assets = ${JSON.stringify([assetId])}; + scene.objects = scene.objects.filter((o) => !objects.includes(o.id)); + p.assets = p.assets.filter((a) => !assets.includes(a.id)); + `); + await t.sleep(300); + return t.json(`{ + objects: __gm.sceneObjects().filter((o) => ${JSON.stringify([boxId, logicId])}.includes(o.id)).length, + assets: __gm.state.project.assets.filter((a) => a.id === ${JSON.stringify(assetId)}).length, + }`); + }, + }; +} diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index 19fe0ca6..92d524e3 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -141,8 +141,6 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { writeFileSync(`/tmp/${shotPrefix}-${name}.png`, Buffer.from(data, 'base64')); }; - // --- mouse --------------------------------------------------------------------------------- - const mouse = (type, x, y, button = 'left', extra = {}) => cdp.send('Input.dispatchMouseEvent', { type, @@ -227,8 +225,6 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { await sleep(200); }; - // --- keyboard ------------------------------------------------------------------------------ - const keyEvent = (type, name, modifiers) => { const key = KEYS[name]; if (!key) throw new Error(`unknown key "${name}"`); @@ -269,8 +265,6 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { await sleep(200); }; - // --- editor state -------------------------------------------------------------------------- - /** Selection, mode, save status and notice – the facts most checks look at. */ const state = () => json( @@ -306,8 +300,6 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { await sleep(300); }; - // --- server ---------------------------------------------------------------------------------- - // Timeout, not convenience: a stalled response keeps its socket open, so node stays alive and // the whole run hangs on this line instead of failing. const api = async (route) => { diff --git a/tools/seed-jumprun.mjs b/tools/seed-jumprun.mjs index bb9510c4..822aff4f 100644 --- a/tools/seed-jumprun.mjs +++ b/tools/seed-jumprun.mjs @@ -19,8 +19,6 @@ import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; const { projectId, baseUrl } = cliArgs('seed-jumprun.mjs', PROJECTS.jumpRun); -// --- colours --------------------------------------------------------------------------------- - const SKY = 0x151821; const STONE = 0x3c4454; const GRASS = 0x4a7d3a; @@ -32,7 +30,7 @@ const WHITE = 0xf2f5f7; const HINT = 0x9aa3b2; const BUTTON_TEXT = 0x10240a; -// --- level geometry (the camera shows x −320…320, y −180…180; +y is down) --------------------- +// The camera shows x −320…320, y −180…180; +y is down. /** A solid box: the sprite plus a collider child of the same size. */ const solid = (id, x, y, w, h, color) => [ @@ -66,7 +64,6 @@ const coinTemplate = [ ]; const levelTemplate = [ - // Ground, side walls and six platforms. ...solid('ground', 0, 165, 660, 30, GRASS), ...solid('wall-l', -315, 0, 10, 360, STONE), ...solid('wall-r', 315, 0, 10, 360, STONE), @@ -87,7 +84,6 @@ const levelTemplate = [ coin('c8', -230, -60), coin('c9', 255, -90), coin('c10', 290, 128), - // The player: sprite, solid collider and the behavior that owns it. sceneObject('player', 'player-sprite', { name: 'Player', x: -270, y: 125, z: 2 }), sceneObject('player-c', 'solid', { parentId: 'player', overrides: { width: 30, height: 40 } }), sceneObject('player-logic', 'player', { parentId: 'player', y: -60 }), @@ -122,8 +118,6 @@ const templates = { lose: screenTemplate('lose', 'TIME UP', 'The time is over - try again.', 'PLAY AGAIN', 'restart-button'), }; -// --- graphs ---------------------------------------------------------------------------------- - /** * "Player": gravity, walking, jumping and picking coins up – three strands. * @@ -353,8 +347,6 @@ const restartButtonGraph = { edges: [edge('re1', 'r-click', 'exec', 'r-restart', 'exec')], }; -// --- project --------------------------------------------------------------------------------- - const project = { formatVersion: 2, id: projectId, diff --git a/tools/seed-tetris.mjs b/tools/seed-tetris.mjs index 2793de0b..3512d8a4 100644 --- a/tools/seed-tetris.mjs +++ b/tools/seed-tetris.mjs @@ -24,8 +24,6 @@ import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; const { projectId, baseUrl } = cliArgs('seed-tetris.mjs', PROJECTS.tetris); -// --- board geometry -------------------------------------------------------------------------- - const COLS = 10; const ROWS = 20; const CELL = 16; @@ -37,8 +35,6 @@ const CELL_Y = (row) => row * CELL - (ROWS * CELL) / 2 + CELL / 2; const EMPTY = -1; const EMPTY_GRID = Array.from({ length: COLS * ROWS }, () => EMPTY); -// --- colours --------------------------------------------------------------------------------- - const BACKGROUND = 0x151821; const WELL = 0x0d1017; const WHITE = 0xf2f5f7; @@ -47,8 +43,6 @@ const HINT = 0x9aa3b2; /** One per shape, in the order of `SHAPES` below – and the `colors` variable as it stands. */ const SHAPE_COLORS = [0x2ec9d6, 0xf5c542, 0xa35fd6, 0x63c74d, 0xe0553f, 0x4a7de0, 0xe08a3f]; -// --- the seven shapes ------------------------------------------------------------------------ - /** * Each shape as its four cells in a box of `size` × `size`, the box being what turning rotates * inside: (x, y) → (size − 1 − y, x) is a quarter turn clockwise. I needs a 4-box, O a 2-box. @@ -147,8 +141,6 @@ function shapeTable() { /** The column a shape starts in, so every piece appears centred over the well. */ const spawnColumns = () => SHAPES.map((s) => Math.floor((COLS - s.size) / 2)); -// --- the board container --------------------------------------------------------------------- - /** The well, the 200 board cells (switched off = empty) and the four cells of the falling piece. */ const boardTemplate = [ sceneObject('well', 'well-sprite', { name: 'Well' }), @@ -166,8 +158,6 @@ const boardTemplate = [ ), ]; -// --- graph helpers ----------------------------------------------------------------------------- - /** * One graph under construction. Nodes are laid out in bands, one per strand, so the editor shows a * behavior as a stack of readable blocks rather than one cloud. @@ -225,8 +215,6 @@ function objectOfRound(g, prefix, loop, namePrefix, column, row) { return object; } -// --- behavior: Piece cell ------------------------------------------------------------------------ - /** * Where one of the four cells of a piece sits: reads the rotation out of `shapes` and adds the * piece's own position. Used by the fit test and by the drawing, which is the whole point of it @@ -277,8 +265,6 @@ function pieceCellGraph() { return g.done(); } -// --- behavior: Fits ------------------------------------------------------------------------------- - /** * Whether the four cells of a piece may stand where it is asked to: inside the well, and on empty * board cells. Every attempt to turn, move or drop goes through this one answer. @@ -344,8 +330,6 @@ function fitsGraph() { return g.done(); } -// --- behavior: Clear lines ------------------------------------------------------------------------- - /** * Removes every full row and counts it. Walks the board from the bottom up; a cleared row does not * move the walk on, because everything above has dropped into it – so the same row is looked at @@ -422,8 +406,6 @@ function clearLinesGraph() { return g.done(); } -// --- behavior: Draw board --------------------------------------------------------------------------- - /** * The board as it stands, cell by cell: switched off where the list says empty, in the colour of * the shape that came to rest there otherwise. No end node – it answers nothing, it only draws. @@ -457,8 +439,6 @@ function drawBoardGraph() { return g.done(); } -// --- behavior: Spawn piece ----------------------------------------------------------------------- - /** Which piece appears next and in which column: one of the seven, centred over the well. */ function spawnPieceGraph() { const g = graphBuilder(); @@ -476,8 +456,6 @@ function spawnPieceGraph() { return g.done(); } -// --- behavior: Next move ------------------------------------------------------------------------- - /** * The one move a frame attempts, and nothing else: turning beats moving beats falling. It only * proposes – whether the piece may go there is `Fits`, one step later. A wish that was granted @@ -615,8 +593,6 @@ function nextMoveGraph() { return g.done(); } -// --- behavior: Draw piece ------------------------------------------------------------------------ - /** * Moves the four cell objects of the falling piece to where the piece is. This is the only place * that touches them, so where they stand *is* where the piece is – which is what `Lock piece` @@ -668,8 +644,6 @@ function drawPieceGraph() { return g.done(); } -// --- behavior: Lock piece ------------------------------------------------------------------------ - /** Writes the four cells of the landed piece into the board, read back from where they stand. */ function lockPieceGraph() { const g = graphBuilder(); @@ -718,8 +692,6 @@ function lockPieceGraph() { return g.done(); } -// --- behavior: Show lines ------------------------------------------------------------------------ - /** The one place that writes the counter on screen. */ function showLinesGraph() { const g = graphBuilder(); @@ -736,8 +708,6 @@ function showLinesGraph() { return g.done(); } -// --- behavior: Tetris --------------------------------------------------------------------------- - /** * The master graph. It owns the state and does none of the work: every step below is a behavior of * its own, called as a node. What is left here is the order the steps run in and which variable @@ -928,8 +898,6 @@ function tetrisGraph() { return g.done(); } -// --- project ----------------------------------------------------------------------------------- - const label = (id, name, x, y, text, size, color, align = 'center') => sceneObject(id, 'label', { name, x, y, overrides: { text, fontSize: size, color, align } });