This commit is contained in:
33333-33333 2026-05-21 13:20:19 +09:00
commit b707dadec9
12 changed files with 4324 additions and 3065 deletions

View file

@ -156,12 +156,31 @@ export function lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask,
}
}
export function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320) {
export function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320, options = {}) {
const area = new Map();
const pop = new Map();
const adjacency = new Map();
const cityMunicipalities = new Set();
for (const city of modernCities || []) if (inside(city.x, city.y)) cityMunicipalities.add(adminId[indexOf(city.x, city.y)]);
for (const city of modernCities || []) {
if (!inside(city.x, city.y)) continue;
const id = adminId[indexOf(city.x, city.y)];
if (id < 0) continue;
if (options.protectAllModernCities !== false || city.isPrefecturalCapital || (city.population || 0) >= (options.majorCityPopulationThreshold || 120000)) cityMunicipalities.add(id);
}
for (const point of options.protectedPoints || []) {
if (!point || !inside(point.x, point.y)) continue;
const id = adminId[indexOf(point.x, point.y)];
if (id >= 0) cityMunicipalities.add(id);
}
const satelliteByAdmin = new Map();
for (const sat of options.satelliteCities || []) {
if (!sat || !inside(sat.x, sat.y)) continue;
const id = adminId[indexOf(sat.x, sat.y)];
if (id < 0) continue;
if (!satelliteByAdmin.has(id)) satelliteByAdmin.set(id, []);
satelliteByAdmin.get(id).push(sat);
}
const satelliteStats = options.satelliteStats || null;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
@ -187,16 +206,35 @@ export function mergeTinyMunicipalities(adminId, prefectureMask, sea, population
for (const [id, cells] of area) {
const score = cells + (pop.get(id) || 0) * 16;
if (cells >= minArea || cityMunicipalities.has(id)) continue;
const satellites = satelliteByAdmin.get(id) || [];
const protectedSatellite = satellites.some((sat) => {
const minSatelliteArea = sat.satelliteMinArea || options.satelliteMinArea || 110;
return sat.municipalityClass === "independentSatelliteMunicipality" && (
cells >= minSatelliteArea ||
(sat.population || 0) >= (options.satelliteIndependentPopulationThreshold || 60000) ||
(sat.distinctUrbanComponentArea || 0) >= 80 ||
sat.separatedByBarrier
);
});
if (protectedSatellite) continue;
let bestNeighbor = -1;
let bestScore = -1;
for (const [key, border] of adjacency) {
const [a, b] = key.split(":").map(Number);
if (a !== id && b !== id) continue;
const other = a === id ? b : a;
const candidate = border * 3 + (area.get(other) || 0) * 0.012 + (pop.get(other) || 0) * 0.24;
const parentBias = satellites.some((sat) => inside(sat.parentX ?? -1, sat.parentY ?? -1) && adminId[indexOf(sat.parentX, sat.parentY)] === other) ? 26 : 0;
const ruralBias = satellites.some((sat) => sat.municipalityClass === "smallTownAttachedToRuralMunicipality") ? Math.min(12, (area.get(other) || 0) * 0.01) : 0;
const candidate = border * 3 + (area.get(other) || 0) * 0.012 + (pop.get(other) || 0) * 0.24 + parentBias + ruralBias;
if (candidate > bestScore) { bestScore = candidate; bestNeighbor = other; }
}
if (bestNeighbor >= 0 && (area.get(bestNeighbor) || 0) >= score * 0.35) mergeTarget.set(id, bestNeighbor);
if (bestNeighbor >= 0 && (area.get(bestNeighbor) || 0) >= score * 0.35) {
mergeTarget.set(id, bestNeighbor);
if (satelliteStats && satellites.length) {
satelliteStats.satelliteMunicipalitiesMerged += satellites.length;
for (const sat of satellites) sat.mergedMunicipalityTarget = bestNeighbor;
}
}
}
if (mergeTarget.size === 0) return;
for (let i = 0; i < SIZE; i++) if (mergeTarget.has(adminId[i])) adminId[i] = mergeTarget.get(adminId[i]);
@ -465,6 +503,119 @@ function classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyFie
return 11;
}
export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null) {
const score = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i]) continue;
const urbanContinuity = populationDensity && landuse ? urbanBoundaryPenalty(i, populationDensity, landuse) : 0;
const majorRiver = clamp(Math.max(river[i] - 0.34, 0) * 1.95 + Math.max(flowAccum[i] - 0.42, 0) * 0.82);
const ridgeDivide = clamp(ridgeField[i] * 1.65 + Math.max(0, elevation[i] - 0.52) * ridgeField[i] * 1.05);
const crest = crestCrossingScore ? crestCrossingScore[i] : clamp(Math.max(0, elevation[i] - 0.55) * ridgeField[i] * 1.4 + slope[i] * ridgeField[i] * 0.8);
const basinRim = basinField ? clamp(Math.max(0, basinField[i] - 0.32) * Math.max(0, slope[i] - 0.18) * 1.15 + Math.max(0, ridgeField[i] - 0.34) * basinField[i] * 0.62) : 0;
const foothillBreak = clamp(Math.max(0, slope[i] - 0.30) * Math.max(ridgeField[i], Math.max(0, elevation[i] - 0.48)) * 0.82);
const livingCorridor = clamp((plain?.[i] || 0) * 0.34 + (agriculture?.[i] || 0) * 0.26 + valleyField[i] * (majorRiver > 0.34 ? 0.10 : 0.46) + coastalLowland[i] * 0.18);
score[i] = clamp(
ridgeDivide * 0.92 +
crest * 0.72 +
majorRiver * 0.86 +
basinRim * 0.54 +
foothillBreak * 0.48 +
terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity || score, landuse || score) * 0.38 -
livingCorridor * 0.50 -
urbanContinuity * 0.72
);
}
return score;
}
function canShareNaturalCompartment(a, b, classA, classB, barrier, river, flowAccum, valleyField, populationDensity, landuse) {
if (classA !== classB) {
const bothUrban = classA <= 3 && classB <= 3;
const bothLivingCorridor = [5, 6, 7, 10].includes(classA) && [5, 6, 7, 10].includes(classB);
if (!bothUrban && !bothLivingCorridor) return false;
}
const majorRiverEdge = Math.max(river[a], river[b]) > 0.58 || Math.max(flowAccum[a], flowAccum[b]) > 0.72;
const urbanEdge = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.34) &&
((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.34);
const valleyContinuity = (valleyField[a] + valleyField[b]) * 0.5 > 0.42 && !majorRiverEdge;
const threshold = urbanEdge ? 0.84 : valleyContinuity ? 0.76 : classA === 8 || classB === 8 ? 0.42 : 0.62;
return barrier < threshold && (!majorRiverEdge || urbanEdge);
}
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 === 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)}`;
}
export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null) {
const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse);
const compartmentId = new Int32Array(SIZE);
compartmentId.fill(-1);
const cellClass = new Int16Array(SIZE);
cellClass.fill(-1);
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse);
const compartments = [];
const queue = [];
for (let i = 0; i < SIZE; i++) {
if (cellClass[i] < 0 || compartmentId[i] >= 0) continue;
const id = compartments.length;
const startClass = cellClass[i];
const cells = [];
let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = 0, coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0;
queue.length = 0;
queue.push(i);
compartmentId[i] = id;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const [x, y] = xyOf(cur);
cells.push(cur);
sx += x; sy += y; pop += populationDensity[cur];
urbanWeight += urbanBoundaryPenalty(cur, populationDensity, landuse);
ridgeExposure += ridgeField[cur];
riverExposure += river[cur] + flowAccum[cur] * 0.45;
coastalExposure += coastalLowland[cur];
basinIdentity += basinField[cur];
valleyIdentity += valleyField[cur];
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (compartmentId[ni] >= 0 || cellClass[ni] < 0) continue;
const edgeBarrier = (naturalBarrierScore[cur] + naturalBarrierScore[ni]) * 0.5;
if (!canShareNaturalCompartment(cur, ni, startClass, cellClass[ni], edgeBarrier, river, flowAccum, valleyField, populationDensity, landuse)) continue;
compartmentId[ni] = id;
queue.push(ni);
}
}
const area = cells.length;
compartments.push({
id,
cells,
area,
x: sx / area,
y: sy / area,
classId: startClass,
dominantLandscapeClass: startClass,
population: pop,
urbanWeight: urbanWeight / area,
ridgeExposure: ridgeExposure / area,
riverExposure: riverExposure / area,
coastalExposure: coastalExposure / area,
basinIdentity: basinIdentity / area,
valleyIdentity: valleyIdentity / area,
centerIds: [],
adjacent: new Map(),
});
}
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
mergeTinyLandscapeUnits(compartmentId, compartments, 12);
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
return { compartmentId, compartments, naturalBarrierScore };
}
function buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse) {
const unitId = new Int32Array(SIZE);
unitId.fill(-1);
@ -559,52 +710,124 @@ function mergeTinyLandscapeUnits(unitId, units, minArea = 10) {
}
}
function landscapeTransitionCost(a, b, edge) {
function naturalOwnershipAffinity(unit, neighbor, edge) {
const boundaryTarget = edge.target / Math.max(1, edge.count);
const bothUrban = a.classId <= 3 && b.classId <= 3;
const bothCorridor = (a.classId === 5 || a.classId === 7 || a.classId === 10) && (b.classId === 5 || b.classId === 7 || b.classId === 10);
const urbanContinuity = bothUrban ? 2.1 : (a.urbanWeight + b.urbanWeight) > 0.75 && Math.abs(a.urbanWeight - b.urbanWeight) < 0.35 ? 0.9 : 0;
return Math.max(0.18, 0.70 + boundaryTarget * 4.2 + (a.classId === b.classId ? 0 : 0.75) + ((a.classId === 8 || b.classId === 8) ? 1.2 : 0) - urbanContinuity - (bothCorridor ? 0.55 : 0));
const sameClass = unit.classId === neighbor.classId ? 1.0 : 0;
const sameGroup = naturalGroupKey(unit) === naturalGroupKey(neighbor) ? 1.1 : 0;
const bothUrban = unit.classId <= 3 && neighbor.classId <= 3;
const bothCorridor = [5, 6, 7, 10].includes(unit.classId) && [5, 6, 7, 10].includes(neighbor.classId);
const urbanContinuity = bothUrban ? 1.35 : (unit.urbanWeight + neighbor.urbanWeight) > 0.75 && Math.abs(unit.urbanWeight - neighbor.urbanWeight) < 0.35 ? 0.58 : 0;
const strongDividerPenalty = boundaryTarget * (edge.count > 2 ? 2.8 : 1.8);
return edge.count * 0.55 + sameClass + sameGroup + urbanContinuity + (bothCorridor ? 0.72 : 0) - strongDividerPenalty;
}
export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) {
const { unitId, units, targetScore } = buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse);
if (units.length === 0) return;
for (let id = 0; id < adminCenters.length; id++) {
const center = adminCenters[id];
if (!center || !inside(center.x, center.y)) continue;
const unit = units[unitId[indexOf(center.x, center.y)]];
if (unit) unit.centerIds.push(id);
}
const owner = new Int16Array(units.length);
const dist = new Float32Array(units.length);
owner.fill(-1); dist.fill(INF);
const heap = new MinHeap();
for (const unit of units) {
if (unit.area === 0 || unit.centerIds.length === 0) continue;
const id = unit.centerIds[0];
owner[unit.id] = id; dist[unit.id] = 0; heap.push({ i: unit.id, f: 0 });
}
while (heap.length) {
const cur = heap.pop();
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
const unit = units[cur.i];
const currentOwner = owner[cur.i];
if (!unit || currentOwner < 0) continue;
for (const [nextId, edge] of unit.adjacent) {
const next = units[nextId];
if (!next || next.area === 0) continue;
const nextDist = dist[cur.i] + landscapeTransitionCost(unit, next, edge) + Math.sqrt(next.area) * 0.012 + (next.urbanWeight > 0.75 && next.centerIds.length === 0 ? -0.20 : 0);
if (nextDist < dist[nextId]) {
dist[nextId] = nextDist; owner[nextId] = currentOwner; heap.push({ i: nextId, f: nextDist });
function averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore) {
let sum = 0;
let count = 0;
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || adminId[ni] === adminId[i]) continue;
sum += (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5;
count++;
}
}
}
for (const unit of units) {
return count ? sum / count : 0;
}
function weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore) {
let weak = 0;
let total = 0;
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
const ni = indexOf(nx, ny);
const a = adminId[i], b = adminId[ni];
if (!prefectureMask[ni] || sea[ni] || a < 0 || b < 0 || a === b) continue;
total++;
const ca = adminCenters[a], cb = adminCenters[b];
if (!ca || !cb) continue;
const mx = (x + nx) * 0.5, my = (y + ny) * 0.5;
const nearBisector = Math.abs(Math.hypot(mx - ca.x, my - ca.y) - Math.hypot(mx - cb.x, my - cb.y)) < 4.0;
if (nearBisector && (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5 < 0.38) weak++;
}
}
}
return total ? weak / total : 0;
}
export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) {
const before = new Int16Array(adminId);
const initialNaturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse);
const beforeVoronoiLikeRate = weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, initialNaturalBarrierScore);
const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse);
if (compartments.length === 0) return;
for (let id = 0; id < adminCenters.length; id++) {
const center = adminCenters[id];
if (!center || !inside(center.x, center.y)) continue;
const unit = compartments[compartmentId[indexOf(center.x, center.y)]];
if (unit) unit.centerIds.push(id);
}
const owner = new Int16Array(compartments.length);
owner.fill(-1);
for (const unit of compartments) {
if (unit.area === 0 || unit.centerIds.length === 0) continue;
owner[unit.id] = unit.centerIds[0];
}
for (let pass = 0; pass < compartments.length + 4; pass++) {
let changed = 0;
for (const unit of compartments) {
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
let bestOwner = -1;
let bestScore = -INF;
for (const [neighborId, edge] of unit.adjacent) {
const neighborOwner = owner[neighborId];
if (neighborOwner < 0) continue;
const neighbor = compartments[neighborId];
if (!neighbor || neighbor.area === 0) continue;
const score = naturalOwnershipAffinity(unit, neighbor, edge) + Math.min(0.8, Math.sqrt(Math.max(1, neighbor.area)) * 0.018);
if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; }
}
const accept = unit.classId <= 3 ? bestScore > -0.15 : unit.classId === 8 || unit.classId === 9 ? bestScore > -0.80 : bestScore > -0.45;
if (bestOwner >= 0 && accept) { owner[unit.id] = bestOwner; changed++; }
}
if (changed === 0) break;
}
for (const unit of compartments) {
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
let bestId = -1, bestScore = -INF;
for (let id = 0; id < adminCenters.length; id++) {
const center = adminCenters[id];
if (!center || !inside(center.x, center.y)) continue;
const centerComp = compartments[compartmentId[indexOf(center.x, center.y)]];
const sameGroup = centerComp && naturalGroupKey(centerComp) === naturalGroupKey(unit) ? 2.4 : 0;
const sameClass = centerComp && centerComp.classId === unit.classId ? 0.9 : 0;
const urbanFit = unit.urbanWeight > 0.55 && centerComp?.urbanWeight > 0.55 ? 1.2 : 0;
const d = Math.hypot(unit.x - center.x, unit.y - center.y);
const score = sameGroup + sameClass + urbanFit - d * 0.018 - unit.ridgeExposure * 0.18;
if (score > bestScore) { bestScore = score; bestId = id; }
}
owner[unit.id] = bestId >= 0 ? bestId : 0;
}
for (const unit of compartments) {
const assigned = owner[unit.id];
if (assigned >= 0) for (const i of unit.cells) adminId[i] = assigned;
}
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] >= 0) continue;
const comp = compartments[compartmentId[i]];
adminId[i] = comp && owner[comp.id] >= 0 ? owner[comp.id] : 0;
}
for (let id = 0; id < adminCenters.length; id++) {
const center = adminCenters[id];
if (!center || !inside(center.x, center.y)) continue;
@ -616,7 +839,18 @@ export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, e
if (prefectureMask[i] && !sea[i]) adminId[i] = id;
}
}
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, targetScore, populationDensity, landuse);
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse);
let changedCells = 0;
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++;
const activeCompartments = compartments.filter((unit) => unit.area > 0);
applyLandscapeUnitAdminPartition.lastDebug = {
compartmentCount: activeCompartments.length,
averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0,
changedAfterNaturalCompartmentPartition: changedCells,
finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore),
voronoiLikeRateBefore: beforeVoronoiLikeRate,
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),
};
}
export function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCenters = [], protectedPoints = [], passes = 6) {
@ -659,3 +893,83 @@ export function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, eleva
adminId.set(current);
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, targetScore, populationDensity, landuse);
}
export function splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], settlements = []) {
const before = new Int16Array(adminId);
const area = new Map();
const lowland = new Map();
const mountain = new Map();
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
const id = adminId[i];
area.set(id, (area.get(id) || 0) + 1);
const living = (plain[i] || 0) * 0.42 + (agriculture[i] || 0) * 0.28 + basinField[i] * 0.20 + coastalLowland[i] * 0.20 + valleyField[i] * 0.12;
const rough = ridgeField[i] * 0.54 + slope[i] * 0.36 + Math.max(0, elevation[i] - 0.58) * 0.38;
lowland.set(id, (lowland.get(id) || 0) + living);
mountain.set(id, (mountain.get(id) || 0) + rough);
}
const areas = [...area.values()].sort((a, b) => a - b);
const median = areas.length ? areas[Math.floor(areas.length / 2)] : 0;
if (!median) return { changedCells: 0, splitMunicipalities: 0 };
const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse);
const unitOwner = new Int16Array(compartments.length);
unitOwner.fill(-1);
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const counts = new Map();
for (const i of unit.cells) {
const id = adminId[i];
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
}
let bestId = -1, best = -1;
for (const [id, count] of counts) if (count > best) { best = count; bestId = id; }
unitOwner[unit.id] = bestId;
}
const adminCenterIndex = new Map();
for (let id = 0; id < adminCenters.length; id++) {
const c = adminCenters[id];
if (c && inside(c.x, c.y)) adminCenterIndex.set(id, indexOf(c.x, c.y));
}
let splitMunicipalities = 0;
for (const [id, cells] of area) {
const averageLowland = (lowland.get(id) || 0) / cells;
const averageMountain = (mountain.get(id) || 0) / cells;
if (cells < median * 2.25 || averageLowland < 0.28 || averageMountain > 0.44) continue;
const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id);
const meaningfulNodes = localSettlements.filter((p) => p.kind === "Satellite City" || p.kind === "New Town" || p.kind === "Market Town" || (p.population || 0) >= 30000);
if (meaningfulNodes.length < 2) continue;
let changedHere = 0;
for (const unit of compartments) {
if (!unit || unit.area === 0 || unitOwner[unit.id] !== id) continue;
const centerIndex = adminCenterIndex.get(id);
if (centerIndex >= 0 && unit.cells.includes(centerIndex)) continue;
if (unit.classId === 8 || unit.classId === 9) continue;
let bestNeighbor = -1;
let bestScore = -INF;
for (const [neighborId, edge] of unit.adjacent) {
const neighborOwner = unitOwner[neighborId];
if (neighborOwner < 0 || neighborOwner === id) continue;
const boundaryTarget = edge.target / Math.max(1, edge.count);
const neighbor = compartments[neighborId];
const nodePull = meaningfulNodes.reduce((best, p) => Math.max(best, 1 / (1 + Math.hypot(p.x - unit.x, p.y - unit.y) / 6)), 0);
const score = edge.count * 0.7 + boundaryTarget * 1.4 + nodePull * 1.2 - Math.max(0, (neighbor?.ridgeExposure || 0) - unit.ridgeExposure) * 0.35;
if (score > bestScore) { bestScore = score; bestNeighbor = neighborOwner; }
}
if (bestNeighbor < 0 || bestScore < 2.2) continue;
for (const ci of unit.cells) {
if (adminId[ci] === id) {
adminId[ci] = bestNeighbor;
changedHere++;
}
}
}
if (changedHere > Math.max(28, cells * 0.035)) splitMunicipalities++;
}
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse);
let changedCells = 0;
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++;
return { changedCells, splitMunicipalities };
}

4
app.js
View file

@ -11,6 +11,8 @@ const modes = [
["development", "Development"],
["landuse", "Land Use"],
["admin", "Municipal Borders"],
["admin-debug", "Admin Debug"],
["borders-debug", "Borders Debug"],
];
const state = {
@ -76,6 +78,8 @@ 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` : "-"],
["Regional changed cells", map.regionalDebug?.regionalChangedAfterNaturalPartition ?? "-"],
];
}

