diff --git a/adminRegions.js b/adminRegions.js index 15f66a8..3b3e1db 100644 --- a/adminRegions.js +++ b/adminRegions.js @@ -1,1791 +1 @@ -import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, indexOf, inside, weightedScore, xyOf } 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; -} - -export function generateAdminRegions(centers, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse) { - const adminId = new Int16Array(SIZE); - adminId.fill(-1); - const dist = new Float32Array(SIZE); - dist.fill(INF); - const heap = new MinHeap(); - - centers.forEach((center, regionId) => { - const i = indexOf(center.x, center.y); - dist[i] = 0; - adminId[i] = regionId; - heap.push({ i, f: 0, regionId }); - }); - - let guard = 0; - while (heap.length > 0 && guard++ < SIZE * 12) { - const current = heap.pop(); - if (!current) continue; - const curIndex = current.i; - const curRegion = adminId[curIndex]; - if (curRegion < 0 || current.f > dist[curIndex] + 1e-5) continue; - - const [cx, cy] = xyOf(curIndex); - for (const [nx, ny, step] of neighbors8(cx, cy)) { - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni]) continue; - - const ridgeBarrier = Math.max(ridgeField[ni], ridgeField[curIndex]); - const riverBarrier = Math.max(river[ni], river[curIndex]); - const highDivide = Math.max(elevation[ni], elevation[curIndex]); - const watershedBarrier = ridgeBarrier * (27.0 + Math.max(0, highDivide - 0.46) * 46.0); - const ridgePenalty = Math.max(0, highDivide - 0.36) * 16.0 + Math.abs(elevation[ni] - elevation[curIndex]) * 12.4 + watershedBarrier; - const slopePenalty = slope[ni] * 10.6; - const valleyBarrier = valleyField[ni] > 0.50 ? valleyField[ni] * (riverBarrier > 0.16 ? 7.2 : 2.6) : 0; - const riverPenalty = riverBarrier > 0.7 ? 22.0 : riverBarrier > 0.42 ? 14.8 : riverBarrier > 0.22 ? 7.4 : riverBarrier > 0.12 ? 2.2 : 0; - const urbanContinuityBonus = (landuse[ni] >= 2 && landuse[ni] <= 4 && populationDensity[ni] > 0.20) ? 1.65 : 0; - const valleyLocalityBonus = valleyField[ni] * 0.16; - const stepCost = Math.max(0.25, 0.72 + ridgePenalty + slopePenalty + riverPenalty + valleyBarrier - valleyLocalityBonus - urbanContinuityBonus) * step; - const nextDist = dist[curIndex] + stepCost; - - if (nextDist < dist[ni]) { - dist[ni] = nextDist; - adminId[ni] = curRegion; - heap.push({ i: ni, f: nextDist, regionId: curRegion }); - } - } - } - - return adminId; -} - -function terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField) { - return clamp(weightedScore([ - [ridgeField[i], 3.15], - [river[i], 2.45], - [valleyField[i], 0.62], - [slope[i], 1.06], - [Math.max(0, elevation[i] - 0.5), 1.18], - ])); -} - -export function smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, passes = 5) { - let current = new Int16Array(adminId); - for (let pass = 0; pass < passes; pass++) { - const next = new Int16Array(current); - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - const own = current[i]; - if (!prefectureMask[i] || sea[i] || own < 0) continue; - const urbanCell = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.24; - const barrier = terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField); - if (barrier > 0.62 || urbanCell) continue; - - const counts = new Map(); - let ownCount = 0; - for (const [nx, ny] of neighbors4(x, y)) { - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni]) continue; - const id = current[ni]; - if (id < 0) continue; - const weight = terrainBoundaryStrength(ni, elevation, slope, river, ridgeField, valleyField) > 0.72 ? 0.45 : 1; - counts.set(id, (counts.get(id) || 0) + weight); - if (id === own) ownCount += weight; - } - let bestId = own; - let best = ownCount; - for (const [id, score] of counts) if (score > best) { best = score; bestId = id; } - if (bestId !== own && (best >= 4.2 || ownCount <= 2.1)) next[i] = bestId; - } - } - current = next; - } - adminId.set(current); -} - -export function lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, maxCells = 360) { - const seen = new Uint8Array(SIZE); - const queue = []; - for (let i = 0; i < SIZE; i++) { - if (seen[i] || !prefectureMask[i] || sea[i]) continue; - const isUrbanStart = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8 || populationDensity[i] > 0.20; - if (!isUrbanStart) 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 (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; - const isUrban = (landuse[ni] >= 2 && landuse[ni] <= 4) || landuse[ni] === 7 || landuse[ni] === 8 || populationDensity[ni] > 0.20; - if (!isUrban) continue; - seen[ni] = 1; - queue.push(ni); - } - } - if (component.length === 0 || component.length > maxCells) continue; - const counts = new Map(); - for (const ci of component) { - const id = adminId[ci]; - if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1 + populationDensity[ci]); - } - let bestId = -1; - let best = -1; - for (const [id, score] of counts) if (score > best) { best = score; bestId = id; } - if (bestId >= 0) for (const ci of component) adminId[ci] = bestId; - } -} - -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)) 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++) { - const i = indexOf(x, y); - if (!prefectureMask[i] || sea[i]) continue; - const id = adminId[i]; - if (id < 0) continue; - area.set(id, (area.get(id) || 0) + 1); - pop.set(id, (pop.get(id) || 0) + populationDensity[i]); - for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { - if (!inside(nx, ny)) continue; - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni]) continue; - const other = adminId[ni]; - if (other < 0 || other === id) continue; - const key = id < other ? `${id}:${other}` : `${other}:${id}`; - adjacency.set(key, (adjacency.get(key) || 0) + 1); - } - } - } - - const mergeTarget = new Map(); - 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 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 (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]); -} - -export function removeMunicipalExclaves(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], maxIslandCells = 220) { - const protectedByAdmin = new Map(); - for (const p of [...adminCenters, ...protectedPoints]) { - if (!p || !inside(p.x, p.y)) continue; - const id = adminId[indexOf(p.x, p.y)]; - if (id < 0) continue; - if (!protectedByAdmin.has(id)) protectedByAdmin.set(id, new Set()); - protectedByAdmin.get(id).add(indexOf(p.x, p.y)); - } - - const ids = new Set(); - for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]); - - const globalSeen = new Uint8Array(SIZE); - const queue = []; - for (const id of ids) { - const components = []; - for (let i = 0; i < SIZE; i++) { - if (globalSeen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue; - const comp = []; - let hasProtected = protectedByAdmin.get(id)?.has(i) || false; - queue.length = 0; - queue.push(i); - globalSeen[i] = 1; - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - comp.push(cur); - if (protectedByAdmin.get(id)?.has(cur)) hasProtected = true; - const [x, y] = xyOf(cur); - for (const [nx, ny] of neighbors4(x, y)) { - const ni = indexOf(nx, ny); - if (globalSeen[ni] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue; - globalSeen[ni] = 1; - queue.push(ni); - } - } - components.push({ cells: comp, hasProtected }); - } - if (components.length <= 1) continue; - components.sort((a, b) => (b.hasProtected ? 1000000 : 0) + b.cells.length - ((a.hasProtected ? 1000000 : 0) + a.cells.length)); - for (const component of components.slice(1)) { - const mainSize = components[0].cells.length; - if (component.hasProtected && component.cells.length > maxIslandCells && component.cells.length > mainSize * 0.42) continue; - if (component.cells.length > maxIslandCells && component.cells.length > mainSize * 0.36) continue; - const counts = new Map(); - for (const ci of component.cells) { - const [x, y] = xyOf(ci); - for (const [nx, ny] of neighbors4(x, y)) { - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni]) continue; - const other = adminId[ni]; - if (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 component.cells) adminId[ci] = target; - } - } -} - -export function terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse) { - const urbanPenalty = urbanBoundaryPenalty(i, populationDensity, landuse); - const majorRiver = clamp(Math.max(river[i] - 0.32, 0) * 1.9 + Math.max(flowAccum[i] - 0.38, 0) * 0.75); - const minorStream = clamp(river[i] * 0.34 + flowAccum[i] * 0.18); - const ridgeDivide = clamp(ridgeField[i] * 1.55 + Math.max(0, elevation[i] - 0.54) * ridgeField[i] * 0.95); - const slopeBreak = clamp(slope[i] * 0.58 + Math.max(0, slope[i] - 0.32) * 0.68); - const highGround = Math.max(0, elevation[i] - 0.56) * 0.22; - const valleyFloorPenalty = valleyField[i] * (majorRiver > 0.34 ? -0.10 : -0.62); - return clamp(ridgeDivide + majorRiver * 0.88 + minorStream * 0.22 + slopeBreak + highGround + valleyFloorPenalty - urbanPenalty * 0.72); -} - -function urbanBoundaryPenalty(i, populationDensity, landuse) { - const lu = landuse[i]; - const core = lu === 3 ? 1.45 : lu === 2 ? 1.12 : lu === 4 ? 0.95 : lu === 7 ? 0.90 : lu === 8 ? 0.64 : lu === 5 || lu === 6 ? 0.48 : 0; - return clamp(core + populationDensity[i] * 1.35); -} - -function isAdminBoundaryCell(labels, prefectureMask, sea, x, y, useEight = true) { - const i = indexOf(x, y); - const own = labels[i]; - if (!prefectureMask[i] || sea[i] || own < 0) return false; - const neighbors = useEight ? neighbors8(x, y) : neighbors4(x, y); - for (const [nx, ny] of neighbors) { - const ni = indexOf(nx, ny); - if (prefectureMask[ni] && !sea[ni] && labels[ni] >= 0 && labels[ni] !== own) return true; - } - return false; -} - -function buildBoundaryBand(labels, prefectureMask, sea, radius = 5) { - const band = new Uint8Array(SIZE); - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - if (!isAdminBoundaryCell(labels, prefectureMask, sea, x, y, true)) continue; - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - if (Math.hypot(dx, dy) > radius) continue; - const nx = x + dx; - const ny = y + dy; - if (!inside(nx, ny)) continue; - const ni = indexOf(nx, ny); - if (prefectureMask[ni] && !sea[ni]) band[ni] = 1; - } - } - } - } - return band; -} - -function buildAdminProtectedMask(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], populationDensity, landuse) { - const protectedMask = new Uint8Array(SIZE); - function protectDisk(p, radius) { - if (!p || !inside(p.x, p.y)) return; - const owner = adminId[indexOf(p.x, p.y)]; - if (owner < 0) return; - const r = Math.ceil(radius); - for (let dy = -r; dy <= r; dy++) { - for (let dx = -r; dx <= r; dx++) { - if (Math.hypot(dx, dy) > radius) continue; - const x = p.x + dx; - const y = p.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (prefectureMask[i] && !sea[i] && adminId[i] === owner) protectedMask[i] = 1; - } - } - } - for (const center of adminCenters) protectDisk(center, 2.2); - for (const p of protectedPoints || []) protectDisk(p, p.population ? clamp(1.6 + Math.sqrt(p.population) / 520, 2.1, 6.0) : p.portClass ? 2.0 : 1.7); - for (let i = 0; i < SIZE; i++) { - if (prefectureMask[i] && !sea[i] && (landuse[i] === 3 || populationDensity[i] > 0.72)) protectedMask[i] = 1; - } - return protectedMask; -} - -function localBoundaryEnergy(labels, i, candidateId, targetScore, centerDist, populationDensity, landuse, river, valleyField) { - const [x, y] = xyOf(i); - const oldId = labels[i]; - let energy = centerDist[candidateId]?.[i] ?? 0; - let same4 = 0; - let diff4 = 0; - let diagDiff = 0; - - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - const neighborId = labels[ni]; - if (neighborId < 0) continue; - const isCardinal = nx === x || ny === y; - const differs = neighborId !== candidateId; - if (isCardinal) { - if (differs) { - diff4++; - const boundaryTarget = (targetScore[i] + targetScore[ni]) * 0.5; - const urbanCut = (urbanBoundaryPenalty(i, populationDensity, landuse) + urbanBoundaryPenalty(ni, populationDensity, landuse)) * 0.5; - const minorValley = (valleyField[i] + valleyField[ni]) * 0.5 > 0.34 && Math.max(river[i], river[ni]) < 0.30 ? 0.72 : 0; - const dHere = centerDist[candidateId]?.[i] ?? 99; - const dThere = centerDist[neighborId]?.[i] ?? 99; - const weakBisectorPenalty = Math.abs(dHere - dThere) < 4.0 && boundaryTarget < 0.42 ? 0.62 : 0; - energy += 2.15 - boundaryTarget * 1.55 + urbanCut * 3.0 + minorValley + weakBisectorPenalty; - } else same4++; - } else if (differs) diagDiff++; - } - - if (same4 === 0) energy += 5.2; - if (same4 === 1) energy += 1.7; - if (diff4 >= 3 && targetScore[i] < 0.42) energy += 1.25; - if (diagDiff >= 3 && diff4 >= 2 && targetScore[i] < 0.50) energy += 0.42; - if (candidateId !== oldId) { - const candidateDistance = centerDistanceAt(centerDist, candidateId, i); - const oldDistance = centerDistanceAt(centerDist, oldId, i); - if (Number.isFinite(candidateDistance) && Number.isFinite(oldDistance)) { - const drift = candidateDistance - oldDistance; - if (drift > 0) energy += Math.min(0.9, drift * 0.012); - } - } - return energy; -} - -function centerDistanceAt(centerDist, id, i) { - const field = centerDist?.fields?.[id] || centerDist?.[id]; - if (field) return field[i]; - const center = centerDist?.centers?.[id]; - if (!center || !inside(center.x, center.y)) return 24; - const [x, y] = xyOf(i); - return Math.hypot(x - center.x, y - center.y); -} - -function buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea) { - // Older versions materialized one full SIZE Float32Array per municipality. - // In multi-prefecture generation this can create heavy transient memory use. - // Keep the same interface conceptually, but compute distances on demand. - return { ids: adminIds, centers: adminCenters, prefectureMask, sea }; -} - -function repairAdminTopology(adminId, prefectureMask, sea, adminCenters = [], targetScore = null, populationDensity = null, landuse = null) { - const ids = new Set(); - for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[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] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue; - const cells = []; - queue.length = 0; - queue.push(i); - seen[i] = 1; - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - cells.push(cur); - const [x, y] = xyOf(cur); - for (const [nx, ny] of neighbors4(x, y)) { - const ni = indexOf(nx, ny); - if (seen[ni] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue; - seen[ni] = 1; - queue.push(ni); - } - } - components.push(cells); - } - if (components.length <= 1) continue; - - const centerIndex = adminCenters[id] && inside(adminCenters[id].x, adminCenters[id].y) ? indexOf(adminCenters[id].x, adminCenters[id].y) : -1; - let keepIndex = centerIndex >= 0 ? components.findIndex((cells) => cells.includes(centerIndex)) : -1; - if (keepIndex < 0) { - let bestSize = -1; - for (let c = 0; c < components.length; c++) if (components[c].length > bestSize) { bestSize = components[c].length; keepIndex = c; } - } - - for (let c = 0; c < components.length; c++) { - if (c === keepIndex) continue; - const counts = new Map(); - for (const ci of components[c]) { - const [x, y] = xyOf(ci); - for (const [nx, ny] of neighbors4(x, y)) { - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni]) continue; - const other = adminId[ni]; - if (other < 0 || other === id) continue; - const terrainFit = targetScore ? targetScore[ci] * 0.18 : 0; - const urbanFit = populationDensity && landuse ? (1 - urbanBoundaryPenalty(ci, populationDensity, landuse)) * 0.08 : 0; - counts.set(other, (counts.get(other) || 0) + 1 + terrainFit + urbanFit); - } - } - let target = -1; - let best = -1; - for (const [other, score] of counts) if (score > best) { best = score; target = other; } - if (target >= 0) for (const ci of components[c]) adminId[ci] = target; - } - } -} - -function classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse) { - if (landuse[i] === 3 || populationDensity[i] > 0.70) return 1; - if ((landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8 || populationDensity[i] > 0.24) return 2; - if (landuse[i] === 5 || landuse[i] === 6) return 3; - if ((river[i] > 0.50 && flowAccum[i] > 0.34) || flowAccum[i] > 0.68) return 4; - if (coastalLowland[i] > 0.42 && elevation[i] < 0.44) return 5; - if (basinField[i] > 0.38 && plain[i] > 0.26) return 6; - if (valleyField[i] > 0.42 && ridgeField[i] < 0.55) return 7; - if (ridgeField[i] > 0.54 || (ridgeField[i] > 0.40 && elevation[i] > 0.54)) return 8; - if (elevation[i] > 0.62 || slope[i] > 0.42) return 9; - if (landuse[i] === 0 || agriculture[i] > 0.45 || plain[i] > 0.48) return 10; - 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 [x, y] = xyOf(i); - let coastEdge = 0; - for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coastEdge = 1; - 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 + - coastEdge * 0.34 + - terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity || score, landuse || score) * 0.38 - - livingCorridor * 0.50 - - urbanContinuity * 0.72 - ); - } - return score; -} - -function lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse) { - const lowRelief = clamp((0.68 - elevation[i]) * 1.25) + clamp((0.36 - slope[i]) * 1.45) + clamp((0.48 - ridgeField[i]) * 1.10); - const landuseFit = [1, 2, 3, 4, 7, 8].includes(landuse[i]) ? 0.42 : landuse[i] === 5 || landuse[i] === 6 ? 0.20 : 0; - return clamp( - lowRelief * 0.30 + - (plain?.[i] || 0) * 0.30 + - (agriculture?.[i] || 0) * 0.16 + - basinField[i] * 0.24 + - coastalLowland[i] * 0.24 + - valleyField[i] * 0.10 + - populationDensity[i] * 0.34 + - landuseFit - ); -} - -function mountainCompartmentFitness(i, elevation, slope, ridgeField, populationDensity, landuse) { - const settled = populationDensity[i] * 0.85 + ([2, 3, 4, 7, 8].includes(landuse[i]) ? 0.35 : 0); - return clamp(elevation[i] * 0.38 + slope[i] * 0.32 + ridgeField[i] * 0.42 - settled); -} - -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.56 || Math.max(flowAccum[a], flowAccum[b]) > 0.68; - 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.48 && !majorRiverEdge; - const threshold = urbanEdge ? 0.78 : valleyContinuity ? 0.62 : classA === 8 || classB === 8 ? 0.36 : 0.50; - return barrier < threshold && (!majorRiverEdge || urbanEdge); -} - -function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse) { - let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = unit.riverExposure || 0; - let coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0, lowlandFitness = 0, mountainFitness = 0; - let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0; - for (const i of unit.cells) { - const [x, y] = xyOf(i); - sx += x; sy += y; pop += populationDensity[i]; - minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); - urbanWeight += urbanBoundaryPenalty(i, populationDensity, landuse); - ridgeExposure += ridgeField[i]; - coastalExposure += coastalLowland[i]; - basinIdentity += basinField[i]; - valleyIdentity += valleyField[i]; - lowlandFitness += lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); - mountainFitness += mountainCompartmentFitness(i, elevation, slope, ridgeField, populationDensity, landuse); - } - const area = unit.cells.length; - unit.area = area; - unit.x = sx / Math.max(1, area); - unit.y = sy / Math.max(1, area); - unit.minX = area ? minX : 0; - unit.minY = area ? minY : 0; - unit.maxX = area ? maxX : 0; - unit.maxY = area ? maxY : 0; - unit.width = area ? maxX - minX + 1 : 0; - unit.height = area ? maxY - minY + 1 : 0; - unit.elongation = Math.max(unit.width, unit.height) / Math.max(1, Math.min(unit.width, unit.height)); - unit.population = pop; - unit.urbanWeight = urbanWeight / Math.max(1, area); - unit.ridgeExposure = ridgeExposure / Math.max(1, area); - unit.riverExposure = riverExposure / Math.max(1, area); - unit.coastalExposure = coastalExposure / Math.max(1, area); - unit.basinIdentity = basinIdentity / Math.max(1, area); - unit.valleyIdentity = valleyIdentity / Math.max(1, area); - unit.lowlandFitness = lowlandFitness / Math.max(1, area); - unit.mountainFitness = mountainFitness / Math.max(1, area); -} - -function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) { - if (!unit || unit.area < 24) return null; - const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum } = fields; - const minPart = Math.max(8, Math.min(28, Math.floor(unit.area * 0.20))); - - let first = -1; - let second = -1; - let bestA = -INF; - let bestB = -INF; - const width = unit.width || (unit.maxX - unit.minX + 1) || 1; - const height = unit.height || (unit.maxY - unit.minY + 1) || 1; - const horizontal = width >= height; - const elongated = Math.max(width, height) / Math.max(1, Math.min(width, height)) > 1.65; - - for (const i of unit.cells) { - const [x, y] = xyOf(i); - const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); - const settled = populationDensity[i] * 0.28 + ([2, 3, 4, 7, 8].includes(landuse[i]) ? 0.34 : 0); - const axis = elongated ? (horizontal ? (unit.maxX - x) / Math.max(1, width) : (unit.maxY - y) / Math.max(1, height)) : 0.0; - const score = axis * 1.7 + low * 0.42 + settled + hashSeededTie(x, y, seed) * 0.05 - ridgeField[i] * 0.10; - if (score > bestA) { bestA = score; first = i; } - } - if (first < 0) return null; - const [fx, fy] = xyOf(first); - for (const i of unit.cells) { - const [x, y] = xyOf(i); - const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); - const axis = elongated ? (horizontal ? (x - unit.minX) / Math.max(1, width) : (y - unit.minY) / Math.max(1, height)) : 0.0; - const d = Math.hypot(x - fx, y - fy); - const score = axis * 1.9 + d * (0.18 + low * 0.22) + hashSeededTie(x, y, seed + 17) * 0.08 - ridgeField[i] * 0.08; - if (score > bestB) { bestB = score; second = i; } - } - if (second < 0 || second === first) return null; - - const cellSet = new Set(unit.cells); - const owner = new Int8Array(SIZE); - owner.fill(-1); - const dist = new Float32Array(SIZE); - dist.fill(INF); - const heap = new MinHeap(); - for (const [source, sourceOwner] of [[first, 0], [second, 1]]) { - owner[source] = sourceOwner; - dist[source] = 0; - heap.push({ i: source, f: 0, owner: sourceOwner }); - } - - while (heap.length > 0) { - const cur = heap.pop(); - if (!cur || cur.f > dist[cur.i] + 1e-5) continue; - const [x, y] = xyOf(cur.i); - for (const [nx, ny, step] of neighbors4(x, y)) { - const ni = indexOf(nx, ny); - if (!cellSet.has(ni)) continue; - const barrier = ((naturalBarrierScore?.[cur.i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5; - const riverBarrier = Math.max(river?.[cur.i] || 0, river?.[ni] || 0) + Math.max(flowAccum?.[cur.i] || 0, flowAccum?.[ni] || 0) * 0.32; - const ridgeStep = Math.max(ridgeField[cur.i], ridgeField[ni]) * 0.80 + Math.abs(elevation[cur.i] - elevation[ni]) * 1.25; - const corridorBonus = Math.min(0.48, ((valleyField[cur.i] + valleyField[ni]) * 0.5 + (plain?.[ni] || 0) * 0.18 + (coastalLowland?.[ni] || 0) * 0.12)); - const stepCost = Math.max(0.18, 0.78 + barrier * 3.0 + riverBarrier * 1.10 + ridgeStep + slope[ni] * 0.38 - corridorBonus) * step; - const nd = cur.f + stepCost; - if (nd < dist[ni]) { - dist[ni] = nd; - owner[ni] = cur.owner; - heap.push({ i: ni, f: nd, owner: cur.owner }); - } - } - } - - const aCells = []; - const bCells = []; - for (const ci of unit.cells) { - if (owner[ci] === 1) bCells.push(ci); - else aCells.push(ci); - } - if (aCells.length < minPart || bCells.length < minPart) return null; - - unit.cells = aCells; - const newUnit = { ...unit, id: newId, cells: bCells, centerIds: [], adjacent: new Map() }; - for (const ci of bCells) compartmentId[ci] = newId; - refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); - refreshCompartmentStats(newUnit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); - return newUnit; -} - -function hashSeededTie(x, y, seed) { - let h = Math.imul((x | 0) ^ (seed | 0), 1597334677) ^ Math.imul((y | 0) ^ ((seed >>> 1) | 0), 3812015801); - h = (h ^ (h >>> 15)) >>> 0; - return h / 4294967295; -} - -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)}`; -} - - -function collectLandComponents(prefectureMask, sea) { - const seen = new Uint8Array(SIZE); - const components = []; - const queue = []; - for (let i = 0; i < SIZE; i++) { - if (seen[i] || !prefectureMask[i] || sea[i]) continue; - const cells = []; - queue.length = 0; - queue.push(i); - seen[i] = 1; - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - cells.push(cur); - const [x, y] = xyOf(cur); - for (const [nx, ny] of neighbors4(x, y)) { - const ni = indexOf(nx, ny); - if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; - seen[ni] = 1; - queue.push(ni); - } - } - components.push(cells); - } - return components; -} - -function naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed) { - const klassUrban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8; - const lowland = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); - const mountain = mountainCompartmentFitness(i, elevation, slope, ridgeField, populationDensity, landuse); - const stableInterior = clamp(1 - (naturalBarrierScore[i] || 0)); - const settlement = clamp(populationDensity[i] * 0.65 + (klassUrban ? 0.24 : 0)); - const streamCorridor = clamp(valleyField[i] * 0.28 + river[i] * 0.08); - const mountainInterior = clamp(mountain * 0.45 + stableInterior * 0.28 - ridgeField[i] * 0.22); - return stableInterior * 0.56 + lowland * 0.42 + mountainInterior * 0.32 + settlement * 0.26 + streamCorridor + hashSeededTie(...xyOf(i), seed) * 0.13 - slope[i] * 0.10; -} - -function chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, seed) { - const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore } = fields; - const totalArea = landComponents.reduce((sum, cells) => sum + cells.length, 0); - const seeds = []; - const seedComponentId = []; - const minCellsPerUnit = 9; - let remainingTarget = Math.max(1, Math.min(targetCount || Math.round(totalArea / 42), Math.floor(totalArea / minCellsPerUnit))); - - const sortedComponents = landComponents - .map((cells, componentIndex) => ({ cells, componentIndex, area: cells.length })) - .sort((a, b) => b.area - a.area); - - for (let componentOrder = 0; componentOrder < sortedComponents.length; componentOrder++) { - const { cells, componentIndex, area } = sortedComponents[componentOrder]; - if (area <= 0) continue; - const proportional = Math.round((targetCount || Math.round(totalArea / 42)) * area / Math.max(1, totalArea)); - let localTarget = Math.max(1, proportional); - localTarget = Math.min(localTarget, Math.max(1, Math.floor(area / minCellsPerUnit))); - if (componentOrder === sortedComponents.length - 1) localTarget = Math.max(1, Math.min(localTarget, remainingTarget)); - remainingTarget -= localTarget; - - const candidates = cells - .map((i) => ({ i, score: naturalSeedScore(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, seed + componentIndex * 1009) })) - .sort((a, b) => b.score - a.score); - const localSeeds = []; - const idealSpacing = Math.sqrt(area / Math.max(1, localTarget)); - const spacingPasses = [0.95, 0.78, 0.62, 0.48, 0.34]; - for (const factor of spacingPasses) { - const minDist = Math.max(2.2, idealSpacing * factor); - for (const candidate of candidates) { - if (localSeeds.length >= localTarget) break; - const [x, y] = xyOf(candidate.i); - let ok = true; - for (const existing of localSeeds) { - const [ex, ey] = xyOf(existing); - if (Math.hypot(x - ex, y - ey) < minDist) { ok = false; break; } - } - if (ok) localSeeds.push(candidate.i); - } - if (localSeeds.length >= localTarget) break; - } - for (const i of localSeeds) { - seeds.push(i); - seedComponentId.push(componentIndex); - } - } - - if (seeds.length === 0 && landComponents[0]?.length) { - seeds.push(landComponents[0][0]); - seedComponentId.push(0); - } - return { seeds, seedComponentId }; -} - -function naturalStepCost(a, b, cellClass, fields) { - const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, flowAccum } = fields; - const barrier = ((naturalBarrierScore?.[a] || 0) + (naturalBarrierScore?.[b] || 0)) * 0.5; - const ridge = Math.max(ridgeField[a], ridgeField[b]); - const riverEdge = Math.max(river[a], river[b]); - const flowEdge = Math.max(flowAccum?.[a] || 0, flowAccum?.[b] || 0); - const majorRiverCrossing = riverEdge > 0.44 || flowEdge > 0.55; - const elevationBreak = Math.abs(elevation[a] - elevation[b]); - const slopeBreak = Math.max(slope[a], slope[b]); - const classBreak = cellClass[a] !== cellClass[b] ? 0.34 : -0.08; - const bothUrban = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.30) && - ((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.30); - const lowlandContinuity = Math.min( - (plain?.[a] || 0) + (agriculture?.[a] || 0) * 0.35 + basinField[a] * 0.25 + coastalLowland[a] * 0.20, - (plain?.[b] || 0) + (agriculture?.[b] || 0) * 0.35 + basinField[b] * 0.25 + coastalLowland[b] * 0.20 - ); - const valleyContinuity = Math.min(valleyField[a], valleyField[b]) * (majorRiverCrossing ? 0.10 : 0.45); - const corridorBonus = Math.min(0.42, lowlandContinuity * 0.22 + valleyContinuity + (bothUrban ? 0.18 : 0)); - const riverPenalty = majorRiverCrossing && !bothUrban ? 1.85 + flowEdge * 1.45 : riverEdge > 0.22 ? 0.38 : 0; - return Math.max(0.16, - 0.72 + - barrier * 5.1 + - ridge * 0.82 + - elevationBreak * 3.0 + - slopeBreak * 0.56 + - riverPenalty + - classBreak - - corridorBonus - ); -} - -function buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields) { - const { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse } = fields; - let maxId = -1; - for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] > maxId) maxId = compartmentId[i]; - const units = Array.from({ length: maxId + 1 }, (_, id) => ({ id, cells: [], centerIds: [], adjacent: new Map(), area: 0 })); - for (let i = 0; i < SIZE; i++) { - const id = compartmentId[i]; - if (id >= 0 && units[id]) units[id].cells.push(i); - } - for (const unit of units) { - if (!unit.cells.length) { unit.area = 0; continue; } - const counts = new Map(); - for (const ci of unit.cells) counts.set(cellClass[ci], (counts.get(cellClass[ci]) || 0) + 1); - let klass = -1, best = -1; - for (const [k, count] of counts) if (count > best) { best = count; klass = k; } - unit.classId = klass; - unit.dominantLandscapeClass = klass; - refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); - let riverExposure = 0; - for (const ci of unit.cells) riverExposure += river[ci] + (fields.flowAccum?.[ci] || 0) * 0.45; - unit.riverExposure = riverExposure / Math.max(1, unit.area); - } - return units; -} - - -function splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea) { - const queue = []; - for (const unit of [...compartments]) { - if (!unit || unit.area === 0 || !unit.cells?.length) continue; - const unitCellSet = new Set(unit.cells); - const seen = new Set(); - const components = []; - for (const start of unit.cells) { - if (seen.has(start) || compartmentId[start] !== unit.id) continue; - const cells = []; - queue.length = 0; - queue.push(start); - seen.add(start); - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - cells.push(cur); - const [x, y] = xyOf(cur); - for (const [nx, ny] of neighbors4(x, y)) { - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni] || seen.has(ni) || compartmentId[ni] !== unit.id || !unitCellSet.has(ni)) continue; - seen.add(ni); - queue.push(ni); - } - } - components.push(cells); - } - if (components.length <= 1) continue; - components.sort((a, b) => b.length - a.length); - unit.cells = components[0]; - for (const extra of components.slice(1)) { - const newId = compartments.length; - for (const ci of extra) compartmentId[ci] = newId; - compartments.push({ id: newId, cells: extra, centerIds: [], adjacent: new Map(), classId: unit.classId, dominantLandscapeClass: unit.dominantLandscapeClass }); - } - } -} - -function renumberCompartments(compartmentId, compartments, prefectureMask, sea) { - const active = compartments.filter((unit) => unit && unit.area > 0 && unit.cells?.length); - const idMap = new Map(); - active.forEach((unit, newId) => idMap.set(unit.id, newId)); - for (let i = 0; i < SIZE; i++) { - const id = compartmentId[i]; - if (!prefectureMask[i] || sea[i]) compartmentId[i] = -1; - else if (idMap.has(id)) compartmentId[i] = idMap.get(id); - } - active.forEach((unit, newId) => { unit.id = newId; unit.centerIds = []; }); - return active; -} - -function refreshAllCompartmentStats(compartments, fields) { - const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, river, flowAccum } = fields; - for (const unit of compartments) { - if (!unit || unit.area === 0 || !unit.cells?.length) continue; - refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); - let riverExposure = 0; - const counts = new Map(); - for (const ci of unit.cells) { - riverExposure += river[ci] + (flowAccum?.[ci] || 0) * 0.45; - if (unit._cellClass) counts.set(unit._cellClass[ci], (counts.get(unit._cellClass[ci]) || 0) + 1); - } - unit.riverExposure = riverExposure / Math.max(1, unit.area); - } -} - -function splitNaturalCompartmentCompact(unit, newId, compartmentId, fields, seed) { - if (!unit || unit.area < 20) return null; - const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum, cellClass } = fields; - const minPart = Math.max(7, Math.min(30, Math.floor(unit.area * 0.18))); - let cx = unit.x || 0, cy = unit.y || 0; - let first = -1, second = -1, bestA = -INF, bestB = -INF; - const elongated = (unit.elongation || 1) > 2.3; - const horizontal = (unit.width || 0) >= (unit.height || 0); - for (const i of unit.cells) { - const [x, y] = xyOf(i); - const centerDist = Math.hypot(x - cx, y - cy); - const axis = elongated ? Math.abs((horizontal ? x - cx : y - cy)) / Math.max(1, horizontal ? unit.width : unit.height) : 0; - const interior = 1 - (naturalBarrierScore[i] || 0); - const score = centerDist * 0.13 + axis * 1.1 + interior * 0.35 + hashSeededTie(x, y, seed) * 0.08 - ridgeField[i] * 0.10; - if (score > bestA) { bestA = score; first = i; } - } - if (first < 0) return null; - const [fx, fy] = xyOf(first); - for (const i of unit.cells) { - const [x, y] = xyOf(i); - const d = Math.hypot(x - fx, y - fy); - const interior = 1 - (naturalBarrierScore[i] || 0); - const score = d * 0.20 + interior * 0.38 + hashSeededTie(x, y, seed + 31) * 0.08 - ridgeField[i] * 0.08; - if (score > bestB) { bestB = score; second = i; } - } - if (second < 0 || second === first) return null; - - const cellSet = new Set(unit.cells); - const owner = new Int8Array(SIZE); - owner.fill(-1); - const dist = new Float32Array(SIZE); - dist.fill(INF); - const heap = new MinHeap(); - for (const [source, sourceOwner] of [[first, 0], [second, 1]]) { - owner[source] = sourceOwner; - dist[source] = 0; - heap.push({ i: source, f: 0, owner: sourceOwner }); - } - while (heap.length > 0) { - const cur = heap.pop(); - if (!cur || cur.f > dist[cur.i] + 1e-5) continue; - const [x, y] = xyOf(cur.i); - for (const [nx, ny, step] of neighbors4(x, y)) { - const ni = indexOf(nx, ny); - if (!cellSet.has(ni)) continue; - const nd = cur.f + naturalStepCost(cur.i, ni, cellClass, fields) * step; - if (nd < dist[ni]) { - dist[ni] = nd; - owner[ni] = cur.owner; - heap.push({ i: ni, f: nd, owner: cur.owner }); - } - } - } - const aCells = [], bCells = []; - for (const ci of unit.cells) (owner[ci] === 1 ? bCells : aCells).push(ci); - if (aCells.length < minPart || bCells.length < minPart) return null; - unit.cells = aCells; - for (const ci of bCells) compartmentId[ci] = newId; - const newUnit = { id: newId, cells: bCells, centerIds: [], adjacent: new Map(), classId: unit.classId, dominantLandscapeClass: unit.dominantLandscapeClass }; - refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); - refreshCompartmentStats(newUnit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse); - return newUnit; -} - -function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) { - const progress = typeof options.progress === "function" ? options.progress : null; - const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse); - 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 fields = { elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, naturalBarrierScore, cellClass }; - const landComponents = collectLandComponents(prefectureMask, sea); - const landArea = landComponents.reduce((sum, cells) => sum + cells.length, 0); - const requestedTarget = options.targetCompartmentCount || clamp(Math.round(landArea / 34), 40, 360); - const targetCount = clamp(Math.round(requestedTarget), Math.min(1, landArea), Math.max(1, Math.floor(landArea / 8))); - const { seeds } = chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, options.seed || 0); - progress?.(`natural seeds chosen: ${seeds.length}/${targetCount}`); - const compartmentId = new Int32Array(SIZE); - compartmentId.fill(-1); - const dist = new Float32Array(SIZE); - dist.fill(INF); - const heap = new MinHeap(); - seeds.forEach((i, id) => { - compartmentId[i] = id; - dist[i] = 0; - heap.push({ i, f: 0, id }); - }); - while (heap.length > 0) { - const cur = heap.pop(); - if (!cur || cur.f > dist[cur.i] + 1e-5) continue; - const [x, y] = xyOf(cur.i); - for (const [nx, ny, step] of neighbors4(x, y)) { - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni]) continue; - const nd = cur.f + naturalStepCost(cur.i, ni, cellClass, fields) * step; - if (nd < dist[ni]) { - dist[ni] = nd; - compartmentId[ni] = cur.id; - heap.push({ i: ni, f: nd, id: cur.id }); - } - } - } - for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] < 0) compartmentId[i] = 0; - progress?.("natural seeded growth complete"); - - let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields); - rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); - mergeTinyLandscapeUnits(compartmentId, compartments, 9); - progress?.(`natural post-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`); - splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); - compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); - refreshAllCompartmentStats(compartments, fields); - rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); - progress?.(`natural pre-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`); - - const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55)); - let guard = Math.max(60, targetCount * 2); - while (guard-- > 0) { - let active = compartments.filter((unit) => unit && unit.area > 0); - const needMore = active.length < targetCount; - const worst = active - .filter((unit) => unit.area >= 20 && (unit._splitRejected || 0) < 3 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2)) - .sort((a, b) => { - const sa = (a.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (a.elongation || 1) - 3.0) * 1.2; - const sb = (b.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (b.elongation || 1) - 3.0) * 1.2; - return sb - sa; - })[0]; - if (!worst) break; - const newUnit = splitNaturalCompartmentCompact(worst, compartments.length, compartmentId, fields, (options.seed || 0) + guard * 97); - if (!newUnit) { - worst._splitRejected = (worst._splitRejected || 0) + 1; - if (worst._splitRejected > 2) worst.elongation = Math.min(worst.elongation || 1, 3.1); - if (!needMore) break; - continue; - } - compartments.push(newUnit); - if (compartments.filter((unit) => unit && unit.area > 0).length >= targetCount && newUnit.area <= maxNaturalCompartmentArea) { - const stillBad = compartments.some((unit) => unit && unit.area >= 20 && ( - unit.area > maxNaturalCompartmentArea * 1.35 || - ((unit.elongation || 1) > 4.2 && unit.area > 28) - )); - if (!stillBad) break; - } - } - - splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); - compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); - refreshAllCompartmentStats(compartments, fields); - rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); - return { compartmentId, compartments, naturalBarrierScore }; -} - -export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) { - return buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse, options); - 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; - let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 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]; - minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); - 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; - const unit = { - id, - cells, - area, - x: sx / area, - y: sy / area, - classId: startClass, - dominantLandscapeClass: startClass, - minX, - minY, - maxX, - maxY, - width: maxX - minX + 1, - height: maxY - minY + 1, - elongation: Math.max(maxX - minX + 1, maxY - minY + 1) / Math.max(1, Math.min(maxX - minX + 1, maxY - minY + 1)), - population: pop, - urbanWeight: urbanWeight / area, - ridgeExposure: ridgeExposure / area, - riverExposure: riverExposure / area, - coastalExposure: coastalExposure / area, - basinIdentity: basinIdentity / area, - valleyIdentity: valleyIdentity / area, - centerIds: [], - adjacent: new Map(), - }; - unit.lowlandFitness = cells.reduce((sum, ci) => sum + lowlandCompartmentFitness(ci, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse), 0) / area; - unit.mountainFitness = cells.reduce((sum, ci) => sum + mountainCompartmentFitness(ci, elevation, slope, ridgeField, populationDensity, landuse), 0) / area; - compartments.push(unit); - } - rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); - mergeTinyLandscapeUnits(compartmentId, compartments, 12); - rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); - const targetCount = options.targetCompartmentCount || 0; - if (targetCount > 0) { - const fields = { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum }; - const landArea = compartments.reduce((sum, unit) => sum + (unit.area || 0), 0); - const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(34, Math.round(landArea / Math.max(1, targetCount) * 1.65)); - const splitScore = (unit) => { - const elongated = Math.max(0, (unit.elongation || 1) - 2.1); - const areaPressure = unit.area / Math.max(1, maxNaturalCompartmentArea); - const settled = (unit.lowlandFitness || 0) * 0.65 + (unit.urbanWeight || 0) * 0.35; - return areaPressure * 2.2 + elongated * 1.4 + settled - (unit.mountainFitness || 0) * 0.20; - }; - let guard = Math.max(targetCount * 4, 80); - while (compartments.filter((unit) => unit.area > 0).length < targetCount && guard-- > 0) { - const candidates = compartments - .filter((unit) => unit.area > 0 && unit.area >= 24 && ((unit.lowlandFitness || 0) > 0.18 || unit.area > maxNaturalCompartmentArea * 1.20 || (unit.elongation || 1) > 2.8)) - .sort((a, b) => splitScore(b) - splitScore(a)); - const target = candidates[0]; - if (!target) break; - const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard); - if (!newUnit) { - target._splitRejected = (target._splitRejected || 0) + 1; - target.elongation = Math.max(1, (target.elongation || 1) * 0.72); - if (target._splitRejected > 2) target.area = target.cells.length; - continue; - } - compartments.push(newUnit); - if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); - } - - guard = Math.max(targetCount * 2, 60); - while (guard-- > 0) { - const target = compartments - .filter((unit) => unit.area > 0 && unit.area >= 24 && (unit.area > maxNaturalCompartmentArea * 1.55 || ((unit.elongation || 1) > 3.2 && unit.area > maxNaturalCompartmentArea * 0.85))) - .sort((a, b) => splitScore(b) - splitScore(a))[0]; - if (!target) break; - const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard + 991); - if (!newUnit) { - target.elongation = Math.max(1, (target.elongation || 1) * 0.70); - break; - } - compartments.push(newUnit); - if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); - } - 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); - 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 units = []; - const queue = []; - for (let i = 0; i < SIZE; i++) { - if (cellClass[i] < 0 || unitId[i] >= 0) continue; - const id = units.length; - const klass = cellClass[i]; - const cells = []; - let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = 0; - queue.length = 0; - queue.push(i); - unitId[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; - for (const [nx, ny] of neighbors4(x, y)) { - const ni = indexOf(nx, ny); - if (unitId[ni] >= 0 || cellClass[ni] !== klass) continue; - unitId[ni] = id; - queue.push(ni); - } - } - units.push({ id, classId: klass, cells, area: cells.length, x: sx / cells.length, y: sy / cells.length, population: pop, urbanWeight: urbanWeight / cells.length, ridgeExposure: ridgeExposure / cells.length, riverExposure: riverExposure / cells.length, adjacent: new Map(), centerIds: [], owner: -1 }); - } - - const targetScore = new Float32Array(SIZE); - for (let i = 0; i < SIZE; i++) if (cellClass[i] >= 0) targetScore[i] = terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse); - rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea); - mergeTinyLandscapeUnits(unitId, units, 10); - rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea); - return { unitId, units, targetScore }; -} - -function rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea) { - for (const unit of units) unit.adjacent = new Map(); - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (!prefectureMask[i] || sea[i]) continue; - const a = unitId[i]; - if (a < 0 || !units[a] || units[a].area === 0) continue; - for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { - if (!inside(nx, ny)) continue; - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni]) continue; - const b = unitId[ni]; - if (b < 0 || b === a || !units[b] || units[b].area === 0) continue; - const v = (targetScore[i] + targetScore[ni]) * 0.5; - const keyA = units[a].adjacent.get(b) || { count: 0, target: 0 }; - keyA.count++; keyA.target += v; units[a].adjacent.set(b, keyA); - const keyB = units[b].adjacent.get(a) || { count: 0, target: 0 }; - keyB.count++; keyB.target += v; units[b].adjacent.set(a, keyB); - } - } - } -} - -function mergeTinyLandscapeUnits(unitId, units, minArea = 10) { - for (const unit of units) { - if (unit.area === 0 || unit.area >= minArea) continue; - let bestId = -1, bestScore = -INF; - for (const [otherId, edge] of unit.adjacent) { - const other = units[otherId]; - if (!other || other.area === 0) continue; - const score = edge.count * 3 + (other.classId === unit.classId ? 8 : 0) + other.area * 0.01 - edge.target / Math.max(1, edge.count); - if (score > bestScore) { bestScore = score; bestId = otherId; } - } - if (bestId < 0) continue; - const target = units[bestId]; - for (const i of unit.cells) { unitId[i] = bestId; target.cells.push(i); } - const totalArea = target.area + unit.area; - target.x = (target.x * target.area + unit.x * unit.area) / totalArea; - target.y = (target.y * target.area + unit.y * unit.area) / totalArea; - target.population += unit.population; - target.urbanWeight = (target.urbanWeight * target.area + unit.urbanWeight * unit.area) / totalArea; - target.ridgeExposure = (target.ridgeExposure * target.area + unit.ridgeExposure * unit.area) / totalArea; - target.riverExposure = (target.riverExposure * target.area + unit.riverExposure * unit.area) / totalArea; - target.area = totalArea; - unit.area = 0; - unit.cells = []; - } -} - -function naturalOwnershipAffinity(unit, neighbor, edge) { - const boundaryTarget = edge.target / Math.max(1, edge.count); - 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; -} - -function compartmentCrossingCost(unit, neighbor, edge) { - const boundaryScore = edge.target / Math.max(1, edge.count); - const sameClass = unit.classId === neighbor.classId ? 1 : 0; - const sameGroup = naturalGroupKey(unit) === naturalGroupKey(neighbor) ? 1 : 0; - const lowlandContinuity = Math.min(unit.lowlandFitness || 0, neighbor.lowlandFitness || 0); - const urbanContinuity = Math.min(unit.urbanWeight || 0, neighbor.urbanWeight || 0); - const mountainPenalty = Math.max(unit.mountainFitness || 0, neighbor.mountainFitness || 0); - const ridgePenalty = Math.max(unit.ridgeExposure || 0, neighbor.ridgeExposure || 0); - return Math.max(0.18, - 1.0 + - boundaryScore * 5.2 + - mountainPenalty * 1.8 + - ridgePenalty * 0.9 - - sameClass * 0.45 - - sameGroup * 0.35 - - lowlandContinuity * 1.15 - - urbanContinuity * 0.70 - - Math.min(1.0, edge.count / 12) * 0.25 - ); -} - -function graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options = {}) { - const owner = new Int16Array(compartments.length); - const dist = new Float32Array(compartments.length); - owner.fill(-1); - dist.fill(INF); - const heap = new MinHeap(); - for (let id = 0; id < adminCenters.length; id++) { - const center = adminCenters[id]; - if (!center || !inside(center.x, center.y)) continue; - const compIndex = compartmentId[indexOf(center.x, center.y)]; - const unit = compartments[compIndex]; - if (compIndex < 0 || !unit || unit.area === 0) continue; - unit.centerIds.push(id); - if (dist[compIndex] > 0) { - dist[compIndex] = 0; - owner[compIndex] = id; - heap.push({ i: compIndex, f: 0, owner: id }); - } - } - while (heap.length > 0) { - const cur = heap.pop(); - if (!cur || cur.f > dist[cur.i] + 1e-5) continue; - const unit = compartments[cur.i]; - if (!unit || unit.area === 0) continue; - const center = adminCenters[cur.owner]; - for (const [neighborId, edge] of unit.adjacent) { - const neighbor = compartments[neighborId]; - if (!neighbor || neighbor.area === 0) continue; - const crossing = compartmentCrossingCost(unit, neighbor, edge); - const euclideanTie = center ? Math.hypot(neighbor.x - center.x, neighbor.y - center.y) * 0.006 : 0; - const hinterlandDrag = (neighbor.mountainFitness || 0) > 0.64 && (neighbor.population || 0) < 6 ? 0.35 : 0; - const next = cur.f + crossing + euclideanTie + hinterlandDrag; - if (next + 1e-5 < dist[neighborId]) { - dist[neighborId] = next; - owner[neighborId] = cur.owner; - heap.push({ i: neighborId, f: next, owner: cur.owner }); - } else if (Math.abs(next - dist[neighborId]) < 0.08 && owner[neighborId] >= 0) { - const oldCenter = adminCenters[owner[neighborId]]; - const oldD = oldCenter ? Math.hypot(neighbor.x - oldCenter.x, neighbor.y - oldCenter.y) : INF; - const newD = center ? Math.hypot(neighbor.x - center.x, neighbor.y - center.y) : INF; - if (newD < oldD - 1.5 || (newD < oldD + 1.5 && cur.owner < owner[neighborId])) owner[neighborId] = cur.owner; - } - } - } - for (const unit of compartments) { - if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue; - let bestOwner = -1, bestScore = INF; - for (const [neighborId, edge] of unit.adjacent) { - if (owner[neighborId] < 0) continue; - const neighbor = compartments[neighborId]; - const score = compartmentCrossingCost(unit, neighbor, edge) + (neighbor?.area || 0) * -0.001; - if (score < bestScore) { bestScore = score; bestOwner = owner[neighborId]; } - } - owner[unit.id] = bestOwner >= 0 ? bestOwner : 0; - } - return owner; -} - -function compartmentMunicipalityMetrics(compartments, owner, targetMunicipalityCount = 0, targetCompartmentCount = 0) { - const counts = new Map(); - let active = 0; - for (const unit of compartments) { - if (!unit || unit.area === 0) continue; - active++; - const id = owner[unit.id]; - if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); - } - const actual = counts.size; - const singles = [...counts.values()].filter((value) => value === 1).length; - return { - targetMunicipalityCount, - actualMunicipalityCount: actual, - targetNaturalCompartmentCount: targetCompartmentCount, - naturalCompartmentCount: active, - compartmentCount: active, - averageCompartmentsPerMunicipality: actual ? active / actual : 0, - singleCompartmentMunicipalityRatio: actual ? singles / actual : 0, - }; -} - -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++; - } - } - } - return count ? sum / count : 0; -} - -function assignCompartmentsToAdminOwners(compartments, compartmentId, adminCenters, naturalBarrierScore, prefectureMask, sea) { - const owner = new Int16Array(compartments.length); - owner.fill(-1); - for (let id = 0; id < adminCenters.length; id++) { - const center = adminCenters[id]; - if (!center || !inside(center.x, center.y)) continue; - const compIndex = compartmentId[indexOf(center.x, center.y)]; - if (compIndex >= 0 && compartments[compIndex]?.area > 0) { - const unit = compartments[compIndex]; - unit.centerIds.push(id); - owner[compIndex] = id; - } - } - - for (let pass = 0; pass < compartments.length + 8; 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 center = adminCenters[neighborOwner]; - const d = center ? Math.hypot(unit.x - center.x, unit.y - center.y) : 0; - const score = naturalOwnershipAffinity(unit, neighbor, edge) - d * 0.006 + Math.min(0.9, Math.sqrt(Math.max(1, neighbor.area)) * 0.020); - if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; } - } - const accept = unit.classId <= 3 ? bestScore > -0.35 : unit.classId === 8 || unit.classId === 9 ? bestScore > -1.05 : bestScore > -0.70; - 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; - let 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.3 : 0; - const sameClass = centerComp && centerComp.classId === unit.classId ? 0.8 : 0; - const urbanFit = unit.urbanWeight > 0.55 && centerComp?.urbanWeight > 0.55 ? 1.3 : 0; - const d = Math.hypot(unit.x - center.x, unit.y - center.y); - const score = sameGroup + sameClass + urbanFit - d * 0.020 - unit.ridgeExposure * 0.16; - if (score > bestScore) { bestScore = score; bestId = id; } - } - owner[unit.id] = bestId >= 0 ? bestId : 0; - } - return owner; -} - -export function extractCompartmentBorders(compartmentId, prefectureMask, 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 (!prefectureMask[i] || sea[i] || compartmentId[i] < 0) continue; - const a = compartmentId[i]; - if (x + 1 < MAP_W) { - const ni = indexOf(x + 1, y); - if (prefectureMask[ni] && !sea[ni] && compartmentId[ni] >= 0 && compartmentId[ni] !== a) segments.push([[x + 1, y], [x + 1, y + 1]]); - } - if (y + 1 < MAP_H) { - const ni = indexOf(x, y + 1); - if (prefectureMask[ni] && !sea[ni] && compartmentId[ni] >= 0 && compartmentId[ni] !== a) segments.push([[x, y + 1], [x + 1, y + 1]]); - } - } - } - return segments; -} - -export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], options = {}) { - const progress = typeof options.progress === "function" ? options.progress : null; - const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse, options); - progress?.("natural compartments built"); - const adminId = new Int16Array(SIZE); - adminId.fill(-1); - const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options); - progress?.("natural compartments assigned"); - for (const unit of compartments) { - const assigned = owner[unit.id]; - if (assigned < 0) continue; - 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; - } - repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse); - progress?.("natural topology repaired"); - const activeCompartments = compartments.filter((unit) => unit.area > 0); - const relationMetrics = compartmentMunicipalityMetrics(compartments, owner, options.targetMunicipalityCount || adminCenters.length, options.targetCompartmentCount || 0); - return { - adminId, - compartmentId, - compartments, - naturalBarrierScore, - debug: { - ...relationMetrics, - compartmentBorders: extractCompartmentBorders(compartmentId, prefectureMask, sea), - averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0, - maxCompartmentArea: activeCompartments.length ? Math.max(...activeCompartments.map((unit) => unit.area || 0)) : 0, - maxCompartmentElongation: activeCompartments.length ? Math.max(...activeCompartments.map((unit) => unit.elongation || 1)) : 1, - worstNaturalCompartments: activeCompartments - .map((unit) => ({ id: unit.id, area: unit.area || 0, width: unit.width || 0, height: unit.height || 0, elongation: unit.elongation || 1, classId: unit.classId, x: Math.round(unit.x || 0), y: Math.round(unit.y || 0) })) - .sort((a, b) => (b.elongation * Math.sqrt(Math.max(1, b.area))) - (a.elongation * Math.sqrt(Math.max(1, a.area)))) - .slice(0, 8), - finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore), - voronoiLikeRateBefore: 0, - voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore), - }, - }; -} - -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; - for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) { - if (Math.hypot(dx, dy) > 2) continue; - const x = center.x + dx, y = center.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (prefectureMask[i] && !sea[i]) adminId[i] = id; - } - } - 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) { - const targetScore = new Float32Array(SIZE); - for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) targetScore[i] = terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse); - const protectedMask = buildAdminProtectedMask(adminId, prefectureMask, sea, adminCenters, protectedPoints, populationDensity, landuse); - const band = buildBoundaryBand(adminId, prefectureMask, sea, 5); - const adminIds = [...new Set([...adminId].filter((id) => id >= 0))]; - const centerDist = buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea); - let current = new Int16Array(adminId); - - for (let pass = 0; pass < passes; pass++) { - const next = new Int16Array(current); - let changed = 0; - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - const own = current[i]; - if (!band[i] || protectedMask[i] || !prefectureMask[i] || sea[i] || own < 0) continue; - if (!isAdminBoundaryCell(current, prefectureMask, sea, x, y, true)) continue; - const candidates = new Set(); - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - if (prefectureMask[ni] && !sea[ni] && current[ni] >= 0 && current[ni] !== own) candidates.add(current[ni]); - } - if (candidates.size === 0) continue; - const currentEnergy = localBoundaryEnergy(current, i, own, targetScore, centerDist, populationDensity, landuse, river, valleyField); - let bestId = own, bestEnergy = currentEnergy; - for (const candidate of candidates) { - const candidateEnergy = localBoundaryEnergy(current, i, candidate, targetScore, centerDist, populationDensity, landuse, river, valleyField); - const threshold = 0.18 + (targetScore[i] < 0.36 ? 0.16 : 0) + urbanBoundaryPenalty(i, populationDensity, landuse) * 0.25; - if (bestEnergy - candidateEnergy > threshold) { bestEnergy = candidateEnergy; bestId = candidate; } - } - if (bestId !== own) { next[i] = bestId; changed++; } - } - } - current = next; - if (changed === 0) break; - } - 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; - let rejectedMunicipalities = 0; - for (const [id, cells] of area) { - const averageLowland = (lowland.get(id) || 0) / cells; - const averageMountain = (mountain.get(id) || 0) / cells; - if (cells < median * 1.85 || averageLowland < 0.24 || averageMountain > 0.48) { - if (cells >= median * 1.85) rejectedMunicipalities++; - 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) { - rejectedMunicipalities++; - 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, rejectedMunicipalities }; -} - -export function splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], settlements = []) { - return splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters, settlements); -} +export * from "./adminRegionsCore.js"; diff --git a/adminRegions.notrace.js b/adminRegionsCore.js similarity index 97% rename from adminRegions.notrace.js rename to adminRegionsCore.js index 877c1cf..15f66a8 100644 --- a/adminRegions.notrace.js +++ b/adminRegionsCore.js @@ -409,25 +409,31 @@ function localBoundaryEnergy(labels, i, candidateId, targetScore, centerDist, po if (same4 === 1) energy += 1.7; if (diff4 >= 3 && targetScore[i] < 0.42) energy += 1.25; if (diagDiff >= 3 && diff4 >= 2 && targetScore[i] < 0.50) energy += 0.42; - if (candidateId !== oldId && centerDist[candidateId] && centerDist[oldId]) { - const drift = centerDist[candidateId][i] - centerDist[oldId][i]; - if (drift > 0) energy += Math.min(0.9, drift * 0.012); + if (candidateId !== oldId) { + const candidateDistance = centerDistanceAt(centerDist, candidateId, i); + const oldDistance = centerDistanceAt(centerDist, oldId, i); + if (Number.isFinite(candidateDistance) && Number.isFinite(oldDistance)) { + const drift = candidateDistance - oldDistance; + if (drift > 0) energy += Math.min(0.9, drift * 0.012); + } } return energy; } +function centerDistanceAt(centerDist, id, i) { + const field = centerDist?.fields?.[id] || centerDist?.[id]; + if (field) return field[i]; + const center = centerDist?.centers?.[id]; + if (!center || !inside(center.x, center.y)) return 24; + const [x, y] = xyOf(i); + return Math.hypot(x - center.x, y - center.y); +} + function buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea) { - const fields = []; - for (const id of adminIds) { - const center = adminCenters[id]; - const field = new Float32Array(SIZE); - if (!center || !inside(center.x, center.y) || sea[indexOf(center.x, center.y)] || !prefectureMask[indexOf(center.x, center.y)]) field.fill(24); - else { - for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) field[indexOf(x, y)] = Math.hypot(x - center.x, y - center.y); - } - fields[id] = field; - } - return fields; + // Older versions materialized one full SIZE Float32Array per municipality. + // In multi-prefecture generation this can create heavy transient memory use. + // Keep the same interface conceptually, but compute distances on demand. + return { ids: adminIds, centers: adminCenters, prefectureMask, sea }; } function repairAdminTopology(adminId, prefectureMask, sea, adminCenters = [], targetScore = null, populationDensity = null, landuse = null) { @@ -981,6 +987,7 @@ function splitNaturalCompartmentCompact(unit, newId, compartmentId, fields, seed } function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) { + const progress = typeof options.progress === "function" ? options.progress : null; const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse); const cellClass = new Int16Array(SIZE); cellClass.fill(-1); @@ -991,6 +998,7 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r const requestedTarget = options.targetCompartmentCount || clamp(Math.round(landArea / 34), 40, 360); const targetCount = clamp(Math.round(requestedTarget), Math.min(1, landArea), Math.max(1, Math.floor(landArea / 8))); const { seeds } = chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, options.seed || 0); + progress?.(`natural seeds chosen: ${seeds.length}/${targetCount}`); const compartmentId = new Int32Array(SIZE); compartmentId.fill(-1); const dist = new Float32Array(SIZE); @@ -1017,22 +1025,25 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r } } for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] < 0) compartmentId[i] = 0; + progress?.("natural seeded growth complete"); let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields); rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); mergeTinyLandscapeUnits(compartmentId, compartments, 9); + progress?.(`natural post-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`); splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); refreshAllCompartmentStats(compartments, fields); rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + progress?.(`natural pre-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`); const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55)); - let guard = Math.max(80, targetCount * 3); + let guard = Math.max(60, targetCount * 2); while (guard-- > 0) { let active = compartments.filter((unit) => unit && unit.area > 0); const needMore = active.length < targetCount; const worst = active - .filter((unit) => unit.area >= 20 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2)) + .filter((unit) => unit.area >= 20 && (unit._splitRejected || 0) < 3 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2)) .sort((a, b) => { const sa = (a.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (a.elongation || 1) - 3.0) * 1.2; const sb = (b.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (b.elongation || 1) - 3.0) * 1.2; @@ -1490,10 +1501,13 @@ export function extractCompartmentBorders(compartmentId, prefectureMask, sea) { } export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], options = {}) { + const progress = typeof options.progress === "function" ? options.progress : null; const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse, options); + progress?.("natural compartments built"); const adminId = new Int16Array(SIZE); adminId.fill(-1); const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options); + progress?.("natural compartments assigned"); for (const unit of compartments) { const assigned = owner[unit.id]; if (assigned < 0) continue; @@ -1505,6 +1519,7 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e adminId[i] = comp && owner[comp.id] >= 0 ? owner[comp.id] : 0; } repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse); + progress?.("natural topology repaired"); const activeCompartments = compartments.filter((unit) => unit.area > 0); const relationMetrics = compartmentMunicipalityMetrics(compartments, owner, options.targetMunicipalityCount || adminCenters.length, options.targetCompartmentCount || 0); return { diff --git a/index.html b/index.html index b968616..f80d45e 100644 --- a/index.html +++ b/index.html @@ -81,6 +81,7 @@
Notes

Open index.html with Live Server. Open test.html to run browser tests.

+

Add preferred reusable place names in CUSTOM_NAME_LIST inside names.js.

diff --git a/mapAdminStage.js b/mapAdminStage.js index 3135050..83e6785 100644 --- a/mapAdminStage.js +++ b/mapAdminStage.js @@ -950,6 +950,104 @@ function maskLandArea(mask, sea) { return area; } +function connectedMaskComponents(mask, sea, minArea = 1) { + const seen = new Uint8Array(SIZE); + const components = []; + for (let i = 0; i < SIZE; i++) { + if (!mask[i] || sea[i] || seen[i]) continue; + const queue = [i]; + const cells = []; + seen[i] = 1; + let head = 0; + while (head < queue.length) { + const cur = queue[head++]; + cells.push(cur); + const [x, y] = xyOf(cur); + 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)) continue; + const ni = indexOf(nx, ny); + if (!mask[ni] || sea[ni] || seen[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + if (cells.length >= minArea) { + let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0; + for (const cell of cells) { + const [x, y] = xyOf(cell); + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + components.push({ cells, area: cells.length, minX, minY, maxX, maxY }); + } + } + return components.sort((a, b) => b.area - a.area); +} + +function maskFromCells(cells) { + const mask = new Uint8Array(SIZE); + for (const i of cells || []) mask[i] = 1; + return mask; +} + + +function splitDisconnectedAdminComponents(adminId, humanMask, sea, centers = [], fields = {}) { + let nextId = Math.max(-1, ...adminId) + 1; + let splitCount = 0; + const ids = [...new Set([...adminId].filter((id, i) => id >= 0 && humanMask[i] && !sea[i]))]; + for (const id of ids) { + const seen = new Uint8Array(SIZE); + const components = []; + for (let i = 0; i < SIZE; i++) { + if (adminId[i] !== id || !humanMask[i] || sea[i] || seen[i]) continue; + const cells = []; + const queue = [i]; + seen[i] = 1; + let head = 0; + while (head < queue.length) { + const cur = queue[head++]; + cells.push(cur); + const [x, y] = xyOf(cur); + 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)) continue; + const ni = indexOf(nx, ny); + if (adminId[ni] !== id || !humanMask[ni] || sea[ni] || seen[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + components.push(cells); + } + if (components.length <= 1) continue; + components.sort((a, b) => b.length - a.length); + for (let c = 1; c < components.length; c++) { + const newId = nextId++; + let bestI = components[c][0]; + let bestScore = -INF; + for (const i of components[c]) { + const score = + (fields.populationDensity?.[i] || 0) * 4.0 + + (fields.stationInfluence?.[i] || 0) * 0.8 + + (fields.roadInfluence?.[i] || 0) * 0.45 + + (fields.settlementScore?.[i] || 0) * 0.6 + + (fields.plain?.[i] || 0) * 0.2 - + (fields.slope?.[i] || 0) * 0.2; + if (score > bestScore) { bestScore = score; bestI = i; } + } + const [x, y] = xyOf(bestI); + for (const i of components[c]) adminId[i] = newId; + centers[newId] = { ...(centers[id] || {}), x, y, score: bestScore, seedKind: "splitDisconnectedMunicipality", generatedOfficePoint: true, municipalityOffice: true }; + splitCount++; + } + } + return { adminId, centers, splitDisconnectedMunicipalityCount: splitCount }; +} function compactAdminIdsAndCenters(adminId, humanMask, sea, centers = [], fields = {}) { const activeIds = [...new Set([...adminId].filter((id, i) => id >= 0 && humanMask[i] && !sea[i]))].sort((a, b) => a - b); @@ -968,11 +1066,12 @@ function compactAdminIdsAndCenters(adminId, humanMask, sea, centers = [], fields const chooseOffice = (newId, oldId) => { const cells = cellsByNewId[newId] || []; const current = centers[oldId]; + let currentValid = false; + let currentScore = -INF; if (current && inside(current.x, current.y)) { const ci = indexOf(current.x, current.y); - if (newAdminId[ci] === newId && humanMask[ci] && !sea[ci]) { - return { ...current, localAdminId: newId, oldAdminId: oldId, municipalityOffice: true }; - } + currentValid = newAdminId[ci] === newId && humanMask[ci] && !sea[ci]; + if (currentValid) currentScore = (fields.populationDensity?.[ci] || 0) + (fields.stationInfluence?.[ci] || 0) * 0.32 + (fields.roadInfluence?.[ci] || 0) * 0.20; } let sx = 0, sy = 0; for (const i of cells) { @@ -991,19 +1090,20 @@ function compactAdminIdsAndCenters(adminId, humanMask, sea, centers = [], fields const density = fields.populationDensity?.[i] || 0; const settlement = fields.settlementScore?.[i] || 0; const score = - density * 2.25 + - settlement * 0.75 + - urbanBonus + + density * 4.20 + + settlement * 0.72 + + urbanBonus * 1.15 + (fields.plain?.[i] || 0) * 0.32 + (fields.basinField?.[i] || 0) * 0.24 + (fields.coastalLowland?.[i] || 0) * 0.18 + - (fields.roadInfluence?.[i] || 0) * 0.34 + - (fields.stationInfluence?.[i] || 0) * 0.45 - + (fields.roadInfluence?.[i] || 0) * 0.48 + + (fields.stationInfluence?.[i] || 0) * 0.86 - (fields.slope?.[i] || 0) * 0.52 - Math.hypot(x - cx, y - cy) * 0.018 + hash2(x, y, 91337 + newId) * 0.012; if (score > bestScore) { bestScore = score; bestI = i; } } + if (currentValid && currentScore >= bestScore * 0.92 && (fields.populationDensity?.[indexOf(current.x, current.y)] || 0) >= 0.16) bestI = indexOf(current.x, current.y); const [bx, by] = bestI >= 0 ? xyOf(bestI) : [Math.round(cx), Math.round(cy)]; return { ...(current || {}), @@ -1041,11 +1141,17 @@ function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) { export function generateAdminLayout(context) { const { prefectureMask, prefectureRegionId, sea, populationDensity, plain, slope, adminProgress } = context; - const minFullAdminRegionArea = 1500; - const regionIds = discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) - .filter((regionId) => regionId !== OUTER_ANCHOR_REGION_ID && maskLandArea(buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId), sea) >= minFullAdminRegionArea); + const minFullAdminRegionArea = 650; + const minComponentArea = 18; + const regionComponents = []; + for (const regionId of discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea)) { + const baseMask = buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId); + const components = connectedMaskComponents(baseMask, sea, minComponentArea); + components.forEach((component, componentIndex) => regionComponents.push({ regionId, componentIndex, component, area: component.area })); + } + const fullComponents = regionComponents.filter((row) => row.area >= minFullAdminRegionArea); - if (!prefectureRegionId || regionIds.length <= 1) return generateAdminLayoutForMask(context); + if (!prefectureRegionId || fullComponents.length <= 1) return generateAdminLayoutForMask(context); let combinedAdminId = new Int16Array(SIZE); combinedAdminId.fill(-1); @@ -1055,14 +1161,15 @@ export function generateAdminLayout(context) { const perRegion = []; let idOffset = 0; - for (const regionId of regionIds) { - const regionMask = buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId); - const regionArea = maskLandArea(regionMask, sea); - if (regionArea < minFullAdminRegionArea) continue; + const processedCells = new Uint8Array(SIZE); + for (const row of fullComponents) { + const { regionId, componentIndex, component } = row; + const regionMask = maskFromCells(component.cells); + const regionArea = component.area; const localContext = { ...context, - seed: (context.seed + regionId * 10007) >>> 0, + seed: (context.seed + regionId * 10007 + componentIndex * 9973) >>> 0, prefectureMask: regionMask, modernCities: filterPointsForMask(context.modernCities, regionMask, sea), satelliteCities: filterPointsForMask(context.satelliteCities, regionMask, sea), @@ -1074,9 +1181,11 @@ export function generateAdminLayout(context) { industrialZones: filterPointsForMask(context.industrialZones, regionMask, sea), logisticsParks: filterPointsForMask(context.logisticsParks, regionMask, sea), adminRegionMeta: { - regionId, + regionId: `${regionId}:${componentIndex}`, + sourceRegionId: regionId, + componentIndex, landArea: regionArea, - isFocusedRegion: true, + isFocusedRegion: regionId === 0, isOuterAnchorRegion: regionId === OUTER_ANCHOR_REGION_ID, }, adminProgress, @@ -1121,12 +1230,14 @@ export function generateAdminLayout(context) { for (let i = 0; i < SIZE; i++) { if (!regionMask[i] || sea[i]) continue; combinedHumanMask[i] = 1; + processedCells[i] = 1; const localId = local.adminId?.[i] ?? -1; if (localId >= 0) combinedAdminId[i] = localId + idOffset; } perRegion.push({ regionId, + componentIndex, area: regionArea, centerCount: localCenters.length, municipalityCount: local.adminDebug?.finalMunicipalityCount || local.adminDebug?.actualMunicipalityCount || new Set([...local.adminId].filter((id, i) => id >= 0 && regionMask[i] && !sea[i])).size, @@ -1141,34 +1252,29 @@ export function generateAdminLayout(context) { idOffset += localSlotCount; } - const leftoverByRegion = new Map(); - for (let i = 0; i < SIZE; i++) { - if (sea[i]) continue; - const regionId = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId); - if ((regionId < 0 && regionId !== OUTER_ANCHOR_REGION_ID) || combinedAdminId[i] >= 0) continue; - if (!leftoverByRegion.has(regionId)) leftoverByRegion.set(regionId, []); - leftoverByRegion.get(regionId).push(i); + const leftoverRows = []; + for (const row of regionComponents) { + const cells = row.component.cells.filter((i) => !sea[i] && combinedAdminId[i] < 0); + if (cells.length) leftoverRows.push({ ...row, cells }); } - for (const [regionId, cells] of leftoverByRegion) { - let sx = 0, sy = 0, bestI = cells[0], bestScore = -INF; + for (const row of leftoverRows) { + const { regionId, componentIndex, cells } = row; + let bestI = cells[0], bestScore = -INF; for (const i of cells) { - const [x, y] = xyOf(i); - sx += x; - sy += y; - const score = (populationDensity?.[i] || 0) + (plain?.[i] || 0) * 0.2 - (slope?.[i] || 0) * 0.2; + const score = (populationDensity?.[i] || 0) * 2.6 + (plain?.[i] || 0) * 0.22 - (slope?.[i] || 0) * 0.20; if (score > bestScore) { bestScore = score; bestI = i; } } const [cx, cy] = xyOf(bestI); const id = combinedCenters.length; - combinedCenters.push({ x: cx, y: cy, score: bestScore, regionId, localAdminId: 0, seedKind: "tinyRegionAdminSeed", invisibleLowlandAdminSeed: true }); + combinedCenters.push({ x: cx, y: cy, score: bestScore, regionId, componentIndex, localAdminId: 0, seedKind: "tinyRegionAdminSeed", invisibleLowlandAdminSeed: true }); for (const i of cells) { combinedHumanMask[i] = 1; combinedAdminId[i] = id; } - perRegion.push({ regionId, area: cells.length, centerCount: 1, municipalityCount: 1, naturalCompartmentCount: 1, targetNaturalCompartmentCount: 1, averageCompartmentArea: cells.length, maxCompartmentArea: cells.length, maxCompartmentElongation: 1, singleCompartmentMunicipalityRatio: 1, tinyRegionFallback: true }); + perRegion.push({ regionId, componentIndex, area: cells.length, centerCount: 1, municipalityCount: 1, naturalCompartmentCount: 1, targetNaturalCompartmentCount: 1, averageCompartmentArea: cells.length, maxCompartmentArea: cells.length, maxCompartmentElongation: 1, singleCompartmentMunicipalityRatio: 1, tinyRegionFallback: true }); } - const compactedAdmin = compactAdminIdsAndCenters(combinedAdminId, combinedHumanMask, sea, combinedCenters, { + const compactFields = { populationDensity, plain, slope, @@ -1178,7 +1284,14 @@ export function generateAdminLayout(context) { coastalLowland: context.coastalLowland, roadInfluence: context.roadInfluence, stationInfluence: context.stationInfluence, - }); + }; + let compactedAdmin = compactAdminIdsAndCenters(combinedAdminId, combinedHumanMask, sea, combinedCenters, compactFields); + combinedAdminId = compactedAdmin.adminId; + combinedCenters = compactedAdmin.adminCenters; + const splitDisconnected = splitDisconnectedAdminComponents(combinedAdminId, combinedHumanMask, sea, combinedCenters, compactFields); + combinedAdminId = splitDisconnected.adminId; + combinedCenters = splitDisconnected.centers; + compactedAdmin = compactAdminIdsAndCenters(combinedAdminId, combinedHumanMask, sea, combinedCenters, compactFields); combinedAdminId = compactedAdmin.adminId; combinedCenters = compactedAdmin.adminCenters; const adminBorders = extractAdminBorderSegments(combinedAdminId, combinedHumanMask); @@ -1191,6 +1304,11 @@ export function generateAdminLayout(context) { multiRegionAdmin: true, adminRegionCount: perRegion.length, minFullAdminRegionArea, + minComponentArea, + connectedComponentAdmin: true, + fullComponentCount: fullComponents.length, + leftoverComponentCount: leftoverRows.length, + splitDisconnectedMunicipalityCount: splitDisconnected.splitDisconnectedMunicipalityCount, perRegion, finalMunicipalityCount: totalMunicipalityCount, actualMunicipalityCount: totalMunicipalityCount, diff --git a/mapAdminStage.nolog.js b/mapAdminStage.nolog.js deleted file mode 100644 index 6a9730a..0000000 --- a/mapAdminStage.nolog.js +++ /dev/null @@ -1,1064 +0,0 @@ -import { - applyLandscapeUnitAdminPartition, - assignAdminRegionsFromNaturalCompartments, - lockSmallUrbanComponentsToMunicipality, - mergeTinyMunicipalities, - removeMunicipalExclaves, - smoothAdminRegionsTerrainAware, - splitOversizedLowlandMunicipalities, - 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"; - -const OUTER_ANCHOR_REGION_ID = -2; - -function adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId) { - if (prefectureMask[i]) return 0; - const regionalId = prefectureRegionId?.[i] ?? -1; - if (regionalId === 0) return OUTER_ANCHOR_REGION_ID; - return regionalId; -} - -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 isProtectedAdminSeed(seed) { - if (!seed) return false; - if (seed.seedKind === "capital" || seed.protectedCity?.isPrefecturalCapital) return true; - if (seed.seedKind === "modernCity" && (seed.protectedCity?.population || seed.population || 0) >= 180000) return true; - if (seed.seedKind === "port" && seed.portClass === "major") return true; - if (seed.seedKind === "satelliteCity" && (seed.protectedSatellite?.population || seed.population || 0) >= 60000) return true; - return false; -} - -function buildSeedLifecycle(adminCenters, adminId, prefectureMask, sea, minArea = 80) { - const areaById = municipalityAreaById(adminId, prefectureMask, sea); - const lifecycle = adminCenters.map((center, id) => { - const protectedSeed = isProtectedAdminSeed(center); - const area = areaById.get(id) || 0; - const enoughArea = area >= (protectedSeed ? 28 : minArea); - return { - id, - protected: protectedSeed, - area, - state: enoughArea || protectedSeed ? "survived" : "pending", - }; - }); - return lifecycle; -} - -function activeSeedIds(seedLifecycle) { - return new Set(seedLifecycle.filter((seed) => seed.state === "survived" || seed.protected).map((seed) => seed.id)); -} - -function dominantCompartmentOwners(compartments, adminId) { - const owner = new Int16Array(compartments.length); - owner.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; } - owner[unit.id] = bestId; - } - return owner; -} - -function applyCompartmentOwners(adminId, compartments, owner) { - for (const unit of compartments) { - if (!unit || unit.area === 0) continue; - const id = owner[unit.id]; - if (id < 0) continue; - for (const i of unit.cells) adminId[i] = id; - } -} - -function absorbSeedCompartments(adminId, compartments, seedLifecycle) { - const owner = dominantCompartmentOwners(compartments, adminId); - const absorbed = new Set(seedLifecycle.filter((seed) => seed.state === "absorbed").map((seed) => seed.id)); - let changed = 0; - for (const unit of compartments) { - if (!unit || unit.area === 0 || !absorbed.has(owner[unit.id])) continue; - let bestId = -1, bestScore = -INF; - for (const [neighborId, edge] of unit.adjacent) { - const candidate = owner[neighborId]; - if (candidate < 0 || absorbed.has(candidate)) continue; - const neighbor = compartments[neighborId]; - const score = edge.count * 2 - (edge.target / Math.max(1, edge.count)) * 2 + (neighbor?.area || 0) * 0.002; - if (score > bestScore) { bestScore = score; bestId = candidate; } - } - if (bestId < 0) continue; - owner[unit.id] = bestId; - changed += unit.area; - } - applyCompartmentOwners(adminId, compartments, owner); - return changed; -} - -function splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, settlements = []) { - const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields; - const areaById = municipalityAreaById(adminId, prefectureMask, sea); - const areas = [...areaById.values()].sort((a, b) => a - b); - const median = areas.length ? areas[Math.floor(areas.length / 2)] : 0; - if (!median) return { changedCells: 0, splitMunicipalities: 0, pendingSeedsUsed: 0 }; - const owner = dominantCompartmentOwners(compartments, adminId); - const unitsByOwner = new Map(); - for (const unit of compartments) { - if (!unit || unit.area === 0 || owner[unit.id] < 0) continue; - if (!unitsByOwner.has(owner[unit.id])) unitsByOwner.set(owner[unit.id], []); - unitsByOwner.get(owner[unit.id]).push(unit); - } - const pending = seedLifecycle.filter((seed) => seed.state === "pending" && !seed.protected); - let changedCells = 0; - let splitMunicipalities = 0; - let pendingSeedsUsed = 0; - for (const [id, units] of unitsByOwner) { - const area = areaById.get(id) || 0; - if (area < Math.max(260, median * 1.45) || units.length < 6) continue; - let lowland = 0, rough = 0; - for (const unit of units) { - for (const i of unit.cells) { - lowland += (plain[i] || 0) * 0.38 + (agriculture[i] || 0) * 0.20 + basinField[i] * 0.22 + coastalLowland[i] * 0.22 + valleyField[i] * 0.10; - rough += ridgeField[i] * 0.48 + slope[i] * 0.34 + Math.max(0, elevation[i] - 0.58) * 0.30; - } - } - if (lowland / area < 0.26 || rough / area > 0.48) continue; - const localPending = pending.filter((seed) => { - const center = adminCenters[seed.id]; - if (!center || !inside(center.x, center.y)) return false; - const centerOwner = adminId[indexOf(center.x, center.y)]; - return centerOwner === id || Math.hypot(center.x - (adminCenters[id]?.x || center.x), center.y - (adminCenters[id]?.y || center.y)) < 28; - }); - const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id); - if (localPending.length === 0 || localPending.length + localSettlements.length < 2) continue; - let municipalitySplit = false; - for (const seed of localPending.slice(0, 3)) { - const center = adminCenters[seed.id]; - if (!center) continue; - const targetArea = clamp(95 + (center.score || 0.5) * 60, 90, 180); - let claimed = 0; - const candidates = units - .filter((unit) => owner[unit.id] === id && unit.classId !== 8 && unit.classId !== 9) - .map((unit) => ({ - unit, - score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 8 - (unit.urbanWeight || 0) * 3, - })) - .sort((a, b) => a.score - b.score); - if (candidates.length < 2) continue; - for (const { unit } of candidates) { - if (claimed >= targetArea && claimed >= 2) break; - owner[unit.id] = seed.id; - claimed += unit.area; - changedCells += unit.area; - } - if (claimed >= 45) { - seed.state = "survived"; - seed.area = claimed; - pendingSeedsUsed++; - municipalitySplit = true; - } - } - if (municipalitySplit) splitMunicipalities++; - } - applyCompartmentOwners(adminId, compartments, owner); - return { changedCells, splitMunicipalities, pendingSeedsUsed }; -} - -function promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, targetMinCount = 20) { - const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields; - let areaById = municipalityAreaById(adminId, prefectureMask, sea); - let currentCount = areaById.size; - if (currentCount >= targetMinCount) return { changedCells: 0, promotedSeeds: 0 }; - const owner = dominantCompartmentOwners(compartments, adminId); - let changedCells = 0; - let promotedSeeds = 0; - const pending = seedLifecycle - .filter((seed) => seed.state === "pending" && !seed.protected) - .sort((a, b) => (adminCenters[b.id]?.score || 0) - (adminCenters[a.id]?.score || 0)); - for (const seed of pending) { - if (currentCount >= targetMinCount) break; - const center = adminCenters[seed.id]; - if (!center || !inside(center.x, center.y)) continue; - const existingArea = areaById.get(seed.id) || 0; - if (existingArea >= 12) { - seed.state = "survived"; - seed.area = existingArea; - promotedSeeds++; - continue; - } - const candidates = compartments - .filter((unit) => { - if (!unit || unit.area === 0) return false; - const currentOwner = owner[unit.id]; - if (currentOwner < 0 || currentOwner === seed.id) return false; - const ownerArea = areaById.get(currentOwner) || 0; - if (ownerArea < 90) return false; - const lowlandFit = (unit.lowlandFitness || 0) + (unit.basinIdentity || 0) * 0.15 + (unit.coastalExposure || 0) * 0.15; - if (lowlandFit < 0.26) return false; - return Math.hypot(unit.x - center.x, unit.y - center.y) < 36; - }) - .map((unit) => ({ - unit, - score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2, - })) - .sort((a, b) => a.score - b.score); - if (candidates.length === 0) continue; - let claimed = 0; - for (const { unit } of candidates) { - const currentOwner = owner[unit.id]; - if ((areaById.get(currentOwner) || 0) - unit.area < 70) continue; - owner[unit.id] = seed.id; - areaById.set(currentOwner, (areaById.get(currentOwner) || 0) - unit.area); - areaById.set(seed.id, (areaById.get(seed.id) || 0) + unit.area); - claimed += unit.area; - changedCells += unit.area; - if (claimed >= 55) break; - } - if (claimed >= 25) { - seed.state = "survived"; - seed.area = areaById.get(seed.id) || claimed; - promotedSeeds++; - currentCount++; - } - } - applyCompartmentOwners(adminId, compartments, owner); - return { changedCells, promotedSeeds }; -} - -function restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compartments, adminCenters, seedLifecycle, targetMinCount = 20) { - const areaById = municipalityAreaById(adminId, prefectureMask, sea); - let currentCount = areaById.size; - if (currentCount >= targetMinCount) return { changedCells: 0, restoredSeeds: 0 }; - const owner = dominantCompartmentOwners(compartments, adminId); - let changedCells = 0; - let restoredSeeds = 0; - const missing = seedLifecycle - .filter((seed) => (seed.state === "survived" || seed.protected) && (areaById.get(seed.id) || 0) === 0) - .sort((a, b) => (b.protected ? 1 : 0) - (a.protected ? 1 : 0)); - for (const seed of missing) { - if (currentCount >= targetMinCount) break; - const center = adminCenters[seed.id]; - if (!center || !inside(center.x, center.y)) continue; - const candidates = compartments - .filter((unit) => { - if (!unit || unit.area === 0 || unit.classId === 8 || unit.classId === 9) return false; - const currentOwner = owner[unit.id]; - if (currentOwner < 0 || currentOwner === seed.id) return false; - if ((areaById.get(currentOwner) || 0) - unit.area < 25) return false; - return Math.hypot(unit.x - center.x, unit.y - center.y) < 90 && ((unit.lowlandFitness || 0) > 0.08 || seed.protected); - }) - .map((unit) => ({ - unit, - score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2 + (unit.classId === 8 || unit.classId === 9 ? 20 : 0), - })) - .sort((a, b) => a.score - b.score); - if (candidates.length === 0) continue; - const unit = candidates[0].unit; - const oldOwner = owner[unit.id]; - if ((areaById.get(oldOwner) || 0) - unit.area < 25) continue; - owner[unit.id] = seed.id; - const claimed = unit.area; - areaById.set(oldOwner, (areaById.get(oldOwner) || 0) - claimed); - changedCells += claimed; - areaById.set(seed.id, claimed); - seed.area = claimed; - restoredSeeds++; - currentCount++; - } - applyCompartmentOwners(adminId, compartments, owner); - return { changedCells, restoredSeeds }; -} - -function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }) { - let landCells = 0; - let habitableCells = 0; - let lowlandCells = 0; - let coastlineComplexity = 0; - let mountainCells = 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]) continue; - landCells++; - if (slope[i] < 0.42 && ridgeField[i] < 0.55 && (!elevation || elevation[i] < 0.72)) habitableCells++; - if ((coastalLowland[i] > 0.20 || basinField[i] > 0.24) && slope[i] < 0.36 && ridgeField[i] < 0.52) lowlandCells++; - if (ridgeField[i] > 0.52 || slope[i] > 0.48) mountainCells++; - for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) { - const ni = indexOf(nx, ny); - if (sea[ni]) { - coastlineComplexity += 1 + coastalLowland[i] * 0.8; - break; - } - } - } - } - const independentSatellites = (satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality").length; - const settlementWeight = modernCities.length * 1.6 + markets.length * 1.0 + ports.length * 0.8 + independentSatellites * 0.7 + villages.length * 0.25; - const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520); - const mountainRatio = landCells ? mountainCells / landCells : 0; - const lowlandBonus = Math.min(7, lowlandCells / 430); - const target = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2); - return clamp(target, 20, 50); -} - -function lowlandAdminSeedScore(i, { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse }) { - const lowRelief = clamp((0.70 - elevation[i]) * 1.35) + clamp((0.34 - slope[i]) * 1.55) + clamp((0.48 - ridgeField[i]) * 1.10); - const landuseFit = [1, 2, 3, 4, 7, 8].includes(landuse[i]) ? 0.40 : landuse[i] === 5 || landuse[i] === 6 ? 0.12 : 0; - return clamp( - lowRelief * 0.25 + - plain[i] * 0.28 + - basinField[i] * 0.24 + - coastalLowland[i] * 0.24 + - settlementScore[i] * 0.30 + - populationDensity[i] * 0.32 + - roadInfluence[i] * 0.16 + - railInfluence2[i] * 0.16 + - (stationInfluence?.[i] || 0) * 0.18 + - landuseFit - - Math.max(0, elevation[i] - 0.62) * 1.2 - - Math.max(0, ridgeField[i] - 0.54) * 0.9 - ); -} - -function buildLowlandAdminSeeds({ - seed, - targetMunicipalityCount, - prefectureMask, - sea, - elevation, - slope, - ridgeField, - plain, - basinField, - coastalLowland, - settlementScore, - populationDensity, - roadInfluence, - railInfluence2, - stationInfluence, - landuse, - modernCities, - satelliteCities, - markets, - ports, - newTowns, - stations, -}) { - const fields = { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse }; - function validLowlandPoint(p, strict = true) { - if (!p || !inside(p.x, p.y)) return false; - const i = indexOf(p.x, p.y); - if (!prefectureMask[i] || sea[i]) return false; - const score = lowlandAdminSeedScore(i, fields); - const mountain = elevation[i] > 0.70 || slope[i] > 0.52 || ridgeField[i] > 0.62; - return score >= (strict ? 0.34 : 0.24) && (!mountain || p.isPrefecturalCapital || (p.population || 0) >= 220000 || p.portClass === "major"); - } - const realSeeds = []; - for (const city of modernCities || []) { - if (!validLowlandPoint(city, !city.isPrefecturalCapital)) continue; - if (!city.isPrefecturalCapital && (city.population || 0) < 85000) continue; - const i = indexOf(city.x, city.y); - realSeeds.push({ x: city.x, y: city.y, score: 1.35 + (city.population || 0) / 650000 + lowlandAdminSeedScore(i, fields), population: city.population || 0, protectedCity: city, seedKind: city.isPrefecturalCapital ? "capital" : "modernCity" }); - } - for (const city of satelliteCities || []) { - if (city.municipalityClass !== "independentSatelliteMunicipality" || !validLowlandPoint(city, false)) continue; - const i = indexOf(city.x, city.y); - realSeeds.push({ x: city.x, y: city.y, score: 0.92 + (city.population || 0) / 260000 + lowlandAdminSeedScore(i, fields), population: city.population || 0, protectedSatellite: city, seedKind: "satelliteCity" }); - } - for (const p of [...(markets || []), ...(ports || []), ...(newTowns || [])]) { - if (!validLowlandPoint(p, true)) continue; - const i = indexOf(p.x, p.y); - const portBonus = p.portClass === "major" ? 0.45 : p.portClass === "regional" ? 0.26 : 0; - realSeeds.push({ x: p.x, y: p.y, score: 0.74 + lowlandAdminSeedScore(i, fields) + portBonus + (p.population || 0) / 420000, population: p.population || 0, portClass: p.portClass, source: p, seedKind: p.portClass ? "port" : "marketTown" }); - } - const picked = pickEntities(realSeeds, { - max: targetMunicipalityCount, - minDistance: 5 + Math.floor(rand(seed, 1302) * 3), - threshold: 0.62, - seed: seed + 1300, - jitter: 0.025, - }); - const invisibleCandidates = []; - 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 score = lowlandAdminSeedScore(i, fields) + hash2(x, y, seed + 1311) * 0.045; - if (score < 0.48) continue; - const insideDenseCore = modernCities.some((city) => (city.population || 0) >= 180000 && Math.hypot(city.x - x, city.y - y) < Math.max(5, (city.coreRadius || 4) * 1.7)); - if (insideDenseCore) continue; - invisibleCandidates.push({ x, y, score, invisibleLowlandAdminSeed: true, seedKind: "invisibleLowland" }); - } - } - if (picked.length < targetMunicipalityCount) { - const extra = pickEntities(invisibleCandidates, { - max: targetMunicipalityCount - picked.length, - minDistance: 5, - threshold: 0.48, - seed: seed + 1304, - jitter: 0.02, - }); - for (const p of extra) if (picked.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4)) picked.push(p); - } - if (picked.length < Math.min(targetMunicipalityCount, 20)) { - const relaxed = pickEntities(invisibleCandidates, { - max: Math.min(targetMunicipalityCount, 20) - picked.length, - minDistance: 4, - threshold: 0.38, - seed: seed + 1305, - jitter: 0.02, - }); - for (const p of relaxed) if (picked.length < targetMunicipalityCount && picked.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 3.5)) picked.push(p); - } - return picked.slice(0, targetMunicipalityCount); -} - -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; -} - -function generateAdminLayoutForMask({ - seed, - prefectureMask, - sea, - elevation, - slope, - river, - ridgeField, - naturalBarrierScore, - valleyField, - basinField, - coastalLowland, - flowAccum, - plain, - agriculture, - settlementScore, - populationDensity, - stationInfluence, - roadInfluence, - railInfluence2, - villageInfluence, - landuse, - modernCities, - satelliteCities, - newTowns, - markets, - villages, - ports, - stations, - industrialZones, - logisticsParks, -}) { - const boundaryRidgeField = naturalBarrierScore - ? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46)) - : ridgeField; - const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum); - const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }); - const compartmentMultiplier = clamp(4.6 + rand(seed, 1320) * 2.4, 4.6, 7.0); - let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 120, 360); - let adminCentersRaw = buildLowlandAdminSeeds({ - seed, - targetMunicipalityCount, - prefectureMask, - sea, - elevation, - slope, - ridgeField: boundaryRidgeField, - plain, - basinField, - coastalLowland, - settlementScore, - populationDensity, - roadInfluence, - railInfluence2, - stationInfluence, - landuse, - modernCities, - satelliteCities, - markets, - ports, - newTowns, - stations, - }); - if (adminCentersRaw.length < 20) targetCompartmentCount = Math.max(targetCompartmentCount, 120); - const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, { - seed, - targetMunicipalityCount, - targetCompartmentCount, - maxNaturalCompartmentArea: Math.max(32, Math.round(maskLandArea(prefectureMask, sea) / Math.max(1, targetCompartmentCount) * 1.65)), - }); - const adminId = compartmentAssignment.adminId; - 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, - targetMunicipalityCount, - actualMunicipalityCount: 0, - municipalityCountReason: "habitable cells, settlement weight, coastline complexity, basin/lowland bonus, and mountain-ratio adjustment", - changedAfterCompartmentAssignment: compartmentAssignment.debug?.naturalCompartmentCount || 0, - oversizedRuralSplits: 0, - oversizedLowlandSplits: 0, - ruralSplitsAccepted: 0, - ruralSplitsRejected: 0, - targetNaturalCompartmentCount: targetCompartmentCount, - compartmentMultiplier, - lowlandAdminSeedCount: adminCentersRaw.filter((p) => p.invisibleLowlandAdminSeed).length, - lowlandAdminSeeds: adminCentersRaw.filter((p) => p.invisibleLowlandAdminSeed).map((p) => ({ x: p.x, y: p.y })), - realAdminSeedCount: adminCentersRaw.filter((p) => !p.invisibleLowlandAdminSeed).length, - highMountainAdminSeedCount: adminCentersRaw.filter((p) => { - const i = indexOf(p.x, p.y); - return elevation[i] > 0.70 || slope[i] > 0.52 || boundaryRidgeField[i] > 0.62; - }).length, - candidateSeedCount: adminCentersRaw.length, - protectedSeedCount: adminCentersRaw.filter(isProtectedAdminSeed).length, - survivedSeedCount: 0, - pendingSeedCount: 0, - absorbedSeedCount: 0, - pendingSeedsUsedForLowlandSplit: 0, - finalMunicipalityCount: 0, - finalTinyMunicipalityCount: 0, - seedCellRevivalCount: 0, - satelliteMunicipalitiesCreated: adminCentersRaw.filter((p) => p.protectedSatellite).length, - satelliteMunicipalitiesMerged: 0, - satelliteMunicipalitiesExpanded: 0, - satelliteMunicipalitiesTooSmall: 0, - averageSatelliteMunicipalityArea: 0, - minSatelliteMunicipalityArea: 0, - satelliteMunicipalityAreaByNameOrIndex: {}, - independentSatelliteMunicipalities: satelliteClassificationDebug.independent, - attachedSatelliteDistricts: satelliteClassificationDebug.attached, - satelliteMunicipalityStats: satelliteClassificationDebug, - ...compartmentAssignment.debug, - }; - const seedLifecycle = buildSeedLifecycle(adminCentersRaw, adminId, prefectureMask, sea, 35); - const pendingSplitDebug = splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, { - plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation, - }, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, [...(satelliteCities || []), ...newTowns, ...markets, ...villages, ...ports]); - adminDebug.changedAfterPendingSeedLowlandSplit = pendingSplitDebug.changedCells; - adminDebug.pendingSeedsUsedForLowlandSplit = pendingSplitDebug.pendingSeedsUsed; - adminDebug.oversizedLowlandSplits += pendingSplitDebug.splitMunicipalities; - const pendingPromotionDebug = promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, { - plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation, - }, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(24, targetMunicipalityCount)); - adminDebug.changedAfterPendingSeedCountRepair = pendingPromotionDebug.changedCells; - adminDebug.pendingSeedsPromotedForCount = pendingPromotionDebug.promotedSeeds; - let areaAfterPendingSplit = municipalityAreaById(adminId, prefectureMask, sea); - for (const seedState of seedLifecycle) { - if (seedState.state !== "pending") continue; - seedState.area = areaAfterPendingSplit.get(seedState.id) || 0; - if (seedState.area >= 35) seedState.state = "survived"; - else seedState.state = "absorbed"; - } - adminDebug.changedAfterAbsorbingSeeds = absorbSeedCompartments(adminId, compartmentAssignment.compartments, seedLifecycle); - let activeAdminIds = activeSeedIds(seedLifecycle); - function markChanged(field) { - adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea); - previousSnapshot = new Int16Array(adminId); - } - smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, populationDensity, landuse, 2); - 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) => { - if (!activeAdminIds.has(id)) return; - 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) => { - if (!activeAdminIds.has(id)) return; - 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: boundaryRidgeField, 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"); - const activeAdminCenters = () => adminCentersRaw.filter((_, id) => activeAdminIds.has(id)); - mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: activeAdminCenters() }); - markChanged("changedAfterInitialMerge"); - removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180); - markChanged("changedAfterInitialExclaveRemoval"); - // The initial compartment graph assignment is now the primary natural partition. - // Re-running the older raw landscape-unit pass here collapses lowland seeds into a few broad owners. - markChanged("changedAfterLandscapePartition"); - const oversizedSplitDebug = splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, [...(satelliteCities || []), ...newTowns, ...markets, ...villages]); - adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells; - adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities; - adminDebug.oversizedRuralSplits = oversizedSplitDebug.splitMunicipalities; - adminDebug.oversizedLowlandSplits = oversizedSplitDebug.splitMunicipalities; - adminDebug.ruralSplitsAccepted = oversizedSplitDebug.splitMunicipalities; - adminDebug.ruralSplitsRejected = oversizedSplitDebug.rejectedMunicipalities || 0; - previousSnapshot = new Int16Array(adminId); - snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 2); - markChanged("changedAfterSnap"); - removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 360); - markChanged("changedAfterFinalExclaveRemoval"); - mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 80, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 90, protectedPoints: activeAdminCenters() }); - 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: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, - landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities, - }); - } - removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 260); - const finalPromotionDebug = promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, { - plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation, - }, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(22, targetMunicipalityCount)); - adminDebug.changedAfterFinalPendingSeedCountRepair = finalPromotionDebug.changedCells; - adminDebug.pendingSeedsPromotedForCount += finalPromotionDebug.promotedSeeds; - const restoredSeedDebug = restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(22, targetMunicipalityCount)); - adminDebug.changedAfterSurvivedSeedCompartmentRestore = restoredSeedDebug.changedCells; - adminDebug.survivedSeedsRestoredByCompartment = restoredSeedDebug.restoredSeeds; - let finalAreaBySeed = municipalityAreaById(adminId, prefectureMask, sea); - for (const seedState of seedLifecycle) { - seedState.area = finalAreaBySeed.get(seedState.id) || 0; - if (!seedState.protected && seedState.state === "pending" && seedState.area < 25) seedState.state = "absorbed"; - } - absorbSeedCompartments(adminId, compartmentAssignment.compartments, seedLifecycle); - activeAdminIds = activeSeedIds(seedLifecycle); - - 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 < Math.max(120, sat.satelliteMinArea || 0) || ((sat.population || 0) >= 60000 && area < 150))) { - 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; - adminDebug.satelliteMunicipalitiesIndependent = satelliteAreas.length; - const landscapeDebug = applyLandscapeUnitAdminPartition.lastDebug || {}; - Object.assign(adminDebug, landscapeDebug); - adminDebug.targetNaturalCompartmentCount = compartmentAssignment.debug?.targetNaturalCompartmentCount || targetCompartmentCount; - adminDebug.naturalCompartmentCount = compartmentAssignment.debug?.naturalCompartmentCount || adminDebug.naturalCompartmentCount || adminDebug.compartmentCount || 0; - adminDebug.compartmentCount = adminDebug.naturalCompartmentCount; - adminDebug.averageCompartmentsPerMunicipality = compartmentAssignment.debug?.averageCompartmentsPerMunicipality || adminDebug.averageCompartmentsPerMunicipality || 0; - adminDebug.singleCompartmentMunicipalityRatio = compartmentAssignment.debug?.singleCompartmentMunicipalityRatio ?? adminDebug.singleCompartmentMunicipalityRatio ?? 0; - adminDebug.actualMunicipalityCount = new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i])).size; - adminDebug.averageCompartmentsPerMunicipality = adminDebug.actualMunicipalityCount ? adminDebug.naturalCompartmentCount / adminDebug.actualMunicipalityCount : 0; - adminDebug.survivedSeedCount = seedLifecycle.filter((seed) => seed.state === "survived").length; - adminDebug.pendingSeedCount = seedLifecycle.filter((seed) => seed.state === "pending").length; - adminDebug.absorbedSeedCount = seedLifecycle.filter((seed) => seed.state === "absorbed").length; - adminDebug.finalMunicipalityCount = adminDebug.actualMunicipalityCount; - adminDebug.finalTinyMunicipalityCount = [...areaById.values()].filter((area) => area > 0 && area < 8).length; - adminDebug.seedLifecycle = seedLifecycle.map((seed) => ({ id: seed.id, state: seed.state, protected: seed.protected, area: seed.area })); - adminDebug.borderNaturalBarrierAverage = adminDebug.finalBorderNaturalBarrierAverage ?? 0; - adminDebug.voronoiLikeRate = adminDebug.voronoiLikeRateAfter ?? 0; - const adminBorders = extractAdminBorderSegments(adminId, prefectureMask); - - - return { adminCentersRaw, adminId, adminBorders, adminDebug }; -} - - -function filterPointsForMask(points = [], mask, sea) { - return (points || []).filter((p) => p && inside(p.x, p.y) && mask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]); -} - -function buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId) { - const mask = new Uint8Array(SIZE); - for (let i = 0; i < SIZE; i++) { - if (sea[i]) continue; - mask[i] = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId) === regionId ? 1 : 0; - } - return mask; -} - -function maskLandArea(mask, sea) { - let area = 0; - for (let i = 0; i < SIZE; i++) if (mask[i] && !sea[i]) area++; - return area; -} - -function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) { - const ids = new Set(); - for (let i = 0; i < SIZE; i++) { - if (sea[i]) continue; - const id = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId); - if (id >= 0 || id === OUTER_ANCHOR_REGION_ID) ids.add(id); - } - return [...ids].sort((a, b) => a - b); -} - -export function generateAdminLayout(context) { - const { prefectureMask, prefectureRegionId, sea, populationDensity, plain, slope } = context; - const regionIds = discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) - .filter((regionId) => maskLandArea(buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId), sea) >= 120); - - if (!prefectureRegionId || regionIds.length <= 1) return generateAdminLayoutForMask(context); - - const combinedAdminId = new Int16Array(SIZE); - combinedAdminId.fill(-1); - const combinedHumanMask = new Uint8Array(SIZE); - const combinedCenters = []; - const combinedCompartmentBorders = []; - const perRegion = []; - let idOffset = 0; - - for (const regionId of regionIds) { - const regionMask = buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId); - const regionArea = maskLandArea(regionMask, sea); - if (regionArea < 120) continue; - - const localContext = { - ...context, - seed: (context.seed + regionId * 10007) >>> 0, - prefectureMask: regionMask, - modernCities: filterPointsForMask(context.modernCities, regionMask, sea), - satelliteCities: filterPointsForMask(context.satelliteCities, regionMask, sea), - newTowns: filterPointsForMask(context.newTowns, regionMask, sea), - markets: filterPointsForMask(context.markets, regionMask, sea), - villages: filterPointsForMask(context.villages, regionMask, sea), - ports: filterPointsForMask(context.ports, regionMask, sea), - stations: filterPointsForMask(context.stations, regionMask, sea), - industrialZones: filterPointsForMask(context.industrialZones, regionMask, sea), - logisticsParks: filterPointsForMask(context.logisticsParks, regionMask, sea), - }; - - const local = generateAdminLayoutForMask(localContext); - if (local.adminDebug?.compartmentBorders?.length) combinedCompartmentBorders.push(...local.adminDebug.compartmentBorders); - let localMaxAdminId = -1; - for (let i = 0; i < SIZE; i++) if (regionMask[i] && !sea[i] && (local.adminId?.[i] ?? -1) > localMaxAdminId) localMaxAdminId = local.adminId[i]; - const localSlotCount = Math.max(local.adminCentersRaw?.length || 0, localMaxAdminId + 1); - const localCenters = []; - for (let localAdminId = 0; localAdminId < localSlotCount; localAdminId++) { - let center = local.adminCentersRaw?.[localAdminId]; - if (!center) { - let sx = 0, sy = 0, count = 0, bestI = -1, bestScore = -INF; - for (let i = 0; i < SIZE; i++) { - if (!regionMask[i] || sea[i] || local.adminId?.[i] !== localAdminId) continue; - const [x, y] = xyOf(i); - sx += x; - sy += y; - count++; - const score = (populationDensity?.[i] || 0) + (plain?.[i] || 0) * 0.2 - (slope?.[i] || 0) * 0.2; - if (score > bestScore) { bestScore = score; bestI = i; } - } - if (count && bestI >= 0) { - const [bx, by] = xyOf(bestI); - center = { x: bx, y: by, score: bestScore, seedKind: "generatedAdminSlot", invisibleLowlandAdminSeed: true }; - } - } - if (!center) center = { x: 0, y: 0, score: 0, seedKind: "emptyAdminSlot", invisibleLowlandAdminSeed: true }; - localCenters.push({ - ...center, - regionId, - localAdminId, - adminIdOffset: idOffset, - }); - } - combinedCenters.push(...localCenters); - - for (let i = 0; i < SIZE; i++) { - if (!regionMask[i] || sea[i]) continue; - combinedHumanMask[i] = 1; - const localId = local.adminId?.[i] ?? -1; - if (localId >= 0) combinedAdminId[i] = localId + idOffset; - } - - perRegion.push({ - regionId, - area: regionArea, - centerCount: localCenters.length, - municipalityCount: local.adminDebug?.finalMunicipalityCount || local.adminDebug?.actualMunicipalityCount || new Set([...local.adminId].filter((id, i) => id >= 0 && regionMask[i] && !sea[i])).size, - naturalCompartmentCount: local.adminDebug?.naturalCompartmentCount || 0, - targetNaturalCompartmentCount: local.adminDebug?.targetNaturalCompartmentCount || 0, - averageCompartmentArea: local.adminDebug?.averageCompartmentArea || 0, - maxCompartmentArea: local.adminDebug?.maxCompartmentArea || 0, - maxCompartmentElongation: local.adminDebug?.maxCompartmentElongation || 1, - worstNaturalCompartments: local.adminDebug?.worstNaturalCompartments || [], - singleCompartmentMunicipalityRatio: local.adminDebug?.singleCompartmentMunicipalityRatio || 0, - }); - idOffset += localSlotCount; - } - - const leftoverByRegion = new Map(); - for (let i = 0; i < SIZE; i++) { - if (sea[i]) continue; - const regionId = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId); - if ((regionId < 0 && regionId !== OUTER_ANCHOR_REGION_ID) || combinedAdminId[i] >= 0) continue; - if (!leftoverByRegion.has(regionId)) leftoverByRegion.set(regionId, []); - leftoverByRegion.get(regionId).push(i); - } - for (const [regionId, cells] of leftoverByRegion) { - let sx = 0, sy = 0, bestI = cells[0], bestScore = -INF; - for (const i of cells) { - const [x, y] = xyOf(i); - sx += x; - sy += y; - const score = (populationDensity?.[i] || 0) + (plain?.[i] || 0) * 0.2 - (slope?.[i] || 0) * 0.2; - if (score > bestScore) { bestScore = score; bestI = i; } - } - const [cx, cy] = xyOf(bestI); - const id = combinedCenters.length; - combinedCenters.push({ x: cx, y: cy, score: bestScore, regionId, localAdminId: 0, seedKind: "tinyRegionAdminSeed", invisibleLowlandAdminSeed: true }); - for (const i of cells) { - combinedHumanMask[i] = 1; - combinedAdminId[i] = id; - } - perRegion.push({ regionId, area: cells.length, centerCount: 1, municipalityCount: 1, naturalCompartmentCount: 1, targetNaturalCompartmentCount: 1, averageCompartmentArea: cells.length, maxCompartmentArea: cells.length, maxCompartmentElongation: 1, singleCompartmentMunicipalityRatio: 1, tinyRegionFallback: true }); - } - - const adminBorders = extractAdminBorderSegments(combinedAdminId, combinedHumanMask); - const totalMunicipalityCount = new Set([...combinedAdminId].filter((id, i) => id >= 0 && combinedHumanMask[i] && !sea[i])).size; - const totalNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.naturalCompartmentCount || 0), 0); - const totalTargetNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.targetNaturalCompartmentCount || 0), 0); - const weightedCompartmentArea = perRegion.reduce((sum, row) => sum + (row.averageCompartmentArea || 0) * (row.naturalCompartmentCount || 0), 0); - const weightedSingleRatio = perRegion.reduce((sum, row) => sum + (row.singleCompartmentMunicipalityRatio || 0) * (row.municipalityCount || 0), 0); - const adminDebug = { - multiRegionAdmin: true, - adminRegionCount: perRegion.length, - perRegion, - finalMunicipalityCount: totalMunicipalityCount, - actualMunicipalityCount: totalMunicipalityCount, - candidateSeedCount: combinedCenters.length, - naturalCompartmentCount: totalNaturalCompartmentCount, - compartmentCount: totalNaturalCompartmentCount, - targetNaturalCompartmentCount: totalTargetNaturalCompartmentCount, - averageCompartmentArea: totalNaturalCompartmentCount ? weightedCompartmentArea / totalNaturalCompartmentCount : 0, - maxCompartmentArea: Math.max(0, ...perRegion.map((row) => row.maxCompartmentArea || 0)), - maxCompartmentElongation: Math.max(1, ...perRegion.map((row) => row.maxCompartmentElongation || 1)), - averageCompartmentsPerMunicipality: totalMunicipalityCount ? totalNaturalCompartmentCount / totalMunicipalityCount : 0, - singleCompartmentMunicipalityRatio: totalMunicipalityCount ? weightedSingleRatio / totalMunicipalityCount : 0, - compartmentBorders: combinedCompartmentBorders, - }; - - return { adminCentersRaw: combinedCenters, adminId: combinedAdminId, adminBorders, adminDebug }; -} diff --git a/mapFeatures.js b/mapFeatures.js index b93f916..ab045dd 100644 --- a/mapFeatures.js +++ b/mapFeatures.js @@ -1,24 +1,15 @@ -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"; -import { LANDUSE, isBuiltLanduse, isUrbanResidentialLanduse } from "./landuseCodes.js"; +import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise } from "./mapUtils.js"; +import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js"; +import { LANDUSE } from "./landuseCodes.js"; + +// Lightweight Human Geography V2 +// -------------------------------- +// This replaces the heavy iterative human stage with a sparse skeleton + raster +// synthesis model: +// 1. build terrain-derived human context once +// 2. place villages/towns/cities by region quotas +// 3. make sparse approximate transport paths without full-resolution A* +// 4. synthesize population and land-use fields in one raster pass export function generateMapFeatures(seed, terrain) { const { @@ -45,113 +36,21 @@ export function generateMapFeatures(seed, terrain) { passSuitability, prefectureMask, prefectureRegionId, + naturalBarrierScore, } = 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 }); + function regionIdAt(x, y) { + if (!inside(x, y)) return -1; + const i = indexOf(x, y); + if (sea[i]) return -1; + if (prefectureMask?.[i]) return 0; + const id = prefectureRegionId?.[i]; + return id !== undefined && id >= 0 ? id : -1; } - 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; - } + function inFocusedPrefecture(p) { + return Boolean(p && inside(p.x, p.y) && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]); } - 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 depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.65 + (deltaField?.[i] || 0) * 0.85; - const spineBarrier = (arcSpineField?.[i] || 0) * 0.62 + (branchRidgeField?.[i] || 0) * 0.42; - 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 + depositional * 0.22); - const terrainGate = clamp(1.0 - slope[i] * 1.18 - ridgeField[i] * 0.52 - spineBarrier * 0.34 - 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 depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.95; - const spineBarrier = (arcSpineField?.[i] || 0) * 0.48 + (branchRidgeField?.[i] || 0) * 0.34; - const riverPull = Math.min(0.36, river[i] * 0.14 + valleyField[i] * 0.16 + depositional * 0.08); - 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] + spineBarrier - 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 + depositional * 0.13 + mountainVillage - slope[i] * 0.48 - ridgeField[i] * 0.24 - spineBarrier * 0.12 - floodplain[i] * 0.06 - remoteMountainPenalty; - settlementScore[i] = clamp(base * (0.74 + settlementCluster[i] * 0.66) + settlementCluster[i] * 0.13); - } - } - - // Capacity-first settlement context. These rasters are intentionally small - // and reusable: all village, town, city-capacity, density and land-use passes - // should read from the same human-geography interpretation of the terrain. - const developable = new Float32Array(SIZE); - const ruralSuitability = new Float32Array(SIZE); - const townSuitability = new Float32Array(SIZE); - const valleySettlement = new Float32Array(SIZE); - const coastalSettlement = new Float32Array(SIZE); - const confluenceField = new Float32Array(SIZE); function localConfluenceScore(x, y) { let arms = 0; @@ -161,115 +60,170 @@ export function generateMapFeatures(seed, terrain) { const ny = y + dy; if (!inside(nx, ny)) continue; const rv = river[indexOf(nx, ny)]; - if (rv > 0.22) arms++; - if (rv > 0.38) strong++; + if (rv > 0.18) arms++; + if (rv > 0.34) strong++; } - return clamp((arms >= 3 ? 0.16 : arms === 2 ? 0.08 : 0) + strong * 0.035); + return clamp((arms >= 3 ? 0.22 : arms === 2 ? 0.09 : 0) + strong * 0.04); } - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { + // --- 1. Human context: one full raster pass ----------------------------- + const developable = new Float32Array(SIZE); + const ruralSuitability = new Float32Array(SIZE); + const townSuitability = new Float32Array(SIZE); + const valleySettlement = new Float32Array(SIZE); + const coastalSettlement = new Float32Array(SIZE); + const confluenceField = new Float32Array(SIZE); + const barrierCost = new Float32Array(SIZE); + const corridorCost = new Float32Array(SIZE); + const settlementCluster = new Float32Array(SIZE); + const settlementScore = 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; - const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.85; + if (sea[i]) { + barrierCost[i] = INF; + corridorCost[i] = INF; + continue; + } + const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.90; const highPenalty = Math.max(0, elevation[i] - 0.56); - const lowSlope = clamp(1 - slope[i] * 2.25); - const confluence = localConfluenceScore(x, y); + const lowSlope = clamp(1 - slope[i] * 2.3); + const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y) : 0; + const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38; confluenceField[i] = confluence; + developable[i] = clamp( plain[i] * 0.34 + - agriculture[i] * 0.22 + - basinField[i] * 0.22 + + agriculture[i] * 0.24 + + basinField[i] * 0.24 + valleyField[i] * 0.24 + - coastalLowland[i] * 0.16 + - depositional * 0.20 + + coastalLowland[i] * 0.18 + + depositional * 0.22 + lowSlope * 0.10 - - slope[i] * 0.84 - - ridgeField[i] * 0.54 - - highPenalty * 1.12 - - floodplain[i] * 0.04 + slope[i] * 0.82 - + ridgeField[i] * 0.52 - + spine * 0.24 - + highPenalty * 1.14 - + floodplain[i] * 0.03 ); valleySettlement[i] = clamp( - valleyField[i] * 0.48 + - river[i] * 0.16 + - confluence * 0.72 + - depositional * 0.14 + - basinField[i] * 0.08 + + valleyField[i] * 0.52 + + river[i] * 0.08 + + confluence * 0.38 + + depositional * 0.20 + + basinField[i] * 0.16 + + plain[i] * 0.08 + lowSlope * 0.12 - - slope[i] * 0.58 - - ridgeField[i] * 0.32 - - highPenalty * 0.74 + slope[i] * 0.54 - + ridgeField[i] * 0.30 - + spine * 0.16 - + highPenalty * 0.70 - + floodplain[i] * 0.10 ); coastalSettlement[i] = clamp( - coastalLowland[i] * 0.48 + - (portSuitability?.[i] || 0) * 0.28 + - (deltaField?.[i] || 0) * 0.18 + + coastalLowland[i] * 0.50 + + (portSuitability?.[i] || 0) * 0.30 + + (deltaField?.[i] || 0) * 0.20 + plain[i] * 0.10 - slope[i] * 0.52 - - ridgeField[i] * 0.22 + ridgeField[i] * 0.24 - + spine * 0.12 ); + const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16; + settlementCluster[i] = clamp((developable[i] * 0.52 + valleySettlement[i] * 0.28 + coastalSettlement[i] * 0.18 + agriculture[i] * 0.22) * clusterNoise); ruralSuitability[i] = clamp( - settlementScore[i] * 0.48 + - agriculture[i] * 0.40 + - developable[i] * 0.22 + + agriculture[i] * 0.42 + + developable[i] * 0.28 + valleySettlement[i] * 0.24 + - coastalSettlement[i] * 0.16 - - Math.max(0, elevation[i] - 0.64) * 0.54 + coastalSettlement[i] * 0.15 + + settlementCluster[i] * 0.24 - + Math.max(0, elevation[i] - 0.64) * 0.56 ); townSuitability[i] = clamp( - settlementScore[i] * 0.30 + - developable[i] * 0.38 + - valleySettlement[i] * 0.24 + + developable[i] * 0.40 + + valleySettlement[i] * 0.26 + coastalSettlement[i] * 0.20 + confluence * 0.34 + - basinField[i] * 0.14 + - plain[i] * 0.10 - + basinField[i] * 0.16 + + plain[i] * 0.12 + + settlementCluster[i] * 0.16 - slope[i] * 0.34 - - ridgeField[i] * 0.16 + ridgeField[i] * 0.17 - + spine * 0.10 ); + settlementScore[i] = clamp(ruralSuitability[i] * 0.58 + townSuitability[i] * 0.34 + confluence * 0.10); + const naturalBarrier = naturalBarrierScore?.[i] || 0; + barrierCost[i] = 1 + slope[i] * 6.4 + ridgeField[i] * 3.2 + spine * 2.2 + highPenalty * 5.8 + river[i] * 0.25 + naturalBarrier * 1.2 - valleyField[i] * 0.55 - plain[i] * 0.30 - coastalLowland[i] * 0.14; + corridorCost[i] = Math.max(0.25, barrierCost[i] - developable[i] * 0.42 - valleySettlement[i] * 0.36 - coastalSettlement[i] * 0.12 + hash2(x, y, seed + 7011) * 0.05); } } - function buildSettlementRegionStats() { - const stats = new Map(); - 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 regionId = regionIdAt(x, y); - if (regionId < 0) continue; - let st = stats.get(regionId); - if (!st) { - st = { area: 0, developable: 0, valley: 0, coast: 0, basin: 0, plain: 0, town: 0 }; - stats.set(regionId, st); - } - st.area++; - if (developable[i] > 0.18) st.developable++; - if (valleySettlement[i] > 0.22) st.valley++; - if (coastalSettlement[i] > 0.24) st.coast++; - if (basinField[i] > 0.22) st.basin++; - if (plain[i] > 0.24) st.plain++; - if (townSuitability[i] > 0.28) st.town++; - } + // --- region statistics --------------------------------------------------- + const regionStats = new Map(); + function ensureRegion(regionId) { + let st = regionStats.get(regionId); + if (!st) { + st = { + id: regionId, + area: 0, + developableCells: 0, + developableSum: 0, + valleyCells: 0, + coastCells: 0, + townCells: 0, + plainCells: 0, + minX: MAP_W, + minY: MAP_H, + maxX: 0, + maxY: 0, + }; + regionStats.set(regionId, st); + } + return st; + } + 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 regionId = regionIdAt(x, y); + if (regionId < 0) continue; + const st = ensureRegion(regionId); + st.area++; + st.developableSum += developable[i]; + if (developable[i] > 0.16) st.developableCells++; + if (valleySettlement[i] > 0.24) st.valleyCells++; + if (coastalSettlement[i] > 0.25) st.coastCells++; + if (townSuitability[i] > 0.28) st.townCells++; + if (plain[i] > 0.24) st.plainCells++; + st.minX = Math.min(st.minX, x); + st.minY = Math.min(st.minY, y); + st.maxX = Math.max(st.maxX, x); + st.maxY = Math.max(st.maxY, y); } - return stats; } - const settlementRegionStats = buildSettlementRegionStats(); + function visibilityFactor(regionId, st) { + if (!st || st.area <= 0) return 0; + // Treat the focused prefecture and neighboring prefectures with the same + // density curve. Only genuinely clipped map-edge slivers are downscaled. + return clamp(Math.sqrt(st.area / 1900), 0.32, 1.05); + } - function pickRegionalSettlementPoints(scoreArray, { - totalMax, - minDistance, - seedOffset, - threshold, + function pickRegionalPoints(scoreArray, { + stride = 1, + threshold = 0.25, + minDistance = 6, + totalMax = 100, + seedOffset = 0, quotaForRegion, - kind, - extraScore = () => 0, predicate = () => true, + kind = "Point", + extraScore = () => 0, }) { const byRegion = new Map(); - for (let y = 2; y < MAP_H - 2; y++) { - for (let x = 2; x < MAP_W - 2; x++) { + for (let y = 2; y < MAP_H - 2; y += stride) { + for (let x = 2; x < MAP_W - 2; x += stride) { const i = indexOf(x, y); if (sea[i] || !predicate(x, y, i)) continue; const regionId = regionIdAt(x, y); @@ -280,194 +234,178 @@ export function generateMapFeatures(seed, terrain) { byRegion.get(regionId).push({ x, y, score, kind, regionId }); } } - const picked = []; + const out = []; for (const [regionId, candidates] of [...byRegion.entries()].sort((a, b) => a[0] - b[0])) { - const quota = quotaForRegion(regionId, settlementRegionStats.get(regionId) || { area: 0 }); + const st = regionStats.get(regionId); + const quota = quotaForRegion ? quotaForRegion(regionId, st) : 0; if (quota <= 0) continue; - picked.push(...pickEntities(candidates, { + out.push(...pickEntities(candidates, { max: quota, minDistance, threshold, - seed: seed + seedOffset + regionId * 997, + seed: seed + seedOffset + regionId * 1009, + jitter: 0.04, })); } - return picked - .sort((a, b) => b.score - a.score) - .slice(0, totalMax) - .sort((a, b) => a.regionId - b.regionId || b.score - a.score); + return out.sort((a, b) => b.score - a.score).slice(0, totalMax); } + function pickGlobalPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true, stride = 1 }) { + const candidates = []; + for (let y = 2; y < MAP_H - 2; y += stride) { + for (let x = 2; x < MAP_W - 2; x += stride) { + const i = indexOf(x, y); + if (sea[i] || !predicate(x, y, i)) continue; + const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.07; + if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) }); + } + } + return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset }); + } + + // --- 2. Sparse points ---------------------------------------------------- + let ports = pickGlobalPoints(portSuitability || coastalSettlement, { + threshold: 0.30 + rand(seed, 1001) * 0.08, + max: 10, + minDistance: 13, + seedOffset: 1000, + predicate: (x, y, i) => coastalSettlement[i] > 0.14 || (portSuitability?.[i] || 0) > 0.25, + }).map((p, n) => { + const i = indexOf(p.x, p.y); + const harborPotential = (portSuitability?.[i] || 0) + coastalLowland[i] * 0.22 + (deltaField?.[i] || 0) * 0.08 - slope[i] * 0.18; + const portClass = n === 0 ? "major" : n < 3 && harborPotential > 0.34 ? "regional" : harborPotential > 0.24 ? "fishing" : "lake"; + const kind = portClass === "major" ? "Major Port" : portClass === "regional" ? "Regional Port" : portClass === "lake" ? "Lake Port" : "Fishing Port"; + return { ...p, harborPotential, portClass, kind, score: harborPotential }; + }).sort((a, b) => b.harborPotential - a.harborPotential); + if (ports.length && !ports.some((p) => p.portClass === "major")) { + ports[0].portClass = "major"; + ports[0].kind = "Major Port"; + } + const commercialPorts = ports.filter((p) => p.portClass === "major" || p.portClass === "regional"); + + const crossings = pickGlobalPoints(crossingSuitability || confluenceField, { + threshold: 0.30 + rand(seed, 1011) * 0.06, + max: 18, + minDistance: 9, + seedOffset: 1010, + predicate: (x, y, i) => river[i] > 0.12 || confluenceField[i] > 0.09, + }).map((p) => ({ ...p, kind: "River Crossing" })); + + const passes = pickGlobalPoints(passSuitability || valleySettlement, { + threshold: 0.18 + rand(seed, 1021) * 0.06, + max: 12, + minDistance: 11, + seedOffset: 1020, + predicate: (x, y, i) => elevation[i] > 0.42 && !sea[i], + }).map((p) => ({ ...p, kind: "Pass" })); + const villageScore = new Float32Array(SIZE); for (let i = 0; i < SIZE; i++) { if (sea[i]) continue; - villageScore[i] = clamp(ruralSuitability[i] * 0.72 + valleySettlement[i] * 0.26 + coastalSettlement[i] * 0.18 + settlementCluster[i] * 0.12); + villageScore[i] = clamp(ruralSuitability[i] * 0.68 + valleySettlement[i] * 0.26 + coastalSettlement[i] * 0.18 + settlementCluster[i] * 0.08); } - - let villages = pickRegionalSettlementPoints(villageScore, { - threshold: 0.26 + rand(seed, 1031) * 0.055, - totalMax: 105 + Math.floor(rand(seed, 1032) * 35), - minDistance: 4, + const villages = pickRegionalPoints(villageScore, { + stride: 2, + threshold: 0.25 + rand(seed, 1031) * 0.04, + totalMax: 140, + minDistance: 5, seedOffset: 1030, kind: "Village", quotaForRegion: (regionId, st) => { - if (!st || st.developable < 12) return 0; - const base = 1.8 + st.developable / 58 + st.valley / 32 + st.coast / 42 + st.basin / 70; - const selectedBonus = regionId === 0 ? 7.5 : 0; - return Math.round(clamp(base + selectedBonus + rand(seed, 1035 + regionId * 13) * 2.4, regionId === 0 ? 10 : 2, regionId === 0 ? 26 : 12)); + if (!st || st.developableCells < 10) return 0; + const vf = visibilityFactor(regionId, st); + const raw = (st.developableCells / 65 + st.valleyCells / 44 + st.coastCells / 55 + 1.2) * vf; + const min = st.area > 2600 ? 7 : st.area > 1400 ? 4 : st.area > 520 ? 2 : st.area > 220 ? 1 : 0; + const max = st.area > 3600 ? 24 : st.area > 2200 ? 17 : st.area > 900 ? 9 : 4; + return Math.round(clamp(raw + rand(seed, 1033 + regionId * 19) * 1.5, min, max)); }, - extraScore: (x, y, i) => confluenceField[i] * 0.10, }).map((p, n) => { const i = indexOf(p.x, p.y); - const settlementType = valleySettlement[i] > 0.42 ? "Valley Village" : coastalSettlement[i] > 0.44 ? "Coastal Village" : "Village"; - const population = Math.round((350 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.9) * 5200 + ruralSuitability[i] * 3200) / 100) * 100; - return { ...p, kind: settlementType, population }; + const kind = valleySettlement[i] > 0.42 ? "Valley Village" : coastalSettlement[i] > 0.43 ? "Coastal Village" : "Village"; + const population = Math.round((300 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.85) * 4700 + ruralSuitability[i] * 2600) / 100) * 100; + return { ...p, kind, population }; }); + const villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2)); + const marketScore = new Float32Array(SIZE); - for (let y = 4; y < MAP_H - 4; y++) { - for (let x = 4; x < MAP_W - 4; x++) { + 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 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 / 2.8); - 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.2)); - const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.12 : 0; + const featurePull = Math.max( + distanceToNearest(ports, x, y) < 8 ? 0.10 : 0, + distanceToNearest(crossings, x, y) < 6 ? 0.06 : 0, + confluenceField[i] * 0.16 + ); + const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.14 : 0; marketScore[i] = clamp( - townSuitability[i] * 0.66 + - villagePull * 0.56 + - featurePull * 0.26 + - confluenceField[i] * 0.30 + + townSuitability[i] * 0.62 + + villageInfluence[i] * 0.38 + + featurePull + valleyMouth + - plain[i] * 0.12 + basinField[i] * 0.12 + - (depositionalLowland?.[i] || 0) * 0.08 + - (deltaField?.[i] || 0) * 0.07 + - nearbyVillages * 0.006 - - slope[i] * 0.22 - - ridgeField[i] * 0.10 - - (arcSpineField?.[i] || 0) * 0.06 + plain[i] * 0.14 + + coastalLowland[i] * 0.08 - + slope[i] * 0.18 - + ridgeField[i] * 0.08 ); } } - let markets = pickRegionalSettlementPoints(marketScore, { - threshold: 0.30 + rand(seed, 1041) * 0.055, - totalMax: 34 + Math.floor(rand(seed, 1042) * 16), - minDistance: 8, + const markets = pickRegionalPoints(marketScore, { + stride: 2, + threshold: 0.31 + rand(seed, 1041) * 0.045, + totalMax: 52, + minDistance: 9, seedOffset: 1040, kind: "Market Town", quotaForRegion: (regionId, st) => { - if (!st || st.town < 8) return 0; - const base = 0.8 + st.developable / 230 + st.valley / 120 + st.coast / 140 + st.basin / 160; - const selectedBonus = regionId === 0 ? 3.0 : 0; - return Math.round(clamp(base + selectedBonus + rand(seed, 1045 + regionId * 17) * 1.3, regionId === 0 ? 4 : 1, regionId === 0 ? 10 : 4)); + if (!st || st.townCells < 8) return 0; + const vf = visibilityFactor(regionId, st); + const raw = (st.developableCells / 260 + st.valleyCells / 150 + st.coastCells / 160 + 0.8) * vf; + const min = st.area > 2600 ? 3 : st.area > 1200 ? 2 : st.area > 520 ? 1 : 0; + const max = st.area > 3600 ? 9 : st.area > 2200 ? 7 : st.area > 800 ? 4 : 2; + return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max)); }, - extraScore: (x, y, i) => (distanceToNearest(ports, x, y) < 7 ? 0.07 : 0) + (distanceToNearest(crossings, x, y) < 5 ? 0.06 : 0), + extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 8 ? 0.07 : 0) + confluenceField[i] * 0.08, }).map((p, n) => { const i = indexOf(p.x, p.y); - const kind = coastalSettlement[i] > 0.46 && distanceToNearest(ports, p.x, p.y) < 9 ? "Port Town" : valleySettlement[i] > 0.42 ? "Valley Market Town" : "Market Town"; - const population = Math.round((4200 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.55) * 26000 + marketScore[i] * 16000) / 1000) * 1000; + const kind = coastalSettlement[i] > 0.45 && distanceToNearest(ports, p.x, p.y) < 9 ? "Port Town" : valleySettlement[i] > 0.42 ? "Valley Market Town" : "Market Town"; + const population = Math.round((4000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.50) * 24000 + marketScore[i] * 13000) / 1000) * 1000; return { ...p, kind, population }; }); 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; - } + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + defenseScore[i] = clamp( + confluenceField[i] * 0.38 + + townSuitability[i] * 0.16 + + ridgeField[i] * clamp(1 - Math.abs(elevation[i] - 0.52) / 0.25) * 0.42 + + plain[i] * 0.08 - + floodplain[i] * 0.36 - + coastalLowland[i] * 0.08 + ); } - - let castles = pickPoints(defenseScore, { - threshold: 0.34 + rand(seed, 1051) * 0.08, - max: 2 + Math.floor(rand(seed, 1052) * 4), - minDistance: 15, + const castles = pickGlobalPoints(defenseScore, { + threshold: 0.34 + rand(seed, 1051) * 0.06, + max: 5, + minDistance: 16, 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" || p.kind === "Market Town" || p.kind === "Valley Market Town" ? 0.07 : 0; - return clamp( - (developable?.[i] || 0) * 0.56 + - (townSuitability?.[i] || 0) * 0.24 + - plain[i] * 0.22 + - agriculture[i] * 0.10 + - basinField[i] * 0.16 + - coastalLowland[i] * 0.16 + - valleyField[i] * 0.14 + - (confluenceField?.[i] || 0) * 0.16 + - portBonus + historicalBonus - - slope[i] * 0.54 - - ridgeField[i] * 0.32 - - Math.max(0, elevation[i] - 0.55) * 1.25 - ); - } + const castleTowns = castles.map((c, n) => { + const near = markets.slice().sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y))[0]; + const x = near && Math.hypot(near.x - c.x, near.y - c.y) < 10 ? near.x : c.x; + const y = near && Math.hypot(near.x - c.x, near.y - c.y) < 10 ? near.y : c.y; + return { x, y, score: c.score, kind: "Castle Town", population: 12000 + Math.round(rand(seed, 1060 + n) * 22000 / 1000) * 1000, regionId: regionIdAt(x, y) }; + }); + // --- 3. Cities by region, without detailed urban flood-fill -------------- function estimateUrbanCapacity(p, radius = 22, densityBias = 1.0) { if (!p || !inside(p.x, p.y)) return 0; const centerRegion = regionIdAt(p.x, p.y); @@ -483,2677 +421,521 @@ export function generateMapFeatures(seed, terrain) { if (centerRegion >= 0 && regionIdAt(x, y) !== centerRegion) continue; const d = Math.hypot(dx, dy); if (d > radius) continue; - const dev = developable?.[i] || urbanSiteSuitability({ x, y }); - if (dev < 0.045) continue; + const dev = developable[i]; + if (dev < 0.04) continue; const radial = clamp(1 - d / Math.max(1, radius)); - const coreDensity = 2600 + 6200 * Math.pow(radial, 1.55); - const suburbanDensity = 650 + 2600 * Math.pow(radial, 0.85); - const terrainMultiplier = clamp(0.58 + plain[i] * 0.28 + basinField[i] * 0.18 + coastalLowland[i] * 0.16 + valleyField[i] * 0.14 - slope[i] * 0.44 - ridgeField[i] * 0.20, 0.28, 1.22); - capacity += dev * (coreDensity * 0.34 + suburbanDensity * 0.66) * terrainMultiplier * densityBias; + const terrainMultiplier = clamp(0.60 + plain[i] * 0.28 + basinField[i] * 0.18 + coastalLowland[i] * 0.16 + valleyField[i] * 0.13 - slope[i] * 0.42 - ridgeField[i] * 0.18, 0.26, 1.24); + capacity += dev * (900 + 6200 * Math.pow(radial, 1.25)) * terrainMultiplier * densityBias; } } - const site = urbanSiteSuitability(p); - const hardSitePenalty = site < 0.18 || elevation[indexOf(p.x, p.y)] > 0.68 || slope[indexOf(p.x, p.y)] > 0.78 || ridgeField[indexOf(p.x, p.y)] > 0.76; - if (hardSitePenalty) capacity = Math.min(capacity, 95000); - else if (site < 0.28) capacity = Math.min(capacity, 220000); return Math.max(26000, Math.round(capacity / 1000) * 1000); } - function cityPopulationCap(p) { - const pop = p?.population || 0; - const radius = p?.isPrefecturalCapital ? 34 : p?.isRegionalCapital ? 29 : pop >= 650000 ? 30 : pop >= 250000 ? 24 : pop >= 90000 ? 18 : 13; - const bias = p?.isPrefecturalCapital ? 1.22 : p?.isRegionalCapital ? 1.12 : 1.0; - return estimateUrbanCapacity(p, radius, bias); - } - - function regionIdAt(x, y) { - const i = indexOf(x, y); - if (prefectureMask[i]) return 0; - const id = prefectureRegionId?.[i]; - return id !== undefined && id >= 0 ? id : -1; - } - - function inFocusedPrefecture(point) { - if (!point || !inside(point.x, point.y)) return false; - const i = indexOf(point.x, point.y); - return Boolean(prefectureMask[i] && !sea[i]); - } - - function isHumanRegionCell(i) { - return !sea[i] && (prefectureMask[i] || ((prefectureRegionId?.[i] ?? -1) >= 0)); - } - - function inHumanRegion(point) { - return Boolean(point && inside(point.x, point.y) && isHumanRegionCell(indexOf(point.x, point.y))); - } - - function sameGeneratedRegion(a, b) { - if (!a || !b) return false; - const ar = regionIdAt(a.x, a.y); - const br = regionIdAt(b.x, b.y); - return ar >= 0 && ar === br; - } - - function pathTouchesHumanRegion(path) { - return Boolean(path?.some(([x, y]) => inside(x, y) && isHumanRegionCell(indexOf(x, y)))); - } - - function populationDensityProxyForCapital(i) { - return settlementScore[i] * 0.18 + marketScore[i] * 0.12; - } - - function buildRegionLandStats() { - const areaByRegion = new Map(); - for (let i = 0; i < SIZE; i++) { - if (sea[i]) continue; - const regionId = prefectureMask[i] ? 0 : ((prefectureRegionId?.[i] ?? -1) >= 0 ? prefectureRegionId[i] : -1); - if (regionId < 0) continue; - areaByRegion.set(regionId, (areaByRegion.get(regionId) || 0) + 1); - } - return areaByRegion; - } - - function fallbackCapitalCandidate(regionId = 0) { - const inRegion = (p) => p && regionIdAt(p.x, p.y) === regionId && !sea[indexOf(p.x, p.y)]; - const pools = [...markets, ...ports, ...villages].filter(inRegion); - 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", score: bestScore, regionId }; - - for (let y = 4; y < MAP_H - 4; y++) { - for (let x = 4; x < MAP_W - 4; x++) { - const i = indexOf(x, y); - if (regionIdAt(x, y) !== regionId || 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", regionId }; } - } - } - return best; - } - - function buildCityCandidatePool(castleTowns) { - const historicalCandidates = [ - ...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 })), - ]; - const terrainCandidates = []; - 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 regionId = regionIdAt(x, y); - if (regionId < 0) continue; - const localCapacity = developable[i] * 0.40 + townSuitability[i] * 0.34 + marketScore[i] * 0.16 + confluenceField[i] * 0.08; - const score = localCapacity + settlementScore[i] * 0.22 + plain[i] * 0.12 + basinField[i] * 0.12 + coastalLowland[i] * 0.10 + valleyField[i] * 0.10 - slope[i] * 0.30 - ridgeField[i] * 0.16 + hash2(x, y, seed + 1666) * 0.035; - if (score > 0.29) terrainCandidates.push({ x, y, score, kind: "City Site", regionId }); - } - } - return [...historicalCandidates, ...terrainCandidates].map((p) => { - const i = indexOf(p.x, p.y); - const suitability = urbanSiteSuitability(p); - return { - ...p, - regionId: p.regionId ?? regionIdAt(p.x, p.y), - 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.regionId >= 0 && (p.urbanSuitability >= 0.10 || p.kind === "Castle Town")); - } - - function cityQuotaForRegion(regionId, area) { - const st = settlementRegionStats.get(regionId) || { developable: area, town: 0, valley: 0, coast: 0 }; - const base = 0.7 + Math.sqrt(Math.max(1, st.developable || area)) / 64 + (st.town || 0) / 360 + rand(seed, 1650 + regionId * 19) * 0.85; - const selectedBonus = regionId === 0 ? 2.5 : 0; - const min = regionId === 0 ? 5 : (st.developable > 90 ? 1 : 0); - const max = regionId === 0 ? 8 : (st.developable > 760 ? 3 : st.developable > 320 ? 2 : 1); - return Math.round(clamp(base + selectedBonus, min, max)); - } - - function makeModernCity(candidate, localIndex, regionId, candidateCount) { - const selectedRegion = regionId === 0; - const isRegionCapital = localIndex === 0; - const rank = isRegionCapital - ? (selectedRegion ? "Prefectural Capital" : "Regional Capital") - : localIndex < 4 ? "Regional Center" : "Small City"; - const r = rand(seed, 1600 + regionId * 97 + localIndex * 13 + candidate.x * 3 + candidate.y); - const rawScale = Math.pow(1 - localIndex / Math.max(1, candidateCount + 1), 1.55) * 0.58 + Math.pow(r, 3.4) * 0.42; - const rankBase = rank === "Prefectural Capital" ? 420000 : rank === "Regional Capital" ? 260000 : rank === "Regional Center" ? 115000 : 26000; - const rankSpread = rank === "Prefectural Capital" ? 1450000 : rank === "Regional Capital" ? 820000 : rank === "Regional Center" ? 520000 : 185000; - const pi = indexOf(candidate.x, candidate.y); - const suitability = candidate.urbanSuitability ?? urbanSiteSuitability(candidate); - 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 + (candidate.kind === "Port Town" ? 0.22 : 0)); - const rawPopulation = Math.round((rankBase + rankSpread * Math.pow(rawScale + geographyBoost * 0.18, 1.75)) / 1000) * 1000; - const capacityRadius = rank === "Prefectural Capital" ? 34 : rank === "Regional Capital" ? 29 : rank === "Regional Center" ? 22 : 15; - const capacityBias = rank === "Prefectural Capital" ? 1.22 : rank === "Regional Capital" ? 1.10 : 1.0; - const capacityPop = estimateUrbanCapacity(candidate, capacityRadius, capacityBias); - const floorPop = rank === "Prefectural Capital" ? 260000 : rank === "Regional Capital" ? 90000 : rank === "Regional Center" ? 48000 : 18000; - const limitedPopulation = Math.min(rawPopulation, capacityPop * 1.08); - const population = Math.round((capacityPop >= floorPop ? Math.max(floorPop, limitedPopulation) : limitedPopulation) / 1000) * 1000; - const footprintCells = Math.max(10, population / (rank === "Small City" ? 2200 : rank === "Regional Center" ? 2800 : 3600)); - const urbanRadius = clamp(6.5 + Math.sqrt(footprintCells / Math.PI) * 1.75 + (isRegionCapital ? (selectedRegion ? 2.7 : 1.6) : rank === "Regional Center" ? 1.1 : 0), 7, selectedRegion ? 34 : 30); - const coreRadius = clamp(2.2 + Math.sqrt(population) / 340, 2.6, selectedRegion ? 9 : 8); - const urbanWeight = clamp(0.74 + Math.log10(Math.max(10000, population)) * 0.36 + Math.sqrt(Math.max(1, footprintCells)) / 130, 1.15, selectedRegion ? 3.15 : 2.8); - return { - ...candidate, - population, - urbanRadius, - coreRadius, - urbanWeight, - rank, - regionId, - isRegionalCapital: isRegionCapital, - isPrefecturalCapital: false, - kind: selectedRegion && isRegionCapital ? "Prefectural Capital" : isRegionCapital ? "Regional Capital" : candidate.kind || "City", - }; - } - - function generateUrbanCentersByRegion() { - const castleTowns = castles.map((c) => ({ x: c.x, y: c.y, score: c.score + 0.45, kind: "Castle Town" })); - const cityCandidates = buildCityCandidatePool(castleTowns); - const areaByRegion = buildRegionLandStats(); - const regionIds = [...areaByRegion.keys()].sort((a, b) => a - b); - const modernCities = []; - const debug = { regionCount: regionIds.length, citiesByRegion: {}, candidateCountByRegion: {} }; - - for (const regionId of regionIds) { - const area = areaByRegion.get(regionId) || 0; - if (area < 140) continue; - const candidates = cityCandidates.filter((p) => p.regionId === regionId); - const quota = cityQuotaForRegion(regionId, area); - const threshold = (regionId === 0 ? 0.33 : 0.35) + rand(seed, 1062 + regionId * 31) * 0.12; - let picked = pickEntities(candidates, { - max: quota, - minDistance: regionId === 0 ? 9 : 10, - threshold, - seed: seed + 1060 + regionId * 101, - }); - - if (picked.length === 0 || (regionId === 0 && !picked.some((city) => prefectureMask[indexOf(city.x, city.y)]))) { - const fallback = fallbackCapitalCandidate(regionId); - if (fallback) picked = [fallback, ...picked]; - } - - picked = picked - .filter((city, index, arr) => arr.findIndex((other) => other.x === city.x && other.y === city.y) === index) - .sort((a, b) => { - const ai = indexOf(a.x, a.y); - const bi = indexOf(b.x, b.y); - const aScore = (a.score || 0) + urbanSiteSuitability(a) * 0.85 + plain[ai] * 0.16 - slope[ai] * 0.24; - const bScore = (b.score || 0) + urbanSiteSuitability(b) * 0.85 + plain[bi] * 0.16 - slope[bi] * 0.24; - return bScore - aScore; - }) - .slice(0, quota); - - const regionCities = picked.map((city, localIndex) => makeModernCity(city, localIndex, regionId, candidates.length)); - debug.citiesByRegion[regionId] = regionCities.length; - debug.candidateCountByRegion[regionId] = candidates.length; - modernCities.push(...regionCities); - } - - if (!modernCities.some((city) => prefectureMask[indexOf(city.x, city.y)])) { - const fallback = fallbackCapitalCandidate(0); - if (fallback) modernCities.unshift(makeModernCity(fallback, 0, 0, 1)); - } - - let selectedCapitalIndex = -1; - let selectedCapitalScore = -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 > selectedCapitalScore) { selectedCapitalScore = score; selectedCapitalIndex = i; } - } - - if (selectedCapitalIndex >= 0) { - const cap = modernCities[selectedCapitalIndex]; - modernCities[selectedCapitalIndex] = { - ...cap, - rank: "Prefectural Capital", - kind: "Prefectural Capital", - isPrefecturalCapital: true, - isRegionalCapital: true, - population: Math.round(Math.min(Math.max(cap.population || 0, 620000), estimateUrbanCapacity(cap, 34, 1.25) * 1.08) / 1000) * 1000, - urbanRadius: Math.max(cap.urbanRadius || 0, 18), - coreRadius: Math.max(cap.coreRadius || 0, 5.5), - urbanWeight: Math.max(cap.urbanWeight || 0, 2.15), - }; - } - - for (let i = 0; i < modernCities.length; i++) { - if (i !== selectedCapitalIndex) modernCities[i] = { ...modernCities[i], isPrefecturalCapital: false }; - } - - modernCities.sort((a, b) => { - const aIn = prefectureMask[indexOf(a.x, a.y)] ? 1 : 0; - const bIn = prefectureMask[indexOf(b.x, b.y)] ? 1 : 0; - if (a.isPrefecturalCapital !== b.isPrefecturalCapital) return a.isPrefecturalCapital ? -1 : 1; - if (aIn !== bIn) return bIn - aIn; - if (a.isRegionalCapital !== b.isRegionalCapital) return a.isRegionalCapital ? -1 : 1; - return ((b.population || 0) + (b.score || 0) * 90000) - ((a.population || 0) + (a.score || 0) * 90000); - }); - - return { castleTowns, modernCities, urbanHierarchyDebug: debug }; - } - - const { castleTowns, modernCities, urbanHierarchyDebug } = generateUrbanCentersByRegion(); - - 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 generatedRegionIdsForTransport = [...new Set([ - ...modernCities, - ...ports, - ...markets, - ...castles, - ...villages, - ].map((p) => regionIdAt(p.x, p.y)).filter((id) => id >= 0))].sort((a, b) => a - b); - - function regionNodes(nodes, regionId) { - return nodes.filter((p) => p && regionIdAt(p.x, p.y) === regionId && !sea[indexOf(p.x, p.y)]); - } - - function primaryNodeForRegion(regionId) { - if (regionId === 0) return capital; - return modernCities.find((city) => city.regionId === regionId && city.isRegionalCapital) - || modernCities.find((city) => regionIdAt(city.x, city.y) === regionId) - || markets.find((p) => regionIdAt(p.x, p.y) === regionId) - || ports.find((p) => regionIdAt(p.x, p.y) === regionId) - || villages.find((p) => regionIdAt(p.x, p.y) === regionId) - || null; - } - - function buildRegionalTransportLinks(nodes, makeConfig, seedSalt = 0) { - const links = []; - for (const regionId of generatedRegionIdsForTransport) { - const primary = primaryNodeForRegion(regionId); - const localNodes = uniqueByPosition([primary, ...regionNodes(nodes, regionId)]); - if (localNodes.length < 2) continue; - const config = makeConfig(regionId, localNodes.length); - const localLinks = buildHierarchicalLinks(localNodes, { - ...config, - seedOffset: (config.seedOffset || 0) + seedSalt + regionId * 997, - }); - links.push(...localLinks.map((link) => ({ ...link, regionId }))); - } - return links; - } - - function uniqueByPosition(nodes) { - const seen = new Set(); - const out = []; - for (const node of nodes.filter(Boolean)) { - const key = `${node.x},${node.y}`; - if (seen.has(key)) continue; - seen.add(key); - out.push(node); - } - return out; - } - - - const urbanFootprint = new Uint8Array(SIZE); - const urbanCoreFootprint = new Uint8Array(SIZE); - const oldUrbanFootprint = new Uint8Array(SIZE); - const ruralSettlementFootprint = new Uint8Array(SIZE); - - function canUrbanizeCell(i, centerRegion) { - if (sea[i] || !isHumanRegionCell(i)) return false; - const [x, y] = xyOf(i); - if (centerRegion >= 0 && regionIdAt(x, y) !== centerRegion) return false; - if (elevation[i] > 0.78 || slope[i] > 0.68 || ridgeField[i] > 0.78) return false; - return (developable[i] || 0) > 0.035 || (townSuitability[i] || 0) > 0.18; - } - - function growSettlementFootprint(center, { - targetCells, - coreCells = 0, - maxRadius, - mask, - coreMask = null, - oldMask = null, - seedOffset = 0, - minSupport = -0.08, - allowSmallMountainValleys = false, - }) { - if (!center || !inside(center.x, center.y)) return 0; - const start = indexOf(center.x, center.y); - const centerRegion = regionIdAt(center.x, center.y); - if (sea[start] || !isHumanRegionCell(start)) return 0; - const heap = new MinHeap(); - const queued = new Set([start]); - const selected = []; - heap.push({ i: start, f: -10 }); - const hardLimit = Math.ceil(maxRadius + 2); - - while (heap.length > 0 && selected.length < targetCells) { - const cur = heap.pop(); - if (!cur) break; - const i = cur.i; - const [x, y] = xyOf(i); - const d = Math.hypot(x - center.x, y - center.y); - if (d > maxRadius) continue; - if (!canUrbanizeCell(i, centerRegion)) { - if (!(allowSmallMountainValleys && valleySettlement[i] > 0.34 && slope[i] < 0.58 && elevation[i] < 0.72)) continue; - } - const support = - developable[i] * 1.12 + - townSuitability[i] * 0.36 + - plain[i] * 0.16 + - basinField[i] * 0.14 + - coastalLowland[i] * 0.13 + - valleyField[i] * 0.12 - - slope[i] * 0.62 - - ridgeField[i] * 0.26 - - Math.max(0, elevation[i] - 0.60) * 0.70 - - d / Math.max(1, maxRadius) * 0.12; - if (support < minSupport && selected.length > 0) continue; - selected.push(i); - mask[i] = 1; - if (oldMask && selected.length <= Math.max(2, Math.round(targetCells * 0.18))) oldMask[i] = 1; - if (coreMask && selected.length <= coreCells) coreMask[i] = 1; - - for (const [nx, ny] of neighbors8(x, y)) { - if (Math.abs(nx - center.x) > hardLimit || Math.abs(ny - center.y) > hardLimit) continue; - const ni = indexOf(nx, ny); - if (queued.has(ni) || sea[ni]) continue; - const nd = Math.hypot(nx - center.x, ny - center.y); - if (nd > maxRadius + 1) continue; - const sameRegion = centerRegion < 0 || regionIdAt(nx, ny) === centerRegion; - if (!sameRegion) continue; - const terrainCost = - nd / Math.max(1, maxRadius) * 0.92 + - slope[ni] * 1.35 + - ridgeField[ni] * 0.70 + - Math.max(0, elevation[ni] - 0.58) * 1.10 + - floodplain[ni] * 0.08 - - developable[ni] * 1.42 - - townSuitability[ni] * 0.40 - - plain[ni] * 0.18 - - valleyField[ni] * 0.14 - - coastalLowland[ni] * 0.12 + - hash2(nx, ny, seed + seedOffset) * 0.055; - queued.add(ni); - heap.push({ i: ni, f: terrainCost }); - } - } - return selected.length; - } - - for (let n = 0; n < modernCities.length; n++) { - const city = modernCities[n]; - const pop = city.population || 40000; - const densityPerCell = city.isPrefecturalCapital ? 3600 : city.isRegionalCapital ? 3300 : pop >= 180000 ? 2850 : 2200; - const targetCells = Math.round(clamp(pop / densityPerCell, city.isRegionalCapital ? 22 : 8, city.isPrefecturalCapital ? 620 : city.isRegionalCapital ? 420 : 190)); - const coreCells = Math.round(clamp(pop / (city.isRegionalCapital ? 27000 : 36000), pop >= 120000 ? 3 : 1, city.isPrefecturalCapital ? 58 : city.isRegionalCapital ? 42 : 18)); - const maxRadius = clamp(Math.max(city.urbanRadius || 8, Math.sqrt(targetCells / Math.PI) * 2.25), 7, city.isPrefecturalCapital ? 35 : city.isRegionalCapital ? 30 : 22); - const made = growSettlementFootprint(city, { - targetCells, - coreCells, - maxRadius, - mask: urbanFootprint, - coreMask: urbanCoreFootprint, - oldMask: oldUrbanFootprint, - seedOffset: 6000 + n * 31, - minSupport: city.isRegionalCapital ? -0.12 : -0.06, - }); - city.urbanFootprintCells = made; - city.coreFootprintCells = Math.min(coreCells, made); - city.urbanRadius = Math.max(city.urbanRadius || 0, clamp(Math.sqrt(Math.max(1, made) / Math.PI) * 1.85, 6, city.isRegionalCapital ? 34 : 26)); - city.coreRadius = Math.max(city.coreRadius || 0, clamp(Math.sqrt(Math.max(1, city.coreFootprintCells) / Math.PI) * 1.25, 2, 9)); - } - - for (let n = 0; n < markets.length; n++) { - const town = markets[n]; - const pop = town.population || 9000; - const targetCells = Math.round(clamp(pop / 3600, 2, 13)); - const maxRadius = clamp(3.5 + Math.sqrt(targetCells) * 1.5, 4, 10); - const made = growSettlementFootprint(town, { - targetCells, - coreCells: 0, - maxRadius, - mask: oldUrbanFootprint, - oldMask: oldUrbanFootprint, - seedOffset: 7000 + n * 23, - minSupport: -0.14, - allowSmallMountainValleys: true, - }); - town.urbanFootprintCells = made; - } - - for (const village of villages) { - const i = indexOf(village.x, village.y); - if (!sea[i]) ruralSettlementFootprint[i] = 1; - if ((village.population || 0) > 2600 || valleySettlement[i] > 0.42) { - for (const [nx, ny] of neighbors8(village.x, village.y)) { - const ni = indexOf(nx, ny); - if (!sea[ni] && regionIdAt(nx, ny) === regionIdAt(village.x, village.y) && (ruralSuitability[ni] > 0.22 || valleySettlement[ni] > 0.32)) ruralSettlementFootprint[ni] = 1; - } - } - } - - 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; - if (urbanCoreFootprint[i]) density += 1.35; - else if (oldUrbanFootprint[i]) density += 0.72; - else if (urbanFootprint[i]) density += 0.52; - else if (ruralSettlementFootprint[i]) density += 0.12; - for (const city of modernCities) { - const populationScale = clamp((Math.log10(Math.max(10000, city.population || 10000)) - 4) / 2.25, 0.12, 1.60); - 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); - const inFootprint = urbanFootprint[i] ? 1 : 0; - density += populationScale * (inFootprint ? 0.92 : 0.82) / (1 + Math.pow(d / urbanR, 2.28)); - density += populationScale * 0.72 * Math.exp(-(d * d) / (coreR * coreR * 2.05)); - } - for (const market of markets) { - const d = Math.hypot(market.x - x, market.y - y); - density += 0.18 / (1 + Math.pow(d / 6.2, 2.0)); - } - for (const village of villages) { - const d = Math.hypot(village.x - x, village.y - y); - density += 0.038 / (1 + Math.pow(d / 3.5, 2)); - } - const terrainFactor = urbanFootprint[i] || oldUrbanFootprint[i] - ? clamp(0.68 + plain[i] * 0.32 + agriculture[i] * 0.08 + basinField[i] * 0.16 + coastalLowland[i] * 0.12 + valleyField[i] * 0.10 - slope[i] * 0.46 - ridgeField[i] * 0.18, 0.34, 1.20) - : clamp(0.42 + plain[i] * 0.58 + agriculture[i] * 0.18 + basinField[i] * 0.22 + coastalLowland[i] * 0.18 + valleyField[i] * 0.14 - slope[i] * 0.92 - ridgeField[i] * 0.42 - Math.max(0, elevation[i] - 0.58) * 0.90, 0.018, 1.18); - density *= terrainFactor; - 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 transportTier(p) { - const pop = p?.population || 0; - if (p?.isPrefecturalCapital || p?.rank === "Prefectural Capital") return 0; - if (pop >= 900000) return 1; - if (pop >= 360000) return 2; - if (pop >= 180000) return 3; - if (pop >= 90000) return 4; - if (p?.portClass === "major") return 2; - if (p?.portClass === "regional") return 3; - if (p?.kind === "Market Town") return 4; - if (p?.kind?.includes("Castle")) return 5; - return 6; - } - - function nodeKey(p) { - return `${p.x},${p.y}`; - } - - function addUniqueNode(list, node) { - if (!node) return; - const key = nodeKey(node); - if (!list.some((p) => nodeKey(p) === key)) list.push(node); - } - - function pointLineDistanceXY(x, y, a, b) { - const vx = b.x - a.x; - const vy = b.y - a.y; - const len2 = vx * vx + vy * vy; - if (len2 <= 0.0001) return Math.hypot(x - a.x, y - a.y); - const t = clamp(((x - a.x) * vx + (y - a.y) * vy) / len2, 0, 1); - return Math.hypot(x - (a.x + vx * t), y - (a.y + vy * t)); - } - - function segmentProgressXY(x, y, a, b) { - const vx = b.x - a.x; - const vy = b.y - a.y; - const len2 = vx * vx + vy * vy; - if (len2 <= 0.0001) return 0; - return clamp(((x - a.x) * vx + (y - a.y) * vy) / len2, 0, 1); - } - - function cellTransportCorridorScore(x, y, mode = "road") { - if (!inside(x, y)) return -INF; - const i = indexOf(x, y); - if (sea[i]) return -INF; - const barrier = mountainBarrierPenalty(x, y, mode === "express" ? "express" : mode === "rail" ? "rail" : "road"); - if (barrier >= INF) return -INF; - const density = densityValue(x, y); - const midDensity = midDensityAffinity(x, y); - const lowland = plain[i] * 0.42 + basinField[i] * 0.24 + coastalLowland[i] * 0.22 + valleyField[i] * 0.34 + agriculture[i] * 0.08; - const terrainCost = slope[i] * (mode === "rail" ? 1.35 : mode === "express" ? 1.18 : 1.0) - + ridgeField[i] * 0.62 - + Math.max(0, elevation[i] - (mode === "rail" ? 0.50 : 0.56)) * 1.18 - + barrier * (mode === "rail" ? 0.0048 : mode === "express" ? 0.0036 : 0.0038); - if (mode === "rail") return density * 1.95 + lowland + coastalLowland[i] * 0.22 + valleyField[i] * 0.24 - terrainCost; - if (mode === "express") return density * 0.82 + midDensity * 1.10 + lowland * 0.62 - Math.max(0, density - 0.88) * 1.35 - terrainCost; - return density * 1.28 + midDensity * 0.28 + lowland + valleyField[i] * 0.18 - terrainCost; - } - - function sampleCorridorValue(a, b, mode = "road", samples = 14) { - let total = 0; - let count = 0; - for (let k = 1; k < samples; k++) { - const t = k / samples; - 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 score = cellTransportCorridorScore(x, y, mode); - if (score <= -INF / 2) continue; - total += score; - count++; - } - return count ? total / count : -3; - } - - function pairTransportScore(a, b, mode = "road") { - const d = Math.hypot(a.x - b.x, a.y - b.y); - if (d < 4) return -INF; - const demand = Math.sqrt(Math.max(0.01, transportDemand(a)) * Math.max(0.01, transportDemand(b))); - const hierarchyDelta = Math.max(0, transportTier(b) - transportTier(a)); - const corridor = sampleCorridorValue(a, b, mode); - const sameCorridor = sameCorridorAffinity(a, b); - const distancePenalty = mode === "express" ? d / 58 : mode === "rail" ? d / 48 : d / 42; - const hierarchyBonus = hierarchyDelta * (mode === "express" ? 0.16 : 0.10); - const portBonus = (a.portClass || b.portClass) ? (mode === "rail" ? 0.34 : mode === "express" ? 0.18 : 0.26) : 0; - return demand * (mode === "express" ? 1.08 : mode === "rail" ? 1.18 : 1.0) + corridor * 0.72 + sameCorridor + hierarchyBonus + portBonus - distancePenalty; - } - - function buildHierarchicalLinks(nodes, { mode = "road", maxLinks = 10, extraLinks = 3, minDistance = 10, maxDistance = 70, maxDegree = 3, seedOffset = 0 } = {}) { - const unique = []; - const seen = new Set(); - for (const node of nodes.filter(Boolean)) { - const i = indexOf(node.x, node.y); - if (!inside(node.x, node.y) || sea[i]) continue; - const key = nodeKey(node); - if (seen.has(key)) continue; - seen.add(key); - unique.push({ ...node, transportTier: transportTier(node), demand: transportDemand(node) }); - } - const ranked = unique.sort((a, b) => a.transportTier - b.transportTier || b.demand - a.demand || b.score - a.score); - const degree = new Map(); - const usedPairs = new Set(); - const links = []; - - function pairKey(a, b) { - const ak = nodeKey(a); - const bk = nodeKey(b); - return ak < bk ? `${ak}|${bk}` : `${bk}|${ak}`; - } - function tryAdd(a, b, force = false) { - if (!a || !b || nodeKey(a) === nodeKey(b)) return false; - const d = Math.hypot(a.x - b.x, a.y - b.y); - if (d < minDistance || d > maxDistance) return false; - const key = pairKey(a, b); - if (usedPairs.has(key)) return false; - if (!force && (getDegree(degree, a) >= maxDegree || getDegree(degree, b) >= maxDegree)) return false; - usedPairs.add(key); - incrementDegree(degree, a); - incrementDegree(degree, b); - links.push({ a, b, score: pairTransportScore(a, b, mode), distance: d }); - return true; - } - - for (let i = 1; i < ranked.length && links.length < maxLinks; i++) { - const child = ranked[i]; - const parentCandidates = ranked.slice(0, i) - .filter((parent) => transportTier(parent) <= transportTier(child) && Math.hypot(parent.x - child.x, parent.y - child.y) <= maxDistance) - .map((parent) => ({ parent, score: pairTransportScore(parent, child, mode) - getDegree(degree, parent) * 0.16 - Math.max(0, getDegree(degree, child) - 1) * 0.22 })) - .sort((a, b) => b.score - a.score); - if (parentCandidates[0]) tryAdd(parentCandidates[0].parent, child, true); - } - - const candidates = []; - for (let i = 0; i < ranked.length; i++) { - for (let j = i + 1; j < ranked.length; j++) { - const a = ranked[i]; - const b = ranked[j]; - const d = Math.hypot(a.x - b.x, a.y - b.y); - if (d < minDistance || d > maxDistance) continue; - candidates.push({ a, b, score: pairTransportScore(a, b, mode) + hash2(a.x + b.x, a.y + b.y, seed + seedOffset + i * 31 + j * 37) * 0.05 }); - } - } - candidates.sort((a, b) => b.score - a.score); - let addedExtra = 0; - for (const c of candidates) { - if (links.length >= maxLinks || addedExtra >= extraLinks) break; - if (tryAdd(c.a, c.b)) addedExtra++; - } - return links; - } - - function pickCorridorWaypoints(a, b, mode = "road", maxCount = 2) { - const d = Math.hypot(a.x - b.x, a.y - b.y); - if (d < 20) return []; - const width = mode === "express" ? 10.5 : mode === "rail" ? 8.5 : 9.5; - const minProgress = 0.18; - const maxProgress = 0.82; - const candidates = []; - const minX = Math.max(1, Math.floor(Math.min(a.x, b.x) - width - 3)); - const maxX = Math.min(MAP_W - 2, Math.ceil(Math.max(a.x, b.x) + width + 3)); - const minY = Math.max(1, Math.floor(Math.min(a.y, b.y) - width - 3)); - const maxY = Math.min(MAP_H - 2, Math.ceil(Math.max(a.y, b.y) + width + 3)); - for (let y = minY; y <= maxY; y++) { - for (let x = minX; x <= maxX; x++) { - const progress = segmentProgressXY(x, y, a, b); - if (progress < minProgress || progress > maxProgress) continue; - const lineD = pointLineDistanceXY(x, y, a, b); - if (lineD > width) continue; - const i = indexOf(x, y); - if (sea[i]) continue; - const cellScore = cellTransportCorridorScore(x, y, mode); - if (cellScore <= -INF / 2) continue; - const centerBias = -Math.abs(progress - 0.5) * 0.15; - const linePenalty = lineD / width * (mode === "express" ? 0.42 : 0.32); - const density = densityValue(x, y); - const densityGate = mode === "express" ? midDensityAffinity(x, y) * 0.22 : density * 0.20; - const score = cellScore + densityGate + centerBias - linePenalty + hash2(x, y, seed + 6400 + mode.length * 101) * 0.05; - candidates.push({ x, y, score, progress, kind: `${mode} corridor waypoint` }); - } - } - if (!candidates.length) return []; - const count = Math.min(maxCount, d > 62 ? 2 : 1); - return pickEntities(candidates, { - max: count, - minDistance: Math.max(7, Math.floor(d / 4.2)), - threshold: mode === "express" ? -0.42 : -0.30, - seed: seed + 6500 + Math.round(a.x * 13 + a.y * 17 + b.x * 19 + b.y * 23), - }).sort((p, q) => p.progress - q.progress); - } - - function makeDensityAwareTransportCost(baseCost, mode, guidePoints = []) { - return (x, y, cx, cy) => { - const base = baseCost(x, y, cx, cy); - if (base >= INF) return base; - const density = densityValue(x, y); - const midDensity = midDensityAffinity(x, y); - const cityDistance = distanceToNearest(modernCities, x, y); - let guidePull = 0; - for (const p of guidePoints) guidePull = Math.max(guidePull, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 5.8)); - if (mode === "rail") { - const lowDemandPenalty = Math.max(0, 0.08 - density) * 2.4; - return Math.max(0.30, base + lowDemandPenalty - density * 0.72 - guidePull * 0.34); - } - if (mode === "express") { - const coreAvoid = cityDistance < 2.6 ? 5.0 : cityDistance < 5.8 ? 1.4 : 0; - const lowDemandPenalty = Math.max(0, 0.10 - density) * 3.2; - return Math.max(0.42, base + lowDemandPenalty + coreAvoid - midDensity * 0.36 - Math.min(density, 0.72) * 0.18 - guidePull * 0.20); - } - const lowDemandPenalty = Math.max(0, 0.06 - density) * 1.5; - return Math.max(0.28, base + lowDemandPenalty - density * 0.44 - midDensity * 0.12 - guidePull * 0.26); - }; - } - - function routeThroughTransportCorridor(a, b, mode, baseCost, existingPaths, hubs, avoidPoints, options = {}) { - const start = routePoint(a, mode, a.x * 31 + a.y * 37 + (options.salt || 0)); - const goal = routePoint(b, mode, b.x * 31 + b.y * 37 + 17 + (options.salt || 0)); - const via = pickCorridorWaypoints(start, goal, mode, options.maxWaypoints ?? (mode === "express" ? 1 : 2)); - const terminals = [start, ...via, goal]; - const endpointSet = [start, goal, ...via]; - const guidedBaseCost = makeDensityAwareTransportCost(baseCost, mode, via); - const path = []; - for (let i = 0; i < terminals.length - 1; i++) { - const from = terminals[i]; - const to = terminals[i + 1]; - const cost = makeTransportCost( - guidedBaseCost, - [...existingPaths, path], - hubs, - endpointSet, - options.corridorRadius ?? (mode === "express" ? 5 : mode === "rail" ? 5 : 3), - options.corridorStrength ?? (mode === "express" ? 9.4 : mode === "rail" ? 8.8 : 5.8), - avoidPoints, - options.avoidRadius ?? (mode === "express" ? 8.0 : mode === "rail" ? 2.5 : 3.2), - options.avoidStrength ?? (mode === "express" ? 12.0 : mode === "rail" ? 4.2 : 5.4), - ); - const segment = aStar(from, to, cost); - if (segment.length < 2) return { path: [], via, start, goal }; - if (path.length) path.push(...segment.slice(1)); - else path.push(...segment); - } - return { path, via, start, goal }; - } - - 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" ? 6 : mode === "rail" ? 2 : 3; - const maxR = mode === "express" ? 16 : mode === "rail" ? 6 : 8; - 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" ? density * 0.70 + midDensityAffinity(x, y) * 0.18 - Math.max(0, density - 0.92) * 0.35 : 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 existingRails = [...railways, ...branchRailways]; - const { path } = routeThroughTransportCorridor(a, b, "rail", railCost, existingRails, railHubs, townAvoidNodes, { - salt: a.x * 19 + a.y * 23 + b.x * 7 + b.y * 11, - maxWaypoints: bucket === railways ? 2 : 1, - corridorRadius: 5, - corridorStrength: bucket === railways ? 10.8 : 8.4, - avoidRadius: 2.4, - avoidStrength: 4.2, - }); - const length = pathLength(path); - const direct = pathEndpointDistance(path); - const overlap = pathOverlapRatio(path, existingRails, 2); - const densityPurpose = averagePathField(path, populationDensity) * 1.22 + averagePathField(path, plain) * 0.24 + averagePathField(path, valleyField) * 0.22 + averagePathField(path, coastalLowland) * 0.16; - const isMain = bucket === railways; - if (path.length > 3 && direct >= (isMain ? 18 : 12) && length >= (isMain ? 22 : 14) && pathCompactness(path) < (isMain ? 3.25 : 3.5) && overlap < (isMain ? 0.32 : 0.22) && densityPurpose > (isMain ? 0.20 : 0.13)) { - bucket.push(path); - incrementDegree(railDegree, a); - incrementDegree(railDegree, b); - return true; - } - return false; - } - - function addRailRouteRelaxed(a, b, bucket = railways) { - if (!a || !b || Math.hypot(a.x - b.x, a.y - b.y) < 6) return false; - const existingRails = [...railways, ...branchRailways, ...bucket].filter(Boolean); - const start = routePoint(a, "rail", a.x * 83 + a.y * 89); - const goal = routePoint(b, "rail", b.x * 97 + b.y * 101); - const path = aStar(start, goal, makeTransportCost(railCost, existingRails, railHubs, [start, goal], 4, 7.4, townAvoidNodes, 2.0, 3.0)); - const direct = pathEndpointDistance(path); - const densityPurpose = averagePathField(path, populationDensity) * 0.95 + averagePathField(path, plain) * 0.20 + averagePathField(path, valleyField) * 0.28 + averagePathField(path, coastalLowland) * 0.18; - if (path.length > 4 && pathTouchesHumanRegion(path) && direct >= 8 && pathLength(path) >= 10 && pathLength(path) < 150 && pathCompactness(path) < 5.1 && pathOverlapRatio(path, existingRails, 2) < 0.58 && densityPurpose > 0.055) { - bucket.push(path); - incrementDegree(railDegree, a); - incrementDegree(railDegree, b); - return true; - } - return false; - } - - function addRailRouteForced(a, b, bucket = railways) { - if (!a || !b || Math.hypot(a.x - b.x, a.y - b.y) < 6) return false; - const existingRails = [...railways, ...branchRailways, ...bucket].filter(Boolean); - const start = routePoint(a, "rail", a.x * 109 + a.y * 113); - const goal = routePoint(b, "rail", b.x * 127 + b.y * 131); - const softRailCost = (x, y) => { - const i = indexOf(x, y); - if (sea[i]) return INF; - const rawBarrier = mountainBarrierPenalty(x, y, "rail"); - if (rawBarrier >= INF && elevation[i] > 0.88) return INF; - const barrier = rawBarrier >= INF ? 80 + Math.max(0, elevation[i] - 0.68) * 140 + slope[i] * 28 : rawBarrier * 0.42; - return Math.max(0.38, 1 + slope[i] * 11.5 + barrier + Math.max(0, elevation[i] - 0.60) * 5.8 + ridgeField[i] * 0.55 + (river[i] > 0.5 ? 0.95 : river[i] * 0.28) - densityValue(x, y) * 0.55 - valleyField[i] * 0.34 - plain[i] * 0.16 - coastalLowland[i] * 0.16 + normalEdgePenalty(x, y) * 0.3); - }; - const path = aStar(start, goal, makeTransportCost(softRailCost, existingRails, railHubs, [start, goal], 4, 6.2, townAvoidNodes, 1.8, 2.2)); - const direct = pathEndpointDistance(path); - if (path.length > 4 && pathTouchesHumanRegion(path) && direct >= 7 && pathLength(path) < 180 && pathCompactness(path) < 7.2 && pathOverlapRatio(path, existingRails, 2) < 0.76) { - bucket.push(path); - incrementDegree(railDegree, a); - incrementDegree(railDegree, b); - return true; - } - return false; - } - - const transportCities = modernCities.filter((city) => (city.population || 0) >= 120000); - const railBackboneNodes = [capital, ...transportCities, ...commercialPorts.filter((p) => p.portClass !== "fishing")]; - const mainRailLinks = buildHierarchicalLinks(railBackboneNodes, { - mode: "rail", - maxLinks: 3 + Math.floor(rand(seed, 1070) * 3), - extraLinks: 1, - minDistance: 16, - maxDistance: 72, - maxDegree: 3, - seedOffset: 1070, - }); - for (const link of mainRailLinks) { - if (addRailRoute(link.a, link.b, railways) || addRailRouteRelaxed(link.a, link.b, railways) || addRailRouteForced(link.a, link.b, railways)) { - addUniqueNode(railCore, link.a); - addUniqueNode(railCore, link.b); - } - } - - const branchRailNodes = [capital, ...railCore, ...modernCities.filter((city) => city !== capital && (city.population || 0) < 220000), ...majorPorts]; - const branchRailLinks = buildHierarchicalLinks(branchRailNodes, { - mode: "rail", - maxLinks: 5 + Math.floor(rand(seed, 1071) * 4), - extraLinks: 1, - minDistance: 12, - maxDistance: 54, - maxDegree: 2, - seedOffset: 1071, - }); - for (const link of branchRailLinks) { - if (railways.length && (addRailRoute(link.a, link.b, branchRailways) || addRailRouteRelaxed(link.a, link.b, branchRailways) || addRailRouteForced(link.a, link.b, branchRailways))) { - addUniqueNode(railCore, link.a); - addUniqueNode(railCore, link.b); - } - } - - const regionalMainRailLinks = buildRegionalTransportLinks( - [...modernCities.filter((city) => (city.population || 0) >= 105000), ...commercialPorts.filter((p) => p.portClass !== "fishing")], - (regionId, count) => ({ - mode: "rail", - maxLinks: Math.min(regionId === 0 ? 5 : 3, Math.max(1, count - 1)), - extraLinks: regionId === 0 ? 1 : 0, - minDistance: 14, - maxDistance: 66, - maxDegree: 3, - seedOffset: 2070, - }), - 2070, - ); - for (const link of regionalMainRailLinks) { - if (addRailRoute(link.a, link.b, railways) || addRailRouteRelaxed(link.a, link.b, railways) || addRailRouteForced(link.a, link.b, railways)) { - addUniqueNode(railCore, link.a); - addUniqueNode(railCore, link.b); - } - } - - const regionalBranchRailLinks = buildRegionalTransportLinks( - [...modernCities.filter((city) => (city.population || 0) >= 45000), ...commercialPorts, ...markets], - (regionId, count) => ({ - mode: "rail", - maxLinks: Math.min(regionId === 0 ? 6 : 3, Math.max(1, count - 1)), - extraLinks: 0, - minDistance: 10, - maxDistance: 48, - maxDegree: 2, - seedOffset: 2071, - }), - 2071, - ); - for (const link of regionalBranchRailLinks) { - if (railways.length && (addRailRoute(link.a, link.b, branchRailways) || addRailRouteRelaxed(link.a, link.b, branchRailways) || addRailRouteForced(link.a, link.b, branchRailways))) { - addUniqueNode(railCore, link.a); - addUniqueNode(railCore, link.b); - } - } - - compactPathArray(railways, { minLength: 10, maxOverlap: 0.40, maxCount: Math.max(5, generatedRegionIdsForTransport.length * 3) }); - compactPathArray(branchRailways, { minLength: 8, maxOverlap: 0.30, maxCount: Math.max(9, generatedRegionIdsForTransport.length * 4) }); - - if (railways.length === 0) { - for (const regionId of generatedRegionIdsForTransport) { - const primary = primaryNodeForRegion(regionId); - const targets = regionNodes(modernCities, regionId) - .filter((city) => primary && nodeKey(city) !== nodeKey(primary) && (city.population || 0) >= 60000) - .sort((a, b) => (b.population || 0) - (a.population || 0)) - .slice(0, 2); - for (const target of targets) { - if (addRailRouteForced(primary, target, railways)) break; - } - if (railways.length >= Math.max(1, Math.min(3, generatedRegionIdsForTransport.length))) break; - } - } - - 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 })), + const urbanCandidates = [ + ...markets.map((p) => ({ ...p, candidateKind: "town" })), + ...castleTowns.map((p) => ({ ...p, candidateKind: "castleTown" })), + ...commercialPorts.map((p) => ({ ...p, candidateKind: "port" })), + ...crossings.filter((p) => confluenceField[indexOf(p.x, p.y)] > 0.12).map((p) => ({ ...p, candidateKind: "crossing" })), ]; - 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); - } + const cityCandidateByRegion = new Map(); + for (const p of urbanCandidates) { + const i = indexOf(p.x, p.y); + const regionId = regionIdAt(p.x, p.y); + if (regionId < 0) continue; + const st = regionStats.get(regionId); + const cityRadius = st && st.area > 2400 ? 28 : st && st.area > 900 ? 24 : 20; + const capacity = estimateUrbanCapacity(p, cityRadius, 1.0); + const score = + Math.log10(capacity + 1) * 0.72 + + townSuitability[i] * 1.40 + + developable[i] * 1.05 + + confluenceField[i] * 0.22 + + (p.candidateKind === "port" ? 0.48 : 0) + + (p.candidateKind === "castleTown" ? 0.22 : 0) + + hash2(p.x, p.y, seed + 12000) * 0.16; + if (!cityCandidateByRegion.has(regionId)) cityCandidateByRegion.set(regionId, []); + cityCandidateByRegion.get(regionId).push({ ...p, score, capacity, regionId }); } - 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 coreAvoid = cityDistance < 2.2 ? 18.0 : cityDistance < 4.5 ? 7.0 : cityDistance < 7.5 ? 2.0 : 0; - const marketAvoid = distanceToNearest(markets, x, y) < 2.5 ? 1.8 : 0; - const lowDensityPenalty = density < 0.10 ? (0.10 - density) * 4.8 : 0; - const urbanCorridorBonus = density * 1.18 + midDensity * 0.28 + (cityDistance >= 4 && cityDistance <= 16 ? 0.40 : 0); - const constructionCost = 0.58 + slope[i] * 28.0 + barrier * 1.10 + Math.max(0, elevation[i] - 0.60) * 16.0 + ridgeField[i] * 1.45 + (river[i] > 0.45 ? 1.15 : river[i] * 0.45); - return Math.max(0.50, 1.18 + constructionCost + coreAvoid + marketAvoid + lowDensityPenalty - urbanCorridorBonus - plain[i] * 0.12 - valleyField[i] * 0.12 - coastalLowland[i] * 0.12 + 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) => inHumanRegion(p) && (p.population || 0) >= 110000), - ...ports.filter(inHumanRegion), - ...markets.filter(inHumanRegion), - ...castles.filter(inHumanRegion), - ].map((p) => ({ ...p, demand: transportDemand(p), score: (p.score || 0.4) + transportDemand(p) * 0.34 + ((p.population || 0) >= 220000 ? 0.30 : 0.05) + densityValue(p.x, p.y) * 0.20 })); - const pickedRoadTargets = pickEntities(roadTargetCandidates, { - max: 8 + Math.floor(rand(seed, 1101) * 9), - minDistance: 9, - threshold: 0.1, - 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 existing = [...nationalRoads, ...railways, ...branchRailways]; - const { path } = routeThroughTransportCorridor(a, b, "road", roadCost, existing, roadHubs, townAvoidNodes, { - salt: a.x * 31 + a.y * 37 + b.x * 13 + b.y * 17, - maxWaypoints: 2, - corridorRadius: 3, - corridorStrength: 7.0, - avoidRadius: 2.8, - avoidStrength: 4.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 densityPurpose = averagePathField(path, populationDensity) * 1.28 + averagePathField(path, plain) * 0.18 + averagePathField(path, valleyField) * 0.16; - const passBonusOk = urbanPasses >= 1 || densityPurpose > 0.16 || direct >= 20; - if (path.length > 3 && direct >= 12 && pathLength(path) >= 14 && pathCompactness(path) < 4.15 && pathOverlapRatio(path, existing, 2) < 0.74 && passBonusOk) { - nationalRoads.push(path); - incrementDegree(roadDegree, a); - incrementDegree(roadDegree, b); - return true; - } - return false; - } - - function addNationalRoadRelaxed(a, b, bucket = nationalRoads) { - if (!a || !b || Math.hypot(a.x - b.x, a.y - b.y) < 5) return false; - const existing = [...nationalRoads, ...bucket, ...railways, ...branchRailways]; - const { path } = routeThroughTransportCorridor(a, b, "road", roadCost, existing, roadHubs, townAvoidNodes, { - salt: a.x * 47 + a.y * 53 + b.x * 59 + b.y * 61, - maxWaypoints: 2, - corridorRadius: 4, - corridorStrength: 8.2, - avoidRadius: 2.3, - avoidStrength: 3.2, - }); - const direct = pathEndpointDistance(path); - const densityPurpose = averagePathField(path, populationDensity) * 1.06 + averagePathField(path, plain) * 0.14 + averagePathField(path, valleyField) * 0.18 + averagePathField(path, coastalLowland) * 0.10; - const usefulInside = pathTouchesHumanRegion(path); - if (path.length > 3 && usefulInside && direct >= 8 && pathLength(path) >= 10 && pathCompactness(path) < 5.05 && pathOverlapRatio(path, existing, 2) < 0.86 && densityPurpose > 0.070) { - bucket.push(path); - incrementDegree(roadDegree, a); - incrementDegree(roadDegree, b); - return true; - } - return false; - } - - function coverageRoadCost(x, y) { - const i = indexOf(x, y); - if (sea[i]) return INF; - const rawBarrier = mountainBarrierPenalty(x, y, "road"); - if (rawBarrier >= INF && elevation[i] > 0.84) return INF; - const barrier = rawBarrier >= INF ? 90 + Math.max(0, elevation[i] - 0.66) * 160 + slope[i] * 34 : rawBarrier * 0.38; - const density = densityValue(x, y); - return Math.max(0.34, 1 + slope[i] * 9.8 + barrier + Math.max(0, elevation[i] - 0.58) * 4.8 + ridgeField[i] * 0.45 + (river[i] > 0.5 ? 0.75 : 0) - density * 0.86 - plain[i] * 0.20 - valleyField[i] * 0.30 - coastalLowland[i] * 0.16 + normalEdgePenalty(x, y) * 0.4 + hash2(x, y, seed + 338) * 0.04); - } - - function nationalRoadCorridorScore(path) { - if (!path || !path.length) return -1; - const density = averagePathField(path, populationDensity); - const lowland = averagePathField(path, plain); - const valley = averagePathField(path, valleyField); - const coast = averagePathField(path, coastalLowland); - const avgSlope = averagePathField(path, slope); - const avgElevation = averagePathField(path, elevation); - // Low-density valley/coastal corridors are allowed. The score is meant to - // reject truly roadless mountain/ridge alignments, not rural national roads. - return density * 0.46 + lowland * 0.24 + valley * 0.24 + coast * 0.16 - avgSlope * 0.18 - Math.max(0, avgElevation - 0.60) * 0.15; - } - - function isBackcountryNationalRoad(path, a, b) { - const corridor = nationalRoadCorridorScore(path); - const endpointWeight = nationalRoadPopulationWeight(a) + nationalRoadPopulationWeight(b); - const direct = pathEndpointDistance(path); - const valley = averagePathField(path, valleyField); - const coast = averagePathField(path, coastalLowland); - const lowland = averagePathField(path, plain); - const naturalCorridor = valley * 0.8 + coast * 0.65 + lowland * 0.55; - const endpointDensity = Math.max(densityValue(a.x, a.y), densityValue(b.x, b.y)); - const remoteEndpoint = endpointDensity < 0.10 && endpointWeight < 52000; - const longRemote = direct > 34 && corridor < 0.075 && naturalCorridor < 0.16; - return (corridor < 0.045 && naturalCorridor < 0.13) || (remoteEndpoint && corridor < 0.070 && naturalCorridor < 0.18) || longRemote; - } - - function addNationalRoadCoverageFallback(a, b, bucket = nationalRoads) { - if (!a || !b || Math.hypot(a.x - b.x, a.y - b.y) < 5) return false; - const existing = [...nationalRoads, ...bucket, ...railways, ...branchRailways].filter(Boolean); - const path = aStar(a, b, makeTransportCost(coverageRoadCost, existing, roadHubs, [a, b], 4, 8.2, townAvoidNodes, 2.0, 3.0)); - const direct = pathEndpointDistance(path); - const usefulInside = pathTouchesHumanRegion(path); - if (path.length > 4 && usefulInside && direct >= 7 && pathLength(path) < 150 && pathCompactness(path) < 6.9 && !isBackcountryNationalRoad(path, a, b)) { - bucket.push(path); - incrementDegree(roadDegree, a); - incrementDegree(roadDegree, b); - return true; - } - return false; - } - - const roadLinks = buildHierarchicalLinks(roadTargets, { - mode: "road", - maxLinks: 7 + Math.floor(rand(seed, 1102) * 5), - extraLinks: 3, - minDistance: 13, - maxDistance: 62, - maxDegree: 3, - seedOffset: 1102, - }); - for (const link of roadLinks) { - if (addNationalRoad(link.a, link.b)) { - addUniqueNode(roadCore, link.a); - addUniqueNode(roadCore, link.b); - } - } - - // 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) => inHumanRegion(city) && (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++) { - const a = trunkCities[i]; - const b = trunkCities[i + 1]; - const d = a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0; - if (a && b && sameGeneratedRegion(a, b) && d >= 13 && d <= 58 && getDegree(roadDegree, a) < 5 && getDegree(roadDegree, b) < 5) addNationalRoad(a, b); - } - // Add a second, sparse north-south / inland-coastal layer so towns are not - // only chained left-to-right. This helps yellow national roads pass through - // multiple towns instead of ending as isolated spurs. - const verticalTrunkCities = trunkCities.slice().sort((a, b) => a.y - b.y || a.x - b.x); - for (let i = 0; i < verticalTrunkCities.length - 2; i += 3) { - const a = verticalTrunkCities[i]; - const b = verticalTrunkCities[Math.min(verticalTrunkCities.length - 1, i + 2)]; - const d = a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0; - if (a && b && sameGeneratedRegion(a, b) && d >= 20 && d <= 62 && (a.population || 0) >= 110000 && (b.population || 0) >= 110000 && getDegree(roadDegree, a) < 5 && getDegree(roadDegree, b) < 5) addNationalRoad(a, b); - } - - function addRegionalNationalRoadBackbones() { - let added = 0; - const regionalRoadLinks = buildRegionalTransportLinks( - [ - ...modernCities.filter((city) => (city.population || 0) >= 45000), - ...ports.filter((p) => p.portClass !== "fishing"), - ...markets, - ...castleTowns, - ], - (regionId, count) => ({ - mode: "road", - maxLinks: Math.min(regionId === 0 ? 8 : 5, Math.max(1, count - 1)), - extraLinks: regionId === 0 ? 2 : 1, - minDistance: 9, - maxDistance: 56, - maxDegree: regionId === 0 ? 4 : 3, - seedOffset: 2102, - }), - 2102, + const modernCities = []; + const usedCitySites = []; + for (const [regionId, list] of [...cityCandidateByRegion.entries()].sort((a, b) => a[0] - b[0])) { + const st = regionStats.get(regionId); + if (!st || st.developableCells < 30) continue; + const vf = visibilityFactor(regionId, st); + const maxCities = clamp( + Math.round((st.developableCells / 720 + 0.9) * vf + rand(seed, 12100 + regionId * 17) * 1.2), + st.area > 1600 ? 1 : 0, + st.area > 3600 ? 6 : st.area > 2200 ? 4 : st.area > 900 ? 3 : 1 ); - for (const link of regionalRoadLinks) { - if (!sameGeneratedRegion(link.a, link.b)) continue; - if (addNationalRoad(link.a, link.b) || addNationalRoadRelaxed(link.a, link.b)) { - addUniqueNode(roadCore, link.a); - addUniqueNode(roadCore, link.b); - added++; - } + const selected = pickEntities(list, { + max: maxCities, + minDistance: 17, + threshold: 0, + seed: seed + 12110 + regionId * 313, + jitter: 0.02, + }); + for (const p of selected) { + if (usedCitySites.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < 12)) continue; + usedCitySites.push(p); + modernCities.push(p); } - for (const regionId of generatedRegionIdsForTransport) { - const localCities = regionNodes(modernCities, regionId) - .filter((city) => (city.population || 0) >= 70000) - .sort((a, b) => a.x - b.x || a.y - b.y); - for (let i = 0; i < localCities.length - 1; i++) { - const a = localCities[i]; - const b = localCities[i + 1]; - const d = Math.hypot(a.x - b.x, a.y - b.y); - if (d >= 10 && d <= 52 && getDegree(roadDegree, a) < 6 && getDegree(roadDegree, b) < 6 && (addNationalRoad(a, b) || addNationalRoadRelaxed(a, b))) added++; - } - } - return added; } - const regionalNationalRoadsAdded = addRegionalNationalRoadBackbones(); + // No focused-prefecture fallback: all prefecture regions use the same city + // selection rules, so the highlighted region is not overwritten after the + // regional pass. - function uniqueByCell(nodes) { - const seen = new Set(); + modernCities.sort((a, b) => b.capacity - a.capacity || b.score - a.score); + for (const [rank, city] of modernCities.entries()) { + const isFirstInRegion = !modernCities.slice(0, rank).some((c) => c.regionId === city.regionId); + const isPrefecturalCapital = inFocusedPrefecture(city) && !modernCities.slice(0, rank).some((c) => c.isPrefecturalCapital); + const isRegionalCapital = isFirstInRegion; + const rawPop = isRegionalCapital + ? 150000 + rand(seed, 12201 + city.regionId * 17) * 520000 + : 32000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.7) * 260000; + const capMultiplier = isRegionalCapital ? 1.10 : 1.0; + const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000; + city.population = Math.max(isRegionalCapital ? 90000 : 24000, population); + city.isPrefecturalCapital = isPrefecturalCapital; + city.isRegionalCapital = isRegionalCapital; + city.rank = isPrefecturalCapital ? "Prefectural Capital" : isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City"; + city.kind = city.rank; + city.urbanRadius = clamp(5.8 + Math.sqrt(city.population) / 72, 8, isRegionalCapital ? 34 : 24); + city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 380, 2.2, isRegionalCapital ? 6.5 : 5.6); + city.sprawlRadius = clamp(city.urbanRadius * (isRegionalCapital ? 1.45 : city.population >= 120000 ? 1.28 : 1.15), city.urbanRadius + 2, isRegionalCapital ? 44 : 30); + city.urbanWeight = clamp(0.95 + Math.log10(Math.max(10000, city.population)) * 0.29, 1.08, 2.5); + } + + function cityPopulationCap(city) { + const radius = city?.isRegionalCapital ? 29 : city?.population >= 350000 ? 26 : 18; + const bias = city?.isRegionalCapital ? 1.12 : 1.0; + return estimateUrbanCapacity(city, radius, bias); + } + + // --- 4. Lightweight corridors ------------------------------------------- + function routeLight(a, b, snapRadius = 3) { + if (!a || !b) return []; + const steps = Math.max(2, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y) * 1.15)); const out = []; - for (const node of nodes.filter(Boolean)) { - if (!inside(node.x, node.y) || sea[indexOf(node.x, node.y)]) continue; - const key = nodeKey(node); - if (seen.has(key)) continue; - seen.add(key); - out.push(node); - } - return out; - } - - function internalNationalRoadCellCount(paths = nationalRoads) { - const seen = new Set(); - for (const path of paths) { - for (const [x, y] of path || []) { - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (prefectureMask[i] && !sea[i]) seen.add(`${x},${y}`); - } - } - return seen.size; - } - - - function nationalRoadPopulationWeight(node) { - if (!node) return 0; - const pop = Math.max(0, node.population || 0); - if (pop > 0) return pop; - if (node.portClass === "major") return 180000; - if (node.portClass === "regional") return 90000; - if (node.portClass) return 35000; - if (node.kind === "Market Town" || node.kind === "Market City") return 55000; - if (node.kind?.includes("Castle")) return 45000; - return 18000; - } - - function nationalRoadPopulationCoverage(paths = nationalRoads, radius = 7.0) { - const ruralNodes = villages - .filter((v) => prefectureMask[indexOf(v.x, v.y)] && !sea[indexOf(v.x, v.y)] && (transportDemand(v) > 0.24 || settlementCluster[indexOf(v.x, v.y)] > 0.33)) - .sort((a, b) => transportDemand(b) - transportDemand(a)) - .slice(0, 18); - const nodes = uniqueByCell([ - capital, - ...modernCities.filter((city) => inFocusedPrefecture(city) && ((city.population || 0) >= 60000 || city.isPrefecturalCapital)), - ...ports.filter((p) => inFocusedPrefecture(p) && p.portClass !== "fishing"), - ...markets.filter(inFocusedPrefecture), - ...castleTowns.filter(inFocusedPrefecture), - ...ruralNodes, - ]); - let total = 0; - let covered = 0; - const uncovered = []; - for (const node of nodes) { - const weight = nationalRoadPopulationWeight(node); - if (weight <= 0) continue; - total += weight; - const d = nearestPathCellDistance(node, paths); - if (d <= radius) covered += weight; - else uncovered.push({ node, weight, distance: d, score: weight * (1 + Math.min(2.8, d / 12)) + transportDemand(node) * 48000 }); - } - uncovered.sort((a, b) => b.score - a.score); - const uncoveredPopulation = Math.max(0, total - covered); - return { ratio: total ? covered / total : 1, total, covered, uncoveredPopulation, uncovered }; - } - - // Metropolitan national roads are split by role: yellow radial roads connect - // the large city to neighbouring cities/ports; white ring roads are generated - // later as ordinary urban ring roads. - const metroRoadHubs = uniqueByCell([capital, ...modernCities.filter((city) => city !== capital && (city.population || 0) >= 240000)]) - .filter((city) => inHumanRegion(city)) - .slice(0, Math.max(4, generatedRegionIdsForTransport.length + 2)); - - function addMetroRadialNationalRoads() { - let added = 0; - for (const hub of metroRoadHubs) { - const maxRadials = (hub.population || 0) >= 900000 || hub.isPrefecturalCapital ? 5 : 3; - const bySector = new Map(); - const candidates = uniqueByCell([ - ...modernCities.filter((city) => city !== hub && (city.population || 0) >= 70000), - ...ports.filter((p) => p.portClass !== "fishing"), - ...markets, - ]); - for (const node of candidates) { - if (!sameGeneratedRegion(hub, node)) continue; - const d = Math.hypot(node.x - hub.x, node.y - hub.y); - if (d < 9 || d > 46) continue; - const angle = Math.atan2(node.y - hub.y, node.x - hub.x); - const sector = Math.floor(((angle + Math.PI) / (Math.PI * 2)) * 8); - const coveredPenalty = nearestPathCellDistance(node, nationalRoads) <= 6.2 ? 0.72 : 0; - const score = pairTransportScore(hub, node, "road") + transportDemand(node) * 0.44 + densityValue(node.x, node.y) * 0.22 - getDegree(roadDegree, node) * 0.16 - coveredPenalty; - const old = bySector.get(sector); - if (!old || score > old.score) bySector.set(sector, { node, score, d }); - } - const sectorTargets = [...bySector.values()].sort((a, b) => b.score - a.score); - let made = 0; - for (const { node } of sectorTargets) { - if (made >= maxRadials) break; - if (getDegree(roadDegree, hub) >= 10 || getDegree(roadDegree, node) >= 7) continue; - if (addNationalRoad(hub, node) || addNationalRoadRelaxed(hub, node) || addNationalRoadCoverageFallback(hub, node)) { - made++; - added++; - } - } - } - return added; - } - - function ensureInternalNationalRoadCoverage(maxAdded = 7) { - let added = 0; - const minimumCells = Math.max(72, Math.floor((MAP_W + MAP_H) * 0.58)); - const targetPopulationCoverage = 0.85; - const maxUncoveredPopulation = 50000; - const minimumNetworkPaths = 10; - const hasEnoughCells = () => internalNationalRoadCellCount() >= minimumCells && nationalRoads.length >= minimumNetworkPaths; - const coverageState = () => nationalRoadPopulationCoverage(nationalRoads, 8.5); - const hasEnoughPopulationCoverage = () => { - const state = coverageState(); - return state.ratio >= targetPopulationCoverage && state.uncoveredPopulation <= maxUncoveredPopulation; - }; - if (hasEnoughPopulationCoverage() && nationalRoads.length >= minimumNetworkPaths) return added; - - const populationNodes = uniqueByCell([ - capital, - ...modernCities.filter((city) => inFocusedPrefecture(city) && (city.population || 0) >= 85000), - ...ports.filter((p) => inFocusedPrefecture(p) && (p.portClass === "major" || p.portClass === "regional")), - ...markets.filter((p) => inFocusedPrefecture(p) && transportDemand(p) > 0.35), - ...castleTowns.filter((p) => inFocusedPrefecture(p) && transportDemand(p) > 0.35), - ]).sort((a, b) => nationalRoadPopulationWeight(b) - nationalRoadPopulationWeight(a) || transportDemand(b) - transportDemand(a)); - const internalNodes = uniqueByCell([ - capital, - ...modernCities.filter((city) => prefectureMask[indexOf(city.x, city.y)] && (city.population || 0) >= 85000), - ...ports.filter((p) => prefectureMask[indexOf(p.x, p.y)] && (p.portClass === "major" || p.portClass === "regional")), - ...markets.filter((p) => prefectureMask[indexOf(p.x, p.y)] && transportDemand(p) > 0.35), - ...castleTowns.filter((p) => prefectureMask[indexOf(p.x, p.y)] && transportDemand(p) > 0.35), - ]).sort((a, b) => nationalRoadPopulationWeight(b) - nationalRoadPopulationWeight(a) || transportDemand(b) - transportDemand(a)); - if (populationNodes.length < 2 && internalNodes.length < 2) return added; - - function targetIsWorthNationalRoad(node) { - if (!node || node === capital) return false; - const w = nationalRoadPopulationWeight(node); - if (w >= 135000) return true; - if (node.portClass === "major" || node.portClass === "regional") return true; - if (densityValue(node.x, node.y) >= 0.18 && w >= 75000) return true; - if (w >= 18000 && transportDemand(node) >= 0.28 && (valleyField[indexOf(node.x, node.y)] > 0.20 || coastalLowland[indexOf(node.x, node.y)] > 0.18 || plain[indexOf(node.x, node.y)] > 0.36)) return true; - return false; - } - - function tryCoverageLink(a, b) { - if (!a || !b || Math.hypot(a.x - b.x, a.y - b.y) < 6) return false; - const before = coverageState().ratio; - const oldCount = nationalRoads.length; - if (!(addNationalRoadRelaxed(a, b) || addNationalRoadCoverageFallback(a, b))) return false; - const path = nationalRoads[nationalRoads.length - 1]; - const after = coverageState().ratio; - if (isBackcountryNationalRoad(path, a, b) && after - before < 0.025) { - nationalRoads.splice(oldCount, nationalRoads.length - oldCount); - return false; - } - return true; - } - - const currentCoverage = coverageState(); - const uncoveredPopulationTargets = currentCoverage.uncovered.map((item) => item.node).filter(targetIsWorthNationalRoad); - const internalSpanNodes = internalNodes.length >= 2 ? internalNodes : populationNodes; - const byX = internalSpanNodes.slice().sort((a, b) => a.x - b.x); - const byY = internalSpanNodes.slice().sort((a, b) => a.y - b.y); - const edgeBackstops = uniqueByCell([byX[0], byX[byX.length - 1], byY[0], byY[byY.length - 1]]) - .filter((node) => targetIsWorthNationalRoad(node) && Math.hypot(node.x - capital.x, node.y - capital.y) >= 9 && densityValue(node.x, node.y) >= 0.12) - .sort((a, b) => nationalRoadPopulationWeight(b) - nationalRoadPopulationWeight(a)); - const ruralBackboneTargets = villages - .filter((v) => prefectureMask[indexOf(v.x, v.y)] && !sea[indexOf(v.x, v.y)] && nearestPathCellDistance(v, nationalRoads) > 7.0) - .filter((v) => transportDemand(v) >= 0.28 && (valleyField[indexOf(v.x, v.y)] > 0.20 || coastalLowland[indexOf(v.x, v.y)] > 0.18 || plain[indexOf(v.x, v.y)] > 0.36)) - .sort((a, b) => transportDemand(b) - transportDemand(a)) - .slice(0, 5); - const primaryTargets = uniqueByCell([ - ...uncoveredPopulationTargets.slice(0, 5), - ...edgeBackstops.slice(0, 2), - ...ruralBackboneTargets, - ...populationNodes.filter(targetIsWorthNationalRoad).slice(0, 4), - ]).filter((node) => node !== capital && Math.hypot(node.x - capital.x, node.y - capital.y) >= 7); - - for (const target of primaryTargets) { - if (added >= maxAdded || hasEnoughPopulationCoverage()) break; - if (nearestPathCellDistance(target, nationalRoads) <= 6.5) continue; - if (tryCoverageLink(capital, target)) added++; - } - - const orderedByPopulation = populationNodes - .filter((node) => targetIsWorthNationalRoad(node) && nearestPathCellDistance(node, nationalRoads) > 7.0) - .sort((a, b) => nationalRoadPopulationWeight(b) - nationalRoadPopulationWeight(a)); - for (const target of orderedByPopulation) { - if (added >= maxAdded || hasEnoughPopulationCoverage()) break; - const anchor = populationNodes - .filter((node) => nodeKey(node) !== nodeKey(target) && nearestPathCellDistance(node, nationalRoads) <= 5.8) - .sort((a, b) => Math.hypot(a.x - target.x, a.y - target.y) - Math.hypot(b.x - target.x, b.y - target.y))[0] || capital; - const d = Math.hypot(anchor.x - target.x, anchor.y - target.y); - if (d < 7 || d > 50) continue; - if (getDegree(roadDegree, anchor) >= 7 || getDegree(roadDegree, target) >= 5) continue; - if (tryCoverageLink(anchor, target)) added++; - } - - // Cell-count backstop is deliberately weak: use it only when both network - // shape and population coverage are poor. This avoids forcing yellow roads - // into sparsely inhabited mountain or peninsula tips just to hit 100% coverage. - if (!hasEnoughCells() && coverageState().ratio < 0.80) { - for (const chain of [byX, byY]) { - if (added >= maxAdded) break; - for (let i = 0; i < chain.length - 1; i += 3) { - if (added >= maxAdded || hasEnoughCells() || hasEnoughPopulationCoverage()) break; - const a = chain[i]; - const b = chain[i + 1]; - const d = Math.hypot(a.x - b.x, a.y - b.y); - if (d < 10 || d > 42) continue; - if (!targetIsWorthNationalRoad(a) && !targetIsWorthNationalRoad(b)) continue; - if (getDegree(roadDegree, a) >= 6 || getDegree(roadDegree, b) >= 6) continue; - if (tryCoverageLink(a, b)) added++; - } - } - } - return added; - } - - function ensureRegionalNationalRoadCoverage(maxAddedPerRegion = 3) { - let added = 0; - for (const regionId of generatedRegionIdsForTransport) { - const primary = primaryNodeForRegion(regionId); - if (!primary) continue; - const localNodes = uniqueByCell([ - primary, - ...regionNodes(modernCities, regionId).filter((city) => (city.population || 0) >= 60000), - ...regionNodes(ports, regionId).filter((p) => p.portClass !== "fishing"), - ...regionNodes(markets, regionId), - ...regionNodes(castleTowns, regionId), - ...regionNodes(villages, regionId).filter((v) => transportDemand(v) >= 0.26).slice(0, 4), - ]).sort((a, b) => nationalRoadPopulationWeight(b) - nationalRoadPopulationWeight(a) || transportDemand(b) - transportDemand(a)); - let made = 0; - for (const target of localNodes) { - if (made >= maxAddedPerRegion) break; - if (nodeKey(target) === nodeKey(primary) || nearestPathCellDistance(target, nationalRoads) <= 6.5) continue; - const anchor = localNodes - .filter((node) => nodeKey(node) !== nodeKey(target) && nearestPathCellDistance(node, nationalRoads) <= 6.0) - .sort((a, b) => Math.hypot(a.x - target.x, a.y - target.y) - Math.hypot(b.x - target.x, b.y - target.y))[0] || primary; - const d = Math.hypot(anchor.x - target.x, anchor.y - target.y); - if (d < 7 || d > 56) continue; - if (addNationalRoadRelaxed(anchor, target) || addNationalRoadCoverageFallback(anchor, target)) { - made++; - added++; - } - } - } - return added; - } - - const metroRadialNationalRoadsAdded = addMetroRadialNationalRoads(); - const internalNationalRoadFallbacks = ensureInternalNationalRoadCoverage(8); - const regionalNationalRoadFallbacks = ensureRegionalNationalRoadCoverage(3); - - const expressways = []; - const expressDegree = new Map(); - const expressCore = [capital]; - - function snapPathToExistingExpressways(path, existingPaths, radius = 2.4) { - if (!path?.length || !existingPaths?.length) return path || []; - const snapped = []; - const skipEnd = Math.min(5, Math.floor(path.length / 5)); - for (let pi = 0; pi < path.length; pi++) { - const [x, y] = path[pi]; + let lastKey = ""; + for (let s = 0; s <= steps; s++) { + const t = s / steps; + const fx = a.x + (b.x - a.x) * t; + const fy = a.y + (b.y - a.y) * t; let best = null; - let bestD = radius; - if (pi >= skipEnd && pi < path.length - skipEnd) { - for (const existing of existingPaths) { - for (const [ex, ey] of existing) { - const d = Math.hypot(x - ex, y - ey); - if (d < bestD) { bestD = d; best = [ex, ey]; } - } - } - } - const next = best || [x, y]; - const last = snapped[snapped.length - 1]; - if (!last || last[0] !== next[0] || last[1] !== next[1]) snapped.push(next); - } - return snapped; - } - - - function addExpressway(a, b, bucket = expressways) { - const existing = [...expressways, ...nationalRoads, ...railways, ...branchRailways]; - let { path } = routeThroughTransportCorridor(a, b, "express", expresswayCost, existing, roadHubs, townAvoidNodes, { - salt: a.x * 41 + a.y * 43 + b.x * 19 + b.y * 29, - maxWaypoints: 1, - corridorRadius: 5, - corridorStrength: 10.8, - avoidRadius: 8.5, - avoidStrength: 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, 12); - path = snapPathToExistingExpressways(path, expressways, 2.4); - const direct = pathEndpointDistance(path); - const densityPurpose = averagePathField(path, populationDensity) * 0.8 + averagePathField(path, plain) * 0.16 + averagePathField(path, coastalLowland) * 0.12; - const turnScore = pathTurnScore(path); - const deviation = pathLateralDeviationRatio(path); - const compact = pathCompactness(path); - if (path.length > 8 && direct >= 26 && pathLength(path) >= 30 && compact < 2.28 && turnScore < 0.64 && deviation < 0.36 && pathOverlapRatio(path, existing, 2) < 0.34 && densityPurpose > 0.11) { - bucket.push(path); - incrementDegree(expressDegree, a); - incrementDegree(expressDegree, b); - return true; - } - return false; - } - - function addExpresswayRelaxed(a, b, bucket = expressways) { - if (!a || !b || Math.hypot(a.x - b.x, a.y - b.y) < 14) return false; - const existing = [...expressways, ...nationalRoads, ...railways, ...branchRailways].filter(Boolean); - let { path } = routeThroughTransportCorridor(a, b, "express", expresswayCost, existing, roadHubs, townAvoidNodes, { - salt: a.x * 149 + a.y * 151 + b.x * 157 + b.y * 163, - maxWaypoints: 1, - corridorRadius: 6, - corridorStrength: 8.4, - avoidRadius: 7.5, - avoidStrength: 10.0, - }); - path = smoothPathByLineOfSight(path, (x, y) => expresswayCost(x, y, x, y) < INF && slope[indexOf(x, y)] < 0.62 && elevation[indexOf(x, y)] < 0.84, 10); - path = snapPathToExistingExpressways(path, expressways, 2.6); - const direct = pathEndpointDistance(path); - const densityPurpose = averagePathField(path, populationDensity) * 0.62 + averagePathField(path, plain) * 0.18 + averagePathField(path, valleyField) * 0.12 + averagePathField(path, coastalLowland) * 0.12; - if (path.length > 7 && pathTouchesHumanRegion(path) && direct >= 18 && pathLength(path) >= 20 && pathCompactness(path) < 3.05 && pathTurnScore(path) < 0.82 && pathLateralDeviationRatio(path) < 0.54 && pathOverlapRatio(path, existing, 2) < 0.48 && densityPurpose > 0.065) { - bucket.push(path); - incrementDegree(expressDegree, a); - incrementDegree(expressDegree, b); - return true; - } - return false; - } - - const expressNodes = [capital, ...modernCities.filter((p) => inHumanRegion(p) && (p.population || 0) >= 220000), ...majorPorts.filter((p) => inHumanRegion(p) && p.portClass === "major")]; - // Expressways are intentionally light in this urban-model iteration. Full - // expressway routing is expensive and will be revisited with the transport - // rewrite; for now, derive at most one express corridor from an existing trunk. - const expressLinks = []; - for (const link of expressLinks) { - if (!sameGeneratedRegion(link.a, link.b)) continue; - if (addExpressway(link.a, link.b) || addExpresswayRelaxed(link.a, link.b)) { - addUniqueNode(expressCore, link.a); - addUniqueNode(expressCore, link.b); - } - } - - // Skipped: relaxed all-region expressway fallback uses repeated A* searches. - - if (expressways.length === 0) { - const trunkCandidate = nationalRoads - .filter((path) => path && path.length >= 26 && pathEndpointDistance(path) >= 18) - .map((path) => ({ - path, - score: pathEndpointDistance(path) * 0.12 + averagePathField(path, populationDensity) * 3.0 + averagePathField(path, plain) * 0.7 + averagePathField(path, coastalLowland) * 0.45 - pathCompactness(path) * 0.25, - })) - .sort((a, b) => b.score - a.score)[0]; - if (trunkCandidate) expressways.push(trunkCandidate.path); - } - - 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; + let bestCost = INF; + const radius = snapRadius + (s > 0 && s < steps ? 1 : 0); + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const x = Math.round(fx + dx); + const y = Math.round(fy + dy); if (!inside(x, y)) continue; const i = indexOf(x, y); - if (sea[i] || regionIdAt(x, y) !== regionIdAt(city.x, city.y)) 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 (sea[i]) continue; + const lineDist = Math.hypot(x - fx, y - fy); + const cost = lineDist * 0.72 + corridorCost[i] * 0.62 - valleySettlement[i] * 0.34 - developable[i] * 0.18 + hash2(x, y, seed + 13000 + s) * 0.05; + if (cost < bestCost) { + bestCost = cost; + best = [x, y]; } } } - 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++; + if (!best) best = [Math.round(fx), Math.round(fy)]; + const key = `${best[0]},${best[1]}`; + if (key !== lastKey) { + out.push(best); + lastKey = key; } } - return made; + return out; } - 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++) { + function importantNodesForRegion(regionId) { + const inRegion = (p) => regionIdAt(p.x, p.y) === regionId; + return [ + ...modernCities.filter(inRegion).map((p) => ({ ...p, nodeWeight: 8 + (p.population || 0) / 120000 })), + ...markets.filter(inRegion).map((p) => ({ ...p, nodeWeight: 3.2 + (p.population || 0) / 25000 })), + ...commercialPorts.filter(inRegion).map((p) => ({ ...p, nodeWeight: p.portClass === "major" ? 6.5 : 4.6 })), + ...passes.filter(inRegion).map((p) => ({ ...p, nodeWeight: 2.2 })), + ].sort((a, b) => b.nodeWeight - a.nodeWeight).slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 16 : 10); + } + + const premodernRoads = []; + const nationalRoads = []; + const minorRoads = []; + const railways = []; + const branchRailways = []; + const externalRoads = []; + const externalRailways = []; + const expressways = []; + const ringRoads = []; + const ringRailways = []; + const ringExpressways = []; + const externalExpressways = []; + const icAccessRoads = []; + const externalGateways = []; + + // Premodern roads connect castles/markets/ports sparsely. + for (const c of castles) { + const near = [...markets, ...ports, ...crossings].sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y)).slice(0, 2); + for (const n of near) { + const path = routeLight(c, n, 2); + if (path.length > 2) premodernRoads.push(path); + } + } + + for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) { + const nodes = importantNodesForRegion(regionId); + if (nodes.length < 2) continue; + const connected = [nodes[0]]; + const remaining = nodes.slice(1); + const maxEdges = (regionStats.get(regionId)?.area || 0) > 2200 ? Math.min(13, nodes.length + 3) : Math.min(7, nodes.length + 1); + while (remaining.length && nationalRoads.length < 48) { + let best = null; + let bestScore = INF; + for (const a of connected) { + for (const b of remaining) { + const d = Math.hypot(a.x - b.x, a.y - b.y); + const score = d - (a.nodeWeight + b.nodeWeight) * 0.9; + if (score < bestScore) { bestScore = score; best = { a, b }; } + } + } + if (!best) break; + const path = routeLight(best.a, best.b, 3); + if (path.length > 2) nationalRoads.push(path); + connected.push(best.b); + remaining.splice(remaining.indexOf(best.b), 1); + if (connected.length - 1 >= maxEdges) break; + } + + // A few k-nearest shortcuts for urbanized regions. + const urbanNodes = nodes.filter((p) => p.population || p.portClass).slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 7 : 4); + for (let i = 0; i < urbanNodes.length; i++) { + const a = urbanNodes[i]; + const b = urbanNodes.slice(i + 1).sort((p, q) => Math.hypot(a.x - p.x, a.y - p.y) - Math.hypot(a.x - q.x, a.y - q.y))[0]; + if (!b || Math.hypot(a.x - b.x, a.y - b.y) > 48) continue; + const path = routeLight(a, b, 3); + if (path.length > 2) nationalRoads.push(path); + } + + // Railways: only high-order cities/ports, as a lightweight placeholder. + const railNodes = nodes.filter((p) => (p.population || 0) > 80000 || p.portClass === "major" || p.portClass === "regional").slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 6 : 4); + railNodes.sort((a, b) => a.x - b.x || a.y - b.y); + for (let i = 1; i < railNodes.length; i++) { + const path = routeLight(railNodes[i - 1], railNodes[i], 4); + if (path.length > 4) railways.push(path); + } + } + + // External gateways at land edges; used by naming/UI and later transport work. + for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) { + const st = regionStats.get(regionId); + if (!st || st.area < 140) continue; + const edgeCandidates = []; + for (let y = st.minY; y <= st.maxY; y += 3) { + for (const x of [st.minX, st.maxX]) { + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (!sea[i] && regionIdAt(x, y) === regionId && (x < 5 || y < 5 || x > MAP_W - 6 || y > MAP_H - 6)) edgeCandidates.push({ x, y, score: developable[i] + valleySettlement[i] }); + } + } + for (let x = st.minX; x <= st.maxX; x += 3) { + for (const y of [st.minY, st.maxY]) { + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (!sea[i] && regionIdAt(x, y) === regionId && (x < 5 || y < 5 || x > MAP_W - 6 || y > MAP_H - 6)) edgeCandidates.push({ x, y, score: developable[i] + valleySettlement[i] }); + } + } + const gateway = pickEntities(edgeCandidates, { max: (regionStats.get(regionId)?.area || 0) > 2200 ? 2 : 1, minDistance: 16, seed: seed + 13200 + regionId * 11 })[0]; + if (gateway) { + gateway.kind = "External Gateway"; + gateway.regionId = regionId; + externalGateways.push(gateway); + const target = importantNodesForRegion(regionId)[0]; + if (target) { + const path = routeLight(gateway, target, 3); + if (path.length > 2) externalRoads.push(path); + } + } + } + + // Approximate expressways as a very small subset of top inter-city links. + const topCities = modernCities.slice().sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 6); + for (let i = 1; i < topCities.length && expressways.length < 4; i++) { + const a = topCities[i - 1]; + const b = topCities[i]; + if (Math.hypot(a.x - b.x, a.y - b.y) < 85) { + const path = routeLight(a, b, 5); + if (path.length > 5) expressways.push(path); + } + } + + // Land-use road influence intentionally excludes expressways. Expressways + // are through-corridors here, not automatic suburbanization generators. + // A narrow field controls land-use attachment, while a broader field raises + // population density around trunk roads without painting a wide suburb band. + const roadLanduseInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 2.25); + const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 5.0); + const roadDensityInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 9.0); + const railInfluence2 = influenceFromPaths([...railways, ...branchRailways, ...externalRailways], 4); + + const stations = []; + const usedStationKeys = new Set(); + function addStation(x, y, kind = "Station", score = 1) { + x = Math.round(x); y = Math.round(y); + if (!inside(x, y) || sea[indexOf(x, y)]) return; + const key = `${x},${y}`; + if (usedStationKeys.has(key)) return; + usedStationKeys.add(key); + stations.push({ x, y, kind, score, regionId: regionIdAt(x, y) }); + } + for (const city of modernCities) addStation(city.x, city.y, city.isPrefecturalCapital || city.isRegionalCapital ? "Major Station" : "Station", 1.5); + for (const path of railways) for (const p of samplePath(path, 14)) addStation(p.x, p.y, "Station", 0.8); + const stationInfluence = influenceFromPoints(stations, 7, (s) => s.kind === "Major Station" ? 1.35 : 0.85); + const stationDensityInfluence = influenceFromPoints(stations, 10, (s) => s.kind === "Major Station" ? 1.85 : 1.05); + + // --- 5. Approximate city/town influence and land-use --------------------- + const cityInfluence = new Float32Array(SIZE); + const coreInfluence = new Float32Array(SIZE); + const oldTownInfluence = influenceFromPoints([...markets, ...castleTowns, ...ports], 7, (p) => p.kind === "Major Port" ? 1.2 : 0.9); + const populationDensity = new Float32Array(SIZE); + + function addKernel(grid, p, radius, weight, exponent = 1.7, terrainWeighted = true, combine = "max") { + const r = Math.ceil(radius); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const x = p.x + dx; + const y = p.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i]) continue; const d = Math.hypot(dx, dy); - if (d < minR || d > maxR) continue; + if (d > radius) continue; + const terrain = terrainWeighted ? clamp(0.24 + developable[i] * 1.00 + valleySettlement[i] * 0.16 + coastalSettlement[i] * 0.10 - slope[i] * 0.20 - ridgeField[i] * 0.12 + roadInfluence[i] * 0.08 + roadDensityInfluence[i] * 0.04 + railInfluence2[i] * 0.06, 0, 1.34) : 1; + const v = weight * Math.pow(1 - d / Math.max(1, radius), exponent) * terrain; + if (combine === "add") grid[i] = Math.min(3.4, grid[i] + v); + else if (v > grid[i]) grid[i] = v; + } + } + } + + for (const city of modernCities) { + addKernel(cityInfluence, city, city.sprawlRadius || Math.round((city.urbanRadius || 10) * 1.4), (city.urbanWeight || 1.0) * (city.isRegionalCapital ? 0.38 : 0.30), 2.75, true, "add"); + addKernel(cityInfluence, city, city.urbanRadius || 10, city.urbanWeight || 1.0, 1.18, true, "add"); + addKernel(coreInfluence, city, city.coreRadius || 3, (city.urbanWeight || 1.0) * 1.10, 1.65, true, "max"); + } + const townInfluence = influenceFromPoints(markets, 6, (m) => clamp((m.population || 10000) / 26000, 0.45, 1.25)); + + // Industrial/logistics/new town placeholders remain lightweight. They are + // routed by land-use proximity rather than expensive search passes. + const industrialZones = []; + for (const p of [...commercialPorts, ...modernCities.slice(0, 5)]) { + const candidates = []; + for (let dy = -10; dy <= 10; dy++) { + for (let dx = -10; dx <= 10; dx++) { + const x = p.x + dx; + const y = p.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (sea[i]) continue; + const d = Math.hypot(dx, dy); + if (d < 3 || d > 10) continue; + const score = coastalLowland[i] * 0.22 + developable[i] * 0.22 + roadInfluence[i] * 0.20 + plain[i] * 0.12 - slope[i] * 0.25 + hash2(x, y, seed + 14000) * 0.06; + if (score > 0.22) candidates.push({ x, y, score, kind: "Industrial Zone", regionId: regionIdAt(x, y) }); + } + } + const z = pickEntities(candidates, { max: 1, minDistance: 6, seed: seed + 14010 + p.x * 3 + p.y })[0]; + if (z && industrialZones.every((q) => Math.hypot(q.x - z.x, q.y - z.y) > 13)) industrialZones.push(z); + if (industrialZones.length >= 8) break; + } + const industrialInfluence = influenceFromPoints(industrialZones, 5, () => 1.0); + + const satelliteCities = []; + const newTowns = []; + const logisticsParks = []; + const interchanges = []; + var landuse = new Uint8Array(SIZE); + + // Re-run land-use classification after landuse allocation. The loop above is + // intentionally inside a helper to keep all thresholds in one place. + function classifyLanduse() { + landuse.fill(LANDUSE.RURAL); + let maxDensity = 0; + const baseNoiseSeed = seed + 15000; + const urbanCapacity = new Float32Array(SIZE); + const ruralDensityFloor = 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; + const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82); + const densityTransport = Math.max(roadDensityInfluence[i] * 0.95, stationDensityInfluence[i] * 1.05, railInfluence2[i] * 0.85); + const urban = cityInfluence[i] * 0.76 + stationInfluence[i] * 0.30 + roadInfluence[i] * 0.14 + railInfluence2[i] * 0.10; + const core = coreInfluence[i]; + const oldTown = oldTownInfluence[i] * 0.70 + townInfluence[i] * 0.38; + const rural = villageInfluence[i] * 0.24 + ruralSuitability[i] * 0.30; + const riverUrban = clamp(river[i] * 0.12 + valleyField[i] * 0.10 + plain[i] * 0.08 + basinField[i] * 0.08 - floodplain[i] * 0.10); + urbanCapacity[i] = clamp( + developable[i] * 0.66 + + plain[i] * 0.16 + + basinField[i] * 0.16 + + valleyField[i] * 0.16 + + coastalLowland[i] * 0.12 + + roadInfluence[i] * 0.14 + roadDensityInfluence[i] * 0.16 + transport * 0.08 + + riverUrban * 0.14 - + slope[i] * 0.18 - + ridgeField[i] * 0.12 - + floodplain[i] * 0.08 + ); + const highPenaltyDensity = Math.max(0, elevation[i] - 0.58); + const agrarianDensity = clamp( + agriculture[i] * 0.045 + + ruralSuitability[i] * 0.035 + + developable[i] * 0.028 + + plain[i] * 0.018 + + basinField[i] * 0.014 + + valleySettlement[i] * 0.014 + + coastalSettlement[i] * 0.012 + + villageInfluence[i] * 0.040 + + townInfluence[i] * 0.022 + + roadDensityInfluence[i] * 0.038 + + stationDensityInfluence[i] * 0.020 + + railInfluence2[i] * 0.012 - + slope[i] * 0.030 - + ridgeField[i] * 0.020 - + highPenaltyDensity * 0.058 + ); + ruralDensityFloor[i] = clamp(0.010 + agrarianDensity, 0, elevation[i] > 0.62 || slope[i] > 0.42 ? 0.052 : 0.115); + populationDensity[i] = clamp( + urban * 0.66 + + core * 0.46 + + oldTown * 0.28 + + townInfluence[i] * 0.16 + + villageInfluence[i] * 0.14 + + roadDensityInfluence[i] * 0.42 + + stationDensityInfluence[i] * 0.34 + + railInfluence2[i] * 0.12 + + transport * 0.05 + + agrarianDensity * 0.34 + ); + maxDensity = Math.max(maxDensity, populationDensity[i]); + + if (elevation[i] > 0.67 || (slope[i] > 0.56 && ridgeField[i] > 0.30) || ridgeField[i] > 0.70) { + landuse[i] = LANDUSE.FOREST; + continue; + } + if (industrialInfluence[i] > 0.22 && urbanCapacity[i] > 0.10) { + landuse[i] = LANDUSE.INDUSTRIAL; + continue; + } + if (core > 0.38 && urbanCapacity[i] > 0.10) { + landuse[i] = LANDUSE.CBD; + continue; + } + if (oldTown > 0.18 && urbanCapacity[i] > 0.09) { + landuse[i] = LANDUSE.OLD_URBAN; + continue; + } + + const suburbanity = urban * 0.88 + transport * 0.23 + stationInfluence[i] * 0.12 + townInfluence[i] * 0.08 + riverUrban * 0.08; + const edgeTaper = clamp(cityInfluence[i] * 0.52 + stationInfluence[i] * 0.16 + roadLanduseInfluence[i] * 0.10 + 0.28); + const sprawlBias = clamp(0.58 + hash2(x, y, baseNoiseSeed) * 0.42); + const sprawlScore = suburbanity * sprawlBias * edgeTaper - core * 0.12; + if (sprawlScore > 0.24 && urbanCapacity[i] > 0.10) { + landuse[i] = LANDUSE.SUBURB; + } else if (roadLanduseInfluence[i] > 0.30 && urbanCapacity[i] > 0.10 && (townInfluence[i] > 0.05 || cityInfluence[i] > 0.11)) { + landuse[i] = LANDUSE.SUBURB; + } else if (agriculture[i] > 0.24 || rural > 0.22 || (developable[i] > 0.18 && plain[i] > 0.18)) { + landuse[i] = LANDUSE.FARMLAND; + } else { + landuse[i] = elevation[i] > 0.52 || slope[i] > 0.34 ? LANDUSE.FOREST : LANDUSE.RURAL; + } + } + } + + const baseLanduse = landuse.slice(); + const isBuilt = (lu) => lu >= LANDUSE.OLD_URBAN && lu <= LANDUSE.ROADSIDE; + 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] || baseLanduse[i] === LANDUSE.FOREST) continue; + const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82); + const densityTransport = Math.max(roadDensityInfluence[i] * 0.95, stationDensityInfluence[i] * 1.05, railInfluence2[i] * 0.85); + let urbanNeighbors = 0; + let cbdNeighbors = 0; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (!dx && !dy) continue; + const lu = baseLanduse[indexOf(x + dx, y + dy)]; + if (isBuilt(lu)) urbanNeighbors++; + if (lu === LANDUSE.CBD) cbdNeighbors++; + } + } + if (baseLanduse[i] === LANDUSE.OLD_URBAN && coreInfluence[i] > 0.31 && cbdNeighbors >= 3) { + landuse[i] = LANDUSE.CBD; + continue; + } + if ((baseLanduse[i] === LANDUSE.FARMLAND || baseLanduse[i] === LANDUSE.RURAL) && urbanCapacity[i] > 0.10) { + const fringeChance = urbanNeighbors * 0.055 + cityInfluence[i] * 0.13 + transport * 0.12 + stationInfluence[i] * 0.08; + const noise = 0.23 + hash2(x, y, seed + 15050) * 0.24; + if (fringeChance > 0.34 + noise) { + landuse[i] = LANDUSE.SUBURB; + } + } + if ((river[i] > 0.10 || railInfluence2[i] > 0.16 || roadLanduseInfluence[i] > 0.22) && urbanNeighbors >= 3 && landuse[i] <= LANDUSE.FARMLAND && urbanCapacity[i] > 0.09) { + landuse[i] = cbdNeighbors >= 1 || coreInfluence[i] > 0.15 ? LANDUSE.OLD_URBAN : LANDUSE.SUBURB; + } + } + } + + if (maxDensity > 0) { + for (let i = 0; i < SIZE; i++) { + if (sea[i]) continue; + const lu = landuse[i]; + let floor = ruralDensityFloor[i]; + if (lu === LANDUSE.FARMLAND) { + floor = Math.max(floor, clamp(0.024 + agriculture[i] * 0.044 + ruralSuitability[i] * 0.024 + roadDensityInfluence[i] * 0.030 + stationDensityInfluence[i] * 0.026 + villageInfluence[i] * 0.018, 0, 0.110)); + } else if (lu === LANDUSE.RURAL) { + floor = Math.max(floor, clamp(0.012 + ruralSuitability[i] * 0.020 + developable[i] * 0.014 + roadDensityInfluence[i] * 0.024 + stationDensityInfluence[i] * 0.020, 0, 0.070)); + } else if (lu === LANDUSE.FOREST) { + floor = Math.min(floor, 0.032); + } + populationDensity[i] = clamp(Math.max(populationDensity[i] / maxDensity, floor)); + } + } + } + classifyLanduse(); + + for (const city of modernCities) { + let urbanFootprintCells = 0; + let coreFootprintCells = 0; + const r = Math.ceil((city.urbanRadius || 8) * 1.3); + 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] || 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 }); + if (sea[i]) continue; + if (Math.hypot(dx, dy) > r) continue; + if (landuse[i] >= LANDUSE.OLD_URBAN && landuse[i] <= LANDUSE.ROADSIDE) urbanFootprintCells++; + if (landuse[i] === LANDUSE.CBD) coreFootprintCells++; } } - return pickEntities(candidates, { max: maxAnchors, minDistance: 5, threshold: -1, seed: seed + city.x * 83 + city.y * 89 }) - .sort((a, b) => a.angle - b.angle); + city.urbanFootprintCells = urbanFootprintCells; + city.coreFootprintCells = coreFootprintCells; } - 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) => inHumanRegion(c) && (c.population || 0) >= 160000).slice(0, Math.max(5, generatedRegionIdsForTransport.length * 2)); - let metroRingRoadSegmentsAdded = 0; - for (const city of mediumRingCities) { - const radius = clamp(8 + Math.sqrt(city.population || 100000) / 175, 10, 22); - metroRingRoadSegmentsAdded += addEnvironmentalRing(city, "road", ringRoads, roadCost, [...nationalRoads, ...railways, ...branchRailways], radius); - } - const largeRingCities = uniqueByCell([...metroRoadHubs, ...modernCities.filter((c) => inHumanRegion(c) && (c.population || 0) >= 420000)]).slice(0, Math.max(3, generatedRegionIdsForTransport.length)); - for (const city of largeRingCities) { - const roadRadius = clamp(10 + Math.sqrt(city.population || 400000) / 155, 13, 28); - const railRadius = Math.max(8, roadRadius - 4); - const roadRingSegments = addEnvironmentalRing(city, "road", ringRoads, roadCost, [...nationalRoads, ...expressways, ...railways, ...branchRailways], roadRadius); - metroRingRoadSegmentsAdded += roadRingSegments || addLooseEnvironmentalRing(city, ringRoads, roadCost, roadRadius); - // Expressway rings are intentionally disabled; expressways stay as sparse interurban corridors. - const railRingSegments = addEnvironmentalRing(city, "rail", ringRailways, railCost, [...railways, ...branchRailways, ...nationalRoads, ...expressways], railRadius); - if (railRingSegments === 0) addLooseEnvironmentalRing(city, ringRailways, softRingRailCost, railRadius); - } - ringExpressways.length = 0; - compactPathArray(ringRoads, { minLength: 8, maxOverlap: 0.32, maxCount: Math.max(18, generatedRegionIdsForTransport.length * 5) }); - compactPathArray(ringRailways, { minLength: 8, maxOverlap: 0.26, maxCount: Math.max(8, generatedRegionIdsForTransport.length * 2) }); - - 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]) { - const density = densityValue(x, y); - gatewayCandidates.push({ - x, y, - side: y === 0 ? "N" : "S", - score: plain[i] * 0.9 + agriculture[i] * 0.35 + valleyField[i] * 0.42 + coastalLowland[i] * 0.28 + density * 0.55 + (1 - slope[i]) * 0.42 - Math.max(0, elevation[i] - 0.56) * 1.8 - ridgeField[i] * 0.42 - }); - } - } - for (let y = 0; y < MAP_H; y++) for (const x of [0, MAP_W - 1]) { - const i = indexOf(x, y); - if (!sea[i]) { - const density = densityValue(x, y); - gatewayCandidates.push({ - x, y, - side: x === 0 ? "W" : "E", - score: plain[i] * 0.9 + agriculture[i] * 0.35 + valleyField[i] * 0.42 + coastalLowland[i] * 0.28 + density * 0.55 + (1 - slope[i]) * 0.42 - Math.max(0, elevation[i] - 0.56) * 1.8 - ridgeField[i] * 0.42 - }); - } - } - - const minExternalGatewayCount = Math.min(5, gatewayCandidates.length); - const targetGatewayCount = Math.min(gatewayCandidates.length, 4 + Math.floor(rand(seed, 1201) * 3)); - let externalGateways = pickEntities(gatewayCandidates, { - max: targetGatewayCount, - minDistance: 20, - threshold: 0.18, - seed: seed + 1201, - }).map((p) => ({ ...p, kind: "External Gateway" })); - if (externalGateways.length < minExternalGatewayCount) { - const fallbackGateways = gatewayCandidates - .slice() - .sort((a, b) => b.score - a.score); - for (const gate of fallbackGateways) { - if (externalGateways.some((p) => Math.hypot(p.x - gate.x, p.y - gate.y) < 18)) continue; - externalGateways.push({ ...gate, kind: "External Gateway" }); - if (externalGateways.length >= minExternalGatewayCount) break; - } - } - - 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 coreAvoid = cityDistance < 2.2 ? 16.0 : cityDistance < 4.5 ? 6.0 : cityDistance < 7.5 ? 1.8 : 0; - const lowDensityPenalty = density < 0.10 ? (0.10 - density) * 4.2 : 0; - const urbanCorridorBonus = density * 1.04 + midDensityAffinity(x, y) * 0.24 + (cityDistance >= 4 && cityDistance <= 16 ? 0.32 : 0); - const constructionCost = 0.55 + slope[i] * 23.0 + barrier * 1.06 + Math.max(0, elevation[i] - 0.60) * 12.0 + ridgeField[i] * 1.20 + (river[i] > 0.45 ? 1 : river[i] * 0.38); - return Math.max(0.50, 1.14 + constructionCost + coreAvoid + lowDensityPenalty + floodplain[i] * 0.18 + borderPenalty - urbanCorridorBonus - plain[i] * 0.10 + hash2(x, y, seed + 444) * 0.04); - }; - } - 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 = []; - const nationalRoadBranchRoads = []; - - 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) => { - // Always lay a national-road class gateway link first. Expressways are - // additional sparse corridors; they should not replace the ordinary trunk - // road connection to the neighbouring prefecture. - const roadStartRaw = selectExternalStart([...roadCore, ...modernCities, ...ports, ...markets], gate, roadDegree, 4); - const roadStart = routePoint(roadStartRaw, "road", gate.x * 53 + gate.y * 59); - const roadExisting = [...nationalRoads, ...externalRoads, ...expressways, ...externalExpressways, ...railways, ...branchRailways]; - let roadPath = aStar(roadStart, gate, makeTransportCost(externalRoadCost(gate), roadExisting, roadHubs, [roadStart, gate], 3, 7.0, townAvoidNodes, 3.2, 5.6)); - if (roadPath.length > 6) { - externalRoads.push(roadPath); - incrementDegree(roadDegree, roadStartRaw); - incrementDegree(roadDegree, gate); - addUniqueNode(roadCore, gate); - } - - const makeExpressLink = false; - if (makeExpressLink) { - const expressStartRaw = selectExternalStart([...expressCore, ...roadCore, ...modernCities, ...ports], gate, expressDegree, 3); - const expressStart = routePoint(expressStartRaw, "express", gate.x * 71 + gate.y * 73); - const expressExisting = [...expressways, ...externalExpressways, ...nationalRoads, ...externalRoads, ...railways, ...branchRailways]; - let expressPath = aStar(expressStart, gate, makeTransportCost(externalExpresswayCost(gate), expressExisting, roadHubs, [expressStart, gate], 4, 8.2, townAvoidNodes, 5.4, 7.8)); - expressPath = smoothPathByLineOfSight(expressPath, (x, y) => externalExpresswayCost(gate)(x, y, x, y) < INF && slope[indexOf(x, y)] < 0.56 && elevation[indexOf(x, y)] < 0.80, 12); - expressPath = snapPathToExistingExpressways(expressPath, [...expressways, ...externalExpressways], 2.4); - const direct = pathEndpointDistance(expressPath); - const densityPurpose = averagePathField(expressPath, populationDensity) * 0.72 + averagePathField(expressPath, plain) * 0.14 + averagePathField(expressPath, coastalLowland) * 0.10; - const turnScore = pathTurnScore(expressPath); - const deviation = pathLateralDeviationRatio(expressPath); - if (expressPath.length > 6 && direct >= 18 && pathLength(expressPath) >= 20 && pathCompactness(expressPath) < 2.40 && turnScore < 0.66 && deviation < 0.42 && densityPurpose > 0.08) { - externalExpressways.push(expressPath); - incrementDegree(expressDegree, expressStartRaw); - incrementDegree(expressDegree, gate); - expressCore.push(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, ...externalRoads, ...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 nearestOtherTrunkCell(node, ownPath, paths, minDistance = 3.5) { - let best = null; - let bestD = INF; - for (const path of paths) { - if (!path || path === ownPath) continue; - for (let k = 0; k < path.length; k += Math.max(1, Math.floor(path.length / 46))) { - const [x, y] = path[k]; - const d = Math.hypot(node.x - x, node.y - y); - if (d < bestD) { - bestD = d; - best = { x, y, d }; - } - } - } - return best && bestD >= minDistance ? best : null; - } - - function terminalIsConnectedToNationalRoad(node, ownPath = null, extraPaths = [], radius = 3.8) { - if (nearMapEdge(node.x, node.y, 4) || distanceToNearest(externalGateways, node.x, node.y) <= 4.2) return true; - const paths = [...nationalRoads, ...externalRoads, ...extraPaths]; - for (const path of paths) { - if (!path || path === ownPath) continue; - const step = Math.max(1, Math.floor(path.length / 64)); - for (let k = 0; k < path.length; k += step) { - const [x, y] = path[k]; - if (Math.hypot(node.x - x, node.y - y) <= radius) return true; - } - } - return false; - } - - function terminalImportance(node) { - let best = 0; - for (const city of modernCities) { - const d = Math.hypot(node.x - city.x, node.y - city.y); - if (d > 6.5) continue; - if (city.isPrefecturalCapital || city.rank === "Prefectural Capital") best = Math.max(best, 4); - else if ((city.population || 0) >= 180000) best = Math.max(best, 3); - else if ((city.population || 0) >= 90000) best = Math.max(best, 2); - else best = Math.max(best, 1); - } - for (const port of ports) { - const d = Math.hypot(node.x - port.x, node.y - port.y); - if (d > 6.5) continue; - if (port.portClass === "major") best = Math.max(best, 3); - else if (port.portClass === "regional") best = Math.max(best, 2); - else best = Math.max(best, 1); - } - for (const market of markets) if (Math.hypot(node.x - market.x, node.y - market.y) <= 5.5) best = Math.max(best, 1); - for (const castle of castles) if (Math.hypot(node.x - castle.x, node.y - castle.y) <= 5.5) best = Math.max(best, 1); - return best; - } - - function repairNationalRoadDeadEnds() { - const repairs = []; - const trunkPaths = () => [...nationalRoads, ...externalRoads, ...repairs]; - let repairCount = 0; - for (let pass = 0; pass < 3; pass++) { - for (const path of nationalRoads) { - if (!path || path.length < 8) continue; - const terminals = [ - { x: path[0][0], y: path[0][1] }, - { x: path[path.length - 1][0], y: path[path.length - 1][1] }, - ]; - for (const terminal of terminals) { - if (repairCount >= 28) break; - if (terminalIsConnectedToNationalRoad(terminal, path, repairs, 3.8)) continue; - const target = nearestOtherTrunkCell(terminal, path, trunkPaths(), 4.0); - if (!target || target.d > 38) continue; - const existing = [...nationalRoads, ...externalRoads, ...repairs]; - const repairPath = aStar(terminal, target, makeTransportCost(roadCost, existing, roadHubs, [terminal, target], 2, 7.4, townAvoidNodes, 2.8, 4.6)); - if (repairPath.length >= 4 && repairPath.length <= 68 && pathCompactness(repairPath) < 4.6 && pathOverlapRatio(repairPath, existing, 2) < 0.76) { - repairs.push(repairPath); - repairCount++; - } - } - } - } - nationalRoads.push(...repairs); - return repairs.length; - } - const nationalRoadDeadEndRepairs = 0; - - function demoteUnresolvedNationalRoadBranches() { - let demoted = 0; - for (let i = nationalRoads.length - 1; i >= 0; i--) { - const path = nationalRoads[i]; - if (!path || path.length < 8) continue; - const a = { x: path[0][0], y: path[0][1] }; - const b = { x: path[path.length - 1][0], y: path[path.length - 1][1] }; - const aConnected = terminalIsConnectedToNationalRoad(a, path, [], 3.8); - const bConnected = terminalIsConnectedToNationalRoad(b, path, [], 3.8); - const deadCount = (aConnected ? 0 : 1) + (bConnected ? 0 : 1); - if (!deadCount) continue; - const aImportance = terminalImportance(a); - const bImportance = terminalImportance(b); - const importantTrunk = Math.max(aImportance, bImportance) >= 3 || (aImportance >= 2 && bImportance >= 2 && pathEndpointDistance(path) >= 24); - const looksLikeBranch = deadCount >= 2 || !importantTrunk || pathLength(path) < 34; - if (!looksLikeBranch) continue; - nationalRoads.splice(i, 1); - nationalRoadBranchRoads.push(path); - demoted++; - } - return demoted; - } - const nationalRoadBranchDemotions = demoteUnresolvedNationalRoadBranches(); - const postDemotionInternalNationalRoadFallbacks = ensureInternalNationalRoadCoverage(3); - - function terminalIsConnectedToRail(node, ownPath = null, extraPaths = [], radius = 3.8) { - if (nearMapEdge(node.x, node.y, 4) || distanceToNearest(externalGateways, node.x, node.y) <= 4.2) return true; - const paths = [...railways, ...branchRailways, ...externalRailways, ...ringRailways, ...extraPaths]; - for (const path of paths) { - if (!path || path === ownPath) continue; - const step = Math.max(1, Math.floor(path.length / 64)); - for (let k = 0; k < path.length; k += step) { - const [x, y] = path[k]; - if (Math.hypot(node.x - x, node.y - y) <= radius) return true; - } - } - return false; - } - - function railTerminalImportance(node) { - let best = 0; - for (const city of modernCities) { - const d = Math.hypot(node.x - city.x, node.y - city.y); - if (d > 6.5) continue; - if (city.isPrefecturalCapital || city.rank === "Prefectural Capital") best = Math.max(best, 4); - else if ((city.population || 0) >= 220000) best = Math.max(best, 3); - else if ((city.population || 0) >= 120000) best = Math.max(best, 2); - else best = Math.max(best, 1); - } - for (const port of ports) { - const d = Math.hypot(node.x - port.x, node.y - port.y); - if (d > 6.5) continue; - if (port.portClass === "major") best = Math.max(best, 2); - else if (port.portClass === "regional") best = Math.max(best, 1); - } - for (const station of stations) if (Math.hypot(node.x - station.x, node.y - station.y) <= 5.0) best = Math.max(best, 1); - return best; - } - - function repairRailDeadEnds() { - const repairs = []; - const currentPaths = () => [...railways, ...branchRailways, ...externalRailways, ...ringRailways, ...repairs]; - for (let pass = 0; pass < 3; pass++) { - for (const path of [...railways, ...branchRailways, ...externalRailways]) { - if (!path || path.length < 8) continue; - for (const terminal of [{ x: path[0][0], y: path[0][1] }, { x: path[path.length - 1][0], y: path[path.length - 1][1] }]) { - if (terminalIsConnectedToRail(terminal, path, repairs, 3.8)) continue; - let target = nearestOtherTrunkCell(terminal, path, currentPaths(), 4.0); - if ((!target || target.d > 48) && railTerminalImportance(terminal) >= 2) { - const candidates = [...modernCities, ...ports, ...stations] - .map((p) => ({ p, d: Math.hypot(terminal.x - p.x, terminal.y - p.y) })) - .filter(({ d }) => d >= 6 && d <= 42) - .sort((a, b) => a.d - b.d); - for (const { p } of candidates) { - const access = routePoint(p, "rail", 9400 + p.x * 17 + p.y * 19); - if (terminalIsConnectedToRail(access, path, repairs, 3.8)) { target = access; break; } - } - } - if (!target || (target.d && target.d > 52)) continue; - const existing = currentPaths(); - const repairPath = aStar(terminal, target, makeTransportCost(railCost, existing, railHubs, [terminal, target], 4, 8.4, townAvoidNodes, 2.0, 3.8)); - if (repairPath.length >= 4 && repairPath.length <= 60 && pathCompactness(repairPath) < 4.0 && pathTurnScore(repairPath) < 0.86 && pathOverlapRatio(repairPath, existing, 2) < 0.84) { - repairs.push(repairPath); - } - } - } - } - branchRailways.push(...repairs); - return repairs.length; - } - const railDeadEndRepairs = 0; - - let throughExpresswayAdded = false; - function throughExpresswayCost(a, b) { - 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 nearEndpoint = Math.min(Math.hypot(x - a.x, y - a.y), Math.hypot(x - b.x, y - b.y)) <= 3; - const borderPenalty = nearEndpoint ? 0 : nearMapEdge(x, y, 3) ? 2.2 : 0; - const density = densityValue(x, y); - const cityDistance = distanceToNearest(modernCities, x, y); - const coreAvoid = cityDistance < 2.0 ? 12.0 : cityDistance < 4.5 ? 4.8 : cityDistance < 7.5 ? 1.4 : 0; - const lowDensityPenalty = density < 0.08 ? (0.08 - density) * 4.0 : 0; - const urbanCorridorBonus = density * 1.00 + midDensityAffinity(x, y) * 0.22 + (cityDistance >= 4 && cityDistance <= 18 ? 0.34 : 0); - return Math.max(0.48, 1.16 + slope[i] * 21.0 + barrier * 1.08 + Math.max(0, elevation[i] - 0.62) * 13.0 + coreAvoid + lowDensityPenalty + (river[i] > 0.45 ? 1.0 : 0) + borderPenalty - urbanCorridorBonus - plain[i] * 0.14 - coastalLowland[i] * 0.12 + hash2(x, y, seed + 9101) * 0.03); - }; - } - - function permissiveThroughExpresswayCost(a, b) { - return (x, y) => { - const i = indexOf(x, y); - if (sea[i]) return 24 + nearMapEdge(x, y, 2) * 2 + hash2(x, y, seed + 9202) * 0.2; - const rawBarrier = mountainBarrierPenalty(x, y, "express"); - const tunnelBarrier = rawBarrier >= INF ? 120 + Math.max(0, elevation[i] - 0.66) * 260 + slope[i] * 55 : rawBarrier; - const nearEndpoint = Math.min(Math.hypot(x - a.x, y - a.y), Math.hypot(x - b.x, y - b.y)) <= 3; - const borderPenalty = nearEndpoint ? 0 : nearMapEdge(x, y, 3) ? 2.0 : 0; - const cityDistance = distanceToNearest(modernCities, x, y); - const coreAvoid = cityDistance < 2.0 ? 9.0 : cityDistance < 4.5 ? 3.5 : 0; - const density = densityValue(x, y); - const urbanCorridorBonus = density * 0.85 + midDensityAffinity(x, y) * 0.18 + (cityDistance >= 4 && cityDistance <= 18 ? 0.24 : 0); - return Math.max(0.52, 1.18 + slope[i] * 14.0 + tunnelBarrier * 0.42 + Math.max(0, elevation[i] - 0.66) * 18.0 + coreAvoid + borderPenalty - urbanCorridorBonus - plain[i] * 0.10 + hash2(x, y, seed + 9201) * 0.035); - }; - } - - function pointToSegmentDistance(p, a, b) { - const vx = b.x - a.x; - const vy = b.y - a.y; - const len2 = vx * vx + vy * vy; - if (len2 <= 0.0001) return Math.hypot(p.x - a.x, p.y - a.y); - const t = clamp(((p.x - a.x) * vx + (p.y - a.y) * vy) / len2, 0, 1); - return Math.hypot(p.x - (a.x + vx * t), p.y - (a.y + vy * t)); - } - - function pathTurnScore(path) { - if (!path || path.length < 3) return 0; - let total = 0; - let count = 0; - for (let i = 1; i < path.length - 1; i++) { - const [x0, y0] = path[i - 1]; - const [x1, y1] = path[i]; - const [x2, y2] = path[i + 1]; - const ax = x1 - x0; - const ay = y1 - y0; - const bx = x2 - x1; - const by = y2 - y1; - const al = Math.hypot(ax, ay); - const bl = Math.hypot(bx, by); - if (al < 0.01 || bl < 0.01) continue; - const dot = clamp((ax * bx + ay * by) / (al * bl), -1, 1); - total += Math.acos(dot); - count++; - } - return count ? total / count : 0; - } - - function pathLateralDeviationRatio(path) { - if (!path || path.length < 3) return 0; - const a = { x: path[0][0], y: path[0][1] }; - const b = { x: path[path.length - 1][0], y: path[path.length - 1][1] }; - const direct = Math.max(1, Math.hypot(b.x - a.x, b.y - a.y)); - let maxDeviation = 0; - for (let i = 1; i < path.length - 1; i++) { - const p = { x: path[i][0], y: path[i][1] }; - maxDeviation = Math.max(maxDeviation, pointToSegmentDistance(p, a, b)); - } - return maxDeviation / direct; - } - - function chooseThroughExpresswayVia(a, b) { - const candidates = [capital, ...modernCities.filter((city) => (city.population || 0) >= 90000)]; - let best = null; - let bestScore = -INF; - for (const city of candidates) { - if (!city || !inHumanRegion(city) || sea[indexOf(city.x, city.y)]) continue; - const access = routePoint(city, "express", city.x * 73 + city.y * 79 + 9301); - const lineD = pointToSegmentDistance(access, a, b); - const density = densityValue(access.x, access.y); - const popScore = Math.sqrt(Math.max(0, city.population || 0)) / 520; - const score = density * 2.8 + popScore + (city.isPrefecturalCapital ? 0.9 : 0) - lineD / 38 - mountainBarrierPenalty(access.x, access.y, "express") * 0.004; - if (score > bestScore) { bestScore = score; best = access; } - } - return best; - } - - function addThroughExpressway() { - if (externalGateways.length < 2) return false; - let bestPair = null; - let bestScore = -INF; - for (let i = 0; i < externalGateways.length; i++) { - for (let j = i + 1; j < externalGateways.length; j++) { - const a = externalGateways[i]; - const b = externalGateways[j]; - const d = Math.hypot(a.x - b.x, a.y - b.y); - const opposite = (a.side === "N" && b.side === "S") || (a.side === "S" && b.side === "N") || (a.side === "W" && b.side === "E") || (a.side === "E" && b.side === "W"); - const score = d + (opposite ? 42 : 0) - Math.abs((a.score || 0) - (b.score || 0)) * 3; - if (score > bestScore) { - bestScore = score; - bestPair = [a, b]; - } - } - } - if (!bestPair) return false; - const [a, b] = bestPair; - const existing = [...externalExpressways, ...expressways, ...nationalRoads, ...railways, ...branchRailways]; - const via = chooseThroughExpresswayVia(a, b); - let path = []; - let viaUsed = false; - if (via) { - let first = aStar(a, via, makeTransportCost(throughExpresswayCost(a, via), existing, roadHubs, [a, via], 5, 9.6, townAvoidNodes, 4.2, 6.0)); - first = smoothPathByLineOfSight(first, (x, y) => throughExpresswayCost(a, via)(x, y, x, y) < INF && elevation[indexOf(x, y)] < 0.88, 11); - let second = aStar(via, b, makeTransportCost(throughExpresswayCost(via, b), [...existing, first], roadHubs, [via, b], 5, 9.6, townAvoidNodes, 4.2, 6.0)); - second = smoothPathByLineOfSight(second, (x, y) => throughExpresswayCost(via, b)(x, y, x, y) < INF && elevation[indexOf(x, y)] < 0.88, 11); - if (first.length > 6 && second.length > 6) { path = [...first, ...second.slice(1)]; viaUsed = true; } - } - if (path.length < 12) { - path = aStar(a, b, makeTransportCost(throughExpresswayCost(a, b), existing, roadHubs, [a, b], 5, 9.4, townAvoidNodes, 6.0, 9.0)); - path = smoothPathByLineOfSight(path, (x, y) => throughExpresswayCost(a, b)(x, y, x, y) < INF && elevation[indexOf(x, y)] < 0.88, 11); - } - path = snapPathToExistingExpressways(path, [...externalExpressways, ...expressways], 2.8); - if (!viaUsed && (path.length < 12 || pathEndpointDistance(path) < Math.min(MAP_W, MAP_H) * 0.45 || pathCompactness(path) > 3.25)) { - path = aStar(a, b, makeTransportCost(permissiveThroughExpresswayCost(a, b), existing, roadHubs, [a, b], 4, 7.2, townAvoidNodes, 4.0, 6.5)); - path = smoothPathByLineOfSight(path, (x, y) => permissiveThroughExpresswayCost(a, b)(x, y, x, y) < INF, 12); - path = snapPathToExistingExpressways(path, [...externalExpressways, ...expressways], 2.8); - } - const turnScore = pathTurnScore(path); - const deviation = pathLateralDeviationRatio(path); - const compactness = pathCompactness(path); - if (path.length < 12 || pathEndpointDistance(path) < Math.min(MAP_W, MAP_H) * 0.42 || compactness > (viaUsed ? 4.10 : 3.05) || turnScore > (viaUsed ? 0.72 : 0.60) || deviation > (viaUsed ? 0.52 : 0.38)) return false; - externalExpressways.push(path); - incrementDegree(expressDegree, a); - incrementDegree(expressDegree, b); - throughExpresswayAdded = true; - return true; - } - // addThroughExpressway(); - - function nearestPathCellDistance(node, paths) { - let best = INF; - for (const path of paths) { - for (const [x, y] of path) best = Math.min(best, Math.hypot(node.x - x, node.y - y)); - } - return best; - } - - 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]) 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] || !isHumanRegionCell(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) => isHumanRegionCell(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 = [...nationalRoadBranchRoads]; - 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) { - if (!a || !b || Math.hypot(a.x - b.x, a.y - b.y) > 38) return; - const key = `${a.x},${a.y}|${b.x},${b.y}`; - const reverseKey = `${b.x},${b.y}|${a.x},${a.y}`; - if (connectedPairs.has(key) || connectedPairs.has(reverseKey)) return; - connectedPairs.add(key); - const path = aStar(a, b, minorRoadCost); - if (path.length > 2 && path.length < 90) minorRoads.push(path); - } - - function nearestRoadAccessNode(node, paths, sampleStep = 7) { - let best = null; - let bestD = INF; - for (const path of paths) { - if (!path || path.length === 0) continue; - const step = Math.max(1, Math.floor(path.length / Math.max(8, Math.ceil(path.length / sampleStep)))); - for (let k = 0; k < path.length; k += step) { - const [x, y] = path[k]; - const d = Math.hypot(node.x - x, node.y - y); - if (d < bestD) { bestD = d; best = { x, y, kind: "Road access", d }; } - } - } - return best; - } - - // Branches from the yellow national-road network are drawn as ordinary white - // roads. This keeps the national-road layer as a through-network while still - // connecting local towns, ports, castle towns, and suburban/new-town nodes. - const nationalAccessPaths = [...nationalRoads, ...externalRoads, ...ringRoads, ...premodernRoads]; - const localTownNodes = [...modernCities, ...ports, ...markets, ...castleTowns, ...satelliteCities, ...newTowns] - .filter((node, idx, arr) => idx === arr.findIndex((p) => Math.hypot(p.x - node.x, p.y - node.y) < 2.5)); - for (const town of localTownNodes) { - const nearestTrunk = nearestRoadAccessNode(town, nationalAccessPaths, 6); - if (nearestTrunk && nearestTrunk.d > 2.8 && nearestTrunk.d < 42) addMinorRoad(town, nearestTrunk); - } - const neighborTownNodes = localTownNodes.slice().sort((a, b) => (b.population || 0) - (a.population || 0)); - for (const town of neighborTownNodes.slice(0, 28)) { - const neighbor = pickEntities(neighborTownNodes.filter((p) => p !== town).map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - town.x, p.y - town.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; - if (neighbor && Math.hypot(neighbor.x - town.x, neighbor.y - town.y) < 22) addMinorRoad(town, neighbor); - } - - for (const village of villages.slice().sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 45)) { - if (rand(seed, village.x * 13 + village.y * 17) < 0.78) { - 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) < 34) addMinorRoad(village, target); - } - } - for (const market of markets.slice().sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 32)) { - const localVillages = pickEntities(villages.map((v) => ({ ...v, score: 1 / (1 + Math.hypot(v.x - market.x, v.y - market.y)) })), { max: 2, minDistance: 1, threshold: 0 }); - for (const v of localVillages) if (Math.hypot(v.x - market.x, v.y - market.y) < 24) 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); - } - for (const port of ports) { - const target = pickEntities([...markets, ...villages, ...stations.slice(0, 18)].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - port.x, p.y - port.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; - if (target && Math.hypot(target.x - port.x, target.y - port.y) < 24) addMinorRoad(port, target); - } - for (const localCenter of [...satelliteCities, ...newTowns]) { - const target = pickEntities([...stations, ...markets, ...modernCities].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - localCenter.x, p.y - localCenter.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; - if (target && Math.hypot(target.x - localCenter.x, target.y - localCenter.y) < 30) addMinorRoad(localCenter, target); - } - for (const station of stations.slice(0, 12)) { - const locals = pickEntities([...villages, ...markets, ...ports].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - station.x, p.y - station.y)) })), { max: 1, minDistance: 1, threshold: 0 }); - for (const local of locals) if (Math.hypot(local.x - station.x, local.y - station.y) < 22) addMinorRoad(station, local); - } - for (const village of villages.slice(0, 16)) { - const neighbor = pickEntities(villages.filter((v) => v !== village).map((v) => ({ ...v, score: 1 / (1 + Math.hypot(v.x - village.x, v.y - village.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; - if (neighbor && Math.hypot(neighbor.x - village.x, neighbor.y - village.y) < 14) addMinorRoad(village, neighbor); - } - - const combinedModernTransport = [...nationalRoads, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways, ...externalRailways, ...expressways]; - const requiredTransportNodes = [capital, ...externalGateways, ...modernCities.filter((city) => (city.population || 0) >= 120000 || city.isPrefecturalCapital)]; - const connectedRequiredNodeCount = requiredTransportNodes.filter((node) => nearestPathCellDistance(node, combinedModernTransport) <= 7).length; - const allExpresswayPaths = [...expressways, ...externalExpressways]; - const expresswayCells = allExpresswayPaths.flat(); - const expresswayAverageDensity = expresswayCells.length ? expresswayCells.reduce((sum, [x, y]) => sum + densityValue(x, y), 0) / expresswayCells.length : 0; - const nationalRoadPopulationCoverageDebug = nationalRoadPopulationCoverage(nationalRoads, 8.5); const transportDebug = { - urbanHierarchy: urbanHierarchyDebug, - requiredNodeCount: requiredTransportNodes.length, - connectedRequiredNodeCount, - throughExpresswayAdded, - nationalRoadDeadEndRepairs, - railDeadEndRepairs, - nationalRoadBranchDemotions, - regionalNationalRoadsAdded, - regionalNationalRoadFallbacks, - metroRadialNationalRoadsAdded, - metroRingRoadSegmentsAdded, - internalNationalRoadFallbacks, - postDemotionInternalNationalRoadFallbacks, - nationalRoadPopulationCoverage: Number(nationalRoadPopulationCoverageDebug.ratio.toFixed(3)), - nationalRoadUncoveredPopulation: Math.round(nationalRoadPopulationCoverageDebug.uncoveredPopulation || 0), - internalNationalRoadCellCount: internalNationalRoadCellCount(), - externalGatewayCount: externalGateways.length, - externalNationalRoadCount: externalRoads.length, - expresswayAverageDensity: Number(expresswayAverageDensity.toFixed(3)), - expresswayPathCount: allExpresswayPaths.length, - minorRoadCount: minorRoads.length, - minorRoadTotalLength: Math.round(minorRoads.reduce((sum, path) => sum + pathLength(path), 0)), - settlementModel: { - villageCount: villages.length, - marketCount: markets.length, - averageUrbanFootprintCells: Number((modernCities.reduce((sum, city) => sum + (city.urbanFootprintCells || 0), 0) / Math.max(1, modernCities.length)).toFixed(1)), - maxUrbanFootprintCells: Math.max(0, ...modernCities.map((city) => city.urbanFootprintCells || 0)), - }, + humanStageVersion: "v2-sparse-raster", + aStarRoutes: 0, + regionalNodeCount: [...regionStats.keys()].reduce((sum, regionId) => sum + importantNodesForRegion(regionId).length, 0), + nationalRoadPopulationCoverage: 0, + nationalRoadUncoveredPopulation: 0, }; - 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 && !urbanFootprint[i] && !oldUrbanFootprint[i]) landuse[i] = LANDUSE.FOREST; - else if (urbanCoreFootprint[i] || (coreScore > 0.72 && density > 0.52 && slope[i] < 0.26 && ridgeField[i] < 0.38)) landuse[i] = LANDUSE.CBD; - else if (oldUrbanFootprint[i] || oldTownScore > 0.49) landuse[i] = LANDUSE.OLD_URBAN; - else if (industrialInfluence[i] > 0.48 && !urbanCoreFootprint[i]) landuse[i] = LANDUSE.INDUSTRIAL; - else if (logisticsInfluence[i] > 0.44 && !urbanCoreFootprint[i]) landuse[i] = LANDUSE.LOGISTICS; - else if (newTownInfluence[i] > 0.42 && urbanEnvelope > 0.16) landuse[i] = LANDUSE.NEW_TOWN; - else if (urbanFootprint[i]) landuse[i] = LANDUSE.SUBURB; - else if (suburbScore > 0.235 && !isolatedCorridor && slope[i] < 0.32 && ridgeField[i] < 0.48 && (normalizedUrbanDistance < 1.42 || satelliteInfluence[i] > 0.24)) landuse[i] = LANDUSE.SUBURB; - 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] = LANDUSE.SUBURB; - else if (farm) landuse[i] = LANDUSE.FARMLAND; - else if (ruralSettlementFootprint[i] || ruralScore > 0.3) landuse[i] = LANDUSE.RURAL; - else landuse[i] = LANDUSE.RURAL; - } - } - - 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 (isUrbanResidentialLanduse(lu)) 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] || !isHumanRegionCell(i)) continue; - const lu0 = landuse[i]; - if (!isBuiltLanduse(lu0)) 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] || !isHumanRegionCell(ni)) continue; - if (!isBuiltLanduse(landuse[ni])) 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 ? LANDUSE.FARMLAND : LANDUSE.RURAL; - } - } - } - - 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] === LANDUSE.CBD && !hasUrbanNeighborCluster(x, y, 2, 8)) landuse[i] = LANDUSE.SUBURB; - } - } - - function growDidCore(center, city, salt) { - if (!center || !city) return 0; - const start = indexOf(center.x, center.y); - if (!isHumanRegionCell(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 || !isHumanRegionCell(i)) continue; - if (!(landuse[i] === LANDUSE.OLD_URBAN || landuse[i] === LANDUSE.CBD || landuse[i] === LANDUSE.SUBURB || landuse[i] === LANDUSE.NEW_TOWN || populationDensity[i] > 0.22 || stationInfluence[i] > 0.14)) continue; - - selected.add(i); - landuse[i] = LANDUSE.CBD; - made++; - - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - if (queued.has(ni) || selected.has(ni) || !isHumanRegionCell(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] === LANDUSE.CBD && !hasUrbanNeighborCluster(x, y, 2, 8)) landuse[i] = LANDUSE.SUBURB; - } - } - - return { ports, crossings, @@ -3163,9 +945,9 @@ export function generateMapFeatures(seed, terrain) { villages, markets, castles, + castleTowns, premodernRoads, minorRoads, - castleTowns, modernCities, populationDensity, railways, @@ -3188,10 +970,12 @@ export function generateMapFeatures(seed, terrain) { landuse, stationInfluence, roadInfluence, + roadDensityInfluence, + stationDensityInfluence, railInfluence2, villageInfluence, externalGateways, - transportDebug, cityPopulationCap, + transportDebug, }; } diff --git a/mapFeaturesV2.js b/mapFeaturesV2.js deleted file mode 100644 index 157774b..0000000 --- a/mapFeaturesV2.js +++ /dev/null @@ -1,930 +0,0 @@ -import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise } from "./mapUtils.js"; -import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js"; -import { LANDUSE } from "./landuseCodes.js"; - -// Lightweight Human Geography V2 -// -------------------------------- -// This replaces the heavy iterative human stage with a sparse skeleton + raster -// synthesis model: -// 1. build terrain-derived human context once -// 2. place villages/towns/cities by region quotas -// 3. make sparse approximate transport paths without full-resolution A* -// 4. synthesize population and land-use fields in one raster pass - -export function generateMapFeatures(seed, terrain) { - const { - elevation, - moisture, - slope, - sea, - river, - floodplain, - plain, - agriculture, - ridgeField, - valleyField, - basinField, - coastalLowland, - flowAccum, - arcSpineField, - branchRidgeField, - depositionalLowland, - alluvialFanField, - deltaField, - portSuitability, - crossingSuitability, - passSuitability, - prefectureMask, - prefectureRegionId, - naturalBarrierScore, - } = terrain; - - function regionIdAt(x, y) { - if (!inside(x, y)) return -1; - const i = indexOf(x, y); - if (sea[i]) return -1; - if (prefectureMask?.[i]) return 0; - const id = prefectureRegionId?.[i]; - return id !== undefined && id >= 0 ? id : -1; - } - - function inFocusedPrefecture(p) { - return Boolean(p && inside(p.x, p.y) && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]); - } - - function localConfluenceScore(x, y) { - let arms = 0; - let strong = 0; - for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) { - const nx = x + dx; - const ny = y + dy; - if (!inside(nx, ny)) continue; - const rv = river[indexOf(nx, ny)]; - if (rv > 0.18) arms++; - if (rv > 0.34) strong++; - } - return clamp((arms >= 3 ? 0.22 : arms === 2 ? 0.09 : 0) + strong * 0.04); - } - - // --- 1. Human context: one full raster pass ----------------------------- - const developable = new Float32Array(SIZE); - const ruralSuitability = new Float32Array(SIZE); - const townSuitability = new Float32Array(SIZE); - const valleySettlement = new Float32Array(SIZE); - const coastalSettlement = new Float32Array(SIZE); - const confluenceField = new Float32Array(SIZE); - const barrierCost = new Float32Array(SIZE); - const corridorCost = new Float32Array(SIZE); - const settlementCluster = new Float32Array(SIZE); - const settlementScore = 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]) { - barrierCost[i] = INF; - corridorCost[i] = INF; - continue; - } - const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.90; - const highPenalty = Math.max(0, elevation[i] - 0.56); - const lowSlope = clamp(1 - slope[i] * 2.3); - const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y) : 0; - const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38; - confluenceField[i] = confluence; - - developable[i] = clamp( - plain[i] * 0.34 + - agriculture[i] * 0.24 + - basinField[i] * 0.24 + - valleyField[i] * 0.24 + - coastalLowland[i] * 0.18 + - depositional * 0.22 + - lowSlope * 0.10 - - slope[i] * 0.82 - - ridgeField[i] * 0.52 - - spine * 0.24 - - highPenalty * 1.14 - - floodplain[i] * 0.03 - ); - valleySettlement[i] = clamp( - valleyField[i] * 0.52 + - river[i] * 0.08 + - confluence * 0.38 + - depositional * 0.20 + - basinField[i] * 0.16 + - plain[i] * 0.08 + - lowSlope * 0.12 - - slope[i] * 0.54 - - ridgeField[i] * 0.30 - - spine * 0.16 - - highPenalty * 0.70 - - floodplain[i] * 0.10 - ); - coastalSettlement[i] = clamp( - coastalLowland[i] * 0.50 + - (portSuitability?.[i] || 0) * 0.30 + - (deltaField?.[i] || 0) * 0.20 + - plain[i] * 0.10 - - slope[i] * 0.52 - - ridgeField[i] * 0.24 - - spine * 0.12 - ); - const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16; - settlementCluster[i] = clamp((developable[i] * 0.52 + valleySettlement[i] * 0.28 + coastalSettlement[i] * 0.18 + agriculture[i] * 0.22) * clusterNoise); - ruralSuitability[i] = clamp( - agriculture[i] * 0.42 + - developable[i] * 0.28 + - valleySettlement[i] * 0.24 + - coastalSettlement[i] * 0.15 + - settlementCluster[i] * 0.24 - - Math.max(0, elevation[i] - 0.64) * 0.56 - ); - townSuitability[i] = clamp( - developable[i] * 0.40 + - valleySettlement[i] * 0.26 + - coastalSettlement[i] * 0.20 + - confluence * 0.34 + - basinField[i] * 0.16 + - plain[i] * 0.12 + - settlementCluster[i] * 0.16 - - slope[i] * 0.34 - - ridgeField[i] * 0.17 - - spine * 0.10 - ); - settlementScore[i] = clamp(ruralSuitability[i] * 0.58 + townSuitability[i] * 0.34 + confluence * 0.10); - const naturalBarrier = naturalBarrierScore?.[i] || 0; - barrierCost[i] = 1 + slope[i] * 6.4 + ridgeField[i] * 3.2 + spine * 2.2 + highPenalty * 5.8 + river[i] * 0.25 + naturalBarrier * 1.2 - valleyField[i] * 0.55 - plain[i] * 0.30 - coastalLowland[i] * 0.14; - corridorCost[i] = Math.max(0.25, barrierCost[i] - developable[i] * 0.42 - valleySettlement[i] * 0.36 - coastalSettlement[i] * 0.12 + hash2(x, y, seed + 7011) * 0.05); - } - } - - // --- region statistics --------------------------------------------------- - const regionStats = new Map(); - function ensureRegion(regionId) { - let st = regionStats.get(regionId); - if (!st) { - st = { - id: regionId, - area: 0, - developableCells: 0, - developableSum: 0, - valleyCells: 0, - coastCells: 0, - townCells: 0, - plainCells: 0, - minX: MAP_W, - minY: MAP_H, - maxX: 0, - maxY: 0, - }; - regionStats.set(regionId, st); - } - return st; - } - 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 regionId = regionIdAt(x, y); - if (regionId < 0) continue; - const st = ensureRegion(regionId); - st.area++; - st.developableSum += developable[i]; - if (developable[i] > 0.16) st.developableCells++; - if (valleySettlement[i] > 0.24) st.valleyCells++; - if (coastalSettlement[i] > 0.25) st.coastCells++; - if (townSuitability[i] > 0.28) st.townCells++; - if (plain[i] > 0.24) st.plainCells++; - st.minX = Math.min(st.minX, x); - st.minY = Math.min(st.minY, y); - st.maxX = Math.max(st.maxX, x); - st.maxY = Math.max(st.maxY, y); - } - } - - function visibilityFactor(regionId, st) { - if (!st || st.area <= 0) return 0; - // Treat the focused prefecture and neighboring prefectures with the same - // density curve. Only genuinely clipped map-edge slivers are downscaled. - return clamp(Math.sqrt(st.area / 1900), 0.32, 1.05); - } - - function pickRegionalPoints(scoreArray, { - stride = 1, - threshold = 0.25, - minDistance = 6, - totalMax = 100, - seedOffset = 0, - quotaForRegion, - predicate = () => true, - kind = "Point", - extraScore = () => 0, - }) { - const byRegion = new Map(); - for (let y = 2; y < MAP_H - 2; y += stride) { - for (let x = 2; x < MAP_W - 2; x += stride) { - const i = indexOf(x, y); - if (sea[i] || !predicate(x, y, i)) continue; - const regionId = regionIdAt(x, y); - if (regionId < 0) continue; - const score = scoreArray[i] + extraScore(x, y, i) + hash2(x, y, seed + seedOffset) * 0.055; - if (score < threshold) continue; - if (!byRegion.has(regionId)) byRegion.set(regionId, []); - byRegion.get(regionId).push({ x, y, score, kind, regionId }); - } - } - const out = []; - for (const [regionId, candidates] of [...byRegion.entries()].sort((a, b) => a[0] - b[0])) { - const st = regionStats.get(regionId); - const quota = quotaForRegion ? quotaForRegion(regionId, st) : 0; - if (quota <= 0) continue; - out.push(...pickEntities(candidates, { - max: quota, - minDistance, - threshold, - seed: seed + seedOffset + regionId * 1009, - jitter: 0.04, - })); - } - return out.sort((a, b) => b.score - a.score).slice(0, totalMax); - } - - function pickGlobalPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true, stride = 1 }) { - const candidates = []; - for (let y = 2; y < MAP_H - 2; y += stride) { - for (let x = 2; x < MAP_W - 2; x += stride) { - const i = indexOf(x, y); - if (sea[i] || !predicate(x, y, i)) continue; - const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.07; - if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) }); - } - } - return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset }); - } - - // --- 2. Sparse points ---------------------------------------------------- - let ports = pickGlobalPoints(portSuitability || coastalSettlement, { - threshold: 0.30 + rand(seed, 1001) * 0.08, - max: 10, - minDistance: 13, - seedOffset: 1000, - predicate: (x, y, i) => coastalSettlement[i] > 0.14 || (portSuitability?.[i] || 0) > 0.25, - }).map((p, n) => { - const i = indexOf(p.x, p.y); - const harborPotential = (portSuitability?.[i] || 0) + coastalLowland[i] * 0.22 + (deltaField?.[i] || 0) * 0.08 - slope[i] * 0.18; - const portClass = n === 0 ? "major" : n < 3 && harborPotential > 0.34 ? "regional" : harborPotential > 0.24 ? "fishing" : "lake"; - const kind = portClass === "major" ? "Major Port" : portClass === "regional" ? "Regional Port" : portClass === "lake" ? "Lake Port" : "Fishing Port"; - return { ...p, harborPotential, portClass, kind, score: harborPotential }; - }).sort((a, b) => b.harborPotential - a.harborPotential); - if (ports.length && !ports.some((p) => p.portClass === "major")) { - ports[0].portClass = "major"; - ports[0].kind = "Major Port"; - } - const commercialPorts = ports.filter((p) => p.portClass === "major" || p.portClass === "regional"); - - const crossings = pickGlobalPoints(crossingSuitability || confluenceField, { - threshold: 0.30 + rand(seed, 1011) * 0.06, - max: 18, - minDistance: 9, - seedOffset: 1010, - predicate: (x, y, i) => river[i] > 0.12 || confluenceField[i] > 0.09, - }).map((p) => ({ ...p, kind: "River Crossing" })); - - const passes = pickGlobalPoints(passSuitability || valleySettlement, { - threshold: 0.18 + rand(seed, 1021) * 0.06, - max: 12, - minDistance: 11, - seedOffset: 1020, - predicate: (x, y, i) => elevation[i] > 0.42 && !sea[i], - }).map((p) => ({ ...p, kind: "Pass" })); - - const villageScore = new Float32Array(SIZE); - for (let i = 0; i < SIZE; i++) { - if (sea[i]) continue; - villageScore[i] = clamp(ruralSuitability[i] * 0.68 + valleySettlement[i] * 0.26 + coastalSettlement[i] * 0.18 + settlementCluster[i] * 0.08); - } - const villages = pickRegionalPoints(villageScore, { - stride: 2, - threshold: 0.25 + rand(seed, 1031) * 0.04, - totalMax: 140, - minDistance: 5, - seedOffset: 1030, - kind: "Village", - quotaForRegion: (regionId, st) => { - if (!st || st.developableCells < 10) return 0; - const vf = visibilityFactor(regionId, st); - const raw = (st.developableCells / 65 + st.valleyCells / 44 + st.coastCells / 55 + 1.2) * vf; - const min = st.area > 2600 ? 7 : st.area > 1400 ? 4 : st.area > 520 ? 2 : st.area > 220 ? 1 : 0; - const max = st.area > 3600 ? 24 : st.area > 2200 ? 17 : st.area > 900 ? 9 : 4; - return Math.round(clamp(raw + rand(seed, 1033 + regionId * 19) * 1.5, min, max)); - }, - }).map((p, n) => { - const i = indexOf(p.x, p.y); - const kind = valleySettlement[i] > 0.42 ? "Valley Village" : coastalSettlement[i] > 0.43 ? "Coastal Village" : "Village"; - const population = Math.round((300 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.85) * 4700 + ruralSuitability[i] * 2600) / 100) * 100; - return { ...p, kind, population }; - }); - - const villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2)); - - const marketScore = 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 featurePull = Math.max( - distanceToNearest(ports, x, y) < 8 ? 0.10 : 0, - distanceToNearest(crossings, x, y) < 6 ? 0.06 : 0, - confluenceField[i] * 0.16 - ); - const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.14 : 0; - marketScore[i] = clamp( - townSuitability[i] * 0.62 + - villageInfluence[i] * 0.38 + - featurePull + - valleyMouth + - basinField[i] * 0.12 + - plain[i] * 0.14 + - coastalLowland[i] * 0.08 - - slope[i] * 0.18 - - ridgeField[i] * 0.08 - ); - } - } - - const markets = pickRegionalPoints(marketScore, { - stride: 2, - threshold: 0.31 + rand(seed, 1041) * 0.045, - totalMax: 52, - minDistance: 9, - seedOffset: 1040, - kind: "Market Town", - quotaForRegion: (regionId, st) => { - if (!st || st.townCells < 8) return 0; - const vf = visibilityFactor(regionId, st); - const raw = (st.developableCells / 260 + st.valleyCells / 150 + st.coastCells / 160 + 0.8) * vf; - const min = st.area > 2600 ? 3 : st.area > 1200 ? 2 : st.area > 520 ? 1 : 0; - const max = st.area > 3600 ? 9 : st.area > 2200 ? 7 : st.area > 800 ? 4 : 2; - return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max)); - }, - extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 8 ? 0.07 : 0) + confluenceField[i] * 0.08, - }).map((p, n) => { - const i = indexOf(p.x, p.y); - const kind = coastalSettlement[i] > 0.45 && distanceToNearest(ports, p.x, p.y) < 9 ? "Port Town" : valleySettlement[i] > 0.42 ? "Valley Market Town" : "Market Town"; - const population = Math.round((4000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.50) * 24000 + marketScore[i] * 13000) / 1000) * 1000; - return { ...p, kind, population }; - }); - - const defenseScore = new Float32Array(SIZE); - for (let i = 0; i < SIZE; i++) { - if (sea[i]) continue; - defenseScore[i] = clamp( - confluenceField[i] * 0.38 + - townSuitability[i] * 0.16 + - ridgeField[i] * clamp(1 - Math.abs(elevation[i] - 0.52) / 0.25) * 0.42 + - plain[i] * 0.08 - - floodplain[i] * 0.36 - - coastalLowland[i] * 0.08 - ); - } - const castles = pickGlobalPoints(defenseScore, { - threshold: 0.34 + rand(seed, 1051) * 0.06, - max: 5, - minDistance: 16, - seedOffset: 1050, - }).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", - })); - - const castleTowns = castles.map((c, n) => { - const near = markets.slice().sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y))[0]; - const x = near && Math.hypot(near.x - c.x, near.y - c.y) < 10 ? near.x : c.x; - const y = near && Math.hypot(near.x - c.x, near.y - c.y) < 10 ? near.y : c.y; - return { x, y, score: c.score, kind: "Castle Town", population: 12000 + Math.round(rand(seed, 1060 + n) * 22000 / 1000) * 1000, regionId: regionIdAt(x, y) }; - }); - - // --- 3. Cities by region, without detailed urban flood-fill -------------- - function estimateUrbanCapacity(p, radius = 22, densityBias = 1.0) { - if (!p || !inside(p.x, p.y)) return 0; - const centerRegion = regionIdAt(p.x, p.y); - let capacity = 0; - const r = Math.ceil(radius); - for (let dy = -r; dy <= r; dy++) { - for (let dx = -r; dx <= r; dx++) { - const x = p.x + dx; - const y = p.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (sea[i]) continue; - if (centerRegion >= 0 && regionIdAt(x, y) !== centerRegion) continue; - const d = Math.hypot(dx, dy); - if (d > radius) continue; - const dev = developable[i]; - if (dev < 0.04) continue; - const radial = clamp(1 - d / Math.max(1, radius)); - const terrainMultiplier = clamp(0.60 + plain[i] * 0.28 + basinField[i] * 0.18 + coastalLowland[i] * 0.16 + valleyField[i] * 0.13 - slope[i] * 0.42 - ridgeField[i] * 0.18, 0.26, 1.24); - capacity += dev * (900 + 6200 * Math.pow(radial, 1.25)) * terrainMultiplier * densityBias; - } - } - return Math.max(26000, Math.round(capacity / 1000) * 1000); - } - - const urbanCandidates = [ - ...markets.map((p) => ({ ...p, candidateKind: "town" })), - ...castleTowns.map((p) => ({ ...p, candidateKind: "castleTown" })), - ...commercialPorts.map((p) => ({ ...p, candidateKind: "port" })), - ...crossings.filter((p) => confluenceField[indexOf(p.x, p.y)] > 0.12).map((p) => ({ ...p, candidateKind: "crossing" })), - ]; - - const cityCandidateByRegion = new Map(); - for (const p of urbanCandidates) { - const i = indexOf(p.x, p.y); - const regionId = regionIdAt(p.x, p.y); - if (regionId < 0) continue; - const st = regionStats.get(regionId); - const cityRadius = st && st.area > 2400 ? 28 : st && st.area > 900 ? 24 : 20; - const capacity = estimateUrbanCapacity(p, cityRadius, 1.0); - const score = - Math.log10(capacity + 1) * 0.72 + - townSuitability[i] * 1.40 + - developable[i] * 1.05 + - confluenceField[i] * 0.22 + - (p.candidateKind === "port" ? 0.48 : 0) + - (p.candidateKind === "castleTown" ? 0.22 : 0) + - hash2(p.x, p.y, seed + 12000) * 0.16; - if (!cityCandidateByRegion.has(regionId)) cityCandidateByRegion.set(regionId, []); - cityCandidateByRegion.get(regionId).push({ ...p, score, capacity, regionId }); - } - - const modernCities = []; - const usedCitySites = []; - for (const [regionId, list] of [...cityCandidateByRegion.entries()].sort((a, b) => a[0] - b[0])) { - const st = regionStats.get(regionId); - if (!st || st.developableCells < 30) continue; - const vf = visibilityFactor(regionId, st); - const maxCities = clamp( - Math.round((st.developableCells / 720 + 0.9) * vf + rand(seed, 12100 + regionId * 17) * 1.2), - st.area > 1600 ? 1 : 0, - st.area > 3600 ? 6 : st.area > 2200 ? 4 : st.area > 900 ? 3 : 1 - ); - const selected = pickEntities(list, { - max: maxCities, - minDistance: 17, - threshold: 0, - seed: seed + 12110 + regionId * 313, - jitter: 0.02, - }); - for (const p of selected) { - if (usedCitySites.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < 12)) continue; - usedCitySites.push(p); - modernCities.push(p); - } - } - - // No focused-prefecture fallback: all prefecture regions use the same city - // selection rules, so the highlighted region is not overwritten after the - // regional pass. - - modernCities.sort((a, b) => b.capacity - a.capacity || b.score - a.score); - for (const [rank, city] of modernCities.entries()) { - const isFirstInRegion = !modernCities.slice(0, rank).some((c) => c.regionId === city.regionId); - const isPrefecturalCapital = inFocusedPrefecture(city) && !modernCities.slice(0, rank).some((c) => c.isPrefecturalCapital); - const isRegionalCapital = isFirstInRegion; - const rawPop = isRegionalCapital - ? 150000 + rand(seed, 12201 + city.regionId * 17) * 520000 - : 32000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.7) * 260000; - const capMultiplier = isRegionalCapital ? 1.10 : 1.0; - const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000; - city.population = Math.max(isRegionalCapital ? 90000 : 24000, population); - city.isPrefecturalCapital = isPrefecturalCapital; - city.isRegionalCapital = isRegionalCapital; - city.rank = isPrefecturalCapital ? "Prefectural Capital" : isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City"; - city.kind = city.rank; - city.urbanRadius = clamp(5.8 + Math.sqrt(city.population) / 72, 8, isRegionalCapital ? 34 : 24); - city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 380, 2.2, isRegionalCapital ? 6.5 : 5.6); - city.sprawlRadius = clamp(city.urbanRadius * (isRegionalCapital ? 1.45 : city.population >= 120000 ? 1.28 : 1.15), city.urbanRadius + 2, isRegionalCapital ? 44 : 30); - city.urbanWeight = clamp(0.95 + Math.log10(Math.max(10000, city.population)) * 0.29, 1.08, 2.5); - } - - function cityPopulationCap(city) { - const radius = city?.isRegionalCapital ? 29 : city?.population >= 350000 ? 26 : 18; - const bias = city?.isRegionalCapital ? 1.12 : 1.0; - return estimateUrbanCapacity(city, radius, bias); - } - - // --- 4. Lightweight corridors ------------------------------------------- - function routeLight(a, b, snapRadius = 3) { - if (!a || !b) return []; - const steps = Math.max(2, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y) * 1.15)); - const out = []; - let lastKey = ""; - for (let s = 0; s <= steps; s++) { - const t = s / steps; - const fx = a.x + (b.x - a.x) * t; - const fy = a.y + (b.y - a.y) * t; - let best = null; - let bestCost = INF; - const radius = snapRadius + (s > 0 && s < steps ? 1 : 0); - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - const x = Math.round(fx + dx); - const y = Math.round(fy + dy); - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (sea[i]) continue; - const lineDist = Math.hypot(x - fx, y - fy); - const cost = lineDist * 0.72 + corridorCost[i] * 0.62 - valleySettlement[i] * 0.34 - developable[i] * 0.18 + hash2(x, y, seed + 13000 + s) * 0.05; - if (cost < bestCost) { - bestCost = cost; - best = [x, y]; - } - } - } - if (!best) best = [Math.round(fx), Math.round(fy)]; - const key = `${best[0]},${best[1]}`; - if (key !== lastKey) { - out.push(best); - lastKey = key; - } - } - return out; - } - - function importantNodesForRegion(regionId) { - const inRegion = (p) => regionIdAt(p.x, p.y) === regionId; - return [ - ...modernCities.filter(inRegion).map((p) => ({ ...p, nodeWeight: 8 + (p.population || 0) / 120000 })), - ...markets.filter(inRegion).map((p) => ({ ...p, nodeWeight: 3.2 + (p.population || 0) / 25000 })), - ...commercialPorts.filter(inRegion).map((p) => ({ ...p, nodeWeight: p.portClass === "major" ? 6.5 : 4.6 })), - ...passes.filter(inRegion).map((p) => ({ ...p, nodeWeight: 2.2 })), - ].sort((a, b) => b.nodeWeight - a.nodeWeight).slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 16 : 10); - } - - const premodernRoads = []; - const nationalRoads = []; - const minorRoads = []; - const railways = []; - const branchRailways = []; - const externalRoads = []; - const externalRailways = []; - const expressways = []; - const ringRoads = []; - const ringRailways = []; - const ringExpressways = []; - const externalExpressways = []; - const icAccessRoads = []; - const externalGateways = []; - - // Premodern roads connect castles/markets/ports sparsely. - for (const c of castles) { - const near = [...markets, ...ports, ...crossings].sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y)).slice(0, 2); - for (const n of near) { - const path = routeLight(c, n, 2); - if (path.length > 2) premodernRoads.push(path); - } - } - - for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) { - const nodes = importantNodesForRegion(regionId); - if (nodes.length < 2) continue; - const connected = [nodes[0]]; - const remaining = nodes.slice(1); - const maxEdges = (regionStats.get(regionId)?.area || 0) > 2200 ? Math.min(13, nodes.length + 3) : Math.min(7, nodes.length + 1); - while (remaining.length && nationalRoads.length < 48) { - let best = null; - let bestScore = INF; - for (const a of connected) { - for (const b of remaining) { - const d = Math.hypot(a.x - b.x, a.y - b.y); - const score = d - (a.nodeWeight + b.nodeWeight) * 0.9; - if (score < bestScore) { bestScore = score; best = { a, b }; } - } - } - if (!best) break; - const path = routeLight(best.a, best.b, 3); - if (path.length > 2) nationalRoads.push(path); - connected.push(best.b); - remaining.splice(remaining.indexOf(best.b), 1); - if (connected.length - 1 >= maxEdges) break; - } - - // A few k-nearest shortcuts for urbanized regions. - const urbanNodes = nodes.filter((p) => p.population || p.portClass).slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 7 : 4); - for (let i = 0; i < urbanNodes.length; i++) { - const a = urbanNodes[i]; - const b = urbanNodes.slice(i + 1).sort((p, q) => Math.hypot(a.x - p.x, a.y - p.y) - Math.hypot(a.x - q.x, a.y - q.y))[0]; - if (!b || Math.hypot(a.x - b.x, a.y - b.y) > 48) continue; - const path = routeLight(a, b, 3); - if (path.length > 2) nationalRoads.push(path); - } - - // Railways: only high-order cities/ports, as a lightweight placeholder. - const railNodes = nodes.filter((p) => (p.population || 0) > 80000 || p.portClass === "major" || p.portClass === "regional").slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 6 : 4); - railNodes.sort((a, b) => a.x - b.x || a.y - b.y); - for (let i = 1; i < railNodes.length; i++) { - const path = routeLight(railNodes[i - 1], railNodes[i], 4); - if (path.length > 4) railways.push(path); - } - } - - // External gateways at land edges; used by naming/UI and later transport work. - for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) { - const st = regionStats.get(regionId); - if (!st || st.area < 140) continue; - const edgeCandidates = []; - for (let y = st.minY; y <= st.maxY; y += 3) { - for (const x of [st.minX, st.maxX]) { - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (!sea[i] && regionIdAt(x, y) === regionId && (x < 5 || y < 5 || x > MAP_W - 6 || y > MAP_H - 6)) edgeCandidates.push({ x, y, score: developable[i] + valleySettlement[i] }); - } - } - for (let x = st.minX; x <= st.maxX; x += 3) { - for (const y of [st.minY, st.maxY]) { - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (!sea[i] && regionIdAt(x, y) === regionId && (x < 5 || y < 5 || x > MAP_W - 6 || y > MAP_H - 6)) edgeCandidates.push({ x, y, score: developable[i] + valleySettlement[i] }); - } - } - const gateway = pickEntities(edgeCandidates, { max: (regionStats.get(regionId)?.area || 0) > 2200 ? 2 : 1, minDistance: 16, seed: seed + 13200 + regionId * 11 })[0]; - if (gateway) { - gateway.kind = "External Gateway"; - gateway.regionId = regionId; - externalGateways.push(gateway); - const target = importantNodesForRegion(regionId)[0]; - if (target) { - const path = routeLight(gateway, target, 3); - if (path.length > 2) externalRoads.push(path); - } - } - } - - // Approximate expressways as a very small subset of top inter-city links. - const topCities = modernCities.slice().sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 6); - for (let i = 1; i < topCities.length && expressways.length < 4; i++) { - const a = topCities[i - 1]; - const b = topCities[i]; - if (Math.hypot(a.x - b.x, a.y - b.y) < 85) { - const path = routeLight(a, b, 5); - if (path.length > 5) expressways.push(path); - } - } - - // Land-use road influence intentionally excludes expressways. Expressways - // are through-corridors here, not automatic suburbanization generators. - // A narrow field controls land-use attachment, while a broader field raises - // population density around trunk roads without painting a wide suburb band. - const roadLanduseInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 2.25); - const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 5.0); - const railInfluence2 = influenceFromPaths([...railways, ...branchRailways, ...externalRailways], 4); - - const stations = []; - const usedStationKeys = new Set(); - function addStation(x, y, kind = "Station", score = 1) { - x = Math.round(x); y = Math.round(y); - if (!inside(x, y) || sea[indexOf(x, y)]) return; - const key = `${x},${y}`; - if (usedStationKeys.has(key)) return; - usedStationKeys.add(key); - stations.push({ x, y, kind, score, regionId: regionIdAt(x, y) }); - } - for (const city of modernCities) addStation(city.x, city.y, city.isPrefecturalCapital || city.isRegionalCapital ? "Major Station" : "Station", 1.5); - for (const path of railways) for (const p of samplePath(path, 14)) addStation(p.x, p.y, "Station", 0.8); - const stationInfluence = influenceFromPoints(stations, 7, (s) => s.kind === "Major Station" ? 1.35 : 0.85); - - // --- 5. Approximate city/town influence and land-use --------------------- - const cityInfluence = new Float32Array(SIZE); - const coreInfluence = new Float32Array(SIZE); - const oldTownInfluence = influenceFromPoints([...markets, ...castleTowns, ...ports], 7, (p) => p.kind === "Major Port" ? 1.2 : 0.9); - const populationDensity = new Float32Array(SIZE); - - function addKernel(grid, p, radius, weight, exponent = 1.7, terrainWeighted = true, combine = "max") { - const r = Math.ceil(radius); - for (let dy = -r; dy <= r; dy++) { - for (let dx = -r; dx <= r; dx++) { - const x = p.x + dx; - const y = p.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (sea[i]) continue; - const d = Math.hypot(dx, dy); - if (d > radius) continue; - const terrain = terrainWeighted ? clamp(0.24 + developable[i] * 1.00 + valleySettlement[i] * 0.16 + coastalSettlement[i] * 0.10 - slope[i] * 0.20 - ridgeField[i] * 0.12 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.06, 0, 1.34) : 1; - const v = weight * Math.pow(1 - d / Math.max(1, radius), exponent) * terrain; - if (combine === "add") grid[i] = Math.min(3.4, grid[i] + v); - else if (v > grid[i]) grid[i] = v; - } - } - } - - for (const city of modernCities) { - addKernel(cityInfluence, city, city.sprawlRadius || Math.round((city.urbanRadius || 10) * 1.4), (city.urbanWeight || 1.0) * (city.isRegionalCapital ? 0.38 : 0.30), 2.75, true, "add"); - addKernel(cityInfluence, city, city.urbanRadius || 10, city.urbanWeight || 1.0, 1.18, true, "add"); - addKernel(coreInfluence, city, city.coreRadius || 3, (city.urbanWeight || 1.0) * 1.10, 1.65, true, "max"); - } - const townInfluence = influenceFromPoints(markets, 6, (m) => clamp((m.population || 10000) / 26000, 0.45, 1.25)); - - // Industrial/logistics/new town placeholders remain lightweight. They are - // routed by land-use proximity rather than expensive search passes. - const industrialZones = []; - for (const p of [...commercialPorts, ...modernCities.slice(0, 5)]) { - const candidates = []; - for (let dy = -10; dy <= 10; dy++) { - for (let dx = -10; dx <= 10; dx++) { - const x = p.x + dx; - const y = p.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (sea[i]) continue; - const d = Math.hypot(dx, dy); - if (d < 3 || d > 10) continue; - const score = coastalLowland[i] * 0.22 + developable[i] * 0.22 + roadInfluence[i] * 0.20 + plain[i] * 0.12 - slope[i] * 0.25 + hash2(x, y, seed + 14000) * 0.06; - if (score > 0.22) candidates.push({ x, y, score, kind: "Industrial Zone", regionId: regionIdAt(x, y) }); - } - } - const z = pickEntities(candidates, { max: 1, minDistance: 6, seed: seed + 14010 + p.x * 3 + p.y })[0]; - if (z && industrialZones.every((q) => Math.hypot(q.x - z.x, q.y - z.y) > 13)) industrialZones.push(z); - if (industrialZones.length >= 8) break; - } - const industrialInfluence = influenceFromPoints(industrialZones, 5, () => 1.0); - - const satelliteCities = []; - const newTowns = []; - const logisticsParks = []; - const interchanges = []; - var landuse = new Uint8Array(SIZE); - - // Re-run land-use classification after landuse allocation. The loop above is - // intentionally inside a helper to keep all thresholds in one place. - function classifyLanduse() { - landuse.fill(LANDUSE.RURAL); - let maxDensity = 0; - const baseNoiseSeed = seed + 15000; - const urbanCapacity = 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; - const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82); - const urban = cityInfluence[i] * 0.76 + stationInfluence[i] * 0.26 + roadInfluence[i] * 0.14 + railInfluence2[i] * 0.10; - const core = coreInfluence[i]; - const oldTown = oldTownInfluence[i] * 0.70 + townInfluence[i] * 0.38; - const rural = villageInfluence[i] * 0.24 + ruralSuitability[i] * 0.30; - const riverUrban = clamp(river[i] * 0.12 + valleyField[i] * 0.10 + plain[i] * 0.08 + basinField[i] * 0.08 - floodplain[i] * 0.10); - urbanCapacity[i] = clamp( - developable[i] * 0.66 + - plain[i] * 0.16 + - basinField[i] * 0.16 + - valleyField[i] * 0.16 + - coastalLowland[i] * 0.12 + - roadInfluence[i] * 0.16 + transport * 0.10 + - riverUrban * 0.14 - - slope[i] * 0.18 - - ridgeField[i] * 0.12 - - floodplain[i] * 0.08 - ); - populationDensity[i] = clamp(urban * 0.66 + core * 0.46 + oldTown * 0.28 + townInfluence[i] * 0.16 + villageInfluence[i] * 0.14 + roadInfluence[i] * 0.18 + transport * 0.08); - maxDensity = Math.max(maxDensity, populationDensity[i]); - - if (elevation[i] > 0.67 || (slope[i] > 0.56 && ridgeField[i] > 0.30) || ridgeField[i] > 0.70) { - landuse[i] = LANDUSE.FOREST; - continue; - } - if (industrialInfluence[i] > 0.22 && urbanCapacity[i] > 0.10) { - landuse[i] = LANDUSE.INDUSTRIAL; - continue; - } - if (core > 0.38 && urbanCapacity[i] > 0.10) { - landuse[i] = LANDUSE.CBD; - continue; - } - if (oldTown > 0.18 && urbanCapacity[i] > 0.09) { - landuse[i] = LANDUSE.OLD_URBAN; - continue; - } - - const suburbanity = urban * 0.88 + transport * 0.23 + stationInfluence[i] * 0.12 + townInfluence[i] * 0.08 + riverUrban * 0.08; - const edgeTaper = clamp(cityInfluence[i] * 0.52 + stationInfluence[i] * 0.16 + roadLanduseInfluence[i] * 0.10 + 0.28); - const sprawlBias = clamp(0.58 + hash2(x, y, baseNoiseSeed) * 0.42); - const sprawlScore = suburbanity * sprawlBias * edgeTaper - core * 0.12; - if (sprawlScore > 0.24 && urbanCapacity[i] > 0.10) { - landuse[i] = LANDUSE.SUBURB; - } else if (roadLanduseInfluence[i] > 0.30 && urbanCapacity[i] > 0.10 && (townInfluence[i] > 0.05 || cityInfluence[i] > 0.11)) { - landuse[i] = LANDUSE.SUBURB; - } else if (agriculture[i] > 0.24 || rural > 0.22 || (developable[i] > 0.18 && plain[i] > 0.18)) { - landuse[i] = LANDUSE.FARMLAND; - } else { - landuse[i] = elevation[i] > 0.52 || slope[i] > 0.34 ? LANDUSE.FOREST : LANDUSE.RURAL; - } - } - } - - const baseLanduse = landuse.slice(); - const isBuilt = (lu) => lu >= LANDUSE.OLD_URBAN && lu <= LANDUSE.ROADSIDE; - 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] || baseLanduse[i] === LANDUSE.FOREST) continue; - const transport = Math.max(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82); - let urbanNeighbors = 0; - let cbdNeighbors = 0; - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - if (!dx && !dy) continue; - const lu = baseLanduse[indexOf(x + dx, y + dy)]; - if (isBuilt(lu)) urbanNeighbors++; - if (lu === LANDUSE.CBD) cbdNeighbors++; - } - } - if (baseLanduse[i] === LANDUSE.OLD_URBAN && coreInfluence[i] > 0.31 && cbdNeighbors >= 3) { - landuse[i] = LANDUSE.CBD; - continue; - } - if ((baseLanduse[i] === LANDUSE.FARMLAND || baseLanduse[i] === LANDUSE.RURAL) && urbanCapacity[i] > 0.10) { - const fringeChance = urbanNeighbors * 0.055 + cityInfluence[i] * 0.13 + transport * 0.12 + stationInfluence[i] * 0.08; - const noise = 0.23 + hash2(x, y, seed + 15050) * 0.24; - if (fringeChance > 0.34 + noise) { - landuse[i] = LANDUSE.SUBURB; - } - } - if ((river[i] > 0.10 || railInfluence2[i] > 0.16 || roadLanduseInfluence[i] > 0.22) && urbanNeighbors >= 3 && landuse[i] <= LANDUSE.FARMLAND && urbanCapacity[i] > 0.09) { - landuse[i] = cbdNeighbors >= 1 || coreInfluence[i] > 0.15 ? LANDUSE.OLD_URBAN : LANDUSE.SUBURB; - } - } - } - - if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity); - } - classifyLanduse(); - - for (const city of modernCities) { - let urbanFootprintCells = 0; - let coreFootprintCells = 0; - const r = Math.ceil((city.urbanRadius || 8) * 1.3); - 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]) continue; - if (Math.hypot(dx, dy) > r) continue; - if (landuse[i] >= LANDUSE.OLD_URBAN && landuse[i] <= LANDUSE.ROADSIDE) urbanFootprintCells++; - if (landuse[i] === LANDUSE.CBD) coreFootprintCells++; - } - } - city.urbanFootprintCells = urbanFootprintCells; - city.coreFootprintCells = coreFootprintCells; - } - - const transportDebug = { - humanStageVersion: "v2-sparse-raster", - aStarRoutes: 0, - regionalNodeCount: [...regionStats.keys()].reduce((sum, regionId) => sum + importantNodesForRegion(regionId).length, 0), - nationalRoadPopulationCoverage: 0, - nationalRoadUncoveredPopulation: 0, - }; - - return { - ports, - crossings, - passes, - settlementCluster, - settlementScore, - villages, - 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, - stationInfluence, - roadInfluence, - railInfluence2, - villageInfluence, - externalGateways, - cityPopulationCap, - transportDebug, - }; -} diff --git a/mapGeneratorHelpers.js b/mapGeneratorHelpers.js index 2eba1b0..0d26fdb 100644 --- a/mapGeneratorHelpers.js +++ b/mapGeneratorHelpers.js @@ -915,69 +915,3 @@ export function applyOutputOptions(map, options = {}) { delete slim.naturalBarrierScore; 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 capitalLike = city.isPrefecturalCapital || city.isRegionalCapital; - const base = city.isPrefecturalCapital ? 90000 : city.isRegionalCapital ? 62000 : city.kind === "Satellite City" ? 16000 : 32000; - const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.isRegionalCapital ? 1350 : city.kind === "Satellite City" ? 900 : 1200); - const coreComponent = coreCells * 3200; - const densityComponent = densitySum * 360; - const computedPopulation = base + urbanComponent + coreComponent + densityComponent; - const footprintCells = city.urbanFootprintCells || urbanCells; - const footprintCoreCells = city.coreFootprintCells || coreCells; - const footprintCap = base + footprintCells * (city.isPrefecturalCapital ? 8500 : city.isRegionalCapital ? 7000 : city.kind === "Satellite City" ? 4300 : 5200) + footprintCoreCells * (city.isPrefecturalCapital ? 10500 : 9000); - city.population = Math.round(Math.max(base, Math.min(computedPopulation, footprintCap)) / 1000) * 1000; - city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 95, city.kind === "Satellite City" ? 5 : 7, capitalLike ? 34 : 28); - city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 360, 2.2, capitalLike ? 9 : 8); - } -} diff --git a/mapOutput.js b/mapOutput.js index 5c517ce..9fb2f40 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -1,7 +1,6 @@ import { createNameDebug } from "./names.js"; -import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, hash2, indexOf, inside, rand } from "./mapUtils.js"; -import { applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLanduse, tagInsidePrefecture } from "./mapGeneratorHelpers.js"; -import { LANDUSE } from "./landuseCodes.js"; +import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js"; +import { applyOutputOptions, attachIdsAndNames, tagInsidePrefecture } from "./mapGeneratorHelpers.js"; const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u; @@ -25,48 +24,6 @@ function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) { return `${value}${municipalitySuffixForCenter(center, fields, seed, ordinal)}`; } -function ensureMunicipalOfficeSettlementFootprints(adminCentersRaw, { sea, elevation, slope, ridgeField, plain, basinField, coastalLowland, populationDensity, landuse, humanRegionMask, roadInfluence, stationInfluence }, seed) { - let changedCells = 0; - const isBuildable = (i) => !sea[i] && (!humanRegionMask || humanRegionMask[i]) && elevation[i] < 0.78 && slope[i] < 0.62 && ridgeField[i] < 0.76; - for (const [n, center] of (adminCentersRaw || []).entries()) { - if (!center || !inside(center.x, center.y)) continue; - const ci = indexOf(center.x, center.y); - if (!isBuildable(ci)) continue; - const existingUrban = landuse[ci] >= LANDUSE.OLD_URBAN && landuse[ci] <= LANDUSE.NEW_TOWN; - const baseRadius = existingUrban ? 1.2 : (center.population || 0) >= 60000 ? 2.6 : 2.0; - const scoreBoost = clamp((populationDensity[ci] || 0) * 0.45 + (roadInfluence?.[ci] || 0) * 0.22 + (stationInfluence?.[ci] || 0) * 0.26 + 0.24); - const r = Math.ceil(baseRadius); - for (let dy = -r; dy <= r; dy++) { - for (let dx = -r; dx <= r; dx++) { - const x = center.x + dx; - const y = center.y + dy; - if (!inside(x, y)) continue; - const i = indexOf(x, y); - if (!isBuildable(i)) continue; - const d = Math.hypot(dx, dy); - if (d > baseRadius) continue; - const lowland = clamp(plain[i] * 0.28 + basinField[i] * 0.24 + coastalLowland[i] * 0.20 + (roadInfluence?.[i] || 0) * 0.14 - slope[i] * 0.28 - ridgeField[i] * 0.16 + 0.30); - if (lowland <= 0.12) continue; - if (d <= 0.85) { - if (landuse[i] < LANDUSE.OLD_URBAN || landuse[i] === LANDUSE.FARMLAND || landuse[i] === LANDUSE.RURAL) { - landuse[i] = LANDUSE.OLD_URBAN; - changedCells++; - } - populationDensity[i] = Math.max(populationDensity[i] || 0, 0.34 + scoreBoost * 0.35); - } else if (d <= baseRadius && landuse[i] <= LANDUSE.FARMLAND) { - const keep = lowland * (1 - d / (baseRadius + 0.1)) + hash2(x, y, seed + 18800 + n) * 0.08; - if (keep > 0.16) { - landuse[i] = LANDUSE.SUBURB; - changedCells++; - populationDensity[i] = Math.max(populationDensity[i] || 0, 0.20 + scoreBoost * 0.20); - } - } - } - } - } - return changedCells; -} - export function finishMapOutput({ seed, options, @@ -177,17 +134,17 @@ export function finishMapOutput({ let externalGateways = inputExternalGateways; const outputProgress = (step) => options?.onProgress?.({ status: "output-step", key: "output", label: `Output: ${step}`, step }); - outputProgress("population recalculation"); - // Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion. - // Use all generated prefecture regions for human-geography density, not only the focused prefecture. + outputProgress("final packaging"); + // Use all generated prefecture regions for human-geography masks, not only + // the focused prefecture. Population density itself is already generated in + // the human stage and is not rebuilt here. const humanRegionMask = new Uint8Array(MAP_W * MAP_H); for (let i = 0; i < humanRegionMask.length; i++) { humanRegionMask[i] = !sea[i] && ((prefectureRegionId?.[i] ?? -1) >= 0 || prefectureMask[i]) ? 1 : 0; } - const municipalOfficeUrbanizedCells = ensureMunicipalOfficeSettlementFootprints(adminCentersRaw, { - sea, elevation, slope, ridgeField, plain, basinField, coastalLowland, populationDensity, landuse, humanRegionMask, roadInfluence, stationInfluence, - }, seed); - recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, humanRegionMask, sea, stationInfluence, roadInfluence, railInfluence2); + // Population density is generated directly in the human stage. Do not rebuild + // it here from land-use or municipal offices; output should only package and + // name features. for (const city of modernCities) { const cap = cityPopulationCap(city); if (cap < INF && (city.population || 0) > cap) { @@ -366,7 +323,7 @@ export function finishMapOutput({ adminCenters, adminId, adminBorders, - adminDebug: adminDebug ? { ...adminDebug, municipalOfficeUrbanizedCells } : { municipalOfficeUrbanizedCells }, + adminDebug, castleRuins, riverPaths, mainRivers, diff --git a/mapPipeline.js b/mapPipeline.js index e2ec25a..5cf9f2f 100644 --- a/mapPipeline.js +++ b/mapPipeline.js @@ -1,6 +1,6 @@ import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js"; import { generateTerrainAndRivers } from "./mapTerrain.js"; -import { generateMapFeatures } from "./mapFeaturesV2.js"; +import { generateMapFeatures } from "./mapFeatures.js"; import { finishMapOutput } from "./mapOutput.js"; import { generateAdminLayout } from "./mapAdminStage.js"; diff --git a/mapTerrain.v4.bak.js b/mapTerrain.v4.bak.js deleted file mode 100644 index cfeb1a0..0000000 --- a/mapTerrain.v4.bak.js +++ /dev/null @@ -1,1804 +0,0 @@ -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 buildTerrainTemplate(seed) { - const deposition = 0.18 + rand(seed, 41) * 0.72; - const erosion = 0.24 + rand(seed, 42) * 0.68; - const roughness = 0.34 + rand(seed, 43) * 0.62; - const coastAxisPick = Math.floor(rand(seed, 10) * 3); - const coastAngle = coastAxisPick === 0 - ? Math.PI / 2 - : coastAxisPick === 1 - ? 0 - : (rand(seed, 11) > 0.5 ? Math.PI / 4 : -Math.PI / 4) + (rand(seed, 14) - 0.5) * 0.28; - const ridgeJaggedness = 0.20 + rand(seed, 44) * 0.70; - const spineCount = 2 + Math.floor(rand(seed, 45) * 2); - const sideAPlain = 0.035 + rand(seed, 56) * 0.115 + deposition * 0.085; - const sideBPlain = 0.035 + rand(seed, 57) * 0.115 + deposition * 0.085; - - return { - seed, - spineCount, - spineAngle: coastAngle + Math.PI * (0.28 + rand(seed, 46) * 0.44), - spineCurve: (rand(seed, 47) - 0.5) * 0.28, - spinePosition: (rand(seed, 48) - 0.5) * 0.56, - spineStrength: 0.56 + rand(seed, 49) * 0.32, - spineWidth: 0.034 + rand(seed, 50) * 0.036, - // v4: 個別の丸い山塊生成を主役にしない。山地は下の folded orogeny field で一括生成する。 - secondaryMountainCount: 0, - secondaryMountainSize: 0.038 + rand(seed, 52) * 0.060, - secondaryMountainStrength: 0.40 + rand(seed, 53) * 0.25, - rangeBreakCount: 4 + Math.floor(rand(seed, 62) * 4), - rangeBreakWidth: 0.022 + rand(seed, 63) * 0.026, - rangeBreakStrength: 0.060 + rand(seed, 64) * 0.070, - plainNoiseSuppression: 0.34 + rand(seed, 65) * 0.22, - peakSoftStart: 0.87 + rand(seed, 66) * 0.045, - peakSoftCap: 0.982 + rand(seed, 67) * 0.014, - orographicStrength: 0.88 + rand(seed, 70) * 0.28, - orographicCoverage: 0.72 + rand(seed, 71) * 0.18, - foldDensity: 5.2 + rand(seed, 72) * 2.2, - foldSharpness: 1.65 + rand(seed, 73) * 0.85, - fluvialAggression: 1.16 + rand(seed, 74) * 0.44, - coastAxis: coastAxisPick === 0 ? "east-west" : coastAxisPick === 1 ? "north-south" : "diagonal", - coastAngle, - coastBias: 0.18 + rand(seed, 12) * 0.24, - coastRoughness: 0.34 + rand(seed, 54) * 0.58, - coastSides: [ - { - penetration: 0.18 + rand(seed, 58) * 0.16, - inletStrength: 0.18 + rand(seed, 59) * 0.56, - plainWidth: sideAPlain, - }, - { - penetration: 0.18 + rand(seed, 60) * 0.16, - inletStrength: 0.18 + rand(seed, 61) * 0.56, - plainWidth: sideBPlain, - }, - ], - deposition, - erosion, - roughness, - ridgeJaggedness, - ridgeBranchiness: 0.32 + rand(seed, 55) * 0.60, - detachedRangeCount: 0, - alpinePeakCount: 0, - }; -} - -function jaggedRidgeContribution(x, y, ridge, seed) { - const dx = x - ridge.x; - const dy = y - ridge.y; - const ca = Math.cos(ridge.angle); - const sa = Math.sin(ridge.angle); - const along = dx * ca + dy * sa; - const perp = -dx * sa + dy * ca; - const nAlong = along / Math.max(0.001, ridge.length); - const lengthFade = smoothstep(1 - Math.abs(nAlong)); - if (lengthFade <= 0) return 0; - - // Bend the centerline itself with coherent long/mid waves, then apply ridge falloff. - const low = (valueNoise(along * 0.85 + ridge.seedOffset, ridge.seedOffset * 0.37, seed + 6100, 28) - 0.5) * 2; - const mid = (valueNoise(along * 1.7 - ridge.seedOffset, ridge.seedOffset * 0.23, seed + 6200, 13) - 0.5) * 2; - const sine = Math.sin(along * ridge.kinkFrequency + ridge.kinkPhase); - const curve = (ridge.curve || 0) * along * along * (along >= 0 ? 1 : -1); - const axisOffset = low * ridge.axisWobble + mid * ridge.axisWobble * 0.55 + sine * ridge.axisWobble * 0.25 + curve; - const widthNoise = 0.78 + valueNoise(along * 1.2 + ridge.seedOffset, ridge.seedOffset * 0.19, seed + 6300, 21) * ridge.widthVariation; - const localWidth = Math.max(0.006, ridge.width * widthNoise); - const jaggedPerp = perp - axisOffset; - const serration = 0.76 + valueNoise(x * 1.1 + along * 0.18, y * 1.1 + perp * 0.18, seed + ridge.seedOffset, 7) * 0.48; - return Math.exp(-(jaggedPerp * jaggedPerp) / (localWidth * localWidth)) * lengthFade * ridge.h * serration; -} - -function spineFieldAt(x, y, template, spineIndex) { - const seed = template.seed || 0; - const spacing = spineIndex === 0 ? 0 : (spineIndex % 2 ? 0.30 : -0.30); - const angle = template.spineAngle + (spineIndex - 0.5) * 0.17 + (rand(seed, 700 + spineIndex) - 0.5) * 0.18; - const ridge = { - x: 0.5 + Math.cos(angle + Math.PI / 2) * (template.spinePosition + spacing) * 0.45, - y: 0.5 + Math.sin(angle + Math.PI / 2) * (template.spinePosition + spacing) * 0.45, - angle, - width: template.spineWidth * (0.82 + rand(seed, 710 + spineIndex) * 0.38), - length: 0.78 + rand(seed, 720 + spineIndex) * 0.28, - h: template.spineStrength * (0.24 + rand(seed, 730 + spineIndex) * 0.12), - curve: template.spineCurve, - axisWobble: template.spineWidth * (0.45 + template.ridgeJaggedness * 1.15), - kinkFrequency: 10 + rand(seed, 740 + spineIndex) * 18, - kinkPhase: rand(seed, 750 + spineIndex) * Math.PI * 2, - seedOffset: 7600 + spineIndex * 211, - widthVariation: 0.18 + template.ridgeJaggedness * 0.34, - }; - return jaggedRidgeContribution(x, y, ridge, seed); -} - -function buildSpineRidges(seed, template) { - const spines = []; - const branches = []; - for (let i = 0; i < template.spineCount; i++) { - const angle = template.spineAngle + (i - 0.5) * 0.17 + (rand(seed, 700 + i) - 0.5) * 0.18; - const spacing = i === 0 ? -0.18 : (i === 1 ? 0.22 : (i % 2 ? 0.42 : -0.42)); - const longitudinalShift = (rand(seed, 705 + i) - 0.5) * 0.38; - const x = 0.5 + Math.cos(angle + Math.PI / 2) * (template.spinePosition + spacing) * 0.48 + Math.cos(angle) * longitudinalShift; - const y = 0.5 + Math.sin(angle + Math.PI / 2) * (template.spinePosition + spacing) * 0.48 + Math.sin(angle) * longitudinalShift; - spines.push({ - x, y, angle, - width: template.spineWidth * (0.70 + rand(seed, 710 + i) * 0.34), - length: 0.52 + rand(seed, 720 + i) * 0.30, - h: template.spineStrength * (0.19 + rand(seed, 730 + i) * 0.10), - curve: template.spineCurve, - axisWobble: template.spineWidth * (0.45 + template.ridgeJaggedness * 1.15), - kinkFrequency: 10 + rand(seed, 740 + i) * 18, - kinkPhase: rand(seed, 750 + i) * Math.PI * 2, - seedOffset: 7600 + i * 211, - widthVariation: 0.18 + template.ridgeJaggedness * 0.34, - }); - const branchCount = 4 + Math.floor(template.ridgeBranchiness * 6); - for (let b = 0; b < branchCount; b++) { - const along = (rand(seed, 810 + i * 31 + b) - 0.5) * 0.62; - const side = rand(seed, 820 + i * 31 + b) > 0.5 ? 1 : -1; - const branchAngle = angle + side * (0.55 + rand(seed, 830 + i * 31 + b) * 0.72); - branches.push({ - x: x + Math.cos(angle) * along, - y: y + Math.sin(angle) * along, - angle: branchAngle, - width: template.spineWidth * (0.42 + rand(seed, 840 + i * 31 + b) * 0.36), - length: 0.16 + rand(seed, 850 + i * 31 + b) * 0.28, - h: template.spineStrength * (0.055 + template.ridgeBranchiness * 0.080 + rand(seed, 860 + i * 31 + b) * 0.050), - curve: template.spineCurve * 0.45, - axisWobble: template.spineWidth * (0.32 + template.ridgeJaggedness * 0.72), - kinkFrequency: 14 + rand(seed, 870 + i * 31 + b) * 20, - kinkPhase: rand(seed, 880 + i * 31 + b) * Math.PI * 2, - seedOffset: 8800 + i * 311 + b * 37, - widthVariation: 0.22 + template.ridgeJaggedness * 0.30, - }); - } - } - return { spines, branches }; -} - - -function softUpperClamp(value, start = 0.8, cap = 0.96) { - if (value <= start) return value; - const range = Math.max(0.001, cap - start); - const t = (value - start) / range; - return start + range * (1 - Math.exp(-t)); -} - -function elongatedFeatureContribution(x, y, feature, seed) { - const dx = x - feature.x; - const dy = y - feature.y; - const ca = Math.cos(feature.angle); - const sa = Math.sin(feature.angle); - const along = dx * ca + dy * sa; - const perp = -dx * sa + dy * ca; - const nAlong = along / Math.max(0.001, feature.length); - if (Math.abs(nAlong) > 1.35) return 0; - const alongFade = Math.exp(-nAlong * nAlong * 1.7); - const low = (valueNoise(along * 0.95 + feature.seedOffset, feature.seedOffset * 0.31, seed + 6400, 19) - 0.5) * 2; - const mid = (valueNoise(along * 1.75 - feature.seedOffset, feature.seedOffset * 0.21, seed + 6500, 9) - 0.5) * 2; - const axisOffset = low * feature.axisWobble + mid * feature.axisWobble * 0.45; - const localWidth = Math.max(0.008, feature.width * (0.84 + valueNoise(along * 1.15, feature.seedOffset, seed + 6600, 14) * feature.widthVariation)); - const offsetPerp = perp - axisOffset; - return Math.exp(-(offsetPerp * offsetPerp) / (localWidth * localWidth)) * alongFade * feature.h; -} - -function buildRangeBreaks(seed, template, spines) { - const rangeBreaks = []; - for (let i = 0; i < spines.length; i++) { - const spine = spines[i]; - const count = Math.max(2, template.rangeBreakCount - 1 + Math.floor(rand(seed, 890 + i) * 3)); - for (let b = 0; b < count; b++) { - const along = (rand(seed, 900 + i * 37 + b) - 0.5) * spine.length * 0.84; - const lateral = (rand(seed, 910 + i * 37 + b) - 0.5) * spine.width * 0.9; - rangeBreaks.push({ - x: spine.x + Math.cos(spine.angle) * along + Math.cos(spine.angle + Math.PI / 2) * lateral, - y: spine.y + Math.sin(spine.angle) * along + Math.sin(spine.angle + Math.PI / 2) * lateral, - angle: spine.angle + (rand(seed, 920 + i * 37 + b) > 0.5 ? Math.PI / 2 : -Math.PI / 2) + (rand(seed, 930 + i * 37 + b) - 0.5) * 0.42, - width: template.rangeBreakWidth * (0.75 + rand(seed, 940 + i * 37 + b) * 0.75), - length: 0.12 + rand(seed, 950 + i * 37 + b) * 0.14, - h: template.rangeBreakStrength * (0.78 + rand(seed, 960 + i * 37 + b) * 0.55), - axisWobble: template.rangeBreakWidth * (0.18 + rand(seed, 970 + i * 37 + b) * 0.32), - widthVariation: 0.14 + rand(seed, 980 + i * 37 + b) * 0.24, - seedOffset: 9900 + i * 311 + b * 41, - }); - } - } - return rangeBreaks; -} - - -function buildDetachedRanges(seed, template) { - const ranges = []; - const count = template.detachedRangeCount ?? 6; - for (let i = 0; i < count; i++) { - const quadrantX = i % 2 === 0 ? 0.24 : 0.76; - const quadrantY = Math.floor(i / 2) % 2 === 0 ? 0.24 : 0.76; - const free = rand(seed, 12000 + i) < 0.45; - const x = free ? 0.12 + rand(seed, 12010 + i) * 0.76 : quadrantX + (rand(seed, 12020 + i) - 0.5) * 0.28; - const y = free ? 0.12 + rand(seed, 12030 + i) * 0.76 : quadrantY + (rand(seed, 12040 + i) - 0.5) * 0.28; - const angle = template.spineAngle + (rand(seed, 12050 + i) - 0.5) * Math.PI * 0.95; - ranges.push({ - x: clamp(x, 0.08, 0.92), - y: clamp(y, 0.08, 0.92), - angle, - width: 0.020 + rand(seed, 12060 + i) * 0.030, - length: 0.18 + rand(seed, 12070 + i) * 0.28, - h: 0.075 + rand(seed, 12080 + i) * 0.095, - curve: (rand(seed, 12090 + i) - 0.5) * 0.10, - axisWobble: 0.018 + template.ridgeJaggedness * 0.030, - kinkFrequency: 14 + rand(seed, 12100 + i) * 24, - kinkPhase: rand(seed, 12110 + i) * Math.PI * 2, - seedOffset: 12120 + i * 173, - widthVariation: 0.28 + template.ridgeJaggedness * 0.36, - }); - } - return ranges; -} - -function buildAlpinePeaks(seed, template, detachedRanges) { - const peaks = []; - const count = template.alpinePeakCount ?? 8; - for (let i = 0; i < count; i++) { - const attach = detachedRanges.length && rand(seed, 12300 + i) < 0.62; - const base = attach ? detachedRanges[i % detachedRanges.length] : null; - const along = base ? (rand(seed, 12310 + i) - 0.5) * base.length * 0.90 : 0; - const perp = base ? (rand(seed, 12320 + i) - 0.5) * base.width * 4.5 : 0; - const x = base ? base.x + Math.cos(base.angle) * along + Math.cos(base.angle + Math.PI / 2) * perp : 0.10 + rand(seed, 12330 + i) * 0.80; - const y = base ? base.y + Math.sin(base.angle) * along + Math.sin(base.angle + Math.PI / 2) * perp : 0.10 + rand(seed, 12340 + i) * 0.80; - peaks.push({ - x: clamp(x, 0.06, 0.94), - y: clamp(y, 0.06, 0.94), - angle: base ? base.angle + (rand(seed, 12350 + i) - 0.5) * 0.9 : rand(seed, 12360 + i) * Math.PI * 2, - rx: 0.022 + rand(seed, 12370 + i) * 0.035, - ry: 0.012 + rand(seed, 12380 + i) * 0.024, - h: 0.070 + rand(seed, 12390 + i) * 0.100, - seedOffset: 12400 + i * 191, - }); - } - return peaks; -} - -// v4: 山塊を一つずつ置くのではなく、列島全体に折り畳み山地を一括合成する。 -// 複数方向の褶曲波 + domain warp + 広域隆起で、日本風の「山がちな基盤」を作る。 -function foldedOrogenyAt(px, py, seed, template, coastLower = 0) { - const baseAngle = template.spineAngle + (rand(seed, 13001) - 0.5) * 0.24; - const warpX = (fbm(px * 3.2 + 17, py * 3.2 - 31, seed + 13010) - 0.5) * 0.16; - const warpY = (fbm(px * 3.0 - 43, py * 3.0 + 19, seed + 13020) - 0.5) * 0.16; - const x = px + warpX; - const y = py + warpY; - - let foldRidges = 0; - let foldMass = 0; - let crossCutValleys = 0; - const families = 4; - for (let k = 0; k < families; k++) { - const angle = baseAngle + (k - 1.5) * 0.31 + (rand(seed, 13100 + k) - 0.5) * 0.30; - const ca = Math.cos(angle); - const sa = Math.sin(angle); - const along = x * ca + y * sa; - const cross = -x * sa + y * ca; - const density = template.foldDensity * (0.70 + k * 0.14 + rand(seed, 13120 + k) * 0.18); - const phaseWarp = (valueNoise(px * 5.0 + k * 9, py * 5.0 - k * 7, seed + 13200 + k, 3.2) - 0.5) * Math.PI * 1.35; - const phase = cross * density * Math.PI * 2 + along * (1.0 + k * 0.22) + rand(seed, 13140 + k) * Math.PI * 2 + phaseWarp; - const crest = Math.pow(Math.max(0, 1 - Math.abs(Math.sin(phase))), template.foldSharpness + (k % 2) * 0.32); - const shoulder = Math.pow(0.5 + 0.5 * Math.cos(phase * 0.5 + k), 1.55); - const local = 0.82 + valueNoise(px * 2.0 + k * 13, py * 2.0 - k * 5, seed + 13300 + k, 2.2) * 0.34; - const weight = (0.15 + k * 0.026) * local; - foldRidges += crest * weight; - foldMass += (crest * 0.50 + shoulder * 0.28) * weight; - - const valleyPhase = along * (density * 0.36) * Math.PI * 2 + cross * 1.45 + rand(seed, 13400 + k) * Math.PI * 2; - crossCutValleys += Math.pow(Math.max(0, 1 - Math.abs(Math.sin(valleyPhase))), 2.3) * 0.050; - } - - const broadA = fbm(px * 0.95 + 23, py * 0.95 - 61, seed + 13500); - const broadB = valueNoise(px * 1.65 - 41, py * 1.65 + 17, seed + 13510, 2.0); - const tectonicEnvelope = clamp((broadA * 0.62 + broadB * 0.38 - 0.24) / 0.64); - const coastalAttenuation = lerp(1.0, 0.78, clamp(coastLower * 0.95)); - const coverageFloor = template.orographicCoverage * 0.16; - const mass = clamp((foldMass * 0.98 + tectonicEnvelope * 0.42 + coverageFloor - crossCutValleys * 1.10) * coastalAttenuation); - const ridges = clamp((foldRidges * 1.62 + mass * 0.24 - crossCutValleys * 0.92) * coastalAttenuation); - const uplift = clamp((mass * 0.54 + ridges * 0.30) * template.orographicStrength); - return { uplift, ridges, valleys: clamp(crossCutValleys * 4.0) }; -} - -export function generateTerrainAndRivers(seed) { - let prefectureMask; - let prefectureBorder; - - const { - elevation, - moisture, - slope, - sea, - ocean, - lake, - river, - floodplain, - plain, - agriculture, - ridgeField, - valleyField, - basinField, - coastalLowland, - flowAccum, - erosionField, - depositionField, - arcSpineField, - branchRidgeField, - depositionalLowland, - alluvialFanField, - deltaField, - naturalBarrierScore, - flowTo, - portSuitability, - crossingSuitability, - passSuitability, - } = createMapFields(); - - const terrainTemplate = buildTerrainTemplate(seed); - const coastAngle = terrainTemplate.coastAngle; - const coastX = Math.cos(coastAngle); - const coastY = Math.sin(coastAngle); - const coastThreshold = terrainTemplate.coastBias; - const coastStrength = 0.10 + (1 - terrainTemplate.deposition) * 0.12 + rand(seed, 13) * 0.09; - const { spines, branches } = buildSpineRidges(seed, terrainTemplate); - const detachedRanges = buildDetachedRanges(seed, terrainTemplate); - const alpinePeaks = buildAlpinePeaks(seed, terrainTemplate, detachedRanges); - const rangeBreaks = buildRangeBreaks(seed, terrainTemplate, spines); - - function coastPressureAt(x, y, wx = x, wy = y) { - const nx = x / (MAP_W - 1) - 0.5; - const ny = y / (MAP_H - 1) - 0.5; - const axis = nx * coastX + ny * coastY; - const waveA = (fbm(wx * 0.72 + 31, wy * 0.72 - 17, seed + 2222) - 0.5) * (0.05 + terrainTemplate.coastRoughness * terrainTemplate.coastSides[0].inletStrength * 0.18) + - (valueNoise(wx + 19, wy - 23, seed + 2233, 18) - 0.5) * (0.03 + terrainTemplate.coastSides[0].inletStrength * 0.10); - const waveB = (fbm(wx * 0.68 - 41, wy * 0.68 + 29, seed + 3222) - 0.5) * (0.05 + terrainTemplate.coastRoughness * terrainTemplate.coastSides[1].inletStrength * 0.18) + - (valueNoise(wx - 13, wy + 37, seed + 3233, 16) - 0.5) * (0.03 + terrainTemplate.coastSides[1].inletStrength * 0.10); - const sideA = smoothstep((axis + waveA - (0.50 - terrainTemplate.coastSides[0].penetration)) / Math.max(0.08, terrainTemplate.coastSides[0].plainWidth * 2.4)); - const sideB = smoothstep((-axis + waveB - (0.50 - terrainTemplate.coastSides[1].penetration)) / Math.max(0.08, terrainTemplate.coastSides[1].plainWidth * 2.4)); - return { sideA, sideB, pressure: Math.max(sideA, sideB), signedAxis: axis }; - } - - const seaLevel = 0.275; - - const mountainBlobs = Array.from({ length: terrainTemplate.secondaryMountainCount }, (_, i) => { - const spine = spines[i % spines.length]; - const nearSpine = rand(seed, 98 + i) < 0.72; - const edgeBias = rand(seed, 99 + i) < 0.28; - const along = (rand(seed, 100 + i) - 0.5) * spine.length * 0.95; - const side = rand(seed, 101 + i) > 0.5 ? 1 : -1; - const offset = (0.055 + rand(seed, 102 + i) * 0.22) * side; - let x = nearSpine ? spine.x + Math.cos(spine.angle) * along + Math.cos(spine.angle + Math.PI / 2) * offset : rand(seed, 103 + i); - let y = nearSpine ? spine.y + Math.sin(spine.angle) * along + Math.sin(spine.angle + Math.PI / 2) * offset : rand(seed, 104 + i); - if (edgeBias) { - const edgeSide = Math.floor(rand(seed, 105 + i) * 4); - if (edgeSide === 0) x = Math.min(x, 0.08 + rand(seed, 106 + i) * 0.10); - if (edgeSide === 1) x = Math.max(x, 0.92 - rand(seed, 107 + i) * 0.10); - if (edgeSide === 2) y = Math.min(y, 0.08 + rand(seed, 108 + i) * 0.10); - if (edgeSide === 3) y = Math.max(y, 0.92 - rand(seed, 109 + i) * 0.10); - } - const coastSide = (x - 0.5) * coastX + (y - 0.5) * coastY; - const mountainSide = coastSide >= 0 ? 1 : -1; - if (rand(seed, 110 + i) < 0.46 && Math.abs(coastSide) > 0.28 - coastThreshold * 0.35) { - x -= coastX * mountainSide * (0.05 + rand(seed, 111 + i) * 0.11); - y -= coastY * mountainSide * (0.05 + rand(seed, 112 + i) * 0.11); - } - const angle = nearSpine ? spine.angle + (rand(seed, 302 + i) - 0.5) * 0.75 : rand(seed, 303 + i) * Math.PI * 2; - const baseRadius = terrainTemplate.secondaryMountainSize * Math.min(MAP_W, MAP_H); - return { - x: clamp(x) * MAP_W, - y: clamp(y) * MAP_H, - angle, - rx: baseRadius * (0.95 + rand(seed, 300 + i) * 1.10), - ry: baseRadius * (0.34 + rand(seed, 301 + i) * 0.46), - h: terrainTemplate.secondaryMountainStrength * (0.11 + rand(seed, 400 + i) * 0.23), - }; - }); - - 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 dx = wx - blob.x; - const dy = wy - blob.y; - const ca = Math.cos(blob.angle); - const sa = Math.sin(blob.angle); - const along = (dx * ca + dy * sa) / Math.max(1, blob.rx); - const perp = (-dx * sa + dy * ca) / Math.max(1, blob.ry); - const d2 = along * along + perp * perp; - const rugged = 0.82 + valueNoise(wx * 0.18 + blob.x, wy * 0.18 - blob.y, seed + 12600, 8) * 0.42; - mountains += Math.exp(-d2 * 2.55) * blob.h * rugged; - } - - const px = wx / (MAP_W - 1); - const py = wy / (MAP_H - 1); - let spineRidges = 0; - for (let si = 0; si < spines.length; si++) spineRidges += jaggedRidgeContribution(px, py, spines[si], seed); - let branchRidges = 0; - for (const ridge of branches) branchRidges += jaggedRidgeContribution(px, py, ridge, seed); - let detachedRidges = 0; - for (const ridge of detachedRanges) detachedRidges += jaggedRidgeContribution(px, py, ridge, seed); - let alpineMassifs = 0; - for (const peak of alpinePeaks) { - const dx = px - peak.x; - const dy = py - peak.y; - const ca = Math.cos(peak.angle); - const sa = Math.sin(peak.angle); - const along = (dx * ca + dy * sa) / Math.max(0.002, peak.rx); - const perp = (-dx * sa + dy * ca) / Math.max(0.002, peak.ry); - const d2 = along * along + perp * perp; - const crag = 0.78 + valueNoise(px * 38 + peak.seedOffset, py * 38 - peak.seedOffset, seed + 12700, 5) * 0.52; - alpineMassifs += Math.exp(-d2 * 1.85) * peak.h * crag; - } - let rangeBreakField = 0; - for (const feature of rangeBreaks) rangeBreakField += elongatedFeatureContribution(px, py, feature, seed); - const ridges = Math.max(0, spineRidges + branchRidges + detachedRidges * 0.95 + alpineMassifs * 0.70 - rangeBreakField * 0.90); - - const coast = coastPressureAt(x, y, wx, wy); - const coastLower = coast.pressure; - const folded = foldedOrogenyAt(px, py, seed, terrainTemplate, coastLower); - const orogenicUplift = folded.uplift; - const orogenicRidges = folded.ridges; - const orogenicValleys = folded.valleys; - // 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) * (0.68 + terrainTemplate.roughness * 0.74); - 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 protoHighland = clamp(orogenicUplift * 1.12 + orogenicRidges * 0.82 + spineRidges * 0.92 + branchRidges * 0.78 + detachedRidges * 0.70 + alpineMassifs * 0.70 + mountains * 0.38 - rangeBreakField * 1.80 - orogenicValleys * 0.38); - const protoLowland = clamp((1 - protoHighland) * 0.44 + coastLower * 0.24 + Math.max(0, -basin) * 0.24 + orogenicValleys * 0.26); - const plainNoiseSuppression = protoLowland * terrainTemplate.plainNoiseSuppression; - const subduedTerrainLocal = lerp(terrainLocal, 0.5, plainNoiseSuppression * 0.58); - const subduedTerrainFine = lerp(terrainFine, 0.5, plainNoiseSuppression * 0.78); - const subduedDissection = fineDissection * (1 - plainNoiseSuppression * 0.88); - const rawElevation = - 0.30 * terrainLarge + - 0.235 * terrainRegional + - 0.105 * subduedTerrainLocal + - 0.045 * subduedTerrainFine + - mountains * 0.12 + - orogenicUplift * 0.38 + - orogenicRidges * 0.13 + - spineRidges * 0.42 + - branchRidges * 0.44 + - detachedRidges * 0.26 + - alpineMassifs * 0.28 + - basin + - subduedDissection + orogenicRidges * 0.020 - - rangeBreakField * (0.44 + terrainTemplate.erosion * 0.18) - - coastLower * (coastStrength + 0.075 + terrainTemplate.deposition * 0.075) + - 0.055; - - const normalizedElevation = 0.5 + (rawElevation - 0.5) * 1.16; - elevation[i] = clamp(softUpperClamp(normalizedElevation, terrainTemplate.peakSoftStart, terrainTemplate.peakSoftCap)); - arcSpineField[i] = clamp(orogenicRidges * 1.20 + orogenicUplift * 0.46 + spineRidges * 1.75 + detachedRidges * 1.00 + alpineMassifs * 0.80); - branchRidgeField[i] = clamp(branchRidges * 1.85 + orogenicValleys * 0.35); - ridgeField[i] = clamp(arcSpineField[i] * 0.82 + branchRidgeField[i] * 0.44 + orogenicRidges * 0.60 + orogenicUplift * 0.24 + Math.max(0, mountains - 0.10) * 0.18 + subduedDissection * 1.00 - rangeBreakField * 0.88 - orogenicValleys * 0.34); - basinField[i] = clamp(Math.max(0, -basin) * 2.2 + rangeBreakField * 1.60 + orogenicValleys * 0.82 + (1 - coastLower) * Math.max(0, 0.42 - elevation[i]) * (0.34 + terrainTemplate.deposition * 0.30)); - 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 coast = coastPressureAt(x, y); - const mountainToSea = ridgeField[i] * (1 - terrainTemplate.deposition) * 0.014; - const oceanSide = coast.pressure + mountainToSea > 0.10 + terrainTemplate.deposition * 0.030; - 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); - } - } - - // Edge-connected water is ocean. Isolated water is only kept when it reads as - // a small mountain/valley lake or lagoon; oversized round basins become wet lowland. - const waterSeen = new Uint8Array(SIZE); - const oceanQueue = []; - for (let x = 0; x < MAP_W; x++) { - for (const y of [0, MAP_H - 1]) { - const i = indexOf(x, y); - if (sea[i] && !waterSeen[i]) { - waterSeen[i] = 1; - ocean[i] = 1; - oceanQueue.push(i); - } - } - } - for (let y = 0; y < MAP_H; y++) { - for (const x of [0, MAP_W - 1]) { - const i = indexOf(x, y); - if (sea[i] && !waterSeen[i]) { - waterSeen[i] = 1; - ocean[i] = 1; - oceanQueue.push(i); - } - } - } - for (let q = 0; q < oceanQueue.length; q++) { - const cur = oceanQueue[q]; - const [x, y] = [cur % MAP_W, Math.floor(cur / MAP_W)]; - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - if (!sea[ni] || waterSeen[ni]) continue; - waterSeen[ni] = 1; - ocean[ni] = 1; - oceanQueue.push(ni); - } - } - for (let i = 0; i < SIZE; i++) { - if (!sea[i] || waterSeen[i]) continue; - const queue = [i]; - const component = [i]; - waterSeen[i] = 1; - let sx = 0, sy = 0, perimeter = 0, ridgeSum = 0, valleySum = 0, coastTouch = 0; - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - const x = cur % MAP_W; - const y = Math.floor(cur / MAP_W); - sx += x; - sy += y; - ridgeSum += ridgeField[cur]; - valleySum += valleyField[cur]; - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - if (!sea[ni]) { - perimeter++; - if (coastalLowland[ni] > 0.12 || coastPressureAt(nx, ny).pressure > 0.42) coastTouch++; - continue; - } - if (waterSeen[ni]) continue; - waterSeen[ni] = 1; - queue.push(ni); - component.push(ni); - } - } - const area = component.length; - const cx = sx / area; - const cy = sy / area; - let radiusSum = 0; - for (const ci of component) { - const x = ci % MAP_W; - const y = Math.floor(ci / MAP_W); - radiusSum += Math.hypot(x - cx, y - cy); - } - const meanRadius = radiusSum / Math.max(1, area); - const circularity = perimeter > 0 ? (4 * Math.PI * area) / (perimeter * perimeter) : 1; - const mountainLake = area <= 38 && ridgeSum / area > 0.28; - const valleyLake = area <= 70 && valleySum / area > 0.24 && circularity < 0.58; - const lagoon = area <= 110 && coastTouch / Math.max(1, perimeter) > 0.18 && circularity < 0.70; - const rareSpecial = area <= 145 && circularity < 0.52 && hash2(Math.round(cx), Math.round(cy), seed + 2401) > 0.88; - const keepLake = mountainLake || valleyLake || lagoon || rareSpecial; - for (const ci of component) { - if (keepLake) { - lake[ci] = 1; - continue; - } - sea[ci] = 0; - elevation[ci] = Math.max(seaLevel + 0.012, seaLevel + Math.min(0.055, meanRadius * 0.004) + hash2(ci, area, seed + 2402) * 0.012); - basinField[ci] = clamp(basinField[ci] + 0.42); - valleyField[ci] = clamp(valleyField[ci] + 0.18); - depositionalLowland[ci] = clamp(depositionalLowland[ci] + 0.28); - depositionField[ci] = clamp(depositionField[ci] + 0.035); - } - } - - // 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; - let nearestOcean = 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 (ocean[indexOf(nx, ny)]) nearestOcean = Math.min(nearestOcean, Math.hypot(dx, dy)); - } - } - if (nearestSea <= 7) { - const coastalCap = seaLevel + 0.018 + nearestSea * (0.022 + terrainTemplate.deposition * 0.012) + Math.max(0, fbm(x * 1.4, y * 1.4, seed + 2350) - 0.5) * (0.014 + terrainTemplate.coastRoughness * 0.018); - elevation[i] = Math.min(elevation[i], coastalCap); - if (nearestOcean <= 7) { - const coast = coastPressureAt(x, y); - const side = coast.sideA >= coast.sideB ? terrainTemplate.coastSides[0] : terrainTemplate.coastSides[1]; - const plainReach = clamp(4.5 + side.plainWidth * 34, 5, 9); - coastalLowland[i] = clamp((1 - nearestOcean / plainReach) * (0.62 + terrainTemplate.deposition * 0.48 + side.plainWidth * 1.9) * (1 - ridgeField[i] * 0.35)); - } - } - } - } - - // Explicit alpine punctuation. The base ridge system defines broad relief, - // while these narrow, detached high points make several visually legible - // mountain groups instead of one round central mass. - 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] || coastalLowland[i] > 0.42) continue; - const px = x / (MAP_W - 1); - const py = y / (MAP_H - 1); - let peakSignal = 0; - for (const peak of alpinePeaks) { - const dx = px - peak.x; - const dy = py - peak.y; - const ca = Math.cos(peak.angle); - const sa = Math.sin(peak.angle); - const along = (dx * ca + dy * sa) / Math.max(0.002, peak.rx); - const perp = (-dx * sa + dy * ca) / Math.max(0.002, peak.ry); - const d2 = along * along + perp * perp; - peakSignal += Math.exp(-d2 * 2.20) * peak.h; - } - if (peakSignal <= 0.026) continue; - const crag = Math.max(0, valueNoise(x * 2.4 + 73, y * 2.4 - 91, seed + 12880, 3.5) - 0.36); - const target = clamp(0.64 + peakSignal * 2.45 + crag * 0.085, seaLevel + 0.006, 0.982); - elevation[i] = Math.max(elevation[i], target); - ridgeField[i] = clamp(ridgeField[i] + peakSignal * 4.6 + crag * 0.28); - arcSpineField[i] = clamp(arcSpineField[i] + peakSignal * 3.2); - basinField[i] = Math.max(0, basinField[i] - peakSignal * 1.2); - depositionalLowland[i] = Math.max(0, depositionalLowland[i] - peakSignal * 1.5); - } - } - - 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.82 + moisture[i] * 0.88 + valleyField[i] * 0.78 + Math.max(0, elevation[i] - seaLevel) * 0.14; - 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.91; - } - 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.62 + Math.pow(flowAccum[i], 0.48) * 0.62); - } - - // 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.58); - const incisionNoise = 0.82 + hash2(x, y, seed + 8120) * 0.36; - const firstOrderPower = smoothstep((flowAccum[i] - 0.018) / 0.12); - const steepValley = clamp(terrainTemplate.fluvialAggression * firstOrderPower * flow * (0.026 + terrainTemplate.erosion * 0.046 + slope[i] * (0.105 + terrainTemplate.erosion * 0.105) + ridgeField[i] * (0.018 + terrainTemplate.erosion * 0.040)) * incisionNoise); - const lateralCut = clamp(terrainTemplate.fluvialAggression * firstOrderPower * Math.pow(flowAccum[i], 0.82) * valleyField[i] * (0.030 + terrainTemplate.erosion * 0.052)); - const lowSettling = clamp(flow * (coastalLowland[i] * (0.018 + terrainTemplate.deposition * 0.040) + basinField[i] * (0.010 + terrainTemplate.deposition * 0.028) + (elevation[i] < 0.40 ? 0.006 + terrainTemplate.deposition * 0.018 : 0)) * (1 - slope[i] * 0.82) * (1 - ridgeField[i] * 0.45)); - erosionField[i] = steepValley + lateralCut; - depositionField[i] = lowSettling; - depositionalLowland[i] = clamp(lowSettling * 6.5 + basinField[i] * terrainTemplate.deposition * 0.28 + coastalLowland[i] * terrainTemplate.deposition * 0.34); - shapedElevation[i] = clamp(elevation[i] - steepValley - lateralCut + lowSettling * 0.72, seaLevel + 0.006, 1); - } - } - elevation.set(shapedElevation); - - // Final orographic pass: ensure true alpine/high-mountain cells remain after - // river incision and lowland smoothing. Uplift is confined to ridge cores and - // fades out in valley floors so drainage still reads correctly. - 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 ridgeCore = clamp(arcSpineField[i] * 0.74 + branchRidgeField[i] * 0.58 + ridgeField[i] * 0.42 - valleyField[i] * 0.34 - flowAccum[i] * 0.25); - const highBase = clamp((elevation[i] - 0.55) / 0.25); - const alpine = clamp(ridgeCore * 0.88 + highBase * 0.22 - coastalLowland[i] * 0.45 - depositionalLowland[i] * 0.36); - if (alpine <= 0.08) continue; - const summitTexture = Math.max(0, valueNoise(x * 2.7 + 31, y * 2.7 - 41, seed + 9771, 3.0) - 0.38); - const uplift = Math.pow(alpine, 1.55) * (0.032 + terrainTemplate.roughness * 0.040 + summitTexture * 0.032); - const summitCap = 0.955 + Math.min(0.040, ridgeCore * 0.040) + summitTexture * 0.018; - elevation[i] = clamp(elevation[i] + uplift, seaLevel + 0.006, summitCap); - ridgeField[i] = clamp(ridgeField[i] + uplift * 1.15); - erosionField[i] = Math.max(0, erosionField[i] - uplift * 0.25); - } - } - - 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.24 + moisture[i] * 0.24 + ridgeField[i] * 0.035 + arcSpineField[i] * 0.035 + branchRidgeField[i] * 0.02 + flowAccum[i] * 1.05 + valleyField[i] * 0.54 + basinField[i] * 0.14 + coastalLowland[i] * 0.08 + hash2(x, y, seed + 9000) * 0.05; - if (elevation[i] > 0.30 && elevation[i] < 0.94 && moisture[i] > 0.18 && (flowAccum[i] > 0.004 || valleyField[i] > 0.045 || slope[i] > 0.20) && ridgeField[i] < 0.98) sourceCandidates.push({ x, y, score }); - } - } - - const sources = pickEntities(sourceCandidates, { - max: 38 + Math.floor(rand(seed, 910) * 24), - minDistance: 5, - threshold: 0.24 + rand(seed, 911) * 0.05, - 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] * 1.24 - - flowAccum[i] * 1.18 - - moisture[i] * 0.22 - - coastalLowland[i] * 0.36 - ); - } - - 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.64 + path.length / 128 + flowAccum[i] * 0.92; - 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.040 && flowAccum[ni] < flowAccum[i] + 0.020) 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.86 : 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] * 2.15 - - flowAccum[ni] * 1.34 - - moisture[ni] * 0.18 - - coastalLowland[ni] * 0.42 - - (river[ni] > 0 ? 0.34 : 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.50 + flowAccum[ri] * 0.68; - 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 < 210; step++) { - const i = indexOf(x, y); - if (seen.has(i)) break; - seen.add(i); - path.push([x, y]); - river[i] += 0.026 + flowAccum[i] * 0.045; - if ((river[i] > 0.62 && path.length > 9) || 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.15 + Math.max(0, -drop) * 20 - Math.max(0, drop) * 1.7 - valleyField[ni] * 1.35 - flowAccum[ni] * 0.72 - moisture[ni] * 0.16 + Math.max(0, sameDirection) * 0.035 - Math.abs(dx * lastDy - dy * lastDx) * 0.024 + (hash2(nx, ny, seed + bonusSeed + step * 13) - 0.5) * 0.065; - 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: 30 + Math.floor(rand(seed, 915) * 22), - minDistance: 4, - threshold: 0.18, - 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.92 + accum * 0.17); - } - } - - 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.095), { - max: 180 + Math.floor(rand(seed, 919) * 120), - minDistance: 2, - threshold: 0.075, - 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.79 && (valleyField[i] >= 0.13 || flowAccum[i] >= 0.030)) 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.46 + k / 170 + flowAccum[i] * 0.72; - } - } - 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.020 + flowAccum[i] * 0.032; - } - } - - 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.26); - } - } - } - 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] / 2.6); - const smallPower = smoothstep((r - 0.025) / 0.16); - const mediumPower = smoothstep((r - 0.20) / 0.34); - const largePower = smoothstep((r - 0.45) / 0.42); - const actionPower = clamp(smallPower * 0.18 + mediumPower * 0.52 + largePower * 0.92); - const channelCut = clamp(terrainTemplate.fluvialAggression * actionPower * Math.pow(r, 0.70) * (0.026 + terrainTemplate.erosion * 0.046 + slope[i] * (0.065 + terrainTemplate.erosion * 0.105) + ridgeField[i] * (0.012 + terrainTemplate.erosion * 0.042))); - const valleyWiden = clamp(terrainTemplate.fluvialAggression * (mediumPower * 0.35 + largePower * 0.75) * Math.pow(r, 0.86) * (0.010 + terrainTemplate.erosion * 0.024 + Math.max(0, elevation[i] - seaLevel) * (0.022 + terrainTemplate.erosion * 0.040) + valleyField[i] * (0.014 + terrainTemplate.erosion * 0.034))); - const alluvium = clamp((mediumPower * 0.32 + largePower * 0.70) * Math.pow(r, 0.86) * (coastalLowland[i] * (0.010 + terrainTemplate.deposition * 0.030) + basinField[i] * (0.007 + terrainTemplate.deposition * 0.020) + (slope[i] < 0.10 ? 0.004 + terrainTemplate.deposition * 0.012 : 0)) * (1 - ridgeField[i] * 0.45)); - erosionField[i] = clamp(erosionField[i] + channelCut + valleyWiden); - depositionField[i] = clamp(depositionField[i] + alluvium); - depositionalLowland[i] = clamp(depositionalLowland[i] + alluvium * 5.5); - 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] / 2.6); - const radius = r > 0.62 ? 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 lateralPower = smoothstep((r - 0.28) / 0.45); - const carve = terrainTemplate.fluvialAggression * Math.max(0, weight) * lateralPower * (0.004 + terrainTemplate.erosion * 0.007 + r * (0.010 + terrainTemplate.erosion * 0.019)) * 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); - } - } - } - } - - // Template-driven deposition is limited to plausible low-energy places: - // river mouths, basin floors, coastal plains, and slope breaks below ridges. - const depositionElevation = new Float32Array(fluvialElevation); - 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 nearSea = 0; - let localRiver = river[i]; - let highSide = 0; - let lowSide = 1; - 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; - const ni = indexOf(nx, ny); - const d = Math.hypot(dx, dy); - if (d > 4.25) continue; - if (sea[ni]) nearSea = Math.max(nearSea, 1 - d / 4.25); - localRiver = Math.max(localRiver, river[ni] / (1 + d * 0.5)); - highSide = Math.max(highSide, fluvialElevation[ni]); - lowSide = Math.min(lowSide, fluvialElevation[ni]); - } - } - const reliefDrop = clamp((highSide - lowSide - 0.075) * 4.5); - const lowlandPotential = clamp( - basinField[i] * 0.44 + - coastalLowland[i] * 0.52 + - Math.pow(flowAccum[i], 0.56) * 0.32 + - plain[i] * 0.18 + - localRiver * 0.16 - - ridgeField[i] * 0.48 - - slope[i] * 0.52 - - Math.max(0, fluvialElevation[i] - 0.55) * 1.35 - ); - const delta = clamp(nearSea * localRiver * coastalLowland[i] * (0.32 + terrainTemplate.deposition * 1.25) * (1 - ridgeField[i] * 0.55)); - const fan = clamp(reliefDrop * localRiver * valleyField[i] * (0.20 + terrainTemplate.deposition * 0.95) * (1 - coastalLowland[i] * 0.45)); - const lowland = clamp(lowlandPotential * terrainTemplate.deposition + delta * 0.72 + fan * 0.42); - if (lowland <= 0.01) continue; - deltaField[i] = clamp(deltaField[i] + delta); - alluvialFanField[i] = clamp(alluvialFanField[i] + fan); - depositionalLowland[i] = clamp(depositionalLowland[i] + lowland); - depositionField[i] = clamp(depositionField[i] + lowland * 0.050); - erosionField[i] = Math.max(0, erosionField[i] - lowland * 0.018); - const floor = seaLevel + 0.008 + basinField[i] * 0.012 + coastalLowland[i] * 0.010; - depositionElevation[i] = clamp(lerp(fluvialElevation[i], Math.max(floor, fluvialElevation[i] - 0.032), lowland * 0.55), seaLevel + 0.005, 1); - } - } - fluvialElevation.set(depositionElevation); - - // 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, 0.985); - 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 + Math.round(terrainTemplate.deposition * 3); 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 + - depositionalLowland[i] * 0.52 + - deltaField[i] * 0.34 + - alluvialFanField[i] * 0.22 + - 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; - let localMin = 1; - let localMax = 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; - localMin = Math.min(localMin, elevation[ni]); - localMax = Math.max(localMax, elevation[ni]); - 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 localRelief = localMax - localMin; - const flatBias = clamp(1 - localRelief / 0.10); - const terrace = Math.round(localMean * 42) / 42; - const target = lerp(localMean, terrace, 0.18 + flatBias * 0.24); - const flattenStrength = lowland * (0.32 + terrainTemplate.deposition * 0.24 + flatBias * 0.22); - nextElevation[i] = clamp(lerp(elevation[i], target, flattenStrength), seaLevel + 0.006, 1); - if (lowland > 0.55) { - depositionField[i] = clamp(depositionField[i] + lowland * (0.010 + terrainTemplate.deposition * 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); - - function pathKey(path) { - return path.map(([x, y]) => `${x},${y}`).join("|"); - } - - function buildPathCellSet(paths) { - const set = new Set(); - for (const path of paths) for (const [x, y] of path) set.add(`${x},${y}`); - return set; - } - - function riverPathStats(path) { - let maxRiver = 0; - let sumRiver = 0; - let maxFlow = 0; - let sumFlow = 0; - let populatedCorridor = 0; - for (let k = 0; k < path.length; k++) { - const [x, y] = path[k]; - const i = indexOf(x, y); - maxRiver = Math.max(maxRiver, river[i]); - sumRiver += river[i]; - maxFlow = Math.max(maxFlow, flowAccum[i]); - sumFlow += flowAccum[i]; - populatedCorridor += plain[i] * 0.18 + valleyField[i] * 0.28 + coastalLowland[i] * 0.10 + basinField[i] * 0.08; - } - const [lx, ly] = path[path.length - 1]; - const li = indexOf(lx, ly); - const outletToWater = Boolean(sea[li] || lake[li]); - const lowerReach = path.slice(Math.max(0, path.length - Math.min(path.length, 8))); - const lowerReachStrength = lowerReach.reduce((sum, [x, y]) => sum + river[indexOf(x, y)], 0) / Math.max(1, lowerReach.length); - const meanRiver = sumRiver / Math.max(1, path.length); - const meanFlow = sumFlow / Math.max(1, path.length); - const corridorMean = populatedCorridor / Math.max(1, path.length); - const score = - path.length * 0.92 + - maxRiver * 8.4 + - meanRiver * 4.4 + - maxFlow * 8.2 + - meanFlow * 2.8 + - lowerReachStrength * 3.2 + - corridorMean * 5.2 + - (outletToWater ? 5.0 : 0); - return { length: path.length, maxRiver, meanRiver, maxFlow, meanFlow, lowerReachStrength, corridorMean, outletToWater, score }; - } - - let rankedRivers = riverPaths - .map((path, i) => ({ path, score: riverScores[i] || 0, stats: riverPathStats(path), key: pathKey(path) })) - .filter((item) => item.path.length >= 5) - .sort((a, b) => (b.stats.score + b.score * 0.25) - (a.stats.score + a.score * 0.25)); - - let mainRivers = rankedRivers - .filter((item) => item.stats.length >= 8) - .slice(0, Math.min(8, rankedRivers.length)) - .map((item) => item.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.55; - } - } - } - } - - // v4: 急峻な地形では自然流下トレースが短く切れる seed があるため、 - // 高地から海へ抜ける中〜大規模河川の骨格を数本だけ補完する。 - if (mainRivers.length < 4 && sourceCandidates.length > 0) { - const usedKeys = new Set(mainRivers.map((path) => pathKey(path))); - const starts = sourceCandidates.slice() - .sort((a, b) => (b.score + elevation[indexOf(b.x, b.y)] * 0.8 + valleyField[indexOf(b.x, b.y)] * 0.6) - (a.score + elevation[indexOf(a.x, a.y)] * 0.8 + valleyField[indexOf(a.x, a.y)] * 0.6)); - for (const start of starts) { - if (mainRivers.length >= 4) break; - const tooClose = mainRivers.some((path) => path.some(([px, py], k) => k % 8 === 0 && Math.hypot(px - start.x, py - start.y) < 10)); - if (tooClose) continue; - const goal = nearestWaterGoal(start); - if (!goal) continue; - const path = aStar(start, goal, (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]); - return Math.max(0.22, 1 + uphill * 42 + slope[i] * 0.42 + elevation[i] * 0.32 - downhill * 2.4 - valleyField[i] * 1.65 - flowAccum[i] * 1.20 - moisture[i] * 0.18 - coastalLowland[i] * 0.38); - }); - if (path.length < 9) continue; - const key = pathKey(path); - if (usedKeys.has(key)) continue; - usedKeys.add(key); - mainRivers.push(path); - riverPaths.push(path); - riverScores.push(path.length * 1.05); - for (let k = 0; k < path.length; k++) { - const [rx, ry] = path[k]; - river[indexOf(rx, ry)] = Math.max(river[indexOf(rx, ry)], 0.62 + k / 180 + flowAccum[indexOf(rx, ry)] * 0.72); - } - } - } - - const mainRiverCells = buildPathCellSet(mainRivers); - const mainRiverKeys = new Set(mainRivers.map((path) => pathKey(path))); - - function pathTouchesMain(path) { - for (const [x, y] of path) { - if (mainRiverCells.has(`${x},${y}`)) return true; - for (const [nx, ny] of neighbors8(x, y)) { - if (mainRiverCells.has(`${nx},${ny}`)) return true; - } - } - return false; - } - - rankedRivers = riverPaths - .map((path, i) => ({ path, score: riverScores[i] || 0, stats: riverPathStats(path), key: pathKey(path) })) - .filter((item) => item.path.length >= 5) - .sort((a, b) => (b.stats.score + b.score * 0.25) - (a.stats.score + a.score * 0.25)); - - const tributaryRivers = []; - const hiddenRiverPaths = []; - for (const item of rankedRivers) { - if (mainRiverKeys.has(item.key)) continue; - const joinsMain = pathTouchesMain(item.path); - const visibleMedium = - item.stats.score >= 18 && - item.stats.length >= 7 && - (joinsMain || item.stats.outletToWater || item.stats.maxRiver >= 0.95 || item.stats.lowerReachStrength >= 0.70); - if (visibleMedium) tributaryRivers.push(item.path); - else hiddenRiverPaths.push(item.path); - } - - for (const path of mainRivers) { - for (let k = 0; k < path.length; k++) { - const [x, y] = path[k]; - const i = indexOf(x, y); - river[i] = Math.max(river[i], 0.92 + k / 150 + flowAccum[i] * 0.96); - } - } - for (const path of tributaryRivers) { - for (let k = 0; k < path.length; k++) { - const [x, y] = path[k]; - const i = indexOf(x, y); - river[i] = Math.max(river[i], 0.58 + k / 195 + flowAccum[i] * 0.62); - } - } - - function traceFlowLinkedMinorStream(startX, startY, bonusSeed = 0) { - let x = startX; - let y = startY; - const path = []; - const seen = new Set(); - for (let step = 0; step < 120; step++) { - const i = indexOf(x, y); - if (sea[i] || seen.has(i)) break; - seen.add(i); - path.push([x, y]); - if (path.length > 7 && river[i] > 0.42) break; - let next = flowTo[i]; - if (next < 0 || next === i || sea[next]) break; - let best = next; - let bestScore = elevation[next] * 1.05 - flowAccum[next] * 0.85 - valleyField[next] * 1.20 - moisture[next] * 0.10; - const cx = x; - const cy = y; - // Micro-streams can braid into the closest descent when flowTo falls into a tiny sink. - for (const [nx, ny] of neighbors8(cx, cy)) { - const ni = indexOf(nx, ny); - if (sea[ni]) continue; - const uphill = Math.max(0, elevation[ni] - elevation[i]); - if (uphill > 0.024 && flowAccum[ni] < flowAccum[i] + 0.006) continue; - const score = elevation[ni] * 1.05 + uphill * 16 - flowAccum[ni] * 0.82 - valleyField[ni] * 1.22 - moisture[ni] * 0.10 + (hash2(nx, ny, seed + bonusSeed + step * 19) - 0.5) * 0.035; - if (score < bestScore) { - bestScore = score; - best = ni; - } - } - if (best < 0 || best === i) break; - x = best % MAP_W; - y = Math.floor(best / MAP_W); - } - return path; - } - - const minorCandidateCells = []; - for (let y = 3; y < MAP_H - 3; y += 1) { - for (let x = 3; x < MAP_W - 3; x += 1) { - const i = indexOf(x, y); - if (sea[i]) continue; - if (elevation[i] < 0.30 || elevation[i] > 0.96) continue; - const drainage = valleyField[i] * 0.52 + Math.pow(flowAccum[i], 0.48) * 0.38 + moisture[i] * 0.18 + slope[i] * 0.08 - ridgeField[i] * 0.10; - const stochastic = hash2(x, y, seed + 9340); - if (drainage > 0.085 && stochastic > 0.10) { - minorCandidateCells.push({ x, y, score: drainage + stochastic * 0.055 }); - } - } - } - const minorSources = pickEntities(minorCandidateCells, { - max: 360 + Math.floor(rand(seed, 9341) * 220), - minDistance: 2, - threshold: 0.070, - seed: seed + 9342, - jitter: 0.02, - }); - const derivedSmallStreams = []; - const occupiedMinorStarts = new Set(); - for (const source of minorSources) { - const startKey = `${source.x},${source.y}`; - if (occupiedMinorStarts.has(startKey)) continue; - const path = traceFlowLinkedMinorStream(source.x, source.y, 11000 + source.x * 13 + source.y * 17); - if (path.length >= 3) { - derivedSmallStreams.push(path); - for (const [x, y] of path.slice(0, 4)) occupiedMinorStarts.add(`${x},${y}`); - for (let k = 0; k < path.length; k++) { - const [x, y] = path[k]; - const i = indexOf(x, y); - river[i] = Math.max(river[i], 0.045 + Math.min(0.16, flowAccum[i] * 0.10) + Math.min(0.055, k / 1900)); - } - } - } - - const smallStreams = streamPaths.filter((path) => path.length >= 4) - .concat(hiddenRiverPaths.filter((path) => path.length >= 5)) - .concat(derivedSmallStreams); - - - 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 + depositionalLowland[i] * 0.34 + deltaField[i] * 0.28 + alluvialFanField[i] * 0.20; - 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(Math.max(alluvialFanField[i], 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 + deltaField[i] * 0.18); - agriculture[i] = clamp(plain[i] * 0.58 + fan * 0.30 + basinField[i] * 0.2 + depositionalLowland[i] * 0.24 + deltaField[i] * 0.18 + 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) || deltaField[i] > 0.16; - const bayShelter = sheltered * 0.012 + seaNear * 0.055 + coastalLowland[i] * 0.16; - portSuitability[i] = clamp(bayShelter + riverNear * 0.24 + (isDelta ? 0.22 : 0) + deltaField[i] * 0.18 + depositionalLowland[i] * 0.08 + 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); - } - } - - 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 = Math.abs(elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]); - const gy = Math.abs(elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]); - const slopeBreak = clamp((gx + gy) * 3.2 + Math.max(0, slope[i] - 0.28) * 0.72); - const majorRiver = clamp(Math.max(0, river[i] - 0.34) * 1.45 + Math.max(0, flowAccum[i] - 0.42) * 0.58); - const basinRim = clamp(basinField[i] * Math.max(0, slope[i] - 0.16) * 1.25 + ridgeField[i] * basinField[i] * 0.32); - naturalBarrierScore[i] = clamp( - arcSpineField[i] * 0.80 + - branchRidgeField[i] * 0.62 + - ridgeField[i] * 0.54 + - majorRiver * 0.62 + - slopeBreak * 0.34 + - basinRim * 0.36 - - valleyField[i] * 0.30 - - depositionalLowland[i] * 0.42 - - coastalLowland[i] * 0.20 - - plain[i] * 0.18 - ); - } - } - - function countWaterComponents(mask, minArea = 1) { - const seen = new Uint8Array(SIZE); - let count = 0; - for (let i = 0; i < SIZE; i++) { - if (!mask[i] || seen[i]) continue; - const queue = [i]; - seen[i] = 1; - let area = 0; - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - area++; - const x = cur % MAP_W; - const y = Math.floor(cur / MAP_W); - 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 (area >= minArea) count++; - } - return count; - } - - function countSmallLandIslands(maxArea = 8) { - const seen = new Uint8Array(SIZE); - let count = 0; - for (let i = 0; i < SIZE; i++) { - if (sea[i] || seen[i]) continue; - const queue = [i]; - seen[i] = 1; - let area = 0; - let touchesEdge = false; - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - area++; - const x = cur % MAP_W; - const y = Math.floor(cur / MAP_W); - if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesEdge = true; - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - if (sea[ni] || seen[ni]) continue; - seen[ni] = 1; - queue.push(ni); - } - } - if (!touchesEdge && area <= maxArea) count++; - } - return count; - } - - const spineValues = [...arcSpineField].filter((_, i) => !sea[i]).sort((a, b) => b - a); - const strongSpineSample = Math.max(1, Math.floor(spineValues.length * 0.05)); - const primarySpineStrength = spineValues.slice(0, strongSpineSample).reduce((sum, value) => sum + value, 0) / strongSpineSample; - const riverConnectivityRate = mainRivers.length - ? mainRivers.filter((path) => path.some(([x, y], k) => k > path.length * 0.45 && neighbors8(x, y).some(([nx, ny]) => sea[indexOf(nx, ny)] || lake[indexOf(nx, ny)]))).length / mainRivers.length - : 0; - const depositionLowlandArea = [...depositionalLowland].filter((value, i) => !sea[i] && value > 0.24).length; - const terrainDebug = { - primarySpineStrength, - riverConnectivityRate, - smallIslandCount: countSmallLandIslands(8), - largeInlandLakeCount: countWaterComponents(Float32Array.from(lake, (value) => value ? 1 : 0), 120), - depositionLowlandArea, - smallStreamCount: smallStreams.length, - }; - - return { - terrainTemplate, - seaLevel, - elevation, - moisture, - slope, - sea, - ocean, - lake, - river, - floodplain, - plain, - agriculture, - ridgeField, - valleyField, - basinField, - coastalLowland, - flowAccum, - erosionField, - depositionField, - arcSpineField, - branchRidgeField, - depositionalLowland, - alluvialFanField, - deltaField, - naturalBarrierScore, - portSuitability, - crossingSuitability, - passSuitability, - prefectureMask, - prefectureBorder, - prefectureRegionId, - regionalDebug, - terrainDebug, - regionalPrefectureBorders, - riverPaths, - mainRivers, - tributaryRivers, - smallStreams, - }; -} diff --git a/names.js b/names.js index 1de3b02..7da8c6f 100644 --- a/names.js +++ b/names.js @@ -4,24 +4,26 @@ export const NAME_KANJI_POOLS = { modifiers: [ "大", "小", "上", "下", "中", "奥", "脇", "東", "西", "南", "北", - "新", "古", "本", "元", + "新", "古", "本", "高", "長", "広", "深", "浅", - "白", "黒", "青", "赤", + "白", "黒", "青", "赤", "藍", "奥", "前", "後", "内", "外", "美", "吉", "福", "幸", "徳", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万", - "霧", "霞", "朝", "日", "天", "雨", "晴", - "早", "安", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", + "霞", "朝", "日", "天", "晴", + "土", "砂", "石", "岩", + "丑", "卯", "辰", "巳", "酉", + "早", "安", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", ], inlandTerrain: [ "山", "野", "荒", "野", "沢", "森", "林", "岡", "丘", "坂", "峰", "峠", "嶺", "尾", "平", "坪", "延", "燧", - "窪", "久", "迫", "久保", "玖保", "漥", "佐古", "作古", "峪", - "塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", + "窪", "久", "迫", "久保", "玖保", "漥", "佐古", "作古", + "塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生", "聡", "郷", "里", - "馬", "鹿", "亀", "鷲", "鷹", + "馬", "鹿", "亀", "鷲", "鷹", "鶴", "竜", "龍", "牛", "鳥", "湯", ], @@ -36,10 +38,10 @@ export const NAME_KANJI_POOLS = { coastalTerrain: [ "津", "浦", "津", "崎", "島", "磯", "潟", "湊", "津", - "州", "洲", "瀬", "砂", "潮", "塩", "汐", + "州", "洲", "瀬", "砂", "潮", "塩", "浜", "泊", "江", "浦", "灘", "入", "戸", "門", - "鯵", "鰐", "漁", "魚" + "鯵", "鰐", "漁", "魚", "鮫", "鮎", ], plants: [ @@ -48,7 +50,7 @@ export const NAME_KANJI_POOLS = { "菅", "榎", "椿", "桐", "柳", "橘", "柏", "槙", "柿", "桃", "梨", "桑", "麻", "芦", "茅", - "粟", "稲", "麦", "稗", "米", "飯", "糠", + "粟", "稲", "稗", "米", "飯", "糠", "榊", "楢", "檜", "椎", "柚", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑" ], @@ -95,7 +97,7 @@ export const NAME_KANJI_POOLS = { "陀", "芸", "雲", "幡", "耆", "摩", "張", "江", "河", "斐", "濃", - "岐", "防", "門", "隅", "向", + "岐", "門", "隅", "向", "居", "前", "中", "後", "波", "勢", "渡", "城", "紫", "野", "度", "津", "島", "信", "登", "賀", "志", @@ -103,17 +105,16 @@ export const NAME_KANJI_POOLS = { ], settlementWords: [ - "里", "郷", "村", "町", "宿", "邑", "垣", "坪", + "里", "郷", "村", "町", "宿", "垣", "坪", "軒", "庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂", "城", "館", "屋", "家", "所", - "市", "場", "府", "関", "地蔵", "辻", "角", "堰", + "市", "場", "関", "地蔵", "辻", "角", "堰", "ヶ沢", "ヶ谷", "ヶ浜", "ヶ崎", "ヶ島", "ヶ浦", "ヶ津", "ヶ丘", ] }; export const NAME_PROBABILITIES = { - customName: 0.20, - forcedName: 1.0, + customNameList: 0.25, retryCount: 24, categoryFallback: { @@ -281,8 +282,9 @@ export const NAME_TEMPLATE_WEIGHTS = { }, }; -export const CUSTOM_NAMES = {}; -export const FORCED_NAMES = {}; +// Add preferred reusable place names here. Each generated entity has a +// deterministic chance to use one before falling back to template kanji. +export const CUSTOM_NAME_LIST = []; // Legacy export kept only so older imports do not fail. export const NAME_PARTS = {}; @@ -316,6 +318,20 @@ function countChars(value) { return Array.from(String(value || "")).length; } +function isKanjiChar(ch) { + return /[\u3400-\u9FFF\uF900-\uFAFF]/u.test(ch); +} + +function hasRepeatedKanji(value) { + const seen = new Set(); + for (const ch of Array.from(String(value || ""))) { + if (!isKanjiChar(ch)) continue; + if (seen.has(ch)) return true; + seen.add(ch); + } + return false; +} + function incrementCounter(counter, key, amount = 1) { counter[key] = (counter[key] || 0) + amount; } @@ -397,15 +413,15 @@ export function createNameDebug(probabilities = NAME_PROBABILITIES, pools = NAME const emptyPools = POOL_KEYS.filter((key) => !pools[key]?.length); const poolsPresent = Object.fromEntries(POOL_KEYS.map((key) => [key, Boolean(pools[key]?.length)])); return { - effectiveCustomNameProbability: probabilities.customName, + effectiveCustomNameListProbability: probabilities.customNameList, poolsPresent, emptyPools, selectedTemplateCounts: {}, selectedContextCounts: {}, - customNamesUsed: 0, - forcedNamesUsed: 0, generatedNamesUsed: 0, + customNameListUsed: 0, invalidNamesRejected: 0, + repeatedKanjiNamesRejected: 0, oneCharacterNamesPrevented: 0, rejectedOneCharacterNames: 0, duplicateRetries: 0, @@ -459,6 +475,7 @@ export function validateGeneratedName(name, options = {}) { } if (!options.allowOneCharacter && length < 2) return { valid: false, reason: "oneCharacter" }; if (!options.allowLong && length > 4) return { valid: false, reason: "tooLong" }; + if (!options.allowRepeatedKanji && hasRepeatedKanji(value)) return { valid: false, reason: "repeatedKanji" }; return { valid: true, reason: "valid" }; } @@ -512,35 +529,39 @@ function uniqueDiagnosticName(seed, id, usedNames, debug, startAttempt = 0) { return `${ASCII_DIAGNOSTIC_PREFIX}${stableHash(`${seed}:${id}`).toString(36).toUpperCase()}`; } -function tryCustomName(seed, id, usedNames, debug) { - const customName = CUSTOM_NAMES[id]; - if (!customName) return null; - if (roll(seed, id, 0, 3501) >= NAME_PROBABILITIES.customName) return null; - const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true }); - if (!validation.valid) { - if (validation.reason === "oneCharacter") debug.oneCharacterNamesPrevented++; - if (validation.reason === "oneCharacter") debug.rejectedOneCharacterNames++; - else debug.invalidNamesRejected++; - return null; +function tryCustomNameList(seed, id, usedNames, debug) { + if (!CUSTOM_NAME_LIST.length) return null; + if (roll(seed, id, 0, 3527) >= (NAME_PROBABILITIES.customNameList ?? 0)) return null; + + const start = Math.floor(roll(seed, id, 0, 3539) * CUSTOM_NAME_LIST.length) % CUSTOM_NAME_LIST.length; + for (let offset = 0; offset < CUSTOM_NAME_LIST.length; offset++) { + const customName = CUSTOM_NAME_LIST[(start + offset) % CUSTOM_NAME_LIST.length]; + const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true }); + if (!validation.valid) { + if (validation.reason === "oneCharacter") { + debug.oneCharacterNamesPrevented++; + debug.rejectedOneCharacterNames++; + } else if (validation.reason === "repeatedKanji") { + debug.repeatedKanjiNamesRejected++; + } else { + debug.invalidNamesRejected++; + } + continue; + } + if (usedNames?.has(customName)) { + debug.duplicateRetries++; + continue; + } + debug.customNameListUsed++; + return customName; } - if (usedNames?.has(customName)) { - debug.duplicateRetries++; - return null; - } - debug.customNamesUsed++; - return customName; + return null; } export function generateEntityName(seed, id, entity, fields, usedNames = null, debug = createNameDebug()) { debug ||= createNameDebug(); - const forcedName = FORCED_NAMES[id]; - if (forcedName) { - debug.forcedNamesUsed++; - return forcedName; - } - - const customName = tryCustomName(seed, id, usedNames, debug); - if (customName) return customName; + const listedCustomName = tryCustomNameList(seed, id, usedNames, debug); + if (listedCustomName) return listedCustomName; const retryCount = Math.max(1, NAME_PROBABILITIES.retryCount || 1); for (let attempt = 0; attempt < retryCount; attempt++) { @@ -556,6 +577,7 @@ export function generateEntityName(seed, id, entity, fields, usedNames = null, d debug.oneCharacterNamesPrevented++; debug.rejectedOneCharacterNames++; } + else if (result.invalidReason === "repeatedKanji") debug.repeatedKanjiNamesRejected++; else debug.invalidNamesRejected++; continue; } diff --git a/test.js b/test.js index 639606b..f1e78d6 100644 --- a/test.js +++ b/test.js @@ -1,7 +1,6 @@ import { generateMap, MAP_W, MAP_H, indexOf } from "./mapGenerator.js"; import { - CUSTOM_NAMES, - FORCED_NAMES, + CUSTOM_NAME_LIST, NAME_KANJI_POOLS, NAME_PARTS, NAME_PROBABILITIES, @@ -9,6 +8,7 @@ import { NAME_TEMPLATE_WEIGHTS, generateEntityName, generateTemplateName, + validateGeneratedName, } from "./names.js"; const result = document.getElementById("result"); @@ -654,6 +654,7 @@ try { assert(map.entitiesForNames.every((item) => typeof item.name === "string"), "nameable entity names are strings"); assert(map.entitiesForNames.every((item) => !String(item.name).includes("\uFFFD")), "generated names contain no replacement characters"); assert(map.entitiesForNames.every((item) => Array.from(String(item.name)).length >= 2), "one-character generated names are prevented"); + assert(map.entitiesForNames.every((item) => validateGeneratedName(item.name, { allowAsciiDiagnostic: true }).valid), "generated names pass place-name validation"); 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"); @@ -664,14 +665,12 @@ try { 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, + map.nameDebug.generatedNamesUsed + map.nameDebug.customNameListUsed + map.nameDebug.fallbackAttempts === namedEntityCount, "nameDebug accounting covers named entities" ); 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"); - assert( map.adminCenters.length !== other.adminCenters.length || map.villages.length !== other.villages.length || @@ -751,28 +750,13 @@ try { .map((seeded) => ({ deposition: seeded.terrainTemplate.deposition, lowlandRatio: terrainCoreMetrics(seeded).lowlandRatio })) .sort((a, b) => a.deposition - b.deposition); assert(byDeposition[byDeposition.length - 1].lowlandRatio >= byDeposition[0].lowlandRatio * 0.72, "higher-deposition templates generally preserve or expand lowland area"); + CUSTOM_NAME_LIST.push("L1", "L2"); + const listedCustomNames = Array.from({ length: 50 }, (_, n) => generateEntityName(9200 + n, `list-probe-${n}`, { x: 10, y: 10, kind: "Probe" }, {}, new Set())); + const listedCustomHits = listedCustomNames.filter((name) => name === "L1" || name === "L2").length; + assert(NAME_PROBABILITIES.customNameList > 0 && listedCustomHits > 0 && listedCustomHits < listedCustomNames.length, "CUSTOM_NAME_LIST supplies probabilistic selected place names"); + CUSTOM_NAME_LIST.length = 0; - CUSTOM_NAMES["city-0"] = "C1"; - const customSameA = generateMap(321); - const customSameB = generateMap(321); - const sameTargetA = customSameA.modernCities.find((item) => item.id === "city-0"); - const sameTargetB = customSameB.modernCities.find((item) => item.id === "city-0"); - const customSeedMaps = [301, 302, 303, 304, 305, 306, 307, 308].map((seedValue) => generateMap(seedValue)); - const customTargets = customSeedMaps.map((seeded) => seeded.modernCities.find((item) => item.id === "city-0")).filter(Boolean); - const customHits = customTargets.filter((item) => item.name === "C1").length; - assert(sameTargetA?.name === sameTargetB?.name, "custom-name probability is deterministic for the same seed"); - 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"]; + assert(!validateGeneratedName("青青").valid && validateGeneratedName("青青").reason === "repeatedKanji", "place names reject repeated kanji"); for (const seed of [101, 2026, 54321]) { const seeded = generateMap(seed);