loop-benchmarking

Controlled experiments across agentic coding configurations. Same task, one variable, what actually works.
git clone https://git.shiptheloop.com/loop-benchmarking.git
Log | Files | Refs | README

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:
Atasks/tetris/eval/gameplay-bot-v2/bot.ts | 1690+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atasks/tetris/eval/gameplay-bot-v2/driver.ts | 1710+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atasks/tetris/eval/gameplay-bot-v2/index.ts | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atasks/tetris/eval/gameplay-bot-v2/playwright.config.ts | 16++++++++++++++++
Atasks/tetris/eval/gameplay-bot-v2/types.ts | 233+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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; + }; +}

Impressum · Datenschutz