map/src/app.js
2026-08-11 21:51:07 +09:00

3983 lines
179 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = `<polygon points="${pts}" />`;
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 = `
<span>${metric.label}</span>
<strong>${metric.value}</strong>
${metric.sub ? `<small>${metric.sub}</small>` : ""}
`;
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 = `
<span>${row.label}</span>
<strong>${row.value}</strong>
${row.sub ? `<small>${row.sub}</small>` : ""}
`;
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 = `
<span>${time}</span>
<strong>${entry.title}</strong>
<p>${entry.message || "-"}</p>
`;
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 = `
<span class="perf-rank">#${index + 1}</span>
<span>${name}</span>
<strong>${formatMs(run.totalMs)}</strong>
<span>${formatAreaCells(run.areaCells)}${options.patch && run.writeAreaCells ? ` / write ${formatAreaCells(run.writeAreaCells)}` : ""}</span>
<span>${formatSeconds(run.secondsPerThousand)} / 1k cells${run.areaCells && run.areaCells < 1000 ? " (fixed overhead)" : ""}</span>
`;
const body = document.createElement("div");
body.className = "timing-grid";
if (options.patch) {
const status = document.createElement("div");
status.className = "timing-pill meta";
status.innerHTML = `<span>Status</span><strong>${String(run.status || "-").toUpperCase()}</strong>`;
const candidate = document.createElement("div");
candidate.className = "timing-pill meta";
candidate.innerHTML = `<span>Candidate</span><strong>${run.candidateOrdinal || "-"}/${run.candidateCount || "-"}</strong>`;
const meta = document.createElement("div");
meta.className = "timing-pill meta";
meta.innerHTML = `<span>Variant</span><strong>${run.variant ?? "-"}</strong>`;
const worker = document.createElement("div");
worker.className = "timing-pill meta";
worker.innerHTML = `<span>Worker</span><strong>${run.worker ? "yes" : "no"}</strong>`;
const ratio = document.createElement("div");
ratio.className = "timing-pill meta";
ratio.innerHTML = `<span>Write / selection</span><strong>${formatPercent((run.writeRatio || 0) * 100)}</strong>`;
const candidateMode = document.createElement("div");
candidateMode.className = "timing-pill meta";
candidateMode.innerHTML = `<span>Candidate</span><strong>${run.patchGenerationMode || "-"}</strong>`;
const seam = document.createElement("div");
seam.className = "timing-pill meta";
seam.innerHTML = `<span>Seam</span><strong>${String(run.seamStatus || "-").toUpperCase()}</strong>`;
const budget = document.createElement("div");
budget.className = "timing-pill meta";
budget.innerHTML = `<span>Budget</span><strong>${run.performanceBudgetMet ? "PASS" : "FAIL"} / ${formatMs(run.performanceBudgetMs)}</strong>`;
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 = `<span>${row.label}</span><strong>${formatMs(row.ms)}</strong>`;
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 = `
<span>${interactionGroupLabel(group.action, group.phase)}</span>
<small>n=${stats.count}</small>
<strong>${formatMs(stats.avg)}</strong>
<strong>${formatMs(stats.max)}</strong>
<strong>${formatMs(stats.p95)}</strong>
`;
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 = `
<span>${run.kind}</span>
<strong>${formatMs(run.ms)}</strong>
<span>${Number(run.zoom || 1).toFixed(2)}x</span>
<span>${run.fast ? "fast" : "full"}</span>
`;
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 = [
`<strong>${entityTitle}</strong>`,
`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("<br>");
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();