Regex tooling: formula gene grouping, content patches, def validation
CI / build-test (push) Successful in 1m12s

Three regex-powered content tools (phase G5):

- Formula group functions gsum/gcount/gavg/gmin/gmax('regex') aggregate
  over every context variable whose name matches the pattern, e.g.
  gsum('leaf_.*'). Adds string literals to the formula grammar and an
  IFormulaContext.ResolveMatching hook; GenomeContext enumerates matching
  genes, so a trait can sum/average a gene group.
- DefDatabase content patches: a { "type": "Patch", patches:[{ defType,
  match (regex on defName), set:{fields} }] } file sets fields on every
  matching raw def before resolution — mods patch Core in bulk.
- DefDatabase.RegisterValidator(typeKey, field, regex): load-time check
  that a string field matches a pattern, throwing otherwise (naming/format
  conventions).

Covered by FormulaTests (group aggregation, composition, bad calls) and
DefDatabaseTests (patch set/match/unknown-type, validator pass/reject).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-13 03:59:36 +03:00
co-authored by Claude Opus 4.8
parent ead22517ee
commit d044cafad9
8 changed files with 440 additions and 1 deletions
+156 -1
View File
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace MrGameEng.Mods;
@@ -24,6 +25,18 @@ public sealed class DefDatabase
private readonly Dictionary<string, TypeEntry> _byKey = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<Type, TypeEntry> _byType = [];
/// <summary>Reserved def-file <c>"type"</c> that carries content patches rather than defs.</summary>
public const string PatchTypeKey = "Patch";
private sealed record PatchRule(string DefType, Regex Match, JsonObject Set);
private sealed record ValidationRule(string Field, Regex Pattern, string Description);
private readonly List<PatchRule> _patches = [];
private readonly Dictionary<string, List<ValidationRule>> _validators = new(
StringComparer.OrdinalIgnoreCase
);
private static readonly JsonDocumentOptions DocumentOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
@@ -43,6 +56,38 @@ public sealed class DefDatabase
_byType.Add(typeof(T), entry);
}
/// <summary>
/// Registers a load-time validation: the string <paramref name="field"/> of every resolved def of
/// type <paramref name="typeKey"/> must match <paramref name="pattern"/> (a regex), or
/// <see cref="Load"/> throws. Non-string or absent fields are skipped. Use it to enforce naming
/// conventions (e.g. gene ids start with <c>Gene</c>) or key/format rules across a mod's content.
/// </summary>
public void RegisterValidator(
string typeKey,
string field,
string pattern,
string? description = null
)
{
Regex regex;
try
{
regex = new Regex(pattern, RegexOptions.CultureInvariant);
}
catch (ArgumentException error)
{
throw new ArgumentException($"Invalid validator regex '{pattern}': {error.Message}");
}
if (!_validators.TryGetValue(typeKey, out var list))
{
list = [];
_validators[typeKey] = list;
}
list.Add(new ValidationRule(field, regex, description ?? $"pattern /{pattern}/"));
}
/// <summary>
/// Loads every <c>Defs/**/*.json</c> of <paramref name="mods"/> (in load order) and
/// resolves inheritance. Call once after registering all def types.
@@ -66,6 +111,8 @@ public sealed class DefDatabase
}
}
ApplyPatches();
foreach (var entry in _byKey.Values)
{
Resolve(entry);
@@ -133,6 +180,12 @@ public sealed class DefDatabase
?? throw new InvalidDataException(
$"Def file '{file}' (mod '{mod.Id}') has no \"type\" field."
);
if (string.Equals(typeKey, PatchTypeKey, StringComparison.OrdinalIgnoreCase))
{
LoadPatches(mod, file, root);
return;
}
if (!_byKey.TryGetValue(typeKey, out var entry))
{
throw new InvalidDataException(
@@ -169,8 +222,83 @@ public sealed class DefDatabase
}
}
private static void Resolve(TypeEntry entry)
// Парсит файл-патч: операции { defType, match (регэксп по defName), set: {поля} }.
private void LoadPatches(Mod mod, string file, JsonNode root)
{
if (root["patches"] is not JsonArray patches)
{
throw new InvalidDataException(
$"Patch file '{file}' (mod '{mod.Id}') has no \"patches\" array."
);
}
foreach (var node in patches)
{
if (node is not JsonObject patch)
{
throw new InvalidDataException(
$"Patch file '{file}' (mod '{mod.Id}') contains a non-object patch."
);
}
var defType =
patch["defType"]?.GetValue<string>()
?? throw new InvalidDataException($"A patch in '{file}' has no \"defType\".");
var match =
patch["match"]?.GetValue<string>()
?? throw new InvalidDataException($"A patch in '{file}' has no \"match\".");
if (patch["set"] is not JsonObject set)
{
throw new InvalidDataException($"A patch in '{file}' has no \"set\" object.");
}
Regex regex;
try
{
regex = new Regex(match, RegexOptions.CultureInvariant);
}
catch (ArgumentException error)
{
throw new InvalidDataException(
$"Patch in '{file}' has invalid regex '{match}': {error.Message}"
);
}
_patches.Add(new PatchRule(defType, regex, (JsonObject)set.DeepClone()));
}
}
// Применяет патчи к сырым дефам (до резолва), в порядке загрузки: каждому дефу нужного типа,
// чьё имя подходит под регэксп, проставляются поля set. Поля наследуются детьми как обычно.
private void ApplyPatches()
{
foreach (var patch in _patches)
{
if (!_byKey.TryGetValue(patch.DefType, out var entry))
{
throw new InvalidDataException(
$"A patch targets unknown def type '{patch.DefType}'."
);
}
foreach (var raw in entry.Raw)
{
if (!patch.Match.IsMatch(raw.Key))
{
continue;
}
foreach (var (key, value) in patch.Set)
{
raw.Value[key] = value?.DeepClone();
}
}
}
}
private void Resolve(TypeEntry entry)
{
_validators.TryGetValue(entry.Key, out var rules);
foreach (var defName in entry.Raw.Keys.Order(StringComparer.Ordinal))
{
var merged = MergeChain(entry, defName, []);
@@ -179,6 +307,11 @@ public sealed class DefDatabase
continue;
}
if (rules is not null)
{
Validate(entry.Key, defName, merged, rules);
}
var def =
(Def?)merged.Deserialize(entry.ClrType, ModInfo.JsonOptions)
?? throw new InvalidDataException(
@@ -188,6 +321,28 @@ public sealed class DefDatabase
}
}
private static void Validate(
string typeKey,
string defName,
JsonObject merged,
List<ValidationRule> rules
)
{
foreach (var rule in rules)
{
if (
merged[rule.Field] is JsonValue value
&& value.TryGetValue<string>(out var text)
&& !rule.Pattern.IsMatch(text)
)
{
throw new InvalidDataException(
$"Def '{defName}' ({typeKey}) field '{rule.Field}'=\"{text}\" violates {rule.Description}."
);
}
}
}
private static JsonObject MergeChain(TypeEntry entry, string defName, HashSet<string> seen)
{
if (!seen.Add(defName))