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>
94 lines
2.7 KiB
C#
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;
|
|
}
|
|
}
|