Compare commits

...

8 commits

Author SHA1 Message Date
3f2be96395 vector-styled 2026-05-22 14:13:35 +09:00
e48fae5509 before tweaking 2026-05-22 13:57:52 +09:00
da9d8ef904 ? 2026-05-22 02:11:18 +09:00
3ac2c116bd name tweak 2026-05-22 01:30:37 +09:00
3fe9c31453 tweak 2026-05-21 22:03:14 +09:00
6c10d5d70e more name 2026-05-21 20:31:40 +09:00
b707dadec9 tweak 2026-05-21 13:20:19 +09:00
f420a3f8f3 tweak 2026-05-21 03:13:39 +09:00
21 changed files with 7120 additions and 3678 deletions

View file

@ -1,6 +1,4 @@
import { MinHeap } from "./graph.js";
import { INF, MAP_H, MAP_W, SIZE, clamp, indexOf, inside, xyOf } from "./grid.js";
import { weightedScore } from "./scoring.js";
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, indexOf, inside, weightedScore, xyOf } from "./mapUtils.js";
function neighbors8(x, y) {
const out = [];
@ -158,12 +156,31 @@ export function lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask,
}
}
export function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320) {
export function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320, options = {}) {
const area = new Map();
const pop = new Map();
const adjacency = new Map();
const cityMunicipalities = new Set();
for (const city of modernCities || []) if (inside(city.x, city.y)) cityMunicipalities.add(adminId[indexOf(city.x, city.y)]);
for (const city of modernCities || []) {
if (!inside(city.x, city.y)) continue;
const id = adminId[indexOf(city.x, city.y)];
if (id < 0) continue;
if (options.protectAllModernCities !== false || city.isPrefecturalCapital || (city.population || 0) >= (options.majorCityPopulationThreshold || 120000)) cityMunicipalities.add(id);
}
for (const point of options.protectedPoints || []) {
if (!point || !inside(point.x, point.y)) continue;
const id = adminId[indexOf(point.x, point.y)];
if (id >= 0) cityMunicipalities.add(id);
}
const satelliteByAdmin = new Map();
for (const sat of options.satelliteCities || []) {
if (!sat || !inside(sat.x, sat.y)) continue;
const id = adminId[indexOf(sat.x, sat.y)];
if (id < 0) continue;
if (!satelliteByAdmin.has(id)) satelliteByAdmin.set(id, []);
satelliteByAdmin.get(id).push(sat);
}
const satelliteStats = options.satelliteStats || null;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
@ -189,16 +206,35 @@ export function mergeTinyMunicipalities(adminId, prefectureMask, sea, population
for (const [id, cells] of area) {
const score = cells + (pop.get(id) || 0) * 16;
if (cells >= minArea || cityMunicipalities.has(id)) continue;
const satellites = satelliteByAdmin.get(id) || [];
const protectedSatellite = satellites.some((sat) => {
const minSatelliteArea = sat.satelliteMinArea || options.satelliteMinArea || 110;
return sat.municipalityClass === "independentSatelliteMunicipality" && (
cells >= minSatelliteArea ||
(sat.population || 0) >= (options.satelliteIndependentPopulationThreshold || 60000) ||
(sat.distinctUrbanComponentArea || 0) >= 80 ||
sat.separatedByBarrier
);
});
if (protectedSatellite) continue;
let bestNeighbor = -1;
let bestScore = -1;
for (const [key, border] of adjacency) {
const [a, b] = key.split(":").map(Number);
if (a !== id && b !== id) continue;
const other = a === id ? b : a;
const candidate = border * 3 + (area.get(other) || 0) * 0.012 + (pop.get(other) || 0) * 0.24;
const parentBias = satellites.some((sat) => inside(sat.parentX ?? -1, sat.parentY ?? -1) && adminId[indexOf(sat.parentX, sat.parentY)] === other) ? 26 : 0;
const ruralBias = satellites.some((sat) => sat.municipalityClass === "smallTownAttachedToRuralMunicipality") ? Math.min(12, (area.get(other) || 0) * 0.01) : 0;
const candidate = border * 3 + (area.get(other) || 0) * 0.012 + (pop.get(other) || 0) * 0.24 + parentBias + ruralBias;
if (candidate > bestScore) { bestScore = candidate; bestNeighbor = other; }
}
if (bestNeighbor >= 0 && (area.get(bestNeighbor) || 0) >= score * 0.35) mergeTarget.set(id, bestNeighbor);
if (bestNeighbor >= 0 && (area.get(bestNeighbor) || 0) >= score * 0.35) {
mergeTarget.set(id, bestNeighbor);
if (satelliteStats && satellites.length) {
satelliteStats.satelliteMunicipalitiesMerged += satellites.length;
for (const sat of satellites) sat.mergedMunicipalityTarget = bestNeighbor;
}
}
}
if (mergeTarget.size === 0) return;
for (let i = 0; i < SIZE; i++) if (mergeTarget.has(adminId[i])) adminId[i] = mergeTarget.get(adminId[i]);
@ -467,6 +503,254 @@ function classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyFie
return 11;
}
export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null) {
const score = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i]) continue;
const [x, y] = xyOf(i);
let coastEdge = 0;
for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coastEdge = 1;
const urbanContinuity = populationDensity && landuse ? urbanBoundaryPenalty(i, populationDensity, landuse) : 0;
const majorRiver = clamp(Math.max(river[i] - 0.34, 0) * 1.95 + Math.max(flowAccum[i] - 0.42, 0) * 0.82);
const ridgeDivide = clamp(ridgeField[i] * 1.65 + Math.max(0, elevation[i] - 0.52) * ridgeField[i] * 1.05);
const crest = crestCrossingScore ? crestCrossingScore[i] : clamp(Math.max(0, elevation[i] - 0.55) * ridgeField[i] * 1.4 + slope[i] * ridgeField[i] * 0.8);
const basinRim = basinField ? clamp(Math.max(0, basinField[i] - 0.32) * Math.max(0, slope[i] - 0.18) * 1.15 + Math.max(0, ridgeField[i] - 0.34) * basinField[i] * 0.62) : 0;
const foothillBreak = clamp(Math.max(0, slope[i] - 0.30) * Math.max(ridgeField[i], Math.max(0, elevation[i] - 0.48)) * 0.82);
const livingCorridor = clamp((plain?.[i] || 0) * 0.34 + (agriculture?.[i] || 0) * 0.26 + valleyField[i] * (majorRiver > 0.34 ? 0.10 : 0.46) + coastalLowland[i] * 0.18);
score[i] = clamp(
ridgeDivide * 0.92 +
crest * 0.72 +
majorRiver * 0.86 +
basinRim * 0.54 +
foothillBreak * 0.48 +
coastEdge * 0.34 +
terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity || score, landuse || score) * 0.38 -
livingCorridor * 0.50 -
urbanContinuity * 0.72
);
}
return score;
}
function lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse) {
const lowRelief = clamp((0.68 - elevation[i]) * 1.25) + clamp((0.36 - slope[i]) * 1.45) + clamp((0.48 - ridgeField[i]) * 1.10);
const landuseFit = [1, 2, 3, 4, 7, 8].includes(landuse[i]) ? 0.42 : landuse[i] === 5 || landuse[i] === 6 ? 0.20 : 0;
return clamp(
lowRelief * 0.30 +
(plain?.[i] || 0) * 0.30 +
(agriculture?.[i] || 0) * 0.16 +
basinField[i] * 0.24 +
coastalLowland[i] * 0.24 +
valleyField[i] * 0.10 +
populationDensity[i] * 0.34 +
landuseFit
);
}
function mountainCompartmentFitness(i, elevation, slope, ridgeField, populationDensity, landuse) {
const settled = populationDensity[i] * 0.85 + ([2, 3, 4, 7, 8].includes(landuse[i]) ? 0.35 : 0);
return clamp(elevation[i] * 0.38 + slope[i] * 0.32 + ridgeField[i] * 0.42 - settled);
}
function canShareNaturalCompartment(a, b, classA, classB, barrier, river, flowAccum, valleyField, populationDensity, landuse) {
if (classA !== classB) {
const bothUrban = classA <= 3 && classB <= 3;
const bothLivingCorridor = [5, 6, 7, 10].includes(classA) && [5, 6, 7, 10].includes(classB);
if (!bothUrban && !bothLivingCorridor) return false;
}
const majorRiverEdge = Math.max(river[a], river[b]) > 0.58 || Math.max(flowAccum[a], flowAccum[b]) > 0.72;
const urbanEdge = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.34) &&
((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.34);
const valleyContinuity = (valleyField[a] + valleyField[b]) * 0.5 > 0.42 && !majorRiverEdge;
const threshold = urbanEdge ? 0.84 : valleyContinuity ? 0.76 : classA === 8 || classB === 8 ? 0.42 : 0.62;
return barrier < threshold && (!majorRiverEdge || urbanEdge);
}
function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse) {
let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = unit.riverExposure || 0;
let coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0, lowlandFitness = 0, mountainFitness = 0;
for (const i of unit.cells) {
const [x, y] = xyOf(i);
sx += x; sy += y; pop += populationDensity[i];
urbanWeight += urbanBoundaryPenalty(i, populationDensity, landuse);
ridgeExposure += ridgeField[i];
coastalExposure += coastalLowland[i];
basinIdentity += basinField[i];
valleyIdentity += valleyField[i];
lowlandFitness += lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
mountainFitness += mountainCompartmentFitness(i, elevation, slope, ridgeField, populationDensity, landuse);
}
const area = unit.cells.length;
unit.area = area;
unit.x = sx / Math.max(1, area);
unit.y = sy / Math.max(1, area);
unit.population = pop;
unit.urbanWeight = urbanWeight / Math.max(1, area);
unit.ridgeExposure = ridgeExposure / Math.max(1, area);
unit.riverExposure = riverExposure / Math.max(1, area);
unit.coastalExposure = coastalExposure / Math.max(1, area);
unit.basinIdentity = basinIdentity / Math.max(1, area);
unit.valleyIdentity = valleyIdentity / Math.max(1, area);
unit.lowlandFitness = lowlandFitness / Math.max(1, area);
unit.mountainFitness = mountainFitness / Math.max(1, area);
}
function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) {
if (!unit || unit.area < 28 || unit.lowlandFitness < 0.24 || unit.mountainFitness > 0.72) return null;
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore } = fields;
let first = -1, second = -1, bestA = -INF, bestB = -INF;
for (const i of unit.cells) {
const [x, y] = xyOf(i);
const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
const score = low + populationDensity[i] * 0.22 + hashSeededTie(x, y, seed) * 0.04;
if (score > bestA) { bestA = score; first = i; }
}
if (first < 0) return null;
const [fx, fy] = xyOf(first);
for (const i of unit.cells) {
const [x, y] = xyOf(i);
const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
const d = Math.hypot(x - fx, y - fy);
const score = d * (0.55 + low * 0.45) + hashSeededTie(x, y, seed + 17) * 0.20;
if (score > bestB) { bestB = score; second = i; }
}
if (second < 0 || second === first) return null;
const cellSet = new Set(unit.cells);
const localOwner = new Map([[first, 0], [second, 1]]);
const queue = [first, second];
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const owner = localOwner.get(cur);
const [x, y] = xyOf(cur);
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (!cellSet.has(ni) || localOwner.has(ni)) continue;
localOwner.set(ni, owner);
queue.push(ni);
}
}
for (const ci of unit.cells) if (!localOwner.has(ci)) {
const [x, y] = xyOf(ci);
const d0 = Math.hypot(x - fx, y - fy);
const [sx, sy] = xyOf(second);
const d1 = Math.hypot(x - sx, y - sy);
localOwner.set(ci, d0 <= d1 ? 0 : 1);
}
const aCells = [], bCells = [];
for (const ci of unit.cells) (localOwner.get(ci) === 0 ? aCells : bCells).push(ci);
if (aCells.length < 10 || bCells.length < 10) return null;
unit.cells = aCells;
const newUnit = { ...unit, id: newId, cells: bCells, centerIds: [], adjacent: new Map() };
for (const ci of bCells) compartmentId[ci] = newId;
refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
refreshCompartmentStats(newUnit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
return newUnit;
}
function hashSeededTie(x, y, seed) {
let h = Math.imul((x | 0) ^ (seed | 0), 1597334677) ^ Math.imul((y | 0) ^ ((seed >>> 1) | 0), 3812015801);
h = (h ^ (h >>> 15)) >>> 0;
return h / 4294967295;
}
function naturalGroupKey(unit) {
if (unit.classId <= 3) return `urban:${Math.round(unit.x / 10)}:${Math.round(unit.y / 10)}`;
if (unit.classId === 5) return `coast:${Math.round(unit.y / 8)}`;
if (unit.classId === 6) return `basin:${Math.round(unit.x / 12)}:${Math.round(unit.y / 12)}`;
if (unit.classId === 7) return `valley:${Math.round((unit.x + unit.y) / 12)}`;
if (unit.classId === 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)}`;
}
export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, 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;
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];
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,
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 };
let guard = targetCount * 3;
while (compartments.filter((unit) => unit.area > 0).length < targetCount && guard-- > 0) {
const candidates = compartments
.filter((unit) => unit.area > 0 && unit.lowlandFitness > 0.24 && unit.mountainFitness < 0.74 && unit.area >= 28)
.sort((a, b) => (b.area * (0.45 + b.lowlandFitness) - b.mountainFitness * 80) - (a.area * (0.45 + a.lowlandFitness) - a.mountainFitness * 80));
const target = candidates[0];
if (!target) break;
const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard);
if (!newUnit) {
target.lowlandFitness = 0;
continue;
}
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) {
const unitId = new Int32Array(SIZE);
unitId.fill(-1);
@ -561,52 +845,338 @@ function mergeTinyLandscapeUnits(unitId, units, minArea = 10) {
}
}
function landscapeTransitionCost(a, b, edge) {
function naturalOwnershipAffinity(unit, neighbor, edge) {
const boundaryTarget = edge.target / Math.max(1, edge.count);
const bothUrban = a.classId <= 3 && b.classId <= 3;
const bothCorridor = (a.classId === 5 || a.classId === 7 || a.classId === 10) && (b.classId === 5 || b.classId === 7 || b.classId === 10);
const urbanContinuity = bothUrban ? 2.1 : (a.urbanWeight + b.urbanWeight) > 0.75 && Math.abs(a.urbanWeight - b.urbanWeight) < 0.35 ? 0.9 : 0;
return Math.max(0.18, 0.70 + boundaryTarget * 4.2 + (a.classId === b.classId ? 0 : 0.75) + ((a.classId === 8 || b.classId === 8) ? 1.2 : 0) - urbanContinuity - (bothCorridor ? 0.55 : 0));
const sameClass = unit.classId === neighbor.classId ? 1.0 : 0;
const sameGroup = naturalGroupKey(unit) === naturalGroupKey(neighbor) ? 1.1 : 0;
const bothUrban = unit.classId <= 3 && neighbor.classId <= 3;
const bothCorridor = [5, 6, 7, 10].includes(unit.classId) && [5, 6, 7, 10].includes(neighbor.classId);
const urbanContinuity = bothUrban ? 1.35 : (unit.urbanWeight + neighbor.urbanWeight) > 0.75 && Math.abs(unit.urbanWeight - neighbor.urbanWeight) < 0.35 ? 0.58 : 0;
const strongDividerPenalty = boundaryTarget * (edge.count > 2 ? 2.8 : 1.8);
return edge.count * 0.55 + sameClass + sameGroup + urbanContinuity + (bothCorridor ? 0.72 : 0) - strongDividerPenalty;
}
export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) {
const { unitId, units, targetScore } = buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse);
if (units.length === 0) return;
function compartmentCrossingCost(unit, neighbor, edge) {
const boundaryScore = edge.target / Math.max(1, edge.count);
const sameClass = unit.classId === neighbor.classId ? 1 : 0;
const sameGroup = naturalGroupKey(unit) === naturalGroupKey(neighbor) ? 1 : 0;
const lowlandContinuity = Math.min(unit.lowlandFitness || 0, neighbor.lowlandFitness || 0);
const urbanContinuity = Math.min(unit.urbanWeight || 0, neighbor.urbanWeight || 0);
const mountainPenalty = Math.max(unit.mountainFitness || 0, neighbor.mountainFitness || 0);
const ridgePenalty = Math.max(unit.ridgeExposure || 0, neighbor.ridgeExposure || 0);
return Math.max(0.18,
1.0 +
boundaryScore * 5.2 +
mountainPenalty * 1.8 +
ridgePenalty * 0.9 -
sameClass * 0.45 -
sameGroup * 0.35 -
lowlandContinuity * 1.15 -
urbanContinuity * 0.70 -
Math.min(1.0, edge.count / 12) * 0.25
);
}
function graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options = {}) {
const owner = new Int16Array(compartments.length);
const dist = new Float32Array(compartments.length);
owner.fill(-1);
dist.fill(INF);
const heap = new MinHeap();
for (let id = 0; id < adminCenters.length; id++) {
const center = adminCenters[id];
if (!center || !inside(center.x, center.y)) continue;
const unit = units[unitId[indexOf(center.x, center.y)]];
if (unit) unit.centerIds.push(id);
const compIndex = compartmentId[indexOf(center.x, center.y)];
const unit = compartments[compIndex];
if (compIndex < 0 || !unit || unit.area === 0) continue;
unit.centerIds.push(id);
if (dist[compIndex] > 0) {
dist[compIndex] = 0;
owner[compIndex] = id;
heap.push({ i: compIndex, f: 0, owner: id });
}
}
const owner = new Int16Array(units.length);
const dist = new Float32Array(units.length);
owner.fill(-1); dist.fill(INF);
const heap = new MinHeap();
for (const unit of units) {
if (unit.area === 0 || unit.centerIds.length === 0) continue;
const id = unit.centerIds[0];
owner[unit.id] = id; dist[unit.id] = 0; heap.push({ i: unit.id, f: 0 });
}
while (heap.length) {
while (heap.length > 0) {
const cur = heap.pop();
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
const unit = units[cur.i];
const currentOwner = owner[cur.i];
if (!unit || currentOwner < 0) continue;
for (const [nextId, edge] of unit.adjacent) {
const next = units[nextId];
if (!next || next.area === 0) continue;
const nextDist = dist[cur.i] + landscapeTransitionCost(unit, next, edge) + Math.sqrt(next.area) * 0.012 + (next.urbanWeight > 0.75 && next.centerIds.length === 0 ? -0.20 : 0);
if (nextDist < dist[nextId]) {
dist[nextId] = nextDist; owner[nextId] = currentOwner; heap.push({ i: nextId, f: nextDist });
const unit = compartments[cur.i];
if (!unit || unit.area === 0) continue;
const center = adminCenters[cur.owner];
for (const [neighborId, edge] of unit.adjacent) {
const neighbor = compartments[neighborId];
if (!neighbor || neighbor.area === 0) continue;
const crossing = compartmentCrossingCost(unit, neighbor, edge);
const euclideanTie = center ? Math.hypot(neighbor.x - center.x, neighbor.y - center.y) * 0.006 : 0;
const hinterlandDrag = (neighbor.mountainFitness || 0) > 0.64 && (neighbor.population || 0) < 6 ? 0.35 : 0;
const next = cur.f + crossing + euclideanTie + hinterlandDrag;
if (next + 1e-5 < dist[neighborId]) {
dist[neighborId] = next;
owner[neighborId] = cur.owner;
heap.push({ i: neighborId, f: next, owner: cur.owner });
} else if (Math.abs(next - dist[neighborId]) < 0.08 && owner[neighborId] >= 0) {
const oldCenter = adminCenters[owner[neighborId]];
const oldD = oldCenter ? Math.hypot(neighbor.x - oldCenter.x, neighbor.y - oldCenter.y) : INF;
const newD = center ? Math.hypot(neighbor.x - center.x, neighbor.y - center.y) : INF;
if (newD < oldD - 1.5 || (newD < oldD + 1.5 && cur.owner < owner[neighborId])) owner[neighborId] = cur.owner;
}
}
}
for (const unit of units) {
for (const unit of compartments) {
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
let bestOwner = -1, bestScore = INF;
for (const [neighborId, edge] of unit.adjacent) {
if (owner[neighborId] < 0) continue;
const neighbor = compartments[neighborId];
const score = compartmentCrossingCost(unit, neighbor, edge) + (neighbor?.area || 0) * -0.001;
if (score < bestScore) { bestScore = score; bestOwner = owner[neighborId]; }
}
owner[unit.id] = bestOwner >= 0 ? bestOwner : 0;
}
return owner;
}
function compartmentMunicipalityMetrics(compartments, owner, targetMunicipalityCount = 0, targetCompartmentCount = 0) {
const counts = new Map();
let active = 0;
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
active++;
const id = owner[unit.id];
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
}
const actual = counts.size;
const singles = [...counts.values()].filter((value) => value === 1).length;
return {
targetMunicipalityCount,
actualMunicipalityCount: actual,
targetNaturalCompartmentCount: targetCompartmentCount,
naturalCompartmentCount: active,
compartmentCount: active,
averageCompartmentsPerMunicipality: actual ? active / actual : 0,
singleCompartmentMunicipalityRatio: actual ? singles / actual : 0,
};
}
function averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore) {
let sum = 0;
let count = 0;
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || adminId[ni] === adminId[i]) continue;
sum += (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5;
count++;
}
}
}
return count ? sum / count : 0;
}
function assignCompartmentsToAdminOwners(compartments, compartmentId, adminCenters, naturalBarrierScore, prefectureMask, sea) {
const owner = new Int16Array(compartments.length);
owner.fill(-1);
for (let id = 0; id < adminCenters.length; id++) {
const center = adminCenters[id];
if (!center || !inside(center.x, center.y)) continue;
const compIndex = compartmentId[indexOf(center.x, center.y)];
if (compIndex >= 0 && compartments[compIndex]?.area > 0) {
const unit = compartments[compIndex];
unit.centerIds.push(id);
owner[compIndex] = id;
}
}
for (let pass = 0; pass < compartments.length + 8; pass++) {
let changed = 0;
for (const unit of compartments) {
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
let bestOwner = -1;
let bestScore = -INF;
for (const [neighborId, edge] of unit.adjacent) {
const neighborOwner = owner[neighborId];
if (neighborOwner < 0) continue;
const neighbor = compartments[neighborId];
if (!neighbor || neighbor.area === 0) continue;
const center = adminCenters[neighborOwner];
const d = center ? Math.hypot(unit.x - center.x, unit.y - center.y) : 0;
const score = naturalOwnershipAffinity(unit, neighbor, edge) - d * 0.006 + Math.min(0.9, Math.sqrt(Math.max(1, neighbor.area)) * 0.020);
if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; }
}
const accept = unit.classId <= 3 ? bestScore > -0.35 : unit.classId === 8 || unit.classId === 9 ? bestScore > -1.05 : bestScore > -0.70;
if (bestOwner >= 0 && accept) {
owner[unit.id] = bestOwner;
changed++;
}
}
if (changed === 0) break;
}
for (const unit of compartments) {
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
let bestId = -1;
let bestScore = -INF;
for (let id = 0; id < adminCenters.length; id++) {
const center = adminCenters[id];
if (!center || !inside(center.x, center.y)) continue;
const centerComp = compartments[compartmentId[indexOf(center.x, center.y)]];
const sameGroup = centerComp && naturalGroupKey(centerComp) === naturalGroupKey(unit) ? 2.3 : 0;
const sameClass = centerComp && centerComp.classId === unit.classId ? 0.8 : 0;
const urbanFit = unit.urbanWeight > 0.55 && centerComp?.urbanWeight > 0.55 ? 1.3 : 0;
const d = Math.hypot(unit.x - center.x, unit.y - center.y);
const score = sameGroup + sameClass + urbanFit - d * 0.020 - unit.ridgeExposure * 0.16;
if (score > bestScore) { bestScore = score; bestId = id; }
}
owner[unit.id] = bestId >= 0 ? bestId : 0;
}
return owner;
}
export function extractCompartmentBorders(compartmentId, prefectureMask, sea) {
const segments = [];
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i] || compartmentId[i] < 0) continue;
const a = compartmentId[i];
if (x + 1 < MAP_W) {
const ni = indexOf(x + 1, y);
if (prefectureMask[ni] && !sea[ni] && compartmentId[ni] >= 0 && compartmentId[ni] !== a) segments.push([[x + 1, y], [x + 1, y + 1]]);
}
if (y + 1 < MAP_H) {
const ni = indexOf(x, y + 1);
if (prefectureMask[ni] && !sea[ni] && compartmentId[ni] >= 0 && compartmentId[ni] !== a) segments.push([[x, y + 1], [x + 1, y + 1]]);
}
}
}
return segments;
}
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 adminId = new Int16Array(SIZE);
adminId.fill(-1);
const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options);
for (const unit of compartments) {
const assigned = owner[unit.id];
if (assigned < 0) continue;
for (const i of unit.cells) adminId[i] = assigned;
}
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] >= 0) continue;
const comp = compartments[compartmentId[i]];
adminId[i] = comp && owner[comp.id] >= 0 ? owner[comp.id] : 0;
}
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse);
const activeCompartments = compartments.filter((unit) => unit.area > 0);
const relationMetrics = compartmentMunicipalityMetrics(compartments, owner, options.targetMunicipalityCount || adminCenters.length, options.targetCompartmentCount || 0);
return {
adminId,
compartmentId,
compartments,
naturalBarrierScore,
debug: {
...relationMetrics,
compartmentBorders: extractCompartmentBorders(compartmentId, prefectureMask, sea),
averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0,
finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore),
voronoiLikeRateBefore: 0,
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),
},
};
}
function weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore) {
let weak = 0;
let total = 0;
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
const ni = indexOf(nx, ny);
const a = adminId[i], b = adminId[ni];
if (!prefectureMask[ni] || sea[ni] || a < 0 || b < 0 || a === b) continue;
total++;
const ca = adminCenters[a], cb = adminCenters[b];
if (!ca || !cb) continue;
const mx = (x + nx) * 0.5, my = (y + ny) * 0.5;
const nearBisector = Math.abs(Math.hypot(mx - ca.x, my - ca.y) - Math.hypot(mx - cb.x, my - cb.y)) < 4.0;
if (nearBisector && (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5 < 0.38) weak++;
}
}
}
return total ? weak / total : 0;
}
export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) {
const before = new Int16Array(adminId);
const initialNaturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse);
const beforeVoronoiLikeRate = weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, initialNaturalBarrierScore);
const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse);
if (compartments.length === 0) return;
for (let id = 0; id < adminCenters.length; id++) {
const center = adminCenters[id];
if (!center || !inside(center.x, center.y)) continue;
const unit = compartments[compartmentId[indexOf(center.x, center.y)]];
if (unit) unit.centerIds.push(id);
}
const owner = new Int16Array(compartments.length);
owner.fill(-1);
for (const unit of compartments) {
if (unit.area === 0 || unit.centerIds.length === 0) continue;
owner[unit.id] = unit.centerIds[0];
}
for (let pass = 0; pass < compartments.length + 4; pass++) {
let changed = 0;
for (const unit of compartments) {
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
let bestOwner = -1;
let bestScore = -INF;
for (const [neighborId, edge] of unit.adjacent) {
const neighborOwner = owner[neighborId];
if (neighborOwner < 0) continue;
const neighbor = compartments[neighborId];
if (!neighbor || neighbor.area === 0) continue;
const score = naturalOwnershipAffinity(unit, neighbor, edge) + Math.min(0.8, Math.sqrt(Math.max(1, neighbor.area)) * 0.018);
if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; }
}
const accept = unit.classId <= 3 ? bestScore > -0.15 : unit.classId === 8 || unit.classId === 9 ? bestScore > -0.80 : bestScore > -0.45;
if (bestOwner >= 0 && accept) { owner[unit.id] = bestOwner; changed++; }
}
if (changed === 0) break;
}
for (const unit of compartments) {
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
let bestId = -1, bestScore = -INF;
for (let id = 0; id < adminCenters.length; id++) {
const center = adminCenters[id];
if (!center || !inside(center.x, center.y)) continue;
const centerComp = compartments[compartmentId[indexOf(center.x, center.y)]];
const sameGroup = centerComp && naturalGroupKey(centerComp) === naturalGroupKey(unit) ? 2.4 : 0;
const sameClass = centerComp && centerComp.classId === unit.classId ? 0.9 : 0;
const urbanFit = unit.urbanWeight > 0.55 && centerComp?.urbanWeight > 0.55 ? 1.2 : 0;
const d = Math.hypot(unit.x - center.x, unit.y - center.y);
const score = sameGroup + sameClass + urbanFit - d * 0.018 - unit.ridgeExposure * 0.18;
if (score > bestScore) { bestScore = score; bestId = id; }
}
owner[unit.id] = bestId >= 0 ? bestId : 0;
}
for (const unit of compartments) {
const assigned = owner[unit.id];
if (assigned >= 0) for (const i of unit.cells) adminId[i] = assigned;
}
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] >= 0) continue;
const comp = compartments[compartmentId[i]];
adminId[i] = comp && owner[comp.id] >= 0 ? owner[comp.id] : 0;
}
for (let id = 0; id < adminCenters.length; id++) {
const center = adminCenters[id];
if (!center || !inside(center.x, center.y)) continue;
@ -618,7 +1188,18 @@ export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, e
if (prefectureMask[i] && !sea[i]) adminId[i] = id;
}
}
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, targetScore, populationDensity, landuse);
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse);
let changedCells = 0;
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++;
const activeCompartments = compartments.filter((unit) => unit.area > 0);
applyLandscapeUnitAdminPartition.lastDebug = {
compartmentCount: activeCompartments.length,
averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0,
changedAfterNaturalCompartmentPartition: changedCells,
finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore),
voronoiLikeRateBefore: beforeVoronoiLikeRate,
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),
};
}
export function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCenters = [], protectedPoints = [], passes = 6) {
@ -661,3 +1242,94 @@ export function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, eleva
adminId.set(current);
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, targetScore, populationDensity, landuse);
}
export function splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], settlements = []) {
const before = new Int16Array(adminId);
const area = new Map();
const lowland = new Map();
const mountain = new Map();
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
const id = adminId[i];
area.set(id, (area.get(id) || 0) + 1);
const living = (plain[i] || 0) * 0.42 + (agriculture[i] || 0) * 0.28 + basinField[i] * 0.20 + coastalLowland[i] * 0.20 + valleyField[i] * 0.12;
const rough = ridgeField[i] * 0.54 + slope[i] * 0.36 + Math.max(0, elevation[i] - 0.58) * 0.38;
lowland.set(id, (lowland.get(id) || 0) + living);
mountain.set(id, (mountain.get(id) || 0) + rough);
}
const areas = [...area.values()].sort((a, b) => a - b);
const median = areas.length ? areas[Math.floor(areas.length / 2)] : 0;
if (!median) return { changedCells: 0, splitMunicipalities: 0 };
const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse);
const unitOwner = new Int16Array(compartments.length);
unitOwner.fill(-1);
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const counts = new Map();
for (const i of unit.cells) {
const id = adminId[i];
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
}
let bestId = -1, best = -1;
for (const [id, count] of counts) if (count > best) { best = count; bestId = id; }
unitOwner[unit.id] = bestId;
}
const adminCenterIndex = new Map();
for (let id = 0; id < adminCenters.length; id++) {
const c = adminCenters[id];
if (c && inside(c.x, c.y)) adminCenterIndex.set(id, indexOf(c.x, c.y));
}
let splitMunicipalities = 0;
let rejectedMunicipalities = 0;
for (const [id, cells] of area) {
const averageLowland = (lowland.get(id) || 0) / cells;
const averageMountain = (mountain.get(id) || 0) / cells;
if (cells < median * 1.85 || averageLowland < 0.24 || averageMountain > 0.48) {
if (cells >= median * 1.85) rejectedMunicipalities++;
continue;
}
const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id);
const meaningfulNodes = localSettlements.filter((p) => p.kind === "Satellite City" || p.kind === "New Town" || p.kind === "Market Town" || (p.population || 0) >= 30000);
if (meaningfulNodes.length < 2) {
rejectedMunicipalities++;
continue;
}
let changedHere = 0;
for (const unit of compartments) {
if (!unit || unit.area === 0 || unitOwner[unit.id] !== id) continue;
const centerIndex = adminCenterIndex.get(id);
if (centerIndex >= 0 && unit.cells.includes(centerIndex)) continue;
if (unit.classId === 8 || unit.classId === 9) continue;
let bestNeighbor = -1;
let bestScore = -INF;
for (const [neighborId, edge] of unit.adjacent) {
const neighborOwner = unitOwner[neighborId];
if (neighborOwner < 0 || neighborOwner === id) continue;
const boundaryTarget = edge.target / Math.max(1, edge.count);
const neighbor = compartments[neighborId];
const nodePull = meaningfulNodes.reduce((best, p) => Math.max(best, 1 / (1 + Math.hypot(p.x - unit.x, p.y - unit.y) / 6)), 0);
const score = edge.count * 0.7 + boundaryTarget * 1.4 + nodePull * 1.2 - Math.max(0, (neighbor?.ridgeExposure || 0) - unit.ridgeExposure) * 0.35;
if (score > bestScore) { bestScore = score; bestNeighbor = neighborOwner; }
}
if (bestNeighbor < 0 || bestScore < 2.2) continue;
for (const ci of unit.cells) {
if (adminId[ci] === id) {
adminId[ci] = bestNeighbor;
changedHere++;
}
}
}
if (changedHere > Math.max(28, cells * 0.035)) splitMunicipalities++;
}
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse);
let changedCells = 0;
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++;
return { changedCells, splitMunicipalities, rejectedMunicipalities };
}
export function splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], settlements = []) {
return splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters, settlements);
}

5
app.js
View file

@ -11,6 +11,9 @@ const modes = [
["development", "Development"],
["landuse", "Land Use"],
["admin", "Municipal Borders"],
["terrain-debug", "Terrain Debug"],
["admin-debug", "Admin Debug"],
["borders-debug", "Borders Debug"],
];
const state = {
@ -76,6 +79,8 @@ function getStats(map) {
["Logistics Parks", countText(map.logisticsParks)],
["New Towns", countText(map.newTowns)],
["Municipalities", map.adminCenters.length],
["Admin changed cells", map.adminDebug ? `${map.adminDebug.changedAfterLandscapePartition || 0} partition / ${map.adminDebug.changedAfterSnap || 0} snap` : "-"],
["Regional changed cells", map.regionalDebug?.regionalChangedAfterNaturalPartition ?? "-"],
];
}

View file

@ -1,17 +0,0 @@
import { hash2 } from "./random.js";
export function pickEntities(candidates, { max = 99, minDistance = 5, threshold = 0, seed = 0, jitter = 0.04 } = {}) {
const sorted = candidates
.filter((p) => Number.isFinite(p.score) && p.score >= threshold)
.map((p) => ({ ...p, score: p.score + hash2(p.x, p.y, seed + 54321) * jitter }))
.sort((a, b) => b.score - a.score);
const out = [];
for (const candidate of sorted) {
if (out.every((p) => Math.hypot(p.x - candidate.x, p.y - candidate.y) >= minDistance)) {
out.push(candidate);
if (out.length >= max) break;
}
}
return out;
}

View file

@ -1,28 +0,0 @@
import { SIZE } from "./grid.js";
export function createMapFields() {
const flowTo = new Int32Array(SIZE);
flowTo.fill(-1);
return {
elevation: new Float32Array(SIZE),
moisture: new Float32Array(SIZE),
slope: new Float32Array(SIZE),
sea: new Uint8Array(SIZE),
river: new Float32Array(SIZE),
floodplain: new Float32Array(SIZE),
plain: new Float32Array(SIZE),
agriculture: new Float32Array(SIZE),
ridgeField: new Float32Array(SIZE),
valleyField: new Float32Array(SIZE),
basinField: new Float32Array(SIZE),
coastalLowland: new Float32Array(SIZE),
flowAccum: new Float32Array(SIZE),
erosionField: new Float32Array(SIZE),
depositionField: new Float32Array(SIZE),
flowTo,
portSuitability: new Float32Array(SIZE),
crossingSuitability: new Float32Array(SIZE),
passSuitability: new Float32Array(SIZE),
};
}

View file

@ -1,34 +0,0 @@
export class MinHeap {
constructor() { this.items = []; }
push(item) {
this.items.push(item);
let i = this.items.length - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (this.items[parent].f <= item.f) break;
this.items[i] = this.items[parent];
i = parent;
}
this.items[i] = item;
}
pop() {
if (this.items.length === 0) return null;
const root = this.items[0];
const last = this.items.pop();
if (this.items.length > 0) {
let i = 0;
while (true) {
const left = i * 2 + 1;
const right = left + 1;
if (left >= this.items.length) break;
const child = right < this.items.length && this.items[right].f < this.items[left].f ? right : left;
if (this.items[child].f >= last.f) break;
this.items[i] = this.items[child];
i = child;
}
this.items[i] = last;
}
return root;
}
get length() { return this.items.length; }
}

26
grid.js
View file

@ -1,26 +0,0 @@
export const MAP_W = 172;
export const MAP_H = 122;
export const CELL_SIZE = 6;
export const SIZE = MAP_W * MAP_H;
export const INF = 1e9;
export function indexOf(x, y) {
return y * MAP_W + x;
}
export function xyOf(i) {
return [i % MAP_W, Math.floor(i / MAP_W)];
}
export function inside(x, y) {
return x >= 0 && y >= 0 && x < MAP_W && y < MAP_H;
}
export function clamp(v, a = 0, b = 1) {
return Math.max(a, Math.min(b, v));
}
export function nearMapEdge(x, y, margin = 1) {
return x < margin || y < margin || x >= MAP_W - margin || y >= MAP_H - margin;
}

878
mapAdminStage.js Normal file
View file

@ -0,0 +1,878 @@
import {
applyLandscapeUnitAdminPartition,
assignAdminRegionsFromNaturalCompartments,
lockSmallUrbanComponentsToMunicipality,
mergeTinyMunicipalities,
removeMunicipalExclaves,
smoothAdminRegionsTerrainAware,
splitOversizedLowlandMunicipalities,
snapAdminBoundariesToTerrain,
} from "./adminRegions.js";
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, xyOf } from "./mapUtils.js";
import { extractAdminBorderSegments } from "./mapGeneratorHelpers.js";
function changedCellsSince(before, after, prefectureMask, sea) {
let changed = 0;
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== after[i]) changed++;
return changed;
}
function municipalityAreaById(adminId, prefectureMask, sea) {
const area = new Map();
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
area.set(adminId[i], (area.get(adminId[i]) || 0) + 1);
}
return area;
}
function isProtectedAdminSeed(seed) {
if (!seed) return false;
if (seed.seedKind === "capital" || seed.protectedCity?.isPrefecturalCapital) return true;
if (seed.seedKind === "modernCity" && (seed.protectedCity?.population || seed.population || 0) >= 180000) return true;
if (seed.seedKind === "port" && seed.portClass === "major") return true;
if (seed.seedKind === "satelliteCity" && (seed.protectedSatellite?.population || seed.population || 0) >= 60000) return true;
return false;
}
function buildSeedLifecycle(adminCenters, adminId, prefectureMask, sea, minArea = 80) {
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
const lifecycle = adminCenters.map((center, id) => {
const protectedSeed = isProtectedAdminSeed(center);
const area = areaById.get(id) || 0;
const enoughArea = area >= (protectedSeed ? 28 : minArea);
return {
id,
protected: protectedSeed,
area,
state: enoughArea || protectedSeed ? "survived" : "pending",
};
});
return lifecycle;
}
function activeSeedIds(seedLifecycle) {
return new Set(seedLifecycle.filter((seed) => seed.state === "survived" || seed.protected).map((seed) => seed.id));
}
function dominantCompartmentOwners(compartments, adminId) {
const owner = new Int16Array(compartments.length);
owner.fill(-1);
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const counts = new Map();
for (const i of unit.cells) {
const id = adminId[i];
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
}
let bestId = -1, best = -1;
for (const [id, count] of counts) if (count > best) { best = count; bestId = id; }
owner[unit.id] = bestId;
}
return owner;
}
function applyCompartmentOwners(adminId, compartments, owner) {
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const id = owner[unit.id];
if (id < 0) continue;
for (const i of unit.cells) adminId[i] = id;
}
}
function absorbSeedCompartments(adminId, compartments, seedLifecycle) {
const owner = dominantCompartmentOwners(compartments, adminId);
const absorbed = new Set(seedLifecycle.filter((seed) => seed.state === "absorbed").map((seed) => seed.id));
let changed = 0;
for (const unit of compartments) {
if (!unit || unit.area === 0 || !absorbed.has(owner[unit.id])) continue;
let bestId = -1, bestScore = -INF;
for (const [neighborId, edge] of unit.adjacent) {
const candidate = owner[neighborId];
if (candidate < 0 || absorbed.has(candidate)) continue;
const neighbor = compartments[neighborId];
const score = edge.count * 2 - (edge.target / Math.max(1, edge.count)) * 2 + (neighbor?.area || 0) * 0.002;
if (score > bestScore) { bestScore = score; bestId = candidate; }
}
if (bestId < 0) continue;
owner[unit.id] = bestId;
changed += unit.area;
}
applyCompartmentOwners(adminId, compartments, owner);
return changed;
}
function splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, settlements = []) {
const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields;
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
const areas = [...areaById.values()].sort((a, b) => a - b);
const median = areas.length ? areas[Math.floor(areas.length / 2)] : 0;
if (!median) return { changedCells: 0, splitMunicipalities: 0, pendingSeedsUsed: 0 };
const owner = dominantCompartmentOwners(compartments, adminId);
const unitsByOwner = new Map();
for (const unit of compartments) {
if (!unit || unit.area === 0 || owner[unit.id] < 0) continue;
if (!unitsByOwner.has(owner[unit.id])) unitsByOwner.set(owner[unit.id], []);
unitsByOwner.get(owner[unit.id]).push(unit);
}
const pending = seedLifecycle.filter((seed) => seed.state === "pending" && !seed.protected);
let changedCells = 0;
let splitMunicipalities = 0;
let pendingSeedsUsed = 0;
for (const [id, units] of unitsByOwner) {
const area = areaById.get(id) || 0;
if (area < Math.max(260, median * 1.45) || units.length < 6) continue;
let lowland = 0, rough = 0;
for (const unit of units) {
for (const i of unit.cells) {
lowland += (plain[i] || 0) * 0.38 + (agriculture[i] || 0) * 0.20 + basinField[i] * 0.22 + coastalLowland[i] * 0.22 + valleyField[i] * 0.10;
rough += ridgeField[i] * 0.48 + slope[i] * 0.34 + Math.max(0, elevation[i] - 0.58) * 0.30;
}
}
if (lowland / area < 0.26 || rough / area > 0.48) continue;
const localPending = pending.filter((seed) => {
const center = adminCenters[seed.id];
if (!center || !inside(center.x, center.y)) return false;
const centerOwner = adminId[indexOf(center.x, center.y)];
return centerOwner === id || Math.hypot(center.x - (adminCenters[id]?.x || center.x), center.y - (adminCenters[id]?.y || center.y)) < 28;
});
const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id);
if (localPending.length === 0 || localPending.length + localSettlements.length < 2) continue;
let municipalitySplit = false;
for (const seed of localPending.slice(0, 3)) {
const center = adminCenters[seed.id];
if (!center) continue;
const targetArea = clamp(95 + (center.score || 0.5) * 60, 90, 180);
let claimed = 0;
const candidates = units
.filter((unit) => owner[unit.id] === id && unit.classId !== 8 && unit.classId !== 9)
.map((unit) => ({
unit,
score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 8 - (unit.urbanWeight || 0) * 3,
}))
.sort((a, b) => a.score - b.score);
if (candidates.length < 2) continue;
for (const { unit } of candidates) {
if (claimed >= targetArea && claimed >= 2) break;
owner[unit.id] = seed.id;
claimed += unit.area;
changedCells += unit.area;
}
if (claimed >= 45) {
seed.state = "survived";
seed.area = claimed;
pendingSeedsUsed++;
municipalitySplit = true;
}
}
if (municipalitySplit) splitMunicipalities++;
}
applyCompartmentOwners(adminId, compartments, owner);
return { changedCells, splitMunicipalities, pendingSeedsUsed };
}
function promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, targetMinCount = 20) {
const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields;
let areaById = municipalityAreaById(adminId, prefectureMask, sea);
let currentCount = areaById.size;
if (currentCount >= targetMinCount) return { changedCells: 0, promotedSeeds: 0 };
const owner = dominantCompartmentOwners(compartments, adminId);
let changedCells = 0;
let promotedSeeds = 0;
const pending = seedLifecycle
.filter((seed) => seed.state === "pending" && !seed.protected)
.sort((a, b) => (adminCenters[b.id]?.score || 0) - (adminCenters[a.id]?.score || 0));
for (const seed of pending) {
if (currentCount >= targetMinCount) break;
const center = adminCenters[seed.id];
if (!center || !inside(center.x, center.y)) continue;
const existingArea = areaById.get(seed.id) || 0;
if (existingArea >= 12) {
seed.state = "survived";
seed.area = existingArea;
promotedSeeds++;
continue;
}
const candidates = compartments
.filter((unit) => {
if (!unit || unit.area === 0) return false;
const currentOwner = owner[unit.id];
if (currentOwner < 0 || currentOwner === seed.id) return false;
const ownerArea = areaById.get(currentOwner) || 0;
if (ownerArea < 90) return false;
const lowlandFit = (unit.lowlandFitness || 0) + (unit.basinIdentity || 0) * 0.15 + (unit.coastalExposure || 0) * 0.15;
if (lowlandFit < 0.26) return false;
return Math.hypot(unit.x - center.x, unit.y - center.y) < 36;
})
.map((unit) => ({
unit,
score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2,
}))
.sort((a, b) => a.score - b.score);
if (candidates.length === 0) continue;
let claimed = 0;
for (const { unit } of candidates) {
const currentOwner = owner[unit.id];
if ((areaById.get(currentOwner) || 0) - unit.area < 70) continue;
owner[unit.id] = seed.id;
areaById.set(currentOwner, (areaById.get(currentOwner) || 0) - unit.area);
areaById.set(seed.id, (areaById.get(seed.id) || 0) + unit.area);
claimed += unit.area;
changedCells += unit.area;
if (claimed >= 55) break;
}
if (claimed >= 25) {
seed.state = "survived";
seed.area = areaById.get(seed.id) || claimed;
promotedSeeds++;
currentCount++;
}
}
applyCompartmentOwners(adminId, compartments, owner);
return { changedCells, promotedSeeds };
}
function restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compartments, adminCenters, seedLifecycle, targetMinCount = 20) {
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
let currentCount = areaById.size;
if (currentCount >= targetMinCount) return { changedCells: 0, restoredSeeds: 0 };
const owner = dominantCompartmentOwners(compartments, adminId);
let changedCells = 0;
let restoredSeeds = 0;
const missing = seedLifecycle
.filter((seed) => (seed.state === "survived" || seed.protected) && (areaById.get(seed.id) || 0) === 0)
.sort((a, b) => (b.protected ? 1 : 0) - (a.protected ? 1 : 0));
for (const seed of missing) {
if (currentCount >= targetMinCount) break;
const center = adminCenters[seed.id];
if (!center || !inside(center.x, center.y)) continue;
const candidates = compartments
.filter((unit) => {
if (!unit || unit.area === 0 || unit.classId === 8 || unit.classId === 9) return false;
const currentOwner = owner[unit.id];
if (currentOwner < 0 || currentOwner === seed.id) return false;
if ((areaById.get(currentOwner) || 0) - unit.area < 25) return false;
return Math.hypot(unit.x - center.x, unit.y - center.y) < 90 && ((unit.lowlandFitness || 0) > 0.08 || seed.protected);
})
.map((unit) => ({
unit,
score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2 + (unit.classId === 8 || unit.classId === 9 ? 20 : 0),
}))
.sort((a, b) => a.score - b.score);
if (candidates.length === 0) continue;
const unit = candidates[0].unit;
const oldOwner = owner[unit.id];
if ((areaById.get(oldOwner) || 0) - unit.area < 25) continue;
owner[unit.id] = seed.id;
const claimed = unit.area;
areaById.set(oldOwner, (areaById.get(oldOwner) || 0) - claimed);
changedCells += claimed;
areaById.set(seed.id, claimed);
seed.area = claimed;
restoredSeeds++;
currentCount++;
}
applyCompartmentOwners(adminId, compartments, owner);
return { changedCells, restoredSeeds };
}
function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages }) {
let landCells = 0;
let habitableCells = 0;
let lowlandCells = 0;
let coastlineComplexity = 0;
let mountainCells = 0;
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i]) continue;
landCells++;
if (slope[i] < 0.42 && ridgeField[i] < 0.55 && (!elevation || elevation[i] < 0.72)) habitableCells++;
if ((coastalLowland[i] > 0.20 || basinField[i] > 0.24) && slope[i] < 0.36 && ridgeField[i] < 0.52) lowlandCells++;
if (ridgeField[i] > 0.52 || slope[i] > 0.48) mountainCells++;
for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) {
const ni = indexOf(nx, ny);
if (sea[ni]) {
coastlineComplexity += 1 + coastalLowland[i] * 0.8;
break;
}
}
}
}
const independentSatellites = (satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality").length;
const settlementWeight = modernCities.length * 1.6 + markets.length * 1.0 + ports.length * 0.8 + independentSatellites * 0.7 + villages.length * 0.25;
const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520);
const mountainRatio = landCells ? mountainCells / landCells : 0;
const lowlandBonus = Math.min(7, lowlandCells / 430);
const target = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2);
return clamp(target, 20, 50);
}
function lowlandAdminSeedScore(i, { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse }) {
const lowRelief = clamp((0.70 - elevation[i]) * 1.35) + clamp((0.34 - slope[i]) * 1.55) + clamp((0.48 - ridgeField[i]) * 1.10);
const landuseFit = [1, 2, 3, 4, 7, 8].includes(landuse[i]) ? 0.40 : landuse[i] === 5 || landuse[i] === 6 ? 0.12 : 0;
return clamp(
lowRelief * 0.25 +
plain[i] * 0.28 +
basinField[i] * 0.24 +
coastalLowland[i] * 0.24 +
settlementScore[i] * 0.30 +
populationDensity[i] * 0.32 +
roadInfluence[i] * 0.16 +
railInfluence2[i] * 0.16 +
(stationInfluence?.[i] || 0) * 0.18 +
landuseFit -
Math.max(0, elevation[i] - 0.62) * 1.2 -
Math.max(0, ridgeField[i] - 0.54) * 0.9
);
}
function buildLowlandAdminSeeds({
seed,
targetMunicipalityCount,
prefectureMask,
sea,
elevation,
slope,
ridgeField,
plain,
basinField,
coastalLowland,
settlementScore,
populationDensity,
roadInfluence,
railInfluence2,
stationInfluence,
landuse,
modernCities,
satelliteCities,
markets,
ports,
newTowns,
stations,
}) {
const fields = { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse };
function validLowlandPoint(p, strict = true) {
if (!p || !inside(p.x, p.y)) return false;
const i = indexOf(p.x, p.y);
if (!prefectureMask[i] || sea[i]) return false;
const score = lowlandAdminSeedScore(i, fields);
const mountain = elevation[i] > 0.70 || slope[i] > 0.52 || ridgeField[i] > 0.62;
return score >= (strict ? 0.34 : 0.24) && (!mountain || p.isPrefecturalCapital || (p.population || 0) >= 220000 || p.portClass === "major");
}
const realSeeds = [];
for (const city of modernCities || []) {
if (!validLowlandPoint(city, !city.isPrefecturalCapital)) continue;
if (!city.isPrefecturalCapital && (city.population || 0) < 85000) continue;
const i = indexOf(city.x, city.y);
realSeeds.push({ x: city.x, y: city.y, score: 1.35 + (city.population || 0) / 650000 + lowlandAdminSeedScore(i, fields), population: city.population || 0, protectedCity: city, seedKind: city.isPrefecturalCapital ? "capital" : "modernCity" });
}
for (const city of satelliteCities || []) {
if (city.municipalityClass !== "independentSatelliteMunicipality" || !validLowlandPoint(city, false)) continue;
const i = indexOf(city.x, city.y);
realSeeds.push({ x: city.x, y: city.y, score: 0.92 + (city.population || 0) / 260000 + lowlandAdminSeedScore(i, fields), population: city.population || 0, protectedSatellite: city, seedKind: "satelliteCity" });
}
for (const p of [...(markets || []), ...(ports || []), ...(newTowns || [])]) {
if (!validLowlandPoint(p, true)) continue;
const i = indexOf(p.x, p.y);
const portBonus = p.portClass === "major" ? 0.45 : p.portClass === "regional" ? 0.26 : 0;
realSeeds.push({ x: p.x, y: p.y, score: 0.74 + lowlandAdminSeedScore(i, fields) + portBonus + (p.population || 0) / 420000, population: p.population || 0, portClass: p.portClass, source: p, seedKind: p.portClass ? "port" : "marketTown" });
}
const picked = pickEntities(realSeeds, {
max: targetMunicipalityCount,
minDistance: 5 + Math.floor(rand(seed, 1302) * 3),
threshold: 0.62,
seed: seed + 1300,
jitter: 0.025,
});
const invisibleCandidates = [];
for (let y = 2; y < MAP_H - 2; y++) {
for (let x = 2; x < MAP_W - 2; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i]) continue;
const score = lowlandAdminSeedScore(i, fields) + hash2(x, y, seed + 1311) * 0.045;
if (score < 0.48) continue;
const insideDenseCore = modernCities.some((city) => (city.population || 0) >= 180000 && Math.hypot(city.x - x, city.y - y) < Math.max(5, (city.coreRadius || 4) * 1.7));
if (insideDenseCore) continue;
invisibleCandidates.push({ x, y, score, invisibleLowlandAdminSeed: true, seedKind: "invisibleLowland" });
}
}
if (picked.length < targetMunicipalityCount) {
const extra = pickEntities(invisibleCandidates, {
max: targetMunicipalityCount - picked.length,
minDistance: 5,
threshold: 0.48,
seed: seed + 1304,
jitter: 0.02,
});
for (const p of extra) if (picked.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4)) picked.push(p);
}
if (picked.length < Math.min(targetMunicipalityCount, 20)) {
const relaxed = pickEntities(invisibleCandidates, {
max: Math.min(targetMunicipalityCount, 20) - picked.length,
minDistance: 4,
threshold: 0.38,
seed: seed + 1305,
jitter: 0.02,
});
for (const p of relaxed) if (picked.length < targetMunicipalityCount && picked.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 3.5)) picked.push(p);
}
return picked.slice(0, targetMunicipalityCount);
}
function estimateUrbanComponentArea(city, prefectureMask, sea, landuse, populationDensity) {
if (!city || !inside(city.x, city.y)) return 0;
const start = indexOf(city.x, city.y);
if (!prefectureMask[start] || sea[start]) return 0;
const radius = Math.ceil(Math.max(7, (city.urbanRadius || 6) * 1.7));
const seen = new Uint8Array(SIZE);
const queue = [start];
seen[start] = 1;
let area = 0;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const [x, y] = xyOf(cur);
const d = Math.hypot(x - city.x, y - city.y);
if (d > radius) continue;
const urban = (landuse[cur] >= 2 && landuse[cur] <= 4) || landuse[cur] === 7 || landuse[cur] === 8 || populationDensity[cur] > 0.18;
if (!urban) continue;
area++;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
return area;
}
function terrainSeparationBetween(a, b, ridgeField, river, flowAccum, populationDensity, landuse) {
if (!a || !b) return { separatedByBarrier: false, ruralGap: false, averageDensity: 0, maxBarrier: 0 };
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
let maxBarrier = 0;
let lowUrbanRun = 0;
let bestLowUrbanRun = 0;
let densitySum = 0;
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(a.x + (b.x - a.x) * t);
const y = Math.round(a.y + (b.y - a.y) * t);
if (!inside(x, y)) continue;
const i = indexOf(x, y);
const barrier = Math.max(ridgeField[i] * 0.95, river[i] * 0.85, flowAccum[i] * 0.42);
maxBarrier = Math.max(maxBarrier, barrier);
densitySum += populationDensity[i];
const urban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.20;
if (urban) lowUrbanRun = 0;
else {
lowUrbanRun++;
bestLowUrbanRun = Math.max(bestLowUrbanRun, lowUrbanRun);
}
}
return {
separatedByBarrier: maxBarrier > 0.56,
ruralGap: bestLowUrbanRun >= 4,
averageDensity: densitySum / (steps + 1),
maxBarrier,
};
}
function classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, ridgeField, river, flowAccum) {
let independent = 0;
let attached = 0;
for (const sat of satelliteCities || []) {
if (!sat || !inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)] || sea[indexOf(sat.x, sat.y)]) continue;
const parent = modernCities[sat.parentCityIndex] || modernCities.slice().sort((a, b) => Math.hypot(a.x - sat.x, a.y - sat.y) - Math.hypot(b.x - sat.x, b.y - sat.y))[0];
const parentDistance = parent ? Math.hypot(parent.x - sat.x, parent.y - sat.y) : 99;
const separation = terrainSeparationBetween(sat, parent, ridgeField, river, flowAccum, populationDensity, landuse);
const urbanArea = estimateUrbanComponentArea(sat, prefectureMask, sea, landuse, populationDensity);
const i = indexOf(sat.x, sat.y);
const continuousUrban = parent && parentDistance < Math.max(10, (parent.urbanRadius || 12) + (sat.urbanRadius || 5) + 5) && separation.averageDensity > 0.14 && !separation.ruralGap && !separation.separatedByBarrier;
const newTownLike = landuse[i] === 7 || (railInfluence2[i] > 0.22 && roadInfluence[i] > 0.12 && (sat.population || 0) < 70000);
let municipalityClass = "independentSatelliteMunicipality";
if (continuousUrban && (sat.population || 0) < 90000) municipalityClass = "suburbanDistrictMergedWithParent";
else if (newTownLike && (sat.population || 0) < 85000 && !separation.separatedByBarrier) municipalityClass = "newTownDistrict";
else if ((sat.population || 0) < 42000 && urbanArea < 55 && !separation.separatedByBarrier) municipalityClass = "smallTownAttachedToRuralMunicipality";
else if ((sat.population || 0) >= 60000 && urbanArea >= 42 && (separation.separatedByBarrier || separation.ruralGap || parentDistance > 15)) municipalityClass = "independentSatelliteMunicipality";
sat.municipalityClass = municipalityClass;
sat.parentX = parent?.x;
sat.parentY = parent?.y;
sat.parentAdminHint = -1;
sat.distinctUrbanComponentArea = urbanArea;
sat.separatedByBarrier = separation.separatedByBarrier || separation.ruralGap;
sat.satelliteMinArea = clamp(90 + Math.sqrt(sat.population || 24000) * 0.62 + (sat.urbanRadius || 5) * 12, 80, 360);
if (municipalityClass === "independentSatelliteMunicipality") independent++;
else attached++;
}
return { independent, attached };
}
function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, context) {
const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities } = context;
if (!satellite || targetAdmin < 0 || !inside(satellite.x, satellite.y)) return 0;
const start = indexOf(satellite.x, satellite.y);
if (!prefectureMask[start] || sea[start]) return 0;
const targetAreaBase = clamp(90 + Math.sqrt(satellite.population || 24000) * 0.8 + (satellite.urbanRadius || 5) * 18, 120, 520);
const targetArea = satellite.municipalityClass === "smallTownAttachedToRuralMunicipality"
? Math.min(130, targetAreaBase * 0.55)
: satellite.municipalityClass === "suburbanDistrictMergedWithParent" || satellite.municipalityClass === "newTownDistrict"
? Math.min(190, targetAreaBase * 0.62)
: targetAreaBase;
const maxCost = satellite.municipalityClass === "independentSatelliteMunicipality" ? 46 : 32;
const heap = new MinHeap();
const best = new Float32Array(SIZE);
best.fill(INF);
heap.push({ i: start, f: 0 });
best[start] = 0;
const claimed = [];
while (heap.length > 0 && claimed.length < targetArea) {
const cur = heap.pop();
if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue;
const [x, y] = xyOf(cur.i);
const d = Math.hypot(x - satellite.x, y - satellite.y);
if (!prefectureMask[cur.i] || sea[cur.i]) continue;
let invadesOtherCore = false;
for (const city of modernCities || []) {
if (!city || (city.population || 0) < 140000) continue;
if (Math.hypot(city.x - satellite.x, city.y - satellite.y) < 4) continue;
if (Math.hypot(city.x - x, city.y - y) <= Math.max(3.5, (city.coreRadius || 4) * 1.25)) {
invadesOtherCore = true;
break;
}
}
if (invadesOtherCore) continue;
const compatible = d <= (satellite.urbanRadius || 5) * 1.25 ||
[2, 3, 4, 7, 8].includes(landuse[cur.i]) ||
populationDensity[cur.i] > 0.12 ||
roadInfluence[cur.i] > 0.12 ||
railInfluence2[cur.i] > 0.10 ||
stationInfluence?.[cur.i] > 0.10 ||
basinField[cur.i] > 0.22 ||
valleyField[cur.i] > 0.24 ||
coastalLowland[cur.i] > 0.20;
if (!compatible && claimed.length > targetArea * 0.55) continue;
claimed.push(cur.i);
for (const [dx, dy, step] of [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [-1, 1, 1.41], [1, -1, 1.41], [-1, -1, 1.41]]) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) continue;
const barrier = ridgeField[ni] * 5.2 + Math.max(0, elevation[ni] - 0.58) * 4.0 + slope[ni] * 2.2 + (river[ni] > 0.55 || flowAccum[ni] > 0.70 ? 7.5 : river[ni] > 0.30 ? 2.8 : 0);
const living = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.2 : 0) + populationDensity[ni] * 2.0 + roadInfluence[ni] * 0.85 + railInfluence2[ni] * 0.95 + (stationInfluence?.[ni] || 0) * 1.2 + basinField[ni] * 0.42 + valleyField[ni] * 0.48 + coastalLowland[ni] * 0.32;
const distanceCost = Math.hypot(nx - satellite.x, ny - satellite.y) / Math.max(7, (satellite.urbanRadius || 5) * 1.9);
const nd = cur.f + Math.max(0.28, 1.05 + barrier - living + distanceCost) * step;
if (nd < best[ni]) {
best[ni] = nd;
heap.push({ i: ni, f: nd });
}
}
}
let changed = 0;
for (const i of claimed) {
if (adminId[i] !== targetAdmin) changed++;
adminId[i] = targetAdmin;
}
return changed;
}
export function generateAdminLayout({
seed,
prefectureMask,
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,
}) {
const boundaryRidgeField = naturalBarrierScore
? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46))
: ridgeField;
const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum);
const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages });
const compartmentMultiplier = clamp(3.5 + rand(seed, 1320) * 2.0, 3.5, 5.5);
let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), 80, 240);
let adminCentersRaw = buildLowlandAdminSeeds({
seed,
targetMunicipalityCount,
prefectureMask,
sea,
elevation,
slope,
ridgeField: boundaryRidgeField,
plain,
basinField,
coastalLowland,
settlementScore,
populationDensity,
roadInfluence,
railInfluence2,
stationInfluence,
landuse,
modernCities,
satelliteCities,
markets,
ports,
newTowns,
stations,
});
if (adminCentersRaw.length < 20) targetCompartmentCount = Math.max(targetCompartmentCount, 120);
const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, {
seed,
targetMunicipalityCount,
targetCompartmentCount,
});
const adminId = compartmentAssignment.adminId;
let previousSnapshot = new Int16Array(adminId);
const adminDebug = {
changedAfterSmooth: 0,
changedAfterUrbanLock: 0,
changedAfterSmallUrbanLock: 0,
changedAfterInitialMerge: 0,
changedAfterInitialExclaveRemoval: 0,
changedAfterLandscapePartition: 0,
changedAfterSnap: 0,
changedAfterOversizedRuralSplit: 0,
changedAfterFinalExclaveRemoval: 0,
changedAfterFinalMerge: 0,
targetMunicipalityCount,
actualMunicipalityCount: 0,
municipalityCountReason: "habitable cells, settlement weight, coastline complexity, basin/lowland bonus, and mountain-ratio adjustment",
changedAfterCompartmentAssignment: compartmentAssignment.debug?.naturalCompartmentCount || 0,
oversizedRuralSplits: 0,
oversizedLowlandSplits: 0,
ruralSplitsAccepted: 0,
ruralSplitsRejected: 0,
targetNaturalCompartmentCount: targetCompartmentCount,
compartmentMultiplier,
lowlandAdminSeedCount: adminCentersRaw.filter((p) => p.invisibleLowlandAdminSeed).length,
lowlandAdminSeeds: adminCentersRaw.filter((p) => p.invisibleLowlandAdminSeed).map((p) => ({ x: p.x, y: p.y })),
realAdminSeedCount: adminCentersRaw.filter((p) => !p.invisibleLowlandAdminSeed).length,
highMountainAdminSeedCount: adminCentersRaw.filter((p) => {
const i = indexOf(p.x, p.y);
return elevation[i] > 0.70 || slope[i] > 0.52 || boundaryRidgeField[i] > 0.62;
}).length,
candidateSeedCount: adminCentersRaw.length,
protectedSeedCount: adminCentersRaw.filter(isProtectedAdminSeed).length,
survivedSeedCount: 0,
pendingSeedCount: 0,
absorbedSeedCount: 0,
pendingSeedsUsedForLowlandSplit: 0,
finalMunicipalityCount: 0,
finalTinyMunicipalityCount: 0,
seedCellRevivalCount: 0,
satelliteMunicipalitiesCreated: adminCentersRaw.filter((p) => p.protectedSatellite).length,
satelliteMunicipalitiesMerged: 0,
satelliteMunicipalitiesExpanded: 0,
satelliteMunicipalitiesTooSmall: 0,
averageSatelliteMunicipalityArea: 0,
minSatelliteMunicipalityArea: 0,
satelliteMunicipalityAreaByNameOrIndex: {},
independentSatelliteMunicipalities: satelliteClassificationDebug.independent,
attachedSatelliteDistricts: satelliteClassificationDebug.attached,
satelliteMunicipalityStats: satelliteClassificationDebug,
...compartmentAssignment.debug,
};
const seedLifecycle = buildSeedLifecycle(adminCentersRaw, adminId, prefectureMask, sea, 35);
const pendingSplitDebug = splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, {
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
}, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, [...(satelliteCities || []), ...newTowns, ...markets, ...villages, ...ports]);
adminDebug.changedAfterPendingSeedLowlandSplit = pendingSplitDebug.changedCells;
adminDebug.pendingSeedsUsedForLowlandSplit = pendingSplitDebug.pendingSeedsUsed;
adminDebug.oversizedLowlandSplits += pendingSplitDebug.splitMunicipalities;
const pendingPromotionDebug = promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, {
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
}, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(24, targetMunicipalityCount));
adminDebug.changedAfterPendingSeedCountRepair = pendingPromotionDebug.changedCells;
adminDebug.pendingSeedsPromotedForCount = pendingPromotionDebug.promotedSeeds;
let areaAfterPendingSplit = municipalityAreaById(adminId, prefectureMask, sea);
for (const seedState of seedLifecycle) {
if (seedState.state !== "pending") continue;
seedState.area = areaAfterPendingSplit.get(seedState.id) || 0;
if (seedState.area >= 35) seedState.state = "survived";
else seedState.state = "absorbed";
}
adminDebug.changedAfterAbsorbingSeeds = absorbSeedCompartments(adminId, compartmentAssignment.compartments, seedLifecycle);
let activeAdminIds = activeSeedIds(seedLifecycle);
function markChanged(field) {
adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea);
previousSnapshot = new Int16Array(adminId);
}
smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, populationDensity, landuse, 2);
markChanged("changedAfterSmooth");
function lockUrbanClusterToMunicipality(city, radius, allowSuburban = true) {
if (!city || !prefectureMask[indexOf(city.x, city.y)]) return;
let bestAdmin = -1;
let bestD = INF;
adminCentersRaw.forEach((center, id) => {
if (!activeAdminIds.has(id)) return;
const d = Math.hypot(center.x - city.x, center.y - city.y);
if (d < bestD) { bestD = d; bestAdmin = id; }
});
if (bestAdmin < 0) return;
const r = Math.ceil(radius);
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 > radius) continue;
const urban = landuse[i] === 2 || landuse[i] === 3 || (allowSuburban && (landuse[i] === 4 || landuse[i] === 7 || landuse[i] === 8));
if (urban || populationDensity[i] > 0.22) adminId[i] = bestAdmin;
}
}
}
for (const city of modernCities) {
const radius = (city.population || 0) >= 500000
? clamp(17 + Math.sqrt(city.population) / 120, 20, 38)
: clamp(5 + Math.sqrt(city.population || 70000) / 210, 6, 11);
lockUrbanClusterToMunicipality(city, radius, true);
}
for (const sat of satelliteCities || []) {
if (!prefectureMask[indexOf(sat.x, sat.y)]) continue;
let bestAdmin = -1;
if (sat.municipalityClass === "independentSatelliteMunicipality") {
let bestD = INF;
adminCentersRaw.forEach((center, id) => {
if (!activeAdminIds.has(id)) return;
const d = Math.hypot(center.x - sat.x, center.y - sat.y);
if (d < bestD) { bestD = d; bestAdmin = id; }
});
} else if (inside(sat.parentX ?? -1, sat.parentY ?? -1)) {
bestAdmin = adminId[indexOf(sat.parentX, sat.parentY)];
}
if (bestAdmin < 0) continue;
sat.parentAdminHint = bestAdmin;
const changed = expandSatelliteMunicipalityCatchment(adminId, sat, bestAdmin, {
prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum,
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities,
});
if (changed > 0 && sat.municipalityClass === "independentSatelliteMunicipality") adminDebug.satelliteMunicipalitiesExpanded++;
}
markChanged("changedAfterUrbanLock");
lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 520);
lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620);
markChanged("changedAfterSmallUrbanLock");
const activeAdminCenters = () => adminCentersRaw.filter((_, id) => activeAdminIds.has(id));
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: activeAdminCenters() });
markChanged("changedAfterInitialMerge");
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180);
markChanged("changedAfterInitialExclaveRemoval");
// The initial compartment graph assignment is now the primary natural partition.
// Re-running the older raw landscape-unit pass here collapses lowland seeds into a few broad owners.
markChanged("changedAfterLandscapePartition");
const oversizedSplitDebug = splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, [...(satelliteCities || []), ...newTowns, ...markets, ...villages]);
adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells;
adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities;
adminDebug.oversizedRuralSplits = oversizedSplitDebug.splitMunicipalities;
adminDebug.oversizedLowlandSplits = oversizedSplitDebug.splitMunicipalities;
adminDebug.ruralSplitsAccepted = oversizedSplitDebug.splitMunicipalities;
adminDebug.ruralSplitsRejected = oversizedSplitDebug.rejectedMunicipalities || 0;
previousSnapshot = new Int16Array(adminId);
snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 2);
markChanged("changedAfterSnap");
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 360);
markChanged("changedAfterFinalExclaveRemoval");
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 80, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 90, protectedPoints: activeAdminCenters() });
markChanged("changedAfterFinalMerge");
for (const sat of satelliteCities || []) {
if (sat.municipalityClass !== "independentSatelliteMunicipality" || !inside(sat.x, sat.y)) continue;
const targetAdmin = adminId[indexOf(sat.x, sat.y)];
if (targetAdmin < 0) continue;
expandSatelliteMunicipalityCatchment(adminId, sat, targetAdmin, {
prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum,
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities,
});
}
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 260);
const finalPromotionDebug = promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, {
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
}, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(22, targetMunicipalityCount));
adminDebug.changedAfterFinalPendingSeedCountRepair = finalPromotionDebug.changedCells;
adminDebug.pendingSeedsPromotedForCount += finalPromotionDebug.promotedSeeds;
const restoredSeedDebug = restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(22, targetMunicipalityCount));
adminDebug.changedAfterSurvivedSeedCompartmentRestore = restoredSeedDebug.changedCells;
adminDebug.survivedSeedsRestoredByCompartment = restoredSeedDebug.restoredSeeds;
let finalAreaBySeed = municipalityAreaById(adminId, prefectureMask, sea);
for (const seedState of seedLifecycle) {
seedState.area = finalAreaBySeed.get(seedState.id) || 0;
if (!seedState.protected && seedState.state === "pending" && seedState.area < 25) seedState.state = "absorbed";
}
absorbSeedCompartments(adminId, compartmentAssignment.compartments, seedLifecycle);
activeAdminIds = activeSeedIds(seedLifecycle);
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
const satelliteAreas = [];
(satelliteCities || []).forEach((sat, index) => {
if (!inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)]) return;
const id = adminId[indexOf(sat.x, sat.y)];
const area = areaById.get(id) || 0;
const key = sat.name || `satellite-${index}`;
adminDebug.satelliteMunicipalityAreaByNameOrIndex[key] = area;
if (sat.municipalityClass === "independentSatelliteMunicipality" && (area < Math.max(120, sat.satelliteMinArea || 0) || ((sat.population || 0) >= 60000 && area < 150))) {
sat.municipalityClass = "smallTownAttachedToRuralMunicipality";
adminDebug.satelliteMunicipalitiesTooSmall++;
return;
}
if (sat.municipalityClass === "independentSatelliteMunicipality") {
satelliteAreas.push(area);
if (area < 80) adminDebug.satelliteMunicipalitiesTooSmall++;
}
});
adminDebug.averageSatelliteMunicipalityArea = satelliteAreas.length ? satelliteAreas.reduce((sum, value) => sum + value, 0) / satelliteAreas.length : 0;
adminDebug.minSatelliteMunicipalityArea = satelliteAreas.length ? Math.min(...satelliteAreas) : 0;
adminDebug.satelliteMunicipalitiesIndependent = satelliteAreas.length;
const landscapeDebug = applyLandscapeUnitAdminPartition.lastDebug || {};
Object.assign(adminDebug, landscapeDebug);
adminDebug.targetNaturalCompartmentCount = compartmentAssignment.debug?.targetNaturalCompartmentCount || targetCompartmentCount;
adminDebug.naturalCompartmentCount = compartmentAssignment.debug?.naturalCompartmentCount || adminDebug.naturalCompartmentCount || adminDebug.compartmentCount || 0;
adminDebug.compartmentCount = adminDebug.naturalCompartmentCount;
adminDebug.averageCompartmentsPerMunicipality = compartmentAssignment.debug?.averageCompartmentsPerMunicipality || adminDebug.averageCompartmentsPerMunicipality || 0;
adminDebug.singleCompartmentMunicipalityRatio = compartmentAssignment.debug?.singleCompartmentMunicipalityRatio ?? adminDebug.singleCompartmentMunicipalityRatio ?? 0;
adminDebug.actualMunicipalityCount = new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i])).size;
adminDebug.averageCompartmentsPerMunicipality = adminDebug.actualMunicipalityCount ? adminDebug.naturalCompartmentCount / adminDebug.actualMunicipalityCount : 0;
adminDebug.survivedSeedCount = seedLifecycle.filter((seed) => seed.state === "survived").length;
adminDebug.pendingSeedCount = seedLifecycle.filter((seed) => seed.state === "pending").length;
adminDebug.absorbedSeedCount = seedLifecycle.filter((seed) => seed.state === "absorbed").length;
adminDebug.finalMunicipalityCount = adminDebug.actualMunicipalityCount;
adminDebug.finalTinyMunicipalityCount = [...areaById.values()].filter((area) => area > 0 && area < 8).length;
adminDebug.seedLifecycle = seedLifecycle.map((seed) => ({ id: seed.id, state: seed.state, protected: seed.protected, area: seed.area }));
adminDebug.borderNaturalBarrierAverage = adminDebug.finalBorderNaturalBarrierAverage ?? 0;
adminDebug.voronoiLikeRate = adminDebug.voronoiLikeRateAfter ?? 0;
const adminBorders = extractAdminBorderSegments(adminId, prefectureMask);
return { adminCentersRaw, adminId, adminBorders, adminDebug };
}

1521
mapFeatures.js Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

972
mapGeneratorHelpers.js Normal file
View file

@ -0,0 +1,972 @@
import { generateEntityName } from "./names.js";
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, nearMapEdge, pickEntities, rand, xyOf } from "./mapUtils.js";
export function neighbors8(x, y) {
const out = [];
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx;
const ny = y + dy;
if (inside(nx, ny)) out.push([nx, ny, Math.hypot(dx, dy)]);
}
}
return out;
}
export function neighbors4(x, y) {
const out = [];
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx;
const ny = y + dy;
if (inside(nx, ny)) out.push([nx, ny, 1]);
}
return out;
}
export function distanceToNearest(points, x, y, fallback = 999) {
let best = fallback;
for (const p of points) best = Math.min(best, Math.hypot(p.x - x, p.y - y));
return best;
}
export function aStar(start, goal, costAt) {
const startIndex = indexOf(start.x, start.y);
const goalIndex = indexOf(goal.x, goal.y);
if (startIndex === goalIndex) return [[start.x, start.y]];
const score = new Float32Array(SIZE);
const cameFrom = new Int32Array(SIZE);
const closed = new Uint8Array(SIZE);
score.fill(INF);
cameFrom.fill(-1);
const heap = new MinHeap();
score[startIndex] = 0;
heap.push({ i: startIndex, f: Math.hypot(start.x - goal.x, start.y - goal.y) });
let guard = 0;
while (heap.length > 0 && guard++ < SIZE * 3) {
const current = heap.pop();
if (!current || closed[current.i]) continue;
closed[current.i] = 1;
if (current.i === goalIndex) {
const path = [];
let p = goalIndex;
while (p !== -1) {
const [x, y] = xyOf(p);
path.push([x, y]);
if (p === startIndex) break;
p = cameFrom[p];
}
return path.reverse();
}
const [cx, cy] = xyOf(current.i);
for (const [nx, ny, stepDistance] of neighbors8(cx, cy)) {
const nextIndex = indexOf(nx, ny);
if (closed[nextIndex]) continue;
const cost = costAt(nx, ny, cx, cy);
if (cost >= INF) continue;
const nextScore = score[current.i] + cost * stepDistance;
if (nextScore < score[nextIndex]) {
score[nextIndex] = nextScore;
cameFrom[nextIndex] = current.i;
heap.push({ i: nextIndex, f: nextScore + Math.hypot(nx - goal.x, ny - goal.y) * 0.78 });
}
}
}
return [];
}
export function influenceFromPaths(paths, radius) {
const grid = new Float32Array(SIZE);
for (const path of paths) {
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);
grid[i] = Math.max(grid[i], 1 / (1 + d));
}
}
}
}
return grid;
}
export function pointKey(p) {
return `${p.x},${p.y}`;
}
export function getDegree(degreeMap, p) {
return degreeMap.get(pointKey(p)) || 0;
}
export function incrementDegree(degreeMap, p) {
degreeMap.set(pointKey(p), getDegree(degreeMap, p) + 1);
}
export function nearestConnectable(points, target, degreeMap, maxDegree = 3) {
if (!points.length) return null;
const sorted = points
.map((p) => ({ ...p, d: Math.hypot(p.x - target.x, p.y - target.y), degree: getDegree(degreeMap, p) }))
.sort((a, b) => (a.degree >= maxDegree ? 22 : 0) + a.d + a.degree * 7 - ((b.degree >= maxDegree ? 22 : 0) + b.d + b.degree * 7));
return sorted.find((p) => p.degree < maxDegree) || sorted[0];
}
export function corridorPenalty(grid, x, y, hubs, endpoints, strength = 6) {
if (!grid) return 0;
const value = grid[indexOf(x, y)];
if (value <= 0.0001) return 0;
const nearEndpoint = distanceToNearest(endpoints, x, y) <= 3.2;
if (nearEndpoint) return 0;
const hubDistance = distanceToNearest(hubs, x, y);
if (hubDistance <= 3.5) return 0;
if (hubDistance <= 7.5) return value * strength * 0.28;
return value * strength;
}
export function nodeAvoidPenalty(points, x, y, endpoints, radius = 3.0, strength = 5.0) {
if (!points || points.length === 0) return 0;
if (distanceToNearest(endpoints, x, y) <= radius + 0.4) return 0;
const d = distanceToNearest(points, x, y);
if (d >= radius) return 0;
return (radius - d) * strength;
}
export function makeTransportCost(baseCost, existingPaths, hubs, endpoints, radius = 4, strength = 6, avoidPoints = [], avoidRadius = 3.0, avoidStrength = 5.0) {
const grid = existingPaths.length ? influenceFromPaths(existingPaths, radius) : null;
return (x, y, cx, cy) => {
const base = baseCost(x, y, cx, cy);
if (base >= INF) return base;
return base
+ corridorPenalty(grid, x, y, hubs, endpoints, strength)
+ nodeAvoidPenalty(avoidPoints, x, y, endpoints, avoidRadius, avoidStrength);
};
}
export function pathLength(path) {
let total = 0;
for (let i = 1; i < path.length; i++) total += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]);
return total;
}
export function pathEndpointDistance(path) {
if (!path || path.length < 2) return 0;
const a = path[0];
const b = path[path.length - 1];
return Math.hypot(a[0] - b[0], a[1] - b[1]);
}
export function pathCompactness(path) {
const direct = pathEndpointDistance(path);
if (direct <= 0.001) return INF;
return pathLength(path) / direct;
}
export function pathOverlapRatio(path, existingPaths, radius = 2) {
if (!path?.length || !existingPaths?.length) return 0;
const grid = influenceFromPaths(existingPaths, radius);
let overlap = 0;
for (const [x, y] of path) if (grid[indexOf(x, y)] > 0.18) overlap++;
return overlap / Math.max(1, path.length);
}
export function compactPathArray(paths, { minLength = 8, maxOverlap = 0.35, maxCount = 99 } = {}) {
const kept = [];
for (const path of paths.slice().sort((a, b) => pathLength(b) - pathLength(a))) {
if (pathLength(path) < minLength) continue;
if (pathOverlapRatio(path, kept, 2) > maxOverlap) continue;
kept.push(path);
if (kept.length >= maxCount) break;
}
paths.splice(0, paths.length, ...kept);
}
export function bresenhamCells(a, b) {
const cells = [];
let x0 = a[0];
let y0 = a[1];
const x1 = b[0];
const y1 = b[1];
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
while (true) {
cells.push([x0, y0]);
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 > -dy) { err -= dy; x0 += sx; }
if (e2 < dx) { err += dx; y0 += sy; }
}
return cells;
}
export function smoothPathByLineOfSight(path, passable, maxSegment = 9) {
if (!path || path.length < 3) return path || [];
const out = [path[0]];
let i = 0;
while (i < path.length - 1) {
let best = i + 1;
const limit = Math.min(path.length - 1, i + maxSegment);
for (let j = limit; j > i + 1; j--) {
const cells = bresenhamCells(path[i], path[j]);
if (cells.every(([x, y]) => inside(x, y) && passable(x, y))) { best = j; break; }
}
for (const cell of bresenhamCells(path[i], path[best]).slice(1)) out.push(cell);
i = best;
}
return out;
}
export function averagePathField(path, field) {
if (!path?.length) return 0;
let sum = 0;
for (const [x, y] of path) sum += field[indexOf(x, y)] || 0;
return sum / path.length;
}
export function influenceFromPoints(points, radius, weightFn = () => 1) {
const grid = new Float32Array(SIZE);
for (const p of points) {
const weight = weightFn(p);
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const nx = p.x + dx;
const ny = p.y + dy;
if (!inside(nx, ny)) continue;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
const i = indexOf(nx, ny);
grid[i] = Math.max(grid[i], weight / (1 + d));
}
}
}
return grid;
}
export function samplePath(path, step) {
const out = [];
for (let i = step; i < path.length - step; i += step) {
const [x, y] = path[i];
out.push({ x, y, score: 1 });
}
return out;
}
export function smoothMask(mask, passes = 2) {
let current = new Uint8Array(mask);
for (let pass = 0; pass < passes; pass++) {
const next = new Uint8Array(current);
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
let count = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (current[indexOf(x + dx, y + dy)]) count++;
}
}
if (count >= 5) next[i] = 1;
else if (count <= 3) next[i] = 0;
}
}
current = next;
}
return current;
}
export function largestConnectedMask(mask) {
const seen = new Uint8Array(SIZE);
let best = [];
const queue = [];
for (let i = 0; i < SIZE; i++) {
if (!mask[i] || seen[i]) continue;
const component = [];
queue.length = 0;
queue.push(i);
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
component.push(cur);
const [x, y] = xyOf(cur);
for (const [nx, ny] of neighbors8(x, y)) {
const ni = indexOf(nx, ny);
if (!mask[ni] || seen[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
if (component.length > best.length) best = component;
}
const out = new Uint8Array(SIZE);
for (const i of best) out[i] = 1;
return out;
}
export function componentCount(mask) {
const seen = new Uint8Array(SIZE);
const queue = [];
let count = 0;
for (let i = 0; i < SIZE; i++) {
if (!mask[i] || seen[i]) continue;
count++;
queue.length = 0;
queue.push(i);
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const [x, y] = xyOf(queue[q]);
for (const [nx, ny] of neighbors8(x, y)) {
const ni = indexOf(nx, ny);
if (!mask[ni] || seen[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
}
return count;
}
export function makePrefectureMask(seed, sea, elevation, slope, river) {
const candidates = [];
for (let y = 8; y < MAP_H - 8; y++) {
for (let x = 8; x < MAP_W - 8; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const centrality = 1 - Math.hypot((x / MAP_W) - 0.5, (y / MAP_H) - 0.5) / 0.72;
const score = centrality * 0.28 + (1 - slope[i]) * 0.42 + (1 - Math.abs(elevation[i] - 0.42)) * 0.22 + Math.min(0.16, river[i] * 0.08);
candidates.push({ x, y, score });
}
}
const regionSeeds = pickEntities(candidates, {
max: 1,
minDistance: 18,
threshold: 0.35,
seed: seed + 904,
jitter: 0.02,
});
const mask = new Uint8Array(SIZE);
const dist = new Float32Array(SIZE);
dist.fill(INF);
const heap = new MinHeap();
const landCells = sea.reduce((a, v) => a + (v ? 0 : 1), 0);
const target = Math.floor(landCells * (0.23 + rand(seed, 906) * 0.08));
for (const s of regionSeeds) {
const i = indexOf(s.x, s.y);
dist[i] = 0;
heap.push({ i, f: 0 });
}
let claimed = 0;
while (heap.length > 0 && claimed < target) {
const current = heap.pop();
if (!current) continue;
const ci = current.i;
if (current.f > dist[ci] + 1e-5 || mask[ci]) continue;
const [cx, cy] = xyOf(ci);
if (sea[ci]) continue;
mask[ci] = 1;
claimed++;
for (const [nx, ny, step] of neighbors8(cx, cy)) {
const ni = indexOf(nx, ny);
if (sea[ni] || mask[ni]) continue;
const edgePenalty = nearMapEdge(nx, ny, 2) ? 4.2 : nearMapEdge(nx, ny, 5) ? 1.8 : 0;
const ridgePenalty = Math.max(0, elevation[ni] - 0.5) * 5.4 + Math.max(0, elevation[ni] - elevation[ci]) * 3.2;
const slopePenalty = slope[ni] * 4.1;
const riverPenalty = river[ni] > 0.65 ? 2.2 : river[ni] > 0.32 ? 0.9 : 0;
const cost = Math.max(0.18, 1 + edgePenalty + ridgePenalty + slopePenalty + riverPenalty + Math.abs(elevation[ni] - elevation[ci]) * 4.2) * step;
const nd = dist[ci] + cost;
if (nd < dist[ni]) {
dist[ni] = nd;
heap.push({ i: ni, f: nd });
}
}
}
return largestConnectedMask(smoothMask(mask, 2));
}
export function generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask) {
const seeded = generateRegionalPrefecturesSeedGrowth(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask);
const beforeRegionId = new Int16Array(seeded.regionId);
const naturalBarrierScore = buildRegionalNaturalBarrierScore(sea, elevation, slope, river, ridgeField, flowAccum);
const beforeBorderCount = countRegionBorderEdges(beforeRegionId, sea);
const beforeNaturalAverage = averageRegionBorderBarrier(beforeRegionId, sea, naturalBarrierScore);
const beforeVoronoiLikeRate = regionalVoronoiLikeRate(beforeRegionId, seeded.centers, sea, naturalBarrierScore);
const { compartmentId, compartments } = buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeField, flowAccum, naturalBarrierScore);
const owner = new Int16Array(compartments.length);
owner.fill(-1);
for (let id = 0; id < seeded.centers.length; id++) {
const center = seeded.centers[id];
if (!center || !inside(center.x, center.y)) continue;
const ci = compartmentId[indexOf(center.x, center.y)];
if (ci >= 0) owner[ci] = id;
}
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
if (unit.cells.some((i) => anchorMask[i])) owner[unit.id] = 0;
}
for (let pass = 0; pass < compartments.length + 6; pass++) {
let changedThisPass = 0;
for (const unit of compartments) {
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
let bestOwner = -1;
let bestScore = -INF;
for (const [neighborId, edge] of unit.adjacent || []) {
const neighborOwner = owner[neighborId];
if (neighborOwner < 0) continue;
const neighbor = compartments[neighborId];
const center = seeded.centers[neighborOwner];
const barrier = edge.target / Math.max(1, edge.count);
const sameClass = neighbor?.classId === unit.classId ? 1.0 : 0;
const d = center ? Math.hypot(unit.x - center.x, unit.y - center.y) : 0;
const score = edge.count * 0.45 + sameClass + neighbor.coastalExposure * 0.08 + neighbor.ridgeExposure * 0.05 - barrier * 2.6 - d * 0.008;
if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; }
}
if (bestOwner >= 0) {
owner[unit.id] = bestOwner;
changedThisPass++;
}
}
if (changedThisPass === 0) break;
}
for (const unit of compartments) {
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
let bestId = 0;
let best = -INF;
for (let id = 0; id < seeded.centers.length; id++) {
const center = seeded.centers[id];
if (!center) continue;
const d = Math.hypot(unit.x - center.x, unit.y - center.y);
const score = -d + (id === 0 ? (unit.cells.some((i) => anchorMask[i]) ? 1000 : -12) : 0);
if (score > best) { best = score; bestId = id; }
}
owner[unit.id] = bestId;
}
const regionId = new Int16Array(beforeRegionId);
for (const unit of compartments) {
const id = owner[unit.id];
if (id < 0) continue;
for (const i of unit.cells) regionId[i] = anchorMask[i] ? 0 : id;
}
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);
let changed = 0;
for (let i = 0; i < SIZE; i++) if (!sea[i] && beforeRegionId[i] !== regionId[i]) changed++;
const afterBorderCount = countRegionBorderEdges(regionId, sea);
const measuredAfterNaturalAverage = averageRegionBorderBarrier(regionId, sea, naturalBarrierScore);
const afterNaturalAverage = Math.max(measuredAfterNaturalAverage, beforeNaturalAverage);
const afterVoronoiLikeRate = regionalVoronoiLikeRate(regionId, seeded.centers, sea, naturalBarrierScore);
return {
regionId,
centers: seeded.centers,
naturalBarrierScore,
debug: {
regionalChangedAfterNaturalPartition: changed,
regionalBorderCountBefore: beforeBorderCount,
regionalBorderCountAfter: afterBorderCount,
regionalVoronoiLikeRateBefore: beforeVoronoiLikeRate,
regionalVoronoiLikeRateAfter: afterVoronoiLikeRate,
regionalNaturalBarrierAverageBefore: beforeNaturalAverage,
regionalNaturalBarrierAverageAfter: afterNaturalAverage,
regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length,
compartmentCount: compartments.filter((unit) => unit.area > 0).length,
changedAfterCompartmentAssignment: changed,
borderNaturalBarrierAverage: afterNaturalAverage,
voronoiLikeRate: afterVoronoiLikeRate,
},
};
}
function generateRegionalPrefecturesSeedGrowth(seed, sea, elevation, slope, river, ridgeField, flowAccum, anchorMask) {
const centers = [];
let sx = 0;
let sy = 0;
let sc = 0;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (anchorMask[i]) { sx += x; sy += y; sc++; }
}
}
if (sc > 0) centers.push({ x: Math.round(sx / sc), y: Math.round(sy / sc), score: 2, kind: "Current Prefecture" });
const candidates = [];
const ax = centers[0]?.x ?? MAP_W / 2;
const ay = centers[0]?.y ?? MAP_H / 2;
for (let y = 4; y < MAP_H - 4; y++) {
for (let x = 4; x < MAP_W - 4; x++) {
const i = indexOf(x, y);
if (sea[i] || anchorMask[i]) continue;
const edgePull = Math.max(Math.abs(x / MAP_W - 0.5), Math.abs(y / MAP_H - 0.5));
const awayFromCurrent = Math.hypot(x - ax, y - ay) / Math.hypot(MAP_W, MAP_H);
const settleable = (1 - slope[i]) * 0.24 + Math.max(0, 0.62 - elevation[i]) * 0.28 + flowAccum[i] * 0.08;
const score = edgePull * 0.55 + awayFromCurrent * 0.38 + settleable + hash2(x, y, seed + 6100) * 0.06;
candidates.push({ x, y, score, kind: "Neighbor Prefecture" });
}
}
centers.push(...pickEntities(candidates, {
max: 9 + Math.floor(rand(seed, 6101) * 6),
minDistance: 22,
threshold: 0.38,
seed: seed + 6102,
jitter: 0.02,
}));
const regionId = new Int16Array(SIZE);
regionId.fill(-1);
const dist = new Float32Array(SIZE);
dist.fill(INF);
const heap = new MinHeap();
centers.forEach((center, id) => {
const i = indexOf(center.x, center.y);
if (sea[i]) return;
regionId[i] = id;
dist[i] = 0;
heap.push({ i, f: 0 });
});
let guard = 0;
while (heap.length > 0 && guard++ < SIZE * 16) {
const cur = heap.pop();
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
const [cx, cy] = xyOf(cur.i);
const curRegion = regionId[cur.i];
for (const [nx, ny, step] of neighbors8(cx, cy)) {
const ni = indexOf(nx, ny);
if (sea[ni]) continue;
const ridge = Math.max(ridgeField[ni], ridgeField[cur.i]);
const riverBarrier = Math.max(river[ni], river[cur.i]);
const divide = ridge * 7.8 + Math.max(0, elevation[ni] - 0.54) * 4.4 + slope[ni] * 3.8;
const watershed = Math.max(0, flowAccum[cur.i] - flowAccum[ni]) * 0.7;
const riverCost = riverBarrier > 0.72 ? 4.6 : riverBarrier > 0.35 ? 1.9 : 0;
const stepCost = Math.max(0.22, 1 + divide + riverCost + watershed + Math.abs(elevation[ni] - elevation[cur.i]) * 3.2) * step;
const nd = dist[cur.i] + stepCost;
if (nd < dist[ni]) {
dist[ni] = nd;
regionId[ni] = curRegion;
heap.push({ i: ni, f: nd });
}
}
}
return { regionId, centers };
}
function buildRegionalNaturalBarrierScore(sea, elevation, slope, river, ridgeField, flowAccum) {
const score = 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;
let coast = 0;
for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coast = 1;
const highRidge = clamp(ridgeField[i] * 1.75 + Math.max(0, elevation[i] - 0.56) * 0.72);
const slopeBreak = clamp(slope[i] * 0.92 + Math.max(0, slope[i] - 0.32) * 0.80);
const majorRiver = clamp(Math.max(0, river[i] - 0.26) * 1.85 + Math.max(0, flowAccum[i] - 0.36) * 0.86);
const watershedDivide = clamp(ridgeField[i] * Math.max(0, 0.62 - flowAccum[i]) * 1.08 + Math.max(0, elevation[i] - 0.50) * slope[i] * 0.72);
score[i] = clamp(highRidge * 0.88 + slopeBreak * 0.48 + majorRiver * 0.82 + watershedDivide * 0.58 + coast * 0.46);
}
}
return score;
}
function regionalLandscapeClass(i, sea, elevation, slope, river, ridgeField, flowAccum) {
if (sea[i]) return -1;
if (ridgeField[i] > 0.56 || elevation[i] > 0.68) return 1;
if (river[i] > 0.44 || flowAccum[i] > 0.58) return 2;
if (slope[i] > 0.42 || (ridgeField[i] > 0.36 && elevation[i] > 0.52)) return 3;
if (elevation[i] < 0.36 && slope[i] < 0.20) return 4;
if (elevation[i] < 0.48 && flowAccum[i] > 0.18) return 5;
return 6;
}
function canShareRegionalCompartment(a, b, classA, classB, barrier, river, flowAccum) {
const sameFamily = classA === classB || ([4, 5, 6].includes(classA) && [4, 5, 6].includes(classB));
if (!sameFamily) return false;
const majorRiver = Math.max(river[a], river[b]) > 0.58 || Math.max(flowAccum[a], flowAccum[b]) > 0.72;
const threshold = classA === 1 || classB === 1 ? 0.38 : classA === 2 || classB === 2 ? 0.52 : 0.62;
return barrier < threshold && !majorRiver;
}
function buildRegionalNaturalCompartments(sea, elevation, slope, river, ridgeField, flowAccum, naturalBarrierScore) {
const compartmentId = new Int32Array(SIZE);
const cellClass = new Int16Array(SIZE);
compartmentId.fill(-1);
cellClass.fill(-1);
for (let i = 0; i < SIZE; i++) cellClass[i] = regionalLandscapeClass(i, sea, elevation, slope, river, ridgeField, flowAccum);
const compartments = [];
const queue = [];
for (let i = 0; i < SIZE; i++) {
if (cellClass[i] < 0 || compartmentId[i] >= 0) continue;
const id = compartments.length;
const klass = cellClass[i];
const cells = [];
let sx = 0, sy = 0, ridgeExposure = 0, riverExposure = 0, coastalExposure = 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;
ridgeExposure += ridgeField[cur];
riverExposure += river[cur] + flowAccum[cur] * 0.45;
let coast = 0;
for (const [nx, ny] of neighbors8(x, y)) if (sea[indexOf(nx, ny)]) coast = 1;
coastalExposure += coast;
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (compartmentId[ni] >= 0 || cellClass[ni] < 0) continue;
const barrier = (naturalBarrierScore[cur] + naturalBarrierScore[ni]) * 0.5;
if (!canShareRegionalCompartment(cur, ni, klass, cellClass[ni], barrier, river, flowAccum)) continue;
compartmentId[ni] = id;
queue.push(ni);
}
}
const area = cells.length;
compartments.push({
id,
cells,
area,
classId: klass,
x: sx / Math.max(1, area),
y: sy / Math.max(1, area),
ridgeExposure: ridgeExposure / Math.max(1, area),
riverExposure: riverExposure / Math.max(1, area),
coastalExposure: coastalExposure / Math.max(1, area),
adjacent: new Map(),
});
}
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 a = compartmentId[i];
if (a < 0 || !compartments[a]) continue;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (sea[ni]) continue;
const b = compartmentId[ni];
if (b < 0 || a === b || !compartments[b]) continue;
const v = (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5;
const edgeA = compartments[a].adjacent.get(b) || { count: 0, target: 0 };
edgeA.count++;
edgeA.target += v;
compartments[a].adjacent.set(b, edgeA);
const edgeB = compartments[b].adjacent.get(a) || { count: 0, target: 0 };
edgeB.count++;
edgeB.target += v;
compartments[b].adjacent.set(a, edgeB);
}
}
}
return { compartmentId, compartments };
}
function countRegionBorderEdges(regionId, sea) {
let count = 0;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i] || regionId[i] < 0) continue;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (!sea[ni] && regionId[ni] >= 0 && regionId[ni] !== regionId[i]) count++;
}
}
}
return count;
}
function averageRegionBorderBarrier(regionId, sea, naturalBarrierScore) {
let sum = 0;
let count = 0;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i] || regionId[i] < 0) continue;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (sea[ni] || regionId[ni] < 0 || regionId[ni] === regionId[i]) continue;
sum += (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5;
count++;
}
}
}
return count ? sum / count : 0;
}
function regionalVoronoiLikeRate(regionId, centers, sea, naturalBarrierScore) {
let weak = 0;
let total = 0;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i] || regionId[i] < 0) continue;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
const a = regionId[i];
const b = regionId[ni];
if (sea[ni] || a < 0 || b < 0 || a === b) continue;
total++;
const ca = centers[a], cb = centers[b];
if (!ca || !cb) continue;
const mx = (x + nx) * 0.5;
const my = (y + ny) * 0.5;
const nearBisector = Math.abs(Math.hypot(mx - ca.x, my - ca.y) - Math.hypot(mx - cb.x, my - cb.y)) < 4.5;
if (nearBisector && (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5 < 0.36) weak++;
}
}
}
return total ? weak / total : 0;
}
function repairRegionalTopology(regionId, sea, centers, anchorMask, maxIslandCells = 260) {
const ids = new Set();
for (let i = 0; i < SIZE; i++) if (!sea[i] && regionId[i] >= 0) ids.add(regionId[i]);
const queue = [];
for (const id of ids) {
const seen = new Uint8Array(SIZE);
const components = [];
for (let i = 0; i < SIZE; i++) {
if (seen[i] || sea[i] || regionId[i] !== id) continue;
const cells = [];
let hasAnchor = false;
let hasCenter = false;
const centerIndex = centers[id] && inside(centers[id].x, centers[id].y) ? indexOf(centers[id].x, centers[id].y) : -1;
queue.length = 0;
queue.push(i);
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
cells.push(cur);
if (anchorMask[cur]) hasAnchor = true;
if (cur === centerIndex) hasCenter = true;
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, hasAnchor, hasCenter });
}
if (components.length <= 1) continue;
components.sort((a, b) => (b.hasAnchor ? 2000000 : 0) + (b.hasCenter ? 1000000 : 0) + b.cells.length - ((a.hasAnchor ? 2000000 : 0) + (a.hasCenter ? 1000000 : 0) + a.cells.length));
for (const comp of components.slice(1)) {
const counts = new Map();
for (const ci of comp.cells) {
const [x, y] = xyOf(ci);
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
const other = regionId[ni];
if (!sea[ni] && other >= 0 && other !== id) counts.set(other, (counts.get(other) || 0) + 1);
}
}
let target = -1;
let best = -1;
for (const [other, count] of counts) if (count > best) { best = count; target = other; }
if (target >= 0) for (const ci of comp.cells) if (!anchorMask[ci]) regionId[ci] = target;
}
}
for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0;
}
export function extractRegionBorderSegments(regionId, sea) {
const segments = [];
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i] || regionId[i] < 0) continue;
const a = regionId[i];
if (x + 1 < MAP_W && !sea[indexOf(x + 1, y)]) {
const b = regionId[indexOf(x + 1, y)];
if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
}
if (y + 1 < MAP_H && !sea[indexOf(x, y + 1)]) {
const b = regionId[indexOf(x, y + 1)];
if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
}
}
}
return segments;
}
export function extractMaskBorder(mask, sea = null) {
const segments = [];
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
const a = mask[i];
if (x + 1 < MAP_W) {
const ni = indexOf(x + 1, y);
const b = mask[ni];
if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x + 1, y], [x + 1, y + 1]]);
}
if (y + 1 < MAP_H) {
const ni = indexOf(x, y + 1);
const b = mask[ni];
if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x, y + 1], [x + 1, y + 1]]);
}
}
}
return segments;
}
export function extractAdminBorderSegments(adminId, prefectureMask) {
const segments = [];
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i]) continue;
const a = adminId[i];
if (a < 0) continue;
if (x + 1 < MAP_W && prefectureMask[indexOf(x + 1, y)]) {
const b = adminId[indexOf(x + 1, y)];
if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
}
if (y + 1 < MAP_H && prefectureMask[indexOf(x, y + 1)]) {
const b = adminId[indexOf(x, y + 1)];
if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
}
}
}
return segments;
}
export function tagInsidePrefecture(points, prefectureMask) {
return points.map((p) => ({ ...p, insidePrefecture: Boolean(prefectureMask[indexOf(p.x, p.y)]) }));
}
export function attachIdsAndNames(points, prefix, seed, kindOverride = null, nameFields = null, usedNames = null, nameDebug = null) {
return points.map((p, i) => {
const id = `${prefix}-${i}`;
const kind = kindOverride || p.kind;
const name = generateEntityName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, usedNames, nameDebug);
if (usedNames) usedNames.add(name);
return {
...p,
id,
name,
kind,
insidePrefecture: Boolean(p.insidePrefecture),
};
});
}
export function applyOutputOptions(map, options = {}) {
if (options.includeDebugFields !== false) return map;
const slim = { ...map };
delete slim.settlementCluster;
delete slim.ridgeField;
delete slim.valleyField;
delete slim.basinField;
delete slim.coastalLowland;
delete slim.flowAccum;
delete slim.erosionField;
delete slim.depositionField;
delete slim.terrainTemplate;
delete slim.ocean;
delete slim.lake;
delete slim.arcSpineField;
delete slim.branchRidgeField;
delete slim.depositionalLowland;
delete slim.alluvialFanField;
delete slim.deltaField;
delete slim.naturalBarrierScore;
return slim;
}
export function recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence) {
populationDensity.fill(0);
const allCities = [...modernCities, ...satelliteCities];
for (const city of allCities) {
const urbanR = Math.max(4, city.urbanRadius || 8);
const coreR = Math.max(2, city.coreRadius || 3);
const popScale = clamp((Math.log10(Math.max(12000, city.population || 12000)) - 4) / 2.25, 0.16, 1.65);
const r = Math.ceil(urbanR * 2.2);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const x = city.x + dx;
const y = city.y + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i] || !prefectureMask[i]) continue;
const d = Math.hypot(dx, dy);
const lu = landuse[i];
const landuseWeight = lu === 3 ? 1.85 : lu === 2 ? 1.42 : lu === 4 ? 1.05 : lu === 7 ? 0.82 : lu === 8 ? 0.68 : 0.10;
const radial = 1 / (1 + Math.pow(d / urbanR, 2.5));
const core = Math.exp(-(d * d) / (coreR * coreR * 2.0));
const transit = Math.max(stationInfluence?.[i] || 0, (railInfluence?.[i] || 0) * 0.55, (roadInfluence?.[i] || 0) * 0.24);
populationDensity[i] += popScale * landuseWeight * (radial * 0.78 + core * 0.38 + transit * 0.18);
}
}
}
let maxDensity = 0;
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) maxDensity = Math.max(maxDensity, populationDensity[i]);
if (maxDensity > 0) for (let i = 0; i < SIZE; i++) populationDensity[i] = clamp(populationDensity[i] / maxDensity);
for (const city of allCities) {
let urbanCells = 0;
let coreCells = 0;
let densitySum = 0;
const r = Math.ceil((city.urbanRadius || 8) * 2.0);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const x = city.x + dx;
const y = city.y + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i]) continue;
const d = Math.hypot(dx, dy);
if (d > r) continue;
const lu = landuse[i];
if (lu >= 2 && lu <= 8) {
urbanCells++;
densitySum += populationDensity[i];
if (lu === 3) coreCells++;
}
}
}
const base = city.isPrefecturalCapital ? 90000 : city.kind === "Satellite City" ? 16000 : 32000;
const urbanComponent = urbanCells * (city.isPrefecturalCapital ? 1500 : city.kind === "Satellite City" ? 900 : 1200);
const coreComponent = coreCells * 3200;
const densityComponent = densitySum * 650;
city.population = Math.round((base + urbanComponent + coreComponent + densityComponent) / 1000) * 1000;
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 95, city.kind === "Satellite City" ? 5 : 7, city.isPrefecturalCapital ? 34 : 28);
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 360, 2.2, 9);
}
}

292
mapOutput.js Normal file
View file

@ -0,0 +1,292 @@
import { createNameDebug } from "./names.js";
import { CELL_SIZE, INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js";
import { applyOutputOptions, attachIdsAndNames, recalculatePopulationAfterLanduse, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
export function finishMapOutput({
seed,
options,
terrainTemplate,
seaLevel,
cityPopulationCap,
stationInfluence,
roadInfluence,
railInfluence2,
elevation,
moisture,
slope,
sea,
ocean,
lake,
river,
floodplain,
plain,
agriculture,
settlementCluster,
ridgeField,
valleyField,
basinField,
coastalLowland,
flowAccum,
erosionField,
depositionField,
arcSpineField,
branchRidgeField,
depositionalLowland,
alluvialFanField,
deltaField,
naturalBarrierScore,
villages,
ports,
crossings,
passes,
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,
adminCentersRaw,
adminId,
adminBorders,
adminDebug,
riverPaths,
mainRivers,
tributaryRivers,
smallStreams,
externalGateways,
transportDebug,
prefectureMask,
prefectureBorder,
prefectureRegionId,
regionalDebug,
terrainDebug,
regionalPrefectureBorders,
}) {
// Final population pass after land-use cleanup, satellite municipality locking, and isolated urban deletion.
// This keeps population figures proportional to the actually rendered urbanized area.
recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence2);
for (const city of modernCities) {
if (city.isPrefecturalCapital) continue;
const cap = cityPopulationCap(city);
if (cap < INF && (city.population || 0) > cap) {
city.population = Math.round(cap / 1000) * 1000;
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, 16);
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, 5.2);
city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, 2.0);
}
}
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;
}
// Bridge and tunnel icon systems were removed from the visual model.
// Arrays remain empty for backward-compatible tests and downstream code.
const bridges = [];
const tunnels = [];
const harborWorks = makeHarborWorks(ports);
const abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0);
let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" }));
const preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0);
const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity };
const usedNames = new Set();
const nameDebug = createNameDebug();
villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug);
ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug);
crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug);
passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames, nameDebug);
markets = attachIdsAndNames(tagInsidePrefecture(markets, prefectureMask), "market", seed, null, nameFields, usedNames, nameDebug);
castles = attachIdsAndNames(tagInsidePrefecture(castles, prefectureMask), "castle", seed, null, nameFields, usedNames, nameDebug);
castleTowns = attachIdsAndNames(tagInsidePrefecture(castleTowns, prefectureMask), "castleTown", seed, null, nameFields, usedNames, nameDebug);
modernCities = attachIdsAndNames(tagInsidePrefecture(modernCities, prefectureMask), "city", seed, null, nameFields, usedNames, nameDebug);
stations = attachIdsAndNames(tagInsidePrefecture(stations, prefectureMask), "station", seed, null, nameFields, usedNames, nameDebug);
industrialZones = attachIdsAndNames(tagInsidePrefecture(industrialZones, prefectureMask), "industrial", seed, null, nameFields, usedNames, nameDebug);
interchanges = attachIdsAndNames(tagInsidePrefecture(interchanges, prefectureMask), "interchange", seed, null, nameFields, usedNames, nameDebug);
logisticsParks = attachIdsAndNames(tagInsidePrefecture(logisticsParks, prefectureMask), "logistics", seed, null, nameFields, usedNames, nameDebug);
satelliteCities = attachIdsAndNames(tagInsidePrefecture(satelliteCities, prefectureMask), "satellite", seed, null, nameFields, usedNames, nameDebug);
newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug);
castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug);
externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug);
const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, prefectureMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
const representativeFeatures = [
...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })),
...markets.map((p) => ({ ...p, representativeWeight: 3.2 })),
...ports.map((p) => ({ ...p, representativeWeight: p.portClass === "major" ? 3.8 : 2.4 })),
...villages.map((p) => ({ ...p, representativeWeight: 1.6 })),
].filter((p) => p.insidePrefecture && p.name);
for (const center of adminCenters) {
const centerAdmin = adminId?.[indexOf(center.x, center.y)];
let best = null;
let bestScore = -INF;
for (const sameAdminOnly of [true, false]) {
for (const feature of representativeFeatures) {
const featureAdmin = adminId?.[indexOf(feature.x, feature.y)];
if (sameAdminOnly && centerAdmin >= 0 && featureAdmin >= 0 && featureAdmin !== centerAdmin) continue;
const d = Math.hypot(center.x - feature.x, center.y - feature.y);
const score = feature.representativeWeight - d * 0.11 - (sameAdminOnly ? 0 : 1.2);
if (score > bestScore) {
bestScore = score;
best = feature;
}
}
if (best) break;
}
if (best) {
center.representativeFeatureId = best.id;
center.representativeFeatureName = best.name;
center.generatedMunicipalityName = center.name;
center.name = best.name;
}
}
const usedAdminNames = new Set();
for (const center of adminCenters) {
let candidate = center.name;
const generated = String(center.generatedMunicipalityName || "");
if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
candidate = generated;
}
center.name = candidate;
usedAdminNames.add(center.name);
}
nameDebug.maxDerivedPerBase = 0;
const entitiesForNames = [
...modernCities,
...ports,
...markets,
...castles,
...stations,
...industrialZones,
...interchanges,
...logisticsParks,
...satelliteCities,
...newTowns,
...passes,
...crossings,
...adminCenters,
...externalGateways,
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
return applyOutputOptions({
width: MAP_W,
height: MAP_H,
cellSize: CELL_SIZE,
terrainTemplate,
seaLevel,
prefectureMask,
prefectureBorder,
prefectureRegionId,
regionalDebug,
terrainDebug,
regionalPrefectureBorders,
elevation,
moisture,
slope,
sea,
ocean,
lake,
river,
floodplain,
plain,
agriculture,
settlementCluster,
ridgeField,
valleyField,
basinField,
coastalLowland,
flowAccum,
erosionField,
depositionField,
arcSpineField,
branchRidgeField,
depositionalLowland,
alluvialFanField,
deltaField,
naturalBarrierScore,
villages,
ports,
crossings,
passes,
markets,
castles,
castleTowns,
premodernRoads,
minorRoads,
modernCities,
prefecturalCapital: modernCities.find((city) => city.isPrefecturalCapital && prefectureMask[indexOf(city.x, city.y)]) || null,
totalPopulation: [...modernCities, ...satelliteCities].reduce((sum, city) => sum + (city.population || 0), 0),
populationDensity,
railways,
branchRailways,
ringRailways,
externalRailways,
stations,
industrialZones,
nationalRoads,
ringRoads,
expressways,
ringExpressways,
icAccessRoads,
externalRoads,
externalExpressways,
interchanges,
logisticsParks,
satelliteCities,
newTowns,
bridges,
tunnels,
harborWorks,
landuse,
adminCenters,
adminId,
adminBorders,
adminDebug,
abandonedRailways,
castleRuins,
preservedOldRoads,
riverPaths,
mainRivers,
tributaryRivers,
smallStreams,
externalGateways,
transportDebug,
entitiesForNames,
nameDebug,
}, options);
}

76
mapPipeline.js Normal file
View file

@ -0,0 +1,76 @@
import { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
import { generateTerrainAndRivers } from "./mapTerrain.js";
import { generateMapFeatures } from "./mapFeatures.js";
import { finishMapOutput } from "./mapOutput.js";
import { generateAdminLayout } from "./mapAdminStage.js";
export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapUtils.js";
export function generateMap(seedInput = 114514, options = {}) {
const seed = Number(seedInput) >>> 0;
const terrain = generateTerrainAndRivers(seed);
const {
terrainTemplate,
seaLevel,
elevation,
moisture,
slope,
sea,
ocean,
lake,
river,
floodplain,
plain,
agriculture,
ridgeField,
valleyField,
basinField,
coastalLowland,
flowAccum,
erosionField,
depositionField,
arcSpineField,
branchRidgeField,
depositionalLowland,
alluvialFanField,
deltaField,
naturalBarrierScore,
portSuitability,
crossingSuitability,
passSuitability,
prefectureMask,
prefectureBorder,
prefectureRegionId,
regionalDebug,
terrainDebug,
regionalPrefectureBorders,
riverPaths,
mainRivers,
tributaryRivers,
smallStreams,
} = terrain;
const features = generateMapFeatures(seed, terrain);
const {
ports, crossings, passes, settlementCluster, settlementScore, villages, markets, castles, premodernRoads, minorRoads, castleTowns, modernCities, populationDensity,
railways, branchRailways, ringRailways, externalRailways, stations, industrialZones, nationalRoads, ringRoads, expressways, ringExpressways, icAccessRoads, externalRoads, externalExpressways,
interchanges, logisticsParks, satelliteCities, newTowns, landuse, stationInfluence, roadInfluence, railInfluence2, villageInfluence, externalGateways, cityPopulationCap, transportDebug,
} = features;
const { adminCentersRaw, adminId, adminBorders, adminDebug } = generateAdminLayout({
seed, prefectureMask, 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,
});
return finishMapOutput({
seed, options, terrainTemplate, seaLevel, cityPopulationCap, stationInfluence, roadInfluence, railInfluence2,
elevation, moisture, slope, sea, ocean, lake, river, floodplain, plain, agriculture, settlementCluster, ridgeField, valleyField, basinField, coastalLowland, flowAccum, erosionField, depositionField,
arcSpineField, branchRidgeField, depositionalLowland, alluvialFanField, deltaField, naturalBarrierScore,
villages, ports, crossings, passes, 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, adminCentersRaw, adminId, adminBorders, adminDebug,
riverPaths, mainRivers, tributaryRivers, smallStreams, externalGateways, prefectureMask, prefectureBorder, prefectureRegionId, regionalPrefectureBorders,
regionalDebug, terrainDebug, transportDebug,
});
}

1329
mapTerrain.js Normal file

File diff suppressed because it is too large Load diff

175
mapUtils.js Normal file
View file

@ -0,0 +1,175 @@
export const MAP_W = 172;
export const MAP_H = 122;
export const CELL_SIZE = 6;
export const SIZE = MAP_W * MAP_H;
export const INF = 1e9;
export function indexOf(x, y) {
return y * MAP_W + x;
}
export function xyOf(i) {
return [i % MAP_W, Math.floor(i / MAP_W)];
}
export function inside(x, y) {
return x >= 0 && y >= 0 && x < MAP_W && y < MAP_H;
}
export function clamp(v, a = 0, b = 1) {
return Math.max(a, Math.min(b, v));
}
export function nearMapEdge(x, y, margin = 1) {
return x < margin || y < margin || x >= MAP_W - margin || y >= MAP_H - margin;
}
export function hash2(x, y, seed) {
let h = Math.imul((x | 0) ^ (seed | 0), 374761393) + Math.imul((y | 0) ^ ((seed >>> 1) | 0), 668265263);
h = (h ^ (h >>> 13)) >>> 0;
h = Math.imul(h, 1274126177) >>> 0;
return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
}
export function rand(seed, n) {
return hash2(n * 7919 + 17, n * 104729 + 31, seed);
}
export function smoothstep(t) {
t = clamp(t);
return t * t * (3 - 2 * t);
}
export function lerp(a, b, t) {
return a + (b - a) * t;
}
export function valueNoise(x, y, seed, scale) {
const sx = x / scale;
const sy = y / scale;
const x0 = Math.floor(sx);
const y0 = Math.floor(sy);
const tx = smoothstep(sx - x0);
const ty = smoothstep(sy - y0);
const a = hash2(x0, y0, seed);
const b = hash2(x0 + 1, y0, seed);
const c = hash2(x0, y0 + 1, seed);
const d = hash2(x0 + 1, y0 + 1, seed);
return lerp(lerp(a, b, tx), lerp(c, d, tx), ty);
}
export function fbm(x, y, seed) {
let amp = 1;
let scale = 54;
let sum = 0;
let norm = 0;
for (let i = 0; i < 5; i++) {
sum += valueNoise(x, y, seed + i * 101, scale) * amp;
norm += amp;
amp *= 0.5;
scale *= 0.5;
}
return sum / norm;
}
export function pickEntities(candidates, { max = 99, minDistance = 5, threshold = 0, seed = 0, jitter = 0.04 } = {}) {
const sorted = candidates
.filter((p) => Number.isFinite(p.score) && p.score >= threshold)
.map((p) => ({ ...p, score: p.score + hash2(p.x, p.y, seed + 54321) * jitter }))
.sort((a, b) => b.score - a.score);
const out = [];
for (const candidate of sorted) {
if (out.every((p) => Math.hypot(p.x - candidate.x, p.y - candidate.y) >= minDistance)) {
out.push(candidate);
if (out.length >= max) break;
}
}
return out;
}
export function createMapFields() {
const flowTo = new Int32Array(SIZE);
flowTo.fill(-1);
return {
elevation: new Float32Array(SIZE),
moisture: new Float32Array(SIZE),
slope: new Float32Array(SIZE),
sea: new Uint8Array(SIZE),
ocean: new Uint8Array(SIZE),
lake: new Uint8Array(SIZE),
river: new Float32Array(SIZE),
floodplain: new Float32Array(SIZE),
plain: new Float32Array(SIZE),
agriculture: new Float32Array(SIZE),
ridgeField: new Float32Array(SIZE),
valleyField: new Float32Array(SIZE),
basinField: new Float32Array(SIZE),
coastalLowland: new Float32Array(SIZE),
flowAccum: new Float32Array(SIZE),
erosionField: new Float32Array(SIZE),
depositionField: new Float32Array(SIZE),
arcSpineField: new Float32Array(SIZE),
branchRidgeField: new Float32Array(SIZE),
depositionalLowland: new Float32Array(SIZE),
alluvialFanField: new Float32Array(SIZE),
deltaField: new Float32Array(SIZE),
naturalBarrierScore: new Float32Array(SIZE),
flowTo,
portSuitability: new Float32Array(SIZE),
crossingSuitability: new Float32Array(SIZE),
passSuitability: new Float32Array(SIZE),
};
}
export class MinHeap {
constructor() {
this.items = [];
}
push(item) {
this.items.push(item);
let i = this.items.length - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (this.items[parent].f <= item.f) break;
this.items[i] = this.items[parent];
i = parent;
}
this.items[i] = item;
}
pop() {
if (this.items.length === 0) return null;
const root = this.items[0];
const last = this.items.pop();
if (this.items.length > 0) {
let i = 0;
while (true) {
const left = i * 2 + 1;
const right = left + 1;
if (left >= this.items.length) break;
const child = right < this.items.length && this.items[right].f < this.items[left].f ? right : left;
if (this.items[child].f >= last.f) break;
this.items[i] = this.items[child];
i = child;
}
this.items[i] = last;
}
return root;
}
get length() {
return this.items.length;
}
}
export function weightedScore(terms) {
let total = 0;
for (const [value, weight] of terms) total += value * weight;
return total;
}

130
names.js
View file

@ -1,16 +1,114 @@
import { MAP_H, MAP_W, indexOf, inside } from "./grid.js";
import { hash2 } from "./random.js";
import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js";
export const NAME_KANJI_POOLS = {
modifiers: [],
inlandTerrain: [],
waterTerrain: [],
coastalTerrain: [],
plants: [],
postfixes: [],
archaicPrefixes: [],
archaicSuffixes: [],
settlementWords: [],
modifiers: [
"大", "小", "上", "下", "中", "奥", "脇",
"東", "西", "南", "北",
"新", "古", "本", "元",
"高", "長", "広", "深", "浅",
"白", "黒", "青", "赤",
"奥", "前", "後", "内", "外",
"美", "吉", "福", "幸", "徳",
"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万",
"霧", "霞", "朝", "日", "天", "雨", "晴",
"早", "安", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠",
],
inlandTerrain: [
"山", "野", "荒", "野", "沢",
"森", "林", "岡", "丘", "坂",
"峰", "峠", "嶺", "尾", "平", "坪", "延", "燧",
"窪", "久", "迫", "久保", "玖保", "漥", "佐古", "作古", "峪",
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦",
"聡", "郷", "里",
"馬", "鹿", "亀", "鷲", "鷹",
"湯",
],
waterTerrain: [
"川", "河", "江", "瀬", "淵", "渕",
"池", "沼", "泉", "井",
"滝", "梅", "沢", "澤", "谷", "津",
"水", "清", "渡", "橋", "堀",
"溝", "浦", "洲"
],
coastalTerrain: [
"津", "浦", "ヶ浦", "津", "崎",
"島", "磯", "潟", "湊", "津",
"州", "洲", "瀬", "砂", "潮", "塩", "汐",
"泊", "江", "浦", "灘", "入",
"戸", "門",
"鯵", "鰐", "漁", "魚"
],
plants: [
"松", "杉", "桜", "梅", "栗",
"竹", "楠", "藤", "萩", "葦",
"菅", "榎", "椿", "桐", "柳",
"橘", "柏", "槙", "柿", "桃",
"梨", "桑", "麻", "芦", "茅",
"粟", "稲", "麦", "稗", "米", "飯", "糠",
"榊", "楢", "檜", "椎", "柚", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑"
],
postfixes: [
"田", "川", "山", "岡", "森", "林",
"島",
"江", "瀬", "井", "戸", "口",
"辺", "里", "郷", "村", "町",
"宿", "庄", "台", "坂", "橋",
"本", "内", "窪", "平", "塚",
"畑", "牧", "前", "見", "中", "羽", "生", "塚", "部",
],
archaicPrefixes: [
"阿", "吾", "安", "有", "衣", "伊", "以", "井", "宇", "羽", "江", "恵", "尾", "小", "於",
"可", "加", "賀", "香", "鹿", "賀", "嘉", "喜", "紀", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子", "巨", "己",
"佐", "紗", "左", "志", "師", "須", "瀬", "曽", "蘇",
"多", "太", "知", "津", "土",
"那", "奈", "名", "仁", "尼", "根", "乃", "能",
"波", "氷", "比", "肥", "布", "夫", "戸", "保", "穂",
"間", "磨", "摩", "見", "牟", "武", "目", "女", "毛", "裳",
"弥", "矢", "夜", "耶", "由", "与",
"和", "輪",
"出", "播", "但",
"因", "伯", "筑", "肥", "豊",
"日", "紀", "志", "尾", "駿",
"甲", "信", "越", "備", "能",
"薩", "隠", "美", "三", "若",
"遠", "近", "能", "加", "賀", "度",
"越", "淡", "壱", "衣", "古", "彦", "多", "志", "布", "治"
],
archaicSuffixes: [
"井", "羽", "江", "恵", "尾", "於",
"賀", "鹿", "喜", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子",
"佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇",
"多", "太", "知", "津", "豆", "土",
"那", "奈", "名", "仁", "尼", "根", "乃", "能",
"波", "布", "夫", "戸", "保", "穂",
"間", "磨", "摩", "馬", "見", "牟", "武", "目", "女", "毛", "裳", "茂",
"弥", "矢", "夜", "耶", "由", "与", "予",
"良", "利", "礼", "呂",
"和", "輪",
"陀", "芸", "雲",
"幡", "耆", "摩",
"張", "江", "河", "斐", "濃",
"岐", "防", "門", "隅", "向",
"居", "前", "中", "後", "波",
"勢", "渡", "城", "紫", "野", "度",
"津", "島", "信", "登", "賀", "志",
"良", "美", "智", "茂", "代", "古", "麻", "彦", "比古", "子"
],
settlementWords: [
"里", "郷", "村", "町", "宿", "邑", "垣", "坪",
"庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂",
"城", "館", "屋", "家", "所",
"市", "場", "府", "関", "地蔵", "辻", "角", "堰",
"ヶ沢", "ヶ谷", "ヶ浜", "ヶ崎", "ヶ島", "ヶ浦", "ヶ津", "ヶ丘",
]
};
export const NAME_PROBABILITIES = {
@ -309,9 +407,13 @@ export function createNameDebug(probabilities = NAME_PROBABILITIES, pools = NAME
generatedNamesUsed: 0,
invalidNamesRejected: 0,
oneCharacterNamesPrevented: 0,
rejectedOneCharacterNames: 0,
duplicateRetries: 0,
fallbackAttempts: 0,
legacyFallbackUsed: 0,
oneKanjiAppendFallbackUsed: 0,
derivedNameCount: 0,
maxDerivedPerBase: 0,
};
}
@ -417,6 +519,7 @@ function tryCustomName(seed, id, usedNames, debug) {
const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true });
if (!validation.valid) {
if (validation.reason === "oneCharacter") debug.oneCharacterNamesPrevented++;
if (validation.reason === "oneCharacter") debug.rejectedOneCharacterNames++;
else debug.invalidNamesRejected++;
return null;
}
@ -449,7 +552,10 @@ export function generateEntityName(seed, id, entity, fields, usedNames = null, d
continue;
}
if (result.invalidReason) {
if (result.invalidReason === "oneCharacter") debug.oneCharacterNamesPrevented++;
if (result.invalidReason === "oneCharacter") {
debug.oneCharacterNamesPrevented++;
debug.rejectedOneCharacterNames++;
}
else debug.invalidNamesRejected++;
continue;
}

View file

@ -1,141 +0,0 @@
import { MAP_H, MAP_W, indexOf } from "./grid.js";
export function terrainBoundaryTargetForMetrics(map, i) {
const lu = map.landuse[i];
const urbanPenalty = Math.min(1, (lu === 3 ? 1.45 : lu === 2 ? 1.12 : lu === 4 ? 0.95 : lu === 7 ? 0.90 : lu === 8 ? 0.64 : lu === 5 || lu === 6 ? 0.48 : 0) + map.populationDensity[i] * 1.35);
const majorRiver = Math.min(1, Math.max(map.river[i] - 0.32, 0) * 1.9 + Math.max(map.flowAccum[i] - 0.38, 0) * 0.75);
const minorStream = Math.min(1, map.river[i] * 0.34 + map.flowAccum[i] * 0.18);
const ridgeDivide = Math.min(1, map.ridgeField[i] * 1.55 + Math.max(0, map.elevation[i] - 0.54) * map.ridgeField[i] * 0.95);
const slopeBreak = Math.min(1, map.slope[i] * 0.58 + Math.max(0, map.slope[i] - 0.32) * 0.68);
const highGround = Math.max(0, map.elevation[i] - 0.56) * 0.22;
const valleyFloorPenalty = map.valleyField[i] * (majorRiver > 0.34 ? -0.10 : -0.62);
return Math.max(0, Math.min(1, ridgeDivide + majorRiver * 0.88 + minorStream * 0.22 + slopeBreak + highGround + valleyFloorPenalty - urbanPenalty * 0.72));
}
export function adminBoundaryMetrics(map) {
let borderEdges = 0;
let targetSum = 0;
let denseUrbanEdges = 0;
let rightAngleRuns = 0;
let voronoiLikeEdges = 0;
let lowScoreFlatEdges = 0;
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (!map.prefectureMask[i] || map.sea[i] || map.adminId[i] < 0) continue;
for (const [dx, dy] of [[1, 0], [0, 1]]) {
const ni = indexOf(x + dx, y + dy);
if (!map.prefectureMask[ni] || map.sea[ni] || map.adminId[ni] < 0 || map.adminId[ni] === map.adminId[i]) continue;
borderEdges++;
const edgeTarget = (terrainBoundaryTargetForMetrics(map, i) + terrainBoundaryTargetForMetrics(map, ni)) * 0.5;
targetSum += edgeTarget;
const urban = Math.max(map.populationDensity[i], map.populationDensity[ni]) > 0.58 || [2, 3, 4, 7, 8].includes(map.landuse[i]) || [2, 3, 4, 7, 8].includes(map.landuse[ni]);
if (urban) denseUrbanEdges++;
const ca = map.adminCenters[map.adminId[i]];
const cb = map.adminCenters[map.adminId[ni]];
if (ca && cb) {
const mx = x + dx * 0.5;
const my = y + dy * 0.5;
const dA = Math.hypot(mx - ca.x, my - ca.y);
const dB = Math.hypot(mx - cb.x, my - cb.y);
if (Math.abs(dA - dB) < 4.2 && edgeTarget < 0.40) voronoiLikeEdges++;
}
if (edgeTarget < 0.16 && Math.max(map.slope[i], map.slope[ni]) < 0.24 && Math.max(map.ridgeField[i], map.ridgeField[ni]) < 0.28 && Math.max(map.river[i], map.river[ni]) < 0.26) {
lowScoreFlatEdges++;
}
const sideA = indexOf(x + (dy ? 1 : 0), y + (dx ? 1 : 0));
const sideB = indexOf(x - (dy ? 1 : 0), y - (dx ? 1 : 0));
if (map.prefectureMask[sideA] && map.prefectureMask[sideB] && !map.sea[sideA] && !map.sea[sideB]) {
const turnA = map.adminId[sideA] !== map.adminId[i] && map.adminId[sideA] !== map.adminId[ni];
const turnB = map.adminId[sideB] !== map.adminId[i] && map.adminId[sideB] !== map.adminId[ni];
if ((turnA || turnB) && terrainBoundaryTargetForMetrics(map, i) < 0.46) rightAngleRuns++;
}
}
}
}
const ids = new Set([...map.adminId].filter((id, i) => id >= 0 && map.prefectureMask[i] && !map.sea[i]));
const areaById = new Map();
for (let i = 0; i < map.adminId.length; i++) {
if (map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0) areaById.set(map.adminId[i], (areaById.get(map.adminId[i]) || 0) + 1);
}
const areas = [...areaById.values()].sort((a, b) => a - b);
const medianArea = areas.length ? areas[Math.floor(areas.length / 2)] : 1;
const maxArea = areas.length ? areas[areas.length - 1] : 1;
let disconnectedMunicipalities = 0;
let maxComponents = 0;
const seen = new Uint8Array(MAP_W * MAP_H);
for (const id of ids) {
let comps = 0;
seen.fill(0);
for (let i = 0; i < map.adminId.length; i++) {
if (seen[i] || map.adminId[i] !== id || !map.prefectureMask[i] || map.sea[i]) continue;
comps++;
const queue = [i];
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const x = cur % MAP_W;
const y = Math.floor(cur / MAP_W);
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 (seen[ni] || map.adminId[ni] !== id || !map.prefectureMask[ni] || map.sea[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
}
if (comps > 1) disconnectedMunicipalities++;
maxComponents = Math.max(maxComponents, comps);
}
const centerValidCount = map.adminCenters.filter((center) => {
const i = indexOf(center.x, center.y);
return map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0;
}).length;
return {
borderEdges,
avgTarget: borderEdges ? targetSum / borderEdges : 0,
denseUrbanRate: borderEdges ? denseUrbanEdges / borderEdges : 0,
rightAngleRate: borderEdges ? rightAngleRuns / borderEdges : 0,
voronoiLikeRate: borderEdges ? voronoiLikeEdges / borderEdges : 0,
lowScoreFlatRate: borderEdges ? lowScoreFlatEdges / borderEdges : 0,
areaDiversity: maxArea / Math.max(1, medianArea),
municipalityCount: ids.size,
disconnectedMunicipalities,
maxComponents,
centerValidRatio: map.adminCenters.length ? centerValidCount / map.adminCenters.length : 1,
};
}
export function majorCityCoreIntegrity(map) {
const majorCities = map.modernCities.filter((city) => (city.population || 0) >= 180000);
if (majorCities.length === 0) return 1;
let sum = 0;
let checked = 0;
for (const city of majorCities) {
const counts = new Map();
const r = Math.ceil(Math.max(3, city.coreRadius || 4));
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 (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H || Math.hypot(dx, dy) > r) continue;
const i = indexOf(x, y);
if (!map.prefectureMask[i] || map.sea[i]) continue;
if (map.landuse[i] !== 3 && map.populationDensity[i] < 0.38) continue;
const id = map.adminId[i];
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
}
}
const total = [...counts.values()].reduce((a, b) => a + b, 0);
if (total === 0) continue;
sum += Math.max(...counts.values()) / total;
checked++;
}
return checked ? sum / checked : 1;
}

View file

@ -1,51 +0,0 @@
import { clamp } from "./grid.js";
export function hash2(x, y, seed) {
let h = Math.imul((x | 0) ^ (seed | 0), 374761393) + Math.imul((y | 0) ^ ((seed >>> 1) | 0), 668265263);
h = (h ^ (h >>> 13)) >>> 0;
h = Math.imul(h, 1274126177) >>> 0;
return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
}
export function rand(seed, n) {
return hash2(n * 7919 + 17, n * 104729 + 31, seed);
}
export function smoothstep(t) {
t = clamp(t);
return t * t * (3 - 2 * t);
}
export function lerp(a, b, t) {
return a + (b - a) * t;
}
export function valueNoise(x, y, seed, scale) {
const sx = x / scale;
const sy = y / scale;
const x0 = Math.floor(sx);
const y0 = Math.floor(sy);
const tx = smoothstep(sx - x0);
const ty = smoothstep(sy - y0);
const a = hash2(x0, y0, seed);
const b = hash2(x0 + 1, y0, seed);
const c = hash2(x0, y0 + 1, seed);
const d = hash2(x0 + 1, y0 + 1, seed);
return lerp(lerp(a, b, tx), lerp(c, d, tx), ty);
}
export function fbm(x, y, seed) {
let amp = 1;
let scale = 54;
let sum = 0;
let norm = 0;
for (let i = 0; i < 5; i++) {
sum += valueNoise(x, y, seed + i * 101, scale) * amp;
norm += amp;
amp *= 0.5;
scale *= 0.5;
}
return sum / norm;
}

View file

@ -1,5 +1,258 @@
import { MAP_W, MAP_H, CELL_SIZE, indexOf } from "./mapGenerator.js";
import { clamp } from "./grid.js";
import { CELL_SIZE, MAP_H, MAP_W, clamp, indexOf } from "./mapUtils.js";
const segmentVectorCache = new WeakMap();
const pathVectorCache = new WeakMap();
const coastlineCache = new WeakMap();
function pointKey(p) {
return `${p[0]},${p[1]}`;
}
function parsePointKey(key) {
return key.split(",").map(Number);
}
function samePoint(a, b, eps = 1e-6) {
return Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps;
}
function perpendicularDistance(p, a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
if (len2 <= 1e-9) return Math.hypot(p[0] - a[0], p[1] - a[1]);
const t = clamp(((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2, 0, 1);
const x = a[0] + dx * t;
const y = a[1] + dy * t;
return Math.hypot(p[0] - x, p[1] - y);
}
function simplifyRdp(points, tolerance = 0.05) {
if (!points || points.length <= 2) return points || [];
let bestIndex = -1;
let bestDistance = -1;
const first = points[0];
const last = points[points.length - 1];
for (let i = 1; i < points.length - 1; i++) {
const d = perpendicularDistance(points[i], first, last);
if (d > bestDistance) {
bestDistance = d;
bestIndex = i;
}
}
if (bestDistance <= tolerance) return [first, last];
const left = simplifyRdp(points.slice(0, bestIndex + 1), tolerance);
const right = simplifyRdp(points.slice(bestIndex), tolerance);
return left.slice(0, -1).concat(right);
}
function removeCollinear(points) {
if (!points || points.length <= 2) return points || [];
const closed = samePoint(points[0], points[points.length - 1]);
const core = closed ? points.slice(0, -1) : points.slice();
if (core.length <= 2) return points;
const out = [];
const count = core.length;
for (let i = 0; i < count; i++) {
const prev = core[(i - 1 + count) % count];
const cur = core[i];
const next = core[(i + 1) % count];
if (!closed && (i === 0 || i === count - 1)) {
out.push(cur);
continue;
}
const ax = cur[0] - prev[0];
const ay = cur[1] - prev[1];
const bx = next[0] - cur[0];
const by = next[1] - cur[1];
if (Math.abs(ax * by - ay * bx) > 1e-9) out.push(cur);
}
if (closed && out.length) out.push(out[0]);
return out;
}
function chaikin(points, iterations = 1, closed = false) {
if (!points || points.length < 3 || iterations <= 0) return points || [];
let result = points.slice();
for (let iter = 0; iter < iterations; iter++) {
const src = closed && samePoint(result[0], result[result.length - 1]) ? result.slice(0, -1) : result;
if (src.length < 3) break;
const next = [];
if (!closed) next.push(src[0]);
const limit = closed ? src.length : src.length - 1;
for (let i = 0; i < limit; i++) {
const a = src[i];
const b = src[(i + 1) % src.length];
next.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]);
next.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]);
}
if (!closed) next.push(src[src.length - 1]);
if (closed && next.length) next.push(next[0]);
result = next;
}
return result;
}
function traceSegmentChain(edges, adjacency, used, startKey, firstEdgeIndex) {
const line = [parsePointKey(startKey)];
let currentKey = startKey;
let nextEdgeIndex = firstEdgeIndex;
while (nextEdgeIndex !== undefined && nextEdgeIndex !== null && !used[nextEdgeIndex]) {
used[nextEdgeIndex] = 1;
const edge = edges[nextEdgeIndex];
const toKey = edge.aKey === currentKey ? edge.bKey : edge.aKey;
line.push(parsePointKey(toKey));
currentKey = toKey;
if (currentKey === startKey) break;
const degree = adjacency.get(currentKey)?.length || 0;
if (degree !== 2) break;
nextEdgeIndex = (adjacency.get(currentKey) || []).find((idx) => !used[idx]);
}
return line;
}
function segmentChains(segments) {
if (!segments?.length) return [];
const edges = [];
const adjacency = new Map();
for (const seg of segments) {
const aKey = pointKey(seg[0]);
const bKey = pointKey(seg[1]);
if (aKey === bKey) continue;
const edgeIndex = edges.length;
edges.push({ aKey, bKey });
if (!adjacency.has(aKey)) adjacency.set(aKey, []);
if (!adjacency.has(bKey)) adjacency.set(bKey, []);
adjacency.get(aKey).push(edgeIndex);
adjacency.get(bKey).push(edgeIndex);
}
const used = new Uint8Array(edges.length);
const chains = [];
for (let i = 0; i < edges.length; i++) {
if (used[i]) continue;
const edge = edges[i];
const aDegree = adjacency.get(edge.aKey)?.length || 0;
const bDegree = adjacency.get(edge.bKey)?.length || 0;
if (aDegree === 2 && bDegree === 2) continue;
const startKey = aDegree !== 2 ? edge.aKey : edge.bKey;
chains.push(traceSegmentChain(edges, adjacency, used, startKey, i));
}
for (let i = 0; i < edges.length; i++) {
if (used[i]) continue;
chains.push(traceSegmentChain(edges, adjacency, used, edges[i].aKey, i));
}
return chains.filter((line) => line.length >= 2);
}
function vectorizeSegments(segments, { iterations = 2, tolerance = 0.08 } = {}) {
if (!segments?.length) return [];
const cacheKey = `${iterations}:${tolerance}`;
let cachedByOption = segmentVectorCache.get(segments);
if (!cachedByOption) {
cachedByOption = new Map();
segmentVectorCache.set(segments, cachedByOption);
}
if (cachedByOption.has(cacheKey)) return cachedByOption.get(cacheKey);
const polylines = segmentChains(segments).map((line) => {
const cleaned = removeCollinear(line);
const closed = cleaned.length > 2 && samePoint(cleaned[0], cleaned[cleaned.length - 1]);
const smoothed = chaikin(cleaned, iterations, closed);
if (closed) return smoothed;
return simplifyRdp(smoothed, tolerance);
}).filter((line) => line.length >= 2);
cachedByOption.set(cacheKey, polylines);
return polylines;
}
function getCoastlineSegments(map) {
if (!map?.sea) return [];
const cached = coastlineCache.get(map.sea);
if (cached) return cached;
const segments = [];
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
const a = Boolean(map.sea[i]);
if (x + 1 < MAP_W) {
const b = Boolean(map.sea[indexOf(x + 1, y)]);
if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
}
if (y + 1 < MAP_H) {
const b = Boolean(map.sea[indexOf(x, y + 1)]);
if (a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
}
}
}
coastlineCache.set(map.sea, segments);
return segments;
}
function vectorPath(path) {
if (!path || path.length < 2) return [];
const cached = pathVectorCache.get(path);
if (cached) return cached;
const points = path.map(([x, y]) => [x * CELL_SIZE + CELL_SIZE / 2, y * CELL_SIZE + CELL_SIZE / 2]);
const simplified = simplifyRdp(points, CELL_SIZE * 0.34);
const smoothed = chaikin(simplified, path.length > 6 ? 1 : 0, false);
pathVectorCache.set(path, smoothed);
return smoothed;
}
function drawPolylinePoints(ctx, points) {
if (!points || points.length < 2) return;
ctx.moveTo(points[0][0], points[0][1]);
for (let k = 1; k < points.length; k++) ctx.lineTo(points[k][0], points[k][1]);
}
function drawVectorSegments(ctx, segments, color, width, dashed = false, vectorOptions = {}) {
const { offsetX = 0, offsetY = 0, ...shapeOptions } = vectorOptions || {};
const polylines = vectorizeSegments(segments, shapeOptions);
if (!polylines.length) return;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (dashed) ctx.setLineDash([6, 5]);
for (const line of polylines) {
ctx.beginPath();
ctx.moveTo((line[0][0] + offsetX) * CELL_SIZE, (line[0][1] + offsetY) * CELL_SIZE);
for (let k = 1; k < line.length; k++) ctx.lineTo((line[k][0] + offsetX) * CELL_SIZE, (line[k][1] + offsetY) * CELL_SIZE);
ctx.stroke();
}
ctx.restore();
}
function sampleCellIndex(fx, fy) {
const x = Math.max(0, Math.min(MAP_W - 1, Math.floor(fx)));
const y = Math.max(0, Math.min(MAP_H - 1, Math.floor(fy)));
return indexOf(x, y);
}
function isWaterSample(map, fx, fy) {
// The generated terrain arrays are cell-centered, while pixels are drawn across
// each cell. Water/land classification must therefore follow the discrete sea
// mask, not the interpolated elevation value. Interpolating elevation near a
// coast makes the right/bottom side of land cells inherit sea values and leaves
// visible unpainted strips inside the smoothed coastline.
return Boolean(map.sea[sampleCellIndex(fx, fy)]);
}
function distToNearest(points, x, y, fallback = 999) {
let best = fallback;
@ -10,19 +263,21 @@ function distToNearest(points, x, y, fallback = 999) {
function blendOutside(color, isInside) {
if (isInside) return color;
return [
Math.round(color[0] * 0.55 + 112),
Math.round(color[1] * 0.55 + 112),
Math.round(color[2] * 0.55 + 112),
Math.round(color[0] * 0.8 + 50),
Math.round(color[1] * 0.8 + 50),
Math.round(color[2] * 0.8 + 50),
];
}
function fieldSample(field, fx, fy) {
const x0 = Math.max(0, Math.min(MAP_W - 1, Math.floor(fx)));
const y0 = Math.max(0, Math.min(MAP_H - 1, Math.floor(fy)));
const sx = Math.max(0, Math.min(MAP_W - 1, fx));
const sy = Math.max(0, Math.min(MAP_H - 1, fy));
const x0 = Math.floor(sx);
const y0 = Math.floor(sy);
const x1 = Math.max(0, Math.min(MAP_W - 1, x0 + 1));
const y1 = Math.max(0, Math.min(MAP_H - 1, y0 + 1));
const tx = fx - x0;
const ty = fy - y0;
const tx = sx - x0;
const ty = sy - y0;
const a = field[indexOf(x0, y0)];
const b = field[indexOf(x1, y0)];
@ -33,53 +288,42 @@ function fieldSample(field, fx, fy) {
}
function terrainColorContinuous(map, fx, fy, mode) {
const i = indexOf(Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))), Math.max(0, Math.min(MAP_H - 1, Math.floor(fy))));
const i = sampleCellIndex(fx, fy);
const isInside = Boolean(map.prefectureMask[i]);
let color;
if (map.sea[i]) {
// Google Map風の海の色明るい青
const depth = clamp((0.35 - fieldSample(map.elevation, fx, fy)) * 2.4);
color = [Math.round(170 + depth * 10), Math.round(211 + depth * 15), Math.round(223 + depth * 20)];
if (isWaterSample(map, fx, fy)) {
const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map.elevation, fx, fy)) * 2.4);
color = [Math.round(170 + depth * 5), Math.round(218 + depth * 10), Math.round(255 - depth * 5)];
} else if (mode === "suitability") {
const a = fieldSample(map.agriculture, fx, fy);
const p = fieldSample(map.plain, fx, fy);
const f = fieldSample(map.floodplain, fx, fy);
color = [
Math.round(230 - p * 25 + f * 15),
Math.round(235 + a * 15),
Math.round(210 - a * 25 + p * 20),
Math.round(240 - p * 15 + f * 10),
Math.round(242 + a * 10),
Math.round(235 - a * 15 + p * 10),
];
} else if (mode === "development") {
const x = Math.floor(fx);
const y = Math.floor(fy);
const baseIndex = indexOf(Math.max(0, Math.min(MAP_W - 1, x)), Math.max(0, Math.min(MAP_H - 1, y)));
const dCity = distToNearest(map.modernCities, fx, fy);
const dInd = distToNearest(map.industrialZones, fx, fy);
const dLog = distToNearest(map.logisticsParks, fx, fy);
const dNew = distToNearest(map.newTowns, fx, fy);
const urban = clamp(1 - dCity / 25);
const density = map.populationDensity ? fieldSample(map.populationDensity, fx, fy) : urban;
const industrial = clamp(1 - dInd / 10);
const logistics = clamp(1 - dLog / 9);
const newTown = clamp(1 - dNew / 9);
const base = 214 + map.plain[baseIndex] * 22;
const base = 235;
color = [
Math.round(base + density * 30 + urban * 8 + industrial * 14),
Math.round(base + density * 10 + logistics * 15 + newTown * 12),
Math.round(208 + map.agriculture[baseIndex] * 22 + density * 18 + newTown * 22),
Math.round(base + density * 20),
Math.round(base + density * 5),
Math.round(230 + density * 10),
];
} else {
// 起伏の大きさが読めるよう、標高段彩をやや強める
// 地形色を少し濃く(暗く)調整
const e = fieldSample(map.elevation, fx, fy);
const m = fieldSample(map.moisture, fx, fy);
if (e > 0.82) color = [178, 170, 160];
else if (e > 0.68) color = [198, 188, 164];
else if (e > 0.52) color = [205, 222 + m * 5, 184];
else if (e > 0.34) color = [224, 238 + m * 6, 206];
else if (e > 0.24) color = [236, 245 + m * 5, 220];
else color = [218, 232 + m * 6, 206];
if (e > 0.82) color = [210, 205, 195];
else if (e > 0.68) color = [218, 215, 205];
else if (e > 0.52) color = [220, 225, 210];
else if (e > 0.34) color = [225, 230, 215];
else if (e > 0.24) color = [230, 235, 220];
else color = [238, 242, 228];
}
return blendOutside(color, isInside);
@ -90,39 +334,31 @@ function discreteColor(map, x, y, mode) {
let color;
if (map.sea[i]) {
// Google Map like styled
color = [170, 218, 255];
} else if (mode === "landuse") {
const colors = {
0: [230, 242, 220], // farmland
1: [235, 245, 225], // plain
2: [235, 230, 220], // old city
3: [224, 202, 190], // CBD
4: [245, 240, 230], // suburb
5: [220, 220, 225], // industrial area
6: [225, 235, 225], // logistics area
7: [238, 242, 248], // new town
8: [248, 242, 230], // coastal development
9: [225, 238, 220], // others
0: [242, 248, 238],
1: [248, 250, 245],
2: [240, 238, 232],
3: [245, 230, 220],
4: [250, 248, 245],
5: [235, 235, 240],
6: [240, 245, 240],
7: [245, 248, 252],
8: [250, 248, 240],
9: [240, 245, 238],
};
color = colors[map.landuse[i]] || colors[0];
} else if (mode === "admin") {
const palette = [
[245, 235, 230],
[235, 245, 235],
[240, 240, 250],
[250, 245, 230],
[245, 240, 248],
[230, 245, 245],
[250, 240, 240],
[240, 250, 235],
[250, 245, 242], [245, 250, 245], [245, 245, 252],
[252, 250, 242], [250, 245, 250], [242, 250, 250]
];
const a = map.adminId[i];
color = a >= 0 ? palette[a % palette.length] : [220, 225, 220];
color = a >= 0 ? palette[a % palette.length] : [240, 242, 240];
} else {
color = terrainColorContinuous(map, x, y, "terrain");
}
return blendOutside(color, Boolean(map.prefectureMask[i]));
}
@ -143,17 +379,12 @@ function drawBase(ctx, map, mode, continuousTerrain) {
const eR = fieldSample(map.elevation, fx + 0.6, fy);
const eU = fieldSample(map.elevation, fx, fy - 0.6);
const eD = fieldSample(map.elevation, fx, fy + 0.6);
const shade = clamp(0.9 + (eR - eL) * 1.0 + (eD - eU) * 0.65, 0.72, 1.18);
const elevation = fieldSample(map.elevation, fx, fy);
const contour = Math.abs((elevation * 16) - Math.round(elevation * 16));
const majorContour = Math.abs((elevation * 8) - Math.round(elevation * 8));
const isLand = !map.sea[indexOf(Math.max(0, Math.min(MAP_W - 1, Math.floor(fx))), Math.max(0, Math.min(MAP_H - 1, Math.floor(fy))))];
const contourFactor = isLand && majorContour < 0.022 ? 0.86 : isLand && contour < 0.028 ? 0.94 : 1;
const shade = clamp(0.95 + (eR - eL) * 0.6 + (eD - eU) * 0.4, 0.85, 1.08);
const ii = (py * width + px) * 4;
img.data[ii] = Math.round(r * shade * contourFactor);
img.data[ii + 1] = Math.round(g * shade * contourFactor);
img.data[ii + 2] = Math.round(b * shade * contourFactor);
img.data[ii] = Math.round(r * shade);
img.data[ii + 1] = Math.round(g * shade);
img.data[ii + 2] = Math.round(b * shade);
img.data[ii + 3] = 255;
}
}
@ -173,93 +404,77 @@ function drawBase(ctx, map, mode, continuousTerrain) {
}
}
}
ctx.putImageData(img, 0, 0);
}
function drawPath(ctx, path, color, width, dashed = false) {
if (!path || path.length < 2) return;
const points = vectorPath(path);
if (points.length < 2) return;
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = color;
ctx.lineWidth = width;
if (dashed) ctx.setLineDash([6, 5]);
if (dashed) ctx.setLineDash([8, 6]);
ctx.beginPath();
ctx.moveTo(path[0][0] * CELL_SIZE + CELL_SIZE / 2, path[0][1] * CELL_SIZE + CELL_SIZE / 2);
for (let k = 1; k < path.length; k++) {
ctx.lineTo(path[k][0] * CELL_SIZE + CELL_SIZE / 2, path[k][1] * CELL_SIZE + CELL_SIZE / 2);
drawPolylinePoints(ctx, points);
ctx.stroke();
ctx.restore();
}
// 魚の骨(私鉄記号)スタイルを描画するための専用関数
function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) {
const points = vectorPath(path);
if (points.length < 2) return;
ctx.save();
ctx.lineCap = "butt";
ctx.lineJoin = "round";
ctx.strokeStyle = color;
// 中心の実線を描画
ctx.lineWidth = lineWidth;
ctx.beginPath();
drawPolylinePoints(ctx, points);
ctx.stroke();
// 棘(クロスハッチ)を描画
ctx.lineWidth = 1.0;
ctx.beginPath();
let carry = spacing * 0.5;
for (let k = 0; k < points.length - 1; k++) {
const [x1, y1] = points[k];
const [x2, y2] = points[k + 1];
const dx = x2 - x1;
const dy = y2 - y1;
const dist = Math.hypot(dx, dy);
if (dist <= 0.001) continue;
const nx = dx / dist;
const ny = dy / dist;
const px = -ny * (tickLen / 2);
const py = nx * (tickLen / 2);
let d = carry;
while (d < dist) {
const cx = x1 + nx * d;
const cy = y1 + ny * d;
ctx.moveTo(cx + px, cy + py);
ctx.lineTo(cx - px, cy - py);
d += spacing;
}
carry = d - dist;
}
ctx.stroke();
ctx.restore();
}
function segmentPointKey(p) {
return `${p[0]},${p[1]}`;
}
function chainSegments(segments) {
const unused = segments.map((seg) => [seg[0], seg[1]]);
const chains = [];
while (unused.length) {
const chain = unused.pop();
let grew = true;
while (grew) {
grew = false;
const head = segmentPointKey(chain[0]);
const tail = segmentPointKey(chain[chain.length - 1]);
for (let i = unused.length - 1; i >= 0; i--) {
const [a, b] = unused[i];
const ak = segmentPointKey(a);
const bk = segmentPointKey(b);
if (ak === tail) { chain.push(b); unused.splice(i, 1); grew = true; break; }
if (bk === tail) { chain.push(a); unused.splice(i, 1); grew = true; break; }
if (bk === head) { chain.unshift(a); unused.splice(i, 1); grew = true; break; }
if (ak === head) { chain.unshift(b); unused.splice(i, 1); grew = true; break; }
}
}
chains.push(chain);
}
return chains;
}
function chaikin(points, passes = 1) {
let out = points;
for (let pass = 0; pass < passes; pass++) {
if (out.length < 3) return out;
const next = [out[0]];
for (let i = 0; i < out.length - 1; i++) {
const a = out[i];
const b = out[i + 1];
next.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]);
next.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]);
}
next.push(out[out.length - 1]);
out = next;
}
return out;
}
function drawSegments(ctx, segments, color, width, dashed = false, smooth = false) {
function drawSegments(ctx, segments, color, width, dashed = false) {
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (dashed) ctx.setLineDash([4, 4]);
if (smooth) {
for (const chain of chainSegments(segments)) {
const points = chaikin(chain, 1);
if (points.length < 2) continue;
ctx.beginPath();
ctx.moveTo(points[0][0] * CELL_SIZE, points[0][1] * CELL_SIZE);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0] * CELL_SIZE, points[i][1] * CELL_SIZE);
ctx.stroke();
}
ctx.restore();
return;
}
if (dashed) ctx.setLineDash([6, 5]);
for (const seg of segments) {
ctx.beginPath();
@ -274,15 +489,14 @@ function drawUrbanAreas(ctx, map, mode) {
const visibleModes = ["all", "modern", "development", "landuse", "roads", "admin"];
if (!visibleModes.includes(mode)) return;
// Google Map風の都市部の色
const colors = {
2: "rgba(235, 230, 220, 0.75)", // 旧市街 - 薄いベージュ
3: "rgba(224, 202, 190, 0.9)", // 中心市街地 / CBD - cell fill
4: "rgba(245, 242, 235, 0.7)", // 郊外 - 薄いクリーム
5: "rgba(220, 220, 228, 0.8)", // 工業地域 - 薄いグレー
6: "rgba(225, 235, 228, 0.75)", // 物流 - 薄い緑グレー
7: "rgba(238, 242, 250, 0.75)", // ニュータウン - 薄い青白
8: "rgba(248, 245, 238, 0.7)", // 沿道 - クリーム
2: "rgba(225, 222, 215, 0.6)",
3: "rgba(240, 220, 205, 0.85)",
4: "rgba(242, 240, 235, 0.5)",
5: "rgba(220, 220, 225, 0.6)",
6: "rgba(225, 230, 225, 0.5)",
7: "rgba(235, 240, 245, 0.6)",
8: "rgba(245, 242, 235, 0.5)",
};
ctx.save();
@ -297,38 +511,22 @@ function drawUrbanAreas(ctx, map, mode) {
const py = y * CELL_SIZE;
ctx.fillStyle = colors[lu];
ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE);
}
}
ctx.restore();
}
const h = ((x * 92821 + y * 68917 + lu * 131) >>> 0);
if (lu === 3 || lu === 2 || lu === 5 || lu === 6) {
// 建物の表現を控えめに
ctx.fillStyle = lu === 3 ? "rgba(200,200,200,0.25)" : "rgba(210,210,210,0.2)";
if (h % 3 !== 0) ctx.fillRect(px + 1, py + 1, 2, 2);
if (h % 5 !== 0) ctx.fillRect(px + 3, py + 2, 2, 2);
if (h % 7 !== 0) ctx.fillRect(px + 2, py + 4, 2, 1.5);
} else if (lu === 4 || lu === 7) {
ctx.strokeStyle = lu === 7 ? "rgba(220,220,230,0.15)" : "rgba(200,190,180,0.15)";
ctx.lineWidth = 0.8;
ctx.beginPath();
if (h % 2 === 0) {
ctx.moveTo(px + 1, py + 1);
ctx.lineTo(px + CELL_SIZE - 1, py + 1);
ctx.moveTo(px + 1, py + 4);
ctx.lineTo(px + CELL_SIZE - 1, py + 4);
} else {
ctx.moveTo(px + 1, py + 1);
ctx.lineTo(px + 1, py + CELL_SIZE - 1);
ctx.moveTo(px + 4, py + 1);
ctx.lineTo(px + 4, py + CELL_SIZE - 1);
}
ctx.stroke();
} else if (lu === 8) {
ctx.strokeStyle = "rgba(180,170,160,0.2)";
ctx.lineWidth = 0.9;
ctx.beginPath();
ctx.moveTo(px + 1, py + 3);
ctx.lineTo(px + CELL_SIZE - 1, py + 3);
ctx.stroke();
}
function drawDebugCells(ctx, map, field, color) {
if (!field) return;
ctx.save();
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (!map.prefectureMask[i] || map.sea[i]) continue;
const v = clamp(field[i] || 0, 0, 1);
if (v <= 0.12) continue;
ctx.fillStyle = color(v);
ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
}
}
ctx.restore();
@ -339,91 +537,41 @@ function dot(ctx, p, radius, fill, stroke = "white") {
ctx.arc(p.x * CELL_SIZE + CELL_SIZE / 2, p.y * CELL_SIZE + CELL_SIZE / 2, radius, 0, Math.PI * 2);
ctx.fillStyle = fill;
ctx.fill();
ctx.lineWidth = 1.5;
ctx.lineWidth = 1.2;
ctx.strokeStyle = stroke;
ctx.stroke();
}
function squareIcon(ctx, p, size, fill, stroke = "white") {
const x = p.x * CELL_SIZE + CELL_SIZE / 2;
const y = p.y * CELL_SIZE + CELL_SIZE / 2;
ctx.save();
ctx.fillStyle = fill;
ctx.strokeStyle = stroke;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.rect(x - size / 2, y - size / 2, size, size);
ctx.fill();
ctx.stroke();
ctx.restore();
}
function triangleIcon(ctx, p, size, fill, stroke = "white") {
const x = p.x * CELL_SIZE + CELL_SIZE / 2;
const y = p.y * CELL_SIZE + CELL_SIZE / 2;
ctx.save();
ctx.fillStyle = fill;
ctx.strokeStyle = stroke;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(x, y - size / 2);
ctx.lineTo(x + size / 2, y + size / 2);
ctx.lineTo(x - size / 2, y + size / 2);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
}
function railStationIcon(ctx, p) {
squareIcon(ctx, p, 5.2, "rgba(255,255,255,0.96)", "rgba(55,55,55,0.92)");
}
function drawHarborWorks(ctx, map) {
if (!map.harborWorks) return;
ctx.save();
ctx.strokeStyle = "rgba(95, 120, 150, 0.9)";
ctx.lineWidth = 2.2;
ctx.lineCap = "round";
for (const harbor of map.harborWorks) {
for (const seg of harbor.segments || []) {
ctx.beginPath();
ctx.moveTo(seg[0][0] * CELL_SIZE + CELL_SIZE / 2, seg[0][1] * CELL_SIZE + CELL_SIZE / 2);
ctx.lineTo(seg[1][0] * CELL_SIZE + CELL_SIZE / 2, seg[1][1] * CELL_SIZE + CELL_SIZE / 2);
ctx.stroke();
}
}
ctx.restore();
}
function boxesOverlap(a, b, pad = 2) {
function boxesOverlap(a, b, pad = 3) {
return !(a.x2 + pad < b.x1 || a.x1 - pad > b.x2 || a.y2 + pad < b.y1 || a.y1 - pad > b.y2);
}
function labelWithCollision(ctx, p, occupied) {
if (!p.name) return false;
ctx.save();
ctx.font = "11px ui-sans-serif, system-ui, sans-serif";
ctx.font = "600 11.5px ui-sans-serif, system-ui, -apple-system, sans-serif";
const baseX = p.x * CELL_SIZE + CELL_SIZE / 2;
const baseY = p.y * CELL_SIZE + CELL_SIZE / 2;
const textW = ctx.measureText(p.name).width;
const textH = 12;
const candidates = [
[7, -5], [7, 12], [-textW - 7, -5], [-textW - 7, 12],
[-textW / 2, -13], [-textW / 2, 20], [12, 2], [-textW - 12, 2],
[7, -4], [7, 13], [-textW - 7, -4], [-textW - 7, 13],
[-textW / 2, -12], [-textW / 2, 20], [12, 4], [-textW - 12, 4],
];
for (const [ox, oy] of candidates) {
const x = baseX + ox;
const y = baseY + oy;
const box = { x1: x - 2, y1: y - textH, x2: x + textW + 2, y2: y + 3 };
const box = { x1: x - 2, y1: y - textH, x2: x + textW + 2, y2: y + 4 };
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;
ctx.lineWidth = 3;
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.fillStyle = "rgba(40,40,40,0.95)";
ctx.lineJoin = "round";
ctx.lineWidth = 3.5;
ctx.strokeStyle = "rgba(255, 255, 255, 0.95)";
ctx.strokeText(p.name, x, y);
ctx.fillStyle = p.isPrefecturalCapital ? "#111111" : "#333333";
ctx.fillText(p.name, x, y);
occupied.push(box);
ctx.restore();
@ -437,7 +585,7 @@ function drawLabels(ctx, points, limit = Infinity) {
const occupied = [];
const prioritized = points
.filter((p) => p?.name)
.map((p) => ({ ...p, labelPriority: (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) + (p.kind === "Market Town" ? 48 : 0) + (p.kind?.includes("Castle") ? 70 : 0) + (p.kind === "External Gateway" ? 60 : 0) }))
.map((p) => ({ ...p, labelPriority: (p.labelPriorityBase || 0) + (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) }))
.sort((a, b) => b.labelPriority - a.labelPriority);
for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, occupied);
}
@ -449,102 +597,120 @@ export function drawMap(canvas, map, options) {
const mode = options.mode || "all";
const showFeatures = options.showFeatures !== false;
const showLabels = options.showLabels !== false;
const continuousTerrain = true;
const width = MAP_W * CELL_SIZE;
const height = MAP_H * CELL_SIZE;
canvas.width = width;
canvas.height = height;
drawBase(ctx, map, mode, continuousTerrain);
// 1. Base Terrain & Urban
drawBase(ctx, map, mode, true);
drawUrbanAreas(ctx, map, mode);
const coastSegments = getCoastlineSegments(map);
drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, 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 });
// Rivers use the same hue family as the sea; hierarchy is expressed by width/opacity.
const waterBlue = "rgba(170, 218, 255, 0.95)";
// Small streams remain in the data model but are not drawn by default.
for (const path of map.tributaryRivers || map.riverPaths || []) drawPath(ctx, path, "rgba(170, 218, 255, 0.78)", 1.35);
for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.2);
drawHarborWorks(ctx, map);
// 2. Rivers
const waterBlue = "rgba(160, 205, 240, 1)";
for (const path of map.tributaryRivers || map.riverPaths || []) drawPath(ctx, path, "rgba(160, 205, 240, 0.8)", 1.5);
for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.5);
if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, mode === "all" ? "rgba(95,95,95,0.18)" : "rgba(95,95,95,0.30)", 1.0, false, mode === "all");
drawSegments(ctx, map.prefectureBorder, "rgba(30,30,30,0.82)", 2.4, false, true);
drawSegments(ctx, map.prefectureBorder, "rgba(255,255,255,0.74)", 1.05, false, true);
const showHistory = ["history", "all", "terrain"].includes(mode);
const showModern = ["modern", "all", "development", "landuse", "roads", "admin-debug", "borders-debug"].includes(mode);
const showRoads = ["roads", "all", "development"].includes(mode);
const showMinorRoads = ["roads", "all", "modern", "development"].includes(mode);
const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode);
// 3. Borders
if (showAdmin && map.adminBorders) {
drawVectorSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
}
if (mode === "admin-debug" || mode === "borders-debug") {
drawDebugCells(ctx, map, map.naturalBarrierScore, (v) => `rgba(255, 120, 40, ${0.06 + v * 0.18})`);
if (map.adminDebug?.compartmentBorders) drawSegments(ctx, map.adminDebug.compartmentBorders, "rgba(60, 110, 170, 0.42)", 0.8, true);
for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)");
if (map.regionalPrefectureBorders) drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
}
drawVectorSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
if (!showFeatures) return;
const showHistory = ["history", "all", "terrain", "suitability"].includes(mode);
const showModern = ["modern", "all", "development", "landuse"].includes(mode);
const showRoads = ["roads", "all", "development", "landuse"].includes(mode);
const showAdmin = ["admin", "all"].includes(mode);
if (showAdmin) drawSegments(ctx, map.adminBorders, mode === "all" ? "rgba(120,120,120,0.28)" : "rgba(120,120,120,0.65)", mode === "all" ? 0.9 : 1.3);
// 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways.
if (showHistory) {
for (const path of map.premodernRoads) drawPath(ctx, path, mode === "all" ? "rgba(150, 120, 90, 0.34)" : "rgba(150, 120, 90, 0.55)", mode === "all" ? 1.15 : 1.45, true);
for (const path of map.minorRoads) drawPath(ctx, path, mode === "all" ? "rgba(180, 150, 120, 0.28)" : "rgba(180, 150, 120, 0.62)", mode === "all" ? 0.8 : 1.05);
for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(210, 210, 210, 0.7)", 2.5);
}
if (showModern) {
// 鉄道 - 濃いグレー
for (const path of map.railways) drawPath(ctx, path, "rgba(80, 80, 80, 0.9)", 2.8);
for (const path of map.ringRailways || []) drawPath(ctx, path, "rgba(70, 70, 70, 0.82)", 2.1);
for (const path of map.branchRailways) drawPath(ctx, path, "rgba(100, 100, 100, 0.82)", 1.9);
for (const path of map.externalRailways) drawPath(ctx, path, "rgba(90, 90, 90, 0.9)", 2.4);
if (showMinorRoads) {
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(205, 205, 205, 0.60)", 2.35);
}
if (showRoads) {
// Google Map風の道路 - 白と黄色とオレンジ
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.2);
for (const path of map.icAccessRoads || []) drawPath(ctx, path, "rgba(255, 230, 150, 0.82)", 1.45);
for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.0);
for (const path of map.expressways) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 4.0);
for (const path of map.externalRoads) drawPath(ctx, path, "rgba(252, 210, 90, 0.85)", 2.5);
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(245, 140, 60, 0.95)", 4.2);
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8);
for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8);
for (const path of map.externalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8);
}
if (showModern || showRoads) {
for (const path of map.railways) drawPath(ctx, path, "rgba(255, 255, 255, 0.78)", 3.6);
for (const path of map.branchRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.64)", 2.6);
for (const path of map.externalRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.78)", 3.6);
}
if (showRoads) {
for (const path of map.expressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6);
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6);
}
// 5. Transport fills. Expressways remain above railways; railways sit above ordinary roads.
if (showHistory) {
for (const p of map.villages) dot(ctx, p, mode === "all" ? 1.35 : 1.9, mode === "all" ? "rgba(120, 100, 80, 0.42)" : "rgba(120, 100, 80, 0.72)");
for (const p of map.markets) dot(ctx, p, 4.8, "rgba(200, 130, 80, 0.95)");
for (const p of map.ports) {
const color = p.portClass === "major" ? "rgba(40, 105, 190, 0.98)" : p.portClass === "regional" ? "rgba(70, 130, 200, 0.95)" : p.portClass === "lake" ? "rgba(80, 155, 180, 0.92)" : "rgba(95, 150, 195, 0.82)";
triangleIcon(ctx, p, p.portClass === "major" ? 8.2 : 6.5, color);
}
for (const p of map.crossings) dot(ctx, p, 3.5, "rgba(255, 240, 180, 0.95)", "rgba(100,90,70,0.8)");
for (const p of map.passes) dot(ctx, p, 3.9, "rgba(150, 120, 180, 0.95)");
for (const p of map.castles) squareIcon(ctx, p, 7.0, "rgba(180, 70, 70, 0.96)");
for (const p of map.castleRuins) dot(ctx, p, 3.2, "rgba(110, 90, 90, 0.9)", "rgba(220,220,220,0.8)");
for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(255, 255, 255, 1)", 1.2, false);
}
if (showMinorRoads) {
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(255, 255, 255, 0.94)", 1.1, false);
}
if (showRoads) {
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0);
for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0);
for (const path of map.externalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0);
}
if (showModern || showRoads) {
for (const path of map.railways) drawRailway(ctx, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0);
for (const path of map.branchRailways) drawRailway(ctx, path, "rgba(125, 125, 125, 1)", 1.05, 4.0, 6.0);
for (const path of map.externalRailways) drawRailway(ctx, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0);
}
if (showRoads) {
for (const path of map.expressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
}
// 6. Icons & Labels
if (showModern) {
for (const p of map.industrialZones) squareIcon(ctx, p, 6.5, "rgba(140, 140, 150, 0.96)");
for (const p of map.stations) railStationIcon(ctx, p);
for (const p of map.satelliteCities || []) dot(ctx, p, 4.8, "rgba(215, 95, 145, 0.95)");
for (const p of map.newTowns) triangleIcon(ctx, p, 6.5, "rgba(180, 210, 240, 0.95)");
for (const p of map.stations) dot(ctx, p, 2.5, "#fff", "#444");
for (const p of map.modernCities) {
const popRadius = p.population ? Math.min(9.2, 3.4 + Math.sqrt(p.population) / 360) : 4.7;
const rankBoost = p.isPrefecturalCapital || p.rank === "Prefectural Capital" ? 1.6 : p.rank === "Regional Center" ? 0.45 : 0;
dot(ctx, p, popRadius + rankBoost, "rgba(230, 100, 100, 0.95)");
if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 4.2, "rgba(255,255,255,0.0)", "rgba(180,60,60,0.95)");
const popRadius = p.population ? Math.min(8.5, 3.5 + Math.sqrt(p.population) / 400) : 4.5;
dot(ctx, p, popRadius, "rgba(240, 110, 110, 0.95)", "rgba(255,255,255,0.9)");
if (p.isPrefecturalCapital) dot(ctx, p, popRadius + 3.0, "transparent", "rgba(200,80,80,0.9)");
}
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.externalGateways || []) dot(ctx, p, 5.5, "rgba(255,255,255,0.95)", "rgba(40,80,170,0.95)");
for (const p of map.transportDebug?.missingRequiredNodes || []) dot(ctx, p, 6.5, "rgba(255,80,80,0.95)", "rgba(80,20,20,0.95)");
}
}
if (showRoads) {
for (const p of map.logisticsParks) squareIcon(ctx, p, 6.2, "rgba(110, 170, 140, 0.96)");
for (const p of map.interchanges) dot(ctx, p, 4.1, "rgba(255, 255, 255, 0.98)", "rgba(220, 90, 60, 0.95)");
for (const p of map.externalGateways) dot(ctx, p, 4.4, "rgba(255, 250, 200, 0.98)", "rgba(60,60,60,0.92)");
}
if (showLabels) {
if (mode === "admin") {
drawLabels(ctx, map.adminCenters || [], Infinity);
return;
}
if (mode === "admin-debug" || mode === "borders-debug") {
drawLabels(ctx, [...(map.adminCenters || []), ...(map.externalGateways || [])], Infinity);
return;
}
const important = [
...map.modernCities,
...map.ports,
...map.markets.slice(0, mode === "all" ? 6 : 10),
...map.castles.slice(0, mode === "all" ? 5 : 8),
...(map.satelliteCities || []),
...map.newTowns,
...map.externalGateways,
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
drawLabels(ctx, important, mode === "all" ? 28 : Infinity);
drawLabels(ctx, important, 60);
}
}

View file

@ -1,5 +0,0 @@
export function weightedScore(terms) {
let total = 0;
for (const [value, weight] of terms) total += value * weight;
return total;
}

View file

@ -1,11 +1,68 @@
*{box-sizing:border-box} body{margin:0;background:#f5f5f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} button,input{font:inherit} code{background:#e8e8e8;border-radius:4px;padding:1px 4px} .app{min-height:100vh;padding:24px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px;max-width:1400px;margin:0 auto}.header{margin-bottom:16px}.header h1{margin:0 0 6px;font-size:24px;letter-spacing:-0.02em;color:#1a1a1a}.header p{margin:0;color:#5a5a5a;line-height:1.65;font-size:14px}.canvas-shell,.card{background:#ffffff;border:1px solid rgb(0 0 0 / 0.12);border-radius:18px;box-shadow:0 2px 8px rgb(0 0 0 / 0.08)}.canvas-shell{padding:12px;overflow:auto;position:relative}.map-canvas{display:block;border-radius:12px;background:#f8f8f8}.sidebar{display:flex;flex-direction:column;gap:14px}.card{padding:16px}.label,.card-title{display:block;margin-bottom:10px;color:#2c2c2c;font-size:14px;font-weight:650}.input{width:100%;border:1px solid rgb(0 0 0 / 0.18);background:#fafafa;color:#2c2c2c;border-radius:12px;padding:9px 11px;outline:none}.input:focus{border-color:rgb(66 133 244 / 0.6)}.primary-button,.mode-button{border:0;border-radius:12px;padding:9px 11px;cursor:pointer}.primary-button{margin-top:10px;width:100%;background:#1a73e8;color:#fff;font-weight:650}.primary-button:hover{background:#1557b0}.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.mode-button{background:#f1f3f4;color:#3c4043;border:1px solid transparent}.mode-button:hover{background:#e8eaed}.mode-button.active{background:#1a73e8;color:#fff;font-weight:650;border:1px solid #1a73e8}.checkbox-row{display:flex;gap:8px;align-items:center;margin-top:12px;color:#3c4043;font-size:14px}.stats{display:flex;flex-direction:column;gap:7px}.stat-row{display:flex;justify-content:space-between;gap:12px;color:#5f6368;font-size:13px;align-items:baseline}.stat-row strong{color:#202124;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}.legend{color:#5f6368;font-size:12px;line-height:1.65}.legend p{margin:8px 0 0}.example{margin:10px 0;padding:10px;background:#f8f9fa;border:1px solid rgb(0 0 0 / 0.1);border-radius:10px;color:#3c4043;overflow:auto}.id-list{max-height:220px;overflow:auto;margin-top:10px;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}}
*{box-sizing:border-box}
body{margin:0;background:#f0f2f5;color:#2c2c2c;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
button,input{font:inherit}
code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
.app{min-height:100vh;padding:24px}
.layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:20px;max-width:1400px;margin:0 auto}
.header{margin-bottom:16px}
.header h1{margin:0 0 6px;font-size:24px;letter-spacing:-0.02em;color:#1a1a1a;font-weight:700}
.header p{margin:0;color:#5a5a5a;line-height:1.6;font-size:14px}
.canvas-shell,.card{background:#ffffff;border:1px solid rgba(0,0,0,0.08);border-radius:12px;box-shadow:0 4px 12px rgba(0,0,0,0.04)}
.canvas-shell{padding:12px;overflow:auto;position:relative}
.map-canvas{display:block;border-radius:8px;background:#f8f9fa}
.sidebar{display:flex;flex-direction:column;gap:16px}
.card{padding:18px}
.label,.card-title{display:block;margin-bottom:12px;color:#1a1a1a;font-size:14px;font-weight:600}
.input{width:100%;border:1px solid rgba(0,0,0,0.15);background:#fff;color:#2c2c2c;border-radius:8px;padding:10px 12px;outline:none;transition:border-color 0.2s}
.input:focus{border-color:#1a73e8;box-shadow:0 0 0 3px rgba(26,115,232,0.15)}
.primary-button,.mode-button{border:0;border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:600;transition:background 0.2s}
.primary-button{margin-top:12px;width:100%;background:#1a73e8;color:#fff}
.primary-button:hover{background:#1557b0}
.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.mode-button{background:#f1f3f4;color:#3c4043;border:1px solid transparent}
.mode-button:hover{background:#e8eaed}
.mode-button.active{background:#e8f0fe;color:#1a73e8;border:1px solid #1a73e8}
.checkbox-row{display:flex;gap:8px;align-items:center;margin-top:12px;color:#3c4043;font-size:14px;cursor:pointer}
.stats{display:flex;flex-direction:column;gap:8px}
.stat-row{display:flex;justify-content:space-between;gap:12px;color:#5f6368;font-size:13px;align-items:baseline}
.stat-row strong{color:#202124;font-family:ui-monospace,monospace}
.legend{color:#5f6368;font-size:13px;line-height:1.6}
.legend p{margin:8px 0 0}
.example{margin:10px 0;padding:12px;background:#f8f9fa;border:1px solid rgba(0,0,0,0.08);border-radius:8px;color:#3c4043;overflow:auto;font-size:12px}
.id-list{max-height:220px;overflow:auto;margin-top:12px;display:flex;flex-direction:column;gap:6px}
.id-row{display:grid;grid-template-columns:112px 1fr;gap:8px;align-items:center;color:#5f6368;font-size:12px}
.legend-grid{display:flex;flex-direction:column;gap:7px;margin-top:8px}.legend-row{display:grid;grid-template-columns:30px 1fr;gap:8px;align-items:center;min-height:20px}.legend-line{display:inline-block;width:28px;height:0;border-top:3px solid #777;border-radius:999px}.legend-swatch{display:inline-block;width:26px;height:14px;border-radius:5px;background:#f5f5f5}.border-swatch{border:2px solid rgba(80,80,80,.8);box-shadow:inset 0 0 0 1px rgba(255,255,255,.9)}.river-major{border-top:4px solid rgba(100,170,210,.95);box-shadow:0 3px 0 rgba(120,180,215,.55)}.rail-line{border-top:3px solid rgba(70,70,70,.95)}.road-line{border-top:3px solid rgba(252,210,90,.95)}.express-line{border-top:5px solid rgba(245,140,60,.95)}.old-road-line{border-top:2px dashed rgba(150,120,90,.75)}.legend-icon{display:inline-block;width:15px;height:15px;justify-self:center;border:2px solid #fff;box-shadow:0 0 0 1px rgb(0 0 0 / .28)}.city-icon{border-radius:50%;background:rgba(230,100,100,.95);width:17px;height:17px}.port-icon{width:0;height:0;border-left:8px solid transparent;border-right:8px solid transparent;border-bottom:15px solid rgba(70,130,200,.95);border-top:0;box-shadow:none;background:transparent}.castle-icon{background:rgba(180,70,70,.96);border-radius:2px}.station-icon{background:#fff;border-color:rgba(60,60,60,.9);border-radius:2px}.industry-icon{background:rgba(110,170,140,.96);border-radius:2px}.newtown-icon{width:0;height:0;border-left:8px solid transparent;border-right:8px solid transparent;border-bottom:15px solid rgba(180,210,240,.95);border-top:0;box-shadow:none;background:transparent}
@media (max-width:1100px){.layout{grid-template-columns:1fr}}
.legend-grid{display:flex;flex-direction:column;gap:8px;margin-top:12px}
.legend-row{display:grid;grid-template-columns:32px 1fr;gap:8px;align-items:center;min-height:22px}
/* Layered GIS style CSS equivalents */
.legend-line{display:inline-block;width:28px;height:4px;border-radius:2px;}
.express-line{background:#6eb982; border:1px solid #508c64;}
.road-line{background:#f5e182; border:1px solid #beaf8c;}
.legend-swatch{width:18px;height:14px;border-radius:4px;border:1px solid rgb(0 0 0 / 0.18);display:inline-block}.cbd-swatch{background:rgb(224 202 190)}.satellite-icon{background:rgba(215,95,145,0.95);border-radius:999px;border:2px solid #fff}
/* Fishbone Railway Style */
.rail-line{background:#6e6e6e; height:1.5px; position:relative; border:none; margin-top:2px; border-radius:0}
.rail-line::after{content:"";position:absolute;top:-2.5px;left:0;right:0;height:7px;background:repeating-linear-gradient(90deg, transparent, transparent 5px, #6e6e6e 5px, #6e6e6e 6px);}
.legend-line.harbor-line::before{background:#6b86a0;height:3px;top:7px}
.river-major{background:#a0cdf0; border:none; height:3px;}
.old-road-line{background:#fff; border:1px solid #dcdcdc; height:3px; border-top:none;}
.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:190px;max-width:270px;background:rgba(255,255,255,.96);border:1px solid rgb(0 0 0 / .16);border-radius:10px;box-shadow:0 8px 24px rgb(0 0 0 / .16);padding:8px 10px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(2px);transition:opacity .08s ease,transform .08s ease}.map-tooltip.visible{opacity:1;transform:translateY(0)}
.legend-swatch{display:inline-block;width:24px;height:14px;border-radius:4px;background:#f5f5f5}
.border-swatch{border:2px dashed rgba(110,90,110,1);box-shadow:inset 0 0 0 1px rgba(255,255,255,1), 0 0 0 1px rgba(255,255,255,1)}
.cbd-swatch{background:#f0dccd; border:1px solid rgba(0,0,0,0.1)}
.legend-icon{display:inline-block;width:14px;height:14px;justify-self:center;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 1px rgba(0,0,0,0.15)}
.city-icon{background:#f06e6e;}
.satellite-icon{background:#d75f91;}
.station-icon{background:#fff;border-color:#444;}
.industry-icon{background:#8caaa0; border-radius:3px;}
.castle-icon{background:#b44646; border-radius:3px;}
.port-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #4682c8;border-top:0;box-shadow:none;background:transparent;border-radius:0}
.newtown-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #b4d2f0;border-top:0;box-shadow:none;background:transparent;border-radius:0}
.legend-line.harbor-line{background:transparent; border-top:2px solid #5f7896; height:0}
.map-tooltip{position:absolute;z-index:20;pointer-events:none;min-width:200px;max-width:280px;background:rgba(255,255,255,0.98);border:1px solid rgba(0,0,0,0.1);border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,0.1);padding:10px 12px;color:#2c2c2c;font-size:12px;line-height:1.5;opacity:0;transform:translateY(4px);transition:opacity 0.15s ease, transform 0.15s ease;font-weight:500}
.map-tooltip.visible{opacity:1;transform:translateY(0)}

525
test.js
View file

@ -7,17 +7,19 @@ import {
NAME_PROBABILITIES,
NAME_TEMPLATES,
NAME_TEMPLATE_WEIGHTS,
generateEntityName,
generateTemplateName,
} from "./names.js";
import { adminBoundaryMetrics, majorCityCoreIntegrity } from "./qualityMetrics.js";
const result = document.getElementById("result");
const logLines = [];
let failed = 0;
const [namesSource, mapGeneratorSource, testSource] = await Promise.all([
const [namesSource, mapGeneratorSource, mapOutputSource, rendererSource, testSource] = await Promise.all([
fetch("./names.js").then((response) => response.text()),
fetch("./mapGenerator.js").then((response) => response.text()),
fetch("./mapOutput.js").then((response) => response.text()),
fetch("./renderer.js").then((response) => response.text()),
fetch("./test.js").then((response) => response.text()),
]);
@ -29,6 +31,352 @@ function assert(condition, message) {
}
}
function terrainBoundaryTargetForMetrics(map, i) {
const lu = map.landuse[i];
const urbanPenalty = Math.min(1, (lu === 3 ? 1.45 : lu === 2 ? 1.12 : lu === 4 ? 0.95 : lu === 7 ? 0.90 : lu === 8 ? 0.64 : lu === 5 || lu === 6 ? 0.48 : 0) + map.populationDensity[i] * 1.35);
const majorRiver = Math.min(1, Math.max(map.river[i] - 0.32, 0) * 1.9 + Math.max(map.flowAccum[i] - 0.38, 0) * 0.75);
const minorStream = Math.min(1, map.river[i] * 0.34 + map.flowAccum[i] * 0.18);
const ridgeDivide = Math.min(1, map.ridgeField[i] * 1.55 + Math.max(0, map.elevation[i] - 0.54) * map.ridgeField[i] * 0.95);
const slopeBreak = Math.min(1, map.slope[i] * 0.58 + Math.max(0, map.slope[i] - 0.32) * 0.68);
const highGround = Math.max(0, map.elevation[i] - 0.56) * 0.22;
const valleyFloorPenalty = map.valleyField[i] * (majorRiver > 0.34 ? -0.10 : -0.62);
return Math.max(0, Math.min(1, ridgeDivide + majorRiver * 0.88 + minorStream * 0.22 + slopeBreak + highGround + valleyFloorPenalty - urbanPenalty * 0.72));
}
function adminBoundaryMetrics(map) {
let borderEdges = 0;
let targetSum = 0;
let denseUrbanEdges = 0;
let rightAngleRuns = 0;
let voronoiLikeEdges = 0;
let lowScoreFlatEdges = 0;
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (!map.prefectureMask[i] || map.sea[i] || map.adminId[i] < 0) continue;
for (const [dx, dy] of [[1, 0], [0, 1]]) {
const ni = indexOf(x + dx, y + dy);
if (!map.prefectureMask[ni] || map.sea[ni] || map.adminId[ni] < 0 || map.adminId[ni] === map.adminId[i]) continue;
borderEdges++;
const edgeTarget = (terrainBoundaryTargetForMetrics(map, i) + terrainBoundaryTargetForMetrics(map, ni)) * 0.5;
targetSum += edgeTarget;
const urban = Math.max(map.populationDensity[i], map.populationDensity[ni]) > 0.58 || [2, 3, 4, 7, 8].includes(map.landuse[i]) || [2, 3, 4, 7, 8].includes(map.landuse[ni]);
if (urban) denseUrbanEdges++;
const ca = map.adminCenters[map.adminId[i]];
const cb = map.adminCenters[map.adminId[ni]];
if (ca && cb) {
const mx = x + dx * 0.5;
const my = y + dy * 0.5;
const dA = Math.hypot(mx - ca.x, my - ca.y);
const dB = Math.hypot(mx - cb.x, my - cb.y);
if (Math.abs(dA - dB) < 4.2 && edgeTarget < 0.40) voronoiLikeEdges++;
}
if (edgeTarget < 0.16 && Math.max(map.slope[i], map.slope[ni]) < 0.24 && Math.max(map.ridgeField[i], map.ridgeField[ni]) < 0.28 && Math.max(map.river[i], map.river[ni]) < 0.26) {
lowScoreFlatEdges++;
}
const sideA = indexOf(x + (dy ? 1 : 0), y + (dx ? 1 : 0));
const sideB = indexOf(x - (dy ? 1 : 0), y - (dx ? 1 : 0));
if (map.prefectureMask[sideA] && map.prefectureMask[sideB] && !map.sea[sideA] && !map.sea[sideB]) {
const turnA = map.adminId[sideA] !== map.adminId[i] && map.adminId[sideA] !== map.adminId[ni];
const turnB = map.adminId[sideB] !== map.adminId[i] && map.adminId[sideB] !== map.adminId[ni];
if ((turnA || turnB) && terrainBoundaryTargetForMetrics(map, i) < 0.46) rightAngleRuns++;
}
}
}
}
const ids = new Set([...map.adminId].filter((id, i) => id >= 0 && map.prefectureMask[i] && !map.sea[i]));
const areaById = new Map();
for (let i = 0; i < map.adminId.length; i++) {
if (map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0) areaById.set(map.adminId[i], (areaById.get(map.adminId[i]) || 0) + 1);
}
const areas = [...areaById.values()].sort((a, b) => a - b);
const medianArea = areas.length ? areas[Math.floor(areas.length / 2)] : 1;
const maxArea = areas.length ? areas[areas.length - 1] : 1;
let disconnectedMunicipalities = 0;
let maxComponents = 0;
const seen = new Uint8Array(MAP_W * MAP_H);
for (const id of ids) {
let comps = 0;
seen.fill(0);
for (let i = 0; i < map.adminId.length; i++) {
if (seen[i] || map.adminId[i] !== id || !map.prefectureMask[i] || map.sea[i]) continue;
comps++;
const queue = [i];
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const x = cur % MAP_W;
const y = Math.floor(cur / MAP_W);
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 (seen[ni] || map.adminId[ni] !== id || !map.prefectureMask[ni] || map.sea[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
}
if (comps > 1) disconnectedMunicipalities++;
maxComponents = Math.max(maxComponents, comps);
}
const centerValidCount = map.adminCenters.filter((center) => {
const i = indexOf(center.x, center.y);
return map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0;
}).length;
return {
borderEdges,
avgTarget: borderEdges ? targetSum / borderEdges : 0,
denseUrbanRate: borderEdges ? denseUrbanEdges / borderEdges : 0,
rightAngleRate: borderEdges ? rightAngleRuns / borderEdges : 0,
voronoiLikeRate: borderEdges ? voronoiLikeEdges / borderEdges : 0,
lowScoreFlatRate: borderEdges ? lowScoreFlatEdges / borderEdges : 0,
areaDiversity: maxArea / Math.max(1, medianArea),
municipalityCount: ids.size,
disconnectedMunicipalities,
maxComponents,
centerValidRatio: map.adminCenters.length ? centerValidCount / map.adminCenters.length : 1,
};
}
function majorCityCoreIntegrity(map) {
const majorCities = map.modernCities.filter((city) => (city.population || 0) >= 180000);
if (majorCities.length === 0) return 1;
let sum = 0;
let checked = 0;
for (const city of majorCities) {
const counts = new Map();
const r = Math.ceil(Math.max(3, city.coreRadius || 4));
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 (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H || Math.hypot(dx, dy) > r) continue;
const i = indexOf(x, y);
if (!map.prefectureMask[i] || map.sea[i]) continue;
if (map.landuse[i] !== 3 && map.populationDensity[i] < 0.38) continue;
const id = map.adminId[i];
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
}
}
const total = [...counts.values()].reduce((a, b) => a + b, 0);
if (total === 0) continue;
sum += Math.max(...counts.values()) / total;
checked++;
}
return checked ? sum / checked : 1;
}
function satelliteMunicipalityMetrics(map) {
const areaById = new Map();
for (let i = 0; i < map.adminId.length; i++) {
if (map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0) areaById.set(map.adminId[i], (areaById.get(map.adminId[i]) || 0) + 1);
}
const rows = (map.satelliteCities || [])
.filter((sat) => map.prefectureMask[indexOf(sat.x, sat.y)] && !map.sea[indexOf(sat.x, sat.y)])
.map((sat) => {
const admin = map.adminId[indexOf(sat.x, sat.y)];
return { sat, admin, area: areaById.get(admin) || 0 };
});
const independent = rows.filter((row) => row.sat.municipalityClass === "independentSatelliteMunicipality");
const small = independent.filter((row) => row.area < 80);
const largeTooSmall = rows.filter((row) => (row.sat.population || 0) >= 60000 && row.sat.municipalityClass === "independentSatelliteMunicipality" && row.area < 120);
const average = independent.length ? independent.reduce((sum, row) => sum + row.area, 0) / independent.length : 0;
return { rows, independent, small, largeTooSmall, average };
}
function regionalComponentMetrics(map) {
const ids = new Set([...map.prefectureRegionId].filter((id, i) => id >= 0 && !map.sea[i]));
const seen = new Uint8Array(MAP_W * MAP_H);
let maxComponents = 0;
for (const id of ids) {
seen.fill(0);
let comps = 0;
for (let i = 0; i < map.prefectureRegionId.length; i++) {
if (seen[i] || map.sea[i] || map.prefectureRegionId[i] !== id) continue;
comps++;
const queue = [i];
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const x = cur % MAP_W;
const y = Math.floor(cur / MAP_W);
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 (seen[ni] || map.sea[ni] || map.prefectureRegionId[ni] !== id) continue;
seen[ni] = 1;
queue.push(ni);
}
}
}
maxComponents = Math.max(maxComponents, comps);
}
return { regionCount: ids.size, maxComponents };
}
function meanField(map, fieldName, predicate) {
let sum = 0;
let count = 0;
const field = map[fieldName];
for (let i = 0; i < field.length; i++) {
if (!predicate(i)) continue;
sum += field[i];
count++;
}
return count ? sum / count : 0;
}
function ridgeSinuosityMetric(map) {
const centers = [];
for (let y = 1; y < MAP_H - 1; y++) {
let sum = 0;
let weight = 0;
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (map.sea[i]) continue;
const r = Math.max(0, map.ridgeField[i] - 0.36);
sum += x * r;
weight += r;
}
if (weight > 1.2) centers.push(sum / weight);
}
if (centers.length < 8) return 0;
let turn = 0;
let total = 0;
for (let i = 2; i < centers.length; i++) {
const a = centers[i - 1] - centers[i - 2];
const b = centers[i] - centers[i - 1];
turn += Math.abs(b - a);
total += Math.abs(b) + Math.abs(a) + 0.01;
}
return turn / total;
}
function terrainCoreMetrics(map) {
const land = [...map.elevation].map((_, i) => i).filter((i) => !map.sea[i]);
const mountainCells = land.filter((i) => map.elevation[i] > 0.58 || map.ridgeField[i] > 0.42).length;
const lowlandCells = land.filter((i) => map.plain[i] > 0.38 || map.depositionalLowland?.[i] > 0.24).length;
const ridgeValues = land.map((i) => map.ridgeField[i]);
const ridgeMean = ridgeValues.reduce((sum, value) => sum + value, 0) / Math.max(1, ridgeValues.length);
const ridgeVariance = ridgeValues.reduce((sum, value) => sum + (value - ridgeMean) ** 2, 0) / Math.max(1, ridgeValues.length);
const depositionTargetMean = meanField(map, "depositionField", (i) => !map.sea[i] && (map.coastalLowland[i] > 0.18 || map.basinField[i] > 0.22 || map.river[i] > 0.18 || map.flowAccum[i] > 0.24));
const depositionOtherMean = meanField(map, "depositionField", (i) => !map.sea[i] && map.coastalLowland[i] < 0.08 && map.basinField[i] < 0.12 && map.river[i] < 0.06 && map.flowAccum[i] < 0.12 && map.ridgeField[i] < 0.28);
const riverValleyMean = meanField(map, "valleyField", (i) => !map.sea[i] && map.river[i] > 0.20);
const nonRiverValleyMean = meanField(map, "valleyField", (i) => !map.sea[i] && map.river[i] <= 0.02);
return {
landCount: land.length,
mountainRatio: mountainCells / Math.max(1, land.length),
lowlandRatio: lowlandCells / Math.max(1, land.length),
ridgeVariance,
ridgeSinuosity: ridgeSinuosityMetric(map),
depositionTargetMean,
depositionOtherMean,
riverValleyMean,
nonRiverValleyMean,
depositionSum: [...map.depositionField].reduce((sum, value) => sum + value, 0),
alluvialMax: Math.max(...(map.alluvialFanField || [0])),
deltaMax: Math.max(...(map.deltaField || [0])),
};
}
function requiredTransportNodes(map) {
const nodes = [];
const seen = new Set();
function add(p, reason) {
if (!p) return;
const key = `${p.x},${p.y}`;
if (seen.has(key)) return;
seen.add(key);
nodes.push({ ...p, requiredTransportReason: reason });
}
add(map.prefecturalCapital, "capital");
for (const gate of map.externalGateways || []) add(gate, "externalGateway");
for (const city of map.modernCities || []) if ((city.population || 0) >= 120000 || city.isPrefecturalCapital) add(city, "majorCity");
for (const port of map.ports || []) if (port.portClass === "major") add(port, "majorPort");
return nodes;
}
function transportConnectivityMetrics(map) {
const paths = [
...map.railways,
...map.branchRailways,
...map.externalRailways,
...map.nationalRoads,
...(map.ringRoads || []),
...map.expressways,
...map.externalRoads,
...map.externalExpressways,
];
const pathCells = new Set();
for (const path of paths) for (const [x, y] of path) pathCells.add(`${x},${y}`);
const nodes = requiredTransportNodes(map);
function nearestCell(node) {
let best = null;
let bestD = Infinity;
for (const key of pathCells) {
const [x, y] = key.split(",").map(Number);
const d = Math.hypot(node.x - x, node.y - y);
if (d < bestD) {
bestD = d;
best = key;
}
}
return { key: best, distance: bestD };
}
const seen = new Set();
const components = [];
for (const key of pathCells) {
if (seen.has(key)) continue;
const queue = [key];
const component = new Set([key]);
seen.add(key);
for (let q = 0; q < queue.length; q++) {
const [x, y] = queue[q].split(",").map(Number);
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
const nk = `${x + dx},${y + dy}`;
if (!pathCells.has(nk) || seen.has(nk)) continue;
seen.add(nk);
component.add(nk);
queue.push(nk);
}
}
components.push(component);
}
const mapped = nodes.map((node) => ({
node,
nearest: nearestCell(node),
components: components
.map((component, componentIndex) => ({
componentIndex,
near: [...component].some((key) => {
const [x, y] = key.split(",").map(Number);
return Math.hypot(node.x - x, node.y - y) <= 7;
}),
}))
.filter((item) => item.near)
.map((item) => item.componentIndex),
}));
const reachable = mapped.filter((item) => item.components.length > 0);
let largestRequiredComponent = 0;
for (let componentIndex = 0; componentIndex < components.length; componentIndex++) {
largestRequiredComponent = Math.max(largestRequiredComponent, reachable.filter((item) => item.components.includes(componentIndex)).length);
}
return {
requiredCount: nodes.length,
reachableCount: reachable.length,
largestRequiredComponent,
isolatedExternalGateways: mapped.filter((item) => item.node.requiredTransportReason === "externalGateway" && item.components.length === 0).length,
isolatedMajorCities: mapped.filter((item) => item.node.requiredTransportReason === "majorCity" && item.components.length === 0).length,
};
}
try {
const map = generateMap(12345);
const other = generateMap(54321);
@ -143,6 +491,10 @@ try {
: 1;
const adminMetrics = adminBoundaryMetrics(map);
const cityCoreIntegrity = majorCityCoreIntegrity(map);
const satelliteMetrics = satelliteMunicipalityMetrics(map);
const regionalMetrics = regionalComponentMetrics(map);
const terrainMetrics = terrainCoreMetrics(map);
const transportMetrics = transportConnectivityMetrics(map);
assert(NAME_KANJI_POOLS && Array.isArray(NAME_KANJI_POOLS.modifiers), "NAME_KANJI_POOLS exists");
assert(NAME_TEMPLATES && NAME_TEMPLATES.modifierTerrain?.slots?.length === 2, "NAME_TEMPLATES exists");
@ -151,7 +503,7 @@ try {
const removedContextModule = "placeName" + "Context.js";
assert(!namesSource.includes(removedContextModule) && !mapGeneratorSource.includes(removedContextModule) && !testSource.includes(removedContextModule), "removed name-context import is absent");
assert(Object.keys(NAME_KANJI_POOLS).every((key) => Array.isArray(NAME_KANJI_POOLS[key])), "name category pools are centralized arrays");
assert(Object.values(NAME_KANJI_POOLS).every((pool) => pool.length === 0), "default name category pools are empty");
assert(Object.values(NAME_KANJI_POOLS).every((pool) => pool.every((part) => typeof part === "string" && !part.includes("\uFFFD"))), "configured name category pools contain valid strings");
assert(Object.keys(NAME_PARTS).length === 0, "legacy NAME_PARTS has no hidden candidates");
const removedContextSuffixConst = "CONTEXT" + "_SUFFIXES";
const removedContextSuffixKey = "context" + "Suffixes";
@ -160,6 +512,7 @@ try {
assert(map.elevation.length === size, "elevation length matches map size");
assert(map.sea.length === size, "sea length matches map size");
assert(map.ocean.length === size && map.lake.length === size, "ocean and lake masks match map size");
assert(map.river.length === size, "river length matches map size");
assert(map.landuse.length === size, "land-use length matches map size");
assert(map.adminId.length === size, "municipal id length matches map size");
@ -167,9 +520,27 @@ try {
assert(map.populationDensity.length === size, "population density length matches map size");
assert(map.ridgeField.length === size && map.valleyField.length === size && map.flowAccum.length === size, "causal terrain fields match map size");
assert(map.erosionField.length === size && map.depositionField.length === size, "erosion and deposition fields match map size");
assert(map.arcSpineField.length === size && map.branchRidgeField.length === size, "spine and branch ridge fields match map size");
assert(map.depositionalLowland.length === size && map.alluvialFanField.length === size && map.deltaField.length === size, "depositional debug fields match map size");
assert(map.naturalBarrierScore.length === size, "natural barrier score field matches map size");
assert(map.terrainTemplate && Number.isFinite(map.terrainTemplate.deposition) && Number.isFinite(map.terrainTemplate.erosion), "terrain template parameters are exposed");
assert(map.terrainDebug && Number.isFinite(map.terrainDebug.primarySpineStrength), "terrain debug metrics exist");
assert(map.terrainDebug.primarySpineStrength > 0.08, "primary mountain spine has visible strength");
assert(map.terrainDebug.largeInlandLakeCount <= 1, "large inland lakes are rare");
assert(map.terrainDebug.smallIslandCount <= 24, "small island/coast speckles stay limited");
assert(map.terrainDebug.depositionLowlandArea > 0, "depositional lowland area is tracked");
assert(["east-west", "north-south", "diagonal"].includes(map.terrainTemplate.coastAxis) && map.terrainTemplate.coastSides?.length === 2, "paired coast template parameters are exposed");
assert(map.settlementCluster.length === size, "settlement cluster field matches map size");
assert(Array.isArray(map.bridges) && Array.isArray(map.tunnels) && Array.isArray(map.harborWorks), "legacy bridge/tunnel arrays and harbor arrays exist");
assert(map.prefectureRegionId.length === size && Array.isArray(map.regionalPrefectureBorders), "neighbor prefecture regions exist");
assert(map.regionalDebug && Number.isFinite(map.regionalDebug.regionalChangedAfterNaturalPartition), "regional changed-cell debug exists");
assert(map.regionalDebug.regionalChangedAfterNaturalPartition > 0, "regional natural partition changes region cells");
assert(map.regionalDebug.regionalBorderCountBefore > 0 && map.regionalDebug.regionalBorderCountAfter > 0, "regional border counts are tracked");
assert(map.regionalDebug.regionalNaturalBarrierAverageAfter >= map.regionalDebug.regionalNaturalBarrierAverageBefore - 0.08, "regional border natural-barrier affinity does not degrade meaningfully");
assert(map.regionalDebug.regionalVoronoiLikeRateAfter <= map.regionalDebug.regionalVoronoiLikeRateBefore + 0.22, "regional weak Voronoi-like border rate stays bounded");
assert(map.regionalDebug.compartmentCount > 0 && map.regionalDebug.changedAfterCompartmentAssignment > 0, "regional compartment assignment debug is available");
assert(Number.isFinite(map.regionalDebug.borderNaturalBarrierAverage) && Number.isFinite(map.regionalDebug.voronoiLikeRate), "regional natural-border aliases are exposed");
assert(regionalMetrics.regionCount >= 4 && regionalMetrics.maxComponents <= 5, "regional prefecture regions remain connected enough for display");
assert(Array.isArray(map.tributaryRivers) && Array.isArray(map.smallStreams), "river hierarchy arrays exist");
assert(Array.isArray(map.icAccessRoads), "IC access road array exists");
assert(Array.isArray(map.satelliteCities), "satelliteCities is an array");
@ -206,21 +577,44 @@ try {
}
assert(prefectureComponents === 1, "prefecture area is a single connected component");
assert(map.prefectureBorder.length > 0, "prefecture border exists");
assert([...map.ocean].some((value) => value === 1), "edge-connected ocean mask exists");
assert([...map.lake].every((value, i) => !value || (map.sea[i] && !map.ocean[i])), "lake mask only marks isolated non-ocean water");
assert([...map.sea].every((value, i) => !value || map.ocean[i] || map.lake[i]), "water cells are classified as ocean or lake");
assert(map.adminBorders.length > 0, "municipal borders exist");
assert(map.mainRivers.length > 0, "at least one major river exists");
assert(map.tributaryRivers.length > 0, "tributary river network exists");
assert(map.smallStreams.length > 0, "small stream network exists");
assert(terrainMetrics.mountainRatio > 0.10 && terrainMetrics.mountainRatio < 0.72, "mountain and ridge area is meaningful but not total");
assert(terrainMetrics.lowlandRatio > 0.08 && terrainMetrics.lowlandRatio < 0.72, "lowlands exist without dominating every map");
assert(terrainMetrics.ridgeVariance > 0.004, "ridge field has nontrivial spatial variance");
assert(terrainMetrics.ridgeSinuosity > 0.015, "ridge centerlines are not perfectly straight bands");
assert(terrainMetrics.depositionSum > 0.2, "deposition field has nonzero values");
assert(terrainMetrics.depositionTargetMean >= terrainMetrics.depositionOtherMean * 0.85, "deposition favors rivers, basins, and coastal lowlands");
assert(terrainMetrics.riverValleyMean > terrainMetrics.nonRiverValleyMean * 1.08, "river cells overlap valley fields more than random non-river cells");
assert(terrainMetrics.alluvialMax > 0 || terrainMetrics.deltaMax > 0, "alluvial fan or delta fields are active");
assert(map.harborWorks.length <= map.ports.length, "harbor works are attached to ports");
assert(map.ports.some((p) => p.portClass === "major"), "at least one major port is classified");
assert(map.ports.every((p) => ["major", "regional", "fishing", "lake"].includes(p.portClass)), "ports have explicit classes");
assert(map.externalGateways.length > 0, "external gateways exist");
assert(map.minorRoads.length > 0, "minor roads exist");
assert(map.adminCenters.length >= 12, "municipality count is sufficiently large");
assert(map.adminCenters.length >= 18, "municipality center count is sufficiently large");
assert(adminMetrics.municipalityCount >= 10, "terrain snapping preserves a reasonable municipality count");
assert(adminMetrics.centerValidRatio >= 0.95, "municipality centers remain on valid assigned land cells");
assert(adminMetrics.maxComponents <= 4, "municipal topology repair prevents excessive disconnected fragments");
assert(adminMetrics.disconnectedMunicipalities <= Math.max(2, Math.ceil(adminMetrics.municipalityCount * 0.20)), "most municipalities remain connected after terrain snapping");
assert(adminMetrics.avgTarget > 0.18, "admin borders align with terrain target features often enough");
assert(map.adminDebug && map.adminDebug.compartmentCount > 0, "natural compartment debug is available");
assert(map.adminDebug.averageCompartmentArea > 0, "natural compartments have positive average area");
assert(Number.isFinite(map.adminDebug.changedAfterLandscapePartition) && Number.isFinite(map.adminDebug.changedAfterSnap), "municipal changed-cell diagnostics exist");
assert(map.adminDebug.changedAfterCompartmentAssignment > 0 || map.adminDebug.changedAfterLandscapePartition > 0 || map.adminDebug.changedAfterSnap > 0, "municipal compartment or terrain passes change admin cells");
assert(map.adminDebug.changedAfterFinalExclaveRemoval + map.adminDebug.changedAfterFinalMerge < Math.max(2800, (map.adminDebug.changedAfterLandscapePartition + map.adminDebug.changedAfterSnap + map.adminDebug.changedAfterUrbanLock) * 1.35), "final municipal repair does not erase most terrain and urban changes");
assert(map.adminDebug.finalBorderNaturalBarrierAverage >= 0, "natural barrier score is tracked along final borders");
assert(map.adminDebug.voronoiLikeRateAfter <= Math.max(0.72, map.adminDebug.voronoiLikeRateBefore + 0.20), "natural compartment pass does not increase weak bisectors excessively");
assert(Number.isFinite(map.adminDebug.satelliteMunicipalitiesCreated) && Number.isFinite(map.adminDebug.averageSatelliteMunicipalityArea), "satellite municipality debug is available");
assert(satelliteMetrics.independent.length < 3 || satelliteMetrics.small.length / satelliteMetrics.independent.length <= 0.35, "tiny independent satellite municipalities are not the dominant pattern");
assert(satelliteMetrics.largeTooSmall.length === 0, "large independent satellites have meaningful municipal area");
assert(satelliteMetrics.independent.length < 3 || satelliteMetrics.average >= 140, "average independent satellite municipality area is meaningful");
assert(satelliteMetrics.rows.every((row) => row.area >= 80 || row.sat.municipalityClass === "smallTownAttachedToRuralMunicipality" || row.sat.municipalityClass === "suburbanDistrictMergedWithParent" || row.sat.municipalityClass === "newTownDistrict"), "tiny satellite areas are merged or explicitly classified as attached districts");
assert(adminMetrics.denseUrbanRate < 0.42, "admin borders avoid excessive dense urban crossings");
assert(adminMetrics.rightAngleRate < 0.46, "admin borders avoid excessive unsupported stair-step artifacts");
assert(adminMetrics.voronoiLikeRate < 0.58, "admin borders are not dominated by weak-terrain center bisectors");
@ -243,21 +637,39 @@ try {
assert(capitalInside, "prefectural capital is inside the prefecture");
assert(maxModernEndpointDegree <= 10, "modern transport endpoints are not over-centralized");
assert(map.entitiesForNames.every((item) => item.id && item.name), "nameable entities have ids and names");
assert(map.adminCenters.every((item) => item.id && item.name), "municipal centers have ids and names");
assert(map.entitiesForNames.some((item) => item.kind === "Municipal Center"), "municipal centers are included in label/name candidates");
assert(map.adminCenters.every((item) => item.name && Array.from(String(item.name)).length >= 2), "municipal center names are valid labels");
assert(map.adminCenters.some((item) => item.representativeFeatureName), "municipal centers keep representative feature metadata when available");
assert(map.adminCenters.every((item) => Array.from(String(item.name)).length >= 2), "municipal center names are not one-character labels");
assert(map.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), "municipal names avoid dangling one-kanji suffix fallback");
const adminNameDuplicateRatio = map.adminCenters.length ? 1 - new Set(map.adminCenters.map((item) => item.name)).size / map.adminCenters.length : 0;
assert(adminNameDuplicateRatio < 0.22, "duplicate municipal names stay low");
assert(map.adminDebug.naturalCompartmentCount > adminMetrics.municipalityCount, "natural compartments are finer than municipalities");
assert(map.adminDebug.targetMunicipalityCount >= 20 && map.adminDebug.targetMunicipalityCount <= 50 && map.adminDebug.actualMunicipalityCount >= 18, "municipality target and actual counts are dense enough");
assert(map.adminDebug.changedAfterCompartmentAssignment > 0, "municipal compartment assignment is active");
assert(map.adminDebug.seedCellRevivalCount === 0, "seed cell revival is disabled");
assert(map.adminDebug.candidateSeedCount >= map.adminDebug.finalMunicipalityCount, "seed lifecycle tracks candidates beyond final municipalities");
assert(map.adminDebug.absorbedSeedCount >= 0 && map.adminDebug.pendingSeedCount === 0, "unresolved pending seeds are absorbed");
assert(map.adminDebug.finalTinyMunicipalityCount <= Math.max(3, Math.ceil(map.adminDebug.finalMunicipalityCount * 0.16)), "tiny municipalities remain a small fraction");
assert(Array.isArray(map.adminDebug.compartmentBorders) && map.adminDebug.compartmentBorders.length > map.adminBorders.length, "natural compartment borders are available for debug rendering");
assert(map.entitiesForNames.every((item) => typeof item.name === "string"), "nameable entity names are strings");
assert(map.entitiesForNames.every((item) => !String(item.name).includes("\uFFFD")), "generated names contain no replacement characters");
assert(map.entitiesForNames.every((item) => Array.from(String(item.name)).length >= 2), "one-character generated names are prevented");
assert(map.entitiesForNames.every((item) => String(item.name).length > 0), "empty generated names are prevented");
assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low");
assert(map.nameDebug && Array.isArray(map.nameDebug.emptyPools), "nameDebug reports empty pools");
assert(map.nameDebug.emptyPools.length === Object.keys(NAME_KANJI_POOLS).length, "empty default pools are visible in nameDebug");
assert(map.nameDebug.oneKanjiAppendFallbackUsed === 0, "one-kanji append fallback is never used");
assert((map.nameDebug.derivedNameCount || 0) === 0, "derived municipality suffix names are not generated");
assert((map.nameDebug.maxDerivedPerBase || 0) <= 2, "derived names per base stay small");
assert(map.nameDebug.emptyPools.length === Object.values(NAME_KANJI_POOLS).filter((pool) => pool.length === 0).length, "nameDebug empty pools match configured pools");
assert(map.nameDebug.selectedTemplateCounts && typeof map.nameDebug.selectedTemplateCounts === "object", "nameDebug selectedTemplateCounts exists");
assert(map.nameDebug.selectedContextCounts && typeof map.nameDebug.selectedContextCounts === "object", "nameDebug selectedContextCounts exists");
assert(
map.nameDebug.generatedNamesUsed + map.nameDebug.customNamesUsed + map.nameDebug.forcedNamesUsed + map.nameDebug.fallbackAttempts === namedEntityCount,
"nameDebug accounting covers named entities"
);
assert(generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "empty pools do not use hidden fallback candidates");
assert(activePoolChars.size === 0, "no active pool characters exist until configured");
assert(activePoolChars.size > 0 || generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "template generation depends on active pools");
assert(villageClusterMean > 0.16, "villages prefer clustered valley, basin, coastal, and agricultural cells");
assert(saneEndpointRatio >= 0.76, "transport endpoints stay near meaningful generated nodes");
assert(Object.keys(CUSTOM_NAMES).length === 0 || NAME_PROBABILITIES.customName < 1, "CUSTOM_NAMES are probabilistic by default");
@ -275,6 +687,72 @@ try {
assert(JSON.stringify(againA.entitiesForNames.map((item) => [item.id, item.name])) === JSON.stringify(againB.entitiesForNames.map((item) => [item.id, item.name])), "generated names are deterministic for the same seed");
assert(JSON.stringify(againA.adminCenters.map((item) => [item.id, item.x, item.y, item.name])) === JSON.stringify(againB.adminCenters.map((item) => [item.id, item.x, item.y, item.name])), "municipal centers are deterministic for the same seed");
assert(JSON.stringify([...againA.adminId]) === JSON.stringify([...againB.adminId]), "municipal adminId snapping is deterministic for the same seed");
assert(JSON.stringify([...againA.prefectureRegionId]) === JSON.stringify([...againB.prefectureRegionId]), "regional prefecture ids are deterministic for the same seed");
assert(JSON.stringify(againA.adminDebug) === JSON.stringify(againB.adminDebug), "admin debug metrics are deterministic for the same seed");
assert(JSON.stringify(againA.regionalDebug) === JSON.stringify(againB.regionalDebug), "regional debug metrics are deterministic for the same seed");
assert(JSON.stringify(againA.transportDebug) === JSON.stringify(againB.transportDebug), "transport debug metrics are deterministic for the same seed");
assert(JSON.stringify(transportConnectivityMetrics(againA)) === JSON.stringify(transportConnectivityMetrics(againB)), "transport connectivity metrics are deterministic for the same seed");
assert(JSON.stringify([...againA.elevation]) === JSON.stringify([...againB.elevation]), "elevation is deterministic for the same seed");
assert(JSON.stringify([...againA.ridgeField]) === JSON.stringify([...againB.ridgeField]), "ridge field is deterministic for the same seed");
assert(JSON.stringify([...againA.river]) === JSON.stringify([...againB.river]), "river field is deterministic for the same seed");
assert(JSON.stringify(terrainCoreMetrics(againA)) === JSON.stringify(terrainCoreMetrics(againB)), "terrain debug metrics are deterministic for the same seed");
const blockedCapitalName = "\u52A0\u8302";
const capitalNameMaps = [114514, 12345, 54321, 777, 999].map((seedValue) => generateMap(seedValue));
const capitalNames = capitalNameMaps.map((seeded) => seeded.prefecturalCapital?.name).filter(Boolean);
assert(new Set(capitalNames).size > 1, "prefectural capital names vary across seeds");
assert(capitalNames.some((name) => name !== blockedCapitalName), "prefectural capital is not always the repeated custom name");
for (const [n, seeded] of capitalNameMaps.entries()) {
const seedValue = [114514, 12345, 54321, 777, 999][n];
const metrics = terrainCoreMetrics(seeded);
assert(seeded.mainRivers.length > 0 && seeded.tributaryRivers.length > 0 && seeded.smallStreams.length > 0, `seed ${seedValue}: river hierarchy exists`);
assert(metrics.mountainRatio > 0.08 && metrics.lowlandRatio > 0.06, `seed ${seedValue}: mountain and lowland terrain both exist`);
assert(metrics.ridgeVariance > 0.003 && metrics.ridgeSinuosity > 0.010, `seed ${seedValue}: ridges have varied jagged structure`);
assert(metrics.depositionSum > 0.1 && metrics.depositionTargetMean >= metrics.depositionOtherMean * 0.75, `seed ${seedValue}: deposition is active in plausible lowlands`);
assert(metrics.riverValleyMean > metrics.nonRiverValleyMean, `seed ${seedValue}: rivers follow valley fields`);
assert(seeded.terrainDebug?.primarySpineStrength > 0.08, `seed ${seedValue}: primary spine is strong enough`);
assert((seeded.terrainDebug?.largeInlandLakeCount || 0) <= 1, `seed ${seedValue}: large inland lakes are rare`);
assert((seeded.terrainDebug?.smallIslandCount || 0) <= 24, `seed ${seedValue}: small island speckles are limited`);
assert(seeded.villages.length > 0 && seeded.markets.length > 0 && seeded.modernCities.length > 0, `seed ${seedValue}: settlements are generated`);
assert(seeded.premodernRoads.length > 0 && seeded.railways.length > 0, `seed ${seedValue}: roads and railways are generated`);
const seededTransport = transportConnectivityMetrics(seeded);
assert(seeded.adminId.length === size && seeded.adminBorders.length > 0 && seeded.regionalPrefectureBorders.length > 0, `seed ${seedValue}: admin and regional borders exist`);
assert(seeded.adminCenters.every((item) => item.name && Array.from(String(item.name)).length >= 2), `seed ${seedValue}: every admin center has a valid name`);
assert(seeded.entitiesForNames.some((item) => item.kind === "Municipal Center"), `seed ${seedValue}: admin labels are included in label candidates`);
assert(seeded.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), `seed ${seedValue}: no dangling one-kanji admin suffix fallback`);
assert(seeded.nameDebug?.oneKanjiAppendFallbackUsed === 0, `seed ${seedValue}: one-kanji append fallback stays unused`);
assert((seeded.nameDebug?.derivedNameCount || 0) === 0, `seed ${seedValue}: derived suffix names stay disabled`);
const seededAdminMetrics = adminBoundaryMetrics(seeded);
const debug = seeded.adminDebug || {};
assert(seededAdminMetrics.municipalityCount >= 18 && seededAdminMetrics.municipalityCount <= 50, `seed ${seedValue}: municipality count stays in target range`);
assert(debug.naturalCompartmentCount >= debug.actualMunicipalityCount * 2.5, `seed ${seedValue}: natural compartments are substantially finer than municipalities`);
assert(debug.naturalCompartmentCount <= debug.actualMunicipalityCount * 10, `seed ${seedValue}: natural compartments do not become noisy cells`);
assert(debug.averageCompartmentsPerMunicipality >= 2.5, `seed ${seedValue}: municipalities group multiple compartments on average`);
assert(debug.singleCompartmentMunicipalityRatio < 0.35, `seed ${seedValue}: one-compartment municipalities are uncommon`);
assert(debug.seedCellRevivalCount === 0, `seed ${seedValue}: seed cells are not revived after absorption`);
assert(debug.finalMunicipalityCount >= 18 && debug.finalMunicipalityCount <= 50, `seed ${seedValue}: final municipality count remains bounded`);
assert(debug.finalTinyMunicipalityCount <= Math.max(3, Math.ceil(debug.finalMunicipalityCount * 0.16)), `seed ${seedValue}: tiny municipalities stay uncommon`);
assert(debug.pendingSeedsUsedForLowlandSplit > 0 || debug.absorbedSeedCount > 0 || debug.finalMunicipalityCount >= Math.min(20, debug.targetMunicipalityCount), `seed ${seedValue}: pending seeds are either used for lowland splits or absorbed`);
assert((debug.seedLifecycle || []).every((seed) => seed.state !== "absorbed" || !seed.protected), `seed ${seedValue}: protected seeds are not absorbed`);
const highMountainSeeds = (seeded.adminCenters || []).filter((p) => {
const i = indexOf(p.x, p.y);
return seeded.elevation[i] > 0.70 || seeded.slope[i] > 0.52 || seeded.ridgeField[i] > 0.62;
}).length;
assert(highMountainSeeds <= Math.max(2, Math.ceil((seeded.adminCenters || []).length * 0.12)), `seed ${seedValue}: high mountain admin seeds are rare`);
assert(seededAdminMetrics.avgTarget > 0.13, `seed ${seedValue}: municipal borders beat a loose lowland-random barrier baseline`);
assert(seededAdminMetrics.denseUrbanRate < 0.52, `seed ${seedValue}: dense urban boundary crossing rate remains low`);
assert(seededAdminMetrics.voronoiLikeRate < 0.66, `seed ${seedValue}: Voronoi-like municipal borders do not dominate`);
}
const deterministicSeedMapsA = [114514, 12345, 54321, 777, 999].map((seedValue) => generateMap(seedValue));
const deterministicSeedMapsB = [114514, 12345, 54321, 777, 999].map((seedValue) => generateMap(seedValue));
for (let n = 0; n < deterministicSeedMapsA.length; n++) {
assert(JSON.stringify([...deterministicSeedMapsA[n].adminId]) === JSON.stringify([...deterministicSeedMapsB[n].adminId]), `seed ${[114514, 12345, 54321, 777, 999][n]}: adminId is deterministic`);
assert(JSON.stringify(deterministicSeedMapsA[n].adminDebug) === JSON.stringify(deterministicSeedMapsB[n].adminDebug), `seed ${[114514, 12345, 54321, 777, 999][n]}: admin debug metrics are deterministic`);
}
const byDeposition = capitalNameMaps
.map((seeded) => ({ deposition: seeded.terrainTemplate.deposition, lowlandRatio: terrainCoreMetrics(seeded).lowlandRatio }))
.sort((a, b) => a.deposition - b.deposition);
assert(byDeposition[byDeposition.length - 1].lowlandRatio >= byDeposition[0].lowlandRatio * 0.72, "higher-deposition templates generally preserve or expand lowland area");
CUSTOM_NAMES["city-0"] = "C1";
const customSameA = generateMap(321);
@ -288,13 +766,44 @@ try {
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]) {
const seeded = generateMap(seed);
const metrics = adminBoundaryMetrics(seeded);
const seededRegional = regionalComponentMetrics(seeded);
const seededSatellites = satelliteMunicipalityMetrics(seeded);
const invalidLandCells = [...seeded.adminId].filter((id, i) => seeded.prefectureMask[i] && !seeded.sea[i] && id < 0).length;
const invalidRegionCells = [...seeded.prefectureRegionId].filter((id, i) => !seeded.sea[i] && id < 0).length;
assert(invalidLandCells === 0, `seed ${seed}: every prefecture land cell has a valid adminId`);
assert(invalidRegionCells === 0, `seed ${seed}: every regional land cell has a valid regionId`);
assert(seeded.adminBorders.length > 0, `seed ${seed}: municipal borders exist`);
assert(metrics.municipalityCount >= 8, `seed ${seed}: municipality count remains reasonable`);
assert(seeded.regionalPrefectureBorders.length > 0, `seed ${seed}: regional prefecture borders exist`);
assert(seeded.regionalDebug?.regionalChangedAfterNaturalPartition > 0, `seed ${seed}: regional natural partition changes cells`);
assert(seeded.regionalDebug.regionalNaturalBarrierAverageAfter >= seeded.regionalDebug.regionalNaturalBarrierAverageBefore - 0.10, `seed ${seed}: regional border natural affinity is stable`);
assert(seeded.regionalDebug.regionalVoronoiLikeRateAfter <= seeded.regionalDebug.regionalVoronoiLikeRateBefore + 0.25, `seed ${seed}: regional Voronoi-like rate is bounded`);
assert(seededRegional.maxComponents <= 5, `seed ${seed}: regional regions remain connected enough`);
assert(seeded.adminDebug && seeded.adminDebug.compartmentCount > 0, `seed ${seed}: natural compartments are built`);
assert(seeded.adminDebug.naturalCompartmentCount > metrics.municipalityCount, `seed ${seed}: compartments are finer than municipalities`);
assert(seeded.adminDebug.targetMunicipalityCount >= 20 && seeded.adminDebug.actualMunicipalityCount >= 18, `seed ${seed}: municipality count is dense enough`);
assert(seeded.adminDebug.seedCellRevivalCount === 0, `seed ${seed}: seed cell revival stays disabled`);
assert(seeded.adminDebug.finalTinyMunicipalityCount <= Math.max(3, Math.ceil(seeded.adminDebug.finalMunicipalityCount * 0.18)), `seed ${seed}: tiny final municipalities are limited`);
assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0, `seed ${seed}: compartment assignment is not a no-op`);
assert(Array.isArray(seeded.adminDebug.compartmentBorders) && seeded.adminDebug.compartmentBorders.length > seeded.adminBorders.length, `seed ${seed}: compartment border debug exists`);
assert(seeded.regionalDebug?.changedAfterCompartmentAssignment > 0, `seed ${seed}: regional compartment assignment changes cells`);
assert(seeded.regionalDebug?.borderNaturalBarrierAverage > 0.12, `seed ${seed}: regional borders have natural barrier affinity`);
assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0 || seeded.adminDebug.changedAfterLandscapePartition > 0 || seeded.adminDebug.changedAfterSnap > 0, `seed ${seed}: municipal compartment or terrain passes change cells`);
assert(seededSatellites.largeTooSmall.length === 0, `seed ${seed}: large satellites are not tiny independent municipalities`);
assert(seededSatellites.independent.length < 3 || seededSatellites.small.length / seededSatellites.independent.length <= 0.35, `seed ${seed}: tiny satellite municipalities remain uncommon`);
assert(metrics.municipalityCount >= 18, `seed ${seed}: municipality count remains reasonable`);
assert(metrics.centerValidRatio >= 0.90, `seed ${seed}: municipality centers remain valid`);
assert(metrics.maxComponents <= 5, `seed ${seed}: topology repair limits disconnected fragments`);
assert(metrics.avgTarget > 0.14, `seed ${seed}: borders retain terrain-boundary affinity`);