import { describe, it, expect } from 'vitest';
import { DialogueRunner, evalConditions, 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); // безопасно завершился
});
it('whenVars: несколько условий по переменным, AND', () => {
const state = new GameState();
state.setVar('gold', 5);
state.setVar('trust', 2);
const { view, shown } = makeView();
const dr = new DialogueRunner(state, view);
dr.start({
start: 'q',
nodes: {
q: {
text: 'Торговец:',
choices: [
{
text: 'Дорогой товар',
whenVars: [
{ key: 'gold', op: 'ge', value: 10 },
{ key: 'trust', op: 'ge', value: 1 }
],
next: 'buy'
},
{ text: 'Уйти', next: 'bye' }
]
},
buy: { text: 'Куплено.' },
bye: { text: 'Пока.' }
}
});
// gold < 10 — первая ветка скрыта, хотя trust проходит
expect(shown[0]?.choices.map((c) => c.text)).toEqual(['Уйти']);
state.setVar('gold', 50);
dr.start({
start: 'q',
nodes: {
q: {
text: 'Торговец:',
choices: [
{
text: 'Дорогой товар',
whenVars: [
{ key: 'gold', op: 'ge', value: 10 },
{ key: 'trust', op: 'ge', value: 1 }
],
next: 'buy'
},
{ text: 'Уйти', next: 'bye' }
]
},
buy: { text: 'Куплено.' },
bye: { text: 'Пока.' }
}
});
expect(shown[1]?.choices.map((c) => c.text)).toEqual(['Дорогой товар', 'Уйти']);
});
it('hasItem резолвится через DialogueWorld; без world — ложно', () => {
const state = new GameState();
const world = { hasItem: (id: string) => id === 'cloth' };
const { view, shown } = makeView();
const dr = new DialogueRunner(state, view, { world });
dr.start({
start: 'q',
nodes: {
q: {
text: 'Торговец:',
choices: [
{ text: 'Продать ткань', hasItem: ['cloth'], next: 'sell' },
{ text: 'Продать соль', hasItem: ['salt'], next: 'nope' },
{ text: 'Уйти', next: 'bye' }
]
},
sell: { text: 'Продано.' },
nope: { text: 'Нет соли.' },
bye: { text: 'Пока.' }
}
});
expect(shown[0]?.choices.map((c) => c.text)).toEqual(['Продать ткань', 'Уйти']);
// узел-выбор без текста и без world: hasItem-варианты скрыты — конец диалога
const dr2 = new DialogueRunner(state, makeView().view);
dr2.start({
start: 'q',
nodes: {
q: { choices: [{ text: 'Продать ткань', hasItem: ['cloth'], next: 'sell' }] },
sell: { text: 'Продано.' }
}
});
expect(dr2.active).toBe(false); // нет доступных вариантов — конец
});
it('evalConditions работает без рантайма на чистом GameState', () => {
const state = new GameState();
state.setFlag('met_elder');
state.setVar('flowers', 3);
const world = { hasItem: (id: string) => id === 'cloth' };
expect(evalConditions({ when: ['met_elder'] }, state)).toBe(true);
expect(evalConditions({ when: ['met_elder'], whenNot: ['quest_done'] }, state)).toBe(true);
expect(evalConditions({ when: ['quest_done'] }, state)).toBe(false);
expect(
evalConditions(
{ whenVars: [{ key: 'flowers', op: 'ge', value: 3 }] },
state,
world
)
).toBe(true);
expect(
evalConditions(
{ whenVars: [{ key: 'flowers', op: 'ge', value: 4 }] },
state
)
).toBe(false);
expect(evalConditions({ hasItem: ['cloth'] }, state, world)).toBe(true);
expect(evalConditions({ hasItem: ['cloth'] }, state)).toBe(false); // нет world
expect(evalConditions({}, state)).toBe(true); // пустые условия — истина
});
it('do[] эмитится через onEffect в порядке следования, у узла и у выбора', () => {
const state = new GameState();
const { view } = makeView();
const dr = new DialogueRunner(state, view);
const emitted: { kind: string; at: string; choice?: number }[] = [];
dr.onEffect = (op, at) => emitted.push({ kind: op.kind, at: at.nodeId, choice: at.choice });
dr.start({
start: 'hub',
nodes: {
hub: {
do: [{ kind: 'sound', id: 'bell' }],
text: 'Возьми?',
choices: [
{ text: 'Да', next: 'ok', do: [{ kind: 'giveItem', id: 'cloth' }, { kind: 'toast', text: 'Ткань' }] }
]
},
ok: { text: 'Держи.', do: [{ kind: 'custom', id: 'plant' }] }
}
});
// эффекты узла при входе
expect(emitted).toEqual([{ kind: 'sound', at: 'hub' }]);
dr.pick(0);
// сначала эффекты выбора, потом эффекты узла ok
expect(emitted.map((e) => e.kind)).toEqual(['sound', 'giveItem', 'toast', 'custom']);
expect(emitted[3]).toEqual({ kind: 'custom', at: 'ok' });
expect(emitted[1].choice).toBe(0);
});
it('do[] без подписчика и с неизвестным kind не падают', () => {
const state = new GameState();
const dr = new DialogueRunner(state, makeView().view);
dr.start({
start: 'a',
nodes: { a: { text: 'Тишина.', do: [{ kind: 'custom' as const, id: 'unknown_thing' }] } }
});
dr.advance();
expect(dr.active).toBe(false);
expect(state.hasFlag('nothing')).toBe(false);
});
it('path/result: путь показанных узлов и выборы; onFinish получает результат', () => {
const state = new GameState();
const { view } = makeView();
const dr = new DialogueRunner(state, view);
let finishedResult: unknown = null;
dr.onFinish = (_graph, result) => {
finishedResult = result;
};
dr.start({
start: 'q',
nodes: {
q: { text: 'Вопрос?', next: 'mid' },
mid: { text: 'Выбирай.', choices: [{ text: 'Ок', next: 'end' }, { text: 'Нет' }] },
end: { text: 'Конец.' }
}
});
expect(dr.path).toEqual(['q']);
dr.advance();
dr.pick(0);
dr.advance();
expect(dr.active).toBe(false);
const expected = {
lastNodeId: 'end',
path: ['q', 'mid', 'end'],
picks: [{ nodeId: 'mid', index: 0, text: 'Ок' }]
};
expect(dr.result).toEqual(expected);
expect(finishedResult).toEqual(expected);
// result переживает новый запуск и сбрасывается
dr.start({ start: 'a', nodes: { a: { text: 'Заново' } } });
expect(dr.result).toBeNull();
expect(dr.path).toEqual(['a']);
});
});