-
Notifications
You must be signed in to change notification settings - Fork 1
Replace SpacetimeDB with custom TCP server and packet protocol #58
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Enn3Developer
wants to merge
11
commits into
master
Choose a base branch
from
claude/custom-tcp-server-fc5qcl
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
fe3e8ff
Replace SpacetimeDB with custom TCP server and packet protocol
claude 1aca068
Make server ip and port configurable
claude 4ce5b1d
Make NetworkNode a custom node instead of an autoload
claude dbe778f
Match doc comment style with the rest of the codebase
claude 1b171c5
Fix the issues found in the code review
claude c60ff99
Fix the spellcheck failure
claude 5ea3478
Fix the issues found in the second code review
claude 41ccac0
Fix the issues found in the third code review
claude 8dea974
Fix a word the spellchecker doesn't accept
claude e1156d7
Fix the issues found in the fourth code review
claude 6c4e904
Fix the issues found in the fifth code review
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) { | ||
| // 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); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() { | ||
|
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); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.