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
+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();
}
}
}
}