import { applyLandscapeUnitAdminPartition, assignAdminRegionsFromNaturalCompartments, lockSmallUrbanComponentsToMunicipality, mergeTinyMunicipalities, removeMunicipalExclaves, smoothAdminRegionsTerrainAware, splitOversizedLowlandMunicipalities, snapAdminBoundariesToTerrain, } from "./adminRegions.js"; import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, xyOf } from "./mapUtils.js"; import { extractAdminBorderSegments } from "./mapGeneratorHelpers.js"; function changedCellsSince(before, after, prefectureMask, sea) { let changed = 0; for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== after[i]) changed++; return changed; } function municipalityAreaById(adminId, prefectureMask, sea) { const area = new Map(); for (let i = 0; i < SIZE; i++) { if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; area.set(adminId[i], (area.get(adminId[i]) || 0) + 1); } return area; } function maskLandArea(mask, sea) { let area = 0; for (let i = 0; i < SIZE; i++) if (mask[i] && !sea[i]) area++; return area; } function compactWholeCompartmentMunicipalities(adminId, centers, prefectureMask, sea, fields = {}) { const activeIds = [...new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i]))].sort((a, b) => a - b); const idMap = new Map(activeIds.map((id, n) => [id, n])); const compactId = new Int16Array(SIZE); compactId.fill(-1); const stats = activeIds.map(() => ({ sx: 0, sy: 0, count: 0, bestI: -1, bestScore: -INF })); for (let i = 0; i < SIZE; i++) { if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; const nextId = idMap.get(adminId[i]); if (nextId === undefined) continue; compactId[i] = nextId; const [x, y] = xyOf(i); const row = stats[nextId]; row.sx += x; row.sy += y; row.count++; const score = (fields.populationDensity?.[i] || 0) * 2.2 + (fields.plain?.[i] || 0) * 0.25 - (fields.slope?.[i] || 0) * 0.2; if (score > row.bestScore) { row.bestScore = score; row.bestI = i; } } const compactCenters = activeIds.map((oldId, newId) => { const current = centers[oldId]; if (current && inside(current.x, current.y) && compactId[indexOf(current.x, current.y)] === newId) { return { ...current, originalAdminId: oldId }; } const row = stats[newId]; const fallback = row.bestI >= 0 ? xyOf(row.bestI) : [Math.round(row.sx / Math.max(1, row.count)), Math.round(row.sy / Math.max(1, row.count))]; return { ...(current || {}), x: fallback[0], y: fallback[1], originalAdminId: oldId, generatedOfficePoint: true, invisibleLowlandAdminSeed: current?.invisibleLowlandAdminSeed ?? true, seedKind: current?.seedKind || "compactedMunicipalityOffice", }; }); return { adminId: compactId, adminCentersRaw: compactCenters, activeMunicipalityCount: compactCenters.length }; } function enforceCompartmentMunicipalityOwnership(adminId, compartmentId, compartments, prefectureMask, sea) { if (!compartmentId || !compartments) return 0; let changed = 0; for (const comp of compartments) { if (!comp || !comp.cells?.length) continue; const counts = new Map(); for (const i of comp.cells) { if (!prefectureMask[i] || sea[i]) continue; const id = adminId[i]; if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); } let bestId = -1, bestCount = -1; for (const [id, count] of counts) { if (count > bestCount || (count === bestCount && id < bestId)) { bestId = id; bestCount = count; } } if (bestId < 0) continue; for (const i of comp.cells) { if (!prefectureMask[i] || sea[i] || adminId[i] === bestId) continue; adminId[i] = bestId; changed++; } } return changed; } function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity) { const nodes = new Map(); const edges = new Map(); for (let i = 0; i < SIZE; i++) { if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; const id = adminId[i]; if (!nodes.has(id)) nodes.set(id, { id, area: 0, population: 0, sx: 0, sy: 0, touchesOutside: false }); const node = nodes.get(id); const [x, y] = xyOf(i); if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) node.touchesOutside = true; for (const [ox, oy] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) { if (!inside(ox, oy)) { node.touchesOutside = true; continue; } const oi = indexOf(ox, oy); if (!prefectureMask[oi] || sea[oi]) node.touchesOutside = true; } node.area++; node.population += populationDensity?.[i] || 0; node.sx += x; node.sy += y; for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { if (!inside(nx, ny)) continue; const ni = indexOf(nx, ny); if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || adminId[ni] === id) continue; const a = Math.min(id, adminId[ni]); const b = Math.max(id, adminId[ni]); const key = `${a}:${b}`; const edge = edges.get(key) || { a, b, count: 0, barrier: 0 }; edge.count++; edge.barrier += ((naturalBarrierScore?.[i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5; edges.set(key, edge); } } for (const node of nodes.values()) { node.x = node.sx / Math.max(1, node.area); node.y = node.sy / Math.max(1, node.area); node.adjacent = new Map(); } for (const edge of edges.values()) { edge.barrier /= Math.max(1, edge.count); nodes.get(edge.a)?.adjacent.set(edge.b, edge); nodes.get(edge.b)?.adjacent.set(edge.a, edge); } return { nodes, edges }; } function choosePrefectureMunicipalitySeeds(nodes, seed) { const active = [...nodes.values()].filter((node) => node.area > 0).sort((a, b) => b.area - a.area || a.id - b.id); const totalArea = active.reduce((sum, node) => sum + node.area, 0); const targetCount = clamp(Math.round(totalArea / 7200), 3, 6); const seeds = []; const first = active.sort((a, b) => (b.population * 1.8 + b.area * 0.08) - (a.population * 1.8 + a.area * 0.08) || a.id - b.id)[0]; if (first) seeds.push(first); while (seeds.length < targetCount) { let best = null, bestScore = -INF; for (const node of active) { if (seeds.includes(node)) continue; const nearest = Math.min(...seeds.map((s) => Math.hypot(node.x - s.x, node.y - s.y))); const score = nearest * (1.0 + hash2(seed, node.id) * 0.08) + Math.sqrt(node.area) * 0.12 + node.population * 0.28; if (score > bestScore) { bestScore = score; best = node; } } if (!best) break; seeds.push(best); } return seeds; } function assignMunicipalitiesToPrefectures(nodes, seeds) { const owner = new Map(); const area = new Map(); const heap = new MinHeap(); seeds.forEach((node, id) => { owner.set(node.id, id); area.set(id, node.area); heap.push({ i: node.id, id, f: 0 }); }); const totalArea = [...nodes.values()].reduce((sum, node) => sum + node.area, 0); const maxArea = Math.max(900, totalArea * 0.30); while (heap.length) { const cur = heap.pop(); if (!cur || owner.get(cur.i) !== cur.id) continue; const node = nodes.get(cur.i); if (!node) continue; for (const [nextId, edge] of node.adjacent) { if (owner.has(nextId)) continue; const next = nodes.get(nextId); if (!next) continue; const areaPressure = Math.max(0, ((area.get(cur.id) || 0) + next.area - maxArea) / Math.max(1, maxArea)); const cost = cur.f + 1.0 + edge.barrier * 5.5 + areaPressure * 14 + hash2(cur.id, nextId) * 0.05; owner.set(nextId, cur.id); area.set(cur.id, (area.get(cur.id) || 0) + next.area); heap.push({ i: nextId, id: cur.id, f: cost }); } } let fallback = 0; for (const id of [...nodes.keys()].sort((a, b) => a - b)) { if (!owner.has(id)) owner.set(id, fallback++ % Math.max(1, seeds.length)); } return owner; } function repairPrefectureMunicipalityConnectivity(nodes, owner) { let changed = 0; for (let pass = 0; pass < 8; pass++) { let passChanged = 0; const prefIds = [...new Set(owner.values())].sort((a, b) => a - b); for (const prefId of prefIds) { const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId); const memberSet = new Set(members); const seen = new Set(); const components = []; for (const start of members) { if (seen.has(start)) continue; const queue = [start]; const comp = []; seen.add(start); for (let q = 0; q < queue.length; q++) { const cur = queue[q]; comp.push(cur); for (const next of nodes.get(cur)?.adjacent.keys() || []) { if (!memberSet.has(next) || seen.has(next)) continue; seen.add(next); queue.push(next); } } components.push(comp); } if (components.length <= 1) continue; components.sort((a, b) => b.length - a.length); for (const comp of components.slice(1)) { const neighborCounts = new Map(); for (const id of comp) { for (const next of nodes.get(id)?.adjacent.keys() || []) { const nOwner = owner.get(next); if (nOwner !== prefId) neighborCounts.set(nOwner, (neighborCounts.get(nOwner) || 0) + 1); } } let best = -1, bestCount = -1; for (const [id, count] of neighborCounts) if (count > bestCount || (count === bestCount && id < best)) { best = id; bestCount = count; } if (best < 0) continue; for (const id of comp) owner.set(id, best); passChanged += comp.length; } } changed += passChanged; if (!passChanged) break; } return changed; } function repairPrefectureMunicipalityEnclaves(nodes, owner, maxPasses = 6) { let changed = 0; for (let pass = 0; pass < maxPasses; pass++) { let passChanged = 0; const prefIds = [...new Set(owner.values())].filter((id) => id >= 0).sort((a, b) => a - b); for (const prefId of prefIds) { const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId); const memberSet = new Set(members); const seen = new Set(); for (const start of members) { if (seen.has(start)) continue; const queue = [start]; const comp = []; seen.add(start); let touchesOutside = false; const boundaryPrefs = new Map(); for (let q = 0; q < queue.length; q++) { const cur = queue[q]; comp.push(cur); const node = nodes.get(cur); if (node?.touchesOutside) touchesOutside = true; for (const next of node?.adjacent.keys() || []) { const nextOwner = owner.get(next); if (nextOwner === prefId) { if (!seen.has(next)) { seen.add(next); queue.push(next); } } else if (nextOwner >= 0) { boundaryPrefs.set(nextOwner, (boundaryPrefs.get(nextOwner) || 0) + 1); } } } if (touchesOutside || boundaryPrefs.size !== 1) continue; const [targetPref] = boundaryPrefs.keys(); if (targetPref < 0 || targetPref === prefId) continue; for (const id of comp) owner.set(id, targetPref); passChanged += comp.length; } } changed += passChanged; if (!passChanged) break; } return changed; } function repairPrefectureCellEnclavesFromGrid(adminId, owner, prefectureMask, sea, maxPasses = 5) { let changed = 0; const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; for (let pass = 0; pass < maxPasses; pass++) { const prefId = new Int16Array(SIZE); prefId.fill(-1); for (let i = 0; i < SIZE; i++) { if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; prefId[i] = owner.get(adminId[i]) ?? -1; } const seen = new Uint8Array(SIZE); let passChanged = 0; for (let i = 0; i < SIZE; i++) { if (seen[i] || prefId[i] < 0) continue; const id = prefId[i]; const queue = [i]; const comp = []; seen[i] = 1; let touchesOutside = false; const boundaryCounts = new Map(); const adminCounts = new Map(); for (let q = 0; q < queue.length; q++) { const cur = queue[q]; comp.push(cur); const aid = adminId[cur]; if (aid >= 0) adminCounts.set(aid, (adminCounts.get(aid) || 0) + 1); const [x, y] = xyOf(cur); if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true; for (const [dx, dy] of dirs) { const nx = x + dx, ny = y + dy; if (!inside(nx, ny)) { touchesOutside = true; continue; } const ni = indexOf(nx, ny); if (!prefectureMask[ni] || sea[ni]) { touchesOutside = true; continue; } const nid = prefId[ni]; if (nid === id) { if (!seen[ni]) { seen[ni] = 1; queue.push(ni); } } else if (nid >= 0) { boundaryCounts.set(nid, (boundaryCounts.get(nid) || 0) + 1); } } } if (touchesOutside || boundaryCounts.size !== 1) continue; const [targetPref] = boundaryCounts.keys(); if (targetPref < 0 || targetPref === id) continue; for (const aid of adminCounts.keys()) { if (owner.get(aid) !== targetPref) { owner.set(aid, targetPref); passChanged++; } } } changed += passChanged; if (!passChanged) break; } return changed; } function repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, maxPasses = 5) { let changed = 0; const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; for (let pass = 0; pass < maxPasses; pass++) { const seen = new Uint8Array(SIZE); let passChanged = 0; for (let i = 0; i < SIZE; i++) { if (seen[i] || !prefectureMask[i] || sea[i] || adminId[i] < 0) continue; const id = adminId[i]; const queue = [i]; const comp = []; seen[i] = 1; let touchesOutside = false; const boundaryCounts = new Map(); for (let q = 0; q < queue.length; q++) { const cur = queue[q]; comp.push(cur); const [x, y] = xyOf(cur); if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true; for (const [dx, dy] of dirs) { const nx = x + dx, ny = y + dy; if (!inside(nx, ny)) { touchesOutside = true; continue; } const ni = indexOf(nx, ny); if (!prefectureMask[ni] || sea[ni]) { touchesOutside = true; continue; } const nid = adminId[ni]; if (nid === id) { if (!seen[ni]) { seen[ni] = 1; queue.push(ni); } } else if (nid >= 0) { boundaryCounts.set(nid, (boundaryCounts.get(nid) || 0) + 1); } } } if (touchesOutside || boundaryCounts.size !== 1) continue; const [targetId] = boundaryCounts.keys(); if (targetId < 0 || targetId === id) continue; for (const ci of comp) adminId[ci] = targetId; passChanged += comp.length; } changed += passChanged; if (!passChanged) break; } return changed; } function lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, maxCells = 950) { if (!landuse || !populationDensity) return 0; const seen = new Uint8Array(SIZE); const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; let changed = 0; const isUrban = (i) => !sea[i] && prefectureMask[i] && (populationDensity[i] > 0.34 || [2, 3, 4, 7, 8].includes(landuse[i])); for (let i = 0; i < SIZE; i++) { if (seen[i] || !isUrban(i) || adminId[i] < 0) continue; const queue = [i]; const comp = []; seen[i] = 1; const counts = new Map(); for (let q = 0; q < queue.length; q++) { const cur = queue[q]; comp.push(cur); const id = adminId[cur]; if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1 + (populationDensity[cur] || 0) * 2.0); const [x, y] = xyOf(cur); for (const [dx, dy] of dirs) { const nx = x + dx, ny = y + dy; if (!inside(nx, ny)) continue; const ni = indexOf(nx, ny); if (seen[ni] || !isUrban(ni)) continue; seen[ni] = 1; queue.push(ni); } } if (comp.length < 8 || comp.length > maxCells || counts.size <= 1 || counts.size > 7) continue; let best = -1, bestScore = -INF; let total = 0; for (const [id, score] of counts) { total += score; if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; } } if (best < 0 || bestScore / Math.max(1, total) < 0.38) continue; for (const ci of comp) { if (adminId[ci] !== best) { adminId[ci] = best; changed++; } } } return changed; } function mergeTinyMunicipalityPrefectures(nodes, owner) { let changed = 0; for (let pass = 0; pass < 6; pass++) { const areaByPref = new Map(); for (const node of nodes.values()) { const pref = owner.get(node.id); areaByPref.set(pref, (areaByPref.get(pref) || 0) + node.area); } const totalArea = [...areaByPref.values()].reduce((sum, value) => sum + value, 0); const minArea = Math.max(520, totalArea / Math.max(1, areaByPref.size) * 0.34); const tiny = [...areaByPref.entries()] .filter(([, area]) => area > 0 && area < minArea && areaByPref.size > 4) .sort((a, b) => a[1] - b[1] || a[0] - b[0])[0]; if (!tiny) break; const [tinyPref] = tiny; const neighborScores = new Map(); for (const node of nodes.values()) { if (owner.get(node.id) !== tinyPref) continue; for (const [nextId, edge] of node.adjacent) { const nextPref = owner.get(nextId); if (nextPref === tinyPref || nextPref < 0) continue; const score = (neighborScores.get(nextPref) || 0) + edge.count * (0.6 + edge.barrier); neighborScores.set(nextPref, score); } } let best = -1, bestScore = -INF; for (const [pref, score] of neighborScores) { if (score > bestScore || (score === bestScore && pref < best)) { best = pref; bestScore = score; } } if (best < 0) break; for (const node of nodes.values()) if (owner.get(node.id) === tinyPref) { owner.set(node.id, best); changed++; } } return changed; } function splitOversizedCompartmentMunicipalities(adminId, centers, compartments, prefectureMask, sea, fields = {}, seed = 0) { if (!compartments?.length) return { changedCells: 0, splitMunicipalities: 0, addedCenters: 0 }; const compOwner = new Int16Array(compartments.length); compOwner.fill(-1); for (const comp of compartments) { if (!comp || !comp.cells?.length) continue; const counts = new Map(); for (const i of comp.cells) { if (!prefectureMask[i] || sea[i]) continue; const id = adminId[i]; if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); } let best = -1, bestCount = -1; for (const [id, count] of counts) if (count > bestCount || (count === bestCount && id < best)) { best = id; bestCount = count; } compOwner[comp.id] = best; } const byOwner = new Map(); for (const comp of compartments) { if (!comp || !comp.cells?.length) continue; const owner = compOwner[comp.id]; if (owner < 0) continue; if (!byOwner.has(owner)) byOwner.set(owner, []); byOwner.get(owner).push(comp); } const areas = [...byOwner.values()].map((list) => list.reduce((sum, comp) => sum + comp.area, 0)).sort((a, b) => a - b); if (!areas.length) return { changedCells: 0, splitMunicipalities: 0, addedCenters: 0 }; const median = areas[Math.floor(areas.length / 2)] || 1; const total = areas.reduce((sum, value) => sum + value, 0); const maxArea = Math.max(360, Math.min(total * 0.11, Math.max(median * 2.65, total / Math.max(12, Math.round(total / 520))))); let changedCells = 0; let splitMunicipalities = 0; let addedCenters = 0; const elevation = fields.elevation; const slope = fields.slope; const ridgeField = fields.ridgeField; const plain = fields.plain; const agriculture = fields.agriculture; const basinField = fields.basinField; const coastalLowland = fields.coastalLowland; const populationDensity = fields.populationDensity; for (const [owner, list] of [...byOwner.entries()].sort((a, b) => a[0] - b[0])) { const area = list.reduce((sum, comp) => sum + comp.area, 0); if (area <= maxArea || list.length < 4) continue; const desiredParts = clamp(Math.ceil(area / Math.max(1, maxArea)), 2, 9); const splitCount = desiredParts - 1; if (splitCount <= 0) continue; const candidates = list.map((comp) => { let sx = 0, sy = 0, n = 0, score = 0, bestI = -1, bestScore = -INF; for (const i of comp.cells) { if (!prefectureMask[i] || sea[i]) continue; const [x, y] = xyOf(i); sx += x; sy += y; n++; const cellScore = (plain?.[i] || 0) * 0.22 + (agriculture?.[i] || 0) * 0.24 + (basinField?.[i] || 0) * 0.14 + (coastalLowland?.[i] || 0) * 0.10 + (populationDensity?.[i] || 0) * 0.24 - (slope?.[i] || 0) * 0.20 - (ridgeField?.[i] || 0) * 0.18 - Math.max(0, (elevation?.[i] || 0) - 0.62) * 0.38; score += cellScore; if (cellScore > bestScore) { bestScore = cellScore; bestI = i; } } const [x, y] = bestI >= 0 ? xyOf(bestI) : [Math.round(sx / Math.max(1, n)), Math.round(sy / Math.max(1, n))]; return { comp, x, y, score: score / Math.max(1, n) + Math.sqrt(comp.area) * 0.025 + hash2(seed + owner, comp.id) * 0.03 }; }).sort((a, b) => b.score - a.score || a.comp.id - b.comp.id); const newSeeds = []; for (const cand of candidates) { if (newSeeds.length >= splitCount) break; if (newSeeds.every((s) => Math.hypot(s.x - cand.x, s.y - cand.y) >= 9)) newSeeds.push(cand); } if (!newSeeds.length) continue; const seedIds = newSeeds.map((cand) => { const id = centers.length; centers.push({ x: cand.x, y: cand.y, score: cand.score, invisibleLowlandAdminSeed: true, seedKind: "oversizedMunicipalitySplit", splitFromAdminId: owner }); addedCenters++; return id; }); const oldCenter = centers[owner] || candidates[0] || { x: list[0].x || 0, y: list[0].y || 0, score: 0 }; const owners = [{ id: owner, x: oldCenter.x, y: oldCenter.y, score: oldCenter.score || 0 }, ...newSeeds.map((cand, k) => ({ id: seedIds[k], x: cand.x, y: cand.y, score: cand.score }))]; const targetArea = area / Math.max(1, owners.length); const claimedArea = new Map(owners.map((entry) => [entry.id, 0])); for (const cand of candidates) { let bestSeed = owner; let bestCost = INF; for (const entry of owners) { const d = Math.hypot(cand.x - entry.x, cand.y - entry.y); const pressure = Math.max(0, ((claimedArea.get(entry.id) || 0) + cand.comp.area - targetArea * 1.25) / Math.max(1, targetArea)); const cost = d + pressure * 18 - cand.score * 2.5 + hash2(entry.id, cand.comp.id) * 0.05; if (cost < bestCost) { bestCost = cost; bestSeed = entry.id; } } claimedArea.set(bestSeed, (claimedArea.get(bestSeed) || 0) + cand.comp.area); if (bestSeed === owner) continue; for (const i of cand.comp.cells) { if (!prefectureMask[i] || sea[i]) continue; if (adminId[i] !== bestSeed) { adminId[i] = bestSeed; changedCells++; } } } splitMunicipalities++; } return { changedCells, splitMunicipalities, addedCenters, maxArea }; } function extractPrefectureBordersFromMunicipalities(adminId, municipalityToPrefectureId, prefectureMask, sea) { const segments = []; for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const i = indexOf(x, y); if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; const aPref = municipalityToPrefectureId[adminId[i]] ?? -1; if (x + 1 < MAP_W) { const ni = indexOf(x + 1, y); const bPref = !sea[ni] && adminId[ni] >= 0 ? municipalityToPrefectureId[adminId[ni]] ?? -1 : -1; if (prefectureMask[ni] && bPref >= 0 && aPref >= 0 && bPref !== aPref && adminId[ni] !== adminId[i]) segments.push([[x + 1, y], [x + 1, y + 1]]); } if (y + 1 < MAP_H) { const ni = indexOf(x, y + 1); const bPref = !sea[ni] && adminId[ni] >= 0 ? municipalityToPrefectureId[adminId[ni]] ?? -1 : -1; if (prefectureMask[ni] && bPref >= 0 && aPref >= 0 && bPref !== aPref && adminId[ni] !== adminId[i]) segments.push([[x, y + 1], [x + 1, y + 1]]); } } } return segments; } function generatePrefecturesFromMunicipalities(context, adminResult) { const { adminId } = adminResult; const { prefectureMask, sea, naturalBarrierScore, populationDensity, seed } = context; const graph = buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity); const seeds = choosePrefectureMunicipalitySeeds(graph.nodes, seed + 91001); const owner = assignMunicipalitiesToPrefectures(graph.nodes, seeds); const changedForTinyMerge = mergeTinyMunicipalityPrefectures(graph.nodes, owner); let changedForConnectivity = repairPrefectureMunicipalityConnectivity(graph.nodes, owner); let changedForEnclaveRepair = repairPrefectureMunicipalityEnclaves(graph.nodes, owner); changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); changedForEnclaveRepair += repairPrefectureCellEnclavesFromGrid(adminId, owner, prefectureMask, sea); changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner); const maxAdminId = Math.max(-1, ...[...graph.nodes.keys()]); const municipalityToPrefectureId = new Int16Array(maxAdminId + 1); municipalityToPrefectureId.fill(-1); for (const [admin, pref] of owner) municipalityToPrefectureId[admin] = pref; const prefectureRegionId = new Int16Array(SIZE); prefectureRegionId.fill(-1); for (let i = 0; i < SIZE; i++) { if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue; prefectureRegionId[i] = municipalityToPrefectureId[adminId[i]] ?? -1; } const regionalPrefectureBorders = extractPrefectureBordersFromMunicipalities(adminId, municipalityToPrefectureId, prefectureMask, sea); const areaByPref = new Map(); const popByPref = new Map(); for (const node of graph.nodes.values()) { const pref = owner.get(node.id); areaByPref.set(pref, (areaByPref.get(pref) || 0) + node.area); popByPref.set(pref, (popByPref.get(pref) || 0) + node.population); } const totalArea = [...areaByPref.values()].reduce((sum, value) => sum + value, 0); return { prefectureRegionId, municipalityToPrefectureId, regionalPrefectureBorders, regionalDebug: { prefecturesGeneratedAfterMunicipalities: true, prefectureSource: "municipality-boundary-union", municipalityGraphNodeCount: graph.nodes.size, municipalityGraphEdgeCount: graph.edges.size, prefectureMunicipalitySeedCount: seeds.length, prefectureTinyMergeChangedMunicipalities: changedForTinyMerge, prefectureConnectivityRepairChangedMunicipalities: changedForConnectivity, prefectureEnclaveRepairChangedMunicipalities: changedForEnclaveRepair, finalRegionalMaxAreaShare: totalArea ? Math.max(0, ...areaByPref.values()) / totalArea : 0, finalRegionalTinyPrefectureCount: [...areaByPref.values()].filter((area) => area < 520).length, finalRegionalPrefectureBorderCount: regionalPrefectureBorders.length, regionalPrefectureBordersRebuiltFromFinalId: true, }, }; } 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 = {}) { // Use the same administrative density curve for the highlighted prefecture // and neighboring prefectures. Only clipped slivers get a low floor. let min = 1; if (landCells >= 360) min = 2; if (landCells >= 750) min = 4; if (landCells >= 1400) min = 7; if (landCells >= 2400) min = 11; if (landCells >= 3800) min = 16; if (landCells >= 5600) min = 22; const max = clamp(Math.round(landCells / 160 + 6), Math.max(min, 4), 72); 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.7 + markets.length * 1.15 + ports.length * 0.8 + independentSatellites * 0.7 + villages.length * 0.32; 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 / 175 + settlementWeight + coastlineComplexity * 0.03 + basinBonus * 0.65 + lowlandBonus * 1.15 - mountainRatio * 1.8); 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.population || 0) < 45000) 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 cityMinimumMunicipalityArea(city) { const populationArea = Math.sqrt(city.population || 0) * 0.72; const footprintArea = (city.urbanFootprintCells || 0) * 0.42; return clamp(95 + populationArea + footprintArea, 130, (city.population || 0) >= 450000 ? 780 : 520); } function enforceCityMunicipalityCatchments(adminId, cities, context) { const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence } = context; const areaById = municipalityAreaById(adminId, prefectureMask, sea); let changed = 0; let protectedCities = 0; let tooSmall = 0; for (const city of cities || []) { if (!city || (city.population || 0) < 95000 || !inside(city.x, city.y)) continue; const start = indexOf(city.x, city.y); if (!prefectureMask[start] || sea[start]) continue; const targetAdmin = adminId[start]; if (targetAdmin < 0) continue; protectedCities++; const minArea = cityMinimumMunicipalityArea(city); if ((areaById.get(targetAdmin) || 0) >= minArea) continue; tooSmall++; const heap = new MinHeap(); const best = new Float32Array(SIZE); best.fill(INF); heap.push({ i: start, f: 0 }); best[start] = 0; const claimed = []; const maxCost = (city.population || 0) >= 450000 ? 78 : 56; let projectedArea = areaById.get(targetAdmin) || 0; while (heap.length > 0 && projectedArea < minArea) { const cur = heap.pop(); if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue; const [x, y] = xyOf(cur.i); if (!prefectureMask[cur.i] || sea[cur.i]) continue; const d = Math.hypot(x - city.x, y - city.y); const compatible = d <= Math.max(5, (city.coreRadius || 3) * 2.0) || [2, 3, 4, 7, 8].includes(landuse[cur.i]) || populationDensity[cur.i] > 0.10 || roadInfluence[cur.i] > 0.10 || railInfluence2[cur.i] > 0.10 || (stationInfluence?.[cur.i] || 0) > 0.10 || valleyField[cur.i] > 0.22 || basinField[cur.i] > 0.20 || coastalLowland[cur.i] > 0.18; if (!compatible && claimed.length > minArea * 0.55) continue; claimed.push(cur.i); if (adminId[cur.i] !== targetAdmin) projectedArea++; 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 majorBarrier = river[ni] > 0.62 || flowAccum[ni] > 0.74 || ridgeField[ni] > 0.70; const barrier = ridgeField[ni] * 5.4 + Math.max(0, elevation[ni] - 0.60) * 4.4 + slope[ni] * 2.6 + (majorBarrier ? 8.5 : river[ni] > 0.34 ? 2.6 : 0); const fit = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.4 : 0) + populationDensity[ni] * 2.2 + roadInfluence[ni] * 0.88 + railInfluence2[ni] * 0.92 + (stationInfluence?.[ni] || 0) * 1.15 + valleyField[ni] * 0.56 + basinField[ni] * 0.42 + coastalLowland[ni] * 0.34; const nd = cur.f + Math.max(0.30, 1.05 + barrier - fit + Math.hypot(nx - city.x, ny - city.y) / Math.max(8, (city.urbanRadius || 7) * 2.1)) * step; if (nd < best[ni]) { best[ni] = nd; heap.push({ i: ni, f: nd }); } } } for (const i of claimed) { const old = adminId[i]; if (old === targetAdmin) continue; if (old >= 0) areaById.set(old, Math.max(0, (areaById.get(old) || 0) - 1)); adminId[i] = targetAdmin; areaById.set(targetAdmin, (areaById.get(targetAdmin) || 0) + 1); changed++; } city.municipalityMinArea = minArea; } return { changed, protectedCities, tooSmall }; } 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, naturalCompartmentId, naturalCompartments, 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 = clamp(Math.round(Math.max(targetMunicipalityCount * 3.4, regionLandArea / 78)), 22, 150); const maxCompartmentTarget = clamp(Math.round(Math.max(targetMunicipalityCount * 6.0, regionLandArea / 36)), minCompartmentTarget, 320); 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 < targetMunicipalityCount) 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)), naturalCompartmentId, naturalCompartments, naturalBarrierScore, progress: (step) => adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step }), }); const adminId = compartmentAssignment.adminId; if (naturalCompartmentId && naturalCompartments) { const oversizedCompartmentSplit = splitOversizedCompartmentMunicipalities(adminId, adminCentersRaw, compartmentAssignment.compartments, prefectureMask, sea, { elevation, slope, ridgeField: boundaryRidgeField, plain, agriculture, basinField, coastalLowland, populationDensity, }, seed + 21900); const changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea); const changedAfterUrbanUnification = lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, 980); const changedAfterAdminEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 6); const compacted = compactWholeCompartmentMunicipalities(adminId, adminCentersRaw, prefectureMask, sea, { populationDensity, plain, slope }); const adminBorders = extractAdminBorderSegments(compacted.adminId, prefectureMask); const actualMunicipalityCount = compacted.activeMunicipalityCount; const adminDebug = { ...compartmentAssignment.debug, sharedNaturalCompartmentLayer: true, skippedLegacyCellCleanupForHierarchy: true, targetMunicipalityCount, actualMunicipalityCount, finalMunicipalityCount: actualMunicipalityCount, candidateSeedCount: adminCentersRaw.length, municipalOfficePointCount: compacted.adminCentersRaw.length, seedCellRevivalCount: 0, survivedSeedCount: compacted.activeMunicipalityCount, pendingSeedCount: 0, absorbedSeedCount: Math.max(0, adminCentersRaw.length - compacted.activeMunicipalityCount), targetNaturalCompartmentCount: targetCompartmentCount, naturalCompartmentCount: compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length, compartmentCount: compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length, changedAfterCompartmentAssignment: compartmentAssignment.debug?.naturalCompartmentCount || 0, changedAfterFinalCompartmentOwnership, changedAfterUrbanUnification, changedAfterAdminEnclaveRepair, changedAfterOversizedCompartmentSplit: oversizedCompartmentSplit.changedCells, oversizedCompartmentMunicipalitiesSplit: oversizedCompartmentSplit.splitMunicipalities, oversizedCompartmentSplitAddedCenters: oversizedCompartmentSplit.addedCenters, oversizedCompartmentSplitMaxArea: oversizedCompartmentSplit.maxArea || 0, finalTinyMunicipalityCount: [...municipalityAreaById(compacted.adminId, prefectureMask, sea).values()].filter((area) => area > 0 && area < 8).length, compartmentBorders: compartmentAssignment.debug?.compartmentBorders || [], borderNaturalBarrierAverage: compartmentAssignment.debug?.finalBorderNaturalBarrierAverage || 0, voronoiLikeRate: compartmentAssignment.debug?.voronoiLikeRateAfter || 0, }; return { adminCentersRaw: compacted.adminCentersRaw, adminId: compacted.adminId, adminBorders, adminDebug, naturalCompartmentId: compartmentAssignment.compartmentId, naturalCompartments: compartmentAssignment.compartments, }; } 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: false, }; 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, }); } const cityCatchmentDebug = enforceCityMunicipalityCatchments(adminId, modernCities, { prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, }); adminDebug.changedAfterCityMunicipalityCatchment = cityCatchmentDebug.changed; adminDebug.protectedCityMunicipalityCount = cityCatchmentDebug.protectedCities; adminDebug.tooSmallCityMunicipalityCountBeforeRepair = cityCatchmentDebug.tooSmall; snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 2); 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); adminDebug.changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea); removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 220); adminDebug.changedAfterPostCompartmentExclaveRemoval = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea); adminDebug.changedAfterUrbanUnification = lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, 980); adminDebug.changedAfterAdminEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 6); adminDebug.changedAfterFinalCompartmentOwnership += enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea); 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, naturalCompartmentId: compartmentAssignment.compartmentId, naturalCompartments: compartmentAssignment.compartments, }; } export function generateAdminLayout(context) { const layout = generateAdminLayoutForMask(context); return { ...layout, ...generatePrefecturesFromMunicipalities(context, layout) }; }