using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Json.Schema; namespace NeonSprawl.Server.Game.Skills; /// Level resolver backed by startup-loaded threshold rows from content files (NEO-39). public sealed class ContentBackedSkillLevelCurve : ISkillLevelCurve { private readonly IReadOnlyList levels; internal ContentBackedSkillLevelCurve(IReadOnlyList levels) { if (levels.Count == 0) throw new InvalidOperationException("Level curve must contain at least one level row."); this.levels = levels; } public int LevelFromTotalXp(int totalXp) { var xp = Math.Max(0, totalXp); var level = 1; foreach (var row in levels) { if (xp < row.RequiredXp) break; level = row.Level; } return level; } internal static ContentBackedSkillLevelCurve Load( string skillsDirectory, string curveFilePath, string schemaPath, ILogger logger) { var errors = new List(); var fullSkillsDirectory = Path.GetFullPath(skillsDirectory); var fullCurvePath = Path.GetFullPath(curveFilePath); var fullSchemaPath = Path.GetFullPath(schemaPath); if (!Directory.Exists(fullSkillsDirectory)) errors.Add($"error: missing directory {fullSkillsDirectory}"); if (!File.Exists(fullCurvePath)) errors.Add($"error: missing level curve file {fullCurvePath}"); if (!File.Exists(fullSchemaPath)) errors.Add($"error: missing level curve schema {fullSchemaPath}"); ThrowIfAny(errors); JsonDocument doc; JsonNode? rootNode; try { var json = File.ReadAllText(fullCurvePath); doc = JsonDocument.Parse(json); rootNode = JsonNode.Parse(json); } catch (JsonException ex) { throw new InvalidOperationException($"Level curve file is invalid JSON: {fullCurvePath}: {ex.Message}"); } using (doc) { var schema = JsonSchema.FromText(File.ReadAllText(fullSchemaPath)); if (rootNode is null) { errors.Add($"error: {fullCurvePath}: failed to parse JSON document"); } else { var eval = schema.Evaluate(rootNode, new EvaluationOptions { OutputFormat = OutputFormat.List }); errors.AddRange(CollectSchemaMessages(eval, fullCurvePath)); } if (doc.RootElement.ValueKind != JsonValueKind.Object) errors.Add($"error: {fullCurvePath}: expected top-level object"); if (!doc.RootElement.TryGetProperty("schemaVersion", out var schemaVersion) || schemaVersion.ValueKind != JsonValueKind.Number || !schemaVersion.TryGetInt32(out var sv) || sv != 1) { errors.Add($"error: {fullCurvePath}: expected schemaVersion = 1"); } if (!doc.RootElement.TryGetProperty("levels", out var levelsNode) || levelsNode.ValueKind != JsonValueKind.Array) { errors.Add($"error: {fullCurvePath}: expected top-level 'levels' array"); ThrowIfAny(errors); } var parsedRows = new List(); var rowIndex = 0; var prevLevel = 0; var prevXp = -1; foreach (var row in levelsNode.EnumerateArray()) { if (row.ValueKind != JsonValueKind.Object) { errors.Add($"error: {fullCurvePath}: levels[{rowIndex}] must be an object"); rowIndex++; continue; } if (!row.TryGetProperty("level", out var levelNode) || levelNode.ValueKind != JsonValueKind.Number || !levelNode.TryGetInt32(out var level) || level < 1) { errors.Add($"error: {fullCurvePath}: levels[{rowIndex}].level must be an integer >= 1"); rowIndex++; continue; } if (!row.TryGetProperty("requiredXp", out var xpNode) || xpNode.ValueKind != JsonValueKind.Number || !xpNode.TryGetInt32(out var requiredXp) || requiredXp < 0) { errors.Add($"error: {fullCurvePath}: levels[{rowIndex}].requiredXp must be an integer >= 0"); rowIndex++; continue; } if (row.TryGetProperty("level", out _) && row.TryGetProperty("requiredXp", out _)) { if (level <= prevLevel) errors.Add($"error: {fullCurvePath}: levels[{rowIndex}].level must be strictly increasing"); if (requiredXp <= prevXp) errors.Add($"error: {fullCurvePath}: levels[{rowIndex}].requiredXp must be strictly increasing"); prevLevel = level; prevXp = requiredXp; parsedRows.Add(new LevelThresholdRow(level, requiredXp)); } rowIndex++; } if (parsedRows.Count == 0) errors.Add($"error: {fullCurvePath}: levels must contain at least one valid row"); else if (parsedRows[0].Level != 1 || parsedRows[0].RequiredXp != 0) errors.Add($"error: {fullCurvePath}: first level row must be {{\"level\":1,\"requiredXp\":0}}"); ThrowIfAny(errors); logger.LogInformation( "Loaded level curve from {CurvePath} with {LevelCount} thresholds.", fullCurvePath, parsedRows.Count); return new ContentBackedSkillLevelCurve(parsedRows); } } internal sealed record LevelThresholdRow(int Level, int RequiredXp); 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("Level curve validation failed:"); foreach (var e in errors.OrderBy(static x => x, StringComparer.Ordinal)) sb.AppendLine(e); throw new InvalidOperationException(sb.ToString().TrimEnd()); } }