2621 lines
121 KiB
JavaScript
2621 lines
121 KiB
JavaScript
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js";
|
|
import { createPointSpatialIndex, distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js";
|
|
import { buildDensityFlowRoadTransportSystem, createPathInfluenceCache, packDebugField, pathAverageField, pathLengthCells, routeQualityAcceptable } from "./mapTransport.js";
|
|
import { buildUnifiedRailODNetwork } from "./mapTransportOD.js";
|
|
import { buildFeatureContext } from "./mapFeatureContext.js";
|
|
import { buildFeatureLanduse } from "./mapFeatureLanduse.js";
|
|
import { buildSettlementDemandFields } from "./mapFeatureSettlements.js";
|
|
import { buildFeatureTransportCostFields } from "./mapFeatureTransportTools.js";
|
|
import { buildCoarseCostGraph, refineCoarsePath, routeCoarsePath } from "./mapTransportGraph.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, options = {}) {
|
|
const SPEED_TOLERANCE = 0.90;
|
|
const patchMode = options?.patchMode === true || options?.generationContext?.hasBoundaryWorld === true;
|
|
const topCenterSuppression = clamp(Number.isFinite(options?.topCenterSuppression) ? options.topCenterSuppression : (patchMode ? 0.68 : 0), 0, 0.95);
|
|
const featureTimings = [];
|
|
const nowMs = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
let timingMark = nowMs();
|
|
function markFeatureTiming(key) {
|
|
const t = nowMs();
|
|
featureTimings.push({ key, ms: Math.round((t - timingMark) * 10) / 10 });
|
|
timingMark = t;
|
|
}
|
|
|
|
const {
|
|
elevation,
|
|
slope,
|
|
sea,
|
|
river,
|
|
floodplain,
|
|
plain,
|
|
agriculture,
|
|
ridgeField,
|
|
valleyField,
|
|
basinField,
|
|
coastalLowland,
|
|
flowAccum,
|
|
depositionalLowland,
|
|
deltaField,
|
|
portSuitability,
|
|
crossingSuitability,
|
|
passSuitability,
|
|
naturalBarrierScore,
|
|
} = terrain;
|
|
|
|
const featureContext = buildFeatureContext(seed, terrain);
|
|
const {
|
|
geoHabitability,
|
|
geoAccessibility,
|
|
geoNaturalCentrality,
|
|
geoLowlandCapacity,
|
|
geoValleyAccess,
|
|
geoCoastalAccess,
|
|
geoBarrier,
|
|
fieldValue,
|
|
regionIdAt,
|
|
inFocusedPrefecture,
|
|
developable,
|
|
ruralSuitability,
|
|
townSuitability,
|
|
valleySettlement,
|
|
coastalSettlement,
|
|
confluenceField,
|
|
barrierCost,
|
|
corridorCost,
|
|
settlementCluster,
|
|
settlementScore,
|
|
regionStats,
|
|
visibilityFactor,
|
|
pickRegionalPoints,
|
|
pickGlobalPoints,
|
|
} = featureContext;
|
|
markFeatureTiming("context");
|
|
// --- 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 portIndex = createPointSpatialIndex(ports, 12);
|
|
const commercialPortIndex = createPointSpatialIndex(commercialPorts, 12);
|
|
|
|
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 crossingIndex = createPointSpatialIndex(crossings, 8);
|
|
|
|
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" }));
|
|
|
|
// Phase 2: provisional upper-tier settlement anchors are selected directly
|
|
// from the unified geography fields before lower-tier villages and market
|
|
// towns are placed. These anchors are not rendered as separate settlements;
|
|
// they guide city selection and lower-tier spacing.
|
|
const geographicUrbanAnchorScore = new Float32Array(SIZE);
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (sea[i]) continue;
|
|
const gHabit = fieldValue(geoHabitability, i, developable[i]);
|
|
const gAccess = fieldValue(geoAccessibility, i, 0);
|
|
const gCentral = fieldValue(geoNaturalCentrality, i, townSuitability[i]);
|
|
const gLow = fieldValue(geoLowlandCapacity, i, plain[i]);
|
|
const gValley = fieldValue(geoValleyAccess, i, valleySettlement[i]);
|
|
const gCoast = fieldValue(geoCoastalAccess, i, coastalSettlement[i]);
|
|
const gBarrier = fieldValue(geoBarrier, i, naturalBarrierScore?.[i] || 0);
|
|
geographicUrbanAnchorScore[i] = clamp(
|
|
gCentral * 0.58 +
|
|
gHabit * 0.34 +
|
|
gAccess * 0.28 +
|
|
gLow * 0.20 +
|
|
gValley * 0.08 +
|
|
gCoast * 0.10 +
|
|
(portSuitability?.[i] || 0) * 0.12 +
|
|
(crossingSuitability?.[i] || 0) * 0.08 +
|
|
confluenceField[i] * 0.06 -
|
|
gBarrier * 0.26 -
|
|
slope[i] * 0.10
|
|
);
|
|
}
|
|
const geographicUrbanAnchors = pickRegionalPoints(geographicUrbanAnchorScore, {
|
|
stride: 2,
|
|
threshold: 0.33 + rand(seed, 1026) * 0.025,
|
|
totalMax: 18,
|
|
minDistance: 24,
|
|
seedOffset: 1025,
|
|
kind: "Geographic Urban Anchor",
|
|
predicate: (x, y, i) => fieldValue(geoHabitability, i, developable[i]) > 0.15 && fieldValue(geoBarrier, i, 0) < 0.58 && slope[i] < 0.42,
|
|
quotaForRegion: (regionId, st) => {
|
|
if (!st || st.developableCells < 40) return 0;
|
|
const vf = visibilityFactor(regionId, st);
|
|
const centralCells = st.highCentralityCells || 0;
|
|
const raw = (centralCells / 180 + st.developableCells / 980 + 0.85) * vf;
|
|
const min = st.area > 1800 || st.developableCells > 260 ? 1 : 0;
|
|
const max = st.area > 4200 ? 4 : st.area > 2400 ? 3 : st.area > 900 ? 2 : 1;
|
|
return Math.round(clamp(raw + rand(seed, 1027 + regionId * 17) * 0.7, min, max));
|
|
},
|
|
extraScore: (x, y, i) => fieldValue(geoNaturalCentrality, i, 0) * 0.18 + fieldValue(geoAccessibility, i, 0) * 0.10,
|
|
}).map((p) => ({ ...p, candidateKind: "geographicAnchor", anchorScore: p.score }));
|
|
const geographicAnchorInfluence = influenceFromPoints(geographicUrbanAnchors, 20, (p) => clamp((p.anchorScore || p.score || 0.4) * 1.35, 0.45, 1.35));
|
|
|
|
const villageScore = new Float32Array(SIZE);
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (sea[i]) continue;
|
|
villageScore[i] = clamp(
|
|
ruralSuitability[i] * 0.42 +
|
|
agriculture[i] * 0.42 +
|
|
plain[i] * 0.34 +
|
|
fieldValue(geoHabitability, i, developable[i]) * 0.22 +
|
|
fieldValue(geoLowlandCapacity, i, plain[i]) * 0.18 +
|
|
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.48 +
|
|
valleySettlement[i] * 0.06 +
|
|
coastalSettlement[i] * 0.28 +
|
|
settlementCluster[i] * 0.18 -
|
|
geographicAnchorInfluence[i] * 0.08 -
|
|
fieldValue(geoBarrier, i, 0) * 0.10 -
|
|
river[i] * 0.10 -
|
|
flowAccum[i] * 0.04
|
|
);
|
|
}
|
|
let villages = pickRegionalPoints(villageScore, {
|
|
stride: 2,
|
|
threshold: 0.18 + rand(seed, 1031) * 0.030,
|
|
totalMax: 190,
|
|
minDistance: 6,
|
|
seedOffset: 1030,
|
|
kind: "Village",
|
|
quotaForRegion: (regionId, st) => {
|
|
if (!st || st.developableCells < 10) return 0;
|
|
const vf = visibilityFactor(regionId, st);
|
|
const raw = (st.developableCells / 40 + st.plainCells / 58 + st.valleyCells / 48 + st.coastCells / 46 + 2.1) * vf;
|
|
const min = st.area > 2600 ? 12 : st.area > 1400 ? 7 : st.area > 520 ? 3 : st.area > 220 ? 1 : 0;
|
|
const max = st.area > 3600 ? 46 : st.area > 2200 ? 32 : st.area > 900 ? 16 : 7;
|
|
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 * 0.82 + settlementCluster[i] * 0.14 + ruralSuitability[i] * 0.16 + fieldValue(geoHabitability, i, developable[i]) * 0.16 + fieldValue(geoLowlandCapacity, i, plain[i]) * 0.12 - geographicAnchorInfluence[i] * 0.05);
|
|
}
|
|
const initialVillageIndex = createPointSpatialIndex(villages, 8);
|
|
const supplementalPlainVillages = pickRegionalPoints(openPlainVillageScore, {
|
|
stride: 2,
|
|
threshold: 0.235 + rand(seed, 1036) * 0.020,
|
|
totalMax: 60,
|
|
minDistance: 7,
|
|
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 / 92 + st.developableCells / 260 + 0.9) * vf;
|
|
const min = st.plainCells > 360 ? 3 : st.plainCells > 160 ? 1 : st.plainCells > 90 ? 1 : 0;
|
|
const max = st.plainCells > 720 ? 12 : st.plainCells > 360 ? 8 : st.plainCells > 140 ? 4 : 2;
|
|
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) => !initialVillageIndex.hasWithin(p.x, p.y, 6.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];
|
|
|
|
let 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(
|
|
portIndex.hasWithin(x, y, 10) ? 0.16 : 0,
|
|
crossingIndex.hasWithin(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.38 +
|
|
fieldValue(geoNaturalCentrality, i, townSuitability[i]) * 0.36 +
|
|
fieldValue(geoAccessibility, i, 0) * 0.18 +
|
|
fieldValue(geoHabitability, i, developable[i]) * 0.18 +
|
|
agriculture[i] * 0.24 +
|
|
plain[i] * 0.22 +
|
|
openPlainMarket * 0.58 +
|
|
coastalSettlement[i] * 0.18 +
|
|
villageInfluence[i] * 0.24 +
|
|
geographicAnchorInfluence[i] * 0.08 +
|
|
featurePull +
|
|
valleyMouth +
|
|
basinField[i] * 0.12 +
|
|
plain[i] * 0.16 +
|
|
coastalLowland[i] * 0.07 -
|
|
fieldValue(geoBarrier, i, 0) * 0.12 -
|
|
slope[i] * 0.16 -
|
|
ridgeField[i] * 0.07 -
|
|
river[i] * 0.08 -
|
|
flowAccum[i] * 0.035
|
|
);
|
|
}
|
|
}
|
|
|
|
let markets = pickRegionalPoints(marketScore, {
|
|
stride: 2,
|
|
threshold: 0.245 + rand(seed, 1041) * 0.035,
|
|
totalMax: 70,
|
|
minDistance: 9,
|
|
seedOffset: 1040,
|
|
kind: "Market Town",
|
|
quotaForRegion: (regionId, st) => {
|
|
if (!st || st.townCells < 8) return 0;
|
|
const vf = visibilityFactor(regionId, st);
|
|
const raw = (st.developableCells / 132 + st.plainCells / 148 + st.valleyCells / 128 + st.coastCells / 104 + 1.8) * vf;
|
|
const min = st.area > 2600 ? 5 : st.area > 1200 ? 3 : st.area > 520 ? 1 : 0;
|
|
const max = st.area > 3600 ? 20 : st.area > 2200 ? 14 : st.area > 800 ? 7 : 3;
|
|
return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max));
|
|
},
|
|
extraScore: (x, y, i) => (commercialPortIndex.hasWithin(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 && portIndex.hasWithin(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 * 0.78 + townSuitability[i] * 0.16 + fieldValue(geoNaturalCentrality, i, townSuitability[i]) * 0.18 + fieldValue(geoHabitability, i, developable[i]) * 0.12 + villageInfluence[i] * 0.16 + settlementCluster[i] * 0.10 + geographicAnchorInfluence[i] * 0.05);
|
|
}
|
|
const preSupplementalMarketIndex = createPointSpatialIndex(markets, 12);
|
|
const villageIndexForMarketSpacing = createPointSpatialIndex(villages, 8);
|
|
const supplementalPlainMarkets = pickRegionalPoints(openPlainMarketScore, {
|
|
stride: 2,
|
|
threshold: 0.335 + rand(seed, 1046) * 0.025,
|
|
totalMax: 26,
|
|
minDistance: 12,
|
|
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 / 380 + st.developableCells / 720 + 0.25) * vf;
|
|
const min = st.plainCells > 520 ? 1 : st.plainCells > 260 ? 1 : 0;
|
|
const max = st.plainCells > 900 ? 4 : st.plainCells > 420 ? 3 : st.plainCells > 160 ? 1 : 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) => !preSupplementalMarketIndex.hasWithin(p.x, p.y, 10.5) && !villageIndexForMarketSpacing.hasWithin(p.x, p.y, 4.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];
|
|
let marketIndex = createPointSpatialIndex(markets, 12);
|
|
let villageIndex = createPointSpatialIndex(villages, 8);
|
|
|
|
// Sparse-area towns: when a developable basin/plain/coast has few nearby towns,
|
|
// add a small market town candidate. This avoids large inhabited regions being
|
|
// empty while still keeping minimum spacing from existing settlements.
|
|
const existingTownInfluenceForSparseFill = influenceFromPoints([...markets, ...commercialPorts], 18, (p) => clamp((p.population || 9000) / 32000, 0.35, 1.25));
|
|
const existingSettlementInfluenceForSparseFill = influenceFromPoints([...markets, ...villages, ...ports], 12, (p) => clamp((p.population || 1800) / 16000, 0.18, 1.0));
|
|
const sparseTownScore = new Float32Array(SIZE);
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (sea[i]) continue;
|
|
const remoteness = clamp((0.30 - existingTownInfluenceForSparseFill[i]) / 0.30);
|
|
const settlementGap = clamp((0.42 - existingSettlementInfluenceForSparseFill[i]) / 0.42);
|
|
const livable = clamp(townSuitability[i] * 0.34 + fieldValue(geoNaturalCentrality, i, townSuitability[i]) * 0.22 + fieldValue(geoHabitability, i, developable[i]) * 0.20 + fieldValue(geoAccessibility, i, 0) * 0.12 + developable[i] * 0.22 + plain[i] * 0.24 + agriculture[i] * 0.18 + basinField[i] * 0.16 + coastalSettlement[i] * 0.14 + valleySettlement[i] * 0.12 - fieldValue(geoBarrier, i, 0) * 0.12 - slope[i] * 0.22 - ridgeField[i] * 0.10);
|
|
sparseTownScore[i] = clamp(livable * (0.42 + remoteness * 0.88) + settlementGap * 0.16);
|
|
}
|
|
const preSparseMarketIndex = marketIndex;
|
|
const preSparseVillageIndex = villageIndex;
|
|
const marketCountByRegion = new Map();
|
|
for (const m of markets) marketCountByRegion.set(m.regionId, (marketCountByRegion.get(m.regionId) || 0) + 1);
|
|
const sparseMarkets = pickRegionalPoints(sparseTownScore, {
|
|
stride: 2,
|
|
threshold: 0.315 + rand(seed, 1049) * 0.025,
|
|
totalMax: 24,
|
|
minDistance: 15,
|
|
seedOffset: 1048,
|
|
kind: "Sparse Market Town",
|
|
predicate: (x, y, i) => existingTownInfluenceForSparseFill[i] < 0.34 && developable[i] > 0.12 && slope[i] < 0.36 && ridgeField[i] < 0.55,
|
|
quotaForRegion: (regionId, st) => {
|
|
if (!st || st.developableCells < 90) return 0;
|
|
const vf = visibilityFactor(regionId, st);
|
|
const underServed = clamp(1.0 - ((marketCountByRegion.get(regionId) || 0) / Math.max(1, st.area / 850)));
|
|
const raw = (st.developableCells / 900 + st.plainCells / 720 + st.coastCells / 560 + 0.55) * vf * (0.55 + underServed * 0.75);
|
|
return Math.round(clamp(raw + rand(seed, 1050 + regionId * 37) * 0.45, 0, st.area > 2000 ? 2 : 1));
|
|
},
|
|
extraScore: (x, y, i) => clamp((0.34 - existingTownInfluenceForSparseFill[i]) * 0.38 + plain[i] * 0.10 + agriculture[i] * 0.08 + coastalSettlement[i] * 0.06),
|
|
})
|
|
.filter((p) => !preSparseMarketIndex.hasWithin(p.x, p.y, 12) && !preSparseVillageIndex.hasWithin(p.x, p.y, 4.5))
|
|
.map((p, n) => {
|
|
const i = indexOf(p.x, p.y);
|
|
const population = Math.round((8000 + Math.pow(rand(seed, 18480 + n * 41 + p.x * 13 + p.y), 1.08) * 36000 + sparseTownScore[i] * 26000) / 1000) * 1000;
|
|
return { ...p, kind: "Sparse Market Town", population };
|
|
});
|
|
markets = [...markets, ...sparseMarkets];
|
|
marketIndex = createPointSpatialIndex(markets, 12);
|
|
|
|
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 = clamp(developable[i] * 0.66 + fieldValue(geoHabitability, i, developable[i]) * 0.34);
|
|
if (dev < 0.04) continue;
|
|
const radial = clamp(1 - d / Math.max(1, radius));
|
|
const terrainMultiplier = clamp(0.58 + plain[i] * 0.40 + agriculture[i] * 0.18 + basinField[i] * 0.24 + coastalLowland[i] * 0.16 + valleyField[i] * 0.06 + fieldValue(geoLowlandCapacity, i, plain[i]) * 0.24 + fieldValue(geoAccessibility, i, 0) * 0.12 - fieldValue(geoBarrier, i, 0) * 0.18 - slope[i] * 0.34 - ridgeField[i] * 0.14, 0.24, 1.46);
|
|
capacity += dev * (900 + 6200 * Math.pow(radial, 1.25)) * terrainMultiplier * densityBias;
|
|
}
|
|
}
|
|
return Math.max(26000, Math.round(capacity / 1000) * 1000);
|
|
}
|
|
|
|
const urbanCandidates = [
|
|
...geographicUrbanAnchors.map((p) => ({ ...p, candidateKind: "geographicAnchor" })),
|
|
...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.66 +
|
|
townSuitability[i] * 0.86 +
|
|
developable[i] * 0.68 +
|
|
fieldValue(geoNaturalCentrality, i, townSuitability[i]) * 1.34 +
|
|
fieldValue(geoHabitability, i, developable[i]) * 0.58 +
|
|
fieldValue(geoAccessibility, i, 0) * 0.44 +
|
|
confluenceField[i] * 0.18 +
|
|
(p.candidateKind === "geographicAnchor" ? 0.42 : 0) +
|
|
(p.candidateKind === "port" ? 0.48 : 0) +
|
|
(p.candidateKind === "castleTown" ? 0.22 : 0) -
|
|
fieldValue(geoBarrier, i, 0) * 0.36 +
|
|
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 / 680 + (st.highCentralityCells || 0) / 360 + 0.95) * vf + rand(seed, 12100 + regionId * 17) * 1.1),
|
|
(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: 24,
|
|
threshold: 0,
|
|
seed: seed + 12110 + regionId * 313,
|
|
jitter: 0.02,
|
|
});
|
|
for (const p of selected) {
|
|
const minD = p.capacity >= 420000 ? 34 : p.capacity >= 220000 ? 28 : p.capacity >= 110000 ? 23 : 20;
|
|
if (usedCitySites.some((q) => {
|
|
const qMinD = q.capacity >= 420000 ? 34 : q.capacity >= 220000 ? 28 : q.capacity >= 110000 ? 23 : 20;
|
|
return Math.hypot(q.x - p.x, q.y - p.y) < Math.max(minD, qMinD) * 0.82;
|
|
})) 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);
|
|
const baseRegionalCapitalSlots = Math.max(1, Math.min(2, Math.floor(Math.sqrt(Math.max(1, modernCities.length)) / 2)));
|
|
const regionalCapitalSlots = patchMode
|
|
? Math.max(0, Math.min(1, Math.round(baseRegionalCapitalSlots * (1 - topCenterSuppression))))
|
|
: baseRegionalCapitalSlots;
|
|
const topCenterGeoThreshold = 0.80 + topCenterSuppression * 0.16;
|
|
for (const [rank, city] of modernCities.entries()) {
|
|
const i = indexOf(city.x, city.y);
|
|
const st = regionStats.get(city.regionId);
|
|
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 geoTierScore = clamp(
|
|
fieldValue(geoNaturalCentrality, i, 0) * 0.48 +
|
|
fieldValue(geoHabitability, i, 0) * 0.24 +
|
|
fieldValue(geoAccessibility, i, 0) * 0.18 +
|
|
Math.log10((city.capacity || 26000) + 1) / 7 * 0.26
|
|
);
|
|
const slotTopCenter = regionalCapitalSlots > 0 && rank < regionalCapitalSlots;
|
|
const exceptionalPatchCenter = patchMode && geoTierScore > topCenterGeoThreshold && (city.capacity || 0) > 360000;
|
|
const isTopCenter = (!patchMode && slotTopCenter) || exceptionalPatchCenter || (!patchMode && geoTierScore > topCenterGeoThreshold);
|
|
const regionalCapacityThreshold = patchMode ? 300000 : 210000;
|
|
const regionalCentralityThreshold = patchMode ? 340 : 220;
|
|
const isRegionalCapital = isFirstInRegion && (isTopCenter || (city.capacity || 0) > regionalCapacityThreshold || (st?.highCentralityCells || 0) > regionalCentralityThreshold);
|
|
const u = rand(st.seed, city.x, city.y, 9101);
|
|
const v = rand(st.seed, city.x, city.y, 9102);
|
|
const w = rand(st.seed, city.x, city.y, 9103);
|
|
|
|
let rawPop;
|
|
|
|
if (isRegionalCapital) {
|
|
if (isTopCenter) {
|
|
// largest 3M - 11M in full generation; patch candidates are suppressed
|
|
// unless they are exceptionally strong geographic centers.
|
|
rawPop =
|
|
3000000 +
|
|
Math.pow(u, 0.42) * 5200000 +
|
|
Math.pow(v, 3.2) * 2800000;
|
|
if (patchMode) rawPop *= (0.42 + (1 - topCenterSuppression) * 0.28);
|
|
} else {
|
|
// larger 0.25M - 2.5M
|
|
rawPop =
|
|
250000 +
|
|
Math.pow(u, 0.55) * 1450000 +
|
|
Math.pow(v, 2.4) * 900000;
|
|
if (patchMode) rawPop *= 0.72;
|
|
}
|
|
} else {
|
|
// normal 5k - 0.75k
|
|
rawPop =
|
|
52000 +
|
|
Math.pow(u, 0.72) * 520000 +
|
|
Math.pow(v, 3.0) * 320000;
|
|
}
|
|
const capMultiplier = isRegionalCapital ? (isTopCenter ? (patchMode ? 1.22 : 1.66) : (patchMode ? 1.08 : 1.42)) : 1.20;
|
|
const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000;
|
|
const floor = isRegionalCapital ? (isTopCenter ? (patchMode ? 150000 : 210000) : (patchMode ? 90000 : 120000)) : 42000;
|
|
city.population = Math.max(floor, population);
|
|
city.isPrefecturalCapital = isPrefecturalCapital;
|
|
city.isRegionalCapital = isRegionalCapital;
|
|
city.geographicTierScore = Math.round(geoTierScore * 1000) / 1000;
|
|
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 urbanSettlementExclusionRadius(city, tier = "market") {
|
|
const pop = city?.population || 0;
|
|
const base = pop >= 500000 ? 9.5 : pop >= 240000 ? 7.6 : pop >= 110000 ? 6.0 : 4.8;
|
|
return tier === "village" ? base + 2.0 : base;
|
|
}
|
|
const marketsBeforeHierarchyFilter = markets.length;
|
|
const villagesBeforeHierarchyFilter = villages.length;
|
|
markets = markets.filter((m) => {
|
|
const nearestCity = modernCities.reduce((best, city) => {
|
|
const d = Math.hypot(m.x - city.x, m.y - city.y);
|
|
return d < best.d ? { city, d } : best;
|
|
}, { city: null, d: Infinity });
|
|
if (!nearestCity.city) return true;
|
|
return nearestCity.d >= urbanSettlementExclusionRadius(nearestCity.city, "market");
|
|
});
|
|
marketIndex = createPointSpatialIndex(markets, 12);
|
|
villages = villages.filter((v) => {
|
|
const nearestCity = modernCities.reduce((best, city) => {
|
|
const d = Math.hypot(v.x - city.x, v.y - city.y);
|
|
return d < best.d ? { city, d } : best;
|
|
}, { city: null, d: Infinity });
|
|
if (nearestCity.city && nearestCity.d < urbanSettlementExclusionRadius(nearestCity.city, "village")) return false;
|
|
return !marketIndex.hasWithin(v.x, v.y, 3.4);
|
|
});
|
|
marketIndex = createPointSpatialIndex(markets, 12);
|
|
villageIndex = createPointSpatialIndex(villages, 8);
|
|
const cityIndex = createPointSpatialIndex(modernCities, 12);
|
|
villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2));
|
|
const settlementHierarchyDebug = {
|
|
version: "phase2-unified-settlement-hierarchy",
|
|
geographicUrbanAnchors: geographicUrbanAnchors.length,
|
|
marketsBeforeHierarchyFilter,
|
|
marketsAfterHierarchyFilter: markets.length,
|
|
villagesBeforeHierarchyFilter,
|
|
villagesAfterHierarchyFilter: villages.length,
|
|
regionalCapitalSlots,
|
|
};
|
|
markFeatureTiming("settlement-placement");
|
|
|
|
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,
|
|
preliminaryTownInfluence,
|
|
preliminaryVillageInfluence,
|
|
settlementDemand,
|
|
urbanEdge,
|
|
logisticsPreSuitability,
|
|
} = buildSettlementDemandFields({
|
|
sea, agriculture, plain, basinField, coastalLowland, slope, ridgeField,
|
|
modernCities, markets, commercialPorts, villages,
|
|
});
|
|
markFeatureTiming("settlement-demand");
|
|
const transportFields = buildFeatureTransportCostFields({
|
|
seed,
|
|
sea, elevation, slope, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland,
|
|
portSuitability, passSuitability, crossingSuitability, naturalBarrierScore,
|
|
settlementDemand, urbanEdge, logisticsPreSuitability,
|
|
preliminaryTownInfluence, preliminaryVillageInfluence,
|
|
valleySettlement, coastalSettlement, developable,
|
|
});
|
|
markFeatureTiming("transport-cost-fields");
|
|
const cachedInfluenceFromPaths = createPathInfluenceCache(influenceFromPaths);
|
|
const coarseRouteStats = { graphBuilds: 0, attempted: 0, routed: 0, refined: 0, fallback: 0, skipped: 0 };
|
|
const coarseGraphCache = new WeakMap();
|
|
function coarseGraphFor(costField, mode = "national") {
|
|
const scale = mode === "local" ? 5 : mode === "rail" ? 4 : mode === "expressway" ? 4 : 4;
|
|
let byMode = coarseGraphCache.get(costField);
|
|
if (!byMode) {
|
|
byMode = new Map();
|
|
coarseGraphCache.set(costField, byMode);
|
|
}
|
|
const key = `${mode}:${scale}`;
|
|
let graph = byMode.get(key);
|
|
if (!graph) {
|
|
graph = buildCoarseCostGraph({ sea, costField }, { scale });
|
|
byMode.set(key, graph);
|
|
coarseRouteStats.graphBuilds++;
|
|
}
|
|
return graph;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const routeScratch = {
|
|
score: new Float32Array(SIZE),
|
|
cameFrom: new Int32Array(SIZE),
|
|
seen: new Int32Array(SIZE),
|
|
closed: new Int32Array(SIZE),
|
|
epoch: 0,
|
|
};
|
|
|
|
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, cameFrom, seen, closed } = routeScratch;
|
|
let epoch = ++routeScratch.epoch;
|
|
if (routeScratch.epoch > 2000000000) {
|
|
routeScratch.seen.fill(0);
|
|
routeScratch.closed.fill(0);
|
|
routeScratch.epoch = 1;
|
|
epoch = 1;
|
|
}
|
|
const heap = new MinHeap();
|
|
seen[startIndex] = epoch;
|
|
score[startIndex] = 0;
|
|
cameFrom[startIndex] = -1;
|
|
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] === epoch) continue;
|
|
closed[current.i] = epoch;
|
|
const cx = current.i % MAP_W;
|
|
const cy = Math.floor(current.i / MAP_W);
|
|
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] === epoch || 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 = prev % MAP_W;
|
|
const py = Math.floor(prev / MAP_W);
|
|
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 (seen[ni] !== epoch || nd < score[ni]) {
|
|
seen[ni] = epoch;
|
|
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([p % MAP_W, Math.floor(p / MAP_W)]);
|
|
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 densifyPathByCost(path, costField) {
|
|
if (!path || path.length < 2) return path || [];
|
|
const out = [];
|
|
for (let k = 0; k < path.length - 1; k++) {
|
|
const [x0, y0] = path[k];
|
|
const [x1, y1] = path[k + 1];
|
|
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
|
|
for (let s = 0; s <= steps; s++) {
|
|
if (k > 0 && s === 0) continue;
|
|
const t = s / steps;
|
|
const x = Math.round(x0 + (x1 - x0) * t);
|
|
const y = Math.round(y0 + (y1 - y0) * t);
|
|
if (!inside(x, y)) return [];
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || costField?.[i] >= INF) return [];
|
|
if (!out.length || out[out.length - 1][0] !== x || out[out.length - 1][1] !== y) out.push([x, y]);
|
|
}
|
|
}
|
|
return out.length >= 2 ? out : [];
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
if (deduped.length < 2) return path;
|
|
const dense = densifyPathByCost(deduped, costField);
|
|
return dense.length >= 2 ? dense : 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 pathWaterCrossingStats(path) {
|
|
let seaCells = 0;
|
|
let maxSeaRun = 0;
|
|
let currentSeaRun = 0;
|
|
let sampled = 0;
|
|
for (let k = 1; k < (path?.length || 0); k++) {
|
|
const a = path[k - 1];
|
|
const b = path[k];
|
|
if (!a || !b) continue;
|
|
const steps = Math.max(1, Math.ceil(Math.hypot(a[0] - b[0], a[1] - b[1])));
|
|
for (let s = 0; 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);
|
|
sampled++;
|
|
const isSea = !inside(x, y) || sea[indexOf(x, y)];
|
|
if (isSea) {
|
|
seaCells++;
|
|
currentSeaRun++;
|
|
maxSeaRun = Math.max(maxSeaRun, currentSeaRun);
|
|
} else {
|
|
currentSeaRun = 0;
|
|
}
|
|
}
|
|
}
|
|
return { seaCells, maxSeaRun, seaShare: sampled ? seaCells / sampled : 0 };
|
|
}
|
|
|
|
function pathTunnelStats(path) {
|
|
let tunnelCells = 0;
|
|
let maxTunnelRun = 0;
|
|
let currentTunnelRun = 0;
|
|
let sampled = 0;
|
|
for (let k = 1; k < (path?.length || 0); k++) {
|
|
const a = path[k - 1];
|
|
const b = path[k];
|
|
if (!a || !b) continue;
|
|
const steps = Math.max(1, Math.ceil(Math.hypot(a[0] - b[0], a[1] - b[1])));
|
|
for (let s = 0; 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)) continue;
|
|
sampled++;
|
|
const i = indexOf(x, y);
|
|
const isTunnel = !sea[i] && ((elevation[i] >= 0.72 && ridgeField[i] >= 0.34) || naturalBarrierScore[i] >= 0.72);
|
|
if (isTunnel) {
|
|
tunnelCells++;
|
|
currentTunnelRun++;
|
|
maxTunnelRun = Math.max(maxTunnelRun, currentTunnelRun);
|
|
} else {
|
|
currentTunnelRun = 0;
|
|
}
|
|
}
|
|
}
|
|
return { tunnelCells, maxTunnelRun, tunnelShare: sampled ? tunnelCells / sampled : 0 };
|
|
}
|
|
|
|
function routePhysicalAcceptable(path, mode, overrides = {}) {
|
|
if (!path?.length) return false;
|
|
const water = pathWaterCrossingStats(path);
|
|
const tunnel = pathTunnelStats(path);
|
|
const bridgeLimit = overrides.bridgeLimit ?? (mode === "expressway" ? 20 : 10);
|
|
const tunnelLimit = overrides.tunnelLimit ?? (mode === "expressway" ? 10 : mode === "national" ? 10 : 0);
|
|
const maxSeaRun = overrides.maxSeaRun ?? bridgeLimit;
|
|
const maxTunnelRun = overrides.maxTunnelRun ?? tunnelLimit;
|
|
const maxSeaShare = overrides.maxSeaShare ?? (mode === "expressway" ? 0.22 : mode === "rail" ? 0.030 : mode === "national" ? 0.10 : 0.05);
|
|
const maxTunnelShare = overrides.maxTunnelShare ?? (mode === "expressway" ? 0.12 : mode === "national" ? 0.12 : 0);
|
|
if (water.maxSeaRun > maxSeaRun || water.seaShare > maxSeaShare) return false;
|
|
if (tunnel.maxTunnelRun > maxTunnelRun || tunnel.tunnelShare > maxTunnelShare) return false;
|
|
if (water.seaCells > 0) {
|
|
const first = path[0];
|
|
const last = path[path.length - 1];
|
|
const ai = inside(first?.[0], first?.[1]) ? indexOf(first[0], first[1]) : -1;
|
|
const bi = inside(last?.[0], last?.[1]) ? indexOf(last[0], last[1]) : -1;
|
|
const demand = clamp((ai >= 0 ? settlementDemand[ai] || 0 : 0) + (bi >= 0 ? settlementDemand[bi] || 0 : 0));
|
|
const threshold = mode === "local" ? 0.62 : mode === "rail" ? 0.54 : 0.42;
|
|
if (demand < threshold && mode !== "national" && mode !== "expressway") return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function transportRouteAcceptable(path, mode, potentialField, penaltyField = null, overrides = {}) {
|
|
if (!routePhysicalAcceptable(path, mode, overrides)) return false;
|
|
return routeQualityAcceptable(path, {
|
|
sea,
|
|
elevation,
|
|
slope,
|
|
potential: potentialField,
|
|
penalty: penaltyField,
|
|
highElevationThreshold: 0.70,
|
|
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 sampledBarrierBetween(a, b) {
|
|
const steps = Math.max(1, Math.ceil(Math.hypot(a.cx - b.cx, a.cy - b.cy)));
|
|
let sum = 0;
|
|
let n = 0;
|
|
let seaHits = 0;
|
|
for (let s = 0; s <= steps; s++) {
|
|
const t = s / steps;
|
|
const x = Math.round(a.cx + (b.cx - a.cx) * t);
|
|
const y = Math.round(a.cy + (b.cy - a.cy) * t);
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea[i]) { seaHits++; sum += 1.25; n++; continue; }
|
|
sum += clamp((naturalBarrierScore?.[i] || 0) * 0.75 + ridgeField[i] * 0.28 + slope[i] * 0.25 + Math.max(0, elevation[i] - 0.58) * 0.45 - (passSuitability?.[i] || 0) * 0.35);
|
|
n++;
|
|
}
|
|
return n ? clamp(sum / n + seaHits / n) : 1;
|
|
}
|
|
|
|
function canRepairConnection(a, b, mode, d) {
|
|
const demand = clamp((a.potential || 0) * 0.55 + (b.potential || 0) * 0.55 + Math.sqrt(Math.max(0, a.importance + b.importance)) / 11 - d / 180);
|
|
const barrier = sampledBarrierBetween(a, b);
|
|
if (mode === "rail" && demand < 0.45) return false;
|
|
if (mode === "expressway" && demand < 0.55) return false;
|
|
if (mode === "national" && demand < 0.24 && d > 48) return false;
|
|
if (barrier > 0.66 && demand < 0.72) return false;
|
|
if (d > 90 && demand < 0.75) return false;
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
if (!canRepairConnection(ca, cb, mode, d)) continue;
|
|
const barrier = sampledBarrierBetween(ca, cb);
|
|
const score = d / Math.sqrt(ca.importance + cb.importance) + barrier * 22 + (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 ? 13 : 8);
|
|
}
|
|
|
|
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" ? 14 : mode === "rail" ? 20 : 30);
|
|
const maxSettlements = options.maxSettlements ?? (mode === "expressway" ? 16 : mode === "rail" ? 27 : 44);
|
|
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" ? 26 : mode === "rail" ? 38 : 58));
|
|
}
|
|
|
|
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" ? 3 : mode === "rail" ? 2.5 : 2.25);
|
|
const searchPad = options.searchPad ?? Math.ceil(Math.max(16, Math.min(48, d * (mode === "expressway" ? 0.40 : mode === "rail" ? 0.37 : 0.31))));
|
|
const bounds = options.bounds || {
|
|
minX: Math.max(0, Math.min(start.x, target.x) - searchPad),
|
|
maxX: Math.min(MAP_W - 1, Math.max(start.x, target.x) + searchPad),
|
|
minY: Math.max(0, Math.min(start.y, target.y) - searchPad),
|
|
maxY: Math.min(MAP_H - 1, Math.max(start.y, target.y) + searchPad),
|
|
};
|
|
const coarseThreshold = options.coarseThreshold ?? (mode === "local" ? 18 : mode === "rail" ? 22 : mode === "expressway" ? 30 : 20);
|
|
let path = [];
|
|
if (!options.forceFullResolution && d >= coarseThreshold) {
|
|
coarseRouteStats.attempted++;
|
|
const graph = coarseGraphFor(costField, mode);
|
|
const coarse = routeCoarsePath(start, target, graph, { heuristicWeight: mode === "rail" ? 0.72 : 0.86 });
|
|
if (coarse.length >= 2) {
|
|
const refined = refineCoarsePath([[start.x, start.y], ...coarse.slice(1, -1), [target.x, target.y]], costField, {
|
|
sea,
|
|
snapRadius: mode === "local" ? 2 : 1,
|
|
});
|
|
if (refined.length >= 4) {
|
|
path = refined;
|
|
coarseRouteStats.routed++;
|
|
coarseRouteStats.refined++;
|
|
} else {
|
|
coarseRouteStats.skipped++;
|
|
}
|
|
} else {
|
|
coarseRouteStats.skipped++;
|
|
}
|
|
}
|
|
if (!path.length) {
|
|
coarseRouteStats.fallback++;
|
|
path = traceCorridorByCost(
|
|
start,
|
|
(x, y) => Math.hypot(x - target.x, y - target.y) <= snap,
|
|
costField,
|
|
penaltyField || null,
|
|
{
|
|
curvePenalty: options.curvePenalty ?? (mode === "expressway" ? 0.12 : mode === "rail" ? 0.15 : mode === "national" ? 0.070 : 0.040),
|
|
penaltyStrength: options.penaltyStrength ?? (mode === "expressway" ? 1.9 : mode === "rail" ? 1.35 : mode === "national" ? 1.05 : 0.78),
|
|
minGoalDistance: Math.min(8, Math.max(2, d * 0.08)),
|
|
keepRegion: false,
|
|
maxExpanded: Math.min(Math.floor(SIZE * SPEED_TOLERANCE), Math.max(1300, Math.floor(d * d * (mode === "expressway" ? 2.9 : mode === "rail" ? 3.5 : 3.9)))),
|
|
terrainFlowBias: options.terrainFlowBias ?? (mode === "expressway" ? 0.10 : mode === "rail" ? 0.12 : mode === "national" ? 0.24 : 0.30),
|
|
surfaceGrain: options.surfaceGrain ?? (mode === "national" ? 0.030 : mode === "local" ? 0.042 : 0.010),
|
|
bounds,
|
|
goalHint: options.goalHint || target,
|
|
heuristicWeight: options.heuristicWeight ?? (mode === "expressway" ? 0.66 : mode === "rail" ? 0.54 : mode === "national" ? 0.46 : 0.30),
|
|
}
|
|
);
|
|
}
|
|
if (path.length < 4) return [];
|
|
if (options.maxPathLength && pathLengthCells(path) > options.maxPathLength) return [];
|
|
const skipRelax = options.skipRelax ?? (mode === "local" && path.length > 42);
|
|
const relaxed = skipRelax ? path : relaxRouteToTerrain(path, costField, {
|
|
radius: options.relaxRadius ?? (mode === "national" ? 2 : 1),
|
|
lineWeight: options.relaxLineWeight ?? (mode === "national" ? 0.30 : mode === "expressway" ? 0.24 : 0.48),
|
|
grain: options.surfaceGrain ?? 0.020,
|
|
iterations: 1,
|
|
});
|
|
const crossesSea = (candidate) => (candidate || []).some(([x, y]) => !inside(x, y) || sea[indexOf(x, y)] || costField[indexOf(x, y)] >= INF);
|
|
const chosen = relaxed.length >= Math.max(3, path.length * 0.55) ? relaxed : path;
|
|
if (crossesSea(chosen)) return crossesSea(path) ? [] : path;
|
|
if (!routePhysicalAcceptable(chosen, mode, options)) return routePhysicalAcceptable(path, mode, options) ? path : [];
|
|
return chosen;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 = [];
|
|
|
|
function pathEntirelyLand(path) {
|
|
if (!path || path.length < 2) return false;
|
|
for (let k = 1; k < path.length; k++) {
|
|
const a = path[k - 1];
|
|
const b = path[k];
|
|
if (!a || !b) return false;
|
|
const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])));
|
|
for (let s = 0; 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)) return false;
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || transportFields.local[i] >= INF) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function addPremodernRoad(a, b, edgeSet) {
|
|
if (!a || !b) return false;
|
|
const keyA = `${Math.round(a.x)},${Math.round(a.y)}`;
|
|
const keyB = `${Math.round(b.x)},${Math.round(b.y)}`;
|
|
const key = keyA < keyB ? `${keyA}|${keyB}` : `${keyB}|${keyA}`;
|
|
if (edgeSet.has(key)) return false;
|
|
edgeSet.add(key);
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
if (d < 5 || d > 96) return false;
|
|
const path = routeLight(a, b, 2.4, transportFields.local);
|
|
if (path.length <= 2 || !pathEntirelyLand(path)) return false;
|
|
if (!routePhysicalAcceptable(path, 'local', { maxSeaRun: 0, maxSeaShare: 0, maxTunnelRun: 0, maxTunnelShare: 0 })) return false;
|
|
premodernRoads.push(path);
|
|
return true;
|
|
}
|
|
|
|
// Premodern roads are a loose land-only network around the labelled historical
|
|
// places (castles, market/village nodes, ports, crossings), not sea chords.
|
|
{
|
|
const edgeSet = new Set();
|
|
const labelledPremodernNodes = dedupePointCandidates([
|
|
...castles.map((p) => ({ ...p, premodernRole: "castle", premodernWeight: 1.35 })),
|
|
...castleTowns.map((p) => ({ ...p, premodernRole: "castle-town", premodernWeight: 1.25 })),
|
|
...markets.filter((p) => (p.population || 0) >= 2500).map((p) => ({ ...p, premodernRole: "market", premodernWeight: 1.0 + Math.min(0.45, (p.population || 0) / 60000) })),
|
|
...villages.filter((p) => (p.population || 0) >= 4200).map((p) => ({ ...p, premodernRole: "village", premodernWeight: 0.74 })),
|
|
...commercialPorts.map((p) => ({ ...p, premodernRole: "port", premodernWeight: p.portClass === "major" ? 1.22 : 1.0 })),
|
|
...crossings.map((p) => ({ ...p, premodernRole: "crossing", premodernWeight: 0.78 })),
|
|
].filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)]), 5.0)
|
|
.sort((a, b) => (b.premodernWeight || 0) - (a.premodernWeight || 0))
|
|
.slice(0, 54);
|
|
|
|
for (const c of castles) {
|
|
const near = labelledPremodernNodes
|
|
.filter((n) => n !== c && Math.hypot(n.x - c.x, n.y - c.y) <= 88)
|
|
.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) addPremodernRoad(c, n, edgeSet);
|
|
}
|
|
|
|
for (const node of labelledPremodernNodes) {
|
|
if (premodernRoads.length >= 68) break;
|
|
const quota = node.premodernRole === "castle" || node.premodernRole === "castle-town" ? 3 : node.premodernRole === "market" ? 2 : 1;
|
|
const near = labelledPremodernNodes
|
|
.filter((n) => n !== node)
|
|
.map((n) => ({ n, d: Math.hypot(n.x - node.x, n.y - node.y), cost: Math.hypot(n.x - node.x, n.y - node.y) / Math.max(0.45, n.premodernWeight || 0.8) }))
|
|
.filter((e) => e.d >= 7 && e.d <= 82)
|
|
.sort((a, b) => a.cost - b.cost)
|
|
.slice(0, quota);
|
|
for (const { n } of near) {
|
|
if (premodernRoads.length >= 68) break;
|
|
addPremodernRoad(node, n, edgeSet);
|
|
}
|
|
}
|
|
}
|
|
|
|
// National roads, expressways, and rail are generated from unified OD demand.
|
|
// Roads are built by the density-flow portal system below; rail waits until
|
|
// external gateways exist so the node model can include outside-region demand.
|
|
|
|
let railODDebug = null;
|
|
|
|
// Expressways are generated later by the density-flow portal system.
|
|
|
|
// 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 railOD = buildUnifiedRailODNetwork({
|
|
seed,
|
|
sea, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, naturalBarrierScore, passSuitability,
|
|
transportFields, settlementDemand, preliminaryUrbanInfluence, preliminaryTownInfluence, preliminaryVillageInfluence,
|
|
modernCities, markets, ports, commercialPorts, externalGateways, geographicUrbanAnchors,
|
|
regionIdAt, routeBetweenTrafficCandidates, addCorridorInfluencePenalty, transportRouteAcceptable, pruneParallelSameMode, cachedInfluenceFromPaths,
|
|
speedTolerance: SPEED_TOLERANCE,
|
|
});
|
|
railways.push(...railOD.railways);
|
|
branchRailways.push(...railOD.branchRailways);
|
|
railODDebug = railOD.debug;
|
|
markFeatureTiming("rail-od");
|
|
|
|
|
|
const { transportDebugLayers, runLocalAccessPass, stitchRasterNearContacts, stitchLongLocalBranches, sanitizeLocalRoads, downgradeShortNationalRoads, connectAllRoadNetworksFinal } = buildDensityFlowRoadTransportSystem({
|
|
seed,
|
|
sea, elevation, slope, ridgeField, valleyField, coastalLowland, naturalBarrierScore,
|
|
agriculture, basinField, plain, passSuitability, crossingSuitability,
|
|
settlementDemand, preliminaryVillageInfluence, preliminaryTownInfluence,
|
|
logisticsPreSuitability, urbanEdge,
|
|
transportFields, cachedInfluenceFromPaths,
|
|
nationalRoads, minorRoads, railways, externalRoads, externalRailways,
|
|
expressways, externalExpressways, icAccessRoads, interchanges, externalGateways,
|
|
modernCities, markets, villages, ports, commercialPorts, passes, regionStats,
|
|
regionIdAt, inFocusedPrefecture, importantNodesForRegion, dedupePointCandidates,
|
|
routeBetweenTrafficCandidates, traceCorridorByCost, addCorridorInfluencePenalty,
|
|
relaxRouteToTerrain, transportRouteAcceptable, repairTransportConnectivity,
|
|
repairDanglingTransportEndpoints, pruneDanglingTerminalSegments, pruneParallelSameMode,
|
|
});
|
|
markFeatureTiming("road-system");
|
|
|
|
// 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.
|
|
let roadLanduseInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 2.25, "road:landuse:provisional");
|
|
let roadInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 5.0, "road:influence:provisional");
|
|
let roadDensityInfluence = cachedInfluenceFromPaths([...nationalRoads, ...ringRoads, ...externalRoads], 9.0, "road:density:provisional");
|
|
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 false;
|
|
const key = `${x},${y}`;
|
|
if (usedStationKeys.has(key)) return false;
|
|
usedStationKeys.add(key);
|
|
stations.push({ x, y, kind, score, regionId: regionIdAt(x, y) });
|
|
return true;
|
|
}
|
|
function stationIntervalForCell(i) {
|
|
const urbanDensity = preliminaryUrbanInfluence[i] || 0;
|
|
const ruralDensity = Math.max(preliminaryTownInfluence[i] || 0, preliminaryVillageInfluence[i] || 0);
|
|
return urbanDensity > 0.50 ? 6 : ruralDensity > 0.24 ? 12 : 20;
|
|
}
|
|
function shouldPlaceRailStation(x, y, i) {
|
|
const nearCity = (preliminaryUrbanInfluence[i] || 0) > 0.12 || cityIndex.hasWithin(x, y, 4.8);
|
|
const nearTown = (preliminaryTownInfluence[i] || 0) > 0.13 || marketIndex.hasWithin(x, y, 4.2);
|
|
const nearVillage = (preliminaryVillageInfluence[i] || 0) > 0.20 || villageIndex.hasWithin(x, y, 3.4);
|
|
const lowland = plain[i] > 0.16 || coastalLowland[i] > 0.16 || basinField[i] > 0.20 || valleyField[i] > 0.22;
|
|
const terrainOk = slope[i] < 0.42 && ridgeField[i] < 0.60 && elevation[i] < 0.76;
|
|
return terrainOk && lowland && (nearCity || nearTown || nearVillage);
|
|
}
|
|
for (const city of modernCities) addStation(city.x, city.y, city.isPrefecturalCapital || city.isRegionalCapital ? "Major Station" : "Station", 1.5);
|
|
for (const path of [...railways, ...branchRailways]) {
|
|
let lastStation = null;
|
|
for (const p of samplePath(path, 5)) {
|
|
const x = Math.round(p.x);
|
|
const y = Math.round(p.y);
|
|
if (!inside(x, y)) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || !shouldPlaceRailStation(x, y, i)) continue;
|
|
const interval = stationIntervalForCell(i);
|
|
if (lastStation && Math.hypot(x - lastStation.x, y - lastStation.y) < interval) continue;
|
|
if (distanceToNearest(stations, x, y) < Math.max(4.5, interval * 0.38)) continue;
|
|
if (addStation(x, y, "Station", 0.8)) lastStation = { x, y };
|
|
}
|
|
}
|
|
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);
|
|
markFeatureTiming("transport-influence-stations");
|
|
|
|
// --- 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);
|
|
|
|
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 = cityIndex.hasWithin(x, 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, 62);
|
|
const localPenalty = cachedInfluenceFromPaths(minorRoads, 4, "final-local:minor");
|
|
runLocalAccessPass({ candidates, accessInfluence, localPenalty, maxAdded: 42, maxLength: 78, 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, 78);
|
|
runLocalAccessPass({
|
|
candidates,
|
|
accessInfluence: roadInfluenceNow,
|
|
localPenalty,
|
|
maxAdded: 32,
|
|
minSpacing: 3.0,
|
|
maxLength: 64,
|
|
debugMode: "rural-mesh",
|
|
from: "rural-settlement",
|
|
to: "local-network",
|
|
targetPredicate: (x, y, i) => roadInfluenceNow[i] > 0.18 || localPenalty[i] > 0.07,
|
|
});
|
|
}
|
|
addFinalLocalAccessForUnservedSettlements();
|
|
addRuralRoadMeshConnectors();
|
|
transportDebugLayers.contactStitches = stitchRasterNearContacts();
|
|
transportDebugLayers.longLocalStitches = stitchLongLocalBranches();
|
|
transportDebugLayers.shortNationalDowngradeFinal = downgradeShortNationalRoads(18, 10);
|
|
transportDebugLayers.localSanitizationFinal = sanitizeLocalRoads();
|
|
transportDebugLayers.contactStitchesAfterConnectivity = stitchRasterNearContacts();
|
|
// Keep this as the final road topology operation. Later sanitization can cut
|
|
// the short connectors that intentionally merge isolated components.
|
|
transportDebugLayers.finalRoadNetworkConnectivity = connectAllRoadNetworksFinal(46);
|
|
markFeatureTiming("local-road-cleanup");
|
|
|
|
function dedupeTransportPathSet(paths, options = {}) {
|
|
const before = paths.length;
|
|
const minLength = options.minLength ?? 0;
|
|
const minPoints = options.minPoints ?? 2;
|
|
const sampleStep = Math.max(1, options.sampleStep ?? 1);
|
|
const seen = new Set();
|
|
const kept = [];
|
|
let removedDuplicates = 0;
|
|
let removedTooShort = 0;
|
|
for (const path of paths) {
|
|
if (!path || path.length < minPoints) { removedTooShort++; continue; }
|
|
const cleaned = [];
|
|
for (const pt of path) {
|
|
if (!pt || pt.length < 2) continue;
|
|
const x = Math.round(pt[0]);
|
|
const y = Math.round(pt[1]);
|
|
if (!cleaned.length || cleaned[cleaned.length - 1][0] !== x || cleaned[cleaned.length - 1][1] !== y) cleaned.push([x, y]);
|
|
}
|
|
if (cleaned.length < minPoints || pathLengthCells(cleaned) < minLength) { removedTooShort++; continue; }
|
|
const sample = (candidate) => candidate
|
|
.map((pt, idx) => (idx % sampleStep === 0 || idx === candidate.length - 1) ? `${pt[0]},${pt[1]}` : '')
|
|
.filter(Boolean)
|
|
.join('|');
|
|
const forward = sample(cleaned);
|
|
const backward = sample([...cleaned].reverse());
|
|
const sig = forward < backward ? forward : backward;
|
|
if (seen.has(sig)) { removedDuplicates++; continue; }
|
|
seen.add(sig);
|
|
kept.push(cleaned);
|
|
}
|
|
paths.length = 0;
|
|
paths.push(...kept);
|
|
return { before, after: kept.length, removedDuplicates, removedTooShort };
|
|
}
|
|
|
|
function pruneTransportPathSet(paths, minLength = 0, minKeep = 0) {
|
|
const ranked = (paths || [])
|
|
.map((path) => ({ path, len: pathLengthCells(path) }))
|
|
.filter((row) => row.path?.length >= 2)
|
|
.sort((a, b) => b.len - a.len);
|
|
const kept = [];
|
|
let pruned = 0;
|
|
for (const row of ranked) {
|
|
if (row.len >= minLength || kept.length < minKeep) kept.push(row.path);
|
|
else pruned++;
|
|
}
|
|
paths.length = 0;
|
|
paths.push(...kept);
|
|
return { before: ranked.length, after: kept.length, pruned, minLength, minKeep };
|
|
}
|
|
|
|
function downgradeBranchNationalSpurs(maxLength = 30, importantRadius = 6.5, junctionRadius = 2.6) {
|
|
const importantNodes = [
|
|
...modernCities.filter((p) => p.isPrefecturalCapital || p.isRegionalCapital || (p.population || 0) >= 90000),
|
|
...ports.filter((p) => p.portClass === 'major' || p.portClass === 'regional'),
|
|
...externalGateways,
|
|
];
|
|
const otherTrunks = [...externalRoads, ...expressways, ...externalExpressways];
|
|
const kept = [];
|
|
const downgraded = [];
|
|
function endpointNearImportant(endpoint) {
|
|
return importantNodes.some((node) => Math.hypot(node.x - endpoint[0], node.y - endpoint[1]) <= importantRadius);
|
|
}
|
|
function endpointTouchesOtherTrunk(endpoint, currentPath) {
|
|
const [ex, ey] = endpoint;
|
|
for (const path of [...nationalRoads, ...otherTrunks]) {
|
|
if (path === currentPath) continue;
|
|
for (const pt of path) {
|
|
if (Math.hypot(pt[0] - ex, pt[1] - ey) <= junctionRadius) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
for (const path of nationalRoads) {
|
|
if (!path || path.length < 2) continue;
|
|
const len = pathLengthCells(path);
|
|
const a = path[0];
|
|
const b = path[path.length - 1];
|
|
const aImportant = endpointNearImportant(a);
|
|
const bImportant = endpointNearImportant(b);
|
|
const aTouch = endpointTouchesOtherTrunk(a, path);
|
|
const bTouch = endpointTouchesOtherTrunk(b, path);
|
|
const oneSidedBranch = (aTouch && !bTouch) || (!aTouch && bTouch);
|
|
const importantEndpoints = Number(aImportant) + Number(bImportant);
|
|
if (oneSidedBranch && len <= maxLength && importantEndpoints <= 1 && !(aImportant && bImportant)) downgraded.push(path);
|
|
else kept.push(path);
|
|
}
|
|
nationalRoads.length = 0;
|
|
nationalRoads.push(...kept);
|
|
minorRoads.push(...downgraded);
|
|
return { threshold: maxLength, downgraded: downgraded.length, kept: kept.length };
|
|
}
|
|
|
|
transportDebugLayers.postConnectivityDedup = {
|
|
national: dedupeTransportPathSet(nationalRoads, { minLength: 0.95, sampleStep: 1 }),
|
|
externalRoads: dedupeTransportPathSet(externalRoads, { minLength: 1.5, sampleStep: 1 }),
|
|
minor: dedupeTransportPathSet(minorRoads, { minLength: 0.95, sampleStep: 2 }),
|
|
expressways: dedupeTransportPathSet(expressways, { minLength: 4, sampleStep: 2 }),
|
|
externalExpressways: dedupeTransportPathSet(externalExpressways, { minLength: 4, sampleStep: 2 }),
|
|
};
|
|
transportDebugLayers.postConnectivityShortNationalDowngrade = downgradeShortNationalRoads(32, 7);
|
|
transportDebugLayers.postConnectivityBranchNationalDowngrade = downgradeBranchNationalSpurs(42);
|
|
transportDebugLayers.postConnectivityNationalPrune = pruneTransportPathSet(nationalRoads, 18, 7);
|
|
transportDebugLayers.postConnectivityMinorDedup = dedupeTransportPathSet(minorRoads, { minLength: 0.95, sampleStep: 2 });
|
|
transportDebugLayers.postConnectivityLocalSanitization = sanitizeLocalRoads();
|
|
|
|
function smoothRasterPath(path, passes = 1) {
|
|
let current = (path || []).map(([x, y]) => [Math.round(x), Math.round(y)]);
|
|
for (let pass = 0; pass < passes; pass++) {
|
|
if (current.length < 3) break;
|
|
const next = [current[0]];
|
|
for (let i = 1; i < current.length - 1; i++) {
|
|
const [ax, ay] = current[i - 1];
|
|
const [bx, by] = current[i];
|
|
const [cx, cy] = current[i + 1];
|
|
const nx = Math.round((ax + bx * 2 + cx) / 4);
|
|
const ny = Math.round((ay + by * 2 + cy) / 4);
|
|
if (next[next.length - 1][0] !== nx || next[next.length - 1][1] !== ny) next.push([nx, ny]);
|
|
}
|
|
next.push(current[current.length - 1]);
|
|
current = next;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function directBridgeTunnelConnector(a, b, maxSegment = 20) {
|
|
if (!a || !b) return [];
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
const steps = Math.max(2, Math.ceil(d));
|
|
const path = [];
|
|
for (let s = 0; s <= steps; s++) {
|
|
const t = s / steps;
|
|
const x = Math.round(a.x + (b.x - a.x) * t);
|
|
const y = Math.round(a.y + (b.y - a.y) * t);
|
|
if (!path.length || path[path.length - 1][0] !== x || path[path.length - 1][1] !== y) path.push([x, y]);
|
|
}
|
|
return routePhysicalAcceptable(path, 'expressway', { maxSeaRun: maxSegment, maxTunnelRun: Math.min(10, maxSegment), maxSeaShare: 0.70, maxTunnelShare: 0.18 }) ? path : [];
|
|
}
|
|
|
|
function ensureInterchangePoint(x, y, kind = 'Interchange', source = 'expressway-endpoint') {
|
|
x = Math.round(x); y = Math.round(y);
|
|
if (!inside(x, y) || sea[indexOf(x, y)]) return false;
|
|
if ((interchanges || []).some((p) => Math.hypot(p.x - x, p.y - y) <= 2.5)) return false;
|
|
interchanges.push({ x, y, kind, score: 1, source });
|
|
return true;
|
|
}
|
|
|
|
function ensureExpresswayEndpointsHaveICs() {
|
|
let added = 0;
|
|
for (const path of [...expressways, ...externalExpressways]) {
|
|
if (!path || path.length < 2) continue;
|
|
const first = path[0];
|
|
const last = path[path.length - 1];
|
|
if (ensureInterchangePoint(first[0], first[1], 'Terminal IC', 'expressway-terminal-endpoint')) added++;
|
|
if (ensureInterchangePoint(last[0], last[1], 'Terminal IC', 'expressway-terminal-endpoint')) added++;
|
|
}
|
|
return { added, total: interchanges.length, strategy: "terminal ICs restored" };
|
|
}
|
|
|
|
function ensureNationalRoadCoverageForTowns(minPopulation = 5000) {
|
|
const nationalInfluence = cachedInfluenceFromPaths([...nationalRoads, ...externalRoads], 3.2, 'final-national:coverage');
|
|
const towns = dedupePointCandidates([
|
|
...modernCities.filter((p) => (p.population || 0) >= minPopulation),
|
|
...markets.filter((p) => (p.population || 0) >= minPopulation),
|
|
...ports.filter((p) => (p.population || 0) >= minPopulation || p.portClass === 'regional' || p.portClass === 'major'),
|
|
], 3.5);
|
|
const debug = { minPopulation, checked: towns.length, added: 0 };
|
|
const nationalPenalty = cachedInfluenceFromPaths([...nationalRoads, ...externalRoads], 6, 'final-national:penalty');
|
|
for (const town of towns) {
|
|
const ti = indexOf(town.x, town.y);
|
|
if ((nationalInfluence[ti] || 0) > 0.24) continue;
|
|
const candidates = dedupePointCandidates([...modernCities, ...externalGateways, ...ports, ...markets], 6)
|
|
.filter((q) => q !== town)
|
|
.map((q) => ({ q, d: Math.hypot(q.x - town.x, q.y - town.y) }))
|
|
.filter((row) => row.d >= 10 && row.d <= 90)
|
|
.sort((a, b) => a.d - b.d);
|
|
let addedPath = null;
|
|
for (const cand of candidates.slice(0, 5)) {
|
|
const path = routeBetweenTrafficCandidates(town, cand.q, 'national', transportFields.national, nationalPenalty, {
|
|
curvePenalty: 0.055,
|
|
penaltyStrength: 0.60,
|
|
terrainFlowBias: 0.18,
|
|
surfaceGrain: 0.018,
|
|
relaxRadius: 2,
|
|
relaxLineWeight: 0.35,
|
|
maxPathLength: cand.d * 2.8 + 40,
|
|
maxSeaRun: 10,
|
|
maxTunnelRun: 10,
|
|
});
|
|
if (path.length >= 4 && transportRouteAcceptable(path, 'national', transportFields.national, nationalPenalty, { minLength: 4, maxLength: cand.d * 3.0 + 48, maxSeaRun: 10, maxTunnelRun: 10 })) {
|
|
addedPath = path;
|
|
break;
|
|
}
|
|
}
|
|
if (addedPath) {
|
|
nationalRoads.push(addedPath);
|
|
debug.added++;
|
|
}
|
|
}
|
|
return debug;
|
|
}
|
|
|
|
function expresswayFringeAnchorForCity(city) {
|
|
if (!city || !inside(city.x, city.y)) return null;
|
|
const inner = Math.max(10, Math.round((city.coreRadius || 4) + 7));
|
|
const outer = Math.max(inner + 8, Math.round(Math.min(34, (city.urbanRadius || 13) * 2.0)));
|
|
let best = null;
|
|
for (let dy = -outer; dy <= outer; dy++) {
|
|
for (let dx = -outer; dx <= outer; dx++) {
|
|
const x = Math.round(city.x + dx);
|
|
const y = Math.round(city.y + dy);
|
|
if (!inside(x, y)) continue;
|
|
const d = Math.hypot(dx, dy);
|
|
if (d < inner || d > outer) continue;
|
|
const i = indexOf(x, y);
|
|
if (sea[i] || elevation[i] >= 0.70 || transportFields.expressway[i] >= INF) continue;
|
|
const radialBand = clamp(1 - Math.abs(d - (inner + outer) * 0.54) / Math.max(2, (outer - inner) * 0.55));
|
|
const corePenalty = clamp(settlementDemand[i] * 0.78 + preliminaryTownInfluence[i] * 0.58 + preliminaryVillageInfluence[i] * 0.42);
|
|
const score =
|
|
(transportFields.expresswayPotential[i] || 0) * 1.18 +
|
|
(urbanEdge[i] || 0) * 0.54 +
|
|
(logisticsPreSuitability[i] || 0) * 0.40 +
|
|
(plain[i] || 0) * 0.20 +
|
|
(basinField[i] || 0) * 0.12 +
|
|
radialBand * 0.32 -
|
|
corePenalty * 1.18 -
|
|
(slope[i] || 0) * 1.05 -
|
|
(ridgeField[i] || 0) * 0.82 +
|
|
hash2(x, y, seed + 24891 + city.x * 11 + city.y * 17) * 0.04;
|
|
if (!best || score > best.score) best = { x, y, score, city, population: city.population || 0, regionId: regionIdAt(x, y), role: 'expressway-fringe-anchor' };
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function nearPath(path, p, radius) {
|
|
return (path || []).some(([x, y]) => Math.hypot(x - p.x, y - p.y) <= radius);
|
|
}
|
|
|
|
function ensureMajorCityExpresswayConnections(minPopulation = 100000) {
|
|
const majorCities = modernCities
|
|
.filter((c) => (c.population || 0) >= minPopulation)
|
|
.sort((a, b) => (b.population || 0) - (a.population || 0));
|
|
const debug = { minPopulation, cityCount: majorCities.length, added: 0, pairs: [], strategy: 'fringe-anchor-only' };
|
|
if (majorCities.length < 2) return debug;
|
|
|
|
const anchors = majorCities
|
|
.map((city) => expresswayFringeAnchorForCity(city))
|
|
.filter(Boolean)
|
|
.sort((a, b) => (b.population || 0) - (a.population || 0));
|
|
debug.anchorCount = anchors.length;
|
|
if (anchors.length < 2) return debug;
|
|
|
|
const permissiveExpresswayCost = new Float32Array(SIZE);
|
|
for (let i = 0; i < SIZE; i++) {
|
|
if (sea[i] || elevation[i] >= 0.70) {
|
|
permissiveExpresswayCost[i] = INF;
|
|
continue;
|
|
}
|
|
const urbanCore = clamp(settlementDemand[i] * 0.78 + preliminaryTownInfluence[i] * 0.62 + preliminaryVillageInfluence[i] * 0.46);
|
|
const terrainBase = Number.isFinite(transportFields.expressway[i]) && transportFields.expressway[i] < INF
|
|
? transportFields.expressway[i]
|
|
: 0.40 + Math.max(0, slope[i] - 0.18) * 1.25 + Math.max(0, elevation[i] - 0.58) * 2.1 + ridgeField[i] * 0.80;
|
|
permissiveExpresswayCost[i] = terrainBase + urbanCore * 2.6 + Math.max(0, elevation[i] - 0.62) * 3.0;
|
|
}
|
|
|
|
const componentOfAnchors = () => {
|
|
const parent = new Map();
|
|
const keyOf = (anchor) => anchor.city?.name || `${anchor.x},${anchor.y}`;
|
|
function find(k) {
|
|
const p = parent.get(k);
|
|
if (p === k) return k;
|
|
const r = find(p);
|
|
parent.set(k, r);
|
|
return r;
|
|
}
|
|
function union(a, b) {
|
|
const ra = find(a); const rb = find(b);
|
|
if (ra !== rb) parent.set(ra, rb);
|
|
}
|
|
for (const anchor of anchors) parent.set(keyOf(anchor), keyOf(anchor));
|
|
for (const path of [...expressways, ...externalExpressways]) {
|
|
const near = anchors.filter((anchor) => nearPath(path, anchor, 8.5));
|
|
if (near.length >= 2) {
|
|
const k0 = keyOf(near[0]);
|
|
for (let i = 1; i < near.length; i++) union(k0, keyOf(near[i]));
|
|
}
|
|
}
|
|
return new Map(anchors.map((anchor) => [keyOf(anchor), find(keyOf(anchor))]));
|
|
};
|
|
|
|
const keyOf = (anchor) => anchor.city?.name || `${anchor.x},${anchor.y}`;
|
|
for (let iter = 0; iter < anchors.length * 2; iter++) {
|
|
const comps = componentOfAnchors();
|
|
const reps = new Set(comps.values());
|
|
if (reps.size <= 1) break;
|
|
let best = null;
|
|
for (const a of anchors) {
|
|
for (const b of anchors) {
|
|
if (a === b || comps.get(keyOf(a)) === comps.get(keyOf(b))) continue;
|
|
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
if (d < 32 || d > 220) continue;
|
|
const pop = Math.sqrt((a.population || minPopulation) * (b.population || minPopulation));
|
|
const score = d / Math.max(1, Math.log2(pop)) - ((a.score || 0) + (b.score || 0)) * 0.18;
|
|
if (!best || score < best.score) best = { a, b, score, d };
|
|
}
|
|
}
|
|
if (!best) break;
|
|
let path = routeBetweenTrafficCandidates(best.a, best.b, 'expressway', permissiveExpresswayCost, null, {
|
|
curvePenalty: 0.125,
|
|
penaltyStrength: 0.95,
|
|
terrainFlowBias: 0.09,
|
|
surfaceGrain: 0.006,
|
|
relaxRadius: 1,
|
|
relaxLineWeight: 0.20,
|
|
maxPathLength: best.d * 2.70 + 84,
|
|
snapRadius: 2.0,
|
|
searchPad: Math.ceil(Math.max(42, Math.min(112, best.d * 0.58 + 12))),
|
|
heuristicWeight: 0.72,
|
|
maxSeaRun: 0,
|
|
maxTunnelRun: 10,
|
|
maxSeaShare: 0,
|
|
maxTunnelShare: 0.12,
|
|
});
|
|
if (path.length >= 4 && transportRouteAcceptable(path, 'expressway', transportFields.expresswayPotential, null, { minLength: 16, maxLength: best.d * 2.85 + 96, maxSeaRun: 0, maxTunnelRun: 10, maxSeaShare: 0, maxTunnelShare: 0.12 })) {
|
|
path = smoothRasterPath(path, 1);
|
|
if (transportRouteAcceptable(path, 'expressway', transportFields.expresswayPotential, null, { minLength: 16, maxLength: best.d * 2.95 + 104, maxSeaRun: 0, maxTunnelRun: 10, maxSeaShare: 0, maxTunnelShare: 0.12 })) {
|
|
expressways.push(path);
|
|
debug.added++;
|
|
debug.pairs.push({ from: best.a.city?.name, to: best.b.city?.name, distance: Math.round(best.d), length: Math.round(pathLengthCells(path)) });
|
|
continue;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
debug.finalExpresswayCount = expressways.length;
|
|
return debug;
|
|
}
|
|
|
|
transportDebugLayers.postConnectivityNationalCoverage = ensureNationalRoadCoverageForTowns(6000);
|
|
transportDebugLayers.postConnectivityMajorCityExpresswayGuarantee = ensureMajorCityExpresswayConnections(110000);
|
|
transportDebugLayers.postConnectivityExpresswayDedup = dedupeTransportPathSet(expressways, { minLength: 8, sampleStep: 2 });
|
|
transportDebugLayers.postConnectivityExpresswayEndpointICs = ensureExpresswayEndpointsHaveICs();
|
|
markFeatureTiming("post-connectivity-guarantees");
|
|
|
|
const finalRoadInfluencePaths = [...nationalRoads, ...ringRoads, ...externalRoads];
|
|
roadLanduseInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 2.25, "road:landuse:final");
|
|
roadInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 5.0, "road:influence:final");
|
|
roadDensityInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 9.0, "road:density:final");
|
|
transportDebugLayers.finalRoadInfluenceRefresh = {
|
|
roadPaths: finalRoadInfluencePaths.length,
|
|
nationalRoads: nationalRoads.length,
|
|
externalRoads: externalRoads.length,
|
|
ringRoads: ringRoads.length,
|
|
};
|
|
|
|
const { landuse, populationDensity } = buildFeatureLanduse({
|
|
seed,
|
|
elevation, slope, sea, river, floodplain, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland,
|
|
developable, ruralSuitability, valleySettlement, coastalSettlement,
|
|
modernCities, logisticsParks,
|
|
roadLanduseInfluence, roadInfluence, roadDensityInfluence, railInfluence2, stationInfluence, stationDensityInfluence, villageInfluence,
|
|
cityInfluence, coreInfluence, oldTownInfluence, townInfluence, industrialInfluence, logisticsInfluence,
|
|
});
|
|
markFeatureTiming("landuse");
|
|
const transportDebug = {
|
|
humanStageVersion: "v2-sparse-raster",
|
|
featureTimings,
|
|
coarseRouting: coarseRouteStats,
|
|
aStarRoutes: 0,
|
|
regionalNodeCount: [...regionStats.keys()].reduce((sum, regionId) => sum + importantNodesForRegion(regionId).length, 0),
|
|
fieldCorridorTransport: false,
|
|
unifiedODTransport: true,
|
|
settlementHierarchy: settlementHierarchyDebug,
|
|
railODTransport: railODDebug,
|
|
expresswayFieldCorridors: expressways.length,
|
|
railFieldCorridors: railways.length,
|
|
nationalRoadFieldCorridors: nationalRoads.length,
|
|
localRoadFieldCorridors: minorRoads.length,
|
|
layers: transportDebugLayers,
|
|
nationalRoadPopulationCoverage: 0,
|
|
nationalRoadUncoveredPopulation: 0,
|
|
};
|
|
|
|
return {
|
|
ports,
|
|
geographicUrbanAnchors,
|
|
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,
|
|
};
|
|
}
|