Files
TheriapolisV3/Theriapolis.Tools/Commands/QuestValidate.cs
T
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

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;
}
}