Compare commits
9
Commits
3438ed77f6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46f3931f85 | ||
|
|
96e19c7c61 | ||
|
|
38bdfbbb81 | ||
|
|
08381703f7 | ||
|
|
f382fc98ea | ||
|
|
d360093be1 | ||
|
|
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>
|
||||
|
||||
@@ -10,16 +10,21 @@ namespace MrGameEng.Core;
|
||||
public sealed class Calendar
|
||||
{
|
||||
private readonly GameClock _clock;
|
||||
private readonly double _startDay;
|
||||
private float _secondsPerDay;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a calendar reading <paramref name="clock"/>; one day spans
|
||||
/// <paramref name="secondsPerDay"/> seconds of scaled time (must be positive).
|
||||
/// <paramref name="startDay"/> offsets the calendar by (fractional) days at clock 0 — e.g.
|
||||
/// <c>7.0 / 24</c> starts the world at 07:00 instead of midnight. It shifts the time of day and
|
||||
/// the day/night phase that reads <see cref="DayProgress"/>, without touching the clock itself.
|
||||
/// </summary>
|
||||
public Calendar(GameClock clock, float secondsPerDay)
|
||||
public Calendar(GameClock clock, float secondsPerDay, double startDay = 0.0)
|
||||
{
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
SecondsPerDay = secondsPerDay;
|
||||
_startDay = startDay;
|
||||
}
|
||||
|
||||
/// <summary>Scaled seconds per in-game day. Must be positive; raising it slows the calendar.</summary>
|
||||
@@ -36,8 +41,8 @@ public sealed class Calendar
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Total elapsed days as a continuous value (e.g. 3.5 = midday of day 4).</summary>
|
||||
public double TotalDays => _clock.TotalTime / _secondsPerDay;
|
||||
/// <summary>Total elapsed days as a continuous value (e.g. 3.5 = midday of day 4), incl. the start offset.</summary>
|
||||
public double TotalDays => _clock.TotalTime / _secondsPerDay + _startDay;
|
||||
|
||||
/// <summary>The current day number, counting from 1.</summary>
|
||||
public int Day => (int)TotalDays + 1;
|
||||
@@ -68,11 +73,16 @@ public static class CalendarEngineExtensions
|
||||
/// <summary>
|
||||
/// Creates a <see cref="Calendar"/> bound to the context's clock and registers it as a
|
||||
/// service. Call once per world. <paramref name="secondsPerDay"/> is the scaled-time length
|
||||
/// of one in-game day (must be positive).
|
||||
/// of one in-game day (must be positive); <paramref name="startDay"/> offsets the starting
|
||||
/// time of day in fractional days (e.g. <c>7.0 / 24</c> begins the world at 07:00).
|
||||
/// </summary>
|
||||
public static Calendar UseCalendar(this EngineContext context, float secondsPerDay)
|
||||
public static Calendar UseCalendar(
|
||||
this EngineContext context,
|
||||
float secondsPerDay,
|
||||
double startDay = 0.0
|
||||
)
|
||||
{
|
||||
var calendar = new Calendar(context.Clock, secondsPerDay);
|
||||
var calendar = new Calendar(context.Clock, secondsPerDay, startDay);
|
||||
context.Services.Add(calendar);
|
||||
return calendar;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,13 @@ public readonly record struct ClimateSettings
|
||||
/// <summary>Day of the year (0-based) with the highest temperature; defaults to mid-summer.</summary>
|
||||
public int WarmestDay { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Day of the year (0-based) that the calendar's day 0 maps to. Shifts the whole seasonal phase
|
||||
/// (season and temperature together) so a world can begin in a chosen part of the year — e.g. a
|
||||
/// warm late spring instead of the cold turn of the year. Defaults to 0 (year begins at day 0).
|
||||
/// </summary>
|
||||
public int StartDayOfYear { get; init; }
|
||||
|
||||
/// <summary>Temperate defaults: a 60-day year, mean 12°, ±14° seasonal, ±5° daily, warmest mid-summer.</summary>
|
||||
public static ClimateSettings Default =>
|
||||
new()
|
||||
@@ -78,18 +85,21 @@ public sealed class Climate
|
||||
/// <summary>In-game days per year.</summary>
|
||||
public int DaysPerYear => _settings.DaysPerYear;
|
||||
|
||||
/// <summary>Elapsed days shifted by <see cref="ClimateSettings.StartDayOfYear"/> — the seasonal clock.</summary>
|
||||
private double YearDays => _calendar.TotalDays + _settings.StartDayOfYear;
|
||||
|
||||
/// <summary>Continuous position within the current year in <c>[0, 1)</c>.</summary>
|
||||
public double YearProgress
|
||||
{
|
||||
get
|
||||
{
|
||||
var years = _calendar.TotalDays / _settings.DaysPerYear;
|
||||
var years = YearDays / _settings.DaysPerYear;
|
||||
return years - Math.Floor(years);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The current year, counting from 1.</summary>
|
||||
public int Year => (int)(_calendar.TotalDays / _settings.DaysPerYear) + 1;
|
||||
public int Year => (int)(YearDays / _settings.DaysPerYear) + 1;
|
||||
|
||||
/// <summary>Day within the current year, 0-based.</summary>
|
||||
public int DayOfYear => (int)(YearProgress * _settings.DaysPerYear);
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -33,6 +33,15 @@ public readonly struct CameraState
|
||||
/// <summary>Physical-screen to virtual-pixel mapping.</summary>
|
||||
public required ViewportMapping Mapping { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// World point at the centre of the virtual screen — the camera's <em>effective</em> position
|
||||
/// after bounds-clamping, i.e. what the view is actually built around. Prefer this over the raw
|
||||
/// <see cref="Camera.Position"/> when anchoring zoom-to-cursor, so the reposition matches what is
|
||||
/// rendered even while the camera is clamped against <see cref="Camera.Bounds"/>.
|
||||
/// </summary>
|
||||
public Vector2 WorldCenter =>
|
||||
Vector2.Transform(new Vector2(VirtualWidth / 2f, VirtualHeight / 2f), InverseView);
|
||||
|
||||
/// <summary>Converts a physical screen point to world coordinates.</summary>
|
||||
public Vector2 ScreenToWorld(Vector2 screen)
|
||||
{
|
||||
|
||||
@@ -52,6 +52,26 @@ public sealed class DayNight
|
||||
/// <summary>Light intensity at a world point in <c>[0, 1]</c>. Global today; local (with shadows) later.</summary>
|
||||
public float SampleAt(Vector2 world) => Daylight;
|
||||
|
||||
/// <summary>
|
||||
/// Offset, in grid cells, of the shadow an occluder casts under the current sun: opposite the
|
||||
/// sun's east–west position and longest near sunrise/sunset (low sun), shrinking to zero at noon
|
||||
/// and at night. Feeds the lightmap's directional shadow pass; <paramref name="maxLength"/> caps
|
||||
/// the dawn/dusk shadow length. Tilted slightly "south" (down) so shadows fall in front of objects.
|
||||
/// </summary>
|
||||
public Vector2 SunShadow(float maxLength)
|
||||
{
|
||||
var day = (_calendar.DayProgress - 0.25f) / 0.5f; // daytime fraction over [06:00, 18:00]
|
||||
if (day <= 0f || day >= 1f)
|
||||
{
|
||||
return Vector2.Zero; // night — no sun; the ambient floor handles darkness
|
||||
}
|
||||
|
||||
var altitude = MathF.Sin(day * MathF.PI); // 0 at dawn/dusk, 1 at noon
|
||||
var direction = new Vector2(2f * day - 1f, 0.4f); // sun east→west ⇒ shadow west→east, tilted south
|
||||
direction.Normalize();
|
||||
return direction * (maxLength * (1f - altitude));
|
||||
}
|
||||
|
||||
/// <summary>Ambient tint for the scene: night color at night, day color at noon, eased between.</summary>
|
||||
public Color Ambient
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
|
||||
namespace MrGameEng.Lighting;
|
||||
|
||||
/// <summary>One point light projected onto the light grid: a cell position, a radius in cells and an intensity.</summary>
|
||||
@@ -16,8 +18,10 @@ public static class LightmapBuilder
|
||||
|
||||
/// <summary>
|
||||
/// Fills <paramref name="light"/> (length <paramref name="width"/>×<paramref name="height"/>) with
|
||||
/// ambient light, shading occluder cells, then adds each point light with grid-traced occlusion.
|
||||
/// Values end clamped to <c>[0, 1]</c>.
|
||||
/// ambient light, shading occluder cells, casts each occluder's directional sun shadow along
|
||||
/// <paramref name="sunShadow"/> (cells), then adds each point light with grid-traced occlusion.
|
||||
/// Values end clamped to <c>[0, 1]</c>. A zero <paramref name="sunShadow"/> or
|
||||
/// <paramref name="sunShadowStrength"/> skips the directional pass (e.g. at night/noon).
|
||||
/// </summary>
|
||||
public static void Build(
|
||||
float[] light,
|
||||
@@ -25,7 +29,9 @@ public static class LightmapBuilder
|
||||
int height,
|
||||
float ambient,
|
||||
ReadOnlySpan<bool> occluders,
|
||||
IReadOnlyList<LightSample> lights
|
||||
IReadOnlyList<LightSample> lights,
|
||||
Vector2 sunShadow = default,
|
||||
float sunShadowStrength = 0f
|
||||
)
|
||||
{
|
||||
for (var i = 0; i < light.Length; i++)
|
||||
@@ -33,6 +39,8 @@ public static class LightmapBuilder
|
||||
light[i] = occluders[i] ? ambient * OccluderShade : ambient;
|
||||
}
|
||||
|
||||
CastSunShadows(light, width, height, occluders, sunShadow, sunShadowStrength);
|
||||
|
||||
foreach (var l in lights)
|
||||
{
|
||||
if (l.Radius <= 0f || l.Intensity <= 0f)
|
||||
@@ -73,6 +81,62 @@ public static class LightmapBuilder
|
||||
}
|
||||
}
|
||||
|
||||
// Направленная тень от солнца: каждый окклюдер (гора/зрелая крона) отбрасывает тень вдоль
|
||||
// вектора sunShadow (в клетках). Затемнение гуще у основания и тает к концу тени; сами клетки-
|
||||
// окклюдеры не трогаем (они уже затенены). Окклюдеры разрежены, так что проход дёшев.
|
||||
private static void CastSunShadows(
|
||||
float[] light,
|
||||
int width,
|
||||
int height,
|
||||
ReadOnlySpan<bool> occluders,
|
||||
Vector2 sunShadow,
|
||||
float strength
|
||||
)
|
||||
{
|
||||
if (strength <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var steps = (int)MathF.Ceiling(sunShadow.Length());
|
||||
if (steps <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var stepX = sunShadow.X / steps;
|
||||
var stepY = sunShadow.Y / steps;
|
||||
for (var oy = 0; oy < height; oy++)
|
||||
{
|
||||
for (var ox = 0; ox < width; ox++)
|
||||
{
|
||||
if (!occluders[oy * width + ox])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var s = 1; s <= steps; s++)
|
||||
{
|
||||
var cx = ox + (int)MathF.Round(stepX * s);
|
||||
var cy = oy + (int)MathF.Round(stepY * s);
|
||||
if (cx < 0 || cx >= width || cy < 0 || cy >= height)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var index = cy * width + cx;
|
||||
if (occluders[index])
|
||||
{
|
||||
continue; // тень проходит над другими окклюдерами — они и так тёмные
|
||||
}
|
||||
|
||||
var falloff = 1f - (float)(s - 1) / steps; // гуще у основания тени
|
||||
light[index] *= 1f - strength * falloff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Есть ли прямая видимость между клетками: проводим линию (Брезенхем) и проверяем
|
||||
// промежуточные клетки на окклюдер (концы исключены).
|
||||
private static bool Visible(
|
||||
|
||||
@@ -14,7 +14,9 @@ namespace MrGameEng.Lighting;
|
||||
public sealed class LightmapSystem : BaseSystem
|
||||
{
|
||||
private const int RebuildEvery = 6; // ~10 Гц при 60 fps
|
||||
private const float NightFloor = 0.18f; // ночь тусклая, но не чёрная (лунный свет)
|
||||
private const float NightFloor = 0.24f; // ночь тусклая, но не чёрная (лунный свет) — чуть светлее, чем было
|
||||
private const float MaxShadowCells = 7f; // макс. длина тени от солнца (на рассвете/закате)
|
||||
private const float SunShadowStrength = 0.5f; // насколько темнеет клетка у основания тени
|
||||
|
||||
private readonly Lightmap _lightmap;
|
||||
private readonly DayNight _dayNight;
|
||||
@@ -77,7 +79,9 @@ public sealed class LightmapSystem : BaseSystem
|
||||
_lightmap.Height,
|
||||
ambient,
|
||||
_occluders(),
|
||||
_lights
|
||||
_lights,
|
||||
_dayNight.SunShadow(MaxShadowCells),
|
||||
SunShadowStrength
|
||||
);
|
||||
_lightmap.Upload();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Friflo.Engine.ECS;
|
||||
using MrGameEng.Core;
|
||||
|
||||
namespace MrGameEng.Net;
|
||||
|
||||
@@ -21,6 +22,7 @@ public sealed class ReplicationClient
|
||||
private readonly ReplicationSchema _schema;
|
||||
private readonly EntityStore _store;
|
||||
private readonly Dictionary<int, Entity> _entities = [];
|
||||
private bool _warnedVersion;
|
||||
|
||||
/// <summary>Creates a replication client writing into <paramref name="store"/>.</summary>
|
||||
public ReplicationClient(ReplicationSchema schema, EntityStore store)
|
||||
@@ -38,55 +40,98 @@ public sealed class ReplicationClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes every replicated entity and forgets all net ids. Call before reconnecting: the
|
||||
/// fresh connection receives the full world again with a clean id space, so stale entities
|
||||
/// from the previous session don't linger as duplicates.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
foreach (var entity in _entities.Values)
|
||||
{
|
||||
entity.DeleteEntity();
|
||||
}
|
||||
|
||||
_entities.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Applies one snapshot message to the local store.</summary>
|
||||
public void Apply(byte[] message)
|
||||
{
|
||||
using var reader = new BinaryReader(new MemoryStream(message));
|
||||
if (reader.ReadByte() != ReplicationMessage.Snapshot)
|
||||
// Заголовок: тип(1) + версия(1). Короче — точно не наш снапшот.
|
||||
if (message.Length < 2 || reader.ReadByte() != ReplicationMessage.Snapshot)
|
||||
{
|
||||
return; // незнакомый тип сообщения — пропускаем, это не снапшот
|
||||
}
|
||||
|
||||
var slots = _schema.Slots;
|
||||
var count = reader.ReadInt32();
|
||||
for (var record = 0; record < count; record++)
|
||||
var version = reader.ReadByte();
|
||||
if (version != ReplicationMessage.ProtocolVersion)
|
||||
{
|
||||
var netId = reader.ReadInt32();
|
||||
var op = reader.ReadByte();
|
||||
if (op == ReplicationMessage.OpDespawn)
|
||||
if (!_warnedVersion)
|
||||
{
|
||||
if (_entities.Remove(netId, out var dead))
|
||||
{
|
||||
dead.DeleteEntity();
|
||||
}
|
||||
|
||||
continue;
|
||||
_warnedVersion = true;
|
||||
Log.Warning(
|
||||
$"Replication protocol mismatch: server v{version}, client "
|
||||
+ $"v{ReplicationMessage.ProtocolVersion} — snapshots dropped. Schemas out of sync."
|
||||
);
|
||||
}
|
||||
|
||||
var mask = reader.ReadUInt32();
|
||||
var spawned = false;
|
||||
if (!_entities.TryGetValue(netId, out var entity))
|
||||
{
|
||||
entity = _store.CreateEntity(new NetId { Value = netId });
|
||||
_entities[netId] = entity;
|
||||
spawned = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var slot in slots)
|
||||
var slots = _schema.Slots;
|
||||
try
|
||||
{
|
||||
var count = reader.ReadInt32();
|
||||
for (var record = 0; record < count; record++)
|
||||
{
|
||||
if ((mask & (1u << slot.Bit)) == 0)
|
||||
var netId = reader.ReadInt32();
|
||||
var op = reader.ReadByte();
|
||||
if (op == ReplicationMessage.OpDespawn)
|
||||
{
|
||||
if (_entities.Remove(netId, out var dead))
|
||||
{
|
||||
dead.DeleteEntity();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var data = reader.ReadBytes(slot.Size);
|
||||
slot.Apply(entity, data, 0);
|
||||
}
|
||||
var mask = reader.ReadUInt32();
|
||||
var spawned = false;
|
||||
if (!_entities.TryGetValue(netId, out var entity))
|
||||
{
|
||||
entity = _store.CreateEntity(new NetId { Value = netId });
|
||||
_entities[netId] = entity;
|
||||
spawned = true;
|
||||
}
|
||||
|
||||
if (spawned)
|
||||
{
|
||||
EntitySpawned?.Invoke(entity);
|
||||
foreach (var slot in slots)
|
||||
{
|
||||
if ((mask & (1u << slot.Bit)) == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var data = reader.ReadBytes(slot.Size);
|
||||
if (data.Length < slot.Size)
|
||||
{
|
||||
return; // снапшот оборван на полпути — дальше читать нечего
|
||||
}
|
||||
|
||||
slot.Apply(entity, data, 0);
|
||||
}
|
||||
|
||||
if (spawned)
|
||||
{
|
||||
EntitySpawned?.Invoke(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (EndOfStreamException)
|
||||
{
|
||||
// Структурно битый/усечённый снапшот — игнорируем остаток, соединение не роняем.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ public sealed class ReplicationServer
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(stream);
|
||||
writer.Write(ReplicationMessage.Snapshot);
|
||||
writer.Write(ReplicationMessage.ProtocolVersion); // версия формата — клиент отвергает чужую
|
||||
var countPosition = stream.Position;
|
||||
writer.Write(0); // количество записей, допишем в конце
|
||||
var records = 0;
|
||||
@@ -169,6 +170,14 @@ public sealed class ReplicationServer
|
||||
internal static class ReplicationMessage
|
||||
{
|
||||
internal const byte Snapshot = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-format version. Bump whenever the snapshot layout or the meaning of the schema's
|
||||
/// component blits changes; a client receiving a mismatched version drops the message
|
||||
/// instead of decoding garbage (guards against a desync between server and client schemas).
|
||||
/// </summary>
|
||||
internal const byte ProtocolVersion = 1;
|
||||
|
||||
internal const byte OpUpsert = 0;
|
||||
internal const byte OpDespawn = 1;
|
||||
}
|
||||
|
||||
@@ -101,6 +101,9 @@ public sealed class WebSocketClient : INetConnection, IDisposable
|
||||
/// <summary>Closes the connection.</summary>
|
||||
public void Dispose() => Close();
|
||||
|
||||
/// <summary>Largest reassembled message accepted from the server before the connection is dropped.</summary>
|
||||
public const int MaxMessageBytes = 16 * 1024 * 1024;
|
||||
|
||||
private async Task ReceiveLoop()
|
||||
{
|
||||
var buffer = new byte[64 * 1024];
|
||||
@@ -117,6 +120,11 @@ public sealed class WebSocketClient : INetConnection, IDisposable
|
||||
break;
|
||||
}
|
||||
|
||||
if (message.Length + result.Count > MaxMessageBytes)
|
||||
{
|
||||
break; // сервер шлёт ненормально большое сообщение — рвём соединение
|
||||
}
|
||||
|
||||
message.Write(buffer, 0, result.Count);
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
|
||||
@@ -10,13 +10,25 @@ namespace MrGameEng.Net;
|
||||
/// dedicated servers. Accepting and reading happen on background tasks; the simulation
|
||||
/// drains new connections with <see cref="TryAcceptConnection"/> and reads messages by
|
||||
/// polling each connection — nothing here touches the ECS world from another thread.
|
||||
/// Binary messages only; pings are answered automatically.
|
||||
/// Binary messages only; incoming pings are answered automatically and the server itself
|
||||
/// heartbeats each connection, closing any that goes silent past <see cref="IdleTimeout"/>
|
||||
/// (detects half-open TCP — a peer that vanished without a close frame). A reassembled
|
||||
/// message is capped at <see cref="MaxMessageBytes"/> so a peer can't exhaust memory.
|
||||
/// </summary>
|
||||
public sealed class WebSocketServer : IDisposable
|
||||
{
|
||||
/// <summary>Largest reassembled (possibly fragmented) message accepted from a peer.</summary>
|
||||
public const int MaxMessageBytes = WebSocketProtocol.MaxPayloadBytes;
|
||||
|
||||
/// <summary>The port the server listens on.</summary>
|
||||
public int Port { get; }
|
||||
|
||||
/// <summary>How often the server pings each connection to keep it alive and probe liveness.</summary>
|
||||
public TimeSpan HeartbeatInterval { get; }
|
||||
|
||||
/// <summary>A connection with no traffic for longer than this is considered dead and closed.</summary>
|
||||
public TimeSpan IdleTimeout { get; }
|
||||
|
||||
/// <summary>Snapshot of currently open connections.</summary>
|
||||
public IReadOnlyList<INetConnection> Connections
|
||||
{
|
||||
@@ -36,10 +48,22 @@ public sealed class WebSocketServer : IDisposable
|
||||
private int _nextConnectionId;
|
||||
private bool _started;
|
||||
|
||||
/// <summary>Creates a server for <paramref name="port"/> on all interfaces. Call <see cref="Start"/> to listen.</summary>
|
||||
public WebSocketServer(int port)
|
||||
/// <summary>
|
||||
/// Creates a server for <paramref name="port"/> on all interfaces. Call <see cref="Start"/>
|
||||
/// to listen. <paramref name="heartbeatInterval"/> (default 10 s) sets how often each
|
||||
/// connection is pinged; <paramref name="idleTimeout"/> (default 30 s) how long a silent
|
||||
/// connection lives before it's dropped as dead. The timeout must exceed the interval so a
|
||||
/// healthy peer's pong lands before it's judged idle.
|
||||
/// </summary>
|
||||
public WebSocketServer(
|
||||
int port,
|
||||
TimeSpan? heartbeatInterval = null,
|
||||
TimeSpan? idleTimeout = null
|
||||
)
|
||||
{
|
||||
Port = port;
|
||||
HeartbeatInterval = heartbeatInterval ?? TimeSpan.FromSeconds(10);
|
||||
IdleTimeout = idleTimeout ?? TimeSpan.FromSeconds(30);
|
||||
_listener = new TcpListener(IPAddress.Any, port);
|
||||
}
|
||||
|
||||
@@ -54,6 +78,7 @@ public sealed class WebSocketServer : IDisposable
|
||||
_started = true;
|
||||
_listener.Start();
|
||||
Task.Run(AcceptLoop);
|
||||
Task.Run(HeartbeatLoop);
|
||||
Log.Info($"WebSocketServer listening on port {Port}");
|
||||
}
|
||||
|
||||
@@ -109,6 +134,48 @@ public sealed class WebSocketServer : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Пингует живые соединения и закрывает те, что молчат дольше IdleTimeout (мёртвый peer
|
||||
// не отвечает pong'ом — его активность не обновляется и он отваливается по таймауту).
|
||||
private async Task HeartbeatLoop()
|
||||
{
|
||||
while (!_shutdown.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(HeartbeatInterval, _shutdown.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ServerConnection[] snapshot;
|
||||
lock (_connections)
|
||||
{
|
||||
snapshot = _connections.ToArray();
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var connection in snapshot)
|
||||
{
|
||||
if (!connection.IsOpen)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (now - connection.LastActivityUtc > IdleTimeout)
|
||||
{
|
||||
Log.Info($"WebSocketServer: connection #{connection.Id} timed out (idle)");
|
||||
connection.Close();
|
||||
}
|
||||
else
|
||||
{
|
||||
connection.SendPing();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Handshake(TcpClient client)
|
||||
{
|
||||
try
|
||||
@@ -148,21 +215,30 @@ public sealed class WebSocketServer : IDisposable
|
||||
public int Id { get; }
|
||||
public bool IsOpen => !_closed;
|
||||
|
||||
/// <summary>UTC of the last frame received from the peer — drives idle-timeout detection.</summary>
|
||||
public DateTime LastActivityUtc =>
|
||||
new(Volatile.Read(ref _lastActivityTicks), DateTimeKind.Utc);
|
||||
|
||||
private readonly TcpClient _client;
|
||||
private readonly NetworkStream _stream;
|
||||
private readonly ConcurrentQueue<byte[]> _inbox = new();
|
||||
private readonly object _sendLock = new();
|
||||
private volatile bool _closed;
|
||||
private long _lastActivityTicks;
|
||||
|
||||
internal ServerConnection(int id, TcpClient client)
|
||||
{
|
||||
Id = id;
|
||||
_client = client;
|
||||
_stream = client.GetStream();
|
||||
_lastActivityTicks = DateTime.UtcNow.Ticks;
|
||||
}
|
||||
|
||||
internal void StartReceiveLoop() => Task.Run(ReceiveLoop);
|
||||
|
||||
/// <summary>Sends a heartbeat ping; a live peer answers with a pong, refreshing activity.</summary>
|
||||
internal void SendPing() => SendControl(WebSocketOpcode.Ping, []);
|
||||
|
||||
public void Send(ReadOnlySpan<byte> message)
|
||||
{
|
||||
if (_closed)
|
||||
@@ -184,6 +260,27 @@ public sealed class WebSocketServer : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void SendControl(WebSocketOpcode opcode, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (_closed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var frame = WebSocketProtocol.EncodeFrame(payload, opcode);
|
||||
try
|
||||
{
|
||||
lock (_sendLock)
|
||||
{
|
||||
_stream.Write(frame, 0, frame.Length);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryReceive(out byte[] message) => _inbox.TryDequeue(out message!);
|
||||
|
||||
public void Close()
|
||||
@@ -224,31 +321,18 @@ public sealed class WebSocketServer : IDisposable
|
||||
break;
|
||||
}
|
||||
|
||||
// Любой кадр (включая pong) — признак жизни: сбрасываем счётчик простоя.
|
||||
Volatile.Write(ref _lastActivityTicks, DateTime.UtcNow.Ticks);
|
||||
|
||||
switch (opcode)
|
||||
{
|
||||
case WebSocketOpcode.Ping:
|
||||
lock (_sendLock)
|
||||
{
|
||||
var pong = WebSocketProtocol.EncodeFrame(
|
||||
payload,
|
||||
WebSocketOpcode.Pong
|
||||
);
|
||||
_stream.Write(pong, 0, pong.Length);
|
||||
}
|
||||
|
||||
SendControl(WebSocketOpcode.Pong, payload);
|
||||
continue;
|
||||
case WebSocketOpcode.Pong:
|
||||
continue;
|
||||
case WebSocketOpcode.Close:
|
||||
lock (_sendLock)
|
||||
{
|
||||
var close = WebSocketProtocol.EncodeFrame(
|
||||
[],
|
||||
WebSocketOpcode.Close
|
||||
);
|
||||
_stream.Write(close, 0, close.Length);
|
||||
}
|
||||
|
||||
SendControl(WebSocketOpcode.Close, []);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -258,6 +342,15 @@ public sealed class WebSocketServer : IDisposable
|
||||
pending.Clear();
|
||||
}
|
||||
|
||||
if (pending.Count + payload.Length > MaxMessageBytes)
|
||||
{
|
||||
Log.Warning(
|
||||
$"WebSocketServer: connection #{Id} exceeded {MaxMessageBytes}-byte "
|
||||
+ "message cap — closing"
|
||||
);
|
||||
return; // finally закроет соединение
|
||||
}
|
||||
|
||||
pending.AddRange(payload);
|
||||
if (fin && pendingOpcode == WebSocketOpcode.Binary)
|
||||
{
|
||||
|
||||
@@ -23,4 +23,41 @@ public static class Suitability
|
||||
var z = (value - optimum) / tolerance;
|
||||
return MathF.Exp(-0.5f * z * z);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trapezoidal suitability in <c>[0, 1]</c>: <c>0</c> at or beyond the hard limits
|
||||
/// <paramref name="min"/>/<paramref name="max"/>, ramping linearly up to <c>1</c> at
|
||||
/// <paramref name="optimalLow"/>, flat <c>1</c> across the comfortable plateau to
|
||||
/// <paramref name="optimalHigh"/>, then ramping back down to <c>0</c> at <paramref name="max"/>.
|
||||
/// Models a hard tolerance band with a plateau (RimWorld-style plant growth vs. temperature:
|
||||
/// dormant below <paramref name="min"/> or above <paramref name="max"/>). A degenerate edge
|
||||
/// (<paramref name="optimalLow"/> ≤ <paramref name="min"/> or <paramref name="optimalHigh"/> ≥
|
||||
/// <paramref name="max"/>) becomes a hard step on that side. The four bounds are expected
|
||||
/// ordered (<c>min ≤ optimalLow ≤ optimalHigh ≤ max</c>); pass ordered values.
|
||||
/// </summary>
|
||||
public static float Trapezoid(
|
||||
float value,
|
||||
float min,
|
||||
float optimalLow,
|
||||
float optimalHigh,
|
||||
float max
|
||||
)
|
||||
{
|
||||
if (value <= min || value >= max)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
if (value < optimalLow)
|
||||
{
|
||||
return optimalLow > min ? (value - min) / (optimalLow - min) : 1f;
|
||||
}
|
||||
|
||||
if (value > optimalHigh)
|
||||
{
|
||||
return max > optimalHigh ? (max - value) / (max - optimalHigh) : 1f;
|
||||
}
|
||||
|
||||
return 1f;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,20 @@ public class CalendarTests
|
||||
Assert.Equal(14 * 60 + 30, calendar.MinuteOfDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartDay_OffsetsTheTimeOfDay()
|
||||
{
|
||||
var clock = new GameClock();
|
||||
var calendar = new Calendar(clock, secondsPerDay: 10f, startDay: 7.0 / 24); // begin at 07:00
|
||||
|
||||
Assert.Equal(1, calendar.Day);
|
||||
Assert.Equal(7, calendar.Hour);
|
||||
Assert.Equal(0, calendar.Minute);
|
||||
|
||||
clock.Advance(5f); // half a day later → 19:00
|
||||
Assert.Equal(19, calendar.Hour);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pause_DoesNotAdvanceTheCalendar()
|
||||
{
|
||||
|
||||
@@ -81,4 +81,15 @@ public class ClimateTests
|
||||
Assert.Equal(2, climate.Year);
|
||||
Assert.Equal(0, climate.DayOfYear);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartDayOfYear_ShiftsTheSeasonalPhase()
|
||||
{
|
||||
// Begin the world already at the warmest day (15): the curve peaks at clock 0.
|
||||
var (_, climate) = Make(Seasonal with { StartDayOfYear = 15 });
|
||||
|
||||
Assert.Equal(30f, climate.Temperature, 2); // mean 10 + amplitude 20, at the peak
|
||||
Assert.Equal(15, climate.DayOfYear); // day-of-year reflects the offset
|
||||
Assert.Equal(Season.Summer, climate.Season); // day 15 of a 60-day year = start of summer
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,29 @@ public class CameraMathTests
|
||||
AssertVector(new Vector2(0f, 200f), state.ScreenToWorld(Vector2.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorldCenter_EqualsUnclampedCameraPosition()
|
||||
{
|
||||
var camera = new Camera(new Vector2(640f, 360f), zoom: 2f);
|
||||
|
||||
var state = CameraMath.Compute(camera, 1280, 720, ViewportMapping.Identity);
|
||||
|
||||
AssertVector(camera.Position, state.WorldCenter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorldCenter_ReflectsBoundsClamp_UnlikeRawPosition()
|
||||
{
|
||||
var bounds = new RectF(0f, 0f, 2000f, 1000f);
|
||||
var camera = new Camera(new Vector2(-500f, 500f), bounds: bounds);
|
||||
|
||||
var state = CameraMath.Compute(camera, 800, 600, ViewportMapping.Identity);
|
||||
|
||||
// Raw position is (-500, 500); only X clamps (to half-width 400 from the left world edge),
|
||||
// Y (500) is already inside [300, 700]. The effective centre the view is built around is (400, 500).
|
||||
AssertVector(new Vector2(400f, 500f), state.WorldCenter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mapping_CentersVirtualResolutionInWiderWindow()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Lighting;
|
||||
using Xunit;
|
||||
@@ -39,6 +40,25 @@ public class DayNightTests
|
||||
Assert.True(dawn < mid && mid < noon, "daylight should rise from dawn to noon");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SunShadow_ZeroAtNight_PointsWestInMorning_EastInAfternoon_ShortAtNoon()
|
||||
{
|
||||
var (clock, dayNight) = Make(); // 1s = 1 in-game hour
|
||||
|
||||
Assert.Equal(Vector2.Zero, dayNight.SunShadow(8f)); // 00:00 — night, no sun
|
||||
|
||||
clock.Advance(7f); // 07:00 — low morning sun in the east
|
||||
var morning = dayNight.SunShadow(8f);
|
||||
Assert.True(morning.X < 0f, "morning shadow points west");
|
||||
Assert.True(morning.Length() > 2f, "low sun casts a long shadow");
|
||||
|
||||
clock.Advance(5f); // 12:00 — sun overhead
|
||||
Assert.True(dayNight.SunShadow(8f).Length() < 1f, "noon sun casts almost no shadow");
|
||||
|
||||
clock.Advance(5f); // 17:00 — afternoon sun in the west
|
||||
Assert.True(dayNight.SunShadow(8f).X > 0f, "afternoon shadow points east");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ambient_DarkerAtNightThanAtNoon()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Lighting;
|
||||
using Xunit;
|
||||
|
||||
@@ -54,6 +55,29 @@ public class LightmapBuilderTests
|
||||
Assert.Equal(0f, light[5], 5); // за препятствием — тень
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SunShadow_DarkensCellsInTheShadowDirection_FadingFromTheCaster()
|
||||
{
|
||||
var occ = new bool[7];
|
||||
occ[3] = true; // occluder in the middle
|
||||
var light = new float[7];
|
||||
LightmapBuilder.Build(
|
||||
light,
|
||||
7,
|
||||
1,
|
||||
1f,
|
||||
occ,
|
||||
[],
|
||||
sunShadow: new Vector2(3f, 0f),
|
||||
sunShadowStrength: 0.6f
|
||||
);
|
||||
|
||||
Assert.Equal(1f, light[2], 5); // toward the sun (opposite the shadow) — unshadowed
|
||||
Assert.Equal(LightmapBuilder.OccluderShade, light[3], 5); // occluder cell stays self-shaded
|
||||
Assert.True(light[4] < 1f); // in shadow
|
||||
Assert.True(light[4] < light[6]); // darker near the caster, fading along the shadow
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Values_StayWithinUnitRange()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using MrGameEng.Net;
|
||||
using Xunit;
|
||||
|
||||
namespace MrGameEng.Net.Tests;
|
||||
|
||||
public class HeartbeatTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HealthyClient_SurvivesPastIdleTimeout()
|
||||
{
|
||||
var port = FreePort();
|
||||
using var server = new WebSocketServer(
|
||||
port,
|
||||
heartbeatInterval: TimeSpan.FromMilliseconds(100),
|
||||
idleTimeout: TimeSpan.FromMilliseconds(400)
|
||||
);
|
||||
server.Start();
|
||||
|
||||
using var client = await WebSocketClient.ConnectAsync(
|
||||
new Uri($"ws://localhost:{port}/"),
|
||||
new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token
|
||||
);
|
||||
var connection = await WaitFor(
|
||||
() => server.TryAcceptConnection(out var c) ? c : null,
|
||||
"server accept"
|
||||
);
|
||||
|
||||
// Дольше idleTimeout: живой клиент авто-отвечает pong на server-ping и остаётся открыт.
|
||||
await Task.Delay(900, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(connection.IsOpen);
|
||||
Assert.True(client.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SilentPeer_IsDroppedAfterIdleTimeout()
|
||||
{
|
||||
var port = FreePort();
|
||||
using var server = new WebSocketServer(
|
||||
port,
|
||||
heartbeatInterval: TimeSpan.FromMilliseconds(100),
|
||||
idleTimeout: TimeSpan.FromMilliseconds(400)
|
||||
);
|
||||
server.Start();
|
||||
|
||||
// Сырой peer: проходит рукопожатие, но дальше молчит и не отвечает на ping.
|
||||
using var tcp = new TcpClient();
|
||||
await tcp.ConnectAsync(IPAddress.Loopback, port, TestContext.Current.CancellationToken);
|
||||
var request =
|
||||
"GET / HTTP/1.1\r\n"
|
||||
+ "Host: localhost\r\n"
|
||||
+ "Upgrade: websocket\r\n"
|
||||
+ "Connection: Upgrade\r\n"
|
||||
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
|
||||
+ "Sec-WebSocket-Version: 13\r\n"
|
||||
+ "\r\n";
|
||||
var bytes = Encoding.ASCII.GetBytes(request);
|
||||
await tcp.GetStream().WriteAsync(bytes, TestContext.Current.CancellationToken);
|
||||
|
||||
var connection = await WaitFor(
|
||||
() => server.TryAcceptConnection(out var c) ? c : null,
|
||||
"server accept"
|
||||
);
|
||||
Assert.True(connection.IsOpen);
|
||||
|
||||
// Peer не отвечает pong'ом → активность не обновляется → сервер закрывает по простою.
|
||||
await WaitFor(() => connection.IsOpen ? null : "closed", "idle drop");
|
||||
Assert.False(connection.IsOpen);
|
||||
}
|
||||
|
||||
private static async Task<T> WaitFor<T>(Func<T?> poll, string what)
|
||||
where T : class
|
||||
{
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
if (poll() is { } result)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
await Task.Delay(25);
|
||||
}
|
||||
|
||||
throw new TimeoutException($"Timed out waiting for {what}.");
|
||||
}
|
||||
|
||||
private static int FreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
}
|
||||
@@ -103,8 +103,8 @@ public class ReplicationTests
|
||||
server.Send([connection]);
|
||||
|
||||
Assert.True(connection.Sent.TryDequeue(out var delta));
|
||||
// Запись: type(1) + count(4) + netId(4) + op(1) + mask(4) + TestPosition(8) — без TestHealth.
|
||||
Assert.Equal(22, delta!.Length);
|
||||
// Запись: type(1) + version(1) + count(4) + netId(4) + op(1) + mask(4) + TestPosition(8) — без TestHealth.
|
||||
Assert.Equal(23, delta!.Length);
|
||||
|
||||
client.Apply(delta);
|
||||
var replicated = FindByNetId(clientStore, 1);
|
||||
@@ -185,6 +185,81 @@ public class ReplicationTests
|
||||
Assert.Equal(1, spawns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_DeletesEveryReplicatedEntity()
|
||||
{
|
||||
var serverStore = new EntityStore();
|
||||
var clientStore = new EntityStore();
|
||||
var server = new ReplicationServer(MakeSchema(), serverStore);
|
||||
var client = new ReplicationClient(MakeSchema(), clientStore);
|
||||
var connection = new FakeConnection();
|
||||
serverStore.CreateEntity(
|
||||
new NetId { Value = server.NextNetId() },
|
||||
new TestPosition { X = 1f, Y = 2f }
|
||||
);
|
||||
serverStore.CreateEntity(
|
||||
new NetId { Value = server.NextNetId() },
|
||||
new TestPosition { X = 3f, Y = 4f }
|
||||
);
|
||||
server.Send([connection]);
|
||||
client.Pump(connection);
|
||||
Assert.Equal(2, client.EntityCount);
|
||||
|
||||
client.Clear();
|
||||
|
||||
Assert.Equal(0, client.EntityCount);
|
||||
foreach (var entity in clientStore.Entities)
|
||||
{
|
||||
Assert.False(entity.HasComponent<NetId>());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MismatchedProtocolVersion_IsDropped()
|
||||
{
|
||||
var serverStore = new EntityStore();
|
||||
var clientStore = new EntityStore();
|
||||
var server = new ReplicationServer(MakeSchema(), serverStore);
|
||||
var client = new ReplicationClient(MakeSchema(), clientStore);
|
||||
var connection = new FakeConnection();
|
||||
serverStore.CreateEntity(
|
||||
new NetId { Value = server.NextNetId() },
|
||||
new TestPosition { X = 1f, Y = 2f }
|
||||
);
|
||||
server.Send([connection]);
|
||||
Assert.True(connection.Sent.TryDequeue(out var snapshot));
|
||||
|
||||
// Портим байт версии (индекс 1: type=0, version=1) — клиент обязан отбросить снапшот целиком.
|
||||
snapshot![1] = 0xFF;
|
||||
client.Apply(snapshot);
|
||||
|
||||
Assert.Equal(0, client.EntityCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TruncatedSnapshot_IsIgnoredWithoutThrowing()
|
||||
{
|
||||
var serverStore = new EntityStore();
|
||||
var clientStore = new EntityStore();
|
||||
var server = new ReplicationServer(MakeSchema(), serverStore);
|
||||
var client = new ReplicationClient(MakeSchema(), clientStore);
|
||||
var connection = new FakeConnection();
|
||||
serverStore.CreateEntity(
|
||||
new NetId { Value = server.NextNetId() },
|
||||
new TestPosition { X = 5f, Y = 6f },
|
||||
new TestHealth { Value = 9 }
|
||||
);
|
||||
server.Send([connection]);
|
||||
Assert.True(connection.Sent.TryDequeue(out var snapshot));
|
||||
|
||||
// Режем хвост: заголовок и счётчик целы, но данные компонентов оборваны.
|
||||
var truncated = snapshot!.AsSpan(0, snapshot.Length - 6).ToArray();
|
||||
client.Apply(truncated); // не должно бросить
|
||||
|
||||
// Записи могли частично примениться, но клиент остался живым и консистентным.
|
||||
Assert.True(client.EntityCount <= 1);
|
||||
}
|
||||
|
||||
private static Entity FindByNetId(EntityStore store, int netId)
|
||||
{
|
||||
foreach (var entity in store.Entities)
|
||||
|
||||
@@ -48,4 +48,45 @@ public class SuitabilityTests
|
||||
Assert.Equal(1f, Suitability.Gaussian(5f, 5f, 0f));
|
||||
Assert.Equal(0f, Suitability.Gaussian(6f, 5f, 0f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trapezoid_IsFlatAcrossThePlateau()
|
||||
{
|
||||
Assert.Equal(1f, Suitability.Trapezoid(10f, 0f, 10f, 42f, 58f), 5);
|
||||
Assert.Equal(1f, Suitability.Trapezoid(25f, 0f, 10f, 42f, 58f), 5);
|
||||
Assert.Equal(1f, Suitability.Trapezoid(42f, 0f, 10f, 42f, 58f), 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trapezoid_RampsLinearlyOnEachShoulder()
|
||||
{
|
||||
Assert.Equal(0.5f, Suitability.Trapezoid(5f, 0f, 10f, 42f, 58f), 5); // halfway up the cold ramp
|
||||
Assert.Equal(0.5f, Suitability.Trapezoid(50f, 0f, 10f, 42f, 58f), 5); // halfway down the heat ramp
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trapezoid_IsZeroAtAndBeyondTheHardLimits()
|
||||
{
|
||||
Assert.Equal(0f, Suitability.Trapezoid(0f, 0f, 10f, 42f, 58f));
|
||||
Assert.Equal(0f, Suitability.Trapezoid(-5f, 0f, 10f, 42f, 58f));
|
||||
Assert.Equal(0f, Suitability.Trapezoid(58f, 0f, 10f, 42f, 58f));
|
||||
Assert.Equal(0f, Suitability.Trapezoid(70f, 0f, 10f, 42f, 58f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trapezoid_DegenerateShoulder_IsAHardStep()
|
||||
{
|
||||
// optimalLow == min: full suitability immediately above the lower limit, zero at/below it.
|
||||
Assert.Equal(1f, Suitability.Trapezoid(5f, 0f, 0f, 10f, 20f));
|
||||
Assert.Equal(0f, Suitability.Trapezoid(0f, 0f, 0f, 10f, 20f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trapezoid_StaysWithinUnitRange()
|
||||
{
|
||||
for (var v = -50f; v <= 80f; v += 1f)
|
||||
{
|
||||
Assert.InRange(Suitability.Trapezoid(v, 0f, 10f, 42f, 58f), 0f, 1f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user