neon-sprawl/server/NeonSprawl.Server/Game/Skills/ContentBackedSkillLevelCurv...

207 lines
7.3 KiB
C#

using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Json.Schema;
namespace NeonSprawl.Server.Game.Skills;
/// <summary>Level resolver backed by startup-loaded threshold rows from content files (NEO-39).</summary>
public sealed class ContentBackedSkillLevelCurve : ISkillLevelCurve
{
private readonly IReadOnlyList<LevelThresholdRow> levels;
internal ContentBackedSkillLevelCurve(IReadOnlyList<LevelThresholdRow> 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<string>();
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<LevelThresholdRow>();
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);
if (logger.IsEnabled(LogLevel.Information))
{
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<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("Level curve validation failed:");
foreach (var e in errors.OrderBy(static x => x, StringComparer.Ordinal))
sb.AppendLine(e);
throw new InvalidOperationException(sb.ToString().TrimEnd());
}
}