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
@@ -71,5 +71,17 @@ public static class Phenotype
throw new FormulaException($"Unknown variable '{name}' while computing traits."); throw new FormulaException($"Unknown variable '{name}' while computing traits.");
} }
// Группировка генов в формулах: значения всех генов, чьи id подходят под шаблон (gsum/gavg/…).
public IEnumerable<float> ResolveMatching(Func<string, bool> matches)
{
foreach (var geneId in genome.Alleles.Keys)
{
if (matches(geneId) && registry.TryGetValue(geneId, out var gene))
{
yield return genome.Express(gene);
}
}
}
} }
} }
+156 -1
View File
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace MrGameEng.Mods; namespace MrGameEng.Mods;
@@ -24,6 +25,18 @@ public sealed class DefDatabase
private readonly Dictionary<string, TypeEntry> _byKey = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, TypeEntry> _byKey = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<Type, TypeEntry> _byType = []; 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() private static readonly JsonDocumentOptions DocumentOptions = new()
{ {
CommentHandling = JsonCommentHandling.Skip, CommentHandling = JsonCommentHandling.Skip,
@@ -43,6 +56,38 @@ public sealed class DefDatabase
_byType.Add(typeof(T), entry); _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> /// <summary>
/// Loads every <c>Defs/**/*.json</c> of <paramref name="mods"/> (in load order) and /// Loads every <c>Defs/**/*.json</c> of <paramref name="mods"/> (in load order) and
/// resolves inheritance. Call once after registering all def types. /// resolves inheritance. Call once after registering all def types.
@@ -66,6 +111,8 @@ public sealed class DefDatabase
} }
} }
ApplyPatches();
foreach (var entry in _byKey.Values) foreach (var entry in _byKey.Values)
{ {
Resolve(entry); Resolve(entry);
@@ -133,6 +180,12 @@ public sealed class DefDatabase
?? throw new InvalidDataException( ?? throw new InvalidDataException(
$"Def file '{file}' (mod '{mod.Id}') has no \"type\" field." $"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)) if (!_byKey.TryGetValue(typeKey, out var entry))
{ {
throw new InvalidDataException( 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)) foreach (var defName in entry.Raw.Keys.Order(StringComparer.Ordinal))
{ {
var merged = MergeChain(entry, defName, []); var merged = MergeChain(entry, defName, []);
@@ -179,6 +307,11 @@ public sealed class DefDatabase
continue; continue;
} }
if (rules is not null)
{
Validate(entry.Key, defName, merged, rules);
}
var def = var def =
(Def?)merged.Deserialize(entry.ClrType, ModInfo.JsonOptions) (Def?)merged.Deserialize(entry.ClrType, ModInfo.JsonOptions)
?? throw new InvalidDataException( ?? 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) private static JsonObject MergeChain(TypeEntry entry, string defName, HashSet<string> seen)
{ {
if (!seen.Add(defName)) if (!seen.Add(defName))
@@ -12,6 +12,13 @@ public interface IFormulaContext
/// if the name is unknown — the engine does not invent a default. /// if the name is unknown — the engine does not invent a default.
/// </summary> /// </summary>
float Resolve(string name); float Resolve(string name);
/// <summary>
/// Returns the values of every variable whose name satisfies <paramref name="matches"/> — the
/// backing for the group functions (<c>gsum</c>, <c>gavg</c>, …) that aggregate over a name
/// pattern, e.g. all <c>leaf_*</c> genes. Contexts with no enumerable variables return nothing.
/// </summary>
IEnumerable<float> ResolveMatching(Func<string, bool> matches) => [];
} }
/// <summary>An <see cref="IFormulaContext"/> backed by a lookup delegate — handy for tests and ad-hoc use.</summary> /// <summary>An <see cref="IFormulaContext"/> backed by a lookup delegate — handy for tests and ad-hoc use.</summary>
@@ -0,0 +1,98 @@
using System.Text.RegularExpressions;
using Node = System.Func<MrGameEng.Formulas.IFormulaContext, float>;
namespace MrGameEng.Formulas;
/// <summary>
/// The group functions — <c>gsum</c>, <c>gcount</c>, <c>gavg</c>, <c>gmin</c>, <c>gmax</c> — which
/// aggregate over every context variable whose name matches a regex literal, e.g.
/// <c>gsum('leaf_.*')</c> sums all <c>leaf_*</c> genes. The pattern is a string literal compiled to a
/// <see cref="Regex"/> once at parse time; aggregation reads <see cref="IFormulaContext.ResolveMatching"/>.
/// </summary>
internal static class FormulaGroups
{
private static readonly HashSet<string> Names = new(StringComparer.Ordinal)
{
"gsum",
"gcount",
"gavg",
"gmin",
"gmax",
};
public static bool IsGroupFunction(string name) => Names.Contains(name);
public static Node Build(string name, string pattern)
{
Regex regex;
try
{
regex = new Regex(pattern, RegexOptions.CultureInvariant);
}
catch (ArgumentException error)
{
throw new FormulaException($"Invalid regex '{pattern}': {error.Message}");
}
bool Match(string variable) => regex.IsMatch(variable);
return name switch
{
"gsum" => ctx => Aggregate(ctx.ResolveMatching(Match), sum: true),
"gavg" => ctx => Aggregate(ctx.ResolveMatching(Match), average: true),
"gcount" => ctx => Count(ctx.ResolveMatching(Match)),
"gmin" => ctx => Extreme(ctx.ResolveMatching(Match), max: false),
"gmax" => ctx => Extreme(ctx.ResolveMatching(Match), max: true),
_ => throw new FormulaException($"Unknown group function '{name}'."),
};
}
private static float Aggregate(
IEnumerable<float> values,
bool sum = false,
bool average = false
)
{
var total = 0f;
var count = 0;
foreach (var value in values)
{
total += value;
count++;
}
if (average)
{
return count == 0 ? 0f : total / count;
}
return total; // sum (count==0 → 0)
}
private static float Count(IEnumerable<float> values)
{
var count = 0;
foreach (var _ in values)
{
count++;
}
return count;
}
private static float Extreme(IEnumerable<float> values, bool max)
{
var has = false;
var best = 0f;
foreach (var value in values)
{
if (!has || (max ? value > best : value < best))
{
best = value;
}
has = true;
}
return best; // empty → 0
}
}
@@ -6,6 +6,7 @@ internal enum TokenType
{ {
Number, Number,
Identifier, Identifier,
String,
Plus, Plus,
Minus, Minus,
Star, Star,
@@ -99,6 +100,27 @@ internal static class FormulaLexer
continue; continue;
} }
if (c == '\'')
{
var open = i;
var start = ++i;
while (i < source.Length && source[i] != '\'')
{
i++;
}
if (i >= source.Length)
{
throw new FormulaException($"Unterminated string at position {open}.");
}
tokens.Add(
new Token(TokenType.String, open, text: source.Substring(start, i - start))
);
i++; // пропускаем закрывающую кавычку
continue;
}
var pos = i; var pos = i;
switch (c) switch (c)
{ {
@@ -232,6 +232,20 @@ internal sealed class FormulaParser(List<Token> tokens)
private Node ParseCall(string name) private Node ParseCall(string name)
{ {
Expect(TokenType.LParen); Expect(TokenType.LParen);
if (FormulaGroups.IsGroupFunction(name))
{
var pattern = Current;
if (!Match(TokenType.String))
{
throw new FormulaException(
$"Group function '{name}' expects a quoted regex pattern at position {pattern.Position}."
);
}
Expect(TokenType.RParen);
return FormulaGroups.Build(name, pattern.Text);
}
var args = new List<Node>(); var args = new List<Node>();
if (!Peek(TokenType.RParen)) if (!Peek(TokenType.RParen))
{ {
@@ -143,4 +143,73 @@ public sealed class DefDatabaseTests : IDisposable
Assert.Throws<KeyNotFoundException>(() => database.Get<AnimalDef>("Dodo")); Assert.Throws<KeyNotFoundException>(() => database.Get<AnimalDef>("Dodo"));
Assert.False(database.TryGet<AnimalDef>("Dodo", out _)); Assert.False(database.TryGet<AnimalDef>("Dodo", out _));
} }
private Mod WriteMod(string fileName, string json)
{
var id = $"mod{_modCounter++:D2}";
var modDir = Path.Combine(_root, id);
Directory.CreateDirectory(Path.Combine(modDir, "Defs"));
File.WriteAllText(Path.Combine(modDir, "Defs", fileName), json);
return new Mod(new ModInfo { Id = id }, modDir);
}
[Fact]
public void Patch_SetsFields_OnDefsMatchingNamePattern()
{
var defs = WriteDefsMod(
"""
{ "type": "Animal", "defs": [
{ "defName": "Wolf", "speed": 9 },
{ "defName": "WolfPup", "speed": 4 },
{ "defName": "Bear", "speed": 6 }
]}
"""
);
var patch = WriteMod(
"patches.json",
"""{ "type": "Patch", "patches": [ { "defType": "Animal", "match": "Wolf.*", "set": { "legs": 6 } } ] }"""
);
var database = LoadAnimals(defs, patch);
Assert.Equal(6, database.Get<AnimalDef>("Wolf").Legs); // matched
Assert.Equal(6, database.Get<AnimalDef>("WolfPup").Legs); // matched
Assert.Equal(4, database.Get<AnimalDef>("Bear").Legs); // unmatched → default
}
[Fact]
public void Patch_UnknownDefType_Throws()
{
var patch = WriteMod(
"patches.json",
"""{ "type": "Patch", "patches": [ { "defType": "Ghost", "match": ".*", "set": {} } ] }"""
);
Assert.Throws<InvalidDataException>(() => LoadAnimals(patch));
}
[Fact]
public void Validator_RejectsField_NotMatchingPattern()
{
var database = new DefDatabase();
database.RegisterType<AnimalDef>("Animal");
database.RegisterValidator("Animal", "defName", "^[A-Z]");
var bad = WriteDefsMod("""{ "type": "Animal", "defs": [ { "defName": "wolf" } ] }""");
var error = Assert.Throws<InvalidDataException>(() => database.Load([bad]));
Assert.Contains("wolf", error.Message);
}
[Fact]
public void Validator_Passes_WhenFieldMatches()
{
var database = new DefDatabase();
database.RegisterType<AnimalDef>("Animal");
database.RegisterValidator("Animal", "defName", "^[A-Z]");
database.Load([
WriteDefsMod("""{ "type": "Animal", "defs": [ { "defName": "Wolf", "speed": 7 } ] }"""),
]);
Assert.Equal(7f, database.Get<AnimalDef>("Wolf").Speed);
}
} }
@@ -109,4 +109,66 @@ public class FormulaTests
var formula = Formula.Compile("x + 1"); var formula = Formula.Compile("x + 1");
Assert.Throws<FormulaException>(() => formula.Evaluate()); Assert.Throws<FormulaException>(() => formula.Evaluate());
} }
// --- Group functions (regex aggregation over matching variables) ---
private sealed class GroupContext(Dictionary<string, float> values) : IFormulaContext
{
public float Resolve(string name) => values[name];
public IEnumerable<float> ResolveMatching(Func<string, bool> matches)
{
foreach (var (name, value) in values)
{
if (matches(name))
{
yield return value;
}
}
}
}
[Fact]
public void Evaluate_GroupFunctions_AggregateMatchingVariables()
{
var ctx = new GroupContext(
new()
{
["leaf_a"] = 2f,
["leaf_b"] = 4f,
["leaf_c"] = 6f,
["root_a"] = 100f,
}
);
Assert.Equal(12f, Formula.Compile("gsum('leaf_.*')").Evaluate(ctx), 4); // 2+4+6
Assert.Equal(3f, Formula.Compile("gcount('leaf_.*')").Evaluate(ctx), 4);
Assert.Equal(4f, Formula.Compile("gavg('leaf_.*')").Evaluate(ctx), 4);
Assert.Equal(2f, Formula.Compile("gmin('leaf_.*')").Evaluate(ctx), 4);
Assert.Equal(6f, Formula.Compile("gmax('leaf_.*')").Evaluate(ctx), 4);
}
[Fact]
public void Evaluate_GroupFunction_ComposesWithArithmetic()
{
var ctx = new GroupContext(new() { ["g1"] = 3f, ["g2"] = 5f });
Assert.Equal(16f, Formula.Compile("gsum('g.*') * 2").Evaluate(ctx), 4); // (3+5)*2
}
[Fact]
public void Evaluate_GroupFunction_NoMatches_IsZero()
{
var ctx = new GroupContext(new() { ["x"] = 1f });
Assert.Equal(0f, Formula.Compile("gsum('none_.*')").Evaluate(ctx), 4);
Assert.Equal(0f, Formula.Compile("gavg('none_.*')").Evaluate(ctx), 4);
}
[Theory]
[InlineData("gsum(leaf)")] // pattern must be a quoted string
[InlineData("gsum('[')")] // invalid regex
[InlineData("gsum('a' 'b')")] // extra token
public void Compile_BadGroupCall_Throws(string expr)
{
Assert.Throws<FormulaException>(() => Formula.Compile(expr));
}
} }