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): Set { const visited = new Set(); 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(); const duplicates = new Set(); // 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; }