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"]); 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 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 = normalizeRect(rect); if (!normalized || !world) return null; 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 = 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 = normalizeRect(userRect); 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 + 96, Math.min(260, repairMargin + Math.max(MAP_W, MAP_H))); const transportReachRect = expandRect(coreRect, transportReachMargin, world); return { coreRect, writeRect, repairRect, contextRect: repairRect, transportReachRect, blendRect: coreRect, userRect: writeRect, selectedRect: coreRect, 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 edge = distanceToRectEdge(x, y, writeRect); 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 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 offsetFieldValue(name, raw, seed) { if (!Number.isFinite(raw) || raw < 0) return raw; const offset = fieldIdOffset(name, seed); return offset ? raw + offset : raw; } function isContinuityTransitionCell(x, y, rects, seed = 0, mode = "normal") { if (!insideRect(x, y, rects.writeRect)) return false; const edge = distanceToRectEdge(x, y, rects.writeRect); const margin = Math.max(2, rects.writeMargin || 1); const alpha = patchAlpha(x, y, rects, seed); const edgeLimit = mode === "prefecture" ? margin * 2.15 : mode === "admin" ? margin * 1.75 : margin * 1.45; const alphaLimit = mode === "prefecture" ? 0.995 : mode === "admin" ? 0.985 : 0.96; return edge <= edgeLimit || alpha < alphaLimit; } function addCount(bucket, key, amount = 1) { if (!Number.isFinite(key) || key < 0) return; bucket.set(key, (bucket.get(key) || 0) + amount); } function buildContinuityIdMappings(world, candidate, rects, window, oldFields, seed = 0) { const out = new Map(); const debug = { continuityIdMappings: 0, continuityIdMappedCells: 0 }; const dirs = [[0,0],[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]; for (const name of CONTINUITY_FIELD_NAMES) { const source = candidate?.[name]; const old = oldFields?.get(name) || world.fields?.[name]; if (!source || !old || !isCellField(source)) continue; const mode = name === "prefectureRegionId" ? "prefecture" : (name === "adminId" || name === "municipalityId") ? "admin" : "natural"; const contacts = new Map(); for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { if (!isContinuityTransitionCell(x, y, rects, seed, mode)) continue; const s = sourceCoordForWorld(window, x, y); const si = sourceIndex(s.x, s.y); if (si < 0) continue; const from = offsetFieldValue(name, source[si], seed); if (!Number.isFinite(from) || from < 0) continue; const bucket = contacts.get(from) || new Map(); for (const [dx, dy] of dirs) { const nx = x + dx, ny = y + dy; const wi = worldIndex(world, nx, ny); if (wi < 0 || old[wi] < 0) continue; const outsideWrite = !insideRect(nx, ny, rects.writeRect); const weakPatch = insideRect(nx, ny, rects.writeRect) && patchAlpha(nx, ny, rects, seed) < (mode === "prefecture" ? 0.96 : 0.88); const edgeWeight = outsideWrite ? 6 : weakPatch ? 3 : (dx || dy ? 1 : 2); addCount(bucket, old[wi], edgeWeight); } contacts.set(from, bucket); } } const mapping = new Map(); for (const [from, bucket] of contacts) { let total = 0; let best = -1; let bestCount = 0; for (const [to, count] of bucket) { total += count; if (count > bestCount) { best = to; bestCount = count; } } const minCount = mode === "prefecture" ? 10 : mode === "admin" ? 8 : 5; const minShare = mode === "prefecture" ? 0.42 : mode === "admin" ? 0.48 : 0.36; if (best >= 0 && bestCount >= minCount && bestCount / Math.max(1, total) >= minShare) { mapping.set(from, best); } } if (mapping.size) { out.set(name, mapping); debug.continuityIdMappings += mapping.size; } } out.debug = debug; return out; } function applyContinuityMapping(idMappings, name, value) { const map = idMappings?.get?.(name); return map && map.has(value) ? map.get(value) : value; } function pointCandidateContinuityIds(p, key, seed = 0) { const ids = []; if (!p) return ids; if (key === "adminCenters") { for (const raw of [p.id, p.adminId, p.adminNumericId, p.municipalityId]) { if (Number.isFinite(raw) && raw >= 0) ids.push(offsetFieldValue("adminId", raw, seed)); } } else if (key === "prefectureRegions") { for (const raw of [p.id, p.prefectureRegionId]) { if (Number.isFinite(raw) && raw >= 0) ids.push(offsetFieldValue("prefectureRegionId", raw, seed)); } } return ids; } 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 continuityIdMappings = buildContinuityIdMappings(world, candidate, rects, window, oldContinuityFields, seed); let continuityIdMappedCells = 0; 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]; let value = idOffset && raw >= 0 ? raw + idOffset : raw; if (CONTINUITY_FIELD_NAMES.has(name)) { const mapped = applyContinuityMapping(continuityIdMappings, name, value); if (mapped !== value) continuityIdMappedCells++; value = mapped; } 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); return { window, idMappings: continuityIdMappings, updatedCells, terrainCellsFullyReplaced, coastCellsChanged, naturalRegionsUpdated, adminCellsReassigned, landUseCellsUpdated, continuityIdMappedCells, continuityIdMappings: continuityIdMappings.debug?.continuityIdMappings || 0, ...continuityDebug, }; } 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) { 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 offset = fieldIdOffset("adminId", seed); if (Number.isFinite(out.id)) out.id += offset; 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 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 connectAnchors(world, sourceMap, anchors, mode, rect) { const keys = mode === "rail" ? ["railways", "branchRailways"] : ["nationalRoads", "minorRoads", "premodernRoads"]; const targets = collectInternalNetworkPoints(world, sourceMap, keys, rect, mode); 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" ? 260 : 300; for (const raw of anchors) { const anchorLand = nearestLand(world, raw.x, raw.y, rect, 18); if (!anchorLand) { disconnected++; continue; } const target = targets .map((p) => ({ ...p, d: Math.hypot(p.x - anchorLand.x, p.y - anchorLand.y) })) .filter((p) => p.d <= maxRange) .sort((a, b) => (a.d / Math.max(0.6, a.weight || 1)) - (b.d / Math.max(0.6, b.weight || 1)))[0]; if (!target) { disconnected++; continue; } const sig = `${anchorLand.x},${anchorLand.y}:${target.x},${target.y}:${mode}`; if (seen.has(sig)) continue; seen.add(sig); const searchRect = expandRect(rect, 8, world); const path = localPathfind(world, anchorLand, target, searchRect, mode, 42000); if (!path || path.length < 2) { disconnected++; continue; } sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2))); connectors++; } 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" ? 36 : 58); 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", 28000); if (!path || path.length < 2) continue; sourceMap.minorRoads.push(sourcePathFromWorld(world, simplifyPath(path, 2))); connectors++; } } return connectors; } function mergePointLayers(world, sourceMap, candidate, rects, window, seed, idMappings = 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 candidateIds = pointCandidateContinuityIds(p, key, seed); if (key === "adminCenters" && candidateIds.some((id) => applyContinuityMapping(idMappings, "adminId", id) !== id || applyContinuityMapping(idMappings, "municipalityId", id) !== id)) { continue; } if (key === "prefectureRegions" && candidateIds.some((id) => applyContinuityMapping(idMappings, "prefectureRegionId", id) !== id)) { continue; } const q = transformCandidatePoint(world, window, p, key, seed); 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); } sourceMap[key] = [...kept, ...generated]; regeneratedInternalEntities += generated.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 roadConn = connectAnchors(world, sourceMap, roadAnchors, "road", transportRect); const railConn = connectAnchors(world, sourceMap, railAnchors, "rail", transportRect); const settlementRoadConnectors = ensureSettlementRoadCoverage(world, sourceMap, transportRect); return { roadsClipped, railsClipped, regeneratedPaths, roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors, railwayConnectorsCreated: railConn.connectors, disconnectedRoadComponents: roadConn.disconnected, disconnectedRailComponents: railConn.disconnected, }; } 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.38) 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 || {}; debug.compartmentBorders = (debug.compartmentBorders || []).filter((seg) => !segmentTouchesRect(world, seg, rects.writeRect)); debug.compartmentBorders.push(...buildBoundarySegmentsFromField(world, "naturalCompartmentId", rects.writeRect, { rects, seed, minAlpha: 0.40 })); 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 candidate = generateMap(seed, { terrainType, legacyTerrain: true, 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); recomputeSlopeAndWaterDependentFields(world, rects.repairRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30); const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.idMappings); 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); 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, continuityCellsRestored: fieldDebug.continuityCellsRestored || 0, continuityCellsRemapped: fieldDebug.continuityCellsRemapped || 0, continuityIdMappings: fieldDebug.continuityIdMappings || 0, continuityIdMappedCells: fieldDebug.continuityIdMappedCells || 0, landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated, logisticsLabelsMigrated, ...segmentDebug, candidateCompartmentSegmentsAdded, }; const record = { ...rects.coreRect, coreRect: { ...rects.coreRect }, writeRect: { ...rects.writeRect }, repairRect: { ...rects.repairRect }, contextRect: { ...rects.contextRect }, blendRect: { ...rects.blendRect }, terrainType: id, label, seed, variant, patchGenerationMode: "legacy-full-pipeline", updatedCells: fieldDebug.updatedCells, terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced, coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged, naturalRegionsUpdated: fieldDebug.naturalRegionsUpdated, naturalRegionFragmentsMerged: 0, 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, terrainType: id, label, seed, variant, patchGenerationMode: "legacy-full-pipeline", updatedCells: record.updatedCells, terrainCellsFullyReplaced: record.terrainCellsFullyReplaced, coastCellsChanged: record.coastCellsChanged, naturalRegionsUpdated: record.naturalRegionsUpdated, naturalRegionFragmentsMerged: 0, seaRatio: seaStats.seaRatio, humanGeography, }; }