Files
feuerwerk/tools/smoke-tetris.mjs
T
rootandClaude Opus 5 f60358c951 Let a list hold structs, and read or write variables in bundles
A list item may now be a struct. The depth is bounded in the declaration
rather than by a comment: FieldKind (number|text|boolean|color) says what a
struct field holds, ListItemKind adds struct on top and says what a list
holds, so list -> struct -> primitive is the deepest storable shape and
sameValue stays finite. A struct item renders inline in the List tab, which
is why it needs no path in the slot address.

Four paths silently assumed a list item was a primitive: sameValue compared
items with ===, textOf would have printed [object Object], asListItem turned
a struct into text on Set item and Fill, and Index of used indexOf, which is
reference equality and could never have found a struct.

Tetris now runs on bundles. shapes is 112 {x, y} structs instead of 224 loose
numbers, piece and the candidate are structs, so Next move hands back a whole
candidate and each strand packs only the field it changes, since mergeStruct
leaves the rest standing. Declared behavior ports drop 49 -> 35, the master's
variables 18 -> 13, its graph 84/94 -> 69/76 nodes/edges.

Get variables and Set variables declare their ports from a list of variable
names, the way Pack and Expand already do, so one node can read or write
several at once. Renaming a variable rewires them, because there the port key
is the variable name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 14:01:56 +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: 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: 69, edges: 76 },
'spawn-piece': { nodes: 6, edges: 7 },
'next-move': { nodes: 44, edges: 61 },
fits: { nodes: 18, edges: 26 },
'draw-piece': { nodes: 15, edges: 24 },
'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: 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(' | '));
});