map/mapAdminStage.js
2026-05-24 19:33:09 +09:00

1218 lines
60 KiB
JavaScript

import {
applyLandscapeUnitAdminPartition,
assignAdminRegionsFromNaturalCompartments,
lockSmallUrbanComponentsToMunicipality,
mergeTinyMunicipalities,
removeMunicipalExclaves,
smoothAdminRegionsTerrainAware,
splitOversizedLowlandMunicipalities,
snapAdminBoundariesToTerrain,
} from "./adminRegions.js";
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, xyOf } from "./mapUtils.js";
import { extractAdminBorderSegments } from "./mapGeneratorHelpers.js";
const OUTER_ANCHOR_REGION_ID = -2;
function adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId) {
if (prefectureMask[i]) return 0;
const regionalId = prefectureRegionId?.[i] ?? -1;
if (regionalId === 0) return OUTER_ANCHOR_REGION_ID;
return regionalId;
}
function changedCellsSince(before, after, prefectureMask, sea) {
let changed = 0;
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== after[i]) changed++;
return changed;
}
function municipalityAreaById(adminId, prefectureMask, sea) {
const area = new Map();
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
area.set(adminId[i], (area.get(adminId[i]) || 0) + 1);
}
return area;
}
function isProtectedAdminSeed(seed) {
if (!seed) return false;
if (seed.seedKind === "capital" || seed.protectedCity?.isPrefecturalCapital) return true;
if (seed.seedKind === "modernCity" && (seed.protectedCity?.population || seed.population || 0) >= 180000) return true;
if (seed.seedKind === "port" && seed.portClass === "major") return true;
if (seed.seedKind === "satelliteCity" && (seed.protectedSatellite?.population || seed.population || 0) >= 60000) return true;
return false;
}
function buildSeedLifecycle(adminCenters, adminId, prefectureMask, sea, minArea = 80) {
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
const lifecycle = adminCenters.map((center, id) => {
const protectedSeed = isProtectedAdminSeed(center);
const area = areaById.get(id) || 0;
const enoughArea = area >= (protectedSeed ? 28 : minArea);
return {
id,
protected: protectedSeed,
area,
state: enoughArea || protectedSeed ? "survived" : "pending",
};
});
return lifecycle;
}
function activeSeedIds(seedLifecycle) {
return new Set(seedLifecycle.filter((seed) => seed.state === "survived" || seed.protected).map((seed) => seed.id));
}
function dominantCompartmentOwners(compartments, adminId) {
const owner = new Int16Array(compartments.length);
owner.fill(-1);
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const counts = new Map();
for (const i of unit.cells) {
const id = adminId[i];
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
}
let bestId = -1, best = -1;
for (const [id, count] of counts) if (count > best) { best = count; bestId = id; }
owner[unit.id] = bestId;
}
return owner;
}
function applyCompartmentOwners(adminId, compartments, owner) {
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const id = owner[unit.id];
if (id < 0) continue;
for (const i of unit.cells) adminId[i] = id;
}
}
function absorbSeedCompartments(adminId, compartments, seedLifecycle) {
const owner = dominantCompartmentOwners(compartments, adminId);
const absorbed = new Set(seedLifecycle.filter((seed) => seed.state === "absorbed").map((seed) => seed.id));
let changed = 0;
for (const unit of compartments) {
if (!unit || unit.area === 0 || !absorbed.has(owner[unit.id])) continue;
let bestId = -1, bestScore = -INF;
for (const [neighborId, edge] of unit.adjacent) {
const candidate = owner[neighborId];
if (candidate < 0 || absorbed.has(candidate)) continue;
const neighbor = compartments[neighborId];
const score = edge.count * 2 - (edge.target / Math.max(1, edge.count)) * 2 + (neighbor?.area || 0) * 0.002;
if (score > bestScore) { bestScore = score; bestId = candidate; }
}
if (bestId < 0) continue;
owner[unit.id] = bestId;
changed += unit.area;
}
applyCompartmentOwners(adminId, compartments, owner);
return changed;
}
function splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, settlements = []) {
const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields;
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
const areas = [...areaById.values()].sort((a, b) => a - b);
const median = areas.length ? areas[Math.floor(areas.length / 2)] : 0;
if (!median) return { changedCells: 0, splitMunicipalities: 0, pendingSeedsUsed: 0 };
const owner = dominantCompartmentOwners(compartments, adminId);
const unitsByOwner = new Map();
for (const unit of compartments) {
if (!unit || unit.area === 0 || owner[unit.id] < 0) continue;
if (!unitsByOwner.has(owner[unit.id])) unitsByOwner.set(owner[unit.id], []);
unitsByOwner.get(owner[unit.id]).push(unit);
}
const pending = seedLifecycle.filter((seed) => seed.state === "pending" && !seed.protected);
let changedCells = 0;
let splitMunicipalities = 0;
let pendingSeedsUsed = 0;
for (const [id, units] of unitsByOwner) {
const area = areaById.get(id) || 0;
if (area < Math.max(260, median * 1.45) || units.length < 6) continue;
let lowland = 0, rough = 0;
for (const unit of units) {
for (const i of unit.cells) {
lowland += (plain[i] || 0) * 0.38 + (agriculture[i] || 0) * 0.20 + basinField[i] * 0.22 + coastalLowland[i] * 0.22 + valleyField[i] * 0.10;
rough += ridgeField[i] * 0.48 + slope[i] * 0.34 + Math.max(0, elevation[i] - 0.58) * 0.30;
}
}
if (lowland / area < 0.26 || rough / area > 0.48) continue;
const localPending = pending.filter((seed) => {
const center = adminCenters[seed.id];
if (!center || !inside(center.x, center.y)) return false;
const centerOwner = adminId[indexOf(center.x, center.y)];
return centerOwner === id || Math.hypot(center.x - (adminCenters[id]?.x || center.x), center.y - (adminCenters[id]?.y || center.y)) < 28;
});
const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id);
if (localPending.length === 0 || localPending.length + localSettlements.length < 2) continue;
let municipalitySplit = false;
for (const seed of localPending.slice(0, 3)) {
const center = adminCenters[seed.id];
if (!center) continue;
const targetArea = clamp(95 + (center.score || 0.5) * 60, 90, 180);
let claimed = 0;
const candidates = units
.filter((unit) => owner[unit.id] === id && unit.classId !== 8 && unit.classId !== 9)
.map((unit) => ({
unit,
score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 8 - (unit.urbanWeight || 0) * 3,
}))
.sort((a, b) => a.score - b.score);
if (candidates.length < 2) continue;
for (const { unit } of candidates) {
if (claimed >= targetArea && claimed >= 2) break;
owner[unit.id] = seed.id;
claimed += unit.area;
changedCells += unit.area;
}
if (claimed >= 45) {
seed.state = "survived";
seed.area = claimed;
pendingSeedsUsed++;
municipalitySplit = true;
}
}
if (municipalitySplit) splitMunicipalities++;
}
applyCompartmentOwners(adminId, compartments, owner);
return { changedCells, splitMunicipalities, pendingSeedsUsed };
}
function promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, targetMinCount = 20) {
const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields;
let areaById = municipalityAreaById(adminId, prefectureMask, sea);
let currentCount = areaById.size;
if (currentCount >= targetMinCount) return { changedCells: 0, promotedSeeds: 0 };
const owner = dominantCompartmentOwners(compartments, adminId);
let changedCells = 0;
let promotedSeeds = 0;
const pending = seedLifecycle
.filter((seed) => seed.state === "pending" && !seed.protected)
.sort((a, b) => (adminCenters[b.id]?.score || 0) - (adminCenters[a.id]?.score || 0));
for (const seed of pending) {
if (currentCount >= targetMinCount) break;
const center = adminCenters[seed.id];
if (!center || !inside(center.x, center.y)) continue;
const existingArea = areaById.get(seed.id) || 0;
if (existingArea >= 12) {
seed.state = "survived";
seed.area = existingArea;
promotedSeeds++;
continue;
}
const candidates = compartments
.filter((unit) => {
if (!unit || unit.area === 0) return false;
const currentOwner = owner[unit.id];
if (currentOwner < 0 || currentOwner === seed.id) return false;
const ownerArea = areaById.get(currentOwner) || 0;
if (ownerArea < 90) return false;
const lowlandFit = (unit.lowlandFitness || 0) + (unit.basinIdentity || 0) * 0.15 + (unit.coastalExposure || 0) * 0.15;
if (lowlandFit < 0.26) return false;
return Math.hypot(unit.x - center.x, unit.y - center.y) < 36;
})
.map((unit) => ({
unit,
score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2,
}))
.sort((a, b) => a.score - b.score);
if (candidates.length === 0) continue;
let claimed = 0;
for (const { unit } of candidates) {
const currentOwner = owner[unit.id];
if ((areaById.get(currentOwner) || 0) - unit.area < 70) continue;
owner[unit.id] = seed.id;
areaById.set(currentOwner, (areaById.get(currentOwner) || 0) - unit.area);
areaById.set(seed.id, (areaById.get(seed.id) || 0) + unit.area);
claimed += unit.area;
changedCells += unit.area;
if (claimed >= 55) break;
}
if (claimed >= 25) {
seed.state = "survived";
seed.area = areaById.get(seed.id) || claimed;
promotedSeeds++;
currentCount++;
}
}
applyCompartmentOwners(adminId, compartments, owner);
return { changedCells, promotedSeeds };
}
function restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compartments, adminCenters, seedLifecycle, targetMinCount = 20) {
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
let currentCount = areaById.size;
if (currentCount >= targetMinCount) return { changedCells: 0, restoredSeeds: 0 };
const owner = dominantCompartmentOwners(compartments, adminId);
let changedCells = 0;
let restoredSeeds = 0;
const missing = seedLifecycle
.filter((seed) => (seed.state === "survived" || seed.protected) && (areaById.get(seed.id) || 0) === 0)
.sort((a, b) => (b.protected ? 1 : 0) - (a.protected ? 1 : 0));
for (const seed of missing) {
if (currentCount >= targetMinCount) break;
const center = adminCenters[seed.id];
if (!center || !inside(center.x, center.y)) continue;
const candidates = compartments
.filter((unit) => {
if (!unit || unit.area === 0 || unit.classId === 8 || unit.classId === 9) return false;
const currentOwner = owner[unit.id];
if (currentOwner < 0 || currentOwner === seed.id) return false;
if ((areaById.get(currentOwner) || 0) - unit.area < 25) return false;
return Math.hypot(unit.x - center.x, unit.y - center.y) < 90 && ((unit.lowlandFitness || 0) > 0.08 || seed.protected);
})
.map((unit) => ({
unit,
score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2 + (unit.classId === 8 || unit.classId === 9 ? 20 : 0),
}))
.sort((a, b) => a.score - b.score);
if (candidates.length === 0) continue;
const unit = candidates[0].unit;
const oldOwner = owner[unit.id];
if ((areaById.get(oldOwner) || 0) - unit.area < 25) continue;
owner[unit.id] = seed.id;
const claimed = unit.area;
areaById.set(oldOwner, (areaById.get(oldOwner) || 0) - claimed);
changedCells += claimed;
areaById.set(seed.id, claimed);
seed.area = claimed;
restoredSeeds++;
currentCount++;
}
applyCompartmentOwners(adminId, compartments, owner);
return { changedCells, restoredSeeds };
}
function municipalityCountBoundsForRegion(landCells, meta = {}) {
const focused = meta.isFocusedRegion !== false;
if (focused) return { min: 20, max: 50 };
// Neighbor prefectures are often visible only as clipped map-edge slivers.
// Avoid giving every tiny visible fragment the full 20-municipality floor.
let min = 1;
if (landCells >= 500) min = 2;
if (landCells >= 950) min = 3;
if (landCells >= 1700) min = 5;
if (landCells >= 2800) min = 7;
if (landCells >= 4300) min = 10;
const max = clamp(Math.round(landCells / 260 + 2), Math.max(min, 2), 34);
return { min, max };
}
function computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField, coastalLowland, basinField, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta = {} }) {
let landCells = 0;
let habitableCells = 0;
let lowlandCells = 0;
let coastlineComplexity = 0;
let mountainCells = 0;
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i]) continue;
landCells++;
if (slope[i] < 0.42 && ridgeField[i] < 0.55 && (!elevation || elevation[i] < 0.72)) habitableCells++;
if ((coastalLowland[i] > 0.20 || basinField[i] > 0.24) && slope[i] < 0.36 && ridgeField[i] < 0.52) lowlandCells++;
if (ridgeField[i] > 0.52 || slope[i] > 0.48) mountainCells++;
for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) {
const ni = indexOf(nx, ny);
if (sea[ni]) {
coastlineComplexity += 1 + coastalLowland[i] * 0.8;
break;
}
}
}
}
const independentSatellites = (satelliteCities || []).filter((p) => p.municipalityClass === "independentSatelliteMunicipality").length;
const settlementWeight = modernCities.length * 1.6 + markets.length * 1.0 + ports.length * 0.8 + independentSatellites * 0.7 + villages.length * 0.25;
const basinBonus = Math.min(8, [...basinField].filter((v, i) => prefectureMask[i] && !sea[i] && v > 0.34).length / 520);
const mountainRatio = landCells ? mountainCells / landCells : 0;
const lowlandBonus = Math.min(7, lowlandCells / 430);
const rawTarget = Math.round(habitableCells / 230 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.55 + lowlandBonus - mountainRatio * 2.2);
const { min, max } = municipalityCountBoundsForRegion(landCells, adminRegionMeta);
return clamp(rawTarget, min, max);
}
function lowlandAdminSeedScore(i, { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse }) {
const lowRelief = clamp((0.70 - elevation[i]) * 1.35) + clamp((0.34 - slope[i]) * 1.55) + clamp((0.48 - ridgeField[i]) * 1.10);
const landuseFit = [1, 2, 3, 4, 7, 8].includes(landuse[i]) ? 0.40 : landuse[i] === 5 || landuse[i] === 6 ? 0.12 : 0;
return clamp(
lowRelief * 0.25 +
plain[i] * 0.28 +
basinField[i] * 0.24 +
coastalLowland[i] * 0.24 +
settlementScore[i] * 0.30 +
populationDensity[i] * 0.32 +
roadInfluence[i] * 0.16 +
railInfluence2[i] * 0.16 +
(stationInfluence?.[i] || 0) * 0.18 +
landuseFit -
Math.max(0, elevation[i] - 0.62) * 1.2 -
Math.max(0, ridgeField[i] - 0.54) * 0.9
);
}
function buildLowlandAdminSeeds({
seed,
targetMunicipalityCount,
prefectureMask,
sea,
elevation,
slope,
ridgeField,
plain,
basinField,
coastalLowland,
settlementScore,
populationDensity,
roadInfluence,
railInfluence2,
stationInfluence,
landuse,
modernCities,
satelliteCities,
markets,
ports,
newTowns,
stations,
}) {
const fields = { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse };
function validLowlandPoint(p, strict = true) {
if (!p || !inside(p.x, p.y)) return false;
const i = indexOf(p.x, p.y);
if (!prefectureMask[i] || sea[i]) return false;
const score = lowlandAdminSeedScore(i, fields);
const mountain = elevation[i] > 0.70 || slope[i] > 0.52 || ridgeField[i] > 0.62;
return score >= (strict ? 0.34 : 0.24) && (!mountain || p.isPrefecturalCapital || (p.population || 0) >= 220000 || p.portClass === "major");
}
const realSeeds = [];
for (const city of modernCities || []) {
if (!validLowlandPoint(city, !city.isPrefecturalCapital)) continue;
if (!city.isPrefecturalCapital && (city.population || 0) < 85000) continue;
const i = indexOf(city.x, city.y);
realSeeds.push({ x: city.x, y: city.y, score: 1.35 + (city.population || 0) / 650000 + lowlandAdminSeedScore(i, fields), population: city.population || 0, protectedCity: city, seedKind: city.isPrefecturalCapital ? "capital" : "modernCity" });
}
for (const city of satelliteCities || []) {
if (city.municipalityClass !== "independentSatelliteMunicipality" || !validLowlandPoint(city, false)) continue;
const i = indexOf(city.x, city.y);
realSeeds.push({ x: city.x, y: city.y, score: 0.92 + (city.population || 0) / 260000 + lowlandAdminSeedScore(i, fields), population: city.population || 0, protectedSatellite: city, seedKind: "satelliteCity" });
}
for (const p of [...(markets || []), ...(ports || []), ...(newTowns || [])]) {
if (!validLowlandPoint(p, true)) continue;
const i = indexOf(p.x, p.y);
const portBonus = p.portClass === "major" ? 0.45 : p.portClass === "regional" ? 0.26 : 0;
realSeeds.push({ x: p.x, y: p.y, score: 0.74 + lowlandAdminSeedScore(i, fields) + portBonus + (p.population || 0) / 420000, population: p.population || 0, portClass: p.portClass, source: p, seedKind: p.portClass ? "port" : "marketTown" });
}
const picked = pickEntities(realSeeds, {
max: targetMunicipalityCount,
minDistance: 5 + Math.floor(rand(seed, 1302) * 3),
threshold: 0.62,
seed: seed + 1300,
jitter: 0.025,
});
const invisibleCandidates = [];
for (let y = 2; y < MAP_H - 2; y++) {
for (let x = 2; x < MAP_W - 2; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i]) continue;
const score = lowlandAdminSeedScore(i, fields) + hash2(x, y, seed + 1311) * 0.045;
if (score < 0.48) continue;
const insideDenseCore = modernCities.some((city) => (city.population || 0) >= 180000 && Math.hypot(city.x - x, city.y - y) < Math.max(5, (city.coreRadius || 4) * 1.7));
if (insideDenseCore) continue;
invisibleCandidates.push({ x, y, score, invisibleLowlandAdminSeed: true, seedKind: "invisibleLowland" });
}
}
if (picked.length < targetMunicipalityCount) {
const extra = pickEntities(invisibleCandidates, {
max: targetMunicipalityCount - picked.length,
minDistance: 5,
threshold: 0.48,
seed: seed + 1304,
jitter: 0.02,
});
for (const p of extra) if (picked.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 4)) picked.push(p);
}
if (picked.length < Math.min(targetMunicipalityCount, 20)) {
const relaxed = pickEntities(invisibleCandidates, {
max: Math.min(targetMunicipalityCount, 20) - picked.length,
minDistance: 4,
threshold: 0.38,
seed: seed + 1305,
jitter: 0.02,
});
for (const p of relaxed) if (picked.length < targetMunicipalityCount && picked.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 3.5)) picked.push(p);
}
return picked.slice(0, targetMunicipalityCount);
}
function estimateUrbanComponentArea(city, prefectureMask, sea, landuse, populationDensity) {
if (!city || !inside(city.x, city.y)) return 0;
const start = indexOf(city.x, city.y);
if (!prefectureMask[start] || sea[start]) return 0;
const radius = Math.ceil(Math.max(7, (city.urbanRadius || 6) * 1.7));
const seen = new Uint8Array(SIZE);
const queue = [start];
seen[start] = 1;
let area = 0;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const [x, y] = xyOf(cur);
const d = Math.hypot(x - city.x, y - city.y);
if (d > radius) continue;
const urban = (landuse[cur] >= 2 && landuse[cur] <= 4) || landuse[cur] === 7 || landuse[cur] === 8 || populationDensity[cur] > 0.18;
if (!urban) continue;
area++;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
return area;
}
function terrainSeparationBetween(a, b, ridgeField, river, flowAccum, populationDensity, landuse) {
if (!a || !b) return { separatedByBarrier: false, ruralGap: false, averageDensity: 0, maxBarrier: 0 };
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
let maxBarrier = 0;
let lowUrbanRun = 0;
let bestLowUrbanRun = 0;
let densitySum = 0;
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(a.x + (b.x - a.x) * t);
const y = Math.round(a.y + (b.y - a.y) * t);
if (!inside(x, y)) continue;
const i = indexOf(x, y);
const barrier = Math.max(ridgeField[i] * 0.95, river[i] * 0.85, flowAccum[i] * 0.42);
maxBarrier = Math.max(maxBarrier, barrier);
densitySum += populationDensity[i];
const urban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.20;
if (urban) lowUrbanRun = 0;
else {
lowUrbanRun++;
bestLowUrbanRun = Math.max(bestLowUrbanRun, lowUrbanRun);
}
}
return {
separatedByBarrier: maxBarrier > 0.56,
ruralGap: bestLowUrbanRun >= 4,
averageDensity: densitySum / (steps + 1),
maxBarrier,
};
}
function classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, ridgeField, river, flowAccum) {
let independent = 0;
let attached = 0;
for (const sat of satelliteCities || []) {
if (!sat || !inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)] || sea[indexOf(sat.x, sat.y)]) continue;
const parent = modernCities[sat.parentCityIndex] || modernCities.slice().sort((a, b) => Math.hypot(a.x - sat.x, a.y - sat.y) - Math.hypot(b.x - sat.x, b.y - sat.y))[0];
const parentDistance = parent ? Math.hypot(parent.x - sat.x, parent.y - sat.y) : 99;
const separation = terrainSeparationBetween(sat, parent, ridgeField, river, flowAccum, populationDensity, landuse);
const urbanArea = estimateUrbanComponentArea(sat, prefectureMask, sea, landuse, populationDensity);
const i = indexOf(sat.x, sat.y);
const continuousUrban = parent && parentDistance < Math.max(10, (parent.urbanRadius || 12) + (sat.urbanRadius || 5) + 5) && separation.averageDensity > 0.14 && !separation.ruralGap && !separation.separatedByBarrier;
const newTownLike = landuse[i] === 7 || (railInfluence2[i] > 0.22 && roadInfluence[i] > 0.12 && (sat.population || 0) < 70000);
let municipalityClass = "independentSatelliteMunicipality";
if (continuousUrban && (sat.population || 0) < 90000) municipalityClass = "suburbanDistrictMergedWithParent";
else if (newTownLike && (sat.population || 0) < 85000 && !separation.separatedByBarrier) municipalityClass = "newTownDistrict";
else if ((sat.population || 0) < 42000 && urbanArea < 55 && !separation.separatedByBarrier) municipalityClass = "smallTownAttachedToRuralMunicipality";
else if ((sat.population || 0) >= 60000 && urbanArea >= 42 && (separation.separatedByBarrier || separation.ruralGap || parentDistance > 15)) municipalityClass = "independentSatelliteMunicipality";
sat.municipalityClass = municipalityClass;
sat.parentX = parent?.x;
sat.parentY = parent?.y;
sat.parentAdminHint = -1;
sat.distinctUrbanComponentArea = urbanArea;
sat.separatedByBarrier = separation.separatedByBarrier || separation.ruralGap;
sat.satelliteMinArea = clamp(90 + Math.sqrt(sat.population || 24000) * 0.62 + (sat.urbanRadius || 5) * 12, 80, 360);
if (municipalityClass === "independentSatelliteMunicipality") independent++;
else attached++;
}
return { independent, attached };
}
function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, context) {
const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities } = context;
if (!satellite || targetAdmin < 0 || !inside(satellite.x, satellite.y)) return 0;
const start = indexOf(satellite.x, satellite.y);
if (!prefectureMask[start] || sea[start]) return 0;
const targetAreaBase = clamp(90 + Math.sqrt(satellite.population || 24000) * 0.8 + (satellite.urbanRadius || 5) * 18, 120, 520);
const targetArea = satellite.municipalityClass === "smallTownAttachedToRuralMunicipality"
? Math.min(130, targetAreaBase * 0.55)
: satellite.municipalityClass === "suburbanDistrictMergedWithParent" || satellite.municipalityClass === "newTownDistrict"
? Math.min(190, targetAreaBase * 0.62)
: targetAreaBase;
const maxCost = satellite.municipalityClass === "independentSatelliteMunicipality" ? 46 : 32;
const heap = new MinHeap();
const best = new Float32Array(SIZE);
best.fill(INF);
heap.push({ i: start, f: 0 });
best[start] = 0;
const claimed = [];
while (heap.length > 0 && claimed.length < targetArea) {
const cur = heap.pop();
if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue;
const [x, y] = xyOf(cur.i);
const d = Math.hypot(x - satellite.x, y - satellite.y);
if (!prefectureMask[cur.i] || sea[cur.i]) continue;
let invadesOtherCore = false;
for (const city of modernCities || []) {
if (!city || (city.population || 0) < 140000) continue;
if (Math.hypot(city.x - satellite.x, city.y - satellite.y) < 4) continue;
if (Math.hypot(city.x - x, city.y - y) <= Math.max(3.5, (city.coreRadius || 4) * 1.25)) {
invadesOtherCore = true;
break;
}
}
if (invadesOtherCore) continue;
const compatible = d <= (satellite.urbanRadius || 5) * 1.25 ||
[2, 3, 4, 7, 8].includes(landuse[cur.i]) ||
populationDensity[cur.i] > 0.12 ||
roadInfluence[cur.i] > 0.12 ||
railInfluence2[cur.i] > 0.10 ||
stationInfluence?.[cur.i] > 0.10 ||
basinField[cur.i] > 0.22 ||
valleyField[cur.i] > 0.24 ||
coastalLowland[cur.i] > 0.20;
if (!compatible && claimed.length > targetArea * 0.55) continue;
claimed.push(cur.i);
for (const [dx, dy, step] of [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [-1, 1, 1.41], [1, -1, 1.41], [-1, -1, 1.41]]) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) continue;
const barrier = ridgeField[ni] * 5.2 + Math.max(0, elevation[ni] - 0.58) * 4.0 + slope[ni] * 2.2 + (river[ni] > 0.55 || flowAccum[ni] > 0.70 ? 7.5 : river[ni] > 0.30 ? 2.8 : 0);
const living = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.2 : 0) + populationDensity[ni] * 2.0 + roadInfluence[ni] * 0.85 + railInfluence2[ni] * 0.95 + (stationInfluence?.[ni] || 0) * 1.2 + basinField[ni] * 0.42 + valleyField[ni] * 0.48 + coastalLowland[ni] * 0.32;
const distanceCost = Math.hypot(nx - satellite.x, ny - satellite.y) / Math.max(7, (satellite.urbanRadius || 5) * 1.9);
const nd = cur.f + Math.max(0.28, 1.05 + barrier - living + distanceCost) * step;
if (nd < best[ni]) {
best[ni] = nd;
heap.push({ i: ni, f: nd });
}
}
}
let changed = 0;
for (const i of claimed) {
if (adminId[i] !== targetAdmin) changed++;
adminId[i] = targetAdmin;
}
return changed;
}
function generateAdminLayoutForMask({
seed,
prefectureMask,
sea,
elevation,
slope,
river,
ridgeField,
naturalBarrierScore,
valleyField,
basinField,
coastalLowland,
flowAccum,
plain,
agriculture,
settlementScore,
populationDensity,
stationInfluence,
roadInfluence,
railInfluence2,
villageInfluence,
landuse,
modernCities,
satelliteCities,
newTowns,
markets,
villages,
ports,
stations,
industrialZones,
logisticsParks,
adminRegionMeta = {},
adminProgress = null,
}) {
const boundaryRidgeField = naturalBarrierScore
? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46))
: ridgeField;
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "classify satellites" });
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, adminRegionMeta });
const compartmentMultiplier = clamp(4.6 + rand(seed, 1320) * 2.4, 4.6, 7.0);
const regionLandArea = adminRegionMeta.landArea || maskLandArea(prefectureMask, sea);
const minCompartmentTarget = adminRegionMeta.isFocusedRegion === false
? clamp(Math.round(Math.max(targetMunicipalityCount * 3.2, regionLandArea / 75)), 18, 90)
: 120;
const maxCompartmentTarget = adminRegionMeta.isFocusedRegion === false
? clamp(Math.round(Math.max(targetMunicipalityCount * 5.8, regionLandArea / 38)), minCompartmentTarget, 220)
: 360;
let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), minCompartmentTarget, maxCompartmentTarget);
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, minCompartmentTarget);
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "natural compartments", targetMunicipalityCount, targetCompartmentCount, seedCount: adminCentersRaw.length });
const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, {
seed,
targetMunicipalityCount,
targetCompartmentCount,
maxNaturalCompartmentArea: Math.max(32, Math.round(maskLandArea(prefectureMask, sea) / Math.max(1, targetCompartmentCount) * 1.65)),
progress: (step) => adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step }),
});
const adminId = compartmentAssignment.adminId;
let previousSnapshot = new Int16Array(adminId);
const adminDebug = {
changedAfterSmooth: 0,
changedAfterUrbanLock: 0,
changedAfterSmallUrbanLock: 0,
changedAfterInitialMerge: 0,
changedAfterInitialExclaveRemoval: 0,
changedAfterLandscapePartition: 0,
changedAfterSnap: 0,
changedAfterOversizedRuralSplit: 0,
changedAfterFinalExclaveRemoval: 0,
changedAfterFinalMerge: 0,
targetMunicipalityCount,
actualMunicipalityCount: 0,
municipalityCountReason: "habitable cells, settlement weight, coastline complexity, basin/lowland bonus, and mountain-ratio adjustment",
changedAfterCompartmentAssignment: compartmentAssignment.debug?.naturalCompartmentCount || 0,
oversizedRuralSplits: 0,
oversizedLowlandSplits: 0,
ruralSplitsAccepted: 0,
ruralSplitsRejected: 0,
targetNaturalCompartmentCount: targetCompartmentCount,
compartmentMultiplier,
lowlandAdminSeedCount: adminCentersRaw.filter((p) => p.invisibleLowlandAdminSeed).length,
lowlandAdminSeeds: adminCentersRaw.filter((p) => p.invisibleLowlandAdminSeed).map((p) => ({ x: p.x, y: p.y })),
realAdminSeedCount: adminCentersRaw.filter((p) => !p.invisibleLowlandAdminSeed).length,
highMountainAdminSeedCount: adminCentersRaw.filter((p) => {
const i = indexOf(p.x, p.y);
return elevation[i] > 0.70 || slope[i] > 0.52 || boundaryRidgeField[i] > 0.62;
}).length,
candidateSeedCount: adminCentersRaw.length,
protectedSeedCount: adminCentersRaw.filter(isProtectedAdminSeed).length,
survivedSeedCount: 0,
pendingSeedCount: 0,
absorbedSeedCount: 0,
pendingSeedsUsedForLowlandSplit: 0,
finalMunicipalityCount: 0,
finalTinyMunicipalityCount: 0,
seedCellRevivalCount: 0,
satelliteMunicipalitiesCreated: adminCentersRaw.filter((p) => p.protectedSatellite).length,
satelliteMunicipalitiesMerged: 0,
satelliteMunicipalitiesExpanded: 0,
satelliteMunicipalitiesTooSmall: 0,
averageSatelliteMunicipalityArea: 0,
minSatelliteMunicipalityArea: 0,
satelliteMunicipalityAreaByNameOrIndex: {},
independentSatelliteMunicipalities: satelliteClassificationDebug.independent,
attachedSatelliteDistricts: satelliteClassificationDebug.attached,
satelliteMunicipalityStats: satelliteClassificationDebug,
...compartmentAssignment.debug,
};
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "seed lifecycle" });
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);
}
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "smooth boundaries" });
smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, populationDensity, landuse, 2);
markChanged("changedAfterSmooth");
function lockUrbanClusterToMunicipality(city, radius, allowSuburban = true) {
if (!city || !prefectureMask[indexOf(city.x, city.y)]) return;
let bestAdmin = -1;
let bestD = INF;
adminCentersRaw.forEach((center, id) => {
if (!activeAdminIds.has(id)) return;
const d = Math.hypot(center.x - city.x, center.y - city.y);
if (d < bestD) { bestD = d; bestAdmin = id; }
});
if (bestAdmin < 0) return;
const r = Math.ceil(radius);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const x = city.x + dx;
const y = city.y + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i]) continue;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
const urban = landuse[i] === 2 || landuse[i] === 3 || (allowSuburban && (landuse[i] === 4 || landuse[i] === 7 || landuse[i] === 8));
if (urban || populationDensity[i] > 0.22) adminId[i] = bestAdmin;
}
}
}
for (const city of modernCities) {
const radius = (city.population || 0) >= 500000
? clamp(17 + Math.sqrt(city.population) / 120, 20, 38)
: clamp(5 + Math.sqrt(city.population || 70000) / 210, 6, 11);
lockUrbanClusterToMunicipality(city, radius, true);
}
for (const sat of satelliteCities || []) {
if (!prefectureMask[indexOf(sat.x, sat.y)]) continue;
let bestAdmin = -1;
if (sat.municipalityClass === "independentSatelliteMunicipality") {
let bestD = INF;
adminCentersRaw.forEach((center, id) => {
if (!activeAdminIds.has(id)) return;
const d = Math.hypot(center.x - sat.x, center.y - sat.y);
if (d < bestD) { bestD = d; bestAdmin = id; }
});
} else if (inside(sat.parentX ?? -1, sat.parentY ?? -1)) {
bestAdmin = adminId[indexOf(sat.parentX, sat.parentY)];
}
if (bestAdmin < 0) continue;
sat.parentAdminHint = bestAdmin;
const changed = expandSatelliteMunicipalityCatchment(adminId, sat, bestAdmin, {
prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum,
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities,
});
if (changed > 0 && sat.municipalityClass === "independentSatelliteMunicipality") adminDebug.satelliteMunicipalitiesExpanded++;
}
markChanged("changedAfterUrbanLock");
lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 520);
lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620);
markChanged("changedAfterSmallUrbanLock");
const activeAdminCenters = () => adminCentersRaw.filter((_, id) => activeAdminIds.has(id));
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: activeAdminCenters() });
markChanged("changedAfterInitialMerge");
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "topology cleanup" });
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180);
markChanged("changedAfterInitialExclaveRemoval");
// The initial compartment graph assignment is now the primary natural partition.
// Re-running the older raw landscape-unit pass here collapses lowland seeds into a few broad owners.
markChanged("changedAfterLandscapePartition");
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "split oversized municipalities" });
// The older oversized-lowland pass rebuilds natural compartments a second time.
// The current pipeline already performs pending-seed lowland splitting on the active
// compartment graph above, so keep the full admin layout while avoiding the duplicate
// high-cost recomputation.
const oversizedSplitDebug = {
changedCells: 0,
splitMunicipalities: 0,
rejectedMunicipalities: 0,
skippedDuplicateCompartmentRebuild: true,
skippedForVisibleFragment: adminRegionMeta.isFocusedRegion === false && regionLandArea < 6500,
};
adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells;
adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities;
adminDebug.oversizedRuralSplits = oversizedSplitDebug.splitMunicipalities;
adminDebug.oversizedLowlandSplits = oversizedSplitDebug.splitMunicipalities;
adminDebug.ruralSplitsAccepted = oversizedSplitDebug.splitMunicipalities;
adminDebug.ruralSplitsRejected = oversizedSplitDebug.rejectedMunicipalities || 0;
adminDebug.oversizedSplitSkippedForVisibleFragment = Boolean(oversizedSplitDebug.skippedForVisibleFragment);
adminDebug.oversizedSplitSkippedDuplicateCompartmentRebuild = Boolean(oversizedSplitDebug.skippedDuplicateCompartmentRebuild);
previousSnapshot = new Int16Array(adminId);
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "terrain snap" });
snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 2);
markChanged("changedAfterSnap");
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 360);
markChanged("changedAfterFinalExclaveRemoval");
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 80, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 90, protectedPoints: activeAdminCenters() });
markChanged("changedAfterFinalMerge");
for (const sat of satelliteCities || []) {
if (sat.municipalityClass !== "independentSatelliteMunicipality" || !inside(sat.x, sat.y)) continue;
const targetAdmin = adminId[indexOf(sat.x, sat.y)];
if (targetAdmin < 0) continue;
expandSatelliteMunicipalityCatchment(adminId, sat, targetAdmin, {
prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum,
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities,
});
}
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 260);
const finalPromotionDebug = promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, {
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
}, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(22, targetMunicipalityCount));
adminDebug.changedAfterFinalPendingSeedCountRepair = finalPromotionDebug.changedCells;
adminDebug.pendingSeedsPromotedForCount += finalPromotionDebug.promotedSeeds;
const restoredSeedDebug = restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(22, targetMunicipalityCount));
adminDebug.changedAfterSurvivedSeedCompartmentRestore = restoredSeedDebug.changedCells;
adminDebug.survivedSeedsRestoredByCompartment = restoredSeedDebug.restoredSeeds;
let finalAreaBySeed = municipalityAreaById(adminId, prefectureMask, sea);
for (const seedState of seedLifecycle) {
seedState.area = finalAreaBySeed.get(seedState.id) || 0;
if (!seedState.protected && seedState.state === "pending" && seedState.area < 25) seedState.state = "absorbed";
}
absorbSeedCompartments(adminId, compartmentAssignment.compartments, seedLifecycle);
activeAdminIds = activeSeedIds(seedLifecycle);
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
const satelliteAreas = [];
(satelliteCities || []).forEach((sat, index) => {
if (!inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)]) return;
const id = adminId[indexOf(sat.x, sat.y)];
const area = areaById.get(id) || 0;
const key = sat.name || `satellite-${index}`;
adminDebug.satelliteMunicipalityAreaByNameOrIndex[key] = area;
if (sat.municipalityClass === "independentSatelliteMunicipality" && (area < Math.max(120, sat.satelliteMinArea || 0) || ((sat.population || 0) >= 60000 && area < 150))) {
sat.municipalityClass = "smallTownAttachedToRuralMunicipality";
adminDebug.satelliteMunicipalitiesTooSmall++;
return;
}
if (sat.municipalityClass === "independentSatelliteMunicipality") {
satelliteAreas.push(area);
if (area < 80) adminDebug.satelliteMunicipalitiesTooSmall++;
}
});
adminDebug.averageSatelliteMunicipalityArea = satelliteAreas.length ? satelliteAreas.reduce((sum, value) => sum + value, 0) / satelliteAreas.length : 0;
adminDebug.minSatelliteMunicipalityArea = satelliteAreas.length ? Math.min(...satelliteAreas) : 0;
adminDebug.satelliteMunicipalitiesIndependent = satelliteAreas.length;
const landscapeDebug = applyLandscapeUnitAdminPartition.lastDebug || {};
Object.assign(adminDebug, landscapeDebug);
adminDebug.targetNaturalCompartmentCount = compartmentAssignment.debug?.targetNaturalCompartmentCount || targetCompartmentCount;
adminDebug.naturalCompartmentCount = compartmentAssignment.debug?.naturalCompartmentCount || adminDebug.naturalCompartmentCount || adminDebug.compartmentCount || 0;
adminDebug.compartmentCount = adminDebug.naturalCompartmentCount;
adminDebug.averageCompartmentsPerMunicipality = compartmentAssignment.debug?.averageCompartmentsPerMunicipality || adminDebug.averageCompartmentsPerMunicipality || 0;
adminDebug.singleCompartmentMunicipalityRatio = compartmentAssignment.debug?.singleCompartmentMunicipalityRatio ?? adminDebug.singleCompartmentMunicipalityRatio ?? 0;
adminDebug.actualMunicipalityCount = new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i])).size;
adminDebug.averageCompartmentsPerMunicipality = adminDebug.actualMunicipalityCount ? adminDebug.naturalCompartmentCount / adminDebug.actualMunicipalityCount : 0;
adminDebug.survivedSeedCount = seedLifecycle.filter((seed) => seed.state === "survived").length;
adminDebug.pendingSeedCount = seedLifecycle.filter((seed) => seed.state === "pending").length;
adminDebug.absorbedSeedCount = seedLifecycle.filter((seed) => seed.state === "absorbed").length;
adminDebug.finalMunicipalityCount = adminDebug.actualMunicipalityCount;
adminDebug.finalTinyMunicipalityCount = [...areaById.values()].filter((area) => area > 0 && area < 8).length;
adminDebug.seedLifecycle = seedLifecycle.map((seed) => ({ id: seed.id, state: seed.state, protected: seed.protected, area: seed.area }));
adminDebug.borderNaturalBarrierAverage = adminDebug.finalBorderNaturalBarrierAverage ?? 0;
adminDebug.voronoiLikeRate = adminDebug.voronoiLikeRateAfter ?? 0;
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "extract borders" });
const adminBorders = extractAdminBorderSegments(adminId, prefectureMask);
return { adminCentersRaw, adminId, adminBorders, adminDebug };
}
function filterPointsForMask(points = [], mask, sea) {
return (points || [])
.filter((p) => p && inside(p.x, p.y) && mask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)])
.map((p) => ({ ...p }));
}
function buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId) {
const mask = new Uint8Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
mask[i] = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId) === regionId ? 1 : 0;
}
return mask;
}
function maskLandArea(mask, sea) {
let area = 0;
for (let i = 0; i < SIZE; i++) if (mask[i] && !sea[i]) area++;
return area;
}
function compactAdminIdsAndCenters(adminId, humanMask, sea, centers = [], fields = {}) {
const activeIds = [...new Set([...adminId].filter((id, i) => id >= 0 && humanMask[i] && !sea[i]))].sort((a, b) => a - b);
const idMap = new Map(activeIds.map((oldId, newId) => [oldId, newId]));
const newAdminId = new Int16Array(SIZE);
newAdminId.fill(-1);
const cellsByNewId = Array.from({ length: activeIds.length }, () => []);
for (let i = 0; i < SIZE; i++) {
if (!humanMask[i] || sea[i]) continue;
const newId = idMap.get(adminId[i]);
if (newId === undefined) continue;
newAdminId[i] = newId;
cellsByNewId[newId].push(i);
}
const chooseOffice = (newId, oldId) => {
const cells = cellsByNewId[newId] || [];
const current = centers[oldId];
if (current && inside(current.x, current.y)) {
const ci = indexOf(current.x, current.y);
if (newAdminId[ci] === newId && humanMask[ci] && !sea[ci]) {
return { ...current, localAdminId: newId, oldAdminId: oldId, municipalityOffice: true };
}
}
let sx = 0, sy = 0;
for (const i of cells) {
const [x, y] = xyOf(i);
sx += x;
sy += y;
}
const cx = cells.length ? sx / cells.length : current?.x || 0;
const cy = cells.length ? sy / cells.length : current?.y || 0;
let bestI = cells[0] ?? -1;
let bestScore = -INF;
for (const i of cells) {
const [x, y] = xyOf(i);
const land = fields.landuse?.[i] ?? 0;
const urbanBonus = land === 3 ? 1.2 : land === 2 ? 1.0 : land === 4 || land === 7 || land === 8 ? 0.55 : land === 1 ? 0.24 : 0;
const density = fields.populationDensity?.[i] || 0;
const settlement = fields.settlementScore?.[i] || 0;
const score =
density * 2.25 +
settlement * 0.75 +
urbanBonus +
(fields.plain?.[i] || 0) * 0.32 +
(fields.basinField?.[i] || 0) * 0.24 +
(fields.coastalLowland?.[i] || 0) * 0.18 +
(fields.roadInfluence?.[i] || 0) * 0.34 +
(fields.stationInfluence?.[i] || 0) * 0.45 -
(fields.slope?.[i] || 0) * 0.52 -
Math.hypot(x - cx, y - cy) * 0.018 +
hash2(x, y, 91337 + newId) * 0.012;
if (score > bestScore) { bestScore = score; bestI = i; }
}
const [bx, by] = bestI >= 0 ? xyOf(bestI) : [Math.round(cx), Math.round(cy)];
return {
...(current || {}),
x: bx,
y: by,
score: bestScore > -INF ? bestScore : 0,
seedKind: current?.seedKind || "generatedMunicipalOffice",
invisibleLowlandAdminSeed: current?.invisibleLowlandAdminSeed ?? true,
localAdminId: newId,
oldAdminId: oldId,
municipalityOffice: true,
generatedOfficePoint: !current || !inside(current.x, current.y) || newAdminId[indexOf(current.x, current.y)] !== newId,
};
};
const adminCenters = activeIds.map((oldId, newId) => chooseOffice(newId, oldId));
return {
adminId: newAdminId,
adminCenters,
activeMunicipalityCount: activeIds.length,
removedUnusedAdminCenterCount: Math.max(0, centers.length - activeIds.length),
generatedOfficePointCount: adminCenters.filter((p) => p.generatedOfficePoint).length,
};
}
function discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea) {
const ids = new Set();
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
const id = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId);
if (id >= 0 || id === OUTER_ANCHOR_REGION_ID) ids.add(id);
}
return [...ids].sort((a, b) => a - b);
}
export function generateAdminLayout(context) {
const { prefectureMask, prefectureRegionId, sea, populationDensity, plain, slope, adminProgress } = context;
const minFullAdminRegionArea = 1500;
const regionIds = discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea)
.filter((regionId) => regionId === 0 || (regionId !== OUTER_ANCHOR_REGION_ID && maskLandArea(buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId), sea) >= minFullAdminRegionArea));
if (!prefectureRegionId || regionIds.length <= 1) return generateAdminLayoutForMask(context);
let combinedAdminId = new Int16Array(SIZE);
combinedAdminId.fill(-1);
const combinedHumanMask = new Uint8Array(SIZE);
let combinedCenters = [];
const combinedCompartmentBorders = [];
const perRegion = [];
let idOffset = 0;
for (const regionId of regionIds) {
const regionMask = buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId);
const regionArea = maskLandArea(regionMask, sea);
if (regionId !== 0 && regionArea < minFullAdminRegionArea) continue;
const localContext = {
...context,
seed: (context.seed + regionId * 10007) >>> 0,
prefectureMask: regionMask,
modernCities: filterPointsForMask(context.modernCities, regionMask, sea),
satelliteCities: filterPointsForMask(context.satelliteCities, regionMask, sea),
newTowns: filterPointsForMask(context.newTowns, regionMask, sea),
markets: filterPointsForMask(context.markets, regionMask, sea),
villages: filterPointsForMask(context.villages, regionMask, sea),
ports: filterPointsForMask(context.ports, regionMask, sea),
stations: filterPointsForMask(context.stations, regionMask, sea),
industrialZones: filterPointsForMask(context.industrialZones, regionMask, sea),
logisticsParks: filterPointsForMask(context.logisticsParks, regionMask, sea),
adminRegionMeta: {
regionId,
landArea: regionArea,
isFocusedRegion: regionId === 0,
isOuterAnchorRegion: regionId === OUTER_ANCHOR_REGION_ID,
},
adminProgress,
};
adminProgress?.({ status: "region-start", regionId, area: regionArea });
const local = generateAdminLayoutForMask(localContext);
adminProgress?.({ status: "region-done", regionId, area: regionArea, municipalities: local.adminDebug?.finalMunicipalityCount || local.adminDebug?.actualMunicipalityCount || 0 });
if (local.adminDebug?.compartmentBorders?.length) combinedCompartmentBorders.push(...local.adminDebug.compartmentBorders);
let localMaxAdminId = -1;
for (let i = 0; i < SIZE; i++) if (regionMask[i] && !sea[i] && (local.adminId?.[i] ?? -1) > localMaxAdminId) localMaxAdminId = local.adminId[i];
const localSlotCount = Math.max(local.adminCentersRaw?.length || 0, localMaxAdminId + 1);
const localCenters = [];
for (let localAdminId = 0; localAdminId < localSlotCount; localAdminId++) {
let center = local.adminCentersRaw?.[localAdminId];
if (!center) {
let sx = 0, sy = 0, count = 0, bestI = -1, bestScore = -INF;
for (let i = 0; i < SIZE; i++) {
if (!regionMask[i] || sea[i] || local.adminId?.[i] !== localAdminId) continue;
const [x, y] = xyOf(i);
sx += x;
sy += y;
count++;
const score = (populationDensity?.[i] || 0) + (plain?.[i] || 0) * 0.2 - (slope?.[i] || 0) * 0.2;
if (score > bestScore) { bestScore = score; bestI = i; }
}
if (count && bestI >= 0) {
const [bx, by] = xyOf(bestI);
center = { x: bx, y: by, score: bestScore, seedKind: "generatedAdminSlot", invisibleLowlandAdminSeed: true };
}
}
if (!center) center = { x: 0, y: 0, score: 0, seedKind: "emptyAdminSlot", invisibleLowlandAdminSeed: true };
localCenters.push({
...center,
regionId,
localAdminId,
adminIdOffset: idOffset,
});
}
combinedCenters.push(...localCenters);
for (let i = 0; i < SIZE; i++) {
if (!regionMask[i] || sea[i]) continue;
combinedHumanMask[i] = 1;
const localId = local.adminId?.[i] ?? -1;
if (localId >= 0) combinedAdminId[i] = localId + idOffset;
}
perRegion.push({
regionId,
area: regionArea,
centerCount: localCenters.length,
municipalityCount: local.adminDebug?.finalMunicipalityCount || local.adminDebug?.actualMunicipalityCount || new Set([...local.adminId].filter((id, i) => id >= 0 && regionMask[i] && !sea[i])).size,
naturalCompartmentCount: local.adminDebug?.naturalCompartmentCount || 0,
targetNaturalCompartmentCount: local.adminDebug?.targetNaturalCompartmentCount || 0,
averageCompartmentArea: local.adminDebug?.averageCompartmentArea || 0,
maxCompartmentArea: local.adminDebug?.maxCompartmentArea || 0,
maxCompartmentElongation: local.adminDebug?.maxCompartmentElongation || 1,
worstNaturalCompartments: local.adminDebug?.worstNaturalCompartments || [],
singleCompartmentMunicipalityRatio: local.adminDebug?.singleCompartmentMunicipalityRatio || 0,
});
idOffset += localSlotCount;
}
const leftoverByRegion = new Map();
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
const regionId = adminGenerationRegionIdAt(i, prefectureMask, prefectureRegionId);
if ((regionId < 0 && regionId !== OUTER_ANCHOR_REGION_ID) || combinedAdminId[i] >= 0) continue;
if (!leftoverByRegion.has(regionId)) leftoverByRegion.set(regionId, []);
leftoverByRegion.get(regionId).push(i);
}
for (const [regionId, cells] of leftoverByRegion) {
let sx = 0, sy = 0, bestI = cells[0], bestScore = -INF;
for (const i of cells) {
const [x, y] = xyOf(i);
sx += x;
sy += y;
const score = (populationDensity?.[i] || 0) + (plain?.[i] || 0) * 0.2 - (slope?.[i] || 0) * 0.2;
if (score > bestScore) { bestScore = score; bestI = i; }
}
const [cx, cy] = xyOf(bestI);
const id = combinedCenters.length;
combinedCenters.push({ x: cx, y: cy, score: bestScore, regionId, localAdminId: 0, seedKind: "tinyRegionAdminSeed", invisibleLowlandAdminSeed: true });
for (const i of cells) {
combinedHumanMask[i] = 1;
combinedAdminId[i] = id;
}
perRegion.push({ regionId, area: cells.length, centerCount: 1, municipalityCount: 1, naturalCompartmentCount: 1, targetNaturalCompartmentCount: 1, averageCompartmentArea: cells.length, maxCompartmentArea: cells.length, maxCompartmentElongation: 1, singleCompartmentMunicipalityRatio: 1, tinyRegionFallback: true });
}
const compactedAdmin = compactAdminIdsAndCenters(combinedAdminId, combinedHumanMask, sea, combinedCenters, {
populationDensity,
plain,
slope,
settlementScore: context.settlementScore,
landuse: context.landuse,
basinField: context.basinField,
coastalLowland: context.coastalLowland,
roadInfluence: context.roadInfluence,
stationInfluence: context.stationInfluence,
});
combinedAdminId = compactedAdmin.adminId;
combinedCenters = compactedAdmin.adminCenters;
const adminBorders = extractAdminBorderSegments(combinedAdminId, combinedHumanMask);
const totalMunicipalityCount = compactedAdmin.activeMunicipalityCount;
const totalNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.naturalCompartmentCount || 0), 0);
const totalTargetNaturalCompartmentCount = perRegion.reduce((sum, row) => sum + (row.targetNaturalCompartmentCount || 0), 0);
const weightedCompartmentArea = perRegion.reduce((sum, row) => sum + (row.averageCompartmentArea || 0) * (row.naturalCompartmentCount || 0), 0);
const weightedSingleRatio = perRegion.reduce((sum, row) => sum + (row.singleCompartmentMunicipalityRatio || 0) * (row.municipalityCount || 0), 0);
const adminDebug = {
multiRegionAdmin: true,
adminRegionCount: perRegion.length,
minFullAdminRegionArea,
perRegion,
finalMunicipalityCount: totalMunicipalityCount,
actualMunicipalityCount: totalMunicipalityCount,
candidateSeedCount: combinedCenters.length,
municipalOfficePointCount: combinedCenters.length,
generatedOfficePointCount: compactedAdmin.generatedOfficePointCount,
removedUnusedAdminCenterCount: compactedAdmin.removedUnusedAdminCenterCount,
naturalCompartmentCount: totalNaturalCompartmentCount,
compartmentCount: totalNaturalCompartmentCount,
targetNaturalCompartmentCount: totalTargetNaturalCompartmentCount,
averageCompartmentArea: totalNaturalCompartmentCount ? weightedCompartmentArea / totalNaturalCompartmentCount : 0,
maxCompartmentArea: Math.max(0, ...perRegion.map((row) => row.maxCompartmentArea || 0)),
maxCompartmentElongation: Math.max(1, ...perRegion.map((row) => row.maxCompartmentElongation || 1)),
averageCompartmentsPerMunicipality: totalMunicipalityCount ? totalNaturalCompartmentCount / totalMunicipalityCount : 0,
singleCompartmentMunicipalityRatio: totalMunicipalityCount ? weightedSingleRatio / totalMunicipalityCount : 0,
compartmentBorders: combinedCompartmentBorders,
};
return { adminCentersRaw: combinedCenters, adminId: combinedAdminId, adminBorders, adminDebug };
}