This commit is contained in:
33333-33333 2026-05-29 15:49:09 +09:00
commit 27ceb6568a
7 changed files with 940 additions and 132 deletions

7
app.js
View file

@ -732,7 +732,6 @@ function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36, adminId = nul
const d = Math.hypot(center.x - x, center.y - y);
if (d < bestD) { best = center; bestD = d; }
}
if (!best && adminId != null && adminId >= 0) return nearestNamedAdminCenter(map, cellIndex, maxDistance, null);
return best;
}
@ -764,7 +763,9 @@ function prefectureNameForCell(map, i) {
const region = (map.prefectureRegions || []).find((p) => hasNumericId(p, id, PREFECTURE_ID_KEYS));
const regionName = firstUsableText(region, PREFECTURE_NAME_KEYS);
if (regionName) return regionName;
const center = nearestNamedAdminCenter(map, i, 90);
const adminId = map.adminId?.[i] ?? -1;
const mappedPref = adminId >= 0 ? map.municipalityToPrefectureId?.[adminId] ?? -1 : -1;
const center = mappedPref === id ? nearestNamedAdminCenter(map, i, 90, adminId) : null;
const fromCenter = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]);
return fromCenter || (id >= 0 ? "Unnamed prefecture" : "-");
}
@ -930,7 +931,7 @@ async function generateSelectedPatch() {
const human = result.humanGeography;
const humanText = human?.ok ? ` / human: ${human.modernCities || 0} cities, ${human.ports || 0} ports, ${human.villages || 0} villages, ${(human.roadConnectorsCreated || 0) + (human.railwayConnectorsCreated || 0)} connectors, admin ${human.adminCellsReassigned || 0}, invalid ports ${human.invalidPortsRemoved || 0}` : "";
if (progressStageEl) progressStageEl.textContent = `Patch generated: ${result.label} / variant ${result.variant ?? variant} / mode ${result.patchGenerationMode || "legacy-full-pipeline"} / core ${formatRectSize(result.rects.coreRect)} / write ${formatRectSize(result.rects.writeRect)} / terrain ${result.updatedCells.toLocaleString()} cells / coast ${result.coastCellsChanged || 0} / natural ${result.naturalRegionsUpdated || 0}${humanText}`;
renderTimingRows([]);
renderTimingRows(result.patchTimings || []);
window.setTimeout(() => setProgressVisible(false), 900);
} catch (error) {
if (progressStageEl) progressStageEl.textContent = `Patch failed: ${error?.message || error}`;

265
mapMunicipalCoherence.js Normal file
View file

@ -0,0 +1,265 @@
import { INF, MAP_H, MAP_W } from "./mapUtils.js";
const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId"];
function coordIndex(width, height, x, y) {
if (x < 0 || y < 0 || x >= width || y >= height) return -1;
return y * width + x;
}
function numericAdminId(point) {
for (const key of ADMIN_ID_KEYS) {
const value = point?.[key];
if (Number.isFinite(value) && value >= 0) return Math.floor(value);
}
return -1;
}
function fieldValue(fields, name, i) {
return fields?.[name]?.[i] || 0;
}
function bestCellScore(fields, i) {
return fieldValue(fields, "populationDensity", i) * 3.0
+ fieldValue(fields, "plain", i) * 0.32
+ fieldValue(fields, "agriculture", i) * 0.16
- fieldValue(fields, "slope", i) * 0.30
- fieldValue(fields, "ridgeField", i) * 0.18;
}
function buildMunicipalStats({ adminId, prefectureRegionId, sea, fields = {}, width = MAP_W, height = MAP_H }) {
const stats = new Map();
for (let i = 0; i < adminId.length; i++) {
const id = adminId[i];
if (!Number.isFinite(id) || id < 0 || sea?.[i]) continue;
const row = stats.get(id) || { id, area: 0, sx: 0, sy: 0, bestI: -1, bestScore: -INF, prefVotes: new Map() };
const x = i % width;
const y = Math.floor(i / width);
row.area++;
row.sx += x;
row.sy += y;
const pref = prefectureRegionId?.[i] ?? -1;
if (pref >= 0) row.prefVotes.set(pref, (row.prefVotes.get(pref) || 0) + 1);
const score = bestCellScore(fields, i);
if (score > row.bestScore) {
row.bestScore = score;
row.bestI = i;
}
stats.set(id, row);
}
for (const row of stats.values()) {
let bestPref = -1;
let bestVotes = -1;
for (const [pref, count] of row.prefVotes) {
if (count > bestVotes || (count === bestVotes && pref < bestPref)) {
bestPref = pref;
bestVotes = count;
}
}
row.prefectureRegionId = bestPref;
row.x = row.bestI >= 0 ? row.bestI % width : Math.round(row.sx / Math.max(1, row.area));
row.y = row.bestI >= 0 ? Math.floor(row.bestI / width) : Math.round(row.sy / Math.max(1, row.area));
}
return stats;
}
function pointFieldCoord(point, pointOffsetX, pointOffsetY) {
return {
x: Math.round((point?.x || 0) + pointOffsetX),
y: Math.round((point?.y || 0) + pointOffsetY),
};
}
function centerQuality(center, id, stat, context) {
if (!center) return -INF;
const { adminId, sea, width, height, pointOffsetX, pointOffsetY } = context;
const p = pointFieldCoord(center, pointOffsetX, pointOffsetY);
const i = coordIndex(width, height, p.x, p.y);
const ownsCell = i >= 0 && !sea?.[i] && adminId?.[i] === id;
return (ownsCell ? 100000 : 0)
+ (center.name ? 5000 : 0)
+ (center.representativeFeatureName || center.canonicalSettlementName ? 1200 : 0)
+ (center.generatedOfficePoint ? -200 : 0)
- Math.hypot(p.x - stat.x, p.y - stat.y);
}
function normalizeCenter(center, id, stat, context, generated = false) {
const { pointOffsetX, pointOffsetY, fields = {}, seed = 0 } = context;
const out = {
...(center || {}),
x: stat.x - pointOffsetX,
y: stat.y - pointOffsetY,
adminId: id,
adminNumericId: id,
municipalityId: id,
prefectureRegionId: stat.prefectureRegionId,
municipalArea: stat.area,
insidePrefecture: true,
};
if (generated) {
out.generatedOfficePoint = true;
out.seedKind ||= "coherenceFallbackMunicipalityOffice";
out.kind ||= "Municipal Center";
out.generatedMunicipalityName ||= `自治${id + 1}`;
out.name ||= out.generatedMunicipalityName;
out.labelName ||= out.generatedMunicipalityName;
out.municipalityName ||= out.generatedMunicipalityName;
}
const i = coordIndex(context.width, context.height, stat.x, stat.y);
if (i >= 0) {
out.officePopulationDensity = fields.populationDensity?.[i] || 0;
out.officeLanduse = fields.landuse?.[i] ?? out.officeLanduse;
}
return out;
}
export function reconcileMunicipalMetadata({
adminId,
municipalityId = null,
prefectureRegionId = null,
sea = null,
adminCenters = [],
municipalityToPrefectureId = null,
fields = {},
width = MAP_W,
height = MAP_H,
pointOffsetX = 0,
pointOffsetY = 0,
seed = 0,
} = {}) {
if (!adminId) return { adminCenters: adminCenters || [], municipalityToPrefectureId, stats: new Map(), debug: { activeMunicipalities: 0 } };
const stats = buildMunicipalStats({ adminId, prefectureRegionId, sea, fields, width, height });
if (municipalityId) {
for (let i = 0; i < adminId.length; i++) municipalityId[i] = sea?.[i] ? -1 : (adminId[i] >= 0 ? adminId[i] : -1);
}
const byId = new Map();
let ghostCentersRemoved = 0;
let centersMovedToOwnedCells = 0;
for (const [index, center] of (adminCenters || []).entries()) {
if (!center) continue;
const explicitId = numericAdminId(center);
const id = explicitId >= 0 ? explicitId : (stats.has(index) ? index : -1);
const stat = stats.get(id);
if (!stat) {
ghostCentersRemoved++;
continue;
}
const current = byId.get(id);
if (!current || centerQuality(center, id, stat, { adminId, sea, width, height, pointOffsetX, pointOffsetY }) > centerQuality(current, id, stat, { adminId, sea, width, height, pointOffsetX, pointOffsetY })) {
byId.set(id, center);
}
}
let fallbackCentersAdded = 0;
const nextCenters = [];
for (const [id, stat] of [...stats.entries()].sort((a, b) => a[0] - b[0])) {
const existing = byId.get(id);
if (!existing) fallbackCentersAdded++;
else {
const p = pointFieldCoord(existing, pointOffsetX, pointOffsetY);
const pi = coordIndex(width, height, p.x, p.y);
if (pi < 0 || sea?.[pi] || adminId[pi] !== id) centersMovedToOwnedCells++;
}
nextCenters.push(normalizeCenter(existing, id, stat, { pointOffsetX, pointOffsetY, fields, width, height, seed }, !existing));
}
let maxId = Math.max(-1, ...stats.keys());
if (municipalityToPrefectureId?.length) maxId = Math.max(maxId, municipalityToPrefectureId.length - 1);
const nextMapping = new Int32Array(Math.max(0, maxId + 1));
nextMapping.fill(-1);
if (municipalityToPrefectureId) {
for (let i = 0; i < municipalityToPrefectureId.length && i < nextMapping.length; i++) nextMapping[i] = municipalityToPrefectureId[i] ?? -1;
}
for (const [id, stat] of stats) if (stat.prefectureRegionId >= 0) nextMapping[id] = stat.prefectureRegionId;
return {
adminCenters: nextCenters,
municipalityToPrefectureId: nextMapping,
stats,
debug: {
activeMunicipalities: stats.size,
ghostCentersRemoved,
fallbackCentersAdded,
centersMovedToOwnedCells,
},
};
}
export function refreshPrefectureRegionsMetadata({
prefectureRegionId,
sea,
existing = [],
fields = {},
width = MAP_W,
height = MAP_H,
pointOffsetX = 0,
pointOffsetY = 0,
} = {}) {
if (!prefectureRegionId) return { prefectureRegions: existing || [], debug: { activePrefectureRegions: 0 } };
const byId = new Map();
for (let i = 0; i < prefectureRegionId.length; i++) {
const id = prefectureRegionId[i];
if (!Number.isFinite(id) || id < 0 || sea?.[i]) continue;
const row = byId.get(id) || { id, area: 0, sx: 0, sy: 0, bestI: -1, bestScore: -INF };
const x = i % width;
const y = Math.floor(i / width);
row.area++;
row.sx += x;
row.sy += y;
const score = bestCellScore(fields, i) - Math.hypot(x - row.sx / Math.max(1, row.area), y - row.sy / Math.max(1, row.area)) * 0.02;
if (score > row.bestScore) {
row.bestScore = score;
row.bestI = i;
}
byId.set(id, row);
}
const existingById = new Map();
for (const region of existing || []) {
const id = Number.isFinite(region?.prefectureRegionId) ? Math.floor(region.prefectureRegionId) : Number.isFinite(region?.id) ? Math.floor(region.id) : -1;
if (id >= 0 && !existingById.has(id)) existingById.set(id, region);
}
let fallbackRegionsAdded = 0;
const prefectureRegions = [];
for (const [id, row] of [...byId.entries()].sort((a, b) => a[0] - b[0])) {
const base = existingById.get(id);
if (!base) fallbackRegionsAdded++;
const x = row.bestI >= 0 ? row.bestI % width : Math.round(row.sx / Math.max(1, row.area));
const y = row.bestI >= 0 ? Math.floor(row.bestI / width) : Math.round(row.sy / Math.max(1, row.area));
prefectureRegions.push({
...(base || {}),
id,
prefectureRegionId: id,
featureId: id,
x: x - pointOffsetX,
y: y - pointOffsetY,
area: row.area,
kind: base?.kind || (id === 0 ? "Current Prefecture" : "Prefecture"),
name: base?.name || `県域${id + 1}`,
labelName: base?.labelName || base?.name || `県域${id + 1}`,
forceLabel: true,
labelPriorityBase: base?.labelPriorityBase || 950 + Math.sqrt(row.area),
});
}
return {
prefectureRegions,
debug: {
activePrefectureRegions: byId.size,
fallbackRegionsAdded,
},
};
}
export function municipalCoherenceForMap(map) {
return reconcileMunicipalMetadata({
adminId: map?.adminId,
municipalityId: map?.municipalityId,
prefectureRegionId: map?.prefectureRegionId,
sea: map?.sea,
adminCenters: map?.adminCenters,
municipalityToPrefectureId: map?.municipalityToPrefectureId,
fields: map,
width: map?.width || MAP_W,
height: map?.height || MAP_H,
});
}

View file

@ -1,6 +1,7 @@
import { createNameDebug } from "./names.js";
import { CELL_SIZE, INF, MAP_H, MAP_W, MinHeap, clamp, indexOf, inside, rand, xyOf } from "./mapUtils.js";
import { applyOutputOptions, attachIdsAndNames, influenceFromPaths, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
import { reconcileMunicipalMetadata } from "./mapMunicipalCoherence.js";
import { routeQualityAcceptable } from "./mapTransport.js";
const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u;
@ -30,15 +31,33 @@ function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) {
}
function centerMunicipalityId(center, fallback = -1) {
for (const key of ["adminId", "municipalityId", "adminNumericId"]) {
const value = center?.[key];
if (Number.isFinite(value) && value >= 0) return Math.floor(value);
}
return fallback;
}
function assignMunicipalityPopulations(adminCenters, adminId, fields, settlementFeatures = []) {
if (!adminCenters?.length || !adminId) return;
const totals = new Float64Array(adminCenters.length);
const settlementTotals = new Float64Array(adminCenters.length);
const landCells = new Uint32Array(adminCenters.length);
const inhabitedCells = new Uint32Array(adminCenters.length);
const centerById = new Map();
let maxId = -1;
for (const center of adminCenters) {
const id = centerMunicipalityId(center);
if (id >= 0 && !centerById.has(id)) {
centerById.set(id, center);
maxId = Math.max(maxId, id);
}
}
for (let i = 0; i < adminId.length; i++) if (adminId[i] >= 0) maxId = Math.max(maxId, adminId[i]);
const totals = new Float64Array(maxId + 1);
const settlementTotals = new Float64Array(maxId + 1);
const landCells = new Uint32Array(maxId + 1);
const inhabitedCells = new Uint32Array(maxId + 1);
for (let i = 0; i < adminId.length; i++) {
const id = adminId[i];
if (id < 0 || id >= totals.length || fields.sea?.[i]) continue;
if (id < 0 || fields.sea?.[i]) continue;
landCells[id]++;
const density = fields.populationDensity?.[i] || 0;
const lu = fields.landuse?.[i] ?? 0;
@ -68,7 +87,7 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement
let skippedDuplicateSettlementPopulation = 0;
for (const { feature, i } of uniqueSettlementByCell.values()) {
const id = adminId[i];
if (id < 0 || id >= totals.length) continue;
if (id < 0 || id >= settlementTotals.length) continue;
settlementTotals[id] += feature.population;
}
for (const feature of settlementFeatures || []) {
@ -77,7 +96,7 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement
const kept = uniqueSettlementByCell.get(key)?.feature;
if (kept && kept !== feature) skippedDuplicateSettlementPopulation += feature.population || 0;
}
for (let id = 0; id < adminCenters.length; id++) {
for (const [id, center] of centerById) {
const raw = (totals[id] || 0) + (settlementTotals[id] || 0);
const minimumResidentPopulation = landCells[id] > 0
? Math.round(Math.max(100, Math.min(3800, 80 + landCells[id] * 9 + inhabitedCells[id] * 16)) / 100) * 100
@ -85,13 +104,13 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement
const adjustedRaw = Math.max(raw, minimumResidentPopulation);
const rounded = adjustedRaw >= 10000 ? Math.round(adjustedRaw / 1000) * 1000 : Math.max(minimumResidentPopulation, Math.round(adjustedRaw / 100) * 100);
const safePopulation = Math.max(landCells[id] > 0 ? 100 : 0, rounded);
adminCenters[id].municipalityPopulation = safePopulation;
center.municipalityPopulation = safePopulation;
// Some consumers still read the generic `population` field from municipal
// centers. Mirror the municipality total there so no municipality is shown
// as 0人 merely because it is not a canonical city/market entity.
adminCenters[id].population = Math.max(adminCenters[id].population || 0, safePopulation);
adminCenters[id].municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100);
adminCenters[id].municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100);
center.population = Math.max(center.population || 0, safePopulation);
center.municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100);
center.municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100);
}
}
@ -467,7 +486,20 @@ export function finishMapOutput({
castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug);
externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug);
outputProgress("municipality naming");
const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
const municipalCoherence = reconcileMunicipalMetadata({
adminId,
prefectureRegionId,
sea,
adminCenters: adminCentersRaw,
municipalityToPrefectureId,
fields: nameFields,
width: MAP_W,
height: MAP_H,
seed,
});
if (adminDebug) adminDebug.municipalCoherence = municipalCoherence.debug;
const coherentMunicipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId || municipalityToPrefectureId;
const adminCenters = attachIdsAndNames(tagInsidePrefecture(municipalCoherence.adminCenters, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
const representativeFeatures = [
...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })),
...markets.map((p) => ({ ...p, representativeWeight: 3.2 })),
@ -515,25 +547,27 @@ export function finishMapOutput({
}
const usedAdminNames = new Set();
for (const [index, center] of adminCenters.entries()) {
center.adminNumericId = index;
center.municipalityId = index;
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index);
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index);
const municipalId = centerMunicipalityId(center, index);
center.adminId = municipalId;
center.adminNumericId = municipalId;
center.municipalityId = municipalId;
let candidate = municipalityNameFromRoot(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name, center, nameFields, seed, municipalId);
const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, municipalId);
if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) {
candidate = generated;
}
if (usedAdminNames.has(candidate)) {
const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, index);
const rootSource = String(center.generatedMunicipalityName || center.name || center.municipalityRootName || `自治${index + 1}`).replace(/[市町村区駅港城跡宿]$/gu, "");
const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, municipalId);
const rootSource = String(center.generatedMunicipalityName || center.name || center.municipalityRootName || `自治${municipalId + 1}`).replace(/[市町村区駅港城跡宿]$/gu, "");
const chars = Array.from(rootSource || "里郷");
const alternates = [
chars.slice(0, 2).join(""),
chars.slice(-2).join(""),
`${chars[0] || "里"}${["里", "郷", "野", "田", "川", "原", "浜", "浦"][(seed + index) % 8]}`,
`${["東", "西", "南", "北", "上", "下", "中"][(seed + index) % 7]}${chars[0] || "里"}`,
`${chars[0] || "里"}${["里", "郷", "野", "田", "川", "原", "浜", "浦"][(seed + municipalId) % 8]}`,
`${["東", "西", "南", "北", "上", "下", "中"][(seed + municipalId) % 7]}${chars[0] || "里"}`,
].filter((v) => Array.from(v).length >= 2);
for (let attempt = 0; attempt < alternates.length + 12 && usedAdminNames.has(candidate); attempt++) {
const root = attempt < alternates.length ? alternates[attempt] : `${(index + attempt) % 10}`;
const root = attempt < alternates.length ? alternates[attempt] : `${(municipalId + attempt) % 10}`;
candidate = `${root}${suffix}`;
}
}
@ -584,7 +618,7 @@ export function finishMapOutput({
return bs - as;
})
.filter((p) => {
const prefId = municipalityToPrefectureId?.[p.municipalityId] ?? -1;
const prefId = coherentMunicipalityToPrefectureId?.[p.municipalityId] ?? -1;
const used = perPrefectureQuota.get(prefId) || 0;
if (used >= 18) return false;
perPrefectureQuota.set(prefId, used + 1);
@ -1233,7 +1267,7 @@ export function finishMapOutput({
humanRegionMask,
prefectureBorder: regionalPrefectureBorders.length ? regionalPrefectureBorders : prefectureBorder,
prefectureRegionId,
municipalityToPrefectureId,
municipalityToPrefectureId: coherentMunicipalityToPrefectureId,
prefectureRegions,
regionalDebug,
terrainDebug,

View file

@ -1,6 +1,7 @@
import { MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, lerp, smoothstep, valueNoise } from "./mapUtils.js";
import { generateMap } from "./mapPipeline.js";
import { LANDUSE } from "./landuseCodes.js";
import { reconcileMunicipalMetadata, refreshPrefectureRegionsMetadata } from "./mapMunicipalCoherence.js";
export const PATCH_MIN_WIDTH = 48;
export const PATCH_MIN_HEIGHT = 48;
@ -24,6 +25,7 @@ const RAIL_LAYER_KEYS = new Set(["railways", "branchRailways", "ringRailways", "
const RIVER_LAYER_KEYS = new Set(["mainRivers", "tributaryRivers", "smallStreams", "riverPaths"]);
const SEGMENT_LAYER_KEYS = ["adminBorders", "regionalPrefectureBorders", "prefectureBorder"];
const PATCH_CANDIDATE_CACHE_LIMIT = 3;
const ID_FIELD_OFFSETS = new Map([
["adminId", 100000],
@ -72,6 +74,23 @@ function rectArea(rect) {
return rectWidth(rect) * rectHeight(rect);
}
function nowMs() {
return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
}
function createPatchTimer() {
const timings = [];
let mark = nowMs();
return {
timings,
mark(key, label = key) {
const t = nowMs();
timings.push({ key, label, ms: Math.round((t - mark) * 10) / 10 });
mark = t;
},
};
}
function normalizeRect(rect) {
if (!rect) return null;
const x0 = Math.floor(Math.min(rect.x0, rect.x1));
@ -257,7 +276,10 @@ export function buildPatchRects(userRect, world = null) {
const repairMargin = Math.max(writeMargin, Math.min(desiredRepair, maxBySource));
const writeRect = expandRect(coreRect, writeMargin, world);
const repairRect = expandRect(coreRect, repairMargin, world);
const transportReachMargin = Math.max(repairMargin + 160, Math.min(420, Math.max(220, repairMargin + Math.floor(Math.max(MAP_W, MAP_H) * 1.35))));
const transportReachMargin = Math.max(
repairMargin + 48,
Math.min(260, Math.max(96, repairMargin + Math.floor(shortSide * 1.15)))
);
const transportReachRect = expandRect(coreRect, transportReachMargin, world);
return {
coreRect,
@ -277,7 +299,7 @@ export function buildPatchRects(userRect, world = null) {
};
}
function patchAlpha(x, y, rects, seed = 0) {
function computePatchAlpha(x, y, rects, seed = 0) {
const writeRect = rects.writeRect || rects.userRect;
if (!insideRect(x, y, writeRect)) return 0;
const margin = Math.max(1, rects.writeMargin || 1);
@ -289,10 +311,9 @@ function patchAlpha(x, y, rects, seed = 0) {
const py = y + 0.5;
const inside = pointInPolygon(px, py, shape.polygon);
const dist = distanceToPolygonEdge(px, py, shape.polygon);
const noisyDist = dist + low * margin * 0.28 + mid * margin * 0.10;
if (inside) return 1;
if (noisyDist >= margin * 1.08) return 0;
return clamp(smoothstep(1 - noisyDist / Math.max(1e-6, margin)));
const signedDist = inside ? dist : -dist;
const noisySigned = signedDist + low * margin * 0.28 + mid * margin * 0.10;
return clamp(smoothstep((noisySigned + margin) / Math.max(1e-6, margin * 2)));
}
const edge = distanceToRectEdge(x, y, writeRect);
const noisyEdge = edge + low * margin * 0.42 + mid * margin * 0.16;
@ -303,6 +324,53 @@ function patchAlpha(x, y, rects, seed = 0) {
return clamp(base);
}
function getPatchAlphaCache(rects, seed = 0) {
const writeRect = rects?.writeRect || rects?.userRect;
if (!writeRect) return null;
const width = rectWidth(writeRect);
const height = rectHeight(writeRect);
const existing = rects.patchAlphaCache;
if (
existing
&& existing.seed === seed
&& existing.width === width
&& existing.height === height
&& existing.x0 === writeRect.x0
&& existing.y0 === writeRect.y0
) return existing;
const data = new Float32Array(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
data[y * width + x] = computePatchAlpha(writeRect.x0 + x, writeRect.y0 + y, rects, seed);
}
}
rects.patchAlphaCache = { seed, width, height, x0: writeRect.x0, y0: writeRect.y0, data };
return rects.patchAlphaCache;
}
function patchAlpha(x, y, rects, seed = 0) {
const writeRect = rects?.writeRect || rects?.userRect;
if (!writeRect || !insideRect(x, y, writeRect)) return 0;
const cache = rects.patchAlphaCache;
if (
cache
&& cache.seed === seed
&& x >= cache.x0
&& y >= cache.y0
&& x < cache.x0 + cache.width
&& y < cache.y0 + cache.height
) return cache.data[(y - cache.y0) * cache.width + (x - cache.x0)] || 0;
return computePatchAlpha(x, y, rects, seed);
}
function patchBand(x, y, rects, seed = 0) {
const a = patchAlpha(x, y, rects, seed);
if (a <= 0.18) return "preserve";
if (a >= 0.82) return "core";
return "feather";
}
function continuityReplaceThreshold(name, x, y, rects, seed = 0) {
const n = valueNoise(x, y, seed ^ 0x4f1bbcdc, 11) - 0.5;
if (ADMIN_CONTINUITY_FIELD_NAMES.has(name)) return clamp(0.82 + n * 0.12, 0.70, 0.92);
@ -315,6 +383,21 @@ function continuitySegmentAllowed(x, y, nx, ny, rects, seed, minAlpha = 0.42) {
return Math.min(patchAlpha(x, y, rects, seed), patchAlpha(nx, ny, rects, seed)) >= minAlpha;
}
function patchAffected(x, y, rects, seed = 0, minAlpha = 0.34) {
return insideRect(Math.round(x), Math.round(y), rects?.writeRect) && patchAlpha(Math.round(x), Math.round(y), rects, seed) >= minAlpha;
}
function segmentTouchesPatch(world, seg, rects, seed = 0, minAlpha = 0.34) {
if (!Array.isArray(seg) || seg.length < 2) return false;
const ax = tupleWorldX(world, seg[0]);
const ay = tupleWorldY(world, seg[0]);
const bx = tupleWorldX(world, seg[1]);
const by = tupleWorldY(world, seg[1]);
const mx = (ax + bx) * 0.5;
const my = (ay + by) * 0.5;
return patchAffected(ax, ay, rects, seed, minAlpha) || patchAffected(bx, by, rects, seed, minAlpha) || patchAffected(mx, my, rects, seed, minAlpha);
}
function sourceWindowForRects(rects) {
const cx = (rects.coreRect.x0 + rects.coreRect.x1 - 1) / 2;
@ -334,6 +417,59 @@ function sourceCoordForWorld(window, x, y) {
};
}
function getPatchSourceIndexCache(rects, window) {
const writeRect = rects?.writeRect;
if (!writeRect || !window) return null;
const width = rectWidth(writeRect);
const height = rectHeight(writeRect);
const existing = rects.patchSourceIndexCache;
if (
existing
&& existing.width === width
&& existing.height === height
&& existing.x0 === writeRect.x0
&& existing.y0 === writeRect.y0
&& existing.worldCenterX === window.worldCenterX
&& existing.worldCenterY === window.worldCenterY
&& existing.sourceCenterX === window.sourceCenterX
&& existing.sourceCenterY === window.sourceCenterY
) return existing;
const data = new Int32Array(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const sx = Math.round(writeRect.x0 + x - window.worldCenterX + window.sourceCenterX);
const sy = Math.round(writeRect.y0 + y - window.worldCenterY + window.sourceCenterY);
data[y * width + x] = sourceIndex(sx, sy);
}
}
rects.patchSourceIndexCache = {
width,
height,
x0: writeRect.x0,
y0: writeRect.y0,
worldCenterX: window.worldCenterX,
worldCenterY: window.worldCenterY,
sourceCenterX: window.sourceCenterX,
sourceCenterY: window.sourceCenterY,
data,
};
return rects.patchSourceIndexCache;
}
function sourceIndexForWorld(rects, window, x, y) {
const cache = rects?.patchSourceIndexCache;
if (
cache
&& x >= cache.x0
&& y >= cache.y0
&& x < cache.x0 + cache.width
&& y < cache.y0 + cache.height
) return cache.data[(y - cache.y0) * cache.width + (x - cache.x0)];
const s = sourceCoordForWorld(window, x, y);
return sourceIndex(s.x, s.y);
}
function worldCoordForSource(window, sx, sy) {
return {
x: Math.round(sx - window.sourceCenterX + window.worldCenterX),
@ -385,8 +521,7 @@ function collectCandidateIdsInRect(candidateField, rects, window, minAlpha = 0.2
for (let y = rect.y0; y < rect.y1; y++) {
for (let x = rect.x0; x < rect.x1; x++) {
if (patchAlpha(x, y, rects, seed) < minAlpha) continue;
const s = sourceCoordForWorld(window, x, y);
const si = sourceIndex(s.x, s.y);
const si = sourceIndexForWorld(rects, window, x, y);
if (si < 0) continue;
const id = candidateField[si];
if (Number.isFinite(id) && id >= 0) ids.add(Math.floor(id));
@ -671,6 +806,70 @@ function stabilizeContinuitySeam(world, rects, oldFields, seed = 0) {
return { continuityCellsRestored: restored, continuityCellsRemapped: remapped };
}
function chooseSeamOwnerValue(world, fieldName, oldField, candidateValue, x, y, rects, seed) {
const a = patchAlpha(x, y, rects, seed);
const i = worldIndex(world, x, y);
const oldValue = oldField?.[i] ?? -1;
if (oldValue < 0 || candidateValue < 0) return candidateValue >= 0 ? candidateValue : oldValue;
if (a <= 0.24) return oldValue;
if (a >= 0.82) return candidateValue;
const field = world.fields?.[fieldName];
const pref = world.fields?.prefectureRegionId;
const naturalBarrier = world.fields?.naturalBarrierScore || world.fields?.ridgeField;
let oldScore = (1 - a) * 3.0;
let candidateScore = a * 3.0;
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
const ni = worldIndex(world, x + dx, y + dy);
if (ni < 0) continue;
const neighbor = field?.[ni] ?? -1;
if (neighbor === oldValue) oldScore += 1.2;
if (neighbor === candidateValue) candidateScore += 1.2;
if (fieldName !== "prefectureRegionId" && pref && pref[ni] >= 0) {
if (pref[ni] === pref[i] && candidateValue !== oldValue) oldScore += 0.18;
}
}
const barrierBonus = naturalBarrier?.[i] || 0;
if (barrierBonus > 0.48 && Math.abs(a - 0.5) < 0.24) {
if (a < 0.5) oldScore += barrierBonus * 0.9;
else candidateScore += barrierBonus * 0.9;
}
return candidateScore > oldScore ? candidateValue : oldValue;
}
function repairDiscreteSeamOwnership(world, rects, oldFields, seed = 0) {
let adminSeamCellsResolved = 0;
let prefectureSeamCellsResolved = 0;
for (const name of ["prefectureRegionId", "adminId", "municipalityId"]) {
const field = world.fields?.[name];
const old = oldFields?.get(name);
if (!field || !old) continue;
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
const i = worldIndex(world, x, y);
if (i < 0 || world.fields.sea?.[i]) continue;
if (patchBand(x, y, rects, seed) !== "feather") continue;
const before = field[i];
const next = chooseSeamOwnerValue(world, name, old, before, x, y, rects, seed);
if (next !== before) {
field[i] = next;
if (name === "prefectureRegionId") prefectureSeamCellsResolved++;
else adminSeamCellsResolved++;
}
}
}
}
if (world.fields.adminId && world.fields.municipalityId) {
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
const i = worldIndex(world, x, y);
if (i >= 0 && !world.fields.sea?.[i]) world.fields.municipalityId[i] = world.fields.adminId[i];
}
}
}
return { adminSeamCellsResolved, prefectureSeamCellsResolved };
}
function copyFullPipelineFields(world, candidate, rects, seed) {
const window = sourceWindowForRects(rects);
const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null;
@ -703,8 +902,7 @@ function copyFullPipelineFields(world, candidate, rects, seed) {
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
const wi = worldIndex(world, x, y);
if (wi < 0) continue;
const s = sourceCoordForWorld(window, x, y);
const si = sourceIndex(s.x, s.y);
const si = sourceIndexForWorld(rects, window, x, y);
if (si < 0) continue;
const alpha = patchAlpha(x, y, rects, seed);
if (alpha <= 0.005) continue;
@ -780,6 +978,7 @@ function copyFullPipelineFields(world, candidate, rects, seed) {
}
const continuityDebug = stabilizeContinuitySeam(world, rects, oldContinuityFields, seed);
const seamOwnershipDebug = repairDiscreteSeamOwnership(world, rects, oldContinuityFields, seed);
if (world.fields.adminId && world.fields.municipalityId && !candidate?.municipalityId) {
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
@ -800,6 +999,7 @@ function copyFullPipelineFields(world, candidate, rects, seed) {
adminIdMapping,
adminIdMappingDebug: summarizeIdMapping(adminIdMapping),
...continuityDebug,
...seamOwnershipDebug,
};
}
@ -830,6 +1030,47 @@ function repairDisplayMasks(world, rects, seed = 0) {
return { displayMaskUpdated };
}
function featherTerrainSeam(world, rects, seed = 0) {
const fields = world.fields || {};
const smoothKeys = [
"elevation", "moisture", "ridgeField", "valleyField", "visibleRavineField",
"basinField", "coastalLowland", "plain", "agriculture", "erosionField",
"depositionField", "depositionalLowland", "alluvialFanField", "deltaField",
"naturalBarrierScore", "settlementScore", "populationDensity",
];
let terrainFeatherCells = 0;
let terrainFeatherValues = 0;
for (const key of smoothKeys) {
const field = fields[key];
if (!field || !ArrayBuffer.isView(field)) continue;
const old = new field.constructor(field);
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
if (patchBand(x, y, rects, seed) !== "feather") continue;
const i = worldIndex(world, x, y);
if (i < 0 || fields.sea?.[i]) continue;
let sum = 0;
let count = 0;
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
const ni = worldIndex(world, x + dx, y + dy);
if (ni >= 0 && !fields.sea?.[ni]) {
sum += old[ni] || 0;
count++;
}
}
if (!count) continue;
const a = patchAlpha(x, y, rects, seed);
const neighborMean = sum / count;
const seamWeight = 0.34 * (1 - Math.abs(a - 0.5) * 1.2);
field[i] = lerp(field[i] || 0, neighborMean, clamp(seamWeight, 0.08, 0.34));
terrainFeatherValues++;
if (key === "elevation") terrainFeatherCells++;
}
}
}
return { terrainFeatherCells, terrainFeatherValues };
}
function nearestLandFieldValue(world, x, y, fieldName, rect, options = {}) {
const field = world.fields?.[fieldName];
@ -873,7 +1114,7 @@ function lookupPrefectureForAdmin(sourceMap, adminIdMapping, adminId) {
return -1;
}
function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null) {
function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null, seed = 0) {
const admin = world.fields?.adminId;
if (!admin) return { seaAdminCellsCleared: 0, landAdminCellsFilled: 0, prefectureCellsFilled: 0, adminPrefectureCellsAligned: 0 };
const expected = world.width * world.height;
@ -905,7 +1146,7 @@ function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null) {
prefecture[i] = -1;
continue;
}
const generated = !coverage || coverage[i] || patchAlpha(x, y, rects, 0) > 0.08;
const generated = patchAlpha(x, y, rects, seed) > 0.08 || (!coverage && insideRect(x, y, rects.writeRect));
if (!generated) continue;
if (admin[i] < 0) {
@ -958,7 +1199,7 @@ function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null) {
return { seaAdminCellsCleared, landAdminCellsFilled, prefectureCellsFilled, adminPrefectureCellsAligned };
}
function smoothWaterTopology(world, rect, seaLevel = 0.30) {
function smoothWaterTopology(world, rect, seaLevel = 0.30, rects = null, seed = 0) {
const sea = world.fields.sea;
const ocean = world.fields.ocean;
const lake = world.fields.lake;
@ -981,8 +1222,11 @@ function smoothWaterTopology(world, rect, seaLevel = 0.30) {
if (sea[ni]) seaN++; else landN++;
}
}
if (sea[i] && seaN <= 1 && elevation[i] > seaLevel - 0.035) flips.push([i, 0]);
else if (!sea[i] && seaN >= 7 && elevation[i] < seaLevel + 0.055) flips.push([i, 1]);
const a = rects ? patchAlpha(x, y, rects, seed) : 1;
if (a < 0.24) continue;
const strongOnly = a < 0.42;
if (sea[i] && seaN <= (strongOnly ? 0 : 1) && elevation[i] > seaLevel - 0.035) flips.push([i, 0]);
else if (!sea[i] && seaN >= (strongOnly ? 8 : 7) && elevation[i] < seaLevel + 0.055) flips.push([i, 1]);
}
}
for (const [i, nextSea] of flips) {
@ -1086,6 +1330,45 @@ function sourcePathFromWorld(world, path) {
return path.map(([x, y]) => [Math.round(x - world.originX), Math.round(y - world.originY)]);
}
function offsetPointNumericFields(point, fields, offset) {
for (const field of fields) if (Number.isFinite(point[field])) point[field] += offset;
}
function normalizeGeneratedPointIds(point, key, seed = 0, adminIdMapping = null) {
const rawAdminId = numericFeatureId(point, ["adminId", "municipalityId", "adminNumericId"]);
if (rawAdminId >= 0) {
const mappedAdminId = adminIdMapping?.admin?.get(rawAdminId) ?? adminIdMapping?.municipality?.get(rawAdminId);
if (Number.isFinite(mappedAdminId)) {
point.sourceAdminId = rawAdminId;
point.adminId = mappedAdminId;
point.adminNumericId = mappedAdminId;
point.municipalityId = mappedAdminId;
} else {
offsetPointNumericFields(point, ["adminId", "adminNumericId", "municipalityId"], fieldIdOffset("adminId", seed));
if (!Number.isFinite(point.adminId) && Number.isFinite(point.municipalityId)) point.adminId = point.municipalityId;
if (!Number.isFinite(point.municipalityId) && Number.isFinite(point.adminId)) point.municipalityId = point.adminId;
}
}
const rawPrefectureId = numericFeatureId(point, key === "prefectureRegions" ? ["prefectureRegionId", "id"] : ["prefectureRegionId"]);
if (rawPrefectureId >= 0) {
const mappedPrefectureId = adminIdMapping?.prefecture?.get(rawPrefectureId);
if (Number.isFinite(mappedPrefectureId)) {
point.sourcePrefectureRegionId = rawPrefectureId;
point.prefectureRegionId = mappedPrefectureId;
if (key === "prefectureRegions") point.id = mappedPrefectureId;
} else {
const offset = fieldIdOffset("prefectureRegionId", seed);
if (Number.isFinite(point.prefectureRegionId)) point.prefectureRegionId += offset;
if (key === "prefectureRegions" && Number.isFinite(point.id)) point.id += offset;
}
}
if (Number.isFinite(point.adminId) && !Number.isFinite(point.municipalityId)) point.municipalityId = point.adminId;
if (Number.isFinite(point.municipalityId) && !Number.isFinite(point.adminId)) point.adminId = point.municipalityId;
return point;
}
function transformCandidatePoint(world, window, p, key, seed = 0, adminIdMapping = null) {
if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) return null;
const w = worldCoordForSource(window, p.x, p.y);
@ -1099,39 +1382,12 @@ function transformCandidatePoint(world, window, p, key, seed = 0, adminIdMapping
w.x = land.x; w.y = land.y;
}
const out = sourcePointFromWorld(world, { ...p, x: w.x, y: w.y });
normalizeGeneratedPointIds(out, key, seed, adminIdMapping);
if (key === "adminCenters") {
const rawAdminId = numericFeatureId(out, ["adminId", "municipalityId", "adminNumericId"]);
const mappedAdminId = rawAdminId >= 0
? adminIdMapping?.admin?.get(rawAdminId) ?? adminIdMapping?.municipality?.get(rawAdminId)
: undefined;
if (Number.isFinite(mappedAdminId)) {
out.sourceAdminId = rawAdminId;
out.adminId = mappedAdminId;
out.adminNumericId = mappedAdminId;
out.municipalityId = mappedAdminId;
const candidatePrefId = adminIdMapping?.candidateAdminToPrefecture?.get(rawAdminId);
if (Number.isFinite(out.sourceAdminId)) {
const candidatePrefId = adminIdMapping?.candidateAdminToPrefecture?.get(out.sourceAdminId);
const mappedPrefId = adminIdMapping?.prefecture?.get(candidatePrefId);
if (Number.isFinite(mappedPrefId)) out.prefectureRegionId = mappedPrefId;
} else {
const offset = fieldIdOffset("adminId", seed);
if (Number.isFinite(out.adminId)) out.adminId += offset;
if (Number.isFinite(out.adminNumericId)) out.adminNumericId += offset;
if (Number.isFinite(out.municipalityId)) out.municipalityId += offset;
if (!Number.isFinite(out.adminId) && Number.isFinite(out.municipalityId)) out.adminId = out.municipalityId;
if (!Number.isFinite(out.municipalityId) && Number.isFinite(out.adminId)) out.municipalityId = out.adminId;
}
}
if (key === "prefectureRegions") {
const rawPrefectureId = numericFeatureId(out, ["prefectureRegionId", "id"]);
const mappedPrefectureId = rawPrefectureId >= 0 ? adminIdMapping?.prefecture?.get(rawPrefectureId) : undefined;
if (Number.isFinite(mappedPrefectureId)) {
out.sourcePrefectureRegionId = rawPrefectureId;
out.id = mappedPrefectureId;
out.prefectureRegionId = mappedPrefectureId;
} else {
const offset = fieldIdOffset("prefectureRegionId", seed);
if (Number.isFinite(out.id)) out.id += offset;
if (Number.isFinite(out.prefectureRegionId)) out.prefectureRegionId += offset;
}
}
if (key === "logisticsParks") sanitizeLogisticsPark(out);
@ -1181,12 +1437,12 @@ function transformCandidatePath(window, path) {
return out;
}
function splitWorldPathByRect(path, rect, keepInside) {
function splitWorldPathByPredicate(path, predicate, keepWhenTrue) {
const chunks = [];
let current = [];
for (const p of path || []) {
const inside = insideRect(Math.round(p[0]), Math.round(p[1]), rect);
if (inside === keepInside) current.push([Math.round(p[0]), Math.round(p[1])]);
const matches = predicate(Math.round(p[0]), Math.round(p[1]));
if (matches === keepWhenTrue) current.push([Math.round(p[0]), Math.round(p[1])]);
else {
if (current.length >= 2) chunks.push(current);
current = [];
@ -1196,13 +1452,21 @@ function splitWorldPathByRect(path, rect, keepInside) {
return chunks;
}
function pruneOldPathLayer(world, paths, rect, mode) {
function splitWorldPathByRect(path, rect, keepInside) {
return splitWorldPathByPredicate(path, (x, y) => insideRect(x, y, rect), keepInside);
}
function splitWorldPathByPatch(path, rects, seed, keepAffected, minAlpha = 0.34) {
return splitWorldPathByPredicate(path, (x, y) => patchAffected(x, y, rects, seed, minAlpha), keepAffected);
}
function pruneOldPathLayer(world, paths, rects, seed, mode) {
const kept = [];
const anchors = [];
let clipped = 0;
for (const path of paths || []) {
const worldPath = (path || []).map((tuple) => [Math.round(tupleWorldX(world, tuple)), Math.round(tupleWorldY(world, tuple))]);
const touches = worldPath.some(([x, y]) => insideRect(x, y, rect));
const touches = worldPath.some(([x, y]) => patchAffected(x, y, rects, seed, 0.34));
if (!touches) {
kept.push(path);
continue;
@ -1211,7 +1475,7 @@ function pruneOldPathLayer(world, paths, rect, mode) {
let lastOutside = null;
let wasInside = false;
for (const [x, y] of worldPath) {
const inside = insideRect(x, y, rect);
const inside = patchAffected(x, y, rects, seed, 0.34);
if (!inside) {
if (wasInside) anchors.push({ x, y, mode });
lastOutside = { x, y, mode };
@ -1220,7 +1484,7 @@ function pruneOldPathLayer(world, paths, rect, mode) {
}
wasInside = inside;
}
for (const chunk of splitWorldPathByRect(worldPath, rect, false)) kept.push(sourcePathFromWorld(world, chunk));
for (const chunk of splitWorldPathByPatch(worldPath, rects, seed, false, 0.34)) kept.push(sourcePathFromWorld(world, chunk));
}
return { kept, anchors, clipped };
}
@ -1357,28 +1621,38 @@ function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRe
const allTargets = collectInternalNetworkPoints(world, sourceMap, keys, rect, mode);
const preferredTargets = preferredTargetRect ? allTargets.filter((p) => insideRect(p.x, p.y, preferredTargetRect)) : [];
const targets = preferredTargets.length ? preferredTargets : allTargets;
if (!targets.length) return { connectors: 0, disconnected: anchors.length };
if (!targets.length) return { connectors: 0, disconnected: anchors.length, skippedConnectorAnchors: 0, connectorAttempts: 0 };
let connectors = 0;
let disconnected = 0;
let skippedConnectorAnchors = 0;
let connectorAttempts = 0;
const layer = mode === "rail" ? "branchRailways" : "minorRoads";
sourceMap[layer] ||= [];
const seen = new Set();
const maxRange = mode === "rail" ? 320 : 360;
for (const raw of anchors) {
const maxRange = mode === "rail" ? 220 : 260;
const maxAnchors = mode === "rail" ? 10 : 18;
const maxTargets = mode === "rail" ? 3 : 3;
const searchRect = expandRect(rect, 16, world);
const orderedAnchors = (anchors || [])
.map((p) => ({ ...p, patchDistance: rectDistance(p.x, p.y, preferredTargetRect || rect) }))
.sort((a, b) => a.patchDistance - b.patchDistance)
.slice(0, maxAnchors);
skippedConnectorAnchors = Math.max(0, (anchors?.length || 0) - orderedAnchors.length);
for (const raw of orderedAnchors) {
const anchorLand = nearestLand(world, raw.x, raw.y, rect, 18);
if (!anchorLand) { disconnected++; continue; }
const targetList = targets
.map((p) => ({ ...p, d: Math.hypot(p.x - anchorLand.x, p.y - anchorLand.y) }))
.filter((p) => p.d <= maxRange && p.d >= 6)
.sort((a, b) => (a.d / Math.max(0.6, a.weight || 1)) - (b.d / Math.max(0.6, b.weight || 1)))
.slice(0, 5);
.slice(0, maxTargets);
if (!targetList.length) { disconnected++; continue; }
let made = false;
for (const target of targetList) {
const sig = `${anchorLand.x},${anchorLand.y}:${target.x},${target.y}:${mode}`;
if (seen.has(sig)) continue;
const searchRect = expandRect(rect, 24, world);
const path = localPathfind(world, anchorLand, target, searchRect, mode, mode === "rail" ? 62000 : 76000);
connectorAttempts++;
const path = localPathfind(world, anchorLand, target, searchRect, mode, mode === "rail" ? 36000 : 44000);
if (!path || path.length < 2) continue;
seen.add(sig);
sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2)));
@ -1388,7 +1662,7 @@ function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRe
}
if (!made) disconnected++;
}
return { connectors, disconnected };
return { connectors, disconnected, skippedConnectorAnchors, connectorAttempts };
}
function nearestNetworkPoint(world, sourceMap, keys, point, rect, maxDistance = 80) {
@ -1413,23 +1687,37 @@ function ensureSettlementRoadCoverage(world, sourceMap, rect) {
const featureKeys = ["modernCities", "ports", "markets", "adminCenters", "villages"];
sourceMap.minorRoads ||= [];
let connectors = 0;
let skippedServedSettlements = 0;
let checked = 0;
const seen = new Set();
for (const key of featureKeys) {
for (const p of sourceMap[key] || []) {
const limit = key === "villages" ? 30 : 18;
const items = (sourceMap[key] || [])
.map((p) => ({ p, d: rectDistance(pointWorldX(world, p), pointWorldY(world, p), rect) }))
.filter((row) => row.d <= (key === "villages" ? 80 : 150))
.sort((a, b) => a.d - b.d)
.slice(0, limit);
for (const { p } of items) {
checked++;
const start = nearestLand(world, pointWorldX(world, p), pointWorldY(world, p), rect, 10);
if (!start || !insideRect(start.x, start.y, rect)) continue;
const si = worldIndex(world, start.x, start.y);
if ((world.fields.roadInfluence?.[si] || 0) > (key === "villages" ? 0.18 : 0.12)) {
skippedServedSettlements++;
continue;
}
const target = nearestNetworkPoint(world, sourceMap, keys, start, rect, key === "villages" ? 72 : 132);
if (!target || Math.hypot(target.x - start.x, target.y - start.y) < 5) continue;
const sig = `${start.x},${start.y}:${target.x},${target.y}`;
if (seen.has(sig)) continue;
seen.add(sig);
const path = localPathfind(world, start, target, rect, "road", 64000);
const path = localPathfind(world, start, target, rect, "road", 36000);
if (!path || path.length < 2) continue;
sourceMap.minorRoads.push(sourcePathFromWorld(world, simplifyPath(path, 2)));
connectors++;
}
}
return connectors;
return { connectors, skippedServedSettlements, checkedSettlementCoverage: checked };
}
function dedupeAdminCentersByWorldId(kept, generated) {
@ -1478,9 +1766,11 @@ function mergePointLayers(world, sourceMap, candidate, rects, window, seed, admi
const inWrite = insideRect(wx, wy, rects.writeRect);
const alpha = inWrite ? patchAlpha(wx, wy, rects, seed) : 0;
if (!inWrite || alpha < 0.34) {
if (key === "ports" && inWrite && (!isLand(world, wx, wy) || seaNeighbors(world, wx, wy, 2) < 2)) { invalidPortsRemoved++; continue; }
kept.push(p);
if (!inWrite) preservedExternalEntities++;
} else if (key === "ports" && (!isLand(world, wx, wy) || seaNeighbors(world, wx, wy, 2) < 2)) {
invalidPortsRemoved++;
continue;
}
}
const generated = [];
@ -1510,13 +1800,13 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
for (const key of PATH_LAYER_KEYS) {
const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
const mode = RAIL_LAYER_KEYS.has(key) ? "rail" : ROAD_LAYER_KEYS.has(key) ? "road" : RIVER_LAYER_KEYS.has(key) ? "river" : "path";
const pruned = pruneOldPathLayer(world, oldArr, rects.writeRect, mode);
const pruned = pruneOldPathLayer(world, oldArr, rects, seed, mode);
if (mode === "rail") { railAnchors = railAnchors.concat(pruned.anchors); railsClipped += pruned.clipped; }
else if (mode === "road") { roadAnchors = roadAnchors.concat(pruned.anchors); roadsClipped += pruned.clipped; }
const next = [...pruned.kept];
for (const path of candidate[key] || []) {
const worldPath = transformCandidatePath(window, path);
const chunks = splitWorldPathByRect(worldPath, rects.writeRect, true)
const chunks = splitWorldPathByPatch(worldPath, rects, seed, true, 0.40)
.map((chunk) => chunk.filter(([x, y]) => mode === "river" || isLand(world, x, y) || patchAlpha(x, y, rects, seed) > 0.90))
.filter((chunk) => chunk.length >= 2);
for (const chunk of chunks) {
@ -1540,21 +1830,19 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
roadsClipped,
railsClipped,
regeneratedPaths,
roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors,
roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors.connectors,
railwayConnectorsCreated: railConn.connectors,
disconnectedRoadComponents: roadConn.disconnected,
disconnectedRailComponents: railConn.disconnected,
skippedConnectorAnchors: (roadConn.skippedConnectorAnchors || 0) + (railConn.skippedConnectorAnchors || 0),
connectorAttempts: (roadConn.connectorAttempts || 0) + (railConn.connectorAttempts || 0),
skippedServedSettlements: settlementRoadConnectors.skippedServedSettlements || 0,
checkedSettlementCoverage: settlementRoadConnectors.checkedSettlementCoverage || 0,
externalRoadAnchors: externalRoadAnchors.length,
externalRailAnchors: externalRailAnchors.length,
};
}
function segmentTouchesRect(world, seg, rect) {
if (!Array.isArray(seg) || seg.length < 2) return false;
return insideRect(tupleWorldX(world, seg[0]), tupleWorldY(world, seg[0]), rect)
|| insideRect(tupleWorldX(world, seg[1]), tupleWorldY(world, seg[1]), rect);
}
function buildBoundarySegmentsFromField(world, fieldName, rect, options = {}) {
const field = world.fields[fieldName];
const sea = world.fields.sea;
@ -1603,7 +1891,7 @@ function mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rec
function mergeSegmentLayers(world, sourceMap, rects, seed = 0) {
for (const key of SEGMENT_LAYER_KEYS) {
const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
sourceMap[key] = oldArr.filter((seg) => !segmentTouchesRect(world, seg, rects.writeRect));
sourceMap[key] = oldArr.filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, 0.34));
}
sourceMap.adminBorders ||= [];
sourceMap.adminBorders.push(...buildBoundarySegmentsFromField(world, "adminId", rects.writeRect, { rects, seed, minAlpha: 0.58 }));
@ -1616,7 +1904,7 @@ function mergeSegmentLayers(world, sourceMap, rects, seed = 0) {
// areas. Rebuilding directly from the raster field made patch compartments
// look denser/smaller than the initial map. Candidate segments are merged
// just after this function.
debug.compartmentBorders = (debug.compartmentBorders || []).filter((seg) => !segmentTouchesRect(world, seg, rects.writeRect));
debug.compartmentBorders = (debug.compartmentBorders || []).filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, 0.34));
sourceMap.adminDebug = debug;
return {
adminBordersRebuilt: sourceMap.adminBorders.length,
@ -1649,6 +1937,89 @@ function repairLanduseAndPopulation(world, rects) {
return { landUseCellsUpdated: updated };
}
function ensureWorldFloatField(world, name) {
const expected = world.width * world.height;
if (!world.fields[name] || world.fields[name].length !== expected) world.fields[name] = new Float32Array(expected);
return world.fields[name];
}
function clearFieldRect(world, field, rect) {
for (let y = rect.y0; y < rect.y1; y++) {
for (let x = rect.x0; x < rect.x1; x++) {
const i = worldIndex(world, x, y);
if (i >= 0) field[i] = 0;
}
}
}
function paintInfluenceDisk(world, field, cx, cy, radius, strength, rect) {
const sea = world.fields.sea;
const r = Math.max(1, Math.ceil(radius));
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
if (dx * dx + dy * dy > radius * radius) continue;
const x = Math.round(cx + dx);
const y = Math.round(cy + dy);
if (!insideRect(x, y, rect)) continue;
const i = worldIndex(world, x, y);
if (i < 0 || sea?.[i]) continue;
const d = Math.hypot(dx, dy);
const value = strength * Math.pow(1 - d / Math.max(1, radius), 1.35);
if (value > field[i]) field[i] = clamp(value);
}
}
}
function refreshPatchInfluenceFields(world, sourceMap, rects) {
const rect = rects.repairRect || rects.writeRect;
const roadInfluence = ensureWorldFloatField(world, "roadInfluence");
const railInfluence2 = ensureWorldFloatField(world, "railInfluence2");
const stationInfluence = ensureWorldFloatField(world, "stationInfluence");
const villageInfluence = ensureWorldFloatField(world, "villageInfluence");
for (const field of [roadInfluence, railInfluence2, stationInfluence, villageInfluence]) clearFieldRect(world, field, rect);
let roadCellsPainted = 0;
let railCellsPainted = 0;
let stationCellsPainted = 0;
let villageCellsPainted = 0;
const paintPathLayer = (keys, field, radius, strength, counterName) => {
let painted = 0;
for (const key of keys) {
for (const path of sourceMap[key] || []) {
for (const tuple of path || []) {
const x = tupleWorldX(world, tuple);
const y = tupleWorldY(world, tuple);
if (rectDistance(x, y, rect) > radius + 1) continue;
paintInfluenceDisk(world, field, x, y, radius, strength, rect);
painted++;
}
}
}
if (counterName === "road") roadCellsPainted += painted;
if (counterName === "rail") railCellsPainted += painted;
};
paintPathLayer(["nationalRoads", "ringRoads", "externalRoads", "minorRoads", "premodernRoads", "icAccessRoads"], roadInfluence, 5, 1, "road");
paintPathLayer(["railways", "branchRailways", "ringRailways", "externalRailways"], railInfluence2, 4, 1, "rail");
for (const p of sourceMap.stations || []) {
const x = pointWorldX(world, p);
const y = pointWorldY(world, p);
if (rectDistance(x, y, rect) > 8) continue;
paintInfluenceDisk(world, stationInfluence, x, y, 5, clamp(p.score || 1), rect);
stationCellsPainted++;
}
for (const p of sourceMap.villages || []) {
const x = pointWorldX(world, p);
const y = pointWorldY(world, p);
if (rectDistance(x, y, rect) > 10) continue;
paintInfluenceDisk(world, villageInfluence, x, y, 6, clamp((p.population || 1800) / 4200, 0.35, 1.2), rect);
villageCellsPainted++;
}
return { roadCellsPainted, railCellsPainted, stationCellsPainted, villageCellsPainted };
}
function countSea(world, rect) {
let seaCount = 0;
let total = 0;
@ -1671,6 +2042,39 @@ function terrainId(candidate, fallback) {
return candidate?.terrainTemplate?.terrainType || candidate?.terrainDebug?.terrainType || fallback;
}
function rectKey(rect) {
return rect ? `${rect.x0},${rect.y0},${rect.x1},${rect.y1}` : "-";
}
function patchCandidateCacheKey({ seed, terrainType, variant, candidateOriginX, candidateOriginY, contextRect, serial = 0 }) {
return [serial, seed >>> 0, terrainType || "auto", variant >>> 0, candidateOriginX | 0, candidateOriginY | 0, rectKey(contextRect)].join("|");
}
function getPatchCandidateCache(world) {
if (!world.patchCandidateCache) world.patchCandidateCache = new Map();
return world.patchCandidateCache;
}
function rememberPatchCandidate(world, key, candidate) {
const cache = getPatchCandidateCache(world);
if (cache.has(key)) cache.delete(key);
cache.set(key, candidate);
while (cache.size > PATCH_CANDIDATE_CACHE_LIMIT) cache.delete(cache.keys().next().value);
}
function getOrGeneratePatchCandidate(world, key, create) {
const cache = getPatchCandidateCache(world);
if (cache.has(key)) {
const candidate = cache.get(key);
cache.delete(key);
cache.set(key, candidate);
return { candidate, cacheHit: true, cacheSize: cache.size };
}
const candidate = create();
rememberPatchCandidate(world, key, candidate);
return { candidate, cacheHit: false, cacheSize: getPatchCandidateCache(world).size };
}
export function generatePatch(world, userRectInput, options = {}) {
const validation = validatePatchRect(userRectInput, world);
if (!validation.ok) return { ok: false, ...validation };
@ -1682,35 +2086,90 @@ export function generatePatch(world, userRectInput, options = {}) {
const candidateWindow = sourceWindowForRects(rects);
const candidateOriginX = Math.round(candidateWindow.worldCenterX - candidateWindow.sourceCenterX);
const candidateOriginY = Math.round(candidateWindow.worldCenterY - candidateWindow.sourceCenterY);
const candidate = generateMap(seed, {
const patchTimer = createPatchTimer();
const patchGenerationMode = "legacy-full-pipeline";
const cacheKey = patchCandidateCacheKey({
seed,
terrainType,
legacyTerrain: true,
worldNative: true,
variant,
originX: candidateOriginX,
originY: candidateOriginY,
width: MAP_W,
height: MAP_H,
candidateOriginX,
candidateOriginY,
contextRect: rects.contextRect,
boundaryWorld: world,
onProgress: () => {},
serial: world.patchGenerationSerial || 0,
});
const { candidate, cacheHit, cacheSize } = getOrGeneratePatchCandidate(world, cacheKey, () => generateMap(seed, {
terrainType,
legacyTerrain: true,
worldNative: true,
variant,
originX: candidateOriginX,
originY: candidateOriginY,
width: MAP_W,
height: MAP_H,
contextRect: rects.contextRect,
boundaryWorld: world,
onProgress: () => {},
}));
patchTimer.mark("candidate", cacheHit ? "Full candidate generation (cached)" : "Full candidate generation");
getPatchAlphaCache(rects, seed);
getPatchSourceIndexCache(rects, candidateWindow);
const sourceMap = world.sourceMap || (world.sourceMap = {});
const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap);
const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed);
const waterDebug = smoothWaterTopology(world, rects.writeRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30);
patchTimer.mark("fields", "Field copy and alpha blend");
const terrainSeamDebug = featherTerrainSeam(world, rects, seed);
const waterDebug = smoothWaterTopology(world, rects.writeRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30, rects, seed);
const maskDebug = repairDisplayMasks(world, rects, seed);
recomputeSlopeAndWaterDependentFields(world, rects.repairRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30);
const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, fieldDebug.adminIdMapping);
const adminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping);
patchTimer.mark("terrainRepair", "Water, masks, and terrain repair");
const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.adminIdMapping);
patchTimer.mark("points", "Point merge");
const pathDebug = mergePathLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed);
patchTimer.mark("paths", "Path merge and connector repair");
const influenceDebug = refreshPatchInfluenceFields(world, sourceMap, rects);
patchTimer.mark("influence", "Influence refresh");
const landDebug = repairLanduseAndPopulation(world, rects);
const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping, seed);
const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, fieldDebug.adminIdMapping);
const municipalCoherence = reconcileMunicipalMetadata({
adminId: world.fields.adminId,
municipalityId: world.fields.municipalityId,
prefectureRegionId: world.fields.prefectureRegionId,
sea: world.fields.sea,
adminCenters: sourceMap.adminCenters || [],
municipalityToPrefectureId: sourceMap.municipalityToPrefectureId,
fields: world.fields,
width: world.width,
height: world.height,
pointOffsetX: world.originX || 0,
pointOffsetY: world.originY || 0,
seed,
});
sourceMap.adminCenters = municipalCoherence.adminCenters;
sourceMap.municipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId;
const prefectureCoherence = refreshPrefectureRegionsMetadata({
prefectureRegionId: world.fields.prefectureRegionId,
sea: world.fields.sea,
existing: sourceMap.prefectureRegions || [],
fields: world.fields,
width: world.width,
height: world.height,
pointOffsetX: world.originX || 0,
pointOffsetY: world.originY || 0,
});
sourceMap.prefectureRegions = prefectureCoherence.prefectureRegions;
sourceMap.adminDebug = {
...(sourceMap.adminDebug || {}),
municipalCoherence: municipalCoherence.debug,
prefectureMetadataCoherence: prefectureCoherence.debug,
};
const segmentDebug = mergeSegmentLayers(world, sourceMap, rects, seed);
const candidateCompartmentSegmentsAdded = mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, fieldDebug.window, seed);
const landDebug = repairLanduseAndPopulation(world, rects);
const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping);
patchTimer.mark("segments", "Boundary and debug segment merge");
sanitizeExistingLogistics(sourceMap);
patchTimer.mark("cleanup", "Land-use, admin, and label cleanup");
const patchTimings = patchTimer.timings;
const seaStats = countSea(world, rects.coreRect);
const label = terrainLabel(candidate, terrainType);
@ -1722,19 +2181,27 @@ export function generatePatch(world, userRectInput, options = {}) {
villages: (sourceMap.villages || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
...pointDebug,
...pathDebug,
...influenceDebug,
adminCellsReassigned: fieldDebug.adminCellsReassigned,
adminIdMapping: fieldDebug.adminIdMappingDebug,
sourceAdminMetadataUpdated,
...adminCoverageDebug,
...finalAdminCoverageDebug,
finalSeaAdminCellsCleared: finalAdminCoverageDebug.seaAdminCellsCleared || 0,
finalLandAdminCellsFilled: finalAdminCoverageDebug.landAdminCellsFilled || 0,
finalPrefectureCellsFilled: finalAdminCoverageDebug.prefectureCellsFilled || 0,
finalAdminPrefectureCellsAligned: finalAdminCoverageDebug.adminPrefectureCellsAligned || 0,
municipalCoherence: municipalCoherence.debug,
prefectureMetadataCoherence: prefectureCoherence.debug,
continuityCellsRestored: fieldDebug.continuityCellsRestored || 0,
continuityCellsRemapped: fieldDebug.continuityCellsRemapped || 0,
adminSeamCellsResolved: fieldDebug.adminSeamCellsResolved || 0,
prefectureSeamCellsResolved: fieldDebug.prefectureSeamCellsResolved || 0,
...terrainSeamDebug,
landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated,
displayMaskUpdated: maskDebug.displayMaskUpdated || 0,
logisticsLabelsMigrated,
candidateCacheHit: cacheHit,
candidateCacheSize: cacheSize,
...segmentDebug,
candidateCompartmentSegmentsAdded,
};
@ -1757,7 +2224,8 @@ export function generatePatch(world, userRectInput, options = {}) {
variant,
candidateOriginX,
candidateOriginY,
patchGenerationMode: "legacy-full-pipeline",
patchGenerationMode,
patchTimings,
updatedCells: fieldDebug.updatedCells,
terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced,
coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged,
@ -1771,6 +2239,7 @@ export function generatePatch(world, userRectInput, options = {}) {
world.generatedRects = [...(world.generatedRects || []), record];
world.invalidatedRects = [...(world.invalidatedRects || []), { ...rects.writeRect }];
world.lastPatchResult = record;
world.patchGenerationSerial = (world.patchGenerationSerial || 0) + 1;
return {
ok: true,
@ -1789,7 +2258,8 @@ export function generatePatch(world, userRectInput, options = {}) {
variant,
candidateOriginX,
candidateOriginY,
patchGenerationMode: "legacy-full-pipeline",
patchGenerationMode,
patchTimings,
updatedCells: record.updatedCells,
terrainCellsFullyReplaced: record.terrainCellsFullyReplaced,
coastCellsChanged: record.coastCellsChanged,

View file

@ -1190,7 +1190,7 @@ export function drawMap(canvas, map, options) {
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, forceLabel: true, labelPriorityBase: p.labelPriorityBase || 1900 }));
if (mode === "admin") {
const municipalLabels = (map.adminCenters || [])
.filter((p) => p && p.name)
.filter((p) => p && p.name && Number.isFinite(p.x) && Number.isFinite(p.y) && p.x >= 0 && p.y >= 0 && p.x < map.width && p.y < map.height && map.adminId?.[Math.round(p.y) * map.width + Math.round(p.x)] === (p.adminId ?? p.municipalityId ?? p.adminNumericId))
.map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 }));
drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity);
finish();

30
test.js
View file

@ -15,7 +15,7 @@ const result = document.getElementById("result");
const logLines = [];
let failed = 0;
const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rendererSource, appSource, mapPipelineSource, mapAdminStageSource, testSource] = await Promise.all([
const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rendererSource, appSource, mapPipelineSource, mapAdminStageSource, mapPatchSource, worldMapSource, municipalSource, 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()),
@ -24,6 +24,9 @@ const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rende
fetch("./app.js").then((response) => response.text()),
fetch("./mapPipeline.js").then((response) => response.text()),
fetch("./mapAdminStage.js").then((response) => response.text()),
fetch("./mapPatch.js").then((response) => response.text()),
fetch("./worldMap.js").then((response) => response.text()),
fetch("./mapMunicipalCoherence.js").then((response) => response.text()),
fetch("./test.js").then((response) => response.text()),
]);
@ -675,6 +678,23 @@ try {
const removedContextSuffixKey = "context" + "Suffixes";
assert(!namesSource.includes(removedContextSuffixConst) && !namesSource.includes(removedContextSuffixKey), "hidden context suffix arrays are absent");
assert(!/export\s+const\s+NAME_PROBABILITIES[\s\S]*export\s+const\s+NAME_PROBABILITIES/.test(namesSource), "NAME_PROBABILITIES has one source");
assert(mapPatchSource.includes("splitWorldPathByPatch") && mapPatchSource.includes("patchAffected"), "patch path merging is alpha-aware for lasso selections");
assert(!mapPatchSource.includes("patchAlpha(x, y, rects, 0)"), "patch admin repair uses the active patch seed");
assert(mapPatchSource.includes("refreshPatchInfluenceFields") && mapPatchSource.includes("roadCellsPainted"), "patch generation refreshes derived transport influence fields after path merges");
assert(mapPatchSource.includes("normalizeGeneratedPointIds"), "patch generation normalizes generated point admin and prefecture ids");
assert(mapPatchSource.includes("generateMap(seed") && mapPatchSource.includes('patchGenerationMode = "legacy-full-pipeline"'), "patch generation remains full-pipeline simulation");
assert(mapPatchSource.includes("patchTimings") && appSource.includes("result.patchTimings"), "patch generation returns and renders timing rows");
assert(mapPatchSource.includes("PATCH_CANDIDATE_CACHE_LIMIT") && mapPatchSource.includes("patchCandidateCacheKey") && mapPatchSource.includes("cache.size > PATCH_CANDIDATE_CACHE_LIMIT"), "patch candidate cache is bounded and keyed");
assert(mapPatchSource.includes("getPatchAlphaCache") && mapPatchSource.includes("getPatchSourceIndexCache"), "patch generation caches alpha and source-index grids for merge work");
assert(mapPatchSource.includes("const searchRect = expandRect(rect, 16, world)") && mapPatchSource.includes("connectorAttempts"), "patch connector pathfinding uses bounded attempts and a shared search rect");
assert(worldMapSource.includes("shiftSelectionShape") && worldMapSource.includes("selectionShape = shiftSelectionShape"), "world expansion shifts stored lasso patch polygons");
assert(municipalSource.includes("reconcileMunicipalMetadata") && mapOutputSource.includes("reconcileMunicipalMetadata") && mapPatchSource.includes("reconcileMunicipalMetadata"), "municipal metadata is reconciled in output and patch repair");
assert(appSource.includes("mappedPref === id") && !appSource.includes("return nearestNamedAdminCenter(map, cellIndex, maxDistance, null)"), "tooltip municipal fallback requires exact coherent ids");
assert(mapPatchSource.includes("signedDist") && mapPatchSource.includes("patchBand"), "lasso patch alpha uses a feathered signed seam band");
assert(mapPatchSource.includes("repairDiscreteSeamOwnership") && mapPatchSource.includes("chooseSeamOwnerValue"), "patch admin and prefecture seams use ownership repair");
assert(mapPatchSource.includes("featherTerrainSeam") && mapPatchSource.includes("terrainFeatherCells"), "patch terrain transition bands are feather-smoothed");
assert(mapPatchSource.includes("strongOnly") && mapPatchSource.includes("patchAlpha(x, y, rects, seed)"), "patch water topology avoids weak low-alpha seam flips");
assert(!municipalSource.includes("Municipality ${id + 1}") && !municipalSource.includes("Prefecture ${id + 1}") && municipalSource.includes("自治${id + 1}") && municipalSource.includes("県域${id + 1}"), "fallback municipal and prefecture metadata avoids generic English labels");
assert(map.elevation.length === size, "elevation length matches map size");
assert(map.sea.length === size, "sea length matches map size");
@ -777,6 +797,14 @@ try {
assert(map.externalGateways.length > 0, "external gateways exist");
assert(map.minorRoads.length > 0, "minor roads exist");
assert(map.adminCenters.length >= 18, "municipality center count is sufficiently large");
const activeMunicipalityIds = new Set([...map.adminId].filter((id, i) => id >= 0 && !map.sea[i]));
const centerIds = new Set(map.adminCenters.map((center) => center.adminId ?? center.municipalityId ?? center.adminNumericId).filter((id) => Number.isFinite(id)));
assert(activeMunicipalityIds.size === map.adminCenters.length && [...activeMunicipalityIds].every((id) => centerIds.has(id)), "every active municipality has exactly one municipal center");
assert(map.adminCenters.every((center) => activeMunicipalityIds.has(center.adminId ?? center.municipalityId ?? center.adminNumericId)), "municipal centers do not point to inactive municipalities");
assert([...activeMunicipalityIds].every((id) => map.municipalityToPrefectureId?.[id] >= 0), "every active municipality maps to a prefecture");
const activePrefectureIds = new Set([...map.prefectureRegionId].filter((id, i) => id >= 0 && !map.sea[i]));
const prefMetadataIds = new Set((map.prefectureRegions || []).map((region) => region.id));
assert([...activePrefectureIds].every((id) => prefMetadataIds.has(id)), "every active prefecture id has metadata");
assert(adminMetrics.municipalityCount >= 10, "terrain snapping preserves a reasonable municipality count");
assert(adminMetrics.centerValidRatio >= 0.95, "municipality centers remain on valid assigned land cells");
assert(adminMetrics.maxComponents <= 4, "municipal topology repair prevents excessive disconnected fragments");

View file

@ -125,24 +125,34 @@ function expandRectByOffset(rect, dx, dy) {
return { ...rect, x0: rect.x0 + dx, y0: rect.y0 + dy, x1: rect.x1 + dx, y1: rect.y1 + dy };
}
function shiftSelectionShape(shape, dx, dy) {
if (!shape?.polygon) return shape;
return {
...shape,
x0: Number.isFinite(shape.x0) ? shape.x0 + dx : shape.x0,
y0: Number.isFinite(shape.y0) ? shape.y0 + dy : shape.y0,
x1: Number.isFinite(shape.x1) ? shape.x1 + dx : shape.x1,
y1: Number.isFinite(shape.y1) ? shape.y1 + dy : shape.y1,
polygon: shape.polygon.map((p) => ({ ...p, x: p.x + dx, y: p.y + dy })),
};
}
function shiftPatchMetadata(item, dx, dy) {
const out = expandRectByOffset(item, dx, dy);
for (const sub of ["coreRect", "writeRect", "repairRect", "contextRect", "blendRect", "transportReachRect"]) {
if (out?.[sub]) out[sub] = expandRectByOffset(out[sub], dx, dy);
}
if (out?.selectionShape) out.selectionShape = shiftSelectionShape(out.selectionShape, dx, dy);
return out;
}
function shiftRectCollections(world, dx, dy) {
if (!dx && !dy) return;
for (const key of ["generatedRects", "invalidatedRects", "humanPatchHistory"]) {
if (!Array.isArray(world[key])) continue;
world[key] = world[key].map((item) => {
const out = expandRectByOffset(item, dx, dy);
for (const sub of ["coreRect", "writeRect", "repairRect", "contextRect", "blendRect", "transportReachRect"]) {
if (out?.[sub]) out[sub] = expandRectByOffset(out[sub], dx, dy);
}
return out;
});
}
if (world.lastPatchResult) {
world.lastPatchResult = { ...world.lastPatchResult };
for (const sub of ["coreRect", "writeRect", "repairRect", "contextRect", "blendRect", "transportReachRect"]) {
if (world.lastPatchResult[sub]) world.lastPatchResult[sub] = expandRectByOffset(world.lastPatchResult[sub], dx, dy);
}
world[key] = world[key].map((item) => shiftPatchMetadata(item, dx, dy));
}
if (world.lastPatchResult) world.lastPatchResult = shiftPatchMetadata(world.lastPatchResult, dx, dy);
}
export function expandWorldMap(world, margins = {}) {