This commit is contained in:
33333-33333 2026-05-21 22:03:14 +09:00
commit 3fe9c31453
12 changed files with 963 additions and 127 deletions

View file

@ -507,6 +507,9 @@ export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope,
const score = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i]) continue;
const [x, y] = xyOf(i);
let coastEdge = 0;
for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coastEdge = 1;
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);
@ -520,6 +523,7 @@ export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope,
majorRiver * 0.86 +
basinRim * 0.54 +
foothillBreak * 0.48 +
coastEdge * 0.34 +
terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity || score, landuse || score) * 0.38 -
livingCorridor * 0.50 -
urbanContinuity * 0.72
@ -739,6 +743,125 @@ function averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierS
return count ? sum / count : 0;
}
function assignCompartmentsToAdminOwners(compartments, compartmentId, adminCenters, naturalBarrierScore, prefectureMask, sea) {
const owner = new Int16Array(compartments.length);
owner.fill(-1);
for (let id = 0; id < adminCenters.length; id++) {
const center = adminCenters[id];
if (!center || !inside(center.x, center.y)) continue;
const compIndex = compartmentId[indexOf(center.x, center.y)];
if (compIndex >= 0 && compartments[compIndex]?.area > 0) {
const unit = compartments[compIndex];
unit.centerIds.push(id);
owner[compIndex] = id;
}
}
for (let pass = 0; pass < compartments.length + 8; 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 center = adminCenters[neighborOwner];
const d = center ? Math.hypot(unit.x - center.x, unit.y - center.y) : 0;
const score = naturalOwnershipAffinity(unit, neighbor, edge) - d * 0.006 + Math.min(0.9, Math.sqrt(Math.max(1, neighbor.area)) * 0.020);
if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; }
}
const accept = unit.classId <= 3 ? bestScore > -0.35 : unit.classId === 8 || unit.classId === 9 ? bestScore > -1.05 : bestScore > -0.70;
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;
let 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.3 : 0;
const sameClass = centerComp && centerComp.classId === unit.classId ? 0.8 : 0;
const urbanFit = unit.urbanWeight > 0.55 && centerComp?.urbanWeight > 0.55 ? 1.3 : 0;
const d = Math.hypot(unit.x - center.x, unit.y - center.y);
const score = sameGroup + sameClass + urbanFit - d * 0.020 - unit.ridgeExposure * 0.16;
if (score > bestScore) { bestScore = score; bestId = id; }
}
owner[unit.id] = bestId >= 0 ? bestId : 0;
}
return owner;
}
export function extractCompartmentBorders(compartmentId, prefectureMask, 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 (!prefectureMask[i] || sea[i] || compartmentId[i] < 0) continue;
const a = compartmentId[i];
if (x + 1 < MAP_W) {
const ni = indexOf(x + 1, y);
if (prefectureMask[ni] && !sea[ni] && compartmentId[ni] >= 0 && compartmentId[ni] !== a) segments.push([[x + 1, y], [x + 1, y + 1]]);
}
if (y + 1 < MAP_H) {
const ni = indexOf(x, y + 1);
if (prefectureMask[ni] && !sea[ni] && compartmentId[ni] >= 0 && compartmentId[ni] !== a) segments.push([[x, y + 1], [x + 1, y + 1]]);
}
}
}
return segments;
}
export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) {
const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse);
const adminId = new Int16Array(SIZE);
adminId.fill(-1);
const owner = assignCompartmentsToAdminOwners(compartments, compartmentId, adminCenters, naturalBarrierScore, prefectureMask, sea);
for (const unit of compartments) {
const assigned = owner[unit.id];
if (assigned < 0) continue;
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;
const i = indexOf(center.x, center.y);
if (prefectureMask[i] && !sea[i]) adminId[i] = id;
}
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse);
const activeCompartments = compartments.filter((unit) => unit.area > 0);
return {
adminId,
compartmentId,
compartments,
naturalBarrierScore,
debug: {
naturalCompartmentCount: activeCompartments.length,
compartmentCount: activeCompartments.length,
compartmentBorders: extractCompartmentBorders(compartmentId, prefectureMask, sea),
averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0,
finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore),
voronoiLikeRateBefore: 0,
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),
},
};
}
function weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore) {
let weak = 0;
let total = 0;
@ -973,3 +1096,7 @@ export function splitOversizedRuralMunicipalities(adminId, prefectureMask, sea,
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++;
return { changedCells, splitMunicipalities };
}
export function splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], settlements = []) {
return splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters, settlements);
}

1
app.js
View file

@ -11,6 +11,7 @@ const modes = [
["development", "Development"],
["landuse", "Land Use"],
["admin", "Municipal Borders"],
["terrain-debug", "Terrain Debug"],
["admin-debug", "Admin Debug"],
["borders-debug", "Borders Debug"],
];

View file

@ -1,11 +1,11 @@
import {
applyLandscapeUnitAdminPartition,
generateAdminRegions,
assignAdminRegionsFromNaturalCompartments,
lockSmallUrbanComponentsToMunicipality,
mergeTinyMunicipalities,
removeMunicipalExclaves,
smoothAdminRegionsTerrainAware,
splitOversizedRuralMunicipalities,
splitOversizedLowlandMunicipalities,
snapAdminBoundariesToTerrain,
} from "./adminRegions.js";
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, xyOf } from "./mapUtils.js";
@ -26,6 +26,34 @@ function municipalityAreaById(adminId, prefectureMask, sea) {
return area;
}
function computeTargetMunicipalityCount({ prefectureMask, sea, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }) {
let landCells = 0;
let habitableCells = 0;
let coastlineComplexity = 0;
let mountainCells = 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]) continue;
landCells++;
if (slope[i] < 0.42 && ridgeField[i] < 0.55) habitableCells++;
if (ridgeField[i] > 0.52 || slope[i] > 0.48) mountainCells++;
for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) {
const ni = indexOf(nx, ny);
if (sea[ni]) {
coastlineComplexity += 1 + coastalLowland[i] * 0.8;
break;
}
}
}
}
const independentSatellites = (satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality").length;
const settlementNodes = modernCities.length * 1.25 + markets.length * 0.9 + ports.length * 0.7 + independentSatellites * 0.8 + villages.length * 0.35;
const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520);
const mountainRatio = landCells ? mountainCells / landCells : 0;
return clamp(Math.round(habitableCells / 260 + settlementNodes * 0.45 + coastlineComplexity * 0.04 + basinBonus + mountainRatio * 4), 18, 48);
}
function estimateUrbanComponentArea(city, prefectureMask, sea, landuse, populationDensity) {
if (!city || !inside(city.x, city.y)) return 0;
const start = indexOf(city.x, city.y);
@ -193,6 +221,7 @@ export function generateAdminLayout({
slope,
river,
ridgeField,
naturalBarrierScore,
valleyField,
basinField,
coastalLowland,
@ -216,7 +245,9 @@ export function generateAdminLayout({
industrialZones,
logisticsParks,
}) {
const prefectureArea = prefectureMask.reduce((sum, v) => sum + (v ? 1 : 0), 0);
const boundaryRidgeField = naturalBarrierScore
? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46))
: ridgeField;
const municipalityCandidates = [];
for (let y = 2; y < MAP_H - 2; y++) {
for (let x = 2; x < MAP_W - 2; x++) {
@ -235,7 +266,8 @@ export function generateAdminLayout({
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 satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum);
const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages });
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 }));
@ -243,24 +275,27 @@ export function generateAdminLayout({
...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,
max: Math.max(0, targetMunicipalityCount - majorMunicipalSeeds.length - satelliteMunicipalSeeds.length),
minDistance: 6 + Math.floor(rand(seed, 1302) * 3),
threshold: 0.34,
seed: seed + 1300,
jitter: 0.025,
}),
];
if (adminCentersRaw.length < 12) {
const fallback = [...modernCities, ...(satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality"), ...markets, ...newTowns, ...stations, ...villages]
if (adminCentersRaw.length < Math.min(targetMunicipalityCount, 18)) {
const fallback = [...modernCities, ...(satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality"), ...markets, ...ports, ...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 });
.map((p) => ({ x: p.x, y: p.y, score: (p.score || 0.5) + (p.population || 0) / 900000 }));
const extraFallback = pickEntities(fallback, { max: targetMunicipalityCount, minDistance: 5, threshold: 0, seed: seed + 1303 });
for (const p of extraFallback) if (adminCentersRaw.length < targetMunicipalityCount && adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4)) adminCentersRaw.push(p);
}
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)));
if (adminCentersRaw.length < targetMunicipalityCount) {
const extra = pickEntities(municipalityCandidates, { max: targetMunicipalityCount - adminCentersRaw.length, minDistance: 5, threshold: 0.26, seed: seed + 1304 });
adminCentersRaw.push(...extra.filter((p) => adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4)));
}
const adminId = generateAdminRegions(adminCentersRaw, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse);
if (adminCentersRaw.length > targetMunicipalityCount) adminCentersRaw = adminCentersRaw.slice(0, targetMunicipalityCount);
const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw);
const adminId = compartmentAssignment.adminId;
let previousSnapshot = new Int16Array(adminId);
const adminDebug = {
changedAfterSmooth: 0,
@ -273,6 +308,10 @@ export function generateAdminLayout({
changedAfterOversizedRuralSplit: 0,
changedAfterFinalExclaveRemoval: 0,
changedAfterFinalMerge: 0,
targetMunicipalityCount,
actualMunicipalityCount: 0,
changedAfterCompartmentAssignment: compartmentAssignment.debug?.naturalCompartmentCount || 0,
oversizedRuralSplits: 0,
satelliteMunicipalitiesCreated: satelliteMunicipalSeeds.length,
satelliteMunicipalitiesMerged: 0,
satelliteMunicipalitiesExpanded: 0,
@ -282,12 +321,14 @@ export function generateAdminLayout({
satelliteMunicipalityAreaByNameOrIndex: {},
independentSatelliteMunicipalities: satelliteClassificationDebug.independent,
attachedSatelliteDistricts: satelliteClassificationDebug.attached,
satelliteMunicipalityStats: satelliteClassificationDebug,
...compartmentAssignment.debug,
};
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);
smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, populationDensity, landuse, 2);
markChanged("changedAfterSmooth");
function lockUrbanClusterToMunicipality(city, radius, allowSuburban = true) {
@ -335,7 +376,7 @@ export function generateAdminLayout({
if (bestAdmin < 0) continue;
sat.parentAdminHint = bestAdmin;
const changed = expandSatelliteMunicipalityCatchment(adminId, sat, bestAdmin, {
prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum,
prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum,
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities,
});
if (changed > 0 && sat.municipalityClass === "independentSatelliteMunicipality") adminDebug.satelliteMunicipalitiesExpanded++;
@ -348,22 +389,23 @@ export function generateAdminLayout({
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);
applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, 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,
prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, 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]);
const oversizedSplitDebug = splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, [...(satelliteCities || []), ...newTowns, ...markets, ...villages]);
adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells;
adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities;
adminDebug.oversizedRuralSplits = 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);
snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 2);
markChanged("changedAfterSnap");
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 360);
markChanged("changedAfterFinalExclaveRemoval");
@ -375,7 +417,7 @@ export function generateAdminLayout({
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,
prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum,
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities,
});
}
@ -401,8 +443,11 @@ export function generateAdminLayout({
});
adminDebug.averageSatelliteMunicipalityArea = satelliteAreas.length ? satelliteAreas.reduce((sum, value) => sum + value, 0) / satelliteAreas.length : 0;
adminDebug.minSatelliteMunicipalityArea = satelliteAreas.length ? Math.min(...satelliteAreas) : 0;
adminDebug.satelliteMunicipalitiesIndependent = satelliteAreas.length;
const landscapeDebug = applyLandscapeUnitAdminPartition.lastDebug || {};
Object.assign(adminDebug, landscapeDebug);
adminDebug.naturalCompartmentCount = adminDebug.naturalCompartmentCount || adminDebug.compartmentCount || 0;
adminDebug.actualMunicipalityCount = new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i])).size;
const adminBorders = extractAdminBorderSegments(adminId, prefectureMask);

