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:
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. |