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>
This commit is contained in:
Leonid Pershin
2026-06-12 23:43:01 +03:00
co-authored by Claude Opus 4.8
parent 3438ed77f6
commit bc18db5df6
7 changed files with 642 additions and 0 deletions
@@ -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);
}
}