space-game001/dialogEditor/src/utils/validation.ts
2026-06-05 21:17:51 +03:00

109 lines
3.6 KiB
TypeScript

import { Dialogue, DialogueNode, ValidationIssue } from '../types/dialogue';
function getOutgoingNodeIds(node: DialogueNode): string[] {
switch (node.type) {
case 'Line':
case 'SetFlag':
case 'CutsceneStart':
return node.next ? [node.next] : [];
case 'Choice':
return node.choices.map(c => c.next).filter(Boolean);
case 'Condition':
return [node.trueNext, node.falseNext].filter(Boolean);
case 'End':
return [];
}
}
function reachableFrom(startId: string, nodeMap: Map<string, DialogueNode>): Set<string> {
const visited = new Set<string>();
const stack = [startId];
while (stack.length > 0) {
const id = stack.pop()!;
if (visited.has(id)) continue;
visited.add(id);
const node = nodeMap.get(id);
if (!node) continue;
for (const next of getOutgoingNodeIds(node)) {
if (!visited.has(next)) stack.push(next);
}
}
return visited;
}
export function validateDialogue(dialogue: Dialogue): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const nodeMap = new Map<string, DialogueNode>();
const duplicates = new Set<string>();
// 1. Duplicate IDs
for (const node of dialogue.nodes) {
if (nodeMap.has(node.id)) {
duplicates.add(node.id);
issues.push({ nodeId: node.id, severity: 'error', message: `Duplicate node ID: "${node.id}"` });
} else {
nodeMap.set(node.id, node);
}
}
// 2. Missing start node
if (dialogue.start && !nodeMap.has(dialogue.start)) {
issues.push({ nodeId: '__dialogue__', severity: 'error', message: `Start node "${dialogue.start}" does not exist` });
}
// 3. Broken references
for (const node of dialogue.nodes) {
if (duplicates.has(node.id)) continue;
if ((node.type === 'Line' || node.type === 'SetFlag' || node.type === 'CutsceneStart') && node.next) {
if (!nodeMap.has(node.next)) {
issues.push({ nodeId: node.id, severity: 'error', message: `"next" points to missing node "${node.next}"` });
}
}
if (node.type === 'Choice') {
for (const choice of node.choices) {
if (choice.next && !nodeMap.has(choice.next)) {
issues.push({ nodeId: node.id, severity: 'error', message: `Choice "${choice.text}" points to missing node "${choice.next}"` });
}
}
}
if (node.type === 'Condition') {
if (node.trueNext && !nodeMap.has(node.trueNext)) {
issues.push({ nodeId: node.id, severity: 'error', message: `"trueNext" points to missing node "${node.trueNext}"` });
}
if (node.falseNext && !nodeMap.has(node.falseNext)) {
issues.push({ nodeId: node.id, severity: 'error', message: `"falseNext" points to missing node "${node.falseNext}"` });
}
}
}
// 4. Reachability from start
if (dialogue.start && nodeMap.has(dialogue.start)) {
const reachable = reachableFrom(dialogue.start, nodeMap);
// No End reachable
const hasEnd = [...reachable].some(id => nodeMap.get(id)?.type === 'End');
if (!hasEnd) {
issues.push({ nodeId: dialogue.start, severity: 'warning', message: 'No End node is reachable from the start' });
}
// Orphaned nodes
for (const node of dialogue.nodes) {
if (!reachable.has(node.id) && !duplicates.has(node.id)) {
issues.push({ nodeId: node.id, severity: 'warning', message: 'Node is not reachable from the start' });
}
}
}
// 5. Empty text warnings
for (const node of dialogue.nodes) {
if ((node.type === 'Line' || node.type === 'Choice') && !node.text.trim()) {
issues.push({ nodeId: node.id, severity: 'warning', message: 'Node has empty text' });
}
}
return issues;
}