diff --git a/app.js b/app.js index d989f9c..6eab3ad 100644 --- a/app.js +++ b/app.js @@ -732,7 +732,6 @@ function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36, adminId = nul const d = Math.hypot(center.x - x, center.y - y); if (d < bestD) { best = center; bestD = d; } } - if (!best && adminId != null && adminId >= 0) return nearestNamedAdminCenter(map, cellIndex, maxDistance, null); return best; } @@ -764,7 +763,9 @@ function prefectureNameForCell(map, i) { const region = (map.prefectureRegions || []).find((p) => hasNumericId(p, id, PREFECTURE_ID_KEYS)); const regionName = firstUsableText(region, PREFECTURE_NAME_KEYS); if (regionName) return regionName; - const center = nearestNamedAdminCenter(map, i, 90); + const adminId = map.adminId?.[i] ?? -1; + const mappedPref = adminId >= 0 ? map.municipalityToPrefectureId?.[adminId] ?? -1 : -1; + const center = mappedPref === id ? nearestNamedAdminCenter(map, i, 90, adminId) : null; const fromCenter = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]); return fromCenter || (id >= 0 ? "Unnamed prefecture" : "-"); } @@ -930,7 +931,7 @@ async function generateSelectedPatch() { const human = result.humanGeography; const humanText = human?.ok ? ` / human: ${human.modernCities || 0} cities, ${human.ports || 0} ports, ${human.villages || 0} villages, ${(human.roadConnectorsCreated || 0) + (human.railwayConnectorsCreated || 0)} connectors, admin ${human.adminCellsReassigned || 0}, invalid ports ${human.invalidPortsRemoved || 0}` : ""; if (progressStageEl) progressStageEl.textContent = `Patch generated: ${result.label} / variant ${result.variant ?? variant} / mode ${result.patchGenerationMode || "legacy-full-pipeline"} / core ${formatRectSize(result.rects.coreRect)} / write ${formatRectSize(result.rects.writeRect)} / terrain ${result.updatedCells.toLocaleString()} cells / coast ${result.coastCellsChanged || 0} / natural ${result.naturalRegionsUpdated || 0}${humanText}`; - renderTimingRows([]); + renderTimingRows(result.patchTimings || []); window.setTimeout(() => setProgressVisible(false), 900); } catch (error) { if (progressStageEl) progressStageEl.textContent = `Patch failed: ${error?.message || error}`; diff --git a/mapMunicipalCoherence.js b/mapMunicipalCoherence.js new file mode 100644 index 0000000..88684ed --- /dev/null +++ b/mapMunicipalCoherence.js @@ -0,0 +1,265 @@ +import { INF, MAP_H, MAP_W } from "./mapUtils.js"; + +const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId"]; + +function coordIndex(width, height, x, y) { + if (x < 0 || y < 0 || x >= width || y >= height) return -1; + return y * width + x; +} + +function numericAdminId(point) { + for (const key of ADMIN_ID_KEYS) { + const value = point?.[key]; + if (Number.isFinite(value) && value >= 0) return Math.floor(value); + } + return -1; +} + +function fieldValue(fields, name, i) { + return fields?.[name]?.[i] || 0; +} + +function bestCellScore(fields, i) { + return fieldValue(fields, "populationDensity", i) * 3.0 + + fieldValue(fields, "plain", i) * 0.32 + + fieldValue(fields, "agriculture", i) * 0.16 + - fieldValue(fields, "slope", i) * 0.30 + - fieldValue(fields, "ridgeField", i) * 0.18; +} + +function buildMunicipalStats({ adminId, prefectureRegionId, sea, fields = {}, width = MAP_W, height = MAP_H }) { + const stats = new Map(); + for (let i = 0; i < adminId.length; i++) { + const id = adminId[i]; + if (!Number.isFinite(id) || id < 0 || sea?.[i]) continue; + const row = stats.get(id) || { id, area: 0, sx: 0, sy: 0, bestI: -1, bestScore: -INF, prefVotes: new Map() }; + const x = i % width; + const y = Math.floor(i / width); + row.area++; + row.sx += x; + row.sy += y; + const pref = prefectureRegionId?.[i] ?? -1; + if (pref >= 0) row.prefVotes.set(pref, (row.prefVotes.get(pref) || 0) + 1); + const score = bestCellScore(fields, i); + if (score > row.bestScore) { + row.bestScore = score; + row.bestI = i; + } + stats.set(id, row); + } + for (const row of stats.values()) { + let bestPref = -1; + let bestVotes = -1; + for (const [pref, count] of row.prefVotes) { + if (count > bestVotes || (count === bestVotes && pref < bestPref)) { + bestPref = pref; + bestVotes = count; + } + } + row.prefectureRegionId = bestPref; + row.x = row.bestI >= 0 ? row.bestI % width : Math.round(row.sx / Math.max(1, row.area)); + row.y = row.bestI >= 0 ? Math.floor(row.bestI / width) : Math.round(row.sy / Math.max(1, row.area)); + } + return stats; +} + +function pointFieldCoord(point, pointOffsetX, pointOffsetY) { + return { + x: Math.round((point?.x || 0) + pointOffsetX), + y: Math.round((point?.y || 0) + pointOffsetY), + }; +} + +function centerQuality(center, id, stat, context) { + if (!center) return -INF; + const { adminId, sea, width, height, pointOffsetX, pointOffsetY } = context; + const p = pointFieldCoord(center, pointOffsetX, pointOffsetY); + const i = coordIndex(width, height, p.x, p.y); + const ownsCell = i >= 0 && !sea?.[i] && adminId?.[i] === id; + return (ownsCell ? 100000 : 0) + + (center.name ? 5000 : 0) + + (center.representativeFeatureName || center.canonicalSettlementName ? 1200 : 0) + + (center.generatedOfficePoint ? -200 : 0) + - Math.hypot(p.x - stat.x, p.y - stat.y); +} + +function normalizeCenter(center, id, stat, context, generated = false) { + const { pointOffsetX, pointOffsetY, fields = {}, seed = 0 } = context; + const out = { + ...(center || {}), + x: stat.x - pointOffsetX, + y: stat.y - pointOffsetY, + adminId: id, + adminNumericId: id, + municipalityId: id, + prefectureRegionId: stat.prefectureRegionId, + municipalArea: stat.area, + insidePrefecture: true, + }; + if (generated) { + out.generatedOfficePoint = true; + out.seedKind ||= "coherenceFallbackMunicipalityOffice"; + out.kind ||= "Municipal Center"; + out.generatedMunicipalityName ||= `自治${id + 1}`; + out.name ||= out.generatedMunicipalityName; + out.labelName ||= out.generatedMunicipalityName; + out.municipalityName ||= out.generatedMunicipalityName; + } + const i = coordIndex(context.width, context.height, stat.x, stat.y); + if (i >= 0) { + out.officePopulationDensity = fields.populationDensity?.[i] || 0; + out.officeLanduse = fields.landuse?.[i] ?? out.officeLanduse; + } + return out; +} + +export function reconcileMunicipalMetadata({ + adminId, + municipalityId = null, + prefectureRegionId = null, + sea = null, + adminCenters = [], + municipalityToPrefectureId = null, + fields = {}, + width = MAP_W, + height = MAP_H, + pointOffsetX = 0, + pointOffsetY = 0, + seed = 0, +} = {}) { + if (!adminId) return { adminCenters: adminCenters || [], municipalityToPrefectureId, stats: new Map(), debug: { activeMunicipalities: 0 } }; + const stats = buildMunicipalStats({ adminId, prefectureRegionId, sea, fields, width, height }); + if (municipalityId) { + for (let i = 0; i < adminId.length; i++) municipalityId[i] = sea?.[i] ? -1 : (adminId[i] >= 0 ? adminId[i] : -1); + } + + const byId = new Map(); + let ghostCentersRemoved = 0; + let centersMovedToOwnedCells = 0; + for (const [index, center] of (adminCenters || []).entries()) { + if (!center) continue; + const explicitId = numericAdminId(center); + const id = explicitId >= 0 ? explicitId : (stats.has(index) ? index : -1); + const stat = stats.get(id); + if (!stat) { + ghostCentersRemoved++; + continue; + } + const current = byId.get(id); + if (!current || centerQuality(center, id, stat, { adminId, sea, width, height, pointOffsetX, pointOffsetY }) > centerQuality(current, id, stat, { adminId, sea, width, height, pointOffsetX, pointOffsetY })) { + byId.set(id, center); + } + } + + let fallbackCentersAdded = 0; + const nextCenters = []; + for (const [id, stat] of [...stats.entries()].sort((a, b) => a[0] - b[0])) { + const existing = byId.get(id); + if (!existing) fallbackCentersAdded++; + else { + const p = pointFieldCoord(existing, pointOffsetX, pointOffsetY); + const pi = coordIndex(width, height, p.x, p.y); + if (pi < 0 || sea?.[pi] || adminId[pi] !== id) centersMovedToOwnedCells++; + } + nextCenters.push(normalizeCenter(existing, id, stat, { pointOffsetX, pointOffsetY, fields, width, height, seed }, !existing)); + } + + let maxId = Math.max(-1, ...stats.keys()); + if (municipalityToPrefectureId?.length) maxId = Math.max(maxId, municipalityToPrefectureId.length - 1); + const nextMapping = new Int32Array(Math.max(0, maxId + 1)); + nextMapping.fill(-1); + if (municipalityToPrefectureId) { + for (let i = 0; i < municipalityToPrefectureId.length && i < nextMapping.length; i++) nextMapping[i] = municipalityToPrefectureId[i] ?? -1; + } + for (const [id, stat] of stats) if (stat.prefectureRegionId >= 0) nextMapping[id] = stat.prefectureRegionId; + + return { + adminCenters: nextCenters, + municipalityToPrefectureId: nextMapping, + stats, + debug: { + activeMunicipalities: stats.size, + ghostCentersRemoved, + fallbackCentersAdded, + centersMovedToOwnedCells, + }, + }; +} + +export function refreshPrefectureRegionsMetadata({ + prefectureRegionId, + sea, + existing = [], + fields = {}, + width = MAP_W, + height = MAP_H, + pointOffsetX = 0, + pointOffsetY = 0, +} = {}) { + if (!prefectureRegionId) return { prefectureRegions: existing || [], debug: { activePrefectureRegions: 0 } }; + const byId = new Map(); + for (let i = 0; i < prefectureRegionId.length; i++) { + const id = prefectureRegionId[i]; + if (!Number.isFinite(id) || id < 0 || sea?.[i]) continue; + const row = byId.get(id) || { id, area: 0, sx: 0, sy: 0, bestI: -1, bestScore: -INF }; + const x = i % width; + const y = Math.floor(i / width); + row.area++; + row.sx += x; + row.sy += y; + const score = bestCellScore(fields, i) - Math.hypot(x - row.sx / Math.max(1, row.area), y - row.sy / Math.max(1, row.area)) * 0.02; + if (score > row.bestScore) { + row.bestScore = score; + row.bestI = i; + } + byId.set(id, row); + } + const existingById = new Map(); + for (const region of existing || []) { + const id = Number.isFinite(region?.prefectureRegionId) ? Math.floor(region.prefectureRegionId) : Number.isFinite(region?.id) ? Math.floor(region.id) : -1; + if (id >= 0 && !existingById.has(id)) existingById.set(id, region); + } + let fallbackRegionsAdded = 0; + const prefectureRegions = []; + for (const [id, row] of [...byId.entries()].sort((a, b) => a[0] - b[0])) { + const base = existingById.get(id); + if (!base) fallbackRegionsAdded++; + const x = row.bestI >= 0 ? row.bestI % width : Math.round(row.sx / Math.max(1, row.area)); + const y = row.bestI >= 0 ? Math.floor(row.bestI / width) : Math.round(row.sy / Math.max(1, row.area)); + prefectureRegions.push({ + ...(base || {}), + id, + prefectureRegionId: id, + featureId: id, + x: x - pointOffsetX, + y: y - pointOffsetY, + area: row.area, + kind: base?.kind || (id === 0 ? "Current Prefecture" : "Prefecture"), + name: base?.name || `県域${id + 1}`, + labelName: base?.labelName || base?.name || `県域${id + 1}`, + forceLabel: true, + labelPriorityBase: base?.labelPriorityBase || 950 + Math.sqrt(row.area), + }); + } + return { + prefectureRegions, + debug: { + activePrefectureRegions: byId.size, + fallbackRegionsAdded, + }, + }; +} + +export function municipalCoherenceForMap(map) { + return reconcileMunicipalMetadata({ + adminId: map?.adminId, + municipalityId: map?.municipalityId, + prefectureRegionId: map?.prefectureRegionId, + sea: map?.sea, + adminCenters: map?.adminCenters, + municipalityToPrefectureId: map?.municipalityToPrefectureId, + fields: map, + width: map?.width || MAP_W, + height: map?.height || MAP_H, + }); +} diff --git a/mapOutput.js b/mapOutput.js index d79b529..20c7e23 100644 --- a/mapOutput.js +++ b/mapOutput.js @@ -1,6 +1,7 @@ import { createNameDebug } from "./names.js"; import { CELL_SIZE, INF, MAP_H, MAP_W, MinHeap, clamp, indexOf, inside, rand, xyOf } from "./mapUtils.js"; import { applyOutputOptions, attachIdsAndNames, influenceFromPaths, tagInsidePrefecture } from "./mapGeneratorHelpers.js"; +import { reconcileMunicipalMetadata } from "./mapMunicipalCoherence.js"; import { routeQualityAcceptable } from "./mapTransport.js"; const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u; @@ -30,15 +31,33 @@ function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) { } +function centerMunicipalityId(center, fallback = -1) { + for (const key of ["adminId", "municipalityId", "adminNumericId"]) { + const value = center?.[key]; + if (Number.isFinite(value) && value >= 0) return Math.floor(value); + } + return fallback; +} + function assignMunicipalityPopulations(adminCenters, adminId, fields, settlementFeatures = []) { if (!adminCenters?.length || !adminId) return; - const totals = new Float64Array(adminCenters.length); - const settlementTotals = new Float64Array(adminCenters.length); - const landCells = new Uint32Array(adminCenters.length); - const inhabitedCells = new Uint32Array(adminCenters.length); + const centerById = new Map(); + let maxId = -1; + for (const center of adminCenters) { + const id = centerMunicipalityId(center); + if (id >= 0 && !centerById.has(id)) { + centerById.set(id, center); + maxId = Math.max(maxId, id); + } + } + for (let i = 0; i < adminId.length; i++) if (adminId[i] >= 0) maxId = Math.max(maxId, adminId[i]); + const totals = new Float64Array(maxId + 1); + const settlementTotals = new Float64Array(maxId + 1); + const landCells = new Uint32Array(maxId + 1); + const inhabitedCells = new Uint32Array(maxId + 1); for (let i = 0; i < adminId.length; i++) { const id = adminId[i]; - if (id < 0 || id >= totals.length || fields.sea?.[i]) continue; + if (id < 0 || fields.sea?.[i]) continue; landCells[id]++; const density = fields.populationDensity?.[i] || 0; const lu = fields.landuse?.[i] ?? 0; @@ -68,7 +87,7 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement let skippedDuplicateSettlementPopulation = 0; for (const { feature, i } of uniqueSettlementByCell.values()) { const id = adminId[i]; - if (id < 0 || id >= totals.length) continue; + if (id < 0 || id >= settlementTotals.length) continue; settlementTotals[id] += feature.population; } for (const feature of settlementFeatures || []) { @@ -77,7 +96,7 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement const kept = uniqueSettlementByCell.get(key)?.feature; if (kept && kept !== feature) skippedDuplicateSettlementPopulation += feature.population || 0; } - for (let id = 0; id < adminCenters.length; id++) { + for (const [id, center] of centerById) { const raw = (totals[id] || 0) + (settlementTotals[id] || 0); const minimumResidentPopulation = landCells[id] > 0 ? Math.round(Math.max(100, Math.min(3800, 80 + landCells[id] * 9 + inhabitedCells[id] * 16)) / 100) * 100 @@ -85,13 +104,13 @@ function assignMunicipalityPopulations(adminCenters, adminId, fields, settlement const adjustedRaw = Math.max(raw, minimumResidentPopulation); const rounded = adjustedRaw >= 10000 ? Math.round(adjustedRaw / 1000) * 1000 : Math.max(minimumResidentPopulation, Math.round(adjustedRaw / 100) * 100); const safePopulation = Math.max(landCells[id] > 0 ? 100 : 0, rounded); - adminCenters[id].municipalityPopulation = safePopulation; + center.municipalityPopulation = safePopulation; // Some consumers still read the generic `population` field from municipal // centers. Mirror the municipality total there so no municipality is shown // as 0人 merely because it is not a canonical city/market entity. - adminCenters[id].population = Math.max(adminCenters[id].population || 0, safePopulation); - adminCenters[id].municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100); - adminCenters[id].municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100); + center.population = Math.max(center.population || 0, safePopulation); + center.municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100); + center.municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100); } } @@ -467,7 +486,20 @@ export function finishMapOutput({ castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug); externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug); outputProgress("municipality naming"); - const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug); + const municipalCoherence = reconcileMunicipalMetadata({ + adminId, + prefectureRegionId, + sea, + adminCenters: adminCentersRaw, + municipalityToPrefectureId, + fields: nameFields, + width: MAP_W, + height: MAP_H, + seed, + }); + if (adminDebug) adminDebug.municipalCoherence = municipalCoherence.debug; + const coherentMunicipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId || municipalityToPrefectureId; + const adminCenters = attachIdsAndNames(tagInsidePrefecture(municipalCoherence.adminCenters, humanRegionMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug); const representativeFeatures = [ ...modernCities.map((p) => ({ ...p, representativeWeight: 5.0 + (p.population || 0) / 180000 })), ...markets.map((p) => ({ ...p, representativeWeight: 3.2 })), @@ -515,25 +547,27 @@ export function finishMapOutput({ } const usedAdminNames = new Set(); for (const [index, center] of adminCenters.entries()) { - center.adminNumericId = index; - center.municipalityId = index; - let candidate = municipalityNameFromRoot(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name, center, nameFields, seed, index); - const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, index); + const municipalId = centerMunicipalityId(center, index); + center.adminId = municipalId; + center.adminNumericId = municipalId; + center.municipalityId = municipalId; + let candidate = municipalityNameFromRoot(center.municipalityRootName || center.canonicalSettlementName || center.generatedMunicipalityName || center.name, center, nameFields, seed, municipalId); + const generated = municipalityNameFromRoot(center.generatedMunicipalityName || center.name, center, nameFields, seed + 177, municipalId); if (usedAdminNames.has(candidate) && Array.from(generated).length >= 2 && !usedAdminNames.has(generated)) { candidate = generated; } if (usedAdminNames.has(candidate)) { - const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, index); - const rootSource = String(center.generatedMunicipalityName || center.name || center.municipalityRootName || `自治${index + 1}`).replace(/[市町村区駅港城跡宿]$/gu, ""); + const suffix = municipalitySuffixForCenter(center, nameFields, seed + 313, municipalId); + const rootSource = String(center.generatedMunicipalityName || center.name || center.municipalityRootName || `自治${municipalId + 1}`).replace(/[市町村区駅港城跡宿]$/gu, ""); const chars = Array.from(rootSource || "里郷"); const alternates = [ chars.slice(0, 2).join(""), chars.slice(-2).join(""), - `${chars[0] || "里"}${["里", "郷", "野", "田", "川", "原", "浜", "浦"][(seed + index) % 8]}`, - `${["東", "西", "南", "北", "上", "下", "中"][(seed + index) % 7]}${chars[0] || "里"}`, + `${chars[0] || "里"}${["里", "郷", "野", "田", "川", "原", "浜", "浦"][(seed + municipalId) % 8]}`, + `${["東", "西", "南", "北", "上", "下", "中"][(seed + municipalId) % 7]}${chars[0] || "里"}`, ].filter((v) => Array.from(v).length >= 2); for (let attempt = 0; attempt < alternates.length + 12 && usedAdminNames.has(candidate); attempt++) { - const root = attempt < alternates.length ? alternates[attempt] : `第${(index + attempt) % 10}`; + const root = attempt < alternates.length ? alternates[attempt] : `第${(municipalId + attempt) % 10}`; candidate = `${root}${suffix}`; } } @@ -584,7 +618,7 @@ export function finishMapOutput({ return bs - as; }) .filter((p) => { - const prefId = municipalityToPrefectureId?.[p.municipalityId] ?? -1; + const prefId = coherentMunicipalityToPrefectureId?.[p.municipalityId] ?? -1; const used = perPrefectureQuota.get(prefId) || 0; if (used >= 18) return false; perPrefectureQuota.set(prefId, used + 1); @@ -1233,7 +1267,7 @@ export function finishMapOutput({ humanRegionMask, prefectureBorder: regionalPrefectureBorders.length ? regionalPrefectureBorders : prefectureBorder, prefectureRegionId, - municipalityToPrefectureId, + municipalityToPrefectureId: coherentMunicipalityToPrefectureId, prefectureRegions, regionalDebug, terrainDebug, diff --git a/mapPatch.js b/mapPatch.js index d01be2a..f8457d2 100644 --- a/mapPatch.js +++ b/mapPatch.js @@ -1,6 +1,7 @@ import { MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, lerp, smoothstep, valueNoise } from "./mapUtils.js"; import { generateMap } from "./mapPipeline.js"; import { LANDUSE } from "./landuseCodes.js"; +import { reconcileMunicipalMetadata, refreshPrefectureRegionsMetadata } from "./mapMunicipalCoherence.js"; export const PATCH_MIN_WIDTH = 48; export const PATCH_MIN_HEIGHT = 48; @@ -24,6 +25,7 @@ const RAIL_LAYER_KEYS = new Set(["railways", "branchRailways", "ringRailways", " const RIVER_LAYER_KEYS = new Set(["mainRivers", "tributaryRivers", "smallStreams", "riverPaths"]); const SEGMENT_LAYER_KEYS = ["adminBorders", "regionalPrefectureBorders", "prefectureBorder"]; +const PATCH_CANDIDATE_CACHE_LIMIT = 3; const ID_FIELD_OFFSETS = new Map([ ["adminId", 100000], @@ -72,6 +74,23 @@ function rectArea(rect) { return rectWidth(rect) * rectHeight(rect); } +function nowMs() { + return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); +} + +function createPatchTimer() { + const timings = []; + let mark = nowMs(); + return { + timings, + mark(key, label = key) { + const t = nowMs(); + timings.push({ key, label, ms: Math.round((t - mark) * 10) / 10 }); + mark = t; + }, + }; +} + function normalizeRect(rect) { if (!rect) return null; const x0 = Math.floor(Math.min(rect.x0, rect.x1)); @@ -257,7 +276,10 @@ export function buildPatchRects(userRect, world = null) { const repairMargin = Math.max(writeMargin, Math.min(desiredRepair, maxBySource)); const writeRect = expandRect(coreRect, writeMargin, world); const repairRect = expandRect(coreRect, repairMargin, world); - const transportReachMargin = Math.max(repairMargin + 160, Math.min(420, Math.max(220, repairMargin + Math.floor(Math.max(MAP_W, MAP_H) * 1.35)))); + const transportReachMargin = Math.max( + repairMargin + 48, + Math.min(260, Math.max(96, repairMargin + Math.floor(shortSide * 1.15))) + ); const transportReachRect = expandRect(coreRect, transportReachMargin, world); return { coreRect, @@ -277,7 +299,7 @@ export function buildPatchRects(userRect, world = null) { }; } -function patchAlpha(x, y, rects, seed = 0) { +function computePatchAlpha(x, y, rects, seed = 0) { const writeRect = rects.writeRect || rects.userRect; if (!insideRect(x, y, writeRect)) return 0; const margin = Math.max(1, rects.writeMargin || 1); @@ -289,10 +311,9 @@ function patchAlpha(x, y, rects, seed = 0) { const py = y + 0.5; const inside = pointInPolygon(px, py, shape.polygon); const dist = distanceToPolygonEdge(px, py, shape.polygon); - const noisyDist = dist + low * margin * 0.28 + mid * margin * 0.10; - if (inside) return 1; - if (noisyDist >= margin * 1.08) return 0; - return clamp(smoothstep(1 - noisyDist / Math.max(1e-6, margin))); + const signedDist = inside ? dist : -dist; + const noisySigned = signedDist + low * margin * 0.28 + mid * margin * 0.10; + return clamp(smoothstep((noisySigned + margin) / Math.max(1e-6, margin * 2))); } const edge = distanceToRectEdge(x, y, writeRect); const noisyEdge = edge + low * margin * 0.42 + mid * margin * 0.16; @@ -303,6 +324,53 @@ function patchAlpha(x, y, rects, seed = 0) { return clamp(base); } +function getPatchAlphaCache(rects, seed = 0) { + const writeRect = rects?.writeRect || rects?.userRect; + if (!writeRect) return null; + const width = rectWidth(writeRect); + const height = rectHeight(writeRect); + const existing = rects.patchAlphaCache; + if ( + existing + && existing.seed === seed + && existing.width === width + && existing.height === height + && existing.x0 === writeRect.x0 + && existing.y0 === writeRect.y0 + ) return existing; + + const data = new Float32Array(width * height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + data[y * width + x] = computePatchAlpha(writeRect.x0 + x, writeRect.y0 + y, rects, seed); + } + } + rects.patchAlphaCache = { seed, width, height, x0: writeRect.x0, y0: writeRect.y0, data }; + return rects.patchAlphaCache; +} + +function patchAlpha(x, y, rects, seed = 0) { + const writeRect = rects?.writeRect || rects?.userRect; + if (!writeRect || !insideRect(x, y, writeRect)) return 0; + const cache = rects.patchAlphaCache; + if ( + cache + && cache.seed === seed + && x >= cache.x0 + && y >= cache.y0 + && x < cache.x0 + cache.width + && y < cache.y0 + cache.height + ) return cache.data[(y - cache.y0) * cache.width + (x - cache.x0)] || 0; + return computePatchAlpha(x, y, rects, seed); +} + +function patchBand(x, y, rects, seed = 0) { + const a = patchAlpha(x, y, rects, seed); + if (a <= 0.18) return "preserve"; + if (a >= 0.82) return "core"; + return "feather"; +} + function continuityReplaceThreshold(name, x, y, rects, seed = 0) { const n = valueNoise(x, y, seed ^ 0x4f1bbcdc, 11) - 0.5; if (ADMIN_CONTINUITY_FIELD_NAMES.has(name)) return clamp(0.82 + n * 0.12, 0.70, 0.92); @@ -315,6 +383,21 @@ function continuitySegmentAllowed(x, y, nx, ny, rects, seed, minAlpha = 0.42) { return Math.min(patchAlpha(x, y, rects, seed), patchAlpha(nx, ny, rects, seed)) >= minAlpha; } +function patchAffected(x, y, rects, seed = 0, minAlpha = 0.34) { + return insideRect(Math.round(x), Math.round(y), rects?.writeRect) && patchAlpha(Math.round(x), Math.round(y), rects, seed) >= minAlpha; +} + +function segmentTouchesPatch(world, seg, rects, seed = 0, minAlpha = 0.34) { + if (!Array.isArray(seg) || seg.length < 2) return false; + const ax = tupleWorldX(world, seg[0]); + const ay = tupleWorldY(world, seg[0]); + const bx = tupleWorldX(world, seg[1]); + const by = tupleWorldY(world, seg[1]); + const mx = (ax + bx) * 0.5; + const my = (ay + by) * 0.5; + return patchAffected(ax, ay, rects, seed, minAlpha) || patchAffected(bx, by, rects, seed, minAlpha) || patchAffected(mx, my, rects, seed, minAlpha); +} + function sourceWindowForRects(rects) { const cx = (rects.coreRect.x0 + rects.coreRect.x1 - 1) / 2; @@ -334,6 +417,59 @@ function sourceCoordForWorld(window, x, y) { }; } +function getPatchSourceIndexCache(rects, window) { + const writeRect = rects?.writeRect; + if (!writeRect || !window) return null; + const width = rectWidth(writeRect); + const height = rectHeight(writeRect); + const existing = rects.patchSourceIndexCache; + if ( + existing + && existing.width === width + && existing.height === height + && existing.x0 === writeRect.x0 + && existing.y0 === writeRect.y0 + && existing.worldCenterX === window.worldCenterX + && existing.worldCenterY === window.worldCenterY + && existing.sourceCenterX === window.sourceCenterX + && existing.sourceCenterY === window.sourceCenterY + ) return existing; + + const data = new Int32Array(width * height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const sx = Math.round(writeRect.x0 + x - window.worldCenterX + window.sourceCenterX); + const sy = Math.round(writeRect.y0 + y - window.worldCenterY + window.sourceCenterY); + data[y * width + x] = sourceIndex(sx, sy); + } + } + rects.patchSourceIndexCache = { + width, + height, + x0: writeRect.x0, + y0: writeRect.y0, + worldCenterX: window.worldCenterX, + worldCenterY: window.worldCenterY, + sourceCenterX: window.sourceCenterX, + sourceCenterY: window.sourceCenterY, + data, + }; + return rects.patchSourceIndexCache; +} + +function sourceIndexForWorld(rects, window, x, y) { + const cache = rects?.patchSourceIndexCache; + if ( + cache + && x >= cache.x0 + && y >= cache.y0 + && x < cache.x0 + cache.width + && y < cache.y0 + cache.height + ) return cache.data[(y - cache.y0) * cache.width + (x - cache.x0)]; + const s = sourceCoordForWorld(window, x, y); + return sourceIndex(s.x, s.y); +} + function worldCoordForSource(window, sx, sy) { return { x: Math.round(sx - window.sourceCenterX + window.worldCenterX), @@ -385,8 +521,7 @@ function collectCandidateIdsInRect(candidateField, rects, window, minAlpha = 0.2 for (let y = rect.y0; y < rect.y1; y++) { for (let x = rect.x0; x < rect.x1; x++) { if (patchAlpha(x, y, rects, seed) < minAlpha) continue; - const s = sourceCoordForWorld(window, x, y); - const si = sourceIndex(s.x, s.y); + const si = sourceIndexForWorld(rects, window, x, y); if (si < 0) continue; const id = candidateField[si]; if (Number.isFinite(id) && id >= 0) ids.add(Math.floor(id)); @@ -671,6 +806,70 @@ function stabilizeContinuitySeam(world, rects, oldFields, seed = 0) { return { continuityCellsRestored: restored, continuityCellsRemapped: remapped }; } +function chooseSeamOwnerValue(world, fieldName, oldField, candidateValue, x, y, rects, seed) { + const a = patchAlpha(x, y, rects, seed); + const i = worldIndex(world, x, y); + const oldValue = oldField?.[i] ?? -1; + if (oldValue < 0 || candidateValue < 0) return candidateValue >= 0 ? candidateValue : oldValue; + if (a <= 0.24) return oldValue; + if (a >= 0.82) return candidateValue; + + const field = world.fields?.[fieldName]; + const pref = world.fields?.prefectureRegionId; + const naturalBarrier = world.fields?.naturalBarrierScore || world.fields?.ridgeField; + let oldScore = (1 - a) * 3.0; + let candidateScore = a * 3.0; + for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) { + const ni = worldIndex(world, x + dx, y + dy); + if (ni < 0) continue; + const neighbor = field?.[ni] ?? -1; + if (neighbor === oldValue) oldScore += 1.2; + if (neighbor === candidateValue) candidateScore += 1.2; + if (fieldName !== "prefectureRegionId" && pref && pref[ni] >= 0) { + if (pref[ni] === pref[i] && candidateValue !== oldValue) oldScore += 0.18; + } + } + const barrierBonus = naturalBarrier?.[i] || 0; + if (barrierBonus > 0.48 && Math.abs(a - 0.5) < 0.24) { + if (a < 0.5) oldScore += barrierBonus * 0.9; + else candidateScore += barrierBonus * 0.9; + } + return candidateScore > oldScore ? candidateValue : oldValue; +} + +function repairDiscreteSeamOwnership(world, rects, oldFields, seed = 0) { + let adminSeamCellsResolved = 0; + let prefectureSeamCellsResolved = 0; + for (const name of ["prefectureRegionId", "adminId", "municipalityId"]) { + const field = world.fields?.[name]; + const old = oldFields?.get(name); + if (!field || !old) continue; + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const i = worldIndex(world, x, y); + if (i < 0 || world.fields.sea?.[i]) continue; + if (patchBand(x, y, rects, seed) !== "feather") continue; + const before = field[i]; + const next = chooseSeamOwnerValue(world, name, old, before, x, y, rects, seed); + if (next !== before) { + field[i] = next; + if (name === "prefectureRegionId") prefectureSeamCellsResolved++; + else adminSeamCellsResolved++; + } + } + } + } + if (world.fields.adminId && world.fields.municipalityId) { + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + const i = worldIndex(world, x, y); + if (i >= 0 && !world.fields.sea?.[i]) world.fields.municipalityId[i] = world.fields.adminId[i]; + } + } + } + return { adminSeamCellsResolved, prefectureSeamCellsResolved }; +} + function copyFullPipelineFields(world, candidate, rects, seed) { const window = sourceWindowForRects(rects); const oldSea = world.fields.sea ? new Uint8Array(world.fields.sea) : null; @@ -703,8 +902,7 @@ function copyFullPipelineFields(world, candidate, rects, seed) { for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { const wi = worldIndex(world, x, y); if (wi < 0) continue; - const s = sourceCoordForWorld(window, x, y); - const si = sourceIndex(s.x, s.y); + const si = sourceIndexForWorld(rects, window, x, y); if (si < 0) continue; const alpha = patchAlpha(x, y, rects, seed); if (alpha <= 0.005) continue; @@ -780,6 +978,7 @@ function copyFullPipelineFields(world, candidate, rects, seed) { } const continuityDebug = stabilizeContinuitySeam(world, rects, oldContinuityFields, seed); + const seamOwnershipDebug = repairDiscreteSeamOwnership(world, rects, oldContinuityFields, seed); if (world.fields.adminId && world.fields.municipalityId && !candidate?.municipalityId) { for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { @@ -800,6 +999,7 @@ function copyFullPipelineFields(world, candidate, rects, seed) { adminIdMapping, adminIdMappingDebug: summarizeIdMapping(adminIdMapping), ...continuityDebug, + ...seamOwnershipDebug, }; } @@ -830,6 +1030,47 @@ function repairDisplayMasks(world, rects, seed = 0) { return { displayMaskUpdated }; } +function featherTerrainSeam(world, rects, seed = 0) { + const fields = world.fields || {}; + const smoothKeys = [ + "elevation", "moisture", "ridgeField", "valleyField", "visibleRavineField", + "basinField", "coastalLowland", "plain", "agriculture", "erosionField", + "depositionField", "depositionalLowland", "alluvialFanField", "deltaField", + "naturalBarrierScore", "settlementScore", "populationDensity", + ]; + let terrainFeatherCells = 0; + let terrainFeatherValues = 0; + for (const key of smoothKeys) { + const field = fields[key]; + if (!field || !ArrayBuffer.isView(field)) continue; + const old = new field.constructor(field); + for (let y = rects.writeRect.y0; y < rects.writeRect.y1; y++) { + for (let x = rects.writeRect.x0; x < rects.writeRect.x1; x++) { + if (patchBand(x, y, rects, seed) !== "feather") continue; + const i = worldIndex(world, x, y); + if (i < 0 || fields.sea?.[i]) continue; + let sum = 0; + let count = 0; + for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) { + const ni = worldIndex(world, x + dx, y + dy); + if (ni >= 0 && !fields.sea?.[ni]) { + sum += old[ni] || 0; + count++; + } + } + if (!count) continue; + const a = patchAlpha(x, y, rects, seed); + const neighborMean = sum / count; + const seamWeight = 0.34 * (1 - Math.abs(a - 0.5) * 1.2); + field[i] = lerp(field[i] || 0, neighborMean, clamp(seamWeight, 0.08, 0.34)); + terrainFeatherValues++; + if (key === "elevation") terrainFeatherCells++; + } + } + } + return { terrainFeatherCells, terrainFeatherValues }; +} + function nearestLandFieldValue(world, x, y, fieldName, rect, options = {}) { const field = world.fields?.[fieldName]; @@ -873,7 +1114,7 @@ function lookupPrefectureForAdmin(sourceMap, adminIdMapping, adminId) { return -1; } -function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null) { +function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null, seed = 0) { const admin = world.fields?.adminId; if (!admin) return { seaAdminCellsCleared: 0, landAdminCellsFilled: 0, prefectureCellsFilled: 0, adminPrefectureCellsAligned: 0 }; const expected = world.width * world.height; @@ -905,7 +1146,7 @@ function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null) { prefecture[i] = -1; continue; } - const generated = !coverage || coverage[i] || patchAlpha(x, y, rects, 0) > 0.08; + const generated = patchAlpha(x, y, rects, seed) > 0.08 || (!coverage && insideRect(x, y, rects.writeRect)); if (!generated) continue; if (admin[i] < 0) { @@ -958,7 +1199,7 @@ function repairAdminCoverage(world, sourceMap, rects, adminIdMapping = null) { return { seaAdminCellsCleared, landAdminCellsFilled, prefectureCellsFilled, adminPrefectureCellsAligned }; } -function smoothWaterTopology(world, rect, seaLevel = 0.30) { +function smoothWaterTopology(world, rect, seaLevel = 0.30, rects = null, seed = 0) { const sea = world.fields.sea; const ocean = world.fields.ocean; const lake = world.fields.lake; @@ -981,8 +1222,11 @@ function smoothWaterTopology(world, rect, seaLevel = 0.30) { if (sea[ni]) seaN++; else landN++; } } - if (sea[i] && seaN <= 1 && elevation[i] > seaLevel - 0.035) flips.push([i, 0]); - else if (!sea[i] && seaN >= 7 && elevation[i] < seaLevel + 0.055) flips.push([i, 1]); + const a = rects ? patchAlpha(x, y, rects, seed) : 1; + if (a < 0.24) continue; + const strongOnly = a < 0.42; + if (sea[i] && seaN <= (strongOnly ? 0 : 1) && elevation[i] > seaLevel - 0.035) flips.push([i, 0]); + else if (!sea[i] && seaN >= (strongOnly ? 8 : 7) && elevation[i] < seaLevel + 0.055) flips.push([i, 1]); } } for (const [i, nextSea] of flips) { @@ -1086,6 +1330,45 @@ function sourcePathFromWorld(world, path) { return path.map(([x, y]) => [Math.round(x - world.originX), Math.round(y - world.originY)]); } +function offsetPointNumericFields(point, fields, offset) { + for (const field of fields) if (Number.isFinite(point[field])) point[field] += offset; +} + +function normalizeGeneratedPointIds(point, key, seed = 0, adminIdMapping = null) { + const rawAdminId = numericFeatureId(point, ["adminId", "municipalityId", "adminNumericId"]); + if (rawAdminId >= 0) { + const mappedAdminId = adminIdMapping?.admin?.get(rawAdminId) ?? adminIdMapping?.municipality?.get(rawAdminId); + if (Number.isFinite(mappedAdminId)) { + point.sourceAdminId = rawAdminId; + point.adminId = mappedAdminId; + point.adminNumericId = mappedAdminId; + point.municipalityId = mappedAdminId; + } else { + offsetPointNumericFields(point, ["adminId", "adminNumericId", "municipalityId"], fieldIdOffset("adminId", seed)); + if (!Number.isFinite(point.adminId) && Number.isFinite(point.municipalityId)) point.adminId = point.municipalityId; + if (!Number.isFinite(point.municipalityId) && Number.isFinite(point.adminId)) point.municipalityId = point.adminId; + } + } + + const rawPrefectureId = numericFeatureId(point, key === "prefectureRegions" ? ["prefectureRegionId", "id"] : ["prefectureRegionId"]); + if (rawPrefectureId >= 0) { + const mappedPrefectureId = adminIdMapping?.prefecture?.get(rawPrefectureId); + if (Number.isFinite(mappedPrefectureId)) { + point.sourcePrefectureRegionId = rawPrefectureId; + point.prefectureRegionId = mappedPrefectureId; + if (key === "prefectureRegions") point.id = mappedPrefectureId; + } else { + const offset = fieldIdOffset("prefectureRegionId", seed); + if (Number.isFinite(point.prefectureRegionId)) point.prefectureRegionId += offset; + if (key === "prefectureRegions" && Number.isFinite(point.id)) point.id += offset; + } + } + + if (Number.isFinite(point.adminId) && !Number.isFinite(point.municipalityId)) point.municipalityId = point.adminId; + if (Number.isFinite(point.municipalityId) && !Number.isFinite(point.adminId)) point.adminId = point.municipalityId; + return point; +} + function transformCandidatePoint(world, window, p, key, seed = 0, adminIdMapping = null) { if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) return null; const w = worldCoordForSource(window, p.x, p.y); @@ -1099,39 +1382,12 @@ function transformCandidatePoint(world, window, p, key, seed = 0, adminIdMapping w.x = land.x; w.y = land.y; } const out = sourcePointFromWorld(world, { ...p, x: w.x, y: w.y }); + normalizeGeneratedPointIds(out, key, seed, adminIdMapping); if (key === "adminCenters") { - const rawAdminId = numericFeatureId(out, ["adminId", "municipalityId", "adminNumericId"]); - const mappedAdminId = rawAdminId >= 0 - ? adminIdMapping?.admin?.get(rawAdminId) ?? adminIdMapping?.municipality?.get(rawAdminId) - : undefined; - if (Number.isFinite(mappedAdminId)) { - out.sourceAdminId = rawAdminId; - out.adminId = mappedAdminId; - out.adminNumericId = mappedAdminId; - out.municipalityId = mappedAdminId; - const candidatePrefId = adminIdMapping?.candidateAdminToPrefecture?.get(rawAdminId); + if (Number.isFinite(out.sourceAdminId)) { + const candidatePrefId = adminIdMapping?.candidateAdminToPrefecture?.get(out.sourceAdminId); const mappedPrefId = adminIdMapping?.prefecture?.get(candidatePrefId); if (Number.isFinite(mappedPrefId)) out.prefectureRegionId = mappedPrefId; - } else { - const offset = fieldIdOffset("adminId", seed); - if (Number.isFinite(out.adminId)) out.adminId += offset; - if (Number.isFinite(out.adminNumericId)) out.adminNumericId += offset; - if (Number.isFinite(out.municipalityId)) out.municipalityId += offset; - if (!Number.isFinite(out.adminId) && Number.isFinite(out.municipalityId)) out.adminId = out.municipalityId; - if (!Number.isFinite(out.municipalityId) && Number.isFinite(out.adminId)) out.municipalityId = out.adminId; - } - } - if (key === "prefectureRegions") { - const rawPrefectureId = numericFeatureId(out, ["prefectureRegionId", "id"]); - const mappedPrefectureId = rawPrefectureId >= 0 ? adminIdMapping?.prefecture?.get(rawPrefectureId) : undefined; - if (Number.isFinite(mappedPrefectureId)) { - out.sourcePrefectureRegionId = rawPrefectureId; - out.id = mappedPrefectureId; - out.prefectureRegionId = mappedPrefectureId; - } else { - const offset = fieldIdOffset("prefectureRegionId", seed); - if (Number.isFinite(out.id)) out.id += offset; - if (Number.isFinite(out.prefectureRegionId)) out.prefectureRegionId += offset; } } if (key === "logisticsParks") sanitizeLogisticsPark(out); @@ -1181,12 +1437,12 @@ function transformCandidatePath(window, path) { return out; } -function splitWorldPathByRect(path, rect, keepInside) { +function splitWorldPathByPredicate(path, predicate, keepWhenTrue) { const chunks = []; let current = []; for (const p of path || []) { - const inside = insideRect(Math.round(p[0]), Math.round(p[1]), rect); - if (inside === keepInside) current.push([Math.round(p[0]), Math.round(p[1])]); + const matches = predicate(Math.round(p[0]), Math.round(p[1])); + if (matches === keepWhenTrue) current.push([Math.round(p[0]), Math.round(p[1])]); else { if (current.length >= 2) chunks.push(current); current = []; @@ -1196,13 +1452,21 @@ function splitWorldPathByRect(path, rect, keepInside) { return chunks; } -function pruneOldPathLayer(world, paths, rect, mode) { +function splitWorldPathByRect(path, rect, keepInside) { + return splitWorldPathByPredicate(path, (x, y) => insideRect(x, y, rect), keepInside); +} + +function splitWorldPathByPatch(path, rects, seed, keepAffected, minAlpha = 0.34) { + return splitWorldPathByPredicate(path, (x, y) => patchAffected(x, y, rects, seed, minAlpha), keepAffected); +} + +function pruneOldPathLayer(world, paths, rects, seed, mode) { const kept = []; const anchors = []; let clipped = 0; for (const path of paths || []) { const worldPath = (path || []).map((tuple) => [Math.round(tupleWorldX(world, tuple)), Math.round(tupleWorldY(world, tuple))]); - const touches = worldPath.some(([x, y]) => insideRect(x, y, rect)); + const touches = worldPath.some(([x, y]) => patchAffected(x, y, rects, seed, 0.34)); if (!touches) { kept.push(path); continue; @@ -1211,7 +1475,7 @@ function pruneOldPathLayer(world, paths, rect, mode) { let lastOutside = null; let wasInside = false; for (const [x, y] of worldPath) { - const inside = insideRect(x, y, rect); + const inside = patchAffected(x, y, rects, seed, 0.34); if (!inside) { if (wasInside) anchors.push({ x, y, mode }); lastOutside = { x, y, mode }; @@ -1220,7 +1484,7 @@ function pruneOldPathLayer(world, paths, rect, mode) { } wasInside = inside; } - for (const chunk of splitWorldPathByRect(worldPath, rect, false)) kept.push(sourcePathFromWorld(world, chunk)); + for (const chunk of splitWorldPathByPatch(worldPath, rects, seed, false, 0.34)) kept.push(sourcePathFromWorld(world, chunk)); } return { kept, anchors, clipped }; } @@ -1357,28 +1621,38 @@ function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRe const allTargets = collectInternalNetworkPoints(world, sourceMap, keys, rect, mode); const preferredTargets = preferredTargetRect ? allTargets.filter((p) => insideRect(p.x, p.y, preferredTargetRect)) : []; const targets = preferredTargets.length ? preferredTargets : allTargets; - if (!targets.length) return { connectors: 0, disconnected: anchors.length }; + if (!targets.length) return { connectors: 0, disconnected: anchors.length, skippedConnectorAnchors: 0, connectorAttempts: 0 }; let connectors = 0; let disconnected = 0; + let skippedConnectorAnchors = 0; + let connectorAttempts = 0; const layer = mode === "rail" ? "branchRailways" : "minorRoads"; sourceMap[layer] ||= []; const seen = new Set(); - const maxRange = mode === "rail" ? 320 : 360; - for (const raw of anchors) { + const maxRange = mode === "rail" ? 220 : 260; + const maxAnchors = mode === "rail" ? 10 : 18; + const maxTargets = mode === "rail" ? 3 : 3; + const searchRect = expandRect(rect, 16, world); + const orderedAnchors = (anchors || []) + .map((p) => ({ ...p, patchDistance: rectDistance(p.x, p.y, preferredTargetRect || rect) })) + .sort((a, b) => a.patchDistance - b.patchDistance) + .slice(0, maxAnchors); + skippedConnectorAnchors = Math.max(0, (anchors?.length || 0) - orderedAnchors.length); + for (const raw of orderedAnchors) { const anchorLand = nearestLand(world, raw.x, raw.y, rect, 18); if (!anchorLand) { disconnected++; continue; } const targetList = targets .map((p) => ({ ...p, d: Math.hypot(p.x - anchorLand.x, p.y - anchorLand.y) })) .filter((p) => p.d <= maxRange && p.d >= 6) .sort((a, b) => (a.d / Math.max(0.6, a.weight || 1)) - (b.d / Math.max(0.6, b.weight || 1))) - .slice(0, 5); + .slice(0, maxTargets); if (!targetList.length) { disconnected++; continue; } let made = false; for (const target of targetList) { const sig = `${anchorLand.x},${anchorLand.y}:${target.x},${target.y}:${mode}`; if (seen.has(sig)) continue; - const searchRect = expandRect(rect, 24, world); - const path = localPathfind(world, anchorLand, target, searchRect, mode, mode === "rail" ? 62000 : 76000); + connectorAttempts++; + const path = localPathfind(world, anchorLand, target, searchRect, mode, mode === "rail" ? 36000 : 44000); if (!path || path.length < 2) continue; seen.add(sig); sourceMap[layer].push(sourcePathFromWorld(world, simplifyPath(path, mode === "rail" ? 3 : 2))); @@ -1388,7 +1662,7 @@ function connectAnchors(world, sourceMap, anchors, mode, rect, preferredTargetRe } if (!made) disconnected++; } - return { connectors, disconnected }; + return { connectors, disconnected, skippedConnectorAnchors, connectorAttempts }; } function nearestNetworkPoint(world, sourceMap, keys, point, rect, maxDistance = 80) { @@ -1413,23 +1687,37 @@ function ensureSettlementRoadCoverage(world, sourceMap, rect) { const featureKeys = ["modernCities", "ports", "markets", "adminCenters", "villages"]; sourceMap.minorRoads ||= []; let connectors = 0; + let skippedServedSettlements = 0; + let checked = 0; const seen = new Set(); for (const key of featureKeys) { - for (const p of sourceMap[key] || []) { + const limit = key === "villages" ? 30 : 18; + const items = (sourceMap[key] || []) + .map((p) => ({ p, d: rectDistance(pointWorldX(world, p), pointWorldY(world, p), rect) })) + .filter((row) => row.d <= (key === "villages" ? 80 : 150)) + .sort((a, b) => a.d - b.d) + .slice(0, limit); + for (const { p } of items) { + checked++; const start = nearestLand(world, pointWorldX(world, p), pointWorldY(world, p), rect, 10); if (!start || !insideRect(start.x, start.y, rect)) continue; + const si = worldIndex(world, start.x, start.y); + if ((world.fields.roadInfluence?.[si] || 0) > (key === "villages" ? 0.18 : 0.12)) { + skippedServedSettlements++; + continue; + } const target = nearestNetworkPoint(world, sourceMap, keys, start, rect, key === "villages" ? 72 : 132); if (!target || Math.hypot(target.x - start.x, target.y - start.y) < 5) continue; const sig = `${start.x},${start.y}:${target.x},${target.y}`; if (seen.has(sig)) continue; seen.add(sig); - const path = localPathfind(world, start, target, rect, "road", 64000); + const path = localPathfind(world, start, target, rect, "road", 36000); if (!path || path.length < 2) continue; sourceMap.minorRoads.push(sourcePathFromWorld(world, simplifyPath(path, 2))); connectors++; } } - return connectors; + return { connectors, skippedServedSettlements, checkedSettlementCoverage: checked }; } function dedupeAdminCentersByWorldId(kept, generated) { @@ -1478,9 +1766,11 @@ function mergePointLayers(world, sourceMap, candidate, rects, window, seed, admi const inWrite = insideRect(wx, wy, rects.writeRect); const alpha = inWrite ? patchAlpha(wx, wy, rects, seed) : 0; if (!inWrite || alpha < 0.34) { - if (key === "ports" && inWrite && (!isLand(world, wx, wy) || seaNeighbors(world, wx, wy, 2) < 2)) { invalidPortsRemoved++; continue; } kept.push(p); if (!inWrite) preservedExternalEntities++; + } else if (key === "ports" && (!isLand(world, wx, wy) || seaNeighbors(world, wx, wy, 2) < 2)) { + invalidPortsRemoved++; + continue; } } const generated = []; @@ -1510,13 +1800,13 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) { for (const key of PATH_LAYER_KEYS) { const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : []; const mode = RAIL_LAYER_KEYS.has(key) ? "rail" : ROAD_LAYER_KEYS.has(key) ? "road" : RIVER_LAYER_KEYS.has(key) ? "river" : "path"; - const pruned = pruneOldPathLayer(world, oldArr, rects.writeRect, mode); + const pruned = pruneOldPathLayer(world, oldArr, rects, seed, mode); if (mode === "rail") { railAnchors = railAnchors.concat(pruned.anchors); railsClipped += pruned.clipped; } else if (mode === "road") { roadAnchors = roadAnchors.concat(pruned.anchors); roadsClipped += pruned.clipped; } const next = [...pruned.kept]; for (const path of candidate[key] || []) { const worldPath = transformCandidatePath(window, path); - const chunks = splitWorldPathByRect(worldPath, rects.writeRect, true) + const chunks = splitWorldPathByPatch(worldPath, rects, seed, true, 0.40) .map((chunk) => chunk.filter(([x, y]) => mode === "river" || isLand(world, x, y) || patchAlpha(x, y, rects, seed) > 0.90)) .filter((chunk) => chunk.length >= 2); for (const chunk of chunks) { @@ -1540,21 +1830,19 @@ function mergePathLayers(world, sourceMap, candidate, rects, window, seed) { roadsClipped, railsClipped, regeneratedPaths, - roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors, + roadConnectorsCreated: roadConn.connectors + settlementRoadConnectors.connectors, railwayConnectorsCreated: railConn.connectors, disconnectedRoadComponents: roadConn.disconnected, disconnectedRailComponents: railConn.disconnected, + skippedConnectorAnchors: (roadConn.skippedConnectorAnchors || 0) + (railConn.skippedConnectorAnchors || 0), + connectorAttempts: (roadConn.connectorAttempts || 0) + (railConn.connectorAttempts || 0), + skippedServedSettlements: settlementRoadConnectors.skippedServedSettlements || 0, + checkedSettlementCoverage: settlementRoadConnectors.checkedSettlementCoverage || 0, externalRoadAnchors: externalRoadAnchors.length, externalRailAnchors: externalRailAnchors.length, }; } -function segmentTouchesRect(world, seg, rect) { - if (!Array.isArray(seg) || seg.length < 2) return false; - return insideRect(tupleWorldX(world, seg[0]), tupleWorldY(world, seg[0]), rect) - || insideRect(tupleWorldX(world, seg[1]), tupleWorldY(world, seg[1]), rect); -} - function buildBoundarySegmentsFromField(world, fieldName, rect, options = {}) { const field = world.fields[fieldName]; const sea = world.fields.sea; @@ -1603,7 +1891,7 @@ function mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rec function mergeSegmentLayers(world, sourceMap, rects, seed = 0) { for (const key of SEGMENT_LAYER_KEYS) { const oldArr = Array.isArray(sourceMap[key]) ? sourceMap[key] : []; - sourceMap[key] = oldArr.filter((seg) => !segmentTouchesRect(world, seg, rects.writeRect)); + sourceMap[key] = oldArr.filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, 0.34)); } sourceMap.adminBorders ||= []; sourceMap.adminBorders.push(...buildBoundarySegmentsFromField(world, "adminId", rects.writeRect, { rects, seed, minAlpha: 0.58 })); @@ -1616,7 +1904,7 @@ function mergeSegmentLayers(world, sourceMap, rects, seed = 0) { // areas. Rebuilding directly from the raster field made patch compartments // look denser/smaller than the initial map. Candidate segments are merged // just after this function. - debug.compartmentBorders = (debug.compartmentBorders || []).filter((seg) => !segmentTouchesRect(world, seg, rects.writeRect)); + debug.compartmentBorders = (debug.compartmentBorders || []).filter((seg) => !segmentTouchesPatch(world, seg, rects, seed, 0.34)); sourceMap.adminDebug = debug; return { adminBordersRebuilt: sourceMap.adminBorders.length, @@ -1649,6 +1937,89 @@ function repairLanduseAndPopulation(world, rects) { return { landUseCellsUpdated: updated }; } +function ensureWorldFloatField(world, name) { + const expected = world.width * world.height; + if (!world.fields[name] || world.fields[name].length !== expected) world.fields[name] = new Float32Array(expected); + return world.fields[name]; +} + +function clearFieldRect(world, field, rect) { + for (let y = rect.y0; y < rect.y1; y++) { + for (let x = rect.x0; x < rect.x1; x++) { + const i = worldIndex(world, x, y); + if (i >= 0) field[i] = 0; + } + } +} + +function paintInfluenceDisk(world, field, cx, cy, radius, strength, rect) { + const sea = world.fields.sea; + const r = Math.max(1, Math.ceil(radius)); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + if (dx * dx + dy * dy > radius * radius) continue; + const x = Math.round(cx + dx); + const y = Math.round(cy + dy); + if (!insideRect(x, y, rect)) continue; + const i = worldIndex(world, x, y); + if (i < 0 || sea?.[i]) continue; + const d = Math.hypot(dx, dy); + const value = strength * Math.pow(1 - d / Math.max(1, radius), 1.35); + if (value > field[i]) field[i] = clamp(value); + } + } +} + +function refreshPatchInfluenceFields(world, sourceMap, rects) { + const rect = rects.repairRect || rects.writeRect; + const roadInfluence = ensureWorldFloatField(world, "roadInfluence"); + const railInfluence2 = ensureWorldFloatField(world, "railInfluence2"); + const stationInfluence = ensureWorldFloatField(world, "stationInfluence"); + const villageInfluence = ensureWorldFloatField(world, "villageInfluence"); + for (const field of [roadInfluence, railInfluence2, stationInfluence, villageInfluence]) clearFieldRect(world, field, rect); + + let roadCellsPainted = 0; + let railCellsPainted = 0; + let stationCellsPainted = 0; + let villageCellsPainted = 0; + const paintPathLayer = (keys, field, radius, strength, counterName) => { + let painted = 0; + for (const key of keys) { + for (const path of sourceMap[key] || []) { + for (const tuple of path || []) { + const x = tupleWorldX(world, tuple); + const y = tupleWorldY(world, tuple); + if (rectDistance(x, y, rect) > radius + 1) continue; + paintInfluenceDisk(world, field, x, y, radius, strength, rect); + painted++; + } + } + } + if (counterName === "road") roadCellsPainted += painted; + if (counterName === "rail") railCellsPainted += painted; + }; + + paintPathLayer(["nationalRoads", "ringRoads", "externalRoads", "minorRoads", "premodernRoads", "icAccessRoads"], roadInfluence, 5, 1, "road"); + paintPathLayer(["railways", "branchRailways", "ringRailways", "externalRailways"], railInfluence2, 4, 1, "rail"); + + for (const p of sourceMap.stations || []) { + const x = pointWorldX(world, p); + const y = pointWorldY(world, p); + if (rectDistance(x, y, rect) > 8) continue; + paintInfluenceDisk(world, stationInfluence, x, y, 5, clamp(p.score || 1), rect); + stationCellsPainted++; + } + for (const p of sourceMap.villages || []) { + const x = pointWorldX(world, p); + const y = pointWorldY(world, p); + if (rectDistance(x, y, rect) > 10) continue; + paintInfluenceDisk(world, villageInfluence, x, y, 6, clamp((p.population || 1800) / 4200, 0.35, 1.2), rect); + villageCellsPainted++; + } + + return { roadCellsPainted, railCellsPainted, stationCellsPainted, villageCellsPainted }; +} + function countSea(world, rect) { let seaCount = 0; let total = 0; @@ -1671,6 +2042,39 @@ function terrainId(candidate, fallback) { return candidate?.terrainTemplate?.terrainType || candidate?.terrainDebug?.terrainType || fallback; } +function rectKey(rect) { + return rect ? `${rect.x0},${rect.y0},${rect.x1},${rect.y1}` : "-"; +} + +function patchCandidateCacheKey({ seed, terrainType, variant, candidateOriginX, candidateOriginY, contextRect, serial = 0 }) { + return [serial, seed >>> 0, terrainType || "auto", variant >>> 0, candidateOriginX | 0, candidateOriginY | 0, rectKey(contextRect)].join("|"); +} + +function getPatchCandidateCache(world) { + if (!world.patchCandidateCache) world.patchCandidateCache = new Map(); + return world.patchCandidateCache; +} + +function rememberPatchCandidate(world, key, candidate) { + const cache = getPatchCandidateCache(world); + if (cache.has(key)) cache.delete(key); + cache.set(key, candidate); + while (cache.size > PATCH_CANDIDATE_CACHE_LIMIT) cache.delete(cache.keys().next().value); +} + +function getOrGeneratePatchCandidate(world, key, create) { + const cache = getPatchCandidateCache(world); + if (cache.has(key)) { + const candidate = cache.get(key); + cache.delete(key); + cache.set(key, candidate); + return { candidate, cacheHit: true, cacheSize: cache.size }; + } + const candidate = create(); + rememberPatchCandidate(world, key, candidate); + return { candidate, cacheHit: false, cacheSize: getPatchCandidateCache(world).size }; +} + export function generatePatch(world, userRectInput, options = {}) { const validation = validatePatchRect(userRectInput, world); if (!validation.ok) return { ok: false, ...validation }; @@ -1682,35 +2086,90 @@ export function generatePatch(world, userRectInput, options = {}) { const candidateWindow = sourceWindowForRects(rects); const candidateOriginX = Math.round(candidateWindow.worldCenterX - candidateWindow.sourceCenterX); const candidateOriginY = Math.round(candidateWindow.worldCenterY - candidateWindow.sourceCenterY); - const candidate = generateMap(seed, { + const patchTimer = createPatchTimer(); + const patchGenerationMode = "legacy-full-pipeline"; + const cacheKey = patchCandidateCacheKey({ + seed, terrainType, - legacyTerrain: true, - worldNative: true, variant, - originX: candidateOriginX, - originY: candidateOriginY, - width: MAP_W, - height: MAP_H, + candidateOriginX, + candidateOriginY, contextRect: rects.contextRect, - boundaryWorld: world, - onProgress: () => {}, + serial: world.patchGenerationSerial || 0, }); + const { candidate, cacheHit, cacheSize } = getOrGeneratePatchCandidate(world, cacheKey, () => generateMap(seed, { + terrainType, + legacyTerrain: true, + worldNative: true, + variant, + originX: candidateOriginX, + originY: candidateOriginY, + width: MAP_W, + height: MAP_H, + contextRect: rects.contextRect, + boundaryWorld: world, + onProgress: () => {}, + })); + patchTimer.mark("candidate", cacheHit ? "Full candidate generation (cached)" : "Full candidate generation"); + getPatchAlphaCache(rects, seed); + getPatchSourceIndexCache(rects, candidateWindow); const sourceMap = world.sourceMap || (world.sourceMap = {}); const logisticsLabelsMigrated = sanitizeExistingLogistics(sourceMap); const fieldDebug = copyFullPipelineFields(world, candidate, rects, seed); - const waterDebug = smoothWaterTopology(world, rects.writeRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30); + patchTimer.mark("fields", "Field copy and alpha blend"); + const terrainSeamDebug = featherTerrainSeam(world, rects, seed); + const waterDebug = smoothWaterTopology(world, rects.writeRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30, rects, seed); const maskDebug = repairDisplayMasks(world, rects, seed); recomputeSlopeAndWaterDependentFields(world, rects.repairRect, candidate.seaLevel || world.sourceMap?.seaLevel || 0.30); - const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, fieldDebug.adminIdMapping); - const adminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping); + patchTimer.mark("terrainRepair", "Water, masks, and terrain repair"); const pointDebug = mergePointLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed, fieldDebug.adminIdMapping); + patchTimer.mark("points", "Point merge"); const pathDebug = mergePathLayers(world, sourceMap, candidate, rects, fieldDebug.window, seed); + patchTimer.mark("paths", "Path merge and connector repair"); + const influenceDebug = refreshPatchInfluenceFields(world, sourceMap, rects); + patchTimer.mark("influence", "Influence refresh"); + const landDebug = repairLanduseAndPopulation(world, rects); + const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping, seed); + const sourceAdminMetadataUpdated = updateSourceAdminMetadata(sourceMap, fieldDebug.adminIdMapping); + const municipalCoherence = reconcileMunicipalMetadata({ + adminId: world.fields.adminId, + municipalityId: world.fields.municipalityId, + prefectureRegionId: world.fields.prefectureRegionId, + sea: world.fields.sea, + adminCenters: sourceMap.adminCenters || [], + municipalityToPrefectureId: sourceMap.municipalityToPrefectureId, + fields: world.fields, + width: world.width, + height: world.height, + pointOffsetX: world.originX || 0, + pointOffsetY: world.originY || 0, + seed, + }); + sourceMap.adminCenters = municipalCoherence.adminCenters; + sourceMap.municipalityToPrefectureId = municipalCoherence.municipalityToPrefectureId; + const prefectureCoherence = refreshPrefectureRegionsMetadata({ + prefectureRegionId: world.fields.prefectureRegionId, + sea: world.fields.sea, + existing: sourceMap.prefectureRegions || [], + fields: world.fields, + width: world.width, + height: world.height, + pointOffsetX: world.originX || 0, + pointOffsetY: world.originY || 0, + }); + sourceMap.prefectureRegions = prefectureCoherence.prefectureRegions; + sourceMap.adminDebug = { + ...(sourceMap.adminDebug || {}), + municipalCoherence: municipalCoherence.debug, + prefectureMetadataCoherence: prefectureCoherence.debug, + }; const segmentDebug = mergeSegmentLayers(world, sourceMap, rects, seed); const candidateCompartmentSegmentsAdded = mergeCandidateCompartmentDebugSegments(world, sourceMap, candidate, rects, fieldDebug.window, seed); - const landDebug = repairLanduseAndPopulation(world, rects); - const finalAdminCoverageDebug = repairAdminCoverage(world, sourceMap, rects, fieldDebug.adminIdMapping); + patchTimer.mark("segments", "Boundary and debug segment merge"); sanitizeExistingLogistics(sourceMap); + patchTimer.mark("cleanup", "Land-use, admin, and label cleanup"); + const patchTimings = patchTimer.timings; const seaStats = countSea(world, rects.coreRect); const label = terrainLabel(candidate, terrainType); @@ -1722,19 +2181,27 @@ export function generatePatch(world, userRectInput, options = {}) { villages: (sourceMap.villages || []).filter((p) => insideRect(pointWorldX(world, p), pointWorldY(world, p), rects.writeRect)).length, ...pointDebug, ...pathDebug, + ...influenceDebug, adminCellsReassigned: fieldDebug.adminCellsReassigned, adminIdMapping: fieldDebug.adminIdMappingDebug, sourceAdminMetadataUpdated, - ...adminCoverageDebug, + ...finalAdminCoverageDebug, finalSeaAdminCellsCleared: finalAdminCoverageDebug.seaAdminCellsCleared || 0, finalLandAdminCellsFilled: finalAdminCoverageDebug.landAdminCellsFilled || 0, finalPrefectureCellsFilled: finalAdminCoverageDebug.prefectureCellsFilled || 0, finalAdminPrefectureCellsAligned: finalAdminCoverageDebug.adminPrefectureCellsAligned || 0, + municipalCoherence: municipalCoherence.debug, + prefectureMetadataCoherence: prefectureCoherence.debug, continuityCellsRestored: fieldDebug.continuityCellsRestored || 0, continuityCellsRemapped: fieldDebug.continuityCellsRemapped || 0, + adminSeamCellsResolved: fieldDebug.adminSeamCellsResolved || 0, + prefectureSeamCellsResolved: fieldDebug.prefectureSeamCellsResolved || 0, + ...terrainSeamDebug, landUseCellsUpdated: fieldDebug.landUseCellsUpdated + landDebug.landUseCellsUpdated, displayMaskUpdated: maskDebug.displayMaskUpdated || 0, logisticsLabelsMigrated, + candidateCacheHit: cacheHit, + candidateCacheSize: cacheSize, ...segmentDebug, candidateCompartmentSegmentsAdded, }; @@ -1757,7 +2224,8 @@ export function generatePatch(world, userRectInput, options = {}) { variant, candidateOriginX, candidateOriginY, - patchGenerationMode: "legacy-full-pipeline", + patchGenerationMode, + patchTimings, updatedCells: fieldDebug.updatedCells, terrainCellsFullyReplaced: fieldDebug.terrainCellsFullyReplaced, coastCellsChanged: fieldDebug.coastCellsChanged + waterDebug.coastCellsChanged, @@ -1771,6 +2239,7 @@ export function generatePatch(world, userRectInput, options = {}) { world.generatedRects = [...(world.generatedRects || []), record]; world.invalidatedRects = [...(world.invalidatedRects || []), { ...rects.writeRect }]; world.lastPatchResult = record; + world.patchGenerationSerial = (world.patchGenerationSerial || 0) + 1; return { ok: true, @@ -1789,7 +2258,8 @@ export function generatePatch(world, userRectInput, options = {}) { variant, candidateOriginX, candidateOriginY, - patchGenerationMode: "legacy-full-pipeline", + patchGenerationMode, + patchTimings, updatedCells: record.updatedCells, terrainCellsFullyReplaced: record.terrainCellsFullyReplaced, coastCellsChanged: record.coastCellsChanged, diff --git a/renderer.js b/renderer.js index 61ac178..e387f7c 100644 --- a/renderer.js +++ b/renderer.js @@ -1190,7 +1190,7 @@ export function drawMap(canvas, map, options) { const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, forceLabel: true, labelPriorityBase: p.labelPriorityBase || 1900 })); if (mode === "admin") { const municipalLabels = (map.adminCenters || []) - .filter((p) => p && p.name) + .filter((p) => p && p.name && Number.isFinite(p.x) && Number.isFinite(p.y) && p.x >= 0 && p.y >= 0 && p.x < map.width && p.y < map.height && map.adminId?.[Math.round(p.y) * map.width + Math.round(p.x)] === (p.adminId ?? p.municipalityId ?? p.adminNumericId)) .map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 })); drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity); finish(); diff --git a/test.js b/test.js index fe3c036..2a54ed6 100644 --- a/test.js +++ b/test.js @@ -15,7 +15,7 @@ const result = document.getElementById("result"); const logLines = []; let failed = 0; -const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rendererSource, appSource, mapPipelineSource, mapAdminStageSource, testSource] = await Promise.all([ +const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rendererSource, appSource, mapPipelineSource, mapAdminStageSource, mapPatchSource, worldMapSource, municipalSource, testSource] = await Promise.all([ fetch("./names.js").then((response) => response.text()), fetch("./mapGenerator.js").then((response) => response.text()), fetch("./mapOutput.js").then((response) => response.text()), @@ -24,6 +24,9 @@ const [namesSource, mapGeneratorSource, mapOutputSource, mapTerrainSource, rende fetch("./app.js").then((response) => response.text()), fetch("./mapPipeline.js").then((response) => response.text()), fetch("./mapAdminStage.js").then((response) => response.text()), + fetch("./mapPatch.js").then((response) => response.text()), + fetch("./worldMap.js").then((response) => response.text()), + fetch("./mapMunicipalCoherence.js").then((response) => response.text()), fetch("./test.js").then((response) => response.text()), ]); @@ -675,6 +678,23 @@ try { const removedContextSuffixKey = "context" + "Suffixes"; assert(!namesSource.includes(removedContextSuffixConst) && !namesSource.includes(removedContextSuffixKey), "hidden context suffix arrays are absent"); assert(!/export\s+const\s+NAME_PROBABILITIES[\s\S]*export\s+const\s+NAME_PROBABILITIES/.test(namesSource), "NAME_PROBABILITIES has one source"); + assert(mapPatchSource.includes("splitWorldPathByPatch") && mapPatchSource.includes("patchAffected"), "patch path merging is alpha-aware for lasso selections"); + assert(!mapPatchSource.includes("patchAlpha(x, y, rects, 0)"), "patch admin repair uses the active patch seed"); + assert(mapPatchSource.includes("refreshPatchInfluenceFields") && mapPatchSource.includes("roadCellsPainted"), "patch generation refreshes derived transport influence fields after path merges"); + assert(mapPatchSource.includes("normalizeGeneratedPointIds"), "patch generation normalizes generated point admin and prefecture ids"); + assert(mapPatchSource.includes("generateMap(seed") && mapPatchSource.includes('patchGenerationMode = "legacy-full-pipeline"'), "patch generation remains full-pipeline simulation"); + assert(mapPatchSource.includes("patchTimings") && appSource.includes("result.patchTimings"), "patch generation returns and renders timing rows"); + assert(mapPatchSource.includes("PATCH_CANDIDATE_CACHE_LIMIT") && mapPatchSource.includes("patchCandidateCacheKey") && mapPatchSource.includes("cache.size > PATCH_CANDIDATE_CACHE_LIMIT"), "patch candidate cache is bounded and keyed"); + assert(mapPatchSource.includes("getPatchAlphaCache") && mapPatchSource.includes("getPatchSourceIndexCache"), "patch generation caches alpha and source-index grids for merge work"); + assert(mapPatchSource.includes("const searchRect = expandRect(rect, 16, world)") && mapPatchSource.includes("connectorAttempts"), "patch connector pathfinding uses bounded attempts and a shared search rect"); + assert(worldMapSource.includes("shiftSelectionShape") && worldMapSource.includes("selectionShape = shiftSelectionShape"), "world expansion shifts stored lasso patch polygons"); + assert(municipalSource.includes("reconcileMunicipalMetadata") && mapOutputSource.includes("reconcileMunicipalMetadata") && mapPatchSource.includes("reconcileMunicipalMetadata"), "municipal metadata is reconciled in output and patch repair"); + assert(appSource.includes("mappedPref === id") && !appSource.includes("return nearestNamedAdminCenter(map, cellIndex, maxDistance, null)"), "tooltip municipal fallback requires exact coherent ids"); + assert(mapPatchSource.includes("signedDist") && mapPatchSource.includes("patchBand"), "lasso patch alpha uses a feathered signed seam band"); + assert(mapPatchSource.includes("repairDiscreteSeamOwnership") && mapPatchSource.includes("chooseSeamOwnerValue"), "patch admin and prefecture seams use ownership repair"); + assert(mapPatchSource.includes("featherTerrainSeam") && mapPatchSource.includes("terrainFeatherCells"), "patch terrain transition bands are feather-smoothed"); + assert(mapPatchSource.includes("strongOnly") && mapPatchSource.includes("patchAlpha(x, y, rects, seed)"), "patch water topology avoids weak low-alpha seam flips"); + assert(!municipalSource.includes("Municipality ${id + 1}") && !municipalSource.includes("Prefecture ${id + 1}") && municipalSource.includes("自治${id + 1}") && municipalSource.includes("県域${id + 1}"), "fallback municipal and prefecture metadata avoids generic English labels"); assert(map.elevation.length === size, "elevation length matches map size"); assert(map.sea.length === size, "sea length matches map size"); @@ -777,6 +797,14 @@ try { assert(map.externalGateways.length > 0, "external gateways exist"); assert(map.minorRoads.length > 0, "minor roads exist"); assert(map.adminCenters.length >= 18, "municipality center count is sufficiently large"); + const activeMunicipalityIds = new Set([...map.adminId].filter((id, i) => id >= 0 && !map.sea[i])); + const centerIds = new Set(map.adminCenters.map((center) => center.adminId ?? center.municipalityId ?? center.adminNumericId).filter((id) => Number.isFinite(id))); + assert(activeMunicipalityIds.size === map.adminCenters.length && [...activeMunicipalityIds].every((id) => centerIds.has(id)), "every active municipality has exactly one municipal center"); + assert(map.adminCenters.every((center) => activeMunicipalityIds.has(center.adminId ?? center.municipalityId ?? center.adminNumericId)), "municipal centers do not point to inactive municipalities"); + assert([...activeMunicipalityIds].every((id) => map.municipalityToPrefectureId?.[id] >= 0), "every active municipality maps to a prefecture"); + const activePrefectureIds = new Set([...map.prefectureRegionId].filter((id, i) => id >= 0 && !map.sea[i])); + const prefMetadataIds = new Set((map.prefectureRegions || []).map((region) => region.id)); + assert([...activePrefectureIds].every((id) => prefMetadataIds.has(id)), "every active prefecture id has metadata"); assert(adminMetrics.municipalityCount >= 10, "terrain snapping preserves a reasonable municipality count"); assert(adminMetrics.centerValidRatio >= 0.95, "municipality centers remain on valid assigned land cells"); assert(adminMetrics.maxComponents <= 4, "municipal topology repair prevents excessive disconnected fragments"); diff --git a/worldMap.js b/worldMap.js index 4c5fc05..04ac910 100644 --- a/worldMap.js +++ b/worldMap.js @@ -125,24 +125,34 @@ function expandRectByOffset(rect, dx, dy) { return { ...rect, x0: rect.x0 + dx, y0: rect.y0 + dy, x1: rect.x1 + dx, y1: rect.y1 + dy }; } +function shiftSelectionShape(shape, dx, dy) { + if (!shape?.polygon) return shape; + return { + ...shape, + x0: Number.isFinite(shape.x0) ? shape.x0 + dx : shape.x0, + y0: Number.isFinite(shape.y0) ? shape.y0 + dy : shape.y0, + x1: Number.isFinite(shape.x1) ? shape.x1 + dx : shape.x1, + y1: Number.isFinite(shape.y1) ? shape.y1 + dy : shape.y1, + polygon: shape.polygon.map((p) => ({ ...p, x: p.x + dx, y: p.y + dy })), + }; +} + +function shiftPatchMetadata(item, dx, dy) { + const out = expandRectByOffset(item, dx, dy); + for (const sub of ["coreRect", "writeRect", "repairRect", "contextRect", "blendRect", "transportReachRect"]) { + if (out?.[sub]) out[sub] = expandRectByOffset(out[sub], dx, dy); + } + if (out?.selectionShape) out.selectionShape = shiftSelectionShape(out.selectionShape, dx, dy); + return out; +} + function shiftRectCollections(world, dx, dy) { if (!dx && !dy) return; for (const key of ["generatedRects", "invalidatedRects", "humanPatchHistory"]) { if (!Array.isArray(world[key])) continue; - world[key] = world[key].map((item) => { - const out = expandRectByOffset(item, dx, dy); - for (const sub of ["coreRect", "writeRect", "repairRect", "contextRect", "blendRect", "transportReachRect"]) { - if (out?.[sub]) out[sub] = expandRectByOffset(out[sub], dx, dy); - } - return out; - }); - } - if (world.lastPatchResult) { - world.lastPatchResult = { ...world.lastPatchResult }; - for (const sub of ["coreRect", "writeRect", "repairRect", "contextRect", "blendRect", "transportReachRect"]) { - if (world.lastPatchResult[sub]) world.lastPatchResult[sub] = expandRectByOffset(world.lastPatchResult[sub], dx, dy); - } + world[key] = world[key].map((item) => shiftPatchMetadata(item, dx, dy)); } + if (world.lastPatchResult) world.lastPatchResult = shiftPatchMetadata(world.lastPatchResult, dx, dy); } export function expandWorldMap(world, margins = {}) {