commit 80d9f5deeb6338744aa648b5a3e363a9a316b0d3
parent 9ea773b1f20332f6913365c151f29e2e4c56eaf7
Author: Kyle Barlow <kb@kylebarlow.com>
Date: Sun, 26 Apr 2026 19:45:28 -0700
Island map
Diffstat:
8 files changed, 789 insertions(+), 338 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -1,3 +1,4 @@
+tests/screenshots
deploy.sh
.DS_Store
Thumbs.db
diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js
@@ -2,7 +2,6 @@ const FT = window.FireTruckLib;
const SCALE = 5;
const CELL_SIZE = 96 * SCALE;
-const ROAD_WIDTH = 44 * SCALE;
const BASE_SPEED = 170;
const MAX_SPEED = 220;
const ACCEL = 260;
@@ -10,17 +9,24 @@ const BRAKE = 320;
const PROMPT_TRIGGER_DIST = CELL_SIZE * 0.72;
const STOP_LINE_DIST = 6 * SCALE;
const AUTO_FAIL_GRACE_MS = 4500;
+const ROUTE_EXTENSION_COUNT = 36;
+
+const WATER_COLOR = 0x6bc4e8;
+const BEACH_COLOR = 0xf2e2b6;
+const ASPHALT_COLOR = 0x2a2d34;
+const STRIPE_COLOR = 0xffd23f;
+const BUILDING_PALETTE = [0xf8d4a5, 0xb8dfe1, 0xf7b4b4, 0xffefb0, 0xcad6f9, 0xdcc7eb];
class FireTruckScene extends Phaser.Scene {
constructor() {
super({ key: 'FireTruckScene' });
this.state = 'driving';
- this.speed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER || 1.0);
- this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER || 1.0);
+ this.speed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0);
+ this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0);
this.promptDir = null;
this.failVisible = false;
this.route = [];
- this.city = null;
+ this.island = null;
this.routeIndex = 0;
this.phase = 'approach';
this.currentStep = null;
@@ -38,15 +44,18 @@ class FireTruckScene extends Phaser.Scene {
}
create() {
- this.cameras.main.setBackgroundColor('#d8ead2');
-
- const rng = {
- next: () => Math.random(),
- pick: arr => arr[Math.floor(Math.random() * arr.length)],
- };
+ this.island = FT.buildIsland({ width: 50, height: 50, routeCount: 200, seed: 20260426 });
+ this.route = this.island.route;
+ this.cellSize = CELL_SIZE;
- this.route = FT.buildRoute(64, rng);
- this.city = FT.buildCity(this.route, rng);
+ this.cameras.main.setBackgroundColor('#6BC4E8');
+ this.cameras.main.setZoom(1 / SCALE);
+ this.cameras.main.setBounds(
+ this.island.bounds.minX * CELL_SIZE,
+ this.island.bounds.minY * CELL_SIZE,
+ this.island.bounds.width * CELL_SIZE,
+ this.island.bounds.height * CELL_SIZE
+ );
this.overlay = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0xe63946, 0)
.setOrigin(0, 0)
@@ -86,16 +95,15 @@ class FireTruckScene extends Phaser.Scene {
}).setScrollFactor(0).setDepth(50);
this.uiCamera = this.cameras.add(0, 0, this.scale.width, this.scale.height);
- this.cameras.main.setZoom(1 / SCALE);
- this.renderCity();
+ 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.currentStep = this.route[0];
- const start = this.worldPoint(-1, 0);
+ const start = this.worldPoint(this.island.routeStart.x, this.island.routeStart.y);
this.segmentEnd = this.worldPoint(this.currentStep.x, this.currentStep.y);
this.heading = this.currentStep.headingIn;
this.targetRotation = this.rotationForHeading(this.heading);
@@ -129,88 +137,111 @@ class FireTruckScene extends Phaser.Scene {
return { x: x * CELL_SIZE, y: y * CELL_SIZE };
}
- renderCity() {
- // Bake the static road+buildings into tiled textures so per-frame cost
- // drops from thousands of Graphics draw commands to a handful of textured
- // quads — and Phaser's camera culling skips offscreen chunks for free.
- const roadCells = new Set(this.city.cells.map(cell => cell.x + ',' + cell.y));
- const xs = this.city.cells.map(cell => cell.x);
- const ys = this.city.cells.map(cell => cell.y);
- const minX = Math.min(...xs) - 4;
- const maxX = Math.max(...xs) + 4;
- const minY = Math.min(...ys) - 4;
- const maxY = Math.max(...ys) + 4;
- const palette = [0xf8d4a5, 0xb8dfe1, 0xf7b4b4, 0xffefb0, 0xcad6f9, 0xdcc7eb];
-
- // Bake at 1/SCALE resolution (pixel-perfect at default zoom since
- // zoom*SCALE == 1), chunked to stay well under MAX_TEXTURE_SIZE.
+ renderIsland() {
+ // Bake the static island into tiled textures so per-frame cost drops to a
+ // handful of textured quads — and Phaser's camera culling skips offscreen
+ // chunks for free.
const BAKE_CELL = CELL_SIZE / SCALE;
- const BAKE_ROAD = ROAD_WIDTH / SCALE;
const CHUNK_CELLS = 16;
const CHUNK_PX = CHUNK_CELLS * BAKE_CELL;
-
- // Bucket road cells by chunk so we only iterate relevant ones per chunk.
- const roadByChunk = new Map();
- this.city.cells.forEach(cell => {
- const ckey = Math.floor(cell.x / CHUNK_CELLS) + ',' + Math.floor(cell.y / CHUNK_CELLS);
- if (!roadByChunk.has(ckey)) roadByChunk.set(ckey, []);
- roadByChunk.get(ckey).push(cell);
- });
+ const chunkCountX = Math.ceil(this.island.width / CHUNK_CELLS);
+ const chunkCountY = Math.ceil(this.island.height / CHUNK_CELLS);
this.cityChunks = [];
- const chunkStartX = Math.floor(minX / CHUNK_CELLS);
- const chunkEndX = Math.floor(maxX / CHUNK_CELLS);
- const chunkStartY = Math.floor(minY / CHUNK_CELLS);
- const chunkEndY = Math.floor(maxY / CHUNK_CELLS);
- for (let chunkY = chunkStartY; chunkY <= chunkEndY; chunkY++) {
- for (let chunkX = chunkStartX; chunkX <= chunkEndX; chunkX++) {
+ for (let chunkY = 0; chunkY < chunkCountY; chunkY++) {
+ for (let chunkX = 0; chunkX < chunkCountX; chunkX++) {
const cellStartX = chunkX * CHUNK_CELLS;
const cellStartY = chunkY * CHUNK_CELLS;
- const cellEndX = cellStartX + CHUNK_CELLS;
- const cellEndY = cellStartY + CHUNK_CELLS;
+ const cellEndX = Math.min(this.island.width, cellStartX + CHUNK_CELLS);
+ const cellEndY = Math.min(this.island.height, cellStartY + CHUNK_CELLS);
const gfx = this.make.graphics({ add: false });
- gfx.translateCanvas(-cellStartX * BAKE_CELL, -cellStartY * BAKE_CELL);
-
- const bStartX = Math.max(minX, cellStartX);
- const bEndX = Math.min(maxX, cellEndX - 1);
- const bStartY = Math.max(minY, cellStartY);
- const bEndY = Math.min(maxY, cellEndY - 1);
- for (let y = bStartY; y <= bEndY; y++) {
- for (let x = bStartX; x <= bEndX; x++) {
- if (roadCells.has(x + ',' + y)) continue;
- const px = x * BAKE_CELL - BAKE_CELL * 0.38;
- const py = y * BAKE_CELL - BAKE_CELL * 0.38;
- const color = palette[Math.abs((x * 13 + y * 17) % palette.length)];
- gfx.fillStyle(color, 1);
- gfx.fillRoundedRect(px, py, BAKE_CELL * 0.76, BAKE_CELL * 0.76, 10);
- gfx.fillStyle(0xffffff, 0.16);
- gfx.fillRect(px + 8, py + 8, BAKE_CELL * 0.3, 10);
+
+ for (let y = cellStartY; y < cellEndY; y++) {
+ for (let x = cellStartX; x < cellEndX; x++) {
+ const cell = this.island.grid[y][x];
+ const localX = (x - cellStartX) * BAKE_CELL;
+ const localY = (y - cellStartY) * BAKE_CELL;
+
+ // 1. Non-road cells (water, beach, building) over full footprint
+ if (cell.type !== FT.CELL_TYPES.ROAD) {
+ gfx.fillStyle(colorForCell(cell), 1);
+ gfx.fillRect(localX, localY, BAKE_CELL, BAKE_CELL);
+ if (cell.type === FT.CELL_TYPES.BUILDING) {
+ gfx.lineStyle(2, 0x000000, 0.08);
+ gfx.strokeRect(localX + 1, localY + 1, BAKE_CELL - 2, BAKE_CELL - 2);
+ }
+ continue;
+ }
+
+ // 2. Road base
+ gfx.fillStyle(ASPHALT_COLOR, 1);
+ gfx.fillRect(localX, localY, BAKE_CELL, BAKE_CELL);
+ }
+ }
+
+ // 3. Road corner masks
+ const maskSize = Math.round(BAKE_CELL * 0.22);
+ for (let y = cellStartY; y < cellEndY; y++) {
+ for (let x = cellStartX; x < cellEndX; x++) {
+ const cell = this.island.grid[y][x];
+ if (cell.type !== FT.CELL_TYPES.ROAD) continue;
+ const localX = (x - cellStartX) * BAKE_CELL;
+ const localY = (y - cellStartY) * BAKE_CELL;
+
+ if (!cell.exits.N && !cell.exits.W) {
+ const dc = this.island.grid[y - 1] && this.island.grid[y - 1][x - 1];
+ gfx.fillStyle(colorForCell(dc), 1);
+ gfx.fillRect(localX, localY, maskSize, maskSize);
+ }
+ if (!cell.exits.N && !cell.exits.E) {
+ const dc = this.island.grid[y - 1] && this.island.grid[y - 1][x + 1];
+ gfx.fillStyle(colorForCell(dc), 1);
+ gfx.fillRect(localX + BAKE_CELL - maskSize, localY, maskSize, maskSize);
+ }
+ if (!cell.exits.S && !cell.exits.E) {
+ const dc = this.island.grid[y + 1] && this.island.grid[y + 1][x + 1];
+ gfx.fillStyle(colorForCell(dc), 1);
+ gfx.fillRect(localX + BAKE_CELL - maskSize, localY + BAKE_CELL - maskSize, maskSize, maskSize);
+ }
+ if (!cell.exits.S && !cell.exits.W) {
+ const dc = this.island.grid[y + 1] && this.island.grid[y + 1][x - 1];
+ gfx.fillStyle(colorForCell(dc), 1);
+ gfx.fillRect(localX, localY + BAKE_CELL - maskSize, maskSize, maskSize);
+ }
}
}
- const chunkRoads = roadByChunk.get(chunkX + ',' + chunkY) || [];
- chunkRoads.forEach(cell => {
- const cx = cell.x * BAKE_CELL;
- const cy = cell.y * BAKE_CELL;
- gfx.fillStyle(0x4a4e57, 1);
- gfx.fillRect(cx - BAKE_ROAD / 2, cy - BAKE_ROAD / 2, BAKE_ROAD, BAKE_ROAD);
- if (cell.exits.N) gfx.fillRect(cx - BAKE_ROAD / 2, cy - BAKE_CELL / 2, BAKE_ROAD, BAKE_CELL / 2);
- if (cell.exits.S) gfx.fillRect(cx - BAKE_ROAD / 2, cy, BAKE_ROAD, BAKE_CELL / 2);
- if (cell.exits.E) gfx.fillRect(cx, cy - BAKE_ROAD / 2, BAKE_CELL / 2, BAKE_ROAD);
- if (cell.exits.W) gfx.fillRect(cx - BAKE_CELL / 2, cy - BAKE_ROAD / 2, BAKE_CELL / 2, BAKE_ROAD);
- gfx.fillStyle(0xfff4b1, 0.92);
- if (cell.exits.N && cell.exits.S) gfx.fillRect(cx - 3, cy - BAKE_CELL / 2 + 8, 6, BAKE_CELL - 16);
- if (cell.exits.E && cell.exits.W) gfx.fillRect(cx - BAKE_CELL / 2 + 8, cy - 3, BAKE_CELL - 16, 6);
- });
-
- const key = `city-bake-${chunkX}_${chunkY}`;
+ // 4. Lane stripes on straight roads only
+ for (let y = cellStartY; y < cellEndY; y++) {
+ for (let x = cellStartX; x < cellEndX; x++) {
+ const cell = this.island.grid[y][x];
+ if (cell.type !== FT.CELL_TYPES.ROAD) continue;
+ const localX = (x - cellStartX) * BAKE_CELL;
+ const localY = (y - cellStartY) * BAKE_CELL;
+
+ const meta = cell.meta || FT.classifyIntersection(cell.exits);
+ if (meta.kind === 'straight') {
+ gfx.fillStyle(STRIPE_COLOR, 0.92);
+ if (cell.exits.N && cell.exits.S) {
+ gfx.fillRect(localX + BAKE_CELL / 2 - 3, localY + BAKE_CELL * 0.1, 6, BAKE_CELL * 0.8);
+ }
+ if (cell.exits.E && cell.exits.W) {
+ gfx.fillRect(localX + BAKE_CELL * 0.1, localY + BAKE_CELL / 2 - 3, BAKE_CELL * 0.8, 6);
+ }
+ }
+ }
+ }
+
+ const key = `island-bake-${chunkX}_${chunkY}`;
if (this.textures.exists(key)) this.textures.remove(key);
gfx.generateTexture(key, CHUNK_PX, CHUNK_PX);
gfx.destroy();
- const img = this.add.image(cellStartX * CELL_SIZE, cellStartY * CELL_SIZE, key)
+ const imageWorldX = (cellStartX - 0.5) * CELL_SIZE;
+ const imageWorldY = (cellStartY - 0.5) * CELL_SIZE;
+ const img = this.add.image(imageWorldX, imageWorldY, key)
.setOrigin(0, 0)
.setScale(SCALE)
.setDepth(0);
@@ -238,7 +269,7 @@ class FireTruckScene extends Phaser.Scene {
}
updateTargetSpeed() {
- const m = window.GAME_SPEED_MULTIPLIER || 1.0;
+ 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') {
@@ -255,7 +286,7 @@ class FireTruckScene extends Phaser.Scene {
this.failVisible = false;
this.overlay.setFillStyle(0xe63946, 0);
this.state = 'driving';
- this.targetSpeed = MAX_SPEED * (window.GAME_SPEED_MULTIPLIER || 1.0);
+ this.targetSpeed = MAX_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0);
this.statusText.setText('Great! Keep driving.');
this.playSuccessSound();
this.successOverlay.setAlpha(0.12);
@@ -330,7 +361,7 @@ class FireTruckScene extends Phaser.Scene {
}
step(dt) {
- const m = window.GAME_SPEED_MULTIPLIER || 1.0;
+ const m = window.GAME_SPEED_MULTIPLIER ?? 1.0;
if (this.speed < this.targetSpeed) this.speed = Math.min(this.targetSpeed, this.speed + ACCEL * m * dt);
if (this.speed > this.targetSpeed) this.speed = Math.max(this.targetSpeed, this.speed - BRAKE * m * dt);
@@ -354,7 +385,7 @@ class FireTruckScene extends Phaser.Scene {
} else {
this.promptResolved = true;
this.state = 'driving';
- this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER || 1.0);
+ this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0);
}
}
@@ -409,7 +440,7 @@ class FireTruckScene extends Phaser.Scene {
this.targetRotation = this.rotationForHeading(this.heading);
this.phase = 'exit';
this.state = 'driving';
- this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER || 1.0);
+ this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0);
return;
}
@@ -424,21 +455,13 @@ class FireTruckScene extends Phaser.Scene {
this.promptResolved = false;
this.promptShownAt = 0;
this.state = 'driving';
- this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER || 1.0);
+ this.targetSpeed = BASE_SPEED * (window.GAME_SPEED_MULTIPLIER ?? 1.0);
this.statusText.setText('Watch for the next arrow.');
}
extendRouteIfNeeded() {
if (this.routeIndex < this.route.length - 12) return;
- const last = this.route[this.route.length - 1];
- const extension = FT.buildRoute(36).map((step, index) => ({
- ...step,
- index: this.route.length + index,
- x: step.x + last.exitX,
- y: step.y + last.exitY,
- exitX: step.exitX + last.exitX,
- exitY: step.exitY + last.exitY,
- }));
+ const extension = FT.extendRouteOnGraph(this.island, this.route, ROUTE_EXTENSION_COUNT);
this.route.push(...extension);
}
@@ -458,6 +481,31 @@ class FireTruckScene extends Phaser.Scene {
this.promptText.setText(this.promptDir ? this.promptDir.toUpperCase() : '');
}
+ cellAt(x, y) {
+ return this.island && this.island.grid[y] && this.island.grid[y][x] ? this.island.grid[y][x] : null;
+ }
+
+ snapTruckToRouteIndex(index) {
+ if (!this.route[index]) throw new Error('Invalid route index: ' + index);
+
+ this.routeIndex = index;
+ this.currentStep = this.route[index];
+ this.heading = this.currentStep.headingIn;
+ this.segmentEnd = this.worldPoint(this.currentStep.x, this.currentStep.y);
+ this.phase = 'approach';
+ this.promptDir = this.currentStep.move;
+ this.promptResolved = false;
+ this.state = 'prompting';
+ this.targetSpeed = 0;
+ this.speed = 0;
+ this.targetRotation = this.rotationForHeading(this.heading);
+
+ const pos = this.worldPoint(this.currentStep.approachX, this.currentStep.approachY);
+ this.truck.setPosition(pos.x, pos.y);
+ this.truck.rotation = this.targetRotation;
+ this.refreshDebug();
+ }
+
shutdown() {
if (this.fallbackTimer) {
clearInterval(this.fallbackTimer);
@@ -466,6 +514,14 @@ class FireTruckScene extends Phaser.Scene {
}
}
+function colorForCell(cell) {
+ if (!cell) return WATER_COLOR;
+ if (cell.type === FT.CELL_TYPES.WATER) return WATER_COLOR;
+ if (cell.type === FT.CELL_TYPES.BEACH) return BEACH_COLOR;
+ if (cell.type === FT.CELL_TYPES.ROAD) return ASPHALT_COLOR;
+ return BUILDING_PALETTE[Math.abs((cell.x * 13 + cell.y * 17) % BUILDING_PALETTE.length)];
+}
+
// 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.
@@ -474,7 +530,7 @@ const IS_TEST = !!window.__TEST_MODE__;
new Phaser.Game({
type: Phaser.AUTO,
parent: 'game-container',
- backgroundColor: '#d8ead2',
+ backgroundColor: '#6BC4E8',
scale: {
mode: Phaser.Scale.RESIZE,
width: window.innerWidth,
diff --git a/games/fire-truck/lib.js b/games/fire-truck/lib.js
@@ -7,17 +7,55 @@
const OPPOSITE = { north: 'south', east: 'west', south: 'north', west: 'east' };
const LEFT = { north: 'west', west: 'south', south: 'east', east: 'north' };
const RIGHT = { north: 'east', east: 'south', south: 'west', west: 'north' };
- const RNG = {
- next() { return Math.random(); },
- pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; },
+
+ const CARD_TO_HEADING = { N: 'north', E: 'east', S: 'south', W: 'west' };
+ const HEADING_TO_CARD = TO_CARD;
+
+ const ISLAND_WIDTH = 50;
+ const ISLAND_HEIGHT = 50;
+ const MIN_BLOCK = 4;
+ const ROUTE_COUNT = 200;
+ const ROUTE_EXTENSION_COUNT = 36;
+
+ const CELL_TYPES = {
+ WATER: 'water',
+ BEACH: 'beach',
+ ROAD: 'road',
+ BUILDING: 'building',
};
- function keyFor(x, y) {
- return x + ',' + y;
+ const DIRS = {
+ N: { dx: 0, dy: -1, heading: 'north' },
+ E: { dx: 1, dy: 0, heading: 'east' },
+ S: { dx: 0, dy: 1, heading: 'south' },
+ W: { dx: -1, dy: 0, heading: 'west' },
+ };
+
+ function createSeededRng(seed) {
+ let state = (seed == null ? 0x12345678 : seed) >>> 0;
+ return {
+ next() {
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
+ return state / 0x100000000;
+ },
+ pick(arr) {
+ return arr[Math.floor(this.next() * arr.length)];
+ },
+ };
+ }
+
+ function getRng(opts, rng) {
+ if (rng) return rng;
+ return createSeededRng(opts && opts.seed);
}
- function getRng(rng) {
- return rng || RNG;
+ function randInt(rand, min, max) {
+ return min + Math.floor(rand.next() * (max - min + 1));
+ }
+
+ function randPick(rand, arr) {
+ if (rand.pick) return rand.pick(arr);
+ return arr[Math.floor(rand.next() * arr.length)];
}
function cloneExits(exits) {
@@ -70,165 +108,387 @@
function pickPromptMove(legalMoves, rng) {
if (!legalMoves.length) throw new Error('No legal moves');
- return getRng(rng).pick(legalMoves);
+ return (rng && rng.pick ? rng : createSeededRng()).pick(legalMoves);
}
- function ensureCell(grid, x, y) {
- const key = keyFor(x, y);
- if (!grid[key]) grid[key] = { x, y, exits: { N: false, E: false, S: false, W: false } };
- return grid[key];
+ function summarizeKinds(cells) {
+ return cells.reduce((acc, cell) => {
+ const kind = classifyIntersection(cell.exits).kind;
+ acc[kind] = (acc[kind] || 0) + 1;
+ return acc;
+ }, {});
}
- function connectCells(grid, from, to, heading) {
- const a = ensureCell(grid, from.x, from.y);
- const b = ensureCell(grid, to.x, to.y);
- const outCard = TO_CARD[heading];
- const inCard = TO_CARD[OPPOSITE[heading]];
- a.exits[outCard] = true;
- b.exits[inCard] = true;
+ // ------------------------------------------------------------------
+ // Island grid
+ // ------------------------------------------------------------------
+
+ function allocateGrid(width, height) {
+ const grid = [];
+ for (let y = 0; y < height; y++) {
+ const row = [];
+ for (let x = 0; x < width; x++) {
+ row.push({
+ x,
+ y,
+ type: CELL_TYPES.WATER,
+ exits: { N: false, E: false, S: false, W: false },
+ meta: null,
+ });
+ }
+ grid.push(row);
+ }
+ return grid;
}
- function addBranch(grid, pos, heading, length) {
- let current = { x: pos.x, y: pos.y };
- for (let i = 0; i < length; i++) {
- const next = stepPosition(current, heading);
- connectCells(grid, current, next, heading);
- current = next;
+ function layOutRings(grid, width, height) {
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ if (x === 0 || y === 0 || x === width - 1 || y === height - 1) {
+ grid[y][x].type = CELL_TYPES.WATER;
+ } else if (x === 1 || y === 1 || x === width - 2 || y === height - 2) {
+ grid[y][x].type = CELL_TYPES.BEACH;
+ } else if (x === 2 || y === 2 || x === width - 3 || y === height - 3) {
+ grid[y][x].type = CELL_TYPES.ROAD;
+ } else {
+ grid[y][x].type = CELL_TYPES.BUILDING;
+ }
+ }
}
}
- function buildRoute(count, rng) {
- const rand = getRng(rng);
+ function drawRoadH(grid, x1, x2, y) {
+ for (let x = x1; x <= x2; x++) grid[y][x].type = CELL_TYPES.ROAD;
+ }
+
+ function drawRoadV(grid, x, y1, y2) {
+ for (let y = y1; y <= y2; y++) grid[y][x].type = CELL_TYPES.ROAD;
+ }
+
+ function pickSplitCoord(rand, start, size) {
+ return randInt(rand, start + MIN_BLOCK, start + size - MIN_BLOCK - 1);
+ }
+
+ function subdivideInteriorRoads(grid, rand) {
+ function subdivide(x, y, w, h) {
+ const canSplitW = w >= MIN_BLOCK * 2 + 1;
+ const canSplitH = h >= MIN_BLOCK * 2 + 1;
+ if (!canSplitW && !canSplitH) return;
+
+ let splitType;
+ if (canSplitW && canSplitH) {
+ const r = rand.next();
+ splitType = r < 0.40 ? 'quad' : (r < 0.70 ? 'horiz' : 'vert');
+ } else if (canSplitH) {
+ splitType = 'horiz';
+ } else {
+ splitType = 'vert';
+ }
+
+ const right = x + w - 1;
+ const bottom = y + h - 1;
+
+ if (splitType === 'quad') {
+ const cx = pickSplitCoord(rand, x, w);
+ const cy = pickSplitCoord(rand, y, h);
+ drawRoadH(grid, x, right, cy);
+ drawRoadV(grid, cx, y, bottom);
+ subdivide(x, y, cx - x, cy - y);
+ subdivide(cx + 1, y, right - cx, cy - y);
+ subdivide(x, cy + 1, cx - x, bottom - cy);
+ subdivide(cx + 1, cy + 1, right - cx, bottom - cy);
+ return;
+ }
+
+ if (splitType === 'horiz') {
+ const cy = pickSplitCoord(rand, y, h);
+ drawRoadH(grid, x, right, cy);
+ subdivide(x, y, w, cy - y);
+ subdivide(x, cy + 1, w, bottom - cy);
+ return;
+ }
+
+ const cx = pickSplitCoord(rand, x, w);
+ drawRoadV(grid, cx, y, bottom);
+ subdivide(x, y, cx - x, h);
+ subdivide(cx + 1, y, right - cx, h);
+ }
+
+ subdivide(3, 3, 44, 44);
+ }
+
+ function stitchRoadExits(grid, width, height) {
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const cell = grid[y][x];
+ cell.exits = { N: false, E: false, S: false, W: false };
+ cell.meta = null;
+ if (cell.type !== CELL_TYPES.ROAD) continue;
+
+ for (const dir of ['N', 'E', 'S', 'W']) {
+ const nx = x + DIRS[dir].dx;
+ const ny = y + DIRS[dir].dy;
+ cell.exits[dir] = !!grid[ny] && !!grid[ny][nx] && grid[ny][nx].type === CELL_TYPES.ROAD;
+ }
+ cell.meta = classifyIntersection(cell.exits);
+ }
+ }
+ }
+
+ function validateConnectedRoads(grid, width, height) {
+ const roads = [];
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ if (grid[y][x].type === CELL_TYPES.ROAD) roads.push(grid[y][x]);
+ }
+ }
+ if (!roads.length) throw new Error('Island has no roads');
+
+ for (const cell of roads) {
+ const degree = Object.values(cell.exits).filter(Boolean).length;
+ if (degree < 2) throw new Error('Dead-end road at ' + cell.x + ',' + cell.y);
+ }
+
+ const seen = new Set();
+ const queue = [roads[0]];
+ seen.add(roads[0].x + ',' + roads[0].y);
+
+ while (queue.length) {
+ const cell = queue.shift();
+ for (const dir of ['N', 'E', 'S', 'W']) {
+ if (!cell.exits[dir]) continue;
+ const nx = cell.x + DIRS[dir].dx;
+ const ny = cell.y + DIRS[dir].dy;
+ const key = nx + ',' + ny;
+ if (!seen.has(key)) {
+ seen.add(key);
+ queue.push(grid[ny][nx]);
+ }
+ }
+ }
+
+ if (seen.size !== roads.length) {
+ throw new Error('Disconnected road graph: reached ' + seen.size + ' of ' + roads.length);
+ }
+ }
+
+ function flattenGrid(grid) {
+ const out = [];
+ for (let y = 0; y < grid.length; y++) {
+ for (let x = 0; x < grid[y].length; x++) {
+ out.push(grid[y][x]);
+ }
+ }
+ return out;
+ }
+
+ function buildIsland(opts, rng) {
+ const width = (opts && opts.width) ?? ISLAND_WIDTH;
+ const height = (opts && opts.height) ?? ISLAND_HEIGHT;
+ const routeCount = (opts && opts.routeCount) ?? ROUTE_COUNT;
+ const rand = getRng(opts, rng);
+
+ if (width !== 50 || height !== 50) {
+ throw new Error('Only 50x50 islands are supported by this layout');
+ }
+
+ const grid = allocateGrid(width, height);
+ layOutRings(grid, width, height);
+ subdivideInteriorRoads(grid, rand);
+ 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);
+
+ return {
+ width,
+ height,
+ grid,
+ cells,
+ roads: cells.filter(c => c.type === CELL_TYPES.ROAD),
+ 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),
+ route,
+ routeStart,
+ bounds: { minX: -0.5, minY: -0.5, maxX: width - 0.5, maxY: height - 0.5, width, height },
+ };
+ }
+
+ // ------------------------------------------------------------------
+ // Route walking on road graph
+ // ------------------------------------------------------------------
+
+ function isDecisionCell(cell) {
+ if (!cell || cell.type !== CELL_TYPES.ROAD) return false;
+ const meta = cell.meta || classifyIntersection(cell.exits);
+ return meta.degree >= 3 || meta.kind === 'corner';
+ }
+
+ function outboundHeadings(cell, headingIn) {
+ const back = OPPOSITE[headingIn];
+ const all = [];
+ for (const card of ['N', 'E', 'S', 'W']) {
+ if (cell.exits[card]) all.push(CARD_TO_HEADING[card]);
+ }
+ const withoutBack = all.filter(heading => heading !== back);
+ return withoutBack.length ? withoutBack : all;
+ }
+
+ function cellAhead(grid, cell, heading) {
+ const next = stepPosition(cell, heading);
+ return grid[next.y] && grid[next.y][next.x];
+ }
+
+ function findNextDecision(grid, fromCell, heading) {
+ let previous = fromCell;
+ let current = cellAhead(grid, fromCell, heading);
+
+ while (current && current.type === CELL_TYPES.ROAD) {
+ if (isDecisionCell(current)) {
+ return { decision: current, approach: previous };
+ }
+
+ const outCard = HEADING_TO_CARD[heading];
+ if (!current.exits[outCard]) {
+ throw new Error('Straight road ended before a decision at ' + current.x + ',' + current.y);
+ }
+
+ previous = current;
+ current = cellAhead(grid, current, heading);
+ }
+
+ throw new Error('Route left the road graph from ' + fromCell.x + ',' + fromCell.y + ' heading ' + heading);
+ }
+
+ function chooseHeading(rand, visitCounts, decision, options) {
+ let lowest = Infinity;
+ let candidates = [];
+
+ for (const heading of options) {
+ const key = decision.x + ',' + decision.y + '>' + heading;
+ const count = visitCounts[key] || 0;
+ if (count < lowest) {
+ lowest = count;
+ candidates = [heading];
+ } else if (count === lowest) {
+ candidates.push(heading);
+ }
+ }
+
+ return randPick(rand, candidates);
+ }
+
+ function buildRouteOnGraph(grid, opts, rng) {
+ const rand = rng || createSeededRng(opts && opts.seed);
+ const count = (opts && opts.count) ?? ROUTE_COUNT;
const route = [];
- let at = { x: 0, y: 0 };
- let heading = 'east';
- let lastMove = null;
+ const visitCounts = (opts && opts.visitCounts) || Object.create(null);
+
+ let fromCell = (opts && opts.startCell) || { x: 3, y: 2 };
+ let heading = (opts && opts.startHeading) || fromCell.heading || 'east';
+ fromCell = grid[fromCell.y][fromCell.x];
+ if (!fromCell || fromCell.type !== CELL_TYPES.ROAD) {
+ throw new Error('Route start is not a road cell');
+ }
+
for (let i = 0; i < count; i++) {
- // Much more likely to go straight
- let options = ['straight', 'straight', 'straight', 'straight', 'left', 'right'];
- if (lastMove === 'left') options = ['straight', 'straight', 'straight', 'straight', 'right'];
- if (lastMove === 'right') options = ['straight', 'straight', 'straight', 'straight', 'left'];
- const move = rand.pick(options);
- const nextHeading = turnHeading(heading, move);
-
- // Determine distance to the next intersection
- const dist = rand.pick([2, 3, 4, 5]);
-
- let intersection = at;
- for (let d = 0; d < dist; d++) {
- intersection = stepPosition(intersection, heading);
+ const found = findNextDecision(grid, fromCell, heading);
+ const decision = found.decision;
+ const approach = found.approach;
+ const options = outboundHeadings(decision, heading);
+ const headingOut = chooseHeading(rand, visitCounts, decision, options);
+ const exit = cellAhead(grid, decision, headingOut);
+
+ if (!exit || exit.type !== CELL_TYPES.ROAD) {
+ throw new Error('Route chose non-road exit from ' + decision.x + ',' + decision.y);
}
-
- const exitCell = stepPosition(intersection, nextHeading);
+
+ const edgeKey = decision.x + ',' + decision.y + '>' + headingOut;
+ visitCounts[edgeKey] = (visitCounts[edgeKey] || 0) + 1;
+
route.push({
- index: i,
- x: intersection.x,
- y: intersection.y,
+ index: ((opts && opts.startIndex) ?? 0) + i,
+ x: decision.x,
+ y: decision.y,
+ approachX: approach.x,
+ approachY: approach.y,
headingIn: heading,
- headingOut: nextHeading,
- move,
- exitX: exitCell.x,
- exitY: exitCell.y,
+ headingOut,
+ move: getRelativeMove(heading, headingOut),
+ exitX: exit.x,
+ exitY: exit.y,
});
- at = exitCell;
- heading = nextHeading;
- lastMove = move;
+
+ fromCell = exit;
+ heading = headingOut;
}
+
return route;
}
- function buildCity(route, rng) {
- const rand = getRng(rng);
- const grid = {};
-
- const allXs = [];
- const allYs = [];
- route.forEach(s => { allXs.push(s.x, s.exitX); allYs.push(s.y, s.exitY); });
- allXs.push(-1, 0);
- allYs.push(0, 0);
- const minX = Math.min(...allXs) - 6;
- const maxX = Math.max(...allXs) + 6;
- const minY = Math.min(...allYs) - 6;
- const maxY = Math.max(...allYs) + 6;
-
- // We want only T intersections or 4-ways, no dead ends.
- // The easiest way is to ensure all roads span entirely across the city bounds.
-
- // First, collect all horizontal (y) and vertical (x) lines we need.
- const hLines = new Set();
- const vLines = new Set();
-
- // Add route paths
- route.forEach(step => {
- // The road we came in on
- if (step.headingIn === 'east' || step.headingIn === 'west') hLines.add(step.y);
- if (step.headingIn === 'north' || step.headingIn === 'south') vLines.add(step.x);
-
- // The road we exit on
- if (step.headingOut === 'east' || step.headingOut === 'west') hLines.add(step.exitY);
- if (step.headingOut === 'north' || step.headingOut === 'south') vLines.add(step.exitX);
-
- // Always create a cross-street at the intersection so the player sees a crossing
- hLines.add(step.y);
- vLines.add(step.x);
- });
- hLines.add(0); // Start line
- vLines.add(0); // Start line
-
- // Add some random background lines to fill the grid, reducing frequency for longer straight aways
- for (let y = minY + 1; y < maxY; y++) {
- if (rand.next() < 0.15) hLines.add(y);
- }
- for (let x = minX + 1; x < maxX; x++) {
- if (rand.next() < 0.15) vLines.add(x);
- }
-
- // Always add boundary lines to ensure no dead ends (they form T-intersections or corners at the edges)
- hLines.add(minY);
- hLines.add(maxY);
- vLines.add(minX);
- vLines.add(maxX);
-
- // Connect all cells along the lines
- hLines.forEach(y => {
- for (let x = minX; x < maxX; x++) {
- connectCells(grid, { x, y }, { x: x + 1, y }, 'east');
- }
- });
- vLines.forEach(x => {
- for (let y = minY; y < maxY; y++) {
- connectCells(grid, { x, y }, { x, y: y + 1 }, 'south');
- }
- });
-
- const cells = Object.values(grid).map(cell => ({
- x: cell.x,
- y: cell.y,
- exits: cloneExits(cell.exits),
- meta: classifyIntersection(cell.exits),
- }));
- return { grid, cells };
+ function visitCountsFromRoute(route) {
+ const visitCounts = Object.create(null);
+ for (const step of route) {
+ const key = step.x + ',' + step.y + '>' + step.headingOut;
+ visitCounts[key] = (visitCounts[key] || 0) + 1;
+ }
+ return visitCounts;
}
- function summarizeKinds(cells) {
- return cells.reduce((acc, cell) => {
- const kind = classifyIntersection(cell.exits).kind;
- acc[kind] = (acc[kind] || 0) + 1;
- return acc;
- }, {});
+ function extendRouteOnGraph(islandOrGrid, existingRoute, additional, rng) {
+ const grid = Array.isArray(islandOrGrid) ? islandOrGrid : islandOrGrid.grid;
+ if (!existingRoute.length) {
+ return buildRouteOnGraph(grid, { count: additional }, rng);
+ }
+
+ const last = existingRoute[existingRoute.length - 1];
+ return buildRouteOnGraph(grid, {
+ count: additional,
+ startCell: { x: last.exitX, y: last.exitY },
+ startHeading: last.headingOut,
+ startIndex: existingRoute.length,
+ visitCounts: visitCountsFromRoute(existingRoute),
+ }, rng);
}
const api = {
HEADINGS,
+ CELL_TYPES,
turnHeading,
getRelativeMove,
stepPosition,
classifyIntersection,
getLegalMoves,
pickPromptMove,
- buildRoute,
- buildCity,
summarizeKinds,
+ createSeededRng,
+ getRng,
+ randInt,
+ randPick,
+ buildIsland,
+ buildRouteOnGraph,
+ extendRouteOnGraph,
+ visitCountsFromRoute,
+ allocateGrid,
+ layOutRings,
+ flattenGrid,
+ stitchRoadExits,
+ validateConnectedRoads,
+ isDecisionCell,
+ outboundHeadings,
+ findNextDecision,
+ cellAhead,
+ chooseHeading,
+ drawRoadH,
+ drawRoadV,
+ subdivideInteriorRoads,
+ OPPOSITE,
+ CARD_TO_HEADING,
+ HEADING_TO_CARD,
+ DIRS,
};
if (typeof module !== 'undefined' && module.exports) module.exports = api;
diff --git a/package.json b/package.json
@@ -4,7 +4,8 @@
"scripts": {
"test": "npm run test:unit && npm run test:browser",
"test:unit": "node --test tests/unit/",
- "test:browser": "node tests/browser/runner.js"
+ "test:browser": "node tests/browser/runner.js",
+ "screenshot": "node tests/browser/screenshot.js"
},
"devDependencies": {
"playwright": "^1.40.0"
diff --git a/tests/browser/runner.js b/tests/browser/runner.js
@@ -267,6 +267,30 @@ async function main() {
if (state.state === 'stopped') throw new Error('Truck did not resume');
});
+ await test('fire-truck: island debug hooks and route bounds', 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 {
+ width: s.island.width,
+ height: s.island.height,
+ cell00: s.cellAt(0, 0).type,
+ 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'),
+ };
+ });
+ if (info.width !== 50) throw new Error(`Expected width 50, got ${info.width}`);
+ if (info.height !== 50) throw new Error(`Expected height 50, got ${info.height}`);
+ if (info.cell00 !== 'water') throw new Error(`Expected cell00 water, got ${info.cell00}`);
+ 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');
+ });
+
// ── Summary ───────────────────────────────────────────────────────────────
await browser.close();
diff --git a/tests/browser/screenshot.js b/tests/browser/screenshot.js
@@ -0,0 +1,66 @@
+const fs = require('fs');
+const path = require('path');
+const { chromium } = require('playwright');
+const serve = require('../serve.js');
+
+async function save(page, filePath) {
+ await page.screenshot({ path: filePath, fullPage: false });
+ console.log(filePath);
+}
+
+async function main() {
+ const outDir = path.join(__dirname, '..', 'screenshots');
+ fs.mkdirSync(outDir, { recursive: true });
+
+ const { server, url } = await serve(8767);
+ const browser = await chromium.launch();
+
+ try {
+ const fireTruckPage = async () => {
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
+ await page.addInitScript(() => {
+ window.__TEST_MODE__ = true;
+ window.GAME_SPEED_MULTIPLIER = 0;
+ });
+ await page.goto(url + '/games/fire-truck/', { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.__FT_SCENE__ && window.__FT_SCENE__.island);
+ return page;
+ };
+
+ const overview = await fireTruckPage();
+ await overview.evaluate(() => {
+ const s = window.__FT_SCENE__;
+ s.cameras.main.stopFollow();
+ s.cameras.main.setZoom(0.05);
+ s.cameras.main.centerOn((s.island.width - 1) / 2 * s.cellSize, (s.island.height - 1) / 2 * s.cellSize);
+ });
+ await save(overview, path.join(outDir, 'fire-truck-overview.png'));
+ await overview.close();
+
+ const gameplay = await fireTruckPage();
+ await save(gameplay, path.join(outDir, 'fire-truck-gameplay.png'));
+ await gameplay.close();
+
+ const intersection = await fireTruckPage();
+ await intersection.evaluate(() => {
+ const s = window.__FT_SCENE__;
+ const index = s.route.findIndex(step => s.cellAt(step.x, step.y).meta.kind === 'four');
+ if (index < 0) throw new Error('No 4-way route step found');
+ s.snapTruckToRouteIndex(index);
+ s.cameras.main.stopFollow();
+ s.cameras.main.centerOn(s.truck.x, s.truck.y);
+ });
+ await save(intersection, path.join(outDir, 'fire-truck-intersection.png'));
+ await intersection.close();
+
+ const portal = await browser.newPage({ viewport: { width: 1280, height: 800 } });
+ await portal.goto(url + '/', { waitUntil: 'domcontentloaded' });
+ await save(portal, path.join(outDir, 'portal.png'));
+ await portal.close();
+ } finally {
+ await browser.close();
+ server.close();
+ }
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/tests/unit/projection.test.js b/tests/unit/projection.test.js
@@ -1,121 +1,120 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
-const { buildRoute, buildCity, summarizeKinds, pickPromptMove } = require('../../games/fire-truck/lib.js');
+const {
+ buildIsland,
+ pickPromptMove,
+} = require('../../games/fire-truck/lib.js');
const fixedRng = { pick: arr => arr[0], next: () => 0.2 };
-function largestConnectedComponent(cells) {
- const grid = new Map();
- cells.forEach(c => grid.set(c.x + ',' + c.y, c));
- const visited = new Set();
- let largest = 0;
+function bfsReachableRoads(grid, roads) {
+ const seen = new Set();
+ const queue = [roads[0]];
+ seen.add(roads[0].x + ',' + roads[0].y);
+ const D = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] };
- function bfs(startKey) {
- const q = [startKey];
- visited.add(startKey);
- let count = 0;
- const D = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] };
- while (q.length) {
- const key = q.shift();
- count++;
- const cell = grid.get(key);
- if (!cell) continue;
- for (const dir of ['N', 'E', 'S', 'W']) {
- if (!cell.exits[dir]) continue;
- const [dx, dy] = D[dir];
- const nx = cell.x + dx;
- const ny = cell.y + dy;
- const nkey = nx + ',' + ny;
- if (grid.has(nkey) && !visited.has(nkey)) {
- visited.add(nkey);
- q.push(nkey);
- }
+ while (queue.length) {
+ const cell = queue.shift();
+ for (const dir of ['N', 'E', 'S', 'W']) {
+ if (!cell.exits[dir]) continue;
+ const [dx, dy] = D[dir];
+ const nx = cell.x + dx;
+ const ny = cell.y + dy;
+ const key = nx + ',' + ny;
+ if (!seen.has(key)) {
+ seen.add(key);
+ queue.push(grid[ny][nx]);
}
}
- return count;
- }
-
- for (const key of grid.keys()) {
- if (!visited.has(key)) {
- const size = bfs(key);
- if (size > largest) largest = size;
- }
}
- return largest;
+ return seen.size;
}
-function averageDegree(cells) {
- if (!cells.length) return 0;
- const total = cells.reduce((sum, c) => {
- return sum + Object.values(c.exits).filter(Boolean).length;
- }, 0);
- return total / cells.length;
-}
+describe('buildIsland()', () => {
+ it('has fixed rings', () => {
+ const island = buildIsland({ seed: 1 });
+ for (let x = 0; x < 50; x++) {
+ assert.equal(island.grid[0][x].type, 'water');
+ assert.equal(island.grid[49][x].type, 'water');
+ }
+ for (let y = 0; y < 50; y++) {
+ assert.equal(island.grid[y][0].type, 'water');
+ assert.equal(island.grid[y][49].type, 'water');
+ }
+ for (let x = 1; x < 49; x++) {
+ assert.equal(island.grid[1][x].type, 'beach');
+ assert.equal(island.grid[48][x].type, 'beach');
+ }
+ for (let y = 1; y < 49; y++) {
+ assert.equal(island.grid[y][1].type, 'beach');
+ assert.equal(island.grid[y][48].type, 'beach');
+ }
+ for (let x = 2; x < 48; x++) {
+ assert.equal(island.grid[2][x].type, 'road');
+ assert.equal(island.grid[47][x].type, 'road');
+ }
+ for (let y = 2; y < 48; y++) {
+ assert.equal(island.grid[y][2].type, 'road');
+ assert.equal(island.grid[y][47].type, 'road');
+ }
+ });
-function countAlternateConnections(cells, route) {
- const grid = new Map();
- cells.forEach(c => grid.set(c.x + ',' + c.y, c));
- let count = 0;
- const D = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] };
- for (const step of route) {
- for (const dir of ['N', 'E', 'S', 'W']) {
- const [dx, dy] = D[dir];
- const nx = step.x + dx;
- const ny = step.y + dy;
- const neighbor = grid.get(nx + ',' + ny);
- if (neighbor && Object.values(neighbor.exits).filter(Boolean).length >= 2) {
- count++;
+ it('has only building or road in interior', () => {
+ const island = buildIsland({ seed: 2 });
+ for (let y = 3; y <= 46; y++) {
+ for (let x = 3; x <= 46; x++) {
+ const t = island.grid[y][x].type;
+ assert.ok(t === 'building' || t === 'road', `expected building or road at ${x},${y}, got ${t}`);
}
}
- }
- return count;
-}
-
-describe('buildCity()', () => {
- it('creates road cells from a generated route', () => {
- const route = buildRoute(12, fixedRng);
- const city = buildCity(route, fixedRng);
- assert(city.cells.length > route.length);
});
- it('contains both t intersections and four-ways in a sample city', () => {
- const route = buildRoute(20, fixedRng);
- const city = buildCity(route, fixedRng);
- const kinds = summarizeKinds(city.cells);
- assert((kinds.t || 0) > 0, 'expected at least one t intersection');
- assert((kinds.four || 0) > 0, 'expected at least one four-way');
+ it('has a fully connected road graph', () => {
+ const island = buildIsland({ seed: 3 });
+ const reachable = bfsReachableRoads(island.grid, island.roads);
+ assert.equal(reachable, island.roads.length);
});
- it('has a large connected road component', () => {
- const route = buildRoute(24, fixedRng);
- const city = buildCity(route, fixedRng);
- const largest = largestConnectedComponent(city.cells);
- assert(largest > city.cells.length * 0.7, `expected largest component > 70% of cells, got ${largest}/${city.cells.length}`);
+ it('has no dead-end roads', () => {
+ const island = buildIsland({ seed: 4 });
+ for (const cell of island.roads) {
+ const degree = Object.values(cell.exits).filter(Boolean).length;
+ assert.ok(degree >= 2, `dead-end at ${cell.x},${cell.y} with degree ${degree}`);
+ }
});
- it('has realistic intersection proportions', () => {
- const route = buildRoute(30, fixedRng);
- const city = buildCity(route, fixedRng);
- const kinds = summarizeKinds(city.cells);
- const total = city.cells.length;
- const fourRatio = (kinds.four || 0) / total;
- const tRatio = (kinds.t || 0) / total;
- assert(fourRatio >= 0.04, `expected four-way ratio >= 0.04, got ${fourRatio.toFixed(3)}`);
- assert(tRatio >= 0.04, `expected t ratio >= 0.04, got ${tRatio.toFixed(3)}`);
+ it('has varied intersections', () => {
+ const island = buildIsland({ seed: 5 });
+ let tCount = 0;
+ let fourCount = 0;
+ for (const cell of island.roads) {
+ if (cell.meta.kind === 't') tCount++;
+ if (cell.meta.kind === 'four') fourCount++;
+ }
+ const decisionCount = tCount + fourCount;
+ assert.ok(decisionCount > 0, 'expected some decision cells');
+ const tRatio = tCount / decisionCount;
+ const fourRatio = fourCount / decisionCount;
+ assert.ok(tRatio >= 0.55, `expected t ratio >= 0.55, got ${tRatio.toFixed(3)}`);
+ assert.ok(fourRatio >= 0.06, `expected four-way ratio >= 0.06, got ${fourRatio.toFixed(3)}`);
});
- it('has above-minimum average road-cell degree', () => {
- const route = buildRoute(24, fixedRng);
- const city = buildCity(route, fixedRng);
- const avg = averageDegree(city.cells);
- assert(avg >= 1.5, `expected avg degree >= 1.5, got ${avg.toFixed(2)}`);
+ it('has average road degree at least 2.0', () => {
+ const island = buildIsland({ seed: 6 });
+ const total = island.roads.reduce((sum, c) => sum + Object.values(c.exits).filter(Boolean).length, 0);
+ const avg = total / island.roads.length;
+ assert.ok(avg >= 2.0, `expected avg degree >= 2.0, got ${avg.toFixed(2)}`);
});
- it('has multiple alternate neighboring connections around the route', () => {
- const route = buildRoute(20, fixedRng);
- const city = buildCity(route, fixedRng);
- const count = countAlternateConnections(city.cells, route);
- assert(count >= route.length, `expected >= ${route.length} alternate connections, got ${count}`);
+ it('has correct building count', () => {
+ const island = buildIsland({ seed: 8 });
+ let interiorRoadCount = 0;
+ for (let y = 3; y <= 46; y++) {
+ for (let x = 3; x <= 46; x++) {
+ if (island.grid[y][x].type === 'road') interiorRoadCount++;
+ }
+ }
+ assert.equal(island.buildings.length + interiorRoadCount, 44 * 44);
});
});
diff --git a/tests/unit/route.test.js b/tests/unit/route.test.js
@@ -5,7 +5,10 @@ const {
getRelativeMove,
classifyIntersection,
getLegalMoves,
- buildRoute,
+ buildIsland,
+ extendRouteOnGraph,
+ OPPOSITE,
+ HEADING_TO_CARD,
} = require('../../games/fire-truck/lib.js');
const fixedRng = { pick: arr => arr[0], next: () => 0.25 };
@@ -46,18 +49,59 @@ describe('getLegalMoves()', () => {
});
});
-describe('buildRoute()', () => {
- it('returns connected route entries with valid moves', () => {
- const route = buildRoute(6, fixedRng);
- assert.equal(route.length, 6);
- route.forEach((step, index) => {
- assert.ok(['left', 'right', 'straight'].includes(step.move));
+describe('buildIsland()', () => {
+ it('is deterministic with seed', () => {
+ const opts = { seed: 123, routeCount: 30 };
+ const a = buildIsland(opts);
+ const b = buildIsland(opts);
+ assert.deepEqual(a.roads.map(c => ({ x: c.x, y: c.y, exits: c.exits })),
+ b.roads.map(c => ({ x: c.x, y: c.y, exits: c.exits })));
+ assert.deepEqual(a.route.map(s => ({ x: s.x, y: s.y, headingIn: s.headingIn, headingOut: s.headingOut, move: s.move })),
+ b.route.map(s => ({ x: s.x, y: s.y, headingIn: s.headingIn, headingOut: s.headingOut, move: s.move })));
+ });
+
+ it('route steps stay on graph', () => {
+ const island = buildIsland({ seed: 42, routeCount: 50 });
+ for (const step of island.route) {
+ const decision = island.grid[step.y][step.x];
+ const approach = island.grid[step.approachY][step.approachX];
+ const exit = island.grid[step.exitY][step.exitX];
+
+ assert.equal(decision.type, 'road');
+ assert.equal(approach.type, 'road');
+ assert.equal(exit.type, 'road');
+
+ const backCard = HEADING_TO_CARD[OPPOSITE[step.headingIn]];
+ assert.equal(decision.exits[backCard], true, 'decision must have exit back to approach');
+ assert.equal(decision.exits[HEADING_TO_CARD[step.headingOut]], true, 'decision must have exit toward headingOut');
+
assert.equal(getRelativeMove(step.headingIn, step.headingOut), step.move);
- if (index > 0) {
- const prev = route[index - 1];
- const gap = Math.abs(step.x - prev.exitX) + Math.abs(step.y - prev.exitY);
- assert.ok(gap >= 2);
- }
- });
+ }
+ });
+
+ it('route starts correctly', () => {
+ const island = buildIsland({ seed: 7, routeCount: 10 });
+ assert.deepEqual(island.routeStart, { x: 3, y: 2, heading: 'east' });
+ assert.equal(island.route[0].headingIn, 'east');
+
+ // Walk from routeStart toward first decision to ensure it's all road
+ let x = island.routeStart.x;
+ let y = island.routeStart.y;
+ const target = island.route[0];
+ while (x !== target.x || y !== target.y) {
+ assert.equal(island.grid[y][x].type, 'road');
+ x += 1; // heading is east
+ }
+ });
+
+ it('extension continues smoothly', () => {
+ const island = buildIsland({ seed: 99, routeCount: 20 });
+ const ext = extendRouteOnGraph(island, island.route, 20);
+ assert.equal(ext.length, 20);
+ assert.equal(ext[0].index, island.route.length);
+
+ const last = island.route[island.route.length - 1];
+ const firstExt = ext[0];
+ assert.equal(firstExt.headingIn, last.headingOut);
});
});