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);