kgames

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

commit d758800ace6fe2c86af546e3c23f573bed724738
parent ec87ac35dbc46c70c22b81192caa056f01c7c7b8
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Mon,  4 May 2026 13:30:02 -0700

fire-truck: endgame scene, fires counter, audio robustness, speed/distance defaults

- Add FireTruckEndScene: coastal celebration with dancing people, dolphins,
  animated water fan, color-cycling 'You did it!' text, and Play Again button
- Track fires extinguished (🔥 0/4 HUD counter); transition to endgame after 4
- Celebration song: triumphant Em/Am/G melody at 170 BPM, triangle-wave synth
- Fix audio: initAudio() fires on any keydown/pointerdown, not just S/Space;
  playSuccessSound/playFailSound defensively call initAudio() themselves
- Default fire distance 20 → 10 blocks; add 5-block option to dropdown
- Speed 2.5× faster (BASE 170→425, MAX 220→550, ACCEL 260→650, BRAKE 320→800)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Diffstat:
Mgames/fire-truck/game.js | 342++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mgames/fire-truck/index.html | 7++++---
Mtests/browser/runner.js | 48+++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 384 insertions(+), 13 deletions(-)

diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js @@ -2,16 +2,17 @@ const FT = window.FireTruckLib; const SCALE = 5; const CELL_SIZE = 96 * SCALE; -const BASE_SPEED = 170; -const MAX_SPEED = 220; -const ACCEL = 260; -const BRAKE = 320; +const BASE_SPEED = 425; +const MAX_SPEED = 550; +const ACCEL = 650; +const BRAKE = 800; const REACTION_TIME_S = 2.0; const MIN_PROMPT_DIST = CELL_SIZE * 0.72; const STOP_LINE_DIST = Math.round(CELL_SIZE * 0.45); const ROUTE_EXTENSION_COUNT = 36; const FIRE_TRIGGER_DIST = CELL_SIZE * 0.6; const FIRE_EXTINGUISH_S = 3.0; +const FIRES_TO_WIN = 4; const WATER_COLOR = 0x6bc4e8; const BEACH_COLOR = 0xf2e2b6; @@ -35,6 +36,24 @@ const FT_CHORDS = [ ['A2','C3','E3'], null, null, null, ]; +// Celebration song for the endgame screen — related to the main theme +// (same Em/Am/G harmonic language, brighter octave, triumphant ascending phrases) +const FT_END_BPM = 170; +const FT_END_MELODY = [ + 'E5','G5','B5','E6', 'D6','B5','G5','A5', + 'A5','C6','E6','A6', 'G6','E6','D6','E6', +]; +const FT_END_BASS = [ + 'E3','G3','B3','E4', 'D4','B3','G3','A3', + 'A3','C4','E4','A4', 'G4','E4','B3','E4', +]; +const FT_END_CHORDS = [ + ['E3','G3','B3'], null, ['B2','D3','G3'], null, + ['G2','B2','D3'], null, ['A2','E3','A3'], null, + ['A2','C3','E3'], null, ['E3','A3','C4'], null, + ['G2','B2','D3'], null, ['E3','G3','B3'], null, +]; + function arrowLabel(dir) { const ARROWS = { left: '←', right: '→', straight: '↑' }; const NAMES = { left: 'LEFT', right: 'RIGHT', straight: 'STRAIGHT' }; @@ -70,6 +89,7 @@ class FireTruckScene extends Phaser.Scene { this.fireGraphics = null; this.waterGraphics = null; this.fireExtinguishAccum = 0; + this.firesExtinguished = 0; this.spaceHeld = false; this.fireMode = false; this._stallSince = 0; @@ -141,13 +161,22 @@ class FireTruckScene extends Phaser.Scene { padding: { x: 6, y: 3 }, }).setScrollFactor(0).setDepth(50); + this.fireCountText = this.add.text(this.scale.width / 2, 78, '🔥 0 / 4', { + fontFamily: 'Fredoka, sans-serif', + fontSize: '26px', + color: '#ffffff', + stroke: '#2f2f2f', + strokeThickness: 7, + align: 'center', + }).setOrigin(0.5, 0).setScrollFactor(0).setDepth(50); + this.uiCamera = this.cameras.add(0, 0, this.scale.width, this.scale.height); this.renderIsland(); this.createTruck(); this.uiCamera.ignore([...this.cityChunks, this.truck]); - this.cameras.main.ignore([this.overlay, this.successOverlay, this.promptText, this.statusText, this.fpsText]); + this.cameras.main.ignore([this.overlay, this.successOverlay, this.promptText, this.statusText, this.fpsText, this.fireCountText]); this.currentStep = this.route[0]; const start = this.worldPoint(this.island.routeStart.x, this.island.routeStart.y); @@ -159,6 +188,7 @@ class FireTruckScene extends Phaser.Scene { this.input.keyboard.on('keydown', this.onKeyDown, this); this.input.keyboard.on('keyup', this.onKeyUp, this); + this.input.once('pointerdown', () => this.initAudio(), this); this.scale.on('resize', this.onResize, this); this.onResize(this.scale.gameSize); @@ -179,6 +209,7 @@ class FireTruckScene extends Phaser.Scene { window.__FT_SCENE__ = this; this._initFireDestination(); + this._updateFireCounter(); this.refreshDebug(); } @@ -314,6 +345,7 @@ class FireTruckScene extends Phaser.Scene { this.successOverlay.setSize(gameSize.width, gameSize.height); this.promptText.setPosition(gameSize.width / 2, gameSize.height - 74); this.statusText.setPosition(gameSize.width / 2, gameSize.height - 34); + this.fireCountText.setPosition(gameSize.width / 2, 78); if (this.uiCamera) this.uiCamera.setSize(gameSize.width, gameSize.height); } @@ -327,13 +359,12 @@ class FireTruckScene extends Phaser.Scene { } onKeyDown(event) { + this.initAudio(); if (event.code === 'Space') { - this.initAudio(); if (this.state === 'firefighting') this.spaceHeld = true; return; } if (event.key === 's' || event.key === 'S') { - this.initAudio(); this.toggleSiren(); return; } @@ -343,7 +374,6 @@ class FireTruckScene extends Phaser.Scene { } const move = event.key === 'ArrowLeft' ? 'left' : event.key === 'ArrowRight' ? 'right' : event.key === 'ArrowUp' ? 'straight' : null; if (!move || !this.promptDir) return; - this.initAudio(); if (move === this.promptDir) { this.promptResolved = true; this.promptDir = null; @@ -497,6 +527,7 @@ class FireTruckScene extends Phaser.Scene { } playSuccessSound() { + this.initAudio(); if (!this.audioCtx || this.sfxMuted) return; try { const ctx = this.audioCtx; @@ -515,6 +546,7 @@ class FireTruckScene extends Phaser.Scene { } playFailSound() { + this.initAudio(); if (!this.audioCtx || this.sfxMuted) return; try { const ctx = this.audioCtx; @@ -789,6 +821,10 @@ class FireTruckScene extends Phaser.Scene { return true; } + _updateFireCounter() { + this.fireCountText.setText(`🔥 ${this.firesExtinguished} / ${FIRES_TO_WIN}`); + } + _initFireDestination() { const island = this.island; const fromCell = island.grid[island.routeStart.y][island.routeStart.x]; @@ -909,6 +945,8 @@ class FireTruckScene extends Phaser.Scene { // Guard against re-entrant calls: step() stays in firefighting state for 600ms // while the delayedCall is pending, so this can fire every frame otherwise. if (!this.fireBuildingCell) return; + this.firesExtinguished += 1; + this._updateFireCounter(); this.successOverlay.setAlpha(0.12); this.successUntil = this.time.now + 200; this.playSuccessSound(); @@ -919,6 +957,11 @@ class FireTruckScene extends Phaser.Scene { this.fireBuildingCell = null; this.statusText.setText('Fire out! Great job!'); + if (this.firesExtinguished >= FIRES_TO_WIN) { + this.time.delayedCall(900, () => this.scene.start('FireTruckEndScene')); + return; + } + this.time.delayedCall(600, () => { const island = this.island; const mult = window.GAME_SPEED_MULTIPLIER ?? 1.0; @@ -971,6 +1014,287 @@ function colorForCell(cell) { return BUILDING_PALETTE[Math.abs((cell.x * 13 + cell.y * 17) % BUILDING_PALETTE.length)]; } +class FireTruckEndScene extends Phaser.Scene { + constructor() { + super({ key: 'FireTruckEndScene' }); + this._hue = 0; + this._W = 0; + this._H = 0; + } + + create() { + const W = this._W = this.scale.width; + const H = this._H = this.scale.height; + + // Sky gradient + const skyGfx = this.add.graphics(); + skyGfx.fillGradientStyle(0x87ceeb, 0x87ceeb, 0x5bacd8, 0x5bacd8, 1); + skyGfx.fillRect(0, 0, W, H * 0.52); + + // Dense urban buildings (left 40%, varying heights) + const bldGfx = this.add.graphics(); + const bldColors = BUILDING_PALETTE; + const numBlds = 10; + const bldZoneW = W * 0.40; + for (let i = 0; i < numBlds; i++) { + const bw = bldZoneW / numBlds; + const bh = H * (0.10 + ((i * 7 + 3) % 9) / 9 * 0.38); + const bx = i * bw; + const by = H * 0.52 - bh; + bldGfx.fillStyle(bldColors[i % bldColors.length], 1); + bldGfx.fillRect(bx, by, bw - 3, bh); + bldGfx.lineStyle(2, 0x000000, 0.12); + bldGfx.strokeRect(bx + 1, by + 1, bw - 5, bh - 2); + // Windows + bldGfx.fillStyle(0xffe8a0, 0.7); + for (let wy = by + 8; wy < H * 0.52 - 10; wy += 14) { + for (let wx = bx + 5; wx < bx + bw - 10; wx += 12) { + bldGfx.fillRect(wx, wy, 7, 8); + } + } + } + + // Road + const roadGfx = this.add.graphics(); + roadGfx.fillStyle(ASPHALT_COLOR, 1); + roadGfx.fillRect(0, H * 0.52, W, H * 0.10); + // Yellow dashes + roadGfx.fillStyle(STRIPE_COLOR, 1); + const dashW = W * 0.06; + const dashGap = W * 0.04; + const dashY = H * 0.52 + H * 0.048; + for (let dx = 0; dx < W; dx += dashW + dashGap) { + roadGfx.fillRect(dx, dashY, dashW, 5); + } + + // Beach + const beachGfx = this.add.graphics(); + beachGfx.fillStyle(BEACH_COLOR, 1); + beachGfx.fillRect(0, H * 0.62, W, H * 0.06); + + // Ocean fill + const oceanGfx = this.add.graphics(); + oceanGfx.fillStyle(0x2a9d8f, 1); + oceanGfx.fillRect(0, H * 0.68, W, H * 0.32); + + // Wave graphics (cleared/redrawn each frame) + this.waveGfx = this.add.graphics(); + + // Fire truck (static, parked on road, facing right) + const truckX = W * 0.32; + const truckY = H * 0.565; + const truckGfx = this.add.graphics(); + const tw = W * 0.10, th = H * 0.065; + // Body + truckGfx.fillStyle(0xe63946, 1); + truckGfx.fillRect(truckX - tw / 2, truckY - th / 2, tw, th); + // Cab (right side) + truckGfx.fillStyle(0xc02030, 1); + truckGfx.fillRect(truckX + tw * 0.18, truckY - th / 2, tw * 0.32, th); + // Windows + truckGfx.fillStyle(0x88ccff, 0.8); + truckGfx.fillRect(truckX + tw * 0.22, truckY - th * 0.38, tw * 0.24, th * 0.38); + // White stripe + truckGfx.fillStyle(0xffffff, 0.6); + truckGfx.fillRect(truckX - tw / 2, truckY + th * 0.1, tw, th * 0.12); + // Wheels + truckGfx.fillStyle(0x222222, 1); + truckGfx.fillCircle(truckX - tw * 0.28, truckY + th / 2 + 4, H * 0.018); + truckGfx.fillCircle(truckX + tw * 0.28, truckY + th / 2 + 4, H * 0.018); + + // Water fan (cleared/redrawn each frame) + this.waterFanGfx = this.add.graphics(); + this._truckFrontX = truckX + tw * 0.5; + this._truckFrontY = truckY; + + // Dolphins (3) — ellipses, start below ocean line + this._dolphins = []; + for (let i = 0; i < 3; i++) { + const dx = W * (0.55 + i * 0.14); + const d = this.add.ellipse(dx, H * 0.88, W * 0.035, H * 0.025, 0x1a7a7a); + d.setAlpha(0); + this._dolphins.push(d); + const launchDolphin = (dol) => { + dol.setAlpha(1).setAngle(-25); + this.tweens.add({ + targets: dol, y: H * 0.72, angle: 0, + duration: 550, ease: 'Sine.easeOut', + onComplete: () => { + this.tweens.add({ + targets: dol, y: H * 0.88, angle: 25, + duration: 550, ease: 'Sine.easeIn', + onComplete: () => { + dol.setAlpha(0); + this.time.delayedCall(900 + i * 300, () => launchDolphin(dol)); + } + }); + } + }); + }; + this.time.delayedCall(i * 1100, () => launchDolphin(d)); + } + + // Dancing people (5) on road + const personColors = [0xe63946, 0x1d7cf2, 0xffd23f, 0x2ec4b6, 0xff8c42]; + for (let i = 0; i < 5; i++) { + const px = W * (0.50 + i * 0.09); + const py = H * 0.565; + const person = this.add.container(px, py); + + const headR = H * 0.022; + const bodyH = H * 0.048; + const armW = H * 0.032; + const armH = H * 0.010; + + const head = this.add.circle(0, -bodyH / 2 - headR, headR, personColors[i % personColors.length]); + const body = this.add.rectangle(0, 0, headR * 1.2, bodyH, personColors[i % personColors.length]); + const armL = this.add.rectangle(-headR * 0.6 - armW / 2, -bodyH * 0.2, armW, armH, personColors[i % personColors.length]); + const armR = this.add.rectangle(headR * 0.6 + armW / 2, -bodyH * 0.2, armW, armH, personColors[i % personColors.length]); + + person.add([head, body, armL, armR]); + + // Bounce person up/down + this.tweens.add({ + targets: person, y: py - H * 0.015, + duration: 350 + i * 60, ease: 'Sine.easeInOut', + yoyo: true, repeat: -1, + }); + // Wave arms + this.tweens.add({ + targets: armL, angle: { from: -45, to: 10 }, + duration: 380 + i * 60, ease: 'Sine.easeInOut', + yoyo: true, repeat: -1, + }); + this.tweens.add({ + targets: armR, angle: { from: 45, to: -10 }, + duration: 380 + i * 60, ease: 'Sine.easeInOut', + yoyo: true, repeat: -1, delay: (380 + i * 60) / 2, + }); + } + + // "You did it!" text + this.didItText = this.add.text(W / 2, H * 0.30, 'You did it!', { + fontFamily: 'Fredoka, sans-serif', + fontSize: Math.round(W / 8) + 'px', + color: '#ffffff', + stroke: '#2f2f2f', + strokeThickness: 10, + align: 'center', + }).setOrigin(0.5).setDepth(60); + + this.tweens.add({ + targets: this.didItText, + scaleX: { from: 0.91, to: 1.09 }, + scaleY: { from: 0.91, to: 1.09 }, + angle: { from: -5, to: 5 }, + duration: 650, + ease: 'Sine.easeInOut', + yoyo: true, + repeat: -1, + }); + + // "Play Again" button + const btnY = H * 0.91; + const btnBg = this.add.rectangle(W / 2, btnY, 210, 56, 0xe63946) + .setStrokeStyle(4, 0x9b1c25) + .setInteractive({ useHandCursor: true }) + .setDepth(61); + const btnText = this.add.text(W / 2, btnY, 'Play Again', { + fontFamily: 'Fredoka, sans-serif', + fontSize: '26px', + color: '#ffffff', + }).setOrigin(0.5).setDepth(62); + + btnBg.on('pointerover', () => btnBg.setFillStyle(0xff6b6b)); + btnBg.on('pointerout', () => btnBg.setFillStyle(0xe63946)); + btnBg.on('pointerdown', () => this.scene.start('FireTruckScene')); + + // Celebration music — starts immediately since user has already been playing + try { + this._endGain = new Tone.Gain(0.7).toDestination(); + + this._endMelodySynth = new Tone.Synth({ + oscillator: { type: 'triangle' }, + envelope: { attack: 0.006, decay: 0.10, sustain: 0.22, release: 0.08 }, + }).connect(this._endGain); + this._endMelodySynth.volume.value = -14; + + this._endBassSynth = new Tone.Synth({ + oscillator: { type: 'sawtooth' }, + envelope: { attack: 0.005, decay: 0.08, sustain: 0.10, release: 0.05 }, + }).connect(this._endGain); + this._endBassSynth.volume.value = -18; + + this._endChordSynth = new Tone.PolySynth(Tone.Synth, { + oscillator: { type: 'square' }, + envelope: { attack: 0.005, decay: 0.06, sustain: 0.08, release: 0.03 }, + }).connect(this._endGain); + this._endChordSynth.volume.value = -26; + + let step = 0; + new Tone.Sequence((time, note) => { + const i = step % FT_END_MELODY.length; + step++; + this._endMelodySynth.triggerAttackRelease(note, '8n', time); + this._endBassSynth.triggerAttackRelease(FT_END_BASS[i], '8n', time); + if (FT_END_CHORDS[i]) this._endChordSynth.triggerAttackRelease(FT_END_CHORDS[i], '8n', time); + }, FT_END_MELODY, '8n').start(0); + + Tone.Transport.bpm.value = FT_END_BPM; + Tone.Transport.start(); + } catch (_) {} + + window.__FT_END_SCENE__ = this; + this.events.on('shutdown', this._shutdown, this); + this.events.on('destroy', this._shutdown, this); + } + + _shutdown() { + try { Tone.Transport.stop(); } catch (_) {} + if (this._endGain) { try { this._endGain.dispose(); } catch (_) {} this._endGain = null; } + if (this._endMelodySynth) { try { this._endMelodySynth.dispose(); } catch (_) {} this._endMelodySynth = null; } + if (this._endBassSynth) { try { this._endBassSynth.dispose(); } catch (_) {} this._endBassSynth = null; } + if (this._endChordSynth) { try { this._endChordSynth.dispose(); } catch (_) {} this._endChordSynth = null; } + window.__FT_END_SCENE__ = null; + } + + update(time, delta) { + const dt = delta / 1000; + const W = this._W; + const H = this._H; + + // Color-cycle "You did it!" text + this._hue = (this._hue + dt * 0.18) % 1; + const c = Phaser.Display.Color.HSLToColor(this._hue, 0.85, 0.62); + this.didItText.setColor(Phaser.Display.Color.RGBToString(c.r, c.g, c.b)); + + // Ocean waves + this.waveGfx.clear(); + for (let w = 0; w < 3; w++) { + const waveY = H * 0.70 + w * H * 0.055; + this.waveGfx.lineStyle(3, 0x5bc8d0, 0.45 - w * 0.1); + this.waveGfx.beginPath(); + let first = true; + for (let x = 0; x <= W; x += 4) { + const y = waveY + Math.sin((x / W) * Math.PI * 6 + time * 0.002 + w * 1.2) * H * 0.012; + if (first) { this.waveGfx.moveTo(x, y); first = false; } else { this.waveGfx.lineTo(x, y); } + } + this.waveGfx.strokePath(); + } + + // Water fan from truck front + this.waterFanGfx.clear(); + this.waterFanGfx.lineStyle(3, 0x88ddff, 0.7); + const tfx = this._truckFrontX; + const tfy = this._truckFrontY; + for (let r = 0; r < 7; r++) { + const angle = -0.28 + r * 0.09 + Math.sin(time * 0.004 + r) * 0.04; + const len = W * 0.09 + Math.sin(time * 0.003 + r * 0.7) * W * 0.01; + this.waterFanGfx.lineBetween(tfx, tfy, tfx + Math.cos(angle) * len, tfy + Math.sin(angle) * len); + } + } +} + // preserveDrawingBuffer is required so Playwright tests can readPixels the // canvas, but it disables compositor fast paths on some drivers and tanks // framerate. Keep it on only for tests. @@ -990,5 +1314,5 @@ new Phaser.Game({ antialias: false, pixelArt: false, }, - scene: [FireTruckScene], + scene: [FireTruckScene, FireTruckEndScene], }); diff --git a/games/fire-truck/index.html b/games/fire-truck/index.html @@ -114,8 +114,9 @@ <div class="setting-row"> <label for="distance-select">Fire distance:</label> <select id="distance-select"> - <option value="10">10 blocks</option> - <option value="20" selected>20 blocks (Default)</option> + <option value="5">5 blocks</option> + <option value="10" selected>10 blocks (Default)</option> + <option value="20">20 blocks</option> <option value="30">30 blocks</option> <option value="50">50 blocks</option> </select> @@ -137,7 +138,7 @@ window.GAME_SPEED_MULTIPLIER = 1.0; } if (typeof window.GAME_FIRE_DISTANCE === 'undefined') { - window.GAME_FIRE_DISTANCE = 20; + window.GAME_FIRE_DISTANCE = 10; } document.getElementById('settings-btn').addEventListener('click', () => { document.getElementById('settings-panel').classList.toggle('open'); diff --git a/tests/browser/runner.js b/tests/browser/runner.js @@ -208,7 +208,7 @@ async function main() { await test('fire-truck: truck starts moving automatically', async (page, url) => { await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1200); + await page.waitForTimeout(600); const start = await page.evaluate(() => ({ x: window.__FT_SCENE__.truckX, y: window.__FT_SCENE__.truckY, @@ -414,6 +414,52 @@ async function main() { if (!result.ok) throw new Error(`Fire distance ${result.d} not in [20,30] for targetDistance=25`); }); + await test('fire-truck: endgame scene renders, exposes __FT_END_SCENE__, no errors', async (page, url, errors) => { + await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1800); + if (errors.length) throw new Error(errors[0]); + await page.evaluate(() => { window.__FT_SCENE__.scene.start('FireTruckEndScene'); }); + await page.waitForTimeout(1200); + if (errors.length) throw new Error(errors[0]); + const hasEndScene = await page.evaluate(() => !!window.__FT_END_SCENE__); + if (!hasEndScene) throw new Error('__FT_END_SCENE__ not exposed after scene start'); + const colorCount = await page.evaluate(() => { + const cv = document.querySelector('canvas'); + if (!cv) return 0; + const seen = new Set(); + const sampleW = 200, sampleH = 200; + const gl = cv.getContext('webgl2') || cv.getContext('webgl'); + if (gl) { + const pixels = new Uint8Array(sampleW * sampleH * 4); + gl.readPixels(0, 0, sampleW, sampleH, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + for (let i = 0; i < pixels.length; i += 16) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`); + } + return seen.size; + }); + if (colorCount < 5) throw new Error(`Endgame scene rendered only ${colorCount} distinct colors — may be blank`); + }); + + await test('fire-truck: play again button restarts FireTruckScene with reset counter', async (page, url, errors) => { + await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1800); + await page.evaluate(() => { window.__FT_SCENE__.scene.start('FireTruckEndScene'); }); + await page.waitForTimeout(1200); + if (errors.length) throw new Error(errors[0]); + const hasEnd = await page.evaluate(() => !!window.__FT_END_SCENE__); + if (!hasEnd) throw new Error('FireTruckEndScene did not start'); + const W = await page.evaluate(() => window.innerWidth); + const H = await page.evaluate(() => window.innerHeight); + await page.mouse.click(W / 2, H * 0.91); + await page.waitForTimeout(1400); + if (errors.length) throw new Error(errors[0]); + const result = await page.evaluate(() => { + const s = window.__FT_SCENE__; + return { hasScene: !!s, counter: s ? s.firesExtinguished : -1 }; + }); + if (!result.hasScene) throw new Error('FireTruckScene not restarted after Play Again'); + if (result.counter !== 0) throw new Error(`firesExtinguished should be 0 after restart, got ${result.counter}`); + }); + // ── Summary ─────────────────────────────────────────────────────────────── await browser.close();