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>
222 lines
8.0 KiB
JavaScript
222 lines
8.0 KiB
JavaScript
#!/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(' | '));
|
||
});
|