Compare commits
3
Commits
3438ed77f6
...
d044cafad9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d044cafad9 | ||
|
|
ead22517ee | ||
|
|
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,56 @@
|
||||
namespace MrGameEng.Genetics;
|
||||
|
||||
/// <summary>
|
||||
/// Shared, deterministic allele sampling used by both <see cref="Genome.Generate"/> (gene defaults)
|
||||
/// and <see cref="GenomeTemplate"/> (per-individual base overrides). Centralizes the numeric
|
||||
/// spread+clamp and the weighted discrete pick so the two paths stay consistent.
|
||||
/// </summary>
|
||||
internal static class GeneSampling
|
||||
{
|
||||
/// <summary>A numeric allele drawn as <c>baseValue ± spread·|baseValue|</c>, clamped to the gene's range.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A discrete variant index in <c>[0, Variants)</c>, picked from <paramref name="weights"/> when
|
||||
/// they match the variant count, otherwise the gene's own weights, otherwise uniformly.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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.
|
||||
/// <paramref name="mutationChance"/>, when given, overrides every gene's
|
||||
/// <see cref="GeneDef.MutationChance"/> — letting the caller drive mutation from an evolvable
|
||||
/// trait rather than a fixed per-gene constant.
|
||||
/// </summary>
|
||||
public static Genome Breed(
|
||||
Genome a,
|
||||
Genome b,
|
||||
IReadOnlyDictionary<string, GeneDef> registry,
|
||||
Random random,
|
||||
float? mutationChance = null
|
||||
)
|
||||
{
|
||||
var child = new Genome();
|
||||
foreach (var geneId in UnionKeys(a, b))
|
||||
{
|
||||
if (!registry.TryGetValue(geneId, out var gene))
|
||||
{
|
||||
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], 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], chance, random),
|
||||
Meiosis(gene, parent[geneId], chance, 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, float mutationChance, Random random)
|
||||
{
|
||||
var inherited = random.NextSingle() < 0.5f ? parent.A : parent.B;
|
||||
if (random.NextSingle() >= mutationChance)
|
||||
{
|
||||
return inherited;
|
||||
}
|
||||
|
||||
if (gene.Kind == GeneKind.Discrete)
|
||||
{
|
||||
return GeneSampling.Variant(gene, null, 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) =>
|
||||
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<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,61 @@
|
||||
namespace MrGameEng.Genetics;
|
||||
|
||||
/// <summary>
|
||||
/// A species' (or any organism kind's) gene allotment: which <see cref="GeneDef"/>s an individual
|
||||
/// carries and the per-organism base values its alleles are generated around. The same
|
||||
/// <see cref="GeneDef"/> (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. <see cref="Generate"/> draws a fresh individual; <see cref="Registry"/> feeds breeding
|
||||
/// and trait computation.
|
||||
/// </summary>
|
||||
public sealed class GenomeTemplate
|
||||
{
|
||||
/// <summary>
|
||||
/// One gene in the allotment. <paramref name="Base"/>/<paramref name="Spread"/> centre a numeric
|
||||
/// gene's alleles; <paramref name="VariantWeights"/> (optional) override a discrete gene's
|
||||
/// variant distribution for this organism.
|
||||
/// </summary>
|
||||
public readonly record struct Entry(
|
||||
GeneDef Gene,
|
||||
float Base,
|
||||
float Spread,
|
||||
float[]? VariantWeights = null
|
||||
);
|
||||
|
||||
private readonly List<Entry> _entries;
|
||||
private readonly Dictionary<string, GeneDef> _registry;
|
||||
|
||||
/// <summary>Builds a template from its gene entries.</summary>
|
||||
public GenomeTemplate(IEnumerable<Entry> entries)
|
||||
{
|
||||
_entries = entries.ToList();
|
||||
_registry = new Dictionary<string, GeneDef>(StringComparer.Ordinal);
|
||||
foreach (var entry in _entries)
|
||||
{
|
||||
_registry[entry.Gene.DefName] = entry.Gene;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The gene entries that make up the allotment.</summary>
|
||||
public IReadOnlyList<Entry> Entries => _entries;
|
||||
|
||||
/// <summary>Gene id → def for every carried gene; pass to <see cref="Genome.Breed"/> and <see cref="Phenotype.Compute"/>.</summary>
|
||||
public IReadOnlyDictionary<string, GeneDef> Registry => _registry;
|
||||
|
||||
/// <summary>Generates a fresh individual: two alleles per gene drawn around each entry's base/variant.</summary>
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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.");
|
||||
}
|
||||
|
||||
// Группировка генов в формулах: значения всех генов, чьи id подходят под шаблон (gsum/gavg/…).
|
||||
public IEnumerable<float> ResolveMatching(Func<string, bool> matches)
|
||||
{
|
||||
foreach (var geneId in genome.Alleles.Keys)
|
||||
{
|
||||
if (matches(geneId) && registry.TryGetValue(geneId, out var gene))
|
||||
{
|
||||
yield return genome.Express(gene);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MrGameEng.Mods;
|
||||
|
||||
@@ -24,6 +25,18 @@ public sealed class DefDatabase
|
||||
private readonly Dictionary<string, TypeEntry> _byKey = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<Type, TypeEntry> _byType = [];
|
||||
|
||||
/// <summary>Reserved def-file <c>"type"</c> that carries content patches rather than defs.</summary>
|
||||
public const string PatchTypeKey = "Patch";
|
||||
|
||||
private sealed record PatchRule(string DefType, Regex Match, JsonObject Set);
|
||||
|
||||
private sealed record ValidationRule(string Field, Regex Pattern, string Description);
|
||||
|
||||
private readonly List<PatchRule> _patches = [];
|
||||
private readonly Dictionary<string, List<ValidationRule>> _validators = new(
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
);
|
||||
|
||||
private static readonly JsonDocumentOptions DocumentOptions = new()
|
||||
{
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
@@ -43,6 +56,38 @@ public sealed class DefDatabase
|
||||
_byType.Add(typeof(T), entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a load-time validation: the string <paramref name="field"/> of every resolved def of
|
||||
/// type <paramref name="typeKey"/> must match <paramref name="pattern"/> (a regex), or
|
||||
/// <see cref="Load"/> throws. Non-string or absent fields are skipped. Use it to enforce naming
|
||||
/// conventions (e.g. gene ids start with <c>Gene</c>) or key/format rules across a mod's content.
|
||||
/// </summary>
|
||||
public void RegisterValidator(
|
||||
string typeKey,
|
||||
string field,
|
||||
string pattern,
|
||||
string? description = null
|
||||
)
|
||||
{
|
||||
Regex regex;
|
||||
try
|
||||
{
|
||||
regex = new Regex(pattern, RegexOptions.CultureInvariant);
|
||||
}
|
||||
catch (ArgumentException error)
|
||||
{
|
||||
throw new ArgumentException($"Invalid validator regex '{pattern}': {error.Message}");
|
||||
}
|
||||
|
||||
if (!_validators.TryGetValue(typeKey, out var list))
|
||||
{
|
||||
list = [];
|
||||
_validators[typeKey] = list;
|
||||
}
|
||||
|
||||
list.Add(new ValidationRule(field, regex, description ?? $"pattern /{pattern}/"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads every <c>Defs/**/*.json</c> of <paramref name="mods"/> (in load order) and
|
||||
/// resolves inheritance. Call once after registering all def types.
|
||||
@@ -66,6 +111,8 @@ public sealed class DefDatabase
|
||||
}
|
||||
}
|
||||
|
||||
ApplyPatches();
|
||||
|
||||
foreach (var entry in _byKey.Values)
|
||||
{
|
||||
Resolve(entry);
|
||||
@@ -133,6 +180,12 @@ public sealed class DefDatabase
|
||||
?? throw new InvalidDataException(
|
||||
$"Def file '{file}' (mod '{mod.Id}') has no \"type\" field."
|
||||
);
|
||||
if (string.Equals(typeKey, PatchTypeKey, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LoadPatches(mod, file, root);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_byKey.TryGetValue(typeKey, out var entry))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
@@ -169,8 +222,83 @@ public sealed class DefDatabase
|
||||
}
|
||||
}
|
||||
|
||||
private static void Resolve(TypeEntry entry)
|
||||
// Парсит файл-патч: операции { defType, match (регэксп по defName), set: {поля} }.
|
||||
private void LoadPatches(Mod mod, string file, JsonNode root)
|
||||
{
|
||||
if (root["patches"] is not JsonArray patches)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Patch file '{file}' (mod '{mod.Id}') has no \"patches\" array."
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var node in patches)
|
||||
{
|
||||
if (node is not JsonObject patch)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Patch file '{file}' (mod '{mod.Id}') contains a non-object patch."
|
||||
);
|
||||
}
|
||||
|
||||
var defType =
|
||||
patch["defType"]?.GetValue<string>()
|
||||
?? throw new InvalidDataException($"A patch in '{file}' has no \"defType\".");
|
||||
var match =
|
||||
patch["match"]?.GetValue<string>()
|
||||
?? throw new InvalidDataException($"A patch in '{file}' has no \"match\".");
|
||||
if (patch["set"] is not JsonObject set)
|
||||
{
|
||||
throw new InvalidDataException($"A patch in '{file}' has no \"set\" object.");
|
||||
}
|
||||
|
||||
Regex regex;
|
||||
try
|
||||
{
|
||||
regex = new Regex(match, RegexOptions.CultureInvariant);
|
||||
}
|
||||
catch (ArgumentException error)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Patch in '{file}' has invalid regex '{match}': {error.Message}"
|
||||
);
|
||||
}
|
||||
|
||||
_patches.Add(new PatchRule(defType, regex, (JsonObject)set.DeepClone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Применяет патчи к сырым дефам (до резолва), в порядке загрузки: каждому дефу нужного типа,
|
||||
// чьё имя подходит под регэксп, проставляются поля set. Поля наследуются детьми как обычно.
|
||||
private void ApplyPatches()
|
||||
{
|
||||
foreach (var patch in _patches)
|
||||
{
|
||||
if (!_byKey.TryGetValue(patch.DefType, out var entry))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"A patch targets unknown def type '{patch.DefType}'."
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var raw in entry.Raw)
|
||||
{
|
||||
if (!patch.Match.IsMatch(raw.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in patch.Set)
|
||||
{
|
||||
raw.Value[key] = value?.DeepClone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Resolve(TypeEntry entry)
|
||||
{
|
||||
_validators.TryGetValue(entry.Key, out var rules);
|
||||
foreach (var defName in entry.Raw.Keys.Order(StringComparer.Ordinal))
|
||||
{
|
||||
var merged = MergeChain(entry, defName, []);
|
||||
@@ -179,6 +307,11 @@ public sealed class DefDatabase
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rules is not null)
|
||||
{
|
||||
Validate(entry.Key, defName, merged, rules);
|
||||
}
|
||||
|
||||
var def =
|
||||
(Def?)merged.Deserialize(entry.ClrType, ModInfo.JsonOptions)
|
||||
?? throw new InvalidDataException(
|
||||
@@ -188,6 +321,28 @@ public sealed class DefDatabase
|
||||
}
|
||||
}
|
||||
|
||||
private static void Validate(
|
||||
string typeKey,
|
||||
string defName,
|
||||
JsonObject merged,
|
||||
List<ValidationRule> rules
|
||||
)
|
||||
{
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (
|
||||
merged[rule.Field] is JsonValue value
|
||||
&& value.TryGetValue<string>(out var text)
|
||||
&& !rule.Pattern.IsMatch(text)
|
||||
)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Def '{defName}' ({typeKey}) field '{rule.Field}'=\"{text}\" violates {rule.Description}."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject MergeChain(TypeEntry entry, string defName, HashSet<string> seen)
|
||||
{
|
||||
if (!seen.Add(defName))
|
||||
|
||||
@@ -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() },
|
||||
};
|
||||
|
||||
/// <summary>Unique mod id, referenced by <see cref="Dependencies"/> of other mods.</summary>
|
||||
|
||||
@@ -12,6 +12,13 @@ public interface IFormulaContext
|
||||
/// if the name is unknown — the engine does not invent a default.
|
||||
/// </summary>
|
||||
float Resolve(string name);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the values of every variable whose name satisfies <paramref name="matches"/> — the
|
||||
/// backing for the group functions (<c>gsum</c>, <c>gavg</c>, …) that aggregate over a name
|
||||
/// pattern, e.g. all <c>leaf_*</c> genes. Contexts with no enumerable variables return nothing.
|
||||
/// </summary>
|
||||
IEnumerable<float> ResolveMatching(Func<string, bool> matches) => [];
|
||||
}
|
||||
|
||||
/// <summary>An <see cref="IFormulaContext"/> backed by a lookup delegate — handy for tests and ad-hoc use.</summary>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Node = System.Func<MrGameEng.Formulas.IFormulaContext, float>;
|
||||
|
||||
namespace MrGameEng.Formulas;
|
||||
|
||||
/// <summary>
|
||||
/// The group functions — <c>gsum</c>, <c>gcount</c>, <c>gavg</c>, <c>gmin</c>, <c>gmax</c> — which
|
||||
/// aggregate over every context variable whose name matches a regex literal, e.g.
|
||||
/// <c>gsum('leaf_.*')</c> sums all <c>leaf_*</c> genes. The pattern is a string literal compiled to a
|
||||
/// <see cref="Regex"/> once at parse time; aggregation reads <see cref="IFormulaContext.ResolveMatching"/>.
|
||||
/// </summary>
|
||||
internal static class FormulaGroups
|
||||
{
|
||||
private static readonly HashSet<string> Names = new(StringComparer.Ordinal)
|
||||
{
|
||||
"gsum",
|
||||
"gcount",
|
||||
"gavg",
|
||||
"gmin",
|
||||
"gmax",
|
||||
};
|
||||
|
||||
public static bool IsGroupFunction(string name) => Names.Contains(name);
|
||||
|
||||
public static Node Build(string name, string pattern)
|
||||
{
|
||||
Regex regex;
|
||||
try
|
||||
{
|
||||
regex = new Regex(pattern, RegexOptions.CultureInvariant);
|
||||
}
|
||||
catch (ArgumentException error)
|
||||
{
|
||||
throw new FormulaException($"Invalid regex '{pattern}': {error.Message}");
|
||||
}
|
||||
|
||||
bool Match(string variable) => regex.IsMatch(variable);
|
||||
return name switch
|
||||
{
|
||||
"gsum" => ctx => Aggregate(ctx.ResolveMatching(Match), sum: true),
|
||||
"gavg" => ctx => Aggregate(ctx.ResolveMatching(Match), average: true),
|
||||
"gcount" => ctx => Count(ctx.ResolveMatching(Match)),
|
||||
"gmin" => ctx => Extreme(ctx.ResolveMatching(Match), max: false),
|
||||
"gmax" => ctx => Extreme(ctx.ResolveMatching(Match), max: true),
|
||||
_ => throw new FormulaException($"Unknown group function '{name}'."),
|
||||
};
|
||||
}
|
||||
|
||||
private static float Aggregate(
|
||||
IEnumerable<float> values,
|
||||
bool sum = false,
|
||||
bool average = false
|
||||
)
|
||||
{
|
||||
var total = 0f;
|
||||
var count = 0;
|
||||
foreach (var value in values)
|
||||
{
|
||||
total += value;
|
||||
count++;
|
||||
}
|
||||
|
||||
if (average)
|
||||
{
|
||||
return count == 0 ? 0f : total / count;
|
||||
}
|
||||
|
||||
return total; // sum (count==0 → 0)
|
||||
}
|
||||
|
||||
private static float Count(IEnumerable<float> values)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var _ in values)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static float Extreme(IEnumerable<float> values, bool max)
|
||||
{
|
||||
var has = false;
|
||||
var best = 0f;
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!has || (max ? value > best : value < best))
|
||||
{
|
||||
best = value;
|
||||
}
|
||||
|
||||
has = true;
|
||||
}
|
||||
|
||||
return best; // empty → 0
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ internal enum TokenType
|
||||
{
|
||||
Number,
|
||||
Identifier,
|
||||
String,
|
||||
Plus,
|
||||
Minus,
|
||||
Star,
|
||||
@@ -99,6 +100,27 @@ internal static class FormulaLexer
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '\'')
|
||||
{
|
||||
var open = i;
|
||||
var start = ++i;
|
||||
while (i < source.Length && source[i] != '\'')
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
if (i >= source.Length)
|
||||
{
|
||||
throw new FormulaException($"Unterminated string at position {open}.");
|
||||
}
|
||||
|
||||
tokens.Add(
|
||||
new Token(TokenType.String, open, text: source.Substring(start, i - start))
|
||||
);
|
||||
i++; // пропускаем закрывающую кавычку
|
||||
continue;
|
||||
}
|
||||
|
||||
var pos = i;
|
||||
switch (c)
|
||||
{
|
||||
|
||||
@@ -232,6 +232,20 @@ internal sealed class FormulaParser(List<Token> tokens)
|
||||
private Node ParseCall(string name)
|
||||
{
|
||||
Expect(TokenType.LParen);
|
||||
if (FormulaGroups.IsGroupFunction(name))
|
||||
{
|
||||
var pattern = Current;
|
||||
if (!Match(TokenType.String))
|
||||
{
|
||||
throw new FormulaException(
|
||||
$"Group function '{name}' expects a quoted regex pattern at position {pattern.Position}."
|
||||
);
|
||||
}
|
||||
|
||||
Expect(TokenType.RParen);
|
||||
return FormulaGroups.Build(name, pattern.Text);
|
||||
}
|
||||
|
||||
var args = new List<Node>();
|
||||
if (!Peek(TokenType.RParen))
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, GeneDef> { ["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<string, GeneDef> { ["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);
|
||||
}
|
||||
}
|
||||
@@ -143,4 +143,73 @@ public sealed class DefDatabaseTests : IDisposable
|
||||
Assert.Throws<KeyNotFoundException>(() => database.Get<AnimalDef>("Dodo"));
|
||||
Assert.False(database.TryGet<AnimalDef>("Dodo", out _));
|
||||
}
|
||||
|
||||
private Mod WriteMod(string fileName, string json)
|
||||
{
|
||||
var id = $"mod{_modCounter++:D2}";
|
||||
var modDir = Path.Combine(_root, id);
|
||||
Directory.CreateDirectory(Path.Combine(modDir, "Defs"));
|
||||
File.WriteAllText(Path.Combine(modDir, "Defs", fileName), json);
|
||||
return new Mod(new ModInfo { Id = id }, modDir);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Patch_SetsFields_OnDefsMatchingNamePattern()
|
||||
{
|
||||
var defs = WriteDefsMod(
|
||||
"""
|
||||
{ "type": "Animal", "defs": [
|
||||
{ "defName": "Wolf", "speed": 9 },
|
||||
{ "defName": "WolfPup", "speed": 4 },
|
||||
{ "defName": "Bear", "speed": 6 }
|
||||
]}
|
||||
"""
|
||||
);
|
||||
var patch = WriteMod(
|
||||
"patches.json",
|
||||
"""{ "type": "Patch", "patches": [ { "defType": "Animal", "match": "Wolf.*", "set": { "legs": 6 } } ] }"""
|
||||
);
|
||||
|
||||
var database = LoadAnimals(defs, patch);
|
||||
|
||||
Assert.Equal(6, database.Get<AnimalDef>("Wolf").Legs); // matched
|
||||
Assert.Equal(6, database.Get<AnimalDef>("WolfPup").Legs); // matched
|
||||
Assert.Equal(4, database.Get<AnimalDef>("Bear").Legs); // unmatched → default
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Patch_UnknownDefType_Throws()
|
||||
{
|
||||
var patch = WriteMod(
|
||||
"patches.json",
|
||||
"""{ "type": "Patch", "patches": [ { "defType": "Ghost", "match": ".*", "set": {} } ] }"""
|
||||
);
|
||||
|
||||
Assert.Throws<InvalidDataException>(() => LoadAnimals(patch));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_RejectsField_NotMatchingPattern()
|
||||
{
|
||||
var database = new DefDatabase();
|
||||
database.RegisterType<AnimalDef>("Animal");
|
||||
database.RegisterValidator("Animal", "defName", "^[A-Z]");
|
||||
var bad = WriteDefsMod("""{ "type": "Animal", "defs": [ { "defName": "wolf" } ] }""");
|
||||
|
||||
var error = Assert.Throws<InvalidDataException>(() => database.Load([bad]));
|
||||
Assert.Contains("wolf", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_Passes_WhenFieldMatches()
|
||||
{
|
||||
var database = new DefDatabase();
|
||||
database.RegisterType<AnimalDef>("Animal");
|
||||
database.RegisterValidator("Animal", "defName", "^[A-Z]");
|
||||
database.Load([
|
||||
WriteDefsMod("""{ "type": "Animal", "defs": [ { "defName": "Wolf", "speed": 7 } ] }"""),
|
||||
]);
|
||||
|
||||
Assert.Equal(7f, database.Get<AnimalDef>("Wolf").Speed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,4 +109,66 @@ public class FormulaTests
|
||||
var formula = Formula.Compile("x + 1");
|
||||
Assert.Throws<FormulaException>(() => formula.Evaluate());
|
||||
}
|
||||
|
||||
// --- Group functions (regex aggregation over matching variables) ---
|
||||
|
||||
private sealed class GroupContext(Dictionary<string, float> values) : IFormulaContext
|
||||
{
|
||||
public float Resolve(string name) => values[name];
|
||||
|
||||
public IEnumerable<float> ResolveMatching(Func<string, bool> matches)
|
||||
{
|
||||
foreach (var (name, value) in values)
|
||||
{
|
||||
if (matches(name))
|
||||
{
|
||||
yield return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_GroupFunctions_AggregateMatchingVariables()
|
||||
{
|
||||
var ctx = new GroupContext(
|
||||
new()
|
||||
{
|
||||
["leaf_a"] = 2f,
|
||||
["leaf_b"] = 4f,
|
||||
["leaf_c"] = 6f,
|
||||
["root_a"] = 100f,
|
||||
}
|
||||
);
|
||||
|
||||
Assert.Equal(12f, Formula.Compile("gsum('leaf_.*')").Evaluate(ctx), 4); // 2+4+6
|
||||
Assert.Equal(3f, Formula.Compile("gcount('leaf_.*')").Evaluate(ctx), 4);
|
||||
Assert.Equal(4f, Formula.Compile("gavg('leaf_.*')").Evaluate(ctx), 4);
|
||||
Assert.Equal(2f, Formula.Compile("gmin('leaf_.*')").Evaluate(ctx), 4);
|
||||
Assert.Equal(6f, Formula.Compile("gmax('leaf_.*')").Evaluate(ctx), 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_GroupFunction_ComposesWithArithmetic()
|
||||
{
|
||||
var ctx = new GroupContext(new() { ["g1"] = 3f, ["g2"] = 5f });
|
||||
Assert.Equal(16f, Formula.Compile("gsum('g.*') * 2").Evaluate(ctx), 4); // (3+5)*2
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_GroupFunction_NoMatches_IsZero()
|
||||
{
|
||||
var ctx = new GroupContext(new() { ["x"] = 1f });
|
||||
Assert.Equal(0f, Formula.Compile("gsum('none_.*')").Evaluate(ctx), 4);
|
||||
Assert.Equal(0f, Formula.Compile("gavg('none_.*')").Evaluate(ctx), 4);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("gsum(leaf)")] // pattern must be a quoted string
|
||||
[InlineData("gsum('[')")] // invalid regex
|
||||
[InlineData("gsum('a' 'b')")] // extra token
|
||||
public void Compile_BadGroupCall_Throws(string expr)
|
||||
{
|
||||
Assert.Throws<FormulaException>(() => Formula.Compile(expr));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user