Files
feuerwerk/tools/smoke-tetris.mjs
T
rootandClaude Opus 5 fe3c63c48c Collapse Tetris' variable clusters, and give the long runs a home
Next move held four clusters of Set variable in a row, fourteen single nodes
in all; clear-lines read three variables one after the other. They are now
one plural node each, which takes Next move 40 -> 30 nodes and clear-lines
25 -> 23. Every remaining single node was checked and really is single: ok/no
are the same variable in opposite branches, take/keep the same one across a
write, and the three key setters hang on three different events.

Two contracts carry those merges, both pinned as tests. Set variables writes
per port with writeTyped exactly as the singular node does, so a Pack{rot}
still merges instead of overwriting - without it, turning would have erased
the other three fields. And the interpreter collects every input before the
node runs, so no input may come from a variable the same node writes; a data
node is evaluated once per pulse and keeps its answer. That second rule is
invisible in the game, because OnUpdate re-reads each frame and heals it, so
it lives in a unit test rather than in the smoke.

/opt/node22 and /opt/npm are gone from the tree. build.sh ran into the empty
path once they were moved away; the other seven mentions were documentation.

e2e.sh and check.sh replace the bare npm calls. A long run does not survive
the end of its shell - four full e2e runs died mid-suite with oom_kill 0 -
so e2e.sh start hands the run to a transient game-maker-e2e.service, which
does. e2e.sh status reports the running job or the last result, and calls a
log without a closing line ABORTED rather than reporting the passes it got
to. Both scripts end on an explicit exit line, because set -o pipefail inside
a script does not protect the caller: measured, ./check.sh | tail still
reports 0. Logs carry a timestamp and land in logs/, which is ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:00:28 +02:00

283 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: 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: 15, edges: 24 },
'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', '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;
/** 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(' | '));
});