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