/**
* Рантайм обхода диалоговых графов (перенос схемы v1), отделённый от
* отрисовки: игра рисует реплику/варианты своим view и зовёт advance/pick.
* Узел с текстом ждёт игрока (advance → next, pick → вариант); узел без
* текста («действие») проваливается дальше сам. Состояние — DialogueState
* (флаги/переменные игры); игровые эффекты из do[] движок только эмитит
* (EffectSink), исполнение — игра.
*/
import {
evalConditions, hasText,
type DialogueGraph, type DialogueNode, type DialogueChoice,
type DialogueEffects, type DialogueResult, type DialogueState, type DialogueWorld, type DialogueEffectOp,
} from './graph';
/** Вид: игра рисует реплику и варианты своими средствами (DOM и т.п.). */
export interface DialogueView {
/** Показать реплику; choices пуст, если выбора нет. */
show(node: { speaker?: string; text: string; choices: { index: number; text: string }[] }): void;
/** Скрыть (диалог завершён). */
hide(): void;
}
const MAX_STEPS = 1000; // защита от цикла в графе без текста
export class DialogueRunner {
/** Вызывается по завершении: граф + как он был пройден. */
onFinish: ((graph: DialogueGraph, result: DialogueResult) => void) | null = null;
/** Игровые эффекты из do[]; at — где сработали: узел или выбор. */
onEffect: ((op: DialogueEffectOp, at: { nodeId: string; choice?: number }) => void) | null = null;
private graph: DialogueGraph | null = null;
private view: DialogueView | null = null;
private readonly state: DialogueState;
private readonly world?: DialogueWorld;
private shownChoices: { index: number; text: string }[] = [];
private currentId: string | null = null;
private pendingNext: string | undefined;
private awaitingChoice = false;
private walkedPath: string[] = [];
private madePicks: { nodeId: string; index: number; text: string }[] = [];
private walkedResult: DialogueResult | null = null;
constructor(state: DialogueState, view?: DialogueView, world?: DialogueWorld) {
this.state = state;
this.view = view ?? null;
this.world = world;
}
/** Подключить/заменить view (после пересоздания UI). */
setView(view: DialogueView): void {
this.view = view;
}
get active(): boolean {
return this.graph !== null;
}
// --- снапшоты для агентного моста/отладки ---
/** id текущего узла (или null — не активен/завершается). */
get nodeId(): string | null {
return this.currentId;
}
get node(): DialogueNode | null {
if (!this.graph || this.currentId === null) return null;
return this.graph.nodes[this.currentId] ?? null;
}
/** Показанные варианты (index — позиция в узле графа). */
get choices(): { index: number; text: string }[] {
return this.shownChoices;
}
/** Ожидается ли выбор варианта. */
get waitingForChoice(): boolean {
return this.awaitingChoice;
}
/** Показанные узлы по порядку (текущий прогон). */
get path(): string[] {
return this.walkedPath;
}
/** Итог последнего завершённого диалога (null — не завершался). */
get result(): DialogueResult | null {
return this.walkedResult;
}
/** Запустить диалог. */
start(graph: DialogueGraph): void {
this.graph = graph;
this.awaitingChoice = false;
this.pendingNext = undefined;
this.walkedPath = [];
this.madePicks = [];
this.walkedResult = null;
this.enterNode(graph.start);
}
/** «Дальше»: во время выбора варианта игнорируется. */
advance(): void {
if (!this.graph || this.awaitingChoice) return;
if (this.pendingNext === undefined) {
this.finish();
return;
}
this.enterNode(this.pendingNext);
}
/** Выбрать вариант ответа (индекс в показанном списке). */
pick(index: number): void {
if (!this.graph || !this.awaitingChoice) return;
const shown = this.shownChoices[index];
const choice = this.node?.choices?.[shown?.index ?? -1];
if (!shown || !choice) return;
const atNode = this.currentId;
this.awaitingChoice = false;
this.shownChoices = [];
if (atNode !== null) this.madePicks.push({ nodeId: atNode, index: shown.index, text: shown.text });
this.applyEffects(choice, atNode, shown.index);
if (choice.next === undefined) this.finish();
else this.enterNode(choice.next);
}
/** Завершить диалог досрочно (результат всё равно записывается). */
abort(): void {
if (this.graph) this.finish();
}
/** Вход в узел: условия → пропуск, эффекты, показ или провал в next. */
private enterNode(id: string): void {
if (!this.graph) return;
let cur = id;
for (let i = 0; i < MAX_STEPS; i++) {
const node = this.graph.nodes[cur];
if (!node) {
this.finish(); // битая ссылка — валидатор ловит заранее
return;
}
if (!evalConditions(node, this.state, this.world)) {
if (node.next === undefined) {
this.finish(); // условный узел закрыт и обхода нет
return;
}
cur = node.next;
continue;
}
this.applyEffects(node, cur);
this.walkedPath.push(cur);
if (!hasText(node)) {
if (node.next === undefined) {
this.finish(); // узел-действие без next — конец
return;
}
cur = node.next;
continue;
}
this.show(node);
return;
}
throw new Error('Диалог: превышен MAX_STEPS — цикл в графе без текста');
}
/** Показ узла: выборы фильтруются условиями; index — позиция в узле. */
private show(node: DialogueNode): void {
this.currentId = this.walkedPath[this.walkedPath.length - 1] ?? null;
this.pendingNext = node.next;
this.shownChoices = (node.choices ?? [])
.map((c: DialogueChoice, i: number) => ({ c, i }))
.filter(({ c }) => evalConditions(c, this.state, this.world))
.map(({ c, i }) => ({ index: i, text: c.text }));
this.view?.show({
speaker: node.speaker,
text: node.text ?? '',
choices: this.shownChoices,
});
if (this.shownChoices.length > 0) this.awaitingChoice = true;
}
private applyEffects(src: DialogueEffects, nodeId: string | null, choice?: number): void {
for (const f of src.setFlags ?? []) this.state.setFlag(f);
for (const f of src.clearFlags ?? []) this.state.clearFlag(f);
for (const [k, v] of Object.entries(src.setVars ?? {})) this.state.setVar(k, v);
for (const op of src.do ?? []) this.onEffect?.(op, { nodeId: nodeId ?? '', choice });
}
private finish(): void {
const graph = this.graph;
this.graph = null;
this.currentId = null;
this.awaitingChoice = false;
this.pendingNext = undefined;
this.shownChoices = [];
this.view?.hide();
if (!graph) return;
this.walkedResult = {
lastNodeId: this.walkedPath[this.walkedPath.length - 1] ?? null,
path: [...this.walkedPath],
picks: [...this.madePicks],
};
this.onFinish?.(graph, this.walkedResult);
}
}