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));
+ }
+ }
+ }
+}