using System.Text;
using System.Text.Json.Nodes;
using Json.Schema;
using Microsoft.Extensions.Logging;
namespace NeonSprawl.Server.Game.Mastery;
/// Loads and validates content/mastery/*_mastery.json using the same rules as scripts/validate_content.py (NEO-46).
public static class MasteryCatalogLoader
{
/// Loads catalogs from disk or throws with actionable messages.
public static MasteryCatalog Load(
string masteryDirectory,
string schemaPath,
IReadOnlySet knownSkillIds,
ILogger logger)
{
masteryDirectory = Path.GetFullPath(masteryDirectory);
schemaPath = Path.GetFullPath(schemaPath);
var errors = new List();
if (!File.Exists(schemaPath))
errors.Add($"error: missing schema file {schemaPath}");
if (!Directory.Exists(masteryDirectory))
errors.Add($"error: missing directory {masteryDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
string[] jsonFiles = [.. Directory.GetFiles(masteryDirectory, "*_mastery.json", SearchOption.TopDirectoryOnly)
.OrderBy(p => p, StringComparer.Ordinal)];
if (jsonFiles.Length == 0)
errors.Add($"error: no *_mastery.json files under {masteryDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
var schema = JsonSchema.FromText(File.ReadAllText(schemaPath));
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
var globalPerkIds = new Dictionary(StringComparer.Ordinal);
var globalReferencedPerkIds = new Dictionary(StringComparer.Ordinal);
var allTrackSkillIds = new List();
var perksById = new Dictionary(StringComparer.Ordinal);
var tracksBySkillId = new Dictionary(StringComparer.Ordinal);
foreach (var path in jsonFiles)
{
JsonNode? root;
try
{
root = JsonNode.Parse(File.ReadAllText(path));
}
catch (System.Text.Json.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 eval = schema.Evaluate(rootObj, evalOptions);
var schemaMsgs = CollectSchemaMessages(eval, path).OrderBy(m => m, StringComparer.Ordinal).ToList();
if (!eval.IsValid)
{
if (schemaMsgs.Count == 0)
schemaMsgs.Add($"error: {path} (root): schema validation failed");
errors.AddRange(schemaMsgs);
continue;
}
var perksNode = rootObj["perks"];
var tracksNode = rootObj["tracks"];
if (perksNode is not JsonObject perksObj || tracksNode is not JsonArray tracksArray)
{
errors.Add($"error: {path}: expected top-level 'perks' object and 'tracks' array");
continue;
}
var referencedPerkIdsInFile = new HashSet(StringComparer.Ordinal);
foreach (var (perkKey, perkNode) in perksObj)
{
if (perkNode is not JsonObject perkObj)
{
errors.Add($"error: {path}: perks[{perkKey}] must be an object");
continue;
}
var pid = (perkObj["id"] as JsonValue)?.GetValue();
if (pid is null)
continue;
if (perkKey != pid)
{
errors.Add($"error: {path}: perks map key '{perkKey}' must match PerkDef.id '{pid}'");
}
if (globalPerkIds.TryGetValue(pid, out var prevPath))
{
errors.Add($"error: duplicate perk id '{pid}' in {prevPath} and {path}");
}
else
{
globalPerkIds[pid] = path;
var displayName = (perkObj["displayName"] as JsonValue)?.GetValue() ?? string.Empty;
string? effectKind = null;
if (perkObj["effectKind"] is JsonValue ek)
effectKind = ek.GetValue();
perksById[pid] = new PerkDefRow(pid, displayName, effectKind);
}
}
for (var ti = 0; ti < tracksArray.Count; ti++)
{
if (tracksArray[ti] is not JsonObject trackObj)
{
errors.Add($"error: {path}: tracks[{ti}] must be an object");
continue;
}
var skillId = (trackObj["skillId"] as JsonValue)?.GetValue();
if (skillId is not null)
{
allTrackSkillIds.Add(skillId);
if (!knownSkillIds.Contains(skillId))
{
var known = string.Join(", ", knownSkillIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"));
errors.Add(
$"error: {path}: tracks[{ti}].skillId '{skillId}' is not a known SkillDef id (known: [{known}])");
}
if (tracksBySkillId.ContainsKey(skillId))
{
errors.Add($"error: duplicate mastery track for skillId '{skillId}' in catalog");
}
}
var tiersNode = trackObj["tiers"];
if (tiersNode is not JsonArray tiersArray)
continue;
var prevRequiredLevel = 0;
HashSet? prevTierBranchIds = null;
var tierIndexValues = new List();
var parsedTiers = new List();
for (var tierI = 0; tierI < tiersArray.Count; tierI++)
{
if (tiersArray[tierI] is not JsonObject tierObj)
continue;
var tierIndex = (tierObj["tierIndex"] as JsonValue)?.GetValue();
if (tierIndex is int idx)
tierIndexValues.Add(idx);
var requiredLevel = (tierObj["requiredLevel"] as JsonValue)?.GetValue();
if (requiredLevel is int rl)
{
if (rl <= prevRequiredLevel)
{
errors.Add(
$"error: {path}: tracks[{ti}].tiers[{tierI}].requiredLevel must be strictly greater than previous tier ({prevRequiredLevel})");
}
prevRequiredLevel = rl;
}
var branchesNode = tierObj["branches"];
if (branchesNode is not JsonArray branchesArray)
continue;
var tierBranchIds = new List();
var parsedBranches = new List();
for (var bi = 0; bi < branchesArray.Count; bi++)
{
if (branchesArray[bi] is not JsonObject branchObj)
continue;
var branchId = (branchObj["branchId"] as JsonValue)?.GetValue();
if (branchId is not null)
{
if (tierBranchIds.Contains(branchId, StringComparer.Ordinal))
{
errors.Add(
$"error: {path}: tracks[{ti}].tiers[{tierI}] duplicate branchId '{branchId}'");
}
tierBranchIds.Add(branchId);
}
string? branchDisplayName = null;
if (branchObj["displayName"] is JsonValue dn)
branchDisplayName = dn.GetValue();
var perkIdsList = new List();
if (branchObj["perkIds"] is JsonArray perkIdsArray)
{
foreach (var perkIdNode in perkIdsArray)
{
if (perkIdNode is not JsonValue pv)
continue;
var perkId = pv.GetValue();
if (!perksObj.ContainsKey(perkId))
{
errors.Add(
$"error: {path}: tracks[{ti}].tiers[{tierI}].branches[{bi}] references unknown perkId '{perkId}'");
}
if (globalReferencedPerkIds.TryGetValue(perkId, out var prevRef))
{
errors.Add($"error: duplicate perk id reference '{perkId}' in {prevRef} and {path}");
}
else
{
globalReferencedPerkIds[perkId] =
$"{path} tracks[{ti}].tiers[{tierI}].branches[{bi}]";
}
referencedPerkIdsInFile.Add(perkId);
perkIdsList.Add(perkId);
}
}
if (branchId is not null)
parsedBranches.Add(new MasteryBranchRow(branchId, branchDisplayName, perkIdsList));
}
var tierBranchSet = tierBranchIds.ToHashSet(StringComparer.Ordinal);
if (prevTierBranchIds is not null && !tierBranchSet.SetEquals(prevTierBranchIds))
{
var current = string.Join(", ", tierBranchSet.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"));
var previous = string.Join(", ", prevTierBranchIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"));
errors.Add(
$"error: {path}: tracks[{ti}].tiers[{tierI}] branchId set [{current}] must match previous tier [{previous}]");
}
if (tierBranchIds.Count > 0)
prevTierBranchIds = tierBranchSet;
if (tierIndex is int tIdx && requiredLevel is int req)
parsedTiers.Add(new MasteryTierRow(tIdx, req, parsedBranches));
}
var tierIndexError = TryGetTierIndexGateError(path, ti, tierIndexValues);
if (tierIndexError is not null)
errors.Add(tierIndexError);
if (skillId is not null && parsedTiers.Count > 0)
tracksBySkillId[skillId] = new MasteryTrackRow(skillId, parsedTiers);
}
foreach (var perkKey in perksObj.Select(p => p.Key))
{
if (!referencedPerkIdsInFile.Contains(perkKey))
{
errors.Add($"error: {path}: perks['{perkKey}'] is not referenced by any branch perkIds");
}
}
}
ThrowIfAny(errors);
var slice4 = PrototypeSlice4MasteryCatalogRules.TryGetSlice4GateError(allTrackSkillIds);
if (slice4 is not null)
{
errors.Add(slice4);
ThrowIfAny(errors);
}
logger.LogInformation(
"Loaded mastery catalog from {MasteryDirectory}: {TrackCount} track(s), {PerkCount} perk(s) across {CatalogFileCount} JSON catalog file(s).",
masteryDirectory,
tracksBySkillId.Count,
perksById.Count,
jsonFiles.Length);
return new MasteryCatalog(masteryDirectory, tracksBySkillId, perksById, jsonFiles.Length);
}
///
/// Server-only gate: unique tierIndex values must be exactly 1..N (NEO-46 kickoff).
/// Stable tierIndex keys branch picks and path-auto tier evaluation.
///
internal static string? TryGetTierIndexGateError(string filePath, int trackIndex, IReadOnlyList tierIndexValues)
{
if (tierIndexValues.Count == 0)
return null;
var seen = new HashSet();
foreach (var value in tierIndexValues)
{
if (!seen.Add(value))
{
return $"error: {filePath}: tracks[{trackIndex}] duplicate tierIndex {value}";
}
}
var expected = Enumerable.Range(1, tierIndexValues.Count).ToHashSet();
if (!seen.SetEquals(expected))
{
var sorted = string.Join(", ", seen.Order().Select(v => v.ToString()));
return
$"error: {filePath}: tracks[{trackIndex}] tierIndex values must be unique and sequential 1..{tierIndexValues.Count}, got [{sorted}]";
}
return null;
}
private static List CollectSchemaMessages(EvaluationResults eval, string filePath)
{
var sink = new List();
AppendSchemaMessages(eval, filePath, sink);
return sink;
}
private static void AppendSchemaMessages(EvaluationResults r, string filePath, List sink)
{
if (r.HasDetails)
{
foreach (var d in r.Details!)
AppendSchemaMessages(d, filePath, 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} {loc}: {kv.Key} — {kv.Value}");
}
}
private static void ThrowIfAny(List errors)
{
if (errors.Count == 0)
return;
var sb = new StringBuilder();
sb.AppendLine("Mastery catalog validation failed:");
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
sb.AppendLine(e);
throw new InvalidOperationException(sb.ToString().TrimEnd());
}
}