map/tests/additional-generation-e2e.js
2026-08-10 13:59:33 +09:00

231 lines
10 KiB
JavaScript

import { createWorldMap } from "../src/worldMap.js";
const params = new URLSearchParams(location.search);
const resultEl = document.getElementById("result");
const seed = (Number(params.get("seed")) || 114514) >>> 0;
const startVariant = (Number(params.get("variant")) || 0) >>> 0;
const candidateLimit = Math.max(1, Math.min(3, Number(params.get("candidates")) || 2));
const selectionWidth = Math.max(48, Math.floor(Number(params.get("width")) || 60));
const selectionHeight = Math.max(48, Math.floor(Number(params.get("height")) || 60));
const patchBudgetMs = Math.max(1000, Number(params.get("budgetMs")) || 60000);
function deriveSeed(worldSeed, terrainType, variant) {
let h = (worldSeed >>> 0) ^ 0x9e3779b9;
h = Math.imul(h ^ (variant >>> 0), 668265263) >>> 0;
for (const ch of String(terrainType || "auto")) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
return h >>> 0;
}
function waitForGeneration(worker) {
return new Promise((resolve, reject) => {
const id = 1;
const progress = [];
const onMessage = (event) => {
if (event.data?.id !== id) return;
if (event.data.type === "progress") {
progress.push({ at: performance.now(), ...(event.data.progress || event.data.event || {}) });
return;
}
worker.removeEventListener("message", onMessage);
if (event.data.ok) resolve({ map: event.data.map, progress });
else reject(new Error(event.data.error || "Initial generation failed"));
};
worker.addEventListener("message", onMessage);
worker.addEventListener("error", (event) => reject(new Error(event.message || "Initial generation Worker crashed")), { once: true });
worker.postMessage({ id, seed, options: { terrainType: params.get("terrain") || "auto" } });
});
}
function waitForApplyAck(worker, patch) {
return new Promise((resolve, reject) => {
const applyToken = patch.result?.applyToken;
if (!applyToken) {
reject(new Error("Accepted patch did not provide a transactional Apply token."));
return;
}
const ackId = `e2e-apply-${Date.now()}`;
const timer = setTimeout(() => reject(new Error("Transactional Apply ACK timed out.")), 30000);
const onMessage = (event) => {
const data = event.data || {};
if (data.type !== "patch-apply-ack-result" || data.ackId !== ackId) return;
clearTimeout(timer);
worker.removeEventListener("message", onMessage);
if (data.ok) resolve(data);
else reject(new Error(data.error || "Transactional Apply ACK failed."));
};
worker.addEventListener("message", onMessage);
worker.postMessage({
type: "patch-apply-ack",
ackId,
applyToken,
baseCommittedRevision: 1,
committedRevision: 2,
});
});
}
function heapSnapshot(label) {
return performance.memory ? {
label,
usedJSHeapSize: performance.memory.usedJSHeapSize,
totalJSHeapSize: performance.memory.totalJSHeapSize,
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
} : null;
}
function waitForPatch(worker, message) {
return new Promise((resolve, reject) => {
const progress = [];
const startedAt = performance.now();
const onMessage = (event) => {
if (event.data?.id !== message.id) return;
if (event.data.type === "progress") {
progress.push({ at: performance.now(), ...event.data.progress });
return;
}
worker.removeEventListener("message", onMessage);
if (event.data.ok) resolve({ ...event.data, progress, wallMs: performance.now() - startedAt });
else reject(new Error(event.data.error || "Patch Worker failed"));
};
worker.addEventListener("message", onMessage);
worker.addEventListener("messageerror", () => reject(new Error("Patch result could not be deserialized")), { once: true });
worker.addEventListener("error", (event) => reject(new Error(event.message || "Patch Worker crashed")), { once: true });
worker.postMessage(message);
});
}
function maxProgressGap(progress, start, end) {
const times = [start, ...(progress || []).map((entry) => entry.at), end];
let max = 0;
for (let index = 1; index < times.length; index++) max = Math.max(max, times[index] - times[index - 1]);
return max;
}
async function main() {
const heap = [heapSnapshot("start")].filter(Boolean);
const initialWorker = new Worker(new URL("../src/generationWorker.js", import.meta.url), { type: "module" });
const initialStartedAt = performance.now();
const initial = await waitForGeneration(initialWorker);
const afterInitialHeap = heapSnapshot("after-initial");
if (afterInitialHeap) heap.push(afterInitialHeap);
const initialEndedAt = performance.now();
initialWorker.terminate();
const world = createWorldMap(initial.map);
const rect = {
x0: world.originX + Math.max(0, Math.floor((258 - selectionWidth) / 2)),
y0: world.originY + Math.max(0, Math.floor((183 - selectionHeight) / 2)),
x1: world.originX + Math.max(0, Math.floor((258 - selectionWidth) / 2)) + selectionWidth,
y1: world.originY + Math.max(0, Math.floor((183 - selectionHeight) / 2)) + selectionHeight,
};
if (params.get("shape") === "lasso") {
const insetX = Math.max(4, Math.floor(selectionWidth * 0.16));
const insetY = Math.max(4, Math.floor(selectionHeight * 0.16));
rect.kind = "lasso";
rect.polygon = [
{ x: rect.x0 + insetX, y: rect.y0 },
{ x: rect.x1 - 1, y: rect.y0 + insetY },
{ x: rect.x1 - insetX, y: rect.y1 - 1 },
{ x: rect.x0, y: rect.y1 - insetY },
];
}
const terrainType = params.get("patchTerrain") || initial.map.terrainTemplate?.terrainType || "auto";
const candidatePlan = Array.from({ length: candidateLimit }, (_, index) => {
const variant = (startVariant + index) >>> 0;
return { candidateId: `e2e:${variant}`, candidateOrdinal: index + 1, variant, seed: deriveSeed(world.seed, terrainType, variant) };
});
const patchWorker = new Worker(new URL("../src/mapPatchWorker.js", import.meta.url), { type: "module" });
const patchStartedAt = performance.now();
const patch = await waitForPatch(patchWorker, {
id: 2,
world,
rect,
options: {
patchMode: params.get("mode") || "regeneration",
terrainType,
variant: startVariant,
seed: candidatePlan[0].seed,
maxQualityRetries: 0,
qualityTerrainAttempts: 1,
acceptBestAvailableQuality: false,
includeSeamVisualization: false,
},
search: {
searchId: "browser-e2e",
operationId: "browser-e2e",
committedRevision: 1,
workerEpoch: 1,
candidatePlan,
totalCandidateCount: candidateLimit,
},
});
const patchEndedAt = performance.now();
const afterPatchHeap = heapSnapshot("after-patch");
if (afterPatchHeap) heap.push(afterPatchHeap);
if (patch.result?.ok !== true) {
throw new Error(`No accepted preview was produced (${patch.result?.code || patch.result?.searchStatus || "unknown rejection"}).`);
}
const applyAck = await waitForApplyAck(patchWorker, patch);
const afterApplyHeap = heapSnapshot("after-apply-ack");
if (afterApplyHeap) heap.push(afterApplyHeap);
patchWorker.terminate();
const attempts = patch.result?.searchAttempts || [];
const boundedEvents = patch.progress.filter((entry) => entry.boundedWork === true);
const invalidBoundedEvents = boundedEvents.filter((entry) => !Number.isFinite(entry.completed)
|| !Number.isFinite(entry.total) || entry.completed < 0 || entry.total < 0 || entry.completed > entry.total);
const assertions = {
workerTransportSucceeded: patch.ok === true,
candidateAuditPresent: attempts.length > 0,
previewPublished: patch.result?.ok === true && patch.result?.searchStatus === "succeeded",
boundedAttempts: attempts.length <= candidateLimit,
fullPipelineTimingsPresent: attempts.every((attempt) => (attempt.patchTimings || []).some((entry) => entry.key === "candidate" || entry.key === "tiled-total" || entry.key === "tiled-regeneration-total")),
noBestAvailableAcceptance: attempts.every((attempt) => attempt.candidateQuality?.acceptedAsBestAvailable !== true),
boundedProgressValid: boundedEvents.length > 0 && invalidBoundedEvents.length === 0,
applyAckHashMatches: applyAck.mirrorHash === patch.result?.acceptedWorldHash,
patchBudgetMet: patch.wallMs < patchBudgetMs,
};
const report = {
status: Object.values(assertions).every(Boolean) ? "pass" : "fail",
environment: {
userAgent: navigator.userAgent,
hardwareConcurrency: navigator.hardwareConcurrency || null,
deviceMemoryGiB: navigator.deviceMemory || null,
crossOriginIsolated,
},
workload: {
seed, startVariant, candidateLimit, selection: rect, selectionWidth, selectionHeight,
selectionShape: rect.kind || "rect", terrainType, patchMode: params.get("mode") || "regeneration",
plannedTileUpperBound: Math.ceil(selectionWidth / Math.floor(258 / 1.72)) * Math.ceil(selectionHeight / Math.floor(183 / 1.72)),
},
timing: {
initialWallMs: initialEndedAt - initialStartedAt,
patchWallMs: patch.wallMs,
patchBudgetMs,
initialMaxProgressGapMs: maxProgressGap(initial.progress, initialStartedAt, initialEndedAt),
patchMaxProgressGapMs: maxProgressGap(patch.progress, patchStartedAt, patchEndedAt),
},
memory: heap.length ? {
snapshots: heap,
peakUsedJSHeapSize: Math.max(...heap.map((entry) => entry.usedJSHeapSize)),
} : null,
assertions,
result: {
ok: patch.result?.ok === true,
code: patch.result?.code || null,
searchStatus: patch.result?.searchStatus || null,
actualVariant: patch.result?.actualVariant ?? null,
nextVariant: patch.result?.nextVariant ?? null,
attempts,
acceptedWorldHash: patch.result?.acceptedWorldHash || null,
applyAck,
},
};
document.documentElement.dataset.status = report.status;
resultEl.textContent = JSON.stringify(report, null, 2);
}
try {
await main();
} catch (error) {
document.documentElement.dataset.status = "fail";
resultEl.textContent = JSON.stringify({ status: "fail", infrastructureError: error?.message || String(error), stack: error?.stack || null }, null, 2);
}