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

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 }

Impressum · Datenschutz