diff --git a/adminRegions.js b/adminRegions.js
index 877c1cf..15f66a8 100644
--- a/adminRegions.js
+++ b/adminRegions.js
@@ -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) {
@@ -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,25 @@ 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);
+ 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;
@@ -1490,10 +1501,13 @@ 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 progress = typeof options.progress === "function" ? options.progress : null;
const { compartmentId, compartments, naturalBarrierScore } = 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 +1519,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 {
diff --git a/app.js b/app.js
index 17158eb..c881bb3 100644
--- a/app.js
+++ b/app.js
@@ -34,6 +34,9 @@ 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");
function parseSeed(seedText) {
const numeric = Number.parseInt(seedText, 10);
@@ -56,10 +59,53 @@ function countText(items) {
return `${insideCount(items)} / outside ${outsideCount(items)}`;
}
+function formatMs(ms) {
+ if (!Number.isFinite(ms)) return "-";
+ return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`;
+}
+
+function renderTimingRows(timings = []) {
+ if (!progressTimingsEl) return;
+ progressTimingsEl.innerHTML = "";
+ for (const row of timings) {
+ const item = document.createElement("div");
+ item.className = "progress-timing-row";
+ const label = document.createElement("span");
+ label.textContent = row.label;
+ const value = document.createElement("strong");
+ value.textContent = formatMs(row.ms);
+ item.append(label, value);
+ progressTimingsEl.append(item);
+ }
+}
+
+function updateGenerationProgress(event) {
+ if (!progressEl) return;
+ progressEl.classList.remove("hidden");
+ if (progressStageEl) {
+ progressStageEl.textContent = event?.status === "done"
+ ? `Completed: ${event.label} / ${formatMs(event.ms)}`
+ : `Running: ${event?.label || "Preparing"}`;
+ }
+ renderTimingRows(event?.timings || []);
+}
+
+function setProgressVisible(visible, message = "Preparing") {
+ if (!progressEl) return;
+ progressEl.classList.toggle("hidden", !visible);
+ if (progressStageEl) progressStageEl.textContent = message;
+ if (visible) renderTimingRows([]);
+}
+
+function nextFrame() {
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()));
+}
+
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 || "-"}` : "-"],
["Villages", countText(map.villages)],
["Market Towns", countText(map.markets)],
["Castles", countText(map.castles)],
@@ -202,12 +248,22 @@ function renderModeButtons() { modeGrid.innerHTML = "";
}
}
-function regenerate() {
+async function regenerate() {
state.seedText = seedInput.value;
- state.map = generateMap(parseSeed(state.seedText));
- renderStats(state.map);
- renderNameIds(state.map);
- redraw();
+ setProgressVisible(true, "Preparing generation...");
+ await nextFrame();
+ try {
+ state.map = generateMap(parseSeed(state.seedText), { onProgress: updateGenerationProgress });
+ renderStats(state.map);
+ renderNameIds(state.map);
+ redraw();
+ if (progressStageEl) progressStageEl.textContent = `Done in ${formatMs(state.map.generationTotalMs || 0)}`;
+ renderTimingRows(state.map.generationTimings || []);
+ window.setTimeout(() => setProgressVisible(false), 900);
+ } catch (error) {
+ if (progressStageEl) progressStageEl.textContent = `Generation failed: ${error?.message || error}`;
+ throw error;
+ }
}
function redraw() {
diff --git a/index.html b/index.html
index 8cb1f7f..92a5cb1 100644
--- a/index.html
+++ b/index.html
@@ -22,6 +22,11 @@
+
+
Generating map...
+
Preparing
+
+
diff --git a/mapAdminStage.js b/mapAdminStage.js
index 6a9730a..411efc1 100644
--- a/mapAdminStage.js
+++ b/mapAdminStage.js
@@ -286,7 +286,22 @@ function restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compart
return { changedCells, restoredSeeds };
}
-function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }) {
+function municipalityCountBoundsForRegion(landCells, meta = {}) {
+ const focused = meta.isFocusedRegion !== false;
+ if (focused) return { min: 20, max: 50 };
+ // Neighbor prefectures are often visible only as clipped map-edge slivers.
+ // Avoid giving every tiny visible fragment the full 20-municipality floor.
+ let min = 1;
+ if (landCells >= 500) min = 2;
+ if (landCells >= 950) min = 3;
+ if (landCells >= 1700) min = 5;
+ if (landCells >= 2800) min = 7;
+ if (landCells >= 4300) min = 10;
+ const max = clamp(Math.round(landCells / 260 + 2), Math.max(min, 2), 34);
+ return { min, max };
+}
+
+function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta = {} }) {
let landCells = 0;
let habitableCells = 0;
let lowlandCells = 0;
@@ -314,8 +329,9 @@ function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope,
const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520);
const mountainRatio = landCells ? mountainCells / landCells : 0;
const lowlandBonus = Math.min(7, lowlandCells / 430);
- const target = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2);
- return clamp(target, 20, 50);
+ const rawTarget = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2);
+ const { min, max } = municipalityCountBoundsForRegion(landCells, adminRegionMeta);
+ return clamp(rawTarget, min, max);
}
function lowlandAdminSeedScore(i, { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse }) {
@@ -620,14 +636,24 @@ function generateAdminLayoutForMask({
stations,
industrialZones,
logisticsParks,
+ adminRegionMeta = {},
+ adminProgress = null,
}) {
const boundaryRidgeField = naturalBarrierScore
? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46))
: ridgeField;
+ adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "classify satellites" });
const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum);
- const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages });
+ const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta });
const compartmentMultiplier = clamp(4.6 + rand(seed, 1320) * 2.4, 4.6, 7.0);
- let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 120, 360);
+ const regionLandArea = adminRegionMeta.landArea || maskLandArea(prefectureMask, sea);
+ const minCompartmentTarget = adminRegionMeta.isFocusedRegion === false
+ ? clamp(Math.round(Math.max(targetMunicipalityCount * 3.2, regionLandArea / 75)), 18, 90)
+ : 120;
+ const maxCompartmentTarget = adminRegionMeta.isFocusedRegion === false
+ ? clamp(Math.round(Math.max(targetMunicipalityCount * 5.8, regionLandArea / 38)), minCompartmentTarget, 220)
+ : 360;
+ let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), minCompartmentTarget, maxCompartmentTarget);
let adminCentersRaw = buildLowlandAdminSeeds({
seed,
targetMunicipalityCount,
@@ -652,12 +678,14 @@ function generateAdminLayoutForMask({
newTowns,
stations,
});
- if (adminCentersRaw.length < 20) targetCompartmentCount = Math.max(targetCompartmentCount, 120);
+ if (adminCentersRaw.length < 20) targetCompartmentCount = Math.max(targetCompartmentCount, minCompartmentTarget);
+ adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "natural compartments", targetMunicipalityCount, targetCompartmentCount, seedCount: adminCentersRaw.length });
const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, {
seed,
targetMunicipalityCount,
targetCompartmentCount,
maxNaturalCompartmentArea: Math.max(32, Math.round(maskLandArea(prefectureMask, sea) / Math.max(1, targetCompartmentCount) * 1.65)),
+ progress: (step) => adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step }),
});
const adminId = compartmentAssignment.adminId;
let previousSnapshot = new Int16Array(adminId);
@@ -710,6 +738,7 @@ function generateAdminLayoutForMask({
satelliteMunicipalityStats: satelliteClassificationDebug,
...compartmentAssignment.debug,
};
+ adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "seed lifecycle" });
const seedLifecycle = buildSeedLifecycle(adminCentersRaw, adminId, prefectureMask, sea, 35);
const pendingSplitDebug = splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, {
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
@@ -735,6 +764,7 @@ function generateAdminLayoutForMask({
adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea);
previousSnapshot = new Int16Array(adminId);
}
+ adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "smooth boundaries" });
smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, populationDensity, landuse, 2);
markChanged("changedAfterSmooth");
@@ -797,19 +827,34 @@ function generateAdminLayoutForMask({
const activeAdminCenters = () => adminCentersRaw.filter((_, id) => activeAdminIds.has(id));
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: activeAdminCenters() });
markChanged("changedAfterInitialMerge");
+ adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "topology cleanup" });
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180);
markChanged("changedAfterInitialExclaveRemoval");
// The initial compartment graph assignment is now the primary natural partition.
// Re-running the older raw landscape-unit pass here collapses lowland seeds into a few broad owners.
markChanged("changedAfterLandscapePartition");
- const oversizedSplitDebug = splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, [...(satelliteCities || []), ...newTowns, ...markets, ...villages]);
+ adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "split oversized municipalities" });
+ // The older oversized-lowland pass rebuilds natural compartments a second time.
+ // The current pipeline already performs pending-seed lowland splitting on the active
+ // compartment graph above, so keep the full admin layout while avoiding the duplicate
+ // high-cost recomputation.
+ const oversizedSplitDebug = {
+ changedCells: 0,
+ splitMunicipalities: 0,
+ rejectedMunicipalities: 0,
+ skippedDuplicateCompartmentRebuild: true,
+ skippedForVisibleFragment: adminRegionMeta.isFocusedRegion === false && regionLandArea < 6500,
+ };
adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells;
adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities;
adminDebug.oversizedRuralSplits = oversizedSplitDebug.splitMunicipalities;
adminDebug.oversizedLowlandSplits = oversizedSplitDebug.splitMunicipalities;
adminDebug.ruralSplitsAccepted = oversizedSplitDebug.splitMunicipalities;
adminDebug.ruralSplitsRejected = oversizedSplitDebug.rejectedMunicipalities || 0;
+ adminDebug.oversizedSplitSkippedForVisibleFragment = Boolean(oversizedSplitDebug.skippedForVisibleFragment);
+ adminDebug.oversizedSplitSkippedDuplicateCompartmentRebuild = Boolean(oversizedSplitDebug.skippedDuplicateCompartmentRebuild);
previousSnapshot = new Int16Array(adminId);
+ adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "terrain snap" });
snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 2);
markChanged("changedAfterSnap");
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 360);
@@ -881,6 +926,7 @@ function generateAdminLayoutForMask({
adminDebug.seedLifecycle = seedLifecycle.map((seed) => ({ id: seed.id, state: seed.state, protected: seed.protected, area: seed.area }));
adminDebug.borderNaturalBarrierAverage = adminDebug.finalBorderNaturalBarrierAverage ?? 0;
adminDebug.voronoiLikeRate = adminDebug.voronoiLikeRateAfter ?? 0;
+ adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "extract borders" });
const adminBorders = extractAdminBorderSegments(adminId, prefectureMask);
@@ -889,7 +935,9 @@ function generateAdminLayoutForMask({
function filterPointsForMask(points = [], mask, sea) {
- return (points || []).filter((p) => p && inside(p.x, p.y) && mask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]);
+ return (points || [])
+ .filter((p) => p && inside(p.x, p.y) && mask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)])
+ .map((p) => ({ ...p }));
}
function buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId) {
@@ -907,6 +955,85 @@ function maskLandArea(mask, sea) {
return area;
}
+
+function compactAdminIdsAndCenters(adminId, humanMask, sea, centers = [], fields = {}) {
+ const activeIds = [...new Set([...adminId].filter((id, i) => id >= 0 && humanMask[i] && !sea[i]))].sort((a, b) => a - b);
+ const idMap = new Map(activeIds.map((oldId, newId) => [oldId, newId]));
+ const newAdminId = new Int16Array(SIZE);
+ newAdminId.fill(-1);
+ const cellsByNewId = Array.from({ length: activeIds.length }, () => []);
+ for (let i = 0; i < SIZE; i++) {
+ if (!humanMask[i] || sea[i]) continue;
+ const newId = idMap.get(adminId[i]);
+ if (newId === undefined) continue;
+ newAdminId[i] = newId;
+ cellsByNewId[newId].push(i);
+ }
+
+ const chooseOffice = (newId, oldId) => {
+ const cells = cellsByNewId[newId] || [];
+ const current = centers[oldId];
+ if (current && inside(current.x, current.y)) {
+ const ci = indexOf(current.x, current.y);
+ if (newAdminId[ci] === newId && humanMask[ci] && !sea[ci]) {
+ return { ...current, localAdminId: newId, oldAdminId: oldId, municipalityOffice: true };
+ }
+ }
+ let sx = 0, sy = 0;
+ for (const i of cells) {
+ const [x, y] = xyOf(i);
+ sx += x;
+ sy += y;
+ }
+ const cx = cells.length ? sx / cells.length : current?.x || 0;
+ const cy = cells.length ? sy / cells.length : current?.y || 0;
+ let bestI = cells[0] ?? -1;
+ let bestScore = -INF;
+ for (const i of cells) {
+ const [x, y] = xyOf(i);
+ const land = fields.landuse?.[i] ?? 0;
+ const urbanBonus = land === 3 ? 1.2 : land === 2 ? 1.0 : land === 4 || land === 7 || land === 8 ? 0.55 : land === 1 ? 0.24 : 0;
+ const density = fields.populationDensity?.[i] || 0;
+ const settlement = fields.settlementScore?.[i] || 0;
+ const score =
+ density * 2.25 +
+ settlement * 0.75 +
+ urbanBonus +
+ (fields.plain?.[i] || 0) * 0.32 +
+ (fields.basinField?.[i] || 0) * 0.24 +
+ (fields.coastalLowland?.[i] || 0) * 0.18 +
+ (fields.roadInfluence?.[i] || 0) * 0.34 +
+ (fields.stationInfluence?.[i] || 0) * 0.45 -
+ (fields.slope?.[i] || 0) * 0.52 -
+ Math.hypot(x - cx, y - cy) * 0.018 +
+ hash2(x, y, 91337 + newId) * 0.012;
+ if (score > bestScore) { bestScore = score; bestI = i; }
+ }
+ const [bx, by] = bestI >= 0 ? xyOf(bestI) : [Math.round(cx), Math.round(cy)];
+ return {
+ ...(current || {}),
+ x: bx,
+ y: by,
+ score: bestScore > -INF ? bestScore : 0,
+ seedKind: current?.seedKind || "generatedMunicipalOffice",
+ invisibleLowlandAdminSeed: current?.invisibleLowlandAdminSeed ?? true,
+ localAdminId: newId,
+ oldAdminId: oldId,
+ municipalityOffice: true,
+ generatedOfficePoint: !current || !inside(current.x, current.y) || newAdminId[indexOf(current.x, current.y)] !== newId,
+ };
+ };
+
+ const adminCenters = activeIds.map((oldId, newId) => chooseOffice(newId, oldId));
+ return {
+ adminId: newAdminId,
+ adminCenters,
+ activeMunicipalityCount: activeIds.length,
+ removedUnusedAdminCenterCount: Math.max(0, centers.length - activeIds.length),
+ generatedOfficePointCount: adminCenters.filter((p) => p.generatedOfficePoint).length,
+ };
+}
+
function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) {
const ids = new Set();
for (let i = 0; i < SIZE; i++) {
@@ -918,16 +1045,17 @@ function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) {
}
export function generateAdminLayout(context) {
- const { prefectureMask, prefectureRegionId, sea, populationDensity, plain, slope } = context;
+ const { prefectureMask, prefectureRegionId, sea, populationDensity, plain, slope, adminProgress } = context;
+ const minFullAdminRegionArea = 1500;
const regionIds = discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea)
- .filter((regionId) => maskLandArea(buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId), sea) >= 120);
+ .filter((regionId) => regionId === 0 || (regionId !== OUTER_ANCHOR_REGION_ID && maskLandArea(buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId), sea) >= minFullAdminRegionArea));
if (!prefectureRegionId || regionIds.length <= 1) return generateAdminLayoutForMask(context);
- const combinedAdminId = new Int16Array(SIZE);
+ let combinedAdminId = new Int16Array(SIZE);
combinedAdminId.fill(-1);
const combinedHumanMask = new Uint8Array(SIZE);
- const combinedCenters = [];
+ let combinedCenters = [];
const combinedCompartmentBorders = [];
const perRegion = [];
let idOffset = 0;
@@ -935,7 +1063,7 @@ export function generateAdminLayout(context) {
for (const regionId of regionIds) {
const regionMask = buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId);
const regionArea = maskLandArea(regionMask, sea);
- if (regionArea < 120) continue;
+ if (regionId !== 0 && regionArea < minFullAdminRegionArea) continue;
const localContext = {
...context,
@@ -950,9 +1078,18 @@ export function generateAdminLayout(context) {
stations: filterPointsForMask(context.stations, regionMask, sea),
industrialZones: filterPointsForMask(context.industrialZones, regionMask, sea),
logisticsParks: filterPointsForMask(context.logisticsParks, regionMask, sea),
+ adminRegionMeta: {
+ regionId,
+ landArea: regionArea,
+ isFocusedRegion: regionId === 0,
+ isOuterAnchorRegion: regionId === OUTER_ANCHOR_REGION_ID,
+ },
+ adminProgress,
};
+ adminProgress?.({ status: "region-start", regionId, area: regionArea });
const local = generateAdminLayoutForMask(localContext);
+ adminProgress?.({ status: "region-done", regionId, area: regionArea, municipalities: local.adminDebug?.finalMunicipalityCount || local.adminDebug?.actualMunicipalityCount || 0 });
if (local.adminDebug?.compartmentBorders?.length) combinedCompartmentBorders.push(...local.adminDebug.compartmentBorders);
let localMaxAdminId = -1;
for (let i = 0; i < SIZE; i++) if (regionMask[i] && !sea[i] && (local.adminId?.[i] ?? -1) > localMaxAdminId) localMaxAdminId = local.adminId[i];
@@ -1036,8 +1173,21 @@ export function generateAdminLayout(context) {
perRegion.push({ regionId, area: cells.length, centerCount: 1, municipalityCount: 1, naturalCompartmentCount: 1, targetNaturalCompartmentCount: 1, averageCompartmentArea: cells.length, maxCompartmentArea: cells.length, maxCompartmentElongation: 1, singleCompartmentMunicipalityRatio: 1, tinyRegionFallback: true });
}
+ const compactedAdmin = compactAdminIdsAndCenters(combinedAdminId, combinedHumanMask, sea, combinedCenters, {
+ populationDensity,
+ plain,
+ slope,
+ settlementScore: context.settlementScore,
+ landuse: context.landuse,
+ basinField: context.basinField,
+ coastalLowland: context.coastalLowland,
+ roadInfluence: context.roadInfluence,
+ stationInfluence: context.stationInfluence,
+ });
+ combinedAdminId = compactedAdmin.adminId;
+ combinedCenters = compactedAdmin.adminCenters;
const adminBorders = extractAdminBorderSegments(combinedAdminId, combinedHumanMask);
- const totalMunicipalityCount = new Set([...combinedAdminId].filter((id, i) => id >= 0 && combinedHumanMask[i] && !sea[i])).size;
+ const totalMunicipalityCount = compactedAdmin.activeMunicipalityCount;
const totalNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.naturalCompartmentCount || 0), 0);
const totalTargetNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.targetNaturalCompartmentCount || 0), 0);
const weightedCompartmentArea = perRegion.reduce((sum, row) => sum + (row.averageCompartmentArea || 0) * (row.naturalCompartmentCount || 0), 0);
@@ -1045,10 +1195,14 @@ export function generateAdminLayout(context) {
const adminDebug = {
multiRegionAdmin: true,
adminRegionCount: perRegion.length,
+ minFullAdminRegionArea,
perRegion,
finalMunicipalityCount: totalMunicipalityCount,
actualMunicipalityCount: totalMunicipalityCount,
candidateSeedCount: combinedCenters.length,
+ municipalOfficePointCount: combinedCenters.length,
+ generatedOfficePointCount: compactedAdmin.generatedOfficePointCount,
+ removedUnusedAdminCenterCount: compactedAdmin.removedUnusedAdminCenterCount,
naturalCompartmentCount: totalNaturalCompartmentCount,
compartmentCount: totalNaturalCompartmentCount,
targetNaturalCompartmentCount: totalTargetNaturalCompartmentCount,
diff --git a/mapFeaturesV2.js b/mapFeaturesV2.js
new file mode 100644
index 0000000..eecedf9
--- /dev/null
+++ b/mapFeaturesV2.js
@@ -0,0 +1,869 @@
+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.50 +
+ river[i] * 0.16 +
+ confluence * 0.72 +
+ depositional * 0.16 +
+ basinField[i] * 0.10 +
+ lowSlope * 0.10 -
+ slope[i] * 0.58 -
+ ridgeField[i] * 0.34 -
+ spine * 0.18 -
+ highPenalty * 0.72
+ );
+ 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.12 : 0,
+ distanceToNearest(crossings, x, y) < 6 ? 0.10 : 0,
+ confluenceField[i] * 0.26
+ );
+ const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.12 : 0;
+ marketScore[i] = clamp(
+ townSuitability[i] * 0.66 +
+ villageInfluence[i] * 0.42 +
+ featurePull +
+ valleyMouth +
+ basinField[i] * 0.10 +
+ plain[i] * 0.09 -
+ slope[i] * 0.20 -
+ ridgeField[i] * 0.10
+ );
+ }
+ }
+
+ 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.50 +
+ (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(4.5 + Math.sqrt(city.population) / 95, 7, isPrefecturalCapital ? 32 : isRegionalCapital ? 27 : 22);
+ city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 380, 2.2, isPrefecturalCapital ? 8.5 : 6.5);
+ city.urbanWeight = clamp(0.85 + Math.log10(Math.max(10000, city.population)) * 0.25, 1.0, 2.2);
+ }
+
+ 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) {
+ 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.20 + developable[i] * 1.05 + valleySettlement[i] * 0.18 + coastalSettlement[i] * 0.10 - slope[i] * 0.22 - ridgeField[i] * 0.14, 0, 1.28) : 1;
+ const v = weight * Math.pow(1 - d / Math.max(1, radius), exponent) * terrain;
+ if (v > grid[i]) grid[i] = v;
+ }
+ }
+ }
+
+ for (const city of modernCities) {
+ addKernel(cityInfluence, city, city.urbanRadius || 10, city.urbanWeight || 1.0, 1.25, true);
+ addKernel(coreInfluence, city, city.coreRadius || 3, (city.urbanWeight || 1.0) * 1.10, 1.65, true);
+ }
+ 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;
+ 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 urban = cityInfluence[i] * 0.78 + stationInfluence[i] * 0.22 + roadInfluence[i] * 0.08;
+ const core = coreInfluence[i];
+ const oldTown = oldTownInfluence[i] * 0.72 + townInfluence[i] * 0.42;
+ const rural = villageInfluence[i] * 0.25 + ruralSuitability[i] * 0.30;
+ populationDensity[i] = clamp(urban * 0.74 + core * 0.32 + oldTown * 0.28 + townInfluence[i] * 0.18 + villageInfluence[i] * 0.16 + roadInfluence[i] * 0.05);
+ 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;
+ else if (industrialInfluence[i] > 0.22 && developable[i] > 0.08) landuse[i] = LANDUSE.INDUSTRIAL;
+ else if (core > 0.38 && developable[i] > 0.07) landuse[i] = LANDUSE.CBD;
+ else if (oldTown > 0.24 && developable[i] > 0.06) landuse[i] = LANDUSE.OLD_URBAN;
+ else if (urban > 0.22 && developable[i] > 0.08) landuse[i] = LANDUSE.SUBURB;
+ else if (roadInfluence[i] > 0.22 && developable[i] > 0.16 && townInfluence[i] > 0.07) landuse[i] = LANDUSE.ROADSIDE;
+ else if (agriculture[i] > 0.22 || rural > 0.22 || (developable[i] > 0.18 && plain[i] > 0.16)) landuse[i] = LANDUSE.FARMLAND;
+ else landuse[i] = elevation[i] > 0.52 || slope[i] > 0.34 ? LANDUSE.FOREST : 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,
+ };
+}
diff --git a/mapOutput.js b/mapOutput.js
index 02e21d1..342a3b8 100644
--- a/mapOutput.js
+++ b/mapOutput.js
@@ -105,6 +105,8 @@ export function finishMapOutput({
terrainDebug,
regionalPrefectureBorders,
}) {
+ 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.
const humanRegionMask = new Uint8Array(MAP_W * MAP_H);
@@ -123,6 +125,8 @@ export function finishMapOutput({
}
}
+ outputProgress("harbor works");
+
function makeHarborWorks(ports) {
const out = [];
for (const port of ports) {
@@ -155,6 +159,7 @@ export function finishMapOutput({
const usedNames = new Set();
const nameDebug = createNameDebug();
+ outputProgress("feature naming");
villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug);
ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug);
crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug);
@@ -171,6 +176,7 @@ export function finishMapOutput({
newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug);
castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug);
externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug);
+ outputProgress("municipality naming");
const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
const representativeFeatures = [
...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })),
@@ -206,6 +212,8 @@ export function finishMapOutput({
}
const usedAdminNames = new Set();
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);
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index);
if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
@@ -223,6 +231,7 @@ export function finishMapOutput({
}
nameDebug.maxDerivedPerBase = 0;
+ outputProgress("final package");
const entitiesForNames = [
...modernCities,
...ports,
diff --git a/mapPipeline.js b/mapPipeline.js
index bc32a17..f4f30c6 100644
--- a/mapPipeline.js
+++ b/mapPipeline.js
@@ -1,15 +1,34 @@
import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
import { generateTerrainAndRivers } from "./mapTerrain.js";
-import { generateMapFeatures } from "./mapFeatures.js";
+import { generateMapFeatures } from "./mapFeaturesV2.js";
import { finishMapOutput } from "./mapOutput.js";
import { generateAdminLayout } from "./mapAdminStage.js";
export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
+function nowMs() {
+ return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
+}
+
+function timedStage(timings, options, key, label, fn) {
+ options?.onProgress?.({ status: "start", key, label, timings: timings.slice() });
+ 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() });
+ return value;
+}
+
export function generateMap(seedInput = 114514, options = {}) {
const seed = Number(seedInput) >>> 0;
+ if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
- const terrain = generateTerrainAndRivers(seed);
+ 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 {
terrainTemplate,
seaLevel,
@@ -54,19 +73,29 @@ export function generateMap(seedInput = 114514, options = {}) {
smallStreams,
} = terrain;
- const features = generateMapFeatures(seed, terrain);
+ const features = stage("human", "Human geography and transport", () => generateMapFeatures(seed, terrain));
const {
ports, crossings, passes, settlementCluster, settlementScore, villages, markets, castles, premodernRoads, minorRoads, castleTowns, modernCities, populationDensity,
railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways,
interchanges, logisticsParks, satelliteCities, newTowns, landuse, stationInfluence, roadInfluence, railInfluence2, villageInfluence, externalGateways, cityPopulationCap, transportDebug,
} = features;
- const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({
+ const { adminCentersRaw, adminId, adminBorders, adminDebug } = stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
seed, prefectureMask, prefectureRegionId: adminPrefectureRegionId || prefectureRegionId, 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(),
+ }),
+ }));
- return finishMapOutput({
+ const output = stage("output", "Names, population totals, and final packaging", () => finishMapOutput({
seed, options, terrainTemplate, seaLevel, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2,
elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, visibleRavineField, surfaceTextureField, basinField, coastalLowland, flowAccum, erosionField, depositionField,
arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore,
@@ -75,5 +104,8 @@ export function generateMap(seedInput = 114514, options = {}) {
interchanges, logisticsParks, satelliteCities, newTowns, landuse, adminCentersRaw, adminId, adminBorders, adminDebug,
riverPaths, mainRivers, tributaryRivers, smallStreams, externalGateways, prefectureMask, prefectureBorder, prefectureRegionId, regionalPrefectureBorders,
regionalDebug, terrainDebug, transportDebug,
- });
+ }));
+ output.generationTimings = generationTimings;
+ output.generationTotalMs = generationTimings.reduce((sum, row) => sum + row.ms, 0);
+ return output;
}
diff --git a/renderer.js b/renderer.js
index f0e2eea..d08051f 100644
--- a/renderer.js
+++ b/renderer.js
@@ -765,9 +765,14 @@ export function drawMap(canvas, map, options) {
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 (map.regionalPrefectureBorders) drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
}
-
+
+ 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 });
@@ -818,6 +823,10 @@ export function drawMap(canvas, map, options) {
}
// 6. Icons & Labels
+ if (["admin", "admin-debug", "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");
for (const p of map.modernCities) {
diff --git a/styles.css b/styles.css
index 5b45a82..9cf2ea4 100644
--- a/styles.css
+++ b/styles.css
@@ -65,4 +65,11 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
.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)}
\ No newline at end of file
+.map-tooltip.visible{opacity:1;transform:translateY(0)}
+
+.generation-progress{position:absolute;inset:24px auto auto 24px;z-index:30;min-width:300px;max-width:440px;background:rgba(255,255,255,0.96);border:1px solid rgba(0,0,0,0.12);border-radius:12px;box-shadow:0 14px 36px rgba(0,0,0,0.14);padding:14px 16px;color:#202124;font-size:13px;line-height:1.5}
+.generation-progress.hidden{display:none}
+.progress-title{font-weight:700;margin-bottom:4px}
+.progress-stage{color:#5f6368;margin-bottom:10px}
+.progress-timings{display:flex;flex-direction:column;gap:4px;font-family:ui-monospace,monospace;font-size:12px;color:#3c4043}
+.progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(0,0,0,0.06);padding-top:4px}