Validator reads schemas: schema checks, references and kinds from mod schemas, option and when conditions checked

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cthangoverandClaude Opus 5.5 committed 2026-10-24 10:01:13 +02:00
1 parent df702b0390
commit 1ae0b79d1b
6 files changed
+311 -120

No files matched your search

+1 -1
View File
@@ -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"]
}
-67
View File
@@ -1,67 +0,0 @@
using System.Collections.Generic;
namespace Cthangover.Validator
{
/// <summary>
/// Declarative map "scenario action → named argument → <see cref="ValidationKind"/>"
/// for actions whose arguments reference declared content. The runtime
/// <c>IScenarioAction</c> 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
/// <see cref="ScenarioCommandValidationStrategy"/>), and
/// <see cref="ReferenceStrategy"/> reports a missing declaration with the
/// exact scenario file and line — the static answer to the typo class the
/// runtime factories can only log.
/// <para/>
/// 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.
/// </summary>
public static class ActionArgumentReferences
{
private static readonly Dictionary<string, string> CharacterType = new Dictionary<string, string>
{
["type"] = ValidationKind.Character,
};
private static readonly Dictionary<string, string> PartyAdd = new Dictionary<string, string>
{
["id"] = ValidationKind.Character,
};
private static readonly Dictionary<string, string> BattleInit = new Dictionary<string, string>
{
["scene"] = ValidationKind.Scene,
["enemies"] = ValidationKind.Character,
["quest_id"] = ValidationKind.Quest,
};
private static readonly Dictionary<string, string> AddSkill = new Dictionary<string, string>
{
["id"] = ValidationKind.Skill,
};
private static readonly Dictionary<string, IReadOnlyDictionary<string, string>> Table =
new Dictionary<string, IReadOnlyDictionary<string, string>>(System.StringComparer.OrdinalIgnoreCase)
{
["party.add"] = PartyAdd,
["character.send_notification"] = CharacterType,
["character.add_skill"] = AddSkill,
["battle.init"] = BattleInit,
};
/// <summary>
/// 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.
/// </summary>
public static bool TryGetKinds(string actionName, out IReadOnlyDictionary<string, string> kinds)
{
return Table.TryGetValue(actionName, out kinds);
}
}
}
+3
View File
@@ -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",
+33 -52
View File
@@ -25,15 +25,16 @@ namespace Cthangover.Validator
/// <item>references collected by <see cref="ICommandReferenceMetadata"/> are added to the
/// shared graph (backgrounds, scenes, music, sounds, effects, avatars) and checked by
/// <see cref="ReferenceStrategy"/>; scenario actions are checked against the action registry.</item>
/// <item>content references inside action arguments (<see cref="ActionArgumentReferences"/>:
/// 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.</item>
/// <item>content references inside action arguments (character ids, skills, quest ids,
/// battle enemy lists) come from the actions' schemas (<see cref="SchemaValidationStrategy"/>)
/// and go into the same graph, so a typo is reported against the declaring mods with file
/// and line.</item>
/// </list>
/// </summary>
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<string> 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);
}
/// <summary>
/// Parses a condition of an <c>option if=</c> or a <c>when</c> 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.
/// </summary>
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<string> CollectLabels(ValidationContext context, IModInfo mod, string file, string[] lines, int lineOffset)
{
var labels = new HashSet<string>(StringComparer.Ordinal);
@@ -391,52 +418,6 @@ namespace Cthangover.Validator
context.Graph.AddReference(ValidationKind.Avatar, value, mod.Id, file, line);
}
/// <summary>
/// Collects content references hidden in the named arguments of an
/// <c>action</c> command. The scenario DSL treats action arguments as
/// opaque dialog variables, so without this pass a typo like
/// <c>action party.add id=Murakamiy</c> would only surface
/// at runtime as a factory error log. Values from
/// <see cref="ActionArgumentReferences"/> become graph references and are
/// reconciled by <see cref="ReferenceStrategy"/>, 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.
/// </summary>
private static void AddActionArgumentReferences(
ValidationContext context,
IModInfo mod,
string file,
int line,
string actionName,
Dictionary<string, string> 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)
+240
View File
@@ -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;
/// <summary>
/// The validator's reading of the schemas every mod ships (<c>schemas/*.json</c>, merged
/// by <see cref="SchemaCatalog"/>). 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:
/// <list type="bullet">
/// <item><c>action</c> lines: unknown and missing parameters, bad numbers and choices, and
/// graph references for every entity a parameter names (characters, quests, skills,
/// scenes, items…), which <see cref="ReferenceStrategy"/> reports when undeclared;</item>
/// <item>other described commands: positional values and their references (a check's stat);</item>
/// <item>entities of described kinds: references in fields, in list elements (a trait's
/// <c>Stat</c>) and in map keys (a character's <c>Stats</c>).</item>
/// </list>
/// 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.
/// </summary>
public class SchemaValidationStrategy : IValidationStrategy
{
/// <inheritdoc />
public string Id => "schemas";
/// <inheritdoc />
public ValidationPhase Phase => ValidationPhase.Contracts;
/// <inheritdoc />
public int Order => 900;
/// <inheritdoc />
public ValidationFeature Feature => ValidationFeature.Json;
/// <inheritdoc />
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<string>(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<string> DeclareDescribedKinds(ValidationContext context, SchemaCatalog catalog, Dictionary<IModInfo, List<(string File, string Kind, JsonElement Entity)>> entities)
{
var known = new HashSet<string>(context.Graph.Declarations.Select(d => d.Kind), StringComparer.OrdinalIgnoreCase);
var own = new HashSet<string>(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<string> declared, HashSet<string> 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<string>();
var named = new Dictionary<string, string>();
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<FieldSchema>())
{
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<FieldSchema>();
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<string> 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<string> 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<SchemaIssue> 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);
}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
namespace Cthangover.Validator
{
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using Cthangover.Mods.Schemas;
/// <summary>
/// The schemas of the validated mods (their <c>schemas/*.json</c>, 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.
/// </summary>
public static class ValidationSchemas
{
private static readonly ConditionalWeakTable<ValidationContext, SchemaCatalog> Catalogs = new();
/// <summary>The merged catalog of <paramref name="context"/>'s mods.</summary>
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));
}
}
}
}