using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Json.Schema; using Microsoft.Extensions.Logging; namespace NeonSprawl.Server.Game.Gathering; /// Loads and validates content/resource-nodes/*.json using the same rules as scripts/validate_content.py (NEO-58). public static class ResourceNodeCatalogLoader { /// Loads catalogs from disk or throws with actionable messages. public static ResourceNodeCatalog Load( string resourceNodesDirectory, string nodeSchemaPath, string yieldSchemaPath, IReadOnlySet knownItemIds, ILogger logger) { resourceNodesDirectory = Path.GetFullPath(resourceNodesDirectory); nodeSchemaPath = Path.GetFullPath(nodeSchemaPath); yieldSchemaPath = Path.GetFullPath(yieldSchemaPath); var errors = new List(); if (!File.Exists(nodeSchemaPath)) errors.Add($"error: missing schema file {nodeSchemaPath}"); if (!File.Exists(yieldSchemaPath)) errors.Add($"error: missing schema file {yieldSchemaPath}"); if (!Directory.Exists(resourceNodesDirectory)) errors.Add($"error: missing directory {resourceNodesDirectory}"); if (errors.Count > 0) ThrowIfAny(errors); var nodeJsonFiles = Directory.GetFiles(resourceNodesDirectory, "*_resource_nodes.json", SearchOption.TopDirectoryOnly) .OrderBy(p => p, StringComparer.Ordinal) .ToArray(); if (nodeJsonFiles.Length == 0) errors.Add($"error: no *_resource_nodes.json files under {resourceNodesDirectory}"); var yieldJsonFiles = Directory.GetFiles(resourceNodesDirectory, "*_resource_yields.json", SearchOption.TopDirectoryOnly) .OrderBy(p => p, StringComparer.Ordinal) .ToArray(); if (yieldJsonFiles.Length == 0) errors.Add($"error: no *_resource_yields.json files under {resourceNodesDirectory}"); if (errors.Count > 0) ThrowIfAny(errors); var nodeSchema = JsonSchema.FromText(File.ReadAllText(nodeSchemaPath)); var yieldSchema = JsonSchema.FromText(File.ReadAllText(yieldSchemaPath)); var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; var nodeDefIdToSourceFile = new Dictionary(StringComparer.Ordinal); var idToGatherLens = new Dictionary(StringComparer.Ordinal); var nodes = new Dictionary(StringComparer.Ordinal); foreach (var path in nodeJsonFiles) { JsonNode? root; try { root = JsonNode.Parse(File.ReadAllText(path)); } catch (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 schemaVersionNode = rootObj["schemaVersion"]; if (schemaVersionNode is not JsonValue schemaVersionValue || !schemaVersionValue.TryGetValue(out var schemaVersion) || schemaVersion != 1) { var got = schemaVersionNode?.ToJsonString() ?? "null"; errors.Add($"error: {path}: expected schemaVersion 1, got {got}"); continue; } var nodesNode = rootObj["nodes"]; if (nodesNode is not JsonArray nodesArray) { errors.Add($"error: {path}: expected top-level 'nodes' array"); continue; } for (var i = 0; i < nodesArray.Count; i++) { var row = nodesArray[i]; if (row is not JsonObject rowObj) { errors.Add($"error: {path}: nodes[{i}] must be an object"); continue; } var eval = nodeSchema.Evaluate(rowObj, evalOptions); var schemaMsgs = CollectSchemaMessages(eval, path, "nodes", i).OrderBy(m => m, StringComparer.Ordinal).ToList(); if (!eval.IsValid) { if (schemaMsgs.Count == 0) schemaMsgs.Add($"error: {path} nodes[{i}] (root): schema validation failed"); errors.AddRange(schemaMsgs); } var rowSchemaErrors = schemaMsgs.Count; var nodeDefId = (rowObj["nodeDefId"] as JsonValue)?.GetValue(); if (nodeDefId is not null && rowSchemaErrors == 0) { if (nodeDefIdToSourceFile.TryGetValue(nodeDefId, out var prevPath)) { errors.Add($"error: duplicate nodeDefId '{nodeDefId}' in {prevPath} and {path}"); continue; } nodeDefIdToSourceFile[nodeDefId] = path; var gatherLens = (rowObj["gatherLens"] as JsonValue)?.GetValue(); if (gatherLens is not null) idToGatherLens[nodeDefId] = gatherLens; nodes[nodeDefId] = ParseNodeRow(rowObj); } } } ThrowIfAny(errors); var slice2Node = PrototypeSlice2ResourceNodeCatalogRules.TryGetSlice2NodeGateError( nodeDefIdToSourceFile, idToGatherLens); if (slice2Node is not null) { errors.Add(slice2Node); ThrowIfAny(errors); } var knownNodeIds = nodeDefIdToSourceFile.Keys.ToHashSet(StringComparer.Ordinal); var yieldNodeDefIdToSourceFile = new Dictionary(StringComparer.Ordinal); var yieldItemIds = new HashSet(StringComparer.Ordinal); var yields = new Dictionary(StringComparer.Ordinal); foreach (var path in yieldJsonFiles) { JsonNode? root; try { root = JsonNode.Parse(File.ReadAllText(path)); } catch (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 schemaVersionNode = rootObj["schemaVersion"]; if (schemaVersionNode is not JsonValue schemaVersionValue || !schemaVersionValue.TryGetValue(out var schemaVersion) || schemaVersion != 1) { var got = schemaVersionNode?.ToJsonString() ?? "null"; errors.Add($"error: {path}: expected schemaVersion 1, got {got}"); continue; } var yieldsNode = rootObj["yields"]; if (yieldsNode is not JsonArray yieldsArray) { errors.Add($"error: {path}: expected top-level 'yields' array"); continue; } for (var i = 0; i < yieldsArray.Count; i++) { var row = yieldsArray[i]; if (row is not JsonObject rowObj) { errors.Add($"error: {path}: yields[{i}] must be an object"); continue; } var eval = yieldSchema.Evaluate(rowObj, evalOptions); var schemaMsgs = CollectSchemaMessages(eval, path, "yields", i).OrderBy(m => m, StringComparer.Ordinal).ToList(); if (!eval.IsValid) { if (schemaMsgs.Count == 0) schemaMsgs.Add($"error: {path} yields[{i}] (root): schema validation failed"); errors.AddRange(schemaMsgs); } var rowSchemaErrors = schemaMsgs.Count; var nodeDefId = (rowObj["nodeDefId"] as JsonValue)?.GetValue(); var itemId = (rowObj["itemId"] as JsonValue)?.GetValue(); if (nodeDefId is not null && rowSchemaErrors == 0) { if (yieldNodeDefIdToSourceFile.TryGetValue(nodeDefId, out var prevPath)) { errors.Add($"error: duplicate yield nodeDefId '{nodeDefId}' in {prevPath} and {path}"); continue; } yieldNodeDefIdToSourceFile[nodeDefId] = path; if (!knownNodeIds.Contains(nodeDefId)) { errors.Add( $"error: {path} yields[{i}]: nodeDefId '{nodeDefId}' is not defined in resource node catalogs"); continue; } yields[nodeDefId] = ParseYieldRow(rowObj); } if (itemId is not null && rowSchemaErrors == 0) { yieldItemIds.Add(itemId); if (!knownItemIds.Contains(itemId)) { errors.Add( $"error: {path} yields[{i}]: itemId '{itemId}' is not in item catalogs"); } } } } ThrowIfAny(errors); var slice2Yield = PrototypeSlice2ResourceNodeCatalogRules.TryGetSlice2YieldGateError( yieldNodeDefIdToSourceFile.Keys.ToHashSet(StringComparer.Ordinal), yieldItemIds); if (slice2Yield is not null) { errors.Add(slice2Yield); ThrowIfAny(errors); } if (logger.IsEnabled(LogLevel.Information)) { logger.LogInformation( "Loaded resource-node catalog from {ResourceNodesDirectory}: {NodeCount} node(s) across {NodeFileCount} node catalog file(s), {YieldCount} yield row(s) across {YieldFileCount} yield catalog file(s).", resourceNodesDirectory, nodes.Count, nodeJsonFiles.Length, yields.Count, yieldJsonFiles.Length); } return new ResourceNodeCatalog( resourceNodesDirectory, nodes, yields, nodeJsonFiles.Length, yieldJsonFiles.Length); } private static ResourceNodeDefRow ParseNodeRow(JsonObject rowObj) { var nodeDefId = (rowObj["nodeDefId"] as JsonValue)!.GetValue(); var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); var gatherLens = (rowObj["gatherLens"] as JsonValue)!.GetValue(); var maxGathers = (rowObj["maxGathers"] as JsonValue)!.GetValue(); return new ResourceNodeDefRow(nodeDefId, displayName, gatherLens, maxGathers); } private static ResourceYieldRow ParseYieldRow(JsonObject rowObj) { var nodeDefId = (rowObj["nodeDefId"] as JsonValue)!.GetValue(); var itemId = (rowObj["itemId"] as JsonValue)!.GetValue(); var quantity = (rowObj["quantity"] as JsonValue)!.GetValue(); return new ResourceYieldRow(nodeDefId, itemId, quantity); } private static List CollectSchemaMessages(EvaluationResults eval, string filePath, string arrayName, int index) { var sink = new List(); AppendSchemaMessages(eval, filePath, arrayName, index, sink); return sink; } private static void AppendSchemaMessages( EvaluationResults r, string filePath, string arrayName, int index, List sink) { if (r.HasDetails) { foreach (var d in r.Details!) AppendSchemaMessages(d, filePath, arrayName, index, 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} {arrayName}[{index}] {loc}: {kv.Key} — {kv.Value}"); } } private static void ThrowIfAny(List errors) { if (errors.Count == 0) return; var sb = new StringBuilder(); sb.AppendLine("Resource node catalog validation failed:"); foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal)) sb.AppendLine(e); throw new InvalidOperationException(sb.ToString().TrimEnd()); } }