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>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user