Newer
Older
rpg / apps / game / src / data / dialogueRules.ts
import type { DialogueChoice, DialogueGraph, DialogueNode, Invariant } from '@rpg/engine';

/**
 * Чистые правила диалоговых графов: реестры — параметры (не импорты), поэтому
 * один и тот же checkGraph работает в validate.ts (юнит-тесты, agent:invariants),
 * в CLI dry-run и в сервере визуального редактора. Истина одна.
 */

export interface GraphRefs {
    /** Допустимые id: флаги, вары, предметы, custom-эффекты, диалоги, строки. */
    flags: ReadonlySet<string>;
    vars: ReadonlySet<string>;
    items: ReadonlySet<string>;
    customs: ReadonlySet<string>;
    strings: ReadonlySet<string>;
    /** Куда записать упоминания (для «мёртвых» сущностей реестра) — опционально. */
    usedFlags?: Set<string>;
    usedVars?: Set<string>;
}

/** Результат обхода графа: кто достижим и где подозрительные места. */
export interface Reachability {
    /** Достижимые от start узлы (BFS по next/choices). */
    reachable: string[];
    /** Узлы, в которые нет ни одной ссылки. */
    orphans: string[];
    /** Циклы из узлов без текста и без выборов (раннер обрывает по MAX_STEPS). */
    textlessCycles: string[][];
    /** Достижимые концы без реплики (диалог закончится молча). */
    silentEnds: string[];
}

/** Ссылки узла/выбора, которые надо сверить с реестрами. */
function refsOf(n: DialogueNode | DialogueChoice): {
    flags: string[];
    vars: string[];
    items: string[];
    doOps: NonNullable<DialogueNode['do']>;
    textKey?: string;
} {
    return {
        flags: [...(n.when ?? []), ...(n.whenNot ?? []), ...(n.setFlags ?? []), ...(n.clearFlags ?? [])],
        vars: [
            ...(n.whenVar ? [n.whenVar.key] : []),
            ...(n.whenVars ?? []).map((c) => c.key),
            ...Object.keys(n.setVars ?? {})
        ],
        items: [...(n.hasItem ?? [])],
        doOps: n.do ?? [],
        textKey: n.textKey
    };
}

function checkSpot(
    out: Invariant[],
    where: string,
    refs: GraphRefs,
    spot: string,
    n: DialogueNode | DialogueChoice,
    next: string | undefined,
    graph: DialogueGraph
): void {
    const r = refsOf(n);
    for (const f of r.flags) {
        if (!refs.flags.has(f)) out.push({ id: 'flag-unknown', severity: 'error', message: `${spot}: флаг «${f}» вне реестра FLAGS`, where });
        refs.usedFlags?.add(f);
    }
    for (const v of r.vars) {
        if (!refs.vars.has(v)) out.push({ id: 'var-unknown', severity: 'error', message: `${spot}: вар «${v}» вне реестра VARS`, where });
        refs.usedVars?.add(v);
    }
    for (const it of r.items) {
        if (!refs.items.has(it)) out.push({ id: 'item-unknown', severity: 'error', message: `${spot}: предмет «${it}» вне реестра ITEMS`, where });
    }
    for (const op of r.doOps) {
        if ((op.kind === 'giveItem' || op.kind === 'takeItem') && (op.id === undefined || !refs.items.has(op.id))) {
            out.push({ id: 'do-item-unknown', severity: 'error', message: `${spot}: do[].${op.kind} — предмет «${op.id}» вне реестра ITEMS`, where });
        }
        if (op.kind === 'custom' && (op.id === undefined || !refs.customs.has(op.id))) {
            out.push({ id: 'do-custom-unknown', severity: 'error', message: `${spot}: do[].custom — имя «${op.id}» вне реестра DIALOGUE_CUSTOM`, where });
        }
    }
    if (r.textKey !== undefined && !refs.strings.has(r.textKey)) {
        out.push({ id: 'string-unknown', severity: 'error', message: `${spot}: textKey «${r.textKey}» вне реестра строк`, where });
    }
    if (next !== undefined && !graph.nodes[next]) {
        out.push({ id: 'dialogue-next', severity: 'error', message: `${spot}: next «${next}» не существует`, where });
    }
}

