diff --git a/src/MrGameEng.Content/Genetics/Phenotype.cs b/src/MrGameEng.Content/Genetics/Phenotype.cs index ffe1dec..323c138 100644 --- a/src/MrGameEng.Content/Genetics/Phenotype.cs +++ b/src/MrGameEng.Content/Genetics/Phenotype.cs @@ -71,5 +71,17 @@ public static class Phenotype throw new FormulaException($"Unknown variable '{name}' while computing traits."); } + + // Группировка генов в формулах: значения всех генов, чьи id подходят под шаблон (gsum/gavg/…). + public IEnumerable ResolveMatching(Func matches) + { + foreach (var geneId in genome.Alleles.Keys) + { + if (matches(geneId) && registry.TryGetValue(geneId, out var gene)) + { + yield return genome.Express(gene); + } + } + } } } diff --git a/src/MrGameEng.Content/Mods/DefDatabase.cs b/src/MrGameEng.Content/Mods/DefDatabase.cs index 860859c..ca48b09 100644 --- a/src/MrGameEng.Content/Mods/DefDatabase.cs +++ b/src/MrGameEng.Content/Mods/DefDatabase.cs @@ -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 _byKey = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _byType = []; + /// Reserved def-file "type" that carries content patches rather than defs. + 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 _patches = []; + private readonly Dictionary> _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); } + /// + /// Registers a load-time validation: the string of every resolved def of + /// type must match (a regex), or + /// throws. Non-string or absent fields are skipped. Use it to enforce naming + /// conventions (e.g. gene ids start with Gene) or key/format rules across a mod's content. + /// + 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}/")); + } + /// /// Loads every Defs/**/*.json of (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() + ?? throw new InvalidDataException($"A patch in '{file}' has no \"defType\"."); + var match = + patch["match"]?.GetValue() + ?? 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 rules + ) + { + foreach (var rule in rules) + { + if ( + merged[rule.Field] is JsonValue value + && value.TryGetValue(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 seen) { if (!seen.Add(defName)) diff --git a/src/MrGameEng.Core/Formulas/FormulaContext.cs b/src/MrGameEng.Core/Formulas/FormulaContext.cs index 25b7d6f..2d696b3 100644 --- a/src/MrGameEng.Core/Formulas/FormulaContext.cs +++ b/src/MrGameEng.Core/Formulas/FormulaContext.cs @@ -12,6 +12,13 @@ public interface IFormulaContext /// if the name is unknown — the engine does not invent a default. /// float Resolve(string name); + + /// + /// Returns the values of every variable whose name satisfies — the + /// backing for the group functions (gsum, gavg, …) that aggregate over a name + /// pattern, e.g. all leaf_* genes. Contexts with no enumerable variables return nothing. + /// + IEnumerable ResolveMatching(Func matches) => []; } /// An backed by a lookup delegate — handy for tests and ad-hoc use. diff --git a/src/MrGameEng.Core/Formulas/FormulaGroups.cs b/src/MrGameEng.Core/Formulas/FormulaGroups.cs new file mode 100644 index 0000000..2b53870 --- /dev/null +++ b/src/MrGameEng.Core/Formulas/FormulaGroups.cs @@ -0,0 +1,98 @@ +using System.Text.RegularExpressions; +using Node = System.Func; + +namespace MrGameEng.Formulas; + +/// +/// The group functions — gsum, gcount, gavg, gmin, gmax — which +/// aggregate over every context variable whose name matches a regex literal, e.g. +/// gsum('leaf_.*') sums all leaf_* genes. The pattern is a string literal compiled to a +/// once at parse time; aggregation reads . +/// +internal static class FormulaGroups +{ + private static readonly HashSet 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 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 values) + { + var count = 0; + foreach (var _ in values) + { + count++; + } + + return count; + } + + private static float Extreme(IEnumerable 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 + } +} diff --git a/src/MrGameEng.Core/Formulas/FormulaLexer.cs b/src/MrGameEng.Core/Formulas/FormulaLexer.cs index 423fbce..467de9e 100644 --- a/src/MrGameEng.Core/Formulas/FormulaLexer.cs +++ b/src/MrGameEng.Core/Formulas/FormulaLexer.cs @@ -6,6 +6,7 @@ internal enum TokenType { Number, Identifier, + String, Plus, Minus, Star, @@ -99,6 +100,27 @@ internal static class FormulaLexer 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; switch (c) { diff --git a/src/MrGameEng.Core/Formulas/FormulaParser.cs b/src/MrGameEng.Core/Formulas/FormulaParser.cs index b897191..4ed99bd 100644 --- a/src/MrGameEng.Core/Formulas/FormulaParser.cs +++ b/src/MrGameEng.Core/Formulas/FormulaParser.cs @@ -232,6 +232,20 @@ internal sealed class FormulaParser(List tokens) private Node ParseCall(string name) { 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(); if (!Peek(TokenType.RParen)) { diff --git a/tests/MrGameEng.Content.Tests/Mods/DefDatabaseTests.cs b/tests/MrGameEng.Content.Tests/Mods/DefDatabaseTests.cs index 841614b..661568d 100644 --- a/tests/MrGameEng.Content.Tests/Mods/DefDatabaseTests.cs +++ b/tests/MrGameEng.Content.Tests/Mods/DefDatabaseTests.cs @@ -143,4 +143,73 @@ public sealed class DefDatabaseTests : IDisposable Assert.Throws(() => database.Get("Dodo")); Assert.False(database.TryGet("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("Wolf").Legs); // matched + Assert.Equal(6, database.Get("WolfPup").Legs); // matched + Assert.Equal(4, database.Get("Bear").Legs); // unmatched → default + } + + [Fact] + public void Patch_UnknownDefType_Throws() + { + var patch = WriteMod( + "patches.json", + """{ "type": "Patch", "patches": [ { "defType": "Ghost", "match": ".*", "set": {} } ] }""" + ); + + Assert.Throws(() => LoadAnimals(patch)); + } + + [Fact] + public void Validator_RejectsField_NotMatchingPattern() + { + var database = new DefDatabase(); + database.RegisterType("Animal"); + database.RegisterValidator("Animal", "defName", "^[A-Z]"); + var bad = WriteDefsMod("""{ "type": "Animal", "defs": [ { "defName": "wolf" } ] }"""); + + var error = Assert.Throws(() => database.Load([bad])); + Assert.Contains("wolf", error.Message); + } + + [Fact] + public void Validator_Passes_WhenFieldMatches() + { + var database = new DefDatabase(); + database.RegisterType("Animal"); + database.RegisterValidator("Animal", "defName", "^[A-Z]"); + database.Load([ + WriteDefsMod("""{ "type": "Animal", "defs": [ { "defName": "Wolf", "speed": 7 } ] }"""), + ]); + + Assert.Equal(7f, database.Get("Wolf").Speed); + } } diff --git a/tests/MrGameEng.Core.Tests/FormulaTests.cs b/tests/MrGameEng.Core.Tests/FormulaTests.cs index e126106..2ac85a9 100644 --- a/tests/MrGameEng.Core.Tests/FormulaTests.cs +++ b/tests/MrGameEng.Core.Tests/FormulaTests.cs @@ -109,4 +109,66 @@ public class FormulaTests var formula = Formula.Compile("x + 1"); Assert.Throws(() => formula.Evaluate()); } + + // --- Group functions (regex aggregation over matching variables) --- + + private sealed class GroupContext(Dictionary values) : IFormulaContext + { + public float Resolve(string name) => values[name]; + + public IEnumerable ResolveMatching(Func 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(() => Formula.Compile(expr)); + } }