commit d162c5ba603ac08e3db2a7fe0919dd0494c4f14d
parent 3012989bb80dca8980569effc60dd0bd59e283c3
Author: Brian Graham <brian@buildingbetterteams.de>
Date: Thu, 9 Apr 2026 21:14:39 +0200
Add gameplay-bot-v2: two-tier architecture (Driver + Bot)
New implementation with clean separation:
- driver.ts (1710 lines): TetrisDriver class, all Playwright interaction
- bot.ts (1690 lines): game logic, 25 tests, zero Playwright imports
- types.ts (233 lines): TetrisDriver interface with 17 methods
Improvements over v1:
- Buttons before keyboard in start detection
- 300ms post-click initialization wait
- False start rejection (immediate game-over check)
- Grid re-calibration after start
- playable_30s gates on errors_during_play only
- Interactivity verification via screenshot + DOM state
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 3887 insertions(+), 0 deletions(-)
diff --git a/tasks/tetris/eval/gameplay-bot-v2/bot.ts b/tasks/tetris/eval/gameplay-bot-v2/bot.ts
@@ -0,0 +1,1690 @@
+// Bot: "The Brain" -- phase orchestration, AI decisions, test derivation
+// NEVER imports from Playwright directly. Uses only TetrisDriver interface.
+
+import type {
+ Grid,
+ PieceType,
+ TetrisDriver,
+ DriverCalibration,
+ GridSnapshot,
+ TestResult,
+ GameplayStats,
+ GameSession,
+ CompetitivePlayResult,
+ SurveyData,
+} from "./types";
+
+// ---------------------------------------------------------------------------
+// AI Player Logic (from LeeYiyuan/tetrisai, MIT License)
+// ---------------------------------------------------------------------------
+
+const W_HEIGHT = -0.510066;
+const W_LINES = 0.760666;
+const W_HOLES = -0.35663;
+const W_BUMPINESS = -0.184483;
+
+const GRID_ROWS = 20;
+const GRID_COLS = 10;
+
+const PIECES: Record<string, [number, number][][]> = {
+ I: [
+ [[0, 0], [0, 1], [0, 2], [0, 3]],
+ [[0, 0], [1, 0], [2, 0], [3, 0]],
+ [[0, 0], [0, 1], [0, 2], [0, 3]],
+ [[0, 0], [1, 0], [2, 0], [3, 0]],
+ ],
+ O: [
+ [[0, 0], [0, 1], [1, 0], [1, 1]],
+ [[0, 0], [0, 1], [1, 0], [1, 1]],
+ [[0, 0], [0, 1], [1, 0], [1, 1]],
+ [[0, 0], [0, 1], [1, 0], [1, 1]],
+ ],
+ T: [
+ [[0, 1], [1, 0], [1, 1], [1, 2]],
+ [[0, 0], [1, 0], [1, 1], [2, 0]],
+ [[0, 0], [0, 1], [0, 2], [1, 1]],
+ [[0, 1], [1, 0], [1, 1], [2, 1]],
+ ],
+ S: [
+ [[0, 1], [0, 2], [1, 0], [1, 1]],
+ [[0, 0], [1, 0], [1, 1], [2, 1]],
+ [[0, 1], [0, 2], [1, 0], [1, 1]],
+ [[0, 0], [1, 0], [1, 1], [2, 1]],
+ ],
+ Z: [
+ [[0, 0], [0, 1], [1, 1], [1, 2]],
+ [[0, 1], [1, 0], [1, 1], [2, 0]],
+ [[0, 0], [0, 1], [1, 1], [1, 2]],
+ [[0, 1], [1, 0], [1, 1], [2, 0]],
+ ],
+ J: [
+ [[0, 0], [1, 0], [1, 1], [1, 2]],
+ [[0, 0], [0, 1], [1, 0], [2, 0]],
+ [[0, 0], [0, 1], [0, 2], [1, 2]],
+ [[0, 0], [1, 0], [2, 0], [2, -1]],
+ ],
+ L: [
+ [[0, 2], [1, 0], [1, 1], [1, 2]],
+ [[0, 0], [1, 0], [2, 0], [2, 1]],
+ [[0, 0], [0, 1], [0, 2], [1, 0]],
+ [[0, 0], [0, 1], [1, 1], [2, 1]],
+ ],
+};
+
+interface Placement {
+ rotations: number;
+ column: number;
+ score: number;
+ linesCleared: number;
+ pieceType: string;
+}
+
+function findBestPlacement(board: Grid, pieceType: PieceType): Placement | null {
+ const rotations = PIECES[pieceType];
+ if (!rotations) return findBestPlacementGeneric(board);
+
+ let bestScore = -Infinity;
+ let bestPlacement: Placement | null = null;
+
+ for (let rot = 0; rot < rotations.length; rot++) {
+ const shape = rotations[rot];
+ const minCol = Math.min(...shape.map(([, c]) => c));
+ const maxCol = Math.max(...shape.map(([, c]) => c));
+ const pieceWidth = maxCol - minCol + 1;
+
+ for (let col = -minCol; col <= GRID_COLS - pieceWidth + (-minCol); col++) {
+ const simResult = simulateDropPiece(board, shape, col);
+ if (!simResult) continue;
+
+ const { cleared, resultBoard } = simResult;
+ const score =
+ W_HEIGHT * aggregateHeight(resultBoard) +
+ W_LINES * cleared +
+ W_HOLES * countHoles(resultBoard) +
+ W_BUMPINESS * bumpiness(resultBoard);
+
+ if (score > bestScore) {
+ bestScore = score;
+ bestPlacement = { rotations: rot, column: col, score, linesCleared: cleared, pieceType };
+ }
+ }
+ }
+
+ return bestPlacement;
+}
+
+function findBestPlacementGeneric(board: Grid): Placement | null {
+ let bestScore = -Infinity;
+ let bestPlacement: Placement | null = null;
+
+ for (let col = 0; col < GRID_COLS; col++) {
+ const simGrid = simulateDropSingleCell(board, col);
+ if (!simGrid) continue;
+ const { cleared, resultBoard } = clearLines(simGrid);
+ const score =
+ W_HEIGHT * aggregateHeight(resultBoard) +
+ W_LINES * cleared +
+ W_HOLES * countHoles(resultBoard) +
+ W_BUMPINESS * bumpiness(resultBoard);
+ if (score > bestScore) {
+ bestScore = score;
+ bestPlacement = { rotations: 0, column: col, score, linesCleared: cleared, pieceType: "unknown" };
+ }
+ }
+ return bestPlacement;
+}
+
+function simulateDropPiece(
+ board: Grid, shape: [number, number][], col: number
+): { cleared: number; resultBoard: Grid } | null {
+ let landRow = -1;
+
+ for (let row = 0; row <= GRID_ROWS; row++) {
+ let valid = true;
+ for (const [dr, dc] of shape) {
+ const r = row + dr;
+ const c = col + dc;
+ if (r >= GRID_ROWS || c < 0 || c >= GRID_COLS) { valid = false; break; }
+ if (r >= 0 && board[r][c]) { valid = false; break; }
+ }
+ if (!valid) { landRow = row - 1; break; }
+ }
+
+ if (landRow < 0) {
+ let valid = true;
+ for (const [dr, dc] of shape) {
+ const r = dr;
+ const c = col + dc;
+ if (r >= GRID_ROWS || c < 0 || c >= GRID_COLS || (r >= 0 && board[r][c])) { valid = false; break; }
+ }
+ if (valid) landRow = 0;
+ else return null;
+ }
+
+ const newBoard: Grid = board.map((row) => [...row]);
+ for (const [dr, dc] of shape) {
+ const r = landRow + dr;
+ const c = col + dc;
+ if (r >= 0 && r < GRID_ROWS && c >= 0 && c < GRID_COLS) newBoard[r][c] = true;
+ }
+
+ return clearLines(newBoard);
+}
+
+function simulateDropSingleCell(board: Grid, col: number): Grid | null {
+ if (col < 0 || col >= GRID_COLS) return null;
+ let landRow = -1;
+ for (let r = GRID_ROWS - 1; r >= 0; r--) {
+ if (!board[r][col]) { landRow = r; break; }
+ }
+ if (landRow < 0) return null;
+ const newGrid: Grid = board.map((row) => [...row]);
+ newGrid[landRow][col] = true;
+ return newGrid;
+}
+
+function stripActivePiece(grid: Grid, activeCells: [number, number][]): Grid {
+ const result: Grid = grid.map((row) => [...row]);
+ for (const [r, c] of activeCells) {
+ if (r >= 0 && r < result.length && c >= 0 && c < result[r].length) result[r][c] = false;
+ }
+ return result;
+}
+
+function clearLines(grid: Grid): { cleared: number; resultBoard: Grid } {
+ const remaining: boolean[][] = [];
+ let cleared = 0;
+ for (const row of grid) {
+ if (row.every(Boolean)) cleared++;
+ else remaining.push([...row]);
+ }
+ while (remaining.length < GRID_ROWS) remaining.unshift(new Array(GRID_COLS).fill(false));
+ return { cleared, resultBoard: remaining };
+}
+
+function aggregateHeight(grid: Grid): number {
+ let total = 0;
+ for (let col = 0; col < GRID_COLS; col++) {
+ for (let row = 0; row < GRID_ROWS; row++) {
+ if (grid[row]?.[col]) { total += GRID_ROWS - row; break; }
+ }
+ }
+ return total;
+}
+
+function countHoles(grid: Grid): number {
+ let holes = 0;
+ for (let col = 0; col < GRID_COLS; col++) {
+ let blockFound = false;
+ for (let row = 0; row < GRID_ROWS; row++) {
+ if (grid[row]?.[col]) blockFound = true;
+ else if (blockFound) holes++;
+ }
+ }
+ return holes;
+}
+
+function bumpiness(grid: Grid): number {
+ const heights: number[] = [];
+ for (let col = 0; col < GRID_COLS; col++) {
+ let h = 0;
+ for (let row = 0; row < GRID_ROWS; row++) {
+ if (grid[row]?.[col]) { h = GRID_ROWS - row; break; }
+ }
+ heights.push(h);
+ }
+ let bump = 0;
+ for (let i = 0; i < heights.length - 1; i++) bump += Math.abs(heights[i] - heights[i + 1]);
+ return bump;
+}
+
+function countFilled(grid: Grid): number {
+ let count = 0;
+ for (const row of grid) for (const cell of row) if (cell) count++;
+ return count;
+}
+
+// ---------------------------------------------------------------------------
+// Helper
+// ---------------------------------------------------------------------------
+
+function boundingBox(cells: [number, number][]): { w: number; h: number } {
+ const minRow = Math.min(...cells.map(([r]) => r));
+ const maxRow = Math.max(...cells.map(([r]) => r));
+ const minCol = Math.min(...cells.map(([, c]) => c));
+ const maxCol = Math.max(...cells.map(([, c]) => c));
+ return { w: maxCol - minCol + 1, h: maxRow - minRow + 1 };
+}
+
+function countFilledInTopRows(grid: Grid, rows: number): number {
+ let count = 0;
+ for (let r = 0; r < Math.min(rows, grid.length); r++) {
+ for (let c = 0; c < grid[r].length; c++) if (grid[r][c]) count++;
+ }
+ return count;
+}
+
+// ---------------------------------------------------------------------------
+// Phase orchestration
+// ---------------------------------------------------------------------------
+
+interface LoadResult {
+ loaded: boolean;
+ detail: string;
+ errorsOnLoad: number;
+}
+
+interface PhaseState {
+ gameStarted: boolean;
+ mechanicsWork: boolean;
+ piecesWork: boolean;
+ gameplayWorks: boolean;
+}
+
+const ALL_TEST_NAMES = [
+ "game_loads",
+ "game_starts",
+ "auto_drop",
+ "move_left",
+ "move_right",
+ "move_down",
+ "rotate",
+ "hard_drop",
+ "all_pieces_rotate",
+ "piece_locks",
+ "new_piece_spawns",
+ "multiple_pieces",
+ "line_clear",
+ "score_changes",
+ "game_over",
+ "playable_30s",
+ "multi_line_clear",
+ "score_scaling",
+ "level_progression",
+ "speed_progression",
+ "next_piece_preview",
+ "game_over_display",
+ "counter_clockwise_rotation",
+ "soft_drop_distinct",
+ "rendering_clean",
+];
+
+function emptyCalibration(consoleErrors: string[]): DriverCalibration {
+ return {
+ renderer: "unknown",
+ gridDetected: false,
+ gridBounds: null,
+ cellWidth: 0,
+ cellHeight: 0,
+ controls: { left: "ArrowLeft", right: "ArrowRight", down: "ArrowDown", rotate: "ArrowUp", drop: "Space" },
+ startMechanism: "unknown",
+ scoreElementSelector: null,
+ levelElementSelector: null,
+ backgroundColor: null,
+ consoleErrors,
+ gridConfidence: 0,
+ gridDetectedAt: "initial",
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Main entry point
+// ---------------------------------------------------------------------------
+
+export async function runAllTests(
+ driver: TetrisDriver,
+ serverUrl: string
+): Promise<{
+ testResults: TestResult[];
+ calibration: DriverCalibration;
+ gameplay: GameplayStats;
+ session: GameSession;
+ survey: SurveyData;
+ competitivePlay: CompetitivePlayResult | null;
+}> {
+ const gameplay: GameplayStats = {
+ pieces_placed: 0,
+ lines_cleared: 0,
+ max_score_observed: 0,
+ play_duration_seconds: 0,
+ errors_during_play: 0,
+ };
+
+ const session: GameSession = {
+ started: false,
+ startMechanism: "unknown",
+ piecesSpawned: 0,
+ piecesLocked: 0,
+ linesCleared: 0,
+ rotationsObserved: 0,
+ movementsObserved: 0,
+ hardDropsObserved: 0,
+ gameOverDetected: false,
+ consoleErrors: [],
+ durationSeconds: 0,
+ pieceTypes: new Set<string>(),
+ scoreValues: [],
+ gridReadSuccess: 0,
+ gridReadFail: 0,
+ frames: 0,
+ events: [],
+ skippedPhases: [],
+ };
+
+ let survey: SurveyData = {
+ has_overlay: false,
+ has_canvas: false,
+ has_dom_grid: false,
+ visible_text: [],
+ clickable_elements: 0,
+ };
+
+ let competitivePlay: CompetitivePlayResult | null = null;
+
+ // ---- Phase 1: Load the page ----
+ const loadResult = await driver.loadPage(serverUrl);
+ if (!loadResult.loaded) {
+ const failedTests = ALL_TEST_NAMES.map((name) => ({
+ name, pass: false, detail: loadResult.detail,
+ }));
+ return {
+ testResults: failedTests,
+ calibration: emptyCalibration(driver.getConsoleErrors()),
+ gameplay, session, survey, competitivePlay,
+ };
+ }
+
+ // ---- Pre-test survey ----
+ survey = await driver.surveyPage();
+
+ // ---- Phase 2: Calibrate + detect start ----
+ let cal: DriverCalibration;
+ try {
+ cal = await driver.calibrate();
+ session.started = cal.startMechanism !== "unknown";
+ session.startMechanism = cal.startMechanism;
+ } catch (err) {
+ cal = emptyCalibration(driver.getConsoleErrors());
+ }
+
+ // Merge console errors from calibration
+ for (const e of cal.consoleErrors) {
+ if (!session.consoleErrors.includes(e)) session.consoleErrors.push(e);
+ }
+
+ let gameStarted = session.started;
+ if (!gameStarted) {
+ session.skippedPhases.push(
+ "mechanics: game did not start",
+ "pieces: game did not start",
+ "gameplay: game did not start",
+ "gameover: game did not start",
+ "endurance: game did not start",
+ "competitive: game did not start"
+ );
+ }
+
+ // Re-calibrate after start: DOM games may create grid cells dynamically
+ if (gameStarted && !cal.gridDetected) {
+ try {
+ await driver.wait(500);
+ const recal = await driver.recalibrate();
+ if (recal.gridDetected) {
+ cal = recal;
+ }
+ } catch { /* keep original */ }
+ }
+
+ // ---- Phase 3: Basic mechanics ----
+ let mechanicsWork = false;
+ if (gameStarted && cal.gridDetected) {
+ await runBasicMechanicsPhase(driver, session);
+ mechanicsWork =
+ session.movementsObserved > 0 ||
+ session.rotationsObserved > 0 ||
+ session.hardDropsObserved > 0 ||
+ session.events.some((e) => e.type === "piece_moved");
+ }
+
+ if (gameStarted && !mechanicsWork) {
+ session.skippedPhases.push(
+ "pieces: mechanics failed",
+ "gameplay: mechanics failed",
+ "gameover: mechanics failed",
+ "endurance: mechanics failed",
+ "competitive: mechanics failed"
+ );
+ }
+
+ // ---- Phase 4: Piece lifecycle ----
+ let piecesWork = false;
+ if (mechanicsWork) {
+ piecesWork = session.piecesLocked > 0 || session.hardDropsObserved > 0;
+ }
+
+ if (mechanicsWork && !piecesWork) {
+ session.skippedPhases.push(
+ "gameplay: piece lifecycle failed",
+ "gameover: piece lifecycle failed",
+ "endurance: piece lifecycle failed",
+ "competitive: piece lifecycle failed"
+ );
+ }
+
+ // ---- Phase 5: Gameplay ----
+ let gameplayWorks = false;
+ if (piecesWork) {
+ try {
+ await driver.loadPage(serverUrl);
+ cal = await driver.calibrate();
+ if (gameStarted && !cal.gridDetected) {
+ await driver.wait(500);
+ const recal = await driver.recalibrate();
+ if (recal.gridDetected) cal = recal;
+ }
+ session.started = session.started || cal.startMechanism !== "unknown";
+ } catch { /* continue */ }
+
+ await runGameplayPhase(driver, session, gameplay);
+ gameplayWorks = gameplay.pieces_placed > 0;
+ }
+
+ if (piecesWork && !gameplayWorks) {
+ session.skippedPhases.push(
+ "endurance: gameplay failed",
+ "competitive: gameplay failed"
+ );
+ }
+
+ // ---- Phase 6: Game over ----
+ if (piecesWork) {
+ try {
+ await driver.loadPage(serverUrl);
+ cal = await driver.calibrate();
+ if (!cal.gridDetected) {
+ await driver.wait(500);
+ const recal = await driver.recalibrate();
+ if (recal.gridDetected) cal = recal;
+ }
+ } catch { /* continue */ }
+
+ await runGameOverPhase(driver, session);
+ }
+
+ // ---- Phase 7: Endurance ----
+ if (gameplayWorks) {
+ try {
+ await driver.loadPage(serverUrl);
+ cal = await driver.calibrate();
+ if (!cal.gridDetected) {
+ await driver.wait(500);
+ const recal = await driver.recalibrate();
+ if (recal.gridDetected) cal = recal;
+ }
+ } catch { /* continue */ }
+
+ await runEndurancePhase(driver, session, gameplay);
+ }
+
+ // ---- Phase 8: Competitive play ----
+ if (gameplayWorks) {
+ try {
+ await driver.loadPage(serverUrl);
+ cal = await driver.calibrate();
+ if (!cal.gridDetected) {
+ await driver.wait(500);
+ const recal = await driver.recalibrate();
+ if (recal.gridDetected) cal = recal;
+ }
+ } catch { /* continue */ }
+
+ competitivePlay = await runCompetitivePlayPhase(driver, session, gameplay);
+ } else if (!session.skippedPhases.some((p) => p.startsWith("competitive:"))) {
+ session.skippedPhases.push("competitive: gameplay failed");
+ }
+
+ session.durationSeconds = gameplay.play_duration_seconds;
+
+ // ---- Derive test results ----
+ const phaseState = { gameStarted, mechanicsWork, piecesWork, gameplayWorks };
+ const testResults = deriveTestResults(session, cal, loadResult, driver.getConsoleErrors(), gameplay, phaseState, competitivePlay);
+
+ return { testResults, calibration: cal, gameplay, session, survey, competitivePlay };
+}
+
+// ---------------------------------------------------------------------------
+// Phase implementations (use driver, never Playwright directly)
+// ---------------------------------------------------------------------------
+
+async function runBasicMechanicsPhase(
+ driver: TetrisDriver,
+ session: GameSession
+): Promise<void> {
+ // Auto-drop test: read grid twice with 5s gap, no input
+ const snapT0 = await driver.readGrid();
+ if (snapT0.grid) session.gridReadSuccess++;
+ else session.gridReadFail++;
+ session.frames++;
+
+ await driver.wait(5000);
+
+ const snapT1 = await driver.readGrid();
+ if (snapT1.grid) session.gridReadSuccess++;
+ else session.gridReadFail++;
+ session.frames++;
+
+ if (snapT0.grid && snapT1.grid && driver.gridsAreDifferent(snapT0.grid, snapT1.grid)) {
+ const topBefore = countFilledInTopRows(snapT0.grid, 10);
+ const topAfter = countFilledInTopRows(snapT1.grid, 10);
+ const bottomBefore = snapT0.filledInBottom(10);
+ const bottomAfter = snapT1.filledInBottom(10);
+ if (bottomAfter > bottomBefore || topAfter < topBefore || driver.gridsAreDifferent(snapT0.grid, snapT1.grid)) {
+ session.events.push({ type: "piece_moved", direction: "down", frame: session.frames });
+ }
+ }
+
+ // Movement tests
+ for (const dir of ["left", "right", "down"] as const) {
+ const snapBefore = await driver.readGrid();
+ if (snapBefore.grid) session.gridReadSuccess++;
+ else session.gridReadFail++;
+ session.frames++;
+
+ await driver.pressKey(dir);
+ await driver.wait(300);
+
+ const snapAfter = await driver.readGrid();
+ if (snapAfter.grid) session.gridReadSuccess++;
+ else session.gridReadFail++;
+ session.frames++;
+
+ if (snapBefore.grid && snapAfter.grid && driver.gridsAreDifferent(snapBefore.grid, snapAfter.grid)) {
+ session.movementsObserved++;
+ session.events.push({ type: "piece_moved", direction: dir, frame: session.frames });
+ }
+ }
+
+ // Rotation test
+ const snapBeforeRot = await driver.readGrid();
+ if (snapBeforeRot.grid) session.gridReadSuccess++;
+ else session.gridReadFail++;
+ session.frames++;
+
+ await driver.pressKey("rotate");
+ await driver.wait(300);
+
+ const snapAfterRot = await driver.readGrid();
+ if (snapAfterRot.grid) session.gridReadSuccess++;
+ else session.gridReadFail++;
+ session.frames++;
+
+ if (snapBeforeRot.grid && snapAfterRot.grid && driver.gridsAreDifferent(snapBeforeRot.grid, snapAfterRot.grid)) {
+ const cellsBefore = snapBeforeRot.activePieceCells;
+ const cellsAfter = snapAfterRot.activePieceCells;
+ if (cellsBefore && cellsAfter) {
+ const bbBefore = boundingBox(cellsBefore);
+ const bbAfter = boundingBox(cellsAfter);
+ if (bbBefore.w !== bbAfter.w || bbBefore.h !== bbAfter.h) {
+ session.rotationsObserved++;
+ session.events.push({ type: "piece_rotated", frame: session.frames });
+ } else {
+ const keyBefore = cellsBefore.map(([r, c]) => `${r},${c}`).sort().join("|");
+ const keyAfter = cellsAfter.map(([r, c]) => `${r},${c}`).sort().join("|");
+ if (keyBefore !== keyAfter) {
+ session.rotationsObserved++;
+ session.events.push({ type: "piece_rotated", frame: session.frames });
+ }
+ }
+ } else {
+ session.rotationsObserved++;
+ session.events.push({ type: "piece_rotated", frame: session.frames });
+ }
+ }
+
+ // Hard drop test
+ const snapBeforeDrop = await driver.readGrid();
+ if (snapBeforeDrop.grid) session.gridReadSuccess++;
+ else session.gridReadFail++;
+ session.frames++;
+
+ await driver.pressKey("drop");
+ await driver.wait(500);
+
+ const snapAfterDrop = await driver.readGrid();
+ if (snapAfterDrop.grid) session.gridReadSuccess++;
+ else session.gridReadFail++;
+ session.frames++;
+
+ if (snapBeforeDrop.grid && snapAfterDrop.grid && driver.gridsAreDifferent(snapBeforeDrop.grid, snapAfterDrop.grid)) {
+ const bottomFilled = snapAfterDrop.filledInBottom(5);
+ if (bottomFilled > 0) {
+ session.hardDropsObserved++;
+ session.piecesLocked++;
+ session.events.push({ type: "hard_drop", frame: session.frames });
+ session.events.push({ type: "piece_locked", frame: session.frames, filledDelta: bottomFilled });
+ }
+ }
+
+ // New piece spawns
+ await driver.wait(500);
+ const snapAfterSpawn = await driver.readGrid(snapAfterDrop.grid);
+ if (snapAfterSpawn.grid) {
+ session.gridReadSuccess++;
+ session.frames++;
+ if (snapAfterSpawn.hasFilledInTop(4)) {
+ session.piecesSpawned++;
+ if (snapAfterSpawn.activePieceCells) {
+ const pt = snapAfterSpawn.activePieceType || "unknown";
+ session.pieceTypes.add(pt);
+ session.events.push({ type: "piece_spawned", pieceType: pt as PieceType, frame: session.frames });
+ }
+ }
+ } else {
+ session.gridReadFail++;
+ session.frames++;
+ }
+
+ // Piece locks persistence test
+ const snapPersist1 = await driver.readGrid();
+ await driver.wait(2000);
+ const snapPersist2 = await driver.readGrid();
+ if (snapPersist1.grid && snapPersist2.grid) {
+ session.gridReadSuccess += 2;
+ session.frames += 2;
+ const bottom1 = snapPersist1.filledInBottom(4);
+ const bottom2 = snapPersist2.filledInBottom(4);
+ if (bottom1 > 0 && bottom2 >= bottom1) {
+ if (session.piecesLocked === 0) session.piecesLocked++;
+ }
+ }
+}
+
+async function runGameplayPhase(
+ driver: TetrisDriver,
+ session: GameSession,
+ gameplay: GameplayStats
+): Promise<void> {
+ const snapBefore = await driver.readGrid();
+ const filledBefore = snapBefore.filledCount;
+ if (snapBefore.grid) session.gridReadSuccess++;
+ else session.gridReadFail++;
+ session.frames++;
+
+ // Read initial score
+ const initialScore = await driver.readScore();
+ if (initialScore !== null) session.scoreValues.push(initialScore);
+
+ // Play using AI
+ const result = await playGame(driver, {
+ maxPieces: 60,
+ maxDurationMs: 45000,
+ });
+ gameplay.pieces_placed += result.piecesPlaced;
+ gameplay.errors_during_play += result.errors;
+ session.gridReadSuccess += result.gridReads;
+ session.gridReadFail += result.gridReadFails;
+ session.frames += result.gridReads + result.gridReadFails;
+ session.piecesLocked += result.piecesPlaced;
+
+ for (const sv of result.scoreValues) {
+ session.scoreValues.push(sv);
+ if (sv > gameplay.max_score_observed) gameplay.max_score_observed = sv;
+ }
+
+ if (result.linesCleared > 0) {
+ session.linesCleared += result.linesCleared;
+ gameplay.lines_cleared += result.linesCleared;
+ for (let i = 0; i < result.linesCleared; i++) {
+ session.events.push({ type: "line_cleared", count: 1, frame: session.frames });
+ }
+ }
+
+ // Read final score
+ const finalScore = await driver.readScore();
+ if (finalScore !== null) {
+ session.scoreValues.push(finalScore);
+ if (finalScore > gameplay.max_score_observed) gameplay.max_score_observed = finalScore;
+ }
+
+ // If no score element found, try to detect changing numbers
+ if (session.scoreValues.length === 0) {
+ // We cannot scan page text without Playwright directly, so skip this fallback
+ // The driver's readScore handles the detection
+ }
+
+ if (result.piecesPlaced > 0) {
+ session.events.push({
+ type: "piece_locked",
+ frame: session.frames,
+ filledDelta: result.piecesPlaced * 4,
+ });
+ }
+
+ // If no lines cleared by AI, try brute-force
+ if (session.linesCleared === 0) {
+ const cleared = await tryFillRow(driver, 10);
+ gameplay.pieces_placed += 10;
+ if (cleared) {
+ session.linesCleared++;
+ gameplay.lines_cleared++;
+ session.events.push({ type: "line_cleared", count: 1, frame: session.frames });
+ }
+ }
+
+ // Check if total filled decreased
+ if (session.linesCleared === 0) {
+ const snapAfter = await driver.readGrid();
+ const filledAfter = snapAfter.filledCount;
+ if (filledAfter < filledBefore && filledBefore > 0) {
+ session.linesCleared++;
+ gameplay.lines_cleared++;
+ session.events.push({ type: "line_cleared", count: 1, frame: session.frames });
+ }
+ }
+}
+
+async function runGameOverPhase(
+ driver: TetrisDriver,
+ session: GameSession
+): Promise<void> {
+ const MAX_DROPS = 40;
+ const BATCH_SIZE = 5;
+
+ for (let i = 0; i < MAX_DROPS; i++) {
+ await driver.pressKey("drop");
+ await driver.wait(150);
+
+ if ((i + 1) % BATCH_SIZE === 0) {
+ const snap = await driver.readGrid();
+ if (snap.grid) {
+ session.gridReadSuccess++;
+ session.frames++;
+
+ if (snap.hasFilledInTop(4)) {
+ await driver.pressKey("drop");
+ await driver.wait(300);
+ const snapAfter = await driver.readGrid();
+ if (snapAfter.grid) {
+ session.gridReadSuccess++;
+ session.frames++;
+ if (!driver.gridsAreDifferent(snap.grid, snapAfter.grid)) {
+ session.gameOverDetected = true;
+ session.events.push({ type: "game_over", frame: session.frames });
+ return;
+ }
+ }
+ }
+ } else {
+ session.gridReadFail++;
+ session.frames++;
+ }
+ }
+ }
+
+ // Check for game over text in DOM
+ const gameOverText = await driver.detectGameOverText();
+ if (gameOverText) {
+ const finalSnap = await driver.readGrid();
+ if (finalSnap.grid && finalSnap.filledCount > 10) {
+ session.gameOverDetected = true;
+ session.events.push({ type: "game_over", frame: session.frames });
+ }
+ }
+}
+
+async function runEndurancePhase(
+ driver: TetrisDriver,
+ session: GameSession,
+ gameplay: GameplayStats
+): Promise<void> {
+ const errorsBefore = driver.getConsoleErrors().length;
+ const start = Date.now();
+
+ const result = await playGame(driver, { maxDurationMs: 30000 });
+
+ const elapsed = Math.round((Date.now() - start) / 1000);
+ gameplay.pieces_placed += result.piecesPlaced;
+ gameplay.lines_cleared += result.linesCleared;
+ session.linesCleared += result.linesCleared;
+ gameplay.play_duration_seconds += elapsed;
+ gameplay.errors_during_play += result.errors;
+ session.gridReadSuccess += result.gridReads;
+ session.gridReadFail += result.gridReadFails;
+ session.frames += result.gridReads + result.gridReadFails;
+
+ const newErrors = driver.getConsoleErrors().slice(errorsBefore);
+ for (const e of newErrors) {
+ if (!session.consoleErrors.includes(e)) session.consoleErrors.push(e);
+ }
+}
+
+async function runCompetitivePlayPhase(
+ driver: TetrisDriver,
+ session: GameSession,
+ gameplay: GameplayStats
+): Promise<CompetitivePlayResult> {
+ const start = Date.now();
+ const maxDuration = 60000;
+
+ const result: CompetitivePlayResult & {
+ _ccwResult?: boolean | null;
+ _ccwTestDone?: boolean;
+ _softDropDistinct?: boolean | null;
+ _softDropTestDone?: boolean;
+ } = {
+ duration_seconds: 0,
+ pieces_placed: 0,
+ total_lines_cleared: 0,
+ single_clears: 0,
+ double_clears: 0,
+ triple_clears: 0,
+ tetris_clears: 0,
+ max_combo: 0,
+ score_readings: [],
+ score_final: 0,
+ score_increases: [],
+ level_readings: [],
+ level_final: 0,
+ game_over_reached: false,
+ game_over_text_found: null,
+ restart_available: false,
+ next_piece_visible: false,
+ speed_increased: false,
+ bugs_detected: [],
+ };
+
+ // Read initial score
+ let lastScore = 0;
+ const initialScore = await driver.readScore();
+ if (initialScore !== null) {
+ lastScore = initialScore;
+ result.score_readings.push(lastScore);
+ }
+
+ // Read initial level
+ const initialLevel = await driver.readLevel();
+ if (initialLevel !== null) result.level_readings.push(initialLevel);
+
+ // Measure initial drop speed
+ const initialDropInterval = await driver.measureDropInterval();
+
+ // Play loop
+ let previousSnap = await driver.readGrid();
+ let settledGrid = previousSnap.grid;
+ let pollCount = 0;
+ let consecutiveClears = 0;
+ let maxCombo = 0;
+ let ccwTestDone = false;
+ let ccwResult: boolean | null = null;
+ let softDropTestDone = false;
+ let softDropDistinct: boolean | null = null;
+
+ let filledCellSamples: number[] = [];
+ let trailCheckPieceMark = 0;
+
+ while (Date.now() - start < maxDuration) {
+ try {
+ const snap = await driver.readGrid(settledGrid);
+ pollCount++;
+
+ if (!snap.grid) {
+ await driver.wait(60);
+ continue;
+ }
+
+ // Score tracking every 5th poll
+ if (pollCount % 5 === 0) {
+ const score = await driver.readScore();
+ if (score !== null && score > 0) {
+ result.score_readings.push(score);
+ if (score > lastScore) {
+ result.score_increases.push(score - lastScore);
+ lastScore = score;
+ }
+ }
+ }
+
+ // Level tracking every 10th poll
+ if (pollCount % 10 === 0) {
+ const level = await driver.readLevel();
+ if (level !== null) result.level_readings.push(level);
+ }
+
+ // Line clear detection
+ if (previousSnap.grid && snap.grid) {
+ const filledBefore = previousSnap.filledCount;
+ const filledNow = snap.filledCount;
+
+ if (filledNow < filledBefore - 5 && filledBefore > 10) {
+ const clearedCount = Math.round((filledBefore + 4 - filledNow) / 10);
+ if (clearedCount > 0 && clearedCount <= 4) {
+ result.total_lines_cleared += clearedCount;
+ consecutiveClears++;
+ if (consecutiveClears > maxCombo) maxCombo = consecutiveClears;
+
+ switch (clearedCount) {
+ case 1: result.single_clears++; break;
+ case 2: result.double_clears++; break;
+ case 3: result.triple_clears++; break;
+ case 4: result.tetris_clears++; break;
+ }
+ }
+ } else {
+ consecutiveClears = 0;
+ }
+ }
+
+ // Active piece detection + AI placement
+ if (snap.activePieceCells && snap.activePieceCells.length === 4) {
+ const pieceType = snap.activePieceType || "unknown";
+ session.pieceTypes.add(pieceType);
+
+ // CCW rotation test
+ if (!ccwTestDone && result.pieces_placed > 5 && result.pieces_placed % 7 === 0) {
+ const gridBeforeZ = await driver.readGrid(settledGrid);
+ await driver.pressRawKey("z");
+ await driver.wait(60);
+ const gridAfterZ = await driver.readGrid(settledGrid);
+
+ if (gridBeforeZ.grid && gridAfterZ.grid && driver.gridsAreDifferent(gridBeforeZ.grid, gridAfterZ.grid)) {
+ const gridBeforeUp = await driver.readGrid(settledGrid);
+ await driver.pressKey("rotate");
+ await driver.wait(60);
+ const gridAfterUp = await driver.readGrid(settledGrid);
+
+ if (gridBeforeUp.grid && gridAfterUp.grid) {
+ ccwResult = driver.gridsAreDifferent(gridAfterZ.grid, gridAfterUp.grid);
+ ccwTestDone = true;
+ }
+ } else {
+ ccwResult = false;
+ ccwTestDone = true;
+ }
+ }
+
+ // Soft drop test
+ if (!softDropTestDone && result.pieces_placed > 3 && result.pieces_placed % 5 === 0) {
+ const snapBeforeDown = await driver.readGrid(settledGrid);
+ await driver.pressKey("down");
+ await driver.wait(60);
+ const snapAfterDown = await driver.readGrid(settledGrid);
+
+ if (snapBeforeDown.activePieceCells && snapAfterDown.activePieceCells) {
+ const avgRowBefore = snapBeforeDown.activePieceCells.reduce((s, [r]) => s + r, 0) / snapBeforeDown.activePieceCells.length;
+ const avgRowAfter = snapAfterDown.activePieceCells.reduce((s, [r]) => s + r, 0) / snapAfterDown.activePieceCells.length;
+ const rowDelta = avgRowAfter - avgRowBefore;
+ softDropDistinct = rowDelta >= 0.5 && rowDelta <= 3;
+ softDropTestDone = true;
+ }
+ }
+
+ // Rendering trail sampling
+ if (result.pieces_placed > 0 && result.pieces_placed % 10 === 0 && result.pieces_placed !== trailCheckPieceMark) {
+ trailCheckPieceMark = result.pieces_placed;
+ const sampleSnap = await driver.readGrid();
+ if (sampleSnap.grid) filledCellSamples.push(sampleSnap.filledCount);
+ }
+
+ // Execute AI placement
+ const boardWithoutPiece = settledGrid ?? stripActivePiece(snap.grid!, snap.activePieceCells);
+ const placement = findBestPlacement(boardWithoutPiece, pieceType as PieceType);
+
+ if (placement) {
+ await executePlacement(driver, placement, snap.activePieceCells);
+ } else {
+ await driver.pressKey("drop");
+ }
+
+ await driver.wait(100);
+ result.pieces_placed++;
+
+ const afterSnap = await driver.readGrid();
+ if (afterSnap.grid) settledGrid = afterSnap.grid;
+ }
+
+ previousSnap = snap;
+ await driver.wait(60);
+ } catch {
+ await driver.wait(60);
+ }
+ }
+
+ result.duration_seconds = Math.round((Date.now() - start) / 1000);
+ result.max_combo = maxCombo;
+
+ // Read final score
+ const finalScore = await driver.readScore();
+ if (finalScore !== null) {
+ result.score_final = finalScore;
+ result.score_readings.push(finalScore);
+ }
+
+ // Read final level
+ const finalLevel = await driver.readLevel();
+ if (finalLevel !== null) {
+ result.level_final = finalLevel;
+ result.level_readings.push(finalLevel);
+ }
+
+ // Measure final drop speed
+ const finalDropInterval = await driver.measureDropInterval();
+ if (initialDropInterval > 0 && finalDropInterval > 0 && finalDropInterval < initialDropInterval * 0.8) {
+ result.speed_increased = true;
+ }
+
+ // Game over check
+ const gameOverText = await driver.detectGameOverText();
+ if (gameOverText) {
+ result.game_over_reached = true;
+ result.game_over_text_found = gameOverText;
+ }
+
+ result.restart_available = await driver.detectRestartOption();
+ result.next_piece_visible = await driver.detectNextPiecePreview();
+
+ // Bug detection
+ if (result.score_increases.length > 3) {
+ const singleDeltas = result.score_increases.filter((d) => d > 0 && d <= 200);
+ const multiDeltas = result.score_increases.filter((d) => d > 200);
+ if (singleDeltas.length > 0 && multiDeltas.length === 0 &&
+ (result.double_clears + result.triple_clears + result.tetris_clears) > 0) {
+ result.bugs_detected.push("score_does_not_scale_with_simultaneous_clears");
+ }
+ }
+
+ if (result.level_readings.length > 1) {
+ const uniqueLevels = [...new Set(result.level_readings)];
+ if (uniqueLevels.length === 1 && result.total_lines_cleared >= 10) {
+ result.bugs_detected.push("level_does_not_increase");
+ }
+ }
+
+ if (result.level_readings.length > 1) {
+ const uniqueLevels = [...new Set(result.level_readings)];
+ if (uniqueLevels.length > 1 && !result.speed_increased) {
+ result.bugs_detected.push("speed_does_not_increase");
+ }
+ }
+
+ // Rendering trail detection
+ if (result.pieces_placed >= 10 && filledCellSamples.length >= 2) {
+ const maxFilled = Math.max(...filledCellSamples);
+ if (maxFilled > result.pieces_placed * 8) {
+ result.rendering_trail_detected = true;
+ result.bugs_detected.push("rendering_trail");
+ } else {
+ const onlyIncreasing = filledCellSamples.every((v, i) =>
+ i === 0 || v >= filledCellSamples[i - 1]
+ );
+ if (onlyIncreasing && filledCellSamples.length >= 3 && maxFilled > result.pieces_placed * 6) {
+ result.rendering_trail_detected = true;
+ result.bugs_detected.push("rendering_trail");
+ } else {
+ result.rendering_trail_detected = false;
+ }
+ }
+ }
+
+ result._ccwResult = ccwResult;
+ result._ccwTestDone = ccwTestDone;
+ result._softDropDistinct = softDropDistinct;
+ result._softDropTestDone = softDropTestDone;
+
+ return result;
+}
+
+// ---------------------------------------------------------------------------
+// Play helpers (use driver, never Playwright directly)
+// ---------------------------------------------------------------------------
+
+async function playGame(
+ driver: TetrisDriver,
+ options: { maxPieces?: number; maxDurationMs?: number }
+): Promise<{ piecesPlaced: number; linesCleared: number; errors: number; gridReads: number; gridReadFails: number; scoreValues: number[] }> {
+ const maxPieces = options.maxPieces ?? 100;
+ const maxDuration = options.maxDurationMs ?? 30000;
+ const start = Date.now();
+ let piecesPlaced = 0;
+ let linesCleared = 0;
+ let errors = 0;
+ let gridReads = 0;
+ let gridReadFails = 0;
+ let consecutiveReadFails = 0;
+ const scoreValues: number[] = [];
+ let scorePollCounter = 0;
+
+ let previousGrid: Grid | null = null;
+ let settledGrid: Grid | null = null;
+ let lastPlacementTime = Date.now();
+ let waitingForNewPiece = false;
+
+ while (piecesPlaced < maxPieces && Date.now() - start < maxDuration) {
+ try {
+ const snap = await driver.readGrid(settledGrid);
+
+ if (!snap.grid) {
+ gridReadFails++;
+ consecutiveReadFails++;
+ if (consecutiveReadFails > 10) {
+ await playRandomForDuration(driver, Math.min(5000, maxDuration - (Date.now() - start)));
+ piecesPlaced += 3;
+ break;
+ }
+ await driver.wait(60);
+ continue;
+ }
+
+ gridReads++;
+ consecutiveReadFails = 0;
+
+ // Score tracking
+ scorePollCounter++;
+ if (scorePollCounter % 5 === 0) {
+ const score = await driver.readScore();
+ if (score !== null) scoreValues.push(score);
+ }
+
+ // Detect if anything changed
+ if (previousGrid && !driver.gridsAreDifferent(snap.grid, previousGrid)) {
+ if (Date.now() - lastPlacementTime > 8000) {
+ await driver.pressKey("drop");
+ lastPlacementTime = Date.now();
+ }
+ await driver.wait(60);
+ continue;
+ }
+
+ if (waitingForNewPiece) {
+ settledGrid = snap.grid;
+ waitingForNewPiece = false;
+ lastPlacementTime = Date.now();
+ previousGrid = snap.grid;
+ await driver.wait(60);
+ continue;
+ }
+
+ if (snap.activePieceCells && snap.activePieceCells.length === 4) {
+ const pieceType = snap.activePieceType || "unknown";
+
+ const boardWithoutPiece = settledGrid ?? stripActivePiece(snap.grid, snap.activePieceCells);
+ const placement = findBestPlacement(boardWithoutPiece, pieceType as PieceType);
+
+ if (placement) {
+ await executePlacement(driver, placement, snap.activePieceCells);
+ linesCleared += placement.linesCleared;
+ piecesPlaced++;
+ waitingForNewPiece = true;
+ } else {
+ await driver.pressKey("drop");
+ piecesPlaced++;
+ waitingForNewPiece = true;
+ }
+
+ await driver.wait(100);
+
+ const afterSnap = await driver.readGrid();
+ if (afterSnap.grid) {
+ if (settledGrid) {
+ const filledBefore = countFilled(settledGrid);
+ const filledAfter = countFilled(afterSnap.grid);
+ if (filledAfter < filledBefore) {
+ const possibleClears = Math.round((filledBefore + 4 - filledAfter) / GRID_COLS);
+ if (possibleClears > 0 && possibleClears <= 4) linesCleared += possibleClears;
+ }
+ }
+ settledGrid = afterSnap.grid;
+ }
+
+ lastPlacementTime = Date.now();
+ }
+
+ previousGrid = snap.grid;
+ await driver.wait(60);
+ } catch {
+ errors++;
+ await playRandomMove(driver);
+ piecesPlaced++;
+ await driver.wait(60);
+ }
+ }
+
+ return { piecesPlaced, linesCleared, errors, gridReads, gridReadFails, scoreValues };
+}
+
+async function executePlacement(
+ driver: TetrisDriver,
+ placement: Placement,
+ activeCells: [number, number][]
+): Promise<void> {
+ for (let i = 0; i < placement.rotations; i++) {
+ await driver.pressKey("rotate");
+ await driver.wait(50);
+ }
+
+ const currentCol = Math.min(...activeCells.map(([, c]) => c));
+ const diff = placement.column - currentCol;
+
+ if (diff < 0) {
+ for (let i = 0; i < Math.abs(diff); i++) {
+ await driver.pressKey("left");
+ await driver.wait(30);
+ }
+ } else if (diff > 0) {
+ for (let i = 0; i < diff; i++) {
+ await driver.pressKey("right");
+ await driver.wait(30);
+ }
+ }
+
+ await driver.pressKey("drop");
+ await driver.wait(60);
+}
+
+async function playRandomMove(driver: TetrisDriver): Promise<void> {
+ const actions = ["left", "right", "rotate", "down"] as const;
+ const randomMoves = Math.floor(Math.random() * 4) + 1;
+ for (let i = 0; i < randomMoves; i++) {
+ const action = actions[Math.floor(Math.random() * actions.length)];
+ await driver.pressKey(action);
+ await driver.wait(50);
+ }
+ await driver.pressKey("drop");
+ await driver.wait(100);
+}
+
+async function playRandomForDuration(driver: TetrisDriver, durationMs: number): Promise<void> {
+ const start = Date.now();
+ const actions = ["left", "right", "rotate", "down", "drop"] as const;
+ while (Date.now() - start < durationMs) {
+ const action = actions[Math.floor(Math.random() * actions.length)];
+ await driver.pressKey(action);
+ await driver.wait(100);
+ }
+}
+
+async function tryFillRow(driver: TetrisDriver, maxAttempts: number): Promise<boolean> {
+ const columns = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
+ let attempts = 0;
+
+ for (const targetCol of columns) {
+ if (attempts >= maxAttempts) break;
+
+ for (let i = 0; i < 6; i++) {
+ await driver.pressKey("left");
+ await driver.wait(30);
+ }
+
+ for (let i = 0; i < targetCol; i++) {
+ await driver.pressKey("right");
+ await driver.wait(30);
+ }
+
+ await driver.pressKey("drop");
+ await driver.wait(200);
+ attempts++;
+ }
+
+ const snap = await driver.readGrid();
+ if (!snap.grid) return false;
+
+ const bottomFilled = snap.filledInBottom(1);
+ return bottomFilled < 8;
+}
+
+// ---------------------------------------------------------------------------
+// Test derivation (pure data, no driver needed)
+// ---------------------------------------------------------------------------
+
+function deriveTestResults(
+ session: GameSession,
+ cal: DriverCalibration,
+ loadResult: LoadResult,
+ consoleErrors: string[],
+ gameplay: GameplayStats,
+ phaseState: PhaseState,
+ competitivePlay: CompetitivePlayResult | null
+): TestResult[] {
+ const results: TestResult[] = [];
+ const gridReliable = session.gridReadSuccess > 0 &&
+ session.gridReadSuccess / (session.gridReadSuccess + session.gridReadFail) > 0.5;
+
+ const skipResult = (name: string, reason: string): TestResult => ({
+ name, pass: false, detail: `skipped: ${reason}`,
+ });
+
+ // 1. game_loads
+ results.push({
+ name: "game_loads",
+ pass: loadResult.loaded && loadResult.errorsOnLoad === 0,
+ detail: loadResult.detail,
+ });
+
+ // 2. game_starts
+ {
+ let startDetail: string;
+ if (session.started) {
+ startDetail = `started via ${session.startMechanism}`;
+ if (cal.startButton) {
+ const btn = cal.startButton;
+ startDetail += ` (${btn.selector}, "${btn.text}"${btn.disappeared ? ", disappeared after click" : ""})`;
+ }
+ } else {
+ startDetail = "could not start game with any mechanism";
+ }
+ results.push({ name: "game_starts", pass: session.started, detail: startDetail });
+ }
+
+ // 3. auto_drop
+ if (!phaseState.gameStarted) {
+ results.push(skipResult("auto_drop", "game did not start"));
+ } else {
+ const autoDropEvents = session.events.filter(
+ (e) => e.type === "piece_moved" && e.direction === "down" && e.frame <= 2
+ );
+ if (autoDropEvents.length > 0) {
+ results.push({ name: "auto_drop", pass: true, detail: "grid state changed after 5s with no input (grid-verified)" });
+ } else if (!gridReliable) {
+ results.push({ name: "auto_drop", pass: false, detail: "grid reader unreliable, cannot verify auto-drop" });
+ } else {
+ results.push({ name: "auto_drop", pass: false, detail: "piece did not move down in 5 seconds (grid-verified)" });
+ }
+ }
+
+ // 4-6. movement tests
+ for (const dir of ["left", "right", "down"] as const) {
+ if (!phaseState.gameStarted) {
+ results.push(skipResult(`move_${dir}`, "game did not start"));
+ continue;
+ }
+ const moveEvents = session.events.filter((e) => e.type === "piece_moved" && e.direction === dir);
+ if (moveEvents.length > 0) {
+ results.push({ name: `move_${dir}`, pass: true, detail: "grid state changed after key press (grid-verified)" });
+ } else if (!gridReliable) {
+ results.push({ name: `move_${dir}`, pass: false, detail: "grid reader unreliable, cannot verify movement" });
+ } else {
+ results.push({ name: `move_${dir}`, pass: false, detail: "no grid change detected after key press" });
+ }
+ }
+
+ // 7. rotate
+ if (!phaseState.gameStarted) {
+ results.push(skipResult("rotate", "game did not start"));
+ } else if (session.rotationsObserved > 0) {
+ results.push({ name: "rotate", pass: true, detail: `piece shape changed after rotate key (grid-verified, ${session.rotationsObserved} rotation(s))` });
+ } else if (!gridReliable) {
+ results.push({ name: "rotate", pass: false, detail: "grid reader unreliable, cannot verify rotation" });
+ } else {
+ results.push({ name: "rotate", pass: false, detail: "no shape change detected after rotate key" });
+ }
+
+ // 8. hard_drop
+ if (!phaseState.gameStarted) {
+ results.push(skipResult("hard_drop", "game did not start"));
+ } else if (session.hardDropsObserved > 0) {
+ results.push({ name: "hard_drop", pass: true, detail: "piece immediately dropped to bottom (grid-verified)" });
+ } else if (!gridReliable) {
+ results.push({ name: "hard_drop", pass: false, detail: "grid reader unreliable, cannot verify hard drop" });
+ } else {
+ results.push({ name: "hard_drop", pass: false, detail: "no grid change with bottom cells detected after hard drop key" });
+ }
+
+ // 9. all_pieces_rotate
+ if (!phaseState.gameStarted) {
+ results.push(skipResult("all_pieces_rotate", "game did not start"));
+ } else {
+ const nonOPieceTypes = [...session.pieceTypes].filter((t) => t !== "O" && t !== "unknown");
+ if (session.rotationsObserved > 0 && nonOPieceTypes.length > 0) {
+ results.push({ name: "all_pieces_rotate", pass: true, detail: `rotation observed, piece types seen: [${[...session.pieceTypes].join(", ")}]` });
+ } else if (session.rotationsObserved > 0) {
+ results.push({ name: "all_pieces_rotate", pass: true, detail: "rotation confirmed but could not identify individual piece types" });
+ } else {
+ results.push({ name: "all_pieces_rotate", pass: false, detail: "could not detect any piece rotations via grid reader" });
+ }
+ }
+
+ // 10. piece_locks
+ if (!phaseState.gameStarted) {
+ results.push(skipResult("piece_locks", "game did not start"));
+ } else if (!gridReliable) {
+ results.push({ name: "piece_locks", pass: false, detail: "grid reader unreliable, cannot verify piece locking" });
+ } else {
+ const lockEvents = session.events.filter((e) => e.type === "piece_locked");
+ if (lockEvents.length > 0) {
+ results.push({ name: "piece_locks", pass: true, detail: `filled cells persist at bottom (grid-verified, ${lockEvents.length} lock event(s))` });
+ } else if (session.piecesLocked > 0 && session.piecesSpawned > 0) {
+ results.push({ name: "piece_locks", pass: true, detail: `${session.piecesLocked} piece(s) locked during play` });
+ } else if (session.piecesLocked > 0 && session.piecesSpawned === 0) {
+ results.push({ name: "piece_locks", pass: false, detail: `${session.piecesLocked} lock event(s) but 0 spawns detected - likely false positive from UI misread` });
+ } else {
+ results.push({ name: "piece_locks", pass: false, detail: "could not verify piece locking via grid reader" });
+ }
+ }
+
+ // 11. new_piece_spawns
+ if (!phaseState.gameStarted) {
+ results.push(skipResult("new_piece_spawns", "game did not start"));
+ } else if (session.piecesSpawned > 0) {
+ results.push({ name: "new_piece_spawns", pass: true, detail: `${session.piecesSpawned} new piece(s) detected at top of grid` });
+ } else {
+ results.push({ name: "new_piece_spawns", pass: false, detail: "could not detect new piece spawning at top via grid reader" });
+ }
+
+ // 12. multiple_pieces
+ if (!phaseState.mechanicsWork) {
+ results.push(skipResult("multiple_pieces", "mechanics phase failed"));
+ } else if (session.piecesLocked >= 3 && session.piecesSpawned > 0) {
+ results.push({ name: "multiple_pieces", pass: true, detail: `${session.piecesLocked} pieces placed during play session` });
+ } else {
+ results.push({ name: "multiple_pieces", pass: false, detail: `only ${session.piecesLocked} piece(s) detected, need at least 3` });
+ }
+
+ // 13. line_clear
+ if (!phaseState.mechanicsWork) {
+ results.push(skipResult("line_clear", "mechanics phase failed"));
+ } else if (session.linesCleared > 0) {
+ results.push({ name: "line_clear", pass: true, detail: `${session.linesCleared} line(s) cleared (grid-verified)` });
+ } else {
+ results.push({ name: "line_clear", pass: false, detail: "could not trigger or detect a line clear via grid reader" });
+ }
+
+ // 14. score_changes
+ if (!phaseState.mechanicsWork) {
+ results.push(skipResult("score_changes", "mechanics phase failed"));
+ } else if (session.scoreValues.length >= 2) {
+ const min = Math.min(...session.scoreValues);
+ const max = Math.max(...session.scoreValues);
+ if (max > min) {
+ results.push({ name: "score_changes", pass: true, detail: `score changed from ${min} to ${max}` });
+ } else {
+ results.push({ name: "score_changes", pass: false, detail: `score stayed at ${min}` });
+ }
+ } else if (!cal.scoreElementSelector) {
+ results.push({ name: "score_changes", pass: false, detail: "no score element found" });
+ } else {
+ results.push({ name: "score_changes", pass: false, detail: "could not read score values" });
+ }
+
+ // 15. game_over
+ if (!phaseState.piecesWork) {
+ results.push(skipResult("game_over", "piece lifecycle failed"));
+ } else {
+ results.push({
+ name: "game_over",
+ pass: session.gameOverDetected,
+ detail: session.gameOverDetected
+ ? "game stopped after stacking to top (grid-verified)"
+ : "could not trigger or detect game over via grid reader",
+ });
+ }
+
+ // 16. playable_30s
+ if (!phaseState.gameplayWorks) {
+ results.push(skipResult("playable_30s", "gameplay phase failed"));
+ } else {
+ // Only count errors during play, not pre-start errors
+ const playErrors = gameplay.errors_during_play;
+ const crashed = playErrors > 3;
+ if (!crashed && gameplay.play_duration_seconds >= 10) {
+ results.push({ name: "playable_30s", pass: true, detail: `played for ${gameplay.play_duration_seconds}s, placed ${gameplay.pieces_placed} pieces, no crashes` });
+ } else if (crashed) {
+ results.push({ name: "playable_30s", pass: false, detail: `${playErrors} play errors` });
+ } else {
+ results.push({ name: "playable_30s", pass: false, detail: `only played for ${gameplay.play_duration_seconds}s` });
+ }
+ }
+
+ // 17-25: Competitive play tests
+
+ // 17. multi_line_clear
+ if (!phaseState.gameplayWorks || !competitivePlay) {
+ results.push(skipResult("multi_line_clear", "competitive play phase did not run"));
+ } else if (competitivePlay.double_clears + competitivePlay.triple_clears + competitivePlay.tetris_clears > 0) {
+ const hasMultiLineBug = competitivePlay.bugs_detected.includes("multi_line_clear_only_removes_one_row");
+ results.push({
+ name: "multi_line_clear",
+ pass: !hasMultiLineBug,
+ detail: hasMultiLineBug
+ ? "multi-line clear detected but only 1 row was removed"
+ : `multi-line clears work: ${competitivePlay.double_clears}x double, ${competitivePlay.triple_clears}x triple, ${competitivePlay.tetris_clears}x tetris`,
+ });
+ } else {
+ results.push(skipResult("multi_line_clear", "no multi-line clear opportunity occurred during play"));
+ }
+
+ // 18. score_scaling
+ if (!phaseState.gameplayWorks || !competitivePlay) {
+ results.push(skipResult("score_scaling", "competitive play phase did not run"));
+ } else if (competitivePlay.double_clears + competitivePlay.triple_clears + competitivePlay.tetris_clears > 0) {
+ const hasBug = competitivePlay.bugs_detected.includes("score_does_not_scale_with_simultaneous_clears");
+ results.push({
+ name: "score_scaling",
+ pass: !hasBug,
+ detail: hasBug
+ ? "multi-line clears give same points as single clears"
+ : `score scales with clear type (${competitivePlay.score_increases.length} score changes observed)`,
+ });
+ } else {
+ results.push(skipResult("score_scaling", "no multi-line clear occurred to test scaling"));
+ }
+
+ // 19. level_progression
+ if (!phaseState.gameplayWorks || !competitivePlay) {
+ results.push(skipResult("level_progression", "competitive play phase did not run"));
+ } else if (competitivePlay.total_lines_cleared < 10) {
+ results.push(skipResult("level_progression", `only ${competitivePlay.total_lines_cleared} lines cleared (need 10+)`));
+ } else {
+ const hasBug = competitivePlay.bugs_detected.includes("level_does_not_increase");
+ if (competitivePlay.level_readings.length < 2) {
+ results.push(skipResult("level_progression", "could not read level display"));
+ } else {
+ results.push({
+ name: "level_progression",
+ pass: !hasBug,
+ detail: hasBug
+ ? `level stayed at ${competitivePlay.level_readings[0]} despite ${competitivePlay.total_lines_cleared} lines cleared`
+ : `level progressed from ${competitivePlay.level_readings[0]} to ${competitivePlay.level_final}`,
+ });
+ }
+ }
+
+ // 20. speed_progression
+ if (!phaseState.gameplayWorks || !competitivePlay) {
+ results.push(skipResult("speed_progression", "competitive play phase did not run"));
+ } else if (competitivePlay.level_readings.length < 2 || new Set(competitivePlay.level_readings).size <= 1) {
+ results.push(skipResult("speed_progression", "level did not increase, cannot test speed change"));
+ } else {
+ const hasBug = competitivePlay.bugs_detected.includes("speed_does_not_increase");
+ results.push({
+ name: "speed_progression",
+ pass: !hasBug && competitivePlay.speed_increased,
+ detail: competitivePlay.speed_increased
+ ? "drop speed increased with level"
+ : "drop speed did not change after level increased",
+ });
+ }
+
+ // 21. next_piece_preview
+ if (!phaseState.gameplayWorks || !competitivePlay) {
+ results.push(skipResult("next_piece_preview", "competitive play phase did not run"));
+ } else {
+ results.push({
+ name: "next_piece_preview",
+ pass: competitivePlay.next_piece_visible,
+ detail: competitivePlay.next_piece_visible ? "next piece preview display found" : "no next piece preview found",
+ });
+ }
+
+ // 22. game_over_display
+ if (!phaseState.gameplayWorks || !competitivePlay) {
+ results.push(skipResult("game_over_display", "competitive play phase did not run"));
+ } else if (!competitivePlay.game_over_reached && !session.gameOverDetected) {
+ results.push(skipResult("game_over_display", "game over not reached during play"));
+ } else {
+ const hasText = competitivePlay.game_over_text_found !== null;
+ const hasRestart = competitivePlay.restart_available;
+ results.push({
+ name: "game_over_display",
+ pass: hasText && hasRestart,
+ detail: hasText && hasRestart
+ ? `game over display: "${competitivePlay.game_over_text_found}", restart available`
+ : `missing: ${!hasText ? "game over text" : ""}${!hasText && !hasRestart ? " and " : ""}${!hasRestart ? "restart option" : ""}`,
+ });
+ }
+
+ // 23. counter_clockwise_rotation
+ if (!phaseState.gameplayWorks || !competitivePlay) {
+ results.push(skipResult("counter_clockwise_rotation", "competitive play phase did not run"));
+ } else {
+ const ccwTestDone = (competitivePlay as any)._ccwTestDone === true;
+ const ccwResult = (competitivePlay as any)._ccwResult;
+ if (!ccwTestDone) {
+ results.push(skipResult("counter_clockwise_rotation", "could not test rotation direction"));
+ } else {
+ results.push({
+ name: "counter_clockwise_rotation",
+ pass: ccwResult === true,
+ detail: ccwResult === true
+ ? "Z key rotates opposite direction from Up arrow"
+ : ccwResult === false
+ ? "Z key does same as Up arrow or does not rotate"
+ : "could not determine rotation direction",
+ });
+ }
+ }
+
+ // 24. soft_drop_distinct
+ if (!phaseState.gameplayWorks || !competitivePlay) {
+ results.push(skipResult("soft_drop_distinct", "competitive play phase did not run"));
+ } else {
+ const softDropTestDone = (competitivePlay as any)._softDropTestDone === true;
+ const softDropDistinct = (competitivePlay as any)._softDropDistinct;
+ if (!softDropTestDone) {
+ results.push(skipResult("soft_drop_distinct", "could not test soft drop behavior"));
+ } else {
+ results.push({
+ name: "soft_drop_distinct",
+ pass: softDropDistinct === true,
+ detail: softDropDistinct === true
+ ? "Down arrow moves piece 1 row (distinct from hard drop)"
+ : "Down arrow acts like hard drop (drops to bottom)",
+ });
+ }
+ }
+
+ // 25. rendering_clean
+ if (!phaseState.gameplayWorks || !competitivePlay) {
+ results.push(skipResult("rendering_clean", "competitive play phase did not run"));
+ } else if (competitivePlay.rendering_trail_detected === undefined) {
+ results.push(skipResult("rendering_clean", "not enough data to assess rendering trails"));
+ } else {
+ results.push({
+ name: "rendering_clean",
+ pass: !competitivePlay.rendering_trail_detected,
+ detail: competitivePlay.rendering_trail_detected
+ ? "rendering trail bug: falling piece leaves old cells colored after moving"
+ : "piece movement clears old cells correctly",
+ });
+ }
+
+ return results;
+}
diff --git a/tasks/tetris/eval/gameplay-bot-v2/driver.ts b/tasks/tetris/eval/gameplay-bot-v2/driver.ts
@@ -0,0 +1,1710 @@
+// PlaywrightDriver: TetrisDriver implementation using Playwright
+// "The Eyes and Hands" -- handles ALL webpage interaction
+
+import type { Page } from "@playwright/test";
+import type {
+ Grid,
+ GridBounds,
+ RendererType,
+ Controls,
+ StartMechanism,
+ SurveyData,
+ PieceType,
+ DriverCalibration,
+ GridSnapshot,
+ TetrisDriver,
+} from "./types";
+
+const GRID_ROWS = 20;
+const GRID_COLS = 10;
+
+const DEFAULT_CONTROLS: Controls = {
+ left: "ArrowLeft",
+ right: "ArrowRight",
+ down: "ArrowDown",
+ rotate: "ArrowUp",
+ drop: "Space",
+};
+
+// ---------------------------------------------------------------------------
+// GridSnapshot factory
+// ---------------------------------------------------------------------------
+
+function makeSnapshot(
+ grid: Grid | null,
+ settledGrid?: Grid | null
+): GridSnapshot {
+ const filledCount = grid
+ ? grid.reduce((s, row) => s + row.filter(Boolean).length, 0)
+ : 0;
+
+ const activePieceCells = detectActivePieceCells(grid, settledGrid ?? null);
+ const activePieceType = activePieceCells ? identifyPieceType(activePieceCells) : null;
+ const completeRows = grid ? countCompleteRows(grid) : 0;
+
+ return {
+ grid,
+ filledCount,
+ filledInBottom(rows: number): number {
+ if (!grid) return 0;
+ let count = 0;
+ const start = Math.max(0, grid.length - rows);
+ for (let r = start; r < grid.length; r++) {
+ for (let c = 0; c < grid[r].length; c++) {
+ if (grid[r][c]) count++;
+ }
+ }
+ return count;
+ },
+ hasFilledInTop(rows: number): boolean {
+ if (!grid) return false;
+ for (let r = 0; r < Math.min(rows, grid.length); r++) {
+ for (let c = 0; c < grid[r].length; c++) {
+ if (grid[r][c]) return true;
+ }
+ }
+ return false;
+ },
+ completeRows,
+ activePieceCells,
+ activePieceType,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Pure grid utility functions
+// ---------------------------------------------------------------------------
+
+function countCompleteRows(grid: Grid): number {
+ let count = 0;
+ for (const row of grid) {
+ if (row.every(Boolean)) count++;
+ }
+ return count;
+}
+
+function detectActivePieceCells(
+ current: Grid | null,
+ settled: Grid | null
+): [number, number][] | null {
+ if (!current) return null;
+
+ const cells: [number, number][] = [];
+
+ if (settled && settled.length === current.length) {
+ for (let row = 0; row < current.length; row++) {
+ for (let col = 0; col < current[row].length; col++) {
+ if (current[row][col] && !settled[row][col]) {
+ cells.push([row, col]);
+ }
+ }
+ }
+ } else {
+ // Fallback: scan top 6 rows for filled cells
+ for (let row = 0; row < Math.min(6, current.length); row++) {
+ for (let col = 0; col < current[row].length; col++) {
+ if (current[row][col]) {
+ cells.push([row, col]);
+ }
+ }
+ }
+ }
+
+ if (cells.length < 3 || cells.length > 5) return null;
+ return cells;
+}
+
+function identifyPieceType(cells: [number, number][]): PieceType {
+ if (cells.length !== 4) return "unknown";
+
+ const minRow = Math.min(...cells.map(([r]) => r));
+ const maxRow = Math.max(...cells.map(([r]) => r));
+ const minCol = Math.min(...cells.map(([, c]) => c));
+ const maxCol = Math.max(...cells.map(([, c]) => c));
+ const w = maxCol - minCol + 1;
+ const h = maxRow - minRow + 1;
+
+ const norm = cells.map(([r, c]) => [r - minRow, c - minCol] as [number, number]);
+ const key = norm
+ .sort((a, b) => a[0] - b[0] || a[1] - b[1])
+ .map(([r, c]) => `${r},${c}`)
+ .join("|");
+
+ if (w === 4 && h === 1) return "I";
+ if (w === 1 && h === 4) return "I";
+ if (w === 2 && h === 2) return "O";
+
+ const tPatterns = [
+ "0,0|0,1|0,2|1,1", "0,0|1,0|1,1|2,0", "0,1|1,0|1,1|1,2",
+ "0,0|0,1|1,0|2,0", "0,1|1,0|1,1|2,1", "0,0|0,1|1,1|2,1",
+ ];
+ if (tPatterns.includes(key)) return "T";
+
+ const sPatterns = ["0,1|0,2|1,0|1,1", "0,0|1,0|1,1|2,1"];
+ if (sPatterns.includes(key)) return "S";
+
+ const zPatterns = ["0,0|0,1|1,1|1,2", "0,1|1,0|1,1|2,0"];
+ if (zPatterns.includes(key)) return "Z";
+
+ const jPatterns = [
+ "0,0|1,0|1,1|1,2", "0,0|0,1|1,0|2,0", "0,0|0,1|0,2|1,2",
+ "0,0|1,0|2,0|2,1", "0,1|1,1|2,0|2,1",
+ ];
+ if (jPatterns.includes(key)) return "J";
+
+ const lPatterns = [
+ "0,2|1,0|1,1|1,2", "0,0|1,0|2,0|2,1", "0,0|0,1|0,2|1,0",
+ "0,0|0,1|1,1|2,1",
+ ];
+ if (lPatterns.includes(key)) return "L";
+
+ return "unknown";
+}
+
+// ---------------------------------------------------------------------------
+// PlaywrightDriver
+// ---------------------------------------------------------------------------
+
+export class PlaywrightDriver implements TetrisDriver {
+ private page: Page;
+ private cal: DriverCalibration | null = null;
+ private consoleErrors: string[] = [];
+ private log = (msg: string) => console.log(`[driver] ${msg}`);
+
+ constructor(page: Page) {
+ this.page = page;
+ }
+
+ // -- Lifecycle --
+
+ async loadPage(url: string): Promise<{ loaded: boolean; detail: string; errorsOnLoad: number }> {
+ this.consoleErrors = [];
+ this.page.on("pageerror", (err) => {
+ this.consoleErrors.push(err.message);
+ });
+
+ const errorsBefore = this.consoleErrors.length;
+
+ try {
+ const response = await this.page.goto(url, {
+ timeout: 15000,
+ waitUntil: "networkidle",
+ });
+ if (!response || !response.ok()) {
+ return {
+ loaded: false,
+ detail: `page load failed: HTTP ${response?.status()}`,
+ errorsOnLoad: this.consoleErrors.length - errorsBefore,
+ };
+ }
+ await this.page.waitForTimeout(3000);
+ } catch (err) {
+ return {
+ loaded: false,
+ detail: `page load failed: ${err instanceof Error ? err.message : String(err)}`,
+ errorsOnLoad: this.consoleErrors.length - errorsBefore,
+ };
+ }
+
+ const newErrors = this.consoleErrors.slice(errorsBefore);
+ return {
+ loaded: true,
+ detail: newErrors.length === 0
+ ? "no console errors"
+ : `${newErrors.length} console error(s): ${newErrors[0]}`,
+ errorsOnLoad: newErrors.length,
+ };
+ }
+
+ async surveyPage(): Promise<SurveyData> {
+ try {
+ return await this.page.evaluate(() => {
+ let hasOverlay = false;
+ const allEls = document.querySelectorAll("*");
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ for (const el of allEls) {
+ const style = window.getComputedStyle(el);
+ const pos = style.position;
+ if (pos === "fixed" || pos === "absolute") {
+ const zIndex = parseInt(style.zIndex, 10);
+ if (zIndex > 0 || style.zIndex === "auto") {
+ const rect = (el as HTMLElement).getBoundingClientRect();
+ if (rect.width > vw * 0.5 && rect.height > vh * 0.5) {
+ hasOverlay = true;
+ break;
+ }
+ }
+ }
+ }
+
+ const hasCanvas = document.querySelectorAll("canvas").length > 0;
+
+ let hasDomGrid = false;
+ const containers = document.querySelectorAll(
+ '[class*="board"], [class*="grid"], [class*="field"], [id*="board"], [id*="grid"], [id*="field"]'
+ );
+ for (const container of containers) {
+ const ch = container.children;
+ if (
+ (ch.length >= 180 && ch.length <= 220) ||
+ (ch.length >= 18 && ch.length <= 22 &&
+ ch[0]?.children.length >= 8 && ch[0]?.children.length <= 12)
+ ) {
+ hasDomGrid = true;
+ break;
+ }
+ }
+ if (!hasDomGrid) {
+ const tables = document.querySelectorAll("table");
+ for (const table of tables) {
+ const rows = table.querySelectorAll("tr");
+ if (rows.length >= 18 && (rows[0]?.querySelectorAll("td").length ?? 0) >= 8) {
+ hasDomGrid = true;
+ break;
+ }
+ }
+ }
+ // Heuristic scan for unlabeled grids
+ if (!hasDomGrid) {
+ const allElements = document.querySelectorAll("div, section, main, article");
+ for (const el of allElements) {
+ const ch = el.children;
+ if (ch.length >= 180 && ch.length <= 220) {
+ const firstChild = ch[0] as HTMLElement;
+ if (!firstChild) continue;
+ const firstRect = firstChild.getBoundingClientRect();
+ if (firstRect.width < 5 || firstRect.height < 5) continue;
+ let uniform = true;
+ for (let i = 1; i < Math.min(10, ch.length); i++) {
+ const r = (ch[i] as HTMLElement).getBoundingClientRect();
+ if (Math.abs(r.width - firstRect.width) > 2 || Math.abs(r.height - firstRect.height) > 2) {
+ uniform = false;
+ break;
+ }
+ }
+ if (uniform) { hasDomGrid = true; break; }
+ }
+ if (ch.length >= 18 && ch.length <= 22) {
+ const firstRowCells = ch[0]?.children;
+ if (firstRowCells && firstRowCells.length >= 8 && firstRowCells.length <= 12) {
+ const rect = el.getBoundingClientRect();
+ if (rect.width > 50 && rect.height > 100) { hasDomGrid = true; break; }
+ }
+ }
+ }
+ }
+
+ const bodyText = (document.body?.innerText || "").trim();
+ const visibleText = bodyText
+ .split("\n")
+ .map((line: string) => line.trim())
+ .filter((line: string) => line.length > 0)
+ .slice(0, 20);
+
+ const clickableSelector =
+ 'button, a, [role="button"], [onclick], input[type="button"], input[type="submit"]';
+ const clickableElements = document.querySelectorAll(clickableSelector).length;
+
+ return { has_overlay: hasOverlay, has_canvas: hasCanvas, has_dom_grid: hasDomGrid, visible_text: visibleText, clickable_elements: clickableElements };
+ });
+ } catch {
+ return { has_overlay: false, has_canvas: false, has_dom_grid: false, visible_text: [], clickable_elements: 0 };
+ }
+ }
+
+ async calibrate(): Promise<DriverCalibration> {
+ await this.page.waitForTimeout(2000);
+
+ let startResult = await this.detectStartMechanism();
+ let startMechanism: StartMechanism = startResult.mechanism;
+ let startButton = startResult.startButton;
+ let gridDetection = await this.detectGrid();
+ let { renderer, gridBounds, cellWidth, cellHeight } = gridDetection;
+ let backgroundColor =
+ renderer === "canvas" && gridBounds
+ ? await this.sampleBackgroundColor(gridBounds, cellWidth, cellHeight)
+ : null;
+
+ // Re-calibration fallback
+ if (startMechanism === "unknown" || gridBounds === null) {
+ const retry = await this.recalibrateWithRetry(startMechanism, gridBounds);
+ if (retry.startMechanism !== "unknown") startMechanism = retry.startMechanism;
+ if (retry.startButton) startButton = retry.startButton;
+ if (retry.gridBounds) {
+ renderer = retry.renderer;
+ gridBounds = retry.gridBounds;
+ cellWidth = retry.cellWidth;
+ cellHeight = retry.cellHeight;
+ backgroundColor =
+ renderer === "canvas" && gridBounds
+ ? await this.sampleBackgroundColor(gridBounds, cellWidth, cellHeight)
+ : null;
+ }
+ }
+
+ const controls = await this.detectControls();
+ const scoreElementSelector = await this.detectScoreElement();
+ const levelElementSelector = await this.detectLevelElement();
+
+ const gridConfidence = await this.measureGridConfidence({
+ renderer, gridDetected: gridBounds !== null, gridBounds, cellWidth, cellHeight,
+ controls, startMechanism, scoreElementSelector, levelElementSelector,
+ backgroundColor, consoleErrors: [...this.consoleErrors], gridConfidence: 0,
+ gridDetectedAt: "initial",
+ });
+
+ this.cal = {
+ renderer,
+ gridDetected: gridBounds !== null,
+ gridBounds,
+ cellWidth,
+ cellHeight,
+ controls,
+ startMechanism,
+ scoreElementSelector,
+ levelElementSelector,
+ backgroundColor,
+ consoleErrors: [...this.consoleErrors],
+ gridConfidence,
+ gridDetectedAt: "initial",
+ };
+
+ if (startButton) {
+ this.cal.startButton = startButton;
+ }
+
+ return this.cal;
+ }
+
+ async recalibrate(): Promise<DriverCalibration> {
+ const prev = this.cal;
+ await this.page.waitForTimeout(500);
+
+ const gridDetection = await this.detectGrid();
+ if (gridDetection.gridBounds && prev) {
+ const backgroundColor =
+ gridDetection.renderer === "canvas" && gridDetection.gridBounds
+ ? await this.sampleBackgroundColor(gridDetection.gridBounds, gridDetection.cellWidth, gridDetection.cellHeight)
+ : null;
+
+ this.cal = {
+ ...prev,
+ renderer: gridDetection.renderer,
+ gridDetected: true,
+ gridBounds: gridDetection.gridBounds,
+ cellWidth: gridDetection.cellWidth,
+ cellHeight: gridDetection.cellHeight,
+ backgroundColor,
+ gridDetectedAt: "after_start",
+ };
+ } else if (!prev) {
+ return this.calibrate();
+ }
+
+ return this.cal!;
+ }
+
+ getCalibration(): DriverCalibration {
+ if (!this.cal) throw new Error("calibrate() must be called before getCalibration()");
+ return this.cal;
+ }
+
+ // -- Grid Reading --
+
+ async readGrid(settledGrid?: Grid | null): Promise<GridSnapshot> {
+ try {
+ const cal = this.cal;
+ if (!cal) return makeSnapshot(null);
+
+ let grid: Grid | null = null;
+
+ if (cal.renderer === "canvas" && cal.gridBounds) {
+ grid = await this.readCanvasGrid(cal.gridBounds, cal.cellWidth, cal.cellHeight, cal.backgroundColor);
+ }
+ if (!grid && cal.renderer === "dom") {
+ grid = await this.readDomGrid();
+ }
+ if (!grid && cal.gridBounds) {
+ grid = await this.readCanvasGrid(cal.gridBounds, cal.cellWidth, cal.cellHeight, cal.backgroundColor);
+ }
+ if (!grid) {
+ grid = await this.readDomGrid();
+ }
+
+ return makeSnapshot(grid, settledGrid);
+ } catch {
+ return makeSnapshot(null);
+ }
+ }
+
+ gridsAreDifferent(a: Grid | null, b: Grid | null): boolean {
+ if (a === null || b === null) return a !== b;
+ if (a.length !== b.length) return true;
+ for (let r = 0; r < a.length; r++) {
+ if (a[r].length !== b[r].length) return true;
+ for (let c = 0; c < a[r].length; c++) {
+ if (a[r][c] !== b[r][c]) return true;
+ }
+ }
+ return false;
+ }
+
+ // -- Input --
+
+ async pressKey(action: "left" | "right" | "down" | "rotate" | "drop"): Promise<void> {
+ const cal = this.cal;
+ const key = cal ? cal.controls[action] : DEFAULT_CONTROLS[action];
+ await this.page.keyboard.press(key);
+ }
+
+ async pressRawKey(key: string): Promise<void> {
+ await this.page.keyboard.press(key);
+ }
+
+ async wait(ms: number): Promise<void> {
+ await this.page.waitForTimeout(ms);
+ }
+
+ // -- Score/Level --
+
+ async readScore(): Promise<number | null> {
+ const cal = this.cal;
+ if (!cal?.scoreElementSelector) return null;
+ try {
+ const scoreText = await this.page.textContent(cal.scoreElementSelector);
+ const nums = this.extractScoreFromText(scoreText);
+ return nums.length > 0 ? Math.max(...nums) : null;
+ } catch {
+ return null;
+ }
+ }
+
+ async readLevel(): Promise<number | null> {
+ try {
+ return await this.page.evaluate(() => {
+ const allElements = document.querySelectorAll("*");
+ for (const el of allElements) {
+ const text = ((el as HTMLElement).innerText || "").toLowerCase();
+ if (text.includes("level") && el.children.length < 5) {
+ const match = text.match(/level\s*[:\-=]?\s*(\d+)/i);
+ if (match) return parseInt(match[1], 10);
+
+ const children = el.querySelectorAll("span, div, p, td, strong, em, b");
+ for (const child of children) {
+ const childText = (child.textContent || "").trim();
+ if (/^\d+$/.test(childText)) return parseInt(childText, 10);
+ }
+
+ const next = el.nextElementSibling;
+ if (next) {
+ const nextText = (next.textContent || "").trim();
+ if (/^\d+$/.test(nextText)) return parseInt(nextText, 10);
+ }
+ }
+ }
+ // Also try nivel (Spanish)
+ for (const el of allElements) {
+ const text = ((el as HTMLElement).innerText || "").toLowerCase();
+ if (text.includes("nivel") && el.children.length < 5) {
+ const match = text.match(/nivel\s*[:\-=]?\s*(\d+)/i);
+ if (match) return parseInt(match[1], 10);
+ }
+ }
+ return null;
+ });
+ } catch {
+ return null;
+ }
+ }
+
+ // -- Page State Queries --
+
+ async detectGameOverText(): Promise<string | null> {
+ try {
+ return await this.page.evaluate(() => {
+ const text = document.body.innerText.toLowerCase();
+ const patterns: [RegExp, string][] = [
+ [/game\s*over/i, "Game Over"],
+ [/you lose/i, "You Lose"],
+ [/try again/i, "Try Again"],
+ [/play again/i, "Play Again"],
+ [/fin del juego/i, "Fin del Juego"],
+ [/juego terminado/i, "Juego Terminado"],
+ [/partida terminada/i, "Partida Terminada"],
+ ];
+ for (const [regex, label] of patterns) {
+ if (regex.test(text)) return label;
+ }
+ return null;
+ });
+ } catch {
+ return null;
+ }
+ }
+
+ async detectRestartOption(): Promise<boolean> {
+ try {
+ return await this.page.evaluate(() => {
+ const text = document.body.innerText.toLowerCase();
+ const buttons = document.querySelectorAll("button");
+ for (const btn of buttons) {
+ const btnText = (btn.textContent || "").toLowerCase();
+ if (btnText.includes("restart") || btnText.includes("play again") ||
+ btnText.includes("new game") || btnText.includes("reiniciar") ||
+ btnText.includes("jugar de nuevo") || btnText.includes("nueva partida")) {
+ return true;
+ }
+ }
+ return text.includes("restart") || text.includes("play again") ||
+ text.includes("press") || text.includes("try again") ||
+ text.includes("reiniciar") || text.includes("jugar de nuevo");
+ });
+ } catch {
+ return false;
+ }
+ }
+
+ async detectNextPiecePreview(): Promise<boolean> {
+ try {
+ return await this.page.evaluate(() => {
+ const allElements = document.querySelectorAll("*");
+ for (const el of allElements) {
+ const text = ((el as HTMLElement).innerText || "").toLowerCase();
+ if ((text.includes("next") || text.includes("siguiente")) && el.children.length < 10) {
+ const rect = (el as HTMLElement).getBoundingClientRect();
+ if (rect.width > 20 && rect.height > 20) return true;
+ }
+ }
+
+ const canvases = document.querySelectorAll("canvas");
+ if (canvases.length >= 2) {
+ const mainRect = canvases[0].getBoundingClientRect();
+ for (let i = 1; i < canvases.length; i++) {
+ const rect = canvases[i].getBoundingClientRect();
+ if (rect.width < mainRect.width * 0.5 && rect.height < mainRect.height * 0.5 &&
+ rect.width > 20 && rect.height > 20) return true;
+ }
+ }
+
+ const nextContainers = document.querySelectorAll(
+ '[class*="next"], [id*="next"], [class*="preview"], [id*="preview"], [class*="siguiente"], [id*="siguiente"]'
+ );
+ for (const container of nextContainers) {
+ const rect = (container as HTMLElement).getBoundingClientRect();
+ if (rect.width > 20 && rect.height > 20) return true;
+ }
+ return false;
+ });
+ } catch {
+ return false;
+ }
+ }
+
+ getConsoleErrors(): string[] {
+ return [...this.consoleErrors];
+ }
+
+ // -- Screenshots --
+
+ async screenshot(): Promise<Buffer> {
+ return await this.page.screenshot();
+ }
+
+ async measureDropInterval(): Promise<number> {
+ try {
+ const intervals: number[] = [];
+ let lastChangeTime = Date.now();
+ let prevSnap = await this.readGrid();
+
+ for (let i = 0; i < 10; i++) {
+ await this.page.waitForTimeout(100);
+ const snap = await this.readGrid();
+ if (snap.grid && prevSnap.grid && this.gridsAreDifferent(snap.grid, prevSnap.grid)) {
+ const now = Date.now();
+ const interval = now - lastChangeTime;
+ if (interval > 50 && interval < 3000) intervals.push(interval);
+ lastChangeTime = now;
+ prevSnap = snap;
+ }
+ }
+
+ if (intervals.length >= 2) {
+ return intervals.reduce((a, b) => a + b, 0) / intervals.length;
+ }
+ } catch { /* ignore */ }
+ return 0;
+ }
+
+ // =========================================================================
+ // PRIVATE METHODS
+ // =========================================================================
+
+ // -- Grid reading internals --
+
+ private async readCanvasGrid(
+ bounds: GridBounds, cellW: number, cellH: number,
+ bgColor: [number, number, number] | null
+ ): Promise<Grid | null> {
+ const bgR = bgColor ? bgColor[0] : 0;
+ const bgG = bgColor ? bgColor[1] : 0;
+ const bgB = bgColor ? bgColor[2] : 0;
+ const threshold = 50;
+
+ const grid = await this.page.evaluate(
+ ({ x, y, cellW, cellH, rows, cols, bgR, bgG, bgB, threshold }) => {
+ const canvas = document.querySelector("canvas") as HTMLCanvasElement | null;
+ if (!canvas) return null;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return null;
+
+ const offsets = [
+ [0, 0],
+ [-Math.floor(cellW / 4), 0],
+ [Math.floor(cellW / 4), 0],
+ [0, -Math.floor(cellH / 4)],
+ [0, Math.floor(cellH / 4)],
+ ];
+
+ const result: boolean[][] = [];
+ for (let row = 0; row < rows; row++) {
+ const rowData: boolean[] = [];
+ for (let col = 0; col < cols; col++) {
+ const cx = Math.floor(x + col * cellW + cellW / 2);
+ const cy = Math.floor(y + row * cellH + cellH / 2);
+
+ let filledCount = 0;
+ for (const [ox, oy] of offsets) {
+ const px = Math.min(Math.max(cx + ox, 0), canvas.width - 1);
+ const py = Math.min(Math.max(cy + oy, 0), canvas.height - 1);
+ const pixel = ctx.getImageData(px, py, 1, 1).data;
+ const dr = pixel[0] - bgR;
+ const dg = pixel[1] - bgG;
+ const db = pixel[2] - bgB;
+ const dist = Math.sqrt(dr * dr + dg * dg + db * db);
+ if (dist > threshold) filledCount++;
+ }
+ rowData.push(filledCount >= 3);
+ }
+ result.push(rowData);
+ }
+ return result;
+ },
+ { x: bounds.x, y: bounds.y, cellW, cellH, rows: GRID_ROWS, cols: GRID_COLS, bgR, bgG, bgB, threshold }
+ );
+
+ if (grid) {
+ const totalCells = GRID_ROWS * GRID_COLS;
+ const filledCells = grid.reduce((sum, row) => sum + row.filter(Boolean).length, 0);
+ if (filledCells / totalCells > 0.60) return null;
+ }
+
+ return grid;
+ }
+
+ private async readDomGrid(): Promise<Grid | null> {
+ const grid = await this.page.evaluate(({ rows, cols }) => {
+ function isCellFilled(cell: HTMLElement, emptyBg?: string): boolean {
+ const style = window.getComputedStyle(cell);
+ const bg = style.backgroundColor;
+ const cls = cell.className.toLowerCase();
+
+ if (
+ cls.includes("filled") || cls.includes("active") ||
+ cls.includes("block") || cls.includes("piece") ||
+ cls.includes("occupied") || cls.includes("locked") ||
+ cell.dataset.filled === "true" || cell.dataset.type !== undefined
+ ) return true;
+
+ if (emptyBg && bg === emptyBg) return false;
+
+ return (
+ bg !== "" && bg !== "rgba(0, 0, 0, 0)" &&
+ bg !== "transparent" && bg !== "rgb(0, 0, 0)"
+ );
+ }
+
+ function detectEmptyBg(cells: HTMLElement[]): string | undefined {
+ const colorCounts = new Map<string, number>();
+ for (const cell of cells) {
+ const bg = window.getComputedStyle(cell).backgroundColor;
+ colorCounts.set(bg, (colorCounts.get(bg) || 0) + 1);
+ }
+ let maxCount = 0;
+ let emptyBg: string | undefined;
+ for (const [color, count] of colorCounts) {
+ if (count > maxCount) { maxCount = count; emptyBg = color; }
+ }
+ if (emptyBg && maxCount > cells.length * 0.6) return emptyBg;
+ return undefined;
+ }
+
+ // Strategy 1: table-based grid
+ const tables = document.querySelectorAll("table");
+ for (const table of tables) {
+ const trs = table.querySelectorAll("tr");
+ if (trs.length >= rows) {
+ const allCells: HTMLElement[] = [];
+ for (let r = 0; r < rows; r++) {
+ const tds = trs[r].querySelectorAll("td");
+ for (let c = 0; c < Math.min(cols, tds.length); c++) allCells.push(tds[c] as HTMLElement);
+ }
+ const emptyBg = detectEmptyBg(allCells);
+ const result: boolean[][] = [];
+ for (let r = 0; r < rows; r++) {
+ const tds = trs[r].querySelectorAll("td");
+ const rowData: boolean[] = [];
+ for (let c = 0; c < cols; c++) {
+ rowData.push(c < tds.length ? isCellFilled(tds[c] as HTMLElement, emptyBg) : false);
+ }
+ result.push(rowData);
+ }
+ return result;
+ }
+ }
+
+ // Strategy 2: named grid containers
+ const containers = document.querySelectorAll(
+ '[class*="board"], [class*="grid"], [class*="field"], [id*="board"], [id*="grid"], [id*="field"]'
+ );
+ for (const container of containers) {
+ const children = container.children;
+ if (children.length >= rows * cols - 10 && children.length <= rows * cols + 10) {
+ const actualCols = cols;
+ const actualRows = Math.round(children.length / actualCols);
+ const allCells = Array.from(children).slice(0, actualRows * actualCols) as HTMLElement[];
+ const emptyBg = detectEmptyBg(allCells);
+ const result: boolean[][] = [];
+ for (let r = 0; r < actualRows; r++) {
+ const rowData: boolean[] = [];
+ for (let c = 0; c < actualCols; c++) {
+ const cell = allCells[r * actualCols + c];
+ rowData.push(cell ? isCellFilled(cell, emptyBg) : false);
+ }
+ result.push(rowData);
+ }
+ return result;
+ }
+ if (children.length >= rows - 2 && children.length <= rows + 2) {
+ const firstRowCells = children[0]?.children;
+ if (firstRowCells && firstRowCells.length >= cols - 2 && firstRowCells.length <= cols + 2) {
+ const actualRows = children.length;
+ const actualCols = firstRowCells.length;
+ const allCells: HTMLElement[] = [];
+ for (let r = 0; r < actualRows; r++) {
+ const cells = children[r].children;
+ for (let c = 0; c < Math.min(actualCols, cells.length); c++) allCells.push(cells[c] as HTMLElement);
+ }
+ const emptyBg = detectEmptyBg(allCells);
+ let valid = true;
+ const result: boolean[][] = [];
+ for (let r = 0; r < actualRows; r++) {
+ const cells = children[r].children;
+ if (cells.length < actualCols) { valid = false; break; }
+ const rowData: boolean[] = [];
+ for (let c = 0; c < actualCols; c++) rowData.push(isCellFilled(cells[c] as HTMLElement, emptyBg));
+ result.push(rowData);
+ }
+ if (valid) return result;
+ }
+ }
+ }
+
+ // Strategy 3: heuristic scan for ANY container with many same-sized children
+ const allElements = document.querySelectorAll("div, section, main, article");
+ for (const el of allElements) {
+ const ch = el.children;
+ if (ch.length >= 180 && ch.length <= 220) {
+ const firstChild = ch[0] as HTMLElement;
+ if (!firstChild) continue;
+ const firstRect = firstChild.getBoundingClientRect();
+ if (firstRect.width < 5 || firstRect.height < 5) continue;
+ let uniform = true;
+ for (let i = 1; i < Math.min(10, ch.length); i++) {
+ const r = (ch[i] as HTMLElement).getBoundingClientRect();
+ if (Math.abs(r.width - firstRect.width) > 2 || Math.abs(r.height - firstRect.height) > 2) { uniform = false; break; }
+ }
+ if (uniform) {
+ const actualCols = cols;
+ const actualRows = Math.round(ch.length / actualCols);
+ const allCells = Array.from(ch).slice(0, actualRows * actualCols) as HTMLElement[];
+ const emptyBg = detectEmptyBg(allCells);
+ const result: boolean[][] = [];
+ for (let r = 0; r < actualRows; r++) {
+ const rowData: boolean[] = [];
+ for (let c = 0; c < actualCols; c++) {
+ const cell = allCells[r * actualCols + c];
+ rowData.push(cell ? isCellFilled(cell, emptyBg) : false);
+ }
+ result.push(rowData);
+ }
+ return result;
+ }
+ }
+ if (ch.length >= 18 && ch.length <= 22) {
+ const firstRowCells = ch[0]?.children;
+ if (firstRowCells && firstRowCells.length >= 8 && firstRowCells.length <= 12) {
+ const rect = el.getBoundingClientRect();
+ if (rect.width > 50 && rect.height > 100) {
+ const actualRows = ch.length;
+ const actualCols = firstRowCells.length;
+ const allCells: HTMLElement[] = [];
+ for (let r = 0; r < actualRows; r++) {
+ const cells = ch[r].children;
+ for (let c = 0; c < Math.min(actualCols, cells.length); c++) allCells.push(cells[c] as HTMLElement);
+ }
+ const emptyBg = detectEmptyBg(allCells);
+ let valid = true;
+ const result: boolean[][] = [];
+ for (let r = 0; r < actualRows; r++) {
+ const cells = ch[r].children;
+ if (cells.length < actualCols) { valid = false; break; }
+ const rowData: boolean[] = [];
+ for (let c = 0; c < actualCols; c++) rowData.push(isCellFilled(cells[c] as HTMLElement, emptyBg));
+ result.push(rowData);
+ }
+ if (valid) return result;
+ }
+ }
+ }
+ }
+
+ return null;
+ }, { rows: GRID_ROWS, cols: GRID_COLS });
+
+ return grid;
+ }
+
+ private async sampleBackgroundColor(
+ bounds: GridBounds, cellW: number, cellH: number
+ ): Promise<[number, number, number] | null> {
+ try {
+ return await this.page.evaluate(
+ ({ x, y, cellW, cellH }) => {
+ const canvas = document.querySelector("canvas") as HTMLCanvasElement | null;
+ if (!canvas) return null;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return null;
+ const px = Math.floor(x + cellW / 2);
+ const py = Math.floor(y + cellH / 2);
+ const pixel = ctx.getImageData(px, py, 1, 1).data;
+ return [pixel[0], pixel[1], pixel[2]] as [number, number, number];
+ },
+ { x: bounds.x, y: bounds.y, cellW, cellH }
+ );
+ } catch {
+ return null;
+ }
+ }
+
+ // -- Start detection --
+
+ private async detectStartMechanism(): Promise<{
+ mechanism: StartMechanism;
+ startButton?: DriverCalibration["startButton"];
+ }> {
+ const deadline = Date.now() + 30000;
+ const budgetExceeded = () => Date.now() >= deadline;
+
+ try {
+ const diag = await this.page.evaluate(() => ({
+ title: document.title,
+ buttons: Array.from(document.querySelectorAll("button")).map(b => b.textContent?.trim()),
+ canvases: Array.from(document.querySelectorAll("canvas")).length,
+ bodySize: document.body?.innerHTML?.length ?? 0,
+ }));
+ this.log(`Page: "${diag.title}", ${diag.buttons.length} buttons, ${diag.canvases} canvases`);
+ } catch { /* continue */ }
+
+ // Phase 1: Auto-start check
+ {
+ this.log("Phase 1: checking auto-start...");
+ const result = await this.detectVisualChange({ frames: 6, intervalMs: 200 });
+ if (result.changed) {
+ const interactive = await this.verifyInteractivity();
+ if (interactive) {
+ this.log("Auto-start detected and interactive");
+ return { mechanism: "auto" };
+ }
+ this.log("Visual change but not interactive (animation?)");
+ }
+ }
+
+ // Phase 2: DOM buttons FIRST (more reliable for DOM games, language-agnostic)
+ if (!budgetExceeded()) {
+ this.log("Phase 2: trying DOM buttons...");
+ const buttonResult = await this.tryDomButtons(budgetExceeded);
+ if (buttonResult) return buttonResult;
+ }
+
+ // Phase 3: Keyboard triggers
+ if (!budgetExceeded()) {
+ this.log("Phase 3: trying keyboard triggers...");
+ const keyResult = await this.tryKeyboardTriggers(budgetExceeded);
+ if (keyResult) return keyResult;
+ }
+
+ // Phase 4: Canvas clicks
+ if (!budgetExceeded()) {
+ this.log("Phase 4: trying canvas clicks...");
+ const canvasResult = await this.tryCanvasClicks(budgetExceeded);
+ if (canvasResult) return canvasResult;
+ }
+
+ // Phase 5: Retry all
+ if (!budgetExceeded()) {
+ const r2 = await this.tryDomButtons(budgetExceeded);
+ if (r2) return r2;
+ }
+ if (!budgetExceeded()) {
+ const r3 = await this.tryKeyboardTriggers(budgetExceeded);
+ if (r3) return r3;
+ }
+ if (!budgetExceeded()) {
+ const r4 = await this.tryCanvasClicks(budgetExceeded);
+ if (r4) return r4;
+ }
+
+ return { mechanism: "unknown" };
+ }
+
+ private async detectVisualChange(
+ options?: { frames?: number; intervalMs?: number; before?: Buffer }
+ ): Promise<{ changed: boolean }> {
+ const FRAMES = options?.frames ?? 6;
+ const INTERVAL = options?.intervalMs ?? 200;
+
+ const screenshots: Buffer[] = [];
+ for (let i = 0; i < FRAMES; i++) {
+ screenshots.push(await this.page.screenshot());
+ if (i < FRAMES - 1) await this.page.waitForTimeout(INTERVAL);
+ }
+
+ let changed = false;
+
+ if (options?.before) {
+ for (const shot of screenshots) {
+ if (!options.before.equals(shot)) { changed = true; break; }
+ }
+ } else {
+ // Compare consecutive frames
+ for (let f = 0; f < screenshots.length - 1; f++) {
+ if (!screenshots[f].equals(screenshots[f + 1])) { changed = true; break; }
+ }
+ // Also check with a late frame
+ if (!changed) {
+ await this.page.waitForTimeout(1200);
+ const lateFrame = await this.page.screenshot();
+ if (!screenshots[0].equals(lateFrame)) changed = true;
+ }
+ }
+
+ return { changed };
+ }
+
+ private async verifyInteractivity(): Promise<boolean> {
+ try {
+ await this.page.waitForTimeout(200);
+
+ const baseline = await this.page.screenshot();
+ const domBefore = await this.page.evaluate(() => {
+ const candidates = document.querySelectorAll(
+ '[class*="board"], [class*="grid"], [class*="field"], [id*="board"], [id*="grid"], table'
+ );
+ let best = "";
+ for (const el of candidates) {
+ const snap = Array.from(el.children).map(c =>
+ (c as HTMLElement).className + (c as HTMLElement).style.cssText
+ ).join("|");
+ if (snap.length > best.length) best = snap;
+ }
+ if (!best) best = document.body.innerHTML.substring(0, 5000);
+ return best;
+ });
+
+ for (const key of ["ArrowLeft", "ArrowRight", "ArrowDown"]) {
+ await this.page.keyboard.press(key);
+ await this.page.waitForTimeout(100);
+
+ const after = await this.page.screenshot();
+ if (!baseline.equals(after)) return true;
+
+ const domAfter = await this.page.evaluate(() => {
+ const candidates = document.querySelectorAll(
+ '[class*="board"], [class*="grid"], [class*="field"], [id*="board"], [id*="grid"], table'
+ );
+ let best = "";
+ for (const el of candidates) {
+ const snap = Array.from(el.children).map(c =>
+ (c as HTMLElement).className + (c as HTMLElement).style.cssText
+ ).join("|");
+ if (snap.length > best.length) best = snap;
+ }
+ if (!best) best = document.body.innerHTML.substring(0, 5000);
+ return best;
+ });
+ if (domAfter !== domBefore) return true;
+ }
+ return false;
+ } catch {
+ return false;
+ }
+ }
+
+ private async tryDomButtons(
+ budgetExceeded: () => boolean
+ ): Promise<{ mechanism: StartMechanism; startButton?: DriverCalibration["startButton"] } | null> {
+ try {
+ const elementInfos = await this.page.evaluate(() => {
+ const seen = new Set<Element>();
+ const results: Array<{
+ index: number; text: string; x: number; y: number;
+ width: number; height: number; area: number; centerDist: number;
+ selector: string; hasBackground: boolean;
+ }> = [];
+
+ const clickableSelector =
+ 'button, a, [role="button"], [onclick], input[type="button"], input[type="submit"]';
+ for (const el of document.querySelectorAll(clickableSelector)) {
+ if (!seen.has(el)) seen.add(el);
+ }
+
+ const allEls = document.querySelectorAll("*");
+ for (const el of allEls) {
+ if (seen.has(el)) continue;
+ try {
+ const style = window.getComputedStyle(el);
+ if (style.cursor === "pointer") seen.add(el);
+ } catch { /* skip */ }
+ }
+
+ const pageW = window.innerWidth;
+ const pageH = window.innerHeight;
+ const pageCenterX = pageW / 2;
+ const pageCenterY = pageH / 2;
+
+ let idx = 0;
+ for (const el of seen) {
+ const rect = el.getBoundingClientRect();
+ if (rect.width < 5 || rect.height < 5) continue;
+ if (rect.top > pageH || rect.left > pageW) continue;
+ if (rect.width > pageW * 0.8 && rect.height > pageH * 0.8) continue;
+
+ const cx = rect.left + rect.width / 2;
+ const cy = rect.top + rect.height / 2;
+ const centerDist = Math.sqrt((cx - pageCenterX) ** 2 + (cy - pageCenterY) ** 2);
+
+ let hasBackground = false;
+ try {
+ const style = window.getComputedStyle(el as HTMLElement);
+ const bg = style.backgroundColor;
+ if (bg && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)") hasBackground = true;
+ } catch { /* skip */ }
+
+ let selector = "";
+ if (el.id) selector = `#${el.id}`;
+ else if ((el as HTMLElement).className) {
+ const cls = (el as HTMLElement).className.toString().split(" ")[0];
+ if (cls) selector = `${el.tagName.toLowerCase()}.${cls}`;
+ }
+ if (!selector) selector = `${el.tagName.toLowerCase()}:nth-of-type(${idx + 1})`;
+
+ results.push({
+ index: idx, text: (el.textContent || "").trim().slice(0, 50),
+ x: Math.round(cx), y: Math.round(cy),
+ width: rect.width, height: rect.height,
+ area: rect.width * rect.height, centerDist, selector, hasBackground,
+ });
+ idx++;
+ }
+
+ results.sort((a, b) => {
+ if (a.hasBackground !== b.hasBackground) return a.hasBackground ? -1 : 1;
+ if (Math.abs(b.area - a.area) > 100) return b.area - a.area;
+ return a.centerDist - b.centerDist;
+ });
+
+ return results;
+ });
+
+ for (const info of elementInfos) {
+ if (budgetExceeded()) break;
+ try {
+ const wasVisible = await this.page.evaluate(
+ ({ x, y }) => document.elementFromPoint(x, y) !== null,
+ { x: info.x, y: info.y }
+ );
+ if (!wasVisible) continue;
+
+ const before = await this.page.screenshot();
+ this.log(`Clicking "${info.text}" (${info.selector}) at (${info.x},${info.y})`);
+ await this.page.mouse.click(info.x, info.y);
+ // Wait 300ms for JS to initialize after button click
+ await this.page.waitForTimeout(300);
+
+ const result = await this.detectVisualChange({ frames: 3, intervalMs: 100, before });
+ if (result.changed) {
+ // Check for immediate game over text (false start rejection)
+ await this.page.waitForTimeout(200);
+ const gameOverText = await this.detectGameOverText();
+ if (gameOverText) {
+ this.log(`Button "${info.text}" triggered game over immediately, rejecting`);
+ continue;
+ }
+
+ const interactive = await this.verifyInteractivity();
+ if (!interactive) {
+ this.log(`Button "${info.text}" not interactive, continuing`);
+ try { await this.page.keyboard.press("Escape"); await this.page.waitForTimeout(50); } catch {}
+ continue;
+ }
+
+ const disappeared = await this.page.evaluate(
+ ({ selector }) => {
+ if (!selector) return false;
+ try {
+ const el = document.querySelector(selector);
+ if (!el) return true;
+ const rect = el.getBoundingClientRect();
+ return rect.width === 0 || rect.height === 0;
+ } catch { return false; }
+ },
+ { selector: info.selector }
+ );
+
+ return {
+ mechanism: "button",
+ startButton: {
+ selector: info.selector, text: info.text,
+ disappeared, position: { x: info.x, y: info.y },
+ },
+ };
+ }
+
+ try { await this.page.keyboard.press("Escape"); await this.page.waitForTimeout(50); } catch {}
+ } catch { /* continue */ }
+ }
+ } catch { /* phase failed */ }
+ return null;
+ }
+
+ private async tryKeyboardTriggers(
+ budgetExceeded: () => boolean
+ ): Promise<{ mechanism: StartMechanism } | null> {
+ const mechanismMap: Record<string, StartMechanism> = {
+ Enter: "enter",
+ Space: "space",
+ ArrowDown: "anykey",
+ z: "anykey",
+ };
+
+ for (const key of ["Enter", "Space", "ArrowDown", "z"]) {
+ if (budgetExceeded()) break;
+ try {
+ const before = await this.page.screenshot();
+ await this.page.keyboard.press(key);
+ await this.page.waitForTimeout(100);
+
+ const result = await this.detectVisualChange({ frames: 3, intervalMs: 100, before });
+ if (result.changed) {
+ const interactive = await this.verifyInteractivity();
+ if (interactive) return { mechanism: mechanismMap[key] };
+ }
+ } catch { /* continue */ }
+ }
+
+ // Combo: click then key
+ for (const key of ["Enter", "Space"]) {
+ if (budgetExceeded()) break;
+ try {
+ const before = await this.page.screenshot();
+ const canvas = this.page.locator("canvas").first();
+ if ((await canvas.count()) > 0) await canvas.click();
+ else {
+ const vp = this.page.viewportSize();
+ if (vp) await this.page.mouse.click(vp.width / 2, vp.height / 2);
+ }
+ await this.page.waitForTimeout(100);
+ await this.page.keyboard.press(key);
+ await this.page.waitForTimeout(100);
+
+ const result = await this.detectVisualChange({ frames: 3, intervalMs: 100, before });
+ if (result.changed) {
+ const interactive = await this.verifyInteractivity();
+ if (interactive) return { mechanism: mechanismMap[key] };
+ }
+ } catch { /* continue */ }
+ }
+
+ return null;
+ }
+
+ private async tryCanvasClicks(
+ budgetExceeded: () => boolean
+ ): Promise<{ mechanism: StartMechanism; startButton?: DriverCalibration["startButton"] } | null> {
+ let targetBox: { x: number; y: number; width: number; height: number } | null = null;
+
+ try {
+ const canvas = this.page.locator("canvas").first();
+ if ((await canvas.count()) > 0) targetBox = await canvas.boundingBox();
+ } catch { /* no canvas */ }
+
+ if (!targetBox) {
+ const vp = this.page.viewportSize();
+ if (vp) targetBox = { x: 0, y: 0, width: vp.width, height: vp.height };
+ }
+ if (!targetBox) return null;
+
+ const cx = targetBox.x + targetBox.width / 2;
+ const cy = targetBox.y + targetBox.height / 2;
+
+ const positions = [
+ { x: cx, y: cy, label: "center" },
+ { x: cx, y: targetBox.y + targetBox.height * 0.25, label: "upper" },
+ { x: cx, y: targetBox.y + targetBox.height * 0.75, label: "lower" },
+ ];
+
+ for (let row = 0; row < 3; row++) {
+ for (let col = 0; col < 3; col++) {
+ if (row === 1 && col === 1) continue;
+ positions.push({
+ x: targetBox.x + targetBox.width * (col + 0.5) / 3,
+ y: targetBox.y + targetBox.height * (row + 0.5) / 3,
+ label: `grid_${row}_${col}`,
+ });
+ }
+ }
+
+ for (const pos of positions) {
+ if (budgetExceeded()) break;
+ try {
+ const before = await this.page.screenshot();
+ await this.page.mouse.click(pos.x, pos.y);
+ await this.page.waitForTimeout(300);
+
+ const result = await this.detectVisualChange({ frames: 3, intervalMs: 100, before });
+ if (result.changed) {
+ const interactive = await this.verifyInteractivity();
+ if (interactive) {
+ return {
+ mechanism: "click_canvas",
+ startButton: {
+ selector: "canvas",
+ text: `canvas click at ${pos.label}`,
+ disappeared: false,
+ position: { x: Math.round(pos.x), y: Math.round(pos.y) },
+ },
+ };
+ }
+ }
+ } catch { /* continue */ }
+ }
+
+ return null;
+ }
+
+ private async recalibrateWithRetry(
+ currentStart: StartMechanism,
+ currentGrid: GridBounds | null
+ ): Promise<{
+ renderer: RendererType; gridBounds: GridBounds | null;
+ cellWidth: number; cellHeight: number; startMechanism: StartMechanism;
+ startButton?: DriverCalibration["startButton"];
+ }> {
+ let startMechanism: StartMechanism = currentStart;
+ let gridResult = { renderer: "unknown" as RendererType, gridBounds: currentGrid, cellWidth: 0, cellHeight: 0 };
+ let startButton: DriverCalibration["startButton"] | undefined;
+
+ const attempts: Array<{ name: StartMechanism; action: () => Promise<void> }> = [
+ { name: "button", action: async () => {
+ const btn = this.page.locator("button").first();
+ if ((await btn.count()) > 0) await btn.click();
+ }},
+ { name: "click_canvas", action: async () => {
+ const canvas = this.page.locator("canvas").first();
+ if ((await canvas.count()) > 0) await canvas.click();
+ }},
+ { name: "click_canvas", action: async () => {
+ await this.page.locator("body").click({ position: { x: 200, y: 200 } });
+ }},
+ { name: "enter", action: async () => { await this.page.keyboard.press("Enter"); } },
+ { name: "space", action: async () => { await this.page.keyboard.press("Space"); } },
+ { name: "anykey", action: async () => { await this.page.keyboard.press("ArrowDown"); } },
+ ];
+
+ for (const attempt of attempts) {
+ try {
+ const before = await this.page.screenshot();
+ await attempt.action();
+ await this.page.waitForTimeout(300);
+
+ if (startMechanism === "unknown") {
+ const result = await this.detectVisualChange({ frames: 3, intervalMs: 100, before });
+ if (result.changed) {
+ const interactive = await this.verifyInteractivity();
+ if (interactive) {
+ startMechanism = attempt.name;
+ }
+ }
+ }
+
+ if (!gridResult.gridBounds) {
+ const detected = await this.detectGrid();
+ if (detected.gridBounds) gridResult = detected;
+ }
+
+ if (startMechanism !== "unknown" && gridResult.gridBounds) break;
+ } catch { /* continue */ }
+ }
+
+ return { ...gridResult, startMechanism, startButton };
+ }
+
+ // -- Grid detection --
+
+ private async detectGrid(): Promise<{
+ renderer: RendererType; gridBounds: GridBounds | null;
+ cellWidth: number; cellHeight: number;
+ }> {
+ // Check for canvas
+ try {
+ const canvasCount = await this.page.locator("canvas").count();
+ if (canvasCount > 0) {
+ const bounds = await this.page.locator("canvas").first().boundingBox();
+ if (bounds) {
+ const canvasDims = await this.page.evaluate(() => {
+ const c = document.querySelector("canvas");
+ return c ? { width: c.width, height: c.height } : null;
+ });
+
+ const internalW = canvasDims ? canvasDims.width : bounds.width;
+ const internalH = canvasDims ? canvasDims.height : bounds.height;
+ const ratio = internalH / internalW;
+
+ let gridX = 0, gridY = 0, gridW = internalW, gridH = internalH;
+
+ if (ratio >= 1.5 && ratio <= 2.5) {
+ // Whole canvas is the grid
+ } else if (ratio < 1.5) {
+ gridW = internalH / 2;
+ gridH = internalH;
+ gridX = 0;
+ gridY = 0;
+ }
+
+ return {
+ renderer: "canvas" as RendererType,
+ gridBounds: { x: gridX, y: gridY, width: gridW, height: gridH },
+ cellWidth: gridW / 10,
+ cellHeight: gridH / 20,
+ };
+ }
+ }
+ } catch { /* continue */ }
+
+ // Check for DOM-based grid
+ try {
+ const domResult = await this.page.evaluate(() => {
+ const tables = document.querySelectorAll("table");
+ for (const table of tables) {
+ const rows = table.querySelectorAll("tr");
+ if (rows.length >= 18) {
+ const firstRow = rows[0].querySelectorAll("td");
+ if (firstRow.length >= 8 && firstRow.length <= 12) {
+ const rect = table.getBoundingClientRect();
+ return { type: "dom" as const, bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, rows: rows.length, cols: firstRow.length };
+ }
+ }
+ }
+
+ const containers = document.querySelectorAll(
+ '[class*="board"], [class*="grid"], [class*="field"], [id*="board"], [id*="grid"], [id*="field"]'
+ );
+ for (const container of containers) {
+ const children = container.children;
+ if (children.length >= 180 && children.length <= 220) {
+ const rect = container.getBoundingClientRect();
+ return { type: "dom" as const, bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, rows: Math.round(children.length / 10), cols: 10 };
+ }
+ if (children.length >= 18 && children.length <= 22) {
+ const firstRowCells = children[0].children;
+ if (firstRowCells.length >= 8 && firstRowCells.length <= 12) {
+ const rect = container.getBoundingClientRect();
+ return { type: "dom" as const, bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, rows: children.length, cols: firstRowCells.length };
+ }
+ }
+ }
+
+ // Heuristic scan
+ const allElements = document.querySelectorAll("div, section, main, article");
+ for (const el of allElements) {
+ const ch = el.children;
+ if (ch.length >= 180 && ch.length <= 220) {
+ const firstChild = ch[0] as HTMLElement;
+ if (!firstChild) continue;
+ const firstRect = firstChild.getBoundingClientRect();
+ if (firstRect.width < 5 || firstRect.height < 5) continue;
+ let uniform = true;
+ for (let i = 1; i < Math.min(10, ch.length); i++) {
+ const r = (ch[i] as HTMLElement).getBoundingClientRect();
+ if (Math.abs(r.width - firstRect.width) > 2 || Math.abs(r.height - firstRect.height) > 2) { uniform = false; break; }
+ }
+ if (uniform) {
+ const rect = el.getBoundingClientRect();
+ return { type: "dom" as const, bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, rows: Math.round(ch.length / 10), cols: 10 };
+ }
+ }
+ if (ch.length >= 18 && ch.length <= 22) {
+ const firstRowCells = ch[0]?.children;
+ if (firstRowCells && firstRowCells.length >= 8 && firstRowCells.length <= 12) {
+ const rect = el.getBoundingClientRect();
+ if (rect.width > 50 && rect.height > 100) {
+ return { type: "dom" as const, bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, rows: ch.length, cols: firstRowCells.length };
+ }
+ }
+ }
+ }
+
+ return null;
+ });
+
+ if (domResult) {
+ return {
+ renderer: "dom",
+ gridBounds: domResult.bounds,
+ cellWidth: domResult.bounds.width / domResult.cols,
+ cellHeight: domResult.bounds.height / domResult.rows,
+ };
+ }
+ } catch { /* continue */ }
+
+ // Check for SVG
+ try {
+ const svgCount = await this.page.locator("svg").count();
+ if (svgCount > 0) {
+ const bounds = await this.page.locator("svg").first().boundingBox();
+ if (bounds) {
+ return {
+ renderer: "svg",
+ gridBounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
+ cellWidth: bounds.width / 10,
+ cellHeight: bounds.height / 20,
+ };
+ }
+ }
+ } catch { /* continue */ }
+
+ return { renderer: "unknown", gridBounds: null, cellWidth: 0, cellHeight: 0 };
+ }
+
+ // -- Control detection --
+
+ private async detectControls(): Promise<Controls> {
+ const controls: Controls = { ...DEFAULT_CONTROLS };
+
+ try {
+ const pageText = await this.page.evaluate(() => document.body.innerText.toLowerCase());
+ if (pageText.includes("wasd") || pageText.includes("w,a,s,d")) {
+ controls.left = "a"; controls.right = "d"; controls.down = "s"; controls.rotate = "w";
+ }
+ if (/z\s*(=|:)?\s*rotate/i.test(pageText)) controls.rotate = "z";
+ if (/x\s*(=|:)?\s*rotate/i.test(pageText)) controls.rotate = "x";
+ } catch { /* use defaults */ }
+
+ try {
+ const before = await this.page.screenshot();
+ await this.page.keyboard.press(controls.left);
+ await this.page.waitForTimeout(200);
+ const after = await this.page.screenshot();
+ if (Buffer.from(before).equals(Buffer.from(after))) {
+ await this.page.keyboard.press("a");
+ await this.page.waitForTimeout(200);
+ const afterA = await this.page.screenshot();
+ if (!Buffer.from(before).equals(Buffer.from(afterA))) {
+ controls.left = "a"; controls.right = "d"; controls.down = "s"; controls.rotate = "w";
+ }
+ }
+ } catch { /* use defaults */ }
+
+ try {
+ const before = await this.page.screenshot();
+ await this.page.keyboard.press(controls.rotate);
+ await this.page.waitForTimeout(200);
+ const after = await this.page.screenshot();
+ if (Buffer.from(before).equals(Buffer.from(after))) {
+ for (const alt of ["z", "x", "ArrowUp"]) {
+ if (alt === controls.rotate) continue;
+ await this.page.keyboard.press(alt);
+ await this.page.waitForTimeout(200);
+ const afterAlt = await this.page.screenshot();
+ if (!Buffer.from(before).equals(Buffer.from(afterAlt))) {
+ controls.rotate = alt;
+ break;
+ }
+ }
+ }
+ } catch { /* use defaults */ }
+
+ return controls;
+ }
+
+ // -- Score/Level element detection --
+
+ private async detectScoreElement(): Promise<string | null> {
+ try {
+ return await this.page.evaluate(() => {
+ function _buildSelector(el: Element): string | null {
+ if (el.id) return `#${el.id}`;
+ if ((el as HTMLElement).className) {
+ const cls = (el as HTMLElement).className.split(" ")[0];
+ if (cls) return `.${cls}`;
+ }
+ return null;
+ }
+
+ const allElements = document.querySelectorAll("*");
+
+ // Strategy 1: child near "score"/"puntuacion"/"puntaje" text with only a number
+ for (const el of allElements) {
+ const text = ((el as HTMLElement).innerText || "").toLowerCase();
+ if ((text.includes("score") || text.includes("puntuacion") || text.includes("puntaje") || text.includes("puntos")) && el.children.length < 10) {
+ const descendants = el.querySelectorAll("span, div, p, td, strong, em, b");
+ for (const desc of descendants) {
+ const descText = desc.textContent?.trim() || "";
+ if (/^\d+$/.test(descText) && desc.children.length === 0) {
+ const sel = _buildSelector(desc);
+ if (sel) return sel;
+ }
+ }
+ const next = el.nextElementSibling;
+ if (next) {
+ const nextText = next.textContent?.trim() || "";
+ if (/^\d+$/.test(nextText)) {
+ const sel = _buildSelector(next);
+ if (sel) return sel;
+ }
+ }
+ const sel = _buildSelector(el);
+ if (sel) return sel;
+ }
+ }
+
+ // Strategy 2: labeled text like "Score: 123"
+ for (const el of allElements) {
+ if (el.children.length > 3) continue;
+ const text = (el as HTMLElement).textContent?.trim() || "";
+ const scoreMatch = text.match(/(?:score|puntuacion|puntaje|puntos)\s*[:\-=]?\s*(\d+)/i);
+ if (scoreMatch) {
+ const sel = _buildSelector(el);
+ if (sel) return sel;
+ }
+ }
+
+ // Strategy 3: leaf elements with just a number
+ const candidates: HTMLElement[] = [];
+ for (const el of allElements) {
+ const text = (el as HTMLElement).textContent?.trim() || "";
+ if (/^\d+$/.test(text) && el.children.length === 0) candidates.push(el as HTMLElement);
+ }
+ if (candidates.length > 0) {
+ const sel = _buildSelector(candidates[0]);
+ if (sel) return sel;
+ }
+
+ return null;
+ });
+ } catch {
+ return null;
+ }
+ }
+
+ private async detectLevelElement(): Promise<string | null> {
+ try {
+ return await this.page.evaluate(() => {
+ function _buildSelector(el: Element): string | null {
+ if (el.id) return `#${el.id}`;
+ if ((el as HTMLElement).className) {
+ const cls = (el as HTMLElement).className.split(" ")[0];
+ if (cls) return `.${cls}`;
+ }
+ return null;
+ }
+
+ const allElements = document.querySelectorAll("*");
+ for (const el of allElements) {
+ const text = ((el as HTMLElement).innerText || "").toLowerCase();
+ if ((text.includes("level") || text.includes("nivel")) && el.children.length < 5) {
+ const sel = _buildSelector(el);
+ if (sel) return sel;
+ }
+ }
+ return null;
+ });
+ } catch {
+ return null;
+ }
+ }
+
+ // -- Grid confidence --
+
+ private async measureGridConfidence(cal: DriverCalibration): Promise<number> {
+ if (!cal.gridBounds) return 0;
+
+ let successes = 0;
+ let attempts = 0;
+ const pollCount = 6;
+ let lastGrid: Grid | null = null;
+ let gridChanged = false;
+
+ for (let i = 0; i < pollCount; i++) {
+ attempts++;
+ try {
+ const snap = await this.readGrid();
+ if (snap.grid) {
+ successes++;
+ if (lastGrid) {
+ if (this.gridsAreDifferent(snap.grid, lastGrid)) gridChanged = true;
+ }
+ lastGrid = snap.grid;
+ }
+ } catch { /* read failed */ }
+ await this.page.waitForTimeout(500);
+ }
+
+ if (successes > 0 && !gridChanged && cal.startMechanism !== "unknown") {
+ const additionalStarts: Array<{ name: string; action: () => Promise<void> }> = [
+ { name: "space", action: async () => { await this.page.keyboard.press("Space"); } },
+ { name: "enter", action: async () => { await this.page.keyboard.press("Enter"); } },
+ { name: "click", action: async () => {
+ const canvas = this.page.locator("canvas").first();
+ if ((await canvas.count()) > 0) await canvas.click();
+ else await this.page.locator("body").click({ position: { x: 200, y: 200 } });
+ }},
+ ];
+
+ for (const start of additionalStarts) {
+ try {
+ await start.action();
+ await this.page.waitForTimeout(1500);
+ const snap = await this.readGrid();
+ if (snap.grid && lastGrid) {
+ if (this.gridsAreDifferent(snap.grid, lastGrid)) { gridChanged = true; break; }
+ lastGrid = snap.grid;
+ }
+ } catch { /* continue */ }
+ }
+ }
+
+ return attempts > 0 ? successes / attempts : 0;
+ }
+
+ // -- Helpers --
+
+ private extractScoreFromText(text: string | null): number[] {
+ if (!text) return [0];
+ const labeledMatch = text.match(/(?:score|puntuacion|puntaje|puntos)\s*[:\-=]?\s*(\d+)/i);
+ if (labeledMatch) return [parseInt(labeledMatch[1], 10)];
+ const allNumbers = (text.match(/\d+/g) || []).map(Number);
+ return allNumbers.length > 0 ? allNumbers : [0];
+ }
+}
diff --git a/tasks/tetris/eval/gameplay-bot-v2/index.ts b/tasks/tetris/eval/gameplay-bot-v2/index.ts
@@ -0,0 +1,238 @@
+import { test } from "@playwright/test";
+import { execSync, spawn, type ChildProcess } from "node:child_process";
+import * as fs from "node:fs";
+import * as path from "node:path";
+import * as net from "node:net";
+import type { BotReport } from "./types";
+import { PlaywrightDriver } from "./driver";
+import { runAllTests } from "./bot";
+
+/**
+ * Find an available port by briefly binding to port 0.
+ */
+async function findFreePort(): Promise<number> {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.listen(0, () => {
+ const addr = server.address();
+ if (addr && typeof addr === "object") {
+ const port = addr.port;
+ server.close(() => resolve(port));
+ } else {
+ server.close(() => reject(new Error("could not determine port")));
+ }
+ });
+ server.on("error", reject);
+ });
+}
+
+/**
+ * Start a simple HTTP server to serve workspace files.
+ */
+async function startServer(workspacePath: string, port: number): Promise<ChildProcess> {
+ let serverProc: ChildProcess;
+
+ try {
+ execSync("npx serve --version", { stdio: "ignore", timeout: 5000 });
+ serverProc = spawn("npx", ["serve", "-l", String(port), "-s", "--no-clipboard"], {
+ cwd: workspacePath,
+ stdio: "ignore",
+ });
+ } catch {
+ serverProc = spawn("python3", ["-m", "http.server", String(port)], {
+ cwd: workspacePath,
+ stdio: "ignore",
+ });
+ }
+
+ const maxWait = 10000;
+ const start = Date.now();
+ while (Date.now() - start < maxWait) {
+ try {
+ await new Promise<void>((resolve, reject) => {
+ const socket = net.createConnection({ port, host: "127.0.0.1" }, () => {
+ socket.destroy();
+ resolve();
+ });
+ socket.on("error", reject);
+ socket.setTimeout(500, () => {
+ socket.destroy();
+ reject(new Error("timeout"));
+ });
+ });
+ return serverProc;
+ } catch {
+ await new Promise((r) => setTimeout(r, 200));
+ }
+ }
+
+ throw new Error(`server did not start on port ${port} within ${maxWait}ms`);
+}
+
+test.describe("Tetris Gameplay Bot v2", () => {
+ let serverProc: ChildProcess | null = null;
+ let serverUrl: string;
+
+ test.beforeAll(async () => {
+ const workspacePath =
+ process.env.WORKSPACE_PATH || process.env.TETRIS_WORKSPACE || process.cwd();
+ const port = await findFreePort();
+ serverProc = await startServer(workspacePath, port);
+ serverUrl = `http://127.0.0.1:${port}`;
+ });
+
+ test.afterAll(async () => {
+ if (serverProc) {
+ serverProc.kill("SIGTERM");
+ serverProc = null;
+ }
+ });
+
+ test("run gameplay bot", async ({ page }) => {
+ test.setTimeout(300_000); // 5-minute total timeout
+
+ // Measure page load time
+ let loadTimeMs = -1;
+ try {
+ const loadStart = Date.now();
+ await page.goto(serverUrl, { waitUntil: "load", timeout: 10000 });
+ loadTimeMs = Date.now() - loadStart;
+ await page.goto("about:blank");
+ } catch {
+ // Load time measurement failed, not critical
+ }
+
+ // Create the Driver (which gets the Playwright Page)
+ const driver = new PlaywrightDriver(page);
+
+ // Create the Bot (which gets the Driver) and run everything
+ const { testResults, calibration, gameplay, session, survey, competitivePlay } =
+ await runAllTests(driver, serverUrl);
+
+ // Accessibility check
+ let a11yIssues: string[] = [];
+ try {
+ await page.goto(serverUrl, { timeout: 10000 });
+ await page.waitForTimeout(1000);
+ a11yIssues = await page.evaluate(() => {
+ const issues: string[] = [];
+ if (!document.title || document.title.trim() === "") issues.push("missing page title");
+ if (document.querySelectorAll("h1, h2, h3, [role='heading']").length === 0) issues.push("no headings found");
+ document.querySelectorAll("img").forEach((img) => {
+ if (!img.alt && !img.getAttribute("aria-label")) issues.push("image without alt text");
+ });
+ document.querySelectorAll("canvas").forEach((canvas) => {
+ if (!canvas.getAttribute("aria-label") && !canvas.getAttribute("role")) issues.push("canvas without aria-label or role");
+ });
+ const focusable = document.querySelectorAll("button, a, input, [tabindex]");
+ if (focusable.length === 0 && document.querySelectorAll("canvas").length === 0) issues.push("no focusable elements");
+ const body = window.getComputedStyle(document.body);
+ if (body.backgroundColor === body.color) issues.push("text color matches background color");
+ return issues;
+ });
+ } catch {
+ // a11y check failed, not critical
+ }
+
+ const passed = testResults.filter((t) => t.pass).length;
+ const skipped = testResults.filter((t) => t.detail.startsWith("skipped:")).length;
+ const failed = testResults.filter((t) => !t.pass && !t.detail.startsWith("skipped:")).length;
+ const total = testResults.length;
+ const scorable = total - skipped;
+
+ const totalReads = session.gridReadSuccess + session.gridReadFail;
+ const gridSuccessRate = totalReads > 0 ? session.gridReadSuccess / totalReads : 0;
+
+ // Clean competitive play result
+ let cleanCompetitivePlay = competitivePlay;
+ if (cleanCompetitivePlay) {
+ const { _ccwResult, _ccwTestDone, _softDropDistinct, _softDropTestDone, ...clean } =
+ cleanCompetitivePlay as any;
+ cleanCompetitivePlay = clean;
+ }
+
+ const report: BotReport = {
+ implementation: {
+ renderer: calibration.renderer,
+ grid_detected: calibration.gridDetected,
+ grid_detected_at: calibration.gridDetectedAt || "initial",
+ grid_bounds: calibration.gridBounds,
+ controls: calibration.controls as unknown as Record<string, string>,
+ start_mechanism: calibration.startMechanism,
+ score_element_found: calibration.scoreElementSelector !== null,
+ grid_confidence: calibration.gridConfidence,
+ survey,
+ },
+ tests: testResults.map((t) => ({ name: t.name, pass: t.pass, detail: t.detail })),
+ summary: {
+ total,
+ passed,
+ failed,
+ skipped,
+ score: scorable > 0 ? Math.round((passed / scorable) * 100) / 100 : 0,
+ },
+ gameplay,
+ competitive_play: cleanCompetitivePlay,
+ session: {
+ frames: session.frames,
+ events_count: session.events.length,
+ pieces_spawned: session.piecesSpawned,
+ pieces_locked: session.piecesLocked,
+ lines_cleared: session.linesCleared,
+ piece_types_seen: [...session.pieceTypes],
+ grid_read_success_rate: Math.round(gridSuccessRate * 100) / 100,
+ },
+ performance: {
+ load_time_ms: loadTimeMs,
+ },
+ accessibility: {
+ issues: a11yIssues,
+ issue_count: a11yIssues.length,
+ pass: a11yIssues.length === 0,
+ },
+ };
+
+ // Write report to file
+ const reportPath =
+ process.env.REPORT_OUTPUT_PATH ||
+ path.join(process.cwd(), "gameplay-bot-report.json");
+
+ const reportDir = path.dirname(reportPath);
+ if (!fs.existsSync(reportDir)) {
+ fs.mkdirSync(reportDir, { recursive: true });
+ }
+
+ fs.writeFileSync(reportPath, JSON.stringify(report, null, 2), "utf-8");
+
+ // Log summary
+ console.log("\n=== Gameplay Bot v2 Report ===");
+ console.log(`Renderer: ${calibration.renderer}`);
+ console.log(`Grid detected: ${calibration.gridDetected} (at: ${calibration.gridDetectedAt})`);
+ console.log(`Grid confidence: ${Math.round(calibration.gridConfidence * 100)}%`);
+ console.log(`Grid read success rate: ${Math.round(gridSuccessRate * 100)}%`);
+ console.log(`Start mechanism: ${calibration.startMechanism}`);
+ console.log(`Score element: ${calibration.scoreElementSelector ?? "none"}`);
+ console.log(`\nTests: ${passed}/${total} passed, ${skipped} skipped, ${failed} failed`);
+ console.log(`Score: ${report.summary.score} (${passed}/${scorable} scorable)`);
+ for (const t of testResults) {
+ const status = t.detail.startsWith("skipped:") ? "SKIP" : t.pass ? "PASS" : "FAIL";
+ console.log(` ${status} ${t.name}: ${t.detail}`);
+ }
+ console.log(`\nSession: ${session.frames} frames, ${session.events.length} events`);
+ console.log(` Pieces spawned: ${session.piecesSpawned}, locked: ${session.piecesLocked}`);
+ console.log(` Lines cleared: ${session.linesCleared}`);
+ console.log(` Piece types: [${[...session.pieceTypes].join(", ")}]`);
+ console.log(`\nGameplay: ${gameplay.pieces_placed} pieces, ${gameplay.lines_cleared} lines`);
+ if (competitivePlay) {
+ console.log(`\nCompetitive play: ${competitivePlay.pieces_placed} pieces, ${competitivePlay.total_lines_cleared} lines`);
+ console.log(` Clears: ${competitivePlay.single_clears}x single, ${competitivePlay.double_clears}x double, ${competitivePlay.triple_clears}x triple, ${competitivePlay.tetris_clears}x tetris`);
+ console.log(` Score: ${competitivePlay.score_final}, Level: ${competitivePlay.level_final}`);
+ if (competitivePlay.bugs_detected.length > 0) {
+ console.log(` Bugs: [${competitivePlay.bugs_detected.join(", ")}]`);
+ }
+ }
+ console.log(`\nSurvey: canvas=${survey.has_canvas}, dom_grid=${survey.has_dom_grid}, overlay=${survey.has_overlay}, clickable=${survey.clickable_elements}`);
+ console.log(`Report written to: ${reportPath}`);
+ console.log("==============================\n");
+ });
+});
diff --git a/tasks/tetris/eval/gameplay-bot-v2/playwright.config.ts b/tasks/tetris/eval/gameplay-bot-v2/playwright.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ testDir: ".",
+ testMatch: "index.ts",
+ timeout: 60_000,
+ retries: 0,
+ workers: 1,
+ reporter: [["list"]],
+ use: {
+ headless: true,
+ viewport: { width: 1280, height: 720 },
+ actionTimeout: 10_000,
+ navigationTimeout: 10_000,
+ },
+});
diff --git a/tasks/tetris/eval/gameplay-bot-v2/types.ts b/tasks/tetris/eval/gameplay-bot-v2/types.ts
@@ -0,0 +1,233 @@
+/** A 10x20 boolean grid: true = filled cell, false = empty. Row 0 is the top. */
+export type Grid = boolean[][];
+
+/** Pixel bounds of the game grid on the page. */
+export interface GridBounds {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+/** How the game renders its grid. */
+export type RendererType = "canvas" | "dom" | "svg" | "unknown";
+
+/** Key mappings for game controls. */
+export interface Controls {
+ left: string;
+ right: string;
+ down: string;
+ rotate: string;
+ drop: string;
+}
+
+/** How the game was started. */
+export type StartMechanism =
+ | "auto"
+ | "click_canvas"
+ | "enter"
+ | "space"
+ | "button"
+ | "anykey"
+ | "unknown";
+
+/** Standard Tetris piece types. */
+export type PieceType = "I" | "O" | "T" | "S" | "Z" | "J" | "L" | "unknown";
+
+/** Pre-test survey data collected before any tests run. */
+export interface SurveyData {
+ has_overlay: boolean;
+ has_canvas: boolean;
+ has_dom_grid: boolean;
+ visible_text: string[];
+ clickable_elements: number;
+}
+
+/** Configuration returned by calibration. */
+export interface DriverCalibration {
+ renderer: RendererType;
+ gridDetected: boolean;
+ gridBounds: GridBounds | null;
+ cellWidth: number;
+ cellHeight: number;
+ controls: Controls;
+ startMechanism: StartMechanism;
+ scoreElementSelector: string | null;
+ levelElementSelector: string | null;
+ backgroundColor: [number, number, number] | null;
+ consoleErrors: string[];
+ gridConfidence: number;
+ gridDetectedAt: "initial" | "after_start";
+ startButton?: {
+ selector: string;
+ text: string;
+ disappeared: boolean;
+ position: { x: number; y: number };
+ };
+}
+
+/** Grid snapshot: the grid state plus derived information the bot needs. */
+export interface GridSnapshot {
+ /** The 10x20 boolean grid. null if reading failed. */
+ grid: Grid | null;
+ /** Total filled cells. 0 if grid is null. */
+ filledCount: number;
+ /** Filled cells in the bottom N rows. */
+ filledInBottom(rows: number): number;
+ /** Whether any cell in the top N rows is filled. */
+ hasFilledInTop(rows: number): boolean;
+ /** Number of fully complete rows. */
+ completeRows: number;
+ /** Active piece cells (diff against settled grid). null if undetectable. */
+ activePieceCells: [number, number][] | null;
+ /** Identified piece type from active piece cells. null if no active piece. */
+ activePieceType: PieceType | null;
+}
+
+/** The Driver interface. This is what the Bot sees. */
+export interface TetrisDriver {
+ // -- Lifecycle --
+ loadPage(url: string): Promise<{ loaded: boolean; detail: string; errorsOnLoad: number }>;
+ surveyPage(): Promise<SurveyData>;
+ calibrate(): Promise<DriverCalibration>;
+ recalibrate(): Promise<DriverCalibration>;
+ getCalibration(): DriverCalibration;
+
+ // -- Grid Reading --
+ readGrid(settledGrid?: Grid | null): Promise<GridSnapshot>;
+ gridsAreDifferent(a: Grid | null, b: Grid | null): boolean;
+
+ // -- Input --
+ pressKey(action: "left" | "right" | "down" | "rotate" | "drop"): Promise<void>;
+ pressRawKey(key: string): Promise<void>;
+ wait(ms: number): Promise<void>;
+
+ // -- Score/Level/Lines Reading --
+ readScore(): Promise<number | null>;
+ readLevel(): Promise<number | null>;
+
+ // -- Page State Queries --
+ detectGameOverText(): Promise<string | null>;
+ detectRestartOption(): Promise<boolean>;
+ detectNextPiecePreview(): Promise<boolean>;
+ getConsoleErrors(): string[];
+
+ // -- Screenshots --
+ screenshot(): Promise<Buffer>;
+ measureDropInterval(): Promise<number>;
+}
+
+/** Competitive play results (Phase 8). */
+export interface CompetitivePlayResult {
+ duration_seconds: number;
+ pieces_placed: number;
+ total_lines_cleared: number;
+ single_clears: number;
+ double_clears: number;
+ triple_clears: number;
+ tetris_clears: number;
+ max_combo: number;
+ score_readings: number[];
+ score_final: number;
+ score_increases: number[];
+ level_readings: number[];
+ level_final: number;
+ game_over_reached: boolean;
+ game_over_text_found: string | null;
+ restart_available: boolean;
+ next_piece_visible: boolean;
+ speed_increased: boolean;
+ bugs_detected: string[];
+ rendering_trail_detected?: boolean;
+}
+
+/** Result of an individual test. */
+export interface TestResult {
+ name: string;
+ pass: boolean;
+ detail: string;
+}
+
+/** Data collected during one continuous observation session. */
+export interface GameSession {
+ started: boolean;
+ startMechanism: string;
+ piecesSpawned: number;
+ piecesLocked: number;
+ linesCleared: number;
+ rotationsObserved: number;
+ movementsObserved: number;
+ hardDropsObserved: number;
+ gameOverDetected: boolean;
+ consoleErrors: string[];
+ durationSeconds: number;
+ pieceTypes: Set<string>;
+ scoreValues: number[];
+ gridReadSuccess: number;
+ gridReadFail: number;
+ frames: number;
+ events: GridEvent[];
+ skippedPhases: string[];
+}
+
+/** An event observed during continuous grid scanning. */
+export type GridEvent =
+ | { type: "piece_spawned"; pieceType: PieceType; frame: number }
+ | { type: "piece_locked"; frame: number; filledDelta: number }
+ | { type: "line_cleared"; count: number; frame: number }
+ | { type: "piece_moved"; direction: "left" | "right" | "down"; frame: number }
+ | { type: "piece_rotated"; frame: number }
+ | { type: "hard_drop"; frame: number }
+ | { type: "game_over"; frame: number }
+ | { type: "grid_read_failed"; frame: number };
+
+/** Gameplay statistics gathered during the play phase. */
+export interface GameplayStats {
+ pieces_placed: number;
+ lines_cleared: number;
+ max_score_observed: number;
+ play_duration_seconds: number;
+ errors_during_play: number;
+}
+
+/** The full JSON report written at the end. */
+export interface BotReport {
+ implementation: {
+ renderer: string;
+ grid_detected: boolean;
+ grid_detected_at: string;
+ grid_bounds: GridBounds | null;
+ controls: Record<string, string>;
+ start_mechanism: string;
+ score_element_found: boolean;
+ grid_confidence: number;
+ survey: SurveyData;
+ };
+ tests: Array<{ name: string; pass: boolean; detail: string }>;
+ summary: {
+ total: number;
+ passed: number;
+ failed: number;
+ skipped: number;
+ score: number;
+ };
+ gameplay: GameplayStats;
+ competitive_play: CompetitivePlayResult | null;
+ session: {
+ frames: number;
+ events_count: number;
+ pieces_spawned: number;
+ pieces_locked: number;
+ lines_cleared: number;
+ piece_types_seen: string[];
+ grid_read_success_rate: number;
+ };
+ performance?: {
+ load_time_ms: number;
+ };
+ accessibility?: {
+ issues: string[];
+ issue_count: number;
+ pass: boolean;
+ };
+}