diff --git a/src/MrGameEng.Content/Genetics/Allele.cs b/src/MrGameEng.Content/Genetics/Allele.cs new file mode 100644 index 0000000..a05de50 --- /dev/null +++ b/src/MrGameEng.Content/Genetics/Allele.cs @@ -0,0 +1,16 @@ +namespace MrGameEng.Genetics; + +/// +/// A diploid gene slot: the two allele values an individual carries for one gene. Stored as floats +/// for both gene kinds — a gene simply holds integral variant +/// indices. How the pair becomes a single phenotype value is decided by the gene's +/// (see ). +/// +public readonly record struct Allele(float A, float B) +{ + /// The average of the two alleles — the phenotype of a numeric gene. + public float Mean => (A + B) * 0.5f; + + /// The lower (dominant) of the two alleles — the phenotype of a discrete gene. + public float Dominant => MathF.Min(A, B); +} diff --git a/src/MrGameEng.Content/Genetics/GeneDef.cs b/src/MrGameEng.Content/Genetics/GeneDef.cs new file mode 100644 index 0000000..044fae1 --- /dev/null +++ b/src/MrGameEng.Content/Genetics/GeneDef.cs @@ -0,0 +1,97 @@ +using System.Text.Json.Serialization; +using MrGameEng.Formulas; +using MrGameEng.Mods; + +namespace MrGameEng.Genetics; + +/// How a gene's two alleles are stored and expressed into a phenotype value. +public enum GeneKind +{ + /// A continuous value; the phenotype is the average of the two alleles (hybrid blending). + Numeric, + + /// + /// A discrete allele index in [0, Variants); the lower index is dominant, so the + /// phenotype is min(a, b) — a higher (recessive) variant shows only when homozygous. + /// A two-variant discrete gene is effectively a flag. + /// + Discrete, +} + +/// +/// An organism-agnostic gene definition — the unit the whole gene system is built from. A +/// describes how to generate an individual's two alleles, how they mutate +/// when bred, and how the gene contribute to named phenotype traits via +/// 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). +/// +public sealed class GeneDef : Def +{ + /// Whether the gene is continuous or a discrete dominant/recessive allele. + public GeneKind Kind { get; init; } = GeneKind.Numeric; + + /// Numeric: the central value an allele is generated around. + public float Default { get; init; } + + /// Numeric: lower clamp for generated and mutated allele values. + public float Min { get; init; } = float.NegativeInfinity; + + /// Numeric: upper clamp for generated and mutated allele values. + public float Max { get; init; } = float.PositiveInfinity; + + /// Numeric: relative spread of generated alleles around (allele = Default ± Spread·|Default|). + public float Spread { get; init; } + + /// Numeric: relative magnitude of a mutation step (value ± Magnitude·|value|). + public float MutationMagnitude { get; init; } = 0.1f; + + /// Discrete: number of allele variants, valued 0..Variants-1. + public int Variants { get; init; } = 2; + + /// + /// Discrete: relative weights for generating each variant (length ). + /// Empty means a uniform distribution. + /// + public float[] VariantWeights { get; init; } = []; + + /// Probability, per allele, that a mutation occurs when this gene is passed to a child. + public float MutationChance { get; init; } + + /// + /// The gene's contributions to phenotype traits: trait name → formula. Each formula may use the + /// variable value (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. + /// + public Dictionary Effects { get; init; } = new(); + + /// Free-form category tags for grouping genes (used by formula grouping and content tooling). + public string[] Tags { get; init; } = []; + + private IReadOnlyDictionary? _compiled; + + /// The compiled once into evaluable formulas (lazy, cached). + [JsonIgnore] + public IReadOnlyDictionary CompiledEffects => _compiled ??= CompileEffects(); + + private Dictionary CompileEffects() + { + var compiled = new Dictionary(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; + } +} diff --git a/src/MrGameEng.Content/Genetics/Genome.cs b/src/MrGameEng.Content/Genetics/Genome.cs new file mode 100644 index 0000000..64ebbb9 --- /dev/null +++ b/src/MrGameEng.Content/Genetics/Genome.cs @@ -0,0 +1,190 @@ +namespace MrGameEng.Genetics; + +/// +/// An individual's managed genome: a variable-composition map from gene id to the +/// 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 s, bred meiotically with mutation, and +/// expressed into phenotype values; all randomness flows through a caller-owned seeded +/// so the simulation stays deterministic. +/// +public sealed class Genome +{ + private readonly Dictionary _alleles; + + /// Creates an empty genome. + public Genome() => _alleles = new Dictionary(StringComparer.Ordinal); + + /// Creates a genome from an existing allele map (copied). + public Genome(IReadOnlyDictionary alleles) => + _alleles = new Dictionary(alleles, StringComparer.Ordinal); + + /// The carried genes and their allele pairs. + public IReadOnlyDictionary Alleles => _alleles; + + /// Whether the genome carries the gene . + public bool Has(string geneId) => _alleles.ContainsKey(geneId); + + /// Gets or sets the allele pair for . + public Allele this[string geneId] + { + get => _alleles[geneId]; + set => _alleles[geneId] = value; + } + + /// Removes a gene from the genome; returns whether it was present. + public bool Remove(string geneId) => _alleles.Remove(geneId); + + /// + /// 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. + /// + 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; + } + + /// Builds the allele map for serialization (a copy). + public Dictionary ToDictionary() => new(_alleles, StringComparer.Ordinal); + + /// + /// Generates a fresh genome carrying every gene in , each allele drawn + /// independently around the gene's default with its spread (numeric) or from its variant + /// distribution (discrete). + /// + public static Genome Generate(IEnumerable 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; + } + + /// + /// 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 . + /// supplies the def for each gene id; genes absent from it are skipped. + /// + public static Genome Breed( + Genome a, + Genome b, + IReadOnlyDictionary 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 UnionKeys(Genome a, Genome b) + { + var keys = new SortedSet(StringComparer.Ordinal); + keys.UnionWith(a._alleles.Keys); + keys.UnionWith(b._alleles.Keys); + return keys; + } +} diff --git a/src/MrGameEng.Content/Genetics/Phenotype.cs b/src/MrGameEng.Content/Genetics/Phenotype.cs new file mode 100644 index 0000000..ffe1dec --- /dev/null +++ b/src/MrGameEng.Content/Genetics/Phenotype.cs @@ -0,0 +1,75 @@ +using MrGameEng.Formulas; + +namespace MrGameEng.Genetics; + +/// +/// Computes the trait layer — the phenotype the simulation actually reads — from a +/// . Each gene's formulas are evaluated and their +/// results summed per trait name, so systems never touch genes directly: one fruiting system reads +/// a fruitYield trait whether it comes from a tree or a human carrying a "fruit" gene. +/// Formulas see the variable value (the contributing gene's expressed phenotype), any other +/// carried gene's id, and whatever environment variables the caller supplies. +/// +public static class Phenotype +{ + /// + /// Evaluates every carried gene's effects against the genome and an optional + /// , summing contributions into a trait map. Genes missing from + /// are skipped. + /// + public static Dictionary Compute( + Genome genome, + IReadOnlyDictionary registry, + IFormulaContext? environment = null + ) + { + var traits = new Dictionary(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 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."); + } + } +} diff --git a/src/MrGameEng.Content/Mods/ModInfo.cs b/src/MrGameEng.Content/Mods/ModInfo.cs index 948116f..63f2dd6 100644 --- a/src/MrGameEng.Content/Mods/ModInfo.cs +++ b/src/MrGameEng.Content/Mods/ModInfo.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Serialization; namespace MrGameEng.Mods; @@ -14,6 +15,7 @@ public sealed class ModInfo ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, WriteIndented = true, + Converters = { new JsonStringEnumConverter() }, }; /// Unique mod id, referenced by of other mods. diff --git a/tests/MrGameEng.Content.Tests/Genetics/GeneDefLoadTests.cs b/tests/MrGameEng.Content.Tests/Genetics/GeneDefLoadTests.cs new file mode 100644 index 0000000..1e2e61d --- /dev/null +++ b/tests/MrGameEng.Content.Tests/Genetics/GeneDefLoadTests.cs @@ -0,0 +1,68 @@ +using MrGameEng.Genetics; +using MrGameEng.Mods; +using Xunit; + +namespace MrGameEng.Genetics.Tests; + +/// +/// Verifies loads through the real the way the game +/// loads genes.json: string-named , parent inheritance and effect maps. +/// +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("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("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("GeneMorph"); + Assert.Equal(GeneKind.Discrete, gene.Kind); + Assert.Equal(2, gene.Variants); + Assert.Equal([0.8f, 0.2f], gene.VariantWeights); + } +} diff --git a/tests/MrGameEng.Content.Tests/Genetics/GeneticsTests.cs b/tests/MrGameEng.Content.Tests/Genetics/GeneticsTests.cs new file mode 100644 index 0000000..d59a619 --- /dev/null +++ b/tests/MrGameEng.Content.Tests/Genetics/GeneticsTests.cs @@ -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? 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 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(() => _ = gene.CompiledEffects); + Assert.Contains("bad", error.Message); + Assert.Contains("t", error.Message); + } +}