kgames

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

commit 80d9f5deeb6338744aa648b5a3e363a9a316b0d3
parent 9ea773b1f20332f6913365c151f29e2e4c56eaf7
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Sun, 26 Apr 2026 19:45:28 -0700

Island map

Diffstat:
M.gitignore | 1+
Mgames/fire-truck/game.js | 248++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mgames/fire-truck/lib.js | 528+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mpackage.json | 3++-
Mtests/browser/runner.js | 24++++++++++++++++++++++++
Atests/browser/screenshot.js | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/unit/projection.test.js | 187+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mtests/unit/route.test.js | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
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); }); });