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>
283 lines
11 KiB
JavaScript
283 lines
11 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;
|
||
|
||
/** 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(' | '));
|
||
});
|