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);
+ }
+}