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

calibrate.ts (47089B)


      1 import type { Page } from "@playwright/test";
      2 import type {
      3   CalibrationResult,
      4   Controls,
      5   GridBounds,
      6   RendererType,
      7   StartMechanism,
      8   SurveyData,
      9 } from "./types";
     10 import { sampleBackgroundColor, readGrid } from "./grid-reader";
     11 
     12 const DEFAULT_CONTROLS: Controls = {
     13   left: "ArrowLeft",
     14   right: "ArrowRight",
     15   down: "ArrowDown",
     16   rotate: "ArrowUp",
     17   drop: "Space",
     18 };
     19 
     20 /**
     21  * Run all calibration steps. Never throws -- returns a result
     22  * with whatever could be detected.
     23  */
     24 export async function calibrate(page: Page): Promise<CalibrationResult> {
     25   const consoleErrors: string[] = [];
     26   page.on("pageerror", (err) => consoleErrors.push(err.message));
     27 
     28   // Wait for DOM to fully settle (scripts, animations, timers)
     29   await page.waitForTimeout(2000);
     30 
     31   let startResult = await detectStartMechanism(page);
     32   let startMechanism: StartMechanism = startResult.mechanism;
     33   let startButton = startResult.startButton;
     34   let { renderer, gridBounds, cellWidth, cellHeight } = await detectGrid(page);
     35   let backgroundColor =
     36     renderer === "canvas" && gridBounds
     37       ? await sampleBackgroundColor(page, gridBounds, cellWidth, cellHeight)
     38       : null;
     39 
     40   // Re-calibration fallback: if start or grid detection failed, retry with
     41   // longer waits and re-scan after each start attempt
     42   if (startMechanism === "unknown" || gridBounds === null) {
     43     const retry = await recalibrateWithRetry(page, startMechanism, gridBounds);
     44     if (retry.startMechanism !== "unknown") startMechanism = retry.startMechanism;
     45     if (retry.gridBounds) {
     46       renderer = retry.renderer;
     47       gridBounds = retry.gridBounds;
     48       cellWidth = retry.cellWidth;
     49       cellHeight = retry.cellHeight;
     50       backgroundColor =
     51         renderer === "canvas" && gridBounds
     52           ? await sampleBackgroundColor(page, gridBounds, cellWidth, cellHeight)
     53           : null;
     54     }
     55   }
     56 
     57   const controls = await detectControls(page);
     58   const scoreElementSelector = await detectScoreElement(page);
     59 
     60   // Grid confidence: poll grid reads to measure reliability
     61   const gridConfidence = await measureGridConfidence(page, {
     62     renderer,
     63     gridDetected: gridBounds !== null,
     64     gridBounds,
     65     cellWidth,
     66     cellHeight,
     67     controls,
     68     startMechanism,
     69     scoreElementSelector,
     70     backgroundColor,
     71     consoleErrors,
     72     gridConfidence: 0,
     73   });
     74 
     75   const result: CalibrationResult = {
     76     renderer,
     77     gridDetected: gridBounds !== null,
     78     gridBounds,
     79     cellWidth,
     80     cellHeight,
     81     controls,
     82     startMechanism,
     83     scoreElementSelector,
     84     backgroundColor,
     85     consoleErrors,
     86     gridConfidence,
     87   };
     88 
     89   if (startButton) {
     90     result.startButton = startButton;
     91   }
     92 
     93   return result;
     94 }
     95 
     96 /**
     97  * Measure grid read confidence by polling several times.
     98  * If the grid never changes despite the game being "started", try
     99  * more start mechanisms.
    100  */
    101 async function measureGridConfidence(
    102   page: Page,
    103   cal: CalibrationResult
    104 ): Promise<number> {
    105   if (!cal.gridBounds) return 0;
    106 
    107   let successes = 0;
    108   let attempts = 0;
    109   const pollCount = 6;
    110   let lastGrid: boolean[][] | null = null;
    111   let gridChanged = false;
    112 
    113   for (let i = 0; i < pollCount; i++) {
    114     attempts++;
    115     try {
    116       const grid = await readGrid(page, cal);
    117       if (grid) {
    118         successes++;
    119         if (lastGrid) {
    120           // Check if grid actually changed (game is running)
    121           for (let r = 0; r < grid.length && !gridChanged; r++) {
    122             for (let c = 0; c < grid[r].length && !gridChanged; c++) {
    123               if (grid[r][c] !== lastGrid[r][c]) gridChanged = true;
    124             }
    125           }
    126         }
    127         lastGrid = grid;
    128       }
    129     } catch {
    130       // read failed
    131     }
    132     await page.waitForTimeout(500);
    133   }
    134 
    135   // If grid reads succeed but grid never changed after 3 seconds,
    136   // try additional start mechanisms
    137   if (successes > 0 && !gridChanged && cal.startMechanism !== "unknown") {
    138     const additionalStarts: Array<{ name: string; action: () => Promise<void> }> = [
    139       { name: "space", action: async () => { await page.keyboard.press("Space"); } },
    140       { name: "enter", action: async () => { await page.keyboard.press("Enter"); } },
    141       { name: "click", action: async () => {
    142         const canvas = page.locator("canvas").first();
    143         if ((await canvas.count()) > 0) await canvas.click();
    144         else await page.locator("body").click({ position: { x: 200, y: 200 } });
    145       }},
    146     ];
    147 
    148     for (const start of additionalStarts) {
    149       try {
    150         await start.action();
    151         await page.waitForTimeout(1500);
    152         const grid = await readGrid(page, cal);
    153         if (grid && lastGrid) {
    154           for (let r = 0; r < grid.length && !gridChanged; r++) {
    155             for (let c = 0; c < grid[r].length && !gridChanged; c++) {
    156               if (grid[r][c] !== lastGrid[r][c]) gridChanged = true;
    157             }
    158           }
    159           if (gridChanged) break;
    160           lastGrid = grid;
    161         }
    162       } catch { /* continue */ }
    163     }
    164   }
    165 
    166   return attempts > 0 ? successes / attempts : 0;
    167 }
    168 
    169 /**
    170  * Take a screenshot and sample it into a grid of "colored" (true) / "background" (false)
    171  * values. Reusable building block for visual change detection.
    172  */
    173 async function sampleScreenshot(
    174   page: Page,
    175   sampleCols: number,
    176   sampleRows: number,
    177   colorThreshold: number = 40
    178 ): Promise<boolean[][]> {
    179   const shot = await page.screenshot();
    180   const base64 = shot.toString("base64");
    181   const grid = await page.evaluate(
    182     async ({ base64, sampleCols, sampleRows, colorThreshold }) => {
    183       const img = new Image();
    184       const loaded = new Promise<void>((resolve, reject) => {
    185         img.onload = () => resolve();
    186         img.onerror = () => reject(new Error("image decode failed"));
    187       });
    188       img.src = `data:image/png;base64,${base64}`;
    189       await loaded;
    190 
    191       const canvas = document.createElement("canvas");
    192       canvas.width = img.width;
    193       canvas.height = img.height;
    194       const ctx = canvas.getContext("2d")!;
    195       ctx.drawImage(img, 0, 0);
    196 
    197       const stepX = img.width / sampleCols;
    198       const stepY = img.height / sampleRows;
    199 
    200       const colors: number[][] = [];
    201       for (let r = 0; r < sampleRows; r++) {
    202         const row: number[] = [];
    203         for (let c = 0; c < sampleCols; c++) {
    204           const px = Math.floor(c * stepX + stepX / 2);
    205           const py = Math.floor(r * stepY + stepY / 2);
    206           const pixel = ctx.getImageData(px, py, 1, 1).data;
    207           row.push(pixel[0] * 1000000 + pixel[1] * 1000 + pixel[2]);
    208         }
    209         colors.push(row);
    210       }
    211 
    212       const colorCounts = new Map<number, number>();
    213       for (const row of colors) {
    214         for (const c of row) {
    215           colorCounts.set(c, (colorCounts.get(c) || 0) + 1);
    216         }
    217       }
    218       let bgColor = 0;
    219       let bgCount = 0;
    220       for (const [color, count] of colorCounts) {
    221         if (count > bgCount) {
    222           bgCount = count;
    223           bgColor = color;
    224         }
    225       }
    226       const bgR = Math.floor(bgColor / 1000000);
    227       const bgG = Math.floor((bgColor % 1000000) / 1000);
    228       const bgB = bgColor % 1000;
    229 
    230       const result: boolean[][] = [];
    231       for (let r = 0; r < sampleRows; r++) {
    232         const row: boolean[] = [];
    233         for (let c = 0; c < sampleCols; c++) {
    234           const v = colors[r][c];
    235           const pR = Math.floor(v / 1000000);
    236           const pG = Math.floor((v % 1000000) / 1000);
    237           const pB = v % 1000;
    238           const dist = Math.sqrt(
    239             (pR - bgR) ** 2 + (pG - bgG) ** 2 + (pB - bgB) ** 2
    240           );
    241           row.push(dist > colorThreshold);
    242         }
    243         result.push(row);
    244       }
    245       return result;
    246     },
    247     { base64, sampleCols, sampleRows, colorThreshold }
    248   );
    249   return grid;
    250 }
    251 
    252 /**
    253  * Detect visual change by comparing screenshots.
    254  *
    255  * Takes a "before" reference screenshot (optional) and a series of "after" screenshots.
    256  * If before is provided, compares before vs each after frame.
    257  * Otherwise compares consecutive after frames (for auto-start detection where
    258  * animation should be continuously visible).
    259  *
    260  * Uses raw buffer comparison: if bytes differ, something changed.
    261  */
    262 async function detectVisualChange(
    263   page: Page,
    264   options?: { frames?: number; intervalMs?: number; before?: Buffer }
    265 ): Promise<{ changed: boolean; gameplayDetected: boolean }> {
    266   const FRAMES = options?.frames ?? 6;
    267   const INTERVAL = options?.intervalMs ?? 200;
    268 
    269   const screenshots: Buffer[] = [];
    270   for (let i = 0; i < FRAMES; i++) {
    271     screenshots.push(await page.screenshot());
    272     if (i < FRAMES - 1) await page.waitForTimeout(INTERVAL);
    273   }
    274 
    275   let changed = false;
    276 
    277   console.log(`[detect] ${FRAMES} frames captured, sizes: [${screenshots.map(s => s.length).join(",")}]${options?.before ? `, before=${options.before.length}` : ""}`);
    278 
    279   if (options?.before) {
    280     // Compare before-action screenshot against each after-action frame
    281     for (let i = 0; i < screenshots.length; i++) {
    282       const same = options.before.equals(screenshots[i]);
    283       console.log(`[detect] before vs frame[${i}]: ${same ? "SAME" : "DIFF"} (${screenshots[i].length} bytes)`);
    284       if (!same) {
    285         changed = true;
    286         break;
    287       }
    288     }
    289   } else {
    290     // No before reference: compare consecutive frames (for auto-start detection)
    291     // Also extend window: take one more shot after a longer pause to catch slow drops
    292     await page.waitForTimeout(1200);
    293     const lateFrame = await page.screenshot();
    294 
    295     for (let f = 0; f < screenshots.length - 1; f++) {
    296       if (!screenshots[f].equals(screenshots[f + 1])) {
    297         changed = true;
    298         console.log(`[detect] consecutive frames ${f} vs ${f+1}: DIFF`);
    299         break;
    300       }
    301     }
    302     // Also compare first frame against the late frame (catches 1000ms drop intervals)
    303     if (!changed && !screenshots[0].equals(lateFrame)) {
    304       changed = true;
    305       console.log(`[detect] first vs late frame: DIFF`);
    306     }
    307     if (!changed) console.log(`[detect] all frames identical (no animation)`);
    308   }
    309 
    310   // gameplayDetected: if something changed, assume gameplay (simplification).
    311   // The old Level 2 "downward movement" check was unreliable due to sampling issues.
    312   // Grid reader in later phases verifies actual gameplay definitively.
    313   return { changed, gameplayDetected: changed };
    314 }
    315 
    316 /**
    317  * Verify that the game is actually interactive -- gameplay inputs cause
    318  * visible state changes. This distinguishes a truly started game from
    319  * animations, overlays, or other false positives.
    320  *
    321  * Sends ArrowLeft then ArrowRight and checks if the page responds.
    322  * A game that started will move a piece; a static page won't change.
    323  */
    324 async function verifyInteractivity(page: Page): Promise<boolean> {
    325   try {
    326     // Wait for at least one render frame before baseline
    327     await page.waitForTimeout(200);
    328 
    329     // Capture baseline: both screenshot and DOM state
    330     const baseline = await page.screenshot();
    331     const domBefore = await page.evaluate(() => {
    332       // Snapshot the largest grid-like container's child class/style state
    333       const candidates = document.querySelectorAll(
    334         '[class*="board"], [class*="grid"], [class*="field"], [id*="board"], [id*="grid"], table'
    335       );
    336       let best = "";
    337       for (const el of candidates) {
    338         const snap = Array.from(el.children).map(c =>
    339           (c as HTMLElement).className + (c as HTMLElement).style.cssText
    340         ).join("|");
    341         if (snap.length > best.length) best = snap;
    342       }
    343       // Also capture body innerHTML hash as fallback
    344       if (!best) best = document.body.innerHTML.substring(0, 5000);
    345       return best;
    346     });
    347 
    348     // Try multiple inputs
    349     for (const key of ["ArrowLeft", "ArrowRight", "ArrowDown"]) {
    350       await page.keyboard.press(key);
    351       await page.waitForTimeout(200);
    352 
    353       // Check screenshot change
    354       const after = await page.screenshot();
    355       if (!baseline.equals(after)) {
    356         return true;
    357       }
    358 
    359       // Check DOM state change (catches games where screenshot is identical
    360       // but DOM classes/styles changed -- e.g. innerHTML-rebuilt grids)
    361       const domAfter = await page.evaluate(() => {
    362         const candidates = document.querySelectorAll(
    363           '[class*="board"], [class*="grid"], [class*="field"], [id*="board"], [id*="grid"], table'
    364         );
    365         let best = "";
    366         for (const el of candidates) {
    367           const snap = Array.from(el.children).map(c =>
    368             (c as HTMLElement).className + (c as HTMLElement).style.cssText
    369           ).join("|");
    370           if (snap.length > best.length) best = snap;
    371         }
    372         if (!best) best = document.body.innerHTML.substring(0, 5000);
    373         return best;
    374       });
    375       if (domAfter !== domBefore) {
    376         return true;
    377       }
    378     }
    379     return false;
    380   } catch {
    381     return false;
    382   }
    383 }
    384 
    385 /**
    386  * Cluster adjacent points using flood fill.
    387  * Two points are adjacent if they differ by at most 1 in both row and column.
    388  */
    389 function clusterPoints(points: [number, number][]): [number, number][][] {
    390   const clusters: [number, number][][] = [];
    391   const visited = new Set<string>();
    392 
    393   for (const [r, c] of points) {
    394     const key = `${r},${c}`;
    395     if (visited.has(key)) continue;
    396 
    397     const cluster: [number, number][] = [];
    398     const stack: [number, number][] = [[r, c]];
    399     visited.add(key);
    400 
    401     while (stack.length > 0) {
    402       const [cr, cc] = stack.pop()!;
    403       cluster.push([cr, cc]);
    404 
    405       // Check all 8 neighbors
    406       for (let dr = -1; dr <= 1; dr++) {
    407         for (let dc = -1; dc <= 1; dc++) {
    408           if (dr === 0 && dc === 0) continue;
    409           const nr = cr + dr;
    410           const nc = cc + dc;
    411           const nk = `${nr},${nc}`;
    412           if (!visited.has(nk) && points.some(([pr, pc]) => pr === nr && pc === nc)) {
    413             visited.add(nk);
    414             stack.push([nr, nc]);
    415           }
    416         }
    417       }
    418     }
    419 
    420     clusters.push(cluster);
    421   }
    422 
    423   return clusters;
    424 }
    425 
    426 /** Result of the 5-phase start detection. */
    427 interface StartDetectionResult {
    428   mechanism: StartMechanism;
    429   startButton?: CalibrationResult["startButton"];
    430 }
    431 
    432 /**
    433  * 5-phase start detection. Fully language-agnostic, visual-first.
    434  * No text matching of any kind -- detection is purely structural and behavioral.
    435  *
    436  * Phase 1: Auto-start (no input, visual change detection)
    437  * Phase 2: Keyboard triggers (Enter, Space, ArrowDown, Z -- fast, universal)
    438  * Phase 3: DOM button discovery (click all clickable elements by visual prominence)
    439  * Phase 4: Canvas click grid (for canvas-rendered buttons)
    440  * Phase 5: Retry phases 2-4 (some games need two interactions)
    441  *
    442  * Total budget: 30 seconds.
    443  */
    444 async function detectStartMechanism(page: Page): Promise<StartDetectionResult> {
    445   const deadline = Date.now() + 30000;
    446   const log = (msg: string) => console.log(`[start-detect] ${msg}`);
    447 
    448   const budgetExceeded = () => Date.now() >= deadline;
    449 
    450   // Quick diagnostic: what's on the page?
    451   try {
    452     const diag = await page.evaluate(() => ({
    453       title: document.title,
    454       buttons: Array.from(document.querySelectorAll("button")).map(b => b.textContent?.trim()),
    455       canvases: Array.from(document.querySelectorAll("canvas")).length,
    456       bodySize: document.body?.innerHTML?.length ?? 0,
    457     }));
    458     log(`Page: "${diag.title}", ${diag.buttons.length} buttons [${diag.buttons.join(", ")}], ${diag.canvases} canvases, body=${diag.bodySize} chars`);
    459   } catch (e) { log(`Diagnostic failed: ${e}`); }
    460 
    461   // ---- Phase 1: Auto-start (no input, ~2.5 seconds with late check) ----
    462   {
    463     log("Phase 1: checking auto-start...");
    464     const result = await detectVisualChange(page, { frames: 6, intervalMs: 200 });
    465     log(`Phase 1 result: changed=${result.changed}`);
    466     if (result.changed) {
    467       const interactive = await verifyInteractivity(page);
    468       if (interactive) {
    469         return { mechanism: "auto" };
    470       }
    471       log("Phase 1: visual change detected but game not interactive (animation?)");
    472     }
    473   }
    474 
    475   // ---- Phase 2: Keyboard triggers (fast, language-agnostic) ----
    476   if (!budgetExceeded()) {
    477     log("Phase 2: trying keyboard triggers...");
    478     const phase2Result = await tryKeyboardTriggers(page, budgetExceeded);
    479     if (phase2Result) {
    480       log(`Phase 2 result: found=${phase2Result.mechanism}`);
    481       return phase2Result;
    482     }
    483     log("Phase 2 result: none");
    484   }
    485 
    486   // ---- Phase 3: DOM button discovery (language-agnostic, visual-only) ----
    487   if (!budgetExceeded()) {
    488     log("Phase 3: trying DOM buttons...");
    489     const phase3Result = await tryDomButtons(page, budgetExceeded);
    490     log(`Phase 3 result: ${phase3Result ? `found=${phase3Result.mechanism}` : "none"}`);
    491     if (phase3Result) return phase3Result;
    492   }
    493 
    494   // ---- Phase 4: Canvas click grid ----
    495   if (!budgetExceeded()) {
    496     log("Phase 4: trying canvas clicks...");
    497     const phase4Result = await tryCanvasClicks(page, budgetExceeded);
    498     if (phase4Result) return phase4Result;
    499   }
    500 
    501   // ---- Phase 5: Retry phases 2-4 (some games need two interactions) ----
    502   if (!budgetExceeded()) {
    503     const phase2Retry = await tryKeyboardTriggers(page, budgetExceeded);
    504     if (phase2Retry) return phase2Retry;
    505   }
    506   if (!budgetExceeded()) {
    507     const phase3Retry = await tryDomButtons(page, budgetExceeded);
    508     if (phase3Retry) return phase3Retry;
    509   }
    510   if (!budgetExceeded()) {
    511     const phase4Retry = await tryCanvasClicks(page, budgetExceeded);
    512     if (phase4Retry) return phase4Retry;
    513   }
    514 
    515   return { mechanism: "unknown" };
    516 }
    517 
    518 /**
    519  * Phase 2: Find all clickable DOM elements (fully language-agnostic, no text matching).
    520  * Finds buttons, anchors, role=button, onclick, and cursor:pointer elements.
    521  * Sort by visual prominence (size, centrality, contrast). Click each and observe.
    522  */
    523 async function tryDomButtons(
    524   page: Page,
    525   budgetExceeded: () => boolean
    526 ): Promise<StartDetectionResult | null> {
    527   try {
    528     // Gather element info (position, size, text) for sorting -- purely structural/visual
    529     const elementInfos = await page.evaluate(() => {
    530       const seen = new Set<Element>();
    531       const results: Array<{
    532         index: number;
    533         text: string;
    534         x: number;
    535         y: number;
    536         width: number;
    537         height: number;
    538         area: number;
    539         centerDist: number;
    540         selector: string;
    541         hasBackground: boolean;
    542       }> = [];
    543 
    544       // Phase A: structural clickable elements (type-based, no text matching)
    545       const clickableSelector =
    546         'button, a, [role="button"], [onclick], input[type="button"], input[type="submit"]';
    547       for (const el of document.querySelectorAll(clickableSelector)) {
    548         if (!seen.has(el)) seen.add(el);
    549       }
    550 
    551       // Phase B: elements with cursor:pointer computed style (catches custom divs/spans acting as buttons)
    552       const allEls = document.querySelectorAll("*");
    553       for (const el of allEls) {
    554         if (seen.has(el)) continue;
    555         try {
    556           const style = window.getComputedStyle(el);
    557           if (style.cursor === "pointer") {
    558             seen.add(el);
    559           }
    560         } catch { /* skip */ }
    561       }
    562 
    563       const pageW = window.innerWidth;
    564       const pageH = window.innerHeight;
    565       const pageCenterX = pageW / 2;
    566       const pageCenterY = pageH / 2;
    567 
    568       let idx = 0;
    569       for (const el of seen) {
    570         const rect = el.getBoundingClientRect();
    571         if (rect.width < 5 || rect.height < 5) continue;
    572         if (rect.top > pageH || rect.left > pageW) continue;
    573         // Skip elements that cover most of the viewport (overlays, not buttons)
    574         if (rect.width > pageW * 0.8 && rect.height > pageH * 0.8) continue;
    575 
    576         const cx = rect.left + rect.width / 2;
    577         const cy = rect.top + rect.height / 2;
    578         const centerDist = Math.sqrt((cx - pageCenterX) ** 2 + (cy - pageCenterY) ** 2);
    579 
    580         // Check if element has a distinct background (high contrast, likely a button)
    581         let hasBackground = false;
    582         try {
    583           const style = window.getComputedStyle(el as HTMLElement);
    584           const bg = style.backgroundColor;
    585           // transparent or rgba(0,0,0,0) means no background
    586           if (bg && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)") {
    587             hasBackground = true;
    588           }
    589         } catch { /* skip */ }
    590 
    591         let selector = "";
    592         if (el.id) {
    593           selector = `#${el.id}`;
    594         } else if ((el as HTMLElement).className) {
    595           const cls = (el as HTMLElement).className.toString().split(" ")[0];
    596           if (cls) selector = `${el.tagName.toLowerCase()}.${cls}`;
    597         }
    598         if (!selector) selector = `${el.tagName.toLowerCase()}:nth-of-type(${idx + 1})`;
    599 
    600         results.push({
    601           index: idx,
    602           text: (el.textContent || "").trim().slice(0, 50),
    603           x: Math.round(cx),
    604           y: Math.round(cy),
    605           width: rect.width,
    606           height: rect.height,
    607           area: rect.width * rect.height,
    608           centerDist,
    609           selector,
    610           hasBackground,
    611         });
    612         idx++;
    613       }
    614 
    615       // Sort by visual prominence:
    616       // 1. Elements with background first (more likely to be buttons)
    617       // 2. Larger elements first
    618       // 3. Closer to center preferred
    619       results.sort((a, b) => {
    620         if (a.hasBackground !== b.hasBackground) return a.hasBackground ? -1 : 1;
    621         if (Math.abs(b.area - a.area) > 100) return b.area - a.area;
    622         return a.centerDist - b.centerDist;
    623       });
    624 
    625       return results;
    626     });
    627 
    628     console.log(`[start-detect] Phase 2: found ${elementInfos.length} clickable elements`);
    629     // Click each element and observe for visual change
    630     for (const info of elementInfos) {
    631       if (budgetExceeded()) break;
    632 
    633       try {
    634         // Check if element still exists before clicking
    635         const wasVisible = await page.evaluate(
    636           ({ x, y }) => {
    637             const el = document.elementFromPoint(x, y);
    638             return el !== null;
    639           },
    640           { x: info.x, y: info.y }
    641         );
    642         if (!wasVisible) continue;
    643 
    644         // Take "before" screenshot, then click, then compare
    645         const before = await page.screenshot();
    646         console.log(`[start-detect] Clicking "${info.text}" (${info.selector}) at (${info.x},${info.y}), before=${before.length} bytes`);
    647         await page.mouse.click(info.x, info.y);
    648         await page.waitForTimeout(100);
    649 
    650         const result = await detectVisualChange(page, { frames: 3, intervalMs: 100, before });
    651         console.log(`[start-detect] After click "${info.text}": changed=${result.changed}`);
    652         if (result.changed) {
    653           // Wait for the game to fully initialize after button click
    654           await page.waitForTimeout(300);
    655           // Verify the game is actually interactive after clicking this button
    656           const interactive = await verifyInteractivity(page);
    657           if (!interactive) {
    658             console.log(`[start-detect] Button "${info.text}" caused visual change but game not interactive, continuing...`);
    659             // Try pressing Escape to undo and continue
    660             try { await page.keyboard.press("Escape"); await page.waitForTimeout(50); } catch {}
    661             continue;
    662           }
    663 
    664           // Check if the element disappeared after clicking
    665           const disappeared = await page.evaluate(
    666             ({ selector }) => {
    667               if (!selector) return false;
    668               try {
    669                 const el = document.querySelector(selector);
    670                 if (!el) return true;
    671                 const rect = el.getBoundingClientRect();
    672                 return rect.width === 0 || rect.height === 0;
    673               } catch {
    674                 return false;
    675               }
    676             },
    677             { selector: info.selector }
    678           );
    679 
    680           return {
    681             mechanism: "button",
    682             startButton: {
    683               selector: info.selector,
    684               text: info.text,
    685               disappeared,
    686               position: { x: info.x, y: info.y },
    687             },
    688           };
    689         }
    690 
    691         // No change -- try pressing Escape to undo any menu we opened
    692         try {
    693           await page.keyboard.press("Escape");
    694           await page.waitForTimeout(50);
    695         } catch { /* ignore */ }
    696       } catch { /* continue to next element */ }
    697     }
    698   } catch { /* phase 2 failed entirely */ }
    699 
    700   return null;
    701 }
    702 
    703 /**
    704  * Phase 3: Click the canvas at strategic positions.
    705  * Center first, then upper-center, lower-center, then a 3x3 grid.
    706  */
    707 async function tryCanvasClicks(
    708   page: Page,
    709   budgetExceeded: () => boolean
    710 ): Promise<StartDetectionResult | null> {
    711   // Find the canvas or primary game container
    712   let targetBox: { x: number; y: number; width: number; height: number } | null = null;
    713 
    714   try {
    715     const canvas = page.locator("canvas").first();
    716     if ((await canvas.count()) > 0) {
    717       targetBox = await canvas.boundingBox();
    718     }
    719   } catch { /* no canvas */ }
    720 
    721   if (!targetBox) {
    722     // Try the viewport itself
    723     const viewport = page.viewportSize();
    724     if (viewport) {
    725       targetBox = { x: 0, y: 0, width: viewport.width, height: viewport.height };
    726     }
    727   }
    728 
    729   if (!targetBox) return null;
    730 
    731   const cx = targetBox.x + targetBox.width / 2;
    732   const cy = targetBox.y + targetBox.height / 2;
    733 
    734   // Click positions: center, upper-center, lower-center, then 3x3 grid
    735   const positions: Array<{ x: number; y: number; label: string }> = [
    736     { x: cx, y: cy, label: "center" },
    737     { x: cx, y: targetBox.y + targetBox.height * 0.25, label: "upper-center" },
    738     { x: cx, y: targetBox.y + targetBox.height * 0.75, label: "lower-center" },
    739   ];
    740 
    741   // Add 3x3 grid positions (skip center since we already have it)
    742   for (let row = 0; row < 3; row++) {
    743     for (let col = 0; col < 3; col++) {
    744       if (row === 1 && col === 1) continue; // skip center duplicate
    745       positions.push({
    746         x: targetBox.x + targetBox.width * (col + 0.5) / 3,
    747         y: targetBox.y + targetBox.height * (row + 0.5) / 3,
    748         label: `grid_${row}_${col}`,
    749       });
    750     }
    751   }
    752 
    753   for (const pos of positions) {
    754     if (budgetExceeded()) break;
    755 
    756     try {
    757       const before = await page.screenshot();
    758       await page.mouse.click(pos.x, pos.y);
    759       await page.waitForTimeout(100);
    760 
    761       const result = await detectVisualChange(page, { frames: 3, intervalMs: 100, before });
    762       if (result.changed) {
    763         const interactive = await verifyInteractivity(page);
    764         if (interactive) {
    765           return {
    766             mechanism: "click_canvas",
    767             startButton: {
    768               selector: "canvas",
    769               text: `canvas click at ${pos.label}`,
    770               disappeared: false,
    771               position: { x: Math.round(pos.x), y: Math.round(pos.y) },
    772             },
    773           };
    774         }
    775         console.log(`[start-detect] Canvas click at ${pos.label} caused change but not interactive`);
    776       }
    777     } catch { /* continue */ }
    778   }
    779 
    780   return null;
    781 }
    782 
    783 /**
    784  * Phase 4: Keyboard triggers.
    785  * Try Enter, Space, ArrowDown, Z individually,
    786  * then click-then-Enter and click-then-Space combos.
    787  */
    788 async function tryKeyboardTriggers(
    789   page: Page,
    790   budgetExceeded: () => boolean
    791 ): Promise<StartDetectionResult | null> {
    792   const mechanismMap: Record<string, StartMechanism> = {
    793     Enter: "enter",
    794     Space: "space",
    795     ArrowDown: "anykey",
    796     z: "anykey",
    797   };
    798 
    799   // Single key presses
    800   for (const key of ["Enter", "Space", "ArrowDown", "z"]) {
    801     if (budgetExceeded()) break;
    802 
    803     try {
    804       const before = await page.screenshot();
    805       await page.keyboard.press(key);
    806       await page.waitForTimeout(100);
    807 
    808       const result = await detectVisualChange(page, { frames: 3, intervalMs: 100, before });
    809       if (result.changed) {
    810         // Verify the game is actually interactive, not just an animation
    811         const interactive = await verifyInteractivity(page);
    812         if (interactive) {
    813           return { mechanism: mechanismMap[key] };
    814         }
    815         console.log(`[start-detect] ${key} caused visual change but game not interactive, continuing...`);
    816       }
    817     } catch { /* continue */ }
    818   }
    819 
    820   // Combo: click canvas center, then Enter / Space
    821   for (const key of ["Enter", "Space"]) {
    822     if (budgetExceeded()) break;
    823 
    824     try {
    825       const before = await page.screenshot();
    826       const canvas = page.locator("canvas").first();
    827       if ((await canvas.count()) > 0) {
    828         await canvas.click();
    829       } else {
    830         const viewport = page.viewportSize();
    831         if (viewport) {
    832           await page.mouse.click(viewport.width / 2, viewport.height / 2);
    833         }
    834       }
    835       await page.waitForTimeout(100);
    836       await page.keyboard.press(key);
    837       await page.waitForTimeout(100);
    838 
    839       const result = await detectVisualChange(page, { frames: 3, intervalMs: 100, before });
    840       if (result.changed) {
    841         const interactive = await verifyInteractivity(page);
    842         if (interactive) {
    843           return { mechanism: mechanismMap[key] };
    844         }
    845         console.log(`[start-detect] ${key}+click caused visual change but game not interactive, continuing...`);
    846       }
    847     } catch { /* continue */ }
    848   }
    849 
    850   return null;
    851 }
    852 
    853 /**
    854  * Re-calibration fallback: try start mechanisms again with longer waits,
    855  * re-scanning for the grid after each attempt. Used when the first pass
    856  * failed to detect the start mechanism or the grid.
    857  *
    858  * Uses detectVisualChange() to confirm the game responded.
    859  */
    860 async function recalibrateWithRetry(
    861   page: Page,
    862   currentStart: StartMechanism,
    863   currentGrid: GridBounds | null
    864 ): Promise<GridDetection & { startMechanism: StartMechanism }> {
    865   let startMechanism: StartMechanism = currentStart;
    866   let gridResult: GridDetection = {
    867     renderer: "unknown",
    868     gridBounds: currentGrid,
    869     cellWidth: 0,
    870     cellHeight: 0,
    871   };
    872 
    873   const attempts: Array<{ name: StartMechanism; action: () => Promise<void> }> = [
    874     {
    875       name: "click_canvas",
    876       action: async () => {
    877         const canvas = page.locator("canvas").first();
    878         if ((await canvas.count()) > 0) await canvas.click();
    879       },
    880     },
    881     {
    882       name: "click_canvas",
    883       action: async () => {
    884         await page.locator("body").click({ position: { x: 200, y: 200 } });
    885       },
    886     },
    887     {
    888       name: "enter",
    889       action: async () => { await page.keyboard.press("Enter"); },
    890     },
    891     {
    892       name: "space",
    893       action: async () => { await page.keyboard.press("Space"); },
    894     },
    895     {
    896       name: "button",
    897       action: async () => {
    898         const btn = page.locator("button").first();
    899         if ((await btn.count()) > 0) await btn.click();
    900       },
    901     },
    902     {
    903       name: "anykey",
    904       action: async () => { await page.keyboard.press("ArrowDown"); },
    905     },
    906   ];
    907 
    908   for (const attempt of attempts) {
    909     try {
    910       const before = await page.screenshot();
    911       await attempt.action();
    912       await page.waitForTimeout(100);
    913 
    914       if (startMechanism === "unknown") {
    915         const result = await detectVisualChange(page, { frames: 3, intervalMs: 100, before });
    916         if (result.changed) {
    917           startMechanism = attempt.name;
    918         }
    919       }
    920 
    921       // Re-scan for grid after each attempt
    922       if (!gridResult.gridBounds) {
    923         const detected = await detectGrid(page);
    924         if (detected.gridBounds) {
    925           gridResult = detected;
    926         }
    927       }
    928 
    929       // If we have both, stop early
    930       if (startMechanism !== "unknown" && gridResult.gridBounds) {
    931         break;
    932       }
    933     } catch { /* continue */ }
    934   }
    935 
    936   return { ...gridResult, startMechanism };
    937 }
    938 
    939 interface GridDetection {
    940   renderer: RendererType;
    941   gridBounds: GridBounds | null;
    942   cellWidth: number;
    943   cellHeight: number;
    944 }
    945 
    946 /**
    947  * Detect the game grid: canvas, DOM-based, or SVG.
    948  */
    949 async function detectGrid(page: Page): Promise<GridDetection> {
    950   // Check for canvas
    951   try {
    952     const canvasCount = await page.locator("canvas").count();
    953     if (canvasCount > 0) {
    954       const bounds = await page.locator("canvas").first().boundingBox();
    955       if (bounds) {
    956         // Try to get the canvas internal dimensions (which may differ from CSS size)
    957         const canvasDims = await page.evaluate(() => {
    958           const c = document.querySelector("canvas");
    959           if (!c) return null;
    960           return { width: c.width, height: c.height };
    961         });
    962 
    963         const internalW = canvasDims ? canvasDims.width : bounds.width;
    964         const internalH = canvasDims ? canvasDims.height : bounds.height;
    965 
    966         // Standard Tetris grid is 10 cols by 20 rows
    967         // The canvas might include sidebars, so try to detect the grid area
    968         // Heuristic: if the aspect ratio is close to 1:2, the whole canvas is the grid
    969         const ratio = internalH / internalW;
    970 
    971         let gridX = 0;
    972         let gridY = 0;
    973         let gridW = internalW;
    974         let gridH = internalH;
    975 
    976         if (ratio >= 1.5 && ratio <= 2.5) {
    977           // Looks like the whole canvas is the grid
    978           gridX = 0;
    979           gridY = 0;
    980           gridW = internalW;
    981           gridH = internalH;
    982         } else if (ratio < 1.5) {
    983           // Canvas is wider than expected -- grid is probably a subset
    984           // Assume grid is centered or left-aligned with 1:2 aspect ratio
    985           gridW = internalH / 2;
    986           gridH = internalH;
    987           gridX = 0; // left-aligned by default
    988           gridY = 0;
    989         }
    990 
    991         const cellWidth = gridW / 10;
    992         const cellHeight = gridH / 20;
    993 
    994         return {
    995           renderer: "canvas" as RendererType,
    996           gridBounds: { x: gridX, y: gridY, width: gridW, height: gridH },
    997           cellWidth,
    998           cellHeight,
    999         };
   1000       }
   1001     }
   1002   } catch { /* continue */ }
   1003 
   1004   // Check for DOM-based grid
   1005   try {
   1006     const domResult = await page.evaluate(() => {
   1007       // Look for table-based grids
   1008       const tables = document.querySelectorAll("table");
   1009       for (const table of tables) {
   1010         const rows = table.querySelectorAll("tr");
   1011         if (rows.length >= 18) {
   1012           // Likely a Tetris grid (might be 18-22 rows)
   1013           const firstRow = rows[0].querySelectorAll("td");
   1014           if (firstRow.length >= 8 && firstRow.length <= 12) {
   1015             const rect = table.getBoundingClientRect();
   1016             return {
   1017               type: "dom" as const,
   1018               bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
   1019               rows: rows.length,
   1020               cols: firstRow.length,
   1021             };
   1022           }
   1023         }
   1024       }
   1025 
   1026       // Look for grid/flex containers
   1027       const containers = document.querySelectorAll(
   1028         '[class*="board"], [class*="grid"], [class*="field"], [id*="board"], [id*="grid"], [id*="field"]'
   1029       );
   1030       for (const container of containers) {
   1031         const children = container.children;
   1032         // Flat list of 200 cells (or close to it)
   1033         if (children.length >= 180 && children.length <= 220) {
   1034           const cols = 10;
   1035           const rows = Math.round(children.length / cols);
   1036           const rect = container.getBoundingClientRect();
   1037           return {
   1038             type: "dom" as const,
   1039             bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
   1040             rows,
   1041             cols,
   1042           };
   1043         }
   1044         // 20 row containers
   1045         if (children.length >= 18 && children.length <= 22) {
   1046           const firstRowCells = children[0].children;
   1047           if (firstRowCells.length >= 8 && firstRowCells.length <= 12) {
   1048             const rect = container.getBoundingClientRect();
   1049             return {
   1050               type: "dom" as const,
   1051               bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
   1052               rows: children.length,
   1053               cols: firstRowCells.length,
   1054             };
   1055           }
   1056         }
   1057       }
   1058 
   1059       // Heuristic scan: look for ANY container with many same-sized children
   1060       // arranged in a grid pattern, even without specific class/id naming
   1061       const allElements = document.querySelectorAll("div, section, main, article");
   1062       for (const el of allElements) {
   1063         const ch = el.children;
   1064         // Flat list of ~200 cells (10x20)
   1065         if (ch.length >= 180 && ch.length <= 220) {
   1066           const firstChild = ch[0] as HTMLElement;
   1067           if (!firstChild) continue;
   1068           const firstRect = firstChild.getBoundingClientRect();
   1069           if (firstRect.width < 5 || firstRect.height < 5) continue;
   1070           let uniform = true;
   1071           for (let i = 1; i < Math.min(10, ch.length); i++) {
   1072             const r = (ch[i] as HTMLElement).getBoundingClientRect();
   1073             if (Math.abs(r.width - firstRect.width) > 2 || Math.abs(r.height - firstRect.height) > 2) {
   1074               uniform = false;
   1075               break;
   1076             }
   1077           }
   1078           if (uniform) {
   1079             const cols = 10;
   1080             const rows = Math.round(ch.length / cols);
   1081             const rect = el.getBoundingClientRect();
   1082             return {
   1083               type: "dom" as const,
   1084               bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
   1085               rows,
   1086               cols,
   1087             };
   1088           }
   1089         }
   1090         // Container with ~20 row children, each having ~10 cell children
   1091         if (ch.length >= 18 && ch.length <= 22) {
   1092           const firstRowCells = ch[0]?.children;
   1093           if (firstRowCells && firstRowCells.length >= 8 && firstRowCells.length <= 12) {
   1094             const rect = el.getBoundingClientRect();
   1095             if (rect.width > 50 && rect.height > 100) {
   1096               return {
   1097                 type: "dom" as const,
   1098                 bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
   1099                 rows: ch.length,
   1100                 cols: firstRowCells.length,
   1101               };
   1102             }
   1103           }
   1104         }
   1105       }
   1106 
   1107       return null;
   1108     });
   1109 
   1110     if (domResult) {
   1111       const cellWidth = domResult.bounds.width / domResult.cols;
   1112       const cellHeight = domResult.bounds.height / domResult.rows;
   1113       return {
   1114         renderer: "dom",
   1115         gridBounds: domResult.bounds,
   1116         cellWidth,
   1117         cellHeight,
   1118       };
   1119     }
   1120   } catch { /* continue */ }
   1121 
   1122   // Check for SVG
   1123   try {
   1124     const svgCount = await page.locator("svg").count();
   1125     if (svgCount > 0) {
   1126       const bounds = await page.locator("svg").first().boundingBox();
   1127       if (bounds) {
   1128         return {
   1129           renderer: "svg",
   1130           gridBounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
   1131           cellWidth: bounds.width / 10,
   1132           cellHeight: bounds.height / 20,
   1133         };
   1134       }
   1135     }
   1136   } catch { /* continue */ }
   1137 
   1138   return { renderer: "unknown", gridBounds: null, cellWidth: 0, cellHeight: 0 };
   1139 }
   1140 
   1141 /**
   1142  * Detect which keys the game responds to for movement and rotation.
   1143  */
   1144 async function detectControls(page: Page): Promise<Controls> {
   1145   const controls: Controls = { ...DEFAULT_CONTROLS };
   1146 
   1147   // First, scan the page for control hints
   1148   try {
   1149     const pageText = await page.evaluate(() => document.body.innerText.toLowerCase());
   1150 
   1151     if (pageText.includes("wasd") || pageText.includes("w,a,s,d")) {
   1152       controls.left = "a";
   1153       controls.right = "d";
   1154       controls.down = "s";
   1155       controls.rotate = "w";
   1156     }
   1157     if (/z\s*(=|:)?\s*rotate/i.test(pageText) || /rotate\s*(=|:)?\s*z/i.test(pageText)) {
   1158       controls.rotate = "z";
   1159     }
   1160     if (/x\s*(=|:)?\s*rotate/i.test(pageText) || /rotate\s*(=|:)?\s*x/i.test(pageText)) {
   1161       controls.rotate = "x";
   1162     }
   1163   } catch { /* use defaults */ }
   1164 
   1165   // Verify left key works by pressing and checking for visual change
   1166   try {
   1167     const before = await page.screenshot();
   1168     await page.keyboard.press(controls.left);
   1169     await page.waitForTimeout(200);
   1170     const after = await page.screenshot();
   1171 
   1172     if (Buffer.from(before).equals(Buffer.from(after))) {
   1173       // ArrowLeft didn't work, try "a"
   1174       await page.keyboard.press("a");
   1175       await page.waitForTimeout(200);
   1176       const afterA = await page.screenshot();
   1177       if (!Buffer.from(before).equals(Buffer.from(afterA))) {
   1178         controls.left = "a";
   1179         controls.right = "d";
   1180         controls.down = "s";
   1181         controls.rotate = "w";
   1182       }
   1183     }
   1184   } catch { /* use defaults */ }
   1185 
   1186   // Verify rotate key
   1187   try {
   1188     const before = await page.screenshot();
   1189     await page.keyboard.press(controls.rotate);
   1190     await page.waitForTimeout(200);
   1191     const after = await page.screenshot();
   1192 
   1193     if (Buffer.from(before).equals(Buffer.from(after))) {
   1194       // Try alternative rotate keys
   1195       for (const alt of ["z", "x", "ArrowUp"]) {
   1196         if (alt === controls.rotate) continue;
   1197         await page.keyboard.press(alt);
   1198         await page.waitForTimeout(200);
   1199         const afterAlt = await page.screenshot();
   1200         if (!Buffer.from(before).equals(Buffer.from(afterAlt))) {
   1201           controls.rotate = alt;
   1202           break;
   1203         }
   1204       }
   1205     }
   1206   } catch { /* use defaults */ }
   1207 
   1208   return controls;
   1209 }
   1210 
   1211 /**
   1212  * Find the score display element on the page.
   1213  *
   1214  * Prefers elements that contain ONLY the score number (a child element
   1215  * whose textContent is a standalone number). This avoids selecting a
   1216  * parent that concatenates "Score: 100Level: 1Lines: 5" into one text node.
   1217  */
   1218 async function detectScoreElement(page: Page): Promise<string | null> {
   1219   try {
   1220     const selector = await page.evaluate(() => {
   1221       function _buildSelector(el: Element): string | null {
   1222         if (el.id) return `#${el.id}`;
   1223         if ((el as HTMLElement).className) {
   1224           const cls = (el as HTMLElement).className.split(" ")[0];
   1225           if (cls) return `.${cls}`;
   1226         }
   1227         return null;
   1228       }
   1229 
   1230       // Strategy 1: Find a child element near "score" text that contains
   1231       // ONLY a single number (the narrowest, most reliable match).
   1232       const allElements = document.querySelectorAll("*");
   1233       for (const el of allElements) {
   1234         const text = (el as HTMLElement).innerText?.toLowerCase() || "";
   1235         if (text.includes("score") && el.children.length < 10) {
   1236           // Look for a child/descendant with ONLY a number
   1237           const descendants = el.querySelectorAll("span, div, p, td, strong, em, b");
   1238           for (const desc of descendants) {
   1239             const descText = desc.textContent?.trim() || "";
   1240             if (/^\d+$/.test(descText) && desc.children.length === 0) {
   1241               const sel = _buildSelector(desc);
   1242               if (sel) return sel;
   1243             }
   1244           }
   1245 
   1246           // Check siblings of the "score" label element
   1247           const next = el.nextElementSibling;
   1248           if (next) {
   1249             const nextText = next.textContent?.trim() || "";
   1250             if (/^\d+$/.test(nextText)) {
   1251               const sel = _buildSelector(next);
   1252               if (sel) return sel;
   1253             }
   1254           }
   1255 
   1256           // Fall back to the element itself, but only if it looks reasonable.
   1257           // Check if the element's own text (without children) contains "score"
   1258           // and has a parseable score value.
   1259           const sel = _buildSelector(el);
   1260           if (sel) return sel;
   1261         }
   1262       }
   1263 
   1264       // Strategy 2: Look for labeled text like "Score: 123" in leaf elements
   1265       for (const el of allElements) {
   1266         if (el.children.length > 3) continue;
   1267         const text = (el as HTMLElement).textContent?.trim() || "";
   1268         const scoreMatch = text.match(/score\s*[:\-=]?\s*(\d+)/i);
   1269         if (scoreMatch) {
   1270           // This element contains labeled score text
   1271           const sel = _buildSelector(el);
   1272           if (sel) return sel;
   1273         }
   1274       }
   1275 
   1276       // Strategy 3: Fallback: look for leaf elements that contain just a number
   1277       const candidates: HTMLElement[] = [];
   1278       for (const el of allElements) {
   1279         const text = (el as HTMLElement).textContent?.trim() || "";
   1280         if (/^\d+$/.test(text) && el.children.length === 0) {
   1281           candidates.push(el as HTMLElement);
   1282         }
   1283       }
   1284       if (candidates.length > 0) {
   1285         const el = candidates[0];
   1286         const sel = _buildSelector(el);
   1287         if (sel) return sel;
   1288       }
   1289 
   1290       return null;
   1291     });
   1292 
   1293     return selector;
   1294   } catch {
   1295     return null;
   1296   }
   1297 }
   1298 
   1299 /**
   1300  * Survey the page before any tests run. Collects information about the page
   1301  * structure that helps inform start mechanism detection and debugging.
   1302  */
   1303 export async function surveyPage(page: Page): Promise<SurveyData> {
   1304   try {
   1305     const data = await page.evaluate(() => {
   1306       // Check for full-screen overlay (language-agnostic: purely structural detection)
   1307       let hasOverlay = false;
   1308       const allEls = document.querySelectorAll("*");
   1309       const vw = window.innerWidth;
   1310       const vh = window.innerHeight;
   1311       for (const el of allEls) {
   1312         const style = window.getComputedStyle(el);
   1313         const pos = style.position;
   1314         if (pos === "fixed" || pos === "absolute") {
   1315           const zIndex = parseInt(style.zIndex, 10);
   1316           if (zIndex > 0 || style.zIndex === "auto") {
   1317             const rect = (el as HTMLElement).getBoundingClientRect();
   1318             if (rect.width > vw * 0.5 && rect.height > vh * 0.5) {
   1319               // Large positioned overlay detected -- no text matching needed
   1320               hasOverlay = true;
   1321               break;
   1322             }
   1323           }
   1324         }
   1325       }
   1326 
   1327       // Check for canvas
   1328       const hasCanvas = document.querySelectorAll("canvas").length > 0;
   1329 
   1330       // Check for DOM grid
   1331       let hasDomGrid = false;
   1332       const containers = document.querySelectorAll(
   1333         '[class*="board"], [class*="grid"], [class*="field"], [id*="board"], [id*="grid"], [id*="field"]'
   1334       );
   1335       for (const container of containers) {
   1336         const children = container.children;
   1337         if (
   1338           (children.length >= 180 && children.length <= 220) ||
   1339           (children.length >= 18 && children.length <= 22 &&
   1340             children[0]?.children.length >= 8 && children[0]?.children.length <= 12)
   1341         ) {
   1342           hasDomGrid = true;
   1343           break;
   1344         }
   1345       }
   1346       // Also check tables
   1347       if (!hasDomGrid) {
   1348         const tables = document.querySelectorAll("table");
   1349         for (const table of tables) {
   1350           const rows = table.querySelectorAll("tr");
   1351           if (rows.length >= 18) {
   1352             const cols = rows[0]?.querySelectorAll("td").length ?? 0;
   1353             if (cols >= 8 && cols <= 12) {
   1354               hasDomGrid = true;
   1355               break;
   1356             }
   1357           }
   1358         }
   1359       }
   1360 
   1361       // Visible text (first 500 chars, split into lines)
   1362       const bodyText = (document.body?.innerText || "").trim();
   1363       const visibleText = bodyText
   1364         .split("\n")
   1365         .map((line: string) => line.trim())
   1366         .filter((line: string) => line.length > 0)
   1367         .slice(0, 20);
   1368 
   1369       // Count clickable elements
   1370       const clickableSelector =
   1371         'button, a, [role="button"], [onclick], input[type="button"], input[type="submit"]';
   1372       const clickableElements = document.querySelectorAll(clickableSelector).length;
   1373 
   1374       return {
   1375         has_overlay: hasOverlay,
   1376         has_canvas: hasCanvas,
   1377         has_dom_grid: hasDomGrid,
   1378         visible_text: visibleText,
   1379         clickable_elements: clickableElements,
   1380       };
   1381     });
   1382 
   1383     return data;
   1384   } catch {
   1385     return {
   1386       has_overlay: false,
   1387       has_canvas: false,
   1388       has_dom_grid: false,
   1389       visible_text: [],
   1390       clickable_elements: 0,
   1391     };
   1392   }
   1393 }

Impressum · Datenschutz