import type { DialogueChoice, DialogueGraph, DialogueNode, Invariant } from '@rpg/engine';
/**
* Чистые правила диалоговых графов: реестры — параметры (не импорты), поэтому
* один и тот же checkGraph работает в validate.ts (юнит-тесты, agent:invariants),
* в CLI dry-run и в сервере визуального редактора. Истина одна.
*/
export interface GraphRefs {
/** Допустимые id: флаги, вары, предметы, custom-эффекты, диалоги, строки. */
flags: ReadonlySet<string>;
vars: ReadonlySet<string>;
items: ReadonlySet<string>;
customs: ReadonlySet<string>;
strings: ReadonlySet<string>;
/** Куда записать упоминания (для «мёртвых» сущностей реестра) — опционально. */
usedFlags?: Set<string>;
usedVars?: Set<string>;
}
/** Результат обхода графа: кто достижим и где подозрительные места. */
export interface Reachability {
/** Достижимые от start узлы (BFS по next/choices). */
reachable: string[];
/** Узлы, в которые нет ни одной ссылки. */
orphans: string[];
/** Циклы из узлов без текста и без выборов (раннер обрывает по MAX_STEPS). */
textlessCycles: string[][];
/** Достижимые концы без реплики (диалог закончится молча). */
silentEnds: string[];
}
/** Ссылки узла/выбора, которые надо сверить с реестрами. */
function refsOf(n: DialogueNode | DialogueChoice): {
flags: string[];
vars: string[];
items: string[];
doOps: NonNullable<DialogueNode['do']>;
textKey?: string;
} {
return {
flags: [...(n.when ?? []), ...(n.whenNot ?? []), ...(n.setFlags ?? []), ...(n.clearFlags ?? [])],
vars: [
...(n.whenVar ? [n.whenVar.key] : []),
...(n.whenVars ?? []).map((c) => c.key),
...Object.keys(n.setVars ?? {})
],
items: [...(n.hasItem ?? [])],
doOps: n.do ?? [],
textKey: n.textKey
};
}
function checkSpot(
out: Invariant[],
where: string,
refs: GraphRefs,
spot: string,
n: DialogueNode | DialogueChoice,
next: string | undefined,
graph: DialogueGraph
): void {
const r = refsOf(n);
for (const f of r.flags) {
if (!refs.flags.has(f)) out.push({ id: 'flag-unknown', severity: 'error', message: `${spot}: флаг «${f}» вне реестра FLAGS`, where });
refs.usedFlags?.add(f);
}
for (const v of r.vars) {
if (!refs.vars.has(v)) out.push({ id: 'var-unknown', severity: 'error', message: `${spot}: вар «${v}» вне реестра VARS`, where });
refs.usedVars?.add(v);
}
for (const it of r.items) {
if (!refs.items.has(it)) out.push({ id: 'item-unknown', severity: 'error', message: `${spot}: предмет «${it}» вне реестра ITEMS`, where });
}
for (const op of r.doOps) {
if ((op.kind === 'giveItem' || op.kind === 'takeItem') && (op.id === undefined || !refs.items.has(op.id))) {
out.push({ id: 'do-item-unknown', severity: 'error', message: `${spot}: do[].${op.kind} — предмет «${op.id}» вне реестра ITEMS`, where });
}
if (op.kind === 'custom' && (op.id === undefined || !refs.customs.has(op.id))) {
out.push({ id: 'do-custom-unknown', severity: 'error', message: `${spot}: do[].custom — имя «${op.id}» вне реестра DIALOGUE_CUSTOM`, where });
}
}
if (r.textKey !== undefined && !refs.strings.has(r.textKey)) {
out.push({ id: 'string-unknown', severity: 'error', message: `${spot}: textKey «${r.textKey}» вне реестра строк`, where });
}
if (next !== undefined && !graph.nodes[next]) {
out.push({ id: 'dialogue-next', severity: 'error', message: `${spot}: next «${next}» не существует`, where });
}
}
/** Все правила одного графа: ссылки, next, сироты, циклы, тихие концы. */
export function checkGraph(id: string, graph: DialogueGraph, refs: GraphRefs): Invariant[] {
const out: Invariant[] = [];
const where = `data/dialogue/${id}`;
if (!graph.nodes[graph.start]) {
out.push({ id: 'dialogue-start', severity: 'error', message: `start «${graph.start}» не существует`, where });
}
for (const [nid, node] of Object.entries(graph.nodes)) {
checkSpot(out, where, refs, `узел «${nid}»`, node, node.next, graph);
(node.choices ?? []).forEach((c, i) => {
checkSpot(out, where, refs, `узел «${nid}», выбор#${i}`, c, c.next, graph);
});
}
const a = analyzeGraph(graph);
for (const o of a.orphans) {
out.push({ id: 'dialogue-orphan', severity: 'warn', message: `узел «${o}» недостижим`, where });
}
for (const cycle of a.textlessCycles) {
out.push({ id: 'textless-cycle', severity: 'error', message: `цикл без текста: ${cycle.join(' → ')}`, where });
}
for (const end of a.silentEnds) {
out.push({ id: 'silent-end', severity: 'warn', message: `диалог может кончиться молча в узле «${end}»`, where });
}
return out;
}
/** Исходящие рёбра узла (next + next выборов). */
function edgesOf(graph: DialogueGraph, id: string): string[] {
const n = graph.nodes[id];
if (!n) return [];
const out = n.next !== undefined ? [n.next] : [];
for (const c of n.choices ?? []) {
if (c.next !== undefined) out.push(c.next);
}
return out;
}
/** Обход графа от start: достижимость, сироты, текстовые циклы, тихие концы. */
export function analyzeGraph(graph: DialogueGraph): Reachability {
const reachable = new Set<string>();
if (graph.nodes[graph.start]) {
const queue = [graph.start];
while (queue.length > 0) {
const id = queue.shift()!;
if (reachable.has(id)) continue;
reachable.add(id);
for (const next of edgesOf(graph, id)) {
if (graph.nodes[next] && !reachable.has(next)) queue.push(next);
}
}
}
const orphans = Object.keys(graph.nodes).filter((id) => !reachable.has(id));
// Циклы без текста: DFS с окраской; цикл собираем из стека.
const color = new Map<string, 1 | 2>(); // 1 — в стеке, 2 — готово
const stack: string[] = [];
const cycles: string[][] = [];
const visit = (id: string): void => {
color.set(id, 1);
stack.push(id);
for (const next of edgesOf(graph, id)) {
if (!graph.nodes[next]) continue;
const c = color.get(next);
if (c === 1) {
const cycle = stack.slice(stack.indexOf(next));
const textless = cycle.every((cid) => {
const n = graph.nodes[cid]!;
return n.text === undefined && n.textKey === undefined && (n.choices?.length ?? 0) === 0;
});
if (textless) cycles.push(cycle);
} else if (c === undefined) {
visit(next);
}
}
stack.pop();
color.set(id, 2);
};
for (const id of reachable) {
if (!color.has(id)) visit(id);
}
const silentEnds = [...reachable].filter((id) => {
const n = graph.nodes[id]!;
return n.next === undefined && (n.choices?.length ?? 0) === 0 && n.text === undefined && n.textKey === undefined;
});
return { reachable: [...reachable], orphans, textlessCycles: cycles, silentEnds };
}