kgames

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

runner.js (79987B)


      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, opts) {
     15     // opts.hasTouch: open the page in a touch-capable context so ElementHandle.tap()
     16     // sends real touch events (pointerdown -> pointerup/touchend), like a phone.
     17     const context = opts && opts.hasTouch ? await browser.newContext({ hasTouch: true }) : null;
     18     const page    = context ? await context.newPage() : await browser.newPage();
     19     // Speed up fire-truck tests
     20     await page.addInitScript(() => {
     21       window.GAME_SPEED_MULTIPLIER = 8.0;
     22       window.__TEST_MODE__ = true;
     23     });
     24     const errors = [];
     25     page.on('console',   m  => { if (m.type() === 'error') errors.push('[console] ' + m.text()); });
     26     page.on('pageerror', e  => { errors.push('[pageerror] ' + e.message); });
     27     try {
     28       await fn(page, url, errors);
     29       console.log(`  ✓  ${name}`);
     30       pass++;
     31     } catch (e) {
     32       console.error(`  ✗  ${name}`);
     33       console.error(`     ${e.message}`);
     34       fail++;
     35     } finally {
     36       await page.close();
     37       if (context) await context.close();
     38     }
     39   }
     40 
     41   console.log('\nBrowser tests:');
     42 
     43   // ── Portal ────────────────────────────────────────────────────────────────
     44 
     45   await test('portal loads and contains all game tiles', async (page, url) => {
     46     await page.goto(url, { waitUntil: 'domcontentloaded' });
     47     const letterFind   = await page.$('a[href*="letter-find"]');
     48     const fireTruck    = await page.$('a[href*="fire-truck"]');
     49     const animalLetter = await page.$('a[href*="animal-letter"]');
     50     const animalSpell  = await page.$('a[href*="animal-spell"]');
     51     const work         = await page.$('a[href*="work"]');
     52     const storyStamp   = await page.$('a[href*="story-stamp"]');
     53     if (!letterFind)   throw new Error('Letter Find tile not found');
     54     if (!storyStamp)   throw new Error('Story Stamp tile not found');
     55     if (!fireTruck)    throw new Error('Fire Truck tile not found');
     56     if (!animalLetter) throw new Error('Animal Letter tile not found');
     57     if (!animalSpell)  throw new Error('Animal Spell tile not found');
     58     if (!work)         throw new Error('Work tile not found');
     59   });
     60 
     61   // ── Letter Find ───────────────────────────────────────────────────────────
     62 
     63   await test('letter-find: loads, no JS errors, canvas exists', async (page, url, errors) => {
     64     await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' });
     65     await page.waitForTimeout(1800);
     66     if (errors.length) throw new Error(errors[0]);
     67     const canvas = await page.$('canvas');
     68     if (!canvas) throw new Error('No canvas element');
     69   });
     70 
     71   await test('letter-find: canvas has multiple distinct colors (actually renders)', async (page, url) => {
     72     await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' });
     73     await page.waitForTimeout(1800);
     74     const colorCount = await page.evaluate(() => {
     75       const cv = document.querySelector('canvas');
     76       if (!cv) return 0;
     77       const seen = new Set();
     78       // Phaser uses WebGL by default
     79       const gl = cv.getContext('webgl2') || cv.getContext('webgl');
     80       if (gl) {
     81         const pixels = new Uint8Array(cv.width * cv.height * 4);
     82         gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
     83         for (let i = 0; i < pixels.length; i += 32) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`);
     84       } else {
     85         const ctx  = cv.getContext('2d');
     86         const data = ctx.getImageData(0, 0, cv.width, cv.height).data;
     87         for (let i = 0; i < data.length; i += 32) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`);
     88       }
     89       return seen.size;
     90     });
     91     if (colorCount < 4) throw new Error(`Only ${colorCount} colors found — canvas may be blank`);
     92   });
     93 
     94   // ── Animal Letter ─────────────────────────────────────────────────────────
     95 
     96   await test('animal-letter: loads, no JS errors, canvas exists', async (page, url, errors) => {
     97     await page.goto(`${url}/games/animal-letter/`, { waitUntil: 'domcontentloaded' });
     98     await page.waitForTimeout(2000);
     99     if (errors.length) throw new Error(errors[0]);
    100     const canvas = await page.$('canvas');
    101     if (!canvas) throw new Error('No canvas element');
    102   });
    103 
    104   await test('animal-letter: canvas renders multiple colors', async (page, url) => {
    105     await page.goto(`${url}/games/animal-letter/`, { waitUntil: 'domcontentloaded' });
    106     await page.waitForTimeout(2000);
    107     const colorCount = await page.evaluate(() => {
    108       const cv = document.querySelector('canvas');
    109       if (!cv) return 0;
    110       const seen = new Set();
    111       const gl = cv.getContext('webgl2') || cv.getContext('webgl');
    112       if (gl) {
    113         const pixels = new Uint8Array(cv.width * cv.height * 4);
    114         gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
    115         for (let i = 0; i < pixels.length; i += 32) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`);
    116       } else {
    117         const ctx  = cv.getContext('2d');
    118         const data = ctx.getImageData(0, 0, cv.width, cv.height).data;
    119         for (let i = 0; i < data.length; i += 32) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`);
    120       }
    121       return seen.size;
    122     });
    123     if (colorCount < 4) throw new Error(`Only ${colorCount} colors found — canvas may be blank`);
    124   });
    125 
    126   await test('animal-letter: correct key press advances score', async (page, url) => {
    127     await page.goto(`${url}/games/animal-letter/`, { waitUntil: 'domcontentloaded' });
    128     await page.waitForTimeout(2000);
    129     const before = await page.evaluate(() => window.__AL_SCENE__ && window.__AL_SCENE__.lettersFound);
    130     const letter  = await page.evaluate(() => window.__AL_SCENE__ && window.__AL_SCENE__.currentAnimal && window.__AL_SCENE__.currentAnimal.letter);
    131     if (!letter) throw new Error('Scene not ready');
    132     await page.keyboard.press(letter);
    133     await page.waitForTimeout(300);
    134     const after = await page.evaluate(() => window.__AL_SCENE__.lettersFound);
    135     if (after !== (before || 0) + 1) throw new Error(`Score did not advance: ${before} -> ${after}`);
    136   });
    137 
    138   // ── Animal Spell ──────────────────────────────────────────────────────────
    139 
    140   await test('animal-spell: loads, no JS errors, canvas exists', async (page, url, errors) => {
    141     await page.goto(`${url}/games/animal-spell/`, { waitUntil: 'domcontentloaded' });
    142     await page.waitForTimeout(2000);
    143     if (errors.length) throw new Error(errors[0]);
    144     const canvas = await page.$('canvas');
    145     if (!canvas) throw new Error('No canvas element');
    146   });
    147 
    148   await test('animal-spell: canvas renders multiple colors', async (page, url) => {
    149     await page.goto(`${url}/games/animal-spell/`, { waitUntil: 'domcontentloaded' });
    150     await page.waitForTimeout(2000);
    151     const colorCount = await page.evaluate(() => {
    152       const cv = document.querySelector('canvas');
    153       if (!cv) return 0;
    154       const seen = new Set();
    155       const gl = cv.getContext('webgl2') || cv.getContext('webgl');
    156       if (gl) {
    157         const pixels = new Uint8Array(cv.width * cv.height * 4);
    158         gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
    159         for (let i = 0; i < pixels.length; i += 32) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`);
    160       } else {
    161         const ctx  = cv.getContext('2d');
    162         const data = ctx.getImageData(0, 0, cv.width, cv.height).data;
    163         for (let i = 0; i < data.length; i += 32) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`);
    164       }
    165       return seen.size;
    166     });
    167     if (colorCount < 4) throw new Error(`Only ${colorCount} colors found — canvas may be blank`);
    168   });
    169 
    170   await test('animal-spell: correct key press advances letter index', async (page, url) => {
    171     await page.goto(`${url}/games/animal-spell/`, { waitUntil: 'domcontentloaded' });
    172     await page.waitForTimeout(2000);
    173     const before = await page.evaluate(() => window.__AS_SCENE__ && window.__AS_SCENE__.letterIndex);
    174     const letter  = await page.evaluate(() => {
    175       const s = window.__AS_SCENE__;
    176       return s && s.wordLetters && s.wordLetters[0];
    177     });
    178     if (!letter) throw new Error('Scene not ready');
    179     await page.keyboard.press(letter);
    180     await page.waitForTimeout(400);
    181     const after = await page.evaluate(() => window.__AS_SCENE__.letterIndex);
    182     if (after !== (before || 0) + 1) throw new Error(`Letter index did not advance: ${before} -> ${after}`);
    183   });
    184 
    185   // ── Work ──────────────────────────────────────────────────────────────────
    186 
    187   await test('work: loads, no JS errors, canvas exists', async (page, url, errors) => {
    188     await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' });
    189     await page.waitForTimeout(1500);
    190     if (errors.length) throw new Error(errors[0]);
    191     const canvas = await page.$('canvas');
    192     if (!canvas) throw new Error('No canvas element');
    193   });
    194 
    195   await test('work: story credit is hidden until a story is playing', async (page, url) => {
    196     await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' });
    197     await page.waitForTimeout(1200);
    198     if (await page.isVisible('#story-credits')) {
    199       throw new Error('Story credit should be hidden outside Stories mode');
    200     }
    201     // Enter Stories mode and check the credit matches the story actually chosen.
    202     const expected = await page.evaluate(() => {
    203       const sc = window.__WORK_SCENE__;
    204       sc.enterMode('madlib');
    205       return sc.madTemplate.source;
    206     });
    207     await page.waitForTimeout(300);
    208     if (!(await page.isVisible('#story-credits'))) {
    209       throw new Error('Story credit should be visible in Stories mode');
    210     }
    211     const shown = await page.$$eval('#story-credits a', (as) =>
    212       as.map(a => ({ href: a.getAttribute('href'), text: a.textContent })));
    213     if (shown.length !== 1) throw new Error(`Expected 1 credit link, got ${shown.length}`);
    214     if (shown[0].href !== expected.url || shown[0].text !== expected.title) {
    215       throw new Error(`Credit does not match active story: ${JSON.stringify(shown[0])}`);
    216     }
    217     const body = await page.textContent('#story-credits');
    218     if (!body.includes(expected.author)) throw new Error('Credit missing author');
    219     // Visible in the DOM is not enough: the page never scrolls, so the footer
    220     // must actually sit inside the viewport (regression: #game-container's
    221     // default min-height:auto pushed it below the fold).
    222     const box = await page.evaluate(() => {
    223       const r = document.getElementById('story-credits').getBoundingClientRect();
    224       return { top: r.top, bottom: r.bottom, innerH: window.innerHeight };
    225     });
    226     if (box.bottom > box.innerH + 1 || box.top >= box.innerH) {
    227       throw new Error(`Story credit is off-screen: ${JSON.stringify(box)}`);
    228     }
    229     // Leaving Stories mode hides it again.
    230     await page.evaluate(() => window.__WORK_SCENE__.enterMode('idle'));
    231     await page.waitForTimeout(200);
    232     if (await page.isVisible('#story-credits')) {
    233       throw new Error('Story credit should hide again on leaving Stories mode');
    234     }
    235   });
    236 
    237   await test('work: canvas renders multiple colors', async (page, url) => {
    238     await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' });
    239     await page.waitForTimeout(1500);
    240     const colorCount = await page.evaluate(() => {
    241       const cv = document.querySelector('canvas');
    242       if (!cv) return 0;
    243       const seen = new Set();
    244       const gl = cv.getContext('webgl2') || cv.getContext('webgl');
    245       if (gl) {
    246         const pixels = new Uint8Array(cv.width * cv.height * 4);
    247         gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
    248         for (let i = 0; i < pixels.length; i += 32) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`);
    249       } else {
    250         const ctx  = cv.getContext('2d');
    251         const data = ctx.getImageData(0, 0, cv.width, cv.height).data;
    252         for (let i = 0; i < data.length; i += 32) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`);
    253       }
    254       return seen.size;
    255     });
    256     if (colorCount < 4) throw new Error(`Only ${colorCount} colors found — canvas may be blank`);
    257   });
    258 
    259   await test('work: name round captures typed name on Enter', async (page, url) => {
    260     await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' });
    261     await page.waitForTimeout(1000);
    262     await page.evaluate(() => window.__WORK_SCENE__.enterMode('name'));
    263     await page.waitForFunction(() => window.__WORK_SCENE__.acceptInput === true, null, { timeout: 5000 });
    264     // delay so key-holds don't overlap; 0-delay typing triggers OS-style
    265     // auto-repeat in Chromium and garbles the buffer (e.g. "AABCCC").
    266     await page.keyboard.type('ABC', { delay: 40 });
    267     await page.keyboard.press('Enter');
    268     await page.waitForTimeout(200);
    269     const name = await page.evaluate(() => window.__WORK_SCENE__.playerName);
    270     if (name !== 'ABC') throw new Error(`Expected playerName "ABC", got "${name}"`);
    271   });
    272 
    273   await test('work: speed round auto-stops when name matches', async (page, url) => {
    274     await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' });
    275     await page.waitForTimeout(1000);
    276     await page.evaluate(() => {
    277       const s = window.__WORK_SCENE__;
    278       s.enterMode('name');
    279       s.playerName = 'Sam';
    280       s._nameRace();
    281     });
    282     await page.waitForFunction(() => window.__WORK_SCENE__.acceptInput === true, null, { timeout: 5000 });
    283     await page.keyboard.type('Sam', { delay: 40 });
    284     await page.waitForFunction(() => window.__WORK_SCENE__.nameState === 'result', null, { timeout: 5000 });
    285     const t = await page.evaluate(() => window.__WORK_SCENE__.lastTimeMs);
    286     if (!(t >= 0)) throw new Error(`Expected a finite lastTimeMs, got ${t}`);
    287   });
    288 
    289   // ── Fire Truck ────────────────────────────────────────────────────────────
    290 
    291   await test('fire-truck: loads, no JS errors, canvas exists', async (page, url, errors) => {
    292     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    293     await page.waitForTimeout(1800);
    294     if (errors.length) throw new Error(errors[0]);
    295     const canvas = await page.$('canvas');
    296     if (!canvas) throw new Error('No canvas element');
    297   });
    298 
    299   await test('fire-truck: canvas shows city colors', async (page, url) => {
    300     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    301     await page.waitForTimeout(1800);
    302     const colorCount = await page.evaluate(() => {
    303       const cv = document.querySelector('canvas');
    304       if (!cv) return 0;
    305       const seen = new Set();
    306       const gl = cv.getContext('webgl2') || cv.getContext('webgl');
    307       if (gl) {
    308         const pixels = new Uint8Array(cv.width * cv.height * 4);
    309         gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
    310         for (let i = 0; i < pixels.length; i += 48) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`);
    311       } else {
    312         const ctx = cv.getContext('2d');
    313         const data = ctx.getImageData(0, 0, cv.width, cv.height).data;
    314         for (let i = 0; i < data.length; i += 48) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`);
    315       }
    316       return seen.size;
    317     });
    318     if (colorCount < 6) throw new Error(`Only ${colorCount} colors found — city may not be rendering`);
    319   });
    320 
    321   await test('fire-truck: truck starts moving automatically', async (page, url) => {
    322     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    323     // debugDistancePx is cumulative from spawn. Whether the truck is still
    324     // driving or has already reached its first intersection (waiting), it can
    325     // only have got there by auto-driving — so a nonzero total proves motion.
    326     // (Comparing two late samples is racy: at 8x test speed both can land in
    327     // the same stationary 'waiting' state, giving a false zero delta.)
    328     await page.waitForTimeout(3000);
    329     const end = await page.evaluate(() => ({
    330       distance: window.__FT_SCENE__.debugDistancePx || 0,
    331       prompt: window.__FT_SCENE__.promptDir,
    332       state: window.__FT_SCENE__.state,
    333     }));
    334     if (end.distance < 120) {
    335       throw new Error(`Truck advanced only ${end.distance.toFixed(1)} path pixels from spawn (state ${end.state}/${end.prompt || 'none'})`);
    336     }
    337   });
    338 
    339   await test('fire-truck: wrong arrow stops the truck', async (page, url) => {
    340     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    341     await page.evaluate(() => { window.__FT_SCENE__.truck.setPosition(window.__FT_SCENE__.segmentEnd.x - 500, window.__FT_SCENE__.segmentEnd.y); });
    342     await page.waitForFunction(() => window.__FT_SCENE__ && !!window.__FT_SCENE__.promptDir, null, { timeout: 25000 });
    343     // Freeze the truck so it doesn't cross the intersection while playwright is IPCing
    344     await page.evaluate(() => { window.__FT_SCENE__.targetSpeed = 0; window.__FT_SCENE__.speed = 0; window.__FT_SCENE__.lastStepTime = performance.now(); });
    345     const promptDir = await page.evaluate(() => window.__FT_SCENE__.promptDir);
    346     const wrongKey = promptDir === 'left' ? 'ArrowRight' : promptDir === 'right' ? 'ArrowLeft' : 'ArrowLeft';
    347     await page.keyboard.press(wrongKey);
    348     await page.waitForFunction(() => window.__FT_SCENE__.state === 'stopped', null, { timeout: 5000 });
    349     const state = await page.evaluate(() => ({ state: window.__FT_SCENE__.state, failVisible: window.__FT_SCENE__.failVisible }));
    350     if (state.state !== 'stopped') throw new Error(`Expected stopped, got ${state.state}`);
    351     if (!state.failVisible) throw new Error('Expected failVisible to be true');
    352   });
    353 
    354   await test('fire-truck: correct arrow after failure resumes movement', async (page, url) => {
    355     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    356     await page.evaluate(() => { window.__FT_SCENE__.truck.setPosition(window.__FT_SCENE__.segmentEnd.x - 500, window.__FT_SCENE__.segmentEnd.y); });
    357     await page.waitForFunction(() => window.__FT_SCENE__ && !!window.__FT_SCENE__.promptDir, null, { timeout: 25000 });
    358     await page.evaluate(() => { window.__FT_SCENE__.targetSpeed = 0; window.__FT_SCENE__.speed = 0; window.__FT_SCENE__.lastStepTime = performance.now(); });
    359     const promptDir = await page.evaluate(() => window.__FT_SCENE__.promptDir);
    360     const wrongKey = promptDir === 'left' ? 'ArrowRight' : promptDir === 'right' ? 'ArrowLeft' : 'ArrowLeft';
    361     const correctKey = promptDir === 'left' ? 'ArrowLeft' : promptDir === 'right' ? 'ArrowRight' : 'ArrowUp';
    362     await page.keyboard.press(wrongKey);
    363     await page.waitForFunction(() => window.__FT_SCENE__.state === 'stopped', null, { timeout: 5000 });
    364     await page.keyboard.press(correctKey);
    365     try {
    366       await page.waitForFunction(() => window.__FT_SCENE__.state === 'driving', null, { timeout: 5000 });
    367     } catch (e) {
    368       const dbg = await page.evaluate(() => ({ state: window.__FT_SCENE__.state, promptDir: window.__FT_SCENE__.promptDir, resolved: window.__FT_SCENE__.promptResolved }));
    369       throw new Error(`Timeout driving. State: ${JSON.stringify(dbg)}`);
    370     }
    371     const state = await page.evaluate(() => ({ state: window.__FT_SCENE__.state }));
    372     if (state.state === 'stopped') throw new Error('Truck did not resume');
    373   });
    374 
    375   await test('fire-truck: island debug hooks and route bounds', async (page, url) => {
    376     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    377     await page.waitForTimeout(1800);
    378     const info = await page.evaluate(() => {
    379       const s = window.__FT_SCENE__;
    380       return {
    381         width: s.island.width,
    382         height: s.island.height,
    383         cell00: s.cellAt(0, 0).type,
    384         cell11: s.cellAt(1, 1).type,
    385         cell22: s.cellAt(2, 2).type,
    386         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),
    387         hasFourWayOnIsland: s.island.roads.some(c => s.cellAt(c.x, c.y).meta.kind === 'four'),
    388       };
    389     });
    390     if (info.width !== 50) throw new Error(`Expected width 50, got ${info.width}`);
    391     if (info.height !== 50) throw new Error(`Expected height 50, got ${info.height}`);
    392     if (info.cell00 !== 'water') throw new Error(`Expected cell00 water, got ${info.cell00}`);
    393     if (info.cell11 !== 'beach') throw new Error(`Expected cell11 beach, got ${info.cell11}`);
    394     if (info.cell22 !== 'road') throw new Error(`Expected cell22 road, got ${info.cell22}`);
    395     if (info.routeOutOfBounds) throw new Error('Route step out of bounds');
    396     if (!info.hasFourWayOnIsland) throw new Error('No four-way intersection on island');
    397   });
    398 
    399   await test('fire-truck: no input leads to waiting state (not stopped)', async (page, url) => {
    400     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    401     await page.evaluate(() => { window.__FT_SCENE__.truck.setPosition(window.__FT_SCENE__.segmentEnd.x - 500, window.__FT_SCENE__.segmentEnd.y); });
    402     await page.waitForFunction(() => window.__FT_SCENE__ && !!window.__FT_SCENE__.promptDir, null, { timeout: 25000 });
    403     await page.evaluate(() => { window.__FT_SCENE__.targetSpeed = 0; window.__FT_SCENE__.speed = 0; window.__FT_SCENE__.lastStepTime = performance.now(); });
    404     // Move truck up to the stop line without resolving. Place it just short of
    405     // segmentEnd *along the real approach vector* (the segment may run in any
    406     // direction) so it lands inside STOP_LINE_DIST regardless of orientation.
    407     await page.evaluate(() => {
    408       const s = window.__FT_SCENE__;
    409       const dx = s.segmentEnd.x - s.truck.x, dy = s.segmentEnd.y - s.truck.y;
    410       const d = Math.hypot(dx, dy) || 1;
    411       const short = 30; // well inside STOP_LINE_DIST (~216px)
    412       s.truck.setPosition(s.segmentEnd.x - (dx / d) * short, s.segmentEnd.y - (dy / d) * short);
    413       s.step(0.016);
    414     });
    415     const state = await page.evaluate(() => window.__FT_SCENE__.state);
    416     if (state !== 'waiting') throw new Error(`Expected waiting, got ${state}`);
    417   });
    418 
    419   await test('fire-truck: correct key from waiting resumes driving', async (page, url) => {
    420     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    421     await page.evaluate(() => { window.__FT_SCENE__.truck.setPosition(window.__FT_SCENE__.segmentEnd.x - 500, window.__FT_SCENE__.segmentEnd.y); });
    422     await page.waitForFunction(() => window.__FT_SCENE__ && !!window.__FT_SCENE__.promptDir, null, { timeout: 25000 });
    423     await page.evaluate(() => { window.__FT_SCENE__.targetSpeed = 0; window.__FT_SCENE__.speed = 0; window.__FT_SCENE__.lastStepTime = performance.now(); });
    424     const promptDir = await page.evaluate(() => window.__FT_SCENE__.promptDir);
    425     const correctKey = promptDir === 'left' ? 'ArrowLeft' : promptDir === 'right' ? 'ArrowRight' : 'ArrowUp';
    426     // Put into waiting: place just short of segmentEnd along the real approach
    427     // vector so it reaches the stop line whatever direction the segment runs.
    428     await page.evaluate(() => {
    429       const s = window.__FT_SCENE__;
    430       const dx = s.segmentEnd.x - s.truck.x, dy = s.segmentEnd.y - s.truck.y;
    431       const d = Math.hypot(dx, dy) || 1;
    432       const short = 30;
    433       s.truck.setPosition(s.segmentEnd.x - (dx / d) * short, s.segmentEnd.y - (dy / d) * short);
    434       s.step(0.016);
    435     });
    436     await page.keyboard.press(correctKey);
    437     try {
    438       await page.waitForFunction(() => window.__FT_SCENE__.state === 'driving', null, { timeout: 5000 });
    439     } catch (e) {
    440       const dbg = await page.evaluate(() => ({ state: window.__FT_SCENE__.state, promptDir: window.__FT_SCENE__.promptDir, resolved: window.__FT_SCENE__.promptResolved }));
    441       throw new Error(`Timeout driving. State: ${JSON.stringify(dbg)}`);
    442     }
    443   });
    444 
    445   await test('fire-truck: manual mode drives only while a key is held', async (page, url) => {
    446     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    447     await page.waitForFunction(() => !!window.__FT_SCENE__ && !!window.__FT_SCENE__.truck, null, { timeout: 25000 });
    448     await page.evaluate(() => window.__FT_SCENE__.setControlMode('manual'));
    449     const before = await page.evaluate(() => window.__FT_SCENE__.debugDistancePx || 0);
    450     // Pick any direction with a road exit from the anchored cell.
    451     const key = await page.evaluate(() => {
    452       const s = window.__FT_SCENE__;
    453       const map = { N: 'ArrowUp', E: 'ArrowRight', S: 'ArrowDown', W: 'ArrowLeft' };
    454       for (const c of ['N', 'E', 'S', 'W']) if (s.manualCell.exits[c]) return map[c];
    455       return null;
    456     });
    457     if (!key) throw new Error('Manual anchor cell has no road exits');
    458     await page.keyboard.down(key);
    459     await page.waitForFunction(
    460       (b) => (window.__FT_SCENE__.debugDistancePx || 0) > b + 50,
    461       before,
    462       { timeout: 5000 }
    463     );
    464     await page.keyboard.up(key);
    465     // Truck brakes to a stop and then stays put (unless the held drive already
    466     // reached the fire and launched the minigame, which also means it moved).
    467     await page.waitForFunction(
    468       () => window.__FT_SCENE__.speed === 0 || window.__FT_SCENE__.state === 'minigame',
    469       null,
    470       { timeout: 5000 }
    471     );
    472     const stopped = await page.evaluate(() => window.__FT_SCENE__.debugDistancePx);
    473     await page.waitForTimeout(400);
    474     const later = await page.evaluate(() => window.__FT_SCENE__.debugDistancePx);
    475     if (later - stopped > 1) throw new Error(`Truck kept moving after key release (${(later - stopped).toFixed(1)}px)`);
    476   });
    477 
    478   await test('fire-truck: manual mode flags wrong-way driving', async (page, url) => {
    479     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    480     await page.waitForFunction(() => !!window.__FT_SCENE__ && !!window.__FT_SCENE__.truck, null, { timeout: 25000 });
    481     const ok = await page.evaluate(() => {
    482       const s = window.__FT_SCENE__;
    483       s.setControlMode('manual');
    484       if (!s.fireDistances || !s.manualCell) return false;
    485       // Simulate arriving on a cell farther from the fire than the last one.
    486       const grid = s.island.grid;
    487       const here = s.manualCell;
    488       let worse = null;
    489       for (const c of ['N', 'E', 'S', 'W']) {
    490         if (!here.exits[c]) continue;
    491         const n = grid[here.y + window.FireTruckLib.DIRS[c].dy][here.x + window.FireTruckLib.DIRS[c].dx];
    492         if (s.fireDistances.get(n) > s.fireDistances.get(here)) { worse = n; break; }
    493       }
    494       if (!worse) return false;
    495       s.manualTarget = worse;
    496       s._manualArrive();
    497       return s.failVisible && s.statusText.text.includes('Wrong way');
    498     });
    499     if (!ok) throw new Error('Wrong-way feedback did not trigger');
    500   });
    501 
    502   await test('fire-truck: fire destination is set after load', async (page, url) => {
    503     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    504     await page.waitForTimeout(1800);
    505     const info = await page.evaluate(() => {
    506       const s = window.__FT_SCENE__;
    507       return {
    508         hasFireCell: !!s.fireCell,
    509         hasFireBuildingCell: !!s.fireBuildingCell,
    510         fireIsRoad: s.fireCell ? s.cellAt(s.fireCell.x, s.fireCell.y).type === 'road' : false,
    511         buildingIsBuilding: s.fireBuildingCell ? s.cellAt(s.fireBuildingCell.x, s.fireBuildingCell.y).type === 'building' : false,
    512         adjacent: s.fireCell && s.fireBuildingCell ? Math.abs(s.fireCell.x - s.fireBuildingCell.x) + Math.abs(s.fireCell.y - s.fireBuildingCell.y) === 1 : false,
    513       };
    514     });
    515     if (!info.hasFireCell) throw new Error('fireCell not set');
    516     if (!info.hasFireBuildingCell) throw new Error('fireBuildingCell not set');
    517     if (!info.fireIsRoad) throw new Error('fireCell is not a road');
    518     if (!info.buildingIsBuilding) throw new Error('fireBuildingCell is not a building');
    519     if (!info.adjacent) throw new Error('fireCell and fireBuildingCell are not adjacent');
    520   });
    521 
    522   await test('fire-truck: reaching the fire launches the hose minigame', async (page, url, errors) => {
    523     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    524     await page.waitForTimeout(1800);
    525     // Teleport the truck onto fireCell so the fire trigger fires
    526     await page.evaluate(() => {
    527       const s = window.__FT_SCENE__;
    528       if (s.fireCell) {
    529         s.state = 'driving';
    530         s.speed = 0;
    531         s.targetSpeed = 0;
    532         s.truck.setPosition(s.fireCell.x * s.cellSize, s.fireCell.y * s.cellSize);
    533         s.step(0.016);
    534       }
    535     });
    536     await page.waitForFunction(
    537       () => window.__FT_HOSE_SCENE__ && window.__FT_HOSE_SCENE__.fires && window.__FT_HOSE_SCENE__.fires.length > 0,
    538       null, { timeout: 5000 });
    539     if (errors.length) throw new Error(errors[0]);
    540     const state = await page.evaluate(() => window.__FT_SCENE__.state);
    541     if (state !== 'minigame') throw new Error(`drive scene should be in minigame state, got: ${state}`);
    542     // Aim at the first burning window and hold SPACE → quench progress rises
    543     await page.evaluate(() => {
    544       const h = window.__FT_HOSE_SCENE__;
    545       const f = h.fires[0];
    546       h.aimX = f.cx;
    547       h.aimY = f.cy;
    548     });
    549     await page.keyboard.down('Space');
    550     await page.waitForTimeout(500);
    551     const progress = await page.evaluate(() => window.__FT_HOSE_SCENE__.fires[0].progress);
    552     await page.keyboard.up('Space');
    553     if (!(progress > 0)) throw new Error(`Quench progress did not accumulate: ${progress}`);
    554   });
    555 
    556   await test('fire-truck: quenching every window returns to driving with counter +1', async (page, url, errors) => {
    557     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    558     await page.waitForTimeout(1800);
    559     await page.evaluate(() => {
    560       const s = window.__FT_SCENE__;
    561       if (s.fireCell) {
    562         s.state = 'driving';
    563         s.speed = 0;
    564         s.targetSpeed = 0;
    565         s.truck.setPosition(s.fireCell.x * s.cellSize, s.fireCell.y * s.cellSize);
    566         s.step(0.016);
    567       }
    568     });
    569     await page.waitForFunction(
    570       () => window.__FT_HOSE_SCENE__ && window.__FT_HOSE_SCENE__.fires && window.__FT_HOSE_SCENE__.fires.length > 0,
    571       null, { timeout: 5000 });
    572     await page.evaluate(() => {
    573       const h = window.__FT_HOSE_SCENE__;
    574       for (const f of h.fires) h._quenchWindow(f);
    575     });
    576     // Celebration plays (~2.6 s), then the drive scene resumes and re-routes (~0.6 s)
    577     await page.waitForFunction(
    578       () => window.__FT_SCENE__.firesExtinguished === 1 && window.__FT_SCENE__.state === 'driving',
    579       null, { timeout: 10000 });
    580     if (errors.length) throw new Error(errors[0]);
    581     const info = await page.evaluate(() => ({
    582       hoseGone: !window.__FT_HOSE_SCENE__,
    583       hasNextFire: !!window.__FT_SCENE__.fireCell,
    584     }));
    585     if (!info.hoseGone) throw new Error('FireHoseScene still active after completion');
    586     if (!info.hasNextFire) throw new Error('next fire destination was not set after minigame');
    587   });
    588 
    589   await test('fire-truck: correct arrow clears prompt immediately', async (page, url) => {
    590     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    591     await page.waitForTimeout(1800);
    592     // Teleport near segmentEnd so prompt triggers, then press the correct arrow
    593     await page.evaluate(() => {
    594       const s = window.__FT_SCENE__;
    595       const end = s.segmentEnd;
    596       s.state = 'driving';
    597       s.speed = 0;
    598       s.targetSpeed = 0;
    599       s.truck.setPosition(end.x - 260, end.y);
    600       s.step(0.016);
    601     });
    602     await page.waitForFunction(() => !!window.__FT_SCENE__.promptDir, null, { timeout: 5000 });
    603     const correctKey = await page.evaluate(() => {
    604       const dir = window.__FT_SCENE__.promptDir;
    605       return dir === 'left' ? 'ArrowLeft' : dir === 'right' ? 'ArrowRight' : 'ArrowUp';
    606     });
    607     await page.keyboard.press(correctKey);
    608     // promptDir must be null on the very next evaluate (no round-trip delay)
    609     const afterPromptDir = await page.evaluate(() => window.__FT_SCENE__.promptDir);
    610     if (afterPromptDir !== null) throw new Error(`promptDir should be null immediately, got: ${afterPromptDir}`);
    611     const promptText = await page.evaluate(() => window.__FT_SCENE__.promptText.text);
    612     if (promptText !== '') throw new Error(`promptText should be empty, got: "${promptText}"`);
    613   });
    614 
    615   await test('fire-truck: fire distance setting is honored at startup', async (page, url) => {
    616     await page.addInitScript(() => { window.GAME_FIRE_DISTANCE = 25; });
    617     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    618     await page.waitForTimeout(1800);
    619     const result = await page.evaluate(() => {
    620       const s = window.__FT_SCENE__;
    621       if (!s.fireCell) return { ok: false, reason: 'no fireCell' };
    622       const fromCell = s.island.grid[s.island.routeStart.y][s.island.routeStart.x];
    623       const FireTruckLib = window.FireTruckLib;
    624       const dists = FireTruckLib.bfsRoadDistances(s.island.grid, fromCell);
    625       const d = dists.get(s.island.grid[s.fireCell.y][s.fireCell.x]);
    626       return { ok: d != null && d >= 20 && d <= 30, d };
    627     });
    628     if (!result.ok) throw new Error(`Fire distance ${result.d} not in [20,30] for targetDistance=25`);
    629   });
    630 
    631   await test('fire-truck: endgame scene renders, exposes __FT_END_SCENE__, no errors', async (page, url, errors) => {
    632     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    633     await page.waitForTimeout(1800);
    634     if (errors.length) throw new Error(errors[0]);
    635     await page.evaluate(() => { window.__FT_SCENE__.scene.start('FireTruckEndScene'); });
    636     await page.waitForTimeout(1200);
    637     if (errors.length) throw new Error(errors[0]);
    638     const hasEndScene = await page.evaluate(() => !!window.__FT_END_SCENE__);
    639     if (!hasEndScene) throw new Error('__FT_END_SCENE__ not exposed after scene start');
    640     const colorCount = await page.evaluate(() => {
    641       const cv = document.querySelector('canvas');
    642       if (!cv) return 0;
    643       const seen = new Set();
    644       const sampleW = 200, sampleH = 200;
    645       const gl = cv.getContext('webgl2') || cv.getContext('webgl');
    646       if (gl) {
    647         const pixels = new Uint8Array(sampleW * sampleH * 4);
    648         gl.readPixels(0, 0, sampleW, sampleH, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
    649         for (let i = 0; i < pixels.length; i += 16) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`);
    650       } else {
    651         // Phaser falls back to the Canvas (2D) renderer in headless Chromium.
    652         const ctx = cv.getContext('2d');
    653         const w = Math.min(cv.width, sampleW), h = Math.min(cv.height, sampleH);
    654         const data = ctx.getImageData(0, 0, w, h).data;
    655         for (let i = 0; i < data.length; i += 16) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`);
    656       }
    657       return seen.size;
    658     });
    659     if (colorCount < 5) throw new Error(`Endgame scene rendered only ${colorCount} distinct colors — may be blank`);
    660   });
    661 
    662   await test('fire-truck: play again button restarts FireTruckScene with reset counter', async (page, url, errors) => {
    663     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    664     await page.waitForTimeout(1800);
    665     await page.evaluate(() => { window.__FT_SCENE__.scene.start('FireTruckEndScene'); });
    666     await page.waitForTimeout(1200);
    667     if (errors.length) throw new Error(errors[0]);
    668     const hasEnd = await page.evaluate(() => !!window.__FT_END_SCENE__);
    669     if (!hasEnd) throw new Error('FireTruckEndScene did not start');
    670     const W = await page.evaluate(() => window.innerWidth);
    671     const H = await page.evaluate(() => window.innerHeight);
    672     await page.mouse.click(W / 2, H * 0.91);
    673     await page.waitForTimeout(1400);
    674     if (errors.length) throw new Error(errors[0]);
    675     const result = await page.evaluate(() => {
    676       const s = window.__FT_SCENE__;
    677       return { hasScene: !!s, counter: s ? s.firesExtinguished : -1 };
    678     });
    679     if (!result.hasScene) throw new Error('FireTruckScene not restarted after Play Again');
    680     if (result.counter !== 0) throw new Error(`firesExtinguished should be 0 after restart, got ${result.counter}`);
    681   });
    682 
    683   await test('fire-truck: ambient traffic spawns and stays on roads', async (page, url) => {
    684     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    685     await page.waitForTimeout(1800);
    686     const info = await page.evaluate(() => {
    687       const s = window.__FT_SCENE__;
    688       const movers = [
    689         ...(s.trafficCars || []), ...(s.buses || []),
    690         ...(s.bicyclists || []), ...(s.pedestrians || []),
    691       ];
    692       const allRoad = movers.every(m =>
    693         s.cellAt(m.from.x, m.from.y).type === 'road' &&
    694         s.cellAt(m.to.x, m.to.y).type === 'road');
    695       return {
    696         cars: (s.trafficCars || []).length,
    697         buses: (s.buses || []).length,
    698         bikes: (s.bicyclists || []).length,
    699         peds: (s.pedestrians || []).length,
    700         allRoad,
    701         ambientReady: s.ambientReady,
    702       };
    703     });
    704     if (!info.ambientReady) throw new Error('ambient systems not ready');
    705     if (info.cars === 0) throw new Error('no traffic cars spawned');
    706     if (info.buses === 0) throw new Error('no buses spawned');
    707     if (info.bikes === 0) throw new Error('no bicyclists spawned');
    708     if (info.peds < 20) throw new Error(`expected a bustling crowd, got ${info.peds} pedestrians`);
    709     if (!info.allRoad) throw new Error('an ambient mover left the road graph');
    710   });
    711 
    712   // ── On-screen touch controls ────────────────────────────────────────────────
    713 
    714   // Helper: returns an ElementHandle for the touch key whose label === text.
    715   async function touchKey(page, text) {
    716     const h = await page.evaluateHandle(
    717       (t) => [...document.querySelectorAll('#kg-touch .kg-key')].find(b => b.textContent === t),
    718       text
    719     );
    720     const el = h.asElement();
    721     if (!el) throw new Error(`On-screen key "${text}" not found`);
    722     return el;
    723   }
    724 
    725   await test('touch: controls hidden by default on non-touch browser', async (page, url) => {
    726     await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' });
    727     await page.waitForTimeout(800);
    728     const bar = await page.$('#kg-touch');
    729     if (bar) throw new Error('Touch bar should not render on a non-touch device');
    730   });
    731 
    732   await test('touch: ?kbd=1 forces the on-screen keyboard to render', async (page, url) => {
    733     await page.goto(`${url}/games/letter-find/?kbd=1`, { waitUntil: 'domcontentloaded' });
    734     await page.waitForSelector('#kg-touch', { timeout: 5000 });
    735     const keys = await page.$$eval('#kg-touch .kg-key', els => els.length);
    736     if (keys !== 26) throw new Error(`Expected 26 alphabet keys, got ${keys}`);
    737   });
    738 
    739   await test('touch: letter-find on-screen key advances score', async (page, url) => {
    740     await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
    741     await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' });
    742     await page.waitForSelector('#kg-touch');
    743     await page.waitForFunction(() => window.__LF_SCENE__ && window.__LF_SCENE__.accepting, null, { timeout: 5000 });
    744     const target = await page.evaluate(() => window.__LF_SCENE__.targetChar);
    745     const before = await page.evaluate(() => window.__LF_SCENE__.lettersFound);
    746     const key = await touchKey(page, target);
    747     await key.click();
    748     await page.waitForTimeout(300);
    749     const after = await page.evaluate(() => window.__LF_SCENE__.lettersFound);
    750     if (after !== before + 1) throw new Error(`Touch key did not advance score: ${before} -> ${after}`);
    751   });
    752 
    753   await test('touch: animal-spell on-screen key advances letter index', async (page, url) => {
    754     await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
    755     await page.goto(`${url}/games/animal-spell/`, { waitUntil: 'domcontentloaded' });
    756     await page.waitForSelector('#kg-touch');
    757     await page.waitForFunction(() => window.__AS_SCENE__ && window.__AS_SCENE__.accepting, null, { timeout: 6000 });
    758     const letter = await page.evaluate(() => window.__AS_SCENE__.wordLetters[0]);
    759     const before = await page.evaluate(() => window.__AS_SCENE__.letterIndex);
    760     const key = await touchKey(page, letter);
    761     await key.click();
    762     await page.waitForTimeout(400);
    763     const after = await page.evaluate(() => window.__AS_SCENE__.letterIndex);
    764     if (after !== before + 1) throw new Error(`Touch key did not advance letter index: ${before} -> ${after}`);
    765   });
    766 
    767   await test('touch: work text keyboard types name and submits on Enter', async (page, url) => {
    768     await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
    769     await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' });
    770     await page.waitForSelector('#kg-touch');
    771     await page.evaluate(() => window.__WORK_SCENE__.enterMode('name'));
    772     await page.waitForFunction(() => window.__WORK_SCENE__.acceptInput === true, null, { timeout: 5000 });
    773     for (const ch of ['A', 'B', 'C']) {
    774       const k = await touchKey(page, ch);
    775       await k.click();
    776       await page.waitForTimeout(50);
    777     }
    778     const enter = await touchKey(page, '⏎');
    779     await enter.click();
    780     await page.waitForTimeout(200);
    781     const name = await page.evaluate(() => window.__WORK_SCENE__.playerName);
    782     if (name !== 'ABC') throw new Error(`Expected playerName "ABC" via touch keyboard, got "${name}"`);
    783   });
    784 
    785   await test('touch: work keyboard has Space and Backspace keys', async (page, url) => {
    786     await page.goto(`${url}/games/work/?kbd=1`, { waitUntil: 'domcontentloaded' });
    787     await page.waitForSelector('#kg-touch');
    788     const labels = await page.$$eval('#kg-touch .kg-key', els => els.map(e => e.textContent));
    789     if (!labels.includes('space')) throw new Error('Space key missing from work keyboard');
    790     if (!labels.includes('⌫')) throw new Error('Backspace key missing from work keyboard');
    791   });
    792 
    793   await test('touch: fire-truck drive pad clears prompt on correct arrow', async (page, url) => {
    794     await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
    795     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    796     await page.waitForSelector('#kg-touch .kg-key-arrow');
    797     await page.evaluate(() => {
    798       const s = window.__FT_SCENE__;
    799       const end = s.segmentEnd;
    800       s.state = 'driving'; s.speed = 0; s.targetSpeed = 0;
    801       s.truck.setPosition(end.x - 260, end.y);
    802       s.step(0.016);
    803     });
    804     await page.waitForFunction(() => !!window.__FT_SCENE__.promptDir, null, { timeout: 6000 });
    805     const dir = await page.evaluate(() => window.__FT_SCENE__.promptDir);
    806     const label = dir === 'left' ? '◀' : dir === 'right' ? '▶' : '▲';
    807     const arrow = await touchKey(page, label);
    808     await arrow.click();
    809     const after = await page.evaluate(() => window.__FT_SCENE__.promptDir);
    810     if (after !== null) throw new Error(`promptDir should be null after touch arrow, got ${after}`);
    811   });
    812 
    813   await test('touch: fire-truck spray button accumulates extinguish (press-hold)', async (page, url) => {
    814     await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
    815     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
    816     await page.waitForSelector('#kg-touch');
    817     await page.evaluate(() => {
    818       const s = window.__FT_SCENE__;
    819       if (s.fireCell) {
    820         s.state = 'driving'; s.speed = 0; s.targetSpeed = 0;
    821         s.truck.setPosition(s.fireCell.x * s.cellSize, s.fireCell.y * s.cellSize);
    822         s.step(0.016);
    823       }
    824     });
    825     await page.waitForFunction(
    826       () => window.__FT_HOSE_SCENE__ && window.__FT_HOSE_SCENE__.fires && window.__FT_HOSE_SCENE__.fires.length > 0,
    827       null, { timeout: 6000 });
    828     await page.evaluate(() => {
    829       const h = window.__FT_HOSE_SCENE__;
    830       const f = h.fires[0];
    831       h.aimX = f.cx;
    832       h.aimY = f.cy;
    833     });
    834     const spray = await touchKey(page, '💦 Spray');
    835     const box = await spray.boundingBox();
    836     await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
    837     await page.mouse.down();
    838     await page.waitForTimeout(400);
    839     const after = await page.evaluate(() => window.__FT_HOSE_SCENE__.fires[0].progress);
    840     await page.mouse.up();
    841     if (after <= 0) throw new Error(`Spray hold did not accumulate quench progress: ${after}`);
    842   });
    843 
    844 
    845   // ── Story Stamp (DOM game, no canvas) ─────────────────────────────────────
    846 
    847   const SS_URL = `${url}/games/story-stamp/`;
    848   const ssState = (page) => page.evaluate(() => window.__SS_GAME__ && window.__SS_GAME__.state);
    849   const ssWait  = (page, st, ms) => page.waitForFunction((s) => window.__SS_GAME__ && window.__SS_GAME__.state === s, st, { timeout: ms || 8000 })
    850     .catch(async () => { throw new Error(`state never became ${st} (now ${await ssState(page)})`); });
    851   async function ssOpen(page, opts) {
    852     await page.goto(SS_URL + ((opts && opts.query) || ''), { waitUntil: 'domcontentloaded' });
    853     await page.waitForFunction(() => !!window.__SS_GAME__);
    854   }
    855   // Start a profile and call the first patron in the queue: lands on the request slip.
    856   async function ssQuickStart(page, name, pick) {
    857     await page.evaluate((n) => { window.__SS_GAME__.quickStart(n); }, name || 'Tester');
    858     await ssWait(page, 'queue');
    859     if (pick != null) await page.keyboard.press(String(pick + 1)); else await page.keyboard.press('Enter');
    860     await ssWait(page, 'read', 15000);
    861   }
    862   // Serve the current patron end to end: type the letters, sound it out, pick the book.
    863   async function ssServe(page) {
    864     const o = await page.evaluate(() => ({ graphemes: window.__SS_GAME__.order.graphemes, word: window.__SS_GAME__.order.word.w, mode: window.__SS_GAME__.order.mode }));
    865     await page.keyboard.type(o.graphemes.join(''), { delay: 20 });
    866     await ssWait(page, 'blend');
    867     await page.keyboard.press('Space');
    868     await page.waitForFunction(() => ['choose', 'stamp', 'serve', 'arrive', 'queue', 'dayend'].includes(window.__SS_GAME__.state), null, { timeout: 15000 });
    869     if ((await ssState(page)) === 'choose') {
    870       const idx = await page.evaluate((w) => window.__SS_GAME__.order.cards.findIndex((c) => c.w === w), o.word);
    871       await page.keyboard.press(String(idx + 1));
    872     }
    873     await page.waitForFunction(() => ['queue', 'dayend'].includes(window.__SS_GAME__.state), null, { timeout: 15000 });
    874     return o;
    875   }
    876 
    877   await test('story-stamp: loads, no JS errors, library scene + profile screen render', async (page, url, errors) => {
    878     await ssOpen(page);
    879     await page.waitForTimeout(400);
    880     if (errors.length) throw new Error(errors[0]);
    881     if (!(await page.$('.ss-root .ss-desk'))) throw new Error('scene not built');
    882     if ((await ssState(page)) !== 'profiles') throw new Error('should start on the profile screen');
    883     if (!(await page.isVisible('.ss-overlay'))) throw new Error('profile overlay hidden');
    884     const imgs = await page.evaluate(() => Array.from(document.images).filter((i) => i.src.includes('/assets/img/')).map((i) => i.complete && i.naturalWidth > 0));
    885     if (!imgs.length || imgs.includes(false)) throw new Error('scene pictures did not load');
    886   });
    887 
    888   await test('story-stamp: keyboard alone creates a librarian and a queue of patrons with topics appears', async (page, url, errors) => {
    889     await ssOpen(page);
    890     await page.keyboard.press('Enter');          // "New librarian"
    891     if ((await ssState(page)) !== 'avatar') throw new Error('avatar step not reached');
    892     await page.keyboard.press('ArrowRight');
    893     await page.keyboard.press('Enter');
    894     await page.keyboard.type('ada');
    895     await page.keyboard.press('Enter');
    896     await ssWait(page, 'queue');
    897     const info = await page.evaluate(() => {
    898       const g = window.__SS_GAME__;
    899       return { name: g.profile.name, patrons: document.querySelectorAll('.ss-patron').length, n: g.queue.length,
    900                topics: new Set(g.queue.map((q) => q.topic.id)).size, signs: document.querySelectorAll('.ss-sign-card').length,
    901                overlay: !document.querySelector('.ss-overlay').hidden && getComputedStyle(document.querySelector('.ss-overlay')).display !== 'none',
    902                stored: JSON.parse(localStorage.getItem('kg-story-stamp-v1')).profiles.length };
    903     });
    904     if (info.name !== 'Ada') throw new Error(`name not captured: ${JSON.stringify(info)}`);
    905     if (info.n < 2 || info.patrons !== info.n || info.signs !== info.n) throw new Error(`queue wrong: ${JSON.stringify(info)}`);
    906     if (info.topics !== info.n) throw new Error('every patron should want a different topic');
    907     if (info.overlay) throw new Error('profile overlay should be gone once the desk opens');
    908     if (info.stored !== 1) throw new Error('profile not saved to localStorage');
    909     // Choosing a patron by arrow + Enter brings the request slip.
    910     await page.keyboard.press('ArrowRight');
    911     const sel = await page.evaluate(() => window.__SS_GAME__.sel);
    912     if (sel !== 1) throw new Error('arrow did not move the selection');
    913     await page.keyboard.press('Enter');
    914     await ssWait(page, 'read', 15000);
    915     const o = await page.evaluate(() => ({ boxes: document.querySelectorAll('.ss-box').length, n: window.__SS_GAME__.order.graphemes.length,
    916       mode: window.__SS_GAME__.order.mode, topic: window.__SS_GAME__.order.topic.id, t: window.__SS_GAME__.order.word.t, cust: document.querySelector('.ss-cust-name').textContent,
    917       counted: window.__SS_GAME__.profile.topics }));
    918     if (o.boxes !== o.n || o.n < 2) throw new Error(`sound boxes wrong: ${JSON.stringify(o)}`);
    919     if (o.mode !== 'read') throw new Error('first book must be read mode');
    920     if (o.topic !== 'any' && !o.t.includes(o.topic)) throw new Error('book does not match the topic asked for');
    921     if (!o.cust) throw new Error('no patron name shown');
    922     if (!Object.keys(o.counted).length) throw new Error('topic choice not recorded');
    923     if (errors.length) throw new Error(errors[0]);
    924   });
    925 
    926   await test('story-stamp: stamping the letters plays each sound; reading it out never plays the whole word', async (page, url, errors) => {
    927     await ssOpen(page);
    928     await ssQuickStart(page);
    929     const o = await page.evaluate(() => ({ graphemes: window.__SS_GAME__.order.graphemes, units: window.__SS_GAME__.order.units, word: window.__SS_GAME__.order.word.w }));
    930     await page.evaluate(() => { window.__SS_GAME__.audio.log.length = 0; });
    931     await page.keyboard.type(o.graphemes.join(''), { delay: 20 });
    932     await ssWait(page, 'blend');
    933     const typed = await page.evaluate(() => ({ stamped: document.querySelectorAll('.ss-box.stamped').length, done: document.querySelectorAll('.ss-box.done').length,
    934       played: window.__SS_GAME__.audio.log.filter((e) => e.kind === 'phoneme').map((e) => e.id), words: window.__SS_GAME__.audio.log.filter((e) => e.kind === 'word').length }));
    935     if (typed.stamped !== o.graphemes.length || typed.done !== o.graphemes.length) throw new Error(`boxes not all stamped: ${JSON.stringify(typed)}`);
    936     const expect = [].concat(...o.units);
    937     if (JSON.stringify(typed.played) !== JSON.stringify(expect)) throw new Error(`phonemes played ${typed.played} != ${expect}`);
    938     if (typed.words) throw new Error('the word must not be played while typing');
    939     await page.evaluate(() => { window.__SS_GAME__.audio.log.length = 0; });
    940     await page.keyboard.press('Space');
    941     await ssWait(page, 'choose', 15000);
    942     // Clips are logged when scheduled; order them by when they actually sound.
    943     const blend = (await page.evaluate(() => window.__SS_GAME__.audio.log)).filter((e) => e.kind !== 'speak').sort((a, b) => a.at - b.at);
    944     const ph = blend.filter((e) => e.kind === 'phoneme');
    945     if (ph.length !== expect.length * 3) throw new Error(`sounding out should play every sound 3 times, played ${ph.length}`);
    946     if (blend.some((e) => e.kind === 'word')) throw new Error('the whole word must never be played: the child has to say it');
    947     const first = ph.slice(0, expect.length), last = ph.slice(-expect.length);
    948     const held = first.some((e, i) => e.len > last[i].len);
    949     if (!held) throw new Error('first pass should hold sounds longer than the last');
    950     if (!(await page.evaluate(() => document.querySelector('.ss-boxes').classList.contains('fused')))) throw new Error('boxes did not fuse');
    951     const cards = await page.evaluate(() => document.querySelectorAll('.ss-book').length);
    952     if (cards !== 3) throw new Error(`expected 3 books on the cart, got ${cards}`);
    953     const idx = await page.evaluate((w) => window.__SS_GAME__.order.cards.findIndex((c) => c.w === w), o.word);
    954     await page.keyboard.press(String(idx + 1));
    955     await page.waitForFunction(() => document.querySelector('.ss-book.stamped'), null, { timeout: 8000 }).catch(() => { throw new Error('book never got stamped'); });
    956     await ssWait(page, 'queue', 15000);
    957     const rec = await page.evaluate((w) => window.__SS_GAME__.profile.words[w], o.word);
    958     if (!rec || rec.streak !== 1) throw new Error(`word not recorded as first-try: ${JSON.stringify(rec)}`);
    959     if (errors.length) throw new Error(errors[0]);
    960   });
    961 
    962   await test('story-stamp: a wrong key gets a re-demonstration, never a fail state', async (page, url) => {
    963     await ssOpen(page);
    964     await ssQuickStart(page);
    965     const o = await page.evaluate(() => ({ graphemes: window.__SS_GAME__.order.graphemes, first: window.__SS_GAME__.order.units[0] }));
    966     const wrong = 'abcdefghijklmnopqrstuvwxyz'.split('').find((c) => c !== o.graphemes[0][0]);
    967     await page.evaluate(() => { window.__SS_GAME__.audio.log.length = 0; });
    968     await page.keyboard.press(wrong);
    969     await page.waitForFunction(() => window.__SS_GAME__.busy === false, null, { timeout: 5000 });
    970     const after = await page.evaluate(() => ({ idx: window.__SS_GAME__.order.idx, missed: window.__SS_GAME__.order.missed, state: window.__SS_GAME__.state,
    971       log: window.__SS_GAME__.audio.log.map((e) => e.kind + ':' + e.id) }));
    972     if (after.idx !== 0 || after.state !== 'read') throw new Error(`wrong key changed progress: ${JSON.stringify(after)}`);
    973     if (!after.missed) throw new Error('miss not recorded');
    974     if (!after.log.includes('phoneme:' + o.first[0])) throw new Error(`expected sound re-demonstrated, log: ${after.log}`);
    975     await page.keyboard.type(o.graphemes.join(''), { delay: 20 });
    976     await ssWait(page, 'blend');
    977   });
    978 
    979   await test('story-stamp: a wrong book is corrected by re-reading; the third miss gives the word away', async (page, url) => {
    980     await ssOpen(page);
    981     await ssQuickStart(page);
    982     const o = await page.evaluate(() => ({ graphemes: window.__SS_GAME__.order.graphemes, word: window.__SS_GAME__.order.word.w }));
    983     await page.keyboard.type(o.graphemes.join(''), { delay: 20 });
    984     await ssWait(page, 'blend');
    985     await page.keyboard.press('Space');
    986     await ssWait(page, 'choose', 15000);
    987     const wrongIdx = await page.evaluate((w) => window.__SS_GAME__.order.cards.findIndex((c) => c.w !== w), o.word);
    988     await page.evaluate(() => { window.__SS_GAME__.audio.log.length = 0; });
    989     await page.keyboard.press(String(wrongIdx + 1));
    990     await page.waitForFunction(() => window.__SS_GAME__.state === 'choose' && window.__SS_GAME__.busy === false, null, { timeout: 8000 });
    991     const log = await page.evaluate(() => window.__SS_GAME__.audio.log.map((e) => e.kind + ':' + e.id));
    992     if (log.some((e) => e === 'word:' + o.word)) throw new Error('the word must not be given away on the first miss');
    993     if (log.filter((e) => e.startsWith('phoneme:')).length < o.graphemes.length) throw new Error('sounds should be re-read after a miss');
    994     // Second miss: a wrong book is taken away. Third miss: the word is said and the book pointed out.
    995     let again = await page.evaluate((w) => window.__SS_GAME__.order.cards.findIndex((c) => c.w !== w), o.word);
    996     await page.keyboard.press(String(again + 1));
    997     await page.waitForFunction(() => window.__SS_GAME__.state === 'choose' && window.__SS_GAME__.busy === false, null, { timeout: 8000 });
    998     const two = await page.evaluate(() => window.__SS_GAME__.order.cards.length);
    999     if (two !== 2) throw new Error(`expected 2 books after the second miss, got ${two}`);
   1000     again = await page.evaluate((w) => window.__SS_GAME__.order.cards.findIndex((c) => c.w !== w), o.word);
   1001     await page.evaluate(() => { window.__SS_GAME__.audio.log.length = 0; });
   1002     await page.keyboard.press(String(again + 1));
   1003     await page.waitForFunction(() => window.__SS_GAME__.state === 'choose' && window.__SS_GAME__.busy === false, null, { timeout: 8000 });
   1004     const help = await page.evaluate((w) => ({ said: window.__SS_GAME__.audio.log.some((e) => e.kind === 'word' && e.id === w), pointed: !!document.querySelector('.ss-book.point') }), o.word);
   1005     if (!help.said || !help.pointed) throw new Error(`third miss should give the word and point at the book: ${JSON.stringify(help)}`);
   1006     const rightIdx = await page.evaluate((w) => window.__SS_GAME__.order.cards.findIndex((c) => c.w === w), o.word);
   1007     await page.keyboard.press(String(rightIdx + 1));
   1008     await ssWait(page, 'queue', 15000);
   1009     const rec = await page.evaluate((w) => window.__SS_GAME__.profile.words[w], o.word);
   1010     if (rec.streak !== 0 || rec.lapses !== 1) throw new Error(`mix-up should count as a lapse: ${JSON.stringify(rec)}`);
   1011   });
   1012 
   1013   await test('story-stamp: six books make a day, then the library reopens', async (page, url, errors) => {
   1014     await ssOpen(page);
   1015     await ssQuickStart(page);
   1016     const words = [];
   1017     for (let i = 0; i < 6; i++) {
   1018       words.push((await ssServe(page)).word);
   1019       const st = await ssState(page);
   1020       if (i < 5) {
   1021         if (st !== 'queue') throw new Error(`book ${i + 1}: expected the queue, got ${st}`);
   1022         await page.keyboard.press('Enter');
   1023         await ssWait(page, 'read', 15000);
   1024       }
   1025     }
   1026     if ((await ssState(page)) !== 'dayend') throw new Error('day did not end after six books');
   1027     const dots = await page.evaluate(() => document.querySelectorAll('.ss-day span.on').length);
   1028     if (dots !== 6) throw new Error(`day progress shows ${dots}/6`);
   1029     await page.keyboard.press('Enter');
   1030     await ssWait(page, 'queue', 15000);
   1031     const p = await page.evaluate(() => ({ orders: window.__SS_GAME__.profile.orders, introduced: window.__SS_GAME__.profile.introduced.length }));
   1032     if (p.orders !== 6) throw new Error(`profile.orders = ${p.orders}`);
   1033     if (p.introduced < 7) throw new Error('six clean books should have introduced a new sound');
   1034     if (errors.length) throw new Error(errors[0]);
   1035   });
   1036 
   1037   await test('story-stamp: a return says the word, hides the letters, and is typed by ear', async (page, url) => {
   1038     await ssOpen(page);
   1039     await page.evaluate(() => {
   1040       const g = window.__SS_GAME__, SS = window.SS, now = Date.now();
   1041       const p = SS.newProfile('Pro', SS.AVATARS[1], now - 10 * SS.DAY, 'pro');
   1042       ['tap', 'pin', 'pan', 'ant', 'snap', 'pants'].forEach((w) => { SS.recordOrder(p, w, true, now - 9 * SS.DAY); SS.recordOrder(p, w, true, now - 8 * SS.DAY); });
   1043       g.store.profiles.push(p); g.save();
   1044       window.__SS_FORCE_MODE__ = 'return';
   1045       g.quickStart('Pro');
   1046     });
   1047     await ssWait(page, 'queue');
   1048     const idx = await page.evaluate(() => window.__SS_GAME__.queue.findIndex((q) => q.mode === 'return'));
   1049     if (idx < 0) throw new Error('no return in the queue');
   1050     await page.keyboard.press(String(idx + 1));
   1051     await ssWait(page, 'read', 15000);
   1052     const o = await page.evaluate(() => ({ mode: window.__SS_GAME__.order.mode, graphemes: window.__SS_GAME__.order.graphemes, word: window.__SS_GAME__.order.word.w,
   1053       hidden: document.querySelectorAll('.ss-box.hidden-letter').length, pic: !!document.querySelector('.ss-bubble img'),
   1054       said: window.__SS_GAME__.audio.log.some((e) => e.kind === 'word' && e.id === window.__SS_GAME__.order.word.w) }));
   1055     if (o.mode !== 'return') throw new Error('mode not forced to return');
   1056     if (o.hidden !== o.graphemes.length) throw new Error('letters should be hidden in return mode');
   1057     if (!o.pic) throw new Error('patron should show the book');
   1058     if (!o.said) throw new Error('the word should have been played before typing');
   1059     await page.keyboard.type(o.graphemes.join(''), { delay: 20 });
   1060     await ssWait(page, 'blend');
   1061     await page.keyboard.press('Space');
   1062     await ssWait(page, 'queue', 15000);   // no book choice for a return: straight back to the shelf
   1063     const rec = await page.evaluate((w) => window.__SS_GAME__.profile.words[w], o.word);
   1064     if (rec.reps !== 3) throw new Error(`review not recorded: ${JSON.stringify(rec)}`);
   1065   });
   1066 
   1067   await test('story-stamp: progress survives a reload and due words return as regulars', async (page, url) => {
   1068     await ssOpen(page);
   1069     await ssQuickStart(page, 'Keep');
   1070     const first = await ssServe(page);
   1071     await page.goto(SS_URL, { waitUntil: 'domcontentloaded' });
   1072     await page.waitForFunction(() => !!window.__SS_GAME__);
   1073     await page.keyboard.press('Enter');     // last librarian is preselected
   1074     await ssWait(page, 'queue');
   1075     const back = await page.evaluate(() => ({ name: window.__SS_GAME__.profile.name, words: Object.keys(window.__SS_GAME__.profile.words) }));
   1076     if (back.name !== 'Keep' || !back.words.includes(first.word)) throw new Error(`progress lost: ${JSON.stringify(back)}`);
   1077     // Jump two days ahead: the served word is due and its regular comes back.
   1078     const review = await page.evaluate((w) => {
   1079       const g = window.__SS_GAME__, SS = window.SS;
   1080       window.__SS_NOW__ = Date.now() + 2 * SS.DAY;
   1081       // pickNext never serves the immediately-previous word, and after exactly
   1082       // one book that is the word we want back. Stand in for "some other book
   1083       // has been served since" so the review itself is what's under test.
   1084       g.profile.lastWord = null;
   1085       let queued = false;
   1086       for (let seed = 1; seed <= 12 && !queued; seed++) {
   1087         const q = SS.buildQueue(g.profile, g.now(), SS.mulberry32(seed), 3);
   1088         queued = q.some((e) => e.word.w === w && e.review && e.patron.id === SS.patronFor(w).id);
   1089       }
   1090       return { due: SS.dueWords(g.profile, g.now()).includes(w), queued };
   1091     }, first.word);
   1092     if (!review.due) throw new Error('served word should be due for review two days later');
   1093     if (!review.queued) throw new Error('the due word should come back with its regular');
   1094   });
   1095 
   1096   await test('story-stamp: menu and shelf open from Backspace and close again', async (page, url) => {
   1097     await ssOpen(page);
   1098     await ssQuickStart(page);
   1099     await page.keyboard.press('Backspace');
   1100     if ((await ssState(page)) !== 'menu') throw new Error('menu did not open');
   1101     await page.keyboard.press('Enter');          // My shelf
   1102     if ((await ssState(page)) !== 'shelf') throw new Error('shelf did not open');
   1103     if (!(await page.evaluate(() => document.querySelector('.ss-panel').textContent.includes('No books yet')))) throw new Error('empty shelf message missing');
   1104     await page.keyboard.press('Backspace');
   1105     if ((await ssState(page)) !== 'read') throw new Error('shelf did not close back to the desk');
   1106     await page.keyboard.press('Backspace');
   1107     await page.keyboard.press('ArrowDown');
   1108     await page.keyboard.press('Enter');          // Change librarian
   1109     if ((await ssState(page)) !== 'profiles') throw new Error('change librarian did not return to profiles');
   1110   });
   1111 
   1112   await test('story-stamp: a librarian can be removed from the profile screen', async (page, url) => {
   1113     await ssOpen(page);
   1114     await ssQuickStart(page, 'Gone');
   1115     await page.keyboard.press('Backspace'); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter');
   1116     await page.keyboard.press('x');
   1117     if ((await ssState(page)) !== 'confirmDelete') throw new Error('no confirmation step');
   1118     await page.keyboard.press('y');
   1119     const left = await page.evaluate(() => window.__SS_GAME__.store.profiles.length);
   1120     if (left !== 0) throw new Error(`profile not removed: ${left} left`);
   1121   });
   1122 
   1123   await test('story-stamp: phone portrait keeps the desk, queue and slip inside the screen', async (page, url) => {
   1124     await page.setViewportSize({ width: 390, height: 780 });
   1125     await ssOpen(page);
   1126     await ssQuickStart(page);
   1127     const box = await page.evaluate(() => {
   1128       const r = (sel) => { const b = document.querySelector(sel).getBoundingClientRect(); return { l: b.left, r: b.right, t: b.top, b: b.bottom }; };
   1129       return { w: innerWidth, h: innerHeight, slip: r('.ss-slip'), desk: r('.ss-desk'), cust: r('.ss-customer') };
   1130     });
   1131     for (const k of ['slip', 'desk', 'cust']) {
   1132       const b = box[k];
   1133       if (b.l < -2 || b.r > box.w + 2 || b.t < 0 || b.b > box.h + 2) throw new Error(`${k} is off screen: ${JSON.stringify(b)} in ${box.w}x${box.h}`);
   1134     }
   1135   });
   1136 
   1137 
   1138   await test('touch: story-stamp on-screen keyboard types a sound and has arrow keys', async (page, url) => {
   1139     await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
   1140     await page.goto(`${url}/games/story-stamp/`, { waitUntil: 'domcontentloaded' });
   1141     await page.waitForSelector('#kg-touch');
   1142     await page.waitForFunction(() => !!window.__SS_GAME__);
   1143     for (const label of ['◀', '▲', '▼', '▶', '⏎']) await touchKey(page, label);
   1144     await page.evaluate(() => { window.__SS_GAME__.quickStart('Tap'); });
   1145     await page.waitForFunction(() => window.__SS_GAME__.state === 'queue', null, { timeout: 8000 });
   1146     await (await touchKey(page, '⏎')).tap();
   1147     await page.waitForFunction(() => window.__SS_GAME__.state === 'read', null, { timeout: 15000 });
   1148     const first = await page.evaluate(() => window.__SS_GAME__.order.graphemes[0][0].toUpperCase());
   1149     const key = await touchKey(page, first);
   1150     await key.tap();
   1151     await page.waitForTimeout(200);
   1152     const idx = await page.evaluate(() => window.__SS_GAME__.order.idx + window.__SS_GAME__.order.buffer.length);
   1153     if (idx < 1) throw new Error('on-screen key did not register');
   1154   }, { hasTouch: true });
   1155 
   1156   // ── Page layout ───────────────────────────────────────────────────────────
   1157 
   1158   // Every shell is a fixed-height, non-scrolling flex column. If the canvas
   1159   // refuses to shrink (flex items default to min-height:auto), whatever sits
   1160   // below it is pushed past the fold with no way to scroll to it.
   1161   const SHELLS = ['letter-find', 'fire-truck', 'animal-letter', 'animal-spell', 'work', 'story-stamp'];
   1162   for (const slug of SHELLS) {
   1163     await test(`${slug}: page does not overflow the viewport`, async (page, url) => {
   1164       await page.setViewportSize({ width: 1000, height: 700 });
   1165       await page.goto(`${url}/games/${slug}/`, { waitUntil: 'domcontentloaded' });
   1166       await page.waitForTimeout(1200);
   1167       const m = await page.evaluate(() => ({
   1168         scrollH: document.documentElement.scrollHeight,
   1169         innerH:  window.innerHeight,
   1170       }));
   1171       if (m.scrollH > m.innerH + 1) {
   1172         throw new Error(`Page overflows viewport: ${JSON.stringify(m)}`);
   1173       }
   1174     });
   1175 
   1176     await test(`${slug}: touch bar fits on screen`, async (page, url) => {
   1177       await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
   1178       await page.setViewportSize({ width: 390, height: 700 });
   1179       await page.goto(`${url}/games/${slug}/`, { waitUntil: 'domcontentloaded' });
   1180       await page.waitForSelector('#kg-touch');
   1181       await page.waitForTimeout(600);
   1182       const m = await page.evaluate(() => {
   1183         const r = document.getElementById('kg-touch').getBoundingClientRect();
   1184         return { top: r.top, bottom: r.bottom, innerH: window.innerHeight };
   1185       });
   1186       if (m.bottom > m.innerH + 1 || m.top >= m.innerH) {
   1187         throw new Error(`Touch bar is off-screen: ${JSON.stringify(m)}`);
   1188       }
   1189     });
   1190   }
   1191 
   1192 
   1193   // ── Audio unlock on phones ──────────────────────────────────────────────────
   1194   //
   1195   // Headless Chromium runs with autoplay allowed and, even under the real
   1196   // policy, quietly auto-resumes a blocked AudioContext on the next gesture.
   1197   // Safari does neither: a context created or resumed outside an activating
   1198   // event (keydown / pointerup / touchend / click — NOT a touch pointerdown)
   1199   // stays suspended forever. Emulate that at the Web Audio API level so the
   1200   // test fails the way an iPhone does.
   1201   function emulateSafariAutoplayPolicy() {
   1202     const ACTIVATING = ['pointerup', 'touchend', 'click', 'keydown', 'mousedown', 'mouseup'];
   1203     let inGesture = false;
   1204     ACTIVATING.forEach((t) => window.addEventListener(t, (e) => {
   1205       if (!e.isTrusted) return;
   1206       inGesture = true;
   1207       setTimeout(() => { inGesture = false; }, 0);
   1208     }, true));
   1209     const unlocked   = new WeakSet();
   1210     const stateDesc  = Object.getOwnPropertyDescriptor(BaseAudioContext.prototype, 'state');
   1211     Object.defineProperty(BaseAudioContext.prototype, 'state', {
   1212       configurable: true,
   1213       get() {
   1214         const real = stateDesc.get.call(this);
   1215         return real === 'running' && !unlocked.has(this) ? 'suspended' : real;
   1216       },
   1217     });
   1218     const origResume = AudioContext.prototype.resume;
   1219     AudioContext.prototype.resume = function () {
   1220       if (!inGesture) return new Promise(() => {});   // pending forever, like WebKit
   1221       unlocked.add(this);
   1222       return origResume.call(this);
   1223     };
   1224     window.__SAFARI_AUDIO_EMULATED__ = true;
   1225   }
   1226 
   1227   async function fireTruckAudioState(page) {
   1228     return page.evaluate(() => {
   1229       const s = window.__FT_SCENE__;
   1230       return {
   1231         inited:    s._audioInited,
   1232         ready:     s._audioReady,
   1233         ctxState:  s.audioCtx ? s.audioCtx.state : 'none',
   1234         toneState: Tone.getContext().state,
   1235         music:     !!s.bgGain,
   1236         transport: Tone.Transport.state,
   1237         sirenOn:   s.sirenOn,
   1238         sirenOsc:  !!s.sirenOsc,
   1239         musicOn:   s.musicOn,
   1240       };
   1241     });
   1242   }
   1243 
   1244   await test('audio: emulated Safari policy really blocks contexts created on touch pointerdown', async (page, url) => {
   1245     await page.addInitScript(emulateSafariAutoplayPolicy);
   1246     await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
   1247     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
   1248     await page.waitForSelector('#kg-touch');
   1249     await page.waitForFunction(() => !!window.__FT_SCENE__);
   1250     // Synthetic key events (what the touch bar dispatches) are not trusted, so
   1251     // audio started from them must stay suspended under the emulation.
   1252     await page.evaluate(() => {
   1253       window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', bubbles: true }));
   1254       window.dispatchEvent(new KeyboardEvent('keyup',   { key: 's', code: 'KeyS', bubbles: true }));
   1255     });
   1256     await page.waitForTimeout(300);
   1257     const st = await fireTruckAudioState(page);
   1258     if (!st.inited) throw new Error('initAudio did not run on synthetic keydown');
   1259     if (st.ready || st.ctxState === 'running' || st.toneState === 'running') {
   1260       throw new Error(`Emulation is not blocking audio: ${JSON.stringify(st)}`);
   1261     }
   1262     if (!st.sirenOn) throw new Error('siren toggle did not register');
   1263   }, { hasTouch: true });
   1264 
   1265   await test('audio: fire-truck music + siren start from a touch-bar tap under Safari policy', async (page, url) => {
   1266     await page.addInitScript(emulateSafariAutoplayPolicy);
   1267     await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
   1268     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
   1269     await page.waitForSelector('#kg-touch');
   1270     await page.waitForFunction(() => !!window.__FT_SCENE__);
   1271     await page.waitForTimeout(300);
   1272     // Nothing may be playing before the player asks for it.
   1273     const idle = await fireTruckAudioState(page);
   1274     if (idle.musicOn || idle.music || idle.sirenOn) {
   1275       throw new Error(`audio should start off: ${JSON.stringify(idle)}`);
   1276     }
   1277     const siren = await touchKey(page, '🔊 Siren');
   1278     await siren.tap();   // touchstart/pointerdown (key fires here) -> pointerup/touchend (activation)
   1279     await page.waitForFunction(() => window.__FT_SCENE__._audioReady === true, null, { timeout: 4000 })
   1280       .catch(async () => { throw new Error(`audio never became ready: ${JSON.stringify(await fireTruckAudioState(page))}`); });
   1281     const music = await touchKey(page, '🎵 Music');
   1282     await music.tap();
   1283     await page.waitForFunction(() => window.__FT_SCENE__._musicStarted === true, null, { timeout: 4000 })
   1284       .catch(async () => { throw new Error(`music never started: ${JSON.stringify(await fireTruckAudioState(page))}`); });
   1285     const st = await fireTruckAudioState(page);
   1286     if (st.ctxState !== 'running')  throw new Error(`SFX context not running: ${JSON.stringify(st)}`);
   1287     if (st.toneState !== 'running') throw new Error(`Tone context not running: ${JSON.stringify(st)}`);
   1288     if (!st.music)                  throw new Error(`music not initialised: ${JSON.stringify(st)}`);
   1289     if (st.transport !== 'started') throw new Error(`Tone.Transport not started: ${JSON.stringify(st)}`);
   1290     if (!st.sirenOn || !st.sirenOsc) throw new Error(`siren not sounding after tap: ${JSON.stringify(st)}`);
   1291     // A second music tap silences it again.
   1292     await music.tap();
   1293     await page.waitForTimeout(100);
   1294     const quiet = await fireTruckAudioState(page);
   1295     if (quiet.musicOn || quiet.transport === 'started') {
   1296       throw new Error(`music did not stop on second tap: ${JSON.stringify(quiet)}`);
   1297     }
   1298     await music.tap();
   1299     await page.waitForTimeout(100);
   1300     // A second tap turns the siren off again.
   1301     await siren.tap();
   1302     await page.waitForTimeout(100);
   1303     const off = await fireTruckAudioState(page);
   1304     if (off.sirenOn || off.sirenOsc) throw new Error(`siren did not stop on second tap: ${JSON.stringify(off)}`);
   1305   }, { hasTouch: true });
   1306 
   1307   await test('audio: fire-truck music is off until M, then starts under Safari policy', async (page, url) => {
   1308     await page.addInitScript(emulateSafariAutoplayPolicy);
   1309     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
   1310     await page.waitForFunction(() => !!window.__FT_SCENE__);
   1311     await page.waitForTimeout(300);
   1312     // Driving around must not start music on its own.
   1313     await page.keyboard.press('ArrowUp');
   1314     await page.waitForTimeout(300);
   1315     const before = await fireTruckAudioState(page);
   1316     if (before.musicOn || before.music || before.transport === 'started') {
   1317       throw new Error(`music started without being asked: ${JSON.stringify(before)}`);
   1318     }
   1319     await page.keyboard.press('m');
   1320     await page.waitForFunction(() => window.__FT_SCENE__._musicStarted === true, null, { timeout: 4000 })
   1321       .catch(async () => { throw new Error(`music never started after M: ${JSON.stringify(await fireTruckAudioState(page))}`); });
   1322     const st = await fireTruckAudioState(page);
   1323     if (!st.musicOn || !st.music || st.transport !== 'started') throw new Error(`music not running: ${JSON.stringify(st)}`);
   1324     // M again turns it back off.
   1325     await page.keyboard.press('m');
   1326     await page.waitForTimeout(100);
   1327     const off = await fireTruckAudioState(page);
   1328     if (off.musicOn || off.transport === 'started') throw new Error(`M did not stop music: ${JSON.stringify(off)}`);
   1329   });
   1330 
   1331   await test('audio: fire-truck settings checkbox starts music from its click', async (page, url) => {
   1332     await page.addInitScript(emulateSafariAutoplayPolicy);
   1333     await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
   1334     await page.waitForFunction(() => !!window.__FT_SCENE__);
   1335     await page.waitForTimeout(300);
   1336     const checked = await page.$eval('#music-toggle', (el) => el.checked);
   1337     if (checked) throw new Error('music checkbox should start unchecked');
   1338     await page.click('#settings-btn');
   1339     await page.click('#music-toggle');
   1340     await page.waitForFunction(() => window.__FT_SCENE__._musicStarted === true, null, { timeout: 4000 })
   1341       .catch(async () => { throw new Error(`music never started from checkbox: ${JSON.stringify(await fireTruckAudioState(page))}`); });
   1342   });
   1343 
   1344   await test('audio: letter-find music starts from a touch-bar tap under Safari policy', async (page, url) => {
   1345     await page.addInitScript(emulateSafariAutoplayPolicy);
   1346     await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
   1347     await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' });
   1348     await page.waitForSelector('#kg-touch');
   1349     await page.waitForFunction(() => !!window.__LF_SCENE__);
   1350     await page.waitForTimeout(500);
   1351     const key = await touchKey(page, 'A');
   1352     await key.tap();
   1353     await page.waitForFunction(() => Tone.Transport.state === 'started', null, { timeout: 4000 })
   1354       .catch(() => { throw new Error('Tone.Transport never started from a touch tap'); });
   1355     const toneState = await page.evaluate(() => Tone.getContext().state);
   1356     if (toneState !== 'running') throw new Error(`Tone context is ${toneState}`);
   1357   }, { hasTouch: true });
   1358 
   1359 
   1360   await test('audio: story-stamp audio context unlocks from a physical key under Safari policy', async (page, url) => {
   1361     await page.addInitScript(emulateSafariAutoplayPolicy);
   1362     await page.goto(`${url}/games/story-stamp/`, { waitUntil: 'domcontentloaded' });
   1363     await page.waitForFunction(() => !!window.__SS_GAME__);
   1364     await page.evaluate(() => { window.__SS_GAME__.quickStart('Aud'); });
   1365     await page.waitForFunction(() => window.__SS_GAME__.state === 'queue', null, { timeout: 8000 });
   1366     const before = await page.evaluate(() => ({ ready: window.__SS_GAME__.audio.ready, state: window.__SS_GAME__.audio.ctx && window.__SS_GAME__.audio.ctx.state }));
   1367     if (before.ready || before.state === 'running') throw new Error(`context should be blocked before a gesture: ${JSON.stringify(before)}`);
   1368     await page.keyboard.press('Space');    // a trusted key press = activation
   1369     await page.waitForFunction(() => window.__SS_GAME__.audio.ready === true, null, { timeout: 4000 })
   1370       .catch(() => { throw new Error('audio never became ready from a key press'); });
   1371     const decoded = () => Object.keys(window.__SS_GAME__.audio.buffers).filter((k) => k.startsWith('ph:') && window.__SS_GAME__.audio.buffers[k]).length;
   1372     await page.waitForFunction(decoded.toString().replace('() =>', '() => 30 <='), null, { timeout: 8000 })
   1373       .catch(async () => { throw new Error(`only ${await page.evaluate(decoded)} phoneme clips decoded`); });
   1374   });
   1375 
   1376   // ── Summary ───────────────────────────────────────────────────────────────
   1377 
   1378   await browser.close();
   1379   server.close();
   1380 
   1381   const total = pass + fail;
   1382   console.log(`\n${total} tests: ${pass} passed, ${fail} failed.\n`);
   1383   if (fail > 0) process.exit(1);
   1384 }
   1385 
   1386 main().catch(e => { console.error(e); process.exit(1); });