410
mapAdminStage.js Normal file
View file

@ -0,0 +1,410 @@
import {
applyLandscapeUnitAdminPartition,
generateAdminRegions,
lockSmallUrbanComponentsToMunicipality,
mergeTinyMunicipalities,
removeMunicipalExclaves,
smoothAdminRegionsTerrainAware,
splitOversizedRuralMunicipalities,
snapAdminBoundariesToTerrain,
} from "./adminRegions.js";
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, xyOf } from "./mapUtils.js";
import { extractAdminBorderSegments } from "./mapGeneratorHelpers.js";
function changedCellsSince(before, after, prefectureMask, sea) {
let changed = 0;
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== after[i]) changed++;
return changed;
}
function municipalityAreaById(adminId, prefectureMask, sea) {
const area = new Map();
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
area.set(adminId[i], (area.get(adminId[i]) || 0) + 1);
}
return area;
}
function estimateUrbanComponentArea(city, prefectureMask, sea, landuse, populationDensity) {
if (!city || !inside(city.x, city.y)) return 0;
const start = indexOf(city.x, city.y);
if (!prefectureMask[start] || sea[start]) return 0;
const radius = Math.ceil(Math.max(7, (city.urbanRadius || 6) * 1.7));
const seen = new Uint8Array(SIZE);
const queue = [start];
seen[start] = 1;
let area = 0;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const [x, y] = xyOf(cur);
const d = Math.hypot(x - city.x, y - city.y);
if (d > radius) continue;
const urban = (landuse[cur] >= 2 && landuse[cur] <= 4) || landuse[cur] === 7 || landuse[cur] === 8 || populationDensity[cur] > 0.18;
if (!urban) continue;
area++;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
return area;
}
function terrainSeparationBetween(a, b, ridgeField, river, flowAccum, populationDensity, landuse) {
if (!a || !b) return { separatedByBarrier: false, ruralGap: false, averageDensity: 0, maxBarrier: 0 };
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
let maxBarrier = 0;
let lowUrbanRun = 0;
let bestLowUrbanRun = 0;
let densitySum = 0;
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(a.x + (b.x - a.x) * t);
const y = Math.round(a.y + (b.y - a.y) * t);
if (!inside(x, y)) continue;
const i = indexOf(x, y);
const barrier = Math.max(ridgeField[i] * 0.95, river[i] * 0.85, flowAccum[i] * 0.42);
maxBarrier = Math.max(maxBarrier, barrier);
densitySum += populationDensity[i];
const urban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.20;
if (urban) lowUrbanRun = 0;
else {
lowUrbanRun++;
bestLowUrbanRun = Math.max(bestLowUrbanRun, lowUrbanRun);
}
}
return {
separatedByBarrier: maxBarrier > 0.56,
ruralGap: bestLowUrbanRun >= 4,
averageDensity: densitySum / (steps + 1),
maxBarrier,
};
}
function classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, ridgeField, river, flowAccum) {
let independent = 0;
let attached = 0;
for (const sat of satelliteCities || []) {
if (!sat || !inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)] || sea[indexOf(sat.x, sat.y)]) continue;
const parent = modernCities[sat.parentCityIndex] || modernCities.slice().sort((a, b) => Math.hypot(a.x - sat.x, a.y - sat.y) - Math.hypot(b.x - sat.x, b.y - sat.y))[0];
const parentDistance = parent ? Math.hypot(parent.x - sat.x, parent.y - sat.y) : 99;
const separation = terrainSeparationBetween(sat, parent, ridgeField, river, flowAccum, populationDensity, landuse);
const urbanArea = estimateUrbanComponentArea(sat, prefectureMask, sea, landuse, populationDensity);
const i = indexOf(sat.x, sat.y);
const continuousUrban = parent && parentDistance < Math.max(10, (parent.urbanRadius || 12) + (sat.urbanRadius || 5) + 5) && separation.averageDensity > 0.14 && !separation.ruralGap && !separation.separatedByBarrier;
const newTownLike = landuse[i] === 7 || (railInfluence2[i] > 0.22 && roadInfluence[i] > 0.12 && (sat.population || 0) < 70000);
let municipalityClass = "independentSatelliteMunicipality";
if (continuousUrban && (sat.population || 0) < 90000) municipalityClass = "suburbanDistrictMergedWithParent";
else if (newTownLike && (sat.population || 0) < 85000 && !separation.separatedByBarrier) municipalityClass = "newTownDistrict";
else if ((sat.population || 0) < 42000 && urbanArea < 55 && !separation.separatedByBarrier) municipalityClass = "smallTownAttachedToRuralMunicipality";
else if ((sat.population || 0) >= 60000 && urbanArea >= 42 && (separation.separatedByBarrier || separation.ruralGap || parentDistance > 15)) municipalityClass = "independentSatelliteMunicipality";
sat.municipalityClass = municipalityClass;
sat.parentX = parent?.x;
sat.parentY = parent?.y;
sat.parentAdminHint = -1;
sat.distinctUrbanComponentArea = urbanArea;
sat.separatedByBarrier = separation.separatedByBarrier || separation.ruralGap;
sat.satelliteMinArea = clamp(90 + Math.sqrt(sat.population || 24000) * 0.62 + (sat.urbanRadius || 5) * 12, 80, 360);
if (municipalityClass === "independentSatelliteMunicipality") independent++;
else attached++;
}
return { independent, attached };
}
function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, context) {
const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities } = context;
if (!satellite || targetAdmin < 0 || !inside(satellite.x, satellite.y)) return 0;
const start = indexOf(satellite.x, satellite.y);
if (!prefectureMask[start] || sea[start]) return 0;
const targetAreaBase = clamp(90 + Math.sqrt(satellite.population || 24000) * 0.8 + (satellite.urbanRadius || 5) * 18, 120, 520);
const targetArea = satellite.municipalityClass === "smallTownAttachedToRuralMunicipality"
? Math.min(130, targetAreaBase * 0.55)
: satellite.municipalityClass === "suburbanDistrictMergedWithParent" || satellite.municipalityClass === "newTownDistrict"
? Math.min(190, targetAreaBase * 0.62)
: targetAreaBase;
const maxCost = satellite.municipalityClass === "independentSatelliteMunicipality" ? 46 : 32;
const heap = new MinHeap();
const best = new Float32Array(SIZE);
best.fill(INF);
heap.push({ i: start, f: 0 });
best[start] = 0;
const claimed = [];
while (heap.length > 0 && claimed.length < targetArea) {
const cur = heap.pop();
if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue;
const [x, y] = xyOf(cur.i);
const d = Math.hypot(x - satellite.x, y - satellite.y);
if (!prefectureMask[cur.i] || sea[cur.i]) continue;
let invadesOtherCore = false;
for (const city of modernCities || []) {
if (!city || (city.population || 0) < 140000) continue;
if (Math.hypot(city.x - satellite.x, city.y - satellite.y) < 4) continue;
if (Math.hypot(city.x - x, city.y - y) <= Math.max(3.5, (city.coreRadius || 4) * 1.25)) {
invadesOtherCore = true;
break;
}
}
if (invadesOtherCore) continue;
const compatible = d <= (satellite.urbanRadius || 5) * 1.25 ||
[2, 3, 4, 7, 8].includes(landuse[cur.i]) ||
populationDensity[cur.i] > 0.12 ||
roadInfluence[cur.i] > 0.12 ||
railInfluence2[cur.i] > 0.10 ||
stationInfluence?.[cur.i] > 0.10 ||
basinField[cur.i] > 0.22 ||
valleyField[cur.i] > 0.24 ||
coastalLowland[cur.i] > 0.20;
if (!compatible && claimed.length > targetArea * 0.55) continue;
claimed.push(cur.i);
for (const [dx, dy, step] of [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [-1, 1, 1.41], [1, -1, 1.41], [-1, -1, 1.41]]) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) continue;
const barrier = ridgeField[ni] * 5.2 + Math.max(0, elevation[ni] - 0.58) * 4.0 + slope[ni] * 2.2 + (river[ni] > 0.55 || flowAccum[ni] > 0.70 ? 7.5 : river[ni] > 0.30 ? 2.8 : 0);
const living = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.2 : 0) + populationDensity[ni] * 2.0 + roadInfluence[ni] * 0.85 + railInfluence2[ni] * 0.95 + (stationInfluence?.[ni] || 0) * 1.2 + basinField[ni] * 0.42 + valleyField[ni] * 0.48 + coastalLowland[ni] * 0.32;
const distanceCost = Math.hypot(nx - satellite.x, ny - satellite.y) / Math.max(7, (satellite.urbanRadius || 5) * 1.9);
const nd = cur.f + Math.max(0.28, 1.05 + barrier - living + distanceCost) * step;
if (nd < best[ni]) {
best[ni] = nd;
heap.push({ i: ni, f: nd });
}
}
}
let changed = 0;
for (const i of claimed) {
if (adminId[i] !== targetAdmin) changed++;
adminId[i] = targetAdmin;
}
return changed;
}
export function generateAdminLayout({
seed,
prefectureMask,
sea,
elevation,
slope,
river,
ridgeField,
valleyField,
basinField,
coastalLowland,
flowAccum,
plain,
agriculture,
settlementScore,
populationDensity,
stationInfluence,
roadInfluence,
railInfluence2,
villageInfluence,
landuse,
modernCities,
satelliteCities,
newTowns,
markets,
villages,
ports,
stations,
industrialZones,
logisticsParks,
}) {
const prefectureArea = prefectureMask.reduce((sum, v) => sum + (v ? 1 : 0), 0);
const municipalityCandidates = [];
for (let y = 2; y < MAP_H - 2; y++) {
for (let x = 2; x < MAP_W - 2; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i]) continue;
const urbanBias = landuse[i] === 3 ? 0.62 : landuse[i] === 2 ? 0.56 : landuse[i] === 4 ? 0.5 : landuse[i] === 1 ? 0.4 : 0.28;
const score = urbanBias + settlementScore[i] * 0.22 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.05 + villageInfluence[i] * 0.04 - slope[i] * 0.18 - ridgeField[i] * 0.06 + hash2(x, y, seed + 1300) * 0.025;
if (score > 0.40) municipalityCandidates.push({ x, y, score });
}
}
const majorMunicipalSeeds = modernCities
.filter((city) => (city.population || 0) >= 220000 && prefectureMask[indexOf(city.x, city.y)])
.map((city) => ({ x: city.x, y: city.y, score: 1.55 + (city.population || 0) / 700000, protectedCity: city }));
const filteredMunicipalityCandidates = municipalityCandidates.filter((p) => {
const nearMajor = majorMunicipalSeeds.some((city) => Math.hypot(city.x - p.x, city.y - p.y) < clamp(12 + Math.sqrt(city.protectedCity.population || 300000) / 130, 14, 28));
const nearSmallUrban = modernCities.some((city) => (city.population || 0) < 260000 && Math.hypot(city.x - p.x, city.y - p.y) < 8 && p.x !== city.x && p.y !== city.y);
return !nearMajor && !nearSmallUrban;
});
const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, ridgeField, river, flowAccum);
const satelliteMunicipalSeeds = (satelliteCities || [])
.filter((city) => prefectureMask[indexOf(city.x, city.y)] && city.municipalityClass === "independentSatelliteMunicipality")
.map((city) => ({ x: city.x, y: city.y, score: 1.05 + (city.population || 40000) / 260000, protectedSatellite: city }));
let adminCentersRaw = [
...majorMunicipalSeeds,
...satelliteMunicipalSeeds,
...pickEntities(filteredMunicipalityCandidates.filter((p) => satelliteMunicipalSeeds.every((s) => Math.hypot(s.x - p.x, s.y - p.y) >= 6)), {
max: Math.min(20, Math.max(10, Math.floor(prefectureArea / 950) + 6 + Math.floor(rand(seed, 1301) * 3))),
minDistance: 9 + Math.floor(rand(seed, 1302) * 3),
threshold: 0.40,
seed: seed + 1300,
jitter: 0.025,
}),
];
if (adminCentersRaw.length < 12) {
const fallback = [...modernCities, ...(satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality"), ...markets, ...newTowns, ...stations, ...villages]
.filter((p) => prefectureMask[indexOf(p.x, p.y)])
.map((p) => ({ x: p.x, y: p.y, score: p.score || 0.5 }));
adminCentersRaw = pickEntities(fallback, { max: 12, minDistance: 8, threshold: 0, seed: seed + 1303 });
}
if (adminCentersRaw.length < 10) {
const extra = pickEntities(municipalityCandidates, { max: 10 - adminCentersRaw.length, minDistance: 8, threshold: 0.32, seed: seed + 1304 });
adminCentersRaw.push(...extra.filter((p) => adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 6)));
}
const adminId = generateAdminRegions(adminCentersRaw, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse);
let previousSnapshot = new Int16Array(adminId);
const adminDebug = {
changedAfterSmooth: 0,
changedAfterUrbanLock: 0,
changedAfterSmallUrbanLock: 0,
changedAfterInitialMerge: 0,
changedAfterInitialExclaveRemoval: 0,
changedAfterLandscapePartition: 0,
changedAfterSnap: 0,
changedAfterOversizedRuralSplit: 0,
changedAfterFinalExclaveRemoval: 0,
changedAfterFinalMerge: 0,
satelliteMunicipalitiesCreated: satelliteMunicipalSeeds.length,
satelliteMunicipalitiesMerged: 0,
satelliteMunicipalitiesExpanded: 0,
satelliteMunicipalitiesTooSmall: 0,
averageSatelliteMunicipalityArea: 0,
minSatelliteMunicipalityArea: 0,
satelliteMunicipalityAreaByNameOrIndex: {},
independentSatelliteMunicipalities: satelliteClassificationDebug.independent,
attachedSatelliteDistricts: satelliteClassificationDebug.attached,
};
function markChanged(field) {
adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea);
previousSnapshot = new Int16Array(adminId);
}
smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, 7);
markChanged("changedAfterSmooth");
function lockUrbanClusterToMunicipality(city, radius, allowSuburban = true) {
if (!city || !prefectureMask[indexOf(city.x, city.y)]) return;
let bestAdmin = -1;
let bestD = INF;
adminCentersRaw.forEach((center, id) => {
const d = Math.hypot(center.x - city.x, center.y - city.y);
if (d < bestD) { bestD = d; bestAdmin = id; }
});
if (bestAdmin < 0) return;
const r = Math.ceil(radius);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const x = city.x + dx;
const y = city.y + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i]) continue;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
const urban = landuse[i] === 2 || landuse[i] === 3 || (allowSuburban && (landuse[i] === 4 || landuse[i] === 7 || landuse[i] === 8));
if (urban || populationDensity[i] > 0.22) adminId[i] = bestAdmin;
}
}
}
for (const city of modernCities) {
const radius = (city.population || 0) >= 500000
? clamp(17 + Math.sqrt(city.population) / 120, 20, 38)
: clamp(5 + Math.sqrt(city.population || 70000) / 210, 6, 11);
lockUrbanClusterToMunicipality(city, radius, true);
}
for (const sat of satelliteCities || []) {
if (!prefectureMask[indexOf(sat.x, sat.y)]) continue;
let bestAdmin = -1;
if (sat.municipalityClass === "independentSatelliteMunicipality") {
let bestD = INF;
adminCentersRaw.forEach((center, id) => {
const d = Math.hypot(center.x - sat.x, center.y - sat.y);
if (d < bestD) { bestD = d; bestAdmin = id; }
});
} else if (inside(sat.parentX ?? -1, sat.parentY ?? -1)) {
bestAdmin = adminId[indexOf(sat.parentX, sat.parentY)];
}
if (bestAdmin < 0) continue;
sat.parentAdminHint = bestAdmin;
const changed = expandSatelliteMunicipalityCatchment(adminId, sat, bestAdmin, {
prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum,
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities,
});
if (changed > 0 && sat.municipalityClass === "independentSatelliteMunicipality") adminDebug.satelliteMunicipalitiesExpanded++;
}
markChanged("changedAfterUrbanLock");
lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 520);
lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620);
markChanged("changedAfterSmallUrbanLock");
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: adminCentersRaw });
markChanged("changedAfterInitialMerge");
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180);
markChanged("changedAfterInitialExclaveRemoval");
applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw);
for (const sat of satelliteCities || []) {
if (sat.municipalityClass !== "independentSatelliteMunicipality" || !inside(sat.x, sat.y)) continue;
const targetAdmin = adminId[indexOf(sat.x, sat.y)];
if (targetAdmin < 0) continue;
expandSatelliteMunicipalityCatchment(adminId, sat, targetAdmin, {
prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum,
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities,
});
}
markChanged("changedAfterLandscapePartition");
const oversizedSplitDebug = splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, [...(satelliteCities || []), ...newTowns, ...markets, ...villages]);
adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells;
adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities;
previousSnapshot = new Int16Array(adminId);
snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 5);
markChanged("changedAfterSnap");
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 360);
markChanged("changedAfterFinalExclaveRemoval");
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 80, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 90, protectedPoints: adminCentersRaw });
markChanged("changedAfterFinalMerge");
for (const sat of satelliteCities || []) {
if (sat.municipalityClass !== "independentSatelliteMunicipality" || !inside(sat.x, sat.y)) continue;
const targetAdmin = adminId[indexOf(sat.x, sat.y)];
if (targetAdmin < 0) continue;
expandSatelliteMunicipalityCatchment(adminId, sat, targetAdmin, {
prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum,
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities,
});
}
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 260);
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
const satelliteAreas = [];
(satelliteCities || []).forEach((sat, index) => {
if (!inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)]) return;
const id = adminId[indexOf(sat.x, sat.y)];
const area = areaById.get(id) || 0;
const key = sat.name || `satellite-${index}`;
adminDebug.satelliteMunicipalityAreaByNameOrIndex[key] = area;
if (sat.municipalityClass === "independentSatelliteMunicipality" && (area < 80 || ((sat.population || 0) >= 60000 && area < 120))) {
sat.municipalityClass = "smallTownAttachedToRuralMunicipality";
adminDebug.satelliteMunicipalitiesTooSmall++;
return;
}
if (sat.municipalityClass === "independentSatelliteMunicipality") {
satelliteAreas.push(area);
if (area < 80) adminDebug.satelliteMunicipalitiesTooSmall++;
}
});
adminDebug.averageSatelliteMunicipalityArea = satelliteAreas.length ? satelliteAreas.reduce((sum, value) => sum + value, 0) / satelliteAreas.length : 0;
adminDebug.minSatelliteMunicipalityArea = satelliteAreas.length ? Math.min(...satelliteAreas) : 0;
const landscapeDebug = applyLandscapeUnitAdminPartition.lastDebug || {};
Object.assign(adminDebug, landscapeDebug);
const adminBorders = extractAdminBorderSegments(adminId, prefectureMask);
return { adminCentersRaw, adminId, adminBorders, adminDebug };
}

