import { INF, MAP_H, MAP_W, SIZE, clamp, hash2, indexOf, inside, pickEntities } from "./mapUtils.js"; import { makeUnionFind, pathAverageField, pathLengthCells } from "./mapTransportUtils.js"; // Phase 3: unified OD rail model // -------------------------------- // Rail is no longer generated from isolated potential-field strokes. It is // derived from a single transport-node model: major cities, prefectural seats, // ports, large market towns, and external gateways create OD demand; MST-style // connectivity gives the skeleton; high-demand pairs add loops; lower-tier // settlements receive short branch connections only when the trunk is nearby. export function buildUnifiedRailODNetwork(ctx) { const { seed, sea, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, naturalBarrierScore, passSuitability, transportFields, settlementDemand, preliminaryUrbanInfluence, preliminaryTownInfluence, preliminaryVillageInfluence, modernCities, markets, ports, commercialPorts, externalGateways, geographicUrbanAnchors = [], regionIdAt, routeBetweenTrafficCandidates, addCorridorInfluencePenalty, transportRouteAcceptable, pruneParallelSameMode, cachedInfluenceFromPaths, } = ctx; const railways = []; const branchRailways = []; const debug = { version: "phase3-unified-rail-od-v1", strategy: "OD nodes -> MST trunk -> demand loops -> short branches", nodeCounts: {}, trunkPairsConsidered: 0, trunkPairsRouted: 0, loopPairsRouted: 0, branchPairsRouted: 0, rejected: {}, nodes: [], trunkCorridors: [], loopCorridors: [], branchCorridors: [], parallelPruning: null, }; const reject = (reason) => { debug.rejected[reason] = (debug.rejected[reason] || 0) + 1; }; function fieldValue(field, i, fallback = 0) { const v = field?.[i]; return Number.isFinite(v) ? v : fallback; } function railCostAt(x, y) { if (!inside(x, y)) return INF; const i = indexOf(x, y); return sea[i] ? INF : transportFields.rail[i]; } function populationProxy(p, fallback = 12000) { if (!p) return fallback; if (Number.isFinite(p.population) && p.population > 0) return p.population; if (p.portClass === "major") return 85000; if (p.portClass === "regional") return 42000; const i = inside(p.x, p.y) ? indexOf(p.x, p.y) : -1; if (i < 0) return fallback; return Math.max(fallback, Math.round( fieldValue(preliminaryUrbanInfluence, i) * 160000 + fieldValue(preliminaryTownInfluence, i) * 65000 + fieldValue(preliminaryVillageInfluence, i) * 15000 + fieldValue(settlementDemand, i) * 45000 )); } function nearbyRailAnchor(point, role = "rail-node", options = {}) { if (!point || !inside(point.x, point.y)) return null; const inner = options.inner ?? 0; const outer = options.outer ?? (role.includes("city") || role.includes("capital") ? 7 : role.includes("port") ? 8 : 5); let best = null; for (let dy = -outer; dy <= outer; dy++) { for (let dx = -outer; dx <= outer; dx++) { const x = Math.round(point.x + dx); const y = Math.round(point.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] || transportFields.rail[i] >= INF) continue; const density = fieldValue(settlementDemand, i); const terrain = fieldValue(transportFields.railPotential, i) * 1.35 + fieldValue(preliminaryUrbanInfluence, i) * 0.42 + fieldValue(preliminaryTownInfluence, i) * 0.46 + valleyField[i] * 0.28 + basinField[i] * 0.20 + coastalLowland[i] * 0.20 + plain[i] * 0.16 - slope[i] * 1.10 - ridgeField[i] * 0.72 - Math.max(0, elevation[i] - 0.58) * 1.45 - Math.max(0, density - 0.78) * 0.32; const centerPenalty = d * (role.includes("city") || role.includes("capital") ? 0.025 : 0.060); const score = terrain - centerPenalty + hash2(x, y, seed + 23101 + point.x * 3 + point.y * 7) * 0.035; if (!best || score > best.score) best = { x, y, score, role, source: point, regionId: regionIdAt(x, y), population: populationProxy(point), name: point.name, kind: point.kind, portClass: point.portClass, }; } } return best; } function dedupeNodes(nodes, minDistance = 5.5) { const sorted = nodes .filter((p) => p && inside(p.x, p.y) && !sea[indexOf(p.x, p.y)] && railCostAt(p.x, p.y) < INF) .sort((a, b) => (b.score || 0) - (a.score || 0)); const out = []; for (const p of sorted) { if (out.every((q) => Math.hypot(q.x - p.x, q.y - p.y) >= minDistance)) out.push(p); } return out; } function lineStats(a, b, costField = transportFields.rail) { const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y))); let cost = 0; let barrier = 0; let high = 0; let seaHits = 0; let n = 0; 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)) { seaHits++; n++; continue; } const i = indexOf(x, y); if (sea[i] || costField[i] >= INF) { seaHits++; cost += 8; barrier += 1; n++; continue; } cost += costField[i]; barrier += clamp( fieldValue(naturalBarrierScore, i) * 0.80 + ridgeField[i] * 0.36 + slope[i] * 0.42 + Math.max(0, elevation[i] - 0.58) * 0.65 - fieldValue(passSuitability, i) * 0.42 - valleyField[i] * 0.12 ); if (elevation[i] > 0.68 || slope[i] > 0.48) high++; n++; } return { avgCost: n ? cost / n : INF, barrier: n ? barrier / n : 1, highShare: n ? high / n : 1, seaShare: n ? seaHits / n : 1, }; } function odDemand(a, b, mode = "trunk") { const d = Math.hypot(a.x - b.x, a.y - b.y); const popDemand = Math.sqrt(Math.max(5000, a.population || 0) * Math.max(5000, b.population || 0)); const roleBonus = (a.role?.includes("capital") || b.role?.includes("capital") ? 0.24 : 0) + (a.role?.includes("regional") || b.role?.includes("regional") ? 0.18 : 0) + (a.role?.includes("port") || b.role?.includes("port") ? 0.14 : 0) + (a.role?.includes("external") || b.role?.includes("external") ? 0.20 : 0); const distanceBand = mode === "branch" ? clamp(1 - Math.abs(d - 24) / 34) : clamp(1 - Math.abs(d - 58) / 74); return popDemand / (mode === "branch" ? 95000 : 145000) + roleBonus + distanceBand * (mode === "branch" ? 0.18 : 0.28); } function pairScore(a, b, mode = "trunk") { const d = Math.hypot(a.x - b.x, a.y - b.y); const stats = lineStats(a, b); if (stats.seaShare > 0.06) return null; if (stats.highShare > (mode === "branch" ? 0.22 : 0.16)) return null; const demand = odDemand(a, b, mode); const crossRegion = a.regionId !== b.regionId ? 0.10 : 0; const barrierPenalty = 1 + stats.barrier * (mode === "branch" ? 1.15 : 1.45) + stats.avgCost * 0.30 + stats.seaShare * 4.0; const score = d * barrierPenalty / Math.max(0.18, demand + crossRegion); return { a, b, d, demand, stats, score }; } function routeRailPair(pair, penalty, branch = false) { const path = routeBetweenTrafficCandidates(pair.a, pair.b, "rail", transportFields.rail, penalty, { curvePenalty: branch ? 0.135 : 0.150, penaltyStrength: branch ? 0.92 : 1.28, terrainFlowBias: branch ? 0.16 : 0.13, surfaceGrain: 0.010, relaxRadius: 1, relaxLineWeight: branch ? 0.44 : 0.50, snapRadius: branch ? 2.4 : 2.8, searchPad: Math.ceil(Math.max(22, Math.min(68, pair.d * 0.48))), maxPathLength: pair.d * (branch ? 2.28 : 2.48) + (branch ? 18 : 36), maxSeaRun: 1, maxSeaShare: 0.006, }); const len = pathLengthCells(path); if (len < (branch ? 5 : 14)) { reject(branch ? "branchTooShort" : "trunkTooShort"); return []; } if (len > pair.d * (branch ? 2.36 : 2.58) + (branch ? 24 : 42)) { reject(branch ? "branchTooLong" : "trunkTooLong"); return []; } if (!transportRouteAcceptable(path, "rail", transportFields.railPotential, penalty, { minLength: branch ? 4 : 12, maxLength: pair.d * (branch ? 2.40 : 2.66) + (branch ? 26 : 46), maxCompactness: branch ? 2.85 : 2.65, maxSteepShare: branch ? 0.24 : 0.20, maxHighElevationShare: branch ? 0.04 : 0.02, minAvgPotential: branch ? 0.06 : 0.10, })) { reject(branch ? "branchQuality" : "trunkQuality"); return []; } if (pathAverageField(path, transportFields.railPotential) < (branch ? 0.08 : 0.13) && pair.d > 24) { reject(branch ? "branchLowPotential" : "trunkLowPotential"); return []; } return path; } function keyOf(p) { return `${p.x},${p.y}`; } const regionalCityNodes = modernCities .filter((c) => c.isRegionalCapital || c.isPrefecturalCapital || (c.population || 0) >= 90000) .map((c) => nearbyRailAnchor(c, c.isRegionalCapital ? "regional-capital-rail" : c.isPrefecturalCapital ? "prefectural-capital-rail" : "major-city-rail", { outer: 8 })) .filter(Boolean); const secondaryCityNodes = modernCities .filter((c) => !regionalCityNodes.some((n) => n.source === c) && (c.population || 0) >= 38000) .map((c) => nearbyRailAnchor(c, "secondary-city-rail", { outer: 7 })) .filter(Boolean); const portNodes = [...commercialPorts, ...ports] .filter((p, idx, arr) => arr.findIndex((q) => q.x === p.x && q.y === p.y) === idx) .filter((p) => p.portClass === "major" || p.portClass === "regional" || (p.population || 0) >= 16000) .map((p) => nearbyRailAnchor(p, "port-rail", { outer: 8 })) .filter(Boolean); const externalNodes = externalGateways .map((g) => nearbyRailAnchor({ ...g, population: 60000 }, "external-rail-gateway", { outer: 5 })) .filter(Boolean); const anchorNodes = geographicUrbanAnchors .filter((a) => (a.score || 0) > 0.76) .slice(0, 8) .map((a) => nearbyRailAnchor({ ...a, population: a.population || 52000 }, "geo-anchor-rail", { outer: 6 })) .filter(Boolean); let trunkNodes = dedupeNodes([...regionalCityNodes, ...secondaryCityNodes, ...portNodes, ...externalNodes, ...anchorNodes], 7.5) .sort((a, b) => (b.population || 0) - (a.population || 0)) .slice(0, 34); if (trunkNodes.length < 2) { trunkNodes = dedupeNodes([ ...modernCities.map((c) => nearbyRailAnchor(c, "fallback-city-rail", { outer: 8 })), ...markets.filter((m) => (m.population || 0) >= 14000).map((m) => nearbyRailAnchor(m, "fallback-market-rail", { outer: 5 })), ], 7).slice(0, 18); } debug.nodeCounts = { regionalCityNodes: regionalCityNodes.length, secondaryCityNodes: secondaryCityNodes.length, portNodes: portNodes.length, externalNodes: externalNodes.length, geographicAnchorNodes: anchorNodes.length, trunkNodes: trunkNodes.length, }; debug.nodes = trunkNodes.map((n) => ({ x: n.x, y: n.y, role: n.role, population: n.population, regionId: n.regionId })); const trunkPairs = []; for (let a = 0; a < trunkNodes.length; a++) { for (let b = a + 1; b < trunkNodes.length; b++) { const A = trunkNodes[a]; const B = trunkNodes[b]; const d = Math.hypot(A.x - B.x, A.y - B.y); if (d < 16 || d > 150) { reject("trunkDistanceEnvelope"); continue; } const pair = pairScore(A, B, "trunk"); if (!pair) { reject("trunkLineStats"); continue; } trunkPairs.push(pair); } } trunkPairs.sort((a, b) => a.score - b.score); debug.trunkPairsConsidered = trunkPairs.length; const uf = makeUnionFind(trunkNodes, keyOf); const penalty = new Float32Array(SIZE); const maxTrunk = Math.min(18, Math.max(4, trunkNodes.length - 1)); let connectedEdges = 0; for (const pair of trunkPairs) { if (connectedEdges >= maxTrunk) break; const ak = keyOf(pair.a); const bk = keyOf(pair.b); if (uf.find(ak) === uf.find(bk)) continue; const path = routeRailPair(pair, penalty, false); if (!path.length) continue; railways.push(path); addCorridorInfluencePenalty(penalty, path, 9, 0.74); uf.unite(ak, bk); connectedEdges++; debug.trunkPairsRouted++; debug.trunkCorridors.push({ from: pair.a.role, to: pair.b.role, distance: Math.round(pair.d), demand: Math.round(pair.demand * 100) / 100, length: Math.round(pathLengthCells(path)), path }); } // Add a few loops / redundant high-demand links after MST. These are the // Shinkansen/main-line analogues around dense corridors and port approaches. let loopAdded = 0; const trunkInfluenceBeforeLoops = cachedInfluenceFromPaths(railways, 5, "rail-od:trunk-before-loops"); for (const pair of trunkPairs) { if (loopAdded >= Math.min(7, Math.max(2, Math.ceil(trunkNodes.length / 5)))) break; const ai = indexOf(pair.a.x, pair.a.y); const bi = indexOf(pair.b.x, pair.b.y); if ((trunkInfluenceBeforeLoops[ai] || 0) > 0.62 && (trunkInfluenceBeforeLoops[bi] || 0) > 0.62 && pair.demand < 0.88) continue; if (pair.score > 92 && pair.demand < 0.78) continue; const path = routeRailPair(pair, penalty, false); if (!path.length) continue; railways.push(path); addCorridorInfluencePenalty(penalty, path, 11, 0.66); loopAdded++; debug.loopPairsRouted++; debug.loopCorridors.push({ from: pair.a.role, to: pair.b.role, distance: Math.round(pair.d), demand: Math.round(pair.demand * 100) / 100, length: Math.round(pathLengthCells(path)), path }); } const railInfluence = cachedInfluenceFromPaths(railways, 8, "rail-od:trunk-for-branches"); const branchCandidates = dedupeNodes([ ...modernCities .filter((c) => (c.population || 0) >= 22000 && (c.population || 0) < 90000) .map((c) => nearbyRailAnchor(c, "branch-city-rail", { outer: 6 })), ...markets .filter((m) => (m.population || 0) >= 16000) .map((m) => nearbyRailAnchor(m, "branch-market-rail", { outer: 5 })), ...ports .filter((p) => p.portClass === "regional" || (p.population || 0) >= 10000) .map((p) => nearbyRailAnchor(p, "branch-port-rail", { outer: 7 })), ], 6).sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 36); const trunkTargets = []; for (const path of railways) { const stride = Math.max(5, Math.floor(path.length / 18)); for (let k = 0; k < path.length; k += stride) { const [x, y] = path[k]; if (inside(x, y) && !sea[indexOf(x, y)]) trunkTargets.push({ x, y, role: "rail-trunk-cell", population: 70000, score: 0.8, regionId: regionIdAt(x, y) }); } } trunkTargets.push(...trunkNodes); let branchAdded = 0; for (const node of branchCandidates) { if (branchAdded >= 14) break; const ni = indexOf(node.x, node.y); if ((railInfluence[ni] || 0) > 0.34) continue; const options = trunkTargets .map((q) => { const d = Math.hypot(q.x - node.x, q.y - node.y); if (d < 8 || d > 54) return null; const pair = pairScore(node, q, "branch"); if (!pair) return null; return pair; }) .filter(Boolean) .sort((a, b) => a.score - b.score); for (const pair of options.slice(0, 5)) { if (odDemand(pair.a, pair.b, "branch") < 0.34 && pair.d > 32) { reject("branchWeakDemand"); continue; } const path = routeRailPair(pair, penalty, true); if (!path.length) continue; branchRailways.push(path); addCorridorInfluencePenalty(penalty, path, 6, 0.42); branchAdded++; debug.branchPairsRouted++; debug.branchCorridors.push({ from: pair.a.role, to: pair.b.role, distance: Math.round(pair.d), demand: Math.round(pair.demand * 100) / 100, length: Math.round(pathLengthCells(path)), path }); break; } } debug.parallelPruning = pruneParallelSameMode([...railways, ...branchRailways], "rail", transportFields.railPotential, { minKeep: Math.min(3, railways.length), radius: 2, threshold: 0.62, shortLength: 24, }); // pruneParallelSameMode mutates only the temporary array above, so repeat a // conservative in-place pass per layer to preserve trunk/branch classification. debug.trunkParallelPruning = pruneParallelSameMode(railways, "rail", transportFields.railPotential, { minKeep: 2, radius: 2, threshold: 0.66, shortLength: 30, }); debug.branchParallelPruning = pruneParallelSameMode(branchRailways, "rail", transportFields.railPotential, { minKeep: 0, radius: 2, threshold: 0.70, shortLength: 18, }); debug.finalRailwayCount = railways.length; debug.finalBranchRailwayCount = branchRailways.length; return { railways, branchRailways, debug }; }