View file

@ -34,6 +34,11 @@ export function generateMapFeatures(seed, terrain) {
basinField,
coastalLowland,
flowAccum,
arcSpineField,
branchRidgeField,
depositionalLowland,
alluvialFanField,
deltaField,
portSuitability,
crossingSuitability,
passSuitability,
@ -108,9 +113,11 @@ export function generateMapFeatures(seed, terrain) {
for (let x = 2; x < MAP_W - 2; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.65 + (deltaField?.[i] || 0) * 0.85;
const spineBarrier = (arcSpineField?.[i] || 0) * 0.62 + (branchRidgeField?.[i] || 0) * 0.42;
const valleyCorridor = clamp(valleyField[i] * 0.62 + river[i] * 0.16);
const lowlandCorridor = clamp(coastalLowland[i] * 0.38 + basinField[i] * 0.34 + plain[i] * 0.24 + agriculture[i] * 0.18);
const terrainGate = clamp(1.0 - slope[i] * 1.18 - ridgeField[i] * 0.52 - Math.max(0, elevation[i] - 0.62) * 1.35, 0.08, 1);
const lowlandCorridor = clamp(coastalLowland[i] * 0.38 + basinField[i] * 0.34 + plain[i] * 0.24 + agriculture[i] * 0.18 + depositional * 0.22);
const terrainGate = clamp(1.0 - slope[i] * 1.18 - ridgeField[i] * 0.52 - spineBarrier * 0.34 - Math.max(0, elevation[i] - 0.62) * 1.35, 0.08, 1);
const localPatch = valueNoise(x * 0.7, y * 0.7, seed + 1037, 10);
const broadPatch = fbm(x * 0.32 + 71, y * 0.32 - 19, seed + 1038);
settlementCluster[i] = clamp((valleyCorridor + lowlandCorridor) * terrainGate * (0.72 + broadPatch * 0.42 + localPatch * 0.18));
@ -124,10 +131,12 @@ export function generateMapFeatures(seed, terrain) {
if (sea[i]) continue;
let nearFeature = 0;
for (const p of [...ports, ...crossings, ...passes]) nearFeature = Math.max(nearFeature, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 4));
const riverPull = Math.min(0.32, river[i] * 0.14 + valleyField[i] * 0.16);
const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.95;
const spineBarrier = (arcSpineField?.[i] || 0) * 0.48 + (branchRidgeField?.[i] || 0) * 0.34;
const riverPull = Math.min(0.36, river[i] * 0.14 + valleyField[i] * 0.16 + depositional * 0.08);
const mountainVillage = valleyField[i] * clamp(elevation[i] - 0.42, 0, 0.3) * 0.52;
const remoteMountainPenalty = Math.max(0, elevation[i] - 0.58) * Math.max(0, ridgeField[i] - 0.22) * (1 - valleyField[i]) * 0.75;
const base = agriculture[i] * 0.50 + plain[i] * 0.14 + nearFeature * 0.23 + riverPull + basinField[i] * 0.13 + coastalLowland[i] * 0.08 + mountainVillage - slope[i] * 0.48 - ridgeField[i] * 0.24 - floodplain[i] * 0.06 - remoteMountainPenalty;
const remoteMountainPenalty = Math.max(0, elevation[i] - 0.58) * Math.max(0, ridgeField[i] + spineBarrier - 0.22) * (1 - valleyField[i]) * 0.75;
const base = agriculture[i] * 0.50 + plain[i] * 0.14 + nearFeature * 0.23 + riverPull + basinField[i] * 0.13 + coastalLowland[i] * 0.08 + depositional * 0.13 + mountainVillage - slope[i] * 0.48 - ridgeField[i] * 0.24 - spineBarrier * 0.12 - floodplain[i] * 0.06 - remoteMountainPenalty;
settlementScore[i] = clamp(base * (0.74 + settlementCluster[i] * 0.66) + settlementCluster[i] * 0.13);
}
}
@ -159,7 +168,7 @@ export function generateMapFeatures(seed, terrain) {
let featurePull = 0;
for (const p of [...ports, ...crossings]) featurePull = Math.max(featurePull, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 3));
const confluence = river[i] > 0.36 && valleyField[i] > 0.24 ? 0.12 : 0;
marketScore[i] = clamp(villagePull * 1.25 + featurePull * 0.34 + plain[i] * 0.2 + basinField[i] * 0.16 + confluence + coastalLowland[i] * 0.08 + river[i] * 0.035 - slope[i] * 0.32 - ridgeField[i] * 0.18 + nearbyVillages * 0.012);
marketScore[i] = clamp(villagePull * 1.25 + featurePull * 0.34 + plain[i] * 0.2 + basinField[i] * 0.16 + (depositionalLowland?.[i] || 0) * 0.10 + (deltaField?.[i] || 0) * 0.08 + confluence + coastalLowland[i] * 0.08 + river[i] * 0.035 - slope[i] * 0.32 - ridgeField[i] * 0.18 - (arcSpineField?.[i] || 0) * 0.08 + nearbyVillages * 0.012);
}
}

View file

@ -418,29 +418,52 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river,
const { compartmentId, compartments } = buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeField, flowAccum, naturalBarrierScore);
const owner = new Int16Array(compartments.length);
owner.fill(-1);
for (let id = 0; id < seeded.centers.length; id++) {
const center = seeded.centers[id];
if (!center || !inside(center.x, center.y)) continue;
const ci = compartmentId[indexOf(center.x, center.y)];
if (ci >= 0) owner[ci] = id;
}
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 (unit.cells.some((i) => anchorMask[i])) owner[unit.id] = 0;
}
for (let pass = 0; pass < compartments.length + 6; pass++) {
let changedThisPass = 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];
const center = seeded.centers[neighborOwner];
const barrier = edge.target / Math.max(1, edge.count);
const sameClass = neighbor?.classId === unit.classId ? 1.0 : 0;
const d = center ? Math.hypot(unit.x - center.x, unit.y - center.y) : 0;
const score = edge.count * 0.45 + sameClass + neighbor.coastalExposure * 0.08 + neighbor.ridgeExposure * 0.05 - barrier * 2.6 - d * 0.008;
if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; }
}
if (bestOwner >= 0) {
owner[unit.id] = bestOwner;
changedThisPass++;
}
}
if (anchorCells > 0) {
owner[unit.id] = 0;
continue;
}
let bestId = -1;
let best = -1;
for (const [id, count] of counts) {
if (changedThisPass === 0) break;
}
for (const unit of compartments) {
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
let bestId = 0;
let best = -INF;
for (let id = 0; id < seeded.centers.length; id++) {
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 (!center) continue;
const d = Math.hypot(unit.x - center.x, unit.y - center.y);
const score = -d + (id === 0 ? (unit.cells.some((i) => anchorMask[i]) ? 1000 : -12) : 0);
if (score > best) { best = score; bestId = id; }
}
owner[unit.id] = bestId >= 0 ? bestId : 0;
owner[unit.id] = bestId;
}
const regionId = new Int16Array(beforeRegionId);
@ -472,6 +495,9 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river,
regionalNaturalBarrierAverageBefore: beforeNaturalAverage,
regionalNaturalBarrierAverageAfter: afterNaturalAverage,
regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length,
compartmentCount: compartments.filter((unit) => unit.area > 0).length,
changedAfterCompartmentAssignment: changed,
borderNaturalBarrierAverage: afterNaturalAverage,
},
};
}
@ -635,8 +661,33 @@ function buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeFie
ridgeExposure: ridgeExposure / Math.max(1, area),
riverExposure: riverExposure / Math.max(1, area),
coastalExposure: coastalExposure / Math.max(1, area),
adjacent: new Map(),
});
}
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 a = compartmentId[i];
if (a < 0 || !compartments[a]) 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]) continue;
const b = compartmentId[ni];
if (b < 0 || a === b || !compartments[b]) continue;
const v = (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5;
const edgeA = compartments[a].adjacent.get(b) || { count: 0, target: 0 };
edgeA.count++;
edgeA.target += v;
compartments[a].adjacent.set(b, edgeA);
const edgeB = compartments[b].adjacent.get(a) || { count: 0, target: 0 };
edgeB.count++;
edgeB.target += v;
compartments[b].adjacent.set(a, edgeB);
}
}
}
return { compartmentId, compartments };
}
@ -845,6 +896,15 @@ export function applyOutputOptions(map, options = {}) {
delete slim.flowAccum;
delete slim.erosionField;
delete slim.depositionField;
delete slim.terrainTemplate;
delete slim.ocean;
delete slim.lake;
delete slim.arcSpineField;
delete slim.branchRidgeField;
delete slim.depositionalLowland;
delete slim.alluvialFanField;
delete slim.deltaField;
delete slim.naturalBarrierScore;
return slim;
}

