207 lines
7.3 KiB
C#
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());
|
|
}
|
|
}
|