using System.Net; using System.Net.Sockets; using CapySR.Common; using CapySR.GameServer.Game; using CapySR.GameServer.Net; using CapySR.Protocol; using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace CapySR.Tests; public class GatewayIntegrationTests { // one world for the whole class; loading the tables is the slow part private static GameWorld World => TestWorld.Current; [Fact] public async Task ClientCompletesHandshakeAndLogin() { if (!TestWorld.Available) { return; } var port = FreeUdpPort(); // Persist off: a test must not read or write the machine's saved profile var config = new ServerConfig { GameServer = { Host = "127.0.0.1", Port = port, Nickname = "Capybara", Persist = false } }; var handlers = HandlerRegistry.Build(NullLogger.Instance); var gateway = new KcpGateway(config, handlers, World, NullLogger.Instance); using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30)); var server = gateway.RunAsync(cancellation.Token); using var client = new TestClient(new IPEndPoint(IPAddress.Loopback, port)); await client.ConnectAsync(); var token = await client.RequestAsync(new PlayerGetTokenCsReq()); Assert.Equal(0u, token.Retcode); Assert.Equal(config.GameServer.Uid, token.Uid); var login = await client.RequestAsync( new PlayerLoginCsReq { LoginRandom = 4242 }); Assert.Equal(0u, login.Retcode); Assert.Equal(4242u, login.LoginRandom); Assert.Equal("Capybara", login.BasicInfo.Nickname); Assert.Equal(6u, login.BasicInfo.WorldLevel); var heartbeat = await client.RequestAsync( new PlayerHeartBeatCsReq { ClientTimeMs = 1234 }); Assert.Equal(1234u, heartbeat.ClientTimeMs); await cancellation.CancelAsync(); await Task.WhenAny(server, Task.Delay(2000)); } [Fact] public async Task UnmodelledRequestStillGetsAnswered() { if (!TestWorld.Available) { return; } var port = FreeUdpPort(); var config = new ServerConfig { GameServer = { Host = "127.0.0.1", Port = port, Persist = false } }; var handlers = HandlerRegistry.Build(NullLogger.Instance); var gateway = new KcpGateway(config, handlers, World, NullLogger.Instance); using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30)); var server = gateway.RunAsync(cancellation.Token); using var client = new TestClient(new IPEndPoint(IPAddress.Loopback, port)); await client.ConnectAsync(); var response = await client.RequestAsync(new GetBagCsReq()); Assert.NotNull(response); await cancellation.CancelAsync(); await Task.WhenAny(server, Task.Delay(2000)); } private static int FreeUdpPort() { using var probe = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); probe.Bind(new IPEndPoint(IPAddress.Loopback, 0)); return ((IPEndPoint)probe.LocalEndPoint!).Port; } } internal sealed class TestClient(IPEndPoint server) : IDisposable { private readonly Socket _socket = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); private readonly List _inbox = []; private Kcp.Kcp? _kcp; public async Task ConnectAsync() { var connect = new NetOperation(NetOperation.ConnectHead, 0, 0, 1234, NetOperation.ConnectTail); await _socket.SendToAsync(connect.ToArray(), SocketFlags.None, server); var buffer = new byte[1500]; var result = await ReceiveAsync(buffer, TimeSpan.FromSeconds(5)); Assert.True(NetOperation.TryRead(buffer.AsSpan(0, result), out var accept)); Assert.Equal(NetOperation.AcceptHead, accept.Head); Assert.Equal(NetOperation.AcceptTail, accept.Tail); _kcp = new Kcp.Kcp(accept.Param1, accept.Param2, data => _socket.SendTo(data, SocketFlags.None, server)); _kcp.SetNoDelay(true, 10, 2, true); _kcp.SetWindowSize(256, 256); } private uint _clock; private readonly List _arrival = []; // notifies the server pushed alongside the responses, in arrival order public IReadOnlyList Inbox => _inbox; // packet names exactly as they came off the wire, so ordering can be asserted public IReadOnlyList ArrivalOrder => _arrival; public async Task RequestAsync(TRequest request) where TRequest : Google.Protobuf.IMessage where TResponse : Google.Protobuf.IMessage, new() { var expected = ProtocolRegistry.GetCmdId(); _kcp!.Send(NetPacket.Create(request).ToArray()); _kcp.Flush(); var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); while (DateTime.UtcNow < deadline) { foreach (var packet in _inbox) { if (packet.CmdId == expected) { _inbox.Remove(packet); return packet.ParseBody(); } } await PumpOnceAsync(TimeSpan.FromMilliseconds(200)); } throw new TimeoutException($"never received {ProtocolRegistry.GetName(expected)}"); } // keep reading for a while so pushes that trail a response land in the inbox public async Task PumpAsync(TimeSpan duration) { var deadline = DateTime.UtcNow + duration; while (DateTime.UtcNow < deadline) { await PumpOnceAsync(TimeSpan.FromMilliseconds(50)); } } public List Take() where T : Google.Protobuf.IMessage, new() { var cmdId = ProtocolRegistry.GetCmdId(); var taken = new List(); foreach (var packet in _inbox.Where(p => p.CmdId == cmdId).ToList()) { taken.Add(packet.ParseBody()); _inbox.Remove(packet); } return taken; } public async Task ExpectAsync(TimeSpan? timeout = null) where T : Google.Protobuf.IMessage, new() { var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5)); while (DateTime.UtcNow < deadline) { var found = Take(); if (found.Count > 0) { return found[0]; } await PumpOnceAsync(TimeSpan.FromMilliseconds(100)); } throw new TimeoutException($"never received {typeof(T).Name}"); } public void ClearInbox() { _inbox.Clear(); _arrival.Clear(); } private async Task PumpOnceAsync(TimeSpan wait) { var buffer = new byte[1500]; var received = await ReceiveAsync(buffer, wait); if (received > 0) { _kcp!.Input(buffer.AsSpan(0, received)); while (_kcp.TryReceive(out var stream)) { var offset = 0; while (NetPacket.TryRead(stream.AsSpan(offset), out var packet, out var consumed)) { _inbox.Add(packet); _arrival.Add(packet.Name); offset += consumed; } } } _clock += 20; _kcp!.Update(_clock); } private async Task ReceiveAsync(byte[] buffer, TimeSpan timeout) { using var cancellation = new CancellationTokenSource(timeout); try { var result = await _socket.ReceiveFromAsync( buffer, SocketFlags.None, new IPEndPoint(IPAddress.Any, 0), cancellation.Token); return result.ReceivedBytes; } catch (OperationCanceledException) { return 0; } } public void Dispose() => _socket.Dispose(); }