map/mapPatch.js
2026-05-29 14:31:42 +09:00

1802 lines
72 KiB
JavaScript

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";
export const PATCH_MIN_WIDTH = 48;
export const PATCH_MIN_HEIGHT = 48;
export const PATCH_MIN_AREA = 3000;
const POINT_LAYER_KEYS = [
"villages", "geographicUrbanAnchors", "markets", "castles", "castleTowns", "castleRuins",
"ports", "crossings", "passes", "modernCities", "satelliteCities", "stations",
"interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters",
"externalGateways", "prefectureRegions",
];
const PATH_LAYER_KEYS = [
"premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads",
"railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways",
"icAccessRoads", "mainRivers", "tributaryRivers", "smallStreams", "riverPaths",
];
const ROAD_LAYER_KEYS = new Set(["premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads", "expressways", "externalExpressways", "icAccessRoads"]);
const RAIL_LAYER_KEYS = new Set(["railways", "branchRailways", "ringRailways", "externalRailways"]);
const RIVER_LAYER_KEYS = new Set(["mainRivers", "tributaryRivers", "smallStreams", "riverPaths"]);
const SEGMENT_LAYER_KEYS = ["adminBorders", "regionalPrefectureBorders", "prefectureBorder"];
const ID_FIELD_OFFSETS = new Map([
["adminId", 100000],
["municipalityId", 100000],
["prefectureRegionId", 200000],
["regionId", 300000],
["naturalCompartmentId", 400000],
["watershedId", 500000],
]);
const DISCRETE_FIELD_NAMES = new Set([
"sea", "ocean", "lake", "prefectureMask", "landMask", "landuse",
"adminId", "municipalityId", "prefectureRegionId", "regionId", "naturalCompartmentId", "watershedId",
]);
const ADMIN_CONTINUITY_FIELD_NAMES = new Set(["adminId", "municipalityId", "prefectureRegionId"]);
const NATURAL_CONTINUITY_FIELD_NAMES = new Set(["regionId", "naturalCompartmentId", "watershedId"]);
const CONTINUITY_FIELD_NAMES = new Set([...ADMIN_CONTINUITY_FIELD_NAMES, ...NATURAL_CONTINUITY_FIELD_NAMES]);
const SKIP_CELL_FIELDS = new Set(["flowTo", "prefectureMask", "humanRegionMask"]);
function worldIndex(world, x, y) {
if (!world || x < 0 || y < 0 || x >= world.width || y >= world.height) return -1;
return y * world.width + x;
}
function sourceIndex(x, y) {
if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) return -1;
return y * MAP_W + x;
}
function isCellField(value) {
return ArrayBuffer.isView(value) && typeof value.length === "number" && value.length === SIZE;
}
function rectWidth(rect) {
return Math.max(0, Math.floor(rect.x1) - Math.floor(rect.x0));
}
function rectHeight(rect) {
return Math.max(0, Math.floor(rect.y1) - Math.floor(rect.y0));
}
function rectArea(rect) {
return rectWidth(rect) * rectHeight(rect);
}
function normalizeRect(rect) {
if (!rect) return null;
const x0 = Math.floor(Math.min(rect.x0, rect.x1));
const y0 = Math.floor(Math.min(rect.y0, rect.y1));
const x1 = Math.ceil(Math.max(rect.x0, rect.x1));
const y1 = Math.ceil(Math.max(rect.y0, rect.y1));
return { x0, y0, x1, y1 };
}
function isPolygonSelection(input) {
return !!input && Array.isArray(input.polygon) && input.polygon.length >= 3;
}
function clampPointToWorld(point, world) {
return {
x: clamp(Math.round(point.x ?? 0), 0, Math.max(0, (world?.width || 1) - 1)),
y: clamp(Math.round(point.y ?? 0), 0, Math.max(0, (world?.height || 1) - 1)),
};
}
function polygonBounds(polygon) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of polygon || []) {
if (!Number.isFinite(p?.x) || !Number.isFinite(p?.y)) continue;
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
}
if (!Number.isFinite(minX)) return null;
return { x0: Math.floor(minX), y0: Math.floor(minY), x1: Math.ceil(maxX + 1), y1: Math.ceil(maxY + 1) };
}
function polygonAreaCells(polygon) {
if (!polygon || polygon.length < 3) return 0;
let area = 0;
for (let i = 0; i < polygon.length; i++) {
const a = polygon[i];
const b = polygon[(i + 1) % polygon.length];
area += a.x * b.y - b.x * a.y;
}
return Math.abs(area) * 0.5;
}
function normalizeSelectionShape(input, world = null) {
if (!isPolygonSelection(input)) return normalizeRect(input);
const polygon = (input.polygon || []).map((p) => world ? clampPointToWorld(p, world) : { x: Math.round(p.x), y: Math.round(p.y) });
const bounds = polygonBounds(polygon);
if (!bounds) return null;
return {
kind: input.kind || 'lasso',
polygon,
areaCells: Math.max(1, Math.round(input.areaCells || polygonAreaCells(polygon))),
x0: bounds.x0,
y0: bounds.y0,
x1: bounds.x1,
y1: bounds.y1,
};
}
function pointInPolygon(px, py, polygon) {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x + 0.5;
const yi = polygon[i].y + 0.5;
const xj = polygon[j].x + 0.5;
const yj = polygon[j].y + 0.5;
const intersect = ((yi > py) !== (yj > py)) && (px < ((xj - xi) * (py - yi)) / Math.max(1e-6, (yj - yi)) + xi);
if (intersect) inside = !inside;
}
return inside;
}
function pointSegmentDistance(px, py, ax, ay, bx, by) {
const dx = bx - ax;
const dy = by - ay;
const len2 = dx * dx + dy * dy;
if (len2 <= 1e-6) return Math.hypot(px - ax, py - ay);
const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1);
return Math.hypot(px - (ax + dx * t), py - (ay + dy * t));
}
function distanceToPolygonEdge(px, py, polygon) {
let best = Infinity;
for (let i = 0; i < polygon.length; i++) {
const a = polygon[i];
const b = polygon[(i + 1) % polygon.length];
best = Math.min(best, pointSegmentDistance(px, py, a.x + 0.5, a.y + 0.5, b.x + 0.5, b.y + 0.5));
}
return best;
}
function insideRect(x, y, rect) {
return !!rect && x >= rect.x0 && y >= rect.y0 && x < rect.x1 && y < rect.y1;
}
function expandRect(rect, margin, world = null) {
return {
x0: Math.max(0, rect.x0 - margin),
y0: Math.max(0, rect.y0 - margin),
x1: Math.min(world?.width ?? Infinity, rect.x1 + margin),
y1: Math.min(world?.height ?? Infinity, rect.y1 + margin),
};
}
function distanceToRectEdge(x, y, rect) {
return Math.min(x - rect.x0, y - rect.y0, rect.x1 - 1 - x, rect.y1 - 1 - y);
}
function defaultForField(name, Constructor) {
if (name === "sea" || name === "ocean") return 1;
if (name === "elevation") return 0.08;
if (ID_FIELD_OFFSETS.has(name)) return -1;
if (Constructor === Float32Array || Constructor === Float64Array) return 0;
return 0;
}
function ensureWorldField(world, name, source) {
if (!source || !isCellField(source)) return null;
const Constructor = source.constructor;
const expected = world.width * world.height;
if (!world.fields[name] || world.fields[name].length !== expected) {
world.fields[name] = new Constructor(expected);
const fallback = defaultForField(name, Constructor);
if (fallback !== 0) world.fields[name].fill(fallback);
}
return world.fields[name];
}
export function clipPatchRect(rect, world) {
const normalized = normalizeSelectionShape(rect, world);
if (!normalized || !world) return null;
if (isPolygonSelection(normalized)) return normalized;
return {
x0: Math.min(Math.max(normalized.x0, 0), world.width),
y0: Math.min(Math.max(normalized.y0, 0), world.height),
x1: Math.min(Math.max(normalized.x1, 0), world.width),
y1: Math.min(Math.max(normalized.y1, 0), world.height),
};
}
export function validatePatchRect(rect, world) {
const clipped = clipPatchRect(rect, world);
if (!clipped) return { ok: false, rect: null, reason: "No selected area." };
const width = rectWidth(clipped);
const height = rectHeight(clipped);
const area = isPolygonSelection(clipped) ? Math.max(1, Math.round(clipped.areaCells || polygonAreaCells(clipped.polygon))) : width * height;
if (width < PATCH_MIN_WIDTH || height < PATCH_MIN_HEIGHT) {
const parts = [];
if (width < PATCH_MIN_WIDTH) parts.push(`minimum width ${PATCH_MIN_WIDTH} cells`);
if (height < PATCH_MIN_HEIGHT) parts.push(`minimum height ${PATCH_MIN_HEIGHT} cells`);
return {
ok: false,
rect: clipped,
width,
height,
area,
reason: `Selection is too small: ${parts.join(", ")} required. Current ${width} x ${height} cells, ${area.toLocaleString()} cells total.`,
};
}
if (area < PATCH_MIN_AREA) {
return {
ok: false,
rect: clipped,
width,
height,
area,
reason: `Selection area is too small: minimum area ${PATCH_MIN_AREA.toLocaleString()} cells required. Current ${area.toLocaleString()} cells.`,
};
}
return { ok: true, rect: clipped, width, height, area, reason: "" };
}
export function buildPatchRects(userRect, world = null) {
const coreRect = normalizeSelectionShape(userRect, world);
const width = rectWidth(coreRect);
const height = rectHeight(coreRect);
const shortSide = Math.max(1, Math.min(width, height));
const desiredWrite = Math.min(96, Math.max(28, Math.floor(shortSide * 0.42)));
const maxBySource = Math.max(0, Math.floor(Math.min((MAP_W - width) / 2, (MAP_H - height) / 2)));
const writeMargin = Math.max(0, Math.min(desiredWrite, maxBySource));
const desiredRepair = Math.min(120, writeMargin + Math.max(8, Math.floor(shortSide * 0.12)));
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 transportReachRect = expandRect(coreRect, transportReachMargin, world);
return {
coreRect,
writeRect,
repairRect,
contextRect: repairRect,
transportReachRect,
blendRect: coreRect,
userRect: writeRect,
selectedRect: coreRect,
selectionShape: isPolygonSelection(coreRect) ? coreRect : null,
writeMargin,
repairMargin,
transportReachMargin,
outerMargin: writeMargin,
innerMargin: 0,
};
}
function patchAlpha(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);
const low = valueNoise(x, y, seed ^ 0x7153a9d1, 18) - 0.5;
const mid = valueNoise(x, y, seed ^ 0x9e3779b9, 7) - 0.5;
const shape = rects.selectionShape;
if (shape?.polygon?.length >= 3) {
const px = x + 0.5;
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 edge = distanceToRectEdge(x, y, writeRect);
const noisyEdge = edge + low * margin * 0.42 + mid * margin * 0.16;
const base = smoothstep(clamp(noisyEdge / margin));
// Keep the expanded repair band as the actual seam. The user's selected core
// is still dominant, but the write edge is irregular, so coastlines and land-use
// no longer inherit the rectangular user selection as a hard boundary.
return clamp(base);
}
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);
if (NATURAL_CONTINUITY_FIELD_NAMES.has(name)) return clamp(0.68 + n * 0.16, 0.54, 0.82);
return clamp(0.46 + n * 0.20, 0.28, 0.68);
}
function continuitySegmentAllowed(x, y, nx, ny, rects, seed, minAlpha = 0.42) {
if (!rects) return true;
return Math.min(patchAlpha(x, y, rects, seed), patchAlpha(nx, ny, rects, seed)) >= minAlpha;
}
function sourceWindowForRects(rects) {
const cx = (rects.coreRect.x0 + rects.coreRect.x1 - 1) / 2;
const cy = (rects.coreRect.y0 + rects.coreRect.y1 - 1) / 2;
return {
worldCenterX: cx,
worldCenterY: cy,
sourceCenterX: (MAP_W - 1) / 2,
sourceCenterY: (MAP_H - 1) / 2,
};
}
function sourceCoordForWorld(window, x, y) {
return {
x: Math.round(x - window.worldCenterX + window.sourceCenterX),
y: Math.round(y - window.worldCenterY + window.sourceCenterY),
};
}
function worldCoordForSource(window, sx, sy) {
return {
x: Math.round(sx - window.sourceCenterX + window.worldCenterX),
y: Math.round(sy - window.sourceCenterY + window.worldCenterY),
};
}
function fieldIdOffset(name, seed) {
const base = ID_FIELD_OFFSETS.get(name) || 0;
if (!base) return 0;
return base + ((seed >>> 0) % 997) * 10000;
}
function maxFieldId(field) {
if (!field) return -1;
let max = -1;
for (let i = 0; i < field.length; i++) {
const id = field[i];
if (Number.isFinite(id) && id > max) max = id;
}
return max;
}
function addMappingVote(votes, from, to, weight = 1) {
if (!Number.isFinite(from) || from < 0 || !Number.isFinite(to) || to < 0) return;
const key = Math.floor(from);
const target = Math.floor(to);
const bucket = votes.get(key) || new Map();
bucket.set(target, (bucket.get(target) || 0) + Math.max(1, weight));
votes.set(key, bucket);
}
function chooseVotedTarget(bucket, minVotes = 1) {
let best = -1;
let bestVotes = 0;
for (const [target, count] of bucket || []) {
if (count > bestVotes || (count === bestVotes && target < best)) {
best = target;
bestVotes = count;
}
}
return best >= 0 && bestVotes >= minVotes ? best : -1;
}
function collectCandidateIdsInRect(candidateField, rects, window, minAlpha = 0.20, seed = 0) {
const ids = new Set();
if (!candidateField) return ids;
const rect = rects.writeRect;
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);
if (si < 0) continue;
const id = candidateField[si];
if (Number.isFinite(id) && id >= 0) ids.add(Math.floor(id));
}
}
return ids;
}
function buildCandidatePrefByAdmin(candidateMap, candidateAdminIds) {
const out = new Map();
const direct = candidateMap?.municipalityToPrefectureId;
for (const id of candidateAdminIds || []) {
const pref = direct?.[id];
if (Number.isFinite(pref) && pref >= 0) out.set(id, Math.floor(pref));
}
const adminField = candidateMap?.adminId || candidateMap?.municipalityId;
const prefField = candidateMap?.prefectureRegionId;
if (!adminField || !prefField) return out;
const votes = new Map();
for (let i = 0; i < adminField.length; i++) {
const admin = adminField[i];
const pref = prefField[i];
if (!Number.isFinite(admin) || admin < 0 || !Number.isFinite(pref) || pref < 0) continue;
if (candidateAdminIds?.size && !candidateAdminIds.has(Math.floor(admin))) continue;
addMappingVote(votes, admin, pref, 1);
}
for (const [admin, bucket] of votes) {
if (!out.has(admin)) {
const pref = chooseVotedTarget(bucket, 1);
if (pref >= 0) out.set(admin, pref);
}
}
return out;
}
export function buildAdminIdMapping({ candidateMap, world, writeRect, seamBand = 24, window = null, rects = null, seed = 0 } = {}) {
const actualWindow = window || (rects ? sourceWindowForRects(rects) : null);
const actualRects = rects || { writeRect, writeMargin: seamBand || 1 };
if (!candidateMap || !world || !writeRect || !actualWindow) {
return {
prefecture: new Map(),
municipality: new Map(),
admin: new Map(),
candidateAdminToPrefecture: new Map(),
debug: { prefecturesMappedToExisting: 0, prefecturesAllocated: 0, municipalitiesMappedToExisting: 0, municipalitiesAllocated: 0 },
};
}
const candidateAdmin = candidateMap.adminId || candidateMap.municipalityId;
const candidateMunicipality = candidateMap.municipalityId || candidateAdmin;
const candidatePrefecture = candidateMap.prefectureRegionId;
const worldAdmin = world.fields?.adminId || world.fields?.municipalityId;
const worldMunicipality = world.fields?.municipalityId || worldAdmin;
const worldPrefecture = world.fields?.prefectureRegionId;
const candidateAdminIds = collectCandidateIdsInRect(candidateAdmin, actualRects, actualWindow, 0.18, seed);
const candidateMunicipalityIds = collectCandidateIdsInRect(candidateMunicipality, actualRects, actualWindow, 0.18, seed);
const candidatePrefectureIds = collectCandidateIdsInRect(candidatePrefecture, actualRects, actualWindow, 0.18, seed);
const candidateAdminToPrefecture = buildCandidatePrefByAdmin(candidateMap, candidateAdminIds);
const adminVotes = new Map();
const municipalityVotes = new Map();
const prefectureVotes = new Map();
const band = Math.max(2, Math.floor(seamBand));
const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
for (let y = writeRect.y0; y < writeRect.y1; y++) {
for (let x = writeRect.x0; x < writeRect.x1; x++) {
const edge = distanceToRectEdge(x, y, writeRect);
if (edge > band) continue;
const s = sourceCoordForWorld(actualWindow, x, y);
const si = sourceIndex(s.x, s.y);
const wi = worldIndex(world, x, y);
if (si < 0 || wi < 0) continue;
const cAdmin = candidateAdmin?.[si] ?? -1;
const cMunicipality = candidateMunicipality?.[si] ?? cAdmin;
const cPrefecture = candidatePrefecture?.[si] ?? -1;
const sameCellWeight = Math.max(1, band + 1 - edge);
addMappingVote(adminVotes, cAdmin, worldAdmin?.[wi] ?? -1, sameCellWeight);
addMappingVote(municipalityVotes, cMunicipality, worldMunicipality?.[wi] ?? worldAdmin?.[wi] ?? -1, sameCellWeight);
addMappingVote(prefectureVotes, cPrefecture, worldPrefecture?.[wi] ?? -1, sameCellWeight);
for (const [dx, dy] of dirs) {
for (let step = 1; step <= 6; step++) {
const nx = x + dx * step;
const ny = y + dy * step;
if (insideRect(nx, ny, writeRect)) continue;
const ni = worldIndex(world, nx, ny);
if (ni < 0) break;
const w = Math.max(1, 7 - step) + Math.max(0, band - edge) * 0.25;
addMappingVote(adminVotes, cAdmin, worldAdmin?.[ni] ?? -1, w);
addMappingVote(municipalityVotes, cMunicipality, worldMunicipality?.[ni] ?? worldAdmin?.[ni] ?? -1, w);
addMappingVote(prefectureVotes, cPrefecture, worldPrefecture?.[ni] ?? -1, w);
break;
}
}
}
}
const prefecture = new Map();
let nextPrefectureId = maxFieldId(worldPrefecture) + 1;
let prefecturesMappedToExisting = 0;
let prefecturesAllocated = 0;
for (const id of [...candidatePrefectureIds].sort((a, b) => a - b)) {
const voted = chooseVotedTarget(prefectureVotes.get(id), 3);
if (voted >= 0) {
prefecture.set(id, voted);
prefecturesMappedToExisting++;
} else {
prefecture.set(id, nextPrefectureId++);
prefecturesAllocated++;
}
}
const usedAdminIds = new Set();
if (worldAdmin) {
for (let i = 0; i < worldAdmin.length; i++) if (worldAdmin[i] >= 0) usedAdminIds.add(worldAdmin[i]);
}
const municipality = new Map();
const admin = new Map();
let nextMunicipalityId = maxFieldId(worldAdmin || worldMunicipality) + 1;
let municipalitiesMappedToExisting = 0;
let municipalitiesAllocated = 0;
const allMunicipalityIds = new Set([...candidateAdminIds, ...candidateMunicipalityIds]);
for (const id of [...allMunicipalityIds].sort((a, b) => a - b)) {
const voted = chooseVotedTarget(municipalityVotes.get(id) || adminVotes.get(id), 4);
if (voted >= 0) {
municipality.set(id, voted);
admin.set(id, voted);
municipalitiesMappedToExisting++;
} else {
while (usedAdminIds.has(nextMunicipalityId)) nextMunicipalityId++;
municipality.set(id, nextMunicipalityId);
admin.set(id, nextMunicipalityId);
usedAdminIds.add(nextMunicipalityId);
nextMunicipalityId++;
municipalitiesAllocated++;
}
}
const municipalityToPrefecture = new Map();
for (const [candidateAdminId, worldAdminId] of admin) {
const candidatePrefId = candidateAdminToPrefecture.get(candidateAdminId);
const worldPrefId = prefecture.get(candidatePrefId);
if (Number.isFinite(worldAdminId) && Number.isFinite(worldPrefId)) municipalityToPrefecture.set(worldAdminId, worldPrefId);
}
return {
prefecture,
municipality,
admin,
candidateAdminToPrefecture,
municipalityToPrefecture,
debug: {
candidatePrefectureIds: candidatePrefectureIds.size,
candidateMunicipalityIds: allMunicipalityIds.size,
prefecturesMappedToExisting,
prefecturesAllocated,
municipalitiesMappedToExisting,
municipalitiesAllocated,
},
};
}
function remapAdminCandidateValue(name, raw, adminIdMapping) {
if (!Number.isFinite(raw) || raw < 0 || !adminIdMapping) return raw;
const id = Math.floor(raw);
if (name === "prefectureRegionId") return adminIdMapping.prefecture?.get(id) ?? raw;
if (name === "adminId") return adminIdMapping.admin?.get(id) ?? raw;
if (name === "municipalityId") return adminIdMapping.municipality?.get(id) ?? adminIdMapping.admin?.get(id) ?? raw;
return raw;
}
function numericFeatureId(point, keys) {
for (const key of keys) {
const value = point?.[key];
if (Number.isFinite(value) && value >= 0) return Math.floor(value);
}
return -1;
}
function summarizeIdMapping(mapping) {
return { ...(mapping?.debug || {}) };
}
function updateSourceAdminMetadata(sourceMap, adminIdMapping) {
if (!sourceMap || !adminIdMapping?.municipalityToPrefecture?.size) return 0;
let maxId = -1;
const current = sourceMap.municipalityToPrefectureId;
if (current && typeof current.length === "number") maxId = Math.max(maxId, current.length - 1);
for (const [adminId] of adminIdMapping.municipalityToPrefecture) maxId = Math.max(maxId, adminId);
const next = new Int32Array(Math.max(0, maxId + 1));
next.fill(-1);
if (current && typeof current.length === "number") {
for (let i = 0; i < current.length && i < next.length; i++) next[i] = current[i] ?? -1;
}
let updated = 0;
for (const [adminId, prefId] of adminIdMapping.municipalityToPrefecture) {
if (!Number.isFinite(adminId) || adminId < 0 || !Number.isFinite(prefId) || prefId < 0) continue;
if (next[adminId] !== prefId) updated++;
next[adminId] = prefId;
}
sourceMap.municipalityToPrefectureId = next;
sourceMap.patchAdminIdMappingDebug = summarizeIdMapping(adminIdMapping);
return updated;
}
function cloneContinuityFields(world) {
const out = new Map();
for (const name of CONTINUITY_FIELD_NAMES) {
const field = world?.fields?.[name];
if (ArrayBuffer.isView(field)) out.set(name, new field.constructor(field));
}
return out;
}
function stabilizeContinuitySeam(world, rects, oldFields, seed = 0) {
let restored = 0;
let remapped = 0;
const margin = Math.max(2, rects.writeMargin || 1);
for (const name of CONTINUITY_FIELD_NAMES) {
const field = world.fields?.[name];
const old = oldFields?.get(name);
if (!field || !old) continue;
const isPrefecture = name === "prefectureRegionId";
const isAdmin = name === "adminId" || name === "municipalityId";
const preserveAlpha = isPrefecture ? 0.94 : isAdmin ? 0.90 : 0.74;
const preserveEdge = isPrefecture ? margin * 1.25 : isAdmin ? margin : margin * 0.72;
// First preserve the old IDs in the transition band. This prevents the
// writeRect edge from becoming a prefecture/municipal border.
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 || old[i] < 0) continue;
const edge = distanceToRectEdge(x, y, rects.writeRect);
const a = patchAlpha(x, y, rects, seed);
if (edge <= preserveEdge || a < preserveAlpha) {
if (field[i] !== old[i]) { field[i] = old[i]; restored++; }
}
}
}
// Then map candidate IDs that contact an outside ID back to that outside ID.
// This lets prefectures/municipalities cross the generated-area seam instead
// of creating a new border exactly on the seam.
const contacts = new Map();
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
for (let y = rects.writeRect.y0 + 1; y < rects.writeRect.y1 - 1; y++) {
for (let x = rects.writeRect.x0 + 1; x < rects.writeRect.x1 - 1; x++) {
const i = worldIndex(world, x, y);
if (i < 0 || field[i] < 0 || old[i] === field[i]) continue;
const a = patchAlpha(x, y, rects, seed);
if (a < 0.98 && !isPrefecture) continue;
for (const [dx, dy] of dirs) {
const ni = worldIndex(world, x + dx, y + dy);
if (ni < 0 || old[ni] < 0 || old[ni] === field[i]) continue;
if (field[ni] === old[ni] || patchAlpha(x + dx, y + dy, rects, seed) < preserveAlpha) {
const key = field[i];
const bucket = contacts.get(key) || new Map();
bucket.set(old[ni], (bucket.get(old[ni]) || 0) + 1);
contacts.set(key, bucket);
}
}
}
}
const mapping = new Map();
for (const [from, bucket] of contacts) {
let best = -1, bestCount = 0;
for (const [to, count] of bucket) if (count > bestCount) { best = to; bestCount = count; }
if (best >= 0 && bestCount >= (isPrefecture ? 2 : 3)) mapping.set(from, best);
}
if (mapping.size) {
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 && mapping.has(field[i])) { field[i] = mapping.get(field[i]); remapped++; }
}
}
}
}
return { continuityCellsRestored: restored, continuityCellsRemapped: remapped };
}
function copyFullPipelineFields(world, candidate, rects, seed) {
const window = sourceWindowForRects(rects);
const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null;
const oldContinuityFields = cloneContinuityFields(world);
const adminIdMapping = buildAdminIdMapping({
candidateMap: candidate,
world,
writeRect: rects.writeRect,
seamBand: Math.max(8, Math.floor(rects.writeMargin || 24)),
window,
rects,
seed,
});
let updatedCells = 0;
let coastCellsChanged = 0;
let terrainCellsFullyReplaced = 0;
let naturalRegionsUpdated = 0;
let adminCellsReassigned = 0;
let landUseCellsUpdated = 0;
for (const [name, source] of Object.entries(candidate || {})) {
if (SKIP_CELL_FIELDS.has(name) || !isCellField(source)) continue;
const dest = ensureWorldField(world, name, source);
if (!dest) continue;
const isFloat = source.constructor === Float32Array || source.constructor === Float64Array;
const isDiscrete = DISCRETE_FIELD_NAMES.has(name) || !isFloat;
const idOffset = fieldIdOffset(name, seed);
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
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);
if (si < 0) continue;
const alpha = patchAlpha(x, y, rects, seed);
if (alpha <= 0.005) continue;
if (isDiscrete) {
const threshold = continuityReplaceThreshold(name, x, y, rects, seed);
if (alpha >= threshold) {
const raw = source[si];
const mapped = remapAdminCandidateValue(name, raw, adminIdMapping);
const value = (name === "adminId" || name === "municipalityId" || name === "prefectureRegionId")
? mapped
: idOffset && raw >= 0 ? raw + idOffset : raw;
if (name === "sea" && oldSea && dest[wi] !== value) coastCellsChanged++;
if ((name === "naturalCompartmentId" || name === "watershedId" || name === "regionId") && dest[wi] !== value) naturalRegionsUpdated++;
if ((name === "adminId" || name === "municipalityId") && dest[wi] !== value) adminCellsReassigned++;
if (name === "landuse" && dest[wi] !== value) landUseCellsUpdated++;
dest[wi] = value;
}
} else {
const before = dest[wi] || 0;
dest[wi] = lerp(before, source[si] || 0, alpha);
}
if (name === "elevation") {
updatedCells++;
if (alpha > 0.94) terrainCellsFullyReplaced++;
}
}
}
}
// The legacy full pipeline uses `adminId` as the municipality raster and
// assigns municipality metadata on `adminCenters`; it does not expose a
// separate municipalityId cell field. If an old experimental field exists,
// keep it synchronized with the canonical legacy adminId instead of leaving
// stale numeric/one-municipality data in regenerated patches.
if (world.fields.adminId && !candidate?.municipalityId) {
const expected = world.width * world.height;
if (!world.fields.municipalityId || world.fields.municipalityId.length !== expected) {
world.fields.municipalityId = new Int32Array(expected);
world.fields.municipalityId.fill(-1);
}
const municipalityId = world.fields.municipalityId;
const adminId = world.fields.adminId;
for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) {
for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) {
const wi = worldIndex(world, x, y);
if (wi >= 0) municipalityId[wi] = adminId[wi];
}
}
}
// Keep water fields coherent after all continuous fields have been blended.
const sea = world.fields.sea;
const ocean = world.fields.ocean;
const lake = world.fields.lake;
const landuse = world.fields.landuse;
if (sea) {
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) continue;
if (sea[i]) {
if (ocean) ocean[i] = 1;
if (lake) lake[i] = 0;
if (landuse) landuse[i] = LANDUSE.WATER || 0;
} else {
if (ocean) ocean[i] = 0;
if (lake) lake[i] = 0;
}
}
}
}
const continuityDebug = stabilizeContinuitySeam(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++) {
const wi = worldIndex(world, x, y);
if (wi >= 0) world.fields.municipalityId[wi] = world.fields.adminId[wi];
}
}
}
return {
window,
updatedCells,
terrainCellsFullyReplaced,
coastCellsChanged,
naturalRegionsUpdated,
adminCellsReassigned,
landUseCellsUpdated,
adminIdMapping,
adminIdMappingDebug: summarizeIdMapping(adminIdMapping),
...continuityDebug,
};
}
function repairDisplayMasks(world, rects, seed = 0) {
const expected = world.width * world.height;
if (!world.fields.prefectureMask || world.fields.prefectureMask.length !== expected) world.fields.prefectureMask = new Uint8Array(expected);
if (!world.fields.landMask || world.fields.landMask.length !== expected) world.fields.landMask = new Uint8Array(expected);
if (!world.fields.humanRegionMask || world.fields.humanRegionMask.length !== expected) world.fields.humanRegionMask = new Uint8Array(expected);
const coverage = world.fields.prefectureMask;
const landMask = world.fields.landMask;
const humanMask = world.fields.humanRegionMask;
const sea = world.fields.sea;
let displayMaskUpdated = 0;
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) continue;
const a = patchAlpha(x, y, rects, seed);
if (a <= 0.08) continue;
if (!coverage[i]) displayMaskUpdated++;
coverage[i] = 1;
const isSea = Boolean(sea?.[i]);
humanMask[i] = isSea ? 0 : 1;
landMask[i] = isSea ? 0 : 1;
}
}
return { displayMaskUpdated };
}
function nearestLandFieldValue(world, x, y, fieldName, rect, options = {}) {
const field = world.fields?.[fieldName];
const sea = world.fields?.sea;
if (!field) return -1;
const maxRadius = Math.max(1, Math.floor(options.maxRadius || 18));
const requiredPref = Number.isFinite(options.requiredPref) ? Math.floor(options.requiredPref) : null;
const prefField = world.fields?.prefectureRegionId;
for (let r = 1; r <= maxRadius; r++) {
let best = -1;
let bestD = Infinity;
const y0 = Math.max(0, y - r);
const y1 = Math.min(world.height - 1, y + r);
const x0 = Math.max(0, x - r);
const x1 = Math.min(world.width - 1, x + r);
for (let yy = y0; yy <= y1; yy++) {
for (let xx = x0; xx <= x1; xx++) {
if (Math.max(Math.abs(xx - x), Math.abs(yy - y)) !== r) continue;
if (rect && !insideRect(xx, yy, rect)) continue;
const i = worldIndex(world, xx, yy);
if (i < 0 || sea?.[i]) continue;
if (requiredPref !== null && prefField?.[i] !== requiredPref) continue;
const id = field[i];
if (!Number.isFinite(id) || id < 0) continue;
const d = Math.hypot(xx - x, yy - y);
if (d < bestD) { best = Math.floor(id); bestD = d; }
}
}
if (best >= 0) return best;
}
return -1;
}
function lookupPrefectureForAdmin(sourceMap, adminIdMapping, adminId) {
if (!Number.isFinite(adminId) || adminId < 0) return -1;
const id = Math.floor(adminId);
const mapped = adminIdMapping?.municipalityToPrefecture?.get(id);
if (Number.isFinite(mapped) && mapped >= 0) return Math.floor(mapped);
const table = sourceMap?.municipalityToPrefectureId;
if (table && id >= 0 && id < table.length && Number.isFinite(table[id]) && table[id] >= 0) return Math.floor(table[id]);
return -1;
}
function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null) {
const admin = world.fields?.adminId;
if (!admin) return { seaAdminCellsCleared: 0, landAdminCellsFilled: 0, prefectureCellsFilled: 0, adminPrefectureCellsAligned: 0 };
const expected = world.width * world.height;
if (!world.fields.municipalityId || world.fields.municipalityId.length !== expected) {
world.fields.municipalityId = new Int32Array(expected);
world.fields.municipalityId.fill(-1);
}
if (!world.fields.prefectureRegionId || world.fields.prefectureRegionId.length !== expected) {
world.fields.prefectureRegionId = new Int32Array(expected);
world.fields.prefectureRegionId.fill(-1);
}
const municipality = world.fields.municipalityId;
const prefecture = world.fields.prefectureRegionId;
const sea = world.fields.sea;
const coverage = world.fields.prefectureMask;
let seaAdminCellsCleared = 0;
let landAdminCellsFilled = 0;
let prefectureCellsFilled = 0;
let adminPrefectureCellsAligned = 0;
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) continue;
if (sea?.[i]) {
if (admin[i] >= 0 || municipality[i] >= 0 || prefecture[i] >= 0) seaAdminCellsCleared++;
admin[i] = -1;
municipality[i] = -1;
prefecture[i] = -1;
continue;
}
const generated = !coverage || coverage[i] || patchAlpha(x, y, rects, 0) > 0.08;
if (!generated) continue;
if (admin[i] < 0) {
const preferredPref = prefecture[i] >= 0 ? prefecture[i] : null;
let nearest = nearestLandFieldValue(world, x, y, 'adminId', rects.repairRect || rects.writeRect, { requiredPref: preferredPref, maxRadius: 24 });
if (nearest < 0) nearest = nearestLandFieldValue(world, x, y, 'adminId', null, { requiredPref: preferredPref, maxRadius: 18 });
if (nearest >= 0) {
admin[i] = nearest;
municipality[i] = nearest;
landAdminCellsFilled++;
}
}
if (municipality[i] < 0 && admin[i] >= 0) municipality[i] = admin[i];
if (admin[i] >= 0 && municipality[i] !== admin[i]) municipality[i] = admin[i];
let targetPref = lookupPrefectureForAdmin(sourceMap, adminIdMapping, admin[i]);
if (targetPref < 0 && prefecture[i] < 0) targetPref = nearestLandFieldValue(world, x, y, 'prefectureRegionId', rects.repairRect || rects.writeRect, { maxRadius: 28 });
if (targetPref >= 0 && prefecture[i] !== targetPref) {
if (prefecture[i] < 0) prefectureCellsFilled++;
else adminPrefectureCellsAligned++;
prefecture[i] = targetPref;
} else if (prefecture[i] < 0) {
const nearestPref = nearestLandFieldValue(world, x, y, 'prefectureRegionId', null, { maxRadius: 22 });
if (nearestPref >= 0) { prefecture[i] = nearestPref; prefectureCellsFilled++; }
}
}
}
const current = sourceMap?.municipalityToPrefectureId;
let maxId = current?.length ? current.length - 1 : -1;
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 && admin[i] >= 0 && prefecture[i] >= 0) maxId = Math.max(maxId, admin[i]);
}
}
if (sourceMap && maxId >= 0) {
const next = new Int32Array(maxId + 1);
next.fill(-1);
if (current) for (let i = 0; i < current.length && i < next.length; i++) next[i] = current[i] ?? -1;
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 && admin[i] >= 0 && prefecture[i] >= 0) next[admin[i]] = prefecture[i];
}
}
sourceMap.municipalityToPrefectureId = next;
}
return { seaAdminCellsCleared, landAdminCellsFilled, prefectureCellsFilled, adminPrefectureCellsAligned };
}
function smoothWaterTopology(world, rect, seaLevel = 0.30) {
const sea = world.fields.sea;
const ocean = world.fields.ocean;
const lake = world.fields.lake;
const elevation = world.fields.elevation;
if (!sea || !elevation) return { coastCellsChanged: 0 };
let changed = 0;
for (let pass = 0; pass < 3; pass++) {
const flips = [];
for (let y = rect.y0 + 1; y < rect.y1 - 1; y++) {
for (let x = rect.x0 + 1; x < rect.x1 - 1; x++) {
const i = worldIndex(world, x, y);
if (i < 0) continue;
let seaN = 0;
let landN = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const ni = worldIndex(world, x + dx, y + dy);
if (ni < 0) continue;
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]);
}
}
for (const [i, nextSea] of flips) {
if (sea[i] === nextSea) continue;
sea[i] = nextSea;
if (ocean) ocean[i] = nextSea;
if (lake) lake[i] = 0;
if (nextSea) elevation[i] = Math.min(elevation[i], seaLevel - 0.004);
else elevation[i] = Math.max(elevation[i], seaLevel + 0.006);
changed++;
}
}
return { coastCellsChanged: changed };
}
function recomputeSlopeAndWaterDependentFields(world, rect, seaLevel = 0.30) {
const fields = world.fields;
const { elevation, sea } = fields;
if (!elevation || !sea) return;
if (!fields.slope) fields.slope = new Float32Array(world.width * world.height);
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) continue;
if (sea[i]) {
for (const key of ["slope", "river", "floodplain", "plain", "agriculture", "ridgeField", "valleyField", "coastalLowland", "naturalBarrierScore", "populationDensity", "settlementScore", "roadInfluence", "railInfluence2", "stationInfluence", "villageInfluence"]) {
if (fields[key]) fields[key][i] = 0;
}
continue;
}
if (x > 0 && y > 0 && x < world.width - 1 && y < world.height - 1) {
const gx = elevation[worldIndex(world, x + 1, y)] - elevation[worldIndex(world, x - 1, y)];
const gy = elevation[worldIndex(world, x, y + 1)] - elevation[worldIndex(world, x, y - 1)];
fields.slope[i] = clamp(Math.hypot(gx, gy) * 8.2);
}
if (fields.plain) fields.plain[i] = clamp((fields.plain[i] || 0) * 0.75 + (1 - (fields.slope[i] || 0)) * clamp((0.62 - elevation[i]) * 1.8) * 0.25);
if (fields.agriculture && fields.plain) fields.agriculture[i] = clamp((fields.agriculture[i] || 0) * 0.72 + fields.plain[i] * 0.28);
}
}
}
function seaNeighbors(world, x, y, radius = 1) {
let count = 0;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (!dx && !dy) continue;
const i = worldIndex(world, x + dx, y + dy);
if (i >= 0 && world.fields.sea?.[i]) count++;
}
}
return count;
}
function isLand(world, x, y) {
const i = worldIndex(world, x, y);
return i >= 0 && !world.fields.sea?.[i];
}
function nearestLand(world, x, y, rect, radius = 10) {
if (insideRect(x, y, rect) && isLand(world, x, y)) return { x, y };
for (let r = 1; r <= radius; r++) {
let best = null;
let bestScore = Infinity;
for (let yy = y - r; yy <= y + r; yy++) {
for (let xx = x - r; xx <= x + r; xx++) {
if (Math.abs(xx - x) !== r && Math.abs(yy - y) !== r) continue;
if (!insideRect(xx, yy, rect) || !isLand(world, xx, yy)) continue;
const i = worldIndex(world, xx, yy);
const score = Math.hypot(xx - x, yy - y) + (world.fields.slope?.[i] || 0) * 3;
if (score < bestScore) { bestScore = score; best = { x: xx, y: yy }; }
}
}
if (best) return best;
}
return null;
}
function pointWorldX(world, p) {
if (Number.isFinite(p?.worldX)) return p.worldX;
return (p?.x || 0) + (world?.originX || 0);
}
function pointWorldY(world, p) {
if (Number.isFinite(p?.worldY)) return p.worldY;
return (p?.y || 0) + (world?.originY || 0);
}
function tupleWorldX(world, tuple) {
return (tuple?.[0] || 0) + (world?.originX || 0);
}
function tupleWorldY(world, tuple) {
return (tuple?.[1] || 0) + (world?.originY || 0);
}
function sourcePointFromWorld(world, point) {
return { ...point, x: point.x - world.originX, y: point.y - world.originY, worldX: point.x, worldY: point.y, patchGenerated: true };
}
function sourcePathFromWorld(world, path) {
return path.map(([x, y]) => [Math.round(x - world.originX), Math.round(y - world.originY)]);
}
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);
if (key === "ports") {
const land = nearestLand(world, w.x, w.y, { x0: 0, y0: 0, x1: world.width, y1: world.height }, 8);
if (!land || seaNeighbors(world, land.x, land.y, 2) < 2) return null;
w.x = land.x; w.y = land.y;
} else if (!["crossings", "passes", "externalGateways", "prefectureRegions"].includes(key) && !isLand(world, w.x, w.y)) {
const land = nearestLand(world, w.x, w.y, { x0: 0, y0: 0, x1: world.width, y1: world.height }, 5);
if (!land) return null;
w.x = land.x; w.y = land.y;
}
const out = sourcePointFromWorld(world, { ...p, x: w.x, y: w.y });
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);
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);
return out;
}
function sanitizeLogisticsPark(p) {
if (!p) return p;
p.name = null;
p.labelName = null;
p.facilityLabel = p.facilityLabel || "Logistics Park";
p.labelStyle = "facility";
p.suppressSettlementLabel = true;
p.kind = "Logistics Park";
p.population = 0;
return p;
}
function sanitizeExistingLogistics(sourceMap) {
let migrated = 0;
if (!Array.isArray(sourceMap.logisticsParks)) return 0;
for (const p of sourceMap.logisticsParks) {
if (!p) continue;
if (p.name || p.labelName || !p.suppressSettlementLabel) migrated++;
sanitizeLogisticsPark(p);
}
for (const key of ["villages", "markets", "modernCities", "satelliteCities", "newTowns", "adminCenters"]) {
const arr = sourceMap[key];
if (!Array.isArray(arr)) continue;
for (const p of arr) {
if (!p?.name || !/\bLogistics\b/i.test(String(p.name))) continue;
p.name = String(p.name).replace(/\s*Logistics\b/ig, "").trim() || null;
p.labelName = p.name;
migrated++;
}
}
return migrated;
}
function transformCandidatePath(window, path) {
const out = [];
for (const tuple of path || []) {
if (!Array.isArray(tuple) || tuple.length < 2) continue;
const p = worldCoordForSource(window, tuple[0], tuple[1]);
out.push([p.x, p.y]);
}
return out;
}
function splitWorldPathByRect(path, rect, keepInside) {
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])]);
else {
if (current.length >= 2) chunks.push(current);
current = [];
}
}
if (current.length >= 2) chunks.push(current);
return chunks;
}
function pruneOldPathLayer(world, paths, rect, 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));
if (!touches) {
kept.push(path);
continue;
}
clipped++;
let lastOutside = null;
let wasInside = false;
for (const [x, y] of worldPath) {
const inside = insideRect(x, y, rect);
if (!inside) {
if (wasInside) anchors.push({ x, y, mode });
lastOutside = { x, y, mode };
} else if (lastOutside && !wasInside) {
anchors.push(lastOutside);
}
wasInside = inside;
}
for (const chunk of splitWorldPathByRect(worldPath, rect, false)) kept.push(sourcePathFromWorld(world, chunk));
}
return { kept, anchors, clipped };
}
function pathCost(world, x, y, mode) {
const i = worldIndex(world, x, y);
if (i < 0 || world.fields.sea?.[i]) return Infinity;
const slope = world.fields.slope?.[i] || 0;
const river = world.fields.river?.[i] || 0;
const plain = world.fields.plain?.[i] || 0;
const roadInfluence = world.fields.roadInfluence?.[i] || 0;
const pop = world.fields.populationDensity?.[i] || 0;
const slopeMult = mode === "rail" ? 15 : 7;
return 1 + slope * slopeMult - plain * 0.35 - roadInfluence * 0.28 - pop * 0.18 + river * 0.18;
}
function localPathfind(world, start, goal, rect, mode = "road", maxExpanded = 24000) {
const sx = Math.round(start.x), sy = Math.round(start.y), gx = Math.round(goal.x), gy = Math.round(goal.y);
if (!insideRect(sx, sy, rect) || !insideRect(gx, gy, rect)) return null;
if (!isLand(world, sx, sy) || !isLand(world, gx, gy)) return null;
const w = rectWidth(rect);
const h = rectHeight(rect);
const n = w * h;
const dist = new Float64Array(n); dist.fill(Infinity);
const prev = new Int32Array(n); prev.fill(-1);
const local = (x, y) => (y - rect.y0) * w + (x - rect.x0);
const heap = new MinHeap();
const startId = local(sx, sy);
dist[startId] = 0;
heap.push({ x: sx, y: sy, f: Math.hypot(sx - gx, sy - gy), id: startId });
let expanded = 0;
let found = -1;
const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
while (heap.items.length && expanded < maxExpanded) {
const cur = heap.pop();
if (!cur) break;
if (cur.x === gx && cur.y === gy) { found = cur.id; break; }
expanded++;
for (const [dx, dy] of dirs) {
const nx = cur.x + dx, ny = cur.y + dy;
if (!insideRect(nx, ny, rect)) continue;
const nid = local(nx, ny);
const c = pathCost(world, nx, ny, mode);
if (!Number.isFinite(c)) continue;
const step = (dx && dy ? 1.42 : 1) * c;
const nd = dist[cur.id] + step;
if (nd >= dist[nid]) continue;
dist[nid] = nd;
prev[nid] = cur.id;
heap.push({ x: nx, y: ny, id: nid, f: nd + Math.hypot(nx - gx, ny - gy) * 1.05 });
}
}
if (found < 0) return null;
const rev = [];
let at = found;
while (at >= 0) {
const x = rect.x0 + (at % w);
const y = rect.y0 + Math.floor(at / w);
rev.push([x, y]);
at = prev[at];
}
return rev.reverse();
}
function simplifyPath(path, keepEvery = 2) {
if (!path || path.length <= 2) return path || [];
const out = [path[0]];
for (let i = 1; i < path.length - 1; i++) if (i % keepEvery === 0) out.push(path[i]);
out.push(path[path.length - 1]);
return out;
}
function collectInternalNetworkPoints(world, sourceMap, keys, rect, mode = "road") {
const points = [];
const seen = new Set();
const add = (x, y, key, weight = 1) => {
x = Math.round(x); y = Math.round(y);
if (!insideRect(x, y, rect) || !isLand(world, x, y)) return;
const sig = `${x},${y},${key}`;
if (seen.has(sig)) return;
seen.add(sig);
points.push({ x, y, key, weight });
};
for (const key of keys) {
for (const path of sourceMap[key] || []) {
for (let i = 0; i < path.length; i += 4) {
add(tupleWorldX(world, path[i]), tupleWorldY(world, path[i]), key, 1.1);
}
}
}
const featureKeys = mode === "rail"
? ["modernCities", "stations", "ports", "adminCenters", "industrialZones", "newTowns"]
: ["modernCities", "ports", "markets", "villages", "adminCenters", "industrialZones", "logisticsParks", "newTowns"];
for (const key of featureKeys) {
for (const p of sourceMap[key] || []) {
add(pointWorldX(world, p), pointWorldY(world, p), key, key === "adminCenters" || key === "modernCities" ? 1.8 : 1.25);
}
}
return points;
}
function rectDistance(x, y, rect) {
if (insideRect(x, y, rect)) return 0;
const dx = x < rect.x0 ? rect.x0 - x : x >= rect.x1 ? x - rect.x1 + 1 : 0;
const dy = y < rect.y0 ? rect.y0 - y : y >= rect.y1 ? y - rect.y1 + 1 : 0;
return Math.hypot(dx, dy);
}
function collectExternalNetworkAnchors(world, sourceMap, keys, writeRect, reachRect, mode = "road") {
const candidates = [];
const seen = new Set();
const step = mode === "rail" ? 6 : 4;
for (const key of keys) {
for (const path of sourceMap[key] || []) {
for (let i = 0; i < path.length; i += step) {
const x = Math.round(tupleWorldX(world, path[i]));
const y = Math.round(tupleWorldY(world, path[i]));
if (!insideRect(x, y, reachRect) || insideRect(x, y, writeRect) || !isLand(world, x, y)) continue;
const d = rectDistance(x, y, writeRect);
if (d < 4 || d > (mode === "rail" ? 380 : 420)) continue;
const sig = `${x},${y},${mode}`;
if (seen.has(sig)) continue;
seen.add(sig);
candidates.push({ x, y, mode, external: true, d });
}
}
}
candidates.sort((a, b) => a.d - b.d);
return candidates.slice(0, mode === "rail" ? 18 : 28);
}
function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRect = null) {
const keys = mode === "rail" ? ["railways", "branchRailways"] : ["nationalRoads", "minorRoads", "premodernRoads"];
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 };
let connectors = 0;
let disconnected = 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 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);
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);
if (!path || path.length < 2) continue;
seen.add(sig);
sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2)));
connectors++;
made = true;
break;
}
if (!made) disconnected++;
}
return { connectors, disconnected };
}
function nearestNetworkPoint(world, sourceMap, keys, point, rect, maxDistance = 80) {
let best = null;
let bestD = maxDistance;
for (const key of keys) {
for (const path of sourceMap[key] || []) {
for (let i = 0; i < path.length; i += 5) {
const x = Math.round(tupleWorldX(world, path[i]));
const y = Math.round(tupleWorldY(world, path[i]));
if (!insideRect(x, y, rect) || !isLand(world, x, y)) continue;
const d = Math.hypot(point.x - x, point.y - y);
if (d < bestD) { bestD = d; best = { x, y, key }; }
}
}
}
return best;
}
function ensureSettlementRoadCoverage(world, sourceMap, rect) {
const keys = ["nationalRoads", "minorRoads", "premodernRoads"];
const featureKeys = ["modernCities", "ports", "markets", "adminCenters", "villages"];
sourceMap.minorRoads ||= [];
let connectors = 0;
const seen = new Set();
for (const key of featureKeys) {
for (const p of sourceMap[key] || []) {
const start = nearestLand(world, pointWorldX(world, p), pointWorldY(world, p), rect, 10);
if (!start || !insideRect(start.x, start.y, rect)) 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);
if (!path || path.length < 2) continue;
sourceMap.minorRoads.push(sourcePathFromWorld(world, simplifyPath(path, 2)));
connectors++;
}
}
return connectors;
}
function dedupeAdminCentersByWorldId(kept, generated) {
const out = [...kept];
const seen = new Set();
for (const p of kept) {
const id = numericFeatureId(p, ["adminId", "municipalityId", "adminNumericId"]);
if (id >= 0) seen.add(id);
}
for (const p of generated) {
const id = numericFeatureId(p, ["adminId", "municipalityId", "adminNumericId"]);
if (id >= 0 && seen.has(id)) continue;
if (id >= 0) seen.add(id);
out.push(p);
}
return out;
}
function dedupePrefectureRegionsByWorldId(kept, generated) {
const out = [...kept];
const seen = new Set();
for (const p of kept) {
const id = numericFeatureId(p, ["prefectureRegionId", "id"]);
if (id >= 0) seen.add(id);
}
for (const p of generated) {
const id = numericFeatureId(p, ["prefectureRegionId", "id"]);
if (id >= 0 && seen.has(id)) continue;
if (id >= 0) seen.add(id);
out.push(p);
}
return out;
}
function mergePointLayers(world, sourceMap, candidate, rects, window, seed, adminIdMapping = null) {
let preservedExternalEntities = 0;
let regeneratedInternalEntities = 0;
let invalidPortsRemoved = 0;
for (const key of POINT_LAYER_KEYS) {
const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : [];
const kept = [];
for (const p of oldArr) {
if (!p) continue;
const wx = Math.round(pointWorldX(world, p));
const wy = Math.round(pointWorldY(world, p));
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++;
}
}
const generated = [];
for (const p of candidate[key] || []) {
const q = transformCandidatePoint(world, window, p, key, seed, adminIdMapping);
if (!q) continue;
const wx = Math.round(pointWorldX(world, q));
const wy = Math.round(pointWorldY(world, q));
if (!insideRect(wx, wy, rects.writeRect)) continue;
if (patchAlpha(wx, wy, rects, seed) < 0.42) continue;
generated.push(q);
}
if (key === "adminCenters") sourceMap[key] = dedupeAdminCentersByWorldId(kept, generated);
else if (key === "prefectureRegions") sourceMap[key] = dedupePrefectureRegionsByWorldId(kept, generated);
else sourceMap[key] = [...kept, ...generated];
regeneratedInternalEntities += Math.max(0, sourceMap[key].length - kept.length);
}
return { preservedExternalEntities, regeneratedInternalEntities, invalidPortsRemoved };
}
function mergePathLayers(world, sourceMap, candidate, rects, window, seed) {
let roadAnchors = [];
let railAnchors = [];
let roadsClipped = 0;
let railsClipped = 0;
let regeneratedPaths = 0;
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);
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)
.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) {
if (chunk.some(([x, y]) => patchAlpha(x, y, rects, seed) >= 0.40)) {
next.push(sourcePathFromWorld(world, simplifyPath(chunk, mode === "rail" ? 3 : 2)));
regeneratedPaths++;
}
}
}
sourceMap[key] = next;
}
const transportRect = rects.transportReachRect || rects.repairRect || rects.writeRect;
const externalRoadAnchors = collectExternalNetworkAnchors(world, sourceMap, ["nationalRoads", "minorRoads", "premodernRoads", "externalRoads"], rects.writeRect, transportRect, "road");
const externalRailAnchors = collectExternalNetworkAnchors(world, sourceMap, ["railways", "branchRailways", "externalRailways"], rects.writeRect, transportRect, "rail");
roadAnchors = roadAnchors.concat(externalRoadAnchors);
railAnchors = railAnchors.concat(externalRailAnchors);
const roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", transportRect, rects.writeRect);
const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", transportRect, rects.writeRect);
const settlementRoadConnectors = ensureSettlementRoadCoverage(world, sourceMap, transportRect);
return {
roadsClipped,
railsClipped,
regeneratedPaths,
roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors,
railwayConnectorsCreated: railConn.connectors,
disconnectedRoadComponents: roadConn.disconnected,
disconnectedRailComponents: railConn.disconnected,
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;
const rects = options.rects || null;
const seed = options.seed || 0;
const minAlpha = Number.isFinite(options.minAlpha) ? options.minAlpha : 0;
if (!field) return [];
const out = [];
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 || sea?.[i]) continue;
const id = field[i];
if (id < 0) continue;
const right = worldIndex(world, x + 1, y);
if (x + 1 < rect.x1 && right >= 0 && !sea?.[right] && field[right] >= 0 && field[right] !== id && continuitySegmentAllowed(x, y, x + 1, y, rects, seed, minAlpha)) {
out.push([[x + 0.5 - world.originX, y - world.originY], [x + 0.5 - world.originX, y + 1 - world.originY]]);
}
const down = worldIndex(world, x, y + 1);
if (y + 1 < rect.y1 && down >= 0 && !sea?.[down] && field[down] >= 0 && field[down] !== id && continuitySegmentAllowed(x, y, x, y + 1, rects, seed, minAlpha)) {
out.push([[x - world.originX, y + 0.5 - world.originY], [x + 1 - world.originX, y + 0.5 - world.originY]]);
}
}
}
return out;
}
function mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, window, seed = 0) {
const debug = sourceMap.adminDebug || {};
debug.compartmentBorders ||= [];
let added = 0;
for (const seg of candidate?.adminDebug?.compartmentBorders || []) {
if (!Array.isArray(seg) || seg.length < 2) continue;
const a = worldCoordForSource(window, seg[0]?.[0], seg[0]?.[1]);
const b = worldCoordForSource(window, seg[1]?.[0], seg[1]?.[1]);
const mx = (a.x + b.x) * 0.5;
const my = (a.y + b.y) * 0.5;
if (!insideRect(mx, my, rects.writeRect) || patchAlpha(mx, my, rects, seed) < 0.08) continue;
debug.compartmentBorders.push(sourcePathFromWorld(world, [[a.x, a.y], [b.x, b.y]]));
added++;
}
sourceMap.adminDebug = debug;
return added;
}
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.adminBorders ||= [];
sourceMap.adminBorders.push(...buildBoundarySegmentsFromField(world, "adminId", rects.writeRect, { rects, seed, minAlpha: 0.58 }));
sourceMap.regionalPrefectureBorders ||= [];
sourceMap.regionalPrefectureBorders.push(...buildBoundarySegmentsFromField(world, "prefectureRegionId", rects.writeRect, { rects, seed, minAlpha: 0.72 }));
sourceMap.prefectureBorder ||= [];
const debug = sourceMap.adminDebug || {};
// Use the legacy full-pipeline compartment debug segments for regenerated
// 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));
sourceMap.adminDebug = debug;
return {
adminBordersRebuilt: sourceMap.adminBorders.length,
prefectureBordersRebuilt: sourceMap.regionalPrefectureBorders.length,
compartmentBordersRebuilt: debug.compartmentBorders.length,
};
}
function repairLanduseAndPopulation(world, rects) {
const landuse = world.fields.landuse;
if (!landuse) return { landUseCellsUpdated: 0 };
let updated = 0;
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) continue;
if (world.fields.sea?.[i]) {
if (landuse[i] !== (LANDUSE.WATER || 0)) updated++;
landuse[i] = LANDUSE.WATER || 0;
continue;
}
if (landuse[i] === (LANDUSE.WATER || 0)) {
const slope = world.fields.slope?.[i] || 0;
const ag = world.fields.agriculture?.[i] || 0;
landuse[i] = slope > 0.42 ? LANDUSE.FOREST : ag > 0.28 ? LANDUSE.FARMLAND : LANDUSE.RURAL;
updated++;
}
}
}
return { landUseCellsUpdated: updated };
}
function countSea(world, rect) {
let seaCount = 0;
let total = 0;
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) continue;
total++;
if (world.fields.sea?.[i]) seaCount++;
}
}
return { seaCount, total, seaRatio: total ? seaCount / total : 0 };
}
function terrainLabel(candidate, fallback) {
return candidate?.terrainTemplate?.terrainTypeLabel || candidate?.terrainDebug?.terrainTypeLabel || fallback;
}
function terrainId(candidate, fallback) {
return candidate?.terrainTemplate?.terrainType || candidate?.terrainDebug?.terrainType || fallback;
}
export function generatePatch(world, userRectInput, options = {}) {
const validation = validatePatchRect(userRectInput, world);
if (!validation.ok) return { ok: false, ...validation };
const rects = buildPatchRects(validation.rect, world);
const terrainType = options.terrainType || "auto";
const seed = Number.isFinite(options.seed) ? options.seed >>> 0 : ((world?.seed || 0) + 1013904223) >>> 0;
const variant = Number.isFinite(options.variant) ? Math.max(0, Math.floor(options.variant)) >>> 0 : 0;
const candidateWindow = sourceWindowForRects(rects);
const candidateOriginX = Math.round(candidateWindow.worldCenterX - candidateWindow.sourceCenterX);
const candidateOriginY = Math.round(candidateWindow.worldCenterY - candidateWindow.sourceCenterY);
const candidate = generateMap(seed, {
terrainType,
legacyTerrain: true,
worldNative: true,
variant,
originX: candidateOriginX,
originY: candidateOriginY,
width: MAP_W,
height: MAP_H,
contextRect: rects.contextRect,
boundaryWorld: world,
onProgress: () => {},
});
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);
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);
const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.adminIdMapping);
const pathDebug = mergePathLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed);
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);
sanitizeExistingLogistics(sourceMap);
const seaStats = countSea(world, rects.coreRect);
const label = terrainLabel(candidate, terrainType);
const id = terrainId(candidate, terrainType);
const humanGeography = {
ok: true,
modernCities: (sourceMap.modernCities || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
ports: (sourceMap.ports || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
villages: (sourceMap.villages || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length,
...pointDebug,
...pathDebug,
adminCellsReassigned: fieldDebug.adminCellsReassigned,
adminIdMapping: fieldDebug.adminIdMappingDebug,
sourceAdminMetadataUpdated,
...adminCoverageDebug,
finalSeaAdminCellsCleared: finalAdminCoverageDebug.seaAdminCellsCleared || 0,
finalLandAdminCellsFilled: finalAdminCoverageDebug.landAdminCellsFilled || 0,
finalPrefectureCellsFilled: finalAdminCoverageDebug.prefectureCellsFilled || 0,
finalAdminPrefectureCellsAligned: finalAdminCoverageDebug.adminPrefectureCellsAligned || 0,
continuityCellsRestored: fieldDebug.continuityCellsRestored || 0,
continuityCellsRemapped: fieldDebug.continuityCellsRemapped || 0,
landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated,
displayMaskUpdated: maskDebug.displayMaskUpdated || 0,
logisticsLabelsMigrated,
...segmentDebug,
candidateCompartmentSegmentsAdded,
};
const record = {
...rects.coreRect,
coreRect: { ...rects.coreRect },
selectionShape: rects.selectionShape ? {
kind: rects.selectionShape.kind || 'lasso',
areaCells: rects.selectionShape.areaCells || 0,
polygon: rects.selectionShape.polygon.map((p) => ({ x: p.x, y: p.y })),
} : null,
writeRect: { ...rects.writeRect },
repairRect: { ...rects.repairRect },
contextRect: { ...rects.contextRect },
blendRect: { ...rects.blendRect },
terrainType: id,
label,
seed,
variant,
candidateOriginX,
candidateOriginY,
patchGenerationMode: "legacy-full-pipeline",
updatedCells: fieldDebug.updatedCells,
terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced,
coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged,
naturalRegionsUpdated: fieldDebug.naturalRegionsUpdated,
naturalRegionFragmentsMerged: 0,
adminIdMapping: fieldDebug.adminIdMappingDebug,
seaRatio: seaStats.seaRatio,
humanGeography,
createdAt: Date.now(),
};
world.generatedRects = [...(world.generatedRects || []), record];
world.invalidatedRects = [...(world.invalidatedRects || []), { ...rects.writeRect }];
world.lastPatchResult = record;
return {
ok: true,
validation,
rects: {
...rects,
selectionShape: rects.selectionShape ? {
kind: rects.selectionShape.kind || 'lasso',
areaCells: rects.selectionShape.areaCells || 0,
polygon: rects.selectionShape.polygon.map((p) => ({ x: p.x, y: p.y })),
} : null,
},
terrainType: id,
label,
seed,
variant,
candidateOriginX,
candidateOriginY,
patchGenerationMode: "legacy-full-pipeline",
updatedCells: record.updatedCells,
terrainCellsFullyReplaced: record.terrainCellsFullyReplaced,
coastCellsChanged: record.coastCellsChanged,
naturalRegionsUpdated: record.naturalRegionsUpdated,
naturalRegionFragmentsMerged: 0,
adminIdMapping: fieldDebug.adminIdMappingDebug,
seaRatio: seaStats.seaRatio,
humanGeography,
};
}