// Mini Motorways — pure simulation logic. No Phaser imports; runs in Node for // headless verification (server/scripts/verifyMiniMotorways.js) and in the // browser scene. All distances are in grid cells, all times in milliseconds. export const WORLD_W = 40; export const WORLD_H = 24; export const TERRAIN = { LAND: 0, WATER: 1, TREE: 2 }; export const COLOR_NAMES = ['red', 'blue', 'yellow', 'green', 'purple', 'orange']; export const COLOR_HEX = { red: 0xe4574c, blue: 0x4a90d9, yellow: 0xf0b429, green: 0x55b86a, purple: 0x9b6dd6, orange: 0xee8a3c, }; export const TUNE = { WEEK_MS: 70000, SUBSTEP_MS: 50, CAR_CAP: 60, HOUSE_CAP: 20, BUILDING_CAP: 8, CAR_SPEED: 2.5, // cells / second HEADWAY: 0.65, // minimum gap behind the car ahead, in cells DWELL_MS: 1000, COOLDOWN_MS: 2000, PIN_MS_BASE: 18000, // pin interval = max(MIN, BASE * DECAY^week) PIN_MS_DECAY: 0.94, PIN_MS_MIN: 3500, PIN_GRACE_MS: 15000, // new buildings wait this long before pin #1 PIN_CAP: 12, OVERFLOW_PINS: 8, OVERFLOW_MS: 35000, // full ring → game over OVERFLOW_DRAIN: 2, // ring drains at 2x fill rate while pins < 8 DELIVERY_RELIEF: 0.06, // each delivery knocks the ring down a touch HOUSE_MS_BASE: 25000, HOUSE_MS_DECAY: 0.85, HOUSE_MS_MIN: 12000, BUILDING_MS_BASE: 120000, BUILDING_MS_DECAY: 0.88, BUILDING_MS_MIN: 70000, START_ROADS: 30, WEEK_ROADS: 12, UPGRADE_ROADS: 10, MOTORWAY_COST: 2.5, // A* cost of the portal edge MOTORWAY_MS: 1200, // real traversal time, ignores all traffic MOTORWAY_MIN_DIST: 4, // portals must be at least this far apart CONGESTION_COST: 0.4, // A* edge penalty per car on the target cell LIGHT_PHASE_MS: 4000, ROUNDABOUT_CAP: 2, DISPATCH_MS: 500, SECOND_CAR_WEEK: 2, // houses gain a second car from this week on COLOR_UNLOCK_WEEKS: [0, 0, 3, 5, 8, 11], GROWTH: [ { week: 0, w: 20, h: 12 }, { week: 3, w: 26, h: 16 }, { week: 6, w: 32, h: 20 }, { week: 9, w: 40, h: 24 }, ], }; // Six cities: palette + terrain generator parameters + personality. export const CITIES = [ { name: 'Marlow', blurb: 'A gentle market town split by one slow river.', palette: { land: 0xf4efe6, landAlt: 0xeae3d4, water: 0x8fcde4, accent: 0xf0b429, night: 0x8e9cc8, road: 0xffffff, roadEdge: 0xd8d0c0 }, gen: { rivers: 1, riverWidth: 1, lakes: 0, lakeSize: 0, treeDensity: 0.02 }, colorOrder: ['red', 'blue', 'yellow', 'green', 'purple', 'orange'], upgradeWeights: { bridge: 1, motorway: 1, light: 1, roundabout: 1, roads: 1.5 }, }, { name: 'Sandpoint', blurb: 'Lake country — build around the water, not through it.', palette: { land: 0xf2e8d5, landAlt: 0xe8dcc2, water: 0x5fb8b0, accent: 0xee8a3c, night: 0x96a0c4, road: 0xfffdf7, roadEdge: 0xd9cdb2 }, gen: { rivers: 0, riverWidth: 1, lakes: 3, lakeSize: 13, treeDensity: 0.015 }, colorOrder: ['orange', 'blue', 'green', 'red', 'yellow', 'purple'], upgradeWeights: { bridge: 1.6, motorway: 1.2, light: 1, roundabout: 1, roads: 1.4 }, }, { name: 'Twin Forks', blurb: 'Two rivers braid through town. Bring bridges.', palette: { land: 0xe9eef2, landAlt: 0xdde4ea, water: 0x7fb5d9, accent: 0x5a8fd6, night: 0x8893be, road: 0xffffff, roadEdge: 0xc9d2dc }, gen: { rivers: 2, riverWidth: 1, lakes: 0, lakeSize: 0, treeDensity: 0.02 }, colorOrder: ['blue', 'yellow', 'red', 'purple', 'orange', 'green'], upgradeWeights: { bridge: 3, motorway: 1, light: 1, roundabout: 1, roads: 1.3 }, }, { name: 'Cedar Falls', blurb: 'Forest roads wind between the pines.', palette: { land: 0xe8f0dd, landAlt: 0xdbe7cb, water: 0x74c4cf, accent: 0x4f9e58, night: 0x84a08e, road: 0xfdfff8, roadEdge: 0xc6d4b4 }, gen: { rivers: 1, riverWidth: 1, lakes: 1, lakeSize: 8, treeDensity: 0.06 }, colorOrder: ['green', 'red', 'purple', 'blue', 'orange', 'yellow'], upgradeWeights: { bridge: 1.4, motorway: 1, light: 1, roundabout: 1.4, roads: 1.5 }, }, { name: 'Saltmere', blurb: 'A wide cold estuary cuts the city in half.', palette: { land: 0xe8e8ec, landAlt: 0xdcdce2, water: 0x6f9fc8, accent: 0x8a77c9, night: 0x7d88b4, road: 0xffffff, roadEdge: 0xc8c8d2 }, gen: { rivers: 1, riverWidth: 3, lakes: 0, lakeSize: 0, treeDensity: 0.01 }, colorOrder: ['purple', 'yellow', 'blue', 'orange', 'green', 'red'], upgradeWeights: { bridge: 2.5, motorway: 1.2, light: 1, roundabout: 1, roads: 1.3 }, }, { name: 'Solano', blurb: 'Dry, sprawling, and fast. Motorway territory.', palette: { land: 0xf6e7d3, landAlt: 0xeedbc0, water: 0x66b2c4, accent: 0xd95f43, night: 0xa78f9e, road: 0xfffaf2, roadEdge: 0xe0cdaf }, gen: { rivers: 0, riverWidth: 1, lakes: 1, lakeSize: 6, treeDensity: 0.025 }, colorOrder: ['red', 'orange', 'purple', 'green', 'blue', 'yellow'], upgradeWeights: { bridge: 0.6, motorway: 2.5, light: 1.2, roundabout: 1, roads: 1.4 }, }, ]; export function mulberry32(seed) { let a = seed >>> 0; return () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } export const keyOf = (x, y) => y * WORLD_W + x; export const xOf = (k) => k % WORLD_W; export const yOf = (k) => Math.floor(k / WORLD_W); const inBounds = (x, y) => x >= 0 && x < WORLD_W && y >= 0 && y < WORLD_H; const SQRT2 = Math.SQRT2; const DIRS = [ [1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1], ]; export function octile(ax, ay, bx, by) { const dx = Math.abs(ax - bx); const dy = Math.abs(ay - by); return Math.max(dx, dy) + (SQRT2 - 1) * Math.min(dx, dy); } function centeredRect(w, h) { const x0 = Math.floor((WORLD_W - w) / 2); const y0 = Math.floor((WORLD_H - h) / 2); return { x0, y0, x1: x0 + w - 1, y1: y0 + h - 1, w, h }; } const START_RECT = centeredRect(TUNE.GROWTH[0].w, TUNE.GROWTH[0].h); // ── Terrain generation ───────────────────────────────────────────────────────── function carveRiver(terrain, rng, width) { // Biased random walk from one edge to the opposite one. const vertical = rng() < 0.5; let x; let y; if (vertical) { x = 6 + Math.floor(rng() * (WORLD_W - 12)); y = 0; } else { x = 0; y = 4 + Math.floor(rng() * (WORLD_H - 8)); } while (inBounds(x, y)) { for (let o = 0; o < width; o++) { const wx = vertical ? x + o : x; const wy = vertical ? y : y + o; if (inBounds(wx, wy)) terrain[keyOf(wx, wy)] = TERRAIN.WATER; } if (vertical) { y += 1; if (rng() < 0.42) x += rng() < 0.5 ? -1 : 1; x = Math.max(1, Math.min(WORLD_W - 1 - width, x)); } else { x += 1; if (rng() < 0.42) y += rng() < 0.5 ? -1 : 1; y = Math.max(1, Math.min(WORLD_H - 1 - width, y)); } } } function carveLake(terrain, rng, size) { const cx = 4 + Math.floor(rng() * (WORLD_W - 8)); const cy = 3 + Math.floor(rng() * (WORLD_H - 6)); const blob = [keyOf(cx, cy)]; const inBlob = new Set(blob); terrain[blob[0]] = TERRAIN.WATER; while (blob.length < size) { const from = blob[Math.floor(rng() * blob.length)]; const [dx, dy] = DIRS[Math.floor(rng() * 4)]; const nx = xOf(from) + dx; const ny = yOf(from) + dy; if (!inBounds(nx, ny)) continue; const k = keyOf(nx, ny); if (inBlob.has(k)) continue; inBlob.add(k); blob.push(k); terrain[k] = TERRAIN.WATER; } } function waterRunLengths(terrain, x, y) { let h = 1; let v = 1; for (let i = x - 1; i >= 0 && terrain[keyOf(i, y)] === TERRAIN.WATER; i--) h++; for (let i = x + 1; i < WORLD_W && terrain[keyOf(i, y)] === TERRAIN.WATER; i++) h++; for (let j = y - 1; j >= 0 && terrain[keyOf(x, j)] === TERRAIN.WATER; j--) v++; for (let j = y + 1; j < WORLD_H && terrain[keyOf(x, j)] === TERRAIN.WATER; j++) v++; return { h, v }; } function startRectOk(terrain) { let water = 0; for (let y = START_RECT.y0; y <= START_RECT.y1; y++) { for (let x = START_RECT.x0; x <= START_RECT.x1; x++) { if (terrain[keyOf(x, y)] !== TERRAIN.WATER) continue; water++; // Every water cell crossing the start zone must be bridgeable (≤3 wide) // along at least one axis. const { h, v } = waterRunLengths(terrain, x, y); if (h > 3 && v > 3) return false; } } return water <= START_RECT.w * START_RECT.h * 0.2; } function forceFixStartRect(terrain) { const cells = []; for (let y = START_RECT.y0; y <= START_RECT.y1; y++) { for (let x = START_RECT.x0; x <= START_RECT.x1; x++) { if (terrain[keyOf(x, y)] === TERRAIN.WATER) cells.push([x, y]); } } for (const [x, y] of cells) { const { h, v } = waterRunLengths(terrain, x, y); if (h > 3 && v > 3) terrain[keyOf(x, y)] = TERRAIN.LAND; } const cap = Math.floor(START_RECT.w * START_RECT.h * 0.2); const cx = WORLD_W / 2; const cy = WORLD_H / 2; let water = cells.filter(([x, y]) => terrain[keyOf(x, y)] === TERRAIN.WATER); water.sort((a, b) => octile(a[0], a[1], cx, cy) - octile(b[0], b[1], cx, cy)); while (water.length > cap) { const [x, y] = water.shift(); terrain[keyOf(x, y)] = TERRAIN.LAND; } } function genTerrainOnce(city, rng) { const terrain = new Uint8Array(WORLD_W * WORLD_H); for (let i = 0; i < city.gen.rivers; i++) carveRiver(terrain, rng, city.gen.riverWidth); for (let i = 0; i < city.gen.lakes; i++) carveLake(terrain, rng, city.gen.lakeSize); for (let k = 0; k < terrain.length; k++) { if (terrain[k] === TERRAIN.LAND && rng() < city.gen.treeDensity) terrain[k] = TERRAIN.TREE; } // Keep a small clearing at the very centre so the first structures always fit. for (let y = 10; y <= 13; y++) { for (let x = 17; x <= 22; x++) { if (terrain[keyOf(x, y)] === TERRAIN.TREE) terrain[keyOf(x, y)] = TERRAIN.LAND; } } return terrain; } export function generateCity(cityIndex, seed) { const city = CITIES[cityIndex]; for (let attempt = 0; attempt < 10; attempt++) { const rng = mulberry32((seed + attempt * 1000003) >>> 0); const terrain = genTerrainOnce(city, rng); if (startRectOk(terrain)) return { terrain }; } const terrain = genTerrainOnce(city, mulberry32(seed >>> 0)); forceFixStartRect(terrain); return { terrain }; } // ── Simulation ───────────────────────────────────────────────────────────────── export class Sim { constructor(cityIndex, seed) { this.cityIndex = cityIndex; this.city = CITIES[cityIndex]; this.rng = mulberry32(seed >>> 0); this.terrain = generateCity(cityIndex, seed).terrain; this.roads = new Set(); this.bridgeCells = new Set(); this.bridges = []; // { id, cells: [k...] } this.items = new Map(); // k -> { type: 'light' | 'roundabout' } this.motorways = []; // { id, a, b } this.portals = new Map(); // k -> twin portal k this.houses = []; // { id, color, k, carIds: [] } this.buildings = []; // { id, color, k, cells, pins, reserved, ring, pinT, graceT, overflowing } this.cars = []; this.nextId = 1; this.stock = { roads: TUNE.START_ROADS, bridges: 0, motorways: 0, lights: 0, roundabouts: 0 }; this.score = 0; this.week = 0; this.weekT = 0; this.time = 0; this.paused = false; this.gameOver = false; this.gameOverInfo = null; this.upgradeChoices = null; this.netVersion = 0; this.connCache = new Map(); this.cellOcc = new Map(); // k -> count of active cars on the cell this.dispatchT = 0; this.events = []; this.growthIdx = 0; this.activeRect = centeredRect(TUNE.GROWTH[0].w, TUNE.GROWTH[0].h); this.colorsUnlocked = 0; // Cities with water start with one bridge in hand so an early river spawn // can't be an unwinnable death sentence. if (this.terrain.includes(TERRAIN.WATER)) this.stock.bridges = 1; this.startComponent = this.computeStartComponent(); this.houseT = this.houseInterval(); this.buildingT = this.buildingInterval(); // Week 0 opens with one colour, each seeded with a destination and homes. this.unlockColors(1); } // ── Small helpers ──────────────────────────────────────────────────────────── emit(type, data = {}) { this.events.push({ type, ...data }); } houseInterval() { return Math.max(TUNE.HOUSE_MS_MIN, TUNE.HOUSE_MS_BASE * TUNE.HOUSE_MS_DECAY ** this.week); } buildingInterval() { return Math.max(TUNE.BUILDING_MS_MIN, TUNE.BUILDING_MS_BASE * TUNE.BUILDING_MS_DECAY ** this.week); } pinInterval() { return Math.max(TUNE.PIN_MS_MIN, TUNE.PIN_MS_BASE * TUNE.PIN_MS_DECAY ** this.week); } carsPerHouse() { return this.week >= TUNE.SECOND_CAR_WEEK ? 2 : 1; } lightPhase() { return Math.floor(this.time / TUNE.LIGHT_PHASE_MS) % 2; } computeStartComponent() { // Flood fill over non-water from the centre: early spawns stay on the // starting landmass so the player is never forced to bridge in week 0. const seen = new Set(); let seed = null; for (let r = 0; r < 10 && seed === null; r++) { for (let dy = -r; dy <= r && seed === null; dy++) { for (let dx = -r; dx <= r; dx++) { const x = 20 + dx; const y = 12 + dy; if (inBounds(x, y) && this.terrain[keyOf(x, y)] !== TERRAIN.WATER) { seed = keyOf(x, y); break; } } } } if (seed === null) return seen; const stack = [seed]; seen.add(seed); while (stack.length) { const k = stack.pop(); const x = xOf(k); const y = yOf(k); for (let d = 0; d < 4; d++) { const nx = x + DIRS[d][0]; const ny = y + DIRS[d][1]; if (!inBounds(nx, ny)) continue; const nk = keyOf(nx, ny); if (seen.has(nk) || this.terrain[nk] === TERRAIN.WATER) continue; seen.add(nk); stack.push(nk); } } return seen; } occupiedAt(k) { for (const h of this.houses) if (h.k === k) return { type: 'house', ref: h }; for (const b of this.buildings) if (b.cells.includes(k)) return { type: 'building', ref: b }; return null; } buildOccupiedSet() { const s = new Set(); for (const h of this.houses) s.add(h.k); for (const b of this.buildings) for (const c of b.cells) s.add(c); return s; } // ── Road network ───────────────────────────────────────────────────────────── touchNetwork() { this.netVersion++; this.connCache.clear(); } roadNeighbors(k) { const x = xOf(k); const y = yOf(k); const out = []; for (const [dx, dy] of DIRS) { const nx = x + dx; const ny = y + dy; if (!inBounds(nx, ny)) continue; const nk = keyOf(nx, ny); if (!this.roads.has(nk)) continue; if (dx !== 0 && dy !== 0) { // Crossing rule: if both shared corners carry road, the two diagonals // of this 2x2 block would cross — and the corners already connect the // cells orthogonally — so the diagonal edge is suppressed. const cornerA = keyOf(x + dx, y); const cornerB = keyOf(x, y + dy); if (this.roads.has(cornerA) && this.roads.has(cornerB)) continue; } out.push({ k: nk, cost: dx !== 0 && dy !== 0 ? SQRT2 : 1 }); } const twin = this.portals.get(k); if (twin !== undefined) out.push({ k: twin, cost: TUNE.MOTORWAY_COST, motorway: true }); return out; } connCount(k) { let n = this.connCache.get(k); if (n === undefined) { n = this.roadNeighbors(k).length; this.connCache.set(k, n); } return n; } canPlaceRoad(k) { return this.stock.roads > 0 && this.terrain[k] === TERRAIN.LAND && !this.roads.has(k) && !this.occupiedAt(k); } placeRoad(k) { if (!this.canPlaceRoad(k)) return false; this.stock.roads--; this.roads.add(k); this.touchNetwork(); return true; } eraseRoad(k) { if (!this.roads.has(k)) return false; if (this.portals.has(k)) return this.eraseMotorwayAt(k); if (this.bridgeCells.has(k)) return this.eraseBridgeAt(k); this.roads.delete(k); const item = this.items.get(k); if (item) { this.items.delete(k); if (item.type === 'light') this.stock.lights++; else this.stock.roundabouts++; } this.stock.roads++; this.touchNetwork(); this.flagReroutes([k]); return true; } flagReroutes(removed) { const gone = new Set(removed); for (const car of this.cars) { if (car.state !== 'toPickup' && car.state !== 'toHome') continue; const from = Math.floor(car.pos); for (let i = from; i < car.path.length; i++) { if (gone.has(car.path[i])) { car.needsReroute = true; break; } } } } // ── Bridges / motorways / intersection items ───────────────────────────────── // cells must be a straight orthogonal run of 1-3 water cells whose two // extension cells (just beyond each end) are dry land. canPlaceBridge(cells) { if (this.stock.bridges < 1) return false; if (!cells || cells.length < 1 || cells.length > 3) return false; const xs = cells.map(xOf); const ys = cells.map(yOf); const horiz = ys.every((y) => y === ys[0]); const vert = xs.every((x) => x === xs[0]); if (!horiz && !vert) return false; const sorted = [...cells].sort((a, b) => a - b); for (let i = 1; i < sorted.length; i++) { const stepOk = horiz ? sorted[i] === sorted[i - 1] + 1 : sorted[i] === sorted[i - 1] + WORLD_W; if (!stepOk) return false; } for (const k of cells) { if (this.terrain[k] !== TERRAIN.WATER || this.roads.has(k)) return false; } const step = horiz ? 1 : WORLD_W; const before = sorted[0] - step; const after = sorted[sorted.length - 1] + step; const dry = (k) => { const x = xOf(k); const y = yOf(k); return inBounds(x, y) && this.terrain[k] !== TERRAIN.WATER; }; // Guard against wrap-around on horizontal runs at the map edge. if (horiz && (xOf(sorted[0]) === 0 || xOf(sorted[sorted.length - 1]) === WORLD_W - 1)) return false; if (!horiz && (yOf(sorted[0]) === 0 || yOf(sorted[sorted.length - 1]) === WORLD_H - 1)) return false; return dry(before) && dry(after); } placeBridge(cells) { if (!this.canPlaceBridge(cells)) return false; this.stock.bridges--; const bridge = { id: this.nextId++, cells: [...cells] }; this.bridges.push(bridge); for (const k of cells) { this.roads.add(k); this.bridgeCells.add(k); } this.touchNetwork(); return true; } eraseBridgeAt(k) { const idx = this.bridges.findIndex((b) => b.cells.includes(k)); if (idx < 0) return false; const bridge = this.bridges[idx]; this.bridges.splice(idx, 1); for (const c of bridge.cells) { this.roads.delete(c); this.bridgeCells.delete(c); } this.stock.bridges++; this.touchNetwork(); this.flagReroutes(bridge.cells); return true; } // Motorway portals sit on previously-empty land next to existing road. canPlacePortal(k) { if (this.terrain[k] !== TERRAIN.LAND || this.roads.has(k) || this.occupiedAt(k)) return false; const x = xOf(k); const y = yOf(k); for (const [dx, dy] of DIRS) { const nx = x + dx; const ny = y + dy; if (inBounds(nx, ny) && this.roads.has(keyOf(nx, ny))) return true; } return false; } canPlaceMotorway(a, b) { return this.stock.motorways > 0 && a !== b && this.canPlacePortal(a) && this.canPlacePortal(b) && octile(xOf(a), yOf(a), xOf(b), yOf(b)) >= TUNE.MOTORWAY_MIN_DIST; } placeMotorway(a, b) { if (!this.canPlaceMotorway(a, b)) return false; this.stock.motorways--; this.motorways.push({ id: this.nextId++, a, b }); this.roads.add(a); this.roads.add(b); this.portals.set(a, b); this.portals.set(b, a); this.touchNetwork(); return true; } eraseMotorwayAt(k) { const idx = this.motorways.findIndex((m) => m.a === k || m.b === k); if (idx < 0) return false; const m = this.motorways[idx]; this.motorways.splice(idx, 1); this.portals.delete(m.a); this.portals.delete(m.b); this.roads.delete(m.a); this.roads.delete(m.b); this.stock.motorways++; this.touchNetwork(); this.flagReroutes([m.a, m.b]); return true; } canPlaceItem(k, type) { const stockOk = type === 'light' ? this.stock.lights > 0 : this.stock.roundabouts > 0; return stockOk && this.roads.has(k) && !this.items.has(k) && !this.portals.has(k) && this.connCount(k) >= 3; } placeItem(k, type) { if (!this.canPlaceItem(k, type)) return false; if (type === 'light') this.stock.lights--; else this.stock.roundabouts--; this.items.set(k, { type }); return true; } eraseItem(k) { const item = this.items.get(k); if (!item) return false; this.items.delete(k); if (item.type === 'light') this.stock.lights++; else this.stock.roundabouts++; return true; } // ── Pathfinding ────────────────────────────────────────────────────────────── roadCellsAround(cells) { const out = []; const seen = new Set(); for (const k of Array.isArray(cells) ? cells : [cells]) { const x = xOf(k); const y = yOf(k); for (const [dx, dy] of DIRS) { const nx = x + dx; const ny = y + dy; if (!inBounds(nx, ny)) continue; const nk = keyOf(nx, ny); if (this.roads.has(nk) && !seen.has(nk)) { seen.add(nk); out.push(nk); } } } return out; } // Multi-source / multi-goal A* over road cells. Returns [k...] or null. findPath(starts, goals) { if (!starts.length || !goals.length) return null; const goalSet = new Set(goals); const goalPts = goals.map((g) => [xOf(g), yOf(g)]); // Octile distance is inadmissible once motorway teleports exist (a portal // can cover 30 cells for cost 2.5), so fall back to Dijkstra then. const h = this.portals.size > 0 ? () => 0 : (k) => { const x = xOf(k); const y = yOf(k); let best = Infinity; for (const [gx, gy] of goalPts) { const d = octile(x, y, gx, gy); if (d < best) best = d; } return best; }; const gScore = new Map(); const cameFrom = new Map(); const open = []; // binary min-heap of [f, k] const push = (f, k) => { open.push([f, k]); let i = open.length - 1; while (i > 0) { const p = (i - 1) >> 1; if (open[p][0] <= open[i][0]) break; [open[p], open[i]] = [open[i], open[p]]; i = p; } }; const pop = () => { const top = open[0]; const last = open.pop(); if (open.length) { open[0] = last; let i = 0; for (;;) { const l = 2 * i + 1; const r = l + 1; let m = i; if (l < open.length && open[l][0] < open[m][0]) m = l; if (r < open.length && open[r][0] < open[m][0]) m = r; if (m === i) break; [open[m], open[i]] = [open[i], open[m]]; i = m; } } return top; }; for (const s of starts) { if (!this.roads.has(s)) continue; gScore.set(s, 0); push(h(s), s); } while (open.length) { const [, k] = pop(); if (goalSet.has(k)) { const path = [k]; let cur = k; while (cameFrom.has(cur)) { cur = cameFrom.get(cur); path.push(cur); } return path.reverse(); } const gk = gScore.get(k); for (const nb of this.roadNeighbors(k)) { const occ = this.cellOcc.get(nb.k) || 0; const tentative = gk + nb.cost + TUNE.CONGESTION_COST * occ; if (tentative < (gScore.get(nb.k) ?? Infinity)) { gScore.set(nb.k, tentative); cameFrom.set(nb.k, k); push(tentative + h(nb.k), nb.k); } } } return null; } // Full trip path for a car leaving home: [houseCell, road..., roadByBuilding]. findTripPath(house, building) { const starts = this.roadCellsAround(house.k); const goals = this.roadCellsAround(building.cells); const path = this.findPath(starts, goals); return path ? [house.k, ...path] : null; } findHomePath(fromRoadCell, house) { if (!this.roads.has(fromRoadCell)) return null; const goals = this.roadCellsAround(house.k); const path = this.findPath([fromRoadCell], goals); return path ? [...path, house.k] : null; } // ── Spawning ───────────────────────────────────────────────────────────────── randomFreeCell(needs2x2) { const r = this.activeRect; const occupied = this.buildOccupiedSet(); const fits = (x, y) => { if (!inBounds(x, y)) return false; const k = keyOf(x, y); if (this.terrain[k] !== TERRAIN.LAND || this.roads.has(k) || occupied.has(k)) return false; if (this.week < 2 && !this.startComponent.has(k)) return false; // One cell of clearance from other structures so driveways stay open. for (const [dx, dy] of DIRS) { const nx = x + dx; const ny = y + dy; if (inBounds(nx, ny) && occupied.has(keyOf(nx, ny))) return false; } return true; }; for (let attempt = 0; attempt < 90; attempt++) { const x = r.x0 + Math.floor(this.rng() * (r.w - (needs2x2 ? 1 : 0))); const y = r.y0 + Math.floor(this.rng() * (r.h - (needs2x2 ? 1 : 0))); if (needs2x2) { if (fits(x, y) && fits(x + 1, y) && fits(x, y + 1) && fits(x + 1, y + 1)) return keyOf(x, y); } else if (fits(x, y)) { return keyOf(x, y); } } return null; } spawnHouse(color, force = false) { if (!force && this.houses.length >= TUNE.HOUSE_CAP) return null; const k = this.randomFreeCell(false); if (k === null) return null; const house = { id: this.nextId++, color, k, carIds: [] }; this.houses.push(house); this.emit('houseSpawn', { id: house.id, k, color }); return house; } spawnBuilding(color) { if (this.buildings.length >= TUNE.BUILDING_CAP) return null; const k = this.randomFreeCell(true); if (k === null) return null; const cells = [k, k + 1, k + WORLD_W, k + WORLD_W + 1]; const building = { id: this.nextId++, color, k, cells, pins: 0, reserved: 0, ring: 0, overflowing: false, pinT: this.pinInterval(), graceT: TUNE.PIN_GRACE_MS, }; this.buildings.push(building); this.emit('buildingSpawn', { id: building.id, k, color }); return building; } unlockColors(target) { while (this.colorsUnlocked < Math.min(target, this.city.colorOrder.length)) { const color = this.city.colorOrder[this.colorsUnlocked]; this.colorsUnlocked++; this.spawnBuilding(color); // Bypass the house cap: a fresh colour must never start supply-starved. this.spawnHouse(color, true); this.spawnHouse(color, true); this.emit('colorUnlock', { color }); } } pickSpawnColor(forBuilding) { const unlocked = this.city.colorOrder.slice(0, this.colorsUnlocked); const stats = unlocked.map((color) => ({ color, houses: this.houses.filter((h) => h.color === color).length, buildings: this.buildings.filter((b) => b.color === color).length, })); if (forBuilding) { // Destinations follow supply: favour colours with spare houses per stop. stats.sort((a, b) => (b.houses / (b.buildings + 1)) - (a.houses / (a.buildings + 1))); } else { // Houses go where demand is under-served. stats.sort((a, b) => (a.houses / Math.max(1, a.buildings)) - (b.houses / Math.max(1, b.buildings))); } const top = stats.slice(0, 2); return top[Math.floor(this.rng() * top.length)].color; } // ── Weekly cycle ───────────────────────────────────────────────────────────── activeRectHasWater() { const r = this.activeRect; for (let y = r.y0; y <= r.y1; y++) { for (let x = r.x0; x <= r.x1; x++) { if (this.terrain[keyOf(x, y)] === TERRAIN.WATER) return true; } } return false; } pickUpgradeChoices() { const weights = { ...this.city.upgradeWeights }; if (!this.activeRectHasWater()) delete weights.bridge; const choices = []; for (let pick = 0; pick < 2; pick++) { const entries = Object.entries(weights).filter(([key]) => !choices.includes(key)); let total = 0; for (const [, w] of entries) total += w; let roll = this.rng() * total; for (const [key, w] of entries) { roll -= w; if (roll <= 0) { choices.push(key); break; } } if (choices.length < pick + 1) choices.push(entries[entries.length - 1][0]); } return choices; } chooseUpgrade(index) { if (!this.upgradeChoices) return; const pick = this.upgradeChoices[index] ?? this.upgradeChoices[0]; if (pick === 'bridge') this.stock.bridges++; else if (pick === 'motorway') this.stock.motorways++; else if (pick === 'light') this.stock.lights++; else if (pick === 'roundabout') this.stock.roundabouts++; else this.stock.roads += TUNE.UPGRADE_ROADS; this.upgradeChoices = null; this.paused = false; } rollWeek() { this.week++; this.stock.roads += TUNE.WEEK_ROADS; const growth = TUNE.GROWTH.findIndex((g) => g.week === this.week); if (growth > this.growthIdx) { this.growthIdx = growth; this.activeRect = centeredRect(TUNE.GROWTH[growth].w, TUNE.GROWTH[growth].h); this.emit('growth', { rect: { ...this.activeRect }, idx: growth }); } const unlockTarget = TUNE.COLOR_UNLOCK_WEEKS.filter((w) => w <= this.week).length; this.unlockColors(unlockTarget); this.upgradeChoices = this.pickUpgradeChoices(); this.paused = true; this.emit('weekEnd', { week: this.week, choices: [...this.upgradeChoices] }); } // ── Cars ───────────────────────────────────────────────────────────────────── setCarOcc(car, k) { if (car.occK === k) return; if (car.occK !== null && car.occK !== undefined) { const n = (this.cellOcc.get(car.occK) || 1) - 1; if (n <= 0) this.cellOcc.delete(car.occK); else this.cellOcc.set(car.occK, n); } car.occK = k; if (k !== null) this.cellOcc.set(k, (this.cellOcc.get(k) || 0) + 1); } syncCarXY(car) { if (car.mw) { const a = car.path[car.mw.fromIdx]; const b = car.path[car.mw.fromIdx + 1]; car.x = xOf(a) + (xOf(b) - xOf(a)) * car.mw.t; car.y = yOf(a) + (yOf(b) - yOf(a)) * car.mw.t; return; } const i = Math.min(Math.floor(car.pos), car.path.length - 1); const j = Math.min(i + 1, car.path.length - 1); const f = car.pos - i; const a = car.path[i]; const b = car.path[j]; car.x = xOf(a) + (xOf(b) - xOf(a)) * f; car.y = yOf(a) + (yOf(b) - yOf(a)) * f; if (a !== b) car.heading = Math.atan2(yOf(b) - yOf(a), xOf(b) - xOf(a)); } createCar(house) { const car = { id: this.nextId++, color: house.color, houseId: house.id, state: 'idle', path: null, pos: 0, x: xOf(house.k), y: yOf(house.k), heading: 0, dwellT: 0, cooldownT: 0, targetId: null, needsReroute: false, mw: null, occK: null, }; this.cars.push(car); house.carIds.push(car.id); return car; } houseById(id) { return this.houses.find((h) => h.id === id); } buildingById(id) { return this.buildings.find((b) => b.id === id); } carById(id) { return this.cars.find((c) => c.id === id); } dispatch() { const wanting = this.buildings .filter((b) => b.pins - b.reserved > 0) .sort((a, b) => (b.ring - a.ring) || (b.pins - a.pins)); for (const building of wanting) { const bx = xOf(building.k); const by = yOf(building.k); const homes = this.houses .filter((h) => h.color === building.color) .sort((a, b) => octile(xOf(a.k), yOf(a.k), bx, by) - octile(xOf(b.k), yOf(b.k), bx, by)); let assignments = 0; let failures = 0; for (const house of homes) { if (assignments >= 3 || failures >= 8) break; if (building.pins - building.reserved <= 0) break; let car = house.carIds.map((id) => this.carById(id)).find((c) => c && c.state === 'idle'); if (!car && house.carIds.length < this.carsPerHouse() && this.cars.length < TUNE.CAR_CAP) { car = this.createCar(house); } if (!car) continue; const path = this.findTripPath(house, building); if (!path) { failures++; continue; } car.state = 'toPickup'; car.path = path; car.pos = 0; car.targetId = building.id; car.needsReroute = false; car.mw = null; building.reserved++; assignments++; this.setCarOcc(car, path[0]); this.syncCarXY(car); } } } releaseReservation(car) { if (car.targetId === null) return; const b = this.buildingById(car.targetId); if (b && b.reserved > 0) b.reserved--; car.targetId = null; } parkAtHome(car) { const house = this.houseById(car.houseId); car.state = 'cooldown'; car.cooldownT = TUNE.COOLDOWN_MS; car.path = null; car.pos = 0; car.mw = null; car.needsReroute = false; if (house) { car.x = xOf(house.k); car.y = yOf(house.k); } this.setCarOcc(car, null); } goHome(car) { const house = this.houseById(car.houseId); const here = car.path[car.path.length - 1]; const path = house && this.roads.has(here) ? this.findHomePath(here, house) : null; if (!path) { this.emit('stranded', { carId: car.id, x: car.x, y: car.y }); this.parkAtHome(car); return; } car.state = 'toHome'; car.path = path; car.pos = 0; car.mw = null; car.needsReroute = false; this.setCarOcc(car, path[0]); this.syncCarXY(car); } reroute(car) { car.needsReroute = false; const idx = Math.min(Math.round(car.pos), car.path.length - 1); let anchor = car.path[idx]; if (!this.roads.has(anchor)) { const back = car.path.slice(0, idx).reverse().find((k) => this.roads.has(k)); if (back === undefined) { if (car.state === 'toPickup') this.releaseReservation(car); this.emit('stranded', { carId: car.id, x: car.x, y: car.y }); this.parkAtHome(car); return; } anchor = back; } let path = null; if (car.state === 'toPickup') { const building = this.buildingById(car.targetId); if (building) { const goals = this.roadCellsAround(building.cells); const tail = this.findPath([anchor], goals); if (tail) path = tail; } if (!path) { this.releaseReservation(car); const house = this.houseById(car.houseId); const home = house ? this.findHomePath(anchor, house) : null; if (home) { car.state = 'toHome'; path = home; } } } else { const house = this.houseById(car.houseId); if (house) path = this.findHomePath(anchor, house); } if (!path) { this.emit('stranded', { carId: car.id, x: car.x, y: car.y }); this.parkAtHome(car); return; } car.path = path; car.pos = 0; car.mw = null; this.setCarOcc(car, path[0]); this.syncCarXY(car); } canEnterCell(car, k, fromK) { if (this.connCount(k) < 3) return true; const item = this.items.get(k); const occ = (this.cellOcc.get(k) || 0); if (item?.type === 'roundabout') return occ < TUNE.ROUNDABOUT_CAP; if (item?.type === 'light') { const dx = xOf(k) - xOf(fromK); const dy = yOf(k) - yOf(fromK); if (dx !== 0 && dy !== 0) return true; // diagonal approaches filter in const axis = dy === 0 ? 0 : 1; return axis === this.lightPhase(); } return occ === 0; } headwayLimit(car, want) { let allowed = want; const hx = Math.cos(car.heading); const hy = Math.sin(car.heading); for (const other of this.cars) { if (other === car || other.mw) continue; if (other.state !== 'toPickup' && other.state !== 'toHome' && other.state !== 'dwell') continue; const ddx = other.x - car.x; const ddy = other.y - car.y; const d2 = ddx * ddx + ddy * ddy; if (d2 > 4) continue; const front = ddx * hx + ddy * hy; if (front <= 0.05) continue; const lat = Math.abs(-ddx * hy + ddy * hx); if (lat > 0.45) continue; if (other.state !== 'dwell') { // Opposite-direction cars pass through each other — this is what keeps // a single road usable both ways without lane simulation. const dot = hx * Math.cos(other.heading) + hy * Math.sin(other.heading); if (dot < 0.3) continue; } allowed = Math.min(allowed, front - TUNE.HEADWAY); } return Math.max(0, allowed); } moveCar(car, dtMs) { if (car.mw) { car.mw.t += dtMs / TUNE.MOTORWAY_MS; if (car.mw.t >= 1) { car.pos = car.mw.fromIdx + 1; car.mw = null; this.setCarOcc(car, car.path[Math.round(car.pos)]); } this.syncCarXY(car); return; } const want = TUNE.CAR_SPEED * (dtMs / 1000); let allowed = this.headwayLimit(car, want); if (allowed <= 0.0001) return; const curIdx = Math.round(car.pos); const boundary = curIdx + 0.5; let target = car.pos + allowed; if (car.pos < boundary && target >= boundary && curIdx + 1 < car.path.length) { const fromK = car.path[curIdx]; const nextK = car.path[curIdx + 1]; const isPortalJump = this.portals.get(fromK) === nextK && octile(xOf(fromK), yOf(fromK), xOf(nextK), yOf(nextK)) > SQRT2 + 0.01; if (isPortalJump) { // Snap to the portal mouth, then fly. car.pos = curIdx; car.mw = { fromIdx: curIdx, t: 0 }; this.setCarOcc(car, null); this.syncCarXY(car); return; } if (!this.canEnterCell(car, nextK, fromK)) target = boundary - 0.01; } car.pos = Math.min(target, car.path.length - 1); const occIdx = Math.round(car.pos); this.setCarOcc(car, car.path[Math.min(occIdx, car.path.length - 1)]); this.syncCarXY(car); if (car.pos >= car.path.length - 1 - 0.0001) { if (car.state === 'toPickup') { car.state = 'dwell'; car.dwellT = TUNE.DWELL_MS; } else if (car.state === 'toHome') { this.parkAtHome(car); car.state = 'cooldown'; } } } updateCar(car, dtMs) { switch (car.state) { case 'idle': return; case 'cooldown': car.cooldownT -= dtMs; if (car.cooldownT <= 0) car.state = 'idle'; return; case 'dwell': { car.dwellT -= dtMs; if (car.dwellT > 0) return; const building = this.buildingById(car.targetId); if (building) { if (building.pins > 0) building.pins--; if (building.reserved > 0) building.reserved--; building.ring = Math.max(0, building.ring - TUNE.DELIVERY_RELIEF); this.score++; this.emit('delivered', { carId: car.id, buildingId: building.id, color: car.color, x: car.x, y: car.y, score: this.score, }); } car.targetId = null; this.goHome(car); return; } case 'toPickup': case 'toHome': if (car.needsReroute) this.reroute(car); if (car.state === 'toPickup' || car.state === 'toHome') this.moveCar(car, dtMs); } } // ── Pins / overflow ────────────────────────────────────────────────────────── updateBuilding(building, dtMs) { if (building.graceT > 0) { building.graceT -= dtMs; } else { building.pinT -= dtMs; if (building.pinT <= 0) { building.pinT += this.pinInterval(); if (building.pins < TUNE.PIN_CAP) { building.pins++; this.emit('pinAdded', { buildingId: building.id, pins: building.pins }); } } } if (building.pins >= TUNE.OVERFLOW_PINS) { if (!building.overflowing) { building.overflowing = true; this.emit('overflowStart', { buildingId: building.id }); } building.ring += dtMs / TUNE.OVERFLOW_MS; if (building.ring >= 1) { this.gameOver = true; this.gameOverInfo = { buildingId: building.id, k: building.k, week: this.week, score: this.score }; this.emit('gameOver', { ...this.gameOverInfo }); } } else if (building.overflowing) { building.ring -= (dtMs / TUNE.OVERFLOW_MS) * TUNE.OVERFLOW_DRAIN; if (building.ring <= 0) { building.ring = 0; building.overflowing = false; this.emit('overflowEnd', { buildingId: building.id }); } } } // ── Main step ──────────────────────────────────────────────────────────────── step(dtMs) { this.events = []; if (this.paused || this.gameOver) return this.events; let remaining = dtMs; while (remaining > 0 && !this.paused && !this.gameOver) { const dt = Math.min(TUNE.SUBSTEP_MS, remaining); remaining -= dt; this.time += dt; // Weekly clock. this.weekT += dt; if (this.weekT >= TUNE.WEEK_MS) { this.weekT -= TUNE.WEEK_MS; this.rollWeek(); // pauses the sim for the upgrade choice } // Spawning. this.houseT -= dt; if (this.houseT <= 0) { this.houseT = this.houseInterval(); this.spawnHouse(this.pickSpawnColor(false)); } this.buildingT -= dt; if (this.buildingT <= 0) { this.buildingT = this.buildingInterval(); this.spawnBuilding(this.pickSpawnColor(true)); } // Demand. for (const building of this.buildings) { this.updateBuilding(building, dt); if (this.gameOver) return this.events; } // Dispatch. this.dispatchT -= dt; if (this.dispatchT <= 0) { this.dispatchT = TUNE.DISPATCH_MS; this.dispatch(); } // Movement. for (const car of this.cars) this.updateCar(car, dt); } return this.events; } }