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.Quests;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Contracts;
/// Loads and validates content/contracts/*_contract_templates.json using the same rules as scripts/validate_content.py (NEO-145).
public static class ContractTemplateCatalogLoader
{
private static JsonSchema? _contractTemplateSchema;
/// Loads catalogs from disk or throws with actionable messages.
public static ContractTemplateCatalog Load(
string contractsDirectory,
string contractTemplateSchemaPath,
string rewardGrantRowSchemaPath,
string questSkillXpGrantSchemaPath,
string questRewardBundleSchemaPath,
string factionGateRuleSchemaPath,
string reputationGrantRowSchemaPath,
IReadOnlySet knownEncounterIds,
IReadOnlySet knownFactionIds,
IReadOnlyDictionary skillDefsById,
ILogger logger)
{
contractsDirectory = Path.GetFullPath(contractsDirectory);
contractTemplateSchemaPath = Path.GetFullPath(contractTemplateSchemaPath);
rewardGrantRowSchemaPath = Path.GetFullPath(rewardGrantRowSchemaPath);
questSkillXpGrantSchemaPath = Path.GetFullPath(questSkillXpGrantSchemaPath);
questRewardBundleSchemaPath = Path.GetFullPath(questRewardBundleSchemaPath);
factionGateRuleSchemaPath = Path.GetFullPath(factionGateRuleSchemaPath);
reputationGrantRowSchemaPath = Path.GetFullPath(reputationGrantRowSchemaPath);
var errors = new List();
if (!File.Exists(contractTemplateSchemaPath))
errors.Add($"error: missing schema file {contractTemplateSchemaPath}");
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 (!File.Exists(factionGateRuleSchemaPath))
errors.Add($"error: missing schema file {factionGateRuleSchemaPath}");
if (!File.Exists(reputationGrantRowSchemaPath))
errors.Add($"error: missing schema file {reputationGrantRowSchemaPath}");
if (!Directory.Exists(contractsDirectory))
errors.Add($"error: missing directory {contractsDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
string[] jsonFiles = [.. Directory.GetFiles(contractsDirectory, "*_contract_templates.json", SearchOption.TopDirectoryOnly)
.OrderBy(p => p, StringComparer.Ordinal)];
if (jsonFiles.Length == 0)
errors.Add($"error: no *_contract_templates.json files under {contractsDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
EnsureContractSchemasLoaded(
contractTemplateSchemaPath,
rewardGrantRowSchemaPath,
questSkillXpGrantSchemaPath,
questRewardBundleSchemaPath,
factionGateRuleSchemaPath,
reputationGrantRowSchemaPath);
var templateSchema = _contractTemplateSchema!;
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
var templateIdToSourceFile = 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 templatesNode = rootObj["contractTemplates"];
if (templatesNode is not JsonArray templatesArray)
{
errors.Add($"error: {path}: expected top-level 'contractTemplates' array");
continue;
}
for (var i = 0; i < templatesArray.Count; i++)
{
var item = templatesArray[i];
if (item is not JsonObject rowObj)
{
errors.Add($"error: {path}: contractTemplates[{i}] must be an object");
continue;
}
var eval = templateSchema.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} contractTemplates[{i}] (root): schema validation failed");
errors.AddRange(schemaMsgs);
}
var tid = (rowObj["id"] as JsonValue)?.GetValue();
if (tid is not null && eval.IsValid)
{
if (templateIdToSourceFile.TryGetValue(tid, out var prevPath))
{
errors.Add($"error: duplicate contract template id '{tid}' in {prevPath} and {path}");
continue;
}
templateIdToSourceFile[tid] = path;
rows[tid] = ParseRow(rowObj);
}
}
}
ThrowIfAny(errors);
var roster = PrototypeE7M4ContractCatalogRules.TryGetRosterGateError(templateIdToSourceFile);
if (roster is not null)
{
errors.Add(roster);
ThrowIfAny(errors);
}
var freeze = PrototypeE7M4ContractCatalogRules.TryGetFreezeGateError(rows);
if (freeze is not null)
{
errors.Add(freeze);
ThrowIfAny(errors);
}
var crossRef = PrototypeE7M4ContractCatalogRules.TryGetCrossRefError(
rows,
knownEncounterIds,
knownFactionIds,
skillDefsById);
if (crossRef is not null)
{
errors.Add(crossRef);
ThrowIfAny(errors);
}
var bandCap = PrototypeE7M4ContractCatalogRules.TryGetBandCapGateError(rows);
if (bandCap is not null)
{
errors.Add(bandCap);
ThrowIfAny(errors);
}
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation(
"Loaded contract template catalog from {ContractsDirectory}: {TemplateCount} template(s) across {CatalogFileCount} JSON catalog file(s).",
contractsDirectory,
rows.Count,
jsonFiles.Length);
}
return new ContractTemplateCatalog(contractsDirectory, rows, jsonFiles.Length);
}
private static ContractTemplateRow ParseRow(JsonObject rowObj)
{
var id = (rowObj["id"] as JsonValue)!.GetValue();
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue();
var zoneDifficultyBand = (rowObj["zoneDifficultyBand"] as JsonValue)!.GetValue();
var objectiveKind = (rowObj["objectiveKind"] as JsonValue)!.GetValue();
var encounterTemplateId = (rowObj["encounterTemplateId"] as JsonValue)!.GetValue();
var minStandingObj = rowObj["minFactionStanding"] as JsonObject;
var factionId = (minStandingObj!["factionId"] as JsonValue)!.GetValue();
var minStanding = (minStandingObj["minStanding"] as JsonValue)!.GetValue();
var completionRewardBundle = ParseCompletionRewardBundle(rowObj["completionRewardBundle"] as JsonObject)!;
return new ContractTemplateRow(
id,
displayName,
zoneDifficultyBand,
objectiveKind,
encounterTemplateId,
new FactionGateRuleRow(factionId, minStanding),
completionRewardBundle);
}
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 List 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 List 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 List 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 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} contractTemplates[{index}] {loc}: {kv.Key} — {kv.Value}");
}
}
/// Registers bundle + gate schemas in once per process (NEO-145).
private static void EnsureContractSchemasLoaded(
string contractTemplateSchemaPath,
string rewardGrantRowSchemaPath,
string questSkillXpGrantSchemaPath,
string questRewardBundleSchemaPath,
string factionGateRuleSchemaPath,
string reputationGrantRowSchemaPath)
{
if (_contractTemplateSchema is not null)
return;
CatalogSchemaRegistry.GetOrRegisterFromFile(factionGateRuleSchemaPath);
CatalogSchemaRegistry.GetOrRegisterFromFile(reputationGrantRowSchemaPath);
CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath);
CatalogSchemaRegistry.GetOrRegisterFromFile(questSkillXpGrantSchemaPath);
CatalogSchemaRegistry.GetOrRegisterFromFile(questRewardBundleSchemaPath);
_contractTemplateSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(contractTemplateSchemaPath);
}
private static void ThrowIfAny(List errors)
{
if (errors.Count == 0)
return;
var sb = new StringBuilder();
sb.AppendLine("Contract template catalog validation failed:");
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
sb.AppendLine(e);
throw new InvalidOperationException(sb.ToString().TrimEnd());
}
}