before tweaking

This commit is contained in:
33333-33333 2026-05-22 13:57:52 +09:00
commit e48fae5509
8 changed files with 980 additions and 246 deletions

View file

@ -532,6 +532,26 @@ export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope,
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;
@ -546,6 +566,94 @@ function canShareNaturalCompartment(a, b, classA, classB, barrier, river, flowAc
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)}`;
@ -555,7 +663,7 @@ function naturalGroupKey(unit) {
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) {
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);
@ -595,7 +703,7 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
}
}
const area = cells.length;
compartments.push({
const unit = {
id,
cells,
area,
@ -612,11 +720,34 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
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 };
}
@ -725,6 +856,107 @@ function naturalOwnershipAffinity(unit, neighbor, edge) {
return edge.count * 0.55 + sameClass + sameGroup + urbanContinuity + (bothCorridor ? 0.72 : 0) - strongDividerPenalty;
}
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 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 });
}
}
while (heap.length > 0) {
const cur = heap.pop();
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
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 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;
@ -822,11 +1054,11 @@ export function extractCompartmentBorders(compartmentId, prefectureMask, sea) {
return segments;
}
export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) {
const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse);
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 = assignCompartmentsToAdminOwners(compartments, compartmentId, adminCenters, naturalBarrierScore, prefectureMask, sea);
const owner = graphVoronoiCompartmentOwners(compartments, compartmentId, adminCenters, options);
for (const unit of compartments) {
const assigned = owner[unit.id];
if (assigned < 0) continue;
@ -837,22 +1069,16 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e
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;
const i = indexOf(center.x, center.y);
if (prefectureMask[i] && !sea[i]) adminId[i] = id;
}
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: {
naturalCompartmentCount: activeCompartments.length,
compartmentCount: activeCompartments.length,
...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),

View file

@ -26,6 +26,257 @@ function municipalityAreaById(adminId, prefectureMask, sea) {
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;
@ -58,6 +309,118 @@ function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope,
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);
@ -252,53 +615,40 @@ export function generateAdminLayout({
const boundaryRidgeField = naturalBarrierScore
? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46))
: ridgeField;
const municipalityCandidates = [];
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 urbanBias = landuse[i] === 3 ? 0.62 : landuse[i] === 2 ? 0.56 : landuse[i] === 4 ? 0.5 : landuse[i] === 1 ? 0.4 : 0.28;
const score = urbanBias + settlementScore[i] * 0.22 + roadInfluence[i] * 0.08 + railInfluence2[i] * 0.05 + villageInfluence[i] * 0.04 - slope[i] * 0.18 - ridgeField[i] * 0.06 + hash2(x, y, seed + 1300) * 0.025;
if (score > 0.40) municipalityCandidates.push({ x, y, score });
}
}
const majorMunicipalSeeds = modernCities
.filter((city) => (city.population || 0) >= 220000 && prefectureMask[indexOf(city.x, city.y)])
.map((city) => ({ x: city.x, y: city.y, score: 1.55 + (city.population || 0) / 700000, protectedCity: city }));
const filteredMunicipalityCandidates = municipalityCandidates.filter((p) => {
const nearMajor = majorMunicipalSeeds.some((city) => Math.hypot(city.x - p.x, city.y - p.y) < clamp(12 + Math.sqrt(city.protectedCity.population || 300000) / 130, 14, 28));
const nearSmallUrban = modernCities.some((city) => (city.population || 0) < 260000 && Math.hypot(city.x - p.x, city.y - p.y) < 8 && p.x !== city.x && p.y !== city.y);
return !nearMajor && !nearSmallUrban;
});
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 satelliteMunicipalSeeds = (satelliteCities || [])
.filter((city) => prefectureMask[indexOf(city.x, city.y)] && city.municipalityClass === "independentSatelliteMunicipality")
.map((city) => ({ x: city.x, y: city.y, score: 1.05 + (city.population || 40000) / 260000, protectedSatellite: city }));
let adminCentersRaw = [
...majorMunicipalSeeds,
...satelliteMunicipalSeeds,
...pickEntities(filteredMunicipalityCandidates.filter((p) => satelliteMunicipalSeeds.every((s) => Math.hypot(s.x - p.x, s.y - p.y) >= 6)), {
max: Math.max(0, targetMunicipalityCount - majorMunicipalSeeds.length - satelliteMunicipalSeeds.length),
minDistance: 6 + Math.floor(rand(seed, 1302) * 3),
threshold: 0.34,
seed: seed + 1300,
jitter: 0.025,
}),
];
if (adminCentersRaw.length < Math.min(targetMunicipalityCount, 18)) {
const fallback = [...modernCities, ...(satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality"), ...markets, ...ports, ...newTowns, ...stations, ...villages]
.filter((p) => prefectureMask[indexOf(p.x, p.y)])
.map((p) => ({ x: p.x, y: p.y, score: (p.score || 0.5) + (p.population || 0) / 900000 }));
const extraFallback = pickEntities(fallback, { max: targetMunicipalityCount, minDistance: 5, threshold: 0, seed: seed + 1303 });
for (const p of extraFallback) if (adminCentersRaw.length < targetMunicipalityCount && adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4)) adminCentersRaw.push(p);
}
if (adminCentersRaw.length < targetMunicipalityCount) {
const extra = pickEntities(municipalityCandidates, { max: targetMunicipalityCount - adminCentersRaw.length, minDistance: 5, threshold: 0.26, seed: seed + 1304 });
adminCentersRaw.push(...extra.filter((p) => adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4)));
}
if (adminCentersRaw.length > targetMunicipalityCount) adminCentersRaw = adminCentersRaw.slice(0, targetMunicipalityCount);
const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw);
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 = {
@ -320,7 +670,25 @@ export function generateAdminLayout({
oversizedLowlandSplits: 0,
ruralSplitsAccepted: 0,
ruralSplitsRejected: 0,
satelliteMunicipalitiesCreated: satelliteMunicipalSeeds.length,
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,
@ -332,6 +700,27 @@ export function generateAdminLayout({
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);
@ -344,6 +733,7 @@ export function generateAdminLayout({
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; }
});
@ -375,6 +765,7 @@ export function generateAdminLayout({
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; }
});
@ -393,20 +784,13 @@ export function generateAdminLayout({
lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 520);
lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620);
markChanged("changedAfterSmallUrbanLock");
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: adminCentersRaw });
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");
applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw);
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,
});
}
// 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;
@ -418,9 +802,9 @@ export function generateAdminLayout({
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, 360);
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 360);
markChanged("changedAfterFinalExclaveRemoval");
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 80, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 90, protectedPoints: adminCentersRaw });
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 80, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 90, protectedPoints: activeAdminCenters() });
markChanged("changedAfterFinalMerge");
for (const sat of satelliteCities || []) {
@ -432,7 +816,22 @@ export function generateAdminLayout({
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities,
});
}
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 260);
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 = [];
@ -442,7 +841,7 @@ export function generateAdminLayout({
const area = areaById.get(id) || 0;
const key = sat.name || `satellite-${index}`;
adminDebug.satelliteMunicipalityAreaByNameOrIndex[key] = area;
if (sat.municipalityClass === "independentSatelliteMunicipality" && (area < 80 || ((sat.population || 0) >= 60000 && area < 120))) {
if (sat.municipalityClass === "independentSatelliteMunicipality" && (area < Math.max(120, sat.satelliteMinArea || 0) || ((sat.population || 0) >= 60000 && area < 150))) {
sat.municipalityClass = "smallTownAttachedToRuralMunicipality";
adminDebug.satelliteMunicipalitiesTooSmall++;
return;
@ -457,8 +856,19 @@ export function generateAdminLayout({
adminDebug.satelliteMunicipalitiesIndependent = satelliteAreas.length;
const landscapeDebug = applyLandscapeUnitAdminPartition.lastDebug || {};
Object.assign(adminDebug, landscapeDebug);
adminDebug.naturalCompartmentCount = adminDebug.naturalCompartmentCount || adminDebug.compartmentCount || 0;
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);

View file

@ -440,8 +440,8 @@ export function generateMapFeatures(seed, terrain) {
function transportAccessPoint(node, mode = "road", salt = 0) {
if (!node || nearMapEdge(node.x, node.y, 1) || node.kind === "External Gateway") return node;
const minR = mode === "express" ? 10 : mode === "rail" ? 2 : 4;
const maxR = mode === "express" ? 20 : mode === "rail" ? 6 : 10;
const minR = mode === "express" ? 6 : mode === "rail" ? 2 : 4;
const maxR = mode === "express" ? 16 : mode === "rail" ? 6 : 10;
let best = null;
let bestScore = -INF;
for (let dy = -maxR; dy <= maxR; dy++) {
@ -460,7 +460,7 @@ export function generateMapFeatures(seed, terrain) {
const ring = -Math.abs(d - targetD) * 0.08;
const riverPenalty = river[i] > 0.5 ? 0.45 : river[i] * 0.12;
const density = densityValue(x, y);
const densityAffinity = mode === "rail" ? density * 0.9 : mode === "express" ? midDensityAffinity(x, y) * 0.52 - Math.max(0, density - 0.72) * 0.9 : density * 0.24;
const densityAffinity = mode === "rail" ? density * 0.9 : mode === "express" ? density * 0.70 + midDensityAffinity(x, y) * 0.18 - Math.max(0, density - 0.92) * 0.35 : density * 0.24;
const noise = hash2(x, y, seed + salt + (mode === "rail" ? 6000 : mode === "express" ? 7000 : 5000)) * 0.12;
const score = flatness + densityAffinity + ring - riverPenalty - barrier * 0.012 + noise;
if (score > bestScore) {
@ -601,10 +601,12 @@ export function generateMapFeatures(seed, terrain) {
const density = densityValue(x, y);
const midDensity = midDensityAffinity(x, y);
const cityDistance = distanceToNearest(modernCities, x, y);
const cityAvoid = cityDistance < 5 ? 22.0 : cityDistance < 9 ? 11.0 : cityDistance < 13 ? 4.0 : distanceToNearest(markets, x, y) < 4 ? 3.2 : 0;
const densityPenalty = density > 0.66 ? (density - 0.66) * 9.5 : density < 0.08 ? (0.08 - density) * 2.4 : 0;
const highPenalty = barrier + (elevation[i] > 0.72 ? 26 : elevation[i] > 0.62 ? 8.5 : 0);
return Math.max(0.42, 1 + slope[i] * 23.0 + highPenalty + cityAvoid + densityPenalty + (river[i] > 0.45 ? 1.0 : 0) - midDensity * 0.82 - plain[i] * 0.16 - valleyField[i] * 0.16 - coastalLowland[i] * 0.18 + ridgeField[i] * 1.20 + normalEdgePenalty(x, y) + hash2(x, y, seed + 444) * 0.015);
const coreAvoid = cityDistance < 2.2 ? 18.0 : cityDistance < 4.5 ? 7.0 : cityDistance < 7.5 ? 2.0 : 0;
const marketAvoid = distanceToNearest(markets, x, y) < 2.5 ? 1.8 : 0;
const lowDensityPenalty = density < 0.10 ? (0.10 - density) * 4.8 : 0;
const urbanCorridorBonus = density * 1.18 + midDensity * 0.28 + (cityDistance >= 4 && cityDistance <= 16 ? 0.40 : 0);
const constructionCost = 0.58 + slope[i] * 28.0 + barrier * 1.10 + Math.max(0, elevation[i] - 0.60) * 16.0 + ridgeField[i] * 1.45 + (river[i] > 0.45 ? 1.15 : river[i] * 0.45);
return Math.max(0.50, 1.18 + constructionCost + coreAvoid + marketAvoid + lowDensityPenalty - urbanCorridorBonus - plain[i] * 0.12 - valleyField[i] * 0.12 - coastalLowland[i] * 0.12 + normalEdgePenalty(x, y) + hash2(x, y, seed + 444) * 0.015);
}
const nationalRoads = [];
@ -648,8 +650,8 @@ export function generateMapFeatures(seed, terrain) {
const path = aStar(start, goal, makeTransportCost(roadCost, existing, roadHubs, [start, goal], 3, 5.8, townAvoidNodes, 3.2, 5.4));
const direct = pathEndpointDistance(path);
const urbanPasses = modernCities.filter((city) => path.some(([x, y]) => Math.hypot(x - city.x, y - city.y) <= Math.max(6, Math.min(13, (city.urbanRadius || 8) * 0.78)))).length;
const passBonusOk = urbanPasses >= 2 || direct >= 24;
if (path.length > 3 && direct >= 16 && pathLength(path) >= 20 && pathCompactness(path) < 3.35 && pathOverlapRatio(path, existing, 2) < 0.48 && passBonusOk) {
const passBonusOk = urbanPasses >= 1 || direct >= 18;
if (path.length > 3 && direct >= 12 && pathLength(path) >= 14 && pathCompactness(path) < 4.20 && pathOverlapRatio(path, existing, 2) < 0.78 && passBonusOk) {
nationalRoads.push(path);
incrementDegree(roadDegree, a);
incrementDegree(roadDegree, b);
@ -706,12 +708,37 @@ export function generateMapFeatures(seed, terrain) {
const expressDegree = new Map();
const expressCore = [capital];
function snapPathToExistingExpressways(path, existingPaths, radius = 2.4) {
if (!path?.length || !existingPaths?.length) return path || [];
const snapped = [];
const skipEnd = Math.min(5, Math.floor(path.length / 5));
for (let pi = 0; pi < path.length; pi++) {
const [x, y] = path[pi];
let best = null;
let bestD = radius;
if (pi >= skipEnd && pi < path.length - skipEnd) {
for (const existing of existingPaths) {
for (const [ex, ey] of existing) {
const d = Math.hypot(x - ex, y - ey);
if (d < bestD) { bestD = d; best = [ex, ey]; }
}
}
}
const next = best || [x, y];
const last = snapped[snapped.length - 1];
if (!last || last[0] !== next[0] || last[1] !== next[1]) snapped.push(next);
}
return snapped;
}
function addExpressway(a, b, bucket = expressways) {
const start = routePoint(a, "express", a.x * 41 + a.y * 43);
const goal = routePoint(b, "express", b.x * 41 + b.y * 43 + 29);
const existing = [...expressways, ...nationalRoads, ...railways, ...branchRailways];
let path = aStar(start, goal, makeTransportCost(expresswayCost, existing, roadHubs, [start, goal], 5, 10.8, townAvoidNodes, 8.5, 14.0));
path = smoothPathByLineOfSight(path, (x, y) => expresswayCost(x, y, x, y) < INF && slope[indexOf(x, y)] < 0.54 && elevation[indexOf(x, y)] < 0.78, 10);
path = snapPathToExistingExpressways(path, expressways, 2.6);
const direct = pathEndpointDistance(path);
if (path.length > 8 && direct >= 26 && pathLength(path) >= 30 && pathCompactness(path) < 2.35 && pathOverlapRatio(path, existing, 2) < 0.30) {
bucket.push(path);
@ -888,6 +915,16 @@ export function generateMapFeatures(seed, terrain) {
threshold: 0.4,
seed: seed + 1201,
}).map((p) => ({ ...p, kind: "External Gateway" }));
if (externalGateways.length < 2) {
const fallbackGateways = gatewayCandidates
.slice()
.sort((a, b) => b.score - a.score);
for (const gate of fallbackGateways) {
if (externalGateways.some((p) => Math.hypot(p.x - gate.x, p.y - gate.y) < 30)) continue;
externalGateways.push({ ...gate, kind: "External Gateway" });
if (externalGateways.length >= 2) break;
}
}
function externalRoadCost(goal) {
return (x, y) => {
@ -910,9 +947,11 @@ export function generateMapFeatures(seed, terrain) {
const borderPenalty = nearMapEdge(x, y, 1) && !(Math.abs(x - goal.x) <= 2 && Math.abs(y - goal.y) <= 2) ? 7 : nearMapEdge(x, y, 3) ? 1.5 : 0;
const density = densityValue(x, y);
const cityDistance = distanceToNearest(modernCities, x, y);
const cityAvoid = cityDistance < 5 ? 22.0 : cityDistance < 9 ? 11.0 : cityDistance < 13 ? 4.0 : distanceToNearest(markets, x, y) < 4 ? 3.2 : 0;
const densityPenalty = density > 0.66 ? (density - 0.66) * 9.5 : density < 0.08 ? (0.08 - density) * 2.4 : 0;
return Math.max(0.42, 1 + slope[i] * 19 + barrier + cityAvoid + densityPenalty + (river[i] > 0.45 ? 1 : 0) + floodplain[i] * 0.2 - midDensityAffinity(x, y) * 0.7 - plain[i] * 0.12 + borderPenalty + hash2(x, y, seed + 444) * 0.05);
const coreAvoid = cityDistance < 2.2 ? 16.0 : cityDistance < 4.5 ? 6.0 : cityDistance < 7.5 ? 1.8 : 0;
const lowDensityPenalty = density < 0.10 ? (0.10 - density) * 4.2 : 0;
const urbanCorridorBonus = density * 1.04 + midDensityAffinity(x, y) * 0.24 + (cityDistance >= 4 && cityDistance <= 16 ? 0.32 : 0);
const constructionCost = 0.55 + slope[i] * 23.0 + barrier * 1.06 + Math.max(0, elevation[i] - 0.60) * 12.0 + ridgeField[i] * 1.20 + (river[i] > 0.45 ? 1 : river[i] * 0.38);
return Math.max(0.50, 1.14 + constructionCost + coreAvoid + lowDensityPenalty + floodplain[i] * 0.18 + borderPenalty - urbanCorridorBonus - plain[i] * 0.10 + hash2(x, y, seed + 444) * 0.04);
};
}
function externalRailCost(goal) {
@ -945,7 +984,8 @@ export function generateMapFeatures(seed, terrain) {
const roadStart = routePoint(roadStartRaw, makeExpressLink ? "express" : "road", gate.x * 53 + gate.y * 59);
const roadExisting = [...nationalRoads, ...expressways, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways];
const roadBaseCost = makeExpressLink ? externalExpresswayCost(gate) : externalRoadCost(gate);
const roadPath = aStar(roadStart, gate, makeTransportCost(roadBaseCost, roadExisting, roadHubs, [roadStart, gate], makeExpressLink ? 4 : 3, makeExpressLink ? 8.2 : 6.2, townAvoidNodes, makeExpressLink ? 5.4 : 3.2, makeExpressLink ? 7.8 : 5.6));
let roadPath = aStar(roadStart, gate, makeTransportCost(roadBaseCost, roadExisting, roadHubs, [roadStart, gate], makeExpressLink ? 4 : 3, makeExpressLink ? 8.2 : 6.2, townAvoidNodes, makeExpressLink ? 5.4 : 3.2, makeExpressLink ? 7.8 : 5.6));
if (makeExpressLink) roadPath = snapPathToExistingExpressways(roadPath, [...expressways, ...externalExpressways], 2.6);
if (roadPath.length > 6) {
if (makeExpressLink) {
externalExpressways.push(roadPath);
@ -971,68 +1011,113 @@ export function generateMapFeatures(seed, terrain) {
}
});
const requiredTransportNodes = [];
function addRequiredTransportNode(node, reason) {
if (!node || !inside(node.x, node.y) || sea[indexOf(node.x, node.y)]) return;
const key = `${node.x},${node.y}`;
if (requiredTransportNodes.some((p) => `${p.x},${p.y}` === key)) return;
requiredTransportNodes.push({ ...node, requiredTransportReason: reason });
let throughExpresswayAdded = false;
function throughExpresswayCost(a, b) {
return (x, y) => {
const i = indexOf(x, y);
if (sea[i]) return INF;
const barrier = mountainBarrierPenalty(x, y, "express");
if (barrier >= INF) return INF;
const nearEndpoint = Math.min(Math.hypot(x - a.x, y - a.y), Math.hypot(x - b.x, y - b.y)) <= 3;
const borderPenalty = nearEndpoint ? 0 : nearMapEdge(x, y, 3) ? 2.2 : 0;
const density = densityValue(x, y);
const cityDistance = distanceToNearest(modernCities, x, y);
const coreAvoid = cityDistance < 2.0 ? 12.0 : cityDistance < 4.5 ? 4.8 : cityDistance < 7.5 ? 1.4 : 0;
const lowDensityPenalty = density < 0.08 ? (0.08 - density) * 4.0 : 0;
const urbanCorridorBonus = density * 1.00 + midDensityAffinity(x, y) * 0.22 + (cityDistance >= 4 && cityDistance <= 18 ? 0.34 : 0);
return Math.max(0.48, 1.16 + slope[i] * 21.0 + barrier * 1.08 + Math.max(0, elevation[i] - 0.62) * 13.0 + coreAvoid + lowDensityPenalty + (river[i] > 0.45 ? 1.0 : 0) + borderPenalty - urbanCorridorBonus - plain[i] * 0.14 - coastalLowland[i] * 0.12 + hash2(x, y, seed + 9101) * 0.03);
};
}
addRequiredTransportNode(capital, "capital");
for (const gate of externalGateways) addRequiredTransportNode(gate, "externalGateway");
for (const city of modernCities) if ((city.population || 0) >= 120000 || city.isPrefecturalCapital) addRequiredTransportNode(city, "majorCity");
for (const port of majorPorts) addRequiredTransportNode(port, "majorPort");
const backboneAccess = new Map();
function nodeKey(p) {
return `${p.x},${p.y}`;
function permissiveThroughExpresswayCost(a, b) {
return (x, y) => {
const i = indexOf(x, y);
if (sea[i]) return 24 + nearMapEdge(x, y, 2) * 2 + hash2(x, y, seed + 9202) * 0.2;
const rawBarrier = mountainBarrierPenalty(x, y, "express");
const tunnelBarrier = rawBarrier >= INF ? 120 + Math.max(0, elevation[i] - 0.66) * 260 + slope[i] * 55 : rawBarrier;
const nearEndpoint = Math.min(Math.hypot(x - a.x, y - a.y), Math.hypot(x - b.x, y - b.y)) <= 3;
const borderPenalty = nearEndpoint ? 0 : nearMapEdge(x, y, 3) ? 2.0 : 0;
const cityDistance = distanceToNearest(modernCities, x, y);
const coreAvoid = cityDistance < 2.0 ? 9.0 : cityDistance < 4.5 ? 3.5 : 0;
const density = densityValue(x, y);
const urbanCorridorBonus = density * 0.85 + midDensityAffinity(x, y) * 0.18 + (cityDistance >= 4 && cityDistance <= 18 ? 0.24 : 0);
return Math.max(0.52, 1.18 + slope[i] * 14.0 + tunnelBarrier * 0.42 + Math.max(0, elevation[i] - 0.66) * 18.0 + coreAvoid + borderPenalty - urbanCorridorBonus - plain[i] * 0.10 + hash2(x, y, seed + 9201) * 0.035);
};
}
function backbonePoint(node) {
const key = nodeKey(node);
if (!backboneAccess.has(key)) {
const mode = node.requiredTransportReason === "externalGateway" ? "road" : "road";
backboneAccess.set(key, routePoint(node, mode, 18000 + node.x * 97 + node.y * 101));
function pointToSegmentDistance(p, a, b) {
const vx = b.x - a.x;
const vy = b.y - a.y;
const len2 = vx * vx + vy * vy;
if (len2 <= 0.0001) return Math.hypot(p.x - a.x, p.y - a.y);
const t = clamp(((p.x - a.x) * vx + (p.y - a.y) * vy) / len2, 0, 1);
return Math.hypot(p.x - (a.x + vx * t), p.y - (a.y + vy * t));
}
function chooseThroughExpresswayVia(a, b) {
const candidates = [capital, ...modernCities.filter((city) => (city.population || 0) >= 90000)];
let best = null;
let bestScore = -INF;
for (const city of candidates) {
if (!city || !prefectureMask[indexOf(city.x, city.y)] || sea[indexOf(city.x, city.y)]) continue;
const access = routePoint(city, "express", city.x * 73 + city.y * 79 + 9301);
const lineD = pointToSegmentDistance(access, a, b);
const density = densityValue(access.x, access.y);
const popScore = Math.sqrt(Math.max(0, city.population || 0)) / 520;
const score = density * 2.8 + popScore + (city.isPrefecturalCapital ? 0.9 : 0) - lineD / 38 - mountainBarrierPenalty(access.x, access.y, "express") * 0.004;
if (score > bestScore) { bestScore = score; best = access; }
}
return backboneAccess.get(key);
return best;
}
function addBackboneRoad(a, b) {
const start = backbonePoint(a);
const goal = backbonePoint(b);
const existing = [...nationalRoads, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways];
const path = aStar(start, goal, makeTransportCost(roadCost, existing, roadHubs, [start, goal], 2, 4.2, townAvoidNodes, 2.6, 4.2));
if (path.length <= 3 || pathCompactness(path) > 3.8 || path.some(([x, y]) => elevation[indexOf(x, y)] > 0.72)) return false;
nationalRoads.push(path);
incrementDegree(roadDegree, a);
incrementDegree(roadDegree, b);
return true;
}
const connectedBackboneNodes = requiredTransportNodes.length ? [requiredTransportNodes[0]] : [];
const pendingBackboneNodes = requiredTransportNodes.slice(1);
let backboneEdgeCount = 0;
while (pendingBackboneNodes.length && connectedBackboneNodes.length) {
let bestIndex = -1;
let bestAnchor = null;
let bestScore = INF;
for (let i = 0; i < pendingBackboneNodes.length; i++) {
const node = pendingBackboneNodes[i];
for (const anchor of connectedBackboneNodes) {
const d = Math.hypot(node.x - anchor.x, node.y - anchor.y);
const ai = indexOf(anchor.x, anchor.y);
const bi = indexOf(node.x, node.y);
const corridor = sameCorridorAffinity(anchor, node);
const score = d * (1.0 - corridor * 0.22) + Math.max(elevation[ai], elevation[bi]) * 8 - Math.max(passSuitability[ai], passSuitability[bi]) * 4;
if (score < bestScore) {
function addThroughExpressway() {
if (externalGateways.length < 2) return false;
let bestPair = null;
let bestScore = -INF;
for (let i = 0; i < externalGateways.length; i++) {
for (let j = i + 1; j < externalGateways.length; j++) {
const a = externalGateways[i];
const b = externalGateways[j];
const d = Math.hypot(a.x - b.x, a.y - b.y);
const opposite = (a.side === "N" && b.side === "S") || (a.side === "S" && b.side === "N") || (a.side === "W" && b.side === "E") || (a.side === "E" && b.side === "W");
const score = d + (opposite ? 42 : 0) - Math.abs((a.score || 0) - (b.score || 0)) * 3;
if (score > bestScore) {
bestScore = score;
bestIndex = i;
bestAnchor = anchor;
bestPair = [a, b];
}
}
}
if (bestIndex < 0 || !bestAnchor) break;
const node = pendingBackboneNodes.splice(bestIndex, 1)[0];
if (addBackboneRoad(bestAnchor, node)) backboneEdgeCount++;
connectedBackboneNodes.push(node);
if (!bestPair) return false;
const [a, b] = bestPair;
const existing = [...externalExpressways, ...expressways, ...nationalRoads, ...railways, ...branchRailways];
const via = chooseThroughExpresswayVia(a, b);
let path = [];
let viaUsed = false;
if (via) {
let first = aStar(a, via, makeTransportCost(throughExpresswayCost(a, via), existing, roadHubs, [a, via], 5, 9.6, townAvoidNodes, 4.2, 6.0));
first = smoothPathByLineOfSight(first, (x, y) => throughExpresswayCost(a, via)(x, y, x, y) < INF && elevation[indexOf(x, y)] < 0.88, 11);
let second = aStar(via, b, makeTransportCost(throughExpresswayCost(via, b), [...existing, first], roadHubs, [via, b], 5, 9.6, townAvoidNodes, 4.2, 6.0));
second = smoothPathByLineOfSight(second, (x, y) => throughExpresswayCost(via, b)(x, y, x, y) < INF && elevation[indexOf(x, y)] < 0.88, 11);
if (first.length > 6 && second.length > 6) { path = [...first, ...second.slice(1)]; viaUsed = true; }
}
if (path.length < 12) {
path = aStar(a, b, makeTransportCost(throughExpresswayCost(a, b), existing, roadHubs, [a, b], 5, 9.4, townAvoidNodes, 6.0, 9.0));
path = smoothPathByLineOfSight(path, (x, y) => throughExpresswayCost(a, b)(x, y, x, y) < INF && elevation[indexOf(x, y)] < 0.88, 11);
}
path = snapPathToExistingExpressways(path, [...externalExpressways, ...expressways], 2.8);
if (!viaUsed && (path.length < 12 || pathEndpointDistance(path) < Math.min(MAP_W, MAP_H) * 0.45 || pathCompactness(path) > 3.25)) {
path = aStar(a, b, makeTransportCost(permissiveThroughExpresswayCost(a, b), existing, roadHubs, [a, b], 4, 7.2, townAvoidNodes, 4.0, 6.5));
path = smoothPathByLineOfSight(path, (x, y) => permissiveThroughExpresswayCost(a, b)(x, y, x, y) < INF, 12);
path = snapPathToExistingExpressways(path, [...externalExpressways, ...expressways], 2.8);
}
if (path.length < 12 || pathEndpointDistance(path) < Math.min(MAP_W, MAP_H) * 0.42 || pathCompactness(path) > (viaUsed ? 5.6 : 3.45)) return false;
externalExpressways.push(path);
incrementDegree(expressDegree, a);
incrementDegree(expressDegree, b);
throughExpresswayAdded = true;
return true;
}
addThroughExpressway();
function nearestPathCellDistance(node, paths) {
let best = INF;
@ -1041,24 +1126,13 @@ export function generateMapFeatures(seed, terrain) {
}
return best;
}
const combinedModernBackbone = [...nationalRoads, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways, ...externalRailways, ...expressways];
const connectedRequiredNodeCount = requiredTransportNodes.filter((node) => nearestPathCellDistance(node, combinedModernBackbone) <= 7).length;
const missingRequiredNodes = requiredTransportNodes
.filter((node) => nearestPathCellDistance(node, combinedModernBackbone) > 7)
.map((node) => ({ x: node.x, y: node.y, kind: node.kind, reason: node.requiredTransportReason }));
const transportDebug = {
requiredNodeCount: requiredTransportNodes.length,
connectedRequiredNodeCount,
missingRequiredNodes,
backboneEdgeCount,
};
function pruneHighMountainTransport(paths, threshold = 0.82) {
for (let i = paths.length - 1; i >= 0; i--) {
if (paths[i].some(([x, y]) => elevation[indexOf(x, y)] > threshold)) paths.splice(i, 1);
}
}
for (const paths of [railways, branchRailways, ringRailways, externalRailways, expressways, externalExpressways]) pruneHighMountainTransport(paths, 0.82);
for (const paths of [railways, branchRailways, ringRailways, externalRailways, expressways]) pruneHighMountainTransport(paths, 0.82);
const expressInfluence = influenceFromPaths([...expressways, ...externalExpressways], 6);
const roadInfluence = influenceFromPaths([...nationalRoads, ...ringRoads, ...expressways, ...externalRoads, ...externalExpressways], 4);
@ -1197,19 +1271,51 @@ export function generateMapFeatures(seed, terrain) {
}
for (const village of villages) {
if (rand(seed, village.x * 13 + village.y * 17) < 0.42) {
if (rand(seed, village.x * 13 + village.y * 17) < 0.90) {
const target = pickEntities(trunkNodes.map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - village.x, p.y - village.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0];
if (target && Math.hypot(target.x - village.x, target.y - village.y) < 28) addMinorRoad(village, target);
if (target && Math.hypot(target.x - village.x, target.y - village.y) < 34) addMinorRoad(village, target);
}
}
for (const market of markets) {
const localVillages = pickEntities(villages.map((v) => ({ ...v, score: 1 / (1 + Math.hypot(v.x - market.x, v.y - market.y)) })), { max: 3, minDistance: 1, threshold: 0 });
const localVillages = pickEntities(villages.map((v) => ({ ...v, score: 1 / (1 + Math.hypot(v.x - market.x, v.y - market.y)) })), { max: 4, minDistance: 1, threshold: 0 });
for (const v of localVillages) addMinorRoad(market, v);
}
for (const pass of passes.slice(0, 8)) {
const target = pickEntities([...markets, ...villages].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - pass.x, p.y - pass.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0];
if (target) addMinorRoad(pass, target);
}
for (const port of ports) {
const target = pickEntities([...markets, ...villages, ...stations.slice(0, 18)].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - port.x, p.y - port.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0];
if (target && Math.hypot(target.x - port.x, target.y - port.y) < 24) addMinorRoad(port, target);
}
for (const localCenter of [...satelliteCities, ...newTowns]) {
const target = pickEntities([...stations, ...markets, ...modernCities].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - localCenter.x, p.y - localCenter.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0];
if (target && Math.hypot(target.x - localCenter.x, target.y - localCenter.y) < 30) addMinorRoad(localCenter, target);
}
for (const station of stations.slice(0, 28)) {
const locals = pickEntities([...villages, ...markets, ...ports].map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - station.x, p.y - station.y)) })), { max: 2, minDistance: 1, threshold: 0 });
for (const local of locals) if (Math.hypot(local.x - station.x, local.y - station.y) < 22) addMinorRoad(station, local);
}
for (const village of villages.slice(0, 42)) {
const neighbor = pickEntities(villages.filter((v) => v !== village).map((v) => ({ ...v, score: 1 / (1 + Math.hypot(v.x - village.x, v.y - village.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0];
if (neighbor && Math.hypot(neighbor.x - village.x, neighbor.y - village.y) < 14) addMinorRoad(village, neighbor);
}
const combinedModernTransport = [...nationalRoads, ...externalRoads, ...externalExpressways, ...railways, ...branchRailways, ...externalRailways, ...expressways];
const requiredTransportNodes = [capital, ...externalGateways, ...modernCities.filter((city) => (city.population || 0) >= 120000 || city.isPrefecturalCapital)];
const connectedRequiredNodeCount = requiredTransportNodes.filter((node) => nearestPathCellDistance(node, combinedModernTransport) <= 7).length;
const allExpresswayPaths = [...expressways, ...externalExpressways];
const expresswayCells = allExpresswayPaths.flat();
const expresswayAverageDensity = expresswayCells.length ? expresswayCells.reduce((sum, [x, y]) => sum + densityValue(x, y), 0) / expresswayCells.length : 0;
const transportDebug = {
requiredNodeCount: requiredTransportNodes.length,
connectedRequiredNodeCount,
throughExpresswayAdded,
expresswayAverageDensity: Number(expresswayAverageDensity.toFixed(3)),
expresswayPathCount: allExpresswayPaths.length,
minorRoadCount: minorRoads.length,
minorRoadTotalLength: Math.round(minorRoads.reduce((sum, path) => sum + pathLength(path), 0)),
};
const newTownInfluence = influenceFromPoints(newTowns, 8, () => 1);
const landuse = new Uint8Array(SIZE);

View file

@ -881,6 +881,7 @@ export function attachIdsAndNames(points, prefix, seed, kindOverride = null, nam
...p,
id,
name,
kind,
insidePrefecture: Boolean(p.insidePrefecture),
};
});

View file

@ -173,48 +173,6 @@ export function finishMapOutput({
center.name = best.name;
}
}
const adminNameSuffixes = [
"\u753A\u57DF",
"\u5E02\u57DF",
"\u90F7\u57DF",
"\u6D41\u57DF",
"\u6E7E\u5CB8",
"\u5C71\u9E93",
"\u5E73\u91CE",
"\u5730\u533A",
];
const adminNameCounts = new Map();
for (const center of adminCenters) adminNameCounts.set(center.name, (adminNameCounts.get(center.name) || 0) + 1);
const baseVariantCounts = new Map();
for (const center of adminCenters) {
const base = String(center.name || "");
const count = adminNameCounts.get(base) || 0;
if (count <= 1) {
baseVariantCounts.set(base, Math.max(baseVariantCounts.get(base) || 0, 1));
continue;
}
const usedForBase = baseVariantCounts.get(base) || 0;
if (usedForBase === 0) {
baseVariantCounts.set(base, 1);
continue;
}
if (usedForBase < 2) {
const i = indexOf(center.x, center.y);
const naturalSuffix = coastalLowland[i] > 0.28
? "\u6E7E\u5CB8"
: basinField[i] > 0.28
? "\u5E73\u91CE"
: ridgeField[i] > 0.42 || slope[i] > 0.34
? "\u5C71\u9E93"
: river[i] > 0.25 || flowAccum[i] > 0.38
? "\u6D41\u57DF"
: adminNameSuffixes[(Math.floor(center.x / 12) + Math.floor(center.y / 12)) % adminNameSuffixes.length];
center.name = `${base}${naturalSuffix}`;
center.derivedFromBaseName = base;
nameDebug.derivedNameCount++;
baseVariantCounts.set(base, usedForBase + 1);
}
}
const usedAdminNames = new Set();
for (const center of adminCenters) {
let candidate = center.name;
@ -225,7 +183,7 @@ export function finishMapOutput({
center.name = candidate;
usedAdminNames.add(center.name);
}
nameDebug.maxDerivedPerBase = Math.max(0, ...baseVariantCounts.values());
nameDebug.maxDerivedPerBase = 0;
const entitiesForNames = [
...modernCities,

View file

@ -22,6 +22,7 @@ export const NAME_KANJI_POOLS = {
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦",
"聡", "郷", "里",
"馬", "鹿", "亀", "鷲", "鷹",
"湯",
],
waterTerrain: [
@ -29,11 +30,11 @@ export const NAME_KANJI_POOLS = {
"池", "沼", "泉", "井",
"滝", "梅", "沢", "澤", "谷", "津",
"水", "清", "渡", "橋", "堀",
"溝", "湯", "浦", "洲"
"溝", "浦", "洲"
],
coastalTerrain: [
"津", "浦", "ヶ浦", "津", "崎",
"津", "浦", "津", "崎",
"島", "磯", "潟", "湊", "津",
"州", "洲", "瀬", "砂", "潮", "塩", "汐",
"泊", "江", "浦", "灘", "入",

View file

@ -366,6 +366,7 @@ export function drawMap(canvas, map, options) {
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
@ -376,6 +377,7 @@ export function drawMap(canvas, map, options) {
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) drawSegments(ctx, map.regionalPrefectureBorders, "rgba(70, 55, 95, 0.95)", 2.4, false);
}
@ -384,47 +386,48 @@ export function drawMap(canvas, map, options) {
if (!showFeatures) return;
// 4. Casings (Outlines)
// 4. Transport casings. Layer order: local roads, trunk roads, railways, expressways.
if (showHistory) {
// 古い道は白の実線が引き立つように淡いケーシングを敷く
for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(210, 210, 210, 0.7)", 2.5);
}
if (showMinorRoads) {
for (const path of map.minorRoads || []) drawPath(ctx, path, "rgba(205, 205, 205, 0.60)", 2.35);
}
if (showRoads) {
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.75)", 3.5);
for (const path of map.branchRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.6)", 2.5);
for (const path of map.externalRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.75)", 3.5);
// 幹線道路のケーシング(色を濃く)
if (showRoads) {
for (const path of map.expressways) drawPath(ctx, path, "rgba(80, 140, 100, 1)", 5.0);
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(80, 140, 100, 1)", 5.0);
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);
}
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. Fills (Inner colors) & Fishbones
// 5. Transport fills. Expressways remain above railways; railways sit above ordinary roads.
if (showHistory) {
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(110, 110, 110, 1)", 1.5, 5.0, 7.0);
for (const path of map.branchRailways) drawRailway(ctx, path, "rgba(140, 140, 140, 1)", 1.0, 4.0, 6.0);
for (const path of map.externalRailways) drawRailway(ctx, path, "rgba(110, 110, 110, 1)", 1.5, 5.0, 7.0);
// 幹線道路の塗り(色を濃く)
if (showRoads) {
for (const path of map.expressways) drawPath(ctx, path, "rgba(110, 185, 130, 1)", 3.0);
for (const path of map.externalExpressways) drawPath(ctx, path, "rgba(110, 185, 130, 1)", 3.0);
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);
}
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
@ -435,6 +438,7 @@ export function drawMap(canvas, map, options) {
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)");

50
test.js
View file

@ -15,9 +15,11 @@ 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()),
]);
@ -594,10 +596,6 @@ try {
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.transportDebug && map.transportDebug.requiredNodeCount > 0, "transport required-node debug exists");
assert(transportMetrics.requiredCount > 0 && transportMetrics.reachableCount === transportMetrics.requiredCount, "required transport nodes touch the modern network");
assert(transportMetrics.largestRequiredComponent === transportMetrics.requiredCount, "required transport nodes are in one connected modern component");
assert(transportMetrics.isolatedExternalGateways === 0 && transportMetrics.isolatedMajorCities === 0, "external gateways and major cities are not isolated");
assert(map.minorRoads.length > 0, "minor roads exist");
assert(map.adminCenters.length >= 18, "municipality center count is sufficiently large");
assert(adminMetrics.municipalityCount >= 10, "terrain snapping preserves a reasonable municipality count");
@ -641,7 +639,8 @@ try {
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.filter((item) => item.representativeFeatureName && String(item.name).includes(item.representativeFeatureName)).length >= Math.max(1, Math.floor(map.adminCenters.length * 0.70)), "municipal center names relate to representative feature names");
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;
@ -649,6 +648,10 @@ try {
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");
@ -657,7 +660,7 @@ try {
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.oneKanjiAppendFallbackUsed === 0, "one-kanji append fallback is never used");
assert((map.nameDebug.derivedNameCount || 0) <= Math.max(6, Math.ceil(map.adminCenters.length * 0.18)), "derived names do not dominate municipality names");
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");
@ -713,15 +716,38 @@ try {
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.transportDebug?.requiredNodeCount === seededTransport.requiredCount, `seed ${seedValue}: required transport node count is exposed`);
assert(seededTransport.reachableCount === seededTransport.requiredCount && seededTransport.largestRequiredComponent === seededTransport.requiredCount, `seed ${seedValue}: required transport nodes are connected`);
assert(seededTransport.isolatedExternalGateways === 0 && seededTransport.isolatedMajorCities === 0, `seed ${seedValue}: no gateway or major city is isolated`);
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) <= Math.max(6, Math.ceil(seeded.adminCenters.length * 0.18)), `seed ${seedValue}: derived names are bounded`);
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 }))
@ -768,6 +794,8 @@ try {
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`);