commit e4fa77508c0adce8d60c8be189c290d8b923b11f Author: iisaev Date: Sun Oct 18 12:41:41 2026 +0300 init mod diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..7588518 --- /dev/null +++ b/locale/en.properties @@ -0,0 +1,11 @@ +mod_validator_name = Validator +mod_validator_desc = Static validation of mods without running the game: manifest schema, entity strategies (characters, items, quests, skills, recipes, actions), cross references, depends, scenario commands and scene patches, with a text report. + +validator/enabled = Enabled +validator/run_on_start = Run on start +validator/parse_csharp = Parse C# +validator/parse_gdscript = Parse GDScript +validator/parse_scenarios = Parse scenarios +validator/write_report = Write report +validator/report_path = Report path +validator/fail_on_error = Block mods on error diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..eb199ed --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,11 @@ +mod_validator_name = Валидатор +mod_validator_desc = Статическая проверка модов без запуска игры: схема манифеста, стратегии сущностей (персонажи, предметы, квесты, умения, рецепты, действия), перекрёстные ссылки, depends, команды сценариев и патчи сцен, текстовый отчёт. + +validator/enabled = Включён +validator/run_on_start = Запускать при старте +validator/parse_csharp = Разбирать C# +validator/parse_gdscript = Разбирать GDScript +validator/parse_scenarios = Разбирать сценарии +validator/write_report = Писать отчёт +validator/report_path = Путь отчёта +validator/fail_on_error = Блокировать моды при ошибке diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..9594bd4 --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "validator", + "name": "Validator", + "version": "0.1.0", + "author": "ct", + "description": "Static validation of mods without running the game: manifest schema, entity strategies (characters, items, quests, skills, recipes, actions), cross references, depends, scenario commands and scene patches, with an optional text report.", + "category": "debug", + "sources": ["src/**/*.cs"], + "depends": ["dialog_base>=1", "interactive_base>=1"] +} diff --git a/src/ActionArgumentReferences.cs b/src/ActionArgumentReferences.cs new file mode 100644 index 0000000..71f8173 --- /dev/null +++ b/src/ActionArgumentReferences.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; + +namespace Cthangover.Validator +{ + /// + /// Declarative map "scenario action → named argument → " + /// for actions whose arguments reference declared content. The runtime + /// IScenarioAction contract carries no parameter metadata, so the + /// validator keeps the knowledge here instead: each entry names one action + /// argument and the entity kind it must resolve to. Scenario validation adds + /// a graph reference per literal value (see + /// ), and + /// reports a missing declaration with the + /// exact scenario file and line — the static answer to the typo class the + /// runtime factories can only log. + /// + /// The table intentionally covers only actions with literal content ids + /// today (character.add_to_party / character.send_notification / + /// character.add_skill / battle.init). Extend it when a new action starts + /// accepting content references; the mechanics need no code changes. + /// Comma-separated lists (battle.init enemies=) and runtime-computed values + /// (the ${name} marker) are supported by the caller, not here. + /// + public static class ActionArgumentReferences + { + private static readonly Dictionary CharacterType = new Dictionary + { + ["type"] = ValidationKind.Character, + }; + + private static readonly Dictionary BattleInit = new Dictionary + { + ["scene"] = ValidationKind.Scene, + ["enemies"] = ValidationKind.Character, + ["quest_id"] = ValidationKind.Quest, + }; + + private static readonly Dictionary AddSkill = new Dictionary + { + ["id"] = ValidationKind.Skill, + }; + + private static readonly Dictionary> Table = + new Dictionary>(System.StringComparer.OrdinalIgnoreCase) + { + ["character.add_to_party"] = CharacterType, + ["character.send_notification"] = CharacterType, + ["character.add_skill"] = AddSkill, + ["battle.init"] = BattleInit, + }; + + /// + /// Returns the argument→kind map for an action, or false when the action + /// carries no known content references. Lookup is case-insensitive to + /// match the reference check for the action name itself. + /// + public static bool TryGetKinds(string actionName, out IReadOnlyDictionary kinds) + { + return Table.TryGetValue(actionName, out kinds); + } + } +} diff --git a/src/ActionEntityStrategy.cs b/src/ActionEntityStrategy.cs new file mode 100644 index 0000000..c333ed8 --- /dev/null +++ b/src/ActionEntityStrategy.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using Cthangover.Core.Mods; + +namespace Cthangover.Validator +{ + /// + /// Phase: Contracts. Collects action declarations, their declared object + /// keys and the property-bag keys from their Values list. Actions used + /// to be distinguished from characters by a heuristic on the shared + /// characters/ directory; the kind makes that explicit. + /// + public class ActionEntityStrategy : EntityKindStrategy + { + public override string Kind => ValidationKind.Action; + + protected override void CollectEntity(ValidationContext context, IModInfo mod, string file, string id, JsonElement item) + { + base.CollectEntity(context, mod, file, id, item); + EntityFactCollector.CollectObjectKeys(context.Graph, item); + + var values = JsonReadHelper.GetProperty(item, "Values"); + if (JsonReadHelper.IsArray(values)) + EntityFactCollector.CollectActionKeys(context.Graph, values.Value); + } + } +} diff --git a/src/CSharpPropertyStrategy.cs b/src/CSharpPropertyStrategy.cs new file mode 100644 index 0000000..5ff8476 --- /dev/null +++ b/src/CSharpPropertyStrategy.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using Cthangover.Core.Mods; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cthangover.Validator +{ + /// + /// Phase: Contracts. Parses every mod's declared C# sources with Roslyn and + /// extracts typed property accesses (GetInt("Turn"), + /// SetFloat("Attack", ...)) from DataBlob, PropertyData + /// and ActionCharacter, which share the same method names. The + /// analysis is syntactic on purpose: it needs no metadata references, so it + /// runs on the background thread and never triggers assembly loading. + /// + /// Findings: non-literal keys (DYNAMIC_KEY, info), the same key used + /// with different value types (TYPE_CONFLICT, error), reads of keys + /// that are neither declared in JSON nor written anywhere + /// (READ_UNDECLARED / READ_UNDECLARED_WITH_DEFAULT), and writes + /// that no code ever reads (WRITE_UNUSED, info). + /// + public class CSharpPropertyStrategy : IValidationStrategy + { + private static readonly Dictionary TypeByMethod = new(StringComparer.Ordinal) + { + { "GetInt", "int" }, + { "SetInt", "int" }, + { "GetFloat", "float" }, + { "SetFloat", "float" }, + { "GetString", "string" }, + { "SetString", "string" }, + { "GetStr", "string" }, + { "SetStr", "string" }, + { "GetBool", "bool" }, + { "SetBool", "bool" }, + { "GetLong", "long" }, + { "SetLong", "long" }, + }; + + public string Id => "csharp_properties"; + public ValidationPhase Phase => ValidationPhase.Contracts; + public int Order => 200; + public ValidationFeature Feature => ValidationFeature.CSharp; + + /// Scans mod C# sources for DataBlob key usages and checks the keys against the persisted blobs, reporting unknown or mistyped keys. + public void Validate(ValidationContext context) + { + var usages = new List(); + var literalCounts = new Dictionary(StringComparer.Ordinal); + + foreach (var mod in context.Mods) + { + context.ThrowIfCancellationRequested(); + + foreach (var file in CollectSources(mod)) + { + context.ThrowIfCancellationRequested(); + ParseSource(mod.Id, file.Path, file.Source, usages, literalCounts); + } + } + + ReportDynamicKeys(context, usages); + ReportTypeConflicts(context, usages); + ReportUndeclaredReads(context, usages); + ReportUnusedWrites(context, usages, literalCounts); + } + + private static List<(string Path, string Source)> CollectSources(IModInfo mod) + { + var result = new List<(string Path, string Source)>(); + var provider = mod.FileProvider; + if (provider == null) + return result; + + var patterns = (mod as ModInfo)?.Manifest?.Sources; + + if (patterns != null && patterns.Count > 0) + { + foreach (var pattern in patterns) + { + var prefix = pattern; + var suffix = "*.cs"; + var separator = pattern.LastIndexOf('/'); + if (separator >= 0) + { + prefix = pattern.Substring(0, separator); + suffix = pattern.Substring(separator + 1); + } + + foreach (var file in ModFileReader.ListFiles(provider, prefix, true)) + { + if (!MatchesSource(file, suffix)) + continue; + + var source = provider.ReadFileText(file); + if (source != null) + result.Add((file, source)); + } + } + } + + if (result.Count == 0) + { + foreach (var file in ModFileReader.ListFiles(provider, string.Empty, true)) + { + if (!file.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + continue; + + var source = provider.ReadFileText(file); + if (source != null) + result.Add((file, source)); + } + } + + return result; + } + + private static bool MatchesSource(string file, string suffix) + { + if (!file.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + return false; + + if (suffix == "*" || suffix == "*.cs") + return true; + + if (suffix.StartsWith("*.", StringComparison.Ordinal)) + return file.EndsWith(suffix.Substring(1), StringComparison.OrdinalIgnoreCase); + + return file.EndsWith(suffix, StringComparison.OrdinalIgnoreCase); + } + + private static void ParseSource( + string modId, + string path, + string source, + List usages, + Dictionary literalCounts) + { + var tree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(), path); + var root = tree.GetRoot(); + + foreach (var token in root.DescendantTokens()) + { + if (!token.IsKind(SyntaxKind.StringLiteralToken)) + continue; + + var text = token.ValueText; + literalCounts.TryGetValue(text, out var count); + literalCounts[text] = count + 1; + } + + foreach (var node in root.DescendantNodes()) + { + if (node is not InvocationExpressionSyntax invocation) + continue; + + if (invocation.Expression is not MemberAccessExpressionSyntax member) + continue; + + var method = member.Name.Identifier.ValueText; + if (!TypeByMethod.TryGetValue(method, out var type)) + continue; + + var arguments = invocation.ArgumentList.Arguments; + if (arguments.Count == 0) + continue; + + var key = TryGetLiteralKey(arguments[0].Expression); + + // A helper of the shape GetString(value, "Name", "ID", ...) has + // nothing to do with property bags, so it must not surface as a + // dynamic-key finding. + if (key == null && HasOnlyStringLiteralTail(arguments)) + continue; + + usages.Add(new CodePropertyUsage + { + ModId = modId, + File = path, + Line = invocation.GetLocation().GetLineSpan().StartLinePosition.Line + 1, + Key = key, + Type = type, + IsWrite = method.StartsWith("Set", StringComparison.Ordinal), + HasDefault = arguments.Count > 1, + Method = method, + }); + } + } + + private static string TryGetLiteralKey(ExpressionSyntax expression) + { + if (expression is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression)) + return literal.Token.ValueText; + + return null; + } + + private static bool HasOnlyStringLiteralTail(SeparatedSyntaxList arguments) + { + if (arguments.Count < 2) + return false; + + for (var i = 1; i < arguments.Count; i++) + { + if (arguments[i].Expression is not LiteralExpressionSyntax literal + || !literal.IsKind(SyntaxKind.StringLiteralExpression)) + return false; + } + + return true; + } + + private static void ReportDynamicKeys(ValidationContext context, List usages) + { + foreach (var usage in usages) + { + if (usage.Key != null) + continue; + + context.AddInfo("DYNAMIC_KEY", + $"{usage.Method}(...) uses a non-literal key; static analysis skipped", + usage.ModId, usage.File, usage.Line); + } + } + + private static void ReportTypeConflicts(ValidationContext context, List usages) + { + var byKey = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var usage in usages) + { + if (usage.Key == null) + continue; + + if (!byKey.TryGetValue(usage.Key, out var list)) + { + list = new List(); + byKey[usage.Key] = list; + } + + list.Add(usage); + } + + foreach (var entry in byKey) + { + var types = new List(); + foreach (var usage in entry.Value) + { + if (!types.Contains(usage.Type)) + types.Add(usage.Type); + } + + if (types.Count < 2) + continue; + + var first = entry.Value[0]; + var related = new List(); + for (var i = 1; i < entry.Value.Count; i++) + { + var other = entry.Value[i]; + related.Add($"{other.ModId}:{other.File}:{other.Line} ({other.Type})"); + } + + context.AddError("TYPE_CONFLICT", + $"property '{entry.Key}' is accessed as {string.Join(", ", types)}", + first.ModId, first.File, first.Line, + "use one type for all readers and writers of this property", + related); + } + } + + private static void ReportUndeclaredReads(ValidationContext context, List usages) + { + var writeKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var usage in usages) + { + if (usage.IsWrite && usage.Key != null) + writeKeys.Add(usage.Key); + } + + var reported = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var usage in usages) + { + if (usage.IsWrite || usage.Key == null) + continue; + + if (context.Graph.IsKeyDeclared(usage.Key) || writeKeys.Contains(usage.Key)) + continue; + + if (!reported.Add(usage.ModId + "|" + usage.File + "|" + usage.Key)) + continue; + + var message = $"property '{usage.Key}' is read as {usage.Type} but is never declared or written"; + var suggestion = "declare the property in JSON (Values/extra fields) or write it before reading"; + + if (usage.HasDefault) + context.AddWarning("READ_UNDECLARED_WITH_DEFAULT", message, usage.ModId, usage.File, usage.Line, suggestion); + else + context.AddError("READ_UNDECLARED", message, usage.ModId, usage.File, usage.Line, suggestion); + } + } + + private static void ReportUnusedWrites( + ValidationContext context, + List usages, + Dictionary literalCounts) + { + var readKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + var writeCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var usage in usages) + { + if (usage.Key == null) + continue; + + if (usage.IsWrite) + { + writeCounts.TryGetValue(usage.Key, out var count); + writeCounts[usage.Key] = count + 1; + } + else + { + readKeys.Add(usage.Key); + } + } + + var reported = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var usage in usages) + { + if (!usage.IsWrite || usage.Key == null) + continue; + + if (readKeys.Contains(usage.Key)) + continue; + + if (!reported.Add(usage.ModId + "|" + usage.Key)) + continue; + + // A key that appears as a string literal more often than it is + // written is probably read through a helper the syntactic pass + // cannot follow (GetString(key, ...) with a literal argument). + literalCounts.TryGetValue(usage.Key, out var occurrences); + writeCounts.TryGetValue(usage.Key, out var writes); + if (occurrences > writes) + continue; + + context.AddInfo("WRITE_UNUSED", + $"property '{usage.Key}' is written but never read", + usage.ModId, usage.File, usage.Line); + } + } + } +} diff --git a/src/CharacterEntityStrategy.cs b/src/CharacterEntityStrategy.cs new file mode 100644 index 0000000..8d79afc --- /dev/null +++ b/src/CharacterEntityStrategy.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using Cthangover.Core.Mods; + +namespace Cthangover.Validator +{ + /// + /// Phase: Contracts. Collects character declarations (player characters and + /// enemies alike), their declared keys, loot item references and combat + /// action references. This is the richest entity kind; the other kinds only + /// look at reference subsets. + /// + public class CharacterEntityStrategy : EntityKindStrategy + { + public override string Kind => ValidationKind.Character; + + protected override void CollectEntity(ValidationContext context, IModInfo mod, string file, string id, JsonElement item) + { + base.CollectEntity(context, mod, file, id, item); + EntityFactCollector.CollectObjectKeys(context.Graph, item); + EntityFactCollector.CollectLootReferences(context, mod, file, item); + EntityFactCollector.CollectActionReferences(context, mod, file, item); + } + } +} diff --git a/src/CodePropertyUsage.cs b/src/CodePropertyUsage.cs new file mode 100644 index 0000000..f3bd8e1 --- /dev/null +++ b/src/CodePropertyUsage.cs @@ -0,0 +1,22 @@ +namespace Cthangover.Validator +{ + /// + /// A single typed property access extracted from a mod's C# source: + /// action.GetInt("Turn", 3) becomes (key=Turn, type=int, read, + /// hasDefault=true). DataBlob and PropertyData share the same + /// method names, so the collector intentionally does not distinguish + /// receivers — only the key, type and direction matter for cross-mod + /// conflict detection. + /// + public class CodePropertyUsage + { + public string ModId { get; set; } + public string File { get; set; } + public int Line { get; set; } + public string Key { get; set; } + public string Type { get; set; } + public bool IsWrite { get; set; } + public bool HasDefault { get; set; } + public string Method { get; set; } + } +} diff --git a/src/DependsStrategy.cs b/src/DependsStrategy.cs new file mode 100644 index 0000000..9de0b2b --- /dev/null +++ b/src/DependsStrategy.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using Cthangover.Core.Mods; + +namespace Cthangover.Validator +{ + /// + /// Phase: Reconciliation. Checks manifest depends entries: malformed + /// strings, required dependencies missing from the loaded set, and circular + /// dependency chains. The registry already filters mods with broken + /// dependencies at load time and the compiler only logs a warning for + /// cycles, so this strategy is what turns those silent conditions into + /// reportable findings. + /// + public class DependsStrategy : IValidationStrategy + { + public string Id => "depends"; + public ValidationPhase Phase => ValidationPhase.Reconciliation; + public int Order => 100; + public ValidationFeature Feature => ValidationFeature.Json; + + /// Re-checks every manifest's depends pins and cycles, reporting broken chains the loader only logs at runtime. + public void Validate(ValidationContext context) + { + var manifests = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var mod in context.Mods) + { + context.ThrowIfCancellationRequested(); + + var manifest = (mod as ModInfo)?.Manifest; + if (manifest != null) + manifests[mod.Id] = manifest; + } + + foreach (var entry in manifests) + { + var depends = entry.Value.Depends; + if (depends == null) + continue; + + foreach (var dependency in depends) + { + if (!dependency.IsValid) + { + context.AddError("SCHEMA_WRONG_TYPE", + $"invalid dependency string: {dependency.Error}", + entry.Key, "manifest.json", 0, + "use format modId[op][version][?]"); + continue; + } + + if (dependency.IsOptional || manifests.ContainsKey(dependency.ModId)) + continue; + + context.AddError("REF_NOT_FOUND", + $"required dependency '{dependency.ModId}' is not loaded", + entry.Key, "manifest.json", 0, + $"install mod '{dependency.ModId}' or mark the dependency optional with '?'"); + } + } + + DetectCycles(context, manifests); + } + + private static void DetectCycles(ValidationContext context, Dictionary manifests) + { + var state = new Dictionary(StringComparer.OrdinalIgnoreCase); + var path = new List(); + var reported = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var modId in manifests.Keys) + Visit(context, manifests, modId, state, path, reported); + } + + private static void Visit( + ValidationContext context, + Dictionary manifests, + string modId, + Dictionary state, + List path, + HashSet reported) + { + if (state.TryGetValue(modId, out var existing)) + { + if (existing == 1) + ReportCycle(context, modId, path, reported); + return; + } + + state[modId] = 1; + path.Add(modId); + + var depends = manifests[modId].Depends; + if (depends != null) + { + foreach (var dependency in depends) + { + if (!dependency.IsValid || !manifests.ContainsKey(dependency.ModId)) + continue; + + Visit(context, manifests, dependency.ModId, state, path, reported); + } + } + + path.RemoveAt(path.Count - 1); + state[modId] = 2; + } + + private static void ReportCycle(ValidationContext context, string repeated, List path, HashSet reported) + { + var start = -1; + for (var i = 0; i < path.Count; i++) + { + if (string.Equals(path[i], repeated, StringComparison.OrdinalIgnoreCase)) + { + start = i; + break; + } + } + + if (start < 0) + return; + + var cycle = new List(); + for (var i = start; i < path.Count; i++) + cycle.Add(path[i]); + cycle.Add(repeated); + + var key = BuildCycleKey(cycle); + if (!reported.Add(key)) + return; + + context.AddError("REF_CIRCULAR", + $"circular dependency: {string.Join(" -> ", cycle)}", + repeated, "manifest.json", 0, + "break the cycle by removing one of the dependencies"); + } + + private static string BuildCycleKey(List cycle) + { + var sorted = new List(cycle); + sorted.Sort(StringComparer.OrdinalIgnoreCase); + return string.Join("|", sorted); + } + } +} diff --git a/src/EntityFactCollector.cs b/src/EntityFactCollector.cs new file mode 100644 index 0000000..e0cac85 --- /dev/null +++ b/src/EntityFactCollector.cs @@ -0,0 +1,93 @@ +using System.Text.Json; +using Cthangover.Core.Mods; + +namespace Cthangover.Validator +{ + /// + /// Shared extraction helpers for + /// implementations. Keeps the per-kind strategies focused on "what is + /// interesting about my entities" while the mechanical work — walking + /// declared object keys, action property keys, loot and ingredient + /// references — lives in one place. All helpers are schema-light + /// (case-insensitive raw reads) so they survive + /// DTO field changes. + /// + public static class EntityFactCollector + { + /// Declares every top-level property name of an entity as a known key. + public static void CollectObjectKeys(ValidationGraph graph, JsonElement entity) + { + if (entity.ValueKind != JsonValueKind.Object) + return; + + foreach (var property in entity.EnumerateObject()) + graph.AddDeclaredKey(property.Name); + } + + /// Declares the Key=Value property bag keys of an action's Values array. + public static void CollectActionKeys(ValidationGraph graph, JsonElement values) + { + foreach (var value in values.EnumerateArray()) + { + if (value.ValueKind != JsonValueKind.String) + continue; + + var text = value.GetString(); + if (string.IsNullOrEmpty(text)) + continue; + + var separator = text.IndexOf('='); + if (separator > 0) + graph.AddDeclaredKey(text.Substring(0, separator)); + } + } + + /// Records item references from a recipe's ingredient array (e.g. Input, Output). + public static void CollectIngredientReferences(ValidationContext context, IModInfo mod, string file, JsonElement entity, string field) + { + var ingredients = JsonReadHelper.GetProperty(entity, field); + if (!JsonReadHelper.IsArray(ingredients)) + return; + + foreach (var ingredient in ingredients.Value.EnumerateArray()) + { + var itemId = JsonReadHelper.GetString(ingredient, "Id", "ID", "id"); + if (!string.IsNullOrWhiteSpace(itemId)) + context.Graph.AddReference(ValidationKind.Item, itemId, mod.Id, file, 0); + } + } + + /// Records item references from a character's Loot table. + public static void CollectLootReferences(ValidationContext context, IModInfo mod, string file, JsonElement entity) + { + var loot = JsonReadHelper.GetProperty(entity, "Loot"); + if (!JsonReadHelper.IsArray(loot)) + return; + + foreach (var drop in loot.Value.EnumerateArray()) + { + var itemId = JsonReadHelper.GetString(drop, "ItemId"); + if (!string.IsNullOrWhiteSpace(itemId)) + context.Graph.AddReference(ValidationKind.Item, itemId, mod.Id, file, 0); + } + } + + /// Records action references from a character's Actions array. + public static void CollectActionReferences(ValidationContext context, IModInfo mod, string file, JsonElement entity) + { + var actions = JsonReadHelper.GetProperty(entity, "Actions"); + if (!JsonReadHelper.IsArray(actions)) + return; + + foreach (var action in actions.Value.EnumerateArray()) + { + if (action.ValueKind != JsonValueKind.String) + continue; + + var actionId = action.GetString(); + if (!string.IsNullOrWhiteSpace(actionId)) + context.Graph.AddReference(ValidationKind.Action, actionId, mod.Id, file, 0); + } + } + } +} diff --git a/src/EntityKindStrategy.cs b/src/EntityKindStrategy.cs new file mode 100644 index 0000000..5a921c1 --- /dev/null +++ b/src/EntityKindStrategy.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using Cthangover.Core.Mods; + +namespace Cthangover.Validator +{ + /// + /// Shared skeleton for implementations: + /// iterates the envelope's items, resolves each entity id case-insensitively + /// and records the declaration. Kinds that carry no extra structure (quests, + /// skills, status effects) only override ; richer kinds + /// override to add declared keys and references + /// through . + /// + public abstract class EntityKindStrategy : IEntityKindStrategy + { + /// + public abstract string Kind { get; } + + /// + public void Collect(ValidationContext context, IModInfo mod, string file, JsonElement items) + { + foreach (var item in items.EnumerateArray()) + { + context.ThrowIfCancellationRequested(); + + var id = JsonReadHelper.GetString(item, "Id", "ID", "id"); + if (string.IsNullOrWhiteSpace(id)) + continue; + + CollectEntity(context, mod, file, id, item); + } + } + + /// + /// Records one entity. The default implementation adds the declaration + /// under , which doubles as the graph vocabulary + /// (). + /// + protected virtual void CollectEntity(ValidationContext context, IModInfo mod, string file, string id, JsonElement item) + { + context.Graph.AddDeclaration(Kind, id, mod.Id, file, 0); + } + } +} diff --git a/src/EntityKindStrategyConsumer.cs b/src/EntityKindStrategyConsumer.cs new file mode 100644 index 0000000..068c9d7 --- /dev/null +++ b/src/EntityKindStrategyConsumer.cs @@ -0,0 +1,21 @@ +using Cthangover.Core.Mods.Consumers; + +namespace Cthangover.Validator +{ + /// + /// Feeds with every mod's + /// implementations as the mods load and + /// removes them when their mod unloads. + /// + public class EntityKindStrategyConsumer : ModTypeConsumer + { + /// + public override string Id => "validator.entity_kinds"; + + /// + protected override void Add(IEntityKindStrategy instance, string modId) => EntityKindStrategyRegistry.Register(instance); + + /// + protected override void Remove(IEntityKindStrategy instance, string modId) => EntityKindStrategyRegistry.Unregister(instance); + } +} diff --git a/src/EntityKindStrategyRegistry.cs b/src/EntityKindStrategyRegistry.cs new file mode 100644 index 0000000..67222c6 --- /dev/null +++ b/src/EntityKindStrategyRegistry.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using Cthangover.Core.Utils; + +namespace Cthangover.Validator +{ + /// + /// Registry of implementations, indexed by + /// the entity kind each one handles. It discovers nothing itself: + /// delivers the strategies of every + /// mod as the kernel registers its assembly and takes them back when the mod + /// unloads, so the map never holds a strategy of a dead assembly. Adding kind + /// handling is just shipping a new public class with a parameterless + /// constructor in any mod. + /// + public static class EntityKindStrategyRegistry + { + private static readonly object sync = new(); + private static readonly Dictionary strategies = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Returns the strategy registered for the entity kind, or null + /// when no strategy claims it — the caller reports the unknown kind as a + /// validation warning. + /// + public static IEntityKindStrategy Resolve(string kind) + { + if (string.IsNullOrWhiteSpace(kind)) + return null; + + lock (sync) + { + strategies.TryGetValue(kind, out var strategy); + return strategy; + } + } + + /// + /// Registers a strategy under its kind. The first strategy of a kind + /// wins; a second one is reported as an error and ignored. + /// + public static void Register(IEntityKindStrategy strategy) + { + if (strategy == null || string.IsNullOrWhiteSpace(strategy.Kind)) + return; + + lock (sync) + { + if (strategies.TryGetValue(strategy.Kind, out var existing)) + { + GameLogger.Log("VALIDATOR", + $"entity kind '{strategy.Kind}' is handled by both '{existing.GetType().Name}' and '{strategy.GetType().Name}'; keeping the first", + LogLevel.Error); + return; + } + + strategies[strategy.Kind] = strategy; + } + } + + /// + /// Removes (its mod is being unloaded). A kind + /// owned by another strategy instance is left alone. + /// + public static void Unregister(IEntityKindStrategy strategy) + { + if (strategy == null || string.IsNullOrWhiteSpace(strategy.Kind)) + return; + + lock (sync) + { + if (strategies.TryGetValue(strategy.Kind, out var registered) && registered == strategy) + strategies.Remove(strategy.Kind); + } + } + } +} diff --git a/src/IEntityKindStrategy.cs b/src/IEntityKindStrategy.cs new file mode 100644 index 0000000..150f7ed --- /dev/null +++ b/src/IEntityKindStrategy.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using Cthangover.Core.Mods; + +namespace Cthangover.Validator +{ + /// + /// One collection strategy per entity kind, discovered by reflection like + /// every other plugin point in the project (Reflections.FindAndCreate). + /// The JSON entity walker () scans every file + /// once, reads the envelope's "Kind" and delegates the items to the + /// strategy registered for that kind — so adding a new entity type means + /// shipping one class, not editing a switch. + /// + /// A strategy fills with declarations + /// and references for its kind. It must not throw on malformed data: missing + /// ids are skipped silently, missing references are reported through the + /// context. + /// + public interface IEntityKindStrategy + { + /// + /// Entity kind this strategy handles — matches the "Kind" string + /// in the JSON envelope and the used in the + /// graph. Duplicate kinds across strategies are logged and the first + /// discovery wins. + /// + string Kind { get; } + + /// + /// Collects facts for one file of this strategy's kind. The caller has + /// already verified the envelope shape and that the declared kind + /// matches . + /// + /// Validation pass state and report sink. + /// Mod that owns the file. + /// Mod-relative file path, used as the declaration/reference source. + /// The envelope's Items array. + void Collect(ValidationContext context, IModInfo mod, string file, JsonElement items); + } +} diff --git a/src/IValidationStrategy.cs b/src/IValidationStrategy.cs new file mode 100644 index 0000000..3ccf23a --- /dev/null +++ b/src/IValidationStrategy.cs @@ -0,0 +1,37 @@ +namespace Cthangover.Validator +{ + /// + /// One self-contained validation step, discovered by reflection like every + /// other plugin point in the project (Reflections.FindAndCreate). + /// Strategies run sequentially in (Phase, Order, Id) order on the validator + /// background thread: Schema strategies validate source shapes, Contracts + /// strategies fill with declarations, + /// references and code usages, and Reconciliation strategies compare those + /// facts and emit findings. + /// + /// To add scenario validation, implement this interface in a new class: + /// extract declarations and references into the graph in the Contracts + /// phase and match them in Reconciliation — no service changes are needed. + /// + public interface IValidationStrategy + { + /// Stable identifier used in logs and in the sort order. + string Id { get; } + + /// Execution stage; strategies of earlier phases run first. + ValidationPhase Phase { get; } + + /// Order inside the phase; lower runs first. + int Order { get; } + + /// Capability group gated by the user settings. + ValidationFeature Feature { get; } + + /// + /// Runs the strategy. Data problems must be reported through the + /// and must not throw; unexpected + /// exceptions are caught and logged by . + /// + void Validate(ValidationContext context); + } +} diff --git a/src/ItemEntityStrategy.cs b/src/ItemEntityStrategy.cs new file mode 100644 index 0000000..8671c3e --- /dev/null +++ b/src/ItemEntityStrategy.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using Cthangover.Core.Mods; + +namespace Cthangover.Validator +{ + /// + /// Phase: Contracts. Collects item declarations and their declared keys. + /// Items are the base entity other kinds reference (loot, recipe + /// ingredients), so their keys feed the unknown-property checks while the + /// declarations feed the reference reconciliation. + /// + public class ItemEntityStrategy : EntityKindStrategy + { + public override string Kind => ValidationKind.Item; + + protected override void CollectEntity(ValidationContext context, IModInfo mod, string file, string id, JsonElement item) + { + base.CollectEntity(context, mod, file, id, item); + EntityFactCollector.CollectObjectKeys(context.Graph, item); + } + } +} diff --git a/src/JsonEntityStrategy.cs b/src/JsonEntityStrategy.cs new file mode 100644 index 0000000..319c66d --- /dev/null +++ b/src/JsonEntityStrategy.cs @@ -0,0 +1,316 @@ +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(); + } + } +} diff --git a/src/JsonReadHelper.cs b/src/JsonReadHelper.cs new file mode 100644 index 0000000..f68ece4 --- /dev/null +++ b/src/JsonReadHelper.cs @@ -0,0 +1,56 @@ +using System; +using System.Text.Json; + +namespace Cthangover.Validator +{ + /// + /// Case-insensitive reads over used by the JSON + /// contract strategy. Mod JSON deliberately mixes naming styles + /// ("Id", "id", "defaultBackground") and the runtime + /// reads it with case-insensitive options, so the validator must not depend + /// on exact casing either. + /// + public static class JsonReadHelper + { + /// + /// Returns the first matching property by exact name, then by + /// case-insensitive comparison. Null when the element is not an object + /// or no name matches. + /// + public static JsonElement? GetProperty(JsonElement element, params string[] names) + { + if (element.ValueKind != JsonValueKind.Object) + return null; + + foreach (var name in names) + { + if (element.TryGetProperty(name, out var exact)) + return exact; + } + + foreach (var property in element.EnumerateObject()) + { + foreach (var name in names) + { + if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) + return property.Value; + } + } + + return null; + } + + /// Returns the string value of the first matching property, or null. + public static string GetString(JsonElement element, params string[] names) + { + var value = GetProperty(element, names); + if (value == null || value.Value.ValueKind != JsonValueKind.String) + return null; + return value.Value.GetString(); + } + + /// True when the element exists and is an array. + public static bool IsArray(JsonElement? element) + => element != null && element.Value.ValueKind == JsonValueKind.Array; + } +} diff --git a/src/ManifestSchemaStrategy.cs b/src/ManifestSchemaStrategy.cs new file mode 100644 index 0000000..f6b2c4a --- /dev/null +++ b/src/ManifestSchemaStrategy.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using Cthangover.Core.Utils; + +namespace Cthangover.Validator +{ + /// + /// Phase: Schema. Validates the mandatory shape of every mod's + /// manifest.json: id and version must be present and be + /// strings, and id must not be empty. The runtime silently falls back + /// to the filesystem entry name for a missing id and tolerates a missing + /// version, so these are exactly the problems an author should fix before + /// publishing. + /// + public class ManifestSchemaStrategy : IValidationStrategy + { + public string Id => "manifest_schema"; + public ValidationPhase Phase => ValidationPhase.Schema; + public int Order => 100; + public ValidationFeature Feature => ValidationFeature.Json; + + /// Checks the required manifest.json fields: non-empty string id and string version. + public void Validate(ValidationContext context) + { + foreach (var mod in context.Mods) + { + context.ThrowIfCancellationRequested(); + + var text = mod.FileProvider?.ReadFileText("manifest.json"); + if (string.IsNullOrWhiteSpace(text)) + { + context.AddError("SCHEMA_MISSING_FIELD", "manifest.json is missing or empty", mod.Id, "manifest.json"); + continue; + } + + JsonDocument document; + try + { + document = JsonDocument.Parse(text, JsonOptionsProvider.DocumentOptions); + } + catch (JsonException ex) + { + context.AddError("JSON_PARSE_ERROR", $"manifest.json is not valid JSON: {ex.Message}", mod.Id, "manifest.json"); + continue; + } + + using (document) + { + ValidateStringField(context, mod.Id, document.RootElement, "id", required: true); + ValidateStringField(context, mod.Id, document.RootElement, "version", required: true); + ValidateStringField(context, mod.Id, document.RootElement, "name", required: false); + } + } + } + + private static void ValidateStringField(ValidationContext context, string modId, JsonElement root, string field, bool required) + { + var value = JsonReadHelper.GetProperty(root, field); + + if (value == null) + { + if (required) + context.AddError("SCHEMA_MISSING_FIELD", $"manifest.json has no '{field}'", modId, "manifest.json"); + return; + } + + if (value.Value.ValueKind != JsonValueKind.String) + { + context.AddError("SCHEMA_WRONG_TYPE", $"'{field}' must be a string", modId, "manifest.json"); + return; + } + + if (required && string.IsNullOrWhiteSpace(value.Value.GetString())) + context.AddError("SCHEMA_MISSING_FIELD", $"'{field}' must not be empty", modId, "manifest.json"); + } + } +} diff --git a/src/ModFileReader.cs b/src/ModFileReader.cs new file mode 100644 index 0000000..7033bf2 --- /dev/null +++ b/src/ModFileReader.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using Cthangover.Core.Mods; +using Cthangover.Core.Mods.Resolvers; + +namespace Cthangover.Validator +{ + /// + /// Pure .NET file access used by validation strategies. Works with both + /// folder and zip providers through and + /// resolves the project's ${include} directives with the same public + /// helper the runtime resolvers use, so the validator sees exactly the JSON + /// the game will see. Safe on the background validation thread because it + /// touches neither Godot nor the resolver caches. + /// + public static class ModFileReader + { + /// + /// Lists files under a mod-relative directory. Directories are pushed + /// onto the work stack only when is set, so + /// the caller can mirror the recursive entity scan or the recursive + /// scene scan. + /// + public static List ListFiles(IModFileProvider provider, string directory, bool recursive) + { + var result = new List(); + if (provider == null) + return result; + + var pending = new Stack(); + pending.Push(directory ?? string.Empty); + + while (pending.Count > 0) + { + var current = pending.Pop(); + foreach (var entry in provider.ListFiles(current)) + { + if (entry.EndsWith("/")) + { + if (recursive) + pending.Push(entry.TrimEnd('/')); + continue; + } + + result.Add(entry); + } + } + + return result; + } + + /// + /// Reads a mod file and expands every ${path} include. Returns + /// null when the file is absent; include failures propagate as + /// so the caller can report them + /// with the owning file name. + /// + public static string ReadResolved(IModFileProvider provider, string path) + { + var content = provider.ReadFileText(path); + if (content == null) + return null; + + var visited = new HashSet(StringComparer.OrdinalIgnoreCase) { path }; + return ModResolutionContext.ExpandModIncludes(provider, content, path, visited); + } + + /// True when the file path ends with one of the given extensions. + public static bool HasExtension(string path, HashSet extensions) + { + var dot = path.LastIndexOf('.'); + return dot > 0 && extensions.Contains(path.Substring(dot)); + } + } +} diff --git a/src/QuestEntityStrategy.cs b/src/QuestEntityStrategy.cs new file mode 100644 index 0000000..58625e4 --- /dev/null +++ b/src/QuestEntityStrategy.cs @@ -0,0 +1,13 @@ +namespace Cthangover.Validator +{ + /// + /// Phase: Contracts. Collects quest declarations. Quests carry no + /// references the validator can check yet, so the base skeleton — parse the + /// envelope, key by Id, declare it under the quest kind — is all + /// there is to do. + /// + public class QuestEntityStrategy : EntityKindStrategy + { + public override string Kind => ValidationKind.Quest; + } +} diff --git a/src/RecipeEntityStrategy.cs b/src/RecipeEntityStrategy.cs new file mode 100644 index 0000000..cc5f11e --- /dev/null +++ b/src/RecipeEntityStrategy.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using Cthangover.Core.Mods; + +namespace Cthangover.Validator +{ + /// + /// Phase: Contracts. Collects recipe declarations and turns their + /// Input/Output ingredients into item references, so a recipe + /// pointing at a deleted item surfaces as a missing reference. + /// + public class RecipeEntityStrategy : EntityKindStrategy + { + public override string Kind => ValidationKind.Recipe; + + protected override void CollectEntity(ValidationContext context, IModInfo mod, string file, string id, JsonElement item) + { + base.CollectEntity(context, mod, file, id, item); + EntityFactCollector.CollectIngredientReferences(context, mod, file, item, "Input"); + EntityFactCollector.CollectIngredientReferences(context, mod, file, item, "Output"); + } + } +} diff --git a/src/ReferenceStrategy.cs b/src/ReferenceStrategy.cs new file mode 100644 index 0000000..1447a4a --- /dev/null +++ b/src/ReferenceStrategy.cs @@ -0,0 +1,34 @@ +namespace Cthangover.Validator +{ + /// + /// Phase: Reconciliation. Matches every reference collected by contract + /// strategies against the declarations in the graph and reports + /// REF_NOT_FOUND for the missing ones. Because the graph is global, + /// a reference in one mod may resolve to a declaration in another — exactly + /// how the runtime factories and scene loader resolve content. + /// + public class ReferenceStrategy : IValidationStrategy + { + public string Id => "references"; + public ValidationPhase Phase => ValidationPhase.Reconciliation; + public int Order => 200; + public ValidationFeature Feature => ValidationFeature.Json; + + /// Reports every recorded reference whose target id has no declaration in the merged fact graph (REF_NOT_FOUND). + public void Validate(ValidationContext context) + { + foreach (var reference in context.Graph.References) + { + context.ThrowIfCancellationRequested(); + + if (context.Graph.HasDeclaration(reference.Kind, reference.TargetId)) + continue; + + context.AddError("REF_NOT_FOUND", + $"{reference.Kind} '{reference.TargetId}' is referenced but never declared", + reference.ModId, reference.File, reference.Line, + $"declare {reference.Kind} '{reference.TargetId}' or fix the reference"); + } + } + } +} diff --git a/src/ReportWriter.cs b/src/ReportWriter.cs new file mode 100644 index 0000000..27b2242 --- /dev/null +++ b/src/ReportWriter.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using System.Text.Json; +using Cthangover.Core.Utils; + +namespace Cthangover.Validator +{ + /// + /// Persists the completed as JSON. Called from + /// the background validation task, so it relies only on plain + /// System.IO; the Godot-style directory is resolved to an absolute path + /// earlier, on the main thread, by + /// . + /// + public static class ReportWriter + { + /// File name inside the configured report directory. + public const string FileName = "report.json"; + + /// + /// Writes the report and returns the absolute path, or null when the + /// directory could not be resolved or the write failed. Never throws — a + /// failed report must not interrupt the game. + /// + public static string Write(ValidationReport report, string directory) + { + if (string.IsNullOrWhiteSpace(directory)) + { + GameLogger.Log("VALIDATOR", "validator: report directory is not resolved", LogLevel.Error); + return null; + } + + try + { + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, FileName); + var json = JsonSerializer.Serialize(report, JsonOptionsProvider.WriteIndentedOptions); + File.WriteAllText(path, json); + GameLogger.Log("VALIDATOR", $"validator: report written to {path}"); + return path; + } + catch (Exception ex) + { + GameLogger.Log("VALIDATOR", $"validator: report write failed: {ex.Message}", LogLevel.Error); + return null; + } + } + } +} diff --git a/src/ScenarioCommandValidationStrategy.cs b/src/ScenarioCommandValidationStrategy.cs new file mode 100644 index 0000000..c56ad68 --- /dev/null +++ b/src/ScenarioCommandValidationStrategy.cs @@ -0,0 +1,579 @@ +using System; +using Cthangover.Mods.Dialog.Scenarios; +using System.Collections.Generic; +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.Mods; +using Cthangover.Core.Scenarios; +using Cthangover.Core.Scenes; + +namespace Cthangover.Validator +{ + /// + /// Phase: Contracts. Validates .scenario DSL files. The command + /// vocabulary is not hardcoded: known command words come from + /// (mods add commands through + /// RegisterAssembly), and each command's argument contract comes from + /// implemented by the strategy itself. + /// Commands without a spec are still recognised, just not argument-checked. + /// + /// Checks performed: + /// + /// unknown command words (SCENARIO_UNKNOWN_COMMAND); + /// missing / extra / unknown / empty arguments based on the command spec; + /// jump targets (goto, option) must point at an existing :label; + /// select without options and option outside a select block; + /// references collected by are added to the + /// shared graph (backgrounds, scenes, music, sounds, effects, avatars) and checked by + /// ; scenario actions are checked against the action registry. + /// content references inside action arguments (: + /// character ids, skills, quest ids, battle enemy lists) are added to the same graph, so a + /// typo is reported against the declaring mods with file and line. + /// + /// + public class ScenarioCommandValidationStrategy : IValidationStrategy + { + private static readonly string[] SelectAllowedNamed = { "key", "first", "second" }; + private static readonly string[] OptionAllowedNamed = { "key", "goto" }; + + private static readonly object actionLock = new(); + private static HashSet actionNames; + + public string Id => "scenario_commands"; + public ValidationPhase Phase => ValidationPhase.Contracts; + public int Order => 300; + public ValidationFeature Feature => ValidationFeature.Scenarios; + + /// Parses every .scenario file and validates DSL commands, named parameters and known action names. + public void Validate(ValidationContext context) + { + foreach (var mod in context.Mods) + { + context.ThrowIfCancellationRequested(); + + var provider = mod.FileProvider; + if (provider == null) + continue; + + foreach (var file in ModFileReader.ListFiles(provider, "scenarios", true)) + { + context.ThrowIfCancellationRequested(); + if (!file.EndsWith(".scenario", StringComparison.OrdinalIgnoreCase)) + continue; + + ValidateFile(context, mod, file); + } + } + } + + private static void ValidateFile(ValidationContext context, IModInfo mod, string file) + { + string text; + try + { + text = ModFileReader.ReadResolved(mod.FileProvider, file); + } + catch (Exception ex) + { + context.AddError("SCENARIO_PARSE_ERROR", $"cannot read scenario: {ex.Message}", mod.Id, file); + return; + } + + if (text == null) + return; + + var metadataResult = ScenarioParser.ParseMetadata(text); + var body = metadataResult.remainingText; + var lines = body.Split('\n'); + var lineOffset = text.Split('\n').Length - lines.Length; + + var labels = CollectLabels(context, mod, file, lines, lineOffset); + + var pendingSelectLine = -1; + var pendingSelectHasOptions = false; + var lineNumber = lineOffset; + + foreach (var rawLine in lines) + { + lineNumber++; + var line = rawLine.Trim(); + + if (line.Length == 0 || line.StartsWith('#')) + { + FinalizeSelect(context, mod, file, ref pendingSelectLine, ref pendingSelectHasOptions); + continue; + } + + if (line.StartsWith(':')) + { + FinalizeSelect(context, mod, file, ref pendingSelectLine, ref pendingSelectHasOptions); + continue; + } + + var tokens = Tokenize(line); + if (tokens.Count == 0) + continue; + + var command = tokens[0].ToLowerInvariant(); + tokens.RemoveAt(0); + + var positional = new List(); + var named = new Dictionary(); + SplitArguments(tokens, positional, named, out var arrowTarget); + + if (command == "select") + { + FinalizeSelect(context, mod, file, ref pendingSelectLine, ref pendingSelectHasOptions); + pendingSelectLine = lineNumber; + pendingSelectHasOptions = false; + + CheckEmptyNamed(context, mod, file, lineNumber, command, named); + CheckAllowedNamed(context, mod, file, lineNumber, command, named, SelectAllowedNamed); + CheckPositionalRange(context, mod, file, lineNumber, command, positional, 0, 1); + AddAvatarReferences(context, mod, file, lineNumber, named); + continue; + } + + if (command == "option") + { + if (pendingSelectLine < 0) + context.AddWarning("SCENARIO_ORPHAN_OPTION", + "option outside a select block is ignored by the parser", + mod.Id, file, lineNumber); + else + pendingSelectHasOptions = true; + + CheckEmptyNamed(context, mod, file, lineNumber, command, named); + CheckAllowedNamed(context, mod, file, lineNumber, command, named, OptionAllowedNamed); + + var target = arrowTarget; + if (string.IsNullOrEmpty(target) && named.TryGetValue("goto", out var goTo)) + target = goTo; + else if (string.IsNullOrEmpty(target) && positional.Count > 1 && positional[1].StartsWith(':')) + target = positional[1].TrimStart(':'); + + CheckLabel(context, mod, file, lineNumber, command, target, labels); + continue; + } + + FinalizeSelect(context, mod, file, ref pendingSelectLine, ref pendingSelectHasOptions); + + var strategy = ScenarioCommandStrategyFactory.Get(command); + if (strategy == null) + { + context.AddError("SCENARIO_UNKNOWN_COMMAND", + $"unknown scenario command '{command}'", + mod.Id, file, lineNumber, + "check the command name or make sure the mod that defines it is enabled"); + continue; + } + + var spec = strategy as IScenarioCommandSpec; + if (spec != null) + CheckSpec(context, mod, file, lineNumber, command, positional, named, arrowTarget, spec, labels); + + CheckEmptyNamed(context, mod, file, lineNumber, command, named); + AddAvatarReferences(context, mod, file, lineNumber, named); + + if (!string.IsNullOrEmpty(arrowTarget)) + CheckLabel(context, mod, file, lineNumber, command, arrowTarget, labels); + + var referenceMetadata = strategy as ICommandReferenceMetadata; + if (referenceMetadata != null + && referenceMetadata.Positional0Kind != PositionalReferenceKind.None + && positional.Count > 0) + { + if (string.IsNullOrWhiteSpace(positional[0])) + context.AddError("SCENARIO_EMPTY_ARGUMENT", + $"'{command}' requires a non-empty {referenceMetadata.Positional0Kind} reference", + mod.Id, file, lineNumber); + else + AddReference(context, mod, file, lineNumber, referenceMetadata.Positional0Kind, positional[0]); + } + + if (command == "action" && positional.Count > 0) + AddActionArgumentReferences(context, mod, file, lineNumber, positional[0], named); + } + + FinalizeSelect(context, mod, file, ref pendingSelectLine, ref pendingSelectHasOptions); + } + + private static HashSet CollectLabels(ValidationContext context, IModInfo mod, string file, string[] lines, int lineOffset) + { + var labels = new HashSet(StringComparer.Ordinal); + var lineNumber = lineOffset; + + foreach (var rawLine in lines) + { + lineNumber++; + var line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith('#')) + continue; + + if (!line.StartsWith(':')) + continue; + + var label = line.Substring(1).Trim(); + if (label.Length == 0) + { + context.AddError("SCENARIO_MISSING_ARGUMENT", "label name is empty", mod.Id, file, lineNumber); + continue; + } + + if (!labels.Add(label)) + context.AddWarning("SCENARIO_DUPLICATE_LABEL", $"label ':{label}' is defined more than once", mod.Id, file, lineNumber); + } + + return labels; + } + + private static void FinalizeSelect(ValidationContext context, IModInfo mod, string file, ref int pendingSelectLine, ref bool pendingSelectHasOptions) + { + if (pendingSelectLine >= 0 && !pendingSelectHasOptions) + context.AddWarning("SCENARIO_EMPTY_SELECT", + "select is not followed by any option and is dropped from the queue", + mod.Id, file, pendingSelectLine); + + pendingSelectLine = -1; + pendingSelectHasOptions = false; + } + + private static void CheckSpec( + ValidationContext context, + IModInfo mod, + string file, + int line, + string command, + List positional, + Dictionary named, + string arrowTarget, + IScenarioCommandSpec spec, + HashSet labels) + { + var argumentCount = positional.Count + named.Count; + if (argumentCount == 0 && !string.IsNullOrEmpty(arrowTarget)) + argumentCount = 1; + + if (positional.Count < spec.MinPositional) + context.AddError("SCENARIO_MISSING_ARGUMENT", + $"'{command}' requires at least {spec.MinPositional} positional argument(s), got {positional.Count}", + mod.Id, file, line); + + if (argumentCount < spec.MinArguments) + context.AddError("SCENARIO_MISSING_ARGUMENT", + $"'{command}' requires at least {spec.MinArguments} argument(s), got {argumentCount}", + mod.Id, file, line); + + if (spec.MaxPositional >= 0 && positional.Count > spec.MaxPositional) + context.AddWarning("SCENARIO_UNEXPECTED_ARGUMENT", + $"'{command}' accepts at most {spec.MaxPositional} positional argument(s), got {positional.Count}", + mod.Id, file, line); + + CheckPositionalRange(context, mod, file, line, command, positional, spec.MinPositional, spec.MaxPositional); + + var required = spec.RequiredNamed; + if (required != null) + { + foreach (var name in required) + { + if (!named.TryGetValue(name.ToLowerInvariant(), out var value) || string.IsNullOrWhiteSpace(value)) + context.AddError("SCENARIO_MISSING_ARGUMENT", + $"'{command}' requires named argument '{name}'", + mod.Id, file, line); + } + } + + var allowed = spec.AllowedNamed; + if (allowed != null && allowed.Count > 0) + CheckAllowedNamed(context, mod, file, line, command, named, allowed); + + var jumpKey = spec.JumpTargetNamedKey; + if (!string.IsNullOrEmpty(jumpKey) && named.TryGetValue(jumpKey, out var jumpTarget)) + CheckLabel(context, mod, file, line, command, jumpTarget, labels); + } + + private static void CheckPositionalRange( + ValidationContext context, + IModInfo mod, + string file, + int line, + string command, + List positional, + int min, + int max) + { + if (min < 1 || positional.Count == 0) + return; + + for (var i = 0; i < positional.Count && i < min; i++) + { + if (string.IsNullOrEmpty(positional[i])) + context.AddError("SCENARIO_EMPTY_ARGUMENT", + $"'{command}' positional argument #{i + 1} is empty", + mod.Id, file, line); + } + } + + private static void CheckEmptyNamed( + ValidationContext context, + IModInfo mod, + string file, + int line, + string command, + Dictionary named) + { + foreach (var pair in named) + { + if (string.IsNullOrEmpty(pair.Value)) + context.AddError("SCENARIO_EMPTY_ARGUMENT", + $"argument '{pair.Key}' of '{command}' has no value", + mod.Id, file, line); + } + } + + private static void CheckAllowedNamed( + ValidationContext context, + IModInfo mod, + string file, + int line, + string command, + Dictionary named, + IReadOnlyList allowed) + { + foreach (var pair in named) + { + if (!ContainsName(allowed, pair.Key)) + context.AddWarning("SCENARIO_UNKNOWN_ARGUMENT", + $"'{command}' does not accept argument '{pair.Key}'", + mod.Id, file, line); + } + } + + private static bool ContainsName(IReadOnlyList names, string name) + { + for (var i = 0; i < names.Count; i++) + { + if (string.Equals(names[i], name, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + private static void CheckLabel( + ValidationContext context, + IModInfo mod, + string file, + int line, + string command, + string target, + HashSet labels) + { + if (string.IsNullOrEmpty(target) || labels.Contains(target)) + return; + + context.AddError("SCENARIO_MISSING_LABEL", + $"'{command}' jumps to ':{target}' but no such label exists in the file", + mod.Id, file, line, + "add the label or fix the jump target"); + } + + private static void AddAvatarReferences(ValidationContext context, IModInfo mod, string file, int line, Dictionary named) + { + AddAvatarReference(context, mod, file, line, named, "first"); + AddAvatarReference(context, mod, file, line, named, "second"); + } + + private static void AddAvatarReference(ValidationContext context, IModInfo mod, string file, int line, Dictionary named, string name) + { + if (!named.TryGetValue(name, out var value) || string.IsNullOrWhiteSpace(value)) + return; + + context.Graph.AddReference(ValidationKind.Avatar, value, mod.Id, file, line); + } + + /// + /// Collects content references hidden in the named arguments of an + /// action command. The scenario DSL treats action arguments as + /// opaque dialog variables, so without this pass a typo like + /// action character.add_to_party type=Murakamiy would only surface + /// at runtime as a factory error log. Values from + /// become graph references and are + /// reconciled by , which reports the + /// offending scenario file and line. Comma-separated values (e.g. + /// battle.init enemies=) produce one reference per entry; values with the + /// ${name} marker are computed at play time and cannot be resolved + /// statically, so they are skipped instead of misreported. + /// + private static void AddActionArgumentReferences( + ValidationContext context, + IModInfo mod, + string file, + int line, + string actionName, + Dictionary named) + { + if (!ActionArgumentReferences.TryGetKinds(actionName, out var kinds)) + return; + + foreach (var pair in kinds) + { + if (!named.TryGetValue(pair.Key, out var value) || string.IsNullOrWhiteSpace(value)) + continue; + + if (value.Contains("$" + "{")) + continue; + + foreach (var part in value.Split(',')) + { + var target = part.Trim(); + if (target.Length == 0) + continue; + + if (pair.Value == ValidationKind.Scene && SceneManager.IsNativeSceneName(target)) + continue; + + context.Graph.AddReference(pair.Value, target, mod.Id, file, line); + } + } + } + + private static void AddReference(ValidationContext context, IModInfo mod, string file, int line, PositionalReferenceKind kind, string target) + { + switch (kind) + { + case PositionalReferenceKind.Background: + context.Graph.AddReference(ValidationKind.Background, target, mod.Id, file, line); + break; + case PositionalReferenceKind.Scene: + if (!SceneManager.IsNativeSceneName(target)) + context.Graph.AddReference(ValidationKind.Scene, target, mod.Id, file, line); + break; + case PositionalReferenceKind.Music: + context.Graph.AddReference(ValidationKind.Music, target, mod.Id, file, line); + break; + case PositionalReferenceKind.Sound: + context.Graph.AddReference(ValidationKind.Sound, target, mod.Id, file, line); + break; + case PositionalReferenceKind.Effect: + context.Graph.AddReference(ValidationKind.Effect, target, mod.Id, file, line); + break; + case PositionalReferenceKind.Action: + CheckAction(context, mod, file, line, target); + break; + } + } + + private static void CheckAction(ValidationContext context, IModInfo mod, string file, int line, string target) + { + if (GetActionNames().Contains(target)) + return; + + context.AddError("REF_NOT_FOUND", + $"scenario action '{target}' is not registered", + mod.Id, file, line, + "check the action name or make sure the mod that defines it is enabled"); + } + + private static HashSet GetActionNames() + { + lock (actionLock) + { + if (actionNames != null) + return actionNames; + + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var action in ScenarioActionFactory.Instance.GetAll()) + names.Add(action.Name); + + actionNames = names; + return actionNames; + } + } + + private static void SplitArguments(List tokens, List positional, Dictionary named, out string arrowTarget) + { + arrowTarget = string.Empty; + + for (var i = 0; i < tokens.Count; i++) + { + var token = tokens[i]; + + if (token == "->") + { + if (i + 1 < tokens.Count) + { + arrowTarget = tokens[i + 1].TrimStart(':'); + i++; + } + continue; + } + + var equalsIndex = token.IndexOf('='); + if (equalsIndex > 0) + { + var name = token.Substring(0, equalsIndex); + if (IsValidIdent(name)) + { + named[name.ToLowerInvariant()] = token.Substring(equalsIndex + 1); + continue; + } + } + + positional.Add(token); + } + } + + private static List Tokenize(string line) + { + var result = new List(); + var i = 0; + + while (i < line.Length) + { + if (char.IsWhiteSpace(line[i])) + { + i++; + continue; + } + + if (line[i] == '"') + { + i++; + var start = i; + while (i < line.Length && line[i] != '"') + { + if (line[i] == '\\' && i + 1 < line.Length) + i++; + i++; + } + result.Add(line.Substring(start, i - start).Replace("\\\"", "\"")); + if (i < line.Length) + i++; + continue; + } + + var tokenStart = i; + while (i < line.Length && !char.IsWhiteSpace(line[i])) + i++; + result.Add(line.Substring(tokenStart, i - tokenStart)); + } + + return result; + } + + private static bool IsValidIdent(string value) + { + if (string.IsNullOrEmpty(value)) + return false; + + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (!char.IsLetterOrDigit(c) && c != '_') + return false; + } + + return true; + } + } +} diff --git a/src/ScenePatchStrategy.cs b/src/ScenePatchStrategy.cs new file mode 100644 index 0000000..99490d8 --- /dev/null +++ b/src/ScenePatchStrategy.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using Cthangover.Core.Mods; +using Cthangover.Core.Scenes.Patching; +using Cthangover.Core.Scenes.TscnParsing; + +namespace Cthangover.Validator +{ + /// + /// Phase: Contracts. Walks every mod's patches/scenes/** tree, parses each patch with + /// the same the runtime uses and reports authoring problems: + /// files that are not inherited scenes, targets no mod provides, attempts to delete the scene + /// root, malformed connection selectors and unknown metadata/_patch_* keys. + /// + /// Target existence is checked only against mod scenes — native res://scenes/** files + /// are invisible to the background validator, so a native target is accepted with a note + /// instead of an error. Deep merge results (whether an override path exists in the target) + /// cannot be validated without loading the target, which the validator deliberately avoids. + /// + public class ScenePatchStrategy : IValidationStrategy + { + private static readonly HashSet KnownReservedKeys = new(StringComparer.Ordinal) + { + PatchMetadata.Delete, + PatchMetadata.Remove, + PatchMetadata.AddGroups, + PatchMetadata.RemoveGroups, + PatchMetadata.DeleteNodes, + PatchMetadata.RemoveConnections, + PatchMetadata.RemoveExtResources, + PatchMetadata.Gc, + }; + + public string Id => "scene_patches"; + public ValidationPhase Phase => ValidationPhase.Contracts; + public int Order => 110; + public ValidationFeature Feature => ValidationFeature.Json; + + /// Validates scene patch .tscn files: known patch operations and target scenes that actually exist. + public void Validate(ValidationContext context) + { + var sceneKeys = CollectSceneKeys(context); + + foreach (var mod in context.Mods) + { + context.ThrowIfCancellationRequested(); + + var provider = mod.FileProvider; + if (provider == null) + continue; + + foreach (var file in ModFileReader.ListFiles(provider, ScenePatchResolver.PatchDirectory, true)) + { + context.ThrowIfCancellationRequested(); + if (!file.EndsWith(".tscn", StringComparison.OrdinalIgnoreCase)) + continue; + + ValidatePatch(context, mod, provider, file, sceneKeys); + } + } + } + + private static HashSet CollectSceneKeys(ValidationContext context) + { + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var mod in context.Mods) + { + var provider = mod.FileProvider; + if (provider == null) + continue; + + foreach (var file in ModFileReader.ListFiles(provider, "scenes", true)) + { + if (file.EndsWith(".tscn", StringComparison.OrdinalIgnoreCase) + || file.EndsWith(".scn", StringComparison.OrdinalIgnoreCase)) + keys.Add(file.Replace('\\', '/')); + } + } + return keys; + } + + private static void ValidatePatch(ValidationContext context, IModInfo mod, IModFileProvider provider, string file, HashSet sceneKeys) + { + var text = provider.ReadFileText(file); + if (!ScenePatchParser.TryParse(text, mod.Id, file, out var patch, out var error)) + { + context.AddError("SCENE_PATCH_INVALID", $"invalid scene patch: {error}", mod.Id, file, 0, + "a scene patch is an inherited scene: root node with instance=ExtResource(\"PackedScene\")"); + return; + } + + if (!TargetExists(patch.TargetPath, sceneKeys)) + { + context.AddWarning("SCENE_PATCH_TARGET_NOT_FOUND", + $"patch target '{patch.TargetPath}' is not provided by any mod (native scene targets are not visible to the validator)", + mod.Id, file, 0, + "check the ext_resource path or ship the target scene"); + } + + var roots = patch.Scene.Nodes.FindAll(n => n.Parent == null); + if (roots.Count != 1) + { + context.AddError("SCENE_PATCH_ROOT_COUNT", $"patch declares {roots.Count} root nodes, expected exactly one", mod.Id, file, 0); + return; + } + + var root = roots[0]; + if (IsTrue(root.Properties.TryGetValue(PatchMetadata.Delete, out var delete) ? delete : null)) + { + context.AddError("SCENE_PATCH_ROOT_DELETE", "patch tries to delete the scene root", mod.Id, file, 0, + "remove the _patch_delete marker from the root node"); + } + + ValidateReservedKeys(context, mod, file, patch); + ValidateConnectionSelectors(context, mod, file, root); + } + + private static void ValidateReservedKeys(ValidationContext context, IModInfo mod, string file, ScenePatch patch) + { + foreach (var node in patch.Scene.Nodes) + CheckKeys(context, mod, file, node.Properties?.Keys); + + foreach (var sub in patch.Scene.SubResources) + CheckKeys(context, mod, file, sub.Properties?.Keys); + } + + private static void CheckKeys(ValidationContext context, IModInfo mod, string file, IEnumerable keys) + { + if (keys == null) + return; + + foreach (var key in keys) + { + if (!key.StartsWith(PatchMetadata.Prefix, StringComparison.Ordinal) || KnownReservedKeys.Contains(key)) + continue; + + context.AddWarning("SCENE_PATCH_UNKNOWN_KEY", $"unknown reserved scene-patch key '{key}'", mod.Id, file, 0, + "reserved keys are listed in the scene patch documentation"); + } + } + + private static void ValidateConnectionSelectors(ValidationContext context, IModInfo mod, string file, ParsedNode root) + { + if (!root.Properties.TryGetValue(PatchMetadata.RemoveConnections, out var value)) + return; + + foreach (var raw in EnumerateValues(value)) + { + if (!ConnectionSelector.TryParse(raw, out _, out var error)) + { + context.AddError("SCENE_PATCH_BAD_SELECTOR", $"invalid connection selector '{raw}': {error}", mod.Id, file, 0, + "use key=value pairs separated by ';', e.g. from=Panel/Btn;signal=pressed"); + } + } + } + + private static bool TargetExists(string targetPath, HashSet sceneKeys) + { + if (string.IsNullOrEmpty(targetPath)) + return false; + + foreach (var key in sceneKeys) + { + if (key.Equals(targetPath, StringComparison.OrdinalIgnoreCase) + || key.EndsWith("/" + targetPath, StringComparison.OrdinalIgnoreCase) + || targetPath.EndsWith("/" + key, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + private static IEnumerable EnumerateValues(object value) + { + switch (value) + { + case string[] array: + return array; + case string single: + return new[] { single }; + default: + return Array.Empty(); + } + } + + private static bool IsTrue(object value) + { + return (value is bool flag && flag) + || (value is string text && bool.TryParse(text, out var parsed) && parsed); + } + } +} diff --git a/src/SkillEntityStrategy.cs b/src/SkillEntityStrategy.cs new file mode 100644 index 0000000..3e1a91e --- /dev/null +++ b/src/SkillEntityStrategy.cs @@ -0,0 +1,13 @@ +namespace Cthangover.Validator +{ + /// + /// Phase: Contracts. Collects skill declarations. Skills are a standalone + /// entity kind — they used to share the characters/ directory with + /// characters and actions, which is exactly the ambiguity the kind system + /// removed; here they are declared under their own kind. + /// + public class SkillEntityStrategy : EntityKindStrategy + { + public override string Kind => ValidationKind.Skill; + } +} diff --git a/src/StatusEffectEntityStrategy.cs b/src/StatusEffectEntityStrategy.cs new file mode 100644 index 0000000..82bf5c4 --- /dev/null +++ b/src/StatusEffectEntityStrategy.cs @@ -0,0 +1,13 @@ +namespace Cthangover.Validator +{ + /// + /// Phase: Contracts. Collects status effect declarations. Definitions name + /// behaviour sets and icons that are resolved at runtime by the battle mods; + /// for now the validator records the declaration so unused or duplicated + /// effects can be reported. + /// + public class StatusEffectEntityStrategy : EntityKindStrategy + { + public override string Kind => ValidationKind.StatusEffect; + } +} diff --git a/src/UnusedDeclarationStrategy.cs b/src/UnusedDeclarationStrategy.cs new file mode 100644 index 0000000..91f0404 --- /dev/null +++ b/src/UnusedDeclarationStrategy.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; + +namespace Cthangover.Validator +{ + /// + /// Phase: Reconciliation. Reports declarations that no static reference + /// points at. Coverage is deliberately limited to kinds whose references + /// are fully collected today — items (loot, recipe ingredients) and actions + /// (character action lists). Backgrounds, avatars and scenes are consumed + /// mostly from .scenario files, so they stay out until a scenario + /// strategy contributes those references; otherwise every asset would be + /// flagged. + /// + public class UnusedDeclarationStrategy : IValidationStrategy + { + public string Id => "unused_declarations"; + public ValidationPhase Phase => ValidationPhase.Reconciliation; + public int Order => 300; + public ValidationFeature Feature => ValidationFeature.Json; + + /// Reports items and actions that are declared but never referenced (DECL_UNUSED). + public void Validate(ValidationContext context) + { + var used = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var reference in context.Graph.References) + used.Add(Key(reference.Kind, reference.TargetId)); + + foreach (var declaration in context.Graph.Declarations) + { + context.ThrowIfCancellationRequested(); + + if (declaration.Kind != ValidationKind.Item && declaration.Kind != ValidationKind.Action) + continue; + + if (used.Contains(Key(declaration.Kind, declaration.Id))) + continue; + + context.AddInfo("DECL_UNUSED", + $"{declaration.Kind} '{declaration.Id}' is never referenced statically", + declaration.ModId, declaration.File, declaration.Line); + } + } + + private static string Key(string kind, string id) => kind + "|" + id; + } +} diff --git a/src/ValidationContext.cs b/src/ValidationContext.cs new file mode 100644 index 0000000..c9e43ab --- /dev/null +++ b/src/ValidationContext.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using System.Threading; +using Cthangover.Core.Mods; + +namespace Cthangover.Validator +{ + /// + /// Everything a strategy needs: the mod snapshot, the report to fill, the + /// shared fact graph and the cancellation token. Instances are created per + /// pass on the validator background thread; strategies run sequentially, so + /// no locking is required inside the context or the graph. + /// + public class ValidationContext + { + public ValidationContext( + IReadOnlyList mods, + ValidationReport report, + ValidationGraph graph, + CancellationToken cancellationToken) + { + Mods = mods; + Report = report; + Graph = graph; + CancellationToken = cancellationToken; + } + + /// Ordered snapshot of the mods to validate. + public IReadOnlyList Mods { get; } + + /// Report being assembled; strategies append issues to it. + public ValidationReport Report { get; } + + /// Facts collected by contract strategies. + public ValidationGraph Graph { get; } + + /// Cooperative cancellation checked between mods and documents. + public CancellationToken CancellationToken { get; } + + /// Appends an error issue to the report (validation failure). + public void AddError(string code, string message, string mod, string file = null, int line = 0, string suggestion = null, List related = null) + => Report.Issues.Add(Create(ValidationSeverity.Error, code, message, mod, file, line, suggestion, related)); + + /// Appends a warning issue (suspicious but not fatal). + public void AddWarning(string code, string message, string mod, string file = null, int line = 0, string suggestion = null, List related = null) + => Report.Issues.Add(Create(ValidationSeverity.Warning, code, message, mod, file, line, suggestion, related)); + + /// Appends an informational issue (e.g. unused content). + public void AddInfo(string code, string message, string mod, string file = null, int line = 0, string suggestion = null, List related = null) + => Report.Issues.Add(Create(ValidationSeverity.Info, code, message, mod, file, line, suggestion, related)); + + /// Cooperative cancellation hook for strategies to call between documents. + public void ThrowIfCancellationRequested() => CancellationToken.ThrowIfCancellationRequested(); + + private static ValidationIssue Create( + string severity, + string code, + string message, + string mod, + string file, + int line, + string suggestion, + List related) + { + return new ValidationIssue + { + Severity = severity, + Code = code, + Message = message, + Mod = mod, + File = file, + Line = line, + Suggestion = suggestion, + Related = related, + }; + } + } +} diff --git a/src/ValidationDeclaration.cs b/src/ValidationDeclaration.cs new file mode 100644 index 0000000..200f98b --- /dev/null +++ b/src/ValidationDeclaration.cs @@ -0,0 +1,17 @@ +namespace Cthangover.Validator +{ + /// + /// A declared entity discovered while collecting contracts (JSON id, asset + /// file id, …). Declarations are matched against + /// during reconciliation, and declarations + /// of kinds with reliable reference coverage can be checked for being unused. + /// + public class ValidationDeclaration + { + public string Kind { get; set; } + public string Id { get; set; } + public string ModId { get; set; } + public string File { get; set; } + public int Line { get; set; } + } +} diff --git a/src/ValidationFeature.cs b/src/ValidationFeature.cs new file mode 100644 index 0000000..a33971a --- /dev/null +++ b/src/ValidationFeature.cs @@ -0,0 +1,23 @@ +namespace Cthangover.Validator +{ + /// + /// Capability group of a validation strategy, mapped to a user setting by + /// . Strategies in a disabled feature are + /// skipped, so expensive analysis (Roslyn, GDScript) stays opt-in without + /// every strategy having to read the settings blob itself. + /// + public enum ValidationFeature + { + /// Cheap file-level and JSON analysis; always enabled. + Json, + + /// Roslyn analysis of mod C# sources; gated by parse_csharp. + CSharp, + + /// GDScript probe; gated by parse_gdscript (off by default). + GdScript, + + /// Scenario DSL analysis; reserved for a future strategy. + Scenarios, + } +} diff --git a/src/ValidationGraph.cs b/src/ValidationGraph.cs new file mode 100644 index 0000000..72a948d --- /dev/null +++ b/src/ValidationGraph.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Cthangover.Validator +{ + /// + /// Shared fact base of a validation pass. Contract strategies append to it + /// and reconciliation strategies query it, which keeps cross-cutting checks + /// (missing references, type conflicts, unused declarations) independent of + /// the parser that produced the facts. A scenario strategy added later only + /// needs to call and . + /// + /// The graph is filled and read sequentially by one background thread, so it + /// needs no synchronisation; it is never exposed to the main thread. + /// + public class ValidationGraph + { + private readonly Dictionary> declarations = new(StringComparer.OrdinalIgnoreCase); + private readonly List references = new(); + private readonly HashSet declaredKeys = new(StringComparer.OrdinalIgnoreCase); + + /// All references collected so far. + public IReadOnlyList References => references; + + /// All declarations collected so far, flattened across kinds. + public IEnumerable Declarations => declarations.Values.SelectMany(list => list); + + /// + /// Registers an entity declaration. Empty kinds/ids are ignored so + /// parsers can call this without nil-checks. + /// + public void AddDeclaration(string kind, string id, string modId, string file, int line) + { + if (string.IsNullOrWhiteSpace(kind) || string.IsNullOrWhiteSpace(id)) + return; + + if (!declarations.TryGetValue(kind, out var list)) + { + list = new List(); + declarations[kind] = list; + } + + list.Add(new ValidationDeclaration + { + Kind = kind, + Id = id, + ModId = modId, + File = file, + Line = line, + }); + } + + /// + /// Registers a reference to an entity. Empty kinds/targets are ignored. + /// + public void AddReference(string kind, string targetId, string modId, string file, int line) + { + if (string.IsNullOrWhiteSpace(kind) || string.IsNullOrWhiteSpace(targetId)) + return; + + references.Add(new ValidationReference + { + Kind = kind, + TargetId = targetId, + ModId = modId, + File = file, + Line = line, + }); + } + + /// + /// Registers a property key seen in JSON entity data. Used by the C# + /// strategy to distinguish declared properties from typos. + /// + public void AddDeclaredKey(string key) + { + if (!string.IsNullOrWhiteSpace(key)) + declaredKeys.Add(key); + } + + /// True when at least one declaration of the given kind and id exists. + public bool HasDeclaration(string kind, string id) + { + return declarations.TryGetValue(kind, out var list) + && list.Any(d => string.Equals(d.Id, id, StringComparison.OrdinalIgnoreCase)); + } + + /// True when the key was seen in any JSON entity or action Values list. + public bool IsKeyDeclared(string key) => declaredKeys.Contains(key); + } +} diff --git a/src/ValidationIssue.cs b/src/ValidationIssue.cs new file mode 100644 index 0000000..76259e9 --- /dev/null +++ b/src/ValidationIssue.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Cthangover.Validator +{ + /// + /// A single machine-readable finding. The code catalogue + /// (SCHEMA_MISSING_FIELD, REF_NOT_FOUND, TYPE_CONFLICT, …) + /// is shared with the validator plan, so the report stays stable for LLM + /// consumers while the set of checks grows. + /// + public class ValidationIssue + { + /// error, warning or info. + [JsonPropertyName("severity")] + public string Severity { get; set; } + + /// Stable machine code of the check that produced the issue. + [JsonPropertyName("code")] + public string Code { get; set; } + + /// Human-readable explanation. + [JsonPropertyName("message")] + public string Message { get; set; } + + /// Mod the issue belongs to. + [JsonPropertyName("mod")] + public string Mod { get; set; } + + /// Mod-relative path of the offending file. + [JsonPropertyName("file")] + public string File { get; set; } + + /// One-based line number, or 0 when unknown. + [JsonPropertyName("line")] + public int Line { get; set; } + + /// Optional remediation hint. + [JsonPropertyName("suggestion")] + public string Suggestion { get; set; } + + /// Related locations in mod:file:line form, when the issue spans several usages. + [JsonPropertyName("related")] + public List Related { get; set; } + } +} diff --git a/src/ValidationKind.cs b/src/ValidationKind.cs new file mode 100644 index 0000000..d471a09 --- /dev/null +++ b/src/ValidationKind.cs @@ -0,0 +1,28 @@ +namespace Cthangover.Validator +{ + /// + /// Entity kinds shared between JSON declaration collection and reference + /// validation. A scenario strategy added later reuses these constants when + /// it extracts background, avatar, quest and scene references from + /// .scenario files, so both sides of the graph speak the same + /// vocabulary. + /// + public static class ValidationKind + { + public const string Item = "item"; + public const string Quest = "quest"; + public const string Character = "character"; + public const string Action = "action"; + public const string StatusEffect = "status_effect"; + public const string Skill = "skill"; + public const string Recipe = "recipe"; + public const string Scene = "scene"; + public const string Interactive = "interactive"; + public const string Background = "background"; + public const string Avatar = "avatar"; + public const string Music = "music"; + public const string Sound = "sound"; + public const string Effect = "effect"; + public const string ScenePath = "scene_path"; + } +} diff --git a/src/ValidationModStatus.cs b/src/ValidationModStatus.cs new file mode 100644 index 0000000..7271e7b --- /dev/null +++ b/src/ValidationModStatus.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Cthangover.Validator +{ + /// + /// Per-mod section of the report. status is ok when the mod + /// contributed no errors, otherwise errors — the field lets a + /// consumer triage mods without scanning the flat issue list. + /// + public class ValidationModStatus + { + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("version")] + public string Version { get; set; } + + [JsonPropertyName("install_sha256")] + public string InstallSha256 { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } = "ok"; + } +} diff --git a/src/ValidationPhase.cs b/src/ValidationPhase.cs new file mode 100644 index 0000000..bde6c7d --- /dev/null +++ b/src/ValidationPhase.cs @@ -0,0 +1,25 @@ +namespace Cthangover.Validator +{ + /// + /// Ordered stages of a validation pass. Strategies declare the stage they + /// belong to, which lets later strategies consume facts collected by earlier + /// ones: schema checks need nothing, contract collectors (JSON graph, C# + /// usages) produce the facts, and reconciliation strategies compare the + /// collected declarations, references and usages against each other. + /// + /// A scenario-validation strategy added later normally belongs to Contracts + /// (extract declarations and references from .scenario files) and + /// Reconciliation (match them against the graph). + /// + public enum ValidationPhase + { + /// Shape of source files: manifest fields, JSON syntax. + Schema = 0, + + /// Fact collection: entity declarations, references, code usages. + Contracts = 1, + + /// Cross-checks: missing references, conflicts, unused declarations. + Reconciliation = 2, + } +} diff --git a/src/ValidationReference.cs b/src/ValidationReference.cs new file mode 100644 index 0000000..6636c79 --- /dev/null +++ b/src/ValidationReference.cs @@ -0,0 +1,16 @@ +namespace Cthangover.Validator +{ + /// + /// A reference from one entity to another (loot item, default background, + /// character action, …). Collected by contract strategies and matched + /// against declarations during reconciliation. + /// + public class ValidationReference + { + public string Kind { get; set; } + public string TargetId { get; set; } + public string ModId { get; set; } + public string File { get; set; } + public int Line { get; set; } + } +} diff --git a/src/ValidationReport.cs b/src/ValidationReport.cs new file mode 100644 index 0000000..18b5b67 --- /dev/null +++ b/src/ValidationReport.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Cthangover.Validator +{ + /// + /// Root DTO of the machine-readable validation report written to + /// user://validation/report.json. Snake_case JSON names are fixed here + /// rather than in serialiser options, so the format cannot drift when global + /// options change. + /// + public class ValidationReport + { + /// UTC timestamp in round-trip format. + [JsonPropertyName("generated_at")] + public string GeneratedAt { get; set; } + + /// Game version the report was produced for. + [JsonPropertyName("game_version")] + public string GameVersion { get; set; } + + /// Validator mod version; part of the report cache key. + [JsonPropertyName("validator_version")] + public string ValidatorVersion { get; set; } + + /// Issue counters. + [JsonPropertyName("summary")] + public ValidationSummary Summary { get; set; } = new(); + + /// Validated mods in load order with their status. + [JsonPropertyName("mods")] + public List Mods { get; set; } = new(); + + /// All findings of the pass. + [JsonPropertyName("issues")] + public List Issues { get; set; } = new(); + } +} diff --git a/src/ValidationSeverity.cs b/src/ValidationSeverity.cs new file mode 100644 index 0000000..671022e --- /dev/null +++ b/src/ValidationSeverity.cs @@ -0,0 +1,15 @@ +namespace Cthangover.Validator +{ + /// + /// Severity labels used by and counted into + /// . Kept as string constants because the + /// report is a machine-readable JSON contract consumed by external tooling + /// and LLMs that expect lower-case labels. + /// + public static class ValidationSeverity + { + public const string Error = "error"; + public const string Warning = "warning"; + public const string Info = "info"; + } +} diff --git a/src/ValidationStrategyConsumer.cs b/src/ValidationStrategyConsumer.cs new file mode 100644 index 0000000..36185e1 --- /dev/null +++ b/src/ValidationStrategyConsumer.cs @@ -0,0 +1,21 @@ +using Cthangover.Core.Mods.Consumers; + +namespace Cthangover.Validator +{ + /// + /// Feeds with every mod's + /// implementations as the mods load and + /// removes them when their mod unloads. + /// + public class ValidationStrategyConsumer : ModTypeConsumer + { + /// + public override string Id => "validator.strategies"; + + /// + protected override void Add(IValidationStrategy instance, string modId) => ValidationStrategyRegistry.Register(instance); + + /// + protected override void Remove(IValidationStrategy instance, string modId) => ValidationStrategyRegistry.Unregister(instance); + } +} diff --git a/src/ValidationStrategyRegistry.cs b/src/ValidationStrategyRegistry.cs new file mode 100644 index 0000000..d11e31d --- /dev/null +++ b/src/ValidationStrategyRegistry.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; + +namespace Cthangover.Validator +{ + /// + /// Registry of all implementations, served + /// in deterministic execution order. It discovers nothing itself: + /// delivers the strategies of every + /// mod as the kernel registers its assembly and takes them back when the mod + /// unloads (game switch, hot reload), so a validation run never executes a + /// strategy of a dead assembly. Adding a new strategy (for example scenario + /// validation) is just shipping a new public class with a parameterless + /// constructor in any mod. + /// + public static class ValidationStrategyRegistry + { + private static readonly object sync = new(); + private static readonly List strategies = new(); + + /// Returns a snapshot of the registered strategies sorted by phase and order. + public static IReadOnlyList GetStrategies() + { + lock (sync) + return strategies.ToArray(); + } + + /// Adds a strategy and keeps the execution order. + public static void Register(IValidationStrategy strategy) + { + if (strategy == null) + return; + lock (sync) + { + strategies.Add(strategy); + strategies.Sort(Compare); + } + } + + /// Removes exactly (its mod is being unloaded). + public static void Unregister(IValidationStrategy strategy) + { + lock (sync) + strategies.Remove(strategy); + } + + private static int Compare(IValidationStrategy a, IValidationStrategy b) + { + var phase = a.Phase.CompareTo(b.Phase); + if (phase != 0) + return phase; + + var order = a.Order.CompareTo(b.Order); + if (order != 0) + return order; + + return string.Compare(a.Id, b.Id, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/ValidationSummary.cs b/src/ValidationSummary.cs new file mode 100644 index 0000000..7bc867c --- /dev/null +++ b/src/ValidationSummary.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Cthangover.Validator +{ + /// + /// Aggregated issue counters placed at the top of the report so consumers can + /// decide whether to read the full issue list without deserialising it. + /// + public class ValidationSummary + { + [JsonPropertyName("errors")] + public int Errors { get; set; } + + [JsonPropertyName("warnings")] + public int Warnings { get; set; } + + [JsonPropertyName("infos")] + public int Infos { get; set; } + } +} diff --git a/src/ValidatorInitializer.cs b/src/ValidatorInitializer.cs new file mode 100644 index 0000000..ae6e845 --- /dev/null +++ b/src/ValidatorInitializer.cs @@ -0,0 +1,51 @@ +using Cthangover.Core.Mods; + +namespace Cthangover.Validator +{ + /// + /// Lifecycle entry point of the validator mod. + /// is the earliest moment when every mod has been discovered, compiled and + /// registered, so it is the only reliable trigger for a global pass — the mod + /// list cannot be assembled from , + /// because asset-only mods (JSON/PNG without code) never produce an assembly + /// and therefore never raise ModLoadedEvent. + /// + /// The hook runs on the main thread; all Godot-bound work (settings load, + /// user:// path resolution) happens inside , + /// and the validation itself continues on a background task so the game start + /// is never blocked. On unload (or re-registration during an in-process + /// reload) the pass in flight is stopped, see . + /// + public class ValidatorInitializer : IModInitializer, IModUnloadAware + { + /// + /// Intentionally empty: asset-only mods are invisible to this callback, so + /// the validator always works from the complete ModRegistry snapshot + /// taken in instead of accumulating + /// mods here. + /// + public void OnModLoaded(string modId) + { + } + + /// + /// Starts the background validation pass once all mods and their JSON + /// resources are available. + /// + public void OnModResourcesReady() + { + ValidatorService.Start(); + } + + /// Stops the background pass while the validator's types are still alive. + public void OnModUnloading() + { + ValidatorService.Stop(); + } + + /// Nothing to drop: the validator keeps no references to other mods between passes. + public void OnModUnloaded(string modId) + { + } + } +} diff --git a/src/ValidatorService.cs b/src/ValidatorService.cs new file mode 100644 index 0000000..3658407 --- /dev/null +++ b/src/ValidatorService.cs @@ -0,0 +1,313 @@ +using System; +using Cthangover.Mods.Dialog.Scenarios; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.Mods; +using Cthangover.Core.Utils; + +namespace Cthangover.Validator +{ + /// + /// Orchestrates the validator pass. is called on the main + /// thread and returns immediately: it snapshots the ordered mod list and the + /// resolved report directory, then hands pure .NET work to a background task so + /// the game start is never blocked. + /// + /// The background task may touch only and + /// — never Godot types and never resolver caches — + /// because mod providers are disposed by ModManager.Reload on the main + /// thread. Strategies are discovered by reflection and executed in + /// (Phase, Order, Id) order: contract strategies fill the shared + /// , reconciliation strategies consume it. + /// + /// A pass in flight is cancelled by when the mod is + /// unloaded or re-registered by an in-process mod-set reload: its thread + /// runs validator code (and reflects over other mods' assemblies), so a + /// pass left running would keep every one of those assemblies loaded, and + /// it would read file providers the reload disposes. + /// + public static class ValidatorService + { + /// Version reported in the JSON report; bump alongside the manifest. + public const string Version = "0.1.0"; + + private static readonly object stateLock = new(); + private static bool running; + private static CancellationTokenSource cancellation; + private static Task pass; + private static List queuedMods; + private static string queuedReportDirectory; + + /// True while a validation pass is in flight. + public static bool IsRunning + { + get + { + lock (stateLock) + return running; + } + } + + /// + /// Main-thread entry point invoked once after all mods are loaded. Reloads + /// settings (persisted values are not applied by the engine yet at this + /// point), checks the enable flags, guards against concurrent passes, takes + /// the mod snapshot, and schedules the background run. + /// + public static void Start() + { + ValidatorSettings.Reload(); + + if (!ValidatorSettings.Enabled || !ValidatorSettings.RunOnStart) + { + GameLogger.Log("VALIDATOR", "validator: disabled by settings"); + return; + } + + lock (stateLock) + { + if (running) + return; + running = true; + } + + var mods = SnapshotMods(); + var reportDirectory = ValidatorSettings.GetReportDirectory(); + + WarmUp(); + + GameLogger.Log("VALIDATOR", $"validator: started, {mods.Count} mod(s) queued"); + lock (stateLock) + { + queuedMods = mods; + queuedReportDirectory = reportDirectory; + cancellation = new CancellationTokenSource(); + pass = Task.Run(RunQueued); + } + } + + /// + /// Cancels the pass in flight and waits (bounded) until its thread has + /// left validator code. Main thread, from the unload notification. + /// + public static void Stop() + { + Task running; + lock (stateLock) + { + cancellation?.Cancel(); + running = pass; + } + if (running == null) + return; + try + { + if (!running.Wait(TimeSpan.FromSeconds(10))) + GameLogger.Log("VALIDATOR", "validator: pass did not stop within 10 s; its mods stay in memory until it ends", LogLevel.Warning); + } + catch (AggregateException) + { + // Run logs its own failures; a cancelled pass is the expected outcome here. + } + } + + private static void RunQueued() + { + List mods; + string reportDirectory; + CancellationToken token; + lock (stateLock) + { + mods = queuedMods; + reportDirectory = queuedReportDirectory; + token = cancellation.Token; + queuedMods = null; + } + Run(mods, reportDirectory, token); + } + + private static List SnapshotMods() + { + var result = new List(); + var registry = ModRegistry.Instance; + + foreach (var modId in registry.GetOrderedModIds()) + { + if (registry.Mods.TryGetValue(modId, out var info) && info != null) + result.Add(info); + } + + return result; + } + + private static void Run(List mods, string reportDirectory, CancellationToken token) + { + try + { + var report = BuildReport(mods, token); + + GameLogger.Log("VALIDATOR", + $"validator: {report.Summary.Errors} errors, {report.Summary.Warnings} warnings, {report.Summary.Infos} infos"); + + if (ValidatorSettings.WriteReport) + ReportWriter.Write(report, reportDirectory); + } + catch (OperationCanceledException) + { + GameLogger.Log("VALIDATOR", "validator: pass cancelled (mod set reloading)"); + } + catch (Exception ex) + { + GameLogger.Log("VALIDATOR", $"validator: failed: {ex.Message}\n{ex.StackTrace}", LogLevel.Error); + } + finally + { + lock (stateLock) + running = false; + } + } + + private static ValidationReport BuildReport(List mods, CancellationToken token) + { + var report = new ValidationReport + { + GeneratedAt = DateTime.UtcNow.ToString("O"), + GameVersion = ValidatorSettings.GameVersion, + ValidatorVersion = Version, + }; + + foreach (var mod in mods) + { + report.Mods.Add(new ValidationModStatus + { + Id = mod.Id, + Version = mod.Version, + InstallSha256 = (mod as ModInfo)?.InstallSha256, + Status = "ok", + }); + } + + Analyze(mods, report, token); + ApplySummary(report); + return report; + } + + /// + /// Runs every enabled strategy over and returns + /// the populated context — declarations, references and issues — + /// without writing a report. Other tool mods (e.g. the resource atlas) + /// build on the same graph instead of re-parsing scenarios and data. + /// Call on the main thread first. + /// + public static ValidationContext Analyze(IReadOnlyList mods, ValidationReport report, CancellationToken cancellationToken) + { + var graph = new ValidationGraph(); + var context = new ValidationContext(mods, report ?? new ValidationReport(), graph, cancellationToken); + var strategies = ValidationStrategyRegistry.GetStrategies(); + + GameLogger.Log("VALIDATOR", $"validator: running {strategies.Count} strategy(ies)"); + foreach (var strategy in strategies) + { + if (!IsFeatureEnabled(strategy.Feature)) + { + GameLogger.Log("VALIDATOR", $"validator: strategy {strategy.Id} skipped, feature {strategy.Feature} disabled", LogLevel.Debug); + continue; + } + + GameLogger.Log("VALIDATOR", $"validator: strategy {strategy.Id} ({strategy.Phase})", LogLevel.Debug); + try + { + strategy.Validate(context); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + GameLogger.Log("VALIDATOR", $"validator: strategy {strategy.Id} failed: {ex.Message}\n{ex.StackTrace}", LogLevel.Error); + } + } + + return context; + } + + private static bool IsFeatureEnabled(ValidationFeature feature) + { + switch (feature) + { + case ValidationFeature.CSharp: + return ValidatorSettings.ParseCSharp; + case ValidationFeature.GdScript: + return ValidatorSettings.ParseGdScript; + case ValidationFeature.Scenarios: + return ValidatorSettings.ParseScenarios; + default: + return true; + } + } + + /// + /// Touches the scenario command and action registries while still on the + /// main thread. Both are static/Lazy registries that instantiate plugin + /// classes on first use; warming them here keeps the background + /// validation pass free of first-touch instantiation of game types. + /// + public static void WarmUp() + { + var commands = 0; + foreach (var _ in ScenarioCommandStrategyFactory.GetAllCommandNames()) + commands++; + + var actions = 0; + foreach (var action in ScenarioActionFactory.Instance.GetAll()) + actions++; + + GameLogger.Log("VALIDATOR", $"validator: scenario registries warmed ({commands} command(s), {actions} action(s))", LogLevel.Debug); + } + + private static void ApplySummary(ValidationReport report) + { + var errors = 0; + var warnings = 0; + var infos = 0; + + foreach (var issue in report.Issues) + { + switch (issue.Severity) + { + case ValidationSeverity.Error: + errors++; + break; + case ValidationSeverity.Warning: + warnings++; + break; + case ValidationSeverity.Info: + infos++; + break; + } + } + + report.Summary.Errors = errors; + report.Summary.Warnings = warnings; + report.Summary.Infos = infos; + + foreach (var status in report.Mods) + status.Status = HasErrors(report, status.Id) ? "errors" : "ok"; + } + + private static bool HasErrors(ValidationReport report, string modId) + { + foreach (var issue in report.Issues) + { + if (issue.Severity == ValidationSeverity.Error + && string.Equals(issue.Mod, modId, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + } +} diff --git a/src/ValidatorSettings.cs b/src/ValidatorSettings.cs new file mode 100644 index 0000000..ed3842f --- /dev/null +++ b/src/ValidatorSettings.cs @@ -0,0 +1,172 @@ +using System.Collections.Generic; +using Cthangover.Core.Mods; +using Cthangover.Core.Settings; +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Validator +{ + /// + /// User-facing settings of the validator, discovered by + /// from the compiled mod assembly and rendered + /// by the standard mod settings tab. Values are held in static properties so the + /// background validation task can read them without owning the discovered + /// instance. + /// + /// is called on the main thread from + /// before the task is scheduled: the engine + /// applies persisted settings in ModSettingsRegistry.LoadAll only after + /// ModResourcesReady, which is too late for the start decision, so the + /// mod reads its own settings file eagerly. + /// + public class ValidatorSettings : IModSettings + { + /// Mod id — also the settings file name under user://mod_settings/. + public const string ModId = "validator"; + + /// Default report directory used when the setting is empty or missing. + public const string DefaultReportPath = "user://validation/"; + + /// Master switch; when false the validation task is not started at all. + public static bool Enabled { get; private set; } = true; + + /// When false, the mod stays idle until a future manual trigger is added. + public static bool RunOnStart { get; private set; } = true; + + /// Enables the Roslyn-based analysis of mod C# sources. + public static bool ParseCSharp { get; private set; } = true; + + /// Enables the GDScript probe; off by default because it is expensive. + public static bool ParseGdScript { get; private set; } + + /// Enables .scenario DSL validation (command names, arguments, labels). + public static bool ParseScenarios { get; private set; } = true; + + /// When false the report is kept in memory only and no file is written. + public static bool WriteReport { get; private set; } = true; + + /// Godot-style directory (user://...) the report is written to. + public static string ReportPath { get; private set; } = DefaultReportPath; + + /// + /// Reserved for the future "block mod on error" behaviour. Cannot be honoured + /// today: mods are registered once and never unloaded, so the most it can do + /// is disable the mod for the next launch. + /// + public static bool FailOnError { get; private set; } + + /// Game version resolved from application/config/version, cached for the report. + public static string GameVersion { get; private set; } = ""; + + /// + public IReadOnlyList GetDefinitions() + { + return new List + { + new() { Key = "enabled", Name = "validator/enabled", Type = SettingType.Bool, DefaultValue = "true" }, + new() { Key = "run_on_start", Name = "validator/run_on_start", Type = SettingType.Bool, DefaultValue = "true" }, + new() { Key = "parse_csharp", Name = "validator/parse_csharp", Type = SettingType.Bool, DefaultValue = "true" }, + new() { Key = "parse_gdscript", Name = "validator/parse_gdscript", Type = SettingType.Bool, DefaultValue = "false" }, + new() { Key = "parse_scenarios", Name = "validator/parse_scenarios", Type = SettingType.Bool, DefaultValue = "true" }, + new() { Key = "write_report", Name = "validator/write_report", Type = SettingType.Bool, DefaultValue = "true" }, + new() { Key = "report_path", Name = "validator/report_path", Type = SettingType.String, DefaultValue = DefaultReportPath }, + new() { Key = "fail_on_error", Name = "validator/fail_on_error", Type = SettingType.Bool, DefaultValue = "false" }, + }; + } + + /// + public void WriteValues(DataBlob blob) + { + blob.SetBool("enabled", Enabled); + blob.SetBool("run_on_start", RunOnStart); + blob.SetBool("parse_csharp", ParseCSharp); + blob.SetBool("parse_gdscript", ParseGdScript); + blob.SetBool("parse_scenarios", ParseScenarios); + blob.SetBool("write_report", WriteReport); + blob.SetString("report_path", ReportPath); + blob.SetBool("fail_on_error", FailOnError); + } + + /// + public void ReadValues(DataBlob blob) + { + Apply(blob); + } + + /// + /// Loads persisted values before the validation task starts and caches the + /// game version for the report. Must be called on the main thread because it + /// reads through Godot.FileAccess and ProjectSettings. + /// + public static void Reload() + { + Apply(ModSettingsRegistry.LoadModSettings(ModId)); + GameVersion = ResolveGameVersion(); + } + + /// + /// Resolves the configured report directory to an absolute filesystem path. + /// Must be called on the main thread because it touches ProjectSettings; + /// returns null when the path cannot be resolved. + /// + public static string GetReportDirectory() + { + if (!GameEnvironment.IsRuntime) + return ReportPath; + + try + { + return ProjectSettings.GlobalizePath(ReportPath); + } + catch + { + return null; + } + } + + private static void Apply(DataBlob blob) + { + Enabled = ReadBool(blob, "enabled", true); + RunOnStart = ReadBool(blob, "run_on_start", true); + ParseCSharp = ReadBool(blob, "parse_csharp", true); + ParseGdScript = ReadBool(blob, "parse_gdscript", false); + ParseScenarios = ReadBool(blob, "parse_scenarios", true); + WriteReport = ReadBool(blob, "write_report", true); + FailOnError = ReadBool(blob, "fail_on_error", false); + + ReportPath = blob.GetString("report_path", DefaultReportPath); + if (string.IsNullOrWhiteSpace(ReportPath)) + ReportPath = DefaultReportPath; + } + + /// + /// Reads a boolean the way the settings UI persists it — as text. The typed + /// DataBlob.GetBool is only used as a fallback because the UI writes + /// every control through SetString, which a persisted blob later + /// exposes as a JSON string. + /// + private static bool ReadBool(DataBlob blob, string key, bool defaultValue) + { + var raw = blob.GetString(key, null); + if (raw == null) + return defaultValue; + + return bool.TryParse(raw, out var parsed) ? parsed : defaultValue; + } + + private static string ResolveGameVersion() + { + if (!GameEnvironment.IsRuntime) + return ""; + + try + { + return ProjectSettings.GetSetting("application/config/version").AsString(); + } + catch + { + return ""; + } + } + } +}