From ead22517ee7a3da4d5d80c29f4061fc36f69089f Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 12 Jun 2026 23:51:02 +0300 Subject: [PATCH] Genetics: GenomeTemplate (per-organism gene allotment) + breed override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Genetics/GeneSampling.cs | 56 +++++++++ src/MrGameEng.Content/Genetics/Genome.cs | 68 +++-------- .../Genetics/GenomeTemplate.cs | 61 ++++++++++ .../Genetics/GenomeTemplateTests.cs | 106 ++++++++++++++++++ 4 files changed, 240 insertions(+), 51 deletions(-) create mode 100644 src/MrGameEng.Content/Genetics/GeneSampling.cs create mode 100644 src/MrGameEng.Content/Genetics/GenomeTemplate.cs create mode 100644 tests/MrGameEng.Content.Tests/Genetics/GenomeTemplateTests.cs diff --git a/src/MrGameEng.Content/Genetics/GeneSampling.cs b/src/MrGameEng.Content/Genetics/GeneSampling.cs new file mode 100644 index 0000000..aa86850 --- /dev/null +++ b/src/MrGameEng.Content/Genetics/GeneSampling.cs @@ -0,0 +1,56 @@ +namespace MrGameEng.Genetics; + +/// +/// Shared, deterministic allele sampling used by both (gene defaults) +/// and (per-individual base overrides). Centralizes the numeric +/// spread+clamp and the weighted discrete pick so the two paths stay consistent. +/// +internal static class GeneSampling +{ + /// A numeric allele drawn as baseValue ± spread·|baseValue|, clamped to the gene's range. + 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); + } + + /// + /// A discrete variant index in [0, Variants), picked from when + /// they match the variant count, otherwise the gene's own weights, otherwise uniformly. + /// + 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; + } +} diff --git a/src/MrGameEng.Content/Genetics/Genome.cs b/src/MrGameEng.Content/Genetics/Genome.cs index 64ebbb9..c6f7b73 100644 --- a/src/MrGameEng.Content/Genetics/Genome.cs +++ b/src/MrGameEng.Content/Genetics/Genome.cs @@ -77,12 +77,16 @@ public sealed class Genome /// carries, it is inherited (from that parent, on both sides) with 50% probability. Every /// inherited allele may then mutate per its . /// supplies the def for each gene id; genes absent from it are skipped. + /// , when given, overrides every gene's + /// — letting the caller drive mutation from an evolvable + /// trait rather than a fixed per-gene constant. /// public static Genome Breed( Genome a, Genome b, IReadOnlyDictionary registry, - Random random + Random random, + float? mutationChance = null ) { var child = new Genome(); @@ -93,21 +97,22 @@ public sealed class Genome 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], random), - Meiosis(gene, b[geneId], random) + 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], random), - Meiosis(gene, parent[geneId], random) + Meiosis(gene, parent[geneId], chance, random), + Meiosis(gene, parent[geneId], chance, random) ); } } @@ -116,17 +121,17 @@ public sealed class Genome } // 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) + 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() >= gene.MutationChance) + if (random.NextSingle() >= mutationChance) { return inherited; } if (gene.Kind == GeneKind.Discrete) { - return PickVariant(gene, random); + return GeneSampling.Variant(gene, null, random); } var shifted = @@ -135,49 +140,10 @@ public sealed class Genome 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; - } + 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 UnionKeys(Genome a, Genome b) diff --git a/src/MrGameEng.Content/Genetics/GenomeTemplate.cs b/src/MrGameEng.Content/Genetics/GenomeTemplate.cs new file mode 100644 index 0000000..bc2a052 --- /dev/null +++ b/src/MrGameEng.Content/Genetics/GenomeTemplate.cs @@ -0,0 +1,61 @@ +namespace MrGameEng.Genetics; + +/// +/// A species' (or any organism kind's) gene allotment: which s an individual +/// carries and the per-organism base values its alleles are generated around. The same +/// (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. draws a fresh individual; feeds breeding +/// and trait computation. +/// +public sealed class GenomeTemplate +{ + /// + /// One gene in the allotment. / centre a numeric + /// gene's alleles; (optional) override a discrete gene's + /// variant distribution for this organism. + /// + public readonly record struct Entry( + GeneDef Gene, + float Base, + float Spread, + float[]? VariantWeights = null + ); + + private readonly List _entries; + private readonly Dictionary _registry; + + /// Builds a template from its gene entries. + public GenomeTemplate(IEnumerable entries) + { + _entries = entries.ToList(); + _registry = new Dictionary(StringComparer.Ordinal); + foreach (var entry in _entries) + { + _registry[entry.Gene.DefName] = entry.Gene; + } + } + + /// The gene entries that make up the allotment. + public IReadOnlyList Entries => _entries; + + /// Gene id → def for every carried gene; pass to and . + public IReadOnlyDictionary Registry => _registry; + + /// Generates a fresh individual: two alleles per gene drawn around each entry's base/variant. + 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); +} diff --git a/tests/MrGameEng.Content.Tests/Genetics/GenomeTemplateTests.cs b/tests/MrGameEng.Content.Tests/Genetics/GenomeTemplateTests.cs new file mode 100644 index 0000000..f6c4f0a --- /dev/null +++ b/tests/MrGameEng.Content.Tests/Genetics/GenomeTemplateTests.cs @@ -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 { ["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 { ["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); + } +}