From 1ea8ba1701094567a53000fe7cc4dd81c5cabffa Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Sun, 24 May 2026 17:38:51 +0900 Subject: [PATCH] town tweak --- adminRegions.js | 505 ++++++++++- adminRegions.notrace.js | 1776 +++++++++++++++++++++++++++++++++++++++ app.js | 17 +- landuseCodes.js | 37 + mapAdminStage.js | 192 ++++- mapAdminStage.nolog.js | 1064 +++++++++++++++++++++++ mapFeatures.js | 1157 +++++++++++++++++++++---- mapGeneratorHelpers.js | 23 +- mapOutput.js | 54 +- mapPipeline.js | 3 +- mapTerrain.js | 469 +++++++++-- renderer.js | 79 +- 12 files changed, 5001 insertions(+), 375 deletions(-) create mode 100644 adminRegions.notrace.js create mode 100644 landuseCodes.js create mode 100644 mapAdminStage.nolog.js diff --git a/adminRegions.js b/adminRegions.js index b823245..877c1cf 100644 --- a/adminRegions.js +++ b/adminRegions.js @@ -558,20 +558,22 @@ function canShareNaturalCompartment(a, b, classA, classB, barrier, river, flowAc const bothLivingCorridor = [5, 6, 7, 10].includes(classA) && [5, 6, 7, 10].includes(classB); if (!bothUrban && !bothLivingCorridor) return false; } - const majorRiverEdge = Math.max(river[a], river[b]) > 0.58 || Math.max(flowAccum[a], flowAccum[b]) > 0.72; + const 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.42 && !majorRiverEdge; - const threshold = urbanEdge ? 0.84 : valleyContinuity ? 0.76 : classA === 8 || classB === 8 ? 0.42 : 0.62; + 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]; @@ -584,6 +586,13 @@ function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField 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); @@ -596,13 +605,25 @@ function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField } function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) { - if (!unit || unit.area < 28 || unit.lowlandFitness < 0.24 || unit.mountainFitness > 0.72) return null; - const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore } = fields; - let first = -1, second = -1, bestA = -INF, bestB = -INF; + 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 score = low + populationDensity[i] * 0.22 + hashSeededTie(x, y, seed) * 0.04; + 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; @@ -610,36 +631,54 @@ function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) { 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 = d * (0.55 + low * 0.45) + hashSeededTie(x, y, seed + 17) * 0.20; + 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 localOwner = new Map([[first, 0], [second, 1]]); - const queue = [first, second]; - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - const owner = localOwner.get(cur); - const [x, y] = xyOf(cur); - for (const [nx, ny] of neighbors4(x, y)) { + 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) || localOwner.has(ni)) continue; - localOwner.set(ni, owner); - queue.push(ni); + 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 }); + } } } - for (const ci of unit.cells) if (!localOwner.has(ci)) { - const [x, y] = xyOf(ci); - const d0 = Math.hypot(x - fx, y - fy); - const [sx, sy] = xyOf(second); - const d1 = Math.hypot(x - sx, y - sy); - localOwner.set(ci, d0 <= d1 ? 0 : 1); + + const aCells = []; + const bCells = []; + for (const ci of unit.cells) { + if (owner[ci] === 1) bCells.push(ci); + else aCells.push(ci); } - const aCells = [], bCells = []; - for (const ci of unit.cells) (localOwner.get(ci) === 0 ? aCells : bCells).push(ci); - if (aCells.length < 10 || bCells.length < 10) return null; + 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; @@ -663,7 +702,369 @@ function naturalGroupKey(unit) { 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 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); + 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; + + let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields); + rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + mergeTinyLandscapeUnits(compartmentId, compartments, 9); + splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); + compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); + refreshAllCompartmentStats(compartments, fields); + rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + + const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55)); + let guard = Math.max(80, targetCount * 3); + 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)) + .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); @@ -679,6 +1080,7 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, 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; @@ -687,6 +1089,7 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, 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; @@ -711,6 +1114,13 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, 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, @@ -730,22 +1140,47 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, 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 }; - let guard = targetCount * 3; + 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.lowlandFitness > 0.24 && unit.mountainFitness < 0.74 && unit.area >= 28) - .sort((a, b) => (b.area * (0.45 + b.lowlandFitness) - b.mountainFitness * 80) - (a.area * (0.45 + a.lowlandFitness) - a.mountainFitness * 80)); + .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.lowlandFitness = 0; + 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 }; @@ -1081,6 +1516,12 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e ...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), diff --git a/adminRegions.notrace.js b/adminRegions.notrace.js new file mode 100644 index 0000000..877c1cf --- /dev/null +++ b/adminRegions.notrace.js @@ -0,0 +1,1776 @@ +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 && centerDist[candidateId] && centerDist[oldId]) { + const drift = centerDist[candidateId][i] - centerDist[oldId][i]; + if (drift > 0) energy += Math.min(0.9, drift * 0.012); + } + return energy; +} + +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; +} + +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 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); + 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; + + let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields); + rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + mergeTinyLandscapeUnits(compartmentId, compartments, 9); + splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea); + compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea); + refreshAllCompartmentStats(compartments, fields); + rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea); + + const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55)); + let guard = Math.max(80, targetCount * 3); + 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)) + .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 { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse, options); + const adminId = new Int16Array(SIZE); + adminId.fill(-1); + const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options); + 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); + 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); +} diff --git a/app.js b/app.js index 6335277..17158eb 100644 --- a/app.js +++ b/app.js @@ -1,5 +1,6 @@ import { generateMap } from "./mapGenerator.js"; import { drawMap } from "./renderer.js"; +import { landuseLabel } from "./landuseCodes.js"; const modes = [ ["all", "All"], @@ -57,6 +58,8 @@ function countText(items) { function getStats(map) { return [ + ["Map Type", map.terrainTemplate?.terrainTypeLabel || map.terrainDebug?.terrainTypeLabel || "-"], + ["Terrain ID", map.terrainTemplate?.terrainType || map.terrainDebug?.terrainType || "-"], ["Villages", countText(map.villages)], ["Market Towns", countText(map.markets)], ["Castles", countText(map.castles)], @@ -66,6 +69,7 @@ function getStats(map) { ["Neighbor Prefectures", (map.neighborPrefectures || []).map((p) => p.name).join(" / ") || "-"], ["Neighbor Features", map.neighborPrefectureDetails ? `${map.neighborPrefectureDetails.cities?.length || 0} cities / ${map.neighborPrefectureDetails.adminCenters?.length || 0} municipalities / ${map.neighborPrefectureDetails.roads?.length || 0} roads` : "-"], ["Prefectural Capital", map.prefecturalCapital?.name || "-"], + ["Regional Capitals", (map.modernCities || []).filter((p) => p.isRegionalCapital).length], ["Modern Cities", countText(map.modernCities)], ["Ports", `${map.ports.filter((p) => p.portClass === "major").length} major / ${map.ports.filter((p) => p.portClass === "regional").length} regional / ${map.ports.filter((p) => p.portClass === "fishing").length} fishing / ${map.ports.filter((p) => p.portClass === "lake").length} lake`], ["Satellite Cities", countText(map.satelliteCities || [])], @@ -148,18 +152,7 @@ function nearestEntity(map, x, y, maxDistance = 5) { } function landuseName(value) { - return { - 0: "Agriculture", - 1: "Plain", - 2: "Old urban area", - 3: "CBD / DID core", - 4: "Suburban urban area", - 5: "Industrial zone", - 6: "Logistics area", - 7: "New town", - 8: "Roadside development", - 9: "Forest / rural land", - }[value] || "Land"; + return landuseLabel(value); } function adminName(map, adminId) { diff --git a/landuseCodes.js b/landuseCodes.js new file mode 100644 index 0000000..acc9888 --- /dev/null +++ b/landuseCodes.js @@ -0,0 +1,37 @@ +export const LANDUSE = Object.freeze({ + RURAL: 0, + FARMLAND: 1, + OLD_URBAN: 2, + CBD: 3, + SUBURB: 4, + INDUSTRIAL: 5, + LOGISTICS: 6, + NEW_TOWN: 7, + ROADSIDE: 8, + FOREST: 9, +}); + +export const LANDUSE_LABELS = Object.freeze({ + [LANDUSE.RURAL]: "Rural / natural land", + [LANDUSE.FARMLAND]: "Farmland", + [LANDUSE.OLD_URBAN]: "Old urban area", + [LANDUSE.CBD]: "CBD / DID core", + [LANDUSE.SUBURB]: "Suburban urban area", + [LANDUSE.INDUSTRIAL]: "Industrial zone", + [LANDUSE.LOGISTICS]: "Logistics area", + [LANDUSE.NEW_TOWN]: "New town", + [LANDUSE.ROADSIDE]: "Roadside development", + [LANDUSE.FOREST]: "Forest / mountain land", +}); + +export function landuseLabel(value) { + return LANDUSE_LABELS[value] || "Land"; +} + +export function isBuiltLanduse(value) { + return value >= LANDUSE.OLD_URBAN && value <= LANDUSE.ROADSIDE; +} + +export function isUrbanResidentialLanduse(value) { + return value === LANDUSE.OLD_URBAN || value === LANDUSE.CBD || value === LANDUSE.SUBURB || value === LANDUSE.NEW_TOWN || value === LANDUSE.ROADSIDE; +} diff --git a/mapAdminStage.js b/mapAdminStage.js index d596b23..6a9730a 100644 --- a/mapAdminStage.js +++ b/mapAdminStage.js @@ -11,6 +11,15 @@ import { 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++; @@ -580,7 +589,7 @@ function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, c return changed; } -export function generateAdminLayout({ +function generateAdminLayoutForMask({ seed, prefectureMask, sea, @@ -617,8 +626,8 @@ export function generateAdminLayout({ : 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(3.5 + rand(seed, 1320) * 2.0, 3.5, 5.5); - let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 80, 240); + 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, @@ -648,6 +657,7 @@ export function generateAdminLayout({ 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); @@ -876,3 +886,179 @@ export function generateAdminLayout({ 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/mapAdminStage.nolog.js b/mapAdminStage.nolog.js new file mode 100644 index 0000000..6a9730a --- /dev/null +++ b/mapAdminStage.nolog.js @@ -0,0 +1,1064 @@ +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 9cded76..8e2ef10 100644 --- a/mapFeatures.js +++ b/mapFeatures.js @@ -18,6 +18,7 @@ import { samplePath, smoothPathByLineOfSight, } from "./mapGeneratorHelpers.js"; +import { LANDUSE, isBuiltLanduse, isUrbanResidentialLanduse } from "./landuseCodes.js"; export function generateMapFeatures(seed, terrain) { const { @@ -43,6 +44,7 @@ export function generateMapFeatures(seed, terrain) { crossingSuitability, passSuitability, prefectureMask, + prefectureRegionId, } = terrain; function pickPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true }) { @@ -141,13 +143,185 @@ export function generateMapFeatures(seed, terrain) { } } - let villages = pickPoints(settlementScore, { - threshold: 0.32 + rand(seed, 1031) * 0.1, - max: 28 + Math.floor(rand(seed, 1032) * 44), - minDistance: 3 + Math.floor(rand(seed, 1033) * 3), + // 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; + 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.22) arms++; + if (rv > 0.38) strong++; + } + return clamp((arms >= 3 ? 0.16 : arms === 2 ? 0.08 : 0) + strong * 0.035); + } + + 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 depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.85; + const highPenalty = Math.max(0, elevation[i] - 0.56); + const lowSlope = clamp(1 - slope[i] * 2.25); + const confluence = localConfluenceScore(x, y); + confluenceField[i] = confluence; + developable[i] = clamp( + plain[i] * 0.34 + + agriculture[i] * 0.22 + + basinField[i] * 0.22 + + valleyField[i] * 0.24 + + coastalLowland[i] * 0.16 + + depositional * 0.20 + + lowSlope * 0.10 - + slope[i] * 0.84 - + ridgeField[i] * 0.54 - + highPenalty * 1.12 - + floodplain[i] * 0.04 + ); + valleySettlement[i] = clamp( + valleyField[i] * 0.48 + + river[i] * 0.16 + + confluence * 0.72 + + depositional * 0.14 + + basinField[i] * 0.08 + + lowSlope * 0.12 - + slope[i] * 0.58 - + ridgeField[i] * 0.32 - + highPenalty * 0.74 + ); + coastalSettlement[i] = clamp( + coastalLowland[i] * 0.48 + + (portSuitability?.[i] || 0) * 0.28 + + (deltaField?.[i] || 0) * 0.18 + + plain[i] * 0.10 - + slope[i] * 0.52 - + ridgeField[i] * 0.22 + ); + ruralSuitability[i] = clamp( + settlementScore[i] * 0.48 + + agriculture[i] * 0.40 + + developable[i] * 0.22 + + valleySettlement[i] * 0.24 + + coastalSettlement[i] * 0.16 - + Math.max(0, elevation[i] - 0.64) * 0.54 + ); + townSuitability[i] = clamp( + settlementScore[i] * 0.30 + + developable[i] * 0.38 + + valleySettlement[i] * 0.24 + + coastalSettlement[i] * 0.20 + + confluence * 0.34 + + basinField[i] * 0.14 + + plain[i] * 0.10 - + slope[i] * 0.34 - + ridgeField[i] * 0.16 + ); + } + } + + 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++; + } + } + return stats; + } + + const settlementRegionStats = buildSettlementRegionStats(); + + function pickRegionalSettlementPoints(scoreArray, { + totalMax, + minDistance, + seedOffset, + threshold, + quotaForRegion, + kind, + extraScore = () => 0, + predicate = () => true, + }) { + const byRegion = new Map(); + 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] || !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 picked = []; + for (const [regionId, candidates] of [...byRegion.entries()].sort((a, b) => a[0] - b[0])) { + const quota = quotaForRegion(regionId, settlementRegionStats.get(regionId) || { area: 0 }); + if (quota <= 0) continue; + picked.push(...pickEntities(candidates, { + max: quota, + minDistance, + threshold, + seed: seed + seedOffset + regionId * 997, + })); + } + return picked + .sort((a, b) => b.score - a.score) + .slice(0, totalMax) + .sort((a, b) => a.regionId - b.regionId || b.score - a.score); + } + + 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); + } + + let villages = pickRegionalSettlementPoints(villageScore, { + threshold: 0.26 + rand(seed, 1031) * 0.055, + totalMax: 105 + Math.floor(rand(seed, 1032) * 35), + minDistance: 4, seedOffset: 1030, - predicate: (x, y, i) => !sea[i], - }).map((p) => ({ ...p, kind: "Village" })); + 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)); + }, + 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 marketScore = new Float32Array(SIZE); for (let y = 4; y < MAP_H - 4; y++) { @@ -160,25 +334,51 @@ export function generateMapFeatures(seed, terrain) { for (const v of villages) { const d = Math.hypot(x - v.x, y - v.y); if (d < 24) { - villagePull += 1 / (1 + d); + 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)); - const confluence = river[i] > 0.36 && valleyField[i] > 0.24 ? 0.12 : 0; - marketScore[i] = clamp(villagePull * 1.25 + featurePull * 0.34 + plain[i] * 0.2 + basinField[i] * 0.16 + (depositionalLowland?.[i] || 0) * 0.10 + (deltaField?.[i] || 0) * 0.08 + confluence + coastalLowland[i] * 0.08 + river[i] * 0.035 - slope[i] * 0.32 - ridgeField[i] * 0.18 - (arcSpineField?.[i] || 0) * 0.08 + nearbyVillages * 0.012); + 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; + marketScore[i] = clamp( + townSuitability[i] * 0.66 + + villagePull * 0.56 + + featurePull * 0.26 + + confluenceField[i] * 0.30 + + 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 + ); } } - let markets = pickPoints(marketScore, { - threshold: 0.2 + rand(seed, 1041) * 0.08, - max: 6 + Math.floor(rand(seed, 1042) * 12), - minDistance: 11, + let markets = pickRegionalSettlementPoints(marketScore, { + threshold: 0.30 + rand(seed, 1041) * 0.055, + totalMax: 34 + Math.floor(rand(seed, 1042) * 16), + minDistance: 8, seedOffset: 1040, - predicate: (x, y, i) => !sea[i], - }).map((p) => ({ ...p, kind: "Market Town" })); + 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)); + }, + extraScore: (x, y, i) => (distanceToNearest(ports, x, y) < 7 ? 0.07 : 0) + (distanceToNearest(crossings, x, y) < 5 ? 0.06 : 0), + }).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; + return { ...p, kind, population }; + }); const defenseScore = new Float32Array(SIZE); for (let y = 3; y < MAP_H - 3; y++) { @@ -251,68 +451,111 @@ export function generateMapFeatures(seed, terrain) { const i = indexOf(p.x, p.y); if (sea[i]) return 0; const portBonus = p.kind === "Port Town" || p.portClass === "major" || p.portClass === "regional" ? 0.18 : 0; - const historicalBonus = p.kind === "Market City" || p.kind === "Castle Town" ? 0.05 : 0; + const historicalBonus = p.kind === "Market City" || p.kind === "Castle Town" || p.kind === "Market Town" || p.kind === "Valley Market Town" ? 0.07 : 0; return clamp( - plain[i] * 0.46 + - agriculture[i] * 0.18 + - basinField[i] * 0.20 + - coastalLowland[i] * 0.20 + - valleyField[i] * 0.12 + + (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.58 - - ridgeField[i] * 0.34 - - Math.max(0, elevation[i] - 0.55) * 1.35 + slope[i] * 0.54 - + ridgeField[i] * 0.32 - + Math.max(0, elevation[i] - 0.55) * 1.25 ); } - function cityPopulationCap(p) { - const i = indexOf(p.x, p.y); - const suitability = urbanSiteSuitability(p); - if (suitability < 0.18 || elevation[i] > 0.66 || slope[i] > 0.82 || ridgeField[i] > 0.72) return 85000; - if (suitability < 0.28 || elevation[i] > 0.60 || slope[i] > 0.62) return 180000; - if (suitability < 0.38) return 420000; - return INF; + 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] || urbanSiteSuitability({ x, y }); + if (dev < 0.045) 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 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); } - let castleTowns = castles.map((c) => ({ x: c.x, y: c.y, score: c.score + 0.45, kind: "Castle Town" })); - const cityCandidates = [ - ...castleTowns.map((p) => ({ ...p, score: p.score + 0.4 })), - ...ports.map((p) => ({ ...p, kind: "Port Town", score: p.score + 0.28 })), - ...markets.map((p) => ({ ...p, kind: "Market City", score: p.score + 0.12 })), - ].map((p) => { - const i = indexOf(p.x, p.y); - const suitability = urbanSiteSuitability(p); - return { - ...p, - urbanSuitability: suitability, - score: p.score + suitability * 0.72 - slope[i] * 0.20 - ridgeField[i] * 0.16 - Math.max(0, elevation[i] - 0.58) * 0.78, - }; - }).filter((p) => p.urbanSuitability >= 0.10 || p.kind === "Castle Town"); + 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); + } - let modernCities = pickEntities(cityCandidates, { - max: 7 + Math.floor(rand(seed, 1061) * 10), - minDistance: 9, - threshold: 0.33 + rand(seed, 1062) * 0.12, - seed: seed + 1060, - }).map((p, n) => { - const rank = n === 0 ? "Prefectural Capital" : n < 4 ? "Regional Center" : "Small City"; - const r = rand(seed, 1600 + n * 13 + p.x * 3 + p.y); - const rawScale = Math.pow(1 - n / Math.max(1, cityCandidates.length + 1), 1.55) * 0.58 + Math.pow(r, 3.4) * 0.42; - const rankBase = rank === "Prefectural Capital" ? 420000 : rank === "Regional Center" ? 115000 : 26000; - const rankSpread = rank === "Prefectural Capital" ? 1450000 : rank === "Regional Center" ? 520000 : 185000; - const pi = indexOf(p.x, p.y); - const suitability = p.urbanSuitability ?? urbanSiteSuitability(p); - const geographyBoost = clamp(plain[pi] * 0.34 + agriculture[pi] * 0.18 + basinField[pi] * 0.2 + coastalLowland[pi] * 0.18 + valleyField[pi] * 0.12 + suitability * 0.24 + (p.kind === "Port Town" ? 0.22 : 0)); - const rawPopulation = Math.round((rankBase + rankSpread * Math.pow(rawScale + geographyBoost * 0.18, 1.75)) / 1000) * 1000; - const population = Math.min(rawPopulation, cityPopulationCap(p)); - const urbanRadius = clamp(7.5 + Math.sqrt(population) / 80 + (rank === "Prefectural Capital" ? 3.0 : rank === "Regional Center" ? 1.5 : 0), 8, 32); - const coreRadius = clamp(2.6 + Math.sqrt(population) / 320, 3, 9); - const urbanWeight = clamp(0.74 + Math.log10(Math.max(10000, population)) * 0.36, 1.15, 3.05); - return { ...p, population, urbanRadius, coreRadius, urbanWeight, rank, kind: p.kind || "City" }; - }); + function 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 fallbackCapitalCandidate() { - const pools = [...markets, ...ports, ...villages].filter((p) => p && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]); + 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) { @@ -320,60 +563,381 @@ export function generateMapFeatures(seed, terrain) { const score = urbanSiteSuitability(p) * 1.6 + plain[i] * 0.32 + populationDensityProxyForCapital(i) + (p.kind?.includes("Port") ? 0.18 : 0) + (p.score || 0); if (score > bestScore) { bestScore = score; best = p; } } - if (best) return { ...best, kind: "Market City", population: 360000, urbanRadius: 15, coreRadius: 4.6, urbanWeight: 1.9, score: bestScore }; + 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 (!prefectureMask[i] || sea[i]) continue; + 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" }; } + if (score > bestScore) { bestScore = score; best = { x, y, score, kind: "Market City", regionId }; } } } - return best ? { ...best, population: 320000, urbanRadius: 14, coreRadius: 4.2, urbanWeight: 1.7 } : null; + return best; } - function populationDensityProxyForCapital(i) { - return settlementScore[i] * 0.18 + marketScore[i] * 0.12; + 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")); } - if (modernCities.length === 0 || !modernCities.some((city) => prefectureMask[indexOf(city.x, city.y)])) { - const fallbackCapital = fallbackCapitalCandidate(); - if (fallbackCapital) modernCities.unshift(fallbackCapital); + 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)); } - if (modernCities.length > 0) { - modernCities.sort((a, b) => (b.population || 0) + b.score * 90000 - ((a.population || 0) + a.score * 90000)); - let capitalIndex = -1; - let capitalScore = -INF; + 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 > capitalScore) { capitalScore = score; capitalIndex = i; } + if (score > selectedCapitalScore) { selectedCapitalScore = score; selectedCapitalIndex = i; } } - if (capitalIndex > 0) modernCities.unshift(modernCities.splice(capitalIndex, 1)[0]); - const capCell = indexOf(modernCities[0].x, modernCities[0].y); - const capPopulation = prefectureMask[capCell] - ? Math.max(modernCities[0].population || 0, 620000) - : Math.min(modernCities[0].population || 0, 180000); - modernCities[0] = { - ...modernCities[0], - rank: prefectureMask[capCell] ? "Prefectural Capital" : "Regional Center", - kind: prefectureMask[capCell] ? "Prefectural Capital" : (modernCities[0].kind || "City"), - isPrefecturalCapital: Boolean(prefectureMask[capCell]), - population: capPopulation, - urbanRadius: prefectureMask[capCell] ? Math.max(modernCities[0].urbanRadius || 0, 18) : modernCities[0].urbanRadius, - coreRadius: prefectureMask[capCell] ? Math.max(modernCities[0].coreRadius || 0, 5.5) : modernCities[0].coreRadius, - urbanWeight: prefectureMask[capCell] ? Math.max(modernCities[0].urbanWeight || 0, 2.15) : modernCities[0].urbanWeight, - }; - for (let i = 1; i < modernCities.length; i++) modernCities[i] = { ...modernCities[i], isPrefecturalCapital: false }; + + 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++) { @@ -381,23 +945,31 @@ export function generateMapFeatures(seed, terrain) { 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.55); + 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); - density += populationScale * 1.55 / (1 + Math.pow(d / urbanR, 2.35)); - density += populationScale * 1.05 * Math.exp(-(d * d) / (coreR * coreR * 2.2)); + 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.22 / (1 + Math.pow(d / 7.5, 2.2)); + 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.055 / (1 + Math.pow(d / 4.2, 2)); + density += 0.038 / (1 + Math.pow(d / 3.5, 2)); } - density *= clamp(0.48 + plain[i] * 0.62 + agriculture[i] * 0.14 + basinField[i] * 0.22 + coastalLowland[i] * 0.18 + valleyField[i] * 0.1 - slope[i] * 1.05 - ridgeField[i] * 0.48 - Math.max(0, elevation[i] - 0.58) * 1.05, 0.018, 1.22); + 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; } @@ -781,6 +1353,47 @@ export function generateMapFeatures(seed, terrain) { 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, { @@ -793,7 +1406,7 @@ export function generateMapFeatures(seed, terrain) { seedOffset: 1070, }); for (const link of mainRailLinks) { - if (addRailRoute(link.a, link.b, railways)) { + 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); } @@ -810,14 +1423,68 @@ export function generateMapFeatures(seed, terrain) { seedOffset: 1071, }); for (const link of branchRailLinks) { - if (railways.length && addRailRoute(link.a, link.b, branchRailways)) { + 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: 17, maxOverlap: 0.34, maxCount: 5 }); - compactPathArray(branchRailways, { minLength: 11, maxOverlap: 0.22, maxCount: 9 }); + 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 = [ @@ -891,8 +1558,12 @@ export function generateMapFeatures(seed, terrain) { return Math.min(0.6, (basinField[ai] + basinField[bi]) * 0.14 + (valleyField[ai] + valleyField[bi]) * 0.10 + (coastalLowland[ai] + coastalLowland[bi]) * 0.10); } - const roadTargetCandidates = [...modernCities.filter((p) => (p.population || 0) >= 110000), ...ports, ...markets, ...castles] - .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 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, @@ -944,7 +1615,7 @@ export function generateMapFeatures(seed, terrain) { }); 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 = path.some(([x, y]) => prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]); + 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); @@ -996,7 +1667,7 @@ export function generateMapFeatures(seed, terrain) { 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 = path.some(([x, y]) => prefectureMask[indexOf(x, y)] && !sea[indexOf(x, y)]); + 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); @@ -1025,14 +1696,14 @@ export function generateMapFeatures(seed, terrain) { // National roads should behave like long trunk corridors: they intentionally // pass near as many urbanized cells/cities as possible, unlike expressways. const trunkCities = modernCities - .filter((city) => prefectureMask[indexOf(city.x, city.y)] && (city.population || 0) >= 90000) + .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 && d >= 13 && d <= 58 && getDegree(roadDegree, a) < 5 && getDegree(roadDegree, b) < 5) addNationalRoad(a, b); + 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 @@ -1042,9 +1713,53 @@ export function generateMapFeatures(seed, terrain) { 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 && d >= 20 && d <= 62 && (a.population || 0) >= 110000 && (b.population || 0) >= 110000 && getDegree(roadDegree, a) < 5 && getDegree(roadDegree, b) < 5) addNationalRoad(a, b); + 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, + ); + 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++; + } + } + 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(); + function uniqueByCell(nodes) { const seen = new Set(); const out = []; @@ -1090,10 +1805,10 @@ export function generateMapFeatures(seed, terrain) { .slice(0, 18); const nodes = uniqueByCell([ capital, - ...modernCities.filter((city) => ((city.population || 0) >= 60000 || city.isPrefecturalCapital)), - ...ports.filter((p) => p.portClass !== "fishing"), - ...markets, - ...castleTowns, + ...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; @@ -1116,8 +1831,8 @@ export function generateMapFeatures(seed, terrain) { // 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) => prefectureMask[indexOf(city.x, city.y)]) - .slice(0, 4); + .filter((city) => inHumanRegion(city)) + .slice(0, Math.max(4, generatedRegionIdsForTransport.length + 2)); function addMetroRadialNationalRoads() { let added = 0; @@ -1130,7 +1845,7 @@ export function generateMapFeatures(seed, terrain) { ...markets, ]); for (const node of candidates) { - if (!prefectureMask[indexOf(node.x, node.y)]) continue; + 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); @@ -1170,10 +1885,10 @@ export function generateMapFeatures(seed, terrain) { const populationNodes = uniqueByCell([ capital, - ...modernCities.filter((city) => (city.population || 0) >= 85000), - ...ports.filter((p) => p.portClass === "major" || p.portClass === "regional"), - ...markets.filter((p) => transportDemand(p) > 0.35), - ...castleTowns.filter((p) => transportDemand(p) > 0.35), + ...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, @@ -1269,8 +1984,40 @@ export function generateMapFeatures(seed, terrain) { 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(); @@ -1326,23 +2073,56 @@ export function generateMapFeatures(seed, terrain) { return false; } - const expressNodes = [capital, ...modernCities.filter((p) => (p.population || 0) >= 220000), ...majorPorts.filter((p) => p.portClass === "major")]; - const expressLinks = buildHierarchicalLinks(expressNodes, { - mode: "express", - maxLinks: 1 + Math.floor(rand(seed, 1120) * 2), - extraLinks: rand(seed, 1121) > 0.72 ? 1 : 0, - minDistance: 26, - maxDistance: 86, - maxDegree: 2, - seedOffset: 1120, - }); + 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 (addExpressway(link.a, link.b)) { + 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 = []; @@ -1366,7 +2146,7 @@ export function generateMapFeatures(seed, terrain) { const y = city.y + dy; if (!inside(x, y)) continue; const i = indexOf(x, y); - if (sea[i] || !prefectureMask[i]) continue; + 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); @@ -1474,13 +2254,13 @@ export function generateMapFeatures(seed, terrain) { return made; } - const mediumRingCities = modernCities.filter((c) => (c.population || 0) >= 160000).slice(0, 5); + 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) => (c.population || 0) >= 420000)]).slice(0, 3); + 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); @@ -1491,8 +2271,8 @@ export function generateMapFeatures(seed, terrain) { if (railRingSegments === 0) addLooseEnvironmentalRing(city, ringRailways, softRingRailCost, railRadius); } ringExpressways.length = 0; - compactPathArray(ringRoads, { minLength: 8, maxOverlap: 0.32, maxCount: 18 }); - compactPathArray(ringRailways, { minLength: 8, maxOverlap: 0.26, maxCount: 8 }); + 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]) { @@ -1605,7 +2385,7 @@ export function generateMapFeatures(seed, terrain) { addUniqueNode(roadCore, gate); } - const makeExpressLink = idx === 0 || idx === 1 || rand(seed, 1210 + idx) > 0.58; + 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); @@ -1719,7 +2499,7 @@ export function generateMapFeatures(seed, terrain) { nationalRoads.push(...repairs); return repairs.length; } - const nationalRoadDeadEndRepairs = repairNationalRoadDeadEnds(); + const nationalRoadDeadEndRepairs = 0; function demoteUnresolvedNationalRoadBranches() { let demoted = 0; @@ -1811,7 +2591,7 @@ export function generateMapFeatures(seed, terrain) { branchRailways.push(...repairs); return repairs.length; } - const railDeadEndRepairs = repairRailDeadEnds(); + const railDeadEndRepairs = 0; let throughExpresswayAdded = false; function throughExpresswayCost(a, b) { @@ -1896,7 +2676,7 @@ export function generateMapFeatures(seed, terrain) { let best = null; let bestScore = -INF; for (const city of candidates) { - if (!city || !prefectureMask[indexOf(city.x, city.y)] || sea[indexOf(city.x, city.y)]) continue; + 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); @@ -1957,7 +2737,7 @@ export function generateMapFeatures(seed, terrain) { throughExpresswayAdded = true; return true; } - addThroughExpressway(); + // addThroughExpressway(); function nearestPathCellDistance(node, paths) { let best = INF; @@ -2035,7 +2815,7 @@ export function generateMapFeatures(seed, terrain) { for (let y = 4; y < MAP_H - 4; y++) { for (let x = 4; x < MAP_W - 4; x++) { const i = indexOf(x, y); - if (sea[i] || !prefectureMask[i]) continue; + if (sea[i] || !isHumanRegionCell(i)) continue; let ringPull = 0; let parent = null; for (const city of largeCitiesForSatellites) { @@ -2055,7 +2835,7 @@ export function generateMapFeatures(seed, terrain) { max: Math.min(14, 2 + largeCitiesForSatellites.length * 4 + Math.floor(rand(seed, 1162) * 4)), minDistance: 8, seedOffset: 1160, - predicate: (x, y, i) => !sea[i] && prefectureMask[i], + 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; @@ -2103,6 +2883,7 @@ export function generateMapFeatures(seed, terrain) { 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; @@ -2137,20 +2918,20 @@ export function generateMapFeatures(seed, terrain) { 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, 48)) { + 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) { - if (rand(seed, village.x * 13 + village.y * 17) < 0.90) { + 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) { - const localVillages = pickEntities(villages.map((v) => ({ ...v, score: 1 / (1 + Math.hypot(v.x - market.x, v.y - market.y)) })), { max: 4, minDistance: 1, threshold: 0 }); - for (const v of localVillages) addMinorRoad(market, v); + 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]; @@ -2164,11 +2945,11 @@ export function generateMapFeatures(seed, terrain) { 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, 28)) { - const locals = pickEntities([...villages, ...markets, ...ports].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - station.x, p.y - station.y)) })), { max: 2, minDistance: 1, threshold: 0 }); + 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, 42)) { + 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); } @@ -2181,12 +2962,15 @@ export function generateMapFeatures(seed, terrain) { 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, @@ -2200,6 +2984,12 @@ export function generateMapFeatures(seed, terrain) { 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)), + }, }; const newTownInfluence = influenceFromPoints(newTowns, 8, () => 1); @@ -2232,17 +3022,18 @@ export function generateMapFeatures(seed, terrain) { const isolatedCorridor = roadInfluence[i] > 0.22 && cityInfluence[i] < 0.08 && stationInfluence[i] < 0.08 && interchangeInfluence[i] < 0.18; const ruralScore = villageInfluence[i] * 0.3 + agriculture[i] * 0.38 + plain[i] * 0.18 - slope[i] * 0.08; - if (mountain) landuse[i] = 9; - else if (industrialInfluence[i] > 0.44) landuse[i] = 5; - else if (logisticsInfluence[i] > 0.42) landuse[i] = 6; - else if (newTownInfluence[i] > 0.42 && urbanEnvelope > 0.16) landuse[i] = 7; - else if (coreScore > 0.68 && density > 0.48 && stationInfluence[i] > 0.05 && slope[i] < 0.24 && ridgeField[i] < 0.36) landuse[i] = 3; - else if (oldTownScore > 0.49) landuse[i] = 2; - else if (suburbScore > 0.235 && !isolatedCorridor && slope[i] < 0.32 && ridgeField[i] < 0.48 && (normalizedUrbanDistance < 1.42 || satelliteInfluence[i] > 0.24)) landuse[i] = 4; - else if (roadsideScore > 0.5 && plain[i] > 0.18 && slope[i] < 0.34 && ridgeField[i] < 0.5 && !isolatedCorridor && (interchangeInfluence[i] > 0.24 || logisticsInfluence[i] > 0.16 || cityInfluence[i] > 0.09)) landuse[i] = 8; - else if (farm) landuse[i] = 1; - else if (ruralScore > 0.3) landuse[i] = 0; - else landuse[i] = 0; + 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.ROADSIDE; + else if (farm) landuse[i] = LANDUSE.FARMLAND; + else if (ruralSettlementFootprint[i] || ruralScore > 0.3) landuse[i] = LANDUSE.RURAL; + else landuse[i] = LANDUSE.RURAL; } } @@ -2254,7 +3045,7 @@ export function generateMapFeatures(seed, terrain) { const ny = y + dy; if (!inside(nx, ny)) continue; const lu = landuse[indexOf(nx, ny)]; - if (lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8) urban++; + if (isUrbanResidentialLanduse(lu)) urban++; } } return urban >= minUrban; @@ -2265,9 +3056,9 @@ export function generateMapFeatures(seed, terrain) { const namedCenters = [...modernCities, ...(satelliteCities || []), ...markets, ...ports, ...newTowns, ...stations]; const queue = []; for (let i = 0; i < SIZE; i++) { - if (seen[i] || !prefectureMask[i] || sea[i]) continue; + if (seen[i] || !isHumanRegionCell(i)) continue; const lu0 = landuse[i]; - if (!(lu0 >= 2 && lu0 <= 8)) continue; + if (!isBuiltLanduse(lu0)) continue; const component = []; let maxDensity = 0; queue.length = 0; @@ -2280,8 +3071,8 @@ export function generateMapFeatures(seed, terrain) { const [x, y] = xyOf(cur); for (const [nx, ny] of neighbors8(x, y)) { const ni = indexOf(nx, ny); - if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; - if (!(landuse[ni] >= 2 && landuse[ni] <= 8)) continue; + if (seen[ni] || !isHumanRegionCell(ni)) continue; + if (!isBuiltLanduse(landuse[ni])) continue; seen[ni] = 1; queue.push(ni); } @@ -2296,7 +3087,7 @@ export function generateMapFeatures(seed, terrain) { } } if (!hasAnchor) { - for (const ci of component) landuse[ci] = agriculture[ci] > 0.34 ? 1 : 0; + for (const ci of component) landuse[ci] = agriculture[ci] > 0.34 ? LANDUSE.FARMLAND : LANDUSE.RURAL; } } } @@ -2309,14 +3100,14 @@ export function generateMapFeatures(seed, terrain) { for (let y = 1; y < MAP_H - 1; y++) { for (let x = 1; x < MAP_W - 1; x++) { const i = indexOf(x, y); - if (landuse[i] === 3 && !hasUrbanNeighborCluster(x, y, 2, 8)) landuse[i] = 4; + 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 (sea[start] || !prefectureMask[start]) return 0; + 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); @@ -2333,16 +3124,16 @@ export function generateMapFeatures(seed, terrain) { const i = cur.i; const d = Math.hypot(x - center.x, y - center.y); const support = populationDensity[i] * 1.18 + cityInfluence[i] * 0.22 + stationInfluence[i] * 0.18 + plain[i] * 0.12 - slope[i] * 1.24 - ridgeField[i] * 0.54 - Math.max(0, elevation[i] - 0.58) * 0.50 - floodplain[i] * 0.08 - d / maxRadius * 0.22; - if (d > maxRadius || support < 0.44 || sea[i] || !prefectureMask[i]) continue; - if (!(landuse[i] === 2 || landuse[i] === 3 || landuse[i] === 4 || landuse[i] === 7 || populationDensity[i] > 0.22 || stationInfluence[i] > 0.14)) continue; + 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] = 3; + 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) || sea[ni] || !prefectureMask[ni]) continue; + 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; @@ -2358,7 +3149,7 @@ export function generateMapFeatures(seed, terrain) { for (let y = 1; y < MAP_H - 1; y++) { for (let x = 1; x < MAP_W - 1; x++) { const i = indexOf(x, y); - if (landuse[i] === 3 && !hasUrbanNeighborCluster(x, y, 2, 8)) landuse[i] = 4; + if (landuse[i] === LANDUSE.CBD && !hasUrbanNeighborCluster(x, y, 2, 8)) landuse[i] = LANDUSE.SUBURB; } } diff --git a/mapGeneratorHelpers.js b/mapGeneratorHelpers.js index 85a4117..2eba1b0 100644 --- a/mapGeneratorHelpers.js +++ b/mapGeneratorHelpers.js @@ -475,6 +475,9 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river, for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0; for (let pass = 0; pass < 4; pass++) repairRegionalTopology(regionId, sea, seeded.centers, anchorMask, 260); + const displayRegionId = new Int16Array(beforeRegionId); + for (let pass = 0; pass < 3; pass++) repairRegionalTopology(displayRegionId, sea, seeded.centers, anchorMask, 200); + let changed = 0; for (let i = 0; i < SIZE; i++) if (!sea[i] && beforeRegionId[i] !== regionId[i]) changed++; const afterBorderCount = countRegionBorderEdges(regionId, sea); @@ -484,6 +487,7 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river, return { regionId, + displayRegionId, centers: seeded.centers, naturalBarrierScore, debug: { @@ -494,6 +498,8 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river, regionalVoronoiLikeRateAfter: afterVoronoiLikeRate, regionalNaturalBarrierAverageBefore: beforeNaturalAverage, regionalNaturalBarrierAverageAfter: afterNaturalAverage, + regionalDisplayBorderCount: countRegionBorderEdges(displayRegionId, sea), + regionalDisplayNaturalBarrierAverage: averageRegionBorderBarrier(displayRegionId, sea, naturalBarrierScore), regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length, compartmentCount: compartments.filter((unit) => unit.area > 0).length, changedAfterCompartmentAssignment: changed, @@ -961,12 +967,17 @@ export function recalculatePopulationAfterLanduse(modernCities, satelliteCities, } } } - const base = city.isPrefecturalCapital ? 90000 : city.kind === "Satellite City" ? 16000 : 32000; - const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.kind === "Satellite City" ? 900 : 1200); + 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 * 650; - city.population = Math.round((base + urbanComponent + coreComponent + densityComponent) / 1000) * 1000; - city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 95, city.kind === "Satellite City" ? 5 : 7, city.isPrefecturalCapital ? 34 : 28); - city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 360, 2.2, 9); + 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 1d8c319..02e21d1 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -2,6 +2,28 @@ import { createNameDebug } from "./names.js"; import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js"; import { applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLanduse, tagInsidePrefecture } from "./mapGeneratorHelpers.js"; +const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u; + +function municipalitySuffixForCenter(center, fields, seed, ordinal = 0) { + const i = inside(center?.x || 0, center?.y || 0) ? indexOf(center.x, center.y) : 0; + const density = fields.populationDensity?.[i] || 0; + const land = fields.landuse?.[i] ?? 0; + const urban = density > 0.36 || [2, 3, 4, 7, 8].includes(land) || center?.protectedSatellite; + const rural = (fields.elevation?.[i] || 0) > 0.58 || (fields.slope?.[i] || 0) > 0.40 || (fields.ridgeField?.[i] || 0) > 0.46; + if (urban) return "市"; + if (rural && rand(seed + ordinal * 17, 9021) < 0.58) return "村"; + return "町"; +} + +function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) { + let value = String(root || center?.name || "").trim(); + if (!value) value = `自治${ordinal + 1}`; + value = value.replace(/[駅港城跡宿]$/u, ""); + if (Array.from(value).length < 2) value = `${value}${String(center?.generatedMunicipalityName || "里")}`.slice(0, 3); + if (MUNICIPAL_SUFFIX_RE.test(value)) return value; + return `${value}${municipalitySuffixForCenter(center, fields, seed, ordinal)}`; +} + export function finishMapOutput({ seed, options, @@ -84,8 +106,12 @@ export function finishMapOutput({ regionalPrefectureBorders, }) { // Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion. - // This keeps population figures proportional to the actually rendered urbanized area. - recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence2); + // Use all generated prefecture regions for human-geography density, not only the focused prefecture. + 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; + } + recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, humanRegionMask, sea, stationInfluence, roadInfluence, railInfluence2); for (const city of modernCities) { if (city.isPrefecturalCapital) continue; const cap = cityPopulationCap(city); @@ -145,13 +171,13 @@ export function finishMapOutput({ newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug); castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug); externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug); - const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, prefectureMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug); + const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug); const representativeFeatures = [ ...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })), ...markets.map((p) => ({ ...p, representativeWeight: 3.2 })), ...ports.map((p) => ({ ...p, representativeWeight: p.portClass === "major" ? 3.8 : 2.4 })), ...villages.map((p) => ({ ...p, representativeWeight: 1.6 })), - ].filter((p) => p.insidePrefecture && p.name); + ].filter((p) => p.name && (p.insidePrefecture || humanRegionMask[indexOf(p.x, p.y)])); for (const center of adminCenters) { const centerAdmin = adminId?.[indexOf(center.x, center.y)]; let best = null; @@ -169,21 +195,30 @@ export function finishMapOutput({ } if (best) break; } + center.generatedMunicipalityName = center.generatedMunicipalityName || center.name; if (best) { center.representativeFeatureId = best.id; center.representativeFeatureName = best.name; - center.generatedMunicipalityName = center.name; - center.name = best.name; + center.municipalityRootName = best.name; + } else { + center.municipalityRootName = center.generatedMunicipalityName; } } const usedAdminNames = new Set(); - for (const center of adminCenters) { - let candidate = center.name; - const generated = String(center.generatedMunicipalityName || ""); + for (const [index, center] of adminCenters.entries()) { + let candidate = municipalityNameFromRoot(center.municipalityRootName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index); + const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index); if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) { candidate = generated; } + if (usedAdminNames.has(candidate)) { + const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, index); + const base = String(center.generatedMunicipalityName || center.municipalityRootName || center.name || `自治${index + 1}`).replace(/[市町村区]$/u, ""); + candidate = `${base}${index + 1}${suffix}`; + } center.name = candidate; + center.labelName = candidate; + center.municipalityName = candidate; usedAdminNames.add(center.name); } nameDebug.maxDerivedPerBase = 0; @@ -212,6 +247,7 @@ export function finishMapOutput({ terrainTemplate, seaLevel, prefectureMask, + humanRegionMask, prefectureBorder, prefectureRegionId, regionalDebug, diff --git a/mapPipeline.js b/mapPipeline.js index 841334f..bc32a17 100644 --- a/mapPipeline.js +++ b/mapPipeline.js @@ -44,6 +44,7 @@ export function generateMap(seedInput = 114514, options = {}) { prefectureMask, prefectureBorder, prefectureRegionId, + adminPrefectureRegionId, regionalDebug, terrainDebug, regionalPrefectureBorders, @@ -61,7 +62,7 @@ export function generateMap(seedInput = 114514, options = {}) { } = features; const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({ - seed, prefectureMask, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, + seed, prefectureMask, prefectureRegionId: adminPrefectureRegionId || prefectureRegionId, 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, }); diff --git a/mapTerrain.js b/mapTerrain.js index 89bbb9e..35f7f44 100644 --- a/mapTerrain.js +++ b/mapTerrain.js @@ -38,6 +38,15 @@ function quantile(values, q) { return lerp(arr[i], arr[Math.min(arr.length - 1, i + 1)], f); } +function softCapElevation(e, start = 0.91, cap = 1.08) { + if (e <= start) return e; + const over = e - start; + const span = Math.max(0.001, cap - start); + // Hard clipping made high mountains become flat mesas. This keeps peaks high, + // but compresses only the excess so local relief survives near the top. + return start + span * (1 - Math.exp(-over / span)); +} + function forDisk(cx, cy, radius, fn) { const r = Math.ceil(radius); for (let dy = -r; dy <= r; dy++) { @@ -131,7 +140,12 @@ function ellipticalMask(px, py, system) { const dy = py - system.y; const { u, v } = rotate(dx, dy, system.angle); const a = Math.max(0.01, system.length * 0.5); - const b = Math.max(0.01, system.width * 0.5); + const along = clamp((u / a + 1) * 0.5); + const widthWave = 1 + + (system.widthVariance ?? 0.28) * Math.sin((along + (system.phase ?? 0)) * Math.PI * 2.0) + + (system.widthVariance ?? 0.28) * 0.50 * Math.sin((along * 2.7 + (system.phase ?? 0) * 1.7) * Math.PI * 2.0); + const endTaper = lerp(0.60, 1.0, Math.sin(along * Math.PI)); + const b = Math.max(0.012, system.width * 0.5 * clamp(widthWave, 0.62, 1.60) * endTaper); const r = Math.sqrt((u / a) ** 2 + (v / b) ** 2); return clamp(1 - smoothstep((r - 0.55) / 0.65)); } @@ -142,35 +156,203 @@ function sampleInsideUnitDisk(seed, n) { return { x: Math.cos(a) * r, y: Math.sin(a) * r, r }; } +const TERRAIN_TYPES = [ + { + id: "tohoku_spine", + label: "東北型・長大脊梁", + weight: 0.24, + coastStyle: "parallel_spine", + mountainMode: "range", + massifnessRange: [0.06, 0.26], + seaRatioRange: [0.13, 0.23], + twoSidedChance: 0.96, + mountainOffsetRange: [0.47, 0.53], + baseHeightRange: [0.74, 1.10], + primaryLengthRange: [0.76, 0.96], + primaryWidthRange: [0.17, 0.30], + systemCountRange: [12, 16], + beltCountRange: [3, 4], + angleSpread: 0.14, + crossSpread: 0.54, + lengthScale: 1.22, + widthScale: 1.16, + heightScale: 1.24, + coastStrength: 0.90, + plainBiasRange: [0.16, 0.34], + riverRichnessRange: [0.70, 1.18], + bigRiverChanceRange: [0.22, 0.46], + }, + { + id: "chubu_mountain", + label: "中部型・交差高山地", + weight: 0.24, + coastStyle: "outer_coast", + mountainMode: "massif", + massifnessRange: [0.42, 0.74], + seaRatioRange: [0.10, 0.20], + twoSidedChance: 0.20, + mountainOffsetRange: [0.16, 0.36], + baseHeightRange: [0.68, 1.04], + primaryLengthRange: [0.62, 0.92], + primaryWidthRange: [0.30, 0.58], + systemCountRange: [16, 20], + beltCountRange: [3, 5], + angleSpread: 0.92, + crossSpread: 0.82, + lengthScale: 1.34, + widthScale: 1.30, + heightScale: 1.12, + coastStrength: 0.74, + plainBiasRange: [0.08, 0.24], + riverRichnessRange: [0.72, 1.12], + bigRiverChanceRange: [0.28, 0.58], + }, + { + id: "setouchi_inland_sea", + label: "瀬戸内型・内海多島", + weight: 0.16, + coastStyle: "inland_sea", + mountainMode: "mixed", + massifnessRange: [0.24, 0.48], + seaRatioRange: [0.20, 0.33], + twoSidedChance: 0.92, + mountainOffsetRange: [0.22, 0.34], + baseHeightRange: [0.56, 1.00], + primaryLengthRange: [0.52, 0.76], + primaryWidthRange: [0.18, 0.34], + systemCountRange: [12, 16], + beltCountRange: [2, 3], + angleSpread: 0.24, + crossSpread: 0.70, + lengthScale: 0.98, + widthScale: 1.08, + heightScale: 1.08, + coastStrength: 1.10, + plainBiasRange: [0.26, 0.50], + riverRichnessRange: [0.58, 0.96], + bigRiverChanceRange: [0.18, 0.42], + }, + { + id: "kanto_alluvial", + label: "関東・濃尾型・大河川平野", + weight: 0.16, + coastStyle: "open_bay", + mountainMode: "range", + massifnessRange: [0.18, 0.44], + seaRatioRange: [0.15, 0.26], + twoSidedChance: 0.18, + mountainOffsetRange: [0.28, 0.46], + baseHeightRange: [0.62, 1.04], + primaryLengthRange: [0.42, 0.70], + primaryWidthRange: [0.20, 0.36], + systemCountRange: [10, 14], + beltCountRange: [2, 3], + angleSpread: 0.34, + crossSpread: 0.62, + lengthScale: 0.92, + widthScale: 1.10, + heightScale: 1.10, + coastStrength: 0.92, + plainBiasRange: [0.56, 0.86], + riverRichnessRange: [0.98, 1.38], + bigRiverChanceRange: [0.62, 0.90], + }, + { + id: "mixed_archipelago", + label: "混合型・列島変化", + weight: 0.20, + coastStyle: "mixed_archipelago", + mountainMode: "mixed", + massifnessRange: [0.16, 0.72], + seaRatioRange: [0.13, 0.29], + twoSidedChance: 0.42, + mountainOffsetRange: [0.18, 0.40], + baseHeightRange: [0.68, 1.18], + primaryLengthRange: [0.46, 0.82], + primaryWidthRange: [0.20, 0.48], + systemCountRange: [14, 17], + beltCountRange: [3, 4], + angleSpread: 0.50, + crossSpread: 0.74, + lengthScale: 1.00, + widthScale: 1.18, + heightScale: 1.25, + coastStrength: 0.96, + plainBiasRange: [0.22, 0.52], + riverRichnessRange: [0.72, 1.26], + bigRiverChanceRange: [0.34, 0.68], + }, +]; + +function pickTerrainType(seed) { + const total = TERRAIN_TYPES.reduce((sum, type) => sum + type.weight, 0); + let r = rand(seed, 10001) * total; + for (const type of TERRAIN_TYPES) { + r -= type.weight; + if (r <= 0) return type; + } + return TERRAIN_TYPES[TERRAIN_TYPES.length - 1]; +} + +function rangeValue(seed, salt, [lo, hi]) { + return lo + rand(seed, salt) * (hi - lo); +} + +function rangeInt(seed, salt, [lo, hi]) { + return Math.round(lo + rand(seed, salt) * (hi - lo)); +} + export function buildTerrainTemplate(seed) { - const mountainModeRoll = rand(seed, 12); - const mountainMode = mountainModeRoll < 0.48 ? "range" : mountainModeRoll < 0.80 ? "mixed" : "massif"; - const mountainMassifness = mountainMode === "massif" ? 0.72 + rand(seed, 13) * 0.24 : mountainMode === "mixed" ? 0.34 + rand(seed, 14) * 0.36 : rand(seed, 15) * 0.24; - const coastAngle = rand(seed, 21) * Math.PI * 2; - const twoSidedCoast = rand(seed, 22) < 0.36; - const seaRatio = 0.14 + rand(seed, 23) * 0.16; - const mountainAngle = coastAngle + Math.PI * (0.26 + rand(seed, 24) * 0.48); - const baseHeight = 0.52 + rand(seed, 25) * 0.46; - const primaryLength = lerp(0.70 + rand(seed, 26) * 0.22, 0.38 + rand(seed, 27) * 0.20, mountainMassifness); - const primaryWidth = lerp(0.15 + rand(seed, 28) * 0.13, 0.36 + rand(seed, 29) * 0.20, mountainMassifness); - const scratchCount = Math.round(lerp(26 + rand(seed, 30) * 22, 18 + rand(seed, 31) * 18, mountainMassifness)); - // 脊梁山脈そのものを複数箇所に置く。旧版の secondary は主山脈の周囲に寄りすぎ、 - // 画面上では「単一の山塊」に見えやすかったため、独立した major system として扱う。 - const mountainSystemCount = 14 + Math.floor(rand(seed, 32) * 3); // 14〜16 + const terrainType = pickTerrainType(seed); + const mountainMode = terrainType.mountainMode === "mixed" + ? (rand(seed, 12) < 0.42 ? "range" : rand(seed, 13) < 0.72 ? "mixed" : "massif") + : terrainType.mountainMode; + let mountainMassifness = rangeValue(seed, 14, terrainType.massifnessRange); + if (mountainMode === "range") mountainMassifness *= 0.70; + if (mountainMode === "massif") mountainMassifness = clamp(mountainMassifness + 0.12); + + let coastAngle = rand(seed, 21) * Math.PI * 2; + let twoSidedCoast = rand(seed, 22) < terrainType.twoSidedChance; + const seaRatio = rangeValue(seed, 23, terrainType.seaRatioRange); + let mountainAngle = coastAngle + Math.PI * (rangeValue(seed, 24, terrainType.mountainOffsetRange)); + if (terrainType.id === "tohoku_spine") { + // 東北型は左右端または上下端に海を置き、海岸線にほぼ平行な長大脊梁を通す。 + // coastAngle は海へ向かう勾配方向、等値線としての海岸線は +90° 方向。 + coastAngle = (rand(seed, 2101) < 0.5 ? 0 : Math.PI / 2) + (rand(seed, 2102) - 0.5) * 0.10; + twoSidedCoast = true; + mountainAngle = coastAngle + Math.PI / 2 + (rand(seed, 2103) - 0.5) * 0.16; + } + const baseHeight = rangeValue(seed, 25, terrainType.baseHeightRange); + const primaryLength = rangeValue(seed, 26, terrainType.primaryLengthRange); + const primaryWidth = rangeValue(seed, 28, terrainType.primaryWidthRange); + const scratchCount = Math.round(lerp(24 + rand(seed, 30) * 20, 16 + rand(seed, 31) * 18, mountainMassifness)); + const mountainSystemCount = rangeInt(seed, 32, terrainType.systemCountRange); + const mountainBeltCount = rangeInt(seed, 46, terrainType.beltCountRange); + return { seed, + terrainType: terrainType.id, + terrainTypeLabel: terrainType.label, + coastStyle: terrainType.coastStyle, seaRatio, coastAngle, twoSidedCoast, - coastNoise: 0.045 + rand(seed, 33) * 0.045, + coastNoise: 0.040 + rand(seed, 33) * 0.056, + coastStrength: terrainType.coastStrength, mountainMode, mountainMassifness, mountainAngle, + mountainAngleSpread: terrainType.angleSpread, + mountainCrossSpread: terrainType.crossSpread, + mountainLengthScale: terrainType.lengthScale, + mountainWidthScale: terrainType.widthScale, + mountainHeightScale: terrainType.heightScale, + mountainBeltCount, mountainBaseHeight: baseHeight, - mountainDensity: 0.62 + rand(seed, 34) * 0.35, + mountainDensity: 0.58 + rand(seed, 34) * 0.39, primaryMountain: { - x: clamp(0.50 + (rand(seed, 35) - 0.5) * 0.28, 0.22, 0.78), - y: clamp(0.50 + (rand(seed, 36) - 0.5) * 0.28, 0.22, 0.78), + x: clamp(0.50 + (rand(seed, 35) - 0.5) * 0.36, 0.18, 0.82), + y: clamp(0.50 + (rand(seed, 36) - 0.5) * 0.36, 0.18, 0.82), angle: mountainAngle, length: primaryLength, width: primaryWidth, @@ -186,9 +368,9 @@ export function buildTerrainTemplate(seed) { roughness: 0.40 + rand(seed, 40) * 0.50, erosion: 0.34 + rand(seed, 41) * 0.48, deposition: 0.28 + rand(seed, 42) * 0.56, - riverRichness: 0.72 + rand(seed, 43) * 0.60, - bigRiverChance: 0.34 + rand(seed, 44) * 0.34, - plainBias: 0.32 + rand(seed, 45) * 0.46, + riverRichness: rangeValue(seed, 43, terrainType.riverRichnessRange), + bigRiverChance: rangeValue(seed, 44, terrainType.bigRiverChanceRange), + plainBias: rangeValue(seed, 45, terrainType.plainBiasRange), }; } @@ -196,85 +378,146 @@ function buildMountainSystems(template, seed) { const systems = []; const targetCount = Math.max(8, template.mountainSystemCount ?? 15); const baseAngle = template.mountainAngle; + const angleSpread = template.mountainAngleSpread ?? 0.42; + const crossSpread = template.mountainCrossSpread ?? 0.70; + const lengthScale = template.mountainLengthScale ?? 1; + const widthScale = template.mountainWidthScale ?? 1; + const heightScale = template.mountainHeightScale ?? 1; + const isChubu = template.terrainType === "chubu_mountain"; + const isTohoku = template.terrainType === "tohoku_spine"; - // 複数の脊梁山脈システムを、画面中央ではなくマップ全域に分散配置する。 - // 5x3 / 4x4 に近い粗い格子へ jitter を入れ、さらに farthest-candidate で - // 既存システムから離れた候補を選ぶ。これにより「中央に単一山塊」化しにくくする。 - const cols = targetCount >= 14 ? 5 : 4; - const rows = Math.ceil(targetCount / cols); - const cellOrder = Array.from({ length: cols * rows }, (_, i) => i) - .map((v) => ({ v, key: rand(seed, 1000 + v * 17) })) - .sort((a, b) => a.key - b.key) - .map((o) => o.v); + // 山脈システムは完全ランダムではなく、複数の広い造山帯に沿って配置する。 + // これにより「方向性はそこそこ揃う」が、「中央一点に集まらない」分布になる。 + const beltCount = Math.max(1, template.mountainBeltCount ?? (targetCount >= 15 ? 4 : 3)); + const belts = []; + for (let b = 0; b < beltCount; b++) { + const t = beltCount === 1 ? 0 : (b / (beltCount - 1) - 0.5); + const angle = baseAngle + (rand(seed, 1000 + b) - 0.5) * angleSpread; + const axisX = Math.cos(angle); + const axisY = Math.sin(angle); + const crossX = Math.cos(angle + Math.PI / 2); + const crossY = Math.sin(angle + Math.PI / 2); + const crossOffset = t * crossSpread + (rand(seed, 1010 + b) - 0.5) * (0.10 + crossSpread * 0.08); + const alongShift = (rand(seed, 1020 + b) - 0.5) * 0.22; + belts.push({ + angle, + x: clamp(0.50 + axisX * alongShift / ASPECT + crossX * crossOffset / ASPECT, 0.08, 0.92), + y: clamp(0.50 + axisY * alongShift + crossY * crossOffset, 0.08, 0.92), + lengthBias: 0.82 + rand(seed, 1030 + b) * 0.32, + heightBias: 0.82 + rand(seed, 1040 + b) * 0.42, + }); + } - function gridCandidate(k, attempt) { - const cell = cellOrder[(k + attempt * 7) % cellOrder.length]; - const cx = cell % cols; - const cy = Math.floor(cell / cols); - const jitterX = (rand(seed, 1100 + k * 101 + attempt * 13) - 0.5) * 0.62; - const jitterY = (rand(seed, 1200 + k * 101 + attempt * 13) - 0.5) * 0.62; - const x = clamp((cx + 0.5 + jitterX) / cols, 0.055, 0.945); - const y = clamp((cy + 0.5 + jitterY) / rows, 0.055, 0.945); - const localTurn = (rand(seed, 1300 + k * 101 + attempt) - 0.5) * Math.PI * 0.92; - const diagonalBias = (cx / Math.max(1, cols - 1) - 0.5 + (cy / Math.max(1, rows - 1) - 0.5) * 0.35) * 0.16; + function beltCandidate(k, attempt) { + const beltIndex = (k + Math.floor(k / beltCount)) % beltCount; + const belt = belts[beltIndex]; + const perBelt = Math.ceil(targetCount / beltCount); + const ordinal = Math.floor(k / beltCount); + const baseT = perBelt <= 1 ? 0 : ordinal / (perBelt - 1) - 0.5; + const alongJitter = (rand(seed, 1100 + k * 79 + attempt * 11) - 0.5) * (attempt < 4 ? 0.15 : 0.28); + const crossJitter = (rand(seed, 1200 + k * 79 + attempt * 11) - 0.5) * (attempt < 4 ? crossSpread * 0.22 : crossSpread * 0.40); + const along = (baseT + alongJitter) * 1.03 * belt.lengthBias; + const cross = crossJitter; + const axisX = Math.cos(belt.angle); + const axisY = Math.sin(belt.angle); + const crossX = Math.cos(belt.angle + Math.PI / 2); + const crossY = Math.sin(belt.angle + Math.PI / 2); return { - x, - y, - angle: baseAngle + localTurn + diagonalBias, + x: clamp(belt.x + axisX * along / ASPECT + crossX * cross / ASPECT, 0.045, 0.955), + y: clamp(belt.y + axisY * along + crossY * cross, 0.045, 0.955), + angle: belt.angle + (rand(seed, 1300 + k * 79 + attempt) - 0.5) * angleSpread * 0.82, + beltIndex, }; } - function randomCandidate(k, attempt) { - return { - x: clamp(0.055 + rand(seed, 2000 + k * 137 + attempt * 31) * 0.89, 0.055, 0.945), - y: clamp(0.055 + rand(seed, 2100 + k * 137 + attempt * 31) * 0.89, 0.055, 0.945), - angle: baseAngle + (rand(seed, 2200 + k * 137 + attempt) - 0.5) * Math.PI * 1.05, - }; + function edgeAwareScore(c) { + const edgeD = Math.min(c.x, c.y, 1 - c.x, 1 - c.y); + const centerD = distNorm(c.x, c.y, 0.5, 0.5); + return Math.min(edgeD, 0.16) * 0.16 + centerD * 0.08; } - function candidateAt(k, attempt) { - return attempt < 5 ? gridCandidate(k, attempt) : randomCandidate(k, attempt); + if (isTohoku) { + const centralAngle = baseAngle + (rand(seed, 3330) - 0.5) * 0.06; + const centralAlong = (rand(seed, 3331) - 0.5) * 0.10; + const centralCross = (rand(seed, 3332) - 0.5) * 0.045; + systems.push({ + x: clamp(0.50 + Math.cos(centralAngle) * centralAlong / ASPECT + Math.cos(centralAngle + Math.PI / 2) * centralCross / ASPECT, 0.12, 0.88), + y: clamp(0.50 + Math.sin(centralAngle) * centralAlong + Math.sin(centralAngle + Math.PI / 2) * centralCross, 0.12, 0.88), + angle: centralAngle, + length: (0.78 + rand(seed, 3333) * 0.18) * lengthScale, + width: (0.17 + rand(seed, 3334) * 0.11) * widthScale, + height: template.mountainBaseHeight * heightScale * (0.58 + rand(seed, 3335) * 0.18), + scratchCount: Math.round(20 + rand(seed, 3336) * 10), + massifness: clamp((template.mountainMassifness ?? 0.16) * 0.55), + role: "central-primary", + beltIndex: 0, + widthVariance: 0.30 + rand(seed, 3337) * 0.46, + phase: rand(seed, 3338), + }); + + if (rand(seed, 3339) < 0.72) { + const side = rand(seed, 3340) < 0.5 ? -1 : 1; + systems.push({ + x: clamp(0.50 + Math.cos(centralAngle) * (centralAlong + side * 0.18) / ASPECT + Math.cos(centralAngle + Math.PI / 2) * (centralCross + side * 0.028) / ASPECT, 0.10, 0.90), + y: clamp(0.50 + Math.sin(centralAngle) * (centralAlong + side * 0.18) + Math.sin(centralAngle + Math.PI / 2) * (centralCross + side * 0.028), 0.10, 0.90), + angle: centralAngle + (rand(seed, 3341) - 0.5) * 0.08, + length: (0.48 + rand(seed, 3342) * 0.20) * lengthScale, + width: (0.11 + rand(seed, 3343) * 0.08) * widthScale, + height: template.mountainBaseHeight * heightScale * (0.38 + rand(seed, 3344) * 0.16), + scratchCount: Math.round(12 + rand(seed, 3345) * 8), + massifness: clamp((template.mountainMassifness ?? 0.16) * 0.65), + role: "central-secondary", + beltIndex: 0, + widthVariance: 0.24 + rand(seed, 3346) * 0.36, + phase: rand(seed, 3347), + }); + } } - for (let k = 0; k < targetCount; k++) { - let best = candidateAt(k, 0); + for (let k = systems.length; k < targetCount; k++) { + let best = beltCandidate(k, 0); let bestScore = -INF; - for (let attempt = 0; attempt < 18; attempt++) { - const c = candidateAt(k, attempt); + for (let attempt = 0; attempt < 12; attempt++) { + const c = beltCandidate(k, attempt); let minD = 999; for (const s of systems) minD = Math.min(minD, distNorm(c.x, c.y, s.x, s.y)); - // 中央集中を避けるため、中心距離を少し加点する。ただし端に張り付きすぎないよう edge も見る。 - const edgeD = Math.min(c.x, c.y, 1 - c.x, 1 - c.y); - const centerD = distNorm(c.x, c.y, 0.5, 0.5); - const score = - minD * 1.25 + - centerD * 0.18 + - Math.min(edgeD, 0.16) * 0.22 + - rand(seed, 2300 + k * 101 + attempt) * 0.04; + const score = minD * 1.05 + edgeAwareScore(c) + rand(seed, 2300 + k * 101 + attempt) * 0.035; if (score > bestScore) { bestScore = score; best = c; } } - const m = clamp(template.mountainMassifness + (rand(seed, 2400 + k) - 0.5) * 0.50); + const belt = belts[best.beltIndex]; + const m = clamp(template.mountainMassifness + (rand(seed, 2400 + k) - 0.5) * 0.38); const isMassif = m > 0.58; - const major = k < 4 || rand(seed, 2500 + k) > 0.68; + const major = k < beltCount || rand(seed, 2500 + k) > 0.72; + const lengthBaseRange = isChubu + ? (major ? [0.48, 0.78] : [0.34, 0.58]) + : isTohoku + ? (major ? [0.44, 0.72] : [0.28, 0.48]) + : (major ? [0.32, 0.52] : [0.21, 0.36]); + const lengthMassifRange = isChubu + ? (major ? [0.38, 0.58] : [0.28, 0.44]) + : (major ? [0.23, 0.35] : [0.17, 0.27]); const length = lerp( - major ? 0.30 + rand(seed, 2600 + k) * 0.22 : 0.20 + rand(seed, 2610 + k) * 0.16, - major ? 0.22 + rand(seed, 2620 + k) * 0.14 : 0.16 + rand(seed, 2630 + k) * 0.12, + lengthBaseRange[0] + rand(seed, 2600 + k) * (lengthBaseRange[1] - lengthBaseRange[0]), + lengthMassifRange[0] + rand(seed, 2620 + k) * (lengthMassifRange[1] - lengthMassifRange[0]), m - ); + ) * belt.lengthBias * lengthScale; + const widthRangeA = major ? [0.082, 0.170] : [0.060, 0.120]; + const widthRangeB = major ? [0.150, 0.260] : [0.110, 0.200]; const width = lerp( - major ? 0.055 + rand(seed, 2700 + k) * 0.060 : 0.040 + rand(seed, 2710 + k) * 0.045, - major ? 0.120 + rand(seed, 2720 + k) * 0.090 : 0.085 + rand(seed, 2730 + k) * 0.070, + widthRangeA[0] + rand(seed, 2700 + k) * (widthRangeA[1] - widthRangeA[0]), + widthRangeB[0] + rand(seed, 2720 + k) * (widthRangeB[1] - widthRangeB[0]), m - ); - const height = template.mountainBaseHeight * ( + ) * widthScale * (0.82 + rand(seed, 2740 + k) * 0.46); + const heightBase = template.mountainBaseHeight * belt.heightBias * heightScale * ( major - ? 0.34 + rand(seed, 2800 + k) * 0.24 - : 0.20 + rand(seed, 2810 + k) * 0.18 + ? 0.44 + rand(seed, 2800 + k) * 0.28 + : 0.26 + rand(seed, 2810 + k) * 0.20 ); + const height = isChubu ? heightBase * 0.82 : heightBase; const scratchCount = Math.round(lerp( - major ? 10 + rand(seed, 2900 + k) * 10 : 6 + rand(seed, 2910 + k) * 7, - isMassif ? 8 + rand(seed, 2920 + k) * 9 : 6 + rand(seed, 2930 + k) * 7, + major ? 9 + rand(seed, 2900 + k) * 9 : 6 + rand(seed, 2910 + k) * 6, + isMassif ? 8 + rand(seed, 2920 + k) * 8 : 6 + rand(seed, 2930 + k) * 6, m )); @@ -287,7 +530,10 @@ function buildMountainSystems(template, seed) { height, scratchCount, massifness: m, - role: major ? (k < 4 ? "primary" : "major") : "minor", + role: major ? (k < beltCount ? "primary" : "major") : "minor", + beltIndex: best.beltIndex, + widthVariance: 0.18 + rand(seed, 3100 + k) * 0.46, + phase: rand(seed, 3200 + k), }); } @@ -308,8 +554,8 @@ function buildScratchRidges(system, seed, systemId) { const x = clamp(system.x + Math.cos(system.angle) * along / ASPECT + Math.cos(system.angle + Math.PI / 2) * cross / ASPECT, 0.03, 0.97); const y = clamp(system.y + Math.sin(system.angle) * along + Math.sin(system.angle + Math.PI / 2) * cross, 0.03, 0.97); const len = lerp(system.length * (0.18 + rand(seed, 2200 + i) * 0.20), system.width * (0.32 + rand(seed, 2200 + i) * 0.30), system.massifness); - const width = lerp(0.010 + rand(seed, 2300 + i) * 0.012, 0.018 + rand(seed, 2300 + i) * 0.020, system.massifness) * (0.80 + density * 0.60); - const height = system.height * (0.040 + density * 0.095 + rand(seed, 2400 + i) * 0.035); + const width = lerp(0.014 + rand(seed, 2300 + i) * 0.018, 0.024 + rand(seed, 2300 + i) * 0.026, system.massifness) * (0.85 + density * 0.70); + const height = system.height * (0.060 + density * 0.128 + rand(seed, 2400 + i) * 0.050); ridges.push({ x, y, angle: localAngle, @@ -327,13 +573,45 @@ function buildScratchRidges(system, seed, systemId) { } function computeCoastLower(px, py, template, seed) { - const axis = (px - 0.5) * Math.cos(template.coastAngle) * ASPECT + (py - 0.5) * Math.sin(template.coastAngle); + const angle = template.coastAngle; + const axis = (px - 0.5) * Math.cos(angle) * ASPECT + (py - 0.5) * Math.sin(angle); + const cross = -(px - 0.5) * Math.sin(angle) * ASPECT + (py - 0.5) * Math.cos(angle); const wave = (fbm(px * 220, py * 220, seed + 300) - 0.5) * template.coastNoise; const bay = (valueNoise(px * 500, py * 500, seed + 301, 22) - 0.5) * 0.055; - const sideA = smoothstep((-axis + 0.24 + wave + bay) / 0.26); - const sideB = template.twoSidedCoast ? smoothstep((axis + 0.20 - wave + bay * 0.7) / 0.27) : 0; - const pressure = Math.max(sideA, sideB); - return { pressure, signedAxis: axis }; + const islandNoise = (fbm(px * 420 + 17, py * 420 - 11, seed + 302) - 0.5) * 0.032; + let pressure = 0; + + if (template.coastStyle === "inland_sea") { + // 瀬戸内型だけは中央を横切る浅い内海を許す。出現率は地形タイプ側で管理する。 + const sideA = smoothstep((-axis + 0.25 + wave + bay) / 0.26); + const sideB = smoothstep((axis + 0.23 - wave + bay * 0.7) / 0.27); + const channel = smoothstep((0.060 - Math.abs(cross + wave * 0.65 + islandNoise)) / 0.090) * 0.82; + pressure = Math.max(sideA, sideB, channel); + } else if (template.coastStyle === "parallel_spine") { + // 東北型: 左右端または上下端に海を置く。海岸線は脊梁山脈とおおよそ平行。 + // 内海的な中央水路は作らない。 + const edgeA = smoothstep((-axis - 0.26 + wave * 0.42 + bay * 0.35) / 0.20); + const edgeB = template.twoSidedCoast ? smoothstep((axis - 0.26 - wave * 0.42 + bay * 0.25) / 0.22) * 0.86 : 0; + pressure = Math.max(edgeA, edgeB); + } else if (template.coastStyle === "outer_coast") { + // 中部型: 外縁海を中心にし、内陸へ海が入り込みすぎないようにする。 + const radial = distNorm(px, py, 0.5, 0.5); + const outer = smoothstep((radial - 0.44 + wave * 0.8 + bay * 0.5) / 0.24); + const side = smoothstep((-axis + 0.30 + wave) / 0.30) * 0.45; + pressure = Math.max(outer, side); + } else if (template.coastStyle === "open_bay") { + // 関東・濃尾型: 一方向に開いた湾と、その背後の沖積平野を作りやすくする。 + const openSide = smoothstep((-axis + 0.29 + wave + bay) / 0.25); + const bayMouth = smoothstep((0.22 - Math.abs(cross + wave * 0.8)) / 0.25) * smoothstep((-axis + 0.16 + bay) / 0.22) * 0.68; + pressure = Math.max(openSide, bayMouth); + } else { + const sideA = smoothstep((-axis + 0.24 + wave + bay) / 0.26); + const sideB = template.twoSidedCoast ? smoothstep((axis + 0.20 - wave + bay * 0.7) / 0.27) : 0; + const outerBite = smoothstep((distNorm(px, py, 0.5, 0.5) - 0.54 + islandNoise) / 0.22) * 0.25; + pressure = Math.max(sideA, sideB, outerBite); + } + + return { pressure: clamp(pressure), signedAxis: axis }; } function recomputeSlope(elevation, sea, slope) { @@ -593,10 +871,10 @@ function deriveFields(seed, template, fields, seaLevel) { const ridge = clamp(arcSpineField[i] * 0.76 + branchRidgeField[i] * 0.86 + Math.max(0, relief) * 9.0 + slope[i] * 0.30 + Math.max(0, e - 0.54) * 0.88 - valleyField[i] * 0.34); ridgeField[i] = clamp(Math.max(ridgeField[i] * 0.30, ridge)); basinField[i] = clamp((0.42 - slope[i]) * 1.55 + Math.max(0, -relief) * 5.0 + clamp((0.48 - e) * 1.35) - coast * 0.40 - river[i] * 0.32); - const low = clamp((0.58 - e) * 1.55); + const low = clamp((0.52 - e) * 1.70); const lowSlope = clamp((0.34 - slope[i]) * 2.8); const riverGate = clamp(riverNear * 0.72 + flowAccum[i] * 0.82 + coast * 0.70 + basinField[i] * 0.20 - ridgeField[i] * 0.40); - plain[i] = clamp(low * lowSlope * (0.18 + template.plainBias * 0.42 + riverGate * 0.92)); + plain[i] = clamp(low * lowSlope * (0.12 + template.plainBias * 0.34 + riverGate * 0.84)); floodplain[i] = clamp(lowSlope * riverNear * (0.35 + flowAccum[i] * 0.82 + river[i] * 0.52)); deltaField[i] = clamp(coast * river[i] * 1.4 + coast * flowAccum[i] * 0.72); alluvialFanField[i] = clamp(riverNear * clamp(slope[i] * 2.5) * clamp((0.58 - e) * 1.7) * clamp(ridgeField[i] * 0.8 + arcSpineField[i] * 0.4)); @@ -675,13 +953,13 @@ export function generateTerrainAndRivers(seed) { const terrainLarge = (fbm(x * 0.65, y * 0.65, seed + 1) - 0.5) * 0.23; const terrainRegional = (valueNoise(x * 0.8, y * 0.8, seed + 2, 42) - 0.5) * 0.16; const { pressure: coastPressure } = computeCoastLower(px, py, terrainTemplate, seed); - let e = 0.42 + terrainLarge + terrainRegional - coastPressure * (0.22 + terrainTemplate.deposition * 0.040); + let e = 0.42 + terrainLarge + terrainRegional - coastPressure * (terrainTemplate.coastStrength ?? 0.96) * (0.22 + terrainTemplate.deposition * 0.040); let mountainMaskMax = 0; for (let s = 0; s < systems.length; s++) { const system = systems[s]; const mask = ellipticalMask(px, py, system); mountainMaskMax = Math.max(mountainMaskMax, mask); - const broad = Math.pow(mask, lerp(2.0, 1.35, system.massifness)) * system.height * lerp(0.13, 0.25, system.massifness); + const broad = Math.pow(mask, lerp(1.70, 1.16, system.massifness)) * system.height * lerp(0.22, 0.34, system.massifness); e += broad; arcSpineField[i] = Math.max(arcSpineField[i], mask * (system.role === "minor" ? 0.42 : system.role === "primary" ? 0.86 : 0.72)); } @@ -698,7 +976,7 @@ export function generateTerrainAndRivers(seed) { e += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80); e += global * 0.020; e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax; - elevation[i] = clamp(e, 0.025, 1.08); + elevation[i] = clamp(softCapElevation(e, terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91, 1.08), 0.025, 1.08); visibleRavineField[i] = clamp(Math.abs(scratch) * mountainMaskMax * 0.30 + Math.max(0, -scratch) * mountainMaskMax * 0.40); surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46); valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18); @@ -707,7 +985,7 @@ export function generateTerrainAndRivers(seed) { } let seaLevel = quantile(elevation, terrainTemplate.seaRatio); - seaLevel = clamp(seaLevel, 0.20, 0.47); + seaLevel = clamp(seaLevel, 0.14, 0.47); classifyWater(elevation, seaLevel, sea, ocean, lake); recomputeSlope(elevation, sea, slope); @@ -721,8 +999,9 @@ export function generateTerrainAndRivers(seed) { const prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river); const regional = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask); const prefectureRegionId = regional.regionId; + const adminPrefectureRegionId = regional.displayRegionId || regional.regionId; const regionalDebug = regional.debug; - const regionalPrefectureBorders = extractRegionBorderSegments(prefectureRegionId, sea); + const regionalPrefectureBorders = extractRegionBorderSegments(adminPrefectureRegionId, sea); const prefectureBorder = extractMaskBorder(prefectureMask, sea); let landCount = 0; @@ -734,12 +1013,15 @@ export function generateTerrainAndRivers(seed) { for (let i = 0; i < SIZE; i++) { if (sea[i]) { waterCount++; continue; } landCount++; - if (elevation[i] > 0.60 || ridgeField[i] > 0.58) mountainCount++; + if (elevation[i] > 0.56 || ridgeField[i] > 0.52) mountainCount++; if (plain[i] > 0.36) plainCount++; if (arcSpineField[i] > 0.55) { primarySpineStrength += arcSpineField[i]; spineSamples++; } } primarySpineStrength /= Math.max(1, spineSamples); const terrainDebug = { + terrainType: terrainTemplate.terrainType, + terrainTypeLabel: terrainTemplate.terrainTypeLabel, + coastStyle: terrainTemplate.coastStyle, primarySpineStrength, riverConnectivityRate: mainRivers.length ? mainRivers.filter((path) => path.some(([x, y], k) => k > path.length * 0.45 && sea[indexOf(x, y)])).length / mainRivers.length : 0, smallIslandCount: 0, @@ -790,6 +1072,7 @@ export function generateTerrainAndRivers(seed) { prefectureMask, prefectureBorder, prefectureRegionId, + adminPrefectureRegionId, regionalDebug, terrainDebug, regionalPrefectureBorders, diff --git a/renderer.js b/renderer.js index 22d4186..f0e2eea 100644 --- a/renderer.js +++ b/renderer.js @@ -358,42 +358,45 @@ function terrainColorContinuous(map, fx, fy, mode) { } function terrainShadeContinuous(map, fx, fy) { - const eL = fieldSample(map.elevation, fx - 0.6, fy); - const eR = fieldSample(map.elevation, fx + 0.6, fy); - const eU = fieldSample(map.elevation, fx, fy - 0.6); - const eD = fieldSample(map.elevation, fx, fy + 0.6); + const step = 0.50; + const eC = fieldSample(map.elevation, fx, fy); + const eL = fieldSample(map.elevation, fx - step, fy); + const eR = fieldSample(map.elevation, fx + step, fy); + const eU = fieldSample(map.elevation, fx, fy - step); + const eD = fieldSample(map.elevation, fx, fy + step); // x は東向き, y は南向き。法線は (-dz/dx, -dz/dy, 1)。 - // 光源は北西上空(日本の地形表現で一般的な見え方)。 - const dzdx = (eR - eL) / 1.2; - const dzdy = (eD - eU) / 1.2; - const nx = -dzdx * 2.5; - const ny = -dzdy * 2.5; + // 描画では地形生成上の差分をやや誇張し、粗いDEMでも山腹の起伏を読ませる。 + const dzdx = (eR - eL) / (step * 2); + const dzdy = (eD - eU) / (step * 2); + const nx = -dzdx * 4.4; + const ny = -dzdy * 4.4; const nz = 1.0; const nLen = Math.hypot(nx, ny, nz) || 1; const lx = -0.5; const ly = -0.5; const lz = 0.7071067811865476; - const hill = clamp((nx * lx + ny * ly + nz * lz) / nLen * 0.5 + 0.5); + const hill = clamp((nx * lx + ny * ly + nz * lz) / nLen * 0.58 + 0.48); const slope = map.slope ? fieldSample(map.slope, fx, fy) : 0; const valley = map.valleyField ? fieldSample(map.valleyField, fx, fy) : 0; const ravine = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy) : 0; const tex = map.surfaceTextureField ? fieldSample(map.surfaceTextureField, fx, fy) : 0; - const rvL = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx - 0.75, fy) : 0; - const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.75, fy) : 0; - const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.75) : 0; - const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.75) : 0; - const ravineRelief = (rvL - rvR) * 0.16 + (rvU - rvD) * 0.12; + const rvL = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx - 0.90, fy) : 0; + const rvR = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx + 0.90, fy) : 0; + const rvU = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy - 0.90) : 0; + const rvD = map.visibleRavineField ? fieldSample(map.visibleRavineField, fx, fy + 0.90) : 0; + const ravineRelief = (rvL - rvR) * 0.26 + (rvU - rvD) * 0.20; + const concavity = clamp(((eL + eR + eU + eD) * 0.25 - eC) * 9.0, -0.18, 0.18); - // 谷底の低傾斜面では陰影を少し圧縮し、標高色がそのまま見えるようにする。 - const valleyFloor = clamp((valley - 0.16) * 1.8) * clamp((0.28 - slope) * 4.5); - let shade = 0.76 + hill * 0.32 + ravineRelief - ravine * 0.08 - tex * 0.028; - if (shade < 1) shade = 1 - (1 - shade) * (1 - valleyFloor * 0.52); - else shade = 1 + (shade - 1) * (1 - valleyFloor * 0.20); + // 谷底の色保持は少し残すが、以前より圧縮を弱めて陰影の振幅を大きくする。 + const valleyFloor = clamp((valley - 0.18) * 1.45) * clamp((0.24 - slope) * 3.6); + let shade = 0.66 + hill * 0.50 + ravineRelief - ravine * 0.12 - tex * 0.042 - concavity * 0.16 + slope * 0.030; + if (shade < 1) shade = 1 - (1 - shade) * (1 - valleyFloor * 0.26); + else shade = 1 + (shade - 1) * (1 - valleyFloor * 0.12); - return clamp(shade, 0.66, 1.13); + return clamp(shade, 0.54, 1.26); } function discreteColor(map, x, y, mode) { @@ -404,16 +407,16 @@ function discreteColor(map, x, y, mode) { color = [160, 205, 239]; } else if (mode === "landuse") { const colors = { - 0: [242, 248, 238], - 1: [248, 250, 245], - 2: [240, 238, 232], - 3: [245, 230, 220], - 4: [250, 248, 245], - 5: [235, 235, 240], - 6: [240, 245, 240], - 7: [245, 248, 252], - 8: [250, 248, 240], - 9: [240, 245, 238], + 0: [242, 248, 238], // rural / natural land + 1: [238, 246, 222], // farmland + 2: [240, 238, 232], // old urban + 3: [245, 230, 220], // CBD / DID core + 4: [250, 248, 245], // suburb + 5: [235, 235, 240], // industrial + 6: [240, 245, 240], // logistics + 7: [245, 248, 252], // new town + 8: [250, 248, 240], // roadside + 9: [225, 238, 220], // forest / mountain land }; color = colors[map.landuse[i]] || colors[0]; } else if (mode === "admin") { @@ -606,7 +609,8 @@ function drawDebugCells(ctx, map, field, color) { for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); - if (!map.prefectureMask[i] || map.sea[i]) continue; + const debugMask = map.humanRegionMask || map.prefectureMask; + if (!debugMask[i] || map.sea[i]) continue; const v = clamp(field[i] || 0, 0, 1); if (v <= 0.12) continue; ctx.fillStyle = color(v); @@ -756,8 +760,10 @@ export function drawMap(canvas, map, options) { drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 }); } if (mode === "admin-debug" || mode === "borders-debug") { - drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => `rgba(255, 120, 40, ${0.06 + v * 0.18})`); - if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(60, 110, 170, 0.42)", 0.8, true); + // Keep the natural barrier heatmap subtle. A dense cell fill can look like + // artificial horizontal hatching, so only strong terrain dividers are shown. + drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => v < 0.42 ? "rgba(0,0,0,0)" : `rgba(255, 120, 40, ${0.025 + v * 0.075})`); + if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(45, 95, 160, 0.72)", 1.0, true); for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)"); if (map.regionalPrefectureBorders) drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 }); } @@ -818,6 +824,7 @@ export function drawMap(canvas, map, options) { const popRadius = p.population ? Math.min(8.5, 3.5 + Math.sqrt(p.population) / 400) : 4.5; dot(ctx, p, popRadius, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.9)"); if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 3.0, "transparent", "rgba(200,80,80,0.9)"); + else if (p.isRegionalCapital) dot(ctx, p, popRadius + 2.2, "transparent", "rgba(190,95,95,0.62)"); } for (const p of map.interchanges || []) dot(ctx, p, 2.7, "rgba(250, 250, 245, 0.95)", "rgba(95, 125, 95, 0.95)"); if (mode === "admin-debug" || mode === "borders-debug") { @@ -832,14 +839,14 @@ export function drawMap(canvas, map, options) { return; } if (mode === "admin-debug" || mode === "borders-debug") { - drawLabels(ctx, [...(map.adminCenters || []), ...(map.externalGateways || [])], Infinity); + drawLabels(ctx, map.adminCenters || [], Infinity); return; } const important = [ ...map.modernCities, ...map.ports, ...(map.satelliteCities || []), - ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); + ].filter((p) => p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway"); drawLabels(ctx, important, 60); } }