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 }