Compare commits

...
9 Commits
Author SHA1 Message Date
Leonid PershinandClaude Opus 4.8 46f3931f85 Lighting: lighter night floor (0.18 -> 0.24)
CI / build-test (push) Successful in 1m16s
Night was a touch too dark; raise the ambient moonlight floor a couple points.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:33:20 +03:00
Leonid Pershin 96e19c7c61 Lighting: directional sun shadows from the day/night sun angle
DayNight.SunShadow returns a cell offset opposite the sun's east-west position, longest near sunrise/sunset (low sun) and zero at noon/night, tilted slightly south so shadows fall in front of occluders. LightmapBuilder gains a directional shadow pass (MaxShadowCells length cap, SunShadowStrength darkening); LightmapSystem feeds it the current sun shadow each rebuild. Tests cover SunShadow's day-arc behaviour and the builder's directional darkening.
2026-06-14 07:04:11 +03:00
Leonid Pershin 38bdfbbb81 Enhance Calendar and Climate systems with start day offsets
CI / build-test (push) Successful in 1m13s
- Added a `startDay` parameter to the `Calendar` class to allow for fractional day offsets at clock initialization, enabling more flexible time settings.
- Updated `TotalDays` calculation to include the `startDay` offset.
- Introduced `StartDayOfYear` in `ClimateSettings` to shift the seasonal phase, allowing worlds to begin at a specific time of year.
- Adjusted `YearProgress` and `Year` calculations in the `Climate` class to account for the new `StartDayOfYear`.
- Added unit tests to verify the functionality of the new start day features in both `Calendar` and `Climate` classes.
2026-06-13 06:55:09 +03:00
Leonid Pershin 08381703f7 Add WorldCenter property to CameraState for effective camera positioning
CI / build-test (push) Successful in 1m19s
Enhanced the CameraState struct with a new WorldCenter property that calculates the effective position of the camera after bounds-clamping. This property is intended to be used for zoom-to-cursor functionality, ensuring that the repositioning aligns with what is rendered.

Added unit tests to verify that WorldCenter reflects the unclamped camera position and correctly accounts for bounds clamping, distinguishing it from the raw camera position.

Tests: WorldCenter_EqualsUnclampedCameraPosition, WorldCenter_ReflectsBoundsClamp_UnlikeRawPosition.
2026-06-13 05:21:24 +03:00
Leonid PershinandClaude Fable 5 f382fc98ea Net: heartbeat liveness, protocol version, message cap, replication reset
CI / build-test (push) Successful in 1m12s
Robustness pass over MrGameEng.Net:
- WebSocketServer heartbeats each connection (configurable interval/timeout)
  and drops peers idle past the timeout — detects half-open TCP that vanished
  without a close frame. Tracks last-activity per connection.
- Reassembled messages capped (server and client) so a peer cannot exhaust
  memory with an oversized fragmented message.
- Replication snapshots carry a protocol-version byte; a client receiving a
  mismatched version drops the message instead of decoding garbage, and a
  truncated snapshot is ignored without throwing.
- ReplicationClient.Clear() deletes all replicated entities, so clients can
  wipe stale state before reconnecting.

Tests: heartbeat healthy-survives / silent-peer-dropped, protocol-version
mismatch, truncated snapshot, replication clear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 04:44:07 +03:00
Leonid PershinandClaude Opus 4.8 d360093be1 Add Trapezoidal suitability function and corresponding tests
CI / build-test (push) Successful in 1m10s
Introduced a new Trapezoid method in the Suitability class to model suitability with a hard tolerance band and a plateau. The method returns suitability values based on defined limits and optimal ranges. Added comprehensive unit tests to validate the functionality, including edge cases for flat plateaus, linear ramps, and hard limits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 04:34:32 +03:00
Leonid PershinandClaude Opus 4.8 d044cafad9 Regex tooling: formula gene grouping, content patches, def validation
CI / build-test (push) Successful in 1m12s
Three regex-powered content tools (phase G5):