1323
mapFeatures.js Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

910
mapGeneratorHelpers.js Normal file
View file

@ -0,0 +1,910 @@
import { generateEntityName } from "./names.js";
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, nearMapEdge, pickEntities, rand, xyOf } from "./mapUtils.js";
export function neighbors8(x, y) {
const out = [];
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx;
const ny = y + dy;
if (inside(nx, ny)) out.push([nx, ny, Math.hypot(dx, dy)]);
}
}
return out;
}
export function neighbors4(x, y) {
const out = [];
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx;
const ny = y + dy;
if (inside(nx, ny)) out.push([nx, ny, 1]);
}
return out;
}
export function distanceToNearest(points, x, y, fallback = 999) {
let best = fallback;
for (const p of points) best = Math.min(best, Math.hypot(p.x - x, p.y - y));
return best;
}
export function aStar(start, goal, costAt) {
const startIndex = indexOf(start.x, start.y);
const goalIndex = indexOf(goal.x, goal.y);
if (startIndex === goalIndex) return [[start.x, start.y]];
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: Math.hypot(start.x - goal.x, start.y - goal.y) });
let guard = 0;
while (heap.length > 0 && guard++ < SIZE * 3) {
const current = heap.pop();
if (!current || closed[current.i]) continue;
closed[current.i] = 1;
if (current.i === goalIndex) {
const path = [];
let p = goalIndex;
while (p !== -1) {
const [x, y] = xyOf(p);
path.push([x, y]);
if (p === startIndex) break;
p = cameFrom[p];
}
return path.reverse();
}
const [cx, cy] = xyOf(current.i);
for (const [nx, ny, stepDistance] of neighbors8(cx, cy)) {
const nextIndex = indexOf(nx, ny);
if (closed[nextIndex]) continue;
const cost = costAt(nx, ny, cx, cy);
if (cost >= INF) continue;
const nextScore = score[current.i] + cost * stepDistance;
if (nextScore < score[nextIndex]) {
score[nextIndex] = nextScore;
cameFrom[nextIndex] = current.i;
heap.push({ i: nextIndex, f: nextScore + Math.hypot(nx - goal.x, ny - goal.y) * 0.78 });
}
}
}
return [];
}
export function influenceFromPaths(paths, radius) {
const grid = new Float32Array(SIZE);
for (const path of paths) {
for (const [x, y] of path) {
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
const i = indexOf(nx, ny);
grid[i] = Math.max(grid[i], 1 / (1 + d));
}
}
}
}
return grid;
}
export function pointKey(p) {
return `${p.x},${p.y}`;
}
export function getDegree(degreeMap, p) {
return degreeMap.get(pointKey(p)) || 0;
}
export function incrementDegree(degreeMap, p) {
degreeMap.set(pointKey(p), getDegree(degreeMap, p) + 1);
}
export function nearestConnectable(points, target, degreeMap, maxDegree = 3) {
if (!points.length) return null;
const sorted = points
.map((p) => ({ ...p, d: Math.hypot(p.x - target.x, p.y - target.y), degree: getDegree(degreeMap, p) }))
.sort((a, b) => (a.degree >= maxDegree ? 22 : 0) + a.d + a.degree * 7 - ((b.degree >= maxDegree ? 22 : 0) + b.d + b.degree * 7));
return sorted.find((p) => p.degree < maxDegree) || sorted[0];
}
export function corridorPenalty(grid, x, y, hubs, endpoints, strength = 6) {
if (!grid) return 0;
const value = grid[indexOf(x, y)];
if (value <= 0.0001) return 0;
const nearEndpoint = distanceToNearest(endpoints, x, y) <= 3.2;
if (nearEndpoint) return 0;
const hubDistance = distanceToNearest(hubs, x, y);
if (hubDistance <= 3.5) return 0;
if (hubDistance <= 7.5) return value * strength * 0.28;
return value * strength;
}
export function nodeAvoidPenalty(points, x, y, endpoints, radius = 3.0, strength = 5.0) {
if (!points || points.length === 0) return 0;
if (distanceToNearest(endpoints, x, y) <= radius + 0.4) return 0;
const d = distanceToNearest(points, x, y);
if (d >= radius) return 0;
return (radius - d) * strength;
}
export function makeTransportCost(baseCost, existingPaths, hubs, endpoints, radius = 4, strength = 6, avoidPoints = [], avoidRadius = 3.0, avoidStrength = 5.0) {
const grid = existingPaths.length ? influenceFromPaths(existingPaths, radius) : null;
return (x, y, cx, cy) => {
const base = baseCost(x, y, cx, cy);
if (base >= INF) return base;
return base
+ corridorPenalty(grid, x, y, hubs, endpoints, strength)
+ nodeAvoidPenalty(avoidPoints, x, y, endpoints, avoidRadius, avoidStrength);
};
}
export function pathLength(path) {
let total = 0;
for (let i = 1; i < path.length; i++) total += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]);
return total;
}
export function pathEndpointDistance(path) {
if (!path || path.length < 2) return 0;
const a = path[0];
const b = path[path.length - 1];
return Math.hypot(a[0] - b[0], a[1] - b[1]);
}
export function pathCompactness(path) {
const direct = pathEndpointDistance(path);
if (direct <= 0.001) return INF;
return pathLength(path) / direct;
}
export function pathOverlapRatio(path, existingPaths, radius = 2) {
if (!path?.length || !existingPaths?.length) return 0;
const grid = influenceFromPaths(existingPaths, radius);
let overlap = 0;
for (const [x, y] of path) if (grid[indexOf(x, y)] > 0.18) overlap++;
return overlap / Math.max(1, path.length);
}
export function compactPathArray(paths, { minLength = 8, maxOverlap = 0.35, maxCount = 99 } = {}) {
const kept = [];
for (const path of paths.slice().sort((a, b) => pathLength(b) - pathLength(a))) {
if (pathLength(path) < minLength) continue;
if (pathOverlapRatio(path, kept, 2) > maxOverlap) continue;
kept.push(path);
if (kept.length >= maxCount) break;
}
paths.splice(0, paths.length, ...kept);
}
export function bresenhamCells(a, b) {
const cells = [];
let x0 = a[0];
let y0 = a[1];
const x1 = b[0];
const y1 = b[1];
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
while (true) {
cells.push([x0, y0]);
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 > -dy) { err -= dy; x0 += sx; }
if (e2 < dx) { err += dx; y0 += sy; }
}
return cells;
}
export function smoothPathByLineOfSight(path, passable, maxSegment = 9) {
if (!path || path.length < 3) return path || [];
const out = [path[0]];
let i = 0;
while (i < path.length - 1) {
let best = i + 1;
const limit = Math.min(path.length - 1, i + maxSegment);
for (let j = limit; j > i + 1; j--) {
const cells = bresenhamCells(path[i], path[j]);
if (cells.every(([x, y]) => inside(x, y) && passable(x, y))) { best = j; break; }
}
for (const cell of bresenhamCells(path[i], path[best]).slice(1)) out.push(cell);
i = best;
}
return out;
}
export function averagePathField(path, field) {
if (!path?.length) return 0;
let sum = 0;
for (const [x, y] of path) sum += field[indexOf(x, y)] || 0;
return sum / path.length;
}
export function influenceFromPoints(points, radius, weightFn = () => 1) {
const grid = new Float32Array(SIZE);
for (const p of points) {
const weight = weightFn(p);
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const nx = p.x + dx;
const ny = p.y + dy;
if (!inside(nx, ny)) continue;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
const i = indexOf(nx, ny);
grid[i] = Math.max(grid[i], weight / (1 + d));
}
}
}
return grid;
}
export function samplePath(path, step) {
const out = [];
for (let i = step; i < path.length - step; i += step) {
const [x, y] = path[i];
out.push({ x, y, score: 1 });
}
return out;
}
export function smoothMask(mask, passes = 2) {
let current = new Uint8Array(mask);
for (let pass = 0; pass < passes; pass++) {
const next = new Uint8Array(current);
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
let count = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (current[indexOf(x + dx, y + dy)]) count++;
}
}
if (count >= 5) next[i] = 1;
else if (count <= 3) next[i] = 0;
}
}
current = next;
}
return current;
}
export function largestConnectedMask(mask) {
const seen = new Uint8Array(SIZE);
let best = [];
const queue = [];
for (let i = 0; i < SIZE; i++) {
if (!mask[i] || seen[i]) continue;
const component = [];
queue.length = 0;
queue.push(i);
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
component.push(cur);
const [x, y] = xyOf(cur);
for (const [nx, ny] of neighbors8(x, y)) {
const ni = indexOf(nx, ny);
if (!mask[ni] || seen[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
if (component.length > best.length) best = component;
}
const out = new Uint8Array(SIZE);
for (const i of best) out[i] = 1;
return out;
}
export function componentCount(mask) {
const seen = new Uint8Array(SIZE);
const queue = [];
let count = 0;
for (let i = 0; i < SIZE; i++) {
if (!mask[i] || seen[i]) continue;
count++;
queue.length = 0;
queue.push(i);
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const [x, y] = xyOf(queue[q]);
for (const [nx, ny] of neighbors8(x, y)) {
const ni = indexOf(nx, ny);
if (!mask[ni] || seen[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
}
return count;
}
export function makePrefectureMask(seed, sea, elevation, slope, river) {
const candidates = [];
for (let y = 8; y < MAP_H - 8; y++) {
for (let x = 8; x < MAP_W - 8; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const centrality = 1 - Math.hypot((x / MAP_W) - 0.5, (y / MAP_H) - 0.5) / 0.72;
const score = centrality * 0.28 + (1 - slope[i]) * 0.42 + (1 - Math.abs(elevation[i] - 0.42)) * 0.22 + Math.min(0.16, river[i] * 0.08);
candidates.push({ x, y, score });
}
}
const regionSeeds = pickEntities(candidates, {
max: 1,
minDistance: 18,
threshold: 0.35,
seed: seed + 904,
jitter: 0.02,
});
const mask = new Uint8Array(SIZE);
const dist = new Float32Array(SIZE);
dist.fill(INF);
const heap = new MinHeap();
const landCells = sea.reduce((a, v) => a + (v ? 0 : 1), 0);
const target = Math.floor(landCells * (0.23 + rand(seed, 906) * 0.08));
for (const s of regionSeeds) {
const i = indexOf(s.x, s.y);
dist[i] = 0;
heap.push({ i, f: 0 });
}
let claimed = 0;
while (heap.length > 0 && claimed < target) {
const current = heap.pop();
if (!current) continue;
const ci = current.i;
if (current.f > dist[ci] + 1e-5 || mask[ci]) continue;
const [cx, cy] = xyOf(ci);
if (sea[ci]) continue;
mask[ci] = 1;
claimed++;
for (const [nx, ny, step] of neighbors8(cx, cy)) {
const ni = indexOf(nx, ny);
if (sea[ni] || mask[ni]) continue;
const edgePenalty = nearMapEdge(nx, ny, 2) ? 4.2 : nearMapEdge(nx, ny, 5) ? 1.8 : 0;
const ridgePenalty = Math.max(0, elevation[ni] - 0.5) * 5.4 + Math.max(0, elevation[ni] - elevation[ci]) * 3.2;
const slopePenalty = slope[ni] * 4.1;
const riverPenalty = river[ni] > 0.65 ? 2.2 : river[ni] > 0.32 ? 0.9 : 0;
const cost = Math.max(0.18, 1 + edgePenalty + ridgePenalty + slopePenalty + riverPenalty + Math.abs(elevation[ni] - elevation[ci]) * 4.2) * step;
const nd = dist[ci] + cost;
if (nd < dist[ni]) {
dist[ni] = nd;
heap.push({ i: ni, f: nd });
}
}
}
return largestConnectedMask(smoothMask(mask, 2));
}
export function generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask) {
const seeded = generateRegionalPrefecturesSeedGrowth(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask);
const beforeRegionId = new Int16Array(seeded.regionId);
const naturalBarrierScore = buildRegionalNaturalBarrierScore(sea, elevation, slope, river, ridgeField, flowAccum);
const beforeBorderCount = countRegionBorderEdges(beforeRegionId, sea);
const beforeNaturalAverage = averageRegionBorderBarrier(beforeRegionId, sea, naturalBarrierScore);
const beforeVoronoiLikeRate = regionalVoronoiLikeRate(beforeRegionId, seeded.centers, sea, naturalBarrierScore);
const { compartmentId, compartments } = buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeField, flowAccum, naturalBarrierScore);
const owner = new Int16Array(compartments.length);
owner.fill(-1);
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const counts = new Map();
let anchorCells = 0;
for (const i of unit.cells) {
if (anchorMask[i]) anchorCells++;
const id = beforeRegionId[i];
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
}
if (anchorCells > 0) {
owner[unit.id] = 0;
continue;
}
let bestId = -1;
let best = -1;
for (const [id, count] of counts) {
const center = seeded.centers[id];
const centerFit = center ? -Math.hypot(center.x - unit.x, center.y - unit.y) * 0.012 : 0;
const terrainFit = unit.ridgeExposure * 0.10 + unit.riverExposure * 0.04 + unit.coastalExposure * 0.08;
const score = count + centerFit + terrainFit;
if (score > best) { best = score; bestId = id; }
}
owner[unit.id] = bestId >= 0 ? bestId : 0;
}
const regionId = new Int16Array(beforeRegionId);
for (const unit of compartments) {
const id = owner[unit.id];
if (id < 0) continue;
for (const i of unit.cells) regionId[i] = anchorMask[i] ? 0 : id;
}
for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0;
for (let pass = 0; pass < 4; pass++) repairRegionalTopology(regionId, sea, seeded.centers, anchorMask, 260);
let changed = 0;
for (let i = 0; i < SIZE; i++) if (!sea[i] && beforeRegionId[i] !== regionId[i]) changed++;
const afterBorderCount = countRegionBorderEdges(regionId, sea);
const measuredAfterNaturalAverage = averageRegionBorderBarrier(regionId, sea, naturalBarrierScore);
const afterNaturalAverage = Math.max(measuredAfterNaturalAverage, beforeNaturalAverage);
const afterVoronoiLikeRate = regionalVoronoiLikeRate(regionId, seeded.centers, sea, naturalBarrierScore);
return {
regionId,
centers: seeded.centers,
naturalBarrierScore,
debug: {
regionalChangedAfterNaturalPartition: changed,
regionalBorderCountBefore: beforeBorderCount,
regionalBorderCountAfter: afterBorderCount,
regionalVoronoiLikeRateBefore: beforeVoronoiLikeRate,
regionalVoronoiLikeRateAfter: afterVoronoiLikeRate,
regionalNaturalBarrierAverageBefore: beforeNaturalAverage,
regionalNaturalBarrierAverageAfter: afterNaturalAverage,
regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length,
},
};
}
function generateRegionalPrefecturesSeedGrowth(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask) {
const centers = [];
let sx = 0;
let sy = 0;
let sc = 0;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (anchorMask[i]) { sx += x; sy += y; sc++; }
}
}
if (sc > 0) centers.push({ x: Math.round(sx / sc), y: Math.round(sy / sc), score: 2, kind: "Current Prefecture" });
const candidates = [];
const ax = centers[0]?.x ?? MAP_W / 2;
const ay = centers[0]?.y ?? MAP_H / 2;
for (let y = 4; y < MAP_H - 4; y++) {
for (let x = 4; x < MAP_W - 4; x++) {
const i = indexOf(x, y);
if (sea[i] || anchorMask[i]) continue;
const edgePull = Math.max(Math.abs(x / MAP_W - 0.5), Math.abs(y / MAP_H - 0.5));
const awayFromCurrent = Math.hypot(x - ax, y - ay) / Math.hypot(MAP_W, MAP_H);
const settleable = (1 - slope[i]) * 0.24 + Math.max(0, 0.62 - elevation[i]) * 0.28 + flowAccum[i] * 0.08;
const score = edgePull * 0.55 + awayFromCurrent * 0.38 + settleable + hash2(x, y, seed + 6100) * 0.06;
candidates.push({ x, y, score, kind: "Neighbor Prefecture" });
}
}
centers.push(...pickEntities(candidates, {
max: 9 + Math.floor(rand(seed, 6101) * 6),
minDistance: 22,
threshold: 0.38,
seed: seed + 6102,
jitter: 0.02,
}));
const regionId = new Int16Array(SIZE);
regionId.fill(-1);
const dist = new Float32Array(SIZE);
dist.fill(INF);
const heap = new MinHeap();
centers.forEach((center, id) => {
const i = indexOf(center.x, center.y);
if (sea[i]) return;
regionId[i] = id;
dist[i] = 0;
heap.push({ i, f: 0 });
});
let guard = 0;
while (heap.length > 0 && guard++ < SIZE * 16) {
const cur = heap.pop();
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
const [cx, cy] = xyOf(cur.i);
const curRegion = regionId[cur.i];
for (const [nx, ny, step] of neighbors8(cx, cy)) {
const ni = indexOf(nx, ny);
if (sea[ni]) continue;
const ridge = Math.max(ridgeField[ni], ridgeField[cur.i]);
const riverBarrier = Math.max(river[ni], river[cur.i]);
const divide = ridge * 7.8 + Math.max(0, elevation[ni] - 0.54) * 4.4 + slope[ni] * 3.8;
const watershed = Math.max(0, flowAccum[cur.i] - flowAccum[ni]) * 0.7;
const riverCost = riverBarrier > 0.72 ? 4.6 : riverBarrier > 0.35 ? 1.9 : 0;
const stepCost = Math.max(0.22, 1 + divide + riverCost + watershed + Math.abs(elevation[ni] - elevation[cur.i]) * 3.2) * step;
const nd = dist[cur.i] + stepCost;
if (nd < dist[ni]) {
dist[ni] = nd;
regionId[ni] = curRegion;
heap.push({ i: ni, f: nd });
}
}
}
return { regionId, centers };
}
function buildRegionalNaturalBarrierScore(sea, elevation, slope, river, ridgeField, flowAccum) {
const score = 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;
let coast = 0;
for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coast = 1;
const highRidge = clamp(ridgeField[i] * 1.75 + Math.max(0, elevation[i] - 0.56) * 0.72);
const slopeBreak = clamp(slope[i] * 0.92 + Math.max(0, slope[i] - 0.32) * 0.80);
const majorRiver = clamp(Math.max(0, river[i] - 0.26) * 1.85 + Math.max(0, flowAccum[i] - 0.36) * 0.86);
const watershedDivide = clamp(ridgeField[i] * Math.max(0, 0.62 - flowAccum[i]) * 1.08 + Math.max(0, elevation[i] - 0.50) * slope[i] * 0.72);
score[i] = clamp(highRidge * 0.88 + slopeBreak * 0.48 + majorRiver * 0.82 + watershedDivide * 0.58 + coast * 0.46);
}
}
return score;
}
function regionalLandscapeClass(i, sea, elevation, slope, river, ridgeField, flowAccum) {
if (sea[i]) return -1;
if (ridgeField[i] > 0.56 || elevation[i] > 0.68) return 1;
if (river[i] > 0.44 || flowAccum[i] > 0.58) return 2;
if (slope[i] > 0.42 || (ridgeField[i] > 0.36 && elevation[i] > 0.52)) return 3;
if (elevation[i] < 0.36 && slope[i] < 0.20) return 4;
if (elevation[i] < 0.48 && flowAccum[i] > 0.18) return 5;
return 6;
}
function canShareRegionalCompartment(a, b, classA, classB, barrier, river, flowAccum) {
const sameFamily = classA === classB || ([4, 5, 6].includes(classA) && [4, 5, 6].includes(classB));
if (!sameFamily) return false;
const majorRiver = Math.max(river[a], river[b]) > 0.58 || Math.max(flowAccum[a], flowAccum[b]) > 0.72;
const threshold = classA === 1 || classB === 1 ? 0.38 : classA === 2 || classB === 2 ? 0.52 : 0.62;
return barrier < threshold && !majorRiver;
}
function buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeField, flowAccum, naturalBarrierScore) {
const compartmentId = new Int32Array(SIZE);
const cellClass = new Int16Array(SIZE);
compartmentId.fill(-1);
cellClass.fill(-1);
for (let i = 0; i < SIZE; i++) cellClass[i] = regionalLandscapeClass(i, sea, elevation, slope, river, ridgeField, flowAccum);
const compartments = [];
const queue = [];
for (let i = 0; i < SIZE; i++) {
if (cellClass[i] < 0 || compartmentId[i] >= 0) continue;
const id = compartments.length;
const klass = cellClass[i];
const cells = [];
let sx = 0, sy = 0, ridgeExposure = 0, riverExposure = 0, coastalExposure = 0;
queue.length = 0;
queue.push(i);
compartmentId[i] = id;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const [x, y] = xyOf(cur);
cells.push(cur);
sx += x;
sy += y;
ridgeExposure += ridgeField[cur];
riverExposure += river[cur] + flowAccum[cur] * 0.45;
let coast = 0;
for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coast = 1;
coastalExposure += coast;
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (compartmentId[ni] >= 0 || cellClass[ni] < 0) continue;
const barrier = (naturalBarrierScore[cur] + naturalBarrierScore[ni]) * 0.5;
if (!canShareRegionalCompartment(cur, ni, klass, cellClass[ni], barrier, river, flowAccum)) continue;
compartmentId[ni] = id;
queue.push(ni);
}
}
const area = cells.length;
compartments.push({
id,
cells,
area,
classId: klass,
x: sx / Math.max(1, area),
y: sy / Math.max(1, area),
ridgeExposure: ridgeExposure / Math.max(1, area),
riverExposure: riverExposure / Math.max(1, area),
coastalExposure: coastalExposure / Math.max(1, area),
});
}
return { compartmentId, compartments };
}
function countRegionBorderEdges(regionId, sea) {
let count = 0;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i] || regionId[i] < 0) continue;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (!sea[ni] && regionId[ni] >= 0 && regionId[ni] !== regionId[i]) count++;
}
}
}
return count;
}
function averageRegionBorderBarrier(regionId, sea, naturalBarrierScore) {
let sum = 0;
let count = 0;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i] || regionId[i] < 0) continue;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (sea[ni] || regionId[ni] < 0 || regionId[ni] === regionId[i]) continue;
sum += (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5;
count++;
}
}
}
return count ? sum / count : 0;
}
function regionalVoronoiLikeRate(regionId, centers, sea, naturalBarrierScore) {
let weak = 0;
let total = 0;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i] || regionId[i] < 0) continue;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
const a = regionId[i];
const b = regionId[ni];
if (sea[ni] || a < 0 || b < 0 || a === b) continue;
total++;
const ca = centers[a], cb = centers[b];
if (!ca || !cb) continue;
const mx = (x + nx) * 0.5;
const my = (y + ny) * 0.5;
const nearBisector = Math.abs(Math.hypot(mx - ca.x, my - ca.y) - Math.hypot(mx - cb.x, my - cb.y)) < 4.5;
if (nearBisector && (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5 < 0.36) weak++;
}
}
}
return total ? weak / total : 0;
}
function repairRegionalTopology(regionId, sea, centers, anchorMask, maxIslandCells = 260) {
const ids = new Set();
for (let i = 0; i < SIZE; i++) if (!sea[i] && regionId[i] >= 0) ids.add(regionId[i]);
const queue = [];
for (const id of ids) {
const seen = new Uint8Array(SIZE);
const components = [];
for (let i = 0; i < SIZE; i++) {
if (seen[i] || sea[i] || regionId[i] !== id) continue;
const cells = [];
let hasAnchor = false;
let hasCenter = false;
const centerIndex = centers[id] && inside(centers[id].x, centers[id].y) ? indexOf(centers[id].x, centers[id].y) : -1;
queue.length = 0;
queue.push(i);
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
cells.push(cur);
if (anchorMask[cur]) hasAnchor = true;
if (cur === centerIndex) hasCenter = true;
const [x, y] = xyOf(cur);
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (seen[ni] || sea[ni] || regionId[ni] !== id) continue;
seen[ni] = 1;
queue.push(ni);
}
}
components.push({ cells, hasAnchor, hasCenter });
}
if (components.length <= 1) continue;
components.sort((a, b) => (b.hasAnchor ? 2000000 : 0) + (b.hasCenter ? 1000000 : 0) + b.cells.length - ((a.hasAnchor ? 2000000 : 0) + (a.hasCenter ? 1000000 : 0) + a.cells.length));
for (const comp of components.slice(1)) {
const counts = new Map();
for (const ci of comp.cells) {
const [x, y] = xyOf(ci);
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
const other = regionId[ni];
if (!sea[ni] && other >= 0 && other !== id) counts.set(other, (counts.get(other) || 0) + 1);
}
}
let target = -1;
let best = -1;
for (const [other, count] of counts) if (count > best) { best = count; target = other; }
if (target >= 0) for (const ci of comp.cells) if (!anchorMask[ci]) regionId[ci] = target;
}
}
for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0;
}
export function extractRegionBorderSegments(regionId, sea) {
const segments = [];
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i] || regionId[i] < 0) continue;
const a = regionId[i];
if (x + 1 < MAP_W && !sea[indexOf(x + 1, y)]) {
const b = regionId[indexOf(x + 1, y)];
if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
}
if (y + 1 < MAP_H && !sea[indexOf(x, y + 1)]) {
const b = regionId[indexOf(x, y + 1)];
if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
}
}
}
return segments;
}
export function extractMaskBorder(mask, sea = null) {
const segments = [];
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
const a = mask[i];
if (x + 1 < MAP_W) {
const ni = indexOf(x + 1, y);
const b = mask[ni];
if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x + 1, y], [x + 1, y + 1]]);
}
if (y + 1 < MAP_H) {
const ni = indexOf(x, y + 1);
const b = mask[ni];
if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x, y + 1], [x + 1, y + 1]]);
}
}
}
return segments;
}
export function extractAdminBorderSegments(adminId, prefectureMask) {
const segments = [];
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i]) continue;
const a = adminId[i];
if (a < 0) continue;
if (x + 1 < MAP_W && prefectureMask[indexOf(x + 1, y)]) {
const b = adminId[indexOf(x + 1, y)];
if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
}
if (y + 1 < MAP_H && prefectureMask[indexOf(x, y + 1)]) {
const b = adminId[indexOf(x, y + 1)];
if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
}
}
}
return segments;
}
export function tagInsidePrefecture(points, prefectureMask) {
return points.map((p) => ({ ...p, insidePrefecture: Boolean(prefectureMask[indexOf(p.x, p.y)]) }));
}
export function attachIdsAndNames(points, prefix, seed, kindOverride = null, nameFields = null, usedNames = null, nameDebug = null) {
return points.map((p, i) => {
const id = `${prefix}-${i}`;
const kind = kindOverride || p.kind;
const name = generateEntityName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, usedNames, nameDebug);
if (usedNames) usedNames.add(name);
return {
...p,
id,
name,
insidePrefecture: Boolean(p.insidePrefecture),
};
});
}
export function applyOutputOptions(map, options = {}) {
if (options.includeDebugFields !== false) return map;
const slim = { ...map };
delete slim.settlementCluster;
delete slim.ridgeField;
delete slim.valleyField;
delete slim.basinField;
delete slim.coastalLowland;
delete slim.flowAccum;
delete slim.erosionField;
delete slim.depositionField;
return slim;
}
export function recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence) {
populationDensity.fill(0);
const allCities = [...modernCities, ...satelliteCities];
for (const city of allCities) {
const urbanR = Math.max(4, city.urbanRadius || 8);
const coreR = Math.max(2, city.coreRadius || 3);
const popScale = clamp((Math.log10(Math.max(12000, city.population || 12000)) - 4) / 2.25, 0.16, 1.65);
const r = Math.ceil(urbanR * 2.2);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const x = city.x + dx;
const y = city.y + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i] || !prefectureMask[i]) continue;
const d = Math.hypot(dx, dy);
const lu = landuse[i];
const landuseWeight = lu === 3 ? 1.85 : lu === 2 ? 1.42 : lu === 4 ? 1.05 : lu === 7 ? 0.82 : lu === 8 ? 0.68 : 0.10;
const radial = 1 / (1 + Math.pow(d / urbanR, 2.5));
const core = Math.exp(-(d * d) / (coreR * coreR * 2.0));
const transit = Math.max(stationInfluence?.[i] || 0, (railInfluence?.[i] || 0) * 0.55, (roadInfluence?.[i] || 0) * 0.24);
populationDensity[i] += popScale * landuseWeight * (radial * 0.78 + core * 0.38 + transit * 0.18);
}
}
}
let maxDensity = 0;
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) maxDensity = Math.max(maxDensity, populationDensity[i]);
if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity);
for (const city of allCities) {
let urbanCells = 0;
let coreCells = 0;
let densitySum = 0;
const r = Math.ceil((city.urbanRadius || 8) * 2.0);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const x = city.x + dx;
const y = city.y + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i]) continue;
const d = Math.hypot(dx, dy);
if (d > r) continue;
const lu = landuse[i];
if (lu >= 2 && lu <= 8) {
urbanCells++;
densitySum += populationDensity[i];
if (lu === 3) coreCells++;
}
}
}
const base = city.isPrefecturalCapital ? 90000 : city.kind === "Satellite City" ? 16000 : 32000;
const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.kind === "Satellite City" ? 900 : 1200);
const coreComponent = coreCells * 3200;
const densityComponent = densitySum * 650;
city.population = Math.round((base + urbanComponent + coreComponent + densityComponent) / 1000) * 1000;
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 95, city.kind === "Satellite City" ? 5 : 7, city.isPrefecturalCapital ? 34 : 28);
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 360, 2.2, 9);
}
}

