362 lines
14 KiB
C#
362 lines
14 KiB
C#
using System.Text;
|
|
using System.Text.Json.Nodes;
|
|
using Json.Schema;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace NeonSprawl.Server.Game.Mastery;
|
|
|
|
/// <summary>Loads and validates <c>content/mastery/*_mastery.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-46).</summary>
|
|
public static class MasteryCatalogLoader
|
|
{
|
|
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
|
|
public static MasteryCatalog Load(
|
|
string masteryDirectory,
|
|
string schemaPath,
|
|
IReadOnlySet<string> knownSkillIds,
|
|
ILogger logger)
|
|
{
|
|
masteryDirectory = Path.GetFullPath(masteryDirectory);
|
|
schemaPath = Path.GetFullPath(schemaPath);
|
|
|
|
var errors = new List<string>();
|
|
|
|
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);
|
|
|
|
var jsonFiles = Directory.GetFiles(masteryDirectory, "*_mastery.json", SearchOption.TopDirectoryOnly)
|
|
.OrderBy(p => p, StringComparer.Ordinal)
|
|
.ToArray();
|
|
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<string, string>(StringComparer.Ordinal);
|
|
var globalReferencedPerkIds = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
var allTrackSkillIds = new List<string>();
|
|
var perksById = new Dictionary<string, PerkDefRow>(StringComparer.Ordinal);
|
|
var tracksBySkillId = new Dictionary<string, MasteryTrackRow>(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<string>(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<string>();
|
|
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>() ?? string.Empty;
|
|
string? effectKind = null;
|
|
if (perkObj["effectKind"] is JsonValue ek)
|
|
effectKind = ek.GetValue<string>();
|
|
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<string>();
|
|
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<string>? prevTierBranchIds = null;
|
|
var tierIndexValues = new List<int>();
|
|
|
|
var parsedTiers = new List<MasteryTierRow>();
|
|
|
|
for (var tierI = 0; tierI < tiersArray.Count; tierI++)
|
|
{
|
|
if (tiersArray[tierI] is not JsonObject tierObj)
|
|
continue;
|
|
|
|
var tierIndex = (tierObj["tierIndex"] as JsonValue)?.GetValue<int>();
|
|
if (tierIndex is int idx)
|
|
tierIndexValues.Add(idx);
|
|
|
|
var requiredLevel = (tierObj["requiredLevel"] as JsonValue)?.GetValue<int>();
|
|
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<string>();
|
|
var parsedBranches = new List<MasteryBranchRow>();
|
|
|
|
for (var bi = 0; bi < branchesArray.Count; bi++)
|
|
{
|
|
if (branchesArray[bi] is not JsonObject branchObj)
|
|
continue;
|
|
|
|
var branchId = (branchObj["branchId"] as JsonValue)?.GetValue<string>();
|
|
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<string>();
|
|
|
|
var perkIdsList = new List<string>();
|
|
if (branchObj["perkIds"] is JsonArray perkIdsArray)
|
|
{
|
|
foreach (var perkIdNode in perkIdsArray)
|
|
{
|
|
if (perkIdNode is not JsonValue pv)
|
|
continue;
|
|
|
|
var perkId = pv.GetValue<string>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-only gate: unique tierIndex values must be exactly 1..N (NEO-46 kickoff).
|
|
/// Stable <c>tierIndex</c> keys <see cref="PerkUnlockEngine"/> branch picks and path-auto tier evaluation.
|
|
/// </summary>
|
|
internal static string? TryGetTierIndexGateError(string filePath, int trackIndex, IReadOnlyList<int> tierIndexValues)
|
|
{
|
|
if (tierIndexValues.Count == 0)
|
|
return null;
|
|
|
|
var seen = new HashSet<int>();
|
|
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<string> CollectSchemaMessages(EvaluationResults eval, string filePath)
|
|
{
|
|
var sink = new List<string>();
|
|
AppendSchemaMessages(eval, filePath, sink);
|
|
return sink;
|
|
}
|
|
|
|
private static void AppendSchemaMessages(EvaluationResults r, string filePath, List<string> 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<string> 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());
|
|
}
|
|
}
|