road update

This commit is contained in:
33333-33333 2026-05-26 21:14:37 +09:00
commit a6e66f2838
9 changed files with 2174 additions and 258 deletions

View file

@ -703,7 +703,7 @@ function naturalGroupKey(unit) {
if (unit.classId <= 3) return `urban:${Math.round(unit.x / 10)}:${Math.round(unit.y / 10)}`;
if (unit.classId === 5) return `coast:${Math.round(unit.y / 8)}`;
if (unit.classId === 6) return `basin:${Math.round(unit.x / 12)}:${Math.round(unit.y / 12)}`;
if (unit.classId === 7) return `valley:${Math.round((unit.x + unit.y) / 12)}`;
if (unit.classId === 7) return `valley:${Math.round(unit.x / 11)}:${Math.round(unit.y / 11)}`;
if (unit.classId === 8 || unit.classId === 9) return `mountain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`;
return `plain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`;
}
@ -1030,6 +1030,7 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields);
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
mergeTinyLandscapeUnits(compartmentId, compartments, 9);
mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(42, Math.round(landArea / Math.max(1, targetCount) * 2.15)), 5);
progress?.(`natural post-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`);
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
@ -1067,6 +1068,7 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
}
}
mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(48, Math.round(landArea / Math.max(1, targetCount) * 2.05)), 4);
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
refreshAllCompartmentStats(compartments, fields);
@ -1137,10 +1139,11 @@ function rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMas
const b = unitId[ni];
if (b < 0 || b === a || !units[b] || units[b].area === 0) continue;
const v = (targetScore[i] + targetScore[ni]) * 0.5;
const keyA = units[a].adjacent.get(b) || { count: 0, target: 0 };
keyA.count++; keyA.target += v; units[a].adjacent.set(b, keyA);
const keyB = units[b].adjacent.get(a) || { count: 0, target: 0 };
keyB.count++; keyB.target += v; units[b].adjacent.set(a, keyB);
const vertical = nx !== x ? 1 : 0;
const keyA = units[a].adjacent.get(b) || { count: 0, target: 0, vertical: 0, horizontal: 0 };
keyA.count++; keyA.target += v; if (vertical) keyA.vertical++; else keyA.horizontal++; units[a].adjacent.set(b, keyA);
const keyB = units[b].adjacent.get(a) || { count: 0, target: 0, vertical: 0, horizontal: 0 };
keyB.count++; keyB.target += v; if (vertical) keyB.vertical++; else keyB.horizontal++; units[b].adjacent.set(a, keyB);
}
}
}
@ -1172,6 +1175,65 @@ function mergeTinyLandscapeUnits(unitId, units, minArea = 10) {
}
}
function mergeUnitInto(unitId, units, fromId, toId, fields) {
const from = units[fromId];
const to = units[toId];
if (!from || !to || from.area === 0 || to.area === 0 || fromId === toId) return false;
for (const ci of from.cells) { unitId[ci] = toId; to.cells.push(ci); }
from.area = 0;
from.cells = [];
refreshCompartmentStats(to, fields.elevation, fields.slope, fields.ridgeField, fields.valleyField, fields.basinField, fields.coastalLowland, fields.plain, fields.agriculture, fields.populationDensity, fields.landuse);
return true;
}
function mergeWeakArtificialLandscapeUnits(unitId, units, fields, prefectureMask, sea, maxMergedArea = 84, passes = 5) {
// Seeded graph growth can create diagonal stair-step borders in uniform plains
// and gentle hills. If the shared edge is weak, balanced H/V, and the two
// sides are the same natural group, merge it instead of preserving an
// artificial Voronoi-like cut.
for (let pass = 0; pass < passes; pass++) {
rebuildLandscapeUnitAdjacency(unitId, units, fields.naturalBarrierScore, prefectureMask, sea);
let best = null;
let bestScore = 0.0;
for (const unit of units) {
if (!unit || unit.area === 0) continue;
for (const [otherId, edge] of unit.adjacent) {
if (otherId <= unit.id) continue;
const other = units[otherId];
if (!other || other.area === 0) continue;
const avgBarrier = edge.target / Math.max(1, edge.count);
const sameClass = unit.classId === other.classId;
const sameGroup = naturalGroupKey(unit) === naturalGroupKey(other);
const combinedArea = unit.area + other.area;
if (!sameClass && !sameGroup) continue;
if (combinedArea > maxMergedArea && unit.area > 18 && other.area > 18) continue;
const h = edge.horizontal || 0;
const v = edge.vertical || 0;
const balancedStair = Math.min(h, v) / Math.max(1, h + v);
const lowlandContinuity = Math.min(unit.lowlandFitness || 0, other.lowlandFitness || 0);
const mountainContinuity = Math.min(unit.mountainFitness || 0, other.mountainFitness || 0);
const urbanGuard = Math.max(unit.urbanWeight || 0, other.urbanWeight || 0);
const weakDivider = avgBarrier < (sameClass ? 0.46 : 0.36);
if (!weakDivider) continue;
const score =
(sameClass ? 1.7 : 0) +
(sameGroup ? 1.2 : 0) +
balancedStair * 1.4 +
edge.count * 0.018 +
lowlandContinuity * 0.85 +
mountainContinuity * 0.35 -
avgBarrier * 4.8 -
Math.max(0, combinedArea - maxMergedArea) * 0.020 -
urbanGuard * 0.20;
if (score > bestScore) best = { fromId: unit.area <= other.area ? unit.id : otherId, toId: unit.area <= other.area ? otherId : unit.id }, bestScore = score;
}
}
if (!best) break;
mergeUnitInto(unitId, units, best.fromId, best.toId, fields);
}
}
function naturalOwnershipAffinity(unit, neighbor, edge) {
const boundaryTarget = edge.target / Math.max(1, edge.count);
const sameClass = unit.classId === neighbor.classId ? 1.0 : 0;

3
app.js
View file

@ -10,8 +10,8 @@ const modes = [
["development", "Development"],
["landuse", "Land Use"],
["admin", "Municipal Borders"],
["admin-debug", "Admin Debug"],
["borders-debug", "Borders Debug"],
["transport-debug", "Transport Debug"],
];
const state = {
@ -148,7 +148,6 @@ function getStats(map) {
["Logistics Parks", countText(map.logisticsParks)],
["New Towns", countText(map.newTowns)],
["Municipalities", map.adminCenters.length],
["Admin changed cells", map.adminDebug ? `${map.adminDebug.changedAfterLandscapePartition || 0} partition / ${map.adminDebug.changedAfterSnap || 0} snap` : "-"],
["Prefecture source", map.regionalDebug?.prefectureSource ?? "-"],
];
}

View file

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prefecture Map Generator v17</title>
<title>Prefecture Map Generator</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>

View file

@ -99,13 +99,440 @@ function enforceCompartmentMunicipalityOwnership(adminId, compartmentId, compart
return changed;
}
function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity) {
function mergeSingleCompartmentMunicipalities(adminId, compartments, prefectureMask, sea, minCompartments = 2, maxPasses = 8) {
if (!compartments?.length) return { changedCells: 0, mergedMunicipalities: 0, remainingSingleCompartmentMunicipalities: 0 };
let totalChangedCells = 0;
let mergedMunicipalities = 0;
let remainingSingles = 0;
for (let pass = 0; pass < maxPasses; pass++) {
const owner = dominantCompartmentOwners(compartments, adminId);
const byOwner = new Map();
const areaByOwner = new Map();
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const id = owner[unit.id];
if (id < 0) continue;
if (!byOwner.has(id)) byOwner.set(id, []);
byOwner.get(id).push(unit);
areaByOwner.set(id, (areaByOwner.get(id) || 0) + unit.area);
}
const singles = [...byOwner.entries()]
.filter(([, units]) => units.length > 0 && units.length < minCompartments)
.sort((a, b) => (areaByOwner.get(a[0]) || 0) - (areaByOwner.get(b[0]) || 0) || a[0] - b[0]);
remainingSingles = singles.length;
if (!singles.length) break;
let passChanged = 0;
for (const [id, units] of singles) {
// The old rule allowed one natural compartment to become one municipality.
// That produces many tiny office-only municipalities and makes the hierarchy
// hard to read. Merge such municipalities into the strongest adjacent owner.
const neighborScores = new Map();
for (const unit of units) {
for (const [neighborId, edge] of unit.adjacent || []) {
const candidate = owner[neighborId];
if (candidate < 0 || candidate === id) continue;
const neighborUnit = compartments[neighborId];
const shared = edge.count || 1;
const barrier = edge.target ? edge.target / Math.max(1, shared) : 0;
const sameLandscape = neighborUnit?.classId === unit.classId ? 0.7 : 0;
const score = shared * (2.2 - Math.min(1.6, barrier) + sameLandscape) + Math.sqrt(areaByOwner.get(candidate) || 1) * 0.05;
neighborScores.set(candidate, (neighborScores.get(candidate) || 0) + score);
}
}
let best = -1, bestScore = -INF;
for (const [candidate, score] of neighborScores) {
if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; }
}
if (best < 0) {
// One-cell islets have no land adjacency. Attach them to the nearest
// existing municipality instead of leaving a one-compartment municipality.
const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
let bestDist = INF;
for (const [candidate, candidateUnits] of byOwner) {
if (candidate === id || candidateUnits.length < minCompartments) continue;
for (const unit of candidateUnits) {
const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy);
if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; }
}
}
if (bestDist > 28) best = -1;
}
if (best < 0) continue;
for (const unit of units) {
for (const i of unit.cells || []) {
if (!prefectureMask[i] || sea[i]) continue;
if (adminId[i] !== best) {
adminId[i] = best;
passChanged++;
}
}
}
mergedMunicipalities++;
}
totalChangedCells += passChanged;
if (!passChanged) break;
}
const finalOwner = dominantCompartmentOwners(compartments, adminId);
const finalCounts = new Map();
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const id = finalOwner[unit.id];
if (id >= 0) finalCounts.set(id, (finalCounts.get(id) || 0) + 1);
}
remainingSingles = [...finalCounts.values()].filter((count) => count > 0 && count < minCompartments).length;
return { changedCells: totalChangedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities: remainingSingles };
}
function ownerAreaByCompartment(owner, compartments) {
const area = new Map();
const count = new Map();
for (const unit of compartments || []) {
if (!unit || unit.area === 0) continue;
const id = owner[unit.id];
if (id < 0) continue;
area.set(id, (area.get(id) || 0) + (unit.area || 0));
count.set(id, (count.get(id) || 0) + 1);
}
return { area, count };
}
function compartmentTouchesOutside(unit, prefectureMask, sea) {
if (!unit?.cells?.length) return true;
for (const i of unit.cells) {
const [x, y] = xyOf(i);
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) return true;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) return true;
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) return true;
}
}
return false;
}
function bestNeighborOwnerForUnits(units, owner, compartments, allowNearestFallback = false) {
const { area } = ownerAreaByCompartment(owner, compartments);
const scores = new Map();
const blocked = new Set(units.map((unit) => owner[unit.id]));
for (const unit of units) {
for (const [neighborId, edge] of unit.adjacent || []) {
const candidate = owner[neighborId];
if (candidate < 0 || blocked.has(candidate)) continue;
const neighbor = compartments[neighborId];
const shared = edge.count || 1;
const barrier = (edge.target || 0) / Math.max(1, shared);
const landscape = neighbor?.classId === unit.classId ? 0.75 : 0;
const lowland = Math.min(unit.lowlandFitness || 0, neighbor?.lowlandFitness || 0) * 0.5;
const score = shared * (2.4 + landscape + lowland - Math.min(1.8, barrier)) + Math.sqrt(area.get(candidate) || 1) * 0.04;
scores.set(candidate, (scores.get(candidate) || 0) + score);
}
}
let best = -1, bestScore = -INF;
for (const [candidate, score] of scores) {
if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; }
}
if (best >= 0 || !allowNearestFallback) return best;
const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
let bestDist = INF;
for (const unit of compartments || []) {
if (!unit || unit.area === 0) continue;
const candidate = owner[unit.id];
if (candidate < 0 || blocked.has(candidate)) continue;
const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy);
if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; }
}
return bestDist <= 32 ? best : -1;
}
function mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, minCompartments = 2, maxPasses = 8) {
let changedCells = 0;
let mergedMunicipalities = 0;
let remainingSingleCompartmentMunicipalities = 0;
for (let pass = 0; pass < maxPasses; pass++) {
const byOwner = new Map();
for (const unit of compartments || []) {
if (!unit || unit.area === 0) continue;
const id = owner[unit.id];
if (id < 0) continue;
if (!byOwner.has(id)) byOwner.set(id, []);
byOwner.get(id).push(unit);
}
const small = [...byOwner.entries()]
.filter(([, units]) => units.length > 0 && units.length < minCompartments)
.sort((a, b) => a[1].length - b[1].length || a[0] - b[0]);
remainingSingleCompartmentMunicipalities = small.length;
if (!small.length) break;
let passChanged = 0;
for (const [id, units] of small) {
const target = bestNeighborOwnerForUnits(units, owner, compartments, true);
if (target < 0 || target === id) continue;
for (const unit of units) {
if (owner[unit.id] === target) continue;
owner[unit.id] = target;
changedCells += unit.area || 0;
passChanged += unit.area || 0;
}
mergedMunicipalities++;
}
if (!passChanged) break;
}
const counts = ownerAreaByCompartment(owner, compartments).count;
remainingSingleCompartmentMunicipalities = [...counts.values()].filter((count) => count > 0 && count < minCompartments).length;
return { changedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities };
}
function repairCompartmentOwnerConnectivity(owner, compartments, maxPasses = 8) {
let changedCells = 0;
let changedComponents = 0;
for (let pass = 0; pass < maxPasses; pass++) {
const ownerIds = [...new Set([...owner].filter((id) => id >= 0))].sort((a, b) => a - b);
let passChanged = 0;
for (const id of ownerIds) {
const members = (compartments || []).filter((unit) => unit && unit.area > 0 && owner[unit.id] === id);
if (members.length <= 1) continue;
const memberSet = new Set(members.map((unit) => unit.id));
const seen = new Set();
const components = [];
for (const unit of members) {
if (seen.has(unit.id)) continue;
const queue = [unit.id];
const comp = [];
seen.add(unit.id);
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
comp.push(compartments[cur]);
for (const next of compartments[cur]?.adjacent?.keys?.() || []) {
if (!memberSet.has(next) || seen.has(next)) continue;
seen.add(next);
queue.push(next);
}
}
components.push(comp);
}
if (components.length <= 1) continue;
components.sort((a, b) => b.reduce((sum, unit) => sum + (unit.area || 0), 0) - a.reduce((sum, unit) => sum + (unit.area || 0), 0));
for (const comp of components.slice(1)) {
const target = bestNeighborOwnerForUnits(comp, owner, compartments, true);
if (target < 0 || target === id) continue;
for (const unit of comp) {
owner[unit.id] = target;
changedCells += unit.area || 0;
passChanged += unit.area || 0;
}
changedComponents++;
}
}
if (!passChanged) break;
}
return { changedCells, changedComponents };
}
function repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, maxPasses = 6) {
let changedCells = 0;
let changedComponents = 0;
const outsideCache = new Map();
const touchesOutside = (unit) => {
if (!outsideCache.has(unit.id)) outsideCache.set(unit.id, compartmentTouchesOutside(unit, prefectureMask, sea));
return outsideCache.get(unit.id);
};
for (let pass = 0; pass < maxPasses; pass++) {
const ownerIds = [...new Set([...owner].filter((id) => id >= 0))].sort((a, b) => a - b);
let passChanged = 0;
for (const id of ownerIds) {
const members = (compartments || []).filter((unit) => unit && unit.area > 0 && owner[unit.id] === id);
if (!members.length) continue;
const memberSet = new Set(members.map((unit) => unit.id));
const seen = new Set();
for (const unit of members) {
if (seen.has(unit.id)) continue;
const queue = [unit.id];
const comp = [];
const boundaryOwners = new Map();
let outside = false;
seen.add(unit.id);
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const curUnit = compartments[cur];
if (!curUnit) continue;
comp.push(curUnit);
if (touchesOutside(curUnit)) outside = true;
for (const [next, edge] of curUnit.adjacent || []) {
const nextOwner = owner[next];
if (nextOwner === id) {
if (!seen.has(next) && memberSet.has(next)) { seen.add(next); queue.push(next); }
} else if (nextOwner >= 0) {
boundaryOwners.set(nextOwner, (boundaryOwners.get(nextOwner) || 0) + (edge.count || 1));
}
}
}
if (outside || boundaryOwners.size !== 1) continue;
const [target] = boundaryOwners.keys();
if (target < 0 || target === id) continue;
for (const compUnit of comp) {
owner[compUnit.id] = target;
changedCells += compUnit.area || 0;
passChanged += compUnit.area || 0;
}
changedComponents++;
}
}
if (!passChanged) break;
}
return { changedCells, changedComponents };
}
function lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, maxCells = 1100) {
const isUrbanUnit = (unit) => unit && unit.area > 0 && (
unit.classId <= 3 ||
(unit.urbanWeight || 0) >= 0.34 ||
((unit.urbanWeight || 0) >= 0.22 && (unit.lowlandFitness || 0) >= 0.34)
);
const seen = new Set();
let changedCells = 0;
let unifiedComponents = 0;
for (const start of compartments || []) {
if (!isUrbanUnit(start) || seen.has(start.id)) continue;
const queue = [start.id];
const comp = [];
seen.add(start.id);
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const unit = compartments[cur];
if (!isUrbanUnit(unit)) continue;
comp.push(unit);
for (const next of unit.adjacent?.keys?.() || []) {
if (seen.has(next) || !isUrbanUnit(compartments[next])) continue;
seen.add(next);
queue.push(next);
}
}
const totalArea = comp.reduce((sum, unit) => sum + (unit.area || 0), 0);
if (comp.length <= 1 || totalArea <= 0 || totalArea > maxCells) continue;
const ownerScore = new Map();
for (const unit of comp) {
const id = owner[unit.id];
if (id < 0) continue;
const score = (unit.area || 0) * (1 + (unit.urbanWeight || 0) * 1.8);
ownerScore.set(id, (ownerScore.get(id) || 0) + score);
}
let best = -1, bestScore = -INF;
for (const [id, score] of ownerScore) if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; }
if (best < 0) continue;
let localChanged = 0;
for (const unit of comp) {
if (owner[unit.id] === best) continue;
owner[unit.id] = best;
localChanged += unit.area || 0;
}
if (localChanged > 0) {
changedCells += localChanged;
unifiedComponents++;
}
}
return { changedCells, unifiedComponents };
}
function lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields = {}) {
const { modernCities = [] } = fields;
if (!owner || !compartments?.length || !modernCities?.length) return { changedCells: 0, unifiedCities: 0 };
let changedCells = 0;
let unifiedCities = 0;
const urbanUnit = (unit) => unit && unit.area > 0 && (
unit.classId <= 3 ||
(unit.urbanWeight || 0) >= 0.24 ||
((unit.urbanWeight || 0) >= 0.16 && (unit.lowlandFitness || 0) >= 0.40)
);
for (const city of modernCities) {
if (!city || !Number.isFinite(city.x) || !Number.isFinite(city.y) || (city.population || 0) < 18000) continue;
const radius = clamp(
(city.urbanRadius || 8) * ((city.population || 0) >= 200000 ? 1.95 : (city.population || 0) >= 80000 ? 1.65 : 1.35),
8,
(city.population || 0) >= 200000 ? 34 : 24
);
const units = [];
for (const unit of compartments) {
if (!urbanUnit(unit)) continue;
const d = Math.hypot((unit.x || 0) - city.x, (unit.y || 0) - city.y);
if (d > radius) continue;
const weight = (unit.area || 1) *
(1 + (unit.urbanWeight || 0) * 2.4 + (unit.lowlandFitness || 0) * 0.55) *
Math.max(0.20, 1 - d / Math.max(1, radius) * 0.58);
units.push({ unit, weight, d });
}
if (units.length <= 1) continue;
const totalArea = units.reduce((sum, row) => sum + (row.unit.area || 0), 0);
// Large multi-core conurbations may legitimately contain multiple municipalities.
// This pass targets compact urban areas that visually read as one city.
const maxArea = (city.population || 0) >= 200000 ? 1800 : 900;
if (totalArea > maxArea) continue;
const ownerScore = new Map();
for (const row of units) {
const id = owner[row.unit.id];
if (id < 0) continue;
ownerScore.set(id, (ownerScore.get(id) || 0) + row.weight);
}
if (ownerScore.size <= 1) continue;
let best = -1, bestScore = -INF, totalScore = 0;
for (const [id, score] of ownerScore) {
totalScore += score;
if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; }
}
if (best < 0 || bestScore / Math.max(1, totalScore) < 0.28) continue;
let localChanged = 0;
for (const row of units) {
if (owner[row.unit.id] === best) continue;
owner[row.unit.id] = best;
localChanged += row.unit.area || 0;
}
if (localChanged > 0) {
changedCells += localChanged;
unifiedCities++;
}
}
return { changedCells, unifiedCities };
}
function enforceSimpleAdministrativeHierarchy(adminId, compartments, prefectureMask, sea, fields = {}) {
const owner = dominantCompartmentOwners(compartments, adminId);
const urban = lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, fields.maxUrbanClusterCells || 1100);
const metro = lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields);
const connectivity1 = repairCompartmentOwnerConnectivity(owner, compartments, 8);
const enclave1 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 6);
const singleMerge = mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, fields.minCompartmentsPerMunicipality || 2, 8);
const connectivity2 = repairCompartmentOwnerConnectivity(owner, compartments, 8);
const enclave2 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 6);
const singleMerge2 = mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, fields.minCompartmentsPerMunicipality || 2, 4);
const connectivity3 = repairCompartmentOwnerConnectivity(owner, compartments, 4);
const enclave3 = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 4);
applyCompartmentOwners(adminId, compartments, owner);
const counts = ownerAreaByCompartment(owner, compartments).count;
return {
changedAfterUrbanUnification: urban.changedCells + metro.changedCells,
urbanComponentsUnified: urban.unifiedComponents,
changedAfterCityMetroMunicipalityUnification: metro.changedCells,
cityMetroMunicipalitiesUnified: metro.unifiedCities,
changedAfterCompartmentConnectivity: connectivity1.changedCells + connectivity2.changedCells + connectivity3.changedCells,
disconnectedCompartmentComponentsMerged: connectivity1.changedComponents + connectivity2.changedComponents + connectivity3.changedComponents,
changedAfterCompartmentEnclaveRepair: enclave1.changedCells + enclave2.changedCells + enclave3.changedCells,
compartmentEnclaveComponentsMerged: enclave1.changedComponents + enclave2.changedComponents + enclave3.changedComponents,
changedAfterSingleCompartmentMunicipalityMerge: singleMerge.changedCells + singleMerge2.changedCells,
singleCompartmentMunicipalitiesMerged: singleMerge.mergedMunicipalities + singleMerge2.mergedMunicipalities,
remainingSingleCompartmentMunicipalities: [...counts.values()].filter((count) => count > 0 && count < (fields.minCompartmentsPerMunicipality || 2)).length,
};
}
function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity, settlementFeatures = []) {
const nodes = new Map();
const edges = new Map();
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
const id = adminId[i];
if (!nodes.has(id)) nodes.set(id, { id, area: 0, population: 0, sx: 0, sy: 0, touchesOutside: false });
if (!nodes.has(id)) nodes.set(id, { id, area: 0, population: 0, cityPopulation: 0, settlementPopulation: 0, majorCityCount: 0, sx: 0, sy: 0, touchesOutside: false });
const node = nodes.get(id);
const [x, y] = xyOf(i);
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) node.touchesOutside = true;
@ -131,6 +558,21 @@ function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScor
edges.set(key, edge);
}
}
for (const feature of settlementFeatures || []) {
if (!feature || !inside(feature.x, feature.y)) continue;
const i = indexOf(feature.x, feature.y);
if (!prefectureMask[i] || sea[i]) continue;
const id = adminId[i];
const node = nodes.get(id);
if (!node) continue;
const pop = Math.max(0, feature.population || 0);
node.settlementPopulation += pop;
if (feature.kind === "Regional Capital" || feature.kind === "Prefectural Capital" || feature.kind === "Regional City" || feature.kind === "Local City" || feature.isRegionalCapital || feature.isPrefecturalCapital) {
node.cityPopulation += pop;
node.majorCityCount += pop >= 120000 ? 1 : 0;
}
node.population += pop / 16000;
}
for (const node of nodes.values()) {
node.x = node.sx / Math.max(1, node.area);
node.y = node.sy / Math.max(1, node.area);
@ -138,6 +580,11 @@ function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScor
}
for (const edge of edges.values()) {
edge.barrier /= Math.max(1, edge.count);
// Prefecture grouping should pay the same natural-compartment crossing
// cost that municipality generation uses: ridges, rivers, valley walls and
// other strong natural dividers should be expensive to cross. Short shared
// boundaries are also unstable, so they get a small extra penalty.
edge.crossingCost = 1.0 + edge.barrier * 8.5 + 2.6 / Math.sqrt(Math.max(1, edge.count));
nodes.get(edge.a)?.adjacent.set(edge.b, edge);
nodes.get(edge.b)?.adjacent.set(edge.a, edge);
}
@ -147,21 +594,44 @@ function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScor
function choosePrefectureMunicipalitySeeds(nodes, seed) {
const active = [...nodes.values()].filter((node) => node.area > 0).sort((a, b) => b.area - a.area || a.id - b.id);
const totalArea = active.reduce((sum, node) => sum + node.area, 0);
const targetCount = clamp(Math.round(totalArea / 7200), 3, 6);
// Real Japan's smallest prefecture by municipality count is roughly Toyama's 15.
// Keep generated prefectures near that scale by limiting prefecture count unless
// enough municipalities exist to give each prefecture a meaningful set.
const minMunicipalitiesPerPrefecture = 14;
const maxByMunicipalityCount = Math.max(3, Math.floor(active.length / minMunicipalitiesPerPrefecture));
const areaBased = clamp(Math.round(totalArea / 7800), 3, 6);
const targetCount = clamp(Math.min(areaBased, maxByMunicipalityCount || areaBased), 3, 6);
const seeds = [];
const first = active.sort((a, b) => (b.population * 1.8 + b.area * 0.08) - (a.population * 1.8 + a.area * 0.08) || a.id - b.id)[0];
if (first) seeds.push(first);
const minSpacing = Math.max(18, Math.sqrt(totalArea / Math.max(1, targetCount)) * 0.46);
function tryAdd(node, relaxed = false) {
if (!node || seeds.includes(node) || seeds.length >= targetCount) return false;
const nearest = seeds.length ? Math.min(...seeds.map((s) => Math.hypot(node.x - s.x, node.y - s.y))) : INF;
if (!relaxed && nearest < minSpacing) return false;
seeds.push(node);
return true;
}
const capitalLike = active
.filter((node) => (node.cityPopulation || 0) >= 120000 || node.majorCityCount > 0)
.sort((a, b) => (b.cityPopulation || 0) - (a.cityPopulation || 0) || b.population - a.population || a.id - b.id);
for (const node of capitalLike) tryAdd(node, false);
for (const node of capitalLike) tryAdd(node, true);
if (!seeds.length) {
const first = active.slice().sort((a, b) => (b.population * 1.8 + b.area * 0.08) - (a.population * 1.8 + a.area * 0.08) || a.id - b.id)[0];
if (first) seeds.push(first);
}
while (seeds.length < targetCount) {
let best = null, bestScore = -INF;
for (const node of active) {
if (seeds.includes(node)) continue;
const nearest = Math.min(...seeds.map((s) => Math.hypot(node.x - s.x, node.y - s.y)));
const score = nearest * (1.0 + hash2(seed, node.id) * 0.08) + Math.sqrt(node.area) * 0.12 + node.population * 0.28;
const capitalBonus = Math.sqrt(Math.max(0, node.cityPopulation || 0)) * 0.05 + (node.majorCityCount || 0) * 8;
const score = nearest * (1.0 + hash2(seed, node.id) * 0.08) + Math.sqrt(node.area) * 0.12 + node.population * 0.28 + capitalBonus;
if (score > bestScore) { bestScore = score; best = node; }
}
if (!best) break;
seeds.push(best);
}
seeds.minMunicipalitiesPerPrefecture = minMunicipalitiesPerPrefecture;
return seeds;
}
@ -186,7 +656,7 @@ function assignMunicipalitiesToPrefectures(nodes, seeds) {
const next = nodes.get(nextId);
if (!next) continue;
const areaPressure = Math.max(0, ((area.get(cur.id) || 0) + next.area - maxArea) / Math.max(1, maxArea));
const cost = cur.f + 1.0 + edge.barrier * 5.5 + areaPressure * 14 + hash2(cur.id, nextId) * 0.05;
const cost = cur.f + (edge.crossingCost ?? (1.0 + edge.barrier * 8.5)) + areaPressure * 14 + hash2(cur.id, nextId) * 0.05;
owner.set(nextId, cur.id);
area.set(cur.id, (area.get(cur.id) || 0) + next.area);
heap.push({ i: nextId, id: cur.id, f: cost });
@ -291,6 +761,108 @@ function repairPrefectureMunicipalityEnclaves(nodes, owner, maxPasses = 6) {
return changed;
}
function lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, context, maxCells = 2600) {
const { prefectureMask, sea, landuse, populationDensity } = context;
if (!owner || !adminId || !landuse) return 0;
const seen = new Uint8Array(SIZE);
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
const isUrban = (i) => {
const lu = landuse[i];
return lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8 || (populationDensity?.[i] || 0) > 0.18;
};
let changedMunicipalities = 0;
for (let start = 0; start < SIZE; start++) {
if (seen[start] || !prefectureMask[start] || sea[start] || adminId[start] < 0 || !isUrban(start)) continue;
const queue = [start];
const comp = [];
seen[start] = 1;
const municipalityWeights = new Map();
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
comp.push(cur);
const id = adminId[cur];
const weight = 1 + Math.max(0, (populationDensity?.[cur] || 0) - 0.12) * 2.4 + (landuse[cur] === 3 ? 2.0 : landuse[cur] === 2 ? 1.2 : 0);
municipalityWeights.set(id, (municipalityWeights.get(id) || 0) + weight);
const [x, y] = xyOf(cur);
for (const [dx, dy] of dirs) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (seen[ni] || !prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || !isUrban(ni)) continue;
seen[ni] = 1;
queue.push(ni);
}
}
if (comp.length < 14 || comp.length > maxCells || municipalityWeights.size <= 1) continue;
const prefectureWeights = new Map();
for (const [munId, weight] of municipalityWeights) {
const prefId = owner.get(munId);
if (prefId < 0) continue;
prefectureWeights.set(prefId, (prefectureWeights.get(prefId) || 0) + weight);
}
if (prefectureWeights.size <= 1) continue;
let bestPref = -1, bestWeight = -INF, totalWeight = 0;
for (const [prefId, weight] of prefectureWeights) {
totalWeight += weight;
if (weight > bestWeight || (weight === bestWeight && prefId < bestPref)) { bestPref = prefId; bestWeight = weight; }
}
if (bestPref < 0 || bestWeight / Math.max(1, totalWeight) < 0.34) continue;
for (const munId of municipalityWeights.keys()) {
if (owner.get(munId) === bestPref) continue;
owner.set(munId, bestPref);
changedMunicipalities++;
}
}
return changedMunicipalities;
}
function lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, context) {
const { prefectureMask, sea, landuse, populationDensity, modernCities = [] } = context;
if (!owner || !adminId || !modernCities?.length) return 0;
let changed = 0;
const isUrban = (i) => {
const lu = landuse?.[i];
return lu === 2 || lu === 3 || lu === 4 || lu === 7 || lu === 8 || (populationDensity?.[i] || 0) > 0.14;
};
for (const city of modernCities) {
if (!city || (city.population || 0) < 24000 || !inside(city.x, city.y)) continue;
const centerAdmin = adminId[indexOf(city.x, city.y)];
if (centerAdmin < 0) continue;
const centerPref = owner.get(centerAdmin);
if (centerPref < 0) continue;
const radius = clamp(Math.round((city.urbanRadius || 8) * ((city.population || 0) >= 160000 ? 1.55 : 1.25)), 7, 26);
const municipalityWeights = new Map();
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const x = city.x + dx;
const y = city.y + dy;
if (!inside(x, y) || Math.hypot(dx, dy) > radius) continue;
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i] || adminId[i] < 0 || !isUrban(i)) continue;
const dist = Math.hypot(dx, dy) / Math.max(1, radius);
const weight = (1 - dist * 0.55) * (1 + (populationDensity?.[i] || 0) * 2.6 + (landuse?.[i] === 3 ? 1.6 : 0));
municipalityWeights.set(adminId[i], (municipalityWeights.get(adminId[i]) || 0) + weight);
}
}
if (municipalityWeights.size <= 1) continue;
let total = 0, centerOwnedWeight = 0;
for (const [munId, weight] of municipalityWeights) {
total += weight;
if (owner.get(munId) === centerPref) centerOwnedWeight += weight;
}
// Only force compact city regions. If the center prefecture has almost no
// share, this is probably a genuine cross-prefecture conurbation or a city
// center on the edge; leave it to the graph repair.
if (centerOwnedWeight / Math.max(1, total) < 0.24) continue;
for (const munId of municipalityWeights.keys()) {
if (owner.get(munId) === centerPref) continue;
owner.set(munId, centerPref);
changed++;
}
}
return changed;
}
function repairPrefectureCellEnclavesFromGrid(adminId, owner, prefectureMask, sea, maxPasses = 5) {
let changed = 0;
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
@ -452,7 +1024,7 @@ function mergeTinyMunicipalityPrefectures(nodes, owner) {
for (const [nextId, edge] of node.adjacent) {
const nextPref = owner.get(nextId);
if (nextPref === tinyPref || nextPref < 0) continue;
const score = (neighborScores.get(nextPref) || 0) + edge.count * (0.6 + edge.barrier);
const score = (neighborScores.get(nextPref) || 0) + edge.count * 0.8 - (edge.crossingCost ?? (1.0 + edge.barrier * 8.5)) * 0.65;
neighborScores.set(nextPref, score);
}
}
@ -593,17 +1165,192 @@ function extractPrefectureBordersFromMunicipalities(adminId, municipalityToPrefe
return segments;
}
function prefectureMunicipalityCounts(owner) {
const counts = new Map();
for (const pref of owner.values()) counts.set(pref, (counts.get(pref) || 0) + 1);
return counts;
}
function ownerMembersByPref(owner) {
const by = new Map();
for (const [id, pref] of owner) {
if (!by.has(pref)) by.set(pref, []);
by.get(pref).push(id);
}
return by;
}
function wouldRemainConnectedAfterRemoval(nodes, owner, adminId, prefId) {
const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId && id !== adminId);
if (members.length <= 1) return true;
const memberSet = new Set(members);
const seen = new Set([members[0]]);
const queue = [members[0]];
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
for (const next of nodes.get(cur)?.adjacent.keys() || []) {
if (!memberSet.has(next) || seen.has(next)) continue;
seen.add(next);
queue.push(next);
}
}
return seen.size === members.length;
}
function rebalanceSmallPrefecturesByMunicipalityCount(nodes, owner, minCount = 14, maxPasses = 96) {
let changed = 0;
for (let pass = 0; pass < maxPasses; pass++) {
const counts = prefectureMunicipalityCounts(owner);
const small = [...counts.entries()].filter(([, count]) => count > 0 && count < minCount).sort((a, b) => a[1] - b[1] || a[0] - b[0])[0];
if (!small) break;
const [smallPref, smallCount] = small;
let best = null, bestScore = -INF;
for (const [id, pref] of owner) {
if (pref === smallPref) continue;
const donorCount = counts.get(pref) || 0;
if (donorCount <= minCount + 1) continue;
const node = nodes.get(id);
if (!node) continue;
let edgeToSmall = null;
for (const [nextId, edge] of node.adjacent || []) {
if (owner.get(nextId) === smallPref) {
edgeToSmall = edge;
break;
}
}
if (!edgeToSmall) continue;
if (!wouldRemainConnectedAfterRemoval(nodes, owner, id, pref)) continue;
const capitalPenalty = (node.cityPopulation || 0) >= 150000 ? 18 : 0;
const donorSurplus = donorCount - minCount;
const score = (edgeToSmall.count || 1) * 2.5 - (edgeToSmall.crossingCost ?? (1.0 + (edgeToSmall.barrier || 0) * 8.5)) * 1.15 + donorSurplus * 1.6 - Math.sqrt(node.area || 1) * 0.03 - capitalPenalty;
if (score > bestScore || (score === bestScore && id < best?.id)) best = { id, pref, score };
}
if (!best) break;
owner.set(best.id, smallPref);
changed++;
repairPrefectureMunicipalityConnectivity(nodes, owner);
}
return changed;
}
function mergePersistentlyTinyPrefecturesByCount(nodes, owner, minCount = 12, minRemainingPrefectures = 2) {
let changed = 0;
for (let pass = 0; pass < 16; pass++) {
const counts = prefectureMunicipalityCounts(owner);
if (counts.size <= minRemainingPrefectures) break;
const tiny = [...counts.entries()].filter(([, count]) => count > 0 && count < minCount).sort((a, b) => a[1] - b[1] || a[0] - b[0])[0];
if (!tiny) break;
const [tinyPref] = tiny;
const neighborScores = new Map();
for (const [id, pref] of owner) {
if (pref !== tinyPref) continue;
const node = nodes.get(id);
for (const [nextId, edge] of node?.adjacent || []) {
const other = owner.get(nextId);
if (other === undefined || other === tinyPref) continue;
const score = (edge.count || 1) * 2.4 - (edge.crossingCost ?? (1.0 + (edge.barrier || 0) * 8.5)) * 1.0 + Math.min(18, counts.get(other) || 0) * 0.20;
neighborScores.set(other, (neighborScores.get(other) || 0) + score);
}
}
let best = -1, bestScore = -INF;
for (const [candidate, score] of neighborScores) {
if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; }
}
if (best < 0) {
const tinyNodes = [...owner.keys()].filter((id) => owner.get(id) === tinyPref).map((id) => nodes.get(id)).filter(Boolean);
const tx = tinyNodes.reduce((sum, node) => sum + node.x * node.area, 0) / Math.max(1, tinyNodes.reduce((sum, node) => sum + node.area, 0));
const ty = tinyNodes.reduce((sum, node) => sum + node.y * node.area, 0) / Math.max(1, tinyNodes.reduce((sum, node) => sum + node.area, 0));
let bestDist = INF;
for (const [candidate, count] of counts) {
if (candidate === tinyPref || count <= 0) continue;
const candidateNodes = [...owner.keys()].filter((id) => owner.get(id) === candidate).map((id) => nodes.get(id)).filter(Boolean);
for (const node of candidateNodes) {
const d = Math.hypot(node.x - tx, node.y - ty);
if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; }
}
}
}
if (best < 0) break;
for (const [id, pref] of owner) if (pref === tinyPref) { owner.set(id, best); changed++; }
repairPrefectureMunicipalityConnectivity(nodes, owner);
}
// Renumber compactly so labels/debug do not expose deleted prefecture IDs.
const active = [...new Set(owner.values())].sort((a, b) => a - b);
const remap = new Map(active.map((id, n) => [id, n]));
for (const [id, pref] of owner) owner.set(id, remap.get(pref));
return changed;
}
function splitOversizedPrefecturesByMunicipalityCount(nodes, owner, maxCount = 88, maxPrefectures = 8) {
let changed = 0;
for (let pass = 0; pass < 10; pass++) {
const counts = prefectureMunicipalityCounts(owner);
const oversized = [...counts.entries()].filter(([, count]) => count > maxCount).sort((a, b) => b[1] - a[1] || a[0] - b[0])[0];
if (!oversized || counts.size >= maxPrefectures) break;
const [prefId, count] = oversized;
const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId);
if (members.length <= maxCount) break;
let sx = 0, sy = 0, area = 0;
for (const id of members) {
const node = nodes.get(id);
if (!node) continue;
sx += node.x * Math.max(1, node.area || 1);
sy += node.y * Math.max(1, node.area || 1);
area += Math.max(1, node.area || 1);
}
const cx = sx / Math.max(1, area);
const cy = sy / Math.max(1, area);
const seedNode = members
.map((id) => nodes.get(id))
.filter(Boolean)
.sort((a, b) => Math.hypot(b.x - cx, b.y - cy) - Math.hypot(a.x - cx, a.y - cy) || a.id - b.id)[0];
if (!seedNode) break;
const newPref = Math.max(-1, ...counts.keys()) + 1;
const target = Math.max(count - maxCount, Math.floor(count * 0.42));
const queue = [seedNode.id];
const picked = new Set([seedNode.id]);
for (let q = 0; q < queue.length && picked.size < target; q++) {
const cur = queue[q];
const nexts = [...(nodes.get(cur)?.adjacent.keys() || [])]
.filter((id) => owner.get(id) === prefId && !picked.has(id))
.map((id) => nodes.get(id))
.filter(Boolean)
.sort((a, b) => Math.hypot(b.x - cx, b.y - cy) - Math.hypot(a.x - cx, a.y - cy) || a.id - b.id);
for (const next of nexts) {
picked.add(next.id);
queue.push(next.id);
if (picked.size >= target) break;
}
}
if (picked.size < Math.max(8, target * 0.55)) break;
for (const id of picked) owner.set(id, newPref);
changed += picked.size;
repairPrefectureMunicipalityConnectivity(nodes, owner);
}
return changed;
}
function generatePrefecturesFromMunicipalities(context, adminResult) {
const { adminId } = adminResult;
const { prefectureMask, sea, naturalBarrierScore, populationDensity, seed } = context;
const graph = buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity);
const { prefectureMask, sea, naturalBarrierScore, populationDensity, landuse, modernCities, markets, seed } = context;
const graph = buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity, [...(modernCities || []), ...(markets || [])]);
const seeds = choosePrefectureMunicipalitySeeds(graph.nodes, seed + 91001);
const owner = assignMunicipalitiesToPrefectures(graph.nodes, seeds);
const changedForTinyMerge = mergeTinyMunicipalityPrefectures(graph.nodes, owner);
let changedForMetroUnification = lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity }, 2600);
changedForMetroUnification += lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity, modernCities });
let changedForConnectivity = repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
let changedForEnclaveRepair = repairPrefectureMunicipalityEnclaves(graph.nodes, owner);
changedForMetroUnification += lockCompactUrbanMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity }, 2600);
changedForMetroUnification += lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity, modernCities });
const changedForMunicipalityCountRebalance = rebalanceSmallPrefecturesByMunicipalityCount(graph.nodes, owner, seeds.minMunicipalitiesPerPrefecture || 14);
const changedForTinyPrefectureCountMerge = mergePersistentlyTinyPrefecturesByCount(graph.nodes, owner, Math.max(13, (seeds.minMunicipalitiesPerPrefecture || 14) - 1));
const changedForPostMergeMunicipalityCountRebalance = rebalanceSmallPrefecturesByMunicipalityCount(graph.nodes, owner, Math.max(12, (seeds.minMunicipalitiesPerPrefecture || 14) - 1), 64);
const changedForOversizedPrefectureSplit = splitOversizedPrefecturesByMunicipalityCount(graph.nodes, owner, 88, 8);
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
changedForEnclaveRepair += repairPrefectureCellEnclavesFromGrid(adminId, owner, prefectureMask, sea);
// Keep prefectures as connected groups of municipalities; do not perform cell-level prefecture repair here.
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
const maxAdminId = Math.max(-1, ...[...graph.nodes.keys()]);
const municipalityToPrefectureId = new Int16Array(maxAdminId + 1);
@ -637,6 +1384,13 @@ function generatePrefecturesFromMunicipalities(context, adminResult) {
prefectureTinyMergeChangedMunicipalities: changedForTinyMerge,
prefectureConnectivityRepairChangedMunicipalities: changedForConnectivity,
prefectureEnclaveRepairChangedMunicipalities: changedForEnclaveRepair,
prefectureUrbanMetroUnificationChangedMunicipalities: changedForMetroUnification,
prefectureMunicipalityCountRebalanceChangedMunicipalities: (changedForMunicipalityCountRebalance || 0) + (changedForPostMergeMunicipalityCountRebalance || 0),
prefectureTinyMunicipalityCountMergeChangedMunicipalities: changedForTinyPrefectureCountMerge || 0,
prefectureOversizedCountSplitChangedMunicipalities: changedForOversizedPrefectureSplit || 0,
finalRegionalMaxMunicipalityCount: Math.max(...prefectureMunicipalityCounts(owner).values()),
finalRegionalMunicipalityCountCap: 88,
finalRegionalMinMunicipalityCount: Math.min(...prefectureMunicipalityCounts(owner).values()),
finalRegionalMaxAreaShare: totalArea ? Math.max(0, ...areaByPref.values()) / totalArea : 0,
finalRegionalTinyPrefectureCount: [...areaByPref.values()].filter((area) => area < 520).length,
finalRegionalPrefectureBorderCount: regionalPrefectureBorders.length,
@ -938,7 +1692,7 @@ function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope,
const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520);
const mountainRatio = landCells ? mountainCells / landCells : 0;
const lowlandBonus = Math.min(7, lowlandCells / 430);
const rawTarget = Math.round(habitableCells / 175 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.65 + lowlandBonus * 1.15 - mountainRatio * 1.8);
const rawTarget = Math.round(habitableCells / 150 + settlementWeight * 1.08 + coastlineComplexity * 0.035 + basinBonus * 0.75 + lowlandBonus * 1.25 - mountainRatio * 1.45);
const { min, max } = municipalityCountBoundsForRegion(landCells, adminRegionMeta);
return clamp(rawTarget, min, max);
}
@ -1378,14 +2132,25 @@ function generateAdminLayoutForMask({
const oversizedCompartmentSplit = splitOversizedCompartmentMunicipalities(adminId, adminCentersRaw, compartmentAssignment.compartments, prefectureMask, sea, {
elevation, slope, ridgeField: boundaryRidgeField, plain, agriculture, basinField, coastalLowland, populationDensity,
}, seed + 21900);
const changedAfterInitialCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
const hierarchyRepair = enforceSimpleAdministrativeHierarchy(adminId, compartmentAssignment.compartments, prefectureMask, sea, {
minCompartmentsPerMunicipality: 2,
maxUrbanClusterCells: 1600,
modernCities,
});
const changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
const changedAfterUrbanUnification = lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, 980);
const changedAfterAdminEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 6);
const compacted = compactWholeCompartmentMunicipalities(adminId, adminCentersRaw, prefectureMask, sea, { populationDensity, plain, slope });
const adminBorders = extractAdminBorderSegments(compacted.adminId, prefectureMask);
const actualMunicipalityCount = compacted.activeMunicipalityCount;
const naturalCompartmentCount = compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length;
const adminDebug = {
...compartmentAssignment.debug,
simpleHierarchyPrototype: true,
administrativeHierarchySpec: "natural-compartments->municipalities->prefectures",
naturalCompartmentsImmutable: true,
municipalitiesAreCompartmentGroups: true,
prefecturesAreMunicipalityGroups: true,
cellLevelAdminSmoothingDisabled: true,
sharedNaturalCompartmentLayer: true,
skippedLegacyCellCleanupForHierarchy: true,
targetMunicipalityCount,
@ -1398,12 +2163,23 @@ function generateAdminLayoutForMask({
pendingSeedCount: 0,
absorbedSeedCount: Math.max(0, adminCentersRaw.length - compacted.activeMunicipalityCount),
targetNaturalCompartmentCount: targetCompartmentCount,
naturalCompartmentCount: compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length,
compartmentCount: compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length,
changedAfterCompartmentAssignment: compartmentAssignment.debug?.naturalCompartmentCount || 0,
naturalCompartmentCount,
compartmentCount: naturalCompartmentCount,
changedAfterCompartmentAssignment: naturalCompartmentCount,
changedAfterInitialCompartmentOwnership,
changedAfterFinalCompartmentOwnership,
changedAfterUrbanUnification,
changedAfterAdminEnclaveRepair,
changedAfterUrbanUnification: hierarchyRepair.changedAfterUrbanUnification,
urbanComponentsUnified: hierarchyRepair.urbanComponentsUnified,
changedAfterCityMetroMunicipalityUnification: hierarchyRepair.changedAfterCityMetroMunicipalityUnification,
cityMetroMunicipalitiesUnified: hierarchyRepair.cityMetroMunicipalitiesUnified,
changedAfterCompartmentConnectivity: hierarchyRepair.changedAfterCompartmentConnectivity,
disconnectedCompartmentComponentsMerged: hierarchyRepair.disconnectedCompartmentComponentsMerged,
changedAfterAdminEnclaveRepair: hierarchyRepair.changedAfterCompartmentEnclaveRepair,
compartmentEnclaveComponentsMerged: hierarchyRepair.compartmentEnclaveComponentsMerged,
changedAfterSingleCompartmentMunicipalityMerge: hierarchyRepair.changedAfterSingleCompartmentMunicipalityMerge,
singleCompartmentMunicipalitiesMerged: hierarchyRepair.singleCompartmentMunicipalitiesMerged,
remainingSingleCompartmentMunicipalities: hierarchyRepair.remainingSingleCompartmentMunicipalities,
changedAfterPostMergeCompartmentOwnership: changedAfterFinalCompartmentOwnership,
changedAfterOversizedCompartmentSplit: oversizedCompartmentSplit.changedCells,
oversizedCompartmentMunicipalitiesSplit: oversizedCompartmentSplit.splitMunicipalities,
oversizedCompartmentSplitAddedCenters: oversizedCompartmentSplit.addedCenters,

View file

@ -1,4 +1,4 @@
import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise } from "./mapUtils.js";
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js";
import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js";
import { LANDUSE } from "./landuseCodes.js";
@ -91,6 +91,7 @@ export function generateMapFeatures(seed, terrain) {
const highPenalty = Math.max(0, elevation[i] - 0.56);
const lowSlope = clamp(1 - slope[i] * 2.3);
const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y) : 0;
const openPlainPotential = clamp(plain[i] * 0.68 + agriculture[i] * 0.54 + basinField[i] * 0.30 + lowSlope * 0.22 - river[i] * 0.18 - valleyField[i] * 0.08 - ridgeField[i] * 0.22 - slope[i] * 0.26);
const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38;
confluenceField[i] = confluence;
@ -132,23 +133,25 @@ export function generateMapFeatures(seed, terrain) {
spine * 0.12
);
const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16;
settlementCluster[i] = clamp((developable[i] * 0.50 + valleySettlement[i] * 0.26 + coastalSettlement[i] * 0.18 + agriculture[i] * 0.34 + plain[i] * 0.12) * clusterNoise);
settlementCluster[i] = clamp((developable[i] * 0.42 + valleySettlement[i] * 0.12 + coastalSettlement[i] * 0.22 + agriculture[i] * 0.48 + plain[i] * 0.32 + openPlainPotential * 0.46) * clusterNoise);
ruralSuitability[i] = clamp(
agriculture[i] * 0.54 +
developable[i] * 0.30 +
valleySettlement[i] * 0.24 +
coastalSettlement[i] * 0.15 +
valleySettlement[i] * 0.18 +
coastalSettlement[i] * 0.20 +
openPlainPotential * 0.34 +
settlementCluster[i] * 0.30 -
Math.max(0, elevation[i] - 0.64) * 0.56
);
townSuitability[i] = clamp(
developable[i] * 0.38 +
agriculture[i] * 0.18 +
valleySettlement[i] * 0.24 +
coastalSettlement[i] * 0.20 +
confluence * 0.34 +
basinField[i] * 0.16 +
plain[i] * 0.18 +
valleySettlement[i] * 0.16 +
coastalSettlement[i] * 0.30 +
confluence * 0.20 +
basinField[i] * 0.18 +
plain[i] * 0.26 +
openPlainPotential * 0.44 +
settlementCluster[i] * 0.22 -
slope[i] * 0.34 -
ridgeField[i] * 0.17 -
@ -304,9 +307,9 @@ export function generateMapFeatures(seed, terrain) {
const villageScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
villageScore[i] = clamp(ruralSuitability[i] * 0.60 + agriculture[i] * 0.30 + plain[i] * 0.15 + valleySettlement[i] * 0.24 + coastalSettlement[i] * 0.16 + settlementCluster[i] * 0.16);
villageScore[i] = clamp(ruralSuitability[i] * 0.50 + agriculture[i] * 0.48 + plain[i] * 0.42 + Math.max(0, plain[i] * 1.12 + agriculture[i] * 0.78 + basinField[i] * 0.30 - river[i] * 0.36 - valleyField[i] * 0.14 - flowAccum[i] * 0.10) * 0.58 + valleySettlement[i] * 0.06 + coastalSettlement[i] * 0.34 + settlementCluster[i] * 0.22 - river[i] * 0.10 - flowAccum[i] * 0.04);
}
const villages = pickRegionalPoints(villageScore, {
let villages = pickRegionalPoints(villageScore, {
stride: 2,
threshold: 0.18 + rand(seed, 1031) * 0.030,
totalMax: 280,
@ -316,18 +319,51 @@ export function generateMapFeatures(seed, terrain) {
quotaForRegion: (regionId, st) => {
if (!st || st.developableCells < 10) return 0;
const vf = visibilityFactor(regionId, st);
const raw = (st.developableCells / 28 + st.plainCells / 42 + st.valleyCells / 34 + st.coastCells / 46 + 3.2) * vf;
const raw = (st.developableCells / 28 + st.plainCells / 42 + st.valleyCells / 34 + st.coastCells / 32 + 3.2) * vf;
const min = st.area > 2600 ? 20 : st.area > 1400 ? 12 : st.area > 520 ? 5 : st.area > 220 ? 2 : 0;
const max = st.area > 3600 ? 72 : st.area > 2200 ? 50 : st.area > 900 ? 25 : 10;
return Math.round(clamp(raw + rand(seed, 1033 + regionId * 19) * 1.5, min, max));
},
}).map((p, n) => {
const i = indexOf(p.x, p.y);
const kind = valleySettlement[i] > 0.42 ? "Valley Village" : coastalSettlement[i] > 0.43 ? "Coastal Village" : "Village";
const population = Math.round((300 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.85) * 4700 + ruralSuitability[i] * 2600) / 100) * 100;
const kind = coastalSettlement[i] > 0.36 ? "Coastal Village" : valleySettlement[i] > 0.46 ? "Valley Village" : "Village";
const population = Math.round((900 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.30) * 9800 + ruralSuitability[i] * 4700 + agriculture[i] * 3600) / 100) * 100;
return { ...p, kind, population };
});
// Supplemental open-plain villages: broad Japanese-style farmland should not be empty
// just because it lacks a river/confluence anchor.
const openPlainVillageScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
const open = Math.max(0, plain[i] * 0.92 + agriculture[i] * 0.72 + basinField[i] * 0.26 + (depositionalLowland?.[i] || 0) * 0.20 - river[i] * 0.22 - valleyField[i] * 0.10 - flowAccum[i] * 0.08 - slope[i] * 0.24 - ridgeField[i] * 0.14);
openPlainVillageScore[i] = clamp(open + settlementCluster[i] * 0.16 + ruralSuitability[i] * 0.18);
}
const supplementalPlainVillages = pickRegionalPoints(openPlainVillageScore, {
stride: 2,
threshold: 0.235 + rand(seed, 1036) * 0.020,
totalMax: 120,
minDistance: 5,
seedOffset: 1035,
kind: "Plain Village",
predicate: (x, y, i) => plain[i] > 0.20 && agriculture[i] > 0.16 && river[i] < 0.30 && valleyField[i] < 0.52 && slope[i] < 0.32,
quotaForRegion: (regionId, st) => {
if (!st || st.plainCells < 24) return 0;
const vf = visibilityFactor(regionId, st);
const raw = (st.plainCells / 62 + st.developableCells / 180 + 1.4) * vf;
const min = st.plainCells > 360 ? 6 : st.plainCells > 160 ? 3 : st.plainCells > 70 ? 1 : 0;
const max = st.plainCells > 720 ? 24 : st.plainCells > 360 ? 16 : st.plainCells > 140 ? 8 : 3;
return Math.round(clamp(raw + rand(seed, 1037 + regionId * 29) * 1.4, min, max));
},
extraScore: (x, y, i) => Math.max(0, plain[i] * 0.34 + agriculture[i] * 0.26 - river[i] * 0.20 - valleyField[i] * 0.12),
}).filter((p) => distanceToNearest(villages, p.x, p.y) >= 4.5)
.map((p, n) => {
const i = indexOf(p.x, p.y);
const population = Math.round((1100 + Math.pow(rand(seed, 18220 + n * 31 + p.x * 7 + p.y), 1.12) * 7600 + agriculture[i] * 3900 + plain[i] * 2200) / 100) * 100;
return { ...p, kind: "Plain Village", population };
});
villages = [...villages, ...supplementalPlainVillages];
const villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2));
const marketScore = new Float32Array(SIZE);
@ -335,29 +371,34 @@ export function generateMapFeatures(seed, terrain) {
for (let x = 2; x < MAP_W - 2; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const openPlainMarket = Math.max(0, plain[i] * 0.68 + agriculture[i] * 0.52 + basinField[i] * 0.24 + (depositionalLowland?.[i] || 0) * 0.18 - river[i] * 0.16 - valleyField[i] * 0.06 - slope[i] * 0.18);
const featurePull = Math.max(
distanceToNearest(ports, x, y) < 8 ? 0.10 : 0,
distanceToNearest(crossings, x, y) < 6 ? 0.06 : 0,
confluenceField[i] * 0.16
distanceToNearest(ports, x, y) < 10 ? 0.16 : 0,
distanceToNearest(crossings, x, y) < 6 ? 0.035 : 0,
confluenceField[i] * 0.08
);
const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.14 : 0;
marketScore[i] = clamp(
townSuitability[i] * 0.58 +
agriculture[i] * 0.16 +
plain[i] * 0.14 +
villageInfluence[i] * 0.40 +
townSuitability[i] * 0.50 +
agriculture[i] * 0.30 +
plain[i] * 0.28 +
openPlainMarket * 0.74 +
coastalSettlement[i] * 0.22 +
villageInfluence[i] * 0.28 +
featurePull +
valleyMouth +
basinField[i] * 0.12 +
plain[i] * 0.14 +
basinField[i] * 0.14 +
plain[i] * 0.22 +
coastalLowland[i] * 0.08 -
slope[i] * 0.18 -
ridgeField[i] * 0.08
ridgeField[i] * 0.08 -
river[i] * 0.08 -
flowAccum[i] * 0.035
);
}
}
const markets = pickRegionalPoints(marketScore, {
let markets = pickRegionalPoints(marketScore, {
stride: 2,
threshold: 0.245 + rand(seed, 1041) * 0.035,
totalMax: 110,
@ -367,19 +408,50 @@ export function generateMapFeatures(seed, terrain) {
quotaForRegion: (regionId, st) => {
if (!st || st.townCells < 8) return 0;
const vf = visibilityFactor(regionId, st);
const raw = (st.developableCells / 105 + st.plainCells / 125 + st.valleyCells / 105 + st.coastCells / 130 + 2.1) * vf;
const raw = (st.developableCells / 92 + st.plainCells / 108 + st.valleyCells / 92 + st.coastCells / 72 + 2.7) * vf;
const min = st.area > 2600 ? 9 : st.area > 1200 ? 5 : st.area > 520 ? 2 : 0;
const max = st.area > 3600 ? 30 : st.area > 2200 ? 22 : st.area > 800 ? 10 : 5;
return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max));
},
extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 8 ? 0.07 : 0) + confluenceField[i] * 0.08,
extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 10 ? 0.12 : 0) + coastalSettlement[i] * 0.08 + Math.max(0, plain[i] * 0.32 + agriculture[i] * 0.20 - river[i] * 0.16) * 0.09 + confluenceField[i] * 0.035,
}).map((p, n) => {
const i = indexOf(p.x, p.y);
const kind = coastalSettlement[i] > 0.45 && distanceToNearest(ports, p.x, p.y) < 9 ? "Port Town" : valleySettlement[i] > 0.42 ? "Valley Market Town" : "Market Town";
const population = Math.round((4000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.50) * 24000 + marketScore[i] * 13000) / 1000) * 1000;
const kind = coastalSettlement[i] > 0.38 && distanceToNearest(ports, p.x, p.y) < 11 ? "Port Town" : valleySettlement[i] > 0.48 ? "Valley Market Town" : "Market Town";
const population = Math.round((9000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.02) * 70000 + marketScore[i] * 40000 + villageInfluence[i] * 7800 + Math.max(0, plain[i] * 0.48 + agriculture[i] * 0.30 + basinField[i] * 0.18 - river[i] * 0.14) * 22000 + coastalSettlement[i] * 12000) / 1000) * 1000;
return { ...p, kind, population };
});
const openPlainMarketScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
const open = Math.max(0, plain[i] * 0.86 + agriculture[i] * 0.64 + basinField[i] * 0.28 + (depositionalLowland?.[i] || 0) * 0.22 - river[i] * 0.20 - valleyField[i] * 0.10 - flowAccum[i] * 0.08 - slope[i] * 0.24 - ridgeField[i] * 0.16);
openPlainMarketScore[i] = clamp(open + townSuitability[i] * 0.20 + villageInfluence[i] * 0.18 + settlementCluster[i] * 0.12);
}
const supplementalPlainMarkets = pickRegionalPoints(openPlainMarketScore, {
stride: 2,
threshold: 0.335 + rand(seed, 1046) * 0.025,
totalMax: 55,
minDistance: 9,
seedOffset: 1045,
kind: "Plain Market Town",
predicate: (x, y, i) => plain[i] > 0.22 && agriculture[i] > 0.18 && river[i] < 0.28 && valleyField[i] < 0.50 && slope[i] < 0.30,
quotaForRegion: (regionId, st) => {
if (!st || st.plainCells < 60) return 0;
const vf = visibilityFactor(regionId, st);
const raw = (st.plainCells / 260 + st.developableCells / 520 + 0.45) * vf;
const min = st.plainCells > 520 ? 2 : st.plainCells > 220 ? 1 : 0;
const max = st.plainCells > 900 ? 8 : st.plainCells > 420 ? 5 : st.plainCells > 160 ? 2 : 1;
return Math.round(clamp(raw + rand(seed, 1047 + regionId * 31) * 0.9, min, max));
},
extraScore: (x, y, i) => Math.max(0, plain[i] * 0.24 + agriculture[i] * 0.18 - river[i] * 0.14 - valleyField[i] * 0.08),
}).filter((p) => distanceToNearest(markets, p.x, p.y) >= 8.5 && distanceToNearest(villages, p.x, p.y) >= 3.5)
.map((p, n) => {
const i = indexOf(p.x, p.y);
const population = Math.round((10000 + Math.pow(rand(seed, 18340 + n * 37 + p.x * 11 + p.y), 1.02) * 52000 + openPlainMarketScore[i] * 26000 + agriculture[i] * 9000 + plain[i] * 8000) / 1000) * 1000;
return { ...p, kind: "Plain Market Town", population };
});
markets = [...markets, ...supplementalPlainMarkets];
const defenseScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
@ -428,7 +500,7 @@ export function generateMapFeatures(seed, terrain) {
const dev = developable[i];
if (dev < 0.04) continue;
const radial = clamp(1 - d / Math.max(1, radius));
const terrainMultiplier = clamp(0.60 + plain[i] * 0.28 + basinField[i] * 0.18 + coastalLowland[i] * 0.16 + valleyField[i] * 0.13 - slope[i] * 0.42 - ridgeField[i] * 0.18, 0.26, 1.24);
const terrainMultiplier = clamp(0.60 + plain[i] * 0.48 + agriculture[i] * 0.22 + basinField[i] * 0.28 + coastalLowland[i] * 0.18 + valleyField[i] * 0.08 - slope[i] * 0.42 - ridgeField[i] * 0.18, 0.26, 1.38);
capacity += dev * (900 + 6200 * Math.pow(radial, 1.25)) * terrainMultiplier * densityBias;
}
}
@ -448,8 +520,8 @@ export function generateMapFeatures(seed, terrain) {
const regionId = regionIdAt(p.x, p.y);
if (regionId < 0) continue;
const st = regionStats.get(regionId);
const cityRadius = st && st.area > 2400 ? 28 : st && st.area > 900 ? 24 : 20;
const capacity = estimateUrbanCapacity(p, cityRadius, 1.0);
const cityRadius = st && st.area > 2400 ? 30 : st && st.area > 900 ? 26 : 22;
const capacity = estimateUrbanCapacity(p, cityRadius, p.candidateKind === "town" ? 1.14 : p.candidateKind === "port" ? 1.10 : 1.06);
const score =
Math.log10(capacity + 1) * 0.72 +
townSuitability[i] * 1.40 +
@ -469,8 +541,8 @@ export function generateMapFeatures(seed, terrain) {
if (!st || st.developableCells < 30) continue;
const vf = visibilityFactor(regionId, st);
const maxCities = clamp(
Math.round((st.developableCells / 720 + 0.9) * vf + rand(seed, 12100 + regionId * 17) * 1.2),
st.area > 1600 ? 1 : 0,
Math.round((st.developableCells / 560 + 1.15) * vf + rand(seed, 12100 + regionId * 17) * 1.4),
(st.area > 900 || st.developableCells > 180) ? 1 : 0,
st.area > 3600 ? 6 : st.area > 2200 ? 4 : st.area > 900 ? 3 : 1
);
const selected = pickEntities(list, {
@ -497,11 +569,11 @@ export function generateMapFeatures(seed, terrain) {
const isPrefecturalCapital = inFocusedPrefecture(city) && !modernCities.slice(0, rank).some((c) => c.isPrefecturalCapital);
const isRegionalCapital = isFirstInRegion;
const rawPop = isRegionalCapital
? 150000 + rand(seed, 12201 + city.regionId * 17) * 520000
: 32000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.7) * 260000;
const capMultiplier = isRegionalCapital ? 1.10 : 1.0;
? 300000 + rand(seed, 12201 + city.regionId * 17) * 740000
: 68000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.62) * 430000;
const capMultiplier = isRegionalCapital ? 1.68 : 1.28;
const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000;
city.population = Math.max(isRegionalCapital ? 90000 : 24000, population);
city.population = Math.max(isRegionalCapital ? 260000 : 52000, population);
city.isPrefecturalCapital = isPrefecturalCapital;
city.isRegionalCapital = isRegionalCapital;
city.rank = isPrefecturalCapital ? "Prefectural Capital" : isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City";
@ -518,8 +590,422 @@ export function generateMapFeatures(seed, terrain) {
return estimateUrbanCapacity(city, radius, bias);
}
// --- 4. Lightweight corridors -------------------------------------------
function routeLight(a, b, snapRadius = 3) {
// --- 4. Field-derived transport corridors -------------------------------
const preliminaryUrbanInfluence = influenceFromPoints(modernCities, 18, (c) => clamp((c.population || 60000) / 260000, 0.55, 2.0));
const preliminaryTownInfluence = influenceFromPoints([...markets, ...commercialPorts], 10, (p) => p.portClass === "major" ? 1.35 : clamp((p.population || 12000) / 36000, 0.42, 1.1));
const preliminaryVillageInfluence = influenceFromPoints(villages, 7, (v) => clamp((v.population || 1800) / 5200, 0.22, 0.9));
const settlementDemand = new Float32Array(SIZE);
const urbanEdge = new Float32Array(SIZE);
const logisticsPreSuitability = new Float32Array(SIZE);
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const density = clamp(preliminaryUrbanInfluence[i] * 0.62 + preliminaryTownInfluence[i] * 0.34 + preliminaryVillageInfluence[i] * 0.16);
settlementDemand[i] = density;
urbanEdge[i] = clamp(1 - Math.abs(density - 0.46) / 0.32);
logisticsPreSuitability[i] = clamp(
agriculture[i] * 0.30 +
plain[i] * 0.24 +
basinField[i] * 0.14 +
coastalLowland[i] * 0.12 +
preliminaryTownInfluence[i] * 0.18 +
urbanEdge[i] * 0.34 -
preliminaryUrbanInfluence[i] * 0.20 -
slope[i] * 0.50 -
ridgeField[i] * 0.32
);
}
}
function buildTransportCostFields() {
const expressway = new Float32Array(SIZE);
const rail = new Float32Array(SIZE);
const national = new Float32Array(SIZE);
const local = new Float32Array(SIZE);
const expresswayPotential = new Float32Array(SIZE);
const railPotential = new Float32Array(SIZE);
const nationalPotential = new Float32Array(SIZE);
const localPotential = new Float32Array(SIZE);
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i]) {
expressway[i] = rail[i] = national[i] = local[i] = INF;
continue;
}
if (elevation[i] > 0.72) {
expressway[i] = rail[i] = national[i] = INF;
local[i] = Math.max(2.8, 1.4 + slope[i] * 2.2 + ridgeField[i] * 1.4);
continue;
}
const density = settlementDemand[i];
const mediumDensity = clamp(1 - Math.abs(density - 0.42) / 0.30);
const highDensity = clamp((density - 0.32) / 0.50);
const lowland = clamp(plain[i] * 0.48 + basinField[i] * 0.28 + valleyField[i] * 0.24 + coastalLowland[i] * 0.26 + agriculture[i] * 0.16);
const pass = passSuitability?.[i] || 0;
const crossing = crossingSuitability?.[i] || 0;
const waterCrossingPenalty = river[i] > 0.18 ? (1 - crossing) * (0.60 + river[i] * 1.1) : 0;
const highMountain = clamp((elevation[i] - 0.58) * 2.6 + ridgeField[i] * 0.65);
const denseCorePenalty = clamp((density - 0.66) / 0.28);
const openPlainParallelPenalty = clamp(plain[i] * 0.48 + agriculture[i] * 0.26 - density * 0.20 - valleyField[i] * 0.20 - coastalLowland[i] * 0.12);
expresswayPotential[i] = clamp(
mediumDensity * 0.62 +
urbanEdge[i] * 0.38 +
logisticsPreSuitability[i] * 0.54 +
lowland * 0.36 +
agriculture[i] * 0.16 -
denseCorePenalty * 0.54 -
slope[i] * 0.64 -
highMountain * 0.58 -
river[i] * 0.14
);
railPotential[i] = clamp(
highDensity * 0.80 +
preliminaryTownInfluence[i] * 0.22 +
lowland * 0.46 +
valleyField[i] * 0.22 +
coastalLowland[i] * 0.22 -
slope[i] * 1.05 -
highMountain * 0.82 -
ridgeField[i] * 0.34
);
nationalPotential[i] = clamp(
density * 0.46 +
preliminaryTownInfluence[i] * 0.32 +
preliminaryVillageInfluence[i] * 0.20 +
agriculture[i] * 0.24 +
valleyField[i] * 0.28 +
coastalLowland[i] * 0.26 +
pass * 0.18 +
crossing * 0.18 -
slope[i] * 0.36 -
ridgeField[i] * 0.18
);
localPotential[i] = clamp(
preliminaryVillageInfluence[i] * 0.52 +
agriculture[i] * 0.38 +
coastalSettlement[i] * 0.30 +
valleySettlement[i] * 0.30 +
developable[i] * 0.18 -
slope[i] * 0.26 -
ridgeField[i] * 0.10
);
expressway[i] = Math.max(0.18, 1.45 - expresswayPotential[i] * 1.06 + denseCorePenalty * 1.25 + slope[i] * 3.5 + highMountain * 2.9 + waterCrossingPenalty * 1.4 + openPlainParallelPenalty * 0.10 + hash2(x, y, seed + 13301) * 0.05);
rail[i] = Math.max(0.16, 1.38 - railPotential[i] * 1.08 + slope[i] * 5.6 + highMountain * 4.2 + waterCrossingPenalty * 1.1 + hash2(x, y, seed + 13302) * 0.04);
national[i] = Math.max(0.16, 1.22 - nationalPotential[i] * 0.88 + slope[i] * 1.65 + ridgeField[i] * 0.72 + Math.max(0, elevation[i] - 0.68) * 1.2 - pass * 0.30 + waterCrossingPenalty * 0.72 + hash2(x, y, seed + 13303) * 0.06);
local[i] = Math.max(0.14, 1.10 - localPotential[i] * 0.88 + slope[i] * 1.05 + ridgeField[i] * 0.42 + Math.max(0, elevation[i] - 0.72) * 0.90 + waterCrossingPenalty * 0.45 + hash2(x, y, seed + 13304) * 0.08);
}
}
return { expressway, rail, national, local, expresswayPotential, railPotential, nationalPotential, localPotential };
}
const transportFields = buildTransportCostFields();
function chooseCorridorSeeds(potentialField, spacing, maxCount, threshold, predicate = () => true, seedOffset = 0) {
const candidates = [];
for (let y = 3; y < MAP_H - 3; y += 2) {
for (let x = 3; x < MAP_W - 3; x += 2) {
const i = indexOf(x, y);
if (sea[i] || !predicate(x, y, i)) continue;
const score = potentialField[i] + hash2(x, y, seed + seedOffset) * 0.055;
if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) });
}
}
return pickEntities(candidates, { max: maxCount, minDistance: spacing, threshold, seed: seed + seedOffset, jitter: 0.03 });
}
function corridorAllowance(i) {
return clamp(settlementDemand[i] * 0.58 + valleyField[i] * 0.42 + coastalLowland[i] * 0.36 - plain[i] * 0.18 - agriculture[i] * 0.14);
}
function traceCorridorByCost(start, goalRegionPredicate, costField, penaltyField, options = {}) {
if (!start || !inside(start.x, start.y)) return [];
const startIndex = indexOf(start.x, start.y);
if (sea[startIndex] || costField[startIndex] >= INF) return [];
const score = new Float32Array(SIZE);
const cameFrom = new Int32Array(SIZE);
const closed = new Uint8Array(SIZE);
score.fill(INF);
cameFrom.fill(-1);
const heap = new MinHeap();
score[startIndex] = 0;
heap.push({ i: startIndex, f: 0 });
const curvePenalty = options.curvePenalty ?? 0.12;
const penaltyStrength = options.penaltyStrength ?? 1.0;
const sameRegion = options.regionId ?? regionIdAt(start.x, start.y);
const minGoalDistance = options.minGoalDistance ?? 18;
const maxExpanded = options.maxExpanded ?? SIZE * 2;
let goalIndex = -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);
if (current.i !== startIndex && Math.hypot(cx - start.x, cy - start.y) >= minGoalDistance && goalRegionPredicate(cx, cy, current.i)) {
goalIndex = current.i;
break;
}
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] || costField[ni] >= INF) continue;
if (sameRegion >= 0 && options.keepRegion !== false && regionIdAt(nx, ny) !== sameRegion) continue;
const prev = cameFrom[current.i];
let turn = 0;
if (prev >= 0) {
const [px, py] = xyOf(prev);
const ax = cx - px;
const ay = cy - py;
turn = Math.abs(ax * dy - ay * dx) > 0 ? curvePenalty : 0;
}
const existing = penaltyField?.[ni] || 0;
const antiConcentration = existing * penaltyStrength * (1 - corridorAllowance(ni) * 0.72);
const nd = score[current.i] + (costField[ni] + antiConcentration + turn) * Math.hypot(dx, dy);
if (nd < score[ni]) {
score[ni] = nd;
cameFrom[ni] = current.i;
heap.push({ i: ni, f: nd });
}
}
}
}
if (goalIndex < 0) return [];
const path = [];
for (let p = goalIndex; p >= 0; p = cameFrom[p]) {
path.push(xyOf(p));
if (p === startIndex) break;
}
return path.reverse();
}
function addCorridorInfluencePenalty(penaltyField, corridor, radius = 7, strength = 0.35) {
for (const [x, y] of corridor || []) {
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;
const openPlain = clamp(plain[i] * 0.54 + agriculture[i] * 0.34 - settlementDemand[i] * 0.24 - valleyField[i] * 0.22 - coastalLowland[i] * 0.18);
const allowParallel = corridorAllowance(i);
penaltyField[i] = Math.max(penaltyField[i], strength * (1 - d / radius) * (0.48 + openPlain * 1.15 - allowParallel * 0.42));
}
}
}
}
function endpointFromPath(path) {
const p = path?.[path.length - 1];
return p ? { x: p[0], y: p[1], regionId: regionIdAt(p[0], p[1]) } : null;
}
function generateCorridorsFromField({ potentialField, costField, spacing, maxCount, threshold, minLength, penaltyRadius, penaltyStrength, curvePenalty, seedOffset, startPredicate, goalPredicate }) {
const paths = [];
const penaltyField = new Float32Array(SIZE);
const seeds = chooseCorridorSeeds(potentialField, spacing, maxCount * 2, threshold, startPredicate, seedOffset);
const usedEndpoints = [];
for (const start of seeds) {
if (paths.length >= maxCount) break;
if (distanceToNearest(usedEndpoints, start.x, start.y) < spacing * 0.55) continue;
const path = traceCorridorByCost(
start,
(x, y, i) => goalPredicate(start, x, y, i, usedEndpoints),
costField,
penaltyField,
{ curvePenalty, penaltyStrength: penaltyStrength * 2.2, minGoalDistance: minLength, regionId: start.regionId }
);
if (path.length < minLength) continue;
paths.push(path);
usedEndpoints.push(start);
const end = endpointFromPath(path);
if (end) usedEndpoints.push(end);
addCorridorInfluencePenalty(penaltyField, path, penaltyRadius, penaltyStrength);
}
return paths;
}
function rasterizeNetworkComponents(paths, mode, potentialField) {
const occupied = new Uint8Array(SIZE);
for (const path of paths) {
for (const [x, y] of path || []) {
if (inside(x, y) && !sea[indexOf(x, y)]) occupied[indexOf(x, y)] = 1;
}
}
const componentId = new Int32Array(SIZE);
componentId.fill(-1);
const components = [];
for (let i = 0; i < SIZE; i++) {
if (!occupied[i] || componentId[i] >= 0) continue;
const id = components.length;
const queue = [i];
const cells = [];
const boundary = [];
componentId[i] = id;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
cells.push(cur);
const x = cur % MAP_W;
const y = Math.floor(cur / MAP_W);
let edge = false;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) { edge = true; continue; }
const ni = indexOf(nx, ny);
if (!occupied[ni]) {
edge = true;
continue;
}
if (componentId[ni] < 0) {
componentId[ni] = id;
queue.push(ni);
}
}
}
if (edge) boundary.push({ x, y, i: cur });
}
let lengthScore = 0;
let densityScore = 0;
let townScore = 0;
let logisticsScore = 0;
let capitalScore = 0;
const sampleStride = Math.max(1, Math.floor(cells.length / 80));
for (let c = 0; c < cells.length; c += sampleStride) {
const ci = cells[c];
const x = ci % MAP_W;
const y = Math.floor(ci / MAP_W);
lengthScore += sampleStride;
densityScore += settlementDemand[ci] * sampleStride;
logisticsScore += logisticsPreSuitability[ci] * sampleStride;
for (const p of [...modernCities, ...markets]) {
const d = Math.hypot(p.x - x, p.y - y);
if (d <= 9) townScore += ((p.population || 8000) / 50000) * (1 - d / 9);
}
for (const cty of modernCities) {
if (!cty.isPrefecturalCapital) continue;
const d = Math.hypot(cty.x - x, cty.y - y);
if (d <= 14) capitalScore += 5.0 * (1 - d / 14);
}
}
const importance =
Math.sqrt(lengthScore) * 1.20 +
densityScore * 0.38 +
townScore * 0.55 +
logisticsScore * 0.24 +
capitalScore;
components.push({ id, mode, cells, boundary, importance, length: cells.length, repairCount: 0, potential: cells.reduce((sum, ci) => sum + (potentialField?.[ci] || 0), 0) / Math.max(1, cells.length) });
}
return { occupied, componentId, components };
}
function componentAnchor(component, target, costField, usedAnchors = []) {
if (!component?.boundary?.length) return null;
let best = null;
let bestScore = INF;
const stride = Math.max(1, Math.floor(component.boundary.length / 90));
for (let k = 0; k < component.boundary.length; k += stride) {
const p = component.boundary[k];
if (costField[p.i] >= INF) continue;
if (distanceToNearest(usedAnchors, p.x, p.y) < 8) continue;
const d = target ? Math.hypot(p.x - target.x, p.y - target.y) : 0;
const score = d + costField[p.i] * 3.5 - corridorAllowance(p.i) * 2.4 + hash2(p.x, p.y, seed + 13701) * 1.8;
if (score < bestScore) {
bestScore = score;
best = { x: p.x, y: p.y, componentId: component.id, regionId: regionIdAt(p.x, p.y) };
}
}
return best;
}
function repairTransportConnectivity(paths, mode, costField, potentialField, options = {}) {
const debug = { mode, components: [], repairs: [] };
const raster = rasterizeNetworkComponents(paths, mode, potentialField);
debug.components = raster.components.map((c) => ({
id: c.id,
mode,
importance: c.importance,
length: c.length,
potential: c.potential,
cells: c.cells.filter((_, k) => k % Math.max(1, Math.floor(c.cells.length / 140)) === 0).map((i) => xyOf(i)),
}));
const important = raster.components
.filter((c) => c.length >= (options.minComponentCells ?? 18) && c.importance >= (options.minImportance ?? 8))
.sort((a, b) => b.importance - a.importance)
.slice(0, options.maxComponents ?? 8);
if (important.length < 2) return debug;
const networkPenalty = influenceFromPaths(paths, options.penaltyRadius ?? 8);
const usedAnchors = [];
const maxRepairs = options.maxRepairs ?? 4;
for (let r = 0; r < maxRepairs; r++) {
let bestPair = null;
let bestScore = INF;
for (let a = 0; a < important.length; a++) {
for (let b = a + 1; b < important.length; b++) {
const ca = important[a];
const cb = important[b];
if (ca.repairCount >= 2 || cb.repairCount >= 2) continue;
const centerA = ca.boundary[Math.floor(ca.boundary.length / 2)] || { x: 0, y: 0 };
const centerB = cb.boundary[Math.floor(cb.boundary.length / 2)] || { x: 0, y: 0 };
const d = Math.hypot(centerA.x - centerB.x, centerA.y - centerB.y);
if (d < (options.minRepairDistance ?? 14) || d > (options.maxRepairDistance ?? 120)) continue;
const score = d / Math.sqrt(ca.importance + cb.importance) + (ca.repairCount + cb.repairCount) * 18;
if (score < bestScore) {
bestScore = score;
bestPair = [ca, cb];
}
}
}
if (!bestPair) break;
const [aComp, bComp] = bestPair;
const roughTarget = bComp.boundary[Math.floor(bComp.boundary.length / 2)];
const start = componentAnchor(aComp, roughTarget, costField, usedAnchors);
const goalTarget = start ? componentAnchor(bComp, start, costField, usedAnchors) : null;
if (!start || !goalTarget) break;
const path = traceCorridorByCost(
start,
(x, y, i) => raster.componentId[i] === bComp.id || (potentialField[i] > (options.highPotentialThreshold ?? 0.42) && Math.hypot(x - goalTarget.x, y - goalTarget.y) < 5),
costField,
networkPenalty,
{
curvePenalty: options.curvePenalty ?? 0.14,
penaltyStrength: options.penaltyStrength ?? 1.8,
minGoalDistance: Math.min(12, Math.max(5, Math.hypot(start.x - goalTarget.x, start.y - goalTarget.y) * 0.35)),
keepRegion: false,
maxExpanded: SIZE,
}
);
if (path.length < (options.minAddedLength ?? 6) || path.length > (options.maxAddedLength ?? 120)) {
aComp.repairCount++;
continue;
}
paths.push(path);
debug.repairs.push({ mode, path, from: aComp.id, to: bComp.id });
usedAnchors.push(start, goalTarget);
aComp.repairCount++;
bComp.repairCount++;
addCorridorInfluencePenalty(networkPenalty, path, options.penaltyRadius ?? 8, options.addedPenalty ?? 0.38);
}
return debug;
}
function routeLight(a, b, snapRadius = 3, costField = transportFields.local) {
if (!a || !b) return [];
const steps = Math.max(2, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y) * 1.15));
const out = [];
@ -537,16 +1023,16 @@ export function generateMapFeatures(seed, terrain) {
const y = Math.round(fy + dy);
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i]) continue;
if (sea[i] || costField[i] >= INF) continue;
const lineDist = Math.hypot(x - fx, y - fy);
const cost = lineDist * 0.72 + corridorCost[i] * 0.62 - valleySettlement[i] * 0.34 - developable[i] * 0.18 + hash2(x, y, seed + 13000 + s) * 0.05;
const cost = lineDist * 0.72 + costField[i] * 0.74 - valleySettlement[i] * 0.20 - developable[i] * 0.12 + hash2(x, y, seed + 13000 + s) * 0.05;
if (cost < bestCost) {
bestCost = cost;
best = [x, y];
}
}
}
if (!best) best = [Math.round(fx), Math.round(fy)];
if (!best) continue;
const key = `${best[0]},${best[1]}`;
if (key !== lastKey) {
out.push(best);
@ -579,6 +1065,7 @@ export function generateMapFeatures(seed, terrain) {
const ringExpressways = [];
const externalExpressways = [];
const icAccessRoads = [];
const interchanges = [];
const externalGateways = [];
// Premodern roads connect castles/markets/ports sparsely.
@ -590,48 +1077,68 @@ export function generateMapFeatures(seed, terrain) {
}
}
for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) {
const nodes = importantNodesForRegion(regionId);
if (nodes.length < 2) continue;
const connected = [nodes[0]];
const remaining = nodes.slice(1);
const maxEdges = (regionStats.get(regionId)?.area || 0) > 2200 ? Math.min(13, nodes.length + 3) : Math.min(7, nodes.length + 1);
while (remaining.length && nationalRoads.length < 48) {
let best = null;
let bestScore = INF;
for (const a of connected) {
for (const b of remaining) {
const d = Math.hypot(a.x - b.x, a.y - b.y);
const score = d - (a.nodeWeight + b.nodeWeight) * 0.9;
if (score < bestScore) { bestScore = score; best = { a, b }; }
}
}
if (!best) break;
const path = routeLight(best.a, best.b, 3);
if (path.length > 2) nationalRoads.push(path);
connected.push(best.b);
remaining.splice(remaining.indexOf(best.b), 1);
if (connected.length - 1 >= maxEdges) break;
}
nationalRoads.push(...generateCorridorsFromField({
potentialField: transportFields.nationalPotential,
costField: transportFields.national,
spacing: 17,
maxCount: 42,
threshold: 0.30,
minLength: 20,
penaltyRadius: 6,
penaltyStrength: 0.34,
curvePenalty: 0.10,
seedOffset: 13400,
startPredicate: (x, y, i) => transportFields.nationalPotential[i] > 0.27 && regionIdAt(x, y) >= 0,
goalPredicate: (start, x, y, i, used) => {
if (regionIdAt(x, y) !== start.regionId) return false;
if (transportFields.nationalPotential[i] < 0.34) return false;
if (distanceToNearest(used, x, y) < 11) return false;
const d = Math.hypot(x - start.x, y - start.y);
return d > 22 && d < 76;
},
}));
// A few k-nearest shortcuts for urbanized regions.
const urbanNodes = nodes.filter((p) => p.population || p.portClass).slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 7 : 4);
for (let i = 0; i < urbanNodes.length; i++) {
const a = urbanNodes[i];
const b = urbanNodes.slice(i + 1).sort((p, q) => Math.hypot(a.x - p.x, a.y - p.y) - Math.hypot(a.x - q.x, a.y - q.y))[0];
if (!b || Math.hypot(a.x - b.x, a.y - b.y) > 48) continue;
const path = routeLight(a, b, 3);
if (path.length > 2) nationalRoads.push(path);
}
railways.push(...generateCorridorsFromField({
potentialField: transportFields.railPotential,
costField: transportFields.rail,
spacing: 24,
maxCount: 18,
threshold: 0.30,
minLength: 24,
penaltyRadius: 7,
penaltyStrength: 0.42,
curvePenalty: 0.30,
seedOffset: 13500,
startPredicate: (x, y, i) => transportFields.railPotential[i] > 0.26 && settlementDemand[i] > 0.16 && regionIdAt(x, y) >= 0,
goalPredicate: (start, x, y, i, used) => {
if (regionIdAt(x, y) !== start.regionId) return false;
if (transportFields.railPotential[i] < 0.30 || settlementDemand[i] < 0.18) return false;
if (distanceToNearest(used, x, y) < 15) return false;
const d = Math.hypot(x - start.x, y - start.y);
return d > 28 && d < 88;
},
}));
// Railways: only high-order cities/ports, as a lightweight placeholder.
const railNodes = nodes.filter((p) => (p.population || 0) > 80000 || p.portClass === "major" || p.portClass === "regional").slice(0, (regionStats.get(regionId)?.area || 0) > 2200 ? 6 : 4);
railNodes.sort((a, b) => a.x - b.x || a.y - b.y);
for (let i = 1; i < railNodes.length; i++) {
const path = routeLight(railNodes[i - 1], railNodes[i], 4);
if (path.length > 4) railways.push(path);
}
}
expressways.push(...generateCorridorsFromField({
potentialField: transportFields.expresswayPotential,
costField: transportFields.expressway,
spacing: 29,
maxCount: 10,
threshold: 0.30,
minLength: 30,
penaltyRadius: 9,
penaltyStrength: 0.55,
curvePenalty: 0.16,
seedOffset: 13600,
startPredicate: (x, y, i) => transportFields.expresswayPotential[i] > 0.27 && regionIdAt(x, y) >= 0,
goalPredicate: (start, x, y, i, used) => {
if (regionIdAt(x, y) !== start.regionId) return false;
if (transportFields.expresswayPotential[i] < 0.30) return false;
if (distanceToNearest(used, x, y) < 18) return false;
const d = Math.hypot(x - start.x, y - start.y);
return d > 34 && d < 104;
},
}));
// External gateways at land edges; used by naming/UI and later transport work.
for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) {
@ -659,20 +1166,105 @@ export function generateMapFeatures(seed, terrain) {
externalGateways.push(gateway);
const target = importantNodesForRegion(regionId)[0];
if (target) {
const path = routeLight(gateway, target, 3);
const path = routeLight(gateway, target, 3, transportFields.national);
if (path.length > 2) externalRoads.push(path);
}
}
}
// Approximate expressways as a very small subset of top inter-city links.
const topCities = modernCities.slice().sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 6);
for (let i = 1; i < topCities.length && expressways.length < 4; i++) {
const a = topCities[i - 1];
const b = topCities[i];
if (Math.hypot(a.x - b.x, a.y - b.y) < 85) {
const path = routeLight(a, b, 5);
if (path.length > 5) expressways.push(path);
const transportDebugLayers = {
expresswayPotential: transportFields.expresswayPotential,
railPotential: transportFields.railPotential,
nationalRoadPotential: transportFields.nationalPotential,
slopeSeaPenalty: (() => {
const out = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) out[i] = sea[i] ? 1 : clamp(slope[i] * 1.55 + Math.max(0, elevation[i] - 0.58) * 2.2 + ridgeField[i] * 0.42);
return out;
})(),
components: [],
repairedSegments: [],
unservedSettlements: [],
};
for (const repair of [
repairTransportConnectivity(expressways, "expressway", transportFields.expressway, transportFields.expresswayPotential, {
minImportance: 8.5,
minComponentCells: 18,
maxComponents: 7,
maxRepairs: 3,
maxRepairDistance: 115,
penaltyRadius: 9,
penaltyStrength: 2.3,
curvePenalty: 0.16,
highPotentialThreshold: 0.38,
}),
repairTransportConnectivity(railways, "rail", transportFields.rail, transportFields.railPotential, {
minImportance: 9.5,
minComponentCells: 16,
maxComponents: 8,
maxRepairs: 4,
maxRepairDistance: 105,
penaltyRadius: 7,
penaltyStrength: 2.0,
curvePenalty: 0.34,
highPotentialThreshold: 0.36,
}),
repairTransportConnectivity(nationalRoads, "national", transportFields.national, transportFields.nationalPotential, {
minImportance: 7.5,
minComponentCells: 12,
maxComponents: 10,
maxRepairs: 6,
maxRepairDistance: 95,
penaltyRadius: 6,
penaltyStrength: 1.7,
curvePenalty: 0.11,
highPotentialThreshold: 0.34,
}),
]) {
transportDebugLayers.components.push(...repair.components);
transportDebugLayers.repairedSegments.push(...repair.repairs);
}
function generateLocalRoadsForUnservedSettlements() {
const trunkInfluence = influenceFromPaths([...nationalRoads, ...railways, ...externalRoads], 8);
const candidates = [...villages, ...markets, ...ports]
.filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)])
.map((p) => {
const i = indexOf(p.x, p.y);
const settlementWeight = p.portClass ? 1.2 : p.population ? clamp(p.population / 18000, 0.35, 1.4) : 0.45;
return { ...p, score: settlementWeight + transportFields.localPotential[i] * 0.65 - trunkInfluence[i] * 1.15 };
})
.filter((p) => p.score > 0.18 && trunkInfluence[indexOf(p.x, p.y)] < 0.26)
.sort((a, b) => b.score - a.score)
.slice(0, 90);
const localPenalty = new Float32Array(SIZE);
const paths = [];
const served = [];
for (const start of candidates) {
if (paths.length >= 75) break;
if (distanceToNearest(served, start.x, start.y) < 4.5) continue;
const path = traceCorridorByCost(
start,
(x, y, i) => trunkInfluence[i] > 0.20 || (paths.length > 8 && localPenalty[i] > 0.05),
transportFields.local,
localPenalty,
{ curvePenalty: 0.08, penaltyStrength: 1.15, minGoalDistance: 5, regionId: start.regionId, maxExpanded: SIZE }
);
if (path.length < 4 || path.length > 70) continue;
paths.push(path);
served.push(start);
addCorridorInfluencePenalty(localPenalty, path, 4, 0.22);
}
return paths;
}
minorRoads.push(...generateLocalRoadsForUnservedSettlements());
for (const path of expressways) {
for (const p of samplePath(path, 24)) {
const i = indexOf(p.x, p.y);
if (sea[i] || transportFields.expresswayPotential[i] < 0.24) continue;
if (interchanges.every((q) => Math.hypot(q.x - p.x, q.y - p.y) > 14)) {
interchanges.push({ x: p.x, y: p.y, kind: "Interchange", score: transportFields.expresswayPotential[i], regionId: regionIdAt(p.x, p.y) });
}
}
}
@ -782,7 +1374,6 @@ export function generateMapFeatures(seed, terrain) {
const satelliteCities = [];
const newTowns = [];
const interchanges = [];
const logisticsScore = new Float32Array(SIZE);
for (let y = 2; y < MAP_H - 2; y++) {
for (let x = 2; x < MAP_W - 2; x++) {
@ -812,6 +1403,36 @@ export function generateMapFeatures(seed, terrain) {
predicate: (x, y, i) => logisticsScore[i] > 0.30 && (roadInfluence[i] > 0.10 || railInfluence2[i] > 0.08 || stationInfluence[i] > 0.08),
}).map((p) => ({ ...p, kind: "Logistics Park", score: logisticsScore[indexOf(p.x, p.y)], population: 0 }));
const logisticsInfluence = influenceFromPoints(logisticsParks, 4.8, () => 1.0);
function addFinalLocalAccessForUnservedSettlements() {
const accessInfluence = influenceFromPaths([...nationalRoads, ...railways, ...externalRoads, ...minorRoads], 7);
const candidates = [
...markets.filter((p) => (p.population || 0) >= 5000),
...ports,
...logisticsParks,
...villages.filter((p) => (elevation[indexOf(p.x, p.y)] > 0.48 || slope[indexOf(p.x, p.y)] > 0.30 || p.kind === "Valley Village") && (p.population || 0) >= 1200),
]
.filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)] && accessInfluence[indexOf(p.x, p.y)] < 0.18)
.map((p) => ({ ...p, score: (p.population || 7000) / 18000 + (p.portClass ? 0.8 : 0) + (p.kind === "Logistics Park" ? 1.0 : 0) + transportFields.localPotential[indexOf(p.x, p.y)] }))
.sort((a, b) => b.score - a.score)
.slice(0, 55);
const localPenalty = influenceFromPaths(minorRoads, 4);
for (const start of candidates) {
const path = traceCorridorByCost(
start,
(x, y, i) => accessInfluence[i] > 0.20 || localPenalty[i] > 0.10,
transportFields.local,
localPenalty,
{ curvePenalty: 0.08, penaltyStrength: 1.0, minGoalDistance: 4, regionId: start.regionId, maxExpanded: Math.floor(SIZE * 0.55) }
);
transportDebugLayers.unservedSettlements.push({ x: start.x, y: start.y, kind: start.kind || "Unserved Settlement", mode: "local-access", repaired: path.length >= 4 && path.length <= 64 });
if (path.length < 4 || path.length > 64) continue;
minorRoads.push(path);
transportDebugLayers.repairedSegments.push({ mode: "local", path, from: "unserved", to: "network" });
addCorridorInfluencePenalty(localPenalty, path, 4, 0.20);
}
}
addFinalLocalAccessForUnservedSettlements();
var landuse = new Uint8Array(SIZE);
// Re-run land-use classification after landuse allocation. The loop above is
@ -1014,6 +1635,12 @@ export function generateMapFeatures(seed, terrain) {
humanStageVersion: "v2-sparse-raster",
aStarRoutes: 0,
regionalNodeCount: [...regionStats.keys()].reduce((sum, regionId) => sum + importantNodesForRegion(regionId).length, 0),
fieldCorridorTransport: true,
expresswayFieldCorridors: expressways.length,
railFieldCorridors: railways.length,
nationalRoadFieldCorridors: nationalRoads.length,
localRoadFieldCorridors: minorRoads.length,
layers: transportDebugLayers,
nationalRoadPopulationCoverage: 0,
nationalRoadUncoveredPopulation: 0,
};

