82 lines
3.3 KiB
C#
82 lines
3.3 KiB
C#
|
|
using Theriapolis.Core.Data;
|
||
|
|
|
||
|
|
namespace Theriapolis.Tools.Commands;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Phase 6 M4 — quest-validate. Loads every <c>quests/*.json</c> through
|
||
|
|
/// <see cref="ContentLoader.LoadQuests"/> (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
|
||
|
|
/// </summary>
|
||
|
|
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<string>(StringComparer.OrdinalIgnoreCase) { q.EntryStep };
|
||
|
|
var queue = new Queue<string>();
|
||
|
|
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, "<end>", 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;
|
||
|
|
}
|
||
|
|
}
|