Add gene foundation: GeneDef, Genome, trait phenotype (Content)
CI / build-test (push) Successful in 1m17s
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:
co-authored by
Claude Opus 4.8
parent
3438ed77f6
commit
bc18db5df6
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace MrGameEng.Mods;
|
namespace MrGameEng.Mods;
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ public sealed class ModInfo
|
|||||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
AllowTrailingCommas = true,
|
AllowTrailingCommas = true,
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Unique mod id, referenced by <see cref="Dependencies"/> of other mods.</summary>
|
/// <summary>Unique mod id, referenced by <see cref="Dependencies"/> of other mods.</summary>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user