import { createNameDebug } from "./names.js"; import { CELL_SIZE, INF, MAP_H, MAP_W, MinHeap, clamp, indexOf, inside, rand, xyOf } from "./mapUtils.js"; import { applyOutputOptions, attachIdsAndNames, influenceFromPaths, tagInsidePrefecture } from "./mapGeneratorHelpers.js"; import { routeQualityAcceptable } from "./mapTransport.js"; const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u; function stripMunicipalSuffix(name) { return String(name || "").replace(/[市町村区]$/u, "").trim(); } function municipalitySuffixForCenter(center, fields, seed, ordinal = 0) { const i = inside(center?.x || 0, center?.y || 0) ? indexOf(center.x, center.y) : 0; const density = fields.populationDensity?.[i] || 0; const land = fields.landuse?.[i] ?? 0; const urban = density > 0.36 || [2, 3, 4, 7, 8].includes(land) || center?.protectedSatellite; const rural = (fields.elevation?.[i] || 0) > 0.58 || (fields.slope?.[i] || 0) > 0.40 || (fields.ridgeField?.[i] || 0) > 0.46; if (urban) return "市"; if (rural && rand(seed + ordinal * 17, 9021) < 0.58) return "村"; return "町"; } function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) { let value = String(root || center?.name || "").trim(); if (!value) value = `自治${ordinal + 1}`; value = value.replace(/[市町村区駅港城跡宿]$/gu, ""); const fallback = String(center?.generatedMunicipalityName || "里").replace(/[市町村区駅港城跡宿]$/gu, ""); if (!value) value = fallback || "里"; return `${value}${municipalitySuffixForCenter(center, fields, seed, ordinal)}`; } function assignMunicipalityPopulations(adminCenters, adminId, fields, settlementFeatures = []) { if (!adminCenters?.length || !adminId) return; const totals = new Float64Array(adminCenters.length); const settlementTotals = new Float64Array(adminCenters.length); const landCells = new Uint32Array(adminCenters.length); const inhabitedCells = new Uint32Array(adminCenters.length); for (let i = 0; i < adminId.length; i++) { const id = adminId[i]; if (id < 0 || id >= totals.length || fields.sea?.[i]) continue; landCells[id]++; const density = fields.populationDensity?.[i] || 0; const lu = fields.landuse?.[i] ?? 0; const plain = fields.plain?.[i] || 0; const agri = fields.agriculture?.[i] || 0; const builtWeight = lu === 3 ? 520 : lu === 2 ? 360 : lu === 4 || lu === 7 || lu === 8 ? 260 : lu === 5 || lu === 6 ? 160 : lu === 1 ? 82 : 22; const ruralFloor = lu === 1 ? 10 + agri * 18 + plain * 10 : 0; if (density > 0.006 || ruralFloor > 0 || lu > 0) inhabitedCells[id]++; totals[id] += density * builtWeight + ruralFloor; } // Population-bearing generated settlements are canonical entities, so add // their explicit populations exactly once to the municipality containing the // point. Some towns are promoted to cities later, so the same coordinate can // appear in both `markets` and `modernCities`; keep only the strongest record // per coordinate to avoid double counting. const uniqueSettlementByCell = new Map(); for (const feature of settlementFeatures || []) { if (!feature || !Number.isFinite(feature.population) || feature.population <= 0) continue; if (!inside(feature.x, feature.y)) continue; const i = indexOf(feature.x, feature.y); if (fields.sea?.[i]) continue; const key = `${Math.round(feature.x)}:${Math.round(feature.y)}`; const current = uniqueSettlementByCell.get(key); const priority = (feature.isPrefecturalCapital ? 4_000_000 : 0) + (feature.isRegionalCapital ? 1_000_000 : 0) + (feature.population || 0); if (!current || priority > current.priority) uniqueSettlementByCell.set(key, { feature, priority, i }); } let skippedDuplicateSettlementPopulation = 0; for (const { feature, i } of uniqueSettlementByCell.values()) { const id = adminId[i]; if (id < 0 || id >= totals.length) continue; settlementTotals[id] += feature.population; } for (const feature of settlementFeatures || []) { if (!feature || !Number.isFinite(feature.population) || feature.population <= 0 || !inside(feature.x, feature.y)) continue; const key = `${Math.round(feature.x)}:${Math.round(feature.y)}`; const kept = uniqueSettlementByCell.get(key)?.feature; if (kept && kept !== feature) skippedDuplicateSettlementPopulation += feature.population || 0; } for (let id = 0; id < adminCenters.length; id++) { const raw = (totals[id] || 0) + (settlementTotals[id] || 0); const minimumResidentPopulation = landCells[id] > 0 ? Math.round(Math.max(100, Math.min(3800, 80 + landCells[id] * 9 + inhabitedCells[id] * 16)) / 100) * 100 : 0; const adjustedRaw = Math.max(raw, minimumResidentPopulation); const rounded = adjustedRaw >= 10000 ? Math.round(adjustedRaw / 1000) * 1000 : Math.max(minimumResidentPopulation, Math.round(adjustedRaw / 100) * 100); const safePopulation = Math.max(landCells[id] > 0 ? 100 : 0, rounded); adminCenters[id].municipalityPopulation = safePopulation; // Some consumers still read the generic `population` field from municipal // centers. Mirror the municipality total there so no municipality is shown // as 0人 merely because it is not a canonical city/market entity. adminCenters[id].population = Math.max(adminCenters[id].population || 0, safePopulation); adminCenters[id].municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100); adminCenters[id].municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100); } } function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, fields, seed, minPopulation = 200000) { if (!prefectureRegionId) return 0; const prefIds = new Set(); for (let i = 0; i < prefectureRegionId.length; i++) { const id = prefectureRegionId[i]; if (!sea[i] && id >= 0) prefIds.add(id); } const prefProfiles = new Map(); for (let i = 0; i < prefectureRegionId.length; i++) { const prefId = prefectureRegionId[i]; if (sea[i] || prefId < 0) continue; const profile = prefProfiles.get(prefId) || { landCells: 0, habitableCells: 0, lowlandCells: 0, densitySum: 0 }; profile.landCells++; const slopeV = fields.slope?.[i] || 0; const ridgeV = fields.ridgeField?.[i] || 0; const elevV = fields.elevation?.[i] || 0; const lowland = ((fields.plain?.[i] || 0) > 0.24 || (fields.basinField?.[i] || 0) > 0.26 || (fields.coastalLowland?.[i] || 0) > 0.22) && slopeV < 0.38 && ridgeV < 0.55; if (slopeV < 0.42 && ridgeV < 0.58 && elevV < 0.74) profile.habitableCells++; if (lowland) profile.lowlandCells++; profile.densitySum += fields.populationDensity?.[i] || 0; prefProfiles.set(prefId, profile); } function capitalFloorForPref(prefId) { const p = prefProfiles.get(prefId) || { landCells: 0, habitableCells: 0, lowlandCells: 0, densitySum: 0 }; const lowlandRatio = p.landCells ? p.lowlandCells / p.landCells : 0; const densityBoost = clamp((p.densitySum / Math.max(1, p.landCells) - 0.16) / 0.42); let base = 45000; if (p.lowlandCells > 900 || (p.lowlandCells > 650 && lowlandRatio > 0.28)) base = minPopulation * 0.90; else if (p.lowlandCells > 520) base = 160000; else if (p.lowlandCells > 260) base = 110000; else if (p.lowlandCells > 120 || p.habitableCells > 420) base = 70000; const adjusted = base + densityBoost * 50000; return Math.round(clamp(adjusted, 42000, minPopulation + 45000) / 1000) * 1000; } let promoted = 0; function prefAt(p) { if (!p || !inside(p.x, p.y)) return -1; return prefectureRegionId[indexOf(p.x, p.y)] ?? -1; } for (const prefId of [...prefIds].sort((a, b) => a - b)) { const cities = (modernCities || []).filter((p) => prefAt(p) === prefId); let target = cities.slice().sort((a, b) => ((b.isRegionalCapital ? 800000 : 0) + (b.population || 0)) - ((a.isRegionalCapital ? 800000 : 0) + (a.population || 0)) )[0]; if (!target) { const market = (markets || []).filter((p) => prefAt(p) === prefId).sort((a, b) => (b.population || 0) - (a.population || 0))[0]; if (market) { target = { ...market, kind: "Regional Capital", rank: "Regional Capital", promotedFromMarketTown: true, labelPriorityBase: 900, }; modernCities.push(target); } } if (!target) { const center = (adminCenters || []).filter((p) => prefAt(p) === prefId).sort((a, b) => (b.municipalityPopulation || 0) - (a.municipalityPopulation || 0))[0]; if (center) { target = { ...center, kind: "Regional Capital", rank: "Regional Capital", promotedFromMunicipalCenter: true, labelPriorityBase: 880, }; modernCities.push(target); } } if (!target) continue; const regionalFloor = capitalFloorForPref(prefId); const candidateCapacity = Number.isFinite(target.capacity) ? Math.max(42000, target.capacity * 1.18) : Infinity; const randomizedFloor = Math.round((regionalFloor + rand(seed + 52000, prefId * 37 + 11) * Math.max(12000, regionalFloor * 0.28)) / 1000) * 1000; const promotedPopulation = Math.max(42000, Math.round(Math.min(randomizedFloor, candidateCapacity) / 1000) * 1000); if ((target.population || 0) < promotedPopulation) { target.population = promotedPopulation; promoted++; } target.isPrefecturalCapital = true; target.isRegionalCapital = true; target.rank = target.rank || "Regional Capital"; target.kind = target.kind === "Market Town" || target.kind === "Port Town" || target.kind === "Valley Market Town" ? "Regional Capital" : (target.kind || "Regional Capital"); target.urbanRadius = clamp(6.2 + Math.sqrt(target.population) / 80, 9, 30); target.coreRadius = clamp(2.0 + Math.sqrt(target.population) / 390, 2.4, 6.4); target.sprawlRadius = clamp(target.urbanRadius * 1.34, target.urbanRadius + 2, 38); target.urbanWeight = clamp(1.05 + Math.log10(Math.max(10000, target.population)) * 0.30, 1.25, 2.55); } return promoted; } function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames, nameDebug, options = {}) { if (!prefectureRegionId) return []; const { modernCities = [], adminCenters = [] } = options; const byId = new Map(); for (let i = 0; i < prefectureRegionId.length; i++) { const id = prefectureRegionId[i]; if (sea[i] || id < 0) continue; if (!byId.has(id)) byId.set(id, { id, area: 0, sx: 0, sy: 0, cells: [] }); const row = byId.get(id); const x = i % MAP_W; const y = Math.floor(i / MAP_W); row.area++; row.sx += x; row.sy += y; row.cells.push(i); } const regions = []; const capitalNameByPref = new Map(); for (const city of modernCities || []) { if (!city || !inside(city.x, city.y) || !city.name) continue; const prefId = prefectureRegionId[indexOf(city.x, city.y)]; if (prefId < 0) continue; const current = capitalNameByPref.get(prefId); const score = (city.isPrefecturalCapital ? 2_000_000 : 0) + (city.isRegionalCapital ? 500_000 : 0) + (city.population || 0); if (!current || score > current.score) capitalNameByPref.set(prefId, { name: stripMunicipalSuffix(city.name), score, x: city.x, y: city.y, population: city.population || 0, source: "city" }); } for (const center of adminCenters || []) { if (!center || !inside(center.x, center.y) || !center.name) continue; const prefId = prefectureRegionId[indexOf(center.x, center.y)]; if (prefId < 0 || capitalNameByPref.has(prefId)) continue; capitalNameByPref.set(prefId, { name: stripMunicipalSuffix(center.name), score: center.municipalityPopulation || 0, x: center.x, y: center.y, population: center.municipalityPopulation || 0, source: "admin" }); } for (const row of [...byId.values()].sort((a, b) => a.id - b.id)) { const cx = row.sx / Math.max(1, row.area); const cy = row.sy / Math.max(1, row.area); let bestI = row.cells[0]; let bestScore = -INF; for (const i of row.cells) { const x = i % MAP_W; const y = Math.floor(i / MAP_W); const score = (fields.populationDensity?.[i] || 0) * 1.6 + (fields.plain?.[i] || 0) * 0.28 + (fields.basinField?.[i] || 0) * 0.22 + (fields.coastalLowland?.[i] || 0) * 0.16 - (fields.slope?.[i] || 0) * 0.36 - (fields.ridgeField?.[i] || 0) * 0.30 - Math.hypot(x - cx, y - cy) * 0.08; if (score > bestScore) { bestScore = score; bestI = i; } } let x = bestI % MAP_W; let y = Math.floor(bestI / MAP_W); const capitalInfo = capitalNameByPref.get(row.id); if (capitalInfo && inside(capitalInfo.x, capitalInfo.y)) { const targetRing = clamp(5.5 + Math.sqrt(row.area) / 52, 6, 15); let labelI = bestI; let labelScore = -INF; for (const i of row.cells) { const lx = i % MAP_W; const ly = Math.floor(i / MAP_W); const d = Math.hypot(lx - capitalInfo.x, ly - capitalInfo.y); if (d < 2 || d > Math.max(19, targetRing * 2.2)) continue; const lu = fields.landuse?.[i] ?? 0; const builtPenalty = lu === 3 ? 1.2 : lu === 2 || lu === 4 || lu === 7 || lu === 8 ? 0.70 : 0; const score = -Math.abs(d - targetRing) * 0.38 - (fields.populationDensity?.[i] || 0) * 1.1 - builtPenalty + (fields.plain?.[i] || 0) * 0.22 + (fields.basinField?.[i] || 0) * 0.14 + (fields.coastalLowland?.[i] || 0) * 0.10 - (fields.slope?.[i] || 0) * 0.30 - (fields.ridgeField?.[i] || 0) * 0.24; if (score > labelScore) { labelScore = score; labelI = i; } } x = labelI % MAP_W; y = Math.floor(labelI / MAP_W); } regions.push({ id: row.id, x, y, area: row.area, kind: row.id === 0 ? "Current Prefecture" : "Prefecture", labelPriorityBase: 900 + Math.sqrt(row.area), capitalX: capitalInfo?.x, capitalY: capitalInfo?.y }); } const named = attachIdsAndNames(regions, "prefecture", seed + 41000, null, fields, usedNames, nameDebug) .map((region, index) => ({ ...region, featureId: region.id, id: regions[index].id, labelName: region.name })); const usedPrefNames = new Set(); for (const region of named) { const capital = capitalNameByPref.get(region.id)?.name; let candidate = capital && Array.from(capital).length >= 2 ? capital : stripMunicipalSuffix(region.name); if (!candidate) candidate = stripMunicipalSuffix(region.name) || `県域${region.id + 1}`; if (usedPrefNames.has(candidate)) candidate = `${candidate}${region.id + 1}`; candidate = `${String(candidate).replace(/[都道府県]$/u, "")}県`; region.name = candidate; region.labelName = candidate; region.prefectureCapitalDerivedName = Boolean(capital); usedPrefNames.add(candidate); } return named; } export function finishMapOutput({ seed, options, terrain, features, admin, geography = null, }) { const { terrainTemplate, seaLevel, elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, ridgeField, valleyField, visibleRavineField, surfaceTextureField, basinField, coastalLowland, flowAccum, watershedId, erosionField, depositionField, arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore, riverPaths, mainRivers, tributaryRivers, smallStreams, prefectureMask, prefectureBorder, terrainDebug, } = terrain; const { cityPopulationCap, stationInfluence, roadInfluence, railInfluence2, settlementCluster, villages: inputVillages, geographicUrbanAnchors: inputGeographicUrbanAnchors = [], ports: inputPorts, crossings: inputCrossings, passes: inputPasses, markets: inputMarkets, castles: inputCastles, castleTowns: inputCastleTowns, premodernRoads, minorRoads, modernCities: inputModernCities, populationDensity, railways, branchRailways, ringRailways, externalRailways, stations: inputStations, industrialZones: inputIndustrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways, interchanges: inputInterchanges, logisticsParks: inputLogisticsParks, satelliteCities: inputSatelliteCities, newTowns: inputNewTowns, landuse, externalGateways: inputExternalGateways, transportDebug, } = features; const { adminCentersRaw, adminId, adminBorders, adminDebug, prefectureRegionId, municipalityToPrefectureId, regionalPrefectureBorders: adminRegionalPrefectureBorders, regionalDebug, naturalCompartmentId, naturalCompartments, } = admin; let villages = inputVillages; let geographicUrbanAnchors = inputGeographicUrbanAnchors; let ports = inputPorts; let crossings = inputCrossings; let passes = inputPasses; let markets = inputMarkets; let castles = inputCastles; let castleTowns = inputCastleTowns; let modernCities = inputModernCities; let stations = inputStations; let industrialZones = inputIndustrialZones; let interchanges = inputInterchanges; let logisticsParks = inputLogisticsParks; let satelliteCities = inputSatelliteCities; let newTowns = inputNewTowns; let externalGateways = inputExternalGateways; const outputProgress = (step) => options?.onProgress?.({ status: "output-step", key: "output", label: `Output: ${step}`, step }); outputProgress("final packaging"); // Use all generated prefecture regions for human-geography masks, not only // the focused prefecture. Population density itself is already generated in // the human stage and is not rebuilt here. const humanRegionMask = new Uint8Array(MAP_W * MAP_H); for (let i = 0; i < humanRegionMask.length; i++) { humanRegionMask[i] = !sea[i] && ((prefectureRegionId?.[i] ?? -1) >= 0 || prefectureMask[i]) ? 1 : 0; } // Population density is generated directly in the human stage. Do not rebuild // it here from land-use or municipal offices; output should only package and // name features. for (const city of modernCities) { const cap = cityPopulationCap(city); if (cap < INF && (city.population || 0) > cap) { city.population = Math.round(cap / 1000) * 1000; city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, 16); city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, 5.2); city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, 2.0); } } let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" })); const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity }; const usedNames = new Set(); const nameDebug = createNameDebug(); outputProgress("feature naming"); villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug); geographicUrbanAnchors = attachIdsAndNames(tagInsidePrefecture(geographicUrbanAnchors, prefectureMask), "geoAnchor", seed, null, nameFields, usedNames, nameDebug); ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug); crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug); passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames, nameDebug); markets = attachIdsAndNames(tagInsidePrefecture(markets, prefectureMask), "market", seed, null, nameFields, usedNames, nameDebug); castles = attachIdsAndNames(tagInsidePrefecture(castles, prefectureMask), "castle", seed, null, nameFields, usedNames, nameDebug); castleTowns = attachIdsAndNames(tagInsidePrefecture(castleTowns, prefectureMask), "castleTown", seed, null, nameFields, usedNames, nameDebug); modernCities = attachIdsAndNames(tagInsidePrefecture(modernCities, prefectureMask), "city", seed, null, nameFields, usedNames, nameDebug); stations = attachIdsAndNames(tagInsidePrefecture(stations, prefectureMask), "station", seed, null, nameFields, usedNames, nameDebug); industrialZones = attachIdsAndNames(tagInsidePrefecture(industrialZones, prefectureMask), "industrial", seed, null, nameFields, usedNames, nameDebug); interchanges = attachIdsAndNames(tagInsidePrefecture(interchanges, prefectureMask), "interchange", seed, null, nameFields, usedNames, nameDebug); logisticsParks = attachIdsAndNames(tagInsidePrefecture(logisticsParks, prefectureMask), "logistics", seed, null, nameFields, usedNames, nameDebug); satelliteCities = attachIdsAndNames(tagInsidePrefecture(satelliteCities, prefectureMask), "satellite", seed, null, nameFields, usedNames, nameDebug); newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug); castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug); externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug); outputProgress("municipality naming"); const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug); const representativeFeatures = [ ...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })), ...markets.map((p) => ({ ...p, representativeWeight: 3.2 })), ...ports.map((p) => ({ ...p, representativeWeight: p.portClass === "major" ? 3.8 : 2.4 })), ...villages.map((p) => ({ ...p, representativeWeight: 1.6 })), ].filter((p) => p.name && (p.insidePrefecture || humanRegionMask[indexOf(p.x, p.y)])); for (const center of adminCenters) { const centerAdmin = adminId?.[indexOf(center.x, center.y)]; let best = null; let bestScore = -INF; for (const sameAdminOnly of [true, false]) { for (const feature of representativeFeatures) { const featureAdmin = adminId?.[indexOf(feature.x, feature.y)]; if (sameAdminOnly && centerAdmin >= 0 && featureAdmin >= 0 && featureAdmin !== centerAdmin) continue; const d = Math.hypot(center.x - feature.x, center.y - feature.y); const score = feature.representativeWeight - d * 0.11 - (sameAdminOnly ? 0 : 1.2); if (score > bestScore) { bestScore = score; best = feature; } } if (best) break; } center.generatedMunicipalityName = center.generatedMunicipalityName || center.name; if (best) { center.representativeFeatureId = best.id; center.representativeFeatureName = best.name; center.canonicalSettlementId = best.id; center.canonicalSettlementName = best.name; center.municipalityRootName = best.name; } else { center.municipalityRootName = center.generatedMunicipalityName; } } const usedAdminNames = new Set(); for (const [index, center] of adminCenters.entries()) { center.adminNumericId = index; center.municipalityId = index; let candidate = municipalityNameFromRoot(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index); const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index); if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) { candidate = generated; } if (usedAdminNames.has(candidate)) { const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, index); const rootSource = String(center.generatedMunicipalityName || center.name || center.municipalityRootName || `自治${index + 1}`).replace(/[市町村区駅港城跡宿]$/gu, ""); const chars = Array.from(rootSource || "里郷"); const alternates = [ chars.slice(0, 2).join(""), chars.slice(-2).join(""), `${chars[0] || "里"}${["里", "郷", "野", "田", "川", "原", "浜", "浦"][(seed + index) % 8]}`, `${["東", "西", "南", "北", "上", "下", "中"][(seed + index) % 7]}${chars[0] || "里"}`, ].filter((v) => Array.from(v).length >= 2); for (let attempt = 0; attempt < alternates.length + 12 && usedAdminNames.has(candidate); attempt++) { const root = attempt < alternates.length ? alternates[attempt] : `第${(index + attempt) % 10}`; candidate = `${root}${suffix}`; } } center.name = candidate; center.labelName = candidate; center.municipalityName = candidate; usedAdminNames.add(center.name); } const promotedPrefectureCapitals = promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, nameFields, seed, 220000); assignMunicipalityPopulations(adminCenters, adminId, nameFields, [ ...modernCities, ...markets, ...villages, ...satelliteCities, ...newTowns, ]); function addMunicipalCenterLocalAccess() { const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = { repairedSegments: [], unservedSettlements: [] })) : { repairedSegments: [], unservedSettlements: [] }; debugLayers.repairedSegments ||= []; debugLayers.unservedSettlements ||= []; const influenceCache = new Map(); const signature = (paths) => `${paths?.length || 0}:${(paths || []).reduce((sum, path) => sum + (path?.length || 0), 0)}`; const cachedInfluence = (paths, radius, label) => { const key = `${label}:${radius}:${signature(paths)}`; let grid = influenceCache.get(key); if (!grid) { grid = influenceFromPaths(paths, radius); influenceCache.set(key, grid); } return grid; }; const accessInfluence = cachedInfluence([...nationalRoads, ...railways, ...externalRoads, ...minorRoads], 8, "municipal-access"); const localPenalty = cachedInfluence(minorRoads, 4, "municipal-minor"); const perPrefectureQuota = new Map(); const candidates = adminCenters .filter((p) => { if (!inside(p.x, p.y) || sea[indexOf(p.x, p.y)]) return false; const i = indexOf(p.x, p.y); const meaningful = (p.municipalityPopulation || 0) >= 4500 || (populationDensity?.[i] || 0) > 0.08 || (p.representativeFeatureName && accessInfluence[i] < 0.22); return meaningful && accessInfluence[i] < 0.42; }) .sort((a, b) => { const ai = indexOf(a.x, a.y); const bi = indexOf(b.x, b.y); const as = (a.municipalityPopulation || 0) + Math.max(0, 0.42 - accessInfluence[ai]) * 145000; const bs = (b.municipalityPopulation || 0) + Math.max(0, 0.42 - accessInfluence[bi]) * 145000; return bs - as; }) .filter((p) => { const prefId = municipalityToPrefectureId?.[p.municipalityId] ?? -1; const used = perPrefectureQuota.get(prefId) || 0; if (used >= 18) return false; perPrefectureQuota.set(prefId, used + 1); return true; }) .slice(0, 95); function addLocalPenalty(path, radius = 4, strength = 0.20) { for (const [x, y] of path || []) { for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { const nx = x + dx; const ny = y + dy; if (!inside(nx, ny)) continue; const d = Math.hypot(dx, dy); if (d > radius) continue; const i = indexOf(nx, ny); if (sea[i]) continue; localPenalty[i] = Math.max(localPenalty[i], strength * (1 - d / radius)); } } } } function routeAccess(start) { const startIndex = indexOf(start.x, start.y); if (sea[startIndex]) return []; const score = new Float32Array(MAP_W * MAP_H); const cameFrom = new Int32Array(MAP_W * MAP_H); const closed = new Uint8Array(MAP_W * MAP_H); score.fill(INF); cameFrom.fill(-1); const heap = new MinHeap(); score[startIndex] = 0; heap.push({ i: startIndex, f: 0 }); const maxExpanded = Math.min(MAP_W * MAP_H, 24000); let goal = -1; let expanded = 0; while (heap.length && expanded++ < maxExpanded) { const current = heap.pop(); if (!current || closed[current.i]) continue; closed[current.i] = 1; const [cx, cy] = xyOf(current.i); const straightDistance = Math.hypot(cx - start.x, cy - start.y); if (straightDistance > 3 && (accessInfluence[current.i] > 0.11 || localPenalty[current.i] > 0.06)) { goal = current.i; break; } if (straightDistance > 150) continue; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (!dx && !dy) continue; const nx = cx + dx; const ny = cy + dy; if (!inside(nx, ny)) continue; const ni = indexOf(nx, ny); if (closed[ni] || sea[ni]) continue; const step = Math.hypot(dx, dy); const highPenalty = Math.max(0, elevation[ni] - 0.64); const targetAttraction = Math.max(accessInfluence[ni] * 2.4, localPenalty[ni] * 1.25, roadInfluence[ni] * 1.8, railInfluence2[ni] * 1.2); const terrainCost = 1.0 + slope[ni] * 1.20 + ridgeField[ni] * 0.52 + highPenalty * 1.55 - plain[ni] * 0.38 - valleyField[ni] * 0.42 - coastalLowland[ni] * 0.18 + Math.max(0, localPenalty[ni] - 0.18) * 0.38 - targetAttraction; const nd = score[current.i] + Math.max(0.16, terrainCost) * step; if (nd < score[ni]) { score[ni] = nd; cameFrom[ni] = current.i; heap.push({ i: ni, f: nd }); } } } } if (goal < 0) return []; const path = []; for (let p = goal; p >= 0; p = cameFrom[p]) { path.push(xyOf(p)); if (p === startIndex) break; } path.reverse(); if (path.length < 4 || path.length > 112) return []; return routeQualityAcceptable(path, { sea, elevation, slope, potential: populationDensity, penalty: localPenalty, highElevationThreshold: 0.78, steepThreshold: 0.52 }, { minLength: 4, maxLength: 112, maxCompactness: 4.0, maxHighElevationShare: 0.22, maxSteepShare: 0.50, }) ? path : []; } for (const center of candidates) { const path = routeAccess(center); debugLayers.unservedSettlements.push({ x: center.x, y: center.y, kind: "Municipal Center", mode: "municipal-access", repaired: path.length >= 4 }); if (path.length < 4) continue; minorRoads.push(path); addLocalPenalty(path); debugLayers.repairedSegments.push({ mode: "municipal-local", path, from: "municipal-center", to: "network" }); } } addMunicipalCenterLocalAccess(); function pruneIsolatedFinalRoadComponents() { const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = {})) : {}; const groups = [ ["minor", minorRoads], ["national", nationalRoads], ["external", externalRoads], ["expressway", expressways], ["externalExpressway", externalExpressways], ]; function rasterize(path, fn) { for (let k = 0; k < (path?.length || 0); k++) { const [x0, y0] = path[k]; const [x1, y1] = path[Math.min(k + 1, path.length - 1)]; const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0))); for (let s = 0; s <= steps; s++) { const t = s / steps; const x = Math.round(x0 + (x1 - x0) * t); const y = Math.round(y0 + (y1 - y0) * t); fn(x, y); } } } function splitPathOnSea(path) { const chunks = []; let chunk = []; function pushPoint(x, y) { if (!inside(x, y) || sea[indexOf(x, y)]) { if (chunk.length >= 2) chunks.push(chunk); chunk = []; return; } if (!chunk.length || chunk[chunk.length - 1][0] !== x || chunk[chunk.length - 1][1] !== y) chunk.push([x, y]); } for (let k = 0; k < (path?.length || 0); k++) { const [x0, y0] = path[k]; const [x1, y1] = path[Math.min(k + 1, path.length - 1)]; const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0))); for (let s = 0; s <= steps; s++) { const t = s / steps; pushPoint(Math.round(x0 + (x1 - x0) * t), Math.round(y0 + (y1 - y0) * t)); } } if (chunk.length >= 2) chunks.push(chunk); return chunks; } // Keep sea-crossing cells in the stored path so the renderer can draw // explicit bridge overlays. Connectivity analysis below ignores sea cells // when rasterizing components, so preserving them here does not make islands // falsely connected by ordinary land roads. function pathNearAdminCenter(path, radius = 0.75) { for (const center of adminCenters || []) { if (!center || !inside(center.x, center.y)) continue; for (const [x, y] of path || []) { if (Math.hypot(center.x - x, center.y - y) <= radius) return true; } } return false; } function components() { const occ = new Uint8Array(MAP_W * MAP_H); for (const [, paths] of groups) for (const path of paths || []) rasterize(path, (x, y) => { if (inside(x, y) && !sea[indexOf(x, y)]) occ[indexOf(x, y)] = 1; }); const seen = new Uint8Array(MAP_W * MAP_H); const out = []; for (let i = 0; i < occ.length; i++) { if (!occ[i] || seen[i]) continue; const queue = [i]; const cells = []; seen[i] = 1; for (let q = 0; q < queue.length; q++) { const cur = queue[q]; cells.push(cur); const [x, y] = xyOf(cur); for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) { if (!dx && !dy) continue; if (dx * dx + dy * dy > 5) continue; const nx = x + dx, ny = y + dy; if (!inside(nx, ny)) continue; const ni = indexOf(nx, ny); if (!occ[ni] || seen[ni]) continue; seen[ni] = 1; queue.push(ni); } } out.push({ cells, size: cells.length }); } return out.sort((a, b) => b.size - a.size); } let comps = components(); const before = comps.length; const pruned = { minor: 0, national: 0, external: 0, expressway: 0, externalExpressway: 0 }; for (let pass = 0; pass < 4 && comps.length > 1; pass++) { const mainMask = new Uint8Array(MAP_W * MAP_H); for (const ci of comps[0].cells) { const [cx, cy] = xyOf(ci); for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) { if (dx * dx + dy * dy > 5) continue; const nx = cx + dx, ny = cy + dy; if (inside(nx, ny)) mainMask[indexOf(nx, ny)] = 1; } } function touchesMain(path) { let hit = 0, n = 0; rasterize(path, (x, y) => { if (!inside(x, y) || sea[indexOf(x, y)]) return; n++; if (mainMask[indexOf(x, y)]) hit++; }); return n > 0 && hit / n >= (pass === 0 ? 0.10 : 0.01); } for (const [key, paths] of groups) { const kept = []; for (const path of paths || []) { if (touchesMain(path) || pathNearAdminCenter(path)) kept.push(path); else pruned[key]++; } paths.length = 0; paths.push(...kept); } comps = components(); } debugLayers.finalOutputRoadConnectivity = { beforeComponents: before, afterComponents: comps.length, pruned }; } pruneIsolatedFinalRoadComponents(); function ensureAdminCenterCellsAfterOutputPrune() { let added = 0; function roadTouches(center) { for (const path of [...minorRoads, ...nationalRoads, ...externalRoads]) { for (const [x, y] of path || []) if (Math.hypot(center.x - x, center.y - y) <= 0.65) return true; } return false; } for (const center of adminCenters || []) { if (!center || !inside(center.x, center.y) || sea[indexOf(center.x, center.y)]) continue; if (roadTouches(center)) continue; const x = Math.round(center.x); const y = Math.round(center.y); const horizontal = [[Math.max(0, x - 1), y], [x, y], [Math.min(MAP_W - 1, x + 1), y]] .filter((pt, idx, arr) => idx === 0 || pt[0] !== arr[idx - 1][0] || pt[1] !== arr[idx - 1][1]); const vertical = [[x, Math.max(0, y - 1)], [x, y], [x, Math.min(MAP_H - 1, y + 1)]] .filter((pt, idx, arr) => idx === 0 || pt[0] !== arr[idx - 1][0] || pt[1] !== arr[idx - 1][1]); minorRoads.push(horizontal.length >= 2 ? horizontal : vertical); added++; } if (transportDebug) { transportDebug.layers ||= {}; transportDebug.layers.adminCenterFinalStubs = added; } } ensureAdminCenterCellsAfterOutputPrune(); nameDebug.maxDerivedPerBase = 0; const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug, { modernCities, adminCenters }); const regionalPrefectureBorders = adminRegionalPrefectureBorders || []; if (regionalDebug) { regionalDebug.finalRegionalPrefectureBorderCount = regionalPrefectureBorders.length; regionalDebug.regionalPrefectureBordersRebuiltFromFinalId = true; regionalDebug.promotedPrefectureCapitals = promotedPrefectureCapitals; } outputProgress("final package"); const entitiesForNames = [ ...modernCities, ...ports, ...markets, ...castles, ...stations, ...industrialZones, ...interchanges, ...logisticsParks, ...satelliteCities, ...newTowns, ...passes, ...crossings, ...adminCenters, ...externalGateways, ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); return applyOutputOptions({ width: MAP_W, height: MAP_H, cellSize: CELL_SIZE, terrainTemplate, seaLevel, prefectureMask, humanRegionMask, prefectureBorder: regionalPrefectureBorders.length ? regionalPrefectureBorders : prefectureBorder, prefectureRegionId, municipalityToPrefectureId, prefectureRegions, regionalDebug, terrainDebug, regionalPrefectureBorders, geography, geographyDebug: geography?.geographyDebug || null, habitability: geography?.habitability || null, accessibility: geography?.accessibility || null, centrality: geography?.centrality || null, naturalCentrality: geography?.naturalCentrality || null, humanCentrality: geography?.humanCentrality || null, transportAccessibility: geography?.transportAccessibility || null, geographicBarrier: geography?.geographicBarrier || null, geographicBarrierCost: geography?.geographicBarrierCost || geography?.barrierCost || null, barrierCost: geography?.barrierCost || geography?.geographicBarrierCost || null, corridorSuitability: geography?.corridorSuitability || null, adminBoundaryPreference: geography?.adminBoundaryPreference || null, boundaryAvoidance: geography?.boundaryAvoidance || null, lowlandCapacity: geography?.lowlandCapacity || null, valleyAccess: geography?.valleyAccess || null, coastalAccess: geography?.coastalAccess || null, geographicCompartmentProfiles: geography?.compartmentProfiles || [], watershedProfiles: geography?.watershedProfiles || [], elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, visibleRavineField, surfaceTextureField, basinField, coastalLowland, flowAccum, watershedId, erosionField, depositionField, arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore, naturalCompartmentId, naturalCompartments, villages, geographicUrbanAnchors, ports, crossings, passes, markets, castles, castleTowns, premodernRoads, minorRoads, modernCities, prefecturalCapital: modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]) || null, totalPopulation: [...modernCities, ...satelliteCities].reduce((sum, city) => sum + (city.population || 0), 0), populationDensity, railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways, interchanges, logisticsParks, satelliteCities, newTowns, landuse, adminCenters, adminId, adminBorders, adminDebug, castleRuins, riverPaths, mainRivers, tributaryRivers, smallStreams, externalGateways, transportDebug, entitiesForNames, nameDebug, }, options); }