Compare commits

...

6 commits

Author SHA1 Message Date
0359bb2445 road tweak 2026-05-27 00:13:13 +09:00
a6e66f2838 road update 2026-05-26 21:14:37 +09:00
71b58cf033 border tweak 2026-05-26 16:56:18 +09:00
4f0df3f6c5 not good but not bad 2026-05-26 15:32:27 +09:00
5c82bfcab7 tweak 2026-05-26 00:45:01 +09:00
d762a88b22 city tweaks 2026-05-24 22:52:22 +09:00
20 changed files with 5776 additions and 9399 deletions

File diff suppressed because it is too large Load diff

View file

@ -409,25 +409,31 @@ function localBoundaryEnergy(labels, i, candidateId, targetScore, centerDist, po
if (same4 === 1) energy += 1.7;
if (diff4 >= 3 && targetScore[i] < 0.42) energy += 1.25;
if (diagDiff >= 3 && diff4 >= 2 && targetScore[i] < 0.50) energy += 0.42;
if (candidateId !== oldId && centerDist[candidateId] && centerDist[oldId]) {
const drift = centerDist[candidateId][i] - centerDist[oldId][i];
if (drift > 0) energy += Math.min(0.9, drift * 0.012);
if (candidateId !== oldId) {
const candidateDistance = centerDistanceAt(centerDist, candidateId, i);
const oldDistance = centerDistanceAt(centerDist, oldId, i);
if (Number.isFinite(candidateDistance) && Number.isFinite(oldDistance)) {
const drift = candidateDistance - oldDistance;
if (drift > 0) energy += Math.min(0.9, drift * 0.012);
}
}
return energy;
}
function centerDistanceAt(centerDist, id, i) {
const field = centerDist?.fields?.[id] || centerDist?.[id];
if (field) return field[i];
const center = centerDist?.centers?.[id];
if (!center || !inside(center.x, center.y)) return 24;
const [x, y] = xyOf(i);
return Math.hypot(x - center.x, y - center.y);
}
function buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea) {
const fields = [];
for (const id of adminIds) {
const center = adminCenters[id];
const field = new Float32Array(SIZE);
if (!center || !inside(center.x, center.y) || sea[indexOf(center.x, center.y)] || !prefectureMask[indexOf(center.x, center.y)]) field.fill(24);
else {
for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) field[indexOf(x, y)] = Math.hypot(x - center.x, y - center.y);
}
fields[id] = field;
}
return fields;
// Older versions materialized one full SIZE Float32Array per municipality.
// In multi-prefecture generation this can create heavy transient memory use.
// Keep the same interface conceptually, but compute distances on demand.
return { ids: adminIds, centers: adminCenters, prefectureMask, sea };
}
function repairAdminTopology(adminId, prefectureMask, sea, adminCenters = [], targetScore = null, populationDensity = null, landuse = null) {
@ -697,7 +703,7 @@ function naturalGroupKey(unit) {
if (unit.classId <= 3) return `urban:${Math.round(unit.x / 10)}:${Math.round(unit.y / 10)}`;
if (unit.classId === 5) return `coast:${Math.round(unit.y / 8)}`;
if (unit.classId === 6) return `basin:${Math.round(unit.x / 12)}:${Math.round(unit.y / 12)}`;
if (unit.classId === 7) return `valley:${Math.round((unit.x + unit.y) / 12)}`;
if (unit.classId === 7) return `valley:${Math.round(unit.x / 11)}:${Math.round(unit.y / 11)}`;
if (unit.classId === 8 || unit.classId === 9) return `mountain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`;
return `plain:${Math.round(unit.x / 14)}:${Math.round(unit.y / 14)}`;
}
@ -981,6 +987,7 @@ function splitNaturalCompartmentCompact(unit, newId, compartmentId, fields, seed
}
function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) {
const progress = typeof options.progress === "function" ? options.progress : null;
const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse);
const cellClass = new Int16Array(SIZE);
cellClass.fill(-1);
@ -991,6 +998,7 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
const requestedTarget = options.targetCompartmentCount || clamp(Math.round(landArea / 34), 40, 360);
const targetCount = clamp(Math.round(requestedTarget), Math.min(1, landArea), Math.max(1, Math.floor(landArea / 8)));
const { seeds } = chooseNaturalCompartmentSeeds(landComponents, targetCount, fields, options.seed || 0);
progress?.(`natural seeds chosen: ${seeds.length}/${targetCount}`);
const compartmentId = new Int32Array(SIZE);
compartmentId.fill(-1);
const dist = new Float32Array(SIZE);
@ -1017,22 +1025,26 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
}
}
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && compartmentId[i] < 0) compartmentId[i] = 0;
progress?.("natural seeded growth complete");
let compartments = buildUnitsFromAssignment(compartmentId, cellClass, prefectureMask, sea, fields);
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
mergeTinyLandscapeUnits(compartmentId, compartments, 9);
mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(42, Math.round(landArea / Math.max(1, targetCount) * 2.15)), 5);
progress?.(`natural post-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`);
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
refreshAllCompartmentStats(compartments, fields);
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
progress?.(`natural pre-split units: ${compartments.filter((unit) => unit && unit.area > 0).length}`);
const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(28, Math.round(landArea / Math.max(1, targetCount) * 1.55));
let guard = Math.max(80, targetCount * 3);
let guard = Math.max(60, targetCount * 2);
while (guard-- > 0) {
let active = compartments.filter((unit) => unit && unit.area > 0);
const needMore = active.length < targetCount;
const worst = active
.filter((unit) => unit.area >= 20 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2))
.filter((unit) => unit.area >= 20 && (unit._splitRejected || 0) < 3 && (needMore || unit.area > maxNaturalCompartmentArea * 1.18 || (unit.elongation || 1) > 4.2))
.sort((a, b) => {
const sa = (a.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (a.elongation || 1) - 3.0) * 1.2;
const sb = (b.area / maxNaturalCompartmentArea) * 1.8 + Math.max(0, (b.elongation || 1) - 3.0) * 1.2;
@ -1056,6 +1068,7 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
}
}
mergeWeakArtificialLandscapeUnits(compartmentId, compartments, fields, prefectureMask, sea, Math.max(48, Math.round(landArea / Math.max(1, targetCount) * 2.05)), 4);
splitDisconnectedCompartments(compartmentId, compartments, prefectureMask, sea);
compartments = renumberCompartments(compartmentId, compartments, prefectureMask, sea);
refreshAllCompartmentStats(compartments, fields);
@ -1065,125 +1078,6 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) {
return buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse, options);
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;
let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 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];
minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
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;
const unit = {
id,
cells,
area,
x: sx / area,
y: sy / area,
classId: startClass,
dominantLandscapeClass: startClass,
minX,
minY,
maxX,
maxY,
width: maxX - minX + 1,
height: maxY - minY + 1,
elongation: Math.max(maxX - minX + 1, maxY - minY + 1) / Math.max(1, Math.min(maxX - minX + 1, maxY - minY + 1)),
population: pop,
urbanWeight: urbanWeight / area,
ridgeExposure: ridgeExposure / area,
riverExposure: riverExposure / area,
coastalExposure: coastalExposure / area,
basinIdentity: basinIdentity / area,
valleyIdentity: valleyIdentity / area,
centerIds: [],
adjacent: new Map(),
};
unit.lowlandFitness = cells.reduce((sum, ci) => sum + lowlandCompartmentFitness(ci, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse), 0) / area;
unit.mountainFitness = cells.reduce((sum, ci) => sum + mountainCompartmentFitness(ci, elevation, slope, ridgeField, populationDensity, landuse), 0) / area;
compartments.push(unit);
}
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
mergeTinyLandscapeUnits(compartmentId, compartments, 12);
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
const targetCount = options.targetCompartmentCount || 0;
if (targetCount > 0) {
const fields = { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum };
const landArea = compartments.reduce((sum, unit) => sum + (unit.area || 0), 0);
const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(34, Math.round(landArea / Math.max(1, targetCount) * 1.65));
const splitScore = (unit) => {
const elongated = Math.max(0, (unit.elongation || 1) - 2.1);
const areaPressure = unit.area / Math.max(1, maxNaturalCompartmentArea);
const settled = (unit.lowlandFitness || 0) * 0.65 + (unit.urbanWeight || 0) * 0.35;
return areaPressure * 2.2 + elongated * 1.4 + settled - (unit.mountainFitness || 0) * 0.20;
};
let guard = Math.max(targetCount * 4, 80);
while (compartments.filter((unit) => unit.area > 0).length < targetCount && guard-- > 0) {
const candidates = compartments
.filter((unit) => unit.area > 0 && unit.area >= 24 && ((unit.lowlandFitness || 0) > 0.18 || unit.area > maxNaturalCompartmentArea * 1.20 || (unit.elongation || 1) > 2.8))
.sort((a, b) => splitScore(b) - splitScore(a));
const target = candidates[0];
if (!target) break;
const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard);
if (!newUnit) {
target._splitRejected = (target._splitRejected || 0) + 1;
target.elongation = Math.max(1, (target.elongation || 1) * 0.72);
if (target._splitRejected > 2) target.area = target.cells.length;
continue;
}
compartments.push(newUnit);
if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
}
guard = Math.max(targetCount * 2, 60);
while (guard-- > 0) {
const target = compartments
.filter((unit) => unit.area > 0 && unit.area >= 24 && (unit.area > maxNaturalCompartmentArea * 1.55 || ((unit.elongation || 1) > 3.2 && unit.area > maxNaturalCompartmentArea * 0.85)))
.sort((a, b) => splitScore(b) - splitScore(a))[0];
if (!target) break;
const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard + 991);
if (!newUnit) {
target.elongation = Math.max(1, (target.elongation || 1) * 0.70);
break;
}
compartments.push(newUnit);
if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
}
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) {
@ -1245,10 +1139,11 @@ function rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMas
const b = unitId[ni];
if (b < 0 || b === a || !units[b] || units[b].area === 0) continue;
const v = (targetScore[i] + targetScore[ni]) * 0.5;
const keyA = units[a].adjacent.get(b) || { count: 0, target: 0 };
keyA.count++; keyA.target += v; units[a].adjacent.set(b, keyA);
const keyB = units[b].adjacent.get(a) || { count: 0, target: 0 };
keyB.count++; keyB.target += v; units[b].adjacent.set(a, keyB);
const vertical = nx !== x ? 1 : 0;
const keyA = units[a].adjacent.get(b) || { count: 0, target: 0, vertical: 0, horizontal: 0 };
keyA.count++; keyA.target += v; if (vertical) keyA.vertical++; else keyA.horizontal++; units[a].adjacent.set(b, keyA);
const keyB = units[b].adjacent.get(a) || { count: 0, target: 0, vertical: 0, horizontal: 0 };
keyB.count++; keyB.target += v; if (vertical) keyB.vertical++; else keyB.horizontal++; units[b].adjacent.set(a, keyB);
}
}
}
@ -1280,6 +1175,65 @@ function mergeTinyLandscapeUnits(unitId, units, minArea = 10) {
}
}
function mergeUnitInto(unitId, units, fromId, toId, fields) {
const from = units[fromId];
const to = units[toId];
if (!from || !to || from.area === 0 || to.area === 0 || fromId === toId) return false;
for (const ci of from.cells) { unitId[ci] = toId; to.cells.push(ci); }
from.area = 0;
from.cells = [];
refreshCompartmentStats(to, fields.elevation, fields.slope, fields.ridgeField, fields.valleyField, fields.basinField, fields.coastalLowland, fields.plain, fields.agriculture, fields.populationDensity, fields.landuse);
return true;
}
function mergeWeakArtificialLandscapeUnits(unitId, units, fields, prefectureMask, sea, maxMergedArea = 84, passes = 5) {
// Seeded graph growth can create diagonal stair-step borders in uniform plains
// and gentle hills. If the shared edge is weak, balanced H/V, and the two
// sides are the same natural group, merge it instead of preserving an
// artificial Voronoi-like cut.
for (let pass = 0; pass < passes; pass++) {
rebuildLandscapeUnitAdjacency(unitId, units, fields.naturalBarrierScore, prefectureMask, sea);
let best = null;
let bestScore = 0.0;
for (const unit of units) {
if (!unit || unit.area === 0) continue;
for (const [otherId, edge] of unit.adjacent) {
if (otherId <= unit.id) continue;
const other = units[otherId];
if (!other || other.area === 0) continue;
const avgBarrier = edge.target / Math.max(1, edge.count);
const sameClass = unit.classId === other.classId;
const sameGroup = naturalGroupKey(unit) === naturalGroupKey(other);
const combinedArea = unit.area + other.area;
if (!sameClass && !sameGroup) continue;
if (combinedArea > maxMergedArea && unit.area > 18 && other.area > 18) continue;
const h = edge.horizontal || 0;
const v = edge.vertical || 0;
const balancedStair = Math.min(h, v) / Math.max(1, h + v);
const lowlandContinuity = Math.min(unit.lowlandFitness || 0, other.lowlandFitness || 0);
const mountainContinuity = Math.min(unit.mountainFitness || 0, other.mountainFitness || 0);
const urbanGuard = Math.max(unit.urbanWeight || 0, other.urbanWeight || 0);
const weakDivider = avgBarrier < (sameClass ? 0.46 : 0.36);
if (!weakDivider) continue;
const score =
(sameClass ? 1.7 : 0) +
(sameGroup ? 1.2 : 0) +
balancedStair * 1.4 +
edge.count * 0.018 +
lowlandContinuity * 0.85 +
mountainContinuity * 0.35 -
avgBarrier * 4.8 -
Math.max(0, combinedArea - maxMergedArea) * 0.020 -
urbanGuard * 0.20;
if (score > bestScore) best = { fromId: unit.area <= other.area ? unit.id : otherId, toId: unit.area <= other.area ? otherId : unit.id }, bestScore = score;
}
}
if (!best) break;
mergeUnitInto(unitId, units, best.fromId, best.toId, fields);
}
}
function naturalOwnershipAffinity(unit, neighbor, edge) {
const boundaryTarget = edge.target / Math.max(1, edge.count);
const sameClass = unit.classId === neighbor.classId ? 1.0 : 0;
@ -1490,10 +1444,17 @@ export function extractCompartmentBorders(compartmentId, prefectureMask, sea) {
}
export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], options = {}) {
const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse, options);
const progress = typeof options.progress === "function" ? options.progress : null;
const sharedCompartments = options.naturalCompartmentId && options.naturalCompartments
? { compartmentId: options.naturalCompartmentId, compartments: options.naturalCompartments, naturalBarrierScore: options.naturalBarrierScore || ridgeField }
: null;
const { compartmentId, compartments, naturalBarrierScore } = sharedCompartments ||
buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse, options);
progress?.("natural compartments built");
const adminId = new Int16Array(SIZE);
adminId.fill(-1);
const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options);
progress?.("natural compartments assigned");
for (const unit of compartments) {
const assigned = owner[unit.id];
if (assigned < 0) continue;
@ -1505,6 +1466,7 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e
adminId[i] = comp && owner[comp.id] >= 0 ? owner[comp.id] : 0;
}
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse);
progress?.("natural topology repaired");
const activeCompartments = compartments.filter((unit) => unit.area > 0);
const relationMetrics = compartmentMunicipalityMetrics(compartments, owner, options.targetMunicipalityCount || adminCenters.length, options.targetCompartmentCount || 0);
return {

100
app.js
View file

@ -1,18 +1,17 @@
import { generateMap } from "./mapGenerator.js";
import { generateMapAsync } from "./mapGenerator.js";
import { drawMap } from "./renderer.js";
import { landuseLabel } from "./landuseCodes.js";
const modes = [
["all", "All"],
["terrain", "Terrain"],
["suitability", "Suitability"],
["history", "Premodern"],
["modern", "Modern"],
["development", "Development"],
["landuse", "Land Use"],
["admin", "Municipal Borders"],
["admin-debug", "Admin Debug"],
["borders-debug", "Borders Debug"],
["transport-debug", "Transport Debug"],
];
const state = {
@ -21,6 +20,7 @@ const state = {
showFeatures: true,
showLabels: true,
map: null,
hoverEntities: [],
};
const canvas = document.getElementById("mapCanvas");
@ -30,11 +30,13 @@ const showFeaturesInput = document.getElementById("showFeatures");
const showLabelsInput = document.getElementById("showLabels");
const modeGrid = document.getElementById("modeGrid");
const statsEl = document.getElementById("stats");
const idsEl = document.getElementById("nameIds");
const tooltipEl = document.getElementById("mapTooltip");
const progressEl = document.getElementById("generationProgress");
const progressStageEl = document.getElementById("generationProgressStage");
const progressTimingsEl = document.getElementById("generationProgressTimings");
let generationStartedAt = 0;
let generationCurrentStage = "";
let generationTimer = null;
function parseSeed(seedText) {
const numeric = Number.parseInt(seedText, 10);
@ -80,10 +82,12 @@ function renderTimingRows(timings = []) {
function updateGenerationProgress(event) {
if (!progressEl) return;
progressEl.classList.remove("hidden");
if (event?.status === "start") generationCurrentStage = event.label || "Preparing";
if (progressStageEl) {
const elapsed = generationStartedAt ? ` / elapsed ${formatMs(performance.now() - generationStartedAt)}` : "";
progressStageEl.textContent = event?.status === "done"
? `Completed: ${event.label} / ${formatMs(event.ms)}`
: `Running: ${event?.label || "Preparing"}`;
? `Completed: ${event.label} / ${formatMs(event.ms)}${elapsed}`
: `Running: ${event?.label || generationCurrentStage || "Preparing"}${elapsed}`;
}
renderTimingRows(event?.timings || []);
}
@ -91,6 +95,19 @@ function updateGenerationProgress(event) {
function setProgressVisible(visible, message = "Preparing") {
if (!progressEl) return;
progressEl.classList.toggle("hidden", !visible);
if (visible) {
generationStartedAt = performance.now();
generationCurrentStage = message;
if (generationTimer) window.clearInterval(generationTimer);
generationTimer = window.setInterval(() => {
if (progressStageEl && !progressEl.classList.contains("hidden")) {
progressStageEl.textContent = `Running: ${generationCurrentStage || "Preparing"} / elapsed ${formatMs(performance.now() - generationStartedAt)}`;
}
}, 100);
} else if (generationTimer) {
window.clearInterval(generationTimer);
generationTimer = null;
}
if (progressStageEl) progressStageEl.textContent = message;
if (visible) renderTimingRows([]);
}
@ -103,7 +120,8 @@ function getStats(map) {
return [
["Map Type", map.terrainTemplate?.terrainTypeLabel || map.terrainDebug?.terrainTypeLabel || "-"],
["Terrain ID", map.terrainTemplate?.terrainType || map.terrainDebug?.terrainType || "-"],
["Generation Time", map.generationTotalMs ? `${formatMs(map.generationTotalMs)} / slowest ${(map.generationTimings || []).slice().sort((a, b) => b.ms - a.ms)[0]?.label || "-"}` : "-"],
["Generation Total", map.generationTotalMs ? formatMs(map.generationTotalMs) : "-"],
...(map.generationTimings || []).map((row) => [`Time: ${row.label}`, formatMs(row.ms)]),
["Villages", countText(map.villages)],
["Market Towns", countText(map.markets)],
["Castles", countText(map.castles)],
@ -120,7 +138,6 @@ function getStats(map) {
["Population", (map.totalPopulation || 0).toLocaleString()],
["Rivers", `${map.mainRivers.length} main / ${(map.tributaryRivers || []).length} tributary / ${(map.smallStreams || []).length} hidden streams`],
["Neighbor Prefecture Borders", (map.regionalPrefectureBorders || []).length],
["Harbor Works", (map.harborWorks || []).length],
["Rail Lines", map.railways.length + map.branchRailways.length + (map.ringRailways || []).length + map.externalRailways.length],
["Industrial Zones", countText(map.industrialZones)],
["National Roads", `${map.nationalRoads.length} / pop cover ${Math.round((map.transportDebug?.nationalRoadPopulationCoverage || 0) * 100)}% / uncovered ${(map.transportDebug?.nationalRoadUncoveredPopulation || 0).toLocaleString()}`],
@ -131,8 +148,7 @@ 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 ?? "-"],
["Prefecture source", map.regionalDebug?.prefectureSource ?? "-"],
];
}
@ -153,27 +169,8 @@ function renderStats(map) {
}
}
function renderNameIds(map) {
idsEl.innerHTML = "";
for (const entity of map.entitiesForNames.slice(0, 120)) {
const row = document.createElement("div");
row.className = "id-row";
const code = document.createElement("code");
code.textContent = entity.id;
const name = document.createElement("span");
const population = entity.population ? ` / ${entity.population.toLocaleString()} people` : "";
name.textContent = `${entity.name} / ${entity.kind}${population}`;
row.append(code, name);
idsEl.append(row);
}
}
function nearestEntity(map, x, y, maxDistance = 5) {
const groups = [
function buildHoverEntities(map) {
return [
...(map.modernCities || []),
...(map.ports || []),
...(map.stations || []),
@ -186,9 +183,12 @@ function nearestEntity(map, x, y, maxDistance = 5) {
...(map.villages || []),
...(map.adminCenters || []),
];
}
function nearestEntity(items, x, y, maxDistance = 5) {
let best = null;
let bestD = maxDistance;
for (const item of groups) {
for (const item of items) {
const d = Math.hypot(item.x - x, item.y - y);
if (d < bestD) { best = item; bestD = d; }
}
@ -204,6 +204,17 @@ function adminName(map, adminId) {
return center?.name || (adminId >= 0 ? `Municipality ${adminId + 1}` : "-");
}
function adminPopulation(map, adminId) {
const center = (map.adminCenters || [])[adminId];
return Number.isFinite(center?.municipalityPopulation) ? center.municipalityPopulation : null;
}
function prefectureNameForCell(map, i) {
const id = map.prefectureRegionId?.[i] ?? -1;
const region = (map.prefectureRegions || []).find((p) => p.id === id);
return region?.name || (id >= 0 ? `Prefecture ${id + 1}` : "-");
}
function updateTooltip(event) {
if (!state.map || !tooltipEl) return;
const rect = canvas.getBoundingClientRect();
@ -214,24 +225,35 @@ function updateTooltip(event) {
return;
}
const i = y * state.map.width + x;
const entity = nearestEntity(state.map, x, y);
const entity = nearestEntity(state.hoverEntities, x, y);
const elevation = state.map.elevation?.[i] ?? 0;
const density = state.map.populationDensity?.[i] ?? 0;
const hoveredAdminId = state.map.adminId?.[i] ?? -1;
const hoveredAdminPopulation = adminPopulation(state.map, hoveredAdminId);
const lines = [
`<strong>${entity ? `${entity.name} / ${entity.kind}` : `${x}, ${y}`}</strong>`,
`Admin: ${adminName(state.map, state.map.adminId?.[i] ?? -1)}`,
`Prefecture: ${prefectureNameForCell(state.map, i)}`,
`Admin: ${adminName(state.map, hoveredAdminId)}`,
`Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`,
`Land: ${state.map.sea?.[i] ? "Sea" : landuseName(state.map.landuse?.[i])}`,
`Elevation: ${elevation.toFixed(3)} / Slope: ${(state.map.slope?.[i] ?? 0).toFixed(3)}`,
`River: ${(state.map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`,
];
if (entity?.population) lines.splice(1, 0, `Population: ${entity.population.toLocaleString()}`);
tooltipEl.innerHTML = lines.join("<br>");
tooltipEl.style.left = `${event.clientX - rect.left + 14}px`;
tooltipEl.style.top = `${event.clientY - rect.top + 14}px`;
const margin = 8;
const offset = 14;
const maxLeft = Math.max(margin, rect.width - tooltipEl.offsetWidth - margin);
const maxTop = Math.max(margin, rect.height - tooltipEl.offsetHeight - margin);
const desiredLeft = event.clientX - rect.left + offset;
const desiredTop = event.clientY - rect.top + offset;
tooltipEl.style.left = `${Math.min(Math.max(margin, desiredLeft), maxLeft)}px`;
tooltipEl.style.top = `${Math.min(Math.max(margin, desiredTop), maxTop)}px`;
tooltipEl.classList.add("visible");
}
function renderModeButtons() { modeGrid.innerHTML = "";
function renderModeButtons() {
modeGrid.innerHTML = "";
for (const [key, label] of modes) {
const button = document.createElement("button");
button.type = "button";
@ -251,9 +273,9 @@ async function regenerate() {
setProgressVisible(true, "Preparing generation...");
await nextFrame();
try {
state.map = generateMap(parseSeed(state.seedText), { onProgress: updateGenerationProgress });
state.map = await generateMapAsync(parseSeed(state.seedText), { onProgress: updateGenerationProgress });
state.hoverEntities = buildHoverEntities(state.map);
renderStats(state.map);
renderNameIds(state.map);
redraw();
if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`;
renderTimingRows(state.map.generationTimings || []);

View file

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prefecture Map Generator v17</title>
<title>Prefecture Map Generator</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
@ -13,10 +13,7 @@
<header class="header">
<div>
<h1>Prefecture Map Generator v17</h1>
<p>
Terrain-highlighted prefecture generation with terrain-snapped municipalities, a clear prefectural capital,
hidden small streams, city-seeking national roads, and hover tooltips.
</p>
<p>Terrain, municipalities, transport, land use, and hover inspection in one generated map.</p>
</div>
</header>
@ -58,17 +55,6 @@
<div id="stats" class="stats"></div>
</section>
<section class="card legend">
<div class="card-title">Name Override IDs</div>
<p>Add entries to <code>names.js</code> in <code>CUSTOM_NAMES</code>.</p>
<pre class="example">export const CUSTOM_NAMES = {
"city-0": "CA",
"port-0": "PB",
"castle-0": "KC"
};</pre>
<div id="nameIds" class="id-list"></div>
</section>
<section class="card legend">
<div class="card-title">Legend</div>
<div class="legend-grid" aria-label="Map legend">
@ -85,7 +71,6 @@
<div class="legend-row"><span class="legend-icon satellite-icon"></span><span>Satellite city</span></div>
<div class="legend-row"><span class="legend-icon station-icon"></span><span>Station</span></div>
<div class="legend-row"><span class="legend-icon industry-icon"></span><span>Industry / logistics</span></div>
<div class="legend-row"><span class="legend-line harbor-line"></span><span>Harbor works</span></div>
<div class="legend-row"><span class="legend-icon newtown-icon"></span><span>New town</span></div>
</div>
</section>
@ -93,6 +78,7 @@
<section class="card legend">
<div class="card-title">Notes</div>
<p>Open <code>index.html</code> with Live Server. Open <code>test.html</code> to run browser tests.</p>
<p>Add preferred reusable place names in <code>CUSTOM_NAME_LIST</code> inside <code>names.js</code>.</p>
</section>
</aside>
</main>

View file

@ -20,7 +20,7 @@ export const LANDUSE_LABELS = Object.freeze({
[LANDUSE.INDUSTRIAL]: "Industrial zone",
[LANDUSE.LOGISTICS]: "Logistics area",
[LANDUSE.NEW_TOWN]: "New town",
[LANDUSE.ROADSIDE]: "Roadside development",
[LANDUSE.ROADSIDE]: "Suburban urban area",
[LANDUSE.FOREST]: "Forest / mountain land",
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,955 +0,0 @@
import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise } from "./mapUtils.js";
import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js";
import { LANDUSE } from "./landuseCodes.js";
// Lightweight Human Geography V2
// --------------------------------
// This replaces the heavy iterative human stage with a sparse skeleton + raster
// synthesis model:
// 1. build terrain-derived human context once
// 2. place villages/towns/cities by region quotas
// 3. make sparse approximate transport paths without full-resolution A*
// 4. synthesize population and land-use fields in one raster pass
export function generateMapFeatures(seed, terrain) {
const {
elevation,
moisture,
slope,
sea,
river,
floodplain,
plain,
agriculture,
ridgeField,
valleyField,
basinField,
coastalLowland,
flowAccum,
arcSpineField,
branchRidgeField,
depositionalLowland,
alluvialFanField,
deltaField,
portSuitability,
crossingSuitability,
passSuitability,
prefectureMask,
prefectureRegionId,
naturalBarrierScore,
} = terrain;
function regionIdAt(x, y) {
if (!inside(x, y)) return -1;
const i = indexOf(x, y);
if (sea[i]) return -1;
if (prefectureMask?.[i]) return 0;
const id = prefectureRegionId?.[i];
return id !== undefined && id >= 0 ? id : -1;
}
function inFocusedPrefecture(p) {
return Boolean(p && inside(p.x, p.y) && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]);
}
function localConfluenceScore(x, y) {
let arms = 0;
let strong = 0;
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
const rv = river[indexOf(nx, ny)];
if (rv > 0.18) arms++;
if (rv > 0.34) strong++;
}
return clamp((arms >= 3 ? 0.22 : arms === 2 ? 0.09 : 0) + strong * 0.04);
}
// --- 1. Human context: one full raster pass -----------------------------
const developable = new Float32Array(SIZE);
const ruralSuitability = new Float32Array(SIZE);
const townSuitability = new Float32Array(SIZE);
const valleySettlement = new Float32Array(SIZE);
const coastalSettlement = new Float32Array(SIZE);
const confluenceField = new Float32Array(SIZE);
const barrierCost = new Float32Array(SIZE);
const corridorCost = new Float32Array(SIZE);
const settlementCluster = new Float32Array(SIZE);
const settlementScore = 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]) {
barrierCost[i] = INF;
corridorCost[i] = INF;
continue;
}
const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.90;
const highPenalty = Math.max(0, elevation[i] - 0.56);
const lowSlope = clamp(1 - slope[i] * 2.3);
const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y) : 0;
const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38;
confluenceField[i] = confluence;
developable[i] = clamp(
plain[i] * 0.34 +
agriculture[i] * 0.24 +
basinField[i] * 0.24 +
valleyField[i] * 0.24 +
coastalLowland[i] * 0.18 +
depositional * 0.22 +
lowSlope * 0.10 -
slope[i] * 0.82 -
ridgeField[i] * 0.52 -
spine * 0.24 -
highPenalty * 1.14 -
floodplain[i] * 0.03
);
valleySettlement[i] = clamp(
valleyField[i] * 0.52 +
river[i] * 0.08 +
confluence * 0.38 +
depositional * 0.20 +
basinField[i] * 0.16 +
plain[i] * 0.08 +
lowSlope * 0.12 -
slope[i] * 0.54 -
ridgeField[i] * 0.30 -
spine * 0.16 -
highPenalty * 0.70 -
floodplain[i] * 0.10
);
coastalSettlement[i] = clamp(
coastalLowland[i] * 0.50 +
(portSuitability?.[i] || 0) * 0.30 +
(deltaField?.[i] || 0) * 0.20 +
plain[i] * 0.10 -
slope[i] * 0.52 -
ridgeField[i] * 0.24 -
spine * 0.12
);
const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16;
settlementCluster[i] = clamp((developable[i] * 0.52 + valleySettlement[i] * 0.28 + coastalSettlement[i] * 0.18 + agriculture[i] * 0.22) * clusterNoise);
ruralSuitability[i] = clamp(
agriculture[i] * 0.42 +
developable[i] * 0.28 +
valleySettlement[i] * 0.24 +
coastalSettlement[i] * 0.15 +
settlementCluster[i] * 0.24 -
Math.max(0, elevation[i] - 0.64) * 0.56
);
townSuitability[i] = clamp(
developable[i] * 0.40 +
valleySettlement[i] * 0.26 +
coastalSettlement[i] * 0.20 +
confluence * 0.34 +
basinField[i] * 0.16 +
plain[i] * 0.12 +
settlementCluster[i] * 0.16 -
slope[i] * 0.34 -
ridgeField[i] * 0.17 -
spine * 0.10
);
settlementScore[i] = clamp(ruralSuitability[i] * 0.58 + townSuitability[i] * 0.34 + confluence * 0.10);
const naturalBarrier = naturalBarrierScore?.[i] || 0;
barrierCost[i] = 1 + slope[i] * 6.4 + ridgeField[i] * 3.2 + spine * 2.2 + highPenalty * 5.8 + river[i] * 0.25 + naturalBarrier * 1.2 - valleyField[i] * 0.55 - plain[i] * 0.30 - coastalLowland[i] * 0.14;
corridorCost[i] = Math.max(0.25, barrierCost[i] - developable[i] * 0.42 - valleySettlement[i] * 0.36 - coastalSettlement[i] * 0.12 + hash2(x, y, seed + 7011) * 0.05);
}
}
// --- region statistics ---------------------------------------------------
const regionStats = new Map();
function ensureRegion(regionId) {
let st = regionStats.get(regionId);
if (!st) {
st = {
id: regionId,
area: 0,
developableCells: 0,
developableSum: 0,
valleyCells: 0,
coastCells: 0,
townCells: 0,
plainCells: 0,
minX: MAP_W,
minY: MAP_H,
maxX: 0,
maxY: 0,
};
regionStats.set(regionId, st);
}
return st;
}
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 regionId = regionIdAt(x, y);
if (regionId < 0) continue;
const st = ensureRegion(regionId);
st.area++;
st.developableSum += developable[i];
if (developable[i] > 0.16) st.developableCells++;
if (valleySettlement[i] > 0.24) st.valleyCells++;
if (coastalSettlement[i] > 0.25) st.coastCells++;
if (townSuitability[i] > 0.28) st.townCells++;
if (plain[i] > 0.24) st.plainCells++;
st.minX = Math.min(st.minX, x);
st.minY = Math.min(st.minY, y);
st.maxX = Math.max(st.maxX, x);
st.maxY = Math.max(st.maxY, y);
}
}
function visibilityFactor(regionId, st) {
if (regionId === 0) return 1.15;
if (!st || st.area <= 0) return 0;
// Small map-edge slivers should not get the same municipal/human density
// as full neighboring prefectures. This keeps external regions legible.
return clamp(Math.sqrt(st.area / 1700), 0.28, 0.92);
}
function pickRegionalPoints(scoreArray, {
stride = 1,
threshold = 0.25,
minDistance = 6,
totalMax = 100,
seedOffset = 0,
quotaForRegion,
predicate = () => true,
kind = "Point",
extraScore = () => 0,
}) {
const byRegion = new Map();
for (let y = 2; y < MAP_H - 2; y += stride) {
for (let x = 2; x < MAP_W - 2; x += stride) {
const i = indexOf(x, y);
if (sea[i] || !predicate(x, y, i)) continue;
const regionId = regionIdAt(x, y);
if (regionId < 0) continue;
const score = scoreArray[i] + extraScore(x, y, i) + hash2(x, y, seed + seedOffset) * 0.055;
if (score < threshold) continue;
if (!byRegion.has(regionId)) byRegion.set(regionId, []);
byRegion.get(regionId).push({ x, y, score, kind, regionId });
}
}
const out = [];
for (const [regionId, candidates] of [...byRegion.entries()].sort((a, b) => a[0] - b[0])) {
const st = regionStats.get(regionId);
const quota = quotaForRegion ? quotaForRegion(regionId, st) : 0;
if (quota <= 0) continue;
out.push(...pickEntities(candidates, {
max: quota,
minDistance,
threshold,
seed: seed + seedOffset + regionId * 1009,
jitter: 0.04,
}));
}
return out.sort((a, b) => b.score - a.score).slice(0, totalMax);
}
function pickGlobalPoints(scoreArray, { threshold, max, minDistance, seedOffset = 0, predicate = () => true, stride = 1 }) {
const candidates = [];
for (let y = 2; y < MAP_H - 2; y += stride) {
for (let x = 2; x < MAP_W - 2; x += stride) {
const i = indexOf(x, y);
if (sea[i] || !predicate(x, y, i)) continue;
const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.07;
if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) });
}
}
return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset });
}
// --- 2. Sparse points ----------------------------------------------------
let ports = pickGlobalPoints(portSuitability || coastalSettlement, {
threshold: 0.30 + rand(seed, 1001) * 0.08,
max: 10,
minDistance: 13,
seedOffset: 1000,
predicate: (x, y, i) => coastalSettlement[i] > 0.14 || (portSuitability?.[i] || 0) > 0.25,
}).map((p, n) => {
const i = indexOf(p.x, p.y);
const harborPotential = (portSuitability?.[i] || 0) + coastalLowland[i] * 0.22 + (deltaField?.[i] || 0) * 0.08 - slope[i] * 0.18;
const portClass = n === 0 ? "major" : n < 3 && harborPotential > 0.34 ? "regional" : harborPotential > 0.24 ? "fishing" : "lake";
const kind = portClass === "major" ? "Major Port" : portClass === "regional" ? "Regional Port" : portClass === "lake" ? "Lake Port" : "Fishing Port";
return { ...p, harborPotential, portClass, kind, score: harborPotential };
}).sort((a, b) => b.harborPotential - a.harborPotential);
if (ports.length && !ports.some((p) => p.portClass === "major")) {
ports[0].portClass = "major";
ports[0].kind = "Major Port";
}
const commercialPorts = ports.filter((p) => p.portClass === "major" || p.portClass === "regional");
const crossings = pickGlobalPoints(crossingSuitability || confluenceField, {
threshold: 0.30 + rand(seed, 1011) * 0.06,
max: 18,
minDistance: 9,
seedOffset: 1010,
predicate: (x, y, i) => river[i] > 0.12 || confluenceField[i] > 0.09,
}).map((p) => ({ ...p, kind: "River Crossing" }));
const passes = pickGlobalPoints(passSuitability || valleySettlement, {
threshold: 0.18 + rand(seed, 1021) * 0.06,
max: 12,
minDistance: 11,
seedOffset: 1020,
predicate: (x, y, i) => elevation[i] > 0.42 && !sea[i],
}).map((p) => ({ ...p, kind: "Pass" }));
const villageScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
villageScore[i] = clamp(ruralSuitability[i] * 0.68 + valleySettlement[i] * 0.26 + coastalSettlement[i] * 0.18 + settlementCluster[i] * 0.08);
}
const villages = pickRegionalPoints(villageScore, {
stride: 2,
threshold: 0.25 + rand(seed, 1031) * 0.04,
totalMax: 140,
minDistance: 5,
seedOffset: 1030,
kind: "Village",
quotaForRegion: (regionId, st) => {
if (!st || st.developableCells < 10) return 0;
const vf = visibilityFactor(regionId, st);
const raw = (st.developableCells / 65 + st.valleyCells / 44 + st.coastCells / 55 + 1.2) * vf;
const min = regionId === 0 ? 10 : st.area > 1100 ? 3 : st.area > 280 ? 1 : 0;
const max = regionId === 0 ? 30 : st.area > 1800 ? 13 : st.area > 600 ? 7 : 3;
return Math.round(clamp(raw + rand(seed, 1033 + regionId * 19) * 1.5, min, max));
},
}).map((p, n) => {
const i = indexOf(p.x, p.y);
const kind = valleySettlement[i] > 0.42 ? "Valley Village" : coastalSettlement[i] > 0.43 ? "Coastal Village" : "Village";
const population = Math.round((300 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.85) * 4700 + ruralSuitability[i] * 2600) / 100) * 100;
return { ...p, kind, population };
});
const villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2));
const marketScore = new Float32Array(SIZE);
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 featurePull = Math.max(
distanceToNearest(ports, x, y) < 8 ? 0.10 : 0,
distanceToNearest(crossings, x, y) < 6 ? 0.06 : 0,
confluenceField[i] * 0.16
);
const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.14 : 0;
marketScore[i] = clamp(
townSuitability[i] * 0.62 +
villageInfluence[i] * 0.38 +
featurePull +
valleyMouth +
basinField[i] * 0.12 +
plain[i] * 0.14 +
coastalLowland[i] * 0.08 -
slope[i] * 0.18 -
ridgeField[i] * 0.08
);
}
}
const markets = pickRegionalPoints(marketScore, {
stride: 2,
threshold: 0.31 + rand(seed, 1041) * 0.045,
totalMax: 52,
minDistance: 9,
seedOffset: 1040,
kind: "Market Town",
quotaForRegion: (regionId, st) => {
if (!st || st.townCells < 8) return 0;
const vf = visibilityFactor(regionId, st);
const raw = (st.developableCells / 260 + st.valleyCells / 150 + st.coastCells / 160 + 0.8) * vf;
const min = regionId === 0 ? 4 : st.area > 1300 ? 1 : 0;
const max = regionId === 0 ? 11 : st.area > 1800 ? 5 : st.area > 650 ? 3 : 1;
return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max));
},
extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 8 ? 0.07 : 0) + confluenceField[i] * 0.08,
}).map((p, n) => {
const i = indexOf(p.x, p.y);
const kind = coastalSettlement[i] > 0.45 && distanceToNearest(ports, p.x, p.y) < 9 ? "Port Town" : valleySettlement[i] > 0.42 ? "Valley Market Town" : "Market Town";
const population = Math.round((4000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.50) * 24000 + marketScore[i] * 13000) / 1000) * 1000;
return { ...p, kind, population };
});
const defenseScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
defenseScore[i] = clamp(
confluenceField[i] * 0.38 +
townSuitability[i] * 0.16 +
ridgeField[i] * clamp(1 - Math.abs(elevation[i] - 0.52) / 0.25) * 0.42 +
plain[i] * 0.08 -
floodplain[i] * 0.36 -
coastalLowland[i] * 0.08
);
}
const castles = pickGlobalPoints(defenseScore, {
threshold: 0.34 + rand(seed, 1051) * 0.06,
max: 5,
minDistance: 16,
seedOffset: 1050,
}).map((p) => ({
...p,
kind: elevation[indexOf(p.x, p.y)] > 0.55 ? "Mountain Castle" : elevation[indexOf(p.x, p.y)] > 0.38 ? "Hilltop Castle" : "Flatland Castle",
}));
const castleTowns = castles.map((c, n) => {
const near = markets.slice().sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y))[0];
const x = near && Math.hypot(near.x - c.x, near.y - c.y) < 10 ? near.x : c.x;
const y = near && Math.hypot(near.x - c.x, near.y - c.y) < 10 ? near.y : c.y;
return { x, y, score: c.score, kind: "Castle Town", population: 12000 + Math.round(rand(seed, 1060 + n) * 22000 / 1000) * 1000, regionId: regionIdAt(x, y) };
});
// --- 3. Cities by region, without detailed urban flood-fill --------------
function estimateUrbanCapacity(p, radius = 22, densityBias = 1.0) {
if (!p || !inside(p.x, p.y)) return 0;
const centerRegion = regionIdAt(p.x, p.y);
let capacity = 0;
const r = Math.ceil(radius);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const x = p.x + dx;
const y = p.y + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i]) continue;
if (centerRegion >= 0 && regionIdAt(x, y) !== centerRegion) continue;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
const dev = developable[i];
if (dev < 0.04) continue;
const radial = clamp(1 - d / Math.max(1, radius));
const terrainMultiplier = clamp(0.60 + plain[i] * 0.28 + basinField[i] * 0.18 + coastalLowland[i] * 0.16 + valleyField[i] * 0.13 - slope[i] * 0.42 - ridgeField[i] * 0.18, 0.26, 1.24);
capacity += dev * (900 + 6200 * Math.pow(radial, 1.25)) * terrainMultiplier * densityBias;
}
}
return Math.max(26000, Math.round(capacity / 1000) * 1000);
}
const urbanCandidates = [
...markets.map((p) => ({ ...p, candidateKind: "town" })),
...castleTowns.map((p) => ({ ...p, candidateKind: "castleTown" })),
...commercialPorts.map((p) => ({ ...p, candidateKind: "port" })),
...crossings.filter((p) => confluenceField[indexOf(p.x, p.y)] > 0.12).map((p) => ({ ...p, candidateKind: "crossing" })),
];
const cityCandidateByRegion = new Map();
for (const p of urbanCandidates) {
const i = indexOf(p.x, p.y);
const regionId = regionIdAt(p.x, p.y);
if (regionId < 0) continue;
const capacity = estimateUrbanCapacity(p, regionId === 0 ? 30 : 24, regionId === 0 ? 1.12 : 1.0);
const score =
Math.log10(capacity + 1) * 0.72 +
townSuitability[i] * 1.40 +
developable[i] * 1.05 +
confluenceField[i] * 0.22 +
(p.candidateKind === "port" ? 0.48 : 0) +
(p.candidateKind === "castleTown" ? 0.22 : 0) +
hash2(p.x, p.y, seed + 12000) * 0.16;
if (!cityCandidateByRegion.has(regionId)) cityCandidateByRegion.set(regionId, []);
cityCandidateByRegion.get(regionId).push({ ...p, score, capacity, regionId });
}
const modernCities = [];
const usedCitySites = [];
for (const [regionId, list] of [...cityCandidateByRegion.entries()].sort((a, b) => a[0] - b[0])) {
const st = regionStats.get(regionId);
if (!st || st.developableCells < 30) continue;
const vf = visibilityFactor(regionId, st);
const maxCities = regionId === 0
? clamp(Math.round(3 + st.developableCells / 520 + rand(seed, 12100) * 2), 5, 9)
: clamp(Math.round((st.developableCells / 850 + 0.8) * vf), st.area > 1500 ? 1 : 0, st.area > 2600 ? 4 : st.area > 950 ? 2 : 1);
const selected = pickEntities(list, {
max: maxCities,
minDistance: regionId === 0 ? 16 : 18,
threshold: 0,
seed: seed + 12110 + regionId * 313,
jitter: 0.02,
});
for (const p of selected) {
if (usedCitySites.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < 12)) continue;
usedCitySites.push(p);
modernCities.push(p);
}
}
if (!modernCities.some((p) => inFocusedPrefecture(p))) {
const focusCandidates = [...markets, ...commercialPorts, ...villages].filter((p) => inFocusedPrefecture(p));
let fallback = focusCandidates.sort((a, b) => {
const ai = indexOf(a.x, a.y);
const bi = indexOf(b.x, b.y);
return (townSuitability[bi] + developable[bi]) - (townSuitability[ai] + developable[ai]);
})[0];
if (!fallback) {
let best = null;
let bestScore = -INF;
for (let y = 2; y < MAP_H - 2; y += 2) {
for (let x = 2; x < MAP_W - 2; x += 2) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i]) continue;
const score = townSuitability[i] + developable[i] + hash2(x, y, seed + 12199) * 0.04;
if (score > bestScore) { bestScore = score; best = { x, y, score, kind: "Local City", regionId: 0 }; }
}
}
fallback = best;
}
if (fallback) modernCities.push({
...fallback,
candidateKind: fallback.candidateKind || "fallback",
score: fallback.score || 0.5,
capacity: estimateUrbanCapacity(fallback, 30, 1.15),
regionId: 0,
});
}
modernCities.sort((a, b) => b.capacity - a.capacity || b.score - a.score);
for (const [rank, city] of modernCities.entries()) {
const isFocused = inFocusedPrefecture(city);
const isPrefecturalCapital = isFocused && !modernCities.slice(0, rank).some((c) => c.isPrefecturalCapital);
const isRegionalCapital = !isFocused && !modernCities.slice(0, rank).some((c) => c.regionId === city.regionId && c.isRegionalCapital);
const rawPop = isPrefecturalCapital
? 450000 + rand(seed, 12200) * 1150000
: isRegionalCapital
? 160000 + rand(seed, 12201 + city.regionId * 17) * 460000
: 32000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.7) * 260000;
const capMultiplier = isPrefecturalCapital ? 1.22 : isRegionalCapital ? 1.08 : 1.0;
const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000;
city.population = Math.max(isPrefecturalCapital ? 260000 : isRegionalCapital ? 90000 : 24000, population);
city.isPrefecturalCapital = isPrefecturalCapital;
city.isRegionalCapital = isRegionalCapital;
city.rank = isPrefecturalCapital ? "Prefectural Capital" : isRegionalCapital ? "Regional Capital" : city.population >= 200000 ? "Regional City" : "Local City";
city.kind = city.rank;
city.urbanRadius = clamp(5.8 + Math.sqrt(city.population) / 72, 8, isPrefecturalCapital ? 40 : isRegionalCapital ? 32 : 24);
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 380, 2.2, isPrefecturalCapital ? 8.5 : 6.5);
city.sprawlRadius = clamp(city.urbanRadius * (isPrefecturalCapital ? 1.65 : isRegionalCapital ? 1.45 : city.population >= 120000 ? 1.28 : 1.15), city.urbanRadius + 2, isPrefecturalCapital ? 56 : isRegionalCapital ? 42 : 30);
city.urbanWeight = clamp(0.95 + Math.log10(Math.max(10000, city.population)) * 0.29, 1.08, 2.5);
}
function cityPopulationCap(city) {
const radius = city?.isPrefecturalCapital ? 34 : city?.isRegionalCapital ? 29 : city?.population >= 350000 ? 26 : 18;
const bias = city?.isPrefecturalCapital ? 1.25 : city?.isRegionalCapital ? 1.12 : 1.0;
return estimateUrbanCapacity(city, radius, bias);
}
// --- 4. Lightweight corridors -------------------------------------------
function routeLight(a, b, snapRadius = 3) {
if (!a || !b) return [];
const steps = Math.max(2, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y) * 1.15));
const out = [];
let lastKey = "";
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const fx = a.x + (b.x - a.x) * t;
const fy = a.y + (b.y - a.y) * t;
let best = null;
let bestCost = INF;
const radius = snapRadius + (s > 0 && s < steps ? 1 : 0);
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const x = Math.round(fx + dx);
const y = Math.round(fy + dy);
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i]) continue;
const lineDist = Math.hypot(x - fx, y - fy);
const cost = lineDist * 0.72 + corridorCost[i] * 0.62 - valleySettlement[i] * 0.34 - developable[i] * 0.18 + hash2(x, y, seed + 13000 + s) * 0.05;
if (cost < bestCost) {
bestCost = cost;
best = [x, y];
}
}
}
if (!best) best = [Math.round(fx), Math.round(fy)];
const key = `${best[0]},${best[1]}`;
if (key !== lastKey) {
out.push(best);
lastKey = key;
}
}
return out;
}
function importantNodesForRegion(regionId) {
const inRegion = (p) => regionIdAt(p.x, p.y) === regionId;
return [
...modernCities.filter(inRegion).map((p) => ({ ...p, nodeWeight: 8 + (p.population || 0) / 120000 })),
...markets.filter(inRegion).map((p) => ({ ...p, nodeWeight: 3.2 + (p.population || 0) / 25000 })),
...commercialPorts.filter(inRegion).map((p) => ({ ...p, nodeWeight: p.portClass === "major" ? 6.5 : 4.6 })),
...passes.filter(inRegion).map((p) => ({ ...p, nodeWeight: 2.2 })),
].sort((a, b) => b.nodeWeight - a.nodeWeight).slice(0, regionId === 0 ? 18 : 10);
}
const premodernRoads = [];
const nationalRoads = [];
const minorRoads = [];
const railways = [];
const branchRailways = [];
const externalRoads = [];
const externalRailways = [];
const expressways = [];
const ringRoads = [];
const ringRailways = [];
const ringExpressways = [];
const externalExpressways = [];
const icAccessRoads = [];
const externalGateways = [];
// Premodern roads connect castles/markets/ports sparsely.
for (const c of castles) {
const near = [...markets, ...ports, ...crossings].sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y)).slice(0, 2);
for (const n of near) {
const path = routeLight(c, n, 2);
if (path.length > 2) premodernRoads.push(path);
}
}
for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) {
const nodes = importantNodesForRegion(regionId);
if (nodes.length < 2) continue;
const connected = [nodes[0]];
const remaining = nodes.slice(1);
const maxEdges = regionId === 0 ? Math.min(14, nodes.length + 3) : Math.min(7, nodes.length + 1);
while (remaining.length && nationalRoads.length < 48) {
let best = null;
let bestScore = INF;
for (const a of connected) {
for (const b of remaining) {
const d = Math.hypot(a.x - b.x, a.y - b.y);
const score = d - (a.nodeWeight + b.nodeWeight) * 0.9;
if (score < bestScore) { bestScore = score; best = { a, b }; }
}
}
if (!best) break;
const path = routeLight(best.a, best.b, 3);
if (path.length > 2) nationalRoads.push(path);
connected.push(best.b);
remaining.splice(remaining.indexOf(best.b), 1);
if (connected.length - 1 >= maxEdges) break;
}
// A few k-nearest shortcuts for urbanized regions.
const urbanNodes = nodes.filter((p) => p.population || p.portClass).slice(0, regionId === 0 ? 8 : 4);
for (let i = 0; i < urbanNodes.length; i++) {
const a = urbanNodes[i];
const b = urbanNodes.slice(i + 1).sort((p, q) => Math.hypot(a.x - p.x, a.y - p.y) - Math.hypot(a.x - q.x, a.y - q.y))[0];
if (!b || Math.hypot(a.x - b.x, a.y - b.y) > 48) continue;
const path = routeLight(a, b, 3);
if (path.length > 2) nationalRoads.push(path);
}
// Railways: only high-order cities/ports, as a lightweight placeholder.
const railNodes = nodes.filter((p) => (p.population || 0) > 80000 || p.portClass === "major" || p.portClass === "regional").slice(0, regionId === 0 ? 7 : 4);
railNodes.sort((a, b) => a.x - b.x || a.y - b.y);
for (let i = 1; i < railNodes.length; i++) {
const path = routeLight(railNodes[i - 1], railNodes[i], 4);
if (path.length > 4) railways.push(path);
}
}
// External gateways at land edges; used by naming/UI and later transport work.
for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) {
const st = regionStats.get(regionId);
if (!st || st.area < 140) continue;
const edgeCandidates = [];
for (let y = st.minY; y <= st.maxY; y += 3) {
for (const x of [st.minX, st.maxX]) {
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (!sea[i] && regionIdAt(x, y) === regionId && (x < 5 || y < 5 || x > MAP_W - 6 || y > MAP_H - 6)) edgeCandidates.push({ x, y, score: developable[i] + valleySettlement[i] });
}
}
for (let x = st.minX; x <= st.maxX; x += 3) {
for (const y of [st.minY, st.maxY]) {
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (!sea[i] && regionIdAt(x, y) === regionId && (x < 5 || y < 5 || x > MAP_W - 6 || y > MAP_H - 6)) edgeCandidates.push({ x, y, score: developable[i] + valleySettlement[i] });
}
}
const gateway = pickEntities(edgeCandidates, { max: regionId === 0 ? 2 : 1, minDistance: 16, seed: seed + 13200 + regionId * 11 })[0];
if (gateway) {
gateway.kind = "External Gateway";
gateway.regionId = regionId;
externalGateways.push(gateway);
const target = importantNodesForRegion(regionId)[0];
if (target) {
const path = routeLight(gateway, target, 3);
if (path.length > 2) externalRoads.push(path);
}
}
}
// Approximate expressways as a very small subset of top inter-city links.
const topCities = modernCities.slice().sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, 6);
for (let i = 1; i < topCities.length && expressways.length < 4; i++) {
const a = topCities[i - 1];
const b = topCities[i];
if (Math.hypot(a.x - b.x, a.y - b.y) < 85) {
const path = routeLight(a, b, 5);
if (path.length > 5) expressways.push(path);
}
}
const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads, ...expressways], 5);
const railInfluence2 = influenceFromPaths([...railways, ...branchRailways, ...externalRailways], 4);
const stations = [];
const usedStationKeys = new Set();
function addStation(x, y, kind = "Station", score = 1) {
x = Math.round(x); y = Math.round(y);
if (!inside(x, y) || sea[indexOf(x, y)]) return;
const key = `${x},${y}`;
if (usedStationKeys.has(key)) return;
usedStationKeys.add(key);
stations.push({ x, y, kind, score, regionId: regionIdAt(x, y) });
}
for (const city of modernCities) addStation(city.x, city.y, city.isPrefecturalCapital || city.isRegionalCapital ? "Major Station" : "Station", 1.5);
for (const path of railways) for (const p of samplePath(path, 14)) addStation(p.x, p.y, "Station", 0.8);
const stationInfluence = influenceFromPoints(stations, 7, (s) => s.kind === "Major Station" ? 1.35 : 0.85);
// --- 5. Approximate city/town influence and land-use ---------------------
const cityInfluence = new Float32Array(SIZE);
const coreInfluence = new Float32Array(SIZE);
const oldTownInfluence = influenceFromPoints([...markets, ...castleTowns, ...ports], 7, (p) => p.kind === "Major Port" ? 1.2 : 0.9);
const populationDensity = new Float32Array(SIZE);
function addKernel(grid, p, radius, weight, exponent = 1.7, terrainWeighted = true, combine = "max") {
const r = Math.ceil(radius);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const x = p.x + dx;
const y = p.y + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i]) continue;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
const terrain = terrainWeighted ? clamp(0.24 + developable[i] * 1.00 + valleySettlement[i] * 0.16 + coastalSettlement[i] * 0.10 - slope[i] * 0.20 - ridgeField[i] * 0.12 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.06, 0, 1.34) : 1;
const v = weight * Math.pow(1 - d / Math.max(1, radius), exponent) * terrain;
if (combine === "add") grid[i] = Math.min(3.4, grid[i] + v);
else if (v > grid[i]) grid[i] = v;
}
}
}
for (const city of modernCities) {
addKernel(cityInfluence, city, city.sprawlRadius || Math.round((city.urbanRadius || 10) * 1.4), (city.urbanWeight || 1.0) * (city.isPrefecturalCapital ? 0.46 : city.isRegionalCapital ? 0.38 : 0.30), 2.75, true, "add");
addKernel(cityInfluence, city, city.urbanRadius || 10, city.urbanWeight || 1.0, 1.18, true, "add");
addKernel(coreInfluence, city, city.coreRadius || 3, (city.urbanWeight || 1.0) * 1.10, 1.65, true, "max");
}
const townInfluence = influenceFromPoints(markets, 6, (m) => clamp((m.population || 10000) / 26000, 0.45, 1.25));
// Industrial/logistics/new town placeholders remain lightweight. They are
// routed by land-use proximity rather than expensive search passes.
const industrialZones = [];
for (const p of [...commercialPorts, ...modernCities.slice(0, 5)]) {
const candidates = [];
for (let dy = -10; dy <= 10; dy++) {
for (let dx = -10; dx <= 10; dx++) {
const x = p.x + dx;
const y = p.y + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i]) continue;
const d = Math.hypot(dx, dy);
if (d < 3 || d > 10) continue;
const score = coastalLowland[i] * 0.22 + developable[i] * 0.22 + roadInfluence[i] * 0.20 + plain[i] * 0.12 - slope[i] * 0.25 + hash2(x, y, seed + 14000) * 0.06;
if (score > 0.22) candidates.push({ x, y, score, kind: "Industrial Zone", regionId: regionIdAt(x, y) });
}
}
const z = pickEntities(candidates, { max: 1, minDistance: 6, seed: seed + 14010 + p.x * 3 + p.y })[0];
if (z && industrialZones.every((q) => Math.hypot(q.x - z.x, q.y - z.y) > 13)) industrialZones.push(z);
if (industrialZones.length >= 8) break;
}
const industrialInfluence = influenceFromPoints(industrialZones, 5, () => 1.0);
const satelliteCities = [];
const newTowns = [];
const logisticsParks = [];
const interchanges = [];
var landuse = new Uint8Array(SIZE);
// Re-run land-use classification after landuse allocation. The loop above is
// intentionally inside a helper to keep all thresholds in one place.
function classifyLanduse() {
landuse.fill(LANDUSE.RURAL);
let maxDensity = 0;
const baseNoiseSeed = seed + 15000;
const urbanCapacity = new Float32Array(SIZE);
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const transport = Math.max(roadInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82);
const urban = cityInfluence[i] * 0.76 + stationInfluence[i] * 0.26 + roadInfluence[i] * 0.12 + railInfluence2[i] * 0.10;
const core = coreInfluence[i];
const oldTown = oldTownInfluence[i] * 0.70 + townInfluence[i] * 0.38;
const rural = villageInfluence[i] * 0.24 + ruralSuitability[i] * 0.30;
const riverUrban = clamp(river[i] * 0.12 + valleyField[i] * 0.10 + plain[i] * 0.08 + basinField[i] * 0.08 - floodplain[i] * 0.10);
urbanCapacity[i] = clamp(
developable[i] * 0.66 +
plain[i] * 0.16 +
basinField[i] * 0.16 +
valleyField[i] * 0.16 +
coastalLowland[i] * 0.12 +
transport * 0.18 +
riverUrban * 0.14 -
slope[i] * 0.18 -
ridgeField[i] * 0.12 -
floodplain[i] * 0.08
);
populationDensity[i] = clamp(urban * 0.66 + core * 0.46 + oldTown * 0.28 + townInfluence[i] * 0.16 + villageInfluence[i] * 0.14 + transport * 0.12);
maxDensity = Math.max(maxDensity, populationDensity[i]);
if (elevation[i] > 0.67 || (slope[i] > 0.56 && ridgeField[i] > 0.30) || ridgeField[i] > 0.70) {
landuse[i] = LANDUSE.FOREST;
continue;
}
if (industrialInfluence[i] > 0.22 && urbanCapacity[i] > 0.10) {
landuse[i] = LANDUSE.INDUSTRIAL;
continue;
}
if (core > 0.38 && urbanCapacity[i] > 0.10) {
landuse[i] = LANDUSE.CBD;
continue;
}
if (oldTown > 0.18 && urbanCapacity[i] > 0.09) {
landuse[i] = LANDUSE.OLD_URBAN;
continue;
}
const suburbanity = urban * 0.88 + transport * 0.23 + stationInfluence[i] * 0.12 + townInfluence[i] * 0.08 + riverUrban * 0.08;
const edgeTaper = clamp(cityInfluence[i] * 0.52 + stationInfluence[i] * 0.16 + roadInfluence[i] * 0.10 + 0.28);
const sprawlBias = clamp(0.58 + hash2(x, y, baseNoiseSeed) * 0.42);
const sprawlScore = suburbanity * sprawlBias * edgeTaper - core * 0.12;
if (sprawlScore > 0.24 && urbanCapacity[i] > 0.10) {
landuse[i] = LANDUSE.SUBURB;
} else if (transport > 0.18 && urbanCapacity[i] > 0.10 && (townInfluence[i] > 0.04 || cityInfluence[i] > 0.09)) {
landuse[i] = transport > 0.28 && stationInfluence[i] > 0.10 ? LANDUSE.SUBURB : LANDUSE.ROADSIDE;
} else if (agriculture[i] > 0.24 || rural > 0.22 || (developable[i] > 0.18 && plain[i] > 0.18)) {
landuse[i] = LANDUSE.FARMLAND;
} else {
landuse[i] = elevation[i] > 0.52 || slope[i] > 0.34 ? LANDUSE.FOREST : LANDUSE.RURAL;
}
}
}
const baseLanduse = landuse.slice();
const isBuilt = (lu) => lu >= LANDUSE.OLD_URBAN && lu <= LANDUSE.ROADSIDE;
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] || baseLanduse[i] === LANDUSE.FOREST) continue;
const transport = Math.max(roadInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82);
let urbanNeighbors = 0;
let cbdNeighbors = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const lu = baseLanduse[indexOf(x + dx, y + dy)];
if (isBuilt(lu)) urbanNeighbors++;
if (lu === LANDUSE.CBD) cbdNeighbors++;
}
}
if (baseLanduse[i] === LANDUSE.OLD_URBAN && coreInfluence[i] > 0.31 && cbdNeighbors >= 3) {
landuse[i] = LANDUSE.CBD;
continue;
}
if ((baseLanduse[i] === LANDUSE.FARMLAND || baseLanduse[i] === LANDUSE.RURAL) && urbanCapacity[i] > 0.10) {
const fringeChance = urbanNeighbors * 0.055 + cityInfluence[i] * 0.13 + transport * 0.12 + stationInfluence[i] * 0.08;
const noise = 0.23 + hash2(x, y, seed + 15050) * 0.24;
if (fringeChance > 0.34 + noise) {
landuse[i] = urbanNeighbors >= 4 || transport > 0.28 ? LANDUSE.SUBURB : LANDUSE.ROADSIDE;
}
}
if ((river[i] > 0.10 || railInfluence2[i] > 0.16 || roadInfluence[i] > 0.22) && urbanNeighbors >= 3 && landuse[i] <= LANDUSE.FARMLAND && urbanCapacity[i] > 0.09) {
landuse[i] = cbdNeighbors >= 1 || coreInfluence[i] > 0.15 ? LANDUSE.OLD_URBAN : LANDUSE.SUBURB;
}
if (landuse[i] === LANDUSE.SUBURB && urbanNeighbors <= 1) {
const keep = clamp(cityInfluence[i] * 0.52 + transport * 0.32 + stationInfluence[i] * 0.18 + 0.08);
if (hash2(x, y, seed + 15051) > keep) {
landuse[i] = agriculture[i] > 0.22 ? LANDUSE.FARMLAND : LANDUSE.RURAL;
}
}
}
}
if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity);
}
classifyLanduse();
for (const city of modernCities) {
let urbanFootprintCells = 0;
let coreFootprintCells = 0;
const r = Math.ceil((city.urbanRadius || 8) * 1.3);
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]) continue;
if (Math.hypot(dx, dy) > r) continue;
if (landuse[i] >= LANDUSE.OLD_URBAN && landuse[i] <= LANDUSE.ROADSIDE) urbanFootprintCells++;
if (landuse[i] === LANDUSE.CBD) coreFootprintCells++;
}
}
city.urbanFootprintCells = urbanFootprintCells;
city.coreFootprintCells = coreFootprintCells;
}
const transportDebug = {
humanStageVersion: "v2-sparse-raster",
aStarRoutes: 0,
regionalNodeCount: [...regionStats.keys()].reduce((sum, regionId) => sum + importantNodesForRegion(regionId).length, 0),
nationalRoadPopulationCoverage: 0,
nationalRoadUncoveredPopulation: 0,
};
return {
ports,
crossings,
passes,
settlementCluster,
settlementScore,
villages,
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,
stationInfluence,
roadInfluence,
railInfluence2,
villageInfluence,
externalGateways,
cityPopulationCap,
transportDebug,
};
}

View file

@ -1 +1 @@
export { generateMap, CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapPipeline.js";
export { generateMap, generateMapAsync, CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapPipeline.js";

View file

@ -474,20 +474,21 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river,
}
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);
const displayRegionId = new Int16Array(beforeRegionId);
for (let pass = 0; pass < 3; pass++) repairRegionalTopology(displayRegionId, sea, seeded.centers, anchorMask, 200);
repairDisconnectedRegionalPrefectures(regionId, sea, seeded.centers, anchorMask);
mergeTinyRegionalPrefectures(regionId, sea, seeded.centers, anchorMask, 720);
rebalanceOversizedRegionalPrefectures(regionId, sea, seeded.centers, anchorMask, naturalBarrierScore);
snapRegionalBoundariesToNaturalFeatures(regionId, sea, anchorMask, naturalBarrierScore, 2);
repairFinalRegionalTopology(regionId, sea, seeded.centers, anchorMask, naturalBarrierScore);
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 afterNaturalAverage = measuredAfterNaturalAverage;
const afterVoronoiLikeRate = regionalVoronoiLikeRate(regionId, seeded.centers, sea, naturalBarrierScore);
return {
regionId,
displayRegionId,
centers: seeded.centers,
naturalBarrierScore,
debug: {
@ -498,13 +499,16 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river,
regionalVoronoiLikeRateAfter: afterVoronoiLikeRate,
regionalNaturalBarrierAverageBefore: beforeNaturalAverage,
regionalNaturalBarrierAverageAfter: afterNaturalAverage,
regionalDisplayBorderCount: countRegionBorderEdges(displayRegionId, sea),
regionalDisplayNaturalBarrierAverage: averageRegionBorderBarrier(displayRegionId, sea, naturalBarrierScore),
regionalDisplayBorderCount: countRegionBorderEdges(regionId, sea),
regionalDisplayNaturalBarrierAverage: averageRegionBorderBarrier(regionId, sea, naturalBarrierScore),
regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length,
compartmentCount: compartments.filter((unit) => unit.area > 0).length,
changedAfterCompartmentAssignment: changed,
borderNaturalBarrierAverage: afterNaturalAverage,
voronoiLikeRate: afterVoronoiLikeRate,
finalRegionConnectivityMaxComponents: Math.max(0, ...[...new Set([...regionId].filter((id, i) => id >= 0 && !sea[i]))].map((id) => collectRegionComponents(regionId, sea, id).length)),
finalRegionalEnclaveCount: countRegionalEnclaves(regionId, sea),
finalRegionalMaxAreaShare: maxRegionalAreaShare(regionId, sea),
},
};
}
@ -537,9 +541,9 @@ function generateRegionalPrefecturesSeedGrowth(seed, sea, elevation, slope, rive
}
}
centers.push(...pickEntities(candidates, {
max: 9 + Math.floor(rand(seed, 6101) * 6),
minDistance: 22,
threshold: 0.38,
max: 6 + Math.floor(rand(seed, 6101) * 4),
minDistance: 31,
threshold: 0.42,
seed: seed + 6102,
jitter: 0.02,
}));
@ -583,6 +587,357 @@ function generateRegionalPrefecturesSeedGrowth(seed, sea, elevation, slope, rive
return { regionId, centers };
}
function collectRegionComponents(regionId, sea, id) {
const seen = new Uint8Array(SIZE);
const components = [];
for (let i = 0; i < SIZE; i++) {
if (seen[i] || sea[i] || regionId[i] !== id) continue;
const queue = [i];
const cells = [];
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
cells.push(cur);
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);
}
return components.sort((a, b) => b.length - a.length);
}
function chooseRegionalReassignment(cells, regionId, sea, centers, forbiddenId = -1) {
const adjacent = new Map();
let sx = 0, sy = 0;
for (const i of cells) {
const [x, y] = xyOf(i);
sx += x; sy += y;
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
const id = regionId[ni];
if (sea[ni] || id < 0 || id === forbiddenId) continue;
adjacent.set(id, (adjacent.get(id) || 0) + 1);
}
}
let bestId = -1;
let bestScore = -INF;
const cx = sx / Math.max(1, cells.length);
const cy = sy / Math.max(1, cells.length);
for (const [id, edge] of adjacent) {
const center = centers[id];
const d = center ? Math.hypot(center.x - cx, center.y - cy) : 0;
const score = edge * 4 - d * 0.04 + (id === 0 ? -1.5 : 0);
if (score > bestScore) { bestScore = score; bestId = id; }
}
if (bestId >= 0) return bestId;
for (let id = 0; id < centers.length; id++) {
if (id === forbiddenId || !centers[id]) continue;
const d = Math.hypot(centers[id].x - cx, centers[id].y - cy);
const score = -d + (id === 0 ? -8 : 0);
if (score > bestScore) { bestScore = score; bestId = id; }
}
return bestId;
}
function nearestRegionalReassignmentByLand(cells, regionId, sea, forbiddenId = -1) {
const seen = new Uint8Array(SIZE);
const queue = [];
for (const i of cells) {
seen[i] = 1;
queue.push(i);
}
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const [x, y] = xyOf(cur);
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (sea[ni] || seen[ni]) continue;
const id = regionId[ni];
if (id >= 0 && id !== forbiddenId) return id;
seen[ni] = 1;
queue.push(ni);
}
}
return -1;
}
function repairDisconnectedRegionalPrefectures(regionId, sea, centers, anchorMask) {
let totalChanged = 0;
for (let pass = 0; pass < 10; pass++) {
let changedThisPass = 0;
const ids = [...new Set([...regionId].filter((id, i) => id >= 0 && !sea[i]))].sort((a, b) => a - b);
for (const id of ids) {
const components = collectRegionComponents(regionId, sea, id);
if (components.length <= 1) continue;
let keepIndex = 0;
if (id === 0) {
const anchorIndex = components.findIndex((cells) => cells.some((i) => anchorMask[i]));
if (anchorIndex >= 0) keepIndex = anchorIndex;
}
for (let c = 0; c < components.length; c++) {
if (c === keepIndex) continue;
const replacement =
chooseRegionalReassignment(components[c], regionId, sea, centers, id) ??
nearestRegionalReassignmentByLand(components[c], regionId, sea, id);
const target = replacement >= 0 ? replacement : nearestRegionalReassignmentByLand(components[c], regionId, sea, id);
if (target < 0) continue;
for (const i of components[c]) {
if (anchorMask[i]) continue;
regionId[i] = target;
changedThisPass++;
}
}
}
totalChanged += changedThisPass;
for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0;
if (changedThisPass === 0) break;
}
return totalChanged;
}
function componentTouchesOutside(cells, sea) {
for (const i of cells) {
const [x, y] = xyOf(i);
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) return true;
for (const [nx, ny] of neighbors4(x, y)) if (sea[indexOf(nx, ny)]) return true;
}
return false;
}
function boundaryNeighborIds(cells, regionId, sea, ownId) {
const ids = new Set();
for (const i of cells) {
const [x, y] = xyOf(i);
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
const id = regionId[ni];
if (!sea[ni] && id >= 0 && id !== ownId) ids.add(id);
}
}
return ids;
}
function countRegionalEnclaves(regionId, sea) {
let count = 0;
const ids = [...new Set([...regionId].filter((id, i) => id >= 0 && !sea[i]))].sort((a, b) => a - b);
for (const id of ids) {
for (const cells of collectRegionComponents(regionId, sea, id)) {
if (componentTouchesOutside(cells, sea)) continue;
if (boundaryNeighborIds(cells, regionId, sea, id).size === 1) count++;
}
}
return count;
}
function carveRegionalCorridor(regionId, sea, cells, ownId, enclosingId, naturalBarrierScore) {
const best = new Float32Array(SIZE);
const cameFrom = new Int32Array(SIZE);
best.fill(INF);
cameFrom.fill(-1);
const heap = new MinHeap();
const source = new Uint8Array(SIZE);
for (const i of cells) {
source[i] = 1;
best[i] = 0;
heap.push({ i, f: 0 });
}
let target = -1;
while (heap.length) {
const cur = heap.pop();
if (!cur || cur.f > best[cur.i] + 1e-5) continue;
const [x, y] = xyOf(cur.i);
if (!source[cur.i]) {
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) { target = cur.i; break; }
let seaAdjacent = false;
let otherAdjacent = false;
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (sea[ni]) seaAdjacent = true;
else if (regionId[ni] >= 0 && regionId[ni] !== ownId && regionId[ni] !== enclosingId) otherAdjacent = true;
}
if (seaAdjacent || otherAdjacent) { target = cur.i; break; }
}
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (sea[ni] || regionId[ni] === ownId) continue;
const id = regionId[ni];
const regionPenalty = id === enclosingId ? 0 : 8;
const barrier = naturalBarrierScore?.[ni] || 0;
const nd = cur.f + 1 + barrier * 3.2 + regionPenalty;
if (nd < best[ni]) {
best[ni] = nd;
cameFrom[ni] = cur.i;
heap.push({ i: ni, f: nd });
}
}
}
if (target < 0) return 0;
let changed = 0;
for (let i = target; i >= 0 && regionId[i] !== ownId; i = cameFrom[i]) {
if (!sea[i] && regionId[i] !== ownId) {
regionId[i] = ownId;
changed++;
}
if (cameFrom[i] < 0) break;
}
return changed;
}
function repairRegionalEnclaves(regionId, sea, centers, anchorMask, naturalBarrierScore) {
let changed = 0;
const ids = [...new Set([...regionId].filter((id, i) => id >= 0 && !sea[i]))].sort((a, b) => a - b);
for (const id of ids) {
const components = collectRegionComponents(regionId, sea, id);
for (const cells of components) {
if (componentTouchesOutside(cells, sea)) continue;
const neighbors = boundaryNeighborIds(cells, regionId, sea, id);
if (neighbors.size !== 1) continue;
const enclosingId = [...neighbors][0];
const protectedAnchor = id === 0 && cells.some((i) => anchorMask[i]);
if (protectedAnchor) {
changed += carveRegionalCorridor(regionId, sea, cells, id, enclosingId, naturalBarrierScore);
} else {
for (const i of cells) {
if (anchorMask[i]) continue;
regionId[i] = enclosingId;
changed++;
}
}
}
}
for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0;
return changed;
}
function repairFinalRegionalTopology(regionId, sea, centers, anchorMask, naturalBarrierScore) {
let totalChanged = 0;
for (let pass = 0; pass < 12; pass++) {
const disconnected = repairDisconnectedRegionalPrefectures(regionId, sea, centers, anchorMask);
const enclaves = repairRegionalEnclaves(regionId, sea, centers, anchorMask, naturalBarrierScore);
totalChanged += disconnected + enclaves;
if (disconnected + enclaves === 0) break;
}
return totalChanged;
}
function regionalAreaById(regionId, sea) {
const area = new Map();
for (let i = 0; i < SIZE; i++) if (!sea[i] && regionId[i] >= 0) area.set(regionId[i], (area.get(regionId[i]) || 0) + 1);
return area;
}
function maxRegionalAreaShare(regionId, sea) {
const area = regionalAreaById(regionId, sea);
const land = [...area.values()].reduce((sum, value) => sum + value, 0);
return land ? Math.max(0, ...area.values()) / land : 0;
}
function canMoveRegionalBoundaryCell(regionId, sea, i, ownId) {
const [x, y] = xyOf(i);
let ownNeighbors = 0;
let otherNeighbors = 0;
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (sea[ni]) continue;
if (regionId[ni] === ownId) ownNeighbors++;
else if (regionId[ni] >= 0) otherNeighbors++;
}
return ownNeighbors >= 2 && otherNeighbors > 0;
}
function rebalanceOversizedRegionalPrefectures(regionId, sea, centers, anchorMask, naturalBarrierScore) {
const minArea = 720;
for (let pass = 0; pass < 4; pass++) {
const area = regionalAreaById(regionId, sea);
const land = [...area.values()].reduce((sum, value) => sum + value, 0);
const maxArea = Math.max(minArea * 2, Math.floor(land * 0.32));
let changed = 0;
const oversized = [...area.entries()].filter(([, value]) => value > maxArea).sort((a, b) => b[1] - a[1]);
for (const [id] of oversized) {
const candidates = [];
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] || regionId[i] !== id || anchorMask[i] || !canMoveRegionalBoundaryCell(regionId, sea, i, id)) continue;
const counts = new Map();
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
const other = regionId[ni];
if (!sea[ni] && other >= 0 && other !== id && (area.get(other) || 0) < maxArea) counts.set(other, (counts.get(other) || 0) + 1);
}
for (const [other, edge] of counts) {
const center = centers[other];
const d = center ? Math.hypot(center.x - x, center.y - y) : 0;
candidates.push({ i, other, score: edge * 2.0 + (naturalBarrierScore?.[i] || 0) * 1.4 - d * 0.006 });
}
}
}
candidates.sort((a, b) => b.score - a.score || a.i - b.i);
for (const candidate of candidates) {
if ((area.get(id) || 0) <= maxArea) break;
if ((area.get(candidate.other) || 0) >= maxArea || regionId[candidate.i] !== id) continue;
regionId[candidate.i] = candidate.other;
area.set(id, (area.get(id) || 0) - 1);
area.set(candidate.other, (area.get(candidate.other) || 0) + 1);
changed++;
}
}
repairDisconnectedRegionalPrefectures(regionId, sea, centers, anchorMask);
if (!changed) break;
}
}
function snapRegionalBoundariesToNaturalFeatures(regionId, sea, anchorMask, naturalBarrierScore, passes = 2) {
for (let pass = 0; pass < passes; pass++) {
const before = new Int16Array(regionId);
let changed = 0;
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
const own = before[i];
if (sea[i] || own < 0 || anchorMask[i]) continue;
const hereBarrier = naturalBarrierScore?.[i] || 0;
if (hereBarrier > 0.54 || !canMoveRegionalBoundaryCell(before, sea, i, own)) continue;
const counts = new Map();
let bestNeighborBarrier = 0;
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
const other = before[ni];
if (sea[ni] || other < 0) continue;
bestNeighborBarrier = Math.max(bestNeighborBarrier, naturalBarrierScore?.[ni] || 0);
if (other !== own) counts.set(other, (counts.get(other) || 0) + 1);
}
if (counts.size === 0 || bestNeighborBarrier < hereBarrier + 0.18) continue;
let target = -1, best = -1;
for (const [other, count] of counts) if (count > best) { best = count; target = other; }
if (target >= 0 && best >= 2) {
regionId[i] = target;
changed++;
}
}
}
if (!changed) break;
}
}
function mergeTinyRegionalPrefectures(regionId, sea, centers, anchorMask, minArea) {
const ids = [...new Set([...regionId].filter((id, i) => id >= 0 && !sea[i]))].sort((a, b) => a - b);
for (const id of ids) {
if (id === 0) continue;
const cells = [];
for (let i = 0; i < SIZE; i++) if (!sea[i] && regionId[i] === id) cells.push(i);
if (cells.length === 0 || cells.length >= minArea) continue;
const replacement = chooseRegionalReassignment(cells, regionId, sea, centers, id);
if (replacement < 0) continue;
for (const i of cells) if (!anchorMask[i]) regionId[i] = replacement;
}
}
function buildRegionalNaturalBarrierScore(sea, elevation, slope, river, ridgeField, flowAccum) {
const score = new Float32Array(SIZE);
for (let y = 0; y < MAP_H; y++) {
@ -811,20 +1166,50 @@ function repairRegionalTopology(regionId, sea, centers, anchorMask, maxIslandCel
for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0;
}
export function extractRegionBorderSegments(regionId, sea) {
export function extractRegionBorderSegments(regionId, sea, options = {}) {
const segments = [];
const nameById = options.prefectureRegions
? new Map(options.prefectureRegions.map((region) => [region.id, region.name]))
: null;
const fail = (message) => {
if (options.throwOnInvalid) throw new Error(message);
if (options.logInvalid) console.error(message);
};
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)];
const ni = indexOf(x + 1, y);
const b = regionId[ni];
if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
else if (options.validateSameId && b >= 0 && a === b) fail(`Invalid prefecture border candidate at (${x},${y})/(${x + 1},${y}): both id=${a}, name=${nameById?.get(a) || "-"}`);
}
if (y + 1 < MAP_H && !sea[indexOf(x, y + 1)]) {
const b = regionId[indexOf(x, y + 1)];
const ni = indexOf(x, y + 1);
const b = regionId[ni];
if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
else if (options.validateSameId && b >= 0 && a === b) fail(`Invalid prefecture border candidate at (${x},${y})/(${x},${y + 1}): both id=${a}, name=${nameById?.get(a) || "-"}`);
}
}
}
if (options.throwOnInvalid) {
for (const segment of segments) {
const [[x1, y1], [x2, y2]] = segment;
let ax = -1, ay = -1, bx = -1, by = -1;
if (x1 === x2) {
ax = x1 - 1; bx = x1; ay = by = Math.min(y1, y2);
} else if (y1 === y2) {
ax = bx = Math.min(x1, x2); ay = y1 - 1; by = y1;
}
if (!inside(ax, ay) || !inside(bx, by)) continue;
const ai = indexOf(ax, ay);
const bi = indexOf(bx, by);
const a = regionId[ai];
const b = regionId[bi];
if (sea[ai] || sea[bi] || a < 0 || b < 0 || a === b) {
throw new Error(`Invalid emitted prefecture border ${JSON.stringify(segment)} between (${ax},${ay}) id=${a} name=${nameById?.get(a) || "-"} and (${bx},${by}) id=${b} name=${nameById?.get(b) || "-"}`);
}
}
}
@ -915,69 +1300,3 @@ export function applyOutputOptions(map, options = {}) {
delete slim.naturalBarrierScore;
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 capitalLike = city.isPrefecturalCapital || city.isRegionalCapital;
const base = city.isPrefecturalCapital ? 90000 : city.isRegionalCapital ? 62000 : city.kind === "Satellite City" ? 16000 : 32000;
const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.isRegionalCapital ? 1350 : city.kind === "Satellite City" ? 900 : 1200);
const coreComponent = coreCells * 3200;
const densityComponent = densitySum * 360;
const computedPopulation = base + urbanComponent + coreComponent + densityComponent;
const footprintCells = city.urbanFootprintCells || urbanCells;
const footprintCoreCells = city.coreFootprintCells || coreCells;
const footprintCap = base + footprintCells * (city.isPrefecturalCapital ? 8500 : city.isRegionalCapital ? 7000 : city.kind === "Satellite City" ? 4300 : 5200) + footprintCoreCells * (city.isPrefecturalCapital ? 10500 : 9000);
city.population = Math.round(Math.max(base, Math.min(computedPopulation, footprintCap)) / 1000) * 1000;
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 95, city.kind === "Satellite City" ? 5 : 7, capitalLike ? 34 : 28);
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 360, 2.2, capitalLike ? 9 : 8);
}
}

View file

@ -1,9 +1,14 @@
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";
import { CELL_SIZE, INF, MAP_H, MAP_W, MinHeap, clamp, indexOf, inside, rand, xyOf } from "./mapUtils.js";
import { applyOutputOptions, attachIdsAndNames, influenceFromPaths, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
import { routeQualityAcceptable } from "./mapTransport.js";
const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u;
function stripMunicipalSuffix(name) {
return String(name || "").replace(/[市町村区]$/u, "").trim();
}
function municipalitySuffixForCenter(center, fields, seed, ordinal = 0) {
const i = inside(center?.x || 0, center?.y || 0) ? indexOf(center.x, center.y) : 0;
const density = fields.populationDensity?.[i] || 0;
@ -18,12 +23,228 @@ function municipalitySuffixForCenter(center, fields, seed, ordinal = 0) {
function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) {
let value = String(root || center?.name || "").trim();
if (!value) value = `自治${ordinal + 1}`;
value = value.replace(/[駅港城跡宿]$/u, "");
if (Array.from(value).length < 2) value = `${value}${String(center?.generatedMunicipalityName || "里")}`.slice(0, 3);
if (MUNICIPAL_SUFFIX_RE.test(value)) return value;
value = value.replace(/[市町村区駅港城跡宿]$/gu, "");
const fallback = String(center?.generatedMunicipalityName || "里").replace(/[市町村区駅港城跡宿]$/gu, "");
if (Array.from(value).length < 2) value = `${value}${fallback || "里"}`;
// Municipality roots should be at most two toponymic elements. The admin
// suffix is separate; avoid direction+root+suffix three-element names.
value = Array.from(value).slice(0, 2).join("");
return `${value}${municipalitySuffixForCenter(center, fields, seed, ordinal)}`;
}
function assignMunicipalityPopulations(adminCenters, adminId, fields, settlementFeatures = []) {
if (!adminCenters?.length || !adminId) return;
const totals = new Float64Array(adminCenters.length);
const settlementTotals = new Float64Array(adminCenters.length);
for (let i = 0; i < adminId.length; i++) {
const id = adminId[i];
if (id < 0 || id >= totals.length || fields.sea?.[i]) continue;
const density = fields.populationDensity?.[i] || 0;
const lu = fields.landuse?.[i] ?? 0;
const plain = fields.plain?.[i] || 0;
const agri = fields.agriculture?.[i] || 0;
const builtWeight = lu === 3 ? 520 : lu === 2 ? 360 : lu === 4 || lu === 7 || lu === 8 ? 260 : lu === 5 || lu === 6 ? 160 : lu === 1 ? 82 : 22;
const ruralFloor = lu === 1 ? 10 + agri * 18 + plain * 10 : 0;
totals[id] += density * builtWeight + ruralFloor;
}
// Population-bearing generated settlements are canonical entities, so add
// their explicit populations exactly once to the municipality containing the
// point. Some towns are promoted to cities later, so the same coordinate can
// appear in both `markets` and `modernCities`; keep only the strongest record
// per coordinate to avoid double counting.
const uniqueSettlementByCell = new Map();
for (const feature of settlementFeatures || []) {
if (!feature || !Number.isFinite(feature.population) || feature.population <= 0) continue;
if (!inside(feature.x, feature.y)) continue;
const i = indexOf(feature.x, feature.y);
if (fields.sea?.[i]) continue;
const key = `${Math.round(feature.x)}:${Math.round(feature.y)}`;
const current = uniqueSettlementByCell.get(key);
const priority = (feature.isPrefecturalCapital ? 4_000_000 : 0) + (feature.isRegionalCapital ? 1_000_000 : 0) + (feature.population || 0);
if (!current || priority > current.priority) uniqueSettlementByCell.set(key, { feature, priority, i });
}
let skippedDuplicateSettlementPopulation = 0;
for (const { feature, i } of uniqueSettlementByCell.values()) {
const id = adminId[i];
if (id < 0 || id >= totals.length) continue;
settlementTotals[id] += feature.population;
}
for (const feature of settlementFeatures || []) {
if (!feature || !Number.isFinite(feature.population) || feature.population <= 0 || !inside(feature.x, feature.y)) continue;
const key = `${Math.round(feature.x)}:${Math.round(feature.y)}`;
const kept = uniqueSettlementByCell.get(key)?.feature;
if (kept && kept !== feature) skippedDuplicateSettlementPopulation += feature.population || 0;
}
for (let id = 0; id < adminCenters.length; id++) {
const raw = (totals[id] || 0) + (settlementTotals[id] || 0);
const rounded = raw >= 10000 ? Math.round(raw / 1000) * 1000 : Math.round(raw / 100) * 100;
adminCenters[id].municipalityPopulation = Math.max(0, rounded);
adminCenters[id].municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100);
adminCenters[id].municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100);
}
}
function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, fields, seed, minPopulation = 200000) {
if (!prefectureRegionId) return 0;
const prefIds = new Set();
for (let i = 0; i < prefectureRegionId.length; i++) {
const id = prefectureRegionId[i];
if (!sea[i] && id >= 0) prefIds.add(id);
}
let promoted = 0;
function prefAt(p) {
if (!p || !inside(p.x, p.y)) return -1;
return prefectureRegionId[indexOf(p.x, p.y)] ?? -1;
}
for (const prefId of [...prefIds].sort((a, b) => a - b)) {
const cities = (modernCities || []).filter((p) => prefAt(p) === prefId);
let target = cities.slice().sort((a, b) =>
((b.isRegionalCapital ? 800000 : 0) + (b.population || 0)) - ((a.isRegionalCapital ? 800000 : 0) + (a.population || 0))
)[0];
if (!target) {
const market = (markets || []).filter((p) => prefAt(p) === prefId).sort((a, b) => (b.population || 0) - (a.population || 0))[0];
if (market) {
target = {
...market,
kind: "Regional Capital",
rank: "Regional Capital",
promotedFromMarketTown: true,
labelPriorityBase: 900,
};
modernCities.push(target);
}
}
if (!target) {
const center = (adminCenters || []).filter((p) => prefAt(p) === prefId).sort((a, b) => (b.municipalityPopulation || 0) - (a.municipalityPopulation || 0))[0];
if (center) {
target = {
...center,
kind: "Regional Capital",
rank: "Regional Capital",
promotedFromMunicipalCenter: true,
labelPriorityBase: 880,
};
modernCities.push(target);
}
}
if (!target) continue;
const promotedPopulation = Math.round((minPopulation + rand(seed + 52000, prefId * 37 + 11) * 120000) / 1000) * 1000;
if ((target.population || 0) < promotedPopulation) {
target.population = promotedPopulation;
promoted++;
}
target.isPrefecturalCapital = true;
target.isRegionalCapital = true;
target.rank = target.rank || "Regional Capital";
target.kind = target.kind === "Market Town" || target.kind === "Port Town" || target.kind === "Valley Market Town" ? "Regional Capital" : (target.kind || "Regional Capital");
target.urbanRadius = clamp(6.2 + Math.sqrt(target.population) / 80, 9, 30);
target.coreRadius = clamp(2.0 + Math.sqrt(target.population) / 390, 2.4, 6.4);
target.sprawlRadius = clamp(target.urbanRadius * 1.34, target.urbanRadius + 2, 38);
target.urbanWeight = clamp(1.05 + Math.log10(Math.max(10000, target.population)) * 0.30, 1.25, 2.55);
}
return promoted;
}
function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames, nameDebug, options = {}) {
if (!prefectureRegionId) return [];
const { modernCities = [], adminCenters = [] } = options;
const byId = new Map();
for (let i = 0; i < prefectureRegionId.length; i++) {
const id = prefectureRegionId[i];
if (sea[i] || id < 0) continue;
if (!byId.has(id)) byId.set(id, { id, area: 0, sx: 0, sy: 0, cells: [] });
const row = byId.get(id);
const x = i % MAP_W;
const y = Math.floor(i / MAP_W);
row.area++;
row.sx += x;
row.sy += y;
row.cells.push(i);
}
const regions = [];
const capitalNameByPref = new Map();
for (const city of modernCities || []) {
if (!city || !inside(city.x, city.y) || !city.name) continue;
const prefId = prefectureRegionId[indexOf(city.x, city.y)];
if (prefId < 0) continue;
const current = capitalNameByPref.get(prefId);
const score = (city.isPrefecturalCapital ? 2_000_000 : 0) + (city.isRegionalCapital ? 500_000 : 0) + (city.population || 0);
if (!current || score > current.score) capitalNameByPref.set(prefId, { name: stripMunicipalSuffix(city.name), score, x: city.x, y: city.y, population: city.population || 0, source: "city" });
}
for (const center of adminCenters || []) {
if (!center || !inside(center.x, center.y) || !center.name) continue;
const prefId = prefectureRegionId[indexOf(center.x, center.y)];
if (prefId < 0 || capitalNameByPref.has(prefId)) continue;
capitalNameByPref.set(prefId, { name: stripMunicipalSuffix(center.name), score: center.municipalityPopulation || 0, x: center.x, y: center.y, population: center.municipalityPopulation || 0, source: "admin" });
}
for (const row of [...byId.values()].sort((a, b) => a.id - b.id)) {
const cx = row.sx / Math.max(1, row.area);
const cy = row.sy / Math.max(1, row.area);
let bestI = row.cells[0];
let bestScore = -INF;
for (const i of row.cells) {
const x = i % MAP_W;
const y = Math.floor(i / MAP_W);
const score =
(fields.populationDensity?.[i] || 0) * 1.6 +
(fields.plain?.[i] || 0) * 0.28 +
(fields.basinField?.[i] || 0) * 0.22 +
(fields.coastalLowland?.[i] || 0) * 0.16 -
(fields.slope?.[i] || 0) * 0.36 -
(fields.ridgeField?.[i] || 0) * 0.30 -
Math.hypot(x - cx, y - cy) * 0.08;
if (score > bestScore) { bestScore = score; bestI = i; }
}
let x = bestI % MAP_W;
let y = Math.floor(bestI / MAP_W);
const capitalInfo = capitalNameByPref.get(row.id);
if (capitalInfo && inside(capitalInfo.x, capitalInfo.y)) {
const targetRing = clamp(5.5 + Math.sqrt(row.area) / 52, 6, 15);
let labelI = bestI;
let labelScore = -INF;
for (const i of row.cells) {
const lx = i % MAP_W;
const ly = Math.floor(i / MAP_W);
const d = Math.hypot(lx - capitalInfo.x, ly - capitalInfo.y);
if (d < 2 || d > Math.max(19, targetRing * 2.2)) continue;
const lu = fields.landuse?.[i] ?? 0;
const builtPenalty = lu === 3 ? 1.2 : lu === 2 || lu === 4 || lu === 7 || lu === 8 ? 0.70 : 0;
const score =
-Math.abs(d - targetRing) * 0.38 -
(fields.populationDensity?.[i] || 0) * 1.1 -
builtPenalty +
(fields.plain?.[i] || 0) * 0.22 +
(fields.basinField?.[i] || 0) * 0.14 +
(fields.coastalLowland?.[i] || 0) * 0.10 -
(fields.slope?.[i] || 0) * 0.30 -
(fields.ridgeField?.[i] || 0) * 0.24;
if (score > labelScore) { labelScore = score; labelI = i; }
}
x = labelI % MAP_W;
y = Math.floor(labelI / MAP_W);
}
regions.push({ id: row.id, x, y, area: row.area, kind: row.id === 0 ? "Current Prefecture" : "Prefecture", labelPriorityBase: 900 + Math.sqrt(row.area), capitalX: capitalInfo?.x, capitalY: capitalInfo?.y });
}
const named = attachIdsAndNames(regions, "prefecture", seed + 41000, null, fields, usedNames, nameDebug)
.map((region, index) => ({ ...region, featureId: region.id, id: regions[index].id, labelName: region.name }));
const usedPrefNames = new Set();
for (const region of named) {
const capital = capitalNameByPref.get(region.id)?.name;
let candidate = capital && Array.from(capital).length >= 2 ? capital : stripMunicipalSuffix(region.name);
if (!candidate) candidate = stripMunicipalSuffix(region.name) || `県域${region.id + 1}`;
if (usedPrefNames.has(candidate)) candidate = `${candidate}${region.id + 1}`;
candidate = `${String(candidate).replace(/[都道府県]$/u, "")}`;
region.name = candidate;
region.labelName = candidate;
region.prefectureCapitalDerivedName = Boolean(capital);
usedPrefNames.add(candidate);
}
return named;
}
export function finishMapOutput({
seed,
options,
@ -65,10 +286,7 @@ export function finishMapOutput({
smallStreams,
prefectureMask,
prefectureBorder,
prefectureRegionId,
regionalDebug,
terrainDebug,
regionalPrefectureBorders,
} = terrain;
const {
@ -115,6 +333,12 @@ export function finishMapOutput({
adminId,
adminBorders,
adminDebug,
prefectureRegionId,
municipalityToPrefectureId,
regionalPrefectureBorders: adminRegionalPrefectureBorders,
regionalDebug,
naturalCompartmentId,
naturalCompartments,
} = admin;
let villages = inputVillages;
@ -134,16 +358,18 @@ export function finishMapOutput({
let externalGateways = inputExternalGateways;
const outputProgress = (step) => options?.onProgress?.({ status: "output-step", key: "output", label: `Output: ${step}`, step });
outputProgress("population recalculation");
// Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion.
// Use all generated prefecture regions for human-geography density, not only the focused prefecture.
outputProgress("final packaging");
// Use all generated prefecture regions for human-geography masks, not only
// the focused prefecture. Population density itself is already generated in
// the human stage and is not rebuilt here.
const humanRegionMask = new Uint8Array(MAP_W * MAP_H);
for (let i = 0; i < humanRegionMask.length; i++) {
humanRegionMask[i] = !sea[i] && ((prefectureRegionId?.[i] ?? -1) >= 0 || prefectureMask[i]) ? 1 : 0;
}
recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, humanRegionMask, sea, stationInfluence, roadInfluence, railInfluence2);
// Population density is generated directly in the human stage. Do not rebuild
// it here from land-use or municipal offices; output should only package and
// name features.
for (const city of modernCities) {
if (city.isPrefecturalCapital) continue;
const cap = cityPopulationCap(city);
if (cap < INF && (city.population || 0) > cap) {
city.population = Math.round(cap / 1000) * 1000;
@ -153,29 +379,6 @@ export function finishMapOutput({
}
}
outputProgress("harbor works");
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;
}
const harborWorks = makeHarborWorks(ports);
let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" }));
const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity };
const usedNames = new Set();
@ -227,6 +430,8 @@ export function finishMapOutput({
if (best) {
center.representativeFeatureId = best.id;
center.representativeFeatureName = best.name;
center.canonicalSettlementId = best.id;
center.canonicalSettlementName = best.name;
center.municipalityRootName = best.name;
} else {
center.municipalityRootName = center.generatedMunicipalityName;
@ -236,22 +441,188 @@ export function finishMapOutput({
for (const [index, center] of adminCenters.entries()) {
center.adminNumericId = index;
center.municipalityId = index;
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index);
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index);
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index);
if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
candidate = generated;
}
if (usedAdminNames.has(candidate)) {
const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, index);
const base = String(center.generatedMunicipalityName || center.municipalityRootName || center.name || `自治${index + 1}`).replace(/[市町村区]$/u, "");
candidate = `${base}${index + 1}${suffix}`;
const rootSource = String(center.generatedMunicipalityName || center.name || center.municipalityRootName || `自治${index + 1}`).replace(/[市町村区駅港城跡宿]$/gu, "");
const chars = Array.from(rootSource || "里郷");
const alternates = [
chars.slice(0, 2).join(""),
chars.slice(-2).join(""),
`${chars[0] || "里"}${["里", "郷", "野", "田", "川", "原", "浜", "浦"][(seed + index) % 8]}`,
`${["東", "西", "南", "北", "上", "下", "中"][(seed + index) % 7]}${chars[0] || "里"}`,
].filter((v) => Array.from(v).length >= 2);
for (let attempt = 0; attempt < alternates.length + 12 && usedAdminNames.has(candidate); attempt++) {
const root = attempt < alternates.length ? alternates[attempt] : `${(index + attempt) % 10}`;
candidate = `${Array.from(root).slice(0, 2).join("")}${suffix}`;
}
}
center.name = candidate;
center.labelName = candidate;
center.municipalityName = candidate;
usedAdminNames.add(center.name);
}
const promotedPrefectureCapitals = promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, nameFields, seed, 220000);
assignMunicipalityPopulations(adminCenters, adminId, nameFields, [
...modernCities,
...markets,
...villages,
...satelliteCities,
...newTowns,
]);
function addMunicipalCenterLocalAccess() {
const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = { repairedSegments: [], unservedSettlements: [] })) : { repairedSegments: [], unservedSettlements: [] };
debugLayers.repairedSegments ||= [];
debugLayers.unservedSettlements ||= [];
const influenceCache = new Map();
const signature = (paths) => `${paths?.length || 0}:${(paths || []).reduce((sum, path) => sum + (path?.length || 0), 0)}`;
const cachedInfluence = (paths, radius, label) => {
const key = `${label}:${radius}:${signature(paths)}`;
let grid = influenceCache.get(key);
if (!grid) {
grid = influenceFromPaths(paths, radius);
influenceCache.set(key, grid);
}
return grid;
};
const accessInfluence = cachedInfluence([...nationalRoads, ...railways, ...externalRoads, ...minorRoads], 8, "municipal-access");
const localPenalty = cachedInfluence(minorRoads, 4, "municipal-minor");
const perPrefectureQuota = new Map();
const candidates = adminCenters
.filter((p) => {
if (!inside(p.x, p.y) || sea[indexOf(p.x, p.y)]) return false;
const i = indexOf(p.x, p.y);
const meaningful = (p.municipalityPopulation || 0) >= 4500 || (populationDensity?.[i] || 0) > 0.08 || (p.representativeFeatureName && accessInfluence[i] < 0.22);
return meaningful && accessInfluence[i] < 0.42;
})
.sort((a, b) => {
const ai = indexOf(a.x, a.y);
const bi = indexOf(b.x, b.y);
const as = (a.municipalityPopulation || 0) + Math.max(0, 0.42 - accessInfluence[ai]) * 145000;
const bs = (b.municipalityPopulation || 0) + Math.max(0, 0.42 - accessInfluence[bi]) * 145000;
return bs - as;
})
.filter((p) => {
const prefId = municipalityToPrefectureId?.[p.municipalityId] ?? -1;
const used = perPrefectureQuota.get(prefId) || 0;
if (used >= 18) return false;
perPrefectureQuota.set(prefId, used + 1);
return true;
})
.slice(0, 95);
function addLocalPenalty(path, radius = 4, strength = 0.20) {
for (const [x, y] of path || []) {
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
const i = indexOf(nx, ny);
if (sea[i]) continue;
localPenalty[i] = Math.max(localPenalty[i], strength * (1 - d / radius));
}
}
}
}
function routeAccess(start) {
const startIndex = indexOf(start.x, start.y);
if (sea[startIndex]) return [];
const score = new Float32Array(MAP_W * MAP_H);
const cameFrom = new Int32Array(MAP_W * MAP_H);
const closed = new Uint8Array(MAP_W * MAP_H);
score.fill(INF);
cameFrom.fill(-1);
const heap = new MinHeap();
score[startIndex] = 0;
heap.push({ i: startIndex, f: 0 });
const maxExpanded = Math.min(MAP_W * MAP_H, 24000);
let goal = -1;
let expanded = 0;
while (heap.length && expanded++ < maxExpanded) {
const current = heap.pop();
if (!current || closed[current.i]) continue;
closed[current.i] = 1;
const [cx, cy] = xyOf(current.i);
const straightDistance = Math.hypot(cx - start.x, cy - start.y);
if (straightDistance > 3 && (accessInfluence[current.i] > 0.11 || localPenalty[current.i] > 0.06)) {
goal = current.i;
break;
}
if (straightDistance > 150) continue;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = cx + dx;
const ny = cy + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (closed[ni] || sea[ni]) continue;
const step = Math.hypot(dx, dy);
const highPenalty = Math.max(0, elevation[ni] - 0.64);
const targetAttraction = Math.max(accessInfluence[ni] * 2.4, localPenalty[ni] * 1.25, roadInfluence[ni] * 1.8, railInfluence2[ni] * 1.2);
const terrainCost =
1.0 +
slope[ni] * 1.20 +
ridgeField[ni] * 0.52 +
highPenalty * 1.55 -
plain[ni] * 0.38 -
valleyField[ni] * 0.42 -
coastalLowland[ni] * 0.18 +
Math.max(0, localPenalty[ni] - 0.18) * 0.38 -
targetAttraction;
const nd = score[current.i] + Math.max(0.16, terrainCost) * step;
if (nd < score[ni]) {
score[ni] = nd;
cameFrom[ni] = current.i;
heap.push({ i: ni, f: nd });
}
}
}
}
if (goal < 0) return [];
const path = [];
for (let p = goal; p >= 0; p = cameFrom[p]) {
path.push(xyOf(p));
if (p === startIndex) break;
}
path.reverse();
if (path.length < 4 || path.length > 112) return [];
return routeQualityAcceptable(path, { sea, elevation, slope, potential: populationDensity, penalty: localPenalty, highElevationThreshold: 0.78, steepThreshold: 0.52 }, {
minLength: 4,
maxLength: 112,
maxCompactness: 4.0,
maxHighElevationShare: 0.22,
maxSteepShare: 0.50,
}) ? path : [];
}
for (const center of candidates) {
const path = routeAccess(center);
debugLayers.unservedSettlements.push({ x: center.x, y: center.y, kind: "Municipal Center", mode: "municipal-access", repaired: path.length >= 4 });
if (path.length < 4) continue;
minorRoads.push(path);
addLocalPenalty(path);
debugLayers.repairedSegments.push({ mode: "municipal-local", path, from: "municipal-center", to: "network" });
}
}
addMunicipalCenterLocalAccess();
nameDebug.maxDerivedPerBase = 0;
const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug, { modernCities, adminCenters });
const regionalPrefectureBorders = adminRegionalPrefectureBorders || [];
if (regionalDebug) {
regionalDebug.finalRegionalPrefectureBorderCount = regionalPrefectureBorders.length;
regionalDebug.regionalPrefectureBordersRebuiltFromFinalId = true;
regionalDebug.promotedPrefectureCapitals = promotedPrefectureCapitals;
}
outputProgress("final package");
const entitiesForNames = [
@ -279,8 +650,10 @@ export function finishMapOutput({
seaLevel,
prefectureMask,
humanRegionMask,
prefectureBorder,
prefectureBorder: regionalPrefectureBorders.length ? regionalPrefectureBorders : prefectureBorder,
prefectureRegionId,
municipalityToPrefectureId,
prefectureRegions,
regionalDebug,
terrainDebug,
regionalPrefectureBorders,
@ -310,6 +683,8 @@ export function finishMapOutput({
alluvialFanField,
deltaField,
naturalBarrierScore,
naturalCompartmentId,
naturalCompartments,
villages,
ports,
crossings,
@ -340,7 +715,6 @@ export function finishMapOutput({
logisticsParks,
satelliteCities,
newTowns,
harborWorks,
landuse,
adminCenters,
adminId,

View file

@ -1,6 +1,6 @@
import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
import { generateTerrainAndRivers } from "./mapTerrain.js";
import { generateMapFeatures } from "./mapFeaturesV2.js";
import { generateMapFeatures } from "./mapFeatures.js";
import { finishMapOutput } from "./mapOutput.js";
import { generateAdminLayout } from "./mapAdminStage.js";
@ -11,7 +11,7 @@ function nowMs() {
}
function timedStage(timings, options, key, label, fn) {
options?.onProgress?.({ status: "start", key, label, timings: timings.slice() });
options?.onProgress?.({ status: "start", key, label, timings: timings.slice(), startedAt: nowMs() });
const t0 = nowMs();
const value = fn();
const ms = Math.round((nowMs() - t0) * 10) / 10;
@ -21,6 +21,27 @@ function timedStage(timings, options, key, label, fn) {
return value;
}
function yieldToBrowser() {
if (typeof requestAnimationFrame === "function") {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function timedStageAsync(timings, options, key, label, fn) {
options?.onProgress?.({ status: "start", key, label, timings: timings.slice(), startedAt: nowMs() });
await yieldToBrowser();
const t0 = nowMs();
const value = fn();
const ms = Math.round((nowMs() - t0) * 10) / 10;
const entry = { key, label, ms };
timings.push(entry);
options?.onProgress?.({ status: "done", key, label, ms, timings: timings.slice() });
await yieldToBrowser();
return value;
}
export function generateMap(seedInput = 114514, options = {}) {
const seed = Number(seedInput) >>> 0;
if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
@ -28,7 +49,7 @@ export function generateMap(seedInput = 114514, options = {}) {
const generationTimings = [];
const stage = (key, label, fn) => timedStage(generationTimings, options, key, label, fn);
const terrain = stage("terrain", "Terrain, rivers, and prefecture regions", () => generateTerrainAndRivers(seed));
const terrain = stage("terrain", "Terrain, rivers, and natural compartments", () => generateTerrainAndRivers(seed));
const {
elevation,
slope,
@ -43,8 +64,9 @@ export function generateMap(seedInput = 114514, options = {}) {
flowAccum,
naturalBarrierScore,
prefectureMask,
prefectureRegionId,
adminPrefectureRegionId,
landMask,
naturalCompartmentId,
naturalCompartments,
} = terrain;
const features = stage("human", "Human geography and transport", () => generateMapFeatures(seed, terrain));
@ -68,7 +90,7 @@ export function generateMap(seedInput = 114514, options = {}) {
} = features;
const admin = stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
seed, prefectureMask, prefectureRegionId: adminPrefectureRegionId || prefectureRegionId, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
seed, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, 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,
adminProgress: (event) => options?.onProgress?.({
...event,
@ -93,3 +115,77 @@ export function generateMap(seedInput = 114514, options = {}) {
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
return output;
}
export async function generateMapAsync(seedInput = 114514, options = {}) {
const seed = Number(seedInput) >>> 0;
if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
const generationTimings = [];
const stage = (key, label, fn) => timedStageAsync(generationTimings, options, key, label, fn);
const terrain = await stage("terrain", "Terrain, rivers, and natural compartments", () => generateTerrainAndRivers(seed));
const {
elevation,
slope,
sea,
river,
plain,
agriculture,
ridgeField,
valleyField,
basinField,
coastalLowland,
flowAccum,
naturalBarrierScore,
prefectureMask,
landMask,
naturalCompartmentId,
naturalCompartments,
} = terrain;
const features = await stage("human", "Human geography and transport", () => generateMapFeatures(seed, terrain));
const {
settlementScore,
villages,
markets,
modernCities,
populationDensity,
stations,
industrialZones,
logisticsParks,
satelliteCities,
newTowns,
ports,
landuse,
stationInfluence,
roadInfluence,
railInfluence2,
villageInfluence,
} = features;
const admin = await stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
seed, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, 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,
adminProgress: (event) => options?.onProgress?.({
...event,
key: "admin",
label: event.status === "region-done"
? `Admin region ${event.regionId} done`
: event.status === "admin-step"
? `Admin region ${event.regionId}: ${event.step}`
: `Admin region ${event.regionId}`,
timings: generationTimings.slice(),
}),
}));
const output = await stage("output", "Names, population totals, and final packaging", () => finishMapOutput({
seed,
options,
terrain,
features,
admin,
}));
output.generationTimings = generationTimings;
output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
return output;
}

View file

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

File diff suppressed because it is too large Load diff

99
mapTransport.js Normal file
View file

@ -0,0 +1,99 @@
import { SIZE, clamp, indexOf, inside } from "./mapUtils.js";
export function pathSetSignature(paths) {
let cells = 0;
let endpoints = 0;
for (const path of paths || []) {
cells += path?.length || 0;
const a = path?.[0];
const b = path?.[path.length - 1];
if (a) endpoints = (endpoints + a[0] * 17 + a[1] * 31) | 0;
if (b) endpoints = (endpoints + b[0] * 47 + b[1] * 73) | 0;
}
return `${paths?.length || 0}:${cells}:${endpoints >>> 0}`;
}
export function createPathInfluenceCache(influenceFromPaths) {
const cache = new Map();
return (paths, radius, label = "paths") => {
const key = `${label}:${radius}:${pathSetSignature(paths)}`;
let grid = cache.get(key);
if (!grid) {
grid = influenceFromPaths(paths, radius);
cache.set(key, grid);
}
return grid;
};
}
export function packDebugField(field) {
const out = new Uint8Array(SIZE);
for (let i = 0; i < SIZE; i++) out[i] = Math.round(clamp(field?.[i] || 0) * 255);
return out;
}
export function pathLengthCells(path) {
let total = 0;
for (let i = 1; i < (path?.length || 0); i++) {
total += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]);
}
return total;
}
export function pathAverageField(path, field) {
if (!path?.length || !field) return 0;
let sum = 0;
let n = 0;
for (const [x, y] of path) {
if (!inside(x, y)) continue;
sum += field[indexOf(x, y)] || 0;
n++;
}
return n ? sum / n : 0;
}
export function routeQualityStats(path, fields = {}) {
if (!path?.length) return { length: 0, compactness: Infinity, highElevationShare: 1, steepShare: 1, waterShare: 1, avgPotential: 0, avgPenalty: 0 };
const length = pathLengthCells(path);
const first = path[0];
const last = path[path.length - 1];
const direct = first && last ? Math.hypot(first[0] - last[0], first[1] - last[1]) : 0;
let high = 0;
let steep = 0;
let water = 0;
let potential = 0;
let penalty = 0;
let n = 0;
for (const [x, y] of path) {
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (fields.sea?.[i]) water++;
if ((fields.elevation?.[i] || 0) > (fields.highElevationThreshold ?? 0.72)) high++;
if ((fields.slope?.[i] || 0) > (fields.steepThreshold ?? 0.44)) steep++;
potential += fields.potential?.[i] || 0;
penalty += fields.penalty?.[i] || 0;
n++;
}
return {
length,
compactness: direct > 0.001 ? length / direct : Infinity,
highElevationShare: high / Math.max(1, n),
steepShare: steep / Math.max(1, n),
waterShare: water / Math.max(1, n),
avgPotential: potential / Math.max(1, n),
avgPenalty: penalty / Math.max(1, n),
};
}
export function routeQualityAcceptable(path, fields = {}, limits = {}) {
const q = routeQualityStats(path, fields);
if (q.length < (limits.minLength ?? 2)) return false;
if (q.length > (limits.maxLength ?? Infinity)) return false;
if (q.compactness > (limits.maxCompactness ?? 3.2)) return false;
if (q.highElevationShare > (limits.maxHighElevationShare ?? 0.0)) return false;
if (q.steepShare > (limits.maxSteepShare ?? 0.38)) return false;
if (q.waterShare > (limits.maxWaterShare ?? 0)) return false;
if (q.avgPenalty > (limits.maxAvgPenalty ?? Infinity) && q.avgPotential < (limits.minPotentialIfPenalty ?? 0.35)) return false;
if (q.avgPotential < (limits.minAvgPotential ?? 0)) return false;
return true;
}

182
names.js
View file

@ -1,55 +1,69 @@
import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js";
export const CUSTOM_NAME_LIST = ["加茂", "春日", "新庄", "常盤", "追分", "清水", "曙", "暁", "旭", "鳥羽", "長門", "吉野", "吉田", "美郷", "美里", "美山", "八幡", "相生",];
function nameCharCount(value) {
return Array.from(String(value || "")).length;
}
function isAtomicNamePart(value) {
// The generator used to create visibly synthetic three-part names by joining
// a prefix with a compound terrain word. For template generation, keep each
// lexical slot atomic so a generated root is at most two visible elements.
return nameCharCount(value) <= 1;
}
export const NAME_KANJI_POOLS = {
modifiers: [
"大", "小", "上", "下", "中", "奥", "脇",
"東", "西", "南", "北",
"新", "古", "本", "元",
"高", "長", "広", "深", "浅",
"白", "黒", "青", "赤",
"新", "古", "本",
"高", "長", "広", "深", "浅", "明", "重", "荒",
"白", "黒", "青", "赤", "藍",
"奥", "前", "後", "内", "外",
"美", "吉", "福", "幸", "徳",
"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万",
"霧", "霞", "朝", "日", "天", "雨", "晴",
"早", "安", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠",
"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千",
"霞", "朝", "日", "天",
"土", "砂", "石", "岩",
"卯", "辰",
"駒", "羽", "鳴", "横", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄"
],
inlandTerrain: [
"山", "野", "荒", "野", "沢",
"山", "野", "野", "沢",
"森", "林", "岡", "丘", "坂",
"峰", "峠", "嶺", "尾", "平", "坪", "延", "燧",
"窪", "久", "迫", "久保", "玖保", "漥", "佐古", "作古", "峪",
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦",
"聡", "郷", "里",
"馬", "鹿", "亀", "鷲", "鷹",
"湯",
"峰", "峠", "嶺", "尾", "平", "坪", "延",
"窪", "久", "迫", "久保", "玖保", "佐古", "作古",
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生",
"郷", "里",
"馬", "鹿", "亀", "鷲", "鷹", "鶴", "鴨", "竜", "龍", "牛", "鳥",
"湯", "宍",
],
waterTerrain: [
"川", "河", "江", "瀬", "淵", "渕",
"池", "沼", "泉", "井",
"滝", "梅", "沢", "澤", "谷", "津",
"水", "清", "渡", "橋", "堀",
"溝", "浦"
"水", "渡", "橋", "堀",
"溝", "浦", "渚"
],
coastalTerrain: [
"津", "浦", "津", "崎",
"島", "磯", "潟", "湊", "津",
"州", "洲", "瀬", "砂", "潮", "塩", "",
"州", "洲", "瀬", "砂", "潮", "塩", "",
"泊", "江", "浦", "灘", "入",
"戸", "門",
"鯵", "鰐", "漁", "魚"
],
plants: [
"松", "杉", "桜", "梅", "栗",
"竹", "楠", "藤", "萩", "葦",
"菅", "榎", "椿", "桐", "柳",
"橘", "柏", "槙", "柿", "桃",
"梨", "桑", "麻", "芦", "茅",
"粟", "稲", "麦", "稗", "米", "飯", "糠",
"榊", "楢", "檜", "椎", "柚", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑"
"橘", "柏", "槙", "柿", "桃", "稲", "花", "草", "菊",
"梨", "桑", "麻", "芦", "茅", "根",
"粟", "稲", "稗", "米", "飯", "糠", "茜", "葵", "篠",
"榊", "楢", "檜", "椎", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑", "瓜"
],
postfixes: [
@ -57,15 +71,15 @@ export const NAME_KANJI_POOLS = {
"島",
"江", "瀬", "井", "戸", "口",
"辺", "里", "郷", "村", "町",
"宿", "庄", "台", "坂", "橋",
"本", "内", "窪", "平", "塚",
"畑", "牧", "前", "見", "中", "羽", "生", "塚", "部",
"宿", "庄", "台", "坂", "橋", "明",
"本", "内", "窪", "平", "塚", "根",
"畑", "牧", "前", "見", "中", "羽", "生", "駒", "塚", "部", "栄", "永", "平",
],
archaicPrefixes: [
"阿", "吾", "安", "有", "衣", "伊", "以", "井", "宇", "羽", "江", "恵", "尾", "小", "於",
"阿", "吾", "安", "有", "衣", "伊", "以", "井", "宇", "羽", "江", "恵", "尾", "小",
"可", "加", "賀", "香", "鹿", "賀", "嘉", "喜", "紀", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子", "巨", "己",
"佐", "紗", "左", "志", "師", "須", "瀬", "曽", "蘇",
"佐", "紗", "左", "志", "師", "須", "瀬", "曽", "蘇", "総",
"多", "太", "知", "津", "土",
"那", "奈", "名", "仁", "尼", "根", "乃", "能",
"波", "氷", "比", "肥", "布", "夫", "戸", "保", "穂",
@ -77,15 +91,15 @@ export const NAME_KANJI_POOLS = {
"日", "紀", "志", "尾", "駿",
"甲", "信", "越", "備", "能",
"薩", "隠", "美", "三", "若",
"遠", "近", "能", "加", "賀", "度",
"越", "淡", "壱", "衣", "古", "彦", "多", "志", "布", "治"
"遠", "近", "能", "加", "賀", "度", "飾",
"越", "淡", "壱", "衣", "古", "彦", "多", "志", "布", "治", "加茂",
],
archaicSuffixes: [
"井", "羽", "江", "恵", "尾", "於",
"井", "羽", "江", "恵", "尾",
"賀", "鹿", "喜", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子",
"佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇",
"多", "太", "知", "津", "豆", "土",
"佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇", "総",
"多", "太", "知", "津", "豆", "土", "登",
"那", "奈", "名", "仁", "尼", "根", "乃", "能",
"波", "布", "夫", "戸", "保", "穂",
"間", "磨", "摩", "馬", "見", "牟", "武", "目", "女", "毛", "裳", "茂",
@ -95,25 +109,23 @@ export const NAME_KANJI_POOLS = {
"陀", "芸", "雲",
"幡", "耆", "摩",
"張", "江", "河", "斐", "濃",
"岐", "防", "門", "隅", "向",
"居", "前", "中", "後", "波",
"岐", "門", "隅", "向", "飾",
"居", "前", "中", "後",
"勢", "渡", "城", "紫", "野", "度",
"津", "島", "信", "登", "賀", "志",
"良", "美", "智", "茂", "代", "古", "麻", "彦", "比古", "子"
"良", "美", "智", "茂", "代", "古", "麻", "彦", "比古", "子", "加茂",
],
settlementWords: [
"里", "郷", "村", "町", "宿", "邑", "垣", "坪",
"里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣", "條",
"庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂",
"城", "館", "屋", "家", "所",
"市", "場", "府", "関", "地蔵", "辻", "角", "堰",
"ヶ沢", "ヶ谷", "ヶ浜", "ヶ崎", "ヶ島", "ヶ浦", "ヶ津", "ヶ丘",
"市", "場", "関", "地蔵", "辻", "角", "堰", "通", "路", "道", "筋",
]
};
export const NAME_PROBABILITIES = {
customName: 0.20,
forcedName: 1.0,
customNameList: 0.25,
retryCount: 24,
categoryFallback: {
@ -281,9 +293,6 @@ export const NAME_TEMPLATE_WEIGHTS = {
},
};
export const CUSTOM_NAMES = {};
export const FORCED_NAMES = {};
// Legacy export kept only so older imports do not fail.
export const NAME_PARTS = {};
@ -316,6 +325,20 @@ function countChars(value) {
return Array.from(String(value || "")).length;
}
function isKanjiChar(ch) {
return /[\u3400-\u9FFF\uF900-\uFAFF]/u.test(ch);
}
function hasRepeatedKanji(value) {
const seen = new Set();
for (const ch of Array.from(String(value || ""))) {
if (!isKanjiChar(ch)) continue;
if (seen.has(ch)) return true;
seen.add(ch);
}
return false;
}
function incrementCounter(counter, key, amount = 1) {
counter[key] = (counter[key] || 0) + amount;
}
@ -397,15 +420,15 @@ export function createNameDebug(probabilities = NAME_PROBABILITIES, pools = NAME
const emptyPools = POOL_KEYS.filter((key) => !pools[key]?.length);
const poolsPresent = Object.fromEntries(POOL_KEYS.map((key) => [key, Boolean(pools[key]?.length)]));
return {
effectiveCustomNameProbability: probabilities.customName,
effectiveCustomNameListProbability: probabilities.customNameList,
poolsPresent,
emptyPools,
selectedTemplateCounts: {},
selectedContextCounts: {},
customNamesUsed: 0,
forcedNamesUsed: 0,
generatedNamesUsed: 0,
customNameListUsed: 0,
invalidNamesRejected: 0,
repeatedKanjiNamesRejected: 0,
oneCharacterNamesPrevented: 0,
rejectedOneCharacterNames: 0,
duplicateRetries: 0,
@ -458,7 +481,9 @@ export function validateGeneratedName(name, options = {}) {
return { valid: false, reason: "asciiDiagnostic" };
}
if (!options.allowOneCharacter && length < 2) return { valid: false, reason: "oneCharacter" };
if (Number.isFinite(options.maxLength) && length > options.maxLength) return { valid: false, reason: "tooLong" };
if (!options.allowLong && length > 4) return { valid: false, reason: "tooLong" };
if (!options.allowRepeatedKanji && hasRepeatedKanji(value)) return { valid: false, reason: "repeatedKanji" };
return { valid: true, reason: "valid" };
}
@ -475,20 +500,24 @@ function generateTemplateNameDetails(seed, id, entity, fields, attempt, usedName
const context = chooseNameContext(entity, fields);
const templateKey = chooseTemplate(context, seed, id, attempt);
const template = NAME_TEMPLATES[templateKey];
if (!template) return { name: null, context, templateKey: null };
// Do not generate old-style three-part random toponyms; names are now either
// curated list entries or at most two lexical elements plus any administrative suffix.
if (!template || (template.slots?.length || 0) > 2) return { name: null, context, templateKey: null };
const parts = [];
for (let slotIndex = 0; slotIndex < template.slots.length; slotIndex++) {
const slot = template.slots[slotIndex];
const slotPool = resolveSlotPool(slot, context, pools, probabilities, seed, id, attempt + slotIndex);
if (!slotPool?.pool?.length) return { name: null, context, templateKey };
const part = pick(slotPool.pool, seed, id, attempt, 2503 + slotIndex * 127 + stableHash(slotPool.key));
const atomicPool = slotPool.pool.filter(isAtomicNamePart);
if (!atomicPool.length) return { name: null, context, templateKey };
const part = pick(atomicPool, seed, id, attempt, 2503 + slotIndex * 127 + stableHash(slotPool.key));
if (!part) return { name: null, context, templateKey };
parts.push(part);
}
const name = parts.join("");
const validation = validateGeneratedName(name);
const validation = validateGeneratedName(name, { allowLong: false, maxLength: 2 });
if (!validation.valid) return { name: null, context, templateKey, invalidReason: validation.reason };
if (usedNames?.has(name)) return { name: null, context, templateKey, duplicate: true };
return { name, context, templateKey };
@ -512,35 +541,43 @@ function uniqueDiagnosticName(seed, id, usedNames, debug, startAttempt = 0) {
return `${ASCII_DIAGNOSTIC_PREFIX}${stableHash(`${seed}:${id}`).toString(36).toUpperCase()}`;
}
function tryCustomName(seed, id, usedNames, debug) {
const customName = CUSTOM_NAMES[id];
if (!customName) return null;
if (roll(seed, id, 0, 3501) >= NAME_PROBABILITIES.customName) return null;
const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true });
if (!validation.valid) {
if (validation.reason === "oneCharacter") debug.oneCharacterNamesPrevented++;
if (validation.reason === "oneCharacter") debug.rejectedOneCharacterNames++;
else debug.invalidNamesRejected++;
return null;
function tryCustomNameList(seed, id, usedNames, debug) {
if (!CUSTOM_NAME_LIST.length) return null;
if (roll(seed, id, 0, 3527) >= (NAME_PROBABILITIES.customNameList ?? 0)) return null;
const start = Math.floor(roll(seed, id, 0, 3539) * CUSTOM_NAME_LIST.length) % CUSTOM_NAME_LIST.length;
for (let offset = 0; offset < CUSTOM_NAME_LIST.length; offset++) {
const customName = CUSTOM_NAME_LIST[(start + offset) % CUSTOM_NAME_LIST.length];
if (nameCharCount(customName) > 2) {
debug.invalidNamesRejected++;
continue;
}
const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true });
if (!validation.valid) {
if (validation.reason === "oneCharacter") {
debug.oneCharacterNamesPrevented++;
debug.rejectedOneCharacterNames++;
} else if (validation.reason === "repeatedKanji") {
debug.repeatedKanjiNamesRejected++;
} else {
debug.invalidNamesRejected++;
}
continue;
}
if (usedNames?.has(customName)) {
debug.duplicateRetries++;
continue;
}
debug.customNameListUsed++;
return customName;
}
if (usedNames?.has(customName)) {
debug.duplicateRetries++;
return null;
}
debug.customNamesUsed++;
return customName;
return null;
}
export function generateEntityName(seed, id, entity, fields, usedNames = null, debug = createNameDebug()) {
debug ||= createNameDebug();
const forcedName = FORCED_NAMES[id];
if (forcedName) {
debug.forcedNamesUsed++;
return forcedName;
}
const customName = tryCustomName(seed, id, usedNames, debug);
if (customName) return customName;
const listedCustomName = tryCustomNameList(seed, id, usedNames, debug);
if (listedCustomName) return listedCustomName;
const retryCount = Math.max(1, NAME_PROBABILITIES.retryCount || 1);
for (let attempt = 0; attempt < retryCount; attempt++) {
@ -556,6 +593,7 @@ export function generateEntityName(seed, id, entity, fields, usedNames = null, d
debug.oneCharacterNamesPrevented++;
debug.rejectedOneCharacterNames++;
}
else if (result.invalidReason === "repeatedKanji") debug.repeatedKanjiNamesRejected++;
else debug.invalidNamesRejected++;
continue;
}

View file

@ -1,9 +1,11 @@
import { CELL_SIZE, MAP_H, MAP_W, clamp, fbm, indexOf, valueNoise } from "./mapUtils.js";
import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf } from "./mapUtils.js";
const segmentVectorCache = new WeakMap();
const pathVectorCache = new WeakMap();
const coastlineCache = new WeakMap();
const baseImageCache = new WeakMap();
const MAX_BASE_CACHE_IMAGES = 4;
function pointKey(p) {
return `${p[0]},${p[1]}`;
@ -206,10 +208,13 @@ function vectorPath(path) {
if (cached) return cached;
const points = path.map(([x, y]) => [x * CELL_SIZE + CELL_SIZE / 2, y * CELL_SIZE + CELL_SIZE / 2]);
const simplified = simplifyRdp(points, CELL_SIZE * 0.34);
const smoothed = chaikin(simplified, path.length > 6 ? 1 : 0, false);
pathVectorCache.set(path, smoothed);
return smoothed;
// Transport routes are already cost-routed on the raster grid. A large RDP
// tolerance erases those small valley/contour bends and makes roads look like
// ruler-straight overlays, so smooth first and simplify only lightly.
const smoothedBase = chaikin(points, path.length > 8 ? 1 : 0, false);
const simplified = simplifyRdp(smoothedBase, CELL_SIZE * 0.14);
pathVectorCache.set(path, simplified);
return simplified;
}
function drawPolylinePoints(ctx, points) {
@ -312,16 +317,8 @@ function terrainColorContinuous(map, fx, fy, mode) {
if (isWaterSample(map, fx, fy)) {
const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4);
color = [Math.round(170 + depth * 5), Math.round(218 + depth * 10), Math.round(255 - depth * 5)];
} else if (mode === "suitability") {
const a = fieldSample(map.agriculture, fx, fy);
const p = fieldSample(map.plain, fx, fy);
const f = fieldSample(map.floodplain, fx, fy);
color = [
Math.round(240 - p * 15 + f * 10),
Math.round(242 + a * 10),
Math.round(235 - a * 15 + p * 10),
];
// Calm sky-blue water; less saturated than the previous bright cyan.
color = [Math.round(174 - depth * 7), Math.round(203 + depth * 2), Math.round(224 + depth * 5)];
} else if (mode === "development") {
const dCity = distToNearest(map.modernCities, fx, fy);
const urban = clamp(1 - dCity / 25);
@ -432,11 +429,29 @@ function discreteColor(map, x, y, mode) {
return blendOutside(color, Boolean(map.prefectureMask[i]));
}
function drawBase(ctx, map, mode, continuousTerrain) {
function baseCacheKey(mode, continuousTerrain) {
const continuousModes = ["terrain", "development", "all"];
if (continuousTerrain && continuousModes.includes(mode)) {
return `continuous:${mode === "all" ? "terrain" : mode}`;
}
return `discrete:${mode}`;
}
function getCachedBaseImage(ctx, map, mode, continuousTerrain) {
let cache = baseImageCache.get(map);
if (!cache) {
cache = new Map();
baseImageCache.set(map, cache);
}
const key = baseCacheKey(mode, continuousTerrain);
let image = cache.get(key);
if (image) return image;
const width = MAP_W * CELL_SIZE;
const height = MAP_H * CELL_SIZE;
const img = ctx.createImageData(width, height);
const continuousModes = ["terrain", "suitability", "development", "all"];
const continuousModes = ["terrain", "development", "all"];
if (continuousTerrain && continuousModes.includes(mode)) {
for (let py = 0; py < height; py++) {
@ -469,7 +484,13 @@ function drawBase(ctx, map, mode, continuousTerrain) {
}
}
}
ctx.putImageData(img, 0, 0);
cache.set(key, img);
if (cache.size > MAX_BASE_CACHE_IMAGES) cache.delete(cache.keys().next().value);
return img;
}
function drawBase(ctx, map, mode, continuousTerrain) {
ctx.putImageData(getCachedBaseImage(ctx, map, mode, continuousTerrain), 0, 0);
}
function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
@ -576,15 +597,17 @@ function drawUrbanAreas(ctx, map, mode) {
const visibleModes = ["all", "modern", "development", "landuse", "admin"];
if (!visibleModes.includes(mode)) return;
const colors = {
const detailedColors = {
2: "rgba(223, 214, 206, 0.72)",
3: "rgba(215, 175, 172, 0.88)",
3: "rgba(215, 175, 172, 0.88)",
4: "rgba(231, 219, 231, 0.68)",
5: "rgba(218, 218, 226, 0.64)",
6: "rgba(225, 230, 225, 0.56)",
7: "rgba(229, 234, 242, 0.64)",
8: "rgba(244, 230, 205, 0.62)",
8: "rgba(231, 219, 231, 0.68)",
};
const cityColor = "rgba(232, 222, 228, 0.60)";
const cbdColor = "rgba(215, 175, 172, 0.84)";
ctx.save();
for (let y = 0; y < MAP_H; y++) {
@ -593,11 +616,15 @@ function drawUrbanAreas(ctx, map, mode) {
const areaMask = map.humanRegionMask || map.prefectureMask;
if (areaMask && !areaMask[i]) continue;
const lu = map.landuse[i];
if (!colors[lu]) continue;
let fill = null;
if (mode === "landuse") fill = detailedColors[lu] || null;
else if (lu === 3) fill = cbdColor;
else if (lu === 2 || lu === 4 || lu === 5 || lu === 6 || lu === 7 || lu === 8) fill = cityColor;
if (!fill) continue;
const px = x * CELL_SIZE;
const py = y * CELL_SIZE;
ctx.fillStyle = colors[lu];
ctx.fillStyle = fill;
ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE);
}
}
@ -612,7 +639,8 @@ function drawDebugCells(ctx, map, field, color) {
const i = indexOf(x, y);
const debugMask = map.humanRegionMask || map.prefectureMask;
if (!debugMask[i] || map.sea[i]) continue;
const v = clamp(field[i] || 0, 0, 1);
const raw = field[i] || 0;
const v = clamp(raw > 1 ? raw / 255 : raw, 0, 1);
if (v <= 0.12) continue;
ctx.fillStyle = color(v);
ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
@ -621,6 +649,64 @@ function drawDebugCells(ctx, map, field, color) {
ctx.restore();
}
function drawTransportDebug(ctx, map) {
const layers = map.transportDebug?.layers;
if (!layers) return;
drawDebugCells(ctx, map, layers.expresswayPotential, (v) => v < 0.16 ? "rgba(0,0,0,0)" : `rgba(80, 190, 110, ${0.035 + v * 0.13})`);
drawDebugCells(ctx, map, layers.railPotential, (v) => v < 0.16 ? "rgba(0,0,0,0)" : `rgba(70, 120, 230, ${0.035 + v * 0.13})`);
drawDebugCells(ctx, map, layers.nationalRoadPotential, (v) => v < 0.16 ? "rgba(0,0,0,0)" : `rgba(245, 205, 65, ${0.030 + v * 0.12})`);
drawDebugCells(ctx, map, layers.slopeSeaPenalty, (v) => v < 0.45 ? "rgba(0,0,0,0)" : `rgba(80, 30, 30, ${0.025 + v * 0.10})`);
const componentColors = {
expressway: "rgba(60, 165, 80, 0.42)",
rail: "rgba(65, 95, 210, 0.42)",
national: "rgba(210, 155, 20, 0.42)",
};
ctx.save();
for (const comp of layers.components || []) {
ctx.fillStyle = componentColors[comp.mode] || "rgba(150,150,150,0.35)";
for (const [x, y] of comp.cells || []) ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
}
ctx.restore();
for (const repair of layers.repairedSegments || []) {
const color = repair.mode === "expressway" ? "rgba(0, 115, 40, 0.95)"
: repair.mode === "rail" ? "rgba(35, 70, 210, 0.95)"
: repair.mode === "national" ? "rgba(180, 120, 0, 0.95)"
: "rgba(210, 35, 155, 0.92)";
drawPath(ctx, repair.path, "rgba(255,255,255,0.92)", 5.0);
drawPath(ctx, repair.path, color, 2.4);
}
}
function prefectureRegionColor(id) {
const palette = [
[234, 220, 214], [218, 232, 218], [218, 224, 238], [238, 232, 208],
[232, 218, 232], [214, 232, 234], [235, 224, 216], [222, 236, 210],
];
return palette[Math.abs(id) % palette.length];
}
function drawPrefectureRegionFill(ctx, map, mode) {
if (!["all", "admin", "borders-debug"].includes(mode)) return;
const ids = map.prefectureRegionId;
if (!ids) return;
const alpha = mode === "borders-debug" ? 0.34 : 0.18;
ctx.save();
ctx.globalAlpha = alpha;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
const id = ids[i];
if (map.sea[i] || id < 0) continue;
const [r, g, b] = prefectureRegionColor(id);
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
}
}
ctx.restore();
}
function dot(ctx, p, radius, fill, stroke = "white") {
ctx.beginPath();
ctx.arc(p.x * CELL_SIZE + CELL_SIZE / 2, p.y * CELL_SIZE + CELL_SIZE / 2, radius, 0, Math.PI * 2);
@ -637,30 +723,38 @@ function boxesOverlap(a, b, pad = 3) {
function labelWithCollision(ctx, p, occupied) {
if (!p.name) return false;
const isPrefectureLabel = p.kind === "Prefecture" || p.kind === "Current Prefecture" || p.isPrefectureLabel;
ctx.save();
ctx.font = "600 11.5px ui-sans-serif, system-ui, -apple-system, sans-serif";
ctx.font = isPrefectureLabel
? "900 20px ui-sans-serif, system-ui, -apple-system, sans-serif"
: "600 11.5px ui-sans-serif, system-ui, -apple-system, sans-serif";
const baseX = p.x * CELL_SIZE + CELL_SIZE / 2;
const baseY = p.y * CELL_SIZE + CELL_SIZE / 2;
const textW = ctx.measureText(p.name).width;
const textH = 12;
const candidates = [
[7, -4], [7, 13], [-textW - 7, -4], [-textW - 7, 13],
[-textW / 2, -12], [-textW / 2, 20], [12, 4], [-textW - 12, 4],
];
const textH = isPrefectureLabel ? 22 : 12;
const candidates = isPrefectureLabel
? [
[-textW / 2, 6], [-textW / 2, -12], [-textW / 2, 24],
[10, 6], [-textW - 10, 6],
]
: [
[7, -4], [7, 13], [-textW - 7, -4], [-textW - 7, 13],
[-textW / 2, -12], [-textW / 2, 20], [12, 4], [-textW - 12, 4],
];
for (const [ox, oy] of candidates) {
const x = baseX + ox;
const y = baseY + oy;
const box = { x1: x - 2, y1: y - textH, x2: x + textW + 2, y2: y + 4 };
const box = { x1: x - 3, y1: y - textH, x2: x + textW + 3, y2: y + 5 };
if (box.x1 < 0 || box.y1 < 0 || box.x2 > MAP_W * CELL_SIZE || box.y2 > MAP_H * CELL_SIZE) continue;
if (occupied.some((b) => boxesOverlap(box, b))) continue;
if (occupied.some((b) => boxesOverlap(box, b, isPrefectureLabel ? 5 : 3))) continue;
ctx.lineJoin = "round";
ctx.lineWidth = 3.5;
ctx.strokeStyle = "rgba(255, 255, 255, 0.95)";
ctx.lineWidth = isPrefectureLabel ? 6.2 : 3.5;
ctx.strokeStyle = isPrefectureLabel ? "rgba(255, 255, 255, 0.98)" : "rgba(255, 255, 255, 0.95)";
ctx.strokeText(p.name, x, y);
ctx.fillStyle = p.isPrefecturalCapital ? "#111111" : "#333333";
ctx.fillStyle = isPrefectureLabel ? "rgba(62, 38, 112, 0.98)" : p.isPrefecturalCapital ? "#111111" : "#333333";
ctx.fillText(p.name, x, y);
occupied.push(box);
ctx.restore();
@ -680,8 +774,8 @@ function drawLabels(ctx, points, limit = Infinity) {
}
function drawScaleBar(ctx) {
const kmPerCell = 2;
const targetKm = 20;
const kmPerCell = 1;
const targetKm = 50;
const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell));
const lengthPx = lengthCells * CELL_SIZE;
const margin = 14;
@ -736,8 +830,8 @@ export function drawMap(canvas, map, options) {
drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
// 2. Rivers
const waterBlue = "rgba(160, 205, 240, 1)";
const mediumBlue = "rgba(160, 205, 240, 0.88)";
const waterBlue = "rgba(116, 165, 202, 0.92)";
const mediumBlue = "rgba(132, 184, 220, 0.78)";
const riverStrengthForPath = (path) => {
if (!path || path.length === 0) return 0;
let peak = 0;
@ -762,7 +856,7 @@ export function drawMap(canvas, map, options) {
for (const path of map.smallStreams || []) {
const strength = riverStrengthForPath(path);
if ((path?.length || 0) < 5 || strength < 0.045) continue;
drawRiverPath(ctx, map, path, "rgba(150, 198, 235, 1)", (s) => s > 0.45 ? 0.58 : s > 0.22 ? 0.48 : 0.36, 0.34);
drawRiverPath(ctx, map, path, "rgba(140, 190, 224, 0.82)", (s) => s > 0.45 ? 0.52 : s > 0.22 ? 0.42 : 0.32, 0.28);
}
for (const path of map.tributaryRivers || []) {
const strength = riverStrengthForPath(path);
@ -786,32 +880,39 @@ export function drawMap(canvas, map, options) {
}
const showHistory = ["history", "all", "terrain"].includes(mode);
const showModern = ["modern", "all", "development", "landuse", "admin-debug", "borders-debug"].includes(mode);
const showRoads = ["all", "development"].includes(mode);
const showMinorRoads = ["all", "modern", "development"].includes(mode);
const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode);
const showTransportDebug = mode === "transport-debug";
const showModern = ["modern", "all", "development", "landuse", "borders-debug", "transport-debug"].includes(mode);
const showRoads = ["all", "development", "transport-debug"].includes(mode);
const showMinorRoads = ["all", "modern", "development", "transport-debug"].includes(mode);
const showAdmin = ["admin", "all", "borders-debug"].includes(mode);
// 3. Borders
const showPrefectureRegions = ["all", "admin", "borders-debug"].includes(mode);
if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode);
if (showAdmin && map.adminBorders) {
drawVectorSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
}
if (mode === "admin-debug" || mode === "borders-debug") {
if (mode === "borders-debug") {
// Keep the natural barrier heatmap subtle. A dense cell fill can look like
// artificial horizontal hatching, so only strong terrain dividers are shown.
drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => v < 0.42 ? "rgba(0,0,0,0)" : `rgba(255, 120, 40, ${0.025 + v * 0.075})`);
if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(45, 95, 160, 0.72)", 1.0, true);
for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)");
}
if (showTransportDebug) drawTransportDebug(ctx, map);
const showPrefectureRegions = ["all", "admin", "admin-debug", "borders-debug"].includes(mode);
if (showPrefectureRegions && map.regionalPrefectureBorders) {
drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(255, 255, 255, 0.90)", 4.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(82, 60, 102, 0.96)", 1.8, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
}
drawVectorSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
if (!showPrefectureRegions) {
const finalPrefectureBorders = (map.regionalPrefectureBorders && map.regionalPrefectureBorders.length) ? map.regionalPrefectureBorders : map.prefectureBorder;
drawVectorSegments(ctx, finalPrefectureBorders, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, finalPrefectureBorders, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
}
if (!showFeatures) return;
@ -820,7 +921,10 @@ export function drawMap(canvas, map, options) {
for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(210, 210, 210, 0.7)", 2.5);
}
if (showMinorRoads) {
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(205, 205, 205, 0.60)", 2.35);
// Local roads need a visible casing on pale green lowland/farmland tiles.
// Keep the fill light, but use a warmer grey outline rather than a nearly
// invisible white-on-green stroke.
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(132, 126, 112, 0.72)", 2.75);
}
if (showRoads) {
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8);
@ -842,7 +946,7 @@ export function drawMap(canvas, map, options) {
for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(255, 255, 255, 1)", 1.2, false);
}
if (showMinorRoads) {
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(255, 255, 255, 0.94)", 1.1, false);
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(255, 253, 244, 0.98)", 1.25, false);
}
if (showRoads) {
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0);
@ -860,14 +964,17 @@ export function drawMap(canvas, map, options) {
}
// 6. Icons & Labels
if (["admin", "admin-debug", "borders-debug"].includes(mode)) {
if (["admin", "borders-debug"].includes(mode)) {
for (const p of map.adminCenters || []) dot(ctx, p, 2.8, "rgba(255,255,255,0.96)", "rgba(75,60,90,0.95)");
}
if (showModern) {
for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444");
const allLayerTowns = mode === "all"
? (map.markets || []).filter((p) => (p.population || 0) >= 25000 && !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y))
? [
...(map.markets || []).filter((p) => (p.population || 0) >= 5000),
...(map.villages || []).filter((p) => (p.population || 0) >= 5000),
].filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y))
: [];
for (const p of allLayerTowns) dot(ctx, p, 3.1, "rgba(244, 164, 118, 0.92)", "rgba(255,255,255,0.88)");
for (const p of map.modernCities) {
@ -877,32 +984,46 @@ export function drawMap(canvas, map, options) {
else if (p.isRegionalCapital) dot(ctx, p, popRadius + 2.4, "transparent", "rgba(190,95,95,0.62)");
}
for (const p of map.interchanges || []) dot(ctx, p, 2.7, "rgba(250, 250, 245, 0.95)", "rgba(95, 125, 95, 0.95)");
if (mode === "admin-debug" || mode === "borders-debug") {
for (const p of map.logisticsParks || []) dot(ctx, p, 2.4, "rgba(235, 238, 230, 0.95)", "rgba(105, 125, 105, 0.88)");
if (mode === "borders-debug") {
for (const p of map.externalGateways || []) dot(ctx, p, 5.5, "rgba(255,255,255,0.95)", "rgba(40,80,170,0.95)");
for (const p of map.transportDebug?.missingRequiredNodes || []) dot(ctx, p, 6.5, "rgba(255,80,80,0.95)", "rgba(80,20,20,0.95)");
}
if (showTransportDebug) {
for (const p of map.transportDebug?.layers?.unservedSettlements || []) {
dot(ctx, p, p.repaired ? 3.4 : 4.8, p.repaired ? "rgba(255,255,255,0.92)" : "rgba(255,80,140,0.95)", "rgba(125,35,105,0.95)");
}
}
}
if (showLabels) {
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, labelPriorityBase: p.labelPriorityBase || 1700 }));
if (mode === "admin") {
drawLabels(ctx, map.adminCenters || [], Infinity);
drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity);
drawScaleBar(ctx);
return;
}
if (mode === "admin-debug" || mode === "borders-debug") {
drawLabels(ctx, map.adminCenters || [], Infinity);
if (mode === "borders-debug") {
drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity);
drawScaleBar(ctx);
return;
}
const allLayerTowns = mode === "all"
? (map.markets || []).filter((p) => (p.population || 0) >= 25000 && !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y)).map((p) => ({ ...p, labelPriorityBase: 120 }))
? [
...(map.markets || []).filter((p) => (p.population || 0) >= 5000),
...(map.villages || []).filter((p) => (p.population || 0) >= 5000),
]
.filter((p) => !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y))
.map((p) => ({ ...p, forceAllLayerTownLabel: true, labelPriorityBase: p.kind === "Village" || p.kind === "Valley Village" || p.kind === "Coastal Village" ? 75 : 135 }))
: [];
const important = [
...prefectureLabels,
...map.modernCities,
...map.ports,
...(map.logisticsParks || []).map((p) => ({ ...p, labelPriorityBase: 60 })),
...(map.satelliteCities || []),
...allLayerTowns,
].filter((p) => p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 25000);
].filter((p) => p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000);
drawLabels(ctx, important, mode === "all" ? 85 : 60);
}
drawScaleBar(ctx);

View file

@ -2,23 +2,23 @@
body{margin:0;background:#f0f2f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
button,input{font:inherit}
code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
.app{min-height:100vh;padding:24px}
.layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:20px;max-width:1400px;margin:0 auto}
.header{margin-bottom:16px}
.app{min-height:100vh;padding:16px}
.layout{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:16px;max-width:1360px;margin:0 auto}
.header{margin-bottom:12px}
.header h1{margin:0 0 6px;font-size:24px;letter-spacing:-0.02em;color:#1a1a1a;font-weight:700}
.header p{margin:0;color:#5a5a5a;line-height:1.6;font-size:14px}
.canvas-shell,.card{background:#ffffff;border:1px solid rgba(0,0,0,0.08);border-radius:12px;box-shadow:0 4px 12px rgba(0,0,0,0.04)}
.canvas-shell,.card{background:#ffffff;border:1px solid rgba(0,0,0,0.08);border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.04)}
.canvas-shell{padding:12px;overflow:auto;position:relative}
.map-canvas{display:block;border-radius:8px;background:#f8f9fa}
.sidebar{display:flex;flex-direction:column;gap:16px}
.card{padding:18px}
.sidebar{display:flex;flex-direction:column;gap:12px}
.card{padding:14px}
.label,.card-title{display:block;margin-bottom:12px;color:#1a1a1a;font-size:14px;font-weight:600}
.input{width:100%;border:1px solid rgba(0,0,0,0.15);background:#fff;color:#2c2c2c;border-radius:8px;padding:10px 12px;outline:none;transition:border-color 0.2s}
.input:focus{border-color:#1a73e8;box-shadow:0 0 0 3px rgba(26,115,232,0.15)}
.primary-button,.mode-button{border:0;border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600;transition:background 0.2s}
.primary-button{margin-top:12px;width:100%;background:#1a73e8;color:#fff}
.primary-button:hover{background:#1557b0}
.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.mode-button{background:#f1f3f4;color:#3c4043;border:1px solid transparent}
.mode-button:hover{background:#e8eaed}
.mode-button.active{background:#e8f0fe;color:#1a73e8;border:1px solid #1a73e8}
@ -28,9 +28,6 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
.stat-row strong{color:#202124;font-family:ui-monospace,monospace}
.legend{color:#5f6368;font-size:13px;line-height:1.6}
.legend p{margin:8px 0 0}
.example{margin:10px 0;padding:12px;background:#f8f9fa;border:1px solid rgba(0,0,0,0.08);border-radius:8px;color:#3c4043;overflow:auto;font-size:12px}
.id-list{max-height:220px;overflow:auto;margin-top:12px;display:flex;flex-direction:column;gap:6px}
.id-row{display:grid;grid-template-columns:112px 1fr;gap:8px;align-items:center;color:#5f6368;font-size:12px}
@media (max-width:1100px){.layout{grid-template-columns:1fr}}
@ -62,7 +59,6 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
.port-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #4682c8;border-top:0;box-shadow:none;background:transparent;border-radius:0}
.newtown-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #b4d2f0;border-top:0;box-shadow:none;background:transparent;border-radius:0}
.legend-line.harbor-line{background:transparent; border-top:2px solid #5f7896; height:0}
.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:200px;max-width:280px;background:rgba(255,255,255,0.98);border:1px solid rgba(0,0,0,0.1);border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,0.1);padding:10px 12px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(4px);transition:opacity 0.15s ease, transform 0.15s ease;font-weight:500}
.map-tooltip.visible{opacity:1;transform:translateY(0)}

266
test.js
View file

@ -1,7 +1,6 @@
import { generateMap, MAP_W, MAP_H, indexOf } from "./mapGenerator.js";
import {
CUSTOM_NAMES,
FORCED_NAMES,
CUSTOM_NAME_LIST,
NAME_KANJI_POOLS,
NAME_PARTS,
NAME_PROBABILITIES,
@ -9,17 +8,22 @@ import {
NAME_TEMPLATE_WEIGHTS,
generateEntityName,
generateTemplateName,
validateGeneratedName,
} from "./names.js";
const result = document.getElementById("result");
const logLines = [];
let failed = 0;
const [namesSource, mapGeneratorSource, mapOutputSource, rendererSource, testSource] = await Promise.all([
const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rendererSource, appSource, mapPipelineSource, mapAdminStageSource, testSource] = await Promise.all([
fetch("./names.js").then((response) => response.text()),
fetch("./mapGenerator.js").then((response) => response.text()),
fetch("./mapOutput.js").then((response) => response.text()),
fetch("./mapTerrain.js").then((response) => response.text()),
fetch("./renderer.js").then((response) => response.text()),
fetch("./app.js").then((response) => response.text()),
fetch("./mapPipeline.js").then((response) => response.text()),
fetch("./mapAdminStage.js").then((response) => response.text()),
fetch("./test.js").then((response) => response.text()),
]);
@ -193,10 +197,13 @@ 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;
const areas = [];
for (const id of ids) {
seen.fill(0);
let comps = 0;
let area = 0;
for (let i = 0; i < map.prefectureRegionId.length; i++) {
if (!map.sea[i] && map.prefectureRegionId[i] === id) area++;
if (seen[i] || map.sea[i] || map.prefectureRegionId[i] !== id) continue;
comps++;
const queue = [i];
@ -217,8 +224,167 @@ function regionalComponentMetrics(map) {
}
}
maxComponents = Math.max(maxComponents, comps);
areas.push(area);
}
return { regionCount: ids.size, maxComponents };
areas.sort((a, b) => a - b);
const medianArea = areas.length ? areas[Math.floor(areas.length / 2)] : 0;
const minArea = areas.length ? areas[0] : 0;
const tinyCount = areas.filter((area) => area < 520).length;
return { regionCount: ids.size, maxComponents, minArea, medianArea, tinyCount, areas };
}
function regionalBorderMetrics(map) {
let invalidSame = 0;
let expected = 0;
const ids = map.prefectureRegionId;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (map.sea[i] || ids[i] < 0) continue;
if (x + 1 < MAP_W) {
const ni = indexOf(x + 1, y);
if (!map.sea[ni] && ids[ni] >= 0 && ids[ni] !== ids[i]) expected++;
}
if (y + 1 < MAP_H) {
const ni = indexOf(x, y + 1);
if (!map.sea[ni] && ids[ni] >= 0 && ids[ni] !== ids[i]) expected++;
}
}
}
for (const segment of map.regionalPrefectureBorders || []) {
const [[x1, y1], [x2, y2]] = segment;
let a = -1, b = -1;
if (x1 === x2) {
const x = x1;
const y = Math.min(y1, y2);
if (x > 0 && x < MAP_W && y >= 0 && y < MAP_H) {
a = ids[indexOf(x - 1, y)];
b = ids[indexOf(x, y)];
}
} else if (y1 === y2) {
const x = Math.min(x1, x2);
const y = y1;
if (y > 0 && y < MAP_H && x >= 0 && x < MAP_W) {
a = ids[indexOf(x, y - 1)];
b = ids[indexOf(x, y)];
}
}
if (a < 0 || b < 0 || a === b) invalidSame++;
}
return { invalidSame, expected, actual: (map.regionalPrefectureBorders || []).length };
}
function borderHierarchyViolations(map) {
let prefectureCutsMunicipality = 0;
let municipalityCutsCompartment = 0;
const compId = map.naturalCompartmentId;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (map.sea[i]) continue;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
if (nx >= MAP_W || ny >= MAP_H) continue;
const ni = indexOf(nx, ny);
if (map.sea[ni]) continue;
if (map.prefectureRegionId[i] !== map.prefectureRegionId[ni] && map.adminId[i] === map.adminId[ni]) prefectureCutsMunicipality++;
if (map.adminId[i] !== map.adminId[ni] && compId && compId[i] === compId[ni]) municipalityCutsCompartment++;
}
}
}
return { prefectureCutsMunicipality, municipalityCutsCompartment };
}
function longStraightLowBarrierSegments(map, segments, minRun = 18) {
const runs = new Map();
for (const seg of segments || []) {
const [[x1, y1], [x2, y2]] = seg;
const vertical = x1 === x2;
const key = vertical ? `v:${x1}` : `h:${y1}`;
const pos = vertical ? Math.min(y1, y2) : Math.min(x1, x2);
if (!runs.has(key)) runs.set(key, []);
runs.get(key).push({ pos, seg });
}
let bad = 0;
for (const rows of runs.values()) {
rows.sort((a, b) => a.pos - b.pos);
let start = 0;
for (let k = 1; k <= rows.length; k++) {
if (k < rows.length && rows[k].pos <= rows[k - 1].pos + 1.01) continue;
const run = rows.slice(start, k);
if (run.length >= minRun) {
const natural = run.reduce((sum, row) => {
const [[x1, y1], [x2, y2]] = row.seg;
const sx = Math.min(Math.max(0, Math.floor((x1 + x2) / 2)), MAP_W - 1);
const sy = Math.min(Math.max(0, Math.floor((y1 + y2) / 2)), MAP_H - 1);
return sum + (map.naturalBarrierScore?.[indexOf(sx, sy)] || 0);
}, 0) / run.length;
if (natural < 0.18) bad++;
}
start = k;
}
}
return bad;
}
function regionalEnclaveCount(map) {
const ids = map.prefectureRegionId;
const regionIds = [...new Set([...ids].filter((id, i) => id >= 0 && !map.sea[i]))];
let enclaves = 0;
for (const id of regionIds) {
const seen = new Uint8Array(MAP_W * MAP_H);
for (let i = 0; i < ids.length; i++) {
if (seen[i] || map.sea[i] || ids[i] !== id) continue;
const queue = [i];
const cells = [];
let touchesOutside = false;
const neighbors = new Set();
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
cells.push(cur);
const x = cur % MAP_W;
const y = Math.floor(cur / MAP_W);
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true;
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 (map.sea[ni]) {
touchesOutside = true;
continue;
}
if (ids[ni] !== id && ids[ni] >= 0) neighbors.add(ids[ni]);
if (seen[ni] || ids[ni] !== id) continue;
seen[ni] = 1;
queue.push(ni);
}
}
if (cells.length && !touchesOutside && neighbors.size === 1) enclaves++;
}
}
return enclaves;
}
function cityMunicipalityAreaMetrics(map) {
const areaById = new Map();
for (let i = 0; i < map.adminId.length; i++) {
const id = map.adminId[i];
if (id >= 0 && !map.sea[i]) areaById.set(id, (areaById.get(id) || 0) + 1);
}
const rows = (map.modernCities || [])
.filter((city) => (city.population || 0) >= 95000 && city.insidePrefecture && !map.sea[indexOf(city.x, city.y)])
.map((city) => {
const admin = map.adminId[indexOf(city.x, city.y)];
const minArea = Math.min((city.population || 0) >= 450000 ? 780 : 520, Math.max(130, 95 + Math.sqrt(city.population || 0) * 0.72 + (city.urbanFootprintCells || 0) * 0.42));
return { city, admin, area: areaById.get(admin) || 0, minArea };
});
return { rows, tooSmall: rows.filter((row) => row.area + 1e-6 < row.minArea * 0.82) };
}
function prefectureNameForTest(map, i) {
const id = map.prefectureRegionId?.[i] ?? -1;
return (map.prefectureRegions || []).find((region) => region.id === id)?.name || "";
}
function meanField(map, fieldName, predicate) {
@ -531,16 +697,30 @@ try {
assert(map.terrainDebug.depositionLowlandArea > 0, "depositional lowland area is tracked");
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.harborWorks), "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(map.regionalDebug.compartmentCount > 0 && map.regionalDebug.changedAfterCompartmentAssignment > 0, "regional compartment assignment debug is available");
assert(Number.isFinite(map.regionalDebug.borderNaturalBarrierAverage) && Number.isFinite(map.regionalDebug.voronoiLikeRate), "regional natural-border aliases are exposed");
assert(regionalMetrics.regionCount >= 4 && regionalMetrics.maxComponents <= 5, "regional prefecture regions remain connected enough for display");
assert(map.prefectureRegionId.length === size, "final prefecture id field matches map size");
assert(map.naturalCompartmentId?.length === size && Array.isArray(map.naturalCompartments), "shared natural compartments are exposed");
assert(map.adminDebug?.naturalCompartmentCount > 0 && map.adminDebug?.finalMunicipalityCount > 0, "natural compartments are generated before municipalities");
assert(map.regionalDebug?.prefecturesGeneratedAfterMunicipalities === true && map.regionalDebug?.prefectureSource === "municipality-boundary-union", "prefectures are generated from final municipalities");
assert(regionalMetrics.regionCount >= 4 && regionalMetrics.maxComponents === 1, "each non-sea prefecture region is connected after repair");
assert(regionalEnclaveCount(map) === 0, "final prefecture regions contain no one-region enclosed enclaves");
const regionalBorders = regionalBorderMetrics(map);
const hierarchyViolations = borderHierarchyViolations(map);
assert(hierarchyViolations.prefectureCutsMunicipality === 0, "no prefecture border cuts through a municipality");
assert(hierarchyViolations.municipalityCutsCompartment === 0, "no municipality border cuts through a natural compartment");
assert(regionalBorders.invalidSame === 0 && regionalBorders.actual === regionalBorders.expected, "rendered prefecture borders separate different final prefecture ids only");
assert(map.regionalDebug.finalRegionalPrefectureBorderCount === map.regionalPrefectureBorders.length, "regional prefecture borders are final output borders");
assert(!mapTerrainSource.includes("generateRegionalPrefectures") && !mapPipelineSource.includes("prefectureRegionId, sea") && mapAdminStageSource.includes("generatePrefecturesFromMunicipalities"), "administrative order is natural compartments to municipalities to prefectures");
assert(![mapTerrainSource, mapPipelineSource, mapAdminStageSource, mapOutputSource, rendererSource, appSource].some((source) => /displayRegionId|adminPrefectureRegionId/.test(source)), "prefecture pipeline does not use display/admin-prefecture id aliases");
assert(!("maritimePrefectureBorders" in map), "maritime prefecture borders are not emitted");
assert(!mapOutputSource.includes("maritimePrefectureBorders") && !rendererSource.includes("maritimePrefectureBorders"), "maritime prefecture borders are not generated or rendered");
assert(rendererSource.includes("drawPrefectureRegionFill") && rendererSource.includes("map.prefectureRegionId"), "prefecture fill renderer uses final prefecture id source");
assert(regionalMetrics.tinyCount <= Math.max(1, Math.floor(regionalMetrics.regionCount * 0.12)) && regionalMetrics.medianArea >= 1200 && regionalMetrics.minArea >= 520, "regional prefectures avoid excessive tiny slivers");
assert(Array.isArray(map.prefectureRegions) && map.prefectureRegions.length === regionalMetrics.regionCount, "prefecture region metadata exists for every region");
assert(map.prefectureRegions.every((region) => region.name && Number.isFinite(region.x) && Number.isFinite(region.y) && region.area > 0 && map.prefectureRegionId[indexOf(region.x, region.y)] === region.id), "every prefecture region has a name and valid label point");
assert(map.regionalDebug.finalRegionalMaxAreaShare < 0.38, "no single prefecture dominates regional land area");
assert(longStraightLowBarrierSegments(map, map.regionalPrefectureBorders, 22) === 0, "prefecture borders avoid long straight low-barrier cuts");
assert(longStraightLowBarrierSegments(map, map.adminBorders, 20) === 0, "municipality borders avoid long straight low-barrier cuts");
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");
@ -592,7 +772,6 @@ try {
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");
assert(map.externalGateways.length > 0, "external gateways exist");
@ -628,6 +807,7 @@ try {
assert(maxPopulation / Math.max(1, minPopulation) > 3, "city populations vary strongly");
assert(map.totalPopulation >= cityPopulations.reduce((sum, value) => sum + value, 0), "total population includes city and satellite populations");
assert(Math.max(...map.populationDensity) > 0.9, "population density is normalized and populated");
assert([...map.populationDensity].some((value, i) => value === 0 && !map.sea[i]), "valid land cells can retain exactly zero population density");
assert(elevationStdDev > 0.18, "terrain relief has sufficient contrast");
assert(maxCoastalElevationStep < 0.12, "coastline and elevation do not create cliff artifacts");
assert(railExpressHighMountainCells === 0, "railways and expressways avoid huge mountain cells");
@ -643,6 +823,11 @@ try {
assert(map.adminCenters.some((item) => item.representativeFeatureName), "municipal centers keep representative feature metadata when available");
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 cityMunicipalityMetrics = cityMunicipalityAreaMetrics(map);
assert(cityMunicipalityMetrics.tooSmall.length === 0, "meaningful populated cities keep population-scaled municipality area");
const hoverCell = map.prefectureRegions.find((region) => region.area > 0);
assert(hoverCell && prefectureNameForTest(map, indexOf(hoverCell.x, hoverCell.y)) === hoverCell.name && appSource.includes("Prefecture:") && appSource.includes("prefectureNameForCell"), "tooltip can resolve prefecture name for a hovered cell");
assert(appSource.includes("maxLeft") && appSource.includes("maxTop"), "tooltip position is clamped inside map container");
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");
@ -656,6 +841,7 @@ try {
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");
assert(map.entitiesForNames.every((item) => validateGeneratedName(item.name, { allowAsciiDiagnostic: true }).valid), "generated names pass place-name validation");
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");
@ -666,14 +852,12 @@ try {
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,
map.nameDebug.generatedNamesUsed + map.nameDebug.customNameListUsed + map.nameDebug.fallbackAttempts === namedEntityCount,
"nameDebug accounting covers named entities"
);
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");
assert(
map.adminCenters.length !== other.adminCenters.length ||
map.villages.length !== other.villages.length ||
@ -687,7 +871,10 @@ 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.naturalCompartmentId]) === JSON.stringify([...againB.naturalCompartmentId]), "natural compartments are 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.municipalityToPrefectureId]) === JSON.stringify([...againB.municipalityToPrefectureId]), "municipality-to-prefecture ids are deterministic for the same seed");
assert(JSON.stringify(againA.regionalPrefectureBorders) === JSON.stringify(againB.regionalPrefectureBorders), "regional prefecture borders 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.transportDebug) === JSON.stringify(againB.transportDebug), "transport debug metrics are deterministic for the same seed");
@ -702,6 +889,9 @@ try {
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");
const semeMap = generateMap(8363712);
const semeAdmin = (semeMap.adminCenters || []).find((center) => center.canonicalSettlementName);
assert(!semeAdmin || semeAdmin.name === semeAdmin.canonicalSettlementName, "seed 8363712: municipality label uses canonical settlement name");
for (const [n, seeded] of capitalNameMaps.entries()) {
const seedValue = [114514, 12345, 54321, 777, 999][n];
const metrics = terrainCoreMetrics(seeded);
@ -753,28 +943,13 @@ try {
.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_NAME_LIST.push("L1", "L2");
const listedCustomNames = Array.from({ length: 50 }, (_, n) => generateEntityName(9200 + n, `list-probe-${n}`, { x: 10, y: 10, kind: "Probe" }, {}, new Set()));
const listedCustomHits = listedCustomNames.filter((name) => name === "L1" || name === "L2").length;
assert(NAME_PROBABILITIES.customNameList > 0 && listedCustomHits > 0 && listedCustomHits < listedCustomNames.length, "CUSTOM_NAME_LIST supplies probabilistic selected place names");
CUSTOM_NAME_LIST.length = 0;
CUSTOM_NAMES["city-0"] = "C1";
const customSameA = generateMap(321);
const customSameB = generateMap(321);
const sameTargetA = customSameA.modernCities.find((item) => item.id === "city-0");
const sameTargetB = customSameB.modernCities.find((item) => item.id === "city-0");
const customSeedMaps = [301, 302, 303, 304, 305, 306, 307, 308].map((seedValue) => generateMap(seedValue));
const customTargets = customSeedMaps.map((seeded) => seeded.modernCities.find((item) => item.id === "city-0")).filter(Boolean);
const customHits = customTargets.filter((item) => item.name === "C1").length;
assert(sameTargetA?.name === sameTargetB?.name, "custom-name probability is deterministic for the same seed");
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"];
assert(!validateGeneratedName("青青").valid && validateGeneratedName("青青").reason === "repeatedKanji", "place names reject repeated kanji");
for (const seed of [101, 2026, 54321]) {
const seeded = generateMap(seed);
@ -787,10 +962,14 @@ try {
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.regionalDebug?.prefecturesGeneratedAfterMunicipalities === true, `seed ${seed}: prefectures are generated after municipalities`);
assert(seeded.regionalDebug?.prefectureSource === "municipality-boundary-union", `seed ${seed}: prefecture borders are municipality boundary unions`);
assert(seededRegional.maxComponents === 1, `seed ${seed}: every final regional prefecture is connected`);
assert(regionalEnclaveCount(seeded) === 0, `seed ${seed}: final regional prefectures have no one-region enclosed enclaves`);
assert(regionalBorderMetrics(seeded).invalidSame === 0, `seed ${seed}: regional borders separate final prefecture ids`);
const seededHierarchy = borderHierarchyViolations(seeded);
assert(seededHierarchy.prefectureCutsMunicipality === 0, `seed ${seed}: prefecture borders do not cut municipalities`);
assert(seededHierarchy.municipalityCutsCompartment === 0, `seed ${seed}: municipal borders do not cut natural compartments`);
assert(seeded.adminDebug && seeded.adminDebug.compartmentCount > 0, `seed ${seed}: natural compartments are built`);
assert(seeded.adminDebug.naturalCompartmentCount > metrics.municipalityCount, `seed ${seed}: compartments are finer than municipalities`);
assert(seeded.adminDebug.targetMunicipalityCount >= 20 && seeded.adminDebug.actualMunicipalityCount >= 18, `seed ${seed}: municipality count is dense enough`);
@ -798,8 +977,7 @@ try {
assert(seeded.adminDebug.finalTinyMunicipalityCount <= Math.max(3, Math.ceil(seeded.adminDebug.finalMunicipalityCount * 0.18)), `seed ${seed}: tiny final municipalities are limited`);
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.regionalDebug?.municipalityGraphNodeCount >= metrics.municipalityCount, `seed ${seed}: prefecture graph is based on municipalities`);
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`);