Compare commits

...
3 Commits
Author SHA1 Message Date
Leonid PershinandClaude Opus 4.8 d044cafad9 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>
2026-06-13 03:59:36 +03:00
Leonid PershinandClaude Opus 4.8 ead22517ee Genetics: GenomeTemplate (per-organism gene allotment) + breed override
CI / build-test (push) Successful in 1m17s
Adds the species/organism layer on top of the gene foundation.
GenomeTemplate carries which GeneDefs an individual has plus the
per-organism base value and spread its alleles are drawn around (and an
optional discrete-variant override), so the same shared GeneDef expresses
different centres for different species — Generate() draws an individual,
Registry() feeds breeding and trait computation.

Genome.Breed gains an optional mutationChance that overrides every gene's
fixed MutationChance, so a caller can drive mutation from an evolvable
trait. Allele sampling (numeric spread+clamp, weighted discrete pick) is
factored into a shared GeneSampling used by both Generate paths.

Covered by GenomeTemplateTests (per-species centres, registry-driven
breeding, mutation override on/off).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:51:02 +03:00
Leonid PershinandClaude Opus 4.8 bc18db5df6 Add gene foundation: GeneDef, Genome, trait phenotype (Content)
CI / build-test (push) Successful in 1m17s
The organism-agnostic core of the gene system, built on the formula
engine. GeneDef is a def describing a gene's kind (numeric/discrete),
allele generation range/spread, mutation, variant distribution and its
effects on named traits as formulas. Genome is a managed, variable-
composition map (geneId -> Allele pair): generated from a gene set, bred
meiotically with per-gene mutation, expressed to a phenotype (numeric
mean / discrete lower-allele dominance); open composition allows hybrids.
Phenotype.Compute aggregates each gene's effect formulas into a trait
map (variable `value` = the gene's expressed phenotype, other gene ids
and an environment context resolve too), so systems read traits, never
genes.

