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:
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');
+ });
+});