Regex tooling: formula gene grouping, content patches, def validation
CI / build-test (push) Successful in 1m12s
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:
co-authored by
Claude Opus 4.8
parent
ead22517ee
commit
d044cafad9
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user