- Formula group functions gsum/gcount/gavg/gmin/gmax('regex') aggregate
  over every context variable whose name matches the pattern, e.g.
  gsum('leaf_.*'). Adds string literals to the formula grammar and an
  IFormulaContext.ResolveMatching hook; GenomeContext enumerates matching
  genes, so a trait can sum/average a gene group.
- DefDatabase content patches: a { "type": "Patch", patches:[{ defType,
  match (regex on defName), set:{fields} }] } file sets fields on every
  matching raw def before resolution — mods patch Core in bulk.
- DefDatabase.RegisterValidator(typeKey, field, regex): load-time check
  that a string field matches a pattern, throwing otherwise (naming/format
  conventions).

Covered by FormulaTests (group aggregation, composition, bad calls) and
DefDatabaseTests (patch set/match/unknown-type, validator pass/reject).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 03:59:36 +03:00
Leonid PershinandClaude Opus 4.8 ead22517ee Genetics: GenomeTemplate (per-organism gene allotment) + breed override
CI / build-test (push) Successful in 1m17s
Adds the species/organism layer on top of the gene foundation.
GenomeTemplate carries which GeneDefs an individual has plus the
per-organism base value and spread its alleles are drawn around (and an
optional discrete-variant override), so the same shared GeneDef expresses
different centres for different species — Generate() draws an individual,
Registry() feeds breeding and trait computation.

Genome.Breed gains an optional mutationChance that overrides every gene's
fixed MutationChance, so a caller can drive mutation from an evolvable
trait. Allele sampling (numeric spread+clamp, weighted discrete pick) is
factored into a shared GeneSampling used by both Generate paths.

Covered by GenomeTemplateTests (per-species centres, registry-driven
breeding, mutation override on/off).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:51:02 +03:00
Leonid PershinandClaude Opus 4.8 bc18db5df6 Add gene foundation: GeneDef, Genome, trait phenotype (Content)
CI / build-test (push) Successful in 1m17s
The organism-agnostic core of the gene system, built on the formula
engine. GeneDef is a def describing a gene's kind (numeric/discrete),
allele generation range/spread, mutation, variant distribution and its
effects on named traits as formulas. Genome is a managed, variable-
composition map (geneId -> Allele pair): generated from a gene set, bred
meiotically with per-gene mutation, expressed to a phenotype (numeric
mean / discrete lower-allele dominance); open composition allows hybrids.
Phenotype.Compute aggregates each gene's effect formulas into a trait
map (variable `value` = the gene's expressed phenotype, other gene ids
and an environment context resolve too), so systems read traits, never
genes.

