Files
Leonid PershinandClaude Fable 5 3438ed77f6
CI / build-test (push) Successful in 1m16s
Add MrGameEng.Net: WebSocket transport + delta component replication
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

94 lines
2.7 KiB
C#

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;
}
}