From fe3e8ffd008a60789311c25aa2dd00402624e3d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 13:05:33 +0000 Subject: [PATCH 01/11] Replace SpacetimeDB with custom TCP server and packet protocol Remove the SpacetimeDB module and client SDK in favor of an in-house netcode stack built on raw TCP: - OpenPolytopia.Common/Network: wire protocol shared by client and server. Packets are framed as [u32 length][u32 packet id][payload] (big-endian) and serialized through INetworkSerializable, with a PacketRegistrar mapping ids to packet types. Includes handshake, keep alive, player registration and all lobby packets, plus NetworkConnection/ServerConnection/ClientConnection managing framing, send/receive loops and keep-alive timeouts. - OpenPolytopia.Server: new console server replacing StdbModule. GameServer + LobbyManager reimplement the old reducer semantics (SetName, CreateLobby, JoinLobby, LeaveLobby, ready handling) with per-player ready state, lobby broadcasts, disconnect cleanup and the 5-second scheduler that starts games when every player is ready. - Godot client: SpacetimeNode and the generated ModuleBindings are replaced by NetworkNode (autoload), which drains received packets on the main thread each physics frame and exposes typed events plus the observable Lobbies collection. Restores Lobby.cs for Lobby.tscn with a working lobby browser and wires Game.tscn to it. - Tests: packet round-trip and framing tests in the GoDotTest suite. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX --- OpenPolytopia.Common/LobbyData.cs | 104 ++++ .../Network/ClientConnection.cs | 86 ++++ .../Network/INetworkSerializable.cs | 24 + .../Network/NetworkConnection.cs | 110 +++++ .../Network/NetworkConstants.cs | 18 + .../Network/NetworkSerialization.cs | 145 ++++++ .../Network/PacketProtocol.cs | 94 ++++ .../Network/PacketRegistrar.cs | 67 +++ .../Network/Packets/HandshakePacket.cs | 40 ++ .../Network/Packets/IPacket.cs | 11 + .../Network/Packets/KeepAlivePacket.cs | 16 + .../Network/Packets/LobbyActionResult.cs | 22 + .../Network/Packets/LobbyPackets.cs | 267 +++++++++++ .../Network/Packets/SetNamePacket.cs | 29 ++ .../Network/ServerConnection.cs | 174 +++++++ OpenPolytopia.Server/GameServer.cs | 300 ++++++++++++ OpenPolytopia.Server/LobbyManager.cs | 181 +++++++ .../OpenPolytopia.Server.csproj | 4 +- OpenPolytopia.Server/Program.cs | 23 + OpenPolytopia.sln | 2 +- OpenPolytopia/OpenPolytopia.csproj | 2 - OpenPolytopia/OpenPolytopia.csproj.old | 64 --- OpenPolytopia/OpenPolytopia.csproj.old.1 | 64 --- OpenPolytopia/project.godot | 4 + OpenPolytopia/src/Client.cs.uid | 1 - OpenPolytopia/src/Game.cs | 37 +- OpenPolytopia/src/Game.tscn | 4 +- OpenPolytopia/src/Lobby.cs | 221 +++++++++ .../src/ModuleBindings/Reducers/AddReady.g.cs | 53 --- .../ModuleBindings/Reducers/AddReady.g.cs.uid | 1 - .../Reducers/ClientConnected.g.cs | 33 -- .../Reducers/ClientConnected.g.cs.uid | 1 - .../Reducers/ClientDisconnected.g.cs | 33 -- .../Reducers/ClientDisconnected.g.cs.uid | 1 - .../ModuleBindings/Reducers/CreateLobby.g.cs | 60 --- .../Reducers/CreateLobby.g.cs.uid | 1 - .../ModuleBindings/Reducers/JoinLobby.g.cs | 60 --- .../Reducers/JoinLobby.g.cs.uid | 1 - .../ModuleBindings/Reducers/LeaveLobby.g.cs | 53 --- .../Reducers/LeaveLobby.g.cs.uid | 1 - .../ModuleBindings/Reducers/RemoveReady.g.cs | 53 --- .../Reducers/RemoveReady.g.cs.uid | 1 - .../src/ModuleBindings/Reducers/SetName.g.cs | 54 --- .../ModuleBindings/Reducers/SetName.g.cs.uid | 1 - .../ModuleBindings/Reducers/StartLobby.g.cs | 54 --- .../Reducers/StartLobby.g.cs.uid | 1 - .../src/ModuleBindings/SpacetimeDBClient.g.cs | 447 ------------------ .../ModuleBindings/SpacetimeDBClient.g.cs.uid | 1 - .../src/ModuleBindings/Tables/Lobby.g.cs | 34 -- .../src/ModuleBindings/Tables/Lobby.g.cs.uid | 1 - .../ModuleBindings/Tables/LobbyPlayer.g.cs | 43 -- .../Tables/LobbyPlayer.g.cs.uid | 1 - .../src/ModuleBindings/Tables/Player.g.cs | 34 -- .../src/ModuleBindings/Tables/Player.g.cs.uid | 1 - .../Tables/StartLobbySchedule.g.cs | 34 -- .../Tables/StartLobbySchedule.g.cs.uid | 1 - .../src/ModuleBindings/Types/Lobby.g.cs | 46 -- .../src/ModuleBindings/Types/Lobby.g.cs.uid | 1 - .../src/ModuleBindings/Types/LobbyPlayer.g.cs | 38 -- .../ModuleBindings/Types/LobbyPlayer.g.cs.uid | 1 - .../src/ModuleBindings/Types/Player.g.cs | 35 -- .../src/ModuleBindings/Types/Player.g.cs.uid | 1 - .../Types/StartLobbySchedule.g.cs | 31 -- .../Types/StartLobbySchedule.g.cs.uid | 1 - OpenPolytopia/src/NetworkNode.cs | 248 ++++++++++ OpenPolytopia/src/PlayerData.cs.uid | 1 - OpenPolytopia/src/SpacetimeNode.cs | 146 ------ OpenPolytopia/src/SpacetimeNode.cs.uid | 1 - OpenPolytopia/test/src/PacketTest.cs | 116 +++++ StdbModule/Exceptions.cs | 17 - StdbModule/Module.cs | 291 ------------ StdbModule/ReducerContextExtensions.cs | 46 -- cspell.json | 3 - 73 files changed, 2336 insertions(+), 1860 deletions(-) create mode 100644 OpenPolytopia.Common/LobbyData.cs create mode 100644 OpenPolytopia.Common/Network/ClientConnection.cs create mode 100644 OpenPolytopia.Common/Network/INetworkSerializable.cs create mode 100644 OpenPolytopia.Common/Network/NetworkConnection.cs create mode 100644 OpenPolytopia.Common/Network/NetworkConstants.cs create mode 100644 OpenPolytopia.Common/Network/NetworkSerialization.cs create mode 100644 OpenPolytopia.Common/Network/PacketProtocol.cs create mode 100644 OpenPolytopia.Common/Network/PacketRegistrar.cs create mode 100644 OpenPolytopia.Common/Network/Packets/HandshakePacket.cs create mode 100644 OpenPolytopia.Common/Network/Packets/IPacket.cs create mode 100644 OpenPolytopia.Common/Network/Packets/KeepAlivePacket.cs create mode 100644 OpenPolytopia.Common/Network/Packets/LobbyActionResult.cs create mode 100644 OpenPolytopia.Common/Network/Packets/LobbyPackets.cs create mode 100644 OpenPolytopia.Common/Network/Packets/SetNamePacket.cs create mode 100644 OpenPolytopia.Common/Network/ServerConnection.cs create mode 100644 OpenPolytopia.Server/GameServer.cs create mode 100644 OpenPolytopia.Server/LobbyManager.cs rename StdbModule/StdbModule.csproj => OpenPolytopia.Server/OpenPolytopia.Server.csproj (76%) create mode 100644 OpenPolytopia.Server/Program.cs delete mode 100644 OpenPolytopia/OpenPolytopia.csproj.old delete mode 100644 OpenPolytopia/OpenPolytopia.csproj.old.1 delete mode 100644 OpenPolytopia/src/Client.cs.uid create mode 100644 OpenPolytopia/src/Lobby.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/Reducers/AddReady.g.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/Reducers/AddReady.g.cs.uid delete mode 100644 OpenPolytopia/src/ModuleBindings/Reducers/ClientConnected.g.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/Reducers/ClientConnected.g.cs.uid delete mode 100644 OpenPolytopia/src/ModuleBindings/Reducers/ClientDisconnected.g.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/Reducers/ClientDisconnected.g.cs.uid delete mode 100644 OpenPolytopia/src/ModuleBindings/Reducers/CreateLobby.g.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/Reducers/CreateLobby.g.cs.uid delete mode 100644 OpenPolytopia/src/ModuleBindings/Reducers/JoinLobby.g.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/Reducers/JoinLobby.g.cs.uid delete mode 100644 OpenPolytopia/src/ModuleBindings/Reducers/LeaveLobby.g.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/Reducers/LeaveLobby.g.cs.uid delete mode 100644 OpenPolytopia/src/ModuleBindings/Reducers/RemoveReady.g.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/Reducers/RemoveReady.g.cs.uid delete mode 100644 OpenPolytopia/src/ModuleBindings/Reducers/SetName.g.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/Reducers/SetName.g.cs.uid delete mode 100644 OpenPolytopia/src/ModuleBindings/Reducers/StartLobby.g.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/Reducers/StartLobby.g.cs.uid delete mode 100644 OpenPolytopia/src/ModuleBindings/SpacetimeDBClient.g.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/SpacetimeDBClient.g.cs.uid delete mode 100644 OpenPolytopia/src/ModuleBindings/Tables/Lobby.g.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/Tables/Lobby.g.cs.uid delete mode 100644 OpenPolytopia/src/ModuleBindings/Tables/LobbyPlayer.g.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/Tables/LobbyPlayer.g.cs.uid delete mode 100644 OpenPolytopia/src/ModuleBindings/Tables/Player.g.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/Tables/Player.g.cs.uid delete mode 100644 OpenPolytopia/src/ModuleBindings/Tables/StartLobbySchedule.g.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/Tables/StartLobbySchedule.g.cs.uid delete mode 100644 OpenPolytopia/src/ModuleBindings/Types/Lobby.g.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/Types/Lobby.g.cs.uid delete mode 100644 OpenPolytopia/src/ModuleBindings/Types/LobbyPlayer.g.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/Types/LobbyPlayer.g.cs.uid delete mode 100644 OpenPolytopia/src/ModuleBindings/Types/Player.g.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/Types/Player.g.cs.uid delete mode 100644 OpenPolytopia/src/ModuleBindings/Types/StartLobbySchedule.g.cs delete mode 100644 OpenPolytopia/src/ModuleBindings/Types/StartLobbySchedule.g.cs.uid create mode 100644 OpenPolytopia/src/NetworkNode.cs delete mode 100644 OpenPolytopia/src/PlayerData.cs.uid delete mode 100644 OpenPolytopia/src/SpacetimeNode.cs delete mode 100644 OpenPolytopia/src/SpacetimeNode.cs.uid create mode 100644 OpenPolytopia/test/src/PacketTest.cs delete mode 100644 StdbModule/Exceptions.cs delete mode 100644 StdbModule/Module.cs delete mode 100644 StdbModule/ReducerContextExtensions.cs 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..2851b887 --- /dev/null +++ b/OpenPolytopia.Common/Network/ClientConnection.cs @@ -0,0 +1,86 @@ +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 so the consumer +/// (e.g. a Godot node) can process them on its own thread by calling a drain loop +/// every frame; s are answered automatically +///
+public class ClientConnection(string address, int port) : IDisposable { + 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 in background + _ = _connection.RunAsync(_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 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..5ac15cf7 --- /dev/null +++ b/OpenPolytopia.Common/Network/NetworkConnection.cs @@ -0,0 +1,110 @@ +namespace OpenPolytopia.Common.Network; + +using System.IO; +using System.Net.Sockets; +using Packets; + +/// +/// Wraps a connected and provides framed packet send/receive on top of it. +/// Used both by the client (a single connection to the server) and +/// by the server (one connection per client). +/// +public class NetworkConnection(uint id, TcpClient client) : IDisposable { + private readonly NetworkStream _stream = client.GetStream(); + private readonly SemaphoreSlim _writeLock = new(1, 1); + private bool _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 && client.Connected; + + /// + /// Sends a single packet; thread-safe + /// + /// the packet to send + /// cancellation token + public async Task SendPacketAsync(IPacket packet, CancellationToken ct = default) { + List bytes = []; + PacketProtocol.FramePacket(packet, bytes); + + await _writeLock.WaitAsync(ct); + try { + await _stream.WriteAsync(bytes.ToArray(), ct); + } + finally { + _writeLock.Release(); + } + } + + /// + /// Reads packets from the connection until it gets closed or the token gets cancelled, + /// firing for each one. + /// Always fires 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; it is safe to call this multiple times + /// + public void Close() { + if (_closed) { + return; + } + + _closed = true; + 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..fc7ca75c --- /dev/null +++ b/OpenPolytopia.Common/Network/NetworkConstants.cs @@ -0,0 +1,18 @@ +namespace OpenPolytopia.Common.Network; + +public static class NetworkConstants { + /// + /// Protocol version; client and server must match to complete the handshake + /// + public const string VERSION = "0.1.0"; + + /// + /// Default port the server listens on + /// + public const int DEFAULT_PORT = 6969; + + /// + /// Maximum allowed size in bytes of a single packet (id + 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..3f81a650 --- /dev/null +++ b/OpenPolytopia.Common/Network/NetworkSerialization.cs @@ -0,0 +1,145 @@ +namespace OpenPolytopia.Common.Network; + +using System.Text; + +// Primitive (de)serialization extensions. +// Everything is written in network byte order (big-endian). +// Every Deserialize increments the index by the number of bytes it consumed. + +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 IntSerialization { + public static void Serialize(this int value, List bytes) => ((uint)value).Serialize(bytes); + + public static void Deserialize(this ref int value, byte[] bytes, ref uint index) { + var unsigned = 0u; + unsigned.Deserialize(bytes, ref index); + value = (int)unsigned; + } +} + +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); + } + } + + public static void Serialize(this List list, List bytes) { + ((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) { + var length = UIntSerialization.Read(bytes, ref index); + + for (var i = 0; i < length; i++) { + list.Add(UIntSerialization.Read(bytes, ref index)); + } + } + + public static void Serialize(this List list, List bytes) { + ((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) { + var length = UIntSerialization.Read(bytes, ref index); + + for (var i = 0; i < length; i++) { + list.Add(StringSerialization.Read(bytes, ref index)); + } + } +} diff --git a/OpenPolytopia.Common/Network/PacketProtocol.cs b/OpenPolytopia.Common/Network/PacketProtocol.cs new file mode 100644 index 00000000..7799f586 --- /dev/null +++ b/OpenPolytopia.Common/Network/PacketProtocol.cs @@ -0,0 +1,94 @@ +namespace OpenPolytopia.Common.Network; + +using System.IO; +using Packets; + +/// +/// Thrown when the remote endpoint sends a malformed or too big packet +/// +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 + 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); + + // insert the content length before the id + var contentLength = (uint)(bytes.Count - startIndex); + bytes.InsertRange(startIndex, contentLength.Serialize()); + } + + /// + /// Frames a packet and writes it to the stream + /// + /// the stream to write to + /// the packet to send + /// cancellation token + public static async Task WritePacketAsync(Stream stream, IPacket packet, CancellationToken ct = default) { + List bytes = []; + FramePacket(packet, bytes); + await stream.WriteAsync(bytes.ToArray(), ct); + } + + /// + /// 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); + + // unknown packets are skipped instead of closing the connection + // 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..3db60beb --- /dev/null +++ b/OpenPolytopia.Common/Network/PacketRegistrar.cs @@ -0,0 +1,67 @@ +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()]; + + /// + /// Register all known packets; it is safe to call this multiple times + /// + 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..aee2ddf9 --- /dev/null +++ b/OpenPolytopia.Common/Network/Packets/HandshakePacket.cs @@ -0,0 +1,40 @@ +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..5bff9b35 --- /dev/null +++ b/OpenPolytopia.Common/Network/Packets/IPacket.cs @@ -0,0 +1,11 @@ +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 type 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..a9bae053 --- /dev/null +++ b/OpenPolytopia.Common/Network/Packets/KeepAlivePacket.cs @@ -0,0 +1,16 @@ +namespace OpenPolytopia.Common.Network.Packets; + +/// +/// Sent periodically by the server; the client must echo it back. +/// If the server doesn't receive it back in time, it closes the connection. +/// +public class KeepAlivePacket : IPacket { + /// + /// Random value that must be echoed back untouched + /// + public uint Captcha; + + public void Serialize(List bytes) => Captcha.Serialize(bytes); + + public void Deserialize(byte[] bytes, ref uint index) => Captcha.Deserialize(bytes, ref index); +} diff --git a/OpenPolytopia.Common/Network/Packets/LobbyActionResult.cs b/OpenPolytopia.Common/Network/Packets/LobbyActionResult.cs new file mode 100644 index 00000000..51286740 --- /dev/null +++ b/OpenPolytopia.Common/Network/Packets/LobbyActionResult.cs @@ -0,0 +1,22 @@ +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, +} + +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..5c08db7a --- /dev/null +++ b/OpenPolytopia.Common/Network/Packets/LobbyPackets.cs @@ -0,0 +1,267 @@ +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 it +/// +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); + } +} + +/// +/// Marks the player as ready (or not ready) 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..031958f0 --- /dev/null +++ b/OpenPolytopia.Common/Network/ServerConnection.cs @@ -0,0 +1,174 @@ +namespace OpenPolytopia.Common.Network; + +using System.Collections.Concurrent; +using System.Net.Sockets; +using System.Security.Cryptography; +using Packets; + +/// +/// Accepts TCP connections and manages one per client. +///
+/// It also takes care of the keep alive logic: every it sends +/// a to every client and disconnects the ones that +/// didn't send anything back for longer than +///
+public class ServerConnection(int port) : IDisposable { + private static readonly TimeSpan KEEP_ALIVE_INTERVAL = TimeSpan.FromSeconds(10); + private static readonly TimeSpan TIMEOUT = TimeSpan.FromSeconds(30); + + private readonly TcpListener _listener = TcpListener.Create(port); + private readonly ConcurrentDictionary _connections = new(); + private readonly CancellationTokenSource _cts = new(); + private uint _nextId; + + /// + /// All the currently connected clients + /// + public IReadOnlyDictionary Connections => _connections; + + /// + /// Fired when a new client connects, before any packet is received from it + /// + 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 for connections and 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) { + var client = await _listener.AcceptTcpClientAsync(_cts.Token); + var id = Interlocked.Increment(ref _nextId); + + var connection = new NetworkConnection(id, client); + connection.OnPacketReceived += ClientPacketReceivedAsync; + connection.OnDisconnected += ClientDisconnected; + _connections[id] = connection; + + OnClientConnected?.Invoke(connection); + + // manage the client in background + _ = connection.RunAsync(_cts.Token); + } + } + catch (OperationCanceledException) { + // server stopping + } + finally { + _listener.Stop(); + + foreach (var connection in _connections.Values) { + connection.Close(); + } + } + } + + /// + /// Stops the server and disconnects every client + /// + public void Stop() => _cts.Cancel(); + + /// + /// Sends a packet to a single client; failures are treated as a disconnection + /// + /// the id of the client + /// the packet to send + public async Task SendToAsync(uint id, IPacket packet) { + if (!_connections.TryGetValue(id, out var connection)) { + return; + } + + try { + await connection.SendPacketAsync(packet, _cts.Token); + } + catch (Exception) { + connection.Close(); + } + } + + /// + /// Sends a packet to every connected client + /// + /// the packet to broadcast + public async Task BroadcastAsync(IPacket packet) { + foreach (var id in _connections.Keys) { + await SendToAsync(id, packet); + } + } + + /// + /// Sends a packet to the given clients + /// + /// the ids of the clients + /// the packet to send + public async Task BroadcastToAsync(IEnumerable ids, IPacket packet) { + foreach (var id in ids) { + await SendToAsync(id, packet); + } + } + + 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) { + _connections.TryRemove(connection.Id, out _); + OnClientDisconnected?.Invoke(connection); + } + + private async Task KeepAliveLoopAsync(CancellationToken ct) { + using var timer = new PeriodicTimer(KEEP_ALIVE_INTERVAL); + + try { + while (await timer.WaitForNextTickAsync(ct)) { + var now = DateTime.UtcNow; + + foreach (var connection in _connections.Values) { + // kick clients that timed out + if (now - connection.LastReceived > TIMEOUT) { + connection.Close(); + continue; + } + + await SendToAsync(connection.Id, new KeepAlivePacket { + Captcha = (uint)RandomNumberGenerator.GetInt32(int.MaxValue) + }); + } + } + } + catch (OperationCanceledException) { + // server stopping + } + } + + public void Dispose() { + Stop(); + _cts.Dispose(); + _listener.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/OpenPolytopia.Server/GameServer.cs b/OpenPolytopia.Server/GameServer.cs new file mode 100644 index 00000000..e0389cc7 --- /dev/null +++ b/OpenPolytopia.Server/GameServer.cs @@ -0,0 +1,300 @@ +namespace OpenPolytopia.Server; + +using OpenPolytopia.Common; +using OpenPolytopia.Common.Network; +using OpenPolytopia.Common.Network.Packets; + +/// +/// The game server: manages players, lobbies and starting games on top of a +/// +public class GameServer(int port) : IDisposable { + /// + /// How often the server checks for lobbies to start + /// + private static readonly TimeSpan START_LOBBY_INTERVAL = TimeSpan.FromSeconds(5); + + private readonly ServerConnection _server = new(port); + 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 port {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) { + // a failing handler must not go unnoticed nor take 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) { + switch (packet) { + // handshake, respond with the result of the version check and the assigned player id + case HandshakePacket handshake: + await _server.SendToAsync(connection.Id, + new HandshakeResponsePacket { Ok = handshake.Version == NetworkConstants.VERSION, PlayerId = connection.Id }); + break; + // 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 async Task ManageSetNameAsync(NetworkConnection connection, SetNamePacket packet) { + var name = packet.Name.Trim(); + var ok = name.Length is > 0 and <= 32; + + if (ok) { + await _stateLock.WaitAsync(); + try { + _playerNames[connection.Id] = name; + } + finally { + _stateLock.Release(); + } + } + + await _server.SendToAsync(connection.Id, new SetNameResponsePacket { Ok = ok }); + } + + private async Task ManageGetLobbiesAsync(NetworkConnection connection) { + GetLobbiesResponsePacket response; + + await _stateLock.WaitAsync(); + try { + response = new GetLobbiesResponsePacket { Lobbies = [.. _lobbyManager.Lobbies] }; + } + finally { + _stateLock.Release(); + } + + await _server.SendToAsync(connection.Id, response); + } + + private async Task ManageCreateLobbyAsync(NetworkConnection connection, CreateLobbyPacket packet) { + LobbyData? lobby = null; + var result = LobbyActionResult.Ok; + + await _stateLock.WaitAsync(); + try { + if (!_playerNames.TryGetValue(connection.Id, out var name)) { + result = LobbyActionResult.NotRegistered; + } + else if (packet.MaxPlayers is < 2 or > 16) { + result = LobbyActionResult.InvalidParameters; + } + else { + lobby = _lobbyManager.CreateLobby(packet.MaxPlayers, + new LobbyPlayerData { PlayerId = connection.Id, Name = name, Tribe = packet.Tribe }); + } + } + finally { + _stateLock.Release(); + } + + await _server.SendToAsync(connection.Id, + new CreateLobbyResponsePacket { Result = result, LobbyId = lobby?.Id ?? 0 }); + + if (lobby != null) { + await _server.BroadcastAsync(new LobbyUpdatedPacket { Lobby = lobby }); + } + } + + private async Task ManageJoinLobbyAsync(NetworkConnection connection, JoinLobbyPacket packet) { + LobbyData? lobby = null; + LobbyActionResult result; + + await _stateLock.WaitAsync(); + try { + if (!_playerNames.TryGetValue(connection.Id, out var name)) { + result = LobbyActionResult.NotRegistered; + } + else { + result = _lobbyManager.JoinLobby(packet.LobbyId, + new LobbyPlayerData { PlayerId = connection.Id, Name = name, Tribe = packet.Tribe }); + lobby = _lobbyManager[packet.LobbyId]; + } + } + finally { + _stateLock.Release(); + } + + await _server.SendToAsync(connection.Id, new JoinLobbyResponsePacket { Result = result, LobbyId = packet.LobbyId }); + + if (result == LobbyActionResult.Ok && lobby != null) { + await _server.BroadcastAsync(new LobbyUpdatedPacket { Lobby = lobby }); + } + } + + private async Task ManageLeaveLobbyAsync(NetworkConnection connection, LeaveLobbyPacket packet) { + LobbyData? lobby = null; + var deleted = false; + LobbyActionResult result; + + await _stateLock.WaitAsync(); + try { + result = _lobbyManager.LeaveLobby(packet.LobbyId, connection.Id); + + if (result == LobbyActionResult.Ok) { + lobby = _lobbyManager[packet.LobbyId]; + + // remove the lobby if it became empty + if (lobby is { PlayersCount: 0 }) { + _lobbyManager.RemoveLobby(lobby.Id); + deleted = true; + } + } + } + finally { + _stateLock.Release(); + } + + await _server.SendToAsync(connection.Id, + new LeaveLobbyResponsePacket { Result = result, LobbyId = packet.LobbyId }); + + if (result != LobbyActionResult.Ok || lobby == null) { + return; + } + + if (deleted) { + await _server.BroadcastAsync(new LobbyDeletedPacket { LobbyId = lobby.Id }); + } + else { + await _server.BroadcastAsync(new LobbyUpdatedPacket { Lobby = lobby }); + } + } + + private async Task ManageSetReadyAsync(NetworkConnection connection, SetReadyPacket packet) { + LobbyData? lobby = null; + LobbyActionResult result; + + await _stateLock.WaitAsync(); + try { + result = _lobbyManager.SetReady(packet.LobbyId, connection.Id, packet.Ready); + if (result == LobbyActionResult.Ok) { + lobby = _lobbyManager[packet.LobbyId]; + } + } + finally { + _stateLock.Release(); + } + + await _server.SendToAsync(connection.Id, new SetReadyResponsePacket { Result = result, LobbyId = packet.LobbyId }); + + if (result == LobbyActionResult.Ok && lobby != null) { + await _server.BroadcastAsync(new LobbyUpdatedPacket { Lobby = lobby }); + } + } + + private async Task ClientDisconnectedAsync(NetworkConnection connection) { + List updated = []; + List deletedIds = []; + + await _stateLock.WaitAsync(); + try { + _playerNames.Remove(connection.Id); + _lobbyManager.RemovePlayerFromAllLobbies(connection.Id, updated, deletedIds); + } + finally { + _stateLock.Release(); + } + + foreach (var lobby in updated) { + await _server.BroadcastAsync(new LobbyUpdatedPacket { Lobby = lobby }); + } + + foreach (var id in deletedIds) { + await _server.BroadcastAsync(new LobbyDeletedPacket { LobbyId = id }); + } + } + + private async Task StartLobbiesLoopAsync(CancellationToken ct) { + using var timer = new PeriodicTimer(START_LOBBY_INTERVAL); + + try { + while (await timer.WaitForNextTickAsync(ct)) { + List starting; + + await _stateLock.WaitAsync(ct); + try { + starting = _lobbyManager.TakeStartingLobbies(); + } + finally { + _stateLock.Release(); + } + + foreach (var lobby in starting) { + // 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 + await _server.BroadcastToAsync(lobby.Players.Select(player => player.PlayerId), + new GameStartedPacket { LobbyId = lobby.Id, Players = lobby.Players }); + await _server.BroadcastAsync(new LobbyDeletedPacket { LobbyId = lobby.Id }); + } + } + } + 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..72b1b8d2 --- /dev/null +++ b/OpenPolytopia.Server/LobbyManager.cs @@ -0,0 +1,181 @@ +namespace OpenPolytopia.Server; + +using OpenPolytopia.Common; +using OpenPolytopia.Common.Network.Packets; + +/// +/// Owns all the lobbies on the server. +///
+/// Not thread-safe on its own: serializes every access through its own lock +///
+public class LobbyManager { + private readonly Dictionary _lobbies = new(); + private ulong _nextId; + + /// + /// All the lobbies on the server + /// + public IReadOnlyCollection Lobbies => _lobbies.Values; + + /// + /// 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 || lobby.Started) { + 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 || lobby.Started) { + return LobbyActionResult.LobbyAlreadyStarted; + } + + var player = lobby[playerId]; + if (player == null) { + return LobbyActionResult.NotInLobby; + } + + lobby.Players.Remove(player); + 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 || lobby.Started) { + return LobbyActionResult.LobbyAlreadyStarted; + } + + var player = lobby[playerId]; + if (player == null) { + return LobbyActionResult.NotInLobby; + } + + player.Ready = ready; + + // check if all players are ready + if (lobby.ReadyCount == lobby.PlayersCount) { + // start the game + lobby.Starting = true; + } + + return LobbyActionResult.Ok; + } + + /// + /// 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()) { + // players can't abandon a game that already started + if (lobby.Started) { + continue; + } + + 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 { + 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 || lobby.Started) { + continue; + } + + 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..f7fd5c70 --- /dev/null +++ b/OpenPolytopia.Server/Program.cs @@ -0,0 +1,23 @@ +namespace OpenPolytopia.Server; + +using OpenPolytopia.Common.Network; + +internal static class Program { + private static async Task Main(string[] args) { + var port = NetworkConstants.DEFAULT_PORT; + if (args.Length > 0 && !int.TryParse(args[0], out port)) { + Console.Error.WriteLine($"Invalid port: {args[0]}"); + Environment.Exit(1); + } + + using var gameServer = new GameServer(port); + + // 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/project.godot b/OpenPolytopia/project.godot index 1d0b26c0..fa73a611 100644 --- a/OpenPolytopia/project.godot +++ b/OpenPolytopia/project.godot @@ -16,6 +16,10 @@ run/main_scene="res://src/Main.tscn" config/features=PackedStringArray("4.5", "C#", "Mobile") config/icon="res://icon.png" +[autoload] + +NetworkNode="*res://src/NetworkNode.cs" + [debug] settings/stdout/print_fps=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..c812ec23 100644 --- a/OpenPolytopia/src/Game.cs +++ b/OpenPolytopia/src/Game.cs @@ -5,26 +5,49 @@ namespace OpenPolytopia; public partial class Game : Control { [Export] public PackedScene? LobbyScene; - public override void _Ready() { - // Check if the lobby scene was set - if (LobbyScene == null) { - return; - } + private string _playerName = ""; + private bool _switching; - GetTree().ChangeSceneToPacked(LobbyScene); + public override void _Ready() => + // switch to the lobby scene once the server accepts the player's name + NetworkNode.Instance.OnNameSet += OnNameSet; + + public override void _ExitTree() { + base._ExitTree(); + NetworkNode.Instance.OnNameSet -= OnNameSet; } /// /// Sets the new name for the player /// /// the new player's name - private void OnNameChanged(string name) { } + private void OnNameChanged(string name) => _playerName = name; /// /// Waits until the player press the play button, creates a new random name if the player hasn't chosen one /// and connects him to the lobby /// private void OnPlayPressed() { + // create a random name if the player hasn't chosen one + if (string.IsNullOrWhiteSpace(_playerName)) { + _playerName = $"Player{GD.Randi() % 10000}"; + } + + NetworkNode.Instance.SetName(_playerName); + } + + private void OnNameSet(bool ok) { + if (!ok) { + GD.PushError("The server refused the chosen name"); + return; + } + + // Check if the lobby scene was set + if (LobbyScene == null || _switching) { + return; + } + + _switching = true; GetTree().ChangeSceneToPacked(LobbyScene); } } diff --git a/OpenPolytopia/src/Game.tscn b/OpenPolytopia/src/Game.tscn index 2ef24304..924c4e78 100644 --- a/OpenPolytopia/src/Game.tscn +++ b/OpenPolytopia/src/Game.tscn @@ -1,6 +1,7 @@ -[gd_scene load_steps=2 format=3 uid="uid://cywpu6lxdjhuu"] +[gd_scene load_steps=3 format=3 uid="uid://cywpu6lxdjhuu"] [ext_resource type="Script" uid="uid://bnycq21rdq54b" path="res://src/Game.cs" id="1_17mmo"] +[ext_resource type="PackedScene" uid="uid://badtuu46ibki5" path="res://src/Lobby.tscn" id="2_lobby"] [node name="Control" type="Control"] layout_mode = 3 @@ -10,6 +11,7 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 script = ExtResource("1_17mmo") +LobbyScene = ExtResource("2_lobby") [node name="Label" type="Label" parent="."] layout_mode = 1 diff --git a/OpenPolytopia/src/Lobby.cs b/OpenPolytopia/src/Lobby.cs new file mode 100644 index 00000000..9f3087c6 --- /dev/null +++ b/OpenPolytopia/src/Lobby.cs @@ -0,0 +1,221 @@ +namespace OpenPolytopia; + +using System; +using System.Collections.Specialized; +using System.Linq; +using Common; +using Common.Network.Packets; +using Godot; + +/// +/// Lobby browser: lists the lobbies on the server and lets the player +/// create, join, leave and ready up in a lobby +/// +public partial class Lobby : Control { + private const uint DEFAULT_MAX_PLAYERS = 4; + + private ItemList _lobbyList = null!; + private OptionButton _tribeButton = null!; + private Button _createButton = null!; + private Button _joinButton = null!; + private Button _leaveButton = null!; + private CheckButton _readyButton = null!; + private Label _statusLabel = null!; + + private ulong _joinedLobbyId; + private bool _joined; + + public override void _Ready() { + BuildUi(); + + var network = NetworkNode.Instance; + network.Lobbies.CollectionChanged += OnLobbiesChanged; + network.OnLobbyCreated += OnLobbyCreated; + network.OnLobbyJoined += OnLobbyJoined; + network.OnLobbyLeft += OnLobbyLeft; + network.OnReadySet += OnReadySet; + network.OnGameStarted += OnGameStarted; + + network.RefreshLobbies(); + RefreshList(); + UpdateButtons(); + } + + public override void _ExitTree() { + base._ExitTree(); + + var network = NetworkNode.Instance; + network.Lobbies.CollectionChanged -= OnLobbiesChanged; + network.OnLobbyCreated -= OnLobbyCreated; + network.OnLobbyJoined -= OnLobbyJoined; + network.OnLobbyLeft -= OnLobbyLeft; + network.OnReadySet -= OnReadySet; + network.OnGameStarted -= OnGameStarted; + } + + private void BuildUi() { + var root = new VBoxContainer(); + root.SetAnchorsPreset(LayoutPreset.FullRect); + root.AddThemeConstantOverride("separation", 8); + AddChild(root); + + var title = new Label { Text = "Lobbies", HorizontalAlignment = HorizontalAlignment.Center }; + title.AddThemeFontSizeOverride("font_size", 32); + root.AddChild(title); + + var topBar = new HBoxContainer(); + root.AddChild(topBar); + + _tribeButton = new OptionButton(); + foreach (var tribe in Enum.GetValues()) { + _tribeButton.AddItem(tribe.ToString(), (int)tribe); + } + + _tribeButton.Select(0); + topBar.AddChild(_tribeButton); + + _createButton = new Button { Text = "Create lobby" }; + _createButton.Pressed += OnCreatePressed; + topBar.AddChild(_createButton); + + var refreshButton = new Button { Text = "Refresh" }; + refreshButton.Pressed += () => NetworkNode.Instance.RefreshLobbies(); + topBar.AddChild(refreshButton); + + _lobbyList = new ItemList { SizeFlagsVertical = SizeFlags.ExpandFill }; + _lobbyList.ItemSelected += _ => UpdateButtons(); + root.AddChild(_lobbyList); + + var bottomBar = new HBoxContainer(); + root.AddChild(bottomBar); + + _joinButton = new Button { Text = "Join" }; + _joinButton.Pressed += OnJoinPressed; + bottomBar.AddChild(_joinButton); + + _leaveButton = new Button { Text = "Leave" }; + _leaveButton.Pressed += OnLeavePressed; + bottomBar.AddChild(_leaveButton); + + _readyButton = new CheckButton { Text = "Ready" }; + _readyButton.Toggled += OnReadyToggled; + bottomBar.AddChild(_readyButton); + + _statusLabel = new Label(); + bottomBar.AddChild(_statusLabel); + } + + private void OnLobbiesChanged(object? sender, NotifyCollectionChangedEventArgs e) { + RefreshList(); + UpdateButtons(); + } + + private void RefreshList() { + var network = NetworkNode.Instance; + _lobbyList.Clear(); + + foreach (var lobby in network.Lobbies) { + var text = $"Lobby {lobby.Id} — {lobby.PlayersCount}/{lobby.MaxPlayers} players, {lobby.ReadyCount} ready"; + if (lobby.Starting) { + text += " (starting)"; + } + + var index = _lobbyList.AddItem(text); + _lobbyList.SetItemMetadata(index, lobby.Id); + } + } + + private LobbyData? SelectedLobby() { + var selected = _lobbyList.GetSelectedItems(); + if (selected.Length == 0) { + return null; + } + + var id = _lobbyList.GetItemMetadata(selected[0]).AsUInt64(); + return NetworkNode.Instance.Lobbies.FirstOrDefault(lobby => lobby.Id == id); + } + + private void UpdateButtons() { + _createButton.Disabled = _joined; + _joinButton.Disabled = _joined || SelectedLobby() == null; + _leaveButton.Disabled = !_joined; + _readyButton.Disabled = !_joined; + } + + private void OnCreatePressed() => + NetworkNode.Instance.CreateLobby(DEFAULT_MAX_PLAYERS, (uint)_tribeButton.GetSelectedId()); + + private void OnJoinPressed() { + var lobby = SelectedLobby(); + if (lobby == null) { + return; + } + + NetworkNode.Instance.JoinLobby(lobby.Id, (uint)_tribeButton.GetSelectedId()); + } + + private void OnLeavePressed() => NetworkNode.Instance.LeaveLobby(_joinedLobbyId); + + private void OnReadyToggled(bool ready) { + if (_joined) { + NetworkNode.Instance.SetReady(_joinedLobbyId, ready); + } + } + + private void OnLobbyCreated(LobbyActionResult result, ulong lobbyId) { + if (result != LobbyActionResult.Ok) { + ShowError(result); + return; + } + + SetJoined(lobbyId); + } + + private void OnLobbyJoined(LobbyActionResult result, ulong lobbyId) { + if (result != LobbyActionResult.Ok) { + ShowError(result); + return; + } + + SetJoined(lobbyId); + } + + private void OnLobbyLeft(LobbyActionResult result, ulong lobbyId) { + if (result != LobbyActionResult.Ok) { + ShowError(result); + return; + } + + _joined = false; + _joinedLobbyId = 0; + _readyButton.SetPressedNoSignal(false); + _statusLabel.Text = ""; + UpdateButtons(); + } + + private void OnReadySet(LobbyActionResult result, ulong lobbyId) { + if (result != LobbyActionResult.Ok) { + ShowError(result); + _readyButton.SetPressedNoSignal(false); + } + } + + private void OnGameStarted(GameStartedPacket packet) { + if (packet.LobbyId != _joinedLobbyId) { + return; + } + + // TODO: switch to the game scene once the game itself is implemented + _statusLabel.Text = "Game started!"; + GD.Print($"Game started for lobby {packet.LobbyId} with {packet.Players.Count} players"); + } + + private void SetJoined(ulong lobbyId) { + _joined = true; + _joinedLobbyId = lobbyId; + _statusLabel.Text = $"In lobby {lobbyId}"; + UpdateButtons(); + } + + private void ShowError(LobbyActionResult result) => _statusLabel.Text = $"Error: {result}"; +} diff --git a/OpenPolytopia/src/ModuleBindings/Reducers/AddReady.g.cs b/OpenPolytopia/src/ModuleBindings/Reducers/AddReady.g.cs deleted file mode 100644 index c300390f..00000000 --- a/OpenPolytopia/src/ModuleBindings/Reducers/AddReady.g.cs +++ /dev/null @@ -1,53 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#nullable enable - -using System; -using SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types { - public sealed partial class RemoteReducers : RemoteBase { - public delegate void AddReadyHandler(ReducerEventContext ctx, ulong lobbyId); - public event AddReadyHandler? OnAddReady; - - public void AddReady(ulong lobbyId) { - conn.InternalCallReducer(new Reducer.AddReady(lobbyId), this.SetCallReducerFlags.AddReadyFlags); - } - - public bool InvokeAddReady(ReducerEventContext ctx, Reducer.AddReady args) { - if (OnAddReady == null) - return false; - OnAddReady( - ctx, - args.LobbyId - ); - return true; - } - } - - public abstract partial class Reducer { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class AddReady : Reducer, IReducerArgs { - [DataMember(Name = "lobbyId")] - public ulong LobbyId; - - public AddReady(ulong LobbyId) { - this.LobbyId = LobbyId; - } - - public AddReady() { - } - - string IReducerArgs.ReducerName => "AddReady"; - } - } - - public sealed partial class SetReducerFlags { - internal CallReducerFlags AddReadyFlags; - public void AddReady(CallReducerFlags flags) => AddReadyFlags = flags; - } -} diff --git a/OpenPolytopia/src/ModuleBindings/Reducers/AddReady.g.cs.uid b/OpenPolytopia/src/ModuleBindings/Reducers/AddReady.g.cs.uid deleted file mode 100644 index c9985137..00000000 --- a/OpenPolytopia/src/ModuleBindings/Reducers/AddReady.g.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dbddqxekunp8n diff --git a/OpenPolytopia/src/ModuleBindings/Reducers/ClientConnected.g.cs b/OpenPolytopia/src/ModuleBindings/Reducers/ClientConnected.g.cs deleted file mode 100644 index d2c4ae7d..00000000 --- a/OpenPolytopia/src/ModuleBindings/Reducers/ClientConnected.g.cs +++ /dev/null @@ -1,33 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#nullable enable - -using System; -using SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types { - public sealed partial class RemoteReducers : RemoteBase { - public delegate void ClientConnectedHandler(ReducerEventContext ctx); - public event ClientConnectedHandler? OnClientConnected; - - public bool InvokeClientConnected(ReducerEventContext ctx, Reducer.ClientConnected args) { - if (OnClientConnected == null) - return false; - OnClientConnected( - ctx - ); - return true; - } - } - - public abstract partial class Reducer { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class ClientConnected : Reducer, IReducerArgs { - string IReducerArgs.ReducerName => "ClientConnected"; - } - } -} diff --git a/OpenPolytopia/src/ModuleBindings/Reducers/ClientConnected.g.cs.uid b/OpenPolytopia/src/ModuleBindings/Reducers/ClientConnected.g.cs.uid deleted file mode 100644 index 42edd9f7..00000000 --- a/OpenPolytopia/src/ModuleBindings/Reducers/ClientConnected.g.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bvd7xsfga5ust diff --git a/OpenPolytopia/src/ModuleBindings/Reducers/ClientDisconnected.g.cs b/OpenPolytopia/src/ModuleBindings/Reducers/ClientDisconnected.g.cs deleted file mode 100644 index ec471f44..00000000 --- a/OpenPolytopia/src/ModuleBindings/Reducers/ClientDisconnected.g.cs +++ /dev/null @@ -1,33 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#nullable enable - -using System; -using SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types { - public sealed partial class RemoteReducers : RemoteBase { - public delegate void ClientDisconnectedHandler(ReducerEventContext ctx); - public event ClientDisconnectedHandler? OnClientDisconnected; - - public bool InvokeClientDisconnected(ReducerEventContext ctx, Reducer.ClientDisconnected args) { - if (OnClientDisconnected == null) - return false; - OnClientDisconnected( - ctx - ); - return true; - } - } - - public abstract partial class Reducer { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class ClientDisconnected : Reducer, IReducerArgs { - string IReducerArgs.ReducerName => "ClientDisconnected"; - } - } -} diff --git a/OpenPolytopia/src/ModuleBindings/Reducers/ClientDisconnected.g.cs.uid b/OpenPolytopia/src/ModuleBindings/Reducers/ClientDisconnected.g.cs.uid deleted file mode 100644 index 1363eb3c..00000000 --- a/OpenPolytopia/src/ModuleBindings/Reducers/ClientDisconnected.g.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bkvrggb4hofgx diff --git a/OpenPolytopia/src/ModuleBindings/Reducers/CreateLobby.g.cs b/OpenPolytopia/src/ModuleBindings/Reducers/CreateLobby.g.cs deleted file mode 100644 index 99a9554d..00000000 --- a/OpenPolytopia/src/ModuleBindings/Reducers/CreateLobby.g.cs +++ /dev/null @@ -1,60 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#nullable enable - -using System; -using SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types { - public sealed partial class RemoteReducers : RemoteBase { - public delegate void CreateLobbyHandler(ReducerEventContext ctx, uint maxPlayers, uint tribe); - public event CreateLobbyHandler? OnCreateLobby; - - public void CreateLobby(uint maxPlayers, uint tribe) { - conn.InternalCallReducer(new Reducer.CreateLobby(maxPlayers, tribe), this.SetCallReducerFlags.CreateLobbyFlags); - } - - public bool InvokeCreateLobby(ReducerEventContext ctx, Reducer.CreateLobby args) { - if (OnCreateLobby == null) - return false; - OnCreateLobby( - ctx, - args.MaxPlayers, - args.Tribe - ); - return true; - } - } - - public abstract partial class Reducer { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class CreateLobby : Reducer, IReducerArgs { - [DataMember(Name = "maxPlayers")] - public uint MaxPlayers; - [DataMember(Name = "tribe")] - public uint Tribe; - - public CreateLobby( - uint MaxPlayers, - uint Tribe - ) { - this.MaxPlayers = MaxPlayers; - this.Tribe = Tribe; - } - - public CreateLobby() { - } - - string IReducerArgs.ReducerName => "CreateLobby"; - } - } - - public sealed partial class SetReducerFlags { - internal CallReducerFlags CreateLobbyFlags; - public void CreateLobby(CallReducerFlags flags) => CreateLobbyFlags = flags; - } -} diff --git a/OpenPolytopia/src/ModuleBindings/Reducers/CreateLobby.g.cs.uid b/OpenPolytopia/src/ModuleBindings/Reducers/CreateLobby.g.cs.uid deleted file mode 100644 index 15b76bc8..00000000 --- a/OpenPolytopia/src/ModuleBindings/Reducers/CreateLobby.g.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bsqyj214gb15r diff --git a/OpenPolytopia/src/ModuleBindings/Reducers/JoinLobby.g.cs b/OpenPolytopia/src/ModuleBindings/Reducers/JoinLobby.g.cs deleted file mode 100644 index edb8df5b..00000000 --- a/OpenPolytopia/src/ModuleBindings/Reducers/JoinLobby.g.cs +++ /dev/null @@ -1,60 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#nullable enable - -using System; -using SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types { - public sealed partial class RemoteReducers : RemoteBase { - public delegate void JoinLobbyHandler(ReducerEventContext ctx, ulong lobbyId, uint tribe); - public event JoinLobbyHandler? OnJoinLobby; - - public void JoinLobby(ulong lobbyId, uint tribe) { - conn.InternalCallReducer(new Reducer.JoinLobby(lobbyId, tribe), this.SetCallReducerFlags.JoinLobbyFlags); - } - - public bool InvokeJoinLobby(ReducerEventContext ctx, Reducer.JoinLobby args) { - if (OnJoinLobby == null) - return false; - OnJoinLobby( - ctx, - args.LobbyId, - args.Tribe - ); - return true; - } - } - - public abstract partial class Reducer { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class JoinLobby : Reducer, IReducerArgs { - [DataMember(Name = "lobbyId")] - public ulong LobbyId; - [DataMember(Name = "tribe")] - public uint Tribe; - - public JoinLobby( - ulong LobbyId, - uint Tribe - ) { - this.LobbyId = LobbyId; - this.Tribe = Tribe; - } - - public JoinLobby() { - } - - string IReducerArgs.ReducerName => "JoinLobby"; - } - } - - public sealed partial class SetReducerFlags { - internal CallReducerFlags JoinLobbyFlags; - public void JoinLobby(CallReducerFlags flags) => JoinLobbyFlags = flags; - } -} diff --git a/OpenPolytopia/src/ModuleBindings/Reducers/JoinLobby.g.cs.uid b/OpenPolytopia/src/ModuleBindings/Reducers/JoinLobby.g.cs.uid deleted file mode 100644 index be5d919a..00000000 --- a/OpenPolytopia/src/ModuleBindings/Reducers/JoinLobby.g.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bojt3bp58x2jn diff --git a/OpenPolytopia/src/ModuleBindings/Reducers/LeaveLobby.g.cs b/OpenPolytopia/src/ModuleBindings/Reducers/LeaveLobby.g.cs deleted file mode 100644 index 4cdaf884..00000000 --- a/OpenPolytopia/src/ModuleBindings/Reducers/LeaveLobby.g.cs +++ /dev/null @@ -1,53 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#nullable enable - -using System; -using SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types { - public sealed partial class RemoteReducers : RemoteBase { - public delegate void LeaveLobbyHandler(ReducerEventContext ctx, ulong lobbyId); - public event LeaveLobbyHandler? OnLeaveLobby; - - public void LeaveLobby(ulong lobbyId) { - conn.InternalCallReducer(new Reducer.LeaveLobby(lobbyId), this.SetCallReducerFlags.LeaveLobbyFlags); - } - - public bool InvokeLeaveLobby(ReducerEventContext ctx, Reducer.LeaveLobby args) { - if (OnLeaveLobby == null) - return false; - OnLeaveLobby( - ctx, - args.LobbyId - ); - return true; - } - } - - public abstract partial class Reducer { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class LeaveLobby : Reducer, IReducerArgs { - [DataMember(Name = "lobbyId")] - public ulong LobbyId; - - public LeaveLobby(ulong LobbyId) { - this.LobbyId = LobbyId; - } - - public LeaveLobby() { - } - - string IReducerArgs.ReducerName => "LeaveLobby"; - } - } - - public sealed partial class SetReducerFlags { - internal CallReducerFlags LeaveLobbyFlags; - public void LeaveLobby(CallReducerFlags flags) => LeaveLobbyFlags = flags; - } -} diff --git a/OpenPolytopia/src/ModuleBindings/Reducers/LeaveLobby.g.cs.uid b/OpenPolytopia/src/ModuleBindings/Reducers/LeaveLobby.g.cs.uid deleted file mode 100644 index 3cc5ba3f..00000000 --- a/OpenPolytopia/src/ModuleBindings/Reducers/LeaveLobby.g.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://wwriaujolago diff --git a/OpenPolytopia/src/ModuleBindings/Reducers/RemoveReady.g.cs b/OpenPolytopia/src/ModuleBindings/Reducers/RemoveReady.g.cs deleted file mode 100644 index 81de41cc..00000000 --- a/OpenPolytopia/src/ModuleBindings/Reducers/RemoveReady.g.cs +++ /dev/null @@ -1,53 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#nullable enable - -using System; -using SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types { - public sealed partial class RemoteReducers : RemoteBase { - public delegate void RemoveReadyHandler(ReducerEventContext ctx, ulong lobbyId); - public event RemoveReadyHandler? OnRemoveReady; - - public void RemoveReady(ulong lobbyId) { - conn.InternalCallReducer(new Reducer.RemoveReady(lobbyId), this.SetCallReducerFlags.RemoveReadyFlags); - } - - public bool InvokeRemoveReady(ReducerEventContext ctx, Reducer.RemoveReady args) { - if (OnRemoveReady == null) - return false; - OnRemoveReady( - ctx, - args.LobbyId - ); - return true; - } - } - - public abstract partial class Reducer { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class RemoveReady : Reducer, IReducerArgs { - [DataMember(Name = "lobbyId")] - public ulong LobbyId; - - public RemoveReady(ulong LobbyId) { - this.LobbyId = LobbyId; - } - - public RemoveReady() { - } - - string IReducerArgs.ReducerName => "RemoveReady"; - } - } - - public sealed partial class SetReducerFlags { - internal CallReducerFlags RemoveReadyFlags; - public void RemoveReady(CallReducerFlags flags) => RemoveReadyFlags = flags; - } -} diff --git a/OpenPolytopia/src/ModuleBindings/Reducers/RemoveReady.g.cs.uid b/OpenPolytopia/src/ModuleBindings/Reducers/RemoveReady.g.cs.uid deleted file mode 100644 index 0f4ff33d..00000000 --- a/OpenPolytopia/src/ModuleBindings/Reducers/RemoveReady.g.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cq86vuhh01pi5 diff --git a/OpenPolytopia/src/ModuleBindings/Reducers/SetName.g.cs b/OpenPolytopia/src/ModuleBindings/Reducers/SetName.g.cs deleted file mode 100644 index 3971e0a5..00000000 --- a/OpenPolytopia/src/ModuleBindings/Reducers/SetName.g.cs +++ /dev/null @@ -1,54 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#nullable enable - -using System; -using SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types { - public sealed partial class RemoteReducers : RemoteBase { - public delegate void SetNameHandler(ReducerEventContext ctx, string name); - public event SetNameHandler? OnSetName; - - public void SetName(string name) { - conn.InternalCallReducer(new Reducer.SetName(name), this.SetCallReducerFlags.SetNameFlags); - } - - public bool InvokeSetName(ReducerEventContext ctx, Reducer.SetName args) { - if (OnSetName == null) - return false; - OnSetName( - ctx, - args.Name - ); - return true; - } - } - - public abstract partial class Reducer { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class SetName : Reducer, IReducerArgs { - [DataMember(Name = "name")] - public string Name; - - public SetName(string Name) { - this.Name = Name; - } - - public SetName() { - this.Name = ""; - } - - string IReducerArgs.ReducerName => "SetName"; - } - } - - public sealed partial class SetReducerFlags { - internal CallReducerFlags SetNameFlags; - public void SetName(CallReducerFlags flags) => SetNameFlags = flags; - } -} diff --git a/OpenPolytopia/src/ModuleBindings/Reducers/SetName.g.cs.uid b/OpenPolytopia/src/ModuleBindings/Reducers/SetName.g.cs.uid deleted file mode 100644 index bd0eb8fd..00000000 --- a/OpenPolytopia/src/ModuleBindings/Reducers/SetName.g.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ch550f75bqafl diff --git a/OpenPolytopia/src/ModuleBindings/Reducers/StartLobby.g.cs b/OpenPolytopia/src/ModuleBindings/Reducers/StartLobby.g.cs deleted file mode 100644 index 77eebd0c..00000000 --- a/OpenPolytopia/src/ModuleBindings/Reducers/StartLobby.g.cs +++ /dev/null @@ -1,54 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#nullable enable - -using System; -using SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types { - public sealed partial class RemoteReducers : RemoteBase { - public delegate void StartLobbyHandler(ReducerEventContext ctx, StartLobbySchedule schedule); - public event StartLobbyHandler? OnStartLobby; - - public void StartLobby(StartLobbySchedule schedule) { - conn.InternalCallReducer(new Reducer.StartLobby(schedule), this.SetCallReducerFlags.StartLobbyFlags); - } - - public bool InvokeStartLobby(ReducerEventContext ctx, Reducer.StartLobby args) { - if (OnStartLobby == null) - return false; - OnStartLobby( - ctx, - args.Schedule - ); - return true; - } - } - - public abstract partial class Reducer { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class StartLobby : Reducer, IReducerArgs { - [DataMember(Name = "schedule")] - public StartLobbySchedule Schedule; - - public StartLobby(StartLobbySchedule Schedule) { - this.Schedule = Schedule; - } - - public StartLobby() { - this.Schedule = new(); - } - - string IReducerArgs.ReducerName => "StartLobby"; - } - } - - public sealed partial class SetReducerFlags { - internal CallReducerFlags StartLobbyFlags; - public void StartLobby(CallReducerFlags flags) => StartLobbyFlags = flags; - } -} diff --git a/OpenPolytopia/src/ModuleBindings/Reducers/StartLobby.g.cs.uid b/OpenPolytopia/src/ModuleBindings/Reducers/StartLobby.g.cs.uid deleted file mode 100644 index 6f99b4dd..00000000 --- a/OpenPolytopia/src/ModuleBindings/Reducers/StartLobby.g.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bkl1ms5jsvmif diff --git a/OpenPolytopia/src/ModuleBindings/SpacetimeDBClient.g.cs b/OpenPolytopia/src/ModuleBindings/SpacetimeDBClient.g.cs deleted file mode 100644 index 7b00c635..00000000 --- a/OpenPolytopia/src/ModuleBindings/SpacetimeDBClient.g.cs +++ /dev/null @@ -1,447 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#nullable enable - -using System; -using SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types { - public sealed partial class RemoteReducers : RemoteBase { - internal RemoteReducers(DbConnection conn, SetReducerFlags flags) : base(conn) => SetCallReducerFlags = flags; - internal readonly SetReducerFlags SetCallReducerFlags; - } - - public sealed partial class RemoteTables : RemoteTablesBase { - public RemoteTables(DbConnection conn) { - AddTable(Lobby = new(conn)); - AddTable(LobbyPlayer = new(conn)); - AddTable(Player = new(conn)); - AddTable(StartLobbySchedule = new(conn)); - } - } - - public sealed partial class SetReducerFlags { } - - public interface IRemoteDbContext : IDbContext { } - - public sealed class EventContext : IEventContext, IRemoteDbContext { - private readonly DbConnection conn; - - /// - /// The event that caused this callback to run. - /// - public readonly Event Event; - - /// - /// Access to tables in the client cache, which stores a read-only replica of the remote database state. - /// - /// The returned DbView will have a method to access each table defined by the module. - /// - public RemoteTables Db => conn.Db; - /// - /// Access to reducers defined by the module. - /// - /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, - /// plus methods for adding and removing callbacks on each of those reducers. - /// - public RemoteReducers Reducers => conn.Reducers; - /// - /// Access to setters for per-reducer flags. - /// - /// The returned SetReducerFlags will have a method to invoke, - /// for each reducer defined by the module, - /// which call-flags for the reducer can be set. - /// - public SetReducerFlags SetReducerFlags => conn.SetReducerFlags; - /// - /// Returns true if the connection is active, i.e. has not yet disconnected. - /// - public bool IsActive => conn.IsActive; - /// - /// Close the connection. - /// - /// Throws an error if the connection is already closed. - /// - public void Disconnect() { - conn.Disconnect(); - } - /// - /// Start building a subscription. - /// - /// A builder-pattern constructor for subscribing to queries, - /// causing matching rows to be replicated into the client cache. - public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); - /// - /// Get the Identity of this connection. - /// - /// This method returns null if the connection was constructed anonymously - /// and we have not yet received our newly-generated Identity from the host. - /// - public Identity? Identity => conn.Identity; - /// - /// Get this connection's ConnectionId. - /// - public ConnectionId ConnectionId => conn.ConnectionId; - - internal EventContext(DbConnection conn, Event Event) { - this.conn = conn; - this.Event = Event; - } - } - - public sealed class ReducerEventContext : IReducerEventContext, IRemoteDbContext { - private readonly DbConnection conn; - /// - /// The reducer event that caused this callback to run. - /// - public readonly ReducerEvent Event; - - /// - /// Access to tables in the client cache, which stores a read-only replica of the remote database state. - /// - /// The returned DbView will have a method to access each table defined by the module. - /// - public RemoteTables Db => conn.Db; - /// - /// Access to reducers defined by the module. - /// - /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, - /// plus methods for adding and removing callbacks on each of those reducers. - /// - public RemoteReducers Reducers => conn.Reducers; - /// - /// Access to setters for per-reducer flags. - /// - /// The returned SetReducerFlags will have a method to invoke, - /// for each reducer defined by the module, - /// which call-flags for the reducer can be set. - /// - public SetReducerFlags SetReducerFlags => conn.SetReducerFlags; - /// - /// Returns true if the connection is active, i.e. has not yet disconnected. - /// - public bool IsActive => conn.IsActive; - /// - /// Close the connection. - /// - /// Throws an error if the connection is already closed. - /// - public void Disconnect() { - conn.Disconnect(); - } - /// - /// Start building a subscription. - /// - /// A builder-pattern constructor for subscribing to queries, - /// causing matching rows to be replicated into the client cache. - public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); - /// - /// Get the Identity of this connection. - /// - /// This method returns null if the connection was constructed anonymously - /// and we have not yet received our newly-generated Identity from the host. - /// - public Identity? Identity => conn.Identity; - /// - /// Get this connection's ConnectionId. - /// - public ConnectionId ConnectionId => conn.ConnectionId; - - internal ReducerEventContext(DbConnection conn, ReducerEvent reducerEvent) { - this.conn = conn; - Event = reducerEvent; - } - } - - public sealed class ErrorContext : IErrorContext, IRemoteDbContext { - private readonly DbConnection conn; - /// - /// The Exception that caused this error callback to be run. - /// - public readonly Exception Event; - Exception IErrorContext.Event { - get { - return Event; - } - } - - /// - /// Access to tables in the client cache, which stores a read-only replica of the remote database state. - /// - /// The returned DbView will have a method to access each table defined by the module. - /// - public RemoteTables Db => conn.Db; - /// - /// Access to reducers defined by the module. - /// - /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, - /// plus methods for adding and removing callbacks on each of those reducers. - /// - public RemoteReducers Reducers => conn.Reducers; - /// - /// Access to setters for per-reducer flags. - /// - /// The returned SetReducerFlags will have a method to invoke, - /// for each reducer defined by the module, - /// which call-flags for the reducer can be set. - /// - public SetReducerFlags SetReducerFlags => conn.SetReducerFlags; - /// - /// Returns true if the connection is active, i.e. has not yet disconnected. - /// - public bool IsActive => conn.IsActive; - /// - /// Close the connection. - /// - /// Throws an error if the connection is already closed. - /// - public void Disconnect() { - conn.Disconnect(); - } - /// - /// Start building a subscription. - /// - /// A builder-pattern constructor for subscribing to queries, - /// causing matching rows to be replicated into the client cache. - public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); - /// - /// Get the Identity of this connection. - /// - /// This method returns null if the connection was constructed anonymously - /// and we have not yet received our newly-generated Identity from the host. - /// - public Identity? Identity => conn.Identity; - /// - /// Get this connection's ConnectionId. - /// - public ConnectionId ConnectionId => conn.ConnectionId; - - internal ErrorContext(DbConnection conn, Exception error) { - this.conn = conn; - Event = error; - } - } - - public sealed class SubscriptionEventContext : ISubscriptionEventContext, IRemoteDbContext { - private readonly DbConnection conn; - - /// - /// Access to tables in the client cache, which stores a read-only replica of the remote database state. - /// - /// The returned DbView will have a method to access each table defined by the module. - /// - public RemoteTables Db => conn.Db; - /// - /// Access to reducers defined by the module. - /// - /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, - /// plus methods for adding and removing callbacks on each of those reducers. - /// - public RemoteReducers Reducers => conn.Reducers; - /// - /// Access to setters for per-reducer flags. - /// - /// The returned SetReducerFlags will have a method to invoke, - /// for each reducer defined by the module, - /// which call-flags for the reducer can be set. - /// - public SetReducerFlags SetReducerFlags => conn.SetReducerFlags; - /// - /// Returns true if the connection is active, i.e. has not yet disconnected. - /// - public bool IsActive => conn.IsActive; - /// - /// Close the connection. - /// - /// Throws an error if the connection is already closed. - /// - public void Disconnect() { - conn.Disconnect(); - } - /// - /// Start building a subscription. - /// - /// A builder-pattern constructor for subscribing to queries, - /// causing matching rows to be replicated into the client cache. - public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); - /// - /// Get the Identity of this connection. - /// - /// This method returns null if the connection was constructed anonymously - /// and we have not yet received our newly-generated Identity from the host. - /// - public Identity? Identity => conn.Identity; - /// - /// Get this connection's ConnectionId. - /// - public ConnectionId ConnectionId => conn.ConnectionId; - - internal SubscriptionEventContext(DbConnection conn) { - this.conn = conn; - } - } - - /// - /// Builder-pattern constructor for subscription queries. - /// - public sealed class SubscriptionBuilder { - private readonly IDbConnection conn; - - private event Action? Applied; - private event Action? Error; - - /// - /// Private API, use conn.SubscriptionBuilder() instead. - /// - public SubscriptionBuilder(IDbConnection conn) { - this.conn = conn; - } - - /// - /// Register a callback to run when the subscription is applied. - /// - public SubscriptionBuilder OnApplied( - Action callback - ) { - Applied += callback; - return this; - } - - /// - /// Register a callback to run when the subscription fails. - /// - /// Note that this callback may run either when attempting to apply the subscription, - /// in which case Self::on_applied will never run, - /// or later during the subscription's lifetime if the module's interface changes, - /// in which case Self::on_applied may have already run. - /// - public SubscriptionBuilder OnError( - Action callback - ) { - Error += callback; - return this; - } - - /// - /// Subscribe to the following SQL queries. - /// - /// This method returns immediately, with the data not yet added to the DbConnection. - /// The provided callbacks will be invoked once the data is returned from the remote server. - /// Data from all the provided queries will be returned at the same time. - /// - /// See the SpacetimeDB SQL docs for more information on SQL syntax: - /// https://spacetimedb.com/docs/sql - /// - public SubscriptionHandle Subscribe( - string[] querySqls - ) => new(conn, Applied, Error, querySqls); - - /// - /// Subscribe to all rows from all tables. - /// - /// This method is intended as a convenience - /// for applications where client-side memory use and network bandwidth are not concerns. - /// Applications where these resources are a constraint - /// should register more precise queries via Self.Subscribe - /// in order to replicate only the subset of data which the client needs to function. - /// - /// This method should not be combined with Self.Subscribe on the same DbConnection. - /// A connection may either Self.Subscribe to particular queries, - /// or Self.SubscribeToAllTables, but not both. - /// Attempting to call Self.Subscribe - /// on a DbConnection that has previously used Self.SubscribeToAllTables, - /// or vice versa, may misbehave in any number of ways, - /// including dropping subscriptions, corrupting the client cache, or panicking. - /// - public void SubscribeToAllTables() { - // Make sure we use the legacy handle constructor here, even though there's only 1 query. - // We drop the error handler, since it can't be called for legacy subscriptions. - new SubscriptionHandle( - conn, - Applied, - new string[] { "SELECT * FROM *" } - ); - } - } - - public sealed class SubscriptionHandle : SubscriptionHandleBase { - /// - /// Internal API. Construct SubscriptionHandles using conn.SubscriptionBuilder. - /// - public SubscriptionHandle(IDbConnection conn, Action? onApplied, string[] querySqls) : base(conn, onApplied, querySqls) { } - - /// - /// Internal API. Construct SubscriptionHandles using conn.SubscriptionBuilder. - /// - public SubscriptionHandle( - IDbConnection conn, - Action? onApplied, - Action? onError, - string[] querySqls - ) : base(conn, onApplied, onError, querySqls) { } - } - - public abstract partial class Reducer { - private Reducer() { } - } - - public sealed class DbConnection : DbConnectionBase { - public override RemoteTables Db { get; } - public readonly RemoteReducers Reducers; - public readonly SetReducerFlags SetReducerFlags = new(); - - public DbConnection() { - Db = new(this); - Reducers = new(this, SetReducerFlags); - } - - protected override Reducer ToReducer(TransactionUpdate update) { - var encodedArgs = update.ReducerCall.Args; - return update.ReducerCall.ReducerName switch { - "AddReady" => BSATNHelpers.Decode(encodedArgs), - "ClientConnected" => BSATNHelpers.Decode(encodedArgs), - "ClientDisconnected" => BSATNHelpers.Decode(encodedArgs), - "CreateLobby" => BSATNHelpers.Decode(encodedArgs), - "JoinLobby" => BSATNHelpers.Decode(encodedArgs), - "LeaveLobby" => BSATNHelpers.Decode(encodedArgs), - "RemoveReady" => BSATNHelpers.Decode(encodedArgs), - "SetName" => BSATNHelpers.Decode(encodedArgs), - "StartLobby" => BSATNHelpers.Decode(encodedArgs), - var reducer => throw new ArgumentOutOfRangeException("Reducer", $"Unknown reducer {reducer}") - }; - } - - protected override IEventContext ToEventContext(Event Event) => - new EventContext(this, Event); - - protected override IReducerEventContext ToReducerEventContext(ReducerEvent reducerEvent) => - new ReducerEventContext(this, reducerEvent); - - protected override ISubscriptionEventContext MakeSubscriptionEventContext() => - new SubscriptionEventContext(this); - - protected override IErrorContext ToErrorContext(Exception exception) => - new ErrorContext(this, exception); - - protected override bool Dispatch(IReducerEventContext context, Reducer reducer) { - var eventContext = (ReducerEventContext)context; - return reducer switch { - Reducer.AddReady args => Reducers.InvokeAddReady(eventContext, args), - Reducer.ClientConnected args => Reducers.InvokeClientConnected(eventContext, args), - Reducer.ClientDisconnected args => Reducers.InvokeClientDisconnected(eventContext, args), - Reducer.CreateLobby args => Reducers.InvokeCreateLobby(eventContext, args), - Reducer.JoinLobby args => Reducers.InvokeJoinLobby(eventContext, args), - Reducer.LeaveLobby args => Reducers.InvokeLeaveLobby(eventContext, args), - Reducer.RemoveReady args => Reducers.InvokeRemoveReady(eventContext, args), - Reducer.SetName args => Reducers.InvokeSetName(eventContext, args), - Reducer.StartLobby args => Reducers.InvokeStartLobby(eventContext, args), - _ => throw new ArgumentOutOfRangeException("Reducer", $"Unknown reducer {reducer}") - }; - } - - public SubscriptionBuilder SubscriptionBuilder() => new(this); - } -} diff --git a/OpenPolytopia/src/ModuleBindings/SpacetimeDBClient.g.cs.uid b/OpenPolytopia/src/ModuleBindings/SpacetimeDBClient.g.cs.uid deleted file mode 100644 index c879a69b..00000000 --- a/OpenPolytopia/src/ModuleBindings/SpacetimeDBClient.g.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ycf0lcxvn1d8 diff --git a/OpenPolytopia/src/ModuleBindings/Tables/Lobby.g.cs b/OpenPolytopia/src/ModuleBindings/Tables/Lobby.g.cs deleted file mode 100644 index f5ab2ff5..00000000 --- a/OpenPolytopia/src/ModuleBindings/Tables/Lobby.g.cs +++ /dev/null @@ -1,34 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#nullable enable - -using System; -using SpacetimeDB.BSATN; -using SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types { - public sealed partial class RemoteTables { - public sealed class LobbyHandle : RemoteTableHandle { - protected override string RemoteTableName => "Lobby"; - - public sealed class IdUniqueIndex : UniqueIndexBase { - protected override ulong GetKey(Lobby row) => row.Id; - - public IdUniqueIndex(LobbyHandle table) : base(table) { } - } - - public readonly IdUniqueIndex Id; - - internal LobbyHandle(DbConnection conn) : base(conn) { - Id = new(this); - } - - protected override object GetPrimaryKey(Lobby row) => row.Id; - } - - public readonly LobbyHandle Lobby; - } -} diff --git a/OpenPolytopia/src/ModuleBindings/Tables/Lobby.g.cs.uid b/OpenPolytopia/src/ModuleBindings/Tables/Lobby.g.cs.uid deleted file mode 100644 index f69cb638..00000000 --- a/OpenPolytopia/src/ModuleBindings/Tables/Lobby.g.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://nd3g25la6si5 diff --git a/OpenPolytopia/src/ModuleBindings/Tables/LobbyPlayer.g.cs b/OpenPolytopia/src/ModuleBindings/Tables/LobbyPlayer.g.cs deleted file mode 100644 index c3a64c94..00000000 --- a/OpenPolytopia/src/ModuleBindings/Tables/LobbyPlayer.g.cs +++ /dev/null @@ -1,43 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#nullable enable - -using System; -using SpacetimeDB.BSATN; -using SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types { - public sealed partial class RemoteTables { - public sealed class LobbyPlayerHandle : RemoteTableHandle { - protected override string RemoteTableName => "LobbyPlayer"; - - public sealed class IdUniqueIndex : UniqueIndexBase { - protected override ulong GetKey(LobbyPlayer row) => row.Id; - - public IdUniqueIndex(LobbyPlayerHandle table) : base(table) { } - } - - public readonly IdUniqueIndex Id; - - public sealed class LobbyAndPlayerIndex : BTreeIndexBase<(ulong LobbyId, SpacetimeDB.Identity PlayerId)> { - protected override (ulong LobbyId, SpacetimeDB.Identity PlayerId) GetKey(LobbyPlayer row) => (row.LobbyId, row.PlayerId); - - public LobbyAndPlayerIndex(LobbyPlayerHandle table) : base(table) { } - } - - public readonly LobbyAndPlayerIndex LobbyAndPlayer; - - internal LobbyPlayerHandle(DbConnection conn) : base(conn) { - Id = new(this); - LobbyAndPlayer = new(this); - } - - protected override object GetPrimaryKey(LobbyPlayer row) => row.Id; - } - - public readonly LobbyPlayerHandle LobbyPlayer; - } -} diff --git a/OpenPolytopia/src/ModuleBindings/Tables/LobbyPlayer.g.cs.uid b/OpenPolytopia/src/ModuleBindings/Tables/LobbyPlayer.g.cs.uid deleted file mode 100644 index eddf1bee..00000000 --- a/OpenPolytopia/src/ModuleBindings/Tables/LobbyPlayer.g.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c1s3t0p6kgw1a diff --git a/OpenPolytopia/src/ModuleBindings/Tables/Player.g.cs b/OpenPolytopia/src/ModuleBindings/Tables/Player.g.cs deleted file mode 100644 index 4ab5df77..00000000 --- a/OpenPolytopia/src/ModuleBindings/Tables/Player.g.cs +++ /dev/null @@ -1,34 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#nullable enable - -using System; -using SpacetimeDB.BSATN; -using SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types { - public sealed partial class RemoteTables { - public sealed class PlayerHandle : RemoteTableHandle { - protected override string RemoteTableName => "Player"; - - public sealed class IdUniqueIndex : UniqueIndexBase { - protected override SpacetimeDB.Identity GetKey(Player row) => row.Id; - - public IdUniqueIndex(PlayerHandle table) : base(table) { } - } - - public readonly IdUniqueIndex Id; - - internal PlayerHandle(DbConnection conn) : base(conn) { - Id = new(this); - } - - protected override object GetPrimaryKey(Player row) => row.Id; - } - - public readonly PlayerHandle Player; - } -} diff --git a/OpenPolytopia/src/ModuleBindings/Tables/Player.g.cs.uid b/OpenPolytopia/src/ModuleBindings/Tables/Player.g.cs.uid deleted file mode 100644 index 763ab591..00000000 --- a/OpenPolytopia/src/ModuleBindings/Tables/Player.g.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cfdfeepd5xxwx diff --git a/OpenPolytopia/src/ModuleBindings/Tables/StartLobbySchedule.g.cs b/OpenPolytopia/src/ModuleBindings/Tables/StartLobbySchedule.g.cs deleted file mode 100644 index d674db4f..00000000 --- a/OpenPolytopia/src/ModuleBindings/Tables/StartLobbySchedule.g.cs +++ /dev/null @@ -1,34 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#nullable enable - -using System; -using SpacetimeDB.BSATN; -using SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types { - public sealed partial class RemoteTables { - public sealed class StartLobbyScheduleHandle : RemoteTableHandle { - protected override string RemoteTableName => "StartLobbySchedule"; - - public sealed class IdUniqueIndex : UniqueIndexBase { - protected override ulong GetKey(StartLobbySchedule row) => row.Id; - - public IdUniqueIndex(StartLobbyScheduleHandle table) : base(table) { } - } - - public readonly IdUniqueIndex Id; - - internal StartLobbyScheduleHandle(DbConnection conn) : base(conn) { - Id = new(this); - } - - protected override object GetPrimaryKey(StartLobbySchedule row) => row.Id; - } - - public readonly StartLobbyScheduleHandle StartLobbySchedule; - } -} diff --git a/OpenPolytopia/src/ModuleBindings/Tables/StartLobbySchedule.g.cs.uid b/OpenPolytopia/src/ModuleBindings/Tables/StartLobbySchedule.g.cs.uid deleted file mode 100644 index fe4cc794..00000000 --- a/OpenPolytopia/src/ModuleBindings/Tables/StartLobbySchedule.g.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://by0h71q5ahoci diff --git a/OpenPolytopia/src/ModuleBindings/Types/Lobby.g.cs b/OpenPolytopia/src/ModuleBindings/Types/Lobby.g.cs deleted file mode 100644 index 41375f55..00000000 --- a/OpenPolytopia/src/ModuleBindings/Types/Lobby.g.cs +++ /dev/null @@ -1,46 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#nullable enable - -using System; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class Lobby { - [DataMember(Name = "Id")] - public ulong Id; - [DataMember(Name = "MaxPlayers")] - public uint MaxPlayers; - [DataMember(Name = "Players")] - public uint Players; - [DataMember(Name = "Ready")] - public uint Ready; - [DataMember(Name = "Started")] - public bool Started; - [DataMember(Name = "Starting")] - public bool Starting; - - public Lobby( - ulong Id, - uint MaxPlayers, - uint Players, - uint Ready, - bool Started, - bool Starting - ) { - this.Id = Id; - this.MaxPlayers = MaxPlayers; - this.Players = Players; - this.Ready = Ready; - this.Started = Started; - this.Starting = Starting; - } - - public Lobby() { - } - } -} diff --git a/OpenPolytopia/src/ModuleBindings/Types/Lobby.g.cs.uid b/OpenPolytopia/src/ModuleBindings/Types/Lobby.g.cs.uid deleted file mode 100644 index dbc19811..00000000 --- a/OpenPolytopia/src/ModuleBindings/Types/Lobby.g.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b7xel1su8kks4 diff --git a/OpenPolytopia/src/ModuleBindings/Types/LobbyPlayer.g.cs b/OpenPolytopia/src/ModuleBindings/Types/LobbyPlayer.g.cs deleted file mode 100644 index 413f4d26..00000000 --- a/OpenPolytopia/src/ModuleBindings/Types/LobbyPlayer.g.cs +++ /dev/null @@ -1,38 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#nullable enable - -using System; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class LobbyPlayer { - [DataMember(Name = "Id")] - public ulong Id; - [DataMember(Name = "LobbyId")] - public ulong LobbyId; - [DataMember(Name = "PlayerId")] - public SpacetimeDB.Identity PlayerId; - [DataMember(Name = "Tribe")] - public uint Tribe; - - public LobbyPlayer( - ulong Id, - ulong LobbyId, - SpacetimeDB.Identity PlayerId, - uint Tribe - ) { - this.Id = Id; - this.LobbyId = LobbyId; - this.PlayerId = PlayerId; - this.Tribe = Tribe; - } - - public LobbyPlayer() { - } - } -} diff --git a/OpenPolytopia/src/ModuleBindings/Types/LobbyPlayer.g.cs.uid b/OpenPolytopia/src/ModuleBindings/Types/LobbyPlayer.g.cs.uid deleted file mode 100644 index a88bd2fe..00000000 --- a/OpenPolytopia/src/ModuleBindings/Types/LobbyPlayer.g.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cqvvnkpo4lad6 diff --git a/OpenPolytopia/src/ModuleBindings/Types/Player.g.cs b/OpenPolytopia/src/ModuleBindings/Types/Player.g.cs deleted file mode 100644 index 5e4fea20..00000000 --- a/OpenPolytopia/src/ModuleBindings/Types/Player.g.cs +++ /dev/null @@ -1,35 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#nullable enable - -using System; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class Player { - [DataMember(Name = "Id")] - public SpacetimeDB.Identity Id; - [DataMember(Name = "Name")] - public string Name; - [DataMember(Name = "Online")] - public bool Online; - - public Player( - SpacetimeDB.Identity Id, - string Name, - bool Online - ) { - this.Id = Id; - this.Name = Name; - this.Online = Online; - } - - public Player() { - this.Name = ""; - } - } -} diff --git a/OpenPolytopia/src/ModuleBindings/Types/Player.g.cs.uid b/OpenPolytopia/src/ModuleBindings/Types/Player.g.cs.uid deleted file mode 100644 index b6745476..00000000 --- a/OpenPolytopia/src/ModuleBindings/Types/Player.g.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bfporpkxtsfmy diff --git a/OpenPolytopia/src/ModuleBindings/Types/StartLobbySchedule.g.cs b/OpenPolytopia/src/ModuleBindings/Types/StartLobbySchedule.g.cs deleted file mode 100644 index 47796651..00000000 --- a/OpenPolytopia/src/ModuleBindings/Types/StartLobbySchedule.g.cs +++ /dev/null @@ -1,31 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#nullable enable - -using System; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class StartLobbySchedule { - [DataMember(Name = "Id")] - public ulong Id; - [DataMember(Name = "ScheduledAt")] - public SpacetimeDB.ScheduleAt ScheduledAt; - - public StartLobbySchedule( - ulong Id, - SpacetimeDB.ScheduleAt ScheduledAt - ) { - this.Id = Id; - this.ScheduledAt = ScheduledAt; - } - - public StartLobbySchedule() { - this.ScheduledAt = null!; - } - } -} diff --git a/OpenPolytopia/src/ModuleBindings/Types/StartLobbySchedule.g.cs.uid b/OpenPolytopia/src/ModuleBindings/Types/StartLobbySchedule.g.cs.uid deleted file mode 100644 index 9e0b86dc..00000000 --- a/OpenPolytopia/src/ModuleBindings/Types/StartLobbySchedule.g.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dvgo7f4u8vhr1 diff --git a/OpenPolytopia/src/NetworkNode.cs b/OpenPolytopia/src/NetworkNode.cs new file mode 100644 index 00000000..3c5cc314 --- /dev/null +++ b/OpenPolytopia/src/NetworkNode.cs @@ -0,0 +1,248 @@ +namespace OpenPolytopia; + +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using Common; +using Common.Network; +using Common.Network.Packets; +using Godot; + +/// +/// Manages the connection with the game server. +///
+/// Packets are received in background and processed on the main thread +/// in , so every event is safe to use with Godot nodes +///
+public partial class NetworkNode : Node { + /// + /// Public singleton + /// + public static NetworkNode Instance { get; private set; } = null!; + + private const string HOST = "enn3.ovh"; + private const int PORT = NetworkConstants.DEFAULT_PORT; + + private ClientConnection? _connection; + + /// + /// Id assigned to this client by the server; valid after + /// + public uint PlayerId { get; private set; } + + /// + /// true after a successful handshake with the server + /// + public bool Connected { get; private set; } + + /// + /// Lobbies data; it updates when the server broadcasts lobby changes + /// + public readonly ObservableCollection Lobbies = []; + + /// + /// Fired after a successful handshake + /// + public event Action? OnConnected; + + /// + /// Fired when the connection with the server gets lost + /// + public event Action? OnDisconnected; + + /// + /// Fired after the server responds to + /// + public event Action? OnNameSet; + + /// + /// Fired after the server responds to + /// + public event Action? OnLobbyCreated; + + /// + /// Fired after the server responds to + /// + public event Action? OnLobbyJoined; + + /// + /// Fired after the server responds to + /// + public event Action? OnLobbyLeft; + + /// + /// Fired after the server responds to + /// + public event Action? OnReadySet; + + /// + /// Fired when the game of a lobby this player joined starts + /// + public event Action? OnGameStarted; + + /// + /// Initialize the connection + /// + public override void _EnterTree() { + base._EnterTree(); + Instance = this; + _ = ConnectToServerAsync(); + } + + /// + /// Disconnect from the server + /// + public override void _ExitTree() { + base._ExitTree(); + _connection?.Dispose(); + _connection = null; + } + + /// + /// Process messages received from the server + /// + /// ignored + public override void _PhysicsProcess(double delta) { + if (_connection == null) { + return; + } + + while (_connection.IncomingPackets.TryDequeue(out var packet)) { + ProcessPacket(packet); + } + } + + /// + /// Registers the player on the server or renames him + /// + /// the new player's name + public void SetName(string name) => Send(new SetNamePacket { Name = name }); + + /// + /// Queries the server for all the lobbies + /// + public void RefreshLobbies() => Send(new GetLobbiesPacket()); + + /// + /// Creates a new lobby; this player automatically joins it + /// + /// max players that can join the lobby + /// the tribe chosen by this player + public void CreateLobby(uint maxPlayers, uint tribe) => + Send(new CreateLobbyPacket { MaxPlayers = maxPlayers, Tribe = tribe }); + + /// + /// Joins an existing lobby + /// + /// the id of the lobby to join + /// the tribe chosen by this player + public void JoinLobby(ulong lobbyId, uint tribe) => Send(new JoinLobbyPacket { LobbyId = lobbyId, Tribe = tribe }); + + /// + /// Leaves a lobby this player joined before + /// + /// the id of the lobby to leave + public void LeaveLobby(ulong lobbyId) => Send(new LeaveLobbyPacket { LobbyId = lobbyId }); + + /// + /// Updates the ready state of this player in a lobby + /// + /// the id of the lobby + /// the new ready state + public void SetReady(ulong lobbyId, bool ready) => Send(new SetReadyPacket { LobbyId = lobbyId, Ready = ready }); + + private async Task ConnectToServerAsync() { + try { + _connection = new ClientConnection(HOST, PORT); + _connection.OnDisconnected += () => { + Connected = false; + OnDisconnected?.Invoke(); + }; + await _connection.ConnectAsync(); + await _connection.SendPacketAsync(new HandshakePacket { Version = NetworkConstants.VERSION }); + } + catch (Exception e) { + GD.PushError($"Error while connecting: {e}"); + } + } + + private void Send(IPacket packet) { + if (_connection == null) { + GD.PushError("Not connected to the server"); + return; + } + + _ = SendAsync(packet); + } + + private async Task SendAsync(IPacket packet) { + try { + await _connection!.SendPacketAsync(packet); + } + catch (Exception e) { + GD.PushError($"Error while sending packet: {e}"); + } + } + + private void ProcessPacket(IPacket packet) { + switch (packet) { + case HandshakeResponsePacket handshakeResponse: + if (!handshakeResponse.Ok) { + GD.PushError("Server refused the connection: incompatible version"); + _connection?.Disconnect(); + break; + } + + PlayerId = handshakeResponse.PlayerId; + Connected = true; + OnConnected?.Invoke(); + + // get the initial lobby list + RefreshLobbies(); + break; + case SetNameResponsePacket setNameResponse: + OnNameSet?.Invoke(setNameResponse.Ok); + break; + case GetLobbiesResponsePacket lobbiesResponse: + Lobbies.Clear(); + foreach (var lobby in lobbiesResponse.Lobbies) { + Lobbies.Add(lobby); + } + + break; + case CreateLobbyResponsePacket createLobbyResponse: + OnLobbyCreated?.Invoke(createLobbyResponse.Result, createLobbyResponse.LobbyId); + break; + case JoinLobbyResponsePacket joinLobbyResponse: + OnLobbyJoined?.Invoke(joinLobbyResponse.Result, joinLobbyResponse.LobbyId); + break; + case LeaveLobbyResponsePacket leaveLobbyResponse: + OnLobbyLeft?.Invoke(leaveLobbyResponse.Result, leaveLobbyResponse.LobbyId); + break; + case SetReadyResponsePacket setReadyResponse: + OnReadySet?.Invoke(setReadyResponse.Result, setReadyResponse.LobbyId); + break; + case LobbyUpdatedPacket lobbyUpdated: + // remove the old lobby data if present + var oldLobby = Lobbies.FirstOrDefault(lobby => lobby.Id == lobbyUpdated.Lobby.Id); + if (oldLobby != null) { + Lobbies.Remove(oldLobby); + } + + // add back the lobby data to force the list to emit the event + Lobbies.Add(lobbyUpdated.Lobby); + break; + case LobbyDeletedPacket lobbyDeleted: + var deletedLobby = Lobbies.FirstOrDefault(lobby => lobby.Id == lobbyDeleted.LobbyId); + if (deletedLobby != null) { + Lobbies.Remove(deletedLobby); + } + + break; + case GameStartedPacket gameStarted: + OnGameStarted?.Invoke(gameStarted); + break; + } + } +} diff --git a/OpenPolytopia/src/PlayerData.cs.uid b/OpenPolytopia/src/PlayerData.cs.uid deleted file mode 100644 index 1ac27abf..00000000 --- a/OpenPolytopia/src/PlayerData.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c06dsx6bvmni3 diff --git a/OpenPolytopia/src/SpacetimeNode.cs b/OpenPolytopia/src/SpacetimeNode.cs deleted file mode 100644 index aca966fc..00000000 --- a/OpenPolytopia/src/SpacetimeNode.cs +++ /dev/null @@ -1,146 +0,0 @@ -namespace OpenPolytopia; - -using System; -using System.Collections.ObjectModel; -using System.Linq; -using Godot; -using SpacetimeDB; -using SpacetimeDB.Types; - -public partial class SpacetimeNode : Node { - /// - /// Public singleton - /// - public static SpacetimeNode Instance { get; private set; } = null!; - - private const string HOST = "https://spacetime.enn3.ovh"; - private const string DBNAME = "openpolytopia"; - - private Identity _identity; - - /// - /// Accessor to the database connection - /// - public DbConnection Connection { get; private set; } = null!; - - /// - /// Lobbies data; it updates - /// - public readonly ObservableCollection Lobbies = []; - - /// - /// Initialize the connection - /// - public override void _EnterTree() { - base._EnterTree(); - Instance = this; - AuthToken.Init(".stdb_openpolytopia"); - Connection = ConnectToDb(); - RegisterCallbacks(); - } - - /// - /// Disconnect from the database - /// - public override void _ExitTree() { - base._ExitTree(); - Connection.Disconnect(); - } - - /// - /// Process messages - /// - /// ignored - public override void _PhysicsProcess(double delta) => Connection.FrameTick(); - - /// - /// Registering callbacks - /// - private void RegisterCallbacks() { - // When a lobby gets added (because the player joined) - Connection.Db.Lobby.OnInsert += (context, row) => { - // add the lobby to the observable list - Lobbies.Add( - new LobbyData { Id = row.Id, MaxPlayers = row.MaxPlayers, Players = row.Players, Ready = row.Ready }); - }; - - // When a lobby gets deleted (because the player left the lobby) - Connection.Db.Lobby.OnDelete += (context, row) => { - // search for the lobby data in memory - var data = Lobbies.FirstOrDefault(data => data.Id == row.Id); - // check if it exists - if (data == null) { - return; - } - - // remove it - Lobbies.Remove(data); - }; - - // When a lobby gets updated (because another player joined, etc...) - Connection.Db.Lobby.OnUpdate += (context, row, newRow) => { - // search for the lobby data in memory - var data = Lobbies.FirstOrDefault(data => data.Id == row.Id); - // check if it exists - if (data != null) { - // so delete it - Lobbies.Remove(data); - } - - // finally, add back the lobby data to force the list to emit the event - Lobbies.Add( - new LobbyData { - Id = newRow.Id, MaxPlayers = newRow.MaxPlayers, Players = newRow.Players, Ready = newRow.Ready - }); - }; - } - - /// - /// Connect to the database - /// - /// the database connection - private DbConnection ConnectToDb() { - var conn = DbConnection.Builder() - .WithUri(HOST) - .WithModuleName(DBNAME) - .WithToken(AuthToken.Token) - .OnConnect(OnConnected) - .OnConnectError(OnConnectError) - .OnDisconnect(OnDisconnected) - .Build(); - return conn; - } - - private void OnConnected(DbConnection conn, Identity identity, string authToken) { - AuthToken.SaveToken(authToken); - _identity = identity; - - // subscribe with these queries to get updates on the tables - conn.SubscriptionBuilder().OnApplied(context => { }).OnError((context, exception) => { }).Subscribe([ - // get lobbies the player joined - $"SELECT Lobby.* FROM Lobby JOIN LobbyPlayer ON Lobby.Id = LobbyPlayer.LobbyId WHERE LobbyPlayer.PlayerId = 0x{identity}", - "SELECT * FROM Player", - "SELECT * FROM LobbyPlayer" - ]); - } - - private void OnConnectError(Exception e) { - GD.PushError($"Error while connecting: {e}"); - } - - private void OnDisconnected(DbConnection conn, Exception? e) { - if (e != null) { - GD.PushError($"Disconnected abnormally: {e}"); - } - else { - GD.Print($"Disconnected normally."); - } - } -} - -public class LobbyData { - public ulong Id { get; init; } - public uint MaxPlayers; - public uint Players; - public uint Ready; -} diff --git a/OpenPolytopia/src/SpacetimeNode.cs.uid b/OpenPolytopia/src/SpacetimeNode.cs.uid deleted file mode 100644 index 721f7ea6..00000000 --- a/OpenPolytopia/src/SpacetimeNode.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cq3ibkhvrgl00 diff --git a/OpenPolytopia/test/src/PacketTest.cs b/OpenPolytopia/test/src/PacketTest.cs new file mode 100644 index 00000000..a15ef3e0 --- /dev/null +++ b/OpenPolytopia/test/src/PacketTest.cs @@ -0,0 +1,116 @@ +namespace OpenPolytopia; + +using System.Collections.Generic; +using Chickensoft.GoDotTest; +using Common; +using Common.Network; +using Common.Network.Packets; +using Godot; +using Shouldly; + +public class PacketTest(Node testScene) : TestClass(testScene) { + private static T RoundTrip(T packet) where T : IPacket, new() { + List bytes = []; + packet.Serialize(bytes); + var deserialized = new T(); + var index = 0u; + deserialized.Deserialize(bytes.ToArray(), ref index); + index.ShouldBe((uint)bytes.Count); + return deserialized; + } + + [Test] + public void TestHandshake() { + var packet = RoundTrip(new HandshakePacket { Version = "0.1.0" }); + packet.Version.ShouldBe("0.1.0"); + } + + [Test] + public void TestHandshakeResponse() { + var packet = RoundTrip(new HandshakeResponsePacket { Ok = true, PlayerId = 42 }); + packet.Ok.ShouldBeTrue(); + packet.PlayerId.ShouldBe(42u); + } + + [Test] + public void TestKeepAlive() { + var packet = RoundTrip(new KeepAlivePacket { Captcha = 20u }); + packet.Captcha.ShouldBe(20u); + } + + [Test] + public void TestSetName() { + var packet = RoundTrip(new SetNamePacket { Name = "Tester àèù" }); + packet.Name.ShouldBe("Tester àèù"); + } + + [Test] + public void TestGetLobbiesResponse() { + var lobby = new LobbyData { Id = 123, MaxPlayers = 4 }; + lobby.Players.Add(new LobbyPlayerData { PlayerId = 7, Name = "Test", Tribe = 2, Ready = true }); + var packet = RoundTrip(new GetLobbiesResponsePacket { Lobbies = [lobby] }); + packet.Lobbies.Count.ShouldBe(1); + packet.Lobbies[0].Id.ShouldBe(123u); + packet.Lobbies[0].MaxPlayers.ShouldBe(4u); + packet.Lobbies[0].Players.Count.ShouldBe(1); + packet.Lobbies[0].Players[0].PlayerId.ShouldBe(7u); + packet.Lobbies[0].Players[0].Name.ShouldBe("Test"); + packet.Lobbies[0].Players[0].Tribe.ShouldBe(2u); + packet.Lobbies[0].Players[0].Ready.ShouldBeTrue(); + packet.Lobbies[0].ReadyCount.ShouldBe(1u); + } + + [Test] + public void TestCreateLobby() { + var packet = RoundTrip(new CreateLobbyPacket { MaxPlayers = 8, Tribe = 3 }); + packet.MaxPlayers.ShouldBe(8u); + packet.Tribe.ShouldBe(3u); + } + + [Test] + public void TestCreateLobbyResponse() { + var packet = RoundTrip(new CreateLobbyResponsePacket { Result = LobbyActionResult.Ok, LobbyId = 99 }); + packet.Result.ShouldBe(LobbyActionResult.Ok); + packet.LobbyId.ShouldBe(99u); + } + + [Test] + public void TestJoinLobbyResponse() { + var packet = RoundTrip(new JoinLobbyResponsePacket { Result = LobbyActionResult.LobbyFull, LobbyId = 5 }); + packet.Result.ShouldBe(LobbyActionResult.LobbyFull); + packet.LobbyId.ShouldBe(5u); + } + + [Test] + public void TestSetReady() { + var packet = RoundTrip(new SetReadyPacket { LobbyId = 11, Ready = true }); + packet.LobbyId.ShouldBe(11u); + packet.Ready.ShouldBeTrue(); + } + + [Test] + public void TestGameStarted() { + var packet = RoundTrip(new GameStartedPacket { + LobbyId = 3, Players = [new LobbyPlayerData { PlayerId = 1, Name = "A" }, new LobbyPlayerData { PlayerId = 2, Name = "B" }] + }); + packet.LobbyId.ShouldBe(3u); + packet.Players.Count.ShouldBe(2); + packet.Players[1].Name.ShouldBe("B"); + } + + [Test] + public void TestFraming() { + PacketRegistrar.RegisterAllPackets(); + var packet = new HandshakePacket { Version = "0.1.0" }; + List bytes = []; + PacketProtocol.FramePacket(packet, bytes); + + // [content length][packet id][payload] + var index = 0u; + var buffer = bytes.ToArray(); + var contentLength = UIntSerialization.Read(buffer, ref index); + contentLength.ShouldBe((uint)bytes.Count - 4); + var packetId = UIntSerialization.Read(buffer, ref index); + packetId.ShouldBe(1u); + } +} diff --git a/StdbModule/Exceptions.cs b/StdbModule/Exceptions.cs deleted file mode 100644 index ca17fdde..00000000 --- a/StdbModule/Exceptions.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace OpenPolytopia.Server; - -using System; - -public class UserNotRegisteredException() : Exception("User not registered"); - -public class LobbyNotFoundException() : Exception("Lobby not found"); - -public class LobbyAlreadyStartedException() : Exception("Lobby has already started a game"); - -public class AlreadyJoinedLobbyException() : Exception("You already joined this lobby"); - -public class LobbyFullException() : Exception("This lobby is full"); - -public class NotInLobbyException() : Exception("You must join a lobby first before leaving it"); - -public class ReducerNoPermissionException() : Exception("You don't have permission to run this reducer"); diff --git a/StdbModule/Module.cs b/StdbModule/Module.cs deleted file mode 100644 index d05ca079..00000000 --- a/StdbModule/Module.cs +++ /dev/null @@ -1,291 +0,0 @@ -namespace OpenPolytopia.Server; - -using System.Linq; -using SpacetimeDB; - -public static partial class Module { - /// - /// Defines a player - /// - [Table(Name = "Player", Public = true)] - public partial class Player { - [PrimaryKey] public Identity Id; - public string Name; - public bool Online; - } - - /// - /// Defines a lobby where players can join and start a game - /// - [Table(Name = "Lobby", Public = true)] - public partial class Lobby { - [PrimaryKey] [AutoInc] public ulong Id; - public uint MaxPlayers; - public uint Players; - public uint Ready; - public bool Started; - public bool Starting; - } - - /// - /// Defines a player who joined a lobby - /// - [Table(Name = "LobbyPlayer", Public = true)] - [Index.BTree(Name = "LobbyAndPlayer", Columns = [nameof(LobbyId), nameof(PlayerId)])] - public partial class LobbyPlayer { - [PrimaryKey] [AutoInc] public ulong Id; - public ulong LobbyId; - public Identity PlayerId; - public uint Tribe; - } - - [Table(Name = "StartLobbySchedule", Scheduled = nameof(StartLobby), ScheduledAt = nameof(ScheduledAt))] - public partial class StartLobbySchedule { - [PrimaryKey] [AutoInc] public ulong Id; - public ScheduleAt ScheduledAt; - } - - [Reducer(ReducerKind.Init)] - public static void Init(ReducerContext ctx) => - // add scheduled start lobby to run every 5 seconds - ctx.Db.StartLobbySchedule.Insert(new StartLobbySchedule { - Id = 0, ScheduledAt = new ScheduleAt.Interval(new TimeDuration(5_000_000)) - }); - - [Reducer(ReducerKind.ClientConnected)] - public static void ClientConnected(ReducerContext ctx) { - // get the player - var player = ctx.FindPlayer(); - - // check if he exists in the database - if (player == null) { - throw new UserNotRegisteredException(); - } - - // set the online status - player.Online = true; - - // update the database - ctx.Db.Player.Id.Update(player); - } - - [Reducer(ReducerKind.ClientDisconnected)] - public static void ClientDisconnected(ReducerContext ctx) { - // get the player - var player = ctx.FindPlayer(); - - // check if he exists in the database - if (player == null) { - throw new UserNotRegisteredException(); - } - - // set the online status - player.Online = false; - - // update the database - ctx.Db.Player.Id.Update(player); - } - - [Reducer] - public static void StartLobby(ReducerContext ctx, StartLobbySchedule schedule) { - // check if the reducer was called from the server and not from a client - if (ctx.Sender != ctx.Identity) { - throw new ReducerNoPermissionException(); - } - - // for each lobby that is starting - foreach (var lobby in ctx.Db.Lobby.Iter()) { - if (!lobby.Starting) { - continue; - } - - // TODO: world generation - // TODO: initialize game data - // TODO: add players to the game - - // remove all players from the lobby - ctx.FilterRemoveLobbyPlayer(lobby); - - // remove the lobby - ctx.RemoveLobby(lobby); - } - } - - [Reducer] - public static void SetName(ReducerContext ctx, string name) { - // get the player - var player = ctx.FindPlayer(); - - // check if player exists - if (player != null) { - // set the name - player.Name = name; - - // and update him - ctx.Db.Player.Id.Update(player); - } - else { - // create a new player; we set the online status to true because to make changes to the name you need to be online - player = new Player { Id = ctx.Sender, Name = name, Online = true, }; - - // and add him - ctx.Db.Player.Insert(player); - } - } - - [Reducer] - public static void CreateLobby(ReducerContext ctx, uint maxPlayers, uint tribe) { - // get the player - var player = ctx.FindPlayer(); - - // check if the player exists - if (player == null) { - throw new UserNotRegisteredException(); - } - - // create the lobby - var lobby = ctx.CreateLobby(maxPlayers); - - // set the players in lobby to 1 - lobby.Players++; - - // create the lobby player - ctx.CreateLobbyPlayer(lobby.Id, tribe); - } - - [Reducer] - public static void JoinLobby(ReducerContext ctx, ulong lobbyId, uint tribe) { - // get the player - var player = ctx.FindPlayer(); - - // check if the player exists - if (player == null) { - throw new UserNotRegisteredException(); - } - - // get the lobby - var lobby = ctx.FindLobby(lobbyId); - - // check if lobby exists - if (lobby == null) { - throw new LobbyNotFoundException(); - } - - // check if lobby is still waiting for more players - if (lobby.Starting || lobby.Started) { - throw new LobbyAlreadyStartedException(); - } - - // check if the lobby is full - if (lobby.Players >= lobby.MaxPlayers) { - throw new LobbyFullException(); - } - - // check if player is in lobby - if (ctx.FilterLobbyPlayer(lobbyId).Any(lobbyPlayer => lobbyPlayer.PlayerId == ctx.Sender)) { - throw new AlreadyJoinedLobbyException(); - } - - // increment the players amount - lobby.Players++; - - // update the lobby - ctx.UpdateLobby(lobby); - - // add the player to the lobby - ctx.CreateLobbyPlayer(lobbyId, tribe); - } - - [Reducer] - public static void LeaveLobby(ReducerContext ctx, ulong lobbyId) { - // get the lobby - var lobby = ctx.FindLobby(lobbyId); - - // check if lobby exists - if (lobby == null) { - throw new LobbyNotFoundException(); - } - - // check if lobby is still waiting for more players - if (lobby.Starting || lobby.Started) { - throw new LobbyAlreadyStartedException(); - } - - // get all players in the lobby - var lobbyPlayer = ctx.FilterLobbyPlayer(lobbyId).FirstOrDefault(lobbyPlayer => lobbyPlayer.PlayerId == ctx.Sender); - - // check if the player is in the lobby - if (lobbyPlayer == null) { - throw new NotInLobbyException(); - } - - // decrement the players amount - lobby.Players--; - - // update lobby - ctx.UpdateLobby(lobby); - - // remove the player from the lobby - ctx.RemoveLobbyPlayer(lobbyPlayer); - } - - [Reducer] - public static void AddReady(ReducerContext ctx, ulong lobbyId) { - // get the lobby - var lobby = ctx.FindLobby(lobbyId); - - // check if lobby exists - if (lobby == null) { - throw new LobbyNotFoundException(); - } - - // check if lobby is still waiting for more players - if (lobby.Starting || lobby.Started) { - throw new LobbyAlreadyStartedException(); - } - - // check if the player is in the lobby - if (ctx.FilterLobbyPlayer(lobbyId).All(lobbyPlayer => lobbyPlayer.PlayerId != ctx.Sender)) { - throw new NotInLobbyException(); - } - - // increment the number of players ready - lobby.Ready++; - - // check if all players are ready - if (lobby.Ready == lobby.Players) { - // start the game - lobby.Starting = true; - } - - // update the lobby - ctx.UpdateLobby(lobby); - } - - [Reducer] - public static void RemoveReady(ReducerContext ctx, ulong lobbyId) { - // get the lobby - var lobby = ctx.FindLobby(lobbyId); - - // check if lobby exists - if (lobby == null) { - throw new LobbyNotFoundException(); - } - - // check if lobby is still waiting for more players - if (lobby.Starting || lobby.Started) { - throw new LobbyAlreadyStartedException(); - } - - // check if the player is in the lobby - if (ctx.FilterLobbyPlayer(lobbyId).All(lobbyPlayer => lobbyPlayer.PlayerId != ctx.Sender)) { - throw new NotInLobbyException(); - } - - // decrement the number of players ready - lobby.Ready--; - - // update the lobby - ctx.UpdateLobby(lobby); - } -} diff --git a/StdbModule/ReducerContextExtensions.cs b/StdbModule/ReducerContextExtensions.cs deleted file mode 100644 index 3a0e495f..00000000 --- a/StdbModule/ReducerContextExtensions.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace OpenPolytopia.Server; - -using SpacetimeDB; - -public static class ReducerContextExtensions { - public static Module.Player? FindPlayer(this ReducerContext ctx, Identity? id = null) => - ctx.Db.Player.Id.Find(id ?? ctx.Sender); - - public static Module.Lobby? FindLobby(this ReducerContext ctx, ulong id) => ctx.Db.Lobby.Id.Find(id); - - public static Module.LobbyPlayer? FindLobbyPlayer(this ReducerContext ctx, ulong id) => - ctx.Db.LobbyPlayer.Id.Find(id); - - public static IEnumerable FilterLobbyPlayer(this ReducerContext ctx, - ulong lobbyId, - Identity? playerId = null) => - playerId == null - ? ctx.Db.LobbyPlayer.LobbyAndPlayer.Filter(lobbyId) - : ctx.Db.LobbyPlayer.LobbyAndPlayer.Filter((lobbyId, playerId.Value)); - - public static Module.Lobby CreateLobby(this ReducerContext ctx, uint maxPlayers) => - ctx.Db.Lobby.Insert(new Module.Lobby { - Id = 0, - MaxPlayers = maxPlayers, - Started = false, - Starting = false, - Players = 0, - Ready = 0 - }); - - public static Module.Lobby UpdateLobby(this ReducerContext ctx, Module.Lobby lobby) => ctx.Db.Lobby.Id.Update(lobby); - - public static void RemoveLobby(this ReducerContext ctx, Module.Lobby lobby) => ctx.Db.Lobby.Id.Delete(lobby.Id); - - public static Module.LobbyPlayer CreateLobbyPlayer(this ReducerContext ctx, ulong lobbyId, uint tribe, - Identity? id = null) => - ctx.Db.LobbyPlayer.Insert(new Module.LobbyPlayer { - Id = 0, LobbyId = lobbyId, Tribe = tribe, PlayerId = id ?? ctx.Sender - }); - - public static void RemoveLobbyPlayer(this ReducerContext ctx, Module.LobbyPlayer lobbyPlayer) => - ctx.Db.LobbyPlayer.Id.Delete(lobbyPlayer.Id); - - public static void FilterRemoveLobbyPlayer(this ReducerContext ctx, Module.Lobby lobby) => - ctx.Db.LobbyPlayer.LobbyAndPlayer.Delete(lobby.Id); -} diff --git a/cspell.json b/cspell.json index ee429a1b..ef813f07 100644 --- a/cspell.json +++ b/cspell.json @@ -100,10 +100,7 @@ "Oumaji", "smithery", "aquatism", - "stdb", "openpolytopia", - "DBNAME", - "SPACETIMEDB", "XDEV" ] } From 1aca06876ad3793a2e3224ff48f3a8bc24e9e9d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 13:20:42 +0000 Subject: [PATCH 02/11] Make server ip and port configurable Server: `OpenPolytopia.Server [port] [bind-address]`, falling back to the OPENPOLYTOPIA_PORT/OPENPOLYTOPIA_BIND_ADDRESS environment variables, then to port 6969 on every interface. ServerConnection now accepts an optional bind address. Client: the host and port NetworkNode connects to are resolved from, in order of precedence: `--server-host=`/`--server-port=` command line user args, OPENPOLYTOPIA_SERVER_HOST/OPENPOLYTOPIA_SERVER_PORT environment variables, the open_polytopia/network/server_host and server_port project settings (editable in the Godot editor), and finally the built-in defaults. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX --- .../Network/ServerConnection.cs | 8 ++- OpenPolytopia.Server/GameServer.cs | 8 ++- OpenPolytopia.Server/Program.cs | 29 +++++++- OpenPolytopia/project.godot | 5 ++ OpenPolytopia/src/NetworkNode.cs | 70 ++++++++++++++++++- 5 files changed, 109 insertions(+), 11 deletions(-) diff --git a/OpenPolytopia.Common/Network/ServerConnection.cs b/OpenPolytopia.Common/Network/ServerConnection.cs index 031958f0..8cb9311a 100644 --- a/OpenPolytopia.Common/Network/ServerConnection.cs +++ b/OpenPolytopia.Common/Network/ServerConnection.cs @@ -12,11 +12,15 @@ namespace OpenPolytopia.Common.Network; /// a to every client and disconnects the ones that /// didn't send anything back for longer than /// -public class ServerConnection(int port) : IDisposable { +/// 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 readonly TcpListener _listener = TcpListener.Create(port); + private readonly TcpListener _listener = bindAddress == null + ? TcpListener.Create(port) + : new TcpListener(System.Net.IPAddress.Parse(bindAddress), port); private readonly ConcurrentDictionary _connections = new(); private readonly CancellationTokenSource _cts = new(); private uint _nextId; diff --git a/OpenPolytopia.Server/GameServer.cs b/OpenPolytopia.Server/GameServer.cs index e0389cc7..d50dd33c 100644 --- a/OpenPolytopia.Server/GameServer.cs +++ b/OpenPolytopia.Server/GameServer.cs @@ -7,13 +7,15 @@ namespace OpenPolytopia.Server; /// /// The game server: manages players, lobbies and starting games on top of a /// -public class GameServer(int port) : IDisposable { +/// 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); - private readonly ServerConnection _server = new(port); + private readonly ServerConnection _server = new(port, bindAddress); private readonly LobbyManager _lobbyManager = new(); private readonly Dictionary _playerNames = new(); @@ -32,7 +34,7 @@ public async Task RunAsync() { // check for lobbies to start in background _ = StartLobbiesLoopAsync(_cts.Token); - Console.WriteLine($"Server listening on port {port}"); + Console.WriteLine($"Server listening on {bindAddress ?? "*"}:{port}"); await _server.RunAsync(); } diff --git a/OpenPolytopia.Server/Program.cs b/OpenPolytopia.Server/Program.cs index f7fd5c70..d309b7a5 100644 --- a/OpenPolytopia.Server/Program.cs +++ b/OpenPolytopia.Server/Program.cs @@ -3,14 +3,37 @@ 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"; + + /// + /// 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 (args.Length > 0 && !int.TryParse(args[0], out port)) { - Console.Error.WriteLine($"Invalid port: {args[0]}"); + if (!string.IsNullOrWhiteSpace(portValue) && !int.TryParse(portValue, out port)) { + Console.Error.WriteLine($"Invalid port: {portValue}"); Environment.Exit(1); } - using var gameServer = new GameServer(port); + 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) => { diff --git a/OpenPolytopia/project.godot b/OpenPolytopia/project.godot index fa73a611..55ad3ec7 100644 --- a/OpenPolytopia/project.godot +++ b/OpenPolytopia/project.godot @@ -20,6 +20,11 @@ config/icon="res://icon.png" NetworkNode="*res://src/NetworkNode.cs" +[open_polytopia] + +network/server_host="enn3.ovh" +network/server_port=6969 + [debug] settings/stdout/print_fps=true diff --git a/OpenPolytopia/src/NetworkNode.cs b/OpenPolytopia/src/NetworkNode.cs index 3c5cc314..9db502f2 100644 --- a/OpenPolytopia/src/NetworkNode.cs +++ b/OpenPolytopia/src/NetworkNode.cs @@ -21,11 +21,31 @@ public partial class NetworkNode : Node { /// public static NetworkNode Instance { get; private set; } = null!; - private const string HOST = "enn3.ovh"; - private const int PORT = NetworkConstants.DEFAULT_PORT; + private const string DEFAULT_HOST = "enn3.ovh"; + + // project settings editable in the editor, see [open_polytopia] in project.godot + private const string HOST_SETTING = "open_polytopia/network/server_host"; + private const string PORT_SETTING = "open_polytopia/network/server_port"; + + // overrides usable without touching the project: + // OPENPOLYTOPIA_SERVER_HOST=... or `godot -- --server-host=...` (same for port) + private const string HOST_ENV = "OPENPOLYTOPIA_SERVER_HOST"; + private const string PORT_ENV = "OPENPOLYTOPIA_SERVER_PORT"; + private const string HOST_ARG = "--server-host"; + private const string PORT_ARG = "--server-port"; private ClientConnection? _connection; + /// + /// Host the client connects to + /// + public string Host { get; private set; } = DEFAULT_HOST; + + /// + /// Port the client connects to + /// + public int Port { get; private set; } = NetworkConstants.DEFAULT_PORT; + /// /// Id assigned to this client by the server; valid after /// @@ -87,9 +107,53 @@ public partial class NetworkNode : Node { public override void _EnterTree() { base._EnterTree(); Instance = this; + ResolveServerAddress(); _ = ConnectToServerAsync(); } + /// + /// Resolves the server host and port to connect to. + /// Precedence: command line argument, environment variable, project setting, default + /// + private void ResolveServerAddress() { + var host = GetUserArg(HOST_ARG) ?? EnvOrNull(HOST_ENV) ?? + SettingOrNull(HOST_SETTING)?.AsString(); + if (!string.IsNullOrWhiteSpace(host)) { + Host = host; + } + + var portValue = GetUserArg(PORT_ARG) ?? EnvOrNull(PORT_ENV); + if (portValue == null && SettingOrNull(PORT_SETTING) is { } portSetting) { + Port = portSetting.AsInt32(); + } + else if (portValue != null) { + if (int.TryParse(portValue, out var port) && port is > 0 and <= ushort.MaxValue) { + Port = port; + } + else { + GD.PushError($"Invalid server port: {portValue}, using {Port}"); + } + } + } + + /// + /// Returns the value of a --name=value user argument (the ones after --) or null + /// + /// the argument name, including the leading dashes + private static string? GetUserArg(string name) => + OS.GetCmdlineUserArgs() + .Where(arg => arg.StartsWith($"{name}=", StringComparison.Ordinal)) + .Select(arg => arg[(name.Length + 1)..]) + .FirstOrDefault(); + + private static string? EnvOrNull(string name) { + var value = OS.GetEnvironment(name); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static Variant? SettingOrNull(string name) => + ProjectSettings.HasSetting(name) ? ProjectSettings.GetSetting(name) : (Variant?)null; + /// /// Disconnect from the server /// @@ -154,7 +218,7 @@ public void CreateLobby(uint maxPlayers, uint tribe) => private async Task ConnectToServerAsync() { try { - _connection = new ClientConnection(HOST, PORT); + _connection = new ClientConnection(Host, Port); _connection.OnDisconnected += () => { Connected = false; OnDisconnected?.Invoke(); From 4ce5b1d05dce34ff48cbb3691a369c54959f2c00 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 13:28:42 +0000 Subject: [PATCH 03/11] Make NetworkNode a custom node instead of an autoload NetworkNode is now a [GlobalClass] node that can be added to any scene from the editor, with Host, Port and AutoConnect exported to the inspector (command line and environment overrides still take precedence). The underlying connection and the session state (player id, handshake state, lobbies) are shared between all instances and survive scene changes: the first node entering the tree connects and the following ones reuse the connection, with only the most recent instance pumping received packets. Game.tscn and Lobby.tscn now embed a NetworkNode; the autoload and the open_polytopia project settings section are removed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX --- OpenPolytopia/project.godot | 9 -- OpenPolytopia/src/Game.tscn | 6 +- OpenPolytopia/src/Lobby.tscn | 6 +- OpenPolytopia/src/NetworkNode.cs | 220 ++++++++++++++++----------- OpenPolytopia/src/NetworkNode.cs.uid | 1 + 5 files changed, 143 insertions(+), 99 deletions(-) create mode 100644 OpenPolytopia/src/NetworkNode.cs.uid diff --git a/OpenPolytopia/project.godot b/OpenPolytopia/project.godot index 55ad3ec7..1d0b26c0 100644 --- a/OpenPolytopia/project.godot +++ b/OpenPolytopia/project.godot @@ -16,15 +16,6 @@ run/main_scene="res://src/Main.tscn" config/features=PackedStringArray("4.5", "C#", "Mobile") config/icon="res://icon.png" -[autoload] - -NetworkNode="*res://src/NetworkNode.cs" - -[open_polytopia] - -network/server_host="enn3.ovh" -network/server_port=6969 - [debug] settings/stdout/print_fps=true diff --git a/OpenPolytopia/src/Game.tscn b/OpenPolytopia/src/Game.tscn index 924c4e78..ee77d3db 100644 --- a/OpenPolytopia/src/Game.tscn +++ b/OpenPolytopia/src/Game.tscn @@ -1,7 +1,8 @@ -[gd_scene load_steps=3 format=3 uid="uid://cywpu6lxdjhuu"] +[gd_scene load_steps=4 format=3 uid="uid://cywpu6lxdjhuu"] [ext_resource type="Script" uid="uid://bnycq21rdq54b" path="res://src/Game.cs" id="1_17mmo"] [ext_resource type="PackedScene" uid="uid://badtuu46ibki5" path="res://src/Lobby.tscn" id="2_lobby"] +[ext_resource type="Script" uid="uid://dcn2kq8wfxg0v" path="res://src/NetworkNode.cs" id="3_netnode"] [node name="Control" type="Control"] layout_mode = 3 @@ -47,5 +48,8 @@ layout_mode = 2 theme_override_font_sizes/font_size = 32 text = "Play" +[node name="NetworkNode" type="Node" parent="."] +script = ExtResource("3_netnode") + [connection signal="text_changed" from="CenterContainer/VBoxContainer/LineEdit" to="." method="OnNameChanged"] [connection signal="pressed" from="CenterContainer/VBoxContainer/Button" to="." method="OnPlayPressed"] diff --git a/OpenPolytopia/src/Lobby.tscn b/OpenPolytopia/src/Lobby.tscn index 8ea226cd..fa11546d 100644 --- a/OpenPolytopia/src/Lobby.tscn +++ b/OpenPolytopia/src/Lobby.tscn @@ -1,6 +1,7 @@ -[gd_scene load_steps=2 format=3 uid="uid://badtuu46ibki5"] +[gd_scene load_steps=3 format=3 uid="uid://badtuu46ibki5"] [ext_resource type="Script" uid="uid://5y8x70doldk0" path="res://src/Lobby.cs" id="1_ciqg2"] +[ext_resource type="Script" uid="uid://dcn2kq8wfxg0v" path="res://src/NetworkNode.cs" id="2_netnode"] [node name="Lobby" type="Control"] layout_mode = 3 @@ -10,3 +11,6 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 script = ExtResource("1_ciqg2") + +[node name="NetworkNode" type="Node" parent="."] +script = ExtResource("2_netnode") diff --git a/OpenPolytopia/src/NetworkNode.cs b/OpenPolytopia/src/NetworkNode.cs index 9db502f2..1c9b7472 100644 --- a/OpenPolytopia/src/NetworkNode.cs +++ b/OpenPolytopia/src/NetworkNode.cs @@ -3,6 +3,7 @@ namespace OpenPolytopia; using System; using System.Collections.ObjectModel; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Common; using Common.Network; @@ -12,54 +13,72 @@ namespace OpenPolytopia; /// /// Manages the connection with the game server. ///
+/// Add this node to every scene that talks to the server: the underlying connection is shared +/// between all the instances and survives scene changes, so the first node to enter the tree +/// connects and the following ones reuse the same connection. +///
/// Packets are received in background and processed on the main thread /// in , so every event is safe to use with Godot nodes ///
+[GlobalClass] public partial class NetworkNode : Node { /// - /// Public singleton + /// The instance currently pumping the connection, i.e. the last one that entered the tree /// public static NetworkNode Instance { get; private set; } = null!; - private const string DEFAULT_HOST = "enn3.ovh"; - - // project settings editable in the editor, see [open_polytopia] in project.godot - private const string HOST_SETTING = "open_polytopia/network/server_host"; - private const string PORT_SETTING = "open_polytopia/network/server_port"; - - // overrides usable without touching the project: + // overrides usable without touching the scene: // OPENPOLYTOPIA_SERVER_HOST=... or `godot -- --server-host=...` (same for port) private const string HOST_ENV = "OPENPOLYTOPIA_SERVER_HOST"; private const string PORT_ENV = "OPENPOLYTOPIA_SERVER_PORT"; private const string HOST_ARG = "--server-host"; private const string PORT_ARG = "--server-port"; - private ClientConnection? _connection; + // the connection and the session state are shared between all NetworkNode instances + // so they survive scene changes; only app shutdown or Disconnect() close the connection + private static ClientConnection? _connection; + private static bool _handshakeDone; + private static uint _playerId; + private static readonly ObservableCollection _lobbies = []; + private static int _disconnectedFlag; + + /// + /// Host to connect to; editable in the inspector. + /// Overridable with the --server-host= command line argument + /// or the OPENPOLYTOPIA_SERVER_HOST environment variable + /// + [Export] + public string Host { get; set; } = "enn3.ovh"; /// - /// Host the client connects to + /// Port to connect to; editable in the inspector. + /// Overridable with the --server-port= command line argument + /// or the OPENPOLYTOPIA_SERVER_PORT environment variable /// - public string Host { get; private set; } = DEFAULT_HOST; + [Export(PropertyHint.Range, "1,65535")] + public int Port { get; set; } = NetworkConstants.DEFAULT_PORT; /// - /// Port the client connects to + /// If true, the node connects to the server as soon as it enters the tree + /// (unless a shared connection already exists) /// - public int Port { get; private set; } = NetworkConstants.DEFAULT_PORT; + [Export] + public bool AutoConnect { get; set; } = true; /// /// Id assigned to this client by the server; valid after /// - public uint PlayerId { get; private set; } + public uint PlayerId => _playerId; /// /// true after a successful handshake with the server /// - public bool Connected { get; private set; } + public bool Connected => _handshakeDone; /// /// Lobbies data; it updates when the server broadcasts lobby changes /// - public readonly ObservableCollection Lobbies = []; + public ObservableCollection Lobbies => _lobbies; /// /// Fired after a successful handshake @@ -102,79 +121,63 @@ public partial class NetworkNode : Node { public event Action? OnGameStarted; /// - /// Initialize the connection + /// Take over the pumping of the shared connection and connect if needed /// public override void _EnterTree() { base._EnterTree(); Instance = this; - ResolveServerAddress(); - _ = ConnectToServerAsync(); + + if (AutoConnect && _connection == null) { + ConnectToServer(); + } } /// - /// Resolves the server host and port to connect to. - /// Precedence: command line argument, environment variable, project setting, default + /// Process messages received from the server /// - private void ResolveServerAddress() { - var host = GetUserArg(HOST_ARG) ?? EnvOrNull(HOST_ENV) ?? - SettingOrNull(HOST_SETTING)?.AsString(); - if (!string.IsNullOrWhiteSpace(host)) { - Host = host; + /// ignored + public override void _PhysicsProcess(double delta) { + // only the active instance pumps the shared connection + if (Instance != this || _connection == null) { + return; } - var portValue = GetUserArg(PORT_ARG) ?? EnvOrNull(PORT_ENV); - if (portValue == null && SettingOrNull(PORT_SETTING) is { } portSetting) { - Port = portSetting.AsInt32(); + while (_connection.IncomingPackets.TryDequeue(out var packet)) { + ProcessPacket(packet); } - else if (portValue != null) { - if (int.TryParse(portValue, out var port) && port is > 0 and <= ushort.MaxValue) { - Port = port; - } - else { - GD.PushError($"Invalid server port: {portValue}, using {Port}"); - } + + // manage a disconnection signalled by the background read task + if (Interlocked.Exchange(ref _disconnectedFlag, 0) == 1) { + _handshakeDone = false; + _connection.Dispose(); + _connection = null; + _lobbies.Clear(); + OnDisconnected?.Invoke(); } } /// - /// Returns the value of a --name=value user argument (the ones after --) or null + /// Connects to the server; called automatically when entering the tree if + /// is enabled. + /// Does nothing if a shared connection already exists /// - /// the argument name, including the leading dashes - private static string? GetUserArg(string name) => - OS.GetCmdlineUserArgs() - .Where(arg => arg.StartsWith($"{name}=", StringComparison.Ordinal)) - .Select(arg => arg[(name.Length + 1)..]) - .FirstOrDefault(); + public void ConnectToServer() { + if (_connection != null) { + return; + } - private static string? EnvOrNull(string name) { - var value = OS.GetEnvironment(name); - return string.IsNullOrWhiteSpace(value) ? null : value; + var (host, port) = ResolveServerAddress(); + _ = ConnectToServerAsync(host, port); } - private static Variant? SettingOrNull(string name) => - ProjectSettings.HasSetting(name) ? ProjectSettings.GetSetting(name) : (Variant?)null; - /// - /// Disconnect from the server + /// Closes the shared connection /// - public override void _ExitTree() { - base._ExitTree(); + public void Disconnect() { _connection?.Dispose(); _connection = null; - } - - /// - /// Process messages received from the server - /// - /// ignored - public override void _PhysicsProcess(double delta) { - if (_connection == null) { - return; - } - - while (_connection.IncomingPackets.TryDequeue(out var packet)) { - ProcessPacket(packet); - } + _handshakeDone = false; + _lobbies.Clear(); } /// @@ -216,18 +219,59 @@ public void CreateLobby(uint maxPlayers, uint tribe) => /// the new ready state public void SetReady(ulong lobbyId, bool ready) => Send(new SetReadyPacket { LobbyId = lobbyId, Ready = ready }); - private async Task ConnectToServerAsync() { + /// + /// Resolves the server host and port to connect to. + /// Precedence: command line argument, environment variable, exported property + /// + private (string, int) ResolveServerAddress() { + var host = GetUserArg(HOST_ARG) ?? EnvOrNull(HOST_ENV) ?? Host; + + var port = Port; + var portValue = GetUserArg(PORT_ARG) ?? EnvOrNull(PORT_ENV); + if (portValue != null) { + if (int.TryParse(portValue, out var parsed) && parsed is > 0 and <= ushort.MaxValue) { + port = parsed; + } + else { + GD.PushError($"Invalid server port: {portValue}, using {port}"); + } + } + + return (host, port); + } + + /// + /// Returns the value of a --name=value user argument (the ones after --) or null + /// + /// the argument name, including the leading dashes + private static string? GetUserArg(string name) => + OS.GetCmdlineUserArgs() + .Where(arg => arg.StartsWith($"{name}=", StringComparison.Ordinal)) + .Select(arg => arg[(name.Length + 1)..]) + .FirstOrDefault(); + + private static string? EnvOrNull(string name) { + var value = OS.GetEnvironment(name); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static async Task ConnectToServerAsync(string host, int port) { + var connection = new ClientConnection(host, port); try { - _connection = new ClientConnection(Host, Port); - _connection.OnDisconnected += () => { - Connected = false; - OnDisconnected?.Invoke(); - }; - await _connection.ConnectAsync(); - await _connection.SendPacketAsync(new HandshakePacket { Version = NetworkConstants.VERSION }); + _connection = connection; + connection.OnDisconnected += () => Interlocked.Exchange(ref _disconnectedFlag, 1); + await connection.ConnectAsync(); + await connection.SendPacketAsync(new HandshakePacket { Version = NetworkConstants.VERSION }); } catch (Exception e) { - GD.PushError($"Error while connecting: {e}"); + GD.PushError($"Error while connecting to {host}:{port}: {e}"); + + // throw the connection away so a new node (or a manual call) can retry + if (_connection == connection) { + _connection = null; + } + + connection.Dispose(); } } @@ -237,12 +281,12 @@ private void Send(IPacket packet) { return; } - _ = SendAsync(packet); + _ = SendAsync(_connection, packet); } - private async Task SendAsync(IPacket packet) { + private static async Task SendAsync(ClientConnection connection, IPacket packet) { try { - await _connection!.SendPacketAsync(packet); + await connection.SendPacketAsync(packet); } catch (Exception e) { GD.PushError($"Error while sending packet: {e}"); @@ -254,12 +298,12 @@ private void ProcessPacket(IPacket packet) { case HandshakeResponsePacket handshakeResponse: if (!handshakeResponse.Ok) { GD.PushError("Server refused the connection: incompatible version"); - _connection?.Disconnect(); + Disconnect(); break; } - PlayerId = handshakeResponse.PlayerId; - Connected = true; + _playerId = handshakeResponse.PlayerId; + _handshakeDone = true; OnConnected?.Invoke(); // get the initial lobby list @@ -269,9 +313,9 @@ private void ProcessPacket(IPacket packet) { OnNameSet?.Invoke(setNameResponse.Ok); break; case GetLobbiesResponsePacket lobbiesResponse: - Lobbies.Clear(); + _lobbies.Clear(); foreach (var lobby in lobbiesResponse.Lobbies) { - Lobbies.Add(lobby); + _lobbies.Add(lobby); } break; @@ -289,18 +333,18 @@ private void ProcessPacket(IPacket packet) { break; case LobbyUpdatedPacket lobbyUpdated: // remove the old lobby data if present - var oldLobby = Lobbies.FirstOrDefault(lobby => lobby.Id == lobbyUpdated.Lobby.Id); + var oldLobby = _lobbies.FirstOrDefault(lobby => lobby.Id == lobbyUpdated.Lobby.Id); if (oldLobby != null) { - Lobbies.Remove(oldLobby); + _lobbies.Remove(oldLobby); } // add back the lobby data to force the list to emit the event - Lobbies.Add(lobbyUpdated.Lobby); + _lobbies.Add(lobbyUpdated.Lobby); break; case LobbyDeletedPacket lobbyDeleted: - var deletedLobby = Lobbies.FirstOrDefault(lobby => lobby.Id == lobbyDeleted.LobbyId); + var deletedLobby = _lobbies.FirstOrDefault(lobby => lobby.Id == lobbyDeleted.LobbyId); if (deletedLobby != null) { - Lobbies.Remove(deletedLobby); + _lobbies.Remove(deletedLobby); } break; diff --git a/OpenPolytopia/src/NetworkNode.cs.uid b/OpenPolytopia/src/NetworkNode.cs.uid new file mode 100644 index 00000000..3c183462 --- /dev/null +++ b/OpenPolytopia/src/NetworkNode.cs.uid @@ -0,0 +1 @@ +uid://dcn2kq8wfxg0v From dbe778f2a46932c250a97f11c06b14974c4e2ab2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 17:51:06 +0000 Subject: [PATCH 04/11] Match doc comment style with the rest of the codebase One-line summaries, side notes moved to remarks tags Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX --- .../Network/ClientConnection.cs | 10 +-- .../Network/NetworkConnection.cs | 26 ++++--- .../Network/NetworkConstants.cs | 7 +- .../Network/NetworkSerialization.cs | 5 +- .../Network/PacketProtocol.cs | 18 ++--- .../Network/PacketRegistrar.cs | 5 +- .../Network/Packets/HandshakePacket.cs | 5 +- .../Network/Packets/IPacket.cs | 11 +-- .../Network/Packets/KeepAlivePacket.cs | 6 +- .../Network/Packets/LobbyPackets.cs | 16 +++-- .../Network/ServerConnection.cs | 25 ++++--- OpenPolytopia.Server/GameServer.cs | 4 +- OpenPolytopia.Server/LobbyManager.cs | 22 +++--- OpenPolytopia.Server/Program.cs | 10 +-- OpenPolytopia/src/Lobby.cs | 6 +- OpenPolytopia/src/NetworkNode.cs | 68 +++++++++++-------- 16 files changed, 151 insertions(+), 93 deletions(-) diff --git a/OpenPolytopia.Common/Network/ClientConnection.cs b/OpenPolytopia.Common/Network/ClientConnection.cs index 2851b887..b0152435 100644 --- a/OpenPolytopia.Common/Network/ClientConnection.cs +++ b/OpenPolytopia.Common/Network/ClientConnection.cs @@ -5,12 +5,12 @@ namespace OpenPolytopia.Common.Network; using Packets; /// -/// Client-side connection to the game server. -///
-/// Received packets are queued in so the consumer -/// (e.g. a Godot node) can process them on its own thread by calling a drain loop -/// every frame; s are answered automatically +/// 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 +/// public class ClientConnection(string address, int port) : IDisposable { private readonly TcpClient _client = new(); private readonly CancellationTokenSource _cts = new(); diff --git a/OpenPolytopia.Common/Network/NetworkConnection.cs b/OpenPolytopia.Common/Network/NetworkConnection.cs index 5ac15cf7..aa80da04 100644 --- a/OpenPolytopia.Common/Network/NetworkConnection.cs +++ b/OpenPolytopia.Common/Network/NetworkConnection.cs @@ -5,10 +5,12 @@ namespace OpenPolytopia.Common.Network; using Packets; /// -/// Wraps a connected and provides framed packet send/receive on top of it. -/// Used both by the client (a single connection to the server) and -/// by the server (one connection per client). +/// 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); @@ -40,8 +42,11 @@ public class NetworkConnection(uint id, TcpClient client) : IDisposable { public bool Connected => !_closed && client.Connected; /// - /// Sends a single packet; thread-safe + /// 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) { @@ -58,10 +63,12 @@ public async Task SendPacketAsync(IPacket packet, CancellationToken ct = default } /// - /// Reads packets from the connection until it gets closed or the token gets cancelled, - /// firing for each one. - /// Always fires at the end + /// 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 { @@ -90,8 +97,11 @@ public async Task RunAsync(CancellationToken ct = default) { } /// - /// Closes the connection; it is safe to call this multiple times + /// Closes the connection /// + /// + /// Calling this multiple times is safe + /// public void Close() { if (_closed) { return; diff --git a/OpenPolytopia.Common/Network/NetworkConstants.cs b/OpenPolytopia.Common/Network/NetworkConstants.cs index fc7ca75c..367415cd 100644 --- a/OpenPolytopia.Common/Network/NetworkConstants.cs +++ b/OpenPolytopia.Common/Network/NetworkConstants.cs @@ -2,8 +2,11 @@ namespace OpenPolytopia.Common.Network; public static class NetworkConstants { /// - /// Protocol version; client and server must match to complete the handshake + /// Version of the network protocol /// + /// + /// The handshake fails if client and server have different versions + /// public const string VERSION = "0.1.0"; /// @@ -12,7 +15,7 @@ public static class NetworkConstants { public const int DEFAULT_PORT = 6969; /// - /// Maximum allowed size in bytes of a single packet (id + payload) + /// 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 index 3f81a650..3febd567 100644 --- a/OpenPolytopia.Common/Network/NetworkSerialization.cs +++ b/OpenPolytopia.Common/Network/NetworkSerialization.cs @@ -2,9 +2,8 @@ namespace OpenPolytopia.Common.Network; using System.Text; -// Primitive (de)serialization extensions. -// Everything is written in network byte order (big-endian). -// Every Deserialize increments the index by the number of bytes it consumed. +// 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()); diff --git a/OpenPolytopia.Common/Network/PacketProtocol.cs b/OpenPolytopia.Common/Network/PacketProtocol.cs index 7799f586..e2ad9950 100644 --- a/OpenPolytopia.Common/Network/PacketProtocol.cs +++ b/OpenPolytopia.Common/Network/PacketProtocol.cs @@ -9,12 +9,13 @@ namespace OpenPolytopia.Common.Network; public class ProtocolViolationException(string message) : Exception(message); /// -/// Implements the wire format of the protocol. -///
+/// 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. -///
+/// 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 @@ -47,9 +48,11 @@ public static async Task WritePacketAsync(Stream stream, IPacket packet, Cancell } /// - /// Reads exactly one packet from the stream. - /// Blocks until a full packet is available or the stream gets closed + /// 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 @@ -76,8 +79,7 @@ public static async Task WritePacketAsync(Stream stream, IPacket packet, Cancell var packetId = UIntSerialization.Read(content, ref index); var packet = PacketRegistrar.CreatePacket(packetId); - // unknown packets are skipped instead of closing the connection - // so older clients can talk to newer servers + // skip unknown packets so older clients can talk to newer servers if (packet == null) { return null; } diff --git a/OpenPolytopia.Common/Network/PacketRegistrar.cs b/OpenPolytopia.Common/Network/PacketRegistrar.cs index 3db60beb..ba0e6994 100644 --- a/OpenPolytopia.Common/Network/PacketRegistrar.cs +++ b/OpenPolytopia.Common/Network/PacketRegistrar.cs @@ -34,8 +34,11 @@ public static class PacketRegistrar { public static uint GetPacketId(IPacket packet) => _packetIds[packet.GetType()]; /// - /// Register all known packets; it is safe to call this multiple times + /// Registers all the packets of the protocol /// + /// + /// Calling this multiple times is safe + /// public static void RegisterAllPackets() { lock (_lock) { if (_registered) { diff --git a/OpenPolytopia.Common/Network/Packets/HandshakePacket.cs b/OpenPolytopia.Common/Network/Packets/HandshakePacket.cs index aee2ddf9..a0cdcada 100644 --- a/OpenPolytopia.Common/Network/Packets/HandshakePacket.cs +++ b/OpenPolytopia.Common/Network/Packets/HandshakePacket.cs @@ -24,8 +24,11 @@ public class HandshakeResponsePacket : IPacket { public bool Ok; /// - /// Id assigned to the client by the server; valid only if is true + /// Id assigned to the client by the server /// + /// + /// Valid only if is true + /// public uint PlayerId; public void Serialize(List bytes) { diff --git a/OpenPolytopia.Common/Network/Packets/IPacket.cs b/OpenPolytopia.Common/Network/Packets/IPacket.cs index 5bff9b35..c5ae32ce 100644 --- a/OpenPolytopia.Common/Network/Packets/IPacket.cs +++ b/OpenPolytopia.Common/Network/Packets/IPacket.cs @@ -1,11 +1,12 @@ namespace OpenPolytopia.Common.Network.Packets; /// -/// Interface to declare a packet. -///
+/// 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 type must be registered in with a unique id -/// and must have a parameterless constructor. -///
+/// 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 index a9bae053..c786059b 100644 --- a/OpenPolytopia.Common/Network/Packets/KeepAlivePacket.cs +++ b/OpenPolytopia.Common/Network/Packets/KeepAlivePacket.cs @@ -1,9 +1,11 @@ namespace OpenPolytopia.Common.Network.Packets; /// -/// Sent periodically by the server; the client must echo it back. -/// If the server doesn't receive it back in time, it closes the connection. +/// Sent periodically by the server to check if a client is still alive /// +/// +/// The client must echo it back, otherwise the server closes the connection +/// public class KeepAlivePacket : IPacket { /// /// Random value that must be echoed back untouched diff --git a/OpenPolytopia.Common/Network/Packets/LobbyPackets.cs b/OpenPolytopia.Common/Network/Packets/LobbyPackets.cs index 5c08db7a..37620ea6 100644 --- a/OpenPolytopia.Common/Network/Packets/LobbyPackets.cs +++ b/OpenPolytopia.Common/Network/Packets/LobbyPackets.cs @@ -24,8 +24,11 @@ public class GetLobbiesResponsePacket : IPacket { } /// -/// Creates a new lobby; the sender automatically joins it +/// Creates a new lobby /// +/// +/// The sender automatically joins the new lobby +/// public class CreateLobbyPacket : IPacket { /// /// Number of max players that can join the lobby @@ -58,8 +61,11 @@ public class CreateLobbyResponsePacket : IPacket { public LobbyActionResult Result; /// - /// Id of the newly created lobby; valid only if is + /// Id of the newly created lobby /// + /// + /// Valid only if is + /// public ulong LobbyId; public void Serialize(List bytes) { @@ -163,9 +169,11 @@ public void Deserialize(byte[] bytes, ref uint index) { } /// -/// Marks the player as ready (or not ready) in a lobby. -/// When all the players in a lobby are ready, the game starts +/// 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 diff --git a/OpenPolytopia.Common/Network/ServerConnection.cs b/OpenPolytopia.Common/Network/ServerConnection.cs index 8cb9311a..c1ac7203 100644 --- a/OpenPolytopia.Common/Network/ServerConnection.cs +++ b/OpenPolytopia.Common/Network/ServerConnection.cs @@ -6,12 +6,12 @@ namespace OpenPolytopia.Common.Network; using Packets; /// -/// Accepts TCP connections and manages one per client. -///
-/// It also takes care of the keep alive logic: every it sends -/// a to every client and disconnects the ones that -/// didn't send anything back for longer than +/// Accepts TCP connections and manages one per client ///
+/// +/// Every it sends a to every client +/// and disconnects the ones that didn't send anything back for longer than +/// /// 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 { @@ -31,8 +31,11 @@ public class ServerConnection(int port, string? bindAddress = null) : IDisposabl public IReadOnlyDictionary Connections => _connections; /// - /// Fired when a new client connects, before any packet is received from it + /// Fired when a new client connects /// + /// + /// Fired before any packet is received from the client + /// public event Action? OnClientConnected; /// @@ -46,8 +49,11 @@ public class ServerConnection(int port, string? bindAddress = null) : IDisposabl public event Func? OnPacketReceived; /// - /// Listens for connections and runs until gets called + /// Listens to incoming connections /// + /// + /// Runs until gets called + /// public async Task RunAsync() { PacketRegistrar.RegisterAllPackets(); _listener.Start(); @@ -89,8 +95,11 @@ public async Task RunAsync() { public void Stop() => _cts.Cancel(); /// - /// Sends a packet to a single client; failures are treated as a disconnection + /// Sends a packet to a single client /// + /// + /// If the send fails, the client gets disconnected + /// /// the id of the client /// the packet to send public async Task SendToAsync(uint id, IPacket packet) { diff --git a/OpenPolytopia.Server/GameServer.cs b/OpenPolytopia.Server/GameServer.cs index d50dd33c..0d26ce05 100644 --- a/OpenPolytopia.Server/GameServer.cs +++ b/OpenPolytopia.Server/GameServer.cs @@ -5,7 +5,7 @@ namespace OpenPolytopia.Server; using OpenPolytopia.Common.Network.Packets; /// -/// The game server: manages players, lobbies and starting games on top of a +/// Manages players, lobbies and starting games on top of a /// /// the port to listen on /// the ip address to bind to; null to listen on every interface @@ -51,7 +51,7 @@ private async Task ManagePacketAsync(NetworkConnection connection, IPacket packe await DispatchPacketAsync(connection, packet); } catch (Exception e) { - // a failing handler must not go unnoticed nor take the server down + // log the error without taking the server down Console.Error.WriteLine($"Error while managing {packet.GetType().Name} from client {connection.Id}: {e}"); } } diff --git a/OpenPolytopia.Server/LobbyManager.cs b/OpenPolytopia.Server/LobbyManager.cs index 72b1b8d2..beea0da6 100644 --- a/OpenPolytopia.Server/LobbyManager.cs +++ b/OpenPolytopia.Server/LobbyManager.cs @@ -4,10 +4,11 @@ namespace OpenPolytopia.Server; using OpenPolytopia.Common.Network.Packets; /// -/// Owns all the lobbies on the server. -///
-/// Not thread-safe on its own: serializes every access through its own lock +/// Owns all the lobbies on the server ///
+/// +/// This class isn't thread-safe, serializes every access through its own lock +/// public class LobbyManager { private readonly Dictionary _lobbies = new(); private ulong _nextId; @@ -89,9 +90,11 @@ public LobbyActionResult LeaveLobby(ulong lobbyId, uint playerId) { } /// - /// Sets the ready state of a player in a lobby. - /// When every player in the lobby is ready, the lobby is marked as starting + /// 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 @@ -113,9 +116,8 @@ public LobbyActionResult SetReady(ulong lobbyId, uint playerId, bool ready) { player.Ready = ready; - // check if all players are ready + // start the game when all players are ready if (lobby.ReadyCount == lobby.PlayersCount) { - // start the game lobby.Starting = true; } @@ -129,9 +131,11 @@ public LobbyActionResult SetReady(ulong lobbyId, uint playerId, bool ready) { 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 + /// 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 diff --git a/OpenPolytopia.Server/Program.cs b/OpenPolytopia.Server/Program.cs index d309b7a5..e961cb21 100644 --- a/OpenPolytopia.Server/Program.cs +++ b/OpenPolytopia.Server/Program.cs @@ -14,12 +14,14 @@ internal static class Program { private const string BIND_ADDRESS_ENV = "OPENPOLYTOPIA_BIND_ADDRESS"; /// - /// Usage: OpenPolytopia.Server [port] [bind-address] - ///
- /// Both arguments are optional and fall back to the + /// 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; diff --git a/OpenPolytopia/src/Lobby.cs b/OpenPolytopia/src/Lobby.cs index 9f3087c6..1eee115c 100644 --- a/OpenPolytopia/src/Lobby.cs +++ b/OpenPolytopia/src/Lobby.cs @@ -8,9 +8,11 @@ namespace OpenPolytopia; using Godot; /// -/// Lobby browser: lists the lobbies on the server and lets the player -/// create, join, leave and ready up in a lobby +/// Lists the lobbies on the server /// +/// +/// Lets the player create, join and leave a lobby and mark himself as ready +/// public partial class Lobby : Control { private const uint DEFAULT_MAX_PLAYERS = 4; diff --git a/OpenPolytopia/src/NetworkNode.cs b/OpenPolytopia/src/NetworkNode.cs index 1c9b7472..a191c31f 100644 --- a/OpenPolytopia/src/NetworkNode.cs +++ b/OpenPolytopia/src/NetworkNode.cs @@ -11,31 +11,31 @@ namespace OpenPolytopia; using Godot; /// -/// Manages the connection with the game server. -///
-/// Add this node to every scene that talks to the server: the underlying connection is shared -/// between all the instances and survives scene changes, so the first node to enter the tree -/// connects and the following ones reuse the same connection. -///
-/// Packets are received in background and processed on the main thread -/// in , so every event is safe to use with Godot nodes +/// Manages the connection with the game server ///
+/// +/// Add this node to every scene that talks to the server. +/// The underlying connection is shared between all the instances and survives scene changes: +/// the first node to enter the tree connects and the following ones reuse the same connection. +/// Packets are processed on the main thread in , +/// so every event is safe to use with Godot nodes +/// [GlobalClass] public partial class NetworkNode : Node { /// - /// The instance currently pumping the connection, i.e. the last one that entered the tree + /// The instance currently processing the packets /// + /// + /// It is the last instance that entered the tree + /// public static NetworkNode Instance { get; private set; } = null!; - // overrides usable without touching the scene: - // OPENPOLYTOPIA_SERVER_HOST=... or `godot -- --server-host=...` (same for port) private const string HOST_ENV = "OPENPOLYTOPIA_SERVER_HOST"; private const string PORT_ENV = "OPENPOLYTOPIA_SERVER_PORT"; private const string HOST_ARG = "--server-host"; private const string PORT_ARG = "--server-port"; - // the connection and the session state are shared between all NetworkNode instances - // so they survive scene changes; only app shutdown or Disconnect() close the connection + // shared between all the instances so the connection survives scene changes private static ClientConnection? _connection; private static bool _handshakeDone; private static uint _playerId; @@ -43,31 +43,37 @@ public partial class NetworkNode : Node { private static int _disconnectedFlag; /// - /// Host to connect to; editable in the inspector. + /// Host to connect to + /// + /// /// Overridable with the --server-host= command line argument /// or the OPENPOLYTOPIA_SERVER_HOST environment variable - ///
+ /// [Export] public string Host { get; set; } = "enn3.ovh"; /// - /// Port to connect to; editable in the inspector. + /// Port to connect to + /// + /// /// Overridable with the --server-port= command line argument /// or the OPENPOLYTOPIA_SERVER_PORT environment variable - ///
+ /// [Export(PropertyHint.Range, "1,65535")] public int Port { get; set; } = NetworkConstants.DEFAULT_PORT; /// /// If true, the node connects to the server as soon as it enters the tree - /// (unless a shared connection already exists) /// [Export] public bool AutoConnect { get; set; } = true; /// - /// Id assigned to this client by the server; valid after + /// Id assigned to this client by the server /// + /// + /// Valid after gets fired + /// public uint PlayerId => _playerId; /// @@ -121,7 +127,7 @@ public partial class NetworkNode : Node { public event Action? OnGameStarted; /// - /// Take over the pumping of the shared connection and connect if needed + /// Takes over the shared connection and connects to the server if needed /// public override void _EnterTree() { base._EnterTree(); @@ -133,11 +139,11 @@ public override void _EnterTree() { } /// - /// Process messages received from the server + /// Processes the packets received from the server /// /// ignored public override void _PhysicsProcess(double delta) { - // only the active instance pumps the shared connection + // only the active instance processes the packets if (Instance != this || _connection == null) { return; } @@ -157,10 +163,12 @@ public override void _PhysicsProcess(double delta) { } /// - /// Connects to the server; called automatically when entering the tree if - /// is enabled. - /// Does nothing if a shared connection already exists + /// Connects to the server /// + /// + /// Called automatically when entering the tree if is enabled; + /// does nothing if a shared connection already exists + /// public void ConnectToServer() { if (_connection != null) { return; @@ -220,9 +228,11 @@ public void CreateLobby(uint maxPlayers, uint tribe) => public void SetReady(ulong lobbyId, bool ready) => Send(new SetReadyPacket { LobbyId = lobbyId, Ready = ready }); /// - /// Resolves the server host and port to connect to. - /// Precedence: command line argument, environment variable, exported property + /// Resolves the server host and port to connect to /// + /// + /// Precedence: command line argument, environment variable, exported property + /// private (string, int) ResolveServerAddress() { var host = GetUserArg(HOST_ARG) ?? EnvOrNull(HOST_ENV) ?? Host; @@ -241,7 +251,7 @@ public void CreateLobby(uint maxPlayers, uint tribe) => } /// - /// Returns the value of a --name=value user argument (the ones after --) or null + /// Returns the value of a --name=value user argument or null if missing /// /// the argument name, including the leading dashes private static string? GetUserArg(string name) => @@ -266,7 +276,7 @@ private static async Task ConnectToServerAsync(string host, int port) { catch (Exception e) { GD.PushError($"Error while connecting to {host}:{port}: {e}"); - // throw the connection away so a new node (or a manual call) can retry + // remove the connection to let a new node retry if (_connection == connection) { _connection = null; } From 1b171c5058531fc2e1fa55b708309e8e1339da81 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 04:38:21 +0000 Subject: [PATCH 05/11] Fix the issues found in the code review - timeout server sends and run broadcasts concurrently so a stalled client can't freeze the server or disable the keep alive kicks - frame packets containing shared lobby data while holding the state lock, broadcasting the pre-built frame to every client; this also removes the per-recipient re-serialization - enforce the handshake server-side: kick clients with an incompatible version and clients that send anything before a successful handshake - make NetworkConnection.Close thread-safe with an atomic flag so OnDisconnected can't fire twice - drop the never-verified keep alive captcha, it's a plain ping now - fix a NullReferenceException in NetworkNode._PhysicsProcess when a packet handler disconnects mid-pump - clear the stale disconnected flag on reconnect so a new connection isn't destroyed by the previous one's disconnection - restore the lobby list selection after a rebuild and keep the list ordered by lobby id - validate the port range in the server entry point Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX --- .../Network/NetworkConnection.cs | 24 ++- .../Network/PacketProtocol.cs | 11 ++ .../Network/Packets/KeepAlivePacket.cs | 13 +- .../Network/ServerConnection.cs | 63 +++++--- OpenPolytopia.Server/GameServer.cs | 141 ++++++++++++------ OpenPolytopia.Server/Program.cs | 3 +- OpenPolytopia/src/Lobby.cs | 9 +- OpenPolytopia/src/NetworkNode.cs | 22 ++- OpenPolytopia/test/src/PacketTest.cs | 4 +- 9 files changed, 202 insertions(+), 88 deletions(-) diff --git a/OpenPolytopia.Common/Network/NetworkConnection.cs b/OpenPolytopia.Common/Network/NetworkConnection.cs index aa80da04..d506aebb 100644 --- a/OpenPolytopia.Common/Network/NetworkConnection.cs +++ b/OpenPolytopia.Common/Network/NetworkConnection.cs @@ -14,7 +14,7 @@ namespace OpenPolytopia.Common.Network; public class NetworkConnection(uint id, TcpClient client) : IDisposable { private readonly NetworkStream _stream = client.GetStream(); private readonly SemaphoreSlim _writeLock = new(1, 1); - private bool _closed; + private int _closed; /// /// Id of this connection; assigned by the server @@ -39,7 +39,7 @@ public class NetworkConnection(uint id, TcpClient client) : IDisposable { /// /// true while the underlying socket is connected /// - public bool Connected => !_closed && client.Connected; + public bool Connected => _closed == 0 && client.Connected; /// /// Sends a packet to the remote endpoint @@ -49,13 +49,21 @@ public class NetworkConnection(uint id, TcpClient client) : IDisposable { /// /// the packet to send /// cancellation token - public async Task SendPacketAsync(IPacket packet, CancellationToken ct = default) { - List bytes = []; - PacketProtocol.FramePacket(packet, bytes); + 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(bytes.ToArray(), ct); + await _stream.WriteAsync(frame, ct); } finally { _writeLock.Release(); @@ -103,11 +111,11 @@ public async Task RunAsync(CancellationToken ct = default) { /// Calling this multiple times is safe /// public void Close() { - if (_closed) { + // atomic exchange so two threads closing at once fire OnDisconnected only once + if (Interlocked.Exchange(ref _closed, 1) == 1) { return; } - _closed = true; client.Close(); OnDisconnected?.Invoke(this); } diff --git a/OpenPolytopia.Common/Network/PacketProtocol.cs b/OpenPolytopia.Common/Network/PacketProtocol.cs index e2ad9950..31b58c25 100644 --- a/OpenPolytopia.Common/Network/PacketProtocol.cs +++ b/OpenPolytopia.Common/Network/PacketProtocol.cs @@ -35,6 +35,17 @@ public static void FramePacket(IPacket packet, List bytes) { 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]; + } + /// /// Frames a packet and writes it to the stream /// diff --git a/OpenPolytopia.Common/Network/Packets/KeepAlivePacket.cs b/OpenPolytopia.Common/Network/Packets/KeepAlivePacket.cs index c786059b..7b4b28b9 100644 --- a/OpenPolytopia.Common/Network/Packets/KeepAlivePacket.cs +++ b/OpenPolytopia.Common/Network/Packets/KeepAlivePacket.cs @@ -4,15 +4,12 @@ namespace OpenPolytopia.Common.Network.Packets; /// Sent periodically by the server to check if a client is still alive /// /// -/// The client must echo it back, otherwise the server closes the connection +/// The client echoes it back; a client that doesn't send anything for too long gets disconnected /// public class KeepAlivePacket : IPacket { - /// - /// Random value that must be echoed back untouched - /// - public uint Captcha; + public void Serialize(List bytes) { + } - public void Serialize(List bytes) => Captcha.Serialize(bytes); - - public void Deserialize(byte[] bytes, ref uint index) => Captcha.Deserialize(bytes, ref index); + public void Deserialize(byte[] bytes, ref uint index) { + } } diff --git a/OpenPolytopia.Common/Network/ServerConnection.cs b/OpenPolytopia.Common/Network/ServerConnection.cs index c1ac7203..3d293031 100644 --- a/OpenPolytopia.Common/Network/ServerConnection.cs +++ b/OpenPolytopia.Common/Network/ServerConnection.cs @@ -2,7 +2,6 @@ namespace OpenPolytopia.Common.Network; using System.Collections.Concurrent; using System.Net.Sockets; -using System.Security.Cryptography; using Packets; /// @@ -17,6 +16,7 @@ namespace OpenPolytopia.Common.Network; 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 SEND_TIMEOUT = TimeSpan.FromSeconds(10); private readonly TcpListener _listener = bindAddress == null ? TcpListener.Create(port) @@ -98,17 +98,31 @@ public async Task RunAsync() { /// Sends a packet to a single client /// /// - /// If the send fails, the client gets disconnected + /// If the send fails or takes longer than , the client gets disconnected /// /// the id of the client /// the packet to send - public async Task SendToAsync(uint id, IPacket packet) { + public async Task SendToAsync(uint id, IPacket packet) => await SendToAsync(id, PacketProtocol.FramePacket(packet)); + + /// + /// Sends an already framed packet to a single client + /// + /// + /// If the send fails or takes longer than , the client gets disconnected + /// + /// the id of the client + /// the framed packet to send + public async Task SendToAsync(uint id, byte[] frame) { if (!_connections.TryGetValue(id, out var connection)) { return; } + // timeout the send so a client that stopped reading can't block the server + using var cts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token); + cts.CancelAfter(SEND_TIMEOUT); + try { - await connection.SendPacketAsync(packet, _cts.Token); + await connection.SendFrameAsync(frame, cts.Token); } catch (Exception) { connection.Close(); @@ -119,22 +133,34 @@ public async Task SendToAsync(uint id, IPacket packet) { /// Sends a packet to every connected client /// /// the packet to broadcast - public async Task BroadcastAsync(IPacket packet) { - foreach (var id in _connections.Keys) { - await SendToAsync(id, packet); - } - } + public async Task BroadcastAsync(IPacket packet) => await BroadcastAsync(PacketProtocol.FramePacket(packet)); + + /// + /// Sends an already framed packet to every connected client + /// + /// + /// The packet is framed once and the sends run concurrently, + /// so a slow client can't delay the others + /// + /// the framed packet to broadcast + public async Task BroadcastAsync(byte[] frame) => + await Task.WhenAll(_connections.Keys.Select(id => SendToAsync(id, frame))); /// /// Sends a packet to the given clients /// /// the ids of the clients /// the packet to send - public async Task BroadcastToAsync(IEnumerable ids, IPacket packet) { - foreach (var id in ids) { - await SendToAsync(id, packet); - } - } + public async Task BroadcastToAsync(IEnumerable ids, IPacket packet) => + await BroadcastToAsync(ids, PacketProtocol.FramePacket(packet)); + + /// + /// Sends an already framed packet to the given clients + /// + /// the ids of the clients + /// the framed packet to send + public async Task BroadcastToAsync(IEnumerable ids, byte[] frame) => + await Task.WhenAll(ids.Select(id => SendToAsync(id, frame))); private async Task ClientPacketReceivedAsync(NetworkConnection connection, IPacket packet) { // the keep alive response is managed here, the rest is forwarded @@ -157,8 +183,11 @@ 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; + List sends = []; foreach (var connection in _connections.Values) { // kick clients that timed out @@ -167,10 +196,10 @@ private async Task KeepAliveLoopAsync(CancellationToken ct) { continue; } - await SendToAsync(connection.Id, new KeepAlivePacket { - Captcha = (uint)RandomNumberGenerator.GetInt32(int.MaxValue) - }); + sends.Add(SendToAsync(connection.Id, keepAlive)); } + + await Task.WhenAll(sends); } } catch (OperationCanceledException) { diff --git a/OpenPolytopia.Server/GameServer.cs b/OpenPolytopia.Server/GameServer.cs index 0d26ce05..fa41809e 100644 --- a/OpenPolytopia.Server/GameServer.cs +++ b/OpenPolytopia.Server/GameServer.cs @@ -18,8 +18,9 @@ public class GameServer(int port, string? bindAddress = null) : IDisposable { private readonly ServerConnection _server = new(port, bindAddress); private readonly LobbyManager _lobbyManager = new(); private readonly Dictionary _playerNames = new(); + private readonly HashSet _handshaked = []; - // guards _lobbyManager and _playerNames: packet handlers run on many client tasks + // guards _lobbyManager, _playerNames and _handshaked: packet handlers run on many client tasks private readonly SemaphoreSlim _stateLock = new(1, 1); private readonly CancellationTokenSource _cts = new(); @@ -57,12 +58,27 @@ private async Task ManagePacketAsync(NetworkConnection connection, IPacket packe } private async Task DispatchPacketAsync(NetworkConnection connection, IPacket packet) { + if (packet is HandshakePacket handshake) { + await ManageHandshakeAsync(connection, handshake); + return; + } + + // kick clients that send anything else before a successful handshake + bool handshaked; + await _stateLock.WaitAsync(); + try { + handshaked = _handshaked.Contains(connection.Id); + } + finally { + _stateLock.Release(); + } + + if (!handshaked) { + connection.Close(); + return; + } + switch (packet) { - // handshake, respond with the result of the version check and the assigned player id - case HandshakePacket handshake: - await _server.SendToAsync(connection.Id, - new HandshakeResponsePacket { Ok = handshake.Version == NetworkConstants.VERSION, PlayerId = connection.Id }); - break; // register the player or rename him case SetNamePacket setName: await ManageSetNameAsync(connection, setName); @@ -90,6 +106,27 @@ await _server.SendToAsync(connection.Id, } } + private async Task ManageHandshakeAsync(NetworkConnection connection, HandshakePacket packet) { + var ok = packet.Version == NetworkConstants.VERSION; + + if (ok) { + await _stateLock.WaitAsync(); + try { + _handshaked.Add(connection.Id); + } + finally { + _stateLock.Release(); + } + } + + await _server.SendToAsync(connection.Id, new HandshakeResponsePacket { Ok = ok, PlayerId = connection.Id }); + + // kick clients with an incompatible version + if (!ok) { + connection.Close(); + } + } + private async Task ManageSetNameAsync(NetworkConnection connection, SetNamePacket packet) { var name = packet.Name.Trim(); var ok = name.Length is > 0 and <= 32; @@ -108,11 +145,12 @@ private async Task ManageSetNameAsync(NetworkConnection connection, SetNamePacke } private async Task ManageGetLobbiesAsync(NetworkConnection connection) { - GetLobbiesResponsePacket response; + byte[] response; + // frame the packet while holding the lock, the lobby data is shared await _stateLock.WaitAsync(); try { - response = new GetLobbiesResponsePacket { Lobbies = [.. _lobbyManager.Lobbies] }; + response = PacketProtocol.FramePacket(new GetLobbiesResponsePacket { Lobbies = [.. _lobbyManager.Lobbies] }); } finally { _stateLock.Release(); @@ -122,7 +160,8 @@ private async Task ManageGetLobbiesAsync(NetworkConnection connection) { } private async Task ManageCreateLobbyAsync(NetworkConnection connection, CreateLobbyPacket packet) { - LobbyData? lobby = null; + var lobbyId = 0ul; + byte[]? lobbyUpdate = null; var result = LobbyActionResult.Ok; await _stateLock.WaitAsync(); @@ -134,24 +173,27 @@ private async Task ManageCreateLobbyAsync(NetworkConnection connection, CreateLo result = LobbyActionResult.InvalidParameters; } else { - lobby = _lobbyManager.CreateLobby(packet.MaxPlayers, + var lobby = _lobbyManager.CreateLobby(packet.MaxPlayers, new LobbyPlayerData { PlayerId = connection.Id, Name = name, Tribe = packet.Tribe }); + lobbyId = lobby.Id; + + // frame the broadcast while holding the lock, the lobby data is shared + lobbyUpdate = PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby }); } } finally { _stateLock.Release(); } - await _server.SendToAsync(connection.Id, - new CreateLobbyResponsePacket { Result = result, LobbyId = lobby?.Id ?? 0 }); + await _server.SendToAsync(connection.Id, new CreateLobbyResponsePacket { Result = result, LobbyId = lobbyId }); - if (lobby != null) { - await _server.BroadcastAsync(new LobbyUpdatedPacket { Lobby = lobby }); + if (lobbyUpdate != null) { + await _server.BroadcastAsync(lobbyUpdate); } } private async Task ManageJoinLobbyAsync(NetworkConnection connection, JoinLobbyPacket packet) { - LobbyData? lobby = null; + byte[]? lobbyUpdate = null; LobbyActionResult result; await _stateLock.WaitAsync(); @@ -162,7 +204,12 @@ private async Task ManageJoinLobbyAsync(NetworkConnection connection, JoinLobbyP else { result = _lobbyManager.JoinLobby(packet.LobbyId, new LobbyPlayerData { PlayerId = connection.Id, Name = name, Tribe = packet.Tribe }); - lobby = _lobbyManager[packet.LobbyId]; + var lobby = _lobbyManager[packet.LobbyId]; + + if (result == LobbyActionResult.Ok && lobby != null) { + // frame the broadcast while holding the lock, the lobby data is shared + lobbyUpdate = PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby }); + } } } finally { @@ -171,27 +218,27 @@ private async Task ManageJoinLobbyAsync(NetworkConnection connection, JoinLobbyP await _server.SendToAsync(connection.Id, new JoinLobbyResponsePacket { Result = result, LobbyId = packet.LobbyId }); - if (result == LobbyActionResult.Ok && lobby != null) { - await _server.BroadcastAsync(new LobbyUpdatedPacket { Lobby = lobby }); + if (lobbyUpdate != null) { + await _server.BroadcastAsync(lobbyUpdate); } } private async Task ManageLeaveLobbyAsync(NetworkConnection connection, LeaveLobbyPacket packet) { - LobbyData? lobby = null; - var deleted = false; + byte[]? broadcast = null; LobbyActionResult result; await _stateLock.WaitAsync(); try { result = _lobbyManager.LeaveLobby(packet.LobbyId, connection.Id); - if (result == LobbyActionResult.Ok) { - lobby = _lobbyManager[packet.LobbyId]; - - // remove the lobby if it became empty - if (lobby is { PlayersCount: 0 }) { + if (result == LobbyActionResult.Ok && _lobbyManager[packet.LobbyId] is { } lobby) { + // remove the lobby if it became empty; frame the broadcast while holding the lock + if (lobby.PlayersCount == 0) { _lobbyManager.RemoveLobby(lobby.Id); - deleted = true; + broadcast = PacketProtocol.FramePacket(new LobbyDeletedPacket { LobbyId = lobby.Id }); + } + else { + broadcast = PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby }); } } } @@ -202,27 +249,22 @@ private async Task ManageLeaveLobbyAsync(NetworkConnection connection, LeaveLobb await _server.SendToAsync(connection.Id, new LeaveLobbyResponsePacket { Result = result, LobbyId = packet.LobbyId }); - if (result != LobbyActionResult.Ok || lobby == null) { - return; - } - - if (deleted) { - await _server.BroadcastAsync(new LobbyDeletedPacket { LobbyId = lobby.Id }); - } - else { - await _server.BroadcastAsync(new LobbyUpdatedPacket { Lobby = lobby }); + if (broadcast != null) { + await _server.BroadcastAsync(broadcast); } } private async Task ManageSetReadyAsync(NetworkConnection connection, SetReadyPacket packet) { - LobbyData? lobby = null; + byte[]? lobbyUpdate = null; LobbyActionResult result; await _stateLock.WaitAsync(); try { result = _lobbyManager.SetReady(packet.LobbyId, connection.Id, packet.Ready); - if (result == LobbyActionResult.Ok) { - lobby = _lobbyManager[packet.LobbyId]; + + if (result == LobbyActionResult.Ok && _lobbyManager[packet.LobbyId] is { } lobby) { + // frame the broadcast while holding the lock, the lobby data is shared + lobbyUpdate = PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby }); } } finally { @@ -231,30 +273,33 @@ private async Task ManageSetReadyAsync(NetworkConnection connection, SetReadyPac await _server.SendToAsync(connection.Id, new SetReadyResponsePacket { Result = result, LobbyId = packet.LobbyId }); - if (result == LobbyActionResult.Ok && lobby != null) { - await _server.BroadcastAsync(new LobbyUpdatedPacket { Lobby = lobby }); + if (lobbyUpdate != null) { + await _server.BroadcastAsync(lobbyUpdate); } } private async Task ClientDisconnectedAsync(NetworkConnection connection) { - List updated = []; - List deletedIds = []; + List broadcasts = []; await _stateLock.WaitAsync(); try { + _handshaked.Remove(connection.Id); _playerNames.Remove(connection.Id); + + List updated = []; + List deletedIds = []; _lobbyManager.RemovePlayerFromAllLobbies(connection.Id, updated, deletedIds); + + // frame the broadcasts while holding the lock, the lobby data is shared + broadcasts.AddRange(updated.Select(lobby => PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby }))); + broadcasts.AddRange(deletedIds.Select(id => PacketProtocol.FramePacket(new LobbyDeletedPacket { LobbyId = id }))); } finally { _stateLock.Release(); } - foreach (var lobby in updated) { - await _server.BroadcastAsync(new LobbyUpdatedPacket { Lobby = lobby }); - } - - foreach (var id in deletedIds) { - await _server.BroadcastAsync(new LobbyDeletedPacket { LobbyId = id }); + foreach (var broadcast in broadcasts) { + await _server.BroadcastAsync(broadcast); } } diff --git a/OpenPolytopia.Server/Program.cs b/OpenPolytopia.Server/Program.cs index e961cb21..167237d4 100644 --- a/OpenPolytopia.Server/Program.cs +++ b/OpenPolytopia.Server/Program.cs @@ -25,7 +25,8 @@ internal static class Program { 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)) { + if (!string.IsNullOrWhiteSpace(portValue) && + (!int.TryParse(portValue, out port) || port is <= 0 or > ushort.MaxValue)) { Console.Error.WriteLine($"Invalid port: {portValue}"); Environment.Exit(1); } diff --git a/OpenPolytopia/src/Lobby.cs b/OpenPolytopia/src/Lobby.cs index 1eee115c..63a511d6 100644 --- a/OpenPolytopia/src/Lobby.cs +++ b/OpenPolytopia/src/Lobby.cs @@ -114,9 +114,12 @@ private void OnLobbiesChanged(object? sender, NotifyCollectionChangedEventArgs e private void RefreshList() { var network = NetworkNode.Instance; + + // remember the selection to restore it after the rebuild + var selectedId = SelectedLobby()?.Id; _lobbyList.Clear(); - foreach (var lobby in network.Lobbies) { + foreach (var lobby in network.Lobbies.OrderBy(lobby => lobby.Id)) { var text = $"Lobby {lobby.Id} — {lobby.PlayersCount}/{lobby.MaxPlayers} players, {lobby.ReadyCount} ready"; if (lobby.Starting) { text += " (starting)"; @@ -124,6 +127,10 @@ private void RefreshList() { var index = _lobbyList.AddItem(text); _lobbyList.SetItemMetadata(index, lobby.Id); + + if (lobby.Id == selectedId) { + _lobbyList.Select(index); + } } } diff --git a/OpenPolytopia/src/NetworkNode.cs b/OpenPolytopia/src/NetworkNode.cs index a191c31f..dac636f0 100644 --- a/OpenPolytopia/src/NetworkNode.cs +++ b/OpenPolytopia/src/NetworkNode.cs @@ -144,18 +144,28 @@ public override void _EnterTree() { /// ignored public override void _PhysicsProcess(double delta) { // only the active instance processes the packets - if (Instance != this || _connection == null) { + if (Instance != this) { return; } - while (_connection.IncomingPackets.TryDequeue(out var packet)) { + var connection = _connection; + if (connection == null) { + return; + } + + while (connection.IncomingPackets.TryDequeue(out var packet)) { ProcessPacket(packet); + + // stop if a handler disconnected + if (_connection != connection) { + return; + } } // manage a disconnection signalled by the background read task if (Interlocked.Exchange(ref _disconnectedFlag, 0) == 1) { _handshakeDone = false; - _connection.Dispose(); + connection.Dispose(); _connection = null; _lobbies.Clear(); OnDisconnected?.Invoke(); @@ -186,6 +196,9 @@ public void Disconnect() { _connection = null; _handshakeDone = false; _lobbies.Clear(); + + // consume the disconnection signalled by disposing the connection + Interlocked.Exchange(ref _disconnectedFlag, 0); } /// @@ -266,6 +279,9 @@ public void CreateLobby(uint maxPlayers, uint tribe) => } private static async Task ConnectToServerAsync(string host, int port) { + // clear a stale disconnection left over from a previous connection + Interlocked.Exchange(ref _disconnectedFlag, 0); + var connection = new ClientConnection(host, port); try { _connection = connection; diff --git a/OpenPolytopia/test/src/PacketTest.cs b/OpenPolytopia/test/src/PacketTest.cs index a15ef3e0..132745bd 100644 --- a/OpenPolytopia/test/src/PacketTest.cs +++ b/OpenPolytopia/test/src/PacketTest.cs @@ -34,8 +34,8 @@ public void TestHandshakeResponse() { [Test] public void TestKeepAlive() { - var packet = RoundTrip(new KeepAlivePacket { Captcha = 20u }); - packet.Captcha.ShouldBe(20u); + var packet = RoundTrip(new KeepAlivePacket()); + packet.ShouldNotBeNull(); } [Test] From c60ff9917c390933bf941598cef05e61b04b5117 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 04:40:34 +0000 Subject: [PATCH 06/11] Fix the spellcheck failure Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX --- OpenPolytopia.Server/GameServer.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/OpenPolytopia.Server/GameServer.cs b/OpenPolytopia.Server/GameServer.cs index fa41809e..4950bf39 100644 --- a/OpenPolytopia.Server/GameServer.cs +++ b/OpenPolytopia.Server/GameServer.cs @@ -18,9 +18,9 @@ public class GameServer(int port, string? bindAddress = null) : IDisposable { private readonly ServerConnection _server = new(port, bindAddress); private readonly LobbyManager _lobbyManager = new(); private readonly Dictionary _playerNames = new(); - private readonly HashSet _handshaked = []; + private readonly HashSet _handshakeDone = []; - // guards _lobbyManager, _playerNames and _handshaked: packet handlers run on many client tasks + // guards _lobbyManager, _playerNames and _handshakeDone: packet handlers run on many client tasks private readonly SemaphoreSlim _stateLock = new(1, 1); private readonly CancellationTokenSource _cts = new(); @@ -64,16 +64,16 @@ private async Task DispatchPacketAsync(NetworkConnection connection, IPacket pac } // kick clients that send anything else before a successful handshake - bool handshaked; + bool handshakeDone; await _stateLock.WaitAsync(); try { - handshaked = _handshaked.Contains(connection.Id); + handshakeDone = _handshakeDone.Contains(connection.Id); } finally { _stateLock.Release(); } - if (!handshaked) { + if (!handshakeDone) { connection.Close(); return; } @@ -112,7 +112,7 @@ private async Task ManageHandshakeAsync(NetworkConnection connection, HandshakeP if (ok) { await _stateLock.WaitAsync(); try { - _handshaked.Add(connection.Id); + _handshakeDone.Add(connection.Id); } finally { _stateLock.Release(); @@ -283,7 +283,7 @@ private async Task ClientDisconnectedAsync(NetworkConnection connection) { await _stateLock.WaitAsync(); try { - _handshaked.Remove(connection.Id); + _handshakeDone.Remove(connection.Id); _playerNames.Remove(connection.Id); List updated = []; From 5ea3478d6549618782b63bf7fe234eb737047673 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 05:58:20 +0000 Subject: [PATCH 07/11] Fix the issues found in the second code review - gate client sends on the completed handshake - cap lobbies to one per player and 100 per server - enforce the max packet size on the sender too - survive transient accept failures on the server - disconnect the client when the server goes silent - surface failed connection attempts to the scenes - reset the lobby scene state on disconnection - validate the tribe on lobby create and join - remove the unused IntSerialization and WritePacketAsync - coalesce lobby list changes into one event per packet Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX --- .../Network/ClientConnection.cs | 24 +++++++- .../Network/NetworkSerialization.cs | 10 ---- .../Network/PacketProtocol.cs | 23 +++----- .../Network/Packets/LobbyActionResult.cs | 1 + .../Network/ServerConnection.cs | 11 +++- OpenPolytopia.Server/GameServer.cs | 21 ++++++- OpenPolytopia.Server/LobbyManager.cs | 11 ++++ OpenPolytopia/src/Lobby.cs | 18 ++++-- OpenPolytopia/src/NetworkNode.cs | 55 ++++++++++++------- 9 files changed, 123 insertions(+), 51 deletions(-) diff --git a/OpenPolytopia.Common/Network/ClientConnection.cs b/OpenPolytopia.Common/Network/ClientConnection.cs index b0152435..c64cc269 100644 --- a/OpenPolytopia.Common/Network/ClientConnection.cs +++ b/OpenPolytopia.Common/Network/ClientConnection.cs @@ -10,8 +10,12 @@ namespace OpenPolytopia.Common.Network; /// /// 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; @@ -42,8 +46,9 @@ public async Task ConnectAsync() { _connection.OnPacketReceived += PacketReceivedAsync; _connection.OnDisconnected += _ => OnDisconnected?.Invoke(); - // read packets in background + // read packets and watch for a dead server in background _ = _connection.RunAsync(_cts.Token); + _ = TimeoutLoopAsync(_connection, _cts.Token); } /// @@ -66,6 +71,23 @@ public void Disconnect() { _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) { diff --git a/OpenPolytopia.Common/Network/NetworkSerialization.cs b/OpenPolytopia.Common/Network/NetworkSerialization.cs index 3febd567..e2ffe082 100644 --- a/OpenPolytopia.Common/Network/NetworkSerialization.cs +++ b/OpenPolytopia.Common/Network/NetworkSerialization.cs @@ -42,16 +42,6 @@ public static uint Read(byte[] bytes, ref uint index) { } } -public static class IntSerialization { - public static void Serialize(this int value, List bytes) => ((uint)value).Serialize(bytes); - - public static void Deserialize(this ref int value, byte[] bytes, ref uint index) { - var unsigned = 0u; - unsigned.Deserialize(bytes, ref index); - value = (int)unsigned; - } -} - public static class ULongSerialization { public static void Serialize(this ulong value, List bytes) { bytes.Add((byte)(value >> 56)); diff --git a/OpenPolytopia.Common/Network/PacketProtocol.cs b/OpenPolytopia.Common/Network/PacketProtocol.cs index 31b58c25..efee6ff8 100644 --- a/OpenPolytopia.Common/Network/PacketProtocol.cs +++ b/OpenPolytopia.Common/Network/PacketProtocol.cs @@ -4,7 +4,7 @@ namespace OpenPolytopia.Common.Network; using Packets; /// -/// Thrown when the remote endpoint sends a malformed or too big packet +/// Thrown when a packet violates the wire format, like being malformed or too big /// public class ProtocolViolationException(string message) : Exception(message); @@ -22,6 +22,7 @@ public static class PacketProtocol { /// /// 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; @@ -30,8 +31,14 @@ public static void FramePacket(IPacket packet, List bytes) { PacketRegistrar.GetPacketId(packet).Serialize(bytes); packet.Serialize(bytes); - // insert the content length before the id + // 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()); } @@ -46,18 +53,6 @@ public static byte[] FramePacket(IPacket packet) { return [.. bytes]; } - /// - /// Frames a packet and writes it to the stream - /// - /// the stream to write to - /// the packet to send - /// cancellation token - public static async Task WritePacketAsync(Stream stream, IPacket packet, CancellationToken ct = default) { - List bytes = []; - FramePacket(packet, bytes); - await stream.WriteAsync(bytes.ToArray(), ct); - } - /// /// Reads exactly one packet from the stream /// diff --git a/OpenPolytopia.Common/Network/Packets/LobbyActionResult.cs b/OpenPolytopia.Common/Network/Packets/LobbyActionResult.cs index 51286740..377d93bc 100644 --- a/OpenPolytopia.Common/Network/Packets/LobbyActionResult.cs +++ b/OpenPolytopia.Common/Network/Packets/LobbyActionResult.cs @@ -12,6 +12,7 @@ public enum LobbyActionResult : byte { AlreadyJoinedLobby = 5, NotInLobby = 6, InvalidParameters = 7, + TooManyLobbies = 8, } public static class LobbyActionResultSerialization { diff --git a/OpenPolytopia.Common/Network/ServerConnection.cs b/OpenPolytopia.Common/Network/ServerConnection.cs index 3d293031..5b97a808 100644 --- a/OpenPolytopia.Common/Network/ServerConnection.cs +++ b/OpenPolytopia.Common/Network/ServerConnection.cs @@ -63,7 +63,16 @@ public async Task RunAsync() { try { while (!_cts.IsCancellationRequested) { - var client = await _listener.AcceptTcpClientAsync(_cts.Token); + TcpClient client; + try { + client = await _listener.AcceptTcpClientAsync(_cts.Token); + } + catch (SocketException e) { + // transient failure, like a connection aborted mid-handshake; keep accepting the others + Console.Error.WriteLine($"Failed to accept a connection: {e.Message}"); + continue; + } + var id = Interlocked.Increment(ref _nextId); var connection = new NetworkConnection(id, client); diff --git a/OpenPolytopia.Server/GameServer.cs b/OpenPolytopia.Server/GameServer.cs index 4950bf39..99a5e9dd 100644 --- a/OpenPolytopia.Server/GameServer.cs +++ b/OpenPolytopia.Server/GameServer.cs @@ -15,6 +15,11 @@ public class GameServer(int port, string? bindAddress = null) : IDisposable { /// 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(); @@ -169,9 +174,16 @@ private async Task ManageCreateLobbyAsync(NetworkConnection connection, CreateLo if (!_playerNames.TryGetValue(connection.Id, out var name)) { result = LobbyActionResult.NotRegistered; } - else if (packet.MaxPlayers is < 2 or > 16) { + 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 { var lobby = _lobbyManager.CreateLobby(packet.MaxPlayers, new LobbyPlayerData { PlayerId = connection.Id, Name = name, Tribe = packet.Tribe }); @@ -201,6 +213,13 @@ private async Task ManageJoinLobbyAsync(NetworkConnection connection, JoinLobbyP 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 }); diff --git a/OpenPolytopia.Server/LobbyManager.cs b/OpenPolytopia.Server/LobbyManager.cs index beea0da6..4f766284 100644 --- a/OpenPolytopia.Server/LobbyManager.cs +++ b/OpenPolytopia.Server/LobbyManager.cs @@ -18,6 +18,11 @@ public class LobbyManager { /// public IReadOnlyCollection Lobbies => _lobbies.Values; + /// + /// Number of lobbies currently on the server + /// + public int LobbiesCount => _lobbies.Count; + /// /// Returns a lobby given its id /// @@ -124,6 +129,12 @@ public LobbyActionResult SetReady(ulong lobbyId, uint playerId, bool ready) { return LobbyActionResult.Ok; } + /// + /// Checks if a player joined any lobby + /// + /// the id of the player + public bool IsPlayerInAnyLobby(uint playerId) => _lobbies.Values.Any(lobby => lobby[playerId] != null); + /// /// Removes a lobby /// diff --git a/OpenPolytopia/src/Lobby.cs b/OpenPolytopia/src/Lobby.cs index 63a511d6..ab6c9648 100644 --- a/OpenPolytopia/src/Lobby.cs +++ b/OpenPolytopia/src/Lobby.cs @@ -1,7 +1,6 @@ namespace OpenPolytopia; using System; -using System.Collections.Specialized; using System.Linq; using Common; using Common.Network.Packets; @@ -31,12 +30,13 @@ public override void _Ready() { BuildUi(); var network = NetworkNode.Instance; - network.Lobbies.CollectionChanged += OnLobbiesChanged; + network.OnLobbiesChanged += OnLobbiesChanged; network.OnLobbyCreated += OnLobbyCreated; network.OnLobbyJoined += OnLobbyJoined; network.OnLobbyLeft += OnLobbyLeft; network.OnReadySet += OnReadySet; network.OnGameStarted += OnGameStarted; + network.OnDisconnected += OnNetworkDisconnected; network.RefreshLobbies(); RefreshList(); @@ -47,12 +47,13 @@ public override void _ExitTree() { base._ExitTree(); var network = NetworkNode.Instance; - network.Lobbies.CollectionChanged -= OnLobbiesChanged; + network.OnLobbiesChanged -= OnLobbiesChanged; network.OnLobbyCreated -= OnLobbyCreated; network.OnLobbyJoined -= OnLobbyJoined; network.OnLobbyLeft -= OnLobbyLeft; network.OnReadySet -= OnReadySet; network.OnGameStarted -= OnGameStarted; + network.OnDisconnected -= OnNetworkDisconnected; } private void BuildUi() { @@ -107,11 +108,20 @@ private void BuildUi() { bottomBar.AddChild(_statusLabel); } - private void OnLobbiesChanged(object? sender, NotifyCollectionChangedEventArgs e) { + private void OnLobbiesChanged() { RefreshList(); UpdateButtons(); } + private void OnNetworkDisconnected() { + // reset the joined state, its server side is gone + _joined = false; + _joinedLobbyId = 0; + _readyButton.SetPressedNoSignal(false); + _statusLabel.Text = "Disconnected from the server"; + UpdateButtons(); + } + private void RefreshList() { var network = NetworkNode.Instance; diff --git a/OpenPolytopia/src/NetworkNode.cs b/OpenPolytopia/src/NetworkNode.cs index dac636f0..ff33c639 100644 --- a/OpenPolytopia/src/NetworkNode.cs +++ b/OpenPolytopia/src/NetworkNode.cs @@ -1,7 +1,7 @@ namespace OpenPolytopia; using System; -using System.Collections.ObjectModel; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -39,7 +39,7 @@ public partial class NetworkNode : Node { private static ClientConnection? _connection; private static bool _handshakeDone; private static uint _playerId; - private static readonly ObservableCollection _lobbies = []; + private static readonly List _lobbies = []; private static int _disconnectedFlag; /// @@ -84,7 +84,12 @@ public partial class NetworkNode : Node { /// /// Lobbies data; it updates when the server broadcasts lobby changes /// - public ObservableCollection Lobbies => _lobbies; + public IReadOnlyList Lobbies => _lobbies; + + /// + /// Fired once after every change to + /// + public event Action? OnLobbiesChanged; /// /// Fired after a successful handshake @@ -150,6 +155,11 @@ public override void _PhysicsProcess(double delta) { var connection = _connection; if (connection == null) { + // surface a connection attempt that failed before being established + if (Interlocked.Exchange(ref _disconnectedFlag, 0) == 1) { + OnDisconnected?.Invoke(); + } + return; } @@ -168,6 +178,7 @@ public override void _PhysicsProcess(double delta) { connection.Dispose(); _connection = null; _lobbies.Clear(); + OnLobbiesChanged?.Invoke(); OnDisconnected?.Invoke(); } } @@ -196,6 +207,7 @@ public void Disconnect() { _connection = null; _handshakeDone = false; _lobbies.Clear(); + OnLobbiesChanged?.Invoke(); // consume the disconnection signalled by disposing the connection Interlocked.Exchange(ref _disconnectedFlag, 0); @@ -292,22 +304,26 @@ private static async Task ConnectToServerAsync(string host, int port) { catch (Exception e) { GD.PushError($"Error while connecting to {host}:{port}: {e}"); - // remove the connection to let a new node retry + // remove the connection and surface the failure to let a scene retry if (_connection == connection) { _connection = null; + Interlocked.Exchange(ref _disconnectedFlag, 1); } connection.Dispose(); } } - private void Send(IPacket packet) { - if (_connection == null) { + private static void Send(IPacket packet) { + var connection = _connection; + + // wait for the handshake, or packets could get lost or beat the handshake onto the wire + if (connection == null || !_handshakeDone) { GD.PushError("Not connected to the server"); return; } - _ = SendAsync(_connection, packet); + _ = SendAsync(connection, packet); } private static async Task SendAsync(ClientConnection connection, IPacket packet) { @@ -340,10 +356,8 @@ private void ProcessPacket(IPacket packet) { break; case GetLobbiesResponsePacket lobbiesResponse: _lobbies.Clear(); - foreach (var lobby in lobbiesResponse.Lobbies) { - _lobbies.Add(lobby); - } - + _lobbies.AddRange(lobbiesResponse.Lobbies); + OnLobbiesChanged?.Invoke(); break; case CreateLobbyResponsePacket createLobbyResponse: OnLobbyCreated?.Invoke(createLobbyResponse.Result, createLobbyResponse.LobbyId); @@ -358,19 +372,20 @@ private void ProcessPacket(IPacket packet) { OnReadySet?.Invoke(setReadyResponse.Result, setReadyResponse.LobbyId); break; case LobbyUpdatedPacket lobbyUpdated: - // remove the old lobby data if present - var oldLobby = _lobbies.FirstOrDefault(lobby => lobby.Id == lobbyUpdated.Lobby.Id); - if (oldLobby != null) { - _lobbies.Remove(oldLobby); + // replace the old lobby data in place if present + var oldIndex = _lobbies.FindIndex(lobby => lobby.Id == lobbyUpdated.Lobby.Id); + if (oldIndex >= 0) { + _lobbies[oldIndex] = lobbyUpdated.Lobby; + } + else { + _lobbies.Add(lobbyUpdated.Lobby); } - // add back the lobby data to force the list to emit the event - _lobbies.Add(lobbyUpdated.Lobby); + OnLobbiesChanged?.Invoke(); break; case LobbyDeletedPacket lobbyDeleted: - var deletedLobby = _lobbies.FirstOrDefault(lobby => lobby.Id == lobbyDeleted.LobbyId); - if (deletedLobby != null) { - _lobbies.Remove(deletedLobby); + if (_lobbies.RemoveAll(lobby => lobby.Id == lobbyDeleted.LobbyId) > 0) { + OnLobbiesChanged?.Invoke(); } break; From 41ccac03e0668200fda1d8c405b3f07fbc5f5d7c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 21:07:24 +0000 Subject: [PATCH 08/11] Fix the issues found in the third code review - Mark a lobby as starting also when the last not-ready player leaves or disconnects, not only on a ready change - Deliver server packets through one ordered queue per client, framed and enqueued while holding the state lock, so lobby updates can't get reordered; broadcasts now reach only handshaked clients - Kick clients that don't complete a handshake within 10 seconds - Queue client packets sent before the handshake completes and flush them right after it, instead of dropping them - Propagate a rename into the lobbies the player joined - Clear NetworkNode.Instance when the active node exits the tree and grab the instance once per scene instead of on every access - Remove the unused List and List wire helpers - Cover the read side of the wire format and the remaining packets with tests Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX --- .../Network/NetworkSerialization.cs | 29 --- .../Network/ServerConnection.cs | 177 ++++++++++------ OpenPolytopia.Server/GameServer.cs | 192 +++++++----------- OpenPolytopia.Server/LobbyManager.cs | 40 +++- OpenPolytopia/src/Game.cs | 13 +- OpenPolytopia/src/Lobby.cs | 22 +- OpenPolytopia/src/NetworkNode.cs | 35 +++- OpenPolytopia/test/src/PacketTest.cs | 133 ++++++++++++ 8 files changed, 415 insertions(+), 226 deletions(-) diff --git a/OpenPolytopia.Common/Network/NetworkSerialization.cs b/OpenPolytopia.Common/Network/NetworkSerialization.cs index e2ffe082..7d3dc3bd 100644 --- a/OpenPolytopia.Common/Network/NetworkSerialization.cs +++ b/OpenPolytopia.Common/Network/NetworkSerialization.cs @@ -102,33 +102,4 @@ public static void Deserialize(this List list, byte[] bytes, ref uint inde } } - public static void Serialize(this List list, List bytes) { - ((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) { - var length = UIntSerialization.Read(bytes, ref index); - - for (var i = 0; i < length; i++) { - list.Add(UIntSerialization.Read(bytes, ref index)); - } - } - - public static void Serialize(this List list, List bytes) { - ((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) { - var length = UIntSerialization.Read(bytes, ref index); - - for (var i = 0; i < length; i++) { - list.Add(StringSerialization.Read(bytes, ref index)); - } - } } diff --git a/OpenPolytopia.Common/Network/ServerConnection.cs b/OpenPolytopia.Common/Network/ServerConnection.cs index 5b97a808..a6c7cbde 100644 --- a/OpenPolytopia.Common/Network/ServerConnection.cs +++ b/OpenPolytopia.Common/Network/ServerConnection.cs @@ -2,34 +2,34 @@ 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 queue per client, so they get delivered +/// in the order they were enqueued and a slow client can't delay the others. /// Every it sends a to every client -/// and disconnects the ones that didn't send anything back for longer than +/// 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 readonly TcpListener _listener = bindAddress == null ? TcpListener.Create(port) : new TcpListener(System.Net.IPAddress.Parse(bindAddress), port); - private readonly ConcurrentDictionary _connections = new(); + private readonly ConcurrentDictionary _clients = new(); private readonly CancellationTokenSource _cts = new(); private uint _nextId; - /// - /// All the currently connected clients - /// - public IReadOnlyDictionary Connections => _connections; - /// /// Fired when a new client connects /// @@ -63,9 +63,9 @@ public async Task RunAsync() { try { while (!_cts.IsCancellationRequested) { - TcpClient client; + TcpClient tcpClient; try { - client = await _listener.AcceptTcpClientAsync(_cts.Token); + tcpClient = await _listener.AcceptTcpClientAsync(_cts.Token); } catch (SocketException e) { // transient failure, like a connection aborted mid-handshake; keep accepting the others @@ -75,15 +75,18 @@ public async Task RunAsync() { var id = Interlocked.Increment(ref _nextId); - var connection = new NetworkConnection(id, client); + var connection = new NetworkConnection(id, tcpClient); connection.OnPacketReceived += ClientPacketReceivedAsync; connection.OnDisconnected += ClientDisconnected; - _connections[id] = connection; + + 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) { @@ -92,8 +95,8 @@ public async Task RunAsync() { finally { _listener.Stop(); - foreach (var connection in _connections.Values) { - connection.Close(); + foreach (var client in _clients.Values) { + client.Connection.Close(); } } } @@ -104,72 +107,105 @@ public async Task RunAsync() { public void Stop() => _cts.Cancel(); /// - /// Sends a packet to a single client + /// Marks a client as having completed the handshake /// /// - /// If the send fails or takes longer than , the client gets disconnected + /// Only handshaked clients receive broadcasts and keep alive packets; + /// the others get disconnected after /// /// the id of the client - /// the packet to send - public async Task SendToAsync(uint id, IPacket packet) => await SendToAsync(id, PacketProtocol.FramePacket(packet)); + public void CompleteHandshake(uint id) { + if (_clients.TryGetValue(id, out var client)) { + client.HandshakeDone = true; + } + } /// - /// Sends an already framed packet to a single client + /// Checks if a client completed the handshake /// - /// - /// If the send fails or takes longer than , the client gets disconnected - /// /// the id of the client - /// the framed packet to send - public async Task SendToAsync(uint id, byte[] frame) { - if (!_connections.TryGetValue(id, out var connection)) { - return; + 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.Outgoing.Writer.TryComplete(); } + } - // timeout the send so a client that stopped reading can't block the server - using var cts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token); - cts.CancelAfter(SEND_TIMEOUT); + /// + /// 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)); - try { - await connection.SendFrameAsync(frame, cts.Token); - } - catch (Exception) { - connection.Close(); + /// + /// 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)) { + client.Outgoing.Writer.TryWrite(frame); } } /// - /// Sends a packet to every connected client + /// Queues a packet for every handshaked client /// /// the packet to broadcast - public async Task BroadcastAsync(IPacket packet) => await BroadcastAsync(PacketProtocol.FramePacket(packet)); + public void Broadcast(IPacket packet) => Broadcast(PacketProtocol.FramePacket(packet)); /// - /// Sends an already framed packet to every connected client + /// Queues an already framed packet for every handshaked client /// - /// - /// The packet is framed once and the sends run concurrently, - /// so a slow client can't delay the others - /// /// the framed packet to broadcast - public async Task BroadcastAsync(byte[] frame) => - await Task.WhenAll(_connections.Keys.Select(id => SendToAsync(id, frame))); + public void Broadcast(byte[] frame) { + foreach (var client in _clients.Values) { + if (client.HandshakeDone) { + client.Outgoing.Writer.TryWrite(frame); + } + } + } /// - /// Sends a packet to the given clients + /// Queues a packet for the given clients /// /// the ids of the clients /// the packet to send - public async Task BroadcastToAsync(IEnumerable ids, IPacket packet) => - await BroadcastToAsync(ids, PacketProtocol.FramePacket(packet)); + public void BroadcastTo(IEnumerable ids, IPacket packet) { + var frame = PacketProtocol.FramePacket(packet); + foreach (var id in ids) { + SendTo(id, frame); + } + } /// - /// Sends an already framed packet to the given clients + /// Sends the queued packets of a client one at a time, in order /// - /// the ids of the clients - /// the framed packet to send - public async Task BroadcastToAsync(IEnumerable ids, byte[] frame) => - await Task.WhenAll(ids.Select(id => SendToAsync(id, frame))); + /// + /// 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 @@ -184,7 +220,11 @@ private async Task ClientPacketReceivedAsync(NetworkConnection connection, IPack } private void ClientDisconnected(NetworkConnection connection) { - _connections.TryRemove(connection.Id, out _); + if (_clients.TryRemove(connection.Id, out var client)) { + // stop the sender loop + client.Outgoing.Writer.TryComplete(); + } + OnClientDisconnected?.Invoke(connection); } @@ -196,19 +236,25 @@ private async Task KeepAliveLoopAsync(CancellationToken ct) { while (await timer.WaitForNextTickAsync(ct)) { var now = DateTime.UtcNow; - List sends = []; - foreach (var connection in _connections.Values) { + foreach (var client in _clients.Values) { // kick clients that timed out - if (now - connection.LastReceived > TIMEOUT) { - connection.Close(); + if (now - client.Connection.LastReceived > TIMEOUT) { + client.Connection.Close(); continue; } - sends.Add(SendToAsync(connection.Id, keepAlive)); - } + // kick clients that connected but never completed a handshake + if (!client.HandshakeDone) { + if (now - client.ConnectedAt > HANDSHAKE_TIMEOUT) { + client.Connection.Close(); + } + + continue; + } - await Task.WhenAll(sends); + SendTo(client.Connection.Id, keepAlive); + } } } catch (OperationCanceledException) { @@ -222,4 +268,19 @@ public void 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; + + /// + /// Packets waiting to be sent to this client + /// + public Channel Outgoing { get; } = + Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + } } diff --git a/OpenPolytopia.Server/GameServer.cs b/OpenPolytopia.Server/GameServer.cs index 99a5e9dd..c7e2bcfb 100644 --- a/OpenPolytopia.Server/GameServer.cs +++ b/OpenPolytopia.Server/GameServer.cs @@ -7,6 +7,10 @@ namespace OpenPolytopia.Server; /// /// 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 { @@ -23,9 +27,8 @@ public class GameServer(int port, string? bindAddress = null) : IDisposable { private readonly ServerConnection _server = new(port, bindAddress); private readonly LobbyManager _lobbyManager = new(); private readonly Dictionary _playerNames = new(); - private readonly HashSet _handshakeDone = []; - // guards _lobbyManager, _playerNames and _handshakeDone: packet handlers run on many client tasks + // guards _lobbyManager and _playerNames: packet handlers run on many client tasks private readonly SemaphoreSlim _stateLock = new(1, 1); private readonly CancellationTokenSource _cts = new(); @@ -64,21 +67,12 @@ private async Task ManagePacketAsync(NetworkConnection connection, IPacket packe private async Task DispatchPacketAsync(NetworkConnection connection, IPacket packet) { if (packet is HandshakePacket handshake) { - await ManageHandshakeAsync(connection, handshake); + ManageHandshake(connection, handshake); return; } // kick clients that send anything else before a successful handshake - bool handshakeDone; - await _stateLock.WaitAsync(); - try { - handshakeDone = _handshakeDone.Contains(connection.Id); - } - finally { - _stateLock.Release(); - } - - if (!handshakeDone) { + if (!_server.IsHandshakeDone(connection.Id)) { connection.Close(); return; } @@ -111,24 +105,18 @@ private async Task DispatchPacketAsync(NetworkConnection connection, IPacket pac } } - private async Task ManageHandshakeAsync(NetworkConnection connection, HandshakePacket packet) { + private void ManageHandshake(NetworkConnection connection, HandshakePacket packet) { var ok = packet.Version == NetworkConstants.VERSION; if (ok) { - await _stateLock.WaitAsync(); - try { - _handshakeDone.Add(connection.Id); - } - finally { - _stateLock.Release(); - } + _server.CompleteHandshake(connection.Id); } - await _server.SendToAsync(connection.Id, new HandshakeResponsePacket { Ok = ok, PlayerId = connection.Id }); + _server.SendTo(connection.Id, new HandshakeResponsePacket { Ok = ok, PlayerId = connection.Id }); - // kick clients with an incompatible version + // kick clients with an incompatible version, after the response gets delivered if (!ok) { - connection.Close(); + _server.Kick(connection.Id); } } @@ -136,41 +124,42 @@ private async Task ManageSetNameAsync(NetworkConnection connection, SetNamePacke var name = packet.Name.Trim(); var ok = name.Length is > 0 and <= 32; - if (ok) { - await _stateLock.WaitAsync(); - try { + 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(PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby })); + } } - finally { - _stateLock.Release(); - } - } - await _server.SendToAsync(connection.Id, new SetNameResponsePacket { Ok = ok }); + _server.SendTo(connection.Id, new SetNameResponsePacket { Ok = ok }); + } + finally { + _stateLock.Release(); + } } private async Task ManageGetLobbiesAsync(NetworkConnection connection) { - byte[] response; - - // frame the packet while holding the lock, the lobby data is shared await _stateLock.WaitAsync(); try { - response = PacketProtocol.FramePacket(new GetLobbiesResponsePacket { Lobbies = [.. _lobbyManager.Lobbies] }); + _server.SendTo(connection.Id, new GetLobbiesResponsePacket { Lobbies = [.. _lobbyManager.Lobbies] }); } finally { _stateLock.Release(); } - - await _server.SendToAsync(connection.Id, response); } private async Task ManageCreateLobbyAsync(NetworkConnection connection, CreateLobbyPacket packet) { - var lobbyId = 0ul; - byte[]? lobbyUpdate = null; - var result = LobbyActionResult.Ok; - await _stateLock.WaitAsync(); try { + LobbyData? lobby = null; + LobbyActionResult result; + if (!_playerNames.TryGetValue(connection.Id, out var name)) { result = LobbyActionResult.NotRegistered; } @@ -185,31 +174,27 @@ private async Task ManageCreateLobbyAsync(NetworkConnection connection, CreateLo result = LobbyActionResult.TooManyLobbies; } else { - var lobby = _lobbyManager.CreateLobby(packet.MaxPlayers, + result = LobbyActionResult.Ok; + lobby = _lobbyManager.CreateLobby(packet.MaxPlayers, new LobbyPlayerData { PlayerId = connection.Id, Name = name, Tribe = packet.Tribe }); - lobbyId = lobby.Id; + } + + _server.SendTo(connection.Id, new CreateLobbyResponsePacket { Result = result, LobbyId = lobby?.Id ?? 0 }); - // frame the broadcast while holding the lock, the lobby data is shared - lobbyUpdate = PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby }); + if (lobby != null) { + _server.Broadcast(PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby })); } } finally { _stateLock.Release(); } - - await _server.SendToAsync(connection.Id, new CreateLobbyResponsePacket { Result = result, LobbyId = lobbyId }); - - if (lobbyUpdate != null) { - await _server.BroadcastAsync(lobbyUpdate); - } } private async Task ManageJoinLobbyAsync(NetworkConnection connection, JoinLobbyPacket packet) { - byte[]? lobbyUpdate = null; - LobbyActionResult result; - await _stateLock.WaitAsync(); try { + LobbyActionResult result; + if (!_playerNames.TryGetValue(connection.Id, out var name)) { result = LobbyActionResult.NotRegistered; } @@ -223,103 +208,78 @@ private async Task ManageJoinLobbyAsync(NetworkConnection connection, JoinLobbyP else { result = _lobbyManager.JoinLobby(packet.LobbyId, new LobbyPlayerData { PlayerId = connection.Id, Name = name, Tribe = packet.Tribe }); - var lobby = _lobbyManager[packet.LobbyId]; + } - if (result == LobbyActionResult.Ok && lobby != null) { - // frame the broadcast while holding the lock, the lobby data is shared - lobbyUpdate = PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby }); - } + _server.SendTo(connection.Id, new JoinLobbyResponsePacket { Result = result, LobbyId = packet.LobbyId }); + + if (result == LobbyActionResult.Ok && _lobbyManager[packet.LobbyId] is { } lobby) { + _server.Broadcast(PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby })); } } finally { _stateLock.Release(); } - - await _server.SendToAsync(connection.Id, new JoinLobbyResponsePacket { Result = result, LobbyId = packet.LobbyId }); - - if (lobbyUpdate != null) { - await _server.BroadcastAsync(lobbyUpdate); - } } private async Task ManageLeaveLobbyAsync(NetworkConnection connection, LeaveLobbyPacket packet) { - byte[]? broadcast = null; - LobbyActionResult result; - await _stateLock.WaitAsync(); try { - result = _lobbyManager.LeaveLobby(packet.LobbyId, connection.Id); + 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; frame the broadcast while holding the lock + // remove the lobby if it became empty if (lobby.PlayersCount == 0) { _lobbyManager.RemoveLobby(lobby.Id); - broadcast = PacketProtocol.FramePacket(new LobbyDeletedPacket { LobbyId = lobby.Id }); + _server.Broadcast(PacketProtocol.FramePacket(new LobbyDeletedPacket { LobbyId = lobby.Id })); } else { - broadcast = PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby }); + _server.Broadcast(PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby })); } } } finally { _stateLock.Release(); } - - await _server.SendToAsync(connection.Id, - new LeaveLobbyResponsePacket { Result = result, LobbyId = packet.LobbyId }); - - if (broadcast != null) { - await _server.BroadcastAsync(broadcast); - } } private async Task ManageSetReadyAsync(NetworkConnection connection, SetReadyPacket packet) { - byte[]? lobbyUpdate = null; - LobbyActionResult result; - await _stateLock.WaitAsync(); try { - result = _lobbyManager.SetReady(packet.LobbyId, connection.Id, packet.Ready); + 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) { - // frame the broadcast while holding the lock, the lobby data is shared - lobbyUpdate = PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby }); + _server.Broadcast(PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby })); } } finally { _stateLock.Release(); } - - await _server.SendToAsync(connection.Id, new SetReadyResponsePacket { Result = result, LobbyId = packet.LobbyId }); - - if (lobbyUpdate != null) { - await _server.BroadcastAsync(lobbyUpdate); - } } private async Task ClientDisconnectedAsync(NetworkConnection connection) { - List broadcasts = []; - await _stateLock.WaitAsync(); try { - _handshakeDone.Remove(connection.Id); _playerNames.Remove(connection.Id); List updated = []; List deletedIds = []; _lobbyManager.RemovePlayerFromAllLobbies(connection.Id, updated, deletedIds); - // frame the broadcasts while holding the lock, the lobby data is shared - broadcasts.AddRange(updated.Select(lobby => PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby }))); - broadcasts.AddRange(deletedIds.Select(id => PacketProtocol.FramePacket(new LobbyDeletedPacket { LobbyId = id }))); + foreach (var lobby in updated) { + _server.Broadcast(PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby })); + } + + foreach (var id in deletedIds) { + _server.Broadcast(PacketProtocol.FramePacket(new LobbyDeletedPacket { LobbyId = id })); + } } finally { _stateLock.Release(); } - - foreach (var broadcast in broadcasts) { - await _server.BroadcastAsync(broadcast); - } } private async Task StartLobbiesLoopAsync(CancellationToken ct) { @@ -327,28 +287,24 @@ private async Task StartLobbiesLoopAsync(CancellationToken ct) { try { while (await timer.WaitForNextTickAsync(ct)) { - List starting; - await _stateLock.WaitAsync(ct); try { - starting = _lobbyManager.TakeStartingLobbies(); + 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(); } - - foreach (var lobby in starting) { - // 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 - await _server.BroadcastToAsync(lobby.Players.Select(player => player.PlayerId), - new GameStartedPacket { LobbyId = lobby.Id, Players = lobby.Players }); - await _server.BroadcastAsync(new LobbyDeletedPacket { LobbyId = lobby.Id }); - } } } catch (OperationCanceledException) { diff --git a/OpenPolytopia.Server/LobbyManager.cs b/OpenPolytopia.Server/LobbyManager.cs index 4f766284..c7f3c225 100644 --- a/OpenPolytopia.Server/LobbyManager.cs +++ b/OpenPolytopia.Server/LobbyManager.cs @@ -91,6 +91,9 @@ public LobbyActionResult LeaveLobby(ulong lobbyId, uint playerId) { } lobby.Players.Remove(player); + + // the leaving player could be the last not-ready one + TryMarkStarting(lobby); return LobbyActionResult.Ok; } @@ -120,13 +123,22 @@ public LobbyActionResult SetReady(ulong lobbyId, uint playerId, bool ready) { } player.Ready = ready; + TryMarkStarting(lobby); + return LobbyActionResult.Ok; + } - // start the game when all players are ready - if (lobby.ReadyCount == lobby.PlayersCount) { + /// + /// Marks a lobby as starting when every player in it is ready + /// + /// + /// Called after every change to the players of a lobby, + /// because removing the last not-ready player must start it too + /// + /// the lobby to check + private static void TryMarkStarting(LobbyData lobby) { + if (lobby.PlayersCount > 0 && lobby.ReadyCount == lobby.PlayersCount) { lobby.Starting = true; } - - return LobbyActionResult.Ok; } /// @@ -135,6 +147,24 @@ public LobbyActionResult SetReady(ulong lobbyId, uint playerId, bool ready) { /// 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 /// @@ -169,6 +199,8 @@ public void RemovePlayerFromAllLobbies(uint playerId, List updated, L deleted.Add(lobby.Id); } else { + // the disconnected player could be the last not-ready one + TryMarkStarting(lobby); updated.Add(lobby); } } diff --git a/OpenPolytopia/src/Game.cs b/OpenPolytopia/src/Game.cs index c812ec23..d3b32950 100644 --- a/OpenPolytopia/src/Game.cs +++ b/OpenPolytopia/src/Game.cs @@ -5,16 +5,21 @@ namespace OpenPolytopia; public partial class Game : Control { [Export] public PackedScene? LobbyScene; + private NetworkNode _network = null!; private string _playerName = ""; private bool _switching; - public override void _Ready() => + public override void _Ready() { + // grab the instance once, the node could leave the tree before this scene + _network = NetworkNode.Instance!; + // switch to the lobby scene once the server accepts the player's name - NetworkNode.Instance.OnNameSet += OnNameSet; + _network.OnNameSet += OnNameSet; + } public override void _ExitTree() { base._ExitTree(); - NetworkNode.Instance.OnNameSet -= OnNameSet; + _network.OnNameSet -= OnNameSet; } /// @@ -33,7 +38,7 @@ private void OnPlayPressed() { _playerName = $"Player{GD.Randi() % 10000}"; } - NetworkNode.Instance.SetName(_playerName); + _network.SetName(_playerName); } private void OnNameSet(bool ok) { diff --git a/OpenPolytopia/src/Lobby.cs b/OpenPolytopia/src/Lobby.cs index ab6c9648..cba3b7c4 100644 --- a/OpenPolytopia/src/Lobby.cs +++ b/OpenPolytopia/src/Lobby.cs @@ -23,13 +23,17 @@ public partial class Lobby : Control { private CheckButton _readyButton = null!; private Label _statusLabel = null!; + private NetworkNode _network = null!; private ulong _joinedLobbyId; private bool _joined; public override void _Ready() { BuildUi(); - var network = NetworkNode.Instance; + // grab the instance once, the node could leave the tree before this scene + _network = NetworkNode.Instance!; + var network = _network; + network.OnLobbiesChanged += OnLobbiesChanged; network.OnLobbyCreated += OnLobbyCreated; network.OnLobbyJoined += OnLobbyJoined; @@ -46,7 +50,7 @@ public override void _Ready() { public override void _ExitTree() { base._ExitTree(); - var network = NetworkNode.Instance; + var network = _network; network.OnLobbiesChanged -= OnLobbiesChanged; network.OnLobbyCreated -= OnLobbyCreated; network.OnLobbyJoined -= OnLobbyJoined; @@ -82,7 +86,7 @@ private void BuildUi() { topBar.AddChild(_createButton); var refreshButton = new Button { Text = "Refresh" }; - refreshButton.Pressed += () => NetworkNode.Instance.RefreshLobbies(); + refreshButton.Pressed += () => _network.RefreshLobbies(); topBar.AddChild(refreshButton); _lobbyList = new ItemList { SizeFlagsVertical = SizeFlags.ExpandFill }; @@ -123,7 +127,7 @@ private void OnNetworkDisconnected() { } private void RefreshList() { - var network = NetworkNode.Instance; + var network = _network; // remember the selection to restore it after the rebuild var selectedId = SelectedLobby()?.Id; @@ -151,7 +155,7 @@ private void RefreshList() { } var id = _lobbyList.GetItemMetadata(selected[0]).AsUInt64(); - return NetworkNode.Instance.Lobbies.FirstOrDefault(lobby => lobby.Id == id); + return _network.Lobbies.FirstOrDefault(lobby => lobby.Id == id); } private void UpdateButtons() { @@ -162,7 +166,7 @@ private void UpdateButtons() { } private void OnCreatePressed() => - NetworkNode.Instance.CreateLobby(DEFAULT_MAX_PLAYERS, (uint)_tribeButton.GetSelectedId()); + _network.CreateLobby(DEFAULT_MAX_PLAYERS, (uint)_tribeButton.GetSelectedId()); private void OnJoinPressed() { var lobby = SelectedLobby(); @@ -170,14 +174,14 @@ private void OnJoinPressed() { return; } - NetworkNode.Instance.JoinLobby(lobby.Id, (uint)_tribeButton.GetSelectedId()); + _network.JoinLobby(lobby.Id, (uint)_tribeButton.GetSelectedId()); } - private void OnLeavePressed() => NetworkNode.Instance.LeaveLobby(_joinedLobbyId); + private void OnLeavePressed() => _network.LeaveLobby(_joinedLobbyId); private void OnReadyToggled(bool ready) { if (_joined) { - NetworkNode.Instance.SetReady(_joinedLobbyId, ready); + _network.SetReady(_joinedLobbyId, ready); } } diff --git a/OpenPolytopia/src/NetworkNode.cs b/OpenPolytopia/src/NetworkNode.cs index ff33c639..fc5f7bd6 100644 --- a/OpenPolytopia/src/NetworkNode.cs +++ b/OpenPolytopia/src/NetworkNode.cs @@ -26,9 +26,11 @@ public partial class NetworkNode : Node { /// The instance currently processing the packets /// /// - /// It is the last instance that entered the tree + /// It is the last instance that entered the tree, + /// or null when no instance is inside the tree. + /// Nodes that use it across scene changes should grab it once in _Ready /// - public static NetworkNode Instance { get; private set; } = null!; + public static NetworkNode? Instance { get; private set; } private const string HOST_ENV = "OPENPOLYTOPIA_SERVER_HOST"; private const string PORT_ENV = "OPENPOLYTOPIA_SERVER_PORT"; @@ -40,6 +42,7 @@ public partial class NetworkNode : Node { private static bool _handshakeDone; private static uint _playerId; private static readonly List _lobbies = []; + private static readonly Queue _pendingPackets = new(); private static int _disconnectedFlag; /// @@ -143,6 +146,21 @@ public override void _EnterTree() { } } + /// + /// Stops being the active instance when leaving the tree + /// + /// + /// Without this, would keep referencing a freed node + /// after a scene change to a scene without a + /// + public override void _ExitTree() { + base._ExitTree(); + + if (Instance == this) { + Instance = null; + } + } + /// /// Processes the packets received from the server /// @@ -157,6 +175,7 @@ public override void _PhysicsProcess(double delta) { if (connection == null) { // surface a connection attempt that failed before being established if (Interlocked.Exchange(ref _disconnectedFlag, 0) == 1) { + _pendingPackets.Clear(); OnDisconnected?.Invoke(); } @@ -178,6 +197,7 @@ public override void _PhysicsProcess(double delta) { connection.Dispose(); _connection = null; _lobbies.Clear(); + _pendingPackets.Clear(); OnLobbiesChanged?.Invoke(); OnDisconnected?.Invoke(); } @@ -207,6 +227,7 @@ public void Disconnect() { _connection = null; _handshakeDone = false; _lobbies.Clear(); + _pendingPackets.Clear(); OnLobbiesChanged?.Invoke(); // consume the disconnection signalled by disposing the connection @@ -317,9 +338,10 @@ private static async Task ConnectToServerAsync(string host, int port) { private static void Send(IPacket packet) { var connection = _connection; - // wait for the handshake, or packets could get lost or beat the handshake onto the wire + // queue the packet while the handshake is in flight, connecting takes a moment; + // the queue gets flushed after the handshake and dropped on a failed connection if (connection == null || !_handshakeDone) { - GD.PushError("Not connected to the server"); + _pendingPackets.Enqueue(packet); return; } @@ -348,6 +370,11 @@ private void ProcessPacket(IPacket packet) { _handshakeDone = true; OnConnected?.Invoke(); + // send the packets queued while connecting; stop if a handler disconnected + while (_handshakeDone && _pendingPackets.TryDequeue(out var pending)) { + Send(pending); + } + // get the initial lobby list RefreshLobbies(); break; diff --git a/OpenPolytopia/test/src/PacketTest.cs b/OpenPolytopia/test/src/PacketTest.cs index 132745bd..715f79c3 100644 --- a/OpenPolytopia/test/src/PacketTest.cs +++ b/OpenPolytopia/test/src/PacketTest.cs @@ -1,6 +1,8 @@ namespace OpenPolytopia; using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; using Chickensoft.GoDotTest; using Common; using Common.Network; @@ -19,6 +21,11 @@ public class PacketTest(Node testScene) : TestClass(testScene) { return deserialized; } + private static async Task ReadBackAsync(byte[] bytes) { + using var stream = new MemoryStream(bytes); + return await PacketProtocol.ReadPacketAsync(stream); + } + [Test] public void TestHandshake() { var packet = RoundTrip(new HandshakePacket { Version = "0.1.0" }); @@ -44,6 +51,18 @@ public void TestSetName() { packet.Name.ShouldBe("Tester àèù"); } + [Test] + public void TestSetNameResponse() { + var packet = RoundTrip(new SetNameResponsePacket { Ok = true }); + packet.Ok.ShouldBeTrue(); + } + + [Test] + public void TestGetLobbies() { + var packet = RoundTrip(new GetLobbiesPacket()); + packet.ShouldNotBeNull(); + } + [Test] public void TestGetLobbiesResponse() { var lobby = new LobbyData { Id = 123, MaxPlayers = 4 }; @@ -74,6 +93,13 @@ public void TestCreateLobbyResponse() { packet.LobbyId.ShouldBe(99u); } + [Test] + public void TestJoinLobby() { + var packet = RoundTrip(new JoinLobbyPacket { LobbyId = 21, Tribe = 4 }); + packet.LobbyId.ShouldBe(21u); + packet.Tribe.ShouldBe(4u); + } + [Test] public void TestJoinLobbyResponse() { var packet = RoundTrip(new JoinLobbyResponsePacket { Result = LobbyActionResult.LobbyFull, LobbyId = 5 }); @@ -81,6 +107,19 @@ public void TestJoinLobbyResponse() { packet.LobbyId.ShouldBe(5u); } + [Test] + public void TestLeaveLobby() { + var packet = RoundTrip(new LeaveLobbyPacket { LobbyId = 33 }); + packet.LobbyId.ShouldBe(33u); + } + + [Test] + public void TestLeaveLobbyResponse() { + var packet = RoundTrip(new LeaveLobbyResponsePacket { Result = LobbyActionResult.NotInLobby, LobbyId = 33 }); + packet.Result.ShouldBe(LobbyActionResult.NotInLobby); + packet.LobbyId.ShouldBe(33u); + } + [Test] public void TestSetReady() { var packet = RoundTrip(new SetReadyPacket { LobbyId = 11, Ready = true }); @@ -88,6 +127,30 @@ public void TestSetReady() { packet.Ready.ShouldBeTrue(); } + [Test] + public void TestSetReadyResponse() { + var packet = RoundTrip(new SetReadyResponsePacket { Result = LobbyActionResult.Ok, LobbyId = 11 }); + packet.Result.ShouldBe(LobbyActionResult.Ok); + packet.LobbyId.ShouldBe(11u); + } + + [Test] + public void TestLobbyUpdated() { + var lobby = new LobbyData { Id = 55, MaxPlayers = 2 }; + lobby.Players.Add(new LobbyPlayerData { PlayerId = 9, Name = "Test", Tribe = 1 }); + var packet = RoundTrip(new LobbyUpdatedPacket { Lobby = lobby }); + packet.Lobby.Id.ShouldBe(55u); + packet.Lobby.MaxPlayers.ShouldBe(2u); + packet.Lobby.Players.Count.ShouldBe(1); + packet.Lobby.Players[0].Name.ShouldBe("Test"); + } + + [Test] + public void TestLobbyDeleted() { + var packet = RoundTrip(new LobbyDeletedPacket { LobbyId = 55 }); + packet.LobbyId.ShouldBe(55u); + } + [Test] public void TestGameStarted() { var packet = RoundTrip(new GameStartedPacket { @@ -113,4 +176,74 @@ public void TestFraming() { var packetId = UIntSerialization.Read(buffer, ref index); packetId.ShouldBe(1u); } + + [Test] + public async Task TestReadPacket() { + PacketRegistrar.RegisterAllPackets(); + var packet = await ReadBackAsync(PacketProtocol.FramePacket(new SetNamePacket { Name = "Tester" })); + packet.ShouldBeOfType().Name.ShouldBe("Tester"); + } + + [Test] + public async Task TestReadManyPackets() { + PacketRegistrar.RegisterAllPackets(); + List bytes = []; + PacketProtocol.FramePacket(new HandshakePacket { Version = "0.1.0" }, bytes); + PacketProtocol.FramePacket(new SetReadyPacket { LobbyId = 7, Ready = true }, bytes); + + using var stream = new MemoryStream(bytes.ToArray()); + (await PacketProtocol.ReadPacketAsync(stream)).ShouldBeOfType().Version.ShouldBe("0.1.0"); + var setReady = (await PacketProtocol.ReadPacketAsync(stream)).ShouldBeOfType(); + setReady.LobbyId.ShouldBe(7u); + setReady.Ready.ShouldBeTrue(); + } + + [Test] + public async Task TestReadSkipsUnknownPacket() { + PacketRegistrar.RegisterAllPackets(); + + // [content length][unregistered packet id][payload], followed by a valid packet + List bytes = []; + 8u.Serialize(bytes); + 0xDEADBEEFu.Serialize(bytes); + 0u.Serialize(bytes); + PacketProtocol.FramePacket(new KeepAlivePacket(), bytes); + + using var stream = new MemoryStream(bytes.ToArray()); + (await PacketProtocol.ReadPacketAsync(stream)).ShouldBeNull(); + + // the payload of the unknown packet must be consumed too + (await PacketProtocol.ReadPacketAsync(stream)).ShouldBeOfType(); + } + + [Test] + public async Task TestReadRejectsTinyPacket() { + // a packet must contain at least the 4 bytes of the packet id + List bytes = []; + 3u.Serialize(bytes); + await Should.ThrowAsync(() => ReadBackAsync([.. bytes])); + } + + [Test] + public async Task TestReadRejectsOversizedPacket() { + List bytes = []; + (NetworkConstants.MAX_PACKET_SIZE + 1).Serialize(bytes); + await Should.ThrowAsync(() => ReadBackAsync([.. bytes])); + } + + [Test] + public async Task TestReadRejectsMalformedPayload() { + PacketRegistrar.RegisterAllPackets(); + + // a SetNamePacket whose string claims more bytes than the packet contains + List content = []; + PacketRegistrar.GetPacketId(new SetNamePacket()).Serialize(content); + 100u.Serialize(content); + + List bytes = []; + ((uint)content.Count).Serialize(bytes); + bytes.AddRange(content); + + await Should.ThrowAsync(() => ReadBackAsync([.. bytes])); + } } From 8dea974f6a71d5cbf492f19474e2cafdf0a447f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 21:08:20 +0000 Subject: [PATCH 09/11] Fix a word the spellchecker doesn't accept Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX --- OpenPolytopia.Common/Network/ServerConnection.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/OpenPolytopia.Common/Network/ServerConnection.cs b/OpenPolytopia.Common/Network/ServerConnection.cs index a6c7cbde..f4730bdc 100644 --- a/OpenPolytopia.Common/Network/ServerConnection.cs +++ b/OpenPolytopia.Common/Network/ServerConnection.cs @@ -110,7 +110,7 @@ public async Task RunAsync() { /// Marks a client as having completed the handshake /// /// - /// Only handshaked clients receive broadcasts and keep alive packets; + /// Only clients that completed the handshake receive broadcasts and keep alive packets; /// the others get disconnected after /// /// the id of the client @@ -155,13 +155,13 @@ public void SendTo(uint id, byte[] frame) { } /// - /// Queues a packet for every handshaked client + /// 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 handshaked client + /// Queues an already framed packet for every client that completed the handshake /// /// the framed packet to broadcast public void Broadcast(byte[] frame) { From e1156d7240f02ff1cd5a070e694c4071a317bf64 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 05:43:18 +0000 Subject: [PATCH 10/11] Fix the issues found in the fourth code review - Reconnect automatically after a connection loss and drop the packets sent while there is no connection instead of queueing them forever - Bound the per-client outgoing queue and disconnect a client that doesn't drain it, so slow readers can't grow the server memory - Require at least 2 players to start a game, solo games aren't allowed - Surface a refused name and a connection loss in the title screen and limit the name input to the length the server accepts - Remove the dead lobby.Started guards, started lobbies leave the manager - Use the Broadcast(IPacket) overload instead of framing at the call sites Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VibpGQTdAC848Y4f8gFdrX --- .../Network/ServerConnection.cs | 36 ++++++++++++++++--- OpenPolytopia.Server/GameServer.cs | 16 ++++----- OpenPolytopia.Server/LobbyManager.cs | 27 +++++++------- OpenPolytopia/src/Game.cs | 20 ++++++++++- OpenPolytopia/src/Game.tscn | 5 +++ OpenPolytopia/src/NetworkNode.cs | 25 +++++++++++-- 6 files changed, 101 insertions(+), 28 deletions(-) diff --git a/OpenPolytopia.Common/Network/ServerConnection.cs b/OpenPolytopia.Common/Network/ServerConnection.cs index f4730bdc..36a36229 100644 --- a/OpenPolytopia.Common/Network/ServerConnection.cs +++ b/OpenPolytopia.Common/Network/ServerConnection.cs @@ -9,8 +9,9 @@ namespace OpenPolytopia.Common.Network; /// Accepts TCP connections and manages one per client /// /// -/// Outgoing packets go through one queue per client, so they get delivered -/// in the order they were enqueued and a slow client can't delay the others. +/// 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 @@ -23,6 +24,11 @@ public class ServerConnection(int port, string? bindAddress = null) : IDisposabl private static readonly TimeSpan HANDSHAKE_TIMEOUT = TimeSpan.FromSeconds(10); private static readonly TimeSpan SEND_TIMEOUT = TimeSpan.FromSeconds(10); + /// + /// 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); @@ -132,6 +138,7 @@ public void CompleteHandshake(uint id) { /// the id of the client public void Kick(uint id) { if (_clients.TryGetValue(id, out var client)) { + client.Kicked = true; client.Outgoing.Writer.TryComplete(); } } @@ -150,7 +157,7 @@ public void Kick(uint id) { /// the framed packet to send public void SendTo(uint id, byte[] frame) { if (_clients.TryGetValue(id, out var client)) { - client.Outgoing.Writer.TryWrite(frame); + Enqueue(client, frame); } } @@ -167,7 +174,7 @@ public void SendTo(uint id, byte[] frame) { public void Broadcast(byte[] frame) { foreach (var client in _clients.Values) { if (client.HandshakeDone) { - client.Outgoing.Writer.TryWrite(frame); + Enqueue(client, frame); } } } @@ -184,6 +191,21 @@ public void BroadcastTo(IEnumerable ids, IPacket packet) { } } + /// + /// 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 /// @@ -276,11 +298,15 @@ 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.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + Channel.CreateBounded(new BoundedChannelOptions(MAX_QUEUED_FRAMES) { SingleReader = true }); } } diff --git a/OpenPolytopia.Server/GameServer.cs b/OpenPolytopia.Server/GameServer.cs index c7e2bcfb..ec4a527f 100644 --- a/OpenPolytopia.Server/GameServer.cs +++ b/OpenPolytopia.Server/GameServer.cs @@ -133,7 +133,7 @@ private async Task ManageSetNameAsync(NetworkConnection connection, SetNamePacke List updated = []; _lobbyManager.RenamePlayerInLobbies(connection.Id, name, updated); foreach (var lobby in updated) { - _server.Broadcast(PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby })); + _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby }); } } @@ -182,7 +182,7 @@ private async Task ManageCreateLobbyAsync(NetworkConnection connection, CreateLo _server.SendTo(connection.Id, new CreateLobbyResponsePacket { Result = result, LobbyId = lobby?.Id ?? 0 }); if (lobby != null) { - _server.Broadcast(PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby })); + _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby }); } } finally { @@ -213,7 +213,7 @@ private async Task ManageJoinLobbyAsync(NetworkConnection connection, JoinLobbyP _server.SendTo(connection.Id, new JoinLobbyResponsePacket { Result = result, LobbyId = packet.LobbyId }); if (result == LobbyActionResult.Ok && _lobbyManager[packet.LobbyId] is { } lobby) { - _server.Broadcast(PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby })); + _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby }); } } finally { @@ -232,10 +232,10 @@ private async Task ManageLeaveLobbyAsync(NetworkConnection connection, LeaveLobb // remove the lobby if it became empty if (lobby.PlayersCount == 0) { _lobbyManager.RemoveLobby(lobby.Id); - _server.Broadcast(PacketProtocol.FramePacket(new LobbyDeletedPacket { LobbyId = lobby.Id })); + _server.Broadcast(new LobbyDeletedPacket { LobbyId = lobby.Id }); } else { - _server.Broadcast(PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby })); + _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby }); } } } @@ -252,7 +252,7 @@ private async Task ManageSetReadyAsync(NetworkConnection connection, SetReadyPac _server.SendTo(connection.Id, new SetReadyResponsePacket { Result = result, LobbyId = packet.LobbyId }); if (result == LobbyActionResult.Ok && _lobbyManager[packet.LobbyId] is { } lobby) { - _server.Broadcast(PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby })); + _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby }); } } finally { @@ -270,11 +270,11 @@ private async Task ClientDisconnectedAsync(NetworkConnection connection) { _lobbyManager.RemovePlayerFromAllLobbies(connection.Id, updated, deletedIds); foreach (var lobby in updated) { - _server.Broadcast(PacketProtocol.FramePacket(new LobbyUpdatedPacket { Lobby = lobby })); + _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby }); } foreach (var id in deletedIds) { - _server.Broadcast(PacketProtocol.FramePacket(new LobbyDeletedPacket { LobbyId = id })); + _server.Broadcast(new LobbyDeletedPacket { LobbyId = id }); } } finally { diff --git a/OpenPolytopia.Server/LobbyManager.cs b/OpenPolytopia.Server/LobbyManager.cs index c7f3c225..6536a314 100644 --- a/OpenPolytopia.Server/LobbyManager.cs +++ b/OpenPolytopia.Server/LobbyManager.cs @@ -10,6 +10,11 @@ namespace OpenPolytopia.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; @@ -53,7 +58,7 @@ public LobbyActionResult JoinLobby(ulong lobbyId, LobbyPlayerData player) { return LobbyActionResult.LobbyNotFound; } - if (lobby.Starting || lobby.Started) { + if (lobby.Starting) { return LobbyActionResult.LobbyAlreadyStarted; } @@ -81,7 +86,7 @@ public LobbyActionResult LeaveLobby(ulong lobbyId, uint playerId) { return LobbyActionResult.LobbyNotFound; } - if (lobby.Starting || lobby.Started) { + if (lobby.Starting) { return LobbyActionResult.LobbyAlreadyStarted; } @@ -113,7 +118,7 @@ public LobbyActionResult SetReady(ulong lobbyId, uint playerId, bool ready) { return LobbyActionResult.LobbyNotFound; } - if (lobby.Starting || lobby.Started) { + if (lobby.Starting) { return LobbyActionResult.LobbyAlreadyStarted; } @@ -128,15 +133,16 @@ public LobbyActionResult SetReady(ulong lobbyId, uint playerId, bool ready) { } /// - /// Marks a lobby as starting when every player in it is ready + /// 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 must start it too + /// because removing the last not-ready player can start it too /// /// the lobby to check private static void TryMarkStarting(LobbyData lobby) { - if (lobby.PlayersCount > 0 && lobby.ReadyCount == lobby.PlayersCount) { + if (lobby.PlayersCount >= MIN_PLAYERS_TO_START && lobby.ReadyCount == lobby.PlayersCount) { lobby.Starting = true; } } @@ -182,11 +188,6 @@ public void RenamePlayerInLobbies(uint playerId, string name, List up /// 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()) { - // players can't abandon a game that already started - if (lobby.Started) { - continue; - } - var player = lobby[playerId]; if (player == null) { continue; @@ -214,10 +215,12 @@ public List TakeStartingLobbies() { List starting = []; foreach (var lobby in _lobbies.Values.ToArray()) { - if (!lobby.Starting || lobby.Started) { + 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); diff --git a/OpenPolytopia/src/Game.cs b/OpenPolytopia/src/Game.cs index d3b32950..c6116204 100644 --- a/OpenPolytopia/src/Game.cs +++ b/OpenPolytopia/src/Game.cs @@ -6,20 +6,27 @@ 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() { + _statusLabel = GetNode