109 lines
3.6 KiB
TypeScript
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;
|
|
}
|