kgames

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

commit a55084f903e7db4ef4d3dc5d308d234625aec038
parent 5ea25da83ea4ef07a1f1b15728cda67d3c6860de
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Tue, 14 Jul 2026 08:51:19 -0700

Remove stray markdown from repo; gitignore stray .md files

Deletes see-screenshot-of-current-sorted-flute.md (accidentally
committed and publicly served from the web root). Adds a .gitignore
rule to ignore all *.md by default while whitelisting the intentional
docs (README, CLAUDE, AGENTS, fire_truck_plan) so stray notes can't be
committed again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Diffstat:
M.gitignore | 7+++++++
Dsee-screenshot-of-current-sorted-flute.md | 952-------------------------------------------------------------------------------
2 files changed, 7 insertions(+), 952 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -10,3 +10,10 @@ node_modules/ .claude/ *.log .opencode/ + +# Ignore stray markdown; whitelist the intentional docs +*.md +!README.md +!CLAUDE.md +!AGENTS.md +!fire_truck_plan.md diff --git a/see-screenshot-of-current-sorted-flute.md b/see-screenshot-of-current-sorted-flute.md @@ -1,952 +0,0 @@ -# Fire-Truck Map & Road Generation Overhaul - -## Context - -The current fire-truck map (see `Screenshot 2026-04-26 at 8.47.40 AM.png`) has three visible problems. - -1. Buildings are drawn at `-0.38 * cell` with size `0.76 * cell` in `games/fire-truck/game.js`, which leaves a gap between adjacent buildings. The gap reads as phantom alleys inside every block. -2. `buildCity()` in `games/fire-truck/lib.js` creates whole horizontal and vertical road lines across the bounded rectangle. Every selected `x` crosses every selected `y`, so the result is closer to a uniform grid than a city sketch with varied T-intersections, 4-ways, and corners. -3. `extendRouteIfNeeded()` extends the route but not the rendered city. The truck can eventually drive outside the rendered map. - -The user wants a finite pre-generated `50 x 50` island with water, a beach border, an outer ring road, a varied inner road network, no phantom alleys, and no double/collapsed intersections. - -## Target Result - -Replace the route-first city generator with one deterministic island generator. - -`buildIsland(opts, rng)` must return the whole playable map and the route that drives on it: - -```javascript -{ - width: 50, - height: 50, - grid, // 2D array, grid[y][x] - cells, // flat array of all 2500 cells - roads, // flat array of road cells - buildings, // flat array of building cells - beach, // flat array of beach cells - water, // flat array of water cells - route, // route steps for the truck - routeStart, // first truck position and heading - bounds // cell-coordinate bounds for camera/rendering -} -``` - -Each cell must use this exact shape: - -```javascript -{ - x, - y, - type, // 'water' | 'beach' | 'road' | 'building' - exits, // { N: false, E: false, S: false, W: false } - meta // classifyIntersection(exits) for roads, null otherwise -} -``` - -Each route step must keep the existing fields that `game.js` already uses and add one explicit approach cell for test/screenshot helpers: - -```javascript -{ - index, - x, - y, // decision cell, where the prompt applies - approachX, - approachY, // road cell immediately before the decision cell - headingIn, - headingOut, - move, // getRelativeMove(headingIn, headingOut) - exitX, - exitY // road cell immediately after the decision cell -} -``` - -`routeStart` must be the road cell where the truck starts before driving toward `route[0]`: - -```javascript -{ x: 3, y: 2, heading: 'east' } -``` - -`bounds` must describe the full island in cell coordinates. Because each cell is centered on integer coordinates, the island spans from `-0.5` to `49.5` on both axes: - -```javascript -{ minX: -0.5, minY: -0.5, maxX: 49.5, maxY: 49.5, width: 50, height: 50 } -``` - -## Files To Change - -| Path | Required change | -|---|---| -| `games/fire-truck/lib.js` | Add `CELL_TYPES`, seeded RNG helpers, `buildIsland`, `buildRouteOnGraph`, and `extendRouteOnGraph`. Keep `HEADINGS`, `turnHeading`, `getRelativeMove`, `stepPosition`, `classifyIntersection`, `getLegalMoves`, `pickPromptMove`, and `summarizeKinds` exported. Remove `buildRoute` and `buildCity` unless temporary local compatibility is needed during the edit. | -| `games/fire-truck/game.js` | Replace `buildRoute(64)` plus `buildCity(route)` with one `buildIsland(...)` call. Rename `renderCity()` to `renderIsland()`. Render full cell footprints by type. Add camera bounds. Change speed multiplier reads from `window.GAME_SPEED_MULTIPLIER || 1.0` to `window.GAME_SPEED_MULTIPLIER ?? 1.0` so tests can freeze movement with `0`. Add `cellAt(x, y)` and `snapTruckToRouteIndex(index)` test hooks. | -| `tests/unit/route.test.js` | Keep helper tests for heading/move functions. Replace `buildRoute()` coverage with `buildIsland()` and `extendRouteOnGraph()` route continuity tests. | -| `tests/unit/projection.test.js` | Replace `buildCity()` coverage with island-grid tests: rings are correct, roads are connected, no road dead-ends, intersection mix exists, and route steps stay on road exits. | -| `tests/browser/runner.js` | Add fire-truck tests for island debug hooks, fixed cell types at the border, route bounds, and existing movement/failure behavior. | -| `tests/browser/screenshot.js` | New Playwright script that saves overview, gameplay, intersection, and portal screenshots. | -| `package.json` | Add `"screenshot": "node tests/browser/screenshot.js"`. | - -## Constants - -Use these defaults unless the implementation needs a small visual adjustment. - -```javascript -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', -}; - -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]; -``` - -## RNG Contract - -The current code passes RNG objects with `next()` and `pick(arr)`. Do not require `rng.frac()` or `rng.integerInRange()`. - -Add these helpers in `lib.js`: - -```javascript -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 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)]; -} -``` - -`buildIsland({ seed: 123 })` must be deterministic when no external RNG is passed. - -## Island Grid Algorithm - -Implement `buildIsland(opts = {}, rng)` as the top-level generator. - -```javascript -function buildIsland(opts = {}, rng) { - const width = opts.width ?? ISLAND_WIDTH; - const height = opts.height ?? ISLAND_HEIGHT; - const routeCount = 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 }, - }; -} -``` - -`allocateGrid(width, height)` must return `grid[y][x]`, not `grid[x][y]`. - -```javascript -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; -} -``` - -`layOutRings(grid, width, height)` must set fixed rings exactly like this: - -```javascript -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; - } - } - } -} -``` - -`flattenGrid(grid)` must preserve row-major order: - -```javascript -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; -} -``` - -This creates water on the outside edge, beach at `x/y = 1` and `48`, and the outer ring road at `x/y = 2` and `47`. Interior building cells initially occupy `x = 3..46` and `y = 3..46`. - -## Mixed Subdivision Road Algorithm - -The inner road network is generated by recursively splitting building rectangles. A split line becomes road. Child rectangles exclude that new road line. - -Coordinate meanings: - -| Name | Meaning | -|---|---| -| `x`, `y` | Top-left cell of a rectangle that currently contains only buildings or roads created by deeper splits. | -| `w`, `h` | Rectangle size in cells. | -| `right` | `x + w - 1`. | -| `bottom` | `y + h - 1`. | -| `MIN_BLOCK` | Minimum building-cell thickness on each side of a split line. | - -Start with the interior building rectangle: - -```javascript -subdivide(3, 3, 44, 44); -``` - -Use this exact split eligibility rule: - -```javascript -const canSplitW = w >= MIN_BLOCK * 2 + 1; -const canSplitH = h >= MIN_BLOCK * 2 + 1; -``` - -The `+ 1` is the road cell used by the split. For `MIN_BLOCK = 4`, the smallest splittable rectangle is `9` cells wide or tall: `4 building cells + 1 road cell + 4 building cells`. - -Use this exact coordinate picker: - -```javascript -function pickSplitCoord(rand, start, size) { - return randInt(rand, start + MIN_BLOCK, start + size - MIN_BLOCK - 1); -} -``` - -Wrap the recursive splitter in `subdivideInteriorRoads(grid, rand)` so `rand` and `grid` are in scope: - -```javascript -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); -} -``` - -Road drawing helpers are simple cell-type changes: - -```javascript -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; -} -``` - -The endpoints of every split line are adjacent to an existing parent boundary road or the outer ring road. After exits are stitched, those adjacent endpoints become connected T-intersections or corners. No orphan rescue pass should be necessary. - -## Exit Stitching And Validation - -After all roads are marked, compute exits from neighbors. Do not try to maintain exits while subdividing. - -```javascript -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 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); - } - } -} -``` - -`validateConnectedRoads(grid, width, height)` must do a BFS over road cells and throw if any road is unreachable or if any road has degree lower than `2`. - -```javascript -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); - } -} -``` - -## Route Walking Algorithm - -Route generation must walk the finished road graph. It must not create roads. - -A decision cell is any road cell with `degree >= 3` or a degree-2 corner. A degree-2 straight cell is not a decision cell. - -```javascript -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'; -} -``` - -Outbound choices at a decision are all road exits except the U-turn exit. There are no dead-ends, so a U-turn should only be allowed if it is the only possible exit after filtering. - -```javascript -const CARD_TO_HEADING = { N: 'north', E: 'east', S: 'south', W: 'west' }; -const HEADING_TO_CARD = { north: 'N', east: 'E', south: 'S', west: 'W' }; -const OPPOSITE = { north: 'south', east: 'west', south: 'north', west: 'east' }; - -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; -} -``` - -To find the next decision, start from the current road cell and walk one cell at a time in the current heading until a decision cell is reached. - -```javascript -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 cellAhead(grid, cell, heading) { - const next = stepPosition(cell, heading); - return grid[next.y] && grid[next.y][next.x]; -} -``` - -Choose among outbound headings by lowest directed-edge visit count. A directed edge key is the decision cell plus the chosen heading. - -```javascript -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); -} -``` - -`buildRouteOnGraph(grid, opts, rng)` must return only route steps. It must not mutate the grid except for reading `cell.meta`. - -```javascript -function buildRouteOnGraph(grid, opts = {}, rng) { - const rand = rng || createSeededRng(opts.seed); - const count = opts.count ?? ROUTE_COUNT; - const route = []; - const visitCounts = opts.visitCounts || Object.create(null); - - let fromCell = opts.startCell || { x: 3, y: 2 }; - let heading = 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++) { - 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 edgeKey = decision.x + ',' + decision.y + '>' + headingOut; - visitCounts[edgeKey] = (visitCounts[edgeKey] || 0) + 1; - - route.push({ - index: (opts.startIndex ?? 0) + i, - x: decision.x, - y: decision.y, - approachX: approach.x, - approachY: approach.y, - headingIn: heading, - headingOut, - move: getRelativeMove(heading, headingOut), - exitX: exit.x, - exitY: exit.y, - }); - - fromCell = exit; - heading = headingOut; - } - - return route; -} -``` - -`extendRouteOnGraph(islandOrGrid, existingRoute, additional, rng)` must return new steps only. It must not create new map cells. - -```javascript -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 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); -} -``` - -## Game Integration - -In `FireTruckScene.create()`, replace route/city setup with this flow. Use a fixed seed so browser tests and screenshots are stable. - -```javascript -this.island = FT.buildIsland({ width: 50, height: 50, routeCount: 200, seed: 20260426 }); -this.route = this.island.route; -this.renderIsland(); -this.createTruck(); - -this.currentStep = this.route[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); -this.truck.setPosition(start.x, start.y); -this.truck.rotation = this.targetRotation; -``` - -Store `this.cellSize = CELL_SIZE` for tests. - -Camera setup must use water as the background and clamp to the island footprint: - -```javascript -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 -); -``` - -Route extension must reuse the finite graph: - -```javascript -const ROUTE_EXTENSION_COUNT = 36; - -extendRouteIfNeeded() { - if (this.routeIndex < this.route.length - 12) return; - const extension = FT.extendRouteOnGraph(this.island, this.route, ROUTE_EXTENSION_COUNT); - this.route.push(...extension); -} -``` - -Replace every speed multiplier fallback with nullish coalescing: - -```javascript -const m = window.GAME_SPEED_MULTIPLIER ?? 1.0; -``` - -This is required because screenshot tests set `GAME_SPEED_MULTIPLIER = 0` to freeze the scene. - -## Test And Screenshot Hooks - -Add these methods to `FireTruckScene`. - -```javascript -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(); -} -``` - -Keep `window.__FT_SCENE__ = this` in `create()`. - -## Island Renderer - -Rename `renderCity()` to `renderIsland()` and render all `50 x 50` cells. Keep chunked texture baking so the final texture size stays under GPU limits. - -Chunking rules: - -```javascript -const BAKE_CELL = CELL_SIZE / SCALE; -const CHUNK_CELLS = 16; -const chunkCountX = Math.ceil(this.island.width / CHUNK_CELLS); -const chunkCountY = Math.ceil(this.island.height / CHUNK_CELLS); -``` - -Each cell is centered at integer coordinates. A cell footprint top-left is `(x - 0.5, y - 0.5)` in cell coordinates. - -When creating a chunk image for cells `cellStartX..cellEndX - 1` and `cellStartY..cellEndY - 1`, place the image at: - -```javascript -const imageWorldX = (cellStartX - 0.5) * CELL_SIZE; -const imageWorldY = (cellStartY - 0.5) * CELL_SIZE; -``` - -Within the chunk texture, cell `(x, y)` starts at: - -```javascript -const localX = (x - cellStartX) * BAKE_CELL; -const localY = (y - cellStartY) * BAKE_CELL; -``` - -Draw order for each chunk: - -1. Draw every non-road cell in the chunk over its full `BAKE_CELL x BAKE_CELL` footprint. -2. Draw every road cell in the chunk as a full asphalt rectangle over its full footprint. -3. Apply road-corner masks for road cells. -4. Draw lane stripes on straight road cells only. - -Base color helper: - -```javascript -function colorForCell(cell) { - if (!cell) return WATER_COLOR; - if (cell.type === CELL_TYPES.WATER) return WATER_COLOR; - if (cell.type === CELL_TYPES.BEACH) return BEACH_COLOR; - if (cell.type === CELL_TYPES.ROAD) return ASPHALT_COLOR; - return BUILDING_PALETTE[Math.abs((cell.x * 13 + cell.y * 17) % BUILDING_PALETTE.length)]; -} -``` - -Building drawing must fill the whole cell. Do not use the old `BAKE_CELL * 0.38` offset or `BAKE_CELL * 0.76` size. - -```javascript -gfx.fillStyle(colorForCell(cell), 1); -gfx.fillRect(localX, localY, BAKE_CELL, BAKE_CELL); -if (cell.type === CELL_TYPES.BUILDING) { - gfx.lineStyle(2, 0x000000, 0.08); - gfx.strokeRect(localX + 1, localY + 1, BAKE_CELL - 2, BAKE_CELL - 2); -} -``` - -Road drawing starts as a full rectangle: - -```javascript -gfx.fillStyle(ASPHALT_COLOR, 1); -gfx.fillRect(localX, localY, BAKE_CELL, BAKE_CELL); -``` - -Corner masking rule: if both adjacent exits for a corner are missing, cover that corner with the diagonal neighbor's base color. Use a square mask of `Math.round(BAKE_CELL * 0.22)` pixels. This keeps T-intersections and corners from looking like full square plazas. - -| Corner | Mask condition | Diagonal color cell | -|---|---|---| -| NW | `!cell.exits.N && !cell.exits.W` | `grid[y - 1][x - 1]` | -| NE | `!cell.exits.N && !cell.exits.E` | `grid[y - 1][x + 1]` | -| SE | `!cell.exits.S && !cell.exits.E` | `grid[y + 1][x + 1]` | -| SW | `!cell.exits.S && !cell.exits.W` | `grid[y + 1][x - 1]` | - -Lane stripes only go on straight cells. Do not draw stripes on corners, T-intersections, or 4-ways. - -```javascript -const meta = cell.meta || 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); - } -} -``` - -After rendering, `this.cityChunks` can either be renamed to `this.islandChunks` or kept as `this.cityChunks` to minimize unrelated changes. If kept, update comments so they refer to the island, not the old city. - -## Unit Test Requirements - -`tests/unit/route.test.js` should keep the existing helper-function tests and add these tests. - -| Test | Assertion | -|---|---| -| `buildIsland()` deterministic with seed | Two calls with `{ seed: 123, routeCount: 30 }` produce identical road-cell coordinates, exits, and route steps. | -| route steps stay on graph | For every route step, decision, approach, and exit cells are roads; approach is adjacent to the decision on `OPPOSITE[headingIn]`; exit is adjacent on `headingOut`; the decision has an exit back to approach and an exit to `headingOut`; `move === getRelativeMove(headingIn, headingOut)`. | -| route starts correctly | `routeStart` is `{ x: 3, y: 2, heading: 'east' }`; `route[0].headingIn` is `'east'`; the first approach path remains on road cells. | -| extension continues smoothly | `extendRouteOnGraph(island, island.route, 20, rng)` returns 20 steps; first extension index is `island.route.length`; first extension starts from the previous last step's `exitX`, `exitY`, and `headingOut`. | - -`tests/unit/projection.test.js` should replace the old city tests with these island tests. - -| Test | Assertion | -|---|---| -| fixed rings | Outer perimeter cells are water; `x/y = 1,48` are beach except perimeter; `x/y = 2,47` are road except where already water/beach precedence applies. | -| interior cells | Every cell in `x = 3..46`, `y = 3..46` is either building or road. There must be no water or beach in the interior. | -| connected graph | BFS from any road reaches exactly `island.roads.length` cells. | -| no dead-ends | Every road cell has degree at least `2`. | -| varied intersections | Count only decision cells for ratios. Let `decisionCount = tCount + fourCount`. Assert `decisionCount > 0`, `tCount / decisionCount >= 0.55`, and `fourCount / decisionCount >= 0.06`. Do not divide these ratios by all road cells because long straight roads make that threshold misleading. | -| average degree | Average degree across all road cells is at least `2.0`. | -| building count sanity | `island.buildings.length + interiorRoadCount === 44 * 44`, where `interiorRoadCount` counts road cells with `x = 3..46` and `y = 3..46`. | - -## Browser Test Requirements - -Keep the existing fire-truck browser tests for load, render, automatic movement, wrong arrow stop, and correct arrow resume. - -Add these checks to `tests/browser/runner.js` after the fire-truck scene loads: - -```javascript -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'), - }; -}); -``` - -Assertions: - -| Field | Expected value | -|---|---| -| `width` | `50` | -| `height` | `50` | -| `cell00` | `'water'` | -| `cell11` | `'beach'` | -| `cell22` | `'road'` | -| `routeOutOfBounds` | `false` | -| `hasFourWayOnRoute` | `true` | - -## Screenshot Harness - -Add `tests/browser/screenshot.js`. It must be runnable with `npm run screenshot`. - -Script behavior: - -1. Start `tests/serve.js` on a non-conflicting port, for example `8767`. -2. Launch Chromium with viewport `{ width: 1280, height: 800 }`. -3. Create `tests/screenshots/` if it does not exist. -4. Set `window.__TEST_MODE__ = true` and `window.GAME_SPEED_MULTIPLIER = 0` before loading fire-truck pages. -5. Save these files and print each path to stdout. - -| File | Page setup | -|---|---| -| `tests/screenshots/fire-truck-overview.png` | Load `/games/fire-truck/`, wait for `window.__FT_SCENE__`, stop camera follow, set zoom to `0.05`, center on island center `((width - 1) / 2 * cellSize, (height - 1) / 2 * cellSize)`. | -| `tests/screenshots/fire-truck-gameplay.png` | Load a fresh `/games/fire-truck/`, wait for scene, keep default camera, take screenshot while frozen. | -| `tests/screenshots/fire-truck-intersection.png` | Load a fresh `/games/fire-truck/`, find the first route index whose decision cell has `meta.kind === 'four'`, call `snapTruckToRouteIndex(index)`, center camera on the truck, take screenshot. | -| `tests/screenshots/portal.png` | Load `/`, take screenshot. | - -Implementation skeleton: - -```javascript -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); }); -``` - -Add this script to `package.json`: - -```json -"screenshot": "node tests/browser/screenshot.js" -``` - -## Visual Acceptance Checklist - -Run `npm run screenshot`, then inspect the PNGs with the `Read` tool. - -The overview screenshot must show a complete water perimeter, a complete one-cell beach ring, a closed outer road ring, and interior roads that are not a uniform full grid. - -The overview screenshot must show no roads outside the island and no truck route outside the outer ring road. - -The gameplay screenshot must show buildings touching their neighboring building cells with no transparent or grass-colored alleys between them. - -The intersection screenshot must show a clean 4-way intersection with no duplicated asphalt overlap, no stray lane stripe through the intersection center, and no road gaps at the exits. - -The portal screenshot must still render normally. - -## Verification Steps - -1. Run `npm run test:unit`. -2. Run `npm run test:browser`. -3. Run `npm run screenshot`. -4. Inspect `tests/screenshots/fire-truck-overview.png`, `tests/screenshots/fire-truck-gameplay.png`, `tests/screenshots/fire-truck-intersection.png`, and `tests/screenshots/portal.png` with the `Read` tool. -5. If practical, run `node tests/serve.js` and manually smoke-test arrow prompts in the browser URL printed by the server. - -Do not commit if `npm test` fails. - -## Critical Files - -| Path | Purpose | -|---|---| -| `games/fire-truck/lib.js` | Core island, road graph, route walking, and route extension logic. | -| `games/fire-truck/game.js` | Island rendering, camera bounds, route integration, and screenshot/test hooks. | -| `tests/unit/projection.test.js` | Graph and island invariants. | -| `tests/unit/route.test.js` | Route continuity and helper behavior. | -| `tests/browser/runner.js` | Browser smoke tests and `__FT_SCENE__` checks. | -| `tests/browser/screenshot.js` | Visual verification harness for the implementation agent. |