import { MAP_W, MAP_H, SIZE, indexOf, inside } 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; for (const [x, y] of path || []) { if (!inside(x, y)) continue; const i = indexOf(x, y); const isSea = Boolean(sea?.[i]); const isTunnel = !isSea && (((elevation?.[i] || 0) >= 0.74 && (ridgeField?.[i] || 0) >= 0.46) || (naturalBarrierScore?.[i] || 0) >= 0.82); if (isSea) { seaRun++; maxSeaRun = Math.max(maxSeaRun, seaRun); } else seaRun = 0; if (isTunnel) { tunnelRun++; maxTunnelRun = Math.max(maxTunnelRun, tunnelRun); } else tunnelRun = 0; } return { maxSeaRun, maxTunnelRun }; } 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 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 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(); const chain = buildTownChain(start, uncovered, 7); uncovered = uncovered.filter((town) => !chain.includes(town)); const parts = []; const before = nearestTrunkOrHub(chain[0], 80); 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 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); } const after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null; 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 && 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 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, added: 0 }; if (cities.length < 2) return result; for (let iter = 0; iter < cities.length * 2; iter++) { const comps = expresswayCityComponents(cities); if (new Set(comps.values()).size <= 1) break; let best = null; for (const a of cities) { for (const b of cities) { if (a === b || comps.get(majorCityKey(a)) === comps.get(majorCityKey(b))) continue; const d = Math.hypot(a.x - b.x, a.y - b.y); const score = d / Math.max(1, Math.log2(Math.sqrt((a.population || 1) * (b.population || 1)))); if (!best || score < best.score) best = { a, b, d, score }; } } if (!best) break; let path = directPath(best.a, best.b, { maxLength: best.d * 1.18 + 12, terrain, maxSeaRun: 20 }); if (!path.length) path = directPath(best.a, best.b, { maxLength: best.d * 1.38 + 28, terrain }); if (!path.length) break; path = smoothPath(path, 2); expressways.push(path); 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 }); 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; // 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) { expressways[i] = smoothed; debug.expresswaysSmoothed++; } } for (const path of [...expressways, ...externalExpressways]) { if (!path || path.length < 2) continue; const a = path[0]; const b = path[path.length - 1]; if (addInterchange(interchanges, a[0], a[1])) debug.expresswayEndpointInterchangesAdded++; if (addInterchange(interchanges, b[0], b[1])) debug.expresswayEndpointInterchangesAdded++; } 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; }