using Theriapolis.Core.Data; namespace Theriapolis.Tools.Commands; /// /// Phase 6 M4 — quest-validate. Loads every quests/*.json through /// (which validates structure + /// references) and runs reachability analysis from each quest's entry /// step. CI gate: exits non-zero on any error. /// /// Usage: /// dotnet run --project Theriapolis.Tools -- quest-validate /// dotnet run --project Theriapolis.Tools -- quest-validate --data-dir ./Content/Data /// public static class QuestValidate { public static int Run(string[] args) { string dataDir = "./Content/Data"; for (int i = 0; i < args.Length; i++) { if (args[i] == "--data-dir" && i + 1 < args.Length) { dataDir = args[++i]; } } Console.WriteLine($"Validating quests in: {dataDir}"); var loader = new ContentLoader(dataDir); QuestDef[] quests; try { var items = loader.LoadItems(); quests = loader.LoadQuests(items); } catch (Exception ex) { Console.Error.WriteLine($" ✗ {ex.Message}"); return 1; } Console.WriteLine($" ✓ {quests.Length} quest tree(s) loaded."); foreach (var q in quests.OrderBy(q => q.Id, StringComparer.Ordinal)) { int outcomes = q.Steps.Sum(s => s.Outcomes.Length); int terminals = q.Steps.Count(s => s.CompletesQuest || s.FailsQuest); Console.WriteLine($" [{q.Id}] '{q.Title}' steps={q.Steps.Length} outcomes={outcomes} terminals={terminals}"); // Reachability from entry step. var stepsById = q.Steps.ToDictionary(s => s.Id, StringComparer.OrdinalIgnoreCase); var reached = new HashSet(StringComparer.OrdinalIgnoreCase) { q.EntryStep }; var queue = new Queue(); queue.Enqueue(q.EntryStep); while (queue.Count > 0) { var cur = queue.Dequeue(); if (!stepsById.TryGetValue(cur, out var s)) continue; foreach (var o in s.Outcomes) { if (string.IsNullOrEmpty(o.Next)) continue; if (string.Equals(o.Next, "", StringComparison.OrdinalIgnoreCase)) continue; if (reached.Add(o.Next)) queue.Enqueue(o.Next); } // start_quest / end_quest effects can transition out, but we // don't follow them here — quest-graph reachability is // single-quest by design. } int unreachable = q.Steps.Length - reached.Count; if (unreachable > 0) { Console.Error.WriteLine($" ✗ {unreachable} unreachable step(s) in '{q.Id}': " + string.Join(", ", q.Steps.Where(s => !reached.Contains(s.Id)).Select(s => s.Id))); return 1; } // Every quest must have at least one terminal path. if (terminals == 0) Console.Error.WriteLine($" ⚠ quest '{q.Id}' has no completes_quest / fails_quest step (warning only)"); } Console.WriteLine(); Console.WriteLine("All quest trees valid."); return 0; } }