not good but not bad
This commit is contained in:
parent
5c82bfcab7
commit
4f0df3f6c5
11 changed files with 1284 additions and 622 deletions
|
|
@ -1076,125 +1076,6 @@ function buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, r
|
|||
|
||||
export function buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null, options = {}) {
|
||||
return buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse, options);
|
||||
const naturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse);
|
||||
const compartmentId = new Int32Array(SIZE);
|
||||
compartmentId.fill(-1);
|
||||
const cellClass = new Int16Array(SIZE);
|
||||
cellClass.fill(-1);
|
||||
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse);
|
||||
|
||||
const compartments = [];
|
||||
const queue = [];
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (cellClass[i] < 0 || compartmentId[i] >= 0) continue;
|
||||
const id = compartments.length;
|
||||
const startClass = cellClass[i];
|
||||
const cells = [];
|
||||
let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = 0, coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0;
|
||||
let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0;
|
||||
queue.length = 0;
|
||||
queue.push(i);
|
||||
compartmentId[i] = id;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
const [x, y] = xyOf(cur);
|
||||
cells.push(cur);
|
||||
sx += x; sy += y; pop += populationDensity[cur];
|
||||
minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
|
||||
urbanWeight += urbanBoundaryPenalty(cur, populationDensity, landuse);
|
||||
ridgeExposure += ridgeField[cur];
|
||||
riverExposure += river[cur] + flowAccum[cur] * 0.45;
|
||||
coastalExposure += coastalLowland[cur];
|
||||
basinIdentity += basinField[cur];
|
||||
valleyIdentity += valleyField[cur];
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (compartmentId[ni] >= 0 || cellClass[ni] < 0) continue;
|
||||
const edgeBarrier = (naturalBarrierScore[cur] + naturalBarrierScore[ni]) * 0.5;
|
||||
if (!canShareNaturalCompartment(cur, ni, startClass, cellClass[ni], edgeBarrier, river, flowAccum, valleyField, populationDensity, landuse)) continue;
|
||||
compartmentId[ni] = id;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
const area = cells.length;
|
||||
const unit = {
|
||||
id,
|
||||
cells,
|
||||
area,
|
||||
x: sx / area,
|
||||
y: sy / area,
|
||||
classId: startClass,
|
||||
dominantLandscapeClass: startClass,
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
width: maxX - minX + 1,
|
||||
height: maxY - minY + 1,
|
||||
elongation: Math.max(maxX - minX + 1, maxY - minY + 1) / Math.max(1, Math.min(maxX - minX + 1, maxY - minY + 1)),
|
||||
population: pop,
|
||||
urbanWeight: urbanWeight / area,
|
||||
ridgeExposure: ridgeExposure / area,
|
||||
riverExposure: riverExposure / area,
|
||||
coastalExposure: coastalExposure / area,
|
||||
basinIdentity: basinIdentity / area,
|
||||
valleyIdentity: valleyIdentity / area,
|
||||
centerIds: [],
|
||||
adjacent: new Map(),
|
||||
};
|
||||
unit.lowlandFitness = cells.reduce((sum, ci) => sum + lowlandCompartmentFitness(ci, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse), 0) / area;
|
||||
unit.mountainFitness = cells.reduce((sum, ci) => sum + mountainCompartmentFitness(ci, elevation, slope, ridgeField, populationDensity, landuse), 0) / area;
|
||||
compartments.push(unit);
|
||||
}
|
||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
mergeTinyLandscapeUnits(compartmentId, compartments, 12);
|
||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
const targetCount = options.targetCompartmentCount || 0;
|
||||
if (targetCount > 0) {
|
||||
const fields = { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum };
|
||||
const landArea = compartments.reduce((sum, unit) => sum + (unit.area || 0), 0);
|
||||
const maxNaturalCompartmentArea = options.maxNaturalCompartmentArea || Math.max(34, Math.round(landArea / Math.max(1, targetCount) * 1.65));
|
||||
const splitScore = (unit) => {
|
||||
const elongated = Math.max(0, (unit.elongation || 1) - 2.1);
|
||||
const areaPressure = unit.area / Math.max(1, maxNaturalCompartmentArea);
|
||||
const settled = (unit.lowlandFitness || 0) * 0.65 + (unit.urbanWeight || 0) * 0.35;
|
||||
return areaPressure * 2.2 + elongated * 1.4 + settled - (unit.mountainFitness || 0) * 0.20;
|
||||
};
|
||||
let guard = Math.max(targetCount * 4, 80);
|
||||
while (compartments.filter((unit) => unit.area > 0).length < targetCount && guard-- > 0) {
|
||||
const candidates = compartments
|
||||
.filter((unit) => unit.area > 0 && unit.area >= 24 && ((unit.lowlandFitness || 0) > 0.18 || unit.area > maxNaturalCompartmentArea * 1.20 || (unit.elongation || 1) > 2.8))
|
||||
.sort((a, b) => splitScore(b) - splitScore(a));
|
||||
const target = candidates[0];
|
||||
if (!target) break;
|
||||
const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard);
|
||||
if (!newUnit) {
|
||||
target._splitRejected = (target._splitRejected || 0) + 1;
|
||||
target.elongation = Math.max(1, (target.elongation || 1) * 0.72);
|
||||
if (target._splitRejected > 2) target.area = target.cells.length;
|
||||
continue;
|
||||
}
|
||||
compartments.push(newUnit);
|
||||
if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
}
|
||||
|
||||
guard = Math.max(targetCount * 2, 60);
|
||||
while (guard-- > 0) {
|
||||
const target = compartments
|
||||
.filter((unit) => unit.area > 0 && unit.area >= 24 && (unit.area > maxNaturalCompartmentArea * 1.55 || ((unit.elongation || 1) > 3.2 && unit.area > maxNaturalCompartmentArea * 0.85)))
|
||||
.sort((a, b) => splitScore(b) - splitScore(a))[0];
|
||||
if (!target) break;
|
||||
const newUnit = splitOneNaturalCompartment(target, compartments.length, compartmentId, fields, (options.seed || 0) + guard + 991);
|
||||
if (!newUnit) {
|
||||
target.elongation = Math.max(1, (target.elongation || 1) * 0.70);
|
||||
break;
|
||||
}
|
||||
compartments.push(newUnit);
|
||||
if (compartments.filter((unit) => unit.area > 0).length % 12 === 0) rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
}
|
||||
rebuildLandscapeUnitAdjacency(compartmentId, compartments, naturalBarrierScore, prefectureMask, sea);
|
||||
}
|
||||
return { compartmentId, compartments, naturalBarrierScore };
|
||||
}
|
||||
|
||||
function buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse) {
|
||||
|
|
@ -1502,7 +1383,11 @@ export function extractCompartmentBorders(compartmentId, prefectureMask, sea) {
|
|||
|
||||
export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], options = {}) {
|
||||
const progress = typeof options.progress === "function" ? options.progress : null;
|
||||
const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse, options);
|
||||
const sharedCompartments = options.naturalCompartmentId && options.naturalCompartments
|
||||
? { compartmentId: options.naturalCompartmentId, compartments: options.naturalCompartments, naturalBarrierScore: options.naturalBarrierScore || ridgeField }
|
||||
: null;
|
||||
const { compartmentId, compartments, naturalBarrierScore } = sharedCompartments ||
|
||||
buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse, options);
|
||||
progress?.("natural compartments built");
|
||||
const adminId = new Int16Array(SIZE);
|
||||
adminId.fill(-1);
|
||||
|
|
|
|||
19
app.js
19
app.js
|
|
@ -148,7 +148,7 @@ function getStats(map) {
|
|||
["New Towns", countText(map.newTowns)],
|
||||
["Municipalities", map.adminCenters.length],
|
||||
["Admin changed cells", map.adminDebug ? `${map.adminDebug.changedAfterLandscapePartition || 0} partition / ${map.adminDebug.changedAfterSnap || 0} snap` : "-"],
|
||||
["Regional changed cells", map.regionalDebug?.regionalChangedAfterNaturalPartition ?? "-"],
|
||||
["Prefecture source", map.regionalDebug?.prefectureSource ?? "-"],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -201,6 +201,12 @@ function adminName(map, adminId) {
|
|||
return center?.name || (adminId >= 0 ? `Municipality ${adminId + 1}` : "-");
|
||||
}
|
||||
|
||||
function prefectureNameForCell(map, i) {
|
||||
const id = map.prefectureRegionId?.[i] ?? -1;
|
||||
const region = (map.prefectureRegions || []).find((p) => p.id === id);
|
||||
return region?.name || (id >= 0 ? `Prefecture ${id + 1}` : "-");
|
||||
}
|
||||
|
||||
function updateTooltip(event) {
|
||||
if (!state.map || !tooltipEl) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
|
@ -216,6 +222,7 @@ function updateTooltip(event) {
|
|||
const density = state.map.populationDensity?.[i] ?? 0;
|
||||
const lines = [
|
||||
`<strong>${entity ? `${entity.name} / ${entity.kind}` : `${x}, ${y}`}</strong>`,
|
||||
`Prefecture: ${prefectureNameForCell(state.map, i)}`,
|
||||
`Admin: ${adminName(state.map, state.map.adminId?.[i] ?? -1)}`,
|
||||
`Land: ${state.map.sea?.[i] ? "Sea" : landuseName(state.map.landuse?.[i])}`,
|
||||
`Elevation: ${elevation.toFixed(3)} / Slope: ${(state.map.slope?.[i] ?? 0).toFixed(3)}`,
|
||||
|
|
@ -223,8 +230,14 @@ function updateTooltip(event) {
|
|||
];
|
||||
if (entity?.population) lines.splice(1, 0, `Population: ${entity.population.toLocaleString()}`);
|
||||
tooltipEl.innerHTML = lines.join("<br>");
|
||||
tooltipEl.style.left = `${event.clientX - rect.left + 14}px`;
|
||||
tooltipEl.style.top = `${event.clientY - rect.top + 14}px`;
|
||||
const margin = 8;
|
||||
const offset = 14;
|
||||
const maxLeft = Math.max(margin, rect.width - tooltipEl.offsetWidth - margin);
|
||||
const maxTop = Math.max(margin, rect.height - tooltipEl.offsetHeight - margin);
|
||||
const desiredLeft = event.clientX - rect.left + offset;
|
||||
const desiredTop = event.clientY - rect.top + offset;
|
||||
tooltipEl.style.left = `${Math.min(Math.max(margin, desiredLeft), maxLeft)}px`;
|
||||
tooltipEl.style.top = `${Math.min(Math.max(margin, desiredTop), maxTop)}px`;
|
||||
tooltipEl.classList.add("visible");
|
||||
}
|
||||
|
||||
|
|
|
|||
859
mapAdminStage.js
859
mapAdminStage.js
|
|
@ -11,15 +11,6 @@ import {
|
|||
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++;
|
||||
|
|
@ -35,6 +26,326 @@ function municipalityAreaById(adminId, prefectureMask, sea) {
|
|||
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 });
|
||||
const node = nodes.get(id);
|
||||
const [x, y] = xyOf(i);
|
||||
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 / 2300), 5, 12);
|
||||
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 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 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);
|
||||
const 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,
|
||||
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;
|
||||
|
|
@ -604,6 +915,82 @@ function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, c
|
|||
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,
|
||||
|
|
@ -635,6 +1022,8 @@ function generateAdminLayoutForMask({
|
|||
stations,
|
||||
industrialZones,
|
||||
logisticsParks,
|
||||
naturalCompartmentId,
|
||||
naturalCompartments,
|
||||
adminRegionMeta = {},
|
||||
adminProgress = null,
|
||||
}) {
|
||||
|
|
@ -680,9 +1069,45 @@ function generateAdminLayoutForMask({
|
|||
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 changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
|
|
@ -866,6 +1291,14 @@ function generateAdminLayoutForMask({
|
|||
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,
|
||||
|
|
@ -882,6 +1315,9 @@ function generateAdminLayoutForMask({
|
|||
}
|
||||
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);
|
||||
|
||||
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
|
||||
const satelliteAreas = [];
|
||||
|
|
@ -925,407 +1361,18 @@ function generateAdminLayoutForMask({
|
|||
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 connectedMaskComponents(mask, sea, minArea = 1) {
|
||||
const seen = new Uint8Array(SIZE);
|
||||
const components = [];
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (!mask[i] || sea[i] || seen[i]) continue;
|
||||
const queue = [i];
|
||||
const cells = [];
|
||||
seen[i] = 1;
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const cur = queue[head++];
|
||||
cells.push(cur);
|
||||
const [x, y] = xyOf(cur);
|
||||
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (!mask[ni] || sea[ni] || seen[ni]) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
if (cells.length >= minArea) {
|
||||
let minX = MAP_W, minY = MAP_H, maxX = 0, maxY = 0;
|
||||
for (const cell of cells) {
|
||||
const [x, y] = xyOf(cell);
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
components.push({ cells, area: cells.length, minX, minY, maxX, maxY });
|
||||
}
|
||||
}
|
||||
return components.sort((a, b) => b.area - a.area);
|
||||
}
|
||||
|
||||
function maskFromCells(cells) {
|
||||
const mask = new Uint8Array(SIZE);
|
||||
for (const i of cells || []) mask[i] = 1;
|
||||
return mask;
|
||||
}
|
||||
|
||||
|
||||
function splitDisconnectedAdminComponents(adminId, humanMask, sea, centers = [], fields = {}) {
|
||||
let nextId = Math.max(-1, ...adminId) + 1;
|
||||
let splitCount = 0;
|
||||
const ids = [...new Set([...adminId].filter((id, i) => id >= 0 && humanMask[i] && !sea[i]))];
|
||||
for (const id of ids) {
|
||||
const seen = new Uint8Array(SIZE);
|
||||
const components = [];
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (adminId[i] !== id || !humanMask[i] || sea[i] || seen[i]) continue;
|
||||
const cells = [];
|
||||
const queue = [i];
|
||||
seen[i] = 1;
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const cur = queue[head++];
|
||||
cells.push(cur);
|
||||
const [x, y] = xyOf(cur);
|
||||
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (!inside(nx, ny)) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (adminId[ni] !== id || !humanMask[ni] || sea[ni] || seen[ni]) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
components.push(cells);
|
||||
}
|
||||
if (components.length <= 1) continue;
|
||||
components.sort((a, b) => b.length - a.length);
|
||||
for (let c = 1; c < components.length; c++) {
|
||||
const newId = nextId++;
|
||||
let bestI = components[c][0];
|
||||
let bestScore = -INF;
|
||||
for (const i of components[c]) {
|
||||
const score =
|
||||
(fields.populationDensity?.[i] || 0) * 4.0 +
|
||||
(fields.stationInfluence?.[i] || 0) * 0.8 +
|
||||
(fields.roadInfluence?.[i] || 0) * 0.45 +
|
||||
(fields.settlementScore?.[i] || 0) * 0.6 +
|
||||
(fields.plain?.[i] || 0) * 0.2 -
|
||||
(fields.slope?.[i] || 0) * 0.2;
|
||||
if (score > bestScore) { bestScore = score; bestI = i; }
|
||||
}
|
||||
const [x, y] = xyOf(bestI);
|
||||
for (const i of components[c]) adminId[i] = newId;
|
||||
centers[newId] = { ...(centers[id] || {}), x, y, score: bestScore, seedKind: "splitDisconnectedMunicipality", generatedOfficePoint: true, municipalityOffice: true };
|
||||
splitCount++;
|
||||
}
|
||||
}
|
||||
return { adminId, centers, splitDisconnectedMunicipalityCount: splitCount };
|
||||
}
|
||||
|
||||
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];
|
||||
let currentValid = false;
|
||||
let currentScore = -INF;
|
||||
if (current && inside(current.x, current.y)) {
|
||||
const ci = indexOf(current.x, current.y);
|
||||
currentValid = newAdminId[ci] === newId && humanMask[ci] && !sea[ci];
|
||||
if (currentValid) currentScore = (fields.populationDensity?.[ci] || 0) + (fields.stationInfluence?.[ci] || 0) * 0.32 + (fields.roadInfluence?.[ci] || 0) * 0.20;
|
||||
}
|
||||
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 * 4.20 +
|
||||
settlement * 0.72 +
|
||||
urbanBonus * 1.15 +
|
||||
(fields.plain?.[i] || 0) * 0.32 +
|
||||
(fields.basinField?.[i] || 0) * 0.24 +
|
||||
(fields.coastalLowland?.[i] || 0) * 0.18 +
|
||||
(fields.roadInfluence?.[i] || 0) * 0.48 +
|
||||
(fields.stationInfluence?.[i] || 0) * 0.86 -
|
||||
(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; }
|
||||
}
|
||||
if (currentValid && currentScore >= bestScore * 0.92 && (fields.populationDensity?.[indexOf(current.x, current.y)] || 0) >= 0.16) bestI = indexOf(current.x, current.y);
|
||||
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,
|
||||
adminCentersRaw,
|
||||
adminId,
|
||||
adminBorders,
|
||||
adminDebug,
|
||||
naturalCompartmentId: compartmentAssignment.compartmentId,
|
||||
naturalCompartments: compartmentAssignment.compartments,
|
||||
};
|
||||
}
|
||||
|
||||
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 = 650;
|
||||
const minComponentArea = 18;
|
||||
const regionComponents = [];
|
||||
for (const regionId of discoverAdminRegionIds(prefectureMask, prefectureRegionId, sea)) {
|
||||
const baseMask = buildRegionMask(prefectureMask, prefectureRegionId, sea, regionId);
|
||||
const components = connectedMaskComponents(baseMask, sea, minComponentArea);
|
||||
components.forEach((component, componentIndex) => regionComponents.push({ regionId, componentIndex, component, area: component.area }));
|
||||
}
|
||||
const fullComponents = regionComponents.filter((row) => row.area >= minFullAdminRegionArea);
|
||||
|
||||
if (!prefectureRegionId || fullComponents.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;
|
||||
|
||||
const processedCells = new Uint8Array(SIZE);
|
||||
for (const row of fullComponents) {
|
||||
const { regionId, componentIndex, component } = row;
|
||||
const regionMask = maskFromCells(component.cells);
|
||||
const regionArea = component.area;
|
||||
|
||||
const localContext = {
|
||||
...context,
|
||||
seed: (context.seed + regionId * 10007 + componentIndex * 9973) >>> 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: `${regionId}:${componentIndex}`,
|
||||
sourceRegionId: regionId,
|
||||
componentIndex,
|
||||
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;
|
||||
processedCells[i] = 1;
|
||||
const localId = local.adminId?.[i] ?? -1;
|
||||
if (localId >= 0) combinedAdminId[i] = localId + idOffset;
|
||||
}
|
||||
|
||||
perRegion.push({
|
||||
regionId,
|
||||
componentIndex,
|
||||
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 leftoverRows = [];
|
||||
for (const row of regionComponents) {
|
||||
const cells = row.component.cells.filter((i) => !sea[i] && combinedAdminId[i] < 0);
|
||||
if (cells.length) leftoverRows.push({ ...row, cells });
|
||||
}
|
||||
for (const row of leftoverRows) {
|
||||
const { regionId, componentIndex, cells } = row;
|
||||
let bestI = cells[0], bestScore = -INF;
|
||||
for (const i of cells) {
|
||||
const score = (populationDensity?.[i] || 0) * 2.6 + (plain?.[i] || 0) * 0.22 - (slope?.[i] || 0) * 0.20;
|
||||
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, componentIndex, localAdminId: 0, seedKind: "tinyRegionAdminSeed", invisibleLowlandAdminSeed: true });
|
||||
for (const i of cells) {
|
||||
combinedHumanMask[i] = 1;
|
||||
combinedAdminId[i] = id;
|
||||
}
|
||||
perRegion.push({ regionId, componentIndex, area: cells.length, centerCount: 1, municipalityCount: 1, naturalCompartmentCount: 1, targetNaturalCompartmentCount: 1, averageCompartmentArea: cells.length, maxCompartmentArea: cells.length, maxCompartmentElongation: 1, singleCompartmentMunicipalityRatio: 1, tinyRegionFallback: true });
|
||||
}
|
||||
|
||||
const compactFields = {
|
||||
populationDensity,
|
||||
plain,
|
||||
slope,
|
||||
settlementScore: context.settlementScore,
|
||||
landuse: context.landuse,
|
||||
basinField: context.basinField,
|
||||
coastalLowland: context.coastalLowland,
|
||||
roadInfluence: context.roadInfluence,
|
||||
stationInfluence: context.stationInfluence,
|
||||
};
|
||||
let compactedAdmin = compactAdminIdsAndCenters(combinedAdminId, combinedHumanMask, sea, combinedCenters, compactFields);
|
||||
combinedAdminId = compactedAdmin.adminId;
|
||||
combinedCenters = compactedAdmin.adminCenters;
|
||||
const splitDisconnected = splitDisconnectedAdminComponents(combinedAdminId, combinedHumanMask, sea, combinedCenters, compactFields);
|
||||
combinedAdminId = splitDisconnected.adminId;
|
||||
combinedCenters = splitDisconnected.centers;
|
||||
compactedAdmin = compactAdminIdsAndCenters(combinedAdminId, combinedHumanMask, sea, combinedCenters, compactFields);
|
||||
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,
|
||||
minComponentArea,
|
||||
connectedComponentAdmin: true,
|
||||
fullComponentCount: fullComponents.length,
|
||||
leftoverComponentCount: leftoverRows.length,
|
||||
splitDisconnectedMunicipalityCount: splitDisconnected.splitDisconnectedMunicipalityCount,
|
||||
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 };
|
||||
const layout = generateAdminLayoutForMask(context);
|
||||
return { ...layout, ...generatePrefecturesFromMunicipalities(context, layout) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export function generateMapFeatures(seed, terrain) {
|
|||
const i = indexOf(x, y);
|
||||
if (sea[i]) return -1;
|
||||
if (prefectureMask?.[i]) return 0;
|
||||
if (!prefectureRegionId) return 0;
|
||||
const id = prefectureRegionId?.[i];
|
||||
return id !== undefined && id >= 0 ? id : -1;
|
||||
}
|
||||
|
|
@ -704,6 +705,11 @@ export function generateMapFeatures(seed, terrain) {
|
|||
|
||||
function addKernel(grid, p, radius, weight, exponent = 1.7, terrainWeighted = true, combine = "max") {
|
||||
const r = Math.ceil(radius);
|
||||
const angle = hash2(p.x, p.y, seed + 14901) * Math.PI * 2;
|
||||
const stretch = 1.35 + hash2(p.x, p.y, seed + 14902) * 0.85;
|
||||
const squeeze = 0.62 + hash2(p.x, p.y, seed + 14903) * 0.28;
|
||||
const ca = Math.cos(angle);
|
||||
const sa = Math.sin(angle);
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = p.x + dx;
|
||||
|
|
@ -711,9 +717,28 @@ export function generateMapFeatures(seed, terrain) {
|
|||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i]) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const along = (dx * ca + dy * sa) / stretch;
|
||||
const across = (-dx * sa + dy * ca) / squeeze;
|
||||
const baseD = Math.hypot(along, across);
|
||||
const conduit = clamp(
|
||||
plain[i] * 0.18 +
|
||||
valleySettlement[i] * 0.26 +
|
||||
coastalSettlement[i] * 0.16 +
|
||||
roadDensityInfluence[i] * 0.34 +
|
||||
roadInfluence[i] * 0.22 +
|
||||
railInfluence2[i] * 0.26 +
|
||||
stationDensityInfluence[i] * 0.12
|
||||
);
|
||||
const barrier = clamp(
|
||||
slope[i] * 0.62 +
|
||||
ridgeField[i] * 0.74 +
|
||||
Math.max(0, elevation[i] - 0.58) * 0.82 +
|
||||
(river[i] > 0.68 ? 0.60 : river[i] > 0.34 ? 0.22 : 0)
|
||||
);
|
||||
const noise = 0.78 + hash2(x, y, seed + 14910 + Math.round((p.population || 0) / 1000)) * 0.46;
|
||||
const d = baseD * (1.10 - conduit * 0.42 + barrier * 0.62) * noise;
|
||||
if (d > radius) continue;
|
||||
const terrain = terrainWeighted ? clamp(0.24 + developable[i] * 1.00 + valleySettlement[i] * 0.16 + coastalSettlement[i] * 0.10 - slope[i] * 0.20 - ridgeField[i] * 0.12 + roadInfluence[i] * 0.08 + roadDensityInfluence[i] * 0.04 + railInfluence2[i] * 0.06, 0, 1.34) : 1;
|
||||
const terrain = terrainWeighted ? clamp(0.06 + developable[i] * 1.08 + valleySettlement[i] * 0.24 + coastalSettlement[i] * 0.14 + conduit * 0.38 - barrier * 0.70, 0, 1.42) : 1;
|
||||
const v = weight * Math.pow(1 - d / Math.max(1, radius), exponent) * terrain;
|
||||
if (combine === "add") grid[i] = Math.min(3.4, grid[i] + v);
|
||||
else if (v > grid[i]) grid[i] = v;
|
||||
|
|
@ -807,7 +832,8 @@ export function generateMapFeatures(seed, terrain) {
|
|||
ridgeField[i] * 0.020 -
|
||||
highPenaltyDensity * 0.058
|
||||
);
|
||||
ruralDensityFloor[i] = clamp(0.010 + agrarianDensity, 0, elevation[i] > 0.62 || slope[i] > 0.42 ? 0.052 : 0.115);
|
||||
const remoteWilderness = elevation[i] > 0.60 && slope[i] > 0.34 && ridgeField[i] > 0.38 && densityTransport < 0.035 && villageInfluence[i] < 0.025 && townInfluence[i] < 0.025 && cityInfluence[i] < 0.025;
|
||||
ruralDensityFloor[i] = remoteWilderness ? 0 : clamp(agrarianDensity, 0, elevation[i] > 0.62 || slope[i] > 0.42 ? 0.052 : 0.105);
|
||||
populationDensity[i] = clamp(
|
||||
urban * 0.66 +
|
||||
core * 0.46 +
|
||||
|
|
@ -900,9 +926,11 @@ export function generateMapFeatures(seed, terrain) {
|
|||
} else if (lu === LANDUSE.RURAL) {
|
||||
floor = Math.max(floor, clamp(0.012 + ruralSuitability[i] * 0.020 + developable[i] * 0.014 + roadDensityInfluence[i] * 0.024 + stationDensityInfluence[i] * 0.020, 0, 0.070));
|
||||
} else if (lu === LANDUSE.FOREST) {
|
||||
floor = Math.min(floor, 0.032);
|
||||
floor = Math.min(floor, (roadDensityInfluence[i] > 0.04 || villageInfluence[i] > 0.03) ? 0.026 : 0);
|
||||
}
|
||||
populationDensity[i] = clamp(Math.max(populationDensity[i] / maxDensity, floor));
|
||||
const normalized = populationDensity[i] / maxDensity;
|
||||
populationDensity[i] = clamp(Math.max(normalized, floor));
|
||||
if (lu === LANDUSE.FOREST && floor === 0 && populationDensity[i] < 0.012) populationDensity[i] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -474,20 +474,21 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river,
|
|||
}
|
||||
for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0;
|
||||
for (let pass = 0; pass < 4; pass++) repairRegionalTopology(regionId, sea, seeded.centers, anchorMask, 260);
|
||||
|
||||
const displayRegionId = new Int16Array(beforeRegionId);
|
||||
for (let pass = 0; pass < 3; pass++) repairRegionalTopology(displayRegionId, sea, seeded.centers, anchorMask, 200);
|
||||
repairDisconnectedRegionalPrefectures(regionId, sea, seeded.centers, anchorMask);
|
||||
mergeTinyRegionalPrefectures(regionId, sea, seeded.centers, anchorMask, 720);
|
||||
rebalanceOversizedRegionalPrefectures(regionId, sea, seeded.centers, anchorMask, naturalBarrierScore);
|
||||
snapRegionalBoundariesToNaturalFeatures(regionId, sea, anchorMask, naturalBarrierScore, 2);
|
||||
repairFinalRegionalTopology(regionId, sea, seeded.centers, anchorMask, naturalBarrierScore);
|
||||
|
||||
let changed = 0;
|
||||
for (let i = 0; i < SIZE; i++) if (!sea[i] && beforeRegionId[i] !== regionId[i]) changed++;
|
||||
const afterBorderCount = countRegionBorderEdges(regionId, sea);
|
||||
const measuredAfterNaturalAverage = averageRegionBorderBarrier(regionId, sea, naturalBarrierScore);
|
||||
const afterNaturalAverage = Math.max(measuredAfterNaturalAverage, beforeNaturalAverage);
|
||||
const afterNaturalAverage = measuredAfterNaturalAverage;
|
||||
const afterVoronoiLikeRate = regionalVoronoiLikeRate(regionId, seeded.centers, sea, naturalBarrierScore);
|
||||
|
||||
return {
|
||||
regionId,
|
||||
displayRegionId,
|
||||
centers: seeded.centers,
|
||||
naturalBarrierScore,
|
||||
debug: {
|
||||
|
|
@ -498,13 +499,16 @@ export function generateRegionalPrefectures(seed, sea, elevation, slope, river,
|
|||
regionalVoronoiLikeRateAfter: afterVoronoiLikeRate,
|
||||
regionalNaturalBarrierAverageBefore: beforeNaturalAverage,
|
||||
regionalNaturalBarrierAverageAfter: afterNaturalAverage,
|
||||
regionalDisplayBorderCount: countRegionBorderEdges(displayRegionId, sea),
|
||||
regionalDisplayNaturalBarrierAverage: averageRegionBorderBarrier(displayRegionId, sea, naturalBarrierScore),
|
||||
regionalDisplayBorderCount: countRegionBorderEdges(regionId, sea),
|
||||
regionalDisplayNaturalBarrierAverage: averageRegionBorderBarrier(regionId, sea, naturalBarrierScore),
|
||||
regionalCompartmentCount: compartments.filter((unit) => unit.area > 0).length,
|
||||
compartmentCount: compartments.filter((unit) => unit.area > 0).length,
|
||||
changedAfterCompartmentAssignment: changed,
|
||||
borderNaturalBarrierAverage: afterNaturalAverage,
|
||||
voronoiLikeRate: afterVoronoiLikeRate,
|
||||
finalRegionConnectivityMaxComponents: Math.max(0, ...[...new Set([...regionId].filter((id, i) => id >= 0 && !sea[i]))].map((id) => collectRegionComponents(regionId, sea, id).length)),
|
||||
finalRegionalEnclaveCount: countRegionalEnclaves(regionId, sea),
|
||||
finalRegionalMaxAreaShare: maxRegionalAreaShare(regionId, sea),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -537,9 +541,9 @@ function generateRegionalPrefecturesSeedGrowth(seed, sea, elevation, slope, rive
|
|||
}
|
||||
}
|
||||
centers.push(...pickEntities(candidates, {
|
||||
max: 9 + Math.floor(rand(seed, 6101) * 6),
|
||||
minDistance: 22,
|
||||
threshold: 0.38,
|
||||
max: 6 + Math.floor(rand(seed, 6101) * 4),
|
||||
minDistance: 31,
|
||||
threshold: 0.42,
|
||||
seed: seed + 6102,
|
||||
jitter: 0.02,
|
||||
}));
|
||||
|
|
@ -583,6 +587,357 @@ function generateRegionalPrefecturesSeedGrowth(seed, sea, elevation, slope, rive
|
|||
return { regionId, centers };
|
||||
}
|
||||
|
||||
function collectRegionComponents(regionId, sea, id) {
|
||||
const seen = new Uint8Array(SIZE);
|
||||
const components = [];
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
if (seen[i] || sea[i] || regionId[i] !== id) continue;
|
||||
const queue = [i];
|
||||
const cells = [];
|
||||
seen[i] = 1;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
cells.push(cur);
|
||||
const [x, y] = xyOf(cur);
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (seen[ni] || sea[ni] || regionId[ni] !== id) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
components.push(cells);
|
||||
}
|
||||
return components.sort((a, b) => b.length - a.length);
|
||||
}
|
||||
|
||||
function chooseRegionalReassignment(cells, regionId, sea, centers, forbiddenId = -1) {
|
||||
const adjacent = new Map();
|
||||
let sx = 0, sy = 0;
|
||||
for (const i of cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
sx += x; sy += y;
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
const id = regionId[ni];
|
||||
if (sea[ni] || id < 0 || id === forbiddenId) continue;
|
||||
adjacent.set(id, (adjacent.get(id) || 0) + 1);
|
||||
}
|
||||
}
|
||||
let bestId = -1;
|
||||
let bestScore = -INF;
|
||||
const cx = sx / Math.max(1, cells.length);
|
||||
const cy = sy / Math.max(1, cells.length);
|
||||
for (const [id, edge] of adjacent) {
|
||||
const center = centers[id];
|
||||
const d = center ? Math.hypot(center.x - cx, center.y - cy) : 0;
|
||||
const score = edge * 4 - d * 0.04 + (id === 0 ? -1.5 : 0);
|
||||
if (score > bestScore) { bestScore = score; bestId = id; }
|
||||
}
|
||||
if (bestId >= 0) return bestId;
|
||||
for (let id = 0; id < centers.length; id++) {
|
||||
if (id === forbiddenId || !centers[id]) continue;
|
||||
const d = Math.hypot(centers[id].x - cx, centers[id].y - cy);
|
||||
const score = -d + (id === 0 ? -8 : 0);
|
||||
if (score > bestScore) { bestScore = score; bestId = id; }
|
||||
}
|
||||
return bestId;
|
||||
}
|
||||
|
||||
function nearestRegionalReassignmentByLand(cells, regionId, sea, forbiddenId = -1) {
|
||||
const seen = new Uint8Array(SIZE);
|
||||
const queue = [];
|
||||
for (const i of cells) {
|
||||
seen[i] = 1;
|
||||
queue.push(i);
|
||||
}
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
const [x, y] = xyOf(cur);
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (sea[ni] || seen[ni]) continue;
|
||||
const id = regionId[ni];
|
||||
if (id >= 0 && id !== forbiddenId) return id;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function repairDisconnectedRegionalPrefectures(regionId, sea, centers, anchorMask) {
|
||||
let totalChanged = 0;
|
||||
for (let pass = 0; pass < 10; pass++) {
|
||||
let changedThisPass = 0;
|
||||
const ids = [...new Set([...regionId].filter((id, i) => id >= 0 && !sea[i]))].sort((a, b) => a - b);
|
||||
for (const id of ids) {
|
||||
const components = collectRegionComponents(regionId, sea, id);
|
||||
if (components.length <= 1) continue;
|
||||
let keepIndex = 0;
|
||||
if (id === 0) {
|
||||
const anchorIndex = components.findIndex((cells) => cells.some((i) => anchorMask[i]));
|
||||
if (anchorIndex >= 0) keepIndex = anchorIndex;
|
||||
}
|
||||
for (let c = 0; c < components.length; c++) {
|
||||
if (c === keepIndex) continue;
|
||||
const replacement =
|
||||
chooseRegionalReassignment(components[c], regionId, sea, centers, id) ??
|
||||
nearestRegionalReassignmentByLand(components[c], regionId, sea, id);
|
||||
const target = replacement >= 0 ? replacement : nearestRegionalReassignmentByLand(components[c], regionId, sea, id);
|
||||
if (target < 0) continue;
|
||||
for (const i of components[c]) {
|
||||
if (anchorMask[i]) continue;
|
||||
regionId[i] = target;
|
||||
changedThisPass++;
|
||||
}
|
||||
}
|
||||
}
|
||||
totalChanged += changedThisPass;
|
||||
for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0;
|
||||
if (changedThisPass === 0) break;
|
||||
}
|
||||
return totalChanged;
|
||||
}
|
||||
|
||||
function componentTouchesOutside(cells, sea) {
|
||||
for (const i of cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) return true;
|
||||
for (const [nx, ny] of neighbors4(x, y)) if (sea[indexOf(nx, ny)]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function boundaryNeighborIds(cells, regionId, sea, ownId) {
|
||||
const ids = new Set();
|
||||
for (const i of cells) {
|
||||
const [x, y] = xyOf(i);
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
const id = regionId[ni];
|
||||
if (!sea[ni] && id >= 0 && id !== ownId) ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function countRegionalEnclaves(regionId, sea) {
|
||||
let count = 0;
|
||||
const ids = [...new Set([...regionId].filter((id, i) => id >= 0 && !sea[i]))].sort((a, b) => a - b);
|
||||
for (const id of ids) {
|
||||
for (const cells of collectRegionComponents(regionId, sea, id)) {
|
||||
if (componentTouchesOutside(cells, sea)) continue;
|
||||
if (boundaryNeighborIds(cells, regionId, sea, id).size === 1) count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function carveRegionalCorridor(regionId, sea, cells, ownId, enclosingId, naturalBarrierScore) {
|
||||
const best = new Float32Array(SIZE);
|
||||
const cameFrom = new Int32Array(SIZE);
|
||||
best.fill(INF);
|
||||
cameFrom.fill(-1);
|
||||
const heap = new MinHeap();
|
||||
const source = new Uint8Array(SIZE);
|
||||
for (const i of cells) {
|
||||
source[i] = 1;
|
||||
best[i] = 0;
|
||||
heap.push({ i, f: 0 });
|
||||
}
|
||||
let target = -1;
|
||||
while (heap.length) {
|
||||
const cur = heap.pop();
|
||||
if (!cur || cur.f > best[cur.i] + 1e-5) continue;
|
||||
const [x, y] = xyOf(cur.i);
|
||||
if (!source[cur.i]) {
|
||||
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) { target = cur.i; break; }
|
||||
let seaAdjacent = false;
|
||||
let otherAdjacent = false;
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (sea[ni]) seaAdjacent = true;
|
||||
else if (regionId[ni] >= 0 && regionId[ni] !== ownId && regionId[ni] !== enclosingId) otherAdjacent = true;
|
||||
}
|
||||
if (seaAdjacent || otherAdjacent) { target = cur.i; break; }
|
||||
}
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (sea[ni] || regionId[ni] === ownId) continue;
|
||||
const id = regionId[ni];
|
||||
const regionPenalty = id === enclosingId ? 0 : 8;
|
||||
const barrier = naturalBarrierScore?.[ni] || 0;
|
||||
const nd = cur.f + 1 + barrier * 3.2 + regionPenalty;
|
||||
if (nd < best[ni]) {
|
||||
best[ni] = nd;
|
||||
cameFrom[ni] = cur.i;
|
||||
heap.push({ i: ni, f: nd });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target < 0) return 0;
|
||||
let changed = 0;
|
||||
for (let i = target; i >= 0 && regionId[i] !== ownId; i = cameFrom[i]) {
|
||||
if (!sea[i] && regionId[i] !== ownId) {
|
||||
regionId[i] = ownId;
|
||||
changed++;
|
||||
}
|
||||
if (cameFrom[i] < 0) break;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function repairRegionalEnclaves(regionId, sea, centers, anchorMask, naturalBarrierScore) {
|
||||
let changed = 0;
|
||||
const ids = [...new Set([...regionId].filter((id, i) => id >= 0 && !sea[i]))].sort((a, b) => a - b);
|
||||
for (const id of ids) {
|
||||
const components = collectRegionComponents(regionId, sea, id);
|
||||
for (const cells of components) {
|
||||
if (componentTouchesOutside(cells, sea)) continue;
|
||||
const neighbors = boundaryNeighborIds(cells, regionId, sea, id);
|
||||
if (neighbors.size !== 1) continue;
|
||||
const enclosingId = [...neighbors][0];
|
||||
const protectedAnchor = id === 0 && cells.some((i) => anchorMask[i]);
|
||||
if (protectedAnchor) {
|
||||
changed += carveRegionalCorridor(regionId, sea, cells, id, enclosingId, naturalBarrierScore);
|
||||
} else {
|
||||
for (const i of cells) {
|
||||
if (anchorMask[i]) continue;
|
||||
regionId[i] = enclosingId;
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0;
|
||||
return changed;
|
||||
}
|
||||
|
||||
function repairFinalRegionalTopology(regionId, sea, centers, anchorMask, naturalBarrierScore) {
|
||||
let totalChanged = 0;
|
||||
for (let pass = 0; pass < 12; pass++) {
|
||||
const disconnected = repairDisconnectedRegionalPrefectures(regionId, sea, centers, anchorMask);
|
||||
const enclaves = repairRegionalEnclaves(regionId, sea, centers, anchorMask, naturalBarrierScore);
|
||||
totalChanged += disconnected + enclaves;
|
||||
if (disconnected + enclaves === 0) break;
|
||||
}
|
||||
return totalChanged;
|
||||
}
|
||||
|
||||
function regionalAreaById(regionId, sea) {
|
||||
const area = new Map();
|
||||
for (let i = 0; i < SIZE; i++) if (!sea[i] && regionId[i] >= 0) area.set(regionId[i], (area.get(regionId[i]) || 0) + 1);
|
||||
return area;
|
||||
}
|
||||
|
||||
function maxRegionalAreaShare(regionId, sea) {
|
||||
const area = regionalAreaById(regionId, sea);
|
||||
const land = [...area.values()].reduce((sum, value) => sum + value, 0);
|
||||
return land ? Math.max(0, ...area.values()) / land : 0;
|
||||
}
|
||||
|
||||
function canMoveRegionalBoundaryCell(regionId, sea, i, ownId) {
|
||||
const [x, y] = xyOf(i);
|
||||
let ownNeighbors = 0;
|
||||
let otherNeighbors = 0;
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
if (sea[ni]) continue;
|
||||
if (regionId[ni] === ownId) ownNeighbors++;
|
||||
else if (regionId[ni] >= 0) otherNeighbors++;
|
||||
}
|
||||
return ownNeighbors >= 2 && otherNeighbors > 0;
|
||||
}
|
||||
|
||||
function rebalanceOversizedRegionalPrefectures(regionId, sea, centers, anchorMask, naturalBarrierScore) {
|
||||
const minArea = 720;
|
||||
for (let pass = 0; pass < 4; pass++) {
|
||||
const area = regionalAreaById(regionId, sea);
|
||||
const land = [...area.values()].reduce((sum, value) => sum + value, 0);
|
||||
const maxArea = Math.max(minArea * 2, Math.floor(land * 0.32));
|
||||
let changed = 0;
|
||||
const oversized = [...area.entries()].filter(([, value]) => value > maxArea).sort((a, b) => b[1] - a[1]);
|
||||
for (const [id] of oversized) {
|
||||
const candidates = [];
|
||||
for (let y = 1; y < MAP_H - 1; y++) {
|
||||
for (let x = 1; x < MAP_W - 1; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || regionId[i] !== id || anchorMask[i] || !canMoveRegionalBoundaryCell(regionId, sea, i, id)) continue;
|
||||
const counts = new Map();
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
const other = regionId[ni];
|
||||
if (!sea[ni] && other >= 0 && other !== id && (area.get(other) || 0) < maxArea) counts.set(other, (counts.get(other) || 0) + 1);
|
||||
}
|
||||
for (const [other, edge] of counts) {
|
||||
const center = centers[other];
|
||||
const d = center ? Math.hypot(center.x - x, center.y - y) : 0;
|
||||
candidates.push({ i, other, score: edge * 2.0 + (naturalBarrierScore?.[i] || 0) * 1.4 - d * 0.006 });
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort((a, b) => b.score - a.score || a.i - b.i);
|
||||
for (const candidate of candidates) {
|
||||
if ((area.get(id) || 0) <= maxArea) break;
|
||||
if ((area.get(candidate.other) || 0) >= maxArea || regionId[candidate.i] !== id) continue;
|
||||
regionId[candidate.i] = candidate.other;
|
||||
area.set(id, (area.get(id) || 0) - 1);
|
||||
area.set(candidate.other, (area.get(candidate.other) || 0) + 1);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
repairDisconnectedRegionalPrefectures(regionId, sea, centers, anchorMask);
|
||||
if (!changed) break;
|
||||
}
|
||||
}
|
||||
|
||||
function snapRegionalBoundariesToNaturalFeatures(regionId, sea, anchorMask, naturalBarrierScore, passes = 2) {
|
||||
for (let pass = 0; pass < passes; pass++) {
|
||||
const before = new Int16Array(regionId);
|
||||
let changed = 0;
|
||||
for (let y = 1; y < MAP_H - 1; y++) {
|
||||
for (let x = 1; x < MAP_W - 1; x++) {
|
||||
const i = indexOf(x, y);
|
||||
const own = before[i];
|
||||
if (sea[i] || own < 0 || anchorMask[i]) continue;
|
||||
const hereBarrier = naturalBarrierScore?.[i] || 0;
|
||||
if (hereBarrier > 0.54 || !canMoveRegionalBoundaryCell(before, sea, i, own)) continue;
|
||||
const counts = new Map();
|
||||
let bestNeighborBarrier = 0;
|
||||
for (const [nx, ny] of neighbors4(x, y)) {
|
||||
const ni = indexOf(nx, ny);
|
||||
const other = before[ni];
|
||||
if (sea[ni] || other < 0) continue;
|
||||
bestNeighborBarrier = Math.max(bestNeighborBarrier, naturalBarrierScore?.[ni] || 0);
|
||||
if (other !== own) counts.set(other, (counts.get(other) || 0) + 1);
|
||||
}
|
||||
if (counts.size === 0 || bestNeighborBarrier < hereBarrier + 0.18) continue;
|
||||
let target = -1, best = -1;
|
||||
for (const [other, count] of counts) if (count > best) { best = count; target = other; }
|
||||
if (target >= 0 && best >= 2) {
|
||||
regionId[i] = target;
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeTinyRegionalPrefectures(regionId, sea, centers, anchorMask, minArea) {
|
||||
const ids = [...new Set([...regionId].filter((id, i) => id >= 0 && !sea[i]))].sort((a, b) => a - b);
|
||||
for (const id of ids) {
|
||||
if (id === 0) continue;
|
||||
const cells = [];
|
||||
for (let i = 0; i < SIZE; i++) if (!sea[i] && regionId[i] === id) cells.push(i);
|
||||
if (cells.length === 0 || cells.length >= minArea) continue;
|
||||
const replacement = chooseRegionalReassignment(cells, regionId, sea, centers, id);
|
||||
if (replacement < 0) continue;
|
||||
for (const i of cells) if (!anchorMask[i]) regionId[i] = replacement;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRegionalNaturalBarrierScore(sea, elevation, slope, river, ridgeField, flowAccum) {
|
||||
const score = new Float32Array(SIZE);
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
|
|
@ -811,20 +1166,50 @@ function repairRegionalTopology(regionId, sea, centers, anchorMask, maxIslandCel
|
|||
for (let i = 0; i < SIZE; i++) if (anchorMask[i] && !sea[i]) regionId[i] = 0;
|
||||
}
|
||||
|
||||
export function extractRegionBorderSegments(regionId, sea) {
|
||||
export function extractRegionBorderSegments(regionId, sea, options = {}) {
|
||||
const segments = [];
|
||||
const nameById = options.prefectureRegions
|
||||
? new Map(options.prefectureRegions.map((region) => [region.id, region.name]))
|
||||
: null;
|
||||
const fail = (message) => {
|
||||
if (options.throwOnInvalid) throw new Error(message);
|
||||
if (options.logInvalid) console.error(message);
|
||||
};
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (sea[i] || regionId[i] < 0) continue;
|
||||
const a = regionId[i];
|
||||
if (x + 1 < MAP_W && !sea[indexOf(x + 1, y)]) {
|
||||
const b = regionId[indexOf(x + 1, y)];
|
||||
const ni = indexOf(x + 1, y);
|
||||
const b = regionId[ni];
|
||||
if (b >= 0 && a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
|
||||
else if (options.validateSameId && b >= 0 && a === b) fail(`Invalid prefecture border candidate at (${x},${y})/(${x + 1},${y}): both id=${a}, name=${nameById?.get(a) || "-"}`);
|
||||
}
|
||||
if (y + 1 < MAP_H && !sea[indexOf(x, y + 1)]) {
|
||||
const b = regionId[indexOf(x, y + 1)];
|
||||
const ni = indexOf(x, y + 1);
|
||||
const b = regionId[ni];
|
||||
if (b >= 0 && a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
|
||||
else if (options.validateSameId && b >= 0 && a === b) fail(`Invalid prefecture border candidate at (${x},${y})/(${x},${y + 1}): both id=${a}, name=${nameById?.get(a) || "-"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.throwOnInvalid) {
|
||||
for (const segment of segments) {
|
||||
const [[x1, y1], [x2, y2]] = segment;
|
||||
let ax = -1, ay = -1, bx = -1, by = -1;
|
||||
if (x1 === x2) {
|
||||
ax = x1 - 1; bx = x1; ay = by = Math.min(y1, y2);
|
||||
} else if (y1 === y2) {
|
||||
ax = bx = Math.min(x1, x2); ay = y1 - 1; by = y1;
|
||||
}
|
||||
if (!inside(ax, ay) || !inside(bx, by)) continue;
|
||||
const ai = indexOf(ax, ay);
|
||||
const bi = indexOf(bx, by);
|
||||
const a = regionId[ai];
|
||||
const b = regionId[bi];
|
||||
if (sea[ai] || sea[bi] || a < 0 || b < 0 || a === b) {
|
||||
throw new Error(`Invalid emitted prefecture border ${JSON.stringify(segment)} between (${ax},${ay}) id=${a} name=${nameById?.get(a) || "-"} and (${bx},${by}) id=${b} name=${nameById?.get(b) || "-"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
69
mapOutput.js
69
mapOutput.js
|
|
@ -24,6 +24,48 @@ function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) {
|
|||
return `${value}${municipalitySuffixForCenter(center, fields, seed, ordinal)}`;
|
||||
}
|
||||
|
||||
function buildPrefectureRegions(prefectureRegionId, sea, fields, seed, usedNames, nameDebug) {
|
||||
if (!prefectureRegionId) return [];
|
||||
const byId = new Map();
|
||||
for (let i = 0; i < prefectureRegionId.length; i++) {
|
||||
const id = prefectureRegionId[i];
|
||||
if (sea[i] || id < 0) continue;
|
||||
if (!byId.has(id)) byId.set(id, { id, area: 0, sx: 0, sy: 0, cells: [] });
|
||||
const row = byId.get(id);
|
||||
const x = i % MAP_W;
|
||||
const y = Math.floor(i / MAP_W);
|
||||
row.area++;
|
||||
row.sx += x;
|
||||
row.sy += y;
|
||||
row.cells.push(i);
|
||||
}
|
||||
const regions = [];
|
||||
for (const row of [...byId.values()].sort((a, b) => a.id - b.id)) {
|
||||
const cx = row.sx / Math.max(1, row.area);
|
||||
const cy = row.sy / Math.max(1, row.area);
|
||||
let bestI = row.cells[0];
|
||||
let bestScore = -INF;
|
||||
for (const i of row.cells) {
|
||||
const x = i % MAP_W;
|
||||
const y = Math.floor(i / MAP_W);
|
||||
const score =
|
||||
(fields.populationDensity?.[i] || 0) * 1.6 +
|
||||
(fields.plain?.[i] || 0) * 0.28 +
|
||||
(fields.basinField?.[i] || 0) * 0.22 +
|
||||
(fields.coastalLowland?.[i] || 0) * 0.16 -
|
||||
(fields.slope?.[i] || 0) * 0.36 -
|
||||
(fields.ridgeField?.[i] || 0) * 0.30 -
|
||||
Math.hypot(x - cx, y - cy) * 0.08;
|
||||
if (score > bestScore) { bestScore = score; bestI = i; }
|
||||
}
|
||||
const x = bestI % MAP_W;
|
||||
const y = Math.floor(bestI / MAP_W);
|
||||
regions.push({ id: row.id, x, y, area: row.area, kind: row.id === 0 ? "Current Prefecture" : "Prefecture", labelPriorityBase: 900 + Math.sqrt(row.area) });
|
||||
}
|
||||
return attachIdsAndNames(regions, "prefecture", seed + 41000, null, fields, usedNames, nameDebug)
|
||||
.map((region, index) => ({ ...region, featureId: region.id, id: regions[index].id, labelName: region.name }));
|
||||
}
|
||||
|
||||
export function finishMapOutput({
|
||||
seed,
|
||||
options,
|
||||
|
|
@ -65,10 +107,7 @@ export function finishMapOutput({
|
|||
smallStreams,
|
||||
prefectureMask,
|
||||
prefectureBorder,
|
||||
prefectureRegionId,
|
||||
regionalDebug,
|
||||
terrainDebug,
|
||||
regionalPrefectureBorders,
|
||||
} = terrain;
|
||||
|
||||
const {
|
||||
|
|
@ -115,6 +154,12 @@ export function finishMapOutput({
|
|||
adminId,
|
||||
adminBorders,
|
||||
adminDebug,
|
||||
prefectureRegionId,
|
||||
municipalityToPrefectureId,
|
||||
regionalPrefectureBorders: adminRegionalPrefectureBorders,
|
||||
regionalDebug,
|
||||
naturalCompartmentId,
|
||||
naturalCompartments,
|
||||
} = admin;
|
||||
|
||||
let villages = inputVillages;
|
||||
|
|
@ -206,6 +251,8 @@ export function finishMapOutput({
|
|||
if (best) {
|
||||
center.representativeFeatureId = best.id;
|
||||
center.representativeFeatureName = best.name;
|
||||
center.canonicalSettlementId = best.id;
|
||||
center.canonicalSettlementName = best.name;
|
||||
center.municipalityRootName = best.name;
|
||||
} else {
|
||||
center.municipalityRootName = center.generatedMunicipalityName;
|
||||
|
|
@ -215,12 +262,12 @@ export function finishMapOutput({
|
|||
for (const [index, center] of adminCenters.entries()) {
|
||||
center.adminNumericId = index;
|
||||
center.municipalityId = index;
|
||||
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index);
|
||||
let candidate = center.canonicalSettlementName || municipalityNameFromRoot(center.municipalityRootName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index);
|
||||
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index);
|
||||
if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
|
||||
if (!center.canonicalSettlementName && usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
|
||||
candidate = generated;
|
||||
}
|
||||
if (usedAdminNames.has(candidate)) {
|
||||
if (!center.canonicalSettlementName && usedAdminNames.has(candidate)) {
|
||||
const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, index);
|
||||
const base = String(center.generatedMunicipalityName || center.municipalityRootName || center.name || `自治${index + 1}`).replace(/[市町村区]$/u, "");
|
||||
candidate = `${base}${index + 1}${suffix}`;
|
||||
|
|
@ -231,6 +278,12 @@ export function finishMapOutput({
|
|||
usedAdminNames.add(center.name);
|
||||
}
|
||||
nameDebug.maxDerivedPerBase = 0;
|
||||
const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug);
|
||||
const regionalPrefectureBorders = adminRegionalPrefectureBorders || [];
|
||||
if (regionalDebug) {
|
||||
regionalDebug.finalRegionalPrefectureBorderCount = regionalPrefectureBorders.length;
|
||||
regionalDebug.regionalPrefectureBordersRebuiltFromFinalId = true;
|
||||
}
|
||||
|
||||
outputProgress("final package");
|
||||
const entitiesForNames = [
|
||||
|
|
@ -260,6 +313,8 @@ export function finishMapOutput({
|
|||
humanRegionMask,
|
||||
prefectureBorder,
|
||||
prefectureRegionId,
|
||||
municipalityToPrefectureId,
|
||||
prefectureRegions,
|
||||
regionalDebug,
|
||||
terrainDebug,
|
||||
regionalPrefectureBorders,
|
||||
|
|
@ -289,6 +344,8 @@ export function finishMapOutput({
|
|||
alluvialFanField,
|
||||
deltaField,
|
||||
naturalBarrierScore,
|
||||
naturalCompartmentId,
|
||||
naturalCompartments,
|
||||
villages,
|
||||
ports,
|
||||
crossings,
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export function generateMap(seedInput = 114514, options = {}) {
|
|||
const generationTimings = [];
|
||||
const stage = (key, label, fn) => timedStage(generationTimings, options, key, label, fn);
|
||||
|
||||
const terrain = stage("terrain", "Terrain, rivers, and prefecture regions", () => generateTerrainAndRivers(seed));
|
||||
const terrain = stage("terrain", "Terrain, rivers, and natural compartments", () => generateTerrainAndRivers(seed));
|
||||
const {
|
||||
elevation,
|
||||
slope,
|
||||
|
|
@ -64,8 +64,9 @@ export function generateMap(seedInput = 114514, options = {}) {
|
|||
flowAccum,
|
||||
naturalBarrierScore,
|
||||
prefectureMask,
|
||||
prefectureRegionId,
|
||||
adminPrefectureRegionId,
|
||||
landMask,
|
||||
naturalCompartmentId,
|
||||
naturalCompartments,
|
||||
} = terrain;
|
||||
|
||||
const features = stage("human", "Human geography and transport", () => generateMapFeatures(seed, terrain));
|
||||
|
|
@ -89,7 +90,7 @@ export function generateMap(seedInput = 114514, options = {}) {
|
|||
} = features;
|
||||
|
||||
const admin = stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
|
||||
seed, prefectureMask, prefectureRegionId: adminPrefectureRegionId || prefectureRegionId, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||
seed, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
|
||||
adminProgress: (event) => options?.onProgress?.({
|
||||
...event,
|
||||
|
|
@ -122,7 +123,7 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
|
|||
const generationTimings = [];
|
||||
const stage = (key, label, fn) => timedStageAsync(generationTimings, options, key, label, fn);
|
||||
|
||||
const terrain = await stage("terrain", "Terrain, rivers, and prefecture regions", () => generateTerrainAndRivers(seed));
|
||||
const terrain = await stage("terrain", "Terrain, rivers, and natural compartments", () => generateTerrainAndRivers(seed));
|
||||
const {
|
||||
elevation,
|
||||
slope,
|
||||
|
|
@ -137,8 +138,9 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
|
|||
flowAccum,
|
||||
naturalBarrierScore,
|
||||
prefectureMask,
|
||||
prefectureRegionId,
|
||||
adminPrefectureRegionId,
|
||||
landMask,
|
||||
naturalCompartmentId,
|
||||
naturalCompartments,
|
||||
} = terrain;
|
||||
|
||||
const features = await stage("human", "Human geography and transport", () => generateMapFeatures(seed, terrain));
|
||||
|
|
@ -162,7 +164,7 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
|
|||
} = features;
|
||||
|
||||
const admin = await stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
|
||||
seed, prefectureMask, prefectureRegionId: adminPrefectureRegionId || prefectureRegionId, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||
seed, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
|
||||
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
|
||||
adminProgress: (event) => options?.onProgress?.({
|
||||
...event,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, createMapFields, fbm, hash2, indexOf, inside, lerp, pickEntities, rand, smoothstep, valueNoise, xyOf } from "./mapUtils.js";
|
||||
import {
|
||||
extractMaskBorder,
|
||||
extractRegionBorderSegments,
|
||||
generateRegionalPrefectures,
|
||||
makePrefectureMask,
|
||||
neighbors8,
|
||||
} from "./mapGeneratorHelpers.js";
|
||||
import { buildNaturalCompartments } from "./adminRegions.js";
|
||||
|
||||
const ASPECT = MAP_W / MAP_H;
|
||||
const SQRT2 = Math.SQRT2;
|
||||
|
|
@ -128,10 +127,10 @@ function ridgeContribution(px, py, ridge, seed) {
|
|||
const along = Math.abs(u / Math.max(0.001, half));
|
||||
if (along >= 1.22) return 0;
|
||||
const taper = smoothstep(1 - clamp((along - 0.68) / 0.54));
|
||||
const wobble = (valueNoise((u + ridge.phase) * 720, (py + ridge.phase) * 720, seed + ridge.seedOffset, 14) - 0.5) * ridge.width * ridge.wobble;
|
||||
const wobble = (valueNoise((u + ridge.phase) * 720, (v + ridge.phase * 0.37) * 980, seed + ridge.seedOffset, 14) - 0.5) * ridge.width * ridge.wobble;
|
||||
const cross = Math.abs(v + wobble);
|
||||
const core = Math.exp(-Math.pow(cross / Math.max(0.0008, ridge.width), 2.0));
|
||||
const serration = 0.82 + 0.36 * valueNoise((px + ridge.phase) * 900, (py - ridge.phase) * 900, seed + ridge.seedOffset + 71, 7.5);
|
||||
const serration = 0.82 + 0.36 * valueNoise((u + ridge.phase) * 900, (v - ridge.phase * 0.41) * 1200, seed + ridge.seedOffset + 71, 7.5);
|
||||
return ridge.height * core * taper * serration;
|
||||
}
|
||||
|
||||
|
|
@ -169,13 +168,13 @@ const TERRAIN_TYPES = [
|
|||
mountainOffsetRange: [0.47, 0.53],
|
||||
baseHeightRange: [0.74, 1.10],
|
||||
primaryLengthRange: [0.76, 0.96],
|
||||
primaryWidthRange: [0.17, 0.30],
|
||||
primaryWidthRange: [0.13, 0.22],
|
||||
systemCountRange: [12, 16],
|
||||
beltCountRange: [3, 4],
|
||||
angleSpread: 0.14,
|
||||
crossSpread: 0.54,
|
||||
crossSpread: 0.38,
|
||||
lengthScale: 1.22,
|
||||
widthScale: 1.16,
|
||||
widthScale: 0.92,
|
||||
heightScale: 1.24,
|
||||
coastStrength: 0.90,
|
||||
plainBiasRange: [0.16, 0.34],
|
||||
|
|
@ -445,7 +444,7 @@ function buildMountainSystems(template, seed) {
|
|||
y: clamp(0.50 + Math.sin(centralAngle) * centralAlong + Math.sin(centralAngle + Math.PI / 2) * centralCross, 0.12, 0.88),
|
||||
angle: centralAngle,
|
||||
length: (0.78 + rand(seed, 3333) * 0.18) * lengthScale,
|
||||
width: (0.17 + rand(seed, 3334) * 0.11) * widthScale,
|
||||
width: (0.12 + rand(seed, 3334) * 0.07) * widthScale,
|
||||
height: template.mountainBaseHeight * heightScale * (0.58 + rand(seed, 3335) * 0.18),
|
||||
scratchCount: Math.round(20 + rand(seed, 3336) * 10),
|
||||
massifness: clamp((template.mountainMassifness ?? 0.16) * 0.55),
|
||||
|
|
@ -462,7 +461,7 @@ function buildMountainSystems(template, seed) {
|
|||
y: clamp(0.50 + Math.sin(centralAngle) * (centralAlong + side * 0.18) + Math.sin(centralAngle + Math.PI / 2) * (centralCross + side * 0.028), 0.10, 0.90),
|
||||
angle: centralAngle + (rand(seed, 3341) - 0.5) * 0.08,
|
||||
length: (0.48 + rand(seed, 3342) * 0.20) * lengthScale,
|
||||
width: (0.11 + rand(seed, 3343) * 0.08) * widthScale,
|
||||
width: (0.075 + rand(seed, 3343) * 0.055) * widthScale,
|
||||
height: template.mountainBaseHeight * heightScale * (0.38 + rand(seed, 3344) * 0.16),
|
||||
scratchCount: Math.round(12 + rand(seed, 3345) * 8),
|
||||
massifness: clamp((template.mountainMassifness ?? 0.16) * 0.65),
|
||||
|
|
@ -970,9 +969,24 @@ export function generateTerrainAndRivers(seed) {
|
|||
branchRidgeField[i] = clamp(branchRidgeField[i] + r * 5.0);
|
||||
arcSpineField[i] = clamp(Math.max(arcSpineField[i], r * 4.6));
|
||||
}
|
||||
const macro = (fbm(x * terrainTemplate.macroNoiseScale * 48, y * terrainTemplate.macroNoiseScale * 48, seed + 500) - 0.5) * 2;
|
||||
let macro;
|
||||
let scratch;
|
||||
if (terrainTemplate.terrainType === "tohoku_spine") {
|
||||
const dx = (px - 0.5) * ASPECT;
|
||||
const dy = py - 0.5;
|
||||
const { u, v } = rotate(dx, dy, terrainTemplate.mountainAngle);
|
||||
const warp = (valueNoise(x * 0.50, y * 0.50, seed + 504, 28) - 0.5) * 18;
|
||||
macro = (fbm(u * 520 + warp, v * 1850 - warp * 0.35, seed + 500) - 0.5) * 2;
|
||||
scratch = (fbm(u * 920 + warp * 0.8, v * 2300 + warp * 0.25, seed + 502) - 0.5) * 2;
|
||||
const passBreak = clamp((valueNoise(u * 760 + 17, v * 1800 - 11, seed + 505, 18) - 0.62) * 2.6);
|
||||
const lateralBranch = clamp((valueNoise(v * 1700 - 3, u * 540 + 5, seed + 506, 16) - 0.54) * 2.1);
|
||||
e += lateralBranch * mountainMaskMax * 0.022;
|
||||
e -= passBreak * mountainMaskMax * 0.052;
|
||||
} else {
|
||||
macro = (fbm(x * terrainTemplate.macroNoiseScale * 48, y * terrainTemplate.macroNoiseScale * 48, seed + 500) - 0.5) * 2;
|
||||
scratch = (fbm(x * 2.2, y * 2.2, seed + 502) - 0.5) * 2;
|
||||
}
|
||||
const global = (valueNoise(x * 0.23, y * 0.23, seed + 501, 38) - 0.5) * 2;
|
||||
const scratch = (fbm(x * 2.2, y * 2.2, seed + 502) - 0.5) * 2;
|
||||
e += macro * terrainTemplate.macroNoiseStrength * (0.38 + mountainMaskMax * 0.80);
|
||||
e += global * 0.020;
|
||||
e += scratch * terrainTemplate.scratchNoiseStrength * mountainMaskMax;
|
||||
|
|
@ -997,11 +1011,16 @@ export function generateTerrainAndRivers(seed) {
|
|||
deriveFields(seed, terrainTemplate, fields, seaLevel);
|
||||
|
||||
const prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river);
|
||||
const regional = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask);
|
||||
const prefectureRegionId = regional.regionId;
|
||||
const adminPrefectureRegionId = regional.displayRegionId || regional.regionId;
|
||||
const regionalDebug = regional.debug;
|
||||
const regionalPrefectureBorders = extractRegionBorderSegments(adminPrefectureRegionId, sea);
|
||||
const landMask = new Uint8Array(SIZE);
|
||||
for (let i = 0; i < SIZE; i++) landMask[i] = sea[i] ? 0 : 1;
|
||||
const zeroDensity = new Float32Array(SIZE);
|
||||
const zeroLanduse = new Int8Array(SIZE);
|
||||
const natural = buildNaturalCompartments(
|
||||
landMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum,
|
||||
null, plain, agriculture, zeroDensity, zeroLanduse,
|
||||
{ seed: seed + 17003, targetCompartmentCount: clamp(Math.round((SIZE - sea.reduce((sum, value) => sum + value, 0)) / 30), 80, 520) }
|
||||
);
|
||||
const sharedNaturalBarrierScore = natural.naturalBarrierScore || naturalBarrierScore;
|
||||
const prefectureBorder = extractMaskBorder(prefectureMask, sea);
|
||||
|
||||
let landCount = 0;
|
||||
|
|
@ -1065,17 +1084,16 @@ export function generateTerrainAndRivers(seed) {
|
|||
depositionalLowland,
|
||||
alluvialFanField,
|
||||
deltaField,
|
||||
naturalBarrierScore,
|
||||
naturalBarrierScore: sharedNaturalBarrierScore,
|
||||
portSuitability,
|
||||
crossingSuitability,
|
||||
passSuitability,
|
||||
prefectureMask,
|
||||
landMask,
|
||||
prefectureBorder,
|
||||
prefectureRegionId,
|
||||
adminPrefectureRegionId,
|
||||
regionalDebug,
|
||||
naturalCompartmentId: natural.compartmentId,
|
||||
naturalCompartments: natural.compartments,
|
||||
terrainDebug,
|
||||
regionalPrefectureBorders,
|
||||
riverPaths,
|
||||
mainRivers,
|
||||
tributaryRivers,
|
||||
|
|
|
|||
31
names.js
31
names.js
|
|
@ -1,5 +1,7 @@
|
|||
import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js";
|
||||
|
||||
export const CUSTOM_NAME_LIST = ["加茂", "春日", "新庄", "常盤", "二軒屋", "三軒家", "追分", "清水", "曙", "暁", "旭", "鳥羽", "長門", "吉野", "吉田", "観音寺",];
|
||||
|
||||
export const NAME_KANJI_POOLS = {
|
||||
modifiers: [
|
||||
"大", "小", "上", "下", "中", "奥", "脇",
|
||||
|
|
@ -10,20 +12,20 @@ export const NAME_KANJI_POOLS = {
|
|||
"奥", "前", "後", "内", "外",
|
||||
"美", "吉", "福", "幸", "徳",
|
||||
"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万",
|
||||
"霞", "朝", "日", "天", "晴",
|
||||
"霞", "朝", "日", "天",
|
||||
"土", "砂", "石", "岩",
|
||||
"丑", "卯", "辰", "巳", "酉",
|
||||
"早", "安", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌",
|
||||
"早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌",
|
||||
],
|
||||
|
||||
inlandTerrain: [
|
||||
"山", "野", "荒", "野", "沢",
|
||||
"森", "林", "岡", "丘", "坂",
|
||||
"峰", "峠", "嶺", "尾", "平", "坪", "延", "燧",
|
||||
"峰", "峠", "嶺", "尾", "平", "坪", "延",
|
||||
"窪", "久", "迫", "久保", "玖保", "漥", "佐古", "作古",
|
||||
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生",
|
||||
"聡", "郷", "里",
|
||||
"馬", "鹿", "亀", "鷲", "鷹", "鶴", "竜", "龍", "牛", "鳥",
|
||||
"馬", "鹿", "亀", "鷲", "鷹", "鶴", "鴨", "竜", "龍", "牛", "鳥",
|
||||
"湯",
|
||||
],
|
||||
|
||||
|
|
@ -31,8 +33,8 @@ export const NAME_KANJI_POOLS = {
|
|||
"川", "河", "江", "瀬", "淵", "渕",
|
||||
"池", "沼", "泉", "井",
|
||||
"滝", "梅", "沢", "澤", "谷", "津",
|
||||
"水", "清", "渡", "橋", "堀",
|
||||
"溝", "浦"
|
||||
"水", "渡", "橋", "堀",
|
||||
"溝", "浦", "渚"
|
||||
],
|
||||
|
||||
coastalTerrain: [
|
||||
|
|
@ -50,8 +52,8 @@ export const NAME_KANJI_POOLS = {
|
|||
"菅", "榎", "椿", "桐", "柳",
|
||||
"橘", "柏", "槙", "柿", "桃",
|
||||
"梨", "桑", "麻", "芦", "茅",
|
||||
"粟", "稲", "稗", "米", "飯", "糠",
|
||||
"榊", "楢", "檜", "椎", "柚", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑"
|
||||
"粟", "稲", "稗", "米", "飯", "糠", "茜", "葵",
|
||||
"榊", "楢", "檜", "椎", "柚", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑", "瓜"
|
||||
],
|
||||
|
||||
postfixes: [
|
||||
|
|
@ -80,14 +82,14 @@ export const NAME_KANJI_POOLS = {
|
|||
"甲", "信", "越", "備", "能",
|
||||
"薩", "隠", "美", "三", "若",
|
||||
"遠", "近", "能", "加", "賀", "度",
|
||||
"越", "淡", "壱", "衣", "古", "彦", "多", "志", "布", "治"
|
||||
"越", "淡", "壱", "衣", "古", "彦", "多", "志", "布", "治", "加茂",
|
||||
],
|
||||
|
||||
archaicSuffixes: [
|
||||
"井", "羽", "江", "恵", "尾", "於",
|
||||
"賀", "鹿", "喜", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子",
|
||||
"佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇",
|
||||
"多", "太", "知", "津", "豆", "土",
|
||||
"多", "太", "知", "津", "豆", "土", "登",
|
||||
"那", "奈", "名", "仁", "尼", "根", "乃", "能",
|
||||
"波", "布", "夫", "戸", "保", "穂",
|
||||
"間", "磨", "摩", "馬", "見", "牟", "武", "目", "女", "毛", "裳", "茂",
|
||||
|
|
@ -101,15 +103,14 @@ export const NAME_KANJI_POOLS = {
|
|||
"居", "前", "中", "後", "波",
|
||||
"勢", "渡", "城", "紫", "野", "度",
|
||||
"津", "島", "信", "登", "賀", "志",
|
||||
"良", "美", "智", "茂", "代", "古", "麻", "彦", "比古", "子"
|
||||
"良", "美", "智", "茂", "代", "古", "麻", "彦", "比古", "子", "加茂",
|
||||
],
|
||||
|
||||
settlementWords: [
|
||||
"里", "郷", "村", "町", "宿", "垣", "坪", "軒",
|
||||
"里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣",
|
||||
"庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂",
|
||||
"城", "館", "屋", "家", "所",
|
||||
"市", "場", "関", "地蔵", "辻", "角", "堰",
|
||||
"ヶ沢", "ヶ谷", "ヶ浜", "ヶ崎", "ヶ島", "ヶ浦", "ヶ津", "ヶ丘",
|
||||
]
|
||||
};
|
||||
|
||||
|
|
@ -282,10 +283,6 @@ export const NAME_TEMPLATE_WEIGHTS = {
|
|||
},
|
||||
};
|
||||
|
||||
// Add preferred reusable place names here. Each generated entity has a
|
||||
// deterministic chance to use one before falling back to template kanji.
|
||||
export const CUSTOM_NAME_LIST = [];
|
||||
|
||||
// Legacy export kept only so older imports do not fail.
|
||||
export const NAME_PARTS = {};
|
||||
|
||||
|
|
|
|||
48
renderer.js
48
renderer.js
|
|
@ -618,6 +618,34 @@ function drawDebugCells(ctx, map, field, color) {
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
function prefectureRegionColor(id) {
|
||||
const palette = [
|
||||
[234, 220, 214], [218, 232, 218], [218, 224, 238], [238, 232, 208],
|
||||
[232, 218, 232], [214, 232, 234], [235, 224, 216], [222, 236, 210],
|
||||
];
|
||||
return palette[Math.abs(id) % palette.length];
|
||||
}
|
||||
|
||||
function drawPrefectureRegionFill(ctx, map, mode) {
|
||||
if (!["all", "admin", "admin-debug", "borders-debug"].includes(mode)) return;
|
||||
const ids = map.prefectureRegionId;
|
||||
if (!ids) return;
|
||||
const alpha = mode === "borders-debug" ? 0.34 : mode === "admin-debug" ? 0.26 : 0.18;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
const id = ids[i];
|
||||
if (map.sea[i] || id < 0) continue;
|
||||
const [r, g, b] = prefectureRegionColor(id);
|
||||
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
||||
ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function dot(ctx, p, radius, fill, stroke = "white") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x * CELL_SIZE + CELL_SIZE / 2, p.y * CELL_SIZE + CELL_SIZE / 2, radius, 0, Math.PI * 2);
|
||||
|
|
@ -677,8 +705,8 @@ function drawLabels(ctx, points, limit = Infinity) {
|
|||
}
|
||||
|
||||
function drawScaleBar(ctx) {
|
||||
const kmPerCell = 2;
|
||||
const targetKm = 20;
|
||||
const kmPerCell = 1;
|
||||
const targetKm = 50;
|
||||
const lengthCells = Math.max(8, Math.round(targetKm / kmPerCell));
|
||||
const lengthPx = lengthCells * CELL_SIZE;
|
||||
const margin = 14;
|
||||
|
|
@ -789,6 +817,9 @@ export function drawMap(canvas, map, options) {
|
|||
const showAdmin = ["admin", "all", "admin-debug", "borders-debug"].includes(mode);
|
||||
|
||||
// 3. Borders
|
||||
const showPrefectureRegions = ["all", "admin", "admin-debug", "borders-debug"].includes(mode);
|
||||
if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode);
|
||||
|
||||
if (showAdmin && map.adminBorders) {
|
||||
drawVectorSegments(ctx, map.adminBorders, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, map.adminBorders, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||
|
|
@ -801,14 +832,15 @@ export function drawMap(canvas, map, options) {
|
|||
for (const p of map.adminDebug?.lowlandAdminSeeds || []) dot(ctx, p, 3.2, "rgba(255,255,255,0.9)", "rgba(40,150,95,0.95)");
|
||||
}
|
||||
|
||||
const showPrefectureRegions = ["all", "admin", "admin-debug", "borders-debug"].includes(mode);
|
||||
if (showPrefectureRegions && map.regionalPrefectureBorders) {
|
||||
drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(255, 255, 255, 0.90)", 4.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, map.regionalPrefectureBorders, "rgba(82, 60, 102, 0.96)", 1.8, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
}
|
||||
|
||||
drawVectorSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
if (!showPrefectureRegions) {
|
||||
drawVectorSegments(ctx, map.prefectureBorder, "rgba(255, 255, 255, 0.95)", 5.0, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, map.prefectureBorder, "rgba(110, 90, 110, 1)", 2.2, true, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
|
||||
}
|
||||
|
||||
if (!showFeatures) return;
|
||||
|
||||
|
|
@ -881,13 +913,14 @@ export function drawMap(canvas, map, options) {
|
|||
}
|
||||
|
||||
if (showLabels) {
|
||||
const prefectureLabels = showPrefectureRegions ? (map.prefectureRegions || []).map((p) => ({ ...p, labelPriorityBase: p.labelPriorityBase || 900 })) : [];
|
||||
if (mode === "admin") {
|
||||
drawLabels(ctx, map.adminCenters || [], Infinity);
|
||||
drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity);
|
||||
drawScaleBar(ctx);
|
||||
return;
|
||||
}
|
||||
if (mode === "admin-debug" || mode === "borders-debug") {
|
||||
drawLabels(ctx, map.adminCenters || [], Infinity);
|
||||
drawLabels(ctx, [...prefectureLabels, ...(map.adminCenters || [])], Infinity);
|
||||
drawScaleBar(ctx);
|
||||
return;
|
||||
}
|
||||
|
|
@ -895,6 +928,7 @@ export function drawMap(canvas, map, options) {
|
|||
? (map.markets || []).filter((p) => (p.population || 0) >= 25000 && !(map.modernCities || []).some((c) => c.x === p.x && c.y === p.y)).map((p) => ({ ...p, labelPriorityBase: 120 }))
|
||||
: [];
|
||||
const important = [
|
||||
...prefectureLabels,
|
||||
...map.modernCities,
|
||||
...map.ports,
|
||||
...(map.satelliteCities || []),
|
||||
|
|
|
|||
228
test.js
228
test.js
|
|
@ -15,11 +15,15 @@ const result = document.getElementById("result");
|
|||
const logLines = [];
|
||||
let failed = 0;
|
||||
|
||||
const [namesSource, mapGeneratorSource, mapOutputSource, rendererSource, testSource] = await Promise.all([
|
||||
const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rendererSource, appSource, mapPipelineSource, mapAdminStageSource, testSource] = await Promise.all([
|
||||
fetch("./names.js").then((response) => response.text()),
|
||||
fetch("./mapGenerator.js").then((response) => response.text()),
|
||||
fetch("./mapOutput.js").then((response) => response.text()),
|
||||
fetch("./mapTerrain.js").then((response) => response.text()),
|
||||
fetch("./renderer.js").then((response) => response.text()),
|
||||
fetch("./app.js").then((response) => response.text()),
|
||||
fetch("./mapPipeline.js").then((response) => response.text()),
|
||||
fetch("./mapAdminStage.js").then((response) => response.text()),
|
||||
fetch("./test.js").then((response) => response.text()),
|
||||
]);
|
||||
|
||||
|
|
@ -193,10 +197,13 @@ function regionalComponentMetrics(map) {
|
|||
const ids = new Set([...map.prefectureRegionId].filter((id, i) => id >= 0 && !map.sea[i]));
|
||||
const seen = new Uint8Array(MAP_W * MAP_H);
|
||||
let maxComponents = 0;
|
||||
const areas = [];
|
||||
for (const id of ids) {
|
||||
seen.fill(0);
|
||||
let comps = 0;
|
||||
let area = 0;
|
||||
for (let i = 0; i < map.prefectureRegionId.length; i++) {
|
||||
if (!map.sea[i] && map.prefectureRegionId[i] === id) area++;
|
||||
if (seen[i] || map.sea[i] || map.prefectureRegionId[i] !== id) continue;
|
||||
comps++;
|
||||
const queue = [i];
|
||||
|
|
@ -217,8 +224,167 @@ function regionalComponentMetrics(map) {
|
|||
}
|
||||
}
|
||||
maxComponents = Math.max(maxComponents, comps);
|
||||
areas.push(area);
|
||||
}
|
||||
return { regionCount: ids.size, maxComponents };
|
||||
areas.sort((a, b) => a - b);
|
||||
const medianArea = areas.length ? areas[Math.floor(areas.length / 2)] : 0;
|
||||
const minArea = areas.length ? areas[0] : 0;
|
||||
const tinyCount = areas.filter((area) => area < 520).length;
|
||||
return { regionCount: ids.size, maxComponents, minArea, medianArea, tinyCount, areas };
|
||||
}
|
||||
|
||||
function regionalBorderMetrics(map) {
|
||||
let invalidSame = 0;
|
||||
let expected = 0;
|
||||
const ids = map.prefectureRegionId;
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (map.sea[i] || ids[i] < 0) continue;
|
||||
if (x + 1 < MAP_W) {
|
||||
const ni = indexOf(x + 1, y);
|
||||
if (!map.sea[ni] && ids[ni] >= 0 && ids[ni] !== ids[i]) expected++;
|
||||
}
|
||||
if (y + 1 < MAP_H) {
|
||||
const ni = indexOf(x, y + 1);
|
||||
if (!map.sea[ni] && ids[ni] >= 0 && ids[ni] !== ids[i]) expected++;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const segment of map.regionalPrefectureBorders || []) {
|
||||
const [[x1, y1], [x2, y2]] = segment;
|
||||
let a = -1, b = -1;
|
||||
if (x1 === x2) {
|
||||
const x = x1;
|
||||
const y = Math.min(y1, y2);
|
||||
if (x > 0 && x < MAP_W && y >= 0 && y < MAP_H) {
|
||||
a = ids[indexOf(x - 1, y)];
|
||||
b = ids[indexOf(x, y)];
|
||||
}
|
||||
} else if (y1 === y2) {
|
||||
const x = Math.min(x1, x2);
|
||||
const y = y1;
|
||||
if (y > 0 && y < MAP_H && x >= 0 && x < MAP_W) {
|
||||
a = ids[indexOf(x, y - 1)];
|
||||
b = ids[indexOf(x, y)];
|
||||
}
|
||||
}
|
||||
if (a < 0 || b < 0 || a === b) invalidSame++;
|
||||
}
|
||||
return { invalidSame, expected, actual: (map.regionalPrefectureBorders || []).length };
|
||||
}
|
||||
|
||||
function borderHierarchyViolations(map) {
|
||||
let prefectureCutsMunicipality = 0;
|
||||
let municipalityCutsCompartment = 0;
|
||||
const compId = map.naturalCompartmentId;
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const i = indexOf(x, y);
|
||||
if (map.sea[i]) continue;
|
||||
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
|
||||
if (nx >= MAP_W || ny >= MAP_H) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (map.sea[ni]) continue;
|
||||
if (map.prefectureRegionId[i] !== map.prefectureRegionId[ni] && map.adminId[i] === map.adminId[ni]) prefectureCutsMunicipality++;
|
||||
if (map.adminId[i] !== map.adminId[ni] && compId && compId[i] === compId[ni]) municipalityCutsCompartment++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { prefectureCutsMunicipality, municipalityCutsCompartment };
|
||||
}
|
||||
|
||||
function longStraightLowBarrierSegments(map, segments, minRun = 18) {
|
||||
const runs = new Map();
|
||||
for (const seg of segments || []) {
|
||||
const [[x1, y1], [x2, y2]] = seg;
|
||||
const vertical = x1 === x2;
|
||||
const key = vertical ? `v:${x1}` : `h:${y1}`;
|
||||
const pos = vertical ? Math.min(y1, y2) : Math.min(x1, x2);
|
||||
if (!runs.has(key)) runs.set(key, []);
|
||||
runs.get(key).push({ pos, seg });
|
||||
}
|
||||
let bad = 0;
|
||||
for (const rows of runs.values()) {
|
||||
rows.sort((a, b) => a.pos - b.pos);
|
||||
let start = 0;
|
||||
for (let k = 1; k <= rows.length; k++) {
|
||||
if (k < rows.length && rows[k].pos <= rows[k - 1].pos + 1.01) continue;
|
||||
const run = rows.slice(start, k);
|
||||
if (run.length >= minRun) {
|
||||
const natural = run.reduce((sum, row) => {
|
||||
const [[x1, y1], [x2, y2]] = row.seg;
|
||||
const sx = Math.min(Math.max(0, Math.floor((x1 + x2) / 2)), MAP_W - 1);
|
||||
const sy = Math.min(Math.max(0, Math.floor((y1 + y2) / 2)), MAP_H - 1);
|
||||
return sum + (map.naturalBarrierScore?.[indexOf(sx, sy)] || 0);
|
||||
}, 0) / run.length;
|
||||
if (natural < 0.18) bad++;
|
||||
}
|
||||
start = k;
|
||||
}
|
||||
}
|
||||
return bad;
|
||||
}
|
||||
|
||||
function regionalEnclaveCount(map) {
|
||||
const ids = map.prefectureRegionId;
|
||||
const regionIds = [...new Set([...ids].filter((id, i) => id >= 0 && !map.sea[i]))];
|
||||
let enclaves = 0;
|
||||
for (const id of regionIds) {
|
||||
const seen = new Uint8Array(MAP_W * MAP_H);
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
if (seen[i] || map.sea[i] || ids[i] !== id) continue;
|
||||
const queue = [i];
|
||||
const cells = [];
|
||||
let touchesOutside = false;
|
||||
const neighbors = new Set();
|
||||
seen[i] = 1;
|
||||
for (let q = 0; q < queue.length; q++) {
|
||||
const cur = queue[q];
|
||||
cells.push(cur);
|
||||
const x = cur % MAP_W;
|
||||
const y = Math.floor(cur / MAP_W);
|
||||
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true;
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
|
||||
const ni = indexOf(nx, ny);
|
||||
if (map.sea[ni]) {
|
||||
touchesOutside = true;
|
||||
continue;
|
||||
}
|
||||
if (ids[ni] !== id && ids[ni] >= 0) neighbors.add(ids[ni]);
|
||||
if (seen[ni] || ids[ni] !== id) continue;
|
||||
seen[ni] = 1;
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
if (cells.length && !touchesOutside && neighbors.size === 1) enclaves++;
|
||||
}
|
||||
}
|
||||
return enclaves;
|
||||
}
|
||||
|
||||
function cityMunicipalityAreaMetrics(map) {
|
||||
const areaById = new Map();
|
||||
for (let i = 0; i < map.adminId.length; i++) {
|
||||
const id = map.adminId[i];
|
||||
if (id >= 0 && !map.sea[i]) areaById.set(id, (areaById.get(id) || 0) + 1);
|
||||
}
|
||||
const rows = (map.modernCities || [])
|
||||
.filter((city) => (city.population || 0) >= 95000 && city.insidePrefecture && !map.sea[indexOf(city.x, city.y)])
|
||||
.map((city) => {
|
||||
const admin = map.adminId[indexOf(city.x, city.y)];
|
||||
const minArea = Math.min((city.population || 0) >= 450000 ? 780 : 520, Math.max(130, 95 + Math.sqrt(city.population || 0) * 0.72 + (city.urbanFootprintCells || 0) * 0.42));
|
||||
return { city, admin, area: areaById.get(admin) || 0, minArea };
|
||||
});
|
||||
return { rows, tooSmall: rows.filter((row) => row.area + 1e-6 < row.minArea * 0.82) };
|
||||
}
|
||||
|
||||
function prefectureNameForTest(map, i) {
|
||||
const id = map.prefectureRegionId?.[i] ?? -1;
|
||||
return (map.prefectureRegions || []).find((region) => region.id === id)?.name || "";
|
||||
}
|
||||
|
||||
function meanField(map, fieldName, predicate) {
|
||||
|
|
@ -532,14 +698,29 @@ try {
|
|||
assert(["east-west", "north-south", "diagonal"].includes(map.terrainTemplate.coastAxis) && map.terrainTemplate.coastSides?.length === 2, "paired coast template parameters are exposed");
|
||||
assert(map.settlementCluster.length === size, "settlement cluster field matches map size");
|
||||
assert(map.prefectureRegionId.length === size && Array.isArray(map.regionalPrefectureBorders), "neighbor prefecture regions exist");
|
||||
assert(map.regionalDebug && Number.isFinite(map.regionalDebug.regionalChangedAfterNaturalPartition), "regional changed-cell debug exists");
|
||||
assert(map.regionalDebug.regionalChangedAfterNaturalPartition > 0, "regional natural partition changes region cells");
|
||||
assert(map.regionalDebug.regionalBorderCountBefore > 0 && map.regionalDebug.regionalBorderCountAfter > 0, "regional border counts are tracked");
|
||||
assert(map.regionalDebug.regionalNaturalBarrierAverageAfter >= map.regionalDebug.regionalNaturalBarrierAverageBefore - 0.08, "regional border natural-barrier affinity does not degrade meaningfully");
|
||||
assert(map.regionalDebug.regionalVoronoiLikeRateAfter <= map.regionalDebug.regionalVoronoiLikeRateBefore + 0.22, "regional weak Voronoi-like border rate stays bounded");
|
||||
assert(map.regionalDebug.compartmentCount > 0 && map.regionalDebug.changedAfterCompartmentAssignment > 0, "regional compartment assignment debug is available");
|
||||
assert(Number.isFinite(map.regionalDebug.borderNaturalBarrierAverage) && Number.isFinite(map.regionalDebug.voronoiLikeRate), "regional natural-border aliases are exposed");
|
||||
assert(regionalMetrics.regionCount >= 4 && regionalMetrics.maxComponents <= 5, "regional prefecture regions remain connected enough for display");
|
||||
assert(map.prefectureRegionId.length === size, "final prefecture id field matches map size");
|
||||
assert(map.naturalCompartmentId?.length === size && Array.isArray(map.naturalCompartments), "shared natural compartments are exposed");
|
||||
assert(map.adminDebug?.naturalCompartmentCount > 0 && map.adminDebug?.finalMunicipalityCount > 0, "natural compartments are generated before municipalities");
|
||||
assert(map.regionalDebug?.prefecturesGeneratedAfterMunicipalities === true && map.regionalDebug?.prefectureSource === "municipality-boundary-union", "prefectures are generated from final municipalities");
|
||||
assert(regionalMetrics.regionCount >= 4 && regionalMetrics.maxComponents === 1, "each non-sea prefecture region is connected after repair");
|
||||
assert(regionalEnclaveCount(map) === 0, "final prefecture regions contain no one-region enclosed enclaves");
|
||||
const regionalBorders = regionalBorderMetrics(map);
|
||||
const hierarchyViolations = borderHierarchyViolations(map);
|
||||
assert(hierarchyViolations.prefectureCutsMunicipality === 0, "no prefecture border cuts through a municipality");
|
||||
assert(hierarchyViolations.municipalityCutsCompartment === 0, "no municipality border cuts through a natural compartment");
|
||||
assert(regionalBorders.invalidSame === 0 && regionalBorders.actual === regionalBorders.expected, "rendered prefecture borders separate different final prefecture ids only");
|
||||
assert(map.regionalDebug.finalRegionalPrefectureBorderCount === map.regionalPrefectureBorders.length, "regional prefecture borders are final output borders");
|
||||
assert(!mapTerrainSource.includes("generateRegionalPrefectures") && !mapPipelineSource.includes("prefectureRegionId, sea") && mapAdminStageSource.includes("generatePrefecturesFromMunicipalities"), "administrative order is natural compartments to municipalities to prefectures");
|
||||
assert(![mapTerrainSource, mapPipelineSource, mapAdminStageSource, mapOutputSource, rendererSource, appSource].some((source) => /displayRegionId|adminPrefectureRegionId/.test(source)), "prefecture pipeline does not use display/admin-prefecture id aliases");
|
||||
assert(!("maritimePrefectureBorders" in map), "maritime prefecture borders are not emitted");
|
||||
assert(!mapOutputSource.includes("maritimePrefectureBorders") && !rendererSource.includes("maritimePrefectureBorders"), "maritime prefecture borders are not generated or rendered");
|
||||
assert(rendererSource.includes("drawPrefectureRegionFill") && rendererSource.includes("map.prefectureRegionId"), "prefecture fill renderer uses final prefecture id source");
|
||||
assert(regionalMetrics.tinyCount <= Math.max(1, Math.floor(regionalMetrics.regionCount * 0.12)) && regionalMetrics.medianArea >= 1200 && regionalMetrics.minArea >= 520, "regional prefectures avoid excessive tiny slivers");
|
||||
assert(Array.isArray(map.prefectureRegions) && map.prefectureRegions.length === regionalMetrics.regionCount, "prefecture region metadata exists for every region");
|
||||
assert(map.prefectureRegions.every((region) => region.name && Number.isFinite(region.x) && Number.isFinite(region.y) && region.area > 0 && map.prefectureRegionId[indexOf(region.x, region.y)] === region.id), "every prefecture region has a name and valid label point");
|
||||
assert(map.regionalDebug.finalRegionalMaxAreaShare < 0.38, "no single prefecture dominates regional land area");
|
||||
assert(longStraightLowBarrierSegments(map, map.regionalPrefectureBorders, 22) === 0, "prefecture borders avoid long straight low-barrier cuts");
|
||||
assert(longStraightLowBarrierSegments(map, map.adminBorders, 20) === 0, "municipality borders avoid long straight low-barrier cuts");
|
||||
assert(Array.isArray(map.tributaryRivers) && Array.isArray(map.smallStreams), "river hierarchy arrays exist");
|
||||
assert(Array.isArray(map.icAccessRoads), "IC access road array exists");
|
||||
assert(Array.isArray(map.satelliteCities), "satelliteCities is an array");
|
||||
|
|
@ -626,6 +807,7 @@ try {
|
|||
assert(maxPopulation / Math.max(1, minPopulation) > 3, "city populations vary strongly");
|
||||
assert(map.totalPopulation >= cityPopulations.reduce((sum, value) => sum + value, 0), "total population includes city and satellite populations");
|
||||
assert(Math.max(...map.populationDensity) > 0.9, "population density is normalized and populated");
|
||||
assert([...map.populationDensity].some((value, i) => value === 0 && !map.sea[i]), "valid land cells can retain exactly zero population density");
|
||||
assert(elevationStdDev > 0.18, "terrain relief has sufficient contrast");
|
||||
assert(maxCoastalElevationStep < 0.12, "coastline and elevation do not create cliff artifacts");
|
||||
assert(railExpressHighMountainCells === 0, "railways and expressways avoid huge mountain cells");
|
||||
|
|
@ -641,6 +823,11 @@ try {
|
|||
assert(map.adminCenters.some((item) => item.representativeFeatureName), "municipal centers keep representative feature metadata when available");
|
||||
assert(map.adminCenters.every((item) => Array.from(String(item.name)).length >= 2), "municipal center names are not one-character labels");
|
||||
assert(map.adminCenters.every((item) => !(item.representativeFeatureName && String(item.name).startsWith(item.representativeFeatureName) && Array.from(String(item.name)).length === Array.from(String(item.representativeFeatureName)).length + 1)), "municipal names avoid dangling one-kanji suffix fallback");
|
||||
const cityMunicipalityMetrics = cityMunicipalityAreaMetrics(map);
|
||||
assert(cityMunicipalityMetrics.tooSmall.length === 0, "meaningful populated cities keep population-scaled municipality area");
|
||||
const hoverCell = map.prefectureRegions.find((region) => region.area > 0);
|
||||
assert(hoverCell && prefectureNameForTest(map, indexOf(hoverCell.x, hoverCell.y)) === hoverCell.name && appSource.includes("Prefecture:") && appSource.includes("prefectureNameForCell"), "tooltip can resolve prefecture name for a hovered cell");
|
||||
assert(appSource.includes("maxLeft") && appSource.includes("maxTop"), "tooltip position is clamped inside map container");
|
||||
const adminNameDuplicateRatio = map.adminCenters.length ? 1 - new Set(map.adminCenters.map((item) => item.name)).size / map.adminCenters.length : 0;
|
||||
assert(adminNameDuplicateRatio < 0.22, "duplicate municipal names stay low");
|
||||
assert(map.adminDebug.naturalCompartmentCount > adminMetrics.municipalityCount, "natural compartments are finer than municipalities");
|
||||
|
|
@ -684,7 +871,10 @@ try {
|
|||
assert(JSON.stringify(againA.entitiesForNames.map((item) => [item.id, item.name])) === JSON.stringify(againB.entitiesForNames.map((item) => [item.id, item.name])), "generated names are deterministic for the same seed");
|
||||
assert(JSON.stringify(againA.adminCenters.map((item) => [item.id, item.x, item.y, item.name])) === JSON.stringify(againB.adminCenters.map((item) => [item.id, item.x, item.y, item.name])), "municipal centers are deterministic for the same seed");
|
||||
assert(JSON.stringify([...againA.adminId]) === JSON.stringify([...againB.adminId]), "municipal adminId snapping is deterministic for the same seed");
|
||||
assert(JSON.stringify([...againA.naturalCompartmentId]) === JSON.stringify([...againB.naturalCompartmentId]), "natural compartments are deterministic for the same seed");
|
||||
assert(JSON.stringify([...againA.prefectureRegionId]) === JSON.stringify([...againB.prefectureRegionId]), "regional prefecture ids are deterministic for the same seed");
|
||||
assert(JSON.stringify([...againA.municipalityToPrefectureId]) === JSON.stringify([...againB.municipalityToPrefectureId]), "municipality-to-prefecture ids are deterministic for the same seed");
|
||||
assert(JSON.stringify(againA.regionalPrefectureBorders) === JSON.stringify(againB.regionalPrefectureBorders), "regional prefecture borders are deterministic for the same seed");
|
||||
assert(JSON.stringify(againA.adminDebug) === JSON.stringify(againB.adminDebug), "admin debug metrics are deterministic for the same seed");
|
||||
assert(JSON.stringify(againA.regionalDebug) === JSON.stringify(againB.regionalDebug), "regional debug metrics are deterministic for the same seed");
|
||||
assert(JSON.stringify(againA.transportDebug) === JSON.stringify(againB.transportDebug), "transport debug metrics are deterministic for the same seed");
|
||||
|
|
@ -699,6 +889,9 @@ try {
|
|||
const capitalNames = capitalNameMaps.map((seeded) => seeded.prefecturalCapital?.name).filter(Boolean);
|
||||
assert(new Set(capitalNames).size > 1, "prefectural capital names vary across seeds");
|
||||
assert(capitalNames.some((name) => name !== blockedCapitalName), "prefectural capital is not always the repeated custom name");
|
||||
const semeMap = generateMap(8363712);
|
||||
const semeAdmin = (semeMap.adminCenters || []).find((center) => center.canonicalSettlementName);
|
||||
assert(!semeAdmin || semeAdmin.name === semeAdmin.canonicalSettlementName, "seed 8363712: municipality label uses canonical settlement name");
|
||||
for (const [n, seeded] of capitalNameMaps.entries()) {
|
||||
const seedValue = [114514, 12345, 54321, 777, 999][n];
|
||||
const metrics = terrainCoreMetrics(seeded);
|
||||
|
|
@ -769,10 +962,14 @@ try {
|
|||
assert(invalidRegionCells === 0, `seed ${seed}: every regional land cell has a valid regionId`);
|
||||
assert(seeded.adminBorders.length > 0, `seed ${seed}: municipal borders exist`);
|
||||
assert(seeded.regionalPrefectureBorders.length > 0, `seed ${seed}: regional prefecture borders exist`);
|
||||
assert(seeded.regionalDebug?.regionalChangedAfterNaturalPartition > 0, `seed ${seed}: regional natural partition changes cells`);
|
||||
assert(seeded.regionalDebug.regionalNaturalBarrierAverageAfter >= seeded.regionalDebug.regionalNaturalBarrierAverageBefore - 0.10, `seed ${seed}: regional border natural affinity is stable`);
|
||||
assert(seeded.regionalDebug.regionalVoronoiLikeRateAfter <= seeded.regionalDebug.regionalVoronoiLikeRateBefore + 0.25, `seed ${seed}: regional Voronoi-like rate is bounded`);
|
||||
assert(seededRegional.maxComponents <= 5, `seed ${seed}: regional regions remain connected enough`);
|
||||
assert(seeded.regionalDebug?.prefecturesGeneratedAfterMunicipalities === true, `seed ${seed}: prefectures are generated after municipalities`);
|
||||
assert(seeded.regionalDebug?.prefectureSource === "municipality-boundary-union", `seed ${seed}: prefecture borders are municipality boundary unions`);
|
||||
assert(seededRegional.maxComponents === 1, `seed ${seed}: every final regional prefecture is connected`);
|
||||
assert(regionalEnclaveCount(seeded) === 0, `seed ${seed}: final regional prefectures have no one-region enclosed enclaves`);
|
||||
assert(regionalBorderMetrics(seeded).invalidSame === 0, `seed ${seed}: regional borders separate final prefecture ids`);
|
||||
const seededHierarchy = borderHierarchyViolations(seeded);
|
||||
assert(seededHierarchy.prefectureCutsMunicipality === 0, `seed ${seed}: prefecture borders do not cut municipalities`);
|
||||
assert(seededHierarchy.municipalityCutsCompartment === 0, `seed ${seed}: municipal borders do not cut natural compartments`);
|
||||
assert(seeded.adminDebug && seeded.adminDebug.compartmentCount > 0, `seed ${seed}: natural compartments are built`);
|
||||
assert(seeded.adminDebug.naturalCompartmentCount > metrics.municipalityCount, `seed ${seed}: compartments are finer than municipalities`);
|
||||
assert(seeded.adminDebug.targetMunicipalityCount >= 20 && seeded.adminDebug.actualMunicipalityCount >= 18, `seed ${seed}: municipality count is dense enough`);
|
||||
|
|
@ -780,8 +977,7 @@ try {
|
|||
assert(seeded.adminDebug.finalTinyMunicipalityCount <= Math.max(3, Math.ceil(seeded.adminDebug.finalMunicipalityCount * 0.18)), `seed ${seed}: tiny final municipalities are limited`);
|
||||
assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0, `seed ${seed}: compartment assignment is not a no-op`);
|
||||
assert(Array.isArray(seeded.adminDebug.compartmentBorders) && seeded.adminDebug.compartmentBorders.length > seeded.adminBorders.length, `seed ${seed}: compartment border debug exists`);
|
||||
assert(seeded.regionalDebug?.changedAfterCompartmentAssignment > 0, `seed ${seed}: regional compartment assignment changes cells`);
|
||||
assert(seeded.regionalDebug?.borderNaturalBarrierAverage > 0.12, `seed ${seed}: regional borders have natural barrier affinity`);
|
||||
assert(seeded.regionalDebug?.municipalityGraphNodeCount >= metrics.municipalityCount, `seed ${seed}: prefecture graph is based on municipalities`);
|
||||
assert(seeded.adminDebug.changedAfterCompartmentAssignment > 0 || seeded.adminDebug.changedAfterLandscapePartition > 0 || seeded.adminDebug.changedAfterSnap > 0, `seed ${seed}: municipal compartment or terrain passes change cells`);
|
||||
assert(seededSatellites.largeTooSmall.length === 0, `seed ${seed}: large satellites are not tiny independent municipalities`);
|
||||
assert(seededSatellites.independent.length < 3 || seededSatellites.small.length / seededSatellites.independent.length <= 0.35, `seed ${seed}: tiny satellite municipalities remain uncommon`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue