Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions OpenPolytopia.Common/LobbyData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
namespace OpenPolytopia.Common;

using Network;

/// <summary>
/// A player inside a lobby
/// </summary>
public class LobbyPlayerData : INetworkSerializable {
/// <summary>
/// Server-assigned id of the player
/// </summary>
public uint PlayerId;

/// <summary>
/// Name of the player
/// </summary>
public string Name = "";

/// <summary>
/// Tribe chosen by the player
/// </summary>
public uint Tribe;

/// <summary>
/// Whether the player is ready to start the game
/// </summary>
public bool Ready;

public void Serialize(List<byte> 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);
}
}

/// <summary>
/// Represents a lobby where players can join and start a game
/// </summary>
public class LobbyData : INetworkSerializable {
/// <summary>
/// ID of the lobby
/// </summary>
public ulong Id;

/// <summary>
/// Number of max players that can join this lobby
/// </summary>
public uint MaxPlayers;

/// <summary>
/// If the game in the lobby has started
/// </summary>
public bool Started;

/// <summary>
/// If the game in the lobby is about to start (all players ready)
/// </summary>
public bool Starting;

/// <summary>
/// The players currently in the lobby
/// </summary>
public List<LobbyPlayerData> Players = [];

/// <summary>
/// Number of players in the lobby
/// </summary>
public uint PlayersCount => (uint)Players.Count;

/// <summary>
/// Number of players ready to start
/// </summary>
public uint ReadyCount => (uint)Players.Count(player => player.Ready);

/// <summary>
/// Returns the player data from a given player id
/// </summary>
/// <param name="playerId">the player's id</param>
public LobbyPlayerData? this[uint playerId] => Players.FirstOrDefault(player => player.PlayerId == playerId);

public void Serialize(List<byte> 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);
}
}
108 changes: 108 additions & 0 deletions OpenPolytopia.Common/Network/ClientConnection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
namespace OpenPolytopia.Common.Network;

using System.Collections.Concurrent;
using System.Net.Sockets;
using Packets;

/// <summary>
/// Client-side connection to the game server
/// </summary>
/// <remarks>
/// Received packets are queued in <see cref="IncomingPackets"/> to let the consumer
/// process them on its own thread; <see cref="KeepAlivePacket"/> gets answered automatically
/// and the connection gets closed if the server doesn't send anything for longer than <see cref="TIMEOUT"/>
/// </remarks>
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;

/// <summary>
/// Packets received from the server, waiting to be processed
/// </summary>
public ConcurrentQueue<IPacket> IncomingPackets { get; } = new();

/// <summary>
/// Fired when the connection to the server gets closed
/// </summary>
public event Action? OnDisconnected;

/// <summary>
/// true while connected to the server
/// </summary>
public bool Connected => _connection?.Connected ?? false;

/// <summary>
/// Connects to the server and starts reading packets in background
/// </summary>
public async Task ConnectAsync() {
PacketRegistrar.RegisterAllPackets();
await _client.ConnectAsync(address, port, _cts.Token);

_connection = new NetworkConnection(0, _client);
_connection.OnPacketReceived += PacketReceivedAsync;
_connection.OnDisconnected += _ => OnDisconnected?.Invoke();

// read packets and watch for a dead server in background
_ = _connection.RunAsync(_cts.Token);
_ = TimeoutLoopAsync(_connection, _cts.Token);
}

/// <summary>
/// Sends a packet to the server
/// </summary>
/// <param name="packet">the packet to send</param>
public async Task SendPacketAsync(IPacket packet) {
if (_connection == null) {
return;
}

await _connection.SendPacketAsync(packet, _cts.Token);
}

/// <summary>
/// Closes the connection
/// </summary>
public void Disconnect() {
_cts.Cancel();
_connection?.Close();
}

private static async Task TimeoutLoopAsync(NetworkConnection connection, CancellationToken ct) {
using var timer = new PeriodicTimer(TIMEOUT_CHECK_INTERVAL);

try {
while (await timer.WaitForNextTickAsync(ct)) {
// the server pings every 10 seconds, a long silence means it's gone
if (DateTime.UtcNow - connection.LastReceived > TIMEOUT) {
connection.Close();
return;
}
}
}
catch (OperationCanceledException) {
// client disconnecting
}
}

private async Task PacketReceivedAsync(NetworkConnection connection, IPacket packet) {
Comment thread
Enn3Developer marked this conversation as resolved.
// 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);
}
}
24 changes: 24 additions & 0 deletions OpenPolytopia.Common/Network/INetworkSerializable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace OpenPolytopia.Common.Network;

/// <summary>
/// Interface for types that need to be serialized to be sent on the network
/// </summary>
public interface INetworkSerializable {
/// <summary>
/// Serialize data
/// </summary>
/// <param name="bytes">the bytes where to serialize into, use <see cref="List{T}.Add"/></param>
public void Serialize(List<byte> bytes);

/// <summary>
/// Deserialize data
/// </summary>
/// <param name="bytes">the buffer bytes where to read from</param>
/// <param name="index">the index where to start reading</param>
/// <remarks>
/// It is assumed that every Deserialize operation increments <c>index</c> as needed.
/// For example, <see langword="bool"/> increments <c>index</c> by one
/// while <see langword="int"/> increments it by four
/// </remarks>
public void Deserialize(byte[] bytes, ref uint index);
}
128 changes: 128 additions & 0 deletions OpenPolytopia.Common/Network/NetworkConnection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
namespace OpenPolytopia.Common.Network;

using System.IO;
using System.Net.Sockets;
using Packets;

/// <summary>
/// Wraps a connected <see cref="TcpClient"/> to send and receive packets
/// </summary>
/// <remarks>
/// Used by the client for its connection to the server
/// and by the server for every connected client
/// </remarks>
public class NetworkConnection(uint id, TcpClient client) : IDisposable {
private readonly NetworkStream _stream = client.GetStream();
private readonly SemaphoreSlim _writeLock = new(1, 1);
private int _closed;

/// <summary>
/// Id of this connection; assigned by the server
/// </summary>
public uint Id { get; } = id;

/// <summary>
/// Timestamp of the last packet received on this connection
/// </summary>
public DateTime LastReceived { get; private set; } = DateTime.UtcNow;

/// <summary>
/// Fired for every packet received on this connection
/// </summary>
public event Func<NetworkConnection, IPacket, Task>? OnPacketReceived;

/// <summary>
/// Fired once when the connection gets closed for any reason
/// </summary>
public event Action<NetworkConnection>? OnDisconnected;

/// <summary>
/// true while the underlying socket is connected
/// </summary>
public bool Connected => _closed == 0 && client.Connected;

/// <summary>
/// Sends a packet to the remote endpoint
/// </summary>
/// <remarks>
/// This method is thread-safe
/// </remarks>
/// <param name="packet">the packet to send</param>
/// <param name="ct">cancellation token</param>
public async Task SendPacketAsync(IPacket packet, CancellationToken ct = default) =>
await SendFrameAsync(PacketProtocol.FramePacket(packet), ct);

/// <summary>
/// Sends an already framed packet to the remote endpoint
/// </summary>
/// <remarks>
/// This method is thread-safe
/// </remarks>
/// <param name="frame">the framed packet to send</param>
/// <param name="ct">cancellation token</param>
public async Task SendFrameAsync(byte[] frame, CancellationToken ct = default) {
await _writeLock.WaitAsync(ct);
try {
await _stream.WriteAsync(frame, ct);
}
finally {
_writeLock.Release();
}
}

/// <summary>
/// Reads packets from the connection until it gets closed or the token gets cancelled
/// </summary>
/// <remarks>
/// Fires <see cref="OnPacketReceived"/> for every packet
/// and <see cref="OnDisconnected"/> at the end
/// </remarks>
/// <param name="ct">cancellation token</param>
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();
}
}

/// <summary>
/// Closes the connection
/// </summary>
/// <remarks>
/// Calling this multiple times is safe
/// </remarks>
public void Close() {
Comment thread
Enn3Developer marked this conversation as resolved.
// atomic exchange so two threads closing at once fire OnDisconnected only once
if (Interlocked.Exchange(ref _closed, 1) == 1) {
return;
}

client.Close();
OnDisconnected?.Invoke(this);
}

public void Dispose() {
Close();
_writeLock.Dispose();
GC.SuppressFinalize(this);
}
}
21 changes: 21 additions & 0 deletions OpenPolytopia.Common/Network/NetworkConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace OpenPolytopia.Common.Network;

public static class NetworkConstants {
/// <summary>
/// Version of the network protocol
/// </summary>
/// <remarks>
/// The handshake fails if client and server have different versions
/// </remarks>
public const string VERSION = "0.1.0";

/// <summary>
/// Default port the server listens on
/// </summary>
public const int DEFAULT_PORT = 6969;

/// <summary>
/// Maximum size in bytes of a single packet, counting the packet id and the payload
/// </summary>
public const uint MAX_PACKET_SIZE = 1024 * 1024;
}
Loading
Loading