Nothing here is species-specific. Def JSON now supports string-named
enums (JsonStringEnumConverter) so a gene's kind reads as "Discrete".
Covered by GeneticsTests (generation/expression/breeding/traits) and
GeneDefLoadTests (GeneDef through the real DefDatabase).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:43:01 +03:00
17 changed files with 1271 additions and 1 deletions
+16
View File
@@ -0,0 +1,16 @@
namespace MrGameEng.Genetics;
/// <summary>
/// A diploid gene slot: the two allele values an individual carries for one gene. Stored as floats
/// for both gene kinds — a <see cref="GeneKind.Discrete"/> gene simply holds integral variant
/// indices. How the pair becomes a single phenotype value is decided by the gene's
/// <see cref="GeneKind"/> (see <see cref="Genome.Express"/>).
/// </summary>
public readonly record struct Allele(float A, float B)
{
/// <summary>The average of the two alleles — the phenotype of a numeric gene.</summary>
public float Mean => (A + B) * 0.5f;
/// <summary>The lower (dominant) of the two alleles — the phenotype of a discrete gene.</summary>
public float Dominant => MathF.Min(A, B);
}
+97
View File
@@ -0,0 +1,97 @@
using System.Text.Json.Serialization;
using MrGameEng.Formulas;
using MrGameEng.Mods;
namespace MrGameEng.Genetics;
/// <summary>How a gene's two alleles are stored and expressed into a phenotype value.</summary>
public enum GeneKind
{
/// <summary>A continuous value; the phenotype is the average of the two alleles (hybrid blending).</summary>
Numeric,
/// <summary>
/// A discrete allele index in <c>[0, Variants)</c>; the lower index is dominant, so the
/// phenotype is <c>min(a, b)</c> — a higher (recessive) variant shows only when homozygous.
/// A two-variant discrete gene is effectively a flag.
/// </summary>
Discrete,
}
/// <summary>
/// An organism-agnostic gene definition — the unit the whole gene system is built from. A
/// <see cref="GeneDef"/> describes how to generate an individual's two alleles, how they mutate
/// when bred, and how the gene <see cref="Effects"/> contribute to named phenotype traits via
/// <see cref="Formula"/> expressions. Nothing here is plant-, animal- or human-specific, so the
/// same machinery drives any organism and arbitrary hybrids (a genome can carry any mix of genes).
/// </summary>
public sealed class GeneDef : Def
{
/// <summary>Whether the gene is continuous or a discrete dominant/recessive allele.</summary>
public GeneKind Kind { get; init; } = GeneKind.Numeric;
/// <summary>Numeric: the central value an allele is generated around.</summary>
public float Default { get; init; }
/// <summary>Numeric: lower clamp for generated and mutated allele values.</summary>
public float Min { get; init; } = float.NegativeInfinity;
/// <summary>Numeric: upper clamp for generated and mutated allele values.</summary>
public float Max { get; init; } = float.PositiveInfinity;
/// <summary>Numeric: relative spread of generated alleles around <see cref="Default"/> (allele = Default ± Spread·|Default|).</summary>
public float Spread { get; init; }
/// <summary>Numeric: relative magnitude of a mutation step (value ± Magnitude·|value|).</summary>
public float MutationMagnitude { get; init; } = 0.1f;
/// <summary>Discrete: number of allele variants, valued <c>0..Variants-1</c>.</summary>
public int Variants { get; init; } = 2;
/// <summary>
/// Discrete: relative weights for generating each variant (length <see cref="Variants"/>).
/// Empty means a uniform distribution.
/// </summary>
public float[] VariantWeights { get; init; } = [];
/// <summary>Probability, per allele, that a mutation occurs when this gene is passed to a child.</summary>
public float MutationChance { get; init; }
/// <summary>
/// The gene's contributions to phenotype traits: trait name → formula. Each formula may use the
/// variable <c>value</c> (this gene's expressed phenotype), any other gene's id (its expressed
/// value) and any environment variable the caller supplies. Contributions to the same trait
/// across genes are summed.
/// </summary>
public Dictionary<string, string> Effects { get; init; } = new();
/// <summary>Free-form category tags for grouping genes (used by formula grouping and content tooling).</summary>
public string[] Tags { get; init; } = [];
private IReadOnlyDictionary<string, Formula>? _compiled;
/// <summary>The <see cref="Effects"/> compiled once into evaluable formulas (lazy, cached).</summary>
[JsonIgnore]
public IReadOnlyDictionary<string, Formula> CompiledEffects => _compiled ??= CompileEffects();
private Dictionary<string, Formula> CompileEffects()
{
var compiled = new Dictionary<string, Formula>(StringComparer.Ordinal);
foreach (var (trait, expression) in Effects)
{
try
{
compiled[trait] = Formula.Compile(expression);
}
catch (FormulaException error)
{
throw new InvalidDataException(
$"Gene '{DefName}' effect on trait '{trait}' has an invalid formula "
+ $"\"{expression}\": {error.Message}"
);
}
}
return compiled;
}
}
@@ -0,0 +1,56 @@
namespace MrGameEng.Genetics;
/// <summary>
/// Shared, deterministic allele sampling used by both <see cref="Genome.Generate"/> (gene defaults)
/// and <see cref="GenomeTemplate"/> (per-individual base overrides). Centralizes the numeric
/// spread+clamp and the weighted discrete pick so the two paths stay consistent.
/// </summary>
internal static class GeneSampling
{
/// <summary>A numeric allele drawn as <c>baseValue ± spread·|baseValue|</c>, clamped to the gene's range.</summary>
public static float Numeric(GeneDef gene, float baseValue, float spread, Random random)
{
var value = baseValue + (random.NextSingle() * 2f - 1f) * spread * MathF.Abs(baseValue);
return Math.Clamp(value, gene.Min, gene.Max);
}
/// <summary>
/// A discrete variant index in <c>[0, Variants)</c>, picked from <paramref name="weights"/> when
/// they match the variant count, otherwise the gene's own weights, otherwise uniformly.
/// </summary>
public static float Variant(GeneDef gene, float[]? weights, Random random)
{
var variants = Math.Max(1, gene.Variants);
var w =
weights is { Length: > 0 } && weights.Length == variants
? weights
: gene.VariantWeights;
if (w.Length != variants)
{
return random.Next(variants);
}
var total = 0f;
foreach (var value in w)
{
total += MathF.Max(0f, value);
}
if (total <= 0f)
{
return random.Next(variants);
}
var roll = random.NextSingle() * total;
for (var i = 0; i < variants; i++)
{
roll -= MathF.Max(0f, w[i]);
if (roll < 0f)
{
return i;
}
}
return variants - 1;
}
}
+156
View File
@@ -0,0 +1,156 @@
namespace MrGameEng.Genetics;
/// <summary>
/// An individual's managed genome: a variable-composition map from gene id to the
/// <see cref="Allele"/> pair it carries. Because composition is open, two organisms need not share
/// the same gene set and a genome can gain "foreign" genes — the basis for arbitrary hybrids. The
/// genome is generated from a set of <see cref="GeneDef"/>s, bred meiotically with mutation, and
/// expressed into phenotype values; all randomness flows through a caller-owned seeded
/// <see cref="Random"/> so the simulation stays deterministic.
/// </summary>
public sealed class Genome
{
private readonly Dictionary<string, Allele> _alleles;
/// <summary>Creates an empty genome.</summary>
public Genome() => _alleles = new Dictionary<string, Allele>(StringComparer.Ordinal);
/// <summary>Creates a genome from an existing allele map (copied).</summary>
public Genome(IReadOnlyDictionary<string, Allele> alleles) =>
_alleles = new Dictionary<string, Allele>(alleles, StringComparer.Ordinal);
/// <summary>The carried genes and their allele pairs.</summary>
public IReadOnlyDictionary<string, Allele> Alleles => _alleles;
/// <summary>Whether the genome carries the gene <paramref name="geneId"/>.</summary>
public bool Has(string geneId) => _alleles.ContainsKey(geneId);
/// <summary>Gets or sets the allele pair for <paramref name="geneId"/>.</summary>
public Allele this[string geneId]
{
get => _alleles[geneId];
set => _alleles[geneId] = value;
}
/// <summary>Removes a gene from the genome; returns whether it was present.</summary>
public bool Remove(string geneId) => _alleles.Remove(geneId);
/// <summary>
/// Expresses the gene's phenotype value: the mean of the alleles for a numeric gene, the
/// dominant (lower) allele for a discrete one. Throws if the genome does not carry the gene.
/// </summary>
public float Express(GeneDef gene)
{
if (!_alleles.TryGetValue(gene.DefName, out var allele))
{
throw new KeyNotFoundException($"Genome does not carry gene '{gene.DefName}'.");
}
return gene.Kind == GeneKind.Numeric ? allele.Mean : allele.Dominant;
}
/// <summary>Builds the allele map for serialization (a copy).</summary>
public Dictionary<string, Allele> ToDictionary() => new(_alleles, StringComparer.Ordinal);
/// <summary>
/// Generates a fresh genome carrying every gene in <paramref name="genes"/>, each allele drawn
/// independently around the gene's default with its spread (numeric) or from its variant
/// distribution (discrete).
/// </summary>
public static Genome Generate(IEnumerable<GeneDef> genes, Random random)
{
var genome = new Genome();
foreach (var gene in genes)
{
genome._alleles[gene.DefName] = new Allele(
GenerateAllele(gene, random),
GenerateAllele(gene, random)
);
}
return genome;
}
/// <summary>
/// Breeds a child genome from two parents (meiosis): the child carries every gene either parent
/// has. For a gene both carry, one allele is drawn from each parent; for a gene only one parent
/// carries, it is inherited (from that parent, on both sides) with 50% probability. Every
/// inherited allele may then mutate per its <see cref="GeneDef"/>. <paramref name="registry"/>
/// supplies the def for each gene id; genes absent from it are skipped.
/// <paramref name="mutationChance"/>, when given, overrides every gene's
/// <see cref="GeneDef.MutationChance"/> — letting the caller drive mutation from an evolvable
/// trait rather than a fixed per-gene constant.
/// </summary>
public static Genome Breed(
Genome a,
Genome b,
IReadOnlyDictionary<string, GeneDef> registry,
Random random,
float? mutationChance = null
)
{
var child = new Genome();
foreach (var geneId in UnionKeys(a, b))
{
if (!registry.TryGetValue(geneId, out var gene))
{
continue;
}
var chance = mutationChance ?? gene.MutationChance;
var inA = a.Has(geneId);
var inB = b.Has(geneId);
if (inA && inB)
{
child._alleles[geneId] = new Allele(
Meiosis(gene, a[geneId], chance, random),
Meiosis(gene, b[geneId], chance, random)
);
}
else if (random.NextSingle() < 0.5f)
{
var parent = inA ? a : b;
child._alleles[geneId] = new Allele(
Meiosis(gene, parent[geneId], chance, random),
Meiosis(gene, parent[geneId], chance, random)
);
}
}
return child;
}
// One inherited allele: pick one of the parent slot's two alleles, then maybe mutate it.
private static float Meiosis(GeneDef gene, Allele parent, float mutationChance, Random random)
{
var inherited = random.NextSingle() < 0.5f ? parent.A : parent.B;
if (random.NextSingle() >= mutationChance)
{
return inherited;
}
if (gene.Kind == GeneKind.Discrete)
{
return GeneSampling.Variant(gene, null, random);
}
var shifted =
inherited
+ (random.NextSingle() * 2f - 1f) * gene.MutationMagnitude * MathF.Abs(inherited);
return Math.Clamp(shifted, gene.Min, gene.Max);
}
private static float GenerateAllele(GeneDef gene, Random random) =>
gene.Kind == GeneKind.Discrete
? GeneSampling.Variant(gene, null, random)
: GeneSampling.Numeric(gene, gene.Default, gene.Spread, random);
// Deterministic union of both parents' gene ids (ordered) so breeding is reproducible.
private static IEnumerable<string> UnionKeys(Genome a, Genome b)
{
var keys = new SortedSet<string>(StringComparer.Ordinal);
keys.UnionWith(a._alleles.Keys);
keys.UnionWith(b._alleles.Keys);
return keys;
}
}
@@ -0,0 +1,61 @@
namespace MrGameEng.Genetics;
/// <summary>
/// A species' (or any organism kind's) gene allotment: which <see cref="GeneDef"/>s an individual
/// carries and the per-organism base values its alleles are generated around. The same
/// <see cref="GeneDef"/> (e.g. "optimal light") is shared by every species, while the template
/// supplies the species-specific centre and spread — so an oak and grass differ in values, not in
/// machinery. <see cref="Generate"/> draws a fresh individual; <see cref="Registry"/> feeds breeding
/// and trait computation.
/// </summary>
public sealed class GenomeTemplate
{
/// <summary>
/// One gene in the allotment. <paramref name="Base"/>/<paramref name="Spread"/> centre a numeric
/// gene's alleles; <paramref name="VariantWeights"/> (optional) override a discrete gene's
/// variant distribution for this organism.
/// </summary>
public readonly record struct Entry(
GeneDef Gene,
float Base,
float Spread,
float[]? VariantWeights = null
);
private readonly List<Entry> _entries;
private readonly Dictionary<string, GeneDef> _registry;
/// <summary>Builds a template from its gene entries.</summary>
public GenomeTemplate(IEnumerable<Entry> entries)
{
_entries = entries.ToList();
_registry = new Dictionary<string, GeneDef>(StringComparer.Ordinal);
foreach (var entry in _entries)
{
_registry[entry.Gene.DefName] = entry.Gene;
}
}
/// <summary>The gene entries that make up the allotment.</summary>
public IReadOnlyList<Entry> Entries => _entries;
/// <summary>Gene id → def for every carried gene; pass to <see cref="Genome.Breed"/> and <see cref="Phenotype.Compute"/>.</summary>
public IReadOnlyDictionary<string, GeneDef> Registry => _registry;
/// <summary>Generates a fresh individual: two alleles per gene drawn around each entry's base/variant.</summary>
public Genome Generate(Random random)
{
var genome = new Genome();
foreach (var entry in _entries)
{
genome[entry.Gene.DefName] = new Allele(Draw(entry, random), Draw(entry, random));
}
return genome;
}
private static float Draw(Entry entry, Random random) =>
entry.Gene.Kind == GeneKind.Discrete
? GeneSampling.Variant(entry.Gene, entry.VariantWeights, random)
: GeneSampling.Numeric(entry.Gene, entry.Base, entry.Spread, random);
}
@@ -0,0 +1,87 @@
using MrGameEng.Formulas;
namespace MrGameEng.Genetics;
/// <summary>
/// Computes the trait layer — the phenotype the simulation actually reads — from a
/// <see cref="Genome"/>. Each gene's <see cref="GeneDef.Effects"/> formulas are evaluated and their
/// results summed per trait name, so systems never touch genes directly: one fruiting system reads
/// a <c>fruitYield</c> trait whether it comes from a tree or a human carrying a "fruit" gene.
/// Formulas see the variable <c>value</c> (the contributing gene's expressed phenotype), any other
/// carried gene's id, and whatever environment variables the caller supplies.
/// </summary>
public static class Phenotype
{
/// <summary>
/// Evaluates every carried gene's effects against the genome and an optional
/// <paramref name="environment"/>, summing contributions into a trait map. Genes missing from
/// <paramref name="registry"/> are skipped.
/// </summary>
public static Dictionary<string, float> Compute(
Genome genome,
IReadOnlyDictionary<string, GeneDef> registry,
IFormulaContext? environment = null
)
{
var traits = new Dictionary<string, float>(StringComparer.Ordinal);
var context = new GenomeContext(genome, registry, environment);
foreach (var geneId in genome.Alleles.Keys)
{
if (!registry.TryGetValue(geneId, out var gene) || gene.CompiledEffects.Count == 0)
{
continue;
}
context.Self = genome.Express(gene);
foreach (var (trait, formula) in gene.CompiledEffects)
{
traits[trait] = traits.GetValueOrDefault(trait) + formula.Evaluate(context);
}
}
return traits;
}
// Resolves formula variables for a gene effect: 'value' is the current gene's phenotype, any
// carried gene's id resolves to its phenotype, anything else falls through to the environment.
private sealed class GenomeContext(
Genome genome,
IReadOnlyDictionary<string, GeneDef> registry,
IFormulaContext? environment
) : IFormulaContext
{
public float Self;
public float Resolve(string name)
{
if (name == "value")
{
return Self;
}
if (genome.Has(name) && registry.TryGetValue(name, out var gene))
{
return genome.Express(gene);
}
if (environment is not null)
{
return environment.Resolve(name);
}
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.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))
+2
View File
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MrGameEng.Mods;
@@ -14,6 +15,7 @@ public sealed class ModInfo
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
WriteIndented = true,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Unique mod id, referenced by <see cref="Dependencies"/> of other mods.</summary>
@@ -12,6 +12,13 @@ public interface IFormulaContext
/// if the name is unknown — the engine does not invent a default.
/// </summary>
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>
@@ -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,
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)
{
@@ -232,6 +232,20 @@ internal sealed class FormulaParser(List<Token> 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<Node>();
if (!Peek(TokenType.RParen))
{
@@ -0,0 +1,68 @@
using MrGameEng.Genetics;
using MrGameEng.Mods;
using Xunit;
namespace MrGameEng.Genetics.Tests;
/// <summary>
/// Verifies <see cref="GeneDef"/> loads through the real <see cref="DefDatabase"/> the way the game
/// loads <c>genes.json</c>: string-named <see cref="GeneKind"/>, parent inheritance and effect maps.
/// </summary>
public sealed class GeneDefLoadTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("mrge-gene-tests-").FullName;
public void Dispose() => Directory.Delete(_root, recursive: true);
private DefDatabase Load(string defsJson)
{
var modDir = Path.Combine(_root, "mod");
Directory.CreateDirectory(Path.Combine(modDir, "Defs"));
File.WriteAllText(Path.Combine(modDir, "Defs", "genes.json"), defsJson);
var database = new DefDatabase();
database.RegisterType<GeneDef>("Gene");
database.Load([new Mod(new ModInfo { Id = "mod" }, modDir)]);
return database;
}
[Fact]
public void Load_NumericGeneWithParentAndEffects_Resolves()
{
var database = Load(
"""
{ "type": "Gene", "defs": [
{ "defName": "BaseGene", "abstract": true, "spread": 0.1, "mutationChance": 0.05 },
{ "defName": "GeneVigor", "parent": "BaseGene", "default": 1.0, "min": 0.1, "max": 3.0,
"tags": ["growth"], "effects": { "vigor": "value" } }
]}
"""
);
var gene = database.Get<GeneDef>("GeneVigor");
Assert.Equal(GeneKind.Numeric, gene.Kind);
Assert.Equal(0.1f, gene.Spread); // inherited from parent
Assert.Equal(0.05f, gene.MutationChance); // inherited
Assert.Equal(3.0f, gene.Max);
Assert.Equal(["growth"], gene.Tags);
Assert.Equal("value", gene.Effects["vigor"]);
Assert.Single(gene.CompiledEffects); // formula compiles
}
[Fact]
public void Load_DiscreteKindByName_Parses()
{
var database = Load(
"""
{ "type": "Gene", "defs": [
{ "defName": "GeneMorph", "kind": "Discrete", "variants": 2,
"variantWeights": [0.8, 0.2], "effects": { "variant": "value" } }
]}
"""
);
var gene = database.Get<GeneDef>("GeneMorph");
Assert.Equal(GeneKind.Discrete, gene.Kind);
Assert.Equal(2, gene.Variants);
Assert.Equal([0.8f, 0.2f], gene.VariantWeights);
}
}
@@ -0,0 +1,194 @@
using MrGameEng.Formulas;
using MrGameEng.Genetics;
using Xunit;
namespace MrGameEng.Genetics.Tests;
public class GeneticsTests
{
private static GeneDef Numeric(
string name,
float def,
float spread = 0f,
float min = float.NegativeInfinity,
float max = float.PositiveInfinity,
float mutationChance = 0f,
float mutationMagnitude = 0.1f,
Dictionary<string, string>? effects = null
) =>
new()
{
DefName = name,
Kind = GeneKind.Numeric,
Default = def,
Spread = spread,
Min = min,
Max = max,
MutationChance = mutationChance,
MutationMagnitude = mutationMagnitude,
Effects = effects ?? new(),
};
private static GeneDef Discrete(string name, int variants = 2, float mutationChance = 0f) =>
new()
{
DefName = name,
Kind = GeneKind.Discrete,
Variants = variants,
MutationChance = mutationChance,
};
private static Dictionary<string, GeneDef> Registry(params GeneDef[] genes) =>
genes.ToDictionary(g => g.DefName, g => g, StringComparer.Ordinal);
[Fact]
public void Generate_NumericAlleles_StayWithinSpreadAndClamp()
{
var gene = Numeric("vigor", def: 1f, spread: 0.2f, min: 0f, max: 2f);
var random = new Random(7);
for (var i = 0; i < 200; i++)
{
var genome = Genome.Generate([gene], random);
var allele = genome["vigor"];
Assert.InRange(allele.A, 0.8f, 1.2f);
Assert.InRange(allele.B, 0.8f, 1.2f);
}
}
[Fact]
public void Generate_SameSeed_IsDeterministic()
{
var genes = new[] { Numeric("a", 1f, 0.3f), Discrete("morph", 2) };
var first = Genome.Generate(genes, new Random(42));
var second = Genome.Generate(genes, new Random(42));
Assert.Equal(first["a"], second["a"]);
Assert.Equal(first["morph"], second["morph"]);
}
[Fact]
public void Express_Numeric_IsAlleleMean()
{
var gene = Numeric("opt", 0f);
var genome = new Genome { ["opt"] = new Allele(0.4f, 0.8f) };
Assert.Equal(0.6f, genome.Express(gene), 5);
}
[Fact]
public void Express_Discrete_DominantIsLowerIndex()
{
var gene = Discrete("morph", 2);
Assert.Equal(0f, new Genome { ["morph"] = new Allele(0f, 1f) }.Express(gene)); // heterozygous → dominant 0
Assert.Equal(1f, new Genome { ["morph"] = new Allele(1f, 1f) }.Express(gene)); // homozygous recessive
}
[Fact]
public void Breed_WithoutMutation_InheritsOneAlleleFromEachParent()
{
var gene = Numeric("g", 0f, mutationChance: 0f);
var registry = Registry(gene);
var a = new Genome { ["g"] = new Allele(1f, 2f) };
var b = new Genome { ["g"] = new Allele(3f, 4f) };
var child = Genome.Breed(a, b, registry, new Random(1));
Assert.Contains(child["g"].A, new[] { 1f, 2f }); // first allele from parent a
Assert.Contains(child["g"].B, new[] { 3f, 4f }); // second allele from parent b
}
[Fact]
public void Breed_UnionOfGenes_ProducesHybridComposition()
{
// a carries only "leaf", b carries only "root" — a child can carry both (hybrid).
var registry = Registry(Numeric("leaf", 1f), Numeric("root", 1f));
var a = new Genome { ["leaf"] = new Allele(1f, 1f) };
var b = new Genome { ["root"] = new Allele(2f, 2f) };
var carriedBoth = false;
for (var seed = 0; seed < 50 && !carriedBoth; seed++)
{
var child = Genome.Breed(a, b, registry, new Random(seed));
carriedBoth = child.Has("leaf") && child.Has("root");
}
Assert.True(carriedBoth, "single-parent genes should sometimes both be inherited");
}
[Fact]
public void Breed_HighMutation_DiscreteCanFlipVariant()
{
var gene = Discrete("morph", variants: 2, mutationChance: 1f);
var registry = Registry(gene);
var parent = new Genome { ["morph"] = new Allele(0f, 0f) };
var sawOne = false;
for (var seed = 0; seed < 50 && !sawOne; seed++)
{
var child = Genome.Breed(parent, parent, registry, new Random(seed));
var allele = child["morph"];
sawOne = allele.A == 1f || allele.B == 1f;
}
Assert.True(sawOne, "with full mutation a 0/0 parent should sometimes yield variant 1");
}
[Fact]
public void Compute_GeneEffect_UsesValueVariable()
{
var gene = Numeric("vigor", 0f, effects: new() { ["growth"] = "value * 2" });
var genome = new Genome { ["vigor"] = new Allele(1.5f, 2.5f) }; // mean 2
var traits = Phenotype.Compute(genome, Registry(gene));
Assert.Equal(4f, traits["growth"], 5); // 2 * 2
}
[Fact]
public void Compute_MultipleGenes_SumContributionsPerTrait()
{
var a = Numeric("a", 0f, effects: new() { ["yield"] = "value" });
var b = Numeric("b", 0f, effects: new() { ["yield"] = "value" });
var genome = new Genome { ["a"] = new Allele(3f, 3f), ["b"] = new Allele(4f, 4f) };
var traits = Phenotype.Compute(genome, Registry(a, b));
Assert.Equal(7f, traits["yield"], 5);
}
[Fact]
public void Compute_FormulaReadsEnvironmentAndOtherGenes()
{
var opt = Numeric("optLight", 0.5f);
var vigor = Numeric(
"vigor",
1f,
effects: new() { ["rate"] = "value * (1 - abs(light - optLight))" }
);
var genome = new Genome
{
["optLight"] = new Allele(0.5f, 0.5f),
["vigor"] = new Allele(1f, 1f),
};
var env = new DelegateFormulaContext(n =>
n == "light" ? 0.7f : throw new FormulaException(n)
);
var traits = Phenotype.Compute(genome, Registry(opt, vigor), env);
Assert.Equal(0.8f, traits["rate"], 5); // 1 * (1 - |0.7 - 0.5|)
}
[Fact]
public void CompiledEffects_InvalidFormula_ThrowsWithGeneAndTrait()
{
var gene = Numeric("bad", 0f, effects: new() { ["t"] = "value *" });
var error = Assert.Throws<InvalidDataException>(() => _ = gene.CompiledEffects);
Assert.Contains("bad", error.Message);
Assert.Contains("t", error.Message);
}
}
@@ -0,0 +1,106 @@
using MrGameEng.Genetics;
using Xunit;
namespace MrGameEng.Genetics.Tests;
public class GenomeTemplateTests
{
private static GeneDef Numeric(string name, float min, float max) =>
new()
{
DefName = name,
Kind = GeneKind.Numeric,
Min = min,
Max = max,
};
private static GeneDef Discrete(string name, int variants = 2) =>
new()
{
DefName = name,
Kind = GeneKind.Discrete,
Variants = variants,
};
[Fact]
public void Generate_UsesPerEntryBaseAndSpread_NotGeneDefault()
{
var gene = Numeric("opt", min: 0f, max: 100f); // GeneDef.Default is 0
var template = new GenomeTemplate([
new GenomeTemplate.Entry(gene, Base: 50f, Spread: 0.1f),
]);
var random = new Random(3);
for (var i = 0; i < 200; i++)
{
var allele = template.Generate(random)["opt"];
Assert.InRange(allele.A, 45f, 55f); // around the template base, not 0
Assert.InRange(allele.B, 45f, 55f);
}
}
[Fact]
public void Generate_TwoSpecies_DifferInCentre()
{
var gene = Numeric("opt", 0f, 100f);
var oak = new GenomeTemplate([new GenomeTemplate.Entry(gene, 20f, 0f)]);
var grass = new GenomeTemplate([new GenomeTemplate.Entry(gene, 80f, 0f)]);
Assert.Equal(20f, oak.Generate(new Random(1)).Express(gene), 3);
Assert.Equal(80f, grass.Generate(new Random(1)).Express(gene), 3);
}
[Fact]
public void Registry_CoversEntries_AndDrivesBreeding()
{
var template = new GenomeTemplate([
new GenomeTemplate.Entry(Numeric("a", 0f, 10f), 5f, 0.1f),
new GenomeTemplate.Entry(Discrete("morph"), 0f, 0f),
]);
var random = new Random(9);
var a = template.Generate(random);
var b = template.Generate(random);
var child = Genome.Breed(a, b, template.Registry, random);
Assert.True(child.Has("a"));
Assert.True(child.Has("morph"));
}
[Fact]
public void Breed_MutationChanceOverride_ForcesMutation()
{
var gene = Numeric("a", min: -100f, max: 100f);
var registry = new Dictionary<string, GeneDef> { ["a"] = gene };
var parent = new Genome { ["a"] = new Allele(10f, 10f) };
// Override chance to 1 → every inherited allele mutates away from 10.
var moved = false;
for (var seed = 0; seed < 30 && !moved; seed++)
{
var child = Genome.Breed(
parent,
parent,
registry,
new Random(seed),
mutationChance: 1f
);
moved = child["a"].A != 10f || child["a"].B != 10f;
}
Assert.True(moved, "with overridden mutationChance=1 the allele should mutate");
}
[Fact]
public void Breed_MutationChanceZero_KeepsAlleles()
{
var gene = Numeric("a", -100f, 100f);
var registry = new Dictionary<string, GeneDef> { ["a"] = gene };
var parent = new Genome { ["a"] = new Allele(10f, 10f) };
var child = Genome.Breed(parent, parent, registry, new Random(5), mutationChance: 0f);
Assert.Equal(10f, child["a"].A);
Assert.Equal(10f, child["a"].B);
}
}
@@ -143,4 +143,73 @@ public sealed class DefDatabaseTests : IDisposable
Assert.Throws<KeyNotFoundException>(() => database.Get<AnimalDef>("Dodo"));
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");
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));
}
}