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); });