map/mapFeatures.js
2026-05-27 00:13:13 +09:00

2581 lines
116 KiB
JavaScript

import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, fbm, hash2, indexOf, inside, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js";
import { distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js";
import { LANDUSE } from "./landuseCodes.js";
import { createPathInfluenceCache, packDebugField, pathAverageField, pathLengthCells, routeQualityAcceptable } from "./mapTransport.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;
if (!prefectureRegionId) 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 openPlainPotential = clamp(plain[i] * 0.68 + agriculture[i] * 0.54 + basinField[i] * 0.30 + lowSlope * 0.22 - river[i] * 0.18 - valleyField[i] * 0.08 - ridgeField[i] * 0.22 - slope[i] * 0.26);
const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38;
confluenceField[i] = confluence;
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.42 + valleySettlement[i] * 0.12 + coastalSettlement[i] * 0.22 + agriculture[i] * 0.48 + plain[i] * 0.32 + openPlainPotential * 0.46) * clusterNoise);
ruralSuitability[i] = clamp(
agriculture[i] * 0.54 +
developable[i] * 0.30 +
valleySettlement[i] * 0.18 +
coastalSettlement[i] * 0.20 +
openPlainPotential * 0.34 +
settlementCluster[i] * 0.30 -
Math.max(0, elevation[i] - 0.64) * 0.56
);
townSuitability[i] = clamp(
developable[i] * 0.38 +
agriculture[i] * 0.18 +
valleySettlement[i] * 0.16 +
coastalSettlement[i] * 0.30 +
confluence * 0.20 +
basinField[i] * 0.18 +
plain[i] * 0.26 +
openPlainPotential * 0.44 +
settlementCluster[i] * 0.22 -
slope[i] * 0.34 -
ridgeField[i] * 0.17 -
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 (!st || st.area <= 0) return 0;
// Treat the focused prefecture and neighboring prefectures with the same
// density curve. Only genuinely clipped map-edge slivers are downscaled.
return clamp(Math.sqrt(st.area / 1900), 0.32, 1.05);
}
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.50 + agriculture[i] * 0.48 + plain[i] * 0.42 + Math.max(0, plain[i] * 1.12 + agriculture[i] * 0.78 + basinField[i] * 0.30 - river[i] * 0.36 - valleyField[i] * 0.14 - flowAccum[i] * 0.10) * 0.58 + valleySettlement[i] * 0.06 + coastalSettlement[i] * 0.34 + settlementCluster[i] * 0.22 - river[i] * 0.10 - flowAccum[i] * 0.04);
}
let villages = pickRegionalPoints(villageScore, {
stride: 2,
threshold: 0.18 + rand(seed, 1031) * 0.030,
totalMax: 280,
minDistance: 4,
seedOffset: 1030,
kind: "Village",
quotaForRegion: (regionId, st) => {
if (!st || st.developableCells < 10) return 0;
const vf = visibilityFactor(regionId, st);
const raw = (st.developableCells / 28 + st.plainCells / 42 + st.valleyCells / 34 + st.coastCells / 32 + 3.2) * vf;
const min = st.area > 2600 ? 20 : st.area > 1400 ? 12 : st.area > 520 ? 5 : st.area > 220 ? 2 : 0;
const max = st.area > 3600 ? 72 : st.area > 2200 ? 50 : st.area > 900 ? 25 : 10;
return Math.round(clamp(raw + rand(seed, 1033 + regionId * 19) * 1.5, min, max));
},
}).map((p, n) => {
const i = indexOf(p.x, p.y);
const kind = coastalSettlement[i] > 0.36 ? "Coastal Village" : valleySettlement[i] > 0.46 ? "Valley Village" : "Village";
const population = Math.round((900 + Math.pow(rand(seed, 18000 + n * 17 + p.x * 3 + p.y), 1.30) * 9800 + ruralSuitability[i] * 4700 + agriculture[i] * 3600) / 100) * 100;
return { ...p, kind, population };
});
// Supplemental open-plain villages: broad Japanese-style farmland should not be empty
// just because it lacks a river/confluence anchor.
const openPlainVillageScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
const open = Math.max(0, plain[i] * 0.92 + agriculture[i] * 0.72 + basinField[i] * 0.26 + (depositionalLowland?.[i] || 0) * 0.20 - river[i] * 0.22 - valleyField[i] * 0.10 - flowAccum[i] * 0.08 - slope[i] * 0.24 - ridgeField[i] * 0.14);
openPlainVillageScore[i] = clamp(open + settlementCluster[i] * 0.16 + ruralSuitability[i] * 0.18);
}
const supplementalPlainVillages = pickRegionalPoints(openPlainVillageScore, {
stride: 2,
threshold: 0.235 + rand(seed, 1036) * 0.020,
totalMax: 120,
minDistance: 5,
seedOffset: 1035,
kind: "Plain Village",
predicate: (x, y, i) => plain[i] > 0.20 && agriculture[i] > 0.16 && river[i] < 0.30 && valleyField[i] < 0.52 && slope[i] < 0.32,
quotaForRegion: (regionId, st) => {
if (!st || st.plainCells < 24) return 0;
const vf = visibilityFactor(regionId, st);
const raw = (st.plainCells / 62 + st.developableCells / 180 + 1.4) * vf;
const min = st.plainCells > 360 ? 6 : st.plainCells > 160 ? 3 : st.plainCells > 70 ? 1 : 0;
const max = st.plainCells > 720 ? 24 : st.plainCells > 360 ? 16 : st.plainCells > 140 ? 8 : 3;
return Math.round(clamp(raw + rand(seed, 1037 + regionId * 29) * 1.4, min, max));
},
extraScore: (x, y, i) => Math.max(0, plain[i] * 0.34 + agriculture[i] * 0.26 - river[i] * 0.20 - valleyField[i] * 0.12),
}).filter((p) => distanceToNearest(villages, p.x, p.y) >= 4.5)
.map((p, n) => {
const i = indexOf(p.x, p.y);
const population = Math.round((1100 + Math.pow(rand(seed, 18220 + n * 31 + p.x * 7 + p.y), 1.12) * 7600 + agriculture[i] * 3900 + plain[i] * 2200) / 100) * 100;
return { ...p, kind: "Plain Village", population };
});
villages = [...villages, ...supplementalPlainVillages];
const villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2));
const marketScore = new Float32Array(SIZE);
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 openPlainMarket = Math.max(0, plain[i] * 0.68 + agriculture[i] * 0.52 + basinField[i] * 0.24 + (depositionalLowland?.[i] || 0) * 0.18 - river[i] * 0.16 - valleyField[i] * 0.06 - slope[i] * 0.18);
const featurePull = Math.max(
distanceToNearest(ports, x, y) < 10 ? 0.16 : 0,
distanceToNearest(crossings, x, y) < 6 ? 0.035 : 0,
confluenceField[i] * 0.08
);
const valleyMouth = valleyField[i] > 0.22 && (plain[i] > 0.22 || basinField[i] > 0.18 || coastalLowland[i] > 0.18) ? 0.14 : 0;
marketScore[i] = clamp(
townSuitability[i] * 0.50 +
agriculture[i] * 0.30 +
plain[i] * 0.28 +
openPlainMarket * 0.74 +
coastalSettlement[i] * 0.22 +
villageInfluence[i] * 0.28 +
featurePull +
valleyMouth +
basinField[i] * 0.14 +
plain[i] * 0.22 +
coastalLowland[i] * 0.08 -
slope[i] * 0.18 -
ridgeField[i] * 0.08 -
river[i] * 0.08 -
flowAccum[i] * 0.035
);
}
}
let markets = pickRegionalPoints(marketScore, {
stride: 2,
threshold: 0.245 + rand(seed, 1041) * 0.035,
totalMax: 110,
minDistance: 6,
seedOffset: 1040,
kind: "Market Town",
quotaForRegion: (regionId, st) => {
if (!st || st.townCells < 8) return 0;
const vf = visibilityFactor(regionId, st);
const raw = (st.developableCells / 92 + st.plainCells / 108 + st.valleyCells / 92 + st.coastCells / 72 + 2.7) * vf;
const min = st.area > 2600 ? 9 : st.area > 1200 ? 5 : st.area > 520 ? 2 : 0;
const max = st.area > 3600 ? 30 : st.area > 2200 ? 22 : st.area > 800 ? 10 : 5;
return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max));
},
extraScore: (x, y, i) => (distanceToNearest(commercialPorts, x, y) < 10 ? 0.12 : 0) + coastalSettlement[i] * 0.08 + Math.max(0, plain[i] * 0.32 + agriculture[i] * 0.20 - river[i] * 0.16) * 0.09 + confluenceField[i] * 0.035,
}).map((p, n) => {
const i = indexOf(p.x, p.y);
const kind = coastalSettlement[i] > 0.38 && distanceToNearest(ports, p.x, p.y) < 11 ? "Port Town" : valleySettlement[i] > 0.48 ? "Valley Market Town" : "Market Town";
const population = Math.round((9000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.02) * 70000 + marketScore[i] * 40000 + villageInfluence[i] * 7800 + Math.max(0, plain[i] * 0.48 + agriculture[i] * 0.30 + basinField[i] * 0.18 - river[i] * 0.14) * 22000 + coastalSettlement[i] * 12000) / 1000) * 1000;
return { ...p, kind, population };
});
const openPlainMarketScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
const open = Math.max(0, plain[i] * 0.86 + agriculture[i] * 0.64 + basinField[i] * 0.28 + (depositionalLowland?.[i] || 0) * 0.22 - river[i] * 0.20 - valleyField[i] * 0.10 - flowAccum[i] * 0.08 - slope[i] * 0.24 - ridgeField[i] * 0.16);
openPlainMarketScore[i] = clamp(open + townSuitability[i] * 0.20 + villageInfluence[i] * 0.18 + settlementCluster[i] * 0.12);
}
const supplementalPlainMarkets = pickRegionalPoints(openPlainMarketScore, {
stride: 2,
threshold: 0.335 + rand(seed, 1046) * 0.025,
totalMax: 55,
minDistance: 9,
seedOffset: 1045,
kind: "Plain Market Town",
predicate: (x, y, i) => plain[i] > 0.22 && agriculture[i] > 0.18 && river[i] < 0.28 && valleyField[i] < 0.50 && slope[i] < 0.30,
quotaForRegion: (regionId, st) => {
if (!st || st.plainCells < 60) return 0;
const vf = visibilityFactor(regionId, st);
const raw = (st.plainCells / 260 + st.developableCells / 520 + 0.45) * vf;
const min = st.plainCells > 520 ? 2 : st.plainCells > 220 ? 1 : 0;
const max = st.plainCells > 900 ? 8 : st.plainCells > 420 ? 5 : st.plainCells > 160 ? 2 : 1;
return Math.round(clamp(raw + rand(seed, 1047 + regionId * 31) * 0.9, min, max));
},
extraScore: (x, y, i) => Math.max(0, plain[i] * 0.24 + agriculture[i] * 0.18 - river[i] * 0.14 - valleyField[i] * 0.08),
}).filter((p) => distanceToNearest(markets, p.x, p.y) >= 8.5 && distanceToNearest(villages, p.x, p.y) >= 3.5)
.map((p, n) => {
const i = indexOf(p.x, p.y);
const population = Math.round((10000 + Math.pow(rand(seed, 18340 + n * 37 + p.x * 11 + p.y), 1.02) * 52000 + openPlainMarketScore[i] * 26000 + agriculture[i] * 9000 + plain[i] * 8000) / 1000) * 1000;
return { ...p, kind: "Plain Market Town", population };
});
markets = [...markets, ...supplementalPlainMarkets];
const defenseScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
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.48 + agriculture[i] * 0.22 + basinField[i] * 0.28 + coastalLowland[i] * 0.18 + valleyField[i] * 0.08 - slope[i] * 0.42 - ridgeField[i] * 0.18, 0.26, 1.38);
capacity += dev * (900 + 6200 * Math.pow(radial, 1.25)) * terrainMultiplier * densityBias;
}
}
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 st = regionStats.get(regionId);
const cityRadius = st && st.area > 2400 ? 30 : st && st.area > 900 ? 26 : 22;
const capacity = estimateUrbanCapacity(p, cityRadius, p.candidateKind === "town" ? 1.14 : p.candidateKind === "port" ? 1.10 : 1.06);
const score =
Math.log10(capacity + 1) * 0.72 +
townSuitability[i] * 1.40 +
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 = clamp(
Math.round((st.developableCells / 560 + 1.15) * vf + rand(seed, 12100 + regionId * 17) * 1.4),
(st.area > 900 || st.developableCells > 180) ? 1 : 0,
st.area > 3600 ? 6 : st.area > 2200 ? 4 : st.area > 900 ? 3 : 1
);
const selected = pickEntities(list, {
max: maxCities,
minDistance: 17,
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);
}
}
// No focused-prefecture fallback: all prefecture regions use the same city
// selection rules, so the highlighted region is not overwritten after the
// regional pass.
modernCities.sort((a, b) => b.capacity - a.capacity || b.score - a.score);
for (const [rank, city] of modernCities.entries()) {
const isFirstInRegion = !modernCities.slice(0, rank).some((c) => c.regionId === city.regionId);
const isPrefecturalCapital = inFocusedPrefecture(city) && !modernCities.slice(0, rank).some((c) => c.isPrefecturalCapital);
const isRegionalCapital = isFirstInRegion;
const rawPop = isRegionalCapital
? 300000 + rand(seed, 12201 + city.regionId * 17) * 740000
: 68000 + Math.pow(rand(seed, 12202 + rank * 19 + city.x), 0.62) * 430000;
const capMultiplier = isRegionalCapital ? 1.68 : 1.28;
const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000;
city.population = Math.max(isRegionalCapital ? 260000 : 52000, 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, isRegionalCapital ? 34 : 24);
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 380, 2.2, isRegionalCapital ? 6.5 : 5.6);
city.sprawlRadius = clamp(city.urbanRadius * (isRegionalCapital ? 1.45 : city.population >= 120000 ? 1.28 : 1.15), city.urbanRadius + 2, isRegionalCapital ? 44 : 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?.isRegionalCapital ? 29 : city?.population >= 350000 ? 26 : 18;
const bias = city?.isRegionalCapital ? 1.12 : 1.0;
return estimateUrbanCapacity(city, radius, bias);
}
// --- 4. Field-derived transport corridors -------------------------------
const preliminaryUrbanInfluence = influenceFromPoints(modernCities, 18, (c) => clamp((c.population || 60000) / 260000, 0.55, 2.0));
const preliminaryTownInfluence = influenceFromPoints([...markets, ...commercialPorts], 10, (p) => p.portClass === "major" ? 1.35 : clamp((p.population || 12000) / 36000, 0.42, 1.1));
const preliminaryVillageInfluence = influenceFromPoints(villages, 7, (v) => clamp((v.population || 1800) / 5200, 0.22, 0.9));
const settlementDemand = new Float32Array(SIZE);
const urbanEdge = new Float32Array(SIZE);
const logisticsPreSuitability = new Float32Array(SIZE);
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const density = clamp(preliminaryUrbanInfluence[i] * 0.62 + preliminaryTownInfluence[i] * 0.34 + preliminaryVillageInfluence[i] * 0.16);
settlementDemand[i] = density;
urbanEdge[i] = clamp(1 - Math.abs(density - 0.46) / 0.32);
logisticsPreSuitability[i] = clamp(
agriculture[i] * 0.30 +
plain[i] * 0.24 +
basinField[i] * 0.14 +
coastalLowland[i] * 0.12 +
preliminaryTownInfluence[i] * 0.18 +
urbanEdge[i] * 0.34 -
preliminaryUrbanInfluence[i] * 0.20 -
slope[i] * 0.50 -
ridgeField[i] * 0.32
);
}
}
function buildTransportCostFields() {
const expressway = new Float32Array(SIZE);
const rail = new Float32Array(SIZE);
const national = new Float32Array(SIZE);
const local = new Float32Array(SIZE);
const expresswayPotential = new Float32Array(SIZE);
const railPotential = new Float32Array(SIZE);
const nationalPotential = new Float32Array(SIZE);
const localPotential = new Float32Array(SIZE);
function seaAdjacency(x, y, radius = 1) {
let sum = 0;
let total = 0;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (!dx && !dy) continue;
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
total++;
if (sea[indexOf(nx, ny)]) sum += 1;
}
}
return total > 0 ? sum / total : 0;
}
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i]) {
expressway[i] = rail[i] = national[i] = local[i] = INF;
continue;
}
const density = settlementDemand[i];
const mediumDensity = clamp(1 - Math.abs(density - 0.42) / 0.30);
const highDensity = clamp((density - 0.32) / 0.50);
const lowland = clamp(plain[i] * 0.48 + basinField[i] * 0.28 + valleyField[i] * 0.24 + coastalLowland[i] * 0.26 + agriculture[i] * 0.16);
const pass = passSuitability?.[i] || 0;
const crossing = crossingSuitability?.[i] || 0;
const waterCrossingPenalty = river[i] > 0.18 ? (1 - crossing) * (0.72 + river[i] * 1.35) : 0;
const seaNear = seaAdjacency(x, y, 1);
const seaBroad = seaAdjacency(x, y, 2);
const coastalTraversePenalty = clamp(seaBroad * 1.35 - coastalLowland[i] * 0.58 - (portSuitability?.[i] || 0) * 0.38);
const highMountain = clamp((elevation[i] - 0.52) * 3.6 + slope[i] * 0.95 + ridgeField[i] * 1.05 - pass * 0.55 - valleyField[i] * 0.12);
const extremeMountain = clamp((elevation[i] - 0.64) * 4.8 + slope[i] * 1.55 + ridgeField[i] * 1.45 - pass * 0.80);
const denseCorePenalty = clamp((density - 0.66) / 0.28);
const openPlainParallelPenalty = clamp(plain[i] * 0.48 + agriculture[i] * 0.26 - density * 0.20 - valleyField[i] * 0.20 - coastalLowland[i] * 0.12);
if (extremeMountain > 0.92 && pass < 0.34) {
expressway[i] = rail[i] = INF;
national[i] = elevation[i] > 0.70 ? INF : 2.6 + extremeMountain * 2.4 + waterCrossingPenalty;
local[i] = 1.8 + extremeMountain * 1.8 + waterCrossingPenalty * 0.45;
expresswayPotential[i] = 0;
railPotential[i] = 0;
nationalPotential[i] = clamp(lowland * 0.18 + pass * 0.24 - extremeMountain * 0.55);
localPotential[i] = clamp(valleySettlement[i] * 0.14 + pass * 0.18 - extremeMountain * 0.28);
continue;
}
expresswayPotential[i] = clamp(
mediumDensity * 0.62 +
urbanEdge[i] * 0.38 +
logisticsPreSuitability[i] * 0.54 +
lowland * 0.36 +
agriculture[i] * 0.16 -
denseCorePenalty * 0.54 -
slope[i] * 0.82 -
highMountain * 0.92 -
coastalTraversePenalty * 0.32 -
river[i] * 0.14
);
railPotential[i] = clamp(
highDensity * 0.80 +
preliminaryTownInfluence[i] * 0.22 +
lowland * 0.46 +
valleyField[i] * 0.22 +
coastalLowland[i] * 0.22 -
slope[i] * 1.28 -
highMountain * 1.10 -
coastalTraversePenalty * 0.26 -
ridgeField[i] * 0.34
);
nationalPotential[i] = clamp(
density * 0.46 +
preliminaryTownInfluence[i] * 0.32 +
preliminaryVillageInfluence[i] * 0.20 +
agriculture[i] * 0.24 +
valleyField[i] * 0.28 +
coastalLowland[i] * 0.26 +
pass * 0.18 +
crossing * 0.18 -
slope[i] * 0.48 -
highMountain * 0.24 -
coastalTraversePenalty * 0.16 -
ridgeField[i] * 0.18
);
localPotential[i] = clamp(
preliminaryVillageInfluence[i] * 0.52 +
agriculture[i] * 0.38 +
coastalSettlement[i] * 0.30 +
valleySettlement[i] * 0.30 +
developable[i] * 0.18 -
slope[i] * 0.34 -
coastalTraversePenalty * 0.08 -
ridgeField[i] * 0.10
);
expressway[i] = Math.max(0.18,
1.62 - expresswayPotential[i] * 0.96 +
denseCorePenalty * 1.30 +
slope[i] * 5.4 +
highMountain * 5.8 +
extremeMountain * 4.2 +
waterCrossingPenalty * 2.1 +
coastalTraversePenalty * 1.8 +
seaNear * 1.7 +
openPlainParallelPenalty * 0.12 +
hash2(x, y, seed + 13301) * 0.04
);
rail[i] = Math.max(0.16,
1.48 - railPotential[i] * 1.02 +
slope[i] * 7.2 +
highMountain * 7.0 +
extremeMountain * 4.8 +
waterCrossingPenalty * 1.7 +
coastalTraversePenalty * 1.2 +
seaNear * 1.1 +
hash2(x, y, seed + 13302) * 0.03
);
national[i] = Math.max(0.16,
1.28 - nationalPotential[i] * 0.84 +
slope[i] * 2.8 +
ridgeField[i] * 1.18 +
Math.max(0, elevation[i] - 0.62) * 3.0 +
highMountain * 2.9 +
waterCrossingPenalty * 1.25 -
valleyField[i] * 0.18 -
coastalLowland[i] * 0.08 +
coastalTraversePenalty * 0.95 -
pass * 0.42 +
hash2(x, y, seed + 13303) * 0.05
);
local[i] = Math.max(0.14,
1.12 - localPotential[i] * 0.86 +
slope[i] * 1.72 +
ridgeField[i] * 0.82 +
Math.max(0, elevation[i] - 0.68) * 1.9 +
highMountain * 1.24 +
waterCrossingPenalty * 0.65 -
valleyField[i] * 0.22 -
coastalLowland[i] * 0.10 +
coastalTraversePenalty * 0.38 +
hash2(x, y, seed + 13304) * 0.07
);
}
}
return { expressway, rail, national, local, expresswayPotential, railPotential, nationalPotential, localPotential };
}
const transportFields = buildTransportCostFields();
const cachedInfluenceFromPaths = createPathInfluenceCache(influenceFromPaths);
const componentCityInfluence = influenceFromPoints([...modernCities, ...markets], 11, (p) => clamp((p.population || 8000) / 50000, 0.18, 8.0));
const componentCapitalInfluence = influenceFromPoints(modernCities.filter((p) => p.isPrefecturalCapital), 16, () => 5.0);
const corridorSkeleton = new Uint8Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
corridorSkeleton[i] = Math.round(clamp(
valleyField[i] * 0.34 +
coastalLowland[i] * 0.24 +
plain[i] * 0.16 +
basinField[i] * 0.16 +
logisticsPreSuitability[i] * 0.18 +
(passSuitability?.[i] || 0) * 0.18 +
(crossingSuitability?.[i] || 0) * 0.10 -
ridgeField[i] * 0.22 -
slope[i] * 0.28
) * 255);
}
function chooseCorridorSeeds(potentialField, spacing, maxCount, threshold, predicate = () => true, seedOffset = 0, mode = "national") {
const candidates = [];
for (let y = 3; y < MAP_H - 3; y += 2) {
for (let x = 3; x < MAP_W - 3; x += 2) {
const i = indexOf(x, y);
if (sea[i] || !predicate(x, y, i)) continue;
const skeletonWeight = mode === "rail" ? 0.24 : mode === "expressway" ? 0.18 : 0.28;
const score = potentialField[i] + (corridorSkeleton[i] / 255) * skeletonWeight + hash2(x, y, seed + seedOffset) * 0.055;
if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) });
}
}
return pickEntities(candidates, { max: maxCount, minDistance: spacing, threshold, seed: seed + seedOffset, jitter: 0.03 });
}
function corridorAllowance(i) {
return clamp(settlementDemand[i] * 0.58 + valleyField[i] * 0.42 + coastalLowland[i] * 0.36 - plain[i] * 0.18 - agriculture[i] * 0.14);
}
function endpointSupport(i) {
if (i < 0 || i >= SIZE || sea[i]) return 0;
return clamp(
settlementDemand[i] * 0.66 +
preliminaryTownInfluence[i] * 0.36 +
preliminaryVillageInfluence[i] * 0.24 +
valleySettlement[i] * 0.22 +
coastalSettlement[i] * 0.18 +
agriculture[i] * 0.10 +
(passSuitability?.[i] || 0) * 0.24 +
(crossingSuitability?.[i] || 0) * 0.16 -
slope[i] * 0.22 -
ridgeField[i] * 0.14
);
}
function nearMapEdgePoint(x, y, margin = 4) {
return x <= margin || y <= margin || x >= MAP_W - 1 - margin || y >= MAP_H - 1 - margin;
}
function naturalEndpoint(x, y, i, mode = "national") {
if (nearMapEdgePoint(x, y, 4)) return true;
const support = endpointSupport(i);
if (mode === "expressway") return support > 0.24 && settlementDemand[i] > 0.06;
if (mode === "rail") return support > 0.18 && settlementDemand[i] > 0.07;
if (mode === "local") return support > 0.12 || valleySettlement[i] > 0.14 || coastalSettlement[i] > 0.14;
return support > 0.14 || transportFields.nationalPotential[i] > 0.37;
}
function traceCorridorByCost(start, goalRegionPredicate, costField, penaltyField, options = {}) {
if (!start || !inside(start.x, start.y)) return [];
const startIndex = indexOf(start.x, start.y);
if (sea[startIndex] || costField[startIndex] >= INF) return [];
const score = new Float32Array(SIZE);
const cameFrom = new Int32Array(SIZE);
const closed = new Uint8Array(SIZE);
score.fill(INF);
cameFrom.fill(-1);
const heap = new MinHeap();
score[startIndex] = 0;
heap.push({ i: startIndex, f: 0 });
const curvePenalty = options.curvePenalty ?? 0.12;
const penaltyStrength = options.penaltyStrength ?? 1.0;
const sameRegion = options.regionId ?? regionIdAt(start.x, start.y);
const minGoalDistance = options.minGoalDistance ?? 18;
const maxExpanded = options.maxExpanded ?? SIZE * 2;
const bounds = options.bounds || null;
const goalHint = options.goalHint || null;
const heuristicWeight = options.heuristicWeight ?? 0;
let goalIndex = -1;
let expanded = 0;
while (heap.length && expanded++ < maxExpanded) {
const current = heap.pop();
if (!current || closed[current.i]) continue;
closed[current.i] = 1;
const [cx, cy] = xyOf(current.i);
if (current.i !== startIndex && Math.hypot(cx - start.x, cy - start.y) >= minGoalDistance && goalRegionPredicate(cx, cy, current.i)) {
goalIndex = current.i;
break;
}
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = cx + dx;
const ny = cy + dy;
if (!inside(nx, ny)) continue;
if (bounds && (nx < bounds.minX || nx > bounds.maxX || ny < bounds.minY || ny > bounds.maxY)) continue;
const ni = indexOf(nx, ny);
if (closed[ni] || sea[ni] || costField[ni] >= INF) continue;
if (sameRegion >= 0 && options.keepRegion !== false && regionIdAt(nx, ny) !== sameRegion) continue;
const prev = cameFrom[current.i];
let turn = 0;
if (prev >= 0) {
const [px, py] = xyOf(prev);
const ax = cx - px;
const ay = cy - py;
turn = Math.abs(ax * dy - ay * dx) > 0 ? curvePenalty : 0;
}
const existing = penaltyField?.[ni] || 0;
const antiConcentration = existing * penaltyStrength * (1 - corridorAllowance(ni) * 0.72);
const terrainFlowBias = (options.terrainFlowBias ?? 0) * clamp(
valleyField[ni] * 0.54 +
coastalLowland[ni] * 0.28 +
plain[ni] * 0.16 +
(passSuitability?.[ni] || 0) * 0.34 -
ridgeField[ni] * 0.24 -
slope[ni] * 0.22
);
const surfaceGrain = (options.surfaceGrain ?? 0) * valueNoise(nx, ny, seed + 13941, 18);
const nd = score[current.i] + Math.max(0.08, costField[ni] + antiConcentration + turn - terrainFlowBias + surfaceGrain) * Math.hypot(dx, dy);
if (nd < score[ni]) {
score[ni] = nd;
cameFrom[ni] = current.i;
const h = goalHint ? Math.hypot(nx - goalHint.x, ny - goalHint.y) * heuristicWeight : 0;
heap.push({ i: ni, f: nd + h });
}
}
}
}
if (goalIndex < 0) return [];
const path = [];
for (let p = goalIndex; p >= 0; p = cameFrom[p]) {
path.push(xyOf(p));
if (p === startIndex) break;
}
return path.reverse();
}
function addCorridorInfluencePenalty(penaltyField, corridor, radius = 7, strength = 0.35) {
for (const [x, y] of corridor || []) {
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
const i = indexOf(nx, ny);
if (sea[i]) continue;
const openPlain = clamp(plain[i] * 0.54 + agriculture[i] * 0.34 - settlementDemand[i] * 0.24 - valleyField[i] * 0.22 - coastalLowland[i] * 0.18);
const allowParallel = corridorAllowance(i);
penaltyField[i] = Math.max(penaltyField[i], strength * (1 - d / radius) * (0.48 + openPlain * 1.15 - allowParallel * 0.42));
}
}
}
}
function relaxRouteToTerrain(path, costField, options = {}) {
if (!path || path.length < 5) return path || [];
const radius = options.radius ?? 2;
const iterations = options.iterations ?? 1;
const lineWeight = options.lineWeight ?? 0.42;
const costWeight = options.costWeight ?? 1.0;
const grain = options.grain ?? 0.05;
let out = path.map((p) => [p[0], p[1]]);
for (let iter = 0; iter < iterations; iter++) {
const src = out.map((p) => [p[0], p[1]]);
for (let k = 1; k < src.length - 1; k++) {
const prev = out[k - 1];
const cur = src[k];
const next = src[k + 1];
let best = cur;
let bestScore = INF;
const vx = next[0] - prev[0];
const vy = next[1] - prev[1];
const vLen2 = Math.max(1e-6, vx * vx + vy * vy);
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (dx * dx + dy * dy > radius * radius) continue;
const x = cur[0] + dx;
const y = cur[1] + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i] || costField[i] >= INF) continue;
const t = clamp(((x - prev[0]) * vx + (y - prev[1]) * vy) / vLen2);
const projX = prev[0] + vx * t;
const projY = prev[1] + vy * t;
const lineDist = Math.hypot(x - projX, y - projY);
const neighborDist = Math.abs(Math.hypot(x - prev[0], y - prev[1]) - Math.hypot(cur[0] - prev[0], cur[1] - prev[1])) * 0.08;
const terrainBonus = valleyField[i] * 0.34 + coastalLowland[i] * 0.16 + plain[i] * 0.08 + (passSuitability?.[i] || 0) * 0.18;
const terrainPenalty = slope[i] * 0.42 + ridgeField[i] * 0.32 + Math.max(0, elevation[i] - 0.62) * 0.45;
const localGrain = grain * valueNoise(x, y, seed + 15123 + k * 17, 14);
const score = costField[i] * costWeight + lineDist * lineWeight + neighborDist + terrainPenalty - terrainBonus + localGrain;
if (score < bestScore) {
bestScore = score;
best = [x, y];
}
}
}
out[k] = best;
}
}
const deduped = [];
let last = "";
for (const p of out) {
const key = `${p[0]},${p[1]}`;
if (key !== last) {
deduped.push(p);
last = key;
}
}
return deduped.length >= 2 ? deduped : path;
}
function endpointFromPath(path) {
const p = path?.[path.length - 1];
return p ? { x: p[0], y: p[1], regionId: regionIdAt(p[0], p[1]) } : null;
}
function qualityLimitsForMode(mode, overrides = {}) {
const base = mode === "rail"
? { maxCompactness: 2.45, maxSteepShare: 0.18, maxHighElevationShare: 0, minAvgPotential: 0.12 }
: mode === "expressway"
? { maxCompactness: 2.75, maxSteepShare: 0.24, maxHighElevationShare: 0, minAvgPotential: 0.10 }
: mode === "local"
? { maxCompactness: 3.6, maxSteepShare: 0.46, maxHighElevationShare: 0.18, minAvgPotential: 0.02 }
: { maxCompactness: 3.3, maxSteepShare: 0.38, maxHighElevationShare: 0.04, minAvgPotential: 0.04 };
return { ...base, ...overrides };
}
function transportRouteAcceptable(path, mode, potentialField, penaltyField = null, overrides = {}) {
return routeQualityAcceptable(path, {
sea,
elevation,
slope,
potential: potentialField,
penalty: penaltyField,
highElevationThreshold: mode === "local" ? 0.78 : 0.72,
steepThreshold: mode === "rail" ? 0.34 : mode === "expressway" ? 0.40 : 0.48,
}, qualityLimitsForMode(mode, overrides));
}
function generateCorridorsFromField({ mode = "national", potentialField, costField, spacing, maxCount, threshold, minLength, penaltyRadius, penaltyStrength, curvePenalty, terrainFlowBias = 0, surfaceGrain = 0, relaxRadius = 2, relaxLineWeight = 0.40, seedOffset, startPredicate, goalPredicate }) {
const paths = [];
const penaltyField = new Float32Array(SIZE);
const seeds = chooseCorridorSeeds(potentialField, spacing, maxCount * 2, threshold, startPredicate, seedOffset, mode);
const usedEndpoints = [];
for (const start of seeds) {
if (paths.length >= maxCount) break;
if (distanceToNearest(usedEndpoints, start.x, start.y) < spacing * 0.55) continue;
let path = traceCorridorByCost(
start,
(x, y, i) => goalPredicate(start, x, y, i, usedEndpoints),
costField,
penaltyField,
{ curvePenalty, penaltyStrength: penaltyStrength * 2.2, minGoalDistance: minLength, regionId: start.regionId, terrainFlowBias, surfaceGrain }
);
if (path.length < minLength) continue;
const rawPath = path;
path = relaxRouteToTerrain(rawPath, costField, { radius: relaxRadius, lineWeight: relaxLineWeight, grain: surfaceGrain, iterations: 1 });
if (path.length < Math.max(2, rawPath.length * 0.55)) path = rawPath;
if (!transportRouteAcceptable(path, mode, potentialField, penaltyField, { minLength, maxLength: mode === "expressway" ? 130 : mode === "rail" ? 112 : 150 })) continue;
paths.push(path);
usedEndpoints.push(start);
const end = endpointFromPath(path);
if (end) usedEndpoints.push(end);
addCorridorInfluencePenalty(penaltyField, path, penaltyRadius, penaltyStrength);
}
return paths;
}
function rasterizeNetworkComponents(paths, mode, potentialField) {
const occupied = new Uint8Array(SIZE);
for (const path of paths) {
for (const [x, y] of path || []) {
if (inside(x, y) && !sea[indexOf(x, y)]) occupied[indexOf(x, y)] = 1;
}
}
const componentId = new Int32Array(SIZE);
componentId.fill(-1);
const components = [];
for (let i = 0; i < SIZE; i++) {
if (!occupied[i] || componentId[i] >= 0) continue;
const id = components.length;
const queue = [i];
const cells = [];
const boundary = [];
componentId[i] = id;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
cells.push(cur);
const x = cur % MAP_W;
const y = Math.floor(cur / MAP_W);
let edge = false;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) { edge = true; continue; }
const ni = indexOf(nx, ny);
if (!occupied[ni]) {
edge = true;
continue;
}
if (componentId[ni] < 0) {
componentId[ni] = id;
queue.push(ni);
}
}
}
if (edge) boundary.push({ x, y, i: cur });
}
let lengthScore = 0;
let densityScore = 0;
let townScore = 0;
let logisticsScore = 0;
let capitalScore = 0;
let sx = 0;
let sy = 0;
const sampleStride = Math.max(1, Math.floor(cells.length / 80));
for (let c = 0; c < cells.length; c += sampleStride) {
const ci = cells[c];
const x = ci % MAP_W;
const y = Math.floor(ci / MAP_W);
lengthScore += sampleStride;
sx += x * sampleStride;
sy += y * sampleStride;
densityScore += settlementDemand[ci] * sampleStride;
logisticsScore += logisticsPreSuitability[ci] * sampleStride;
townScore += componentCityInfluence[ci] * sampleStride;
capitalScore += componentCapitalInfluence[ci] * sampleStride;
}
const importance =
Math.sqrt(lengthScore) * 1.20 +
densityScore * 0.38 +
townScore * 0.55 +
logisticsScore * 0.24 +
capitalScore;
components.push({
id,
mode,
cells,
boundary,
cx: sx / Math.max(1, lengthScore),
cy: sy / Math.max(1, lengthScore),
importance,
length: cells.length,
repairCount: 0,
potential: cells.reduce((sum, ci) => sum + (potentialField?.[ci] || 0), 0) / Math.max(1, cells.length),
});
}
return { occupied, componentId, components };
}
function componentAnchor(component, target, costField, usedAnchors = []) {
if (!component?.boundary?.length) return null;
let best = null;
let bestScore = INF;
const stride = Math.max(1, Math.floor(component.boundary.length / 90));
for (let k = 0; k < component.boundary.length; k += stride) {
const p = component.boundary[k];
if (costField[p.i] >= INF) continue;
if (distanceToNearest(usedAnchors, p.x, p.y) < 8) continue;
const d = target ? Math.hypot(p.x - target.x, p.y - target.y) : 0;
const score = d + costField[p.i] * 3.5 - corridorAllowance(p.i) * 2.4 + hash2(p.x, p.y, seed + 13701) * 1.8;
if (score < bestScore) {
bestScore = score;
best = { x: p.x, y: p.y, componentId: component.id, regionId: regionIdAt(p.x, p.y) };
}
}
return best;
}
function componentBounds(aComp, bComp, pad = 18) {
let minX = MAP_W - 1;
let minY = MAP_H - 1;
let maxX = 0;
let maxY = 0;
for (const comp of [aComp, bComp]) {
for (const p of comp.boundary || []) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
}
}
return {
minX: Math.max(0, minX - pad),
minY: Math.max(0, minY - pad),
maxX: Math.min(MAP_W - 1, maxX + pad),
maxY: Math.min(MAP_H - 1, maxY + pad),
};
}
function traceCoarseRepair(start, targetComp, raster, costField, penaltyField, options = {}) {
const scale = options.coarseScale ?? 3;
if (scale <= 1) return null;
const cw = Math.ceil(MAP_W / scale);
const ch = Math.ceil(MAP_H / scale);
const cSize = cw * ch;
const bounds = options.bounds || { minX: 0, minY: 0, maxX: MAP_W - 1, maxY: MAP_H - 1 };
const cbounds = {
minX: Math.max(0, Math.floor(bounds.minX / scale) - 1),
minY: Math.max(0, Math.floor(bounds.minY / scale) - 1),
maxX: Math.min(cw - 1, Math.ceil(bounds.maxX / scale) + 1),
maxY: Math.min(ch - 1, Math.ceil(bounds.maxY / scale) + 1),
};
const cIndex = (x, y) => y * cw + x;
const cCost = new Float32Array(cSize);
const cTarget = new Uint8Array(cSize);
cCost.fill(INF);
for (let cy = cbounds.minY; cy <= cbounds.maxY; cy++) {
for (let cx = cbounds.minX; cx <= cbounds.maxX; cx++) {
let best = INF;
let target = 0;
for (let dy = 0; dy < scale; dy++) {
for (let dx = 0; dx < scale; dx++) {
const x = cx * scale + dx;
const y = cy * scale + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i] || costField[i] >= INF) continue;
best = Math.min(best, costField[i] + (penaltyField?.[i] || 0) * (options.penaltyStrength ?? 1));
if (raster.componentId[i] === targetComp.id) target = 1;
}
}
const ci = cIndex(cx, cy);
cCost[ci] = best;
cTarget[ci] = target;
}
}
const sx = Math.floor(start.x / scale);
const sy = Math.floor(start.y / scale);
if (sx < cbounds.minX || sx > cbounds.maxX || sy < cbounds.minY || sy > cbounds.maxY) return null;
const startCi = cIndex(sx, sy);
if (cCost[startCi] >= INF) return null;
const score = new Float32Array(cSize);
const cameFrom = new Int32Array(cSize);
const closed = new Uint8Array(cSize);
score.fill(INF);
cameFrom.fill(-1);
const heap = new MinHeap();
score[startCi] = 0;
heap.push({ i: startCi, f: 0 });
let goal = -1;
let guard = 0;
while (heap.length && guard++ < cSize * 2) {
const cur = heap.pop();
if (!cur || closed[cur.i]) continue;
closed[cur.i] = 1;
if (cur.i !== startCi && cTarget[cur.i]) {
goal = cur.i;
break;
}
const cx = cur.i % cw;
const cy = Math.floor(cur.i / cw);
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 (nx < cbounds.minX || nx > cbounds.maxX || ny < cbounds.minY || ny > cbounds.maxY) continue;
const ni = cIndex(nx, ny);
if (closed[ni] || cCost[ni] >= INF) continue;
const nd = score[cur.i] + cCost[ni] * Math.hypot(dx, dy);
if (nd < score[ni]) {
score[ni] = nd;
cameFrom[ni] = cur.i;
const h = Math.hypot(nx - targetComp.cx / scale, ny - targetComp.cy / scale) * (options.heuristicWeight ?? 0.18);
heap.push({ i: ni, f: nd + h });
}
}
}
}
if (goal < 0) return null;
const coarse = [];
for (let p = goal; p >= 0; p = cameFrom[p]) {
const cx = p % cw;
const cy = Math.floor(p / cw);
coarse.push([Math.min(MAP_W - 1, Math.round(cx * scale + scale * 0.5)), Math.min(MAP_H - 1, Math.round(cy * scale + scale * 0.5))]);
if (p === startCi) break;
}
coarse.reverse();
if (coarse.length < 2) return null;
const full = [[start.x, start.y]];
for (let k = 1; k < coarse.length; k++) {
const a = full[full.length - 1];
const b = coarse[k];
const steps = Math.max(1, Math.ceil(Math.hypot(a[0] - b[0], a[1] - b[1])));
for (let s = 1; s <= steps; s++) {
const t = s / steps;
const x = Math.round(a[0] + (b[0] - a[0]) * t);
const y = Math.round(a[1] + (b[1] - a[1]) * t);
if (inside(x, y) && !sea[indexOf(x, y)] && costField[indexOf(x, y)] < INF) full.push([x, y]);
}
}
const tail = full[full.length - 1];
let nearest = null;
let nearestD = INF;
const stride = Math.max(1, Math.floor((targetComp.boundary?.length || 1) / 80));
for (let k = 0; k < (targetComp.boundary?.length || 0); k += stride) {
const p = targetComp.boundary[k];
const d = Math.hypot(p.x - tail[0], p.y - tail[1]);
if (d < nearestD) {
nearestD = d;
nearest = p;
}
}
if (nearest && nearestD <= scale * 3 + 4) {
const a = full[full.length - 1];
const steps = Math.max(1, Math.ceil(nearestD));
for (let s = 1; s <= steps; s++) {
const t = s / steps;
const x = Math.round(a[0] + (nearest.x - a[0]) * t);
const y = Math.round(a[1] + (nearest.y - a[1]) * t);
if (inside(x, y) && !sea[indexOf(x, y)] && costField[indexOf(x, y)] < INF) full.push([x, y]);
}
}
return full;
}
function repairTransportConnectivity(paths, mode, costField, potentialField, options = {}) {
const debug = { mode, components: [], repairs: [] };
const raster = rasterizeNetworkComponents(paths, mode, potentialField);
debug.components = raster.components.map((c) => ({
id: c.id,
mode,
importance: Math.round(c.importance * 10) / 10,
length: c.length,
potential: Math.round(c.potential * 100) / 100,
cx: Math.round(c.cx),
cy: Math.round(c.cy),
cells: c.cells.filter((_, k) => k % Math.max(1, Math.floor(c.cells.length / 40)) === 0).slice(0, 40).map((i) => xyOf(i)),
}));
const important = raster.components
.filter((c) => c.length >= (options.minComponentCells ?? 18) && c.importance >= (options.minImportance ?? 8))
.sort((a, b) => b.importance - a.importance)
.slice(0, options.maxComponents ?? 8);
if (important.length < 2) return debug;
const networkPenalty = cachedInfluenceFromPaths(paths, options.penaltyRadius ?? 8, `${mode}:repair`);
const usedAnchors = [];
const maxRepairs = options.maxRepairs ?? 4;
for (let r = 0; r < maxRepairs; r++) {
let bestPair = null;
let bestScore = INF;
for (let a = 0; a < important.length; a++) {
for (let b = a + 1; b < important.length; b++) {
const ca = important[a];
const cb = important[b];
if (ca.repairCount >= 2 || cb.repairCount >= 2) continue;
const d = Math.hypot(ca.cx - cb.cx, ca.cy - cb.cy);
if (d < (options.minRepairDistance ?? 14) || d > (options.maxRepairDistance ?? 120)) continue;
const score = d / Math.sqrt(ca.importance + cb.importance) + (ca.repairCount + cb.repairCount) * 18;
if (score < bestScore) {
bestScore = score;
bestPair = [ca, cb];
}
}
}
if (!bestPair) break;
const [aComp, bComp] = bestPair;
const roughTarget = bComp.boundary[Math.floor(bComp.boundary.length / 2)];
const start = componentAnchor(aComp, roughTarget, costField, usedAnchors);
const goalTarget = start ? componentAnchor(bComp, start, costField, usedAnchors) : null;
if (!start || !goalTarget) break;
const bounds = componentBounds(aComp, bComp, options.searchPad ?? 20);
let path = traceCoarseRepair(start, bComp, raster, costField, networkPenalty, {
...options,
bounds,
heuristicWeight: 0.22,
});
if (!path) {
path = traceCorridorByCost(
start,
(x, y, i) => raster.componentId[i] === bComp.id || (potentialField[i] > (options.highPotentialThreshold ?? 0.42) && Math.hypot(x - goalTarget.x, y - goalTarget.y) < 5),
costField,
networkPenalty,
{
curvePenalty: options.curvePenalty ?? 0.14,
penaltyStrength: options.penaltyStrength ?? 1.8,
terrainFlowBias: options.terrainFlowBias ?? 0.12,
surfaceGrain: options.surfaceGrain ?? 0.012,
minGoalDistance: Math.min(12, Math.max(5, Math.hypot(start.x - goalTarget.x, start.y - goalTarget.y) * 0.35)),
keepRegion: false,
maxExpanded: Math.floor(SIZE * 0.45),
bounds,
goalHint: goalTarget,
heuristicWeight: 0.18,
}
);
}
if (path.length < (options.minAddedLength ?? 6) || path.length > (options.maxAddedLength ?? 120)) {
aComp.repairCount++;
continue;
}
const rawRepairPath = path;
path = relaxRouteToTerrain(rawRepairPath, costField, { radius: options.relaxRadius ?? 2, lineWeight: options.relaxLineWeight ?? 0.36, grain: options.surfaceGrain ?? 0.012, iterations: 1 });
if (path.length < Math.max(2, rawRepairPath.length * 0.55)) path = rawRepairPath;
if (path.length < (options.minAddedLength ?? 6) || path.length > (options.maxAddedLength ?? 120)) {
aComp.repairCount++;
continue;
}
if (!transportRouteAcceptable(path, mode, potentialField, networkPenalty, { minLength: options.minAddedLength ?? 6, maxLength: options.maxAddedLength ?? 120 })) {
aComp.repairCount++;
continue;
}
paths.push(path);
debug.repairs.push({ mode, path, from: aComp.id, to: bComp.id });
usedAnchors.push(start, goalTarget);
aComp.repairCount++;
bComp.repairCount++;
addCorridorInfluencePenalty(networkPenalty, path, options.penaltyRadius ?? 8, options.addedPenalty ?? 0.38);
}
return debug;
}
function routeLight(a, b, snapRadius = 3, costField = transportFields.local) {
if (!a || !b) return [];
const start = {
x: Math.round(a.x),
y: Math.round(a.y),
regionId: regionIdAt(Math.round(a.x), Math.round(a.y)),
};
const target = { x: Math.round(b.x), y: Math.round(b.y) };
if (!inside(start.x, start.y) || !inside(target.x, target.y)) return [];
const dist = Math.hypot(start.x - target.x, start.y - target.y);
const routed = traceCorridorByCost(
start,
(x, y) => Math.hypot(x - target.x, y - target.y) <= snapRadius,
costField,
null,
{
curvePenalty: 0.025,
penaltyStrength: 0,
minGoalDistance: Math.min(5, Math.max(2, dist * 0.10)),
keepRegion: false,
maxExpanded: Math.min(SIZE, Math.max(1800, Math.floor(dist * dist * 5.5))),
terrainFlowBias: 0.16,
surfaceGrain: 0.025,
}
);
if (routed.length >= 2) return relaxRouteToTerrain(routed, costField, { radius: 2, lineWeight: 0.34, grain: 0.035, iterations: 1 });
// Fallback for rare isolated cells: still use a snapped line, but keep the
// radius small and terrain-weighted so it does not become a long artificial
// chord across mountains.
const steps = Math.max(2, Math.ceil(dist * 1.35));
const out = [];
let lastKey = "";
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const fx = start.x + (target.x - start.x) * t;
const fy = start.y + (target.y - start.y) * t;
let best = null;
let bestCost = INF;
const radius = Math.max(1, Math.min(snapRadius, 2));
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] || costField[i] >= INF) continue;
const lineDist = Math.hypot(x - fx, y - fy);
const cost = lineDist * 0.92 + costField[i] * 1.10 - valleyField[i] * 0.22 - coastalLowland[i] * 0.10 + ridgeField[i] * 0.22 + slope[i] * 0.18;
if (cost < bestCost) {
bestCost = cost;
best = [x, y];
}
}
}
if (!best) continue;
const key = `${best[0]},${best[1]}`;
if (key !== lastKey) {
out.push(best);
lastKey = key;
}
}
return relaxRouteToTerrain(out, costField, { radius: 2, lineWeight: 0.36, grain: 0.030, iterations: 1 });
}
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, (regionStats.get(regionId)?.area || 0) > 2200 ? 16 : 10);
}
function dedupePointCandidates(points, minDistance = 5) {
const out = [];
for (const raw of points) {
if (!raw) continue;
const x = Math.round(raw.x);
const y = Math.round(raw.y);
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i]) continue;
if (out.some((p) => Math.hypot(p.x - x, p.y - y) < minDistance)) continue;
out.push({ ...raw, x, y, regionId: regionIdAt(x, y), score: raw.score ?? raw.nodeWeight ?? 0.5 });
}
return out;
}
function modeSettlementAnchors(mode, potentialField, max = 48) {
const anchors = [];
const add = (p, baseScore, role) => {
if (!p || !inside(p.x, p.y)) return;
const x = Math.round(p.x);
const y = Math.round(p.y);
const i = indexOf(x, y);
if (sea[i] || regionIdAt(x, y) < 0) return;
anchors.push({
x, y, role, regionId: regionIdAt(x, y),
score: baseScore + (potentialField?.[i] || 0) * 1.4 + settlementDemand[i] * 0.75 + valleyField[i] * 0.18 + coastalLowland[i] * 0.12,
});
};
for (const c of modernCities) add(c, mode === "expressway" ? 4.2 : mode === "rail" ? 4.0 : 3.5, "city");
for (const pnt of commercialPorts) add(pnt, mode === "rail" ? 3.4 : mode === "expressway" ? 3.2 : 2.9, "port");
if (mode !== "expressway") {
for (const m of markets) add(m, mode === "rail" ? 2.1 : 2.4, "market");
for (const pss of passes) add(pss, mode === "rail" ? 0.4 : 1.5, "pass");
}
if (mode === "national" || mode === "local") {
for (const v of villages) add(v, mode === "local" ? 1.7 : 0.9, "village");
}
return dedupePointCandidates(anchors.sort((a, b) => b.score - a.score), 4.5).slice(0, max);
}
function preferenceCellAnchors(mode, potentialField, max = 36, minDistance = 9) {
const step = mode === "expressway" ? 5 : mode === "rail" ? 4 : 4;
const candidates = [];
for (let y = 2; y < MAP_H - 2; y += step) {
for (let x = 2; x < MAP_W - 2; x += step) {
const i = indexOf(x, y);
if (sea[i] || regionIdAt(x, y) < 0) continue;
const pass = passSuitability?.[i] || 0;
const base = potentialField[i] || 0;
let score = base * 2.2 + settlementDemand[i] * (mode === "expressway" ? 0.35 : 0.65) + valleyField[i] * 0.42 + coastalLowland[i] * 0.20 + plain[i] * 0.12 + pass * (mode === "rail" ? 0.08 : 0.22);
if (mode === "expressway") score += logisticsPreSuitability[i] * 0.65 - settlementDemand[i] * 0.10;
if (mode === "rail") score += preliminaryTownInfluence[i] * 0.42 - slope[i] * 0.55;
if (mode === "national") score += preliminaryVillageInfluence[i] * 0.24 + crossingSuitability[i] * 0.20;
score -= ridgeField[i] * (mode === "expressway" ? 0.50 : mode === "rail" ? 0.65 : 0.30);
score -= Math.max(0, elevation[i] - 0.62) * (mode === "expressway" ? 1.4 : mode === "rail" ? 1.7 : 0.85);
score += hash2(x, y, seed + 17100 + (mode === "rail" ? 17 : mode === "expressway" ? 31 : 0)) * 0.055;
if (score > (mode === "expressway" ? 0.64 : mode === "rail" ? 0.58 : 0.52)) candidates.push({ x, y, score, regionId: regionIdAt(x, y), role: "preference-cell" });
}
}
return pickEntities(candidates, { max, minDistance, seed: seed + 17200 + (mode === "rail" ? 23 : mode === "expressway" ? 41 : 0) });
}
function transportCandidatePoints(mode, potentialField, options = {}) {
const maxCells = options.maxCells ?? (mode === "expressway" ? 18 : mode === "rail" ? 26 : 40);
const maxSettlements = options.maxSettlements ?? (mode === "expressway" ? 20 : mode === "rail" ? 34 : 56);
const minDistance = options.minDistance ?? (mode === "expressway" ? 13 : mode === "rail" ? 10 : 8);
const cells = preferenceCellAnchors(mode, potentialField, maxCells, minDistance);
const settlements = modeSettlementAnchors(mode, potentialField, maxSettlements);
return dedupePointCandidates([...settlements, ...cells].sort((a, b) => b.score - a.score), Math.max(4, minDistance * 0.55))
.slice(0, options.maxTotal ?? (mode === "expressway" ? 32 : mode === "rail" ? 48 : 74));
}
function routeBetweenTrafficCandidates(a, b, mode, costField, penaltyField, options = {}) {
if (!a || !b) return [];
const start = { x: Math.round(a.x), y: Math.round(a.y), regionId: regionIdAt(Math.round(a.x), Math.round(a.y)) };
const target = { x: Math.round(b.x), y: Math.round(b.y) };
if (!inside(start.x, start.y) || !inside(target.x, target.y)) return [];
if (sea[indexOf(start.x, start.y)] || sea[indexOf(target.x, target.y)]) return [];
const d = Math.hypot(start.x - target.x, start.y - target.y);
const snap = options.snapRadius ?? (mode === "expressway" ? 4 : mode === "rail" ? 3 : 3);
const path = traceCorridorByCost(
start,
(x, y) => Math.hypot(x - target.x, y - target.y) <= snap,
costField,
penaltyField || null,
{
curvePenalty: options.curvePenalty ?? (mode === "expressway" ? 0.10 : mode === "rail" ? 0.13 : 0.055),
penaltyStrength: options.penaltyStrength ?? (mode === "expressway" ? 1.4 : mode === "rail" ? 1.35 : 0.86),
minGoalDistance: Math.min(8, Math.max(3, d * 0.10)),
keepRegion: false,
maxExpanded: Math.min(SIZE, Math.max(2200, Math.floor(d * d * (mode === "expressway" ? 4.6 : 5.8)))),
terrainFlowBias: options.terrainFlowBias ?? (mode === "expressway" ? 0.12 : mode === "rail" ? 0.12 : 0.24),
surfaceGrain: options.surfaceGrain ?? (mode === "national" ? 0.035 : 0.012),
}
);
if (path.length < 4) return [];
const relaxed = relaxRouteToTerrain(path, costField, {
radius: options.relaxRadius ?? (mode === "national" ? 2 : 1),
lineWeight: options.relaxLineWeight ?? (mode === "national" ? 0.34 : 0.52),
grain: options.surfaceGrain ?? 0.020,
iterations: 1,
});
return relaxed.length >= Math.max(3, path.length * 0.55) ? relaxed : path;
}
function addCandidateKnnNetwork(paths, mode, costField, potentialField, options = {}) {
const debug = { mode, candidates: 0, added: [], skipped: 0 };
const candidates = transportCandidatePoints(mode, potentialField, options);
debug.candidates = candidates.length;
if (candidates.length < 2) return debug;
const penalty = cachedInfluenceFromPaths(paths, options.parallelRadius ?? (mode === "expressway" ? 10 : mode === "rail" ? 7 : 6), `${mode}:knn`);
const degree = new Map();
const edgeUsed = new Set();
const ordered = candidates.slice().sort((a, b) => b.score - a.score);
const maxAdded = options.maxAdded ?? (mode === "expressway" ? 4 : mode === "rail" ? 8 : 18);
const k = options.k ?? (mode === "expressway" ? 3 : mode === "rail" ? 4 : 5);
const maxDegree = options.maxDegree ?? (mode === "expressway" ? 2 : 3);
for (const a of ordered) {
if (debug.added.length >= maxAdded) break;
const aid = `${a.x},${a.y}`;
if ((degree.get(aid) || 0) >= maxDegree) continue;
const near = candidates
.filter((b) => b !== a && (!options.sameRegionOnly || b.regionId === a.regionId))
.map((b) => {
const d = Math.hypot(a.x - b.x, a.y - b.y);
const bearingJitter = hash2(a.x + b.x, a.y + b.y, seed + 17333) * 0.04;
const desire = Math.sqrt(Math.max(0.2, a.score) * Math.max(0.2, b.score));
return { b, d, sortScore: d / desire + bearingJitter };
})
.filter((e) => e.d >= (options.minDistance ?? (mode === "expressway" ? 26 : mode === "rail" ? 18 : 12)) && e.d <= (options.maxDistance ?? (mode === "expressway" ? 112 : mode === "rail" ? 92 : 78)))
.sort((x, y) => x.sortScore - y.sortScore)
.slice(0, k);
for (const { b, d } of near) {
if (debug.added.length >= maxAdded) break;
const bid = `${b.x},${b.y}`;
if ((degree.get(bid) || 0) >= maxDegree) continue;
const key = aid < bid ? `${aid}|${bid}` : `${bid}|${aid}`;
if (edgeUsed.has(key)) continue;
edgeUsed.add(key);
const path = routeBetweenTrafficCandidates(a, b, mode, costField, penalty, options);
const len = pathLengthCells(path);
if (len < (options.minPathLength ?? Math.max(8, d * 0.45)) || len > (options.maxPathLength ?? d * 2.75 + 34)) { debug.skipped++; continue; }
const avgPenalty = pathAverageField(path, penalty);
const avgPotential = pathAverageField(path, potentialField);
if (avgPenalty > (options.maxParallelInfluence ?? (mode === "expressway" ? 0.39 : mode === "rail" ? 0.42 : 0.47)) && avgPotential < (options.minPotentialIfParallel ?? 0.38)) { debug.skipped++; continue; }
if (!transportRouteAcceptable(path, mode, potentialField, penalty, { minLength: options.minPathLength ?? 6, maxLength: options.maxPathLength ?? d * 2.75 + 34, maxAvgPenalty: options.maxParallelInfluence ?? Infinity })) { debug.skipped++; continue; }
paths.push(path);
debug.added.push({ mode: `${mode}-knn`, path, from: a.role || "candidate", to: b.role || "candidate" });
degree.set(aid, (degree.get(aid) || 0) + 1);
degree.set(bid, (degree.get(bid) || 0) + 1);
addCorridorInfluencePenalty(penalty, path, options.parallelRadius ?? 6, options.addedPenalty ?? 0.30);
if ((degree.get(aid) || 0) >= maxDegree) break;
}
}
return debug;
}
function pruneParallelSameMode(paths, mode, potentialField, options = {}) {
if (!paths?.length) return { mode, pruned: 0, kept: 0 };
const scored = paths.map((path, originalIndex) => {
const len = pathLengthCells(path);
return { path, originalIndex, len, score: pathAverageField(path, potentialField) * 12 + Math.log1p(len) + (len > 80 ? 0.30 : 0) };
}).sort((a, b) => b.score - a.score);
const accepted = new Uint8Array(SIZE);
const kept = [];
let pruned = 0;
const radius = options.radius ?? (mode === "expressway" ? 3 : mode === "rail" ? 2 : 2);
const threshold = options.threshold ?? (mode === "expressway" ? 0.54 : mode === "rail" ? 0.58 : 0.62);
function mark(path) {
for (const [px, py] of path) {
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (dx * dx + dy * dy > radius * radius) continue;
const x = px + dx, y = py + dy;
if (inside(x, y)) accepted[indexOf(x, y)] = 1;
}
}
}
}
function overlap(path) {
let hit = 0, n = 0;
for (const [x, y] of path) {
if (!inside(x, y)) continue;
n++;
if (accepted[indexOf(x, y)]) hit++;
}
return n ? hit / n : 0;
}
for (const item of scored) {
const ov = overlap(item.path);
const shortStub = item.len < (options.shortLength ?? (mode === "expressway" ? 40 : mode === "rail" ? 30 : 22));
if (kept.length >= (options.minKeep ?? 2) && ov > threshold && (shortStub || ov > threshold + 0.13)) {
pruned++;
continue;
}
kept.push(item);
mark(item.path);
}
kept.sort((a, b) => a.originalIndex - b.originalIndex);
paths.length = 0;
paths.push(...kept.map((item) => item.path));
return { mode, pruned, kept: kept.length };
}
function endpointList(paths) {
const out = [];
paths.forEach((path, pathIndex) => {
if (!path || path.length < 2) return;
const a = path[0];
const b = path[path.length - 1];
out.push({ x: a[0], y: a[1], pathIndex, atStart: true });
out.push({ x: b[0], y: b[1], pathIndex, atStart: false });
});
return out;
}
function validTransportEndpoint(p, mode, baseInfluence, civicPoints = []) {
if (!p || !inside(p.x, p.y)) return true;
const i = indexOf(p.x, p.y);
if (sea[i]) return true;
if (p.x < 4 || p.y < 4 || p.x > MAP_W - 5 || p.y > MAP_H - 5) return true;
const nearNetwork = (baseInfluence?.[i] || 0) > (mode === "expressway" ? 0.22 : 0.16);
const civicRadius = mode === "expressway" ? 9 : mode === "rail" ? 7 : 6;
const nearCivic = civicPoints.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < civicRadius);
const settlementLike = settlementDemand[i] > (mode === "expressway" ? 0.22 : 0.14) || preliminaryTownInfluence[i] > 0.16 || preliminaryVillageInfluence[i] > 0.22;
const passTerminus = mode !== "expressway" && (passSuitability?.[i] || 0) > 0.58 && settlementDemand[i] > 0.08;
return nearNetwork || nearCivic || (settlementLike && naturalEndpoint(p.x, p.y, i, mode)) || passTerminus;
}
function repairDanglingTransportEndpoints(paths, mode, costField, targetPaths, potentialField, options = {}) {
const debug = { mode, added: [], checked: 0 };
if (!paths?.length) return debug;
const endpoints = endpointList(paths);
const civic = dedupePointCandidates([
...modernCities, ...markets, ...commercialPorts, ...(mode === "national" || mode === "local" ? villages : []),
], 4);
const baseInfluence = cachedInfluenceFromPaths(targetPaths || [], options.targetRadius ?? (mode === "expressway" ? 7 : 5), `${mode}:endpoint-base`);
const candidates = transportCandidatePoints(mode === "local" ? "national" : mode, potentialField, {
maxCells: mode === "expressway" ? 10 : 20,
maxSettlements: mode === "expressway" ? 12 : 36,
maxTotal: mode === "expressway" ? 20 : 52,
minDistance: mode === "expressway" ? 14 : 7,
});
const maxAdded = options.maxAdded ?? (mode === "expressway" ? 3 : mode === "rail" ? 5 : 16);
const penalty = cachedInfluenceFromPaths(paths, options.penaltyRadius ?? 5, `${mode}:endpoint-penalty`);
for (const ep of endpoints) {
if (debug.added.length >= maxAdded) break;
debug.checked++;
if (validTransportEndpoint(ep, mode, baseInfluence, civic)) continue;
const target = [...civic, ...candidates]
.filter((q) => Math.hypot(q.x - ep.x, q.y - ep.y) >= (options.minTargetDistance ?? 7))
.map((q) => {
const d = Math.hypot(q.x - ep.x, q.y - ep.y);
return { q, d, score: d / Math.sqrt(Math.max(0.35, q.score || 0.7)) };
})
.filter((e) => e.d <= (options.maxTargetDistance ?? (mode === "expressway" ? 58 : mode === "rail" ? 48 : 42)))
.sort((a, b) => a.score - b.score)[0]?.q;
if (!target) continue;
const path = routeBetweenTrafficCandidates(ep, target, mode, costField, penalty, {
...options,
snapRadius: options.snapRadius ?? 3,
maxPathLength: options.maxPathLength ?? (mode === "expressway" ? 76 : 58),
});
if (path.length < 4) continue;
if (!transportRouteAcceptable(path, mode, potentialField, penalty, { minLength: 4, maxLength: options.maxPathLength ?? (mode === "expressway" ? 76 : 58) })) continue;
paths.push(path);
debug.added.push({ mode: `${mode}-endpoint-repair`, path, from: "dangling-end", to: target.role || target.kind || "candidate" });
addCorridorInfluencePenalty(penalty, path, options.penaltyRadius ?? 5, options.addedPenalty ?? 0.20);
}
return debug;
}
function pruneDanglingTerminalSegments(paths, mode, targetPaths, options = {}) {
const debug = { mode, pruned: 0, kept: 0 };
if (!paths?.length) return debug;
const civic = dedupePointCandidates([
...modernCities, ...markets, ...commercialPorts, ...(mode === "national" || mode === "local" ? villages : []),
], 4);
const baseInfluence = cachedInfluenceFromPaths(targetPaths || [], options.targetRadius ?? (mode === "expressway" ? 7 : 5), `${mode}:terminal-base`);
const oneInvalidMax = options.oneInvalidMax ?? (mode === "expressway" ? 34 : mode === "rail" ? 24 : mode === "local" ? 10 : 18);
const bothInvalidMax = options.bothInvalidMax ?? (mode === "expressway" ? 54 : mode === "rail" ? 42 : mode === "local" ? 18 : 34);
const minKeep = options.minKeep ?? 1;
const kept = [];
for (const path of paths) {
if (!path || path.length < 2) continue;
const first = path[0];
const last = path[path.length - 1];
const a = { x: first[0], y: first[1] };
const b = { x: last[0], y: last[1] };
const len = pathLengthCells(path);
const invalidA = !validTransportEndpoint(a, mode, baseInfluence, civic);
const invalidB = !validTransportEndpoint(b, mode, baseInfluence, civic);
const prune = paths.length - debug.pruned > minKeep && ((invalidA && invalidB && len < bothInvalidMax) || ((invalidA || invalidB) && len < oneInvalidMax));
if (prune) {
debug.pruned++;
} else {
kept.push(path);
}
}
paths.length = 0;
paths.push(...kept);
debug.kept = paths.length;
return debug;
}
function rebalanceTransportNetworks() {
const debug = { candidateGraphs: [], parallelPruning: [], endpointRepairs: [], prunedDanglingSegments: [] };
debug.candidateGraphs.push(addCandidateKnnNetwork(nationalRoads, "national", transportFields.national, transportFields.nationalPotential, {
maxCells: 30, maxSettlements: 46, maxTotal: 60, k: 4, maxDegree: 3, maxAdded: 13, minDistance: 12, maxDistance: 82, sameRegionOnly: false, parallelRadius: 6, maxParallelInfluence: 0.48, curvePenalty: 0.050, terrainFlowBias: 0.24, surfaceGrain: 0.038, relaxRadius: 2, relaxLineWeight: 0.33,
}));
debug.candidateGraphs.push(addCandidateKnnNetwork(railways, "rail", transportFields.rail, transportFields.railPotential, {
maxCells: 16, maxSettlements: 24, maxTotal: 34, k: 3, maxDegree: 2, maxAdded: 4, minDistance: 22, maxDistance: 86, sameRegionOnly: false, parallelRadius: 7, maxParallelInfluence: 0.40, curvePenalty: 0.16, terrainFlowBias: 0.12, surfaceGrain: 0.010, relaxRadius: 1, relaxLineWeight: 0.52,
}));
debug.candidateGraphs.push(addCandidateKnnNetwork(expressways, "expressway", transportFields.expressway, transportFields.expresswayPotential, {
maxCells: 10, maxSettlements: 12, maxTotal: 20, k: 2, maxDegree: 2, maxAdded: 2, minDistance: 36, maxDistance: 112, sameRegionOnly: false, parallelRadius: 10, maxParallelInfluence: 0.34, curvePenalty: 0.10, terrainFlowBias: 0.11, surfaceGrain: 0.008, relaxRadius: 1, relaxLineWeight: 0.58,
}));
debug.endpointRepairs.push(repairDanglingTransportEndpoints(nationalRoads, "national", transportFields.national, [...externalRoads, ...railways], transportFields.nationalPotential, { maxAdded: 12, maxTargetDistance: 44, curvePenalty: 0.055, terrainFlowBias: 0.24, surfaceGrain: 0.038, relaxRadius: 2, relaxLineWeight: 0.33 }));
debug.endpointRepairs.push(repairDanglingTransportEndpoints(railways, "rail", transportFields.rail, [...nationalRoads, ...externalRailways], transportFields.railPotential, { maxAdded: 3, maxTargetDistance: 44, curvePenalty: 0.16, terrainFlowBias: 0.12, surfaceGrain: 0.010 }));
debug.endpointRepairs.push(repairDanglingTransportEndpoints(expressways, "expressway", transportFields.expressway, [...nationalRoads, ...externalExpressways], transportFields.expresswayPotential, { maxAdded: 1, maxTargetDistance: 60, curvePenalty: 0.10, terrainFlowBias: 0.11, surfaceGrain: 0.008 }));
debug.prunedDanglingSegments.push(pruneDanglingTerminalSegments(nationalRoads, "national", [...externalRoads, ...railways, ...minorRoads], { oneInvalidMax: 20, bothInvalidMax: 36, minKeep: 10 }));
debug.prunedDanglingSegments.push(pruneDanglingTerminalSegments(railways, "rail", [...nationalRoads, ...externalRailways], { oneInvalidMax: 26, bothInvalidMax: 42, minKeep: 3 }));
debug.prunedDanglingSegments.push(pruneDanglingTerminalSegments(expressways, "expressway", [...nationalRoads, ...externalExpressways], { oneInvalidMax: 38, bothInvalidMax: 58, minKeep: 1 }));
debug.parallelPruning.push(pruneParallelSameMode(nationalRoads, "national", transportFields.nationalPotential, { threshold: 0.64, radius: 2, minKeep: 8, shortLength: 20 }));
debug.parallelPruning.push(pruneParallelSameMode(railways, "rail", transportFields.railPotential, { threshold: 0.58, radius: 2, minKeep: 3, shortLength: 28 }));
debug.parallelPruning.push(pruneParallelSameMode(expressways, "expressway", transportFields.expresswayPotential, { threshold: 0.50, radius: 3, minKeep: 1, shortLength: 42 }));
return debug;
}
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 interchanges = [];
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);
}
}
nationalRoads.push(...generateCorridorsFromField({
mode: "national",
potentialField: transportFields.nationalPotential,
costField: transportFields.national,
spacing: 17,
maxCount: 42,
threshold: 0.30,
minLength: 20,
penaltyRadius: 6,
penaltyStrength: 0.34,
curvePenalty: 0.045,
terrainFlowBias: 0.22,
surfaceGrain: 0.040,
relaxRadius: 2,
relaxLineWeight: 0.32,
seedOffset: 13400,
startPredicate: (x, y, i) => transportFields.nationalPotential[i] > 0.27 && regionIdAt(x, y) >= 0,
goalPredicate: (start, x, y, i, used) => {
if (regionIdAt(x, y) !== start.regionId) return false;
if (transportFields.nationalPotential[i] < 0.34) return false;
if (!naturalEndpoint(x, y, i, "national")) return false;
if (distanceToNearest(used, x, y) < 11) return false;
const d = Math.hypot(x - start.x, y - start.y);
return d > 22 && d < 76;
},
}));
railways.push(...generateCorridorsFromField({
mode: "rail",
potentialField: transportFields.railPotential,
costField: transportFields.rail,
spacing: 24,
maxCount: 18,
threshold: 0.30,
minLength: 24,
penaltyRadius: 7,
penaltyStrength: 0.42,
curvePenalty: 0.16,
terrainFlowBias: 0.15,
surfaceGrain: 0.012,
relaxRadius: 1,
relaxLineWeight: 0.52,
seedOffset: 13500,
startPredicate: (x, y, i) => transportFields.railPotential[i] > 0.26 && settlementDemand[i] > 0.16 && regionIdAt(x, y) >= 0,
goalPredicate: (start, x, y, i, used) => {
if (regionIdAt(x, y) !== start.regionId) return false;
if (transportFields.railPotential[i] < 0.30 || settlementDemand[i] < 0.18) return false;
if (!naturalEndpoint(x, y, i, "rail")) return false;
if (distanceToNearest(used, x, y) < 15) return false;
const d = Math.hypot(x - start.x, y - start.y);
return d > 28 && d < 88;
},
}));
expressways.push(...generateCorridorsFromField({
mode: "expressway",
potentialField: transportFields.expresswayPotential,
costField: transportFields.expressway,
spacing: 36,
maxCount: 5,
threshold: 0.35,
minLength: 34,
penaltyRadius: 10,
penaltyStrength: 0.72,
curvePenalty: 0.08,
terrainFlowBias: 0.12,
surfaceGrain: 0.010,
relaxRadius: 1,
relaxLineWeight: 0.58,
seedOffset: 13600,
startPredicate: (x, y, i) => transportFields.expresswayPotential[i] > 0.33 && settlementDemand[i] > 0.12 && regionIdAt(x, y) >= 0,
goalPredicate: (start, x, y, i, used) => {
if (regionIdAt(x, y) !== start.regionId) return false;
if (transportFields.expresswayPotential[i] < 0.35 || settlementDemand[i] < 0.10) return false;
if (!naturalEndpoint(x, y, i, "expressway")) return false;
if (distanceToNearest(used, x, y) < 22) return false;
const d = Math.hypot(x - start.x, y - start.y);
return d > 42 && d < 112;
},
}));
// 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: (regionStats.get(regionId)?.area || 0) > 2200 ? 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, transportFields.national);
if (path.length > 2) externalRoads.push(path);
}
}
}
const transportDebugLayers = {
packedHeatmaps: true,
expresswayPotential: packDebugField(transportFields.expresswayPotential),
railPotential: packDebugField(transportFields.railPotential),
nationalRoadPotential: packDebugField(transportFields.nationalPotential),
slopeSeaPenalty: (() => {
const src = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) src[i] = sea[i] ? 1 : clamp(slope[i] * 1.55 + Math.max(0, elevation[i] - 0.58) * 2.2 + ridgeField[i] * 0.42);
return packDebugField(src);
})(),
components: [],
repairedSegments: [],
unservedSettlements: [],
};
for (const repair of [
repairTransportConnectivity(expressways, "expressway", transportFields.expressway, transportFields.expresswayPotential, {
minImportance: 10.0,
minComponentCells: 22,
maxComponents: 5,
maxRepairs: 2,
maxRepairDistance: 116,
penaltyRadius: 10,
penaltyStrength: 2.35,
curvePenalty: 0.18,
highPotentialThreshold: 0.40,
maxAddedLength: 112,
}),
repairTransportConnectivity(railways, "rail", transportFields.rail, transportFields.railPotential, {
minImportance: 10.5,
minComponentCells: 18,
maxComponents: 6,
maxRepairs: 3,
maxRepairDistance: 105,
penaltyRadius: 7,
penaltyStrength: 2.0,
curvePenalty: 0.16,
terrainFlowBias: 0.13,
surfaceGrain: 0.008,
relaxRadius: 1,
relaxLineWeight: 0.50,
highPotentialThreshold: 0.36,
}),
repairTransportConnectivity(nationalRoads, "national", transportFields.national, transportFields.nationalPotential, {
minImportance: 6.4,
minComponentCells: 10,
maxComponents: 16,
maxRepairs: 10,
maxRepairDistance: 138,
penaltyRadius: 6,
penaltyStrength: 1.18,
curvePenalty: 0.10,
highPotentialThreshold: 0.28,
maxAddedLength: 138,
}),
]) {
transportDebugLayers.components.push(...repair.components);
transportDebugLayers.repairedSegments.push(...repair.repairs);
}
const graphNetworkDebug = rebalanceTransportNetworks();
transportDebugLayers.graphCandidateNetworks = graphNetworkDebug.candidateGraphs.map((item) => ({
mode: item.mode,
candidates: item.candidates,
skipped: item.skipped,
addedCount: item.added?.length || 0,
}));
transportDebugLayers.parallelPruning = graphNetworkDebug.parallelPruning;
transportDebugLayers.endpointRepairs = graphNetworkDebug.endpointRepairs.map((item) => ({
mode: item.mode,
checked: item.checked,
addedCount: item.added?.length || 0,
}));
transportDebugLayers.prunedDanglingSegments = graphNetworkDebug.prunedDanglingSegments;
for (const item of graphNetworkDebug.candidateGraphs || []) {
transportDebugLayers.repairedSegments.push(...(item.added || []));
}
for (const item of graphNetworkDebug.endpointRepairs || []) {
transportDebugLayers.repairedSegments.push(...(item.added || []));
}
function generateLocalRoadsForUnservedSettlements() {
const trunkInfluence = cachedInfluenceFromPaths([...nationalRoads, ...railways, ...externalRoads], 8, "local:trunk");
const candidates = [...villages, ...markets, ...ports]
.filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)])
.map((p) => {
const i = indexOf(p.x, p.y);
const settlementWeight = p.portClass ? 1.2 : p.population ? clamp(p.population / 18000, 0.35, 1.4) : 0.45;
return { ...p, score: settlementWeight + transportFields.localPotential[i] * 0.65 - trunkInfluence[i] * 1.15 };
})
.filter((p) => p.score > 0.06 && trunkInfluence[indexOf(p.x, p.y)] < 0.34)
.sort((a, b) => b.score - a.score)
.slice(0, 150);
const localPenalty = new Float32Array(SIZE);
const paths = [];
const served = [];
for (const start of candidates) {
if (paths.length >= 115) break;
if (distanceToNearest(served, start.x, start.y) < 4.5) continue;
let path = traceCorridorByCost(
start,
(x, y, i) => trunkInfluence[i] > 0.18 || (paths.length > 6 && localPenalty[i] > 0.045),
transportFields.local,
localPenalty,
{ curvePenalty: 0.08, penaltyStrength: 1.00, minGoalDistance: 5, regionId: start.regionId, maxExpanded: SIZE }
);
if (path.length < 4 || path.length > 86) continue;
if (!transportRouteAcceptable(path, "local", transportFields.localPotential, localPenalty, { minLength: 4, maxLength: 86 })) continue;
paths.push(path);
served.push(start);
addCorridorInfluencePenalty(localPenalty, path, 4, 0.22);
}
return paths;
}
function runLocalAccessPass({
candidates,
accessInfluence,
localPenalty,
maxAdded = 80,
minSpacing = 3.5,
maxLength = 82,
debugMode = "local-access",
from = "unserved",
to = "network",
targetPredicate = null,
}) {
const served = [];
let added = 0;
for (const start of candidates) {
if (added >= maxAdded) break;
if (distanceToNearest(served, start.x, start.y) < minSpacing) continue;
let path = traceCorridorByCost(
start,
targetPredicate || ((x, y, i) => accessInfluence[i] > 0.16 || localPenalty[i] > 0.075),
transportFields.local,
localPenalty,
{ curvePenalty: 0.020, penaltyStrength: 0.90, minGoalDistance: 3, regionId: start.regionId, maxExpanded: Math.floor(SIZE * 0.72), terrainFlowBias: 0.26, surfaceGrain: 0.042 }
);
path = relaxRouteToTerrain(path, transportFields.local, { radius: 2, lineWeight: 0.25, grain: 0.048, iterations: 1 });
const ok = path.length >= 4 && path.length <= maxLength && transportRouteAcceptable(path, "local", transportFields.localPotential, localPenalty, { minLength: 4, maxLength });
transportDebugLayers.unservedSettlements.push({ x: start.x, y: start.y, kind: start.kind || "Unserved Settlement", mode: debugMode, repaired: ok });
if (!ok) continue;
minorRoads.push(path);
served.push(start);
added++;
transportDebugLayers.repairedSegments.push({ mode: "local", path, from, to });
addCorridorInfluencePenalty(localPenalty, path, 4, 0.18);
if (accessInfluence) addCorridorInfluencePenalty(accessInfluence, path, 5, 0.22);
}
return added;
}
minorRoads.push(...generateLocalRoadsForUnservedSettlements());
const localEndpointRepair = repairDanglingTransportEndpoints(minorRoads, "local", transportFields.local, [...nationalRoads, ...externalRoads, ...railways], transportFields.localPotential, {
maxAdded: 36,
maxTargetDistance: 34,
curvePenalty: 0.055,
terrainFlowBias: 0.26,
surfaceGrain: 0.048,
relaxRadius: 2,
relaxLineWeight: 0.30,
targetRadius: 5,
});
transportDebugLayers.endpointRepairs.push(localEndpointRepair);
transportDebugLayers.repairedSegments.push(...localEndpointRepair.added);
const localDanglingPrune = pruneDanglingTerminalSegments(minorRoads, "local", [...nationalRoads, ...externalRoads, ...railways], {
oneInvalidMax: 11,
bothInvalidMax: 18,
minKeep: 24,
});
transportDebugLayers.prunedDanglingSegments.push(localDanglingPrune);
const localParallelPruning = pruneParallelSameMode(minorRoads, "local", transportFields.localPotential, {
threshold: 0.70,
radius: 1,
minKeep: 28,
shortLength: 13,
});
transportDebugLayers.parallelPruning.push(localParallelPruning);
for (const path of expressways) {
for (const p of samplePath(path, 24)) {
const i = indexOf(p.x, p.y);
if (sea[i] || transportFields.expresswayPotential[i] < 0.24) continue;
if (interchanges.every((q) => Math.hypot(q.x - p.x, q.y - p.y) > 14)) {
interchanges.push({ x: p.x, y: p.y, kind: "Interchange", score: transportFields.expresswayPotential[i], regionId: regionIdAt(p.x, p.y) });
}
}
}
// Land-use road influence intentionally excludes expressways. Expressways
// are through-corridors here, not automatic suburbanization generators.
// A narrow field controls land-use attachment, while a broader field raises
// population density around trunk roads without painting a wide suburb band.
const roadLanduseInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 2.25, "road:landuse");
const roadInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 5.0, "road:influence");
const roadDensityInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 9.0, "road:density");
const railInfluence2 = cachedInfluenceFromPaths([...railways, ...branchRailways, ...externalRailways], 4, "rail:influence");
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);
const stationDensityInfluence = influenceFromPoints(stations, 10, (s) => s.kind === "Major Station" ? 1.85 : 1.05);
// --- 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);
const angle = hash2(p.x, p.y, seed + 14901) * Math.PI * 2;
const stretch = 1.35 + hash2(p.x, p.y, seed + 14902) * 0.85;
const squeeze = 0.62 + hash2(p.x, p.y, seed + 14903) * 0.28;
const ca = Math.cos(angle);
const sa = Math.sin(angle);
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 along = (dx * ca + dy * sa) / stretch;
const across = (-dx * sa + dy * ca) / squeeze;
const baseD = Math.hypot(along, across);
const conduit = clamp(
plain[i] * 0.18 +
valleySettlement[i] * 0.26 +
coastalSettlement[i] * 0.16 +
roadDensityInfluence[i] * 0.34 +
roadInfluence[i] * 0.22 +
railInfluence2[i] * 0.26 +
stationDensityInfluence[i] * 0.12
);
const barrier = clamp(
slope[i] * 0.62 +
ridgeField[i] * 0.74 +
Math.max(0, elevation[i] - 0.58) * 0.82 +
(river[i] > 0.68 ? 0.60 : river[i] > 0.34 ? 0.22 : 0)
);
const noise = 0.78 + hash2(x, y, seed + 14910 + Math.round((p.population || 0) / 1000)) * 0.46;
const d = baseD * (1.10 - conduit * 0.42 + barrier * 0.62) * noise;
if (d > radius) continue;
const terrain = terrainWeighted ? clamp(0.06 + developable[i] * 1.08 + valleySettlement[i] * 0.24 + coastalSettlement[i] * 0.14 + conduit * 0.38 - barrier * 0.70, 0, 1.42) : 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.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 logisticsScore = 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 flatAgriculturalCorridor = clamp(
agriculture[i] * 0.34 +
plain[i] * 0.24 +
developable[i] * 0.20 +
basinField[i] * 0.12 +
coastalLowland[i] * 0.08 +
roadInfluence[i] * 0.26 +
railInfluence2[i] * 0.18 +
stationInfluence[i] * 0.10 -
slope[i] * 0.44 -
ridgeField[i] * 0.32
);
const nearMajorCity = modernCities.some((c) => Math.hypot(c.x - x, c.y - y) < 4);
logisticsScore[i] = nearMajorCity ? 0 : flatAgriculturalCorridor;
}
}
const logisticsParks = pickGlobalPoints(logisticsScore, {
threshold: 0.34,
max: 18,
minDistance: 12,
seedOffset: 1450,
predicate: (x, y, i) => logisticsScore[i] > 0.30 && (roadInfluence[i] > 0.10 || railInfluence2[i] > 0.08 || stationInfluence[i] > 0.08),
}).map((p) => ({ ...p, kind: "Logistics Park", score: logisticsScore[indexOf(p.x, p.y)], population: 0 }));
const logisticsInfluence = influenceFromPoints(logisticsParks, 4.8, () => 1.0);
function addFinalLocalAccessForUnservedSettlements() {
const accessInfluence = cachedInfluenceFromPaths([...nationalRoads, ...railways, ...externalRoads, ...minorRoads], 8, "final-local:access");
const candidates = [
...markets.filter((p) => (p.population || 0) >= 1800),
...ports,
...logisticsParks,
...villages.filter((p) => (p.population || 0) >= 450),
]
.filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)] && accessInfluence[indexOf(p.x, p.y)] < 0.30)
.map((p) => {
const i = indexOf(p.x, p.y);
const remoteness = Math.max(0, 0.32 - accessInfluence[i]);
const ruralValue = agriculture[i] * 0.42 + valleySettlement[i] * 0.34 + coastalSettlement[i] * 0.24 + ruralSuitability[i] * 0.34;
return { ...p, score: (p.population || 1200) / 16000 + remoteness * 3.2 + ruralValue + (p.portClass ? 0.8 : 0) + (p.kind === "Logistics Park" ? 1.0 : 0) + transportFields.localPotential[i] };
})
.sort((a, b) => b.score - a.score)
.slice(0, 130);
const localPenalty = cachedInfluenceFromPaths(minorRoads, 4, "final-local:minor");
runLocalAccessPass({ candidates, accessInfluence, localPenalty, maxAdded: 90, maxLength: 92, debugMode: "local-access", from: "unserved", to: "network" });
}
function addRuralRoadMeshConnectors() {
const roadInfluenceNow = cachedInfluenceFromPaths([...nationalRoads, ...externalRoads, ...railways, ...minorRoads], 7, "municipal-local:access");
const localPenalty = cachedInfluenceFromPaths(minorRoads, 3, "municipal-local:minor");
const candidates = villages
.filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)] && roadInfluenceNow[indexOf(p.x, p.y)] < 0.46)
.map((p) => {
const i = indexOf(p.x, p.y);
const ruralScore = agriculture[i] * 0.55 + valleySettlement[i] * 0.42 + coastalSettlement[i] * 0.25 + ruralSuitability[i] * 0.42 + Math.max(0, 0.46 - roadInfluenceNow[i]) * 2.4;
return { ...p, score: ruralScore + (p.population || 700) / 24000 + transportFields.localPotential[i] * 0.55 };
})
.filter((p) => p.score > 0.30)
.sort((a, b) => b.score - a.score)
.slice(0, 170);
runLocalAccessPass({
candidates,
accessInfluence: roadInfluenceNow,
localPenalty,
maxAdded: 70,
minSpacing: 3.0,
maxLength: 76,
debugMode: "rural-mesh",
from: "rural-settlement",
to: "local-network",
targetPredicate: (x, y, i) => roadInfluenceNow[i] > 0.18 || localPenalty[i] > 0.07,
});
}
addFinalLocalAccessForUnservedSettlements();
addRuralRoadMeshConnectors();
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);
const ruralDensityFloor = 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(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82);
const densityTransport = Math.max(roadDensityInfluence[i] * 0.95, stationDensityInfluence[i] * 1.05, railInfluence2[i] * 0.85);
const urban = cityInfluence[i] * 0.76 + stationInfluence[i] * 0.30 + roadInfluence[i] * 0.14 + 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 +
roadInfluence[i] * 0.14 + roadDensityInfluence[i] * 0.16 + transport * 0.08 +
riverUrban * 0.14 -
slope[i] * 0.18 -
ridgeField[i] * 0.12 -
floodplain[i] * 0.08
);
const highPenaltyDensity = Math.max(0, elevation[i] - 0.58);
const agrarianDensity = clamp(
agriculture[i] * 0.045 +
ruralSuitability[i] * 0.035 +
developable[i] * 0.028 +
plain[i] * 0.018 +
basinField[i] * 0.014 +
valleySettlement[i] * 0.014 +
coastalSettlement[i] * 0.012 +
villageInfluence[i] * 0.040 +
townInfluence[i] * 0.022 +
roadDensityInfluence[i] * 0.038 +
stationDensityInfluence[i] * 0.020 +
railInfluence2[i] * 0.012 -
slope[i] * 0.030 -
ridgeField[i] * 0.020 -
highPenaltyDensity * 0.058
);
const remoteWilderness = elevation[i] > 0.60 && slope[i] > 0.34 && ridgeField[i] > 0.38 && densityTransport < 0.035 && villageInfluence[i] < 0.025 && townInfluence[i] < 0.025 && cityInfluence[i] < 0.025;
ruralDensityFloor[i] = remoteWilderness ? 0 : clamp(agrarianDensity, 0, elevation[i] > 0.62 || slope[i] > 0.42 ? 0.052 : 0.105);
populationDensity[i] = clamp(
urban * 0.66 +
core * 0.46 +
oldTown * 0.28 +
townInfluence[i] * 0.16 +
villageInfluence[i] * 0.14 +
roadDensityInfluence[i] * 0.42 +
stationDensityInfluence[i] * 0.34 +
railInfluence2[i] * 0.12 +
transport * 0.05 +
agrarianDensity * 0.34
);
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 (logisticsInfluence[i] > 0.24 && urbanCapacity[i] > 0.08 && (roadInfluence[i] > 0.08 || railInfluence2[i] > 0.06)) {
landuse[i] = LANDUSE.LOGISTICS;
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 + roadLanduseInfluence[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 (roadLanduseInfluence[i] > 0.30 && urbanCapacity[i] > 0.10 && (townInfluence[i] > 0.05 || cityInfluence[i] > 0.11)) {
landuse[i] = LANDUSE.SUBURB;
} else if (agriculture[i] > 0.16 || rural > 0.18 || (developable[i] > 0.13 && plain[i] > 0.13) || (basinField[i] > 0.18 && slope[i] < 0.34) || (coastalLowland[i] > 0.16 && slope[i] < 0.32)) {
landuse[i] = LANDUSE.FARMLAND;
} else {
const usablePlain = slope[i] < 0.30 && (plain[i] > 0.18 || developable[i] > 0.20 || basinField[i] > 0.20 || coastalLowland[i] > 0.18);
landuse[i] = elevation[i] > 0.58 || slope[i] > 0.38 ? LANDUSE.FOREST : usablePlain ? LANDUSE.FARMLAND : 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(roadLanduseInfluence[i] * 0.95, railInfluence2[i] * 0.95, stationInfluence[i] * 0.82);
const densityTransport = Math.max(roadDensityInfluence[i] * 0.95, stationDensityInfluence[i] * 1.05, railInfluence2[i] * 0.85);
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] = LANDUSE.SUBURB;
}
}
if ((river[i] > 0.10 || railInfluence2[i] > 0.16 || roadLanduseInfluence[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;
}
}
}
for (const park of logisticsParks) {
const r = 3;
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const x = park.x + dx;
const y = park.y + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i] || landuse[i] === LANDUSE.CBD || landuse[i] === LANDUSE.FOREST) continue;
if (Math.hypot(dx, dy) <= r && (agriculture[i] > 0.18 || plain[i] > 0.15 || roadInfluence[i] > 0.06 || railInfluence2[i] > 0.05)) {
landuse[i] = LANDUSE.LOGISTICS;
}
}
}
}
if (maxDensity > 0) {
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
const lu = landuse[i];
let floor = ruralDensityFloor[i];
if (lu === LANDUSE.FARMLAND) {
floor = Math.max(floor, clamp(0.024 + agriculture[i] * 0.044 + ruralSuitability[i] * 0.024 + roadDensityInfluence[i] * 0.030 + stationDensityInfluence[i] * 0.026 + villageInfluence[i] * 0.018, 0, 0.110));
} else if (lu === LANDUSE.LOGISTICS) {
floor = Math.max(floor, clamp(0.018 + roadDensityInfluence[i] * 0.026 + railInfluence2[i] * 0.014 + logisticsInfluence[i] * 0.012, 0, 0.060));
} else if (lu === LANDUSE.RURAL) {
floor = Math.max(floor, clamp(0.012 + ruralSuitability[i] * 0.020 + developable[i] * 0.014 + roadDensityInfluence[i] * 0.024 + stationDensityInfluence[i] * 0.020, 0, 0.070));
} else if (lu === LANDUSE.FOREST) {
floor = Math.min(floor, (roadDensityInfluence[i] > 0.04 || villageInfluence[i] > 0.03) ? 0.026 : 0);
}
const normalized = populationDensity[i] / maxDensity;
populationDensity[i] = clamp(Math.max(normalized, floor));
if (lu === LANDUSE.FOREST && floor === 0 && populationDensity[i] < 0.012) populationDensity[i] = 0;
}
}
}
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),
fieldCorridorTransport: true,
expresswayFieldCorridors: expressways.length,
railFieldCorridors: railways.length,
nationalRoadFieldCorridors: nationalRoads.length,
localRoadFieldCorridors: minorRoads.length,
layers: transportDebugLayers,
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,
roadDensityInfluence,
stationDensityInfluence,
railInfluence2,
villageInfluence,
externalGateways,
cityPopulationCap,
transportDebug,
};
}