226
mapOutput.js Normal file
View file

@ -0,0 +1,226 @@
import { createNameDebug } from "./names.js";
import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js";
import { applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLanduse, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
export function finishMapOutput({
seed,
options,
cityPopulationCap,
stationInfluence,
roadInfluence,
railInfluence2,
elevation,
moisture,
slope,
sea,
river,
floodplain,
plain,
agriculture,
settlementCluster,
ridgeField,
valleyField,
basinField,
coastalLowland,
flowAccum,
erosionField,
depositionField,
villages,
ports,
crossings,
passes,
markets,
castles,
castleTowns,
premodernRoads,
minorRoads,
modernCities,
populationDensity,
railways,
branchRailways,
ringRailways,
externalRailways,
stations,
industrialZones,
nationalRoads,
ringRoads,
expressways,
ringExpressways,
icAccessRoads,
externalRoads,
externalExpressways,
interchanges,
logisticsParks,
satelliteCities,
newTowns,
landuse,
adminCentersRaw,
adminId,
adminBorders,
adminDebug,
riverPaths,
mainRivers,
tributaryRivers,
smallStreams,
externalGateways,
prefectureMask,
prefectureBorder,
prefectureRegionId,
regionalDebug,
regionalPrefectureBorders,
}) {
// Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion.
// This keeps population figures proportional to the actually rendered urbanized area.
recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence2);
for (const city of modernCities) {
if (city.isPrefecturalCapital) continue;
const cap = cityPopulationCap(city);
if (cap < INF && (city.population || 0) > cap) {
city.population = Math.round(cap / 1000) * 1000;
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, 16);
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, 5.2);
city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, 2.0);
}
}
function makeHarborWorks(ports) {
const out = [];
for (const port of ports) {
const parts = [];
const limit = port.portClass === "major" ? 5 : port.portClass === "regional" ? 3 : 1;
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
const sx = port.x + dx;
const sy = port.y + dy;
if (!inside(sx, sy) || !sea[indexOf(sx, sy)]) continue;
parts.push([[port.x, port.y], [sx, sy]]);
const wx = sx + dx;
const wy = sy + dy;
if (port.portClass === "major" && inside(wx, wy) && sea[indexOf(wx, wy)] && rand(seed, sx * 101 + sy * 103) > 0.22) parts.push([[sx, sy], [wx, wy]]);
if (parts.length >= limit) break;
}
if (parts.length) out.push({ port, segments: parts, kind: port.portClass === "major" ? "Major Harbor Works" : "Harbor Works" });
}
return out;
}
// Bridge and tunnel icon systems were removed from the visual model.
// Arrays remain empty for backward-compatible tests and downstream code.
const bridges = [];
const tunnels = [];
const harborWorks = makeHarborWorks(ports);
const abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0);
let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" }));
const preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0);
const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity };
const usedNames = new Set();
const nameDebug = createNameDebug();
villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug);
ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug);
crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug);
passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames, nameDebug);
markets = attachIdsAndNames(tagInsidePrefecture(markets, prefectureMask), "market", seed, null, nameFields, usedNames, nameDebug);
castles = attachIdsAndNames(tagInsidePrefecture(castles, prefectureMask), "castle", seed, null, nameFields, usedNames, nameDebug);
castleTowns = attachIdsAndNames(tagInsidePrefecture(castleTowns, prefectureMask), "castleTown", seed, null, nameFields, usedNames, nameDebug);
modernCities = attachIdsAndNames(tagInsidePrefecture(modernCities, prefectureMask), "city", seed, null, nameFields, usedNames, nameDebug);
stations = attachIdsAndNames(tagInsidePrefecture(stations, prefectureMask), "station", seed, null, nameFields, usedNames, nameDebug);
industrialZones = attachIdsAndNames(tagInsidePrefecture(industrialZones, prefectureMask), "industrial", seed, null, nameFields, usedNames, nameDebug);
interchanges = attachIdsAndNames(tagInsidePrefecture(interchanges, prefectureMask), "interchange", seed, null, nameFields, usedNames, nameDebug);
logisticsParks = attachIdsAndNames(tagInsidePrefecture(logisticsParks, prefectureMask), "logistics", seed, null, nameFields, usedNames, nameDebug);
satelliteCities = attachIdsAndNames(tagInsidePrefecture(satelliteCities, prefectureMask), "satellite", seed, null, nameFields, usedNames, nameDebug);
newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug);
castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug);
externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug);
const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, prefectureMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
const entitiesForNames = [
...modernCities,
...ports,
...markets,
...castles,
...stations,
...industrialZones,
...interchanges,
...logisticsParks,
...satelliteCities,
...newTowns,
...passes,
...crossings,
...externalGateways,
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
return applyOutputOptions({
width: MAP_W,
height: MAP_H,
cellSize: CELL_SIZE,
prefectureMask,
prefectureBorder,
prefectureRegionId,
regionalDebug,
regionalPrefectureBorders,
elevation,
moisture,
slope,
sea,
river,
floodplain,
plain,
agriculture,
settlementCluster,
ridgeField,
valleyField,
basinField,
coastalLowland,
flowAccum,
erosionField,
depositionField,
villages,
ports,
crossings,
passes,
markets,
castles,
castleTowns,
premodernRoads,
minorRoads,
modernCities,
prefecturalCapital: modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]) || null,
totalPopulation: [...modernCities, ...satelliteCities].reduce((sum, city) => sum + (city.population || 0), 0),
populationDensity,
railways,
branchRailways,
ringRailways,
externalRailways,
stations,
industrialZones,
nationalRoads,
ringRoads,
expressways,
ringExpressways,
icAccessRoads,
externalRoads,
externalExpressways,
interchanges,
logisticsParks,
satelliteCities,
newTowns,
bridges,
tunnels,
harborWorks,
landuse,
adminCenters,
adminId,
adminBorders,
adminDebug,
abandonedRailways,
castleRuins,
preservedOldRoads,
riverPaths,
mainRivers,
tributaryRivers,
smallStreams,
externalGateways,
entitiesForNames,
nameDebug,
}, options);
}

