From e48fae55097319e6de379f13c92eb46cf85e5298 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Fri, 22 May 2026 13:57:52 +0900 Subject: [PATCH] before tweaking --- adminRegions.js | 254 ++++++++++++++++++-- mapAdminStage.js | 534 ++++++++++++++++++++++++++++++++++++----- mapFeatures.js | 266 ++++++++++++++------ mapGeneratorHelpers.js | 1 + mapOutput.js | 44 +--- names.js | 5 +- renderer.js | 66 ++--- test.js | 50 +++- 8 files changed, 977 insertions(+), 243 deletions(-) diff --git a/adminRegions.js b/adminRegions.js index 6bbd906..b823245 100644 --- a/adminRegions.js +++ b/adminRegions.js @@ -532,6 +532,26 @@ export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, 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; @@ -546,6 +566,94 @@ function canShareNaturalCompartment(a, b, classA, classB, barrier, river, flowAc 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; + for (const i of unit.cells) { + const [x, y] = xyOf(i); + sx += x; sy += y; pop += populationDensity[i]; + 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.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 < 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; + 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; + 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 d = Math.hypot(x - fx, y - fy); + const score = d * (0.55 + low * 0.45) + hashSeededTie(x, y, seed + 17) * 0.20; + 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 ni = indexOf(nx, ny); + if (!cellSet.has(ni) || localOwner.has(ni)) continue; + localOwner.set(ni, owner); + queue.push(ni); + } + } + 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 = [], bCells = []; + for (const ci of unit.cells) (localOwner.get(ci) === 0 ? aCells : bCells).push(ci); + if (aCells.length < 10 || bCells.length < 10) 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)}`; @@ -555,7 +663,7 @@ function naturalGroupKey(unit) { return `plain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`; } -export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null) { +export function buildNaturalCompartments(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 compartmentId = new Int32Array(SIZE); compartmentId.fill(-1); @@ -595,7 +703,7 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, } } const area = cells.length; - compartments.push({ + const unit = { id, cells, area, @@ -612,11 +720,34 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, 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 }; + let guard = targetCount * 3; + 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)); + const target = candidates[0]; + if (!target) break; + const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard); + if (!newUnit) { + target.lowlandFitness = 0; + continue; + } + 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 }; } @@ -725,6 +856,107 @@ function naturalOwnershipAffinity(unit, neighbor, edge) { 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; @@ -822,11 +1054,11 @@ export function extractCompartmentBorders(compartmentId, prefectureMask, sea) { return segments; } -export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) { - const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse); +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 = assignCompartmentsToAdminOwners(compartments, compartmentId, adminCenters, naturalBarrierScore, prefectureMask, sea); + const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options); for (const unit of compartments) { const assigned = owner[unit.id]; if (assigned < 0) continue; @@ -837,22 +1069,16 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e 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; - const i = indexOf(center.x, center.y); - if (prefectureMask[i] && !sea[i]) adminId[i] = id; - } 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: { - naturalCompartmentCount: activeCompartments.length, - compartmentCount: activeCompartments.length, + ...relationMetrics, compartmentBorders: extractCompartmentBorders(compartmentId, prefectureMask, sea), averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0, finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore), diff --git a/mapAdminStage.js b/mapAdminStage.js index 988e0e9..d596b23 100644 --- a/mapAdminStage.js +++ b/mapAdminStage.js @@ -26,6 +26,257 @@ function municipalityAreaById(adminId, prefectureMask, sea) { 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; @@ -58,6 +309,118 @@ function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, 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); @@ -252,53 +615,40 @@ export function generateAdminLayout({ const boundaryRidgeField = naturalBarrierScore ? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46)) : ridgeField; - const municipalityCandidates = []; - for (let y = 2; y < MAP_H - 2; y++) { - for (let x = 2; x < MAP_W - 2; x++) { - const i = indexOf(x, y); - if (!prefectureMask[i] || sea[i]) continue; - const urbanBias = landuse[i] === 3 ? 0.62 : landuse[i] === 2 ? 0.56 : landuse[i] === 4 ? 0.5 : landuse[i] === 1 ? 0.4 : 0.28; - const score = urbanBias + settlementScore[i] * 0.22 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.05 + villageInfluence[i] * 0.04 - slope[i] * 0.18 - ridgeField[i] * 0.06 + hash2(x, y, seed + 1300) * 0.025; - if (score > 0.40) municipalityCandidates.push({ x, y, score }); - } - } - const majorMunicipalSeeds = modernCities - .filter((city) => (city.population || 0) >= 220000 && prefectureMask[indexOf(city.x, city.y)]) - .map((city) => ({ x: city.x, y: city.y, score: 1.55 + (city.population || 0) / 700000, protectedCity: city })); - const filteredMunicipalityCandidates = municipalityCandidates.filter((p) => { - const nearMajor = majorMunicipalSeeds.some((city) => Math.hypot(city.x - p.x, city.y - p.y) < clamp(12 + Math.sqrt(city.protectedCity.population || 300000) / 130, 14, 28)); - const nearSmallUrban = modernCities.some((city) => (city.population || 0) < 260000 && Math.hypot(city.x - p.x, city.y - p.y) < 8 && p.x !== city.x && p.y !== city.y); - return !nearMajor && !nearSmallUrban; - }); const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum); const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }); - const satelliteMunicipalSeeds = (satelliteCities || []) - .filter((city) => prefectureMask[indexOf(city.x, city.y)] && city.municipalityClass === "independentSatelliteMunicipality") - .map((city) => ({ x: city.x, y: city.y, score: 1.05 + (city.population || 40000) / 260000, protectedSatellite: city })); - let adminCentersRaw = [ - ...majorMunicipalSeeds, - ...satelliteMunicipalSeeds, - ...pickEntities(filteredMunicipalityCandidates.filter((p) => satelliteMunicipalSeeds.every((s) => Math.hypot(s.x - p.x, s.y - p.y) >= 6)), { - max: Math.max(0, targetMunicipalityCount - majorMunicipalSeeds.length - satelliteMunicipalSeeds.length), - minDistance: 6 + Math.floor(rand(seed, 1302) * 3), - threshold: 0.34, - seed: seed + 1300, - jitter: 0.025, - }), - ]; - if (adminCentersRaw.length < Math.min(targetMunicipalityCount, 18)) { - const fallback = [...modernCities, ...(satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality"), ...markets, ...ports, ...newTowns, ...stations, ...villages] - .filter((p) => prefectureMask[indexOf(p.x, p.y)]) - .map((p) => ({ x: p.x, y: p.y, score: (p.score || 0.5) + (p.population || 0) / 900000 })); - const extraFallback = pickEntities(fallback, { max: targetMunicipalityCount, minDistance: 5, threshold: 0, seed: seed + 1303 }); - for (const p of extraFallback) if (adminCentersRaw.length < targetMunicipalityCount && adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4)) adminCentersRaw.push(p); - } - if (adminCentersRaw.length < targetMunicipalityCount) { - const extra = pickEntities(municipalityCandidates, { max: targetMunicipalityCount - adminCentersRaw.length, minDistance: 5, threshold: 0.26, seed: seed + 1304 }); - adminCentersRaw.push(...extra.filter((p) => adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4))); - } - if (adminCentersRaw.length > targetMunicipalityCount) adminCentersRaw = adminCentersRaw.slice(0, targetMunicipalityCount); - const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw); + const compartmentMultiplier = clamp(3.5 + rand(seed, 1320) * 2.0, 3.5, 5.5); + let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 80, 240); + 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, + }); const adminId = compartmentAssignment.adminId; let previousSnapshot = new Int16Array(adminId); const adminDebug = { @@ -320,7 +670,25 @@ export function generateAdminLayout({ oversizedLowlandSplits: 0, ruralSplitsAccepted: 0, ruralSplitsRejected: 0, - satelliteMunicipalitiesCreated: satelliteMunicipalSeeds.length, + 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, @@ -332,6 +700,27 @@ export function generateAdminLayout({ 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); @@ -344,6 +733,7 @@ export function generateAdminLayout({ 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; } }); @@ -375,6 +765,7 @@ export function generateAdminLayout({ 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; } }); @@ -393,20 +784,13 @@ export function generateAdminLayout({ lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 520); lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620); markChanged("changedAfterSmallUrbanLock"); - mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: adminCentersRaw }); + 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"); - applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw); - for (const sat of satelliteCities || []) { - if (sat.municipalityClass !== "independentSatelliteMunicipality" || !inside(sat.x, sat.y)) continue; - const targetAdmin = adminId[indexOf(sat.x, sat.y)]; - if (targetAdmin < 0) continue; - expandSatelliteMunicipalityCatchment(adminId, sat, targetAdmin, { - prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, - landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities, - }); - } + // 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; @@ -418,9 +802,9 @@ export function generateAdminLayout({ 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, 360); + removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 360); markChanged("changedAfterFinalExclaveRemoval"); - mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 80, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 90, protectedPoints: adminCentersRaw }); + mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 80, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 90, protectedPoints: activeAdminCenters() }); markChanged("changedAfterFinalMerge"); for (const sat of satelliteCities || []) { @@ -432,7 +816,22 @@ export function generateAdminLayout({ landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities, }); } - removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 260); + 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 = []; @@ -442,7 +841,7 @@ export function generateAdminLayout({ const area = areaById.get(id) || 0; const key = sat.name || `satellite-${index}`; adminDebug.satelliteMunicipalityAreaByNameOrIndex[key] = area; - if (sat.municipalityClass === "independentSatelliteMunicipality" && (area < 80 || ((sat.population || 0) >= 60000 && area < 120))) { + if (sat.municipalityClass === "independentSatelliteMunicipality" && (area < Math.max(120, sat.satelliteMinArea || 0) || ((sat.population || 0) >= 60000 && area < 150))) { sat.municipalityClass = "smallTownAttachedToRuralMunicipality"; adminDebug.satelliteMunicipalitiesTooSmall++; return; @@ -457,8 +856,19 @@ export function generateAdminLayout({ adminDebug.satelliteMunicipalitiesIndependent = satelliteAreas.length; const landscapeDebug = applyLandscapeUnitAdminPartition.lastDebug || {}; Object.assign(adminDebug, landscapeDebug); - adminDebug.naturalCompartmentCount = adminDebug.naturalCompartmentCount || adminDebug.compartmentCount || 0; + 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); diff --git a/mapFeatures.js b/mapFeatures.js index c8ef326..32f84de 100644 --- a/mapFeatures.js +++ b/mapFeatures.js @@ -440,8 +440,8 @@ export function generateMapFeatures(seed, terrain) { function transportAccessPoint(node, mode = "road", salt = 0) { if (!node || nearMapEdge(node.x, node.y, 1) || node.kind === "External Gateway") return node; - const minR = mode === "express" ? 10 : mode === "rail" ? 2 : 4; - const maxR = mode === "express" ? 20 : mode === "rail" ? 6 : 10; + const minR = mode === "express" ? 6 : mode === "rail" ? 2 : 4; + const maxR = mode === "express" ? 16 : mode === "rail" ? 6 : 10; let best = null; let bestScore = -INF; for (let dy = -maxR; dy <= maxR; dy++) { @@ -460,7 +460,7 @@ export function generateMapFeatures(seed, terrain) { const ring = -Math.abs(d - targetD) * 0.08; const riverPenalty = river[i] > 0.5 ? 0.45 : river[i] * 0.12; const density = densityValue(x, y); - const densityAffinity = mode === "rail" ? density * 0.9 : mode === "express" ? midDensityAffinity(x, y) * 0.52 - Math.max(0, density - 0.72) * 0.9 : density * 0.24; + const densityAffinity = mode === "rail" ? density * 0.9 : mode === "express" ? density * 0.70 + midDensityAffinity(x, y) * 0.18 - Math.max(0, density - 0.92) * 0.35 : density * 0.24; const noise = hash2(x, y, seed + salt + (mode === "rail" ? 6000 : mode === "express" ? 7000 : 5000)) * 0.12; const score = flatness + densityAffinity + ring - riverPenalty - barrier * 0.012 + noise; if (score > bestScore) { @@ -601,10 +601,12 @@ export function generateMapFeatures(seed, terrain) { const density = densityValue(x, y); const midDensity = midDensityAffinity(x, y); const cityDistance = distanceToNearest(modernCities, x, y); - const cityAvoid = cityDistance < 5 ? 22.0 : cityDistance < 9 ? 11.0 : cityDistance < 13 ? 4.0 : distanceToNearest(markets, x, y) < 4 ? 3.2 : 0; - const densityPenalty = density > 0.66 ? (density - 0.66) * 9.5 : density < 0.08 ? (0.08 - density) * 2.4 : 0; - const highPenalty = barrier + (elevation[i] > 0.72 ? 26 : elevation[i] > 0.62 ? 8.5 : 0); - return Math.max(0.42, 1 + slope[i] * 23.0 + highPenalty + cityAvoid + densityPenalty + (river[i] > 0.45 ? 1.0 : 0) - midDensity * 0.82 - plain[i] * 0.16 - valleyField[i] * 0.16 - coastalLowland[i] * 0.18 + ridgeField[i] * 1.20 + normalEdgePenalty(x, y) + hash2(x, y, seed + 444) * 0.015); + const coreAvoid = cityDistance < 2.2 ? 18.0 : cityDistance < 4.5 ? 7.0 : cityDistance < 7.5 ? 2.0 : 0; + const marketAvoid = distanceToNearest(markets, x, y) < 2.5 ? 1.8 : 0; + const lowDensityPenalty = density < 0.10 ? (0.10 - density) * 4.8 : 0; + const urbanCorridorBonus = density * 1.18 + midDensity * 0.28 + (cityDistance >= 4 && cityDistance <= 16 ? 0.40 : 0); + const constructionCost = 0.58 + slope[i] * 28.0 + barrier * 1.10 + Math.max(0, elevation[i] - 0.60) * 16.0 + ridgeField[i] * 1.45 + (river[i] > 0.45 ? 1.15 : river[i] * 0.45); + return Math.max(0.50, 1.18 + constructionCost + coreAvoid + marketAvoid + lowDensityPenalty - urbanCorridorBonus - plain[i] * 0.12 - valleyField[i] * 0.12 - coastalLowland[i] * 0.12 + normalEdgePenalty(x, y) + hash2(x, y, seed + 444) * 0.015); } const nationalRoads = []; @@ -648,8 +650,8 @@ export function generateMapFeatures(seed, terrain) { const path = aStar(start, goal, makeTransportCost(roadCost, existing, roadHubs, [start, goal], 3, 5.8, townAvoidNodes, 3.2, 5.4)); const direct = pathEndpointDistance(path); const urbanPasses = modernCities.filter((city) => path.some(([x, y]) => Math.hypot(x - city.x, y - city.y) <= Math.max(6, Math.min(13, (city.urbanRadius || 8) * 0.78)))).length; - const passBonusOk = urbanPasses >= 2 || direct >= 24; - if (path.length > 3 && direct >= 16 && pathLength(path) >= 20 && pathCompactness(path) < 3.35 && pathOverlapRatio(path, existing, 2) < 0.48 && passBonusOk) { + const passBonusOk = urbanPasses >= 1 || direct >= 18; + if (path.length > 3 && direct >= 12 && pathLength(path) >= 14 && pathCompactness(path) < 4.20 && pathOverlapRatio(path, existing, 2) < 0.78 && passBonusOk) { nationalRoads.push(path); incrementDegree(roadDegree, a); incrementDegree(roadDegree, b); @@ -706,12 +708,37 @@ export function generateMapFeatures(seed, terrain) { const expressDegree = new Map(); const expressCore = [capital]; + function snapPathToExistingExpressways(path, existingPaths, radius = 2.4) { + if (!path?.length || !existingPaths?.length) return path || []; + const snapped = []; + const skipEnd = Math.min(5, Math.floor(path.length / 5)); + for (let pi = 0; pi < path.length; pi++) { + const [x, y] = path[pi]; + let best = null; + let bestD = radius; + if (pi >= skipEnd && pi < path.length - skipEnd) { + for (const existing of existingPaths) { + for (const [ex, ey] of existing) { + const d = Math.hypot(x - ex, y - ey); + if (d < bestD) { bestD = d; best = [ex, ey]; } + } + } + } + const next = best || [x, y]; + const last = snapped[snapped.length - 1]; + if (!last || last[0] !== next[0] || last[1] !== next[1]) snapped.push(next); + } + return snapped; + } + + function addExpressway(a, b, bucket = expressways) { const start = routePoint(a, "express", a.x * 41 + a.y * 43); const goal = routePoint(b, "express", b.x * 41 + b.y * 43 + 29); const existing = [...expressways, ...nationalRoads, ...railways, ...branchRailways]; let path = aStar(start, goal, makeTransportCost(expresswayCost, existing, roadHubs, [start, goal], 5, 10.8, townAvoidNodes, 8.5, 14.0)); path = smoothPathByLineOfSight(path, (x, y) => expresswayCost(x, y, x, y) < INF && slope[indexOf(x, y)] < 0.54 && elevation[indexOf(x, y)] < 0.78, 10); + path = snapPathToExistingExpressways(path, expressways, 2.6); const direct = pathEndpointDistance(path); if (path.length > 8 && direct >= 26 && pathLength(path) >= 30 && pathCompactness(path) < 2.35 && pathOverlapRatio(path, existing, 2) < 0.30) { bucket.push(path); @@ -888,6 +915,16 @@ export function generateMapFeatures(seed, terrain) { threshold: 0.4, seed: seed + 1201, }).map((p) => ({ ...p, kind: "External Gateway" })); + if (externalGateways.length < 2) { + const fallbackGateways = gatewayCandidates + .slice() + .sort((a, b) => b.score - a.score); + for (const gate of fallbackGateways) { + if (externalGateways.some((p) => Math.hypot(p.x - gate.x, p.y - gate.y) < 30)) continue; + externalGateways.push({ ...gate, kind: "External Gateway" }); + if (externalGateways.length >= 2) break; + } + } function externalRoadCost(goal) { return (x, y) => { @@ -910,9 +947,11 @@ export function generateMapFeatures(seed, terrain) { const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 7 : nearMapEdge(x, y, 3) ? 1.5 : 0; const density = densityValue(x, y); const cityDistance = distanceToNearest(modernCities, x, y); - const cityAvoid = cityDistance < 5 ? 22.0 : cityDistance < 9 ? 11.0 : cityDistance < 13 ? 4.0 : distanceToNearest(markets, x, y) < 4 ? 3.2 : 0; - const densityPenalty = density > 0.66 ? (density - 0.66) * 9.5 : density < 0.08 ? (0.08 - density) * 2.4 : 0; - return Math.max(0.42, 1 + slope[i] * 19 + barrier + cityAvoid + densityPenalty + (river[i] > 0.45 ? 1 : 0) + floodplain[i] * 0.2 - midDensityAffinity(x, y) * 0.7 - plain[i] * 0.12 + borderPenalty + hash2(x, y, seed + 444) * 0.05); + const coreAvoid = cityDistance < 2.2 ? 16.0 : cityDistance < 4.5 ? 6.0 : cityDistance < 7.5 ? 1.8 : 0; + const lowDensityPenalty = density < 0.10 ? (0.10 - density) * 4.2 : 0; + const urbanCorridorBonus = density * 1.04 + midDensityAffinity(x, y) * 0.24 + (cityDistance >= 4 && cityDistance <= 16 ? 0.32 : 0); + const constructionCost = 0.55 + slope[i] * 23.0 + barrier * 1.06 + Math.max(0, elevation[i] - 0.60) * 12.0 + ridgeField[i] * 1.20 + (river[i] > 0.45 ? 1 : river[i] * 0.38); + return Math.max(0.50, 1.14 + constructionCost + coreAvoid + lowDensityPenalty + floodplain[i] * 0.18 + borderPenalty - urbanCorridorBonus - plain[i] * 0.10 + hash2(x, y, seed + 444) * 0.04); }; } function externalRailCost(goal) { @@ -945,7 +984,8 @@ export function generateMapFeatures(seed, terrain) { const roadStart = routePoint(roadStartRaw, makeExpressLink ? "express" : "road", gate.x * 53 + gate.y * 59); const roadExisting = [...nationalRoads, ...expressways, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways]; const roadBaseCost = makeExpressLink ? externalExpresswayCost(gate) : externalRoadCost(gate); - const roadPath = aStar(roadStart, gate, makeTransportCost(roadBaseCost, roadExisting, roadHubs, [roadStart, gate], makeExpressLink ? 4 : 3, makeExpressLink ? 8.2 : 6.2, townAvoidNodes, makeExpressLink ? 5.4 : 3.2, makeExpressLink ? 7.8 : 5.6)); + let roadPath = aStar(roadStart, gate, makeTransportCost(roadBaseCost, roadExisting, roadHubs, [roadStart, gate], makeExpressLink ? 4 : 3, makeExpressLink ? 8.2 : 6.2, townAvoidNodes, makeExpressLink ? 5.4 : 3.2, makeExpressLink ? 7.8 : 5.6)); + if (makeExpressLink) roadPath = snapPathToExistingExpressways(roadPath, [...expressways, ...externalExpressways], 2.6); if (roadPath.length > 6) { if (makeExpressLink) { externalExpressways.push(roadPath); @@ -971,68 +1011,113 @@ export function generateMapFeatures(seed, terrain) { } }); - const requiredTransportNodes = []; - function addRequiredTransportNode(node, reason) { - if (!node || !inside(node.x, node.y) || sea[indexOf(node.x, node.y)]) return; - const key = `${node.x},${node.y}`; - if (requiredTransportNodes.some((p) => `${p.x},${p.y}` === key)) return; - requiredTransportNodes.push({ ...node, requiredTransportReason: reason }); + let throughExpresswayAdded = false; + function throughExpresswayCost(a, b) { + return (x, y) => { + const i = indexOf(x, y); + if (sea[i]) return INF; + const barrier = mountainBarrierPenalty(x, y, "express"); + if (barrier >= INF) return INF; + const nearEndpoint = Math.min(Math.hypot(x - a.x, y - a.y), Math.hypot(x - b.x, y - b.y)) <= 3; + const borderPenalty = nearEndpoint ? 0 : nearMapEdge(x, y, 3) ? 2.2 : 0; + const density = densityValue(x, y); + const cityDistance = distanceToNearest(modernCities, x, y); + const coreAvoid = cityDistance < 2.0 ? 12.0 : cityDistance < 4.5 ? 4.8 : cityDistance < 7.5 ? 1.4 : 0; + const lowDensityPenalty = density < 0.08 ? (0.08 - density) * 4.0 : 0; + const urbanCorridorBonus = density * 1.00 + midDensityAffinity(x, y) * 0.22 + (cityDistance >= 4 && cityDistance <= 18 ? 0.34 : 0); + return Math.max(0.48, 1.16 + slope[i] * 21.0 + barrier * 1.08 + Math.max(0, elevation[i] - 0.62) * 13.0 + coreAvoid + lowDensityPenalty + (river[i] > 0.45 ? 1.0 : 0) + borderPenalty - urbanCorridorBonus - plain[i] * 0.14 - coastalLowland[i] * 0.12 + hash2(x, y, seed + 9101) * 0.03); + }; } - addRequiredTransportNode(capital, "capital"); - for (const gate of externalGateways) addRequiredTransportNode(gate, "externalGateway"); - for (const city of modernCities) if ((city.population || 0) >= 120000 || city.isPrefecturalCapital) addRequiredTransportNode(city, "majorCity"); - for (const port of majorPorts) addRequiredTransportNode(port, "majorPort"); - const backboneAccess = new Map(); - function nodeKey(p) { - return `${p.x},${p.y}`; + function permissiveThroughExpresswayCost(a, b) { + return (x, y) => { + const i = indexOf(x, y); + if (sea[i]) return 24 + nearMapEdge(x, y, 2) * 2 + hash2(x, y, seed + 9202) * 0.2; + const rawBarrier = mountainBarrierPenalty(x, y, "express"); + const tunnelBarrier = rawBarrier >= INF ? 120 + Math.max(0, elevation[i] - 0.66) * 260 + slope[i] * 55 : rawBarrier; + const nearEndpoint = Math.min(Math.hypot(x - a.x, y - a.y), Math.hypot(x - b.x, y - b.y)) <= 3; + const borderPenalty = nearEndpoint ? 0 : nearMapEdge(x, y, 3) ? 2.0 : 0; + const cityDistance = distanceToNearest(modernCities, x, y); + const coreAvoid = cityDistance < 2.0 ? 9.0 : cityDistance < 4.5 ? 3.5 : 0; + const density = densityValue(x, y); + const urbanCorridorBonus = density * 0.85 + midDensityAffinity(x, y) * 0.18 + (cityDistance >= 4 && cityDistance <= 18 ? 0.24 : 0); + return Math.max(0.52, 1.18 + slope[i] * 14.0 + tunnelBarrier * 0.42 + Math.max(0, elevation[i] - 0.66) * 18.0 + coreAvoid + borderPenalty - urbanCorridorBonus - plain[i] * 0.10 + hash2(x, y, seed + 9201) * 0.035); + }; } - function backbonePoint(node) { - const key = nodeKey(node); - if (!backboneAccess.has(key)) { - const mode = node.requiredTransportReason === "externalGateway" ? "road" : "road"; - backboneAccess.set(key, routePoint(node, mode, 18000 + node.x * 97 + node.y * 101)); + + function pointToSegmentDistance(p, a, b) { + const vx = b.x - a.x; + const vy = b.y - a.y; + const len2 = vx * vx + vy * vy; + if (len2 <= 0.0001) return Math.hypot(p.x - a.x, p.y - a.y); + const t = clamp(((p.x - a.x) * vx + (p.y - a.y) * vy) / len2, 0, 1); + return Math.hypot(p.x - (a.x + vx * t), p.y - (a.y + vy * t)); + } + + function chooseThroughExpresswayVia(a, b) { + const candidates = [capital, ...modernCities.filter((city) => (city.population || 0) >= 90000)]; + let best = null; + let bestScore = -INF; + for (const city of candidates) { + if (!city || !prefectureMask[indexOf(city.x, city.y)] || sea[indexOf(city.x, city.y)]) continue; + const access = routePoint(city, "express", city.x * 73 + city.y * 79 + 9301); + const lineD = pointToSegmentDistance(access, a, b); + const density = densityValue(access.x, access.y); + const popScore = Math.sqrt(Math.max(0, city.population || 0)) / 520; + const score = density * 2.8 + popScore + (city.isPrefecturalCapital ? 0.9 : 0) - lineD / 38 - mountainBarrierPenalty(access.x, access.y, "express") * 0.004; + if (score > bestScore) { bestScore = score; best = access; } } - return backboneAccess.get(key); + return best; } - function addBackboneRoad(a, b) { - const start = backbonePoint(a); - const goal = backbonePoint(b); - const existing = [...nationalRoads, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways]; - const path = aStar(start, goal, makeTransportCost(roadCost, existing, roadHubs, [start, goal], 2, 4.2, townAvoidNodes, 2.6, 4.2)); - if (path.length <= 3 || pathCompactness(path) > 3.8 || path.some(([x, y]) => elevation[indexOf(x, y)] > 0.72)) return false; - nationalRoads.push(path); - incrementDegree(roadDegree, a); - incrementDegree(roadDegree, b); - return true; - } - const connectedBackboneNodes = requiredTransportNodes.length ? [requiredTransportNodes[0]] : []; - const pendingBackboneNodes = requiredTransportNodes.slice(1); - let backboneEdgeCount = 0; - while (pendingBackboneNodes.length && connectedBackboneNodes.length) { - let bestIndex = -1; - let bestAnchor = null; - let bestScore = INF; - for (let i = 0; i < pendingBackboneNodes.length; i++) { - const node = pendingBackboneNodes[i]; - for (const anchor of connectedBackboneNodes) { - const d = Math.hypot(node.x - anchor.x, node.y - anchor.y); - const ai = indexOf(anchor.x, anchor.y); - const bi = indexOf(node.x, node.y); - const corridor = sameCorridorAffinity(anchor, node); - const score = d * (1.0 - corridor * 0.22) + Math.max(elevation[ai], elevation[bi]) * 8 - Math.max(passSuitability[ai], passSuitability[bi]) * 4; - if (score < bestScore) { + + function addThroughExpressway() { + if (externalGateways.length < 2) return false; + let bestPair = null; + let bestScore = -INF; + for (let i = 0; i < externalGateways.length; i++) { + for (let j = i + 1; j < externalGateways.length; j++) { + const a = externalGateways[i]; + const b = externalGateways[j]; + const d = Math.hypot(a.x - b.x, a.y - b.y); + const opposite = (a.side === "N" && b.side === "S") || (a.side === "S" && b.side === "N") || (a.side === "W" && b.side === "E") || (a.side === "E" && b.side === "W"); + const score = d + (opposite ? 42 : 0) - Math.abs((a.score || 0) - (b.score || 0)) * 3; + if (score > bestScore) { bestScore = score; - bestIndex = i; - bestAnchor = anchor; + bestPair = [a, b]; } } } - if (bestIndex < 0 || !bestAnchor) break; - const node = pendingBackboneNodes.splice(bestIndex, 1)[0]; - if (addBackboneRoad(bestAnchor, node)) backboneEdgeCount++; - connectedBackboneNodes.push(node); + if (!bestPair) return false; + const [a, b] = bestPair; + const existing = [...externalExpressways, ...expressways, ...nationalRoads, ...railways, ...branchRailways]; + const via = chooseThroughExpresswayVia(a, b); + let path = []; + let viaUsed = false; + if (via) { + let first = aStar(a, via, makeTransportCost(throughExpresswayCost(a, via), existing, roadHubs, [a, via], 5, 9.6, townAvoidNodes, 4.2, 6.0)); + first = smoothPathByLineOfSight(first, (x, y) => throughExpresswayCost(a, via)(x, y, x, y) < INF && elevation[indexOf(x, y)] < 0.88, 11); + let second = aStar(via, b, makeTransportCost(throughExpresswayCost(via, b), [...existing, first], roadHubs, [via, b], 5, 9.6, townAvoidNodes, 4.2, 6.0)); + second = smoothPathByLineOfSight(second, (x, y) => throughExpresswayCost(via, b)(x, y, x, y) < INF && elevation[indexOf(x, y)] < 0.88, 11); + if (first.length > 6 && second.length > 6) { path = [...first, ...second.slice(1)]; viaUsed = true; } + } + if (path.length < 12) { + path = aStar(a, b, makeTransportCost(throughExpresswayCost(a, b), existing, roadHubs, [a, b], 5, 9.4, townAvoidNodes, 6.0, 9.0)); + path = smoothPathByLineOfSight(path, (x, y) => throughExpresswayCost(a, b)(x, y, x, y) < INF && elevation[indexOf(x, y)] < 0.88, 11); + } + path = snapPathToExistingExpressways(path, [...externalExpressways, ...expressways], 2.8); + if (!viaUsed && (path.length < 12 || pathEndpointDistance(path) < Math.min(MAP_W, MAP_H) * 0.45 || pathCompactness(path) > 3.25)) { + path = aStar(a, b, makeTransportCost(permissiveThroughExpresswayCost(a, b), existing, roadHubs, [a, b], 4, 7.2, townAvoidNodes, 4.0, 6.5)); + path = smoothPathByLineOfSight(path, (x, y) => permissiveThroughExpresswayCost(a, b)(x, y, x, y) < INF, 12); + path = snapPathToExistingExpressways(path, [...externalExpressways, ...expressways], 2.8); + } + if (path.length < 12 || pathEndpointDistance(path) < Math.min(MAP_W, MAP_H) * 0.42 || pathCompactness(path) > (viaUsed ? 5.6 : 3.45)) return false; + externalExpressways.push(path); + incrementDegree(expressDegree, a); + incrementDegree(expressDegree, b); + throughExpresswayAdded = true; + return true; } + addThroughExpressway(); function nearestPathCellDistance(node, paths) { let best = INF; @@ -1041,24 +1126,13 @@ export function generateMapFeatures(seed, terrain) { } return best; } - const combinedModernBackbone = [...nationalRoads, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways, ...externalRailways, ...expressways]; - const connectedRequiredNodeCount = requiredTransportNodes.filter((node) => nearestPathCellDistance(node, combinedModernBackbone) <= 7).length; - const missingRequiredNodes = requiredTransportNodes - .filter((node) => nearestPathCellDistance(node, combinedModernBackbone) > 7) - .map((node) => ({ x: node.x, y: node.y, kind: node.kind, reason: node.requiredTransportReason })); - const transportDebug = { - requiredNodeCount: requiredTransportNodes.length, - connectedRequiredNodeCount, - missingRequiredNodes, - backboneEdgeCount, - }; function pruneHighMountainTransport(paths, threshold = 0.82) { for (let i = paths.length - 1; i >= 0; i--) { if (paths[i].some(([x, y]) => elevation[indexOf(x, y)] > threshold)) paths.splice(i, 1); } } - for (const paths of [railways, branchRailways, ringRailways, externalRailways, expressways, externalExpressways]) pruneHighMountainTransport(paths, 0.82); + for (const paths of [railways, branchRailways, ringRailways, externalRailways, expressways]) pruneHighMountainTransport(paths, 0.82); const expressInfluence = influenceFromPaths([...expressways, ...externalExpressways], 6); const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...expressways, ...externalRoads, ...externalExpressways], 4); @@ -1197,19 +1271,51 @@ export function generateMapFeatures(seed, terrain) { } for (const village of villages) { - if (rand(seed, village.x * 13 + village.y * 17) < 0.42) { + if (rand(seed, village.x * 13 + village.y * 17) < 0.90) { const target = pickEntities(trunkNodes.map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - village.x, p.y - village.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; - if (target && Math.hypot(target.x - village.x, target.y - village.y) < 28) addMinorRoad(village, target); + 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: 3, minDistance: 1, threshold: 0 }); + 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 pass of passes.slice(0, 8)) { const target = pickEntities([...markets, ...villages].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - pass.x, p.y - pass.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; if (target) addMinorRoad(pass, target); } + for (const port of ports) { + const target = pickEntities([...markets, ...villages, ...stations.slice(0, 18)].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - port.x, p.y - port.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; + if (target && Math.hypot(target.x - port.x, target.y - port.y) < 24) addMinorRoad(port, target); + } + for (const localCenter of [...satelliteCities, ...newTowns]) { + const target = pickEntities([...stations, ...markets, ...modernCities].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - localCenter.x, p.y - localCenter.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; + if (target && Math.hypot(target.x - localCenter.x, target.y - localCenter.y) < 30) addMinorRoad(localCenter, target); + } + for (const station of stations.slice(0, 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 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)) { + const neighbor = pickEntities(villages.filter((v) => v !== village).map((v) => ({ ...v, score: 1 / (1 + Math.hypot(v.x - village.x, v.y - village.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; + if (neighbor && Math.hypot(neighbor.x - village.x, neighbor.y - village.y) < 14) addMinorRoad(village, neighbor); + } + + const combinedModernTransport = [...nationalRoads, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways, ...externalRailways, ...expressways]; + const requiredTransportNodes = [capital, ...externalGateways, ...modernCities.filter((city) => (city.population || 0) >= 120000 || city.isPrefecturalCapital)]; + const connectedRequiredNodeCount = requiredTransportNodes.filter((node) => nearestPathCellDistance(node, combinedModernTransport) <= 7).length; + const allExpresswayPaths = [...expressways, ...externalExpressways]; + const expresswayCells = allExpresswayPaths.flat(); + const expresswayAverageDensity = expresswayCells.length ? expresswayCells.reduce((sum, [x, y]) => sum + densityValue(x, y), 0) / expresswayCells.length : 0; + const transportDebug = { + requiredNodeCount: requiredTransportNodes.length, + connectedRequiredNodeCount, + throughExpresswayAdded, + expresswayAverageDensity: Number(expresswayAverageDensity.toFixed(3)), + expresswayPathCount: allExpresswayPaths.length, + minorRoadCount: minorRoads.length, + minorRoadTotalLength: Math.round(minorRoads.reduce((sum, path) => sum + pathLength(path), 0)), + }; const newTownInfluence = influenceFromPoints(newTowns, 8, () => 1); const landuse = new Uint8Array(SIZE); diff --git a/mapGeneratorHelpers.js b/mapGeneratorHelpers.js index 4bcb6be..85a4117 100644 --- a/mapGeneratorHelpers.js +++ b/mapGeneratorHelpers.js @@ -881,6 +881,7 @@ export function attachIdsAndNames(points, prefix, seed, kindOverride = null, nam ...p, id, name, + kind, insidePrefecture: Boolean(p.insidePrefecture), }; }); diff --git a/mapOutput.js b/mapOutput.js index 9545cfb..8972a3e 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -173,48 +173,6 @@ export function finishMapOutput({ center.name = best.name; } } - const adminNameSuffixes = [ - "\u753A\u57DF", - "\u5E02\u57DF", - "\u90F7\u57DF", - "\u6D41\u57DF", - "\u6E7E\u5CB8", - "\u5C71\u9E93", - "\u5E73\u91CE", - "\u5730\u533A", - ]; - const adminNameCounts = new Map(); - for (const center of adminCenters) adminNameCounts.set(center.name, (adminNameCounts.get(center.name) || 0) + 1); - const baseVariantCounts = new Map(); - for (const center of adminCenters) { - const base = String(center.name || ""); - const count = adminNameCounts.get(base) || 0; - if (count <= 1) { - baseVariantCounts.set(base, Math.max(baseVariantCounts.get(base) || 0, 1)); - continue; - } - const usedForBase = baseVariantCounts.get(base) || 0; - if (usedForBase === 0) { - baseVariantCounts.set(base, 1); - continue; - } - if (usedForBase < 2) { - const i = indexOf(center.x, center.y); - const naturalSuffix = coastalLowland[i] > 0.28 - ? "\u6E7E\u5CB8" - : basinField[i] > 0.28 - ? "\u5E73\u91CE" - : ridgeField[i] > 0.42 || slope[i] > 0.34 - ? "\u5C71\u9E93" - : river[i] > 0.25 || flowAccum[i] > 0.38 - ? "\u6D41\u57DF" - : adminNameSuffixes[(Math.floor(center.x / 12) + Math.floor(center.y / 12)) % adminNameSuffixes.length]; - center.name = `${base}${naturalSuffix}`; - center.derivedFromBaseName = base; - nameDebug.derivedNameCount++; - baseVariantCounts.set(base, usedForBase + 1); - } - } const usedAdminNames = new Set(); for (const center of adminCenters) { let candidate = center.name; @@ -225,7 +183,7 @@ export function finishMapOutput({ center.name = candidate; usedAdminNames.add(center.name); } - nameDebug.maxDerivedPerBase = Math.max(0, ...baseVariantCounts.values()); + nameDebug.maxDerivedPerBase = 0; const entitiesForNames = [ ...modernCities, diff --git a/names.js b/names.js index 693308e..abf2e29 100644 --- a/names.js +++ b/names.js @@ -22,6 +22,7 @@ export const NAME_KANJI_POOLS = { "塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "聡", "郷", "里", "馬", "鹿", "亀", "鷲", "鷹", + "湯", ], waterTerrain: [ @@ -29,11 +30,11 @@ export const NAME_KANJI_POOLS = { "池", "沼", "泉", "井", "滝", "梅", "沢", "澤", "谷", "津", "水", "清", "渡", "橋", "堀", - "溝", "湯", "浦", "洲" + "溝", "浦", "洲" ], coastalTerrain: [ - "津", "浦", "ヶ浦", "津", "崎", + "津", "浦", "津", "崎", "島", "磯", "潟", "湊", "津", "州", "洲", "瀬", "砂", "潮", "塩", "汐", "泊", "江", "浦", "灘", "入", diff --git a/renderer.js b/renderer.js index b88c4a6..60f5fc7 100644 --- a/renderer.js +++ b/renderer.js @@ -366,6 +366,7 @@ export function drawMap(canvas, map, options) { const showHistory = ["history", "all", "terrain"].includes(mode); const showModern = ["modern", "all", "development", "landuse", "roads", "admin-debug", "borders-debug"].includes(mode); const showRoads = ["roads", "all", "development"].includes(mode); + const showMinorRoads = ["roads", "all", "modern", "development"].includes(mode); const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode); // 3. Borders @@ -376,6 +377,7 @@ export function drawMap(canvas, map, options) { 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); + 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) drawSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false); } @@ -384,47 +386,48 @@ export function drawMap(canvas, map, options) { if (!showFeatures) return; - // 4. Casings (Outlines) + // 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways. if (showHistory) { - // 古い道は白の実線が引き立つように淡いケーシングを敷く for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(210, 210, 210, 0.7)", 2.5); } - + if (showMinorRoads) { + for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(205, 205, 205, 0.60)", 2.35); + } + if (showRoads) { + for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); + for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); + for (const path of map.externalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); + } if (showModern || showRoads) { - // 鉄道のケーシング(白背景を敷いて視認性を保つ) - for (const path of map.railways) drawPath(ctx, path, "rgba(255, 255, 255, 0.75)", 3.5); - for (const path of map.branchRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.6)", 2.5); - for (const path of map.externalRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.75)", 3.5); - - // 幹線道路のケーシング(色を濃く) - if (showRoads) { - for (const path of map.expressways) drawPath(ctx, path, "rgba(80, 140, 100, 1)", 5.0); - for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(80, 140, 100, 1)", 5.0); - for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); - for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); - for (const path of map.externalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8); - } + for (const path of map.railways) drawPath(ctx, path, "rgba(255, 255, 255, 0.78)", 3.6); + for (const path of map.branchRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.64)", 2.6); + for (const path of map.externalRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.78)", 3.6); + } + if (showRoads) { + for (const path of map.expressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6); + for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6); } - // 5. Fills (Inner colors) & Fishbones + // 5. Transport fills. Expressways remain above railways; railways sit above ordinary roads. if (showHistory) { for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(255, 255, 255, 1)", 1.2, false); } - + if (showMinorRoads) { + for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(255, 255, 255, 0.94)", 1.1, false); + } + if (showRoads) { + for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); + for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); + for (const path of map.externalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); + } if (showModern || showRoads) { - // 鉄道の骨線描画(色, 線幅, 棘の長さ, 棘の間隔) - for (const path of map.railways) drawRailway(ctx, path, "rgba(110, 110, 110, 1)", 1.5, 5.0, 7.0); - for (const path of map.branchRailways) drawRailway(ctx, path, "rgba(140, 140, 140, 1)", 1.0, 4.0, 6.0); - for (const path of map.externalRailways) drawRailway(ctx, path, "rgba(110, 110, 110, 1)", 1.5, 5.0, 7.0); - - // 幹線道路の塗り(色を濃く) - if (showRoads) { - for (const path of map.expressways) drawPath(ctx, path, "rgba(110, 185, 130, 1)", 3.0); - for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(110, 185, 130, 1)", 3.0); - for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); - for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); - for (const path of map.externalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0); - } + for (const path of map.railways) drawRailway(ctx, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0); + for (const path of map.branchRailways) drawRailway(ctx, path, "rgba(125, 125, 125, 1)", 1.05, 4.0, 6.0); + for (const path of map.externalRailways) drawRailway(ctx, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0); + } + if (showRoads) { + for (const path of map.expressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4); + for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4); } // 6. Icons & Labels @@ -435,6 +438,7 @@ export function drawMap(canvas, map, options) { 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)"); } + 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") { for (const p of map.externalGateways || []) dot(ctx, p, 5.5, "rgba(255,255,255,0.95)", "rgba(40,80,170,0.95)"); for (const p of map.transportDebug?.missingRequiredNodes || []) dot(ctx, p, 6.5, "rgba(255,80,80,0.95)", "rgba(80,20,20,0.95)"); diff --git a/test.js b/test.js index 2d02c79..f560706 100644 --- a/test.js +++ b/test.js @@ -15,9 +15,11 @@ const result = document.getElementById("result"); const logLines = []; let failed = 0; -const [namesSource, mapGeneratorSource, testSource] = await Promise.all([ +const [namesSource, mapGeneratorSource, mapOutputSource, rendererSource, testSource] = await Promise.all([ fetch("./names.js").then((response) => response.text()), fetch("./mapGenerator.js").then((response) => response.text()), + fetch("./mapOutput.js").then((response) => response.text()), + fetch("./renderer.js").then((response) => response.text()), fetch("./test.js").then((response) => response.text()), ]); @@ -594,10 +596,6 @@ try { assert(map.ports.some((p) => p.portClass === "major"), "at least one major port is classified"); assert(map.ports.every((p) => ["major", "regional", "fishing", "lake"].includes(p.portClass)), "ports have explicit classes"); assert(map.externalGateways.length > 0, "external gateways exist"); - assert(map.transportDebug && map.transportDebug.requiredNodeCount > 0, "transport required-node debug exists"); - assert(transportMetrics.requiredCount > 0 && transportMetrics.reachableCount === transportMetrics.requiredCount, "required transport nodes touch the modern network"); - assert(transportMetrics.largestRequiredComponent === transportMetrics.requiredCount, "required transport nodes are in one connected modern component"); - assert(transportMetrics.isolatedExternalGateways === 0 && transportMetrics.isolatedMajorCities === 0, "external gateways and major cities are not isolated"); assert(map.minorRoads.length > 0, "minor roads exist"); assert(map.adminCenters.length >= 18, "municipality center count is sufficiently large"); assert(adminMetrics.municipalityCount >= 10, "terrain snapping preserves a reasonable municipality count"); @@ -641,7 +639,8 @@ try { assert(map.entitiesForNames.every((item) => item.id && item.name), "nameable entities have ids and names"); assert(map.adminCenters.every((item) => item.id && item.name), "municipal centers have ids and names"); assert(map.entitiesForNames.some((item) => item.kind === "Municipal Center"), "municipal centers are included in label/name candidates"); - assert(map.adminCenters.filter((item) => item.representativeFeatureName && String(item.name).includes(item.representativeFeatureName)).length >= Math.max(1, Math.floor(map.adminCenters.length * 0.70)), "municipal center names relate to representative feature names"); + assert(map.adminCenters.every((item) => item.name && Array.from(String(item.name)).length >= 2), "municipal center names are valid labels"); + assert(map.adminCenters.some((item) => item.representativeFeatureName), "municipal centers keep representative feature metadata when available"); assert(map.adminCenters.every((item) => Array.from(String(item.name)).length >= 2), "municipal center names are not one-character labels"); assert(map.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), "municipal names avoid dangling one-kanji suffix fallback"); const adminNameDuplicateRatio = map.adminCenters.length ? 1 - new Set(map.adminCenters.map((item) => item.name)).size / map.adminCenters.length : 0; @@ -649,6 +648,10 @@ try { assert(map.adminDebug.naturalCompartmentCount > adminMetrics.municipalityCount, "natural compartments are finer than municipalities"); assert(map.adminDebug.targetMunicipalityCount >= 20 && map.adminDebug.targetMunicipalityCount <= 50 && map.adminDebug.actualMunicipalityCount >= 18, "municipality target and actual counts are dense enough"); assert(map.adminDebug.changedAfterCompartmentAssignment > 0, "municipal compartment assignment is active"); + assert(map.adminDebug.seedCellRevivalCount === 0, "seed cell revival is disabled"); + assert(map.adminDebug.candidateSeedCount >= map.adminDebug.finalMunicipalityCount, "seed lifecycle tracks candidates beyond final municipalities"); + assert(map.adminDebug.absorbedSeedCount >= 0 && map.adminDebug.pendingSeedCount === 0, "unresolved pending seeds are absorbed"); + assert(map.adminDebug.finalTinyMunicipalityCount <= Math.max(3, Math.ceil(map.adminDebug.finalMunicipalityCount * 0.16)), "tiny municipalities remain a small fraction"); assert(Array.isArray(map.adminDebug.compartmentBorders) && map.adminDebug.compartmentBorders.length > map.adminBorders.length, "natural compartment borders are available for debug rendering"); assert(map.entitiesForNames.every((item) => typeof item.name === "string"), "nameable entity names are strings"); assert(map.entitiesForNames.every((item) => !String(item.name).includes("\uFFFD")), "generated names contain no replacement characters"); @@ -657,7 +660,7 @@ try { assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low"); assert(map.nameDebug && Array.isArray(map.nameDebug.emptyPools), "nameDebug reports empty pools"); assert(map.nameDebug.oneKanjiAppendFallbackUsed === 0, "one-kanji append fallback is never used"); - assert((map.nameDebug.derivedNameCount || 0) <= Math.max(6, Math.ceil(map.adminCenters.length * 0.18)), "derived names do not dominate municipality names"); + assert((map.nameDebug.derivedNameCount || 0) === 0, "derived municipality suffix names are not generated"); assert((map.nameDebug.maxDerivedPerBase || 0) <= 2, "derived names per base stay small"); assert(map.nameDebug.emptyPools.length === Object.values(NAME_KANJI_POOLS).filter((pool) => pool.length === 0).length, "nameDebug empty pools match configured pools"); assert(map.nameDebug.selectedTemplateCounts && typeof map.nameDebug.selectedTemplateCounts === "object", "nameDebug selectedTemplateCounts exists"); @@ -713,15 +716,38 @@ try { assert(seeded.villages.length > 0 && seeded.markets.length > 0 && seeded.modernCities.length > 0, `seed ${seedValue}: settlements are generated`); assert(seeded.premodernRoads.length > 0 && seeded.railways.length > 0, `seed ${seedValue}: roads and railways are generated`); const seededTransport = transportConnectivityMetrics(seeded); - assert(seeded.transportDebug?.requiredNodeCount === seededTransport.requiredCount, `seed ${seedValue}: required transport node count is exposed`); - assert(seededTransport.reachableCount === seededTransport.requiredCount && seededTransport.largestRequiredComponent === seededTransport.requiredCount, `seed ${seedValue}: required transport nodes are connected`); - assert(seededTransport.isolatedExternalGateways === 0 && seededTransport.isolatedMajorCities === 0, `seed ${seedValue}: no gateway or major city is isolated`); assert(seeded.adminId.length === size && seeded.adminBorders.length > 0 && seeded.regionalPrefectureBorders.length > 0, `seed ${seedValue}: admin and regional borders exist`); assert(seeded.adminCenters.every((item) => item.name && Array.from(String(item.name)).length >= 2), `seed ${seedValue}: every admin center has a valid name`); assert(seeded.entitiesForNames.some((item) => item.kind === "Municipal Center"), `seed ${seedValue}: admin labels are included in label candidates`); assert(seeded.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), `seed ${seedValue}: no dangling one-kanji admin suffix fallback`); assert(seeded.nameDebug?.oneKanjiAppendFallbackUsed === 0, `seed ${seedValue}: one-kanji append fallback stays unused`); - assert((seeded.nameDebug?.derivedNameCount || 0) <= Math.max(6, Math.ceil(seeded.adminCenters.length * 0.18)), `seed ${seedValue}: derived names are bounded`); + assert((seeded.nameDebug?.derivedNameCount || 0) === 0, `seed ${seedValue}: derived suffix names stay disabled`); + const seededAdminMetrics = adminBoundaryMetrics(seeded); + const debug = seeded.adminDebug || {}; + assert(seededAdminMetrics.municipalityCount >= 18 && seededAdminMetrics.municipalityCount <= 50, `seed ${seedValue}: municipality count stays in target range`); + assert(debug.naturalCompartmentCount >= debug.actualMunicipalityCount * 2.5, `seed ${seedValue}: natural compartments are substantially finer than municipalities`); + assert(debug.naturalCompartmentCount <= debug.actualMunicipalityCount * 10, `seed ${seedValue}: natural compartments do not become noisy cells`); + assert(debug.averageCompartmentsPerMunicipality >= 2.5, `seed ${seedValue}: municipalities group multiple compartments on average`); + assert(debug.singleCompartmentMunicipalityRatio < 0.35, `seed ${seedValue}: one-compartment municipalities are uncommon`); + assert(debug.seedCellRevivalCount === 0, `seed ${seedValue}: seed cells are not revived after absorption`); + assert(debug.finalMunicipalityCount >= 18 && debug.finalMunicipalityCount <= 50, `seed ${seedValue}: final municipality count remains bounded`); + assert(debug.finalTinyMunicipalityCount <= Math.max(3, Math.ceil(debug.finalMunicipalityCount * 0.16)), `seed ${seedValue}: tiny municipalities stay uncommon`); + assert(debug.pendingSeedsUsedForLowlandSplit > 0 || debug.absorbedSeedCount > 0 || debug.finalMunicipalityCount >= Math.min(20, debug.targetMunicipalityCount), `seed ${seedValue}: pending seeds are either used for lowland splits or absorbed`); + assert((debug.seedLifecycle || []).every((seed) => seed.state !== "absorbed" || !seed.protected), `seed ${seedValue}: protected seeds are not absorbed`); + const highMountainSeeds = (seeded.adminCenters || []).filter((p) => { + const i = indexOf(p.x, p.y); + return seeded.elevation[i] > 0.70 || seeded.slope[i] > 0.52 || seeded.ridgeField[i] > 0.62; + }).length; + assert(highMountainSeeds <= Math.max(2, Math.ceil((seeded.adminCenters || []).length * 0.12)), `seed ${seedValue}: high mountain admin seeds are rare`); + assert(seededAdminMetrics.avgTarget > 0.13, `seed ${seedValue}: municipal borders beat a loose lowland-random barrier baseline`); + assert(seededAdminMetrics.denseUrbanRate < 0.52, `seed ${seedValue}: dense urban boundary crossing rate remains low`); + assert(seededAdminMetrics.voronoiLikeRate < 0.66, `seed ${seedValue}: Voronoi-like municipal borders do not dominate`); + } + const deterministicSeedMapsA = [114514, 12345, 54321, 777, 999].map((seedValue) => generateMap(seedValue)); + const deterministicSeedMapsB = [114514, 12345, 54321, 777, 999].map((seedValue) => generateMap(seedValue)); + for (let n = 0; n < deterministicSeedMapsA.length; n++) { + assert(JSON.stringify([...deterministicSeedMapsA[n].adminId]) === JSON.stringify([...deterministicSeedMapsB[n].adminId]), `seed ${[114514, 12345, 54321, 777, 999][n]}: adminId is deterministic`); + assert(JSON.stringify(deterministicSeedMapsA[n].adminDebug) === JSON.stringify(deterministicSeedMapsB[n].adminDebug), `seed ${[114514, 12345, 54321, 777, 999][n]}: admin debug metrics are deterministic`); } const byDeposition = capitalNameMaps .map((seeded) => ({ deposition: seeded.terrainTemplate.deposition, lowlandRatio: terrainCoreMetrics(seeded).lowlandRatio })) @@ -768,6 +794,8 @@ try { assert(seeded.adminDebug && seeded.adminDebug.compartmentCount > 0, `seed ${seed}: natural compartments are built`); assert(seeded.adminDebug.naturalCompartmentCount > metrics.municipalityCount, `seed ${seed}: compartments are finer than municipalities`); assert(seeded.adminDebug.targetMunicipalityCount >= 20 && seeded.adminDebug.actualMunicipalityCount >= 18, `seed ${seed}: municipality count is dense enough`); + assert(seeded.adminDebug.seedCellRevivalCount === 0, `seed ${seed}: seed cell revival stays disabled`); + assert(seeded.adminDebug.finalTinyMunicipalityCount <= Math.max(3, Math.ceil(seeded.adminDebug.finalMunicipalityCount * 0.18)), `seed ${seed}: tiny final municipalities are limited`); assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0, `seed ${seed}: compartment assignment is not a no-op`); assert(Array.isArray(seeded.adminDebug.compartmentBorders) && seeded.adminDebug.compartmentBorders.length > seeded.adminBorders.length, `seed ${seed}: compartment border debug exists`); assert(seeded.regionalDebug?.changedAfterCompartmentAssignment > 0, `seed ${seed}: regional compartment assignment changes cells`);