View file

@ -1,9 +1,13 @@
import { createNameDebug } from "./names.js";
import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js";
import { applyOutputOptions, attachIdsAndNames, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
import { applyOutputOptions, attachIdsAndNames, influenceFromPaths, tagInsidePrefecture } from "./mapGeneratorHelpers.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;
@ -18,16 +22,20 @@ function municipalitySuffixForCenter(center, fields, seed, ordinal = 0) {
function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) {
let value = String(root || center?.name || "").trim();
if (!value) value = `自治${ordinal + 1}`;
value = value.replace(/[駅港城跡宿]$/u, "");
if (Array.from(value).length < 2) value = `${value}${String(center?.generatedMunicipalityName || "里")}`.slice(0, 3);
if (MUNICIPAL_SUFFIX_RE.test(value)) return value;
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) {
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;
@ -39,15 +47,108 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields) {
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;
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 buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames, nameDebug) {
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];
@ -62,6 +163,21 @@ function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames
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);
@ -80,14 +196,54 @@ function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames
Math.hypot(x - cx, y - cy) * 0.08;
if (score > bestScore) { bestScore = score; bestI = i; }
}
const x = bestI % MAP_W;
const y = Math.floor(bestI / 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) });
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 });
}
return attachIdsAndNames(regions, "prefecture", seed + 41000, null, fields, usedNames, nameDebug)
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,
@ -284,18 +440,24 @@ export function finishMapOutput({
for (const [index, center] of adminCenters.entries()) {
center.adminNumericId = index;
center.municipalityId = index;
let candidate = center.canonicalSettlementName || municipalityNameFromRoot(center.municipalityRootName || center.generatedMunicipalityName || center.name, center, nameFields, seed, 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 root = String(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name || `自治${index + 1}`).replace(/[市町村区]$/u, "");
const directions = ["東", "西", "南", "北", "上", "下", "中"];
for (let attempt = 0; attempt < directions.length + 3 && usedAdminNames.has(candidate); attempt++) {
const prefix = directions[attempt % directions.length];
candidate = attempt < directions.length ? `${prefix}${root}${suffix}` : `${root}${index + 1}${suffix}`;
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;
@ -303,13 +465,93 @@ export function finishMapOutput({
center.municipalityName = candidate;
usedAdminNames.add(center.name);
}
assignMunicipalityPopulations(adminCenters, adminId, nameFields);
const promotedPrefectureCapitals = promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, nameFields, seed, 220000);
assignMunicipalityPopulations(adminCenters, adminId, nameFields, [
...modernCities,
...markets,
...villages,
...satelliteCities,
...newTowns,
]);
function addMunicipalCenterLocalAccess() {
if (!transportDebug) return;
const debugLayers = transportDebug.layers || (transportDebug.layers = { repairedSegments: [], unservedSettlements: [] });
debugLayers.repairedSegments ||= [];
debugLayers.unservedSettlements ||= [];
const accessInfluence = influenceFromPaths([...nationalRoads, ...railways, ...externalRoads, ...minorRoads], 7);
const localPenalty = influenceFromPaths(minorRoads, 4);
const candidates = adminCenters
.filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)] && accessInfluence[indexOf(p.x, p.y)] < 0.16)
.sort((a, b) => (b.municipalityPopulation || 0) - (a.municipalityPopulation || 0))
.slice(0, 45);
function routeAccess(start) {
const maxSteps = 72;
const out = [];
const visited = new Set([`${start.x},${start.y}`]);
let x = start.x;
let y = start.y;
let lastKey = "";
for (let step = 0; step < maxSteps; step++) {
const i = indexOf(x, y);
if (step > 3 && (accessInfluence[i] > 0.20 || localPenalty[i] > 0.10)) return out;
let best = null;
let bestScore = INF;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (sea[ni]) continue;
if (visited.has(`${nx},${ny}`)) continue;
const cost =
1.1 +
slope[ni] * 1.4 +
ridgeField[ni] * 0.55 +
Math.max(0, elevation[ni] - 0.70) * 1.2 -
Math.max(roadInfluence[ni], railInfluence2[ni]) * 2.8 -
plain[ni] * 0.28 -
valleyField[ni] * 0.18 -
coastalLowland[ni] * 0.16 +
localPenalty[ni] * 0.55;
if (cost < bestScore) {
bestScore = cost;
best = [nx, ny];
}
}
}
if (!best) break;
x = best[0];
y = best[1];
const key = `${x},${y}`;
visited.add(key);
if (key !== lastKey) {
out.push([x, y]);
lastKey = key;
}
}
return [];
}
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);
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);
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");

