kgames

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

commit 837cd9820a0d2aed03b3a1c63c9500c4a8b12279
parent 97451ce2d6dd172834d7166bd8575dd67276407f
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Mon, 27 Apr 2026 08:51:48 -0700

fire-truck: random start, stop-line enforcement, dynamic prompt, fire minigame, arrow labels

- Random start position each game via pickRandomRouteStart()
- Truck stops at stop line (216 px) when no input; waiting state
- Dynamic prompt trigger: max(0.72 cell, speed * 2s)
- Goal-directed BFS route to fire + firefighting minigame
- Unicode arrows + text labels in prompts
- Removed AUTO_FAIL_GRACE_MS auto-resolve behavior
- Added unit and browser tests

Diffstat:
Mgames/fire-truck/game.js | 252++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mgames/fire-truck/lib.js | 112++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mtests/browser/runner.js | 87++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mtests/unit/route.test.js | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 522 insertions(+), 29 deletions(-)

diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js @@ -6,10 +6,12 @@ const BASE_SPEED = 170; const MAX_SPEED = 220; const ACCEL = 260; const BRAKE = 320; -const PROMPT_TRIGGER_DIST = CELL_SIZE * 0.72; -const STOP_LINE_DIST = 6 * SCALE; -const AUTO_FAIL_GRACE_MS = 4500; +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 WATER_COLOR = 0x6bc4e8; const BEACH_COLOR = 0xf2e2b6; @@ -17,6 +19,12 @@ const ASPHALT_COLOR = 0x2a2d34; const STRIPE_COLOR = 0xffd23f; const BUILDING_PALETTE = [0xf8d4a5, 0xb8dfe1, 0xf7b4b4, 0xffefb0, 0xcad6f9, 0xdcc7eb]; +function arrowLabel(dir) { + const ARROWS = { left: '←', right: '→', straight: '↑' }; + const NAMES = { left: 'LEFT', right: 'RIGHT', straight: 'STRAIGHT' }; + return dir ? (ARROWS[dir] || '') + ' ' + (NAMES[dir] || dir.toUpperCase()) : ''; +} + class FireTruckScene extends Phaser.Scene { constructor() { super({ key: 'FireTruckScene' }); @@ -41,10 +49,17 @@ class FireTruckScene extends Phaser.Scene { this.successUntil = 0; this.debugDistance = 0; this.promptShownAt = 0; + this.fireCell = null; + this.fireBuildingCell = null; + this.fireGraphics = null; + this.waterGraphics = null; + this.fireExtinguishAccum = 0; + this.spaceHeld = false; + this.fireMode = false; } create() { - this.island = FT.buildIsland({ width: 50, height: 50, routeCount: 200, seed: 20260426 }); + this.island = FT.buildIsland({ width: 50, height: 50, routeCount: 200 }); this.route = this.island.route; this.cellSize = CELL_SIZE; @@ -111,6 +126,7 @@ class FireTruckScene extends Phaser.Scene { this.truck.rotation = this.targetRotation; this.input.keyboard.on('keydown', this.onKeyDown, this); + this.input.keyboard.on('keyup', this.onKeyUp, this); this.scale.on('resize', this.onResize, this); this.onResize(this.scale.gameSize); @@ -130,6 +146,7 @@ class FireTruckScene extends Phaser.Scene { this.events.on('destroy', this.shutdown, this); window.__FT_SCENE__ = this; + this._initFireDestination(); this.refreshDebug(); } @@ -272,12 +289,16 @@ class FireTruckScene extends Phaser.Scene { const m = window.GAME_SPEED_MULTIPLIER ?? 1.0; if (this.state === 'driving') { this.targetSpeed = (this.promptResolved ? MAX_SPEED : BASE_SPEED) * m; - } else if (this.state === 'stopped') { + } else if (this.state === 'waiting' || this.state === 'stopped') { this.targetSpeed = 0; } } onKeyDown(event) { + if (event.code === 'Space') { + if (this.state === 'firefighting') this.spaceHeld = true; + return; + } const move = event.key === 'ArrowLeft' ? 'left' : event.key === 'ArrowRight' ? 'right' : event.key === 'ArrowUp' ? 'straight' : null; if (!move || !this.promptDir) return; this.initAudio(); @@ -294,7 +315,11 @@ class FireTruckScene extends Phaser.Scene { this.refreshDebug(); return; } - if (this.state !== 'stopped') this.enterStopped(); + if (this.state === 'driving' || this.state === 'waiting') this.enterStopped(); + } + + onKeyUp(event) { + if (event.code === 'Space') this.spaceHeld = false; } initAudio() { @@ -346,11 +371,19 @@ class FireTruckScene extends Phaser.Scene { this.failVisible = true; this.warningUntil = this.time.now + 250; this.overlay.setFillStyle(0xe63946, 0.18); - this.statusText.setText('Try the ' + this.promptDir + ' arrow.'); + this.statusText.setText('Try the ' + arrowLabel(this.promptDir) + '.'); this.playFailSound(); this.refreshDebug(); } + enterWaiting() { + if (this.state === 'stopped' || this.state === 'waiting') return; + this.state = 'waiting'; + this.targetSpeed = 0; + this.statusText.setText('Stop! Press ' + arrowLabel(this.promptDir)); + this.refreshDebug(); + } + update(_, deltaMs) { const now = performance.now(); const elapsed = now - this.lastStepTime; @@ -369,33 +402,55 @@ class FireTruckScene extends Phaser.Scene { const dy = this.segmentEnd.y - this.truck.y; const dist = Math.hypot(dx, dy); + const promptTriggerDist = Math.max(MIN_PROMPT_DIST, this.speed * REACTION_TIME_S); + let promptJustSet = false; - if (this.phase === 'approach' && !this.promptDir && dist <= PROMPT_TRIGGER_DIST) { + if (this.phase === 'approach' && !this.promptDir && dist <= promptTriggerDist) { this.promptDir = this.currentStep.move; this.promptResolved = false; - this.state = 'prompting'; this.promptShownAt = this.time.now; promptJustSet = true; - this.statusText.setText('Press the ' + this.promptDir + ' arrow before the crossing.'); + this.statusText.setText('Press the ' + arrowLabel(this.promptDir) + ' before the crossing.'); } - if (!promptJustSet && this.phase === 'approach' && this.promptDir && !this.promptResolved && dist <= STOP_LINE_DIST) { - if (this.time.now - this.promptShownAt >= AUTO_FAIL_GRACE_MS) { - this.enterStopped(); - } else { - this.promptResolved = true; - this.state = 'driving'; - this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0); - } + if (!promptJustSet && this.phase === 'approach' && this.promptDir && !this.promptResolved + && dist <= STOP_LINE_DIST + 1.0) { + this.enterWaiting(); } if (dist > 0.001 && this.speed > 0) { - const travel = Math.min(dist, this.speed * dt); + let travel = Math.min(dist, this.speed * dt); + if (this.phase === 'approach' && this.promptDir && !this.promptResolved) { + travel = Math.min(travel, Math.max(0, dist - STOP_LINE_DIST)); + } this.truck.x += (dx / dist) * travel; this.truck.y += (dy / dist) * travel; this.debugDistance += travel; } + // Fire proximity → enter firefighting + if (this.fireMode && this.fireCell && this.state === 'driving') { + const fdx = this.fireCell.x * CELL_SIZE - this.truck.x; + const fdy = this.fireCell.y * CELL_SIZE - this.truck.y; + if (Math.hypot(fdx, fdy) < FIRE_TRIGGER_DIST) { + this.enterFirefighting(); + return; + } + } + // Firefighting update + if (this.state === 'firefighting') { + if (this.spaceHeld) { + this.fireExtinguishAccum += dt; + if (this.fireExtinguishAccum >= FIRE_EXTINGUISH_S) this._extinguishFire(); + } + this._updateFireAnimation(this.time.now); + this._updateWaterSpray(); + this.refreshDebug(); + return; + } + // Always animate fire while driving toward it + if (this.fireGraphics) this._updateFireAnimation(this.time.now); + const remaining = Math.hypot(this.segmentEnd.x - this.truck.x, this.segmentEnd.y - this.truck.y); if (remaining <= 0.8 * SCALE) { this.truck.setPosition(this.segmentEnd.x, this.segmentEnd.y); @@ -447,7 +502,14 @@ class FireTruckScene extends Phaser.Scene { this.routeIndex += 1; this.extendRouteIfNeeded(); this.currentStep = this.route[this.routeIndex]; - if (!this.currentStep) return; + if (!this.currentStep) { + if (this.fireMode && this.fireCell) { + this.segmentEnd = { x: this.fireCell.x * CELL_SIZE, y: this.fireCell.y * CELL_SIZE }; + this.phase = 'approach'; + this.promptDir = null; + } + return; + } this.heading = this.currentStep.headingIn; this.segmentEnd = this.worldPoint(this.currentStep.x, this.currentStep.y); this.phase = 'approach'; @@ -460,6 +522,7 @@ class FireTruckScene extends Phaser.Scene { } extendRouteIfNeeded() { + if (this.fireMode) return; if (this.routeIndex < this.route.length - 12) return; const extension = FT.extendRouteOnGraph(this.island, this.route, ROUTE_EXTENSION_COUNT); this.route.push(...extension); @@ -478,7 +541,7 @@ class FireTruckScene extends Phaser.Scene { this.debugDistancePx = this.debugDistance; this.fps = this.game.loop.actualFps; if (this.fpsText) this.fpsText.setText(`FPS: ${this.fps.toFixed(0)}`); - this.promptText.setText(this.promptDir ? this.promptDir.toUpperCase() : ''); + this.promptText.setText(this.promptDir ? arrowLabel(this.promptDir) : ''); } cellAt(x, y) { @@ -495,7 +558,7 @@ class FireTruckScene extends Phaser.Scene { this.phase = 'approach'; this.promptDir = this.currentStep.move; this.promptResolved = false; - this.state = 'prompting'; + this.state = 'driving'; this.targetSpeed = 0; this.speed = 0; this.targetRotation = this.rotationForHeading(this.heading); @@ -506,11 +569,156 @@ class FireTruckScene extends Phaser.Scene { this.refreshDebug(); } + _initFireDestination() { + const island = this.island; + const rng = FT.createSeededRng(Date.now()); + const dest = FT.pickFireDestination(island, rng); + if (!dest) return; + this.fireCell = dest.roadCell; + this.fireBuildingCell = dest.buildingCell; + + const startCell = island.grid[island.routeStart.y][island.routeStart.x]; + const targetCell = island.grid[this.fireCell.y][this.fireCell.x]; + const cellPath = FT.bfsRoadPath(island.grid, startCell, targetCell); + if (!cellPath) return; + + const route = FT.pathToRouteSteps(island.grid, cellPath, 0); + this.route = route; + this.routeIndex = 0; + this.currentStep = route[0]; + + if (route.length === 0) { + this.segmentEnd = { x: this.fireCell.x * CELL_SIZE, y: this.fireCell.y * CELL_SIZE }; + this.heading = FT.headingFromTo(cellPath[0], cellPath[1]); + } else { + this.heading = this.currentStep.headingIn; + this.segmentEnd = this.worldPoint(this.currentStep.x, this.currentStep.y); + } + + this.targetRotation = this.rotationForHeading(this.heading); + this._createFireGraphics(); + this.fireMode = true; + } + + _createFireGraphics() { + this.fireGraphics = this.add.graphics(); + this.fireGraphics.setDepth(10); + this.uiCamera.ignore(this.fireGraphics); + } + + _updateFireAnimation(t) { + if (!this.fireGraphics || !this.fireBuildingCell) return; + const gfx = this.fireGraphics; + gfx.clear(); + const cx = this.fireBuildingCell.x * CELL_SIZE; + const cy = this.fireBuildingCell.y * CELL_SIZE; + const colors = [0xff6600, 0xff2200, 0xffcc00, 0xff8800]; + for (let i = 0; i < 5; i++) { + const offset = Math.sin(t * 0.003 + i * 1.3) * 10; + const h = 20 + Math.sin(t * 0.005 + i * 2.1) * 10; + gfx.fillStyle(colors[i % colors.length], 0.7 + Math.sin(t * 0.004 + i) * 0.3); + gfx.fillTriangle( + cx + offset - 8, cy + 20, + cx + offset + 8, cy + 20, + cx + offset, cy + 20 - h + ); + } + } + + _updateWaterSpray() { + if (!this.spaceHeld || !this.fireCell) { + if (this.waterGraphics) this.waterGraphics.clear(); + return; + } + if (!this.waterGraphics) { + this.waterGraphics = this.add.graphics(); + this.waterGraphics.setDepth(11); + this.uiCamera.ignore(this.waterGraphics); + } + const gfx = this.waterGraphics; + gfx.clear(); + const tx = this.fireCell.x * CELL_SIZE + CELL_SIZE / 2; + const ty = this.fireCell.y * CELL_SIZE + CELL_SIZE / 2; + const sx = this.truck.x; + const sy = this.truck.y; + gfx.lineStyle(8 * SCALE, 0x44aaff, 0.75); + for (let i = 0; i < 8; i++) { + const t0 = i / 8; + const t1 = (i + 1) / 8; + const x0 = sx + (tx - sx) * t0 + Math.sin(t0 * Math.PI * 2) * 10; + const y0 = sy + (ty - sy) * t0; + const x1 = sx + (tx - sx) * t1 + Math.sin(t1 * Math.PI * 2) * 10; + const y1 = sy + (ty - sy) * t1; + gfx.lineBetween(x0, y0, x1, y1); + } + } + + enterFirefighting() { + this.state = 'firefighting'; + this.targetSpeed = 0; + this.speed = 0; + this.fireExtinguishAccum = 0; + this.spaceHeld = false; + this.promptDir = null; + this.statusText.setText('Hold SPACE to spray water!'); + this.promptText.setText(''); + } + + _extinguishFire() { + this.successOverlay.setAlpha(0.12); + this.successUntil = this.time.now + 200; + this.playSuccessSound(); + if (this.fireGraphics) { this.fireGraphics.destroy(); this.fireGraphics = null; } + if (this.waterGraphics) { this.waterGraphics.destroy(); this.waterGraphics = null; } + this.fireCell = null; + this.fireBuildingCell = null; + this.statusText.setText('Fire out! Great job!'); + + this.time.delayedCall(600, () => { + const island = this.island; + const rng = FT.createSeededRng(Date.now()); + const dest = FT.pickFireDestination(island, rng); + if (!dest) return; + this.fireCell = dest.roadCell; + this.fireBuildingCell = dest.buildingCell; + + // BFS from current truck position cell + const truckX = Math.round(this.truck.x / CELL_SIZE); + const truckY = Math.round(this.truck.y / CELL_SIZE); + const startCell = island.grid[truckY] && island.grid[truckY][truckX]; + if (!startCell || startCell.type !== FT.CELL_TYPES.ROAD) return; + const targetCell = island.grid[this.fireCell.y][this.fireCell.x]; + const cellPath = FT.bfsRoadPath(island.grid, startCell, targetCell); + if (!cellPath) return; + + const route = FT.pathToRouteSteps(island.grid, cellPath, 0); + this.route = route; + this.routeIndex = 0; + this.currentStep = route[0]; + + if (route.length === 0) { + this.segmentEnd = { x: this.fireCell.x * CELL_SIZE, y: this.fireCell.y * CELL_SIZE }; + } else { + this.heading = this.currentStep.headingIn; + this.segmentEnd = this.worldPoint(this.currentStep.x, this.currentStep.y); + } + this.phase = 'approach'; + this.promptDir = null; + this.promptResolved = false; + this.state = 'driving'; + this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0); + this.statusText.setText('Watch for the next arrow.'); + this._createFireGraphics(); + }); + } + shutdown() { if (this.fallbackTimer) { clearInterval(this.fallbackTimer); this.fallbackTimer = null; } + if (this.fireGraphics) { this.fireGraphics.destroy(); this.fireGraphics = null; } + if (this.waterGraphics) { this.waterGraphics.destroy(); this.waterGraphics = null; } } } diff --git a/games/fire-truck/lib.js b/games/fire-truck/lib.js @@ -282,6 +282,104 @@ return out; } + function pickRandomRouteStart(roads, rand) { + const candidates = roads.filter(c => { + const meta = c.meta || classifyIntersection(c.exits); + return meta.degree >= 2; + }); + const cell = randPick(rand, candidates); + const cards = ['N', 'E', 'S', 'W'].filter(c => cell.exits[c]); + const card = randPick(rand, cards); + return { x: cell.x, y: cell.y, heading: CARD_TO_HEADING[card] }; + } + + function headingFromTo(a, b) { + const dx = b.x - a.x, dy = b.y - a.y; + if (dx === 1) return 'east'; + if (dx === -1) return 'west'; + if (dy === 1) return 'south'; + return 'north'; + } + + function bfsRoadPath(grid, startCell, targetCell) { + if (startCell === targetCell) return [startCell]; + const queue = [startCell]; + const parent = new Map(); + parent.set(startCell, null); + while (queue.length) { + const cell = queue.shift(); + for (const card of ['N', 'E', 'S', 'W']) { + if (!cell.exits[card]) continue; + const nx = cell.x + DIRS[card].dx; + const ny = cell.y + DIRS[card].dy; + const next = grid[ny] && grid[ny][nx]; + if (!next || next.type !== CELL_TYPES.ROAD) continue; + if (parent.has(next)) continue; + parent.set(next, cell); + if (next === targetCell) { + const path = []; + let cur = next; + while (cur) { + path.push(cur); + cur = parent.get(cur); + } + return path.reverse(); + } + queue.push(next); + } + } + return null; + } + + function pathToRouteSteps(grid, cellPath, startIndex) { + if (!cellPath || cellPath.length <= 2) return []; + const steps = []; + for (let i = 1; i < cellPath.length - 1; i++) { + const decision = cellPath[i]; + if (!isDecisionCell(decision)) continue; + const approach = cellPath[i - 1]; + const exitCell = cellPath[i + 1]; + const headingIn = headingFromTo(approach, decision); + const headingOut = headingFromTo(decision, exitCell); + steps.push({ + index: (startIndex ?? 0) + steps.length, + x: decision.x, + y: decision.y, + approachX: approach.x, + approachY: approach.y, + headingIn, + headingOut, + move: getRelativeMove(headingIn, headingOut), + exitX: exitCell.x, + exitY: exitCell.y, + }); + } + return steps; + } + + function pickFireDestination(island, rng) { + const rand = rng || createSeededRng(); + const candidates = island.roads.filter(cell => { + for (const card of ['N', 'E', 'S', 'W']) { + const nx = cell.x + DIRS[card].dx; + const ny = cell.y + DIRS[card].dy; + const neighbor = island.grid[ny] && island.grid[ny][nx]; + if (neighbor && neighbor.type === CELL_TYPES.BUILDING) return true; + } + return false; + }); + const roadCell = randPick(rand, candidates); + for (const card of ['N', 'E', 'S', 'W']) { + const nx = roadCell.x + DIRS[card].dx; + const ny = roadCell.y + DIRS[card].dy; + const neighbor = island.grid[ny] && island.grid[ny][nx]; + if (neighbor && neighbor.type === CELL_TYPES.BUILDING) { + return { roadCell, buildingCell: neighbor }; + } + } + return null; + } + function buildIsland(opts, rng) { const width = (opts && opts.width) ?? ISLAND_WIDTH; const height = (opts && opts.height) ?? ISLAND_HEIGHT; @@ -298,16 +396,19 @@ stitchRoadExits(grid, width, height); validateConnectedRoads(grid, width, height); - const routeStart = { x: 3, y: 2, heading: 'east' }; - const route = buildRouteOnGraph(grid, { count: routeCount, startCell: routeStart, startHeading: routeStart.heading }, rand); const cells = flattenGrid(grid); + const roads = cells.filter(c => c.type === CELL_TYPES.ROAD); + const routeStart = (opts && opts.routeStart) + ? opts.routeStart + : pickRandomRouteStart(roads, rand); + const route = buildRouteOnGraph(grid, { count: routeCount, startCell: routeStart, startHeading: routeStart.heading }, rand); return { width, height, grid, cells, - roads: cells.filter(c => c.type === CELL_TYPES.ROAD), + roads, buildings: cells.filter(c => c.type === CELL_TYPES.BUILDING), beach: cells.filter(c => c.type === CELL_TYPES.BEACH), water: cells.filter(c => c.type === CELL_TYPES.WATER), @@ -489,6 +590,11 @@ CARD_TO_HEADING, HEADING_TO_CARD, DIRS, + pickRandomRouteStart, + headingFromTo, + bfsRoadPath, + pathToRouteSteps, + pickFireDestination, }; if (typeof module !== 'undefined' && module.exports) module.exports = api; diff --git a/tests/browser/runner.js b/tests/browser/runner.js @@ -226,7 +226,8 @@ async function main() { })); const moved = Math.hypot(end.x - start.x, end.y - start.y); const distanceMoved = end.distance - start.distance; - if (moved < 20 && distanceMoved < 120) { + const reachedWaiting = start.state === 'driving' && end.state === 'waiting'; + if (moved < 20 && distanceMoved < 120 && !reachedWaiting) { throw new Error(`Truck moved only ${moved.toFixed(1)} pixels and advanced ${distanceMoved.toFixed(1)} path pixels (start ${start.state}/${start.prompt || 'none'}, end ${end.state}/${end.prompt || 'none'})`); } }); @@ -279,7 +280,7 @@ async function main() { cell11: s.cellAt(1, 1).type, cell22: s.cellAt(2, 2).type, 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), - hasFourWayOnRoute: s.route.some(step => s.cellAt(step.x, step.y).meta.kind === 'four'), + hasFourWayOnIsland: s.island.roads.some(c => s.cellAt(c.x, c.y).meta.kind === 'four'), }; }); if (info.width !== 50) throw new Error(`Expected width 50, got ${info.width}`); @@ -288,7 +289,87 @@ async function main() { if (info.cell11 !== 'beach') throw new Error(`Expected cell11 beach, got ${info.cell11}`); if (info.cell22 !== 'road') throw new Error(`Expected cell22 road, got ${info.cell22}`); if (info.routeOutOfBounds) throw new Error('Route step out of bounds'); - if (!info.hasFourWayOnRoute) throw new Error('No four-way intersection on route'); + if (!info.hasFourWayOnIsland) throw new Error('No four-way intersection on island'); + }); + + await test('fire-truck: no input leads to waiting state (not stopped)', async (page, url) => { + await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { window.__FT_SCENE__.truck.setPosition(window.__FT_SCENE__.segmentEnd.x - 500, window.__FT_SCENE__.segmentEnd.y); }); + await page.waitForFunction(() => window.__FT_SCENE__ && !!window.__FT_SCENE__.promptDir, null, { timeout: 25000 }); + await page.evaluate(() => { window.__FT_SCENE__.targetSpeed = 0; window.__FT_SCENE__.speed = 0; window.__FT_SCENE__.lastStepTime = performance.now(); }); + // Move truck right up to the stop line without resolving + await page.evaluate(() => { + const s = window.__FT_SCENE__; + s.truck.setPosition(s.segmentEnd.x - 260, s.segmentEnd.y); + s.step(0.016); + }); + const state = await page.evaluate(() => window.__FT_SCENE__.state); + if (state !== 'waiting') throw new Error(`Expected waiting, got ${state}`); + }); + + await test('fire-truck: correct key from waiting resumes driving', async (page, url) => { + await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { window.__FT_SCENE__.truck.setPosition(window.__FT_SCENE__.segmentEnd.x - 500, window.__FT_SCENE__.segmentEnd.y); }); + await page.waitForFunction(() => window.__FT_SCENE__ && !!window.__FT_SCENE__.promptDir, null, { timeout: 25000 }); + await page.evaluate(() => { window.__FT_SCENE__.targetSpeed = 0; window.__FT_SCENE__.speed = 0; window.__FT_SCENE__.lastStepTime = performance.now(); }); + const promptDir = await page.evaluate(() => window.__FT_SCENE__.promptDir); + const correctKey = promptDir === 'left' ? 'ArrowLeft' : promptDir === 'right' ? 'ArrowRight' : 'ArrowUp'; + // Put into waiting + await page.evaluate(() => { + const s = window.__FT_SCENE__; + s.truck.setPosition(s.segmentEnd.x - 260, s.segmentEnd.y); + s.step(0.016); + }); + await page.keyboard.press(correctKey); + try { + await page.waitForFunction(() => window.__FT_SCENE__.state === 'driving', null, { timeout: 5000 }); + } catch (e) { + const dbg = await page.evaluate(() => ({ state: window.__FT_SCENE__.state, promptDir: window.__FT_SCENE__.promptDir, resolved: window.__FT_SCENE__.promptResolved })); + throw new Error(`Timeout driving. State: ${JSON.stringify(dbg)}`); + } + }); + + await test('fire-truck: fire destination is set after load', async (page, url) => { + await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1800); + const info = await page.evaluate(() => { + const s = window.__FT_SCENE__; + return { + hasFireCell: !!s.fireCell, + hasFireBuildingCell: !!s.fireBuildingCell, + fireIsRoad: s.fireCell ? s.cellAt(s.fireCell.x, s.fireCell.y).type === 'road' : false, + buildingIsBuilding: s.fireBuildingCell ? s.cellAt(s.fireBuildingCell.x, s.fireBuildingCell.y).type === 'building' : false, + adjacent: s.fireCell && s.fireBuildingCell ? Math.abs(s.fireCell.x - s.fireBuildingCell.x) + Math.abs(s.fireCell.y - s.fireBuildingCell.y) === 1 : false, + }; + }); + if (!info.hasFireCell) throw new Error('fireCell not set'); + if (!info.hasFireBuildingCell) throw new Error('fireBuildingCell not set'); + if (!info.fireIsRoad) throw new Error('fireCell is not a road'); + if (!info.buildingIsBuilding) throw new Error('fireBuildingCell is not a building'); + if (!info.adjacent) throw new Error('fireCell and fireBuildingCell are not adjacent'); + }); + + await test('fire-truck: spacebar accumulates extinguish in firefighting state', async (page, url) => { + await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1800); + // Force into firefighting by teleporting truck to fireCell + await page.evaluate(() => { + const s = window.__FT_SCENE__; + if (s.fireCell) { + s.state = 'driving'; + s.speed = 0; + s.targetSpeed = 0; + s.truck.setPosition(s.fireCell.x * s.cellSize, s.fireCell.y * s.cellSize); + s.step(0.016); + } + }); + await page.waitForFunction(() => window.__FT_SCENE__.state === 'firefighting', null, { timeout: 5000 }); + const before = await page.evaluate(() => window.__FT_SCENE__.fireExtinguishAccum); + await page.keyboard.down('Space'); + await page.waitForTimeout(400); + const after = await page.evaluate(() => window.__FT_SCENE__.fireExtinguishAccum); + await page.keyboard.up('Space'); + if (after <= before) throw new Error(`Extinguish did not accumulate: ${before} -> ${after}`); }); // ── Summary ─────────────────────────────────────────────────────────────── diff --git a/tests/unit/route.test.js b/tests/unit/route.test.js @@ -9,6 +9,14 @@ const { extendRouteOnGraph, OPPOSITE, HEADING_TO_CARD, + pickRandomRouteStart, + bfsRoadPath, + pathToRouteSteps, + pickFireDestination, + headingFromTo, + isDecisionCell, + createSeededRng, + CARD_TO_HEADING, } = require('../../games/fire-truck/lib.js'); const fixedRng = { pick: arr => arr[0], next: () => 0.25 }; @@ -80,7 +88,7 @@ describe('buildIsland()', () => { }); it('route starts correctly', () => { - const island = buildIsland({ seed: 7, routeCount: 10 }); + const island = buildIsland({ seed: 7, routeCount: 10, routeStart: { x: 3, y: 2, heading: 'east' } }); assert.deepEqual(island.routeStart, { x: 3, y: 2, heading: 'east' }); assert.equal(island.route[0].headingIn, 'east'); @@ -105,3 +113,93 @@ describe('buildIsland()', () => { assert.equal(firstExt.headingIn, last.headingOut); }); }); + +describe('pickRandomRouteStart()', () => { + it('returns a road cell with a valid exit heading', () => { + const island = buildIsland({ seed: 1, routeCount: 10 }); + const start = pickRandomRouteStart(island.roads, createSeededRng(42)); + const cell = island.grid[start.y][start.x]; + assert.equal(cell.type, 'road'); + const meta = cell.meta || classifyIntersection(cell.exits); + assert.ok(meta.degree >= 2); + const card = HEADING_TO_CARD[start.heading]; + assert.equal(cell.exits[card], true); + }); +}); + +describe('headingFromTo()', () => { + it('derives heading between adjacent cells', () => { + assert.equal(headingFromTo({ x: 1, y: 1 }, { x: 2, y: 1 }), 'east'); + assert.equal(headingFromTo({ x: 1, y: 1 }, { x: 0, y: 1 }), 'west'); + assert.equal(headingFromTo({ x: 1, y: 1 }, { x: 1, y: 2 }), 'south'); + assert.equal(headingFromTo({ x: 1, y: 1 }, { x: 1, y: 0 }), 'north'); + }); +}); + +describe('bfsRoadPath()', () => { + it('finds a valid road path', () => { + const island = buildIsland({ seed: 5, routeCount: 10 }); + // Pick two distinct road cells + const start = island.roads[0]; + const target = island.roads.find(c => c !== start && Math.abs(c.x - start.x) + Math.abs(c.y - start.y) > 5); + assert.ok(target, 'need a distinct road cell for target'); + const path = bfsRoadPath(island.grid, start, target); + assert.ok(path); + assert.ok(path.length > 0); + assert.equal(path[0], start); + assert.equal(path[path.length - 1], target); + for (const cell of path) { + assert.equal(cell.type, 'road'); + } + for (let i = 1; i < path.length; i++) { + const dx = path[i].x - path[i - 1].x; + const dy = path[i].y - path[i - 1].y; + assert.ok(Math.abs(dx) + Math.abs(dy) === 1); + } + }); + + it('returns single cell when start === target', () => { + const island = buildIsland({ seed: 5, routeCount: 10 }); + const cell = island.grid[3][3]; + const path = bfsRoadPath(island.grid, cell, cell); + assert.deepEqual(path, [cell]); + }); +}); + +describe('pathToRouteSteps()', () => { + it('emits steps only at decision cells', () => { + const island = buildIsland({ seed: 6, routeCount: 10 }); + const start = island.grid[island.routeStart.y][island.routeStart.x]; + const end = island.route[island.route.length - 1]; + const target = island.grid[end.y][end.x]; + const path = bfsRoadPath(island.grid, start, target); + const steps = pathToRouteSteps(island.grid, path, 0); + for (const step of steps) { + const cell = island.grid[step.y][step.x]; + assert.ok(isDecisionCell(cell)); + assert.equal(step.move, getRelativeMove(step.headingIn, step.headingOut)); + } + }); + + it('returns empty array for short paths', () => { + const island = buildIsland({ seed: 6, routeCount: 10 }); + const cell = island.grid[3][3]; + assert.deepEqual(pathToRouteSteps(island.grid, [cell], 0), []); + assert.deepEqual(pathToRouteSteps(island.grid, [cell, island.grid[3][4]], 0), []); + }); +}); + +describe('pickFireDestination()', () => { + it('returns adjacent road and building cells', () => { + const island = buildIsland({ seed: 8, routeCount: 10 }); + const dest = pickFireDestination(island, createSeededRng(42)); + assert.ok(dest); + assert.ok(dest.roadCell); + assert.ok(dest.buildingCell); + const dx = Math.abs(dest.roadCell.x - dest.buildingCell.x); + const dy = Math.abs(dest.roadCell.y - dest.buildingCell.y); + assert.equal(dx + dy, 1); + assert.equal(dest.roadCell.type, 'road'); + assert.equal(dest.buildingCell.type, 'building'); + }); +});