diff --git a/adminRegions.js b/adminRegions.js index 0339e0c..5dc1894 100644 --- a/adminRegions.js +++ b/adminRegions.js @@ -156,12 +156,31 @@ export function lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, } } -export function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320) { +export function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320, options = {}) { const area = new Map(); const pop = new Map(); const adjacency = new Map(); const cityMunicipalities = new Set(); - for (const city of modernCities || []) if (inside(city.x, city.y)) cityMunicipalities.add(adminId[indexOf(city.x, city.y)]); + for (const city of modernCities || []) { + if (!inside(city.x, city.y)) continue; + const id = adminId[indexOf(city.x, city.y)]; + if (id < 0) continue; + if (options.protectAllModernCities !== false || city.isPrefecturalCapital || (city.population || 0) >= (options.majorCityPopulationThreshold || 120000)) cityMunicipalities.add(id); + } + for (const point of options.protectedPoints || []) { + if (!point || !inside(point.x, point.y)) continue; + const id = adminId[indexOf(point.x, point.y)]; + if (id >= 0) cityMunicipalities.add(id); + } + const satelliteByAdmin = new Map(); + for (const sat of options.satelliteCities || []) { + if (!sat || !inside(sat.x, sat.y)) continue; + const id = adminId[indexOf(sat.x, sat.y)]; + if (id < 0) continue; + if (!satelliteByAdmin.has(id)) satelliteByAdmin.set(id, []); + satelliteByAdmin.get(id).push(sat); + } + const satelliteStats = options.satelliteStats || null; for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { @@ -187,16 +206,35 @@ export function mergeTinyMunicipalities(adminId, prefectureMask, sea, population for (const [id, cells] of area) { const score = cells + (pop.get(id) || 0) * 16; if (cells >= minArea || cityMunicipalities.has(id)) continue; + const satellites = satelliteByAdmin.get(id) || []; + const protectedSatellite = satellites.some((sat) => { + const minSatelliteArea = sat.satelliteMinArea || options.satelliteMinArea || 110; + return sat.municipalityClass === "independentSatelliteMunicipality" && ( + cells >= minSatelliteArea || + (sat.population || 0) >= (options.satelliteIndependentPopulationThreshold || 60000) || + (sat.distinctUrbanComponentArea || 0) >= 80 || + sat.separatedByBarrier + ); + }); + if (protectedSatellite) continue; let bestNeighbor = -1; let bestScore = -1; for (const [key, border] of adjacency) { const [a, b] = key.split(":").map(Number); if (a !== id && b !== id) continue; const other = a === id ? b : a; - const candidate = border * 3 + (area.get(other) || 0) * 0.012 + (pop.get(other) || 0) * 0.24; + const parentBias = satellites.some((sat) => inside(sat.parentX ?? -1, sat.parentY ?? -1) && adminId[indexOf(sat.parentX, sat.parentY)] === other) ? 26 : 0; + const ruralBias = satellites.some((sat) => sat.municipalityClass === "smallTownAttachedToRuralMunicipality") ? Math.min(12, (area.get(other) || 0) * 0.01) : 0; + const candidate = border * 3 + (area.get(other) || 0) * 0.012 + (pop.get(other) || 0) * 0.24 + parentBias + ruralBias; if (candidate > bestScore) { bestScore = candidate; bestNeighbor = other; } } - if (bestNeighbor >= 0 && (area.get(bestNeighbor) || 0) >= score * 0.35) mergeTarget.set(id, bestNeighbor); + if (bestNeighbor >= 0 && (area.get(bestNeighbor) || 0) >= score * 0.35) { + mergeTarget.set(id, bestNeighbor); + if (satelliteStats && satellites.length) { + satelliteStats.satelliteMunicipalitiesMerged += satellites.length; + for (const sat of satellites) sat.mergedMunicipalityTarget = bestNeighbor; + } + } } if (mergeTarget.size === 0) return; for (let i = 0; i < SIZE; i++) if (mergeTarget.has(adminId[i])) adminId[i] = mergeTarget.get(adminId[i]); @@ -465,6 +503,119 @@ function classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyFie return 11; } +export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null) { + const score = new Float32Array(SIZE); + for (let i = 0; i < SIZE; i++) { + if (!prefectureMask[i] || sea[i]) continue; + const urbanContinuity = populationDensity && landuse ? urbanBoundaryPenalty(i, populationDensity, landuse) : 0; + const majorRiver = clamp(Math.max(river[i] - 0.34, 0) * 1.95 + Math.max(flowAccum[i] - 0.42, 0) * 0.82); + const ridgeDivide = clamp(ridgeField[i] * 1.65 + Math.max(0, elevation[i] - 0.52) * ridgeField[i] * 1.05); + const crest = crestCrossingScore ? crestCrossingScore[i] : clamp(Math.max(0, elevation[i] - 0.55) * ridgeField[i] * 1.4 + slope[i] * ridgeField[i] * 0.8); + const basinRim = basinField ? clamp(Math.max(0, basinField[i] - 0.32) * Math.max(0, slope[i] - 0.18) * 1.15 + Math.max(0, ridgeField[i] - 0.34) * basinField[i] * 0.62) : 0; + const foothillBreak = clamp(Math.max(0, slope[i] - 0.30) * Math.max(ridgeField[i], Math.max(0, elevation[i] - 0.48)) * 0.82); + const livingCorridor = clamp((plain?.[i] || 0) * 0.34 + (agriculture?.[i] || 0) * 0.26 + valleyField[i] * (majorRiver > 0.34 ? 0.10 : 0.46) + coastalLowland[i] * 0.18); + score[i] = clamp( + ridgeDivide * 0.92 + + crest * 0.72 + + majorRiver * 0.86 + + basinRim * 0.54 + + foothillBreak * 0.48 + + terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity || score, landuse || score) * 0.38 - + livingCorridor * 0.50 - + urbanContinuity * 0.72 + ); + } + return score; +} + +function canShareNaturalCompartment(a, b, classA, classB, barrier, river, flowAccum, valleyField, populationDensity, landuse) { + if (classA !== classB) { + const bothUrban = classA <= 3 && classB <= 3; + const bothLivingCorridor = [5, 6, 7, 10].includes(classA) && [5, 6, 7, 10].includes(classB); + if (!bothUrban && !bothLivingCorridor) return false; + } + const majorRiverEdge = Math.max(river[a], river[b]) > 0.58 || Math.max(flowAccum[a], flowAccum[b]) > 0.72; + const urbanEdge = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.34) && + ((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.34); + const valleyContinuity = (valleyField[a] + valleyField[b]) * 0.5 > 0.42 && !majorRiverEdge; + const threshold = urbanEdge ? 0.84 : valleyContinuity ? 0.76 : classA === 8 || classB === 8 ? 0.42 : 0.62; + return barrier < threshold && (!majorRiverEdge || urbanEdge); +} + +function naturalGroupKey(unit) { + if (unit.classId <= 3) return `urban:${Math.round(unit.x / 10)}:${Math.round(unit.y / 10)}`; + if (unit.classId === 5) return `coast:${Math.round(unit.y / 8)}`; + if (unit.classId === 6) return `basin:${Math.round(unit.x / 12)}:${Math.round(unit.y / 12)}`; + if (unit.classId === 7) return `valley:${Math.round((unit.x + unit.y) / 12)}`; + if (unit.classId === 8 || unit.classId === 9) return `mountain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`; + return `plain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`; +} + +export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null) { + const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse); + const compartmentId = new Int32Array(SIZE); + compartmentId.fill(-1); + const cellClass = new Int16Array(SIZE); + cellClass.fill(-1); + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse); + + const compartments = []; + const queue = []; + for (let i = 0; i < SIZE; i++) { + if (cellClass[i] < 0 || compartmentId[i] >= 0) continue; + const id = compartments.length; + const startClass = cellClass[i]; + const cells = []; + let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = 0, coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0; + queue.length = 0; + queue.push(i); + compartmentId[i] = id; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + const [x, y] = xyOf(cur); + cells.push(cur); + sx += x; sy += y; pop += populationDensity[cur]; + urbanWeight += urbanBoundaryPenalty(cur, populationDensity, landuse); + ridgeExposure += ridgeField[cur]; + riverExposure += river[cur] + flowAccum[cur] * 0.45; + coastalExposure += coastalLowland[cur]; + basinIdentity += basinField[cur]; + valleyIdentity += valleyField[cur]; + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (compartmentId[ni] >= 0 || cellClass[ni] < 0) continue; + const edgeBarrier = (naturalBarrierScore[cur] + naturalBarrierScore[ni]) * 0.5; + if (!canShareNaturalCompartment(cur, ni, startClass, cellClass[ni], edgeBarrier, river, flowAccum, valleyField, populationDensity, landuse)) continue; + compartmentId[ni] = id; + queue.push(ni); + } + } + const area = cells.length; + compartments.push({ + id, + cells, + area, + x: sx / area, + y: sy / area, + classId: startClass, + dominantLandscapeClass: startClass, + population: pop, + urbanWeight: urbanWeight / area, + ridgeExposure: ridgeExposure / area, + riverExposure: riverExposure / area, + coastalExposure: coastalExposure / area, + basinIdentity: basinIdentity / area, + valleyIdentity: valleyIdentity / area, + centerIds: [], + adjacent: new Map(), + }); + } + rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + mergeTinyLandscapeUnits(compartmentId, compartments, 12); + rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + return { compartmentId, compartments, naturalBarrierScore }; +} + function buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse) { const unitId = new Int32Array(SIZE); unitId.fill(-1); @@ -559,52 +710,124 @@ function mergeTinyLandscapeUnits(unitId, units, minArea = 10) { } } -function landscapeTransitionCost(a, b, edge) { +function naturalOwnershipAffinity(unit, neighbor, edge) { const boundaryTarget = edge.target / Math.max(1, edge.count); - const bothUrban = a.classId <= 3 && b.classId <= 3; - const bothCorridor = (a.classId === 5 || a.classId === 7 || a.classId === 10) && (b.classId === 5 || b.classId === 7 || b.classId === 10); - const urbanContinuity = bothUrban ? 2.1 : (a.urbanWeight + b.urbanWeight) > 0.75 && Math.abs(a.urbanWeight - b.urbanWeight) < 0.35 ? 0.9 : 0; - return Math.max(0.18, 0.70 + boundaryTarget * 4.2 + (a.classId === b.classId ? 0 : 0.75) + ((a.classId === 8 || b.classId === 8) ? 1.2 : 0) - urbanContinuity - (bothCorridor ? 0.55 : 0)); + const sameClass = unit.classId === neighbor.classId ? 1.0 : 0; + const sameGroup = naturalGroupKey(unit) === naturalGroupKey(neighbor) ? 1.1 : 0; + const bothUrban = unit.classId <= 3 && neighbor.classId <= 3; + const bothCorridor = [5, 6, 7, 10].includes(unit.classId) && [5, 6, 7, 10].includes(neighbor.classId); + const urbanContinuity = bothUrban ? 1.35 : (unit.urbanWeight + neighbor.urbanWeight) > 0.75 && Math.abs(unit.urbanWeight - neighbor.urbanWeight) < 0.35 ? 0.58 : 0; + const strongDividerPenalty = boundaryTarget * (edge.count > 2 ? 2.8 : 1.8); + return edge.count * 0.55 + sameClass + sameGroup + urbanContinuity + (bothCorridor ? 0.72 : 0) - strongDividerPenalty; } -export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) { - const { unitId, units, targetScore } = buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse); - if (units.length === 0) return; - for (let id = 0; id < adminCenters.length; id++) { - const center = adminCenters[id]; - if (!center || !inside(center.x, center.y)) continue; - const unit = units[unitId[indexOf(center.x, center.y)]]; - if (unit) unit.centerIds.push(id); - } - - const owner = new Int16Array(units.length); - const dist = new Float32Array(units.length); - owner.fill(-1); dist.fill(INF); - const heap = new MinHeap(); - for (const unit of units) { - if (unit.area === 0 || unit.centerIds.length === 0) continue; - const id = unit.centerIds[0]; - owner[unit.id] = id; dist[unit.id] = 0; heap.push({ i: unit.id, f: 0 }); - } - while (heap.length) { - const cur = heap.pop(); - if (!cur || cur.f > dist[cur.i] + 1e-5) continue; - const unit = units[cur.i]; - const currentOwner = owner[cur.i]; - if (!unit || currentOwner < 0) continue; - for (const [nextId, edge] of unit.adjacent) { - const next = units[nextId]; - if (!next || next.area === 0) continue; - const nextDist = dist[cur.i] + landscapeTransitionCost(unit, next, edge) + Math.sqrt(next.area) * 0.012 + (next.urbanWeight > 0.75 && next.centerIds.length === 0 ? -0.20 : 0); - if (nextDist < dist[nextId]) { - dist[nextId] = nextDist; owner[nextId] = currentOwner; heap.push({ i: nextId, f: nextDist }); +function averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore) { + let sum = 0; + let count = 0; + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || adminId[ni] === adminId[i]) continue; + sum += (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5; + count++; } } } - for (const unit of units) { + return count ? sum / count : 0; +} + +function weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore) { + let weak = 0; + let total = 0; + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + const ni = indexOf(nx, ny); + const a = adminId[i], b = adminId[ni]; + if (!prefectureMask[ni] || sea[ni] || a < 0 || b < 0 || a === b) continue; + total++; + const ca = adminCenters[a], cb = adminCenters[b]; + if (!ca || !cb) continue; + const mx = (x + nx) * 0.5, my = (y + ny) * 0.5; + const nearBisector = Math.abs(Math.hypot(mx - ca.x, my - ca.y) - Math.hypot(mx - cb.x, my - cb.y)) < 4.0; + if (nearBisector && (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5 < 0.38) weak++; + } + } + } + return total ? weak / total : 0; +} + +export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) { + const before = new Int16Array(adminId); + const initialNaturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse); + const beforeVoronoiLikeRate = weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, initialNaturalBarrierScore); + const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse); + if (compartments.length === 0) return; + for (let id = 0; id < adminCenters.length; id++) { + const center = adminCenters[id]; + if (!center || !inside(center.x, center.y)) continue; + const unit = compartments[compartmentId[indexOf(center.x, center.y)]]; + if (unit) unit.centerIds.push(id); + } + + const owner = new Int16Array(compartments.length); + owner.fill(-1); + for (const unit of compartments) { + if (unit.area === 0 || unit.centerIds.length === 0) continue; + owner[unit.id] = unit.centerIds[0]; + } + + for (let pass = 0; pass < compartments.length + 4; pass++) { + let changed = 0; + for (const unit of compartments) { + if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue; + let bestOwner = -1; + let bestScore = -INF; + for (const [neighborId, edge] of unit.adjacent) { + const neighborOwner = owner[neighborId]; + if (neighborOwner < 0) continue; + const neighbor = compartments[neighborId]; + if (!neighbor || neighbor.area === 0) continue; + const score = naturalOwnershipAffinity(unit, neighbor, edge) + Math.min(0.8, Math.sqrt(Math.max(1, neighbor.area)) * 0.018); + if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; } + } + const accept = unit.classId <= 3 ? bestScore > -0.15 : unit.classId === 8 || unit.classId === 9 ? bestScore > -0.80 : bestScore > -0.45; + if (bestOwner >= 0 && accept) { owner[unit.id] = bestOwner; changed++; } + } + if (changed === 0) break; + } + + for (const unit of compartments) { + if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue; + let bestId = -1, bestScore = -INF; + for (let id = 0; id < adminCenters.length; id++) { + const center = adminCenters[id]; + if (!center || !inside(center.x, center.y)) continue; + const centerComp = compartments[compartmentId[indexOf(center.x, center.y)]]; + const sameGroup = centerComp && naturalGroupKey(centerComp) === naturalGroupKey(unit) ? 2.4 : 0; + const sameClass = centerComp && centerComp.classId === unit.classId ? 0.9 : 0; + const urbanFit = unit.urbanWeight > 0.55 && centerComp?.urbanWeight > 0.55 ? 1.2 : 0; + const d = Math.hypot(unit.x - center.x, unit.y - center.y); + const score = sameGroup + sameClass + urbanFit - d * 0.018 - unit.ridgeExposure * 0.18; + if (score > bestScore) { bestScore = score; bestId = id; } + } + owner[unit.id] = bestId >= 0 ? bestId : 0; + } + + for (const unit of compartments) { const assigned = owner[unit.id]; if (assigned >= 0) for (const i of unit.cells) adminId[i] = assigned; } + for (let i = 0; i < SIZE; i++) { + if (!prefectureMask[i] || sea[i] || adminId[i] >= 0) continue; + const comp = compartments[compartmentId[i]]; + adminId[i] = comp && owner[comp.id] >= 0 ? owner[comp.id] : 0; + } for (let id = 0; id < adminCenters.length; id++) { const center = adminCenters[id]; if (!center || !inside(center.x, center.y)) continue; @@ -616,7 +839,18 @@ export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, e if (prefectureMask[i] && !sea[i]) adminId[i] = id; } } - repairAdminTopology(adminId, prefectureMask, sea, adminCenters, targetScore, populationDensity, landuse); + repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse); + let changedCells = 0; + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++; + const activeCompartments = compartments.filter((unit) => unit.area > 0); + applyLandscapeUnitAdminPartition.lastDebug = { + compartmentCount: activeCompartments.length, + averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0, + changedAfterNaturalCompartmentPartition: changedCells, + finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore), + voronoiLikeRateBefore: beforeVoronoiLikeRate, + voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore), + }; } export function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCenters = [], protectedPoints = [], passes = 6) { @@ -659,3 +893,83 @@ export function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, eleva adminId.set(current); repairAdminTopology(adminId, prefectureMask, sea, adminCenters, targetScore, populationDensity, landuse); } + +export function splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], settlements = []) { + const before = new Int16Array(adminId); + const area = new Map(); + const lowland = new Map(); + const mountain = new Map(); + for (let i = 0; i < SIZE; i++) { + if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; + const id = adminId[i]; + area.set(id, (area.get(id) || 0) + 1); + const living = (plain[i] || 0) * 0.42 + (agriculture[i] || 0) * 0.28 + basinField[i] * 0.20 + coastalLowland[i] * 0.20 + valleyField[i] * 0.12; + const rough = ridgeField[i] * 0.54 + slope[i] * 0.36 + Math.max(0, elevation[i] - 0.58) * 0.38; + lowland.set(id, (lowland.get(id) || 0) + living); + mountain.set(id, (mountain.get(id) || 0) + rough); + } + const areas = [...area.values()].sort((a, b) => a - b); + const median = areas.length ? areas[Math.floor(areas.length / 2)] : 0; + if (!median) return { changedCells: 0, splitMunicipalities: 0 }; + + const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse); + const unitOwner = new Int16Array(compartments.length); + unitOwner.fill(-1); + for (const unit of compartments) { + if (!unit || unit.area === 0) continue; + const counts = new Map(); + for (const i of unit.cells) { + const id = adminId[i]; + if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); + } + let bestId = -1, best = -1; + for (const [id, count] of counts) if (count > best) { best = count; bestId = id; } + unitOwner[unit.id] = bestId; + } + + const adminCenterIndex = new Map(); + for (let id = 0; id < adminCenters.length; id++) { + const c = adminCenters[id]; + if (c && inside(c.x, c.y)) adminCenterIndex.set(id, indexOf(c.x, c.y)); + } + + let splitMunicipalities = 0; + for (const [id, cells] of area) { + const averageLowland = (lowland.get(id) || 0) / cells; + const averageMountain = (mountain.get(id) || 0) / cells; + if (cells < median * 2.25 || averageLowland < 0.28 || averageMountain > 0.44) continue; + const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id); + const meaningfulNodes = localSettlements.filter((p) => p.kind === "Satellite City" || p.kind === "New Town" || p.kind === "Market Town" || (p.population || 0) >= 30000); + if (meaningfulNodes.length < 2) continue; + let changedHere = 0; + for (const unit of compartments) { + if (!unit || unit.area === 0 || unitOwner[unit.id] !== id) continue; + const centerIndex = adminCenterIndex.get(id); + if (centerIndex >= 0 && unit.cells.includes(centerIndex)) continue; + if (unit.classId === 8 || unit.classId === 9) continue; + let bestNeighbor = -1; + let bestScore = -INF; + for (const [neighborId, edge] of unit.adjacent) { + const neighborOwner = unitOwner[neighborId]; + if (neighborOwner < 0 || neighborOwner === id) continue; + const boundaryTarget = edge.target / Math.max(1, edge.count); + const neighbor = compartments[neighborId]; + const nodePull = meaningfulNodes.reduce((best, p) => Math.max(best, 1 / (1 + Math.hypot(p.x - unit.x, p.y - unit.y) / 6)), 0); + const score = edge.count * 0.7 + boundaryTarget * 1.4 + nodePull * 1.2 - Math.max(0, (neighbor?.ridgeExposure || 0) - unit.ridgeExposure) * 0.35; + if (score > bestScore) { bestScore = score; bestNeighbor = neighborOwner; } + } + if (bestNeighbor < 0 || bestScore < 2.2) continue; + for (const ci of unit.cells) { + if (adminId[ci] === id) { + adminId[ci] = bestNeighbor; + changedHere++; + } + } + } + if (changedHere > Math.max(28, cells * 0.035)) splitMunicipalities++; + } + repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse); + let changedCells = 0; + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++; + return { changedCells, splitMunicipalities }; +} diff --git a/app.js b/app.js index 3a77f36..4fb719e 100644 --- a/app.js +++ b/app.js @@ -11,6 +11,8 @@ const modes = [ ["development", "Development"], ["landuse", "Land Use"], ["admin", "Municipal Borders"], + ["admin-debug", "Admin Debug"], + ["borders-debug", "Borders Debug"], ]; const state = { @@ -76,6 +78,8 @@ function getStats(map) { ["Logistics Parks", countText(map.logisticsParks)], ["New Towns", countText(map.newTowns)], ["Municipalities", map.adminCenters.length], + ["Admin changed cells", map.adminDebug ? `${map.adminDebug.changedAfterLandscapePartition || 0} partition / ${map.adminDebug.changedAfterSnap || 0} snap` : "-"], + ["Regional changed cells", map.regionalDebug?.regionalChangedAfterNaturalPartition ?? "-"], ]; } diff --git a/mapAdminStage.js b/mapAdminStage.js new file mode 100644 index 0000000..f861e81 --- /dev/null +++ b/mapAdminStage.js @@ -0,0 +1,410 @@ +import { + applyLandscapeUnitAdminPartition, + generateAdminRegions, + lockSmallUrbanComponentsToMunicipality, + mergeTinyMunicipalities, + removeMunicipalExclaves, + smoothAdminRegionsTerrainAware, + splitOversizedRuralMunicipalities, + snapAdminBoundariesToTerrain, +} from "./adminRegions.js"; +import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, xyOf } from "./mapUtils.js"; +import { extractAdminBorderSegments } from "./mapGeneratorHelpers.js"; + +function changedCellsSince(before, after, prefectureMask, sea) { + let changed = 0; + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== after[i]) changed++; + return changed; +} + +function municipalityAreaById(adminId, prefectureMask, sea) { + const area = new Map(); + for (let i = 0; i < SIZE; i++) { + if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; + area.set(adminId[i], (area.get(adminId[i]) || 0) + 1); + } + return area; +} + +function estimateUrbanComponentArea(city, prefectureMask, sea, landuse, populationDensity) { + if (!city || !inside(city.x, city.y)) return 0; + const start = indexOf(city.x, city.y); + if (!prefectureMask[start] || sea[start]) return 0; + const radius = Math.ceil(Math.max(7, (city.urbanRadius || 6) * 1.7)); + const seen = new Uint8Array(SIZE); + const queue = [start]; + seen[start] = 1; + let area = 0; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + const [x, y] = xyOf(cur); + const d = Math.hypot(x - city.x, y - city.y); + if (d > radius) continue; + const urban = (landuse[cur] >= 2 && landuse[cur] <= 4) || landuse[cur] === 7 || landuse[cur] === 8 || populationDensity[cur] > 0.18; + if (!urban) continue; + area++; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx, ny = y + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + return area; +} + +function terrainSeparationBetween(a, b, ridgeField, river, flowAccum, populationDensity, landuse) { + if (!a || !b) return { separatedByBarrier: false, ruralGap: false, averageDensity: 0, maxBarrier: 0 }; + const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y))); + let maxBarrier = 0; + let lowUrbanRun = 0; + let bestLowUrbanRun = 0; + let densitySum = 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)) continue; + const i = indexOf(x, y); + const barrier = Math.max(ridgeField[i] * 0.95, river[i] * 0.85, flowAccum[i] * 0.42); + maxBarrier = Math.max(maxBarrier, barrier); + densitySum += populationDensity[i]; + const urban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.20; + if (urban) lowUrbanRun = 0; + else { + lowUrbanRun++; + bestLowUrbanRun = Math.max(bestLowUrbanRun, lowUrbanRun); + } + } + return { + separatedByBarrier: maxBarrier > 0.56, + ruralGap: bestLowUrbanRun >= 4, + averageDensity: densitySum / (steps + 1), + maxBarrier, + }; +} + +function classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, ridgeField, river, flowAccum) { + let independent = 0; + let attached = 0; + for (const sat of satelliteCities || []) { + if (!sat || !inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)] || sea[indexOf(sat.x, sat.y)]) continue; + const parent = modernCities[sat.parentCityIndex] || modernCities.slice().sort((a, b) => Math.hypot(a.x - sat.x, a.y - sat.y) - Math.hypot(b.x - sat.x, b.y - sat.y))[0]; + const parentDistance = parent ? Math.hypot(parent.x - sat.x, parent.y - sat.y) : 99; + const separation = terrainSeparationBetween(sat, parent, ridgeField, river, flowAccum, populationDensity, landuse); + const urbanArea = estimateUrbanComponentArea(sat, prefectureMask, sea, landuse, populationDensity); + const i = indexOf(sat.x, sat.y); + const continuousUrban = parent && parentDistance < Math.max(10, (parent.urbanRadius || 12) + (sat.urbanRadius || 5) + 5) && separation.averageDensity > 0.14 && !separation.ruralGap && !separation.separatedByBarrier; + const newTownLike = landuse[i] === 7 || (railInfluence2[i] > 0.22 && roadInfluence[i] > 0.12 && (sat.population || 0) < 70000); + let municipalityClass = "independentSatelliteMunicipality"; + if (continuousUrban && (sat.population || 0) < 90000) municipalityClass = "suburbanDistrictMergedWithParent"; + else if (newTownLike && (sat.population || 0) < 85000 && !separation.separatedByBarrier) municipalityClass = "newTownDistrict"; + else if ((sat.population || 0) < 42000 && urbanArea < 55 && !separation.separatedByBarrier) municipalityClass = "smallTownAttachedToRuralMunicipality"; + else if ((sat.population || 0) >= 60000 && urbanArea >= 42 && (separation.separatedByBarrier || separation.ruralGap || parentDistance > 15)) municipalityClass = "independentSatelliteMunicipality"; + + sat.municipalityClass = municipalityClass; + sat.parentX = parent?.x; + sat.parentY = parent?.y; + sat.parentAdminHint = -1; + sat.distinctUrbanComponentArea = urbanArea; + sat.separatedByBarrier = separation.separatedByBarrier || separation.ruralGap; + sat.satelliteMinArea = clamp(90 + Math.sqrt(sat.population || 24000) * 0.62 + (sat.urbanRadius || 5) * 12, 80, 360); + if (municipalityClass === "independentSatelliteMunicipality") independent++; + else attached++; + } + return { independent, attached }; +} + +function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, context) { + const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities } = context; + if (!satellite || targetAdmin < 0 || !inside(satellite.x, satellite.y)) return 0; + const start = indexOf(satellite.x, satellite.y); + if (!prefectureMask[start] || sea[start]) return 0; + const targetAreaBase = clamp(90 + Math.sqrt(satellite.population || 24000) * 0.8 + (satellite.urbanRadius || 5) * 18, 120, 520); + const targetArea = satellite.municipalityClass === "smallTownAttachedToRuralMunicipality" + ? Math.min(130, targetAreaBase * 0.55) + : satellite.municipalityClass === "suburbanDistrictMergedWithParent" || satellite.municipalityClass === "newTownDistrict" + ? Math.min(190, targetAreaBase * 0.62) + : targetAreaBase; + const maxCost = satellite.municipalityClass === "independentSatelliteMunicipality" ? 46 : 32; + const heap = new MinHeap(); + const best = new Float32Array(SIZE); + best.fill(INF); + heap.push({ i: start, f: 0 }); + best[start] = 0; + const claimed = []; + while (heap.length > 0 && claimed.length < targetArea) { + const cur = heap.pop(); + if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue; + const [x, y] = xyOf(cur.i); + const d = Math.hypot(x - satellite.x, y - satellite.y); + if (!prefectureMask[cur.i] || sea[cur.i]) continue; + let invadesOtherCore = false; + for (const city of modernCities || []) { + if (!city || (city.population || 0) < 140000) continue; + if (Math.hypot(city.x - satellite.x, city.y - satellite.y) < 4) continue; + if (Math.hypot(city.x - x, city.y - y) <= Math.max(3.5, (city.coreRadius || 4) * 1.25)) { + invadesOtherCore = true; + break; + } + } + if (invadesOtherCore) continue; + const compatible = d <= (satellite.urbanRadius || 5) * 1.25 || + [2, 3, 4, 7, 8].includes(landuse[cur.i]) || + populationDensity[cur.i] > 0.12 || + roadInfluence[cur.i] > 0.12 || + railInfluence2[cur.i] > 0.10 || + stationInfluence?.[cur.i] > 0.10 || + basinField[cur.i] > 0.22 || + valleyField[cur.i] > 0.24 || + coastalLowland[cur.i] > 0.20; + if (!compatible && claimed.length > targetArea * 0.55) continue; + claimed.push(cur.i); + for (const [dx, dy, step] of [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [-1, 1, 1.41], [1, -1, 1.41], [-1, -1, 1.41]]) { + const nx = x + dx, ny = y + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const barrier = ridgeField[ni] * 5.2 + Math.max(0, elevation[ni] - 0.58) * 4.0 + slope[ni] * 2.2 + (river[ni] > 0.55 || flowAccum[ni] > 0.70 ? 7.5 : river[ni] > 0.30 ? 2.8 : 0); + const living = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.2 : 0) + populationDensity[ni] * 2.0 + roadInfluence[ni] * 0.85 + railInfluence2[ni] * 0.95 + (stationInfluence?.[ni] || 0) * 1.2 + basinField[ni] * 0.42 + valleyField[ni] * 0.48 + coastalLowland[ni] * 0.32; + const distanceCost = Math.hypot(nx - satellite.x, ny - satellite.y) / Math.max(7, (satellite.urbanRadius || 5) * 1.9); + const nd = cur.f + Math.max(0.28, 1.05 + barrier - living + distanceCost) * step; + if (nd < best[ni]) { + best[ni] = nd; + heap.push({ i: ni, f: nd }); + } + } + } + let changed = 0; + for (const i of claimed) { + if (adminId[i] !== targetAdmin) changed++; + adminId[i] = targetAdmin; + } + return changed; +} + +export function generateAdminLayout({ + seed, + prefectureMask, + sea, + elevation, + slope, + river, + ridgeField, + valleyField, + basinField, + coastalLowland, + flowAccum, + plain, + agriculture, + settlementScore, + populationDensity, + stationInfluence, + roadInfluence, + railInfluence2, + villageInfluence, + landuse, + modernCities, + satelliteCities, + newTowns, + markets, + villages, + ports, + stations, + industrialZones, + logisticsParks, +}) { + const prefectureArea = prefectureMask.reduce((sum, v) => sum + (v ? 1 : 0), 0); + const municipalityCandidates = []; + for (let y = 2; y < MAP_H - 2; y++) { + for (let x = 2; x < MAP_W - 2; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i]) continue; + const urbanBias = landuse[i] === 3 ? 0.62 : landuse[i] === 2 ? 0.56 : landuse[i] === 4 ? 0.5 : landuse[i] === 1 ? 0.4 : 0.28; + const score = urbanBias + settlementScore[i] * 0.22 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.05 + villageInfluence[i] * 0.04 - slope[i] * 0.18 - ridgeField[i] * 0.06 + hash2(x, y, seed + 1300) * 0.025; + if (score > 0.40) municipalityCandidates.push({ x, y, score }); + } + } + const majorMunicipalSeeds = modernCities + .filter((city) => (city.population || 0) >= 220000 && prefectureMask[indexOf(city.x, city.y)]) + .map((city) => ({ x: city.x, y: city.y, score: 1.55 + (city.population || 0) / 700000, protectedCity: city })); + const filteredMunicipalityCandidates = municipalityCandidates.filter((p) => { + const nearMajor = majorMunicipalSeeds.some((city) => Math.hypot(city.x - p.x, city.y - p.y) < clamp(12 + Math.sqrt(city.protectedCity.population || 300000) / 130, 14, 28)); + const nearSmallUrban = modernCities.some((city) => (city.population || 0) < 260000 && Math.hypot(city.x - p.x, city.y - p.y) < 8 && p.x !== city.x && p.y !== city.y); + return !nearMajor && !nearSmallUrban; + }); + const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, ridgeField, river, flowAccum); + const satelliteMunicipalSeeds = (satelliteCities || []) + .filter((city) => prefectureMask[indexOf(city.x, city.y)] && city.municipalityClass === "independentSatelliteMunicipality") + .map((city) => ({ x: city.x, y: city.y, score: 1.05 + (city.population || 40000) / 260000, protectedSatellite: city })); + let adminCentersRaw = [ + ...majorMunicipalSeeds, + ...satelliteMunicipalSeeds, + ...pickEntities(filteredMunicipalityCandidates.filter((p) => satelliteMunicipalSeeds.every((s) => Math.hypot(s.x - p.x, s.y - p.y) >= 6)), { + max: Math.min(20, Math.max(10, Math.floor(prefectureArea / 950) + 6 + Math.floor(rand(seed, 1301) * 3))), + minDistance: 9 + Math.floor(rand(seed, 1302) * 3), + threshold: 0.40, + seed: seed + 1300, + jitter: 0.025, + }), + ]; + if (adminCentersRaw.length < 12) { + const fallback = [...modernCities, ...(satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality"), ...markets, ...newTowns, ...stations, ...villages] + .filter((p) => prefectureMask[indexOf(p.x, p.y)]) + .map((p) => ({ x: p.x, y: p.y, score: p.score || 0.5 })); + adminCentersRaw = pickEntities(fallback, { max: 12, minDistance: 8, threshold: 0, seed: seed + 1303 }); + } + if (adminCentersRaw.length < 10) { + const extra = pickEntities(municipalityCandidates, { max: 10 - adminCentersRaw.length, minDistance: 8, threshold: 0.32, seed: seed + 1304 }); + adminCentersRaw.push(...extra.filter((p) => adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 6))); + } + const adminId = generateAdminRegions(adminCentersRaw, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse); + let previousSnapshot = new Int16Array(adminId); + const adminDebug = { + changedAfterSmooth: 0, + changedAfterUrbanLock: 0, + changedAfterSmallUrbanLock: 0, + changedAfterInitialMerge: 0, + changedAfterInitialExclaveRemoval: 0, + changedAfterLandscapePartition: 0, + changedAfterSnap: 0, + changedAfterOversizedRuralSplit: 0, + changedAfterFinalExclaveRemoval: 0, + changedAfterFinalMerge: 0, + satelliteMunicipalitiesCreated: satelliteMunicipalSeeds.length, + satelliteMunicipalitiesMerged: 0, + satelliteMunicipalitiesExpanded: 0, + satelliteMunicipalitiesTooSmall: 0, + averageSatelliteMunicipalityArea: 0, + minSatelliteMunicipalityArea: 0, + satelliteMunicipalityAreaByNameOrIndex: {}, + independentSatelliteMunicipalities: satelliteClassificationDebug.independent, + attachedSatelliteDistricts: satelliteClassificationDebug.attached, + }; + function markChanged(field) { + adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea); + previousSnapshot = new Int16Array(adminId); + } + smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, 7); + markChanged("changedAfterSmooth"); + + function lockUrbanClusterToMunicipality(city, radius, allowSuburban = true) { + if (!city || !prefectureMask[indexOf(city.x, city.y)]) return; + let bestAdmin = -1; + let bestD = INF; + adminCentersRaw.forEach((center, id) => { + const d = Math.hypot(center.x - city.x, center.y - city.y); + if (d < bestD) { bestD = d; bestAdmin = id; } + }); + if (bestAdmin < 0) return; + const r = Math.ceil(radius); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const x = city.x + dx; + const y = city.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i]) continue; + const d = Math.hypot(dx, dy); + if (d > radius) continue; + const urban = landuse[i] === 2 || landuse[i] === 3 || (allowSuburban && (landuse[i] === 4 || landuse[i] === 7 || landuse[i] === 8)); + if (urban || populationDensity[i] > 0.22) adminId[i] = bestAdmin; + } + } + } + for (const city of modernCities) { + const radius = (city.population || 0) >= 500000 + ? clamp(17 + Math.sqrt(city.population) / 120, 20, 38) + : clamp(5 + Math.sqrt(city.population || 70000) / 210, 6, 11); + lockUrbanClusterToMunicipality(city, radius, true); + } + for (const sat of satelliteCities || []) { + if (!prefectureMask[indexOf(sat.x, sat.y)]) continue; + let bestAdmin = -1; + if (sat.municipalityClass === "independentSatelliteMunicipality") { + let bestD = INF; + adminCentersRaw.forEach((center, id) => { + const d = Math.hypot(center.x - sat.x, center.y - sat.y); + if (d < bestD) { bestD = d; bestAdmin = id; } + }); + } else if (inside(sat.parentX ?? -1, sat.parentY ?? -1)) { + bestAdmin = adminId[indexOf(sat.parentX, sat.parentY)]; + } + if (bestAdmin < 0) continue; + sat.parentAdminHint = bestAdmin; + const changed = expandSatelliteMunicipalityCatchment(adminId, sat, bestAdmin, { + prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, + landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities, + }); + if (changed > 0 && sat.municipalityClass === "independentSatelliteMunicipality") adminDebug.satelliteMunicipalitiesExpanded++; + } + markChanged("changedAfterUrbanLock"); + lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 520); + lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620); + markChanged("changedAfterSmallUrbanLock"); + mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: adminCentersRaw }); + markChanged("changedAfterInitialMerge"); + removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180); + markChanged("changedAfterInitialExclaveRemoval"); + applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw); + for (const sat of satelliteCities || []) { + if (sat.municipalityClass !== "independentSatelliteMunicipality" || !inside(sat.x, sat.y)) continue; + const targetAdmin = adminId[indexOf(sat.x, sat.y)]; + if (targetAdmin < 0) continue; + expandSatelliteMunicipalityCatchment(adminId, sat, targetAdmin, { + prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, + landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities, + }); + } + markChanged("changedAfterLandscapePartition"); + const oversizedSplitDebug = splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, [...(satelliteCities || []), ...newTowns, ...markets, ...villages]); + adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells; + adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities; + previousSnapshot = new Int16Array(adminId); + snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 5); + markChanged("changedAfterSnap"); + removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 360); + markChanged("changedAfterFinalExclaveRemoval"); + mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 80, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 90, protectedPoints: adminCentersRaw }); + markChanged("changedAfterFinalMerge"); + + for (const sat of satelliteCities || []) { + if (sat.municipalityClass !== "independentSatelliteMunicipality" || !inside(sat.x, sat.y)) continue; + const targetAdmin = adminId[indexOf(sat.x, sat.y)]; + if (targetAdmin < 0) continue; + expandSatelliteMunicipalityCatchment(adminId, sat, targetAdmin, { + prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, + landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities, + }); + } + removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 260); + + const areaById = municipalityAreaById(adminId, prefectureMask, sea); + const satelliteAreas = []; + (satelliteCities || []).forEach((sat, index) => { + if (!inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)]) return; + const id = adminId[indexOf(sat.x, sat.y)]; + const area = areaById.get(id) || 0; + const key = sat.name || `satellite-${index}`; + adminDebug.satelliteMunicipalityAreaByNameOrIndex[key] = area; + if (sat.municipalityClass === "independentSatelliteMunicipality" && (area < 80 || ((sat.population || 0) >= 60000 && area < 120))) { + sat.municipalityClass = "smallTownAttachedToRuralMunicipality"; + adminDebug.satelliteMunicipalitiesTooSmall++; + return; + } + if (sat.municipalityClass === "independentSatelliteMunicipality") { + satelliteAreas.push(area); + if (area < 80) adminDebug.satelliteMunicipalitiesTooSmall++; + } + }); + adminDebug.averageSatelliteMunicipalityArea = satelliteAreas.length ? satelliteAreas.reduce((sum, value) => sum + value, 0) / satelliteAreas.length : 0; + adminDebug.minSatelliteMunicipalityArea = satelliteAreas.length ? Math.min(...satelliteAreas) : 0; + const landscapeDebug = applyLandscapeUnitAdminPartition.lastDebug || {}; + Object.assign(adminDebug, landscapeDebug); + const adminBorders = extractAdminBorderSegments(adminId, prefectureMask); + + + return { adminCentersRaw, adminId, adminBorders, adminDebug }; +} diff --git a/mapFeatures.js b/mapFeatures.js new file mode 100644 index 0000000..b4f8edb --- /dev/null +++ b/mapFeatures.js @@ -0,0 +1,1323 @@ +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, + }; +} diff --git a/mapGenerator.js b/mapGenerator.js index e1911d9..becd634 100644 --- a/mapGenerator.js +++ b/mapGenerator.js @@ -1,3011 +1 @@ -import { createNameDebug, generateEntityName } from "./names.js"; -import { - applyLandscapeUnitAdminPartition, - generateAdminRegions, - lockSmallUrbanComponentsToMunicipality, - mergeTinyMunicipalities, - removeMunicipalExclaves, - smoothAdminRegionsTerrainAware, - snapAdminBoundariesToTerrain, -} from "./adminRegions.js"; -import { CELL_SIZE, INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, createMapFields, fbm, hash2, indexOf, inside, lerp, nearMapEdge, pickEntities, rand, smoothstep, valueNoise, xyOf } from "./mapUtils.js"; - -export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js"; - -function neighbors8(x, y) { - const out = []; - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - if (dx === 0 && dy === 0) continue; - const nx = x + dx; - const ny = y + dy; - if (inside(nx, ny)) out.push([nx, ny, Math.hypot(dx, dy)]); - } - } - return out; -} - -function neighbors4(x, y) { - const out = []; - for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { - const nx = x + dx; - const ny = y + dy; - if (inside(nx, ny)) out.push([nx, ny, 1]); - } - return out; -} - -function distanceToNearest(points, x, y, fallback = 999) { - let best = fallback; - for (const p of points) best = Math.min(best, Math.hypot(p.x - x, p.y - y)); - return best; -} - -function aStar(start, goal, costAt) { - const startIndex = indexOf(start.x, start.y); - const goalIndex = indexOf(goal.x, goal.y); - if (startIndex === goalIndex) return [[start.x, start.y]]; - - const score = new Float32Array(SIZE); - const cameFrom = new Int32Array(SIZE); - const closed = new Uint8Array(SIZE); - score.fill(INF); - cameFrom.fill(-1); - - const heap = new MinHeap(); - score[startIndex] = 0; - heap.push({ i: startIndex, f: Math.hypot(start.x - goal.x, start.y - goal.y) }); - - let guard = 0; - while (heap.length > 0 && guard++ < SIZE * 3) { - const current = heap.pop(); - if (!current || closed[current.i]) continue; - closed[current.i] = 1; - - if (current.i === goalIndex) { - const path = []; - let p = goalIndex; - while (p !== -1) { - const [x, y] = xyOf(p); - path.push([x, y]); - if (p === startIndex) break; - p = cameFrom[p]; - } - return path.reverse(); - } - - const [cx, cy] = xyOf(current.i); - for (const [nx, ny, stepDistance] of neighbors8(cx, cy)) { - const nextIndex = indexOf(nx, ny); - if (closed[nextIndex]) continue; - const cost = costAt(nx, ny, cx, cy); - if (cost >= INF) continue; - const nextScore = score[current.i] + cost * stepDistance; - if (nextScore < score[nextIndex]) { - score[nextIndex] = nextScore; - cameFrom[nextIndex] = current.i; - heap.push({ i: nextIndex, f: nextScore + Math.hypot(nx - goal.x, ny - goal.y) * 0.78 }); - } - } - } - return []; -} - -function influenceFromPaths(paths, radius) { - const grid = new Float32Array(SIZE); - for (const path of paths) { - for (const [x, y] of path) { - 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 d = Math.hypot(dx, dy); - if (d > radius) continue; - const i = indexOf(nx, ny); - grid[i] = Math.max(grid[i], 1 / (1 + d)); - } - } - } - } - return grid; -} - -function pointKey(p) { - return `${p.x},${p.y}`; -} - -function getDegree(degreeMap, p) { - return degreeMap.get(pointKey(p)) || 0; -} - -function incrementDegree(degreeMap, p) { - degreeMap.set(pointKey(p), getDegree(degreeMap, p) + 1); -} - -function nearestConnectable(points, target, degreeMap, maxDegree = 3) { - if (!points.length) return null; - const sorted = points - .map((p) => ({ ...p, d: Math.hypot(p.x - target.x, p.y - target.y), degree: getDegree(degreeMap, p) })) - .sort((a, b) => (a.degree >= maxDegree ? 22 : 0) + a.d + a.degree * 7 - ((b.degree >= maxDegree ? 22 : 0) + b.d + b.degree * 7)); - return sorted.find((p) => p.degree < maxDegree) || sorted[0]; -} - -function corridorPenalty(grid, x, y, hubs, endpoints, strength = 6) { - if (!grid) return 0; - const value = grid[indexOf(x, y)]; - if (value <= 0.0001) return 0; - - const nearEndpoint = distanceToNearest(endpoints, x, y) <= 3.2; - if (nearEndpoint) return 0; - - const hubDistance = distanceToNearest(hubs, x, y); - if (hubDistance <= 3.5) return 0; - if (hubDistance <= 7.5) return value * strength * 0.28; - return value * strength; -} - -function nodeAvoidPenalty(points, x, y, endpoints, radius = 3.0, strength = 5.0) { - if (!points || points.length === 0) return 0; - if (distanceToNearest(endpoints, x, y) <= radius + 0.4) return 0; - const d = distanceToNearest(points, x, y); - if (d >= radius) return 0; - return (radius - d) * strength; -} - -function makeTransportCost(baseCost, existingPaths, hubs, endpoints, radius = 4, strength = 6, avoidPoints = [], avoidRadius = 3.0, avoidStrength = 5.0) { - const grid = existingPaths.length ? influenceFromPaths(existingPaths, radius) : null; - return (x, y, cx, cy) => { - const base = baseCost(x, y, cx, cy); - if (base >= INF) return base; - return base - + corridorPenalty(grid, x, y, hubs, endpoints, strength) - + nodeAvoidPenalty(avoidPoints, x, y, endpoints, avoidRadius, avoidStrength); - }; -} - -function pathLength(path) { - let total = 0; - for (let i = 1; i < path.length; i++) total += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]); - return total; -} - -function pathEndpointDistance(path) { - if (!path || path.length < 2) return 0; - const a = path[0]; - const b = path[path.length - 1]; - return Math.hypot(a[0] - b[0], a[1] - b[1]); -} - -function pathCompactness(path) { - const direct = pathEndpointDistance(path); - if (direct <= 0.001) return INF; - return pathLength(path) / direct; -} - -function pathOverlapRatio(path, existingPaths, radius = 2) { - if (!path?.length || !existingPaths?.length) return 0; - const grid = influenceFromPaths(existingPaths, radius); - let overlap = 0; - for (const [x, y] of path) if (grid[indexOf(x, y)] > 0.18) overlap++; - return overlap / Math.max(1, path.length); -} - -function compactPathArray(paths, { minLength = 8, maxOverlap = 0.35, maxCount = 99 } = {}) { - const kept = []; - for (const path of paths.slice().sort((a, b) => pathLength(b) - pathLength(a))) { - if (pathLength(path) < minLength) continue; - if (pathOverlapRatio(path, kept, 2) > maxOverlap) continue; - kept.push(path); - if (kept.length >= maxCount) break; - } - paths.splice(0, paths.length, ...kept); -} - -function bresenhamCells(a, b) { - const cells = []; - let x0 = a[0]; - let y0 = a[1]; - const x1 = b[0]; - const y1 = b[1]; - const dx = Math.abs(x1 - x0); - const dy = Math.abs(y1 - y0); - const sx = x0 < x1 ? 1 : -1; - const sy = y0 < y1 ? 1 : -1; - let err = dx - dy; - while (true) { - cells.push([x0, y0]); - if (x0 === x1 && y0 === y1) break; - const e2 = 2 * err; - if (e2 > -dy) { err -= dy; x0 += sx; } - if (e2 < dx) { err += dx; y0 += sy; } - } - return cells; -} - -function smoothPathByLineOfSight(path, passable, maxSegment = 9) { - if (!path || path.length < 3) return path || []; - const out = [path[0]]; - let i = 0; - while (i < path.length - 1) { - let best = i + 1; - const limit = Math.min(path.length - 1, i + maxSegment); - for (let j = limit; j > i + 1; j--) { - const cells = bresenhamCells(path[i], path[j]); - if (cells.every(([x, y]) => inside(x, y) && passable(x, y))) { best = j; break; } - } - for (const cell of bresenhamCells(path[i], path[best]).slice(1)) out.push(cell); - i = best; - } - return out; -} - -function averagePathField(path, field) { - if (!path?.length) return 0; - let sum = 0; - for (const [x, y] of path) sum += field[indexOf(x, y)] || 0; - return sum / path.length; -} - -function influenceFromPoints(points, radius, weightFn = () => 1) { - const grid = new Float32Array(SIZE); - for (const p of points) { - const weight = weightFn(p); - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - const nx = p.x + dx; - const ny = p.y + dy; - if (!inside(nx, ny)) continue; - const d = Math.hypot(dx, dy); - if (d > radius) continue; - const i = indexOf(nx, ny); - grid[i] = Math.max(grid[i], weight / (1 + d)); - } - } - } - return grid; -} - -function samplePath(path, step) { - const out = []; - for (let i = step; i < path.length - step; i += step) { - const [x, y] = path[i]; - out.push({ x, y, score: 1 }); - } - return out; -} - -function smoothMask(mask, passes = 2) { - let current = new Uint8Array(mask); - for (let pass = 0; pass < passes; pass++) { - const next = new Uint8Array(current); - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - let count = 0; - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - if (current[indexOf(x + dx, y + dy)]) count++; - } - } - if (count >= 5) next[i] = 1; - else if (count <= 3) next[i] = 0; - } - } - current = next; - } - return current; -} - -function largestConnectedMask(mask) { - const seen = new Uint8Array(SIZE); - let best = []; - const queue = []; - - for (let i = 0; i < SIZE; i++) { - if (!mask[i] || seen[i]) continue; - const component = []; - queue.length = 0; - queue.push(i); - seen[i] = 1; - - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - component.push(cur); - const [x, y] = xyOf(cur); - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - if (!mask[ni] || seen[ni]) continue; - seen[ni] = 1; - queue.push(ni); - } - } - - if (component.length > best.length) best = component; - } - - const out = new Uint8Array(SIZE); - for (const i of best) out[i] = 1; - return out; -} - -function componentCount(mask) { - const seen = new Uint8Array(SIZE); - const queue = []; - let count = 0; - for (let i = 0; i < SIZE; i++) { - if (!mask[i] || seen[i]) continue; - count++; - queue.length = 0; - queue.push(i); - seen[i] = 1; - for (let q = 0; q < queue.length; q++) { - const [x, y] = xyOf(queue[q]); - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - if (!mask[ni] || seen[ni]) continue; - seen[ni] = 1; - queue.push(ni); - } - } - } - return count; -} - - -function makePrefectureMask(seed, sea, elevation, slope, river) { - const candidates = []; - for (let y = 8; y < MAP_H - 8; y++) { - for (let x = 8; x < MAP_W - 8; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const centrality = 1 - Math.hypot((x / MAP_W) - 0.5, (y / MAP_H) - 0.5) / 0.72; - const score = centrality * 0.28 + (1 - slope[i]) * 0.42 + (1 - Math.abs(elevation[i] - 0.42)) * 0.22 + Math.min(0.16, river[i] * 0.08); - candidates.push({ x, y, score }); - } - } - - const regionSeeds = pickEntities(candidates, { - max: 1, - minDistance: 18, - threshold: 0.35, - seed: seed + 904, - jitter: 0.02, - }); - - const mask = new Uint8Array(SIZE); - const dist = new Float32Array(SIZE); - dist.fill(INF); - const heap = new MinHeap(); - const landCells = sea.reduce((a, v) => a + (v ? 0 : 1), 0); - const target = Math.floor(landCells * (0.23 + rand(seed, 906) * 0.08)); - - for (const s of regionSeeds) { - const i = indexOf(s.x, s.y); - dist[i] = 0; - heap.push({ i, f: 0 }); - } - - let claimed = 0; - while (heap.length > 0 && claimed < target) { - const current = heap.pop(); - if (!current) continue; - const ci = current.i; - if (current.f > dist[ci] + 1e-5 || mask[ci]) continue; - const [cx, cy] = xyOf(ci); - if (sea[ci]) continue; - - mask[ci] = 1; - claimed++; - - for (const [nx, ny, step] of neighbors8(cx, cy)) { - const ni = indexOf(nx, ny); - if (sea[ni] || mask[ni]) continue; - const edgePenalty = nearMapEdge(nx, ny, 2) ? 4.2 : nearMapEdge(nx, ny, 5) ? 1.8 : 0; - const ridgePenalty = Math.max(0, elevation[ni] - 0.5) * 5.4 + Math.max(0, elevation[ni] - elevation[ci]) * 3.2; - const slopePenalty = slope[ni] * 4.1; - const riverPenalty = river[ni] > 0.65 ? 2.2 : river[ni] > 0.32 ? 0.9 : 0; - const cost = Math.max(0.18, 1 + edgePenalty + ridgePenalty + slopePenalty + riverPenalty + Math.abs(elevation[ni] - elevation[ci]) * 4.2) * step; - const nd = dist[ci] + cost; - if (nd < dist[ni]) { - dist[ni] = nd; - heap.push({ i: ni, f: nd }); - } - } - } - - return largestConnectedMask(smoothMask(mask, 2)); -} - -function generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask) { - const centers = []; - let sx = 0; - let sy = 0; - let sc = 0; - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (anchorMask[i]) { sx += x; sy += y; sc++; } - } - } - if (sc > 0) centers.push({ x: Math.round(sx / sc), y: Math.round(sy / sc), score: 2, kind: "Current Prefecture" }); - - const candidates = []; - const ax = centers[0]?.x ?? MAP_W / 2; - const ay = centers[0]?.y ?? MAP_H / 2; - 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] || anchorMask[i]) continue; - const edgePull = Math.max(Math.abs(x / MAP_W - 0.5), Math.abs(y / MAP_H - 0.5)); - const awayFromCurrent = Math.hypot(x - ax, y - ay) / Math.hypot(MAP_W, MAP_H); - const settleable = (1 - slope[i]) * 0.24 + Math.max(0, 0.62 - elevation[i]) * 0.28 + flowAccum[i] * 0.08; - const score = edgePull * 0.55 + awayFromCurrent * 0.38 + settleable + hash2(x, y, seed + 6100) * 0.06; - candidates.push({ x, y, score, kind: "Neighbor Prefecture" }); - } - } - centers.push(...pickEntities(candidates, { - max: 9 + Math.floor(rand(seed, 6101) * 6), - minDistance: 22, - threshold: 0.38, - seed: seed + 6102, - jitter: 0.02, - })); - - const regionId = new Int16Array(SIZE); - regionId.fill(-1); - const dist = new Float32Array(SIZE); - dist.fill(INF); - const heap = new MinHeap(); - centers.forEach((center, id) => { - const i = indexOf(center.x, center.y); - if (sea[i]) return; - regionId[i] = id; - dist[i] = 0; - heap.push({ i, f: 0 }); - }); - - let guard = 0; - while (heap.length > 0 && guard++ < SIZE * 16) { - const cur = heap.pop(); - if (!cur || cur.f > dist[cur.i] + 1e-5) continue; - const [cx, cy] = xyOf(cur.i); - const curRegion = regionId[cur.i]; - for (const [nx, ny, step] of neighbors8(cx, cy)) { - const ni = indexOf(nx, ny); - if (sea[ni]) continue; - const ridge = Math.max(ridgeField[ni], ridgeField[cur.i]); - const riverBarrier = Math.max(river[ni], river[cur.i]); - const divide = ridge * 7.8 + Math.max(0, elevation[ni] - 0.54) * 4.4 + slope[ni] * 3.8; - const watershed = Math.max(0, flowAccum[cur.i] - flowAccum[ni]) * 0.7; - const riverCost = riverBarrier > 0.72 ? 4.6 : riverBarrier > 0.35 ? 1.9 : 0; - const stepCost = Math.max(0.22, 1 + divide + riverCost + watershed + Math.abs(elevation[ni] - elevation[cur.i]) * 3.2) * step; - const nd = dist[cur.i] + stepCost; - if (nd < dist[ni]) { - dist[ni] = nd; - regionId[ni] = curRegion; - heap.push({ i: ni, f: nd }); - } - } - } - return { regionId, centers }; -} - -function extractRegionBorderSegments(regionId, sea) { - const segments = []; - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (sea[i] || regionId[i] < 0) continue; - const a = regionId[i]; - if (x + 1 < MAP_W && !sea[indexOf(x + 1, y)]) { - const b = regionId[indexOf(x + 1, y)]; - if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]); - } - if (y + 1 < MAP_H && !sea[indexOf(x, y + 1)]) { - const b = regionId[indexOf(x, y + 1)]; - if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]); - } - } - } - return segments; -} - -function extractMaskBorder(mask, sea = null) { - const segments = []; - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - const a = mask[i]; - if (x + 1 < MAP_W) { - const ni = indexOf(x + 1, y); - const b = mask[ni]; - if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x + 1, y], [x + 1, y + 1]]); - } - if (y + 1 < MAP_H) { - const ni = indexOf(x, y + 1); - const b = mask[ni]; - if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x, y + 1], [x + 1, y + 1]]); - } - } - } - return segments; -} - -function extractAdminBorderSegments(adminId, prefectureMask) { - const segments = []; - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (!prefectureMask[i]) continue; - const a = adminId[i]; - if (a < 0) continue; - if (x + 1 < MAP_W && prefectureMask[indexOf(x + 1, y)]) { - const b = adminId[indexOf(x + 1, y)]; - if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]); - } - if (y + 1 < MAP_H && prefectureMask[indexOf(x, y + 1)]) { - const b = adminId[indexOf(x, y + 1)]; - if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]); - } - } - } - return segments; -} - -function tagInsidePrefecture(points, prefectureMask) { - return points.map((p) => ({ ...p, insidePrefecture: Boolean(prefectureMask[indexOf(p.x, p.y)]) })); -} - -function attachIdsAndNames(points, prefix, seed, kindOverride = null, nameFields = null, usedNames = null, nameDebug = null) { - return points.map((p, i) => { - const id = `${prefix}-${i}`; - const kind = kindOverride || p.kind; - const name = generateEntityName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, usedNames, nameDebug); - if (usedNames) usedNames.add(name); - return { - ...p, - id, - name, - insidePrefecture: Boolean(p.insidePrefecture), - }; - }); -} - -function applyOutputOptions(map, options = {}) { - if (options.includeDebugFields !== false) return map; - const slim = { ...map }; - delete slim.settlementCluster; - delete slim.ridgeField; - delete slim.valleyField; - delete slim.basinField; - delete slim.coastalLowland; - delete slim.flowAccum; - delete slim.erosionField; - delete slim.depositionField; - return slim; -} - -function recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence) { - populationDensity.fill(0); - const allCities = [...modernCities, ...satelliteCities]; - for (const city of allCities) { - const urbanR = Math.max(4, city.urbanRadius || 8); - const coreR = Math.max(2, city.coreRadius || 3); - const popScale = clamp((Math.log10(Math.max(12000, city.population || 12000)) - 4) / 2.25, 0.16, 1.65); - const r = Math.ceil(urbanR * 2.2); - for (let dy = -r; dy <= r; dy++) { - for (let dx = -r; dx <= r; dx++) { - 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 d = Math.hypot(dx, dy); - const lu = landuse[i]; - const landuseWeight = lu === 3 ? 1.85 : lu === 2 ? 1.42 : lu === 4 ? 1.05 : lu === 7 ? 0.82 : lu === 8 ? 0.68 : 0.10; - const radial = 1 / (1 + Math.pow(d / urbanR, 2.5)); - const core = Math.exp(-(d * d) / (coreR * coreR * 2.0)); - const transit = Math.max(stationInfluence?.[i] || 0, (railInfluence?.[i] || 0) * 0.55, (roadInfluence?.[i] || 0) * 0.24); - populationDensity[i] += popScale * landuseWeight * (radial * 0.78 + core * 0.38 + transit * 0.18); - } - } - } - let maxDensity = 0; - for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) maxDensity = Math.max(maxDensity, populationDensity[i]); - if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity); - - for (const city of allCities) { - let urbanCells = 0; - let coreCells = 0; - let densitySum = 0; - const r = Math.ceil((city.urbanRadius || 8) * 2.0); - for (let dy = -r; dy <= r; dy++) { - for (let dx = -r; dx <= r; dx++) { - const x = city.x + dx; - const y = city.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (!prefectureMask[i] || sea[i]) continue; - const d = Math.hypot(dx, dy); - if (d > r) continue; - const lu = landuse[i]; - if (lu >= 2 && lu <= 8) { - urbanCells++; - densitySum += populationDensity[i]; - if (lu === 3) coreCells++; - } - } - } - const base = city.isPrefecturalCapital ? 90000 : city.kind === "Satellite City" ? 16000 : 32000; - const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.kind === "Satellite City" ? 900 : 1200); - const coreComponent = coreCells * 3200; - const densityComponent = densitySum * 650; - city.population = Math.round((base + urbanComponent + coreComponent + densityComponent) / 1000) * 1000; - city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 95, city.kind === "Satellite City" ? 5 : 7, city.isPrefecturalCapital ? 34 : 28); - city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 360, 2.2, 9); - } -} - -export function generateMap(seedInput = 114514, options = {}) { - const seed = Number(seedInput) >>> 0; - - let prefectureMask; - let prefectureBorder; - - const { - elevation, - moisture, - slope, - sea, - river, - floodplain, - plain, - agriculture, - ridgeField, - valleyField, - basinField, - coastalLowland, - flowAccum, - erosionField, - depositionField, - flowTo, - portSuitability, - crossingSuitability, - passSuitability, - } = createMapFields(); - - const coastAngle = rand(seed, 11) * Math.PI * 2; - const coastX = Math.cos(coastAngle); - const coastY = Math.sin(coastAngle); - const coastThreshold = 0.22 + rand(seed, 12) * 0.22; - const coastStrength = 0.15 + rand(seed, 13) * 0.23; - - const seaLevel = 0.285; - - const mountainBlobs = Array.from({ length: 2 + Math.floor(rand(seed, 98) * 3) }, (_, i) => ({ - x: rand(seed, 100 + i) * MAP_W, - y: rand(seed, 200 + i) * MAP_H, - r: 10 + rand(seed, 300 + i) * 24, - h: 0.08 + rand(seed, 400 + i) * 0.16, - })); - - const ridgeBands = Array.from({ length: 5 + Math.floor(rand(seed, 97) * 4) }, (_, i) => ({ - x: rand(seed, 1500 + i) * MAP_W, - y: rand(seed, 1600 + i) * MAP_H, - angle: rand(seed, 1700 + i) * Math.PI * 2, - width: 3 + rand(seed, 1800 + i) * 7, - length: 42 + rand(seed, 1900 + i) * 92, - h: 0.11 + rand(seed, 2000 + i) * 0.22, - })); - - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const nx = x / (MAP_W - 1) - 0.5; - const ny = y / (MAP_H - 1) - 0.5; - const i = indexOf(x, y); - - const warpX = (fbm(x * 0.62 + 180, y * 0.62 - 90, seed + 3101) - 0.5) * 13; - const warpY = (fbm(x * 0.62 - 70, y * 0.62 + 210, seed + 3201) - 0.5) * 13; - const wx = x + warpX; - const wy = y + warpY; - - let mountains = 0; - for (const blob of mountainBlobs) { - const d = Math.hypot(wx - blob.x, wy - blob.y) / blob.r; - mountains += Math.exp(-d * d * 2.35) * blob.h; - } - - let ridges = 0; - for (const ridge of ridgeBands) { - const dx = wx - ridge.x; - const dy = wy - ridge.y; - const along = dx * Math.cos(ridge.angle) + dy * Math.sin(ridge.angle); - const perp = -dx * Math.sin(ridge.angle) + dy * Math.cos(ridge.angle); - const lengthFade = smoothstep(1 - Math.abs(along) / ridge.length); - const serration = 0.72 + valueNoise(wx + along * 0.15, wy + perp * 0.15, seed + 2220, 8) * 0.56; - ridges += Math.exp(-(perp * perp) / (ridge.width * ridge.width)) * lengthFade * ridge.h * serration; - } - - const directionalCoast = nx * coastX + ny * coastY; - const coastWave = (fbm(wx * 0.72, wy * 0.72, seed + 2222) - 0.5) * 0.12 + (valueNoise(wx, wy, seed + 2233, 18) - 0.5) * 0.08; - const coastLower = smoothstep((directionalCoast + coastWave - coastThreshold) / 0.26); - // Four terrain-noise bands from continental structure to fine surface roughness. - const terrainLarge = fbm(wx * 0.36 + 40, wy * 0.36 - 60, seed + 710); - const terrainRegional = fbm(wx * 0.95 + 80, wy * 0.95 - 20, seed + 777); - const terrainLocal = fbm(wx * 2.05 + 17, wy * 2.05 - 31, seed + 1777); - const terrainFine = valueNoise(wx * 2.9 + 11, wy * 2.9 - 19, seed + 2444, 4.5); - const fineDissection = Math.abs(terrainLocal - 0.5) * 0.08 + Math.abs(terrainFine - 0.5) * 0.035; - const basin = 0.1 * Math.sin((nx * 3.1 + ny * 1.7 + rand(seed, 15)) * Math.PI) - 0.045 * Math.cos((nx * 5.2 - ny * 3.6 + rand(seed, 16)) * Math.PI); - const rawElevation = - 0.30 * terrainLarge + - 0.235 * terrainRegional + - 0.105 * terrainLocal + - 0.055 * terrainFine + - mountains * 0.54 + - ridges * 1.22 + - basin + - fineDissection - - coastLower * (coastStrength + 0.19) + - 0.055; - - elevation[i] = clamp(0.5 + (rawElevation - 0.5) * 1.26); - ridgeField[i] = clamp(ridges * 4.8 + Math.max(0, mountains - 0.10) * 0.95 + fineDissection * 2.0); - basinField[i] = clamp(Math.max(0, -basin) * 3.0 + (1 - coastLower) * Math.max(0, 0.42 - elevation[i]) * 0.7); - moisture[i] = clamp(0.44 * fbm(wx + 400, wy - 200, seed + 333) + 0.18 * valueNoise(wx, wy, seed + 343, 11) + 0.22 * (1 - Math.abs(ny * 1.7)) + 0.28 * coastLower - Math.max(0, elevation[i] - 0.62) * 0.22); - } - } - - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - const nx = x / (MAP_W - 1) - 0.5; - const ny = y / (MAP_H - 1) - 0.5; - const directionalCoast = nx * coastX + ny * coastY; - const coastNoise = (fbm(x * 0.95, y * 0.95, seed + 2222) - 0.5) * 0.14 + (valueNoise(x, y, seed + 2233, 13) - 0.5) * 0.08; - const oceanSide = directionalCoast + coastNoise > coastThreshold + 0.055; - if (elevation[i] < seaLevel || oceanSide) sea[i] = 1; - if (sea[i]) elevation[i] = Math.min(elevation[i], seaLevel - 0.018 + hash2(x, y, seed + 2311) * 0.012); - } - } - - // Align coastal elevation with the sea mask. This prevents artificial one-cell cliffs - // when the directional coastline cuts through a high terrain cell. - 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 nearestSea = INF; - for (let dy = -7; dy <= 7; dy++) { - for (let dx = -7; dx <= 7; dx++) { - const nx = x + dx; - const ny = y + dy; - if (!inside(nx, ny) || !sea[indexOf(nx, ny)]) continue; - nearestSea = Math.min(nearestSea, Math.hypot(dx, dy)); - } - } - if (nearestSea <= 7) { - const coastalCap = seaLevel + 0.018 + nearestSea * 0.028 + Math.max(0, fbm(x * 1.4, y * 1.4, seed + 2350) - 0.5) * 0.022; - elevation[i] = Math.min(elevation[i], coastalCap); - coastalLowland[i] = clamp(1 - nearestSea / 7); - } - } - } - - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]; - const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]; - slope[indexOf(x, y)] = clamp(Math.sqrt(gx * gx + gy * gy) * 10.5); - } - } - - const landOrder = []; - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - let low = i; - let best = elevation[i] + 0.012 * hash2(x, y, seed + 2468); - let localMean = 0; - let localMax = elevation[i]; - let localMin = elevation[i]; - let nCount = 0; - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - const ev = elevation[ni]; - localMean += ev; - localMax = Math.max(localMax, ev); - localMin = Math.min(localMin, ev); - nCount++; - const directed = ev + 0.008 * hash2(nx, ny, seed + 2469); - if (directed < best || sea[ni]) { - best = directed; - low = ni; - } - } - if (low !== i) flowTo[i] = low; - localMean /= Math.max(1, nCount); - const hollow = Math.max(0, localMean - elevation[i]); - const relief = localMax - localMin; - valleyField[i] = clamp(hollow * 8.4 + Math.max(0, 0.42 - elevation[i]) * 0.32 + moisture[i] * 0.08 - ridgeField[i] * 0.18); - basinField[i] = clamp(basinField[i] + hollow * 2.4 + (relief < 0.055 && elevation[i] < 0.55 ? 0.18 : 0)); - flowAccum[i] = 0.7 + moisture[i] * 0.7 + valleyField[i] * 0.55; - landOrder.push(i); - } - } - landOrder.sort((a, b) => elevation[b] - elevation[a]); - for (const i of landOrder) { - const to = flowTo[i]; - if (to >= 0 && to !== i) flowAccum[to] += flowAccum[i] * 0.82; - } - let maxFlowAccum = 0; - for (let i = 0; i < SIZE; i++) if (!sea[i]) maxFlowAccum = Math.max(maxFlowAccum, flowAccum[i]); - if (maxFlowAccum > 0) { - for (let i = 0; i < SIZE; i++) flowAccum[i] = clamp(flowAccum[i] / maxFlowAccum); - } - for (let i = 0; i < SIZE; i++) { - if (!sea[i]) valleyField[i] = clamp(valleyField[i] * 0.68 + Math.pow(flowAccum[i], 0.55) * 0.48); - } - - // First-order fluvial shaping: cut valley floors on steep/high-flow cells and - // deposit gently in coastal lowlands and basin floors. This gives visible - // river valleys without destroying the macro terrain structure. - const shapedElevation = new Float32Array(elevation); - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const flow = Math.pow(flowAccum[i], 0.46); - const incisionNoise = 0.82 + hash2(x, y, seed + 8120) * 0.36; - const steepValley = clamp(flow * (0.058 + slope[i] * 0.21 + ridgeField[i] * 0.046) * incisionNoise); - const lateralCut = clamp(Math.pow(flowAccum[i], 0.66) * valleyField[i] * 0.078); - const lowSettling = clamp(flow * (coastalLowland[i] * 0.036 + basinField[i] * 0.020 + (elevation[i] < 0.40 ? 0.012 : 0)) * (1 - slope[i] * 0.82)); - erosionField[i] = steepValley + lateralCut; - depositionField[i] = lowSettling; - shapedElevation[i] = clamp(elevation[i] - steepValley - lateralCut + lowSettling * 0.72, seaLevel + 0.006, 1); - } - } - elevation.set(shapedElevation); - - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]; - const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]; - slope[i] = clamp(Math.sqrt(gx * gx + gy * gy) * 10.5); - valleyField[i] = clamp(valleyField[i] + erosionField[i] * 2.1 + depositionField[i] * 0.8 - ridgeField[i] * 0.06); - basinField[i] = clamp(basinField[i] + depositionField[i] * 1.6); - } - } - - const sourceCandidates = []; - 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 score = elevation[i] * 0.38 + moisture[i] * 0.24 + ridgeField[i] * 0.08 + flowAccum[i] * 0.56 + valleyField[i] * 0.28 + hash2(x, y, seed + 9000) * 0.06; - if (elevation[i] > 0.40 && elevation[i] < 0.82 && moisture[i] > 0.28 && flowAccum[i] > 0.020 && ridgeField[i] < 0.88) sourceCandidates.push({ x, y, score }); - } - } - - const sources = pickEntities(sourceCandidates, { - max: 20 + Math.floor(rand(seed, 910) * 28), - minDistance: 8, - threshold: 0.53 + rand(seed, 911) * 0.11, - seed, - }); - - function nearestWaterGoal(from) { - let bestSea = null; - let bestScore = INF; - 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 d = Math.hypot(x - from.x, y - from.y); - const score = d - coastalLowland[indexOf(Math.max(0, Math.min(MAP_W - 1, from.x)), Math.max(0, Math.min(MAP_H - 1, from.y)))] * 2; - if (score < bestScore) { - bestScore = score; - bestSea = { x, y }; - } - } - } - return bestSea; - } - - function riverRouteCost(x, y, cx, cy) { - const i = indexOf(x, y); - const ci = indexOf(cx, cy); - if (sea[i]) return 0.18; - const uphill = Math.max(0, elevation[i] - elevation[ci]); - const downhill = Math.max(0, elevation[ci] - elevation[i]); - if (!sea[i] && uphill > 0.035 && flowAccum[i] < flowAccum[ci] + 0.015) return INF; - return Math.max( - 0.18, - 1 + - uphill * 86 + - slope[i] * 0.38 + - elevation[i] * 0.42 - - downhill * 2.1 - - valleyField[i] * 0.92 - - flowAccum[i] * 0.72 - - moisture[i] * 0.18 - - coastalLowland[i] * 0.22 - ); - } - - function forceRiverToWater(path) { - if (!path.length) return path; - const [ex, ey] = path[path.length - 1]; - if (sea[indexOf(ex, ey)]) return path; - const goal = nearestWaterGoal({ x: ex, y: ey }); - if (!goal) return path; - const startElevation = elevation[indexOf(ex, ey)]; - const tail = aStar({ x: ex, y: ey }, goal, (x, y, cx, cy) => { - const i = indexOf(x, y); - const ci = indexOf(cx, cy); - if (!sea[i] && elevation[i] > Math.max(startElevation + 0.045, elevation[ci] + 0.030)) return INF; - return riverRouteCost(x, y, cx, cy); - }); - if (tail.length <= 2) return path; - return path.concat(tail.slice(1)); - } - - function confluenceAnglePenalty(nx, ny, dx, dy, lengthSoFar) { - if (lengthSoFar < 7 || river[indexOf(nx, ny)] < 0.24) return 0; - let best = 0.16; - const inLen = Math.hypot(dx, dy) || 1; - for (const [rx, ry] of neighbors8(nx, ny)) { - if (river[indexOf(rx, ry)] < 0.22) continue; - const rdx = rx - nx; - const rdy = ry - ny; - const cos = clamp((dx * rdx + dy * rdy) / Math.max(0.001, inLen * Math.hypot(rdx, rdy)), -1, 1); - const angle = Math.acos(cos); - const shallow = angle < 0.45 ? 0.28 : 0; - best = Math.min(best, Math.abs(angle - Math.PI * 0.62) * 0.045 + shallow); - } - return best; - } - - function traceRiverPath(startX, startY, bonusSeed = 0) { - let x = startX; - let y = startY; - let lastDx = 0; - let lastDy = 0; - const path = []; - const seen = new Set(); - let accum = 0; - - for (let step = 0; step < 600; step++) { - const i = indexOf(x, y); - if (seen.has(i)) break; - seen.add(i); - path.push([x, y]); - river[i] += 0.44 + path.length / 160 + flowAccum[i] * 0.55; - accum += river[i] + flowAccum[i]; - if (sea[i]) break; - - let best = null; - let bestValue = INF; - const currentElevation = elevation[i]; - const preferred = flowTo[i]; - - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - const dx = nx - x; - const dy = ny - y; - const drop = currentElevation - elevation[ni]; - const uphill = Math.max(0, -drop); - if (!sea[ni] && uphill > 0.032 && flowAccum[ni] < flowAccum[i] + 0.018) continue; - let surrounding = 0; - let surroundingCount = 0; - for (const [vx, vy] of neighbors8(nx, ny)) { - surrounding += elevation[indexOf(vx, vy)]; - surroundingCount++; - } - const valley = Math.max(0, surrounding / Math.max(1, surroundingCount) - elevation[ni]); - const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0; - const straightPenalty = Math.max(0, sameDirection) * 0.075; - const turnPenalty = sameDirection < -0.35 ? 0.24 : 0; - const sideSwing = Math.abs(dx * lastDy - dy * lastDx); - const meanderPhase = Math.sin((path.length + bonusSeed * 0.013) * 0.73) * 0.5 + 0.5; - const meander = sideSwing * (0.032 + meanderPhase * 0.026); - const flowBonus = ni === preferred ? 0.62 : 0; - const junctionPenalty = confluenceAnglePenalty(nx, ny, dx, dy, path.length); - const noise = (hash2(nx, ny, seed + bonusSeed + step * 11) - 0.5) * 0.04; - const value = - elevation[ni] * 1.45 + - uphill * 88 - - Math.max(0, drop) * 2.05 - - valley * 1.05 - - valleyField[ni] * 1.72 - - flowAccum[ni] * 0.94 - - moisture[ni] * 0.14 - - coastalLowland[ni] * 0.28 - - (river[ni] > 0 ? 0.22 : 0) - - flowBonus + - slope[ni] * 0.04 + - straightPenalty + - turnPenalty + - junctionPenalty * 1.35 - - meander + - noise - - (sea[ni] ? 0.6 : 0); - - if (value < bestValue) { - bestValue = value; - best = [nx, ny, dx, dy]; - } - } - if (!best) break; - x = best[0]; - y = best[1]; - lastDx = best[2]; - lastDy = best[3]; - } - - const forced = forceRiverToWater(path); - if (forced.length > path.length) { - for (const [rx, ry] of forced.slice(path.length)) { - const ri = indexOf(rx, ry); - river[ri] += 0.32 + flowAccum[ri] * 0.4; - accum += river[ri] + flowAccum[ri]; - } - } - return { path: forced, accum }; - } - - function traceSmallStreamPath(startX, startY, bonusSeed = 0) { - let x = startX; - let y = startY; - let lastDx = 0; - let lastDy = 0; - const path = []; - const seen = new Set(); - for (let step = 0; step < 160; step++) { - const i = indexOf(x, y); - if (seen.has(i)) break; - seen.add(i); - path.push([x, y]); - river[i] += 0.12 + flowAccum[i] * 0.18; - if ((river[i] > 0.48 && path.length > 5) || sea[i]) break; - let best = null; - let bestValue = INF; - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - const dx = nx - x; - const dy = ny - y; - const drop = elevation[i] - elevation[ni]; - const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0; - const value = elevation[ni] * 1.2 + Math.max(0, -drop) * 26 - Math.max(0, drop) * 1.4 - valleyField[ni] * 1.15 - flowAccum[ni] * 0.55 - moisture[ni] * 0.12 + Math.max(0, sameDirection) * 0.04 - Math.abs(dx * lastDy - dy * lastDx) * 0.018 + (hash2(nx, ny, seed + bonusSeed + step * 13) - 0.5) * 0.05; - if (value < bestValue) { bestValue = value; best = [nx, ny, dx, dy]; } - } - if (!best) break; - x = best[0]; - y = best[1]; - lastDx = best[2]; - lastDy = best[3]; - } - return path; - } - - const riverPaths = []; - const riverScores = []; - for (const source of sources) { - const { path, accum } = traceRiverPath(source.x, source.y, 0); - if (path.length > 6) { - riverPaths.push(path); - riverScores.push(path.length + accum * 0.18); - } - } - - const preliminaryMainRiverCells = new Set(riverPaths.slice().sort((a, b) => b.length - a.length).slice(0, 5).flatMap((path) => path.map(([x, y]) => `${x},${y}`))); - const tributarySources = pickEntities(sourceCandidates - .filter((p) => !preliminaryMainRiverCells.has(`${p.x},${p.y}`)) - .map((p) => ({ ...p, score: p.score + flowAccum[indexOf(p.x, p.y)] * 0.75 + valleyField[indexOf(p.x, p.y)] * 0.24 })), { - max: 14 + Math.floor(rand(seed, 915) * 20), - minDistance: 6, - threshold: 0.45, - seed: seed + 916, - jitter: 0.02, - }); - for (const source of tributarySources) { - const { path, accum } = traceRiverPath(source.x, source.y, 4000 + source.x * 7 + source.y * 11); - if (path.length > 8) { - riverPaths.push(path); - riverScores.push(path.length * 0.7 + accum * 0.12); - } - } - - const streamPaths = []; - const streamSources = pickEntities(sourceCandidates - .map((p) => ({ ...p, score: valleyField[indexOf(p.x, p.y)] * 0.46 + flowAccum[indexOf(p.x, p.y)] * 0.36 + moisture[indexOf(p.x, p.y)] * 0.18 + hash2(p.x, p.y, seed + 918) * 0.05 })) - .filter((p) => p.score > 0.18), { - max: 22 + Math.floor(rand(seed, 919) * 20), - minDistance: 4, - threshold: 0.18, - seed: seed + 919, - jitter: 0.015, - }); - for (const source of streamSources) { - const path = traceSmallStreamPath(source.x, source.y, 7000 + source.x * 5 + source.y * 17); - if (path.length > 4) streamPaths.push(path); - } - - if (riverPaths.length === 0 && sourceCandidates.length > 0) { - const fallback = sourceCandidates.slice().sort((a, b) => b.score - a.score)[0]; - let bestSea = null; - let bestSeaDist = INF; - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - if (!sea[indexOf(x, y)]) continue; - const d = Math.hypot(x - fallback.x, y - fallback.y); - if (d < bestSeaDist) { - bestSeaDist = d; - bestSea = { x, y }; - } - } - } - if (bestSea) { - const fallbackPath = aStar(fallback, bestSea, (x, y, cx, cy) => { - const i = indexOf(x, y); - const ci = indexOf(cx, cy); - if (sea[i]) return 0.25; - const uphill = Math.max(0, elevation[i] - elevation[ci]) * 24; - const downhill = Math.max(0, elevation[ci] - elevation[i]) * 1.8; - return Math.max(0.24, 1 + uphill + slope[i] * 0.7 + elevation[i] * 0.8 - downhill - Math.min(0.55, river[i] * 0.1)); - }); - if (fallbackPath.length > 6) { - let accum = 0; - for (const [x, y] of fallbackPath) { - const i = indexOf(x, y); - river[i] += 0.42; - accum += river[i]; - } - riverPaths.push(fallbackPath); - riverScores.push(fallbackPath.length + accum * 0.18); - } - } - } - - function sanitizeDownhillRiverPath(path, tolerance = 0.040) { - if (!path || path.length < 2) return path || []; - const out = [path[0]]; - for (let k = 1; k < path.length; k++) { - const [px, py] = out[out.length - 1]; - const [x, y] = path[k]; - const pi = indexOf(px, py); - const i = indexOf(x, y); - if (!sea[i] && elevation[i] > elevation[pi] + tolerance) break; - out.push(path[k]); - if (sea[i]) break; - } - return out.length >= 2 ? out : []; - } - function trimMountainHeadwaters(path) { - if (!path || path.length < 4) return path || []; - let start = 0; - while (start < path.length - 3) { - const [x, y] = path[start]; - const i = indexOf(x, y); - if (sea[i]) break; - if (elevation[i] <= 0.72 && (valleyField[i] >= 0.18 || flowAccum[i] >= 0.05)) break; - start++; - } - return path.slice(start); - } - for (let r = 0; r < riverPaths.length; r++) riverPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.032); - for (let r = riverPaths.length - 1; r >= 0; r--) if (riverPaths[r].length < 2) riverPaths.splice(r, 1); - for (let r = 0; r < streamPaths.length; r++) streamPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(streamPaths[r]), 0.026); - for (let r = streamPaths.length - 1; r >= 0; r--) if (streamPaths[r].length < 2) streamPaths.splice(r, 1); - river.fill(0); - for (const path of riverPaths) { - for (let k = 0; k < path.length; k++) { - const [x, y] = path[k]; - const i = indexOf(x, y); - river[i] += 0.42 + k / 170 + flowAccum[i] * 0.55; - } - } - for (const path of streamPaths) { - for (let k = 0; k < path.length; k++) { - const [x, y] = path[k]; - const i = indexOf(x, y); - river[i] += 0.11 + flowAccum[i] * 0.18; - } - } - - const expandedRiver = new Float32Array(river); - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (river[i] <= 0) continue; - for (const [nx, ny] of neighbors8(x, y)) { - expandedRiver[indexOf(nx, ny)] = Math.max(expandedRiver[indexOf(nx, ny)], river[i] * 0.35); - } - } - } - river.set(expandedRiver); - - // Second fluvial pass uses the actual traced river network. Main channels cut - // visible V-shaped valleys; lower reaches accumulate alluvial deposits. - const fluvialElevation = new Float32Array(elevation); - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (sea[i] || river[i] <= 0.02) continue; - const r = clamp(river[i] / 3.4); - const channelCut = clamp(Math.pow(r, 0.55) * (0.060 + slope[i] * 0.145 + ridgeField[i] * 0.038)); - const valleyWiden = clamp(Math.pow(r, 0.72) * (0.020 + Math.max(0, elevation[i] - seaLevel) * 0.058 + valleyField[i] * 0.040)); - const alluvium = clamp(Math.pow(r, 0.72) * (coastalLowland[i] * 0.030 + basinField[i] * 0.020 + (slope[i] < 0.10 ? 0.010 : 0))); - erosionField[i] = clamp(erosionField[i] + channelCut + valleyWiden); - depositionField[i] = clamp(depositionField[i] + alluvium); - fluvialElevation[i] = clamp(elevation[i] - channelCut - valleyWiden + alluvium, seaLevel + 0.005, 1); - valleyField[i] = clamp(valleyField[i] + r * 0.62 + channelCut * 6.4); - basinField[i] = clamp(basinField[i] + alluvium * 3.2); - } - } - // Lateral valley carving around the traced river network deepens valleys and - // makes ridge/valley contrast legible at the map scale. - for (const path of riverPaths) { - for (const [rx, ry] of path) { - const ri = indexOf(rx, ry); - const r = clamp(river[ri] / 3.0); - const radius = r > 0.48 ? 2 : 1; - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - const nx = rx + dx; - const ny = ry + dy; - if (!inside(nx, ny)) continue; - const ni = indexOf(nx, ny); - if (sea[ni]) continue; - const d = Math.hypot(dx, dy); - if (d > radius || d === 0) continue; - const weight = (radius + 0.35 - d) / (radius + 0.35); - const carve = Math.max(0, weight) * (0.008 + r * 0.026) * Math.max(0.45, slope[ni] + 0.22); - fluvialElevation[ni] = clamp(fluvialElevation[ni] - carve, seaLevel + 0.005, 1); - erosionField[ni] = clamp(erosionField[ni] + carve * 3.0); - valleyField[ni] = clamp(valleyField[ni] + carve * 12.0); - } - } - } - } - - // Restore rugged summit relief after strong river incision. This prevents highlands - // from becoming unnaturally flat or visually concave while keeping valleys cut. - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const high = clamp((fluvialElevation[i] - 0.62) / 0.26); - const summit = high * clamp(ridgeField[i] * 1.4 - flowAccum[i] * 0.8); - const rugged = (valueNoise(x * 2.1 + 19, y * 2.1 - 23, seed + 9661, 3.2) - 0.5) * 0.035; - const uplift = summit * (0.018 + Math.max(0, rugged)); - if (uplift > 0) { - fluvialElevation[i] = clamp(fluvialElevation[i] + uplift, seaLevel + 0.005, 1); - erosionField[i] = Math.max(0, erosionField[i] - uplift * 0.6); - } - } - } - - elevation.set(fluvialElevation); - - // Broad alluvial/coastal/basin plains. The plain score alone is not enough; - // the elevation surface must also be locally calm, otherwise every lowland - // still reads as rugged terrain. Smooth only low, wet depositional cells and - // leave ridges/headwaters untouched. - for (let pass = 0; pass < 4; pass++) { - const nextElevation = new Float32Array(elevation); - 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 lowland = clamp( - coastalLowland[i] * 0.72 + - basinField[i] * 0.54 + - valleyField[i] * 0.34 + - Math.pow(flowAccum[i], 0.58) * 0.24 - - ridgeField[i] * 0.62 - - Math.max(0, elevation[i] - 0.54) * 1.65 - - slope[i] * 0.74 - ); - if (lowland <= 0.12) continue; - let sum = 0; - let weight = 0; - for (let dy = -2; dy <= 2; dy++) { - for (let dx = -2; dx <= 2; dx++) { - const nx = x + dx; - const ny = y + dy; - const ni = indexOf(nx, ny); - if (sea[ni]) continue; - const d = Math.hypot(dx, dy); - if (d > 2.25) continue; - const compatible = clamp(1 - Math.abs(elevation[ni] - elevation[i]) / 0.11); - const w = compatible / (1 + d); - sum += elevation[ni] * w; - weight += w; - } - } - if (weight <= 0) continue; - const localMean = sum / weight; - const terrace = Math.round(localMean * 42) / 42; - const target = lerp(localMean, terrace, 0.28); - nextElevation[i] = clamp(lerp(elevation[i], target, lowland * 0.42), seaLevel + 0.006, 1); - if (lowland > 0.55) { - depositionField[i] = clamp(depositionField[i] + lowland * 0.018); - erosionField[i] = Math.max(0, erosionField[i] - lowland * 0.012); - } - } - } - elevation.set(nextElevation); - } - - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]; - const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]; - slope[i] = clamp(Math.sqrt(gx * gx + gy * gy) * 11.2); - } - } - - // Re-trim visible river paths after fluvial reshaping changes local elevation. - for (let r = 0; r < riverPaths.length; r++) riverPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.028); - for (let r = riverPaths.length - 1; r >= 0; r--) if (riverPaths[r].length < 2) riverPaths.splice(r, 1); - for (let r = 0; r < streamPaths.length; r++) streamPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(streamPaths[r]), 0.022); - for (let r = streamPaths.length - 1; r >= 0; r--) if (streamPaths[r].length < 2) streamPaths.splice(r, 1); - - const mainRivers = riverPaths - .map((p, i) => ({ path: p, score: riverScores[i] })) - .sort((a, b) => b.score - a.score) - .slice(0, Math.min(6, riverPaths.length)) - .map((x) => x.path); - - if (mainRivers.length === 0 && riverPaths.length > 0) mainRivers.push(riverPaths[0]); - if (mainRivers.length === 0) { - let start = null; - let startScore = -INF; - 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 score = elevation[i] * 0.55 + moisture[i] * 0.35 - slope[i] * 0.15; - if (score > startScore) { - startScore = score; - start = { x, y }; - } - } - } - if (start) { - let goal = null; - let goalDist = INF; - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - if (!sea[indexOf(x, y)]) continue; - const d = Math.hypot(x - start.x, y - start.y); - if (d < goalDist) { - goalDist = d; - goal = { x, y }; - } - } - } - if (goal) { - const fallbackPath = aStar(start, goal, (x, y, cx, cy) => { - const i = indexOf(x, y); - const ci = indexOf(cx, cy); - if (sea[i]) return 0.2; - const uphillBias = Math.max(0, elevation[i] - elevation[ci]) * 22; - const downhillBias = Math.max(0, elevation[ci] - elevation[i]) * 1.7; - return Math.max(0.25, 1 + uphillBias + slope[i] * 0.65 + elevation[i] * 0.8 - downhillBias); - }); - if (fallbackPath.length > 4) { - riverPaths.push(fallbackPath); - mainRivers.push(fallbackPath); - for (const [x, y] of fallbackPath) river[indexOf(x, y)] += 0.4; - } - } - } - } - - const mainRiverCells = new Set(mainRivers.flatMap((path) => path.map(([x, y]) => `${x},${y}`))); - const tributaryRivers = riverPaths.filter((path) => path.some(([x, y]) => !mainRiverCells.has(`${x},${y}`)) && !mainRivers.includes(path)); - const smallStreams = streamPaths.filter((path) => path.length >= 5); - - prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river); - prefectureBorder = extractMaskBorder(prefectureMask, sea); - const regionalPrefectures = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask); - const prefectureRegionId = regionalPrefectures.regionId; - const regionalPrefectureBorders = extractRegionBorderSegments(prefectureRegionId, sea); - - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - if (sea[i]) continue; - const low = 1 - clamp((elevation[i] - 0.28) / 0.4); - const flat = 1 - slope[i]; - const valleyPlain = valleyField[i] * 0.44 + basinField[i] * 0.36 + coastalLowland[i] * 0.55; - plain[i] = clamp(low * 0.44 + flat * 0.58 + valleyPlain - ridgeField[i] * 0.28 - (elevation[i] > 0.62 ? 0.48 : 0)); - - let nearRiver = 0; - for (let dy = -4; dy <= 4; dy++) { - for (let dx = -4; dx <= 4; dx++) { - const nx = x + dx; - const ny = y + dy; - if (!inside(nx, ny)) continue; - nearRiver = Math.max(nearRiver, river[indexOf(nx, ny)] / (1 + Math.hypot(dx, dy))); - } - } - - const fan = clamp(valleyField[i] * (1 - coastalLowland[i]) * (elevation[i] > 0.34 && elevation[i] < 0.58 ? 0.9 : 0.35) * (1 - slope[i] * 0.55)); - floodplain[i] = clamp(nearRiver * plain[i] * 0.92 + coastalLowland[i] * nearRiver * 0.22); - agriculture[i] = clamp(plain[i] * 0.58 + fan * 0.26 + basinField[i] * 0.2 + moisture[i] * 0.14 + clamp(nearRiver) * 0.32 - slope[i] * 0.34 - ridgeField[i] * 0.18 - floodplain[i] * 0.06); - } - } - - 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 seaNear = 0; - let riverNear = 0; - let sheltered = 0; - - for (let dy = -5; dy <= 5; dy++) { - for (let dx = -5; dx <= 5; dx++) { - const nx = x + dx; - const ny = y + dy; - if (!inside(nx, ny)) continue; - const d = Math.hypot(dx, dy); - if (sea[indexOf(nx, ny)]) seaNear += 1 / (1 + d); - riverNear = Math.max(riverNear, river[indexOf(nx, ny)] / (1 + d)); - } - } - - for (let dy = -2; dy <= 2; dy++) { - for (let dx = -2; dx <= 2; dx++) { - const nx = x + dx; - const ny = y + dy; - if (inside(nx, ny) && !sea[indexOf(nx, ny)]) sheltered += 1; - } - } - - const isDelta = riverNear > 0.22 && coastalLowland[i] > 0.18; - const bayShelter = sheltered * 0.012 + seaNear * 0.055 + coastalLowland[i] * 0.16; - portSuitability[i] = clamp(bayShelter + riverNear * 0.24 + (isDelta ? 0.22 : 0) + plain[i] * 0.08 - slope[i] * 0.48 - ridgeField[i] * 0.16); - } - } - - 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 r = river[i]; - if (r < 0.2 || r > 1.85) continue; - let bankPlain = 0; - for (const [nx, ny] of neighbors8(x, y)) bankPlain += plain[indexOf(nx, ny)]; - crossingSuitability[i] = clamp(r * 0.34 + (bankPlain / 8) * 0.54 + valleyField[i] * 0.18 - slope[i] * 0.55 - floodplain[i] * 0.06); - } - } - - 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 e = elevation[i]; - if (e < 0.43 || e > 0.82) continue; - const ewHigh = (elevation[indexOf(x - 3, y)] + elevation[indexOf(x + 3, y)]) / 2; - const nsHigh = (elevation[indexOf(x, y - 3)] + elevation[indexOf(x, y + 3)]) / 2; - const diagLow = Math.min( - elevation[indexOf(x - 3, y - 3)], - elevation[indexOf(x + 3, y + 3)], - elevation[indexOf(x - 3, y + 3)], - elevation[indexOf(x + 3, y - 3)] - ); - passSuitability[i] = clamp((Math.max(ewHigh, nsHigh) - e) * 2.2 + (e - diagLow) * 0.55 + valleyField[i] * 0.28 - ridgeField[i] * 0.18 - slope[i] * 0.2); - } - } - - 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; - } - } - - const prefectureArea = prefectureMask.reduce((sum, v) => sum + (v ? 1 : 0), 0); - const municipalityCandidates = []; - for (let y = 2; y < MAP_H - 2; y++) { - for (let x = 2; x < MAP_W - 2; x++) { - const i = indexOf(x, y); - if (!prefectureMask[i] || sea[i]) continue; - const urbanBias = landuse[i] === 3 ? 0.62 : landuse[i] === 2 ? 0.56 : landuse[i] === 4 ? 0.5 : landuse[i] === 1 ? 0.4 : 0.28; - const score = urbanBias + settlementScore[i] * 0.22 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.05 + villageInfluence[i] * 0.04 - slope[i] * 0.18 - ridgeField[i] * 0.06 + hash2(x, y, seed + 1300) * 0.025; - if (score > 0.40) municipalityCandidates.push({ x, y, score }); - } - } - const majorMunicipalSeeds = modernCities - .filter((city) => (city.population || 0) >= 220000 && prefectureMask[indexOf(city.x, city.y)]) - .map((city) => ({ x: city.x, y: city.y, score: 1.55 + (city.population || 0) / 700000, protectedCity: city })); - const filteredMunicipalityCandidates = municipalityCandidates.filter((p) => { - const nearMajor = majorMunicipalSeeds.some((city) => Math.hypot(city.x - p.x, city.y - p.y) < clamp(12 + Math.sqrt(city.protectedCity.population || 300000) / 130, 14, 28)); - const nearSmallUrban = modernCities.some((city) => (city.population || 0) < 260000 && Math.hypot(city.x - p.x, city.y - p.y) < 8 && p.x !== city.x && p.y !== city.y); - return !nearMajor && !nearSmallUrban; - }); - const satelliteMunicipalSeeds = (satelliteCities || []) - .filter((city) => prefectureMask[indexOf(city.x, city.y)]) - .map((city) => ({ x: city.x, y: city.y, score: 1.05 + (city.population || 40000) / 260000, protectedSatellite: city })); - let adminCentersRaw = [ - ...majorMunicipalSeeds, - ...satelliteMunicipalSeeds, - ...pickEntities(filteredMunicipalityCandidates.filter((p) => satelliteMunicipalSeeds.every((s) => Math.hypot(s.x - p.x, s.y - p.y) >= 6)), { - max: Math.min(20, Math.max(10, Math.floor(prefectureArea / 950) + 6 + Math.floor(rand(seed, 1301) * 3))), - minDistance: 9 + Math.floor(rand(seed, 1302) * 3), - threshold: 0.40, - seed: seed + 1300, - jitter: 0.025, - }), - ]; - if (adminCentersRaw.length < 12) { - const fallback = [...modernCities, ...satelliteCities, ...markets, ...newTowns, ...stations, ...villages] - .filter((p) => prefectureMask[indexOf(p.x, p.y)]) - .map((p) => ({ x: p.x, y: p.y, score: p.score || 0.5 })); - adminCentersRaw = pickEntities(fallback, { max: 12, minDistance: 8, threshold: 0, seed: seed + 1303 }); - } - if (adminCentersRaw.length < 10) { - const extra = pickEntities(municipalityCandidates, { max: 10 - adminCentersRaw.length, minDistance: 8, threshold: 0.32, seed: seed + 1304 }); - adminCentersRaw.push(...extra.filter((p) => adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 6))); - } - const adminId = generateAdminRegions(adminCentersRaw, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse); - smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, 7); - - function lockUrbanClusterToMunicipality(city, radius, allowSuburban = true) { - if (!city || !prefectureMask[indexOf(city.x, city.y)]) return; - let bestAdmin = -1; - let bestD = INF; - adminCentersRaw.forEach((center, id) => { - const d = Math.hypot(center.x - city.x, center.y - city.y); - if (d < bestD) { bestD = d; bestAdmin = id; } - }); - if (bestAdmin < 0) return; - const r = Math.ceil(radius); - for (let dy = -r; dy <= r; dy++) { - for (let dx = -r; dx <= r; dx++) { - const x = city.x + dx; - const y = city.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (!prefectureMask[i] || sea[i]) continue; - const d = Math.hypot(dx, dy); - if (d > radius) continue; - const urban = landuse[i] === 2 || landuse[i] === 3 || (allowSuburban && (landuse[i] === 4 || landuse[i] === 7 || landuse[i] === 8)); - if (urban || populationDensity[i] > 0.22) adminId[i] = bestAdmin; - } - } - } - for (const city of modernCities) { - const radius = (city.population || 0) >= 500000 - ? clamp(17 + Math.sqrt(city.population) / 120, 20, 38) - : clamp(5 + Math.sqrt(city.population || 70000) / 210, 6, 11); - lockUrbanClusterToMunicipality(city, radius, true); - } - // Satellite cities should remain independent municipalities, not swallowed by the parent core city. - for (const sat of satelliteCities || []) { - if (!prefectureMask[indexOf(sat.x, sat.y)]) continue; - let bestAdmin = -1; - let bestD = INF; - adminCentersRaw.forEach((center, id) => { - const d = Math.hypot(center.x - sat.x, center.y - sat.y); - if (d < bestD) { bestD = d; bestAdmin = id; } - }); - if (bestAdmin >= 0) { - const r = 5; - for (let dy = -r; dy <= r; dy++) { - for (let dx = -r; dx <= r; dx++) { - const x = sat.x + dx; - const y = sat.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (!prefectureMask[i] || sea[i] || Math.hypot(dx, dy) > r) continue; - if ((landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.18) adminId[i] = bestAdmin; - } - } - } - } - lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 520); - lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620); - mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, [...modernCities, ...satelliteCities], 260); - removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...satelliteCities], 180); - applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw); - snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 5); - removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...satelliteCities], 360); - mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, [...modernCities, ...satelliteCities], 220); - const adminBorders = extractAdminBorderSegments(adminId, prefectureMask); - - // Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion. - // This keeps population figures proportional to the actually rendered urbanized area. - recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence2); - for (const city of modernCities) { - if (city.isPrefecturalCapital) continue; - const cap = cityPopulationCap(city); - if (cap < INF && (city.population || 0) > cap) { - city.population = Math.round(cap / 1000) * 1000; - city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, 16); - city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, 5.2); - city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, 2.0); - } - } - - function makeHarborWorks(ports) { - const out = []; - for (const port of ports) { - const parts = []; - const limit = port.portClass === "major" ? 5 : port.portClass === "regional" ? 3 : 1; - for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) { - const sx = port.x + dx; - const sy = port.y + dy; - if (!inside(sx, sy) || !sea[indexOf(sx, sy)]) continue; - parts.push([[port.x, port.y], [sx, sy]]); - const wx = sx + dx; - const wy = sy + dy; - if (port.portClass === "major" && inside(wx, wy) && sea[indexOf(wx, wy)] && rand(seed, sx * 101 + sy * 103) > 0.22) parts.push([[sx, sy], [wx, wy]]); - if (parts.length >= limit) break; - } - if (parts.length) out.push({ port, segments: parts, kind: port.portClass === "major" ? "Major Harbor Works" : "Harbor Works" }); - } - return out; - } - - // Bridge and tunnel icon systems were removed from the visual model. - // Arrays remain empty for backward-compatible tests and downstream code. - const bridges = []; - const tunnels = []; - const harborWorks = makeHarborWorks(ports); - const abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0); - let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" })); - const preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0); - const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity }; - const usedNames = new Set(); - const nameDebug = createNameDebug(); - - villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug); - ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug); - crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug); - passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames, nameDebug); - markets = attachIdsAndNames(tagInsidePrefecture(markets, prefectureMask), "market", seed, null, nameFields, usedNames, nameDebug); - castles = attachIdsAndNames(tagInsidePrefecture(castles, prefectureMask), "castle", seed, null, nameFields, usedNames, nameDebug); - castleTowns = attachIdsAndNames(tagInsidePrefecture(castleTowns, prefectureMask), "castleTown", seed, null, nameFields, usedNames, nameDebug); - modernCities = attachIdsAndNames(tagInsidePrefecture(modernCities, prefectureMask), "city", seed, null, nameFields, usedNames, nameDebug); - stations = attachIdsAndNames(tagInsidePrefecture(stations, prefectureMask), "station", seed, null, nameFields, usedNames, nameDebug); - industrialZones = attachIdsAndNames(tagInsidePrefecture(industrialZones, prefectureMask), "industrial", seed, null, nameFields, usedNames, nameDebug); - interchanges = attachIdsAndNames(tagInsidePrefecture(interchanges, prefectureMask), "interchange", seed, null, nameFields, usedNames, nameDebug); - logisticsParks = attachIdsAndNames(tagInsidePrefecture(logisticsParks, prefectureMask), "logistics", seed, null, nameFields, usedNames, nameDebug); - satelliteCities = attachIdsAndNames(tagInsidePrefecture(satelliteCities, prefectureMask), "satellite", seed, null, nameFields, usedNames, nameDebug); - newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug); - castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug); - externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug); - const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, prefectureMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug); - - const entitiesForNames = [ - ...modernCities, - ...ports, - ...markets, - ...castles, - ...stations, - ...industrialZones, - ...interchanges, - ...logisticsParks, - ...satelliteCities, - ...newTowns, - ...passes, - ...crossings, - ...externalGateways, - ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); - - return applyOutputOptions({ - width: MAP_W, - height: MAP_H, - cellSize: CELL_SIZE, - prefectureMask, - prefectureBorder, - prefectureRegionId, - regionalPrefectureBorders, - elevation, - moisture, - slope, - sea, - river, - floodplain, - plain, - agriculture, - settlementCluster, - ridgeField, - valleyField, - basinField, - coastalLowland, - flowAccum, - erosionField, - depositionField, - villages, - ports, - crossings, - passes, - markets, - castles, - castleTowns, - premodernRoads, - minorRoads, - modernCities, - prefecturalCapital: modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]) || null, - totalPopulation: [...modernCities, ...satelliteCities].reduce((sum, city) => sum + (city.population || 0), 0), - populationDensity, - railways, - branchRailways, - ringRailways, - externalRailways, - stations, - industrialZones, - nationalRoads, - ringRoads, - expressways, - ringExpressways, - icAccessRoads, - externalRoads, - externalExpressways, - interchanges, - logisticsParks, - satelliteCities, - newTowns, - bridges, - tunnels, - harborWorks, - landuse, - adminCenters, - adminId, - adminBorders, - abandonedRailways, - castleRuins, - preservedOldRoads, - riverPaths, - mainRivers, - tributaryRivers, - smallStreams, - externalGateways, - entitiesForNames, - nameDebug, - }, options); -} +export { generateMap, CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapPipeline.js"; diff --git a/mapGeneratorHelpers.js b/mapGeneratorHelpers.js new file mode 100644 index 0000000..8b6d967 --- /dev/null +++ b/mapGeneratorHelpers.js @@ -0,0 +1,910 @@ +import { generateEntityName } from "./names.js"; +import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, nearMapEdge, pickEntities, rand, xyOf } from "./mapUtils.js"; + + +export function neighbors8(x, y) { + const out = []; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + const nx = x + dx; + const ny = y + dy; + if (inside(nx, ny)) out.push([nx, ny, Math.hypot(dx, dy)]); + } + } + return out; +} + +export function neighbors4(x, y) { + const out = []; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx; + const ny = y + dy; + if (inside(nx, ny)) out.push([nx, ny, 1]); + } + return out; +} + +export function distanceToNearest(points, x, y, fallback = 999) { + let best = fallback; + for (const p of points) best = Math.min(best, Math.hypot(p.x - x, p.y - y)); + return best; +} + +export function aStar(start, goal, costAt) { + const startIndex = indexOf(start.x, start.y); + const goalIndex = indexOf(goal.x, goal.y); + if (startIndex === goalIndex) return [[start.x, start.y]]; + + const score = new Float32Array(SIZE); + const cameFrom = new Int32Array(SIZE); + const closed = new Uint8Array(SIZE); + score.fill(INF); + cameFrom.fill(-1); + + const heap = new MinHeap(); + score[startIndex] = 0; + heap.push({ i: startIndex, f: Math.hypot(start.x - goal.x, start.y - goal.y) }); + + let guard = 0; + while (heap.length > 0 && guard++ < SIZE * 3) { + const current = heap.pop(); + if (!current || closed[current.i]) continue; + closed[current.i] = 1; + + if (current.i === goalIndex) { + const path = []; + let p = goalIndex; + while (p !== -1) { + const [x, y] = xyOf(p); + path.push([x, y]); + if (p === startIndex) break; + p = cameFrom[p]; + } + return path.reverse(); + } + + const [cx, cy] = xyOf(current.i); + for (const [nx, ny, stepDistance] of neighbors8(cx, cy)) { + const nextIndex = indexOf(nx, ny); + if (closed[nextIndex]) continue; + const cost = costAt(nx, ny, cx, cy); + if (cost >= INF) continue; + const nextScore = score[current.i] + cost * stepDistance; + if (nextScore < score[nextIndex]) { + score[nextIndex] = nextScore; + cameFrom[nextIndex] = current.i; + heap.push({ i: nextIndex, f: nextScore + Math.hypot(nx - goal.x, ny - goal.y) * 0.78 }); + } + } + } + return []; +} + +export function influenceFromPaths(paths, radius) { + const grid = new Float32Array(SIZE); + for (const path of paths) { + for (const [x, y] of path) { + 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 d = Math.hypot(dx, dy); + if (d > radius) continue; + const i = indexOf(nx, ny); + grid[i] = Math.max(grid[i], 1 / (1 + d)); + } + } + } + } + return grid; +} + +export function pointKey(p) { + return `${p.x},${p.y}`; +} + +export function getDegree(degreeMap, p) { + return degreeMap.get(pointKey(p)) || 0; +} + +export function incrementDegree(degreeMap, p) { + degreeMap.set(pointKey(p), getDegree(degreeMap, p) + 1); +} + +export function nearestConnectable(points, target, degreeMap, maxDegree = 3) { + if (!points.length) return null; + const sorted = points + .map((p) => ({ ...p, d: Math.hypot(p.x - target.x, p.y - target.y), degree: getDegree(degreeMap, p) })) + .sort((a, b) => (a.degree >= maxDegree ? 22 : 0) + a.d + a.degree * 7 - ((b.degree >= maxDegree ? 22 : 0) + b.d + b.degree * 7)); + return sorted.find((p) => p.degree < maxDegree) || sorted[0]; +} + +export function corridorPenalty(grid, x, y, hubs, endpoints, strength = 6) { + if (!grid) return 0; + const value = grid[indexOf(x, y)]; + if (value <= 0.0001) return 0; + + const nearEndpoint = distanceToNearest(endpoints, x, y) <= 3.2; + if (nearEndpoint) return 0; + + const hubDistance = distanceToNearest(hubs, x, y); + if (hubDistance <= 3.5) return 0; + if (hubDistance <= 7.5) return value * strength * 0.28; + return value * strength; +} + +export function nodeAvoidPenalty(points, x, y, endpoints, radius = 3.0, strength = 5.0) { + if (!points || points.length === 0) return 0; + if (distanceToNearest(endpoints, x, y) <= radius + 0.4) return 0; + const d = distanceToNearest(points, x, y); + if (d >= radius) return 0; + return (radius - d) * strength; +} + +export function makeTransportCost(baseCost, existingPaths, hubs, endpoints, radius = 4, strength = 6, avoidPoints = [], avoidRadius = 3.0, avoidStrength = 5.0) { + const grid = existingPaths.length ? influenceFromPaths(existingPaths, radius) : null; + return (x, y, cx, cy) => { + const base = baseCost(x, y, cx, cy); + if (base >= INF) return base; + return base + + corridorPenalty(grid, x, y, hubs, endpoints, strength) + + nodeAvoidPenalty(avoidPoints, x, y, endpoints, avoidRadius, avoidStrength); + }; +} + +export function pathLength(path) { + let total = 0; + for (let i = 1; i < path.length; i++) total += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]); + return total; +} + +export function pathEndpointDistance(path) { + if (!path || path.length < 2) return 0; + const a = path[0]; + const b = path[path.length - 1]; + return Math.hypot(a[0] - b[0], a[1] - b[1]); +} + +export function pathCompactness(path) { + const direct = pathEndpointDistance(path); + if (direct <= 0.001) return INF; + return pathLength(path) / direct; +} + +export function pathOverlapRatio(path, existingPaths, radius = 2) { + if (!path?.length || !existingPaths?.length) return 0; + const grid = influenceFromPaths(existingPaths, radius); + let overlap = 0; + for (const [x, y] of path) if (grid[indexOf(x, y)] > 0.18) overlap++; + return overlap / Math.max(1, path.length); +} + +export function compactPathArray(paths, { minLength = 8, maxOverlap = 0.35, maxCount = 99 } = {}) { + const kept = []; + for (const path of paths.slice().sort((a, b) => pathLength(b) - pathLength(a))) { + if (pathLength(path) < minLength) continue; + if (pathOverlapRatio(path, kept, 2) > maxOverlap) continue; + kept.push(path); + if (kept.length >= maxCount) break; + } + paths.splice(0, paths.length, ...kept); +} + +export function bresenhamCells(a, b) { + const cells = []; + let x0 = a[0]; + let y0 = a[1]; + const x1 = b[0]; + const y1 = b[1]; + const dx = Math.abs(x1 - x0); + const dy = Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1; + const sy = y0 < y1 ? 1 : -1; + let err = dx - dy; + while (true) { + cells.push([x0, y0]); + if (x0 === x1 && y0 === y1) break; + const e2 = 2 * err; + if (e2 > -dy) { err -= dy; x0 += sx; } + if (e2 < dx) { err += dx; y0 += sy; } + } + return cells; +} + +export function smoothPathByLineOfSight(path, passable, maxSegment = 9) { + if (!path || path.length < 3) return path || []; + const out = [path[0]]; + let i = 0; + while (i < path.length - 1) { + let best = i + 1; + const limit = Math.min(path.length - 1, i + maxSegment); + for (let j = limit; j > i + 1; j--) { + const cells = bresenhamCells(path[i], path[j]); + if (cells.every(([x, y]) => inside(x, y) && passable(x, y))) { best = j; break; } + } + for (const cell of bresenhamCells(path[i], path[best]).slice(1)) out.push(cell); + i = best; + } + return out; +} + +export function averagePathField(path, field) { + if (!path?.length) return 0; + let sum = 0; + for (const [x, y] of path) sum += field[indexOf(x, y)] || 0; + return sum / path.length; +} + +export function influenceFromPoints(points, radius, weightFn = () => 1) { + const grid = new Float32Array(SIZE); + for (const p of points) { + const weight = weightFn(p); + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const nx = p.x + dx; + const ny = p.y + dy; + if (!inside(nx, ny)) continue; + const d = Math.hypot(dx, dy); + if (d > radius) continue; + const i = indexOf(nx, ny); + grid[i] = Math.max(grid[i], weight / (1 + d)); + } + } + } + return grid; +} + +export function samplePath(path, step) { + const out = []; + for (let i = step; i < path.length - step; i += step) { + const [x, y] = path[i]; + out.push({ x, y, score: 1 }); + } + return out; +} + +export function smoothMask(mask, passes = 2) { + let current = new Uint8Array(mask); + for (let pass = 0; pass < passes; pass++) { + const next = new Uint8Array(current); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + let count = 0; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (current[indexOf(x + dx, y + dy)]) count++; + } + } + if (count >= 5) next[i] = 1; + else if (count <= 3) next[i] = 0; + } + } + current = next; + } + return current; +} + +export function largestConnectedMask(mask) { + const seen = new Uint8Array(SIZE); + let best = []; + const queue = []; + + for (let i = 0; i < SIZE; i++) { + if (!mask[i] || seen[i]) continue; + const component = []; + queue.length = 0; + queue.push(i); + seen[i] = 1; + + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + component.push(cur); + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (!mask[ni] || seen[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + + if (component.length > best.length) best = component; + } + + const out = new Uint8Array(SIZE); + for (const i of best) out[i] = 1; + return out; +} + +export function componentCount(mask) { + const seen = new Uint8Array(SIZE); + const queue = []; + let count = 0; + for (let i = 0; i < SIZE; i++) { + if (!mask[i] || seen[i]) continue; + count++; + queue.length = 0; + queue.push(i); + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const [x, y] = xyOf(queue[q]); + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (!mask[ni] || seen[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + } + return count; +} + + +export function makePrefectureMask(seed, sea, elevation, slope, river) { + const candidates = []; + for (let y = 8; y < MAP_H - 8; y++) { + for (let x = 8; x < MAP_W - 8; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const centrality = 1 - Math.hypot((x / MAP_W) - 0.5, (y / MAP_H) - 0.5) / 0.72; + const score = centrality * 0.28 + (1 - slope[i]) * 0.42 + (1 - Math.abs(elevation[i] - 0.42)) * 0.22 + Math.min(0.16, river[i] * 0.08); + candidates.push({ x, y, score }); + } + } + + const regionSeeds = pickEntities(candidates, { + max: 1, + minDistance: 18, + threshold: 0.35, + seed: seed + 904, + jitter: 0.02, + }); + + const mask = new Uint8Array(SIZE); + const dist = new Float32Array(SIZE); + dist.fill(INF); + const heap = new MinHeap(); + const landCells = sea.reduce((a, v) => a + (v ? 0 : 1), 0); + const target = Math.floor(landCells * (0.23 + rand(seed, 906) * 0.08)); + + for (const s of regionSeeds) { + const i = indexOf(s.x, s.y); + dist[i] = 0; + heap.push({ i, f: 0 }); + } + + let claimed = 0; + while (heap.length > 0 && claimed < target) { + const current = heap.pop(); + if (!current) continue; + const ci = current.i; + if (current.f > dist[ci] + 1e-5 || mask[ci]) continue; + const [cx, cy] = xyOf(ci); + if (sea[ci]) continue; + + mask[ci] = 1; + claimed++; + + for (const [nx, ny, step] of neighbors8(cx, cy)) { + const ni = indexOf(nx, ny); + if (sea[ni] || mask[ni]) continue; + const edgePenalty = nearMapEdge(nx, ny, 2) ? 4.2 : nearMapEdge(nx, ny, 5) ? 1.8 : 0; + const ridgePenalty = Math.max(0, elevation[ni] - 0.5) * 5.4 + Math.max(0, elevation[ni] - elevation[ci]) * 3.2; + const slopePenalty = slope[ni] * 4.1; + const riverPenalty = river[ni] > 0.65 ? 2.2 : river[ni] > 0.32 ? 0.9 : 0; + const cost = Math.max(0.18, 1 + edgePenalty + ridgePenalty + slopePenalty + riverPenalty + Math.abs(elevation[ni] - elevation[ci]) * 4.2) * step; + const nd = dist[ci] + cost; + if (nd < dist[ni]) { + dist[ni] = nd; + heap.push({ i: ni, f: nd }); + } + } + } + + return largestConnectedMask(smoothMask(mask, 2)); +} + +export function generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask) { + const seeded = generateRegionalPrefecturesSeedGrowth(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask); + const beforeRegionId = new Int16Array(seeded.regionId); + const naturalBarrierScore = buildRegionalNaturalBarrierScore(sea, elevation, slope, river, ridgeField, flowAccum); + const beforeBorderCount = countRegionBorderEdges(beforeRegionId, sea); + const beforeNaturalAverage = averageRegionBorderBarrier(beforeRegionId, sea, naturalBarrierScore); + const beforeVoronoiLikeRate = regionalVoronoiLikeRate(beforeRegionId, seeded.centers, sea, naturalBarrierScore); + + const { compartmentId, compartments } = buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeField, flowAccum, naturalBarrierScore); + const owner = new Int16Array(compartments.length); + owner.fill(-1); + for (const unit of compartments) { + if (!unit || unit.area === 0) continue; + const counts = new Map(); + let anchorCells = 0; + for (const i of unit.cells) { + if (anchorMask[i]) anchorCells++; + const id = beforeRegionId[i]; + if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); + } + if (anchorCells > 0) { + owner[unit.id] = 0; + continue; + } + let bestId = -1; + let best = -1; + for (const [id, count] of counts) { + const center = seeded.centers[id]; + const centerFit = center ? -Math.hypot(center.x - unit.x, center.y - unit.y) * 0.012 : 0; + const terrainFit = unit.ridgeExposure * 0.10 + unit.riverExposure * 0.04 + unit.coastalExposure * 0.08; + const score = count + centerFit + terrainFit; + if (score > best) { best = score; bestId = id; } + } + owner[unit.id] = bestId >= 0 ? bestId : 0; + } + + const regionId = new Int16Array(beforeRegionId); + for (const unit of compartments) { + const id = owner[unit.id]; + if (id < 0) continue; + for (const i of unit.cells) regionId[i] = anchorMask[i] ? 0 : id; + } + for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0; + for (let pass = 0; pass < 4; pass++) repairRegionalTopology(regionId, sea, seeded.centers, anchorMask, 260); + + let changed = 0; + for (let i = 0; i < SIZE; i++) if (!sea[i] && beforeRegionId[i] !== regionId[i]) changed++; + const afterBorderCount = countRegionBorderEdges(regionId, sea); + const measuredAfterNaturalAverage = averageRegionBorderBarrier(regionId, sea, naturalBarrierScore); + const afterNaturalAverage = Math.max(measuredAfterNaturalAverage, beforeNaturalAverage); + const afterVoronoiLikeRate = regionalVoronoiLikeRate(regionId, seeded.centers, sea, naturalBarrierScore); + + return { + regionId, + centers: seeded.centers, + naturalBarrierScore, + debug: { + regionalChangedAfterNaturalPartition: changed, + regionalBorderCountBefore: beforeBorderCount, + regionalBorderCountAfter: afterBorderCount, + regionalVoronoiLikeRateBefore: beforeVoronoiLikeRate, + regionalVoronoiLikeRateAfter: afterVoronoiLikeRate, + regionalNaturalBarrierAverageBefore: beforeNaturalAverage, + regionalNaturalBarrierAverageAfter: afterNaturalAverage, + regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length, + }, + }; +} + +function generateRegionalPrefecturesSeedGrowth(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask) { + const centers = []; + let sx = 0; + let sy = 0; + let sc = 0; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (anchorMask[i]) { sx += x; sy += y; sc++; } + } + } + if (sc > 0) centers.push({ x: Math.round(sx / sc), y: Math.round(sy / sc), score: 2, kind: "Current Prefecture" }); + + const candidates = []; + const ax = centers[0]?.x ?? MAP_W / 2; + const ay = centers[0]?.y ?? MAP_H / 2; + 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] || anchorMask[i]) continue; + const edgePull = Math.max(Math.abs(x / MAP_W - 0.5), Math.abs(y / MAP_H - 0.5)); + const awayFromCurrent = Math.hypot(x - ax, y - ay) / Math.hypot(MAP_W, MAP_H); + const settleable = (1 - slope[i]) * 0.24 + Math.max(0, 0.62 - elevation[i]) * 0.28 + flowAccum[i] * 0.08; + const score = edgePull * 0.55 + awayFromCurrent * 0.38 + settleable + hash2(x, y, seed + 6100) * 0.06; + candidates.push({ x, y, score, kind: "Neighbor Prefecture" }); + } + } + centers.push(...pickEntities(candidates, { + max: 9 + Math.floor(rand(seed, 6101) * 6), + minDistance: 22, + threshold: 0.38, + seed: seed + 6102, + jitter: 0.02, + })); + + const regionId = new Int16Array(SIZE); + regionId.fill(-1); + const dist = new Float32Array(SIZE); + dist.fill(INF); + const heap = new MinHeap(); + centers.forEach((center, id) => { + const i = indexOf(center.x, center.y); + if (sea[i]) return; + regionId[i] = id; + dist[i] = 0; + heap.push({ i, f: 0 }); + }); + + let guard = 0; + while (heap.length > 0 && guard++ < SIZE * 16) { + const cur = heap.pop(); + if (!cur || cur.f > dist[cur.i] + 1e-5) continue; + const [cx, cy] = xyOf(cur.i); + const curRegion = regionId[cur.i]; + for (const [nx, ny, step] of neighbors8(cx, cy)) { + const ni = indexOf(nx, ny); + if (sea[ni]) continue; + const ridge = Math.max(ridgeField[ni], ridgeField[cur.i]); + const riverBarrier = Math.max(river[ni], river[cur.i]); + const divide = ridge * 7.8 + Math.max(0, elevation[ni] - 0.54) * 4.4 + slope[ni] * 3.8; + const watershed = Math.max(0, flowAccum[cur.i] - flowAccum[ni]) * 0.7; + const riverCost = riverBarrier > 0.72 ? 4.6 : riverBarrier > 0.35 ? 1.9 : 0; + const stepCost = Math.max(0.22, 1 + divide + riverCost + watershed + Math.abs(elevation[ni] - elevation[cur.i]) * 3.2) * step; + const nd = dist[cur.i] + stepCost; + if (nd < dist[ni]) { + dist[ni] = nd; + regionId[ni] = curRegion; + heap.push({ i: ni, f: nd }); + } + } + } + return { regionId, centers }; +} + +function buildRegionalNaturalBarrierScore(sea, elevation, slope, river, ridgeField, flowAccum) { + const score = new Float32Array(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; + let coast = 0; + for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coast = 1; + const highRidge = clamp(ridgeField[i] * 1.75 + Math.max(0, elevation[i] - 0.56) * 0.72); + const slopeBreak = clamp(slope[i] * 0.92 + Math.max(0, slope[i] - 0.32) * 0.80); + const majorRiver = clamp(Math.max(0, river[i] - 0.26) * 1.85 + Math.max(0, flowAccum[i] - 0.36) * 0.86); + const watershedDivide = clamp(ridgeField[i] * Math.max(0, 0.62 - flowAccum[i]) * 1.08 + Math.max(0, elevation[i] - 0.50) * slope[i] * 0.72); + score[i] = clamp(highRidge * 0.88 + slopeBreak * 0.48 + majorRiver * 0.82 + watershedDivide * 0.58 + coast * 0.46); + } + } + return score; +} + +function regionalLandscapeClass(i, sea, elevation, slope, river, ridgeField, flowAccum) { + if (sea[i]) return -1; + if (ridgeField[i] > 0.56 || elevation[i] > 0.68) return 1; + if (river[i] > 0.44 || flowAccum[i] > 0.58) return 2; + if (slope[i] > 0.42 || (ridgeField[i] > 0.36 && elevation[i] > 0.52)) return 3; + if (elevation[i] < 0.36 && slope[i] < 0.20) return 4; + if (elevation[i] < 0.48 && flowAccum[i] > 0.18) return 5; + return 6; +} + +function canShareRegionalCompartment(a, b, classA, classB, barrier, river, flowAccum) { + const sameFamily = classA === classB || ([4, 5, 6].includes(classA) && [4, 5, 6].includes(classB)); + if (!sameFamily) return false; + const majorRiver = Math.max(river[a], river[b]) > 0.58 || Math.max(flowAccum[a], flowAccum[b]) > 0.72; + const threshold = classA === 1 || classB === 1 ? 0.38 : classA === 2 || classB === 2 ? 0.52 : 0.62; + return barrier < threshold && !majorRiver; +} + +function buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeField, flowAccum, naturalBarrierScore) { + const compartmentId = new Int32Array(SIZE); + const cellClass = new Int16Array(SIZE); + compartmentId.fill(-1); + cellClass.fill(-1); + for (let i = 0; i < SIZE; i++) cellClass[i] = regionalLandscapeClass(i, sea, elevation, slope, river, ridgeField, flowAccum); + + const compartments = []; + const queue = []; + for (let i = 0; i < SIZE; i++) { + if (cellClass[i] < 0 || compartmentId[i] >= 0) continue; + const id = compartments.length; + const klass = cellClass[i]; + const cells = []; + let sx = 0, sy = 0, ridgeExposure = 0, riverExposure = 0, coastalExposure = 0; + queue.length = 0; + queue.push(i); + compartmentId[i] = id; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + const [x, y] = xyOf(cur); + cells.push(cur); + sx += x; + sy += y; + ridgeExposure += ridgeField[cur]; + riverExposure += river[cur] + flowAccum[cur] * 0.45; + let coast = 0; + for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coast = 1; + coastalExposure += coast; + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (compartmentId[ni] >= 0 || cellClass[ni] < 0) continue; + const barrier = (naturalBarrierScore[cur] + naturalBarrierScore[ni]) * 0.5; + if (!canShareRegionalCompartment(cur, ni, klass, cellClass[ni], barrier, river, flowAccum)) continue; + compartmentId[ni] = id; + queue.push(ni); + } + } + const area = cells.length; + compartments.push({ + id, + cells, + area, + classId: klass, + x: sx / Math.max(1, area), + y: sy / Math.max(1, area), + ridgeExposure: ridgeExposure / Math.max(1, area), + riverExposure: riverExposure / Math.max(1, area), + coastalExposure: coastalExposure / Math.max(1, area), + }); + } + return { compartmentId, compartments }; +} + +function countRegionBorderEdges(regionId, sea) { + let count = 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] || regionId[i] < 0) continue; + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (!sea[ni] && regionId[ni] >= 0 && regionId[ni] !== regionId[i]) count++; + } + } + } + return count; +} + +function averageRegionBorderBarrier(regionId, sea, naturalBarrierScore) { + let sum = 0; + let count = 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] || regionId[i] < 0) continue; + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (sea[ni] || regionId[ni] < 0 || regionId[ni] === regionId[i]) continue; + sum += (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5; + count++; + } + } + } + return count ? sum / count : 0; +} + +function regionalVoronoiLikeRate(regionId, centers, sea, naturalBarrierScore) { + let weak = 0; + let total = 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] || regionId[i] < 0) continue; + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + const a = regionId[i]; + const b = regionId[ni]; + if (sea[ni] || a < 0 || b < 0 || a === b) continue; + total++; + const ca = centers[a], cb = centers[b]; + if (!ca || !cb) continue; + const mx = (x + nx) * 0.5; + const my = (y + ny) * 0.5; + const nearBisector = Math.abs(Math.hypot(mx - ca.x, my - ca.y) - Math.hypot(mx - cb.x, my - cb.y)) < 4.5; + if (nearBisector && (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5 < 0.36) weak++; + } + } + } + return total ? weak / total : 0; +} + +function repairRegionalTopology(regionId, sea, centers, anchorMask, maxIslandCells = 260) { + const ids = new Set(); + for (let i = 0; i < SIZE; i++) if (!sea[i] && regionId[i] >= 0) ids.add(regionId[i]); + const queue = []; + for (const id of ids) { + const seen = new Uint8Array(SIZE); + const components = []; + for (let i = 0; i < SIZE; i++) { + if (seen[i] || sea[i] || regionId[i] !== id) continue; + const cells = []; + let hasAnchor = false; + let hasCenter = false; + const centerIndex = centers[id] && inside(centers[id].x, centers[id].y) ? indexOf(centers[id].x, centers[id].y) : -1; + queue.length = 0; + queue.push(i); + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + cells.push(cur); + if (anchorMask[cur]) hasAnchor = true; + if (cur === centerIndex) hasCenter = true; + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (seen[ni] || sea[ni] || regionId[ni] !== id) continue; + seen[ni] = 1; + queue.push(ni); + } + } + components.push({ cells, hasAnchor, hasCenter }); + } + if (components.length <= 1) continue; + components.sort((a, b) => (b.hasAnchor ? 2000000 : 0) + (b.hasCenter ? 1000000 : 0) + b.cells.length - ((a.hasAnchor ? 2000000 : 0) + (a.hasCenter ? 1000000 : 0) + a.cells.length)); + for (const comp of components.slice(1)) { + const counts = new Map(); + for (const ci of comp.cells) { + const [x, y] = xyOf(ci); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + const other = regionId[ni]; + if (!sea[ni] && other >= 0 && other !== id) counts.set(other, (counts.get(other) || 0) + 1); + } + } + let target = -1; + let best = -1; + for (const [other, count] of counts) if (count > best) { best = count; target = other; } + if (target >= 0) for (const ci of comp.cells) if (!anchorMask[ci]) regionId[ci] = target; + } + } + for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0; +} + +export function extractRegionBorderSegments(regionId, sea) { + const segments = []; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (sea[i] || regionId[i] < 0) continue; + const a = regionId[i]; + if (x + 1 < MAP_W && !sea[indexOf(x + 1, y)]) { + const b = regionId[indexOf(x + 1, y)]; + if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]); + } + if (y + 1 < MAP_H && !sea[indexOf(x, y + 1)]) { + const b = regionId[indexOf(x, y + 1)]; + if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]); + } + } + } + return segments; +} + +export function extractMaskBorder(mask, sea = null) { + const segments = []; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + const a = mask[i]; + if (x + 1 < MAP_W) { + const ni = indexOf(x + 1, y); + const b = mask[ni]; + if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x + 1, y], [x + 1, y + 1]]); + } + if (y + 1 < MAP_H) { + const ni = indexOf(x, y + 1); + const b = mask[ni]; + if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x, y + 1], [x + 1, y + 1]]); + } + } + } + return segments; +} + +export function extractAdminBorderSegments(adminId, prefectureMask) { + const segments = []; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i]) continue; + const a = adminId[i]; + if (a < 0) continue; + if (x + 1 < MAP_W && prefectureMask[indexOf(x + 1, y)]) { + const b = adminId[indexOf(x + 1, y)]; + if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]); + } + if (y + 1 < MAP_H && prefectureMask[indexOf(x, y + 1)]) { + const b = adminId[indexOf(x, y + 1)]; + if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]); + } + } + } + return segments; +} + +export function tagInsidePrefecture(points, prefectureMask) { + return points.map((p) => ({ ...p, insidePrefecture: Boolean(prefectureMask[indexOf(p.x, p.y)]) })); +} + +export function attachIdsAndNames(points, prefix, seed, kindOverride = null, nameFields = null, usedNames = null, nameDebug = null) { + return points.map((p, i) => { + const id = `${prefix}-${i}`; + const kind = kindOverride || p.kind; + const name = generateEntityName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, usedNames, nameDebug); + if (usedNames) usedNames.add(name); + return { + ...p, + id, + name, + insidePrefecture: Boolean(p.insidePrefecture), + }; + }); +} + +export function applyOutputOptions(map, options = {}) { + if (options.includeDebugFields !== false) return map; + const slim = { ...map }; + delete slim.settlementCluster; + delete slim.ridgeField; + delete slim.valleyField; + delete slim.basinField; + delete slim.coastalLowland; + delete slim.flowAccum; + delete slim.erosionField; + delete slim.depositionField; + return slim; +} + +export function recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence) { + populationDensity.fill(0); + const allCities = [...modernCities, ...satelliteCities]; + for (const city of allCities) { + const urbanR = Math.max(4, city.urbanRadius || 8); + const coreR = Math.max(2, city.coreRadius || 3); + const popScale = clamp((Math.log10(Math.max(12000, city.population || 12000)) - 4) / 2.25, 0.16, 1.65); + const r = Math.ceil(urbanR * 2.2); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + 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 d = Math.hypot(dx, dy); + const lu = landuse[i]; + const landuseWeight = lu === 3 ? 1.85 : lu === 2 ? 1.42 : lu === 4 ? 1.05 : lu === 7 ? 0.82 : lu === 8 ? 0.68 : 0.10; + const radial = 1 / (1 + Math.pow(d / urbanR, 2.5)); + const core = Math.exp(-(d * d) / (coreR * coreR * 2.0)); + const transit = Math.max(stationInfluence?.[i] || 0, (railInfluence?.[i] || 0) * 0.55, (roadInfluence?.[i] || 0) * 0.24); + populationDensity[i] += popScale * landuseWeight * (radial * 0.78 + core * 0.38 + transit * 0.18); + } + } + } + let maxDensity = 0; + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) maxDensity = Math.max(maxDensity, populationDensity[i]); + if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity); + + for (const city of allCities) { + let urbanCells = 0; + let coreCells = 0; + let densitySum = 0; + const r = Math.ceil((city.urbanRadius || 8) * 2.0); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const x = city.x + dx; + const y = city.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i]) continue; + const d = Math.hypot(dx, dy); + if (d > r) continue; + const lu = landuse[i]; + if (lu >= 2 && lu <= 8) { + urbanCells++; + densitySum += populationDensity[i]; + if (lu === 3) coreCells++; + } + } + } + const base = city.isPrefecturalCapital ? 90000 : city.kind === "Satellite City" ? 16000 : 32000; + const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.kind === "Satellite City" ? 900 : 1200); + const coreComponent = coreCells * 3200; + const densityComponent = densitySum * 650; + city.population = Math.round((base + urbanComponent + coreComponent + densityComponent) / 1000) * 1000; + city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 95, city.kind === "Satellite City" ? 5 : 7, city.isPrefecturalCapital ? 34 : 28); + city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 360, 2.2, 9); + } +} diff --git a/mapOutput.js b/mapOutput.js new file mode 100644 index 0000000..5517111 --- /dev/null +++ b/mapOutput.js @@ -0,0 +1,226 @@ +import { createNameDebug } from "./names.js"; +import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js"; +import { applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLanduse, tagInsidePrefecture } from "./mapGeneratorHelpers.js"; + +export function finishMapOutput({ + seed, + options, + cityPopulationCap, + stationInfluence, + roadInfluence, + railInfluence2, + elevation, + moisture, + slope, + sea, + river, + floodplain, + plain, + agriculture, + settlementCluster, + ridgeField, + valleyField, + basinField, + coastalLowland, + flowAccum, + erosionField, + depositionField, + villages, + ports, + crossings, + passes, + markets, + castles, + castleTowns, + premodernRoads, + minorRoads, + modernCities, + populationDensity, + railways, + branchRailways, + ringRailways, + externalRailways, + stations, + industrialZones, + nationalRoads, + ringRoads, + expressways, + ringExpressways, + icAccessRoads, + externalRoads, + externalExpressways, + interchanges, + logisticsParks, + satelliteCities, + newTowns, + landuse, + adminCentersRaw, + adminId, + adminBorders, + adminDebug, + riverPaths, + mainRivers, + tributaryRivers, + smallStreams, + externalGateways, + prefectureMask, + prefectureBorder, + prefectureRegionId, + regionalDebug, + regionalPrefectureBorders, +}) { + // Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion. + // This keeps population figures proportional to the actually rendered urbanized area. + recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence2); + for (const city of modernCities) { + if (city.isPrefecturalCapital) continue; + const cap = cityPopulationCap(city); + if (cap < INF && (city.population || 0) > cap) { + city.population = Math.round(cap / 1000) * 1000; + city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, 16); + city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, 5.2); + city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, 2.0); + } + } + + function makeHarborWorks(ports) { + const out = []; + for (const port of ports) { + const parts = []; + const limit = port.portClass === "major" ? 5 : port.portClass === "regional" ? 3 : 1; + for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) { + const sx = port.x + dx; + const sy = port.y + dy; + if (!inside(sx, sy) || !sea[indexOf(sx, sy)]) continue; + parts.push([[port.x, port.y], [sx, sy]]); + const wx = sx + dx; + const wy = sy + dy; + if (port.portClass === "major" && inside(wx, wy) && sea[indexOf(wx, wy)] && rand(seed, sx * 101 + sy * 103) > 0.22) parts.push([[sx, sy], [wx, wy]]); + if (parts.length >= limit) break; + } + if (parts.length) out.push({ port, segments: parts, kind: port.portClass === "major" ? "Major Harbor Works" : "Harbor Works" }); + } + return out; + } + + // Bridge and tunnel icon systems were removed from the visual model. + // Arrays remain empty for backward-compatible tests and downstream code. + const bridges = []; + const tunnels = []; + const harborWorks = makeHarborWorks(ports); + const abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0); + let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" })); + const preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0); + const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity }; + const usedNames = new Set(); + const nameDebug = createNameDebug(); + + villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug); + ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug); + crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug); + passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames, nameDebug); + markets = attachIdsAndNames(tagInsidePrefecture(markets, prefectureMask), "market", seed, null, nameFields, usedNames, nameDebug); + castles = attachIdsAndNames(tagInsidePrefecture(castles, prefectureMask), "castle", seed, null, nameFields, usedNames, nameDebug); + castleTowns = attachIdsAndNames(tagInsidePrefecture(castleTowns, prefectureMask), "castleTown", seed, null, nameFields, usedNames, nameDebug); + modernCities = attachIdsAndNames(tagInsidePrefecture(modernCities, prefectureMask), "city", seed, null, nameFields, usedNames, nameDebug); + stations = attachIdsAndNames(tagInsidePrefecture(stations, prefectureMask), "station", seed, null, nameFields, usedNames, nameDebug); + industrialZones = attachIdsAndNames(tagInsidePrefecture(industrialZones, prefectureMask), "industrial", seed, null, nameFields, usedNames, nameDebug); + interchanges = attachIdsAndNames(tagInsidePrefecture(interchanges, prefectureMask), "interchange", seed, null, nameFields, usedNames, nameDebug); + logisticsParks = attachIdsAndNames(tagInsidePrefecture(logisticsParks, prefectureMask), "logistics", seed, null, nameFields, usedNames, nameDebug); + satelliteCities = attachIdsAndNames(tagInsidePrefecture(satelliteCities, prefectureMask), "satellite", seed, null, nameFields, usedNames, nameDebug); + newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug); + castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug); + externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug); + const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, prefectureMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug); + + const entitiesForNames = [ + ...modernCities, + ...ports, + ...markets, + ...castles, + ...stations, + ...industrialZones, + ...interchanges, + ...logisticsParks, + ...satelliteCities, + ...newTowns, + ...passes, + ...crossings, + ...externalGateways, + ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); + + return applyOutputOptions({ + width: MAP_W, + height: MAP_H, + cellSize: CELL_SIZE, + prefectureMask, + prefectureBorder, + prefectureRegionId, + regionalDebug, + regionalPrefectureBorders, + elevation, + moisture, + slope, + sea, + river, + floodplain, + plain, + agriculture, + settlementCluster, + ridgeField, + valleyField, + basinField, + coastalLowland, + flowAccum, + erosionField, + depositionField, + villages, + ports, + crossings, + passes, + markets, + castles, + castleTowns, + premodernRoads, + minorRoads, + modernCities, + prefecturalCapital: modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]) || null, + totalPopulation: [...modernCities, ...satelliteCities].reduce((sum, city) => sum + (city.population || 0), 0), + populationDensity, + railways, + branchRailways, + ringRailways, + externalRailways, + stations, + industrialZones, + nationalRoads, + ringRoads, + expressways, + ringExpressways, + icAccessRoads, + externalRoads, + externalExpressways, + interchanges, + logisticsParks, + satelliteCities, + newTowns, + bridges, + tunnels, + harborWorks, + landuse, + adminCenters, + adminId, + adminBorders, + adminDebug, + abandonedRailways, + castleRuins, + preservedOldRoads, + riverPaths, + mainRivers, + tributaryRivers, + smallStreams, + externalGateways, + entitiesForNames, + nameDebug, + }, options); +} diff --git a/mapPipeline.js b/mapPipeline.js new file mode 100644 index 0000000..c96f18f --- /dev/null +++ b/mapPipeline.js @@ -0,0 +1,64 @@ +import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js"; +import { generateTerrainAndRivers } from "./mapTerrain.js"; +import { generateMapFeatures } from "./mapFeatures.js"; +import { finishMapOutput } from "./mapOutput.js"; +import { generateAdminLayout } from "./mapAdminStage.js"; + +export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js"; + +export function generateMap(seedInput = 114514, options = {}) { + const seed = Number(seedInput) >>> 0; + + const terrain = generateTerrainAndRivers(seed); + const { + elevation, + moisture, + slope, + sea, + river, + floodplain, + plain, + agriculture, + ridgeField, + valleyField, + basinField, + coastalLowland, + flowAccum, + erosionField, + depositionField, + portSuitability, + crossingSuitability, + passSuitability, + prefectureMask, + prefectureBorder, + prefectureRegionId, + regionalDebug, + regionalPrefectureBorders, + riverPaths, + mainRivers, + tributaryRivers, + smallStreams, + } = terrain; + + const features = generateMapFeatures(seed, terrain); + const { + 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, + } = features; + + const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({ + seed, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, + settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks, + }); + + return finishMapOutput({ + seed, options, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2, + elevation, moisture, slope, sea, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, basinField, coastalLowland, flowAccum, erosionField, depositionField, + villages, ports, crossings, passes, markets, castles, castleTowns, premodernRoads, minorRoads, modernCities, populationDensity, + railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways, + interchanges, logisticsParks, satelliteCities, newTowns, landuse, adminCentersRaw, adminId, adminBorders, adminDebug, + riverPaths, mainRivers, tributaryRivers, smallStreams, externalGateways, prefectureMask, prefectureBorder, prefectureRegionId, regionalPrefectureBorders, + regionalDebug, + }); +} diff --git a/mapTerrain.js b/mapTerrain.js new file mode 100644 index 0000000..a30164b --- /dev/null +++ b/mapTerrain.js @@ -0,0 +1,900 @@ +import { INF, MAP_H, MAP_W, SIZE, clamp, createMapFields, fbm, hash2, indexOf, inside, lerp, pickEntities, rand, smoothstep, valueNoise } from "./mapUtils.js"; +import { + aStar, + extractMaskBorder, + extractRegionBorderSegments, + generateRegionalPrefectures, + makePrefectureMask, + neighbors8, +} from "./mapGeneratorHelpers.js"; + +export function generateTerrainAndRivers(seed) { + let prefectureMask; + let prefectureBorder; + + const { + elevation, + moisture, + slope, + sea, + river, + floodplain, + plain, + agriculture, + ridgeField, + valleyField, + basinField, + coastalLowland, + flowAccum, + erosionField, + depositionField, + flowTo, + portSuitability, + crossingSuitability, + passSuitability, + } = createMapFields(); + + const coastAngle = rand(seed, 11) * Math.PI * 2; + const coastX = Math.cos(coastAngle); + const coastY = Math.sin(coastAngle); + const coastThreshold = 0.22 + rand(seed, 12) * 0.22; + const coastStrength = 0.15 + rand(seed, 13) * 0.23; + + const seaLevel = 0.285; + + const mountainBlobs = Array.from({ length: 2 + Math.floor(rand(seed, 98) * 3) }, (_, i) => ({ + x: rand(seed, 100 + i) * MAP_W, + y: rand(seed, 200 + i) * MAP_H, + r: 10 + rand(seed, 300 + i) * 24, + h: 0.08 + rand(seed, 400 + i) * 0.16, + })); + + const ridgeBands = Array.from({ length: 5 + Math.floor(rand(seed, 97) * 4) }, (_, i) => ({ + x: rand(seed, 1500 + i) * MAP_W, + y: rand(seed, 1600 + i) * MAP_H, + angle: rand(seed, 1700 + i) * Math.PI * 2, + width: 3 + rand(seed, 1800 + i) * 7, + length: 42 + rand(seed, 1900 + i) * 92, + h: 0.11 + rand(seed, 2000 + i) * 0.22, + })); + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const nx = x / (MAP_W - 1) - 0.5; + const ny = y / (MAP_H - 1) - 0.5; + const i = indexOf(x, y); + + const warpX = (fbm(x * 0.62 + 180, y * 0.62 - 90, seed + 3101) - 0.5) * 13; + const warpY = (fbm(x * 0.62 - 70, y * 0.62 + 210, seed + 3201) - 0.5) * 13; + const wx = x + warpX; + const wy = y + warpY; + + let mountains = 0; + for (const blob of mountainBlobs) { + const d = Math.hypot(wx - blob.x, wy - blob.y) / blob.r; + mountains += Math.exp(-d * d * 2.35) * blob.h; + } + + let ridges = 0; + for (const ridge of ridgeBands) { + const dx = wx - ridge.x; + const dy = wy - ridge.y; + const along = dx * Math.cos(ridge.angle) + dy * Math.sin(ridge.angle); + const perp = -dx * Math.sin(ridge.angle) + dy * Math.cos(ridge.angle); + const lengthFade = smoothstep(1 - Math.abs(along) / ridge.length); + const serration = 0.72 + valueNoise(wx + along * 0.15, wy + perp * 0.15, seed + 2220, 8) * 0.56; + ridges += Math.exp(-(perp * perp) / (ridge.width * ridge.width)) * lengthFade * ridge.h * serration; + } + + const directionalCoast = nx * coastX + ny * coastY; + const coastWave = (fbm(wx * 0.72, wy * 0.72, seed + 2222) - 0.5) * 0.12 + (valueNoise(wx, wy, seed + 2233, 18) - 0.5) * 0.08; + const coastLower = smoothstep((directionalCoast + coastWave - coastThreshold) / 0.26); + // Four terrain-noise bands from continental structure to fine surface roughness. + const terrainLarge = fbm(wx * 0.36 + 40, wy * 0.36 - 60, seed + 710); + const terrainRegional = fbm(wx * 0.95 + 80, wy * 0.95 - 20, seed + 777); + const terrainLocal = fbm(wx * 2.05 + 17, wy * 2.05 - 31, seed + 1777); + const terrainFine = valueNoise(wx * 2.9 + 11, wy * 2.9 - 19, seed + 2444, 4.5); + const fineDissection = Math.abs(terrainLocal - 0.5) * 0.08 + Math.abs(terrainFine - 0.5) * 0.035; + const basin = 0.1 * Math.sin((nx * 3.1 + ny * 1.7 + rand(seed, 15)) * Math.PI) - 0.045 * Math.cos((nx * 5.2 - ny * 3.6 + rand(seed, 16)) * Math.PI); + const rawElevation = + 0.30 * terrainLarge + + 0.235 * terrainRegional + + 0.105 * terrainLocal + + 0.055 * terrainFine + + mountains * 0.54 + + ridges * 1.22 + + basin + + fineDissection - + coastLower * (coastStrength + 0.19) + + 0.055; + + elevation[i] = clamp(0.5 + (rawElevation - 0.5) * 1.26); + ridgeField[i] = clamp(ridges * 4.8 + Math.max(0, mountains - 0.10) * 0.95 + fineDissection * 2.0); + basinField[i] = clamp(Math.max(0, -basin) * 3.0 + (1 - coastLower) * Math.max(0, 0.42 - elevation[i]) * 0.7); + moisture[i] = clamp(0.44 * fbm(wx + 400, wy - 200, seed + 333) + 0.18 * valueNoise(wx, wy, seed + 343, 11) + 0.22 * (1 - Math.abs(ny * 1.7)) + 0.28 * coastLower - Math.max(0, elevation[i] - 0.62) * 0.22); + } + } + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + const nx = x / (MAP_W - 1) - 0.5; + const ny = y / (MAP_H - 1) - 0.5; + const directionalCoast = nx * coastX + ny * coastY; + const coastNoise = (fbm(x * 0.95, y * 0.95, seed + 2222) - 0.5) * 0.14 + (valueNoise(x, y, seed + 2233, 13) - 0.5) * 0.08; + const oceanSide = directionalCoast + coastNoise > coastThreshold + 0.055; + if (elevation[i] < seaLevel || oceanSide) sea[i] = 1; + if (sea[i]) elevation[i] = Math.min(elevation[i], seaLevel - 0.018 + hash2(x, y, seed + 2311) * 0.012); + } + } + + // Align coastal elevation with the sea mask. This prevents artificial one-cell cliffs + // when the directional coastline cuts through a high terrain cell. + 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 nearestSea = INF; + for (let dy = -7; dy <= 7; dy++) { + for (let dx = -7; dx <= 7; dx++) { + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny) || !sea[indexOf(nx, ny)]) continue; + nearestSea = Math.min(nearestSea, Math.hypot(dx, dy)); + } + } + if (nearestSea <= 7) { + const coastalCap = seaLevel + 0.018 + nearestSea * 0.028 + Math.max(0, fbm(x * 1.4, y * 1.4, seed + 2350) - 0.5) * 0.022; + elevation[i] = Math.min(elevation[i], coastalCap); + coastalLowland[i] = clamp(1 - nearestSea / 7); + } + } + } + + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]; + const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]; + slope[indexOf(x, y)] = clamp(Math.sqrt(gx * gx + gy * gy) * 10.5); + } + } + + const landOrder = []; + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + let low = i; + let best = elevation[i] + 0.012 * hash2(x, y, seed + 2468); + let localMean = 0; + let localMax = elevation[i]; + let localMin = elevation[i]; + let nCount = 0; + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + const ev = elevation[ni]; + localMean += ev; + localMax = Math.max(localMax, ev); + localMin = Math.min(localMin, ev); + nCount++; + const directed = ev + 0.008 * hash2(nx, ny, seed + 2469); + if (directed < best || sea[ni]) { + best = directed; + low = ni; + } + } + if (low !== i) flowTo[i] = low; + localMean /= Math.max(1, nCount); + const hollow = Math.max(0, localMean - elevation[i]); + const relief = localMax - localMin; + valleyField[i] = clamp(hollow * 8.4 + Math.max(0, 0.42 - elevation[i]) * 0.32 + moisture[i] * 0.08 - ridgeField[i] * 0.18); + basinField[i] = clamp(basinField[i] + hollow * 2.4 + (relief < 0.055 && elevation[i] < 0.55 ? 0.18 : 0)); + flowAccum[i] = 0.7 + moisture[i] * 0.7 + valleyField[i] * 0.55; + landOrder.push(i); + } + } + landOrder.sort((a, b) => elevation[b] - elevation[a]); + for (const i of landOrder) { + const to = flowTo[i]; + if (to >= 0 && to !== i) flowAccum[to] += flowAccum[i] * 0.82; + } + let maxFlowAccum = 0; + for (let i = 0; i < SIZE; i++) if (!sea[i]) maxFlowAccum = Math.max(maxFlowAccum, flowAccum[i]); + if (maxFlowAccum > 0) { + for (let i = 0; i < SIZE; i++) flowAccum[i] = clamp(flowAccum[i] / maxFlowAccum); + } + for (let i = 0; i < SIZE; i++) { + if (!sea[i]) valleyField[i] = clamp(valleyField[i] * 0.68 + Math.pow(flowAccum[i], 0.55) * 0.48); + } + + // First-order fluvial shaping: cut valley floors on steep/high-flow cells and + // deposit gently in coastal lowlands and basin floors. This gives visible + // river valleys without destroying the macro terrain structure. + const shapedElevation = new Float32Array(elevation); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const flow = Math.pow(flowAccum[i], 0.46); + const incisionNoise = 0.82 + hash2(x, y, seed + 8120) * 0.36; + const steepValley = clamp(flow * (0.058 + slope[i] * 0.21 + ridgeField[i] * 0.046) * incisionNoise); + const lateralCut = clamp(Math.pow(flowAccum[i], 0.66) * valleyField[i] * 0.078); + const lowSettling = clamp(flow * (coastalLowland[i] * 0.036 + basinField[i] * 0.020 + (elevation[i] < 0.40 ? 0.012 : 0)) * (1 - slope[i] * 0.82)); + erosionField[i] = steepValley + lateralCut; + depositionField[i] = lowSettling; + shapedElevation[i] = clamp(elevation[i] - steepValley - lateralCut + lowSettling * 0.72, seaLevel + 0.006, 1); + } + } + elevation.set(shapedElevation); + + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]; + const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]; + slope[i] = clamp(Math.sqrt(gx * gx + gy * gy) * 10.5); + valleyField[i] = clamp(valleyField[i] + erosionField[i] * 2.1 + depositionField[i] * 0.8 - ridgeField[i] * 0.06); + basinField[i] = clamp(basinField[i] + depositionField[i] * 1.6); + } + } + + const sourceCandidates = []; + 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 score = elevation[i] * 0.38 + moisture[i] * 0.24 + ridgeField[i] * 0.08 + flowAccum[i] * 0.56 + valleyField[i] * 0.28 + hash2(x, y, seed + 9000) * 0.06; + if (elevation[i] > 0.40 && elevation[i] < 0.82 && moisture[i] > 0.28 && flowAccum[i] > 0.020 && ridgeField[i] < 0.88) sourceCandidates.push({ x, y, score }); + } + } + + const sources = pickEntities(sourceCandidates, { + max: 20 + Math.floor(rand(seed, 910) * 28), + minDistance: 8, + threshold: 0.53 + rand(seed, 911) * 0.11, + seed, + }); + + function nearestWaterGoal(from) { + let bestSea = null; + let bestScore = INF; + 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 d = Math.hypot(x - from.x, y - from.y); + const score = d - coastalLowland[indexOf(Math.max(0, Math.min(MAP_W - 1, from.x)), Math.max(0, Math.min(MAP_H - 1, from.y)))] * 2; + if (score < bestScore) { + bestScore = score; + bestSea = { x, y }; + } + } + } + return bestSea; + } + + function riverRouteCost(x, y, cx, cy) { + const i = indexOf(x, y); + const ci = indexOf(cx, cy); + if (sea[i]) return 0.18; + const uphill = Math.max(0, elevation[i] - elevation[ci]); + const downhill = Math.max(0, elevation[ci] - elevation[i]); + if (!sea[i] && uphill > 0.035 && flowAccum[i] < flowAccum[ci] + 0.015) return INF; + return Math.max( + 0.18, + 1 + + uphill * 86 + + slope[i] * 0.38 + + elevation[i] * 0.42 - + downhill * 2.1 - + valleyField[i] * 0.92 - + flowAccum[i] * 0.72 - + moisture[i] * 0.18 - + coastalLowland[i] * 0.22 + ); + } + + function forceRiverToWater(path) { + if (!path.length) return path; + const [ex, ey] = path[path.length - 1]; + if (sea[indexOf(ex, ey)]) return path; + const goal = nearestWaterGoal({ x: ex, y: ey }); + if (!goal) return path; + const startElevation = elevation[indexOf(ex, ey)]; + const tail = aStar({ x: ex, y: ey }, goal, (x, y, cx, cy) => { + const i = indexOf(x, y); + const ci = indexOf(cx, cy); + if (!sea[i] && elevation[i] > Math.max(startElevation + 0.045, elevation[ci] + 0.030)) return INF; + return riverRouteCost(x, y, cx, cy); + }); + if (tail.length <= 2) return path; + return path.concat(tail.slice(1)); + } + + function confluenceAnglePenalty(nx, ny, dx, dy, lengthSoFar) { + if (lengthSoFar < 7 || river[indexOf(nx, ny)] < 0.24) return 0; + let best = 0.16; + const inLen = Math.hypot(dx, dy) || 1; + for (const [rx, ry] of neighbors8(nx, ny)) { + if (river[indexOf(rx, ry)] < 0.22) continue; + const rdx = rx - nx; + const rdy = ry - ny; + const cos = clamp((dx * rdx + dy * rdy) / Math.max(0.001, inLen * Math.hypot(rdx, rdy)), -1, 1); + const angle = Math.acos(cos); + const shallow = angle < 0.45 ? 0.28 : 0; + best = Math.min(best, Math.abs(angle - Math.PI * 0.62) * 0.045 + shallow); + } + return best; + } + + function traceRiverPath(startX, startY, bonusSeed = 0) { + let x = startX; + let y = startY; + let lastDx = 0; + let lastDy = 0; + const path = []; + const seen = new Set(); + let accum = 0; + + for (let step = 0; step < 600; step++) { + const i = indexOf(x, y); + if (seen.has(i)) break; + seen.add(i); + path.push([x, y]); + river[i] += 0.44 + path.length / 160 + flowAccum[i] * 0.55; + accum += river[i] + flowAccum[i]; + if (sea[i]) break; + + let best = null; + let bestValue = INF; + const currentElevation = elevation[i]; + const preferred = flowTo[i]; + + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + const dx = nx - x; + const dy = ny - y; + const drop = currentElevation - elevation[ni]; + const uphill = Math.max(0, -drop); + if (!sea[ni] && uphill > 0.032 && flowAccum[ni] < flowAccum[i] + 0.018) continue; + let surrounding = 0; + let surroundingCount = 0; + for (const [vx, vy] of neighbors8(nx, ny)) { + surrounding += elevation[indexOf(vx, vy)]; + surroundingCount++; + } + const valley = Math.max(0, surrounding / Math.max(1, surroundingCount) - elevation[ni]); + const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0; + const straightPenalty = Math.max(0, sameDirection) * 0.075; + const turnPenalty = sameDirection < -0.35 ? 0.24 : 0; + const sideSwing = Math.abs(dx * lastDy - dy * lastDx); + const meanderPhase = Math.sin((path.length + bonusSeed * 0.013) * 0.73) * 0.5 + 0.5; + const meander = sideSwing * (0.032 + meanderPhase * 0.026); + const flowBonus = ni === preferred ? 0.62 : 0; + const junctionPenalty = confluenceAnglePenalty(nx, ny, dx, dy, path.length); + const noise = (hash2(nx, ny, seed + bonusSeed + step * 11) - 0.5) * 0.04; + const value = + elevation[ni] * 1.45 + + uphill * 88 - + Math.max(0, drop) * 2.05 - + valley * 1.05 - + valleyField[ni] * 1.72 - + flowAccum[ni] * 0.94 - + moisture[ni] * 0.14 - + coastalLowland[ni] * 0.28 - + (river[ni] > 0 ? 0.22 : 0) - + flowBonus + + slope[ni] * 0.04 + + straightPenalty + + turnPenalty + + junctionPenalty * 1.35 - + meander + + noise - + (sea[ni] ? 0.6 : 0); + + if (value < bestValue) { + bestValue = value; + best = [nx, ny, dx, dy]; + } + } + if (!best) break; + x = best[0]; + y = best[1]; + lastDx = best[2]; + lastDy = best[3]; + } + + const forced = forceRiverToWater(path); + if (forced.length > path.length) { + for (const [rx, ry] of forced.slice(path.length)) { + const ri = indexOf(rx, ry); + river[ri] += 0.32 + flowAccum[ri] * 0.4; + accum += river[ri] + flowAccum[ri]; + } + } + return { path: forced, accum }; + } + + function traceSmallStreamPath(startX, startY, bonusSeed = 0) { + let x = startX; + let y = startY; + let lastDx = 0; + let lastDy = 0; + const path = []; + const seen = new Set(); + for (let step = 0; step < 160; step++) { + const i = indexOf(x, y); + if (seen.has(i)) break; + seen.add(i); + path.push([x, y]); + river[i] += 0.12 + flowAccum[i] * 0.18; + if ((river[i] > 0.48 && path.length > 5) || sea[i]) break; + let best = null; + let bestValue = INF; + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + const dx = nx - x; + const dy = ny - y; + const drop = elevation[i] - elevation[ni]; + const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0; + const value = elevation[ni] * 1.2 + Math.max(0, -drop) * 26 - Math.max(0, drop) * 1.4 - valleyField[ni] * 1.15 - flowAccum[ni] * 0.55 - moisture[ni] * 0.12 + Math.max(0, sameDirection) * 0.04 - Math.abs(dx * lastDy - dy * lastDx) * 0.018 + (hash2(nx, ny, seed + bonusSeed + step * 13) - 0.5) * 0.05; + if (value < bestValue) { bestValue = value; best = [nx, ny, dx, dy]; } + } + if (!best) break; + x = best[0]; + y = best[1]; + lastDx = best[2]; + lastDy = best[3]; + } + return path; + } + + const riverPaths = []; + const riverScores = []; + for (const source of sources) { + const { path, accum } = traceRiverPath(source.x, source.y, 0); + if (path.length > 6) { + riverPaths.push(path); + riverScores.push(path.length + accum * 0.18); + } + } + + const preliminaryMainRiverCells = new Set(riverPaths.slice().sort((a, b) => b.length - a.length).slice(0, 5).flatMap((path) => path.map(([x, y]) => `${x},${y}`))); + const tributarySources = pickEntities(sourceCandidates + .filter((p) => !preliminaryMainRiverCells.has(`${p.x},${p.y}`)) + .map((p) => ({ ...p, score: p.score + flowAccum[indexOf(p.x, p.y)] * 0.75 + valleyField[indexOf(p.x, p.y)] * 0.24 })), { + max: 14 + Math.floor(rand(seed, 915) * 20), + minDistance: 6, + threshold: 0.45, + seed: seed + 916, + jitter: 0.02, + }); + for (const source of tributarySources) { + const { path, accum } = traceRiverPath(source.x, source.y, 4000 + source.x * 7 + source.y * 11); + if (path.length > 8) { + riverPaths.push(path); + riverScores.push(path.length * 0.7 + accum * 0.12); + } + } + + const streamPaths = []; + const streamSources = pickEntities(sourceCandidates + .map((p) => ({ ...p, score: valleyField[indexOf(p.x, p.y)] * 0.46 + flowAccum[indexOf(p.x, p.y)] * 0.36 + moisture[indexOf(p.x, p.y)] * 0.18 + hash2(p.x, p.y, seed + 918) * 0.05 })) + .filter((p) => p.score > 0.18), { + max: 22 + Math.floor(rand(seed, 919) * 20), + minDistance: 4, + threshold: 0.18, + seed: seed + 919, + jitter: 0.015, + }); + for (const source of streamSources) { + const path = traceSmallStreamPath(source.x, source.y, 7000 + source.x * 5 + source.y * 17); + if (path.length > 4) streamPaths.push(path); + } + + if (riverPaths.length === 0 && sourceCandidates.length > 0) { + const fallback = sourceCandidates.slice().sort((a, b) => b.score - a.score)[0]; + let bestSea = null; + let bestSeaDist = INF; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + if (!sea[indexOf(x, y)]) continue; + const d = Math.hypot(x - fallback.x, y - fallback.y); + if (d < bestSeaDist) { + bestSeaDist = d; + bestSea = { x, y }; + } + } + } + if (bestSea) { + const fallbackPath = aStar(fallback, bestSea, (x, y, cx, cy) => { + const i = indexOf(x, y); + const ci = indexOf(cx, cy); + if (sea[i]) return 0.25; + const uphill = Math.max(0, elevation[i] - elevation[ci]) * 24; + const downhill = Math.max(0, elevation[ci] - elevation[i]) * 1.8; + return Math.max(0.24, 1 + uphill + slope[i] * 0.7 + elevation[i] * 0.8 - downhill - Math.min(0.55, river[i] * 0.1)); + }); + if (fallbackPath.length > 6) { + let accum = 0; + for (const [x, y] of fallbackPath) { + const i = indexOf(x, y); + river[i] += 0.42; + accum += river[i]; + } + riverPaths.push(fallbackPath); + riverScores.push(fallbackPath.length + accum * 0.18); + } + } + } + + function sanitizeDownhillRiverPath(path, tolerance = 0.040) { + if (!path || path.length < 2) return path || []; + const out = [path[0]]; + for (let k = 1; k < path.length; k++) { + const [px, py] = out[out.length - 1]; + const [x, y] = path[k]; + const pi = indexOf(px, py); + const i = indexOf(x, y); + if (!sea[i] && elevation[i] > elevation[pi] + tolerance) break; + out.push(path[k]); + if (sea[i]) break; + } + return out.length >= 2 ? out : []; + } + function trimMountainHeadwaters(path) { + if (!path || path.length < 4) return path || []; + let start = 0; + while (start < path.length - 3) { + const [x, y] = path[start]; + const i = indexOf(x, y); + if (sea[i]) break; + if (elevation[i] <= 0.72 && (valleyField[i] >= 0.18 || flowAccum[i] >= 0.05)) break; + start++; + } + return path.slice(start); + } + for (let r = 0; r < riverPaths.length; r++) riverPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.032); + for (let r = riverPaths.length - 1; r >= 0; r--) if (riverPaths[r].length < 2) riverPaths.splice(r, 1); + for (let r = 0; r < streamPaths.length; r++) streamPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(streamPaths[r]), 0.026); + for (let r = streamPaths.length - 1; r >= 0; r--) if (streamPaths[r].length < 2) streamPaths.splice(r, 1); + river.fill(0); + for (const path of riverPaths) { + for (let k = 0; k < path.length; k++) { + const [x, y] = path[k]; + const i = indexOf(x, y); + river[i] += 0.42 + k / 170 + flowAccum[i] * 0.55; + } + } + for (const path of streamPaths) { + for (let k = 0; k < path.length; k++) { + const [x, y] = path[k]; + const i = indexOf(x, y); + river[i] += 0.11 + flowAccum[i] * 0.18; + } + } + + const expandedRiver = new Float32Array(river); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (river[i] <= 0) continue; + for (const [nx, ny] of neighbors8(x, y)) { + expandedRiver[indexOf(nx, ny)] = Math.max(expandedRiver[indexOf(nx, ny)], river[i] * 0.35); + } + } + } + river.set(expandedRiver); + + // Second fluvial pass uses the actual traced river network. Main channels cut + // visible V-shaped valleys; lower reaches accumulate alluvial deposits. + const fluvialElevation = new Float32Array(elevation); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i] || river[i] <= 0.02) continue; + const r = clamp(river[i] / 3.4); + const channelCut = clamp(Math.pow(r, 0.55) * (0.060 + slope[i] * 0.145 + ridgeField[i] * 0.038)); + const valleyWiden = clamp(Math.pow(r, 0.72) * (0.020 + Math.max(0, elevation[i] - seaLevel) * 0.058 + valleyField[i] * 0.040)); + const alluvium = clamp(Math.pow(r, 0.72) * (coastalLowland[i] * 0.030 + basinField[i] * 0.020 + (slope[i] < 0.10 ? 0.010 : 0))); + erosionField[i] = clamp(erosionField[i] + channelCut + valleyWiden); + depositionField[i] = clamp(depositionField[i] + alluvium); + fluvialElevation[i] = clamp(elevation[i] - channelCut - valleyWiden + alluvium, seaLevel + 0.005, 1); + valleyField[i] = clamp(valleyField[i] + r * 0.62 + channelCut * 6.4); + basinField[i] = clamp(basinField[i] + alluvium * 3.2); + } + } + // Lateral valley carving around the traced river network deepens valleys and + // makes ridge/valley contrast legible at the map scale. + for (const path of riverPaths) { + for (const [rx, ry] of path) { + const ri = indexOf(rx, ry); + const r = clamp(river[ri] / 3.0); + const radius = r > 0.48 ? 2 : 1; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const nx = rx + dx; + const ny = ry + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (sea[ni]) continue; + const d = Math.hypot(dx, dy); + if (d > radius || d === 0) continue; + const weight = (radius + 0.35 - d) / (radius + 0.35); + const carve = Math.max(0, weight) * (0.008 + r * 0.026) * Math.max(0.45, slope[ni] + 0.22); + fluvialElevation[ni] = clamp(fluvialElevation[ni] - carve, seaLevel + 0.005, 1); + erosionField[ni] = clamp(erosionField[ni] + carve * 3.0); + valleyField[ni] = clamp(valleyField[ni] + carve * 12.0); + } + } + } + } + + // Restore rugged summit relief after strong river incision. This prevents highlands + // from becoming unnaturally flat or visually concave while keeping valleys cut. + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const high = clamp((fluvialElevation[i] - 0.62) / 0.26); + const summit = high * clamp(ridgeField[i] * 1.4 - flowAccum[i] * 0.8); + const rugged = (valueNoise(x * 2.1 + 19, y * 2.1 - 23, seed + 9661, 3.2) - 0.5) * 0.035; + const uplift = summit * (0.018 + Math.max(0, rugged)); + if (uplift > 0) { + fluvialElevation[i] = clamp(fluvialElevation[i] + uplift, seaLevel + 0.005, 1); + erosionField[i] = Math.max(0, erosionField[i] - uplift * 0.6); + } + } + } + + elevation.set(fluvialElevation); + + // Broad alluvial/coastal/basin plains. The plain score alone is not enough; + // the elevation surface must also be locally calm, otherwise every lowland + // still reads as rugged terrain. Smooth only low, wet depositional cells and + // leave ridges/headwaters untouched. + for (let pass = 0; pass < 4; pass++) { + const nextElevation = new Float32Array(elevation); + 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 lowland = clamp( + coastalLowland[i] * 0.72 + + basinField[i] * 0.54 + + valleyField[i] * 0.34 + + Math.pow(flowAccum[i], 0.58) * 0.24 - + ridgeField[i] * 0.62 - + Math.max(0, elevation[i] - 0.54) * 1.65 - + slope[i] * 0.74 + ); + if (lowland <= 0.12) continue; + let sum = 0; + let weight = 0; + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + const nx = x + dx; + const ny = y + dy; + const ni = indexOf(nx, ny); + if (sea[ni]) continue; + const d = Math.hypot(dx, dy); + if (d > 2.25) continue; + const compatible = clamp(1 - Math.abs(elevation[ni] - elevation[i]) / 0.11); + const w = compatible / (1 + d); + sum += elevation[ni] * w; + weight += w; + } + } + if (weight <= 0) continue; + const localMean = sum / weight; + const terrace = Math.round(localMean * 42) / 42; + const target = lerp(localMean, terrace, 0.28); + nextElevation[i] = clamp(lerp(elevation[i], target, lowland * 0.42), seaLevel + 0.006, 1); + if (lowland > 0.55) { + depositionField[i] = clamp(depositionField[i] + lowland * 0.018); + erosionField[i] = Math.max(0, erosionField[i] - lowland * 0.012); + } + } + } + elevation.set(nextElevation); + } + + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]; + const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]; + slope[i] = clamp(Math.sqrt(gx * gx + gy * gy) * 11.2); + } + } + + // Re-trim visible river paths after fluvial reshaping changes local elevation. + for (let r = 0; r < riverPaths.length; r++) riverPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.028); + for (let r = riverPaths.length - 1; r >= 0; r--) if (riverPaths[r].length < 2) riverPaths.splice(r, 1); + for (let r = 0; r < streamPaths.length; r++) streamPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(streamPaths[r]), 0.022); + for (let r = streamPaths.length - 1; r >= 0; r--) if (streamPaths[r].length < 2) streamPaths.splice(r, 1); + + const mainRivers = riverPaths + .map((p, i) => ({ path: p, score: riverScores[i] })) + .sort((a, b) => b.score - a.score) + .slice(0, Math.min(6, riverPaths.length)) + .map((x) => x.path); + + if (mainRivers.length === 0 && riverPaths.length > 0) mainRivers.push(riverPaths[0]); + if (mainRivers.length === 0) { + let start = null; + let startScore = -INF; + 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 score = elevation[i] * 0.55 + moisture[i] * 0.35 - slope[i] * 0.15; + if (score > startScore) { + startScore = score; + start = { x, y }; + } + } + } + if (start) { + let goal = null; + let goalDist = INF; + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + if (!sea[indexOf(x, y)]) continue; + const d = Math.hypot(x - start.x, y - start.y); + if (d < goalDist) { + goalDist = d; + goal = { x, y }; + } + } + } + if (goal) { + const fallbackPath = aStar(start, goal, (x, y, cx, cy) => { + const i = indexOf(x, y); + const ci = indexOf(cx, cy); + if (sea[i]) return 0.2; + const uphillBias = Math.max(0, elevation[i] - elevation[ci]) * 22; + const downhillBias = Math.max(0, elevation[ci] - elevation[i]) * 1.7; + return Math.max(0.25, 1 + uphillBias + slope[i] * 0.65 + elevation[i] * 0.8 - downhillBias); + }); + if (fallbackPath.length > 4) { + riverPaths.push(fallbackPath); + mainRivers.push(fallbackPath); + for (const [x, y] of fallbackPath) river[indexOf(x, y)] += 0.4; + } + } + } + } + + const mainRiverCells = new Set(mainRivers.flatMap((path) => path.map(([x, y]) => `${x},${y}`))); + const tributaryRivers = riverPaths.filter((path) => path.some(([x, y]) => !mainRiverCells.has(`${x},${y}`)) && !mainRivers.includes(path)); + const smallStreams = streamPaths.filter((path) => path.length >= 5); + + prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river); + prefectureBorder = extractMaskBorder(prefectureMask, sea); + const regionalPrefectures = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask); + const prefectureRegionId = regionalPrefectures.regionId; + const regionalDebug = regionalPrefectures.debug; + const regionalPrefectureBorders = extractRegionBorderSegments(prefectureRegionId, sea); + + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const low = 1 - clamp((elevation[i] - 0.28) / 0.4); + const flat = 1 - slope[i]; + const valleyPlain = valleyField[i] * 0.44 + basinField[i] * 0.36 + coastalLowland[i] * 0.55; + plain[i] = clamp(low * 0.44 + flat * 0.58 + valleyPlain - ridgeField[i] * 0.28 - (elevation[i] > 0.62 ? 0.48 : 0)); + + let nearRiver = 0; + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + nearRiver = Math.max(nearRiver, river[indexOf(nx, ny)] / (1 + Math.hypot(dx, dy))); + } + } + + const fan = clamp(valleyField[i] * (1 - coastalLowland[i]) * (elevation[i] > 0.34 && elevation[i] < 0.58 ? 0.9 : 0.35) * (1 - slope[i] * 0.55)); + floodplain[i] = clamp(nearRiver * plain[i] * 0.92 + coastalLowland[i] * nearRiver * 0.22); + agriculture[i] = clamp(plain[i] * 0.58 + fan * 0.26 + basinField[i] * 0.2 + moisture[i] * 0.14 + clamp(nearRiver) * 0.32 - slope[i] * 0.34 - ridgeField[i] * 0.18 - floodplain[i] * 0.06); + } + } + + 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 seaNear = 0; + let riverNear = 0; + let sheltered = 0; + + for (let dy = -5; dy <= 5; dy++) { + for (let dx = -5; dx <= 5; dx++) { + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + const d = Math.hypot(dx, dy); + if (sea[indexOf(nx, ny)]) seaNear += 1 / (1 + d); + riverNear = Math.max(riverNear, river[indexOf(nx, ny)] / (1 + d)); + } + } + + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + const nx = x + dx; + const ny = y + dy; + if (inside(nx, ny) && !sea[indexOf(nx, ny)]) sheltered += 1; + } + } + + const isDelta = riverNear > 0.22 && coastalLowland[i] > 0.18; + const bayShelter = sheltered * 0.012 + seaNear * 0.055 + coastalLowland[i] * 0.16; + portSuitability[i] = clamp(bayShelter + riverNear * 0.24 + (isDelta ? 0.22 : 0) + plain[i] * 0.08 - slope[i] * 0.48 - ridgeField[i] * 0.16); + } + } + + 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 r = river[i]; + if (r < 0.2 || r > 1.85) continue; + let bankPlain = 0; + for (const [nx, ny] of neighbors8(x, y)) bankPlain += plain[indexOf(nx, ny)]; + crossingSuitability[i] = clamp(r * 0.34 + (bankPlain / 8) * 0.54 + valleyField[i] * 0.18 - slope[i] * 0.55 - floodplain[i] * 0.06); + } + } + + 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 e = elevation[i]; + if (e < 0.43 || e > 0.82) continue; + const ewHigh = (elevation[indexOf(x - 3, y)] + elevation[indexOf(x + 3, y)]) / 2; + const nsHigh = (elevation[indexOf(x, y - 3)] + elevation[indexOf(x, y + 3)]) / 2; + const diagLow = Math.min( + elevation[indexOf(x - 3, y - 3)], + elevation[indexOf(x + 3, y + 3)], + elevation[indexOf(x - 3, y + 3)], + elevation[indexOf(x + 3, y - 3)] + ); + passSuitability[i] = clamp((Math.max(ewHigh, nsHigh) - e) * 2.2 + (e - diagLow) * 0.55 + valleyField[i] * 0.28 - ridgeField[i] * 0.18 - slope[i] * 0.2); + } + } + + + return { + elevation, + moisture, + slope, + sea, + river, + floodplain, + plain, + agriculture, + ridgeField, + valleyField, + basinField, + coastalLowland, + flowAccum, + erosionField, + depositionField, + portSuitability, + crossingSuitability, + passSuitability, + prefectureMask, + prefectureBorder, + prefectureRegionId, + regionalDebug, + regionalPrefectureBorders, + riverPaths, + mainRivers, + tributaryRivers, + smallStreams, + }; +} diff --git a/names.js b/names.js index 4585f85..ef47f7b 100644 --- a/names.js +++ b/names.js @@ -2,26 +2,28 @@ import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js"; export const NAME_KANJI_POOLS = { modifiers: [ - "大", "小", "上", "下", "中", + "大", "小", "上", "下", "中", "奥", "東", "西", "南", "北", "新", "古", "本", "元", "高", "長", "広", "深", "浅", "白", "黒", "青", "赤", "奥", "前", "後", "内", "外", - "早", "早", "真", "丸", "平" + "早", "安", "真", "丸", "平", + "美", + "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万", ], inlandTerrain: [ "山", "谷", "沢", "原", "野", "森", "林", "岡", "丘", "坂", "峰", "峠", "嶺", "尾", "平", - "窪", "久", "洞", "迫", "台", - "塚", "牧", "畑", "田", "森", - "麓", "郷", "里" + "窪", "久", "洞", "迫", "久保", "玖保", "漥", "佐古", "作古", "峪", + "塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", + "聡", "郷", "里" ], waterTerrain: [ - "川", "河", "江", "瀬", "淵", + "川", "河", "江", "瀬", "淵", "渕", "池", "沼", "泉", "井", "湖", "滝", "渓", "沢", "谷", "津", "水", "清", "渡", "橋", "堀", @@ -29,11 +31,11 @@ export const NAME_KANJI_POOLS = { ], coastalTerrain: [ - "浜", "浦", "津", "崎", "岬", - "島", "磯", "潟", "湊", "港", - "海", "洲", "瀬", "砂", "潮", + "浜", "浦", "津", "崎", + "島", "磯", "潟", "湊", "津", + "州", "洲", "瀬", "砂", "潮", "泊", "江", "浦", "灘", "入", - "湾", "戸", "門" + "戸", "門" ], plants: [ @@ -53,7 +55,7 @@ export const NAME_KANJI_POOLS = { "辺", "里", "郷", "村", "町", "宿", "庄", "台", "坂", "橋", "本", "内", "窪", "平", "塚", - "畑", "牧", "前", "後", "中" + "畑", "牧", "前", "見", "中" ], archaicPrefixes: [ @@ -64,7 +66,7 @@ export const NAME_KANJI_POOLS = { "甲", "信", "越", "備", "讃", "薩", "隠", "美", "三", "若", "遠", "近", "能", "加", "賀", - "越", "淡", "壱", "対" + "越", "淡", "壱", "阿" ], archaicSuffixes: [ @@ -75,7 +77,7 @@ export const NAME_KANJI_POOLS = { "伊", "前", "中", "後", "波", "勢", "渡", "城", "紫", "野", "津", "島", "海", "登", "賀", - "良", "美", "智", "智", "代" + "良", "美", "智", "茂", "代" ], settlementWords: [ diff --git a/renderer.js b/renderer.js index ad253bc..78d2844 100644 --- a/renderer.js +++ b/renderer.js @@ -118,6 +118,19 @@ function discreteColor(map, x, y, mode) { ]; const a = map.adminId[i]; color = a >= 0 ? palette[a % palette.length] : [220, 225, 220]; + } else if (mode === "admin-debug" || mode === "borders-debug") { + const barrier = clamp( + map.ridgeField[i] * 0.88 + + Math.max(0, map.river[i] - 0.28) * 1.25 + + Math.max(0, map.flowAccum[i] - 0.36) * 0.72 + + map.slope[i] * 0.48 + + Math.max(0, map.elevation[i] - 0.54) * 0.34 + ); + color = [ + Math.round(238 - barrier * 28), + Math.round(242 - barrier * 88), + Math.round(226 + barrier * 20), + ]; } else { color = terrainColorContinuous(map, x, y, "terrain"); } @@ -465,7 +478,8 @@ export function drawMap(canvas, map, options) { for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.2); drawHarborWorks(ctx, map); - if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, mode === "all" ? "rgba(95,95,95,0.18)" : "rgba(95,95,95,0.30)", 1.0, false, mode === "all"); + const debugBorders = mode === "admin-debug" || mode === "borders-debug"; + if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, debugBorders ? "rgba(40,40,40,0.82)" : mode === "all" ? "rgba(95,95,95,0.18)" : "rgba(95,95,95,0.30)", debugBorders ? 1.8 : 1.0, false, mode === "all"); drawSegments(ctx, map.prefectureBorder, "rgba(30,30,30,0.82)", 2.4, false, true); drawSegments(ctx, map.prefectureBorder, "rgba(255,255,255,0.74)", 1.05, false, true); @@ -474,9 +488,9 @@ export function drawMap(canvas, map, options) { const showHistory = ["history", "all", "terrain", "suitability"].includes(mode); const showModern = ["modern", "all", "development", "landuse"].includes(mode); const showRoads = ["roads", "all", "development", "landuse"].includes(mode); - const showAdmin = ["admin", "all"].includes(mode); + const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode); - if (showAdmin) drawSegments(ctx, map.adminBorders, mode === "all" ? "rgba(120,120,120,0.28)" : "rgba(120,120,120,0.65)", mode === "all" ? 0.9 : 1.3); + if (showAdmin) drawSegments(ctx, map.adminBorders, debugBorders ? "rgba(20,90,180,0.90)" : mode === "all" ? "rgba(120,120,120,0.28)" : "rgba(120,120,120,0.65)", debugBorders ? 1.5 : mode === "all" ? 0.9 : 1.3); if (showHistory) { for (const path of map.premodernRoads) drawPath(ctx, path, mode === "all" ? "rgba(150, 120, 90, 0.34)" : "rgba(150, 120, 90, 0.55)", mode === "all" ? 1.15 : 1.45, true); diff --git a/test.js b/test.js index 25a114d..62b6a94 100644 --- a/test.js +++ b/test.js @@ -7,6 +7,7 @@ import { NAME_PROBABILITIES, NAME_TEMPLATES, NAME_TEMPLATE_WEIGHTS, + generateEntityName, generateTemplateName, } from "./names.js"; @@ -168,6 +169,56 @@ function majorCityCoreIntegrity(map) { return checked ? sum / checked : 1; } +function satelliteMunicipalityMetrics(map) { + const areaById = new Map(); + for (let i = 0; i < map.adminId.length; i++) { + if (map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0) areaById.set(map.adminId[i], (areaById.get(map.adminId[i]) || 0) + 1); + } + const rows = (map.satelliteCities || []) + .filter((sat) => map.prefectureMask[indexOf(sat.x, sat.y)] && !map.sea[indexOf(sat.x, sat.y)]) + .map((sat) => { + const admin = map.adminId[indexOf(sat.x, sat.y)]; + return { sat, admin, area: areaById.get(admin) || 0 }; + }); + const independent = rows.filter((row) => row.sat.municipalityClass === "independentSatelliteMunicipality"); + const small = independent.filter((row) => row.area < 80); + const largeTooSmall = rows.filter((row) => (row.sat.population || 0) >= 60000 && row.sat.municipalityClass === "independentSatelliteMunicipality" && row.area < 120); + const average = independent.length ? independent.reduce((sum, row) => sum + row.area, 0) / independent.length : 0; + return { rows, independent, small, largeTooSmall, average }; +} + +function regionalComponentMetrics(map) { + const ids = new Set([...map.prefectureRegionId].filter((id, i) => id >= 0 && !map.sea[i])); + const seen = new Uint8Array(MAP_W * MAP_H); + let maxComponents = 0; + for (const id of ids) { + seen.fill(0); + let comps = 0; + for (let i = 0; i < map.prefectureRegionId.length; i++) { + if (seen[i] || map.sea[i] || map.prefectureRegionId[i] !== id) continue; + comps++; + const queue = [i]; + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + const x = cur % MAP_W; + const y = Math.floor(cur / MAP_W); + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue; + const ni = indexOf(nx, ny); + if (seen[ni] || map.sea[ni] || map.prefectureRegionId[ni] !== id) continue; + seen[ni] = 1; + queue.push(ni); + } + } + } + maxComponents = Math.max(maxComponents, comps); + } + return { regionCount: ids.size, maxComponents }; +} + try { const map = generateMap(12345); const other = generateMap(54321); @@ -282,6 +333,8 @@ try { : 1; const adminMetrics = adminBoundaryMetrics(map); const cityCoreIntegrity = majorCityCoreIntegrity(map); + const satelliteMetrics = satelliteMunicipalityMetrics(map); + const regionalMetrics = regionalComponentMetrics(map); assert(NAME_KANJI_POOLS && Array.isArray(NAME_KANJI_POOLS.modifiers), "NAME_KANJI_POOLS exists"); assert(NAME_TEMPLATES && NAME_TEMPLATES.modifierTerrain?.slots?.length === 2, "NAME_TEMPLATES exists"); @@ -290,7 +343,7 @@ try { const removedContextModule = "placeName" + "Context.js"; assert(!namesSource.includes(removedContextModule) && !mapGeneratorSource.includes(removedContextModule) && !testSource.includes(removedContextModule), "removed name-context import is absent"); assert(Object.keys(NAME_KANJI_POOLS).every((key) => Array.isArray(NAME_KANJI_POOLS[key])), "name category pools are centralized arrays"); - assert(Object.values(NAME_KANJI_POOLS).every((pool) => pool.length === 0), "default name category pools are empty"); + assert(Object.values(NAME_KANJI_POOLS).every((pool) => pool.every((part) => typeof part === "string" && !part.includes("\uFFFD"))), "configured name category pools contain valid strings"); assert(Object.keys(NAME_PARTS).length === 0, "legacy NAME_PARTS has no hidden candidates"); const removedContextSuffixConst = "CONTEXT" + "_SUFFIXES"; const removedContextSuffixKey = "context" + "Suffixes"; @@ -309,6 +362,12 @@ try { assert(map.settlementCluster.length === size, "settlement cluster field matches map size"); assert(Array.isArray(map.bridges) && Array.isArray(map.tunnels) && Array.isArray(map.harborWorks), "legacy bridge/tunnel arrays and harbor arrays exist"); assert(map.prefectureRegionId.length === size && Array.isArray(map.regionalPrefectureBorders), "neighbor prefecture regions exist"); + assert(map.regionalDebug && Number.isFinite(map.regionalDebug.regionalChangedAfterNaturalPartition), "regional changed-cell debug exists"); + assert(map.regionalDebug.regionalChangedAfterNaturalPartition > 0, "regional natural partition changes region cells"); + assert(map.regionalDebug.regionalBorderCountBefore > 0 && map.regionalDebug.regionalBorderCountAfter > 0, "regional border counts are tracked"); + assert(map.regionalDebug.regionalNaturalBarrierAverageAfter >= map.regionalDebug.regionalNaturalBarrierAverageBefore - 0.08, "regional border natural-barrier affinity does not degrade meaningfully"); + assert(map.regionalDebug.regionalVoronoiLikeRateAfter <= map.regionalDebug.regionalVoronoiLikeRateBefore + 0.22, "regional weak Voronoi-like border rate stays bounded"); + assert(regionalMetrics.regionCount >= 4 && regionalMetrics.maxComponents <= 5, "regional prefecture regions remain connected enough for display"); assert(Array.isArray(map.tributaryRivers) && Array.isArray(map.smallStreams), "river hierarchy arrays exist"); assert(Array.isArray(map.icAccessRoads), "IC access road array exists"); assert(Array.isArray(map.satelliteCities), "satelliteCities is an array"); @@ -360,6 +419,18 @@ try { assert(adminMetrics.maxComponents <= 4, "municipal topology repair prevents excessive disconnected fragments"); assert(adminMetrics.disconnectedMunicipalities <= Math.max(2, Math.ceil(adminMetrics.municipalityCount * 0.20)), "most municipalities remain connected after terrain snapping"); assert(adminMetrics.avgTarget > 0.18, "admin borders align with terrain target features often enough"); + assert(map.adminDebug && map.adminDebug.compartmentCount > 0, "natural compartment debug is available"); + assert(map.adminDebug.averageCompartmentArea > 0, "natural compartments have positive average area"); + assert(Number.isFinite(map.adminDebug.changedAfterLandscapePartition) && Number.isFinite(map.adminDebug.changedAfterSnap), "municipal changed-cell diagnostics exist"); + assert(map.adminDebug.changedAfterLandscapePartition > 0 || map.adminDebug.changedAfterSnap > 0, "municipal terrain partition or snap changes admin cells"); + assert(map.adminDebug.changedAfterFinalExclaveRemoval + map.adminDebug.changedAfterFinalMerge < Math.max(2800, (map.adminDebug.changedAfterLandscapePartition + map.adminDebug.changedAfterSnap + map.adminDebug.changedAfterUrbanLock) * 1.35), "final municipal repair does not erase most terrain and urban changes"); + assert(map.adminDebug.finalBorderNaturalBarrierAverage >= 0, "natural barrier score is tracked along final borders"); + assert(map.adminDebug.voronoiLikeRateAfter <= Math.max(0.72, map.adminDebug.voronoiLikeRateBefore + 0.20), "natural compartment pass does not increase weak bisectors excessively"); + assert(Number.isFinite(map.adminDebug.satelliteMunicipalitiesCreated) && Number.isFinite(map.adminDebug.averageSatelliteMunicipalityArea), "satellite municipality debug is available"); + assert(satelliteMetrics.independent.length < 3 || satelliteMetrics.small.length / satelliteMetrics.independent.length <= 0.35, "tiny independent satellite municipalities are not the dominant pattern"); + assert(satelliteMetrics.largeTooSmall.length === 0, "large independent satellites have meaningful municipal area"); + assert(satelliteMetrics.independent.length < 3 || satelliteMetrics.average >= 140, "average independent satellite municipality area is meaningful"); + assert(satelliteMetrics.rows.every((row) => row.area >= 80 || row.sat.municipalityClass === "smallTownAttachedToRuralMunicipality" || row.sat.municipalityClass === "suburbanDistrictMergedWithParent" || row.sat.municipalityClass === "newTownDistrict"), "tiny satellite areas are merged or explicitly classified as attached districts"); assert(adminMetrics.denseUrbanRate < 0.42, "admin borders avoid excessive dense urban crossings"); assert(adminMetrics.rightAngleRate < 0.46, "admin borders avoid excessive unsupported stair-step artifacts"); assert(adminMetrics.voronoiLikeRate < 0.58, "admin borders are not dominated by weak-terrain center bisectors"); @@ -388,15 +459,14 @@ try { assert(map.entitiesForNames.every((item) => String(item.name).length > 0), "empty generated names are prevented"); assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low"); assert(map.nameDebug && Array.isArray(map.nameDebug.emptyPools), "nameDebug reports empty pools"); - assert(map.nameDebug.emptyPools.length === Object.keys(NAME_KANJI_POOLS).length, "empty default pools are visible in nameDebug"); + assert(map.nameDebug.emptyPools.length === Object.values(NAME_KANJI_POOLS).filter((pool) => pool.length === 0).length, "nameDebug empty pools match configured pools"); assert(map.nameDebug.selectedTemplateCounts && typeof map.nameDebug.selectedTemplateCounts === "object", "nameDebug selectedTemplateCounts exists"); assert(map.nameDebug.selectedContextCounts && typeof map.nameDebug.selectedContextCounts === "object", "nameDebug selectedContextCounts exists"); assert( map.nameDebug.generatedNamesUsed + map.nameDebug.customNamesUsed + map.nameDebug.forcedNamesUsed + map.nameDebug.fallbackAttempts === namedEntityCount, "nameDebug accounting covers named entities" ); - assert(generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "empty pools do not use hidden fallback candidates"); - assert(activePoolChars.size === 0, "no active pool characters exist until configured"); + assert(activePoolChars.size > 0 || generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "template generation depends on active pools"); assert(villageClusterMean > 0.16, "villages prefer clustered valley, basin, coastal, and agricultural cells"); assert(saneEndpointRatio >= 0.76, "transport endpoints stay near meaningful generated nodes"); assert(Object.keys(CUSTOM_NAMES).length === 0 || NAME_PROBABILITIES.customName < 1, "CUSTOM_NAMES are probabilistic by default"); @@ -414,6 +484,15 @@ try { assert(JSON.stringify(againA.entitiesForNames.map((item) => [item.id, item.name])) === JSON.stringify(againB.entitiesForNames.map((item) => [item.id, item.name])), "generated names are deterministic for the same seed"); assert(JSON.stringify(againA.adminCenters.map((item) => [item.id, item.x, item.y, item.name])) === JSON.stringify(againB.adminCenters.map((item) => [item.id, item.x, item.y, item.name])), "municipal centers are deterministic for the same seed"); assert(JSON.stringify([...againA.adminId]) === JSON.stringify([...againB.adminId]), "municipal adminId snapping is deterministic for the same seed"); + assert(JSON.stringify([...againA.prefectureRegionId]) === JSON.stringify([...againB.prefectureRegionId]), "regional prefecture ids are deterministic for the same seed"); + assert(JSON.stringify(againA.adminDebug) === JSON.stringify(againB.adminDebug), "admin debug metrics are deterministic for the same seed"); + assert(JSON.stringify(againA.regionalDebug) === JSON.stringify(againB.regionalDebug), "regional debug metrics are deterministic for the same seed"); + + const blockedCapitalName = "\u52A0\u8302"; + const capitalNameMaps = [114514, 12345, 54321, 777, 999].map((seedValue) => generateMap(seedValue)); + const capitalNames = capitalNameMaps.map((seeded) => seeded.prefecturalCapital?.name).filter(Boolean); + assert(new Set(capitalNames).size > 1, "prefectural capital names vary across seeds"); + assert(capitalNames.some((name) => name !== blockedCapitalName), "prefectural capital is not always the repeated custom name"); CUSTOM_NAMES["city-0"] = "C1"; const customSameA = generateMap(321); @@ -427,12 +506,35 @@ try { assert(!FORCED_NAMES["city-0"] && customTargets.length > 0 && customHits < customTargets.length, "CUSTOM_NAMES do not force every seed"); delete CUSTOM_NAMES["city-0"]; + CUSTOM_NAMES["custom-probe"] = "C1"; + const directCustomNames = Array.from({ length: 40 }, (_, n) => generateEntityName(9000 + n, "custom-probe", { x: 10, y: 10, kind: "Probe" }, {}, new Set())); + const directCustomHits = directCustomNames.filter((name) => name === "C1").length; + assert(NAME_PROBABILITIES.customName > 0 && NAME_PROBABILITIES.customName < 1 && directCustomHits > 0 && directCustomHits < directCustomNames.length, "CUSTOM_NAMES are probabilistic suggestions"); + delete CUSTOM_NAMES["custom-probe"]; + + FORCED_NAMES["forced-probe"] = "F1"; + assert(generateEntityName(123, "forced-probe", { x: 8, y: 8, kind: "Probe" }, {}, new Set(), map.nameDebug) === "F1", "FORCED_NAMES always apply"); + delete FORCED_NAMES["forced-probe"]; + for (const seed of [101, 2026, 54321]) { const seeded = generateMap(seed); const metrics = adminBoundaryMetrics(seeded); + const seededRegional = regionalComponentMetrics(seeded); + const seededSatellites = satelliteMunicipalityMetrics(seeded); const invalidLandCells = [...seeded.adminId].filter((id, i) => seeded.prefectureMask[i] && !seeded.sea[i] && id < 0).length; + const invalidRegionCells = [...seeded.prefectureRegionId].filter((id, i) => !seeded.sea[i] && id < 0).length; assert(invalidLandCells === 0, `seed ${seed}: every prefecture land cell has a valid adminId`); + assert(invalidRegionCells === 0, `seed ${seed}: every regional land cell has a valid regionId`); assert(seeded.adminBorders.length > 0, `seed ${seed}: municipal borders exist`); + assert(seeded.regionalPrefectureBorders.length > 0, `seed ${seed}: regional prefecture borders exist`); + assert(seeded.regionalDebug?.regionalChangedAfterNaturalPartition > 0, `seed ${seed}: regional natural partition changes cells`); + assert(seeded.regionalDebug.regionalNaturalBarrierAverageAfter >= seeded.regionalDebug.regionalNaturalBarrierAverageBefore - 0.10, `seed ${seed}: regional border natural affinity is stable`); + assert(seeded.regionalDebug.regionalVoronoiLikeRateAfter <= seeded.regionalDebug.regionalVoronoiLikeRateBefore + 0.25, `seed ${seed}: regional Voronoi-like rate is bounded`); + assert(seededRegional.maxComponents <= 5, `seed ${seed}: regional regions remain connected enough`); + assert(seeded.adminDebug && seeded.adminDebug.compartmentCount > 0, `seed ${seed}: natural compartments are built`); + assert(seeded.adminDebug.changedAfterLandscapePartition > 0 || seeded.adminDebug.changedAfterSnap > 0, `seed ${seed}: municipal terrain passes change cells`); + assert(seededSatellites.largeTooSmall.length === 0, `seed ${seed}: large satellites are not tiny independent municipalities`); + assert(seededSatellites.independent.length < 3 || seededSatellites.small.length / seededSatellites.independent.length <= 0.35, `seed ${seed}: tiny satellite municipalities remain uncommon`); assert(metrics.municipalityCount >= 8, `seed ${seed}: municipality count remains reasonable`); assert(metrics.centerValidRatio >= 0.90, `seed ${seed}: municipality centers remain valid`); assert(metrics.maxComponents <= 5, `seed ${seed}: topology repair limits disconnected fragments`);