Newer
Older
rpg / packages / engine / src / dialogue / __tests__ / DialogueRunner.test.ts
import { describe, it, expect } from 'vitest';
import { DialogueRunner, type DialogueView } from '../DialogueRunner';
import { GameState } from '../../core/GameState';

/** Тестовый view: запоминает показанные реплики. */
interface ShownLine {
    speaker?: string;
    text: string;
    choices: { index: number; text: string }[];
}

function makeView(): { view: DialogueView; shown: ShownLine[]; hidden: () => number } {
    const shown: ShownLine[] = [];
    let hideCount = 0;
    const view: DialogueView = {
        show: (n) => shown.push(n),
        hide: () => hideCount++
    };
    return { view, shown, hidden: () => hideCount };
}

describe('DialogueRunner', () => {
    it('линейный диалог: advance до конца, onFinish вызывается', () => {
        const state = new GameState();
        const { view, shown, hidden } = makeView();
        const dr = new DialogueRunner(state, view);
        let finished = 0;
        dr.onFinish = () => finished++;

        dr.start({
            start: 'a',
            nodes: {
                a: { text: 'Привет.', next: 'b' },
                b: { text: 'Пока.', next: 'c' },
                c: { text: '...' }
            }
        });

        expect(dr.active).toBe(true);
        expect(shown[0]?.text).toBe('Привет.');
        dr.advance();
        expect(shown[1]?.text).toBe('Пока.');
        dr.advance();
        expect(shown[2]?.text).toBe('...');
        dr.advance();
        expect(finished).toBe(1);
        expect(dr.active).toBe(false);
        expect(hidden()).toBe(1);
    });

    it('узел без next — конец диалога', () => {
        const state = new GameState();
        const dr = new DialogueRunner(state, makeView().view);
        let finished = 0;
        dr.onFinish = () => finished++;
        dr.start({ start: 'a', nodes: { a: { text: 'Конец' } } });
        dr.advance();
        expect(finished).toBe(1);
    });

    it('выборы: показываются только прошедшие условия, pick уходит по next', () => {
        const state = new GameState();
        const { view, shown } = makeView();
        const dr = new DialogueRunner(state, view);

        dr.start({
            start: 'q',
            nodes: {
                q: {
                    speaker: 'Ирвин',
                    text: 'Поможешь?',
                    choices: [
                        { text: 'Да', next: 'yes', setFlags: ['quest_taken'] },
                        { text: 'Нет', next: 'no' },
                        { text: 'Секрет', when: ['knows_secret'], next: 'secret' }
                    ]
                },
                yes: { text: 'Спасибо!' },
                no: { text: 'Жаль.' },
                secret: { text: 'Ты знаешь тайну.' }
            }
        });

        expect(shown[0]?.speaker).toBe('Ирвин');
        expect(shown[0]?.choices.map((c) => c.text)).toEqual(['Да', 'Нет']);

        dr.pick(0);
        expect(state.hasFlag('quest_taken')).toBe(true);
        // advance показывает следующий узел после применения эффекта выбора
        dr.advance();
        expect(shown[1]?.text).toBe('Спасибо!');
    });

    it('advance во время выбора игнорируется', () => {
        const state = new GameState();
        const { view, shown } = makeView();
        const dr = new DialogueRunner(state, view);
        dr.start({
            start: 'q',
            nodes: { q: { text: 'Выбор?', choices: [{ text: 'A', next: 'a2' }], next: 'x' }, a2: { text: 'A' }, x: { text: 'X' } }
        });
        dr.advance();
        expect(shown.length).toBe(1); // реплика 'X' не показалась
    });

    it('узел-действие без текста применяет флаги и переходит дальше', () => {
        const state = new GameState();
        const { view, shown } = makeView();
        const dr = new DialogueRunner(state, view);
        dr.start({
            start: 'give',
            nodes: {
                give: { setFlags: ['reward_given'], next: 'msg' },
                msg: { text: 'Готово.' }
            }
        });
        expect(shown[0]?.text).toBe('Готово.');
        expect(state.hasFlag('reward_given')).toBe(true);
    });

    it('when/whenNot фильтруют узлы: диалог идёт по другой ветке', () => {
        const state = new GameState();
        state.setFlag('was_here');
        const { view, shown } = makeView();
        const dr = new DialogueRunner(state, view);
        dr.start({
            start: 'check',
            nodes: {
                check: { when: ['was_here'], text: 'Опять ты.', next: 'end' },
                first: { text: 'Первый раз.' },
                end: { text: 'Конец.' }
            }
        });
        // check прошёл условие
        expect(shown[0]?.text).toBe('Опять ты.');
    });

    it('когда узел не прошёл условие, диалог уходит по его next', () => {
        const state = new GameState();
        const { view, shown } = makeView();
        const dr = new DialogueRunner(state, view);
        dr.start({
            start: 'check',
            nodes: {
                check: { when: ['vip'], text: 'Ветка VIP.', next: 'end' },
                end: { text: 'Финал.' }
            }
        });
        // check не прошёл (нет vip) — но у check есть next: показывается end
        expect(shown[0]?.text).toBe('Финал.');
        expect(dr.active).toBe(true);
    });

    it('узел-действие с whenNot переходит по next при проходе условия', () => {
        const state = new GameState();
        const { view, shown } = makeView();
        const dr = new DialogueRunner(state, view);
        dr.start({
            start: 'hub',
            nodes: {
                hub: { whenNot: ['vip'], next: 'normal' },
                normal: { text: 'Обычная ветка.', next: 'end' },
                end: { text: 'Финал.' }
            }
        });
        // hub без текста: условие прошло, эффектов нет, next -> normal
        expect(shown[0]?.text).toBe('Обычная ветка.');
    });

    it('whenVar фильтрует по числовой переменной', () => {
        const state = new GameState();
        state.setVar('gold', 5);
        const { view, shown } = makeView();
        const dr = new DialogueRunner(state, view);
        dr.start({
            start: 'q',
            nodes: {
                q: {
                    text: 'Торговец:',
                    choices: [
                        { text: 'Купить (10 зол.)', whenVar: { key: 'gold', op: 'ge', value: 10 }, next: 'buy' },
                        { text: 'Уйти', next: 'bye' }
                    ]
                },
                buy: { text: 'Куплено.' },
                bye: { text: 'Пока.' }
            }
        });
        expect(shown[0]?.choices.map((c) => c.text)).toEqual(['Уйти']);
    });

    it('setVars в выборах меняет состояние', () => {
        const state = new GameState();
        const dr = new DialogueRunner(state, makeView().view);
        dr.start({
            start: 'q',
            nodes: {
                q: { text: 'Сколько?', choices: [{ text: 'Дать 30', setVars: { gold: 30 }, next: 'ok' }] },
                ok: { text: 'Ок.' }
            }
        });
        dr.pick(0);
        expect(state.getNumber('gold')).toBe(30);
    });

    it('цикл из узлов без текста не зависает — защита по MAX_STEPS', () => {
        const state = new GameState();
        const dr = new DialogueRunner(state, makeView().view);
        dr.start({
            start: 'a',
            nodes: {
                a: { next: 'b' },
                b: { next: 'a' }
            }
        });
        expect(dr.active).toBe(false); // безопасно завершился
    });
});