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>
This commit is contained in:
Leonid Pershin
2026-06-12 23:35:57 +03:00
co-authored by Claude Fable 5
parent 10898b08a0
commit 3438ed77f6
16 changed files with 1492 additions and 0 deletions
@@ -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;
}
}