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
+190
View File
@@ -0,0 +1,190 @@
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.
/// </summary>
public static Genome Breed(
Genome a,
Genome b,
IReadOnlyDictionary<string, GeneDef> registry,
Random random
)
{
var child = new Genome();
foreach (var geneId in UnionKeys(a, b))
{
if (!registry.TryGetValue(geneId, out var gene))
{
continue;
}
var inA = a.Has(geneId);
var inB = b.Has(geneId);
if (inA && inB)
{
child._alleles[geneId] = new Allele(
Meiosis(gene, a[geneId], random),
Meiosis(gene, b[geneId], random)
);
}
else if (random.NextSingle() < 0.5f)
{
var parent = inA ? a : b;
child._alleles[geneId] = new Allele(
Meiosis(gene, parent[geneId], random),
Meiosis(gene, parent[geneId], 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, Random random)
{
var inherited = random.NextSingle() < 0.5f ? parent.A : parent.B;
if (random.NextSingle() >= gene.MutationChance)
{
return inherited;
}
if (gene.Kind == GeneKind.Discrete)
{
return PickVariant(gene, 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)
{
if (gene.Kind == GeneKind.Discrete)
{
return PickVariant(gene, random);
}
var value =
gene.Default + (random.NextSingle() * 2f - 1f) * gene.Spread * MathF.Abs(gene.Default);
return Math.Clamp(value, gene.Min, gene.Max);
}
private static float PickVariant(GeneDef gene, Random random)
{
var variants = Math.Max(1, gene.Variants);
if (gene.VariantWeights.Length != variants)
{
return random.Next(variants);
}
var total = 0f;
foreach (var w in gene.VariantWeights)
{
total += MathF.Max(0f, w);
}
if (total <= 0f)
{
return random.Next(variants);
}
var roll = random.NextSingle() * total;
for (var i = 0; i < variants; i++)
{
roll -= MathF.Max(0f, gene.VariantWeights[i]);
if (roll < 0f)
{
return i;
}
}
return variants - 1;
}
// 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;
}
}