import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, fbm, hash2, indexOf, inside, nearMapEdge, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js"; import { aStar, averagePathField, compactPathArray, distanceToNearest, getDegree, incrementDegree, influenceFromPaths, influenceFromPoints, makeTransportCost, nearestConnectable, neighbors8, pathCompactness, pathEndpointDistance, pathLength, pathOverlapRatio, samplePath, smoothPathByLineOfSight, } from "./mapGeneratorHelpers.js"; export function generateMapFeatures(seed, terrain) { const { elevation, moisture, slope, sea, river, floodplain, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, portSuitability, crossingSuitability, passSuitability, prefectureMask, } = terrain; function pickPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true }) { const candidates = []; for (let y = 2; y < MAP_H - 2; y++) { for (let x = 2; x < MAP_W - 2; x++) { const i = indexOf(x, y); if (!predicate(x, y, i)) continue; const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.08; if (score >= threshold) candidates.push({ x, y, score }); } } return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset }); } let ports = pickPoints(portSuitability, { threshold: 0.3 + rand(seed, 1001) * 0.08, max: 3 + Math.floor(rand(seed, 1002) * 7), minDistance: 10, seedOffset: 1000, predicate: (x, y, i) => !sea[i], }).map((p) => { const i = indexOf(p.x, p.y); let seaEdge = 0; for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) { const nx = p.x + dx; const ny = p.y + dy; if (inside(nx, ny) && sea[indexOf(nx, ny)]) seaEdge += 1 / (1 + Math.hypot(dx, dy)); } const harborPotential = p.score + coastalLowland[i] * 0.28 + river[i] * 0.08 + seaEdge * 0.025 - slope[i] * 0.2; return { ...p, harborPotential, seaEdge, portClass: "fishing", kind: "Fishing Port" }; }).sort((a, b) => b.harborPotential - a.harborPotential) .map((p, n) => { const isLakeLike = p.seaEdge < 0.25 && river[indexOf(p.x, p.y)] > 0.32; const portClass = isLakeLike ? "lake" : n === 0 ? "major" : n < 3 && p.harborPotential > 0.34 ? "regional" : "fishing"; const kind = portClass === "major" ? "Major Port" : portClass === "regional" ? "Regional Port" : portClass === "lake" ? "Lake Port" : "Fishing Port"; return { ...p, portClass, kind, score: p.harborPotential }; }); if (!ports.some((p) => p.portClass === "major")) { const fallbackMajor = ports.find((p) => p.portClass !== "lake") || ports[0]; if (fallbackMajor) { fallbackMajor.portClass = "major"; fallbackMajor.kind = "Major Port"; fallbackMajor.score += 0.16; } } const majorPorts = ports.filter((p) => p.portClass === "major"); const commercialPorts = ports.filter((p) => p.portClass === "major" || p.portClass === "regional"); let crossings = pickPoints(crossingSuitability, { threshold: 0.28 + rand(seed, 1011) * 0.08, max: 8 + Math.floor(rand(seed, 1012) * 15), minDistance: 8, seedOffset: 1010, predicate: (x, y, i) => !sea[i], }).map((p) => ({ ...p, kind: "River Crossing" })); let passes = pickPoints(passSuitability, { threshold: 0.16 + rand(seed, 1021) * 0.08, max: 4 + Math.floor(rand(seed, 1022) * 10), minDistance: 9, seedOffset: 1020, predicate: (x, y, i) => !sea[i], }).map((p) => ({ ...p, kind: "Pass" })); const settlementCluster = new Float32Array(SIZE); for (let y = 2; y < MAP_H - 2; y++) { for (let x = 2; x < MAP_W - 2; x++) { const i = indexOf(x, y); if (sea[i]) continue; const valleyCorridor = clamp(valleyField[i] * 0.62 + river[i] * 0.16); const lowlandCorridor = clamp(coastalLowland[i] * 0.38 + basinField[i] * 0.34 + plain[i] * 0.24 + agriculture[i] * 0.18); const terrainGate = clamp(1.0 - slope[i] * 1.18 - ridgeField[i] * 0.52 - Math.max(0, elevation[i] - 0.62) * 1.35, 0.08, 1); const localPatch = valueNoise(x * 0.7, y * 0.7, seed + 1037, 10); const broadPatch = fbm(x * 0.32 + 71, y * 0.32 - 19, seed + 1038); settlementCluster[i] = clamp((valleyCorridor + lowlandCorridor) * terrainGate * (0.72 + broadPatch * 0.42 + localPatch * 0.18)); } } const settlementScore = new Float32Array(SIZE); for (let y = 2; y < MAP_H - 2; y++) { for (let x = 2; x < MAP_W - 2; x++) { const i = indexOf(x, y); if (sea[i]) continue; let nearFeature = 0; for (const p of [...ports, ...crossings, ...passes]) nearFeature = Math.max(nearFeature, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 4)); const riverPull = Math.min(0.32, river[i] * 0.14 + valleyField[i] * 0.16); const mountainVillage = valleyField[i] * clamp(elevation[i] - 0.42, 0, 0.3) * 0.52; const remoteMountainPenalty = Math.max(0, elevation[i] - 0.58) * Math.max(0, ridgeField[i] - 0.22) * (1 - valleyField[i]) * 0.75; const base = agriculture[i] * 0.50 + plain[i] * 0.14 + nearFeature * 0.23 + riverPull + basinField[i] * 0.13 + coastalLowland[i] * 0.08 + mountainVillage - slope[i] * 0.48 - ridgeField[i] * 0.24 - floodplain[i] * 0.06 - remoteMountainPenalty; settlementScore[i] = clamp(base * (0.74 + settlementCluster[i] * 0.66) + settlementCluster[i] * 0.13); } } let villages = pickPoints(settlementScore, { threshold: 0.32 + rand(seed, 1031) * 0.1, max: 28 + Math.floor(rand(seed, 1032) * 44), minDistance: 3 + Math.floor(rand(seed, 1033) * 3), seedOffset: 1030, predicate: (x, y, i) => !sea[i], }).map((p) => ({ ...p, kind: "Village" })); const marketScore = new Float32Array(SIZE); for (let y = 4; y < MAP_H - 4; y++) { for (let x = 4; x < MAP_W - 4; x++) { const i = indexOf(x, y); if (sea[i]) continue; let villagePull = 0; let nearbyVillages = 0; for (const v of villages) { const d = Math.hypot(x - v.x, y - v.y); if (d < 24) { villagePull += 1 / (1 + d); nearbyVillages++; } } let featurePull = 0; for (const p of [...ports, ...crossings]) featurePull = Math.max(featurePull, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 3)); const confluence = river[i] > 0.36 && valleyField[i] > 0.24 ? 0.12 : 0; marketScore[i] = clamp(villagePull * 1.25 + featurePull * 0.34 + plain[i] * 0.2 + basinField[i] * 0.16 + confluence + coastalLowland[i] * 0.08 + river[i] * 0.035 - slope[i] * 0.32 - ridgeField[i] * 0.18 + nearbyVillages * 0.012); } } let markets = pickPoints(marketScore, { threshold: 0.2 + rand(seed, 1041) * 0.08, max: 6 + Math.floor(rand(seed, 1042) * 12), minDistance: 11, seedOffset: 1040, predicate: (x, y, i) => !sea[i], }).map((p) => ({ ...p, kind: "Market Town" })); const defenseScore = new Float32Array(SIZE); for (let y = 3; y < MAP_H - 3; y++) { for (let x = 3; x < MAP_W - 3; x++) { const i = indexOf(x, y); if (sea[i]) continue; const hillShoulder = clamp(1 - Math.abs(elevation[i] - 0.50) / 0.24); let riverArms = 0; for (const [nx, ny] of neighbors8(x, y)) if (river[indexOf(nx, ny)] > 0.32) riverArms++; const confluence = riverArms >= 3 ? 0.38 : riverArms === 2 ? 0.18 : 0; const roadJunctionProxy = ( (distanceToNearest(markets, x, y) < 7 ? 1 : 0) + (distanceToNearest(crossings, x, y) < 6 ? 1 : 0) + (distanceToNearest(passes, x, y) < 7 ? 1 : 0) + (distanceToNearest(commercialPorts, x, y) < 8 ? 1 : 0) ) >= 2 ? 0.32 : 0; const hillEdge = plain[i] > 0.2 && elevation[i] > 0.36 && elevation[i] < 0.62 && (slope[i] > 0.12 || ridgeField[i] > 0.12) ? 0.3 : 0; const mountainRidgeCastle = elevation[i] > 0.56 && ridgeField[i] > 0.3 && valleyField[i] > 0.1 ? 0.28 : 0; const validCastleSite = confluence > 0 || roadJunctionProxy > 0 || hillEdge > 0 || mountainRidgeCastle > 0; defenseScore[i] = validCastleSite ? clamp(hillShoulder * 0.28 + confluence + roadJunctionProxy + hillEdge + mountainRidgeCastle + slope[i] * 0.05 - floodplain[i] * 0.42 - coastalLowland[i] * 0.12) : 0; } } let castles = pickPoints(defenseScore, { threshold: 0.34 + rand(seed, 1051) * 0.08, max: 2 + Math.floor(rand(seed, 1052) * 4), minDistance: 15, seedOffset: 1050, predicate: (x, y, i) => !sea[i] && defenseScore[i] > 0, }).map((p) => ({ ...p, kind: elevation[indexOf(p.x, p.y)] > 0.55 ? "Mountain Castle" : elevation[indexOf(p.x, p.y)] > 0.38 ? "Hilltop Castle" : "Flatland Castle", })); function normalEdgePenalty(x, y) { if (nearMapEdge(x, y, 1)) return INF; if (nearMapEdge(x, y, 2)) return 7; if (nearMapEdge(x, y, 4)) return 2.8; return 0; } function premodernCost(x, y) { const i = indexOf(x, y); if (sea[i]) return INF; const crossingBonus = distanceToNearest(crossings, x, y) < 4 ? 0.65 : 0; const passBonus = distanceToNearest(passes, x, y) < 4 ? 0.45 : 0; const riverPenalty = river[i] > 0.28 ? (crossingBonus ? 0.45 : 2.4) : 0; const highMountain = elevation[i] > 0.72 ? 4.2 : elevation[i] > 0.58 ? 1.4 : 0; return Math.max(0.35, 1 + slope[i] * 5.8 + riverPenalty + highMountain + floodplain[i] * 0.62 - plain[i] * 0.32 - valleyField[i] * 0.42 - coastalLowland[i] * 0.12 - passBonus + normalEdgePenalty(x, y) + hash2(x, y, seed + 111) * 0.16); } const premodernRoads = []; function addPremodernRoad(a, b) { const path = aStar(a, b, premodernCost); if (path.length > 3) premodernRoads.push(path); } for (const castle of castles) { const near = pickEntities([...markets, ...ports, ...crossings, ...passes].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - castle.x, p.y - castle.y)) })), { max: 2 + Math.floor(rand(seed, castle.x + castle.y) * 3), minDistance: 1, threshold: 0 }); for (const p of near) addPremodernRoad(castle, p); } for (const market of markets) { const near = pickEntities([...markets.filter((p) => p !== market), ...ports, ...crossings].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - market.x, p.y - market.y)) })), { max: 1 + Math.floor(rand(seed, market.x + market.y + 20) * 3), minDistance: 1, threshold: 0 }); for (const p of near) addPremodernRoad(market, p); } function urbanSiteSuitability(p) { const i = indexOf(p.x, p.y); if (sea[i]) return 0; const portBonus = p.kind === "Port Town" || p.portClass === "major" || p.portClass === "regional" ? 0.18 : 0; const historicalBonus = p.kind === "Market City" || p.kind === "Castle Town" ? 0.05 : 0; return clamp( plain[i] * 0.46 + agriculture[i] * 0.18 + basinField[i] * 0.20 + coastalLowland[i] * 0.20 + valleyField[i] * 0.12 + portBonus + historicalBonus - slope[i] * 0.58 - ridgeField[i] * 0.34 - Math.max(0, elevation[i] - 0.55) * 1.35 ); } function cityPopulationCap(p) { const i = indexOf(p.x, p.y); const suitability = urbanSiteSuitability(p); if (suitability < 0.18 || elevation[i] > 0.66 || slope[i] > 0.82 || ridgeField[i] > 0.72) return 85000; if (suitability < 0.28 || elevation[i] > 0.60 || slope[i] > 0.62) return 180000; if (suitability < 0.38) return 420000; return INF; } let castleTowns = castles.map((c) => ({ x: c.x, y: c.y, score: c.score + 0.45, kind: "Castle Town" })); const cityCandidates = [ ...castleTowns.map((p) => ({ ...p, score: p.score + 0.4 })), ...ports.map((p) => ({ ...p, kind: "Port Town", score: p.score + 0.28 })), ...markets.map((p) => ({ ...p, kind: "Market City", score: p.score + 0.12 })), ].map((p) => { const i = indexOf(p.x, p.y); const suitability = urbanSiteSuitability(p); return { ...p, urbanSuitability: suitability, score: p.score + suitability * 0.72 - slope[i] * 0.20 - ridgeField[i] * 0.16 - Math.max(0, elevation[i] - 0.58) * 0.78, }; }).filter((p) => p.urbanSuitability >= 0.10 || p.kind === "Castle Town"); let modernCities = pickEntities(cityCandidates, { max: 7 + Math.floor(rand(seed, 1061) * 10), minDistance: 9, threshold: 0.33 + rand(seed, 1062) * 0.12, seed: seed + 1060, }).map((p, n) => { const rank = n === 0 ? "Prefectural Capital" : n < 4 ? "Regional Center" : "Small City"; const r = rand(seed, 1600 + n * 13 + p.x * 3 + p.y); const rawScale = Math.pow(1 - n / Math.max(1, cityCandidates.length + 1), 1.55) * 0.58 + Math.pow(r, 3.4) * 0.42; const rankBase = rank === "Prefectural Capital" ? 420000 : rank === "Regional Center" ? 115000 : 26000; const rankSpread = rank === "Prefectural Capital" ? 1450000 : rank === "Regional Center" ? 520000 : 185000; const pi = indexOf(p.x, p.y); const suitability = p.urbanSuitability ?? urbanSiteSuitability(p); const geographyBoost = clamp(plain[pi] * 0.34 + agriculture[pi] * 0.18 + basinField[pi] * 0.2 + coastalLowland[pi] * 0.18 + valleyField[pi] * 0.12 + suitability * 0.24 + (p.kind === "Port Town" ? 0.22 : 0)); const rawPopulation = Math.round((rankBase + rankSpread * Math.pow(rawScale + geographyBoost * 0.18, 1.75)) / 1000) * 1000; const population = Math.min(rawPopulation, cityPopulationCap(p)); const urbanRadius = clamp(7.5 + Math.sqrt(population) / 80 + (rank === "Prefectural Capital" ? 3.0 : rank === "Regional Center" ? 1.5 : 0), 8, 32); const coreRadius = clamp(2.6 + Math.sqrt(population) / 320, 3, 9); const urbanWeight = clamp(0.74 + Math.log10(Math.max(10000, population)) * 0.36, 1.15, 3.05); return { ...p, population, urbanRadius, coreRadius, urbanWeight, rank, kind: p.kind || "City" }; }); function fallbackCapitalCandidate() { const pools = [...markets, ...ports, ...villages].filter((p) => p && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]); let best = null; let bestScore = -INF; for (const p of pools) { const i = indexOf(p.x, p.y); const score = urbanSiteSuitability(p) * 1.6 + plain[i] * 0.32 + populationDensityProxyForCapital(i) + (p.kind?.includes("Port") ? 0.18 : 0) + (p.score || 0); if (score > bestScore) { bestScore = score; best = p; } } if (best) return { ...best, kind: "Market City", population: 360000, urbanRadius: 15, coreRadius: 4.6, urbanWeight: 1.9, score: bestScore }; for (let y = 4; y < MAP_H - 4; y++) { for (let x = 4; x < MAP_W - 4; x++) { const i = indexOf(x, y); if (!prefectureMask[i] || sea[i]) continue; const score = plain[i] * 0.72 + agriculture[i] * 0.24 + basinField[i] * 0.18 + coastalLowland[i] * 0.14 - slope[i] * 0.72 - ridgeField[i] * 0.32; if (score > bestScore) { bestScore = score; best = { x, y, score, kind: "Market City" }; } } } return best ? { ...best, population: 320000, urbanRadius: 14, coreRadius: 4.2, urbanWeight: 1.7 } : null; } function populationDensityProxyForCapital(i) { return settlementScore[i] * 0.18 + marketScore[i] * 0.12; } if (modernCities.length === 0 || !modernCities.some((city) => prefectureMask[indexOf(city.x, city.y)])) { const fallbackCapital = fallbackCapitalCandidate(); if (fallbackCapital) modernCities.unshift(fallbackCapital); } if (modernCities.length > 0) { modernCities.sort((a, b) => (b.population || 0) + b.score * 90000 - ((a.population || 0) + a.score * 90000)); let capitalIndex = -1; let capitalScore = -INF; for (let i = 0; i < modernCities.length; i++) { const city = modernCities[i]; const ci = indexOf(city.x, city.y); if (!prefectureMask[ci] || sea[ci]) continue; const suitability = urbanSiteSuitability(city); const score = suitability * 900000 + (city.population || 0) * 0.55 + (city.score || 0) * 120000 - slope[ci] * 180000 - Math.max(0, elevation[ci] - 0.58) * 360000; if (score > capitalScore) { capitalScore = score; capitalIndex = i; } } if (capitalIndex > 0) modernCities.unshift(modernCities.splice(capitalIndex, 1)[0]); const capCell = indexOf(modernCities[0].x, modernCities[0].y); const capPopulation = prefectureMask[capCell] ? Math.max(modernCities[0].population || 0, 620000) : Math.min(modernCities[0].population || 0, 180000); modernCities[0] = { ...modernCities[0], rank: prefectureMask[capCell] ? "Prefectural Capital" : "Regional Center", kind: prefectureMask[capCell] ? "Prefectural Capital" : (modernCities[0].kind || "City"), isPrefecturalCapital: Boolean(prefectureMask[capCell]), population: capPopulation, urbanRadius: prefectureMask[capCell] ? Math.max(modernCities[0].urbanRadius || 0, 18) : modernCities[0].urbanRadius, coreRadius: prefectureMask[capCell] ? Math.max(modernCities[0].coreRadius || 0, 5.5) : modernCities[0].coreRadius, urbanWeight: prefectureMask[capCell] ? Math.max(modernCities[0].urbanWeight || 0, 2.15) : modernCities[0].urbanWeight, }; for (let i = 1; i < modernCities.length; i++) modernCities[i] = { ...modernCities[i], isPrefecturalCapital: false }; } const capital = modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]) || modernCities.find((city) => prefectureMask[indexOf(city.x, city.y)]) || markets.find((p) => prefectureMask[indexOf(p.x, p.y)]) || ports.find((p) => prefectureMask[indexOf(p.x, p.y)]) || { x: Math.floor(MAP_W / 2), y: Math.floor(MAP_H / 2), score: 1, population: 0, urbanRadius: 12, coreRadius: 4, urbanWeight: 1, isPrefecturalCapital: true }; const populationDensity = new Float32Array(SIZE); let maxPopulationDensity = 0; for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); if (sea[i]) continue; let density = 0; for (const city of modernCities) { const populationScale = clamp((Math.log10(Math.max(10000, city.population || 10000)) - 4) / 2.25, 0.12, 1.55); const d = Math.hypot(city.x - x, city.y - y); const urbanR = Math.max(5, city.urbanRadius || 11); const coreR = Math.max(2.4, city.coreRadius || 4); density += populationScale * 1.55 / (1 + Math.pow(d / urbanR, 2.35)); density += populationScale * 1.05 * Math.exp(-(d * d) / (coreR * coreR * 2.2)); } for (const market of markets) { const d = Math.hypot(market.x - x, market.y - y); density += 0.22 / (1 + Math.pow(d / 7.5, 2.2)); } for (const village of villages) { const d = Math.hypot(village.x - x, village.y - y); density += 0.055 / (1 + Math.pow(d / 4.2, 2)); } density *= clamp(0.48 + plain[i] * 0.62 + agriculture[i] * 0.14 + basinField[i] * 0.22 + coastalLowland[i] * 0.18 + valleyField[i] * 0.1 - slope[i] * 1.05 - ridgeField[i] * 0.48 - Math.max(0, elevation[i] - 0.58) * 1.05, 0.018, 1.22); populationDensity[i] = density; if (density > maxPopulationDensity) maxPopulationDensity = density; } } if (maxPopulationDensity > 0) { for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxPopulationDensity); } function densityValue(x, y) { return populationDensity[indexOf(x, y)] || 0; } function midDensityAffinity(x, y) { const d = densityValue(x, y); return clamp(1 - Math.abs(d - 0.38) / 0.38); } function nearPassPoint(x, y, radius = 5) { return distanceToNearest(passes, x, y) <= radius; } function mountainBarrierPenalty(x, y, type = "rail") { const i = indexOf(x, y); const e = elevation[i]; const s = slope[i]; const pass = nearPassPoint(x, y, type === "express" ? 7 : type === "rail" ? 6 : 5); if (e > 0.84) return INF; if (pass && e > 0.80 && s > 0.16) return INF; if (!pass && e > 0.78) return INF; if (!pass && e > 0.70 && s > 0.16) return INF; if (!pass && e > 0.66 && s > 0.28) return INF; if (!pass && e > 0.72) return type === "express" ? 260 : type === "rail" ? 330 : type === "minor" ? 80 : 155; if (!pass && e > 0.64 && s > 0.20) return type === "express" ? 145 : type === "rail" ? 180 : type === "minor" ? 54 : 96; const passDiscount = pass ? (type === "minor" ? 0.35 : 0.22) : 1; const mountain = Math.max(0, e - 0.48); const steep = Math.max(0, s - 0.15); const typeFactor = type === "express" ? 360 : type === "rail" ? 430 : type === "minor" ? 115 : 210; return (mountain * mountain * typeFactor + steep * steep * 150 + ridgeField[i] * 9.5) * passDiscount; } function transportAccessPoint(node, mode = "road", salt = 0) { if (!node || nearMapEdge(node.x, node.y, 1) || node.kind === "External Gateway") return node; const minR = mode === "express" ? 10 : mode === "rail" ? 2 : 4; const maxR = mode === "express" ? 20 : mode === "rail" ? 6 : 10; let best = null; let bestScore = -INF; for (let dy = -maxR; dy <= maxR; dy++) { for (let dx = -maxR; dx <= maxR; dx++) { const d = Math.hypot(dx, dy); if (d < minR || d > maxR) continue; const x = node.x + dx; const y = node.y + dy; if (!inside(x, y)) continue; const i = indexOf(x, y); if (sea[i]) continue; const barrier = mode === "express" || mode === "rail" ? mountainBarrierPenalty(x, y, mode) : mountainBarrierPenalty(x, y, "road"); if (barrier >= INF) continue; const targetD = (minR + maxR) * 0.5; const flatness = plain[i] * 1.0 + agriculture[i] * 0.2 + valleyField[i] * 0.26 + coastalLowland[i] * 0.16 - slope[i] * 1.22 - ridgeField[i] * 0.72 - Math.max(0, elevation[i] - 0.58) * 2.35; const ring = -Math.abs(d - targetD) * 0.08; const riverPenalty = river[i] > 0.5 ? 0.45 : river[i] * 0.12; const density = densityValue(x, y); const densityAffinity = mode === "rail" ? density * 0.9 : mode === "express" ? midDensityAffinity(x, y) * 0.52 - Math.max(0, density - 0.72) * 0.9 : density * 0.24; const noise = hash2(x, y, seed + salt + (mode === "rail" ? 6000 : mode === "express" ? 7000 : 5000)) * 0.12; const score = flatness + densityAffinity + ring - riverPenalty - barrier * 0.012 + noise; if (score > bestScore) { bestScore = score; best = { x, y, score: node.score || 0.5, kind: `${mode} Access`, parent: node }; } } } return best || node; } function routePoint(node, mode, salt = 0) { return transportAccessPoint(node, mode, salt); } const townAvoidNodes = [...modernCities, ...markets, ...ports]; const urbanCenters = modernCities.map((city, n) => { let best = { x: city.x, y: city.y, score: city.score + 0.5 }; let bestScore = -INF; const searchR = Math.max(2, Math.round(city.coreRadius)); for (let dy = -searchR; dy <= searchR; dy++) { for (let dx = -searchR; dx <= searchR; dx++) { const x = city.x + dx; const y = city.y + dy; if (!inside(x, y)) continue; const i = indexOf(x, y); if (sea[i]) continue; const d = Math.hypot(dx, dy); const score = plain[i] * 0.54 + agriculture[i] * 0.16 - slope[i] * 0.36 - d * 0.06 + hash2(x, y, seed + 1700 + n) * 0.07; if (score > bestScore) { bestScore = score; best = { x, y, score: city.score + 0.5, cityIndex: n, parent: city }; } } } return { ...best, kind: city.rank === "Prefectural Capital" ? "Central Business District" : "Urban Center", population: Math.round(city.population * (city.rank === "Prefectural Capital" ? 0.18 : 0.12)), insidePrefecture: Boolean(prefectureMask[indexOf(best.x, best.y)]) }; }); function railCost(x, y) { const i = indexOf(x, y); if (sea[i]) return INF; const barrier = mountainBarrierPenalty(x, y, "rail"); if (barrier >= INF) return INF; const density = densityValue(x, y); const highPenalty = Math.max(0, elevation[i] - 0.52) * 14 + barrier; const riverPenalty = river[i] > 0.5 ? 1.6 : river[i] > 0.25 ? 0.7 : 0; return Math.max(0.42, 1 + slope[i] * 22 + highPenalty + riverPenalty + floodplain[i] * 0.28 - density * 0.88 - plain[i] * 0.28 - valleyField[i] * 0.62 - coastalLowland[i] * 0.48 + ridgeField[i] * 1.4 + normalEdgePenalty(x, y) + hash2(x, y, seed + 222) * 0.08); } const railways = []; const branchRailways = []; const railDegree = new Map(); const railCore = [capital]; const railHubs = [...modernCities, ...commercialPorts]; function addRailRoute(a, b, bucket = railways) { const start = routePoint(a, "rail", a.x * 19 + a.y * 23); const goal = routePoint(b, "rail", b.x * 19 + b.y * 23 + 11); const existingRails = [...railways, ...branchRailways]; const cost = makeTransportCost(railCost, existingRails, railHubs, [start, goal], 5, 10.5, townAvoidNodes, 2.4, 4.2); const path = aStar(start, goal, cost); const length = pathLength(path); const direct = pathEndpointDistance(path); const overlap = pathOverlapRatio(path, existingRails, 2); const densityPurpose = averagePathField(path, populationDensity) + averagePathField(path, plain) * 0.28 + averagePathField(path, valleyField) * 0.2; const isMain = bucket === railways; if (path.length > 3 && direct >= (isMain ? 18 : 12) && length >= (isMain ? 22 : 14) && pathCompactness(path) < (isMain ? 3.1 : 3.4) && overlap < (isMain ? 0.30 : 0.20) && densityPurpose > (isMain ? 0.18 : 0.12)) { bucket.push(path); incrementDegree(railDegree, a); incrementDegree(railDegree, b); return true; } return false; } const transportCities = modernCities.filter((city) => (city.population || 0) >= 120000); const mainRailTargets = transportCities.filter((city) => city !== capital).slice(0, 2 + Math.floor(rand(seed, 1070) * 3)); for (const city of mainRailTargets) { const anchor = nearestConnectable(railCore, city, railDegree, 3) || capital; if (addRailRoute(anchor, city, railways)) railCore.push(city); } for (const city of modernCities.filter((city) => city !== capital && !mainRailTargets.includes(city))) { const anchor = nearestConnectable(railCore, city, railDegree, 2) || capital; if (anchor && rand(seed, city.x * 10 + city.y) > 0.2) { if (addRailRoute(city, anchor, branchRailways)) railCore.push(city); } } for (const port of majorPorts.slice(0, 1 + Math.floor(rand(seed, 1071) * 2))) { const anchor = nearestConnectable(railCore, port, railDegree, 2) || capital; if (anchor && addRailRoute(port, anchor, branchRailways)) railCore.push(port); } compactPathArray(railways, { minLength: 17, maxOverlap: 0.34, maxCount: 5 }); compactPathArray(branchRailways, { minLength: 11, maxOverlap: 0.22, maxCount: 9 }); const railInfluence = influenceFromPaths([...railways, ...branchRailways], 5); const stationCandidates = [ ...modernCities.map((p, i) => ({ ...routePoint(p, "rail", 1900 + i), score: p.score + 0.46, kind: "Major Station", population: p.population })), ...railways.flatMap((path) => samplePath(path, 18 + Math.floor(rand(seed, path.length) * 14))).map((p) => ({ ...p, kind: "Station", score: 0.52 + agriculture[indexOf(p.x, p.y)] * 0.2 })), ...branchRailways.flatMap((path) => samplePath(path, 16 + Math.floor(rand(seed, path.length + 99) * 14))).map((p) => ({ ...p, kind: "Station", score: 0.42 + agriculture[indexOf(p.x, p.y)] * 0.2 })), ]; let stations = pickEntities(stationCandidates, { max: 14 + Math.floor(rand(seed, 1080) * 22), minDistance: 6, threshold: 0.38, seed: seed + 1080 }); const industrialScore = new Float32Array(SIZE); for (let y = 4; y < MAP_H - 4; y++) { for (let x = 4; x < MAP_W - 4; x++) { const i = indexOf(x, y); if (sea[i]) continue; const nearPort = 1 / (1 + distanceToNearest(majorPorts.length ? majorPorts : commercialPorts, x, y) / 5); const nearCity = distanceToNearest(modernCities, x, y); const cityEdge = nearCity > 5 && nearCity < 20 ? 0.22 : nearCity <= 5 ? -0.25 : 0; industrialScore[i] = clamp(plain[i] * 0.24 + coastalLowland[i] * 0.24 + railInfluence[i] * 0.38 + nearPort * 0.58 + river[i] * 0.04 + cityEdge - slope[i] * 0.36 - ridgeField[i] * 0.18 - floodplain[i] * 0.03); } } let industrialZones = pickPoints(industrialScore, { threshold: 0.31 + rand(seed, 1091) * 0.09, max: 4 + Math.floor(rand(seed, 1092) * 13), minDistance: 10, seedOffset: 1090, predicate: (x, y, i) => !sea[i], }).map((p) => ({ ...p, kind: "Industrial Zone" })); function roadCost(x, y) { const i = indexOf(x, y); if (sea[i]) return INF; const barrier = mountainBarrierPenalty(x, y, "road"); if (barrier >= INF) return INF; const density = densityValue(x, y); const nodeAvoid = distanceToNearest(townAvoidNodes, x, y) < 2.2 ? 2.0 : 0; return Math.max(0.35, 1 + slope[i] * 17.8 + barrier + Math.max(0, elevation[i] - 0.54) * 9.2 + nodeAvoid + (river[i] > 0.45 ? 0.85 : 0) + floodplain[i] * 0.22 - density * 0.50 - plain[i] * 0.22 - valleyField[i] * 0.28 - coastalLowland[i] * 0.20 + ridgeField[i] * 1.15 + normalEdgePenalty(x, y) + hash2(x, y, seed + 333) * 0.08); } function expresswayCost(x, y) { const i = indexOf(x, y); if (sea[i]) return INF; const barrier = mountainBarrierPenalty(x, y, "express"); if (barrier >= INF) return INF; const density = densityValue(x, y); const midDensity = midDensityAffinity(x, y); const cityDistance = distanceToNearest(modernCities, x, y); const cityAvoid = cityDistance < 5 ? 22.0 : cityDistance < 9 ? 11.0 : cityDistance < 13 ? 4.0 : distanceToNearest(markets, x, y) < 4 ? 3.2 : 0; const densityPenalty = density > 0.66 ? (density - 0.66) * 9.5 : density < 0.08 ? (0.08 - density) * 2.4 : 0; const highPenalty = barrier + (elevation[i] > 0.72 ? 26 : elevation[i] > 0.62 ? 8.5 : 0); return Math.max(0.42, 1 + slope[i] * 23.0 + highPenalty + cityAvoid + densityPenalty + (river[i] > 0.45 ? 1.0 : 0) - midDensity * 0.82 - plain[i] * 0.16 - valleyField[i] * 0.16 - coastalLowland[i] * 0.18 + ridgeField[i] * 1.20 + normalEdgePenalty(x, y) + hash2(x, y, seed + 444) * 0.015); } const nationalRoads = []; const roadDegree = new Map(); function transportDemand(p) { const pop = Math.sqrt(Math.max(0, p.population || 0)) / 700; const capitalBoost = p.isPrefecturalCapital || p.rank === "Prefectural Capital" ? 2.1 : 0; const portBoost = p.portClass === "major" ? 1.4 : p.portClass === "regional" ? 0.8 : p.portClass ? 0.35 : 0; const historyBoost = p.kind?.includes("Castle") ? 0.55 : p.kind === "Market Town" ? 0.42 : 0; const gatewayBoost = p.kind === "External Gateway" ? 1.1 : 0; return 0.35 + pop + capitalBoost + portBoost + historyBoost + gatewayBoost; } function sameCorridorAffinity(a, b) { const ai = indexOf(a.x, a.y); const bi = indexOf(b.x, b.y); return Math.min(0.6, (basinField[ai] + basinField[bi]) * 0.14 + (valleyField[ai] + valleyField[bi]) * 0.10 + (coastalLowland[ai] + coastalLowland[bi]) * 0.10); } const roadTargetCandidates = [...modernCities.filter((p) => (p.population || 0) >= 90000), ...ports, ...markets, ...castles] .map((p) => ({ ...p, demand: transportDemand(p), score: (p.score || 0.4) + transportDemand(p) * 0.24 + ((p.population || 0) >= 180000 ? 0.18 : 0.05) })); const pickedRoadTargets = pickEntities(roadTargetCandidates, { max: 8 + Math.floor(rand(seed, 1101) * 10), minDistance: 9, threshold: 0, seed: seed + 1100, }); const roadTargets = [ capital, ...pickedRoadTargets .filter((p) => Math.hypot(p.x - capital.x, p.y - capital.y) > 2) .sort((a, b) => transportDemand(b) - transportDemand(a)), ]; const roadHubs = [...modernCities, ...ports, ...markets, ...stations]; const roadCore = [capital]; function addNationalRoad(a, b) { const start = routePoint(a, "road", a.x * 31 + a.y * 37); const goal = routePoint(b, "road", b.x * 31 + b.y * 37 + 17); const existing = [...nationalRoads, ...railways, ...branchRailways]; const path = aStar(start, goal, makeTransportCost(roadCost, existing, roadHubs, [start, goal], 3, 5.8, townAvoidNodes, 3.2, 5.4)); const direct = pathEndpointDistance(path); const urbanPasses = modernCities.filter((city) => path.some(([x, y]) => Math.hypot(x - city.x, y - city.y) <= Math.max(6, Math.min(13, (city.urbanRadius || 8) * 0.78)))).length; const passBonusOk = urbanPasses >= 2 || direct >= 24; if (path.length > 3 && direct >= 16 && pathLength(path) >= 20 && pathCompactness(path) < 3.35 && pathOverlapRatio(path, existing, 2) < 0.48 && passBonusOk) { nationalRoads.push(path); incrementDegree(roadDegree, a); incrementDegree(roadDegree, b); return true; } return false; } for (const target of roadTargets.slice(1, 8 + Math.floor(rand(seed, 1102) * 7))) { const anchor = nearestConnectable(roadCore, target, roadDegree, 3) || capital; if (addNationalRoad(anchor, target)) roadCore.push(target); } const roadLinkCandidates = []; for (let i = 0; i < roadTargets.length; i++) { for (let j = i + 1; j < roadTargets.length; j++) { const a = roadTargets[i]; const b = roadTargets[j]; const d = Math.hypot(a.x - b.x, a.y - b.y); if (d < 18 || d > 58) continue; const demand = Math.sqrt(transportDemand(a) * transportDemand(b)); roadLinkCandidates.push({ a, b, score: demand / (1 + d / 18) + sameCorridorAffinity(a, b) + hash2(a.x + b.x, a.y + b.y, seed + 1111) * 0.05 }); } } roadLinkCandidates.sort((a, b) => b.score - a.score); let extraRoadLinks = 0; for (const link of roadLinkCandidates) { if (extraRoadLinks >= 4) break; if (getDegree(roadDegree, link.a) >= 4 || getDegree(roadDegree, link.b) >= 4) continue; if (addNationalRoad(link.a, link.b)) { extraRoadLinks++; } } // National roads should behave like long trunk corridors: they intentionally // pass near as many urbanized cells/cities as possible, unlike expressways. const trunkCities = modernCities .filter((city) => prefectureMask[indexOf(city.x, city.y)] && (city.population || 0) >= 90000) .slice() .sort((a, b) => a.x - b.x || a.y - b.y); for (let i = 0; i < trunkCities.length - 1; i += 2) { const a = trunkCities[i]; const b = trunkCities[Math.min(trunkCities.length - 1, i + 2)]; if (a && b && Math.hypot(a.x - b.x, a.y - b.y) >= 22 && getDegree(roadDegree, a) < 5) addNationalRoad(a, b); } const expressTargets = pickEntities(modernCities.filter((p) => p !== capital && (p.population || 0) >= 180000).map((p) => ({ ...p, score: p.score + Math.hypot(p.x - capital.x, p.y - capital.y) / 80 + 0.15 })).concat(majorPorts.map((p) => ({ ...p, score: p.score + 0.55 }))), { max: 1 + Math.floor(rand(seed, 1120) * 3), minDistance: 20, threshold: 0.05, seed: seed + 1120, }); const expressways = []; const expressDegree = new Map(); const expressCore = [capital]; function addExpressway(a, b, bucket = expressways) { const start = routePoint(a, "express", a.x * 41 + a.y * 43); const goal = routePoint(b, "express", b.x * 41 + b.y * 43 + 29); const existing = [...expressways, ...nationalRoads, ...railways, ...branchRailways]; let path = aStar(start, goal, makeTransportCost(expresswayCost, existing, roadHubs, [start, goal], 5, 10.8, townAvoidNodes, 8.5, 14.0)); path = smoothPathByLineOfSight(path, (x, y) => expresswayCost(x, y, x, y) < INF && slope[indexOf(x, y)] < 0.54 && elevation[indexOf(x, y)] < 0.78, 10); const direct = pathEndpointDistance(path); if (path.length > 8 && direct >= 26 && pathLength(path) >= 30 && pathCompactness(path) < 2.35 && pathOverlapRatio(path, existing, 2) < 0.30) { bucket.push(path); incrementDegree(expressDegree, a); incrementDegree(expressDegree, b); return true; } return false; } for (const target of expressTargets) { const anchor = nearestConnectable(expressCore, target, expressDegree, 2) || capital; if (addExpressway(anchor, target)) expressCore.push(target); } const ringRoads = []; const ringExpressways = []; const ringRailways = []; function ringAnchorCandidates(city, mode, targetRadius, sectors = 8) { const anchors = []; const minR = Math.max(5, targetRadius - 5); const maxR = targetRadius + 7; for (let s = 0; s < sectors; s++) { const angle0 = (s / sectors) * Math.PI * 2; let best = null; let bestScore = -INF; for (let dy = -Math.ceil(maxR); dy <= Math.ceil(maxR); dy++) { for (let dx = -Math.ceil(maxR); dx <= Math.ceil(maxR); dx++) { const d = Math.hypot(dx, dy); if (d < minR || d > maxR) continue; const angle = Math.atan2(dy, dx); let delta = Math.abs(Math.atan2(Math.sin(angle - angle0), Math.cos(angle - angle0))); if (delta > Math.PI / sectors * 0.95) continue; const x = city.x + dx; const y = city.y + dy; if (!inside(x, y)) continue; const i = indexOf(x, y); if (sea[i] || !prefectureMask[i]) continue; const barrier = mode === "road" ? mountainBarrierPenalty(x, y, "road") : mountainBarrierPenalty(x, y, mode === "express" ? "express" : "rail"); if (barrier >= INF) continue; const density = densityValue(x, y); const densityTerm = mode === "rail" ? density * 0.75 : mode === "express" ? midDensityAffinity(x, y) * 0.72 : density * 0.28 + midDensityAffinity(x, y) * 0.22; const score = plain[i] * 0.72 + agriculture[i] * 0.12 + densityTerm - slope[i] * 1.25 - Math.max(0, elevation[i] - 0.58) * 1.3 - barrier * 0.01 - Math.abs(d - targetRadius) * 0.035 + hash2(x, y, seed + 4100 + s * 37 + mode.length * 101) * 0.08; if (score > bestScore) { bestScore = score; best = { x, y, score, kind: `${mode} ring anchor`, parent: city }; } } } if (best) anchors.push(best); } return anchors; } function ringCost(baseCost, city, targetRadius, mode) { return (x, y, cx, cy) => { const base = baseCost(x, y, cx, cy); if (base >= INF) return base; const d = Math.hypot(x - city.x, y - city.y); const tooClose = Math.max(0, targetRadius * 0.46 - d); const tooFar = Math.max(0, d - targetRadius * 1.55); const bandPenalty = tooClose * 0.34 + tooFar * 0.16 + Math.abs(d - targetRadius) * 0.018; const density = densityValue(x, y); const densityBias = mode === "rail" ? -density * 0.42 : mode === "express" ? -midDensityAffinity(x, y) * 0.32 + Math.max(0, density - 0.82) * 0.8 : -density * 0.12; return Math.max(0.36, base + bandPenalty + densityBias); }; } function softRingRailCost(x, y) { const i = indexOf(x, y); const barrier = mountainBarrierPenalty(x, y, "rail"); if (sea[i] || barrier >= INF) return INF; const density = densityValue(x, y); return Math.max(0.38, 1 + slope[i] * 14 + barrier + Math.max(0, elevation[i] - 0.56) * 22 + (river[i] > 0.5 ? 1.3 : river[i] * 0.6) - density * 0.62 - plain[i] * 0.20 + normalEdgePenalty(x, y) + hash2(x, y, seed + 7222) * 0.05); } function softRingExpressCost(x, y) { const i = indexOf(x, y); const barrier = mountainBarrierPenalty(x, y, "express"); if (sea[i] || barrier >= INF) return INF; return Math.max(0.38, 1 + slope[i] * 13 + barrier + Math.max(0, elevation[i] - 0.58) * 20 + (river[i] > 0.5 ? 1.0 : river[i] * 0.5) - midDensityAffinity(x, y) * 0.42 - plain[i] * 0.14 + normalEdgePenalty(x, y) + hash2(x, y, seed + 7444) * 0.05); } function addEnvironmentalRing(city, mode, bucket, baseCost, existingPaths, targetRadius) { const anchors = ringAnchorCandidates(city, mode, targetRadius, mode === "road" ? 7 : 8); if (anchors.length < 3) return 0; let made = 0; const cost = ringCost(baseCost, city, targetRadius, mode); for (let i = 0; i < anchors.length - (anchors.length < 4 ? 1 : 0); i++) { const a = anchors[i]; const b = anchors[(i + 1) % anchors.length]; if (Math.hypot(a.x - b.x, a.y - b.y) > targetRadius * 1.85) continue; const path = aStar(a, b, makeTransportCost(cost, [...existingPaths, ...bucket], roadHubs, [a, b], mode === "road" ? 3 : 4, mode === "road" ? 4.8 : 7.0, townAvoidNodes, mode === "express" ? 3.8 : 2.2, mode === "express" ? 4.8 : 2.8)); if (path.length >= 5 && path.length <= targetRadius * 8.0) { bucket.push(path); made++; } } return made; } function flexibleRingAnchors(city, targetRadius, maxAnchors = 6) { const candidates = []; const maxR = targetRadius + 11; const minR = Math.max(5, targetRadius * 0.45); for (let dy = -Math.ceil(maxR); dy <= Math.ceil(maxR); dy++) { for (let dx = -Math.ceil(maxR); dx <= Math.ceil(maxR); dx++) { const d = Math.hypot(dx, dy); if (d < minR || d > maxR) continue; const x = city.x + dx; const y = city.y + dy; if (!inside(x, y)) continue; const i = indexOf(x, y); if (sea[i] || !prefectureMask[i] || elevation[i] > 0.82) continue; const score = plain[i] * 0.7 + midDensityAffinity(x, y) * 0.32 + densityValue(x, y) * 0.2 - slope[i] * 1.15 - Math.max(0, elevation[i] - 0.58) * 0.88 - Math.abs(d - targetRadius) * 0.02 + hash2(x, y, seed + 7555) * 0.06; candidates.push({ x, y, score, angle: Math.atan2(dy, dx), kind: "flexible ring anchor", parent: city }); } } return pickEntities(candidates, { max: maxAnchors, minDistance: 5, threshold: -1, seed: seed + city.x * 83 + city.y * 89 }) .sort((a, b) => a.angle - b.angle); } function addLooseEnvironmentalRing(city, bucket, baseCost, targetRadius) { let anchors = ringAnchorCandidates(city, "road", targetRadius, 6); if (anchors.length < 3) anchors = flexibleRingAnchors(city, targetRadius, 6); if (anchors.length < 2) return 0; let made = 0; for (let i = 0; i < anchors.length; i++) { const a = anchors[i]; const b = anchors[(i + 1) % anchors.length]; const path = aStar(a, b, (x, y, cx, cy) => { const base = baseCost(x, y, cx, cy); if (base >= INF) return INF; const d = Math.hypot(x - city.x, y - city.y); const band = Math.max(0, targetRadius * 0.42 - d) * 0.22 + Math.max(0, d - targetRadius * 1.7) * 0.14 + Math.abs(d - targetRadius) * 0.012; return Math.max(0.3, base + band); }); if (path.length >= 4 && path.length <= targetRadius * 9.0) { bucket.push(path); made++; } } return made; } const mediumRingCities = modernCities.filter((c) => (c.population || 0) >= 130000).slice(0, 6); for (const city of mediumRingCities) { const radius = clamp(8 + Math.sqrt(city.population || 100000) / 170, 10, 22); addEnvironmentalRing(city, "road", ringRoads, roadCost, [...nationalRoads, ...railways, ...branchRailways], radius); } const largeRingCities = modernCities.filter((c) => (c.population || 0) >= 900000).slice(0, 1); for (const city of largeRingCities) { const roadRadius = clamp(10 + Math.sqrt(city.population || 400000) / 155, 13, 28); const expressRadius = roadRadius + 3 + rand(seed, city.x * 71 + city.y * 73) * 3; const railRadius = Math.max(8, roadRadius - 4); addEnvironmentalRing(city, "road", ringRoads, roadCost, [...nationalRoads, ...expressways, ...railways, ...branchRailways], roadRadius); // Expressway rings are intentionally disabled; expressways stay as sparse interurban corridors. const railRingSegments = addEnvironmentalRing(city, "rail", ringRailways, railCost, [...railways, ...branchRailways, ...nationalRoads, ...expressways], railRadius); // Expressway rings should be rare; do not force a fallback ring when terrain rejects it. if (railRingSegments === 0) addLooseEnvironmentalRing(city, ringRailways, softRingRailCost, railRadius); } ringExpressways.length = 0; compactPathArray(ringRoads, { minLength: 8, maxOverlap: 0.32, maxCount: 18 }); compactPathArray(ringRailways, { minLength: 8, maxOverlap: 0.26, maxCount: 8 }); const gatewayCandidates = []; for (let x = 0; x < MAP_W; x++) for (const y of [0, MAP_H - 1]) { const i = indexOf(x, y); if (!sea[i]) gatewayCandidates.push({ x, y, side: y === 0 ? "N" : "S", score: plain[i] + agriculture[i] + (1 - slope[i]) * 0.5 + coastalLowland[i] * 0.2 - Math.max(0, elevation[i] - 0.56) * 1.6 - ridgeField[i] * 0.35 }); } for (let y = 0; y < MAP_H; y++) for (const x of [0, MAP_W - 1]) { const i = indexOf(x, y); if (!sea[i]) gatewayCandidates.push({ x, y, side: x === 0 ? "W" : "E", score: plain[i] + agriculture[i] + (1 - slope[i]) * 0.5 + coastalLowland[i] * 0.2 - Math.max(0, elevation[i] - 0.56) * 1.6 - ridgeField[i] * 0.35 }); } let externalGateways = pickEntities(gatewayCandidates, { max: 2 + Math.floor(rand(seed, 1201) * 3), minDistance: 28, threshold: 0.4, seed: seed + 1201, }).map((p) => ({ ...p, kind: "External Gateway" })); function externalRoadCost(goal) { return (x, y) => { const i = indexOf(x, y); if (sea[i]) return INF; const barrier = mountainBarrierPenalty(x, y, "road"); if (barrier >= INF) return INF; const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 7 : nearMapEdge(x, y, 3) ? 1.5 : 0; const density = densityValue(x, y); const nodeAvoid = distanceToNearest(townAvoidNodes, x, y) < 2.2 ? 2.0 : 0; return Math.max(0.35, 1 + slope[i] * 13 + barrier + Math.max(0, elevation[i] - 0.54) * 7.5 + nodeAvoid + (river[i] > 0.45 ? 0.9 : 0) + floodplain[i] * 0.24 - density * 0.3 - plain[i] * 0.24 + borderPenalty + hash2(x, y, seed + 333) * 0.06); }; } function externalExpresswayCost(goal) { return (x, y) => { const i = indexOf(x, y); if (sea[i]) return INF; const barrier = mountainBarrierPenalty(x, y, "express"); if (barrier >= INF) return INF; const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 7 : nearMapEdge(x, y, 3) ? 1.5 : 0; const density = densityValue(x, y); const cityDistance = distanceToNearest(modernCities, x, y); const cityAvoid = cityDistance < 5 ? 22.0 : cityDistance < 9 ? 11.0 : cityDistance < 13 ? 4.0 : distanceToNearest(markets, x, y) < 4 ? 3.2 : 0; const densityPenalty = density > 0.66 ? (density - 0.66) * 9.5 : density < 0.08 ? (0.08 - density) * 2.4 : 0; return Math.max(0.42, 1 + slope[i] * 19 + barrier + cityAvoid + densityPenalty + (river[i] > 0.45 ? 1 : 0) + floodplain[i] * 0.2 - midDensityAffinity(x, y) * 0.7 - plain[i] * 0.12 + borderPenalty + hash2(x, y, seed + 444) * 0.05); }; } function externalRailCost(goal) { return (x, y) => { const i = indexOf(x, y); if (sea[i]) return INF; const barrier = mountainBarrierPenalty(x, y, "rail"); if (barrier >= INF) return INF; const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 9 : nearMapEdge(x, y, 3) ? 1.8 : 0; const density = densityValue(x, y); return Math.max(0.42, 1 + slope[i] * 22 + barrier + Math.max(0, elevation[i] - 0.52) * 14 + (river[i] > 0.45 ? 1.2 : 0) + borderPenalty - density * 1.0 - plain[i] * 0.28 + hash2(x, y, seed + 222) * 0.05); }; } const externalRoads = []; const externalExpressways = []; const externalRailways = []; function selectExternalStart(pool, gate, degreeMap, maxDegree = 2) { const sorted = pool .filter(Boolean) .map((p) => ({ ...p, d: Math.hypot(p.x - gate.x, p.y - gate.y), degree: getDegree(degreeMap, p) })) .sort((a, b) => a.d + a.degree * 16 + (a.degree >= maxDegree ? 30 : 0) - (b.d + b.degree * 16 + (b.degree >= maxDegree ? 30 : 0))); return sorted.find((p) => p.degree < maxDegree) || sorted[0] || capital; } externalGateways.forEach((gate, idx) => { const makeExpressLink = idx === 0 || rand(seed, 1210 + idx) > 0.4; const roadStartRaw = selectExternalStart([...roadCore, ...modernCities, ...ports, ...markets], gate, roadDegree, 3); const roadStart = routePoint(roadStartRaw, makeExpressLink ? "express" : "road", gate.x * 53 + gate.y * 59); const roadExisting = [...nationalRoads, ...expressways, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways]; const roadBaseCost = makeExpressLink ? externalExpresswayCost(gate) : externalRoadCost(gate); const roadPath = aStar(roadStart, gate, makeTransportCost(roadBaseCost, roadExisting, roadHubs, [roadStart, gate], makeExpressLink ? 4 : 3, makeExpressLink ? 8.2 : 6.2, townAvoidNodes, makeExpressLink ? 5.4 : 3.2, makeExpressLink ? 7.8 : 5.6)); if (roadPath.length > 6) { if (makeExpressLink) { externalExpressways.push(roadPath); incrementDegree(expressDegree, roadStartRaw); incrementDegree(expressDegree, gate); expressCore.push(gate); } else { externalRoads.push(roadPath); incrementDegree(roadDegree, roadStartRaw); incrementDegree(roadDegree, gate); } } if ((idx === 0 || rand(seed, 1220 + idx) > 0.5) && modernCities.length > 0) { const railStartRaw = selectExternalStart([...railCore, ...modernCities, ...ports], gate, railDegree, 2); const railStart = routePoint(railStartRaw, "rail", gate.x * 61 + gate.y * 67); const railExisting = [...railways, ...branchRailways, ...externalRailways, ...nationalRoads, ...expressways, ...externalExpressways]; const railPath = aStar(railStart, gate, makeTransportCost(externalRailCost(gate), railExisting, railHubs, [railStart, gate], 4, 8.2, townAvoidNodes, 2.5, 4.4)); if (railPath.length > 6) { externalRailways.push(railPath); incrementDegree(railDegree, railStartRaw); incrementDegree(railDegree, gate); } } }); function pruneHighMountainTransport(paths, threshold = 0.82) { for (let i = paths.length - 1; i >= 0; i--) { if (paths[i].some(([x, y]) => elevation[indexOf(x, y)] > threshold)) paths.splice(i, 1); } } for (const paths of [railways, branchRailways, ringRailways, externalRailways, expressways, externalExpressways]) pruneHighMountainTransport(paths, 0.82); const expressInfluence = influenceFromPaths([...expressways, ...externalExpressways], 6); const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...expressways, ...externalRoads, ...externalExpressways], 4); const icCandidates = []; for (const path of [...expressways, ...externalExpressways]) { icCandidates.push(...samplePath(path, 11 + Math.floor(rand(seed, path.length + 333) * 5)).map((p) => ({ ...p, score: 0.62 + plain[indexOf(p.x, p.y)] * 0.24 + midDensityAffinity(p.x, p.y) * 0.16, kind: "Interchange" }))); for (const city of modernCities) { let best = null; let bestDistance = 999; for (const [x, y] of path) { const d = Math.hypot(x - city.x, y - city.y); if (d < bestDistance) { bestDistance = d; best = { x, y }; } } if (best && bestDistance > 4 && bestDistance < 18) icCandidates.push({ ...best, score: 0.8 + city.score * 0.1, kind: "Urban Interchange" }); } } let interchanges = pickEntities(icCandidates, { max: 14 + Math.floor(rand(seed, 1130) * 18), minDistance: 7, threshold: 0.44, seed: seed + 1130 }); const icAccessRoads = []; const nationalRoadAccessPoints = nationalRoads.flatMap((path) => samplePath(path, 8)); for (const ic of interchanges) { const accessTargets = [ ...industrialZones.map((p) => ({ ...p, score: 0.95 / (1 + Math.hypot(p.x - ic.x, p.y - ic.y) / 7) })), ...modernCities.map((p) => ({ ...routePoint(p, "road", 8200 + p.x * 7 + p.y), score: 0.72 / (1 + Math.hypot(p.x - ic.x, p.y - ic.y) / 10) })), ...nationalRoadAccessPoints.map((p) => ({ ...p, score: 0.62 / (1 + Math.hypot(p.x - ic.x, p.y - ic.y) / 6), kind: "National Road Access" })), ]; const target = pickEntities(accessTargets, { max: 1, minDistance: 1, threshold: 0, seed: seed + 1134 + ic.x * 3 + ic.y })[0]; if (!target || Math.hypot(target.x - ic.x, target.y - ic.y) > 22) continue; const path = aStar(ic, target, roadCost); if (path.length > 2 && path.length < 36) icAccessRoads.push(path); } const logisticsScore = new Float32Array(SIZE); for (let y = 4; y < MAP_H - 4; y++) { for (let x = 4; x < MAP_W - 4; x++) { const i = indexOf(x, y); if (sea[i]) continue; const nearIC = 1 / (1 + distanceToNearest(interchanges, x, y) / 3); const cityPenalty = distanceToNearest(modernCities, x, y) < 5 ? 0.28 : 0; logisticsScore[i] = clamp(nearIC * 0.56 + plain[i] * 0.24 + roadInfluence[i] * 0.22 + expressInfluence[i] * 0.16 - slope[i] * 0.32 - cityPenalty); } } let logisticsParks = pickPoints(logisticsScore, { threshold: 0.32 + rand(seed, 1141) * 0.1, max: 3 + Math.floor(rand(seed, 1142) * 13), minDistance: 9, seedOffset: 1140, predicate: (x, y, i) => !sea[i], }).map((p) => ({ ...p, kind: "Logistics Park" })); const cityInfluence = influenceFromPoints(modernCities, 34, (p) => p.urbanWeight || 1.2); const cityCoreInfluence = influenceFromPoints(urbanCenters, 11, (p) => p.parent?.coreRadius ? 1.35 + p.parent.coreRadius / 5 : 1.2); const stationInfluence = influenceFromPoints(stations, 10, () => 1); const railInfluence2 = influenceFromPaths([...railways, ...branchRailways, ...ringRailways, ...externalRailways], 6); const satelliteScore = new Float32Array(SIZE); const largeCitiesForSatellites = modernCities.filter((c) => (c.population || 0) >= 320000); for (let y = 4; y < MAP_H - 4; y++) { for (let x = 4; x < MAP_W - 4; x++) { const i = indexOf(x, y); if (sea[i] || !prefectureMask[i]) continue; let ringPull = 0; let parent = null; for (const city of largeCitiesForSatellites) { const d = Math.hypot(city.x - x, city.y - y); const ideal = clamp(11 + Math.sqrt(city.population || 320000) / 150, 13, 27); const v = clamp(1 - Math.abs(d - ideal) / 9); if (v > ringPull) { ringPull = v; parent = city; } } if (!parent) continue; const railPull = Math.max(railInfluence2[i], stationInfluence[i] * 0.84); const separated = distanceToNearest(modernCities, x, y) > 7 ? 1 : 0; satelliteScore[i] = clamp(ringPull * 0.42 + railPull * 0.38 + populationDensity[i] * 0.14 + plain[i] * 0.2 + basinField[i] * 0.08 + agriculture[i] * 0.05 - slope[i] * 0.86 - ridgeField[i] * 0.34 - Math.max(0, elevation[i] - 0.56) * 0.72 + separated * 0.1 + hash2(x, y, seed + 1160) * 0.035); } } let satelliteCities = pickPoints(satelliteScore, { threshold: 0.43 + rand(seed, 1161) * 0.07, max: Math.min(14, 2 + largeCitiesForSatellites.length * 4 + Math.floor(rand(seed, 1162) * 4)), minDistance: 8, seedOffset: 1160, predicate: (x, y, i) => !sea[i] && prefectureMask[i], }).map((p, n) => { const parent = largeCitiesForSatellites.slice().sort((a, b) => Math.hypot(a.x - p.x, a.y - p.y) - Math.hypot(b.x - p.x, b.y - p.y))[0]; const basePop = parent ? parent.population * (0.045 + rand(seed, 1165 + n) * 0.11) : 42000 + rand(seed, 1165 + n) * 90000; return { ...p, kind: "Satellite City", parentCityIndex: parent ? modernCities.indexOf(parent) : -1, population: Math.round(basePop / 1000) * 1000, urbanRadius: 5 + Math.sqrt(basePop) / 135, coreRadius: 1.5 + Math.sqrt(basePop) / 420, urbanWeight: 0.55 + Math.sqrt(basePop) / 720 }; }); const satelliteInfluence = influenceFromPoints(satelliteCities, 16, (p) => p.urbanWeight || 0.8); const oldCoreInfluence = influenceFromPoints([...castleTowns, ...markets, ...ports], 12, () => 1); const industrialInfluence = influenceFromPoints(industrialZones, 9, () => 1); const logisticsInfluence = influenceFromPoints(logisticsParks, 9, () => 1); const interchangeInfluence = influenceFromPoints(interchanges, 8, () => 1); const premodernInfluence = influenceFromPaths(premodernRoads, 4); const villageInfluence = influenceFromPoints(villages, 7, () => 1); const newTownScore = new Float32Array(SIZE); for (let y = 4; y < MAP_H - 4; y++) { for (let x = 4; x < MAP_W - 4; x++) { const i = indexOf(x, y); if (sea[i]) continue; const dCity = distanceToNearest(modernCities, x, y); const ring = dCity > 8 && dCity < 22 ? 1 : 0; const uplandTerrace = elevation[i] > 0.36 && elevation[i] < 0.58 && slope[i] < 0.34 && ridgeField[i] < 0.34 ? 0.24 : 0; newTownScore[i] = clamp(ring * 0.34 + stationInfluence[i] * 0.24 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.1 + plain[i] * 0.14 + uplandTerrace + agriculture[i] * 0.06 - slope[i] * 0.72 - ridgeField[i] * 0.22 - floodplain[i] * 0.22 - satelliteInfluence[i] * 0.18); } } let newTowns = pickPoints(newTownScore, { threshold: 0.32 + rand(seed, 1151) * 0.1, max: 2 + Math.floor(rand(seed, 1152) * 10), minDistance: 11, seedOffset: 1150, predicate: (x, y, i) => !sea[i], }).map((p) => ({ ...p, kind: "New Town" })); const minorRoads = []; const trunkNodes = [...markets, ...modernCities, ...stations.slice(0, 24), ...crossings.slice(0, 16)]; const roadNetInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...expressways, ...ringExpressways, ...externalRoads, ...externalExpressways, ...premodernRoads], 3); function minorRoadCost(x, y) { const i = indexOf(x, y); if (sea[i] || elevation[i] > 0.72) return INF; const barrier = mountainBarrierPenalty(x, y, "minor"); if (barrier >= INF) return INF; return Math.max(0.3, 1 + slope[i] * 8.4 + barrier * 0.55 + Math.max(0, elevation[i] - 0.58) * 4.4 + floodplain[i] * 0.18 + (river[i] > 0.5 ? 1.0 : 0.18 * river[i]) - plain[i] * 0.24 - valleyField[i] * 0.36 - coastalLowland[i] * 0.12 + ridgeField[i] * 0.58 - roadNetInfluence[i] * 0.35 + normalEdgePenalty(x, y) + hash2(x, y, seed + 555) * 0.15); } const connectedPairs = new Set(); function addMinorRoad(a, b) { const key = `${a.x},${a.y}|${b.x},${b.y}`; if (connectedPairs.has(key)) return; connectedPairs.add(key); const path = aStar(a, b, minorRoadCost); if (path.length > 2 && path.length < 90) minorRoads.push(path); } for (const village of villages) { if (rand(seed, village.x * 13 + village.y * 17) < 0.42) { const target = pickEntities(trunkNodes.map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - village.x, p.y - village.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; if (target && Math.hypot(target.x - village.x, target.y - village.y) < 28) addMinorRoad(village, target); } } for (const market of markets) { const localVillages = pickEntities(villages.map((v) => ({ ...v, score: 1 / (1 + Math.hypot(v.x - market.x, v.y - market.y)) })), { max: 3, minDistance: 1, threshold: 0 }); for (const v of localVillages) addMinorRoad(market, v); } for (const pass of passes.slice(0, 8)) { const target = pickEntities([...markets, ...villages].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - pass.x, p.y - pass.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; if (target) addMinorRoad(pass, target); } const newTownInfluence = influenceFromPoints(newTowns, 8, () => 1); const landuse = new Uint8Array(SIZE); for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); if (sea[i]) continue; const mountain = elevation[i] > 0.62 || slope[i] > 0.46 || ridgeField[i] > 0.64; const farm = agriculture[i] > 0.26 && (plain[i] > 0.2 || valleyField[i] > 0.32 || basinField[i] > 0.25); let nearestCity = null; let nearestCityDistance = INF; for (const city of modernCities) { const d = Math.hypot(city.x - x, city.y - y); if (d < nearestCityDistance) { nearestCityDistance = d; nearestCity = city; } } const dCity = nearestCityDistance; const populationScale = nearestCity ? clamp(Math.log10(Math.max(10000, nearestCity.population)) - 4, 0.25, 2.2) : 0.5; const normalizedUrbanDistance = nearestCity ? dCity / Math.max(6, nearestCity.urbanRadius) : 99; const cityClusterBoost = nearestCity ? clamp(1 - normalizedUrbanDistance) * (0.18 + populationScale * 0.16) : 0; const density = populationDensity[i]; const oldTownScore = oldCoreInfluence[i] * 0.64 + premodernInfluence[i] * 0.32 + plain[i] * 0.12 + density * 0.08; const terrainUrbanPenalty = slope[i] * 1.02 + ridgeField[i] * 0.55 + Math.max(0, elevation[i] - 0.56) * 0.56; const nodeCausalPull = Math.max(stationInfluence[i] * 0.18, premodernInfluence[i] * 0.13, coastalLowland[i] * river[i] * 0.12, valleyField[i] * 0.08); const satelliteEnvelope = satelliteInfluence[i] * 0.54; const urbanEnvelope = cityInfluence[i] * 0.58 + cityCoreInfluence[i] * 0.3 + satelliteEnvelope + density * 0.47 + stationInfluence[i] * 0.18 + oldCoreInfluence[i] * 0.14 + newTownInfluence[i] * 0.12 + cityClusterBoost + nodeCausalPull - terrainUrbanPenalty; const coreScore = cityCoreInfluence[i] * 0.74 + urbanEnvelope * 0.3 + density * 0.36 + satelliteInfluence[i] * 0.16 + stationInfluence[i] * 0.06 + railInfluence2[i] * 0.04 - slope[i] * 0.82 - ridgeField[i] * 0.28; const suburbScore = urbanEnvelope * 0.54 + density * 0.14 + satelliteInfluence[i] * 0.22 + stationInfluence[i] * 0.09 + roadInfluence[i] * 0.05 + railInfluence2[i] * 0.05 + plain[i] * 0.16 + valleyField[i] * 0.04 + populationScale * 0.05 + (coreScore < 0.58 ? 0.05 : 0) - slope[i] * 0.76 - ridgeField[i] * 0.22; const roadsideScore = interchangeInfluence[i] * 0.54 + logisticsInfluence[i] * 0.18 + roadInfluence[i] * 0.1 + plain[i] * 0.1 - cityInfluence[i] * 0.02; const isolatedCorridor = roadInfluence[i] > 0.22 && cityInfluence[i] < 0.08 && stationInfluence[i] < 0.08 && interchangeInfluence[i] < 0.18; const ruralScore = villageInfluence[i] * 0.3 + agriculture[i] * 0.38 + plain[i] * 0.18 - slope[i] * 0.08; if (mountain) landuse[i] = 9; else if (industrialInfluence[i] > 0.44) landuse[i] = 5; else if (logisticsInfluence[i] > 0.42) landuse[i] = 6; else if (newTownInfluence[i] > 0.42 && urbanEnvelope > 0.16) landuse[i] = 7; else if (coreScore > 0.68 && density > 0.48 && stationInfluence[i] > 0.05 && slope[i] < 0.24 && ridgeField[i] < 0.36) landuse[i] = 3; else if (oldTownScore > 0.49) landuse[i] = 2; else if (suburbScore > 0.235 && !isolatedCorridor && slope[i] < 0.32 && ridgeField[i] < 0.48 && (normalizedUrbanDistance < 1.42 || satelliteInfluence[i] > 0.24)) landuse[i] = 4; else if (roadsideScore > 0.5 && plain[i] > 0.18 && slope[i] < 0.34 && ridgeField[i] < 0.5 && !isolatedCorridor && (interchangeInfluence[i] > 0.24 || logisticsInfluence[i] > 0.16 || cityInfluence[i] > 0.09)) landuse[i] = 8; else if (farm) landuse[i] = 1; else if (ruralScore > 0.3) landuse[i] = 0; else landuse[i] = 0; } } function hasUrbanNeighborCluster(x, y, radius = 2, minUrban = 7) { let urban = 0; for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { const nx = x + dx; const ny = y + dy; if (!inside(nx, ny)) continue; const lu = landuse[indexOf(nx, ny)]; if (lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8) urban++; } } return urban >= minUrban; } function removeIsolatedUrbanPatches(maxCells = 22) { const seen = new Uint8Array(SIZE); const namedCenters = [...modernCities, ...(satelliteCities || []), ...markets, ...ports, ...newTowns, ...stations]; const queue = []; for (let i = 0; i < SIZE; i++) { if (seen[i] || !prefectureMask[i] || sea[i]) continue; const lu0 = landuse[i]; if (!(lu0 >= 2 && lu0 <= 8)) continue; const component = []; let maxDensity = 0; queue.length = 0; queue.push(i); seen[i] = 1; for (let q = 0; q < queue.length; q++) { const cur = queue[q]; component.push(cur); maxDensity = Math.max(maxDensity, populationDensity[cur]); const [x, y] = xyOf(cur); for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; if (!(landuse[ni] >= 2 && landuse[ni] <= 8)) continue; seen[ni] = 1; queue.push(ni); } } if (component.length > maxCells) continue; let hasAnchor = false; for (const ci of component) { const [x, y] = xyOf(ci); if (distanceToNearest(namedCenters, x, y) <= 5.8) { hasAnchor = true; break; } } if (!hasAnchor) { for (const ci of component) landuse[ci] = agriculture[ci] > 0.34 ? 1 : 0; } } } for (let pass = 0; pass < 2; pass++) removeIsolatedUrbanPatches(36); // CBD is no longer a marker. It is a DID-like contiguous high-density core: // first remove isolated core cells, then grow connected high-density cells // from each urban center according to population scale. for (let y = 1; y < MAP_H - 1; y++) { for (let x = 1; x < MAP_W - 1; x++) { const i = indexOf(x, y); if (landuse[i] === 3 && !hasUrbanNeighborCluster(x, y, 2, 8)) landuse[i] = 4; } } function growDidCore(center, city, salt) { if (!center || !city) return 0; const start = indexOf(center.x, center.y); if (sea[start] || !prefectureMask[start]) return 0; if ((city.population || 0) < 220000) return 0; const targetCells = Math.round(clamp(2 + Math.sqrt(city.population || 80000) / 74, 4, 22)); const maxRadius = clamp((city.coreRadius || 3) * 2.4 + Math.sqrt(city.population || 80000) / 260, 6, 16); const selected = new Set(); const queued = new Set([start]); const heap = new MinHeap(); heap.push({ i: start, f: -10 }); let made = 0; while (heap.length > 0 && made < targetCells) { const cur = heap.pop(); if (!cur || selected.has(cur.i)) continue; const [x, y] = xyOf(cur.i); const i = cur.i; const d = Math.hypot(x - center.x, y - center.y); const support = populationDensity[i] * 1.18 + cityInfluence[i] * 0.22 + stationInfluence[i] * 0.18 + plain[i] * 0.12 - slope[i] * 1.24 - ridgeField[i] * 0.54 - Math.max(0, elevation[i] - 0.58) * 0.50 - floodplain[i] * 0.08 - d / maxRadius * 0.22; if (d > maxRadius || support < 0.44 || sea[i] || !prefectureMask[i]) continue; if (!(landuse[i] === 2 || landuse[i] === 3 || landuse[i] === 4 || landuse[i] === 7 || populationDensity[i] > 0.22 || stationInfluence[i] > 0.14)) continue; selected.add(i); landuse[i] = 3; made++; for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); if (queued.has(ni) || selected.has(ni) || sea[ni] || !prefectureMask[ni]) continue; const nd = Math.hypot(nx - center.x, ny - center.y); if (nd > maxRadius + 1) continue; const score = populationDensity[ni] * 1.24 + cityInfluence[ni] * 0.22 + stationInfluence[ni] * 0.18 + plain[ni] * 0.12 - slope[ni] * 1.25 - ridgeField[ni] * 0.54 - nd / maxRadius * 0.22 + hash2(nx, ny, seed + salt) * 0.03; queued.add(ni); heap.push({ i: ni, f: -score }); } } return made; } urbanCenters.forEach((center, n) => growDidCore(center, center.parent || modernCities[n], 9400 + n * 17)); for (let pass = 0; pass < 3; pass++) removeIsolatedUrbanPatches(42); for (let y = 1; y < MAP_H - 1; y++) { for (let x = 1; x < MAP_W - 1; x++) { const i = indexOf(x, y); if (landuse[i] === 3 && !hasUrbanNeighborCluster(x, y, 2, 8)) landuse[i] = 4; } } return { ports, crossings, passes, settlementCluster, settlementScore, villages, markets, castles, premodernRoads, minorRoads, castleTowns, modernCities, populationDensity, railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways, interchanges, logisticsParks, satelliteCities, newTowns, landuse, stationInfluence, roadInfluence, railInfluence2, villageInfluence, externalGateways, cityPopulationCap, }; }