This commit is contained in:
iisaev committed 2026-10-18 12:41:41 +03:00
commit e4fa77508c
46 files changed
+3525

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
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 (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.
/// </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> 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)
{
["character.add_to_party"] = CharacterType,
["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);
}
}
}
+26
View File
@@ -0,0 +1,26 @@
using System.Text.Json;
using Cthangover.Core.Mods;
namespace Cthangover.Validator
{
/// <summary>
/// Phase: Contracts. Collects action declarations, their declared object
/// keys and the property-bag keys from their <c>Values</c> list. Actions used
/// to be distinguished from characters by a heuristic on the shared
/// <c>characters/</c> directory; the kind makes that explicit.
/// </summary>
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);
}
}
}
+356
View File
@@ -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
{
/// <summary>
/// Phase: Contracts. Parses every mod's declared C# sources with Roslyn and
/// extracts typed property accesses (<c>GetInt("Turn")</c>,
/// <c>SetFloat("Attack", ...)</c>) from <c>DataBlob</c>, <c>PropertyData</c>
/// and <c>ActionCharacter</c>, 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 (<c>DYNAMIC_KEY</c>, info), the same key used
/// with different value types (<c>TYPE_CONFLICT</c>, error), reads of keys
/// that are neither declared in JSON nor written anywhere
/// (<c>READ_UNDECLARED</c> / <c>READ_UNDECLARED_WITH_DEFAULT</c>), and writes
/// that no code ever reads (<c>WRITE_UNUSED</c>, info).
/// </summary>
public class CSharpPropertyStrategy : IValidationStrategy
{
private static readonly Dictionary<string, string> 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;
/// <summary>Scans mod C# sources for DataBlob key usages and checks the keys against the persisted blobs, reporting unknown or mistyped keys.</summary>
public void Validate(ValidationContext context)
{
var usages = new List<CodePropertyUsage>();
var literalCounts = new Dictionary<string, int>(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<CodePropertyUsage> usages,
Dictionary<string, int> 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<ArgumentSyntax> 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<CodePropertyUsage> 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<CodePropertyUsage> usages)
{
var byKey = new Dictionary<string, List<CodePropertyUsage>>(StringComparer.OrdinalIgnoreCase);
foreach (var usage in usages)
{
if (usage.Key == null)
continue;
if (!byKey.TryGetValue(usage.Key, out var list))
{
list = new List<CodePropertyUsage>();
byKey[usage.Key] = list;
}
list.Add(usage);
}
foreach (var entry in byKey)
{
var types = new List<string>();
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<string>();
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<CodePropertyUsage> usages)
{
var writeKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var usage in usages)
{
if (usage.IsWrite && usage.Key != null)
writeKeys.Add(usage.Key);
}
var reported = new HashSet<string>(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<CodePropertyUsage> usages,
Dictionary<string, int> literalCounts)
{
var readKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var writeCounts = new Dictionary<string, int>(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<string>(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);
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
using System.Text.Json;
using Cthangover.Core.Mods;
namespace Cthangover.Validator
{
/// <summary>
/// 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.
/// </summary>
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);
}
}
}
+22
View File
@@ -0,0 +1,22 @@
namespace Cthangover.Validator
{
/// <summary>
/// A single typed property access extracted from a mod's C# source:
/// <c>action.GetInt("Turn", 3)</c> becomes (key=Turn, type=int, read,
/// hasDefault=true). <c>DataBlob</c> and <c>PropertyData</c> 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.
/// </summary>
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; }
}
}
+146
View File
@@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Mods;
namespace Cthangover.Validator
{
/// <summary>
/// Phase: Reconciliation. Checks manifest <c>depends</c> 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.
/// </summary>
public class DependsStrategy : IValidationStrategy
{
public string Id => "depends";
public ValidationPhase Phase => ValidationPhase.Reconciliation;
public int Order => 100;
public ValidationFeature Feature => ValidationFeature.Json;
/// <summary>Re-checks every manifest's depends pins and cycles, reporting broken chains the loader only logs at runtime.</summary>
public void Validate(ValidationContext context)
{
var manifests = new Dictionary<string, ModManifest>(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<string, ModManifest> manifests)
{
var state = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var path = new List<string>();
var reported = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var modId in manifests.Keys)
Visit(context, manifests, modId, state, path, reported);
}
private static void Visit(
ValidationContext context,
Dictionary<string, ModManifest> manifests,
string modId,
Dictionary<string, int> state,
List<string> path,
HashSet<string> 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<string> path, HashSet<string> 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<string>();
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<string> cycle)
{
var sorted = new List<string>(cycle);
sorted.Sort(StringComparer.OrdinalIgnoreCase);
return string.Join("|", sorted);
}
}
}
+93
View File
@@ -0,0 +1,93 @@
using System.Text.Json;
using Cthangover.Core.Mods;
namespace Cthangover.Validator
{
/// <summary>
/// Shared extraction helpers for <see cref="IEntityKindStrategy"/>
/// 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 <see cref="JsonElement"/> reads) so they survive
/// DTO field changes.
/// </summary>
public static class EntityFactCollector
{
/// <summary>Declares every top-level property name of an entity as a known key.</summary>
public static void CollectObjectKeys(ValidationGraph graph, JsonElement entity)
{
if (entity.ValueKind != JsonValueKind.Object)
return;
foreach (var property in entity.EnumerateObject())
graph.AddDeclaredKey(property.Name);
}
/// <summary>Declares the <c>Key=Value</c> property bag keys of an action's <c>Values</c> array.</summary>
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));
}
}
/// <summary>Records item references from a recipe's ingredient array (e.g. <c>Input</c>, <c>Output</c>).</summary>
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);
}
}
/// <summary>Records item references from a character's <c>Loot</c> table.</summary>
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);
}
}
/// <summary>Records action references from a character's <c>Actions</c> array.</summary>
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);
}
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using System.Text.Json;
using Cthangover.Core.Mods;
namespace Cthangover.Validator
{
/// <summary>
/// Shared skeleton for <see cref="IEntityKindStrategy"/> 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 <see cref="Kind"/>; richer kinds
/// override <see cref="CollectEntity"/> to add declared keys and references
/// through <see cref="EntityFactCollector"/>.
/// </summary>
public abstract class EntityKindStrategy : IEntityKindStrategy
{
/// <inheritdoc />
public abstract string Kind { get; }
/// <inheritdoc />
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);
}
}
/// <summary>
/// Records one entity. The default implementation adds the declaration
/// under <see cref="Kind"/>, which doubles as the graph vocabulary
/// (<see cref="ValidationKind"/>).
/// </summary>
protected virtual void CollectEntity(ValidationContext context, IModInfo mod, string file, string id, JsonElement item)
{
context.Graph.AddDeclaration(Kind, id, mod.Id, file, 0);
}
}
}
+21
View File
@@ -0,0 +1,21 @@
using Cthangover.Core.Mods.Consumers;
namespace Cthangover.Validator
{
/// <summary>
/// Feeds <see cref="EntityKindStrategyRegistry"/> with every mod's
/// <see cref="IEntityKindStrategy"/> implementations as the mods load and
/// removes them when their mod unloads.
/// </summary>
public class EntityKindStrategyConsumer : ModTypeConsumer<IEntityKindStrategy>
{
/// <inheritdoc />
public override string Id => "validator.entity_kinds";
/// <inheritdoc />
protected override void Add(IEntityKindStrategy instance, string modId) => EntityKindStrategyRegistry.Register(instance);
/// <inheritdoc />
protected override void Remove(IEntityKindStrategy instance, string modId) => EntityKindStrategyRegistry.Unregister(instance);
}
}
+77
View File
@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Utils;
namespace Cthangover.Validator
{
/// <summary>
/// Registry of <see cref="IEntityKindStrategy"/> implementations, indexed by
/// the entity kind each one handles. It discovers nothing itself:
/// <see cref="EntityKindStrategyConsumer"/> 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.
/// </summary>
public static class EntityKindStrategyRegistry
{
private static readonly object sync = new();
private static readonly Dictionary<string, IEntityKindStrategy> strategies = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Returns the strategy registered for the entity kind, or <c>null</c>
/// when no strategy claims it — the caller reports the unknown kind as a
/// validation warning.
/// </summary>
public static IEntityKindStrategy Resolve(string kind)
{
if (string.IsNullOrWhiteSpace(kind))
return null;
lock (sync)
{
strategies.TryGetValue(kind, out var strategy);
return strategy;
}
}
/// <summary>
/// Registers a strategy under its kind. The first strategy of a kind
/// wins; a second one is reported as an error and ignored.
/// </summary>
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;
}
}
/// <summary>
/// Removes <paramref name="strategy"/> (its mod is being unloaded). A kind
/// owned by another strategy instance is left alone.
/// </summary>
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);
}
}
}
}
+40
View File
@@ -0,0 +1,40 @@
using System.Text.Json;
using Cthangover.Core.Mods;
namespace Cthangover.Validator
{
/// <summary>
/// One collection strategy per entity kind, discovered by reflection like
/// every other plugin point in the project (<c>Reflections.FindAndCreate</c>).
/// The JSON entity walker (<see cref="JsonEntityStrategy"/>) scans every file
/// once, reads the envelope's <c>"Kind"</c> 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.
/// <para/>
/// A strategy fills <see cref="ValidationContext.Graph"/> 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.
/// </summary>
public interface IEntityKindStrategy
{
/// <summary>
/// Entity kind this strategy handles — matches the <c>"Kind"</c> string
/// in the JSON envelope and the <see cref="ValidationKind"/> used in the
/// graph. Duplicate kinds across strategies are logged and the first
/// discovery wins.
/// </summary>
string Kind { get; }
/// <summary>
/// Collects facts for one file of this strategy's kind. The caller has
/// already verified the envelope shape and that the declared kind
/// matches <see cref="Kind"/>.
/// </summary>
/// <param name="context">Validation pass state and report sink.</param>
/// <param name="mod">Mod that owns the file.</param>
/// <param name="file">Mod-relative file path, used as the declaration/reference source.</param>
/// <param name="items">The envelope's <c>Items</c> array.</param>
void Collect(ValidationContext context, IModInfo mod, string file, JsonElement items);
}
}
+37
View File
@@ -0,0 +1,37 @@
namespace Cthangover.Validator
{
/// <summary>
/// One self-contained validation step, discovered by reflection like every
/// other plugin point in the project (<c>Reflections.FindAndCreate</c>).
/// Strategies run sequentially in (Phase, Order, Id) order on the validator
/// background thread: Schema strategies validate source shapes, Contracts
/// strategies fill <see cref="ValidationContext.Graph"/> 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.
/// </summary>
public interface IValidationStrategy
{
/// <summary>Stable identifier used in logs and in the sort order.</summary>
string Id { get; }
/// <summary>Execution stage; strategies of earlier phases run first.</summary>
ValidationPhase Phase { get; }
/// <summary>Order inside the phase; lower runs first.</summary>
int Order { get; }
/// <summary>Capability group gated by the user settings.</summary>
ValidationFeature Feature { get; }
/// <summary>
/// Runs the strategy. Data problems must be reported through the
/// <see cref="ValidationContext"/> and must not throw; unexpected
/// exceptions are caught and logged by <see cref="ValidatorService"/>.
/// </summary>
void Validate(ValidationContext context);
}
}
+22
View File
@@ -0,0 +1,22 @@
using System.Text.Json;
using Cthangover.Core.Mods;
namespace Cthangover.Validator
{
/// <summary>
/// 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.
/// </summary>
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);
}
}
}
+316
View File
@@ -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
{
/// <summary>
/// Phase: Contracts. Walks every mod's JSON entities the same way the
/// runtime resolver does — every <c>.json</c> file is opened and routed by
/// its top-level <c>"Kind"</c>, with <c>${include}</c> 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 <c>ui</c>/<c>main_menu</c>
/// <c>scene_path</c> entries against the owning mod's files and turns
/// <c>ui[].scene</c> into a scene reference.
///
/// Extraction is intentionally schema-light (raw <see cref="JsonDocument"/>
/// 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.
/// </summary>
public class JsonEntityStrategy : IValidationStrategy
{
private static readonly HashSet<string> 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;
/// <summary>Walks every mod's JSON entities, scene definitions, interactives and texture assets, recording declarations and references into the fact graph.</summary>
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<string>(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);
}
}
/// <summary>
/// Scans every JSON file of the mod and routes it by its declared
/// <c>"Kind"</c>, mirroring the runtime <c>EntityResolver</c>. The kind
/// resolves to an <see cref="IEntityKindStrategy"/> 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.
/// </summary>
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<JsonDocument>();
var items = new List<JsonElement>();
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<string> 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<JsonDocument> documents,
List<JsonElement> 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<JsonDocument> documents)
{
foreach (var document in documents)
document.Dispose();
}
}
}
+56
View File
@@ -0,0 +1,56 @@
using System;
using System.Text.Json;
namespace Cthangover.Validator
{
/// <summary>
/// Case-insensitive reads over <see cref="JsonElement"/> used by the JSON
/// contract strategy. Mod JSON deliberately mixes naming styles
/// (<c>"Id"</c>, <c>"id"</c>, <c>"defaultBackground"</c>) and the runtime
/// reads it with case-insensitive options, so the validator must not depend
/// on exact casing either.
/// </summary>
public static class JsonReadHelper
{
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Returns the string value of the first matching property, or null.</summary>
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();
}
/// <summary>True when the element exists and is an array.</summary>
public static bool IsArray(JsonElement? element)
=> element != null && element.Value.ValueKind == JsonValueKind.Array;
}
}
+76
View File
@@ -0,0 +1,76 @@
using System.Text.Json;
using Cthangover.Core.Utils;
namespace Cthangover.Validator
{
/// <summary>
/// Phase: Schema. Validates the mandatory shape of every mod's
/// <c>manifest.json</c>: <c>id</c> and <c>version</c> must be present and be
/// strings, and <c>id</c> 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.
/// </summary>
public class ManifestSchemaStrategy : IValidationStrategy
{
public string Id => "manifest_schema";
public ValidationPhase Phase => ValidationPhase.Schema;
public int Order => 100;
public ValidationFeature Feature => ValidationFeature.Json;
/// <summary>Checks the required manifest.json fields: non-empty string id and string version.</summary>
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");
}
}
}
+75
View File
@@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
namespace Cthangover.Validator
{
/// <summary>
/// Pure .NET file access used by validation strategies. Works with both
/// folder and zip providers through <see cref="IModFileProvider"/> and
/// resolves the project's <c>${include}</c> 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.
/// </summary>
public static class ModFileReader
{
/// <summary>
/// Lists files under a mod-relative directory. Directories are pushed
/// onto the work stack only when <paramref name="recursive"/> is set, so
/// the caller can mirror the recursive entity scan or the recursive
/// scene scan.
/// </summary>
public static List<string> ListFiles(IModFileProvider provider, string directory, bool recursive)
{
var result = new List<string>();
if (provider == null)
return result;
var pending = new Stack<string>();
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;
}
/// <summary>
/// Reads a mod file and expands every <c>${path}</c> include. Returns
/// null when the file is absent; include failures propagate as
/// <see cref="InvalidOperationException"/> so the caller can report them
/// with the owning file name.
/// </summary>
public static string ReadResolved(IModFileProvider provider, string path)
{
var content = provider.ReadFileText(path);
if (content == null)
return null;
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { path };
return ModResolutionContext.ExpandModIncludes(provider, content, path, visited);
}
/// <summary>True when the file path ends with one of the given extensions.</summary>
public static bool HasExtension(string path, HashSet<string> extensions)
{
var dot = path.LastIndexOf('.');
return dot > 0 && extensions.Contains(path.Substring(dot));
}
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace Cthangover.Validator
{
/// <summary>
/// Phase: Contracts. Collects quest declarations. Quests carry no
/// references the validator can check yet, so the base skeleton — parse the
/// envelope, key by <c>Id</c>, declare it under the quest kind — is all
/// there is to do.
/// </summary>
public class QuestEntityStrategy : EntityKindStrategy
{
public override string Kind => ValidationKind.Quest;
}
}
+22
View File
@@ -0,0 +1,22 @@
using System.Text.Json;
using Cthangover.Core.Mods;
namespace Cthangover.Validator
{
/// <summary>
/// Phase: Contracts. Collects recipe declarations and turns their
/// <c>Input</c>/<c>Output</c> ingredients into item references, so a recipe
/// pointing at a deleted item surfaces as a missing reference.
/// </summary>
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");
}
}
}
+34
View File
@@ -0,0 +1,34 @@
namespace Cthangover.Validator
{
/// <summary>
/// Phase: Reconciliation. Matches every reference collected by contract
/// strategies against the declarations in the graph and reports
/// <c>REF_NOT_FOUND</c> 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.
/// </summary>
public class ReferenceStrategy : IValidationStrategy
{
public string Id => "references";
public ValidationPhase Phase => ValidationPhase.Reconciliation;
public int Order => 200;
public ValidationFeature Feature => ValidationFeature.Json;
/// <summary>Reports every recorded reference whose target id has no declaration in the merged fact graph (REF_NOT_FOUND).</summary>
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");
}
}
}
}
+49
View File
@@ -0,0 +1,49 @@
using System;
using System.IO;
using System.Text.Json;
using Cthangover.Core.Utils;
namespace Cthangover.Validator
{
/// <summary>
/// Persists the completed <see cref="ValidationReport"/> as JSON. Called from
/// the background validation task, so it relies only on plain
/// <c>System.IO</c>; the Godot-style directory is resolved to an absolute path
/// earlier, on the main thread, by
/// <see cref="ValidatorSettings.GetReportDirectory"/>.
/// </summary>
public static class ReportWriter
{
/// <summary>File name inside the configured report directory.</summary>
public const string FileName = "report.json";
/// <summary>
/// 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.
/// </summary>
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;
}
}
}
}
+579
View File
@@ -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
{
/// <summary>
/// Phase: Contracts. Validates <c>.scenario</c> DSL files. The command
/// vocabulary is not hardcoded: known command words come from
/// <see cref="ScenarioCommandStrategyFactory"/> (mods add commands through
/// <c>RegisterAssembly</c>), and each command's argument contract comes from
/// <see cref="IScenarioCommandSpec"/> implemented by the strategy itself.
/// Commands without a spec are still recognised, just not argument-checked.
///
/// Checks performed:
/// <list type="bullet">
/// <item>unknown command words (<c>SCENARIO_UNKNOWN_COMMAND</c>);</item>
/// <item>missing / extra / unknown / empty arguments based on the command spec;</item>
/// <item>jump targets (<c>goto</c>, <c>option</c>) must point at an existing <c>:label</c>;</item>
/// <item><c>select</c> without options and <c>option</c> outside a select block;</item>
/// <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>
/// </list>
/// </summary>
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<string> actionNames;
public string Id => "scenario_commands";
public ValidationPhase Phase => ValidationPhase.Contracts;
public int Order => 300;
public ValidationFeature Feature => ValidationFeature.Scenarios;
/// <summary>Parses every .scenario file and validates DSL commands, named parameters and known action names.</summary>
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<string>();
var named = new Dictionary<string, string>();
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<string> CollectLabels(ValidationContext context, IModInfo mod, string file, string[] lines, int lineOffset)
{
var labels = new HashSet<string>(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<string> positional,
Dictionary<string, string> named,
string arrowTarget,
IScenarioCommandSpec spec,
HashSet<string> 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<string> 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<string, string> 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<string, string> named,
IReadOnlyList<string> 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<string> 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<string> 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<string, string> 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<string, string> named, string name)
{
if (!named.TryGetValue(name, out var value) || string.IsNullOrWhiteSpace(value))
return;
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 character.add_to_party type=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)
{
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<string> GetActionNames()
{
lock (actionLock)
{
if (actionNames != null)
return actionNames;
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var action in ScenarioActionFactory.Instance.GetAll())
names.Add(action.Name);
actionNames = names;
return actionNames;
}
}
private static void SplitArguments(List<string> tokens, List<string> positional, Dictionary<string, string> 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<string> Tokenize(string line)
{
var result = new List<string>();
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;
}
}
}
+192
View File
@@ -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
{
/// <summary>
/// Phase: Contracts. Walks every mod's <c>patches/scenes/**</c> tree, parses each patch with
/// the same <see cref="ScenePatchParser"/> 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 <c>metadata/_patch_*</c> keys.
/// <para/>
/// Target existence is checked only against mod scenes — native <c>res://scenes/**</c> 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.
/// </summary>
public class ScenePatchStrategy : IValidationStrategy
{
private static readonly HashSet<string> 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;
/// <summary>Validates scene patch .tscn files: known patch operations and target scenes that actually exist.</summary>
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<string> CollectSceneKeys(ValidationContext context)
{
var keys = new HashSet<string>(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<string> 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<string> 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<string> 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<string> EnumerateValues(object value)
{
switch (value)
{
case string[] array:
return array;
case string single:
return new[] { single };
default:
return Array.Empty<string>();
}
}
private static bool IsTrue(object value)
{
return (value is bool flag && flag)
|| (value is string text && bool.TryParse(text, out var parsed) && parsed);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace Cthangover.Validator
{
/// <summary>
/// Phase: Contracts. Collects skill declarations. Skills are a standalone
/// entity kind — they used to share the <c>characters/</c> directory with
/// characters and actions, which is exactly the ambiguity the kind system
/// removed; here they are declared under their own kind.
/// </summary>
public class SkillEntityStrategy : EntityKindStrategy
{
public override string Kind => ValidationKind.Skill;
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace Cthangover.Validator
{
/// <summary>
/// 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.
/// </summary>
public class StatusEffectEntityStrategy : EntityKindStrategy
{
public override string Kind => ValidationKind.StatusEffect;
}
}
+47
View File
@@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
namespace Cthangover.Validator
{
/// <summary>
/// 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 <c>.scenario</c> files, so they stay out until a scenario
/// strategy contributes those references; otherwise every asset would be
/// flagged.
/// </summary>
public class UnusedDeclarationStrategy : IValidationStrategy
{
public string Id => "unused_declarations";
public ValidationPhase Phase => ValidationPhase.Reconciliation;
public int Order => 300;
public ValidationFeature Feature => ValidationFeature.Json;
/// <summary>Reports items and actions that are declared but never referenced (DECL_UNUSED).</summary>
public void Validate(ValidationContext context)
{
var used = new HashSet<string>(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;
}
}
+77
View File
@@ -0,0 +1,77 @@
using System.Collections.Generic;
using System.Threading;
using Cthangover.Core.Mods;
namespace Cthangover.Validator
{
/// <summary>
/// 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.
/// </summary>
public class ValidationContext
{
public ValidationContext(
IReadOnlyList<IModInfo> mods,
ValidationReport report,
ValidationGraph graph,
CancellationToken cancellationToken)
{
Mods = mods;
Report = report;
Graph = graph;
CancellationToken = cancellationToken;
}
/// <summary>Ordered snapshot of the mods to validate.</summary>
public IReadOnlyList<IModInfo> Mods { get; }
/// <summary>Report being assembled; strategies append issues to it.</summary>
public ValidationReport Report { get; }
/// <summary>Facts collected by contract strategies.</summary>
public ValidationGraph Graph { get; }
/// <summary>Cooperative cancellation checked between mods and documents.</summary>
public CancellationToken CancellationToken { get; }
/// <summary>Appends an error issue to the report (validation failure).</summary>
public void AddError(string code, string message, string mod, string file = null, int line = 0, string suggestion = null, List<string> related = null)
=> Report.Issues.Add(Create(ValidationSeverity.Error, code, message, mod, file, line, suggestion, related));
/// <summary>Appends a warning issue (suspicious but not fatal).</summary>
public void AddWarning(string code, string message, string mod, string file = null, int line = 0, string suggestion = null, List<string> related = null)
=> Report.Issues.Add(Create(ValidationSeverity.Warning, code, message, mod, file, line, suggestion, related));
/// <summary>Appends an informational issue (e.g. unused content).</summary>
public void AddInfo(string code, string message, string mod, string file = null, int line = 0, string suggestion = null, List<string> related = null)
=> Report.Issues.Add(Create(ValidationSeverity.Info, code, message, mod, file, line, suggestion, related));
/// <summary>Cooperative cancellation hook for strategies to call between documents.</summary>
public void ThrowIfCancellationRequested() => CancellationToken.ThrowIfCancellationRequested();
private static ValidationIssue Create(
string severity,
string code,
string message,
string mod,
string file,
int line,
string suggestion,
List<string> related)
{
return new ValidationIssue
{
Severity = severity,
Code = code,
Message = message,
Mod = mod,
File = file,
Line = line,
Suggestion = suggestion,
Related = related,
};
}
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace Cthangover.Validator
{
/// <summary>
/// A declared entity discovered while collecting contracts (JSON id, asset
/// file id, …). Declarations are matched against
/// <see cref="ValidationReference"/> during reconciliation, and declarations
/// of kinds with reliable reference coverage can be checked for being unused.
/// </summary>
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; }
}
}
+23
View File
@@ -0,0 +1,23 @@
namespace Cthangover.Validator
{
/// <summary>
/// Capability group of a validation strategy, mapped to a user setting by
/// <see cref="ValidatorService"/>. 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.
/// </summary>
public enum ValidationFeature
{
/// <summary>Cheap file-level and JSON analysis; always enabled.</summary>
Json,
/// <summary>Roslyn analysis of mod C# sources; gated by <c>parse_csharp</c>.</summary>
CSharp,
/// <summary>GDScript probe; gated by <c>parse_gdscript</c> (off by default).</summary>
GdScript,
/// <summary>Scenario DSL analysis; reserved for a future strategy.</summary>
Scenarios,
}
}
+92
View File
@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Cthangover.Validator
{
/// <summary>
/// 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 <see cref="AddDeclaration"/> and <see cref="AddReference"/>.
///
/// The graph is filled and read sequentially by one background thread, so it
/// needs no synchronisation; it is never exposed to the main thread.
/// </summary>
public class ValidationGraph
{
private readonly Dictionary<string, List<ValidationDeclaration>> declarations = new(StringComparer.OrdinalIgnoreCase);
private readonly List<ValidationReference> references = new();
private readonly HashSet<string> declaredKeys = new(StringComparer.OrdinalIgnoreCase);
/// <summary>All references collected so far.</summary>
public IReadOnlyList<ValidationReference> References => references;
/// <summary>All declarations collected so far, flattened across kinds.</summary>
public IEnumerable<ValidationDeclaration> Declarations => declarations.Values.SelectMany(list => list);
/// <summary>
/// Registers an entity declaration. Empty kinds/ids are ignored so
/// parsers can call this without nil-checks.
/// </summary>
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<ValidationDeclaration>();
declarations[kind] = list;
}
list.Add(new ValidationDeclaration
{
Kind = kind,
Id = id,
ModId = modId,
File = file,
Line = line,
});
}
/// <summary>
/// Registers a reference to an entity. Empty kinds/targets are ignored.
/// </summary>
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,
});
}
/// <summary>
/// Registers a property key seen in JSON entity data. Used by the C#
/// strategy to distinguish declared properties from typos.
/// </summary>
public void AddDeclaredKey(string key)
{
if (!string.IsNullOrWhiteSpace(key))
declaredKeys.Add(key);
}
/// <summary>True when at least one declaration of the given kind and id exists.</summary>
public bool HasDeclaration(string kind, string id)
{
return declarations.TryGetValue(kind, out var list)
&& list.Any(d => string.Equals(d.Id, id, StringComparison.OrdinalIgnoreCase));
}
/// <summary>True when the key was seen in any JSON entity or action Values list.</summary>
public bool IsKeyDeclared(string key) => declaredKeys.Contains(key);
}
}
+46
View File
@@ -0,0 +1,46 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Cthangover.Validator
{
/// <summary>
/// A single machine-readable finding. The code catalogue
/// (<c>SCHEMA_MISSING_FIELD</c>, <c>REF_NOT_FOUND</c>, <c>TYPE_CONFLICT</c>, …)
/// is shared with the validator plan, so the report stays stable for LLM
/// consumers while the set of checks grows.
/// </summary>
public class ValidationIssue
{
/// <summary><c>error</c>, <c>warning</c> or <c>info</c>.</summary>
[JsonPropertyName("severity")]
public string Severity { get; set; }
/// <summary>Stable machine code of the check that produced the issue.</summary>
[JsonPropertyName("code")]
public string Code { get; set; }
/// <summary>Human-readable explanation.</summary>
[JsonPropertyName("message")]
public string Message { get; set; }
/// <summary>Mod the issue belongs to.</summary>
[JsonPropertyName("mod")]
public string Mod { get; set; }
/// <summary>Mod-relative path of the offending file.</summary>
[JsonPropertyName("file")]
public string File { get; set; }
/// <summary>One-based line number, or 0 when unknown.</summary>
[JsonPropertyName("line")]
public int Line { get; set; }
/// <summary>Optional remediation hint.</summary>
[JsonPropertyName("suggestion")]
public string Suggestion { get; set; }
/// <summary>Related locations in <c>mod:file:line</c> form, when the issue spans several usages.</summary>
[JsonPropertyName("related")]
public List<string> Related { get; set; }
}
}
+28
View File
@@ -0,0 +1,28 @@
namespace Cthangover.Validator
{
/// <summary>
/// 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
/// <c>.scenario</c> files, so both sides of the graph speak the same
/// vocabulary.
/// </summary>
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";
}
}
+24
View File
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace Cthangover.Validator
{
/// <summary>
/// Per-mod section of the report. <c>status</c> is <c>ok</c> when the mod
/// contributed no errors, otherwise <c>errors</c> — the field lets a
/// consumer triage mods without scanning the flat issue list.
/// </summary>
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";
}
}
+25
View File
@@ -0,0 +1,25 @@
namespace Cthangover.Validator
{
/// <summary>
/// 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 <c>.scenario</c> files) and
/// Reconciliation (match them against the graph).
/// </summary>
public enum ValidationPhase
{
/// <summary>Shape of source files: manifest fields, JSON syntax.</summary>
Schema = 0,
/// <summary>Fact collection: entity declarations, references, code usages.</summary>
Contracts = 1,
/// <summary>Cross-checks: missing references, conflicts, unused declarations.</summary>
Reconciliation = 2,
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace Cthangover.Validator
{
/// <summary>
/// A reference from one entity to another (loot item, default background,
/// character action, …). Collected by contract strategies and matched
/// against declarations during reconciliation.
/// </summary>
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; }
}
}
+38
View File
@@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Cthangover.Validator
{
/// <summary>
/// Root DTO of the machine-readable validation report written to
/// <c>user://validation/report.json</c>. Snake_case JSON names are fixed here
/// rather than in serialiser options, so the format cannot drift when global
/// options change.
/// </summary>
public class ValidationReport
{
/// <summary>UTC timestamp in round-trip format.</summary>
[JsonPropertyName("generated_at")]
public string GeneratedAt { get; set; }
/// <summary>Game version the report was produced for.</summary>
[JsonPropertyName("game_version")]
public string GameVersion { get; set; }
/// <summary>Validator mod version; part of the report cache key.</summary>
[JsonPropertyName("validator_version")]
public string ValidatorVersion { get; set; }
/// <summary>Issue counters.</summary>
[JsonPropertyName("summary")]
public ValidationSummary Summary { get; set; } = new();
/// <summary>Validated mods in load order with their status.</summary>
[JsonPropertyName("mods")]
public List<ValidationModStatus> Mods { get; set; } = new();
/// <summary>All findings of the pass.</summary>
[JsonPropertyName("issues")]
public List<ValidationIssue> Issues { get; set; } = new();
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace Cthangover.Validator
{
/// <summary>
/// Severity labels used by <see cref="ValidationIssue"/> and counted into
/// <see cref="ValidationSummary"/>. Kept as string constants because the
/// report is a machine-readable JSON contract consumed by external tooling
/// and LLMs that expect lower-case labels.
/// </summary>
public static class ValidationSeverity
{
public const string Error = "error";
public const string Warning = "warning";
public const string Info = "info";
}
}
+21
View File
@@ -0,0 +1,21 @@
using Cthangover.Core.Mods.Consumers;
namespace Cthangover.Validator
{
/// <summary>
/// Feeds <see cref="ValidationStrategyRegistry"/> with every mod's
/// <see cref="IValidationStrategy"/> implementations as the mods load and
/// removes them when their mod unloads.
/// </summary>
public class ValidationStrategyConsumer : ModTypeConsumer<IValidationStrategy>
{
/// <inheritdoc />
public override string Id => "validator.strategies";
/// <inheritdoc />
protected override void Add(IValidationStrategy instance, string modId) => ValidationStrategyRegistry.Register(instance);
/// <inheritdoc />
protected override void Remove(IValidationStrategy instance, string modId) => ValidationStrategyRegistry.Unregister(instance);
}
}
+60
View File
@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
namespace Cthangover.Validator
{
/// <summary>
/// Registry of all <see cref="IValidationStrategy"/> implementations, served
/// in deterministic execution order. It discovers nothing itself:
/// <see cref="ValidationStrategyConsumer"/> 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.
/// </summary>
public static class ValidationStrategyRegistry
{
private static readonly object sync = new();
private static readonly List<IValidationStrategy> strategies = new();
/// <summary>Returns a snapshot of the registered strategies sorted by phase and order.</summary>
public static IReadOnlyList<IValidationStrategy> GetStrategies()
{
lock (sync)
return strategies.ToArray();
}
/// <summary>Adds a strategy and keeps the execution order.</summary>
public static void Register(IValidationStrategy strategy)
{
if (strategy == null)
return;
lock (sync)
{
strategies.Add(strategy);
strategies.Sort(Compare);
}
}
/// <summary>Removes exactly <paramref name="strategy"/> (its mod is being unloaded).</summary>
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);
}
}
}
+20
View File
@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
namespace Cthangover.Validator
{
/// <summary>
/// Aggregated issue counters placed at the top of the report so consumers can
/// decide whether to read the full issue list without deserialising it.
/// </summary>
public class ValidationSummary
{
[JsonPropertyName("errors")]
public int Errors { get; set; }
[JsonPropertyName("warnings")]
public int Warnings { get; set; }
[JsonPropertyName("infos")]
public int Infos { get; set; }
}
}
+51
View File
@@ -0,0 +1,51 @@
using Cthangover.Core.Mods;
namespace Cthangover.Validator
{
/// <summary>
/// Lifecycle entry point of the validator mod. <see cref="OnModResourcesReady"/>
/// 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 <see cref="IModInitializer.OnModLoaded"/>,
/// because asset-only mods (JSON/PNG without code) never produce an assembly
/// and therefore never raise <c>ModLoadedEvent</c>.
///
/// The hook runs on the main thread; all Godot-bound work (settings load,
/// <c>user://</c> path resolution) happens inside <see cref="ValidatorService.Start"/>,
/// 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 <see cref="ValidatorService.Stop"/>.
/// </summary>
public class ValidatorInitializer : IModInitializer, IModUnloadAware
{
/// <summary>
/// Intentionally empty: asset-only mods are invisible to this callback, so
/// the validator always works from the complete <c>ModRegistry</c> snapshot
/// taken in <see cref="ValidatorService.Start"/> instead of accumulating
/// mods here.
/// </summary>
public void OnModLoaded(string modId)
{
}
/// <summary>
/// Starts the background validation pass once all mods and their JSON
/// resources are available.
/// </summary>
public void OnModResourcesReady()
{
ValidatorService.Start();
}
/// <summary>Stops the background pass while the validator's types are still alive.</summary>
public void OnModUnloading()
{
ValidatorService.Stop();
}
/// <summary>Nothing to drop: the validator keeps no references to other mods between passes.</summary>
public void OnModUnloaded(string modId)
{
}
}
}
+313
View File
@@ -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
{
/// <summary>
/// Orchestrates the validator pass. <see cref="Start"/> 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 <see cref="IModInfo"/> and
/// <see cref="IModFileProvider"/> — never Godot types and never resolver caches —
/// because mod providers are disposed by <c>ModManager.Reload</c> on the main
/// thread. Strategies are discovered by reflection and executed in
/// (Phase, Order, Id) order: contract strategies fill the shared
/// <see cref="ValidationGraph"/>, reconciliation strategies consume it.
///
/// A pass in flight is cancelled by <see cref="Stop"/> 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.
/// </summary>
public static class ValidatorService
{
/// <summary>Version reported in the JSON report; bump alongside the manifest.</summary>
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<IModInfo> queuedMods;
private static string queuedReportDirectory;
/// <summary>True while a validation pass is in flight.</summary>
public static bool IsRunning
{
get
{
lock (stateLock)
return running;
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>
/// Cancels the pass in flight and waits (bounded) until its thread has
/// left validator code. Main thread, from the unload notification.
/// </summary>
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<IModInfo> mods;
string reportDirectory;
CancellationToken token;
lock (stateLock)
{
mods = queuedMods;
reportDirectory = queuedReportDirectory;
token = cancellation.Token;
queuedMods = null;
}
Run(mods, reportDirectory, token);
}
private static List<IModInfo> SnapshotMods()
{
var result = new List<IModInfo>();
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<IModInfo> 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<IModInfo> 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;
}
/// <summary>
/// Runs every enabled strategy over <paramref name="mods"/> 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 <see cref="WarmUp"/> on the main thread first.
/// </summary>
public static ValidationContext Analyze(IReadOnlyList<IModInfo> 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;
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
}
+172
View File
@@ -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
{
/// <summary>
/// User-facing settings of the validator, discovered by
/// <see cref="ModSettingsRegistry"/> 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.
///
/// <see cref="Reload"/> is called on the main thread from
/// <see cref="ValidatorService.Start"/> before the task is scheduled: the engine
/// applies persisted settings in <c>ModSettingsRegistry.LoadAll</c> only after
/// <c>ModResourcesReady</c>, which is too late for the start decision, so the
/// mod reads its own settings file eagerly.
/// </summary>
public class ValidatorSettings : IModSettings
{
/// <summary>Mod id — also the settings file name under <c>user://mod_settings/</c>.</summary>
public const string ModId = "validator";
/// <summary>Default report directory used when the setting is empty or missing.</summary>
public const string DefaultReportPath = "user://validation/";
/// <summary>Master switch; when false the validation task is not started at all.</summary>
public static bool Enabled { get; private set; } = true;
/// <summary>When false, the mod stays idle until a future manual trigger is added.</summary>
public static bool RunOnStart { get; private set; } = true;
/// <summary>Enables the Roslyn-based analysis of mod C# sources.</summary>
public static bool ParseCSharp { get; private set; } = true;
/// <summary>Enables the GDScript probe; off by default because it is expensive.</summary>
public static bool ParseGdScript { get; private set; }
/// <summary>Enables .scenario DSL validation (command names, arguments, labels).</summary>
public static bool ParseScenarios { get; private set; } = true;
/// <summary>When false the report is kept in memory only and no file is written.</summary>
public static bool WriteReport { get; private set; } = true;
/// <summary>Godot-style directory (<c>user://...</c>) the report is written to.</summary>
public static string ReportPath { get; private set; } = DefaultReportPath;
/// <summary>
/// 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.
/// </summary>
public static bool FailOnError { get; private set; }
/// <summary>Game version resolved from <c>application/config/version</c>, cached for the report.</summary>
public static string GameVersion { get; private set; } = "";
/// <inheritdoc/>
public IReadOnlyList<ModSettingDefinition> GetDefinitions()
{
return new List<ModSettingDefinition>
{
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" },
};
}
/// <inheritdoc/>
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);
}
/// <inheritdoc/>
public void ReadValues(DataBlob blob)
{
Apply(blob);
}
/// <summary>
/// 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 <c>Godot.FileAccess</c> and <c>ProjectSettings</c>.
/// </summary>
public static void Reload()
{
Apply(ModSettingsRegistry.LoadModSettings(ModId));
GameVersion = ResolveGameVersion();
}
/// <summary>
/// Resolves the configured report directory to an absolute filesystem path.
/// Must be called on the main thread because it touches <c>ProjectSettings</c>;
/// returns null when the path cannot be resolved.
/// </summary>
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;
}
/// <summary>
/// Reads a boolean the way the settings UI persists it — as text. The typed
/// <c>DataBlob.GetBool</c> is only used as a fallback because the UI writes
/// every control through <c>SetString</c>, which a persisted blob later
/// exposes as a JSON string.
/// </summary>
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 "";
}
}
}
}