Compare commits

..

6 commits

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

File diff suppressed because it is too large Load diff

View file

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

100
app.js
View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

99
mapTransport.js Normal file
View file

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

182
names.js
View file

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

View file

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

View file

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

266
test.js
View file

@ -1,7 +1,6 @@
import { generateMap, MAP_W, MAP_H, indexOf } from "./mapGenerator.js"; import { generateMap, MAP_W, MAP_H, indexOf } from "./mapGenerator.js";
import { import {
CUSTOM_NAMES, CUSTOM_NAME_LIST,
FORCED_NAMES,
NAME_KANJI_POOLS, NAME_KANJI_POOLS,
NAME_PARTS, NAME_PARTS,
NAME_PROBABILITIES, NAME_PROBABILITIES,
@ -9,17 +8,22 @@ import {
NAME_TEMPLATE_WEIGHTS, NAME_TEMPLATE_WEIGHTS,
generateEntityName, generateEntityName,
generateTemplateName, generateTemplateName,
validateGeneratedName,
} from "./names.js"; } from "./names.js";
const result = document.getElementById("result"); const result = document.getElementById("result");
const logLines = []; const logLines = [];
let failed = 0; let failed = 0;
const [namesSource, mapGeneratorSource, mapOutputSource, rendererSource, testSource] = await Promise.all([ const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rendererSource, appSource, mapPipelineSource, mapAdminStageSource, testSource] = await Promise.all([
fetch("./names.js").then((response) => response.text()), fetch("./names.js").then((response) => response.text()),
fetch("./mapGenerator.js").then((response) => response.text()), fetch("./mapGenerator.js").then((response) => response.text()),
fetch("./mapOutput.js").then((response) => response.text()), fetch("./mapOutput.js").then((response) => response.text()),
fetch("./mapTerrain.js").then((response) => response.text()),
fetch("./renderer.js").then((response) => response.text()), fetch("./renderer.js").then((response) => response.text()),
fetch("./app.js").then((response) => response.text()),
fetch("./mapPipeline.js").then((response) => response.text()),
fetch("./mapAdminStage.js").then((response) => response.text()),
fetch("./test.js").then((response) => response.text()), fetch("./test.js").then((response) => response.text()),
]); ]);
@ -193,10 +197,13 @@ function regionalComponentMetrics(map) {
const ids = new Set([...map.prefectureRegionId].filter((id, i) => id >= 0 && !map.sea[i])); const ids = new Set([...map.prefectureRegionId].filter((id, i) => id >= 0 && !map.sea[i]));
const seen = new Uint8Array(MAP_W * MAP_H); const seen = new Uint8Array(MAP_W * MAP_H);
let maxComponents = 0; let maxComponents = 0;
const areas = [];
for (const id of ids) { for (const id of ids) {
seen.fill(0); seen.fill(0);
let comps = 0; let comps = 0;
let area = 0;
for (let i = 0; i < map.prefectureRegionId.length; i++) { for (let i = 0; i < map.prefectureRegionId.length; i++) {
if (!map.sea[i] && map.prefectureRegionId[i] === id) area++;
if (seen[i] || map.sea[i] || map.prefectureRegionId[i] !== id) continue; if (seen[i] || map.sea[i] || map.prefectureRegionId[i] !== id) continue;
comps++; comps++;
const queue = [i]; const queue = [i];
@ -217,8 +224,167 @@ function regionalComponentMetrics(map) {
} }
} }
maxComponents = Math.max(maxComponents, comps); maxComponents = Math.max(maxComponents, comps);
areas.push(area);
} }
return { regionCount: ids.size, maxComponents }; areas.sort((a, b) => a - b);
const medianArea = areas.length ? areas[Math.floor(areas.length / 2)] : 0;
const minArea = areas.length ? areas[0] : 0;
const tinyCount = areas.filter((area) => area < 520).length;
return { regionCount: ids.size, maxComponents, minArea, medianArea, tinyCount, areas };
}
function regionalBorderMetrics(map) {
let invalidSame = 0;
let expected = 0;
const ids = map.prefectureRegionId;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (map.sea[i] || ids[i] < 0) continue;
if (x + 1 < MAP_W) {
const ni = indexOf(x + 1, y);
if (!map.sea[ni] && ids[ni] >= 0 && ids[ni] !== ids[i]) expected++;
}
if (y + 1 < MAP_H) {
const ni = indexOf(x, y + 1);
if (!map.sea[ni] && ids[ni] >= 0 && ids[ni] !== ids[i]) expected++;
}
}
}
for (const segment of map.regionalPrefectureBorders || []) {
const [[x1, y1], [x2, y2]] = segment;
let a = -1, b = -1;
if (x1 === x2) {
const x = x1;
const y = Math.min(y1, y2);
if (x > 0 && x < MAP_W && y >= 0 && y < MAP_H) {
a = ids[indexOf(x - 1, y)];
b = ids[indexOf(x, y)];
}
} else if (y1 === y2) {
const x = Math.min(x1, x2);
const y = y1;
if (y > 0 && y < MAP_H && x >= 0 && x < MAP_W) {
a = ids[indexOf(x, y - 1)];
b = ids[indexOf(x, y)];
}
}
if (a < 0 || b < 0 || a === b) invalidSame++;
}
return { invalidSame, expected, actual: (map.regionalPrefectureBorders || []).length };
}
function borderHierarchyViolations(map) {
let prefectureCutsMunicipality = 0;
let municipalityCutsCompartment = 0;
const compId = map.naturalCompartmentId;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (map.sea[i]) continue;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
if (nx >= MAP_W || ny >= MAP_H) continue;
const ni = indexOf(nx, ny);
if (map.sea[ni]) continue;
if (map.prefectureRegionId[i] !== map.prefectureRegionId[ni] && map.adminId[i] === map.adminId[ni]) prefectureCutsMunicipality++;
if (map.adminId[i] !== map.adminId[ni] && compId && compId[i] === compId[ni]) municipalityCutsCompartment++;
}
}
}
return { prefectureCutsMunicipality, municipalityCutsCompartment };
}
function longStraightLowBarrierSegments(map, segments, minRun = 18) {
const runs = new Map();
for (const seg of segments || []) {
const [[x1, y1], [x2, y2]] = seg;
const vertical = x1 === x2;
const key = vertical ? `v:${x1}` : `h:${y1}`;
const pos = vertical ? Math.min(y1, y2) : Math.min(x1, x2);
if (!runs.has(key)) runs.set(key, []);
runs.get(key).push({ pos, seg });
}
let bad = 0;
for (const rows of runs.values()) {
rows.sort((a, b) => a.pos - b.pos);
let start = 0;
for (let k = 1; k <= rows.length; k++) {
if (k < rows.length && rows[k].pos <= rows[k - 1].pos + 1.01) continue;
const run = rows.slice(start, k);
if (run.length >= minRun) {
const natural = run.reduce((sum, row) => {
const [[x1, y1], [x2, y2]] = row.seg;
const sx = Math.min(Math.max(0, Math.floor((x1 + x2) / 2)), MAP_W - 1);
const sy = Math.min(Math.max(0, Math.floor((y1 + y2) / 2)), MAP_H - 1);
return sum + (map.naturalBarrierScore?.[indexOf(sx, sy)] || 0);
}, 0) / run.length;
if (natural < 0.18) bad++;
}
start = k;
}
}
return bad;
}
function regionalEnclaveCount(map) {
const ids = map.prefectureRegionId;
const regionIds = [...new Set([...ids].filter((id, i) => id >= 0 && !map.sea[i]))];
let enclaves = 0;
for (const id of regionIds) {
const seen = new Uint8Array(MAP_W * MAP_H);
for (let i = 0; i < ids.length; i++) {
if (seen[i] || map.sea[i] || ids[i] !== id) continue;
const queue = [i];
const cells = [];
let touchesOutside = false;
const neighbors = new Set();
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
cells.push(cur);
const x = cur % MAP_W;
const y = Math.floor(cur / MAP_W);
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
const ni = indexOf(nx, ny);
if (map.sea[ni]) {
touchesOutside = true;
continue;
}
if (ids[ni] !== id && ids[ni] >= 0) neighbors.add(ids[ni]);
if (seen[ni] || ids[ni] !== id) continue;
seen[ni] = 1;
queue.push(ni);
}
}
if (cells.length && !touchesOutside && neighbors.size === 1) enclaves++;
}
}
return enclaves;
}
function cityMunicipalityAreaMetrics(map) {
const areaById = new Map();
for (let i = 0; i < map.adminId.length; i++) {
const id = map.adminId[i];
if (id >= 0 && !map.sea[i]) areaById.set(id, (areaById.get(id) || 0) + 1);
}
const rows = (map.modernCities || [])
.filter((city) => (city.population || 0) >= 95000 && city.insidePrefecture && !map.sea[indexOf(city.x, city.y)])
.map((city) => {
const admin = map.adminId[indexOf(city.x, city.y)];
const minArea = Math.min((city.population || 0) >= 450000 ? 780 : 520, Math.max(130, 95 + Math.sqrt(city.population || 0) * 0.72 + (city.urbanFootprintCells || 0) * 0.42));
return { city, admin, area: areaById.get(admin) || 0, minArea };
});
return { rows, tooSmall: rows.filter((row) => row.area + 1e-6 < row.minArea * 0.82) };
}
function prefectureNameForTest(map, i) {
const id = map.prefectureRegionId?.[i] ?? -1;
return (map.prefectureRegions || []).find((region) => region.id === id)?.name || "";
} }
function meanField(map, fieldName, predicate) { function meanField(map, fieldName, predicate) {
@ -531,16 +697,30 @@ try {
assert(map.terrainDebug.depositionLowlandArea > 0, "depositional lowland area is tracked"); assert(map.terrainDebug.depositionLowlandArea > 0, "depositional lowland area is tracked");
assert(["east-west", "north-south", "diagonal"].includes(map.terrainTemplate.coastAxis) && map.terrainTemplate.coastSides?.length === 2, "paired coast template parameters are exposed"); assert(["east-west", "north-south", "diagonal"].includes(map.terrainTemplate.coastAxis) && map.terrainTemplate.coastSides?.length === 2, "paired coast template parameters are exposed");
assert(map.settlementCluster.length === size, "settlement cluster field matches map size"); assert(map.settlementCluster.length === size, "settlement cluster field matches map size");
assert(Array.isArray(map.harborWorks), "harbor arrays exist");
assert(map.prefectureRegionId.length === size && Array.isArray(map.regionalPrefectureBorders), "neighbor prefecture regions exist"); assert(map.prefectureRegionId.length === size && Array.isArray(map.regionalPrefectureBorders), "neighbor prefecture regions exist");
assert(map.regionalDebug && Number.isFinite(map.regionalDebug.regionalChangedAfterNaturalPartition), "regional changed-cell debug exists"); assert(map.prefectureRegionId.length === size, "final prefecture id field matches map size");
assert(map.regionalDebug.regionalChangedAfterNaturalPartition > 0, "regional natural partition changes region cells"); assert(map.naturalCompartmentId?.length === size && Array.isArray(map.naturalCompartments), "shared natural compartments are exposed");
assert(map.regionalDebug.regionalBorderCountBefore > 0 && map.regionalDebug.regionalBorderCountAfter > 0, "regional border counts are tracked"); assert(map.adminDebug?.naturalCompartmentCount > 0 && map.adminDebug?.finalMunicipalityCount > 0, "natural compartments are generated before municipalities");
assert(map.regionalDebug.regionalNaturalBarrierAverageAfter >= map.regionalDebug.regionalNaturalBarrierAverageBefore - 0.08, "regional border natural-barrier affinity does not degrade meaningfully"); assert(map.regionalDebug?.prefecturesGeneratedAfterMunicipalities === true && map.regionalDebug?.prefectureSource === "municipality-boundary-union", "prefectures are generated from final municipalities");
assert(map.regionalDebug.regionalVoronoiLikeRateAfter <= map.regionalDebug.regionalVoronoiLikeRateBefore + 0.22, "regional weak Voronoi-like border rate stays bounded"); assert(regionalMetrics.regionCount >= 4 && regionalMetrics.maxComponents === 1, "each non-sea prefecture region is connected after repair");
assert(map.regionalDebug.compartmentCount > 0 && map.regionalDebug.changedAfterCompartmentAssignment > 0, "regional compartment assignment debug is available"); assert(regionalEnclaveCount(map) === 0, "final prefecture regions contain no one-region enclosed enclaves");
assert(Number.isFinite(map.regionalDebug.borderNaturalBarrierAverage) && Number.isFinite(map.regionalDebug.voronoiLikeRate), "regional natural-border aliases are exposed"); const regionalBorders = regionalBorderMetrics(map);
assert(regionalMetrics.regionCount >= 4 && regionalMetrics.maxComponents <= 5, "regional prefecture regions remain connected enough for display"); const hierarchyViolations = borderHierarchyViolations(map);
assert(hierarchyViolations.prefectureCutsMunicipality === 0, "no prefecture border cuts through a municipality");
assert(hierarchyViolations.municipalityCutsCompartment === 0, "no municipality border cuts through a natural compartment");
assert(regionalBorders.invalidSame === 0 && regionalBorders.actual === regionalBorders.expected, "rendered prefecture borders separate different final prefecture ids only");
assert(map.regionalDebug.finalRegionalPrefectureBorderCount === map.regionalPrefectureBorders.length, "regional prefecture borders are final output borders");
assert(!mapTerrainSource.includes("generateRegionalPrefectures") && !mapPipelineSource.includes("prefectureRegionId, sea") && mapAdminStageSource.includes("generatePrefecturesFromMunicipalities"), "administrative order is natural compartments to municipalities to prefectures");
assert(![mapTerrainSource, mapPipelineSource, mapAdminStageSource, mapOutputSource, rendererSource, appSource].some((source) => /displayRegionId|adminPrefectureRegionId/.test(source)), "prefecture pipeline does not use display/admin-prefecture id aliases");
assert(!("maritimePrefectureBorders" in map), "maritime prefecture borders are not emitted");
assert(!mapOutputSource.includes("maritimePrefectureBorders") && !rendererSource.includes("maritimePrefectureBorders"), "maritime prefecture borders are not generated or rendered");
assert(rendererSource.includes("drawPrefectureRegionFill") && rendererSource.includes("map.prefectureRegionId"), "prefecture fill renderer uses final prefecture id source");
assert(regionalMetrics.tinyCount <= Math.max(1, Math.floor(regionalMetrics.regionCount * 0.12)) && regionalMetrics.medianArea >= 1200 && regionalMetrics.minArea >= 520, "regional prefectures avoid excessive tiny slivers");
assert(Array.isArray(map.prefectureRegions) && map.prefectureRegions.length === regionalMetrics.regionCount, "prefecture region metadata exists for every region");
assert(map.prefectureRegions.every((region) => region.name && Number.isFinite(region.x) && Number.isFinite(region.y) && region.area > 0 && map.prefectureRegionId[indexOf(region.x, region.y)] === region.id), "every prefecture region has a name and valid label point");
assert(map.regionalDebug.finalRegionalMaxAreaShare < 0.38, "no single prefecture dominates regional land area");
assert(longStraightLowBarrierSegments(map, map.regionalPrefectureBorders, 22) === 0, "prefecture borders avoid long straight low-barrier cuts");
assert(longStraightLowBarrierSegments(map, map.adminBorders, 20) === 0, "municipality borders avoid long straight low-barrier cuts");
assert(Array.isArray(map.tributaryRivers) && Array.isArray(map.smallStreams), "river hierarchy arrays exist"); assert(Array.isArray(map.tributaryRivers) && Array.isArray(map.smallStreams), "river hierarchy arrays exist");
assert(Array.isArray(map.icAccessRoads), "IC access road array exists"); assert(Array.isArray(map.icAccessRoads), "IC access road array exists");
assert(Array.isArray(map.satelliteCities), "satelliteCities is an array"); assert(Array.isArray(map.satelliteCities), "satelliteCities is an array");
@ -592,7 +772,6 @@ try {
assert(terrainMetrics.depositionTargetMean >= terrainMetrics.depositionOtherMean * 0.85, "deposition favors rivers, basins, and coastal lowlands"); assert(terrainMetrics.depositionTargetMean >= terrainMetrics.depositionOtherMean * 0.85, "deposition favors rivers, basins, and coastal lowlands");
assert(terrainMetrics.riverValleyMean > terrainMetrics.nonRiverValleyMean * 1.08, "river cells overlap valley fields more than random non-river cells"); assert(terrainMetrics.riverValleyMean > terrainMetrics.nonRiverValleyMean * 1.08, "river cells overlap valley fields more than random non-river cells");
assert(terrainMetrics.alluvialMax > 0 || terrainMetrics.deltaMax > 0, "alluvial fan or delta fields are active"); assert(terrainMetrics.alluvialMax > 0 || terrainMetrics.deltaMax > 0, "alluvial fan or delta fields are active");
assert(map.harborWorks.length <= map.ports.length, "harbor works are attached to ports");
assert(map.ports.some((p) => p.portClass === "major"), "at least one major port is classified"); assert(map.ports.some((p) => p.portClass === "major"), "at least one major port is classified");
assert(map.ports.every((p) => ["major", "regional", "fishing", "lake"].includes(p.portClass)), "ports have explicit classes"); assert(map.ports.every((p) => ["major", "regional", "fishing", "lake"].includes(p.portClass)), "ports have explicit classes");
assert(map.externalGateways.length > 0, "external gateways exist"); assert(map.externalGateways.length > 0, "external gateways exist");
@ -628,6 +807,7 @@ try {
assert(maxPopulation / Math.max(1, minPopulation) > 3, "city populations vary strongly"); assert(maxPopulation / Math.max(1, minPopulation) > 3, "city populations vary strongly");
assert(map.totalPopulation >= cityPopulations.reduce((sum, value) => sum + value, 0), "total population includes city and satellite populations"); assert(map.totalPopulation >= cityPopulations.reduce((sum, value) => sum + value, 0), "total population includes city and satellite populations");
assert(Math.max(...map.populationDensity) > 0.9, "population density is normalized and populated"); assert(Math.max(...map.populationDensity) > 0.9, "population density is normalized and populated");
assert([...map.populationDensity].some((value, i) => value === 0 && !map.sea[i]), "valid land cells can retain exactly zero population density");
assert(elevationStdDev > 0.18, "terrain relief has sufficient contrast"); assert(elevationStdDev > 0.18, "terrain relief has sufficient contrast");
assert(maxCoastalElevationStep < 0.12, "coastline and elevation do not create cliff artifacts"); assert(maxCoastalElevationStep < 0.12, "coastline and elevation do not create cliff artifacts");
assert(railExpressHighMountainCells === 0, "railways and expressways avoid huge mountain cells"); assert(railExpressHighMountainCells === 0, "railways and expressways avoid huge mountain cells");
@ -643,6 +823,11 @@ try {
assert(map.adminCenters.some((item) => item.representativeFeatureName), "municipal centers keep representative feature metadata when available"); assert(map.adminCenters.some((item) => item.representativeFeatureName), "municipal centers keep representative feature metadata when available");
assert(map.adminCenters.every((item) => Array.from(String(item.name)).length >= 2), "municipal center names are not one-character labels"); assert(map.adminCenters.every((item) => Array.from(String(item.name)).length >= 2), "municipal center names are not one-character labels");
assert(map.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), "municipal names avoid dangling one-kanji suffix fallback"); assert(map.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), "municipal names avoid dangling one-kanji suffix fallback");
const cityMunicipalityMetrics = cityMunicipalityAreaMetrics(map);
assert(cityMunicipalityMetrics.tooSmall.length === 0, "meaningful populated cities keep population-scaled municipality area");
const hoverCell = map.prefectureRegions.find((region) => region.area > 0);
assert(hoverCell && prefectureNameForTest(map, indexOf(hoverCell.x, hoverCell.y)) === hoverCell.name && appSource.includes("Prefecture:") && appSource.includes("prefectureNameForCell"), "tooltip can resolve prefecture name for a hovered cell");
assert(appSource.includes("maxLeft") && appSource.includes("maxTop"), "tooltip position is clamped inside map container");
const adminNameDuplicateRatio = map.adminCenters.length ? 1 - new Set(map.adminCenters.map((item) => item.name)).size / map.adminCenters.length : 0; const adminNameDuplicateRatio = map.adminCenters.length ? 1 - new Set(map.adminCenters.map((item) => item.name)).size / map.adminCenters.length : 0;
assert(adminNameDuplicateRatio < 0.22, "duplicate municipal names stay low"); assert(adminNameDuplicateRatio < 0.22, "duplicate municipal names stay low");
assert(map.adminDebug.naturalCompartmentCount > adminMetrics.municipalityCount, "natural compartments are finer than municipalities"); assert(map.adminDebug.naturalCompartmentCount > adminMetrics.municipalityCount, "natural compartments are finer than municipalities");
@ -656,6 +841,7 @@ try {
assert(map.entitiesForNames.every((item) => typeof item.name === "string"), "nameable entity names are strings"); assert(map.entitiesForNames.every((item) => typeof item.name === "string"), "nameable entity names are strings");
assert(map.entitiesForNames.every((item) => !String(item.name).includes("\uFFFD")), "generated names contain no replacement characters"); assert(map.entitiesForNames.every((item) => !String(item.name).includes("\uFFFD")), "generated names contain no replacement characters");
assert(map.entitiesForNames.every((item) => Array.from(String(item.name)).length >= 2), "one-character generated names are prevented"); assert(map.entitiesForNames.every((item) => Array.from(String(item.name)).length >= 2), "one-character generated names are prevented");
assert(map.entitiesForNames.every((item) => validateGeneratedName(item.name, { allowAsciiDiagnostic: true }).valid), "generated names pass place-name validation");
assert(map.entitiesForNames.every((item) => String(item.name).length > 0), "empty generated names are prevented"); assert(map.entitiesForNames.every((item) => String(item.name).length > 0), "empty generated names are prevented");
assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low"); assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low");
assert(map.nameDebug && Array.isArray(map.nameDebug.emptyPools), "nameDebug reports empty pools"); assert(map.nameDebug && Array.isArray(map.nameDebug.emptyPools), "nameDebug reports empty pools");
@ -666,14 +852,12 @@ try {
assert(map.nameDebug.selectedTemplateCounts && typeof map.nameDebug.selectedTemplateCounts === "object", "nameDebug selectedTemplateCounts exists"); assert(map.nameDebug.selectedTemplateCounts && typeof map.nameDebug.selectedTemplateCounts === "object", "nameDebug selectedTemplateCounts exists");
assert(map.nameDebug.selectedContextCounts && typeof map.nameDebug.selectedContextCounts === "object", "nameDebug selectedContextCounts exists"); assert(map.nameDebug.selectedContextCounts && typeof map.nameDebug.selectedContextCounts === "object", "nameDebug selectedContextCounts exists");
assert( assert(
map.nameDebug.generatedNamesUsed + map.nameDebug.customNamesUsed + map.nameDebug.forcedNamesUsed + map.nameDebug.fallbackAttempts === namedEntityCount, map.nameDebug.generatedNamesUsed + map.nameDebug.customNameListUsed + map.nameDebug.fallbackAttempts === namedEntityCount,
"nameDebug accounting covers named entities" "nameDebug accounting covers named entities"
); );
assert(activePoolChars.size > 0 || generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "template generation depends on active pools"); assert(activePoolChars.size > 0 || generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "template generation depends on active pools");
assert(villageClusterMean > 0.16, "villages prefer clustered valley, basin, coastal, and agricultural cells"); assert(villageClusterMean > 0.16, "villages prefer clustered valley, basin, coastal, and agricultural cells");
assert(saneEndpointRatio >= 0.76, "transport endpoints stay near meaningful generated nodes"); assert(saneEndpointRatio >= 0.76, "transport endpoints stay near meaningful generated nodes");
assert(Object.keys(CUSTOM_NAMES).length === 0 || NAME_PROBABILITIES.customName < 1, "CUSTOM_NAMES are probabilistic by default");
assert( assert(
map.adminCenters.length !== other.adminCenters.length || map.adminCenters.length !== other.adminCenters.length ||
map.villages.length !== other.villages.length || map.villages.length !== other.villages.length ||
@ -687,7 +871,10 @@ try {
assert(JSON.stringify(againA.entitiesForNames.map((item) => [item.id, item.name])) === JSON.stringify(againB.entitiesForNames.map((item) => [item.id, item.name])), "generated names are deterministic for the same seed"); assert(JSON.stringify(againA.entitiesForNames.map((item) => [item.id, item.name])) === JSON.stringify(againB.entitiesForNames.map((item) => [item.id, item.name])), "generated names are deterministic for the same seed");
assert(JSON.stringify(againA.adminCenters.map((item) => [item.id, item.x, item.y, item.name])) === JSON.stringify(againB.adminCenters.map((item) => [item.id, item.x, item.y, item.name])), "municipal centers are deterministic for the same seed"); assert(JSON.stringify(againA.adminCenters.map((item) => [item.id, item.x, item.y, item.name])) === JSON.stringify(againB.adminCenters.map((item) => [item.id, item.x, item.y, item.name])), "municipal centers are deterministic for the same seed");
assert(JSON.stringify([...againA.adminId]) === JSON.stringify([...againB.adminId]), "municipal adminId snapping is deterministic for the same seed"); assert(JSON.stringify([...againA.adminId]) === JSON.stringify([...againB.adminId]), "municipal adminId snapping is deterministic for the same seed");
assert(JSON.stringify([...againA.naturalCompartmentId]) === JSON.stringify([...againB.naturalCompartmentId]), "natural compartments are deterministic for the same seed");
assert(JSON.stringify([...againA.prefectureRegionId]) === JSON.stringify([...againB.prefectureRegionId]), "regional prefecture ids are deterministic for the same seed"); assert(JSON.stringify([...againA.prefectureRegionId]) === JSON.stringify([...againB.prefectureRegionId]), "regional prefecture ids are deterministic for the same seed");
assert(JSON.stringify([...againA.municipalityToPrefectureId]) === JSON.stringify([...againB.municipalityToPrefectureId]), "municipality-to-prefecture ids are deterministic for the same seed");
assert(JSON.stringify(againA.regionalPrefectureBorders) === JSON.stringify(againB.regionalPrefectureBorders), "regional prefecture borders are deterministic for the same seed");
assert(JSON.stringify(againA.adminDebug) === JSON.stringify(againB.adminDebug), "admin debug metrics are deterministic for the same seed"); assert(JSON.stringify(againA.adminDebug) === JSON.stringify(againB.adminDebug), "admin debug metrics are deterministic for the same seed");
assert(JSON.stringify(againA.regionalDebug) === JSON.stringify(againB.regionalDebug), "regional debug metrics are deterministic for the same seed"); assert(JSON.stringify(againA.regionalDebug) === JSON.stringify(againB.regionalDebug), "regional debug metrics are deterministic for the same seed");
assert(JSON.stringify(againA.transportDebug) === JSON.stringify(againB.transportDebug), "transport debug metrics are deterministic for the same seed"); assert(JSON.stringify(againA.transportDebug) === JSON.stringify(againB.transportDebug), "transport debug metrics are deterministic for the same seed");
@ -702,6 +889,9 @@ try {
const capitalNames = capitalNameMaps.map((seeded) => seeded.prefecturalCapital?.name).filter(Boolean); const capitalNames = capitalNameMaps.map((seeded) => seeded.prefecturalCapital?.name).filter(Boolean);
assert(new Set(capitalNames).size > 1, "prefectural capital names vary across seeds"); assert(new Set(capitalNames).size > 1, "prefectural capital names vary across seeds");
assert(capitalNames.some((name) => name !== blockedCapitalName), "prefectural capital is not always the repeated custom name"); assert(capitalNames.some((name) => name !== blockedCapitalName), "prefectural capital is not always the repeated custom name");
const semeMap = generateMap(8363712);
const semeAdmin = (semeMap.adminCenters || []).find((center) => center.canonicalSettlementName);
assert(!semeAdmin || semeAdmin.name === semeAdmin.canonicalSettlementName, "seed 8363712: municipality label uses canonical settlement name");
for (const [n, seeded] of capitalNameMaps.entries()) { for (const [n, seeded] of capitalNameMaps.entries()) {
const seedValue = [114514, 12345, 54321, 777, 999][n]; const seedValue = [114514, 12345, 54321, 777, 999][n];
const metrics = terrainCoreMetrics(seeded); const metrics = terrainCoreMetrics(seeded);
@ -753,28 +943,13 @@ try {
.map((seeded) => ({ deposition: seeded.terrainTemplate.deposition, lowlandRatio: terrainCoreMetrics(seeded).lowlandRatio })) .map((seeded) => ({ deposition: seeded.terrainTemplate.deposition, lowlandRatio: terrainCoreMetrics(seeded).lowlandRatio }))
.sort((a, b) => a.deposition - b.deposition); .sort((a, b) => a.deposition - b.deposition);
assert(byDeposition[byDeposition.length - 1].lowlandRatio >= byDeposition[0].lowlandRatio * 0.72, "higher-deposition templates generally preserve or expand lowland area"); assert(byDeposition[byDeposition.length - 1].lowlandRatio >= byDeposition[0].lowlandRatio * 0.72, "higher-deposition templates generally preserve or expand lowland area");
CUSTOM_NAME_LIST.push("L1", "L2");
const listedCustomNames = Array.from({ length: 50 }, (_, n) => generateEntityName(9200 + n, `list-probe-${n}`, { x: 10, y: 10, kind: "Probe" }, {}, new Set()));
const listedCustomHits = listedCustomNames.filter((name) => name === "L1" || name === "L2").length;
assert(NAME_PROBABILITIES.customNameList > 0 && listedCustomHits > 0 && listedCustomHits < listedCustomNames.length, "CUSTOM_NAME_LIST supplies probabilistic selected place names");
CUSTOM_NAME_LIST.length = 0;
CUSTOM_NAMES["city-0"] = "C1"; assert(!validateGeneratedName("青青").valid && validateGeneratedName("青青").reason === "repeatedKanji", "place names reject repeated kanji");
const customSameA = generateMap(321);
const customSameB = generateMap(321);
const sameTargetA = customSameA.modernCities.find((item) => item.id === "city-0");
const sameTargetB = customSameB.modernCities.find((item) => item.id === "city-0");
const customSeedMaps = [301, 302, 303, 304, 305, 306, 307, 308].map((seedValue) => generateMap(seedValue));
const customTargets = customSeedMaps.map((seeded) => seeded.modernCities.find((item) => item.id === "city-0")).filter(Boolean);
const customHits = customTargets.filter((item) => item.name === "C1").length;
assert(sameTargetA?.name === sameTargetB?.name, "custom-name probability is deterministic for the same seed");
assert(!FORCED_NAMES["city-0"] && customTargets.length > 0 && customHits < customTargets.length, "CUSTOM_NAMES do not force every seed");
delete CUSTOM_NAMES["city-0"];
CUSTOM_NAMES["custom-probe"] = "C1";
const directCustomNames = Array.from({ length: 40 }, (_, n) => generateEntityName(9000 + n, "custom-probe", { x: 10, y: 10, kind: "Probe" }, {}, new Set()));
const directCustomHits = directCustomNames.filter((name) => name === "C1").length;
assert(NAME_PROBABILITIES.customName > 0 && NAME_PROBABILITIES.customName < 1 && directCustomHits > 0 && directCustomHits < directCustomNames.length, "CUSTOM_NAMES are probabilistic suggestions");
delete CUSTOM_NAMES["custom-probe"];
FORCED_NAMES["forced-probe"] = "F1";
assert(generateEntityName(123, "forced-probe", { x: 8, y: 8, kind: "Probe" }, {}, new Set(), map.nameDebug) === "F1", "FORCED_NAMES always apply");
delete FORCED_NAMES["forced-probe"];
for (const seed of [101, 2026, 54321]) { for (const seed of [101, 2026, 54321]) {
const seeded = generateMap(seed); const seeded = generateMap(seed);
@ -787,10 +962,14 @@ try {
assert(invalidRegionCells === 0, `seed ${seed}: every regional land cell has a valid regionId`); assert(invalidRegionCells === 0, `seed ${seed}: every regional land cell has a valid regionId`);
assert(seeded.adminBorders.length > 0, `seed ${seed}: municipal borders exist`); assert(seeded.adminBorders.length > 0, `seed ${seed}: municipal borders exist`);
assert(seeded.regionalPrefectureBorders.length > 0, `seed ${seed}: regional prefecture borders exist`); assert(seeded.regionalPrefectureBorders.length > 0, `seed ${seed}: regional prefecture borders exist`);
assert(seeded.regionalDebug?.regionalChangedAfterNaturalPartition > 0, `seed ${seed}: regional natural partition changes cells`); assert(seeded.regionalDebug?.prefecturesGeneratedAfterMunicipalities === true, `seed ${seed}: prefectures are generated after municipalities`);
assert(seeded.regionalDebug.regionalNaturalBarrierAverageAfter >= seeded.regionalDebug.regionalNaturalBarrierAverageBefore - 0.10, `seed ${seed}: regional border natural affinity is stable`); assert(seeded.regionalDebug?.prefectureSource === "municipality-boundary-union", `seed ${seed}: prefecture borders are municipality boundary unions`);
assert(seeded.regionalDebug.regionalVoronoiLikeRateAfter <= seeded.regionalDebug.regionalVoronoiLikeRateBefore + 0.25, `seed ${seed}: regional Voronoi-like rate is bounded`); assert(seededRegional.maxComponents === 1, `seed ${seed}: every final regional prefecture is connected`);
assert(seededRegional.maxComponents <= 5, `seed ${seed}: regional regions remain connected enough`); assert(regionalEnclaveCount(seeded) === 0, `seed ${seed}: final regional prefectures have no one-region enclosed enclaves`);
assert(regionalBorderMetrics(seeded).invalidSame === 0, `seed ${seed}: regional borders separate final prefecture ids`);
const seededHierarchy = borderHierarchyViolations(seeded);
assert(seededHierarchy.prefectureCutsMunicipality === 0, `seed ${seed}: prefecture borders do not cut municipalities`);
assert(seededHierarchy.municipalityCutsCompartment === 0, `seed ${seed}: municipal borders do not cut natural compartments`);
assert(seeded.adminDebug && seeded.adminDebug.compartmentCount > 0, `seed ${seed}: natural compartments are built`); assert(seeded.adminDebug && seeded.adminDebug.compartmentCount > 0, `seed ${seed}: natural compartments are built`);
assert(seeded.adminDebug.naturalCompartmentCount > metrics.municipalityCount, `seed ${seed}: compartments are finer than municipalities`); assert(seeded.adminDebug.naturalCompartmentCount > metrics.municipalityCount, `seed ${seed}: compartments are finer than municipalities`);
assert(seeded.adminDebug.targetMunicipalityCount >= 20 && seeded.adminDebug.actualMunicipalityCount >= 18, `seed ${seed}: municipality count is dense enough`); assert(seeded.adminDebug.targetMunicipalityCount >= 20 && seeded.adminDebug.actualMunicipalityCount >= 18, `seed ${seed}: municipality count is dense enough`);
@ -798,8 +977,7 @@ try {
assert(seeded.adminDebug.finalTinyMunicipalityCount <= Math.max(3, Math.ceil(seeded.adminDebug.finalMunicipalityCount * 0.18)), `seed ${seed}: tiny final municipalities are limited`); assert(seeded.adminDebug.finalTinyMunicipalityCount <= Math.max(3, Math.ceil(seeded.adminDebug.finalMunicipalityCount * 0.18)), `seed ${seed}: tiny final municipalities are limited`);
assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0, `seed ${seed}: compartment assignment is not a no-op`); assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0, `seed ${seed}: compartment assignment is not a no-op`);
assert(Array.isArray(seeded.adminDebug.compartmentBorders) && seeded.adminDebug.compartmentBorders.length > seeded.adminBorders.length, `seed ${seed}: compartment border debug exists`); assert(Array.isArray(seeded.adminDebug.compartmentBorders) && seeded.adminDebug.compartmentBorders.length > seeded.adminBorders.length, `seed ${seed}: compartment border debug exists`);
assert(seeded.regionalDebug?.changedAfterCompartmentAssignment > 0, `seed ${seed}: regional compartment assignment changes cells`); assert(seeded.regionalDebug?.municipalityGraphNodeCount >= metrics.municipalityCount, `seed ${seed}: prefecture graph is based on municipalities`);
assert(seeded.regionalDebug?.borderNaturalBarrierAverage > 0.12, `seed ${seed}: regional borders have natural barrier affinity`);
assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0 || seeded.adminDebug.changedAfterLandscapePartition > 0 || seeded.adminDebug.changedAfterSnap > 0, `seed ${seed}: municipal compartment or terrain passes change cells`); assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0 || seeded.adminDebug.changedAfterLandscapePartition > 0 || seeded.adminDebug.changedAfterSnap > 0, `seed ${seed}: municipal compartment or terrain passes change cells`);
assert(seededSatellites.largeTooSmall.length === 0, `seed ${seed}: large satellites are not tiny independent municipalities`); assert(seededSatellites.largeTooSmall.length === 0, `seed ${seed}: large satellites are not tiny independent municipalities`);
assert(seededSatellites.independent.length < 3 || seededSatellites.small.length / seededSatellites.independent.length <= 0.35, `seed ${seed}: tiny satellite municipalities remain uncommon`); assert(seededSatellites.independent.length < 3 || seededSatellites.small.length / seededSatellites.independent.length <= 0.35, `seed ${seed}: tiny satellite municipalities remain uncommon`);