View file

@ -166,7 +166,7 @@ const TERRAIN_TYPES = [
seaRatioRange: [0.13, 0.23],
twoSidedChance: 0.96,
mountainOffsetRange: [0.47, 0.53],
baseHeightRange: [0.74, 1.10],
baseHeightRange: [0.56, 0.82],
primaryLengthRange: [0.76, 0.96],
primaryWidthRange: [0.13, 0.22],
systemCountRange: [12, 16],
@ -175,7 +175,7 @@ const TERRAIN_TYPES = [
crossSpread: 0.38,
lengthScale: 1.22,
widthScale: 0.92,
heightScale: 1.24,
heightScale: 0.86,
coastStrength: 0.90,
plainBiasRange: [0.16, 0.34],
riverRichnessRange: [0.70, 1.18],
@ -188,10 +188,10 @@ const TERRAIN_TYPES = [
coastStyle: "outer_coast",
mountainMode: "massif",
massifnessRange: [0.42, 0.74],
seaRatioRange: [0.10, 0.20],
twoSidedChance: 0.20,
seaRatioRange: [0.000, 0.030],
twoSidedChance: 0.10,
mountainOffsetRange: [0.16, 0.36],
baseHeightRange: [0.68, 1.04],
baseHeightRange: [0.76, 1.12],
primaryLengthRange: [0.62, 0.92],
primaryWidthRange: [0.30, 0.58],
systemCountRange: [16, 20],
@ -200,11 +200,11 @@ const TERRAIN_TYPES = [
crossSpread: 0.82,
lengthScale: 1.34,
widthScale: 1.30,
heightScale: 1.12,
coastStrength: 0.74,
heightScale: 1.32,
coastStrength: 0.30,
plainBiasRange: [0.08, 0.24],
riverRichnessRange: [0.72, 1.12],
bigRiverChanceRange: [0.28, 0.58],
riverRichnessRange: [0.74, 1.14],
bigRiverChanceRange: [0.30, 0.60],
},
{
id: "setouchi_inland_sea",
@ -212,20 +212,20 @@ const TERRAIN_TYPES = [
weight: 0.16,
coastStyle: "inland_sea",
mountainMode: "mixed",
massifnessRange: [0.24, 0.48],
massifnessRange: [0.34, 0.62],
seaRatioRange: [0.20, 0.33],
twoSidedChance: 0.92,
mountainOffsetRange: [0.22, 0.34],
baseHeightRange: [0.56, 1.00],
primaryLengthRange: [0.52, 0.76],
primaryWidthRange: [0.18, 0.34],
systemCountRange: [12, 16],
beltCountRange: [2, 3],
angleSpread: 0.24,
crossSpread: 0.70,
lengthScale: 0.98,
widthScale: 1.08,
heightScale: 1.08,
baseHeightRange: [0.46, 0.78],
primaryLengthRange: [0.52, 0.78],
primaryWidthRange: [0.20, 0.38],
systemCountRange: [16, 22],
beltCountRange: [3, 4],
angleSpread: 0.34,
crossSpread: 0.86,
lengthScale: 1.00,
widthScale: 1.18,
heightScale: 0.82,
coastStrength: 1.10,
plainBiasRange: [0.26, 0.50],
riverRichnessRange: [0.58, 0.96],
@ -237,24 +237,24 @@ const TERRAIN_TYPES = [
weight: 0.16,
coastStyle: "open_bay",
mountainMode: "range",
massifnessRange: [0.18, 0.44],
seaRatioRange: [0.15, 0.26],
twoSidedChance: 0.18,
massifnessRange: [0.10, 0.30],
seaRatioRange: [0.08, 0.17],
twoSidedChance: 0.10,
mountainOffsetRange: [0.28, 0.46],
baseHeightRange: [0.62, 1.04],
primaryLengthRange: [0.42, 0.70],
primaryWidthRange: [0.20, 0.36],
systemCountRange: [10, 14],
baseHeightRange: [0.48, 0.82],
primaryLengthRange: [0.38, 0.62],
primaryWidthRange: [0.16, 0.30],
systemCountRange: [7, 11],
beltCountRange: [2, 3],
angleSpread: 0.34,
crossSpread: 0.62,
lengthScale: 0.92,
widthScale: 1.10,
heightScale: 1.10,
coastStrength: 0.92,
plainBiasRange: [0.56, 0.86],
riverRichnessRange: [0.98, 1.38],
bigRiverChanceRange: [0.62, 0.90],
heightScale: 0.84,
coastStrength: 0.68,
plainBiasRange: [0.70, 0.96],
riverRichnessRange: [1.18, 1.58],
bigRiverChanceRange: [0.80, 0.98],
},
{
id: "mixed_archipelago",
@ -284,13 +284,11 @@ const TERRAIN_TYPES = [
];
function pickTerrainType(seed) {
const total = TERRAIN_TYPES.reduce((sum, type) => sum + type.weight, 0);
let r = rand(seed, 10001) * total;
for (const type of TERRAIN_TYPES) {
r -= type.weight;
if (r <= 0) return type;
}
return TERRAIN_TYPES[TERRAIN_TYPES.length - 1];
// Terrain type selection is intentionally uniform. Individual terrain
// templates still contain their own parameter ranges, but there is no
// terrain-type appearance weighting.
const index = Math.floor(rand(seed, 10001) * TERRAIN_TYPES.length) % TERRAIN_TYPES.length;
return TERRAIN_TYPES[index];
}
function rangeValue(seed, salt, [lo, hi]) {
@ -315,11 +313,12 @@ export function buildTerrainTemplate(seed) {
const seaRatio = rangeValue(seed, 23, terrainType.seaRatioRange);
let mountainAngle = coastAngle + Math.PI * (rangeValue(seed, 24, terrainType.mountainOffsetRange));
if (terrainType.id === "tohoku_spine") {
// 東北型は左右端または上下端に海を置き、海岸線にほぼ平行な長大脊梁を通す。
// coastAngle は海へ向かう勾配方向、等値線としての海岸線は +90° 方向。
coastAngle = (rand(seed, 2101) < 0.5 ? 0 : Math.PI / 2) + (rand(seed, 2102) - 0.5) * 0.10;
// 東北型の脊梁は南北/東西だけでなく斜め軸も許容する。
// 海岸勾配は脊梁軸に概ね直交させるが、山脈自体の向きは独立に選ぶ。
const axisChoices = [0, Math.PI / 2, Math.PI / 4, -Math.PI / 4, Math.PI * 0.35, Math.PI * 0.65];
mountainAngle = axisChoices[Math.floor(rand(seed, 2101) * axisChoices.length) % axisChoices.length] + (rand(seed, 2102) - 0.5) * 0.24;
coastAngle = mountainAngle - Math.PI / 2 + (rand(seed, 2104) - 0.5) * 0.12;
twoSidedCoast = true;
mountainAngle = coastAngle + Math.PI / 2 + (rand(seed, 2103) - 0.5) * 0.16;
}
const baseHeight = rangeValue(seed, 25, terrainType.baseHeightRange);
const primaryLength = rangeValue(seed, 26, terrainType.primaryLengthRange);
@ -445,7 +444,7 @@ function buildMountainSystems(template, seed) {
angle: centralAngle,
length: (0.78 + rand(seed, 3333) * 0.18) * lengthScale,
width: (0.12 + rand(seed, 3334) * 0.07) * widthScale,
height: template.mountainBaseHeight * heightScale * (0.58 + rand(seed, 3335) * 0.18),
height: template.mountainBaseHeight * heightScale * (0.50 + rand(seed, 3335) * 0.15),
scratchCount: Math.round(20 + rand(seed, 3336) * 10),
massifness: clamp((template.mountainMassifness ?? 0.16) * 0.55),
role: "central-primary",
@ -462,7 +461,7 @@ function buildMountainSystems(template, seed) {
angle: centralAngle + (rand(seed, 3341) - 0.5) * 0.08,
length: (0.48 + rand(seed, 3342) * 0.20) * lengthScale,
width: (0.075 + rand(seed, 3343) * 0.055) * widthScale,
height: template.mountainBaseHeight * heightScale * (0.38 + rand(seed, 3344) * 0.16),
height: template.mountainBaseHeight * heightScale * (0.33 + rand(seed, 3344) * 0.13),
scratchCount: Math.round(12 + rand(seed, 3345) * 8),
massifness: clamp((template.mountainMassifness ?? 0.16) * 0.65),
role: "central-secondary",
@ -756,6 +755,140 @@ function traceFlowPath(start, sea, flowTo, maxSteps = 900) {
return path;
}
function lineCellsBetween(ax, ay, bx, by) {
const cells = [];
const steps = Math.max(Math.abs(bx - ax), Math.abs(by - ay), 1);
for (let t = 0; t <= steps; t++) {
const x = Math.round(ax + (bx - ax) * t / steps);
const y = Math.round(ay + (by - ay) * t / steps);
if (inside(x, y) && (!cells.length || cells[cells.length - 1][0] !== x || cells[cells.length - 1][1] !== y)) cells.push([x, y]);
}
return cells;
}
function meanderRiverPath(path, seed, salt, sea, lake, elevation, slope) {
if (!path || path.length < 18) return path;
// Add a visible but controlled meander before valley incision. The meander
// diameter is about 3-10 cells. Mountain reaches are no longer damped;
// the same low-frequency bend model is applied throughout the course.
const lengthFactor = clamp(path.length / 190);
const controlStep = Math.max(4, Math.round(8 - lengthFactor * 3));
const phase = hash2(seed + salt, 17) * Math.PI * 2;
const waveCells = 14 + Math.floor(hash2(seed, salt + 31) * 16); // broad wavelength
const secondaryCells = 28 + Math.floor(hash2(seed + 3, salt + 53) * 24);
const maxDiameter = 3 + hash2(seed + 5, salt + 71) * 7;
const baseAmp = maxDiameter * 0.5;
const controls = [];
for (let k = 0; k < path.length; k += controlStep) controls.push(k);
if (controls[controls.length - 1] !== path.length - 1) controls.push(path.length - 1);
const displacedControls = [];
for (const k of controls) {
const [x, y] = path[k];
if (k === 0 || k === path.length - 1) { displacedControls.push([x, y]); continue; }
const [px, py] = path[Math.max(0, k - controlStep * 2)];
const [nx0, ny0] = path[Math.min(path.length - 1, k + controlStep * 2)];
const tx = nx0 - px;
const ty = ny0 - py;
const len = Math.hypot(tx, ty) || 1;
const normalX = -ty / len;
const normalY = tx / len;
const primary = Math.sin(k / waveCells * Math.PI * 2 + phase);
const secondary = Math.sin(k / secondaryCells * Math.PI * 2 + phase * 0.43) * 0.35;
const amp = baseAmp * 0.82 * (primary + secondary);
let mx = Math.round(x + normalX * amp);
let my = Math.round(y + normalY * amp);
if (!inside(mx, my)) { displacedControls.push([x, y]); continue; }
const mi = indexOf(mx, my);
if (sea[mi] && !lake[mi] && k < path.length - controlStep) { displacedControls.push([x, y]); continue; }
displacedControls.push([mx, my]);
}
const out = [];
for (let c = 0; c < displacedControls.length - 1; c++) {
const [ax, ay] = displacedControls[c];
const [bx, by] = displacedControls[c + 1];
const line = lineCellsBetween(ax, ay, bx, by);
for (const cell of line) {
if (out.length && out[out.length - 1][0] === cell[0] && out[out.length - 1][1] === cell[1]) continue;
const ci = indexOf(cell[0], cell[1]);
if (sea[ci] && !lake[ci] && c < displacedControls.length - 3) continue;
out.push(cell);
}
}
return out.length >= Math.max(8, path.length * 0.42) ? out : path;
}
function scoreRiverPathForDedup(path, flowAccum) {
let maxFlow = 0;
let meanFlow = 0;
for (const [x, y] of path) {
const f = flowAccum[indexOf(x, y)] || 0;
maxFlow = Math.max(maxFlow, f);
meanFlow += f;
}
meanFlow /= Math.max(1, path.length);
return path.length * 0.75 + maxFlow * 90 + meanFlow * 35;
}
function dedupeRiverPaths(paths, flowAccum, sea, lake) {
// Multiple traces often share, or run one cell beside, the same downstream
// trunk. Trim later traces at the first near-confluence so visually only one
// river occupies a channel, while true tributaries remain visible upstream.
const sorted = paths
.map((path) => ({ path, score: scoreRiverPathForDedup(path, flowAccum) }))
.sort((a, b) => b.score - a.score);
const occupied = new Uint8Array(SIZE);
const accepted = [];
const nearOccupied = (x, y) => {
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
if (occupied[indexOf(nx, ny)]) return true;
}
}
return false;
};
const markNear = (x, y) => {
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = x + dx;
const ny = y + dy;
if (inside(nx, ny)) occupied[indexOf(nx, ny)] = 1;
}
}
};
for (const item of sorted) {
const path = item.path;
if (!path || path.length < 8) continue;
let joinAt = -1;
let nearRun = 0;
for (let k = 0; k < path.length; k++) {
const [x, y] = path[k];
if (nearOccupied(x, y) && k > 6) {
nearRun++;
if (nearRun >= 2) { joinAt = Math.max(6, k - 1); break; }
} else {
nearRun = 0;
}
}
const trimmed = joinAt >= 0 ? path.slice(0, Math.min(path.length, joinAt + 1)) : path;
let uniqueCells = 0;
for (const [x, y] of trimmed) if (!nearOccupied(x, y)) uniqueCells++;
if (trimmed.length < 8 || uniqueCells < Math.max(5, Math.min(18, trimmed.length * 0.38))) continue;
accepted.push(trimmed);
for (const [x, y] of trimmed) {
const i = indexOf(x, y);
if (!sea[i] || lake[i]) markNear(x, y);
}
if (accepted.length >= 62) break;
}
return accepted;
}
function buildRiverNetwork(seed, template, sea, lake, elevation, slope, flowTo, flowAccum, river, erosionField) {
river.fill(0);
const candidates = [];
@ -770,46 +903,39 @@ function buildRiverNetwork(seed, template, sea, lake, elevation, slope, flowTo,
if (score > 0.24) candidates.push({ x, y, score });
}
}
const desired = 44 + Math.floor(template.riverRichness * 28);
const sources = pickEntities(candidates, { max: desired, minDistance: 6, threshold: 0.26, seed: seed + 12100, jitter: 0.035 });
const desired = 26 + Math.floor(template.riverRichness * 15);
const sources = pickEntities(candidates, { max: desired, minDistance: 8, threshold: 0.27, seed: seed + 12100, jitter: 0.035 });
const riverPaths = [];
for (const s of sources) {
const path = traceFlowPath(indexOf(s.x, s.y), sea, flowTo);
const traced = traceFlowPath(indexOf(s.x, s.y), sea, flowTo);
const path = meanderRiverPath(traced, seed, s.x * 4096 + s.y, sea, lake, elevation, slope);
if (path.length >= 8 && path.some(([x, y], k) => k > 5 && (sea[indexOf(x, y)] || lake[indexOf(x, y)]))) riverPaths.push(path);
else if (path.length >= 14) riverPaths.push(path);
}
const longPaths = riverPaths
.map((path) => {
let maxFlow = 0;
let meanFlow = 0;
for (const [x, y] of path) {
const f = flowAccum[indexOf(x, y)];
maxFlow = Math.max(maxFlow, f);
meanFlow += f;
}
meanFlow /= Math.max(1, path.length);
return { path, score: path.length * 0.75 + maxFlow * 90 + meanFlow * 35 };
})
const visibleRiverPaths = dedupeRiverPaths(riverPaths, flowAccum, sea, lake);
const longPaths = visibleRiverPaths
.map((path) => ({ path, score: scoreRiverPathForDedup(path, flowAccum) }))
.sort((a, b) => b.score - a.score);
const mainCount = Math.min(longPaths.length, template.bigRiverChance > 0.56 ? 4 : 3);
const mainCount = Math.min(longPaths.length, template.terrainType === "kanto_alluvial" ? 5 : template.bigRiverChance > 0.56 ? 4 : 3);
const mainSet = new Set(longPaths.slice(0, mainCount).map((p) => p.path));
const riverThreshold = 0.26 - template.riverRichness * 0.035 - (template.bigRiverChance > 0.56 ? 0.030 : 0);
const riverThreshold = 0.26 - template.riverRichness * 0.040 - (template.bigRiverChance > 0.56 ? 0.038 : 0) - (template.terrainType === "kanto_alluvial" ? 0.035 : 0);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
const f = flowAccum[i];
if (f > riverThreshold) river[i] = clamp((f - riverThreshold) / (0.55 - riverThreshold));
}
for (const path of riverPaths) {
for (const path of visibleRiverPaths) {
const main = mainSet.has(path);
for (let k = 0; k < path.length; k++) {
const [x, y] = path[k];
const i = indexOf(x, y);
if (sea[i]) continue;
const downstream = k / Math.max(1, path.length - 1);
const boost = main ? 0.54 + downstream * 0.42 : 0.30 + downstream * 0.22;
river[i] = clamp(Math.max(river[i], boost + flowAccum[i] * (main ? 0.70 : 0.42)));
const kantoMain = main && template.terrainType === "kanto_alluvial";
const boost = main ? (kantoMain ? 0.66 : 0.54) + downstream * (kantoMain ? 0.50 : 0.42) : 0.30 + downstream * 0.22;
river[i] = clamp(Math.max(river[i], boost + flowAccum[i] * (main ? (kantoMain ? 0.88 : 0.70) : 0.42)));
}
}
@ -835,8 +961,9 @@ function buildRiverNetwork(seed, template, sea, lake, elevation, slope, flowTo,
const sortedPaths = longPaths.map((p) => p.path);
const mainRivers = sortedPaths.filter((p) => mainSet.has(p)).slice(0, mainCount);
const tributaryRivers = sortedPaths.filter((p) => !mainSet.has(p)).slice(0, 24);
const smallStreams = sortedPaths.slice(mainCount + 8, mainCount + 58);
const nonMainPaths = sortedPaths.filter((p) => !mainSet.has(p));
const tributaryRivers = nonMainPaths.slice(0, 24);
const smallStreams = nonMainPaths.slice(24, 74);
return { riverPaths: sortedPaths.slice(0, 80), mainRivers, tributaryRivers, smallStreams };
}
@ -990,7 +1117,24 @@ export function generateTerrainAndRivers(seed) {
e += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80);
e += global * 0.020;
e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax;
elevation[i] = clamp(softCapElevation(e, terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91, 1.08), 0.025, 1.08);
if (terrainTemplate.terrainType === "tohoku_spine") {
// Keep the broad Ou/backbone footprint, but compress peak height so the
// range reads as a long Japanese spine rather than an alpine wall.
const high = Math.max(0, e - 0.48);
e -= high * clamp(0.18 + mountainMaskMax * 0.22, 0.18, 0.40);
}
if (terrainTemplate.terrainType === "setouchi_inland_sea") {
// Setouchi maps should have many low hills and island backbones rather
// than a few high alpine ridges. Add broad low relief, then cap peaks.
const lowHillNoise = clamp((fbm(x * 0.95 + 17, y * 0.95 - 23, seed + 571) - 0.38) * 2.9);
const lowHillMask = clamp(0.34 + mountainMaskMax * 0.72 + lowHillNoise * 0.50 - coastPressure * 0.12);
e += lowHillMask * 0.145;
const high = Math.max(0, e - 0.62);
e -= high * 0.42;
}
const softCapStart = terrainTemplate.terrainType === "tohoku_spine" ? 0.78 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.72 : terrainTemplate.terrainType === "chubu_mountain" ? 0.88 : 0.91;
const softCapMax = terrainTemplate.terrainType === "tohoku_spine" ? 0.96 : terrainTemplate.terrainType === "setouchi_inland_sea" ? 0.92 : 1.08;
elevation[i] = clamp(softCapElevation(e, softCapStart, softCapMax), 0.025, softCapMax);
visibleRavineField[i] = clamp(Math.abs(scratch) * mountainMaskMax * 0.30 + Math.max(0, -scratch) * mountainMaskMax * 0.40);
surfaceTextureField[i] = clamp(Math.abs(macro) * 0.12 + Math.abs(scratch) * mountainMaskMax * 0.46);
valleyField[i] = clamp(Math.max(0, -scratch) * mountainMaskMax * 0.18);
@ -1018,7 +1162,7 @@ export function generateTerrainAndRivers(seed) {
const natural = buildNaturalCompartments(
landMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum,
null, plain, agriculture, zeroDensity, zeroLanduse,
{ seed: seed + 17003, targetCompartmentCount: clamp(Math.round((SIZE - sea.reduce((sum, value) => sum + value, 0)) / 30), 80, 520) }
{ seed: seed + 17003, targetCompartmentCount: clamp(Math.round((SIZE - sea.reduce((sum, value) => sum + value, 0)) / 45), 70, 360) }
);
const sharedNaturalBarrierScore = natural.naturalBarrierScore || naturalBarrierScore;
const prefectureBorder = extractMaskBorder(prefectureMask, sea);

View file

@ -1,6 +1,17 @@
import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js";
export const CUSTOM_NAME_LIST = ["加茂", "春日", "新庄", "常盤", "二軒屋", "三軒家", "追分", "清水", "曙", "暁", "旭", "鳥羽", "長門", "吉野", "吉田", "観音寺",];
export const CUSTOM_NAME_LIST = ["加茂", "春日", "新庄", "常盤", "追分", "清水", "曙", "暁", "旭", "鳥羽", "長門", "吉野", "吉田", "美郷", "美里", "美山"];
function nameCharCount(value) {
return Array.from(String(value || "")).length;
}
function isAtomicNamePart(value) {
// The generator used to create visibly synthetic three-part names by joining
// a prefix with a compound terrain word. For template generation, keep each
// lexical slot atomic so a generated root is at most two visible elements.
return nameCharCount(value) <= 1;
}
export const NAME_KANJI_POOLS = {
modifiers: [
@ -11,20 +22,20 @@ export const NAME_KANJI_POOLS = {
"白", "黒", "青", "赤", "藍",
"奥", "前", "後", "内", "外",
"美", "吉", "福", "幸", "徳",
"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万",
"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千",
"霞", "朝", "日", "天",
"土", "砂", "石", "岩",
"丑", "卯", "辰", "巳", "酉",
"早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌",
"卯", "辰",
"荒", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄"
],
inlandTerrain: [
"山", "野", "荒", "野", "沢",
"山", "野", "野", "沢",
"森", "林", "岡", "丘", "坂",
"峰", "峠", "嶺", "尾", "平", "坪", "延",
"窪", "久", "迫", "久保", "玖保", "漥", "佐古", "作古",
"窪", "久", "迫", "久保", "玖保", "佐古", "作古",
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生",
"聡", "郷", "里",
"郷", "里",
"馬", "鹿", "亀", "鷲", "鷹", "鶴", "鴨", "竜", "龍", "牛", "鳥",
"湯",
],
@ -53,7 +64,7 @@ export const NAME_KANJI_POOLS = {
"橘", "柏", "槙", "柿", "桃",
"梨", "桑", "麻", "芦", "茅",
"粟", "稲", "稗", "米", "飯", "糠", "茜", "葵",
"榊", "楢", "檜", "椎", "柚", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑", "瓜"
"榊", "楢", "檜", "椎", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑", "瓜"
],
postfixes: [
@ -63,13 +74,13 @@ export const NAME_KANJI_POOLS = {
"辺", "里", "郷", "村", "町",
"宿", "庄", "台", "坂", "橋",
"本", "内", "窪", "平", "塚",
"畑", "牧", "前", "見", "中", "羽", "生", "塚", "部",
"畑", "牧", "前", "見", "中", "羽", "生", "塚", "部", "栄", "永",
],
archaicPrefixes: [
"阿", "吾", "安", "有", "衣", "伊", "以", "井", "宇", "羽", "江", "恵", "尾", "小", "於",
"阿", "吾", "安", "有", "衣", "伊", "以", "井", "宇", "羽", "江", "恵", "尾", "小",
"可", "加", "賀", "香", "鹿", "賀", "嘉", "喜", "紀", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子", "巨", "己",
"佐", "紗", "左", "志", "師", "須", "瀬", "曽", "蘇",
"佐", "紗", "左", "志", "師", "須", "瀬", "曽", "蘇", "総",
"多", "太", "知", "津", "土",
"那", "奈", "名", "仁", "尼", "根", "乃", "能",
"波", "氷", "比", "肥", "布", "夫", "戸", "保", "穂",
@ -81,14 +92,14 @@ export const NAME_KANJI_POOLS = {
"日", "紀", "志", "尾", "駿",
"甲", "信", "越", "備", "能",
"薩", "隠", "美", "三", "若",
"遠", "近", "能", "加", "賀", "度",
"遠", "近", "能", "加", "賀", "度", "飾",
"越", "淡", "壱", "衣", "古", "彦", "多", "志", "布", "治", "加茂",
],
archaicSuffixes: [
"井", "羽", "江", "恵", "尾", "於",
"井", "羽", "江", "恵", "尾",
"賀", "鹿", "喜", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子",
"佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇",
"佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇", "総",
"多", "太", "知", "津", "豆", "土", "登",
"那", "奈", "名", "仁", "尼", "根", "乃", "能",
"波", "布", "夫", "戸", "保", "穂",
@ -99,8 +110,8 @@ export const NAME_KANJI_POOLS = {
"陀", "芸", "雲",
"幡", "耆", "摩",
"張", "江", "河", "斐", "濃",
"岐", "門", "隅", "向",
"居", "前", "中", "後", "波",
"岐", "門", "隅", "向", "飾",
"居", "前", "中", "後",
"勢", "渡", "城", "紫", "野", "度",
"津", "島", "信", "登", "賀", "志",
"良", "美", "智", "茂", "代", "古", "麻", "彦", "比古", "子", "加茂",
@ -110,7 +121,7 @@ export const NAME_KANJI_POOLS = {
"里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣",
"庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂",
"城", "館", "屋", "家", "所",
"市", "場", "関", "地蔵", "辻", "角", "堰",
"市", "場", "関", "地蔵", "辻", "角", "堰", "通", "路", "道", "筋",
]
};
@ -471,6 +482,7 @@ export function validateGeneratedName(name, options = {}) {
return { valid: false, reason: "asciiDiagnostic" };
}
if (!options.allowOneCharacter && length < 2) return { valid: false, reason: "oneCharacter" };
if (Number.isFinite(options.maxLength) && length > options.maxLength) return { valid: false, reason: "tooLong" };
if (!options.allowLong && length > 4) return { valid: false, reason: "tooLong" };
if (!options.allowRepeatedKanji && hasRepeatedKanji(value)) return { valid: false, reason: "repeatedKanji" };
return { valid: true, reason: "valid" };
@ -489,20 +501,24 @@ function generateTemplateNameDetails(seed, id, entity, fields, attempt, usedName
const context = chooseNameContext(entity, fields);
const templateKey = chooseTemplate(context, seed, id, attempt);
const template = NAME_TEMPLATES[templateKey];
if (!template) return { name: null, context, templateKey: null };
// Do not generate old-style three-part random toponyms; names are now either
// curated list entries or at most two lexical elements plus any administrative suffix.
if (!template || (template.slots?.length || 0) > 2) return { name: null, context, templateKey: null };
const parts = [];
for (let slotIndex = 0; slotIndex < template.slots.length; slotIndex++) {
const slot = template.slots[slotIndex];
const slotPool = resolveSlotPool(slot, context, pools, probabilities, seed, id, attempt + slotIndex);
if (!slotPool?.pool?.length) return { name: null, context, templateKey };
const part = pick(slotPool.pool, seed, id, attempt, 2503 + slotIndex * 127 + stableHash(slotPool.key));
const atomicPool = slotPool.pool.filter(isAtomicNamePart);
if (!atomicPool.length) return { name: null, context, templateKey };
const part = pick(atomicPool, seed, id, attempt, 2503 + slotIndex * 127 + stableHash(slotPool.key));
if (!part) return { name: null, context, templateKey };
parts.push(part);
}
const name = parts.join("");
const validation = validateGeneratedName(name);
const validation = validateGeneratedName(name, { allowLong: false, maxLength: 2 });
if (!validation.valid) return { name: null, context, templateKey, invalidReason: validation.reason };
if (usedNames?.has(name)) return { name: null, context, templateKey, duplicate: true };
return { name, context, templateKey };
@ -533,6 +549,10 @@ function tryCustomNameList(seed, id, usedNames, debug) {
const start = Math.floor(roll(seed, id, 0, 3539) * CUSTOM_NAME_LIST.length) % CUSTOM_NAME_LIST.length;
for (let offset = 0; offset < CUSTOM_NAME_LIST.length; offset++) {
const customName = CUSTOM_NAME_LIST[(start + offset) % CUSTOM_NAME_LIST.length];
if (nameCharCount(customName) > 2) {
debug.invalidNamesRejected++;
continue;
}
const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true });
if (!validation.valid) {
if (validation.reason === "oneCharacter") {

View file

@ -314,7 +314,8 @@ function terrainColorContinuous(map, fx, fy, mode) {
if (isWaterSample(map, fx, fy)) {
const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4);
color = [Math.round(170 + depth * 5), Math.round(218 + depth * 10), Math.round(255 - depth * 5)];
// Calm sky-blue water; less saturated than the previous bright cyan.
color = [Math.round(174 - depth * 7), Math.round(203 + depth * 2), Math.round(224 + depth * 5)];
} else if (mode === "development") {
const dCity = distToNearest(map.modernCities, fx, fy);
const urban = clamp(1 - dCity / 25);
@ -644,6 +645,36 @@ function drawDebugCells(ctx, map, field, color) {
ctx.restore();
}
function drawTransportDebug(ctx, map) {
const layers = map.transportDebug?.layers;
if (!layers) return;
drawDebugCells(ctx, map, layers.expresswayPotential, (v) => v < 0.16 ? "rgba(0,0,0,0)" : `rgba(80, 190, 110, ${0.035 + v * 0.13})`);
drawDebugCells(ctx, map, layers.railPotential, (v) => v < 0.16 ? "rgba(0,0,0,0)" : `rgba(70, 120, 230, ${0.035 + v * 0.13})`);
drawDebugCells(ctx, map, layers.nationalRoadPotential, (v) => v < 0.16 ? "rgba(0,0,0,0)" : `rgba(245, 205, 65, ${0.030 + v * 0.12})`);
drawDebugCells(ctx, map, layers.slopeSeaPenalty, (v) => v < 0.45 ? "rgba(0,0,0,0)" : `rgba(80, 30, 30, ${0.025 + v * 0.10})`);
const componentColors = {
expressway: "rgba(60, 165, 80, 0.42)",
rail: "rgba(65, 95, 210, 0.42)",
national: "rgba(210, 155, 20, 0.42)",
};
ctx.save();
for (const comp of layers.components || []) {
ctx.fillStyle = componentColors[comp.mode] || "rgba(150,150,150,0.35)";
for (const [x, y] of comp.cells || []) ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
}
ctx.restore();
for (const repair of layers.repairedSegments || []) {
const color = repair.mode === "expressway" ? "rgba(0, 115, 40, 0.95)"
: repair.mode === "rail" ? "rgba(35, 70, 210, 0.95)"
: repair.mode === "national" ? "rgba(180, 120, 0, 0.95)"
: "rgba(210, 35, 155, 0.92)";
drawPath(ctx, repair.path, "rgba(255,255,255,0.92)", 5.0);
drawPath(ctx, repair.path, color, 2.4);
}
}
function prefectureRegionColor(id) {
const palette = [
[234, 220, 214], [218, 232, 218], [218, 224, 238], [238, 232, 208],
@ -653,10 +684,10 @@ function prefectureRegionColor(id) {
}
function drawPrefectureRegionFill(ctx, map, mode) {
if (!["all", "admin", "admin-debug", "borders-debug"].includes(mode)) return;
if (!["all", "admin", "borders-debug"].includes(mode)) return;
const ids = map.prefectureRegionId;
if (!ids) return;
const alpha = mode === "borders-debug" ? 0.34 : mode === "admin-debug" ? 0.26 : 0.18;
const alpha = mode === "borders-debug" ? 0.34 : 0.18;
ctx.save();
ctx.globalAlpha = alpha;
for (let y = 0; y < MAP_H; y++) {
@ -688,30 +719,38 @@ function boxesOverlap(a, b, pad = 3) {
function labelWithCollision(ctx, p, occupied) {
if (!p.name) return false;
const isPrefectureLabel = p.kind === "Prefecture" || p.kind === "Current Prefecture" || p.isPrefectureLabel;
ctx.save();
ctx.font = "600 11.5px ui-sans-serif, system-ui, -apple-system, sans-serif";
ctx.font = isPrefectureLabel
? "900 20px ui-sans-serif, system-ui, -apple-system, sans-serif"
: "600 11.5px ui-sans-serif, system-ui, -apple-system, sans-serif";
const baseX = p.x * CELL_SIZE + CELL_SIZE / 2;
const baseY = p.y * CELL_SIZE + CELL_SIZE / 2;
const textW = ctx.measureText(p.name).width;
const textH = 12;
const candidates = [
[7, -4], [7, 13], [-textW - 7, -4], [-textW - 7, 13],
[-textW / 2, -12], [-textW / 2, 20], [12, 4], [-textW - 12, 4],
];
const textH = isPrefectureLabel ? 22 : 12;
const candidates = isPrefectureLabel
? [
[-textW / 2, 6], [-textW / 2, -12], [-textW / 2, 24],
[10, 6], [-textW - 10, 6],
]
: [
[7, -4], [7, 13], [-textW - 7, -4], [-textW - 7, 13],
[-textW / 2, -12], [-textW / 2, 20], [12, 4], [-textW - 12, 4],
];
for (const [ox, oy] of candidates) {
const x = baseX + ox;
const y = baseY + oy;
const box = { x1: x - 2, y1: y - textH, x2: x + textW + 2, y2: y + 4 };
const box = { x1: x - 3, y1: y - textH, x2: x + textW + 3, y2: y + 5 };
if (box.x1 < 0 || box.y1 < 0 || box.x2 > MAP_W * CELL_SIZE || box.y2 > MAP_H * CELL_SIZE) continue;
if (occupied.some((b) => boxesOverlap(box, b))) continue;
if (occupied.some((b) => boxesOverlap(box, b, isPrefectureLabel ? 5 : 3))) continue;
ctx.lineJoin = "round";
ctx.lineWidth = 3.5;
ctx.strokeStyle = "rgba(255, 255, 255, 0.95)";
ctx.lineWidth = isPrefectureLabel ? 6.2 : 3.5;
ctx.strokeStyle = isPrefectureLabel ? "rgba(255, 255, 255, 0.98)" : "rgba(255, 255, 255, 0.95)";
ctx.strokeText(p.name, x, y);
ctx.fillStyle = p.isPrefecturalCapital ? "#111111" : "#333333";
ctx.fillStyle = isPrefectureLabel ? "rgba(62, 38, 112, 0.98)" : p.isPrefecturalCapital ? "#111111" : "#333333";
ctx.fillText(p.name, x, y);
occupied.push(box);
ctx.restore();
@ -787,8 +826,8 @@ export function drawMap(canvas, map, options) {
drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
// 2. Rivers
const waterBlue = "rgba(160, 205, 240, 1)";
const mediumBlue = "rgba(160, 205, 240, 0.88)";
const waterBlue = "rgba(116, 165, 202, 0.92)";
const mediumBlue = "rgba(132, 184, 220, 0.78)";
const riverStrengthForPath = (path) => {
if (!path || path.length === 0) return 0;
let peak = 0;
@ -813,7 +852,7 @@ export function drawMap(canvas, map, options) {
for (const path of map.smallStreams || []) {
const strength = riverStrengthForPath(path);
if ((path?.length || 0) < 5 || strength < 0.045) continue;
drawRiverPath(ctx, map, path, "rgba(150, 198, 235, 1)", (s) => s > 0.45 ? 0.58 : s > 0.22 ? 0.48 : 0.36, 0.34);
drawRiverPath(ctx, map, path, "rgba(140, 190, 224, 0.82)", (s) => s > 0.45 ? 0.52 : s > 0.22 ? 0.42 : 0.32, 0.28);
}
for (const path of map.tributaryRivers || []) {
const strength = riverStrengthForPath(path);
@ -837,26 +876,28 @@ export function drawMap(canvas, map, options) {
}
const showHistory = ["history", "all", "terrain"].includes(mode);
const showModern = ["modern", "all", "development", "landuse", "admin-debug", "borders-debug"].includes(mode);
const showRoads = ["all", "development"].includes(mode);
const showMinorRoads = ["all", "modern", "development"].includes(mode);
const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode);
const showTransportDebug = mode === "transport-debug";
const showModern = ["modern", "all", "development", "landuse", "borders-debug", "transport-debug"].includes(mode);
const showRoads = ["all", "development", "transport-debug"].includes(mode);
const showMinorRoads = ["all", "modern", "development", "transport-debug"].includes(mode);
const showAdmin = ["admin", "all", "borders-debug"].includes(mode);
// 3. Borders
const showPrefectureRegions = ["all", "admin", "admin-debug", "borders-debug"].includes(mode);
const showPrefectureRegions = ["all", "admin", "borders-debug"].includes(mode);
if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode);
if (showAdmin && map.adminBorders) {
drawVectorSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
}
if (mode === "admin-debug" || mode === "borders-debug") {
if (mode === "borders-debug") {
// Keep the natural barrier heatmap subtle. A dense cell fill can look like
// artificial horizontal hatching, so only strong terrain dividers are shown.
drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => v < 0.42 ? "rgba(0,0,0,0)" : `rgba(255, 120, 40, ${0.025 + v * 0.075})`);
if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(45, 95, 160, 0.72)", 1.0, true);
for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)");
}
if (showTransportDebug) drawTransportDebug(ctx, map);
if (showPrefectureRegions && map.regionalPrefectureBorders) {
drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(255, 255, 255, 0.90)", 4.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
@ -916,7 +957,7 @@ export function drawMap(canvas, map, options) {
}
// 6. Icons & Labels
if (["admin", "admin-debug", "borders-debug"].includes(mode)) {
if (["admin", "borders-debug"].includes(mode)) {
for (const p of map.adminCenters || []) dot(ctx, p, 2.8, "rgba(255,255,255,0.96)", "rgba(75,60,90,0.95)");
}
@ -937,20 +978,25 @@ export function drawMap(canvas, map, options) {
}
for (const p of map.interchanges || []) dot(ctx, p, 2.7, "rgba(250, 250, 245, 0.95)", "rgba(95, 125, 95, 0.95)");
for (const p of map.logisticsParks || []) dot(ctx, p, 2.4, "rgba(235, 238, 230, 0.95)", "rgba(105, 125, 105, 0.88)");
if (mode === "admin-debug" || mode === "borders-debug") {
if (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)");
}
if (showTransportDebug) {
for (const p of map.transportDebug?.layers?.unservedSettlements || []) {
dot(ctx, p, p.repaired ? 3.4 : 4.8, p.repaired ? "rgba(255,255,255,0.92)" : "rgba(255,80,140,0.95)", "rgba(125,35,105,0.95)");
}
}
}
if (showLabels) {
const prefectureLabels = showPrefectureRegions ? (map.prefectureRegions || []).map((p) => ({ ...p, labelPriorityBase: p.labelPriorityBase || 900 })) : [];
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, labelPriorityBase: p.labelPriorityBase || 1700 }));
if (mode === "admin") {
drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity);
drawScaleBar(ctx);
return;
}
if (mode === "admin-debug" || mode === "borders-debug") {
if (mode === "borders-debug") {
drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity);
drawScaleBar(ctx);
return;
@ -970,7 +1016,7 @@ export function drawMap(canvas, map, options) {
...(map.logisticsParks || []).map((p) => ({ ...p, labelPriorityBase: 60 })),
...(map.satelliteCities || []),
...allLayerTowns,
].filter((p) => p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000);
].filter((p) => p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000);
drawLabels(ctx, important, mode === "all" ? 85 : 60);
}
drawScaleBar(ctx);