/** Все правила одного графа: ссылки, next, сироты, циклы, тихие концы. */
export function checkGraph(id: string, graph: DialogueGraph, refs: GraphRefs): Invariant[] {
    const out: Invariant[] = [];
    const where = `data/dialogue/${id}`;
    if (!graph.nodes[graph.start]) {
        out.push({ id: 'dialogue-start', severity: 'error', message: `start «${graph.start}» не существует`, where });
    }
    for (const [nid, node] of Object.entries(graph.nodes)) {
        checkSpot(out, where, refs, `узел «${nid}»`, node, node.next, graph);
        (node.choices ?? []).forEach((c, i) => {
            checkSpot(out, where, refs, `узел «${nid}», выбор#${i}`, c, c.next, graph);
        });
    }
    const a = analyzeGraph(graph);
    for (const o of a.orphans) {
        out.push({ id: 'dialogue-orphan', severity: 'warn', message: `узел «${o}» недостижим`, where });
    }
    for (const cycle of a.textlessCycles) {
        out.push({ id: 'textless-cycle', severity: 'error', message: `цикл без текста: ${cycle.join(' → ')}`, where });
    }
    for (const end of a.silentEnds) {
        out.push({ id: 'silent-end', severity: 'warn', message: `диалог может кончиться молча в узле «${end}»`, where });
    }
    return out;
}

/** Исходящие рёбра узла (next + next выборов). */
function edgesOf(graph: DialogueGraph, id: string): string[] {
    const n = graph.nodes[id];
    if (!n) return [];
    const out = n.next !== undefined ? [n.next] : [];
    for (const c of n.choices ?? []) {
        if (c.next !== undefined) out.push(c.next);
    }
    return out;
}

/** Обход графа от start: достижимость, сироты, текстовые циклы, тихие концы. */
export function analyzeGraph(graph: DialogueGraph): Reachability {
    const reachable = new Set<string>();
    if (graph.nodes[graph.start]) {
        const queue = [graph.start];
        while (queue.length > 0) {
            const id = queue.shift()!;
            if (reachable.has(id)) continue;
            reachable.add(id);
            for (const next of edgesOf(graph, id)) {
                if (graph.nodes[next] && !reachable.has(next)) queue.push(next);
            }
        }
    }
    const orphans = Object.keys(graph.nodes).filter((id) => !reachable.has(id));

    // Циклы без текста: DFS с окраской; цикл собираем из стека.
    const color = new Map<string, 1 | 2>(); // 1 — в стеке, 2 — готово
    const stack: string[] = [];
    const cycles: string[][] = [];
    const visit = (id: string): void => {
        color.set(id, 1);
        stack.push(id);
        for (const next of edgesOf(graph, id)) {
            if (!graph.nodes[next]) continue;
            const c = color.get(next);
            if (c === 1) {
                const cycle = stack.slice(stack.indexOf(next));
                const textless = cycle.every((cid) => {
                    const n = graph.nodes[cid]!;
                    return n.text === undefined && n.textKey === undefined && (n.choices?.length ?? 0) === 0;
                });
                if (textless) cycles.push(cycle);
            } else if (c === undefined) {
                visit(next);
            }
        }
        stack.pop();
        color.set(id, 2);
    };
    for (const id of reachable) {
        if (!color.has(id)) visit(id);
    }

    const silentEnds = [...reachable].filter((id) => {
        const n = graph.nodes[id]!;
        return n.next === undefined && (n.choices?.length ?? 0) === 0 && n.text === undefined && n.textKey === undefined;
    });

    return { reachable: [...reachable], orphans, textlessCycles: cycles, silentEnds };
}