map/src/app.js

3983 lines
179 KiB
JavaScript
Raw Normal View History

2026-08-10 13:59:33 +09:00
import { drawMap, drawMapCooperative } from "./renderer.js";
2026-05-24 17:38:51 +09:00
import { landuseLabel } from "./landuseCodes.js";
2026-08-08 17:41:30 +09:00
import { CELL_SIZE, MAP_H, MAP_W, worldIndexOf } from "./mapUtils.js";
2026-05-28 23:51:55 +09:00
import { clampCameraToWorld, createInitialCamera, createWorldMap, ensureWorldPaddingForCamera } from "./worldMap.js";
2026-05-28 19:37:28 +09:00
import { getViewportMap } from "./worldViewport.js";
2026-08-10 13:59:33 +09:00
import { PATCH_MIN_AREA, PATCH_MIN_HEIGHT, PATCH_MIN_WIDTH, buildLargeExpansionTiles, buildPatchRects, validatePatchRect } from "./mapPatch.js";
import { hashCommittedWorldAsync, materializeCommittedWorldDeltaCooperative } from "./committedWorldDelta.js";
2026-05-20 13:50:56 +09:00
const modes = [
["all", "All"],
["terrain", "Terrain"],
["modern", "Modern"],
2026-05-29 23:49:02 +09:00
["history", "Premodern"],
2026-05-20 13:50:56 +09:00
["landuse", "Land Use"],
2026-05-29 23:49:02 +09:00
["admin", "Admin"],
2026-05-20 13:50:56 +09:00
];
const state = {
seedText: "114514",
2026-05-28 16:45:00 +09:00
generationType: "auto",
2026-05-20 13:50:56 +09:00
mode: "all",
2026-05-29 23:49:02 +09:00
toolMode: "pan",
2026-05-20 13:50:56 +09:00
showFeatures: true,
showLabels: true,
2026-08-08 17:41:30 +09:00
showSeamDiagnostics: true,
2026-05-20 13:50:56 +09:00
map: null,
2026-05-28 19:37:28 +09:00
world: null,
camera: { x: 0, y: 0 },
viewportMap: null,
2026-05-29 14:31:42 +09:00
viewWidth: MAP_W,
viewHeight: MAP_H,
2026-05-26 16:56:18 +09:00
hoverEntities: [],
2026-05-28 19:37:28 +09:00
selectionRect: null,
2026-05-28 23:51:55 +09:00
patchVariant: 0,
zoom: 1,
2026-05-28 19:37:28 +09:00
lastPatchResult: null,
2026-05-29 22:00:42 +09:00
pendingPatch: null,
2026-08-08 17:41:30 +09:00
patchBusy: false,
patchBusyVariant: null,
patchStatusMessage: "",
2026-08-10 13:59:33 +09:00
fullGenerationBusy: false,
selectionRevision: 0,
committedRevision: 0,
2026-08-08 17:41:30 +09:00
renderRevision: 0,
2026-05-29 23:49:02 +09:00
generationRuns: [],
patchRuns: [],
interactionRuns: [],
diagnosticLog: [],
diagnostics: {
worldExpansionCount: 0,
lastWorldExpansion: null,
lastViewport: null,
lastFeatureCounts: null,
lastWorkerUsed: null,
lastWorkerFallbackReason: null,
lastPatchWorkerKind: null,
2026-08-08 17:41:30 +09:00
lastGenerationWorkerUsed: null,
lastGenerationWorkerFallbackReason: null,
2026-05-29 23:49:02 +09:00
},
2026-05-20 13:50:56 +09:00
};
const canvas = document.getElementById("mapCanvas");
2026-05-28 00:30:09 +09:00
const canvasShell = document.querySelector(".canvas-shell");
2026-05-20 13:50:56 +09:00
const seedInput = document.getElementById("seed");
2026-05-28 16:45:00 +09:00
const generationTypeInput = document.getElementById("generationType");
2026-05-28 19:37:28 +09:00
const patchTerrainTypeInput = document.getElementById("patchTerrainType");
2026-08-08 17:41:30 +09:00
const patchModeInput = document.getElementById("patchMode");
2026-05-28 23:51:55 +09:00
const patchVariantInput = document.getElementById("patchVariant");
2026-05-28 19:37:28 +09:00
const generatePatchButton = document.getElementById("generatePatch");
2026-05-28 23:51:55 +09:00
const alternativePatchButton = document.getElementById("alternativePatch");
2026-05-28 19:37:28 +09:00
const patchStatusEl = document.getElementById("patchStatus");
2026-05-29 23:49:02 +09:00
const generateMapButton = document.getElementById("generateMap");
2026-05-20 13:50:56 +09:00
const randomSeedButton = document.getElementById("randomSeed");
2026-05-29 23:49:02 +09:00
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");
2026-08-08 17:41:30 +09:00
const clearPatchSelectionButton = document.getElementById("clearPatchSelection");
const cancelPatchButton = document.getElementById("cancelPatchGeneration");
2026-05-20 13:50:56 +09:00
const showFeaturesInput = document.getElementById("showFeatures");
const showLabelsInput = document.getElementById("showLabels");
2026-08-08 17:41:30 +09:00
const showSeamDiagnosticsInput = document.getElementById("showSeamDiagnostics");
2026-05-20 13:50:56 +09:00
const modeGrid = document.getElementById("modeGrid");
2026-05-29 23:49:02 +09:00
const mainLegendGrid = document.getElementById("mainLegendGrid");
2026-05-20 13:50:56 +09:00
const statsEl = document.getElementById("stats");
2026-05-29 23:49:02 +09:00
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");
2026-08-08 17:41:30 +09:00
const advancedSeamDiagnosticsEl = document.getElementById("advancedSeamDiagnostics");
2026-05-29 23:49:02 +09:00
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");
2026-05-20 13:50:56 +09:00
const tooltipEl = document.getElementById("mapTooltip");
2026-05-29 14:31:42 +09:00
const selectionSvgEl = document.getElementById("mapSelectionSvg");
2026-05-28 19:37:28 +09:00
const selectionEl = document.getElementById("mapSelection");
2026-05-24 19:33:09 +09:00
const progressEl = document.getElementById("generationProgress");
const progressStageEl = document.getElementById("generationProgressStage");
const progressTimingsEl = document.getElementById("generationProgressTimings");
2026-05-24 22:52:22 +09:00
let generationStartedAt = 0;
let generationCurrentStage = "";
let generationTimer = null;
2026-08-10 13:59:33 +09:00
let progressRevision = 0;
let progressHideTimer = null;
2026-05-29 14:31:42 +09:00
let zoomRedrawRaf = null;
let zoomSettledTimer = null;
2026-05-29 22:00:42 +09:00
let zoomVisualState = null;
2026-05-29 23:49:02 +09:00
let zoomLatencyStartedAt = null;
2026-05-29 22:00:42 +09:00
let patchWorker = null;
let patchJobSeq = 0;
2026-08-08 17:41:30 +09:00
let patchRequestSeq = 0;
let activePatchCancel = null;
2026-08-10 13:59:33 +09:00
let activePatchOperation = null;
let patchGeometryPreviewCache = null;
let patchWorkerEpoch = 0;
let patchWorkerConstructorCount = 0;
let patchSearchSeries = { contextId: null, consumedCandidateIds: new Set(), nextVariant: null };
2026-08-11 21:51:07 +09:00
const PATCH_SEARCH_BATCH_SIZE = 3;
const PATCH_SEARCH_DEFAULT_LIMIT = 12;
const PATCH_SEARCH_LARGE_LIMIT = 12;
2026-08-10 13:59:33 +09:00
const PATCH_WORKER_STALL_MS = 120_000;
const PATCH_NON_COOPERATIVE_DEADLINE_MS = 300_000;
2026-08-08 17:41:30 +09:00
let generationWorker = null;
let generationJobSeq = 0;
let generationRequestSeq = 0;
const generationPendingJobs = new Map();
2026-05-20 13:50:56 +09:00
2026-05-28 19:37:28 +09:00
const dragState = {
mode: null,
pointerId: null,
startClientX: 0,
startClientY: 0,
startCameraX: 0,
startCameraY: 0,
2026-05-29 18:50:54 +09:00
lastClientX: 0,
lastClientY: 0,
panRemainderX: 0,
panRemainderY: 0,
2026-05-28 19:37:28 +09:00
selectStart: null,
selectEnd: null,
2026-05-29 14:31:42 +09:00
selectPath: null,
2026-05-28 19:37:28 +09:00
pendingCamera: null,
panRaf: null,
2026-05-29 23:49:02 +09:00
panLatencyStartedAt: null,
2026-05-28 19:37:28 +09:00
};
2026-05-29 22:00:42 +09:00
function displayWorld() {
return state.pendingPatch?.world || state.world;
}
function displaySourceMap() {
return displayWorld()?.sourceMap || state.map;
}
2026-05-28 19:37:28 +09:00
function activeMap() {
2026-05-29 22:00:42 +09:00
return state.viewportMap || displaySourceMap();
2026-05-28 19:37:28 +09:00
}
2026-05-28 00:30:09 +09:00
2026-05-28 23:51:55 +09:00
function clampZoom(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 1;
return Math.min(Math.max(parsed, 0.55), 2.8);
}
2026-05-29 14:31:42 +09:00
function viewportSizeForZoom(zoom = state.zoom) {
const z = clampZoom(zoom || 1);
2026-05-28 23:51:55 +09:00
return {
2026-05-29 14:31:42 +09:00
width: Math.max(1, Math.ceil(MAP_W / z)),
height: Math.max(1, Math.ceil(MAP_H / z)),
2026-05-28 23:51:55 +09:00
};
}
2026-05-29 22:00:42 +09:00
function clampCameraForView(camera, size = viewportSizeForZoom(state.zoom), world = displayWorld()) {
return clampCameraToWorld(camera, world, size?.width || MAP_W, size?.height || MAP_H);
2026-05-29 14:31:42 +09:00
}
function syncViewportSize() {
const size = viewportSizeForZoom(state.zoom);
state.viewWidth = size.width;
state.viewHeight = size.height;
return size;
}
2026-05-29 22:00:42 +09:00
function canvasInteractionRect() {
return zoomVisualState?.baseRect || canvas.getBoundingClientRect();
}
2026-05-29 14:31:42 +09:00
function mapCellScreenSize(map = activeMap()) {
2026-05-29 22:00:42 +09:00
const rect = canvasInteractionRect();
2026-05-29 14:31:42 +09:00
const mapWidth = Math.max(1, map?.width || state.viewWidth || MAP_W);
return rect.width ? rect.width / mapWidth : CELL_SIZE * clampZoom(state.zoom || 1);
}
2026-05-28 23:51:55 +09:00
function applyCanvasZoom() {
if (!canvas) return;
state.zoom = clampZoom(state.zoom || 1);
2026-05-29 23:49:02 +09:00
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();
2026-05-28 23:51:55 +09:00
updateSelectionOverlayFromWorldRect();
}
function displayedCellSize() {
2026-05-29 14:31:42 +09:00
return mapCellScreenSize();
2026-05-28 23:51:55 +09:00
}
2026-05-29 14:31:42 +09:00
function screenPointToMapPixel(clientX, clientY, sizeOverride = null) {
2026-05-29 22:00:42 +09:00
const rect = canvasInteractionRect();
2026-05-28 23:51:55 +09:00
if (!rect.width || !rect.height) return null;
2026-05-29 14:31:42 +09:00
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 };
2026-05-28 23:51:55 +09:00
}
function mapPixelToScreenPoint(px, py) {
2026-05-29 22:00:42 +09:00
const rect = canvasInteractionRect();
2026-05-29 14:31:42 +09:00
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);
2026-05-28 23:51:55 +09:00
return {
2026-05-29 14:31:42 +09:00
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)),
2026-05-28 23:51:55 +09:00
};
}
2026-05-29 14:31:42 +09:00
function mapClientToCell(event, sizeOverride = null) {
2026-05-28 19:37:28 +09:00
const map = activeMap();
2026-05-29 14:31:42 +09:00
if (!map && !sizeOverride) return null;
const p = screenPointToMapPixel(event.clientX, event.clientY, sizeOverride);
2026-05-28 23:51:55 +09:00
if (!p) return null;
2026-05-28 00:30:09 +09:00
return {
2026-05-28 23:51:55 +09:00
x: Math.floor(p.x / CELL_SIZE),
y: Math.floor(p.y / CELL_SIZE),
2026-05-28 00:30:09 +09:00
};
}
2026-05-28 19:37:28 +09:00
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,
};
2026-05-28 00:30:09 +09:00
}
2026-05-28 19:37:28 +09:00
function clampCanvasPoint(event) {
2026-05-29 22:00:42 +09:00
const rect = canvasInteractionRect();
2026-05-28 19:37:28 +09:00
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),
};
}
2026-05-29 14:31:42 +09:00
function screenPointToWorldCell(point) {
const map = activeMap();
if (!map || !point) return null;
2026-05-29 22:00:42 +09:00
const rect = canvasInteractionRect();
2026-05-29 14:31:42 +09:00
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 = [];
2026-08-08 17:41:30 +09:00
// 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.
2026-08-10 13:59:33 +09:00
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) {
2026-08-08 17:41:30 +09:00
if (!out.length || Math.hypot(out[out.length - 1].x - p.x, out[out.length - 1].y - p.y) >= minDistance) out.push(p);
2026-05-29 14:31:42 +09:00
}
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;
2026-08-08 17:41:30 +09:00
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();
2026-05-29 14:31:42 +09:00
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))),
};
}
2026-05-29 18:50:54 +09:00
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 };
}
2026-05-29 14:31:42 +09:00
function drawSelectionSvg(points, invalid = false) {
if (!selectionSvgEl) return;
2026-05-29 18:50:54 +09:00
const bounds = syncSelectionSvgToCanvas();
if (!bounds || !points || points.length < 3) {
2026-05-29 14:31:42 +09:00
selectionSvgEl.style.display = "none";
selectionSvgEl.innerHTML = "";
return;
}
2026-05-29 18:50:54 +09:00
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(" ");
2026-05-29 14:31:42 +09:00
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");
}
2026-05-28 19:37:28 +09:00
function updateSelectionOverlay() {
2026-05-29 14:31:42 +09:00
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";
2026-05-28 19:37:28 +09:00
if (generatePatchButton) generatePatchButton.disabled = true;
2026-05-28 23:51:55 +09:00
if (alternativePatchButton) alternativePatchButton.disabled = true;
2026-05-28 19:37:28 +09:00
if (patchStatusEl) {
2026-05-29 14:31:42 +09:00
const current = validation.rect || liveShape;
2026-05-28 19:37:28 +09:00
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() {
2026-05-29 14:31:42 +09:00
if (!state.selectionRect || !state.camera || !activeMap()) return;
2026-05-28 19:37:28 +09:00
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
2026-05-29 14:31:42 +09:00
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;
}
2026-05-28 19:37:28 +09:00
const cameraX = Math.round(state.camera.x || 0);
const cameraY = Math.round(state.camera.y || 0);
2026-05-28 23:51:55 +09:00
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;
2026-05-28 19:37:28 +09:00
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";
2026-05-28 00:30:09 +09:00
return;
}
2026-05-29 14:31:42 +09:00
hideSelectionSvg();
2026-05-28 19:37:28 +09:00
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);
2026-05-29 14:31:42 +09:00
const area = Math.max(0, rect.areaCells || (w * h));
return `${w} x ${h} cells / ${area.toLocaleString()} cells`;
2026-05-28 19:37:28 +09:00
}
2026-05-28 23:51:55 +09:00
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 } = {}) {
2026-08-10 13:59:33 +09:00
resetPatchSearchSeries();
2026-05-28 23:51:55 +09:00
return setPatchVariant(0, { update });
}
2026-08-10 13:59:33 +09:00
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;
}
2026-05-28 19:37:28 +09:00
function updatePatchControls() {
2026-05-28 23:51:55 +09:00
const variant = readPatchVariant();
2026-05-29 23:49:02 +09:00
const validation = validatePatchRect(state.selectionRect, state.world);
const hasValidSelection = !!validation.ok;
const hasPreview = !!state.pendingPatch;
2026-08-10 13:59:33 +09:00
const patchBusy = !!state.patchBusy;
const fullBusy = !!state.fullGenerationBusy;
const busy = patchBusy || fullBusy;
2026-08-08 17:41:30 +09:00
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;
2026-08-10 13:59:33 +09:00
if (cancelPatchButton) cancelPatchButton.disabled = !patchBusy;
2026-08-08 17:41:30 +09:00
if (patchVariantInput) patchVariantInput.disabled = busy;
2026-08-10 13:59:33 +09:00
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;
2026-05-28 19:37:28 +09:00
if (!patchStatusEl) return;
if (!state.selectionRect) {
2026-05-29 23:49:02 +09:00
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.";
2026-05-28 19:37:28 +09:00
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;
2026-05-28 00:30:09 +09:00
}
2026-08-10 13:59:33 +09:00
const rects = previewPatchRects(validation);
2026-05-29 22:00:42 +09:00
const shownPatch = state.pendingPatch?.result || state.lastPatchResult;
2026-08-08 17:41:30 +09:00
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).`
: "";
2026-05-29 23:49:02 +09:00
const previewText = state.pendingPatch
2026-08-08 17:41:30 +09:00
? ` Preview ready: ${shownPatch?.label || "candidate"}, variant ${shownPatch?.variant ?? variant}${candidateModeText ? `, ${candidateModeText}` : ""}.${deltaText} Use Apply Preview or Discard.`
2026-05-29 23:49:02 +09:00
: shownPatch
2026-08-08 17:41:30 +09:00
? ` Last applied: ${shownPatch.label || "patch"}, variant ${shownPatch.variant ?? "-"}${candidateModeText ? `, ${candidateModeText}` : ""}.`
2026-05-29 23:49:02 +09:00
: "";
2026-08-08 17:41:30 +09:00
const busyText = state.patchBusy
? ` Generating variant ${state.patchBusyVariant ?? variant}; the currently displayed map will be replaced only after a verified preview is rendered.`
2026-08-10 13:59:33 +09:00
: state.fullGenerationBusy
? " Full-map generation is running; patch actions are temporarily locked."
2026-08-08 17:41:30 +09:00
: "";
const statusText = state.patchStatusMessage ? ` ${state.patchStatusMessage}` : "";
patchStatusEl.textContent = `Selection: ${formatRectSize(validation.rect)}. Core ${formatRectSize(rects.coreRect)} / write ${formatRectSize(rects.writeRect)}.${previewText}${busyText}${statusText}`;
2026-05-28 19:37:28 +09:00
patchStatusEl.classList.toggle("invalid", false);
}
2026-05-29 23:49:02 +09:00
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 });
}
2026-05-28 19:37:28 +09:00
function clearDragMode() {
dragState.mode = null;
dragState.pointerId = null;
dragState.pendingCamera = null;
2026-05-29 18:50:54 +09:00
dragState.panRemainderX = 0;
dragState.panRemainderY = 0;
2026-05-28 19:37:28 +09:00
if (dragState.panRaf != null) {
cancelAnimationFrame(dragState.panRaf);
dragState.panRaf = null;
}
2026-05-29 23:49:02 +09:00
dragState.panLatencyStartedAt = null;
2026-05-28 19:37:28 +09:00
canvasShell?.classList.remove("panning", "selecting");
}
function schedulePanRedraw(camera) {
dragState.pendingCamera = camera;
2026-05-29 23:49:02 +09:00
if (!dragState.panLatencyStartedAt) dragState.panLatencyStartedAt = performance.now();
2026-05-28 19:37:28 +09:00
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;
2026-05-29 23:49:02 +09:00
const startedAt = dragState.panLatencyStartedAt || performance.now();
dragState.panLatencyStartedAt = null;
2026-05-28 19:37:28 +09:00
state.camera = next;
2026-05-29 18:50:54 +09:00
// 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 });
2026-05-29 23:49:02 +09:00
recordInteractionLatency("pan", startedAt, { zoom: state.zoom, fast: true });
2026-05-28 19:37:28 +09:00
});
}
2026-05-29 22:00:42 +09:00
function commitPendingPatch({ redrawAfter = true } = {}) {
if (!state.pendingPatch?.world) return false;
2026-08-10 13:59:33 +09:00
const acceptedPatch = state.pendingPatch;
const applyWorker = acceptedPatch.worker && acceptedPatch.result?.applyToken ? patchWorker : null;
state.world = acceptedPatch.world;
2026-08-11 21:51:07 +09:00
// 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;
2026-08-10 13:59:33 +09:00
const committedRevision = advanceCommittedRevision({ preservePatchWorker: !!applyWorker });
2026-05-29 22:00:42 +09:00
state.map = state.world.sourceMap || state.map;
state.lastPatchResult = state.pendingPatch.result || state.world.lastPatchResult || state.lastPatchResult;
state.pendingPatch = null;
2026-08-08 17:41:30 +09:00
state.patchStatusMessage = "";
2026-05-29 22:00:42 +09:00
state.viewportMap = null;
2026-08-10 13:59:33 +09:00
if (applyWorker) acknowledgePatchApply(applyWorker, acceptedPatch, committedRevision);
2026-05-29 22:00:42 +09:00
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;
2026-08-10 13:59:33 +09:00
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. */ }
}
2026-05-29 22:00:42 +09:00
state.pendingPatch = null;
2026-08-08 17:41:30 +09:00
state.patchStatusMessage = "";
2026-05-29 22:00:42 +09:00
state.viewportMap = null;
if (redrawAfter) {
renderStats(displaySourceMap());
redraw({ fastTerrain: false, allowWorldExpand: false });
}
return true;
}
function hideSelectionOverlay(options = {}) {
const commitPreview = options.commitPreview === true;
2026-08-08 17:41:30 +09:00
const discardPreview = options.discardPreview === true || (!commitPreview && options.keepPreview !== true && !!state.pendingPatch);
2026-05-29 22:00:42 +09:00
if (commitPreview) commitPendingPatch({ redrawAfter: false });
2026-08-08 17:41:30 +09:00
else if (discardPreview) discardPendingPatch({ redrawAfter: false });
2026-05-28 19:37:28 +09:00
dragState.selectStart = null;
dragState.selectEnd = null;
2026-05-29 14:31:42 +09:00
dragState.selectPath = null;
2026-08-10 13:59:33 +09:00
replaceSelectionRect(null);
2026-05-28 23:51:55 +09:00
resetPatchVariant({ update: false });
2026-05-29 14:31:42 +09:00
hideSelectionSvg();
2026-05-28 19:37:28 +09:00
if (selectionEl) selectionEl.style.display = "none";
2026-05-29 22:00:42 +09:00
state.viewportMap = null;
renderStats(displaySourceMap());
2026-05-28 19:37:28 +09:00
updatePatchControls();
2026-08-08 17:41:30 +09:00
if (commitPreview || discardPreview) redraw({ fastTerrain: false, allowWorldExpand: false });
2026-05-28 00:30:09 +09:00
}
2026-05-28 19:37:28 +09:00
function selectionPixelsToCells(start, end) {
2026-05-29 14:31:42 +09:00
const a = screenPointToWorldCell(start);
const b = screenPointToWorldCell(end);
if (!a || !b) return null;
2026-05-28 19:37:28 +09:00
return {
2026-05-29 14:31:42 +09:00
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,
2026-05-28 19:37:28 +09:00
};
2026-05-28 00:30:09 +09:00
}
2026-05-29 14:31:42 +09:00
function selectionPixelsToShape(start, end, path = null) {
if (Array.isArray(path) && path.length >= 3) return selectionPathToShape(path);
return selectionPixelsToCells(start, end);
}
2026-05-28 19:37:28 +09:00
function handleMapPointerDown(event) {
if (!state.world || !canvasShell) return;
if (event.button !== 0 && event.button !== 2) return;
2026-08-10 13:59:33 +09:00
if (event.button === 2 && (state.patchBusy || state.fullGenerationBusy)) return;
2026-05-28 19:37:28 +09:00
dragState.pointerId = event.pointerId;
dragState.startClientX = event.clientX;
dragState.startClientY = event.clientY;
dragState.startCameraX = state.camera.x;
dragState.startCameraY = state.camera.y;
2026-05-29 18:50:54 +09:00
dragState.lastClientX = event.clientX;
dragState.lastClientY = event.clientY;
dragState.panRemainderX = 0;
dragState.panRemainderY = 0;
2026-05-28 19:37:28 +09:00
tooltipEl?.classList.remove("visible");
if (event.button === 0) {
dragState.mode = "pan";
canvasShell.classList.add("panning");
} else {
2026-05-29 23:49:02 +09:00
if (state.toolMode !== "patch") setToolMode("patch");
hideSelectionSvg();
if (selectionEl) selectionEl.style.display = "none";
2026-05-28 19:37:28 +09:00
dragState.mode = "select";
dragState.selectStart = clampCanvasPoint(event);
dragState.selectEnd = dragState.selectStart;
2026-05-29 14:31:42 +09:00
dragState.selectPath = [dragState.selectStart];
2026-05-28 19:37:28 +09:00
canvasShell.classList.add("selecting");
updateSelectionOverlay();
}
canvas.setPointerCapture?.(event.pointerId);
2026-05-28 00:30:09 +09:00
event.preventDefault();
}
2026-05-28 19:37:28 +09:00
function handleMapPointerMove(event) {
if (!dragState.mode || dragState.pointerId !== event.pointerId || !canvasShell) return;
tooltipEl?.classList.remove("visible");
if (dragState.mode === "pan") {
2026-05-29 18:50:54 +09:00
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);
}
}
2026-05-28 19:37:28 +09:00
} else if (dragState.mode === "select") {
dragState.selectEnd = clampCanvasPoint(event);
2026-05-29 14:31:42 +09:00
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) {
2026-08-10 13:59:33 +09:00
(dragState.selectPath || (dragState.selectPath = [])).push(dragState.selectEnd);
2026-05-29 14:31:42 +09:00
}
2026-05-28 19:37:28 +09:00
updateSelectionOverlay();
}
event.preventDefault();
}
function handleMapPointerUp(event) {
if (dragState.pointerId !== event.pointerId) return;
const wasPanning = dragState.mode === "pan";
2026-08-10 13:59:33 +09:00
const wasCancelled = event.type === "pointercancel";
if (wasCancelled) {
canvas.releasePointerCapture?.(event.pointerId);
clearDragMode();
if (state.selectionRect) updateSelectionOverlayFromWorldRect();
else hideSelectionSvg();
updatePatchControls();
return;
}
2026-05-28 19:37:28 +09:00
if (dragState.mode === "select") {
dragState.selectEnd = clampCanvasPoint(event);
2026-05-29 14:31:42 +09:00
if (!dragState.selectPath || dragState.selectPath.length < 2) dragState.selectPath = [dragState.selectStart, dragState.selectEnd];
2026-08-10 13:59:33 +09:00
else dragState.selectPath.push(dragState.selectEnd);
2026-05-29 14:31:42 +09:00
const shape = selectionPixelsToShape(dragState.selectStart, dragState.selectEnd, dragState.selectPath);
2026-05-28 19:37:28 +09:00
const width = Math.abs(dragState.selectEnd.x - dragState.selectStart.x);
const height = Math.abs(dragState.selectEnd.y - dragState.selectStart.y);
2026-05-29 14:31:42 +09:00
if (shape && (dragState.selectPath.length >= 3 || (width >= 4 && height >= 4))) {
2026-08-10 13:59:33 +09:00
if (state.pendingPatch) discardPendingPatch({ redrawAfter: true });
replaceSelectionRect(shape);
2026-05-28 23:51:55 +09:00
state.lastPatchResult = null;
2026-08-08 17:41:30 +09:00
state.patchStatusMessage = "";
2026-05-28 23:51:55 +09:00
resetPatchVariant({ update: false });
2026-05-28 19:37:28 +09:00
updateSelectionOverlayFromWorldRect();
updatePatchControls();
} else {
2026-08-10 13:59:33 +09:00
if (state.selectionRect) updateSelectionOverlayFromWorldRect();
else hideSelectionSvg();
updatePatchControls();
2026-05-28 19:37:28 +09:00
}
}
canvas.releasePointerCapture?.(event.pointerId);
if (wasPanning && dragState.pendingCamera) {
state.camera = dragState.pendingCamera;
dragState.pendingCamera = null;
}
clearDragMode();
2026-05-29 23:49:02 +09:00
if (wasPanning) {
const startedAt = performance.now();
redraw({ fastTerrain: false });
recordInteractionLatency("pan settle", startedAt, { zoom: state.zoom, fast: false });
}
2026-05-28 00:30:09 +09:00
event.preventDefault();
}
2026-05-20 13:50:56 +09:00
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;
}
2026-05-24 19:33:09 +09:00
function formatMs(ms) {
if (!Number.isFinite(ms)) return "-";
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`;
}
2026-05-29 23:49:02 +09:00
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,
2026-08-08 17:41:30 +09:00
showSeamDiagnostics: state.showSeamDiagnostics && !options.fastTerrain,
2026-05-29 23:49:02 +09:00
viewportMs: timings.viewportMs || 0,
hoverMs: timings.hoverMs || 0,
drawMs: timings.drawMs || 0,
totalRenderMs: timings.totalRenderMs || 0,
2026-05-30 03:25:19 +09:00
renderBreakdown: timings.renderBreakdown || null,
2026-05-29 23:49:02 +09:00
};
state.diagnostics.lastFeatureCounts = collectFeatureCounts(map);
}
function selectionWriteDiagnostics() {
const rect = state.selectionRect;
const validation = validatePatchRect(rect, state.world);
const currentSelection = validation.rect || rect;
2026-08-10 13:59:33 +09:00
const rects = previewPatchRects(validation);
2026-05-29 23:49:02 +09:00
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") },
2026-08-08 17:41:30 +09:00
{ label: "Resolved patch mode", value: rects?.patchMode || "-", sub: rects ? `${rects.patchModeAutoDetected ? "auto" : "explicit"} · ${formatPercent((rects.coverageStats?.ungeneratedRatio || 0) * 100)} ungenerated` : "requires valid selection" },
2026-05-29 23:49:02 +09:00
{ 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;
2026-05-30 03:25:19 +09:00
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" },
] : [];
2026-05-29 23:49:02 +09:00
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` },
2026-05-30 03:25:19 +09:00
...renderRows,
2026-05-29 23:49:02 +09:00
] : [], "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.");
}
2026-08-08 17:41:30 +09:00
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 ? [
2026-08-11 21:51:07 +09:00
{ 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)}` },
2026-08-08 17:41:30 +09:00
{ 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.");
}
2026-05-29 23:49:02 +09:00
function renderWorkerWorldDiagnostics() {
const workerAvailable = typeof Worker !== "undefined";
const workerRows = [
{ label: "Worker API", value: workerAvailable ? "available" : "unavailable", sub: "browser capability" },
2026-08-08 17:41:30 +09:00
{ 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" },
2026-05-29 23:49:02 +09:00
{ 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" },
2026-08-08 17:41:30 +09:00
{ label: "Patch fallback", value: state.diagnostics.lastWorkerFallbackReason || "none", sub: "last patch worker fallback" },
2026-05-29 23:49:02 +09:00
];
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 [];
2026-05-30 03:25:19 +09:00
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";
2026-05-29 23:49:02 +09:00
return [
2026-08-10 13:59:33 +09:00
{ label: "Samples", value: String(totals.count), sub: "retained runs, including failures and cancellations" },
2026-05-29 23:49:02 +09:00
{ 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" },
2026-05-30 03:25:19 +09:00
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,
2026-05-29 23:49:02 +09:00
];
}
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);
2026-05-30 03:25:19 +09:00
const featureTimings = (map.transportDebug?.featureTimings || []).map((row) => ({
label: `Settlements / ${row.key || "substage"}`,
ms: Number(row.ms) || 0,
}));
2026-05-29 23:49:02 +09:00
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,
2026-05-30 03:25:19 +09:00
})).concat(featureTimings),
2026-05-29 23:49:02 +09:00
});
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;
2026-08-10 13:59:33 +09:00
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;
2026-05-29 23:49:02 +09:00
pushCapped(state.patchRuns, {
id: Date.now(),
createdAt: new Date(),
kind: meta.kind || "Patch preview",
2026-08-10 13:59:33 +09:00
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 : [],
2026-05-29 23:49:02 +09:00
terrainType: terrainType || result.terrainType || "auto",
variant: Number.isFinite(variant) ? variant : result.variant,
2026-08-10 13:59:33 +09:00
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),
2026-05-29 23:49:02 +09:00
worker: usedWorker,
2026-08-10 13:59:33 +09:00
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),
2026-05-29 23:49:02 +09:00
label: result.label || "candidate",
2026-08-08 17:41:30 +09:00
patchGenerationMode: result.patchGenerationMode || "-",
patchMode: result.patchMode || "regeneration",
patchModeAutoDetected: !!result.patchModeAutoDetected,
ungeneratedSelectionRatio: result.coverageStats?.ungeneratedRatio || 0,
seamStatus: result.seamDiagnostics?.status || "-",
2026-05-29 23:49:02 +09:00
areaCells: area,
writeAreaCells: writeArea,
writeRatio,
totalMs,
2026-08-10 13:59:33 +09:00
performanceBudgetMs,
performanceBudgetMet,
2026-05-29 23:49:02 +09:00
secondsPerThousand: secondsPerThousandCells(totalMs, area),
timings,
2026-08-10 13:59:33 +09:00
}, 100);
2026-05-29 23:49:02 +09:00
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>
2026-05-30 03:25:19 +09:00
<span>${formatSeconds(run.secondsPerThousand)} / 1k cells${run.areaCells && run.areaCells < 1000 ? " (fixed overhead)" : ""}</span>
2026-05-29 23:49:02 +09:00
`;
const body = document.createElement("div");
body.className = "timing-grid";
if (options.patch) {
2026-08-10 13:59:33 +09:00
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>`;
2026-05-29 23:49:02 +09:00
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>`;
2026-08-08 17:41:30 +09:00
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>`;
2026-08-10 13:59:33 +09:00
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);
2026-05-29 23:49:02 +09:00
}
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();
2026-08-08 17:41:30 +09:00
renderSeamDiagnostics();
2026-05-29 23:49:02 +09:00
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" },
2026-08-08 17:41:30 +09:00
{ 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" },
2026-05-29 23:49:02 +09:00
{ 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" },
2026-08-08 17:41:30 +09:00
{ label: "Patch fallback", value: state.diagnostics.lastWorkerFallbackReason || "none", sub: "last patch worker fallback" },
2026-05-29 23:49:02 +09:00
];
}
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" },
2026-08-08 17:41:30 +09:00
{ label: "Patch mode", value: patchModeInput?.value || "auto", sub: "auto separates expansion from regeneration" },
2026-05-29 23:49:02 +09:00
{ 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()),
"",
2026-08-08 17:41:30 +09:00
...reportRows("Seam Diagnostics", seamDiagnosticRows()),
"",
2026-05-29 23:49:02 +09:00
...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);
}
}
}
2026-05-24 19:33:09 +09:00
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);
}
}
2026-08-10 13:59:33 +09:00
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);
}
2026-05-24 19:33:09 +09:00
function updateGenerationProgress(event) {
if (!progressEl) return;
2026-08-10 13:59:33 +09:00
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,
});
2026-05-24 19:33:09 +09:00
}
2026-08-10 13:59:33 +09:00
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"}`);
2026-05-24 19:33:09 +09:00
renderTimingRows(event?.timings || []);
}
function setProgressVisible(visible, message = "Preparing") {
if (!progressEl) return;
2026-08-10 13:59:33 +09:00
if (progressHideTimer) {
window.clearTimeout(progressHideTimer);
progressHideTimer = null;
}
2026-05-24 19:33:09 +09:00
progressEl.classList.toggle("hidden", !visible);
2026-05-24 22:52:22 +09:00
if (visible) {
2026-08-10 13:59:33 +09:00
progressRevision++;
2026-05-24 22:52:22 +09:00
generationStartedAt = performance.now();
generationCurrentStage = message;
2026-08-10 13:59:33 +09:00
writeProgressStage(`Running: ${message}`);
2026-05-24 22:52:22 +09:00
if (generationTimer) window.clearInterval(generationTimer);
generationTimer = window.setInterval(() => {
if (progressStageEl && !progressEl.classList.contains("hidden")) {
2026-08-10 13:59:33 +09:00
const elapsed = progressStageEl.querySelector("[data-progress-elapsed]");
if (elapsed) elapsed.textContent = ` / elapsed ${formatMs(performance.now() - generationStartedAt)}`;
2026-05-24 22:52:22 +09:00
}
2026-08-10 13:59:33 +09:00
}, 1000);
2026-05-24 22:52:22 +09:00
} else if (generationTimer) {
window.clearInterval(generationTimer);
generationTimer = null;
}
2026-05-24 19:33:09 +09:00
if (progressStageEl) progressStageEl.textContent = message;
if (visible) renderTimingRows([]);
}
2026-08-10 13:59:33 +09:00
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;
}
2026-05-24 19:33:09 +09:00
function nextFrame() {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
2026-08-10 13:59:33 +09:00
function yieldPatchMainThread() {
if (typeof globalThis.scheduler?.yield === "function") return globalThis.scheduler.yield();
return new Promise((resolve) => window.setTimeout(resolve, 0));
}
2026-05-29 23:49:02 +09:00
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;
}
2026-05-20 13:50:56 +09:00
function getStats(map) {
2026-05-29 23:49:02 +09:00
if (!map) return [];
2026-05-20 13:50:56 +09:00
return [
["Population", (map.totalPopulation || 0).toLocaleString()],
2026-05-29 23:49:02 +09:00
["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) : "-"],
2026-05-20 13:50:56 +09:00
];
}
function renderStats(map) {
2026-05-29 23:49:02 +09:00
if (!statsEl) return;
2026-05-20 13:50:56 +09:00
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);
}
}
2026-05-29 23:49:02 +09:00
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);
}
2026-05-26 16:56:18 +09:00
function buildHoverEntities(map) {
return [
2026-05-20 13:50:56 +09:00
...(map.modernCities || []),
...(map.ports || []),
...(map.stations || []),
...(map.interchanges || []),
...(map.industrialZones || []),
...(map.logisticsParks || []),
...(map.newTowns || []),
...(map.castles || []),
...(map.markets || []),
...(map.villages || []),
...(map.adminCenters || []),
];
2026-05-26 16:56:18 +09:00
}
function nearestEntity(items, x, y, maxDistance = 5) {
2026-05-20 13:50:56 +09:00
let best = null;
let bestD = maxDistance;
2026-05-29 14:31:42 +09:00
for (const item of items || []) {
if (!item || !Number.isFinite(item.x) || !Number.isFinite(item.y)) continue;
2026-05-20 13:50:56 +09:00
const d = Math.hypot(item.x - x, item.y - y);
if (d < bestD) { best = item; bestD = d; }
}
return best;
}
function landuseName(value) {
2026-05-24 17:38:51 +09:00
return landuseLabel(value);
2026-05-20 13:50:56 +09:00
}
2026-05-29 14:31:42 +09:00
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));
}
2026-05-29 22:00:42 +09:00
function numericPrefectureId(item) {
const value = numericIdOf(item, PREFECTURE_ID_KEYS);
return value == null ? -1 : value;
}
2026-05-29 14:31:42 +09:00
function firstUsableText(item, keys) {
for (const key of keys) {
2026-05-28 23:51:55 +09:00
const value = item?.[key];
2026-05-29 14:31:42 +09:00
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);
2026-05-28 23:51:55 +09:00
}
return null;
}
function adminCenterForId(map, adminId) {
if (!map || adminId == null || adminId < 0) return null;
2026-05-29 14:31:42 +09:00
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;
2026-05-28 23:51:55 +09:00
}
function looksNumericName(name) {
if (!name) return true;
const text = String(name).trim();
2026-05-29 22:00:42 +09:00
return !text || /^県域\d*$/u.test(text) || /^-?\d+(?:\s*[,]\s*\d+)*$/u.test(text) || /^(Municipality|Prefecture|Admin|Region)\s*-?\d+/i.test(text);
2026-05-20 13:50:56 +09:00
}
2026-05-29 14:31:42 +09:00
function nearestNamedAdminCenter(map, cellIndex, maxDistance = 36, adminId = null) {
2026-05-28 23:51:55 +09:00
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 || []) {
2026-05-29 14:31:42 +09:00
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;
2026-05-28 23:51:55 +09:00
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) {
2026-05-29 14:31:42 +09:00
const center = adminCenterForId(map, adminId) || nearestNamedAdminCenter(map, cellIndex, 54, adminId);
const name = firstUsableText(center, MUNICIPALITY_NAME_KEYS);
if (name) return name;
2026-05-28 23:51:55 +09:00
return adminId >= 0 ? "Unnamed municipality" : "-";
}
function adminPopulation(map, adminId, cellIndex = -1) {
2026-05-29 14:31:42 +09:00
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;
2026-05-26 16:56:18 +09:00
}
2026-05-29 22:00:42 +09:00
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);
2026-05-29 14:31:42 +09:00
const regionName = firstUsableText(region, PREFECTURE_NAME_KEYS);
if (regionName) return regionName;
2026-05-29 22:00:42 +09:00
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;
2026-05-29 15:49:09 +09:00
const adminId = map.adminId?.[i] ?? -1;
2026-05-29 22:00:42 +09:00
const mappedPref = adminId >= 0 ? map.municipalityToPrefectureId?.[adminId] ?? state.world?.sourceMap?.municipalityToPrefectureId?.[adminId] ?? -1 : -1;
const mapped = prefectureNameForId(mappedPref, adminId);
if (mapped) return mapped;
2026-05-29 15:49:09 +09:00
const center = mappedPref === id ? nearestNamedAdminCenter(map, i, 90, adminId) : null;
2026-05-29 14:31:42 +09:00
const fromCenter = firstUsableText(center, ["prefectureName", "prefectureRegionName", "regionName"]);
return fromCenter || (id >= 0 ? "Unnamed prefecture" : "-");
2026-05-26 15:32:27 +09:00
}
2026-05-20 13:50:56 +09:00
function updateTooltip(event) {
2026-05-28 19:37:28 +09:00
const map = activeMap();
if (!map || !tooltipEl || dragState.mode) return;
2026-05-20 13:50:56 +09:00
const rect = canvas.getBoundingClientRect();
2026-05-28 00:30:09 +09:00
const cell = mapClientToCell(event);
if (!cell) return;
const { x, y } = cell;
2026-05-28 19:37:28 +09:00
if (x < 0 || y < 0 || x >= map.width || y >= map.height) {
2026-05-20 13:50:56 +09:00
tooltipEl.classList.remove("visible");
return;
}
2026-05-28 19:37:28 +09:00
const i = y * map.width + x;
const worldCell = viewportCellToWorldCell({ x, y });
2026-05-26 16:56:18 +09:00
const entity = nearestEntity(state.hoverEntities, x, y);
2026-05-28 19:37:28 +09:00
const elevation = map.elevation?.[i] ?? 0;
2026-05-28 23:51:55 +09:00
const density = map.populationDensity?.[i] ?? map.settlementScore?.[i] ?? 0;
2026-05-28 19:37:28 +09:00
const hoveredAdminId = map.adminId?.[i] ?? -1;
2026-05-28 23:51:55 +09:00
const hoveredAdminPopulation = adminPopulation(map, hoveredAdminId, i);
2026-05-28 19:37:28 +09:00
const coordinateText = worldCell ? `World ${worldCell.x}, ${worldCell.y} / View ${x}, ${y}` : `${x}, ${y}`;
2026-05-29 14:31:42 +09:00
const entityName = firstUsableText(entity, ["name", "facilityLabel", "municipalityName", "canonicalSettlementName", "kind"]);
2026-05-28 19:37:28 +09:00
const entityTitle = entity
2026-05-29 14:31:42 +09:00
? `${entityName || adminName(map, hoveredAdminId, i) || entity.kind || "Feature"} / ${entity.kind || "Feature"}`
2026-05-28 19:37:28 +09:00
: coordinateText;
2026-05-20 13:50:56 +09:00
const lines = [
2026-05-28 19:37:28 +09:00
`<strong>${entityTitle}</strong>`,
`Prefecture: ${prefectureNameForCell(map, i)}`,
2026-05-28 23:51:55 +09:00
`Admin: ${adminName(map, hoveredAdminId, i)}`,
2026-05-26 16:56:18 +09:00
`Admin Pop: ${hoveredAdminPopulation === null ? "-" : hoveredAdminPopulation.toLocaleString()}`,
2026-05-28 19:37:28 +09:00
`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)}`,
2026-05-20 13:50:56 +09:00
];
if (entity?.population) lines.splice(1, 0, `Population: ${entity.population.toLocaleString()}`);
tooltipEl.innerHTML = lines.join("<br>");
2026-05-26 15:32:27 +09:00
const margin = 8;
2026-08-11 21:51:07 +09:00
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`;
2026-05-20 13:50:56 +09:00
tooltipEl.classList.add("visible");
}
2026-05-26 16:56:18 +09:00
function renderModeButtons() {
2026-05-29 23:49:02 +09:00
if (!modeGrid) return;
2026-05-26 16:56:18 +09:00
modeGrid.innerHTML = "";
2026-05-20 13:50:56 +09:00
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();
2026-05-29 23:49:02 +09:00
renderLegend();
2026-05-20 13:50:56 +09:00
redraw();
});
modeGrid.append(button);
}
2026-05-29 23:49:02 +09:00
renderLegend();
2026-05-20 13:50:56 +09:00
}
2026-08-08 17:41:30 +09:00
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).`);
}
2026-05-24 19:33:09 +09:00
async function regenerate() {
2026-08-10 13:59:33 +09:00
if (state.fullGenerationBusy) return;
const requestedSeedText = seedInput.value;
const requestedGenerationType = generationTypeInput?.value || "auto";
2026-08-08 17:41:30 +09:00
const requestId = ++generationRequestSeq;
2026-08-10 13:59:33 +09:00
if (state.patchBusy || activePatchCancel) {
cancelPatchGeneration({ reason: "Patch generation superseded by full-map generation.", silentProgress: true });
}
state.fullGenerationBusy = true;
2026-08-08 17:41:30 +09:00
state.patchStatusMessage = "";
2026-08-10 13:59:33 +09:00
updatePatchControls();
2026-08-08 17:41:30 +09:00
// 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);
2026-05-24 19:33:09 +09:00
setProgressVisible(true, "Preparing generation...");
await nextFrame();
try {
2026-08-10 13:59:33 +09:00
const map = await generateFullMap(parseSeed(requestedSeedText), { onProgress: updateGenerationProgress, terrainType: requestedGenerationType });
2026-08-08 17:41:30 +09:00
if (requestId !== generationRequestSeq) return;
2026-08-10 13:59:33 +09:00
state.seedText = requestedSeedText;
state.generationType = requestedGenerationType;
state.world = createWorldMap(map);
state.map = state.world.sourceMap;
advanceCommittedRevision();
2026-05-28 19:37:28 +09:00
state.camera = createInitialCamera(state.world);
state.lastPatchResult = null;
2026-05-29 22:00:42 +09:00
state.pendingPatch = null;
2026-05-28 23:51:55 +09:00
resetPatchVariant({ update: false });
2026-05-29 22:00:42 +09:00
hideSelectionOverlay({ discardPreview: true });
2026-08-10 13:59:33 +09:00
recordGenerationRun(map, state.generationType);
2026-05-24 19:33:09 +09:00
renderStats(state.map);
redraw();
2026-08-10 13:59:33 +09:00
const workerText = state.diagnostics.lastGenerationWorkerUsed ? " in worker" : "";
finishProgress(`Done${workerText} in ${formatMs(state.map.generationTotalMs || 0)}`, state.map.generationTimings || [], 900);
2026-05-24 19:33:09 +09:00
} catch (error) {
2026-08-08 17:41:30 +09:00
// Superseding an old request is an expected cancellation, not a generation
// failure. The newer request owns the progress UI.
if (requestId !== generationRequestSeq) return;
2026-05-29 23:49:02 +09:00
const reason = error?.message || String(error || "unknown generation error");
2026-08-10 13:59:33 +09:00
recordDiagnosticLog("error", "Full generation failed", reason, { terrainType: requestedGenerationType });
finishProgress(`Generation failed: ${reason}`, null, 1800);
2026-08-08 17:41:30 +09:00
console.error("Full generation failed", error);
2026-08-10 13:59:33 +09:00
} finally {
if (requestId === generationRequestSeq) {
state.fullGenerationBusy = false;
updatePatchControls();
}
2026-05-24 19:33:09 +09:00
}
2026-05-20 13:50:56 +09:00
}
2026-05-28 19:37:28 +09:00
2026-08-10 13:59:33 +09:00
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;
2026-05-28 23:51:55 +09:00
h = Math.imul(h ^ normalizePatchVariant(variant), 668265263) >>> 0;
2026-05-28 19:37:28 +09:00
for (const ch of String(terrainType || "auto")) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
return h >>> 0;
}
2026-08-10 13:59:33 +09:00
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,
2026-08-11 21:51:07 +09:00
"quality-batched-production-search-v4|initial-quality-oracle-transport-parity-v3",
2026-08-10 13:59:33 +09:00
].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) {
2026-08-11 21:51:07 +09:00
if (attempt?.status !== "rejected" && attempt?.status !== "evaluated" && attempt?.status !== "success") continue;
2026-08-10 13:59:33 +09:00
const candidateId = `${contextId}|${normalizePatchVariant(attempt.variant)}|${normalizePatchVariant(attempt.seed)}`;
patchSearchSeries.consumedCandidateIds.add(candidateId);
}
}
2026-05-28 23:51:55 +09:00
2026-05-29 22:00:42 +09:00
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;
}
}
2026-05-28 23:51:55 +09:00
function handleCanvasWheel(event) {
if (!state.world || !activeMap()) return;
event.preventDefault();
2026-05-29 23:49:02 +09:00
if (zoomLatencyStartedAt == null) zoomLatencyStartedAt = performance.now();
2026-05-28 23:51:55 +09:00
tooltipEl?.classList.remove("visible");
2026-05-29 14:31:42 +09:00
const beforeSize = viewportSizeForZoom(state.zoom);
const beforeCell = mapClientToCell(event, beforeSize);
2026-05-28 23:51:55 +09:00
const beforeWorld = beforeCell ? viewportCellToWorldCell(beforeCell) : null;
const oldZoom = clampZoom(state.zoom || 1);
2026-05-29 14:31:42 +09:00
const delta = event.deltaY < 0 ? 1.10 : 1 / 1.10;
2026-05-28 23:51:55 +09:00
const nextZoom = clampZoom(oldZoom * delta);
if (Math.abs(nextZoom - oldZoom) < 0.001) return;
state.zoom = nextZoom;
2026-05-29 14:31:42 +09:00
const nextSize = syncViewportSize();
2026-05-28 23:51:55 +09:00
if (beforeWorld) {
2026-05-29 14:31:42 +09:00
const afterCell = mapClientToCell(event, nextSize);
2026-05-28 23:51:55 +09:00
if (afterCell) {
2026-05-29 14:31:42 +09:00
state.camera = clampCameraForView({
2026-05-28 23:51:55 +09:00
x: beforeWorld.x - afterCell.x,
y: beforeWorld.y - afterCell.y,
2026-05-29 14:31:42 +09:00
}, nextSize);
2026-05-28 23:51:55 +09:00
}
}
2026-05-29 14:31:42 +09:00
2026-05-29 22:00:42 +09:00
// 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();
2026-05-29 14:31:42 +09:00
if (zoomSettledTimer != null) clearTimeout(zoomSettledTimer);
zoomSettledTimer = window.setTimeout(() => {
zoomSettledTimer = null;
2026-05-29 23:49:02 +09:00
const startedAt = zoomLatencyStartedAt || performance.now();
zoomLatencyStartedAt = null;
2026-05-29 22:00:42 +09:00
finishZoomVisual();
2026-05-29 14:31:42 +09:00
redraw({ fastTerrain: false, allowWorldExpand: false });
2026-05-29 23:49:02 +09:00
recordInteractionLatency("wheel zoom", startedAt, { zoom: state.zoom });
2026-05-29 22:00:42 +09:00
}, 170);
}
2026-08-10 13:59:33 +09:00
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;
2026-08-08 17:41:30 +09:00
}
2026-08-10 13:59:33 +09:00
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;
2026-08-08 17:41:30 +09:00
}
function previewPatchDelta(baseWorld, previewWorld, rectLike) {
2026-08-10 13:59:33 +09:00
const rect = rectLike?.transportReachRect || rectLike?.repairRect || rectLike?.writeRect || rectLike || null;
2026-08-08 17:41:30 +09:00
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));
2026-08-10 13:59:33 +09:00
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 || {})]);
2026-08-08 17:41:30 +09:00
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;
2026-08-10 13:59:33 +09:00
let cellChanged = false;
for (const name of fieldNames) {
2026-08-08 17:41:30 +09:00
const a = baseWorld.fields?.[name]?.[bi];
const b = previewWorld.fields?.[name]?.[pi];
2026-08-10 13:59:33 +09:00
if (a === b || (Number.isNaN(a) && Number.isNaN(b))) continue;
cellChanged = true;
if (terrainFields.has(name)) terrainChanged = true;
if (adminFields.has(name)) adminChanged = true;
2026-08-08 17:41:30 +09:00
}
2026-08-10 13:59:33 +09:00
if (cellChanged) changedCells++;
2026-08-08 17:41:30 +09:00
if (terrainChanged) terrainChangedCells++;
if (adminChanged) adminChangedCells++;
}
}
2026-08-10 13:59:33 +09:00
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 };
2026-08-08 17:41:30 +09:00
}
2026-05-29 22:00:42 +09:00
function createPatchWorker() {
2026-05-29 23:49:02 +09:00
if (patchWorker) return patchWorker;
if (typeof Worker === "undefined") {
state.diagnostics.lastWorkerFallbackReason = "Worker API unavailable";
return null;
}
2026-05-29 22:00:42 +09:00
try {
patchWorker = new Worker(new URL("./mapPatchWorker.js", import.meta.url), { type: "module" });
2026-08-10 13:59:33 +09:00
patchWorkerEpoch++;
patchWorkerConstructorCount++;
patchWorker.__patchWorkerEpoch = patchWorkerEpoch;
patchWorker.__patchWorkerConstructorOrdinal = patchWorkerConstructorCount;
2026-08-08 17:41:30 +09:00
const worker = patchWorker;
worker.addEventListener("error", (event) => {
if (patchWorker !== worker) return;
2026-05-29 23:49:02 +09:00
const reason = event?.message || "Patch worker runtime error";
state.diagnostics.lastWorkerFallbackReason = reason;
recordDiagnosticLog("warning", "Patch worker reset", reason);
2026-08-08 17:41:30 +09:00
worker.terminate?.();
2026-05-29 22:00:42 +09:00
patchWorker = null;
});
2026-05-29 23:49:02 +09:00
} catch (error) {
state.diagnostics.lastWorkerFallbackReason = error?.message || "Patch worker creation failed";
2026-05-29 22:00:42 +09:00
patchWorker = null;
}
return patchWorker;
}
2026-08-10 13:59:33 +09:00
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;
2026-05-29 22:00:42 +09:00
const worker = createPatchWorker();
if (!worker) return null;
2026-08-10 13:59:33 +09:00
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;
}
2026-05-29 22:00:42 +09:00
const id = ++patchJobSeq;
return new Promise((resolve, reject) => {
2026-08-08 17:41:30 +09:00
let settled = false;
let watchdogTimer = null;
2026-08-10 13:59:33 +09:00
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;
2026-08-08 17:41:30 +09:00
if (watchdogTimer != null) clearTimeout(watchdogTimer);
2026-08-10 13:59:33 +09:00
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;
2026-08-08 17:41:30 +09:00
watchdogTimer = window.setTimeout(() => {
2026-08-10 13:59:33 +09:00
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);
2026-08-08 17:41:30 +09:00
};
2026-08-10 13:59:33 +09:00
const cleanup = (terminateWorker = false) => {
2026-08-08 17:41:30 +09:00
if (watchdogTimer != null) { clearTimeout(watchdogTimer); watchdogTimer = null; }
2026-05-29 22:00:42 +09:00
worker.removeEventListener("message", onMessage);
worker.removeEventListener("error", onError);
worker.removeEventListener("messageerror", onMessageError);
2026-08-08 17:41:30 +09:00
if (activePatchCancel === cancelJob) activePatchCancel = null;
2026-08-10 13:59:33 +09:00
if (mirrorSyncWaiter) {
const error = new Error("Patch mirror synchronization stopped.");
error.name = "AbortError";
rejectMirrorWaiter(error);
}
if (terminateWorker) {
if (patchWorker === worker) patchWorker = null;
worker.terminate?.();
}
2026-08-08 17:41:30 +09:00
};
2026-08-10 13:59:33 +09:00
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"))); };
2026-08-08 17:41:30 +09:00
const cancelJob = (reason = "Patch generation cancelled by user.") => {
const error = new Error(reason);
error.name = "AbortError";
fail(error);
2026-05-29 22:00:42 +09:00
};
2026-08-10 13:59:33 +09:00
const onMessage = async (event) => {
2026-05-29 22:00:42 +09:00
if (event.data?.id !== id) return;
2026-08-10 13:59:33 +09:00
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));
}
2026-05-29 22:00:42 +09:00
};
2026-08-10 13:59:33 +09:00
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));
2026-05-29 22:00:42 +09:00
worker.addEventListener("message", onMessage);
worker.addEventListener("error", onError);
worker.addEventListener("messageerror", onMessageError);
2026-08-08 17:41:30 +09:00
activePatchCancel = cancelJob;
2026-08-10 13:59:33 +09:00
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,
2026-08-11 21:51:07 +09:00
selectBestCandidate: operation.selectBestCandidate === true,
draftSelection: operation.draftSelection === true,
2026-08-10 13:59:33 +09:00
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));
});
2026-05-29 22:00:42 +09:00
});
}
2026-08-10 13:59:33 +09:00
function cancelPatchGeneration({ clearSelection = false, reason = "Patch generation cancelled by user.", silentProgress = false } = {}) {
if (!state.patchBusy && !activePatchCancel) return false;
const cancelledOperation = activePatchOperation;
2026-08-08 17:41:30 +09:00
patchRequestSeq++;
2026-08-10 13:59:33 +09:00
activePatchOperation = null;
2026-08-08 17:41:30 +09:00
const cancel = activePatchCancel;
activePatchCancel = null;
2026-08-10 13:59:33 +09:00
cancel?.(reason);
2026-08-08 17:41:30 +09:00
patchWorker?.terminate?.();
patchWorker = null;
state.patchBusy = false;
state.patchBusyVariant = null;
2026-08-10 13:59:33 +09:00
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);
2026-08-08 17:41:30 +09:00
if (clearSelection) hideSelectionOverlay({ discardPreview: true });
else updatePatchControls();
2026-08-10 13:59:33 +09:00
if (deferredRunRecord) window.setTimeout(deferredRunRecord, 0);
2026-08-08 17:41:30 +09:00
return true;
}
2026-08-10 13:59:33 +09:00
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++;
2026-05-29 22:00:42 +09:00
try {
2026-08-10 13:59:33 +09:00
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;
}
2026-08-11 21:51:07 +09:00
// 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;
}
2026-08-10 13:59:33 +09:00
job = execution;
2026-05-29 22:00:42 +09:00
} catch (error) {
2026-08-10 13:59:33 +09:00
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;
}
2026-05-29 23:49:02 +09:00
const reason = error?.message || String(error || "unknown worker failure");
state.diagnostics.lastWorkerFallbackReason = reason;
2026-08-10 13:59:33 +09:00
recordDiagnosticLog("error", "Patch worker failed", reason, { workerEpoch: operation?.workerEpoch || 0 });
2026-08-08 17:41:30 +09:00
throw error;
2026-05-29 22:00:42 +09:00
}
}
2026-08-10 13:59:33 +09:00
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;
2026-05-28 23:51:55 +09:00
}
2026-05-29 23:49:02 +09:00
async function generateSelectedPatch(kind = "Patch preview") {
2026-08-10 13:59:33 +09:00
if (state.patchBusy || state.fullGenerationBusy) return;
const patchStartedAt = performance.now();
2026-05-28 19:37:28 +09:00
const validation = validatePatchRect(state.selectionRect, state.world);
if (!validation.ok) {
2026-05-29 23:49:02 +09:00
recordDiagnosticLog("warning", "Invalid patch selection", validation.reason || "Selection is not valid.");
2026-05-28 19:37:28 +09:00
updatePatchControls();
return;
}
2026-08-08 17:41:30 +09:00
const baseWorld = state.world;
const requestId = ++patchRequestSeq;
2026-08-10 13:59:33 +09:00
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;
2026-08-08 17:41:30 +09:00
const patchMode = patchModeInput?.value || "auto";
2026-08-10 13:59:33 +09:00
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(",");
2026-05-28 23:51:55 +09:00
const variant = readPatchVariant();
2026-08-10 13:59:33 +09:00
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,
2026-08-11 21:51:07 +09:00
generatorPolicyVersion: "quality-batched-production-search-v4",
qualityPolicyVersion: "initial-quality-oracle-transport-parity-v3",
selectBestCandidate: true,
draftSelection: true,
2026-08-10 13:59:33 +09:00
requestedVariant: variant,
requestedSeed: seed,
currentVariant: variant,
currentSeed: seed,
currentCandidateOrdinal: 0,
executionAttempt: 1,
completedAttemptSummaries: [],
executions: [],
candidateLimit: searchPlan.candidateLimit,
2026-08-11 21:51:07 +09:00
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,
2026-08-10 13:59:33 +09:00
estimatedTileCount: searchPlan.estimatedTileCount,
includeSeamVisualization: state.showSeamDiagnostics,
selectionRect: validation.rect,
startedAt: patchStartedAt,
};
activePatchOperation = operation;
2026-08-08 17:41:30 +09:00
state.patchBusy = true;
state.patchBusyVariant = variant;
state.patchStatusMessage = "";
updatePatchControls();
2026-08-11 21:51:07 +09:00
setProgressVisible(true, `Generating quality batch 1 (${operation.candidatePlan.length} lightweight drafts, up to ${searchPlan.plan.length} candidates); only fully finalized candidates can be previewed...`);
2026-05-28 19:37:28 +09:00
await nextFrame();
try {
2026-08-10 13:59:33 +09:00
if (!isPatchOperationCurrent(operation)) return;
2026-08-08 17:41:30 +09:00
const job = await generatePatchPreviewWorld(baseWorld, validation.rect, {
terrainType,
patchMode,
seed,
variant,
2026-08-11 21:51:07 +09:00
// 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.
2026-08-08 17:41:30 +09:00
maxQualityRetries: 0,
qualityTerrainAttempts: 1,
2026-08-10 13:59:33 +09:00
acceptBestAvailableQuality: false,
includeSeamVisualization: operation.includeSeamVisualization,
}, operation);
if (!isPatchOperationCurrent(operation)) return;
2026-05-29 22:00:42 +09:00
const result = job.result;
2026-08-10 13:59:33 +09:00
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;
2026-05-28 19:37:28 +09:00
if (!result.ok) {
2026-08-08 17:41:30 +09:00
const reason = result.reason || result.code || "invalid selection";
const showing = state.pendingPatch?.variant;
2026-08-10 13:59:33 +09:00
const rejectedVariants = searchAttempts.filter((attempt) => attempt.status === "rejected").map((attempt) => attempt.variant);
const attemptText = rejectedVariants.length ? ` Tried variants ${rejectedVariants.join(", ")}.` : "";
2026-08-08 17:41:30 +09:00
state.patchStatusMessage = showing == null
2026-08-10 13:59:33 +09:00
? `${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);
2026-05-28 19:37:28 +09:00
updatePatchControls();
return;
}
2026-08-10 13:59:33 +09:00
// 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,
};
2026-08-08 17:41:30 +09:00
result.previewDelta = previewDelta;
2026-08-10 13:59:33 +09:00
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}...`;
2026-08-08 17:41:30 +09:00
const renderStartedAt = performance.now();
2026-08-10 13:59:33 +09:00
const stagedRenderBundle = await stagePreviewRenderBundle(job.world, actualVariant, operation);
if (!stagedRenderBundle || !isPatchOperationCurrent(operation)) return;
publishPreviewRenderBundle(stagedRenderBundle, stagedPatch);
result.publicationStatus = "published";
operation.searchStatus = "published";
2026-08-08 17:41:30 +09:00
const renderMs = performance.now() - renderStartedAt;
2026-08-10 13:59:33 +09:00
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
2026-08-11 21:51:07 +09:00
? `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.`
2026-08-10 13:59:33 +09:00
: `Variant ${actualVariant} completed but is identical to the committed map in the audited change scope.`;
2026-05-28 19:37:28 +09:00
updatePatchControls();
2026-08-10 13:59:33 +09:00
const modeText = `${result.patchMode || patchMode} / ${result.patchGenerationMode}`;
const quality = result.candidateQuality;
const qualityText = quality
2026-08-11 21:51:07 +09:00
? ` / quality ${quality.hardPass ? "PASS" : "FAIL"} ${Number(quality.score || 0).toFixed(3)} / selected terrain variant ${Number(quality.selectedVariant || 0)}`
2026-08-10 13:59:33 +09:00
: "";
const retryText = job.qualityWorkerRetryCount ? ` / quality retries ${job.qualityWorkerRetryCount}` : "";
2026-08-11 21:51:07 +09:00
const searchText = ` / best candidate ${result.candidateOrdinal || 1}/${result.candidateCount || searchPlan.plan.length} / evaluated ${searchAttempts.length || 1}`;
2026-08-10 13:59:33 +09:00
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);
2026-05-28 19:37:28 +09:00
} catch (error) {
2026-08-08 17:41:30 +09:00
if (error?.name === "AbortError") {
2026-08-10 13:59:33 +09:00
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);
}
2026-08-08 17:41:30 +09:00
return;
}
if (requestId !== patchRequestSeq) return;
2026-05-29 23:49:02 +09:00
const reason = error?.message || String(error || "unknown patch error");
2026-08-08 17:41:30 +09:00
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}.`;
2026-08-10 13:59:33 +09:00
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,
});
2026-05-29 23:49:02 +09:00
recordDiagnosticLog("error", "Patch exception", reason, { kind, terrainType, variant });
2026-08-10 13:59:33 +09:00
finishProgress(state.patchStatusMessage, null, 1800);
2026-08-08 17:41:30 +09:00
console.error("Patch generation failed", error);
} finally {
if (requestId === patchRequestSeq) {
state.patchBusy = false;
state.patchBusyVariant = null;
2026-08-10 13:59:33 +09:00
if (activePatchOperation === operation) activePatchOperation = null;
2026-08-08 17:41:30 +09:00
updatePatchControls();
}
2026-05-28 19:37:28 +09:00
}
}
2026-05-28 23:51:55 +09:00
async function generateAlternativePatch() {
2026-08-10 13:59:33 +09:00
if (state.patchBusy || state.fullGenerationBusy) return;
2026-05-28 23:51:55 +09:00
const validation = validatePatchRect(state.selectionRect, state.world);
if (!validation.ok) {
updatePatchControls();
return;
}
2026-08-10 13:59:33 +09:00
const nextVariant = Number.isFinite(patchSearchSeries.nextVariant)
? patchSearchSeries.nextVariant >>> 0
: (readPatchVariant() + 1) >>> 0;
setPatchVariant(nextVariant, { update: false });
2026-05-29 23:49:02 +09:00
await generateSelectedPatch("Patch alternative");
2026-05-28 23:51:55 +09:00
}
2026-08-10 13:59:33 +09:00
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;
}
}
2026-05-28 19:37:28 +09:00
function redraw(options = {}) {
2026-05-29 23:49:02 +09:00
const redrawStartedAt = performance.now();
2026-05-29 22:00:42 +09:00
const renderWorld = displayWorld();
if (!renderWorld) return;
2026-05-29 14:31:42 +09:00
const viewSize = syncViewportSize();
2026-08-08 17:41:30 +09:00
// 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.
2026-08-10 13:59:33 +09:00
const mayExpand = !state.pendingPatch && !state.patchBusy && !state.fullGenerationBusy && options.allowWorldExpand !== false;
2026-05-29 22:00:42 +09:00
const expansion = mayExpand ? ensureWorldPaddingForCamera(state.world, state.camera, viewSize.width, viewSize.height) : null;
2026-05-28 23:51:55 +09:00
if (expansion?.expanded) {
2026-08-10 13:59:33 +09:00
advanceCommittedRevision();
2026-05-29 18:50:54 +09:00
const ex = expansion.dx || 0;
const ey = expansion.dy || 0;
2026-05-29 23:49:02 +09:00
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,
};
2026-05-29 18:50:54 +09:00
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 };
}
2026-05-28 23:51:55 +09:00
if (state.selectionRect) {
2026-08-10 13:59:33 +09:00
replaceSelectionRect({
2026-05-29 14:31:42 +09:00
...state.selectionRect,
2026-05-29 18:50:54 +09:00
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,
2026-08-10 13:59:33 +09:00
});
2026-05-28 23:51:55 +09:00
}
2026-08-10 13:59:33 +09:00
state.lastPatchResult = state.world?.lastPatchResult || null;
2026-05-28 23:51:55 +09:00
}
2026-05-29 22:00:42 +09:00
const activeWorldForRender = displayWorld();
state.camera = clampCameraForView(state.camera, viewSize, activeWorldForRender);
2026-05-29 23:49:02 +09:00
const viewportStartedAt = performance.now();
2026-05-29 22:00:42 +09:00
state.viewportMap = getViewportMap(activeWorldForRender, state.camera, viewSize.width, viewSize.height, { light: !!options.fastTerrain });
2026-05-29 23:49:02 +09:00
const viewportMs = performance.now() - viewportStartedAt;
const hoverStartedAt = performance.now();
2026-05-29 14:31:42 +09:00
state.hoverEntities = options.fastTerrain ? [] : buildHoverEntities(state.viewportMap);
2026-05-29 23:49:02 +09:00
const hoverMs = performance.now() - hoverStartedAt;
const drawStartedAt = performance.now();
2026-05-30 03:25:19 +09:00
const renderBreakdown = drawMap(canvas, state.viewportMap, {
2026-05-20 13:50:56 +09:00
mode: state.mode,
2026-05-29 14:31:42 +09:00
showFeatures: state.showFeatures && !options.fastTerrain,
2026-05-28 19:37:28 +09:00
showLabels: state.showLabels && !options.fastTerrain,
2026-08-08 17:41:30 +09:00
showSeamDiagnostics: state.showSeamDiagnostics && !options.fastTerrain,
2026-05-28 19:37:28 +09:00
continuousTerrain: !options.fastTerrain,
2026-05-29 14:31:42 +09:00
fastTerrain: !!options.fastTerrain,
2026-05-28 23:51:55 +09:00
zoom: state.zoom || 1,
2026-05-20 13:50:56 +09:00
});
2026-05-29 23:49:02 +09:00
const drawMs = performance.now() - drawStartedAt;
2026-05-28 23:51:55 +09:00
applyCanvasZoom();
2026-05-28 19:37:28 +09:00
if (state.selectionRect && dragState.mode !== "select") updateSelectionOverlayFromWorldRect();
2026-05-29 23:49:02 +09:00
updateRenderDiagnostics(options, {
viewportMs,
hoverMs,
drawMs,
totalRenderMs: performance.now() - redrawStartedAt,
2026-05-30 03:25:19 +09:00
renderBreakdown,
2026-05-29 23:49:02 +09:00
});
renderAdvancedData();
2026-05-20 13:50:56 +09:00
}
2026-08-10 13:59:33 +09:00
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();
},
};
}
2026-05-20 13:50:56 +09:00
function init() {
renderModeButtons();
2026-05-29 23:49:02 +09:00
setToolMode("pan");
2026-05-20 13:50:56 +09:00
2026-05-29 23:49:02 +09:00
generateMapButton?.addEventListener("click", regenerate);
2026-05-20 13:50:56 +09:00
seedInput.addEventListener("change", regenerate);
seedInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") regenerate();
});
2026-05-28 16:45:00 +09:00
generationTypeInput?.addEventListener("change", regenerate);
2026-05-28 23:51:55 +09:00
patchTerrainTypeInput?.addEventListener("change", () => {
2026-08-10 13:59:33 +09:00
if (state.patchBusy) cancelPatchGeneration({ reason: "Patch generation cancelled because Terrain changed." });
if (state.fullGenerationBusy) return;
2026-05-29 22:00:42 +09:00
discardPendingPatch({ redrawAfter: true });
2026-05-28 23:51:55 +09:00
state.lastPatchResult = null;
2026-08-08 17:41:30 +09:00
state.patchStatusMessage = "";
2026-05-28 23:51:55 +09:00
resetPatchVariant({ update: false });
updatePatchControls();
});
2026-08-08 17:41:30 +09:00
patchModeInput?.addEventListener("change", () => {
2026-08-10 13:59:33 +09:00
if (state.patchBusy) cancelPatchGeneration({ reason: "Patch generation cancelled because Mode changed." });
if (state.fullGenerationBusy) return;
2026-08-08 17:41:30 +09:00
discardPendingPatch({ redrawAfter: true });
state.lastPatchResult = null;
state.patchStatusMessage = "";
updatePatchControls();
renderAdvancedData();
});
patchVariantInput?.addEventListener("change", () => {
2026-08-10 13:59:33 +09:00
if (state.patchBusy) cancelPatchGeneration({ reason: "Patch generation cancelled because Variant changed." });
if (state.fullGenerationBusy) return;
discardPendingPatch({ redrawAfter: true });
2026-08-08 17:41:30 +09:00
state.patchStatusMessage = "";
setPatchVariant(patchVariantInput.value);
});
2026-05-28 23:51:55 +09:00
patchVariantInput?.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
setPatchVariant(patchVariantInput.value);
generateSelectedPatch();
}
});
2026-05-29 23:49:02 +09:00
generatePatchButton?.addEventListener("click", () => generateSelectedPatch("Patch preview"));
2026-05-28 23:51:55 +09:00
alternativePatchButton?.addEventListener("click", generateAlternativePatch);
2026-05-29 23:49:02 +09:00
applyPatchButton?.addEventListener("click", () => hideSelectionOverlay({ commitPreview: true }));
discardPatchButton?.addEventListener("click", () => hideSelectionOverlay({ discardPreview: true }));
2026-08-08 17:41:30 +09:00
clearPatchSelectionButton?.addEventListener("click", () => hideSelectionOverlay({ discardPreview: true }));
2026-08-10 13:59:33 +09:00
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,
};
}
});
2026-05-29 23:49:02 +09:00
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);
2026-05-28 16:45:00 +09:00
2026-05-20 13:50:56 +09:00
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();
});
2026-08-08 17:41:30 +09:00
showSeamDiagnosticsInput?.addEventListener("change", () => {
2026-08-10 13:59:33 +09:00
if (state.patchBusy) cancelPatchGeneration({ reason: "Patch generation cancelled because seam visualization changed." });
2026-08-08 17:41:30 +09:00
state.showSeamDiagnostics = showSeamDiagnosticsInput.checked;
redraw({ fastTerrain: false, allowWorldExpand: false });
});
2026-05-20 13:50:56 +09:00
2026-05-28 00:30:09 +09:00
canvasShell?.setAttribute("tabindex", "0");
2026-05-28 19:37:28 +09:00
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
2026-05-28 23:51:55 +09:00
canvas.addEventListener("wheel", handleCanvasWheel, { passive: false });
2026-05-28 19:37:28 +09:00
canvas.addEventListener("pointerdown", handleMapPointerDown);
canvas.addEventListener("pointermove", handleMapPointerMove);
canvas.addEventListener("pointerup", handleMapPointerUp);
canvas.addEventListener("pointercancel", handleMapPointerUp);
2026-05-20 13:50:56 +09:00
canvas.addEventListener("mousemove", updateTooltip);
2026-05-28 00:30:09 +09:00
canvas.addEventListener("mouseleave", () => {
tooltipEl?.classList.remove("visible");
});
2026-08-08 17:41:30 +09:00
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();
});
2026-05-20 13:50:56 +09:00
2026-05-29 23:49:02 +09:00
let resizeRaf = null;
window.addEventListener("resize", () => {
if (resizeRaf) cancelAnimationFrame(resizeRaf);
resizeRaf = requestAnimationFrame(() => {
resizeRaf = null;
if (state.world) redraw({ allowWorldExpand: false });
else applyCanvasZoom();
});
});
2026-05-28 19:37:28 +09:00
updatePatchControls();
2026-05-29 23:49:02 +09:00
renderAdvancedData();
2026-08-10 13:59:33 +09:00
installAdditionalGenerationE2EHarness();
2026-05-20 13:50:56 +09:00
regenerate();
}
init();