import { INF, MAP_W, MAP_H, SIZE, MinHeap, indexOf, inside, xyOf } from "./mapUtils.js"; import { pathLengthCells } from "./mapTransport.js"; function cellKey(x, y) { return `${Math.round(x)},${Math.round(y)}`; } function pathTouchesCell(path, x, y, radius = 0.65) { if (!path || path.length < 1) return false; for (const [px, py] of path) if (Math.hypot(px - x, py - y) <= radius) return true; return false; } function anyPathTouches(paths, p, radius = 0.65) { return (paths || []).some((path) => pathTouchesCell(path, p.x, p.y, radius)); } function pathTerrainRuns(path, terrain = null) { const sea = terrain?.sea; const elevation = terrain?.elevation; const ridgeField = terrain?.ridgeField; const naturalBarrierScore = terrain?.naturalBarrierScore; let seaRun = 0, maxSeaRun = 0, tunnelRun = 0, maxTunnelRun = 0; let sampled = 0; const visit = (x, y) => { if (!inside(x, y)) { seaRun++; maxSeaRun = Math.max(maxSeaRun, seaRun); tunnelRun = 0; sampled++; return; } const i = indexOf(x, y); const isSea = Boolean(sea?.[i]); // Use the same sensitive tunnel proxy as the main transport validator. // Sampling every raster cell along each segment prevents smoothed or direct // paths from hiding over-limit tunnel runs between sparse vertices. const isTunnel = !isSea && (((elevation?.[i] || 0) >= 0.72 && (ridgeField?.[i] || 0) >= 0.34) || (naturalBarrierScore?.[i] || 0) >= 0.72); if (isSea) { seaRun++; maxSeaRun = Math.max(maxSeaRun, seaRun); } else seaRun = 0; if (isTunnel) { tunnelRun++; maxTunnelRun = Math.max(maxTunnelRun, tunnelRun); } else tunnelRun = 0; sampled++; }; for (let k = 1; k < (path?.length || 0); k++) { const a = path[k - 1]; const b = path[k]; if (!a || !b) continue; const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]))); for (let s = 0; s <= steps; s++) { if (k > 1 && s === 0) continue; const t = s / steps; visit(Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t)); } } if ((path?.length || 0) === 1) visit(path[0][0], path[0][1]); return { maxSeaRun, maxTunnelRun, sampled }; } function directPath(a, b, options = {}) { if (!a || !b) return []; const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y))); const out = []; for (let s = 0; s <= steps; s++) { const t = s / steps; const x = Math.round(a.x + (b.x - a.x) * t); const y = Math.round(a.y + (b.y - a.y) * t); if (!inside(x, y)) return []; if (!out.length || out[out.length - 1][0] !== x || out[out.length - 1][1] !== y) out.push([x, y]); } if (options.maxLength && pathLengthCells(out) > options.maxLength) return []; if (options.terrain) { const runs = pathTerrainRuns(out, options.terrain); if (Number.isFinite(options.maxSeaRun) && runs.maxSeaRun > options.maxSeaRun) return []; if (Number.isFinite(options.maxTunnelRun) && runs.maxTunnelRun > options.maxTunnelRun) return []; } return out; } function routeTerrainPath(a, b, terrain = null, options = {}) { if (!a || !b || !inside(a.x, a.y) || !inside(b.x, b.y)) return []; const sea = terrain?.sea; const elevation = terrain?.elevation; const slope = terrain?.slope; const ridgeField = terrain?.ridgeField; const start = indexOf(Math.round(a.x), Math.round(a.y)); const goal = indexOf(Math.round(b.x), Math.round(b.y)); if (sea?.[start] || sea?.[goal]) return []; const straight = Math.hypot(a.x - b.x, a.y - b.y); const maxLength = options.maxLength ?? straight * 2.8 + 60; const maxExpanded = Math.min(SIZE, options.maxExpanded ?? Math.max(9000, Math.floor(straight * straight * 5.5))); const dist = new Float64Array(SIZE); dist.fill(INF); const prev = new Int32Array(SIZE); prev.fill(-1); const closed = new Uint8Array(SIZE); const heap = new MinHeap(); dist[start] = 0; prev[start] = start; heap.push({ i: start, f: straight * 0.42 }); let hit = -1; let expanded = 0; while (heap.length && expanded++ < maxExpanded) { const current = heap.pop(); if (!current || closed[current.i]) continue; const cur = current.i; closed[cur] = 1; const [x, y] = xyOf(cur); if (Math.hypot(x - b.x, y - b.y) <= (options.snapRadius ?? 2.0)) { hit = cur; break; } if (Math.hypot(x - a.x, y - a.y) > maxLength) continue; for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { if (!dx && !dy) continue; const nx = x + dx, ny = y + dy; if (!inside(nx, ny)) continue; const ni = indexOf(nx, ny); if (closed[ni] || sea?.[ni]) continue; const step = Math.hypot(dx, dy); const terrainCost = 1.0 + (slope?.[ni] || 0) * 1.55 + (ridgeField?.[ni] || 0) * 0.82 + Math.max(0, (elevation?.[ni] || 0) - 0.66) * 2.05; const nd = dist[cur] + step * Math.max(0.42, terrainCost); if (nd >= dist[ni]) continue; dist[ni] = nd; prev[ni] = cur; const h = Math.hypot(nx - b.x, ny - b.y) * 0.42; heap.push({ i: ni, f: nd + h }); } } if (hit < 0) return []; const path = []; let cur = hit; for (let guard = 0; guard < Math.max(80, maxLength * 3) && cur >= 0; guard++) { const [x, y] = xyOf(cur); path.push([x, y]); if (prev[cur] === cur) break; cur = prev[cur]; } path.reverse(); if (path.length < 2 || pathLengthCells(path) > maxLength) return []; const runs = pathTerrainRuns(path, terrain); if (Number.isFinite(options.maxSeaRun) && runs.maxSeaRun > options.maxSeaRun) return []; if (Number.isFinite(options.maxTunnelRun) && runs.maxTunnelRun > options.maxTunnelRun) return []; return path; } function nearestPointOnPaths(paths, p, maxDistance = Infinity) { let best = null; for (const path of paths || []) { for (const [x, y] of path || []) { const d = Math.hypot(p.x - x, p.y - y); if (d <= maxDistance && (!best || d < best.d)) best = { x, y, d }; } } return best; } function nearestEntity(entities, p, maxDistance = Infinity) { let best = null; for (const q of entities || []) { if (!q || !Number.isFinite(q.x) || !Number.isFinite(q.y) || (q.x === p.x && q.y === p.y)) continue; const d = Math.hypot(q.x - p.x, q.y - p.y); if (d <= maxDistance && (!best || d < best.d)) best = { ...q, d }; } return best; } function dedupePaths(paths, sampleStep = 2) { const seen = new Set(); const kept = []; for (const path of paths || []) { if (!path || path.length < 2) continue; const cleaned = []; for (const pt of path) { const x = Math.round(pt[0]); const y = Math.round(pt[1]); if (!inside(x, y)) continue; if (!cleaned.length || cleaned[cleaned.length - 1][0] !== x || cleaned[cleaned.length - 1][1] !== y) cleaned.push([x, y]); } if (cleaned.length < 2) continue; const sigFor = (arr) => arr.map((p, i) => (i % sampleStep === 0 || i === arr.length - 1) ? `${p[0]},${p[1]}` : "").filter(Boolean).join("|"); const f = sigFor(cleaned); const r = sigFor([...cleaned].reverse()); const sig = f < r ? f : r; if (seen.has(sig)) continue; seen.add(sig); kept.push(cleaned); } return kept; } function addInterchange(interchanges, x, y, source = "post-admin-expressway-endpoint") { x = Math.round(x); y = Math.round(y); if (!inside(x, y)) return false; if ((interchanges || []).some((p) => Math.hypot(p.x - x, p.y - y) <= 2.5)) return false; interchanges.push({ x, y, kind: "Interchange", score: 1, source }); return true; } function smoothPath(path, passes = 1) { let cur = (path || []).map(([x, y]) => [Math.round(x), Math.round(y)]); for (let pass = 0; pass < passes; pass++) { if (cur.length < 3) break; const next = [cur[0]]; for (let i = 1; i < cur.length - 1; i++) { const [ax, ay] = cur[i - 1]; const [bx, by] = cur[i]; const [cx, cy] = cur[i + 1]; const x = Math.round((ax + bx * 2 + cx) / 4); const y = Math.round((ay + by * 2 + cy) / 4); if (!next.length || next[next.length - 1][0] !== x || next[next.length - 1][1] !== y) next.push([x, y]); } next.push(cur[cur.length - 1]); cur = next; } return cur; } function rebuildInfluence(paths, radius = 5) { const field = new Float32Array(SIZE); const r = Math.ceil(radius); for (const path of paths || []) { for (const [px, py] of path || []) { for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) { const x = px + dx, y = py + dy; if (!inside(x, y)) continue; const d = Math.hypot(dx, dy); if (d > radius) continue; const i = indexOf(x, y); field[i] = Math.max(field[i], Math.max(0, 1 - d / Math.max(0.001, radius))); } } } return field; } export function finalizeAdminAwareTransport({ seed, terrain, features, admin, geography = null }) { if (!features || !admin) return features; const minorRoads = features.minorRoads || []; const nationalRoads = features.nationalRoads || []; const externalRoads = features.externalRoads || []; const expressways = features.expressways || []; const externalExpressways = features.externalExpressways || []; const interchanges = features.interchanges || []; const adminCenters = admin.adminCentersRaw || features.adminCenters || []; const townsForNational = [ ...(features.modernCities || []).filter((p) => (p.population || 0) >= 5000), ...(features.markets || []).filter((p) => (p.population || 0) >= 5000), ...(features.villages || []).filter((p) => (p.population || 0) >= 5000), ...(features.ports || []).filter((p) => (p.population || 0) >= 5000 || p.portClass === "major" || p.portClass === "regional" || p.portClass === "fishing"), ]; const debug = { order: "settlements -> administration -> transport", adminCentersChecked: 0, adminLocalRoadsAdded: 0, nationalTownSpursAdded: 0, nationalTownChainsAdded: 0, nationalTownChainTownsCovered: 0, expresswayEndpointInterchangesAdded: 0, expresswaysSmoothed: 0, expresswayMajorCityLinksAdded: 0, railCityChainsAdded: 0, railCityChainCitiesCovered: 0 }; // Local roads after admin: every municipal office cell should lie on a road. const settlementTargets = [...(features.modernCities || []), ...(features.markets || []), ...(features.villages || []), ...(features.ports || [])]; for (const center of adminCenters || []) { if (!center || !inside(center.x, center.y)) continue; debug.adminCentersChecked++; const roadSet = [...minorRoads, ...nationalRoads, ...externalRoads]; if (anyPathTouches(roadSet, center, 0.65)) continue; const nearRoad = nearestPointOnPaths(roadSet, center, 22); const nearSettlement = nearestEntity(settlementTargets, center, 18); const target = nearRoad || nearSettlement; let path = target ? directPath(center, target, { maxLength: 34 }) : []; if (!path.length) { const x = center.x, y = center.y; const a = { x: Math.max(0, x - 2), y }; const b = { x: Math.min(MAP_W - 1, x + 2), y }; path = directPath(a, b, { maxLength: 8 }); } if (path.length >= 2 && pathTouchesCell(path, center.x, center.y, 0.65)) { minorRoads.push(path); debug.adminLocalRoadsAdded++; } } // National roads after admin/settlements: try to cover red-dot towns by chain routes instead of one spur per town. function concatPaths(parts) { const out = []; for (const part of parts || []) { if (!part || part.length < 2) continue; for (const pt of part) { if (!out.length || out[out.length - 1][0] !== pt[0] || out[out.length - 1][1] !== pt[1]) out.push(pt); } } return out; } function townWeight(p) { return (p.population || 0) + (p.isPrefecturalCapital ? 800000 : 0) + (p.isRegionalCapital ? 350000 : 0) + (p.portClass === "major" ? 220000 : p.portClass === "regional" ? 120000 : p.portClass === "fishing" ? 45000 : 0); } function relayGeometryAcceptable(points, options = {}) { const pts = (points || []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y)); if (pts.length < 3) return true; const first = pts[0]; const last = pts[pts.length - 1]; const vx = last.x - first.x; const vy = last.y - first.y; const direct = Math.hypot(vx, vy); if (direct < 0.001) return false; let via = 0; for (let i = 1; i < pts.length; i++) via += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y); const maxDetour = options.maxDetour ?? 1.68; if (via > direct * maxDetour + (options.detourSlack ?? 16)) return false; const maxOffset = Math.max(options.minOffset ?? 14, Math.min(options.maxOffset ?? 30, direct * (options.offsetRatio ?? 0.32))); for (let i = 1; i < pts.length - 1; i++) { const p = pts[i]; const wx = p.x - first.x; const wy = p.y - first.y; const t = (wx * vx + wy * vy) / Math.max(0.0001, direct * direct); const projX = first.x + vx * t; const projY = first.y + vy * t; const offset = Math.hypot(p.x - projX, p.y - projY); if (t < (options.minProjection ?? -0.10) || t > (options.maxProjection ?? 1.10)) return false; if (offset > maxOffset) return false; } return true; } function pathGeometryAcceptable(path, options = {}) { if (!path || path.length < 3) return true; const step = Math.max(1, Math.floor(path.length / 10)); const pts = []; for (let k = 0; k < path.length; k += step) pts.push({ x: path[k][0], y: path[k][1] }); const last = path[path.length - 1]; pts.push({ x: last[0], y: last[1] }); return relayGeometryAcceptable(pts, options); } function nearestTrunkOrHub(p, maxDistance = 85) { const trunk = nearestPointOnPaths([...nationalRoads, ...externalRoads], p, maxDistance); if (trunk) return trunk; return nearestEntity([...(features.modernCities || []), ...(features.ports || []), ...(features.markets || []), ...(features.externalGateways || [])], p, maxDistance); } function buildTownChain(start, pool, maxHops = 7) { const chain = [start]; let cur = start; for (let hop = 1; hop < maxHops; hop++) { let best = null; for (const town of pool) { if (chain.includes(town)) continue; const d = Math.hypot(cur.x - town.x, cur.y - town.y); if (d > 42) continue; const score = d - Math.min(18, Math.sqrt(Math.max(0, townWeight(town))) / 70); if (!best || score < best.score) best = { town, d, score }; } if (!best) break; chain.push(best.town); cur = best.town; } return chain; } function addNationalTownChains() { let uncovered = townsForNational .filter((town) => town && inside(town.x, town.y) && !anyPathTouches([...nationalRoads, ...externalRoads], town, 0.65)) .sort((a, b) => townWeight(b) - townWeight(a)); let chainsAdded = 0; let townsCovered = 0; while (uncovered.length) { const start = uncovered.shift(); let chain = buildTownChain(start, uncovered, 7); uncovered = uncovered.filter((town) => !chain.includes(town)); const parts = []; let before = nearestTrunkOrHub(chain[0], 80); let after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null; const relayPoints = [before || chain[0], ...chain, after || chain[chain.length - 1]]; if (!relayGeometryAcceptable(relayPoints, { maxDetour: 1.62, minOffset: 12, maxOffset: 26, offsetRatio: 0.30 })) { // The town-chain pass is a coverage fallback, not a mandate to drag a // road through a remote off-axis waypoint. Collapse to a single spur // when the waypoint chain would create a hooked or S-shaped route. chain = [chain[0]]; before = nearestTrunkOrHub(chain[0], 80); after = null; } if (before) { const p = directPath(before, chain[0], { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 }); if (p.length) parts.push(p); } for (let i = 1; i < chain.length; i++) { const d = Math.hypot(chain[i - 1].x - chain[i].x, chain[i - 1].y - chain[i].y); const segmentPoints = [chain[i - 1], chain[i]]; if (!relayGeometryAcceptable(segmentPoints, { maxDetour: 1.25, minOffset: 10, maxOffset: 18 })) continue; const p = directPath(chain[i - 1], chain[i], { maxLength: Math.max(28, d * 1.35 + 8), terrain, maxSeaRun: 10, maxTunnelRun: 10 }); if (p.length) parts.push(p); } if (after) { const p = directPath(chain[chain.length - 1], after, { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 10 }); if (p.length) parts.push(p); } let path = concatPaths(parts); if (path.length < 2) { const target = nearestTrunkOrHub(chain[0], 90); path = target ? directPath(chain[0], target, { maxLength: 100, terrain, maxSeaRun: 10, maxTunnelRun: 10 }) : []; } if (path.length >= 2 && pathGeometryAcceptable(path, { maxDetour: 1.78, minOffset: 14, maxOffset: 32, offsetRatio: 0.34 }) && chain.some((town) => pathTouchesCell(path, town.x, town.y, 0.65))) { nationalRoads.push(path); chainsAdded++; townsCovered += chain.filter((town) => pathTouchesCell(path, town.x, town.y, 0.65)).length; } } return { chainsAdded, townsCovered }; } const chainDebug = addNationalTownChains(); debug.nationalTownChainsAdded = chainDebug.chainsAdded; debug.nationalTownChainTownsCovered = chainDebug.townsCovered; debug.nationalTownSpursAdded = chainDebug.chainsAdded; function majorCityKey(city) { return city?.name || `${Math.round(city.x)},${Math.round(city.y)}`; } function expresswayCityComponents(cities, radius = 8.0) { const parent = new Map(); function find(k) { const p = parent.get(k); if (p === k) return k; const r = find(p); parent.set(k, r); return r; } function unite(a, b) { const ra = find(a), rb = find(b); if (ra !== rb) parent.set(ra, rb); } for (const city of cities) parent.set(majorCityKey(city), majorCityKey(city)); for (const path of [...expressways, ...externalExpressways]) { const near = cities.filter((city) => pathTouchesCell(path, city.x, city.y, radius)); if (near.length >= 2) { const first = majorCityKey(near[0]); for (const city of near.slice(1)) unite(first, majorCityKey(city)); } } return new Map(cities.map((city) => [majorCityKey(city), find(majorCityKey(city))])); } function suburbanExpresswayAnchorForCity(city, target = null) { if (!city || !inside(city.x, city.y)) return null; const sea = terrain?.sea; const elevation = terrain?.elevation; const slope = terrain?.slope; const ridgeField = terrain?.ridgeField; const inner = Math.max(8, Math.round((city.coreRadius || 4) + 6)); const outer = Math.max(inner + 7, Math.round((city.urbanRadius || 12) * 1.9)); let best = null; for (let dy = -outer; dy <= outer; dy++) { for (let dx = -outer; dx <= outer; dx++) { const x = city.x + dx, y = city.y + dy; if (!inside(x, y)) continue; const d = Math.hypot(dx, dy); if (d < inner || d > outer) continue; const i = indexOf(x, y); if (sea?.[i]) continue; const radial = Math.abs(d - (inner + outer) * 0.52); const targetBias = target ? Math.hypot(x - target.x, y - target.y) * 0.038 : 0; const score = -radial * 0.26 - targetBias - (slope?.[i] || 0) * 0.70 - (ridgeField?.[i] || 0) * 0.55 - Math.max(0, (elevation?.[i] || 0) - 0.70) * 0.75; if (!best || score > best.score) best = { x, y, score }; } } return best; } function suburbanExpresswayStubForCity(city, preferredAnchor = null) { if (!city || !inside(city.x, city.y)) return []; const angles = []; if (preferredAnchor) angles.push(Math.atan2(preferredAnchor.y - city.y, preferredAnchor.x - city.x)); for (let k = 0; k < 8; k++) angles.push((Math.PI * 2 * k) / 8 + (k % 2 ? 0.18 : 0)); const seenAngles = new Set(); for (const angle of angles) { const bucket = Math.round(angle * 100) / 100; if (seenAngles.has(bucket)) continue; seenAngles.add(bucket); const hint = { x: Math.round(city.x + Math.cos(angle) * 120), y: Math.round(city.y + Math.sin(angle) * 120) }; const anchor = suburbanExpresswayAnchorForCity(city, hint); if (!anchor) continue; const minD = Math.max(20, (city.urbanRadius || 12) * 1.35); const maxD = Math.max(minD + 10, (city.urbanRadius || 12) * 2.65); let bestEnd = null; for (let d = minD; d <= maxD; d += 2) { const x = Math.round(city.x + Math.cos(angle) * d); const y = Math.round(city.y + Math.sin(angle) * d); if (!inside(x, y)) continue; const i = indexOf(x, y); if (terrain?.sea?.[i]) continue; bestEnd = { x, y }; } if (!bestEnd || Math.hypot(bestEnd.x - anchor.x, bestEnd.y - anchor.y) < 8) continue; const d = Math.hypot(bestEnd.x - anchor.x, bestEnd.y - anchor.y); let path = directPath(anchor, bestEnd, { maxLength: d * 1.8 + 12, terrain, maxSeaRun: 0, maxTunnelRun: 10 }); if (!path.length) path = routeTerrainPath(anchor, bestEnd, terrain, { maxLength: d * 2.6 + 20, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 1.6 }); if (path.length >= 4) return path; } return []; } function expresswayServesCityFringe(city) { const inner = Math.max(7.0, (city.coreRadius || 4) + 4.5); const outer = Math.max(inner + 7, (city.urbanRadius || 12) * 2.0); for (const path of [...(features.expressways || []), ...(features.externalExpressways || [])]) { let inBand = false; let exits = false; for (const [x, y] of path || []) { const d = Math.hypot(x - city.x, y - city.y); if (d >= inner && d <= outer) inBand = true; if (d >= Math.max(24, (city.urbanRadius || 12) * 1.55)) exits = true; if (inBand && exits) return true; } } return false; } function ensureMajorCityExpresswayLinks(minPopulation = 100000) { const cities = (features.modernCities || []) .filter((city) => (city.population || 0) >= minPopulation && inside(city.x, city.y)) .sort((a, b) => (b.population || 0) - (a.population || 0)); const result = { minPopulation, checked: cities.length, covered: 0, added: 0, noTarget: 0, noPath: 0 }; features.expressways ||= []; if (!cities.length) return result; for (const city of cities) { if (expresswayServesCityFringe(city)) { result.covered++; continue; } const existing = [...(features.expressways || []), ...(features.externalExpressways || [])]; let target = nearestPointOnPaths(existing, city, 145); if (!target) { const other = cities.find((c) => c !== city && expresswayServesCityFringe(c)); target = other ? suburbanExpresswayAnchorForCity(other, city) : null; } if (!target) { result.noTarget++; continue; } const anchor = suburbanExpresswayAnchorForCity(city, target); if (!anchor) { result.noTarget++; continue; } const d = Math.hypot(anchor.x - target.x, anchor.y - target.y); if (d < 4) { result.covered++; continue; } let path = directPath(anchor, target, { maxLength: d * 1.35 + 18, terrain, maxSeaRun: 20, maxTunnelRun: 10 }); if (!path.length) path = directPath(anchor, target, { maxLength: d * 1.75 + 34, terrain, maxSeaRun: 20, maxTunnelRun: 10 }); if (!path.length) path = routeTerrainPath(anchor, target, terrain, { maxLength: d * 2.9 + 64, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 2.5 }); if (!path.length) { const cityTargets = cities .filter((other) => other !== city) .map((other) => { const otherAnchor = suburbanExpresswayAnchorForCity(other, anchor); return otherAnchor ? { other, otherAnchor, d: Math.hypot(otherAnchor.x - anchor.x, otherAnchor.y - anchor.y) } : null; }) .filter(Boolean) .filter((row) => row.d >= 16 && row.d <= 185) .sort((a, b) => a.d - b.d); for (const row of cityTargets.slice(0, 6)) { let candidate = directPath(anchor, row.otherAnchor, { maxLength: row.d * 1.6 + 26, terrain, maxSeaRun: 20, maxTunnelRun: 10 }); if (!candidate.length) candidate = routeTerrainPath(anchor, row.otherAnchor, terrain, { maxLength: row.d * 2.9 + 70, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 2.5 }); if (candidate.length >= 4) { path = candidate; break; } } } if (!path.length) path = suburbanExpresswayStubForCity(city, anchor); if (!path.length || pathLengthCells(path) < 4) { result.noPath++; continue; } features.expressways.push(smoothPath(path, 1)); result.added++; } return result; } function cityRailChain(minPopulation = 50000) { const cities = (features.modernCities || []) .filter((city) => (city.population || 0) >= minPopulation && inside(city.x, city.y)) .sort((a, b) => a.x - b.x || a.y - b.y); const result = { minPopulation, checked: cities.length, chainsAdded: 0, citiesCovered: 0 }; if (cities.length < 2) return result; const existingRail = [...(features.railways || []), ...(features.branchRailways || []), ...(features.externalRailways || [])]; const uncovered = cities.filter((city) => !anyPathTouches(existingRail, city, 1.2)); if (!uncovered.length) return result; const remaining = uncovered.slice(); let chain = [remaining.shift()]; while (remaining.length) { const cur = chain[chain.length - 1]; let bestIndex = 0; let bestD = Infinity; for (let i = 0; i < remaining.length; i++) { const d = Math.hypot(cur.x - remaining[i].x, cur.y - remaining[i].y); if (d < bestD) { bestD = d; bestIndex = i; } } chain.push(remaining.splice(bestIndex, 1)[0]); } const parts = []; for (let i = 1; i < chain.length; i++) { const a = chain[i - 1], b = chain[i]; const d = Math.hypot(a.x - b.x, a.y - b.y); let p = directPath(a, b, { maxLength: d * 1.55 + 20, terrain, maxSeaRun: 10, maxTunnelRun: 10 }); if (!p.length) p = directPath(a, b, { maxLength: d * 1.85 + 40, terrain, maxSeaRun: 10, maxTunnelRun: 10 }); if (p.length) parts.push(p); } const path = concatPaths(parts); if (path.length >= 2) { features.railways = features.railways || []; features.railways.push(smoothPath(path, 1)); result.chainsAdded = 1; result.citiesCovered = chain.filter((city) => pathTouchesCell(path, city.x, city.y, 1.2)).length; } return result; } const railDebug = cityRailChain(50000); debug.railCityChainsAdded = railDebug.chainsAdded; debug.railCityChainCitiesCovered = railDebug.citiesCovered; const expressDebug = ensureMajorCityExpresswayLinks(100000); debug.expresswayMajorCityLinksAdded = expressDebug.added; debug.expresswayMajorCityLinksCovered = expressDebug.covered; debug.expresswayMajorCityLinksNoTarget = expressDebug.noTarget; debug.expresswayMajorCityLinksNoPath = expressDebug.noPath; // Expressway finalization after administration: smooth and ensure both endpoints are ICs. for (let i = 0; i < expressways.length; i++) { const smoothed = smoothPath(expressways[i], 2); if (smoothed.length >= 2) { const runs = pathTerrainRuns(smoothed, terrain); if (runs.maxTunnelRun <= 10 && runs.maxSeaRun <= 20) { expressways[i] = smoothed; debug.expresswaysSmoothed++; } } } const expresswayBeforeTerrainPrune = expressways.length; for (let i = expressways.length - 1; i >= 0; i--) { const runs = pathTerrainRuns(expressways[i], terrain); if (runs.maxTunnelRun > 10 || runs.maxSeaRun > 20) expressways.splice(i, 1); } debug.expresswaysPrunedForBridgeTunnelLimits = expresswayBeforeTerrainPrune - expressways.length; function pointInsideCityNodeBuffer(x, y) { for (const city of features.modernCities || []) { if (!city || (city.population || 0) < 25000) continue; const r = Math.max(4.2, (city.coreRadius || 3) + 1.6); if (Math.hypot(x - city.x, y - city.y) <= r) return true; } return false; } function splitExpresswayAwayFromCityNodes(path) { const chunks = []; let cur = []; for (const [x, y] of path || []) { if (pointInsideCityNodeBuffer(x, y)) { if (cur.length >= 2) chunks.push(cur); cur = []; continue; } if (!cur.length || cur[cur.length - 1][0] !== x || cur[cur.length - 1][1] !== y) cur.push([x, y]); } if (cur.length >= 2) chunks.push(cur); return chunks.filter((chunk) => pathLengthCells(chunk) >= 12); } const expresswayBeforeCityNodePrune = expressways.length; const separatedExpressways = []; for (const path of expressways) separatedExpressways.push(...splitExpresswayAwayFromCityNodes(path)); expressways.length = 0; expressways.push(...dedupePaths(separatedExpressways, 2)); debug.expresswaysPrunedForCityNodeSeparation = expresswayBeforeCityNodePrune - expressways.length; function connectNearbyExpresswayTermini() { const result = { candidates: 0, added: 0, failed: 0 }; const expressGroups = [ { key: "expressway", paths: expressways }, { key: "externalExpressway", paths: externalExpressways }, ]; const endpoints = []; for (const group of expressGroups) { for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) { const path = group.paths[pathIdx]; if (!path || path.length < 2) continue; for (const end of [0, 1]) { const raw = end === 0 ? path[0] : path[path.length - 1]; const x = Math.round(raw[0]), y = Math.round(raw[1]); if (!inside(x, y) || terrain?.sea?.[indexOf(x, y)] || pointInsideCityNodeBuffer(x, y)) continue; endpoints.push({ group: group.key, pathIdx, end, x, y }); } } } const pairs = []; for (let i = 0; i < endpoints.length; i++) { const a = endpoints[i]; for (let j = i + 1; j < endpoints.length; j++) { const b = endpoints[j]; if (a.group === b.group && a.pathIdx === b.pathIdx) continue; const d = Math.hypot(a.x - b.x, a.y - b.y); if (d < 3.0 || d > 24.0) continue; pairs.push({ a, b, d, kind: "terminus-terminus" }); } } // Also snap a dead-end to the side of a nearby expressway if no terminal is // close enough. This removes visible half-built expressway stubs without // requiring every segment to be merged into a single polyline. for (const a of endpoints) { let best = null; for (const group of expressGroups) { for (let pathIdx = 0; pathIdx < (group.paths?.length || 0); pathIdx++) { if (a.group === group.key && a.pathIdx === pathIdx) continue; const path = group.paths[pathIdx]; for (let k = 1; k < (path?.length || 0) - 1; k += 2) { const [x, y] = path[k]; const d = Math.hypot(a.x - x, a.y - y); if (d < 3.0 || d > 14.0) continue; if (!best || d < best.d) best = { a, b: { group: group.key, pathIdx, end: -1, x, y }, d, kind: "terminus-side" }; } } } if (best) pairs.push(best); } pairs.sort((a, b) => a.d - b.d || (a.kind === "terminus-terminus" ? -1 : 1)); const used = new Set(); for (const pair of pairs) { if (result.added >= 10) break; const ak = `${pair.a.group}:${pair.a.pathIdx}:${pair.a.end}`; const bk = `${pair.b.group}:${pair.b.pathIdx}:${pair.b.end}`; if (used.has(ak) || (pair.b.end >= 0 && used.has(bk))) continue; result.candidates++; let path = directPath(pair.a, pair.b, { maxLength: pair.d * 1.65 + 18, terrain, maxSeaRun: 20, maxTunnelRun: 10 }); if (!path.length) path = routeTerrainPath(pair.a, pair.b, terrain, { maxLength: pair.d * 2.6 + 44, maxSeaRun: 0, maxTunnelRun: 10, snapRadius: 1.8 }); if (!path.length || pathLengthCells(path) < 3) { result.failed++; continue; } const runs = pathTerrainRuns(path, terrain); if (runs.maxTunnelRun > 10 || runs.maxSeaRun > 20) { result.failed++; continue; } expressways.push(smoothPath(path, 1)); used.add(ak); if (pair.b.end >= 0) used.add(bk); result.added++; } return result; } const expresswayTerminusConnectDebug = connectNearbyExpresswayTermini(); debug.expresswayTerminusConnectionsAdded = expresswayTerminusConnectDebug.added; debug.expresswayTerminusConnectionCandidates = expresswayTerminusConnectDebug.candidates; debug.expresswayTerminusConnectionFailures = expresswayTerminusConnectDebug.failed; function pointOnExpressway(p, radius = 1.5) { return (expressways || []).some((path) => pathTouchesCell(path, p.x, p.y, radius)); } const icBeforePrune = interchanges.length; const pairedInterchanges = []; const pairedAccessRoads = []; for (let i = 0; i < interchanges.length; i++) { const ic = interchanges[i]; const access = (features.icAccessRoads || [])[i]; if (ic && pointOnExpressway(ic, 1.8) && access && access.length >= 2) { pairedInterchanges.push(ic); pairedAccessRoads.push(access); } } interchanges.length = 0; interchanges.push(...pairedInterchanges); features.icAccessRoads = pairedAccessRoads; debug.interchangesPrunedWithoutExpresswayOrAccess = icBeforePrune - interchanges.length; function ensureTerminalInterchangesWithAccess() { const result = { endpointsChecked: 0, added: 0, accessAdded: 0, withoutAccess: 0 }; features.icAccessRoads ||= []; const ordinaryRoads = [...(features.nationalRoads || []), ...(features.externalRoads || []), ...(features.minorRoads || [])]; for (const path of [...(features.expressways || []), ...(features.externalExpressways || [])]) { if (!path || path.length < 2) continue; for (const raw of [path[0], path[path.length - 1]]) { const p = { x: Math.round(raw[0]), y: Math.round(raw[1]) }; result.endpointsChecked++; if (!inside(p.x, p.y) || terrain?.sea?.[indexOf(p.x, p.y)]) continue; if ((interchanges || []).some((ic) => Math.hypot(ic.x - p.x, ic.y - p.y) <= 2.8)) continue; const hit = nearestPointOnPaths(ordinaryRoads, p, 58); let access = []; if (hit) { const d = Math.hypot(p.x - hit.x, p.y - hit.y); access = directPath(p, hit, { maxLength: d * 1.75 + 18, terrain, maxSeaRun: 0, maxTunnelRun: 8 }); if (access.length >= 2) { features.icAccessRoads.push(access); features.minorRoads ||= []; features.minorRoads.push(access); result.accessAdded++; } } addInterchange(interchanges, p.x, p.y, access.length >= 2 ? "post-admin-terminal-ic" : "post-admin-terminal-ic-no-access"); result.added++; if (access.length < 2) result.withoutAccess++; } } return result; } const terminalIcDebug = ensureTerminalInterchangesWithAccess(); debug.expresswayTerminalInterchangesAdded = terminalIcDebug.added; debug.expresswayTerminalInterchangeAccessAdded = terminalIcDebug.accessAdded; debug.expresswayTerminalInterchangesWithoutAccess = terminalIcDebug.withoutAccess; features.minorRoads = dedupePaths(minorRoads, 2); features.nationalRoads = dedupePaths(nationalRoads, 1); features.externalRoads = dedupePaths(externalRoads, 1); features.expressways = dedupePaths(expressways, 2); features.externalExpressways = dedupePaths(externalExpressways, 2); features.railways = dedupePaths(features.railways || [], 2); features.interchanges = interchanges; // Final invariant: after all dedupe passes, every municipal office cell has at least a local road cell on it. let finalAdminStubsAdded = 0; for (const center of adminCenters || []) { if (!center || !inside(center.x, center.y)) continue; if (anyPathTouches([...features.minorRoads, ...features.nationalRoads, ...features.externalRoads], center, 0.65)) continue; const x = Math.round(center.x), y = Math.round(center.y); const candidates = [ [{ x: Math.max(0, x - 1), y }, { x, y }, { x: Math.min(MAP_W - 1, x + 1), y }], [{ x, y: Math.max(0, y - 1) }, { x, y }, { x, y: Math.min(MAP_H - 1, y + 1) }], ]; const stub = candidates .map((cand) => cand.map((p) => [p.x, p.y]).filter(([px, py], idx, arr) => idx === 0 || px !== arr[idx - 1][0] || py !== arr[idx - 1][1])) .find((p) => p.length >= 2) || [[x, y], [Math.min(MAP_W - 1, x + 1), y]]; features.minorRoads.push(stub); finalAdminStubsAdded++; } debug.finalAdminStubsAdded = finalAdminStubsAdded; features.roadInfluence = rebuildInfluence([...features.nationalRoads, ...(features.ringRoads || []), ...features.externalRoads, ...features.minorRoads], 5.0); features.roadDensityInfluence = rebuildInfluence([...features.nationalRoads, ...(features.ringRoads || []), ...features.externalRoads, ...features.minorRoads], 9.0); features.transportDebug = { ...(features.transportDebug || {}), generationOrder: debug.order, postAdminTransportFinalization: debug, }; return features; }