From 2674eb2a10ef377ca4a984d844752bbcd260ab6d Mon Sep 17 00:00:00 2001 From: Thomas Woischnig Date: Tue, 28 Nov 2023 00:57:50 +0100 Subject: [PATCH] =?UTF-8?q?Projektdateien=20hinzuf=C3=BCgen.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LobbyClient/LobbyClient.cs | 275 +++++++++++ LobbyClient/LobbyClient.csproj | 13 + LobbyClient/LobbyClientDisconnectReason.cs | 14 + LobbyClient/LobbyClientEvent.cs | 27 ++ LobbyClient/TcpClient.cs | 161 +++++++ LobbyClientTest/FakeGameHost.cs | 53 +++ LobbyClientTest/LobbyClientTest.csproj | 14 + LobbyClientTest/Program.cs | 184 ++++++++ LobbyServer.sln | 49 ++ LobbyServer/Lobby.cs | 29 ++ LobbyServer/LobbyServer.csproj | 15 + LobbyServer/Program.cs | 442 ++++++++++++++++++ LobbyServer/TcpLobbyServer.cs | 227 +++++++++ LobbyServerDto/BufferRental.cs | 28 ++ LobbyServerDto/LobbiesObserve.cs | 8 + LobbyServerDto/LobbiesStopObserve.cs | 7 + LobbyServerDto/LobbyCreate.cs | 20 + LobbyServerDto/LobbyDelete.cs | 9 + LobbyServerDto/LobbyHostInfo.cs | 13 + LobbyServerDto/LobbyInfo.cs | 13 + LobbyServerDto/LobbyNatPunchDone.cs | 11 + LobbyServerDto/LobbyRequestHostInfo.cs | 12 + LobbyServerDto/LobbyRequestNatPunch.cs | 17 + LobbyServerDto/LobbyServerDto.csproj | 14 + LobbyServerDto/LobbyUpdate.cs | 17 + LobbyServerDto/LobbyWrongPassword.cs | 8 + LobbyServerDto/NatPuncher.cs | 29 ++ LobbyServerDto/Properties/launchSettings.json | 1 + LobbyServerDto/ValidGameGuids.cs | 9 + .../LobbyMessageSourceGenerator.cs | 342 ++++++++++++++ .../LobbyServerSourceGenerator.csproj | 22 + .../Properties/launchSettings.json | 8 + 32 files changed, 2091 insertions(+) create mode 100644 LobbyClient/LobbyClient.cs create mode 100644 LobbyClient/LobbyClient.csproj create mode 100644 LobbyClient/LobbyClientDisconnectReason.cs create mode 100644 LobbyClient/LobbyClientEvent.cs create mode 100644 LobbyClient/TcpClient.cs create mode 100644 LobbyClientTest/FakeGameHost.cs create mode 100644 LobbyClientTest/LobbyClientTest.csproj create mode 100644 LobbyClientTest/Program.cs create mode 100644 LobbyServer.sln create mode 100644 LobbyServer/Lobby.cs create mode 100644 LobbyServer/LobbyServer.csproj create mode 100644 LobbyServer/Program.cs create mode 100644 LobbyServer/TcpLobbyServer.cs create mode 100644 LobbyServerDto/BufferRental.cs create mode 100644 LobbyServerDto/LobbiesObserve.cs create mode 100644 LobbyServerDto/LobbiesStopObserve.cs create mode 100644 LobbyServerDto/LobbyCreate.cs create mode 100644 LobbyServerDto/LobbyDelete.cs create mode 100644 LobbyServerDto/LobbyHostInfo.cs create mode 100644 LobbyServerDto/LobbyInfo.cs create mode 100644 LobbyServerDto/LobbyNatPunchDone.cs create mode 100644 LobbyServerDto/LobbyRequestHostInfo.cs create mode 100644 LobbyServerDto/LobbyRequestNatPunch.cs create mode 100644 LobbyServerDto/LobbyServerDto.csproj create mode 100644 LobbyServerDto/LobbyUpdate.cs create mode 100644 LobbyServerDto/LobbyWrongPassword.cs create mode 100644 LobbyServerDto/NatPuncher.cs create mode 100644 LobbyServerDto/Properties/launchSettings.json create mode 100644 LobbyServerDto/ValidGameGuids.cs create mode 100644 LobbyServerSourceGenerator/LobbyMessageSourceGenerator.cs create mode 100644 LobbyServerSourceGenerator/LobbyServerSourceGenerator.csproj create mode 100644 LobbyServerSourceGenerator/Properties/launchSettings.json diff --git a/LobbyClient/LobbyClient.cs b/LobbyClient/LobbyClient.cs new file mode 100644 index 0000000..fae50c3 --- /dev/null +++ b/LobbyClient/LobbyClient.cs @@ -0,0 +1,275 @@ +using LobbyServerDto; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Lobbies +{ + public class LobbyClient : IDisposable + { + TcpLobbbyClient tcpClient = new TcpLobbbyClient(); + private readonly ConcurrentQueue events = new ConcurrentQueue(); + BufferRental bufferRental = new BufferRental(4096); + public void Connect(string host, int port, CancellationToken cancellationToken) + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + using var cts = cancellationToken.Register(() => + { + tcpClient.Stop(); + }); + + tcpClient.DataReceived -= TcpClient_DataReceived; + tcpClient.DataReceived += TcpClient_DataReceived; + + tcpClient.Disconnected -= TcpClient_Disconnected; + tcpClient.Disconnected += TcpClient_Disconnected; + + tcpClient.Connected -= TcpClient_Connected; + tcpClient.Connected += TcpClient_Connected; + + _ = Task.Run(() => tcpClient.Connect(host, port)); + } + catch + { + + } + } + + public IEnumerable ReadEvents(int maxEvents) + { + if (events.Count > 0) + { + maxEvents = Math.Min(maxEvents, events.Count); + + while (maxEvents > 0) + { + if(events.TryDequeue(out var _event)) + { + yield return _event; + } + else + { + break; + } + } + } + } + + public void HostLobby(Guid gameId, string name, int gameMode, int maxPlayerCount,string? password, string? ip, int port) + { + var lobbyCreate = new LobbyCreate() + { + GameId = gameId, + Name = name, + GameMode = gameMode, + MaxPlayerCount = maxPlayerCount, + PlayerCount = 0, + PasswordHash = string.IsNullOrEmpty(password) ? null : SHA256.HashData(Encoding.UTF8.GetBytes(password)), + HostIp = ip, + HostPort = port + }; + + byte[] messageData = bufferRental.Rent(); + var len = lobbyCreate.Serialize(messageData); + _ = Task.Run(async () => { await tcpClient.Send(messageData, 0, len); bufferRental.Return(messageData); }); + } + + public void RequestLobbyHostInfo(Guid lobbyId, string? password) + { + var lobbyCreate = new LobbyRequestHostInfo() + { + LobbyId = lobbyId, + PasswordHash = string.IsNullOrEmpty(password) ? null : SHA256.HashData(Encoding.UTF8.GetBytes(password)), + }; + + byte[] messageData = bufferRental.Rent(); + var len = lobbyCreate.Serialize(messageData); + _ = Task.Run(async () => { await tcpClient.Send(messageData, 0, len); bufferRental.Return(messageData); }); + } + + public void RequestLobbyNatPunch(Guid lobbyId, string? password, string? clientIp, int clientPort) + { + var lobbyRequestNatPunch = new LobbyRequestNatPunch() + { + LobbyId = lobbyId, + PasswordHash = string.IsNullOrEmpty(password) ? null : SHA256.HashData(Encoding.UTF8.GetBytes(password)), + ClientIp = clientIp, + ClientPort = clientPort + }; + + byte[] messageData = bufferRental.Rent(); + var len = lobbyRequestNatPunch.Serialize(messageData); + _ = Task.Run(async () => { await tcpClient.Send(messageData, 0, len); bufferRental.Return(messageData); }); + } + + public void UpdateLobby(string name, int gameMode, int maxPlayerCount, int playerCount, string? password, string? ip, int port) + { + var lobbyUpdate = new LobbyUpdate() + { + Name = name, + GameMode = gameMode, + MaxPlayerCount = maxPlayerCount, + PlayerCount = playerCount, + PasswordHash = string.IsNullOrEmpty(password) ? null : SHA256.HashData(Encoding.UTF8.GetBytes(password)), + HostIp = ip, + HostPort = port + }; + + byte[] messageData = bufferRental.Rent(); + var len = lobbyUpdate.Serialize(messageData); + _ = Task.Run(async () => { await tcpClient.Send(messageData, 0, len); bufferRental.Return(messageData); }); + } + + public void CloseLobby() + { + var lobbyDelete = new LobbyDelete() + { + + }; + + byte[] messageData = bufferRental.Rent(); + var len = lobbyDelete.Serialize(messageData); + _ = Task.Run(async () => { await tcpClient.Send(messageData, 0, len); bufferRental.Return(messageData); }); + } + + public void ObserveLobbies(Guid gameId) + { + var lobbiesObserve = new LobbiesObserve() { GameId = gameId }; + + byte[] messageData = bufferRental.Rent(); + var len = lobbiesObserve.Serialize(messageData); + _ = Task.Run(async () => { await tcpClient.Send(messageData, 0, len); bufferRental.Return(messageData); }); + } + + public void StopObservingLobbies() + { + var lobbiesStopObserve = new LobbiesStopObserve() { }; + + byte[] messageData = bufferRental.Rent(); + var len = lobbiesStopObserve.Serialize(messageData); + _ = Task.Run(async () => { await tcpClient.Send(messageData, 0, len); bufferRental.Return(messageData); }); + } + + public void NotifyLobbyNatPunchDone(int natPunchId) + { + var lobbyNatPunchDone = new LobbyNatPunchDone() { NatPunchId = natPunchId }; + + byte[] messageData = bufferRental.Rent(); + var len = lobbyNatPunchDone.Serialize(messageData); + _ = Task.Run(async () => { await tcpClient.Send(messageData, 0, len); bufferRental.Return(messageData); }); + } + + public void Stop() + { + tcpClient.Stop(); + } + + private void TcpClient_Connected() + { + events.Enqueue(new LobbyClientEvent { EventType = LobbyClientEventTypes.Connected, EventData = null }); + } + + private void TcpClient_Disconnected(bool clean, string error) + { + if(!clean) + events.Enqueue(new LobbyClientEvent { EventType = LobbyClientEventTypes.Failed, EventData = new LobbyClientDisconnectReason { WasError = true, ErrorMessage = error } }); + else + events.Enqueue(new LobbyClientEvent { EventType = LobbyClientEventTypes.Disconnected, EventData = new LobbyClientDisconnectReason { WasError = false, ErrorMessage = string.Empty } }); + } + + private int PeekTypeId(ReadOnlySpan buffer) + { + int typeId = 0; + int shift = 0; + int offset = 0; + byte b; + do + { + { + // Check for a corrupted stream. Read a max of 5 bytes. + // In a future version, add a DataFormatException. + if (shift == 5 * 7) // 5 bytes max per Int32, shift += 7 + throw new FormatException("Format_Bad7BitInt32"); + + // ReadByte handles end of stream cases for us. + b = buffer[offset++]; + typeId |= (b & 0x7F) << shift; + shift += 7; + } + } while ((b & 0x80) != 0); + + return typeId; + } + + private void TcpClient_DataReceived(int dataLength, Memory data) + { + try + { + if (dataLength > 0) + { + switch (PeekTypeId(data.Span)) + { + case LobbyInfo.TypeId: + { + var lobbyInfo = LobbyInfo.Deserialize(data.Span); + if (lobbyInfo != null) + { + events.Enqueue(new LobbyClientEvent { EventType = LobbyClientEventTypes.LobbyUpdate, EventData = lobbyInfo }); + } + } + break; + case LobbyDelete.TypeId: + { + var lobbyDelete = LobbyDelete.Deserialize(data.Span); + if (lobbyDelete != null) + { + events.Enqueue(new LobbyClientEvent { EventType = LobbyClientEventTypes.LobbyDelete, EventData = lobbyDelete }); + } + } + break; + case LobbyHostInfo.TypeId: + { + var lobbyHostInfo = LobbyHostInfo.Deserialize(data.Span); + if (lobbyHostInfo != null) + { + events.Enqueue(new LobbyClientEvent { EventType = LobbyClientEventTypes.LobbyHostInfo, EventData = lobbyHostInfo }); + } + } + break; + case LobbyRequestNatPunch.TypeId: + { + var lobbyRequestNatPunch = LobbyRequestNatPunch.Deserialize(data.Span); + if (lobbyRequestNatPunch != null) + { + events.Enqueue(new LobbyClientEvent { EventType = LobbyClientEventTypes.LobbyRequestNatPunch, EventData = lobbyRequestNatPunch }); + } + } + break; + case LobbyNatPunchDone.TypeId: + { + var lobbyNatPunchDone = LobbyNatPunchDone.Deserialize(data.Span); + if (lobbyNatPunchDone != null) + { + events.Enqueue(new LobbyClientEvent { EventType = LobbyClientEventTypes.LobbyNatPunchDone, EventData = lobbyNatPunchDone }); + } + } + break; + } + } + } + catch { } + } + + public void Dispose() + { + tcpClient.Dispose(); + } + + } +} diff --git a/LobbyClient/LobbyClient.csproj b/LobbyClient/LobbyClient.csproj new file mode 100644 index 0000000..2d6db45 --- /dev/null +++ b/LobbyClient/LobbyClient.csproj @@ -0,0 +1,13 @@ + + + + net7.0 + latest + enable + + + + + + + diff --git a/LobbyClient/LobbyClientDisconnectReason.cs b/LobbyClient/LobbyClientDisconnectReason.cs new file mode 100644 index 0000000..dd7c851 --- /dev/null +++ b/LobbyClient/LobbyClientDisconnectReason.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Lobbies +{ + public class LobbyClientDisconnectReason + { + public bool WasError { get; internal set; } + public string? ErrorMessage { get; internal set; } + } +} diff --git a/LobbyClient/LobbyClientEvent.cs b/LobbyClient/LobbyClientEvent.cs new file mode 100644 index 0000000..1a7f21c --- /dev/null +++ b/LobbyClient/LobbyClientEvent.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Lobbies +{ + public class LobbyClientEvent + { + public LobbyClientEventTypes EventType { get; internal set; } + public object? EventData { get; internal set; } + } + + public enum LobbyClientEventTypes + { + Connected, + Disconnected, + Failed, + LobbyAdd, + LobbyUpdate, + LobbyDelete, + LobbyHostInfo, + LobbyRequestNatPunch, + LobbyNatPunchDone, + } +} diff --git a/LobbyClient/TcpClient.cs b/LobbyClient/TcpClient.cs new file mode 100644 index 0000000..790af20 --- /dev/null +++ b/LobbyClient/TcpClient.cs @@ -0,0 +1,161 @@ +using System; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Lobbies +{ + internal class TcpLobbbyClient : IDisposable + { + internal delegate void DataReceivedEventArgs(int bytes, Memory data); + internal event DataReceivedEventArgs? DataReceived; + + internal delegate void DisconnectedEventArgs(bool clean, string error); + internal event DisconnectedEventArgs? Disconnected; + + + internal delegate void ConnectedEventArgs(); + internal event ConnectedEventArgs? Connected; + + TcpClient? tcpClient; + NetworkStream? networkStream; + CancellationTokenSource? cancellationTokenSource = new CancellationTokenSource(); + bool running = false; + + internal async Task Connect(string host, int port) + { + bool wasError = false; + string error = string.Empty; + + try + { + running = true; + tcpClient = new TcpClient(); + await tcpClient.ConnectAsync(host, port, cancellationTokenSource!.Token); + networkStream = tcpClient.GetStream(); + + Memory buffer = new byte[4096]; + Memory target = new byte[4096]; + + int currentOffset = 0; + int currentMessageRemainingLength = 0; + int currentMessageLength = 0; + bool validMessage = true; + int currentReadOffset = 0; + bool offsetSizeInt = false; + Connected?.Invoke(); + + while (running) + { + int copyOffset = 0; + int receivedBytes = currentReadOffset; + + if (currentReadOffset < 4) + { + receivedBytes += await networkStream.ReadAsync(buffer.Slice(currentReadOffset), cancellationTokenSource.Token) + currentReadOffset; + } + + if (receivedBytes == 0 && running && !cancellationTokenSource.Token.IsCancellationRequested) + { + throw new Exception("Connection lost!"); + } + + if (receivedBytes > 3 || (currentMessageRemainingLength > 0 && receivedBytes > currentMessageRemainingLength)) + { + currentReadOffset = 0; + if (currentMessageLength == 0) + { + currentMessageRemainingLength = BitConverter.ToInt32(buffer.Span); + currentMessageLength = currentMessageRemainingLength; + receivedBytes -= 4; + copyOffset += 4; + offsetSizeInt = true; + } + else + offsetSizeInt = false; + + var receivedCount = Math.Min(receivedBytes, currentMessageRemainingLength); + receivedBytes -= receivedCount; + copyOffset += receivedCount; + + if (validMessage && currentOffset + receivedCount > 0) + { + if (currentOffset + receivedCount < target.Length) + buffer.Slice(offsetSizeInt ? 4 : 0, receivedCount).CopyTo(target.Slice(currentOffset)); + else + validMessage = false; + } + + currentOffset += receivedCount; + currentMessageRemainingLength -= receivedCount; + + if (currentMessageRemainingLength <= 0) + { + if (validMessage) + DataReceived?.Invoke(currentMessageLength, target); + + if (receivedBytes > 0) + { + buffer.Slice(copyOffset, receivedBytes).CopyTo(buffer); + currentReadOffset += receivedBytes; + } + + currentOffset = 0; + currentMessageLength = 0; + currentMessageRemainingLength = 0; + validMessage = true; + } + } + else if (receivedBytes > 0) + { + currentReadOffset += receivedBytes; + } + } + } + catch(Exception e) + { + error = e.Message; + } + finally + { + wasError = running; + + running = false; + + networkStream?.Dispose(); + tcpClient?.Dispose(); + + tcpClient = null; + networkStream = null; + + Disconnected?.Invoke(!wasError, error); + } + } + + internal async Task Send(byte[] buffer, int offset, int count) + { + try + { + if (running && networkStream != null) + { + await networkStream.WriteAsync(BitConverter.GetBytes(count - offset), 0, 4, cancellationTokenSource!.Token); + await networkStream.WriteAsync(buffer, offset, count, cancellationTokenSource!.Token); + } + } + catch { } + } + + internal void Stop() + { + running = false; + cancellationTokenSource?.Cancel(); + } + + public void Dispose() + { + cancellationTokenSource?.Cancel(); + tcpClient?.Dispose(); + cancellationTokenSource?.Dispose(); + } + } +} diff --git a/LobbyClientTest/FakeGameHost.cs b/LobbyClientTest/FakeGameHost.cs new file mode 100644 index 0000000..f829bea --- /dev/null +++ b/LobbyClientTest/FakeGameHost.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Sockets; +using System.Net; +using System.Text; +using System.Threading.Tasks; + +namespace LobbyClientTest +{ + internal class FakeGameHost + { + private Socket _socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + private const int bufSize = 8 * 1024; + private State state = new State(); + private EndPoint epFrom = new IPEndPoint(IPAddress.Any, 0); + private AsyncCallback recv = null; + public bool isHost; + public class State + { + public byte[] buffer = new byte[bufSize]; + } + + public int Server(int port) + { + _socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.ReuseAddress, true); + _socket.ExclusiveAddressUse = false; + _socket.Bind(new IPEndPoint(IPAddress.Any, port)); + Receive(); + return ((IPEndPoint)_socket.LocalEndPoint!).Port; + } + + public void Send(EndPoint ep, string text) + { + byte[] data = Encoding.ASCII.GetBytes(text); + _socket.SendTo(data, 0, data.Length, SocketFlags.None, ep); + } + + private void Receive() + { + _socket.BeginReceiveFrom(state.buffer, 0, bufSize, SocketFlags.None, ref epFrom, recv = (ar) => + { + State so = (State)ar.AsyncState; + int bytes = _socket.EndReceiveFrom(ar, ref epFrom); + _socket.BeginReceiveFrom(so.buffer, 0, bufSize, SocketFlags.None, ref epFrom, recv, so); + Console.WriteLine($"Game {(isHost ? "host" : "client")} received: {epFrom.ToString()}: {bytes}, {Encoding.ASCII.GetString(so.buffer, 0, bytes)}"); + if (isHost) + Send(epFrom, "Hello from Game Server!"); + }, state); + } + } +} + diff --git a/LobbyClientTest/LobbyClientTest.csproj b/LobbyClientTest/LobbyClientTest.csproj new file mode 100644 index 0000000..d2c3ee6 --- /dev/null +++ b/LobbyClientTest/LobbyClientTest.csproj @@ -0,0 +1,14 @@ + + + + Exe + net7.0 + enable + enable + + + + + + + diff --git a/LobbyClientTest/Program.cs b/LobbyClientTest/Program.cs new file mode 100644 index 0000000..84039f2 --- /dev/null +++ b/LobbyClientTest/Program.cs @@ -0,0 +1,184 @@ +// See https://aka.ms/new-console-template for more information +using Lobbies; +using LobbyClientTest; +using LobbyServerDto; +using System.Net; + +Console.WriteLine("Starting lobby client!"); +var lobbyClient = new LobbyClient(); +var cancellationTokenSource = new CancellationTokenSource(); + +List openLobbies = new List(); + +lobbyClient.Connect("localhost", 8088, cancellationTokenSource.Token); + +FakeGameHost fakeGameHost = new FakeGameHost(); +int myPort = fakeGameHost.Server(0); +IPEndPoint? hostInfo = null; + +bool running = true; + +_ = Task.Run(() => +{ + while (running) + { + foreach (var lobbyEvent in lobbyClient.ReadEvents(20)) + { + switch (lobbyEvent.EventType) + { + case LobbyClientEventTypes.Connected: + { + var p = Console.GetCursorPosition(); + Console.SetCursorPosition(0, p.Top); + Console.WriteLine("Lobby client connected!"); + Console.Write(">"); + lobbyClient.ObserveLobbies(GameGuids.NFS); + } + break; + case LobbyClientEventTypes.LobbyAdd: + case LobbyClientEventTypes.LobbyUpdate: + { + var lobbyInfo = lobbyEvent.EventData as LobbyInfo; + openLobbies.Add(lobbyInfo!); + var p = Console.GetCursorPosition(); + Console.SetCursorPosition(0, p.Top); + Console.WriteLine($"LobbyInfo: {lobbyInfo!.Id}, name: {lobbyInfo.Name}, mode: {lobbyInfo.GameMode}, maxplayercount: {lobbyInfo.MaxPlayerCount}, playercount: {lobbyInfo.PlayerCount}, password: {lobbyInfo.PasswordProtected}"); + Console.Write(">"); + } + break; + case LobbyClientEventTypes.LobbyDelete: + { + var lobbyDelete = lobbyEvent.EventData as LobbyDelete; + var existingLobby = openLobbies.FirstOrDefault(l => l.Id == lobbyDelete!.Id); + + if (existingLobby != null) + openLobbies.Remove(existingLobby); + + var p = Console.GetCursorPosition(); + Console.SetCursorPosition(0, p.Top); + Console.WriteLine($"LobbyDelete: {lobbyDelete!.Id}"); + Console.Write(">"); + } + break; + case LobbyClientEventTypes.Failed: + { + var reason = lobbyEvent.EventData as LobbyClientDisconnectReason; + var p = Console.GetCursorPosition(); + Console.SetCursorPosition(0, p.Top); + Console.WriteLine($"Lobby connection failed! WasError: {reason!.WasError}, error: {reason.ErrorMessage}"); + running = false; + } + break; + case LobbyClientEventTypes.LobbyHostInfo: + { + var lobbyHostInfo = lobbyEvent.EventData as LobbyHostInfo; + var p = Console.GetCursorPosition(); + Console.SetCursorPosition(0, p.Top); + Console.WriteLine($"Host info for lobby {lobbyHostInfo!.LobbyId} is {lobbyHostInfo.HostIp}:{lobbyHostInfo.HostPort}!"); + hostInfo = new IPEndPoint(IPAddress.Parse(lobbyHostInfo.HostIp!), lobbyHostInfo.HostPort); + Console.WriteLine($"Requesting nat punch!"); + lobbyClient.RequestLobbyNatPunch(lobbyHostInfo.LobbyId, null, null, myPort); + Console.Write(">"); + } + break; + case LobbyClientEventTypes.LobbyRequestNatPunch: + { + var lobbyRequestNatPunch = lobbyEvent.EventData as LobbyRequestNatPunch; + var p = Console.GetCursorPosition(); + Console.SetCursorPosition(0, p.Top); + Console.WriteLine($"Nat punch requested to {lobbyRequestNatPunch!.ClientIp}:{lobbyRequestNatPunch.ClientPort}!"); + //NatPuncher.NatPunch(new IPEndPoint(IPAddress.Any, myPort), new IPEndPoint(IPAddress.Parse(lobbyRequestNatPunch.ClientIp!), lobbyRequestNatPunch.ClientPort)); + var ep = new IPEndPoint(IPAddress.Parse(lobbyRequestNatPunch.ClientIp!), lobbyRequestNatPunch.ClientPort); + for (int z = 0; z < 16; z++) + { + fakeGameHost.Send(ep, "Nat Falcon Punch!"); + } + lobbyClient.NotifyLobbyNatPunchDone(lobbyRequestNatPunch.NatPunchId); + Console.Write(">"); + } + break; + case LobbyClientEventTypes.LobbyNatPunchDone: + { + var lobbyNatPunchDone = lobbyEvent.EventData as LobbyNatPunchDone; + var p = Console.GetCursorPosition(); + Console.SetCursorPosition(0, p.Top); + Console.WriteLine($"Nat punch request done!"); + Console.WriteLine($"Connecting game client!"); + fakeGameHost.Send(hostInfo!, "Hello from Game Client!"); + Console.Write(">"); + } + break; + case LobbyClientEventTypes.Disconnected: + { + var p = Console.GetCursorPosition(); + Console.SetCursorPosition(0, p.Top); + Console.WriteLine($"Lobby disonnected!"); + running = false; + } + break; + } + } + + Thread.Sleep(10); + } +}); + +while (running) +{ + Console.Write(">"); + var line = Console.ReadLine(); + if (line != null) + { + switch (line) + { + case "host": + { + Console.WriteLine("Hosting game ..."); + lobbyClient.HostLobby(GameGuids.NFS, "Hallo, Welt!", 1, 8, null, null, myPort); + fakeGameHost.isHost = true; + } + break; + case "host stop": + { + Console.WriteLine("Stop hosting game ..."); + lobbyClient.CloseLobby(); + fakeGameHost.isHost = false; + } + break; + case "join": + { + Console.WriteLine("Trying to join first lobby ..."); + var firstLobby = openLobbies.FirstOrDefault(); + if (firstLobby != null) + { + lobbyClient.RequestLobbyHostInfo(firstLobby.Id, null); + } + else + { + Console.WriteLine("Seeing no open lobby!"); + } + } + break; + case "observe": + { + Console.WriteLine("Observing lobby list ..."); + lobbyClient.ObserveLobbies(GameGuids.NFS); + } + break; + case "observe stop": + { + Console.WriteLine("Stop observing lobby list ..."); + lobbyClient.StopObservingLobbies(); + } + break; + case "exit": + running = false; + break; + } + } +} + +lobbyClient.Stop(); +lobbyClient.Dispose(); + + diff --git a/LobbyServer.sln b/LobbyServer.sln new file mode 100644 index 0000000..02b567d --- /dev/null +++ b/LobbyServer.sln @@ -0,0 +1,49 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.7.34003.232 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LobbyServer", "LobbyServer\LobbyServer.csproj", "{64B89314-4185-4025-B8B9-AC0D3A921E6A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LobbyServerDto", "LobbyServerDto\LobbyServerDto.csproj", "{5AA6CC31-3A59-4463-8E25-56852430765C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LobbyServerSourceGenerator", "LobbyServerSourceGenerator\LobbyServerSourceGenerator.csproj", "{5353E418-2365-432B-ACC6-C20448F93CC9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LobbyClient", "LobbyClient\LobbyClient.csproj", "{1D6DE49F-7A41-4117-A9AF-6EE3417948EB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LobbyClientTest", "LobbyClientTest\LobbyClientTest.csproj", "{2A5901FE-CE35-4C81-9B8A-E8180EAE7465}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {64B89314-4185-4025-B8B9-AC0D3A921E6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {64B89314-4185-4025-B8B9-AC0D3A921E6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {64B89314-4185-4025-B8B9-AC0D3A921E6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {64B89314-4185-4025-B8B9-AC0D3A921E6A}.Release|Any CPU.Build.0 = Release|Any CPU + {5AA6CC31-3A59-4463-8E25-56852430765C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5AA6CC31-3A59-4463-8E25-56852430765C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5AA6CC31-3A59-4463-8E25-56852430765C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5AA6CC31-3A59-4463-8E25-56852430765C}.Release|Any CPU.Build.0 = Release|Any CPU + {5353E418-2365-432B-ACC6-C20448F93CC9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5353E418-2365-432B-ACC6-C20448F93CC9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5353E418-2365-432B-ACC6-C20448F93CC9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5353E418-2365-432B-ACC6-C20448F93CC9}.Release|Any CPU.Build.0 = Release|Any CPU + {1D6DE49F-7A41-4117-A9AF-6EE3417948EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1D6DE49F-7A41-4117-A9AF-6EE3417948EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1D6DE49F-7A41-4117-A9AF-6EE3417948EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1D6DE49F-7A41-4117-A9AF-6EE3417948EB}.Release|Any CPU.Build.0 = Release|Any CPU + {2A5901FE-CE35-4C81-9B8A-E8180EAE7465}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A5901FE-CE35-4C81-9B8A-E8180EAE7465}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A5901FE-CE35-4C81-9B8A-E8180EAE7465}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A5901FE-CE35-4C81-9B8A-E8180EAE7465}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {6FBBD037-DB88-41C4-A377-9F93163E5AA8} + EndGlobalSection +EndGlobal diff --git a/LobbyServer/Lobby.cs b/LobbyServer/Lobby.cs new file mode 100644 index 0000000..da5ca88 --- /dev/null +++ b/LobbyServer/Lobby.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LobbyServer +{ + internal class Lobby + { + internal enum LobbyUpdateType + { + Add, + Update, + Delete + }; + + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid GameId { get; set; } + public int HostClientId { get; set; } + public required string Name { get; set; } + public int GameMode { get; set; } + public int PlayerCount { get; set; } + public int MaxPlayerCount { get; set; } + public byte[]? PasswordHash { get; set; } + public required string HostIp { get; set; } + public int HostPort { get; set; } + } +} diff --git a/LobbyServer/LobbyServer.csproj b/LobbyServer/LobbyServer.csproj new file mode 100644 index 0000000..cd818a1 --- /dev/null +++ b/LobbyServer/LobbyServer.csproj @@ -0,0 +1,15 @@ + + + + Exe + net7.0 + enable + enable + latest + + + + + + + diff --git a/LobbyServer/Program.cs b/LobbyServer/Program.cs new file mode 100644 index 0000000..f239ef7 --- /dev/null +++ b/LobbyServer/Program.cs @@ -0,0 +1,442 @@ +using LobbyServer; +using LobbyServerDto; +using System.Collections.Concurrent; + + +using var closing = new AutoResetEvent(false); +using var tcpServer = new TcpServer(); + +ConcurrentDictionary lobbiesById = new ConcurrentDictionary(); +ConcurrentDictionary> lobbiesByGameId = new ConcurrentDictionary>(); +ConcurrentDictionary lobbiesByClientId = new ConcurrentDictionary(); + +ConcurrentDictionary clientWatchingGameIdLobbies = new ConcurrentDictionary(); +ConcurrentDictionary> clientsWatchingGameId = new ConcurrentDictionary>(); +BufferRental bufferRental = new BufferRental(4096); + +int PeekTypeId(ReadOnlySpan buffer) +{ + int typeId = 0; + int shift = 0; + int offset = 0; + byte b; + do + { + { + // Check for a corrupted stream. Read a max of 5 bytes. + // In a future version, add a DataFormatException. + if (shift == 5 * 7) // 5 bytes max per Int32, shift += 7 + throw new FormatException("Format_Bad7BitInt32"); + + // ReadByte handles end of stream cases for us. + b = buffer[offset++]; + typeId |= (b & 0x7F) << shift; + shift += 7; + } + } while ((b & 0x80) != 0); + + return typeId; +} + +tcpServer.DataReceived += (clientId, dataLength, data) => +{ + if (dataLength > 0) + { + switch (PeekTypeId(data.Span)) + { + case LobbyCreate.TypeId: + { + var lobbyCreate = LobbyCreate.Deserialize(data.Span); + + if (lobbyCreate != null) + { + if (!GameGuids.ValidGuids.Contains(lobbyCreate.GameId)) + throw new Exception("Invalid game guid!"); + + var lobby = new Lobby + { + Name = lobbyCreate.Name, + GameId = lobbyCreate.GameId, + GameMode = lobbyCreate.GameMode, + PlayerCount = lobbyCreate.PlayerCount, + MaxPlayerCount = lobbyCreate.MaxPlayerCount, + PasswordHash = lobbyCreate.PasswordHash, + HostClientId = clientId, + HostIp = lobbyCreate.HostIp == null ? tcpServer.GetClientIp(clientId)! : lobbyCreate.HostIp, + HostPort = lobbyCreate.HostPort, + }; + + if(lobbiesByClientId.TryGetValue(clientId, out var existingLobby)) + { + var lobbyDelete = LobbyDelete.Deserialize(data.Span); + + if (lobbyDelete != null) + { + if (lobbiesByClientId.TryRemove(clientId, out var _)) + { + if (lobbiesById.TryRemove(existingLobby.Id, out var _)) + { + if (lobbiesByGameId.TryGetValue(existingLobby.GameId, out var lobbyList)) + { + lock (lobbyList) + { + lobbyList.Remove(existingLobby); + } + } + + _ = Task.Run(() => SendLobbyUpdate(Lobby.LobbyUpdateType.Delete, existingLobby)); + } + } + } + } + + if (lobbiesByClientId.TryAdd(clientId, lobby)) + { + lobbiesById.TryAdd(lobby.Id, lobby); + + lobbiesByGameId.AddOrUpdate(lobby.GameId, new List() { lobby }, (key, lobbyList) => + { + lock (lobbyList) + { + lobbyList.Add(lobby); + return lobbyList; + } + }); + } + + _ = Task.Run(() => SendLobbyUpdate(Lobby.LobbyUpdateType.Add, lobby)); + } + } + break; + case LobbyUpdate.TypeId: + { + var lobbyUpdate = LobbyUpdate.Deserialize(data.Span); + + if (lobbyUpdate != null) + { + if (lobbiesByClientId.TryGetValue(clientId, out var existingLobby)) + { + existingLobby.Name = lobbyUpdate.Name; + existingLobby.GameMode = lobbyUpdate.GameMode; + existingLobby.PlayerCount = lobbyUpdate.PlayerCount; + existingLobby.MaxPlayerCount = lobbyUpdate.MaxPlayerCount; + existingLobby.PasswordHash = lobbyUpdate.PasswordHash; + + if (lobbyUpdate.HostIp != null) + existingLobby.HostIp = lobbyUpdate.HostIp; + + existingLobby.HostPort = lobbyUpdate.HostPort; + _ = Task.Run(() => SendLobbyUpdate(Lobby.LobbyUpdateType.Update, existingLobby)); + } + } + } + break; + case LobbyDelete.TypeId: + { + var lobbyDelete = LobbyDelete.Deserialize(data.Span); + + if (lobbyDelete != null) + { + if (lobbiesByClientId.TryRemove(clientId, out var existingLobby)) + { + if (lobbiesById.TryRemove(existingLobby.Id, out var _)) + { + if (lobbiesByGameId.TryGetValue(existingLobby.GameId, out var lobbyList)) + { + lock (lobbyList) + { + lobbyList.Remove(existingLobby); + } + } + + _ = Task.Run(() => SendLobbyUpdate(Lobby.LobbyUpdateType.Delete, existingLobby)); + } + } + } + } + break; + case LobbiesObserve.TypeId: + { + var lobbyObserve = LobbiesObserve.Deserialize(data.Span); + + if (lobbyObserve != null) + { + if (!GameGuids.ValidGuids.Contains(lobbyObserve.GameId)) + throw new Exception("Invalid game guid!"); + + if (clientWatchingGameIdLobbies.TryAdd(clientId, lobbyObserve.GameId)) + { + clientsWatchingGameId.AddOrUpdate(lobbyObserve.GameId, new List() { clientId }, (key, clientWatchingLobbyList) => + { + lock (clientWatchingLobbyList) + { + clientWatchingLobbyList.Add(clientId); + return clientWatchingLobbyList; + } + }); + + if (lobbiesByGameId.TryGetValue(lobbyObserve.GameId, out var lobbyList)) + { + lock (lobbyList) + { + _ = Task.Run(() => SendLobbiesToClient(clientId, lobbyList.ToList())); + } + } + } + } + } + break; + case LobbiesStopObserve.TypeId: + { + var lobbyStopObserve = LobbiesStopObserve.Deserialize(data.Span); + + if (lobbyStopObserve != null) + { + if (clientWatchingGameIdLobbies.TryRemove(clientId, out var gameGuid)) + { + if (clientsWatchingGameId.TryGetValue(gameGuid, out var clientWatchingLobbyList)) + { + lock (clientWatchingLobbyList) + { + clientWatchingLobbyList.Remove(clientId); + } + } + } + } + } + break; + case LobbyRequestHostInfo.TypeId: + { + var lobbyRequestHostInfo = LobbyRequestHostInfo.Deserialize(data.Span); + if(lobbyRequestHostInfo != null) + { + if(lobbiesById.TryGetValue(lobbyRequestHostInfo.LobbyId, out var lobby)) + { + if(lobby.PasswordHash != null) + { + if(lobbyRequestHostInfo.PasswordHash == null || !lobby.PasswordHash.SequenceEqual(lobbyRequestHostInfo.PasswordHash)) + { + var messageData = bufferRental.Rent(); + var lobbyWrongPassword = new LobbyWrongPassword() { LobbyId = lobby.Id }; + var messageDataLength = lobbyWrongPassword.Serialize(messageData); + _ = Task.Run(async () => { + await tcpServer.Send(clientId, messageData, 0, messageDataLength); + bufferRental.Return(messageData); + }); + + return; + } + } + + { + var messageData = bufferRental.Rent(); + var lobbyHostInfo = new LobbyHostInfo() { LobbyId = lobby.Id, HostIp = lobby.HostIp, HostPort = lobby.HostPort }; + var messageDataLength = lobbyHostInfo.Serialize(messageData); + _ = Task.Run(async () => + { + await tcpServer.Send(clientId, messageData, 0, messageDataLength); + bufferRental.Return(messageData); + }); + } + } + } + } + break; + case LobbyRequestNatPunch.TypeId: + { + var lobbyRequestNatPunch = LobbyRequestNatPunch.Deserialize(data.Span); + if (lobbyRequestNatPunch != null) + { + if (lobbiesById.TryGetValue(lobbyRequestNatPunch.LobbyId, out var lobby)) + { + if (lobby.PasswordHash != null) + { + if (lobbyRequestNatPunch.PasswordHash == null || !lobby.PasswordHash.SequenceEqual(lobbyRequestNatPunch.PasswordHash)) + { + var messageData = bufferRental.Rent(); + var lobbyWrongPassword = new LobbyWrongPassword() { LobbyId = lobby.Id }; + var messageDataLength = lobbyWrongPassword.Serialize(messageData); + _ = Task.Run(async () => { + await tcpServer.Send(clientId, messageData, 0, messageDataLength); + bufferRental.Return(messageData); + }); + + return; + } + } + + { + if (string.IsNullOrEmpty(lobbyRequestNatPunch.ClientIp)) + { + lobbyRequestNatPunch.ClientIp = tcpServer.GetClientIp(clientId)!; + } + + lobbyRequestNatPunch.NatPunchId = clientId; + var messageData = bufferRental.Rent(); + var messageDataLength = lobbyRequestNatPunch.Serialize(messageData); + _ = Task.Run(async () => + { + await tcpServer.Send(lobby.HostClientId, messageData, 0, messageDataLength); + bufferRental.Return(messageData); + }); + } + } + } + } + break; + case LobbyNatPunchDone.TypeId: + { + var lobbyNatPunchDone = LobbyNatPunchDone.Deserialize(data.Span); + if (lobbyNatPunchDone != null) + { + if (lobbiesByClientId.TryGetValue(clientId, out var lobby)) + { + lobbyNatPunchDone.LobbyId = lobby.Id; + var messageData = bufferRental.Rent(); + var messageDataLength = lobbyNatPunchDone.Serialize(messageData); + _ = Task.Run(async () => + { + await tcpServer.Send(lobbyNatPunchDone.NatPunchId, messageData, 0, messageDataLength); + bufferRental.Return(messageData); + }); + } + } + } + break; + } + } +}; + +tcpServer.ClientDisconnected += (clientId) => +{ + if(clientWatchingGameIdLobbies.TryRemove(clientId, out var gameId)) + { + if(clientsWatchingGameId.TryGetValue(gameId, out var clientWatchingLobbyList)) + { + lock(clientWatchingLobbyList) + { + clientWatchingLobbyList.Remove(clientId); + } + } + } + + if (lobbiesByClientId.TryRemove(clientId, out var lobby)) + { + lobbiesById.TryRemove(lobby.Id, out var _); + if(lobbiesByGameId.TryGetValue(lobby.GameId, out var lobbyList)) + { + lock(lobbyList) + { + lobbyList.Remove(lobby); + } + } + + _ = Task.Run(() => SendLobbyUpdate(Lobby.LobbyUpdateType.Delete, lobby)); + + if (clientsWatchingGameId.TryGetValue(gameId, out var clientWatchingLobbyList)) + { + lock (clientWatchingLobbyList) + { + clientWatchingLobbyList.Remove(clientId); + } + } + } +}; + +async Task SendLobbyUpdate(Lobby.LobbyUpdateType lobbyUpdateType, Lobby lobby) +{ + byte[] messageData = bufferRental.Rent(); + try + { + if (clientsWatchingGameId.TryGetValue(lobby.GameId, out var clientWatchingLobbyList)) + { + + int messageDataLength = 0; + + switch (lobbyUpdateType) + { + case Lobby.LobbyUpdateType.Add: + case Lobby.LobbyUpdateType.Update: + var lobbyInfo = new LobbyInfo + { + Id = lobby.Id, + Name = lobby.Name, + GameMode = lobby.GameMode, + MaxPlayerCount = lobby.MaxPlayerCount, + PlayerCount = lobby.PlayerCount, + PasswordProtected = lobby.PasswordHash != null && lobby.PasswordHash.Length > 0 + }; + + messageDataLength = lobbyInfo.Serialize(messageData); + break; + case Lobby.LobbyUpdateType.Delete: + var lobbyDelete = new LobbyDelete { Id = lobby.Id, GameId = lobby.GameId }; + + messageDataLength = lobbyDelete.Serialize(messageData); + break; + } + + List tasks = new List(); + if (messageDataLength != 0) + { + lock (clientWatchingLobbyList) + { + foreach (var watchingClientId in clientWatchingLobbyList) + { + tasks.Add(tcpServer.Send(watchingClientId, messageData, 0, messageDataLength)); + } + } + } + + await Task.WhenAll(tasks); + } + } + finally + { + bufferRental.Return(messageData); + } +} + +async Task SendLobbiesToClient(int clientId, List lobbies) +{ + byte[] messageData = bufferRental.Rent(); + + try + { + foreach (var lobby in lobbies) + { + var lobbyInfo = new LobbyInfo + { + Id = lobby.Id, + Name = lobby.Name, + GameMode = lobby.GameMode, + MaxPlayerCount = lobby.MaxPlayerCount, + PlayerCount = lobby.PlayerCount, + PasswordProtected = lobby.PasswordHash != null && lobby.PasswordHash.Length > 0 + }; + + var messageDataLength = lobbyInfo.Serialize(messageData); + await tcpServer.Send(clientId, messageData, 0, messageDataLength); + } + } + finally + { + bufferRental.Return(messageData); + } +} + +Console.CancelKeyPress += (sender, args) => +{ + Console.WriteLine($"{DateTime.Now}: Closing application"); + tcpServer.Stop(); + closing.Set(); + args.Cancel = true; +}; + +Console.WriteLine($"{DateTime.Now}: Application started"); + +tcpServer.Start(8088); + +closing.WaitOne(); + +Console.WriteLine($"{DateTime.Now}: Application closed"); \ No newline at end of file diff --git a/LobbyServer/TcpLobbyServer.cs b/LobbyServer/TcpLobbyServer.cs new file mode 100644 index 0000000..3cecf05 --- /dev/null +++ b/LobbyServer/TcpLobbyServer.cs @@ -0,0 +1,227 @@ +using System.Net.Sockets; +using System.Net; +using System.Collections.Concurrent; + + +namespace LobbyServer +{ + internal class TcpServer : IDisposable + { + public delegate void DataReceivedEventArgs(int clientId, int bytes, Memory data); + public event DataReceivedEventArgs? DataReceived; + + public delegate void ClientDisconnectedEventArgs(int clientId); + public event ClientDisconnectedEventArgs? ClientDisconnected; + + internal class Client : IDisposable + { + internal CancellationTokenSource? cancellationToken = null; + internal NetworkStream? stream; + internal TcpClient? client; + + public void Dispose() + { + cancellationToken?.Dispose(); + cancellationToken = null; + } + } + + private int clientIdCounter = 0; + private bool running = false; + private readonly ConcurrentDictionary activeClients = new ConcurrentDictionary (); + private readonly CancellationTokenSource cancellationToken = new CancellationTokenSource(); + private readonly AutoResetEvent serverClosed = new AutoResetEvent (false); + + public void Start(int port) + { + serverClosed.Reset(); + cancellationToken.TryReset(); + running = true; + clientIdCounter = 0; + _ = Task.Run(() => Listener(port)); + } + + public void Stop() + { + running = false; + cancellationToken.Cancel(); + + foreach(var client in activeClients.Values) + { + try + { + client.cancellationToken?.Cancel(); + } + catch + { } + } + activeClients.Clear (); + + serverClosed.WaitOne(); + } + + private async Task Listener(int port) + { + var ipEndPoint = new IPEndPoint(IPAddress.Any, port); + TcpListener listener = new(ipEndPoint); + + try + { + cancellationToken.Token.ThrowIfCancellationRequested(); + + listener.Start(); + + Console.WriteLine($"{DateTime.Now}: Server listening on port {port}"); + + while (running) + { + var client = await listener.AcceptTcpClientAsync(cancellationToken.Token); + + if (client != null && running) + { + _ = Task.Run(() => ClientThread(client)); + } + } + } + finally + { + Console.WriteLine($"{DateTime.Now}: Server closed"); + listener.Stop(); + serverClosed.Set(); + } + } + + public async Task Send(int clientId, byte[] buffer, int offset, int count) + { + try + { + if (activeClients.TryGetValue(clientId, out var lobbyClient) && lobbyClient.stream != null && lobbyClient.cancellationToken != null) + { + await lobbyClient.stream.WriteAsync(BitConverter.GetBytes(count - offset), 0, 4, lobbyClient.cancellationToken.Token); + await lobbyClient.stream.WriteAsync(buffer, offset, count, lobbyClient.cancellationToken.Token); + } + } + catch { } + } + + private async Task ClientThread(TcpClient client) + { + int myId = Interlocked.Increment(ref clientIdCounter); + + try + { + await using NetworkStream stream = client.GetStream(); + + Memory buffer = new byte[4096]; + Memory target = new byte[4096]; + + using var lobbyClient = new Client + { + cancellationToken = new CancellationTokenSource(), + stream = stream, + client = client + }; + + activeClients.TryAdd(myId, lobbyClient); + + int currentOffset = 0; + int currentMessageRemainingLength = 0; + int currentMessageLength = 0; + bool validMessage = true; + bool offsetSizeInt = false; + int currentReadOffset = 0; + + while (running) + { + int copyOffset = 0; + int receivedBytes = currentReadOffset; + + if (currentReadOffset < 4) + { + receivedBytes += await stream.ReadAsync(buffer.Slice(currentReadOffset), lobbyClient.cancellationToken.Token) + currentReadOffset; + if (receivedBytes == 0) + throw new Exception("Connection lost!"); + } + + if (receivedBytes > 3 || (currentMessageRemainingLength > 0 && receivedBytes >= currentMessageRemainingLength)) + { + currentReadOffset = 0; + if (currentMessageLength == 0) + { + currentMessageRemainingLength = BitConverter.ToInt32(buffer.Span); + currentMessageLength = currentMessageRemainingLength; + receivedBytes -= 4; + copyOffset += 4; + offsetSizeInt = true; + } + else + offsetSizeInt = false; + + var receivedCount = Math.Min(receivedBytes, currentMessageRemainingLength); + + receivedBytes -= receivedCount; + copyOffset += receivedCount; + + if (validMessage && currentOffset + receivedCount > 0) + { + if (currentOffset + receivedCount < target.Length) + buffer.Slice(offsetSizeInt ? 4 : 0, receivedCount).CopyTo(target.Slice(currentOffset)); + else + validMessage = false; + } + + currentOffset += receivedCount; + currentMessageRemainingLength -= receivedCount; + + if (currentMessageRemainingLength <= 0) + { + if(validMessage) + DataReceived?.Invoke(myId, currentMessageLength, target); + + if (receivedBytes > 0) + { + buffer.Slice(copyOffset, receivedBytes).CopyTo(buffer); + currentReadOffset += receivedBytes; + } + + currentOffset = 0; + currentMessageLength = 0; + currentMessageRemainingLength = 0; + validMessage = true; + } + } + else if(receivedBytes > 0) + { + currentReadOffset += receivedBytes; + } + } + } + finally + { + ClientDisconnected?.Invoke(myId); + activeClients.TryRemove(myId, out var _); + client?.Dispose(); + } + } + + internal string? GetClientIp(int cliendId) + { + try + { + if (activeClients.TryGetValue(cliendId, out var client)) + { + return (client.client!.Client.RemoteEndPoint as IPEndPoint)!.Address.ToString(); + } + } + catch { } + + return null; + } + + public void Dispose() + { + cancellationToken?.Dispose(); + serverClosed?.Dispose(); + } + } +} diff --git a/LobbyServerDto/BufferRental.cs b/LobbyServerDto/BufferRental.cs new file mode 100644 index 0000000..2792ca9 --- /dev/null +++ b/LobbyServerDto/BufferRental.cs @@ -0,0 +1,28 @@ +using System.Collections.Concurrent; +using System.Drawing; + +namespace LobbyServerDto +{ + public class BufferRental + { + private readonly ConcurrentQueue buffers = new ConcurrentQueue(); + private readonly int size; + public BufferRental(int size) + { + this.size = size; + } + + public byte[] Rent() + { + if (buffers.TryDequeue(out var buffer)) + return buffer; + else + return new byte[size]; + } + + public void Return(byte[] buffer) + { + buffers.Enqueue(buffer); + } + } +} diff --git a/LobbyServerDto/LobbiesObserve.cs b/LobbyServerDto/LobbiesObserve.cs new file mode 100644 index 0000000..d09caf6 --- /dev/null +++ b/LobbyServerDto/LobbiesObserve.cs @@ -0,0 +1,8 @@ +namespace LobbyServerDto +{ + [LobbyMessage] + public partial class LobbiesObserve + { + public Guid GameId { get; set; } + } +} \ No newline at end of file diff --git a/LobbyServerDto/LobbiesStopObserve.cs b/LobbyServerDto/LobbiesStopObserve.cs new file mode 100644 index 0000000..13d62af --- /dev/null +++ b/LobbyServerDto/LobbiesStopObserve.cs @@ -0,0 +1,7 @@ +namespace LobbyServerDto +{ + [LobbyMessage] + public partial class LobbiesStopObserve + { + } +} \ No newline at end of file diff --git a/LobbyServerDto/LobbyCreate.cs b/LobbyServerDto/LobbyCreate.cs new file mode 100644 index 0000000..f67c0ce --- /dev/null +++ b/LobbyServerDto/LobbyCreate.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace LobbyServerDto +{ + [LobbyMessage] + public partial class LobbyCreate + { + public Guid GameId { get; set; } + [MaxLength(64)] + public string Name { get; set; } = string.Empty; + public int GameMode { get; set; } + public int PlayerCount { get; set; } + public int MaxPlayerCount { get; set; } + [MaxLength(26)] + public byte[]? PasswordHash { get; set; } + [MaxLength(32)] + public string? HostIp { get; set; } + public int HostPort { get; set; } + } +} \ No newline at end of file diff --git a/LobbyServerDto/LobbyDelete.cs b/LobbyServerDto/LobbyDelete.cs new file mode 100644 index 0000000..613efa2 --- /dev/null +++ b/LobbyServerDto/LobbyDelete.cs @@ -0,0 +1,9 @@ +namespace LobbyServerDto +{ + [LobbyMessage] + public partial class LobbyDelete + { + public Guid Id { get; set; } + public Guid GameId { get; set; } + } +} \ No newline at end of file diff --git a/LobbyServerDto/LobbyHostInfo.cs b/LobbyServerDto/LobbyHostInfo.cs new file mode 100644 index 0000000..5c1364f --- /dev/null +++ b/LobbyServerDto/LobbyHostInfo.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace LobbyServerDto +{ + [LobbyMessage] + public partial class LobbyHostInfo + { + public Guid LobbyId { get; set; } + [MaxLength(32)] + public string? HostIp { get; set; } + public int HostPort { get; set; } + } +} \ No newline at end of file diff --git a/LobbyServerDto/LobbyInfo.cs b/LobbyServerDto/LobbyInfo.cs new file mode 100644 index 0000000..b7a9c91 --- /dev/null +++ b/LobbyServerDto/LobbyInfo.cs @@ -0,0 +1,13 @@ +namespace LobbyServerDto +{ + [LobbyMessage] + public partial class LobbyInfo + { + public Guid Id { get; set; } + public string? Name { get; set; } + public int GameMode { get; set; } + public int PlayerCount { get; set; } + public int MaxPlayerCount { get; set; } + public bool PasswordProtected { get; set; } + } +} diff --git a/LobbyServerDto/LobbyNatPunchDone.cs b/LobbyServerDto/LobbyNatPunchDone.cs new file mode 100644 index 0000000..946d636 --- /dev/null +++ b/LobbyServerDto/LobbyNatPunchDone.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace LobbyServerDto +{ + [LobbyMessage] + public partial class LobbyNatPunchDone + { + public Guid LobbyId { get; set; } + public int NatPunchId { get; set; } + } +} \ No newline at end of file diff --git a/LobbyServerDto/LobbyRequestHostInfo.cs b/LobbyServerDto/LobbyRequestHostInfo.cs new file mode 100644 index 0000000..5d57a09 --- /dev/null +++ b/LobbyServerDto/LobbyRequestHostInfo.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace LobbyServerDto +{ + [LobbyMessage] + public partial class LobbyRequestHostInfo + { + public Guid LobbyId { get; set; } + [MaxLength(26)] + public byte[]? PasswordHash { get; set; } + } +} \ No newline at end of file diff --git a/LobbyServerDto/LobbyRequestNatPunch.cs b/LobbyServerDto/LobbyRequestNatPunch.cs new file mode 100644 index 0000000..4f54e7c --- /dev/null +++ b/LobbyServerDto/LobbyRequestNatPunch.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace LobbyServerDto +{ + [LobbyMessage] + public partial class LobbyRequestNatPunch + { + public Guid LobbyId { get; set; } + [MaxLength(26)] + public byte[]? PasswordHash { get; set; } + + public string? ClientIp { get; set; } + public int ClientPort { get; set; } + + public int NatPunchId { get; set; } + } +} \ No newline at end of file diff --git a/LobbyServerDto/LobbyServerDto.csproj b/LobbyServerDto/LobbyServerDto.csproj new file mode 100644 index 0000000..7e1cc03 --- /dev/null +++ b/LobbyServerDto/LobbyServerDto.csproj @@ -0,0 +1,14 @@ + + + + enable + enable + net7.0 + true + + + + + + + diff --git a/LobbyServerDto/LobbyUpdate.cs b/LobbyServerDto/LobbyUpdate.cs new file mode 100644 index 0000000..4cc276b --- /dev/null +++ b/LobbyServerDto/LobbyUpdate.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace LobbyServerDto +{ + [LobbyMessage] + public partial class LobbyUpdate + { + public string Name { get; set; } = string.Empty; + public int GameMode { get; set; } + public int PlayerCount { get; set; } + public int MaxPlayerCount { get; set; } + public byte[]? PasswordHash { get; set; } + [MaxLength(32)] + public string? HostIp { get; set; } + public int HostPort { get; set; } + } +} \ No newline at end of file diff --git a/LobbyServerDto/LobbyWrongPassword.cs b/LobbyServerDto/LobbyWrongPassword.cs new file mode 100644 index 0000000..6cd18ad --- /dev/null +++ b/LobbyServerDto/LobbyWrongPassword.cs @@ -0,0 +1,8 @@ +namespace LobbyServerDto +{ + [LobbyMessage] + public partial class LobbyWrongPassword + { + public Guid LobbyId { get; set; } + } +} \ No newline at end of file diff --git a/LobbyServerDto/NatPuncher.cs b/LobbyServerDto/NatPuncher.cs new file mode 100644 index 0000000..46fe96e --- /dev/null +++ b/LobbyServerDto/NatPuncher.cs @@ -0,0 +1,29 @@ +using System.Net; +using System.Net.Sockets; + + +namespace LobbyServerDto +{ + public class NatPuncher + { + public static void NatPunch(IPEndPoint localEndpoint, IPEndPoint remoteEndpoint) + { + try + { + using Socket socket = new Socket(SocketType.Dgram, ProtocolType.Udp); + socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.ReuseAddress, true); + socket.ExclusiveAddressUse = false; + socket.Bind(localEndpoint); + + for (int i = 0; i < 16; i++) + { + socket.SendTo(new byte[] { }, remoteEndpoint); + } + } + catch + { + throw; + } + } + } +} diff --git a/LobbyServerDto/Properties/launchSettings.json b/LobbyServerDto/Properties/launchSettings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/LobbyServerDto/Properties/launchSettings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/LobbyServerDto/ValidGameGuids.cs b/LobbyServerDto/ValidGameGuids.cs new file mode 100644 index 0000000..aca426e --- /dev/null +++ b/LobbyServerDto/ValidGameGuids.cs @@ -0,0 +1,9 @@ + +namespace LobbyServerDto +{ + public static class GameGuids + { + public static Guid NFS = new Guid("0572706f-0fd9-46a9-8ea6-5a31a5363442"); + public static HashSet ValidGuids = new HashSet() { NFS }; + } +} diff --git a/LobbyServerSourceGenerator/LobbyMessageSourceGenerator.cs b/LobbyServerSourceGenerator/LobbyMessageSourceGenerator.cs new file mode 100644 index 0000000..f79219d --- /dev/null +++ b/LobbyServerSourceGenerator/LobbyMessageSourceGenerator.cs @@ -0,0 +1,342 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; + +namespace LobbyServerDto +{ + [Generator] + public class LobbyMessageSourceGenerator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var typeDeclarations = context.SyntaxProvider.ForAttributeWithMetadataName( + "LobbyServerDto.LobbyMessageAttribute", + predicate: static (node, token) => + { + return (node is ClassDeclarationSyntax + or StructDeclarationSyntax + or RecordDeclarationSyntax + or InterfaceDeclarationSyntax); + }, + transform: static (context, token) => + { + return (TypeDeclarationSyntax)context.TargetNode; + }); + + var source = typeDeclarations + .Combine(context.CompilationProvider) + .WithComparer(Comparer.Instance).Collect(); + + context.RegisterSourceOutput(source, static (context, source) => + { + GenerateCode(context, source); + }); + } + + private static void GenerateCode(SourceProductionContext context, ImmutableArray<(TypeDeclarationSyntax, Compilation)> source) + { + if (source.IsDefaultOrEmpty) + return; + + Dictionary sortableSyntaxes = new Dictionary(); + + foreach (var syntax in source) + { + var semanticModel = syntax.Item2.GetSemanticModel(syntax.Item1.SyntaxTree); + + var typeSymbol = semanticModel.GetDeclaredSymbol(syntax.Item1, context.CancellationToken); + if (typeSymbol == null) + { + return; + } + + var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace + ? null + : typeSymbol.ContainingNamespace.ToString(); + var name = typeSymbol.Name; + + var fullName = $"{ns}.{name}"; + + sortableSyntaxes.Add(fullName, new SyntaxInfo() { name = name, nameSpace = ns, semanticModel = semanticModel, syntaxNode = syntax.Item1, compilation = syntax.Item2 }); + } + + StringBuilder s = new StringBuilder(); + + int id = 0; + foreach (var foundClass in sortableSyntaxes.OrderBy(s => s.Key)) + { + s.Clear(); + s.Append(@$"// +using System.Collections.Generic; +using System.Text; + +{(foundClass.Value.nameSpace is null ? null : $@"namespace {foundClass.Value.nameSpace} +{{")} + public partial class {foundClass.Value.name} + {{ + public const int TypeId = {id++}; + + public int Serialize(byte[] buffer) + {{ + int offset = 0; + + {{ + uint v = (uint)TypeId; + while (v >= 0x80) + {{ + buffer[offset++] = (byte)(v | 0x80); + v >>= 7; + }} + buffer[offset++] = (byte)v; + }} +"); + foreach (var member in foundClass.Value.syntaxNode.Members) + { + if (member is PropertyDeclarationSyntax p) + { + int maxLength = 256; + + var iMember = foundClass.Value.semanticModel.GetDeclaredSymbol(member); + + foreach (var maxLengthAttr in iMember.GetAttributes().Where(a => a.AttributeClass.Name == "MaxLengthAttribute")) + { + var length = (int)maxLengthAttr.ConstructorArguments.First().Value; + if(length < maxLength) + maxLength = length; + } + + var name = p.Identifier.ToString(); + switch(p.Type.ToString()) + { + case "bool": + s.Append($@" + buffer[offset++] = (byte)({name} == true ? 1 : 0);"); + break; + case "int": + s.Append($@" + buffer[offset++] = (byte){name}; + buffer[offset++] = (byte)({name} >> 8); + buffer[offset++] = (byte)({name} >> 16); + buffer[offset++] = (byte)({name} >> 24);"); + break; + case "Guid": + s.Append($@" + Buffer.BlockCopy({name}.ToByteArray(), 0, buffer, offset, 16); + offset += 16;"); + break; + case "string": + case "string?": + s.Append($@" + if ({name} != null) + {{ + var str1 = Encoding.UTF8.GetBytes({name}.Substring(0, Math.Min({maxLength}, {name}.Length))); + + uint v = (uint)str1.Length; + while (v >= 0x80) + {{ + buffer[offset++] = (byte)(v | 0x80); + v >>= 7; + }} + buffer[offset++] = (byte)v; + + Buffer.BlockCopy(str1, 0, buffer, offset, str1.Length); + offset += str1.Length; + }} + else + {{ + buffer[offset++] = 0; + }} +"); + break; + case "byte[]": + case "byte[]?": + s.Append($@" + if ({name} != null) + {{ + int maxLength = Math.Min(PasswordHash.Length, {maxLength}); + uint v = (uint)maxLength; + while (v >= 0x80) + {{ + buffer[offset++] = (byte)(v | 0x80); + v >>= 7; + }} + buffer[offset++] = (byte)v; + + Buffer.BlockCopy(PasswordHash, 0, buffer, offset, maxLength); + offset += maxLength; + }} + else + {{ + buffer[offset++] = 0; + }} +"); + break; + default: + throw new Exception($"Unkown type {p.Type.ToString()} on field {name}"); + } + } + } + + s.Append(@$" + return offset; + }} + + public static {foundClass.Value.name} Deserialize(ReadOnlySpan buffer) + {{ + int offset = 0; + {foundClass.Value.name} ret = new {foundClass.Value.name}(); + {{ + int count = 0; + int shift = 0; + byte b; + do {{ + // Check for a corrupted stream. Read a max of 5 bytes. + // In a future version, add a DataFormatException. + if (shift == 5 * 7) // 5 bytes max per Int32, shift += 7 + throw new FormatException(""Format_Bad7BitInt32""); + + // ReadByte handles end of stream cases for us. + b = buffer[offset++]; + count |= (b & 0x7F) << shift; + shift += 7; + }} while ((b & 0x80) != 0); + }} +"); + + foreach (var member in foundClass.Value.syntaxNode.Members) + { + if (member is PropertyDeclarationSyntax p) + { + int maxLength = 256; + + var iMember = foundClass.Value.semanticModel.GetDeclaredSymbol(member); + + foreach (var maxLengthAttr in iMember.GetAttributes().Where(a => a.AttributeClass.Name == "MaxLengthAttribute")) + { + var length = (int)maxLengthAttr.ConstructorArguments.First().Value; + if (length < maxLength) + maxLength = length; + } + + var name = p.Identifier.ToString(); + switch (p.Type.ToString()) + { + case "bool": + s.Append($@" + ret.{name} = buffer[offset++] == 0 ? false : true;"); + break; + case "int": + s.Append($@" + ret.{name} = (int)(buffer[offset++] | buffer[offset++] << 8 | buffer[offset++] << 16 | buffer[offset++] << 24);"); + break; + case "Guid": + s.Append($@" + {{ + ret.{name} = new Guid(buffer.Slice(offset, 16)); + offset+=16; + }}"); + break; + case "string": + case "string?": + s.Append($@" + {{ + int strLen = 0; + int shift = 0; + byte b; + do {{ + // Check for a corrupted stream. Read a max of 5 bytes. + // In a future version, add a DataFormatException. + if (shift == 5 * 7) // 5 bytes max per Int32, shift += 7 + throw new FormatException(""Format_Bad7BitInt32""); + + // ReadByte handles end of stream cases for us. + b = buffer[offset++]; + strLen |= (b & 0x7F) << shift; + shift += 7; + }} while ((b & 0x80) != 0); + + if(strLen > 0) + {{ + ret.{name} = Encoding.UTF8.GetString(buffer.Slice(offset, strLen)); + offset += strLen; + }} + }}"); + break; + case "byte[]": + case "byte[]?": + s.Append($@" + {{ + int strLen = 0; + int shift = 0; + byte b; + do {{ + // Check for a corrupted stream. Read a max of 5 bytes. + // In a future version, add a DataFormatException. + if (shift == 5 * 7) // 5 bytes max per Int32, shift += 7 + throw new FormatException(""Format_Bad7BitInt32""); + + // ReadByte handles end of stream cases for us. + b = buffer[offset++]; + strLen |= (b & 0x7F) << shift; + shift += 7; + }} while ((b & 0x80) != 0); + + if(strLen > 0) + {{ + ret.{name} = buffer.Slice(offset, strLen).ToArray(); + offset += strLen; + }} + }}"); + break; + default: + throw new Exception($"Unkown type {p.Type.ToString()} on field {name}"); + } + } + } + + s.Append(@$" + return ret; + }} + }} +{(foundClass.Value.nameSpace is null ? null : @"} +")}"); + context.AddSource($"{foundClass.Key}.g.cs", s.ToString()); + } + } + } + + class SyntaxInfo + { + public TypeDeclarationSyntax syntaxNode; + public SemanticModel semanticModel; + public string nameSpace; + public string name; + internal Compilation compilation; + } + + + class Comparer : IEqualityComparer<(TypeDeclarationSyntax, Compilation)> + { + public static readonly Comparer Instance = new Comparer(); + + public bool Equals((TypeDeclarationSyntax, Compilation) x, (TypeDeclarationSyntax, Compilation) y) + { + return x.Item1.Equals(y.Item1); + } + + public int GetHashCode((TypeDeclarationSyntax, Compilation) obj) + { + return obj.Item1.GetHashCode(); + } + } + + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface, AllowMultiple = false, Inherited = false)] + public class LobbyMessageAttribute : Attribute + { + } +} diff --git a/LobbyServerSourceGenerator/LobbyServerSourceGenerator.csproj b/LobbyServerSourceGenerator/LobbyServerSourceGenerator.csproj new file mode 100644 index 0000000..a6941ba --- /dev/null +++ b/LobbyServerSourceGenerator/LobbyServerSourceGenerator.csproj @@ -0,0 +1,22 @@ + + + + netstandard2.0 + true + latest + true + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/LobbyServerSourceGenerator/Properties/launchSettings.json b/LobbyServerSourceGenerator/Properties/launchSettings.json new file mode 100644 index 0000000..21b362d --- /dev/null +++ b/LobbyServerSourceGenerator/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Generator": { + "commandName": "DebugRoslynComponent", + "targetProject": "..\\LobbyServerDto\\LobbyServerDto.csproj" + } + } +} \ No newline at end of file