Files
feuerwerk/tools/smoke-tetris.mjs
T
rootandClaude Opus 5 4f78c7518c Give the graph a loop, and build Tetris with it
A Tetris example project needs to draw 200 board cells and scan rows
for a full line. Neither is expressible without iteration, so the node
system gets a Repeat node plus the missing text side: Substring, Find
text and To number. The interpreter drops its data cache once per
round -- otherwise every round would recompute with the first round's
values.

Object by name takes its name as an input now, not just as a setting;
addressing "c" + index is impossible otherwise. An unconnected input
reads the stored setting, so saved projects are untouched.

The board itself is a 200-character text, "." for empty. That makes
both operations of Tetris a single expression instead of a loop, and
each frame attempts exactly one move -- rotate beats sideways beats
fall -- so the fit check exists once in the whole graph.

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

222 lines
8.0 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;
const GRAPH = { nodes: 219, edges: 248 };
const PIECE = (i) => instanceChild('oboard', `piece${i}`);
const EMPTY_ROW = '.'.repeat(COLS);
const FULL_ROW = '0'.repeat(COLS);
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.split('').filter((c) => c !== '.').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: 219 nodes and 248 edges is the whole game.
const graph = await json(
`(() => { const a = __gm.state.project.assets.find((x) => x.id === 'tetris'); return { nodes: a.graph.nodes.length, edges: a.graph.edges.length }; })()`,
);
check(
'the whole graph loads – no unknown node type, no dropped edge',
graph.nodes === GRAPH.nodes && graph.edges === GRAPH.edges,
JSON.stringify(graph),
);
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', '.'.repeat(COLS * 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) }),
);
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', EMPTY_ROW.repeat(ROWS - 1) + FULL_ROW);
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)}"`,
);
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('.') && bottom !== EMPTY_ROW,
`bottom row "${bottom}", ${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.
await setVariable('grid', EMPTY_ROW + FULL_ROW.repeat(ROWS - 1));
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,
ok: over.ok,
lines: over.lines,
top: over.grid.slice(0, 30),
})
: 'stopped',
);
await shot('06-game-over');
check('no exceptions during the run', exceptions().length === 0, exceptions().slice(0, 3).join(' | '));
});