kgames

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

lib.js (26965B)


      1 (function (root) {
      2   const HEADINGS = ['north', 'east', 'south', 'west'];
      3   const CARDINALS = ['N', 'E', 'S', 'W'];
      4   const DX = { north: 0, east: 1, south: 0, west: -1 };
      5   const DY = { north: -1, east: 0, south: 1, west: 0 };
      6   const TO_CARD = { north: 'N', east: 'E', south: 'S', west: 'W' };
      7   const OPPOSITE = { north: 'south', east: 'west', south: 'north', west: 'east' };
      8   const LEFT = { north: 'west', west: 'south', south: 'east', east: 'north' };
      9   const RIGHT = { north: 'east', east: 'south', south: 'west', west: 'north' };
     10 
     11   const CARD_TO_HEADING = { N: 'north', E: 'east', S: 'south', W: 'west' };
     12   const HEADING_TO_CARD = TO_CARD;
     13 
     14   const ISLAND_WIDTH = 50;
     15   const ISLAND_HEIGHT = 50;
     16   const MIN_BLOCK = 4;
     17   const ROUTE_COUNT = 200;
     18   const ROUTE_EXTENSION_COUNT = 36;
     19 
     20   const CELL_TYPES = {
     21     WATER: 'water',
     22     BEACH: 'beach',
     23     ROAD: 'road',
     24     BUILDING: 'building',
     25   };
     26 
     27   const DIRS = {
     28     N: { dx: 0, dy: -1, heading: 'north' },
     29     E: { dx: 1, dy: 0, heading: 'east' },
     30     S: { dx: 0, dy: 1, heading: 'south' },
     31     W: { dx: -1, dy: 0, heading: 'west' },
     32   };
     33 
     34   function createSeededRng(seed) {
     35     let state = (seed == null ? 0x12345678 : seed) >>> 0;
     36     return {
     37       next() {
     38         state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
     39         return state / 0x100000000;
     40       },
     41       pick(arr) {
     42         return arr[Math.floor(this.next() * arr.length)];
     43       },
     44     };
     45   }
     46 
     47   function getRng(opts, rng) {
     48     if (rng) return rng;
     49     return createSeededRng(opts && opts.seed);
     50   }
     51 
     52   function randInt(rand, min, max) {
     53     return min + Math.floor(rand.next() * (max - min + 1));
     54   }
     55 
     56   function randPick(rand, arr) {
     57     if (rand.pick) return rand.pick(arr);
     58     return arr[Math.floor(rand.next() * arr.length)];
     59   }
     60 
     61   function cloneExits(exits) {
     62     const out = { N: false, E: false, S: false, W: false };
     63     CARDINALS.forEach(card => { out[card] = !!exits[card]; });
     64     return out;
     65   }
     66 
     67   function turnHeading(heading, move) {
     68     if (move === 'straight') return heading;
     69     if (move === 'left') return LEFT[heading];
     70     if (move === 'right') return RIGHT[heading];
     71     throw new Error('Invalid move: ' + move);
     72   }
     73 
     74   function getRelativeMove(fromHeading, toHeading) {
     75     if (toHeading === fromHeading) return 'straight';
     76     if (toHeading === LEFT[fromHeading]) return 'left';
     77     if (toHeading === RIGHT[fromHeading]) return 'right';
     78     throw new Error('Unsupported heading transition');
     79   }
     80 
     81   function stepPosition(pos, heading) {
     82     return { x: pos.x + DX[heading], y: pos.y + DY[heading] };
     83   }
     84 
     85   function classifyIntersection(exits) {
     86     const norm = cloneExits(exits);
     87     const dirs = CARDINALS.filter(card => norm[card]);
     88     const degree = dirs.length;
     89     let kind = 'dead-end';
     90     if (degree === 4) kind = 'four';
     91     else if (degree === 3) kind = 't';
     92     else if (degree === 2) {
     93       kind = (norm.N && norm.S) || (norm.E && norm.W) ? 'straight' : 'corner';
     94     } else if (degree === 1) {
     95       kind = 'dead-end';
     96     }
     97     return { degree, kind, exits: norm };
     98   }
     99 
    100   function getLegalMoves(cell, heading) {
    101     const exits = cloneExits(cell.exits || {});
    102     const legal = [];
    103     if (exits[TO_CARD[heading]]) legal.push('straight');
    104     if (exits[TO_CARD[LEFT[heading]]]) legal.push('left');
    105     if (exits[TO_CARD[RIGHT[heading]]]) legal.push('right');
    106     return legal;
    107   }
    108 
    109   function pickPromptMove(legalMoves, rng) {
    110     if (!legalMoves.length) throw new Error('No legal moves');
    111     return (rng && rng.pick ? rng : createSeededRng()).pick(legalMoves);
    112   }
    113 
    114   function summarizeKinds(cells) {
    115     return cells.reduce((acc, cell) => {
    116       const kind = classifyIntersection(cell.exits).kind;
    117       acc[kind] = (acc[kind] || 0) + 1;
    118       return acc;
    119     }, {});
    120   }
    121 
    122   // ------------------------------------------------------------------
    123   // Island grid
    124   // ------------------------------------------------------------------
    125 
    126   function allocateGrid(width, height) {
    127     const grid = [];
    128     for (let y = 0; y < height; y++) {
    129       const row = [];
    130       for (let x = 0; x < width; x++) {
    131         row.push({
    132           x,
    133           y,
    134           type: CELL_TYPES.WATER,
    135           exits: { N: false, E: false, S: false, W: false },
    136           meta: null,
    137         });
    138       }
    139       grid.push(row);
    140     }
    141     return grid;
    142   }
    143 
    144   function layOutRings(grid, width, height) {
    145     for (let y = 0; y < height; y++) {
    146       for (let x = 0; x < width; x++) {
    147         if (x === 0 || y === 0 || x === width - 1 || y === height - 1) {
    148           grid[y][x].type = CELL_TYPES.WATER;
    149         } else if (x === 1 || y === 1 || x === width - 2 || y === height - 2) {
    150           grid[y][x].type = CELL_TYPES.BEACH;
    151         } else if (x === 2 || y === 2 || x === width - 3 || y === height - 3) {
    152           grid[y][x].type = CELL_TYPES.ROAD;
    153         } else {
    154           grid[y][x].type = CELL_TYPES.BUILDING;
    155         }
    156       }
    157     }
    158   }
    159 
    160   function drawRoadH(grid, x1, x2, y) {
    161     for (let x = x1; x <= x2; x++) grid[y][x].type = CELL_TYPES.ROAD;
    162   }
    163 
    164   function drawRoadV(grid, x, y1, y2) {
    165     for (let y = y1; y <= y2; y++) grid[y][x].type = CELL_TYPES.ROAD;
    166   }
    167 
    168   function pickSplitCoord(rand, start, size) {
    169     return randInt(rand, start + MIN_BLOCK, start + size - MIN_BLOCK - 1);
    170   }
    171 
    172   function subdivideInteriorRoads(grid, rand) {
    173     function subdivide(x, y, w, h) {
    174       const canSplitW = w >= MIN_BLOCK * 2 + 1;
    175       const canSplitH = h >= MIN_BLOCK * 2 + 1;
    176       if (!canSplitW && !canSplitH) return;
    177 
    178       let splitType;
    179       if (canSplitW && canSplitH) {
    180         const r = rand.next();
    181         splitType = r < 0.40 ? 'quad' : (r < 0.70 ? 'horiz' : 'vert');
    182       } else if (canSplitH) {
    183         splitType = 'horiz';
    184       } else {
    185         splitType = 'vert';
    186       }
    187 
    188       const right = x + w - 1;
    189       const bottom = y + h - 1;
    190 
    191       if (splitType === 'quad') {
    192         const cx = pickSplitCoord(rand, x, w);
    193         const cy = pickSplitCoord(rand, y, h);
    194         drawRoadH(grid, x, right, cy);
    195         drawRoadV(grid, cx, y, bottom);
    196         subdivide(x, y, cx - x, cy - y);
    197         subdivide(cx + 1, y, right - cx, cy - y);
    198         subdivide(x, cy + 1, cx - x, bottom - cy);
    199         subdivide(cx + 1, cy + 1, right - cx, bottom - cy);
    200         return;
    201       }
    202 
    203       if (splitType === 'horiz') {
    204         const cy = pickSplitCoord(rand, y, h);
    205         drawRoadH(grid, x, right, cy);
    206         subdivide(x, y, w, cy - y);
    207         subdivide(x, cy + 1, w, bottom - cy);
    208         return;
    209       }
    210 
    211       const cx = pickSplitCoord(rand, x, w);
    212       drawRoadV(grid, cx, y, bottom);
    213       subdivide(x, y, cx - x, h);
    214       subdivide(cx + 1, y, right - cx, h);
    215     }
    216 
    217     subdivide(3, 3, 44, 44);
    218   }
    219 
    220   function stitchRoadExits(grid, width, height) {
    221     for (let y = 0; y < height; y++) {
    222       for (let x = 0; x < width; x++) {
    223         const cell = grid[y][x];
    224         cell.exits = { N: false, E: false, S: false, W: false };
    225         cell.meta = null;
    226         if (cell.type !== CELL_TYPES.ROAD) continue;
    227 
    228         for (const dir of ['N', 'E', 'S', 'W']) {
    229           const nx = x + DIRS[dir].dx;
    230           const ny = y + DIRS[dir].dy;
    231           cell.exits[dir] = !!grid[ny] && !!grid[ny][nx] && grid[ny][nx].type === CELL_TYPES.ROAD;
    232         }
    233         cell.meta = classifyIntersection(cell.exits);
    234       }
    235     }
    236   }
    237 
    238   function validateConnectedRoads(grid, width, height) {
    239     const roads = [];
    240     for (let y = 0; y < height; y++) {
    241       for (let x = 0; x < width; x++) {
    242         if (grid[y][x].type === CELL_TYPES.ROAD) roads.push(grid[y][x]);
    243       }
    244     }
    245     if (!roads.length) throw new Error('Island has no roads');
    246 
    247     for (const cell of roads) {
    248       const degree = Object.values(cell.exits).filter(Boolean).length;
    249       if (degree < 2) throw new Error('Dead-end road at ' + cell.x + ',' + cell.y);
    250     }
    251 
    252     const seen = new Set();
    253     const queue = [roads[0]];
    254     seen.add(roads[0].x + ',' + roads[0].y);
    255 
    256     while (queue.length) {
    257       const cell = queue.shift();
    258       for (const dir of ['N', 'E', 'S', 'W']) {
    259         if (!cell.exits[dir]) continue;
    260         const nx = cell.x + DIRS[dir].dx;
    261         const ny = cell.y + DIRS[dir].dy;
    262         const key = nx + ',' + ny;
    263         if (!seen.has(key)) {
    264           seen.add(key);
    265           queue.push(grid[ny][nx]);
    266         }
    267       }
    268     }
    269 
    270     if (seen.size !== roads.length) {
    271       throw new Error('Disconnected road graph: reached ' + seen.size + ' of ' + roads.length);
    272     }
    273   }
    274 
    275   function flattenGrid(grid) {
    276     const out = [];
    277     for (let y = 0; y < grid.length; y++) {
    278       for (let x = 0; x < grid[y].length; x++) {
    279         out.push(grid[y][x]);
    280       }
    281     }
    282     return out;
    283   }
    284 
    285   function pickRandomRouteStart(roads, rand) {
    286     const candidates = roads.filter(c => {
    287       const meta = c.meta || classifyIntersection(c.exits);
    288       return meta.degree >= 2;
    289     });
    290     const cell = randPick(rand, candidates);
    291     const cards = ['N', 'E', 'S', 'W'].filter(c => cell.exits[c]);
    292     const card = randPick(rand, cards);
    293     return { x: cell.x, y: cell.y, heading: CARD_TO_HEADING[card] };
    294   }
    295 
    296   function headingFromTo(a, b) {
    297     const dx = b.x - a.x, dy = b.y - a.y;
    298     if (dx === 1) return 'east';
    299     if (dx === -1) return 'west';
    300     if (dy === 1) return 'south';
    301     return 'north';
    302   }
    303 
    304   function bfsRoadPath(grid, startCell, targetCell) {
    305     if (startCell === targetCell) return [startCell];
    306     const queue = [startCell];
    307     const parent = new Map();
    308     parent.set(startCell, null);
    309     while (queue.length) {
    310       const cell = queue.shift();
    311       for (const card of ['N', 'E', 'S', 'W']) {
    312         if (!cell.exits[card]) continue;
    313         const nx = cell.x + DIRS[card].dx;
    314         const ny = cell.y + DIRS[card].dy;
    315         const next = grid[ny] && grid[ny][nx];
    316         if (!next || next.type !== CELL_TYPES.ROAD) continue;
    317         if (parent.has(next)) continue;
    318         parent.set(next, cell);
    319         if (next === targetCell) {
    320           const path = [];
    321           let cur = next;
    322           while (cur) {
    323             path.push(cur);
    324             cur = parent.get(cur);
    325           }
    326           return path.reverse();
    327         }
    328         queue.push(next);
    329       }
    330     }
    331     return null;
    332   }
    333 
    334   function pathToRouteSteps(grid, cellPath, startIndex) {
    335     if (!cellPath || cellPath.length <= 2) return [];
    336     const steps = [];
    337     for (let i = 1; i < cellPath.length - 1; i++) {
    338       const decision = cellPath[i];
    339       if (!isDecisionCell(decision)) continue;
    340       const approach = cellPath[i - 1];
    341       const exitCell = cellPath[i + 1];
    342       const headingIn = headingFromTo(approach, decision);
    343       const headingOut = headingFromTo(decision, exitCell);
    344       steps.push({
    345         index: (startIndex ?? 0) + steps.length,
    346         x: decision.x,
    347         y: decision.y,
    348         approachX: approach.x,
    349         approachY: approach.y,
    350         headingIn,
    351         headingOut,
    352         move: getRelativeMove(headingIn, headingOut),
    353         exitX: exitCell.x,
    354         exitY: exitCell.y,
    355       });
    356     }
    357     return steps;
    358   }
    359 
    360   function bfsRoadDistances(grid, startCell) {
    361     const dist = new Map();
    362     dist.set(startCell, 0);
    363     const queue = [startCell];
    364     while (queue.length) {
    365       const cell = queue.shift();
    366       const d = dist.get(cell);
    367       for (const card of ['N', 'E', 'S', 'W']) {
    368         if (!cell.exits[card]) continue;
    369         const nx = cell.x + DIRS[card].dx;
    370         const ny = cell.y + DIRS[card].dy;
    371         const next = grid[ny] && grid[ny][nx];
    372         if (!next || next.type !== CELL_TYPES.ROAD) continue;
    373         if (dist.has(next)) continue;
    374         dist.set(next, d + 1);
    375         queue.push(next);
    376       }
    377     }
    378     return dist;
    379   }
    380 
    381   // Cardinal ('N'/'E'/'S'/'W') that moves from `cell` to the road neighbour
    382   // closest to the distMap origin (see bfsRoadDistances), or null if no
    383   // neighbour improves on the current cell — e.g. when already at the origin.
    384   function bestRoadDirection(grid, cell, distMap) {
    385     let best = null;
    386     let bestDist = distMap.has(cell) ? distMap.get(cell) : Infinity;
    387     for (const card of CARDINALS) {
    388       if (!cell.exits[card]) continue;
    389       const row = grid[cell.y + DIRS[card].dy];
    390       const next = row && row[cell.x + DIRS[card].dx];
    391       if (!next || !distMap.has(next)) continue;
    392       const d = distMap.get(next);
    393       if (d < bestDist) { bestDist = d; best = card; }
    394     }
    395     return best;
    396   }
    397 
    398   function pickFireDestination(island, rng, opts) {
    399     const rand = rng || createSeededRng();
    400     const targetDistance = opts && opts.targetDistance != null ? opts.targetDistance : 0;
    401     const variance = opts && opts.variance != null ? opts.variance : 5;
    402     const fromCell = opts && opts.fromCell;
    403 
    404     const candidates = island.roads.filter(cell => {
    405       for (const card of ['N', 'E', 'S', 'W']) {
    406         const nx = cell.x + DIRS[card].dx;
    407         const ny = cell.y + DIRS[card].dy;
    408         const neighbor = island.grid[ny] && island.grid[ny][nx];
    409         if (neighbor && neighbor.type === CELL_TYPES.BUILDING && !neighbor.park) return true;
    410       }
    411       return false;
    412     });
    413     if (!candidates.length) return null;
    414 
    415     let chosen;
    416     if (fromCell && targetDistance > 0) {
    417       const dists = bfsRoadDistances(island.grid, fromCell);
    418       const minD = targetDistance - variance;
    419       const maxD = targetDistance + variance;
    420       const inRange = candidates.filter(c => {
    421         const d = dists.get(c);
    422         return d != null && d >= minD && d <= maxD;
    423       });
    424       if (inRange.length) {
    425         chosen = randPick(rand, inRange);
    426       } else {
    427         let bestDiff = Infinity;
    428         for (const c of candidates) {
    429           const d = dists.get(c);
    430           if (d == null) continue;
    431           const diff = Math.abs(d - targetDistance);
    432           if (diff < bestDiff) { bestDiff = diff; chosen = c; }
    433         }
    434         if (!chosen) chosen = randPick(rand, candidates);
    435       }
    436     } else {
    437       chosen = randPick(rand, candidates);
    438     }
    439 
    440     if (!chosen) return null;
    441     for (const card of ['N', 'E', 'S', 'W']) {
    442       const nx = chosen.x + DIRS[card].dx;
    443       const ny = chosen.y + DIRS[card].dy;
    444       const neighbor = island.grid[ny] && island.grid[ny][nx];
    445       if (neighbor && neighbor.type === CELL_TYPES.BUILDING && !neighbor.park) {
    446         return { roadCell: chosen, buildingCell: neighbor };
    447       }
    448     }
    449     return null;
    450   }
    451 
    452   // ------------------------------------------------------------------
    453   // Decoration helpers (pure, deterministic) — drive all baked/ambient
    454   // variety so the island renders identically every run.
    455   // ------------------------------------------------------------------
    456 
    457   // Deterministic hash → float in [0,1). All decor variety derives from this.
    458   function hashCell(x, y, salt) {
    459     let h = Math.imul(((x | 0) + 0x9e3779b9) >>> 0, 0x85ebca6b);
    460     h = (h ^ Math.imul(((y | 0) + 0x165667b1) >>> 0, 0xc2b2ae35)) >>> 0;
    461     h = (h ^ Math.imul(((salt | 0) + 0x27d4eb2f) >>> 0, 0x2545f491)) >>> 0;
    462     h ^= h >>> 15;
    463     h = Math.imul(h, 0x2c1b3c6d) >>> 0;
    464     h ^= h >>> 13;
    465     return (h >>> 0) / 0x100000000;
    466   }
    467 
    468   // Greedily cover all BUILDING cells with disjoint rectangles. BSP road
    469   // subdivision guarantees building cells form road-bounded rectangular blocks,
    470   // so this yields one rectangle per block — render each as ONE building.
    471   function findBuildingBlocks(grid) {
    472     const h = grid.length;
    473     const w = grid[0].length;
    474     const covered = [];
    475     for (let y = 0; y < h; y++) covered.push(new Array(w).fill(false));
    476     const isB = (x, y) => !!(grid[y] && grid[y][x] && grid[y][x].type === CELL_TYPES.BUILDING);
    477     const blocks = [];
    478     for (let y = 0; y < h; y++) {
    479       for (let x = 0; x < w; x++) {
    480         if (!isB(x, y) || covered[y][x]) continue;
    481         let bw = 1;
    482         while (isB(x + bw, y) && !covered[y][x + bw]) bw++;
    483         let bh = 1;
    484         let canGrow = true;
    485         while (canGrow) {
    486           const ny = y + bh;
    487           for (let xx = x; xx < x + bw; xx++) {
    488             if (!isB(xx, ny) || covered[ny][xx]) { canGrow = false; break; }
    489           }
    490           if (canGrow) bh++;
    491         }
    492         for (let yy = y; yy < y + bh; yy++) {
    493           for (let xx = x; xx < x + bw; xx++) covered[yy][xx] = true;
    494         }
    495         blocks.push({ x, y, w: bw, h: bh });
    496       }
    497     }
    498     return blocks;
    499   }
    500 
    501   // Tag ~fraction of building blocks as parks (cell.park = true), preferring
    502   // larger (≥2×2) blocks. Type stays 'building' so grid/route tests are
    503   // untouched. Deterministic ordering via hashCell. Returns the block list.
    504   function assignParks(island, opts) {
    505     const fraction = (opts && opts.fraction != null) ? opts.fraction : 0.15;
    506     const grid = island.grid;
    507     const blocks = findBuildingBlocks(grid);
    508     const scored = blocks.map(b => ({
    509       b,
    510       big: (b.w >= 2 && b.h >= 2) ? 1 : 0,
    511       r: hashCell(b.x, b.y, 7),
    512     }));
    513     scored.sort((a, c) => (c.big - a.big) || (a.r - c.r) || (a.b.y - c.b.y) || (a.b.x - c.b.x));
    514     const target = Math.round(blocks.length * fraction);
    515     let count = 0;
    516     for (const s of scored) {
    517       if (count >= target) break;
    518       for (let yy = s.b.y; yy < s.b.y + s.b.h; yy++) {
    519         for (let xx = s.b.x; xx < s.b.x + s.b.w; xx++) {
    520           if (grid[yy] && grid[yy][xx]) grid[yy][xx].park = true;
    521         }
    522       }
    523       count++;
    524     }
    525     return blocks;
    526   }
    527 
    528   // Next {cell, heading} for an ambient car: reuse road exits, prefer going
    529   // straight, never reverse unless it's the only option (dead-end).
    530   function advanceCarPlan(grid, cell, heading, rand) {
    531     const back = OPPOSITE[heading];
    532     const options = [];
    533     for (const card of ['N', 'E', 'S', 'W']) {
    534       if (cell.exits && cell.exits[card]) {
    535         const hd = CARD_TO_HEADING[card];
    536         if (hd !== back) options.push(hd);
    537       }
    538     }
    539     let chosen;
    540     if (!options.length) {
    541       chosen = back;
    542     } else if (options.indexOf(heading) !== -1 && rand.next() < 0.7) {
    543       chosen = heading;
    544     } else {
    545       chosen = randPick(rand, options);
    546     }
    547     const next = cellAhead(grid, cell, chosen);
    548     if (!next || next.type !== CELL_TYPES.ROAD) {
    549       const rev = cellAhead(grid, cell, back);
    550       return { cell: (rev && rev.type === CELL_TYPES.ROAD) ? rev : cell, heading: back };
    551     }
    552     return { cell: next, heading: chosen };
    553   }
    554 
    555   // Which edges of a road cell abut a building/park (drives sidewalk baking
    556   // and pedestrian paths). Parks are type 'building' so they're included.
    557   function sidewalkEdges(grid, x, y) {
    558     const out = { N: false, E: false, S: false, W: false };
    559     for (const card of ['N', 'E', 'S', 'W']) {
    560       const nx = x + DIRS[card].dx;
    561       const ny = y + DIRS[card].dy;
    562       const nb = grid[ny] && grid[ny][nx];
    563       if (nb && nb.type === CELL_TYPES.BUILDING) out[card] = true;
    564     }
    565     return out;
    566   }
    567 
    568   function buildIsland(opts, rng) {
    569     const width = (opts && opts.width) ?? ISLAND_WIDTH;
    570     const height = (opts && opts.height) ?? ISLAND_HEIGHT;
    571     const routeCount = (opts && opts.routeCount) ?? ROUTE_COUNT;
    572     const rand = getRng(opts, rng);
    573 
    574     if (width !== 50 || height !== 50) {
    575       throw new Error('Only 50x50 islands are supported by this layout');
    576     }
    577 
    578     const grid = allocateGrid(width, height);
    579     layOutRings(grid, width, height);
    580     subdivideInteriorRoads(grid, rand);
    581     stitchRoadExits(grid, width, height);
    582     validateConnectedRoads(grid, width, height);
    583 
    584     const cells = flattenGrid(grid);
    585     const roads = cells.filter(c => c.type === CELL_TYPES.ROAD);
    586     const routeStart = (opts && opts.routeStart)
    587       ? opts.routeStart
    588       : pickRandomRouteStart(roads, rand);
    589     const route = buildRouteOnGraph(grid, { count: routeCount, startCell: routeStart, startHeading: routeStart.heading }, rand);
    590 
    591     return {
    592       width,
    593       height,
    594       grid,
    595       cells,
    596       roads,
    597       buildings: cells.filter(c => c.type === CELL_TYPES.BUILDING),
    598       beach: cells.filter(c => c.type === CELL_TYPES.BEACH),
    599       water: cells.filter(c => c.type === CELL_TYPES.WATER),
    600       route,
    601       routeStart,
    602       bounds: { minX: -0.5, minY: -0.5, maxX: width - 0.5, maxY: height - 0.5, width, height },
    603     };
    604   }
    605 
    606   // ------------------------------------------------------------------
    607   // Route walking on road graph
    608   // ------------------------------------------------------------------
    609 
    610   function isDecisionCell(cell) {
    611     if (!cell || cell.type !== CELL_TYPES.ROAD) return false;
    612     const meta = cell.meta || classifyIntersection(cell.exits);
    613     return meta.degree >= 3 || meta.kind === 'corner';
    614   }
    615 
    616   function outboundHeadings(cell, headingIn) {
    617     const back = OPPOSITE[headingIn];
    618     const all = [];
    619     for (const card of ['N', 'E', 'S', 'W']) {
    620       if (cell.exits[card]) all.push(CARD_TO_HEADING[card]);
    621     }
    622     const withoutBack = all.filter(heading => heading !== back);
    623     return withoutBack.length ? withoutBack : all;
    624   }
    625 
    626   function cellAhead(grid, cell, heading) {
    627     const next = stepPosition(cell, heading);
    628     return grid[next.y] && grid[next.y][next.x];
    629   }
    630 
    631   function findNextDecision(grid, fromCell, heading) {
    632     let previous = fromCell;
    633     let current = cellAhead(grid, fromCell, heading);
    634 
    635     while (current && current.type === CELL_TYPES.ROAD) {
    636       if (isDecisionCell(current)) {
    637         return { decision: current, approach: previous };
    638       }
    639 
    640       const outCard = HEADING_TO_CARD[heading];
    641       if (!current.exits[outCard]) {
    642         throw new Error('Straight road ended before a decision at ' + current.x + ',' + current.y);
    643       }
    644 
    645       previous = current;
    646       current = cellAhead(grid, current, heading);
    647     }
    648 
    649     throw new Error('Route left the road graph from ' + fromCell.x + ',' + fromCell.y + ' heading ' + heading);
    650   }
    651 
    652   function chooseHeading(rand, visitCounts, decision, options) {
    653     let lowest = Infinity;
    654     let candidates = [];
    655 
    656     for (const heading of options) {
    657       const key = decision.x + ',' + decision.y + '>' + heading;
    658       const count = visitCounts[key] || 0;
    659       if (count < lowest) {
    660         lowest = count;
    661         candidates = [heading];
    662       } else if (count === lowest) {
    663         candidates.push(heading);
    664       }
    665     }
    666 
    667     return randPick(rand, candidates);
    668   }
    669 
    670   function buildRouteOnGraph(grid, opts, rng) {
    671     const rand = rng || createSeededRng(opts && opts.seed);
    672     const count = (opts && opts.count) ?? ROUTE_COUNT;
    673     const route = [];
    674     const visitCounts = (opts && opts.visitCounts) || Object.create(null);
    675 
    676     let fromCell = (opts && opts.startCell) || { x: 3, y: 2 };
    677     let heading = (opts && opts.startHeading) || fromCell.heading || 'east';
    678     fromCell = grid[fromCell.y][fromCell.x];
    679     if (!fromCell || fromCell.type !== CELL_TYPES.ROAD) {
    680       throw new Error('Route start is not a road cell');
    681     }
    682 
    683     for (let i = 0; i < count; i++) {
    684       const found = findNextDecision(grid, fromCell, heading);
    685       const decision = found.decision;
    686       const approach = found.approach;
    687       const options = outboundHeadings(decision, heading);
    688       const headingOut = chooseHeading(rand, visitCounts, decision, options);
    689       const exit = cellAhead(grid, decision, headingOut);
    690 
    691       if (!exit || exit.type !== CELL_TYPES.ROAD) {
    692         throw new Error('Route chose non-road exit from ' + decision.x + ',' + decision.y);
    693       }
    694 
    695       const edgeKey = decision.x + ',' + decision.y + '>' + headingOut;
    696       visitCounts[edgeKey] = (visitCounts[edgeKey] || 0) + 1;
    697 
    698       route.push({
    699         index: ((opts && opts.startIndex) ?? 0) + i,
    700         x: decision.x,
    701         y: decision.y,
    702         approachX: approach.x,
    703         approachY: approach.y,
    704         headingIn: heading,
    705         headingOut,
    706         move: getRelativeMove(heading, headingOut),
    707         exitX: exit.x,
    708         exitY: exit.y,
    709       });
    710 
    711       fromCell = exit;
    712       heading = headingOut;
    713     }
    714 
    715     return route;
    716   }
    717 
    718   function visitCountsFromRoute(route) {
    719     const visitCounts = Object.create(null);
    720     for (const step of route) {
    721       const key = step.x + ',' + step.y + '>' + step.headingOut;
    722       visitCounts[key] = (visitCounts[key] || 0) + 1;
    723     }
    724     return visitCounts;
    725   }
    726 
    727   function extendRouteOnGraph(islandOrGrid, existingRoute, additional, rng) {
    728     const grid = Array.isArray(islandOrGrid) ? islandOrGrid : islandOrGrid.grid;
    729     if (!existingRoute.length) {
    730       return buildRouteOnGraph(grid, { count: additional }, rng);
    731     }
    732 
    733     const last = existingRoute[existingRoute.length - 1];
    734     return buildRouteOnGraph(grid, {
    735       count: additional,
    736       startCell: { x: last.exitX, y: last.exitY },
    737       startHeading: last.headingOut,
    738       startIndex: existingRoute.length,
    739       visitCounts: visitCountsFromRoute(existingRoute),
    740     }, rng);
    741   }
    742 
    743   // ── Fire-hose minigame facade ───────────────────────────────────────────
    744   // Pure layout for the street-view hose minigame: a cols×rows window grid
    745   // plus which windows start on fire. fireNumber (0-based, how many fires the
    746   // player has already put out) ramps the count up gently.
    747   function buildFacade(rng, opts) {
    748     const o = opts || {};
    749     const rand = rng || createSeededRng(o.seed);
    750     const cols = o.cols || randInt(rand, 4, 6);
    751     const rows = o.rows || randInt(rand, 3, 4);
    752     const total = cols * rows;
    753     const maxFires = Math.max(2, Math.floor(total / 2));
    754     const want = Math.min(3 + (o.fireNumber || 0), maxFires);
    755 
    756     const indices = [];
    757     for (let i = 0; i < total; i++) indices.push(i);
    758     for (let i = indices.length - 1; i > 0; i--) {
    759       const j = Math.floor(rand.next() * (i + 1));
    760       const tmp = indices[i]; indices[i] = indices[j]; indices[j] = tmp;
    761     }
    762     const fires = indices.slice(0, want).sort((a, b) => a - b)
    763       .map((i) => ({ index: i, col: i % cols, row: Math.floor(i / cols) }));
    764 
    765     return { cols, rows, fires };
    766   }
    767 
    768   const api = {
    769     HEADINGS,
    770     buildFacade,
    771     CELL_TYPES,
    772     turnHeading,
    773     getRelativeMove,
    774     stepPosition,
    775     classifyIntersection,
    776     getLegalMoves,
    777     pickPromptMove,
    778     summarizeKinds,
    779     createSeededRng,
    780     getRng,
    781     randInt,
    782     randPick,
    783     buildIsland,
    784     buildRouteOnGraph,
    785     extendRouteOnGraph,
    786     visitCountsFromRoute,
    787     allocateGrid,
    788     layOutRings,
    789     flattenGrid,
    790     stitchRoadExits,
    791     validateConnectedRoads,
    792     isDecisionCell,
    793     outboundHeadings,
    794     findNextDecision,
    795     cellAhead,
    796     chooseHeading,
    797     drawRoadH,
    798     drawRoadV,
    799     subdivideInteriorRoads,
    800     OPPOSITE,
    801     CARD_TO_HEADING,
    802     HEADING_TO_CARD,
    803     DIRS,
    804     pickRandomRouteStart,
    805     headingFromTo,
    806     bfsRoadPath,
    807     bfsRoadDistances,
    808     bestRoadDirection,
    809     pathToRouteSteps,
    810     pickFireDestination,
    811     hashCell,
    812     findBuildingBlocks,
    813     assignParks,
    814     advanceCarPlan,
    815     sidewalkEdges,
    816   };
    817 
    818   if (typeof module !== 'undefined' && module.exports) module.exports = api;
    819   root.FireTruckLib = api;
    820 })(typeof window !== 'undefined' ? window : globalThis);