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 }