← Files ModRetro Chromatic PluginARCHIVED FILE

dist/scene-batch.js

71.2 KB · Oct 2, 2026 · 00:37 UTC

↓ Download file

import { createHash } from "node:crypto";
import { lstat, readFile, rmdir, unlink } from "node:fs/promises";
import path from "node:path";
import { publishNativeImportFile, readNativeImportSnapshot, rollbackNativeImportFile, replaceNativeImportFile, rollbackNativeReplacement, finalizeNativeReplacement, NativeRecoveryConflict, NativeImportFileError } from "./native-import-files.js";
import { createActorResource, mergeActorChanges, validateActorResource, } from "./actors.js";
import { applyCollisionEdits } from "./collisions.js";
import { applyScriptEdit, buildSceneTransitionEvent, } from "./game-scripts.js";
import { applyVariableUpdate } from "./game-variables.js";
import { nextResourceIndex, projectPathExists, projectRelativePath, readProjectJson, readProjectJsonWithRevision, resolveProjectPath, resourceSlug, stableResourceId, withProjectResourceWriteLock, withProjectWriteLock, writeProjectBytesAtomic, } from "./project-files.js";
import { PROJECT_REVISION_ALGORITHM, } from "./project-access.js";
import { loadProjectSnapshot, projectRevision, projectRevisionBeforeAdditions, } from "./project-snapshot.js";
import { GameStudioProjectError, discoverProject, resolveNativeAssetIdentity, validateNativeSceneUpdate, } from "./project.js";
import { createTriggerResource, mergeTriggerChanges, validateTriggerResource, } from "./triggers.js";
import { explicitlyPatchedNativeScriptCoordinates, findInvalidNativeScriptEvent, } from "./native-script-events.js";
const MAX_BATCH_OPERATIONS = 100;
const MAX_INSPECTION_RESOURCES = 100;
const MAX_INSPECTION_EVENTS = 250;
const MAX_REFERENCE_SAMPLES = 10;
const FORBIDDEN_KEYS = new Set(["__proto__", "prototype", "constructor"]);
const IMMUTABLE_KEYS = new Set(["id", "_resourceType", "_index", "symbol"]);
const TRIGGER_REFERENCE_KEYS = new Set(["triggerId", "trigger", "triggerIds", "targetTriggerId"]);
const projectMutationQueues = new Map();
export class ProjectTransactionError extends GameStudioProjectError {
    recovery;
    constructor(code, cause, recovery) {
        super(code, `Native import failed: ${recovery.originalCause.message}. ${recovery.conflictedPaths.length ? "Recovery conflicts require inspection; preserved files were not overwritten." : "Published outputs were rolled back."}`);
        this.recovery = recovery;
        Object.defineProperty(this, "cause", { value: cause });
    }
}
function isObject(value) {
    return typeof value === "object" && value !== null && !Array.isArray(value);
}
function digest(value) {
    return createHash("sha256").update(value).digest("hex");
}
function stagedBytes(value) {
    return Buffer.isBuffer(value)
        ? Buffer.from(value)
        : Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function projectError(code, message) {
    throw new GameStudioProjectError(code, message);
}
function assertIndexedProjectAccess(projectPath, indexedAccess) {
    if (!indexedAccess)
        return;
    const requestedPath = path.resolve(projectPath);
    if (requestedPath !== indexedAccess.projectPath && requestedPath !== indexedAccess.projectRoot) {
        projectError("STALE_PROJECT", "Indexed project access does not belong to the requested native game project");
    }
    if (indexedAccess.revisionAlgorithm !== PROJECT_REVISION_ALGORITHM) {
        projectError("STALE_PROJECT", "Indexed project access uses an obsolete project revision algorithm");
    }
}
function requiredString(value, label) {
    if (typeof value !== "string" || value.trim().length === 0) {
        projectError("INVALID_INPUT", `${label} must be a non-empty string`);
    }
    return value.trim();
}
function boundedLimit(value, fallback, maximum, label) {
    if (value === undefined)
        return fallback;
    if (!Number.isInteger(value) || typeof value !== "number" || value < 0 || value > maximum) {
        projectError("INVALID_INPUT", `${label} must be an integer between 0 and ${maximum}`);
    }
    return value;
}
function assertSafeValue(value, label, forbidImmutable = false) {
    if (Array.isArray(value)) {
        for (const child of value)
            assertSafeValue(child, label);
        return;
    }
    if (!isObject(value))
        return;
    for (const [key, child] of Object.entries(value)) {
        if (FORBIDDEN_KEYS.has(key) || (forbidImmutable && IMMUTABLE_KEYS.has(key))) {
            projectError("INVALID_PROPERTY", `${label} cannot modify reserved property ${key}`);
        }
        assertSafeValue(child, label);
    }
}
function strippedResource(resource) {
    const cloned = structuredClone(resource);
    delete cloned.resourcePath;
    if ("actors" in resource && "triggers" in resource) {
        delete cloned.actors;
        delete cloned.triggers;
    }
    return cloned;
}
function sceneResource(overlay) {
    return {
        ...overlay.scene.value,
        id: overlay.scene.id,
        name: typeof overlay.scene.value.name === "string" ? overlay.scene.value.name : "",
        resourcePath: overlay.scene.relativePath,
        actors: [...overlay.actors.values()].map(toProjectResource),
        triggers: [...overlay.triggers.values()].map(toProjectResource),
    };
}
function toProjectResource(resource) {
    return {
        ...resource.value,
        id: resource.id,
        name: typeof resource.value.name === "string" ? resource.value.name : "",
        resourcePath: resource.relativePath,
    };
}
/** Keep local project operations ordered; authored writes also exclude other plugin processes. */
export async function withProjectMutation(projectRoot, action, options = {}) {
    const previous = projectMutationQueues.get(projectRoot) ?? Promise.resolve();
    let release;
    const pending = new Promise((resolve) => { release = resolve; });
    const tail = previous.catch(() => undefined).then(() => pending);
    projectMutationQueues.set(projectRoot, tail);
    await previous.catch(() => undefined);
    try {
        // The authored project gate must be outside per-resource gates (and uses a distinct lock namespace).
        // Artifact compilation and browser state retain local ordering without blocking other authoring processes.
        return options.authoredWrite === false ? await action() : await withProjectWriteLock(projectRoot, action);
    }
    finally {
        release();
        if (projectMutationQueues.get(projectRoot) === tail)
            projectMutationQueues.delete(projectRoot);
    }
}
function assertExpectedRevision(snapshot, expectedRevision) {
    if (expectedRevision !== undefined && expectedRevision !== snapshot.revision) {
        projectError("STALE_PROJECT_REVISION", `Expected project revision ${expectedRevision}, but the current revision is ${snapshot.revision}`);
    }
}
function findScene(snapshot, sceneId) {
    const scene = snapshot.scenesById.get(sceneId);
    if (!scene)
        projectError("SCENE_NOT_FOUND", `No scene with id ${sceneId} exists in this project`);
    return scene;
}
function collectScriptEvents(value, visit) {
    if (Array.isArray(value)) {
        for (const child of value)
            collectScriptEvents(child, visit);
        return;
    }
    if (!isObject(value))
        return;
    if (typeof value.command === "string")
        visit(value);
    for (const [key, child] of Object.entries(value)) {
        if (key === "args")
            continue;
        if (Array.isArray(child) || isObject(child))
            collectScriptEvents(child, visit);
    }
}
function collectEventReferences(value) {
    const references = { scenes: new Set(), actors: new Set(), variables: new Set() };
    collectScriptEvents(value, (event) => {
        if (!isObject(event.args))
            return;
        const args = event.args;
        if (event.command === "EVENT_SWITCH_SCENE" && typeof args.sceneId === "string")
            references.scenes.add(args.sceneId);
        if (typeof args.actorId === "string" && args.actorId !== "$self$" && args.actorId !== "player") {
            references.actors.add(args.actorId);
        }
        if (typeof args.variable === "string")
            references.variables.add(args.variable);
        collectVariableValues(args, references.variables);
    });
    return references;
}
function collectVariableValues(value, output) {
    if (Array.isArray(value)) {
        for (const child of value)
            collectVariableValues(child, output);
        return;
    }
    if (!isObject(value))
        return;
    if (value.type === "variable" && typeof value.value === "string")
        output.add(value.value);
    for (const child of Object.values(value))
        collectVariableValues(child, output);
}
function containsStructuralTriggerReference(value, triggerId) {
    if (Array.isArray(value)) {
        return value.some((child) => containsStructuralTriggerReference(child, triggerId));
    }
    if (!isObject(value))
        return false;
    for (const [key, child] of Object.entries(value)) {
        if (TRIGGER_REFERENCE_KEYS.has(key)) {
            if (child === triggerId)
                return true;
            if (Array.isArray(child) && child.includes(triggerId))
                return true;
            if (isObject(child) && (child.id === triggerId || child.value === triggerId))
                return true;
        }
        if (containsStructuralTriggerReference(child, triggerId))
            return true;
    }
    return false;
}
function assertCompleteDestructiveCoverage(indexedAccess) {
    if (indexedAccess && !indexedAccess.coverage.complete) {
        projectError("RESOURCE_IN_USE", "Cannot verify complete semantic reference coverage for a destructive scene transaction");
    }
}
function allAuthoredOwners(snapshot) {
    const owners = [];
    for (const scene of snapshot.scenesById.values()) {
        owners.push({ sceneId: scene.id, resource: strippedResource(scene), path: scene.resourcePath });
        for (const resource of [...scene.actors, ...scene.triggers]) {
            owners.push({ sceneId: scene.id, resource: strippedResource(resource), path: resource.resourcePath });
        }
    }
    return owners;
}
function compactResource(resource, includeScripts, remaining) {
    const result = structuredClone(resource);
    for (const [key, value] of Object.entries(result)) {
        if (!(key === "script" || /^[A-Za-z][A-Za-z0-9]*Script$/.test(key)) || !Array.isArray(value))
            continue;
        if (!includeScripts) {
            delete result[key];
            continue;
        }
        let events = 0;
        collectScriptEvents(value, () => { events++; });
        if (events > remaining.value) {
            result[key] = [];
            remaining.truncated = true;
        }
        else {
            remaining.value -= events;
        }
    }
    return result;
}
export async function inspectScene(projectPath, input, indexedAccess) {
    assertIndexedProjectAccess(projectPath, indexedAccess);
    const sceneId = requiredString(input.sceneId, "Scene id");
    const maxActors = boundedLimit(input.maxActors, 25, MAX_INSPECTION_RESOURCES, "maxActors");
    const maxTriggers = boundedLimit(input.maxTriggers, 25, MAX_INSPECTION_RESOURCES, "maxTriggers");
    const maxEvents = boundedLimit(input.maxEvents, 50, MAX_INSPECTION_EVENTS, "maxEvents");
    let projectRoot;
    let selectedProjectPath;
    let revision;
    let selected;
    if (indexedAccess) {
        await indexedAccess.ensureFresh();
        const indexedScene = indexedAccess.scene(sceneId);
        if (!indexedScene)
            projectError("SCENE_NOT_FOUND", `No scene with id ${sceneId} exists in this project`);
        projectRoot = indexedAccess.projectRoot;
        selectedProjectPath = indexedAccess.projectPath;
        revision = indexedAccess.revision;
        selected = indexedScene;
    }
    else {
        const snapshot = await loadProjectSnapshot(projectPath);
        projectRoot = snapshot.projectRoot;
        selectedProjectPath = snapshot.projectPath;
        revision = snapshot.revision;
        selected = findScene(snapshot, sceneId);
    }
    const format = path.resolve(projectRoot, selected.resourcePath) === path.resolve(selectedProjectPath) ? "legacy" : "distributed";
    const loaded = await readProjectJsonWithRevision(projectRoot, selected.resourcePath);
    const nativeScene = format === "legacy"
        ? (Array.isArray(loaded.json.scenes) ? loaded.json.scenes.find((value) => isObject(value) && value.id === sceneId) : undefined)
        : loaded.json;
    if (!isObject(nativeScene) || nativeScene.id !== sceneId) {
        projectError("SCENE_NOT_FOUND", `Scene ${sceneId} changed or disappeared from its native resource`);
    }
    const normalizeNative = (resource) => {
        const { resourcePath: _resourcePath, ...value } = resource;
        return { ...value, id: typeof resource.id === "string" ? resource.id : "", name: typeof resource.name === "string" ? resource.name : "" };
    };
    const { actors: _sceneActors, triggers: _sceneTriggers, ...sceneData } = normalizeNative(nativeScene);
    const actors = format === "legacy"
        ? (Array.isArray(nativeScene.actors) ? nativeScene.actors.filter(isObject).map(normalizeNative) : [])
        : selected.actors.map(strippedResource);
    const triggers = format === "legacy"
        ? (Array.isArray(nativeScene.triggers) ? nativeScene.triggers.filter(isObject).map(normalizeNative) : [])
        : selected.triggers.map(strippedResource);
    let eventCount = 0;
    for (const resource of [sceneData, ...actors, ...triggers]) {
        collectScriptEvents(resource, () => { eventCount++; });
    }
    const remaining = { value: maxEvents, truncated: false };
    return {
        projectPath: selectedProjectPath,
        projectRoot,
        revision,
        revisionAlgorithm: PROJECT_REVISION_ALGORITHM,
        sceneRevision: loaded.revision,
        scene: compactResource(sceneData, input.includeScripts ?? false, remaining),
        actors: actors.slice(0, maxActors).map((actor) => compactResource(actor, input.includeScripts ?? false, remaining)),
        triggers: triggers.slice(0, maxTriggers).map((trigger) => compactResource(trigger, input.includeScripts ?? false, remaining)),
        counts: { actors: actors.length, triggers: triggers.length, events: eventCount },
        truncated: {
            actors: actors.length > maxActors,
            triggers: triggers.length > maxTriggers,
            events: remaining.truncated,
        },
    };
}
async function stageFile(overlay, relativePath, after) {
    const existing = overlay.staged.get(relativePath);
    if (existing) {
        existing.after = after;
        return;
    }
    const absolutePath = path.join(overlay.snapshot.projectRoot, relativePath);
    await resolveProjectPath(overlay.snapshot.projectRoot, absolutePath, { mustExist: false });
    let before = null;
    if (await projectPathExists(overlay.snapshot.projectRoot, absolutePath)) {
        await resolveProjectPath(overlay.snapshot.projectRoot, absolutePath, { mustExist: true });
        before = await readFile(absolutePath);
    }
    overlay.staged.set(relativePath, { relativePath, absolutePath, before, after });
}
async function markResource(overlay, resource, deleted = false) {
    if (overlay.format === "legacy") {
        if (!overlay.descriptor)
            projectError("INVALID_RESOURCE", "Legacy transaction is missing its descriptor");
        await stageFile(overlay, projectRelativePath(overlay.snapshot.projectRoot, overlay.snapshot.projectPath), overlay.descriptor);
    }
    else {
        await stageFile(overlay, resource.relativePath, deleted ? null : resource.value);
    }
}
async function createOverlay(snapshot, sceneId) {
    const selected = findScene(snapshot, sceneId);
    const inventory = snapshot.inventory;
    const distributed = inventory.format === "distributed";
    let descriptor;
    let sceneValue = strippedResource(selected);
    const actors = new Map();
    const triggers = new Map();
    if (!distributed) {
        descriptor = structuredClone(snapshot.descriptor);
        const scenes = Array.isArray(descriptor.scenes) ? descriptor.scenes : [];
        const original = scenes.find((candidate) => isObject(candidate) && candidate.id === sceneId);
        if (!isObject(original))
            projectError("SCENE_NOT_FOUND", `Scene ${sceneId} disappeared from the legacy descriptor`);
        sceneValue = original;
    }
    for (const [resources, destination, collectionKey] of [
        [selected.actors, actors, "actors"],
        [selected.triggers, triggers, "triggers"],
    ]) {
        for (const resource of resources) {
            let value = strippedResource(resource);
            if (!distributed) {
                const collection = sceneValue[collectionKey];
                const original = (Array.isArray(collection) ? collection : []).find((candidate) => isObject(candidate) && candidate.id === resource.id);
                if (!isObject(original))
                    projectError("RESOURCE_NOT_FOUND", `Resource ${resource.id} disappeared from the legacy descriptor`);
                value = original;
            }
            destination.set(resource.id, { id: resource.id, relativePath: resource.resourcePath, value });
        }
    }
    return {
        snapshot,
        inventory,
        format: inventory.format,
        scene: { id: selected.id, relativePath: selected.resourcePath, value: sceneValue },
        actors,
        triggers,
        ...(descriptor ? { descriptor } : {}),
        deletedActorIds: new Set(),
        deletedTriggerIds: new Set(),
        deletedVariableIds: new Set(),
        staged: new Map(),
    };
}
function operationFields(operation) {
    if (operation.input !== undefined && !isObject(operation.input)) {
        projectError("INVALID_INPUT", "Operation input must be a JSON object");
    }
    const { type: _type, clientId: _clientId, input, ...rest } = operation;
    return { ...rest, ...(isObject(input) ? input : {}) };
}
function normalizeOperationType(type) {
    const raw = requiredString(type, "Operation type");
    const aliases = {
        actor_create: "actor.create",
        actor_update: "actor.update",
        actor_delete: "actor.delete",
        trigger_create: "trigger.create",
        trigger_update: "trigger.update",
        trigger_delete: "trigger.delete",
        script_edit: "event.edit",
        "script.edit": "event.edit",
        event_edit: "event.edit",
        collision_edit: "collision.edit",
        variable_update: "variable.update",
        scene_transition: "scene.transition",
        scene_update: "scene.update",
    };
    return aliases[raw] ?? raw;
}
function nextOverlayIndex(resources) {
    return nextResourceIndex([...resources.values()].map(toProjectResource));
}
async function chooseResourcePath(overlay, kind, name) {
    const base = resourceSlug(name, kind === "actors" ? "actor" : "trigger");
    const folder = path.join(path.dirname(overlay.scene.relativePath), kind);
    for (let index = 1; index <= 10_000; index++) {
        const slug = index === 1 ? base : `${base}_${index}`;
        const relativePath = path.join(folder, `${slug}.gbsres`).split(path.sep).join("/");
        const staged = overlay.staged.get(relativePath);
        if (staged?.after === null)
            continue;
        if (staged || await projectPathExists(overlay.snapshot.projectRoot, path.join(overlay.snapshot.projectRoot, relativePath)))
            continue;
        return { relativePath, slug };
    }
    projectError("RESOURCE_NAME_EXHAUSTED", `Could not choose a unique ${kind} resource path for ${base}`);
}
function assertKnownScene(input, sceneId) {
    if (input.sceneId !== undefined && input.sceneId !== sceneId) {
        projectError("INVALID_INPUT", `Operation scene ${String(input.sceneId)} does not match batch scene ${sceneId}`);
    }
}
function pushLegacyResource(overlay, kind, value) {
    if (overlay.format !== "legacy")
        return;
    const collection = overlay.scene.value[kind];
    if (!Array.isArray(collection)) {
        overlay.scene.value[kind] = [value];
    }
    else {
        collection.push(value);
    }
}
function replaceLegacyResource(overlay, kind, resourceId, replacement) {
    if (overlay.format !== "legacy")
        return;
    const collection = overlay.scene.value[kind];
    if (!Array.isArray(collection))
        projectError("INVALID_RESOURCE", `Legacy scene has an invalid ${kind} collection`);
    const index = collection.findIndex((candidate) => isObject(candidate) && candidate.id === resourceId);
    if (index < 0)
        projectError("RESOURCE_NOT_FOUND", `Legacy ${kind} resource ${resourceId} disappeared`);
    if (replacement)
        collection[index] = replacement;
    else
        collection.splice(index, 1);
}
function assertUniqueResourceId(overlay, id) {
    if (overlay.snapshot.actorsById.has(id) || overlay.snapshot.triggersById.has(id) || overlay.snapshot.scenesById.has(id)
        || overlay.actors.has(id) || overlay.triggers.has(id)) {
        projectError("DUPLICATE_RESOURCE_ID", `A resource with id ${id} already exists`);
    }
}
async function applyActorOperation(overlay, type, fields) {
    assertKnownScene(fields, overlay.scene.id);
    if (type === "actor.create") {
        const name = requiredString(fields.name, "Actor name");
        const chosen = overlay.format === "distributed"
            ? await chooseResourcePath(overlay, "actors", name)
            : { relativePath: overlay.scene.relativePath, slug: resourceSlug(name, "actor") };
        const id = fields.id === undefined ? stableResourceId(overlay.scene.id, "actor", chosen.slug) : requiredString(fields.id, "Actor id");
        assertUniqueResourceId(overlay, id);
        const input = { ...fields, sceneId: overlay.scene.id, name };
        const created = createActorResource(input, id, nextOverlayIndex(overlay.actors), chosen.slug);
        const value = fields.properties === undefined
            ? created
            : mergeActorChanges(created, { sceneId: overlay.scene.id, actorId: id, properties: fields.properties });
        validateActorResource(overlay.inventory, sceneResource(overlay), value);
        const resource = { id, relativePath: chosen.relativePath, value };
        overlay.actors.set(id, resource);
        pushLegacyResource(overlay, "actors", value);
        await markResource(overlay, resource);
        return { resourceId: id, resourcePath: chosen.relativePath };
    }
    const actorId = requiredString(fields.actorId ?? fields.id, "Actor id");
    const resource = overlay.actors.get(actorId);
    if (!resource)
        projectError("ACTOR_NOT_FOUND", `No actor with id ${actorId} exists in scene ${overlay.scene.id}`);
    if (type === "actor.delete") {
        overlay.actors.delete(actorId);
        overlay.deletedActorIds.add(actorId);
        replaceLegacyResource(overlay, "actors", actorId);
        await markResource(overlay, resource, true);
        return { resourceId: actorId, resourcePath: resource.relativePath };
    }
    const merged = mergeActorChanges(resource.value, { ...fields, sceneId: overlay.scene.id, actorId });
    validateActorResource(overlay.inventory, sceneResource(overlay), merged, resource.value, {
        explicitlyPatchedCoordinates: explicitlyPatchedNativeScriptCoordinates(isObject(fields.properties) ? fields.properties : undefined),
    });
    resource.value = merged;
    replaceLegacyResource(overlay, "actors", actorId, merged);
    await markResource(overlay, resource);
    return { resourceId: actorId, resourcePath: resource.relativePath };
}
async function applyTriggerOperation(overlay, type, fields) {
    assertKnownScene(fields, overlay.scene.id);
    if (type === "trigger.create") {
        const name = requiredString(fields.name, "Trigger name");
        const chosen = overlay.format === "distributed"
            ? await chooseResourcePath(overlay, "triggers", name)
            : { relativePath: overlay.scene.relativePath, slug: resourceSlug(name, "trigger") };
        const id = fields.id === undefined ? stableResourceId(overlay.scene.id, "trigger", chosen.slug) : requiredString(fields.id, "Trigger id");
        assertUniqueResourceId(overlay, id);
        const input = { ...fields, sceneId: overlay.scene.id, name };
        const created = createTriggerResource(input, id, nextOverlayIndex(overlay.triggers), chosen.slug);
        const value = fields.properties === undefined
            ? created
            : mergeTriggerChanges(created, { sceneId: overlay.scene.id, triggerId: id, properties: fields.properties });
        validateTriggerResource(sceneResource(overlay), value);
        const resource = { id, relativePath: chosen.relativePath, value };
        overlay.triggers.set(id, resource);
        pushLegacyResource(overlay, "triggers", value);
        await markResource(overlay, resource);
        return { resourceId: id, resourcePath: chosen.relativePath };
    }
    const triggerId = requiredString(fields.triggerId ?? fields.id, "Trigger id");
    const resource = overlay.triggers.get(triggerId);
    if (!resource)
        projectError("TRIGGER_NOT_FOUND", `No trigger with id ${triggerId} exists in scene ${overlay.scene.id}`);
    if (type === "trigger.delete") {
        overlay.triggers.delete(triggerId);
        overlay.deletedTriggerIds.add(triggerId);
        replaceLegacyResource(overlay, "triggers", triggerId);
        await markResource(overlay, resource, true);
        return { resourceId: triggerId, resourcePath: resource.relativePath };
    }
    const merged = mergeTriggerChanges(resource.value, { ...fields, sceneId: overlay.scene.id, triggerId });
    validateTriggerResource(sceneResource(overlay), merged, resource.value, {
        explicitlyPatchedCoordinates: explicitlyPatchedNativeScriptCoordinates(isObject(fields.properties) ? fields.properties : undefined),
    });
    resource.value = merged;
    replaceLegacyResource(overlay, "triggers", triggerId, merged);
    await markResource(overlay, resource);
    return { resourceId: triggerId, resourcePath: resource.relativePath };
}
function resolveScriptOwner(overlay, fields) {
    const target = isObject(fields.target) ? fields.target : fields;
    assertKnownScene(target, overlay.scene.id);
    if (target.actorId && target.triggerId)
        projectError("INVALID_INPUT", "Specify either actorId or triggerId, not both");
    if (target.actorId !== undefined) {
        const actorId = requiredString(target.actorId, "Actor id");
        const actor = overlay.actors.get(actorId);
        if (!actor)
            projectError("ACTOR_NOT_FOUND", `No actor with id ${actorId} exists in scene ${overlay.scene.id}`);
        return { owner: actor, target };
    }
    if (target.triggerId !== undefined) {
        const triggerId = requiredString(target.triggerId, "Trigger id");
        const trigger = overlay.triggers.get(triggerId);
        if (!trigger)
            projectError("TRIGGER_NOT_FOUND", `No trigger with id ${triggerId} exists in scene ${overlay.scene.id}`);
        return { owner: trigger, target };
    }
    return { owner: overlay.scene, target };
}
async function applyEventOperation(overlay, fields) {
    const { owner, target } = resolveScriptOwner(overlay, fields);
    const input = { ...fields, ...target, target: { ...target, sceneId: overlay.scene.id } };
    const edited = applyScriptEdit(owner.value, input);
    owner.value = edited.resource;
    if (owner.id === overlay.scene.id) {
        if (overlay.format === "legacy" && overlay.descriptor) {
            const scenes = Array.isArray(overlay.descriptor.scenes) ? overlay.descriptor.scenes : [];
            const index = scenes.findIndex((scene) => isObject(scene) && scene.id === overlay.scene.id);
            if (index < 0)
                projectError("SCENE_NOT_FOUND", `Legacy scene ${overlay.scene.id} disappeared`);
            scenes[index] = edited.resource;
            overlay.scene.value = edited.resource;
        }
    }
    else {
        replaceLegacyResource(overlay, overlay.actors.has(owner.id) ? "actors" : "triggers", owner.id, edited.resource);
    }
    await markResource(overlay, owner);
    return {
        resourceId: owner.id,
        resourcePath: owner.relativePath,
        ...(typeof edited.event.id === "string" ? { eventId: edited.event.id } : {}),
    };
}
async function applyTransitionOperation(overlay, fields) {
    const destinationId = requiredString(fields.destinationSceneId, "Destination scene id");
    const destination = overlay.snapshot.scenesById.get(destinationId);
    if (!destination)
        projectError("SCENE_NOT_FOUND", `No destination scene with id ${destinationId} exists`);
    const event = buildSceneTransitionEvent(fields, destination);
    return applyEventOperation(overlay, { ...fields, action: "insert", event });
}
async function loadVariablesResource(overlay) {
    if (overlay.variables)
        return overlay.variables;
    if (overlay.format === "legacy") {
        if (!overlay.descriptor || !Array.isArray(overlay.descriptor.variables)) {
            projectError("UNSUPPORTED_PROJECT_FORMAT", "This legacy project does not contain a verified native variables collection");
        }
        overlay.variables = {
            id: "variables",
            relativePath: overlay.scene.relativePath,
            value: { variables: overlay.descriptor.variables, constants: Array.isArray(overlay.descriptor.constants) ? overlay.descriptor.constants : [] },
        };
        return overlay.variables;
    }
    const relativePath = "project/variables.gbsres";
    const absolutePath = path.join(overlay.snapshot.projectRoot, relativePath);
    const value = await projectPathExists(overlay.snapshot.projectRoot, absolutePath)
        ? await readProjectJson(overlay.snapshot.projectRoot, absolutePath)
        : { _resourceType: "variables", variables: [], constants: [] };
    overlay.variables = { id: "variables", relativePath, value };
    return overlay.variables;
}
async function applyVariableOperation(overlay, type, fields) {
    const resource = await loadVariablesResource(overlay);
    const action = type === "variable.delete" ? "delete" : type === "variable.upsert" ? "upsert" : fields.action;
    if (action !== "delete" && action !== "upsert")
        projectError("INVALID_INPUT", "Variable action must be upsert or delete");
    const input = { ...fields, action };
    const edited = applyVariableUpdate(resource.value, input);
    resource.value = edited.resource;
    if (overlay.format === "legacy" && overlay.descriptor) {
        overlay.descriptor.variables = edited.resource.variables;
        if (Array.isArray(overlay.descriptor.constants))
            overlay.descriptor.constants = edited.resource.constants;
    }
    if (action === "delete" && typeof edited.variable.id === "string")
        overlay.deletedVariableIds.add(edited.variable.id);
    await markResource(overlay, resource);
    return { resourceId: typeof edited.variable.id === "string" ? edited.variable.id : undefined, resourcePath: resource.relativePath };
}
async function applyCollisionOperation(overlay, fields) {
    assertKnownScene(fields, overlay.scene.id);
    const edited = applyCollisionEdits(overlay.scene.value, { ...fields, sceneId: overlay.scene.id });
    if (overlay.format === "legacy" && overlay.descriptor) {
        const scenes = Array.isArray(overlay.descriptor.scenes) ? overlay.descriptor.scenes : [];
        const index = scenes.findIndex((scene) => isObject(scene) && scene.id === overlay.scene.id);
        if (index < 0)
            projectError("SCENE_NOT_FOUND", `Legacy scene ${overlay.scene.id} disappeared`);
        scenes[index] = edited.scene;
    }
    overlay.scene.value = edited.scene;
    await markResource(overlay, overlay.scene);
    return { resourceId: overlay.scene.id, resourcePath: overlay.scene.relativePath, changed: edited.changed };
}
async function applySceneUpdateOperation(overlay, fields) {
    assertKnownScene(fields, overlay.scene.id);
    const { sceneId: _sceneId, properties, ...typed } = fields;
    if (properties !== undefined && !isObject(properties))
        projectError("INVALID_INPUT", "Scene properties must be a JSON object");
    assertSafeValue(properties, "Scene properties", true);
    assertSafeValue(typed, "Scene updates", true);
    const updates = { ...(isObject(properties) ? properties : {}), ...typed };
    const updated = { ...overlay.scene.value, ...updates };
    validateNativeSceneUpdate(overlay.scene.value, updated, updates, (id) => resolveNativeAssetIdentity(overlay.inventory, id)?.type === "background");
    if (overlay.format === "legacy" && overlay.descriptor) {
        const scenes = Array.isArray(overlay.descriptor.scenes) ? overlay.descriptor.scenes : [];
        const index = scenes.findIndex((scene) => isObject(scene) && scene.id === overlay.scene.id);
        if (index < 0)
            projectError("SCENE_NOT_FOUND", `Legacy scene ${overlay.scene.id} disappeared`);
        scenes[index] = updated;
    }
    overlay.scene.value = updated;
    await markResource(overlay, overlay.scene);
    return { resourceId: overlay.scene.id, resourcePath: overlay.scene.relativePath };
}
async function applyOperation(overlay, operation, index) {
    if (!isObject(operation))
        projectError("INVALID_INPUT", `Operation ${index} must be an object`);
    const type = normalizeOperationType(operation.type);
    const fields = operationFields(operation);
    assertSafeValue(fields, `Operation ${index}`);
    let result;
    switch (type) {
        case "actor.create":
        case "actor.update":
        case "actor.delete":
            result = await applyActorOperation(overlay, type, fields);
            break;
        case "trigger.create":
        case "trigger.update":
        case "trigger.delete":
            result = await applyTriggerOperation(overlay, type, fields);
            break;
        case "event.edit":
            result = await applyEventOperation(overlay, fields);
            break;
        case "scene.transition":
            result = await applyTransitionOperation(overlay, fields);
            break;
        case "collision.edit":
            result = await applyCollisionOperation(overlay, fields);
            break;
        case "variable.upsert":
        case "variable.delete":
        case "variable.update":
            result = await applyVariableOperation(overlay, type, fields);
            break;
        case "scene.update":
            result = await applySceneUpdateOperation(overlay, fields);
            break;
        default:
            projectError("INVALID_BATCH_OPERATION", `Unsupported scene batch operation ${type}`);
    }
    return { index, type, ...(operation.clientId !== undefined ? { clientId: requiredString(operation.clientId, "Client id") } : {}), ...result };
}
function validateFinalGraph(overlay, indexedAccess) {
    const selected = sceneResource(overlay);
    const originalScene = overlay.snapshot.scenesById.get(overlay.scene.id);
    const invalidScene = findInvalidNativeScriptEvent(overlay.scene.value, originalScene);
    if (invalidScene)
        projectError("INVALID_EVENT", invalidScene.message);
    for (const actor of overlay.actors.values()) {
        const original = originalScene?.actors.find((candidate) => candidate.id === actor.id);
        validateActorResource(overlay.inventory, selected, actor.value, original);
    }
    for (const trigger of overlay.triggers.values()) {
        const original = originalScene?.triggers.find((candidate) => candidate.id === trigger.id);
        validateTriggerResource(selected, trigger.value, original);
    }
    const deletedResources = overlay.deletedActorIds.size > 0
        || overlay.deletedTriggerIds.size > 0
        || overlay.deletedVariableIds.size > 0;
    if (deletedResources)
        assertCompleteDestructiveCoverage(indexedAccess);
    const owners = deletedResources && !indexedAccess
        ? allAuthoredOwners(overlay.snapshot).filter((owner) => owner.sceneId !== overlay.scene.id)
        : [];
    if (deletedResources && indexedAccess) {
        // The index proves the on-disk owners, not this proposed overlay. A new or
        // changed surviving owner can introduce helper-hidden dependencies even
        // when none existed before. Without overlay extraction, refuse that mixed
        // transaction. Pure owner removal still removes its hidden references.
        const unchangedOwner = (next, previous) => previous !== undefined
            && JSON.stringify(strippedResource(next)) === JSON.stringify(strippedResource(previous));
        const originalActors = new Map(originalScene?.actors.map((owner) => [owner.id, owner]));
        const originalTriggers = new Map(originalScene?.triggers.map((owner) => [owner.id, owner]));
        if (!unchangedOwner(selected, originalScene)
            || [...overlay.actors.values()].some((owner) => !unchangedOwner(toProjectResource(owner), originalActors.get(owner.id)))
            || [...overlay.triggers.values()].some((owner) => !unchangedOwner(toProjectResource(owner), originalTriggers.get(owner.id)))) {
            projectError("RESOURCE_IN_USE", "Cannot verify new dependencies when a destructive scene transaction also creates or changes a surviving owner");
        }
        const retainedReference = (reference) => {
            if (reference.owner.sceneId !== overlay.scene.id)
                return true;
            if (!reference.reviewedContract)
                return false;
            // The final authored-argument scan cannot recover a hidden helper effect.
            // Retain it conservatively while its owner survives this transaction.
            if (reference.owner.resourceType === "actor")
                return overlay.actors.has(reference.owner.resourceId);
            if (reference.owner.resourceType === "trigger")
                return overlay.triggers.has(reference.owner.resourceId);
            return true;
        };
        for (const actorId of overlay.deletedActorIds) {
            const reference = indexedAccess.structuralReferences({ type: "actor", id: actorId })
                .find(retainedReference);
            if (reference) {
                projectError("RESOURCE_IN_USE", `Actor ${actorId} is still referenced by ${reference.resourcePath}`);
            }
        }
        for (const triggerId of overlay.deletedTriggerIds) {
            const reference = indexedAccess.structuralReferences({
                type: "trigger",
                id: triggerId,
                sceneId: overlay.scene.id,
            }).find(retainedReference);
            if (reference) {
                projectError("RESOURCE_IN_USE", `Trigger ${triggerId} is still referenced by ${reference.resourcePath}`);
            }
        }
        for (const variableId of overlay.deletedVariableIds) {
            const reference = indexedAccess.structuralReferences({ type: "variable", id: variableId })
                .find(retainedReference);
            if (reference) {
                projectError("RESOURCE_IN_USE", `Variable ${variableId} is still referenced by ${reference.resourcePath}`);
            }
        }
    }
    for (const resource of [overlay.scene, ...overlay.actors.values(), ...overlay.triggers.values()]) {
        owners.push({ sceneId: overlay.scene.id, resource: resource.value, path: resource.relativePath });
    }
    for (const owner of owners) {
        const references = collectEventReferences(owner.resource);
        for (const actorId of overlay.deletedActorIds) {
            if (references.actors.has(actorId))
                projectError("RESOURCE_IN_USE", `Actor ${actorId} is still referenced by ${owner.path}`);
        }
        for (const triggerId of overlay.deletedTriggerIds) {
            if (containsStructuralTriggerReference(owner.resource, triggerId)) {
                projectError("RESOURCE_IN_USE", `Trigger ${triggerId} is still referenced by ${owner.path}`);
            }
        }
        for (const variableId of overlay.deletedVariableIds) {
            if (references.variables.has(variableId))
                projectError("RESOURCE_IN_USE", `Variable ${variableId} is still referenced by ${owner.path}`);
        }
        if (owner.sceneId === overlay.scene.id) {
            for (const sceneId of references.scenes) {
                if (!overlay.snapshot.scenesById.has(sceneId))
                    projectError("SCENE_NOT_FOUND", `Script ${owner.path} references missing scene ${sceneId}`);
            }
        }
    }
}
async function restoreOriginalFile(projectRoot, staged) {
    if (staged.replacement) {
        await rollbackNativeReplacement(projectRoot, staged.relativePath, staged.replacement);
        return;
    }
    if (staged.createOnly && staged.createdIdentity) {
        await rollbackNativeImportFile(projectRoot, staged.relativePath, staged.createdIdentity);
        return;
    }
    if (staged.strictPath)
        await preparedPath(projectRoot, staged.relativePath);
    const currentlyExists = await projectPathExists(projectRoot, staged.absolutePath);
    if (staged.appliedDigest === null) {
        if (currentlyExists)
            projectError("TRANSACTION_RECOVERY_CONFLICT", `An external editor recreated ${staged.relativePath} during transaction rollback`);
    }
    else {
        if (!currentlyExists)
            projectError("TRANSACTION_RECOVERY_CONFLICT", `An external editor removed ${staged.relativePath} during transaction rollback`);
        const current = await readFile(staged.absolutePath);
        if (digest(current) !== staged.appliedDigest) {
            projectError("TRANSACTION_RECOVERY_CONFLICT", `An external editor changed ${staged.relativePath} during transaction rollback`);
        }
    }
    if (staged.before === null) {
        if (currentlyExists)
            await unlink(staged.absolutePath);
        return;
    }
    await writeProjectBytesAtomic(projectRoot, staged.absolutePath, staged.before);
}
async function verifyStagedBeforeImage(projectRoot, staged) {
    if (staged.beforeIdentity && staged.before) {
        const current = await readNativeImportSnapshot(projectRoot, staged.relativePath, staged.before.length);
        if (current.identity.dev !== staged.beforeIdentity.dev || current.identity.ino !== staged.beforeIdentity.ino
            || current.identity.sha256 !== staged.beforeIdentity.sha256 || current.identity.mode !== staged.beforeIdentity.mode || !current.bytes.equals(staged.before)) {
            projectError("STALE_PROJECT_REVISION", `Native import source changed: ${staged.relativePath}`);
        }
        return;
    }
    if (staged.strictPath)
        await preparedPath(projectRoot, staged.relativePath);
    const currentlyExists = await projectPathExists(projectRoot, staged.absolutePath);
    if (staged.before === null) {
        if (currentlyExists) {
            projectError("STALE_PROJECT_REVISION", `An external editor created ${staged.relativePath} during this scene transaction`);
        }
        return;
    }
    if (!currentlyExists) {
        projectError("STALE_PROJECT_REVISION", `An external editor removed ${staged.relativePath} during this scene transaction`);
    }
    await resolveProjectPath(projectRoot, staged.absolutePath, { mustExist: true });
    const current = await readFile(staged.absolutePath);
    if (!current.equals(staged.before)) {
        projectError("STALE_PROJECT_REVISION", `An external editor changed ${staged.relativePath} during this scene transaction`);
    }
}
async function withStagedResourceWriteLocks(projectRoot, staged, action) {
    const candidates = new Set();
    for (const entry of staged) {
        if (entry.before !== null) {
            await verifyStagedBeforeImage(projectRoot, entry);
            candidates.add(entry.absolutePath);
        }
        else if (/^project\/scenes\/[^/]+\/(?:actors|triggers)\//u.test(entry.relativePath)) {
            // New child files do not exist to lock; their existing scene owner coordinates direct creators.
            const sceneOwner = path.join(path.dirname(path.dirname(entry.absolutePath)), "scene.gbsres");
            if (await projectPathExists(projectRoot, sceneOwner))
                candidates.add(sceneOwner);
        }
    }
    const canonical = new Map();
    for (const candidate of candidates) {
        const identity = await resolveProjectPath(projectRoot, candidate, { mustExist: true });
        canonical.set(identity, candidate);
    }
    const owners = [...canonical.keys()].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
    const acquire = (index) => index === owners.length
        ? action()
        : withProjectResourceWriteLock(projectRoot, canonical.get(owners[index]), () => acquire(index + 1));
    return acquire(0);
}
async function commitStagedFiles(snapshot, staged, indexedAccess, verifySemanticFreshness = false, dryRun = false, expectedSemanticGeneration) {
    const ordered = [...staged.values()].sort((left, right) => left.relativePath.localeCompare(right.relativePath));
    return withStagedResourceWriteLocks(snapshot.projectRoot, ordered, () => commitLockedStagedFiles(snapshot, ordered, indexedAccess, verifySemanticFreshness, dryRun, expectedSemanticGeneration));
}
async function commitLockedStagedFiles(snapshot, ordered, indexedAccess, verifySemanticFreshness, dryRun, expectedSemanticGeneration) {
    const semanticGeneration = expectedSemanticGeneration ?? indexedAccess?.semanticGeneration;
    if (indexedAccess)
        await indexedAccess.strongRefresh();
    const verifiedRevision = indexedAccess?.revision ?? await projectRevision(snapshot.projectPath);
    if (verifiedRevision !== snapshot.revision) {
        projectError("STALE_PROJECT_REVISION", "The project changed while this scene transaction was being prepared");
    }
    if ((verifySemanticFreshness || expectedSemanticGeneration !== undefined) && indexedAccess && indexedAccess.semanticGeneration !== semanticGeneration) {
        projectError("STALE_PROJECT_REVISION", "Project event semantics changed while this native transaction was being prepared");
    }
    if (verifySemanticFreshness)
        assertCompleteDestructiveCoverage(indexedAccess);
    const applied = [];
    try {
        for (const entry of ordered) {
            await verifyStagedBeforeImage(snapshot.projectRoot, entry);
            if (dryRun)
                continue;
            if (entry.after === null) {
                if (entry.before === null)
                    continue;
                await resolveProjectPath(snapshot.projectRoot, entry.absolutePath, { mustExist: true });
                const status = await lstat(entry.absolutePath);
                if (status.isSymbolicLink())
                    projectError("UNSAFE_SYMLINK", `Refusing to remove symbolic link ${entry.relativePath}`);
                await unlink(entry.absolutePath);
                entry.appliedDigest = null;
            }
            else {
                const bytes = stagedBytes(entry.after);
                if (entry.before?.equals(bytes))
                    continue;
                if (entry.createOnly) {
                    await publishNativeImportFile(snapshot.projectRoot, entry.relativePath, bytes, identity => {
                        entry.createdIdentity = identity;
                        entry.appliedDigest = identity.sha256;
                        applied.push(entry);
                    });
                }
                else if (entry.preserveMode) {
                    await replaceNativeImportFile(snapshot.projectRoot, entry.relativePath, entry.before, entry.beforeIdentity, bytes, receipt => {
                        entry.replacement = receipt;
                        entry.appliedDigest = receipt.published.sha256;
                        applied.push(entry);
                    });
                }
                else {
                    await writeProjectBytesAtomic(snapshot.projectRoot, entry.absolutePath, bytes);
                    entry.appliedDigest = digest(bytes);
                }
            }
            if (!entry.createOnly && !entry.preserveMode)
                applied.push(entry);
        }
        if (!dryRun && indexedAccess && ordered.some(entry => entry.createOnly || entry.preserveMode)) {
            await indexedAccess.noteCommittedPaths(applied.map(entry => entry.absolutePath));
        }
        if (ordered.some(entry => entry.createOnly)) {
            const additions = applied.filter(entry => entry.createOnly).map(entry => entry.relativePath);
            const additiveOnly = ordered.every(entry => entry.createOnly || (entry.before !== null && entry.after !== null && entry.before.equals(stagedBytes(entry.after))));
            if (additiveOnly && await projectRevisionBeforeAdditions(snapshot.projectPath, additions) !== snapshot.revision) {
                projectError("STALE_PROJECT_REVISION", "The authored project changed during native import publication");
            }
            for (const entry of applied)
                if (entry.createdIdentity) {
                    const current = await readNativeImportSnapshot(snapshot.projectRoot, entry.relativePath, entry.createdIdentity.size);
                    if (current.identity.dev !== entry.createdIdentity.dev || current.identity.ino !== entry.createdIdentity.ino
                        || current.identity.sha256 !== entry.createdIdentity.sha256) {
                        projectError("STALE_PROJECT_REVISION", `A published native import output changed: ${entry.relativePath}`);
                    }
                }
        }
        // Paired source files may live outside the normal authored revision tree.
        for (const entry of ordered)
            if (entry.beforeIdentity && entry.before !== null && entry.after !== null
                && entry.before.equals(stagedBytes(entry.after)))
                await verifyStagedBeforeImage(snapshot.projectRoot, entry);
        for (const entry of applied)
            if (entry.preserveMode && entry.after !== null) {
                const bytes = stagedBytes(entry.after);
                const current = await readNativeImportSnapshot(snapshot.projectRoot, entry.relativePath, bytes.length);
                if (!entry.replacement || !current.bytes.equals(bytes) || current.identity.mode !== entry.replacement.published.mode
                    || current.identity.dev !== entry.replacement.published.dev || current.identity.ino !== entry.replacement.published.ino) {
                    projectError("STALE_PROJECT_REVISION", `A native update changed during publication: ${entry.relativePath}`);
                }
            }
        for (const entry of applied)
            if (entry.replacement)
                await finalizeNativeReplacement(snapshot.projectRoot, entry.replacement);
    }
    catch (error) {
        const publishedPaths = applied.map(entry => entry.relativePath);
        const rolledBackPaths = [], conflictedPaths = [], retainedPaths = [];
        const recoveryFailures = [];
        for (const entry of applied.reverse()) {
            try {
                await restoreOriginalFile(snapshot.projectRoot, entry);
                rolledBackPaths.push(entry.relativePath);
            }
            catch (recoveryError) {
                conflictedPaths.push(entry.relativePath);
                if (recoveryError instanceof NativeRecoveryConflict)
                    retainedPaths.push(...recoveryError.retainedPaths);
                recoveryFailures.push(`${entry.relativePath}: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`);
            }
        }
        if (ordered.some(entry => entry.createOnly || entry.preserveMode)) {
            if (recoveryFailures.length || error instanceof NativeRecoveryConflict)
                indexedAccess?.invalidate("Native import rollback conflicted with external changes");
            else if (indexedAccess && applied.length) {
                try {
                    await indexedAccess.noteCommittedPaths(applied.map(entry => entry.absolutePath));
                }
                catch {
                    indexedAccess.invalidate("Native import rollback could not refresh the index");
                }
            }
            const original = error instanceof NativeImportFileError ? error.originalCause : error;
            if (error instanceof NativeImportFileError)
                retainedPaths.push(...error.retainedPaths);
            if (error instanceof NativeRecoveryConflict) {
                retainedPaths.push(...error.retainedPaths);
                for (const entry of ordered)
                    if (entry.preserveMode && !conflictedPaths.includes(entry.relativePath))
                        conflictedPaths.push(entry.relativePath);
            }
            for (const entry of applied)
                if (entry.createdIdentity?.temporaryDirectory)
                    retainedPaths.push(path.relative(snapshot.projectRoot, entry.createdIdentity.temporaryDirectory).split(path.sep).join("/"));
            throw new ProjectTransactionError(recoveryFailures.length || error instanceof NativeRecoveryConflict ? "TRANSACTION_RECOVERY_CONFLICT" : original instanceof GameStudioProjectError ? original.code : "TRANSACTION_COMMIT_FAILED", original, {
                originalCause: { message: original instanceof Error ? original.message : String(original), ...(original instanceof GameStudioProjectError ? { code: original.code } : {}) },
                publishedPaths, rolledBackPaths, conflictedPaths, retainedPaths: [...new Set(retainedPaths)],
                ...(error instanceof NativeImportFileError ? { cleanupFailures: error.cleanupFailures } : {}),
            });
        }
        if (recoveryFailures.length > 0) {
            indexedAccess?.invalidate("Scene transaction rollback conflicted with external authored changes");
            projectError("TRANSACTION_RECOVERY_CONFLICT", `Scene transaction could not be safely rolled back: ${recoveryFailures.join("; ")}`);
        }
        if (indexedAccess && applied.length > 0) {
            try {
                await indexedAccess.noteCommittedPaths(applied.map((entry) => entry.absolutePath));
            }
            catch {
                indexedAccess.invalidate("Scene transaction rollback could not refresh touched authored resources");
            }
        }
        if (error instanceof GameStudioProjectError)
            throw error;
        projectError("TRANSACTION_COMMIT_FAILED", `Scene transaction was rolled back after a write failed: ${error instanceof Error ? error.message : String(error)}`);
    }
}
function normalizedPreparedPath(relativePath) {
    if (typeof relativePath !== "string" || relativePath.length === 0 || relativePath.includes("\u0000")
        || relativePath.includes("\\") || path.posix.isAbsolute(relativePath)
        || path.posix.normalize(relativePath) !== relativePath || relativePath === "."
        || relativePath === ".." || relativePath.startsWith("../")) {
        projectError("INVALID_RESOURCE_PATH", "Prepared file paths must be normalized, project-relative POSIX paths");
    }
    return relativePath;
}
/** A prepared output names one real path, never an alias through a project symlink. */
async function preparedPath(projectRoot, relativePath) {
    const normalized = normalizedPreparedPath(relativePath);
    const absolutePath = path.join(projectRoot, normalized);
    const resolved = await resolveProjectPath(projectRoot, absolutePath, { allowRoot: false });
    if (resolved !== absolutePath) {
        projectError("UNSAFE_SYMLINK", `Prepared resource ${relativePath} resolves through an aliased project path`);
    }
    let ancestor = projectRoot;
    const components = normalized.split("/");
    for (let index = 0; index < components.length; index++) {
        ancestor = path.join(ancestor, components[index]);
        let entry;
        try {
            entry = await lstat(ancestor);
        }
        catch (error) {
            if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")
                break;
            throw error;
        }
        if (entry.isSymbolicLink())
            projectError("UNSAFE_SYMLINK", `Prepared resource ${relativePath} uses a symbolic link`);
        const final = index === components.length - 1;
        if (final ? !entry.isFile() : !entry.isDirectory()) {
            projectError("INVALID_RESOURCE_PATH", `Prepared resource ${relativePath} does not name a regular file or a new descendant`);
        }
        if (final)
            return { absolutePath, identity: `${entry.dev}:${entry.ino}` };
    }
    return { absolutePath };
}
async function addPreparedFile(snapshot, staged, identities, file) {
    if (!isObject(file) || (file.before !== null && !Buffer.isBuffer(file.before))
        || (file.after !== null && !Buffer.isBuffer(file.after) && !isObject(file.after))) {
        projectError("INVALID_INPUT", "Prepared files require exact before bytes and JSON, byte, or deleted after images");
    }
    if (file.createOnly !== undefined && (file.createOnly !== true || file.before !== null || file.after === null)) {
        projectError("INVALID_INPUT", "createOnly requires a new nonempty after image and no before image");
    }
    if (file.beforeIdentity && (file.before === null || !Buffer.isBuffer(file.after) || (!file.before.equals(file.after) && !file.preserveMode)
        || file.beforeIdentity.size !== file.before.length || file.beforeIdentity.sha256 !== digest(file.before))) {
        projectError("INVALID_INPUT", "A guarded native source must match its snapshot identity");
    }
    if (file.preserveMode && (!file.beforeIdentity || ![0o600, 0o644].includes(file.beforeIdentity.mode ?? -1)
        || file.before === null || !Buffer.isBuffer(file.after))) {
        projectError("INVALID_INPUT", "A mode-preserving native update requires a bound 0600 or 0644 regular file");
    }
    const relativePath = normalizedPreparedPath(file.relativePath);
    // Conservative case/Unicode alias rejection also keeps prepared output portable.
    const key = relativePath.normalize("NFC").toLowerCase();
    for (const existing of staged.keys()) {
        const other = existing.normalize("NFC").toLowerCase();
        if (key === other || key.startsWith(`${other}/`) || other.startsWith(`${key}/`)) {
            projectError("DUPLICATE_TRANSACTION_PATH", `Prepared resource ${relativePath} overlaps ${existing}`);
        }
    }
    const { absolutePath, identity } = await preparedPath(snapshot.projectRoot, relativePath);
    if (identity !== undefined) {
        if (identities.has(identity))
            projectError("DUPLICATE_TRANSACTION_PATH", `Prepared resource ${relativePath} aliases another output file`);
        identities.add(identity);
    }
    staged.set(relativePath, {
        relativePath,
        absolutePath,
        before: file.before === null ? null : Buffer.from(file.before),
        after: file.after === null ? null : stagedBytes(file.after),
        strictPath: true,
        ...(file.createOnly ? { createOnly: true } : {}),
        ...(file.beforeIdentity ? { beforeIdentity: { ...file.beforeIdentity } } : {}),
        ...(file.preserveMode ? { preserveMode: true } : {}),
    });
}
function withPreparedAssets(snapshot, newAssets, staged) {
    if (newAssets.length === 0)
        return snapshot;
    const assets = [...snapshot.inventory.assets];
    const assetsById = new Map(snapshot.assetsById);
    for (const asset of newAssets) {
        const id = requiredString(asset.id, "Prepared asset id");
        const resourcePath = normalizedPreparedPath(asset.resourcePath);
        const metadataPath = asset.metadataPath === null ? "" : normalizedPreparedPath(asset.metadataPath);
        if (assetsById.has(id) || assets.some((existing) => existing.resourcePath === resourcePath || existing.metadataPath === metadataPath)) {
            projectError("DUPLICATE_RESOURCE_ID", `Prepared asset ${id} must be a new additive native asset`);
        }
        const image = staged.get(resourcePath);
        const sidecar = staged.get(metadataPath);
        if (!image || !sidecar || image.before !== null || sidecar.before !== null
            || !Buffer.isBuffer(image.after) || !Buffer.isBuffer(sidecar.after)
            || metadataPath !== `${resourcePath}.gbsres` || !asset.hasMetadata) {
            projectError("INVALID_RESOURCE", `Prepared asset ${id} requires a new image and its native sidecar in this transaction`);
        }
        let metadata;
        try {
            metadata = JSON.parse(sidecar.after.toString("utf8"));
        }
        catch {
            projectError("INVALID_RESOURCE", `Prepared asset ${id} has invalid native sidecar JSON`);
        }
        if (!isObject(metadata) || metadata.id !== id || metadata._resourceType !== asset.type || metadata.filename !== asset.filename) {
            projectError("INVALID_RESOURCE", `Prepared asset ${id} does not match its native sidecar identity`);
        }
        const copied = structuredClone(asset);
        assets.push(copied);
        assetsById.set(id, copied);
    }
    return {
        ...snapshot,
        assetsById,
        counts: { ...snapshot.counts, assets: assets.length },
        inventory: { ...snapshot.inventory, assets, counts: { ...snapshot.inventory.counts, assets: assets.length } },
    };
}
/** Internal native-authoring adapter; callers own format-specific validation, not a public file-write API. */
export async function applyPreparedProjectTransaction(projectPath, input, prepare, indexedAccess) {
    assertIndexedProjectAccess(projectPath, indexedAccess);
    const expectedRevision = requiredString(input.expectedRevision, "Expected project revision");
    const initial = indexedAccess ? undefined : await discoverProject(projectPath);
    return withProjectMutation(indexedAccess?.projectRoot ?? initial.projectRoot, async () => {
        if (indexedAccess)
            await indexedAccess.strongRefresh();
        const snapshot = indexedAccess
            ? await indexedAccess.snapshot()
            : await loadProjectSnapshot(initial.projectPath);
        if (snapshot.inventory.format !== "distributed") {
            projectError("UNSUPPORTED_PROJECT_FORMAT", "Prepared native authoring transactions require a distributed native game project");
        }
        assertExpectedRevision(snapshot, expectedRevision);
        const preparedSemanticGeneration = indexedAccess?.semanticGeneration;
        const prepared = await prepare(snapshot);
        if (!Array.isArray(prepared.files))
            projectError("INVALID_INPUT", "A prepared transaction requires a file array");
        const staged = new Map();
        const identities = new Set();
        for (const file of prepared.files)
            await addPreparedFile(snapshot, staged, identities, file);
        let operationResults;
        let verifySemanticFreshness = false;
        if (prepared.sceneBatch !== undefined) {
            const { sceneId, operations, newAssets = [] } = prepared.sceneBatch;
            if (!Array.isArray(operations) || operations.length > MAX_BATCH_OPERATIONS || !Array.isArray(newAssets)) {
                projectError("INVALID_INPUT", `Prepared scene operations must contain at most ${MAX_BATCH_OPERATIONS} entries`);
            }
            const augmented = withPreparedAssets(snapshot, newAssets, staged);
            const overlay = await createOverlay(augmented, requiredString(sceneId, "Scene id"));
            operationResults = [];
            for (let index = 0; index < operations.length; index++) {
                operationResults.push(await applyOperation(overlay, operations[index], index));
            }
            validateFinalGraph(overlay, indexedAccess);
            verifySemanticFreshness = overlay.deletedActorIds.size > 0 || overlay.deletedTriggerIds.size > 0 || overlay.deletedVariableIds.size > 0;
            for (const file of overlay.staged.values())
                await addPreparedFile(snapshot, staged, identities, file);
        }
        const changed = [...staged.values()].filter((file) => file.after === null
            ? file.before !== null
            : !file.before?.equals(stagedBytes(file.after)));
        // Even an empty/no-op plan and a dry run must finish against current before images.
        await commitStagedFiles(snapshot, staged, indexedAccess, verifySemanticFreshness, input.dryRun === true, preparedSemanticGeneration);
        if (input.dryRun !== true && indexedAccess && changed.length > 0 && !changed.some(file => file.createOnly || file.preserveMode)) {
            await indexedAccess.noteCommittedPaths(changed.map((file) => file.absolutePath));
        }
        const revision = indexedAccess?.revision ?? await projectRevision(snapshot.projectPath);
        return {
            projectPath: snapshot.projectPath,
            projectRoot: snapshot.projectRoot,
            previousRevision: snapshot.revision,
            revision,
            revisionAlgorithm: PROJECT_REVISION_ALGORITHM,
            dryRun: input.dryRun === true,
            changedPaths: changed.map((file) => file.relativePath).sort((left, right) => left.localeCompare(right)),
            transactionGuarantee: "distributed-best-effort-rollback",
            ...(operationResults === undefined ? {} : { operationResults }),
            value: prepared.value,
        };
    });
}
function transactionResult(snapshot, sceneId, staged, operationResults, dryRun, revision) {
    return {
        projectPath: snapshot.projectPath,
        projectRoot: snapshot.projectRoot,
        sceneId,
        revision,
        revisionAlgorithm: PROJECT_REVISION_ALGORITHM,
        previousRevision: snapshot.revision,
        dryRun,
        changedPaths: [...staged.keys()].sort((left, right) => left.localeCompare(right)),
        operationResults,
        transactionGuarantee: snapshot.inventory.format === "legacy" ? "legacy-single-file-atomic" : "distributed-best-effort-rollback",
    };
}
export async function applySceneBatch(projectPath, input, indexedAccess) {
    assertIndexedProjectAccess(projectPath, indexedAccess);
    const sceneId = requiredString(input.sceneId, "Scene id");
    if (!Array.isArray(input.operations) || input.operations.length === 0 || input.operations.length > MAX_BATCH_OPERATIONS) {
        projectError("INVALID_INPUT", `Scene batch operations must contain between 1 and ${MAX_BATCH_OPERATIONS} entries`);
    }
    const initial = indexedAccess ? undefined : await loadProjectSnapshot(projectPath);
    return withProjectMutation(indexedAccess?.projectRoot ?? initial.projectRoot, async () => {
        if (indexedAccess)
            await indexedAccess.ensureFresh();
        const snapshot = indexedAccess
            ? await indexedAccess.snapshot()
            : await loadProjectSnapshot(initial.projectPath);
        assertExpectedRevision(snapshot, input.expectedRevision);
        const overlay = await createOverlay(snapshot, sceneId);
        const results = [];
        for (let index = 0; index < input.operations.length; index++) {
            const operation = input.operations[index];
            if (!operation)
                projectError("INVALID_BATCH_OPERATION", `Operation ${index} is missing`);
            try {
                results.push(await applyOperation(overlay, operation, index));
            }
            catch (error) {
                if (error instanceof GameStudioProjectError) {
                    throw new GameStudioProjectError(error.code, `Operation ${index} (${String(operation.type)}) failed: ${error.message}`, error.resourcePath);
                }
                throw error;
            }
        }
        validateFinalGraph(overlay, indexedAccess);
        if (input.dryRun)
            return transactionResult(snapshot, sceneId, overlay.staged, results, true, snapshot.revision);
        await commitStagedFiles(snapshot, overlay.staged, indexedAccess, overlay.deletedActorIds.size > 0 || overlay.deletedTriggerIds.size > 0 || overlay.deletedVariableIds.size > 0);
        if (indexedAccess)
            await indexedAccess.noteCommittedPaths([...overlay.staged.values()].map((entry) => entry.absolutePath));
        const revision = indexedAccess?.revision ?? await projectRevision(snapshot.projectPath);
        return transactionResult(snapshot, sceneId, overlay.staged, results, false, revision);
    });
}
async function pruneEmptySceneDirectories(projectRoot, relativePaths) {
    const candidates = new Set();
    for (const relativePath of relativePaths) {
        let current = path.dirname(path.join(projectRoot, relativePath));
        const scenesRoot = path.join(projectRoot, "project", "scenes");
        while (current !== scenesRoot && current.startsWith(`${scenesRoot}${path.sep}`)) {
            candidates.add(current);
            current = path.dirname(current);
        }
    }
    for (const candidate of [...candidates].sort((left, right) => right.length - left.length)) {
        try {
            await rmdir(candidate);
        }
        catch (error) {
            if (!isObject(error) || !["ENOENT", "ENOTEMPTY"].includes(String(error.code)))
                throw error;
        }
    }
}
export async function deleteScene(projectPath, input, indexedAccess) {
    assertIndexedProjectAccess(projectPath, indexedAccess);
    const sceneId = requiredString(input.sceneId, "Scene id");
    const initial = indexedAccess ? undefined : await loadProjectSnapshot(projectPath);
    return withProjectMutation(indexedAccess?.projectRoot ?? initial.projectRoot, async () => {
        if (indexedAccess)
            await indexedAccess.ensureFresh();
        const snapshot = indexedAccess
            ? await indexedAccess.snapshot()
            : await loadProjectSnapshot(initial.projectPath);
        assertExpectedRevision(snapshot, input.expectedRevision);
        const selected = findScene(snapshot, sceneId);
        if (snapshot.settings.startSceneId === sceneId) {
            projectError("RESOURCE_IN_USE", `Scene ${sceneId} is selected as the project start scene`);
        }
        assertCompleteDestructiveCoverage(indexedAccess);
        const references = indexedAccess
            ? indexedAccess.structuralReferences({ type: "scene", id: sceneId })
                .filter((reference) => reference.owner.sceneId !== sceneId)
                .map((reference) => reference.resourcePath)
            : allAuthoredOwners(snapshot)
                .filter((owner) => owner.sceneId !== sceneId && collectEventReferences(owner.resource).scenes.has(sceneId))
                .map((owner) => owner.path);
        if (references.length > 0) {
            const sample = references.slice(0, MAX_REFERENCE_SAMPLES).join(", ");
            projectError("RESOURCE_IN_USE", `Scene ${sceneId} is still referenced by ${sample}${references.length > MAX_REFERENCE_SAMPLES ? ", ..." : ""}`);
        }
        const overlay = await createOverlay(snapshot, sceneId);
        if (overlay.format === "legacy") {
            if (!overlay.descriptor)
                projectError("INVALID_RESOURCE", "Legacy scene deletion is missing its descriptor");
            overlay.descriptor.scenes = (Array.isArray(overlay.descriptor.scenes) ? overlay.descriptor.scenes : [])
                .filter((scene) => !isObject(scene) || scene.id !== sceneId);
            await markResource(overlay, overlay.scene, true);
        }
        else {
            for (const resource of [...overlay.actors.values(), ...overlay.triggers.values(), overlay.scene]) {
                await markResource(overlay, resource, true);
            }
        }
        const results = [{ index: 0, type: "scene.delete", resourceId: sceneId, resourcePath: selected.resourcePath }];
        if (input.dryRun)
            return transactionResult(snapshot, sceneId, overlay.staged, results, true, snapshot.revision);
        await commitStagedFiles(snapshot, overlay.staged, indexedAccess, true);
        if (overlay.format === "distributed")
            await pruneEmptySceneDirectories(snapshot.projectRoot, [...overlay.staged.keys()]);
        if (indexedAccess)
            await indexedAccess.noteCommittedPaths([...overlay.staged.values()].map((entry) => entry.absolutePath));
        const revision = indexedAccess?.revision ?? await projectRevision(snapshot.projectPath);
        return transactionResult(snapshot, sceneId, overlay.staged, results, false, revision);
    });
}
export const sceneInspect = inspectScene;
export const sceneDelete = deleteScene;
export const sceneApply = applySceneBatch;
//# sourceMappingURL=scene-batch.js.map

SHA-256: 02db72d5ae70f46d37f7e92d024a0b83ac8d3dfb6e7f3220620b29079538d5e0