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');
});
});