2026-08-10 13:59:33 +09:00
|
|
|
import { capturePatchTransactionSnapshot, generatePatch, generatePatchAsync, restorePatchTransactionSnapshot } from "./mapPatch.js";
|
2026-08-08 17:41:30 +09:00
|
|
|
import { collectTransferableBuffers } from "./transferUtils.js";
|
2026-08-10 13:59:33 +09:00
|
|
|
import { applyCommittedWorldDelta, hashCommittedWorld } from "./committedWorldDelta.js";
|
2026-08-08 17:41:30 +09:00
|
|
|
|
2026-08-10 13:59:33 +09:00
|
|
|
export { applyCommittedWorldDelta as applyCommittedMirrorDelta, hashCommittedWorld } from "./committedWorldDelta.js";
|
|
|
|
|
|
|
|
|
|
let persistentCommittedMirror = null;
|
|
|
|
|
let persistentCommittedRevision = -1;
|
|
|
|
|
let pendingMirrorBootstrap = null;
|
|
|
|
|
const pendingApplyDeltas = new Map();
|
|
|
|
|
|
|
|
|
|
const rawCandidateWorkerSlots = [];
|
|
|
|
|
const rawCandidateWorkerTerminations = [];
|
|
|
|
|
let rawCandidateTaskSerial = 0;
|
|
|
|
|
|
|
|
|
|
function normalizeWorkerTermination(value) {
|
|
|
|
|
return Promise.resolve(value).catch(() => undefined);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function destroyRawCandidateWorkerSlot(index, reason = "Raw patch candidate worker was terminated.") {
|
|
|
|
|
const slot = rawCandidateWorkerSlots[index];
|
|
|
|
|
if (!slot) return rawCandidateWorkerTerminations[index] || Promise.resolve();
|
|
|
|
|
rawCandidateWorkerSlots[index] = null;
|
|
|
|
|
const active = slot.active;
|
|
|
|
|
slot.active = null;
|
|
|
|
|
if (active) {
|
|
|
|
|
const error = new Error(reason);
|
|
|
|
|
error.code = "raw-candidate-worker-terminated";
|
|
|
|
|
active.reject(error);
|
|
|
|
|
}
|
|
|
|
|
let termination;
|
2026-08-08 17:41:30 +09:00
|
|
|
try {
|
2026-08-10 13:59:33 +09:00
|
|
|
termination = normalizeWorkerTermination(slot.worker?.terminate?.());
|
|
|
|
|
} catch {
|
|
|
|
|
termination = Promise.resolve();
|
|
|
|
|
}
|
|
|
|
|
const trackedTermination = termination.finally(() => {
|
|
|
|
|
if (rawCandidateWorkerTerminations[index] === trackedTermination) rawCandidateWorkerTerminations[index] = null;
|
|
|
|
|
});
|
|
|
|
|
rawCandidateWorkerTerminations[index] = trackedTermination;
|
|
|
|
|
return trackedTermination;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function ensureRawCandidateWorkerSlot(index) {
|
|
|
|
|
if (rawCandidateWorkerTerminations[index]) await rawCandidateWorkerTerminations[index];
|
|
|
|
|
const existing = rawCandidateWorkerSlots[index];
|
|
|
|
|
if (existing?.worker) return existing;
|
|
|
|
|
if (typeof Worker !== "function") {
|
|
|
|
|
const error = new Error("Nested module workers are not available in this environment.");
|
|
|
|
|
error.code = "raw-candidate-worker-unavailable";
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
const worker = new Worker(new URL("./patchCandidateWorker.js", import.meta.url), { type: "module" });
|
|
|
|
|
const slot = { worker, active: null, index };
|
|
|
|
|
worker.onmessage = (event) => {
|
|
|
|
|
const message = event.data || {};
|
|
|
|
|
const active = slot.active;
|
|
|
|
|
if (!active || Number(message.id) !== active.id) return;
|
|
|
|
|
if (message.type === "raw-patch-candidate-progress") {
|
|
|
|
|
active.onProgress?.(active.request, message.progress || {});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (message.type !== "raw-patch-candidate-result") return;
|
|
|
|
|
slot.active = null;
|
|
|
|
|
if (!message.ok) {
|
|
|
|
|
const error = new Error(message.error || "Raw patch candidate generation failed.");
|
|
|
|
|
error.code = message.code || "raw-patch-candidate-error";
|
|
|
|
|
error.stack = message.stack || error.stack;
|
|
|
|
|
active.reject(error);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
active.resolve(message.candidate);
|
|
|
|
|
};
|
|
|
|
|
worker.onerror = (event) => {
|
|
|
|
|
const active = slot.active;
|
|
|
|
|
slot.active = null;
|
|
|
|
|
const error = new Error(event?.message || "Raw patch candidate worker failed.");
|
|
|
|
|
error.code = "raw-candidate-worker-error";
|
|
|
|
|
if (active) active.reject(error);
|
|
|
|
|
// Do not reuse a worker that raised an uncaught error. The next task in
|
|
|
|
|
// this lane waits for termination before constructing a replacement.
|
|
|
|
|
destroyRawCandidateWorkerSlot(index, error.message);
|
|
|
|
|
};
|
|
|
|
|
rawCandidateWorkerSlots[index] = slot;
|
|
|
|
|
return slot;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runRawCandidateTask(slotIndex, request, onProgress) {
|
|
|
|
|
const slot = await ensureRawCandidateWorkerSlot(slotIndex);
|
|
|
|
|
if (slot.active) {
|
|
|
|
|
const error = new Error(`Raw candidate worker slot ${slotIndex} is unexpectedly busy.`);
|
|
|
|
|
error.code = "raw-candidate-worker-busy";
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
const id = ++rawCandidateTaskSerial;
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
slot.active = { id, request, onProgress, resolve, reject };
|
|
|
|
|
try {
|
|
|
|
|
slot.worker.postMessage({
|
|
|
|
|
type: "generate-raw-patch-candidate",
|
|
|
|
|
id,
|
|
|
|
|
taskId: request.taskId || `raw-candidate-${id}`,
|
|
|
|
|
seed: request.seed >>> 0,
|
|
|
|
|
mapOptions: request.mapOptions || {},
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
slot.active = null;
|
|
|
|
|
reject(error);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function precomputeRawCandidateBatch(requests, onProgress) {
|
|
|
|
|
if (!Array.isArray(requests) || !requests.length) return [];
|
|
|
|
|
if (requests.length > 2) throw new Error(`Raw candidate batch exceeds the two-worker limit (${requests.length}).`);
|
|
|
|
|
try {
|
|
|
|
|
return await Promise.all(requests.map((request, index) => runRawCandidateTask(index, request, onProgress)));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// A failed child may have left its module worker in an unknown state. Reset
|
|
|
|
|
// the bounded pool before the coordinator falls back to serial generation.
|
|
|
|
|
await Promise.all(rawCandidateWorkerSlots.map((slot, index) => slot?.active ? destroyRawCandidateWorkerSlot(index) : null));
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Continuously feed at most two production helpers instead of waiting for
|
|
|
|
|
// fixed pairs to finish. Merge order remains strictly deterministic in
|
|
|
|
|
// mapPatch.js. Callers choose a bounded sliding window; production uses one
|
|
|
|
|
// resident result per lane so transferred candidate graphs cannot accumulate.
|
|
|
|
|
export function scheduleRawCandidateSequence(requests, onProgress, options = {}) {
|
|
|
|
|
const list = Array.isArray(requests) ? requests : [];
|
|
|
|
|
const count = list.length;
|
|
|
|
|
const laneCount = Math.max(1, Math.min(2, Math.floor(options.parallelism || 2), Math.max(1, count)));
|
|
|
|
|
const windowSize = Math.max(laneCount, Math.min(count || laneCount, Math.floor(options.windowSize || (laneCount + 1))));
|
|
|
|
|
const recycleWorkers = options.recycleWorkers === true;
|
|
|
|
|
// Keep each helper for a small bounded run. Reusing it forever lets
|
|
|
|
|
// generateMap's module-local/cache state accumulate; recycling after every
|
|
|
|
|
// tile creates excessive isolate churn on max-range selections. Three
|
|
|
|
|
// complete candidates per lane is the bounded middle ground.
|
|
|
|
|
const recycleEvery = recycleWorkers
|
|
|
|
|
? Math.max(1, Math.min(4, Math.floor(options.recycleEvery || 3)))
|
|
|
|
|
: Infinity;
|
|
|
|
|
const controls = list.map(() => {
|
|
|
|
|
let resolve;
|
|
|
|
|
let reject;
|
|
|
|
|
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
|
|
|
|
// The coordinator can stop after an earlier failure. Register a rejection
|
|
|
|
|
// observer now so later cancelled items cannot become unhandled rejections.
|
|
|
|
|
promise.catch(() => undefined);
|
|
|
|
|
return { promise, resolve, reject, settled: false };
|
|
|
|
|
});
|
|
|
|
|
const released = new Uint8Array(count);
|
|
|
|
|
let releasedPrefix = 0;
|
|
|
|
|
let nextToSchedule = 0;
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
let cancelError = null;
|
|
|
|
|
const gateWaiters = new Set();
|
|
|
|
|
|
|
|
|
|
const wake = () => {
|
|
|
|
|
for (const resolve of gateWaiters) resolve();
|
|
|
|
|
gateWaiters.clear();
|
|
|
|
|
};
|
|
|
|
|
const waitForGate = () => new Promise((resolve) => gateWaiters.add(resolve));
|
|
|
|
|
const allowedEnd = () => Math.min(count, releasedPrefix + windowSize);
|
|
|
|
|
const takeNext = () => {
|
|
|
|
|
if (cancelled || nextToSchedule >= count) return -1;
|
|
|
|
|
if (nextToSchedule >= allowedEnd()) return -2;
|
|
|
|
|
return nextToSchedule++;
|
|
|
|
|
};
|
|
|
|
|
const failAll = (error) => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
cancelled = true;
|
|
|
|
|
cancelError = error instanceof Error ? error : new Error(String(error || "Raw candidate sequence cancelled."));
|
|
|
|
|
for (const control of controls) {
|
|
|
|
|
if (!control?.settled) {
|
|
|
|
|
control.settled = true;
|
|
|
|
|
const reject = control.reject;
|
|
|
|
|
// Promise capability functions keep their Promise reachable. Clear
|
|
|
|
|
// both callbacks as soon as the public Promise has settled so a
|
|
|
|
|
// transferred raw candidate cannot remain retained through scheduler
|
|
|
|
|
// bookkeeping after the coordinator drops publicPromises[index].
|
|
|
|
|
control.resolve = null;
|
|
|
|
|
control.reject = null;
|
|
|
|
|
reject?.(cancelError);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (let index = 0; index < laneCount; index++) {
|
|
|
|
|
if (rawCandidateWorkerSlots[index]?.active) destroyRawCandidateWorkerSlot(index, cancelError.message);
|
|
|
|
|
}
|
|
|
|
|
wake();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const lane = async (slotIndex) => {
|
|
|
|
|
let tasksSinceRecycle = 0;
|
|
|
|
|
while (!cancelled) {
|
|
|
|
|
const index = takeNext();
|
|
|
|
|
if (index === -1) return;
|
|
|
|
|
if (index === -2) {
|
|
|
|
|
await waitForGate();
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
let candidate = await runRawCandidateTask(slotIndex, list[index], onProgress);
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
const control = controls[index];
|
|
|
|
|
if (!control.settled) {
|
|
|
|
|
control.settled = true;
|
|
|
|
|
const resolve = control.resolve;
|
|
|
|
|
control.resolve = null;
|
|
|
|
|
control.reject = null;
|
|
|
|
|
resolve?.(candidate);
|
|
|
|
|
}
|
|
|
|
|
// The Promise result owns the transferred graph now. Do not keep an
|
|
|
|
|
// additional lane-local reference while waiting for the next window.
|
|
|
|
|
candidate = null;
|
|
|
|
|
// Reclaim helper-local generator state before it can grow without
|
|
|
|
|
// paying module-worker startup/teardown cost on every single tile.
|
|
|
|
|
tasksSinceRecycle++;
|
|
|
|
|
if (recycleWorkers && tasksSinceRecycle >= recycleEvery) {
|
|
|
|
|
await destroyRawCandidateWorkerSlot(slotIndex, `Raw candidate helper recycled after ${tasksSinceRecycle} completed tiles.`);
|
|
|
|
|
tasksSinceRecycle = 0;
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
failAll(error);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Keep the fulfilled Promise graph only in the public array. Once the
|
|
|
|
|
// coordinator nulls an entry after merge, scheduler bookkeeping must not keep
|
|
|
|
|
// the transferred candidate reachable for the rest of the sequence.
|
|
|
|
|
const publicPromises = controls.map((control) => control.promise);
|
|
|
|
|
for (const control of controls) control.promise = null;
|
|
|
|
|
const lanes = count ? Array.from({ length: laneCount }, (_, index) => lane(index)) : [];
|
|
|
|
|
const done = Promise.allSettled(lanes).then(() => undefined);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
promises: publicPromises,
|
|
|
|
|
release(index) {
|
|
|
|
|
const i = Math.floor(Number(index));
|
|
|
|
|
if (i < 0 || i >= count || released[i]) return;
|
|
|
|
|
released[i] = 1;
|
|
|
|
|
while (releasedPrefix < count && released[releasedPrefix]) releasedPrefix++;
|
|
|
|
|
wake();
|
|
|
|
|
},
|
|
|
|
|
cancel(reason = "Raw candidate sequence cancelled.") {
|
|
|
|
|
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
|
|
|
if (!error.code) error.code = "raw-candidate-sequence-cancelled";
|
|
|
|
|
failAll(error);
|
|
|
|
|
return done;
|
|
|
|
|
},
|
|
|
|
|
done,
|
|
|
|
|
async dispose(reason = "Raw candidate sequence disposed.") {
|
|
|
|
|
await done;
|
|
|
|
|
await Promise.allSettled(Array.from({ length: laneCount }, (_, index) =>
|
|
|
|
|
rawCandidateWorkerSlots[index] ? destroyRawCandidateWorkerSlot(index, reason) : rawCandidateWorkerTerminations[index]
|
|
|
|
|
).filter(Boolean));
|
|
|
|
|
},
|
|
|
|
|
get cancelled() { return cancelled; },
|
|
|
|
|
get error() { return cancelError; },
|
|
|
|
|
laneCount,
|
|
|
|
|
windowSize,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function shutdownRawCandidateWorkers() {
|
|
|
|
|
const pending = [];
|
|
|
|
|
for (let index = 0; index < rawCandidateWorkerSlots.length; index++) {
|
|
|
|
|
if (rawCandidateWorkerSlots[index]) pending.push(destroyRawCandidateWorkerSlot(index, "Raw candidate operation completed."));
|
|
|
|
|
else if (rawCandidateWorkerTerminations[index]) pending.push(rawCandidateWorkerTerminations[index]);
|
|
|
|
|
}
|
|
|
|
|
if (pending.length) await Promise.allSettled(pending);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isMirrorSyncMessage(type) {
|
|
|
|
|
return typeof type === "string" && type.startsWith("patch-mirror-sync-");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function safeMirrorKey(key, label = "key") {
|
|
|
|
|
const normalized = String(key ?? "");
|
|
|
|
|
if (!normalized || normalized === "__proto__" || normalized === "prototype" || normalized === "constructor") {
|
|
|
|
|
throw new Error(`Invalid mirror ${label}: ${normalized || "<empty>"}.`);
|
|
|
|
|
}
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function defineMirrorEntry(target, key, value) {
|
|
|
|
|
Object.defineProperty(target, key, {
|
|
|
|
|
value,
|
|
|
|
|
enumerable: true,
|
|
|
|
|
configurable: true,
|
|
|
|
|
writable: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sameMirrorKeys(actual, expected) {
|
|
|
|
|
const actualKeys = Object.keys(actual || {});
|
|
|
|
|
if (!Array.isArray(expected) || actualKeys.length !== expected.length) return false;
|
|
|
|
|
const actualSet = new Set(actualKeys);
|
|
|
|
|
return expected.every((key) => actualSet.has(key));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateCompletedMirrorBootstrap(bootstrap) {
|
|
|
|
|
if (!bootstrap?.world || !bootstrap?.manifest) throw new Error("Mirror bootstrap is incomplete.");
|
|
|
|
|
const { world, manifest } = bootstrap;
|
|
|
|
|
if (!sameMirrorKeys(world.fields, manifest.fieldKeys)) throw new Error("Mirror field manifest is incomplete.");
|
|
|
|
|
if (!sameMirrorKeys(world.sourceMap, manifest.sourceKeys)) throw new Error("Mirror sourceMap manifest is incomplete.");
|
|
|
|
|
const rootActual = Object.keys(world).filter((key) => key !== "fields" && key !== "sourceMap" && key !== "generatedMask");
|
|
|
|
|
const rootExpected = Array.isArray(manifest.rootKeys) ? manifest.rootKeys : [];
|
|
|
|
|
if (rootActual.length !== rootExpected.length || !rootExpected.every((key) => rootActual.includes(key))) {
|
|
|
|
|
throw new Error("Mirror root manifest is incomplete.");
|
|
|
|
|
}
|
|
|
|
|
if (!!manifest.hasGeneratedMask !== !!world.generatedMask) throw new Error("Mirror generatedMask manifest is incomplete.");
|
|
|
|
|
const width = Number(world.width);
|
|
|
|
|
const height = Number(world.height);
|
|
|
|
|
const area = width * height;
|
|
|
|
|
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0 || !Number.isSafeInteger(area) || area <= 0) {
|
|
|
|
|
throw new Error("Mirror dimensions are invalid.");
|
|
|
|
|
}
|
|
|
|
|
for (const key of manifest.fieldKeys || []) {
|
|
|
|
|
const field = world.fields[key];
|
|
|
|
|
if (!ArrayBuffer.isView(field) || field.length !== area) {
|
|
|
|
|
throw new Error(`Mirror field ${key} has invalid storage (${field?.length ?? "missing"} != ${area}).`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (manifest.hasGeneratedMask && (!ArrayBuffer.isView(world.generatedMask) || world.generatedMask.length !== area)) {
|
|
|
|
|
throw new Error(`Mirror generatedMask has invalid storage (${world.generatedMask?.length ?? "missing"} != ${area}).`);
|
|
|
|
|
}
|
|
|
|
|
for (const [key, childKeys] of Object.entries(manifest.expandedSourceObjects || {})) {
|
|
|
|
|
if (!world.sourceMap[key] || !sameMirrorKeys(world.sourceMap[key], childKeys)) {
|
|
|
|
|
throw new Error(`Mirror sourceMap.${key} manifest is incomplete.`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleMirrorSyncMessage(incoming) {
|
|
|
|
|
if (!isMirrorSyncMessage(incoming?.type)) return false;
|
|
|
|
|
const type = incoming.type;
|
|
|
|
|
const syncId = String(incoming.syncId || "");
|
|
|
|
|
const sequence = Number(incoming.sequence || 0);
|
|
|
|
|
const acknowledge = (ok, error = null, extra = {}) => {
|
|
|
|
|
self.postMessage({
|
|
|
|
|
id: incoming.id,
|
|
|
|
|
type: "patch-mirror-sync-ack",
|
|
|
|
|
syncId,
|
|
|
|
|
sequence,
|
|
|
|
|
stage: type,
|
|
|
|
|
ok,
|
|
|
|
|
error,
|
|
|
|
|
...extra,
|
2026-08-08 17:41:30 +09:00
|
|
|
});
|
2026-08-10 13:59:33 +09:00
|
|
|
};
|
|
|
|
|
try {
|
|
|
|
|
if (!syncId || !Number.isSafeInteger(sequence) || sequence <= 0) throw new Error("Invalid mirror synchronization envelope.");
|
|
|
|
|
if (type === "patch-mirror-sync-start") {
|
|
|
|
|
const manifest = incoming.manifest || {};
|
|
|
|
|
if (!Array.isArray(manifest.rootKeys) || !Array.isArray(manifest.fieldKeys) || !Array.isArray(manifest.sourceKeys)) {
|
|
|
|
|
throw new Error("Mirror synchronization manifest is invalid.");
|
|
|
|
|
}
|
|
|
|
|
pendingMirrorBootstrap = {
|
|
|
|
|
id: incoming.id,
|
|
|
|
|
syncId,
|
|
|
|
|
committedRevision: Number(incoming.committedRevision ?? -1),
|
|
|
|
|
lastSequence: sequence,
|
|
|
|
|
manifest,
|
|
|
|
|
world: { fields: {}, sourceMap: {} },
|
|
|
|
|
};
|
|
|
|
|
acknowledge(true);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
const bootstrap = pendingMirrorBootstrap;
|
|
|
|
|
if (!bootstrap || bootstrap.syncId !== syncId || bootstrap.id !== incoming.id) throw new Error("Mirror synchronization session is unavailable or stale.");
|
|
|
|
|
if (sequence <= bootstrap.lastSequence) throw new Error(`Mirror synchronization sequence did not advance (${sequence} <= ${bootstrap.lastSequence}).`);
|
|
|
|
|
bootstrap.lastSequence = sequence;
|
|
|
|
|
if (type === "patch-mirror-sync-root") {
|
|
|
|
|
const key = safeMirrorKey(incoming.key, "root key");
|
|
|
|
|
if (!bootstrap.manifest.rootKeys.includes(key)) throw new Error(`Unexpected mirror root key ${key}.`);
|
|
|
|
|
defineMirrorEntry(bootstrap.world, key, incoming.value);
|
|
|
|
|
} else if (type === "patch-mirror-sync-field") {
|
|
|
|
|
const key = safeMirrorKey(incoming.key, "field key");
|
|
|
|
|
if (!bootstrap.manifest.fieldKeys.includes(key)) throw new Error(`Unexpected mirror field ${key}.`);
|
|
|
|
|
defineMirrorEntry(bootstrap.world.fields, key, incoming.value);
|
|
|
|
|
} else if (type === "patch-mirror-sync-generated-mask") {
|
|
|
|
|
if (!bootstrap.manifest.hasGeneratedMask) throw new Error("Unexpected mirror generatedMask.");
|
|
|
|
|
bootstrap.world.generatedMask = incoming.value;
|
|
|
|
|
} else if (type === "patch-mirror-sync-source") {
|
|
|
|
|
const key = safeMirrorKey(incoming.key, "source key");
|
|
|
|
|
if (!bootstrap.manifest.sourceKeys.includes(key)) throw new Error(`Unexpected mirror source key ${key}.`);
|
|
|
|
|
if (Object.prototype.hasOwnProperty.call(bootstrap.manifest.expandedSourceObjects || {}, key)) {
|
|
|
|
|
throw new Error(`Mirror source key ${key} must be synchronized by child entries.`);
|
|
|
|
|
}
|
|
|
|
|
defineMirrorEntry(bootstrap.world.sourceMap, key, incoming.value);
|
|
|
|
|
} else if (type === "patch-mirror-sync-source-object-start") {
|
|
|
|
|
const key = safeMirrorKey(incoming.key, "source object key");
|
|
|
|
|
if (!Object.prototype.hasOwnProperty.call(bootstrap.manifest.expandedSourceObjects || {}, key)) {
|
|
|
|
|
throw new Error(`Unexpected expanded mirror source key ${key}.`);
|
|
|
|
|
}
|
|
|
|
|
defineMirrorEntry(bootstrap.world.sourceMap, key, {});
|
|
|
|
|
} else if (type === "patch-mirror-sync-source-object-entry") {
|
|
|
|
|
const key = safeMirrorKey(incoming.key, "source object key");
|
|
|
|
|
const childKey = safeMirrorKey(incoming.childKey, "source child key");
|
|
|
|
|
const expectedChildren = bootstrap.manifest.expandedSourceObjects?.[key];
|
|
|
|
|
if (!Array.isArray(expectedChildren) || !expectedChildren.includes(childKey) || !bootstrap.world.sourceMap[key]) {
|
|
|
|
|
throw new Error(`Unexpected mirror source child ${key}.${childKey}.`);
|
|
|
|
|
}
|
|
|
|
|
defineMirrorEntry(bootstrap.world.sourceMap[key], childKey, incoming.value);
|
|
|
|
|
} else if (type === "patch-mirror-sync-finish") {
|
|
|
|
|
validateCompletedMirrorBootstrap(bootstrap);
|
|
|
|
|
persistentCommittedMirror = bootstrap.world;
|
|
|
|
|
persistentCommittedRevision = bootstrap.committedRevision;
|
|
|
|
|
pendingMirrorBootstrap = null;
|
|
|
|
|
pendingApplyDeltas.clear();
|
|
|
|
|
acknowledge(true, null, { mirrorCommittedRevision: persistentCommittedRevision });
|
|
|
|
|
return true;
|
|
|
|
|
} else if (type === "patch-mirror-sync-abort") {
|
|
|
|
|
pendingMirrorBootstrap = null;
|
|
|
|
|
acknowledge(true);
|
|
|
|
|
return true;
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error(`Unknown mirror synchronization stage ${type}.`);
|
|
|
|
|
}
|
|
|
|
|
acknowledge(true);
|
2026-08-08 17:41:30 +09:00
|
|
|
} catch (error) {
|
2026-08-10 13:59:33 +09:00
|
|
|
if (type === "patch-mirror-sync-start" || pendingMirrorBootstrap?.syncId === syncId) pendingMirrorBootstrap = null;
|
|
|
|
|
acknowledge(false, error?.message || String(error));
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function valuesEqual(a, b) {
|
|
|
|
|
return a === b || (Number.isNaN(a) && Number.isNaN(b));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function exactStructuredEqual(a, b) {
|
|
|
|
|
if (valuesEqual(a, 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 (!valuesEqual(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 (!exactStructuredEqual(a[index], b[index])) return false;
|
|
|
|
|
// Generated paths use one enumerable array annotation. Compare it directly
|
|
|
|
|
// instead of allocating Object.keys arrays for every coordinate tuple.
|
|
|
|
|
return a.patchGenerated === b.patchGenerated;
|
|
|
|
|
}
|
|
|
|
|
if (a instanceof Date || b instanceof Date) return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
|
|
|
|
|
if (a instanceof Map || b instanceof Map) {
|
|
|
|
|
if (!(a instanceof Map) || !(b instanceof Map) || a.size !== b.size) return false;
|
|
|
|
|
for (const [key, value] of a) if (!b.has(key) || !exactStructuredEqual(value, b.get(key))) return false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (a instanceof Set || b instanceof Set) {
|
|
|
|
|
if (!(a instanceof Set) || !(b instanceof Set) || a.size !== b.size) return false;
|
|
|
|
|
for (const value of a) if (!b.has(value)) return false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
const aKeys = Object.keys(a);
|
|
|
|
|
const bKeys = Object.keys(b);
|
|
|
|
|
if (aKeys.length !== bKeys.length) return false;
|
|
|
|
|
for (let index = 0; index < aKeys.length; index++) {
|
|
|
|
|
const key = aKeys[index];
|
|
|
|
|
if (key !== bKeys[index] || !exactStructuredEqual(a[key], b[key])) return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isDensePlainArray(value) {
|
|
|
|
|
if (!Array.isArray(value)) return false;
|
|
|
|
|
const keys = Object.keys(value);
|
|
|
|
|
if (keys.length !== value.length) return false;
|
|
|
|
|
for (let index = 0; index < keys.length; index++) if (keys[index] !== String(index)) return false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildExactArraySplice(base, next, { cloneValues = true } = {}) {
|
|
|
|
|
if (!isDensePlainArray(base) || !isDensePlainArray(next)) return null;
|
|
|
|
|
const commonLimit = Math.min(base.length, next.length);
|
|
|
|
|
let start = 0;
|
|
|
|
|
while (start < commonLimit && exactStructuredEqual(base[start], next[start])) start++;
|
|
|
|
|
if (start === base.length && start === next.length) return { unchanged: true };
|
|
|
|
|
let suffix = 0;
|
|
|
|
|
while (suffix < commonLimit - start
|
|
|
|
|
&& exactStructuredEqual(base[base.length - 1 - suffix], next[next.length - 1 - suffix])) suffix++;
|
|
|
|
|
const items = next.slice(start, next.length - suffix);
|
|
|
|
|
return {
|
|
|
|
|
start,
|
|
|
|
|
deleteCount: base.length - start - suffix,
|
|
|
|
|
items: cloneValues ? structuredClone(items) : items,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildExactObjectDelta(base = {}, next = {}, { cloneValues = true } = {}) {
|
|
|
|
|
const set = {};
|
|
|
|
|
const arraySplices = {};
|
|
|
|
|
for (const [key, value] of Object.entries(next || {})) {
|
|
|
|
|
if (!Object.prototype.hasOwnProperty.call(base || {}, key)) {
|
|
|
|
|
set[key] = cloneValues ? structuredClone(value) : value;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (isDensePlainArray(base[key]) && isDensePlainArray(value)) {
|
|
|
|
|
const splice = buildExactArraySplice(base[key], value, { cloneValues });
|
|
|
|
|
if (splice && !splice.unchanged) arraySplices[key] = splice;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!exactStructuredEqual(base[key], value)) set[key] = cloneValues ? structuredClone(value) : value;
|
|
|
|
|
}
|
|
|
|
|
const removed = Object.keys(base || {}).filter((key) => !Object.prototype.hasOwnProperty.call(next || {}, key));
|
|
|
|
|
return { set, arraySplices, removed };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildTypedRowDelta(base, next, width) {
|
|
|
|
|
if (!ArrayBuffer.isView(next)) return null;
|
|
|
|
|
if (!ArrayBuffer.isView(base) || base.constructor?.name !== next.constructor?.name || base.length !== next.length) {
|
|
|
|
|
return { constructorName: next.constructor.name, length: next.length, replace: new next.constructor(next) };
|
|
|
|
|
}
|
|
|
|
|
const rows = [];
|
|
|
|
|
const rowWidth = Math.max(1, Number(width || next.length));
|
|
|
|
|
for (let rowStart = 0; rowStart < next.length; rowStart += rowWidth) {
|
|
|
|
|
const rowEnd = Math.min(next.length, rowStart + rowWidth);
|
|
|
|
|
let first = -1;
|
|
|
|
|
let last = -1;
|
|
|
|
|
for (let index = rowStart; index < rowEnd; index++) {
|
|
|
|
|
if (valuesEqual(base[index], next[index])) continue;
|
|
|
|
|
if (first < 0) first = index;
|
|
|
|
|
last = index;
|
|
|
|
|
}
|
|
|
|
|
if (first >= 0) rows.push({ start: first, values: new next.constructor(next.subarray(first, last + 1)) });
|
2026-08-08 17:41:30 +09:00
|
|
|
}
|
2026-08-10 13:59:33 +09:00
|
|
|
return rows.length ? { constructorName: next.constructor.name, length: next.length, rows } : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildCommittedMirrorDelta(baseWorld, nextWorld) {
|
|
|
|
|
if (!baseWorld || !nextWorld) throw new Error("Cannot build a committed mirror delta without both worlds.");
|
|
|
|
|
const fieldNames = new Set([...Object.keys(baseWorld.fields || {}), ...Object.keys(nextWorld.fields || {})]);
|
|
|
|
|
const fields = {};
|
|
|
|
|
for (const name of fieldNames) {
|
|
|
|
|
const next = nextWorld.fields?.[name];
|
|
|
|
|
if (!next) {
|
|
|
|
|
fields[name] = { remove: true };
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const delta = buildTypedRowDelta(baseWorld.fields?.[name], next, nextWorld.width);
|
|
|
|
|
if (delta) fields[name] = delta;
|
|
|
|
|
}
|
|
|
|
|
const generatedMask = buildTypedRowDelta(baseWorld.generatedMask, nextWorld.generatedMask, nextWorld.width);
|
|
|
|
|
const baseMeta = {};
|
|
|
|
|
const nextMeta = {};
|
|
|
|
|
for (const [key, value] of Object.entries(baseWorld)) {
|
|
|
|
|
if (key !== "fields" && key !== "generatedMask" && key !== "sourceMap") baseMeta[key] = value;
|
|
|
|
|
}
|
|
|
|
|
for (const [key, value] of Object.entries(nextWorld)) {
|
|
|
|
|
if (key !== "fields" && key !== "generatedMask" && key !== "sourceMap") nextMeta[key] = value;
|
|
|
|
|
}
|
|
|
|
|
const exactDeltas = structuredClone({
|
|
|
|
|
sourceMapDelta: buildExactObjectDelta(baseWorld.sourceMap || {}, nextWorld.sourceMap || {}, { cloneValues: false }),
|
|
|
|
|
metaDelta: buildExactObjectDelta(baseMeta, nextMeta, { cloneValues: false }),
|
|
|
|
|
});
|
|
|
|
|
return {
|
|
|
|
|
width: nextWorld.width,
|
|
|
|
|
height: nextWorld.height,
|
|
|
|
|
fields,
|
|
|
|
|
generatedMask,
|
|
|
|
|
sourceMapDelta: exactDeltas.sourceMapDelta,
|
|
|
|
|
metaDelta: exactDeltas.metaDelta,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildMainThreadTransferDelta(delta) {
|
|
|
|
|
if (!delta) return null;
|
|
|
|
|
// Raster row buffers are transferred to the main thread and therefore need
|
|
|
|
|
// a Worker-side copy so the retained Apply-ACK delta is not detached. Plain
|
|
|
|
|
// metadata is already cloned once by postMessage; cloning that graph here as
|
|
|
|
|
// well only doubled paths, points, histories and diagnostics at peak memory.
|
|
|
|
|
const raster = structuredClone({
|
|
|
|
|
fields: delta.fields || {},
|
|
|
|
|
generatedMask: delta.generatedMask || null,
|
|
|
|
|
});
|
|
|
|
|
return {
|
|
|
|
|
...delta,
|
|
|
|
|
fields: raster.fields,
|
|
|
|
|
generatedMask: raster.generatedMask,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function markPreviewChange(changeTracker, worldIndex, category = 0) {
|
|
|
|
|
if (!changeTracker?.mask) return;
|
|
|
|
|
const width = changeTracker.worldWidth;
|
|
|
|
|
const x = worldIndex % width;
|
|
|
|
|
const y = Math.floor(worldIndex / width);
|
|
|
|
|
const rect = changeTracker.rect;
|
|
|
|
|
if (x < rect.x0 || y < rect.y0 || x >= rect.x1 || y >= rect.y1) return;
|
|
|
|
|
const localIndex = (y - rect.y0) * changeTracker.width + (x - rect.x0);
|
|
|
|
|
changeTracker.mask[localIndex] |= 1 | category;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function markAllPreviewChanges(changeTracker, category = 0) {
|
|
|
|
|
if (!changeTracker?.mask) return;
|
|
|
|
|
const flags = 1 | category;
|
|
|
|
|
changeTracker.mask.fill(flags);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildTypedRowDeltaFromTransaction(snapshot, name, next, changeTracker = null) {
|
|
|
|
|
if (!ArrayBuffer.isView(next)) return null;
|
|
|
|
|
const baseRef = snapshot?.fieldRefs?.get(name);
|
|
|
|
|
if (!ArrayBuffer.isView(baseRef) || baseRef.constructor?.name !== next.constructor?.name || baseRef.length !== next.length) {
|
|
|
|
|
const category = PREVIEW_TERRAIN_FIELDS.has(name) ? 2 : PREVIEW_ADMIN_FIELDS.has(name) ? 4 : 0;
|
|
|
|
|
markAllPreviewChanges(changeTracker, category);
|
|
|
|
|
return { constructorName: next.constructor.name, length: next.length, replace: new next.constructor(next) };
|
|
|
|
|
}
|
|
|
|
|
const localEntry = snapshot?.fields?.get(name) || null;
|
|
|
|
|
const rect = snapshot?.fieldRect || null;
|
|
|
|
|
const width = Math.max(1, Number(snapshot?.width || next.length));
|
|
|
|
|
const rows = [];
|
|
|
|
|
const scanLocalRect = localEntry && rect;
|
|
|
|
|
const startY = scanLocalRect ? Math.max(0, rect.y0) : 0;
|
|
|
|
|
const endY = scanLocalRect ? Math.min(snapshot.height, rect.y1) : Math.ceil(next.length / width);
|
|
|
|
|
for (let y = startY; y < endY; y++) {
|
|
|
|
|
const rowStart = y * width;
|
|
|
|
|
const rowEnd = Math.min(next.length, rowStart + width);
|
|
|
|
|
const scanStart = scanLocalRect ? Math.max(rowStart, rowStart + rect.x0) : rowStart;
|
|
|
|
|
const scanEnd = scanLocalRect ? Math.min(rowEnd, rowStart + rect.x1) : rowEnd;
|
|
|
|
|
let first = -1;
|
|
|
|
|
let last = -1;
|
|
|
|
|
for (let index = scanStart; index < scanEnd; index++) {
|
|
|
|
|
const x = index - rowStart;
|
|
|
|
|
let oldValue = baseRef[index];
|
|
|
|
|
if (localEntry && rect && x >= rect.x0 && x < rect.x1 && y >= rect.y0 && y < rect.y1) {
|
|
|
|
|
oldValue = localEntry.data[(y - rect.y0) * snapshot.fieldWidth + (x - rect.x0)];
|
|
|
|
|
}
|
|
|
|
|
if (valuesEqual(oldValue, next[index])) continue;
|
|
|
|
|
const category = PREVIEW_TERRAIN_FIELDS.has(name) ? 2 : PREVIEW_ADMIN_FIELDS.has(name) ? 4 : 0;
|
|
|
|
|
markPreviewChange(changeTracker, index, category);
|
|
|
|
|
if (first < 0) first = index;
|
|
|
|
|
last = index;
|
|
|
|
|
}
|
|
|
|
|
if (first >= 0) rows.push({ start: first, values: new next.constructor(next.subarray(first, last + 1)) });
|
|
|
|
|
}
|
|
|
|
|
return rows.length ? { constructorName: next.constructor.name, length: next.length, rows } : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildCommittedMirrorDeltaFromTransaction(snapshot, nextWorld) {
|
|
|
|
|
if (!snapshot || snapshot.lightweight || !nextWorld) throw new Error("A full patch transaction snapshot is required.");
|
|
|
|
|
const fieldNames = new Set([...(snapshot.fieldNames || []), ...Object.keys(nextWorld.fields || {})]);
|
|
|
|
|
const fields = {};
|
|
|
|
|
const snapshotRect = snapshot.fieldRect || { x0: 0, y0: 0, x1: nextWorld.width, y1: nextWorld.height };
|
|
|
|
|
const changeRect = {
|
|
|
|
|
x0: Math.max(0, Math.floor(snapshotRect.x0 || 0)),
|
|
|
|
|
y0: Math.max(0, Math.floor(snapshotRect.y0 || 0)),
|
|
|
|
|
x1: Math.min(nextWorld.width, Math.ceil(snapshotRect.x1 || 0)),
|
|
|
|
|
y1: Math.min(nextWorld.height, Math.ceil(snapshotRect.y1 || 0)),
|
|
|
|
|
};
|
|
|
|
|
const changeTracker = {
|
|
|
|
|
rect: changeRect,
|
|
|
|
|
width: Math.max(0, changeRect.x1 - changeRect.x0),
|
|
|
|
|
worldWidth: nextWorld.width,
|
|
|
|
|
mask: null,
|
|
|
|
|
};
|
|
|
|
|
changeTracker.mask = new Uint8Array(changeTracker.width * Math.max(0, changeRect.y1 - changeRect.y0));
|
|
|
|
|
for (const name of fieldNames) {
|
|
|
|
|
const next = nextWorld.fields?.[name];
|
|
|
|
|
if (!next) {
|
|
|
|
|
if (snapshot.fieldNames?.has(name)) {
|
|
|
|
|
fields[name] = { remove: true };
|
|
|
|
|
const category = PREVIEW_TERRAIN_FIELDS.has(name) ? 2 : PREVIEW_ADMIN_FIELDS.has(name) ? 4 : 0;
|
|
|
|
|
markAllPreviewChanges(changeTracker, category);
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const delta = buildTypedRowDeltaFromTransaction(snapshot, name, next, changeTracker);
|
|
|
|
|
if (delta) fields[name] = delta;
|
|
|
|
|
}
|
|
|
|
|
const baseMeta = {};
|
|
|
|
|
const nextMeta = {};
|
|
|
|
|
for (const [key, value] of Object.entries(nextWorld)) {
|
|
|
|
|
if (key !== "fields" && key !== "generatedMask" && key !== "sourceMap") {
|
|
|
|
|
baseMeta[key] = value;
|
|
|
|
|
nextMeta[key] = value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Object.assign(baseMeta, {
|
|
|
|
|
width: snapshot.width,
|
|
|
|
|
height: snapshot.height,
|
|
|
|
|
originX: snapshot.originX,
|
|
|
|
|
originY: snapshot.originY,
|
|
|
|
|
sourceWidth: snapshot.sourceWidth,
|
|
|
|
|
sourceHeight: snapshot.sourceHeight,
|
|
|
|
|
generatedRects: snapshot.generatedRects,
|
|
|
|
|
lastPatchResult: snapshot.lastPatchResult,
|
|
|
|
|
patchGenerationSerial: snapshot.patchGenerationSerial,
|
|
|
|
|
seaLevel: snapshot.seaLevel,
|
|
|
|
|
});
|
|
|
|
|
const rawSourceMapDelta = buildExactObjectDelta(snapshot.sourceMap || {}, nextWorld.sourceMap || {}, { cloneValues: false });
|
|
|
|
|
const rawMetaDelta = buildExactObjectDelta(baseMeta, nextMeta, { cloneValues: false });
|
|
|
|
|
// Transaction restore replaces world/sourceMap roots; it does not mutate the
|
|
|
|
|
// accepted arrays and diagnostic objects referenced by these deltas. Adopt
|
|
|
|
|
// that completed graph directly. Cloning it here duplicated all changed
|
|
|
|
|
// metadata immediately before rollback and the later postMessage clone.
|
|
|
|
|
const exactDeltas = { sourceMapDelta: rawSourceMapDelta, metaDelta: rawMetaDelta };
|
|
|
|
|
let changedCells = 0;
|
|
|
|
|
let terrainChangedCells = 0;
|
|
|
|
|
let adminChangedCells = 0;
|
|
|
|
|
for (const flags of changeTracker.mask) {
|
|
|
|
|
if (flags & 1) changedCells++;
|
|
|
|
|
if (flags & 2) terrainChangedCells++;
|
|
|
|
|
if (flags & 4) adminChangedCells++;
|
|
|
|
|
}
|
|
|
|
|
const changedSourceKeys = new Set([
|
|
|
|
|
...Object.keys(rawSourceMapDelta.set || {}),
|
|
|
|
|
...Object.keys(rawSourceMapDelta.arraySplices || {}),
|
|
|
|
|
...(rawSourceMapDelta.removed || []),
|
|
|
|
|
]);
|
|
|
|
|
let featureLayersChanged = 0;
|
|
|
|
|
for (const key of PREVIEW_FEATURE_KEYS) if (changedSourceKeys.has(key)) featureLayersChanged++;
|
|
|
|
|
return {
|
|
|
|
|
width: nextWorld.width,
|
|
|
|
|
height: nextWorld.height,
|
|
|
|
|
fields,
|
|
|
|
|
generatedMask: buildTypedRowDelta(
|
|
|
|
|
snapshot.generatedMask || snapshot.generatedMaskRef,
|
|
|
|
|
nextWorld.generatedMask,
|
|
|
|
|
nextWorld.width
|
|
|
|
|
),
|
|
|
|
|
sourceMapDelta: exactDeltas.sourceMapDelta,
|
|
|
|
|
metaDelta: exactDeltas.metaDelta,
|
|
|
|
|
previewDelta: {
|
|
|
|
|
changedCells,
|
|
|
|
|
terrainChangedCells,
|
|
|
|
|
adminChangedCells,
|
|
|
|
|
featureLayersChanged,
|
|
|
|
|
identical: changedCells === 0 && featureLayersChanged === 0,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nowMs() {
|
|
|
|
|
return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function compactQuality(quality) {
|
|
|
|
|
if (!quality || typeof quality !== "object") return null;
|
|
|
|
|
return {
|
|
|
|
|
hardPass: quality.hardPass !== false,
|
|
|
|
|
score: Number(quality.score || 0),
|
|
|
|
|
selectedVariant: Number.isFinite(quality.selectedVariant) ? quality.selectedVariant >>> 0 : null,
|
|
|
|
|
terrainType: quality.terrainType || quality.terrain?.terrainType || null,
|
|
|
|
|
terrain: quality.terrain ? {
|
|
|
|
|
hardPass: quality.terrain.hardPass !== false,
|
|
|
|
|
landRatio: Number(quality.terrain.landRatio || 0),
|
|
|
|
|
developableRatio: Number(quality.terrain.developableRatio || 0),
|
|
|
|
|
largestComponentRatio: Number(quality.terrain.largestComponentRatio || 0),
|
|
|
|
|
} : null,
|
|
|
|
|
human: quality.human ? {
|
|
|
|
|
hardPass: quality.human.hardPass !== false,
|
|
|
|
|
settlementCount: Number(quality.human.settlementCount || 0),
|
|
|
|
|
labelCount: Number(quality.human.labelCount || 0),
|
|
|
|
|
roadPaths: Number(quality.human.roadPaths || 0),
|
|
|
|
|
railPaths: Number(quality.human.railPaths || 0),
|
|
|
|
|
} : null,
|
|
|
|
|
finalMerge: quality.finalMerge ? {
|
|
|
|
|
hardPass: quality.finalMerge.hardPass !== false,
|
|
|
|
|
ownedCells: Number(quality.finalMerge.ownedCells || 0),
|
|
|
|
|
ownedLandCells: Number(quality.finalMerge.ownedLandCells || 0),
|
|
|
|
|
ownedLandRatio: Number(quality.finalMerge.ownedLandRatio || 0),
|
|
|
|
|
landFloor: Number(quality.finalMerge.landFloor || 0),
|
|
|
|
|
settlementCount: Number(quality.finalMerge.settlementCount || 0),
|
|
|
|
|
minFinalSettlements: Number(quality.finalMerge.minFinalSettlements || 0),
|
|
|
|
|
preMergeSettlementCount: Number.isFinite(quality.finalMerge.preMergeSettlementCount)
|
|
|
|
|
? Number(quality.finalMerge.preMergeSettlementCount) : null,
|
|
|
|
|
candidateMinimumSettlements: Number(quality.finalMerge.candidateMinimumSettlements || 0),
|
|
|
|
|
labelCount: Number(quality.finalMerge.labelCount || 0),
|
|
|
|
|
minFinalLabels: Number(quality.finalMerge.minFinalLabels || 0),
|
|
|
|
|
preMergeLabelCount: Number.isFinite(quality.finalMerge.preMergeLabelCount)
|
|
|
|
|
? Number(quality.finalMerge.preMergeLabelCount) : null,
|
|
|
|
|
candidateMinimumLabels: Number(quality.finalMerge.candidateMinimumLabels || 0),
|
|
|
|
|
adminCenters: Number(quality.finalMerge.counts?.adminCenters || 0),
|
|
|
|
|
minFinalAdminCenters: Number(quality.finalMerge.minFinalAdminCenters || 0),
|
|
|
|
|
transportRequired: quality.finalMerge.transportRequired === true,
|
|
|
|
|
roadPaths: Number(quality.finalMerge.roadPaths || 0),
|
|
|
|
|
railPaths: Number(quality.finalMerge.railPaths || 0),
|
|
|
|
|
rectangularCoastHardPass: quality.finalMerge.rectangularCoastHardPass !== false,
|
|
|
|
|
rectangularCoastRun: Number(quality.finalMerge.rectangularCoastCut?.maxAxisAlignedRun || 0),
|
|
|
|
|
rectangularCoastLongestRun: quality.finalMerge.rectangularCoastCut?.longestRun
|
|
|
|
|
? { ...quality.finalMerge.rectangularCoastCut.longestRun }
|
|
|
|
|
: null,
|
|
|
|
|
rectangularCoastCutScope: quality.finalMerge.rectangularCoastCutScope || null,
|
|
|
|
|
score: Number(quality.finalMerge.score || 0),
|
|
|
|
|
} : null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function compactSeam(diagnostics) {
|
|
|
|
|
if (!diagnostics || typeof diagnostics !== "object") return null;
|
|
|
|
|
return {
|
|
|
|
|
hardPass: diagnostics.hardPass !== false,
|
|
|
|
|
status: diagnostics.status || null,
|
|
|
|
|
gateReasons: [...(diagnostics.gateReasons || [])],
|
|
|
|
|
roadPortalsBroken: Number(diagnostics.roadPortalsBroken || 0),
|
|
|
|
|
railPortalsBroken: Number(diagnostics.railPortalsBroken || 0),
|
|
|
|
|
prefectureSeamBreakEdges: Number(diagnostics.prefectureSeamBreakEdges || 0),
|
|
|
|
|
adminSeamBreakEdges: Number(diagnostics.adminSeamBreakEdges || 0),
|
|
|
|
|
landToSeaCells: Number(diagnostics.landToSeaCells || 0),
|
|
|
|
|
transportLandToSeaConflicts: Number(diagnostics.transportLandToSeaConflicts || 0),
|
|
|
|
|
duplicateBoundaryPairs: Number(diagnostics.duplicateBoundaryPairs || 0),
|
|
|
|
|
expansionFootprintEscapedCells: Number(diagnostics.expansionFootprintEscapedCells || 0),
|
|
|
|
|
maxEstablishedFrontierElevationJump: Number(diagnostics.maxEstablishedFrontierElevationJump || 0),
|
|
|
|
|
humanTerrainReconciliation: diagnostics.humanTerrainReconciliation ? {
|
|
|
|
|
checked: Number(diagnostics.humanTerrainReconciliation.checked || 0),
|
|
|
|
|
alreadyValid: Number(diagnostics.humanTerrainReconciliation.alreadyValid || 0),
|
|
|
|
|
relocated: Number(diagnostics.humanTerrainReconciliation.relocated || 0),
|
|
|
|
|
dropped: Number(diagnostics.humanTerrainReconciliation.dropped || 0),
|
|
|
|
|
stagedOutsideOwnership: Number(diagnostics.humanTerrainReconciliation.stagedOutsideOwnership || 0),
|
|
|
|
|
byLayer: diagnostics.humanTerrainReconciliation.byLayer
|
|
|
|
|
? Object.fromEntries(Object.entries(diagnostics.humanTerrainReconciliation.byLayer).map(([key, value]) => [key, {
|
|
|
|
|
checked: Number(value?.checked || 0),
|
|
|
|
|
alreadyValid: Number(value?.alreadyValid || 0),
|
|
|
|
|
relocated: Number(value?.relocated || 0),
|
|
|
|
|
dropped: Number(value?.dropped || 0),
|
|
|
|
|
stagedOutsideOwnership: Number(value?.stagedOutsideOwnership || 0),
|
|
|
|
|
}]))
|
|
|
|
|
: {},
|
|
|
|
|
} : null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isInvariantFailure(result) {
|
|
|
|
|
if (result?.tileResult && isInvariantFailure(result.tileResult)) return true;
|
|
|
|
|
const code = String(result?.code || "");
|
|
|
|
|
const reasons = result?.seamDiagnostics?.gateReasons || [];
|
|
|
|
|
return code === "patch-candidate-coverage-incomplete"
|
|
|
|
|
|| code === "patch-generated-footprint-write-escape"
|
|
|
|
|
|| code === "patch-invariant-breach"
|
|
|
|
|
|| reasons.includes("generated-footprint-write-escape")
|
|
|
|
|
|| Number(result?.candidateUnmappedActiveCells || 0) > 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isContentRejection(result) {
|
|
|
|
|
const code = String(result?.code || "");
|
|
|
|
|
if (isInvariantFailure(result)) return false;
|
|
|
|
|
if ((code === "patch-large-tile-failed" || code === "patch-large-regeneration-tile-failed") && result?.tileResult) {
|
|
|
|
|
return isContentRejection(result.tileResult);
|
|
|
|
|
}
|
|
|
|
|
return code === "patch-quality-gate-failed"
|
|
|
|
|
|| code === "patch-seam-gate-failed"
|
|
|
|
|
|| code === "patch-large-final-seam-failed";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeCandidateResult(result) {
|
|
|
|
|
if (!result?.ok || result?.seamDiagnostics?.hardPass !== false) return result;
|
|
|
|
|
return {
|
|
|
|
|
...result,
|
|
|
|
|
ok: false,
|
|
|
|
|
code: "patch-seam-gate-failed",
|
|
|
|
|
reason: `Candidate failed final seam continuity (${(result.seamDiagnostics.gateReasons || []).join(", ") || "unknown seam failure"}).`,
|
|
|
|
|
rolledBack: true,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function summarizeAttempt(result, candidate, candidateOrdinal, wallMs, status, executionAttempt = 1) {
|
|
|
|
|
return {
|
|
|
|
|
candidateId: candidate.candidateId || `${candidate.variant >>> 0}:${candidate.seed >>> 0}`,
|
|
|
|
|
candidateOrdinal,
|
|
|
|
|
executionAttempt: Math.max(1, Number(executionAttempt || 1)),
|
|
|
|
|
variant: candidate.variant >>> 0,
|
|
|
|
|
seed: candidate.seed >>> 0,
|
|
|
|
|
status,
|
|
|
|
|
ok: result?.ok === true,
|
|
|
|
|
code: result?.code || null,
|
|
|
|
|
reason: result?.reason || null,
|
|
|
|
|
patchMode: result?.patchMode || null,
|
|
|
|
|
tileCount: Number(result?.tileCount || 0),
|
|
|
|
|
wallMs: Math.round(wallMs * 10) / 10,
|
|
|
|
|
candidateQuality: compactQuality(result?.candidateQuality),
|
|
|
|
|
seamDiagnostics: compactSeam(result?.seamDiagnostics),
|
|
|
|
|
patchTimings: (result?.patchTimings || []).map((entry) => ({
|
|
|
|
|
key: entry.key || null,
|
|
|
|
|
label: entry.label || entry.key || "Stage",
|
|
|
|
|
ms: Number(entry.ms || 0),
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const PREVIEW_TERRAIN_FIELDS = new Set(["elevation", "slope", "sea", "landMask", "plain", "landuse", "populationDensity"]);
|
|
|
|
|
const PREVIEW_ADMIN_FIELDS = new Set(["adminId", "municipalityId", "prefectureRegionId", "prefectureMask", "humanRegionMask"]);
|
|
|
|
|
const PREVIEW_FEATURE_KEYS = [
|
|
|
|
|
"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",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function computePreviewDelta(baseWorld, previewWorld, rectLike, onProgress = () => {}) {
|
|
|
|
|
const rect = rectLike?.transportReachRect || rectLike?.repairRect || rectLike?.writeRect || rectLike || null;
|
|
|
|
|
if (!baseWorld || !previewWorld || !rect) return null;
|
|
|
|
|
const x0 = Math.max(0, Math.floor(rect.x0 || 0));
|
|
|
|
|
const y0 = Math.max(0, Math.floor(rect.y0 || 0));
|
|
|
|
|
const x1 = Math.min(Math.max(baseWorld.width || 0, previewWorld.width || 0), Math.ceil(rect.x1 || 0));
|
|
|
|
|
const y1 = Math.min(Math.max(baseWorld.height || 0, previewWorld.height || 0), Math.ceil(rect.y1 || 0));
|
|
|
|
|
const fieldNames = new Set([...Object.keys(baseWorld.fields || {}), ...Object.keys(previewWorld.fields || {})]);
|
|
|
|
|
let changedCells = 0;
|
|
|
|
|
let terrainChangedCells = 0;
|
|
|
|
|
let adminChangedCells = 0;
|
|
|
|
|
const totalRows = Math.max(0, y1 - y0) + PREVIEW_FEATURE_KEYS.length;
|
|
|
|
|
for (let y = y0; y < y1; y++) {
|
|
|
|
|
for (let x = x0; x < x1; x++) {
|
|
|
|
|
const bi = x >= 0 && y >= 0 && x < baseWorld.width && y < baseWorld.height ? y * baseWorld.width + x : -1;
|
|
|
|
|
const pi = x >= 0 && y >= 0 && x < previewWorld.width && y < previewWorld.height ? y * previewWorld.width + x : -1;
|
|
|
|
|
if (bi < 0 || pi < 0) continue;
|
|
|
|
|
let terrainChanged = false;
|
|
|
|
|
let adminChanged = false;
|
|
|
|
|
let cellChanged = false;
|
|
|
|
|
for (const name of fieldNames) {
|
|
|
|
|
const a = baseWorld.fields?.[name]?.[bi];
|
|
|
|
|
const b = previewWorld.fields?.[name]?.[pi];
|
|
|
|
|
if (a === b || (Number.isNaN(a) && Number.isNaN(b))) continue;
|
|
|
|
|
cellChanged = true;
|
|
|
|
|
if (PREVIEW_TERRAIN_FIELDS.has(name)) terrainChanged = true;
|
|
|
|
|
if (PREVIEW_ADMIN_FIELDS.has(name)) adminChanged = true;
|
|
|
|
|
}
|
|
|
|
|
if (cellChanged) changedCells++;
|
|
|
|
|
if (terrainChanged) terrainChangedCells++;
|
|
|
|
|
if (adminChanged) adminChangedCells++;
|
|
|
|
|
}
|
|
|
|
|
if ((y - y0) % 16 === 15 || y + 1 === y1) onProgress(y - y0 + 1, totalRows, "raster");
|
|
|
|
|
}
|
|
|
|
|
let featureLayersChanged = 0;
|
|
|
|
|
for (let index = 0; index < PREVIEW_FEATURE_KEYS.length; index++) {
|
|
|
|
|
const key = PREVIEW_FEATURE_KEYS[index];
|
|
|
|
|
if (!exactStructuredEqual(baseWorld.sourceMap?.[key] || [], previewWorld.sourceMap?.[key] || [])) featureLayersChanged++;
|
|
|
|
|
onProgress(Math.max(0, y1 - y0) + index + 1, totalRows, "features");
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
changedCells,
|
|
|
|
|
terrainChangedCells,
|
|
|
|
|
adminChangedCells,
|
|
|
|
|
featureLayersChanged,
|
|
|
|
|
identical: changedCells === 0 && featureLayersChanged === 0,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function runPatchCandidateSearch(message, dependencies = {}) {
|
|
|
|
|
const { id, world, rect, options, search } = message || {};
|
|
|
|
|
const cloneWorld = dependencies.cloneWorld || ((value) => structuredClone(value));
|
|
|
|
|
const generateCandidate = dependencies.generateCandidate || generatePatch;
|
|
|
|
|
const clock = dependencies.now || nowMs;
|
|
|
|
|
const publishProgress = dependencies.onProgress || (() => {});
|
|
|
|
|
const transactional = dependencies.transactional === true;
|
|
|
|
|
const candidatePlan = Array.isArray(search?.candidatePlan) && search.candidatePlan.length
|
|
|
|
|
? search.candidatePlan
|
|
|
|
|
: [{ variant: options?.variant || 0, seed: options?.seed || world?.seed || 0 }];
|
|
|
|
|
const candidateCount = Math.max(candidatePlan.length, Number(search?.totalCandidateCount || candidatePlan.length));
|
|
|
|
|
const searchId = search?.searchId || `patch-${id}`;
|
|
|
|
|
const workerEpoch = Number(search?.workerEpoch || 0);
|
|
|
|
|
const executionAttempt = Math.max(1, Number(search?.executionAttempt || 1));
|
|
|
|
|
let eventSeq = 0;
|
|
|
|
|
let phaseOrdinal = 0;
|
|
|
|
|
let currentPhase = null;
|
|
|
|
|
const workUnits = new Map();
|
|
|
|
|
const emitProgress = (progress = {}, candidateOrdinal = 0) => {
|
|
|
|
|
const phase = String(progress.phase || progress.key || "patch");
|
|
|
|
|
if (phase !== currentPhase) {
|
|
|
|
|
currentPhase = phase;
|
|
|
|
|
phaseOrdinal++;
|
|
|
|
|
}
|
|
|
|
|
const hasCompleted = progress.completed != null;
|
|
|
|
|
const hasTotal = progress.total != null;
|
|
|
|
|
const completed = Number(progress.completed);
|
|
|
|
|
const total = Number(progress.total);
|
|
|
|
|
const boundedWork = hasCompleted || hasTotal;
|
|
|
|
|
const explicitWorkUnitId = progress.workUnitId != null && String(progress.workUnitId).length > 0;
|
|
|
|
|
const workUnitId = String(explicitWorkUnitId ? progress.workUnitId : (progress.key || phase));
|
|
|
|
|
if (boundedWork) {
|
|
|
|
|
if (!hasCompleted || !hasTotal || !Number.isFinite(completed) || !Number.isFinite(total)) {
|
|
|
|
|
const error = new Error(`Incomplete bounded progress for ${phase}/${workUnitId}: ${String(progress.completed)}/${String(progress.total)}`);
|
|
|
|
|
error.code = "worker-progress-invariant";
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
if (!explicitWorkUnitId) {
|
|
|
|
|
const error = new Error(`Bounded progress is missing an explicit workUnitId for ${phase}/${workUnitId}.`);
|
|
|
|
|
error.code = "worker-progress-invariant";
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
if (completed < 0 || total < 0 || completed > total) {
|
|
|
|
|
const error = new Error(`Invalid progress bounds for ${phase}/${workUnitId}: ${completed}/${total}`);
|
|
|
|
|
error.code = "worker-progress-invariant";
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
// workUnitId is globally unique within one candidate-search operation.
|
|
|
|
|
// Do not reset its monotonicity merely because a display phase changed in
|
|
|
|
|
// between heartbeats; that would hide real restarts/runaway producers.
|
|
|
|
|
const unitKey = workUnitId;
|
|
|
|
|
const previous = workUnits.get(unitKey);
|
|
|
|
|
if (previous && (total !== previous.total || completed < previous.completed)) {
|
|
|
|
|
const error = new Error(`Non-monotonic progress for ${phase}/${workUnitId}: ${completed}/${total} after ${previous.completed}/${previous.total}`);
|
|
|
|
|
error.code = "worker-progress-invariant";
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
const workerNow = nowMs();
|
|
|
|
|
const startedAtWorker = previous?.startedAtWorker ?? workerNow;
|
|
|
|
|
const lastAdvancedAtWorker = !previous || completed > previous.completed
|
|
|
|
|
? workerNow
|
|
|
|
|
: (previous.lastAdvancedAtWorker ?? startedAtWorker);
|
|
|
|
|
workUnits.set(unitKey, { completed, total, phase, startedAtWorker, lastAdvancedAtWorker });
|
|
|
|
|
}
|
|
|
|
|
eventSeq++;
|
|
|
|
|
publishProgress({
|
|
|
|
|
id,
|
|
|
|
|
type: "progress",
|
|
|
|
|
progress: {
|
|
|
|
|
...progress,
|
|
|
|
|
searchId,
|
|
|
|
|
operationId: search?.operationId || searchId,
|
|
|
|
|
candidateOrdinal,
|
|
|
|
|
candidateCount,
|
|
|
|
|
executionAttempt,
|
|
|
|
|
workerEpoch,
|
|
|
|
|
committedRevision: Number(search?.committedRevision || 0),
|
|
|
|
|
eventSeq,
|
|
|
|
|
counter: eventSeq,
|
|
|
|
|
phase,
|
|
|
|
|
phaseOrdinal,
|
|
|
|
|
workUnitId,
|
|
|
|
|
boundedWork,
|
|
|
|
|
startedAtWorker: boundedWork ? workUnits.get(workUnitId)?.startedAtWorker : undefined,
|
|
|
|
|
lastAdvancedAtWorker: boundedWork ? workUnits.get(workUnitId)?.lastAdvancedAtWorker : undefined,
|
|
|
|
|
cooperative: progress.nonCooperative !== true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
try {
|
|
|
|
|
const attempts = [];
|
|
|
|
|
let terminalResult = null;
|
|
|
|
|
let successWorld = null;
|
|
|
|
|
let successDelta = null;
|
|
|
|
|
let successTargetHash = null;
|
|
|
|
|
emitProgress({ status: "start", key: "search", phase: "search", workUnitId: "candidate-search", label: `Searching ${candidateCount} complete candidate${candidateCount === 1 ? "" : "s"}`, completed: 0, total: candidateCount });
|
|
|
|
|
for (let index = 0; index < candidatePlan.length; index++) {
|
|
|
|
|
const candidate = candidatePlan[index];
|
|
|
|
|
const candidateOrdinal = Math.max(1, Number(candidate.candidateOrdinal || index + 1));
|
|
|
|
|
emitProgress({
|
|
|
|
|
status: "start",
|
|
|
|
|
key: transactional ? "candidate-transaction" : "candidate-clone",
|
|
|
|
|
phase: transactional ? "candidate-transaction" : "candidate-clone",
|
|
|
|
|
label: transactional
|
|
|
|
|
? `Candidate ${candidateOrdinal}/${candidateCount}: capturing exact rollback state`
|
|
|
|
|
: `Candidate ${candidateOrdinal}/${candidateCount}: preparing immutable baseline`,
|
|
|
|
|
variant: candidate.variant >>> 0,
|
|
|
|
|
workUnitId: "candidate-search",
|
|
|
|
|
// candidateOrdinal is global across the full search, whereas `index` is
|
|
|
|
|
// local to this Worker execution. Recovery can restart with candidates
|
|
|
|
|
// 2..N after candidate 1 was already rejected, so using `index` here
|
|
|
|
|
// made the shared search work unit move backward (2/3 -> 1/3).
|
|
|
|
|
completed: Math.max(0, candidateOrdinal - 1),
|
|
|
|
|
total: candidateCount,
|
|
|
|
|
nonCooperative: true,
|
|
|
|
|
}, candidateOrdinal);
|
|
|
|
|
let candidateWorld;
|
|
|
|
|
let transaction = null;
|
|
|
|
|
try {
|
|
|
|
|
if (transactional) {
|
|
|
|
|
transaction = capturePatchTransactionSnapshot(world, {
|
|
|
|
|
lightweight: false,
|
|
|
|
|
isolateSourceMap: true,
|
|
|
|
|
copyGeneratedMask: String(search?.resolvedPatchMode || "").toLowerCase() !== "regeneration",
|
|
|
|
|
});
|
|
|
|
|
candidateWorld = world;
|
|
|
|
|
} else {
|
|
|
|
|
candidateWorld = cloneWorld(world);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const failed = {
|
|
|
|
|
ok: false,
|
|
|
|
|
code: transactional ? "candidate-transaction-failed" : "candidate-clone-failed",
|
|
|
|
|
reason: error?.message || String(error),
|
|
|
|
|
};
|
|
|
|
|
attempts.push(summarizeAttempt(failed, candidate, candidateOrdinal, 0, "infrastructure-error", executionAttempt));
|
|
|
|
|
terminalResult = {
|
|
|
|
|
...failed,
|
|
|
|
|
searchAttempts: attempts,
|
|
|
|
|
searchStatus: "infrastructure-error",
|
|
|
|
|
nextVariant: candidate.variant >>> 0,
|
|
|
|
|
};
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
const startedAt = clock();
|
|
|
|
|
let result;
|
|
|
|
|
try {
|
|
|
|
|
result = generateCandidate(candidateWorld, rect, {
|
|
|
|
|
...(options || {}),
|
|
|
|
|
seed: candidate.seed >>> 0,
|
|
|
|
|
variant: candidate.variant >>> 0,
|
|
|
|
|
maxQualityRetries: 0,
|
|
|
|
|
// The production Worker mutates its committed mirror transactionally
|
|
|
|
|
// and restores it after extracting the accepted delta. Direct callers
|
|
|
|
|
// retain the immutable-clone reference path for independent tests.
|
|
|
|
|
_workerOwnedPreview: !transactional,
|
|
|
|
|
_externalTransactionSnapshot: transaction,
|
|
|
|
|
onProgress: (progress) => {
|
|
|
|
|
// Producer workUnitIds are invocation-local. The same complete
|
|
|
|
|
// pipeline is executed again when a content-rejected candidate
|
|
|
|
|
// advances to the next Variant, so namespace every explicit inner
|
|
|
|
|
// unit by candidate ordinal before the search-wide monotonicity
|
|
|
|
|
// validator sees it. Do not invent an ID for a bounded producer
|
|
|
|
|
// that omitted one; emitProgress must still reject that protocol
|
|
|
|
|
// violation instead of masking it.
|
|
|
|
|
const rawWorkUnitId = progress?.workUnitId != null && String(progress.workUnitId).length > 0
|
|
|
|
|
? String(progress.workUnitId)
|
|
|
|
|
: null;
|
|
|
|
|
emitProgress({
|
|
|
|
|
...progress,
|
|
|
|
|
workUnitId: rawWorkUnitId ? `candidate-${candidateOrdinal}/${rawWorkUnitId}` : progress?.workUnitId,
|
|
|
|
|
variant: candidate.variant >>> 0,
|
|
|
|
|
seed: candidate.seed >>> 0,
|
|
|
|
|
}, candidateOrdinal);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
result = normalizeCandidateResult(result);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (transaction) restorePatchTransactionSnapshot(world, transaction);
|
|
|
|
|
if (error?.code === "worker-progress-invariant") throw error;
|
|
|
|
|
const wallMs = clock() - startedAt;
|
|
|
|
|
const failed = {
|
|
|
|
|
ok: false,
|
|
|
|
|
code: "candidate-execution-error",
|
|
|
|
|
reason: error?.message || String(error),
|
|
|
|
|
stack: error?.stack || "",
|
|
|
|
|
};
|
|
|
|
|
attempts.push(summarizeAttempt(failed, candidate, candidateOrdinal, wallMs, "execution-error", executionAttempt));
|
|
|
|
|
terminalResult = { ...failed, searchAttempts: attempts, searchStatus: "execution-error", nextVariant: candidate.variant >>> 0 };
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
const wallMs = clock() - startedAt;
|
|
|
|
|
if (result?.ok) {
|
|
|
|
|
if (transaction) {
|
|
|
|
|
try {
|
|
|
|
|
successDelta = buildCommittedMirrorDeltaFromTransaction(transaction, candidateWorld);
|
|
|
|
|
successTargetHash = hashCommittedWorld(candidateWorld);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
restorePatchTransactionSnapshot(world, transaction);
|
|
|
|
|
const failed = {
|
|
|
|
|
ok: false,
|
|
|
|
|
code: "candidate-delta-build-failed",
|
|
|
|
|
reason: error?.message || String(error),
|
|
|
|
|
};
|
|
|
|
|
attempts.push(summarizeAttempt(failed, candidate, candidateOrdinal, wallMs, "infrastructure-error", executionAttempt));
|
|
|
|
|
terminalResult = { ...failed, searchAttempts: attempts, searchStatus: "infrastructure-error", nextVariant: candidate.variant >>> 0 };
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
restorePatchTransactionSnapshot(world, transaction);
|
|
|
|
|
}
|
|
|
|
|
attempts.push(summarizeAttempt(result, candidate, candidateOrdinal, wallMs, "success", executionAttempt));
|
|
|
|
|
result.searchAttempts = attempts;
|
|
|
|
|
result.searchStatus = "succeeded";
|
|
|
|
|
result.candidateOrdinal = candidateOrdinal;
|
|
|
|
|
result.candidateCount = candidateCount;
|
|
|
|
|
result.actualVariant = candidate.variant >>> 0;
|
|
|
|
|
result.actualSeed = candidate.seed >>> 0;
|
|
|
|
|
result.nextVariant = ((candidate.variant >>> 0) + 1) >>> 0;
|
|
|
|
|
if (successDelta?.previewDelta) result.previewDelta = { ...successDelta.previewDelta };
|
|
|
|
|
terminalResult = result;
|
|
|
|
|
successWorld = transactional ? null : candidateWorld;
|
|
|
|
|
emitProgress({ status: "done", key: "search", phase: "search", workUnitId: "candidate-search", label: `Candidate ${candidateOrdinal}/${candidateCount} accepted`, variant: candidate.variant >>> 0, completed: candidateOrdinal, total: candidateCount }, candidateOrdinal);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const invariant = isInvariantFailure(result);
|
|
|
|
|
const contentRejected = isContentRejection(result);
|
|
|
|
|
const status = invariant ? "invariant-breach" : contentRejected ? "rejected" : "failed";
|
|
|
|
|
const attemptSummary = summarizeAttempt(result, candidate, candidateOrdinal, wallMs, status, executionAttempt);
|
|
|
|
|
attempts.push(attemptSummary);
|
|
|
|
|
if (transaction) restorePatchTransactionSnapshot(world, transaction);
|
|
|
|
|
emitProgress({
|
|
|
|
|
status: contentRejected ? "rejected" : "error",
|
|
|
|
|
key: contentRejected ? "candidate-rejected" : "candidate-failed",
|
|
|
|
|
phase: "candidate-result",
|
|
|
|
|
label: contentRejected
|
|
|
|
|
? `Candidate ${candidateOrdinal}/${candidateCount} rejected; searching the next complete candidate`
|
|
|
|
|
: `Candidate ${candidateOrdinal}/${candidateCount} stopped: ${result?.reason || result?.code || "failure"}`,
|
|
|
|
|
variant: candidate.variant >>> 0,
|
|
|
|
|
workUnitId: "candidate-search",
|
|
|
|
|
completed: candidateOrdinal,
|
|
|
|
|
total: candidateCount,
|
|
|
|
|
code: result?.code || null,
|
|
|
|
|
attemptSummary,
|
|
|
|
|
}, candidateOrdinal);
|
|
|
|
|
if (!contentRejected) {
|
|
|
|
|
terminalResult = {
|
|
|
|
|
...(result || { ok: false }),
|
|
|
|
|
searchAttempts: attempts,
|
|
|
|
|
searchStatus: invariant ? "invariant-breach" : "failed",
|
|
|
|
|
nextVariant: candidate.variant >>> 0,
|
|
|
|
|
};
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
// The rejected full world is no longer observable. Drop the last strong
|
|
|
|
|
// references before cloning the next Variant so the browser may reclaim
|
|
|
|
|
// its field buffers instead of retaining multiple complete candidates.
|
|
|
|
|
candidateWorld = null;
|
|
|
|
|
transaction = null;
|
|
|
|
|
result = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!terminalResult) {
|
|
|
|
|
const last = candidatePlan[candidatePlan.length - 1];
|
|
|
|
|
terminalResult = {
|
|
|
|
|
ok: false,
|
|
|
|
|
code: "patch-search-exhausted",
|
|
|
|
|
reason: `All ${candidateCount} complete production candidates were rejected.`,
|
|
|
|
|
searchStatus: "exhausted",
|
|
|
|
|
searchAttempts: attempts,
|
|
|
|
|
nextVariant: (((last?.variant || 0) >>> 0) + 1) >>> 0,
|
|
|
|
|
candidateCount,
|
|
|
|
|
};
|
|
|
|
|
emitProgress({ status: "done", key: "search-exhausted", phase: "search", workUnitId: "candidate-search", label: terminalResult.reason, completed: candidateCount, total: candidateCount });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (successWorld && terminalResult?.ok) {
|
|
|
|
|
emitProgress({
|
|
|
|
|
status: "start",
|
|
|
|
|
key: "preview-delta",
|
|
|
|
|
phase: "preview-delta",
|
|
|
|
|
label: "Auditing preview changes",
|
|
|
|
|
}, terminalResult.candidateOrdinal || 0);
|
|
|
|
|
terminalResult.previewDelta = computePreviewDelta(world, successWorld, terminalResult.rects || rect, (completed, total, part) => {
|
|
|
|
|
emitProgress({
|
|
|
|
|
status: "step",
|
|
|
|
|
key: `preview-delta:${part}`,
|
|
|
|
|
phase: "preview-delta",
|
|
|
|
|
label: part === "raster" ? "Auditing preview raster changes" : "Auditing preview feature changes",
|
|
|
|
|
workUnitId: "preview-delta-audit",
|
|
|
|
|
completed,
|
|
|
|
|
total,
|
|
|
|
|
}, terminalResult.candidateOrdinal || 0);
|
|
|
|
|
});
|
|
|
|
|
emitProgress({
|
|
|
|
|
status: "done",
|
|
|
|
|
key: "preview-delta",
|
|
|
|
|
phase: "preview-delta",
|
|
|
|
|
label: "Preview change audit complete",
|
|
|
|
|
}, terminalResult.candidateOrdinal || 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Return only the accepted candidate world. Rejected candidate clones and
|
|
|
|
|
// the immutable worker baseline remain worker-local and become collectible.
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
ok: true,
|
|
|
|
|
world: successWorld,
|
|
|
|
|
transactionDelta: successDelta,
|
|
|
|
|
targetHash: successTargetHash,
|
|
|
|
|
result: terminalResult,
|
|
|
|
|
searchId,
|
|
|
|
|
workerEpoch,
|
|
|
|
|
eventSeq,
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return { id, ok: false, code: error?.code || "candidate-search-error", error: error?.message || String(error), stack: error?.stack || "", searchId, workerEpoch, eventSeq };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function runPatchCandidateSearchAsync(message, dependencies = {}) {
|
|
|
|
|
const { id, world, rect, options, search } = message || {};
|
|
|
|
|
const cloneWorld = dependencies.cloneWorld || ((value) => structuredClone(value));
|
|
|
|
|
const generateCandidate = dependencies.generateCandidate || generatePatchAsync;
|
|
|
|
|
const clock = dependencies.now || nowMs;
|
|
|
|
|
const publishProgress = dependencies.onProgress || (() => {});
|
|
|
|
|
const transactional = dependencies.transactional === true;
|
|
|
|
|
const candidatePlan = Array.isArray(search?.candidatePlan) && search.candidatePlan.length
|
|
|
|
|
? search.candidatePlan
|
|
|
|
|
: [{ variant: options?.variant || 0, seed: options?.seed || world?.seed || 0 }];
|
|
|
|
|
const candidateCount = Math.max(candidatePlan.length, Number(search?.totalCandidateCount || candidatePlan.length));
|
|
|
|
|
const searchId = search?.searchId || `patch-${id}`;
|
|
|
|
|
const workerEpoch = Number(search?.workerEpoch || 0);
|
|
|
|
|
const executionAttempt = Math.max(1, Number(search?.executionAttempt || 1));
|
|
|
|
|
let eventSeq = 0;
|
|
|
|
|
let phaseOrdinal = 0;
|
|
|
|
|
let currentPhase = null;
|
|
|
|
|
const workUnits = new Map();
|
|
|
|
|
const emitProgress = (progress = {}, candidateOrdinal = 0) => {
|
|
|
|
|
const phase = String(progress.phase || progress.key || "patch");
|
|
|
|
|
if (phase !== currentPhase) {
|
|
|
|
|
currentPhase = phase;
|
|
|
|
|
phaseOrdinal++;
|
|
|
|
|
}
|
|
|
|
|
const hasCompleted = progress.completed != null;
|
|
|
|
|
const hasTotal = progress.total != null;
|
|
|
|
|
const completed = Number(progress.completed);
|
|
|
|
|
const total = Number(progress.total);
|
|
|
|
|
const boundedWork = hasCompleted || hasTotal;
|
|
|
|
|
const explicitWorkUnitId = progress.workUnitId != null && String(progress.workUnitId).length > 0;
|
|
|
|
|
const workUnitId = String(explicitWorkUnitId ? progress.workUnitId : (progress.key || phase));
|
|
|
|
|
if (boundedWork) {
|
|
|
|
|
if (!hasCompleted || !hasTotal || !Number.isFinite(completed) || !Number.isFinite(total)) {
|
|
|
|
|
const error = new Error(`Incomplete bounded progress for ${phase}/${workUnitId}: ${String(progress.completed)}/${String(progress.total)}`);
|
|
|
|
|
error.code = "worker-progress-invariant";
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
if (!explicitWorkUnitId) {
|
|
|
|
|
const error = new Error(`Bounded progress is missing an explicit workUnitId for ${phase}/${workUnitId}.`);
|
|
|
|
|
error.code = "worker-progress-invariant";
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
if (completed < 0 || total < 0 || completed > total) {
|
|
|
|
|
const error = new Error(`Invalid progress bounds for ${phase}/${workUnitId}: ${completed}/${total}`);
|
|
|
|
|
error.code = "worker-progress-invariant";
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
// workUnitId is globally unique within one candidate-search operation.
|
|
|
|
|
// Do not reset its monotonicity merely because a display phase changed in
|
|
|
|
|
// between heartbeats; that would hide real restarts/runaway producers.
|
|
|
|
|
const unitKey = workUnitId;
|
|
|
|
|
const previous = workUnits.get(unitKey);
|
|
|
|
|
if (previous && (total !== previous.total || completed < previous.completed)) {
|
|
|
|
|
const error = new Error(`Non-monotonic progress for ${phase}/${workUnitId}: ${completed}/${total} after ${previous.completed}/${previous.total}`);
|
|
|
|
|
error.code = "worker-progress-invariant";
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
const workerNow = nowMs();
|
|
|
|
|
const startedAtWorker = previous?.startedAtWorker ?? workerNow;
|
|
|
|
|
const lastAdvancedAtWorker = !previous || completed > previous.completed
|
|
|
|
|
? workerNow
|
|
|
|
|
: (previous.lastAdvancedAtWorker ?? startedAtWorker);
|
|
|
|
|
workUnits.set(unitKey, { completed, total, phase, startedAtWorker, lastAdvancedAtWorker });
|
|
|
|
|
}
|
|
|
|
|
eventSeq++;
|
|
|
|
|
publishProgress({
|
|
|
|
|
id,
|
|
|
|
|
type: "progress",
|
|
|
|
|
progress: {
|
|
|
|
|
...progress,
|
|
|
|
|
searchId,
|
|
|
|
|
operationId: search?.operationId || searchId,
|
|
|
|
|
candidateOrdinal,
|
|
|
|
|
candidateCount,
|
|
|
|
|
executionAttempt,
|
|
|
|
|
workerEpoch,
|
|
|
|
|
committedRevision: Number(search?.committedRevision || 0),
|
|
|
|
|
eventSeq,
|
|
|
|
|
counter: eventSeq,
|
|
|
|
|
phase,
|
|
|
|
|
phaseOrdinal,
|
|
|
|
|
workUnitId,
|
|
|
|
|
boundedWork,
|
|
|
|
|
startedAtWorker: boundedWork ? workUnits.get(workUnitId)?.startedAtWorker : undefined,
|
|
|
|
|
lastAdvancedAtWorker: boundedWork ? workUnits.get(workUnitId)?.lastAdvancedAtWorker : undefined,
|
|
|
|
|
cooperative: progress.nonCooperative !== true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
try {
|
|
|
|
|
const attempts = [];
|
|
|
|
|
let terminalResult = null;
|
|
|
|
|
let successWorld = null;
|
|
|
|
|
let successDelta = null;
|
|
|
|
|
let successTargetHash = null;
|
|
|
|
|
emitProgress({ status: "start", key: "search", phase: "search", workUnitId: "candidate-search", label: `Searching ${candidateCount} complete candidate${candidateCount === 1 ? "" : "s"}`, completed: 0, total: candidateCount });
|
|
|
|
|
for (let index = 0; index < candidatePlan.length; index++) {
|
|
|
|
|
const candidate = candidatePlan[index];
|
|
|
|
|
const candidateOrdinal = Math.max(1, Number(candidate.candidateOrdinal || index + 1));
|
|
|
|
|
emitProgress({
|
|
|
|
|
status: "start",
|
|
|
|
|
key: transactional ? "candidate-transaction" : "candidate-clone",
|
|
|
|
|
phase: transactional ? "candidate-transaction" : "candidate-clone",
|
|
|
|
|
label: transactional
|
|
|
|
|
? `Candidate ${candidateOrdinal}/${candidateCount}: capturing exact rollback state`
|
|
|
|
|
: `Candidate ${candidateOrdinal}/${candidateCount}: preparing immutable baseline`,
|
|
|
|
|
variant: candidate.variant >>> 0,
|
|
|
|
|
workUnitId: "candidate-search",
|
|
|
|
|
// candidateOrdinal is global across the full search, whereas `index` is
|
|
|
|
|
// local to this Worker execution. Recovery can restart with candidates
|
|
|
|
|
// 2..N after candidate 1 was already rejected, so using `index` here
|
|
|
|
|
// made the shared search work unit move backward (2/3 -> 1/3).
|
|
|
|
|
completed: Math.max(0, candidateOrdinal - 1),
|
|
|
|
|
total: candidateCount,
|
|
|
|
|
nonCooperative: true,
|
|
|
|
|
}, candidateOrdinal);
|
|
|
|
|
let candidateWorld;
|
|
|
|
|
let transaction = null;
|
|
|
|
|
try {
|
|
|
|
|
if (transactional) {
|
|
|
|
|
transaction = capturePatchTransactionSnapshot(world, {
|
|
|
|
|
lightweight: false,
|
|
|
|
|
isolateSourceMap: true,
|
|
|
|
|
copyGeneratedMask: String(search?.resolvedPatchMode || "").toLowerCase() !== "regeneration",
|
|
|
|
|
});
|
|
|
|
|
candidateWorld = world;
|
|
|
|
|
} else {
|
|
|
|
|
candidateWorld = cloneWorld(world);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const failed = {
|
|
|
|
|
ok: false,
|
|
|
|
|
code: transactional ? "candidate-transaction-failed" : "candidate-clone-failed",
|
|
|
|
|
reason: error?.message || String(error),
|
|
|
|
|
};
|
|
|
|
|
attempts.push(summarizeAttempt(failed, candidate, candidateOrdinal, 0, "infrastructure-error", executionAttempt));
|
|
|
|
|
terminalResult = {
|
|
|
|
|
...failed,
|
|
|
|
|
searchAttempts: attempts,
|
|
|
|
|
searchStatus: "infrastructure-error",
|
|
|
|
|
nextVariant: candidate.variant >>> 0,
|
|
|
|
|
};
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
const startedAt = clock();
|
|
|
|
|
let result;
|
|
|
|
|
try {
|
|
|
|
|
result = await generateCandidate(candidateWorld, rect, {
|
|
|
|
|
...(options || {}),
|
|
|
|
|
seed: candidate.seed >>> 0,
|
|
|
|
|
variant: candidate.variant >>> 0,
|
|
|
|
|
maxQualityRetries: 0,
|
|
|
|
|
// The production Worker mutates its committed mirror transactionally
|
|
|
|
|
// and restores it after extracting the accepted delta. Direct callers
|
|
|
|
|
// retain the immutable-clone reference path for independent tests.
|
|
|
|
|
_workerOwnedPreview: !transactional,
|
|
|
|
|
_externalTransactionSnapshot: transaction,
|
|
|
|
|
_precomputeRawCandidateBatch: dependencies.precomputeRawCandidateBatch,
|
|
|
|
|
_precomputeRawCandidateSequence: dependencies.precomputeRawCandidateSequence,
|
|
|
|
|
_rawCandidateParallelism: Math.max(1, Math.min(2, Number(dependencies.rawCandidateParallelism || 2))),
|
|
|
|
|
onProgress: (progress) => {
|
|
|
|
|
// Producer workUnitIds are invocation-local. The same complete
|
|
|
|
|
// pipeline is executed again when a content-rejected candidate
|
|
|
|
|
// advances to the next Variant, so namespace every explicit inner
|
|
|
|
|
// unit by candidate ordinal before the search-wide monotonicity
|
|
|
|
|
// validator sees it. Do not invent an ID for a bounded producer
|
|
|
|
|
// that omitted one; emitProgress must still reject that protocol
|
|
|
|
|
// violation instead of masking it.
|
|
|
|
|
const rawWorkUnitId = progress?.workUnitId != null && String(progress.workUnitId).length > 0
|
|
|
|
|
? String(progress.workUnitId)
|
|
|
|
|
: null;
|
|
|
|
|
emitProgress({
|
|
|
|
|
...progress,
|
|
|
|
|
workUnitId: rawWorkUnitId ? `candidate-${candidateOrdinal}/${rawWorkUnitId}` : progress?.workUnitId,
|
|
|
|
|
variant: candidate.variant >>> 0,
|
|
|
|
|
seed: candidate.seed >>> 0,
|
|
|
|
|
}, candidateOrdinal);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
result = normalizeCandidateResult(result);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (transaction) restorePatchTransactionSnapshot(world, transaction);
|
|
|
|
|
if (error?.code === "worker-progress-invariant") throw error;
|
|
|
|
|
const wallMs = clock() - startedAt;
|
|
|
|
|
const failed = {
|
|
|
|
|
ok: false,
|
|
|
|
|
code: "candidate-execution-error",
|
|
|
|
|
reason: error?.message || String(error),
|
|
|
|
|
stack: error?.stack || "",
|
|
|
|
|
};
|
|
|
|
|
attempts.push(summarizeAttempt(failed, candidate, candidateOrdinal, wallMs, "execution-error", executionAttempt));
|
|
|
|
|
terminalResult = { ...failed, searchAttempts: attempts, searchStatus: "execution-error", nextVariant: candidate.variant >>> 0 };
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
const wallMs = clock() - startedAt;
|
|
|
|
|
if (result?.ok) {
|
|
|
|
|
if (transaction) {
|
|
|
|
|
try {
|
|
|
|
|
successDelta = buildCommittedMirrorDeltaFromTransaction(transaction, candidateWorld);
|
|
|
|
|
successTargetHash = hashCommittedWorld(candidateWorld);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
restorePatchTransactionSnapshot(world, transaction);
|
|
|
|
|
const failed = {
|
|
|
|
|
ok: false,
|
|
|
|
|
code: "candidate-delta-build-failed",
|
|
|
|
|
reason: error?.message || String(error),
|
|
|
|
|
};
|
|
|
|
|
attempts.push(summarizeAttempt(failed, candidate, candidateOrdinal, wallMs, "infrastructure-error", executionAttempt));
|
|
|
|
|
terminalResult = { ...failed, searchAttempts: attempts, searchStatus: "infrastructure-error", nextVariant: candidate.variant >>> 0 };
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
restorePatchTransactionSnapshot(world, transaction);
|
|
|
|
|
}
|
|
|
|
|
attempts.push(summarizeAttempt(result, candidate, candidateOrdinal, wallMs, "success", executionAttempt));
|
|
|
|
|
result.searchAttempts = attempts;
|
|
|
|
|
result.searchStatus = "succeeded";
|
|
|
|
|
result.candidateOrdinal = candidateOrdinal;
|
|
|
|
|
result.candidateCount = candidateCount;
|
|
|
|
|
result.actualVariant = candidate.variant >>> 0;
|
|
|
|
|
result.actualSeed = candidate.seed >>> 0;
|
|
|
|
|
result.nextVariant = ((candidate.variant >>> 0) + 1) >>> 0;
|
|
|
|
|
if (successDelta?.previewDelta) result.previewDelta = { ...successDelta.previewDelta };
|
|
|
|
|
terminalResult = result;
|
|
|
|
|
successWorld = transactional ? null : candidateWorld;
|
|
|
|
|
emitProgress({ status: "done", key: "search", phase: "search", workUnitId: "candidate-search", label: `Candidate ${candidateOrdinal}/${candidateCount} accepted`, variant: candidate.variant >>> 0, completed: candidateOrdinal, total: candidateCount }, candidateOrdinal);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const invariant = isInvariantFailure(result);
|
|
|
|
|
const contentRejected = isContentRejection(result);
|
|
|
|
|
const status = invariant ? "invariant-breach" : contentRejected ? "rejected" : "failed";
|
|
|
|
|
const attemptSummary = summarizeAttempt(result, candidate, candidateOrdinal, wallMs, status, executionAttempt);
|
|
|
|
|
attempts.push(attemptSummary);
|
|
|
|
|
if (transaction) restorePatchTransactionSnapshot(world, transaction);
|
|
|
|
|
emitProgress({
|
|
|
|
|
status: contentRejected ? "rejected" : "error",
|
|
|
|
|
key: contentRejected ? "candidate-rejected" : "candidate-failed",
|
|
|
|
|
phase: "candidate-result",
|
|
|
|
|
label: contentRejected
|
|
|
|
|
? `Candidate ${candidateOrdinal}/${candidateCount} rejected; searching the next complete candidate`
|
|
|
|
|
: `Candidate ${candidateOrdinal}/${candidateCount} stopped: ${result?.reason || result?.code || "failure"}`,
|
|
|
|
|
variant: candidate.variant >>> 0,
|
|
|
|
|
workUnitId: "candidate-search",
|
|
|
|
|
completed: candidateOrdinal,
|
|
|
|
|
total: candidateCount,
|
|
|
|
|
code: result?.code || null,
|
|
|
|
|
attemptSummary,
|
|
|
|
|
}, candidateOrdinal);
|
|
|
|
|
if (!contentRejected) {
|
|
|
|
|
terminalResult = {
|
|
|
|
|
...(result || { ok: false }),
|
|
|
|
|
searchAttempts: attempts,
|
|
|
|
|
searchStatus: invariant ? "invariant-breach" : "failed",
|
|
|
|
|
nextVariant: candidate.variant >>> 0,
|
|
|
|
|
};
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
// The rejected full world is no longer observable. Drop the last strong
|
|
|
|
|
// references before cloning the next Variant so the browser may reclaim
|
|
|
|
|
// its field buffers instead of retaining multiple complete candidates.
|
|
|
|
|
candidateWorld = null;
|
|
|
|
|
transaction = null;
|
|
|
|
|
result = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!terminalResult) {
|
|
|
|
|
const last = candidatePlan[candidatePlan.length - 1];
|
|
|
|
|
terminalResult = {
|
|
|
|
|
ok: false,
|
|
|
|
|
code: "patch-search-exhausted",
|
|
|
|
|
reason: `All ${candidateCount} complete production candidates were rejected.`,
|
|
|
|
|
searchStatus: "exhausted",
|
|
|
|
|
searchAttempts: attempts,
|
|
|
|
|
nextVariant: (((last?.variant || 0) >>> 0) + 1) >>> 0,
|
|
|
|
|
candidateCount,
|
|
|
|
|
};
|
|
|
|
|
emitProgress({ status: "done", key: "search-exhausted", phase: "search", workUnitId: "candidate-search", label: terminalResult.reason, completed: candidateCount, total: candidateCount });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (successWorld && terminalResult?.ok) {
|
|
|
|
|
emitProgress({
|
|
|
|
|
status: "start",
|
|
|
|
|
key: "preview-delta",
|
|
|
|
|
phase: "preview-delta",
|
|
|
|
|
label: "Auditing preview changes",
|
|
|
|
|
}, terminalResult.candidateOrdinal || 0);
|
|
|
|
|
terminalResult.previewDelta = computePreviewDelta(world, successWorld, terminalResult.rects || rect, (completed, total, part) => {
|
|
|
|
|
emitProgress({
|
|
|
|
|
status: "step",
|
|
|
|
|
key: `preview-delta:${part}`,
|
|
|
|
|
phase: "preview-delta",
|
|
|
|
|
label: part === "raster" ? "Auditing preview raster changes" : "Auditing preview feature changes",
|
|
|
|
|
workUnitId: "preview-delta-audit",
|
|
|
|
|
completed,
|
|
|
|
|
total,
|
|
|
|
|
}, terminalResult.candidateOrdinal || 0);
|
|
|
|
|
});
|
|
|
|
|
emitProgress({
|
|
|
|
|
status: "done",
|
|
|
|
|
key: "preview-delta",
|
|
|
|
|
phase: "preview-delta",
|
|
|
|
|
label: "Preview change audit complete",
|
|
|
|
|
}, terminalResult.candidateOrdinal || 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Return only the accepted candidate world. Rejected candidate clones and
|
|
|
|
|
// the immutable worker baseline remain worker-local and become collectible.
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
ok: true,
|
|
|
|
|
world: successWorld,
|
|
|
|
|
transactionDelta: successDelta,
|
|
|
|
|
targetHash: successTargetHash,
|
|
|
|
|
result: terminalResult,
|
|
|
|
|
searchId,
|
|
|
|
|
workerEpoch,
|
|
|
|
|
eventSeq,
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return { id, ok: false, code: error?.code || "candidate-search-error", error: error?.message || String(error), stack: error?.stack || "", searchId, workerEpoch, eventSeq };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof self !== "undefined") {
|
|
|
|
|
self.onmessage = async (event) => {
|
|
|
|
|
const incoming = event.data || {};
|
|
|
|
|
if (handleMirrorSyncMessage(incoming)) return;
|
|
|
|
|
if (incoming.type === "patch-apply-discard") {
|
|
|
|
|
if (incoming.applyToken) pendingApplyDeltas.delete(incoming.applyToken);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (incoming.type === "patch-apply-ack") {
|
|
|
|
|
const pending = pendingApplyDeltas.get(incoming.applyToken);
|
|
|
|
|
let ok = false;
|
|
|
|
|
let error = null;
|
|
|
|
|
try {
|
|
|
|
|
if (!pending) throw new Error("Pending patch delta is unavailable.");
|
|
|
|
|
if (persistentCommittedRevision !== Number(incoming.baseCommittedRevision)) {
|
|
|
|
|
throw new Error(`Committed mirror revision conflict (${persistentCommittedRevision} != ${incoming.baseCommittedRevision}).`);
|
|
|
|
|
}
|
|
|
|
|
applyCommittedWorldDelta(persistentCommittedMirror, pending.delta, { consumeMetadata: true });
|
|
|
|
|
const mirrorHash = hashCommittedWorld(persistentCommittedMirror);
|
|
|
|
|
if (mirrorHash !== pending.targetHash) {
|
|
|
|
|
throw new Error(`Committed mirror hash mismatch (${mirrorHash} != ${pending.targetHash}).`);
|
|
|
|
|
}
|
|
|
|
|
persistentCommittedRevision = Number(incoming.committedRevision);
|
|
|
|
|
pendingApplyDeltas.delete(incoming.applyToken);
|
|
|
|
|
ok = true;
|
|
|
|
|
} catch (applyError) {
|
|
|
|
|
error = applyError?.message || String(applyError);
|
|
|
|
|
persistentCommittedMirror = null;
|
|
|
|
|
persistentCommittedRevision = -1;
|
|
|
|
|
pendingApplyDeltas.clear();
|
|
|
|
|
}
|
|
|
|
|
self.postMessage({
|
|
|
|
|
type: "patch-apply-ack-result",
|
|
|
|
|
ackId: incoming.ackId,
|
|
|
|
|
applyToken: incoming.applyToken,
|
|
|
|
|
ok,
|
|
|
|
|
error,
|
|
|
|
|
mirrorHash: ok ? pending?.targetHash || null : null,
|
|
|
|
|
mirrorCommittedRevision: persistentCommittedRevision,
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const requestedRevision = Number(incoming.search?.committedRevision ?? -1);
|
|
|
|
|
if (incoming.world) {
|
|
|
|
|
persistentCommittedMirror = incoming.world;
|
|
|
|
|
persistentCommittedRevision = requestedRevision;
|
|
|
|
|
pendingMirrorBootstrap = null;
|
|
|
|
|
pendingApplyDeltas.clear();
|
|
|
|
|
} else if (!incoming.search?.reuseCommittedMirror || !persistentCommittedMirror || persistentCommittedRevision !== requestedRevision) {
|
|
|
|
|
self.postMessage({
|
|
|
|
|
id: incoming.id,
|
|
|
|
|
ok: false,
|
|
|
|
|
error: "Committed worker mirror is unavailable or stale.",
|
|
|
|
|
code: "worker-mirror-stale",
|
|
|
|
|
searchId: incoming.search?.searchId || null,
|
|
|
|
|
workerEpoch: Number(incoming.search?.workerEpoch || 0),
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const payload = await runPatchCandidateSearchAsync({ ...incoming, world: persistentCommittedMirror }, {
|
|
|
|
|
onProgress: (message) => self.postMessage(message),
|
|
|
|
|
transactional: true,
|
|
|
|
|
precomputeRawCandidateBatch,
|
|
|
|
|
precomputeRawCandidateSequence: scheduleRawCandidateSequence,
|
|
|
|
|
rawCandidateParallelism: 2,
|
|
|
|
|
});
|
|
|
|
|
// Helper workers are scoped to one candidate-search operation. Releasing
|
|
|
|
|
// them here prevents a completed/rejected large search from retaining two
|
|
|
|
|
// full generator heaps while the coordinator waits for Apply/Alternative.
|
|
|
|
|
await shutdownRawCandidateWorkers();
|
|
|
|
|
payload.mirrorCommittedRevision = persistentCommittedRevision;
|
|
|
|
|
if (payload.ok && payload.result?.ok && (payload.transactionDelta || payload.world)) {
|
|
|
|
|
payload.eventSeq = Number(payload.eventSeq || 0) + 1;
|
|
|
|
|
self.postMessage({
|
|
|
|
|
id: incoming.id,
|
|
|
|
|
type: "progress",
|
|
|
|
|
progress: {
|
|
|
|
|
searchId: payload.searchId,
|
|
|
|
|
operationId: incoming.search?.operationId || payload.searchId,
|
|
|
|
|
candidateOrdinal: Number(payload.result?.candidateOrdinal || 0),
|
|
|
|
|
candidateCount: Number(payload.result?.candidateCount || incoming.search?.totalCandidateCount || 0),
|
|
|
|
|
executionAttempt: Math.max(1, Number(incoming.search?.executionAttempt || 1)),
|
|
|
|
|
workerEpoch: payload.workerEpoch,
|
|
|
|
|
committedRevision: requestedRevision,
|
|
|
|
|
eventSeq: payload.eventSeq,
|
|
|
|
|
counter: payload.eventSeq,
|
|
|
|
|
phase: "mirror-delta-build",
|
|
|
|
|
phaseOrdinal: Number.MAX_SAFE_INTEGER - 1,
|
|
|
|
|
workUnitId: "mirror-delta-build",
|
|
|
|
|
boundedWork: false,
|
|
|
|
|
cooperative: false,
|
|
|
|
|
nonCooperative: true,
|
|
|
|
|
status: "start",
|
|
|
|
|
key: "mirror-delta-build",
|
|
|
|
|
label: "Building transactional Apply delta",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const applyToken = `${payload.searchId}:${payload.result.candidateOrdinal || 0}:${payload.result.actualVariant ?? payload.result.variant ?? 0}:${requestedRevision}`;
|
|
|
|
|
try {
|
|
|
|
|
const delta = payload.transactionDelta || buildCommittedMirrorDelta(persistentCommittedMirror, payload.world);
|
|
|
|
|
const targetHash = payload.targetHash || hashCommittedWorld(payload.world);
|
|
|
|
|
const mainThreadDelta = buildMainThreadTransferDelta(delta);
|
|
|
|
|
pendingApplyDeltas.clear();
|
|
|
|
|
pendingApplyDeltas.set(applyToken, { delta, baseCommittedRevision: requestedRevision, targetHash });
|
|
|
|
|
// The main thread needs one transferable copy to materialize the
|
|
|
|
|
// preview. Keep the original delta Worker-local for the later Apply ACK.
|
|
|
|
|
payload.worldDelta = mainThreadDelta;
|
|
|
|
|
payload.transactionDelta = null;
|
|
|
|
|
payload.targetHash = null;
|
|
|
|
|
payload.world = null;
|
|
|
|
|
payload.result.applyToken = applyToken;
|
|
|
|
|
payload.result.acceptedWorldHash = targetHash;
|
|
|
|
|
} catch (deltaError) {
|
|
|
|
|
pendingApplyDeltas.delete(applyToken);
|
|
|
|
|
payload.transactionDelta = null;
|
|
|
|
|
payload.targetHash = null;
|
|
|
|
|
payload.world = null;
|
|
|
|
|
payload.result = {
|
|
|
|
|
...payload.result,
|
|
|
|
|
ok: false,
|
|
|
|
|
code: "candidate-delta-build-failed",
|
|
|
|
|
reason: deltaError?.message || String(deltaError),
|
|
|
|
|
searchStatus: "infrastructure-error",
|
|
|
|
|
applyToken: null,
|
|
|
|
|
applyDeltaError: deltaError?.message || String(deltaError),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (payload.ok) {
|
|
|
|
|
payload.eventSeq = Number(payload.eventSeq || 0) + 1;
|
|
|
|
|
self.postMessage({
|
|
|
|
|
id: incoming.id,
|
|
|
|
|
type: "progress",
|
|
|
|
|
progress: {
|
|
|
|
|
searchId: payload.searchId,
|
|
|
|
|
operationId: incoming.search?.operationId || payload.searchId,
|
|
|
|
|
candidateOrdinal: Number(payload.result?.candidateOrdinal || 0),
|
|
|
|
|
candidateCount: Number(payload.result?.candidateCount || incoming.search?.totalCandidateCount || 0),
|
|
|
|
|
executionAttempt: Math.max(1, Number(incoming.search?.executionAttempt || 1)),
|
|
|
|
|
workerEpoch: payload.workerEpoch,
|
|
|
|
|
committedRevision: requestedRevision,
|
|
|
|
|
eventSeq: payload.eventSeq,
|
|
|
|
|
counter: payload.eventSeq,
|
|
|
|
|
phase: "result-serialization",
|
|
|
|
|
phaseOrdinal: Number.MAX_SAFE_INTEGER,
|
|
|
|
|
workUnitId: "result-serialization",
|
|
|
|
|
boundedWork: false,
|
|
|
|
|
cooperative: false,
|
|
|
|
|
nonCooperative: true,
|
|
|
|
|
status: "start",
|
|
|
|
|
key: "result-serialization",
|
|
|
|
|
label: "Serializing accepted patch result",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
// Accepted transactional results keep all large typed payloads under the
|
|
|
|
|
// world delta. Traversing the complete diagnostic/result graph here caused
|
|
|
|
|
// a long no-progress pause at the final stage without finding additional
|
|
|
|
|
// buffers. Legacy full-world payloads still transfer their world buffers.
|
|
|
|
|
// Only the copied raster delta buffers are transferable. Metadata in
|
|
|
|
|
// worldDelta deliberately shares the Worker-retained ACK graph until
|
|
|
|
|
// postMessage clones it; transferring a nested typed metadata buffer would
|
|
|
|
|
// detach the retained delta and make Apply fail.
|
|
|
|
|
const transferRoot = payload.worldDelta
|
|
|
|
|
? { fields: payload.worldDelta.fields, generatedMask: payload.worldDelta.generatedMask }
|
|
|
|
|
: (payload.world || null);
|
|
|
|
|
const transfer = payload.ok && transferRoot ? Array.from(collectTransferableBuffers(transferRoot)) : [];
|
|
|
|
|
self.postMessage(payload, transfer);
|
|
|
|
|
};
|
|
|
|
|
}
|