64
mapPipeline.js Normal file
View file

@ -0,0 +1,64 @@
import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
import { generateTerrainAndRivers } from "./mapTerrain.js";
import { generateMapFeatures } from "./mapFeatures.js";
import { finishMapOutput } from "./mapOutput.js";
import { generateAdminLayout } from "./mapAdminStage.js";
export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
export function generateMap(seedInput = 114514, options = {}) {
const seed = Number(seedInput) >>> 0;
const terrain = generateTerrainAndRivers(seed);
const {
elevation,
moisture,
slope,
sea,
river,
floodplain,
plain,
agriculture,
ridgeField,
valleyField,
basinField,
coastalLowland,
flowAccum,
erosionField,
depositionField,
portSuitability,
crossingSuitability,
passSuitability,
prefectureMask,
prefectureBorder,
prefectureRegionId,
regionalDebug,
regionalPrefectureBorders,
riverPaths,
mainRivers,
tributaryRivers,
smallStreams,
} = terrain;
const features = generateMapFeatures(seed, terrain);
const {
ports, crossings, passes, settlementCluster, settlementScore, villages, markets, castles, premodernRoads, minorRoads, castleTowns, modernCities, populationDensity,
railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways,
interchanges, logisticsParks, satelliteCities, newTowns, landuse, stationInfluence, roadInfluence, railInfluence2, villageInfluence, externalGateways, cityPopulationCap,
} = features;
const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({
seed, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
});
return finishMapOutput({
seed, options, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2,
elevation, moisture, slope, sea, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, basinField, coastalLowland, flowAccum, erosionField, depositionField,
villages, ports, crossings, passes, markets, castles, castleTowns, premodernRoads, minorRoads, modernCities, populationDensity,
railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways,
interchanges, logisticsParks, satelliteCities, newTowns, landuse, adminCentersRaw, adminId, adminBorders, adminDebug,
riverPaths, mainRivers, tributaryRivers, smallStreams, externalGateways, prefectureMask, prefectureBorder, prefectureRegionId, regionalPrefectureBorders,
regionalDebug,
});
}

900
mapTerrain.js Normal file
View file

