Add MrGameEng.Net: WebSocket transport + delta component replication
CI / build-test (push) Successful in 1m16s
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:
co-authored by
Claude Fable 5
parent
10898b08a0
commit
3438ed77f6
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user