Newer
Older
rpg / apps / game / tools / dialogues / dry-run.ts
/**
 * CLI dry-run диалогов: прогон графов через DialogueRunner на реальном
 * GameState (без Pixi) + структурный анализ. Печатает дерево «узел →
 * выборы → куда», сироты, циклы, тихие концы и битые ссылки.
 *
 * Запуск: npm run dialogues:dry [-- <id>]
 * (vite-node — чтобы работал TS/JSON-импорт из src.)
 */
import { GameState, DialogueRunner } from '@rpg/engine';
import { DIALOGUES } from '../../src/data/dialogues';
import { analyzeGraph, checkGraph, type GraphRefs } from '../../src/data/dialogueRules';
import { FLAGS, VARS } from '../../src/data/ids';
import { ITEMS } from '../../src/data/items';
import { DIALOGUE_CUSTOM } from '../../src/data/effects';

/** Реестры игры для checkGraph (те же множества, что в validate.ts). */
function refs(): GraphRefs {
    return {
        flags: new Set(Object.keys(FLAGS)),
        vars: new Set(Object.keys(VARS)),
        items: new Set(Object.keys(ITEMS)),
        customs: new Set(Object.keys(DIALOGUE_CUSTOM)),
        strings: new Set()
    };
}

/** Пресеты состояния: в каких условиях гоняем каждый граф. */
const PRESETS: { name: string; setup: (s: GameState) => void }[] = [
    { name: 'fresh', setup: () => {} },
    {
        name: 'quest-taken+flowers',
        setup: (s) => {
            s.setFlag('quest_bells_taken');
            s.setFlag('met_elder');
            s.setVar('flowers', 3);
        }
    },
    {
        name: 'quest-done',
        setup: (s) => {
            s.setFlag('quest_bells_taken');
            s.setFlag('met_elder');
            s.setFlag('quest_bells_done');
        }
    }
];

/** Дерево реплик: что покажет раннер и куда можно пойти — см. main(). */

const main = (): void => {
    const only = process.argv[2];
    let errors = 0;
    for (const [id, graph] of Object.entries(DIALOGUES)) {
        if (only && id !== only) continue;
        console.log(`\n=== ${id} (start: ${graph.start}) ===`);
        const a = analyzeGraph(graph);
        const bad = checkGraph(id, graph, refs()).filter((i) => i.severity === 'error');
        errors += bad.length;
        for (const inv of bad) console.log(`  [${inv.severity}] ${inv.id}: ${inv.message}`);
        for (const o of a.orphans) console.log(`  [warn] сирота: «${o}»`);
        for (const cycle of a.textlessCycles) console.log(`  [error] цикл без текста: ${cycle.join(' → ')}`);
        for (const end of a.silentEnds) console.log(`  [warn] тихий конец: «${end}»`);

        // Прогон на каждом пресете: какие реплики реально покажутся.
        for (const preset of PRESETS) {
            const state = new GameState();
            preset.setup(state);
            const runner = new DialogueRunner(state);
            const shown: string[] = [];
            runner.setView({
                show: (n) => shown.push(`${n.speaker ? n.speaker + ': ' : ''}${n.text}` +
                    (n.choices.length > 0 ? ` [${n.choices.map((c) => c.text).join(' | ')}]` : '')),
                hide: () => {}
            });
            runner.start(graph);
            while (runner.active && !runner.waitingForChoice) runner.advance();
            console.log(`  [${preset.name}] ${shown.join(' → ') || '(молча)'}`);
        }
    }
    if (errors > 0) process.exitCode = 1;
};

main();