using System; using System.Collections.Generic; using System.Text.Json; using Cthangover.Core.Mods; using Cthangover.Core.Utils; namespace Cthangover.Validator { /// /// Phase: Contracts. Walks every mod's JSON entities the same way the /// runtime resolver does — every .json file is opened and routed by /// its top-level "Kind", with ${include} expansion applied — /// and records declarations and references in the shared graph. Files /// without a kind (manifests, collections, live2d models, scene /// definitions, patches) are not entity data and are skipped; scene and /// interactive definitions plus raw asset files keep their directory-based /// collection. It also validates manifest ui/main_menu /// scene_path entries against the owning mod's files and turns /// ui[].scene into a scene reference. /// /// Extraction is intentionally schema-light (raw /// plus case-insensitive reads) so it keeps working when DTOs gain fields /// and so it can run on the background thread without touching Godot types /// that some DTOs embed. /// public class JsonEntityStrategy : IValidationStrategy { private static readonly HashSet EffectExtensions = new(StringComparer.OrdinalIgnoreCase) { ".tscn", ".scn" }; public string Id => "json_entities"; public ValidationPhase Phase => ValidationPhase.Contracts; public int Order => 100; public ValidationFeature Feature => ValidationFeature.Json; /// Walks every mod's JSON entities, scene definitions, interactives and texture assets, recording declarations and references into the fact graph. public void Validate(ValidationContext context) { foreach (var mod in context.Mods) { context.ThrowIfCancellationRequested(); var provider = mod.FileProvider; if (provider == null) continue; CollectEntities(context, mod, provider); CollectSceneDefinitions(context, mod, provider); CollectEnvelopes(context, mod, provider, "interactives", true, ValidationKind.Interactive); var textureExtensions = ModConfig.Instance.GetTextureExtensionSet(); CollectAssets(context, mod, provider, "backgrounds", ValidationKind.Background, textureExtensions); CollectAssets(context, mod, provider, "avatars", ValidationKind.Avatar, textureExtensions); var audioExtensions = new HashSet(ModConfig.Instance.AudioExtensions, StringComparer.OrdinalIgnoreCase); CollectAssets(context, mod, provider, "music", ValidationKind.Music, audioExtensions); CollectAssets(context, mod, provider, "sounds", ValidationKind.Sound, audioExtensions); CollectAssets(context, mod, provider, "effects", ValidationKind.Effect, EffectExtensions); ValidateManifestEntries(context, mod, provider); } } /// /// Scans every JSON file of the mod and routes it by its declared /// "Kind", mirroring the runtime EntityResolver. The kind /// resolves to an that records the /// declarations, declared keys and references for that entity type — so /// an action can no longer be mistaken for a character just because both /// lived in the same directory, and a new kind is a new strategy class /// instead of a new switch case. /// private static void CollectEntities(ValidationContext context, IModInfo mod, IModFileProvider provider) { foreach (var file in ModFileReader.ListFiles(provider, string.Empty, true)) { context.ThrowIfCancellationRequested(); if (!file.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) continue; // The reserved collections/ folder holds scheme documents whose // "kind" (game/component/toolset) is not an entity discriminator. if (file.StartsWith(ModCollectionStore.ProviderDirectory + "/", StringComparison.OrdinalIgnoreCase)) continue; var document = ReadDocument(context, mod, file); if (document == null) continue; using (document) { var kind = JsonReadHelper.GetString(document.RootElement, "Kind", "kind"); if (string.IsNullOrWhiteSpace(kind)) continue; var envelope = JsonReadHelper.GetProperty(document.RootElement, "Items"); if (!JsonReadHelper.IsArray(envelope)) continue; var strategy = EntityKindStrategyRegistry.Resolve(kind); if (strategy == null) { context.AddWarning("UNKNOWN_ENTITY_KIND", $"unknown entity kind '{kind}'", mod.Id, file, 0); continue; } strategy.Collect(context, mod, file, envelope.Value); } } } private static void CollectSceneDefinitions(ValidationContext context, IModInfo mod, IModFileProvider provider) { foreach (var file in ModFileReader.ListFiles(provider, "scenes", true)) { context.ThrowIfCancellationRequested(); if (!file.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) continue; var document = ReadDocument(context, mod, file); if (document == null) continue; using (document) { var name = JsonReadHelper.GetString(document.RootElement, "name", "Name"); if (string.IsNullOrWhiteSpace(name)) { context.AddWarning("SCHEMA_MISSING_FIELD", "scene definition has no 'name'", mod.Id, file); continue; } context.Graph.AddDeclaration(ValidationKind.Scene, name, mod.Id, file, 0); CollectBackgroundReferences(context, mod, document.RootElement, file); } } } private static void CollectEnvelopes( ValidationContext context, IModInfo mod, IModFileProvider provider, string directory, bool recursive, string kind) { var documents = new List(); var items = new List(); try { OpenEnvelopes(context, mod, provider, directory, recursive, documents, items); foreach (var item in items) { var id = JsonReadHelper.GetString(item, "Id", "ID", "id"); context.Graph.AddDeclaration(kind, id, mod.Id, directory, 0); } } finally { DisposeAll(documents); } } private static void CollectAssets( ValidationContext context, IModInfo mod, IModFileProvider provider, string directory, string kind, HashSet extensions) { foreach (var file in ModFileReader.ListFiles(provider, directory, true)) { context.ThrowIfCancellationRequested(); if (!ModFileReader.HasExtension(file, extensions)) continue; var prefixLength = directory.Length + 1; var relative = file.Length > prefixLength ? file.Substring(prefixLength) : file; var dot = relative.LastIndexOf('.'); if (dot <= 0) continue; context.Graph.AddDeclaration(kind, relative.Substring(0, dot), mod.Id, file, 0); } } private static void ValidateManifestEntries(ValidationContext context, IModInfo mod, IModFileProvider provider) { var manifest = (mod as ModInfo)?.Manifest; if (manifest == null) return; if (manifest.Ui != null) { foreach (var entry in manifest.Ui) { if (entry == null) continue; if (!string.IsNullOrWhiteSpace(entry.ScenePath) && !provider.FileExists(entry.ScenePath)) context.AddError("REF_NOT_FOUND", $"ui scene_path '{entry.ScenePath}' not found in mod '{mod.Id}'", mod.Id, "manifest.json", 0, "fix scene_path or add the scene file"); if (!string.IsNullOrWhiteSpace(entry.Scene) && !string.Equals(entry.Scene, "shell", StringComparison.OrdinalIgnoreCase)) context.Graph.AddReference(ValidationKind.Scene, entry.Scene, mod.Id, "manifest.json", 0); } } if (manifest.MainMenu != null) { foreach (var entry in manifest.MainMenu) { if (entry == null || string.IsNullOrWhiteSpace(entry.ScenePath)) continue; if (!provider.FileExists(entry.ScenePath)) context.AddError("REF_NOT_FOUND", $"main_menu scene_path '{entry.ScenePath}' not found in mod '{mod.Id}'", mod.Id, "manifest.json", 0, "fix scene_path or add the scene file"); } } } private static void OpenEnvelopes( ValidationContext context, IModInfo mod, IModFileProvider provider, string directory, bool recursive, List documents, List items) { foreach (var file in ModFileReader.ListFiles(provider, directory, recursive)) { context.ThrowIfCancellationRequested(); if (!file.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) continue; var document = ReadDocument(context, mod, file); if (document == null) continue; var envelope = JsonReadHelper.GetProperty(document.RootElement, "Items"); if (!JsonReadHelper.IsArray(envelope)) { document.Dispose(); continue; } documents.Add(document); foreach (var item in envelope.Value.EnumerateArray()) items.Add(item); } } private static JsonDocument ReadDocument(ValidationContext context, IModInfo mod, string file) { string text; try { text = ModFileReader.ReadResolved(mod.FileProvider, file); } catch (Exception ex) { context.AddError("JSON_PARSE_ERROR", $"include resolution failed: {ex.Message}", mod.Id, file); return null; } if (text == null) return null; try { return JsonDocument.Parse(text, JsonOptionsProvider.DocumentOptions); } catch (JsonException ex) { context.AddError("JSON_PARSE_ERROR", $"invalid JSON: {ex.Message}", mod.Id, file); return null; } } private static void CollectBackgroundReferences(ValidationContext context, IModInfo mod, JsonElement root, string file) { var background = JsonReadHelper.GetProperty(root, "defaultBackground", "DefaultBackground"); if (background == null) return; if (background.Value.ValueKind == JsonValueKind.String) { context.Graph.AddReference(ValidationKind.Background, background.Value.GetString(), mod.Id, file, 0); return; } if (background.Value.ValueKind != JsonValueKind.Array) return; foreach (var entry in background.Value.EnumerateArray()) { if (entry.ValueKind == JsonValueKind.String) context.Graph.AddReference(ValidationKind.Background, entry.GetString(), mod.Id, file, 0); } } private static void DisposeAll(List documents) { foreach (var document in documents) document.Dispose(); } } }