Newer
Older
rpg / v2 / packages / engine / src / dialogue / __tests__ / runner.test.ts
import { describe, it, expect } from 'vitest';
import { DialogueRunner, type DialogueView } from '../runner';
import type { DialogueGraph, DialogueEffectOp, DialogueState } from '../graph';

/** Состояние-заглушка + собранные эффекты do[] для проверок. */
function makeState(): DialogueState & { flags: Set<string>; vars: Map<string, number | string | boolean> } {
    const flags = new Set<string>();
    const vars = new Map<string, number | string | boolean>();
    return {
        flags,
        vars,
        hasFlag: (f) => flags.has(f),
        setFlag: (f) => flags.add(f),
        clearFlag: (f) => flags.delete(f),
        getVar: (k) => vars.get(k),
        setVar: (k, v) => vars.set(k, v),
    };
}

/** View-заглушка: пишет показы реплик в журнал. */
function makeView(): DialogueView & { shown: { speaker?: string; text: string; choices: string[] }[]; hidden: number } {
    const v = { shown: [], hidden: 0 } as { shown: { speaker?: string; text: string; choices: string[] }[]; hidden: number };
    const view: DialogueView = {
        show: (n) => v.shown.push({ speaker: n.speaker, text: n.text, choices: n.choices.map((c) => c.text) }),
        hide: () => v.hidden++,
    };
    return Object.assign(v, view) as never;
}

describe('dialogue/runner — линейный проход', () => {
    const graph: DialogueGraph = {
        start: 'a',
        nodes: {
            a: { speaker: 'Страж', text: 'Стой, кто идёт?', next: 'b' },
            b: { text: 'Проходи.', next: 'c' },
            c: { end: true },
        },
    };

    it('старт показывает первый узел; advance ходит по next; конец — hide', () => {
        const st = makeState();
        const view = makeView();
        const r = new DialogueRunner(st, view);
        r.start(graph);
        expect(r.active).toBe(true);
        expect(r.nodeId).toBe('a');
        expect(view.shown).toEqual([{ speaker: 'Страж', text: 'Стой, кто идёт?', choices: [] }]);
        r.advance();
        expect(r.nodeId).toBe('b');
        r.advance(); // у c нет next → завершение
        expect(r.active).toBe(false);
        expect(view.hidden).toBe(1);
        expect(r.result).toEqual({ lastNodeId: 'c', path: ['a', 'b', 'c'], picks: [] });
    });

    it('advance на последнем узле ждёт игрока: второй advance уже не нужен, но безвреден', () => {
        const r = new DialogueRunner(makeState(), makeView());
        r.start(graph);
        r.advance();
        r.advance();
        r.advance(); // активен уже false — игнор
        expect(r.active).toBe(false);
        expect(r.result?.lastNodeId).toBe('c');
    });

    it('действие-узел без текста проваливается сам, не показываясь', () => {
        const g: DialogueGraph = {
            start: 'act',
            nodes: { act: { setFlags: ['met'], next: 't' }, t: { text: 'Привет', end: true } },
        };
        const st = makeState();
        const r = new DialogueRunner(st, makeView());
        r.start(g);
        expect(r.nodeId).toBe('t');
        expect(st.hasFlag('met')).toBe(true);
        expect(r.path).toEqual(['act', 't']);
    });
});

