← Files ModRetro Chromatic PluginARCHIVED FILE

dist/game-scripts.js

20.4 KB · Oct 3, 2026 · 06:38 UTC

↓ Download file

import path from "node:path";
import { GameStudioProjectError, inventoryProject, } from "./project.js";
import { readProjectJsonWithRevision, resourceRevision, stableResourceId, withProjectResourceWriteLock, writeProjectJsonAtomic, } from "./project-files.js";
import { findInvalidNativeScriptEvent } from "./native-script-events.js";
const FORBIDDEN_KEYS = new Set(["__proto__", "prototype", "constructor"]);
const VALID_DIRECTIONS = new Set(["up", "down", "left", "right"]);
const MAXIMUM_EVENT_DEPTH = 64;
const MAXIMUM_EVENT_COUNT = 10_000;
function isObject(value) {
    return typeof value === "object" && value !== null && !Array.isArray(value);
}
function assertSafeJson(value, label, depth = 0) {
    if (depth > MAXIMUM_EVENT_DEPTH) {
        throw new GameStudioProjectError("INVALID_INPUT", `${label} exceeds the maximum nesting depth`);
    }
    if (Array.isArray(value)) {
        for (const child of value)
            assertSafeJson(child, label, depth + 1);
        return;
    }
    if (!isObject(value))
        return;
    for (const [key, child] of Object.entries(value)) {
        if (FORBIDDEN_KEYS.has(key)) {
            throw new GameStudioProjectError("INVALID_PROPERTY", `${label} cannot contain reserved property ${key}`);
        }
        assertSafeJson(child, label, depth + 1);
    }
}
function mergeJson(existing, updates) {
    const result = { ...existing };
    for (const [key, value] of Object.entries(updates)) {
        const original = result[key];
        result[key] = isObject(original) && isObject(value) ? mergeJson(original, value) : structuredClone(value);
    }
    return result;
}
function resolveScriptKey(target) {
    const key = target.scriptKey ?? "script";
    if (!/^(?:script|[A-Za-z][A-Za-z0-9]*Script)$/.test(key) || FORBIDDEN_KEYS.has(key)) {
        throw new GameStudioProjectError("INVALID_INPUT", `Unsupported script property ${key}`);
    }
    return key;
}
function scriptEvents(resource, target) {
    const scriptKey = resolveScriptKey(target);
    const root = resource[scriptKey];
    if (!Array.isArray(root)) {
        throw new GameStudioProjectError("SCRIPT_NOT_FOUND", `Resource ${String(resource.id ?? "unknown")} has no ${scriptKey} script`);
    }
    let current = root;
    for (const segment of target.branchPath ?? []) {
        if (!isObject(segment) || typeof segment.eventId !== "string" || typeof segment.branch !== "string" || !segment.branch) {
            throw new GameStudioProjectError("INVALID_INPUT", "Every script branch requires a nonempty eventId and branch");
        }
        if (FORBIDDEN_KEYS.has(segment.branch)) {
            throw new GameStudioProjectError("INVALID_PROPERTY", `Script branch ${segment.branch} is reserved`);
        }
        const parent = current.find((event) => isObject(event) && event.id === segment.eventId);
        if (!isObject(parent)) {
            throw new GameStudioProjectError("EVENT_NOT_FOUND", `No parent event with id ${segment.eventId} exists in the selected branch`);
        }
        const branch = isObject(parent.children) ? parent.children[segment.branch] : undefined;
        if (!Array.isArray(branch)) {
            throw new GameStudioProjectError("SCRIPT_BRANCH_NOT_FOUND", `Event ${segment.eventId} has no ${segment.branch} branch`);
        }
        current = branch;
    }
    if (!current.every(isObject)) {
        throw new GameStudioProjectError("INVALID_RESOURCE", `Resource ${String(resource.id ?? "unknown")} contains a malformed script event`);
    }
    return current;
}
function validateEventTree(resource, options) {
    const eventIds = new Set();
    let count = 0;
    function inspect(events, depth) {
        if (depth > MAXIMUM_EVENT_DEPTH) {
            throw new GameStudioProjectError("INVALID_EVENT", "Event tree exceeds the maximum nesting depth");
        }
        for (const candidate of events) {
            if (++count > MAXIMUM_EVENT_COUNT) {
                throw new GameStudioProjectError("INVALID_EVENT", "Event tree exceeds the maximum event count");
            }
            if (!isObject(candidate) || typeof candidate.id !== "string" || !candidate.id || typeof candidate.command !== "string" || !candidate.command) {
                throw new GameStudioProjectError("INVALID_EVENT", "Every script event requires a nonempty id and command");
            }
            if (eventIds.has(candidate.id)) {
                throw new GameStudioProjectError("DUPLICATE_EVENT_ID", `Script already contains event ${candidate.id}`);
            }
            eventIds.add(candidate.id);
            if (candidate.args !== undefined && !isObject(candidate.args)) {
                throw new GameStudioProjectError("INVALID_EVENT", `Event ${candidate.id} must contain an arguments object`);
            }
            if (candidate.command === "EVENT_SWITCH_SCENE") {
                const destination = isObject(candidate.args) ? candidate.args.sceneId : undefined;
                if (typeof destination !== "string" ||
                    !destination ||
                    (options.knownSceneIds && !options.knownSceneIds.has(destination)) ||
                    (options.sceneExists && !options.sceneExists(destination))) {
                    throw new GameStudioProjectError("SCENE_NOT_FOUND", `Event ${candidate.id} references an unknown destination scene`);
                }
            }
            if (candidate.children !== undefined && !isObject(candidate.children)) {
                throw new GameStudioProjectError("INVALID_EVENT", `Event ${candidate.id} must contain a named-branch object`);
            }
            if (isObject(candidate.children)) {
                for (const [branch, children] of Object.entries(candidate.children)) {
                    if (FORBIDDEN_KEYS.has(branch) || !Array.isArray(children)) {
                        throw new GameStudioProjectError("INVALID_EVENT", `Event ${candidate.id} has an invalid ${branch} branch`);
                    }
                    inspect(children, depth + 1);
                }
            }
        }
    }
    for (const [key, value] of Object.entries(resource)) {
        if (/^(?:script|[A-Za-z][A-Za-z0-9]*Script)$/.test(key) && Array.isArray(value))
            inspect(value, 0);
    }
}
function insertionIndex(events, input) {
    const placements = Number(input.index !== undefined) + Number(input.beforeEventId !== undefined) + Number(input.afterEventId !== undefined);
    if (placements > 1) {
        throw new GameStudioProjectError("INVALID_INPUT", "Specify at most one of index, beforeEventId, or afterEventId");
    }
    if (input.index !== undefined) {
        if (!Number.isInteger(input.index) || input.index < 0 || input.index > events.length) {
            throw new GameStudioProjectError("INVALID_INPUT", `Event index must be between 0 and ${events.length}`);
        }
        return input.index;
    }
    const reference = input.beforeEventId ?? input.afterEventId;
    if (reference !== undefined) {
        const index = events.findIndex((event) => event.id === reference);
        if (index < 0)
            throw new GameStudioProjectError("EVENT_NOT_FOUND", `No placement event with id ${reference} exists`);
        return index + Number(input.afterEventId !== undefined);
    }
    return events.length;
}
export function applyScriptEdit(resource, input, options = {}) {
    if (!input.target || !["insert", "update", "delete", "move"].includes(input.action)) {
        throw new GameStudioProjectError("INVALID_INPUT", "Script edits require a target and a supported action");
    }
    if (input.event)
        assertSafeJson(input.event, "Script event");
    if (input.patch)
        assertSafeJson(input.patch, "Script event patch");
    const cloned = structuredClone(resource);
    const events = scriptEvents(cloned, input.target);
    const scriptKey = resolveScriptKey(input.target);
    let event;
    let index;
    if (input.action === "insert") {
        if (!input.event || !isObject(input.event)) {
            throw new GameStudioProjectError("INVALID_INPUT", "Inserting a script event requires an event object");
        }
        index = insertionIndex(events, input);
        const supplied = structuredClone(input.event);
        event = {
            ...supplied,
            id: supplied.id ?? stableResourceId(String(resource.id ?? "resource"), scriptKey, JSON.stringify(input.target.branchPath ?? []), String(index), String(supplied.command ?? "event")),
        };
        events.splice(index, 0, event);
    }
    else {
        if (!input.eventId)
            throw new GameStudioProjectError("INVALID_INPUT", `${input.action} requires an eventId`);
        index = events.findIndex((candidate) => candidate.id === input.eventId);
        const selected = events[index];
        if (index < 0 || !selected) {
            throw new GameStudioProjectError("EVENT_NOT_FOUND", `No event with id ${input.eventId} exists in the selected branch`);
        }
        if (input.action === "update") {
            if (!input.patch || !isObject(input.patch)) {
                throw new GameStudioProjectError("INVALID_INPUT", "Updating a script event requires a patch object");
            }
            if (input.patch.id !== undefined && input.patch.id !== input.eventId) {
                throw new GameStudioProjectError("INVALID_PROPERTY", "Script event IDs cannot be changed");
            }
            event = mergeJson(selected, input.patch);
            events[index] = event;
        }
        else if (input.action === "delete") {
            event = selected;
            events.splice(index, 1);
        }
        else {
            event = selected;
            events.splice(index, 1);
            index = insertionIndex(events, input);
            events.splice(index, 0, event);
        }
    }
    validateEventTree(cloned, options);
    const explicitlyPatchedCoordinates = new Map();
    if (input.action === "update" && input.eventId && isObject(input.patch?.args)) {
        const coordinates = new Set();
        for (const coordinate of ["x", "y"]) {
            if (Object.prototype.hasOwnProperty.call(input.patch.args, coordinate))
                coordinates.add(coordinate);
        }
        if (coordinates.size > 0)
            explicitlyPatchedCoordinates.set(input.eventId, coordinates);
    }
    const invalid = findInvalidNativeScriptEvent(cloned, resource, { explicitlyPatchedCoordinates });
    if (invalid)
        throw new GameStudioProjectError("INVALID_EVENT", invalid.message);
    return { resource: cloned, event, action: input.action, index, scriptKey };
}
async function locateResource(projectPath, target, access, existingInventory) {
    if (!target || typeof target.sceneId !== "string" || !target.sceneId) {
        throw new GameStudioProjectError("INVALID_INPUT", "Script targets require a sceneId");
    }
    if (target.actorId && target.triggerId) {
        throw new GameStudioProjectError("INVALID_INPUT", "Specify either actorId or triggerId, not both");
    }
    if (access)
        await access.ensureFresh();
    const inventory = access ? undefined : existingInventory ?? await inventoryProject(projectPath);
    const projectRoot = access?.projectRoot ?? inventory.projectRoot;
    const selectedProjectPath = access?.projectPath ?? inventory.projectPath;
    const format = access ? access.projectInspection().format : inventory.format;
    const scene = access ? access.scene(target.sceneId) : inventory.scenes.find((candidate) => candidate.id === target.sceneId);
    if (!scene)
        throw new GameStudioProjectError("SCENE_NOT_FOUND", `No scene with id ${target.sceneId} exists`);
    let selected = scene;
    if (target.actorId) {
        const actor = access ? access.actor(scene.id, target.actorId) : scene.actors.find((candidate) => candidate.id === target.actorId);
        if (!actor)
            throw new GameStudioProjectError("ACTOR_NOT_FOUND", `No actor with id ${target.actorId} exists in scene ${scene.id}`);
        selected = actor;
    }
    if (target.triggerId) {
        const trigger = access ? access.trigger(scene.id, target.triggerId) : scene.triggers.find((candidate) => candidate.id === target.triggerId);
        if (!trigger)
            throw new GameStudioProjectError("TRIGGER_NOT_FOUND", `No trigger with id ${target.triggerId} exists in scene ${scene.id}`);
        selected = trigger;
    }
    const filename = path.join(projectRoot, selected.resourcePath);
    return {
        projectPath: selectedProjectPath,
        projectRoot,
        format,
        ...(inventory ? { inventory } : {}),
        scene,
        selected,
        filename,
    };
}
async function readSelectedResource(context, target, owner) {
    const { format, scene, selected } = context;
    if (format === "distributed") {
        return { ...context, resource: owner };
    }
    const descriptor = owner;
    const existingScene = (Array.isArray(descriptor.scenes) ? descriptor.scenes : []).find((candidate) => isObject(candidate) && candidate.id === scene.id);
    if (!isObject(existingScene))
        throw new GameStudioProjectError("SCENE_NOT_FOUND", `Scene ${scene.id} disappeared during script selection`);
    let resource = existingScene;
    if (target.actorId || target.triggerId) {
        const collection = existingScene[target.actorId ? "actors" : "triggers"];
        const matching = (Array.isArray(collection) ? collection : []).find((candidate) => isObject(candidate) && candidate.id === selected.id);
        if (!isObject(matching))
            throw new GameStudioProjectError("RESOURCE_NOT_FOUND", `Script owner ${selected.id} disappeared during selection`);
        resource = matching;
    }
    return { ...context, resource, descriptor };
}
async function selectResource(projectPath, target, access, existingInventory) {
    const location = await locateResource(projectPath, target, access, existingInventory);
    const loaded = await readProjectJsonWithRevision(location.projectRoot, location.filename);
    return { ...await readSelectedResource(location, target, loaded.json), revision: loaded.revision };
}
async function assertRevision(root, filename, expected, actual) {
    if (expected !== undefined && (actual ?? await resourceRevision(root, filename)) !== expected) {
        throw new GameStudioProjectError("STALE_RESOURCE", "The target resource changed before the requested script edit", filename);
    }
}
export async function inspectScript(projectPath, input, access) {
    const selection = await selectResource(projectPath, input.target, access);
    let events = structuredClone(scriptEvents(selection.resource, input.target));
    if (input.eventId !== undefined) {
        const event = events.find((candidate) => candidate.id === input.eventId);
        if (!event)
            throw new GameStudioProjectError("EVENT_NOT_FOUND", `No event with id ${input.eventId} exists in the selected branch`);
        events = [event];
    }
    return {
        projectPath: selection.projectPath,
        projectRoot: selection.projectRoot,
        resourcePath: selection.selected.resourcePath,
        sceneId: selection.scene.id,
        ...(input.target.actorId ? { actorId: input.target.actorId } : {}),
        ...(input.target.triggerId ? { triggerId: input.target.triggerId } : {}),
        scriptKey: resolveScriptKey(input.target),
        branchPath: structuredClone(input.target.branchPath ?? []),
        events,
        eventCount: events.length,
        revision: selection.revision,
    };
}
async function editSelectedScript(projectPath, input, access, inventory) {
    const location = await locateResource(projectPath, input.target, access, inventory);
    return withProjectResourceWriteLock(location.projectRoot, location.filename, async () => {
        const loaded = await readProjectJsonWithRevision(location.projectRoot, location.filename);
        const beforeRevision = loaded.revision;
        await assertRevision(location.projectRoot, location.filename, input.expectedRevision, beforeRevision);
        const selection = await readSelectedResource(location, input.target, loaded.json);
        const applied = applyScriptEdit(selection.resource, input, {
            ...(access
                ? { sceneExists: (sceneId) => access.scene(sceneId) !== undefined }
                : { knownSceneIds: new Set(selection.inventory.scenes.map((scene) => scene.id)) }),
        });
        let updated = applied.resource;
        if (selection.format === "legacy") {
            const descriptor = selection.descriptor;
            if (!descriptor)
                throw new GameStudioProjectError("INVALID_RESOURCE", "Legacy project descriptor is unavailable");
            const scenes = Array.isArray(descriptor.scenes) ? descriptor.scenes : [];
            const replaced = scenes.map((candidate) => {
                if (!isObject(candidate) || candidate.id !== selection.scene.id)
                    return candidate;
                if (!input.target.actorId && !input.target.triggerId)
                    return applied.resource;
                const key = input.target.actorId ? "actors" : "triggers";
                const collection = Array.isArray(candidate[key]) ? candidate[key] : [];
                return { ...candidate, [key]: collection.map((owner) => isObject(owner) && owner.id === selection.selected.id ? applied.resource : owner) };
            });
            updated = { ...descriptor, scenes: replaced };
        }
        await assertRevision(selection.projectRoot, selection.filename, beforeRevision);
        await writeProjectJsonAtomic(selection.projectRoot, selection.filename, updated);
        if (access)
            await access.noteCommittedPaths([selection.selected.resourcePath]);
        return {
            projectPath: selection.projectPath,
            projectRoot: selection.projectRoot,
            resourcePath: selection.selected.resourcePath,
            sceneId: selection.scene.id,
            ...(input.target.actorId ? { actorId: input.target.actorId } : {}),
            ...(input.target.triggerId ? { triggerId: input.target.triggerId } : {}),
            event: applied.event,
            action: applied.action,
            index: applied.index,
            scriptKey: applied.scriptKey,
            revision: await resourceRevision(selection.projectRoot, selection.filename),
        };
    });
}
export async function editScript(projectPath, input, access) {
    return editSelectedScript(projectPath, input, access);
}
export function buildSceneTransitionEvent(input, destination) {
    if (destination.id !== input.destinationSceneId) {
        throw new GameStudioProjectError("SCENE_NOT_FOUND", `No destination scene with id ${input.destinationSceneId} exists`);
    }
    for (const key of ["x", "y"]) {
        const value = input[key];
        const boundary = destination[key === "x" ? "width" : "height"];
        if (!Number.isInteger(value) || value < 0 || typeof boundary !== "number" || value >= boundary) {
            throw new GameStudioProjectError("INVALID_INPUT", `Transition ${key} must fit within the destination scene`);
        }
    }
    const direction = input.direction ?? "down";
    if (!VALID_DIRECTIONS.has(direction)) {
        throw new GameStudioProjectError("INVALID_INPUT", "Transition direction must be up, down, left, or right");
    }
    const fadeSpeed = input.fadeSpeed === undefined ? "2" : String(input.fadeSpeed);
    if (!/^\d+$/.test(fadeSpeed)) {
        throw new GameStudioProjectError("INVALID_INPUT", "Transition fadeSpeed must be a nonnegative integer");
    }
    return {
        command: "EVENT_SWITCH_SCENE",
        args: {
            sceneId: input.destinationSceneId,
            x: { type: "number", value: input.x },
            y: { type: "number", value: input.y },
            direction,
            fadeSpeed,
        },
    };
}
export async function createSceneTransition(projectPath, input, access) {
    if (access)
        await access.ensureFresh();
    const inventory = access ? undefined : await inventoryProject(projectPath);
    const destination = access
        ? access.scene(input.destinationSceneId)
        : inventory.scenes.find((scene) => scene.id === input.destinationSceneId);
    if (!destination)
        throw new GameStudioProjectError("SCENE_NOT_FOUND", `No destination scene with id ${input.destinationSceneId} exists`);
    return editSelectedScript(projectPath, {
        action: "insert",
        target: input.target,
        event: buildSceneTransitionEvent(input, destination),
        ...(input.index === undefined ? {} : { index: input.index }),
        ...(input.expectedRevision === undefined ? {} : { expectedRevision: input.expectedRevision }),
    }, access, inventory);
}
//# sourceMappingURL=game-scripts.js.map

SHA-256: b4ce3f25aa917e7ed0d3f1495ec22fff7f927e8d6c22ef72db42c99f7daabfd2