#!/usr/bin/env node /** * Plays the Tetris example (tools/seed-tetris.mjs) in a headless browser: a piece appears and * falls, the arrow keys move and turn it, holding down drops it, a landing writes itself into the * board, a full line disappears and counts, and a full well ends the run. * * It is not part of the e2e suite – like smoke-jumprun.mjs it proves one seeded project, and is * run when that project or the engine underneath it changes. * * Usage: node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs * Screenshots land in /tmp/gm-tetris-*.png. */ import { instanceChild } from './lib/builders.mjs'; import { runChecks } from './lib/harness.mjs'; import { PROJECTS, cliArgs } from './lib/projects.mjs'; const { projectId, baseUrl } = cliArgs('smoke-tetris.mjs', PROJECTS.tetris); const COLS = 10; const ROWS = 20; const CELL = 16; /** The board container, the five texts, the camera and the behavior. */ const STORED_COUNT = 8; /** Those eight, the board instance keeping its place, plus the well, 200 cells and 4 piece cells. */ const OBJECT_COUNT = STORED_COUNT + 1 + COLS * ROWS + 4; /** The master graph and the nine behaviors the game is split into. */ const GRAPHS = { tetris: { nodes: 44, edges: 64 }, 'spawn-piece': { nodes: 6, edges: 7 }, 'next-move': { nodes: 30, edges: 51 }, fits: { nodes: 18, edges: 26 }, 'draw-piece': { nodes: 14, edges: 22 }, 'lock-piece': { nodes: 20, edges: 23 }, 'clear-lines': { nodes: 23, edges: 33 }, 'show-lines': { nodes: 5, edges: 5 }, 'draw-board': { nodes: 11, edges: 17 }, 'piece-cell': { nodes: 11, edges: 17 }, }; /** The steps the master calls. `piece-cell` is missing on purpose: three of these call it. */ const STEPS = [ 'spawn-piece', 'next-move', 'fits', 'draw-piece', 'lock-piece', 'clear-lines', 'show-lines', 'draw-board', ]; /** Node types that would mean the master is still doing a step's work itself. */ const WORK_NODES = ['Repeat', 'ItemAt', 'SetItem', 'GetPosition', 'Properties', 'SetPosition']; const PIECE = (i) => instanceChild('oboard', `piece${i}`); /** The board is a list of numbers: -1 is empty, anything else the shape that came to rest. */ const EMPTY = -1; /** Named here rather than read from the project: a test that copies the answer proves nothing. */ const SHAPE_COLORS = [0x2ec9d6, 0xf5c542, 0xa35fd6, 0x63c74d, 0xe0553f, 0x4a7de0, 0xe08a3f]; const emptyRows = (n) => Array.from({ length: n * COLS }, () => EMPTY); const fullRows = (n) => Array.from({ length: n * COLS }, () => 0); await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, async (t) => { const { check, sleep, evaluate, json, shot, press, holdKey, entity, world, mutate, play, navigate, exceptions, } = t; /** The variables of the Tetris behavior, live from the running world. */ const vars = async () => (await entity('ogame')).props; /** The falling piece as the game now keeps it: one bundle, not four loose numbers. */ const piece = async () => (await vars()).piece; const filledCells = (grid) => grid.filter((c) => c !== EMPTY).length; const pieceCells = async () => { const w = await world(); return [0, 1, 2, 3].map((i) => w.entities.find((e) => e.objectId === PIECE(i))); }; /** Where the four cells of the falling piece stand, as board columns and rows. */ const piecePosition = async () => { const cells = await pieceCells(); return cells.map((c) => ({ column: Math.round((c.x + (COLS * CELL) / 2 - CELL / 2) / CELL), row: Math.round((c.y + (ROWS * CELL) / 2 - CELL / 2) / CELL), })); }; const litCells = async () => (await world()).entities.filter((e) => /^c\d+$/.test(e.name) && e.props.enabled !== false).length; /** Waits for a condition instead of for the clock: a fixed sleep makes the check a coin toss. */ const waitFor = async (condition, ms = 6000) => { for (let waited = 0; waited < ms; waited += 200) { if (await condition()) return true; await sleep(200); } return false; }; /** Writes a behavior variable of the play copy and pushes it into the running world. */ const setVariable = (key, value) => mutate( `__gm.state.activeScene.objects.find((x) => x.id === 'ogame').overrides[${JSON.stringify(key)}] = ${JSON.stringify(value)}`, ); await navigate(); // 1. The document survived the loader: ten graphs, no unknown node type, no dropped edge. const graphs = await json( `Object.fromEntries(__gm.state.project.assets.filter((a) => a.type === 'behavior').map((a) => [a.id, { nodes: a.graph.nodes.length, edges: a.graph.edges.length }]))`, ); check( 'all ten graphs load – no unknown node type, no dropped edge', Object.entries(GRAPHS).every( ([id, want]) => graphs[id]?.nodes === want.nodes && graphs[id]?.edges === want.edges, ), JSON.stringify(graphs), ); const types = await json( `__gm.state.project.assets.find((a) => a.id === 'tetris').graph.nodes.map((n) => n.type === 'RunBehavior' ? 'run:' + n.params.behavior : n.type)`, ); const calls = types.filter((t) => t.startsWith('run:')).map((t) => t.slice(4)); const work = types.filter((t) => WORK_NODES.includes(t)); check( 'the master calls every step as a node, and does none of their work itself', STEPS.every((id) => calls.includes(id)) && work.length === 0, JSON.stringify({ calls, work }), ); const stored = await evaluate('__gm.state.activeScene.objects.length'); const listed = await evaluate('__gm.sceneObjects().length'); check( 'the scene stores eight objects and resolves the board out of one of them', stored === STORED_COUNT && listed === OBJECT_COUNT, JSON.stringify({ stored, listed }), ); await shot('01-editor'); // 2. Play: a piece appears, all four of its cells switched on and inside the well. await play(); await sleep(400); const spawned = await vars(); const cells = await pieceCells(); check( 'playing puts a piece into the well', spawned.hasPiece === true && cells.every((c) => c && c.props.enabled === true), JSON.stringify({ hasPiece: spawned.hasPiece, piece: spawned.piece }), ); const start = await piecePosition(); check( 'its four cells stand inside the board', start.every((c) => c.column >= 0 && c.column < COLS && c.row >= 0 && c.row < ROWS), JSON.stringify(start), ); await shot('02-playing'); // 3. It falls by itself. await sleep(1300); const fallen = await piecePosition(); check('the piece falls on its own', fallen[0].row > start[0].row, `row ${start[0].row} → ${fallen[0].row}`); // 4. Left and right move it by exactly one column per press, up turns it. const beforeMove = await piecePosition(); await press('ArrowLeft'); await sleep(150); const moved = await piecePosition(); check( 'the left arrow moves the piece one column', moved.every((c, i) => c.column === beforeMove[i].column - 1), `${beforeMove.map((c) => c.column).join(',')} → ${moved.map((c) => c.column).join(',')}`, ); await press('ArrowRight'); await sleep(150); const back = await piecePosition(); check( 'the right arrow moves it back', back.every((c, i) => c.column === beforeMove[i].column), back.map((c) => c.column).join(','), ); const beforeTurn = (await piece()).rot; await press('ArrowUp'); await sleep(150); const afterTurn = (await piece()).rot; check( 'the up arrow turns the piece', afterTurn === (beforeTurn + 1) % 4, `rot ${beforeTurn} → ${afterTurn}`, ); await shot('03-moved'); // 5. Holding down drops it, and where it lands is written into the board text. await setVariable('grid', emptyRows(ROWS)); await holdKey('ArrowDown', 1600); await sleep(300); const landedVars = await vars(); check( 'holding the down arrow drops the piece, and the landing is written into the board', filledCells(landedVars.grid) >= 4, `${filledCells(landedVars.grid)} cells taken`, ); check( 'the board draws exactly the cells the board text names', (await litCells()) === filledCells((await vars()).grid), JSON.stringify({ lit: await litCells(), inText: filledCells((await vars()).grid) }), ); // The colour is the one proof that goes all the way through the colour list: the board is drawn // by `Draw board`, which picks it with `Item at(colors, cell)`. const painted = await json( `(() => { const grid = __gm.world().entities.find((e) => e.name === 'Tetris').props.grid; const index = grid.findIndex((c) => c !== -1); const cell = __gm.world().entities.find((e) => e.name === 'c' + index); return { index, shape: grid[index], color: cell && cell.props.color }; })()`, ); check( 'a cell that came to rest is painted in its shape colour, straight out of the colour list', painted.color === SHAPE_COLORS[painted.shape], JSON.stringify({ ...painted, expected: SHAPE_COLORS[painted.shape] }), ); await shot('04-landed'); // 6. A full line disappears and counts. The bottom row is filled in beforehand, so whatever the // piece is and wherever it comes down, landing on top of it completes that line. await setVariable('lines', 0); await setVariable('grid', [...emptyRows(ROWS - 1), ...fullRows(1)]); await holdKey('ArrowDown', 2200); await sleep(400); const cleared = await vars(); check( 'a full line disappears and is counted', cleared.lines === 1, `lines ${cleared.lines}, bottom row [${cleared.grid.slice((ROWS - 1) * COLS).join(',')}]`, ); check( 'the counter on screen says so too', Boolean(await evaluate(`Boolean(__gm.locate('Lines 1'))`)), 'Lines 1', ); const bottom = cleared.grid.slice((ROWS - 1) * COLS); check( 'the row that was full is gone, and what stood on it dropped into its place', bottom.includes(EMPTY) && bottom.some((c) => c !== EMPTY), `bottom row [${bottom.join(',')}], ${filledCells(cleared.grid)} cells taken in all`, ); await shot('05-line-cleared'); // 7. A well filled to the top ends the run: the new piece cannot come down even once. Every row // keeps its last column free – a full row would be cleared away before the piece ever stands. const blocked = Array.from({ length: COLS }, (_, column) => (column === COLS - 1 ? EMPTY : 0)); await setVariable('grid', [...emptyRows(1), ...Array.from({ length: ROWS - 1 }, () => blocked).flat()]); await setVariable('hasPiece', false); await waitFor(async () => (await evaluate('__gm.state.mode')) === 'edit'); const mode = await evaluate('__gm.state.mode'); const over = mode === 'play' ? await vars() : null; check( 'a piece that cannot fall at the very top stops the game', mode === 'edit', over ? JSON.stringify({ mode, piece: over.piece, hasPiece: over.hasPiece, kind: over.kind, lines: over.lines, top: over.grid.slice(0, 30).join(','), }) : 'stopped', ); await shot('06-game-over'); check('no exceptions during the run', exceptions().length === 0, exceptions().slice(0, 3).join(' | ')); });