describe('dialogue/runner — выборы и условия', () => {
    const graph: DialogueGraph = {
        start: 'q',
        nodes: {
            q: {
                text: 'Помочь?',
                choices: [
                    { text: 'Да', next: 'thanks', setFlags: ['helped'] },
                    { text: 'Нет', setFlags: ['refused'] },
                ],
            },
            thanks: { text: 'Спасибо!', end: true },
        },
    };

    it('pick применяет эффекты выбора и идёт в next; выбор без next завершает', () => {
        const st = makeState();
        const view = makeView();
        const r = new DialogueRunner(st, view);
        r.start(graph);
        expect(r.waitingForChoice).toBe(true);
        expect(r.choices).toEqual([
            { index: 0, text: 'Да' },
            { index: 1, text: 'Нет' },
        ]);
        r.pick(0);
        expect(st.hasFlag('helped')).toBe(true);
        expect(r.nodeId).toBe('thanks');
        expect(r.path).toEqual(['q', 'thanks']);
        r.advance();
        expect(r.result?.picks).toEqual([{ nodeId: 'q', index: 0, text: 'Да' }]);
    });

    it('pick последнего варианта (без next) завершает диалог сразу', () => {
        const st = makeState();
        const r = new DialogueRunner(st, makeView());
        r.start(graph);
        r.pick(1);
        expect(st.hasFlag('refused')).toBe(true);
        expect(r.active).toBe(false);
        expect(r.result?.picks).toEqual([{ nodeId: 'q', index: 1, text: 'Нет' }]);
    });

    it('выбор, скрытый условиями, не показывается; index — позиция в узле', () => {
        const g: DialogueGraph = {
            start: 'q',
            nodes: {
                q: {
                    text: 'Что?',
                    choices: [
                        { text: 'Скрытый', when: ['secret'] },
                        { text: 'Видимый' },
                    ],
                },
            },
        };
        const r = new DialogueRunner(makeState(), makeView());
        r.start(g);
        expect(r.choices).toEqual([{ index: 1, text: 'Видимый' }]);
        r.pick(0);
        expect(r.active).toBe(false);
    });

    it('условия на узле: закрытый узел пропускается в next, без next — конец', () => {
        const g: DialogueGraph = {
            start: 'a',
            nodes: {
                a: { text: 'Раз', next: 'locked' },
                locked: { when: ['secret'], text: 'Тайна', next: 'c' },
                c: { text: 'Три', end: true },
            },
        };
        const r = new DialogueRunner(makeState(), makeView());
        r.start(g);
        expect(r.nodeId).toBe('a');
        r.advance(); // «locked» закрыт условием → прыгаем сразу в «c»
        expect(r.nodeId).toBe('c');
        expect(r.path).toEqual(['a', 'c']);
        r.advance();
        expect(r.result?.path).toEqual(['a', 'c']);
    });
});

describe('dialogue/runner — эффекты do[] и прочее', () => {
    it('onEffect эмитится для узлов и выборов с координатами', () => {
        const ops: DialogueEffectOp[] = [];
        const g: DialogueGraph = {
            start: 'a',
            nodes: {
                a: {
                    text: 'Держи',
                    do: [{ kind: 'giveItem', id: 'herb', count: 2 }],
                    choices: [{ text: 'Ок', do: [{ kind: 'toast', text: 'Получено' }] }],
                },
            },
        };
        const r = new DialogueRunner(makeState());
        r.onEffect = (op, at) => ops.push({ ...op }, { kind: 'custom', payload: { at: at.nodeId, choice: at.choice === undefined ? -1 : at.choice } });
        r.start(g);
        expect(ops).toEqual([
            { kind: 'giveItem', id: 'herb', count: 2 },
            { kind: 'custom', payload: { at: 'a', choice: -1 } },
        ]);
        r.pick(0);
        expect(ops[2]).toEqual({ kind: 'toast', text: 'Получено' });
        expect(ops[3]).toEqual({ kind: 'custom', payload: { at: 'a', choice: 0 } });
        expect(r.active).toBe(false);
    });

    it('setVars применяются и видны условиям дальше по графу', () => {
        const g: DialogueGraph = {
            start: 'a',
            nodes: {
                a: { text: 'Сколько?', next: 'b', setVars: { gold: 5 } },
                b: {
                    whenVar: { key: 'gold', op: 'ge', value: 5 },
                    text: 'Богат!',
                    next: 'poor',
                },
                poor: { text: 'Беден', end: true },
            },
        };
        const st = makeState();
        const r = new DialogueRunner(st, makeView());
        r.start(g);
        expect(r.nodeId).toBe('a'); // a показан, setVars уже применены
        expect(st.getVar('gold')).toBe(5);
        r.advance(); // условие b (gold >= 5) истинно → показан b
        expect(r.nodeId).toBe('b');
    });

    it('abort завершает и записывает результат; start повторно сбрасывает', () => {
        const r = new DialogueRunner(makeState(), makeView());
        const graph: DialogueGraph = { start: 'a', nodes: { a: { text: 'Хи', next: 'b' }, b: { text: 'Ха', end: true } } };
        r.start(graph);
        r.advance();
        r.abort();
        expect(r.active).toBe(false);
        expect(r.result?.lastNodeId).toBe('b');
        r.start(graph);
        expect(r.path).toEqual(['a']); // стартовый узел уже показан
        expect(r.result).toBeNull(); // прошлый прогон сброшен
        expect(r.nodeId).toBe('a');
    });
});