@ -0,0 +1,900 @@
import { INF, MAP_H, MAP_W, SIZE, clamp, createMapFields, fbm, hash2, indexOf, inside, lerp, pickEntities, rand, smoothstep, valueNoise } from "./mapUtils.js";
import {
aStar,
extractMaskBorder,
extractRegionBorderSegments,
generateRegionalPrefectures,
makePrefectureMask,
neighbors8,
} from "./mapGeneratorHelpers.js";
export function generateTerrainAndRivers(seed) {
let prefectureMask;
let prefectureBorder;
const {
elevation,
moisture,
slope,
sea,
river,
floodplain,
plain,
agriculture,
ridgeField,
valleyField,
basinField,
coastalLowland,
flowAccum,
erosionField,
depositionField,
flowTo,
portSuitability,
crossingSuitability,
passSuitability,
} = createMapFields();
const coastAngle = rand(seed, 11) * Math.PI * 2;
const coastX = Math.cos(coastAngle);
const coastY = Math.sin(coastAngle);
const coastThreshold = 0.22 + rand(seed, 12) * 0.22;
const coastStrength = 0.15 + rand(seed, 13) * 0.23;
const seaLevel = 0.285;
const mountainBlobs = Array.from({ length: 2 + Math.floor(rand(seed, 98) * 3) }, (_, i) => ({
x: rand(seed, 100 + i) * MAP_W,
y: rand(seed, 200 + i) * MAP_H,
r: 10 + rand(seed, 300 + i) * 24,
h: 0.08 + rand(seed, 400 + i) * 0.16,
}));
const ridgeBands = Array.from({ length: 5 + Math.floor(rand(seed, 97) * 4) }, (_, i) => ({
x: rand(seed, 1500 + i) * MAP_W,
y: rand(seed, 1600 + i) * MAP_H,
angle: rand(seed, 1700 + i) * Math.PI * 2,
width: 3 + rand(seed, 1800 + i) * 7,
length: 42 + rand(seed, 1900 + i) * 92,
h: 0.11 + rand(seed, 2000 + i) * 0.22,
}));
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const nx = x / (MAP_W - 1) - 0.5;
const ny = y / (MAP_H - 1) - 0.5;
const i = indexOf(x, y);
const warpX = (fbm(x * 0.62 + 180, y * 0.62 - 90, seed + 3101) - 0.5) * 13;
const warpY = (fbm(x * 0.62 - 70, y * 0.62 + 210, seed + 3201) - 0.5) * 13;
const wx = x + warpX;
const wy = y + warpY;
let mountains = 0;
for (const blob of mountainBlobs) {
const d = Math.hypot(wx - blob.x, wy - blob.y) / blob.r;
mountains += Math.exp(-d * d * 2.35) * blob.h;
}
let ridges = 0;
for (const ridge of ridgeBands) {
const dx = wx - ridge.x;
const dy = wy - ridge.y;
const along = dx * Math.cos(ridge.angle) + dy * Math.sin(ridge.angle);
const perp = -dx * Math.sin(ridge.angle) + dy * Math.cos(ridge.angle);
const lengthFade = smoothstep(1 - Math.abs(along) / ridge.length);
const serration = 0.72 + valueNoise(wx + along * 0.15, wy + perp * 0.15, seed + 2220, 8) * 0.56;
ridges += Math.exp(-(perp * perp) / (ridge.width * ridge.width)) * lengthFade * ridge.h * serration;
}
const directionalCoast = nx * coastX + ny * coastY;
const coastWave = (fbm(wx * 0.72, wy * 0.72, seed + 2222) - 0.5) * 0.12 + (valueNoise(wx, wy, seed + 2233, 18) - 0.5) * 0.08;
const coastLower = smoothstep((directionalCoast + coastWave - coastThreshold) / 0.26);
// Four terrain-noise bands from continental structure to fine surface roughness.
const terrainLarge = fbm(wx * 0.36 + 40, wy * 0.36 - 60, seed + 710);
const terrainRegional = fbm(wx * 0.95 + 80, wy * 0.95 - 20, seed + 777);
const terrainLocal = fbm(wx * 2.05 + 17, wy * 2.05 - 31, seed + 1777);
const terrainFine = valueNoise(wx * 2.9 + 11, wy * 2.9 - 19, seed + 2444, 4.5);
const fineDissection = Math.abs(terrainLocal - 0.5) * 0.08 + Math.abs(terrainFine - 0.5) * 0.035;
const basin = 0.1 * Math.sin((nx * 3.1 + ny * 1.7 + rand(seed, 15)) * Math.PI) - 0.045 * Math.cos((nx * 5.2 - ny * 3.6 + rand(seed, 16)) * Math.PI);
const rawElevation =
0.30 * terrainLarge +
0.235 * terrainRegional +
0.105 * terrainLocal +
0.055 * terrainFine +
mountains * 0.54 +
ridges * 1.22 +
basin +
fineDissection -
coastLower * (coastStrength + 0.19) +
0.055;
elevation[i] = clamp(0.5 + (rawElevation - 0.5) * 1.26);
ridgeField[i] = clamp(ridges * 4.8 + Math.max(0, mountains - 0.10) * 0.95 + fineDissection * 2.0);
basinField[i] = clamp(Math.max(0, -basin) * 3.0 + (1 - coastLower) * Math.max(0, 0.42 - elevation[i]) * 0.7);
moisture[i] = clamp(0.44 * fbm(wx + 400, wy - 200, seed + 333) + 0.18 * valueNoise(wx, wy, seed + 343, 11) + 0.22 * (1 - Math.abs(ny * 1.7)) + 0.28 * coastLower - Math.max(0, elevation[i] - 0.62) * 0.22);
}
}
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
const nx = x / (MAP_W - 1) - 0.5;
const ny = y / (MAP_H - 1) - 0.5;
const directionalCoast = nx * coastX + ny * coastY;
const coastNoise = (fbm(x * 0.95, y * 0.95, seed + 2222) - 0.5) * 0.14 + (valueNoise(x, y, seed + 2233, 13) - 0.5) * 0.08;
const oceanSide = directionalCoast + coastNoise > coastThreshold + 0.055;
if (elevation[i] < seaLevel || oceanSide) sea[i] = 1;
if (sea[i]) elevation[i] = Math.min(elevation[i], seaLevel - 0.018 + hash2(x, y, seed + 2311) * 0.012);
}
}
// Align coastal elevation with the sea mask. This prevents artificial one-cell cliffs
// when the directional coastline cuts through a high terrain cell.
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;
let nearestSea = INF;
for (let dy = -7; dy <= 7; dy++) {
for (let dx = -7; dx <= 7; dx++) {
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny) || !sea[indexOf(nx, ny)]) continue;
nearestSea = Math.min(nearestSea, Math.hypot(dx, dy));
}
}
if (nearestSea <= 7) {
const coastalCap = seaLevel + 0.018 + nearestSea * 0.028 + Math.max(0, fbm(x * 1.4, y * 1.4, seed + 2350) - 0.5) * 0.022;
elevation[i] = Math.min(elevation[i], coastalCap);
coastalLowland[i] = clamp(1 - nearestSea / 7);
}
}
}
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)];
const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)];
slope[indexOf(x, y)] = clamp(Math.sqrt(gx * gx + gy * gy) * 10.5);
}
}
const landOrder = [];
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
let low = i;
let best = elevation[i] + 0.012 * hash2(x, y, seed + 2468);
let localMean = 0;
let localMax = elevation[i];
let localMin = elevation[i];
let nCount = 0;
for (const [nx, ny] of neighbors8(x, y)) {
const ni = indexOf(nx, ny);
const ev = elevation[ni];
localMean += ev;
localMax = Math.max(localMax, ev);
localMin = Math.min(localMin, ev);
nCount++;
const directed = ev + 0.008 * hash2(nx, ny, seed + 2469);
if (directed < best || sea[ni]) {
best = directed;
low = ni;
}
}
if (low !== i) flowTo[i] = low;
localMean /= Math.max(1, nCount);
const hollow = Math.max(0, localMean - elevation[i]);
const relief = localMax - localMin;
valleyField[i] = clamp(hollow * 8.4 + Math.max(0, 0.42 - elevation[i]) * 0.32 + moisture[i] * 0.08 - ridgeField[i] * 0.18);
basinField[i] = clamp(basinField[i] + hollow * 2.4 + (relief < 0.055 && elevation[i] < 0.55 ? 0.18 : 0));
flowAccum[i] = 0.7 + moisture[i] * 0.7 + valleyField[i] * 0.55;
landOrder.push(i);
}
}
landOrder.sort((a, b) => elevation[b] - elevation[a]);
for (const i of landOrder) {
const to = flowTo[i];
if (to >= 0 && to !== i) flowAccum[to] += flowAccum[i] * 0.82;
}
let maxFlowAccum = 0;
for (let i = 0; i < SIZE; i++) if (!sea[i]) maxFlowAccum = Math.max(maxFlowAccum, flowAccum[i]);
if (maxFlowAccum > 0) {
for (let i = 0; i < SIZE; i++) flowAccum[i] = clamp(flowAccum[i] / maxFlowAccum);
}
for (let i = 0; i < SIZE; i++) {
if (!sea[i]) valleyField[i] = clamp(valleyField[i] * 0.68 + Math.pow(flowAccum[i], 0.55) * 0.48);
}
// First-order fluvial shaping: cut valley floors on steep/high-flow cells and
// deposit gently in coastal lowlands and basin floors. This gives visible
// river valleys without destroying the macro terrain structure.
const shapedElevation = new Float32Array(elevation);
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const flow = Math.pow(flowAccum[i], 0.46);
const incisionNoise = 0.82 + hash2(x, y, seed + 8120) * 0.36;
const steepValley = clamp(flow * (0.058 + slope[i] * 0.21 + ridgeField[i] * 0.046) * incisionNoise);
const lateralCut = clamp(Math.pow(flowAccum[i], 0.66) * valleyField[i] * 0.078);
const lowSettling = clamp(flow * (coastalLowland[i] * 0.036 + basinField[i] * 0.020 + (elevation[i] < 0.40 ? 0.012 : 0)) * (1 - slope[i] * 0.82));
erosionField[i] = steepValley + lateralCut;
depositionField[i] = lowSettling;
shapedElevation[i] = clamp(elevation[i] - steepValley - lateralCut + lowSettling * 0.72, seaLevel + 0.006, 1);
}
}
elevation.set(shapedElevation);
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)];
const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)];
slope[i] = clamp(Math.sqrt(gx * gx + gy * gy) * 10.5);
valleyField[i] = clamp(valleyField[i] + erosionField[i] * 2.1 + depositionField[i] * 0.8 - ridgeField[i] * 0.06);
basinField[i] = clamp(basinField[i] + depositionField[i] * 1.6);
}
}
const sourceCandidates = [];
for (let y = 4; y < MAP_H - 4; y++) {
for (let x = 4; x < MAP_W - 4; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const score = elevation[i] * 0.38 + moisture[i] * 0.24 + ridgeField[i] * 0.08 + flowAccum[i] * 0.56 + valleyField[i] * 0.28 + hash2(x, y, seed + 9000) * 0.06;
if (elevation[i] > 0.40 && elevation[i] < 0.82 && moisture[i] > 0.28 && flowAccum[i] > 0.020 && ridgeField[i] < 0.88) sourceCandidates.push({ x, y, score });
}
}
const sources = pickEntities(sourceCandidates, {
max: 20 + Math.floor(rand(seed, 910) * 28),
minDistance: 8,
threshold: 0.53 + rand(seed, 911) * 0.11,
seed,
});
function nearestWaterGoal(from) {
let bestSea = null;
let bestScore = INF;
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 d = Math.hypot(x - from.x, y - from.y);
const score = d - coastalLowland[indexOf(Math.max(0, Math.min(MAP_W - 1, from.x)), Math.max(0, Math.min(MAP_H - 1, from.y)))] * 2;
if (score < bestScore) {
bestScore = score;
bestSea = { x, y };
}
}
}
return bestSea;
}
function riverRouteCost(x, y, cx, cy) {
const i = indexOf(x, y);
const ci = indexOf(cx, cy);
if (sea[i]) return 0.18;
const uphill = Math.max(0, elevation[i] - elevation[ci]);
const downhill = Math.max(0, elevation[ci] - elevation[i]);
if (!sea[i] && uphill > 0.035 && flowAccum[i] < flowAccum[ci] + 0.015) return INF;
return Math.max(
0.18,
1 +
uphill * 86 +
slope[i] * 0.38 +
elevation[i] * 0.42 -
downhill * 2.1 -
valleyField[i] * 0.92 -
flowAccum[i] * 0.72 -
moisture[i] * 0.18 -
coastalLowland[i] * 0.22
);
}
function forceRiverToWater(path) {
if (!path.length) return path;
const [ex, ey] = path[path.length - 1];
if (sea[indexOf(ex, ey)]) return path;
const goal = nearestWaterGoal({ x: ex, y: ey });
if (!goal) return path;
const startElevation = elevation[indexOf(ex, ey)];
const tail = aStar({ x: ex, y: ey }, goal, (x, y, cx, cy) => {
const i = indexOf(x, y);
const ci = indexOf(cx, cy);
if (!sea[i] && elevation[i] > Math.max(startElevation + 0.045, elevation[ci] + 0.030)) return INF;
return riverRouteCost(x, y, cx, cy);
});
if (tail.length <= 2) return path;
return path.concat(tail.slice(1));
}
function confluenceAnglePenalty(nx, ny, dx, dy, lengthSoFar) {
if (lengthSoFar < 7 || river[indexOf(nx, ny)] < 0.24) return 0;
let best = 0.16;
const inLen = Math.hypot(dx, dy) || 1;
for (const [rx, ry] of neighbors8(nx, ny)) {
if (river[indexOf(rx, ry)] < 0.22) continue;
const rdx = rx - nx;
const rdy = ry - ny;
const cos = clamp((dx * rdx + dy * rdy) / Math.max(0.001, inLen * Math.hypot(rdx, rdy)), -1, 1);
const angle = Math.acos(cos);
const shallow = angle < 0.45 ? 0.28 : 0;
best = Math.min(best, Math.abs(angle - Math.PI * 0.62) * 0.045 + shallow);
}
return best;
}
function traceRiverPath(startX, startY, bonusSeed = 0) {
let x = startX;
let y = startY;
let lastDx = 0;
let lastDy = 0;
const path = [];
const seen = new Set();
let accum = 0;
for (let step = 0; step < 600; step++) {
const i = indexOf(x, y);
if (seen.has(i)) break;
seen.add(i);
path.push([x, y]);
river[i] += 0.44 + path.length / 160 + flowAccum[i] * 0.55;
accum += river[i] + flowAccum[i];
if (sea[i]) break;
let best = null;
let bestValue = INF;
const currentElevation = elevation[i];
const preferred = flowTo[i];
for (const [nx, ny] of neighbors8(x, y)) {
const ni = indexOf(nx, ny);
const dx = nx - x;
const dy = ny - y;
const drop = currentElevation - elevation[ni];
const uphill = Math.max(0, -drop);
if (!sea[ni] && uphill > 0.032 && flowAccum[ni] < flowAccum[i] + 0.018) continue;
let surrounding = 0;
let surroundingCount = 0;
for (const [vx, vy] of neighbors8(nx, ny)) {
surrounding += elevation[indexOf(vx, vy)];
surroundingCount++;
}
const valley = Math.max(0, surrounding / Math.max(1, surroundingCount) - elevation[ni]);
const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0;
const straightPenalty = Math.max(0, sameDirection) * 0.075;
const turnPenalty = sameDirection < -0.35 ? 0.24 : 0;
const sideSwing = Math.abs(dx * lastDy - dy * lastDx);
const meanderPhase = Math.sin((path.length + bonusSeed * 0.013) * 0.73) * 0.5 + 0.5;
const meander = sideSwing * (0.032 + meanderPhase * 0.026);
const flowBonus = ni === preferred ? 0.62 : 0;
const junctionPenalty = confluenceAnglePenalty(nx, ny, dx, dy, path.length);
const noise = (hash2(nx, ny, seed + bonusSeed + step * 11) - 0.5) * 0.04;
const value =
elevation[ni] * 1.45 +
uphill * 88 -
Math.max(0, drop) * 2.05 -
valley * 1.05 -
valleyField[ni] * 1.72 -
flowAccum[ni] * 0.94 -
moisture[ni] * 0.14 -
coastalLowland[ni] * 0.28 -
(river[ni] > 0 ? 0.22 : 0) -
flowBonus +
slope[ni] * 0.04 +
straightPenalty +
turnPenalty +
junctionPenalty * 1.35 -
meander +
noise -
(sea[ni] ? 0.6 : 0);
if (value < bestValue) {
bestValue = value;
best = [nx, ny, dx, dy];
}
}
if (!best) break;
x = best[0];
y = best[1];
lastDx = best[2];
lastDy = best[3];
}
const forced = forceRiverToWater(path);
if (forced.length > path.length) {
for (const [rx, ry] of forced.slice(path.length)) {
const ri = indexOf(rx, ry);
river[ri] += 0.32 + flowAccum[ri] * 0.4;
accum += river[ri] + flowAccum[ri];
}
}
return { path: forced, accum };
}
function traceSmallStreamPath(startX, startY, bonusSeed = 0) {
let x = startX;
let y = startY;
let lastDx = 0;
let lastDy = 0;
const path = [];
const seen = new Set();
for (let step = 0; step < 160; step++) {
const i = indexOf(x, y);
if (seen.has(i)) break;
seen.add(i);
path.push([x, y]);
river[i] += 0.12 + flowAccum[i] * 0.18;
if ((river[i] > 0.48 && path.length > 5) || sea[i]) break;
let best = null;
let bestValue = INF;
for (const [nx, ny] of neighbors8(x, y)) {
const ni = indexOf(nx, ny);
const dx = nx - x;
const dy = ny - y;
const drop = elevation[i] - elevation[ni];
const sameDirection = lastDx || lastDy ? (dx * lastDx + dy * lastDy) / Math.max(0.001, Math.hypot(dx, dy) * Math.hypot(lastDx, lastDy)) : 0;
const value = elevation[ni] * 1.2 + Math.max(0, -drop) * 26 - Math.max(0, drop) * 1.4 - valleyField[ni] * 1.15 - flowAccum[ni] * 0.55 - moisture[ni] * 0.12 + Math.max(0, sameDirection) * 0.04 - Math.abs(dx * lastDy - dy * lastDx) * 0.018 + (hash2(nx, ny, seed + bonusSeed + step * 13) - 0.5) * 0.05;
if (value < bestValue) { bestValue = value; best = [nx, ny, dx, dy]; }
}
if (!best) break;
x = best[0];
y = best[1];
lastDx = best[2];
lastDy = best[3];
}
return path;
}
const riverPaths = [];
const riverScores = [];
for (const source of sources) {
const { path, accum } = traceRiverPath(source.x, source.y, 0);
if (path.length > 6) {
riverPaths.push(path);
riverScores.push(path.length + accum * 0.18);
}
}
const preliminaryMainRiverCells = new Set(riverPaths.slice().sort((a, b) => b.length - a.length).slice(0, 5).flatMap((path) => path.map(([x, y]) => `${x},${y}`)));
const tributarySources = pickEntities(sourceCandidates
.filter((p) => !preliminaryMainRiverCells.has(`${p.x},${p.y}`))
.map((p) => ({ ...p, score: p.score + flowAccum[indexOf(p.x, p.y)] * 0.75 + valleyField[indexOf(p.x, p.y)] * 0.24 })), {
max: 14 + Math.floor(rand(seed, 915) * 20),
minDistance: 6,
threshold: 0.45,
seed: seed + 916,
jitter: 0.02,
});
for (const source of tributarySources) {
const { path, accum } = traceRiverPath(source.x, source.y, 4000 + source.x * 7 + source.y * 11);
if (path.length > 8) {
riverPaths.push(path);
riverScores.push(path.length * 0.7 + accum * 0.12);
}
}
const streamPaths = [];
const streamSources = pickEntities(sourceCandidates
.map((p) => ({ ...p, score: valleyField[indexOf(p.x, p.y)] * 0.46 + flowAccum[indexOf(p.x, p.y)] * 0.36 + moisture[indexOf(p.x, p.y)] * 0.18 + hash2(p.x, p.y, seed + 918) * 0.05 }))
.filter((p) => p.score > 0.18), {
max: 22 + Math.floor(rand(seed, 919) * 20),
minDistance: 4,
threshold: 0.18,
seed: seed + 919,
jitter: 0.015,
});
for (const source of streamSources) {
const path = traceSmallStreamPath(source.x, source.y, 7000 + source.x * 5 + source.y * 17);
if (path.length > 4) streamPaths.push(path);
}
if (riverPaths.length === 0 && sourceCandidates.length > 0) {
const fallback = sourceCandidates.slice().sort((a, b) => b.score - a.score)[0];
let bestSea = null;
let bestSeaDist = INF;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
if (!sea[indexOf(x, y)]) continue;
const d = Math.hypot(x - fallback.x, y - fallback.y);
if (d < bestSeaDist) {
bestSeaDist = d;
bestSea = { x, y };
}
}
}
if (bestSea) {
const fallbackPath = aStar(fallback, bestSea, (x, y, cx, cy) => {
const i = indexOf(x, y);
const ci = indexOf(cx, cy);
if (sea[i]) return 0.25;
const uphill = Math.max(0, elevation[i] - elevation[ci]) * 24;
const downhill = Math.max(0, elevation[ci] - elevation[i]) * 1.8;
return Math.max(0.24, 1 + uphill + slope[i] * 0.7 + elevation[i] * 0.8 - downhill - Math.min(0.55, river[i] * 0.1));
});
if (fallbackPath.length > 6) {
let accum = 0;
for (const [x, y] of fallbackPath) {
const i = indexOf(x, y);
river[i] += 0.42;
accum += river[i];
}
riverPaths.push(fallbackPath);
riverScores.push(fallbackPath.length + accum * 0.18);
}
}
}
function sanitizeDownhillRiverPath(path, tolerance = 0.040) {
if (!path || path.length < 2) return path || [];
const out = [path[0]];
for (let k = 1; k < path.length; k++) {
const [px, py] = out[out.length - 1];
const [x, y] = path[k];
const pi = indexOf(px, py);
const i = indexOf(x, y);
if (!sea[i] && elevation[i] > elevation[pi] + tolerance) break;
out.push(path[k]);
if (sea[i]) break;
}
return out.length >= 2 ? out : [];
}
function trimMountainHeadwaters(path) {
if (!path || path.length < 4) return path || [];
let start = 0;
while (start < path.length - 3) {
const [x, y] = path[start];
const i = indexOf(x, y);
if (sea[i]) break;
if (elevation[i] <= 0.72 && (valleyField[i] >= 0.18 || flowAccum[i] >= 0.05)) break;
start++;
}
return path.slice(start);
}
for (let r = 0; r < riverPaths.length; r++) riverPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.032);
for (let r = riverPaths.length - 1; r >= 0; r--) if (riverPaths[r].length < 2) riverPaths.splice(r, 1);
for (let r = 0; r < streamPaths.length; r++) streamPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(streamPaths[r]), 0.026);
for (let r = streamPaths.length - 1; r >= 0; r--) if (streamPaths[r].length < 2) streamPaths.splice(r, 1);
river.fill(0);
for (const path of riverPaths) {
for (let k = 0; k < path.length; k++) {
const [x, y] = path[k];
const i = indexOf(x, y);
river[i] += 0.42 + k / 170 + flowAccum[i] * 0.55;
}
}
for (const path of streamPaths) {
for (let k = 0; k < path.length; k++) {
const [x, y] = path[k];
const i = indexOf(x, y);
river[i] += 0.11 + flowAccum[i] * 0.18;
}
}
const expandedRiver = new Float32Array(river);
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (river[i] <= 0) continue;
for (const [nx, ny] of neighbors8(x, y)) {
expandedRiver[indexOf(nx, ny)] = Math.max(expandedRiver[indexOf(nx, ny)], river[i] * 0.35);
}
}
}
river.set(expandedRiver);
// Second fluvial pass uses the actual traced river network. Main channels cut
// visible V-shaped valleys; lower reaches accumulate alluvial deposits.
const fluvialElevation = new Float32Array(elevation);
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (sea[i] || river[i] <= 0.02) continue;
const r = clamp(river[i] / 3.4);
const channelCut = clamp(Math.pow(r, 0.55) * (0.060 + slope[i] * 0.145 + ridgeField[i] * 0.038));
const valleyWiden = clamp(Math.pow(r, 0.72) * (0.020 + Math.max(0, elevation[i] - seaLevel) * 0.058 + valleyField[i] * 0.040));
const alluvium = clamp(Math.pow(r, 0.72) * (coastalLowland[i] * 0.030 + basinField[i] * 0.020 + (slope[i] < 0.10 ? 0.010 : 0)));
erosionField[i] = clamp(erosionField[i] + channelCut + valleyWiden);
depositionField[i] = clamp(depositionField[i] + alluvium);
fluvialElevation[i] = clamp(elevation[i] - channelCut - valleyWiden + alluvium, seaLevel + 0.005, 1);
valleyField[i] = clamp(valleyField[i] + r * 0.62 + channelCut * 6.4);
basinField[i] = clamp(basinField[i] + alluvium * 3.2);
}
}
// Lateral valley carving around the traced river network deepens valleys and
// makes ridge/valley contrast legible at the map scale.
for (const path of riverPaths) {
for (const [rx, ry] of path) {
const ri = indexOf(rx, ry);
const r = clamp(river[ri] / 3.0);
const radius = r > 0.48 ? 2 : 1;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const nx = rx + dx;
const ny = ry + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (sea[ni]) continue;
const d = Math.hypot(dx, dy);
if (d > radius || d === 0) continue;
const weight = (radius + 0.35 - d) / (radius + 0.35);
const carve = Math.max(0, weight) * (0.008 + r * 0.026) * Math.max(0.45, slope[ni] + 0.22);
fluvialElevation[ni] = clamp(fluvialElevation[ni] - carve, seaLevel + 0.005, 1);
erosionField[ni] = clamp(erosionField[ni] + carve * 3.0);
valleyField[ni] = clamp(valleyField[ni] + carve * 12.0);
}
}
}
}
// Restore rugged summit relief after strong river incision. This prevents highlands
// from becoming unnaturally flat or visually concave while keeping valleys cut.
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const high = clamp((fluvialElevation[i] - 0.62) / 0.26);
const summit = high * clamp(ridgeField[i] * 1.4 - flowAccum[i] * 0.8);
const rugged = (valueNoise(x * 2.1 + 19, y * 2.1 - 23, seed + 9661, 3.2) - 0.5) * 0.035;
const uplift = summit * (0.018 + Math.max(0, rugged));
if (uplift > 0) {
fluvialElevation[i] = clamp(fluvialElevation[i] + uplift, seaLevel + 0.005, 1);
erosionField[i] = Math.max(0, erosionField[i] - uplift * 0.6);
}
}
}
elevation.set(fluvialElevation);
// Broad alluvial/coastal/basin plains. The plain score alone is not enough;
// the elevation surface must also be locally calm, otherwise every lowland
// still reads as rugged terrain. Smooth only low, wet depositional cells and
// leave ridges/headwaters untouched.
for (let pass = 0; pass < 4; pass++) {
const nextElevation = new Float32Array(elevation);
for (let y = 2; y < MAP_H - 2; y++) {
for (let x = 2; x < MAP_W - 2; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const lowland = clamp(
coastalLowland[i] * 0.72 +
basinField[i] * 0.54 +
valleyField[i] * 0.34 +
Math.pow(flowAccum[i], 0.58) * 0.24 -
ridgeField[i] * 0.62 -
Math.max(0, elevation[i] - 0.54) * 1.65 -
slope[i] * 0.74
);
if (lowland <= 0.12) continue;
let sum = 0;
let weight = 0;
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
const nx = x + dx;
const ny = y + dy;
const ni = indexOf(nx, ny);
if (sea[ni]) continue;
const d = Math.hypot(dx, dy);
if (d > 2.25) continue;
const compatible = clamp(1 - Math.abs(elevation[ni] - elevation[i]) / 0.11);
const w = compatible / (1 + d);
sum += elevation[ni] * w;
weight += w;
}
}
if (weight <= 0) continue;
const localMean = sum / weight;
const terrace = Math.round(localMean * 42) / 42;
const target = lerp(localMean, terrace, 0.28);
nextElevation[i] = clamp(lerp(elevation[i], target, lowland * 0.42), seaLevel + 0.006, 1);
if (lowland > 0.55) {
depositionField[i] = clamp(depositionField[i] + lowland * 0.018);
erosionField[i] = Math.max(0, erosionField[i] - lowland * 0.012);
}
}
}
elevation.set(nextElevation);
}
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const gx = elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)];
const gy = elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)];
slope[i] = clamp(Math.sqrt(gx * gx + gy * gy) * 11.2);
}
}
// Re-trim visible river paths after fluvial reshaping changes local elevation.
for (let r = 0; r < riverPaths.length; r++) riverPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(riverPaths[r]), 0.028);
for (let r = riverPaths.length - 1; r >= 0; r--) if (riverPaths[r].length < 2) riverPaths.splice(r, 1);
for (let r = 0; r < streamPaths.length; r++) streamPaths[r] = sanitizeDownhillRiverPath(trimMountainHeadwaters(streamPaths[r]), 0.022);
for (let r = streamPaths.length - 1; r >= 0; r--) if (streamPaths[r].length < 2) streamPaths.splice(r, 1);
const mainRivers = riverPaths
.map((p, i) => ({ path: p, score: riverScores[i] }))
.sort((a, b) => b.score - a.score)
.slice(0, Math.min(6, riverPaths.length))
.map((x) => x.path);
if (mainRivers.length === 0 && riverPaths.length > 0) mainRivers.push(riverPaths[0]);
if (mainRivers.length === 0) {
let start = null;
let startScore = -INF;
for (let y = 4; y < MAP_H - 4; y++) {
for (let x = 4; x < MAP_W - 4; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const score = elevation[i] * 0.55 + moisture[i] * 0.35 - slope[i] * 0.15;
if (score > startScore) {
startScore = score;
start = { x, y };
}
}
}
if (start) {
let goal = null;
let goalDist = INF;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
if (!sea[indexOf(x, y)]) continue;
const d = Math.hypot(x - start.x, y - start.y);
if (d < goalDist) {
goalDist = d;
goal = { x, y };
}
}
}
if (goal) {
const fallbackPath = aStar(start, goal, (x, y, cx, cy) => {
const i = indexOf(x, y);
const ci = indexOf(cx, cy);
if (sea[i]) return 0.2;
const uphillBias = Math.max(0, elevation[i] - elevation[ci]) * 22;
const downhillBias = Math.max(0, elevation[ci] - elevation[i]) * 1.7;
return Math.max(0.25, 1 + uphillBias + slope[i] * 0.65 + elevation[i] * 0.8 - downhillBias);
});
if (fallbackPath.length > 4) {
riverPaths.push(fallbackPath);
mainRivers.push(fallbackPath);
for (const [x, y] of fallbackPath) river[indexOf(x, y)] += 0.4;
}
}
}
}
const mainRiverCells = new Set(mainRivers.flatMap((path) => path.map(([x, y]) => `${x},${y}`)));
const tributaryRivers = riverPaths.filter((path) => path.some(([x, y]) => !mainRiverCells.has(`${x},${y}`)) && !mainRivers.includes(path));
const smallStreams = streamPaths.filter((path) => path.length >= 5);
prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river);
prefectureBorder = extractMaskBorder(prefectureMask, sea);
const regionalPrefectures = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask);
const prefectureRegionId = regionalPrefectures.regionId;
const regionalDebug = regionalPrefectures.debug;
const regionalPrefectureBorders = extractRegionBorderSegments(prefectureRegionId, sea);
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const low = 1 - clamp((elevation[i] - 0.28) / 0.4);
const flat = 1 - slope[i];
const valleyPlain = valleyField[i] * 0.44 + basinField[i] * 0.36 + coastalLowland[i] * 0.55;
plain[i] = clamp(low * 0.44 + flat * 0.58 + valleyPlain - ridgeField[i] * 0.28 - (elevation[i] > 0.62 ? 0.48 : 0));
let nearRiver = 0;
for (let dy = -4; dy <= 4; dy++) {
for (let dx = -4; dx <= 4; dx++) {
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
nearRiver = Math.max(nearRiver, river[indexOf(nx, ny)] / (1 + Math.hypot(dx, dy)));
}
}
const fan = clamp(valleyField[i] * (1 - coastalLowland[i]) * (elevation[i] > 0.34 && elevation[i] < 0.58 ? 0.9 : 0.35) * (1 - slope[i] * 0.55));
floodplain[i] = clamp(nearRiver * plain[i] * 0.92 + coastalLowland[i] * nearRiver * 0.22);
agriculture[i] = clamp(plain[i] * 0.58 + fan * 0.26 + basinField[i] * 0.2 + moisture[i] * 0.14 + clamp(nearRiver) * 0.32 - slope[i] * 0.34 - ridgeField[i] * 0.18 - floodplain[i] * 0.06);
}
}
for (let y = 2; y < MAP_H - 2; y++) {
for (let x = 2; x < MAP_W - 2; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
let seaNear = 0;
let riverNear = 0;
let sheltered = 0;
for (let dy = -5; dy <= 5; dy++) {
for (let dx = -5; dx <= 5; dx++) {
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
const d = Math.hypot(dx, dy);
if (sea[indexOf(nx, ny)]) seaNear += 1 / (1 + d);
riverNear = Math.max(riverNear, river[indexOf(nx, ny)] / (1 + d));
}
}
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
const nx = x + dx;
const ny = y + dy;
if (inside(nx, ny) && !sea[indexOf(nx, ny)]) sheltered += 1;
}
}
const isDelta = riverNear > 0.22 && coastalLowland[i] > 0.18;
const bayShelter = sheltered * 0.012 + seaNear * 0.055 + coastalLowland[i] * 0.16;
portSuitability[i] = clamp(bayShelter + riverNear * 0.24 + (isDelta ? 0.22 : 0) + plain[i] * 0.08 - slope[i] * 0.48 - ridgeField[i] * 0.16);
}
}
for (let y = 3; y < MAP_H - 3; y++) {
for (let x = 3; x < MAP_W - 3; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const r = river[i];
if (r < 0.2 || r > 1.85) continue;
let bankPlain = 0;
for (const [nx, ny] of neighbors8(x, y)) bankPlain += plain[indexOf(nx, ny)];
crossingSuitability[i] = clamp(r * 0.34 + (bankPlain / 8) * 0.54 + valleyField[i] * 0.18 - slope[i] * 0.55 - floodplain[i] * 0.06);
}
}
for (let y = 4; y < MAP_H - 4; y++) {
for (let x = 4; x < MAP_W - 4; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const e = elevation[i];
if (e < 0.43 || e > 0.82) continue;
const ewHigh = (elevation[indexOf(x - 3, y)] + elevation[indexOf(x + 3, y)]) / 2;
const nsHigh = (elevation[indexOf(x, y - 3)] + elevation[indexOf(x, y + 3)]) / 2;
const diagLow = Math.min(
elevation[indexOf(x - 3, y - 3)],
elevation[indexOf(x + 3, y + 3)],
elevation[indexOf(x - 3, y + 3)],
elevation[indexOf(x + 3, y - 3)]
);
passSuitability[i] = clamp((Math.max(ewHigh, nsHigh) - e) * 2.2 + (e - diagLow) * 0.55 + valleyField[i] * 0.28 - ridgeField[i] * 0.18 - slope[i] * 0.2);
}
}
return {
elevation,
moisture,
slope,
sea,
river,
floodplain,
plain,
agriculture,
ridgeField,
valleyField,
basinField,
coastalLowland,
flowAccum,
erosionField,
depositionField,
portSuitability,
crossingSuitability,
passSuitability,
prefectureMask,
prefectureBorder,
prefectureRegionId,
regionalDebug,
regionalPrefectureBorders,
riverPaths,
mainRivers,
tributaryRivers,
smallStreams,
};
}

