diff --git a/adminRegionsCore.js b/adminRegionsCore.js
index 706cdb5..5e964a7 100644
--- a/adminRegionsCore.js
+++ b/adminRegionsCore.js
@@ -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;
diff --git a/app.js b/app.js
index 772e569..0190ccf 100644
--- a/app.js
+++ b/app.js
@@ -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 ?? "-"],
];
}
diff --git a/index.html b/index.html
index 7a0c4e4..1179b32 100644
--- a/index.html
+++ b/index.html
@@ -3,7 +3,7 @@
- Prefecture Map Generator v17
+ Prefecture Map Generator
diff --git a/mapAdminStage.js b/mapAdminStage.js
index 284f792..1ae38bf 100644
--- a/mapAdminStage.js
+++ b/mapAdminStage.js
@@ -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,
diff --git a/mapFeatures.js b/mapFeatures.js
index 0855ec6..3dd04ed 100644
--- a/mapFeatures.js
+++ b/mapFeatures.js
@@ -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,
};
diff --git a/mapOutput.js b/mapOutput.js
index 63904f6..c5da6b8 100644
--- a/mapOutput.js
+++ b/mapOutput.js
@@ -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");
diff --git a/mapTerrain.js b/mapTerrain.js
index 28e0a66..77b1fff 100644
--- a/mapTerrain.js
+++ b/mapTerrain.js
@@ -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);
diff --git a/names.js b/names.js
index c7142af..bc59096 100644
--- a/names.js
+++ b/names.js
@@ -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") {
diff --git a/renderer.js b/renderer.js
index 053096f..d7fd419 100644
--- a/renderer.js
+++ b/renderer.js
@@ -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);