Files
feuerwerk/tools/smoke-tetris.mjs
T
rootandClaude Opus 5 558365d33f Rebuild Tetris as ten behaviors that pass values around
The board is a list of 200 numbers now instead of 200 characters, so a cell is
Item at and a landing is Set item instead of seven nodes of string surgery.

The bigger change is that the one 219-node graph is gone. Each step of a frame is
a behavior of its own - Spawn piece, Next move, Fits, Draw piece, Lock piece,
Clear lines, Show lines, Draw board, plus Piece cell for where one of the four
cells of a piece sits. None of them holds state: what they need arrives through
their inputs, every answer leaves through their outputs. That is what lets Fits
answer for turning, moving and falling alike, and Piece cell serve the fit test,
the drawing and the landing instead of being the same twenty nodes three times.

Tetris itself is down to 84 nodes and only says in which order the steps run and
which variable each answer lands in. The smoke test holds it to that: it fails if
a loop or a list access reappears in the master graph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:27:01 +02:00

281 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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: PATH=/opt/node22/bin:$PATH 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: 84, edges: 94 },
'spawn-piece': { nodes: 5, edges: 6 },
'next-move': { nodes: 44, edges: 63 },
fits: { nodes: 18, edges: 29 },
'draw-piece': { nodes: 14, edges: 26 },
'lock-piece': { nodes: 20, edges: 23 },
'clear-lines': { nodes: 29, edges: 36 },
'show-lines': { nodes: 5, edges: 5 },
'draw-board': { nodes: 11, edges: 17 },
'piece-cell': { nodes: 12, edges: 19 },
};
/** 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', 'SetText', '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;
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, shape: spawned.shape, px: spawned.px }),
);
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 vars()).rot;
await press('ArrowUp');
await sleep(150);
const afterTurn = (await vars()).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,
py: over.py,
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(' | '));
});