/**
* Модель диалоговых графов (перенос архитектуры v1): граф — данные
* (JSON/TS): узлы с репликами, выборами, условиями и эффектами.
* Чистые предикаты над графом — без рантайма; рантайм — DialogueRunner,
* отрисовка — игра (view). Валидация числовая — агент правит графы текстом.
*/
/** Условие по переменной состояния. */
export interface VarCondition {
key: string;
op: 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le';
value: number | string;
}
/** Действия, применяемые к состоянию при входе в узел/выбор. */
export interface DialogueEffects {
setFlags?: string[];
clearFlags?: string[];
setVars?: Record<string, number | string | boolean>;
/**
* Игровые эффекты как данные: движок только сообщает их (onEffect),
* исполнение — игра (EffectSink). Неизвестный kind — ошибка валидатора.
*/
do?: DialogueEffectOp[];
}
/** Одна игровая операция эффекта (смысл знает игра). */
export interface DialogueEffectOp {
kind: 'giveItem' | 'takeItem' | 'sound' | 'toast' | 'custom';
/** id предмета (give/take), звука (sound) или имя сюжетного эффекта (custom). */
id?: string;
/** Количество (give/take), по умолчанию 1. */
count?: number;
/** Текст всплывашки (toast). */
text?: string;
/** Свободные параметры (custom). */
payload?: Record<string, number | string | boolean>;
}
/** Итог пройденного диалога: где остановились, как дошли, что выбрали. */
export interface DialogueResult {
lastNodeId: string | null;
path: string[];
picks: { nodeId: string; index: number; text: string }[];
}
/** Предикаты мира, которых движок знать не может (сумка, репутация...). */
export interface DialogueWorld {
hasItem(id: string): boolean;
}
/** Условия показа узла/варианта (все перечисленные группы — AND). */
export interface DialogueConditions {
when?: string[];
whenNot?: string[];
whenVar?: VarCondition;
whenVars?: VarCondition[];
hasItem?: string[];
}
/** Интерфейс состояния для условий/эффектов (игра реализует поверх своего стейта). */
export interface DialogueState {
hasFlag(flag: string): boolean;
setFlag(flag: string): void;
clearFlag(flag: string): void;
getVar(key: string): number | string | boolean | undefined;
setVar(key: string, value: number | string | boolean): void;
}
export interface DialogueChoice extends DialogueEffects, DialogueConditions {
text: string;
/** Следующий узел (по умолчанию — конец диалога). */
next?: string;
mood?: string;
/** Свободные метки для агента/инструментов (на геймплей не влияют). */
tags?: string[];
}
export interface DialogueNode extends DialogueEffects, DialogueConditions {
speaker?: string;
/** Текст реплики. Узел без текста — «действие»: применяет эффекты и уходит в next. */
text?: string;
choices?: DialogueChoice[];
next?: string;
/** Явный конец диалога (для конечных узлов без choices/next). */
end?: boolean;
mood?: string;
tags?: string[];
}
export interface DialogueGraph {
/** id стартового узла. */
start: string;
nodes: Record<string, DialogueNode>;
}
const CMP = {
eq: (a: unknown, b: unknown) => a === b,
ne: (a: unknown, b: unknown) => a !== b,
gt: (a: number, b: number) => a > b,
lt: (a: number, b: number) => a < b,
ge: (a: number, b: number) => a >= b,
le: (a: number, b: number) => a <= b,
} as const;
/** Есть ли хоть одно условие (для различения «узла-действия» и реплики). */
export function hasConditions(c: DialogueConditions): boolean {
return !!(c.when?.length || c.whenNot?.length || c.whenVar || c.whenVars?.length || c.hasItem?.length);
}
/** Есть ли текст (inline или ключ). */
export function hasText(n: { text?: string }): boolean {
return typeof n.text === 'string' && n.text.length > 0;
}
function cmpVars(c: VarCondition, state: DialogueState): boolean {
const actual = state.getVar(c.key);
const value = c.value;
if (c.op === 'eq') return actual === value;
if (c.op === 'ne') return actual !== value;
if (typeof actual !== 'number' || typeof value !== 'number') return false; // нет/не число — сравнение ложно
const cmp = CMP[c.op];
return cmp(actual, value);
}
/** Все группы условий — AND; мир (сумка) — через DialogueWorld. */
export function evalConditions(c: DialogueConditions, state: DialogueState, world?: DialogueWorld): boolean {
if (c.when && !c.when.every((f) => state.hasFlag(f))) return false;
if (c.whenNot && c.whenNot.some((f) => state.hasFlag(f))) return false;
if (c.whenVar && !cmpVars(c.whenVar, state)) return false;
if (c.whenVars && !c.whenVars.every((v) => cmpVars(v, state))) return false;
if (c.hasItem && !(world && c.hasItem.every((id) => world.hasItem(id)))) return false;
return true;
}
/** Известные kind игровых эффектов (неизвестный — ошибка валидатора). */
const EFFECT_KINDS = { giveItem: 1, takeItem: 1, sound: 1, toast: 1, custom: 1 } as const;
/** Валидация графа: старт, ссылки next, структура узлов и выборов, циклы. */
export function validateDialogue(g: DialogueGraph): string[] {
const errs: string[] = [];
const ids = Object.keys(g.nodes);
if (!g.nodes[g.start]) errs.push(`start: узел «${g.start}» не существует`);
for (const id of ids) {
const n = g.nodes[id];
const refs: Array<[string | undefined, string]> = [
[n.next, 'next'], ...(n.choices ?? []).map((c): [string | undefined, string] => [c.next, 'choice.next']),
];
for (const [ref, what] of refs) {
if (ref !== undefined && !g.nodes[ref]) errs.push(`${id}: ${what} → «${ref}» не существует`);
}
if (!hasText(n) && !n.next && !n.choices?.length && !n.end) {
errs.push(`${id}: узел без текста, next, choices и end — тупик-«действие»`);
}
for (const op of n.do ?? []) {
if (!(op.kind in EFFECT_KINDS)) {
errs.push(`${id}: неизвестный kind «${String((op as { kind: string }).kind)}»`);
}
}
for (const c of n.choices ?? []) {
if (!c.text) errs.push(`${id}: выбор без текста`);
for (const op of c.do ?? []) {
if (!(op.kind in EFFECT_KINDS)) {
errs.push(`${id}: неизвестный kind «${String((op as { kind: string }).kind)}»`);
}
}
}
// Цикл узлов-действий: рантайм проваливается по next без ожидания
// игрока — замкнутая цепочка бросит «превышен MAX_STEPS» посреди игры.
if (!hasText(n) && n.next) {
const seen = new Set<string>([id]);
let cur: string | undefined = n.next;
while (cur && g.nodes[cur] && !hasText(g.nodes[cur]) && g.nodes[cur].next) {
if (cur === id) {
errs.push(`${id}: цикл узлов-действий (next замыкается)`);
break;
}
if (seen.has(cur)) break; // цикл не с этого узла — сообщит его участник
seen.add(cur);
cur = g.nodes[cur].next;
}
}
}
return errs;
}