From 1ae0b79d1b12157f793086192d1f6341316620cc Mon Sep 17 00:00:00 2001 From: iisaev Date: Sat, 24 Oct 2026 10:01:13 +0200 Subject: [PATCH] Validator reads schemas: schema checks, references and kinds from mod schemas, option and when conditions checked Co-Authored-By: Claude Opus 5.5 --- manifest.json | 2 +- src/ActionArgumentReferences.cs | 67 ------- src/JsonEntityStrategy.cs | 3 + src/ScenarioCommandValidationStrategy.cs | 85 ++++---- src/SchemaValidationStrategy.cs | 240 +++++++++++++++++++++++ src/ValidationSchemas.cs | 34 ++++ 6 files changed, 311 insertions(+), 120 deletions(-) delete mode 100644 src/ActionArgumentReferences.cs create mode 100644 src/SchemaValidationStrategy.cs create mode 100644 src/ValidationSchemas.cs diff --git a/manifest.json b/manifest.json index 9594bd4..8cf424f 100644 --- a/manifest.json +++ b/manifest.json @@ -6,5 +6,5 @@ "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"] + "depends": ["dialog_base>=1", "interactive_base>=1", "schema_base>=1"] } diff --git a/src/ActionArgumentReferences.cs b/src/ActionArgumentReferences.cs deleted file mode 100644 index feb8320..0000000 --- a/src/ActionArgumentReferences.cs +++ /dev/null @@ -1,67 +0,0 @@ -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 (party.add / 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 PartyAdd = new Dictionary - { - ["id"] = 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) - { - ["party.add"] = PartyAdd, - ["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/JsonEntityStrategy.cs b/src/JsonEntityStrategy.cs index 39a023a..59b5e16 100644 --- a/src/JsonEntityStrategy.cs +++ b/src/JsonEntityStrategy.cs @@ -92,6 +92,9 @@ namespace Cthangover.Validator continue; var strategy = EntityKindStrategyRegistry.Resolve(kind); + // A kind some mod describes in a schema is known without a strategy of its own. + if (strategy == null && ValidationSchemas.Of(context).Kinds.ContainsKey(kind)) + continue; if (strategy == null) { context.AddWarning("UNKNOWN_ENTITY_KIND", diff --git a/src/ScenarioCommandValidationStrategy.cs b/src/ScenarioCommandValidationStrategy.cs index c028182..77d6802 100644 --- a/src/ScenarioCommandValidationStrategy.cs +++ b/src/ScenarioCommandValidationStrategy.cs @@ -25,15 +25,16 @@ namespace Cthangover.Validator /// 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. + /// content references inside action arguments (character ids, skills, quest ids, + /// battle enemy lists) come from the actions' schemas () + /// and go into 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 string[] OptionAllowedNamed = { "key", "goto", "if" }; private static readonly object actionLock = new(); private static HashSet actionNames; @@ -144,6 +145,8 @@ namespace Cthangover.Validator CheckEmptyNamed(context, mod, file, lineNumber, command, named); CheckAllowedNamed(context, mod, file, lineNumber, command, named, OptionAllowedNamed); + if (named.TryGetValue("if", out var optionCondition)) + CheckCondition(context, mod, file, lineNumber, command, optionCondition); var target = arrowTarget; if (string.IsNullOrEmpty(target) && named.TryGetValue("goto", out var goTo)) @@ -167,6 +170,9 @@ namespace Cthangover.Validator continue; } + if (command == "when") + CheckCondition(context, mod, file, lineNumber, command, string.Join(" ", positional)); + var spec = strategy as IScenarioCommandSpec; if (spec != null) CheckSpec(context, mod, file, lineNumber, command, positional, named, arrowTarget, spec, labels); @@ -190,13 +196,34 @@ namespace Cthangover.Validator 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); } + /// + /// Parses a condition of an option if= or a when line with the game's own + /// condition parser (every leaf parser the loaded mods registered), so a broken + /// condition is an error here and not a silently hidden option at play time. + /// + private static void CheckCondition(ValidationContext context, IModInfo mod, string file, int line, string command, string condition) + { + if (string.IsNullOrWhiteSpace(condition)) + { + context.AddError("SCENARIO_EMPTY_ARGUMENT", $"'{command}' needs a condition", mod.Id, file, line); + return; + } + try + { + ConditionParser.Parse(condition); + } + catch (FormatException ex) + { + context.AddError("SCENARIO_BAD_CONDITION", $"'{command}': {ex.Message.Split('\n')[0]}", mod.Id, file, line, + "quote a condition with spaces whole (\"if=a && b\") and check names and parentheses"); + } + } + private static HashSet CollectLabels(ValidationContext context, IModInfo mod, string file, string[] lines, int lineOffset) { var labels = new HashSet(StringComparer.Ordinal); @@ -391,52 +418,6 @@ namespace Cthangover.Validator 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 party.add id=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) diff --git a/src/SchemaValidationStrategy.cs b/src/SchemaValidationStrategy.cs new file mode 100644 index 0000000..365eac6 --- /dev/null +++ b/src/SchemaValidationStrategy.cs @@ -0,0 +1,240 @@ +namespace Cthangover.Validator +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text.Json; + using Cthangover.Core.Mods; + using Cthangover.Core.Scenes; + using Cthangover.Mods.Dialog.Scenarios; + using Cthangover.Mods.Schemas; + + /// + /// The validator's reading of the schemas every mod ships (schemas/*.json, merged + /// by ). It replaced the table of action arguments the + /// validator kept in code — four actions, one of them with a wrong parameter name — + /// with descriptions that live next to the actions and that any mod may add: + /// + /// action lines: unknown and missing parameters, bad numbers and choices, and + /// graph references for every entity a parameter names (characters, quests, skills, + /// scenes, items…), which reports when undeclared; + /// other described commands: positional values and their references (a check's stat); + /// entities of described kinds: references in fields, in list elements (a trait's + /// Stat) and in map keys (a character's Stats). + /// + /// Kinds no other strategy declares (shops, stats, archetypes, equipment slots…) are + /// declared here from the mods' data, so their references can be checked and the + /// resource atlas sees their owners and patches. References are added only for kinds + /// that are declared at all; a kind nothing in the graph knows (a map, a tileset) is + /// not judged. Like every strategy it reads the mods' files, never the running game. + /// + public class SchemaValidationStrategy : IValidationStrategy + { + /// + public string Id => "schemas"; + + /// + public ValidationPhase Phase => ValidationPhase.Contracts; + + /// + public int Order => 900; + + /// + public ValidationFeature Feature => ValidationFeature.Json; + + /// + public void Validate(ValidationContext context) + { + var catalog = ValidationSchemas.Of(context); + foreach (var error in catalog.Errors) + context.AddError("SCHEMA_PARSE_ERROR", error, error.Split(':')[0]); + + // Every data file is parsed once: for the declarations and for the field checks. + var entities = context.Mods.Where(m => m.FileProvider != null).ToDictionary(m => m, m => Entities(m).ToList()); + var ownKinds = DeclareDescribedKinds(context, catalog, entities); + var declared = new HashSet(context.Graph.Declarations.Select(d => d.Kind), StringComparer.OrdinalIgnoreCase) { ValidationKind.Scene }; + + foreach (var (mod, list) in entities) + { + context.ThrowIfCancellationRequested(); + foreach (var file in ModFileReader.ListFiles(mod.FileProvider, "scenarios", true).Where(f => f.EndsWith(".scenario", StringComparison.OrdinalIgnoreCase))) + CheckScenario(context, catalog, mod, file, declared, ownKinds); + foreach (var (file, kind, entity) in list) + { + if (catalog.Kinds.TryGetValue(kind, out var schema)) + CheckEntity(context, mod, file, schema, entity, ownKinds.Contains(kind) ? declared : ownKinds); + } + } + } + + private static HashSet DeclareDescribedKinds(ValidationContext context, SchemaCatalog catalog, Dictionary> entities) + { + var known = new HashSet(context.Graph.Declarations.Select(d => d.Kind), StringComparer.OrdinalIgnoreCase); + var own = new HashSet(catalog.Kinds.Keys.Where(k => !known.Contains(k)), StringComparer.OrdinalIgnoreCase); + foreach (var (mod, list) in entities) + { + foreach (var (file, kind, entity) in list) + { + if (own.Contains(kind) && entity.TryGetProperty("Id", out var id)) + context.Graph.AddDeclaration(kind, id.ToString(), mod.Id, file, 0); + } + } + return own; + } + + private static void CheckScenario(ValidationContext context, SchemaCatalog catalog, IModInfo mod, string file, HashSet declared, HashSet ownKinds) + { + string text; + try + { + text = ModFileReader.ReadResolved(mod.FileProvider, file); + } + catch (Exception) + { + return; + } + if (text == null) + return; + var body = ScenarioParser.ParseMetadata(text).remainingText.Split('\n'); + var offset = text.Split('\n').Length - body.Length; + for (var i = 0; i < body.Length; i++) + { + var line = body[i].Trim(); + if (line.Length == 0 || line.StartsWith('#') || line.StartsWith(':')) + continue; + var positional = new List(); + var named = new Dictionary(); + var command = ScenarioParser.SplitLine(line, positional, named, out _); + var lineNumber = offset + i + 1; + if (command == "action" && positional.Count > 0 && catalog.Actions.TryGetValue(positional[0], out var action)) + { + Report(context, SchemaChecker.CheckNamed(action.Id, action.Params, named, null), mod.Id, file, lineNumber); + foreach (var field in action.Params ?? new List()) + { + var value = named.FirstOrDefault(p => string.Equals(p.Key, field.Key, StringComparison.OrdinalIgnoreCase)).Value; + AddReferences(context, field, value, declared, mod.Id, file, lineNumber); + } + } + else if (command != null && command != "action" && catalog.Commands.TryGetValue(command, out var schema)) + { + Report(context, SchemaChecker.CheckPositional(command, schema.Positional, positional, null), mod.Id, file, lineNumber); + // The commands' own strategies already reference their scenes and resources; + // only kinds the schemas brought in are referenced here. + var fields = schema.Positional ?? new List(); + for (var p = 0; p < fields.Count && p < positional.Count; p++) + AddReferences(context, fields[p], positional[p], ownKinds, mod.Id, file, lineNumber); + } + } + } + + private static void CheckEntity(ValidationContext context, IModInfo mod, string file, KindSchema schema, JsonElement entity, HashSet targets) + { + var id = entity.TryGetProperty("Id", out var idProp) ? idProp.ToString() : "?"; + var owner = $"{schema.Kind} '{id}'"; + foreach (var field in schema.Fields) + { + if (!TryGet(entity, field.Key, out var value)) + continue; + switch (field.Type?.ToLowerInvariant()) + { + case "list" when value.ValueKind == JsonValueKind.Array && field.Of != null: + foreach (var element in value.EnumerateArray().Where(e => e.ValueKind == JsonValueKind.Object)) + { + foreach (var sub in field.Of) + { + if (!TryGet(element, sub.Key, out var subValue)) + continue; + Report(context, SchemaChecker.CheckValue($"{owner}.{field.Key}", sub, Text(subValue), null), mod.Id, file, 0); + AddReferences(context, sub, Text(subValue), targets, mod.Id, file, 0); + } + } + break; + case "map" when value.ValueKind == JsonValueKind.Object && targets.Contains(field.Kind ?? string.Empty): + foreach (var key in value.EnumerateObject().Select(p => p.Name)) + context.Graph.AddReference(field.Kind, key, mod.Id, file, 0); + break; + case "ref_list" when value.ValueKind == JsonValueKind.Array: + AddReferences(context, field, string.Join(",", value.EnumerateArray().Select(Text)), targets, mod.Id, file, 0); + break; + default: + if (value.ValueKind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False) + { + Report(context, SchemaChecker.CheckValue(owner, field, Text(value), null), mod.Id, file, 0); + AddReferences(context, field, Text(value), targets, mod.Id, file, 0); + } + break; + } + } + } + + private static void AddReferences(ValidationContext context, FieldSchema field, string value, HashSet kinds, string mod, string file, int line) + { + foreach (var (kind, id) in SchemaReferences.Of(field, value)) + { + if (!kinds.Contains(kind)) + continue; + if (kind == ValidationKind.Scene && SceneManager.IsNativeSceneName(id)) + continue; + context.Graph.AddReference(kind, id, mod, file, line); + } + } + + private static IEnumerable<(string File, string Kind, JsonElement Entity)> Entities(IModInfo mod) + { + foreach (var file in ModFileReader.ListFiles(mod.FileProvider, string.Empty, true).Where(IsDataFile)) + { + JsonDocument doc; + try + { + doc = JsonDocument.Parse(ModFileReader.ReadResolved(mod.FileProvider, file) ?? string.Empty); + } + catch (Exception) + { + continue; + } + using (doc) + { + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("Kind", out var kind) || kind.ValueKind != JsonValueKind.String + || !root.TryGetProperty("Items", out var items) || items.ValueKind != JsonValueKind.Array) + continue; + foreach (var entity in items.EnumerateArray().Where(e => e.ValueKind == JsonValueKind.Object).ToList()) + yield return (file, kind.GetString(), entity.Clone()); + } + } + } + + private static bool IsDataFile(string path) + => path.EndsWith(".json", StringComparison.OrdinalIgnoreCase) + && !path.StartsWith("schemas/", StringComparison.OrdinalIgnoreCase) + && !path.StartsWith("patches/", StringComparison.OrdinalIgnoreCase) + && !path.EndsWith("manifest.json", StringComparison.OrdinalIgnoreCase); + + private static bool TryGet(JsonElement obj, string key, out JsonElement value) + { + foreach (var prop in obj.EnumerateObject()) + { + if (string.Equals(prop.Name, key, StringComparison.OrdinalIgnoreCase)) + { + value = prop.Value; + return true; + } + } + value = default; + return false; + } + + private static string Text(JsonElement value) => value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString(); + + private static void Report(ValidationContext context, IEnumerable issues, string mod, string file, int line) + { + foreach (var issue in issues) + { + if (issue.IsError) + context.AddError(issue.Code, issue.Message, mod, file, line); + else + context.AddWarning(issue.Code, issue.Message, mod, file, line); + } + } + } +} diff --git a/src/ValidationSchemas.cs b/src/ValidationSchemas.cs new file mode 100644 index 0000000..1962b2e --- /dev/null +++ b/src/ValidationSchemas.cs @@ -0,0 +1,34 @@ +namespace Cthangover.Validator +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Runtime.CompilerServices; + using Cthangover.Mods.Schemas; + + /// + /// The schemas of the validated mods (their schemas/*.json, read from the providers + /// in the context's mod order), built once per validation run and shared by the + /// strategies that need them: the entity strategy treats a described kind as known, the + /// schema strategy checks content against it. + /// + public static class ValidationSchemas + { + private static readonly ConditionalWeakTable Catalogs = new(); + + /// The merged catalog of 's mods. + public static SchemaCatalog Of(ValidationContext context) + => Catalogs.GetValue(context, Build); + + private static SchemaCatalog Build(ValidationContext context) => SchemaCatalog.Build(Files(context)); + + private static IEnumerable<(string, string, string)> Files(ValidationContext context) + { + foreach (var mod in context.Mods.Where(m => m.FileProvider != null)) + { + foreach (var file in ModFileReader.ListFiles(mod.FileProvider, "schemas", true).Where(f => f.EndsWith(".json", StringComparison.OrdinalIgnoreCase)).OrderBy(f => f, StringComparer.Ordinal)) + yield return (mod.Id, file, ModFileReader.ReadResolved(mod.FileProvider, file)); + } + } + } +}