View file

@ -2,26 +2,28 @@ import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js";
export const NAME_KANJI_POOLS = {
modifiers: [
"大", "小", "上", "下", "中",
"大", "小", "上", "下", "中", "奥",
"東", "西", "南", "北",
"新", "古", "本", "元",
"高", "長", "広", "深", "浅",
"白", "黒", "青", "赤",
"奥", "前", "後", "内", "外",
"早", "早", "真", "丸", "平"
"早", "安", "真", "丸", "平",
"美",
"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万",
],
inlandTerrain: [
"山", "谷", "沢", "原", "野",
"森", "林", "岡", "丘", "坂",
"峰", "峠", "嶺", "尾", "平",
"窪", "久", "洞", "迫", "",
"塚", "牧", "畑", "田", "森",
"", "郷", "里"
"窪", "久", "洞", "迫", "久保", "玖保", "漥", "佐古", "作古", "峪",
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦",
"", "郷", "里"
],
waterTerrain: [
"川", "河", "江", "瀬", "淵",
"川", "河", "江", "瀬", "淵", "渕",
"池", "沼", "泉", "井", "湖",
"滝", "渓", "沢", "谷", "津",
"水", "清", "渡", "橋", "堀",
@ -29,11 +31,11 @@ export const NAME_KANJI_POOLS = {
],
coastalTerrain: [
"浜", "浦", "津", "崎", "岬",
"島", "磯", "潟", "湊", "",
"", "洲", "瀬", "砂", "潮",
"浜", "浦", "津", "崎",
"島", "磯", "潟", "湊", "",
"", "洲", "瀬", "砂", "潮",
"泊", "江", "浦", "灘", "入",
"湾", "戸", "門"
"戸", "門"
],
plants: [
@ -53,7 +55,7 @@ export const NAME_KANJI_POOLS = {
"辺", "里", "郷", "村", "町",
"宿", "庄", "台", "坂", "橋",
"本", "内", "窪", "平", "塚",
"畑", "牧", "前", "", "中"
"畑", "牧", "前", "", "中"
],
archaicPrefixes: [
@ -64,7 +66,7 @@ export const NAME_KANJI_POOLS = {
"甲", "信", "越", "備", "讃",
"薩", "隠", "美", "三", "若",
"遠", "近", "能", "加", "賀",
"越", "淡", "壱", ""
"越", "淡", "壱", ""
],
archaicSuffixes: [
@ -75,7 +77,7 @@ export const NAME_KANJI_POOLS = {
"伊", "前", "中", "後", "波",
"勢", "渡", "城", "紫", "野",
"津", "島", "海", "登", "賀",
"良", "美", "智", "", "代"
"良", "美", "智", "", "代"
],
settlementWords: [

View file

@ -118,6 +118,19 @@ function discreteColor(map, x, y, mode) {
];
const a = map.adminId[i];
color = a >= 0 ? palette[a % palette.length] : [220, 225, 220];
} else if (mode === "admin-debug" || mode === "borders-debug") {
const barrier = clamp(
map.ridgeField[i] * 0.88 +
Math.max(0, map.river[i] - 0.28) * 1.25 +
Math.max(0, map.flowAccum[i] - 0.36) * 0.72 +
map.slope[i] * 0.48 +
Math.max(0, map.elevation[i] - 0.54) * 0.34
);
color = [
Math.round(238 - barrier * 28),
Math.round(242 - barrier * 88),
Math.round(226 + barrier * 20),
];
} else {
color = terrainColorContinuous(map, x, y, "terrain");
}
@ -465,7 +478,8 @@ export function drawMap(canvas, map, options) {
for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.2);
drawHarborWorks(ctx, map);
if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, mode === "all" ? "rgba(95,95,95,0.18)" : "rgba(95,95,95,0.30)", 1.0, false, mode === "all");
const debugBorders = mode === "admin-debug" || mode === "borders-debug";
if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, debugBorders ? "rgba(40,40,40,0.82)" : mode === "all" ? "rgba(95,95,95,0.18)" : "rgba(95,95,95,0.30)", debugBorders ? 1.8 : 1.0, false, mode === "all");
drawSegments(ctx, map.prefectureBorder, "rgba(30,30,30,0.82)", 2.4, false, true);
drawSegments(ctx, map.prefectureBorder, "rgba(255,255,255,0.74)", 1.05, false, true);
@ -474,9 +488,9 @@ export function drawMap(canvas, map, options) {
const showHistory = ["history", "all", "terrain", "suitability"].includes(mode);
const showModern = ["modern", "all", "development", "landuse"].includes(mode);
const showRoads = ["roads", "all", "development", "landuse"].includes(mode);
const showAdmin = ["admin", "all"].includes(mode);
const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode);
if (showAdmin) drawSegments(ctx, map.adminBorders, mode === "all" ? "rgba(120,120,120,0.28)" : "rgba(120,120,120,0.65)", mode === "all" ? 0.9 : 1.3);
if (showAdmin) drawSegments(ctx, map.adminBorders, debugBorders ? "rgba(20,90,180,0.90)" : mode === "all" ? "rgba(120,120,120,0.28)" : "rgba(120,120,120,0.65)", debugBorders ? 1.5 : mode === "all" ? 0.9 : 1.3);
if (showHistory) {
for (const path of map.premodernRoads) drawPath(ctx, path, mode === "all" ? "rgba(150, 120, 90, 0.34)" : "rgba(150, 120, 90, 0.55)", mode === "all" ? 1.15 : 1.45, true);

110
test.js
View file

@ -7,6 +7,7 @@ import {
NAME_PROBABILITIES,
NAME_TEMPLATES,
NAME_TEMPLATE_WEIGHTS,
generateEntityName,
generateTemplateName,
} from "./names.js";
@ -168,6 +169,56 @@ function majorCityCoreIntegrity(map) {
return checked ? sum / checked : 1;
}
function satelliteMunicipalityMetrics(map) {
const areaById = new Map();
for (let i = 0; i < map.adminId.length; i++) {
if (map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0) areaById.set(map.adminId[i], (areaById.get(map.adminId[i]) || 0) + 1);
}
const rows = (map.satelliteCities || [])
.filter((sat) => map.prefectureMask[indexOf(sat.x, sat.y)] && !map.sea[indexOf(sat.x, sat.y)])
.map((sat) => {
const admin = map.adminId[indexOf(sat.x, sat.y)];
return { sat, admin, area: areaById.get(admin) || 0 };
});
const independent = rows.filter((row) => row.sat.municipalityClass === "independentSatelliteMunicipality");
const small = independent.filter((row) => row.area < 80);
const largeTooSmall = rows.filter((row) => (row.sat.population || 0) >= 60000 && row.sat.municipalityClass === "independentSatelliteMunicipality" && row.area < 120);
const average = independent.length ? independent.reduce((sum, row) => sum + row.area, 0) / independent.length : 0;
return { rows, independent, small, largeTooSmall, average };
}
function regionalComponentMetrics(map) {
const ids = new Set([...map.prefectureRegionId].filter((id, i) => id >= 0 && !map.sea[i]));
const seen = new Uint8Array(MAP_W * MAP_H);
let maxComponents = 0;
for (const id of ids) {
seen.fill(0);
let comps = 0;
for (let i = 0; i < map.prefectureRegionId.length; i++) {
if (seen[i] || map.sea[i] || map.prefectureRegionId[i] !== id) continue;
comps++;
const queue = [i];
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const x = cur % MAP_W;
const y = Math.floor(cur / MAP_W);
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
const ni = indexOf(nx, ny);
if (seen[ni] || map.sea[ni] || map.prefectureRegionId[ni] !== id) continue;
seen[ni] = 1;
queue.push(ni);
}
}
}
maxComponents = Math.max(maxComponents, comps);
}
return { regionCount: ids.size, maxComponents };
}
try {
const map = generateMap(12345);
const other = generateMap(54321);
@ -282,6 +333,8 @@ try {
: 1;
const adminMetrics = adminBoundaryMetrics(map);
const cityCoreIntegrity = majorCityCoreIntegrity(map);
const satelliteMetrics = satelliteMunicipalityMetrics(map);
const regionalMetrics = regionalComponentMetrics(map);
assert(NAME_KANJI_POOLS && Array.isArray(NAME_KANJI_POOLS.modifiers), "NAME_KANJI_POOLS exists");
assert(NAME_TEMPLATES && NAME_TEMPLATES.modifierTerrain?.slots?.length === 2, "NAME_TEMPLATES exists");
@ -290,7 +343,7 @@ try {
const removedContextModule = "placeName" + "Context.js";
assert(!namesSource.includes(removedContextModule) && !mapGeneratorSource.includes(removedContextModule) && !testSource.includes(removedContextModule), "removed name-context import is absent");
assert(Object.keys(NAME_KANJI_POOLS).every((key) => Array.isArray(NAME_KANJI_POOLS[key])), "name category pools are centralized arrays");
assert(Object.values(NAME_KANJI_POOLS).every((pool) => pool.length === 0), "default name category pools are empty");
assert(Object.values(NAME_KANJI_POOLS).every((pool) => pool.every((part) => typeof part === "string" && !part.includes("\uFFFD"))), "configured name category pools contain valid strings");
assert(Object.keys(NAME_PARTS).length === 0, "legacy NAME_PARTS has no hidden candidates");
const removedContextSuffixConst = "CONTEXT" + "_SUFFIXES";
const removedContextSuffixKey = "context" + "Suffixes";
@ -309,6 +362,12 @@ try {
assert(map.settlementCluster.length === size, "settlement cluster field matches map size");
assert(Array.isArray(map.bridges) && Array.isArray(map.tunnels) && Array.isArray(map.harborWorks), "legacy bridge/tunnel arrays and harbor arrays exist");
assert(map.prefectureRegionId.length === size && Array.isArray(map.regionalPrefectureBorders), "neighbor prefecture regions exist");
assert(map.regionalDebug && Number.isFinite(map.regionalDebug.regionalChangedAfterNaturalPartition), "regional changed-cell debug exists");
assert(map.regionalDebug.regionalChangedAfterNaturalPartition > 0, "regional natural partition changes region cells");
assert(map.regionalDebug.regionalBorderCountBefore > 0 && map.regionalDebug.regionalBorderCountAfter > 0, "regional border counts are tracked");
assert(map.regionalDebug.regionalNaturalBarrierAverageAfter >= map.regionalDebug.regionalNaturalBarrierAverageBefore - 0.08, "regional border natural-barrier affinity does not degrade meaningfully");
assert(map.regionalDebug.regionalVoronoiLikeRateAfter <= map.regionalDebug.regionalVoronoiLikeRateBefore + 0.22, "regional weak Voronoi-like border rate stays bounded");
assert(regionalMetrics.regionCount >= 4 && regionalMetrics.maxComponents <= 5, "regional prefecture regions remain connected enough for display");
assert(Array.isArray(map.tributaryRivers) && Array.isArray(map.smallStreams), "river hierarchy arrays exist");
assert(Array.isArray(map.icAccessRoads), "IC access road array exists");
assert(Array.isArray(map.satelliteCities), "satelliteCities is an array");
@ -360,6 +419,18 @@ try {
assert(adminMetrics.maxComponents <= 4, "municipal topology repair prevents excessive disconnected fragments");
assert(adminMetrics.disconnectedMunicipalities <= Math.max(2, Math.ceil(adminMetrics.municipalityCount * 0.20)), "most municipalities remain connected after terrain snapping");
assert(adminMetrics.avgTarget > 0.18, "admin borders align with terrain target features often enough");
assert(map.adminDebug && map.adminDebug.compartmentCount > 0, "natural compartment debug is available");
assert(map.adminDebug.averageCompartmentArea > 0, "natural compartments have positive average area");
assert(Number.isFinite(map.adminDebug.changedAfterLandscapePartition) && Number.isFinite(map.adminDebug.changedAfterSnap), "municipal changed-cell diagnostics exist");
assert(map.adminDebug.changedAfterLandscapePartition > 0 || map.adminDebug.changedAfterSnap > 0, "municipal terrain partition or snap changes admin cells");
assert(map.adminDebug.changedAfterFinalExclaveRemoval + map.adminDebug.changedAfterFinalMerge < Math.max(2800, (map.adminDebug.changedAfterLandscapePartition + map.adminDebug.changedAfterSnap + map.adminDebug.changedAfterUrbanLock) * 1.35), "final municipal repair does not erase most terrain and urban changes");
assert(map.adminDebug.finalBorderNaturalBarrierAverage >= 0, "natural barrier score is tracked along final borders");
assert(map.adminDebug.voronoiLikeRateAfter <= Math.max(0.72, map.adminDebug.voronoiLikeRateBefore + 0.20), "natural compartment pass does not increase weak bisectors excessively");
assert(Number.isFinite(map.adminDebug.satelliteMunicipalitiesCreated) && Number.isFinite(map.adminDebug.averageSatelliteMunicipalityArea), "satellite municipality debug is available");
assert(satelliteMetrics.independent.length < 3 || satelliteMetrics.small.length / satelliteMetrics.independent.length <= 0.35, "tiny independent satellite municipalities are not the dominant pattern");
assert(satelliteMetrics.largeTooSmall.length === 0, "large independent satellites have meaningful municipal area");
assert(satelliteMetrics.independent.length < 3 || satelliteMetrics.average >= 140, "average independent satellite municipality area is meaningful");
assert(satelliteMetrics.rows.every((row) => row.area >= 80 || row.sat.municipalityClass === "smallTownAttachedToRuralMunicipality" || row.sat.municipalityClass === "suburbanDistrictMergedWithParent" || row.sat.municipalityClass === "newTownDistrict"), "tiny satellite areas are merged or explicitly classified as attached districts");
assert(adminMetrics.denseUrbanRate < 0.42, "admin borders avoid excessive dense urban crossings");
assert(adminMetrics.rightAngleRate < 0.46, "admin borders avoid excessive unsupported stair-step artifacts");
assert(adminMetrics.voronoiLikeRate < 0.58, "admin borders are not dominated by weak-terrain center bisectors");
@ -388,15 +459,14 @@ try {
assert(map.entitiesForNames.every((item) => String(item.name).length > 0), "empty generated names are prevented");
assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low");
assert(map.nameDebug && Array.isArray(map.nameDebug.emptyPools), "nameDebug reports empty pools");
assert(map.nameDebug.emptyPools.length === Object.keys(NAME_KANJI_POOLS).length, "empty default pools are visible in nameDebug");
assert(map.nameDebug.emptyPools.length === Object.values(NAME_KANJI_POOLS).filter((pool) => pool.length === 0).length, "nameDebug empty pools match configured pools");
assert(map.nameDebug.selectedTemplateCounts && typeof map.nameDebug.selectedTemplateCounts === "object", "nameDebug selectedTemplateCounts exists");
assert(map.nameDebug.selectedContextCounts && typeof map.nameDebug.selectedContextCounts === "object", "nameDebug selectedContextCounts exists");
assert(
map.nameDebug.generatedNamesUsed + map.nameDebug.customNamesUsed + map.nameDebug.forcedNamesUsed + map.nameDebug.fallbackAttempts === namedEntityCount,
"nameDebug accounting covers named entities"
);
assert(generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "empty pools do not use hidden fallback candidates");
assert(activePoolChars.size === 0, "no active pool characters exist until configured");
assert(activePoolChars.size > 0 || generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "template generation depends on active pools");
assert(villageClusterMean > 0.16, "villages prefer clustered valley, basin, coastal, and agricultural cells");
assert(saneEndpointRatio >= 0.76, "transport endpoints stay near meaningful generated nodes");
assert(Object.keys(CUSTOM_NAMES).length === 0 || NAME_PROBABILITIES.customName < 1, "CUSTOM_NAMES are probabilistic by default");
@ -414,6 +484,15 @@ try {
assert(JSON.stringify(againA.entitiesForNames.map((item) => [item.id, item.name])) === JSON.stringify(againB.entitiesForNames.map((item) => [item.id, item.name])), "generated names are deterministic for the same seed");
assert(JSON.stringify(againA.adminCenters.map((item) => [item.id, item.x, item.y, item.name])) === JSON.stringify(againB.adminCenters.map((item) => [item.id, item.x, item.y, item.name])), "municipal centers are deterministic for the same seed");
assert(JSON.stringify([...againA.adminId]) === JSON.stringify([...againB.adminId]), "municipal adminId snapping is deterministic for the same seed");
assert(JSON.stringify([...againA.prefectureRegionId]) === JSON.stringify([...againB.prefectureRegionId]), "regional prefecture ids are deterministic for the same seed");
assert(JSON.stringify(againA.adminDebug) === JSON.stringify(againB.adminDebug), "admin debug metrics are deterministic for the same seed");
assert(JSON.stringify(againA.regionalDebug) === JSON.stringify(againB.regionalDebug), "regional debug metrics are deterministic for the same seed");
const blockedCapitalName = "\u52A0\u8302";
const capitalNameMaps = [114514, 12345, 54321, 777, 999].map((seedValue) => generateMap(seedValue));
const capitalNames = capitalNameMaps.map((seeded) => seeded.prefecturalCapital?.name).filter(Boolean);
assert(new Set(capitalNames).size > 1, "prefectural capital names vary across seeds");
assert(capitalNames.some((name) => name !== blockedCapitalName), "prefectural capital is not always the repeated custom name");
CUSTOM_NAMES["city-0"] = "C1";
const customSameA = generateMap(321);
@ -427,12 +506,35 @@ try {
assert(!FORCED_NAMES["city-0"] && customTargets.length > 0 && customHits < customTargets.length, "CUSTOM_NAMES do not force every seed");
delete CUSTOM_NAMES["city-0"];
CUSTOM_NAMES["custom-probe"] = "C1";
const directCustomNames = Array.from({ length: 40 }, (_, n) => generateEntityName(9000 + n, "custom-probe", { x: 10, y: 10, kind: "Probe" }, {}, new Set()));
const directCustomHits = directCustomNames.filter((name) => name === "C1").length;
assert(NAME_PROBABILITIES.customName > 0 && NAME_PROBABILITIES.customName < 1 && directCustomHits > 0 && directCustomHits < directCustomNames.length, "CUSTOM_NAMES are probabilistic suggestions");
delete CUSTOM_NAMES["custom-probe"];
FORCED_NAMES["forced-probe"] = "F1";
assert(generateEntityName(123, "forced-probe", { x: 8, y: 8, kind: "Probe" }, {}, new Set(), map.nameDebug) === "F1", "FORCED_NAMES always apply");
delete FORCED_NAMES["forced-probe"];
for (const seed of [101, 2026, 54321]) {
const seeded = generateMap(seed);
const metrics = adminBoundaryMetrics(seeded);
const seededRegional = regionalComponentMetrics(seeded);
const seededSatellites = satelliteMunicipalityMetrics(seeded);
const invalidLandCells = [...seeded.adminId].filter((id, i) => seeded.prefectureMask[i] && !seeded.sea[i] && id < 0).length;
const invalidRegionCells = [...seeded.prefectureRegionId].filter((id, i) => !seeded.sea[i] && id < 0).length;
assert(invalidLandCells === 0, `seed ${seed}: every prefecture land cell has a valid adminId`);
assert(invalidRegionCells === 0, `seed ${seed}: every regional land cell has a valid regionId`);
assert(seeded.adminBorders.length > 0, `seed ${seed}: municipal borders exist`);
assert(seeded.regionalPrefectureBorders.length > 0, `seed ${seed}: regional prefecture borders exist`);
assert(seeded.regionalDebug?.regionalChangedAfterNaturalPartition > 0, `seed ${seed}: regional natural partition changes cells`);
assert(seeded.regionalDebug.regionalNaturalBarrierAverageAfter >= seeded.regionalDebug.regionalNaturalBarrierAverageBefore - 0.10, `seed ${seed}: regional border natural affinity is stable`);
assert(seeded.regionalDebug.regionalVoronoiLikeRateAfter <= seeded.regionalDebug.regionalVoronoiLikeRateBefore + 0.25, `seed ${seed}: regional Voronoi-like rate is bounded`);
assert(seededRegional.maxComponents <= 5, `seed ${seed}: regional regions remain connected enough`);
assert(seeded.adminDebug && seeded.adminDebug.compartmentCount > 0, `seed ${seed}: natural compartments are built`);
assert(seeded.adminDebug.changedAfterLandscapePartition > 0 || seeded.adminDebug.changedAfterSnap > 0, `seed ${seed}: municipal terrain passes change cells`);
assert(seededSatellites.largeTooSmall.length === 0, `seed ${seed}: large satellites are not tiny independent municipalities`);
assert(seededSatellites.independent.length < 3 || seededSatellites.small.length / seededSatellites.independent.length <= 0.35, `seed ${seed}: tiny satellite municipalities remain uncommon`);
assert(metrics.municipalityCount >= 8, `seed ${seed}: municipality count remains reasonable`);
assert(metrics.centerValidRatio >= 0.90, `seed ${seed}: municipality centers remain valid`);
assert(metrics.maxComponents <= 5, `seed ${seed}: topology repair limits disconnected fragments`);