View file

@ -5,6 +5,7 @@ import { applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLandus
export function finishMapOutput({
seed,
options,
terrainTemplate,
cityPopulationCap,
stationInfluence,
roadInfluence,
@ -13,6 +14,8 @@ export function finishMapOutput({
moisture,
slope,
sea,
ocean,
lake,
river,
floodplain,
plain,
@ -25,6 +28,12 @@ export function finishMapOutput({
flowAccum,
erosionField,
depositionField,
arcSpineField,
branchRidgeField,
depositionalLowland,
alluvialFanField,
deltaField,
naturalBarrierScore,
villages,
ports,
crossings,
@ -132,6 +141,58 @@ export function finishMapOutput({
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 representativeFeatures = [
...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })),
...markets.map((p) => ({ ...p, representativeWeight: 3.2 })),
...ports.map((p) => ({ ...p, representativeWeight: p.portClass === "major" ? 3.8 : 2.4 })),
...villages.map((p) => ({ ...p, representativeWeight: 1.6 })),
].filter((p) => p.insidePrefecture && p.name);
for (const center of adminCenters) {
const centerAdmin = adminId?.[indexOf(center.x, center.y)];
let best = null;
let bestScore = -INF;
for (const sameAdminOnly of [true, false]) {
for (const feature of representativeFeatures) {
const featureAdmin = adminId?.[indexOf(feature.x, feature.y)];
if (sameAdminOnly && centerAdmin >= 0 && featureAdmin >= 0 && featureAdmin !== centerAdmin) continue;
const d = Math.hypot(center.x - feature.x, center.y - feature.y);
const score = feature.representativeWeight - d * 0.11 - (sameAdminOnly ? 0 : 1.2);
if (score > bestScore) {
bestScore = score;
best = feature;
}
}
if (best) break;
}
if (best) {
center.representativeFeatureId = best.id;
center.representativeFeatureName = best.name;
center.generatedMunicipalityName = center.name;
center.name = best.name;
}
}
const adminNamePrefixes = ["\u6771", "\u897F", "\u5357", "\u5317", "\u4E0A", "\u4E0B", "\u65B0", "\u65E7", "\u4E2D", "\u5916"];
const adminNameCounts = new Map();
for (const center of adminCenters) adminNameCounts.set(center.name, (adminNameCounts.get(center.name) || 0) + 1);
const duplicateOrdinal = new Map();
for (const center of adminCenters) {
if ((adminNameCounts.get(center.name) || 0) <= 1) continue;
const n = duplicateOrdinal.get(center.name) || 0;
duplicateOrdinal.set(center.name, n + 1);
const prefix = adminNamePrefixes[(Math.floor(center.x / Math.max(1, MAP_W / 3)) + Math.floor(center.y / Math.max(1, MAP_H / 3)) * 3 + n) % adminNamePrefixes.length];
if (!String(center.name).startsWith(prefix)) center.name = `${prefix}${center.name}`;
}
const usedAdminNames = new Set();
for (const center of adminCenters) {
let candidate = center.name;
let guard = 0;
while (usedAdminNames.has(candidate) && guard < adminNamePrefixes.length) {
candidate = `${adminNamePrefixes[(guard + Math.floor(center.x / 12) + Math.floor(center.y / 12)) % adminNamePrefixes.length]}${center.name}`;
guard++;
}
center.name = candidate;
usedAdminNames.add(center.name);
}
const entitiesForNames = [
...modernCities,
@ -146,6 +207,7 @@ export function finishMapOutput({
...newTowns,
...passes,
...crossings,
...adminCenters,
...externalGateways,
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
@ -153,6 +215,7 @@ export function finishMapOutput({
width: MAP_W,
height: MAP_H,
cellSize: CELL_SIZE,
terrainTemplate,
prefectureMask,
prefectureBorder,
prefectureRegionId,
@ -162,6 +225,8 @@ export function finishMapOutput({
moisture,
slope,
sea,
ocean,
lake,
river,
floodplain,
plain,
@ -174,6 +239,12 @@ export function finishMapOutput({
flowAccum,
erosionField,
depositionField,
arcSpineField,
branchRidgeField,
depositionalLowland,
alluvialFanField,
deltaField,
naturalBarrierScore,
villages,
ports,
crossings,

View file

@ -11,10 +11,13 @@ export function generateMap(seedInput = 114514, options = {}) {
const terrain = generateTerrainAndRivers(seed);
const {
terrainTemplate,
elevation,
moisture,
slope,
sea,
ocean,
lake,
river,
floodplain,
plain,
@ -26,6 +29,12 @@ export function generateMap(seedInput = 114514, options = {}) {
flowAccum,
erosionField,
depositionField,
arcSpineField,
branchRidgeField,
depositionalLowland,
alluvialFanField,
deltaField,
naturalBarrierScore,
portSuitability,
crossingSuitability,
passSuitability,
@ -48,13 +57,14 @@ export function generateMap(seedInput = 114514, options = {}) {
} = features;
const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({
seed, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
seed, prefectureMask, sea, elevation, slope, river, ridgeField, naturalBarrierScore, 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,
seed, options, terrainTemplate, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2,
elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, basinField, coastalLowland, flowAccum, erosionField, depositionField,
arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore,
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,

View file

@ -8,6 +8,145 @@ import {
neighbors8,
} from "./mapGeneratorHelpers.js";
export function buildTerrainTemplate(seed) {
const deposition = 0.18 + rand(seed, 41) * 0.72;
const erosion = 0.24 + rand(seed, 42) * 0.68;
const roughness = 0.34 + rand(seed, 43) * 0.62;
const coastAxisPick = Math.floor(rand(seed, 10) * 3);
const coastAngle = coastAxisPick === 0
? Math.PI / 2
: coastAxisPick === 1
? 0
: (rand(seed, 11) > 0.5 ? Math.PI / 4 : -Math.PI / 4) + (rand(seed, 14) - 0.5) * 0.28;
const ridgeJaggedness = 0.20 + rand(seed, 44) * 0.70;
const spineCount = rand(seed, 45) > 0.64 ? 2 : 1;
const sideAPlain = 0.035 + rand(seed, 56) * 0.115 + deposition * 0.085;
const sideBPlain = 0.035 + rand(seed, 57) * 0.115 + deposition * 0.085;
return {
seed,
spineCount,
spineAngle: coastAngle + Math.PI * (0.28 + rand(seed, 46) * 0.44),
spineCurve: (rand(seed, 47) - 0.5) * 0.28,
spinePosition: (rand(seed, 48) - 0.5) * 0.56,
spineStrength: 0.66 + rand(seed, 49) * 0.44,
spineWidth: 0.060 + rand(seed, 50) * 0.050,
secondaryMountainCount: 3 + Math.floor(rand(seed, 51) * 5),
secondaryMountainSize: 0.060 + rand(seed, 52) * 0.085,
secondaryMountainStrength: 0.55 + rand(seed, 53) * 0.55,
coastAxis: coastAxisPick === 0 ? "east-west" : coastAxisPick === 1 ? "north-south" : "diagonal",
coastAngle,
coastBias: 0.18 + rand(seed, 12) * 0.24,
coastRoughness: 0.34 + rand(seed, 54) * 0.58,
coastSides: [
{
penetration: 0.24 + rand(seed, 58) * 0.24,
inletStrength: 0.18 + rand(seed, 59) * 0.56,
plainWidth: sideAPlain,
},
{
penetration: 0.24 + rand(seed, 60) * 0.24,
inletStrength: 0.18 + rand(seed, 61) * 0.56,
plainWidth: sideBPlain,
},
],
deposition,
erosion,
roughness,
ridgeJaggedness,
ridgeBranchiness: 0.28 + rand(seed, 55) * 0.62,
};
}
function jaggedRidgeContribution(x, y, ridge, seed) {
const dx = x - ridge.x;
const dy = y - ridge.y;
const ca = Math.cos(ridge.angle);
const sa = Math.sin(ridge.angle);
const along = dx * ca + dy * sa;
const perp = -dx * sa + dy * ca;
const nAlong = along / Math.max(0.001, ridge.length);
const lengthFade = smoothstep(1 - Math.abs(nAlong));
if (lengthFade <= 0) return 0;
// Bend the centerline itself with coherent long/mid waves, then apply ridge falloff.
const low = (valueNoise(along * 0.85 + ridge.seedOffset, ridge.seedOffset * 0.37, seed + 6100, 28) - 0.5) * 2;
const mid = (valueNoise(along * 1.7 - ridge.seedOffset, ridge.seedOffset * 0.23, seed + 6200, 13) - 0.5) * 2;
const sine = Math.sin(along * ridge.kinkFrequency + ridge.kinkPhase);
const curve = (ridge.curve || 0) * along * along * (along >= 0 ? 1 : -1);
const axisOffset = low * ridge.axisWobble + mid * ridge.axisWobble * 0.55 + sine * ridge.axisWobble * 0.25 + curve;
const widthNoise = 0.78 + valueNoise(along * 1.2 + ridge.seedOffset, ridge.seedOffset * 0.19, seed + 6300, 21) * ridge.widthVariation;
const localWidth = Math.max(0.006, ridge.width * widthNoise);
const jaggedPerp = perp - axisOffset;
const serration = 0.76 + valueNoise(x * 1.1 + along * 0.18, y * 1.1 + perp * 0.18, seed + ridge.seedOffset, 7) * 0.48;
return Math.exp(-(jaggedPerp * jaggedPerp) / (localWidth * localWidth)) * lengthFade * ridge.h * serration;
}
function spineFieldAt(x, y, template, spineIndex) {
const seed = template.seed || 0;
const spacing = spineIndex === 0 ? 0 : (spineIndex % 2 ? 0.18 : -0.18);
const angle = template.spineAngle + (spineIndex - 0.5) * 0.17 + (rand(seed, 700 + spineIndex) - 0.5) * 0.18;
const ridge = {
x: 0.5 + Math.cos(angle + Math.PI / 2) * (template.spinePosition + spacing) * 0.45,
y: 0.5 + Math.sin(angle + Math.PI / 2) * (template.spinePosition + spacing) * 0.45,
angle,
width: template.spineWidth * (0.82 + rand(seed, 710 + spineIndex) * 0.38),
length: 0.78 + rand(seed, 720 + spineIndex) * 0.28,
h: template.spineStrength * (0.18 + rand(seed, 730 + spineIndex) * 0.08),
curve: template.spineCurve,
axisWobble: template.spineWidth * (0.45 + template.ridgeJaggedness * 1.15),
kinkFrequency: 10 + rand(seed, 740 + spineIndex) * 18,
kinkPhase: rand(seed, 750 + spineIndex) * Math.PI * 2,
seedOffset: 7600 + spineIndex * 211,
widthVariation: 0.18 + template.ridgeJaggedness * 0.34,
};
return jaggedRidgeContribution(x, y, ridge, seed);
}
function buildSpineRidges(seed, template) {
const spines = [];
const branches = [];
for (let i = 0; i < template.spineCount; i++) {
const angle = template.spineAngle + (i - 0.5) * 0.17 + (rand(seed, 700 + i) - 0.5) * 0.18;
const spacing = i === 0 ? 0 : (i % 2 ? 0.18 : -0.18);
const x = 0.5 + Math.cos(angle + Math.PI / 2) * (template.spinePosition + spacing) * 0.45;
const y = 0.5 + Math.sin(angle + Math.PI / 2) * (template.spinePosition + spacing) * 0.45;
spines.push({
x, y, angle,
width: template.spineWidth * (0.82 + rand(seed, 710 + i) * 0.38),
length: 0.78 + rand(seed, 720 + i) * 0.28,
h: template.spineStrength * (0.18 + rand(seed, 730 + i) * 0.08),
curve: template.spineCurve,
axisWobble: template.spineWidth * (0.45 + template.ridgeJaggedness * 1.15),
kinkFrequency: 10 + rand(seed, 740 + i) * 18,
kinkPhase: rand(seed, 750 + i) * Math.PI * 2,
seedOffset: 7600 + i * 211,
widthVariation: 0.18 + template.ridgeJaggedness * 0.34,
});
const branchCount = 3 + Math.floor(template.ridgeBranchiness * 5);
for (let b = 0; b < branchCount; b++) {
const along = (rand(seed, 810 + i * 31 + b) - 0.5) * 0.62;
const side = rand(seed, 820 + i * 31 + b) > 0.5 ? 1 : -1;
const branchAngle = angle + side * (0.55 + rand(seed, 830 + i * 31 + b) * 0.72);
branches.push({
x: x + Math.cos(angle) * along,
y: y + Math.sin(angle) * along,
angle: branchAngle,
width: template.spineWidth * (0.42 + rand(seed, 840 + i * 31 + b) * 0.36),
length: 0.16 + rand(seed, 850 + i * 31 + b) * 0.28,
h: template.spineStrength * (0.055 + template.ridgeBranchiness * 0.085 + rand(seed, 860 + i * 31 + b) * 0.055),
curve: template.spineCurve * 0.45,
axisWobble: template.spineWidth * (0.32 + template.ridgeJaggedness * 0.72),
kinkFrequency: 14 + rand(seed, 870 + i * 31 + b) * 20,
kinkPhase: rand(seed, 880 + i * 31 + b) * Math.PI * 2,
seedOffset: 8800 + i * 311 + b * 37,
widthVariation: 0.22 + template.ridgeJaggedness * 0.30,
});
}
}
return { spines, branches };
}
export function generateTerrainAndRivers(seed) {
let prefectureMask;
let prefectureBorder;
@ -17,6 +156,8 @@ export function generateTerrainAndRivers(seed) {
moisture,
slope,
sea,
ocean,
lake,
river,
floodplain,
plain,
@ -28,35 +169,70 @@ export function generateTerrainAndRivers(seed) {
flowAccum,
erosionField,
depositionField,
arcSpineField,
branchRidgeField,
depositionalLowland,
alluvialFanField,
deltaField,
naturalBarrierScore,
flowTo,
portSuitability,
crossingSuitability,
passSuitability,
} = createMapFields();
const coastAngle = rand(seed, 11) * Math.PI * 2;
const terrainTemplate = buildTerrainTemplate(seed);
const coastAngle = terrainTemplate.coastAngle;
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 coastThreshold = terrainTemplate.coastBias;
const coastStrength = 0.13 + (1 - terrainTemplate.deposition) * 0.16 + rand(seed, 13) * 0.13;
const { spines, branches } = buildSpineRidges(seed, terrainTemplate);
function coastPressureAt(x, y, wx = x, wy = y) {
const nx = x / (MAP_W - 1) - 0.5;
const ny = y / (MAP_H - 1) - 0.5;
const axis = nx * coastX + ny * coastY;
const waveA = (fbm(wx * 0.72 + 31, wy * 0.72 - 17, seed + 2222) - 0.5) * (0.05 + terrainTemplate.coastRoughness * terrainTemplate.coastSides[0].inletStrength * 0.18) +
(valueNoise(wx + 19, wy - 23, seed + 2233, 18) - 0.5) * (0.03 + terrainTemplate.coastSides[0].inletStrength * 0.10);
const waveB = (fbm(wx * 0.68 - 41, wy * 0.68 + 29, seed + 3222) - 0.5) * (0.05 + terrainTemplate.coastRoughness * terrainTemplate.coastSides[1].inletStrength * 0.18) +
(valueNoise(wx - 13, wy + 37, seed + 3233, 16) - 0.5) * (0.03 + terrainTemplate.coastSides[1].inletStrength * 0.10);
const sideA = smoothstep((axis + waveA - (0.50 - terrainTemplate.coastSides[0].penetration)) / Math.max(0.08, terrainTemplate.coastSides[0].plainWidth * 2.4));
const sideB = smoothstep((-axis + waveB - (0.50 - terrainTemplate.coastSides[1].penetration)) / Math.max(0.08, terrainTemplate.coastSides[1].plainWidth * 2.4));
return { sideA, sideB, pressure: Math.max(sideA, sideB), signedAxis: axis };
}
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,
}));
const mountainBlobs = Array.from({ length: terrainTemplate.secondaryMountainCount }, (_, i) => {
const spine = spines[i % spines.length];
const nearSpine = rand(seed, 98 + i) < 0.72;
const edgeBias = rand(seed, 99 + i) < 0.28;
const along = (rand(seed, 100 + i) - 0.5) * spine.length * 0.95;
const side = rand(seed, 101 + i) > 0.5 ? 1 : -1;
const offset = (0.055 + rand(seed, 102 + i) * 0.22) * side;
let x = nearSpine ? spine.x + Math.cos(spine.angle) * along + Math.cos(spine.angle + Math.PI / 2) * offset : rand(seed, 103 + i);
let y = nearSpine ? spine.y + Math.sin(spine.angle) * along + Math.sin(spine.angle + Math.PI / 2) * offset : rand(seed, 104 + i);
if (edgeBias) {
const edgeSide = Math.floor(rand(seed, 105 + i) * 4);
if (edgeSide === 0) x = Math.min(x, 0.08 + rand(seed, 106 + i) * 0.10);
if (edgeSide === 1) x = Math.max(x, 0.92 - rand(seed, 107 + i) * 0.10);
if (edgeSide === 2) y = Math.min(y, 0.08 + rand(seed, 108 + i) * 0.10);
if (edgeSide === 3) y = Math.max(y, 0.92 - rand(seed, 109 + i) * 0.10);
}
const coastSide = (x - 0.5) * coastX + (y - 0.5) * coastY;
const mountainSide = coastSide >= 0 ? 1 : -1;
if (rand(seed, 110 + i) < 0.46 && Math.abs(coastSide) > 0.28 - coastThreshold * 0.35) {
x -= coastX * mountainSide * (0.05 + rand(seed, 111 + i) * 0.11);
y -= coastY * mountainSide * (0.05 + rand(seed, 112 + i) * 0.11);
}
return {
x: clamp(x) * MAP_W,
y: clamp(y) * MAP_H,
r: (terrainTemplate.secondaryMountainSize * (0.72 + rand(seed, 300 + i) * 0.72)) * Math.min(MAP_W, MAP_H),
h: terrainTemplate.secondaryMountainStrength * (0.08 + rand(seed, 400 + i) * 0.17),
};
});
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
@ -75,26 +251,22 @@ export function generateTerrainAndRivers(seed) {
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 px = wx / (MAP_W - 1);
const py = wy / (MAP_H - 1);
let spineRidges = 0;
for (let si = 0; si < spines.length; si++) spineRidges += jaggedRidgeContribution(px, py, spines[si], seed);
let branchRidges = 0;
for (const ridge of branches) branchRidges += jaggedRidgeContribution(px, py, ridge, seed);
const ridges = spineRidges + branchRidges;
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);
const coast = coastPressureAt(x, y, wx, wy);
const coastLower = coast.pressure;
// 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 fineDissection = (Math.abs(terrainLocal - 0.5) * 0.08 + Math.abs(terrainFine - 0.5) * 0.035) * (0.68 + terrainTemplate.roughness * 0.74);
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 +
@ -102,15 +274,18 @@ export function generateTerrainAndRivers(seed) {
0.105 * terrainLocal +
0.055 * terrainFine +
mountains * 0.54 +
ridges * 1.22 +
spineRidges * 0.78 +
branchRidges * 0.92 +
basin +
fineDissection -
coastLower * (coastStrength + 0.19) +
coastLower * (coastStrength + 0.10 + terrainTemplate.deposition * 0.10) +
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);
arcSpineField[i] = clamp(spineRidges * 3.7);
branchRidgeField[i] = clamp(branchRidges * 3.9);
ridgeField[i] = clamp(arcSpineField[i] * 0.86 + branchRidgeField[i] * 0.72 + 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.48 + terrainTemplate.deposition * 0.42));
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);
}
}
@ -118,16 +293,106 @@ export function generateTerrainAndRivers(seed) {
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;
const coast = coastPressureAt(x, y);
const mountainToSea = ridgeField[i] * (1 - terrainTemplate.deposition) * 0.035;
const oceanSide = coast.pressure + mountainToSea > 0.56 + terrainTemplate.deposition * 0.035;
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);
}
}
// Edge-connected water is ocean. Isolated water is only kept when it reads as
// a small mountain/valley lake or lagoon; oversized round basins become wet lowland.
const waterSeen = new Uint8Array(SIZE);
const oceanQueue = [];
for (let x = 0; x < MAP_W; x++) {
for (const y of [0, MAP_H - 1]) {
const i = indexOf(x, y);
if (sea[i] && !waterSeen[i]) {
waterSeen[i] = 1;
ocean[i] = 1;
oceanQueue.push(i);
}
}
}
for (let y = 0; y < MAP_H; y++) {
for (const x of [0, MAP_W - 1]) {
const i = indexOf(x, y);
if (sea[i] && !waterSeen[i]) {
waterSeen[i] = 1;
ocean[i] = 1;
oceanQueue.push(i);
}
}
}
for (let q = 0; q < oceanQueue.length; q++) {
const cur = oceanQueue[q];
const [x, y] = [cur % MAP_W, Math.floor(cur / MAP_W)];
for (const [nx, ny] of neighbors8(x, y)) {
const ni = indexOf(nx, ny);
if (!sea[ni] || waterSeen[ni]) continue;
waterSeen[ni] = 1;
ocean[ni] = 1;
oceanQueue.push(ni);
}
}
for (let i = 0; i < SIZE; i++) {
if (!sea[i] || waterSeen[i]) continue;
const queue = [i];
const component = [i];
waterSeen[i] = 1;
let sx = 0, sy = 0, perimeter = 0, ridgeSum = 0, valleySum = 0, coastTouch = 0;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const x = cur % MAP_W;
const y = Math.floor(cur / MAP_W);
sx += x;
sy += y;
ridgeSum += ridgeField[cur];
valleySum += valleyField[cur];
for (const [nx, ny] of neighbors8(x, y)) {
const ni = indexOf(nx, ny);
if (!sea[ni]) {
perimeter++;
if (coastalLowland[ni] > 0.12 || coastPressureAt(nx, ny).pressure > 0.42) coastTouch++;
continue;
}
if (waterSeen[ni]) continue;
waterSeen[ni] = 1;
queue.push(ni);
component.push(ni);
}
}
const area = component.length;
const cx = sx / area;
const cy = sy / area;
let radiusSum = 0;
for (const ci of component) {
const x = ci % MAP_W;
const y = Math.floor(ci / MAP_W);
radiusSum += Math.hypot(x - cx, y - cy);
}
const meanRadius = radiusSum / Math.max(1, area);
const circularity = perimeter > 0 ? (4 * Math.PI * area) / (perimeter * perimeter) : 1;
const mountainLake = area <= 38 && ridgeSum / area > 0.28;
const valleyLake = area <= 70 && valleySum / area > 0.24 && circularity < 0.58;
const lagoon = area <= 110 && coastTouch / Math.max(1, perimeter) > 0.18 && circularity < 0.70;
const rareSpecial = area <= 145 && circularity < 0.52 && hash2(Math.round(cx), Math.round(cy), seed + 2401) > 0.88;
const keepLake = mountainLake || valleyLake || lagoon || rareSpecial;
for (const ci of component) {
if (keepLake) {
lake[ci] = 1;
continue;
}
sea[ci] = 0;
elevation[ci] = Math.max(seaLevel + 0.012, seaLevel + Math.min(0.055, meanRadius * 0.004) + hash2(ci, area, seed + 2402) * 0.012);
basinField[ci] = clamp(basinField[ci] + 0.42);
valleyField[ci] = clamp(valleyField[ci] + 0.18);
depositionalLowland[ci] = clamp(depositionalLowland[ci] + 0.28);
depositionField[ci] = clamp(depositionField[ci] + 0.035);
}
}
// 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++) {
@ -135,18 +400,25 @@ export function generateTerrainAndRivers(seed) {
const i = indexOf(x, y);
if (sea[i]) continue;
let nearestSea = INF;
let nearestOcean = 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 (ocean[indexOf(nx, ny)]) nearestOcean = Math.min(nearestOcean, 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;
const coastalCap = seaLevel + 0.018 + nearestSea * (0.022 + terrainTemplate.deposition * 0.012) + Math.max(0, fbm(x * 1.4, y * 1.4, seed + 2350) - 0.5) * (0.014 + terrainTemplate.coastRoughness * 0.018);
elevation[i] = Math.min(elevation[i], coastalCap);
coastalLowland[i] = clamp(1 - nearestSea / 7);
if (nearestOcean <= 7) {
const coast = coastPressureAt(x, y);
const side = coast.sideA >= coast.sideB ? terrainTemplate.coastSides[0] : terrainTemplate.coastSides[1];
const plainReach = clamp(4.5 + side.plainWidth * 34, 5, 9);
coastalLowland[i] = clamp((1 - nearestOcean / plainReach) * (0.62 + terrainTemplate.deposition * 0.48 + side.plainWidth * 1.9) * (1 - ridgeField[i] * 0.35));
}
}
}
}
@ -217,11 +489,12 @@ export function generateTerrainAndRivers(seed) {
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));
const steepValley = clamp(flow * (0.036 + terrainTemplate.erosion * 0.050 + slope[i] * (0.14 + terrainTemplate.erosion * 0.13) + ridgeField[i] * (0.022 + terrainTemplate.erosion * 0.044)) * incisionNoise);
const lateralCut = clamp(Math.pow(flowAccum[i], 0.66) * valleyField[i] * (0.044 + terrainTemplate.erosion * 0.064));
const lowSettling = clamp(flow * (coastalLowland[i] * (0.018 + terrainTemplate.deposition * 0.040) + basinField[i] * (0.010 + terrainTemplate.deposition * 0.028) + (elevation[i] < 0.40 ? 0.006 + terrainTemplate.deposition * 0.018 : 0)) * (1 - slope[i] * 0.82) * (1 - ridgeField[i] * 0.45));
erosionField[i] = steepValley + lateralCut;
depositionField[i] = lowSettling;
depositionalLowland[i] = clamp(lowSettling * 6.5 + basinField[i] * terrainTemplate.deposition * 0.28 + coastalLowland[i] * terrainTemplate.deposition * 0.34);
shapedElevation[i] = clamp(elevation[i] - steepValley - lateralCut + lowSettling * 0.72, seaLevel + 0.006, 1);
}
}
@ -244,8 +517,8 @@ export function generateTerrainAndRivers(seed) {
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 score = elevation[i] * 0.38 + moisture[i] * 0.24 + ridgeField[i] * 0.08 + arcSpineField[i] * 0.07 + branchRidgeField[i] * 0.04 + flowAccum[i] * 0.56 + valleyField[i] * 0.28 + hash2(x, y, seed + 9000) * 0.06;
if (elevation[i] > 0.40 && elevation[i] < 0.84 && moisture[i] > 0.28 && flowAccum[i] > 0.020 && ridgeField[i] < 0.95) sourceCandidates.push({ x, y, score });
}
}
@ -595,11 +868,12 @@ export function generateTerrainAndRivers(seed) {
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)));
const channelCut = clamp(Math.pow(r, 0.55) * (0.034 + terrainTemplate.erosion * 0.052 + slope[i] * (0.075 + terrainTemplate.erosion * 0.120) + ridgeField[i] * (0.018 + terrainTemplate.erosion * 0.048)));
const valleyWiden = clamp(Math.pow(r, 0.72) * (0.012 + terrainTemplate.erosion * 0.026 + Math.max(0, elevation[i] - seaLevel) * (0.030 + terrainTemplate.erosion * 0.050) + valleyField[i] * (0.020 + terrainTemplate.erosion * 0.045)));
const alluvium = clamp(Math.pow(r, 0.72) * (coastalLowland[i] * (0.014 + terrainTemplate.deposition * 0.040) + basinField[i] * (0.010 + terrainTemplate.deposition * 0.028) + (slope[i] < 0.10 ? 0.006 + terrainTemplate.deposition * 0.018 : 0)) * (1 - ridgeField[i] * 0.45));
erosionField[i] = clamp(erosionField[i] + channelCut + valleyWiden);
depositionField[i] = clamp(depositionField[i] + alluvium);
depositionalLowland[i] = clamp(depositionalLowland[i] + alluvium * 5.5);
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);
@ -622,7 +896,7 @@ export function generateTerrainAndRivers(seed) {
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);
const carve = Math.max(0, weight) * (0.005 + terrainTemplate.erosion * 0.007 + r * (0.014 + terrainTemplate.erosion * 0.022)) * 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);
@ -631,6 +905,57 @@ export function generateTerrainAndRivers(seed) {
}
}
// Template-driven deposition is limited to plausible low-energy places:
// river mouths, basin floors, coastal plains, and slope breaks below ridges.
const depositionElevation = new Float32Array(fluvialElevation);
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 nearSea = 0;
let localRiver = river[i];
let highSide = 0;
let lowSide = 1;
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;
const ni = indexOf(nx, ny);
const d = Math.hypot(dx, dy);
if (d > 4.25) continue;
if (sea[ni]) nearSea = Math.max(nearSea, 1 - d / 4.25);
localRiver = Math.max(localRiver, river[ni] / (1 + d * 0.5));
highSide = Math.max(highSide, fluvialElevation[ni]);
lowSide = Math.min(lowSide, fluvialElevation[ni]);
}
}
const reliefDrop = clamp((highSide - lowSide - 0.075) * 4.5);
const lowlandPotential = clamp(
basinField[i] * 0.44 +
coastalLowland[i] * 0.52 +
Math.pow(flowAccum[i], 0.56) * 0.32 +
plain[i] * 0.18 +
localRiver * 0.16 -
ridgeField[i] * 0.48 -
slope[i] * 0.52 -
Math.max(0, fluvialElevation[i] - 0.55) * 1.35
);
const delta = clamp(nearSea * localRiver * coastalLowland[i] * (0.32 + terrainTemplate.deposition * 1.25) * (1 - ridgeField[i] * 0.55));
const fan = clamp(reliefDrop * localRiver * valleyField[i] * (0.20 + terrainTemplate.deposition * 0.95) * (1 - coastalLowland[i] * 0.45));
const lowland = clamp(lowlandPotential * terrainTemplate.deposition + delta * 0.72 + fan * 0.42);
if (lowland <= 0.01) continue;
deltaField[i] = clamp(deltaField[i] + delta);
alluvialFanField[i] = clamp(alluvialFanField[i] + fan);
depositionalLowland[i] = clamp(depositionalLowland[i] + lowland);
depositionField[i] = clamp(depositionField[i] + lowland * 0.050);
erosionField[i] = Math.max(0, erosionField[i] - lowland * 0.018);
const floor = seaLevel + 0.008 + basinField[i] * 0.012 + coastalLowland[i] * 0.010;
depositionElevation[i] = clamp(lerp(fluvialElevation[i], Math.max(floor, fluvialElevation[i] - 0.032), lowland * 0.55), seaLevel + 0.005, 1);
}
}
fluvialElevation.set(depositionElevation);
// 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++) {
@ -654,7 +979,7 @@ export function generateTerrainAndRivers(seed) {
// 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++) {
for (let pass = 0; pass < 3 + Math.round(terrainTemplate.deposition * 2); pass++) {
const nextElevation = new Float32Array(elevation);
for (let y = 2; y < MAP_H - 2; y++) {
for (let x = 2; x < MAP_W - 2; x++) {
@ -663,6 +988,9 @@ export function generateTerrainAndRivers(seed) {
const lowland = clamp(
coastalLowland[i] * 0.72 +
basinField[i] * 0.54 +
depositionalLowland[i] * 0.52 +
deltaField[i] * 0.34 +
alluvialFanField[i] * 0.22 +
valleyField[i] * 0.34 +
Math.pow(flowAccum[i], 0.58) * 0.24 -
ridgeField[i] * 0.62 -
@ -690,9 +1018,9 @@ export function generateTerrainAndRivers(seed) {
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);
nextElevation[i] = clamp(lerp(elevation[i], target, lowland * (0.30 + terrainTemplate.deposition * 0.26)), seaLevel + 0.006, 1);
if (lowland > 0.55) {
depositionField[i] = clamp(depositionField[i] + lowland * 0.018);
depositionField[i] = clamp(depositionField[i] + lowland * (0.010 + terrainTemplate.deposition * 0.018));
erosionField[i] = Math.max(0, erosionField[i] - lowland * 0.012);
}
}
@ -785,7 +1113,7 @@ export function generateTerrainAndRivers(seed) {
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;
const valleyPlain = valleyField[i] * 0.44 + basinField[i] * 0.36 + coastalLowland[i] * 0.55 + depositionalLowland[i] * 0.34 + deltaField[i] * 0.28 + alluvialFanField[i] * 0.20;
plain[i] = clamp(low * 0.44 + flat * 0.58 + valleyPlain - ridgeField[i] * 0.28 - (elevation[i] > 0.62 ? 0.48 : 0));
let nearRiver = 0;
@ -798,9 +1126,9 @@ export function generateTerrainAndRivers(seed) {
}
}
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);
const fan = clamp(Math.max(alluvialFanField[i], 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 + deltaField[i] * 0.18);
agriculture[i] = clamp(plain[i] * 0.58 + fan * 0.30 + basinField[i] * 0.2 + depositionalLowland[i] * 0.24 + deltaField[i] * 0.18 + moisture[i] * 0.14 + clamp(nearRiver) * 0.32 - slope[i] * 0.34 - ridgeField[i] * 0.18 - floodplain[i] * 0.06);
}
}
@ -831,9 +1159,9 @@ export function generateTerrainAndRivers(seed) {
}
}
const isDelta = riverNear > 0.22 && coastalLowland[i] > 0.18;
const isDelta = (riverNear > 0.22 && coastalLowland[i] > 0.18) || deltaField[i] > 0.16;
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);
portSuitability[i] = clamp(bayShelter + riverNear * 0.24 + (isDelta ? 0.22 : 0) + deltaField[i] * 0.18 + depositionalLowland[i] * 0.08 + plain[i] * 0.08 - slope[i] * 0.48 - ridgeField[i] * 0.16);
}
}
@ -867,12 +1195,39 @@ export function generateTerrainAndRivers(seed) {
}
}
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 = Math.abs(elevation[indexOf(x + 1, y)] - elevation[indexOf(x - 1, y)]);
const gy = Math.abs(elevation[indexOf(x, y + 1)] - elevation[indexOf(x, y - 1)]);
const slopeBreak = clamp((gx + gy) * 3.2 + Math.max(0, slope[i] - 0.28) * 0.72);
const majorRiver = clamp(Math.max(0, river[i] - 0.34) * 1.45 + Math.max(0, flowAccum[i] - 0.42) * 0.58);
const basinRim = clamp(basinField[i] * Math.max(0, slope[i] - 0.16) * 1.25 + ridgeField[i] * basinField[i] * 0.32);
naturalBarrierScore[i] = clamp(
arcSpineField[i] * 0.80 +
branchRidgeField[i] * 0.62 +
ridgeField[i] * 0.54 +
majorRiver * 0.62 +
slopeBreak * 0.34 +
basinRim * 0.36 -
valleyField[i] * 0.30 -
depositionalLowland[i] * 0.42 -
coastalLowland[i] * 0.20 -
plain[i] * 0.18
);
}
}
return {
terrainTemplate,
elevation,
moisture,
slope,
sea,
ocean,
lake,
river,
floodplain,
plain,
@ -884,6 +1239,12 @@ export function generateTerrainAndRivers(seed) {
flowAccum,
erosionField,
depositionField,
arcSpineField,
branchRidgeField,
depositionalLowland,
alluvialFanField,
deltaField,
naturalBarrierScore,
portSuitability,
crossingSuitability,
passSuitability,

View file

@ -100,6 +100,8 @@ export function createMapFields() {
moisture: new Float32Array(SIZE),
slope: new Float32Array(SIZE),
sea: new Uint8Array(SIZE),
ocean: new Uint8Array(SIZE),
lake: new Uint8Array(SIZE),
river: new Float32Array(SIZE),
floodplain: new Float32Array(SIZE),
plain: new Float32Array(SIZE),
@ -111,6 +113,12 @@ export function createMapFields() {
flowAccum: new Float32Array(SIZE),
erosionField: new Float32Array(SIZE),
depositionField: new Float32Array(SIZE),
arcSpineField: new Float32Array(SIZE),
branchRidgeField: new Float32Array(SIZE),
depositionalLowland: new Float32Array(SIZE),
alluvialFanField: new Float32Array(SIZE),
deltaField: new Float32Array(SIZE),
naturalBarrierScore: new Float32Array(SIZE),
flowTo,
portSuitability: new Float32Array(SIZE),
crossingSuitability: new Float32Array(SIZE),

View file

@ -8,19 +8,21 @@ export const NAME_KANJI_POOLS = {
"高", "長", "広", "深", "浅",
"白", "黒", "青", "赤",
"奥", "前", "後", "内", "外",
"早", "安", "真", "丸", "平",
"美", "吉", "福", "幸", "徳",
"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万",
],
"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万",
"霧", "霞", "朝", "日", "天", "雨", "晴",
"早", "安", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠",
],
inlandTerrain: [
"山", "谷", "ヶ谷", "沢", "原", "野",
"山", "谷", "ヶ谷", "沢", "原", "野", "荒",
"森", "林", "岡", "丘", "坂",
"峰", "峠", "嶺", "尾", "平", "坪", "延",
"窪", "久", "洞", "迫", "久保", "玖保", "漥", "佐古", "作古", "峪",
"峰", "峠", "嶺", "尾", "平", "坪", "延", "燧",
"窪", "久", "迫", "久保", "玖保", "漥", "佐古", "作古", "峪",
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦",
"聡", "郷", "里",
"馬", "鹿", "亀", "鷲", "鷹"
"馬", "鹿", "亀", "鷲", "鷹",
"妙見",
],
waterTerrain: [
@ -45,7 +47,8 @@ export const NAME_KANJI_POOLS = {
"竹", "楠", "藤", "萩", "葦",
"菅", "榎", "椿", "桐", "柳",
"橘", "柏", "槙", "柿", "桃",
"梨", "桑", "麻", "芦", "茅",
"梨", "桑", "麻", "芦", "茅",
"粟", "稲", "麦", "稗", "米", "飯", "糠",
"榊", "楢", "檜", "椎", "柚", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑"
],
@ -57,7 +60,7 @@ export const NAME_KANJI_POOLS = {
"辺", "里", "郷", "村", "町",
"宿", "庄", "台", "坂", "橋",
"本", "内", "窪", "平", "塚",
"畑", "牧", "前", "見", "中", "羽", "生", "塚"
"畑", "牧", "前", "見", "中", "羽", "生", "塚", "部",
],
archaicPrefixes: [
@ -65,9 +68,9 @@ export const NAME_KANJI_POOLS = {
"土", "出", "丹", "播", "但",
"因", "伯", "筑", "肥", "豊",
"日", "紀", "志", "尾", "駿",
"甲", "信", "越", "備", "",
"甲", "信", "越", "備", "",
"薩", "隠", "美", "三", "若",
"遠", "近", "能", "加", "賀",
"遠", "近", "能", "加", "賀", "度",
"越", "淡", "壱", "阿", "衣", "古", "彦", "多", "志", "布", "治"
],
@ -77,14 +80,14 @@ export const NAME_KANJI_POOLS = {
"張", "江", "河", "斐", "濃",
"岐", "防", "門", "隅", "向",
"居", "前", "中", "後", "波",
"勢", "渡", "城", "紫", "野",
"勢", "渡", "城", "紫", "野", "度",
"津", "島", "信", "登", "賀", "志",
"良", "美", "智", "茂", "代", "古", "摩", "磨", "麻", "彦", "比古", "子"
],
settlementWords: [
"里", "郷", "村", "町", "宿", "邑", "垣", "坪",
"庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂", "妙見",
"庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂",
"城", "館", "屋", "家", "所",
"市", "場", "府", "関", "地蔵", "辻", "角", "堰",
]

View file

@ -118,6 +118,16 @@ function discreteColor(map, x, y, mode) {
];
const a = map.adminId[i];
color = a >= 0 ? palette[a % palette.length] : [220, 225, 220];
} else if (mode === "terrain-debug") {
const ridge = clamp(map.ridgeField[i] * 0.68 + (map.arcSpineField?.[i] || 0) * 0.42 + (map.branchRidgeField?.[i] || 0) * 0.34);
const deposit = clamp((map.depositionField?.[i] || 0) * 5.0 + (map.depositionalLowland?.[i] || 0) * 0.48 + (map.alluvialFanField?.[i] || 0) * 0.34 + (map.deltaField?.[i] || 0) * 0.46);
const valley = clamp(map.valleyField[i] * 0.72 + map.river[i] * 0.22);
const barrier = clamp(map.naturalBarrierScore?.[i] || ridge);
color = [
Math.round(220 - deposit * 70 + ridge * 48),
Math.round(226 + deposit * 38 + valley * 28 - barrier * 52),
Math.round(214 + valley * 58 + barrier * 34 - ridge * 42),
];
} else if (mode === "admin-debug" || mode === "borders-debug") {
const barrier = clamp(
map.ridgeField[i] * 0.88 +
@ -449,7 +459,7 @@ function drawLabels(ctx, points, limit = Infinity) {
const occupied = [];
const prioritized = points
.filter((p) => p?.name)
.map((p) => ({ ...p, labelPriority: (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) + (p.kind === "Market Town" ? 48 : 0) + (p.kind?.includes("Castle") ? 70 : 0) + (p.kind === "External Gateway" ? 60 : 0) }))
.map((p) => ({ ...p, labelPriority: (p.labelPriorityBase || 0) + (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) + (p.kind === "Market Town" ? 48 : 0) + (p.kind?.includes("Castle") ? 70 : 0) + (p.kind === "External Gateway" ? 60 : 0) + (p.kind === "Municipal Center" ? 34 : 0) }))
.sort((a, b) => b.labelPriority - a.labelPriority);
for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, occupied);
}
@ -490,7 +500,11 @@ export function drawMap(canvas, map, options) {
const showRoads = ["roads", "all", "development", "landuse"].includes(mode);
const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode);
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 (debugBorders && map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(70,70,70,0.32)", 0.75);
if (showAdmin) drawSegments(ctx, map.adminBorders, debugBorders ? "rgba(20,90,180,0.96)" : mode === "all" ? "rgba(120,120,120,0.28)" : "rgba(120,120,120,0.65)", debugBorders ? 2.1 : mode === "all" ? 0.9 : 1.3);
if (showAdmin && mode !== "all") {
for (const p of map.adminCenters || []) dot(ctx, p, 3.0, "rgba(255,255,255,0.96)", "rgba(70,90,120,0.85)");
}
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);
@ -548,6 +562,9 @@ export function drawMap(canvas, map, options) {
}
if (showLabels) {
const adminLabels = (map.adminCenters || [])
.map((p) => ({ ...p, labelPriorityBase: mode === "admin" || mode === "admin-debug" || mode === "borders-debug" ? 90 : 8 }))
.filter((p) => mode !== "all" || p.representativeFeatureName);
const important = [
...map.modernCities,
...map.ports,
@ -555,9 +572,10 @@ export function drawMap(canvas, map, options) {
...map.castles.slice(0, mode === "all" ? 5 : 8),
...(map.satelliteCities || []),
...map.newTowns,
...(mode === "admin" || mode === "admin-debug" || mode === "borders-debug" ? adminLabels : adminLabels.slice(0, 8)),
...map.externalGateways,
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
drawLabels(ctx, important, mode === "all" ? 28 : Infinity);
drawLabels(ctx, important, mode === "all" ? 34 : Infinity);
}
}

127
test.js
View file

@ -219,6 +219,71 @@ function regionalComponentMetrics(map) {
return { regionCount: ids.size, maxComponents };
}
function meanField(map, fieldName, predicate) {
let sum = 0;
let count = 0;
const field = map[fieldName];
for (let i = 0; i < field.length; i++) {
if (!predicate(i)) continue;
sum += field[i];
count++;
}
return count ? sum / count : 0;
}
function ridgeSinuosityMetric(map) {
const centers = [];
for (let y = 1; y < MAP_H - 1; y++) {
let sum = 0;
let weight = 0;
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (map.sea[i]) continue;
const r = Math.max(0, map.ridgeField[i] - 0.36);
sum += x * r;
weight += r;
}
if (weight > 1.2) centers.push(sum / weight);
}
if (centers.length < 8) return 0;
let turn = 0;
let total = 0;
for (let i = 2; i < centers.length; i++) {
const a = centers[i - 1] - centers[i - 2];
const b = centers[i] - centers[i - 1];
turn += Math.abs(b - a);
total += Math.abs(b) + Math.abs(a) + 0.01;
}
return turn / total;
}
function terrainCoreMetrics(map) {
const land = [...map.elevation].map((_, i) => i).filter((i) => !map.sea[i]);
const mountainCells = land.filter((i) => map.elevation[i] > 0.58 || map.ridgeField[i] > 0.42).length;
const lowlandCells = land.filter((i) => map.plain[i] > 0.38 || map.depositionalLowland?.[i] > 0.24).length;
const ridgeValues = land.map((i) => map.ridgeField[i]);
const ridgeMean = ridgeValues.reduce((sum, value) => sum + value, 0) / Math.max(1, ridgeValues.length);
const ridgeVariance = ridgeValues.reduce((sum, value) => sum + (value - ridgeMean) ** 2, 0) / Math.max(1, ridgeValues.length);
const depositionTargetMean = meanField(map, "depositionField", (i) => !map.sea[i] && (map.coastalLowland[i] > 0.18 || map.basinField[i] > 0.22 || map.river[i] > 0.18 || map.flowAccum[i] > 0.24));
const depositionOtherMean = meanField(map, "depositionField", (i) => !map.sea[i] && map.coastalLowland[i] < 0.08 && map.basinField[i] < 0.12 && map.river[i] < 0.06 && map.flowAccum[i] < 0.12 && map.ridgeField[i] < 0.28);
const riverValleyMean = meanField(map, "valleyField", (i) => !map.sea[i] && map.river[i] > 0.20);
const nonRiverValleyMean = meanField(map, "valleyField", (i) => !map.sea[i] && map.river[i] <= 0.02);
return {
landCount: land.length,
mountainRatio: mountainCells / Math.max(1, land.length),
lowlandRatio: lowlandCells / Math.max(1, land.length),
ridgeVariance,
ridgeSinuosity: ridgeSinuosityMetric(map),
depositionTargetMean,
depositionOtherMean,
riverValleyMean,
nonRiverValleyMean,
depositionSum: [...map.depositionField].reduce((sum, value) => sum + value, 0),
alluvialMax: Math.max(...(map.alluvialFanField || [0])),
deltaMax: Math.max(...(map.deltaField || [0])),
};
}
try {
const map = generateMap(12345);
const other = generateMap(54321);
@ -335,6 +400,7 @@ try {
const cityCoreIntegrity = majorCityCoreIntegrity(map);
const satelliteMetrics = satelliteMunicipalityMetrics(map);
const regionalMetrics = regionalComponentMetrics(map);
const terrainMetrics = terrainCoreMetrics(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");
@ -352,6 +418,7 @@ try {
assert(map.elevation.length === size, "elevation length matches map size");
assert(map.sea.length === size, "sea length matches map size");
assert(map.ocean.length === size && map.lake.length === size, "ocean and lake masks match map size");
assert(map.river.length === size, "river length matches map size");
assert(map.landuse.length === size, "land-use length matches map size");
assert(map.adminId.length === size, "municipal id length matches map size");
@ -359,6 +426,11 @@ try {
assert(map.populationDensity.length === size, "population density length matches map size");
assert(map.ridgeField.length === size && map.valleyField.length === size && map.flowAccum.length === size, "causal terrain fields match map size");
assert(map.erosionField.length === size && map.depositionField.length === size, "erosion and deposition fields match map size");
assert(map.arcSpineField.length === size && map.branchRidgeField.length === size, "spine and branch ridge fields match map size");
assert(map.depositionalLowland.length === size && map.alluvialFanField.length === size && map.deltaField.length === size, "depositional debug fields match map size");
assert(map.naturalBarrierScore.length === size, "natural barrier score field matches map size");
assert(map.terrainTemplate && Number.isFinite(map.terrainTemplate.deposition) && Number.isFinite(map.terrainTemplate.erosion), "terrain template parameters are exposed");
assert(["east-west", "north-south", "diagonal"].includes(map.terrainTemplate.coastAxis) && map.terrainTemplate.coastSides?.length === 2, "paired coast template parameters are exposed");
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");
@ -404,10 +476,21 @@ try {
}
assert(prefectureComponents === 1, "prefecture area is a single connected component");
assert(map.prefectureBorder.length > 0, "prefecture border exists");
assert([...map.ocean].some((value) => value === 1), "edge-connected ocean mask exists");
assert([...map.lake].every((value, i) => !value || (map.sea[i] && !map.ocean[i])), "lake mask only marks isolated non-ocean water");
assert([...map.sea].every((value, i) => !value || map.ocean[i] || map.lake[i]), "water cells are classified as ocean or lake");
assert(map.adminBorders.length > 0, "municipal borders exist");
assert(map.mainRivers.length > 0, "at least one major river exists");
assert(map.tributaryRivers.length > 0, "tributary river network exists");
assert(map.smallStreams.length > 0, "small stream network exists");
assert(terrainMetrics.mountainRatio > 0.10 && terrainMetrics.mountainRatio < 0.72, "mountain and ridge area is meaningful but not total");
assert(terrainMetrics.lowlandRatio > 0.08 && terrainMetrics.lowlandRatio < 0.72, "lowlands exist without dominating every map");
assert(terrainMetrics.ridgeVariance > 0.004, "ridge field has nontrivial spatial variance");
assert(terrainMetrics.ridgeSinuosity > 0.015, "ridge centerlines are not perfectly straight bands");
assert(terrainMetrics.depositionSum > 0.2, "deposition field has nonzero values");
assert(terrainMetrics.depositionTargetMean >= terrainMetrics.depositionOtherMean * 0.85, "deposition favors rivers, basins, and coastal lowlands");
assert(terrainMetrics.riverValleyMean > terrainMetrics.nonRiverValleyMean * 1.08, "river cells overlap valley fields more than random non-river cells");
assert(terrainMetrics.alluvialMax > 0 || terrainMetrics.deltaMax > 0, "alluvial fan or delta fields are active");
assert(map.harborWorks.length <= map.ports.length, "harbor works are attached to ports");
assert(map.ports.some((p) => p.portClass === "major"), "at least one major port is classified");
assert(map.ports.every((p) => ["major", "regional", "fishing", "lake"].includes(p.portClass)), "ports have explicit classes");
@ -422,7 +505,7 @@ try {
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.changedAfterCompartmentAssignment > 0 || map.adminDebug.changedAfterLandscapePartition > 0 || map.adminDebug.changedAfterSnap > 0, "municipal compartment or terrain passes change 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");
@ -453,6 +536,17 @@ try {
assert(capitalInside, "prefectural capital is inside the prefecture");
assert(maxModernEndpointDegree <= 10, "modern transport endpoints are not over-centralized");
assert(map.entitiesForNames.every((item) => item.id && item.name), "nameable entities have ids and names");
assert(map.adminCenters.every((item) => item.id && item.name), "municipal centers have ids and names");
assert(map.entitiesForNames.some((item) => item.kind === "Municipal Center"), "municipal centers are included in label/name candidates");
assert(map.adminCenters.filter((item) => item.representativeFeatureName && String(item.name).includes(item.representativeFeatureName)).length >= Math.max(1, Math.floor(map.adminCenters.length * 0.70)), "municipal center names relate to representative feature names");
assert(map.adminCenters.every((item) => Array.from(String(item.name)).length >= 2), "municipal center names are not one-character labels");
assert(map.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), "municipal names avoid dangling one-kanji suffix fallback");
const adminNameDuplicateRatio = map.adminCenters.length ? 1 - new Set(map.adminCenters.map((item) => item.name)).size / map.adminCenters.length : 0;
assert(adminNameDuplicateRatio < 0.22, "duplicate municipal names stay low");
assert(map.adminDebug.naturalCompartmentCount > adminMetrics.municipalityCount, "natural compartments are finer than municipalities");
assert(map.adminDebug.targetMunicipalityCount >= 18 && map.adminDebug.actualMunicipalityCount >= 16, "municipality target and actual counts are dense enough");
assert(map.adminDebug.changedAfterCompartmentAssignment > 0, "municipal compartment assignment is active");
assert(Array.isArray(map.adminDebug.compartmentBorders) && map.adminDebug.compartmentBorders.length > map.adminBorders.length, "natural compartment borders are available for debug rendering");
assert(map.entitiesForNames.every((item) => typeof item.name === "string"), "nameable entity names are strings");
assert(map.entitiesForNames.every((item) => !String(item.name).includes("\uFFFD")), "generated names contain no replacement characters");
assert(map.entitiesForNames.every((item) => Array.from(String(item.name)).length >= 2), "one-character generated names are prevented");
@ -487,12 +581,35 @@ try {
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");
assert(JSON.stringify([...againA.elevation]) === JSON.stringify([...againB.elevation]), "elevation is deterministic for the same seed");
assert(JSON.stringify([...againA.ridgeField]) === JSON.stringify([...againB.ridgeField]), "ridge field is deterministic for the same seed");
assert(JSON.stringify([...againA.river]) === JSON.stringify([...againB.river]), "river field is deterministic for the same seed");
assert(JSON.stringify(terrainCoreMetrics(againA)) === JSON.stringify(terrainCoreMetrics(againB)), "terrain 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");
for (const [n, seeded] of capitalNameMaps.entries()) {
const seedValue = [114514, 12345, 54321, 777, 999][n];
const metrics = terrainCoreMetrics(seeded);
assert(seeded.mainRivers.length > 0 && seeded.tributaryRivers.length > 0 && seeded.smallStreams.length > 0, `seed ${seedValue}: river hierarchy exists`);
assert(metrics.mountainRatio > 0.08 && metrics.lowlandRatio > 0.06, `seed ${seedValue}: mountain and lowland terrain both exist`);
assert(metrics.ridgeVariance > 0.003 && metrics.ridgeSinuosity > 0.010, `seed ${seedValue}: ridges have varied jagged structure`);
assert(metrics.depositionSum > 0.1 && metrics.depositionTargetMean >= metrics.depositionOtherMean * 0.75, `seed ${seedValue}: deposition is active in plausible lowlands`);
assert(metrics.riverValleyMean > metrics.nonRiverValleyMean, `seed ${seedValue}: rivers follow valley fields`);
assert(seeded.villages.length > 0 && seeded.markets.length > 0 && seeded.modernCities.length > 0, `seed ${seedValue}: settlements are generated`);
assert(seeded.premodernRoads.length > 0 && seeded.railways.length > 0, `seed ${seedValue}: roads and railways are generated`);
assert(seeded.adminId.length === size && seeded.adminBorders.length > 0 && seeded.regionalPrefectureBorders.length > 0, `seed ${seedValue}: admin and regional borders exist`);
assert(seeded.adminCenters.every((item) => item.name && Array.from(String(item.name)).length >= 2), `seed ${seedValue}: every admin center has a valid name`);
assert(seeded.entitiesForNames.some((item) => item.kind === "Municipal Center"), `seed ${seedValue}: admin labels are included in label candidates`);
assert(seeded.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), `seed ${seedValue}: no dangling one-kanji admin suffix fallback`);
}
const byDeposition = capitalNameMaps
.map((seeded) => ({ deposition: seeded.terrainTemplate.deposition, lowlandRatio: terrainCoreMetrics(seeded).lowlandRatio }))
.sort((a, b) => a.deposition - b.deposition);
assert(byDeposition[byDeposition.length - 1].lowlandRatio >= byDeposition[0].lowlandRatio * 0.72, "higher-deposition templates generally preserve or expand lowland area");
CUSTOM_NAMES["city-0"] = "C1";
const customSameA = generateMap(321);
@ -532,7 +649,13 @@ try {
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(seeded.adminDebug.naturalCompartmentCount > metrics.municipalityCount, `seed ${seed}: compartments are finer than municipalities`);
assert(seeded.adminDebug.targetMunicipalityCount >= 18 && seeded.adminDebug.actualMunicipalityCount >= 16, `seed ${seed}: municipality count is dense enough`);
assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0, `seed ${seed}: compartment assignment is not a no-op`);
assert(Array.isArray(seeded.adminDebug.compartmentBorders) && seeded.adminDebug.compartmentBorders.length > seeded.adminBorders.length, `seed ${seed}: compartment border debug exists`);
assert(seeded.regionalDebug?.changedAfterCompartmentAssignment > 0, `seed ${seed}: regional compartment assignment changes cells`);
assert(seeded.regionalDebug?.borderNaturalBarrierAverage > 0.12, `seed ${seed}: regional borders have natural barrier affinity`);
assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0 || seeded.adminDebug.changedAfterLandscapePartition > 0 || seeded.adminDebug.changedAfterSnap > 0, `seed ${seed}: municipal compartment or 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`);