import { drawMap, drawMapCooperative } from "./renderer.js"; import { landuseLabel } from "./landuseCodes.js"; import { CELL_SIZE, MAP_H, MAP_W, worldIndexOf } from "./mapUtils.js"; import { clampCameraToWorld, createInitialCamera, createWorldMap, ensureWorldPaddingForCamera } from "./worldMap.js"; import { getViewportMap } from "./worldViewport.js"; import { PATCH_MIN_AREA, PATCH_MIN_HEIGHT, PATCH_MIN_WIDTH, buildLargeExpansionTiles, buildPatchRects, validatePatchRect } from "./mapPatch.js"; import { hashCommittedWorldAsync, materializeCommittedWorldDeltaCooperative } from "./committedWorldDelta.js"; const modes = [ ["all", "All"], ["terrain", "Terrain"], ["modern", "Modern"], ["history", "Premodern"], ["landuse", "Land Use"], ["admin", "Admin"], ]; const state = { seedText: "114514", generationType: "auto", mode: "all", toolMode: "pan", showFeatures: true, showLabels: true, showSeamDiagnostics: true, map: null, world: null, camera: { x: 0, y: 0 }, viewportMap: null, viewWidth: MAP_W, viewHeight: MAP_H, hoverEntities: [], selectionRect: null, patchVariant: 0, zoom: 1, lastPatchResult: null, pendingPatch: null, patchBusy: false, patchBusyVariant: null, patchStatusMessage: "", fullGenerationBusy: false, selectionRevision: 0, committedRevision: 0, renderRevision: 0, generationRuns: [], patchRuns: [], interactionRuns: [], diagnosticLog: [], diagnostics: { worldExpansionCount: 0, lastWorldExpansion: null, lastViewport: null, lastFeatureCounts: null, lastWorkerUsed: null, lastWorkerFallbackReason: null, lastPatchWorkerKind: null, lastGenerationWorkerUsed: null, lastGenerationWorkerFallbackReason: null, }, }; const canvas = document.getElementById("mapCanvas"); const canvasShell = document.querySelector(".canvas-shell"); const seedInput = document.getElementById("seed"); const generationTypeInput = document.getElementById("generationType"); const patchTerrainTypeInput = document.getElementById("patchTerrainType"); const patchModeInput = document.getElementById("patchMode"); const patchVariantInput = document.getElementById("patchVariant"); const generatePatchButton = document.getElementById("generatePatch"); const alternativePatchButton = document.getElementById("alternativePatch"); const patchStatusEl = document.getElementById("patchStatus"); const generateMapButton = document.getElementById("generateMap"); const randomSeedButton = document.getElementById("randomSeed"); const toolPanButton = document.getElementById("toolPan"); const toolPatchButton = document.getElementById("toolPatch"); const toolHintEl = document.getElementById("toolHint"); const zoomInButton = document.getElementById("zoomIn"); const zoomOutButton = document.getElementById("zoomOut"); const zoomResetButton = document.getElementById("zoomReset"); const centerMapButton = document.getElementById("centerMap"); const applyPatchButton = document.getElementById("applyPatch"); const discardPatchButton = document.getElementById("discardPatch"); const clearPatchSelectionButton = document.getElementById("clearPatchSelection"); const cancelPatchButton = document.getElementById("cancelPatchGeneration"); const showFeaturesInput = document.getElementById("showFeatures"); const showLabelsInput = document.getElementById("showLabels"); const showSeamDiagnosticsInput = document.getElementById("showSeamDiagnostics"); const modeGrid = document.getElementById("modeGrid"); const mainLegendGrid = document.getElementById("mainLegendGrid"); const statsEl = document.getElementById("stats"); const advancedGenerationStatsEl = document.getElementById("advancedGenerationStats"); const advancedGenerationHistoryEl = document.getElementById("advancedGenerationHistory"); const advancedPatchStatsEl = document.getElementById("advancedPatchStats"); const advancedPatchHistoryEl = document.getElementById("advancedPatchHistory"); const advancedInteractionStatsEl = document.getElementById("advancedInteractionStats"); const advancedInteractionHistoryEl = document.getElementById("advancedInteractionHistory"); const advancedViewportDiagnosticsEl = document.getElementById("advancedViewportDiagnostics"); const advancedFeatureCountsEl = document.getElementById("advancedFeatureCounts"); const advancedPatchDiagnosticsEl = document.getElementById("advancedPatchDiagnostics"); const advancedSeamDiagnosticsEl = document.getElementById("advancedSeamDiagnostics"); const advancedWorkerDiagnosticsEl = document.getElementById("advancedWorkerDiagnostics"); const advancedWorldDiagnosticsEl = document.getElementById("advancedWorldDiagnostics"); const advancedWarningHistoryEl = document.getElementById("advancedWarningHistory"); const copyImportantDataButton = document.getElementById("copyImportantData"); const copyDebugStatusEl = document.getElementById("copyDebugStatus"); const tooltipEl = document.getElementById("mapTooltip"); const selectionSvgEl = document.getElementById("mapSelectionSvg"); const selectionEl = document.getElementById("mapSelection"); const progressEl = document.getElementById("generationProgress"); const progressStageEl = document.getElementById("generationProgressStage"); const progressTimingsEl = document.getElementById("generationProgressTimings"); let generationStartedAt = 0; let generationCurrentStage = ""; let generationTimer = null; let progressRevision = 0; let progressHideTimer = null; let zoomRedrawRaf = null; let zoomSettledTimer = null; let zoomVisualState = null; let zoomLatencyStartedAt = null; let patchWorker = null; let patchJobSeq = 0; let patchRequestSeq = 0; let activePatchCancel = null; let activePatchOperation = null; let patchGeometryPreviewCache = null; let patchWorkerEpoch = 0; let patchWorkerConstructorCount = 0; let patchSearchSeries = { contextId: null, consumedCandidateIds: new Set(), nextVariant: null }; const PATCH_SEARCH_BATCH_SIZE = 3; const PATCH_SEARCH_DEFAULT_LIMIT = 12; const PATCH_SEARCH_LARGE_LIMIT = 12; const PATCH_WORKER_STALL_MS = 120_000; const PATCH_NON_COOPERATIVE_DEADLINE_MS = 300_000; let generationWorker = null; let generationJobSeq = 0; let generationRequestSeq = 0; const generationPendingJobs = new Map(); const dragState = { mode: null, pointerId: null, startClientX: 0, startClientY: 0, startCameraX: 0, startCameraY: 0, lastClientX: 0, lastClientY: 0, panRemainderX: 0, panRemainderY: 0, selectStart: null, selectEnd: null, selectPath: null, pendingCamera: null, panRaf: null, panLatencyStartedAt: null, }; function displayWorld() { return state.pendingPatch?.world || state.world; } function displaySourceMap() { return displayWorld()?.sourceMap || state.map; } function activeMap() { return state.viewportMap || displaySourceMap(); } function clampZoom(value) { const parsed = Number(value); if (!Number.isFinite(parsed)) return 1; return Math.min(Math.max(parsed, 0.55), 2.8); } function viewportSizeForZoom(zoom = state.zoom) { const z = clampZoom(zoom || 1); return { width: Math.max(1, Math.ceil(MAP_W / z)), height: Math.max(1, Math.ceil(MAP_H / z)), }; } function clampCameraForView(camera, size = viewportSizeForZoom(state.zoom), world = displayWorld()) { return clampCameraToWorld(camera, world, size?.width || MAP_W, size?.height || MAP_H); } function syncViewportSize() { const size = viewportSizeForZoom(state.zoom); state.viewWidth = size.width; state.viewHeight = size.height; return size; } function canvasInteractionRect() { return zoomVisualState?.baseRect || canvas.getBoundingClientRect(); } function mapCellScreenSize(map = activeMap()) { const rect = canvasInteractionRect(); const mapWidth = Math.max(1, map?.width || state.viewWidth || MAP_W); return rect.width ? rect.width / mapWidth : CELL_SIZE * clampZoom(state.zoom || 1); } function applyCanvasZoom() { if (!canvas) return; state.zoom = clampZoom(state.zoom || 1); const baseWidth = MAP_W * CELL_SIZE; const baseHeight = MAP_H * CELL_SIZE; const shellRect = canvasShell?.getBoundingClientRect(); const availableWidth = Math.max(160, (shellRect?.width || baseWidth) - 24); const availableHeight = Math.max(160, (shellRect?.height || baseHeight) - 24); const displayScale = Math.max(0.18, Math.min(availableWidth / baseWidth, availableHeight / baseHeight)); canvas.style.width = `${Math.round(baseWidth * displayScale)}px`; canvas.style.height = `${Math.round(baseHeight * displayScale)}px`; syncSelectionSvgToCanvas(); updateSelectionOverlayFromWorldRect(); } function displayedCellSize() { return mapCellScreenSize(); } function screenPointToMapPixel(clientX, clientY, sizeOverride = null) { const rect = canvasInteractionRect(); if (!rect.width || !rect.height) return null; const map = activeMap(); const viewWidth = Math.max(1, sizeOverride?.width || map?.width || state.viewWidth || MAP_W); const viewHeight = Math.max(1, sizeOverride?.height || map?.height || state.viewHeight || MAP_H); const canvasX = (clientX - rect.left) * ((canvas.width || MAP_W * CELL_SIZE) / rect.width); const canvasY = (clientY - rect.top) * ((canvas.height || MAP_H * CELL_SIZE) / rect.height); const cellX = canvasX / Math.max(1e-6, (canvas.width || MAP_W * CELL_SIZE) / viewWidth); const cellY = canvasY / Math.max(1e-6, (canvas.height || MAP_H * CELL_SIZE) / viewHeight); return { x: cellX * CELL_SIZE, y: cellY * CELL_SIZE }; } function mapPixelToScreenPoint(px, py) { const rect = canvasInteractionRect(); const map = activeMap(); const viewWidth = Math.max(1, map?.width || state.viewWidth || MAP_W); const viewHeight = Math.max(1, map?.height || state.viewHeight || MAP_H); const canvasX = (px / CELL_SIZE) * ((canvas.width || MAP_W * CELL_SIZE) / viewWidth); const canvasY = (py / CELL_SIZE) * ((canvas.height || MAP_H * CELL_SIZE) / viewHeight); return { x: canvas.offsetLeft + canvasX * (rect.width / Math.max(1, canvas.width || MAP_W * CELL_SIZE)), y: canvas.offsetTop + canvasY * (rect.height / Math.max(1, canvas.height || MAP_H * CELL_SIZE)), }; } function mapClientToCell(event, sizeOverride = null) { const map = activeMap(); if (!map && !sizeOverride) return null; const p = screenPointToMapPixel(event.clientX, event.clientY, sizeOverride); if (!p) return null; return { x: Math.floor(p.x / CELL_SIZE), y: Math.floor(p.y / CELL_SIZE), }; } function viewportCellToWorldCell(cell) { if (!cell || !state.camera) return null; return { x: Math.round(state.camera.x || 0) + cell.x, y: Math.round(state.camera.y || 0) + cell.y, }; } function clampCanvasPoint(event) { const rect = canvasInteractionRect(); return { x: Math.min(Math.max(event.clientX - rect.left, 0), rect.width), y: Math.min(Math.max(event.clientY - rect.top, 0), rect.height), }; } function screenPointToWorldCell(point) { const map = activeMap(); if (!map || !point) return null; const rect = canvasInteractionRect(); if (!rect.width || !rect.height) return null; const viewWidth = Math.max(1, map.width || state.viewWidth || MAP_W); const viewHeight = Math.max(1, map.height || state.viewHeight || MAP_H); const canvasX = point.x * ((canvas.width || MAP_W * CELL_SIZE) / rect.width); const canvasY = point.y * ((canvas.height || MAP_H * CELL_SIZE) / rect.height); const localX = Math.floor((canvasX / Math.max(1e-6, (canvas.width || MAP_W * CELL_SIZE) / viewWidth))); const localY = Math.floor((canvasY / Math.max(1e-6, (canvas.height || MAP_H * CELL_SIZE) / viewHeight))); const cameraX = Math.round(state.camera?.x || 0); const cameraY = Math.round(state.camera?.y || 0); return { x: cameraX + Math.min(Math.max(localX, 0), Math.max(0, map.width - 1)), y: cameraY + Math.min(Math.max(localY, 0), Math.max(0, map.height - 1)), }; } function worldCellToOverlayPoint(point) { const cameraX = Math.round(state.camera?.x || 0); const cameraY = Math.round(state.camera?.y || 0); const screen = mapPixelToScreenPoint((point.x - cameraX + 0.5) * CELL_SIZE, (point.y - cameraY + 0.5) * CELL_SIZE); return { x: screen.x - canvas.offsetLeft, y: screen.y - canvas.offsetTop }; } function simplifySelectionPath(points) { const out = []; // Pointer sampling already suppresses sub-3 px jitter. A second 6 px filter // turned curved lassos into long chords at low zoom, and those chords leaked // into terrain/administrative seams. Keep a small adaptive tolerance instead. const input = points || []; let pathLength = 0; for (let i = 1; i < input.length; i++) pathLength += Math.hypot(input[i].x - input[i - 1].x, input[i].y - input[i - 1].y); // Bound polygon complexity before it reaches alpha/coverage geometry. The // adaptive spacing preserves the full drawn contour while preventing a long // gesture from turning every affected-cell operation into A x thousands. const minDistance = Math.max(1.5, Math.min(3, displayedCellSize() * 0.35), pathLength / 256); for (const p of input) { if (!out.length || Math.hypot(out[out.length - 1].x - p.x, out[out.length - 1].y - p.y) >= minDistance) out.push(p); } return out; } function polygonArea(points) { let area = 0; for (let i = 0; i < points.length; i++) { const a = points[i]; const b = points[(i + 1) % points.length]; area += a.x * b.y - b.x * a.y; } return Math.abs(area) * 0.5; } function selectionPathToShape(points) { const simplified = simplifySelectionPath(points || []); if (simplified.length < 3) return null; const polygon = []; for (const point of simplified.map(screenPointToWorldCell).filter(Boolean)) { const last = polygon[polygon.length - 1]; if (!last || last.x !== point.x || last.y !== point.y) polygon.push(point); } if (polygon.length > 2 && polygon[0].x === polygon[polygon.length - 1].x && polygon[0].y === polygon[polygon.length - 1].y) polygon.pop(); if (polygon.length < 3) return null; const xs = polygon.map((p) => p.x); const ys = polygon.map((p) => p.y); return { kind: "lasso", polygon, x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs) + 1, y1: Math.max(...ys) + 1, areaCells: Math.max(1, Math.round(polygonArea(polygon))), }; } function syncSelectionSvgToCanvas() { if (!selectionSvgEl || !canvas) return null; const rect = canvas.getBoundingClientRect(); const width = Math.max(1, rect.width || canvas.clientWidth || canvas.width || 1); const height = Math.max(1, rect.height || canvas.clientHeight || canvas.height || 1); selectionSvgEl.style.left = `${canvas.offsetLeft}px`; selectionSvgEl.style.top = `${canvas.offsetTop}px`; selectionSvgEl.style.width = `${width}px`; selectionSvgEl.style.height = `${height}px`; selectionSvgEl.setAttribute("width", String(width)); selectionSvgEl.setAttribute("height", String(height)); selectionSvgEl.setAttribute("viewBox", `0 0 ${width} ${height}`); return { width, height }; } function drawSelectionSvg(points, invalid = false) { if (!selectionSvgEl) return; const bounds = syncSelectionSvgToCanvas(); if (!bounds || !points || points.length < 3) { selectionSvgEl.style.display = "none"; selectionSvgEl.innerHTML = ""; return; } const pts = points.map((p) => { const x = Math.min(Math.max(p.x, 0), bounds.width); const y = Math.min(Math.max(p.y, 0), bounds.height); return `${x},${y}`; }).join(" "); selectionSvgEl.innerHTML = ``; selectionSvgEl.style.display = "block"; selectionSvgEl.classList.toggle("invalid", !!invalid); } function hideSelectionSvg() { if (!selectionSvgEl) return; selectionSvgEl.style.display = "none"; selectionSvgEl.innerHTML = ""; selectionSvgEl.classList.remove("invalid"); } function updateSelectionOverlay() { if (!dragState.selectPath?.length) return; const liveShape = selectionPathToShape(dragState.selectPath); const validation = validatePatchRect(liveShape, state.world); drawSelectionSvg(dragState.selectPath, !validation.ok); if (selectionEl) selectionEl.style.display = "none"; if (generatePatchButton) generatePatchButton.disabled = true; if (alternativePatchButton) alternativePatchButton.disabled = true; if (patchStatusEl) { const current = validation.rect || liveShape; patchStatusEl.textContent = validation.ok ? `Selected: ${formatRectSize(validation.rect)}. Release to confirm patch selection.` : `${validation.reason} Current: ${formatRectSize(current)}.`; patchStatusEl.classList.toggle("invalid", !validation.ok); } } function updateSelectionOverlayFromWorldRect() { if (!state.selectionRect || !state.camera || !activeMap()) return; const rect = canvas.getBoundingClientRect(); if (!rect.width || !rect.height) return; if (Array.isArray(state.selectionRect.polygon) && state.selectionRect.polygon.length >= 3) { const points = state.selectionRect.polygon.map(worldCellToOverlayPoint) .map((p) => ({ x: Math.min(Math.max(p.x, 0), rect.width), y: Math.min(Math.max(p.y, 0), rect.height) })); drawSelectionSvg(points, !validatePatchRect(state.selectionRect, state.world).ok); if (selectionEl) selectionEl.style.display = "none"; return; } const cameraX = Math.round(state.camera.x || 0); const cameraY = Math.round(state.camera.y || 0); const p0 = mapPixelToScreenPoint((state.selectionRect.x0 - cameraX) * CELL_SIZE, (state.selectionRect.y0 - cameraY) * CELL_SIZE); const p1 = mapPixelToScreenPoint((state.selectionRect.x1 - cameraX) * CELL_SIZE, (state.selectionRect.y1 - cameraY) * CELL_SIZE); const vx0 = p0.x - canvas.offsetLeft; const vy0 = p0.y - canvas.offsetTop; const vx1 = p1.x - canvas.offsetLeft; const vy1 = p1.y - canvas.offsetTop; const x0 = Math.min(Math.max(Math.min(vx0, vx1), 0), rect.width); const y0 = Math.min(Math.max(Math.min(vy0, vy1), 0), rect.height); const x1 = Math.min(Math.max(Math.max(vx0, vx1), 0), rect.width); const y1 = Math.min(Math.max(Math.max(vy0, vy1), 0), rect.height); if (x1 - x0 < 1 || y1 - y0 < 1) { selectionEl.style.display = "none"; return; } hideSelectionSvg(); selectionEl.style.display = "block"; selectionEl.style.left = `${canvas.offsetLeft + x0}px`; selectionEl.style.top = `${canvas.offsetTop + y0}px`; selectionEl.style.width = `${Math.max(1, x1 - x0)}px`; selectionEl.style.height = `${Math.max(1, y1 - y0)}px`; const validation = validatePatchRect(state.selectionRect, state.world); selectionEl.classList.toggle("invalid", !validation.ok); } function formatRectSize(rect) { if (!rect) return "-"; const w = Math.max(0, rect.x1 - rect.x0); const h = Math.max(0, rect.y1 - rect.y0); const area = Math.max(0, rect.areaCells || (w * h)); return `${w} x ${h} cells / ${area.toLocaleString()} cells`; } function normalizePatchVariant(value) { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) ? Math.max(0, parsed) >>> 0 : 0; } function setPatchVariant(value, { update = true } = {}) { state.patchVariant = normalizePatchVariant(value); if (patchVariantInput && patchVariantInput.value !== String(state.patchVariant)) { patchVariantInput.value = String(state.patchVariant); } if (update) updatePatchControls(); return state.patchVariant; } function readPatchVariant() { return setPatchVariant(patchVariantInput?.value ?? state.patchVariant, { update: false }); } function resetPatchVariant({ update = true } = {}) { resetPatchSearchSeries(); return setPatchVariant(0, { update }); } function replaceSelectionRect(rect) { state.selectionRect = rect || null; state.selectionRevision = (state.selectionRevision || 0) + 1; resetPatchSearchSeries(); return state.selectionRect; } function selectionSignature(rect) { if (!rect) return "none"; const polygon = Array.isArray(rect.polygon) ? rect.polygon.map((point) => `${Math.round(point.x)},${Math.round(point.y)}`).join(";") : ""; return `${rect.kind || "rect"}:${rect.x0},${rect.y0},${rect.x1},${rect.y1}:${polygon}`; } function resetPatchSearchSeries() { patchSearchSeries = { contextId: null, consumedCandidateIds: new Set(), nextVariant: null }; } function advanceCommittedRevision({ preservePatchWorker = false } = {}) { state.committedRevision = (state.committedRevision || 0) + 1; resetPatchSearchSeries(); // A persistent mirror is valid only for the exact committed revision. Apply, // full regeneration, and backing-world resize replace/reshape that content; // terminate the idle Worker so it cannot retain the old full world alongside // the new committed map. The next patch rebuilds one fresh mirror. if (patchWorker && !state.patchBusy && !preservePatchWorker) { patchWorker.terminate?.(); patchWorker = null; } return state.committedRevision; } function acknowledgePatchApply(worker, pendingPatch, committedRevision) { const applyToken = pendingPatch?.result?.applyToken; if (!worker || !applyToken) return Promise.resolve(false); const ackId = `apply-${committedRevision}-${Date.now()}-${Math.random().toString(16).slice(2)}`; return new Promise((resolve) => { let settled = false; const finish = (ok) => { if (settled) return; settled = true; window.clearTimeout(timeout); worker.removeEventListener("message", onMessage); if (!ok && patchWorker === worker) { patchWorker = null; worker.terminate?.(); } resolve(ok); }; const onMessage = (event) => { const data = event.data || {}; if (data.type !== "patch-apply-ack-result" || data.ackId !== ackId) return; if (data.ok && Number(data.mirrorCommittedRevision) === committedRevision && data.mirrorHash === pendingPatch.result?.acceptedWorldHash) { worker.__mirrorCommittedRevision = committedRevision; worker.__mirrorBaseWorld = state.world; recordDiagnosticLog("info", "Patch Apply mirror acknowledged", `Worker mirror advanced to committed revision ${committedRevision}.`, { committedRevision, applyToken }); finish(true); } else { recordDiagnosticLog("warning", "Patch Apply mirror rejected", data.error || "Worker mirror ACK mismatch.", { committedRevision, applyToken }); finish(false); } }; const timeout = window.setTimeout(() => { recordDiagnosticLog("warning", "Patch Apply mirror timed out", "The committed map is safe, but the stale Worker mirror was discarded.", { committedRevision, applyToken }); finish(false); }, 30_000); worker.addEventListener("message", onMessage); try { worker.postMessage({ type: "patch-apply-ack", ackId, applyToken, baseCommittedRevision: pendingPatch.baseCommittedRevision, committedRevision, }); } catch (error) { recordDiagnosticLog("warning", "Patch Apply mirror dispatch failed", error?.message || String(error), { committedRevision, applyToken }); finish(false); } }); } function isPatchOperationCurrent(operation) { return !!operation && activePatchOperation === operation && state.patchBusy && operation.requestId === patchRequestSeq && operation.baseWorld === state.world && operation.committedRevision === state.committedRevision && operation.selectionRevision === state.selectionRevision && operation.selectionSignature === selectionSignature(state.selectionRect); } function previewPatchRects(validation) { if (!validation?.ok || !state.world) return null; const patchMode = patchModeInput?.value || "auto"; const key = [ state.selectionRevision, selectionSignature(validation.rect), patchMode, state.world.width, state.world.height, state.world.patchGenerationSerial || 0, ].join("|"); if (patchGeometryPreviewCache?.key === key) return patchGeometryPreviewCache.rects; const rects = buildPatchRects(validation.rect, state.world, { patchMode, _geometryOnly: true }); patchGeometryPreviewCache = { key, rects }; return rects; } function updatePatchControls() { const variant = readPatchVariant(); const validation = validatePatchRect(state.selectionRect, state.world); const hasValidSelection = !!validation.ok; const hasPreview = !!state.pendingPatch; const patchBusy = !!state.patchBusy; const fullBusy = !!state.fullGenerationBusy; const busy = patchBusy || fullBusy; if (generatePatchButton) generatePatchButton.disabled = !hasValidSelection || busy; if (alternativePatchButton) alternativePatchButton.disabled = !hasValidSelection || busy; if (applyPatchButton) applyPatchButton.disabled = !hasPreview || busy; if (discardPatchButton) discardPatchButton.disabled = !hasPreview || busy; if (clearPatchSelectionButton) clearPatchSelectionButton.disabled = !state.selectionRect || busy; if (cancelPatchButton) cancelPatchButton.disabled = !patchBusy; if (patchVariantInput) patchVariantInput.disabled = busy; if (patchTerrainTypeInput) patchTerrainTypeInput.disabled = busy; if (patchModeInput) patchModeInput.disabled = busy; if (showSeamDiagnosticsInput) showSeamDiagnosticsInput.disabled = busy; if (generateMapButton) generateMapButton.disabled = busy; if (randomSeedButton) randomSeedButton.disabled = busy; if (seedInput) seedInput.disabled = busy; if (generationTypeInput) generationTypeInput.disabled = busy; if (!patchStatusEl) return; if (!state.selectionRect) { patchStatusEl.textContent = state.toolMode === "patch" ? `Right-drag a freeform patch area. Minimum: ${PATCH_MIN_WIDTH} x ${PATCH_MIN_HEIGHT} cells and ${PATCH_MIN_AREA.toLocaleString()} cells.` : "Right-drag on the map to draw a patch area."; patchStatusEl.classList.toggle("invalid", false); return; } if (!validation.ok) { patchStatusEl.textContent = `${validation.reason} Current: ${formatRectSize(validation.rect || state.selectionRect)}.`; patchStatusEl.classList.toggle("invalid", true); return; } const rects = previewPatchRects(validation); const shownPatch = state.pendingPatch?.result || state.lastPatchResult; const candidateModeText = shownPatch?.patchGenerationMode || ""; const delta = state.pendingPatch?.previewDelta || shownPatch?.previewDelta || null; const deltaText = delta ? ` Changed ${Number(delta.changedCells || 0).toLocaleString()} cells (${Number(delta.terrainChangedCells || 0).toLocaleString()} terrain / ${Number(delta.adminChangedCells || 0).toLocaleString()} admin).` : ""; const previewText = state.pendingPatch ? ` Preview ready: ${shownPatch?.label || "candidate"}, variant ${shownPatch?.variant ?? variant}${candidateModeText ? `, ${candidateModeText}` : ""}.${deltaText} Use Apply Preview or Discard.` : shownPatch ? ` Last applied: ${shownPatch.label || "patch"}, variant ${shownPatch.variant ?? "-"}${candidateModeText ? `, ${candidateModeText}` : ""}.` : ""; const busyText = state.patchBusy ? ` Generating variant ${state.patchBusyVariant ?? variant}; the currently displayed map will be replaced only after a verified preview is rendered.` : state.fullGenerationBusy ? " Full-map generation is running; patch actions are temporarily locked." : ""; const statusText = state.patchStatusMessage ? ` ${state.patchStatusMessage}` : ""; patchStatusEl.textContent = `Selection: ${formatRectSize(validation.rect)}. Core ${formatRectSize(rects.coreRect)} / write ${formatRectSize(rects.writeRect)}.${previewText}${busyText}${statusText}`; patchStatusEl.classList.toggle("invalid", false); } function setToolMode(mode) { state.toolMode = mode === "patch" ? "patch" : "pan"; toolPanButton?.classList.toggle("active", state.toolMode === "pan"); toolPatchButton?.classList.toggle("active", state.toolMode === "patch"); toolHintEl?.classList.toggle("hidden", state.toolMode !== "patch"); canvasShell?.classList.toggle("patch-intent", state.toolMode === "patch"); updatePatchControls(); } function setZoomKeepingCenter(nextZoom) { const startedAt = performance.now(); const renderWorld = displayWorld(); if (!renderWorld) return; const oldSize = viewportSizeForZoom(state.zoom); const centerWorld = { x: Math.round((state.camera?.x || 0) + oldSize.width / 2), y: Math.round((state.camera?.y || 0) + oldSize.height / 2), }; state.zoom = clampZoom(nextZoom); const nextSize = syncViewportSize(); state.camera = clampCameraForView({ x: Math.round(centerWorld.x - nextSize.width / 2), y: Math.round(centerWorld.y - nextSize.height / 2), }, nextSize, renderWorld); redraw({ fastTerrain: false, allowWorldExpand: false }); recordInteractionLatency("zoom button", startedAt, { zoom: state.zoom }); } function recenterMap() { const renderWorld = displayWorld(); if (!renderWorld) return; state.camera = createInitialCamera(renderWorld); redraw({ fastTerrain: false, allowWorldExpand: false }); } function clearDragMode() { dragState.mode = null; dragState.pointerId = null; dragState.pendingCamera = null; dragState.panRemainderX = 0; dragState.panRemainderY = 0; if (dragState.panRaf != null) { cancelAnimationFrame(dragState.panRaf); dragState.panRaf = null; } dragState.panLatencyStartedAt = null; canvasShell?.classList.remove("panning", "selecting"); } function schedulePanRedraw(camera) { dragState.pendingCamera = camera; if (!dragState.panLatencyStartedAt) dragState.panLatencyStartedAt = performance.now(); if (dragState.panRaf != null) return; dragState.panRaf = requestAnimationFrame(() => { dragState.panRaf = null; if (!dragState.pendingCamera) return; const next = dragState.pendingCamera; dragState.pendingCamera = null; if (next.x === state.camera.x && next.y === state.camera.y) return; const startedAt = dragState.panLatencyStartedAt || performance.now(); dragState.panLatencyStartedAt = null; state.camera = next; // Do not auto-expand the backing world while a pointer drag is active. // Expansion shifts world coordinates; doing it mid-drag invalidates the // pointer-to-camera baseline and can make the viewport appear to jump. redraw({ fastTerrain: true, allowWorldExpand: false }); recordInteractionLatency("pan", startedAt, { zoom: state.zoom, fast: true }); }); } function commitPendingPatch({ redrawAfter = true } = {}) { if (!state.pendingPatch?.world) return false; const acceptedPatch = state.pendingPatch; const applyWorker = acceptedPatch.worker && acceptedPatch.result?.applyToken ? patchWorker : null; state.world = acceptedPatch.world; // Seam diagnostics are a preview aid. Once the patch is applied, disable the // magenta/red dashed overlay in both the committed source data and UI state so // it cannot remain stuck on the map after the selection overlay disappears. if (state.world?.sourceMap?.patchSeamDiagnostics) { state.world.sourceMap.patchSeamDiagnostics.enabled = false; } state.showSeamDiagnostics = false; if (showSeamDiagnosticsInput) showSeamDiagnosticsInput.checked = false; const committedRevision = advanceCommittedRevision({ preservePatchWorker: !!applyWorker }); state.map = state.world.sourceMap || state.map; state.lastPatchResult = state.pendingPatch.result || state.world.lastPatchResult || state.lastPatchResult; state.pendingPatch = null; state.patchStatusMessage = ""; state.viewportMap = null; if (applyWorker) acknowledgePatchApply(applyWorker, acceptedPatch, committedRevision); if (redrawAfter) { renderStats(displaySourceMap()); redraw({ fastTerrain: true, allowWorldExpand: false }); window.setTimeout(() => redraw({ fastTerrain: false, allowWorldExpand: false }), 80); } return true; } function discardPendingPatch({ redrawAfter = true } = {}) { if (!state.pendingPatch) return false; const discardedToken = state.pendingPatch.result?.applyToken; if (discardedToken && patchWorker) { try { patchWorker.postMessage({ type: "patch-apply-discard", applyToken: discardedToken }); } catch { /* Worker cleanup is best-effort; the next search replaces its pending delta. */ } } state.pendingPatch = null; state.patchStatusMessage = ""; state.viewportMap = null; if (redrawAfter) { renderStats(displaySourceMap()); redraw({ fastTerrain: false, allowWorldExpand: false }); } return true; } function hideSelectionOverlay(options = {}) { const commitPreview = options.commitPreview === true; const discardPreview = options.discardPreview === true || (!commitPreview && options.keepPreview !== true && !!state.pendingPatch); if (commitPreview) commitPendingPatch({ redrawAfter: false }); else if (discardPreview) discardPendingPatch({ redrawAfter: false }); dragState.selectStart = null; dragState.selectEnd = null; dragState.selectPath = null; replaceSelectionRect(null); resetPatchVariant({ update: false }); hideSelectionSvg(); if (selectionEl) selectionEl.style.display = "none"; state.viewportMap = null; renderStats(displaySourceMap()); updatePatchControls(); if (commitPreview || discardPreview) redraw({ fastTerrain: false, allowWorldExpand: false }); } function selectionPixelsToCells(start, end) { const a = screenPointToWorldCell(start); const b = screenPointToWorldCell(end); if (!a || !b) return null; return { x0: Math.min(a.x, b.x), y0: Math.min(a.y, b.y), x1: Math.max(a.x, b.x) + 1, y1: Math.max(a.y, b.y) + 1, }; } function selectionPixelsToShape(start, end, path = null) { if (Array.isArray(path) && path.length >= 3) return selectionPathToShape(path); return selectionPixelsToCells(start, end); } function handleMapPointerDown(event) { if (!state.world || !canvasShell) return; if (event.button !== 0 && event.button !== 2) return; if (event.button === 2 && (state.patchBusy || state.fullGenerationBusy)) return; dragState.pointerId = event.pointerId; dragState.startClientX = event.clientX; dragState.startClientY = event.clientY; dragState.startCameraX = state.camera.x; dragState.startCameraY = state.camera.y; dragState.lastClientX = event.clientX; dragState.lastClientY = event.clientY; dragState.panRemainderX = 0; dragState.panRemainderY = 0; tooltipEl?.classList.remove("visible"); if (event.button === 0) { dragState.mode = "pan"; canvasShell.classList.add("panning"); } else { if (state.toolMode !== "patch") setToolMode("patch"); hideSelectionSvg(); if (selectionEl) selectionEl.style.display = "none"; dragState.mode = "select"; dragState.selectStart = clampCanvasPoint(event); dragState.selectEnd = dragState.selectStart; dragState.selectPath = [dragState.selectStart]; canvasShell.classList.add("selecting"); updateSelectionOverlay(); } canvas.setPointerCapture?.(event.pointerId); event.preventDefault(); } function handleMapPointerMove(event) { if (!dragState.mode || dragState.pointerId !== event.pointerId || !canvasShell) return; tooltipEl?.classList.remove("visible"); if (dragState.mode === "pan") { const rect = canvas.getBoundingClientRect(); const maxDeltaX = Math.max(320, rect.width * 0.72); const maxDeltaY = Math.max(240, rect.height * 0.72); const rawDx = event.clientX - dragState.lastClientX; const rawDy = event.clientY - dragState.lastClientY; dragState.lastClientX = event.clientX; dragState.lastClientY = event.clientY; // Pointer capture can occasionally deliver a stale/outlier coordinate after // a tab switch, resize, context-menu gesture, or OS-level event hiccup. A // single implausibly large delta would otherwise become a large camera jump. if (Math.abs(rawDx) <= maxDeltaX && Math.abs(rawDy) <= maxDeltaY) { const cellSize = Math.max(1, displayedCellSize()); const totalX = dragState.panRemainderX + rawDx / cellSize; const totalY = dragState.panRemainderY + rawDy / cellSize; const dxCells = totalX < 0 ? Math.ceil(totalX) : Math.floor(totalX); const dyCells = totalY < 0 ? Math.ceil(totalY) : Math.floor(totalY); dragState.panRemainderX = totalX - dxCells; dragState.panRemainderY = totalY - dyCells; if (dxCells || dyCells) { const base = dragState.pendingCamera || state.camera; const nextCamera = clampCameraForView({ x: base.x - dxCells, y: base.y - dyCells, }, viewportSizeForZoom(state.zoom)); schedulePanRedraw(nextCamera); } } } else if (dragState.mode === "select") { dragState.selectEnd = clampCanvasPoint(event); if (!dragState.selectPath || Math.hypot(dragState.selectEnd.x - dragState.selectPath[dragState.selectPath.length - 1].x, dragState.selectEnd.y - dragState.selectPath[dragState.selectPath.length - 1].y) >= 3) { (dragState.selectPath || (dragState.selectPath = [])).push(dragState.selectEnd); } updateSelectionOverlay(); } event.preventDefault(); } function handleMapPointerUp(event) { if (dragState.pointerId !== event.pointerId) return; const wasPanning = dragState.mode === "pan"; const wasCancelled = event.type === "pointercancel"; if (wasCancelled) { canvas.releasePointerCapture?.(event.pointerId); clearDragMode(); if (state.selectionRect) updateSelectionOverlayFromWorldRect(); else hideSelectionSvg(); updatePatchControls(); return; } if (dragState.mode === "select") { dragState.selectEnd = clampCanvasPoint(event); if (!dragState.selectPath || dragState.selectPath.length < 2) dragState.selectPath = [dragState.selectStart, dragState.selectEnd]; else dragState.selectPath.push(dragState.selectEnd); const shape = selectionPixelsToShape(dragState.selectStart, dragState.selectEnd, dragState.selectPath); const width = Math.abs(dragState.selectEnd.x - dragState.selectStart.x); const height = Math.abs(dragState.selectEnd.y - dragState.selectStart.y); if (shape && (dragState.selectPath.length >= 3 || (width >= 4 && height >= 4))) { if (state.pendingPatch) discardPendingPatch({ redrawAfter: true }); replaceSelectionRect(shape); state.lastPatchResult = null; state.patchStatusMessage = ""; resetPatchVariant({ update: false }); updateSelectionOverlayFromWorldRect(); updatePatchControls(); } else { if (state.selectionRect) updateSelectionOverlayFromWorldRect(); else hideSelectionSvg(); updatePatchControls(); } } canvas.releasePointerCapture?.(event.pointerId); if (wasPanning && dragState.pendingCamera) { state.camera = dragState.pendingCamera; dragState.pendingCamera = null; } clearDragMode(); if (wasPanning) { const startedAt = performance.now(); redraw({ fastTerrain: false }); recordInteractionLatency("pan settle", startedAt, { zoom: state.zoom, fast: false }); } event.preventDefault(); } function parseSeed(seedText) { const numeric = Number.parseInt(seedText, 10); if (Number.isFinite(numeric)) return numeric >>> 0; let hash = 2166136261; for (const ch of seedText) hash = Math.imul(hash ^ ch.charCodeAt(0), 16777619); return hash >>> 0; } function formatMs(ms) { if (!Number.isFinite(ms)) return "-"; return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`; } function formatSeconds(value, digits = 4) { if (!Number.isFinite(value)) return "-"; return `${value.toFixed(digits)}s`; } function pushCapped(history, item, limit = 10) { history.unshift(item); if (history.length > limit) history.length = limit; } function countTruthyCells(mask) { if (!mask || typeof mask.length !== "number") return 0; let count = 0; for (let i = 0; i < mask.length; i++) if (mask[i]) count++; return count; } function mapAreaCells(map) { if (!map) return MAP_W * MAP_H; const focusedArea = countTruthyCells(map.focusedPrefectureMask || map.prefectureMask || map.humanRegionMask); if (focusedArea > 0) return focusedArea; return Math.max(1, (map.width || MAP_W) * (map.height || MAP_H)); } function formatAreaCells(cells) { return `${Math.max(0, Math.round(cells || 0)).toLocaleString()} cells`; } function rectAreaCells(rect) { if (!rect) return 0; return Math.max(0, Math.round((rect.x1 - rect.x0) * (rect.y1 - rect.y0))); } function terrainTypeLabel(value) { const option = generationTypeInput ? Array.from(generationTypeInput.options).find((item) => item.value === value) : null; return option?.textContent || value || "Auto"; } function secondsPerThousandCells(totalMs, areaCells) { const area = Math.max(1, areaCells || 0); return (totalMs || 0) / area; } function numericValues(items, selector) { return (items || []) .map(selector) .map(Number) .filter((value) => Number.isFinite(value) && value >= 0); } function percentile(values, percentileRank) { const sorted = [...values].sort((a, b) => a - b); if (!sorted.length) return NaN; if (sorted.length === 1) return sorted[0]; const rank = Math.min(Math.max(percentileRank, 0), 100) / 100 * (sorted.length - 1); const lower = Math.floor(rank); const upper = Math.ceil(rank); if (lower === upper) return sorted[lower]; const weight = rank - lower; return sorted[lower] * (1 - weight) + sorted[upper] * weight; } function summarizeValues(values) { if (!values.length) return null; const sum = values.reduce((acc, value) => acc + value, 0); return { count: values.length, avg: sum / values.length, max: Math.max(...values), p95: percentile(values, 95), }; } function renderMetricGrid(container, metrics, emptyText = "No samples yet.") { if (!container) return; container.innerHTML = ""; const visibleMetrics = (metrics || []).filter(Boolean); if (!visibleMetrics.length) { renderEmptyState(container, emptyText); return; } for (const metric of visibleMetrics) { const card = document.createElement("div"); card.className = "metric-card"; card.innerHTML = ` ${metric.label} ${metric.value} ${metric.sub ? `${metric.sub}` : ""} `; container.append(card); } } function formatPercent(value, digits = 1) { if (!Number.isFinite(value)) return "-"; return `${value.toFixed(digits)}%`; } function countArray(value) { return Array.isArray(value) ? value.length : 0; } function countPaths(paths) { return (paths || []).reduce((sum, path) => sum + (Array.isArray(path) ? path.length : 0), 0); } function renderDiagnosticGrid(container, metrics, emptyText = "No diagnostics yet.") { renderMetricGrid(container, metrics, emptyText); } function renderDiagnosticTable(container, rows, emptyText = "No diagnostics yet.") { if (!container) return; container.innerHTML = ""; const visibleRows = (rows || []).filter(Boolean); if (!visibleRows.length) { renderEmptyState(container, emptyText); return; } for (const row of visibleRows) { const item = document.createElement("div"); item.className = "diagnostic-row"; item.innerHTML = ` ${row.label} ${row.value} ${row.sub ? `${row.sub}` : ""} `; container.append(item); } } function collectFeatureCounts(map = activeMap()) { if (!map) return null; const roads = [ ...(map.nationalRoads || []), ...(map.ringRoads || []), ...(map.externalRoads || []), ...(map.expressways || []), ...(map.externalExpressways || []), ...(map.minorRoads || []), ...(map.icAccessRoads || []), ]; const railways = [ ...(map.railways || []), ...(map.branchRailways || []), ...(map.ringRailways || []), ...(map.externalRailways || []), ]; const rivers = [ ...(map.mainRivers || []), ...(map.tributaryRivers || []), ...(map.smallStreams || []), ]; const settlements = [ ...(map.modernCities || []), ...(map.satelliteCities || []), ...(map.villages || []), ...(map.markets || []), ...(map.castles || []), ...(map.ports || []), ...(map.newTowns || []), ]; const adminBorders = [ ...(map.adminBorders || []), ...(map.prefectureBorder || []), ...(map.regionalPrefectureBorders || []), ]; return { roads: roads.length, roadCells: countPaths(roads), railways: railways.length, railwayCells: countPaths(railways), rivers: rivers.length, riverCells: countPaths(rivers), settlements: settlements.length, stations: countArray(map.stations), labels: countArray(state.hoverEntities), adminCenters: countArray(map.adminCenters), adminBorders: adminBorders.length, adminBorderCells: countPaths(adminBorders), industrialZones: countArray(map.industrialZones), logisticsParks: countArray(map.logisticsParks), }; } function recordDiagnosticLog(level, title, message = "", meta = {}) { pushCapped(state.diagnosticLog, { id: Date.now() + Math.random(), createdAt: new Date(), level: level || "info", title: title || "Diagnostic", message: String(message || ""), meta, }); renderAdvancedData(); } function updateRenderDiagnostics(options = {}, timings = {}) { const map = activeMap(); const canvasRect = canvas?.getBoundingClientRect?.(); const viewWidth = Math.max(1, state.viewWidth || map?.width || MAP_W); const viewHeight = Math.max(1, state.viewHeight || map?.height || MAP_H); state.diagnostics.lastViewport = { viewWidth, viewHeight, cells: viewWidth * viewHeight, mapWidth: map?.width || viewWidth, mapHeight: map?.height || viewHeight, cssWidth: canvasRect?.width || 0, cssHeight: canvasRect?.height || 0, bitmapWidth: canvas?.width || 0, bitmapHeight: canvas?.height || 0, zoom: state.zoom || 1, mode: state.mode, fast: !!options.fastTerrain, showFeatures: state.showFeatures && !options.fastTerrain, showLabels: state.showLabels && !options.fastTerrain, showSeamDiagnostics: state.showSeamDiagnostics && !options.fastTerrain, viewportMs: timings.viewportMs || 0, hoverMs: timings.hoverMs || 0, drawMs: timings.drawMs || 0, totalRenderMs: timings.totalRenderMs || 0, renderBreakdown: timings.renderBreakdown || null, }; state.diagnostics.lastFeatureCounts = collectFeatureCounts(map); } function selectionWriteDiagnostics() { const rect = state.selectionRect; const validation = validatePatchRect(rect, state.world); const currentSelection = validation.rect || rect; const rects = previewPatchRects(validation); const selectedArea = currentSelection?.areaCells || rectAreaCells(currentSelection); const writeArea = rects?.writeRect ? rectAreaCells(rects.writeRect) : 0; const last = state.patchRuns[0]; const lastRatio = last?.areaCells ? (last.writeAreaCells || 0) / Math.max(1, last.areaCells) * 100 : NaN; return [ { label: "Current selection", value: selectedArea ? formatAreaCells(selectedArea) : "none", sub: validation.ok ? "valid" : (rect ? validation.reason : "no active selection") }, { label: "Resolved patch mode", value: rects?.patchMode || "-", sub: rects ? `${rects.patchModeAutoDetected ? "auto" : "explicit"} · ${formatPercent((rects.coverageStats?.ungeneratedRatio || 0) * 100)} ungenerated` : "requires valid selection" }, { label: "Current write area", value: writeArea ? formatAreaCells(writeArea) : "-", sub: selectedArea ? `${formatPercent(writeArea / Math.max(1, selectedArea) * 100)} of selection` : "requires valid selection" }, { label: "Last patch selection", value: last ? formatAreaCells(last.areaCells) : "-", sub: last ? `${last.kind || "Patch"} · ${terrainTypeLabel(last.terrainType)}` : "no patch runs" }, { label: "Last patch write", value: last?.writeAreaCells ? formatAreaCells(last.writeAreaCells) : "-", sub: Number.isFinite(lastRatio) ? `${formatPercent(lastRatio)} of selection` : "no patch runs" }, { label: "Last patch variant", value: last ? String(last.variant ?? "-") : "-", sub: last?.label || "no candidate" }, ]; } function renderViewportDiagnostics() { const viewport = state.diagnostics.lastViewport; const renderRows = viewport?.renderBreakdown ? [ { label: "Draw / base", value: formatMs(viewport.renderBreakdown.baseTerrain || 0), sub: "terrain raster" }, { label: "Draw / urban", value: formatMs(viewport.renderBreakdown.urbanFill || 0), sub: "land-use overlay" }, { label: "Draw / coast", value: formatMs(viewport.renderBreakdown.coastline || 0), sub: "coastline vectors" }, { label: "Draw / rivers", value: formatMs(viewport.renderBreakdown.rivers || 0), sub: "river paths" }, { label: "Draw / admin", value: formatMs(viewport.renderBreakdown.adminBorders || 0), sub: "fills and borders" }, { label: "Draw / transport", value: formatMs(viewport.renderBreakdown.transport || 0), sub: "roads and rail" }, { label: "Draw / labels", value: formatMs((viewport.renderBreakdown.icons || 0) + (viewport.renderBreakdown.labels || 0)), sub: "icons and text" }, ] : []; renderDiagnosticGrid(advancedViewportDiagnosticsEl, viewport ? [ { label: "Viewport", value: `${viewport.viewWidth}×${viewport.viewHeight}`, sub: `${formatAreaCells(viewport.cells)} drawn` }, { label: "Canvas CSS", value: `${Math.round(viewport.cssWidth)}×${Math.round(viewport.cssHeight)}`, sub: "display pixels" }, { label: "Canvas bitmap", value: `${viewport.bitmapWidth}×${viewport.bitmapHeight}`, sub: "render target" }, { label: "Zoom", value: `${Number(viewport.zoom || 1).toFixed(2)}x`, sub: viewport.fast ? "fast redraw" : "full redraw" }, { label: "Viewport build", value: formatMs(viewport.viewportMs), sub: "getViewportMap" }, { label: "Canvas draw", value: formatMs(viewport.drawMs), sub: "drawMap" }, { label: "Hover index", value: formatMs(viewport.hoverMs), sub: "labels / hit targets" }, { label: "Render total", value: formatMs(viewport.totalRenderMs), sub: `${viewport.mode} mode` }, ...renderRows, ] : [], "No viewport render recorded yet."); const counts = state.diagnostics.lastFeatureCounts; renderDiagnosticTable(advancedFeatureCountsEl, counts ? [ { label: "Road paths", value: counts.roads.toLocaleString(), sub: `${counts.roadCells.toLocaleString()} path cells` }, { label: "Rail paths", value: counts.railways.toLocaleString(), sub: `${counts.railwayCells.toLocaleString()} path cells` }, { label: "River paths", value: counts.rivers.toLocaleString(), sub: `${counts.riverCells.toLocaleString()} path cells` }, { label: "Settlements", value: counts.settlements.toLocaleString(), sub: "cities, towns, ports, castles" }, { label: "Stations", value: counts.stations.toLocaleString(), sub: "rail markers" }, { label: "Hover labels", value: counts.labels.toLocaleString(), sub: "active hit targets" }, { label: "Admin centers", value: counts.adminCenters.toLocaleString(), sub: "municipality labels" }, { label: "Admin borders", value: counts.adminBorders.toLocaleString(), sub: `${counts.adminBorderCells.toLocaleString()} path cells` }, { label: "Industry / logistics", value: (counts.industrialZones + counts.logisticsParks).toLocaleString(), sub: `${counts.industrialZones} industrial, ${counts.logisticsParks} logistics` }, ] : [], "No feature counts recorded yet."); } function renderPatchDiagnostics() { renderDiagnosticTable(advancedPatchDiagnosticsEl, selectionWriteDiagnostics(), "No patch diagnostics yet."); } function currentSeamDiagnostics() { return state.pendingPatch?.result?.seamDiagnostics || state.lastPatchResult?.seamDiagnostics || displaySourceMap()?.patchSeamDiagnostics || null; } function seamDiagnosticRows() { const d = currentSeamDiagnostics(); if (!d) return []; const roadTotal = Number(d.roadPortalsBefore || 0); const railTotal = Number(d.railPortalsBefore || 0); const duplicateParts = [ `${Number(d.duplicateAdminBoundaryPairs || 0)} municipal`, `${Number(d.duplicatePrefectureBoundaryPairs || 0)} prefecture`, `${Number(d.overlappingAdminPrefecturePairs || 0)} overlap`, ].join(" / "); const modeSub = "production-sized full pipeline"; return [ { label: "Status", value: String(d.status || "-").toUpperCase(), sub: `${Number(d.criticalCount || 0)} critical / ${Number(d.warningCount || 0)} warning signals` }, { label: "Patch mode", value: d.patchMode || "-", sub: `${d.patchModeAutoDetected ? "auto-detected" : "explicit"} · ${formatPercent(Number(d.ungeneratedSelectionRatio || 0) * 100)} ungenerated · overlap ${Number(d.expansionOverlap || 0)} cells` }, { label: "World sea level", value: Number(d.worldSeaLevel || 0).toFixed(3), sub: "shared by initial and additional generation" }, { label: "Candidate mode", value: d.patchGenerationMode || "-", sub: modeSub }, { label: "Candidate window", value: `${Number(d.candidateWidth || 0)}×${Number(d.candidateHeight || 0)}`, sub: `${formatPercent(Number(d.candidateAreaRatio || 0) * 100)} of full candidate area` }, ...(d.qualityPolicyVersion ? [ { label: "Expansion quality gate", value: d.qualityHardPass ? "PASS" : "FAIL", sub: `${d.qualityPolicyVersion} · score ${Number(d.qualityScore || 0).toFixed(3)} · selected variant ${Number(d.qualitySelectedVariant || 0)}` }, { label: "Candidate land quality", value: formatPercent(Number(d.qualityLandRatio || 0) * 100), sub: `${d.qualityTerrainType || "-"} · ${formatPercent(Number(d.qualityDevelopableRatio || 0) * 100)} developable · ${formatPercent(Number(d.qualityLargestComponentRatio || 0) * 100)} largest component` }, { label: "Candidate place density", value: Number(d.qualityLabelCount || 0).toLocaleString(), sub: `${Number(d.qualitySettlementCount || 0)} settlements · ${Number(d.qualityLabelDensityPer1000 || 0).toFixed(2)} labels / 1000 land cells` }, { label: "Merged patch quality", value: d.qualityFinalHardPass ? "PASS" : "WARNING", sub: `${formatPercent(Number(d.qualityFinalOwnedLandRatio || 0) * 100)} land in owned interior · ${Number(d.qualityFinalLabelCount || 0)} labels / ${Number(d.qualityFinalSettlementCount || 0)} settlements` }, ] : []), { label: "Seam band", value: Number(d.seamBandCells || 0).toLocaleString(), sub: "cells inspected" }, { label: "Coast flips", value: Number(d.seaFlipCells || 0).toLocaleString(), sub: `${Number(d.landToSeaCells || 0)} land→sea / ${Number(d.seaToLandCells || 0)} sea→land` }, { label: "Transport/coast conflicts", value: Number(d.transportLandToSeaConflicts || 0).toLocaleString(), sub: "existing road or rail cell changed to sea" }, { label: "Road seam portals", value: `${Number(d.roadPortalsConnected || 0)}/${roadTotal}`, sub: `${Number(d.roadPortalsBroken || 0)} disconnected` }, { label: "Rail seam portals", value: `${Number(d.railPortalsConnected || 0)}/${railTotal}`, sub: `${Number(d.railPortalsBroken || 0)} disconnected` }, { label: "New seam boundaries", value: `${Number(d.adminSeamBreakEdges || 0)} / ${Number(d.prefectureSeamBreakEdges || 0)}`, sub: "municipal / prefecture edges created where the old ID was continuous" }, { label: "Near-duplicate boundaries", value: Number(d.duplicateBoundaryPairs || 0).toLocaleString(), sub: duplicateParts }, { label: "Established frontier elevation", value: Number(d.maxEstablishedFrontierElevationJump || 0).toFixed(3), sub: `${Number(d.establishedFrontierElevationEdges || 0).toLocaleString()} land edges inspected · hard limit ${Number(d.gateBudgets?.maxEstablishedFrontierElevationJump || 0.075).toFixed(3)}` }, { label: "Elevation cliffs", value: Number(d.elevationCliffEdges || 0).toLocaleString(), sub: `max ${Number(d.maxElevationJump || 0).toFixed(3)} / mean ${Number(d.meanElevationJump || 0).toFixed(3)}` }, { label: "Map markers", value: Number(d.markerCount || 0).toLocaleString(), sub: state.showSeamDiagnostics ? "overlay visible" : "overlay hidden" }, ]; } function renderSeamDiagnostics() { renderDiagnosticTable(advancedSeamDiagnosticsEl, seamDiagnosticRows(), "Generate a patch preview to collect seam diagnostics."); } function renderWorkerWorldDiagnostics() { const workerAvailable = typeof Worker !== "undefined"; const workerRows = [ { label: "Worker API", value: workerAvailable ? "available" : "unavailable", sub: "browser capability" }, { label: "Full-generation worker", value: generationWorker ? "active" : "not active", sub: state.diagnostics.lastGenerationWorkerUsed == null ? "created on demand" : (state.diagnostics.lastGenerationWorkerUsed ? "last full generation used worker" : "last full generation used main thread") }, { label: "Generation fallback", value: state.diagnostics.lastGenerationWorkerFallbackReason || "none", sub: "full-map generation" }, { label: "Patch worker object", value: patchWorker ? "active" : "not active", sub: patchWorker ? "created" : "created on demand" }, { label: "Last patch worker", value: state.diagnostics.lastWorkerUsed == null ? "-" : (state.diagnostics.lastWorkerUsed ? "used" : "main thread"), sub: state.diagnostics.lastPatchWorkerKind || "no patch run" }, { label: "Patch fallback", value: state.diagnostics.lastWorkerFallbackReason || "none", sub: "last patch worker fallback" }, ]; renderDiagnosticTable(advancedWorkerDiagnosticsEl, workerRows); const world = displayWorld(); const source = displaySourceMap(); const expansion = state.diagnostics.lastWorldExpansion; renderDiagnosticTable(advancedWorldDiagnosticsEl, [ { label: "World size", value: world ? `${world.width}×${world.height}` : "-", sub: world ? formatAreaCells(world.width * world.height) : "no world" }, { label: "Source map", value: source ? `${source.width || MAP_W}×${source.height || MAP_H}` : "-", sub: source ? formatAreaCells((source.width || MAP_W) * (source.height || MAP_H)) : "no source" }, { label: "Camera", value: state.camera ? `${Math.round(state.camera.x || 0)}, ${Math.round(state.camera.y || 0)}` : "-", sub: "world-space origin" }, { label: "World expansions", value: state.diagnostics.worldExpansionCount.toLocaleString(), sub: expansion ? `last dx ${expansion.dx}, dy ${expansion.dy}` : "none yet" }, { label: "Pending patch", value: state.pendingPatch ? "yes" : "no", sub: state.pendingPatch ? `${terrainTypeLabel(state.pendingPatch.terrainType)} · variant ${state.pendingPatch.variant}` : "committed world" }, ]); } function renderWarningHistory() { if (!advancedWarningHistoryEl) return; advancedWarningHistoryEl.innerHTML = ""; if (!state.diagnosticLog.length) { renderEmptyState(advancedWarningHistoryEl, "No warnings or errors recorded yet."); return; } for (const entry of state.diagnosticLog) { const row = document.createElement("div"); row.className = `diagnostic-log-row ${entry.level || "info"}`; const time = entry.createdAt instanceof Date ? entry.createdAt.toLocaleTimeString() : "-"; row.innerHTML = ` ${time} ${entry.title}

${entry.message || "-"}

`; advancedWarningHistoryEl.append(row); } } function performanceMetricsForRuns(runs) { const totals = summarizeValues(numericValues(runs, (run) => run.totalMs)); const perArea = summarizeValues(numericValues(runs, (run) => run.secondsPerThousand)); if (!totals) return []; const hasSmallAreaRuns = (runs || []).some((run) => Number(run?.areaCells) > 0 && Number(run.areaCells) < 1000); const perAreaSub = hasSmallAreaRuns ? "area-normalized; small runs include fixed overhead" : "seconds / 1k cells"; return [ { label: "Samples", value: String(totals.count), sub: "retained runs, including failures and cancellations" }, { label: "Avg total", value: formatMs(totals.avg), sub: "generation time" }, { label: "Max total", value: formatMs(totals.max), sub: "slowest run" }, { label: "P95 total", value: formatMs(totals.p95), sub: "tail latency" }, perArea ? { label: "Avg / 1k", value: formatSeconds(perArea.avg), sub: perAreaSub } : null, perArea ? { label: "P95 / 1k", value: formatSeconds(perArea.p95), sub: hasSmallAreaRuns ? "fixed overhead dominated below 1k cells" : "area-normalized" } : null, ]; } function recordGenerationRun(map, terrainType) { if (!map) return; const area = mapAreaCells(map); const totalMs = Number.isFinite(map.generationTotalMs) ? map.generationTotalMs : (map.generationTimings || []).reduce((sum, row) => sum + (Number(row?.ms) || 0), 0); const featureTimings = (map.transportDebug?.featureTimings || []).map((row) => ({ label: `Settlements / ${row.key || "substage"}`, ms: Number(row.ms) || 0, })); pushCapped(state.generationRuns, { id: Date.now(), createdAt: new Date(), terrainType: terrainType || "auto", areaCells: area, totalMs, secondsPerThousand: secondsPerThousandCells(totalMs, area), timings: (map.generationTimings || []).map((row) => ({ label: row.label || row.key || "Stage", ms: Number(row.ms) || 0, })).concat(featureTimings), }); renderAdvancedData(); } function recordPatchRun(result, terrainType, selectionRect, variant, meta = {}) { if (!result) return; const timings = (result.patchTimings || []).map((row) => ({ label: row.label || row.key || "Stage", ms: Number(row.ms) || 0, })); const totalMs = Number.isFinite(meta.wallMs) ? meta.wallMs : timings.reduce((sum, row) => sum + (Number(row.ms) || 0), 0); const selectionArea = result.selectionShape?.areaCells || selectionRect?.areaCells || rectAreaCells(selectionRect); const writeArea = rectAreaCells(result.writeRect || result.rects?.writeRect || selectionRect); const area = Math.max(1, Math.round(selectionArea || writeArea || 1)); const writeRatio = writeArea / Math.max(1, area); const usedWorker = meta.worker === true; const estimatedTileCount = Math.max(1, Number(meta.estimatedTileCount || result.tileCount || 1)); const actualCandidateCount = Math.max(1, Array.isArray(result.searchAttempts) && result.searchAttempts.length ? result.searchAttempts.length : Number(result.candidateOrdinal || meta.candidateOrdinal || 1)); // Tiling and automatic candidate search, not selected area alone, determine // production cost. A thin selection can cross canonical tile boundaries and // be materially more expensive than a larger single-tile patch. const performanceBudgetMs = estimatedTileCount <= 1 && actualCandidateCount <= 1 ? 30_000 : 60_000; const performanceBudgetMet = totalMs <= performanceBudgetMs; pushCapped(state.patchRuns, { id: Date.now(), createdAt: new Date(), kind: meta.kind || "Patch preview", status: meta.status || (result.ok === false ? "rejected" : "success"), searchStatus: result.searchStatus || meta.searchStatus || null, candidateOrdinal: Number(result.candidateOrdinal || meta.candidateOrdinal || 0), candidateCount: Number(result.candidateCount || meta.candidateCount || 0), searchAttempts: Array.isArray(result.searchAttempts) ? result.searchAttempts : [], executions: Array.isArray(result.executions) ? result.executions : [], terrainType: terrainType || result.terrainType || "auto", variant: Number.isFinite(variant) ? variant : result.variant, requestedVariant: Number.isFinite(meta.requestedVariant) ? meta.requestedVariant : result.requestedVariant, seed: Number.isFinite(meta.seed) ? meta.seed >>> 0 : (Number.isFinite(result.actualSeed) ? result.actualSeed >>> 0 : result.seed), worker: usedWorker, workerStartCount: Number(meta.workerStartCount || result.workerStartCount || 0), workerEpoch: Number(meta.workerEpoch || result.workerEpoch || 0), committedRevision: Number(meta.committedRevision ?? result.baseCommittedRevision ?? state.committedRevision ?? 0), inputDispatchMs: Number(meta.inputDispatchMs || result.inputDispatchMs || 0), inputMirrorReused: meta.inputMirrorReused === true || result.inputMirrorReused === true, estimatedTileCount, qualityWorkerRetryCount: Number(result.qualityWorkerRetryCount || 0), workerRestartCount: Number(result.workerRestartCount || 0), renderMs: Number(meta.renderMs || 0), label: result.label || "candidate", patchGenerationMode: result.patchGenerationMode || "-", patchMode: result.patchMode || "regeneration", patchModeAutoDetected: !!result.patchModeAutoDetected, ungeneratedSelectionRatio: result.coverageStats?.ungeneratedRatio || 0, seamStatus: result.seamDiagnostics?.status || "-", areaCells: area, writeAreaCells: writeArea, writeRatio, totalMs, performanceBudgetMs, performanceBudgetMet, secondsPerThousand: secondsPerThousandCells(totalMs, area), timings, }, 100); state.diagnostics.lastWorkerUsed = usedWorker; state.diagnostics.lastPatchWorkerKind = meta.kind || "Patch preview"; if (usedWorker) state.diagnostics.lastWorkerFallbackReason = null; renderAdvancedData(); } function interactionAction(kind = "") { const label = String(kind).toLowerCase(); if (label.includes("pan")) return "pan"; if (label.includes("zoom")) return "zoom"; return label || "other"; } function interactionPhase(run) { return run.fast ? "fast" : "full"; } function interactionGroupLabel(action, phase) { const actionLabel = action === "pan" ? "Pan" : action === "zoom" ? "Zoom" : action; return `${actionLabel} / ${phase === "fast" ? "fast redraw" : "full redraw"}`; } function recordInteractionLatency(kind, startedAt, meta = {}) { if (!Number.isFinite(startedAt)) return; const ms = performance.now() - startedAt; if (!Number.isFinite(ms) || ms < 0) return; const item = { id: Date.now(), createdAt: new Date(), kind, ms, zoom: Number.isFinite(meta.zoom) ? meta.zoom : state.zoom, fast: meta.fast === true, }; item.action = interactionAction(kind); item.phase = interactionPhase(item); pushCapped(state.interactionRuns, item); renderAdvancedData(); } function renderEmptyState(container, text) { if (!container) return; container.innerHTML = ""; const empty = document.createElement("div"); empty.className = "perf-empty"; empty.textContent = text; container.append(empty); } function appendRunHistory(container, runs, options = {}) { if (!container) return; container.innerHTML = ""; if (!runs.length) { renderEmptyState(container, options.emptyText || "No runs recorded yet."); return; } runs.forEach((run, index) => { const item = document.createElement("details"); item.className = "perf-run"; if (index === 0) item.open = true; const summary = document.createElement("summary"); summary.className = options.patch ? "perf-summary patch" : "perf-summary"; const name = options.patch ? `${run.kind || "Patch"} · ${terrainTypeLabel(run.terrainType)}` : terrainTypeLabel(run.terrainType); summary.innerHTML = ` #${index + 1} ${name} ${formatMs(run.totalMs)} ${formatAreaCells(run.areaCells)}${options.patch && run.writeAreaCells ? ` / write ${formatAreaCells(run.writeAreaCells)}` : ""} ${formatSeconds(run.secondsPerThousand)} / 1k cells${run.areaCells && run.areaCells < 1000 ? " (fixed overhead)" : ""} `; const body = document.createElement("div"); body.className = "timing-grid"; if (options.patch) { const status = document.createElement("div"); status.className = "timing-pill meta"; status.innerHTML = `Status${String(run.status || "-").toUpperCase()}`; const candidate = document.createElement("div"); candidate.className = "timing-pill meta"; candidate.innerHTML = `Candidate${run.candidateOrdinal || "-"}/${run.candidateCount || "-"}`; const meta = document.createElement("div"); meta.className = "timing-pill meta"; meta.innerHTML = `Variant${run.variant ?? "-"}`; const worker = document.createElement("div"); worker.className = "timing-pill meta"; worker.innerHTML = `Worker${run.worker ? "yes" : "no"}`; const ratio = document.createElement("div"); ratio.className = "timing-pill meta"; ratio.innerHTML = `Write / selection${formatPercent((run.writeRatio || 0) * 100)}`; const candidateMode = document.createElement("div"); candidateMode.className = "timing-pill meta"; candidateMode.innerHTML = `Candidate${run.patchGenerationMode || "-"}`; const seam = document.createElement("div"); seam.className = "timing-pill meta"; seam.innerHTML = `Seam${String(run.seamStatus || "-").toUpperCase()}`; const budget = document.createElement("div"); budget.className = "timing-pill meta"; budget.innerHTML = `Budget${run.performanceBudgetMet ? "PASS" : "FAIL"} / ${formatMs(run.performanceBudgetMs)}`; body.append(status, candidate, meta, worker, ratio, candidateMode, seam, budget); } for (const row of run.timings) { const timing = document.createElement("div"); timing.className = "timing-pill"; timing.innerHTML = `${row.label}${formatMs(row.ms)}`; body.append(timing); } if (!run.timings.length) { const empty = document.createElement("div"); empty.className = "perf-empty inline"; empty.textContent = "No stage breakdown available."; body.append(empty); } item.append(summary, body); container.append(item); }); } function renderGenerationHistory() { renderMetricGrid(advancedGenerationStatsEl, performanceMetricsForRuns(state.generationRuns), "No generation runs recorded yet."); appendRunHistory(advancedGenerationHistoryEl, state.generationRuns, { emptyText: "No generation runs recorded yet.", }); } function renderPatchHistory() { renderMetricGrid(advancedPatchStatsEl, performanceMetricsForRuns(state.patchRuns), "No patch generation runs recorded yet."); appendRunHistory(advancedPatchHistoryEl, state.patchRuns, { patch: true, emptyText: "No patch generation runs recorded yet.", }); } function renderInteractionSummary() { if (!advancedInteractionStatsEl) return; advancedInteractionStatsEl.innerHTML = ""; if (!state.interactionRuns.length) { renderEmptyState(advancedInteractionStatsEl, "No pan or zoom operations recorded yet."); return; } const groups = new Map(); for (const run of state.interactionRuns) { const action = run.action || interactionAction(run.kind); const phase = run.phase || interactionPhase(run); const key = `${action}:${phase}`; if (!groups.has(key)) groups.set(key, { action, phase, values: [] }); groups.get(key).values.push(run.ms); } const table = document.createElement("div"); table.className = "aggregate-table"; for (const group of groups.values()) { const stats = summarizeValues(group.values); const row = document.createElement("div"); row.className = "aggregate-row"; row.innerHTML = ` ${interactionGroupLabel(group.action, group.phase)} n=${stats.count} ${formatMs(stats.avg)} ${formatMs(stats.max)} ${formatMs(stats.p95)} `; table.append(row); } advancedInteractionStatsEl.append(table); } function renderInteractionHistory() { if (!advancedInteractionHistoryEl) return; advancedInteractionHistoryEl.innerHTML = ""; if (!state.interactionRuns.length) { renderEmptyState(advancedInteractionHistoryEl, "No pan or zoom operations recorded yet."); return; } const table = document.createElement("div"); table.className = "interaction-table"; for (const run of state.interactionRuns) { const row = document.createElement("div"); row.className = "interaction-row"; row.innerHTML = ` ${run.kind} ${formatMs(run.ms)} ${Number(run.zoom || 1).toFixed(2)}x ${run.fast ? "fast" : "full"} `; table.append(row); } advancedInteractionHistoryEl.append(table); } function renderAdvancedData() { renderGenerationHistory(); renderPatchHistory(); renderInteractionSummary(); renderInteractionHistory(); renderViewportDiagnostics(); renderPatchDiagnostics(); renderSeamDiagnostics(); renderWorkerWorldDiagnostics(); renderWarningHistory(); } function reportValue(value) { return value == null || value === "" ? "-" : String(value); } function reportRows(title, rows = []) { const lines = [`[${title}]`]; const visibleRows = (rows || []).filter(Boolean); if (!visibleRows.length) { lines.push("- none"); return lines; } for (const row of visibleRows) { const sub = row.sub ? ` (${row.sub})` : ""; lines.push(`- ${row.label}: ${reportValue(row.value)}${sub}`); } return lines; } function reportMetrics(title, runs = []) { const lines = reportRows(title, performanceMetricsForRuns(runs)); if (!runs.length) return lines; lines.push("Runs:"); runs.forEach((run, index) => { const prefix = run.kind ? `${run.kind} · ` : ""; const variant = run.kind ? `, variant=${run.variant ?? "-"}, worker=${run.worker ? "yes" : "no"}` : ""; const write = run.writeAreaCells ? `, write=${formatAreaCells(run.writeAreaCells)}, write/selection=${formatPercent((run.writeRatio || 0) * 100)}` : ""; lines.push(` ${index + 1}. ${prefix}${terrainTypeLabel(run.terrainType)}: total=${formatMs(run.totalMs)}, area=${formatAreaCells(run.areaCells)}, sec/1000 cells=${formatSeconds(run.secondsPerThousand)}${variant}${write}`); if (run.timings?.length) { lines.push(` breakdown: ${run.timings.map((row) => `${row.label}=${formatMs(row.ms)}`).join(", ")}`); } }); return lines; } function interactionGroupsForReport() { const groups = new Map(); for (const run of state.interactionRuns) { const action = run.action || interactionAction(run.kind); const phase = run.phase || interactionPhase(run); const key = `${action}:${phase}`; if (!groups.has(key)) groups.set(key, { action, phase, values: [] }); groups.get(key).values.push(run.ms); } return Array.from(groups.values()).map((group) => { const stats = summarizeValues(group.values); return { label: interactionGroupLabel(group.action, group.phase), value: `n=${stats.count}, avg=${formatMs(stats.avg)}, max=${formatMs(stats.max)}, p95=${formatMs(stats.p95)}`, }; }); } function reportInteractions() { const lines = reportRows("Viewport Interaction Latency", interactionGroupsForReport()); if (!state.interactionRuns.length) return lines; lines.push("Recent operations:"); state.interactionRuns.forEach((run, index) => { lines.push(` ${index + 1}. ${run.kind}: ${formatMs(run.ms)}, zoom=${Number(run.zoom || 1).toFixed(2)}x, phase=${run.fast ? "fast" : "full"}`); }); return lines; } function viewportDiagnosticRowsForReport() { const viewport = state.diagnostics.lastViewport; return viewport ? [ { label: "Viewport", value: `${viewport.viewWidth}×${viewport.viewHeight}`, sub: `${formatAreaCells(viewport.cells)} drawn` }, { label: "Canvas CSS", value: `${Math.round(viewport.cssWidth)}×${Math.round(viewport.cssHeight)}`, sub: "display pixels" }, { label: "Canvas bitmap", value: `${viewport.bitmapWidth}×${viewport.bitmapHeight}`, sub: "render target" }, { label: "Zoom", value: `${Number(viewport.zoom || 1).toFixed(2)}x`, sub: viewport.fast ? "fast redraw" : "full redraw" }, { label: "Viewport build", value: formatMs(viewport.viewportMs), sub: "getViewportMap" }, { label: "Canvas draw", value: formatMs(viewport.drawMs), sub: "drawMap" }, { label: "Hover index", value: formatMs(viewport.hoverMs), sub: "labels / hit targets" }, { label: "Render total", value: formatMs(viewport.totalRenderMs), sub: `${viewport.mode} mode` }, ] : []; } function featureCountRowsForReport() { const counts = state.diagnostics.lastFeatureCounts; return counts ? [ { label: "Road paths", value: counts.roads.toLocaleString(), sub: `${counts.roadCells.toLocaleString()} path cells` }, { label: "Rail paths", value: counts.railways.toLocaleString(), sub: `${counts.railwayCells.toLocaleString()} path cells` }, { label: "River paths", value: counts.rivers.toLocaleString(), sub: `${counts.riverCells.toLocaleString()} path cells` }, { label: "Settlements", value: counts.settlements.toLocaleString(), sub: "cities, towns, ports, castles" }, { label: "Stations", value: counts.stations.toLocaleString(), sub: "rail markers" }, { label: "Hover labels", value: counts.labels.toLocaleString(), sub: "active hit targets" }, { label: "Admin centers", value: counts.adminCenters.toLocaleString(), sub: "municipality labels" }, { label: "Admin borders", value: counts.adminBorders.toLocaleString(), sub: `${counts.adminBorderCells.toLocaleString()} path cells` }, { label: "Industry / logistics", value: (counts.industrialZones + counts.logisticsParks).toLocaleString(), sub: `${counts.industrialZones} industrial, ${counts.logisticsParks} logistics` }, ] : []; } function workerDiagnosticRowsForReport() { const workerAvailable = typeof Worker !== "undefined"; return [ { label: "Worker API", value: workerAvailable ? "available" : "unavailable", sub: "browser capability" }, { label: "Full-generation worker", value: generationWorker ? "active" : "not active", sub: state.diagnostics.lastGenerationWorkerUsed == null ? "created on demand" : (state.diagnostics.lastGenerationWorkerUsed ? "last full generation used worker" : "last full generation used main thread") }, { label: "Generation fallback", value: state.diagnostics.lastGenerationWorkerFallbackReason || "none", sub: "full-map generation" }, { label: "Patch worker object", value: patchWorker ? "active" : "not active", sub: patchWorker ? "created" : "created on demand" }, { label: "Last patch worker", value: state.diagnostics.lastWorkerUsed == null ? "-" : (state.diagnostics.lastWorkerUsed ? "used" : "main thread"), sub: state.diagnostics.lastPatchWorkerKind || "no patch run" }, { label: "Patch fallback", value: state.diagnostics.lastWorkerFallbackReason || "none", sub: "last patch worker fallback" }, ]; } function worldDiagnosticRowsForReport() { const world = displayWorld(); const source = displaySourceMap(); const expansion = state.diagnostics.lastWorldExpansion; return [ { label: "World size", value: world ? `${world.width}×${world.height}` : "-", sub: world ? formatAreaCells(world.width * world.height) : "no world" }, { label: "Source map", value: source ? `${source.width || MAP_W}×${source.height || MAP_H}` : "-", sub: source ? formatAreaCells((source.width || MAP_W) * (source.height || MAP_H)) : "no source" }, { label: "Camera", value: state.camera ? `${Math.round(state.camera.x || 0)}, ${Math.round(state.camera.y || 0)}` : "-", sub: "world-space origin" }, { label: "World expansions", value: state.diagnostics.worldExpansionCount.toLocaleString(), sub: expansion ? `last dx ${expansion.dx}, dy ${expansion.dy}` : "none yet" }, { label: "Pending patch", value: state.pendingPatch ? "yes" : "no", sub: state.pendingPatch ? `${terrainTypeLabel(state.pendingPatch.terrainType)} · variant ${state.pendingPatch.variant}` : "committed world" }, ]; } function reportWarnings() { const lines = ["[Warnings / Errors]"]; if (!state.diagnosticLog.length) { lines.push("- none"); return lines; } state.diagnosticLog.forEach((entry, index) => { const time = entry.createdAt instanceof Date ? entry.createdAt.toISOString() : "-"; lines.push(`- ${index + 1}. ${time} ${String(entry.level || "info").toUpperCase()} ${entry.title}: ${entry.message || "-"}`); }); return lines; } function buildImportantDebugReport() { const source = displaySourceMap(); const summaryRows = getStats(source).map(([label, value]) => ({ label, value })); const contextRows = [ { label: "Generated at", value: new Date().toISOString() }, { label: "Seed", value: seedInput?.value || state.seedText || "-" }, { label: "Generation type", value: terrainTypeLabel(generationTypeInput?.value || state.generationType), sub: generationTypeInput?.value || state.generationType }, { label: "Patch terrain", value: terrainTypeLabel(patchTerrainTypeInput?.value || "auto"), sub: patchTerrainTypeInput?.value || "auto" }, { label: "Patch mode", value: patchModeInput?.value || "auto", sub: "auto separates expansion from regeneration" }, { label: "Tool mode", value: state.toolMode || "-" }, { label: "Display mode", value: state.mode || "-" }, { label: "Features", value: state.showFeatures ? "on" : "off" }, { label: "Labels", value: state.showLabels ? "on" : "off" }, ]; return [ "Prefecture Map Generator — Important Debug Data", "===============================================", ...reportRows("Context", contextRows), "", ...reportRows("Current Map Summary", summaryRows), "", ...reportMetrics("Full Generation Performance", state.generationRuns), "", ...reportMetrics("Patch / Additional Generation Performance", state.patchRuns), "", ...reportInteractions(), "", ...reportRows("Viewport Diagnostics", viewportDiagnosticRowsForReport()), "", ...reportRows("Feature Counts", featureCountRowsForReport()), "", ...reportRows("Selection / Write Ratio", selectionWriteDiagnostics()), "", ...reportRows("Seam Diagnostics", seamDiagnosticRows()), "", ...reportRows("Worker Diagnostics", workerDiagnosticRowsForReport()), "", ...reportRows("World Diagnostics", worldDiagnosticRowsForReport()), "", ...reportWarnings(), ].join("\n"); } function setCopyDebugStatus(text, isError = false) { if (!copyDebugStatusEl) return; copyDebugStatusEl.textContent = text; copyDebugStatusEl.classList.toggle("error", !!isError); window.clearTimeout(setCopyDebugStatus.timer); setCopyDebugStatus.timer = window.setTimeout(() => { if (copyDebugStatusEl.textContent === text) copyDebugStatusEl.textContent = ""; copyDebugStatusEl.classList.remove("error"); }, 1800); } function copyTextFallback(text) { const textarea = document.createElement("textarea"); textarea.value = text; textarea.setAttribute("readonly", ""); textarea.style.position = "fixed"; textarea.style.left = "-9999px"; textarea.style.top = "0"; document.body.append(textarea); textarea.select(); const ok = document.execCommand("copy"); textarea.remove(); if (!ok) throw new Error("Copy command failed"); } async function copyImportantDebugData(event) { event?.preventDefault?.(); event?.stopPropagation?.(); const text = buildImportantDebugReport(); try { if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(text); else copyTextFallback(text); setCopyDebugStatus("Copied"); } catch (error) { console.warn("Failed to copy debug data", error); try { copyTextFallback(text); setCopyDebugStatus("Copied"); } catch (fallbackError) { console.warn("Fallback copy failed", fallbackError); setCopyDebugStatus("Copy failed", true); } } } function renderTimingRows(timings = []) { if (!progressTimingsEl) return; progressTimingsEl.innerHTML = ""; for (const row of timings) { const item = document.createElement("div"); item.className = "progress-timing-row"; const label = document.createElement("span"); label.textContent = row.label; const value = document.createElement("strong"); value.textContent = formatMs(row.ms); item.append(label, value); progressTimingsEl.append(item); } } function writeProgressStage(label, { includeElapsed = true } = {}) { if (!progressStageEl) return; progressStageEl.textContent = label; if (!includeElapsed || !generationStartedAt) return; const elapsed = document.createElement("span"); elapsed.dataset.progressElapsed = "true"; elapsed.setAttribute("aria-hidden", "true"); elapsed.textContent = ` / elapsed ${formatMs(performance.now() - generationStartedAt)}`; progressStageEl.append(elapsed); } function updateGenerationProgress(event) { if (!progressEl) return; if (Array.isArray(window.__additionalGenerationE2EProgress)) { window.__additionalGenerationE2EProgress.push({ at: performance.now(), phase: event?.phase || event?.key || null, workUnitId: event?.workUnitId || null, completed: event?.completed, total: event?.total, boundedWork: event?.boundedWork === true, cooperative: event?.cooperative !== false, startedAtWorker: event?.startedAtWorker, lastAdvancedAtWorker: event?.lastAdvancedAtWorker, }); } progressEl.classList.remove("hidden"); const candidatePrefix = Number(event?.candidateCount || 0) > 0 && Number(event?.candidateOrdinal || 0) > 0 ? `Candidate ${event.candidateOrdinal}/${event.candidateCount}: ` : ""; if (event?.label) generationCurrentStage = `${candidatePrefix}${event.label}`; const completedText = Number.isFinite(event?.ms) ? ` / ${formatMs(event.ms)}` : ""; writeProgressStage(event?.status === "done" ? `Completed: ${generationCurrentStage || event?.label || "Done"}${completedText}` : `Running: ${generationCurrentStage || "Preparing"}`); renderTimingRows(event?.timings || []); } function setProgressVisible(visible, message = "Preparing") { if (!progressEl) return; if (progressHideTimer) { window.clearTimeout(progressHideTimer); progressHideTimer = null; } progressEl.classList.toggle("hidden", !visible); if (visible) { progressRevision++; generationStartedAt = performance.now(); generationCurrentStage = message; writeProgressStage(`Running: ${message}`); if (generationTimer) window.clearInterval(generationTimer); generationTimer = window.setInterval(() => { if (progressStageEl && !progressEl.classList.contains("hidden")) { const elapsed = progressStageEl.querySelector("[data-progress-elapsed]"); if (elapsed) elapsed.textContent = ` / elapsed ${formatMs(performance.now() - generationStartedAt)}`; } }, 1000); } else if (generationTimer) { window.clearInterval(generationTimer); generationTimer = null; } if (progressStageEl) progressStageEl.textContent = message; if (visible) renderTimingRows([]); } function finishProgress(message, timings = null, hideDelay = 0) { if (generationTimer) { window.clearInterval(generationTimer); generationTimer = null; } generationCurrentStage = message || generationCurrentStage; if (progressStageEl) progressStageEl.textContent = message || "Done"; if (timings) renderTimingRows(timings); const revision = progressRevision; if (progressHideTimer) window.clearTimeout(progressHideTimer); progressHideTimer = hideDelay > 0 ? window.setTimeout(() => { if (revision !== progressRevision) return; progressHideTimer = null; setProgressVisible(false, message || "Done"); }, hideDelay) : null; } function nextFrame() { return new Promise((resolve) => requestAnimationFrame(() => resolve())); } function yieldPatchMainThread() { if (typeof globalThis.scheduler?.yield === "function") return globalThis.scheduler.yield(); return new Promise((resolve) => window.setTimeout(resolve, 0)); } function countInside(items) { return (items || []).filter((item) => item?.insidePrefecture !== false).length; } function totalRailLineCount(map) { return (map.railways || []).length + (map.branchRailways || []).length + (map.ringRailways || []).length + (map.externalRailways || []).length; } function getStats(map) { if (!map) return []; return [ ["Population", (map.totalPopulation || 0).toLocaleString()], ["Municipalities", (map.adminCenters || []).length.toLocaleString()], ["Major cities", countInside(map.modernCities).toLocaleString()], ["Ports", countInside(map.ports).toLocaleString()], ["Rail lines", totalRailLineCount(map).toLocaleString()], ["Generation", map.generationTotalMs ? formatMs(map.generationTotalMs) : "-"], ]; } function renderStats(map) { if (!statsEl) return; statsEl.innerHTML = ""; for (const [labelText, valueText] of getStats(map)) { const row = document.createElement("div"); row.className = "stat-row"; const label = document.createElement("span"); label.textContent = labelText; const value = document.createElement("strong"); value.textContent = String(valueText); row.append(label, value); statsEl.append(row); } } const legendItems = { base: [ ["terrain-swatch", "Terrain shading"], ["river-major", "Rivers and lakes", "line"], ["border-swatch", "Prefecture border"], ], all: [ ["city-icon", "City / major town", "icon"], ["rail-line", "Railway", "line"], ["road-line", "Major road", "line"], ["river-major", "River", "line"], ["border-swatch", "Prefecture border"], ], terrain: [ ["terrain-swatch", "Elevation and relief"], ["sea-swatch", "Sea / lake"], ["river-major", "River system", "line"], ["border-swatch", "Prefecture border"], ], modern: [ ["city-icon", "Modern city", "icon"], ["station-icon", "Station", "icon"], ["rail-line", "Railway", "line"], ["minor-road-line", "Local road", "line"], ], history: [ ["town-icon", "Market / village", "icon"], ["castle-icon", "Castle / ruins", "icon"], ["port-icon", "Historical port", "icon"], ["old-road-line", "Premodern road", "line"], ], landuse: [ ["urban-swatch", "Urban land use"], ["industry-icon", "Industry / logistics", "icon"], ["city-icon", "City core", "icon"], ["river-major", "Water body", "line"], ], admin: [ ["admin-swatch", "Municipal border"], ["border-swatch", "Prefecture border"], ["city-icon", "Admin center", "icon"], ], }; function legendRowsForMode(mode) { return legendItems[mode] || legendItems.all; } function renderLegendGrid(container, rows) { if (!container) return; container.innerHTML = ""; for (const [className, text, kind = "swatch"] of rows) { const row = document.createElement("div"); row.className = "legend-row"; const mark = document.createElement("span"); mark.className = kind === "line" ? `legend-line ${className}` : kind === "icon" ? `legend-icon ${className}` : `legend-swatch ${className}`; const label = document.createElement("span"); label.textContent = text; row.append(mark, label); container.append(row); } } function renderLegend() { const rows = legendRowsForMode(state.mode); renderLegendGrid(mainLegendGrid, rows); } function buildHoverEntities(map) { return [ ...(map.modernCities || []), ...(map.ports || []), ...(map.stations || []), ...(map.interchanges || []), ...(map.industrialZones || []), ...(map.logisticsParks || []), ...(map.newTowns || []), ...(map.castles || []), ...(map.markets || []), ...(map.villages || []), ...(map.adminCenters || []), ]; } function nearestEntity(items, x, y, maxDistance = 5) { let best = null; let bestD = maxDistance; for (const item of items || []) { if (!item || !Number.isFinite(item.x) || !Number.isFinite(item.y)) continue; const d = Math.hypot(item.x - x, item.y - y); if (d < bestD) { best = item; bestD = d; } } return best; } function landuseName(value) { return landuseLabel(value); } const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId", "id", "numericId"]; const PREFECTURE_ID_KEYS = ["prefectureRegionId", "prefectureId", "id", "numericId"]; const MUNICIPALITY_NAME_KEYS = ["municipalityName", "name", "canonicalSettlementName", "municipalityRootName", "generatedMunicipalityName", "label"]; const PREFECTURE_NAME_KEYS = ["prefectureName", "prefectureRegionName", "regionName", "name", "label"]; const POPULATION_KEYS = ["municipalityPopulation", "adminPopulation", "population", "estimatedPopulation"]; function numericIdOf(item, keys = ADMIN_ID_KEYS) { for (const key of keys) { const value = item?.[key]; if (Number.isFinite(value)) return Math.floor(value); } return null; } function hasNumericId(item, id, keys = ADMIN_ID_KEYS) { if (!item || id == null || id < 0) return false; return keys.some((key) => Number.isFinite(item?.[key]) && Math.floor(item[key]) === Math.floor(id)); } function numericPrefectureId(item) { const value = numericIdOf(item, PREFECTURE_ID_KEYS); return value == null ? -1 : value; } function firstUsableText(item, keys) { for (const key of keys) { const value = item?.[key]; if (!looksNumericName(value)) return String(value).trim(); } return ""; } function firstPopulation(item) { for (const key of POPULATION_KEYS) { const value = item?.[key]; if (Number.isFinite(value) && value > 0) return Math.round(value); } return null; } function adminCenterForId(map, adminId) { if (!map || adminId == null || adminId < 0) return null; const centers = (map.adminCenters || []).filter(Boolean); const exact = centers.find((center) => hasNumericId(center, adminId)); if (exact) return exact; // Some legacy/admin debug arrays were once addressed by array index. Keep this // only as a guarded fallback so numeric IDs are not mistaken for indexes. const direct = map.adminCenters?.[adminId]; return hasNumericId(direct, adminId) ? direct : null; } function looksNumericName(name) { if (!name) return true; const text = String(name).trim(); return !text || /^県域\d*$/u.test(text) || /^-?\d+(?:\s*[,,]\s*\d+)*$/u.test(text) || /^(Municipality|Prefecture|Admin|Region)\s*-?\d+/i.test(text); } function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36, adminId = null) { if (!map || cellIndex < 0) return null; const x = cellIndex % map.width; const y = Math.floor(cellIndex / map.width); let best = null; let bestD = maxDistance; for (const center of map.adminCenters || []) { if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) continue; if (adminId != null && adminId >= 0 && !hasNumericId(center, adminId)) continue; if (!firstUsableText(center, MUNICIPALITY_NAME_KEYS)) continue; const d = Math.hypot(center.x - x, center.y - y); if (d < bestD) { best = center; bestD = d; } } return best; } function adminName(map, adminId, cellIndex = -1) { const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex, 54, adminId); const name = firstUsableText(center, MUNICIPALITY_NAME_KEYS); if (name) return name; return adminId >= 0 ? "Unnamed municipality" : "-"; } function adminPopulation(map, adminId, cellIndex = -1) { const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex, 54, adminId); const centerPop = firstPopulation(center); if (centerPop !== null) return centerPop; let sum = 0; let found = false; for (const key of ["modernCities", "satelliteCities", "ports", "markets", "villages"]) { for (const p of map?.[key] || []) { if (!hasNumericId(p, adminId)) continue; const pop = firstPopulation(p); if (pop !== null) { sum += pop; found = true; } } } return found ? sum : null; } function worldSourceMap() { return displayWorld()?.sourceMap || null; } function prefectureRegionById(id, maps = []) { if (!Number.isFinite(id) || id < 0) return null; for (const source of maps) { const region = (source?.prefectureRegions || []).find((p) => hasNumericId(p, id, PREFECTURE_ID_KEYS)); if (region) return region; } return null; } function prefectureNameForId(id, adminId = -1) { if (!Number.isFinite(id) || id < 0) return ""; const sources = [activeMap(), worldSourceMap()].filter(Boolean); const region = prefectureRegionById(id, sources); const regionName = firstUsableText(region, PREFECTURE_NAME_KEYS); if (regionName) return regionName; for (const source of sources) { for (const center of source?.adminCenters || []) { if (adminId >= 0 && !hasNumericId(center, adminId)) continue; const centerPref = numericPrefectureId(center); if (centerPref >= 0 && centerPref !== id) continue; const name = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]); if (name) return name; } } return ""; } function prefectureNameForCell(map, i) { const id = map.prefectureRegionId?.[i] ?? -1; const direct = prefectureNameForId(id); if (direct) return direct; const adminId = map.adminId?.[i] ?? -1; const mappedPref = adminId >= 0 ? map.municipalityToPrefectureId?.[adminId] ?? state.world?.sourceMap?.municipalityToPrefectureId?.[adminId] ?? -1 : -1; const mapped = prefectureNameForId(mappedPref, adminId); if (mapped) return mapped; const center = mappedPref === id ? nearestNamedAdminCenter(map, i, 90, adminId) : null; const fromCenter = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]); return fromCenter || (id >= 0 ? "Unnamed prefecture" : "-"); } function updateTooltip(event) { const map = activeMap(); if (!map || !tooltipEl || dragState.mode) return; const rect = canvas.getBoundingClientRect(); const cell = mapClientToCell(event); if (!cell) return; const { x, y } = cell; if (x < 0 || y < 0 || x >= map.width || y >= map.height) { tooltipEl.classList.remove("visible"); return; } const i = y * map.width + x; const worldCell = viewportCellToWorldCell({ x, y }); const entity = nearestEntity(state.hoverEntities, x, y); const elevation = map.elevation?.[i] ?? 0; const density = map.populationDensity?.[i] ?? map.settlementScore?.[i] ?? 0; const hoveredAdminId = map.adminId?.[i] ?? -1; const hoveredAdminPopulation = adminPopulation(map, hoveredAdminId, i); const coordinateText = worldCell ? `World ${worldCell.x}, ${worldCell.y} / View ${x}, ${y}` : `${x}, ${y}`; const entityName = firstUsableText(entity, ["name", "facilityLabel", "municipalityName", "canonicalSettlementName", "kind"]); const entityTitle = entity ? `${entityName || adminName(map, hoveredAdminId, i) || entity.kind || "Feature"} / ${entity.kind || "Feature"}` : coordinateText; const lines = [ `${entityTitle}`, `Prefecture: ${prefectureNameForCell(map, i)}`, `Admin: ${adminName(map, hoveredAdminId, i)}`, `Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`, `Land: ${map.sea?.[i] ? "Sea" : landuseName(map.landuse?.[i])}`, `Elevation: ${elevation.toFixed(3)} / Slope: ${(map.slope?.[i] ?? 0).toFixed(3)}`, `River: ${(map.river?.[i] ?? 0).toFixed(2)} / Density: ${density.toFixed(2)}`, ]; if (entity?.population) lines.splice(1, 0, `Population: ${entity.population.toLocaleString()}`); tooltipEl.innerHTML = lines.join("
"); const margin = 8; const gap = 18; const cursorX = event.clientX - rect.left; const cursorY = event.clientY - rect.top; const tooltipWidth = tooltipEl.offsetWidth; const tooltipHeight = tooltipEl.offsetHeight; const maxLeft = Math.max(margin, rect.width - tooltipWidth - margin); const maxTop = Math.max(margin, rect.height - tooltipHeight - margin); // Keep following the cursor all the way to the bottom. Near the lower edge, // prefer moving to the cursor's left/right while pinning the tooltip to the // bottom margin; the old vertical flip made it appear to stop moving well // before the cursor reached the bottom of the map. const roomRight = rect.width - cursorX - gap; const roomLeft = cursorX - gap; const canRight = roomRight >= tooltipWidth; const canLeft = roomLeft >= tooltipWidth; let left = canRight ? cursorX + gap : canLeft ? cursorX - gap - tooltipWidth : Math.min(Math.max(margin, cursorX + gap), maxLeft); let top = Math.min(Math.max(margin, cursorY + 10), maxTop); left = Math.min(Math.max(margin, left), maxLeft); // Cursor exclusion is a hard invariant. If horizontal separation is not // available (very narrow viewport), then and only then move vertically. const exclusion = 12; const overlapsCursor = () => cursorX >= left - exclusion && cursorX <= left + tooltipWidth + exclusion && cursorY >= top - exclusion && cursorY <= top + tooltipHeight + exclusion; if (overlapsCursor()) { const leftAlt = cursorX - gap - tooltipWidth; const rightAlt = cursorX + gap; if (leftAlt >= margin) left = leftAlt; else if (rightAlt + tooltipWidth <= rect.width - margin) left = rightAlt; } if (overlapsCursor()) { const above = cursorY - gap - tooltipHeight; const below = cursorY + gap; if (above >= margin) top = above; else if (below + tooltipHeight <= rect.height - margin) top = below; } left = Math.min(Math.max(margin, left), maxLeft); top = Math.min(Math.max(margin, top), maxTop); tooltipEl.style.left = `${left}px`; tooltipEl.style.top = `${top}px`; tooltipEl.classList.add("visible"); } function renderModeButtons() { if (!modeGrid) return; modeGrid.innerHTML = ""; for (const [key, label] of modes) { const button = document.createElement("button"); button.type = "button"; button.textContent = label; button.className = key === state.mode ? "mode-button active" : "mode-button"; button.addEventListener("click", () => { state.mode = key; renderModeButtons(); renderLegend(); redraw(); }); modeGrid.append(button); } renderLegend(); } function rejectGenerationJobs(error) { for (const pending of generationPendingJobs.values()) pending.reject(error); generationPendingJobs.clear(); } function resetGenerationWorker(reason = "Generation worker reset", rejectPending = true) { const worker = generationWorker; generationWorker = null; worker?.terminate?.(); if (rejectPending && generationPendingJobs.size) rejectGenerationJobs(new Error(reason)); } function createGenerationWorker() { if (generationWorker) return generationWorker; if (typeof Worker === "undefined") { state.diagnostics.lastGenerationWorkerFallbackReason = "Worker API unavailable"; return null; } try { const worker = new Worker(new URL("./generationWorker.js", import.meta.url), { type: "module" }); worker.addEventListener("message", (event) => { const data = event.data || {}; const pending = generationPendingJobs.get(data.id); if (!pending) return; if (data.type === "progress") { pending.onProgress?.(data.event || {}); return; } if (data.type !== "result") return; generationPendingJobs.delete(data.id); if (data.ok) pending.resolve({ map: data.map, worker: true }); else pending.reject(new Error(data.error || "Generation worker failed")); }); worker.addEventListener("error", (event) => { if (generationWorker !== worker) return; const reason = event?.message || "Generation worker runtime error"; state.diagnostics.lastGenerationWorkerFallbackReason = reason; recordDiagnosticLog("error", "Generation worker reset", reason); resetGenerationWorker(reason, true); }); worker.addEventListener("messageerror", () => { if (generationWorker !== worker) return; const reason = "Generation worker message clone failed"; state.diagnostics.lastGenerationWorkerFallbackReason = reason; recordDiagnosticLog("error", "Generation worker reset", reason); resetGenerationWorker(reason, true); }); generationWorker = worker; return worker; } catch (error) { state.diagnostics.lastGenerationWorkerFallbackReason = error?.message || "Generation worker creation failed"; generationWorker = null; return null; } } function runGenerationInWorker(seed, options = {}, onProgress = null) { const worker = createGenerationWorker(); if (!worker) return null; const id = ++generationJobSeq; return new Promise((resolve, reject) => { generationPendingJobs.set(id, { resolve, reject, onProgress }); try { worker.postMessage({ id, seed, options }); } catch (error) { generationPendingJobs.delete(id); reject(error); } }); } async function generateFullMap(seed, options = {}) { // Full-map generation used to run on the UI thread. A slow browser could // therefore trigger the browser's long-running-script watchdog after tens of // seconds even though generation itself was still making progress. Keep the // complete CPU-bound pipeline in a worker; only progress messages and the // finished map cross back to the UI thread. const workerPromise = runGenerationInWorker(seed, { terrainType: options.terrainType }, options.onProgress); if (workerPromise) { const job = await workerPromise; state.diagnostics.lastGenerationWorkerUsed = true; state.diagnostics.lastGenerationWorkerFallbackReason = null; return job.map; } // Do not restart a CPU-heavy full generation on the UI thread. On slower // browsers that old compatibility fallback could hit the long-running-script // watchdog after tens of seconds. Modern browser execution therefore // requires a worker; if workers are blocked, fail immediately with a useful // diagnostic instead of appearing to generate and then being interrupted. state.diagnostics.lastGenerationWorkerUsed = false; const reason = state.diagnostics.lastGenerationWorkerFallbackReason || "Generation worker unavailable"; throw new Error(`${reason}. Full-map generation requires Web Worker support (serve the app over HTTP/HTTPS if local file workers are blocked).`); } async function regenerate() { if (state.fullGenerationBusy) return; const requestedSeedText = seedInput.value; const requestedGenerationType = generationTypeInput?.value || "auto"; const requestId = ++generationRequestSeq; if (state.patchBusy || activePatchCancel) { cancelPatchGeneration({ reason: "Patch generation superseded by full-map generation.", silentProgress: true }); } state.fullGenerationBusy = true; state.patchStatusMessage = ""; updatePatchControls(); // A deliberate new Generate request supersedes an older one. Do not leave // multiple full-map jobs queued behind a busy worker. if (generationPendingJobs.size) resetGenerationWorker("Generation superseded by a newer request", true); setProgressVisible(true, "Preparing generation..."); await nextFrame(); try { const map = await generateFullMap(parseSeed(requestedSeedText), { onProgress: updateGenerationProgress, terrainType: requestedGenerationType }); if (requestId !== generationRequestSeq) return; state.seedText = requestedSeedText; state.generationType = requestedGenerationType; state.world = createWorldMap(map); state.map = state.world.sourceMap; advanceCommittedRevision(); state.camera = createInitialCamera(state.world); state.lastPatchResult = null; state.pendingPatch = null; resetPatchVariant({ update: false }); hideSelectionOverlay({ discardPreview: true }); recordGenerationRun(map, state.generationType); renderStats(state.map); redraw(); const workerText = state.diagnostics.lastGenerationWorkerUsed ? " in worker" : ""; finishProgress(`Done${workerText} in ${formatMs(state.map.generationTotalMs || 0)}`, state.map.generationTimings || [], 900); } catch (error) { // Superseding an old request is an expected cancellation, not a generation // failure. The newer request owns the progress UI. if (requestId !== generationRequestSeq) return; const reason = error?.message || String(error || "unknown generation error"); recordDiagnosticLog("error", "Full generation failed", reason, { terrainType: requestedGenerationType }); finishProgress(`Generation failed: ${reason}`, null, 1800); console.error("Full generation failed", error); } finally { if (requestId === generationRequestSeq) { state.fullGenerationBusy = false; updatePatchControls(); } } } function derivePatchSeed(world, terrainType, variant = 0) { // Candidate placement already receives padding-invariant world coordinates. // Keep the seed independent of selection bounds and backing-array padding so // shared geography is reproducible when a selection grows or the world shifts. let h = (Number.isFinite(world?.seed) ? world.seed >>> 0 : parseSeed(state.seedText)) ^ 0x9e3779b9; h = Math.imul(h ^ normalizePatchVariant(variant), 668265263) >>> 0; for (const ch of String(terrainType || "auto")) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0; return h >>> 0; } function patchSearchContextId(world, rect, terrainType, requestedPatchMode, resolvedPatchMode, candidateWindowSignature) { return [ state.committedRevision || 0, selectionSignature(rect), terrainType || "auto", requestedPatchMode || "auto", resolvedPatchMode || requestedPatchMode || "auto", candidateWindowSignature || "default-window", world?.width || 0, world?.height || 0, world?.originX || 0, world?.originY || 0, "quality-batched-production-search-v4|initial-quality-oracle-transport-parity-v3", ].join("|"); } function estimatePatchTileCount(world, rect, resolvedPatchMode = "expansion") { if (!world || !rect) return 1; const regeneration = String(resolvedPatchMode || "").toLowerCase() === "regeneration"; // Keep search-policy sizing tied to the actual production tiler. The former // MAP_W/1.72 heuristic predated selection-anchored Expansion tiling and could // classify a real four-tile request as 16 tiles, unnecessarily shrinking the // candidate search window. This function is UI planning only; constructing the // tile geometry is deterministic and does not generate any candidate content. const tiles = buildLargeExpansionTiles(rect, world, regeneration ? { _largeSelectionThresholdWidth: MAP_W, _largeSelectionThresholdHeight: MAP_H, _tileCoreWidth: MAP_W, _tileCoreHeight: MAP_H, } : {}); return Math.max(1, tiles.length || 1); } function buildPatchCandidatePlan(world, rect, terrainType, requestedPatchMode, resolvedPatchMode, candidateWindowSignature, startVariant, { explicitFirst = true } = {}) { const contextId = patchSearchContextId(world, rect, terrainType, requestedPatchMode, resolvedPatchMode, candidateWindowSignature); if (patchSearchSeries.contextId !== contextId) { patchSearchSeries = { contextId, consumedCandidateIds: new Set(), nextVariant: null }; } const estimatedTileCount = estimatePatchTileCount(world, rect, resolvedPatchMode); const candidateLimit = estimatedTileCount >= 16 ? PATCH_SEARCH_LARGE_LIMIT : PATCH_SEARCH_DEFAULT_LIMIT; const plan = []; let variant = normalizePatchVariant(startVariant); let scanned = 0; while (plan.length < candidateLimit && scanned < candidateLimit + patchSearchSeries.consumedCandidateIds.size + 8) { const seed = derivePatchSeed(world, terrainType, variant); const candidateId = `${contextId}|${variant}|${seed}`; if ((explicitFirst && plan.length === 0 && scanned === 0) || !patchSearchSeries.consumedCandidateIds.has(candidateId)) { plan.push({ candidateOrdinal: plan.length + 1, candidateId, variant, seed }); } variant = (variant + 1) >>> 0; scanned++; } return { contextId, candidateLimit, estimatedTileCount, plan }; } function consumePatchSearchAttempts(contextId, attempts = []) { if (patchSearchSeries.contextId !== contextId) return; for (const attempt of attempts) { if (attempt?.status !== "rejected" && attempt?.status !== "evaluated" && attempt?.status !== "success") continue; const candidateId = `${contextId}|${normalizePatchVariant(attempt.variant)}|${normalizePatchVariant(attempt.seed)}`; patchSearchSeries.consumedCandidateIds.add(candidateId); } } function beginZoomVisual(oldZoom, event) { if (zoomVisualState) return; const rect = canvas.getBoundingClientRect(); zoomVisualState = { baseRect: rect, startZoom: clampZoom(oldZoom || state.zoom || 1), originX: Math.min(Math.max(event.clientX - rect.left, 0), rect.width), originY: Math.min(Math.max(event.clientY - rect.top, 0), rect.height), }; canvas.style.transformOrigin = `${zoomVisualState.originX}px ${zoomVisualState.originY}px`; canvas.style.willChange = "transform"; canvas.classList.add("is-zooming"); if (selectionSvgEl) selectionSvgEl.style.visibility = "hidden"; } function scheduleZoomVisualUpdate() { if (!zoomVisualState || zoomRedrawRaf != null) return; zoomRedrawRaf = requestAnimationFrame(() => { zoomRedrawRaf = null; if (!zoomVisualState) return; const scale = clampZoom(state.zoom || 1) / Math.max(1e-6, zoomVisualState.startZoom || 1); canvas.style.transform = `scale(${scale})`; }); } function finishZoomVisual() { if (zoomRedrawRaf != null) { cancelAnimationFrame(zoomRedrawRaf); zoomRedrawRaf = null; } if (zoomVisualState) { canvas.style.transform = ""; canvas.style.transformOrigin = ""; canvas.style.willChange = ""; canvas.classList.remove("is-zooming"); if (selectionSvgEl) selectionSvgEl.style.visibility = ""; zoomVisualState = null; } } function handleCanvasWheel(event) { if (!state.world || !activeMap()) return; event.preventDefault(); if (zoomLatencyStartedAt == null) zoomLatencyStartedAt = performance.now(); tooltipEl?.classList.remove("visible"); const beforeSize = viewportSizeForZoom(state.zoom); const beforeCell = mapClientToCell(event, beforeSize); const beforeWorld = beforeCell ? viewportCellToWorldCell(beforeCell) : null; const oldZoom = clampZoom(state.zoom || 1); const delta = event.deltaY < 0 ? 1.10 : 1 / 1.10; const nextZoom = clampZoom(oldZoom * delta); if (Math.abs(nextZoom - oldZoom) < 0.001) return; state.zoom = nextZoom; const nextSize = syncViewportSize(); if (beforeWorld) { const afterCell = mapClientToCell(event, nextSize); if (afterCell) { state.camera = clampCameraForView({ x: beforeWorld.x - afterCell.x, y: beforeWorld.y - afterCell.y, }, nextSize); } } // Wheel events can fire dozens of times per second. During the gesture, keep // the last rendered bitmap and only transform it on the GPU; rebuild the // viewport and labels once the gesture settles. beginZoomVisual(oldZoom, event); scheduleZoomVisualUpdate(); if (zoomSettledTimer != null) clearTimeout(zoomSettledTimer); zoomSettledTimer = window.setTimeout(() => { zoomSettledTimer = null; const startedAt = zoomLatencyStartedAt || performance.now(); zoomLatencyStartedAt = null; finishZoomVisual(); redraw({ fastTerrain: false, allowWorldExpand: false }); recordInteractionLatency("wheel zoom", startedAt, { zoom: state.zoom }); }, 170); } function previewValuesEqual(a, b) { if (a === b || (Number.isNaN(a) && Number.isNaN(b))) return true; if (a == null || b == null || typeof a !== "object" || typeof b !== "object") return false; if (ArrayBuffer.isView(a) || ArrayBuffer.isView(b)) { if (!ArrayBuffer.isView(a) || !ArrayBuffer.isView(b) || a.constructor !== b.constructor || a.length !== b.length) return false; for (let index = 0; index < a.length; index++) if (!previewValuesEqual(a[index], b[index])) return false; return true; } if (Array.isArray(a) || Array.isArray(b)) { if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; for (let index = 0; index < a.length; index++) if (!previewValuesEqual(a[index], b[index])) return false; return true; } const aKeys = Object.keys(a); const bKeys = Object.keys(b); if (aKeys.length !== bKeys.length) return false; for (let index = 0; index < aKeys.length; index++) { const key = aKeys[index]; if (key !== bKeys[index] || !previewValuesEqual(a[key], b[key])) return false; } return true; } function previewPatchDelta(baseWorld, previewWorld, rectLike) { const rect = rectLike?.transportReachRect || rectLike?.repairRect || rectLike?.writeRect || rectLike || null; if (!baseWorld || !previewWorld || !rect) return null; const x0 = Math.max(0, Math.floor(rect.x0 || 0)); const y0 = Math.max(0, Math.floor(rect.y0 || 0)); const x1 = Math.min(Math.max(baseWorld.width || 0, previewWorld.width || 0), Math.ceil(rect.x1 || 0)); const y1 = Math.min(Math.max(baseWorld.height || 0, previewWorld.height || 0), Math.ceil(rect.y1 || 0)); const terrainFields = new Set(["elevation", "slope", "sea", "landMask", "plain", "landuse", "populationDensity"]); const adminFields = new Set(["adminId", "municipalityId", "prefectureRegionId", "prefectureMask", "humanRegionMask"]); const fieldNames = new Set([...Object.keys(baseWorld.fields || {}), ...Object.keys(previewWorld.fields || {})]); let changedCells = 0; let terrainChangedCells = 0; let adminChangedCells = 0; for (let y = y0; y < y1; y++) { for (let x = x0; x < x1; x++) { const bi = worldIndexOf(baseWorld, x, y); const pi = worldIndexOf(previewWorld, x, y); if (bi < 0 || pi < 0) continue; let terrainChanged = false; let adminChanged = false; let cellChanged = false; for (const name of fieldNames) { const a = baseWorld.fields?.[name]?.[bi]; const b = previewWorld.fields?.[name]?.[pi]; if (a === b || (Number.isNaN(a) && Number.isNaN(b))) continue; cellChanged = true; if (terrainFields.has(name)) terrainChanged = true; if (adminFields.has(name)) adminChanged = true; } if (cellChanged) changedCells++; if (terrainChanged) terrainChangedCells++; if (adminChanged) adminChangedCells++; } } const featureKeys = [ "villages", "markets", "castles", "ports", "modernCities", "satelliteCities", "stations", "interchanges", "industrialZones", "logisticsParks", "newTowns", "adminCenters", "externalGateways", "prefectureRegions", "premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads", "railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways", "mainRivers", "tributaryRivers", "smallStreams", "prefectureBorder", "regionalPrefectureBorders", "adminBorders", ]; const featureLayersChanged = featureKeys.reduce((count, key) => count + Number(!previewValuesEqual(baseWorld.sourceMap?.[key] || [], previewWorld.sourceMap?.[key] || [])), 0); return { changedCells, terrainChangedCells, adminChangedCells, featureLayersChanged, identical: changedCells === 0 && featureLayersChanged === 0 }; } function createPatchWorker() { if (patchWorker) return patchWorker; if (typeof Worker === "undefined") { state.diagnostics.lastWorkerFallbackReason = "Worker API unavailable"; return null; } try { patchWorker = new Worker(new URL("./mapPatchWorker.js", import.meta.url), { type: "module" }); patchWorkerEpoch++; patchWorkerConstructorCount++; patchWorker.__patchWorkerEpoch = patchWorkerEpoch; patchWorker.__patchWorkerConstructorOrdinal = patchWorkerConstructorCount; const worker = patchWorker; worker.addEventListener("error", (event) => { if (patchWorker !== worker) return; const reason = event?.message || "Patch worker runtime error"; state.diagnostics.lastWorkerFallbackReason = reason; recordDiagnosticLog("warning", "Patch worker reset", reason); worker.terminate?.(); patchWorker = null; }); } catch (error) { state.diagnostics.lastWorkerFallbackReason = error?.message || "Patch worker creation failed"; patchWorker = null; } return patchWorker; } function isPlainMirrorObject(value) { if (!value || typeof value !== "object" || Array.isArray(value) || ArrayBuffer.isView(value)) return false; const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; } function preparePatchMirrorTransferValue(value) { if (ArrayBuffer.isView(value)) { if (value instanceof DataView) { const buffer = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength); return { value: new DataView(buffer), transfer: [buffer] }; } const copy = new value.constructor(value); return { value: copy, transfer: [copy.buffer] }; } if (value instanceof ArrayBuffer) { const copy = value.slice(0); return { value: copy, transfer: [copy] }; } return { value, transfer: [] }; } function patchMirrorSourceEntryNeedsChunking(value) { if (!isPlainMirrorObject(value)) return false; // The geography metadata object contains a large family of fixed-map typed // rasters. Sending that object as one structured-clone job creates a long // main-thread task even though each raster is small enough to transfer // responsively on its own. Split any source object with direct binary views. return Object.values(value).some((entry) => ArrayBuffer.isView(entry) || entry instanceof ArrayBuffer); } function buildPatchMirrorManifest(world) { const rootKeys = Object.keys(world || {}).filter((key) => key !== "fields" && key !== "sourceMap" && key !== "generatedMask"); const fieldKeys = Object.keys(world?.fields || {}); const sourceKeys = Object.keys(world?.sourceMap || {}); const expandedSourceObjects = {}; for (const key of sourceKeys) { const value = world.sourceMap[key]; if (patchMirrorSourceEntryNeedsChunking(value)) expandedSourceObjects[key] = Object.keys(value); } return { rootKeys, fieldKeys, sourceKeys, expandedSourceObjects, hasGeneratedMask: !!world?.generatedMask, }; } function validatePatchWorkerProgress(operation, progress) { if (!operation) return { ok: true, advanced: true }; const phaseOrdinal = Number(progress?.phaseOrdinal || 0); const phase = String(progress?.phase || progress?.key || "patch"); const explicitWorkUnitId = progress?.workUnitId != null && String(progress.workUnitId).length > 0; const workUnitId = String(explicitWorkUnitId ? progress.workUnitId : (progress?.key || phase)); const protocol = operation.progressProtocol || (operation.progressProtocol = { phaseOrdinal: 0, phase: null, units: new Map(), }); if (phaseOrdinal > 0 && phaseOrdinal < protocol.phaseOrdinal) { return { ok: false, reason: `Progress phase moved backward from ${protocol.phaseOrdinal} to ${phaseOrdinal}.` }; } if (phaseOrdinal > 0 && phaseOrdinal === protocol.phaseOrdinal && protocol.phase && phase !== protocol.phase) { return { ok: false, reason: `Progress phase identity changed inside ordinal ${phaseOrdinal}: ${protocol.phase} -> ${phase}.` }; } const phaseAdvanced = phaseOrdinal > protocol.phaseOrdinal || (protocol.phase == null && phase !== protocol.phase); if (phaseAdvanced) { protocol.phaseOrdinal = Math.max(protocol.phaseOrdinal, phaseOrdinal); protocol.phase = phase; } if (progress?.boundedWork !== true) { // Older or intrinsically message-based stages do not yet expose a finite // work total. Keep their heartbeat behavior, but record that they cannot // satisfy the bounded-work release gate. operation.unboundedProgressPhases ||= new Set(); operation.unboundedProgressPhases.add(phase); // Unbounded messages are telemetry only: repeated messages must never keep // a runaway cooperative stage alive. The watchdog is armed on first entry // and only a finite counter advance (or an explicit non-cooperative stage // transition) can refresh its deadline. return { ok: true, advanced: phaseAdvanced, bounded: false, phaseAdvanced }; } const completed = Number(progress.completed); const total = Number(progress.total); if (!explicitWorkUnitId) { return { ok: false, reason: `Bounded progress is missing an explicit workUnitId for ${phase}/${workUnitId}.` }; } if (!Number.isFinite(completed) || !Number.isFinite(total) || completed < 0 || total < 0 || completed > total) { return { ok: false, reason: `Invalid bounded progress for ${phase}/${workUnitId}: ${completed}/${total}.` }; } // A finite workUnitId names one bounded invocation for the complete search. // Phase changes are presentation/telemetry boundaries and must never reset // its monotonicity, otherwise a restarted producer can evade the watchdog. const unitKey = workUnitId; const previous = protocol.units.get(unitKey); if (previous && (total !== previous.total || completed < previous.completed)) { return { ok: false, reason: `Non-monotonic bounded progress for ${phase}/${workUnitId}: ${completed}/${total} after ${previous.completed}/${previous.total}.` }; } const advanced = phaseAdvanced || !previous || completed > previous.completed; protocol.units.set(unitKey, { completed, total, phase }); return { ok: true, advanced, bounded: true, phaseAdvanced }; } function runPatchInWorker(world, rect, options, operation = null) { const workerWasWarm = !!patchWorker; const worker = createPatchWorker(); if (!worker) return null; const workerEpoch = Number(worker.__patchWorkerEpoch || patchWorkerEpoch || 0); const mirrorReused = !!operation && worker.__mirrorCommittedRevision === operation.committedRevision && worker.__mirrorBaseWorld === world; if (operation) { operation.workerEpoch = workerEpoch; operation.lastWorkerEventSeq = 0; operation.progressProtocol = null; } const id = ++patchJobSeq; return new Promise((resolve, reject) => { let settled = false; let watchdogTimer = null; let dispatchMs = 0; let mirrorBuildMs = 0; let mirrorBuilt = false; let mirrorSyncSequence = 0; let mirrorSyncWaiter = null; const mirrorSyncId = `${id}:${workerEpoch}:${operation?.committedRevision ?? -1}`; const workerFailure = (message, code, recoverable = true) => { const error = new Error(message); error.name = "PatchWorkerError"; error.code = code; error.recoverable = recoverable; return error; }; const resetWatchdog = (progress = null, { advanced = true } = {}) => { // Always arm a deadline when entering work. Once armed, telemetry that // does not advance a finite counter cannot extend it indefinitely. if (!advanced && watchdogTimer != null) return; if (watchdogTimer != null) clearTimeout(watchdogTimer); const stageKey = String(progress?.phase || progress?.key || "patch"); const nonCooperative = progress?.nonCooperative === true || progress?.cooperative === false; const deadlineMs = nonCooperative ? PATCH_NON_COOPERATIVE_DEADLINE_MS : PATCH_WORKER_STALL_MS; watchdogTimer = window.setTimeout(() => { fail(workerFailure( nonCooperative ? `Patch worker did not leave non-cooperative phase ${stageKey} within ${Math.round(deadlineMs / 1000)} seconds and was stopped.` : `Patch worker bounded work stopped advancing during ${stageKey} for ${Math.round(deadlineMs / 1000)} seconds and was stopped.`, nonCooperative ? "worker-noncooperative-deadline" : "worker-stalled" )); }, deadlineMs); }; const rejectMirrorWaiter = (error) => { if (!mirrorSyncWaiter) return; const waiter = mirrorSyncWaiter; mirrorSyncWaiter = null; waiter.reject(error); }; const cleanup = (terminateWorker = false) => { if (watchdogTimer != null) { clearTimeout(watchdogTimer); watchdogTimer = null; } worker.removeEventListener("message", onMessage); worker.removeEventListener("error", onError); worker.removeEventListener("messageerror", onMessageError); if (activePatchCancel === cancelJob) activePatchCancel = null; if (mirrorSyncWaiter) { const error = new Error("Patch mirror synchronization stopped."); error.name = "AbortError"; rejectMirrorWaiter(error); } if (terminateWorker) { if (patchWorker === worker) patchWorker = null; worker.terminate?.(); } }; const succeed = (value) => { if (settled) return; settled = true; cleanup(false); resolve(value); }; const fail = (error) => { if (settled) return; settled = true; cleanup(true); reject(error instanceof Error ? error : new Error(String(error || "Patch worker failed"))); }; const cancelJob = (reason = "Patch generation cancelled by user.") => { const error = new Error(reason); error.name = "AbortError"; fail(error); }; const onMessage = async (event) => { if (event.data?.id !== id) return; if (event.data?.type === "patch-mirror-sync-ack") { if (event.data.syncId !== mirrorSyncId || !mirrorSyncWaiter) return; if (Number(event.data.sequence || 0) !== mirrorSyncWaiter.sequence) return; const waiter = mirrorSyncWaiter; mirrorSyncWaiter = null; if (event.data.ok) { resetWatchdog({ phase: "mirror-sync", key: event.data.stage || "mirror-sync", cooperative: true }); waiter.resolve(event.data); } else { waiter.reject(workerFailure(event.data.error || "Patch worker mirror synchronization failed.", "worker-mirror-sync", true)); } return; } if (operation && event.data?.searchId && event.data.searchId !== operation.searchId) return; if (operation && Number.isFinite(event.data?.workerEpoch) && event.data.workerEpoch !== operation.workerEpoch) return; if (operation && !isPatchOperationCurrent(operation)) { cancelJob("Patch generation superseded by a newer operation."); return; } if (event.data?.type === "progress") { const progress = event.data.progress || {}; let protocolResult = { ok: true, advanced: true }; if (operation) { if (progress.searchId !== operation.searchId || progress.workerEpoch !== operation.workerEpoch) return; if (Number(progress.eventSeq || 0) <= Number(operation.lastWorkerEventSeq || 0)) return; operation.lastWorkerEventSeq = Number(progress.eventSeq || 0); protocolResult = validatePatchWorkerProgress(operation, progress); if (!protocolResult.ok) { fail(workerFailure(protocolResult.reason, "worker-progress-invariant", false)); return; } operation.currentCandidateOrdinal = Number(progress.candidateOrdinal || operation.currentCandidateOrdinal || 0); operation.currentVariant = Number.isFinite(progress.variant) ? progress.variant >>> 0 : operation.currentVariant; operation.currentSeed = Number.isFinite(progress.seed) ? progress.seed >>> 0 : operation.currentSeed; if (progress.attemptSummary?.status === "rejected") { const summaries = operation.completedAttemptSummaries || (operation.completedAttemptSummaries = []); if (!summaries.some((entry) => entry.candidateId === progress.attemptSummary.candidateId)) { summaries.push(progress.attemptSummary); } } } resetWatchdog(progress, { advanced: protocolResult.advanced }); updateGenerationProgress(progress); return; } if (event.data.ok) { let previewWorld = event.data.world || null; if (!previewWorld && event.data.worldDelta) { try { // Materialize the accepted preview only after the Worker has // restored its committed mirror. This avoids keeping a second full // candidate world alive throughout production generation. // Materialize only changed fields/layers. Cloning the complete // committed world here duplicated every unchanged raster and the // complete metadata graph immediately before preview rendering. previewWorld = await materializeCommittedWorldDeltaCooperative(world, event.data.worldDelta, { consumeMetadata: true, yieldControl: yieldPatchMainThread, shouldCancel: () => settled || (operation && !isPatchOperationCurrent(operation)), chunkBytes: 4 * 1024 * 1024, }); // Verify the exact Worker-completed world before publication. The // cooperative hash is bit-identical to the Worker hash but yields // between bounded chunks, so Cancel remains serviceable while a // large padded preview is being audited. const expectedPreviewHash = event.data.result?.acceptedWorldHash || null; if (expectedPreviewHash) { const previewHash = await hashCommittedWorldAsync(previewWorld, { yieldControl: yieldPatchMainThread, shouldAbort: () => settled || (operation && !isPatchOperationCurrent(operation)), }); if (previewHash !== expectedPreviewHash) { throw new Error(`Accepted preview hash mismatch (${previewHash} != ${expectedPreviewHash}).`); } } } catch (error) { fail(workerFailure(error?.message || String(error), "preview-delta-apply-failed", false)); return; } } if (operation && Number(event.data.mirrorCommittedRevision) === operation.committedRevision) { worker.__mirrorCommittedRevision = operation.committedRevision; worker.__mirrorBaseWorld = world; } succeed({ world: previewWorld, result: event.data.result, worker: true, workerEpoch, workerStartCount: workerWasWarm ? 0 : 1, dispatchMs, mirrorBuildMs, mirrorReused, mirrorBuilt, }); } else { const terminalCode = event.data.code || "generation-error"; const recoverable = terminalCode === "worker-mirror-stale"; fail(workerFailure(event.data.error || "Patch worker failed", terminalCode, recoverable)); } }; const onError = (event) => fail(workerFailure(event.message || "Patch worker error", "worker-crash")); const onMessageError = () => fail(workerFailure("Patch worker message clone failed", "worker-message-clone", false)); worker.addEventListener("message", onMessage); worker.addEventListener("error", onError); worker.addEventListener("messageerror", onMessageError); activePatchCancel = cancelJob; if (operation && !isPatchOperationCurrent(operation)) { cancelJob("Patch generation superseded before worker launch."); return; } const accountDispatch = (startedAt) => { const elapsed = performance.now() - startedAt; dispatchMs += elapsed; if (operation) operation.maxInputDispatchMs = Math.max(Number(operation.maxInputDispatchMs || 0), elapsed); return elapsed; }; const sendMirrorSyncMessage = (type, payload = {}, transfer = []) => { if (settled) { const error = new Error("Patch mirror synchronization was cancelled."); error.name = "AbortError"; return Promise.reject(error); } const sequence = ++mirrorSyncSequence; return new Promise((resolveSync, rejectSync) => { if (mirrorSyncWaiter) { rejectSync(workerFailure("Patch mirror synchronization protocol overlap.", "worker-mirror-sync", false)); return; } mirrorSyncWaiter = { sequence, resolve: resolveSync, reject: rejectSync }; try { const startedAt = performance.now(); worker.postMessage({ id, type, syncId: mirrorSyncId, sequence, ...payload }, transfer); accountDispatch(startedAt); resetWatchdog({ phase: "mirror-sync", key: type, cooperative: true }); } catch (error) { mirrorSyncWaiter = null; rejectSync(workerFailure(error?.message || String(error), "worker-post-message", false)); } }); }; const sendMirrorValue = async (type, payload, value) => { const prepared = preparePatchMirrorTransferValue(value); await sendMirrorSyncMessage(type, { ...payload, value: prepared.value }, prepared.transfer); }; const synchronizeCommittedMirror = async () => { const startedAt = performance.now(); const manifest = buildPatchMirrorManifest(world); await sendMirrorSyncMessage("patch-mirror-sync-start", { committedRevision: operation.committedRevision, manifest, }); for (const key of manifest.rootKeys) { if (settled) throw new DOMException("Patch mirror synchronization cancelled.", "AbortError"); await sendMirrorValue("patch-mirror-sync-root", { key }, world[key]); } for (const key of manifest.fieldKeys) { if (settled) throw new DOMException("Patch mirror synchronization cancelled.", "AbortError"); await sendMirrorValue("patch-mirror-sync-field", { key }, world.fields[key]); } if (manifest.hasGeneratedMask) { await sendMirrorValue("patch-mirror-sync-generated-mask", {}, world.generatedMask); } for (const key of manifest.sourceKeys) { if (settled) throw new DOMException("Patch mirror synchronization cancelled.", "AbortError"); const value = world.sourceMap[key]; const childKeys = manifest.expandedSourceObjects[key]; if (Array.isArray(childKeys)) { await sendMirrorSyncMessage("patch-mirror-sync-source-object-start", { key }); for (const childKey of childKeys) { if (settled) throw new DOMException("Patch mirror synchronization cancelled.", "AbortError"); await sendMirrorValue("patch-mirror-sync-source-object-entry", { key, childKey }, value[childKey]); } } else { await sendMirrorValue("patch-mirror-sync-source", { key }, value); } } const finishAck = await sendMirrorSyncMessage("patch-mirror-sync-finish", {}); mirrorBuildMs = performance.now() - startedAt; mirrorBuilt = true; worker.__mirrorCommittedRevision = operation.committedRevision; worker.__mirrorBaseWorld = world; if (operation) operation.mirrorBuildMs = Math.max(Number(operation.mirrorBuildMs || 0), mirrorBuildMs); return finishAck; }; const dispatchCandidate = (reuseCommittedMirror, includeWorld = null) => { const startedAt = performance.now(); worker.postMessage({ id, world: includeWorld, rect, options, search: operation ? { searchId: operation.searchId, operationId: operation.operationId, committedRevision: operation.committedRevision, workerEpoch, executionAttempt: operation.executionAttempt || 1, totalCandidateCount: operation.candidateLimit || operation.candidatePlan.length, reuseCommittedMirror, resolvedPatchMode: operation.resolvedPatchMode, selectBestCandidate: operation.selectBestCandidate === true, draftSelection: operation.draftSelection === true, candidatePlan: operation.candidatePlan, } : null, }); accountDispatch(startedAt); resetWatchdog({ key: "candidate-dispatch", phase: "input-clone-dispatch", nonCooperative: true }); }; const launch = async () => { if (operation) { if (!mirrorReused) await synchronizeCommittedMirror(); if (settled || !isPatchOperationCurrent(operation)) { const error = new Error("Patch generation superseded before candidate dispatch."); error.name = "AbortError"; throw error; } // A synchronized mirror is now authoritative in the Worker; never send // the complete world in the candidate request. This keeps cold/rebuild // input work in sub-megabyte, cancellable main-thread chunks. dispatchCandidate(true, null); } else { // Backward-compatible internal path. Production calls always provide an // operation and therefore use the chunked persistent-mirror protocol. dispatchCandidate(false, world); } }; launch().catch((error) => { if (!settled) fail(error?.name === "AbortError" ? error : workerFailure(error?.message || String(error), error?.code || "worker-mirror-sync", error?.recoverable !== false)); }); }); } function cancelPatchGeneration({ clearSelection = false, reason = "Patch generation cancelled by user.", silentProgress = false } = {}) { if (!state.patchBusy && !activePatchCancel) return false; const cancelledOperation = activePatchOperation; patchRequestSeq++; activePatchOperation = null; const cancel = activePatchCancel; activePatchCancel = null; cancel?.(reason); patchWorker?.terminate?.(); patchWorker = null; state.patchBusy = false; state.patchBusyVariant = null; let deferredRunRecord = null; if (cancelledOperation?.baseWorld && Number.isFinite(cancelledOperation.startedAt)) { const cancelledVariant = Number.isFinite(cancelledOperation.currentVariant) ? cancelledOperation.currentVariant >>> 0 : cancelledOperation.requestedVariant; const cancelledSeed = Number.isFinite(cancelledOperation.currentSeed) ? cancelledOperation.currentSeed >>> 0 : cancelledOperation.requestedSeed; const cancelledWallMs = performance.now() - cancelledOperation.startedAt; // renderAdvancedData() is intentionally outside the trusted-input critical // path. On a long diagnostics history it can build hundreds of DOM nodes; // cancellation itself must only reject/terminate the job, unlock controls, // and preserve the committed canvas before returning to the event loop. deferredRunRecord = () => recordPatchRun( { ok: false, reason, patchMode: cancelledOperation.patchMode }, cancelledOperation.terrainType, cancelledOperation.selectionRect, cancelledVariant, { kind: "Patch preview", worker: true, wallMs: cancelledWallMs, status: reason.includes("superseded") ? "superseded" : "cancelled", seed: cancelledSeed, requestedVariant: cancelledOperation.requestedVariant, candidateOrdinal: cancelledOperation.currentCandidateOrdinal, candidateCount: cancelledOperation.candidatePlan?.length || 0, workerEpoch: cancelledOperation.workerEpoch, committedRevision: cancelledOperation.committedRevision, estimatedTileCount: cancelledOperation.estimatedTileCount, } ); } state.patchStatusMessage = reason.includes("superseded") ? "" : "Generation cancelled."; if (!silentProgress) setProgressVisible(false); if (clearSelection) hideSelectionOverlay({ discardPreview: true }); else updatePatchControls(); if (deferredRunRecord) window.setTimeout(deferredRunRecord, 0); return true; } async function generatePatchPreviewWorld(baseWorld, rect, options, operation) { const assertCurrent = () => { if (isPatchOperationCurrent(operation)) return; const error = new Error("Patch generation superseded by a newer operation."); error.name = "AbortError"; throw error; }; assertCurrent(); const requestedVariant = Number.isFinite(options.variant) ? options.variant >>> 0 : 0; const requestedSeed = derivePatchSeed(baseWorld, options.terrainType, requestedVariant); let job; let infrastructureRetries = 0; let workerExecutionCount = 0; let workerStartCount = 0; let totalDispatchMs = 0; while (!job) { assertCurrent(); const executionRecord = { executionAttempt: Math.max(1, Number(operation.executionAttempt || 1)), startedAt: performance.now(), workerEpoch: null, variants: operation.candidatePlan.map((candidate) => candidate.variant >>> 0), status: "dispatching", code: null, reason: null, }; operation.executions ||= []; operation.executions.push(executionRecord); const workerPromise = runPatchInWorker(baseWorld, rect, { ...options, seed: requestedSeed, variant: requestedVariant, maxQualityRetries: 0, }, operation); executionRecord.workerEpoch = operation.workerEpoch || null; if (!workerPromise) { const reason = state.diagnostics.lastWorkerFallbackReason || "Patch worker unavailable"; executionRecord.status = "worker-unavailable"; executionRecord.reason = reason; executionRecord.endedAt = performance.now(); executionRecord.wallMs = executionRecord.endedAt - executionRecord.startedAt; throw new Error(`${reason}. Patch generation requires Web Worker support (serve the app over HTTP/HTTPS if local file workers are blocked).`); } workerExecutionCount++; try { const execution = await workerPromise; executionRecord.endedAt = performance.now(); executionRecord.wallMs = executionRecord.endedAt - executionRecord.startedAt; executionRecord.status = execution.result?.searchStatus || (execution.result?.ok ? "succeeded" : "failed"); executionRecord.code = execution.result?.code || null; executionRecord.reason = execution.result?.reason || null; workerStartCount += Number(execution.workerStartCount || 0); totalDispatchMs += Number(execution.dispatchMs || 0); if (execution.result?.searchStatus === "infrastructure-error" && infrastructureRetries < 1) { infrastructureRetries++; patchWorker?.terminate?.(); patchWorker = null; operation.executionAttempt = infrastructureRetries + 1; continue; } // r9 quality search is batched: only three drafts are evaluated at a time. // A batch that contains no publishable, fully finalized candidate advances // to the next three variants without ever exposing a draft or a quality- // rejected production result. Structural/invariant failures remain terminal. const contentBatchExhausted = execution.result?.searchStatus === "exhausted" && ["patch-search-exhausted", "patch-draft-search-exhausted"].includes(String(execution.result?.code || "")); const allCandidatePlan = Array.isArray(operation.allCandidatePlan) ? operation.allCandidatePlan : operation.candidatePlan; const nextCandidateIndex = Math.max(0, Number(operation.nextCandidateIndex || 0)); if (contentBatchExhausted && nextCandidateIndex < allCandidatePlan.length) { operation.completedAttemptSummaries ||= []; for (const attempt of execution.result?.searchAttempts || []) { const identity = `${attempt?.candidateId || `${attempt?.variant}:${attempt?.seed}`}|${attempt?.status || "unknown"}`; if (!operation.completedAttemptSummaries.some((entry) => `${entry?.candidateId || `${entry?.variant}:${entry?.seed}`}|${entry?.status || "unknown"}` === identity)) { operation.completedAttemptSummaries.push({ ...attempt }); } } const batchSize = Math.max(1, Number(operation.candidateBatchSize || PATCH_SEARCH_BATCH_SIZE)); const batchEnd = Math.min(allCandidatePlan.length, nextCandidateIndex + batchSize); operation.candidatePlan = allCandidatePlan.slice(nextCandidateIndex, batchEnd); operation.nextCandidateIndex = batchEnd; operation.candidateBatchOrdinal = Math.max(1, Number(operation.candidateBatchOrdinal || 1)) + 1; operation.executionAttempt = Math.max(1, Number(operation.executionAttempt || 1)) + 1; if (progressStageEl) { const firstOrdinal = operation.candidatePlan[0]?.candidateOrdinal || (nextCandidateIndex + 1); const lastOrdinal = operation.candidatePlan[operation.candidatePlan.length - 1]?.candidateOrdinal || batchEnd; progressStageEl.textContent = `Quality batch ${operation.candidateBatchOrdinal}: candidates ${firstOrdinal}-${lastOrdinal}/${allCandidatePlan.length}; previous batch did not meet the production quality floor...`; } recordDiagnosticLog("info", "Patch quality search continuing", `No publishable candidate in quality batch ${operation.candidateBatchOrdinal - 1}; evaluating the next batch.`, { completedCandidates: nextCandidateIndex, remainingCandidates: allCandidatePlan.length - nextCandidateIndex, nextVariants: operation.candidatePlan.map((candidate) => candidate.variant >>> 0), }); continue; } job = execution; } catch (error) { executionRecord.endedAt = performance.now(); executionRecord.wallMs = executionRecord.endedAt - executionRecord.startedAt; executionRecord.status = error?.name === "AbortError" ? "cancelled" : "infrastructure-error"; executionRecord.code = error?.code || error?.name || "worker-error"; executionRecord.reason = error?.message || String(error || "worker error"); if (error?.name === "AbortError") throw error; if (error?.recoverable === true && infrastructureRetries < 1) { infrastructureRetries++; const rejectedIds = new Set((operation.completedAttemptSummaries || []).map((entry) => entry.candidateId)); const remainingPlan = operation.candidatePlan.filter((candidate) => !rejectedIds.has(candidate.candidateId)); operation.candidatePlan = remainingPlan.length ? remainingPlan : operation.candidatePlan; operation.executionAttempt = infrastructureRetries + 1; recordDiagnosticLog("warning", "Patch worker restarted", error.message || "Recoverable worker failure", { workerEpoch: operation?.workerEpoch || 0, executionAttempt: operation.executionAttempt, remainingVariants: operation.candidatePlan.map((candidate) => candidate.variant), }); continue; } const reason = error?.message || String(error || "unknown worker failure"); state.diagnostics.lastWorkerFallbackReason = reason; recordDiagnosticLog("error", "Patch worker failed", reason, { workerEpoch: operation?.workerEpoch || 0 }); throw error; } } assertCurrent(); const mergedAttempts = []; for (const attempt of [...(operation.completedAttemptSummaries || []), ...(job.result?.searchAttempts || [])]) { const identity = `${attempt.candidateId || `${attempt.variant}:${attempt.seed}`}|${attempt.status}`; if (!mergedAttempts.some((entry) => entry._identity === identity)) mergedAttempts.push({ ...attempt, _identity: identity }); } if (job.result) { job.result.searchAttempts = mergedAttempts.map(({ _identity, ...attempt }) => attempt); job.result.candidateCount = operation.candidateLimit || job.result.candidateCount; job.result.executions = (operation.executions || []).map((execution) => ({ ...execution })); } job.actualVariant = Number.isFinite(job.result?.actualVariant) ? job.result.actualVariant >>> 0 : Number.isFinite(job.result?.variant) ? job.result.variant >>> 0 : requestedVariant; job.actualSeed = Number.isFinite(job.result?.actualSeed) ? job.result.actualSeed >>> 0 : Number.isFinite(job.result?.seed) ? job.result.seed >>> 0 : requestedSeed; job.qualityWorkerRetryCount = 0; job.workerRestartCount = infrastructureRetries; job.workerExecutionCount = workerExecutionCount; job.workerStartCount = workerStartCount; job.dispatchMs = totalDispatchMs; if (job.result) { job.result.requestedVariant = requestedVariant; job.result.actualVariant = job.actualVariant; job.result.actualSeed = job.actualSeed; job.result.qualityWorkerRetryCount = 0; job.result.workerRestartCount = infrastructureRetries; job.result.workerExecutionCount = workerExecutionCount; job.result.workerStartCount = job.workerStartCount; job.result.inputDispatchMs = Number(job.dispatchMs || 0); job.result.inputMirrorReused = job.mirrorReused === true; } return job; } async function generateSelectedPatch(kind = "Patch preview") { if (state.patchBusy || state.fullGenerationBusy) return; const patchStartedAt = performance.now(); const validation = validatePatchRect(state.selectionRect, state.world); if (!validation.ok) { recordDiagnosticLog("warning", "Invalid patch selection", validation.reason || "Selection is not valid."); updatePatchControls(); return; } const baseWorld = state.world; const requestId = ++patchRequestSeq; const requestedTerrainType = patchTerrainTypeInput?.value || generationTypeInput?.value || "auto"; const inheritedTerrainType = baseWorld?.sourceMap?.terrainTemplate?.terrainType || baseWorld?.sourceMap?.terrainDebug?.terrainType || (state.generationType !== "auto" ? state.generationType : null); // "Auto" on an expansion means continue the existing world's generator // profile. It must not select a different template for the pasted area. const terrainType = requestedTerrainType === "auto" && inheritedTerrainType ? inheritedTerrainType : requestedTerrainType; const patchMode = patchModeInput?.value || "auto"; const resolvedPatchRects = buildPatchRects(validation.rect, baseWorld, { patchMode, _geometryOnly: true }); const resolvedPatchMode = resolvedPatchRects.patchMode || patchMode; const candidateWindowSignature = [ resolvedPatchRects.coreRect?.x0, resolvedPatchRects.coreRect?.y0, resolvedPatchRects.coreRect?.x1, resolvedPatchRects.coreRect?.y1, resolvedPatchRects.writeRect?.x0, resolvedPatchRects.writeRect?.y0, resolvedPatchRects.writeRect?.x1, resolvedPatchRects.writeRect?.y1, ].join(","); const variant = readPatchVariant(); const searchPlan = buildPatchCandidatePlan( baseWorld, validation.rect, terrainType, patchMode, resolvedPatchMode, candidateWindowSignature, variant, { explicitFirst: kind !== "Patch alternative" } ); const seed = searchPlan.plan[0]?.seed ?? derivePatchSeed(baseWorld, terrainType, variant); const operation = { requestId, operationId: `patch-operation-${requestId}`, searchId: `${searchPlan.contextId}|search-${requestId}`, searchContextId: searchPlan.contextId, committedRevision: state.committedRevision, baseWorld, selectionRevision: state.selectionRevision, selectionSignature: selectionSignature(state.selectionRect), requestedTerrainType, terrainType, patchMode, resolvedPatchMode, candidateWindowSignature, generatorPolicyVersion: "quality-batched-production-search-v4", qualityPolicyVersion: "initial-quality-oracle-transport-parity-v3", selectBestCandidate: true, draftSelection: true, requestedVariant: variant, requestedSeed: seed, currentVariant: variant, currentSeed: seed, currentCandidateOrdinal: 0, executionAttempt: 1, completedAttemptSummaries: [], executions: [], candidateLimit: searchPlan.candidateLimit, candidateBatchSize: PATCH_SEARCH_BATCH_SIZE, allCandidatePlan: searchPlan.plan, candidatePlan: searchPlan.plan.slice(0, PATCH_SEARCH_BATCH_SIZE), nextCandidateIndex: Math.min(PATCH_SEARCH_BATCH_SIZE, searchPlan.plan.length), candidateBatchOrdinal: 1, estimatedTileCount: searchPlan.estimatedTileCount, includeSeamVisualization: state.showSeamDiagnostics, selectionRect: validation.rect, startedAt: patchStartedAt, }; activePatchOperation = operation; state.patchBusy = true; state.patchBusyVariant = variant; state.patchStatusMessage = ""; updatePatchControls(); setProgressVisible(true, `Generating quality batch 1 (${operation.candidatePlan.length} lightweight drafts, up to ${searchPlan.plan.length} candidates); only fully finalized candidates can be previewed...`); await nextFrame(); try { if (!isPatchOperationCurrent(operation)) return; const job = await generatePatchPreviewWorld(baseWorld, validation.rect, { terrainType, patchMode, seed, variant, // Three lightweight drafts are ranked first. Only the top draft normally // runs administration, final transport, merge repair, seam audit, delta, // and hash construction. Structural/invariant failures remain fatal. maxQualityRetries: 0, qualityTerrainAttempts: 1, acceptBestAvailableQuality: false, includeSeamVisualization: operation.includeSeamVisualization, }, operation); if (!isPatchOperationCurrent(operation)) return; const result = job.result; const searchAttempts = Array.isArray(result?.searchAttempts) ? result.searchAttempts : []; const lastAttempt = searchAttempts[searchAttempts.length - 1] || null; const actualVariant = Number.isFinite(job.actualVariant) ? job.actualVariant : Number.isFinite(lastAttempt?.variant) ? lastAttempt.variant : (Number.isFinite(result?.variant) ? result.variant : variant); const actualSeed = Number.isFinite(job.actualSeed) ? job.actualSeed : Number.isFinite(lastAttempt?.seed) ? lastAttempt.seed : (Number.isFinite(result?.seed) ? result.seed : seed); consumePatchSearchAttempts(operation.searchContextId, searchAttempts.filter((attempt) => attempt?.status === "rejected")); if (Number.isFinite(result?.nextVariant)) patchSearchSeries.nextVariant = result.nextVariant >>> 0; if (!result.ok) { const reason = result.reason || result.code || "invalid selection"; const showing = state.pendingPatch?.variant; const rejectedVariants = searchAttempts.filter((attempt) => attempt.status === "rejected").map((attempt) => attempt.variant); const attemptText = rejectedVariants.length ? ` Tried variants ${rejectedVariants.join(", ")}.` : ""; state.patchStatusMessage = showing == null ? `${reason}; no preview was applied.${attemptText}` : `${reason}; still showing preview variant ${showing}.${attemptText}`; const runStatus = result.searchStatus === "exhausted" ? "search-exhausted" : result.searchStatus === "invariant-breach" ? "invariant-breach" : result.searchStatus === "execution-error" ? "execution-error" : "rejected"; recordPatchRun(result, terrainType, validation.rect, actualVariant, { kind, worker: job.worker, wallMs: performance.now() - patchStartedAt, status: runStatus, seed: actualSeed, requestedVariant: variant, workerStartCount: job.workerStartCount, candidateOrdinal: lastAttempt?.candidateOrdinal || 0, candidateCount: searchPlan.plan.length, workerEpoch: job.workerEpoch, committedRevision: operation.committedRevision, inputDispatchMs: job.dispatchMs, inputMirrorReused: job.mirrorReused, estimatedTileCount: operation.estimatedTileCount, }); recordDiagnosticLog("warning", "Patch search did not publish a preview", reason, { kind, terrainType, variant: actualVariant, requestedVariant: variant, seed: actualSeed, searchStatus: result.searchStatus, attempts: searchAttempts, }); finishProgress(state.patchStatusMessage, result.patchTimings || [], 1800); updatePatchControls(); return; } // The worker already owns both the immutable baseline and accepted world, // so it performs the complete raster/feature comparison before transfer. // Keep the local path only for backward-compatible worker results. const previewDelta = result.previewDelta || previewPatchDelta(baseWorld, job.world, result.rects || validation.rect) || { changedCells: 0, terrainChangedCells: 0, adminChangedCells: 0, featureLayersChanged: 0, identical: true, }; result.previewDelta = previewDelta; result.baseCommittedRevision = operation.committedRevision; result.publicationStatus = "staged"; const stagedPatch = { world: job.world, result, rect: validation.rect, terrainType, seed: actualSeed, variant: actualVariant, requestedVariant: variant, worker: job.worker, previewDelta, operationId: requestId, baseCommittedRevision: operation.committedRevision, }; if (progressStageEl) progressStageEl.textContent = `Rendering preview variant ${actualVariant}...`; const renderStartedAt = performance.now(); const stagedRenderBundle = await stagePreviewRenderBundle(job.world, actualVariant, operation); if (!stagedRenderBundle || !isPatchOperationCurrent(operation)) return; publishPreviewRenderBundle(stagedRenderBundle, stagedPatch); result.publicationStatus = "published"; operation.searchStatus = "published"; const renderMs = performance.now() - renderStartedAt; consumePatchSearchAttempts(operation.searchContextId, searchAttempts); patchSearchSeries.nextVariant = Number.isFinite(result.nextVariant) ? result.nextVariant >>> 0 : ((actualVariant + 1) >>> 0); setPatchVariant(actualVariant, { update: false }); recordPatchRun(result, terrainType, validation.rect, actualVariant, { kind, worker: job.worker, wallMs: performance.now() - patchStartedAt, renderMs, status: "success", seed: actualSeed, requestedVariant: variant, workerStartCount: job.workerStartCount, candidateOrdinal: result.candidateOrdinal, candidateCount: result.candidateCount, workerEpoch: job.workerEpoch, committedRevision: operation.committedRevision, inputDispatchMs: job.dispatchMs, inputMirrorReused: job.mirrorReused, estimatedTileCount: operation.estimatedTileCount, }); state.patchStatusMessage = !previewDelta.identical ? `Highest-quality candidate ${result.candidateOrdinal || 1}/${result.candidateCount || searchPlan.plan.length}, variant ${actualVariant}, is displayed (render revision ${job.world.renderRevision}); ${previewDelta.changedCells.toLocaleString()} cells and ${previewDelta.featureLayersChanged.toLocaleString()} feature layers differ from the committed map.` : `Variant ${actualVariant} completed but is identical to the committed map in the audited change scope.`; updatePatchControls(); const modeText = `${result.patchMode || patchMode} / ${result.patchGenerationMode}`; const quality = result.candidateQuality; const qualityText = quality ? ` / quality ${quality.hardPass ? "PASS" : "FAIL"} ${Number(quality.score || 0).toFixed(3)} / selected terrain variant ${Number(quality.selectedVariant || 0)}` : ""; const retryText = job.qualityWorkerRetryCount ? ` / quality retries ${job.qualityWorkerRetryCount}` : ""; const searchText = ` / best candidate ${result.candidateOrdinal || 1}/${result.candidateCount || searchPlan.plan.length} / evaluated ${searchAttempts.length || 1}`; finishProgress(`Preview variant ${actualVariant} displayed${job.worker ? " from worker" : ""}: ${previewDelta.changedCells.toLocaleString()} changed cells + ${previewDelta.featureLayersChanged.toLocaleString()} changed feature layers / end-to-end ${formatMs(performance.now() - patchStartedAt)} / dispatch ${formatMs(job.dispatchMs || 0)} / render ${formatMs(renderMs)}${searchText}${retryText}${qualityText} / ${modeText}.`, result.patchTimings || [], 1400); } catch (error) { if (error?.name === "AbortError") { if (requestId === patchRequestSeq) { state.patchStatusMessage = "Generation cancelled."; recordPatchRun({ ok: false, reason: error.message || "cancelled", patchMode }, terrainType, validation.rect, operation.currentVariant ?? variant, { kind, worker: true, wallMs: performance.now() - patchStartedAt, status: "cancelled", seed: operation.currentSeed ?? seed, requestedVariant: variant, candidateOrdinal: operation.currentCandidateOrdinal, candidateCount: operation.candidatePlan.length, workerEpoch: operation.workerEpoch, committedRevision: operation.committedRevision, estimatedTileCount: operation.estimatedTileCount, }); updatePatchControls(); finishProgress(state.patchStatusMessage, null, 1200); } return; } if (requestId !== patchRequestSeq) return; const reason = error?.message || String(error || "unknown patch error"); const showing = state.pendingPatch?.variant; state.patchStatusMessage = showing == null ? `Variant ${variant} failed (${reason}); no preview was applied.` : `Variant ${variant} failed (${reason}); still showing preview variant ${showing}.`; recordPatchRun({ ok: false, reason, patchMode }, terrainType, validation.rect, operation.currentVariant ?? variant, { kind, worker: true, wallMs: performance.now() - patchStartedAt, status: "error", seed: operation.currentSeed ?? seed, requestedVariant: variant, candidateOrdinal: operation.currentCandidateOrdinal, candidateCount: operation.candidatePlan.length, workerEpoch: operation.workerEpoch, committedRevision: operation.committedRevision, estimatedTileCount: operation.estimatedTileCount, }); recordDiagnosticLog("error", "Patch exception", reason, { kind, terrainType, variant }); finishProgress(state.patchStatusMessage, null, 1800); console.error("Patch generation failed", error); } finally { if (requestId === patchRequestSeq) { state.patchBusy = false; state.patchBusyVariant = null; if (activePatchOperation === operation) activePatchOperation = null; updatePatchControls(); } } } async function generateAlternativePatch() { if (state.patchBusy || state.fullGenerationBusy) return; const validation = validatePatchRect(state.selectionRect, state.world); if (!validation.ok) { updatePatchControls(); return; } const nextVariant = Number.isFinite(patchSearchSeries.nextVariant) ? patchSearchSeries.nextVariant >>> 0 : (readPatchVariant() + 1) >>> 0; setPatchVariant(nextVariant, { update: false }); await generateSelectedPatch("Patch alternative"); } function previewRenderOptions() { return { mode: state.mode, showFeatures: state.showFeatures, showLabels: state.showLabels, showSeamDiagnostics: state.showSeamDiagnostics, continuousTerrain: true, fastTerrain: false, zoom: state.zoom || 1, }; } async function stagePreviewRenderBundle(world, variant, operation) { const startedAt = performance.now(); const viewSize = viewportSizeForZoom(state.zoom); const camera = clampCameraForView(state.camera, viewSize, world); await nextFrame(); if (!isPatchOperationCurrent(operation)) return null; const viewportStartedAt = performance.now(); const viewportMap = getViewportMap(world, camera, viewSize.width, viewSize.height, { light: false }); const viewportMs = performance.now() - viewportStartedAt; await nextFrame(); if (!isPatchOperationCurrent(operation)) return null; const hoverStartedAt = performance.now(); const hoverEntities = buildHoverEntities(viewportMap); const hoverMs = performance.now() - hoverStartedAt; const frame = document.createElement("canvas"); const drawStartedAt = performance.now(); const renderBreakdown = await drawMapCooperative(frame, viewportMap, previewRenderOptions(), { yieldControl: yieldPatchMainThread, shouldCancel: () => !isPatchOperationCurrent(operation), }); const drawMs = performance.now() - drawStartedAt; await nextFrame(); if (!isPatchOperationCurrent(operation)) return null; return { world, variant, revision: state.renderRevision + 1, viewSize, camera, viewportMap, hoverEntities, frame, timings: { viewportMs, hoverMs, drawMs, totalRenderMs: performance.now() - startedAt, renderBreakdown, }, }; } function publishPreviewRenderBundle(bundle, stagedPatch) { const previous = { pendingPatch: state.pendingPatch, viewportMap: state.viewportMap, hoverEntities: state.hoverEntities, renderRevision: state.renderRevision, camera: state.camera, viewWidth: state.viewWidth, viewHeight: state.viewHeight, patchStatusMessage: state.patchStatusMessage, diagnostics: state.diagnostics.lastViewport, featureCounts: state.diagnostics.lastFeatureCounts, }; try { bundle.world.renderRevision = bundle.revision; bundle.world.previewVariant = bundle.variant; state.pendingPatch = stagedPatch; state.viewportMap = bundle.viewportMap; state.hoverEntities = bundle.hoverEntities; state.renderRevision = bundle.revision; state.camera = bundle.camera; state.viewWidth = bundle.viewSize.width; state.viewHeight = bundle.viewSize.height; const visibleContext = canvas.getContext("2d"); if (!visibleContext) throw new Error("Visible canvas context is unavailable."); canvas.width = bundle.frame.width; canvas.height = bundle.frame.height; visibleContext.drawImage(bundle.frame, 0, 0); applyCanvasZoom(); updateRenderDiagnostics({ fastTerrain: false }, bundle.timings); renderStats(displaySourceMap()); renderAdvancedData(); updatePatchControls(); return previous; } catch (error) { state.pendingPatch = previous.pendingPatch; state.viewportMap = previous.viewportMap; state.hoverEntities = previous.hoverEntities; state.renderRevision = previous.renderRevision; state.camera = previous.camera; state.viewWidth = previous.viewWidth; state.viewHeight = previous.viewHeight; state.patchStatusMessage = previous.patchStatusMessage; state.diagnostics.lastViewport = previous.diagnostics; state.diagnostics.lastFeatureCounts = previous.featureCounts; // Rebuild the old frame from the restored state only on the exceptional // path. Keeping a full-size rollback canvas during every successful preview // doubled peak raster memory for no user-visible benefit. try { redraw({ allowWorldExpand: false }); updatePatchControls(); } catch { // Preserve the original publication error; state has already rolled back. } throw error; } } function redraw(options = {}) { const redrawStartedAt = performance.now(); const renderWorld = displayWorld(); if (!renderWorld) return; const viewSize = syncViewportSize(); // Patch workers clone world coordinates at launch. Expanding the committed // world while a patch is running shifts origin/fields in-place and makes the // returning preview use a stale coordinate frame. Defer automatic expansion // until generation has finished. const mayExpand = !state.pendingPatch && !state.patchBusy && !state.fullGenerationBusy && options.allowWorldExpand !== false; const expansion = mayExpand ? ensureWorldPaddingForCamera(state.world, state.camera, viewSize.width, viewSize.height) : null; if (expansion?.expanded) { advanceCommittedRevision(); const ex = expansion.dx || 0; const ey = expansion.dy || 0; state.diagnostics.worldExpansionCount += 1; state.diagnostics.lastWorldExpansion = { at: new Date(), dx: ex, dy: ey, worldWidth: state.world?.width || 0, worldHeight: state.world?.height || 0, viewWidth: viewSize.width, viewHeight: viewSize.height, }; state.camera = { x: (state.camera?.x || 0) + ex, y: (state.camera?.y || 0) + ey }; if (dragState.mode === "pan") { dragState.startCameraX += ex; dragState.startCameraY += ey; if (dragState.pendingCamera) dragState.pendingCamera = { x: dragState.pendingCamera.x + ex, y: dragState.pendingCamera.y + ey }; } if (state.selectionRect) { replaceSelectionRect({ ...state.selectionRect, x0: state.selectionRect.x0 + ex, y0: state.selectionRect.y0 + ey, x1: state.selectionRect.x1 + ex, y1: state.selectionRect.y1 + ey, polygon: Array.isArray(state.selectionRect.polygon) ? state.selectionRect.polygon.map((p) => ({ x: p.x + ex, y: p.y + ey })) : state.selectionRect.polygon, }); } state.lastPatchResult = state.world?.lastPatchResult || null; } const activeWorldForRender = displayWorld(); state.camera = clampCameraForView(state.camera, viewSize, activeWorldForRender); const viewportStartedAt = performance.now(); state.viewportMap = getViewportMap(activeWorldForRender, state.camera, viewSize.width, viewSize.height, { light: !!options.fastTerrain }); const viewportMs = performance.now() - viewportStartedAt; const hoverStartedAt = performance.now(); state.hoverEntities = options.fastTerrain ? [] : buildHoverEntities(state.viewportMap); const hoverMs = performance.now() - hoverStartedAt; const drawStartedAt = performance.now(); const renderBreakdown = drawMap(canvas, state.viewportMap, { mode: state.mode, showFeatures: state.showFeatures && !options.fastTerrain, showLabels: state.showLabels && !options.fastTerrain, showSeamDiagnostics: state.showSeamDiagnostics && !options.fastTerrain, continuousTerrain: !options.fastTerrain, fastTerrain: !!options.fastTerrain, zoom: state.zoom || 1, }); const drawMs = performance.now() - drawStartedAt; applyCanvasZoom(); if (state.selectionRect && dragState.mode !== "select") updateSelectionOverlayFromWorldRect(); updateRenderDiagnostics(options, { viewportMs, hoverMs, drawMs, totalRenderMs: performance.now() - redrawStartedAt, renderBreakdown, }); renderAdvancedData(); } function canvasDigestForE2E() { const context = canvas?.getContext?.("2d", { willReadFrequently: true }); if (!context || !canvas.width || !canvas.height) return null; const bytes = context.getImageData(0, 0, canvas.width, canvas.height).data; let hash = 2166136261 >>> 0; // This path exists only behind ?additionalGenerationE2E=1. Hash every pixel // byte so atomic-publication tests cannot miss a localized stale/partial // render because of sampling stride aliasing. for (let index = 0; index < bytes.length; index++) { hash ^= bytes[index]; hash = Math.imul(hash, 16777619) >>> 0; } return `${canvas.width}x${canvas.height}:${hash.toString(16).padStart(8, "0")}`; } function installAdditionalGenerationE2EHarness() { if (new URLSearchParams(location.search).get("additionalGenerationE2E") !== "1") return; window.__additionalGenerationE2EProgress = []; window.__additionalGenerationE2ECancelTiming = null; const snapshot = () => ({ ready: !!state.world && !state.fullGenerationBusy, worldWidth: state.world?.width || 0, worldHeight: state.world?.height || 0, originX: state.world?.originX || 0, originY: state.world?.originY || 0, fullGenerationBusy: state.fullGenerationBusy, patchBusy: state.patchBusy, pending: !!state.pendingPatch, pendingVariant: state.pendingPatch?.variant ?? null, pendingSeed: state.pendingPatch?.seed ?? null, publicationStatus: state.pendingPatch?.result?.publicationStatus || null, searchStatus: state.pendingPatch?.result?.searchStatus || null, acceptedWorldHash: state.pendingPatch?.result?.acceptedWorldHash || null, committedRevision: state.committedRevision, workerMirrorRevision: patchWorker?.__mirrorCommittedRevision ?? null, patchStatus: state.patchStatusMessage, progressText: progressStageEl?.textContent || "", statsText: statsEl?.textContent || "", diagnosticsText: advancedPatchDiagnosticsEl?.textContent || "", seamText: advancedSeamDiagnosticsEl?.textContent || "", canvasDigest: canvasDigestForE2E(), lastRun: state.patchRuns[state.patchRuns.length - 1] || null, progressEvents: [...window.__additionalGenerationE2EProgress], }); window.__additionalGenerationE2E = { snapshot, configure({ rect, patchMode = "regeneration", terrainType = "auto", variant = 0, seamDiagnostics = false } = {}) { if (!state.world || state.fullGenerationBusy || state.patchBusy) throw new Error("Application is not ready for patch configuration."); discardPendingPatch({ redrawAfter: false }); window.__additionalGenerationE2EProgress.length = 0; window.__additionalGenerationE2ECancelTiming = null; patchModeInput.value = patchMode; patchTerrainTypeInput.value = terrainType; showSeamDiagnosticsInput.checked = seamDiagnostics; state.showSeamDiagnostics = seamDiagnostics; setPatchVariant(variant, { update: false }); replaceSelectionRect(rect); updatePatchControls(); return snapshot(); }, async generate() { await generateSelectedPatch("Browser E2E preview"); return snapshot(); }, async alternative() { await generateAlternativePatch(); return snapshot(); }, cancel() { cancelPatchGeneration({ reason: "Browser E2E cancellation." }); return snapshot(); }, async apply() { if (!commitPendingPatch({ redrawAfter: true })) throw new Error("No preview is available to Apply."); const deadline = performance.now() + 30_000; while (patchWorker && patchWorker.__mirrorCommittedRevision !== state.committedRevision && performance.now() < deadline) { await new Promise((resolve) => window.setTimeout(resolve, 25)); } return snapshot(); }, discard() { discardPendingPatch({ redrawAfter: true }); return snapshot(); }, }; } function init() { renderModeButtons(); setToolMode("pan"); generateMapButton?.addEventListener("click", regenerate); seedInput.addEventListener("change", regenerate); seedInput.addEventListener("keydown", (event) => { if (event.key === "Enter") regenerate(); }); generationTypeInput?.addEventListener("change", regenerate); patchTerrainTypeInput?.addEventListener("change", () => { if (state.patchBusy) cancelPatchGeneration({ reason: "Patch generation cancelled because Terrain changed." }); if (state.fullGenerationBusy) return; discardPendingPatch({ redrawAfter: true }); state.lastPatchResult = null; state.patchStatusMessage = ""; resetPatchVariant({ update: false }); updatePatchControls(); }); patchModeInput?.addEventListener("change", () => { if (state.patchBusy) cancelPatchGeneration({ reason: "Patch generation cancelled because Mode changed." }); if (state.fullGenerationBusy) return; discardPendingPatch({ redrawAfter: true }); state.lastPatchResult = null; state.patchStatusMessage = ""; updatePatchControls(); renderAdvancedData(); }); patchVariantInput?.addEventListener("change", () => { if (state.patchBusy) cancelPatchGeneration({ reason: "Patch generation cancelled because Variant changed." }); if (state.fullGenerationBusy) return; discardPendingPatch({ redrawAfter: true }); state.patchStatusMessage = ""; setPatchVariant(patchVariantInput.value); }); patchVariantInput?.addEventListener("keydown", (event) => { if (event.key === "Enter") { setPatchVariant(patchVariantInput.value); generateSelectedPatch(); } }); generatePatchButton?.addEventListener("click", () => generateSelectedPatch("Patch preview")); alternativePatchButton?.addEventListener("click", generateAlternativePatch); applyPatchButton?.addEventListener("click", () => hideSelectionOverlay({ commitPreview: true })); discardPatchButton?.addEventListener("click", () => hideSelectionOverlay({ discardPreview: true })); clearPatchSelectionButton?.addEventListener("click", () => hideSelectionOverlay({ discardPreview: true })); cancelPatchButton?.addEventListener("click", (event) => { const handlerStartedAt = performance.now(); const inputTimestamp = Number.isFinite(Number(event?.timeStamp)) ? Number(event.timeStamp) : handlerStartedAt; const cancelled = cancelPatchGeneration(); const cancelledAt = performance.now(); if (Object.prototype.hasOwnProperty.call(window, "__additionalGenerationE2ECancelTiming")) { window.__additionalGenerationE2ECancelTiming = { isTrusted: event?.isTrusted === true, inputTimestamp, handlerStartedAt, cancelledAt, inputToHandlerMs: Math.max(0, handlerStartedAt - inputTimestamp), handlerToCancelledMs: Math.max(0, cancelledAt - handlerStartedAt), inputToCancelledMs: Math.max(0, cancelledAt - inputTimestamp), cancelled, patchBusyAfter: state.patchBusy, }; } }); toolPanButton?.addEventListener("click", () => setToolMode("pan")); toolPatchButton?.addEventListener("click", () => setToolMode("patch")); copyImportantDataButton?.addEventListener("click", copyImportantDebugData); zoomInButton?.addEventListener("click", () => setZoomKeepingCenter((state.zoom || 1) * 1.2)); zoomOutButton?.addEventListener("click", () => setZoomKeepingCenter((state.zoom || 1) / 1.2)); zoomResetButton?.addEventListener("click", () => setZoomKeepingCenter(1)); centerMapButton?.addEventListener("click", recenterMap); randomSeedButton.addEventListener("click", () => { seedInput.value = String(Math.floor(Math.random() * 9999999)); regenerate(); }); showFeaturesInput.addEventListener("change", () => { state.showFeatures = showFeaturesInput.checked; redraw(); }); showLabelsInput.addEventListener("change", () => { state.showLabels = showLabelsInput.checked; redraw(); }); showSeamDiagnosticsInput?.addEventListener("change", () => { if (state.patchBusy) cancelPatchGeneration({ reason: "Patch generation cancelled because seam visualization changed." }); state.showSeamDiagnostics = showSeamDiagnosticsInput.checked; redraw({ fastTerrain: false, allowWorldExpand: false }); }); canvasShell?.setAttribute("tabindex", "0"); canvas.addEventListener("contextmenu", (event) => event.preventDefault()); canvas.addEventListener("wheel", handleCanvasWheel, { passive: false }); canvas.addEventListener("pointerdown", handleMapPointerDown); canvas.addEventListener("pointermove", handleMapPointerMove); canvas.addEventListener("pointerup", handleMapPointerUp); canvas.addEventListener("pointercancel", handleMapPointerUp); canvas.addEventListener("mousemove", updateTooltip); canvas.addEventListener("mouseleave", () => { tooltipEl?.classList.remove("visible"); }); window.addEventListener("keydown", (event) => { if (event.key !== "Escape") return; if (state.patchBusy) { cancelPatchGeneration(); event.preventDefault(); return; } if (!state.selectionRect) return; hideSelectionOverlay({ discardPreview: true }); event.preventDefault(); }); let resizeRaf = null; window.addEventListener("resize", () => { if (resizeRaf) cancelAnimationFrame(resizeRaf); resizeRaf = requestAnimationFrame(() => { resizeRaf = null; if (state.world) redraw({ allowWorldExpand: false }); else applyCanvasZoom(); }); }); updatePatchControls(); renderAdvancedData(); installAdditionalGenerationE2EHarness(); regenerate(); } init();