using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Json.Schema;
using Microsoft.Extensions.Logging;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Quests;
/// Loads and validates content/quests/*_quests.json using the same rules as scripts/validate_content.py (NEO-113, NEO-125, NEO-134).
public static class QuestDefinitionCatalogLoader
{
private static JsonSchema? _questObjectiveDefSchema;
private static JsonSchema? _questStepDefSchema;
private static JsonSchema? _questDefSchema;
/// Loads catalogs from disk or throws with actionable messages.
public static QuestDefinitionCatalog Load(
string questsDirectory,
string questDefSchemaPath,
string questStepDefSchemaPath,
string questObjectiveDefSchemaPath,
string rewardGrantRowSchemaPath,
string questSkillXpGrantSchemaPath,
string questRewardBundleSchemaPath,
IReadOnlySet knownItemIds,
IReadOnlySet knownRecipeIds,
IReadOnlySet knownEncounterIds,
IReadOnlySet knownFactionIds,
IReadOnlyDictionary skillDefsById,
ILogger logger)
{
questsDirectory = Path.GetFullPath(questsDirectory);
questDefSchemaPath = Path.GetFullPath(questDefSchemaPath);
questStepDefSchemaPath = Path.GetFullPath(questStepDefSchemaPath);
questObjectiveDefSchemaPath = Path.GetFullPath(questObjectiveDefSchemaPath);
rewardGrantRowSchemaPath = Path.GetFullPath(rewardGrantRowSchemaPath);
questSkillXpGrantSchemaPath = Path.GetFullPath(questSkillXpGrantSchemaPath);
questRewardBundleSchemaPath = Path.GetFullPath(questRewardBundleSchemaPath);
var errors = new List();
if (!File.Exists(questDefSchemaPath))
errors.Add($"error: missing schema file {questDefSchemaPath}");
if (!File.Exists(questStepDefSchemaPath))
errors.Add($"error: missing schema file {questStepDefSchemaPath}");
if (!File.Exists(questObjectiveDefSchemaPath))
errors.Add($"error: missing schema file {questObjectiveDefSchemaPath}");
if (!File.Exists(rewardGrantRowSchemaPath))
errors.Add($"error: missing schema file {rewardGrantRowSchemaPath}");
if (!File.Exists(questSkillXpGrantSchemaPath))
errors.Add($"error: missing schema file {questSkillXpGrantSchemaPath}");
if (!File.Exists(questRewardBundleSchemaPath))
errors.Add($"error: missing schema file {questRewardBundleSchemaPath}");
if (!Directory.Exists(questsDirectory))
errors.Add($"error: missing directory {questsDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
string[] jsonFiles = [.. Directory.GetFiles(questsDirectory, "*_quests.json", SearchOption.TopDirectoryOnly)
.OrderBy(p => p, StringComparer.Ordinal)];
if (jsonFiles.Length == 0)
errors.Add($"error: no *_quests.json files under {questsDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
EnsureQuestSchemasLoaded(
questObjectiveDefSchemaPath,
questStepDefSchemaPath,
rewardGrantRowSchemaPath,
questSkillXpGrantSchemaPath,
questRewardBundleSchemaPath,
questDefSchemaPath);
var defSchema = _questDefSchema!;
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
var questIdToSourceFile = new Dictionary(StringComparer.Ordinal);
var rows = new Dictionary(StringComparer.Ordinal);
foreach (var path in jsonFiles)
{
JsonNode? root;
try
{
root = JsonNode.Parse(File.ReadAllText(path));
}
catch (JsonException ex)
{
errors.Add($"error: {path}: invalid JSON: {ex.Message}");
continue;
}
if (root is not JsonObject rootObj)
{
errors.Add($"error: {path}: expected JSON object at root");
continue;
}
var schemaVersionNode = rootObj["schemaVersion"];
if (schemaVersionNode is not JsonValue schemaVersionValue ||
!schemaVersionValue.TryGetValue(out var schemaVersion) ||
schemaVersion != 1)
{
var got = schemaVersionNode?.ToJsonString() ?? "null";
errors.Add($"error: {path}: expected schemaVersion 1, got {got}");
continue;
}
var questsNode = rootObj["quests"];
if (questsNode is not JsonArray questsArray)
{
errors.Add($"error: {path}: expected top-level 'quests' array");
continue;
}
for (var i = 0; i < questsArray.Count; i++)
{
var quest = questsArray[i];
if (quest is not JsonObject rowObj)
{
errors.Add($"error: {path}: quests[{i}] must be an object");
continue;
}
var eval = defSchema.Evaluate(rowObj, evalOptions);
var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList();
if (!eval.IsValid)
{
if (schemaMsgs.Count == 0)
schemaMsgs.Add($"error: {path} quests[{i}] (root): schema validation failed");
errors.AddRange(schemaMsgs);
}
var qid = (rowObj["id"] as JsonValue)?.GetValue();
if (qid is not null && eval.IsValid)
{
if (questIdToSourceFile.TryGetValue(qid, out var prevPath))
{
errors.Add($"error: duplicate quest id '{qid}' in {prevPath} and {path}");
continue;
}
var stepParseErrors = TryParseSteps(rowObj, path, i, out var steps, out _, out _);
errors.AddRange(stepParseErrors);
questIdToSourceFile[qid] = path;
rows[qid] = ParseRow(rowObj, steps);
}
}
}
ThrowIfAny(errors);
var e7m1 = PrototypeE7M1QuestCatalogRules.TryGetE7M1GateError(questIdToSourceFile);
if (e7m1 is not null)
{
errors.Add(e7m1);
ThrowIfAny(errors);
}
var prereq = PrototypeE7M1QuestCatalogRules.TryGetPrerequisiteGateError(rows);
if (prereq is not null)
{
errors.Add(prereq);
ThrowIfAny(errors);
}
var crossRef = PrototypeE7M1QuestCatalogRules.TryGetCrossRefError(
rows,
knownItemIds,
knownRecipeIds,
knownEncounterIds);
if (crossRef is not null)
{
errors.Add(crossRef);
ThrowIfAny(errors);
}
var chainTerminal = PrototypeE7M1QuestCatalogRules.TryGetChainTerminalGateError(rows);
if (chainTerminal is not null)
{
errors.Add(chainTerminal);
ThrowIfAny(errors);
}
var bundlePresence = PrototypeE7M2QuestCatalogRules.TryGetCompletionBundlePresenceError(rows);
if (bundlePresence is not null)
{
errors.Add(bundlePresence);
ThrowIfAny(errors);
}
var bundleFreeze = PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleFreezeError(rows);
if (bundleFreeze is not null)
{
errors.Add(bundleFreeze);
ThrowIfAny(errors);
}
var bundleCrossRef = PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleCrossRefError(rows, skillDefsById);
if (bundleCrossRef is not null)
{
errors.Add(bundleCrossRef);
ThrowIfAny(errors);
}
var factionCrossRef = PrototypeE7M3QuestFactionRules.TryGetFactionCrossRefError(rows, knownFactionIds);
if (factionCrossRef is not null)
{
errors.Add(factionCrossRef);
ThrowIfAny(errors);
}
var e7m3BundleFreeze = PrototypeE7M3QuestFactionRules.TryGetCompletionBundleFreezeError(rows);
if (e7m3BundleFreeze is not null)
{
errors.Add(e7m3BundleFreeze);
ThrowIfAny(errors);
}
var gridContractShape = PrototypeE7M3QuestFactionRules.TryGetGridContractShapeError(rows);
if (gridContractShape is not null)
{
errors.Add(gridContractShape);
ThrowIfAny(errors);
}
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation(
"Loaded quest catalog from {QuestsDirectory}: {QuestCount} quest(s) across {CatalogFileCount} JSON catalog file(s).",
questsDirectory,
rows.Count,
jsonFiles.Length);
}
return new QuestDefinitionCatalog(questsDirectory, rows, jsonFiles.Length);
}
private static List TryParseSteps(
JsonObject rowObj,
string path,
int questIndex,
out IReadOnlyList steps,
out HashSet stepIdsSeen,
out HashSet objectiveIdsSeen)
{
var errors = new List();
stepIdsSeen = new HashSet(StringComparer.Ordinal);
objectiveIdsSeen = new HashSet(StringComparer.Ordinal);
var parsedSteps = new List();
var stepsNode = rowObj["steps"] as JsonArray;
if (stepsNode is null)
{
steps = parsedSteps;
return errors;
}
for (var si = 0; si < stepsNode.Count; si++)
{
if (stepsNode[si] is not JsonObject stepObj)
continue;
var sid = (stepObj["id"] as JsonValue)?.GetValue();
if (sid is not null)
{
if (stepIdsSeen.Contains(sid))
{
errors.Add($"error: {path} quests[{questIndex}] steps[{si}]: duplicate step id '{sid}'");
}
else
{
stepIdsSeen.Add(sid);
}
}
var objectivesNode = stepObj["objectives"] as JsonArray;
if (objectivesNode is null)
continue;
for (var oi = 0; oi < objectivesNode.Count; oi++)
{
if (objectivesNode[oi] is not JsonObject objObj)
continue;
var oid = (objObj["id"] as JsonValue)?.GetValue();
if (oid is not null)
{
if (objectiveIdsSeen.Contains(oid))
{
errors.Add(
$"error: {path} quests[{questIndex}] steps[{si}] objectives[{oi}]: " +
$"duplicate objective id '{oid}' within quest");
}
else
{
objectiveIdsSeen.Add(oid);
}
}
}
}
for (var si = 0; si < stepsNode.Count; si++)
{
if (stepsNode[si] is JsonObject stepObj)
parsedSteps.Add(ParseStep(stepObj));
}
steps = parsedSteps;
return errors;
}
private static QuestDefRow ParseRow(JsonObject rowObj, IReadOnlyList steps)
{
var id = (rowObj["id"] as JsonValue)!.GetValue();
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue();
var prerequisiteQuestIds = ParseStringArray(rowObj["prerequisiteQuestIds"] as JsonArray);
var completionRewardBundle = ParseCompletionRewardBundle(rowObj["completionRewardBundle"] as JsonObject);
var factionGateRules = ParseFactionGateRules(rowObj["factionGateRules"] as JsonArray);
return new QuestDefRow(id, displayName, prerequisiteQuestIds, steps, completionRewardBundle, factionGateRules);
}
private static IReadOnlyList ParseFactionGateRules(JsonArray? array)
{
if (array is null || array.Count == 0)
return [];
var rows = new List(array.Count);
foreach (var node in array)
{
if (node is not JsonObject ruleObj)
continue;
var factionId = (ruleObj["factionId"] as JsonValue)!.GetValue();
var minStanding = (ruleObj["minStanding"] as JsonValue)!.GetValue();
rows.Add(new FactionGateRuleRow(factionId, minStanding));
}
return rows;
}
private static QuestRewardBundleRow? ParseCompletionRewardBundle(JsonObject? bundleObj)
{
if (bundleObj is null)
return null;
var itemGrants = ParseItemGrantRows(bundleObj["itemGrants"] as JsonArray);
var skillXpGrants = ParseSkillXpGrantRows(bundleObj["skillXpGrants"] as JsonArray);
var reputationGrants = ParseReputationGrantRows(bundleObj["reputationGrants"] as JsonArray);
return new QuestRewardBundleRow(itemGrants, skillXpGrants, reputationGrants);
}
private static IReadOnlyList ParseReputationGrantRows(JsonArray? array)
{
if (array is null || array.Count == 0)
return [];
var rows = new List(array.Count);
foreach (var node in array)
{
if (node is not JsonObject grantObj)
continue;
var factionId = (grantObj["factionId"] as JsonValue)!.GetValue();
var amount = (grantObj["amount"] as JsonValue)!.GetValue();
rows.Add(new ReputationGrantRow(factionId, amount));
}
return rows;
}
private static IReadOnlyList ParseItemGrantRows(JsonArray? array)
{
if (array is null || array.Count == 0)
return [];
var rows = new List(array.Count);
foreach (var node in array)
{
if (node is not JsonObject grantObj)
continue;
var itemId = (grantObj["itemId"] as JsonValue)!.GetValue();
var quantity = (grantObj["quantity"] as JsonValue)!.GetValue();
rows.Add(new RewardGrantRow(itemId, quantity));
}
return rows;
}
private static IReadOnlyList ParseSkillXpGrantRows(JsonArray? array)
{
if (array is null || array.Count == 0)
return [];
var rows = new List(array.Count);
foreach (var node in array)
{
if (node is not JsonObject grantObj)
continue;
var skillId = (grantObj["skillId"] as JsonValue)!.GetValue();
var amount = (grantObj["amount"] as JsonValue)!.GetValue();
rows.Add(new QuestSkillXpGrantRow(skillId, amount));
}
return rows;
}
private static QuestStepDefRow ParseStep(JsonObject stepObj)
{
var id = (stepObj["id"] as JsonValue)!.GetValue();
var displayName = (stepObj["displayName"] as JsonValue)!.GetValue();
var objectivesNode = stepObj["objectives"] as JsonArray ?? [];
var objectives = new List(objectivesNode.Count);
foreach (var node in objectivesNode)
{
if (node is JsonObject objObj)
objectives.Add(ParseObjective(objObj));
}
return new QuestStepDefRow(id, displayName, objectives);
}
private static QuestObjectiveDefRow ParseObjective(JsonObject objObj)
{
var id = (objObj["id"] as JsonValue)!.GetValue();
var kind = (objObj["kind"] as JsonValue)!.GetValue();
string? itemId = null;
int? quantity = null;
string? recipeId = null;
string? encounterId = null;
if (objObj["itemId"] is JsonValue itemValue && itemValue.TryGetValue(out var item))
itemId = item;
if (objObj["quantity"] is JsonValue qtyValue && qtyValue.TryGetValue(out var qty))
quantity = qty;
if (objObj["recipeId"] is JsonValue recipeValue && recipeValue.TryGetValue(out var recipe))
recipeId = recipe;
if (objObj["encounterId"] is JsonValue encounterValue && encounterValue.TryGetValue(out var encounter))
encounterId = encounter;
return new QuestObjectiveDefRow(id, kind, itemId, quantity, recipeId, encounterId);
}
private static IReadOnlyList ParseStringArray(JsonArray? array)
{
if (array is null)
return [];
var values = new List(array.Count);
foreach (var node in array)
{
if (node is JsonValue value && value.TryGetValue(out var s))
values.Add(s);
}
return values;
}
private static List CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
{
var sink = new List();
AppendSchemaMessages(eval, filePath, index, sink);
return sink;
}
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List sink)
{
if (r.HasDetails)
{
foreach (var d in r.Details!)
AppendSchemaMessages(d, filePath, index, sink);
}
if (!r.HasErrors)
return;
foreach (var kv in r.Errors!)
{
var loc = r.InstanceLocation?.ToString();
if (string.IsNullOrEmpty(loc) || loc == "#")
loc = "(root)";
sink.Add($"error: {filePath} quests[{index}] {loc}: {kv.Key} — {kv.Value}");
}
}
/// Registers bundle + objective → step → def in once per process (NEO-113, NEO-125).
private static void EnsureQuestSchemasLoaded(
string questObjectiveDefSchemaPath,
string questStepDefSchemaPath,
string rewardGrantRowSchemaPath,
string questSkillXpGrantSchemaPath,
string questRewardBundleSchemaPath,
string questDefSchemaPath)
{
if (_questDefSchema is not null)
return;
var schemaDir = Path.GetDirectoryName(questDefSchemaPath)!;
CatalogSchemaRegistry.GetOrRegisterFromFile(Path.Combine(schemaDir, "faction-gate-rule.schema.json"));
CatalogSchemaRegistry.GetOrRegisterFromFile(Path.Combine(schemaDir, "reputation-grant-row.schema.json"));
CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath);
CatalogSchemaRegistry.GetOrRegisterFromFile(questSkillXpGrantSchemaPath);
CatalogSchemaRegistry.GetOrRegisterFromFile(questRewardBundleSchemaPath);
_questObjectiveDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questObjectiveDefSchemaPath);
_questStepDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questStepDefSchemaPath);
_questDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questDefSchemaPath);
}
private static void ThrowIfAny(List errors)
{
if (errors.Count == 0)
return;
var sb = new StringBuilder();
sb.AppendLine("Quest catalog validation failed:");
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
sb.AppendLine(e);
throw new InvalidOperationException(sb.ToString().TrimEnd());
}
}