kgames

KGames — free keyboard games for kids
git clone https://www.keyboard.games/code/kgames.git
Log | Files | Refs | README | LICENSE

runner.js (44720B)


      1 // Playwright browser smoke tests for KGames.
      2 // Run with: node tests/browser/runner.js
      3 // Requires: npm install (playwright)
      4 
      5 const { chromium } = require('playwright');
      6 const serve        = require('../serve.js');
      7 
      8 async function main() {
      9   const { server, url } = await serve(8766);
     10   const browser = await chromium.launch();
     11   let pass = 0;
     12   let fail = 0;
     13 
     14   async function test(name, fn) {
     15     const page   = await browser.newPage();
     16     // Speed up fire-truck tests
     17     await page.addInitScript(() => {
     18       window.GAME_SPEED_MULTIPLIER = 8.0;
     19       window.__TEST_MODE__ = true;
     20     });
     21     const errors = [];
     22     page.on('console',   m  => { if (m.type() === 'error') errors.push('[console] ' + m.text()); });
     23     page.on('pageerror', e  => { errors.push('[pageerror] ' + e.message); });
     24     try {
     25       await fn(page, url, errors);
     26       console.log(`  ✓  ${name}`);
     27       pass++;
     28     } catch (e) {
     29       console.error(`  ✗  ${name}`);
     30       console.error(`     ${e.message}`);
     31       fail++;
     32     } finally {
     33       await page.close();
     34     }
     35   }
     36 
     37   console.log('\nBrowser tests:');
     38 
     39   // ── Portal ────────────────────────────────────────────────────────────────
     40 
     41   await test('portal loads and contains all game tiles', async (page, url) => {
     42     await page.goto(url, { waitUntil: 'domcontentloaded' });
     43     const letterFind   = await page.$('a[href*="letter-find"]');
     44     const fireTruck    = await page.$('a[href*="fire-truck"]');
     45     const animalLetter = await page.$('a[href*="animal-letter"]');
     46     const animalSpell  = await page.$('a[href*="animal-spell"]');
     47     const work         = await page.$('a[href*="work"]');
     48     if (!letterFind)   throw new Error('Letter Find tile not found');
     49     if (!fireTruck)    throw new Error('Fire Truck tile not found');
     50     if (!animalLetter) throw new Error('Animal Letter tile not found');
     51     if (!animalSpell)  throw new Error('Animal Spell tile not found');
     52     if (!work)         throw new Error('Work tile not found');
     53   });
     54 
     55   // ── Letter Find ───────────────────────────────────────────────────────────
     56 
     57   await test('letter-find: loads, no JS errors, canvas exists', async (page, url, errors) => {
     58     await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' });
     59     await page.waitForTimeout(1800);
     60     if (errors.length) throw new Error(errors[0]);
     61     const canvas = await page.$('canvas');
     62     if (!canvas) throw new Error('No canvas element');
     63   });
     64 
     65   await test('letter-find: canvas has multiple distinct colors (actually renders)', async (page, url) => {
     66     await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' });
     67     await page.waitForTimeout(1800);
     68     const colorCount = await page.evaluate(() => {
     69       const cv = document.querySelector('canvas');
     70       if (!cv) return 0;
     71       const seen = new Set();
     72       // Phaser uses WebGL by default
     73       const gl = cv.getContext('webgl2') || cv.getContext('webgl');
     74       if (gl) {
     75         const pixels = new Uint8Array(cv.width * cv.height * 4);
     76         gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
     77         for (let i = 0; i < pixels.length; i += 32) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`);
     78       } else {
     79         const ctx  = cv.getContext('2d');
     80         const data = ctx.getImageData(0, 0, cv.width, cv.height).data;
     81         for (let i = 0; i < data.length; i += 32) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`);
     82       }
     83       return seen.size;
     84     });
     85     if (colorCount < 4) throw new Error(`Only ${colorCount} colors found — canvas may be blank`);
     86   });
     87 
     88   // ── Animal Letter ─────────────────────────────────────────────────────────
     89 
     90   await test('animal-letter: loads, no JS errors, canvas exists', async (page, url, errors) => {
     91     await page.goto(`${url}/games/animal-letter/`, { waitUntil: 'domcontentloaded' });
     92     await page.waitForTimeout(2000);
     93     if (errors.length) throw new Error(errors[0]);
     94     const canvas = await page.$('canvas');
     95     if (!canvas) throw new Error('No canvas element');
     96   });
     97 
     98   await test('animal-letter: canvas renders multiple colors', async (page, url) => {
     99     await page.goto(`${url}/games/animal-letter/`, { waitUntil: 'domcontentloaded' });
    100     await page.waitForTimeout(2000);
    101     const colorCount = await page.evaluate(() => {
    102       const cv = document.querySelector('canvas');
    103       if (!cv) return 0;
    104       const seen = new Set();
    105       const gl = cv.getContext('webgl2') || cv.getContext('webgl');
    106       if (gl) {
    107         const pixels = new Uint8Array(cv.width * cv.height * 4);
    108         gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
    109         for (let i = 0; i < pixels.length; i += 32) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`);
    110       } else {
    111         const ctx  = cv.getContext('2d');
    112         const data = ctx.getImageData(0, 0, cv.width, cv.height).data;
    113         for (let i = 0; i < data.length; i += 32) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`);
    114       }
    115       return seen.size;
    116     });
    117     if (colorCount < 4) throw new Error(`Only ${colorCount} colors found — canvas may be blank`);
    118   });
    119 
    120   await test('animal-letter: correct key press advances score', async (page, url) => {
    121     await page.goto(`${url}/games/animal-letter/`, { waitUntil: 'domcontentloaded' });
    122     await page.waitForTimeout(2000);
    123     const before = await page.evaluate(() => window.__AL_SCENE__ && window.__AL_SCENE__.lettersFound);
    124     const letter  = await page.evaluate(() => window.__AL_SCENE__ && window.__AL_SCENE__.currentAnimal && window.__AL_SCENE__.currentAnimal.letter);
    125     if (!letter) throw new Error('Scene not ready');
    126     await page.keyboard.press(letter);
    127     await page.waitForTimeout(300);
    128     const after = await page.evaluate(() => window.__AL_SCENE__.lettersFound);
    129     if (after !== (before || 0) + 1) throw new Error(`Score did not advance: ${before} -> ${after}`);
    130   });
    131 
    132   // ── Animal Spell ──────────────────────────────────────────────────────────
    133 
    134   await test('animal-spell: loads, no JS errors, canvas exists', async (page, url, errors) => {
    135     await page.goto(`${url}/games/animal-spell/`, { waitUntil: 'domcontentloaded' });
    136     await page.waitForTimeout(2000);
    137     if (errors.length) throw new Error(errors[0]);
    138     const canvas = await page.$('canvas');
    139     if (!canvas) throw new Error('No canvas element');
    140   });
    141 
    142   await test('animal-spell: canvas renders multiple colors', async (page, url) => {
    143     await page.goto(`${url}/games/animal-spell/`, { waitUntil: 'domcontentloaded' });
    144     await page.waitForTimeout(2000);
    145     const colorCount = await page.evaluate(() => {
    146       const cv = document.querySelector('canvas');
    147       if (!cv) return 0;
    148       const seen = new Set();
    149       const gl = cv.getContext('webgl2') || cv.getContext('webgl');
    150       if (gl) {
    151         const pixels = new Uint8Array(cv.width * cv.height * 4);
    152         gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
    153         for (let i = 0; i < pixels.length; i += 32) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`);
    154       } else {
    155         const ctx  = cv.getContext('2d');
    156         const data = ctx.getImageData(0, 0, cv.width, cv.height).data;
    157         for (let i = 0; i < data.length; i += 32) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`);
    158       }
    159       return seen.size;
    160     });
    161     if (colorCount < 4) throw new Error(`Only ${colorCount} colors found — canvas may be blank`);
    162   });
    163 
    164   await test('animal-spell: correct key press advances letter index', async (page, url) => {
    165     await page.goto(`${url}/games/animal-spell/`, { waitUntil: 'domcontentloaded' });
    166     await page.waitForTimeout(2000);
    167     const before = await page.evaluate(() => window.__AS_SCENE__ && window.__AS_SCENE__.letterIndex);
    168     const letter  = await page.evaluate(() => {
    169       const s = window.__AS_SCENE__;
    170       return s && s.wordLetters && s.wordLetters[0];
    171     });
    172     if (!letter) throw new Error('Scene not ready');
    173     await page.keyboard.press(letter);
    174     await page.waitForTimeout(400);
    175     const after = await page.evaluate(() => window.__AS_SCENE__.letterIndex);
    176     if (after !== (before || 0) + 1) throw new Error(`Letter index did not advance: ${before} -> ${after}`);
    177   });
    178 
    179   // ── Work ──────────────────────────────────────────────────────────────────
    180 
    181   await test('work: loads, no JS errors, canvas exists', async (page, url, errors) => {
    182     await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' });
    183     await page.waitForTimeout(1500);
    184     if (errors.length) throw new Error(errors[0]);
    185     const canvas = await page.$('canvas');
    186     if (!canvas) throw new Error('No canvas element');
    187   });
    188 
    189   await test('work: story credit is hidden until a story is playing', async (page, url) => {
    190     await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' });
    191     await page.waitForTimeout(1200);
    192     if (await page.isVisible('#story-credits')) {
    193       throw new Error('Story credit should be hidden outside Stories mode');
    194     }
    195     // Enter Stories mode and check the credit matches the story actually chosen.
    196     const expected = await page.evaluate(() => {
    197       const sc = window.__WORK_SCENE__;
    198       sc.enterMode('madlib');
    199       return sc.madTemplate.source;
    200     });
    201     await page.waitForTimeout(300);
    202     if (!(await page.isVisible('#story-credits'))) {
    203       throw new Error('Story credit should be visible in Stories mode');
    204     }
    205     const shown = await page.$$eval('#story-credits a', (as) =>
    206       as.map(a => ({ href: a.getAttribute('href'), text: a.textContent })));
    207     if (shown.length !== 1) throw new Error(`Expected 1 credit link, got ${shown.length}`);
    208     if (shown[0].href !== expected.url || shown[0].text !== expected.title) {
    209       throw new Error(`Credit does not match active story: ${JSON.stringify(shown[0])}`);
    210     }
    211     const body = await page.textContent('#story-credits');
    212     if (!body.includes(expected.author)) throw new Error('Credit missing author');
    213     // Leaving Stories mode hides it again.
    214     await page.evaluate(() => window.__WORK_SCENE__.enterMode('idle'));
    215     await page.waitForTimeout(200);
    216     if (await page.isVisible('#story-credits')) {
    217       throw new Error('Story credit should hide again on leaving Stories mode');
    218     }
    219   });
    220 
    221   await test('work: canvas renders multiple colors', async (page, url) => {
    222     await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' });
    223     await page.waitForTimeout(1500);
    224     const colorCount = await page.evaluate(() => {
    225       const cv = document.querySelector('canvas');
    226       if (!cv) return 0;
    227       const seen = new Set();
    228       const gl = cv.getContext('webgl2') || cv.getContext('webgl');
    229       if (gl) {
    230         const pixels = new Uint8Array(cv.width * cv.height * 4);
    231         gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
    232         for (let i = 0; i < pixels.length; i += 32) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`);
    233       } else {
    234         const ctx  = cv.getContext('2d');
    235         const data = ctx.getImageData(0, 0, cv.width, cv.height).data;
    236         for (let i = 0; i < data.length; i += 32) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`);
    237       }
    238       return seen.size;
    239     });
    240     if (colorCount < 4) throw new Error(`Only ${colorCount} colors found — canvas may be blank`);
    241   });
    242 
    243   await test('work: name round captures typed name on Enter', async (page, url) => {
    244     await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' });
    245     await page.waitForTimeout(1000);
    246     await page.evaluate(() => window.__WORK_SCENE__.enterMode('name'));
    247     await page.waitForFunction(() => window.__WORK_SCENE__.acceptInput === true, null, { timeout: 5000 });
    248     // delay so key-holds don't overlap; 0-delay typing triggers OS-style
    249     // auto-repeat in Chromium and garbles the buffer (e.g. "AABCCC").
    250     await page.keyboard.type('ABC', { delay: 40 });
    251     await page.keyboard.press('Enter');
    252     await page.waitForTimeout(200);
    253     const name = await page.evaluate(() => window.__WORK_SCENE__.playerName);
    254     if (name !== 'ABC') throw new Error(`Expected playerName "ABC", got "${name}"`);
    255   });
    256 
    257   await test('work: speed round auto-stops when name matches', async (page, url) => {
    258     await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' });
    259     await page.waitForTimeout(1000);
    260     await page.evaluate(() => {
    261       const s = window.__WORK_SCENE__;
    262       s.enterMode('name');
    263       s.playerName = 'Sam';
    264       s._nameRace();
    265     });
    266     await page.waitForFunction(() => window.__WORK_SCENE__.acceptInput === true, null, { timeout: 5000 });
    267     await page.keyboard.type('Sam', { delay: 40 });
    268     await page.waitForFunction(() => window.__WORK_SCENE__.nameState === 'result', null, { timeout: 5000 });
    269     const t = await page.evaluate(() => window.__WORK_SCENE__.lastTimeMs);
    270     if (!(t >= 0)) throw new Error(`Expected a finite lastTimeMs, got ${t}`);
    271   });
    272 
    273   // ── Fire Truck ────────────────────────────────────────────────────────────
    274 
    275   await test('fire-truck: loads, no JS errors, canvas exists', async (page, url, errors) => {
    276     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    277     await page.waitForTimeout(1800);
    278     if (errors.length) throw new Error(errors[0]);
    279     const canvas = await page.$('canvas');
    280     if (!canvas) throw new Error('No canvas element');
    281   });
    282 
    283   await test('fire-truck: canvas shows city colors', async (page, url) => {
    284     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    285     await page.waitForTimeout(1800);
    286     const colorCount = await page.evaluate(() => {
    287       const cv = document.querySelector('canvas');
    288       if (!cv) return 0;
    289       const seen = new Set();
    290       const gl = cv.getContext('webgl2') || cv.getContext('webgl');
    291       if (gl) {
    292         const pixels = new Uint8Array(cv.width * cv.height * 4);
    293         gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
    294         for (let i = 0; i < pixels.length; i += 48) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`);
    295       } else {
    296         const ctx = cv.getContext('2d');
    297         const data = ctx.getImageData(0, 0, cv.width, cv.height).data;
    298         for (let i = 0; i < data.length; i += 48) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`);
    299       }
    300       return seen.size;
    301     });
    302     if (colorCount < 6) throw new Error(`Only ${colorCount} colors found — city may not be rendering`);
    303   });
    304 
    305   await test('fire-truck: truck starts moving automatically', async (page, url) => {
    306     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    307     // debugDistancePx is cumulative from spawn. Whether the truck is still
    308     // driving or has already reached its first intersection (waiting), it can
    309     // only have got there by auto-driving — so a nonzero total proves motion.
    310     // (Comparing two late samples is racy: at 8x test speed both can land in
    311     // the same stationary 'waiting' state, giving a false zero delta.)
    312     await page.waitForTimeout(3000);
    313     const end = await page.evaluate(() => ({
    314       distance: window.__FT_SCENE__.debugDistancePx || 0,
    315       prompt: window.__FT_SCENE__.promptDir,
    316       state: window.__FT_SCENE__.state,
    317     }));
    318     if (end.distance < 120) {
    319       throw new Error(`Truck advanced only ${end.distance.toFixed(1)} path pixels from spawn (state ${end.state}/${end.prompt || 'none'})`);
    320     }
    321   });
    322 
    323   await test('fire-truck: wrong arrow stops the truck', async (page, url) => {
    324     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    325     await page.evaluate(() => { window.__FT_SCENE__.truck.setPosition(window.__FT_SCENE__.segmentEnd.x - 500, window.__FT_SCENE__.segmentEnd.y); });
    326     await page.waitForFunction(() => window.__FT_SCENE__ && !!window.__FT_SCENE__.promptDir, null, { timeout: 25000 });
    327     // Freeze the truck so it doesn't cross the intersection while playwright is IPCing
    328     await page.evaluate(() => { window.__FT_SCENE__.targetSpeed = 0; window.__FT_SCENE__.speed = 0; window.__FT_SCENE__.lastStepTime = performance.now(); });
    329     const promptDir = await page.evaluate(() => window.__FT_SCENE__.promptDir);
    330     const wrongKey = promptDir === 'left' ? 'ArrowRight' : promptDir === 'right' ? 'ArrowLeft' : 'ArrowLeft';
    331     await page.keyboard.press(wrongKey);
    332     await page.waitForFunction(() => window.__FT_SCENE__.state === 'stopped', null, { timeout: 5000 });
    333     const state = await page.evaluate(() => ({ state: window.__FT_SCENE__.state, failVisible: window.__FT_SCENE__.failVisible }));
    334     if (state.state !== 'stopped') throw new Error(`Expected stopped, got ${state.state}`);
    335     if (!state.failVisible) throw new Error('Expected failVisible to be true');
    336   });
    337 
    338   await test('fire-truck: correct arrow after failure resumes movement', async (page, url) => {
    339     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    340     await page.evaluate(() => { window.__FT_SCENE__.truck.setPosition(window.__FT_SCENE__.segmentEnd.x - 500, window.__FT_SCENE__.segmentEnd.y); });
    341     await page.waitForFunction(() => window.__FT_SCENE__ && !!window.__FT_SCENE__.promptDir, null, { timeout: 25000 });
    342     await page.evaluate(() => { window.__FT_SCENE__.targetSpeed = 0; window.__FT_SCENE__.speed = 0; window.__FT_SCENE__.lastStepTime = performance.now(); });
    343     const promptDir = await page.evaluate(() => window.__FT_SCENE__.promptDir);
    344     const wrongKey = promptDir === 'left' ? 'ArrowRight' : promptDir === 'right' ? 'ArrowLeft' : 'ArrowLeft';
    345     const correctKey = promptDir === 'left' ? 'ArrowLeft' : promptDir === 'right' ? 'ArrowRight' : 'ArrowUp';
    346     await page.keyboard.press(wrongKey);
    347     await page.waitForFunction(() => window.__FT_SCENE__.state === 'stopped', null, { timeout: 5000 });
    348     await page.keyboard.press(correctKey);
    349     try {
    350       await page.waitForFunction(() => window.__FT_SCENE__.state === 'driving', null, { timeout: 5000 });
    351     } catch (e) {
    352       const dbg = await page.evaluate(() => ({ state: window.__FT_SCENE__.state, promptDir: window.__FT_SCENE__.promptDir, resolved: window.__FT_SCENE__.promptResolved }));
    353       throw new Error(`Timeout driving. State: ${JSON.stringify(dbg)}`);
    354     }
    355     const state = await page.evaluate(() => ({ state: window.__FT_SCENE__.state }));
    356     if (state.state === 'stopped') throw new Error('Truck did not resume');
    357   });
    358 
    359   await test('fire-truck: island debug hooks and route bounds', async (page, url) => {
    360     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    361     await page.waitForTimeout(1800);
    362     const info = await page.evaluate(() => {
    363       const s = window.__FT_SCENE__;
    364       return {
    365         width: s.island.width,
    366         height: s.island.height,
    367         cell00: s.cellAt(0, 0).type,
    368         cell11: s.cellAt(1, 1).type,
    369         cell22: s.cellAt(2, 2).type,
    370         routeOutOfBounds: s.route.some(step => step.x < 2 || step.x > 47 || step.y < 2 || step.y > 47 || step.approachX < 2 || step.approachX > 47 || step.approachY < 2 || step.approachY > 47 || step.exitX < 2 || step.exitX > 47 || step.exitY < 2 || step.exitY > 47),
    371         hasFourWayOnIsland: s.island.roads.some(c => s.cellAt(c.x, c.y).meta.kind === 'four'),
    372       };
    373     });
    374     if (info.width !== 50) throw new Error(`Expected width 50, got ${info.width}`);
    375     if (info.height !== 50) throw new Error(`Expected height 50, got ${info.height}`);
    376     if (info.cell00 !== 'water') throw new Error(`Expected cell00 water, got ${info.cell00}`);
    377     if (info.cell11 !== 'beach') throw new Error(`Expected cell11 beach, got ${info.cell11}`);
    378     if (info.cell22 !== 'road') throw new Error(`Expected cell22 road, got ${info.cell22}`);
    379     if (info.routeOutOfBounds) throw new Error('Route step out of bounds');
    380     if (!info.hasFourWayOnIsland) throw new Error('No four-way intersection on island');
    381   });
    382 
    383   await test('fire-truck: no input leads to waiting state (not stopped)', async (page, url) => {
    384     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    385     await page.evaluate(() => { window.__FT_SCENE__.truck.setPosition(window.__FT_SCENE__.segmentEnd.x - 500, window.__FT_SCENE__.segmentEnd.y); });
    386     await page.waitForFunction(() => window.__FT_SCENE__ && !!window.__FT_SCENE__.promptDir, null, { timeout: 25000 });
    387     await page.evaluate(() => { window.__FT_SCENE__.targetSpeed = 0; window.__FT_SCENE__.speed = 0; window.__FT_SCENE__.lastStepTime = performance.now(); });
    388     // Move truck up to the stop line without resolving. Place it just short of
    389     // segmentEnd *along the real approach vector* (the segment may run in any
    390     // direction) so it lands inside STOP_LINE_DIST regardless of orientation.
    391     await page.evaluate(() => {
    392       const s = window.__FT_SCENE__;
    393       const dx = s.segmentEnd.x - s.truck.x, dy = s.segmentEnd.y - s.truck.y;
    394       const d = Math.hypot(dx, dy) || 1;
    395       const short = 30; // well inside STOP_LINE_DIST (~216px)
    396       s.truck.setPosition(s.segmentEnd.x - (dx / d) * short, s.segmentEnd.y - (dy / d) * short);
    397       s.step(0.016);
    398     });
    399     const state = await page.evaluate(() => window.__FT_SCENE__.state);
    400     if (state !== 'waiting') throw new Error(`Expected waiting, got ${state}`);
    401   });
    402 
    403   await test('fire-truck: correct key from waiting resumes driving', async (page, url) => {
    404     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    405     await page.evaluate(() => { window.__FT_SCENE__.truck.setPosition(window.__FT_SCENE__.segmentEnd.x - 500, window.__FT_SCENE__.segmentEnd.y); });
    406     await page.waitForFunction(() => window.__FT_SCENE__ && !!window.__FT_SCENE__.promptDir, null, { timeout: 25000 });
    407     await page.evaluate(() => { window.__FT_SCENE__.targetSpeed = 0; window.__FT_SCENE__.speed = 0; window.__FT_SCENE__.lastStepTime = performance.now(); });
    408     const promptDir = await page.evaluate(() => window.__FT_SCENE__.promptDir);
    409     const correctKey = promptDir === 'left' ? 'ArrowLeft' : promptDir === 'right' ? 'ArrowRight' : 'ArrowUp';
    410     // Put into waiting: place just short of segmentEnd along the real approach
    411     // vector so it reaches the stop line whatever direction the segment runs.
    412     await page.evaluate(() => {
    413       const s = window.__FT_SCENE__;
    414       const dx = s.segmentEnd.x - s.truck.x, dy = s.segmentEnd.y - s.truck.y;
    415       const d = Math.hypot(dx, dy) || 1;
    416       const short = 30;
    417       s.truck.setPosition(s.segmentEnd.x - (dx / d) * short, s.segmentEnd.y - (dy / d) * short);
    418       s.step(0.016);
    419     });
    420     await page.keyboard.press(correctKey);
    421     try {
    422       await page.waitForFunction(() => window.__FT_SCENE__.state === 'driving', null, { timeout: 5000 });
    423     } catch (e) {
    424       const dbg = await page.evaluate(() => ({ state: window.__FT_SCENE__.state, promptDir: window.__FT_SCENE__.promptDir, resolved: window.__FT_SCENE__.promptResolved }));
    425       throw new Error(`Timeout driving. State: ${JSON.stringify(dbg)}`);
    426     }
    427   });
    428 
    429   await test('fire-truck: manual mode drives only while a key is held', async (page, url) => {
    430     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    431     await page.waitForFunction(() => !!window.__FT_SCENE__ && !!window.__FT_SCENE__.truck, null, { timeout: 25000 });
    432     await page.evaluate(() => window.__FT_SCENE__.setControlMode('manual'));
    433     const before = await page.evaluate(() => window.__FT_SCENE__.debugDistancePx || 0);
    434     // Pick any direction with a road exit from the anchored cell.
    435     const key = await page.evaluate(() => {
    436       const s = window.__FT_SCENE__;
    437       const map = { N: 'ArrowUp', E: 'ArrowRight', S: 'ArrowDown', W: 'ArrowLeft' };
    438       for (const c of ['N', 'E', 'S', 'W']) if (s.manualCell.exits[c]) return map[c];
    439       return null;
    440     });
    441     if (!key) throw new Error('Manual anchor cell has no road exits');
    442     await page.keyboard.down(key);
    443     await page.waitForFunction(
    444       (b) => (window.__FT_SCENE__.debugDistancePx || 0) > b + 50,
    445       before,
    446       { timeout: 5000 }
    447     );
    448     await page.keyboard.up(key);
    449     // Truck brakes to a stop and then stays put (unless the held drive already
    450     // reached the fire and launched the minigame, which also means it moved).
    451     await page.waitForFunction(
    452       () => window.__FT_SCENE__.speed === 0 || window.__FT_SCENE__.state === 'minigame',
    453       null,
    454       { timeout: 5000 }
    455     );
    456     const stopped = await page.evaluate(() => window.__FT_SCENE__.debugDistancePx);
    457     await page.waitForTimeout(400);
    458     const later = await page.evaluate(() => window.__FT_SCENE__.debugDistancePx);
    459     if (later - stopped > 1) throw new Error(`Truck kept moving after key release (${(later - stopped).toFixed(1)}px)`);
    460   });
    461 
    462   await test('fire-truck: manual mode flags wrong-way driving', async (page, url) => {
    463     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    464     await page.waitForFunction(() => !!window.__FT_SCENE__ && !!window.__FT_SCENE__.truck, null, { timeout: 25000 });
    465     const ok = await page.evaluate(() => {
    466       const s = window.__FT_SCENE__;
    467       s.setControlMode('manual');
    468       if (!s.fireDistances || !s.manualCell) return false;
    469       // Simulate arriving on a cell farther from the fire than the last one.
    470       const grid = s.island.grid;
    471       const here = s.manualCell;
    472       let worse = null;
    473       for (const c of ['N', 'E', 'S', 'W']) {
    474         if (!here.exits[c]) continue;
    475         const n = grid[here.y + window.FireTruckLib.DIRS[c].dy][here.x + window.FireTruckLib.DIRS[c].dx];
    476         if (s.fireDistances.get(n) > s.fireDistances.get(here)) { worse = n; break; }
    477       }
    478       if (!worse) return false;
    479       s.manualTarget = worse;
    480       s._manualArrive();
    481       return s.failVisible && s.statusText.text.includes('Wrong way');
    482     });
    483     if (!ok) throw new Error('Wrong-way feedback did not trigger');
    484   });
    485 
    486   await test('fire-truck: fire destination is set after load', async (page, url) => {
    487     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    488     await page.waitForTimeout(1800);
    489     const info = await page.evaluate(() => {
    490       const s = window.__FT_SCENE__;
    491       return {
    492         hasFireCell: !!s.fireCell,
    493         hasFireBuildingCell: !!s.fireBuildingCell,
    494         fireIsRoad: s.fireCell ? s.cellAt(s.fireCell.x, s.fireCell.y).type === 'road' : false,
    495         buildingIsBuilding: s.fireBuildingCell ? s.cellAt(s.fireBuildingCell.x, s.fireBuildingCell.y).type === 'building' : false,
    496         adjacent: s.fireCell && s.fireBuildingCell ? Math.abs(s.fireCell.x - s.fireBuildingCell.x) + Math.abs(s.fireCell.y - s.fireBuildingCell.y) === 1 : false,
    497       };
    498     });
    499     if (!info.hasFireCell) throw new Error('fireCell not set');
    500     if (!info.hasFireBuildingCell) throw new Error('fireBuildingCell not set');
    501     if (!info.fireIsRoad) throw new Error('fireCell is not a road');
    502     if (!info.buildingIsBuilding) throw new Error('fireBuildingCell is not a building');
    503     if (!info.adjacent) throw new Error('fireCell and fireBuildingCell are not adjacent');
    504   });
    505 
    506   await test('fire-truck: reaching the fire launches the hose minigame', async (page, url, errors) => {
    507     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    508     await page.waitForTimeout(1800);
    509     // Teleport the truck onto fireCell so the fire trigger fires
    510     await page.evaluate(() => {
    511       const s = window.__FT_SCENE__;
    512       if (s.fireCell) {
    513         s.state = 'driving';
    514         s.speed = 0;
    515         s.targetSpeed = 0;
    516         s.truck.setPosition(s.fireCell.x * s.cellSize, s.fireCell.y * s.cellSize);
    517         s.step(0.016);
    518       }
    519     });
    520     await page.waitForFunction(
    521       () => window.__FT_HOSE_SCENE__ && window.__FT_HOSE_SCENE__.fires && window.__FT_HOSE_SCENE__.fires.length > 0,
    522       null, { timeout: 5000 });
    523     if (errors.length) throw new Error(errors[0]);
    524     const state = await page.evaluate(() => window.__FT_SCENE__.state);
    525     if (state !== 'minigame') throw new Error(`drive scene should be in minigame state, got: ${state}`);
    526     // Aim at the first burning window and hold SPACE → quench progress rises
    527     await page.evaluate(() => {
    528       const h = window.__FT_HOSE_SCENE__;
    529       const f = h.fires[0];
    530       h.aimX = f.cx;
    531       h.aimY = f.cy;
    532     });
    533     await page.keyboard.down('Space');
    534     await page.waitForTimeout(500);
    535     const progress = await page.evaluate(() => window.__FT_HOSE_SCENE__.fires[0].progress);
    536     await page.keyboard.up('Space');
    537     if (!(progress > 0)) throw new Error(`Quench progress did not accumulate: ${progress}`);
    538   });
    539 
    540   await test('fire-truck: quenching every window returns to driving with counter +1', async (page, url, errors) => {
    541     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    542     await page.waitForTimeout(1800);
    543     await page.evaluate(() => {
    544       const s = window.__FT_SCENE__;
    545       if (s.fireCell) {
    546         s.state = 'driving';
    547         s.speed = 0;
    548         s.targetSpeed = 0;
    549         s.truck.setPosition(s.fireCell.x * s.cellSize, s.fireCell.y * s.cellSize);
    550         s.step(0.016);
    551       }
    552     });
    553     await page.waitForFunction(
    554       () => window.__FT_HOSE_SCENE__ && window.__FT_HOSE_SCENE__.fires && window.__FT_HOSE_SCENE__.fires.length > 0,
    555       null, { timeout: 5000 });
    556     await page.evaluate(() => {
    557       const h = window.__FT_HOSE_SCENE__;
    558       for (const f of h.fires) h._quenchWindow(f);
    559     });
    560     // Celebration plays (~2.6 s), then the drive scene resumes and re-routes (~0.6 s)
    561     await page.waitForFunction(
    562       () => window.__FT_SCENE__.firesExtinguished === 1 && window.__FT_SCENE__.state === 'driving',
    563       null, { timeout: 10000 });
    564     if (errors.length) throw new Error(errors[0]);
    565     const info = await page.evaluate(() => ({
    566       hoseGone: !window.__FT_HOSE_SCENE__,
    567       hasNextFire: !!window.__FT_SCENE__.fireCell,
    568     }));
    569     if (!info.hoseGone) throw new Error('FireHoseScene still active after completion');
    570     if (!info.hasNextFire) throw new Error('next fire destination was not set after minigame');
    571   });
    572 
    573   await test('fire-truck: correct arrow clears prompt immediately', async (page, url) => {
    574     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    575     await page.waitForTimeout(1800);
    576     // Teleport near segmentEnd so prompt triggers, then press the correct arrow
    577     await page.evaluate(() => {
    578       const s = window.__FT_SCENE__;
    579       const end = s.segmentEnd;
    580       s.state = 'driving';
    581       s.speed = 0;
    582       s.targetSpeed = 0;
    583       s.truck.setPosition(end.x - 260, end.y);
    584       s.step(0.016);
    585     });
    586     await page.waitForFunction(() => !!window.__FT_SCENE__.promptDir, null, { timeout: 5000 });
    587     const correctKey = await page.evaluate(() => {
    588       const dir = window.__FT_SCENE__.promptDir;
    589       return dir === 'left' ? 'ArrowLeft' : dir === 'right' ? 'ArrowRight' : 'ArrowUp';
    590     });
    591     await page.keyboard.press(correctKey);
    592     // promptDir must be null on the very next evaluate (no round-trip delay)
    593     const afterPromptDir = await page.evaluate(() => window.__FT_SCENE__.promptDir);
    594     if (afterPromptDir !== null) throw new Error(`promptDir should be null immediately, got: ${afterPromptDir}`);
    595     const promptText = await page.evaluate(() => window.__FT_SCENE__.promptText.text);
    596     if (promptText !== '') throw new Error(`promptText should be empty, got: "${promptText}"`);
    597   });
    598 
    599   await test('fire-truck: fire distance setting is honored at startup', async (page, url) => {
    600     await page.addInitScript(() => { window.GAME_FIRE_DISTANCE = 25; });
    601     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    602     await page.waitForTimeout(1800);
    603     const result = await page.evaluate(() => {
    604       const s = window.__FT_SCENE__;
    605       if (!s.fireCell) return { ok: false, reason: 'no fireCell' };
    606       const fromCell = s.island.grid[s.island.routeStart.y][s.island.routeStart.x];
    607       const FireTruckLib = window.FireTruckLib;
    608       const dists = FireTruckLib.bfsRoadDistances(s.island.grid, fromCell);
    609       const d = dists.get(s.island.grid[s.fireCell.y][s.fireCell.x]);
    610       return { ok: d != null && d >= 20 && d <= 30, d };
    611     });
    612     if (!result.ok) throw new Error(`Fire distance ${result.d} not in [20,30] for targetDistance=25`);
    613   });
    614 
    615   await test('fire-truck: endgame scene renders, exposes __FT_END_SCENE__, no errors', async (page, url, errors) => {
    616     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    617     await page.waitForTimeout(1800);
    618     if (errors.length) throw new Error(errors[0]);
    619     await page.evaluate(() => { window.__FT_SCENE__.scene.start('FireTruckEndScene'); });
    620     await page.waitForTimeout(1200);
    621     if (errors.length) throw new Error(errors[0]);
    622     const hasEndScene = await page.evaluate(() => !!window.__FT_END_SCENE__);
    623     if (!hasEndScene) throw new Error('__FT_END_SCENE__ not exposed after scene start');
    624     const colorCount = await page.evaluate(() => {
    625       const cv = document.querySelector('canvas');
    626       if (!cv) return 0;
    627       const seen = new Set();
    628       const sampleW = 200, sampleH = 200;
    629       const gl = cv.getContext('webgl2') || cv.getContext('webgl');
    630       if (gl) {
    631         const pixels = new Uint8Array(sampleW * sampleH * 4);
    632         gl.readPixels(0, 0, sampleW, sampleH, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
    633         for (let i = 0; i < pixels.length; i += 16) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`);
    634       } else {
    635         // Phaser falls back to the Canvas (2D) renderer in headless Chromium.
    636         const ctx = cv.getContext('2d');
    637         const w = Math.min(cv.width, sampleW), h = Math.min(cv.height, sampleH);
    638         const data = ctx.getImageData(0, 0, w, h).data;
    639         for (let i = 0; i < data.length; i += 16) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`);
    640       }
    641       return seen.size;
    642     });
    643     if (colorCount < 5) throw new Error(`Endgame scene rendered only ${colorCount} distinct colors — may be blank`);
    644   });
    645 
    646   await test('fire-truck: play again button restarts FireTruckScene with reset counter', async (page, url, errors) => {
    647     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    648     await page.waitForTimeout(1800);
    649     await page.evaluate(() => { window.__FT_SCENE__.scene.start('FireTruckEndScene'); });
    650     await page.waitForTimeout(1200);
    651     if (errors.length) throw new Error(errors[0]);
    652     const hasEnd = await page.evaluate(() => !!window.__FT_END_SCENE__);
    653     if (!hasEnd) throw new Error('FireTruckEndScene did not start');
    654     const W = await page.evaluate(() => window.innerWidth);
    655     const H = await page.evaluate(() => window.innerHeight);
    656     await page.mouse.click(W / 2, H * 0.91);
    657     await page.waitForTimeout(1400);
    658     if (errors.length) throw new Error(errors[0]);
    659     const result = await page.evaluate(() => {
    660       const s = window.__FT_SCENE__;
    661       return { hasScene: !!s, counter: s ? s.firesExtinguished : -1 };
    662     });
    663     if (!result.hasScene) throw new Error('FireTruckScene not restarted after Play Again');
    664     if (result.counter !== 0) throw new Error(`firesExtinguished should be 0 after restart, got ${result.counter}`);
    665   });
    666 
    667   await test('fire-truck: ambient traffic spawns and stays on roads', async (page, url) => {
    668     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    669     await page.waitForTimeout(1800);
    670     const info = await page.evaluate(() => {
    671       const s = window.__FT_SCENE__;
    672       const movers = [
    673         ...(s.trafficCars || []), ...(s.buses || []),
    674         ...(s.bicyclists || []), ...(s.pedestrians || []),
    675       ];
    676       const allRoad = movers.every(m =>
    677         s.cellAt(m.from.x, m.from.y).type === 'road' &&
    678         s.cellAt(m.to.x, m.to.y).type === 'road');
    679       return {
    680         cars: (s.trafficCars || []).length,
    681         buses: (s.buses || []).length,
    682         bikes: (s.bicyclists || []).length,
    683         peds: (s.pedestrians || []).length,
    684         allRoad,
    685         ambientReady: s.ambientReady,
    686       };
    687     });
    688     if (!info.ambientReady) throw new Error('ambient systems not ready');
    689     if (info.cars === 0) throw new Error('no traffic cars spawned');
    690     if (info.buses === 0) throw new Error('no buses spawned');
    691     if (info.bikes === 0) throw new Error('no bicyclists spawned');
    692     if (info.peds < 20) throw new Error(`expected a bustling crowd, got ${info.peds} pedestrians`);
    693     if (!info.allRoad) throw new Error('an ambient mover left the road graph');
    694   });
    695 
    696   // ── On-screen touch controls ────────────────────────────────────────────────
    697 
    698   // Helper: returns an ElementHandle for the touch key whose label === text.
    699   async function touchKey(page, text) {
    700     const h = await page.evaluateHandle(
    701       (t) => [...document.querySelectorAll('#kg-touch .kg-key')].find(b => b.textContent === t),
    702       text
    703     );
    704     const el = h.asElement();
    705     if (!el) throw new Error(`On-screen key "${text}" not found`);
    706     return el;
    707   }
    708 
    709   await test('touch: controls hidden by default on non-touch browser', async (page, url) => {
    710     await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' });
    711     await page.waitForTimeout(800);
    712     const bar = await page.$('#kg-touch');
    713     if (bar) throw new Error('Touch bar should not render on a non-touch device');
    714   });
    715 
    716   await test('touch: ?kbd=1 forces the on-screen keyboard to render', async (page, url) => {
    717     await page.goto(`${url}/games/letter-find/?kbd=1`, { waitUntil: 'domcontentloaded' });
    718     await page.waitForSelector('#kg-touch', { timeout: 5000 });
    719     const keys = await page.$$eval('#kg-touch .kg-key', els => els.length);
    720     if (keys !== 26) throw new Error(`Expected 26 alphabet keys, got ${keys}`);
    721   });
    722 
    723   await test('touch: letter-find on-screen key advances score', async (page, url) => {
    724     await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
    725     await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' });
    726     await page.waitForSelector('#kg-touch');
    727     await page.waitForFunction(() => window.__LF_SCENE__ && window.__LF_SCENE__.accepting, null, { timeout: 5000 });
    728     const target = await page.evaluate(() => window.__LF_SCENE__.targetChar);
    729     const before = await page.evaluate(() => window.__LF_SCENE__.lettersFound);
    730     const key = await touchKey(page, target);
    731     await key.click();
    732     await page.waitForTimeout(300);
    733     const after = await page.evaluate(() => window.__LF_SCENE__.lettersFound);
    734     if (after !== before + 1) throw new Error(`Touch key did not advance score: ${before} -> ${after}`);
    735   });
    736 
    737   await test('touch: animal-spell on-screen key advances letter index', async (page, url) => {
    738     await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
    739     await page.goto(`${url}/games/animal-spell/`, { waitUntil: 'domcontentloaded' });
    740     await page.waitForSelector('#kg-touch');
    741     await page.waitForFunction(() => window.__AS_SCENE__ && window.__AS_SCENE__.accepting, null, { timeout: 6000 });
    742     const letter = await page.evaluate(() => window.__AS_SCENE__.wordLetters[0]);
    743     const before = await page.evaluate(() => window.__AS_SCENE__.letterIndex);
    744     const key = await touchKey(page, letter);
    745     await key.click();
    746     await page.waitForTimeout(400);
    747     const after = await page.evaluate(() => window.__AS_SCENE__.letterIndex);
    748     if (after !== before + 1) throw new Error(`Touch key did not advance letter index: ${before} -> ${after}`);
    749   });
    750 
    751   await test('touch: work text keyboard types name and submits on Enter', async (page, url) => {
    752     await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
    753     await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' });
    754     await page.waitForSelector('#kg-touch');
    755     await page.evaluate(() => window.__WORK_SCENE__.enterMode('name'));
    756     await page.waitForFunction(() => window.__WORK_SCENE__.acceptInput === true, null, { timeout: 5000 });
    757     for (const ch of ['A', 'B', 'C']) {
    758       const k = await touchKey(page, ch);
    759       await k.click();
    760       await page.waitForTimeout(50);
    761     }
    762     const enter = await touchKey(page, '⏎');
    763     await enter.click();
    764     await page.waitForTimeout(200);
    765     const name = await page.evaluate(() => window.__WORK_SCENE__.playerName);
    766     if (name !== 'ABC') throw new Error(`Expected playerName "ABC" via touch keyboard, got "${name}"`);
    767   });
    768 
    769   await test('touch: work keyboard has Space and Backspace keys', async (page, url) => {
    770     await page.goto(`${url}/games/work/?kbd=1`, { waitUntil: 'domcontentloaded' });
    771     await page.waitForSelector('#kg-touch');
    772     const labels = await page.$$eval('#kg-touch .kg-key', els => els.map(e => e.textContent));
    773     if (!labels.includes('space')) throw new Error('Space key missing from work keyboard');
    774     if (!labels.includes('⌫')) throw new Error('Backspace key missing from work keyboard');
    775   });
    776 
    777   await test('touch: fire-truck drive pad clears prompt on correct arrow', async (page, url) => {
    778     await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
    779     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    780     await page.waitForSelector('#kg-touch .kg-key-arrow');
    781     await page.evaluate(() => {
    782       const s = window.__FT_SCENE__;
    783       const end = s.segmentEnd;
    784       s.state = 'driving'; s.speed = 0; s.targetSpeed = 0;
    785       s.truck.setPosition(end.x - 260, end.y);
    786       s.step(0.016);
    787     });
    788     await page.waitForFunction(() => !!window.__FT_SCENE__.promptDir, null, { timeout: 6000 });
    789     const dir = await page.evaluate(() => window.__FT_SCENE__.promptDir);
    790     const label = dir === 'left' ? '◀' : dir === 'right' ? '▶' : '▲';
    791     const arrow = await touchKey(page, label);
    792     await arrow.click();
    793     const after = await page.evaluate(() => window.__FT_SCENE__.promptDir);
    794     if (after !== null) throw new Error(`promptDir should be null after touch arrow, got ${after}`);
    795   });
    796 
    797   await test('touch: fire-truck spray button accumulates extinguish (press-hold)', async (page, url) => {
    798     await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
    799     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    800     await page.waitForSelector('#kg-touch');
    801     await page.evaluate(() => {
    802       const s = window.__FT_SCENE__;
    803       if (s.fireCell) {
    804         s.state = 'driving'; s.speed = 0; s.targetSpeed = 0;
    805         s.truck.setPosition(s.fireCell.x * s.cellSize, s.fireCell.y * s.cellSize);
    806         s.step(0.016);
    807       }
    808     });
    809     await page.waitForFunction(
    810       () => window.__FT_HOSE_SCENE__ && window.__FT_HOSE_SCENE__.fires && window.__FT_HOSE_SCENE__.fires.length > 0,
    811       null, { timeout: 6000 });
    812     await page.evaluate(() => {
    813       const h = window.__FT_HOSE_SCENE__;
    814       const f = h.fires[0];
    815       h.aimX = f.cx;
    816       h.aimY = f.cy;
    817     });
    818     const spray = await touchKey(page, '💦 Spray');
    819     const box = await spray.boundingBox();
    820     await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
    821     await page.mouse.down();
    822     await page.waitForTimeout(400);
    823     const after = await page.evaluate(() => window.__FT_HOSE_SCENE__.fires[0].progress);
    824     await page.mouse.up();
    825     if (after <= 0) throw new Error(`Spray hold did not accumulate quench progress: ${after}`);
    826   });
    827 
    828   // ── Summary ───────────────────────────────────────────────────────────────
    829 
    830   await browser.close();
    831   server.close();
    832 
    833   const total = pass + fail;
    834   console.log(`\n${total} tests: ${pass} passed, ${fail} failed.\n`);
    835   if (fail > 0) process.exit(1);
    836 }
    837 
    838 main().catch(e => { console.error(e); process.exit(1); });