diff --git a/OpenPolytopia.Common/LobbyData.cs b/OpenPolytopia.Common/LobbyData.cs new file mode 100644 index 00000000..3b8867aa --- /dev/null +++ b/OpenPolytopia.Common/LobbyData.cs @@ -0,0 +1,104 @@ +namespace OpenPolytopia.Common; + +using Network; + +/// +/// A player inside a lobby +/// +public class LobbyPlayerData : INetworkSerializable { + /// + /// Server-assigned id of the player + /// + public uint PlayerId; + + /// + /// Name of the player + /// + public string Name = ""; + + /// + /// Tribe chosen by the player + /// + public uint Tribe; + + /// + /// Whether the player is ready to start the game + /// + public bool Ready; + + public void Serialize(List bytes) { + PlayerId.Serialize(bytes); + Name.Serialize(bytes); + Tribe.Serialize(bytes); + Ready.Serialize(bytes); + } + + public void Deserialize(byte[] bytes, ref uint index) { + PlayerId.Deserialize(bytes, ref index); + Name = StringSerialization.Read(bytes, ref index); + Tribe.Deserialize(bytes, ref index); + Ready.Deserialize(bytes, ref index); + } +} + +/// +/// Represents a lobby where players can join and start a game +/// +public class LobbyData : INetworkSerializable { + /// + /// ID of the lobby + /// + public ulong Id; + + /// + /// Number of max players that can join this lobby + /// + public uint MaxPlayers; + + /// + /// If the game in the lobby has started + /// + public bool Started; + + /// + /// If the game in the lobby is about to start (all players ready) + /// + public bool Starting; + + /// + /// The players currently in the lobby + /// + public List Players = []; + + /// + /// Number of players in the lobby + /// + public uint PlayersCount => (uint)Players.Count; + + /// + /// Number of players ready to start + /// + public uint ReadyCount => (uint)Players.Count(player => player.Ready); + + /// + /// Returns the player data from a given player id + /// + /// the player's id + public LobbyPlayerData? this[uint playerId] => Players.FirstOrDefault(player => player.PlayerId == playerId); + + public void Serialize(List bytes) { + Id.Serialize(bytes); + MaxPlayers.Serialize(bytes); + Started.Serialize(bytes); + Starting.Serialize(bytes); + Players.Serialize(bytes); + } + + public void Deserialize(byte[] bytes, ref uint index) { + Id.Deserialize(bytes, ref index); + MaxPlayers.Deserialize(bytes, ref index); + Started.Deserialize(bytes, ref index); + Starting.Deserialize(bytes, ref index); + Players.Deserialize(bytes, ref index); + } +} diff --git a/OpenPolytopia.Common/Network/ClientConnection.cs b/OpenPolytopia.Common/Network/ClientConnection.cs new file mode 100644 index 00000000..c64cc269 --- /dev/null +++ b/OpenPolytopia.Common/Network/ClientConnection.cs @@ -0,0 +1,108 @@ +namespace OpenPolytopia.Common.Network; + +using System.Collections.Concurrent; +using System.Net.Sockets; +using Packets; + +/// +/// Client-side connection to the game server +/// +/// +/// Received packets are queued in to let the consumer +/// process them on its own thread; gets answered automatically +/// and the connection gets closed if the server doesn't send anything for longer than +/// +public class ClientConnection(string address, int port) : IDisposable { + private static readonly TimeSpan TIMEOUT = TimeSpan.FromSeconds(30); + private static readonly TimeSpan TIMEOUT_CHECK_INTERVAL = TimeSpan.FromSeconds(5); + + private readonly TcpClient _client = new(); + private readonly CancellationTokenSource _cts = new(); + private NetworkConnection? _connection; + + /// + /// Packets received from the server, waiting to be processed + /// + public ConcurrentQueue IncomingPackets { get; } = new(); + + /// + /// Fired when the connection to the server gets closed + /// + public event Action? OnDisconnected; + + /// + /// true while connected to the server + /// + public bool Connected => _connection?.Connected ?? false; + + /// + /// Connects to the server and starts reading packets in background + /// + public async Task ConnectAsync() { + PacketRegistrar.RegisterAllPackets(); + await _client.ConnectAsync(address, port, _cts.Token); + + _connection = new NetworkConnection(0, _client); + _connection.OnPacketReceived += PacketReceivedAsync; + _connection.OnDisconnected += _ => OnDisconnected?.Invoke(); + + // read packets and watch for a dead server in background + _ = _connection.RunAsync(_cts.Token); + _ = TimeoutLoopAsync(_connection, _cts.Token); + } + + /// + /// Sends a packet to the server + /// + /// the packet to send + public async Task SendPacketAsync(IPacket packet) { + if (_connection == null) { + return; + } + + await _connection.SendPacketAsync(packet, _cts.Token); + } + + /// + /// Closes the connection + /// + public void Disconnect() { + _cts.Cancel(); + _connection?.Close(); + } + + private static async Task TimeoutLoopAsync(NetworkConnection connection, CancellationToken ct) { + using var timer = new PeriodicTimer(TIMEOUT_CHECK_INTERVAL); + + try { + while (await timer.WaitForNextTickAsync(ct)) { + // the server pings every 10 seconds, a long silence means it's gone + if (DateTime.UtcNow - connection.LastReceived > TIMEOUT) { + connection.Close(); + return; + } + } + } + catch (OperationCanceledException) { + // client disconnecting + } + } + + private async Task PacketReceivedAsync(NetworkConnection connection, IPacket packet) { + // echo keep alive packets back, everything else goes to the queue + if (packet is KeepAlivePacket keepAlive) { + await connection.SendPacketAsync(keepAlive, _cts.Token); + return; + } + + IncomingPackets.Enqueue(packet); + } + + public void Dispose() { + Disconnect(); + _cts.Dispose(); + _connection?.Dispose(); + _client.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/OpenPolytopia.Common/Network/INetworkSerializable.cs b/OpenPolytopia.Common/Network/INetworkSerializable.cs new file mode 100644 index 00000000..83d92e27 --- /dev/null +++ b/OpenPolytopia.Common/Network/INetworkSerializable.cs @@ -0,0 +1,24 @@ +namespace OpenPolytopia.Common.Network; + +/// +/// Interface for types that need to be serialized to be sent on the network +/// +public interface INetworkSerializable { + /// + /// Serialize data + /// + /// the bytes where to serialize into, use + public void Serialize(List bytes); + + /// + /// Deserialize data + /// + /// the buffer bytes where to read from + /// the index where to start reading + /// + /// It is assumed that every Deserialize operation increments index as needed. + /// For example, increments index by one + /// while increments it by four + /// + public void Deserialize(byte[] bytes, ref uint index); +} diff --git a/OpenPolytopia.Common/Network/NetworkConnection.cs b/OpenPolytopia.Common/Network/NetworkConnection.cs new file mode 100644 index 00000000..d506aebb --- /dev/null +++ b/OpenPolytopia.Common/Network/NetworkConnection.cs @@ -0,0 +1,128 @@ +namespace OpenPolytopia.Common.Network; + +using System.IO; +using System.Net.Sockets; +using Packets; + +/// +/// Wraps a connected to send and receive packets +/// +/// +/// Used by the client for its connection to the server +/// and by the server for every connected client +/// +public class NetworkConnection(uint id, TcpClient client) : IDisposable { + private readonly NetworkStream _stream = client.GetStream(); + private readonly SemaphoreSlim _writeLock = new(1, 1); + private int _closed; + + /// + /// Id of this connection; assigned by the server + /// + public uint Id { get; } = id; + + /// + /// Timestamp of the last packet received on this connection + /// + public DateTime LastReceived { get; private set; } = DateTime.UtcNow; + + /// + /// Fired for every packet received on this connection + /// + public event Func? OnPacketReceived; + + /// + /// Fired once when the connection gets closed for any reason + /// + public event Action? OnDisconnected; + + /// + /// true while the underlying socket is connected + /// + public bool Connected => _closed == 0 && client.Connected; + + /// + /// Sends a packet to the remote endpoint + /// + /// + /// This method is thread-safe + /// + /// the packet to send + /// cancellation token + public async Task SendPacketAsync(IPacket packet, CancellationToken ct = default) => + await SendFrameAsync(PacketProtocol.FramePacket(packet), ct); + + /// + /// Sends an already framed packet to the remote endpoint + /// + /// + /// This method is thread-safe + /// + /// the framed packet to send + /// cancellation token + public async Task SendFrameAsync(byte[] frame, CancellationToken ct = default) { + await _writeLock.WaitAsync(ct); + try { + await _stream.WriteAsync(frame, ct); + } + finally { + _writeLock.Release(); + } + } + + /// + /// Reads packets from the connection until it gets closed or the token gets cancelled + /// + /// + /// Fires for every packet + /// and at the end + /// + /// cancellation token + public async Task RunAsync(CancellationToken ct = default) { + try { + while (!ct.IsCancellationRequested) { + var packet = await PacketProtocol.ReadPacketAsync(_stream, ct); + LastReceived = DateTime.UtcNow; + + // unknown packet, skip it + if (packet == null) { + continue; + } + + var handler = OnPacketReceived; + if (handler != null) { + await handler(this, packet); + } + } + } + catch (Exception e) when (e is OperationCanceledException or EndOfStreamException or IOException + or ProtocolViolationException or ObjectDisposedException or SocketException) { + // connection closed or unusable, fall through to cleanup + } + finally { + Close(); + } + } + + /// + /// Closes the connection + /// + /// + /// Calling this multiple times is safe + /// + public void Close() { + // atomic exchange so two threads closing at once fire OnDisconnected only once + if (Interlocked.Exchange(ref _closed, 1) == 1) { + return; + } + + client.Close(); + OnDisconnected?.Invoke(this); + } + + public void Dispose() { + Close(); + _writeLock.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/OpenPolytopia.Common/Network/NetworkConstants.cs b/OpenPolytopia.Common/Network/NetworkConstants.cs new file mode 100644 index 00000000..367415cd --- /dev/null +++ b/OpenPolytopia.Common/Network/NetworkConstants.cs @@ -0,0 +1,21 @@ +namespace OpenPolytopia.Common.Network; + +public static class NetworkConstants { + /// + /// Version of the network protocol + /// + /// + /// The handshake fails if client and server have different versions + /// + public const string VERSION = "0.1.0"; + + /// + /// Default port the server listens on + /// + public const int DEFAULT_PORT = 6969; + + /// + /// Maximum size in bytes of a single packet, counting the packet id and the payload + /// + public const uint MAX_PACKET_SIZE = 1024 * 1024; +} diff --git a/OpenPolytopia.Common/Network/NetworkSerialization.cs b/OpenPolytopia.Common/Network/NetworkSerialization.cs new file mode 100644 index 00000000..7d3dc3bd --- /dev/null +++ b/OpenPolytopia.Common/Network/NetworkSerialization.cs @@ -0,0 +1,105 @@ +namespace OpenPolytopia.Common.Network; + +using System.Text; + +// Everything is written in network byte order (big-endian) +// Every Deserialize should increment the index + +public static class BoolSerialization { + public static void Serialize(this bool value, List bytes) => bytes.Add((byte)value.ToUInt()); + + public static void Deserialize(this ref bool value, byte[] bytes, ref uint index) => value = bytes[index++] == 1; +} + +public static class ByteSerialization { + public static void Serialize(this byte value, List bytes) => bytes.Add(value); + + public static void Deserialize(this ref byte value, byte[] bytes, ref uint index) => value = bytes[index++]; +} + +public static class UIntSerialization { + public static void Serialize(this uint value, List bytes) { + bytes.Add((byte)(value >> 24)); + bytes.Add((byte)(value >> 16)); + bytes.Add((byte)(value >> 8)); + bytes.Add((byte)value); + } + + public static byte[] Serialize(this uint value) => [ + (byte)(value >> 24), (byte)(value >> 16), (byte)(value >> 8), (byte)value + ]; + + public static void Deserialize(this ref uint value, byte[] bytes, ref uint index) { + value = ((uint)bytes[index] << 24) | ((uint)bytes[index + 1] << 16) | ((uint)bytes[index + 2] << 8) | + bytes[index + 3]; + index += 4; + } + + public static uint Read(byte[] bytes, ref uint index) { + var value = 0u; + value.Deserialize(bytes, ref index); + return value; + } +} + +public static class ULongSerialization { + public static void Serialize(this ulong value, List bytes) { + bytes.Add((byte)(value >> 56)); + bytes.Add((byte)(value >> 48)); + bytes.Add((byte)(value >> 40)); + bytes.Add((byte)(value >> 32)); + bytes.Add((byte)(value >> 24)); + bytes.Add((byte)(value >> 16)); + bytes.Add((byte)(value >> 8)); + bytes.Add((byte)value); + } + + public static void Deserialize(this ref ulong value, byte[] bytes, ref uint index) { + value = 0; + for (var i = 0; i < 8; i++) { + value = (value << 8) | bytes[index++]; + } + } + + public static ulong Read(byte[] bytes, ref uint index) { + var value = 0ul; + value.Deserialize(bytes, ref index); + return value; + } +} + +public static class StringSerialization { + public static void Serialize(this string value, List bytes) { + var encoded = Encoding.UTF8.GetBytes(value); + ((uint)encoded.Length).Serialize(bytes); + bytes.AddRange(encoded); + } + + public static string Read(byte[] bytes, ref uint index) { + var length = UIntSerialization.Read(bytes, ref index); + var value = Encoding.UTF8.GetString(bytes, (int)index, (int)length); + index += length; + return value; + } +} + +public static class ListSerialization { + public static void Serialize(this List list, List bytes) where T : INetworkSerializable { + ((uint)list.Count).Serialize(bytes); + foreach (var element in list) { + element.Serialize(bytes); + } + } + + public static void Deserialize(this List list, byte[] bytes, ref uint index) + where T : INetworkSerializable, new() { + var length = UIntSerialization.Read(bytes, ref index); + + for (var i = 0; i < length; i++) { + var value = new T(); + value.Deserialize(bytes, ref index); + list.Add(value); + } + } + +} diff --git a/OpenPolytopia.Common/Network/PacketProtocol.cs b/OpenPolytopia.Common/Network/PacketProtocol.cs new file mode 100644 index 00000000..efee6ff8 --- /dev/null +++ b/OpenPolytopia.Common/Network/PacketProtocol.cs @@ -0,0 +1,102 @@ +namespace OpenPolytopia.Common.Network; + +using System.IO; +using Packets; + +/// +/// Thrown when a packet violates the wire format, like being malformed or too big +/// +public class ProtocolViolationException(string message) : Exception(message); + +/// +/// Implements the wire format of the protocol +/// +/// +/// Every packet is framed as [uint content length][uint packet id][payload], +/// with every integer in network byte order (big-endian); +/// the content length covers the packet id and the payload +/// +public static class PacketProtocol { + /// + /// Frames a packet into a byte list ready to be sent on the wire + /// + /// the packet to frame + /// the list where to append the framed packet + /// if the framed packet exceeds + public static void FramePacket(IPacket packet, List bytes) { + // remember where this packet starts to insert the content length later + var startIndex = bytes.Count; + + // serialize the id and the payload + PacketRegistrar.GetPacketId(packet).Serialize(bytes); + packet.Serialize(bytes); + + // fail on the sender instead of disconnecting the receivers + var contentLength = (uint)(bytes.Count - startIndex); + if (contentLength > NetworkConstants.MAX_PACKET_SIZE) { + bytes.RemoveRange(startIndex, bytes.Count - startIndex); + throw new ProtocolViolationException($"Packet too big: {contentLength} bytes"); + } + + // insert the content length before the id + bytes.InsertRange(startIndex, contentLength.Serialize()); + } + + /// + /// Frames a packet into a byte array ready to be sent on the wire + /// + /// the packet to frame + /// the framed packet + public static byte[] FramePacket(IPacket packet) { + List bytes = []; + FramePacket(packet, bytes); + return [.. bytes]; + } + + /// + /// Reads exactly one packet from the stream + /// + /// + /// Blocks until a full packet is available or the stream gets closed + /// + /// the stream to read from + /// cancellation token + /// the packet or null if the packet id isn't registered + /// if the remote endpoint violates the protocol + /// if the connection gets closed + public static async Task ReadPacketAsync(Stream stream, CancellationToken ct = default) { + // read the content length + var header = new byte[4]; + await stream.ReadExactlyAsync(header, ct); + var index = 0u; + var contentLength = UIntSerialization.Read(header, ref index); + + // a packet must contain at least the packet id + if (contentLength is < 4 or > NetworkConstants.MAX_PACKET_SIZE) { + throw new ProtocolViolationException($"Invalid packet length: {contentLength}"); + } + + // read the whole content (packet id + payload) + var content = new byte[contentLength]; + await stream.ReadExactlyAsync(content, ct); + + // parse the packet id and create the corresponding packet + index = 0u; + var packetId = UIntSerialization.Read(content, ref index); + var packet = PacketRegistrar.CreatePacket(packetId); + + // skip unknown packets so older clients can talk to newer servers + if (packet == null) { + return null; + } + + try { + packet.Deserialize(content, ref index); + } + catch (Exception e) when (e is IndexOutOfRangeException or ArgumentOutOfRangeException) { + throw new ProtocolViolationException($"Malformed payload for packet {packet.GetType().Name}"); + } + + return packet; + } +} diff --git a/OpenPolytopia.Common/Network/PacketRegistrar.cs b/OpenPolytopia.Common/Network/PacketRegistrar.cs new file mode 100644 index 00000000..ba0e6994 --- /dev/null +++ b/OpenPolytopia.Common/Network/PacketRegistrar.cs @@ -0,0 +1,70 @@ +namespace OpenPolytopia.Common.Network; + +using Packets; + +public static class PacketRegistrar { + private static readonly Dictionary> _packetFactories = new(32); + private static readonly Dictionary _packetIds = new(32); + private static readonly object _lock = new(); + private static bool _registered; + + /// + /// Register a new packet with the given ID + /// + /// the id of the packet + /// the type of the packet + public static void RegisterPacket(uint id) where T : IPacket, new() { + _packetFactories.Add(id, () => new T()); + _packetIds.Add(typeof(T), id); + } + + /// + /// Creates a new empty packet instance given the ID + /// + /// the id of the packet + /// a new packet instance or null if the id isn't registered + public static IPacket? CreatePacket(uint id) => + _packetFactories.TryGetValue(id, out var factory) ? factory() : null; + + /// + /// Returns a packet ID given its type + /// + /// the packet + /// the packet ID + public static uint GetPacketId(IPacket packet) => _packetIds[packet.GetType()]; + + /// + /// Registers all the packets of the protocol + /// + /// + /// Calling this multiple times is safe + /// + public static void RegisterAllPackets() { + lock (_lock) { + if (_registered) { + return; + } + + _registered = true; + + RegisterPacket(0); + RegisterPacket(1); + RegisterPacket(2); + RegisterPacket(3); + RegisterPacket(4); + RegisterPacket(5); + RegisterPacket(6); + RegisterPacket(7); + RegisterPacket(8); + RegisterPacket(9); + RegisterPacket(10); + RegisterPacket(11); + RegisterPacket(12); + RegisterPacket(13); + RegisterPacket(14); + RegisterPacket(15); + RegisterPacket(16); + RegisterPacket(17); + } + } +} diff --git a/OpenPolytopia.Common/Network/Packets/HandshakePacket.cs b/OpenPolytopia.Common/Network/Packets/HandshakePacket.cs new file mode 100644 index 00000000..a0cdcada --- /dev/null +++ b/OpenPolytopia.Common/Network/Packets/HandshakePacket.cs @@ -0,0 +1,43 @@ +namespace OpenPolytopia.Common.Network.Packets; + +/// +/// First packet sent by the client to check protocol compatibility +/// +public class HandshakePacket : IPacket { + /// + /// Protocol version of the client, see + /// + public string Version = ""; + + public void Serialize(List bytes) => Version.Serialize(bytes); + + public void Deserialize(byte[] bytes, ref uint index) => Version = StringSerialization.Read(bytes, ref index); +} + +/// +/// Server response to +/// +public class HandshakeResponsePacket : IPacket { + /// + /// true if the client version is compatible with the server + /// + public bool Ok; + + /// + /// Id assigned to the client by the server + /// + /// + /// Valid only if is true + /// + public uint PlayerId; + + public void Serialize(List bytes) { + Ok.Serialize(bytes); + PlayerId.Serialize(bytes); + } + + public void Deserialize(byte[] bytes, ref uint index) { + Ok.Deserialize(bytes, ref index); + PlayerId.Deserialize(bytes, ref index); + } +} diff --git a/OpenPolytopia.Common/Network/Packets/IPacket.cs b/OpenPolytopia.Common/Network/Packets/IPacket.cs new file mode 100644 index 00000000..c5ae32ce --- /dev/null +++ b/OpenPolytopia.Common/Network/Packets/IPacket.cs @@ -0,0 +1,12 @@ +namespace OpenPolytopia.Common.Network.Packets; + +/// +/// Interface to declare a packet +/// +/// +/// A packet is sent on the wire as [uint content length][uint packet id][payload] +/// where the content length covers the packet id and the payload. +/// Every packet must be registered in with a unique id +/// and must have a parameterless constructor +/// +public interface IPacket : INetworkSerializable; diff --git a/OpenPolytopia.Common/Network/Packets/KeepAlivePacket.cs b/OpenPolytopia.Common/Network/Packets/KeepAlivePacket.cs new file mode 100644 index 00000000..7b4b28b9 --- /dev/null +++ b/OpenPolytopia.Common/Network/Packets/KeepAlivePacket.cs @@ -0,0 +1,15 @@ +namespace OpenPolytopia.Common.Network.Packets; + +/// +/// Sent periodically by the server to check if a client is still alive +/// +/// +/// The client echoes it back; a client that doesn't send anything for too long gets disconnected +/// +public class KeepAlivePacket : IPacket { + public void Serialize(List bytes) { + } + + public void Deserialize(byte[] bytes, ref uint index) { + } +} diff --git a/OpenPolytopia.Common/Network/Packets/LobbyActionResult.cs b/OpenPolytopia.Common/Network/Packets/LobbyActionResult.cs new file mode 100644 index 00000000..377d93bc --- /dev/null +++ b/OpenPolytopia.Common/Network/Packets/LobbyActionResult.cs @@ -0,0 +1,23 @@ +namespace OpenPolytopia.Common.Network.Packets; + +/// +/// Result of a lobby-related request +/// +public enum LobbyActionResult : byte { + Ok = 0, + NotRegistered = 1, + LobbyNotFound = 2, + LobbyAlreadyStarted = 3, + LobbyFull = 4, + AlreadyJoinedLobby = 5, + NotInLobby = 6, + InvalidParameters = 7, + TooManyLobbies = 8, +} + +public static class LobbyActionResultSerialization { + public static void Serialize(this LobbyActionResult value, List bytes) => bytes.Add((byte)value); + + public static void Deserialize(this ref LobbyActionResult value, byte[] bytes, ref uint index) => + value = (LobbyActionResult)bytes[index++]; +} diff --git a/OpenPolytopia.Common/Network/Packets/LobbyPackets.cs b/OpenPolytopia.Common/Network/Packets/LobbyPackets.cs new file mode 100644 index 00000000..37620ea6 --- /dev/null +++ b/OpenPolytopia.Common/Network/Packets/LobbyPackets.cs @@ -0,0 +1,275 @@ +namespace OpenPolytopia.Common.Network.Packets; + +/// +/// Asks the server for the list of all the lobbies +/// +public class GetLobbiesPacket : IPacket { + public void Serialize(List bytes) { } + + public void Deserialize(byte[] bytes, ref uint index) { } +} + +/// +/// Server response to +/// +public class GetLobbiesResponsePacket : IPacket { + /// + /// All the lobbies currently on the server + /// + public List Lobbies = []; + + public void Serialize(List bytes) => Lobbies.Serialize(bytes); + + public void Deserialize(byte[] bytes, ref uint index) => Lobbies.Deserialize(bytes, ref index); +} + +/// +/// Creates a new lobby +/// +/// +/// The sender automatically joins the new lobby +/// +public class CreateLobbyPacket : IPacket { + /// + /// Number of max players that can join the lobby + /// + public uint MaxPlayers; + + /// + /// Tribe chosen by the player creating the lobby + /// + public uint Tribe; + + public void Serialize(List bytes) { + MaxPlayers.Serialize(bytes); + Tribe.Serialize(bytes); + } + + public void Deserialize(byte[] bytes, ref uint index) { + MaxPlayers.Deserialize(bytes, ref index); + Tribe.Deserialize(bytes, ref index); + } +} + +/// +/// Server response to +/// +public class CreateLobbyResponsePacket : IPacket { + /// + /// Result of the operation + /// + public LobbyActionResult Result; + + /// + /// Id of the newly created lobby + /// + /// + /// Valid only if is + /// + public ulong LobbyId; + + public void Serialize(List bytes) { + Result.Serialize(bytes); + LobbyId.Serialize(bytes); + } + + public void Deserialize(byte[] bytes, ref uint index) { + Result.Deserialize(bytes, ref index); + LobbyId.Deserialize(bytes, ref index); + } +} + +/// +/// Joins an existing lobby +/// +public class JoinLobbyPacket : IPacket { + /// + /// Id of the lobby to join + /// + public ulong LobbyId; + + /// + /// Tribe chosen by the player + /// + public uint Tribe; + + public void Serialize(List bytes) { + LobbyId.Serialize(bytes); + Tribe.Serialize(bytes); + } + + public void Deserialize(byte[] bytes, ref uint index) { + LobbyId.Deserialize(bytes, ref index); + Tribe.Deserialize(bytes, ref index); + } +} + +/// +/// Server response to +/// +public class JoinLobbyResponsePacket : IPacket { + /// + /// Result of the operation + /// + public LobbyActionResult Result; + + /// + /// Id of the lobby the player tried to join + /// + public ulong LobbyId; + + public void Serialize(List bytes) { + Result.Serialize(bytes); + LobbyId.Serialize(bytes); + } + + public void Deserialize(byte[] bytes, ref uint index) { + Result.Deserialize(bytes, ref index); + LobbyId.Deserialize(bytes, ref index); + } +} + +/// +/// Leaves a lobby the player joined before +/// +public class LeaveLobbyPacket : IPacket { + /// + /// Id of the lobby to leave + /// + public ulong LobbyId; + + public void Serialize(List bytes) => LobbyId.Serialize(bytes); + + public void Deserialize(byte[] bytes, ref uint index) => LobbyId.Deserialize(bytes, ref index); +} + +/// +/// Server response to +/// +public class LeaveLobbyResponsePacket : IPacket { + /// + /// Result of the operation + /// + public LobbyActionResult Result; + + /// + /// Id of the lobby the player tried to leave + /// + public ulong LobbyId; + + public void Serialize(List bytes) { + Result.Serialize(bytes); + LobbyId.Serialize(bytes); + } + + public void Deserialize(byte[] bytes, ref uint index) { + Result.Deserialize(bytes, ref index); + LobbyId.Deserialize(bytes, ref index); + } +} + +/// +/// Updates the ready state of the player in a lobby +/// +/// +/// When all the players in a lobby are ready, the game starts +/// +public class SetReadyPacket : IPacket { + /// + /// Id of the lobby + /// + public ulong LobbyId; + + /// + /// true to mark the player as ready, false to remove the ready state + /// + public bool Ready; + + public void Serialize(List bytes) { + LobbyId.Serialize(bytes); + Ready.Serialize(bytes); + } + + public void Deserialize(byte[] bytes, ref uint index) { + LobbyId.Deserialize(bytes, ref index); + Ready.Deserialize(bytes, ref index); + } +} + +/// +/// Server response to +/// +public class SetReadyResponsePacket : IPacket { + /// + /// Result of the operation + /// + public LobbyActionResult Result; + + /// + /// Id of the lobby + /// + public ulong LobbyId; + + public void Serialize(List bytes) { + Result.Serialize(bytes); + LobbyId.Serialize(bytes); + } + + public void Deserialize(byte[] bytes, ref uint index) { + Result.Deserialize(bytes, ref index); + LobbyId.Deserialize(bytes, ref index); + } +} + +/// +/// Broadcast by the server when a lobby gets created or modified +/// +public class LobbyUpdatedPacket : IPacket { + /// + /// The new state of the lobby + /// + public LobbyData Lobby = new(); + + public void Serialize(List bytes) => Lobby.Serialize(bytes); + + public void Deserialize(byte[] bytes, ref uint index) => Lobby.Deserialize(bytes, ref index); +} + +/// +/// Broadcast by the server when a lobby gets deleted +/// +public class LobbyDeletedPacket : IPacket { + /// + /// Id of the deleted lobby + /// + public ulong LobbyId; + + public void Serialize(List bytes) => LobbyId.Serialize(bytes); + + public void Deserialize(byte[] bytes, ref uint index) => LobbyId.Deserialize(bytes, ref index); +} + +/// +/// Sent by the server to every player of a lobby when its game starts +/// +public class GameStartedPacket : IPacket { + /// + /// Id of the lobby whose game started + /// + public ulong LobbyId; + + /// + /// The players taking part in the game + /// + public List Players = []; + + public void Serialize(List bytes) { + LobbyId.Serialize(bytes); + Players.Serialize(bytes); + } + + public void Deserialize(byte[] bytes, ref uint index) { + LobbyId.Deserialize(bytes, ref index); + Players.Deserialize(bytes, ref index); + } +} diff --git a/OpenPolytopia.Common/Network/Packets/SetNamePacket.cs b/OpenPolytopia.Common/Network/Packets/SetNamePacket.cs new file mode 100644 index 00000000..7a920b94 --- /dev/null +++ b/OpenPolytopia.Common/Network/Packets/SetNamePacket.cs @@ -0,0 +1,29 @@ +namespace OpenPolytopia.Common.Network.Packets; + +/// +/// Registers the player on the server or renames him +/// +public class SetNamePacket : IPacket { + /// + /// The new name of the player + /// + public string Name = ""; + + public void Serialize(List bytes) => Name.Serialize(bytes); + + public void Deserialize(byte[] bytes, ref uint index) => Name = StringSerialization.Read(bytes, ref index); +} + +/// +/// Server response to +/// +public class SetNameResponsePacket : IPacket { + /// + /// true if the name was accepted + /// + public bool Ok; + + public void Serialize(List bytes) => Ok.Serialize(bytes); + + public void Deserialize(byte[] bytes, ref uint index) => Ok.Deserialize(bytes, ref index); +} diff --git a/OpenPolytopia.Common/Network/ServerConnection.cs b/OpenPolytopia.Common/Network/ServerConnection.cs new file mode 100644 index 00000000..b11cf650 --- /dev/null +++ b/OpenPolytopia.Common/Network/ServerConnection.cs @@ -0,0 +1,316 @@ +namespace OpenPolytopia.Common.Network; + +using System.Collections.Concurrent; +using System.Net.Sockets; +using System.Threading.Channels; +using Packets; + +/// +/// Accepts TCP connections and manages one per client +/// +/// +/// Outgoing packets go through one bounded queue per client, so they get delivered +/// in the order they were enqueued and a slow client can't delay the others +/// nor grow the memory of the server by never draining his queue. +/// Every it sends a to every client +/// and disconnects the ones that didn't send anything back for longer than ; +/// clients that don't complete a handshake within get disconnected too +/// +/// the port to listen on +/// the ip address to bind to; null to listen on every interface +public class ServerConnection(int port, string? bindAddress = null) : IDisposable { + private static readonly TimeSpan KEEP_ALIVE_INTERVAL = TimeSpan.FromSeconds(10); + private static readonly TimeSpan TIMEOUT = TimeSpan.FromSeconds(30); + private static readonly TimeSpan HANDSHAKE_TIMEOUT = TimeSpan.FromSeconds(10); + private static readonly TimeSpan SEND_TIMEOUT = TimeSpan.FromSeconds(10); + private static readonly TimeSpan ACCEPT_RETRY_DELAY = TimeSpan.FromSeconds(1); + + /// + /// Max frames queued for a single client; way more than lobby traffic ever needs + /// + private const int MAX_QUEUED_FRAMES = 256; + + private readonly TcpListener _listener = bindAddress == null + ? TcpListener.Create(port) + : new TcpListener(System.Net.IPAddress.Parse(bindAddress), port); + private readonly ConcurrentDictionary _clients = new(); + private readonly CancellationTokenSource _cts = new(); + private uint _nextId; + + /// + /// Fired when a new client connects + /// + /// + /// Fired before any packet is received from the client + /// + public event Action? OnClientConnected; + + /// + /// Fired when a client disconnects + /// + public event Action? OnClientDisconnected; + + /// + /// Fired for every packet received from any client + /// + public event Func? OnPacketReceived; + + /// + /// Listens to incoming connections + /// + /// + /// Runs until gets called + /// + public async Task RunAsync() { + PacketRegistrar.RegisterAllPackets(); + _listener.Start(); + + // manage the keep alive in background + _ = KeepAliveLoopAsync(_cts.Token); + + try { + while (!_cts.IsCancellationRequested) { + TcpClient tcpClient; + try { + tcpClient = await _listener.AcceptTcpClientAsync(_cts.Token); + } + catch (SocketException e) { + // transient failure, like a connection aborted mid-handshake; keep accepting the others. + // back off a little so a persistent failure, like running out of file descriptors, + // doesn't turn this into a busy loop + Console.Error.WriteLine($"Failed to accept a connection: {e.Message}"); + await Task.Delay(ACCEPT_RETRY_DELAY, _cts.Token); + continue; + } + + var id = Interlocked.Increment(ref _nextId); + + var connection = new NetworkConnection(id, tcpClient); + connection.OnPacketReceived += ClientPacketReceivedAsync; + connection.OnDisconnected += ClientDisconnected; + + var client = new Client(connection); + _clients[id] = client; + + OnClientConnected?.Invoke(connection); + + // manage the client in background + _ = connection.RunAsync(_cts.Token); + _ = SenderLoopAsync(client, _cts.Token); + } + } + catch (OperationCanceledException) { + // server stopping + } + finally { + _listener.Stop(); + + foreach (var client in _clients.Values) { + client.Connection.Close(); + } + } + } + + /// + /// Stops the server and disconnects every client + /// + public void Stop() => _cts.Cancel(); + + /// + /// Marks a client as having completed the handshake + /// + /// + /// Only clients that completed the handshake receive broadcasts and keep alive packets; + /// the others get disconnected after + /// + /// the id of the client + public void CompleteHandshake(uint id) { + if (_clients.TryGetValue(id, out var client)) { + client.HandshakeDone = true; + } + } + + /// + /// Checks if a client completed the handshake + /// + /// the id of the client + public bool IsHandshakeDone(uint id) => _clients.TryGetValue(id, out var client) && client.HandshakeDone; + + /// + /// Disconnects a client after delivering the packets already queued for him + /// + /// the id of the client + public void Kick(uint id) { + if (_clients.TryGetValue(id, out var client)) { + client.Kicked = true; + client.Outgoing.Writer.TryComplete(); + } + } + + /// + /// Queues a packet for a single client + /// + /// the id of the client + /// the packet to send + public void SendTo(uint id, IPacket packet) => SendTo(id, PacketProtocol.FramePacket(packet)); + + /// + /// Queues an already framed packet for a single client + /// + /// the id of the client + /// the framed packet to send + public void SendTo(uint id, byte[] frame) { + if (_clients.TryGetValue(id, out var client)) { + Enqueue(client, frame); + } + } + + /// + /// Queues a packet for every client that completed the handshake + /// + /// the packet to broadcast + public void Broadcast(IPacket packet) => Broadcast(PacketProtocol.FramePacket(packet)); + + /// + /// Queues an already framed packet for every client that completed the handshake + /// + /// the framed packet to broadcast + public void Broadcast(byte[] frame) { + foreach (var client in _clients.Values) { + if (client.HandshakeDone) { + Enqueue(client, frame); + } + } + } + + /// + /// Queues a packet for the given clients + /// + /// the ids of the clients + /// the packet to send + public void BroadcastTo(IEnumerable ids, IPacket packet) { + var frame = PacketProtocol.FramePacket(packet); + foreach (var id in ids) { + SendTo(id, frame); + } + } + + /// + /// Queues a frame for a client + /// + /// + /// A full queue means the client reads too slowly to keep up with the server, + /// so he gets disconnected instead of eating up memory + /// + /// the client to queue the frame for + /// the framed packet to queue + private static void Enqueue(Client client, byte[] frame) { + if (!client.Outgoing.Writer.TryWrite(frame) && !client.Kicked) { + client.Connection.Close(); + } + } + + /// + /// Sends the queued packets of a client one at a time, in order + /// + /// + /// If a send fails or takes longer than , the client gets disconnected + /// + private static async Task SenderLoopAsync(Client client, CancellationToken ct) { + try { + await foreach (var frame in client.Outgoing.Reader.ReadAllAsync(ct)) { + // timeout the send so a client that stopped reading can't pile up frames forever + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(SEND_TIMEOUT); + await client.Connection.SendFrameAsync(frame, cts.Token); + } + } + catch (Exception) { + // send failure or server stopping, close below + } + + // the queue only completes on a kick, disconnect once it's drained + client.Connection.Close(); + } + + private async Task ClientPacketReceivedAsync(NetworkConnection connection, IPacket packet) { + // the keep alive response is managed here, the rest is forwarded + if (packet is KeepAlivePacket) { + return; + } + + var handler = OnPacketReceived; + if (handler != null) { + await handler(connection, packet); + } + } + + private void ClientDisconnected(NetworkConnection connection) { + if (_clients.TryRemove(connection.Id, out var client)) { + // stop the sender loop + client.Outgoing.Writer.TryComplete(); + } + + OnClientDisconnected?.Invoke(connection); + } + + private async Task KeepAliveLoopAsync(CancellationToken ct) { + using var timer = new PeriodicTimer(KEEP_ALIVE_INTERVAL); + + try { + var keepAlive = PacketProtocol.FramePacket(new KeepAlivePacket()); + + while (await timer.WaitForNextTickAsync(ct)) { + var now = DateTime.UtcNow; + + foreach (var client in _clients.Values) { + // kick clients that timed out + if (now - client.Connection.LastReceived > TIMEOUT) { + client.Connection.Close(); + continue; + } + + // kick clients that connected but never completed a handshake + if (!client.HandshakeDone) { + if (now - client.ConnectedAt > HANDSHAKE_TIMEOUT) { + client.Connection.Close(); + } + + continue; + } + + SendTo(client.Connection.Id, keepAlive); + } + } + } + catch (OperationCanceledException) { + // server stopping + } + } + + public void Dispose() { + Stop(); + _cts.Dispose(); + _listener.Dispose(); + GC.SuppressFinalize(this); + } + + /// + /// Server-side state of a connected client + /// + private sealed class Client(NetworkConnection connection) { + public NetworkConnection Connection { get; } = connection; + public DateTime ConnectedAt { get; } = DateTime.UtcNow; + public volatile bool HandshakeDone; + public volatile bool Kicked; + + /// + /// Packets waiting to be sent to this client + /// + /// + /// Bounded, so a client that stops draining it can't grow the memory of the server forever + /// + public Channel Outgoing { get; } = + Channel.CreateBounded(new BoundedChannelOptions(MAX_QUEUED_FRAMES) { SingleReader = true }); + } +} diff --git a/OpenPolytopia.Server/GameServer.cs b/OpenPolytopia.Server/GameServer.cs new file mode 100644 index 00000000..ec4a527f --- /dev/null +++ b/OpenPolytopia.Server/GameServer.cs @@ -0,0 +1,322 @@ +namespace OpenPolytopia.Server; + +using OpenPolytopia.Common; +using OpenPolytopia.Common.Network; +using OpenPolytopia.Common.Network.Packets; + +/// +/// Manages players, lobbies and starting games on top of a +/// +/// +/// Every packet is queued while holding the state lock, +/// so the clients receive the lobby updates in the same order the server applied them +/// +/// the port to listen on +/// the ip address to bind to; null to listen on every interface +public class GameServer(int port, string? bindAddress = null) : IDisposable { + /// + /// How often the server checks for lobbies to start + /// + private static readonly TimeSpan START_LOBBY_INTERVAL = TimeSpan.FromSeconds(5); + + /// + /// Max lobbies the server accepts before refusing new ones + /// + private const int MAX_LOBBIES = 100; + + private readonly ServerConnection _server = new(port, bindAddress); + private readonly LobbyManager _lobbyManager = new(); + private readonly Dictionary _playerNames = new(); + + // guards _lobbyManager and _playerNames: packet handlers run on many client tasks + private readonly SemaphoreSlim _stateLock = new(1, 1); + + private readonly CancellationTokenSource _cts = new(); + + /// + /// Runs the server until gets called + /// + public async Task RunAsync() { + _server.OnPacketReceived += ManagePacketAsync; + _server.OnClientDisconnected += connection => _ = ClientDisconnectedAsync(connection); + + // check for lobbies to start in background + _ = StartLobbiesLoopAsync(_cts.Token); + + Console.WriteLine($"Server listening on {bindAddress ?? "*"}:{port}"); + await _server.RunAsync(); + } + + /// + /// Stops the server + /// + public void Stop() { + _cts.Cancel(); + _server.Stop(); + } + + private async Task ManagePacketAsync(NetworkConnection connection, IPacket packet) { + try { + await DispatchPacketAsync(connection, packet); + } + catch (Exception e) { + // log the error without taking the server down + Console.Error.WriteLine($"Error while managing {packet.GetType().Name} from client {connection.Id}: {e}"); + } + } + + private async Task DispatchPacketAsync(NetworkConnection connection, IPacket packet) { + if (packet is HandshakePacket handshake) { + ManageHandshake(connection, handshake); + return; + } + + // kick clients that send anything else before a successful handshake + if (!_server.IsHandshakeDone(connection.Id)) { + connection.Close(); + return; + } + + switch (packet) { + // register the player or rename him + case SetNamePacket setName: + await ManageSetNameAsync(connection, setName); + break; + // respond with all the lobbies currently on the server + case GetLobbiesPacket: + await ManageGetLobbiesAsync(connection); + break; + // create a new lobby with the sender inside + case CreateLobbyPacket createLobby: + await ManageCreateLobbyAsync(connection, createLobby); + break; + // add the sender to an existing lobby + case JoinLobbyPacket joinLobby: + await ManageJoinLobbyAsync(connection, joinLobby); + break; + // remove the sender from a lobby + case LeaveLobbyPacket leaveLobby: + await ManageLeaveLobbyAsync(connection, leaveLobby); + break; + // update the ready state of the sender in a lobby + case SetReadyPacket setReady: + await ManageSetReadyAsync(connection, setReady); + break; + } + } + + private void ManageHandshake(NetworkConnection connection, HandshakePacket packet) { + var ok = packet.Version == NetworkConstants.VERSION; + + if (ok) { + _server.CompleteHandshake(connection.Id); + } + + _server.SendTo(connection.Id, new HandshakeResponsePacket { Ok = ok, PlayerId = connection.Id }); + + // kick clients with an incompatible version, after the response gets delivered + if (!ok) { + _server.Kick(connection.Id); + } + } + + private async Task ManageSetNameAsync(NetworkConnection connection, SetNamePacket packet) { + var name = packet.Name.Trim(); + var ok = name.Length is > 0 and <= 32; + + await _stateLock.WaitAsync(); + try { + if (ok) { + _playerNames[connection.Id] = name; + + // propagate the rename into the lobbies the player joined + List updated = []; + _lobbyManager.RenamePlayerInLobbies(connection.Id, name, updated); + foreach (var lobby in updated) { + _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby }); + } + } + + _server.SendTo(connection.Id, new SetNameResponsePacket { Ok = ok }); + } + finally { + _stateLock.Release(); + } + } + + private async Task ManageGetLobbiesAsync(NetworkConnection connection) { + await _stateLock.WaitAsync(); + try { + _server.SendTo(connection.Id, new GetLobbiesResponsePacket { Lobbies = [.. _lobbyManager.Lobbies] }); + } + finally { + _stateLock.Release(); + } + } + + private async Task ManageCreateLobbyAsync(NetworkConnection connection, CreateLobbyPacket packet) { + await _stateLock.WaitAsync(); + try { + LobbyData? lobby = null; + LobbyActionResult result; + + if (!_playerNames.TryGetValue(connection.Id, out var name)) { + result = LobbyActionResult.NotRegistered; + } + else if (packet.MaxPlayers is < 2 or > 16 || !Enum.IsDefined((TribeType)packet.Tribe)) { + result = LobbyActionResult.InvalidParameters; + } + // one lobby per player and a global cap, or a client could flood the server + else if (_lobbyManager.IsPlayerInAnyLobby(connection.Id)) { + result = LobbyActionResult.AlreadyJoinedLobby; + } + else if (_lobbyManager.LobbiesCount >= MAX_LOBBIES) { + result = LobbyActionResult.TooManyLobbies; + } + else { + result = LobbyActionResult.Ok; + lobby = _lobbyManager.CreateLobby(packet.MaxPlayers, + new LobbyPlayerData { PlayerId = connection.Id, Name = name, Tribe = packet.Tribe }); + } + + _server.SendTo(connection.Id, new CreateLobbyResponsePacket { Result = result, LobbyId = lobby?.Id ?? 0 }); + + if (lobby != null) { + _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby }); + } + } + finally { + _stateLock.Release(); + } + } + + private async Task ManageJoinLobbyAsync(NetworkConnection connection, JoinLobbyPacket packet) { + await _stateLock.WaitAsync(); + try { + LobbyActionResult result; + + if (!_playerNames.TryGetValue(connection.Id, out var name)) { + result = LobbyActionResult.NotRegistered; + } + else if (!Enum.IsDefined((TribeType)packet.Tribe)) { + result = LobbyActionResult.InvalidParameters; + } + // one lobby per player, or a client could flood the server + else if (_lobbyManager.IsPlayerInAnyLobby(connection.Id)) { + result = LobbyActionResult.AlreadyJoinedLobby; + } + else { + result = _lobbyManager.JoinLobby(packet.LobbyId, + new LobbyPlayerData { PlayerId = connection.Id, Name = name, Tribe = packet.Tribe }); + } + + _server.SendTo(connection.Id, new JoinLobbyResponsePacket { Result = result, LobbyId = packet.LobbyId }); + + if (result == LobbyActionResult.Ok && _lobbyManager[packet.LobbyId] is { } lobby) { + _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby }); + } + } + finally { + _stateLock.Release(); + } + } + + private async Task ManageLeaveLobbyAsync(NetworkConnection connection, LeaveLobbyPacket packet) { + await _stateLock.WaitAsync(); + try { + var result = _lobbyManager.LeaveLobby(packet.LobbyId, connection.Id); + + _server.SendTo(connection.Id, new LeaveLobbyResponsePacket { Result = result, LobbyId = packet.LobbyId }); + + if (result == LobbyActionResult.Ok && _lobbyManager[packet.LobbyId] is { } lobby) { + // remove the lobby if it became empty + if (lobby.PlayersCount == 0) { + _lobbyManager.RemoveLobby(lobby.Id); + _server.Broadcast(new LobbyDeletedPacket { LobbyId = lobby.Id }); + } + else { + _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby }); + } + } + } + finally { + _stateLock.Release(); + } + } + + private async Task ManageSetReadyAsync(NetworkConnection connection, SetReadyPacket packet) { + await _stateLock.WaitAsync(); + try { + var result = _lobbyManager.SetReady(packet.LobbyId, connection.Id, packet.Ready); + + _server.SendTo(connection.Id, new SetReadyResponsePacket { Result = result, LobbyId = packet.LobbyId }); + + if (result == LobbyActionResult.Ok && _lobbyManager[packet.LobbyId] is { } lobby) { + _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby }); + } + } + finally { + _stateLock.Release(); + } + } + + private async Task ClientDisconnectedAsync(NetworkConnection connection) { + await _stateLock.WaitAsync(); + try { + _playerNames.Remove(connection.Id); + + List updated = []; + List deletedIds = []; + _lobbyManager.RemovePlayerFromAllLobbies(connection.Id, updated, deletedIds); + + foreach (var lobby in updated) { + _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby }); + } + + foreach (var id in deletedIds) { + _server.Broadcast(new LobbyDeletedPacket { LobbyId = id }); + } + } + finally { + _stateLock.Release(); + } + } + + private async Task StartLobbiesLoopAsync(CancellationToken ct) { + using var timer = new PeriodicTimer(START_LOBBY_INTERVAL); + + try { + while (await timer.WaitForNextTickAsync(ct)) { + await _stateLock.WaitAsync(ct); + try { + foreach (var lobby in _lobbyManager.TakeStartingLobbies()) { + // TODO: world generation + // TODO: initialize game data + // TODO: add players to the game + + Console.WriteLine($"Starting game for lobby {lobby.Id} with {lobby.PlayersCount} players"); + + // notify the players that their game started and remove the lobby from the list + _server.BroadcastTo(lobby.Players.Select(player => player.PlayerId), + new GameStartedPacket { LobbyId = lobby.Id, Players = lobby.Players }); + _server.Broadcast(new LobbyDeletedPacket { LobbyId = lobby.Id }); + } + } + finally { + _stateLock.Release(); + } + } + } + catch (OperationCanceledException) { + // server stopping + } + } + + public void Dispose() { + Stop(); + _cts.Dispose(); + _server.Dispose(); + _stateLock.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/OpenPolytopia.Server/LobbyManager.cs b/OpenPolytopia.Server/LobbyManager.cs new file mode 100644 index 00000000..0cdc2a3b --- /dev/null +++ b/OpenPolytopia.Server/LobbyManager.cs @@ -0,0 +1,233 @@ +namespace OpenPolytopia.Server; + +using OpenPolytopia.Common; +using OpenPolytopia.Common.Network.Packets; + +/// +/// Owns all the lobbies on the server +/// +/// +/// This class isn't thread-safe, serializes every access through its own lock +/// +public class LobbyManager { + /// + /// Minimum players needed to start a game; solo games aren't allowed + /// + private const uint MIN_PLAYERS_TO_START = 2; + + private readonly Dictionary _lobbies = new(); + private ulong _nextId; + + /// + /// All the lobbies on the server + /// + public IReadOnlyCollection Lobbies => _lobbies.Values; + + /// + /// Number of lobbies currently on the server + /// + public int LobbiesCount => _lobbies.Count; + + /// + /// Returns a lobby given its id + /// + /// the id of the lobby + public LobbyData? this[ulong id] => _lobbies.GetValueOrDefault(id); + + /// + /// Creates a new lobby and adds the creator to it + /// + /// max players that can join the lobby + /// the player creating the lobby + /// the new lobby + public LobbyData CreateLobby(uint maxPlayers, LobbyPlayerData creator) { + var lobby = new LobbyData { Id = ++_nextId, MaxPlayers = maxPlayers, Players = [creator] }; + _lobbies[lobby.Id] = lobby; + return lobby; + } + + /// + /// Adds a player to a lobby, applying all the lobby rules + /// + /// the id of the lobby to join + /// the joining player + /// the result of the operation + public LobbyActionResult JoinLobby(ulong lobbyId, LobbyPlayerData player) { + var lobby = this[lobbyId]; + if (lobby == null) { + return LobbyActionResult.LobbyNotFound; + } + + if (lobby.Starting) { + return LobbyActionResult.LobbyAlreadyStarted; + } + + if (lobby[player.PlayerId] != null) { + return LobbyActionResult.AlreadyJoinedLobby; + } + + if (lobby.PlayersCount >= lobby.MaxPlayers) { + return LobbyActionResult.LobbyFull; + } + + lobby.Players.Add(player); + return LobbyActionResult.Ok; + } + + /// + /// Removes a player from a lobby + /// + /// the id of the lobby to leave + /// the id of the leaving player + /// the result of the operation + public LobbyActionResult LeaveLobby(ulong lobbyId, uint playerId) { + var lobby = this[lobbyId]; + if (lobby == null) { + return LobbyActionResult.LobbyNotFound; + } + + if (lobby.Starting) { + return LobbyActionResult.LobbyAlreadyStarted; + } + + var player = lobby[playerId]; + if (player == null) { + return LobbyActionResult.NotInLobby; + } + + lobby.Players.Remove(player); + + // the leaving player could be the last not-ready one + TryMarkStarting(lobby); + return LobbyActionResult.Ok; + } + + /// + /// Sets the ready state of a player in a lobby + /// + /// + /// When every player in the lobby is ready, the lobby is marked as starting + /// + /// the id of the lobby + /// the id of the player + /// the new ready state + /// the result of the operation + public LobbyActionResult SetReady(ulong lobbyId, uint playerId, bool ready) { + var lobby = this[lobbyId]; + if (lobby == null) { + return LobbyActionResult.LobbyNotFound; + } + + if (lobby.Starting) { + return LobbyActionResult.LobbyAlreadyStarted; + } + + var player = lobby[playerId]; + if (player == null) { + return LobbyActionResult.NotInLobby; + } + + player.Ready = ready; + TryMarkStarting(lobby); + return LobbyActionResult.Ok; + } + + /// + /// Marks a lobby as starting when it has at least players + /// and every one of them is ready + /// + /// + /// Called after every change to the players of a lobby, + /// because removing the last not-ready player can start it too + /// + /// the lobby to check + private static void TryMarkStarting(LobbyData lobby) { + if (lobby.PlayersCount >= MIN_PLAYERS_TO_START && lobby.ReadyCount == lobby.PlayersCount) { + lobby.Starting = true; + } + } + + /// + /// Checks if a player joined any lobby + /// + /// the id of the player + public bool IsPlayerInAnyLobby(uint playerId) => _lobbies.Values.Any(lobby => lobby[playerId] != null); + + /// + /// Renames a player in every lobby he joined + /// + /// the id of the player + /// the new name + /// filled with the lobbies that changed + public void RenamePlayerInLobbies(uint playerId, string name, List updated) { + foreach (var lobby in _lobbies.Values) { + var player = lobby[playerId]; + if (player == null) { + continue; + } + + player.Name = name; + updated.Add(lobby); + } + } + + /// + /// Removes a lobby + /// + /// the id of the lobby to remove + public void RemoveLobby(ulong lobbyId) => _lobbies.Remove(lobbyId); + + /// + /// Removes a player from every lobby he joined + /// + /// + /// Used when a client disconnects; lobbies that become empty get removed + /// + /// the id of the disconnected player + /// filled with the lobbies that changed + /// filled with the ids of the lobbies that got removed + public void RemovePlayerFromAllLobbies(uint playerId, List updated, List deleted) { + foreach (var lobby in _lobbies.Values.ToArray()) { + var player = lobby[playerId]; + if (player == null) { + continue; + } + + lobby.Players.Remove(player); + + if (lobby.PlayersCount == 0) { + _lobbies.Remove(lobby.Id); + deleted.Add(lobby.Id); + } + else { + // the disconnected player could be the last not-ready one, + // or he could drop the lobby below the minimum to start + lobby.Starting = false; + TryMarkStarting(lobby); + updated.Add(lobby); + } + } + } + + /// + /// Removes and returns all the lobbies that are marked as starting + /// + /// the lobbies whose game must start now + public List TakeStartingLobbies() { + List starting = []; + + foreach (var lobby in _lobbies.Values.ToArray()) { + if (!lobby.Starting) { + continue; + } + + // the flag marks the snapshot handed to the game; the lobby itself leaves the manager, + // so no lobby inside it is ever started + lobby.Started = true; + _lobbies.Remove(lobby.Id); + starting.Add(lobby); + } + + return starting; + } +} diff --git a/StdbModule/StdbModule.csproj b/OpenPolytopia.Server/OpenPolytopia.Server.csproj similarity index 76% rename from StdbModule/StdbModule.csproj rename to OpenPolytopia.Server/OpenPolytopia.Server.csproj index eb1aacc3..0e1eec5d 100644 --- a/StdbModule/StdbModule.csproj +++ b/OpenPolytopia.Server/OpenPolytopia.Server.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -11,7 +11,7 @@ - + diff --git a/OpenPolytopia.Server/Program.cs b/OpenPolytopia.Server/Program.cs new file mode 100644 index 00000000..167237d4 --- /dev/null +++ b/OpenPolytopia.Server/Program.cs @@ -0,0 +1,49 @@ +namespace OpenPolytopia.Server; + +using OpenPolytopia.Common.Network; + +internal static class Program { + /// + /// Environment variable that overrides the default port + /// + private const string PORT_ENV = "OPENPOLYTOPIA_PORT"; + + /// + /// Environment variable that overrides the default bind address + /// + private const string BIND_ADDRESS_ENV = "OPENPOLYTOPIA_BIND_ADDRESS"; + + /// + /// Entry point of the server + /// + /// + /// Usage: OpenPolytopia.Server [port] [bind-address]; + /// both arguments are optional and fall back to the + /// OPENPOLYTOPIA_PORT/OPENPOLYTOPIA_BIND_ADDRESS environment variables, + /// then to port on every interface + /// + private static async Task Main(string[] args) { + var portValue = args.Length > 0 ? args[0] : Environment.GetEnvironmentVariable(PORT_ENV); + var port = NetworkConstants.DEFAULT_PORT; + if (!string.IsNullOrWhiteSpace(portValue) && + (!int.TryParse(portValue, out port) || port is <= 0 or > ushort.MaxValue)) { + Console.Error.WriteLine($"Invalid port: {portValue}"); + Environment.Exit(1); + } + + var bindAddress = args.Length > 1 ? args[1] : Environment.GetEnvironmentVariable(BIND_ADDRESS_ENV); + if (string.IsNullOrWhiteSpace(bindAddress)) { + bindAddress = null; + } + + using var gameServer = new GameServer(port, bindAddress); + + // stop gracefully on ctrl+c + Console.CancelKeyPress += (_, eventArgs) => { + eventArgs.Cancel = true; + gameServer.Stop(); + }; + + await gameServer.RunAsync(); + } +} diff --git a/OpenPolytopia.sln b/OpenPolytopia.sln index 268b3f11..86427fbd 100644 --- a/OpenPolytopia.sln +++ b/OpenPolytopia.sln @@ -6,7 +6,7 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FSharpLibrary", "FSharpLibr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenPolytopia.Common", "OpenPolytopia.Common\OpenPolytopia.Common.csproj", "{07BAA3DA-9489-4A3A-85AD-92C16108241D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StdbModule", "StdbModule\StdbModule.csproj", "{0B313502-8039-43DA-AC92-4B8146E88349}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenPolytopia.Server", "OpenPolytopia.Server\OpenPolytopia.Server.csproj", "{0B313502-8039-43DA-AC92-4B8146E88349}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/OpenPolytopia/OpenPolytopia.csproj b/OpenPolytopia/OpenPolytopia.csproj index 3248946f..4a28e427 100644 --- a/OpenPolytopia/OpenPolytopia.csproj +++ b/OpenPolytopia/OpenPolytopia.csproj @@ -47,8 +47,6 @@ - - diff --git a/OpenPolytopia/OpenPolytopia.csproj.old b/OpenPolytopia/OpenPolytopia.csproj.old deleted file mode 100644 index afbf4da2..00000000 --- a/OpenPolytopia/OpenPolytopia.csproj.old +++ /dev/null @@ -1,64 +0,0 @@ - - - true - latest - enable - OpenPolytopia - - CS9057 - - - true - - - - portable - true - OpenPolytopia - 0.1.0 - OpenPolytopia - © 2024 Enn3DevPlayer, GamerPlayer888, Remoxx, C0MPL3XDEV - Enn3DevPlayer, GamerPlayer888, Remoxx, C0MPL3XDEV - Enn3DevPlayer, GamerPlayer888, Remoxx, C0MPL3XDEV - - - $(DefaultItemExcludes);test/**/* - - net8.0 - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/OpenPolytopia/OpenPolytopia.csproj.old.1 b/OpenPolytopia/OpenPolytopia.csproj.old.1 deleted file mode 100644 index afbf4da2..00000000 --- a/OpenPolytopia/OpenPolytopia.csproj.old.1 +++ /dev/null @@ -1,64 +0,0 @@ - - - true - latest - enable - OpenPolytopia - - CS9057 - - - true - - - - portable - true - OpenPolytopia - 0.1.0 - OpenPolytopia - © 2024 Enn3DevPlayer, GamerPlayer888, Remoxx, C0MPL3XDEV - Enn3DevPlayer, GamerPlayer888, Remoxx, C0MPL3XDEV - Enn3DevPlayer, GamerPlayer888, Remoxx, C0MPL3XDEV - - - $(DefaultItemExcludes);test/**/* - - net8.0 - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/OpenPolytopia/src/Client.cs.uid b/OpenPolytopia/src/Client.cs.uid deleted file mode 100644 index fd369383..00000000 --- a/OpenPolytopia/src/Client.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bl30yxis03aax diff --git a/OpenPolytopia/src/Game.cs b/OpenPolytopia/src/Game.cs index 8cb402c3..c6116204 100644 --- a/OpenPolytopia/src/Game.cs +++ b/OpenPolytopia/src/Game.cs @@ -5,26 +5,72 @@ namespace OpenPolytopia; public partial class Game : Control { [Export] public PackedScene? LobbyScene; + private NetworkNode _network = null!; + private Label _statusLabel = null!; + private string _playerName = ""; + private bool _switching; + public override void _Ready() { - // Check if the lobby scene was set - if (LobbyScene == null) { - return; - } + _statusLabel = GetNode