Nothing here is species-specific. Def JSON now supports string-named
enums (JsonStringEnumConverter) so a gene's kind reads as "Discrete".
Covered by GeneticsTests (generation/expression/breeding/traits) and
GeneDefLoadTests (GeneDef through the real DefDatabase).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:43:01 +03:00
36 changed files with 1951 additions and 66 deletions
+16
View File
@@ -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);
}
+97
View File
@@ -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;
}
}
+156
View File
@@ -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);
}
}
}
}
}
+156 -1
View File
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace MrGameEng.Mods; namespace MrGameEng.Mods;
@@ -24,6 +25,18 @@ public sealed class DefDatabase
private readonly Dictionary<string, TypeEntry> _byKey = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, TypeEntry> _byKey = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<Type, TypeEntry> _byType = []; 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() private static readonly JsonDocumentOptions DocumentOptions = new()
{ {
CommentHandling = JsonCommentHandling.Skip, CommentHandling = JsonCommentHandling.Skip,
@@ -43,6 +56,38 @@ public sealed class DefDatabase
_byType.Add(typeof(T), entry); _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> /// <summary>
/// Loads every <c>Defs/**/*.json</c> of <paramref name="mods"/> (in load order) and /// Loads every <c>Defs/**/*.json</c> of <paramref name="mods"/> (in load order) and
/// resolves inheritance. Call once after registering all def types. /// resolves inheritance. Call once after registering all def types.
@@ -66,6 +111,8 @@ public sealed class DefDatabase
} }
} }
ApplyPatches();
foreach (var entry in _byKey.Values) foreach (var entry in _byKey.Values)
{ {
Resolve(entry); Resolve(entry);
@@ -133,6 +180,12 @@ public sealed class DefDatabase
?? throw new InvalidDataException( ?? throw new InvalidDataException(
$"Def file '{file}' (mod '{mod.Id}') has no \"type\" field." $"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)) if (!_byKey.TryGetValue(typeKey, out var entry))
{ {
throw new InvalidDataException( 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)) foreach (var defName in entry.Raw.Keys.Order(StringComparer.Ordinal))
{ {
var merged = MergeChain(entry, defName, []); var merged = MergeChain(entry, defName, []);
@@ -179,6 +307,11 @@ public sealed class DefDatabase
continue; continue;
} }
if (rules is not null)
{
Validate(entry.Key, defName, merged, rules);
}
var def = var def =
(Def?)merged.Deserialize(entry.ClrType, ModInfo.JsonOptions) (Def?)merged.Deserialize(entry.ClrType, ModInfo.JsonOptions)
?? throw new InvalidDataException( ?? 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) private static JsonObject MergeChain(TypeEntry entry, string defName, HashSet<string> seen)
{ {
if (!seen.Add(defName)) if (!seen.Add(defName))
+2
View File
@@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
namespace MrGameEng.Mods; namespace MrGameEng.Mods;
@@ -14,6 +15,7 @@ public sealed class ModInfo
ReadCommentHandling = JsonCommentHandling.Skip, ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true, AllowTrailingCommas = true,
WriteIndented = true, WriteIndented = true,
Converters = { new JsonStringEnumConverter() },
}; };
/// <summary>Unique mod id, referenced by <see cref="Dependencies"/> of other mods.</summary> /// <summary>Unique mod id, referenced by <see cref="Dependencies"/> of other mods.</summary>
+16 -6
View File
@@ -10,16 +10,21 @@ namespace MrGameEng.Core;
public sealed class Calendar public sealed class Calendar
{ {
private readonly GameClock _clock; private readonly GameClock _clock;
private readonly double _startDay;
private float _secondsPerDay; private float _secondsPerDay;
/// <summary> /// <summary>
/// Creates a calendar reading <paramref name="clock"/>; one day spans /// Creates a calendar reading <paramref name="clock"/>; one day spans
/// <paramref name="secondsPerDay"/> seconds of scaled time (must be positive). /// <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> /// </summary>
public Calendar(GameClock clock, float secondsPerDay) public Calendar(GameClock clock, float secondsPerDay, double startDay = 0.0)
{ {
_clock = clock ?? throw new ArgumentNullException(nameof(clock)); _clock = clock ?? throw new ArgumentNullException(nameof(clock));
SecondsPerDay = secondsPerDay; SecondsPerDay = secondsPerDay;
_startDay = startDay;
} }
/// <summary>Scaled seconds per in-game day. Must be positive; raising it slows the calendar.</summary> /// <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> /// <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; public double TotalDays => _clock.TotalTime / _secondsPerDay + _startDay;
/// <summary>The current day number, counting from 1.</summary> /// <summary>The current day number, counting from 1.</summary>
public int Day => (int)TotalDays + 1; public int Day => (int)TotalDays + 1;
@@ -68,11 +73,16 @@ public static class CalendarEngineExtensions
/// <summary> /// <summary>
/// Creates a <see cref="Calendar"/> bound to the context's clock and registers it as a /// 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 /// 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> /// </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); context.Services.Add(calendar);
return calendar; return calendar;
} }
+12 -2
View File
@@ -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> /// <summary>Day of the year (0-based) with the highest temperature; defaults to mid-summer.</summary>
public int WarmestDay { get; init; } 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> /// <summary>Temperate defaults: a 60-day year, mean 12°, ±14° seasonal, ±5° daily, warmest mid-summer.</summary>
public static ClimateSettings Default => public static ClimateSettings Default =>
new() new()
@@ -78,18 +85,21 @@ public sealed class Climate
/// <summary>In-game days per year.</summary> /// <summary>In-game days per year.</summary>
public int DaysPerYear => _settings.DaysPerYear; 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> /// <summary>Continuous position within the current year in <c>[0, 1)</c>.</summary>
public double YearProgress public double YearProgress
{ {
get get
{ {
var years = _calendar.TotalDays / _settings.DaysPerYear; var years = YearDays / _settings.DaysPerYear;
return years - Math.Floor(years); return years - Math.Floor(years);
} }
} }
/// <summary>The current year, counting from 1.</summary> /// <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> /// <summary>Day within the current year, 0-based.</summary>
public int DayOfYear => (int)(YearProgress * _settings.DaysPerYear); 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. /// if the name is unknown — the engine does not invent a default.
/// </summary> /// </summary>
float Resolve(string name); 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> /// <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, Number,
Identifier, Identifier,
String,
Plus, Plus,
Minus, Minus,
Star, Star,
@@ -99,6 +100,27 @@ internal static class FormulaLexer
continue; 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; var pos = i;
switch (c) switch (c)
{ {
@@ -232,6 +232,20 @@ internal sealed class FormulaParser(List<Token> tokens)
private Node ParseCall(string name) private Node ParseCall(string name)
{ {
Expect(TokenType.LParen); 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>(); var args = new List<Node>();
if (!Peek(TokenType.RParen)) if (!Peek(TokenType.RParen))
{ {
+9
View File
@@ -33,6 +33,15 @@ public readonly struct CameraState
/// <summary>Physical-screen to virtual-pixel mapping.</summary> /// <summary>Physical-screen to virtual-pixel mapping.</summary>
public required ViewportMapping Mapping { get; init; } 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> /// <summary>Converts a physical screen point to world coordinates.</summary>
public Vector2 ScreenToWorld(Vector2 screen) 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> /// <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; 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 eastwest 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> /// <summary>Ambient tint for the scene: night color at night, day color at noon, eased between.</summary>
public Color Ambient public Color Ambient
{ {
@@ -1,3 +1,5 @@
using Microsoft.Xna.Framework;
namespace MrGameEng.Lighting; namespace MrGameEng.Lighting;
/// <summary>One point light projected onto the light grid: a cell position, a radius in cells and an intensity.</summary> /// <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> /// <summary>
/// Fills <paramref name="light"/> (length <paramref name="width"/>×<paramref name="height"/>) with /// 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. /// ambient light, shading occluder cells, casts each occluder's directional sun shadow along
/// Values end clamped to <c>[0, 1]</c>. /// <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> /// </summary>
public static void Build( public static void Build(
float[] light, float[] light,
@@ -25,7 +29,9 @@ public static class LightmapBuilder
int height, int height,
float ambient, float ambient,
ReadOnlySpan<bool> occluders, ReadOnlySpan<bool> occluders,
IReadOnlyList<LightSample> lights IReadOnlyList<LightSample> lights,
Vector2 sunShadow = default,
float sunShadowStrength = 0f
) )
{ {
for (var i = 0; i < light.Length; i++) for (var i = 0; i < light.Length; i++)
@@ -33,6 +39,8 @@ public static class LightmapBuilder
light[i] = occluders[i] ? ambient * OccluderShade : ambient; light[i] = occluders[i] ? ambient * OccluderShade : ambient;
} }
CastSunShadows(light, width, height, occluders, sunShadow, sunShadowStrength);
foreach (var l in lights) foreach (var l in lights)
{ {
if (l.Radius <= 0f || l.Intensity <= 0f) 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( private static bool Visible(
@@ -14,7 +14,9 @@ namespace MrGameEng.Lighting;
public sealed class LightmapSystem : BaseSystem public sealed class LightmapSystem : BaseSystem
{ {
private const int RebuildEvery = 6; // ~10 Гц при 60 fps 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 Lightmap _lightmap;
private readonly DayNight _dayNight; private readonly DayNight _dayNight;
@@ -77,7 +79,9 @@ public sealed class LightmapSystem : BaseSystem
_lightmap.Height, _lightmap.Height,
ambient, ambient,
_occluders(), _occluders(),
_lights _lights,
_dayNight.SunShadow(MaxShadowCells),
SunShadowStrength
); );
_lightmap.Upload(); _lightmap.Upload();
} }
@@ -1,4 +1,5 @@
using Friflo.Engine.ECS; using Friflo.Engine.ECS;
using MrGameEng.Core;
namespace MrGameEng.Net; namespace MrGameEng.Net;
@@ -21,6 +22,7 @@ public sealed class ReplicationClient
private readonly ReplicationSchema _schema; private readonly ReplicationSchema _schema;
private readonly EntityStore _store; private readonly EntityStore _store;
private readonly Dictionary<int, Entity> _entities = []; private readonly Dictionary<int, Entity> _entities = [];
private bool _warnedVersion;
/// <summary>Creates a replication client writing into <paramref name="store"/>.</summary> /// <summary>Creates a replication client writing into <paramref name="store"/>.</summary>
public ReplicationClient(ReplicationSchema schema, EntityStore store) public ReplicationClient(ReplicationSchema schema, EntityStore store)
@@ -38,16 +40,49 @@ 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> /// <summary>Applies one snapshot message to the local store.</summary>
public void Apply(byte[] message) public void Apply(byte[] message)
{ {
using var reader = new BinaryReader(new MemoryStream(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; // незнакомый тип сообщения — пропускаем, это не снапшот return; // незнакомый тип сообщения — пропускаем, это не снапшот
} }
var version = reader.ReadByte();
if (version != ReplicationMessage.ProtocolVersion)
{
if (!_warnedVersion)
{
_warnedVersion = true;
Log.Warning(
$"Replication protocol mismatch: server v{version}, client "
+ $"v{ReplicationMessage.ProtocolVersion} — snapshots dropped. Schemas out of sync."
);
}
return;
}
var slots = _schema.Slots; var slots = _schema.Slots;
try
{
var count = reader.ReadInt32(); var count = reader.ReadInt32();
for (var record = 0; record < count; record++) for (var record = 0; record < count; record++)
{ {
@@ -80,6 +115,11 @@ public sealed class ReplicationClient
} }
var data = reader.ReadBytes(slot.Size); var data = reader.ReadBytes(slot.Size);
if (data.Length < slot.Size)
{
return; // снапшот оборван на полпути — дальше читать нечего
}
slot.Apply(entity, data, 0); slot.Apply(entity, data, 0);
} }
@@ -89,4 +129,9 @@ public sealed class ReplicationClient
} }
} }
} }
catch (EndOfStreamException)
{
// Структурно битый/усечённый снапшот — игнорируем остаток, соединение не роняем.
}
}
} }
@@ -98,6 +98,7 @@ public sealed class ReplicationServer
using var stream = new MemoryStream(); using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream); using var writer = new BinaryWriter(stream);
writer.Write(ReplicationMessage.Snapshot); writer.Write(ReplicationMessage.Snapshot);
writer.Write(ReplicationMessage.ProtocolVersion); // версия формата — клиент отвергает чужую
var countPosition = stream.Position; var countPosition = stream.Position;
writer.Write(0); // количество записей, допишем в конце writer.Write(0); // количество записей, допишем в конце
var records = 0; var records = 0;
@@ -169,6 +170,14 @@ public sealed class ReplicationServer
internal static class ReplicationMessage internal static class ReplicationMessage
{ {
internal const byte Snapshot = 1; 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 OpUpsert = 0;
internal const byte OpDespawn = 1; internal const byte OpDespawn = 1;
} }
+8
View File
@@ -101,6 +101,9 @@ public sealed class WebSocketClient : INetConnection, IDisposable
/// <summary>Closes the connection.</summary> /// <summary>Closes the connection.</summary>
public void Dispose() => Close(); 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() private async Task ReceiveLoop()
{ {
var buffer = new byte[64 * 1024]; var buffer = new byte[64 * 1024];
@@ -117,6 +120,11 @@ public sealed class WebSocketClient : INetConnection, IDisposable
break; break;
} }
if (message.Length + result.Count > MaxMessageBytes)
{
break; // сервер шлёт ненормально большое сообщение — рвём соединение
}
message.Write(buffer, 0, result.Count); message.Write(buffer, 0, result.Count);
if (result.EndOfMessage) if (result.EndOfMessage)
{ {
+114 -21
View File
@@ -10,13 +10,25 @@ namespace MrGameEng.Net;
/// dedicated servers. Accepting and reading happen on background tasks; the simulation /// dedicated servers. Accepting and reading happen on background tasks; the simulation
/// drains new connections with <see cref="TryAcceptConnection"/> and reads messages by /// drains new connections with <see cref="TryAcceptConnection"/> and reads messages by
/// polling each connection — nothing here touches the ECS world from another thread. /// 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> /// </summary>
public sealed class WebSocketServer : IDisposable 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> /// <summary>The port the server listens on.</summary>
public int Port { get; } 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> /// <summary>Snapshot of currently open connections.</summary>
public IReadOnlyList<INetConnection> Connections public IReadOnlyList<INetConnection> Connections
{ {
@@ -36,10 +48,22 @@ public sealed class WebSocketServer : IDisposable
private int _nextConnectionId; private int _nextConnectionId;
private bool _started; private bool _started;
/// <summary>Creates a server for <paramref name="port"/> on all interfaces. Call <see cref="Start"/> to listen.</summary> /// <summary>
public WebSocketServer(int port) /// 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; Port = port;
HeartbeatInterval = heartbeatInterval ?? TimeSpan.FromSeconds(10);
IdleTimeout = idleTimeout ?? TimeSpan.FromSeconds(30);
_listener = new TcpListener(IPAddress.Any, port); _listener = new TcpListener(IPAddress.Any, port);
} }
@@ -54,6 +78,7 @@ public sealed class WebSocketServer : IDisposable
_started = true; _started = true;
_listener.Start(); _listener.Start();
Task.Run(AcceptLoop); Task.Run(AcceptLoop);
Task.Run(HeartbeatLoop);
Log.Info($"WebSocketServer listening on port {Port}"); 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) private void Handshake(TcpClient client)
{ {
try try
@@ -148,21 +215,30 @@ public sealed class WebSocketServer : IDisposable
public int Id { get; } public int Id { get; }
public bool IsOpen => !_closed; 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 TcpClient _client;
private readonly NetworkStream _stream; private readonly NetworkStream _stream;
private readonly ConcurrentQueue<byte[]> _inbox = new(); private readonly ConcurrentQueue<byte[]> _inbox = new();
private readonly object _sendLock = new(); private readonly object _sendLock = new();
private volatile bool _closed; private volatile bool _closed;
private long _lastActivityTicks;
internal ServerConnection(int id, TcpClient client) internal ServerConnection(int id, TcpClient client)
{ {
Id = id; Id = id;
_client = client; _client = client;
_stream = client.GetStream(); _stream = client.GetStream();
_lastActivityTicks = DateTime.UtcNow.Ticks;
} }
internal void StartReceiveLoop() => Task.Run(ReceiveLoop); 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) public void Send(ReadOnlySpan<byte> message)
{ {
if (_closed) 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 bool TryReceive(out byte[] message) => _inbox.TryDequeue(out message!);
public void Close() public void Close()
@@ -224,31 +321,18 @@ public sealed class WebSocketServer : IDisposable
break; break;
} }
// Любой кадр (включая pong) — признак жизни: сбрасываем счётчик простоя.
Volatile.Write(ref _lastActivityTicks, DateTime.UtcNow.Ticks);
switch (opcode) switch (opcode)
{ {
case WebSocketOpcode.Ping: case WebSocketOpcode.Ping:
lock (_sendLock) SendControl(WebSocketOpcode.Pong, payload);
{
var pong = WebSocketProtocol.EncodeFrame(
payload,
WebSocketOpcode.Pong
);
_stream.Write(pong, 0, pong.Length);
}
continue; continue;
case WebSocketOpcode.Pong: case WebSocketOpcode.Pong:
continue; continue;
case WebSocketOpcode.Close: case WebSocketOpcode.Close:
lock (_sendLock) SendControl(WebSocketOpcode.Close, []);
{
var close = WebSocketProtocol.EncodeFrame(
[],
WebSocketOpcode.Close
);
_stream.Write(close, 0, close.Length);
}
return; return;
} }
@@ -258,6 +342,15 @@ public sealed class WebSocketServer : IDisposable
pending.Clear(); pending.Clear();
} }
if (pending.Count + payload.Length > MaxMessageBytes)
{
Log.Warning(
$"WebSocketServer: connection #{Id} exceeded {MaxMessageBytes}-byte "
+ "message cap — closing"
);
return; // finally закроет соединение
}
pending.AddRange(payload); pending.AddRange(payload);
if (fin && pendingOpcode == WebSocketOpcode.Binary) if (fin && pendingOpcode == WebSocketOpcode.Binary)
{ {
@@ -23,4 +23,41 @@ public static class Suitability
var z = (value - optimum) / tolerance; var z = (value - optimum) / tolerance;
return MathF.Exp(-0.5f * z * z); 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.Throws<KeyNotFoundException>(() => database.Get<AnimalDef>("Dodo"));
Assert.False(database.TryGet<AnimalDef>("Dodo", out _)); 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); 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] [Fact]
public void Pause_DoesNotAdvanceTheCalendar() public void Pause_DoesNotAdvanceTheCalendar()
{ {
@@ -81,4 +81,15 @@ public class ClimateTests
Assert.Equal(2, climate.Year); Assert.Equal(2, climate.Year);
Assert.Equal(0, climate.DayOfYear); 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"); var formula = Formula.Compile("x + 1");
Assert.Throws<FormulaException>(() => formula.Evaluate()); 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)); 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] [Fact]
public void Mapping_CentersVirtualResolutionInWiderWindow() public void Mapping_CentersVirtualResolutionInWiderWindow()
{ {
@@ -1,3 +1,4 @@
using Microsoft.Xna.Framework;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.Lighting; using MrGameEng.Lighting;
using Xunit; using Xunit;
@@ -39,6 +40,25 @@ public class DayNightTests
Assert.True(dawn < mid && mid < noon, "daylight should rise from dawn to noon"); 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] [Fact]
public void Ambient_DarkerAtNightThanAtNoon() public void Ambient_DarkerAtNightThanAtNoon()
{ {
@@ -1,3 +1,4 @@
using Microsoft.Xna.Framework;
using MrGameEng.Lighting; using MrGameEng.Lighting;
using Xunit; using Xunit;
@@ -54,6 +55,29 @@ public class LightmapBuilderTests
Assert.Equal(0f, light[5], 5); // за препятствием — тень 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] [Fact]
public void Values_StayWithinUnitRange() 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;
}
}
+77 -2
View File
@@ -103,8 +103,8 @@ public class ReplicationTests
server.Send([connection]); server.Send([connection]);
Assert.True(connection.Sent.TryDequeue(out var delta)); Assert.True(connection.Sent.TryDequeue(out var delta));
// Запись: type(1) + count(4) + netId(4) + op(1) + mask(4) + TestPosition(8) — без TestHealth. // Запись: type(1) + version(1) + count(4) + netId(4) + op(1) + mask(4) + TestPosition(8) — без TestHealth.
Assert.Equal(22, delta!.Length); Assert.Equal(23, delta!.Length);
client.Apply(delta); client.Apply(delta);
var replicated = FindByNetId(clientStore, 1); var replicated = FindByNetId(clientStore, 1);
@@ -185,6 +185,81 @@ public class ReplicationTests
Assert.Equal(1, spawns); 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) private static Entity FindByNetId(EntityStore store, int netId)
{ {
foreach (var entity in store.Entities) foreach (var entity in store.Entities)
@@ -48,4 +48,45 @@ public class SuitabilityTests
Assert.Equal(1f, Suitability.Gaussian(5f, 5f, 0f)); Assert.Equal(1f, Suitability.Gaussian(5f, 5f, 0f));
Assert.Equal(0f, Suitability.Gaussian(6f, 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);
}
}
} }