map/mapOutput.js
2026-05-27 00:13:13 +09:00

733 lines
31 KiB
JavaScript

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 (Array.from(value).length < 2) value = `${value}${fallback || "里"}`;
// Municipality roots should be at most two toponymic elements. The admin
// suffix is separate; avoid direction+root+suffix three-element names.
value = Array.from(value).slice(0, 2).join("");
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);
for (let i = 0; i < adminId.length; i++) {
const id = adminId[i];
if (id < 0 || id >= totals.length || fields.sea?.[i]) continue;
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;
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 rounded = raw >= 10000 ? Math.round(raw / 1000) * 1000 : Math.round(raw / 100) * 100;
adminCenters[id].municipalityPopulation = Math.max(0, rounded);
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);
}
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 promotedPopulation = Math.round((minPopulation + rand(seed + 52000, prefId * 37 + 11) * 120000) / 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,
}) {
const {
terrainTemplate,
seaLevel,
elevation,
moisture,
slope,
sea,
ocean,
lake,
river,
floodplain,
plain,
agriculture,
ridgeField,
valleyField,
visibleRavineField,
surfaceTextureField,
basinField,
coastalLowland,
flowAccum,
erosionField,
depositionField,
arcSpineField,
branchRidgeField,
depositionalLowland,
alluvialFanField,
deltaField,
naturalBarrierScore,
riverPaths,
mainRivers,
tributaryRivers,
smallStreams,
prefectureMask,
prefectureBorder,
terrainDebug,
} = terrain;
const {
cityPopulationCap,
stationInfluence,
roadInfluence,
railInfluence2,
settlementCluster,
villages: inputVillages,
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 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);
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 = `${Array.from(root).slice(0, 2).join("")}${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();
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,
elevation,
moisture,
slope,
sea,
ocean,
lake,
river,
floodplain,
plain,
agriculture,
settlementCluster,
ridgeField,
valleyField,
visibleRavineField,
surfaceTextureField,
basinField,
coastalLowland,
flowAccum,
erosionField,
depositionField,
arcSpineField,
branchRidgeField,
depositionalLowland,
alluvialFanField,
deltaField,
naturalBarrierScore,
naturalCompartmentId,
naturalCompartments,
villages,
ports,
crossings,
passes,
markets,
castles,
castleTowns,
premodernRoads,
minorRoads,
modernCities,
prefecturalCapital: modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]) || null,
totalPopulation: [...modernCities, ...satelliteCities].reduce((sum, city) => sum + (city.population || 0), 0),
populationDensity,
railways,
branchRailways,
ringRailways,
externalRailways,
stations,
industrialZones,
nationalRoads,
ringRoads,
expressways,
ringExpressways,
icAccessRoads,
externalRoads,
externalExpressways,
interchanges,
logisticsParks,
satelliteCities,
newTowns,
landuse,
adminCenters,
adminId,
adminBorders,
adminDebug,
castleRuins,
riverPaths,
mainRivers,
tributaryRivers,
smallStreams,
externalGateways,
transportDebug,
entitiesForNames,
nameDebug,
}, options);
}