player.ts (19702B)
1 // Pierre Dellacherie's 4-heuristic Tetris AI (2003). 2 // Weights from Colin Fahey's genetic algorithm optimization. 3 // Reference implementation: LeeYiyuan/tetrisai (MIT License) -- piece 4 // definitions and simulation logic adapted from that codebase. 5 6 import type { Page } from "@playwright/test"; 7 import type { Grid, CalibrationResult, PieceType } from "./types"; 8 import { readGrid, detectActivePieceCells, identifyPieceType, gridsAreDifferent } from "./grid-reader"; 9 10 // Genetically-optimized weights (Fahey) 11 const W_HEIGHT = -0.510066; 12 const W_LINES = 0.760666; 13 const W_HOLES = -0.35663; 14 const W_BUMPINESS = -0.184483; 15 16 const GRID_ROWS = 20; 17 const GRID_COLS = 10; 18 19 /** 20 * Standard Tetris piece definitions. 21 * Each piece has 4 rotation states. 22 * Each rotation state is a list of [row, col] offsets from the piece origin. 23 * Adapted from LeeYiyuan/tetrisai piece.js (reference implementation) 24 */ 25 const PIECES: Record<string, [number, number][][]> = { 26 I: [ 27 [[0, 0], [0, 1], [0, 2], [0, 3]], // horizontal 28 [[0, 0], [1, 0], [2, 0], [3, 0]], // vertical 29 [[0, 0], [0, 1], [0, 2], [0, 3]], // horizontal (same as 0) 30 [[0, 0], [1, 0], [2, 0], [3, 0]], // vertical (same as 1) 31 ], 32 O: [ 33 [[0, 0], [0, 1], [1, 0], [1, 1]], 34 [[0, 0], [0, 1], [1, 0], [1, 1]], 35 [[0, 0], [0, 1], [1, 0], [1, 1]], 36 [[0, 0], [0, 1], [1, 0], [1, 1]], 37 ], 38 T: [ 39 [[0, 1], [1, 0], [1, 1], [1, 2]], // T up 40 [[0, 0], [1, 0], [1, 1], [2, 0]], // T right 41 [[0, 0], [0, 1], [0, 2], [1, 1]], // T down 42 [[0, 1], [1, 0], [1, 1], [2, 1]], // T left 43 ], 44 S: [ 45 [[0, 1], [0, 2], [1, 0], [1, 1]], // S horizontal 46 [[0, 0], [1, 0], [1, 1], [2, 1]], // S vertical 47 [[0, 1], [0, 2], [1, 0], [1, 1]], 48 [[0, 0], [1, 0], [1, 1], [2, 1]], 49 ], 50 Z: [ 51 [[0, 0], [0, 1], [1, 1], [1, 2]], // Z horizontal 52 [[0, 1], [1, 0], [1, 1], [2, 0]], // Z vertical 53 [[0, 0], [0, 1], [1, 1], [1, 2]], 54 [[0, 1], [1, 0], [1, 1], [2, 0]], 55 ], 56 J: [ 57 [[0, 0], [1, 0], [1, 1], [1, 2]], // J up 58 [[0, 0], [0, 1], [1, 0], [2, 0]], // J right 59 [[0, 0], [0, 1], [0, 2], [1, 2]], // J down 60 [[0, 0], [1, 0], [2, 0], [2, -1]], // J left (using relative) 61 ], 62 L: [ 63 [[0, 2], [1, 0], [1, 1], [1, 2]], // L up 64 [[0, 0], [1, 0], [2, 0], [2, 1]], // L right 65 [[0, 0], [0, 1], [0, 2], [1, 0]], // L down 66 [[0, 0], [0, 1], [1, 1], [2, 1]], // L left 67 ], 68 }; 69 70 /** The result of finding the best placement. */ 71 interface Placement { 72 rotations: number; 73 column: number; 74 score: number; 75 linesCleared: number; 76 pieceType: string; 77 } 78 79 /** 80 * Play the game using continuous grid polling and the 4-heuristic AI. 81 * Adapted from mikhail-vlasenko/Tetris-AI's continuous polling approach. 82 * 83 * Instead of "snapshot, act, snapshot, compare", this continuously reads 84 * the grid and reacts to changes. 85 */ 86 export async function playGame( 87 page: Page, 88 cal: CalibrationResult, 89 options: { maxPieces?: number; maxDurationMs?: number; scoreSelector?: string } 90 ): Promise<{ piecesPlaced: number; linesCleared: number; errors: number; gridReads: number; gridReadFails: number; scoreValues: number[] }> { 91 const maxPieces = options.maxPieces ?? 100; 92 const maxDuration = options.maxDurationMs ?? 30000; 93 const scoreSelector = options.scoreSelector ?? null; 94 const start = Date.now(); 95 let piecesPlaced = 0; 96 let linesCleared = 0; 97 let errors = 0; 98 let gridReads = 0; 99 let gridReadFails = 0; 100 let consecutiveReadFails = 0; 101 const scoreValues: number[] = []; 102 let scorePollCounter = 0; 103 104 let previousGrid: Grid | null = null; 105 let settledGrid: Grid | null = null; 106 let lastPlacementTime = Date.now(); 107 let waitingForNewPiece = false; 108 109 while (piecesPlaced < maxPieces && Date.now() - start < maxDuration) { 110 try { 111 const grid = await readGrid(page, cal); 112 113 if (!grid) { 114 gridReadFails++; 115 consecutiveReadFails++; 116 if (consecutiveReadFails > 10) { 117 // Grid reading is broken, fall back to random play 118 await playRandomForDuration(page, cal, Math.min(5000, maxDuration - (Date.now() - start))); 119 piecesPlaced += 3; 120 break; 121 } 122 await page.waitForTimeout(60); 123 continue; 124 } 125 126 gridReads++; 127 consecutiveReadFails = 0; 128 129 // Lightweight score tracking: read score every ~5 polls 130 if (scoreSelector) { 131 scorePollCounter++; 132 if (scorePollCounter % 5 === 0) { 133 try { 134 const scoreText = await page.textContent(scoreSelector); 135 if (scoreText) { 136 const nums = (scoreText.match(/\d+/g) || []).map(Number); 137 if (nums.length > 0) { 138 scoreValues.push(Math.max(...nums)); 139 } 140 } 141 } catch { /* ignore score read failures */ } 142 } 143 } 144 145 // Detect if anything changed 146 if (previousGrid && !gridsAreDifferent(grid, previousGrid)) { 147 // Nothing changed, wait and poll again 148 // If we've been waiting too long without changes, the game may be paused 149 if (Date.now() - lastPlacementTime > 8000) { 150 // Try pressing a key to unpause/restart 151 await page.keyboard.press(cal.controls.drop); 152 lastPlacementTime = Date.now(); 153 } 154 await page.waitForTimeout(60); 155 continue; 156 } 157 158 // Grid changed -- figure out what happened 159 if (waitingForNewPiece) { 160 // We just dropped a piece and are waiting for the next one 161 settledGrid = grid; 162 waitingForNewPiece = false; 163 lastPlacementTime = Date.now(); 164 previousGrid = grid; 165 await page.waitForTimeout(60); 166 continue; 167 } 168 169 // Try to detect the active piece 170 const activeCells = detectActivePieceCells(grid, settledGrid); 171 172 if (activeCells && activeCells.length === 4) { 173 const pieceType = identifyPieceType(activeCells); 174 175 // Find best placement for this piece 176 const boardWithoutPiece = settledGrid ?? stripActivePiece(grid, activeCells); 177 const placement = findBestPlacement(boardWithoutPiece, pieceType); 178 179 if (placement) { 180 await executePlacement(page, cal, placement, activeCells); 181 linesCleared += placement.linesCleared; 182 piecesPlaced++; 183 waitingForNewPiece = true; 184 } else { 185 // Can't find placement, just hard drop 186 await page.keyboard.press(cal.controls.drop); 187 piecesPlaced++; 188 waitingForNewPiece = true; 189 } 190 191 // Wait for the piece to lock and next piece to spawn 192 await page.waitForTimeout(100); 193 194 // Read the settled state 195 const afterGrid = await readGrid(page, cal); 196 if (afterGrid) { 197 // Check if lines were cleared 198 if (settledGrid) { 199 const filledBefore = countTotalFilled(settledGrid); 200 const filledAfter = countTotalFilled(afterGrid); 201 // If filled count dropped significantly, lines were cleared 202 if (filledAfter < filledBefore) { 203 const possibleClears = Math.round((filledBefore + 4 - filledAfter) / GRID_COLS); 204 if (possibleClears > 0 && possibleClears <= 4) { 205 linesCleared += possibleClears; 206 } 207 } 208 } 209 settledGrid = afterGrid; 210 } 211 212 lastPlacementTime = Date.now(); 213 } else { 214 // Could not detect active piece -- the grid changed but we can't 215 // identify what moved. This could be auto-drop, line clear animation, etc. 216 // Just update our view and wait. 217 } 218 219 previousGrid = grid; 220 await page.waitForTimeout(60); 221 } catch { 222 errors++; 223 await playRandomMove(page, cal); 224 piecesPlaced++; 225 await page.waitForTimeout(60); 226 } 227 } 228 229 return { piecesPlaced, linesCleared, errors, gridReads, gridReadFails, scoreValues }; 230 } 231 232 /** 233 * Execute a single hard drop (for tests that just need to drop a piece). 234 */ 235 export async function hardDrop(page: Page, cal: CalibrationResult): Promise<void> { 236 await page.keyboard.press(cal.controls.drop); 237 await page.waitForTimeout(200); 238 } 239 240 /** 241 * Execute a placement: rotate, move to column, then hard drop. 242 * Uses the detected active piece position to calculate the correct moves. 243 */ 244 async function executePlacement( 245 page: Page, 246 cal: CalibrationResult, 247 placement: Placement, 248 activeCells: [number, number][] 249 ): Promise<void> { 250 // Rotate to target rotation 251 for (let i = 0; i < placement.rotations; i++) { 252 await page.keyboard.press(cal.controls.rotate); 253 await page.waitForTimeout(50); 254 } 255 256 // Determine current column of the piece (leftmost cell) 257 const currentCol = Math.min(...activeCells.map(([, c]) => c)); 258 259 // After rotation, the piece position may have shifted, so we estimate 260 // the column based on the original position 261 const diff = placement.column - currentCol; 262 263 if (diff < 0) { 264 for (let i = 0; i < Math.abs(diff); i++) { 265 await page.keyboard.press(cal.controls.left); 266 await page.waitForTimeout(30); 267 } 268 } else if (diff > 0) { 269 for (let i = 0; i < diff; i++) { 270 await page.keyboard.press(cal.controls.right); 271 await page.waitForTimeout(30); 272 } 273 } 274 275 // Hard drop 276 await page.keyboard.press(cal.controls.drop); 277 await page.waitForTimeout(60); 278 } 279 280 /** 281 * Play a random move (fallback when grid reading fails). 282 */ 283 async function playRandomMove(page: Page, cal: CalibrationResult): Promise<void> { 284 const moves = [cal.controls.left, cal.controls.right, cal.controls.rotate, cal.controls.down]; 285 const randomMoves = Math.floor(Math.random() * 4) + 1; 286 for (let i = 0; i < randomMoves; i++) { 287 const key = moves[Math.floor(Math.random() * moves.length)]; 288 await page.keyboard.press(key); 289 await page.waitForTimeout(50); 290 } 291 await page.keyboard.press(cal.controls.drop); 292 await page.waitForTimeout(100); 293 } 294 295 /** 296 * Play randomly for a set duration (when grid reading is broken). 297 */ 298 async function playRandomForDuration( 299 page: Page, 300 cal: CalibrationResult, 301 durationMs: number 302 ): Promise<void> { 303 const start = Date.now(); 304 const moves = [cal.controls.left, cal.controls.right, cal.controls.rotate, cal.controls.down, cal.controls.drop]; 305 306 while (Date.now() - start < durationMs) { 307 const key = moves[Math.floor(Math.random() * moves.length)]; 308 await page.keyboard.press(key); 309 await page.waitForTimeout(100); 310 } 311 } 312 313 /** 314 * Try to fill a specific row by placing pieces strategically. 315 * Uses repeated hard drops at different columns to build up the bottom row. 316 */ 317 export async function tryFillRow( 318 page: Page, 319 cal: CalibrationResult, 320 maxAttempts: number 321 ): Promise<boolean> { 322 // Strategy: move piece to each column left to right and hard drop 323 const columns = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; 324 let attempts = 0; 325 326 for (const targetCol of columns) { 327 if (attempts >= maxAttempts) break; 328 329 // Move to far left first 330 for (let i = 0; i < 6; i++) { 331 await page.keyboard.press(cal.controls.left); 332 await page.waitForTimeout(30); 333 } 334 335 // Then move right to target column 336 for (let i = 0; i < targetCol; i++) { 337 await page.keyboard.press(cal.controls.right); 338 await page.waitForTimeout(30); 339 } 340 341 await page.keyboard.press(cal.controls.drop); 342 await page.waitForTimeout(200); 343 attempts++; 344 } 345 346 // Check if a line was cleared 347 const grid = await readGrid(page, cal); 348 if (!grid) return false; 349 350 // If bottom row is now empty after being full, a line was cleared 351 const bottomFilled = grid[GRID_ROWS - 1].filter(Boolean).length; 352 return bottomFilled < 8; 353 } 354 355 /** 356 * Quickly stack pieces to reach game over. 357 */ 358 export async function stackToGameOver( 359 page: Page, 360 cal: CalibrationResult, 361 maxAttempts: number 362 ): Promise<boolean> { 363 // Strategy: hard drop in the same column repeatedly to build a tower 364 for (let i = 0; i < maxAttempts; i++) { 365 await page.keyboard.press(cal.controls.drop); 366 await page.waitForTimeout(150); 367 } 368 369 // Check if the game appears to have stopped 370 const shot1 = await page.screenshot(); 371 await page.waitForTimeout(1000); 372 await page.keyboard.press(cal.controls.drop); 373 await page.waitForTimeout(500); 374 const shot2 = await page.screenshot(); 375 376 const screenshotsSame = Buffer.from(shot1).equals(Buffer.from(shot2)); 377 378 const hasGameOverText = await page.evaluate(() => { 379 const text = document.body.innerText.toLowerCase(); 380 return ( 381 text.includes("game over") || 382 text.includes("gameover") || 383 text.includes("you lose") || 384 text.includes("try again") || 385 text.includes("restart") || 386 text.includes("play again") 387 ); 388 }); 389 390 return screenshotsSame || hasGameOverText; 391 } 392 393 // --- Heuristic evaluation functions --- 394 // Pierre Dellacherie 4-heuristic, reference: LeeYiyuan/tetrisai (MIT License) 395 396 /** 397 * Find the best column and rotation for a given piece type using the 398 * 4-heuristic scoring function. 399 * 400 * For each possible (rotation, column) combination: 401 * 1. Simulate placing the piece (drop it straight down) 402 * 2. Score the resulting board 403 * 3. Pick the best score 404 */ 405 function findBestPlacement(board: Grid, pieceType: PieceType): Placement | null { 406 const rotations = PIECES[pieceType]; 407 if (!rotations) { 408 // Unknown piece type -- try all rotations with single-cell simulation 409 return findBestPlacementGeneric(board); 410 } 411 412 let bestScore = -Infinity; 413 let bestPlacement: Placement | null = null; 414 415 for (let rot = 0; rot < rotations.length; rot++) { 416 const shape = rotations[rot]; 417 418 // Determine the piece's width in this rotation 419 const minCol = Math.min(...shape.map(([, c]) => c)); 420 const maxCol = Math.max(...shape.map(([, c]) => c)); 421 const pieceWidth = maxCol - minCol + 1; 422 423 // Try every valid column position 424 for (let col = -minCol; col <= GRID_COLS - pieceWidth + (-minCol); col++) { 425 // Simulate dropping the piece at this column 426 const simResult = simulateDropPiece(board, shape, col); 427 if (!simResult) continue; 428 429 const { cleared, resultBoard } = simResult; 430 const score = 431 W_HEIGHT * aggregateHeight(resultBoard) + 432 W_LINES * cleared + 433 W_HOLES * countHoles(resultBoard) + 434 W_BUMPINESS * bumpiness(resultBoard); 435 436 if (score > bestScore) { 437 bestScore = score; 438 bestPlacement = { 439 rotations: rot, 440 column: col, 441 score, 442 linesCleared: cleared, 443 pieceType, 444 }; 445 } 446 } 447 } 448 449 return bestPlacement; 450 } 451 452 /** 453 * Generic placement finder when piece type is unknown. 454 * Simulates dropping a single cell at each column (simplified). 455 */ 456 function findBestPlacementGeneric(board: Grid): Placement | null { 457 let bestScore = -Infinity; 458 let bestPlacement: Placement | null = null; 459 460 for (let col = 0; col < GRID_COLS; col++) { 461 const simGrid = simulateDropSingleCell(board, col); 462 if (!simGrid) continue; 463 464 const { cleared, resultBoard } = clearLines(simGrid); 465 const score = 466 W_HEIGHT * aggregateHeight(resultBoard) + 467 W_LINES * cleared + 468 W_HOLES * countHoles(resultBoard) + 469 W_BUMPINESS * bumpiness(resultBoard); 470 471 if (score > bestScore) { 472 bestScore = score; 473 bestPlacement = { rotations: 0, column: col, score, linesCleared: cleared, pieceType: "unknown" }; 474 } 475 } 476 477 return bestPlacement; 478 } 479 480 /** 481 * Simulate dropping a piece (defined by its shape offsets) at a given column. 482 * Returns the resulting board after clearing lines, or null if placement is invalid. 483 */ 484 function simulateDropPiece( 485 board: Grid, 486 shape: [number, number][], 487 col: number 488 ): { cleared: number; resultBoard: Grid } | null { 489 // Find the lowest valid row for this piece 490 let landRow = -1; 491 492 for (let row = 0; row <= GRID_ROWS; row++) { 493 let valid = true; 494 for (const [dr, dc] of shape) { 495 const r = row + dr; 496 const c = col + dc; 497 if (r >= GRID_ROWS || c < 0 || c >= GRID_COLS) { 498 valid = false; 499 break; 500 } 501 if (r >= 0 && board[r][c]) { 502 valid = false; 503 break; 504 } 505 } 506 if (!valid) { 507 landRow = row - 1; 508 break; 509 } 510 } 511 512 if (landRow < 0) { 513 // Check if the piece can sit at row 0 514 let valid = true; 515 for (const [dr, dc] of shape) { 516 const r = dr; 517 const c = col + dc; 518 if (r >= GRID_ROWS || c < 0 || c >= GRID_COLS || (r >= 0 && board[r][c])) { 519 valid = false; 520 break; 521 } 522 } 523 if (valid) landRow = 0; 524 else return null; 525 } 526 527 // Clone board and place piece 528 const newBoard: Grid = board.map((row) => [...row]); 529 for (const [dr, dc] of shape) { 530 const r = landRow + dr; 531 const c = col + dc; 532 if (r >= 0 && r < GRID_ROWS && c >= 0 && c < GRID_COLS) { 533 newBoard[r][c] = true; 534 } 535 } 536 537 return clearLines(newBoard); 538 } 539 540 /** 541 * Simulate dropping a single cell at the given column (simplified fallback). 542 */ 543 function simulateDropSingleCell(board: Grid, col: number): Grid | null { 544 if (col < 0 || col >= GRID_COLS) return null; 545 546 let landRow = -1; 547 for (let r = GRID_ROWS - 1; r >= 0; r--) { 548 if (!board[r][col]) { 549 landRow = r; 550 break; 551 } 552 } 553 if (landRow < 0) return null; 554 555 const newGrid: Grid = board.map((row) => [...row]); 556 newGrid[landRow][col] = true; 557 return newGrid; 558 } 559 560 /** 561 * Remove the active piece cells from a grid to get the settled state. 562 */ 563 function stripActivePiece(grid: Grid, activeCells: [number, number][]): Grid { 564 const result: Grid = grid.map((row) => [...row]); 565 for (const [r, c] of activeCells) { 566 if (r >= 0 && r < result.length && c >= 0 && c < result[r].length) { 567 result[r][c] = false; 568 } 569 } 570 return result; 571 } 572 573 /** 574 * Clear completed lines and return the count + new board. 575 */ 576 function clearLines(grid: Grid): { cleared: number; resultBoard: Grid } { 577 const remaining: boolean[][] = []; 578 let cleared = 0; 579 580 for (const row of grid) { 581 if (row.every(Boolean)) { 582 cleared++; 583 } else { 584 remaining.push([...row]); 585 } 586 } 587 588 // Add empty rows at the top 589 while (remaining.length < GRID_ROWS) { 590 remaining.unshift(new Array(GRID_COLS).fill(false)); 591 } 592 593 return { cleared, resultBoard: remaining }; 594 } 595 596 /** 597 * Sum of column heights (distance from top to highest filled cell per column). 598 */ 599 function aggregateHeight(grid: Grid): number { 600 let total = 0; 601 for (let col = 0; col < GRID_COLS; col++) { 602 for (let row = 0; row < GRID_ROWS; row++) { 603 if (grid[row]?.[col]) { 604 total += GRID_ROWS - row; 605 break; 606 } 607 } 608 } 609 return total; 610 } 611 612 /** 613 * Count holes (empty cells with a filled cell above them in the same column). 614 */ 615 function countHoles(grid: Grid): number { 616 let holes = 0; 617 for (let col = 0; col < GRID_COLS; col++) { 618 let blockFound = false; 619 for (let row = 0; row < GRID_ROWS; row++) { 620 if (grid[row]?.[col]) { 621 blockFound = true; 622 } else if (blockFound) { 623 holes++; 624 } 625 } 626 } 627 return holes; 628 } 629 630 /** 631 * Sum of absolute height differences between adjacent columns. 632 */ 633 function bumpiness(grid: Grid): number { 634 const heights: number[] = []; 635 for (let col = 0; col < GRID_COLS; col++) { 636 let h = 0; 637 for (let row = 0; row < GRID_ROWS; row++) { 638 if (grid[row]?.[col]) { 639 h = GRID_ROWS - row; 640 break; 641 } 642 } 643 heights.push(h); 644 } 645 646 let bump = 0; 647 for (let i = 0; i < heights.length - 1; i++) { 648 bump += Math.abs(heights[i] - heights[i + 1]); 649 } 650 return bump; 651 } 652 653 /** 654 * Count total filled cells in the grid. 655 */ 656 function countTotalFilled(grid: Grid): number { 657 let count = 0; 658 for (const row of grid) { 659 for (const cell of row) { 660 if (cell) count++; 661 } 662 } 663 return count; 664 }