Compare commits

...
2 Commits
Author SHA1 Message Date
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
Leonid PershinandClaude Fable 5 3438ed77f6 Add MrGameEng.Net: WebSocket transport + delta component replication
CI / build-test (push) Successful in 1m16s
Browsers can't speak UDP, so WebSocket is the engine's one transport.
The server side is a dependency-free RFC 6455 implementation over
TcpListener (handshake, frame codec with masking and fragmentation,
ping/pong — unit-tested against the RFC example vectors); the client
wraps ClientWebSocket, which works on desktop and maps to the browser
WebSocket in Blazor WASM. Both sit behind the poll-based INetConnection
so simulation systems drain messages from their own thread; client
sends are chained fire-and-forget (no blocking — wasm-safe).

Replication is server-authoritative: games register unmanaged component
types in a ReplicationSchema (same order both sides, up to 32 types),
ReplicationServer snapshots entities carrying NetId once per send and
ships each connection only the components that changed since its last
snapshot — a reliable ordered transport needs no acks for deltas. New
connections receive the full state through the same path; despawns are
tracked by set difference. ReplicationClient applies snapshots to a
local EntityStore and raises EntitySpawned so the game can decorate
replicated entities with presentation components.

Covered by 16 tests including a real loopback exchange between
WebSocketClient and WebSocketServer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:35:57 +03:00
23 changed files with 2134 additions and 0 deletions
+7
View File
@@ -64,6 +64,13 @@ Engine libraries (each feature is a namespaced subfolder of its host):
**Collisions** (`MrGameEng.Collisions`: `Collider` component, spatial hash rebuilt per
tick, pairs/queries/raycast, `scene.UseCollisions()` after movement systems).
`Core`, `Graphics` (Collisions needs `Transform2D`, `RectF`).
- **`Net`** — multiplayer building blocks, browser-compatible by design: a dependency-free
RFC 6455 WebSocket server over `TcpListener` (browsers can't speak UDP, so WebSocket is
the engine's one transport), a `ClientWebSocket`-based client (works in Blazor WASM),
both behind the poll-based `INetConnection`; server-authoritative component replication
(`ReplicationSchema` of unmanaged components, `ReplicationServer` sending per-connection
deltas — no acks needed over a reliable ordered transport, `ReplicationClient` applying
snapshots to a local store, `NetId`). → `Core`.
- **`UI`** — Myra integration (`scene.UseUI()` after `UseRenderer2D()`),
**DevConsole** (`MrGameEng.DevConsole`: in-game console capturing `Core.Log`,
`scene.UseDevConsole()` last in OnLoad) and **Inspector** (`MrGameEng.Inspector`: a
+30
View File
@@ -43,6 +43,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host", "src\MrGam
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host.Tests", "tests\MrGameEng.Host.Tests\MrGameEng.Host.Tests.csproj", "{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net", "src\MrGameEng.Net\MrGameEng.Net.csproj", "{A4E754C7-C5FD-43A2-B345-34152D3A22D1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net.Tests", "tests\MrGameEng.Net.Tests\MrGameEng.Net.Tests.csproj", "{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -257,6 +261,30 @@ Global
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x64.Build.0 = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x86.ActiveCfg = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x86.Build.0 = Release|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x64.ActiveCfg = Debug|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x64.Build.0 = Debug|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x86.ActiveCfg = Debug|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x86.Build.0 = Debug|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|Any CPU.Build.0 = Release|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x64.ActiveCfg = Release|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x64.Build.0 = Release|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x86.ActiveCfg = Release|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x86.Build.0 = Release|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|Any CPU.Build.0 = Debug|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x64.ActiveCfg = Debug|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x64.Build.0 = Debug|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x86.ActiveCfg = Debug|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x86.Build.0 = Debug|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|Any CPU.ActiveCfg = Release|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|Any CPU.Build.0 = Release|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x64.ActiveCfg = Release|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x64.Build.0 = Release|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x86.ActiveCfg = Release|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -279,5 +307,7 @@ Global
{4407F6E6-0B65-41A3-ADFA-B78684A9B918} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{59818072-0D2B-4007-A50F-1343FA189EC6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{A4E754C7-C5FD-43A2-B345-34152D3A22D1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
+2
View File
@@ -39,6 +39,7 @@
| `MrGameEng.Audio` | Звуковые эффекты и музыка (NVorbis); `AudioManager` с `SoundVolume`/`MasterVolume` (одна ручка на эффекты и музыку) |
| `MrGameEng.Content` | Пайплайн контента. **Assets** (`MrGameEng.Assets`): runtime-загрузка без Content Pipeline, кэш, `AssetRef<T>`. **Atlases** (`MrGameEng.Atlases`): текстурные атласы — сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`), CLI `tools/MrGameEng.AtlasTool`. **Mods** (`MrGameEng.Mods`): система модов — порядок загрузки, JSON-дефы, локализация, слияние деревьев контента |
| `MrGameEng.Simulation` | Детерминированные геймплей-примитивы без данных мира. **Pathfinding** (`MrGameEng.Pathfinding`): A*, Dijkstra, BFS, flow fields по гриду. **AI** (`MrGameEng.AI`): utility-ИИ — кривые отклика, соображения, действия, выбор (`UtilityAi<TContext>`), `Blackboard`. **Collisions** (`MrGameEng.Collisions`): компонент `Collider`, spatial hash, пары/запросы/raycast |
| `MrGameEng.Net` | Мультиплеер, совместимый с браузером по построению: WebSocket-сервер (RFC 6455 поверх `TcpListener`, без зависимостей — браузер не умеет UDP, поэтому транспорт движка один — WebSocket), клиент на `ClientWebSocket` (работает в Blazor WASM), оба за poll-интерфейсом `INetConnection`; server-authoritative репликация компонентов: `ReplicationSchema` (unmanaged-компоненты, до 32 типов), `ReplicationServer` (пер-соединенческие дельты против последнего отправленного — ack не нужны поверх надёжного упорядоченного транспорта), `ReplicationClient` (применение снапшотов в локальный `EntityStore`), компонент `NetId` |
| `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг. **DevConsole** (`MrGameEng.DevConsole`): ингейм-консоль — логи `Log`, команды, история, автодополнение. **Inspector** (`MrGameEng.Inspector`): ECS-дебагер в духе Chrome DevTools — дерево сущностей по архетипам, компоненты/поля с правкой простых полей, выбор кликом по миру с подсветкой, вкладка перфа рендера (`scene.UseInspector(renderer)`, F1) |
| `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов (отдельный анализатор netstandard2.0) |
@@ -72,6 +73,7 @@
```
MrGameEng.Host ─┐
MrGameEng.Audio ─┤
MrGameEng.Net ─┤
MrGameEng.Graphics ─┼──► MrGameEng.Core ──► Friflo.Engine.ECS
MrGameEng.Content ─┤
+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;
}
}
+190
View File
@@ -0,0 +1,190 @@
namespace MrGameEng.Genetics;
/// <summary>
/// An individual's managed genome: a variable-composition map from gene id to the
/// <see cref="Allele"/> pair it carries. Because composition is open, two organisms need not share
/// the same gene set and a genome can gain "foreign" genes — the basis for arbitrary hybrids. The
/// genome is generated from a set of <see cref="GeneDef"/>s, bred meiotically with mutation, and
/// expressed into phenotype values; all randomness flows through a caller-owned seeded
/// <see cref="Random"/> so the simulation stays deterministic.
/// </summary>
public sealed class Genome
{
private readonly Dictionary<string, Allele> _alleles;
/// <summary>Creates an empty genome.</summary>
public Genome() => _alleles = new Dictionary<string, Allele>(StringComparer.Ordinal);
/// <summary>Creates a genome from an existing allele map (copied).</summary>
public Genome(IReadOnlyDictionary<string, Allele> alleles) =>
_alleles = new Dictionary<string, Allele>(alleles, StringComparer.Ordinal);
/// <summary>The carried genes and their allele pairs.</summary>
public IReadOnlyDictionary<string, Allele> Alleles => _alleles;
/// <summary>Whether the genome carries the gene <paramref name="geneId"/>.</summary>
public bool Has(string geneId) => _alleles.ContainsKey(geneId);
/// <summary>Gets or sets the allele pair for <paramref name="geneId"/>.</summary>
public Allele this[string geneId]
{
get => _alleles[geneId];
set => _alleles[geneId] = value;
}
/// <summary>Removes a gene from the genome; returns whether it was present.</summary>
public bool Remove(string geneId) => _alleles.Remove(geneId);
/// <summary>
/// Expresses the gene's phenotype value: the mean of the alleles for a numeric gene, the
/// dominant (lower) allele for a discrete one. Throws if the genome does not carry the gene.
/// </summary>
public float Express(GeneDef gene)
{
if (!_alleles.TryGetValue(gene.DefName, out var allele))
{
throw new KeyNotFoundException($"Genome does not carry gene '{gene.DefName}'.");
}
return gene.Kind == GeneKind.Numeric ? allele.Mean : allele.Dominant;
}
/// <summary>Builds the allele map for serialization (a copy).</summary>
public Dictionary<string, Allele> ToDictionary() => new(_alleles, StringComparer.Ordinal);
/// <summary>
/// Generates a fresh genome carrying every gene in <paramref name="genes"/>, each allele drawn
/// independently around the gene's default with its spread (numeric) or from its variant
/// distribution (discrete).
/// </summary>
public static Genome Generate(IEnumerable<GeneDef> genes, Random random)
{
var genome = new Genome();
foreach (var gene in genes)
{
genome._alleles[gene.DefName] = new Allele(
GenerateAllele(gene, random),
GenerateAllele(gene, random)
);
}
return genome;
}
/// <summary>
/// Breeds a child genome from two parents (meiosis): the child carries every gene either parent
/// has. For a gene both carry, one allele is drawn from each parent; for a gene only one parent
/// carries, it is inherited (from that parent, on both sides) with 50% probability. Every
/// inherited allele may then mutate per its <see cref="GeneDef"/>. <paramref name="registry"/>
/// supplies the def for each gene id; genes absent from it are skipped.
/// </summary>
public static Genome Breed(
Genome a,
Genome b,
IReadOnlyDictionary<string, GeneDef> registry,
Random random
)
{
var child = new Genome();
foreach (var geneId in UnionKeys(a, b))
{
if (!registry.TryGetValue(geneId, out var gene))
{
continue;
}
var inA = a.Has(geneId);
var inB = b.Has(geneId);
if (inA && inB)
{
child._alleles[geneId] = new Allele(
Meiosis(gene, a[geneId], random),
Meiosis(gene, b[geneId], random)
);
}
else if (random.NextSingle() < 0.5f)
{
var parent = inA ? a : b;
child._alleles[geneId] = new Allele(
Meiosis(gene, parent[geneId], random),
Meiosis(gene, parent[geneId], random)
);
}
}
return child;
}
// One inherited allele: pick one of the parent slot's two alleles, then maybe mutate it.
private static float Meiosis(GeneDef gene, Allele parent, Random random)
{
var inherited = random.NextSingle() < 0.5f ? parent.A : parent.B;
if (random.NextSingle() >= gene.MutationChance)
{
return inherited;
}
if (gene.Kind == GeneKind.Discrete)
{
return PickVariant(gene, random);
}
var shifted =
inherited
+ (random.NextSingle() * 2f - 1f) * gene.MutationMagnitude * MathF.Abs(inherited);
return Math.Clamp(shifted, gene.Min, gene.Max);
}
private static float GenerateAllele(GeneDef gene, Random random)
{
if (gene.Kind == GeneKind.Discrete)
{
return PickVariant(gene, random);
}
var value =
gene.Default + (random.NextSingle() * 2f - 1f) * gene.Spread * MathF.Abs(gene.Default);
return Math.Clamp(value, gene.Min, gene.Max);
}
private static float PickVariant(GeneDef gene, Random random)
{
var variants = Math.Max(1, gene.Variants);
if (gene.VariantWeights.Length != variants)
{
return random.Next(variants);
}
var total = 0f;
foreach (var w in gene.VariantWeights)
{
total += MathF.Max(0f, w);
}
if (total <= 0f)
{
return random.Next(variants);
}
var roll = random.NextSingle() * total;
for (var i = 0; i < variants; i++)
{
roll -= MathF.Max(0f, gene.VariantWeights[i]);
if (roll < 0f)
{
return i;
}
}
return variants - 1;
}
// Deterministic union of both parents' gene ids (ordered) so breeding is reproducible.
private static IEnumerable<string> UnionKeys(Genome a, Genome b)
{
var keys = new SortedSet<string>(StringComparer.Ordinal);
keys.UnionWith(a._alleles.Keys);
keys.UnionWith(b._alleles.Keys);
return keys;
}
}
@@ -0,0 +1,75 @@
using MrGameEng.Formulas;
namespace MrGameEng.Genetics;
/// <summary>
/// Computes the trait layer — the phenotype the simulation actually reads — from a
/// <see cref="Genome"/>. Each gene's <see cref="GeneDef.Effects"/> formulas are evaluated and their
/// results summed per trait name, so systems never touch genes directly: one fruiting system reads
/// a <c>fruitYield</c> trait whether it comes from a tree or a human carrying a "fruit" gene.
/// Formulas see the variable <c>value</c> (the contributing gene's expressed phenotype), any other
/// carried gene's id, and whatever environment variables the caller supplies.
/// </summary>
public static class Phenotype
{
/// <summary>
/// Evaluates every carried gene's effects against the genome and an optional
/// <paramref name="environment"/>, summing contributions into a trait map. Genes missing from
/// <paramref name="registry"/> are skipped.
/// </summary>
public static Dictionary<string, float> Compute(
Genome genome,
IReadOnlyDictionary<string, GeneDef> registry,
IFormulaContext? environment = null
)
{
var traits = new Dictionary<string, float>(StringComparer.Ordinal);
var context = new GenomeContext(genome, registry, environment);
foreach (var geneId in genome.Alleles.Keys)
{
if (!registry.TryGetValue(geneId, out var gene) || gene.CompiledEffects.Count == 0)
{
continue;
}
context.Self = genome.Express(gene);
foreach (var (trait, formula) in gene.CompiledEffects)
{
traits[trait] = traits.GetValueOrDefault(trait) + formula.Evaluate(context);
}
}
return traits;
}
// Resolves formula variables for a gene effect: 'value' is the current gene's phenotype, any
// carried gene's id resolves to its phenotype, anything else falls through to the environment.
private sealed class GenomeContext(
Genome genome,
IReadOnlyDictionary<string, GeneDef> registry,
IFormulaContext? environment
) : IFormulaContext
{
public float Self;
public float Resolve(string name)
{
if (name == "value")
{
return Self;
}
if (genome.Has(name) && registry.TryGetValue(name, out var gene))
{
return genome.Express(gene);
}
if (environment is not null)
{
return environment.Resolve(name);
}
throw new FormulaException($"Unknown variable '{name}' while computing traits.");
}
}
}
+2
View File
@@ -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>
+25
View File
@@ -0,0 +1,25 @@
namespace MrGameEng.Net;
/// <summary>
/// A bidirectional, reliable, ordered binary message channel (a WebSocket under the hood).
/// Receiving is poll-based to fit the simulation loop: incoming messages queue up on a
/// background reader and are drained with <see cref="TryReceive"/> from the tick. Sending
/// never blocks the caller. Implementations are safe to use from one simulation thread.
/// </summary>
public interface INetConnection
{
/// <summary>Connection id, unique within its owner (server-assigned; 0 for a client's own connection).</summary>
int Id { get; }
/// <summary>False once the peer disconnected or the connection failed; sends become no-ops.</summary>
bool IsOpen { get; }
/// <summary>Queues one binary message for delivery. No-op when the connection is closed.</summary>
void Send(ReadOnlySpan<byte> message);
/// <summary>Dequeues the next received binary message, if any.</summary>
bool TryReceive(out byte[] message);
/// <summary>Closes the connection.</summary>
void Close();
}
+18
View File
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Friflo.Engine.ECS" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Net.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+14
View File
@@ -0,0 +1,14 @@
using Friflo.Engine.ECS;
namespace MrGameEng.Net;
/// <summary>
/// Marks an entity as replicated and identifies it across the network. The server assigns
/// values (see <see cref="ReplicationServer.NextNetId"/>); the client creates a local
/// entity with the same <see cref="Value"/> when the first snapshot arrives.
/// </summary>
public struct NetId : IComponent
{
/// <summary>Network-wide entity id, unique per server world.</summary>
public int Value;
}
@@ -0,0 +1,92 @@
using Friflo.Engine.ECS;
namespace MrGameEng.Net;
/// <summary>
/// Client side of replication: applies snapshot messages from a
/// <see cref="ReplicationServer"/> to a local <see cref="EntityStore"/>. Unknown net ids
/// spawn local entities (carrying <see cref="NetId"/>), known ones get their changed
/// components overwritten, despawns delete. The game decorates replicated entities with
/// presentation components (sprites etc.) on top — replication never touches types outside
/// its <see cref="ReplicationSchema"/>.
/// </summary>
public sealed class ReplicationClient
{
/// <summary>Number of replicated entities currently alive locally.</summary>
public int EntityCount => _entities.Count;
/// <summary>Raised after an entity is created from a snapshot. Hook presentation setup here.</summary>
public event Action<Entity>? EntitySpawned;
private readonly ReplicationSchema _schema;
private readonly EntityStore _store;
private readonly Dictionary<int, Entity> _entities = [];
/// <summary>Creates a replication client writing into <paramref name="store"/>.</summary>
public ReplicationClient(ReplicationSchema schema, EntityStore store)
{
_schema = schema;
_store = store;
}
/// <summary>Applies every message queued on <paramref name="connection"/>.</summary>
public void Pump(INetConnection connection)
{
while (connection.TryReceive(out var message))
{
Apply(message);
}
}
/// <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)
{
return; // незнакомый тип сообщения — пропускаем, это не снапшот
}
var slots = _schema.Slots;
var count = reader.ReadInt32();
for (var record = 0; record < count; record++)
{
var netId = reader.ReadInt32();
var op = reader.ReadByte();
if (op == ReplicationMessage.OpDespawn)
{
if (_entities.Remove(netId, out var dead))
{
dead.DeleteEntity();
}
continue;
}
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;
}
foreach (var slot in slots)
{
if ((mask & (1u << slot.Bit)) == 0)
{
continue;
}
var data = reader.ReadBytes(slot.Size);
slot.Apply(entity, data, 0);
}
if (spawned)
{
EntitySpawned?.Invoke(entity);
}
}
}
}
@@ -0,0 +1,64 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Friflo.Engine.ECS;
namespace MrGameEng.Net;
/// <summary>
/// The set of component types a game replicates, registered in the same order on the
/// server and every client (the order defines the wire ids). Components must be
/// unmanaged structs — they are blitted to the wire as raw bytes, so server and client
/// must run on the same engine version. Up to 32 types.
/// </summary>
public sealed class ReplicationSchema
{
internal sealed class ComponentSlot
{
public required int Bit;
public required int Size;
public required Func<Entity, byte[], bool> TryWrite;
public required Action<Entity, byte[], int> Apply;
}
internal readonly List<ComponentSlot> Slots = [];
/// <summary>
/// Registers component type <typeparamref name="T"/> for replication. Returns this
/// schema for fluent chaining.
/// </summary>
public ReplicationSchema Register<T>()
where T : unmanaged, IComponent
{
if (Slots.Count == 32)
{
throw new InvalidOperationException(
"ReplicationSchema supports at most 32 component types."
);
}
var size = Unsafe.SizeOf<T>();
Slots.Add(
new ComponentSlot
{
Bit = Slots.Count,
Size = size,
TryWrite = (entity, buffer) =>
{
if (!entity.HasComponent<T>())
{
return false;
}
MemoryMarshal.Write(buffer, in entity.GetComponent<T>());
return true;
},
Apply = (entity, data, offset) =>
{
var value = MemoryMarshal.Read<T>(data.AsSpan(offset, size));
entity.AddComponent(value);
},
}
);
return this;
}
}
@@ -0,0 +1,174 @@
using Friflo.Engine.ECS;
namespace MrGameEng.Net;
/// <summary>
/// Server-authoritative component replication. Each call to <see cref="Send"/> snapshots
/// every entity carrying <see cref="NetId"/> and sends each connection only what changed
/// since that connection's previous snapshot (per-component deltas; a new connection gets
/// the full state the same way). Deltas need no acknowledgements because the transport is
/// reliable and ordered. Call at the desired send rate (e.g. every Nth simulation tick),
/// from the simulation thread.
/// </summary>
public sealed class ReplicationServer
{
private sealed class ConnectionState
{
// По netId: последний отправленный блоб каждого зарегистрированного компонента.
public readonly Dictionary<int, byte[]?[]> LastSent = [];
}
private readonly ReplicationSchema _schema;
private readonly ArchetypeQuery<NetId> _query;
private readonly Dictionary<INetConnection, ConnectionState> _states = [];
// Снапшот текущего тика, переиспользуется между соединениями.
private readonly List<(int NetId, byte[]?[] Components)> _current = [];
private readonly HashSet<int> _currentIds = [];
private int _nextNetId;
/// <summary>Creates a replication server over <paramref name="store"/>.</summary>
public ReplicationServer(ReplicationSchema schema, EntityStore store)
{
_schema = schema;
_query = store.Query<NetId>();
}
/// <summary>Allocates the next free network id for a newly spawned replicated entity.</summary>
public int NextNetId() => ++_nextNetId;
/// <summary>
/// Snapshots the world once and sends per-connection deltas. Closed connections are
/// forgotten; brand-new ones receive the full state.
/// </summary>
public void Send(IReadOnlyList<INetConnection> connections)
{
CaptureCurrentState();
foreach (var connection in connections)
{
if (!connection.IsOpen)
{
continue;
}
if (!_states.TryGetValue(connection, out var state))
{
state = new ConnectionState();
_states[connection] = state;
}
var message = BuildDelta(state);
if (message is not null)
{
connection.Send(message);
}
}
// Забываем состояние умерших соединений, чтобы не копить мусор.
foreach (var dead in _states.Keys.Where(c => !c.IsOpen).ToList())
{
_states.Remove(dead);
}
}
private void CaptureCurrentState()
{
_current.Clear();
_currentIds.Clear();
var slots = _schema.Slots;
_query.ForEachEntity(
(ref NetId netId, Entity entity) =>
{
var components = new byte[]?[slots.Count];
foreach (var slot in slots)
{
var buffer = new byte[slot.Size];
components[slot.Bit] = slot.TryWrite(entity, buffer) ? buffer : null;
}
_current.Add((netId.Value, components));
_currentIds.Add(netId.Value);
}
);
}
private byte[]? BuildDelta(ConnectionState state)
{
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream);
writer.Write(ReplicationMessage.Snapshot);
var countPosition = stream.Position;
writer.Write(0); // количество записей, допишем в конце
var records = 0;
foreach (var (netId, components) in _current)
{
if (!state.LastSent.TryGetValue(netId, out var lastSent))
{
lastSent = new byte[]?[components.Length];
state.LastSent[netId] = lastSent;
}
uint mask = 0;
for (var bit = 0; bit < components.Length; bit++)
{
var current = components[bit];
if (current is null)
{
continue;
}
if (lastSent[bit] is null || !current.AsSpan().SequenceEqual(lastSent[bit]))
{
mask |= 1u << bit;
lastSent[bit] = current;
}
}
if (mask == 0)
{
continue;
}
writer.Write(netId);
writer.Write(ReplicationMessage.OpUpsert);
writer.Write(mask);
for (var bit = 0; bit < components.Length; bit++)
{
if ((mask & (1u << bit)) != 0)
{
writer.Write(components[bit]!);
}
}
records++;
}
// Сущности, которые соединение знает, а в мире их больше нет.
foreach (var known in state.LastSent.Keys.Where(id => !_currentIds.Contains(id)).ToList())
{
state.LastSent.Remove(known);
writer.Write(known);
writer.Write(ReplicationMessage.OpDespawn);
records++;
}
if (records == 0)
{
return null;
}
stream.Position = countPosition;
writer.Write(records);
return stream.ToArray();
}
}
/// <summary>Wire constants shared by <see cref="ReplicationServer"/> and <see cref="ReplicationClient"/>.</summary>
internal static class ReplicationMessage
{
internal const byte Snapshot = 1;
internal const byte OpUpsert = 0;
internal const byte OpDespawn = 1;
}
+141
View File
@@ -0,0 +1,141 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
namespace MrGameEng.Net;
/// <summary>
/// Client side of <see cref="INetConnection"/>: a thin wrapper over the BCL
/// <see cref="ClientWebSocket"/>, which works on desktop and inside Blazor WebAssembly
/// (where it maps to the browser's WebSocket). Receiving runs on a background task into a
/// queue; sends are chained fire-and-forget so the caller — and the browser's single
/// thread — never blocks.
/// </summary>
public sealed class WebSocketClient : INetConnection, IDisposable
{
/// <inheritdoc />
public int Id => 0;
/// <inheritdoc />
public bool IsOpen => !_closed && _socket.State == WebSocketState.Open;
private readonly ClientWebSocket _socket;
private readonly ConcurrentQueue<byte[]> _inbox = new();
private readonly CancellationTokenSource _shutdown = new();
private Task _sendTail = Task.CompletedTask;
private volatile bool _closed;
private WebSocketClient(ClientWebSocket socket) => _socket = socket;
/// <summary>
/// Connects to <paramref name="uri"/> (ws:// or wss://) and starts the receive loop.
/// </summary>
public static async Task<WebSocketClient> ConnectAsync(
Uri uri,
CancellationToken cancellationToken = default
)
{
var socket = new ClientWebSocket();
await socket.ConnectAsync(uri, cancellationToken).ConfigureAwait(false);
var client = new WebSocketClient(socket);
_ = Task.Run(client.ReceiveLoop);
return client;
}
/// <inheritdoc />
public void Send(ReadOnlySpan<byte> message)
{
if (!IsOpen)
{
return;
}
var copy = message.ToArray();
// Отправки сцеплены в хвост: ClientWebSocket не терпит параллельных SendAsync,
// а блокировать поток нельзя (в wasm это смерть).
lock (_shutdown)
{
_sendTail = _sendTail.ContinueWith(
_ => SendCore(copy),
CancellationToken.None,
TaskContinuationOptions.None,
TaskScheduler.Default
);
}
}
private async Task SendCore(byte[] message)
{
try
{
await _socket
.SendAsync(
message,
WebSocketMessageType.Binary,
endOfMessage: true,
_shutdown.Token
)
.ConfigureAwait(false);
}
catch (Exception)
{
Close();
}
}
/// <inheritdoc />
public bool TryReceive(out byte[] message) => _inbox.TryDequeue(out message!);
/// <inheritdoc />
public void Close()
{
if (_closed)
{
return;
}
_closed = true;
_shutdown.Cancel();
_socket.Dispose();
}
/// <summary>Closes the connection.</summary>
public void Dispose() => Close();
private async Task ReceiveLoop()
{
var buffer = new byte[64 * 1024];
var message = new MemoryStream();
try
{
while (!_closed)
{
var result = await _socket
.ReceiveAsync(buffer, _shutdown.Token)
.ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
break;
}
message.Write(buffer, 0, result.Count);
if (result.EndOfMessage)
{
if (result.MessageType == WebSocketMessageType.Binary)
{
_inbox.Enqueue(message.ToArray());
}
message.SetLength(0);
}
}
}
catch (Exception)
{
// обрыв или закрытие — штатное завершение цикла
}
finally
{
Close();
}
}
}
+248
View File
@@ -0,0 +1,248 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace MrGameEng.Net;
/// <summary>WebSocket frame opcodes used by the server.</summary>
internal enum WebSocketOpcode : byte
{
Continuation = 0x0,
Text = 0x1,
Binary = 0x2,
Close = 0x8,
Ping = 0x9,
Pong = 0xA,
}
/// <summary>
/// Minimal RFC 6455 building blocks for the server side: the upgrade handshake and frame
/// encode/decode over a <see cref="Stream"/>. Kept free of sockets so the protocol logic is
/// unit-testable; <see cref="WebSocketServer"/> wires it to TCP.
/// </summary>
internal static class WebSocketProtocol
{
private const string HandshakeGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
/// <summary>Computes the Sec-WebSocket-Accept value for a client's Sec-WebSocket-Key.</summary>
internal static string AcceptKey(string secWebSocketKey)
{
var bytes = Encoding.ASCII.GetBytes(secWebSocketKey + HandshakeGuid);
return Convert.ToBase64String(SHA1.HashData(bytes));
}
/// <summary>
/// Reads the HTTP upgrade request from <paramref name="stream"/> (up to the blank line)
/// and extracts the Sec-WebSocket-Key header. Returns false on a malformed request.
/// </summary>
internal static bool TryReadHandshakeKey(Stream stream, out string key)
{
key = "";
var buffer = new byte[8 * 1024];
var length = 0;
// Читаем до конца заголовков (\r\n\r\n); запрос маленький, побайтовое чтение не больно.
while (length < buffer.Length)
{
var read = stream.Read(buffer, length, 1);
if (read == 0)
{
return false;
}
length++;
if (
length >= 4
&& buffer[length - 4] == (byte)'\r'
&& buffer[length - 3] == (byte)'\n'
&& buffer[length - 2] == (byte)'\r'
&& buffer[length - 1] == (byte)'\n'
)
{
break;
}
}
var request = Encoding.ASCII.GetString(buffer, 0, length);
foreach (var line in request.Split("\r\n"))
{
var separator = line.IndexOf(':');
if (separator < 0)
{
continue;
}
if (
line[..separator]
.Trim()
.Equals("Sec-WebSocket-Key", StringComparison.OrdinalIgnoreCase)
)
{
key = line[(separator + 1)..].Trim();
return key.Length > 0;
}
}
return false;
}
/// <summary>Writes the 101 Switching Protocols response completing the handshake.</summary>
internal static void WriteHandshakeResponse(Stream stream, string secWebSocketKey)
{
var response =
"HTTP/1.1 101 Switching Protocols\r\n"
+ "Upgrade: websocket\r\n"
+ "Connection: Upgrade\r\n"
+ $"Sec-WebSocket-Accept: {AcceptKey(secWebSocketKey)}\r\n"
+ "\r\n";
var bytes = Encoding.ASCII.GetBytes(response);
stream.Write(bytes, 0, bytes.Length);
}
/// <summary>
/// Encodes one complete (FIN) frame. Server frames are unmasked per RFC 6455;
/// <paramref name="maskKey"/> is for tests that emulate a client.
/// </summary>
internal static byte[] EncodeFrame(
ReadOnlySpan<byte> payload,
WebSocketOpcode opcode,
byte[]? maskKey = null
)
{
var masked = maskKey is not null;
var headerLength =
2
+ payload.Length switch
{
<= 125 => 0,
<= ushort.MaxValue => 2,
_ => 8,
};
var frame = new byte[headerLength + (masked ? 4 : 0) + payload.Length];
frame[0] = (byte)(0x80 | (byte)opcode);
switch (payload.Length)
{
case <= 125:
frame[1] = (byte)payload.Length;
break;
case <= ushort.MaxValue:
frame[1] = 126;
BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(2), (ushort)payload.Length);
break;
default:
frame[1] = 127;
BinaryPrimitives.WriteUInt64BigEndian(frame.AsSpan(2), (ulong)payload.Length);
break;
}
var offset = headerLength;
if (masked)
{
frame[1] |= 0x80;
maskKey!.CopyTo(frame, offset);
offset += 4;
for (var i = 0; i < payload.Length; i++)
{
frame[offset + i] = (byte)(payload[i] ^ maskKey[i % 4]);
}
}
else
{
payload.CopyTo(frame.AsSpan(offset));
}
return frame;
}
/// <summary>
/// Reads one frame. Returns false on a clean end of stream. Masked payloads are unmasked.
/// </summary>
internal static bool TryReadFrame(
Stream stream,
out WebSocketOpcode opcode,
out bool fin,
out byte[] payload
)
{
opcode = WebSocketOpcode.Close;
fin = true;
payload = [];
var header = new byte[2];
if (!TryReadExactly(stream, header))
{
return false;
}
fin = (header[0] & 0x80) != 0;
opcode = (WebSocketOpcode)(header[0] & 0x0F);
var masked = (header[1] & 0x80) != 0;
long length = header[1] & 0x7F;
if (length == 126)
{
var extended = new byte[2];
if (!TryReadExactly(stream, extended))
{
return false;
}
length = BinaryPrimitives.ReadUInt16BigEndian(extended);
}
else if (length == 127)
{
var extended = new byte[8];
if (!TryReadExactly(stream, extended))
{
return false;
}
length = (long)BinaryPrimitives.ReadUInt64BigEndian(extended);
}
if (length > MaxPayloadBytes)
{
return false; // защита от злонамеренной длины — соединение закроется
}
var maskKey = new byte[4];
if (masked && !TryReadExactly(stream, maskKey))
{
return false;
}
payload = new byte[length];
if (!TryReadExactly(stream, payload))
{
return false;
}
if (masked)
{
for (var i = 0; i < payload.Length; i++)
{
payload[i] ^= maskKey[i % 4];
}
}
return true;
}
/// <summary>Upper bound for a single frame payload accepted by the server.</summary>
internal const int MaxPayloadBytes = 16 * 1024 * 1024;
private static bool TryReadExactly(Stream stream, byte[] buffer)
{
var offset = 0;
while (offset < buffer.Length)
{
var read = stream.Read(buffer, offset, buffer.Length - offset);
if (read == 0)
{
return false;
}
offset += read;
}
return true;
}
}
+279
View File
@@ -0,0 +1,279 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using MrGameEng.Core;
namespace MrGameEng.Net;
/// <summary>
/// A dependency-free WebSocket server (RFC 6455 over <see cref="TcpListener"/>) for
/// 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.
/// </summary>
public sealed class WebSocketServer : IDisposable
{
/// <summary>The port the server listens on.</summary>
public int Port { get; }
/// <summary>Snapshot of currently open connections.</summary>
public IReadOnlyList<INetConnection> Connections
{
get
{
lock (_connections)
{
return _connections.Where(c => c.IsOpen).Cast<INetConnection>().ToArray();
}
}
}
private readonly TcpListener _listener;
private readonly List<ServerConnection> _connections = [];
private readonly ConcurrentQueue<ServerConnection> _accepted = new();
private readonly CancellationTokenSource _shutdown = new();
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)
{
Port = port;
_listener = new TcpListener(IPAddress.Any, port);
}
/// <summary>Starts listening and accepting connections in the background.</summary>
public void Start()
{
if (_started)
{
return;
}
_started = true;
_listener.Start();
Task.Run(AcceptLoop);
Log.Info($"WebSocketServer listening on port {Port}");
}
/// <summary>Dequeues a connection that completed its handshake since the last call.</summary>
public bool TryAcceptConnection(out INetConnection connection)
{
if (_accepted.TryDequeue(out var accepted))
{
connection = accepted;
return true;
}
connection = null!;
return false;
}
/// <summary>Stops listening and closes every connection.</summary>
public void Dispose()
{
_shutdown.Cancel();
_listener.Stop();
lock (_connections)
{
foreach (var connection in _connections)
{
connection.Close();
}
_connections.Clear();
}
}
private async Task AcceptLoop()
{
while (!_shutdown.IsCancellationRequested)
{
TcpClient client;
try
{
client = await _listener.AcceptTcpClientAsync(_shutdown.Token);
}
catch (Exception) when (_shutdown.IsCancellationRequested)
{
return;
}
catch (Exception exception)
{
Log.Warning($"WebSocketServer accept failed: {exception.Message}");
continue;
}
_ = Task.Run(() => Handshake(client));
}
}
private void Handshake(TcpClient client)
{
try
{
client.NoDelay = true;
var stream = client.GetStream();
if (!WebSocketProtocol.TryReadHandshakeKey(stream, out var key))
{
client.Dispose();
return;
}
WebSocketProtocol.WriteHandshakeResponse(stream, key);
var connection = new ServerConnection(
Interlocked.Increment(ref _nextConnectionId),
client
);
lock (_connections)
{
_connections.RemoveAll(c => !c.IsOpen);
_connections.Add(connection);
}
_accepted.Enqueue(connection);
connection.StartReceiveLoop();
Log.Info($"WebSocketServer: connection #{connection.Id} accepted");
}
catch (Exception exception)
{
Log.Warning($"WebSocketServer handshake failed: {exception.Message}");
client.Dispose();
}
}
private sealed class ServerConnection : INetConnection
{
public int Id { get; }
public bool IsOpen => !_closed;
private readonly TcpClient _client;
private readonly NetworkStream _stream;
private readonly ConcurrentQueue<byte[]> _inbox = new();
private readonly object _sendLock = new();
private volatile bool _closed;
internal ServerConnection(int id, TcpClient client)
{
Id = id;
_client = client;
_stream = client.GetStream();
}
internal void StartReceiveLoop() => Task.Run(ReceiveLoop);
public void Send(ReadOnlySpan<byte> message)
{
if (_closed)
{
return;
}
var frame = WebSocketProtocol.EncodeFrame(message, WebSocketOpcode.Binary);
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()
{
if (_closed)
{
return;
}
_closed = true;
try
{
_client.Dispose();
}
catch (Exception)
{
// соединение уже мертво — закрытие не должно бросать
}
}
private void ReceiveLoop()
{
var pending = new List<byte>();
var pendingOpcode = WebSocketOpcode.Binary;
try
{
while (!_closed)
{
if (
!WebSocketProtocol.TryReadFrame(
_stream,
out var opcode,
out var fin,
out var payload
)
)
{
break;
}
switch (opcode)
{
case WebSocketOpcode.Ping:
lock (_sendLock)
{
var pong = WebSocketProtocol.EncodeFrame(
payload,
WebSocketOpcode.Pong
);
_stream.Write(pong, 0, pong.Length);
}
continue;
case WebSocketOpcode.Pong:
continue;
case WebSocketOpcode.Close:
lock (_sendLock)
{
var close = WebSocketProtocol.EncodeFrame(
[],
WebSocketOpcode.Close
);
_stream.Write(close, 0, close.Length);
}
return;
}
if (opcode != WebSocketOpcode.Continuation)
{
pendingOpcode = opcode;
pending.Clear();
}
pending.AddRange(payload);
if (fin && pendingOpcode == WebSocketOpcode.Binary)
{
_inbox.Enqueue(pending.ToArray());
pending.Clear();
}
}
}
catch (Exception)
{
// обрыв соединения — штатный путь завершения цикла
}
finally
{
Close();
}
}
}
}
@@ -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,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Net\MrGameEng.Net.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,200 @@
using System.Collections.Concurrent;
using Friflo.Engine.ECS;
using MrGameEng.Net;
using Xunit;
namespace MrGameEng.Net.Tests;
public class ReplicationTests
{
private struct TestPosition : IComponent
{
public float X;
public float Y;
}
private struct TestHealth : IComponent
{
public int Value;
}
private sealed class FakeConnection : INetConnection
{
public int Id { get; init; }
public bool IsOpen { get; set; } = true;
public readonly ConcurrentQueue<byte[]> Sent = new();
public void Send(ReadOnlySpan<byte> message) => Sent.Enqueue(message.ToArray());
public bool TryReceive(out byte[] message) => Sent.TryDequeue(out message!);
public void Close() => IsOpen = false;
}
private static ReplicationSchema MakeSchema() =>
new ReplicationSchema().Register<TestPosition>().Register<TestHealth>();
[Fact]
public void FirstSnapshot_SpawnsEntities_OnTheClient()
{
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 = 10f, Y = 20f },
new TestHealth { Value = 7 }
);
serverStore.CreateEntity(
new NetId { Value = server.NextNetId() },
new TestPosition { X = -3f, Y = 4f }
);
server.Send([connection]);
client.Pump(connection);
Assert.Equal(2, client.EntityCount);
var first = FindByNetId(clientStore, 1);
Assert.Equal(10f, first.GetComponent<TestPosition>().X);
Assert.Equal(7, first.GetComponent<TestHealth>().Value);
var second = FindByNetId(clientStore, 2);
Assert.False(second.HasComponent<TestHealth>());
}
[Fact]
public void UnchangedWorld_SendsNothing()
{
var serverStore = new EntityStore();
var server = new ReplicationServer(MakeSchema(), serverStore);
var connection = new FakeConnection();
serverStore.CreateEntity(
new NetId { Value = server.NextNetId() },
new TestPosition { X = 1f, Y = 1f }
);
server.Send([connection]);
Assert.Single(connection.Sent);
server.Send([connection]);
Assert.Single(connection.Sent); // дельта пустая — второго сообщения нет
}
[Fact]
public void ChangedComponent_IsTheOnlyThingResent()
{
var serverStore = new EntityStore();
var clientStore = new EntityStore();
var server = new ReplicationServer(MakeSchema(), serverStore);
var client = new ReplicationClient(MakeSchema(), clientStore);
var connection = new FakeConnection();
var entity = serverStore.CreateEntity(
new NetId { Value = server.NextNetId() },
new TestPosition { X = 1f, Y = 1f },
new TestHealth { Value = 100 }
);
server.Send([connection]);
client.Pump(connection);
entity.AddComponent(new TestPosition { X = 5f, Y = 6f }); // здоровье не трогаем
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);
client.Apply(delta);
var replicated = FindByNetId(clientStore, 1);
Assert.Equal(5f, replicated.GetComponent<TestPosition>().X);
Assert.Equal(100, replicated.GetComponent<TestHealth>().Value);
}
[Fact]
public void DeletedEntity_DespawnsOnTheClient()
{
var serverStore = new EntityStore();
var clientStore = new EntityStore();
var server = new ReplicationServer(MakeSchema(), serverStore);
var client = new ReplicationClient(MakeSchema(), clientStore);
var connection = new FakeConnection();
var entity = serverStore.CreateEntity(
new NetId { Value = server.NextNetId() },
new TestPosition { X = 1f, Y = 1f }
);
server.Send([connection]);
client.Pump(connection);
Assert.Equal(1, client.EntityCount);
entity.DeleteEntity();
server.Send([connection]);
client.Pump(connection);
Assert.Equal(0, client.EntityCount);
}
[Fact]
public void LateJoiner_GetsFullState()
{
var serverStore = new EntityStore();
var server = new ReplicationServer(MakeSchema(), serverStore);
var early = new FakeConnection { Id = 1 };
var late = new FakeConnection { Id = 2 };
serverStore.CreateEntity(
new NetId { Value = server.NextNetId() },
new TestPosition { X = 9f, Y = 9f }
);
server.Send([early]);
server.Send([early, late]); // мир не менялся: early — тишина, late — полный стейт
Assert.Single(early.Sent);
Assert.Single(late.Sent);
var clientStore = new EntityStore();
var client = new ReplicationClient(MakeSchema(), clientStore);
client.Pump(late);
Assert.Equal(1, client.EntityCount);
Assert.Equal(9f, FindByNetId(clientStore, 1).GetComponent<TestPosition>().X);
}
[Fact]
public void EntitySpawned_FiresOncePerEntity()
{
var serverStore = new EntityStore();
var clientStore = new EntityStore();
var server = new ReplicationServer(MakeSchema(), serverStore);
var client = new ReplicationClient(MakeSchema(), clientStore);
var connection = new FakeConnection();
var spawns = 0;
client.EntitySpawned += _ => spawns++;
var entity = serverStore.CreateEntity(
new NetId { Value = server.NextNetId() },
new TestPosition { X = 1f, Y = 1f }
);
server.Send([connection]);
client.Pump(connection);
entity.AddComponent(new TestPosition { X = 2f, Y = 2f });
server.Send([connection]);
client.Pump(connection);
Assert.Equal(1, spawns);
}
private static Entity FindByNetId(EntityStore store, int netId)
{
foreach (var entity in store.Entities)
{
if (entity.HasComponent<NetId>() && entity.GetComponent<NetId>().Value == netId)
{
return entity;
}
}
throw new InvalidOperationException($"Entity with NetId {netId} not found.");
}
}
@@ -0,0 +1,93 @@
using System.Net;
using System.Net.Sockets;
using MrGameEng.Net;
using Xunit;
namespace MrGameEng.Net.Tests;
public class WebSocketLoopbackTests
{
[Fact]
public async Task ClientAndServer_ExchangeBinaryMessages_OverLoopback()
{
var port = FreePort();
using var server = new WebSocketServer(port);
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"
);
// Сервер → клиент.
connection.Send([1, 2, 3, 250]);
var received = await WaitFor(
() => client.TryReceive(out var m) ? m : null,
"client receive"
);
Assert.Equal(new byte[] { 1, 2, 3, 250 }, received);
// Клиент → сервер (ClientWebSocket маскирует кадры — сервер обязан размаскировать).
client.Send([9, 8, 7]);
var echoed = await WaitFor(
() => connection.TryReceive(out var m) ? m : null,
"server receive"
);
Assert.Equal(new byte[] { 9, 8, 7 }, echoed);
Assert.True(connection.IsOpen);
Assert.True(client.IsOpen);
}
[Fact]
public async Task ClosingTheClient_ClosesTheServerConnection()
{
var port = FreePort();
using var server = new WebSocketServer(port);
server.Start();
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"
);
client.Close();
await WaitFor(() => connection.IsOpen ? null : "closed", "server-side close");
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;
}
}
@@ -0,0 +1,88 @@
using MrGameEng.Net;
using Xunit;
namespace MrGameEng.Net.Tests;
public class WebSocketProtocolTests
{
[Fact]
public void AcceptKey_MatchesRfc6455Example()
{
// Пример рукопожатия прямо из RFC 6455, раздел 1.3.
Assert.Equal(
"s3pPLMBiTxaQ9kYGzzhZRbK+xOo=",
WebSocketProtocol.AcceptKey("dGhlIHNhbXBsZSBub25jZQ==")
);
}
[Theory]
[InlineData(0)]
[InlineData(125)]
[InlineData(126)]
[InlineData(70_000)]
public void Frame_Roundtrips_Unmasked(int payloadLength)
{
var payload = MakePayload(payloadLength);
var frame = WebSocketProtocol.EncodeFrame(payload, WebSocketOpcode.Binary);
using var stream = new MemoryStream(frame);
Assert.True(
WebSocketProtocol.TryReadFrame(stream, out var opcode, out var fin, out var decoded)
);
Assert.Equal(WebSocketOpcode.Binary, opcode);
Assert.True(fin);
Assert.Equal(payload, decoded);
}
[Fact]
public void Frame_Roundtrips_Masked()
{
var payload = MakePayload(1000);
var frame = WebSocketProtocol.EncodeFrame(
payload,
WebSocketOpcode.Binary,
maskKey: [0x12, 0x34, 0x56, 0x78]
);
using var stream = new MemoryStream(frame);
Assert.True(WebSocketProtocol.TryReadFrame(stream, out _, out _, out var decoded));
Assert.Equal(payload, decoded);
}
[Fact]
public void TryReadHandshakeKey_ExtractsHeader()
{
var request =
"GET /chat 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";
using var stream = new MemoryStream(System.Text.Encoding.ASCII.GetBytes(request));
Assert.True(WebSocketProtocol.TryReadHandshakeKey(stream, out var key));
Assert.Equal("dGhlIHNhbXBsZSBub25jZQ==", key);
}
[Fact]
public void TryReadFrame_TruncatedStream_ReturnsFalse()
{
var frame = WebSocketProtocol.EncodeFrame(MakePayload(100), WebSocketOpcode.Binary);
using var stream = new MemoryStream(frame, 0, frame.Length - 10);
Assert.False(WebSocketProtocol.TryReadFrame(stream, out _, out _, out _));
}
private static byte[] MakePayload(int length)
{
var payload = new byte[length];
for (var i = 0; i < length; i++)
{
payload[i] = (byte)(i * 31);
}
return payload;
}
}