231 lines
10 KiB
JavaScript
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);
|
|
}
|