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

grid-reader.ts (20677B)


      1 // Screen reading approach adapted from mikhail-vlasenko/Tetris-AI (MIT License)
      2 // Cell sampling uses center + offset checks for robustness
      3 
      4 import type { Page } from "@playwright/test";
      5 import type { Grid, GridBounds, CalibrationResult, PieceType } from "./types";
      6 
      7 const GRID_ROWS = 20;
      8 const GRID_COLS = 10;
      9 
     10 /**
     11  * Read the game grid state. Dispatches to canvas or DOM reader
     12  * based on calibration results. Returns a 10x20 boolean matrix,
     13  * or null if reading fails.
     14  */
     15 export async function readGrid(
     16   page: Page,
     17   cal: CalibrationResult
     18 ): Promise<Grid | null> {
     19   try {
     20     if (cal.renderer === "canvas" && cal.gridBounds) {
     21       return await readCanvasGrid(page, cal.gridBounds, cal.cellWidth, cal.cellHeight, cal.backgroundColor);
     22     }
     23     if (cal.renderer === "dom") {
     24       return await readDomGrid(page);
     25     }
     26     // Fallback: try canvas anyway if bounds exist
     27     if (cal.gridBounds) {
     28       return await readCanvasGrid(page, cal.gridBounds, cal.cellWidth, cal.cellHeight, cal.backgroundColor);
     29     }
     30     // Last resort: try DOM reader even if renderer is unknown
     31     // (the grid may have appeared after calibration)
     32     const domGrid = await readDomGrid(page);
     33     if (domGrid) return domGrid;
     34     return null;
     35   } catch {
     36     return null;
     37   }
     38 }
     39 
     40 /**
     41  * Read grid from a canvas element using getImageData.
     42  * Samples the center pixel of each cell and compares to the background color.
     43  * Uses multi-point sampling (center + offsets) for robustness, adapted from
     44  * mikhail-vlasenko/Tetris-AI's approach of checking multiple points per cell.
     45  */
     46 async function readCanvasGrid(
     47   page: Page,
     48   bounds: GridBounds,
     49   cellW: number,
     50   cellH: number,
     51   bgColor: [number, number, number] | null
     52 ): Promise<Grid | null> {
     53   const bgR = bgColor ? bgColor[0] : 0;
     54   const bgG = bgColor ? bgColor[1] : 0;
     55   const bgB = bgColor ? bgColor[2] : 0;
     56   const threshold = 50; // color distance threshold
     57 
     58   const grid = await page.evaluate(
     59     ({ x, y, cellW, cellH, rows, cols, bgR, bgG, bgB, threshold }) => {
     60       const canvas = document.querySelector("canvas") as HTMLCanvasElement | null;
     61       if (!canvas) return null;
     62       const ctx = canvas.getContext("2d");
     63       if (!ctx) return null;
     64 
     65       // Offsets to sample within each cell: center + 4 points at 1/3 offsets
     66       // This catches pieces even when the center is on a border or gap
     67       const offsets = [
     68         [0, 0],
     69         [-Math.floor(cellW / 4), 0],
     70         [Math.floor(cellW / 4), 0],
     71         [0, -Math.floor(cellH / 4)],
     72         [0, Math.floor(cellH / 4)],
     73       ];
     74 
     75       const result: boolean[][] = [];
     76       for (let row = 0; row < rows; row++) {
     77         const rowData: boolean[] = [];
     78         for (let col = 0; col < cols; col++) {
     79           const cx = Math.floor(x + col * cellW + cellW / 2);
     80           const cy = Math.floor(y + row * cellH + cellH / 2);
     81 
     82           let filledCount = 0;
     83           for (const [ox, oy] of offsets) {
     84             const px = Math.min(Math.max(cx + ox, 0), canvas.width - 1);
     85             const py = Math.min(Math.max(cy + oy, 0), canvas.height - 1);
     86             const pixel = ctx.getImageData(px, py, 1, 1).data;
     87             const dr = pixel[0] - bgR;
     88             const dg = pixel[1] - bgG;
     89             const db = pixel[2] - bgB;
     90             const dist = Math.sqrt(dr * dr + dg * dg + db * db);
     91             if (dist > threshold) filledCount++;
     92           }
     93           // Cell is filled if majority of sample points say so
     94           rowData.push(filledCount >= 3);
     95         }
     96         result.push(rowData);
     97       }
     98       return result;
     99     },
    100     { x: bounds.x, y: bounds.y, cellW, cellH, rows: GRID_ROWS, cols: GRID_COLS, bgR, bgG, bgB, threshold }
    101   );
    102 
    103   // Validate: a freshly-read grid should make sense
    104   if (grid) {
    105     const totalCells = GRID_ROWS * GRID_COLS;
    106     const filledCells = grid.reduce((sum, row) => sum + row.filter(Boolean).length, 0);
    107     const filledPct = filledCells / totalCells;
    108 
    109     // If >60% of the grid is "filled" at any point, we're probably reading
    110     // off-grid (UI chrome, borders, decorations). A real Tetris grid rarely
    111     // exceeds 50% filled even in a losing game.
    112     if (filledPct > 0.60) {
    113       return null;
    114     }
    115   }
    116 
    117   return grid;
    118 }
    119 
    120 /**
    121  * Validate that calibrated grid bounds look like a real Tetris grid.
    122  * Returns true if the bounds are plausible.
    123  */
    124 export function validateGridBounds(bounds: GridBounds | null): boolean {
    125   if (!bounds) return false;
    126 
    127   // Aspect ratio should be roughly 1:2 (width:height) for a 10x20 grid
    128   const ratio = bounds.height / bounds.width;
    129   if (ratio < 1.3 || ratio > 2.8) return false;
    130 
    131   // Grid should be a reasonable size (not tiny or the entire viewport)
    132   if (bounds.width < 50 || bounds.height < 100) return false;
    133   if (bounds.width > 1000 || bounds.height > 1500) return false;
    134 
    135   return true;
    136 }
    137 
    138 /**
    139  * Read grid from DOM elements. Looks for a grid-like structure and checks
    140  * background colors or class names to determine filled vs empty cells.
    141  */
    142 async function readDomGrid(page: Page): Promise<Grid | null> {
    143   const grid = await page.evaluate(({ rows, cols }) => {
    144     // Strategy 1: look for a table-based grid
    145     const tables = document.querySelectorAll("table");
    146     for (const table of tables) {
    147       const trs = table.querySelectorAll("tr");
    148       if (trs.length >= rows) {
    149         const result: boolean[][] = [];
    150         for (let r = 0; r < rows; r++) {
    151           const tds = trs[r].querySelectorAll("td");
    152           const rowData: boolean[] = [];
    153           for (let c = 0; c < cols; c++) {
    154             if (c < tds.length) {
    155               const td = tds[c] as HTMLElement;
    156               const style = window.getComputedStyle(td);
    157               const bg = style.backgroundColor;
    158               const cls = td.className.toLowerCase();
    159               // Filled if it has a non-default background or a class suggesting a piece
    160               const isFilled =
    161                 (bg !== "" && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent" && bg !== "rgb(0, 0, 0)") ||
    162                 cls.includes("filled") ||
    163                 cls.includes("active") ||
    164                 cls.includes("block") ||
    165                 cls.includes("piece") ||
    166                 td.dataset.filled === "true";
    167               rowData.push(isFilled);
    168             } else {
    169               rowData.push(false);
    170             }
    171           }
    172           result.push(rowData);
    173         }
    174         return result;
    175       }
    176     }
    177 
    178     // Helper: determine if a cell element is "filled" by checking its
    179     // background color, class names, and data attributes. Also accepts
    180     // an optional "empty" reference color so we can distinguish filled
    181     // cells in games that use a non-standard background (e.g. dark gray
    182     // for empty cells instead of transparent/black).
    183     function isCellFilled(cell: HTMLElement, emptyBg?: string): boolean {
    184       const style = window.getComputedStyle(cell);
    185       const bg = style.backgroundColor;
    186       const cls = cell.className.toLowerCase();
    187 
    188       // Class/data attribute hints always win
    189       if (
    190         cls.includes("filled") ||
    191         cls.includes("active") ||
    192         cls.includes("block") ||
    193         cls.includes("piece") ||
    194         cls.includes("occupied") ||
    195         cls.includes("locked") ||
    196         cell.dataset.filled === "true" ||
    197         cell.dataset.type !== undefined
    198       ) {
    199         return true;
    200       }
    201 
    202       // If we have a known empty background, compare against it
    203       if (emptyBg && bg === emptyBg) return false;
    204 
    205       // Default: non-transparent, non-black background = filled
    206       return (
    207         bg !== "" &&
    208         bg !== "rgba(0, 0, 0, 0)" &&
    209         bg !== "transparent" &&
    210         bg !== "rgb(0, 0, 0)"
    211       );
    212     }
    213 
    214     // Determine the "empty cell" background by sampling a few cells
    215     // and picking the most common background color
    216     function detectEmptyBg(cells: HTMLElement[]): string | undefined {
    217       const colorCounts = new Map<string, number>();
    218       for (const cell of cells) {
    219         const bg = window.getComputedStyle(cell).backgroundColor;
    220         colorCounts.set(bg, (colorCounts.get(bg) || 0) + 1);
    221       }
    222       // The most common color is likely the empty cell color
    223       let maxCount = 0;
    224       let emptyBg: string | undefined;
    225       for (const [color, count] of colorCounts) {
    226         if (count > maxCount) {
    227           maxCount = count;
    228           emptyBg = color;
    229         }
    230       }
    231       // Only use if it appears in > 60% of cells (most cells should be empty)
    232       if (emptyBg && maxCount > cells.length * 0.6) return emptyBg;
    233       return undefined;
    234     }
    235 
    236     // Strategy 2: look for a grid/flex container with child cells
    237     const containers = document.querySelectorAll(
    238       '[class*="board"], [class*="grid"], [class*="field"], [id*="board"], [id*="grid"], [id*="field"]'
    239     );
    240     for (const container of containers) {
    241       const children = container.children;
    242       // Could be a flat list of 200 cells (10x20) or 20 rows of 10 cells
    243       if (children.length >= rows * cols - 10 && children.length <= rows * cols + 10) {
    244         const actualCols = cols;
    245         const actualRows = Math.round(children.length / actualCols);
    246         const allCells = Array.from(children).slice(0, actualRows * actualCols) as HTMLElement[];
    247         const emptyBg = detectEmptyBg(allCells);
    248         const result: boolean[][] = [];
    249         for (let r = 0; r < actualRows; r++) {
    250           const rowData: boolean[] = [];
    251           for (let c = 0; c < actualCols; c++) {
    252             const cell = allCells[r * actualCols + c];
    253             rowData.push(cell ? isCellFilled(cell, emptyBg) : false);
    254           }
    255           result.push(rowData);
    256         }
    257         return result;
    258       }
    259       // Could be 20 row containers each with 10 cells
    260       if (children.length >= rows - 2 && children.length <= rows + 2) {
    261         const firstRowCells = children[0]?.children;
    262         if (firstRowCells && firstRowCells.length >= cols - 2 && firstRowCells.length <= cols + 2) {
    263           const actualRows = children.length;
    264           const actualCols = firstRowCells.length;
    265           // Collect all cells for empty-bg detection
    266           const allCells: HTMLElement[] = [];
    267           for (let r = 0; r < actualRows; r++) {
    268             const cells = children[r].children;
    269             for (let c = 0; c < Math.min(actualCols, cells.length); c++) {
    270               allCells.push(cells[c] as HTMLElement);
    271             }
    272           }
    273           const emptyBg = detectEmptyBg(allCells);
    274           let valid = true;
    275           const result: boolean[][] = [];
    276           for (let r = 0; r < actualRows; r++) {
    277             const rowEl = children[r];
    278             const cells = rowEl.children;
    279             if (cells.length < actualCols) { valid = false; break; }
    280             const rowData: boolean[] = [];
    281             for (let c = 0; c < actualCols; c++) {
    282               rowData.push(isCellFilled(cells[c] as HTMLElement, emptyBg));
    283             }
    284             result.push(rowData);
    285           }
    286           if (valid) return result;
    287         }
    288       }
    289     }
    290 
    291     // Strategy 3: heuristic scan for ANY container with many same-sized
    292     // children arranged in a grid pattern (no class/id naming required)
    293     const allElements = document.querySelectorAll("div, section, main, article");
    294     for (const el of allElements) {
    295       const ch = el.children;
    296       // Flat list of ~200 cells
    297       if (ch.length >= 180 && ch.length <= 220) {
    298         const firstChild = ch[0] as HTMLElement;
    299         if (!firstChild) continue;
    300         const firstRect = firstChild.getBoundingClientRect();
    301         if (firstRect.width < 5 || firstRect.height < 5) continue;
    302         let uniform = true;
    303         for (let i = 1; i < Math.min(10, ch.length); i++) {
    304           const r = (ch[i] as HTMLElement).getBoundingClientRect();
    305           if (Math.abs(r.width - firstRect.width) > 2 || Math.abs(r.height - firstRect.height) > 2) {
    306             uniform = false;
    307             break;
    308           }
    309         }
    310         if (uniform) {
    311           const actualCols = cols;
    312           const actualRows = Math.round(ch.length / actualCols);
    313           const allCells = Array.from(ch).slice(0, actualRows * actualCols) as HTMLElement[];
    314           const emptyBg = detectEmptyBg(allCells);
    315           const result: boolean[][] = [];
    316           for (let r = 0; r < actualRows; r++) {
    317             const rowData: boolean[] = [];
    318             for (let c = 0; c < actualCols; c++) {
    319               const cell = allCells[r * actualCols + c];
    320               rowData.push(cell ? isCellFilled(cell, emptyBg) : false);
    321             }
    322             result.push(rowData);
    323           }
    324           return result;
    325         }
    326       }
    327       // Container with ~20 row children each having ~10 cell children
    328       if (ch.length >= 18 && ch.length <= 22) {
    329         const firstRowCells = ch[0]?.children;
    330         if (firstRowCells && firstRowCells.length >= 8 && firstRowCells.length <= 12) {
    331           const rect = el.getBoundingClientRect();
    332           if (rect.width > 50 && rect.height > 100) {
    333             const actualRows = ch.length;
    334             const actualCols = firstRowCells.length;
    335             const allCells: HTMLElement[] = [];
    336             for (let r = 0; r < actualRows; r++) {
    337               const cells = ch[r].children;
    338               for (let c = 0; c < Math.min(actualCols, cells.length); c++) {
    339                 allCells.push(cells[c] as HTMLElement);
    340               }
    341             }
    342             const emptyBg = detectEmptyBg(allCells);
    343             let valid = true;
    344             const result: boolean[][] = [];
    345             for (let r = 0; r < actualRows; r++) {
    346               const cells = ch[r].children;
    347               if (cells.length < actualCols) { valid = false; break; }
    348               const rowData: boolean[] = [];
    349               for (let c = 0; c < actualCols; c++) {
    350                 rowData.push(isCellFilled(cells[c] as HTMLElement, emptyBg));
    351               }
    352               result.push(rowData);
    353             }
    354             if (valid) return result;
    355           }
    356         }
    357       }
    358     }
    359 
    360     return null;
    361   }, { rows: GRID_ROWS, cols: GRID_COLS });
    362 
    363   return grid;
    364 }
    365 
    366 /**
    367  * Sample the background color from the top-left cell of an empty grid.
    368  * Called during calibration before the game has pieces.
    369  */
    370 export async function sampleBackgroundColor(
    371   page: Page,
    372   bounds: GridBounds,
    373   cellW: number,
    374   cellH: number
    375 ): Promise<[number, number, number] | null> {
    376   try {
    377     const color = await page.evaluate(
    378       ({ x, y, cellW, cellH }) => {
    379         const canvas = document.querySelector("canvas") as HTMLCanvasElement | null;
    380         if (!canvas) return null;
    381         const ctx = canvas.getContext("2d");
    382         if (!ctx) return null;
    383         // Sample from the center of the first cell
    384         const px = Math.floor(x + cellW / 2);
    385         const py = Math.floor(y + cellH / 2);
    386         const pixel = ctx.getImageData(px, py, 1, 1).data;
    387         return [pixel[0], pixel[1], pixel[2]] as [number, number, number];
    388       },
    389       { x: bounds.x, y: bounds.y, cellW, cellH }
    390     );
    391     return color;
    392   } catch {
    393     return null;
    394   }
    395 }
    396 
    397 /**
    398  * Compare two grids and return true if they differ.
    399  */
    400 export function gridsAreDifferent(a: Grid | null, b: Grid | null): boolean {
    401   if (a === null || b === null) return a !== b;
    402   if (a.length !== b.length) return true;
    403   for (let r = 0; r < a.length; r++) {
    404     if (a[r].length !== b[r].length) return true;
    405     for (let c = 0; c < a[r].length; c++) {
    406       if (a[r][c] !== b[r][c]) return true;
    407     }
    408   }
    409   return false;
    410 }
    411 
    412 /**
    413  * Count the number of filled cells in the bottom N rows of the grid.
    414  */
    415 export function countFilledInBottomRows(grid: Grid, rows: number): number {
    416   let count = 0;
    417   const startRow = Math.max(0, grid.length - rows);
    418   for (let r = startRow; r < grid.length; r++) {
    419     for (let c = 0; c < grid[r].length; c++) {
    420       if (grid[r][c]) count++;
    421     }
    422   }
    423   return count;
    424 }
    425 
    426 /**
    427  * Count total filled cells in the grid.
    428  */
    429 export function countFilled(grid: Grid): number {
    430   let count = 0;
    431   for (const row of grid) {
    432     for (const cell of row) {
    433       if (cell) count++;
    434     }
    435   }
    436   return count;
    437 }
    438 
    439 /**
    440  * Check if there are filled cells in the top few rows (near game over).
    441  */
    442 export function hasFilledInTopRows(grid: Grid, rows: number): boolean {
    443   for (let r = 0; r < Math.min(rows, grid.length); r++) {
    444     for (let c = 0; c < grid[r].length; c++) {
    445       if (grid[r][c]) return true;
    446     }
    447   }
    448   return false;
    449 }
    450 
    451 /**
    452  * Detect active piece cells by diffing the current grid against a settled
    453  * (locked-pieces-only) grid. Returns an array of [row, col] positions,
    454  * or null if detection fails.
    455  */
    456 export function detectActivePieceCells(
    457   current: Grid | null,
    458   settled: Grid | null
    459 ): [number, number][] | null {
    460   if (!current) return null;
    461 
    462   const cells: [number, number][] = [];
    463 
    464   if (settled && settled.length === current.length) {
    465     for (let row = 0; row < current.length; row++) {
    466       for (let col = 0; col < current[row].length; col++) {
    467         if (current[row][col] && !settled[row][col]) {
    468           cells.push([row, col]);
    469         }
    470       }
    471     }
    472   } else {
    473     // Fallback: scan top 6 rows for filled cells
    474     for (let row = 0; row < Math.min(6, current.length); row++) {
    475       for (let col = 0; col < current[row].length; col++) {
    476         if (current[row][col]) {
    477           cells.push([row, col]);
    478         }
    479       }
    480     }
    481   }
    482 
    483   // A tetromino has exactly 4 cells
    484   if (cells.length < 3 || cells.length > 5) return null;
    485   return cells;
    486 }
    487 
    488 /**
    489  * Identify the piece type from its cell positions by matching against
    490  * known tetromino shapes (bounding box + cell pattern).
    491  */
    492 export function identifyPieceType(cells: [number, number][]): PieceType {
    493   if (cells.length !== 4) return "unknown";
    494 
    495   const minRow = Math.min(...cells.map(([r]) => r));
    496   const maxRow = Math.max(...cells.map(([r]) => r));
    497   const minCol = Math.min(...cells.map(([, c]) => c));
    498   const maxCol = Math.max(...cells.map(([, c]) => c));
    499   const w = maxCol - minCol + 1;
    500   const h = maxRow - minRow + 1;
    501 
    502   // Normalize to origin
    503   const norm = cells.map(([r, c]) => [r - minRow, c - minCol] as [number, number]);
    504   const key = norm
    505     .sort((a, b) => a[0] - b[0] || a[1] - b[1])
    506     .map(([r, c]) => `${r},${c}`)
    507     .join("|");
    508 
    509   // I piece: 4x1 or 1x4
    510   if (w === 4 && h === 1) return "I";
    511   if (w === 1 && h === 4) return "I";
    512 
    513   // O piece: 2x2
    514   if (w === 2 && h === 2) return "O";
    515 
    516   // For 3x2 and 2x3 shapes, match exact patterns
    517   // T piece rotations
    518   const tPatterns = [
    519     "0,0|0,1|0,2|1,1",     // T flat
    520     "0,0|1,0|1,1|2,0",     // T right
    521     "0,1|1,0|1,1|1,2",     // T inverted
    522     "0,0|0,1|1,0|2,0",     // T left (corrected)
    523     "0,1|1,0|1,1|2,1",     // T right alt
    524     "0,0|0,1|1,1|2,1",     // T left alt
    525   ];
    526   if (tPatterns.includes(key)) return "T";
    527 
    528   // S piece rotations
    529   const sPatterns = [
    530     "0,1|0,2|1,0|1,1",     // S flat
    531     "0,0|1,0|1,1|2,1",     // S vertical
    532   ];
    533   if (sPatterns.includes(key)) return "S";
    534 
    535   // Z piece rotations
    536   const zPatterns = [
    537     "0,0|0,1|1,1|1,2",     // Z flat
    538     "0,1|1,0|1,1|2,0",     // Z vertical
    539   ];
    540   if (zPatterns.includes(key)) return "Z";
    541 
    542   // J piece rotations
    543   const jPatterns = [
    544     "0,0|1,0|1,1|1,2",     // J flat
    545     "0,0|0,1|1,0|2,0",     // J right
    546     "0,0|0,1|0,2|1,2",     // J inverted
    547     "0,0|1,0|2,0|2,1",     // J left (corrected)
    548     "0,1|1,1|2,0|2,1",     // J left alt
    549   ];
    550   if (jPatterns.includes(key)) return "J";
    551 
    552   // L piece rotations
    553   const lPatterns = [
    554     "0,2|1,0|1,1|1,2",     // L flat
    555     "0,0|1,0|2,0|2,1",     // L right (same as J left)
    556     "0,0|0,1|0,2|1,0",     // L inverted
    557     "0,0|0,1|1,1|2,1",     // L left
    558   ];
    559   if (lPatterns.includes(key)) return "L";
    560 
    561   // If no exact match, classify by bounding box
    562   if ((w === 3 && h === 2) || (w === 2 && h === 3)) return "unknown";
    563 
    564   return "unknown";
    565 }
    566 
    567 /**
    568  * Check if a specific row in the grid is completely filled.
    569  */
    570 export function isRowComplete(grid: Grid, row: number): boolean {
    571   if (row < 0 || row >= grid.length) return false;
    572   return grid[row].every(Boolean);
    573 }
    574 
    575 /**
    576  * Count complete (filled) rows in the grid.
    577  */
    578 export function countCompleteRows(grid: Grid): number {
    579   let count = 0;
    580   for (let r = 0; r < grid.length; r++) {
    581     if (isRowComplete(grid, r)) count++;
    582   }
    583   return count;
    584 }
    585 
    586 /**
    587  * Get column heights (distance from top to highest filled cell per column).
    588  */
    589 export function getColumnHeights(grid: Grid): number[] {
    590   const heights: number[] = [];
    591   for (let col = 0; col < GRID_COLS; col++) {
    592     let h = 0;
    593     for (let row = 0; row < GRID_ROWS; row++) {
    594       if (grid[row]?.[col]) {
    595         h = GRID_ROWS - row;
    596         break;
    597       }
    598     }
    599     heights.push(h);
    600   }
    601   return heights;
    602 }

Impressum · Datenschutz