diff --git a/.github/workflows/claude-pr-summary.yml b/.github/workflows/claude-pr-summary.yml
index 8b54260..8ab05d6 100644
--- a/.github/workflows/claude-pr-summary.yml
+++ b/.github/workflows/claude-pr-summary.yml
@@ -53,6 +53,6 @@ jobs:
Keep it under 200 words. No code review, no praise, no findings.
claude_args: >-
- --max-turns 15
+ --max-turns 30
--allowedTools "Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(gh pr view:*),Bash(gh pr diff:*)"
--disallowedTools "Edit,Write,MultiEdit,NotebookEdit"
diff --git a/.gitignore b/.gitignore
index bd5ad01..9afac65 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,9 @@ obj/
/packages/
riderModule.iml
/_ReSharper.Caches/
-/.idea
\ No newline at end of file
+/.idea
+# Local server persistence and TLS credentials
+*.db
+*.db-wal
+*.db-shm
+*.pfx
diff --git a/OpenPolytopia.Common/Gameplay/Game.Persistence.cs b/OpenPolytopia.Common/Gameplay/Game.Persistence.cs
new file mode 100644
index 0000000..6c60b0b
--- /dev/null
+++ b/OpenPolytopia.Common/Gameplay/Game.Persistence.cs
@@ -0,0 +1,244 @@
+namespace OpenPolytopia.Common.Gameplay;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+///
+/// Capture and restore of a whole match, for persisting a game between two runs of the server
+///
+///
+/// A snapshot only carries what a game mutates; everything else it's built from is content, so
+/// asks for it again and a restored game shares nothing with the one it came from
+///
+public partial class Game {
+ ///
+ /// Rebuilds a game from its parts, without running any of the setup the public constructor does
+ ///
+ ///
+ /// The public constructor builds every from the tribe, which is exactly what a restore
+ /// can't do: a restored player has the stars, the score and the researched nodes of the snapshot, not the ones a
+ /// tribe starts with
+ ///
+ /// the grid, already filled
+ /// the city manager, with every city already registered
+ /// the troop manager, already filled
+ /// the registered building definitions
+ /// the settings of the game
+ /// the players, in turn order, already restored
+ private Game(Grid grid, CityManager cityManager, TroopManager troopManager, BuildingManager buildingManager,
+ GameSettings settings, List players) {
+ Grid = grid;
+ Cities = cityManager;
+ Troops = troopManager;
+ Buildings = buildingManager;
+ Settings = settings;
+ Players = players;
+ _playersById = players.ToDictionary(player => player.Id);
+ _turnIndexById = players.Select((player, index) => (player.Id, index))
+ .ToDictionary(entry => entry.Id, entry => entry.index);
+ }
+
+ ///
+ /// Captures everything this game mutates into a snapshot
+ ///
+ ///
+ /// The snapshot owns its arrays, so playing on after taking one never changes what was captured
+ ///
+ /// the snapshot, restorable with
+ public GameSnapshot ToSnapshot() {
+ var cells = Grid.Size * Grid.Size;
+ var tiles = new ulong[cells];
+ var troops = new uint[cells];
+
+ for (var index = 0u; index < cells; index++) {
+ tiles[index] = Grid[index].Raw;
+ troops[index] = Troops[index].Raw;
+ }
+
+ return new GameSnapshot {
+ Version = GameSnapshot.CURRENT_VERSION,
+ GridSize = Grid.Size,
+ Tiles = tiles,
+ Troops = troops,
+ CityIndexes = [.. Cities.Cities],
+ Players = [
+ .. Players.Select(player => new PlayerSnapshot {
+ Id = player.Id,
+ Tribe = player.Tribe,
+ Stars = player.Stars,
+ Score = player.Score.ScoreValue,
+ Alive = player.Alive,
+ ResearchedTechs = [.. player.TechTree.ResearchedIds()]
+ })
+ ],
+ MaxTurns = Settings.MaxTurns,
+ Turn = Turn,
+ CurrentPlayer = CurrentPlayer,
+ Started = Started,
+ Over = Over,
+ Winner = Winner
+ };
+ }
+
+ ///
+ /// Rebuilds the game a snapshot was taken from
+ ///
+ ///
+ /// The content arguments have to be the same the original game was built from: a snapshot stores the researched
+ /// node ids, not the tech tree, and the raw tiles/troops, not the definitions they refer to, so restoring against
+ /// different content gives a game that isn't the one that was saved
+ ///
+ /// is taken instead of built because only the caller has the troop definitions to
+ /// register in it; it must be empty and sized like the grid of the snapshot. The grid and the city manager are
+ /// built here because both are fully described by the snapshot
+ ///
+ /// the snapshot to restore
+ /// an empty troop manager, sized , with the troop definitions registered
+ /// the registered tribes
+ /// the registered building definitions
+ /// the shape of the tech tree the original game was built from
+ /// the restored game, ready to be played on
+ /// if any argument, or any member of the snapshot, is null
+ ///
+ /// if the snapshot wasn't written by , if its arrays don't have one entry per
+ /// grid cell, if isn't sized like the grid, if it doesn't have 2 to 16 players, if
+ /// a player id is out of range or used twice, if a tribe isn't registered, if a researched node isn't in the tech
+ /// tree of its player, if a city index is outside the grid or if there are more than 255 cities
+ ///
+ public static Game Restore(GameSnapshot snapshot, TroopManager troopManager, TribeManager tribeManager,
+ BuildingManager buildingManager, TechTreeDefinition techTreeDefinition) {
+ ArgumentNullException.ThrowIfNull(snapshot);
+ ArgumentNullException.ThrowIfNull(troopManager);
+ ArgumentNullException.ThrowIfNull(tribeManager);
+ ArgumentNullException.ThrowIfNull(buildingManager);
+ ArgumentNullException.ThrowIfNull(techTreeDefinition);
+ ArgumentNullException.ThrowIfNull(snapshot.Tiles);
+ ArgumentNullException.ThrowIfNull(snapshot.Troops);
+ ArgumentNullException.ThrowIfNull(snapshot.CityIndexes);
+ ArgumentNullException.ThrowIfNull(snapshot.Players);
+
+ if (snapshot.Version != GameSnapshot.CURRENT_VERSION) {
+ throw new ArgumentException(
+ $"snapshot version {snapshot.Version} can't be restored; this build writes version {GameSnapshot.CURRENT_VERSION}",
+ nameof(snapshot));
+ }
+
+ if (snapshot.GridSize == 0) {
+ throw new ArgumentException("a snapshot of a game has a grid", nameof(snapshot));
+ }
+
+ var cells = snapshot.GridSize * snapshot.GridSize;
+ if (snapshot.Tiles.Length != cells) {
+ throw new ArgumentException(
+ $"a {snapshot.GridSize}x{snapshot.GridSize} grid has {cells} tiles, the snapshot has {snapshot.Tiles.Length}",
+ nameof(snapshot));
+ }
+
+ if (snapshot.Troops.Length != cells) {
+ throw new ArgumentException(
+ $"a {snapshot.GridSize}x{snapshot.GridSize} grid has {cells} cells, the snapshot has {snapshot.Troops.Length} troop slots",
+ nameof(snapshot));
+ }
+
+ if (troopManager.Size != snapshot.GridSize) {
+ throw new ArgumentException(
+ $"the troop manager is sized {troopManager.Size}, the snapshot needs {snapshot.GridSize}", nameof(troopManager));
+ }
+
+ if (snapshot.Players.Count is < 2 or > 16) {
+ throw new ArgumentException($"a game needs 2 to 16 players, the snapshot has {snapshot.Players.Count}",
+ nameof(snapshot));
+ }
+
+ var grid = new Grid(snapshot.GridSize);
+ var cityManager = new CityManager(grid);
+
+ // the cities go in first: registering one writes into its tile, so the raw tiles have to be written after it
+ foreach (var index in snapshot.CityIndexes) {
+ if (index >= cells) {
+ throw new ArgumentException($"city index {index} is outside a {snapshot.GridSize}x{snapshot.GridSize} grid",
+ nameof(snapshot));
+ }
+
+ cityManager.RegisterCity(index);
+ }
+
+ for (var index = 0u; index < cells; index++) {
+ grid[index] = new Tile { Raw = snapshot.Tiles[index] };
+ troopManager.SetRaw(index, snapshot.Troops[index]);
+ }
+
+ var players = new List(snapshot.Players.Count);
+ var seenIds = new HashSet(snapshot.Players.Count);
+
+ foreach (var playerSnapshot in snapshot.Players) {
+ ArgumentNullException.ThrowIfNull(playerSnapshot, nameof(snapshot));
+ ArgumentNullException.ThrowIfNull(playerSnapshot.ResearchedTechs, nameof(snapshot));
+
+ if (playerSnapshot.Id is < 1 or > 16) {
+ throw new ArgumentException($"player id {playerSnapshot.Id} is out of range; ids must be between 1 and 16",
+ nameof(snapshot));
+ }
+
+ if (!seenIds.Add(playerSnapshot.Id)) {
+ throw new ArgumentException($"player id {playerSnapshot.Id} is used by more than one player", nameof(snapshot));
+ }
+
+ var tribe = tribeManager[playerSnapshot.Tribe] ??
+ throw new ArgumentException($"tribe {playerSnapshot.Tribe} of player {playerSnapshot.Id} isn't registered",
+ nameof(snapshot));
+
+ players.Add(RestorePlayer(playerSnapshot, tribe, techTreeDefinition));
+ }
+
+ var game = new Game(grid, cityManager, troopManager, buildingManager,
+ new GameSettings { MaxTurns = snapshot.MaxTurns }, players) {
+ Turn = snapshot.Turn,
+ CurrentPlayer = snapshot.CurrentPlayer,
+ Started = snapshot.Started,
+ Over = snapshot.Over,
+ Winner = snapshot.Winner
+ };
+
+ // a started, unfinished game hands the turn to somebody, and turn order is looked up by that id
+ if (game is { Started: true, Over: false } && !game._turnIndexById.ContainsKey(game.CurrentPlayer)) {
+ throw new ArgumentException($"player {snapshot.CurrentPlayer} holds the turn but isn't in the game",
+ nameof(snapshot));
+ }
+
+ return game;
+ }
+
+ ///
+ /// Rebuilds the state of a single player
+ ///
+ ///
+ /// The tech tree is built empty from the definition of the tribe, with the overrides applied exactly like
+ /// does, and then only what the snapshot says was researched is
+ /// marked: the starting node of the tribe isn't researched for free, so a snapshot restores the researched set it
+ /// captured and nothing else
+ ///
+ /// the snapshot of the player
+ /// the tribe of the player
+ /// the shape of the tech tree, before the overrides of the tribe
+ /// the restored state
+ /// if a researched node isn't in the tech tree of this player
+ private static PlayerState RestorePlayer(PlayerSnapshot snapshot, Tribe tribe,
+ TechTreeDefinition techTreeDefinition) {
+ var definition = tribe.TechOverrides is { Count: > 0 } overrides
+ ? techTreeDefinition.Override(overrides)
+ : techTreeDefinition;
+ var techTree = new TechTree(definition);
+
+ foreach (var techId in snapshot.ResearchedTechs) {
+ if (!techTree.Research(techId)) {
+ throw new ArgumentException($"node {techId} of player {snapshot.Id} isn't in the tech tree", nameof(snapshot));
+ }
+ }
+
+ var player = new PlayerState(snapshot.Id, snapshot.Tribe, snapshot.Stars, techTree) { Alive = snapshot.Alive };
+ player.Score.Restore(snapshot.Score);
+ return player;
+ }
+}
diff --git a/OpenPolytopia.Common/Gameplay/Game.cs b/OpenPolytopia.Common/Gameplay/Game.cs
index 1ab3703..46126ac 100644
--- a/OpenPolytopia.Common/Gameplay/Game.cs
+++ b/OpenPolytopia.Common/Gameplay/Game.cs
@@ -102,7 +102,7 @@ public partial class Game {
/// than once, or a player's tribe isn't registered in
///
public Game(Grid grid, CityManager cityManager, TroopManager troopManager, TribeManager tribeManager,
- BuildingManager buildingManager, TechTreeDefinition techTreeDefinition, IReadOnlyList players,
+ BuildingManager buildingManager, TechTreeDefinition techTreeDefinition, IReadOnlyList players,
GameSettings? settings = null) {
ArgumentNullException.ThrowIfNull(grid);
ArgumentNullException.ThrowIfNull(cityManager);
diff --git a/OpenPolytopia.Common/Gameplay/GameSnapshot.cs b/OpenPolytopia.Common/Gameplay/GameSnapshot.cs
new file mode 100644
index 0000000..9d4a925
--- /dev/null
+++ b/OpenPolytopia.Common/Gameplay/GameSnapshot.cs
@@ -0,0 +1,139 @@
+namespace OpenPolytopia.Common.Gameplay;
+
+using System.Collections.Generic;
+
+///
+/// A lossless capture of everything a mutates while it's played
+///
+///
+/// Only mutable state lives here: the content a game is built from (troop definitions, building definitions, tribes
+/// and the shape of the tech tree) isn't captured because it never changes during a match, so
+/// takes it back from the caller
+///
+/// Every member is a primitive, an array of primitives or a list of , so a row of a
+/// SQLite table maps one to one onto it: the two big arrays go in BLOB columns through
+/// , the players in a child table keyed by turn order
+///
+public sealed class GameSnapshot {
+ ///
+ /// The layout version written by
+ ///
+ ///
+ /// Bumped whenever the meaning of a member or of a packed raw value changes, so a snapshot written by an older
+ /// build is rejected instead of silently restoring a wrong game
+ ///
+ public const int CURRENT_VERSION = 1;
+
+ ///
+ /// The layout version this snapshot was written with, see
+ ///
+ public required int Version { get; init; }
+
+ ///
+ /// The width (and height) of the grid
+ ///
+ public required uint GridSize { get; init; }
+
+ ///
+ /// The packed representation of every tile, indexed like
+ ///
+ ///
+ /// This carries the custom data of the tile too, so the internals of every city (level, population, troops, parks,
+ /// wall, forge, capital and connected flags) ride along with it
+ ///
+ public required ulong[] Tiles { get; init; }
+
+ ///
+ /// The packed representation of every troop, indexed like
+ ///
+ ///
+ /// A 0 is an empty tile, see
+ ///
+ public required uint[] Troops { get; init; }
+
+ ///
+ /// The grid index of every registered city, ordered by city id
+ ///
+ ///
+ /// The city with id i is at position i - 1, exactly like
+ ///
+ public required uint[] CityIndexes { get; init; }
+
+ ///
+ /// Every player, in turn order
+ ///
+ public required IReadOnlyList Players { get; init; }
+
+ ///
+ /// of the game
+ ///
+ public required uint MaxTurns { get; init; }
+
+ ///
+ /// of the game
+ ///
+ public required uint Turn { get; init; }
+
+ ///
+ /// of the game
+ ///
+ public required int CurrentPlayer { get; init; }
+
+ ///
+ /// of the game
+ ///
+ public required bool Started { get; init; }
+
+ ///
+ /// of the game
+ ///
+ public required bool Over { get; init; }
+
+ ///
+ /// of the game
+ ///
+ public required int Winner { get; init; }
+}
+
+///
+/// A lossless capture of a single
+///
+///
+/// The position of a player in is their turn order, so it has to be preserved by
+/// whoever stores it: a SQLite child table needs an explicit order column, a plain automatically assigned id isn't enough
+///
+public sealed class PlayerSnapshot {
+ ///
+ /// of the player, 1..16
+ ///
+ public required int Id { get; init; }
+
+ ///
+ /// of the player
+ ///
+ public required TribeType Tribe { get; init; }
+
+ ///
+ /// of the player
+ ///
+ public required int Stars { get; init; }
+
+ ///
+ /// of the player
+ ///
+ public required int Score { get; init; }
+
+ ///
+ /// of the player
+ ///
+ public required bool Alive { get; init; }
+
+ ///
+ /// The ids of every node this player researched, as returned by
+ ///
+ ///
+ /// The tech tree itself isn't captured: it's rebuilt from the definition and the overrides of the tribe, which are
+ /// content, and only the researched state is player state
+ ///
+ public required IReadOnlyList ResearchedTechs { get; init; }
+}
diff --git a/OpenPolytopia.Common/Gameplay/SnapshotEncoding.cs b/OpenPolytopia.Common/Gameplay/SnapshotEncoding.cs
new file mode 100644
index 0000000..4011f5e
--- /dev/null
+++ b/OpenPolytopia.Common/Gameplay/SnapshotEncoding.cs
@@ -0,0 +1,99 @@
+namespace OpenPolytopia.Common.Gameplay;
+
+using System;
+using System.Buffers.Binary;
+
+///
+/// Converts the packed arrays of a to and from the BLOB a database column holds
+///
+///
+/// The encoding is little endian and has no header: the length of the blob divided by the size of an element is the
+/// number of cells, and the caller already knows how many it expects from
+///
+/// Little endian is written explicitly instead of relying on the layout of the machine, so a database file written on
+/// one architecture restores the same game on another
+///
+public static class SnapshotEncoding {
+ ///
+ /// Packs into a blob
+ ///
+ /// the raw tiles
+ /// the blob, 8 bytes per tile
+ public static byte[] PackTiles(ReadOnlySpan tiles) {
+ var bytes = new byte[tiles.Length * sizeof(ulong)];
+ for (var index = 0; index < tiles.Length; index++) {
+ BinaryPrimitives.WriteUInt64LittleEndian(bytes.AsSpan(index * sizeof(ulong)), tiles[index]);
+ }
+
+ return bytes;
+ }
+
+ ///
+ /// Unpacks a blob written by
+ ///
+ /// the blob
+ /// the raw tiles
+ /// if the length of the blob isn't a multiple of 8
+ public static ulong[] UnpackTiles(ReadOnlySpan bytes) {
+ if (bytes.Length % sizeof(ulong) != 0) {
+ throw new ArgumentException(
+ $"a tile blob is {sizeof(ulong)} bytes per tile, got {bytes.Length} bytes", nameof(bytes));
+ }
+
+ var tiles = new ulong[bytes.Length / sizeof(ulong)];
+ for (var index = 0; index < tiles.Length; index++) {
+ tiles[index] = BinaryPrimitives.ReadUInt64LittleEndian(bytes[(index * sizeof(ulong))..]);
+ }
+
+ return tiles;
+ }
+
+ ///
+ /// Packs into a blob
+ ///
+ /// the raw troops
+ /// the blob, 4 bytes per troop
+ public static byte[] PackTroops(ReadOnlySpan troops) {
+ var bytes = new byte[troops.Length * sizeof(uint)];
+ for (var index = 0; index < troops.Length; index++) {
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(index * sizeof(uint)), troops[index]);
+ }
+
+ return bytes;
+ }
+
+ ///
+ /// Unpacks a blob written by
+ ///
+ /// the blob
+ /// the raw troops
+ /// if the length of the blob isn't a multiple of 4
+ public static uint[] UnpackTroops(ReadOnlySpan bytes) {
+ if (bytes.Length % sizeof(uint) != 0) {
+ throw new ArgumentException(
+ $"a troop blob is {sizeof(uint)} bytes per troop, got {bytes.Length} bytes", nameof(bytes));
+ }
+
+ var troops = new uint[bytes.Length / sizeof(uint)];
+ for (var index = 0; index < troops.Length; index++) {
+ troops[index] = BinaryPrimitives.ReadUInt32LittleEndian(bytes[(index * sizeof(uint))..]);
+ }
+
+ return troops;
+ }
+
+ ///
+ /// Packs into a blob
+ ///
+ /// the grid index of every city, ordered by city id
+ /// the blob, 4 bytes per city
+ public static byte[] PackCityIndexes(ReadOnlySpan cityIndexes) => PackTroops(cityIndexes);
+
+ ///
+ /// Unpacks a blob written by
+ ///
+ /// the blob
+ /// the grid index of every city, ordered by city id
+ /// if the length of the blob isn't a multiple of 4
+ public static uint[] UnpackCityIndexes(ReadOnlySpan bytes) => UnpackTroops(bytes);
+}
diff --git a/OpenPolytopia.Common/LobbyData.cs b/OpenPolytopia.Common/LobbyData.cs
index ee70341..7a3ec23 100644
--- a/OpenPolytopia.Common/LobbyData.cs
+++ b/OpenPolytopia.Common/LobbyData.cs
@@ -65,6 +65,9 @@ public class LobbyData : INetworkSerializable {
///
public bool Started;
+ /// Turn timer: 0 for Live, 1 for 24-hour turns.
+ public uint TimerMode = 1;
+
///
/// If the game in the lobby is about to start (lobby full and all players ready)
///
@@ -96,6 +99,7 @@ public void Serialize(List bytes) {
MaxPlayers.Serialize(bytes);
WorldSize.Serialize(bytes);
Started.Serialize(bytes);
+ TimerMode.Serialize(bytes);
Starting.Serialize(bytes);
Players.Serialize(bytes);
}
@@ -105,6 +109,7 @@ public void Deserialize(byte[] bytes, ref uint index) {
MaxPlayers.Deserialize(bytes, ref index);
WorldSize.Deserialize(bytes, ref index);
Started.Deserialize(bytes, ref index);
+ TimerMode.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
index c64cc26..4d1587b 100644
--- a/OpenPolytopia.Common/Network/ClientConnection.cs
+++ b/OpenPolytopia.Common/Network/ClientConnection.cs
@@ -1,7 +1,10 @@
namespace OpenPolytopia.Common.Network;
using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Security;
using System.Net.Sockets;
+using System.Security.Authentication;
using Packets;
///
@@ -10,15 +13,30 @@ 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
+/// and the connection gets closed if the server doesn't send anything for longer than .
+/// Every connection that leaves the machine goes through TLS, validated against the certificate store
+/// of the system; there is no plaintext fallback, a server with a bad certificate is a server we don't talk to
///
-public class ClientConnection(string address, int port) : IDisposable {
+/// the host name or ip address of the server
+/// the port of the server
+///
+/// true to wrap the connection in TLS, false for plaintext;
+/// null to decide from , meaning TLS for everything but loopback
+///
+public class ClientConnection(string address, int port, bool? useTls = null) : IDisposable {
private static readonly TimeSpan TIMEOUT = TimeSpan.FromSeconds(30);
private static readonly TimeSpan TIMEOUT_CHECK_INTERVAL = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan TLS_HANDSHAKE_TIMEOUT = TimeSpan.FromSeconds(10);
private readonly TcpClient _client = new();
private readonly CancellationTokenSource _cts = new();
private NetworkConnection? _connection;
+ private int _disposed;
+
+ ///
+ /// true when the connection to the server is wrapped in TLS
+ ///
+ public bool UsesTls { get; } = useTls ?? !IsLoopback(address);
///
/// Packets received from the server, waiting to be processed
@@ -38,11 +56,16 @@ public class ClientConnection(string address, int port) : IDisposable {
///
/// Connects to the server and starts reading packets in background
///
+ ///
+ /// if the server doesn't present a certificate this machine trusts for
+ ///
public async Task ConnectAsync() {
PacketRegistrar.RegisterAllPackets();
await _client.ConnectAsync(address, port, _cts.Token);
- _connection = new NetworkConnection(0, _client);
+ var stream = UsesTls ? await AuthenticateAsync() : null;
+
+ _connection = new NetworkConnection(0, _client, stream);
_connection.OnPacketReceived += PacketReceivedAsync;
_connection.OnDisconnected += _ => OnDisconnected?.Invoke();
@@ -71,6 +94,45 @@ public void Disconnect() {
_connection?.Close();
}
+ ///
+ /// Checks if an address points to this same machine
+ ///
+ ///
+ /// Loopback traffic never leaves the machine, so it's the only case where plaintext is acceptable;
+ /// anything that can't be recognized as loopback gets treated as remote and encrypted
+ ///
+ /// the host name or ip address to check
+ private static bool IsLoopback(string address) =>
+ string.Equals(address, "localhost", StringComparison.OrdinalIgnoreCase) ||
+ (IPAddress.TryParse(address, out var ip) && IPAddress.IsLoopback(ip));
+
+ ///
+ /// Wraps the connection in TLS
+ ///
+ ///
+ /// The certificate gets validated the standard way, against the trust store of the operating system;
+ /// a failure throws and leaves the connection unusable, it never falls back to plaintext
+ ///
+ /// the authenticated stream
+ private async Task AuthenticateAsync() {
+ var ssl = new SslStream(_client.GetStream(), false);
+
+ try {
+ using var handshake = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token);
+ handshake.CancelAfter(TLS_HANDSHAKE_TIMEOUT);
+ await ssl.AuthenticateAsClientAsync(
+ new SslClientAuthenticationOptions {
+ TargetHost = address, EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
+ }, handshake.Token);
+ }
+ catch {
+ await ssl.DisposeAsync();
+ throw;
+ }
+
+ return ssl;
+ }
+
private static async Task TimeoutLoopAsync(NetworkConnection connection, CancellationToken ct) {
using var timer = new PeriodicTimer(TIMEOUT_CHECK_INTERVAL);
@@ -99,6 +161,7 @@ private async Task PacketReceivedAsync(NetworkConnection connection, IPacket pac
}
public void Dispose() {
+ if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
Disconnect();
_cts.Dispose();
_connection?.Dispose();
diff --git a/OpenPolytopia.Common/Network/NetworkConnection.cs b/OpenPolytopia.Common/Network/NetworkConnection.cs
index d506aeb..b65fb12 100644
--- a/OpenPolytopia.Common/Network/NetworkConnection.cs
+++ b/OpenPolytopia.Common/Network/NetworkConnection.cs
@@ -11,8 +11,18 @@ namespace OpenPolytopia.Common.Network;
/// 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();
+/// the id of this connection
+/// the connected socket
+///
+/// the stream to read and write packets on; null to use the plaintext stream of .
+/// Pass an already authenticated here to talk over TLS
+///
+public class NetworkConnection(uint id, TcpClient client, Stream? stream = null) : IDisposable {
+ private readonly Stream _stream = stream ?? client.GetStream();
+ /// Peer address used for connection-independent authentication throttling.
+ public string RemoteAddress { get; } =
+ (client.Client.RemoteEndPoint as System.Net.IPEndPoint)?.Address.ToString() ?? "unknown";
+
private readonly SemaphoreSlim _writeLock = new(1, 1);
private int _closed;
@@ -116,6 +126,15 @@ public void Close() {
return;
}
+ // disposing the stream unblocks a pending read and, on TLS, sends the close notify;
+ // it can fail if the remote endpoint is already gone, which is not a problem here
+ try {
+ _stream.Dispose();
+ }
+ catch (Exception e) when (e is IOException or ObjectDisposedException or SocketException) {
+ // remote endpoint already gone
+ }
+
client.Close();
OnDisconnected?.Invoke(this);
}
diff --git a/OpenPolytopia.Common/Network/NetworkConstants.cs b/OpenPolytopia.Common/Network/NetworkConstants.cs
index 367415c..5b23019 100644
--- a/OpenPolytopia.Common/Network/NetworkConstants.cs
+++ b/OpenPolytopia.Common/Network/NetworkConstants.cs
@@ -7,7 +7,7 @@ public static class NetworkConstants {
///
/// The handshake fails if client and server have different versions
///
- public const string VERSION = "0.1.0";
+ public const string VERSION = "0.2.0";
///
/// Default port the server listens on
diff --git a/OpenPolytopia.Common/Network/PacketRegistrar.cs b/OpenPolytopia.Common/Network/PacketRegistrar.cs
index f1f4d34..b3bebe5 100644
--- a/OpenPolytopia.Common/Network/PacketRegistrar.cs
+++ b/OpenPolytopia.Common/Network/PacketRegistrar.cs
@@ -90,6 +90,20 @@ public static void RegisterAllPackets() {
RegisterPacket(40);
RegisterPacket(41);
RegisterPacket(42);
+ RegisterPacket(43);
+ RegisterPacket(44);
+ RegisterPacket(45);
+ RegisterPacket(46);
+ RegisterPacket(47);
+ RegisterPacket(48);
+ RegisterPacket(49);
+ RegisterPacket(50);
+ RegisterPacket(51);
+ RegisterPacket(52);
+ RegisterPacket(53);
+ RegisterPacket(54);
+ RegisterPacket(55);
+
}
}
}
diff --git a/OpenPolytopia.Common/Network/Packets/AccountPackets.cs b/OpenPolytopia.Common/Network/Packets/AccountPackets.cs
new file mode 100644
index 0000000..c88bd0c
--- /dev/null
+++ b/OpenPolytopia.Common/Network/Packets/AccountPackets.cs
@@ -0,0 +1,71 @@
+namespace OpenPolytopia.Common.Network.Packets;
+
+/// Creates a persistent account with a unique username.
+[GeneratedPacket]
+public partial class RegisterAccountPacket : IPacket {
+ [PacketField] public string Username = "";
+ [PacketField] public string Password = "";
+}
+
+/// Authenticates an existing account.
+[GeneratedPacket]
+public partial class LoginPacket : IPacket {
+ [PacketField] public string Username = "";
+ [PacketField] public string Password = "";
+}
+
+/// Resumes an unexpired session after reconnecting.
+[GeneratedPacket]
+public partial class ResumeSessionPacket : IPacket {
+ [PacketField] public string Token = "";
+}
+
+/// Revokes the current session and detaches from all game views.
+[GeneratedPacket]
+public partial class LogoutPacket : IPacket { }
+
+/// Authentication result; PlayerId is the persistent account id.
+[GeneratedPacket]
+public partial class AuthenticationPacket : IPacket {
+ /// Temporary admission failure; keep any saved session token and retry.
+ [PacketField] public bool Retryable;
+ [PacketField] public bool Ok;
+ [PacketField] public uint PlayerId;
+ [PacketField] public string Name = "";
+ [PacketField] public string Token = "";
+}
+
+/// Lists games belonging to the authenticated account.
+[GeneratedPacket]
+public partial class GetMyGamesPacket : IPacket { }
+
+/// Ids of the account's active and completed games.
+[GeneratedPacket]
+public partial class MyGamesPacket : IPacket {
+ [PacketField] public ulong[] GameIds = [];
+}
+
+/// Opens an existing game membership and subscribes to updates.
+[GeneratedPacket]
+public partial class JoinGamePacket : IPacket {
+ [PacketField] public ulong GameId;
+}
+
+/// Closes a game view without resigning.
+[GeneratedPacket]
+public partial class LeaveGamePacket : IPacket {
+ [PacketField] public ulong GameId;
+}
+
+/// Explicitly resigns a member from a game.
+[GeneratedPacket]
+public partial class ResignGamePacket : IPacket {
+ [PacketField] public ulong GameId;
+}
+
+/// Result of leaving or resigning from a game.
+[GeneratedPacket]
+public partial class MembershipResultPacket : IPacket {
+ [PacketField] public ulong GameId;
+ [PacketField] public Gameplay.GameActionResult Result;
+}
diff --git a/OpenPolytopia.Common/Network/Packets/GameData.cs b/OpenPolytopia.Common/Network/Packets/GameData.cs
index f11b6b1..56be3db 100644
--- a/OpenPolytopia.Common/Network/Packets/GameData.cs
+++ b/OpenPolytopia.Common/Network/Packets/GameData.cs
@@ -10,6 +10,9 @@ public class GamePlayerData : INetworkSerializable {
///
public uint PlayerId;
+ /// The persistent account controlling this seat.
+ public uint AccountId;
+
///
/// Name of the player
///
@@ -42,6 +45,7 @@ public class GamePlayerData : INetworkSerializable {
public void Serialize(List bytes) {
PlayerId.Serialize(bytes);
+ AccountId.Serialize(bytes);
Name.Serialize(bytes);
Tribe.Serialize(bytes);
Stars.Serialize(bytes);
@@ -52,6 +56,7 @@ public void Serialize(List bytes) {
public void Deserialize(byte[] bytes, ref uint index) {
PlayerId.Deserialize(bytes, ref index);
+ AccountId.Deserialize(bytes, ref index);
Name = StringSerialization.Read(bytes, ref index);
Tribe.Deserialize(bytes, ref index);
Stars.Deserialize(bytes, ref index);
diff --git a/OpenPolytopia.Common/Network/Packets/LobbyPackets.cs b/OpenPolytopia.Common/Network/Packets/LobbyPackets.cs
index 9c409ba..523c3b5 100644
--- a/OpenPolytopia.Common/Network/Packets/LobbyPackets.cs
+++ b/OpenPolytopia.Common/Network/Packets/LobbyPackets.cs
@@ -26,6 +26,8 @@ public partial class GetLobbiesResponsePacket : IPacket {
///
[GeneratedPacket]
public partial class CreateLobbyPacket : IPacket {
+ /// Turn timer: 0 for Live, 1 for 24-hour turns.
+ [PacketField] public uint TimerMode = 1;
///
/// Number of max players that can join the lobby
///
diff --git a/OpenPolytopia.Common/Network/Packets/TimerPackets.cs b/OpenPolytopia.Common/Network/Packets/TimerPackets.cs
new file mode 100644
index 0000000..4e90be8
--- /dev/null
+++ b/OpenPolytopia.Common/Network/Packets/TimerPackets.cs
@@ -0,0 +1,20 @@
+namespace OpenPolytopia.Common.Network.Packets;
+
+/// Authoritative UTC clock for the current turn; daily overdue clocks wait for a member to act.
+[GeneratedPacket]
+public partial class GameClockPacket : IPacket {
+ [PacketField] public ulong GameId;
+ [PacketField] public uint TimerMode;
+ [PacketField] public uint PlayerId;
+ [PacketField] public long DeadlineUnixMilliseconds;
+ [PacketField] public long ServerUnixMilliseconds;
+}
+
+/// Skips or kicks the current player of an overdue daily game.
+[GeneratedPacket]
+public partial class ResolveOverdueTurnPacket : IPacket {
+ [PacketField] public ulong GameId;
+ [PacketField] public bool Kick;
+ [PacketField] public uint ExpectedTurn;
+ [PacketField] public uint ExpectedPlayer;
+}
diff --git a/OpenPolytopia.Common/Network/ServerConnection.cs b/OpenPolytopia.Common/Network/ServerConnection.cs
index b11cf65..bd84abc 100644
--- a/OpenPolytopia.Common/Network/ServerConnection.cs
+++ b/OpenPolytopia.Common/Network/ServerConnection.cs
@@ -1,7 +1,10 @@
namespace OpenPolytopia.Common.Network;
using System.Collections.Concurrent;
+using System.Net.Security;
using System.Net.Sockets;
+using System.Security.Authentication;
+using System.Security.Cryptography.X509Certificates;
using System.Threading.Channels;
using Packets;
@@ -14,17 +17,29 @@ namespace OpenPolytopia.Common.Network;
/// 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
+/// clients that don't complete a handshake within get disconnected too.
+/// When a certificate is given every connection is wrapped in TLS before any packet is read;
+/// the TLS handshake runs in background so a slow or hostile client can't stall the accept loop
///
/// 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 {
+///
+/// the certificate to serve TLS with; null to accept plaintext connections, which is only
+/// acceptable on loopback
+///
+public class ServerConnection(int port, string? bindAddress = null, X509Certificate2? certificate = null)
+ : IDisposable {
private static readonly TimeSpan KEEP_ALIVE_INTERVAL = TimeSpan.FromSeconds(10);
private static readonly TimeSpan TIMEOUT = TimeSpan.FromSeconds(30);
private static readonly TimeSpan HANDSHAKE_TIMEOUT = TimeSpan.FromSeconds(10);
private static readonly TimeSpan SEND_TIMEOUT = TimeSpan.FromSeconds(10);
private static readonly TimeSpan ACCEPT_RETRY_DELAY = TimeSpan.FromSeconds(1);
+ ///
+ /// How long a client has to complete the TLS handshake before its socket gets dropped
+ ///
+ private static readonly TimeSpan TLS_HANDSHAKE_TIMEOUT = TimeSpan.FromSeconds(10);
+
///
/// Max frames queued for a single client; way more than lobby traffic ever needs
///
@@ -37,6 +52,11 @@ public class ServerConnection(int port, string? bindAddress = null) : IDisposabl
private readonly CancellationTokenSource _cts = new();
private uint _nextId;
+ ///
+ /// true when connections get wrapped in TLS
+ ///
+ public bool TlsEnabled => certificate != null;
+
///
/// Fired when a new client connects
///
@@ -83,20 +103,9 @@ public async Task RunAsync() {
continue;
}
- var id = Interlocked.Increment(ref _nextId);
-
- var connection = new NetworkConnection(id, tcpClient);
- connection.OnPacketReceived += ClientPacketReceivedAsync;
- connection.OnDisconnected += ClientDisconnected;
-
- var client = new Client(connection);
- _clients[id] = client;
-
- OnClientConnected?.Invoke(connection);
-
- // manage the client in background
- _ = connection.RunAsync(_cts.Token);
- _ = SenderLoopAsync(client, _cts.Token);
+ // the TLS handshake needs a round trip with the client, so it can't run here
+ // or one slow client would keep everybody else from connecting
+ _ = SetupClientAsync(tcpClient);
}
}
catch (OperationCanceledException) {
@@ -116,6 +125,84 @@ public async Task RunAsync() {
///
public void Stop() => _cts.Cancel();
+ ///
+ /// Completes the TLS handshake, if enabled, and registers the client
+ ///
+ ///
+ /// Runs in background, one task per accepted socket; a socket that fails or takes longer than
+ /// to negotiate TLS gets dropped without ever becoming a client
+ ///
+ /// the freshly accepted socket
+ private async Task SetupClientAsync(TcpClient tcpClient) {
+ var stream = await AuthenticateAsync(tcpClient);
+
+ // the handshake failed, the socket is already gone
+ if (certificate != null && stream == null) {
+ return;
+ }
+
+ if (_cts.IsCancellationRequested) {
+ stream?.Dispose();
+ tcpClient.Dispose();
+ return;
+ }
+
+ var id = Interlocked.Increment(ref _nextId);
+
+ var connection = new NetworkConnection(id, tcpClient, stream);
+ connection.OnPacketReceived += ClientPacketReceivedAsync;
+ connection.OnDisconnected += ClientDisconnected;
+
+ var client = new Client(connection);
+ _clients[id] = client;
+
+ OnClientConnected?.Invoke(connection);
+
+ // manage the client in background
+ _ = connection.RunAsync(_cts.Token);
+ _ = SenderLoopAsync(client, _cts.Token);
+ }
+
+ ///
+ /// Wraps a socket in TLS
+ ///
+ /// the socket to wrap
+ ///
+ /// the authenticated stream, null when TLS is disabled (plaintext) or when the handshake failed;
+ /// on failure the socket gets disposed
+ ///
+ private async Task AuthenticateAsync(TcpClient tcpClient) {
+ if (certificate == null) {
+ return null;
+ }
+
+ SslStream? ssl = null;
+ try {
+ ssl = new SslStream(tcpClient.GetStream(), false);
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token);
+ cts.CancelAfter(TLS_HANDSHAKE_TIMEOUT);
+
+ await ssl.AuthenticateAsServerAsync(
+ new SslServerAuthenticationOptions {
+ ServerCertificate = certificate,
+ ClientCertificateRequired = false,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
+ }, cts.Token);
+
+ return ssl;
+ }
+ catch (Exception e) when (e is AuthenticationException or IOException or OperationCanceledException
+ or ObjectDisposedException or SocketException) {
+ Console.Error.WriteLine($"TLS handshake failed: {e.Message}");
+
+ // nothing was ever handed out for this socket, drop it here
+ ssl?.Dispose();
+ tcpClient.Dispose();
+ return null;
+ }
+ }
+
///
/// Marks a client as having completed the handshake
///
diff --git a/OpenPolytopia.Common/Player.cs b/OpenPolytopia.Common/Player.cs
index 3f6915e..6307599 100644
--- a/OpenPolytopia.Common/Player.cs
+++ b/OpenPolytopia.Common/Player.cs
@@ -1,4 +1,12 @@
namespace OpenPolytopia.Common;
-public record Player(TribeType Tribe, int Id) {
+/// A participant independent of its human or future bot controller.
+public interface IPlayer {
+ /// The participant's tribe.
+ TribeType Tribe { get; }
+ /// The participant's id within a game.
+ int Id { get; }
}
+
+/// A human participant in a game.
+public record Player(TribeType Tribe, int Id) : IPlayer;
diff --git a/OpenPolytopia.Common/Score.cs b/OpenPolytopia.Common/Score.cs
index 6cd2c40..e2d1273 100644
--- a/OpenPolytopia.Common/Score.cs
+++ b/OpenPolytopia.Common/Score.cs
@@ -19,4 +19,15 @@ public int ScoreValue {
/// the scoring event to add, see
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void AddScore(ScoreType type) => ScoreValue += type.ToInt();
+
+ ///
+ /// Overwrites the score with the one of a persisted game
+ ///
+ ///
+ /// Only uses this: a score is otherwise only ever moved by
+ /// , so every point a player has comes from a scoring event
+ ///
+ /// the score to restore; clamped to 0 like every other write
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal void Restore(int value) => ScoreValue = value;
}
diff --git a/OpenPolytopia.Common/TerrainGeneration.cs b/OpenPolytopia.Common/TerrainGeneration.cs
index beb43f3..2d423b7 100644
--- a/OpenPolytopia.Common/TerrainGeneration.cs
+++ b/OpenPolytopia.Common/TerrainGeneration.cs
@@ -28,7 +28,7 @@ public class TerrainGeneration(
Grid grid,
CityManager cityManager,
TribeManager tribeManager,
- Player[] players,
+ IPlayer[] players,
int? seed = null) {
// base terrain rates, multiplied by every tribe's TerrainRate
private const float BASE_FOREST_RATE = 0.38f;
diff --git a/OpenPolytopia.Server/GameManager.cs b/OpenPolytopia.Server/GameManager.cs
index d47467b..919ef01 100644
--- a/OpenPolytopia.Server/GameManager.cs
+++ b/OpenPolytopia.Server/GameManager.cs
@@ -12,7 +12,11 @@ namespace OpenPolytopia.Server;
///
public class GameManager {
private readonly Dictionary _sessions = new();
- private readonly Dictionary _connectionToGame = new();
+ /// All retained games, including completed games.
+ public IReadOnlyCollection Sessions => _sessions.Values;
+
+ internal void Clear() => _sessions.Clear();
+ internal void Restore(GameSession session) => _sessions.Add(session.Id, session);
///
/// Returns a running game given its id
@@ -26,7 +30,16 @@ public class GameManager {
/// the connection id
/// the session; null if the connection isn't mapped to any game
public GameSession? FindByConnection(uint connectionId) =>
- _connectionToGame.TryGetValue(connectionId, out var gameId) ? this[gameId] : null;
+ _sessions.Values.FirstOrDefault(session => session.PlayerIdOf(connectionId) != 0);
+
+ /// Lists every game belonging to a persistent account.
+ public IEnumerable FindByAccount(uint accountId) =>
+ _sessions.Values.Where(session => session.PlayerIdOfAccount(accountId) != 0);
+
+ /// Detaches a transport from every game without resigning.
+ public void Disconnect(uint connectionId) {
+ foreach (var session in _sessions.Values) session.RemoveConnection(connectionId);
+ }
///
/// Creates and starts a new game from a lobby that just finished waiting for players
@@ -40,7 +53,8 @@ public class GameManager {
/// the optional world generation seed, mainly for deterministic tests
/// the newly created, started and registered session
/// if the lobby doesn't have 2 to 16 players
- public async Task CreateGameAsync(LobbyData lobby, GameData data, int? seed = null) {
+ public async Task CreateGameAsync(LobbyData lobby, GameData data, int? seed = null,
+ IReadOnlyDictionary? onlineConnections = null) {
ArgumentNullException.ThrowIfNull(lobby);
ArgumentNullException.ThrowIfNull(data);
@@ -48,10 +62,8 @@ public async Task CreateGameAsync(LobbyData lobby, GameData data, i
throw new ArgumentException($"a game needs 2 to 16 players, got {lobby.Players.Count}", nameof(lobby));
}
- foreach (var lobbyPlayer in lobby.Players) {
- if (_connectionToGame.ContainsKey(lobbyPlayer.PlayerId)) {
- throw new InvalidOperationException($"connection {lobbyPlayer.PlayerId} is already playing a game");
- }
+ if (_sessions.ContainsKey(lobby.Id) || lobby.Players.Select(p => p.PlayerId).Distinct().Count() != lobby.Players.Count) {
+ throw new InvalidOperationException("duplicate game or participant");
}
var grid = new Grid(lobby.WorldSize);
@@ -77,11 +89,13 @@ public async Task CreateGameAsync(LobbyData lobby, GameData data, i
game.Start();
var session = new GameSession(lobby.Id, game, connections, names);
- _sessions[lobby.Id] = session;
- foreach (var connectionId in connections.Values) {
- _connectionToGame[connectionId] = lobby.Id;
+ if (onlineConnections != null) {
+ foreach (var connectionId in session.ConnectionIds.ToArray()) session.RemoveConnection(connectionId);
+ foreach (var accountId in connections.Values) {
+ if (onlineConnections.TryGetValue(accountId, out var connectionId)) session.Join(accountId, connectionId);
+ }
}
-
+ _sessions[lobby.Id] = session;
Console.WriteLine($"Created game {lobby.Id} with {players.Length} players on a {lobby.WorldSize}x{lobby.WorldSize} world");
return session;
@@ -91,35 +105,12 @@ public async Task CreateGameAsync(LobbyData lobby, GameData data, i
/// Removes a game and forgets the connections of every one of its players
///
/// the id of the game to remove
- public void RemoveGame(ulong id) {
- if (!_sessions.Remove(id, out var session)) {
- return;
- }
+ public void RemoveGame(ulong id) => _sessions.Remove(id);
- foreach (var connectionId in session.ConnectionIds) {
- if (_connectionToGame.TryGetValue(connectionId, out var gameId) && gameId == id) {
- _connectionToGame.Remove(connectionId);
- }
- }
- }
-
- ///
- /// Forgets the connection of a player, so it stops resolving to the game it was playing
- ///
- ///
- /// The game itself is left untouched: the other players may still be playing it, and the caller decides whether
- /// the player behind the connection resigns
- ///
- /// the connection that went away
- /// the id of the player behind the connection; 0 if it wasn't in any game
- /// the session the connection was playing; null if it wasn't mapped to any game
+ /// Detaches a connection from the first matching session.
public GameSession? RemovePlayer(uint connectionId, out int playerId) {
- playerId = 0;
- if (!_connectionToGame.Remove(connectionId, out var gameId) || this[gameId] is not { } session) {
- return null;
- }
-
- playerId = session.RemoveConnection(connectionId);
+ var session = FindByConnection(connectionId);
+ playerId = session?.RemoveConnection(connectionId) ?? 0;
return session;
}
}
diff --git a/OpenPolytopia.Server/GameServer.Accounts.cs b/OpenPolytopia.Server/GameServer.Accounts.cs
new file mode 100644
index 0000000..d7cca95
--- /dev/null
+++ b/OpenPolytopia.Server/GameServer.Accounts.cs
@@ -0,0 +1,124 @@
+namespace OpenPolytopia.Server;
+
+using OpenPolytopia.Common.Gameplay;
+using OpenPolytopia.Common.Network;
+using OpenPolytopia.Common.Network.Packets;
+
+public partial class GameServer {
+ private readonly Dictionary _authenticated = new();
+ private readonly Dictionary _sessionTokens = new();
+ private readonly Dictionary> _loginAttempts = new();
+ private readonly object _loginLock = new();
+ private readonly SemaphoreSlim _passwordWorkers = new(2, 2);
+
+ private async Task<(AuthResult? Result, bool Retryable)> CheckCredentialsAsync(NetworkConnection connection, IPacket packet) {
+ await _stateLock.WaitAsync(_cts.Token);
+ try {
+ if (_authenticated.TryGetValue(connection.Id, out var id)) return (new AuthResult(new Account {
+ Id = id, Username = "", DisplayName = _playerNames[connection.Id]
+ }, _sessionTokens[connection.Id]), false);
+ }
+ finally { _stateLock.Release(); }
+ var now = DateTimeOffset.UtcNow;
+ lock (_loginLock) {
+ foreach (var key in _loginAttempts.Keys.ToArray()) {
+ var queue = _loginAttempts[key];
+ while (queue.TryPeek(out var time) && now - time >= TimeSpan.FromMinutes(1)) queue.Dequeue();
+ if (queue.Count == 0) _loginAttempts.Remove(key);
+ }
+ var keyForPeer = connection.RemoteAddress;
+ if (!_loginAttempts.TryGetValue(keyForPeer, out var attempts)) {
+ if (_loginAttempts.Count >= 4096) return (null, true);
+ _loginAttempts[keyForPeer] = attempts = new();
+ }
+ if (attempts.Count >= 60) return (null, true);
+ attempts.Enqueue(now);
+ }
+ // Token resume is cheap and does not compete for password workers.
+ if (packet is ResumeSessionPacket resume) return (_store.Resume(resume.Token) is { } account
+ ? new AuthResult(account, resume.Token) : null, false);
+ if (!await _passwordWorkers.WaitAsync(0)) return (null, true);
+ try {
+ var result = await Task.Run(() => packet switch {
+ RegisterAccountPacket register => _store.Register(register.Username, register.Password),
+ LoginPacket login => _store.Login(login.Username, login.Password),
+ _ => null
+ });
+ return (result, false);
+ }
+ catch (ArgumentException) { return (null, false); }
+ finally { _passwordWorkers.Release(); }
+ }
+
+ private uint AccountId(NetworkConnection connection) => _authenticated.GetValueOrDefault(connection.Id);
+
+ private void RegisterAccountHandlers() {
+ _dispatcher.Register((c, _) => {
+ if (_sessionTokens.Remove(c.Id, out var token)) _store.Logout(token);
+ _authenticated.Remove(c.Id);
+ _playerNames.Remove(c.Id);
+ _gameManager.Disconnect(c.Id);
+ SendTo(c.Id, new AuthenticationPacket());
+ });
+ _dispatcher.Register((c, _) => SendTo(c.Id,
+ new MyGamesPacket { GameIds = [.. _gameManager.FindByAccount(AccountId(c)).Select(s => s.Id).Concat(_store.CompletedGameIds(AccountId(c))).Distinct()] }));
+ _dispatcher.Register((c, p) => {
+ var session = FindSession(p.GameId, AccountId(c));
+ if (session != null && session.Join(AccountId(c), c.Id)) {
+ SendTo(c.Id, session.BuildState());
+ }
+ else SendTo(c.Id, new GameStatePacket { GameId = p.GameId,
+ Result = session == null ? GameActionResult.GameNotFound : GameActionResult.NotInGame });
+ });
+ _dispatcher.Register((c, p) => {
+ var session = FindSession(p.GameId, AccountId(c));
+ var result = session == null ? GameActionResult.GameNotFound :
+ session.PlayerIdOfAccount(AccountId(c)) == 0 ? GameActionResult.NotInGame : GameActionResult.Ok;
+ if (result == GameActionResult.Ok) session!.RemoveConnection(c.Id);
+ SendTo(c.Id, new MembershipResultPacket { GameId = p.GameId, Result = result });
+ });
+ _dispatcher.Register((c, p) => {
+ var session = FindSession(p.GameId, AccountId(c));
+ var playerId = session?.PlayerIdOfAccount(AccountId(c)) ?? 0;
+ var result = session == null ? GameActionResult.GameNotFound : playerId == 0 ? GameActionResult.NotInGame :
+ session.Game.Resign(playerId).Result;
+ SendTo(c.Id, new MembershipResultPacket { GameId = p.GameId, Result = result });
+ if (result != GameActionResult.Ok) return;
+ BroadcastTo(session!.ConnectionIds, new PlayerEliminatedPacket {
+ GameId = session.Id, PlayerId = (uint)playerId, Update = session.TakeUpdate()
+ });
+ if (session.Game.Over) EndGame(session);
+ else BroadcastTo(session.ConnectionIds, session.BuildState());
+ });
+ }
+
+ private void Authenticate(NetworkConnection connection, Func authenticate, bool retryable = false) {
+ if (_authenticated.TryGetValue(connection.Id, out var existing)) {
+ SendTo(connection.Id, new AuthenticationPacket { Ok = true, PlayerId = existing,
+ Name = _playerNames[connection.Id], Token = _sessionTokens[connection.Id] });
+ return;
+ }
+ AuthResult? result;
+ try { result = authenticate(); }
+ catch (ArgumentException) { result = null; }
+ if (result == null) {
+ SendTo(connection.Id, new AuthenticationPacket { Retryable = retryable });
+ return;
+ }
+
+ // One active transport per account prevents competing devices from racing the same seat.
+ foreach (var previous in _authenticated.Where(pair => pair.Value == result.Account.Id).Select(pair => pair.Key).ToArray()) {
+ _authenticated.Remove(previous);
+ _playerNames.Remove(previous);
+ _sessionTokens.Remove(previous);
+ _gameManager.Disconnect(previous);
+ Kick(previous);
+ }
+ _authenticated[connection.Id] = result.Account.Id;
+ _playerNames[connection.Id] = result.Account.DisplayName;
+ _sessionTokens[connection.Id] = result.Token;
+ SendTo(connection.Id, new AuthenticationPacket {
+ Ok = true, PlayerId = result.Account.Id, Name = result.Account.DisplayName, Token = result.Token
+ });
+ }
+}
diff --git a/OpenPolytopia.Server/GameServer.Persistence.cs b/OpenPolytopia.Server/GameServer.Persistence.cs
new file mode 100644
index 0000000..500f8de
--- /dev/null
+++ b/OpenPolytopia.Server/GameServer.Persistence.cs
@@ -0,0 +1,123 @@
+namespace OpenPolytopia.Server;
+
+using System.Text.Json;
+using OpenPolytopia.Common.Network;
+using OpenPolytopia.Common;
+using OpenPolytopia.Common.Gameplay;
+using OpenPolytopia.Common.Network.Packets;
+
+public partial class GameServer {
+ private readonly ServerStore _store = new(databasePath ??
+ Environment.GetEnvironmentVariable("OPENPOLYTOPIA_DATABASE") ?? "openpolytopia.db");
+ private static readonly JsonSerializerOptions _json = new() { IncludeFields = true };
+ private readonly List _outbox = [];
+ private string? _savedState;
+ private readonly Dictionary _pendingRenames = new();
+
+ private sealed record SavedSession(ulong Id, GameSnapshot Game, Dictionary Accounts,
+ Dictionary Names);
+ private sealed record SavedServer(int Version, ulong NextLobbyId, List Lobbies,
+ List Games);
+
+ private static SavedSession SaveSession(GameSession session) => new(session.Id,
+ session.Game.ToSnapshot(), new(session.Accounts), new(session.Names));
+
+ private string CaptureState(bool includeCompleted = true) => JsonSerializer.Serialize(new SavedServer(1, _lobbyManager.LastId,
+ [.. _lobbyManager.Lobbies], [.. _gameManager.Sessions.Where(session => includeCompleted || !session.Game.Over).Select(SaveSession)]), _json);
+
+ private void RestoreState(string? json) {
+ if (json == null) return;
+ var state = JsonSerializer.Deserialize(json, _json) ?? throw new InvalidDataException("Empty server state");
+ if (state.Version != 1) throw new InvalidDataException("Unsupported server state version");
+ var nextLobbies = new LobbyManager();
+ nextLobbies.Restore(state.NextLobbyId, state.Lobbies);
+ var nextGames = new GameManager();
+ foreach (var saved in state.Games) {
+ var session = RestoreSession(saved);
+ nextGames.Restore(session);
+ }
+ _lobbyManager = nextLobbies;
+ _gameManager = nextGames;
+ _savedState = json;
+ }
+
+ private GameSession RestoreSession(SavedSession saved) {
+ var troops = new TroopManager(saved.Game.GridSize);
+ troops.RegisterTroops(_gameData.TroopsSerializedData);
+ var game = Game.Restore(saved.Game, troops, _gameData.Tribes, _gameData.Buildings, _gameData.TechTreeDefinition);
+ var session = new GameSession(saved.Id, game, saved.Accounts, saved.Names);
+ foreach (var connection in session.ConnectionIds.ToArray()) session.RemoveConnection(connection);
+ return session;
+ }
+
+ private GameSession? FindSession(ulong id, uint accountId) {
+ if (_gameManager[id] is { } active) return active;
+ var json = _store.LoadCompletedGame(id, accountId);
+ if (json == null) return null;
+ var saved = JsonSerializer.Deserialize(json, _json) ?? throw new InvalidDataException("Empty completed game");
+ var session = RestoreSession(saved);
+ if (!session.Game.Over) throw new InvalidDataException("Archived game is still active");
+ return session;
+ }
+
+ // A successful reply must never describe state which has not reached SQLite.
+ private async Task WithStateAsync(Func action, bool stateMayChange = true) {
+ if (_cts.IsCancellationRequested) return;
+ await _stateLock.WaitAsync();
+ if (_cts.IsCancellationRequested) { _stateLock.Release(); return; }
+ string? before = null;
+ var committed = false;
+ var names = new Dictionary(_playerNames);
+ var accounts = new Dictionary(_authenticated);
+ var tokens = new Dictionary(_sessionTokens);
+ var attachments = _gameManager.Sessions.Where(s => s.Connections.Count != 0).ToDictionary(s => s.Id, s => s.Connections.ToArray());
+ try {
+ before = _savedState ??= CaptureState();
+ var changes = stateMayChange || _lobbyManager.Lobbies.Any(l => l.Starting);
+ await action();
+ var completed = _gameManager.Sessions.Where(session => session.Game.Over).Select(session =>
+ new ArchivedGame(session.Id, JsonSerializer.Serialize(SaveSession(session), _json), session.Accounts.Values.ToArray())).ToArray();
+ var after = changes || completed.Length != 0 ? CaptureState(false) : before;
+ if (after != before || _pendingRenames.Count != 0 || completed.Length != 0)
+ _store.SaveState(after, _pendingRenames, completed);
+ _savedState = after;
+ committed = true;
+ foreach (var game in completed) _gameManager.RemoveGame(game.Id);
+ foreach (var send in _outbox) send();
+ }
+ catch {
+ if (committed) throw;
+ try { RestoreState(before); }
+ catch { Stop(); throw; }
+ _authenticated.Clear();
+ foreach (var (id, account) in accounts) _authenticated[id] = account;
+ _sessionTokens.Clear();
+ foreach (var (id, token) in tokens) _sessionTokens[id] = token;
+ _playerNames.Clear();
+ foreach (var (id, name) in names) _playerNames[id] = name;
+ foreach (var (id, connections) in attachments) {
+ var session = _gameManager[id]!;
+ foreach (var (playerId, connectionId) in connections) session.Join(session.Accounts[playerId], connectionId);
+ }
+ throw;
+ }
+ finally {
+ _outbox.Clear();
+ _pendingRenames.Clear();
+ _stateLock.Release();
+ }
+ }
+
+ private void Kick(uint connection) => _outbox.Add(() => _server.Kick(connection));
+
+ private void SendTo(uint connection, IPacket packet) {
+ var frame = PacketProtocol.FramePacket(packet);
+ _outbox.Add(() => _server.SendTo(connection, frame));
+ }
+ private void Broadcast(IPacket packet) => BroadcastTo(_authenticated.Keys, packet);
+ private void BroadcastTo(IEnumerable connections, IPacket packet) {
+ var recipients = connections.ToArray();
+ var frame = PacketProtocol.FramePacket(packet);
+ _outbox.Add(() => { foreach (var recipient in recipients) _server.SendTo(recipient, frame); });
+ }
+}
diff --git a/OpenPolytopia.Server/GameServer.cs b/OpenPolytopia.Server/GameServer.cs
index 191419f..f2e04a3 100644
--- a/OpenPolytopia.Server/GameServer.cs
+++ b/OpenPolytopia.Server/GameServer.cs
@@ -15,7 +15,7 @@ namespace OpenPolytopia.Server;
///
/// 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 {
+public partial class GameServer(int port, string? bindAddress = null, string? databasePath = null) : IDisposable {
///
/// How often the server checks for lobbies to start
///
@@ -26,9 +26,10 @@ public class GameServer(int port, string? bindAddress = null) : IDisposable {
///
private const int MAX_LOBBIES = 100;
- private readonly ServerConnection _server = new(port, bindAddress);
- private readonly LobbyManager _lobbyManager = new();
- private readonly GameManager _gameManager = new();
+ private readonly System.Security.Cryptography.X509Certificates.X509Certificate2? _certificate = ServerTls.LoadAndValidate(bindAddress);
+ private ServerConnection _server = null!;
+ private LobbyManager _lobbyManager = new();
+ private GameManager _gameManager = new();
private readonly Dictionary _playerNames = new();
// loaded once: every game on this server shares the same static gameplay data
@@ -40,21 +41,31 @@ public class GameServer(int port, string? bindAddress = null) : IDisposable {
private readonly PacketDispatcher _dispatcher = new();
private readonly CancellationTokenSource _cts = new();
+ private int _activeRequests;
///
/// Runs the server until gets called
///
public async Task RunAsync() {
+ _server = new ServerConnection(port, bindAddress, _certificate);
+ RestoreState(_store.LoadState());
RegisterHandlers();
_server.OnPacketReceived += ManagePacketAsync;
_server.OnClientDisconnected += connection => _ = ClientDisconnectedAsync(connection);
// check for lobbies to start in background
- _ = StartLobbiesLoopAsync(_cts.Token);
+ var lobbyLoop = StartLobbiesLoopAsync(_cts.Token);
Console.WriteLine($"Server listening on {bindAddress ?? "*"}:{port}");
- await _server.RunAsync();
+ try { await _server.RunAsync(); }
+ finally {
+ _cts.Cancel();
+ await lobbyLoop;
+ while (Volatile.Read(ref _activeRequests) != 0) await Task.Delay(10);
+ await _stateLock.WaitAsync();
+ _stateLock.Release();
+ }
}
///
@@ -62,17 +73,32 @@ public async Task RunAsync() {
///
public void Stop() {
_cts.Cancel();
- _server.Stop();
+ _server?.Stop();
}
private async Task ManagePacketAsync(NetworkConnection connection, IPacket packet) {
+ if (_cts.IsCancellationRequested) return;
+ Interlocked.Increment(ref _activeRequests);
try {
- await DispatchPacketAsync(connection, packet);
+ if (_server.IsHandshakeDone(connection.Id) &&
+ packet is RegisterAccountPacket or LoginPacket or ResumeSessionPacket) {
+ var authenticated = await CheckCredentialsAsync(connection, packet);
+ await WithStateAsync(() => {
+ Authenticate(connection, () => authenticated.Result, authenticated.Retryable);
+ return Task.CompletedTask;
+ }, false);
+ return;
+ }
+ var mutates = packet is not HandshakePacket and not GetLobbiesPacket and not GetMyGamesPacket and
+ not GetGameStatePacket and not JoinGamePacket and not LeaveGamePacket and not LogoutPacket;
+ await WithStateAsync(() => DispatchPacketAsync(connection, packet), mutates);
}
catch (Exception e) {
// log the error without taking the server down
Console.Error.WriteLine($"Error while managing {packet.GetType().Name} from client {connection.Id}: {e}");
+ connection.Close();
}
+ finally { Interlocked.Decrement(ref _activeRequests); }
}
private async Task DispatchPacketAsync(NetworkConnection connection, IPacket packet) {
@@ -87,6 +113,12 @@ private async Task DispatchPacketAsync(NetworkConnection connection, IPacket pac
return;
}
+ if (packet is not RegisterAccountPacket and not LoginPacket and not ResumeSessionPacket &&
+ !_authenticated.ContainsKey(connection.Id)) {
+ Kick(connection.Id);
+ return;
+ }
+
if (!await _dispatcher.DispatchAsync(connection, packet)) {
// a client sending a packet the server never handles, e.g. a response packet
Console.Error.WriteLine($"Unhandled {packet.GetType().Name} from client {connection.Id}");
@@ -102,6 +134,7 @@ private async Task DispatchPacketAsync(NetworkConnection connection, IPacket pac
///
private void RegisterHandlers() {
// register the player or rename him
+ RegisterAccountHandlers();
_dispatcher.Register(ManageSetNameAsync);
// respond with all the lobbies currently on the server
_dispatcher.Register((connection, _) => ManageGetLobbiesAsync(connection));
@@ -138,51 +171,44 @@ private void ManageHandshake(NetworkConnection connection, HandshakePacket packe
_server.CompleteHandshake(connection.Id);
}
- _server.SendTo(connection.Id, new HandshakeResponsePacket { Ok = ok, PlayerId = connection.Id });
+ SendTo(connection.Id, new HandshakeResponsePacket { Ok = ok, PlayerId = 0 });
// kick clients with an incompatible version, after the response gets delivered
if (!ok) {
- _server.Kick(connection.Id);
+ Kick(connection.Id);
}
}
private async Task ManageSetNameAsync(NetworkConnection connection, SetNamePacket packet) {
var name = packet.Name.Trim();
- var ok = name.Length is > 0 and <= 32;
+ var ok = name.Length is > 0 and <= 32 && !name.Any(char.IsControl);
- await _stateLock.WaitAsync();
- try {
+ {
if (ok) {
+ _pendingRenames[AccountId(connection)] = name;
_playerNames[connection.Id] = name;
+ foreach (var session in _gameManager.FindByAccount(AccountId(connection))) session.Rename(AccountId(connection), name);
// propagate the rename into the lobbies the player joined
List updated = [];
- _lobbyManager.RenamePlayerInLobbies(connection.Id, name, updated);
+ _lobbyManager.RenamePlayerInLobbies(AccountId(connection), name, updated);
foreach (var lobby in updated) {
- _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby });
+ Broadcast(new LobbyUpdatedPacket { Lobby = lobby });
}
}
- _server.SendTo(connection.Id, new SetNameResponsePacket { Ok = ok });
- }
- finally {
- _stateLock.Release();
+ SendTo(connection.Id, new SetNameResponsePacket { Ok = ok });
}
}
private async Task ManageGetLobbiesAsync(NetworkConnection connection) {
- await _stateLock.WaitAsync();
- try {
- _server.SendTo(connection.Id, new GetLobbiesResponsePacket { Lobbies = [.. _lobbyManager.Lobbies] });
- }
- finally {
- _stateLock.Release();
+ {
+ SendTo(connection.Id, new GetLobbiesResponsePacket { Lobbies = [.. _lobbyManager.Lobbies] });
}
}
private async Task ManageCreateLobbyAsync(NetworkConnection connection, CreateLobbyPacket packet) {
- await _stateLock.WaitAsync();
- try {
+ {
LobbyData? lobby = null;
LobbyActionResult result;
@@ -193,34 +219,25 @@ private async Task ManageCreateLobbyAsync(NetworkConnection connection, CreateLo
else if (_gameData.Tribes[(TribeType)packet.Tribe] == null) {
result = LobbyActionResult.InvalidParameters;
}
- // a player can wait in one lobby or play one game, never both
- else if (_lobbyManager.IsPlayerInAnyLobby(connection.Id) ||
- _gameManager.FindByConnection(connection.Id) != null) {
- result = LobbyActionResult.AlreadyJoinedLobby;
- }
else if (_lobbyManager.LobbiesCount >= MAX_LOBBIES) {
result = LobbyActionResult.TooManyLobbies;
}
else {
// the lobby rules themselves are checked by the manager, so a lobby is never half-valid
result = _lobbyManager.CreateLobby(packet.MaxPlayers, packet.WorldSize,
- new LobbyPlayerData { PlayerId = connection.Id, Name = name, Tribe = packet.Tribe }, out lobby);
+ new LobbyPlayerData { PlayerId = AccountId(connection), Name = name, Tribe = packet.Tribe }, out lobby, packet.TimerMode);
}
- _server.SendTo(connection.Id, new CreateLobbyResponsePacket { Result = result, LobbyId = lobby?.Id ?? 0 });
+ SendTo(connection.Id, new CreateLobbyResponsePacket { Result = result, LobbyId = lobby?.Id ?? 0 });
if (lobby != null) {
- _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby });
+ Broadcast(new LobbyUpdatedPacket { Lobby = lobby });
}
}
- finally {
- _stateLock.Release();
- }
}
private async Task ManageJoinLobbyAsync(NetworkConnection connection, JoinLobbyPacket packet) {
- await _stateLock.WaitAsync();
- try {
+ {
LobbyActionResult result;
if (!_playerNames.TryGetValue(connection.Id, out var name)) {
@@ -230,132 +247,67 @@ private async Task ManageJoinLobbyAsync(NetworkConnection connection, JoinLobbyP
else if (_gameData.Tribes[(TribeType)packet.Tribe] == null) {
result = LobbyActionResult.InvalidParameters;
}
- // a player can wait in one lobby or play one game, never both
- else if (_lobbyManager.IsPlayerInAnyLobby(connection.Id) ||
- _gameManager.FindByConnection(connection.Id) != null) {
- result = LobbyActionResult.AlreadyJoinedLobby;
- }
else {
result = _lobbyManager.JoinLobby(packet.LobbyId,
- new LobbyPlayerData { PlayerId = connection.Id, Name = name, Tribe = packet.Tribe });
+ new LobbyPlayerData { PlayerId = AccountId(connection), Name = name, Tribe = packet.Tribe });
}
- _server.SendTo(connection.Id, new JoinLobbyResponsePacket { Result = result, LobbyId = packet.LobbyId });
+ SendTo(connection.Id, new JoinLobbyResponsePacket { Result = result, LobbyId = packet.LobbyId });
if (result == LobbyActionResult.Ok && _lobbyManager[packet.LobbyId] is { } lobby) {
- _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby });
+ Broadcast(new LobbyUpdatedPacket { Lobby = lobby });
}
}
- finally {
- _stateLock.Release();
- }
}
private async Task ManageLeaveLobbyAsync(NetworkConnection connection, LeaveLobbyPacket packet) {
- await _stateLock.WaitAsync();
- try {
- var result = _lobbyManager.LeaveLobby(packet.LobbyId, connection.Id);
+ {
+ var result = _lobbyManager.LeaveLobby(packet.LobbyId, AccountId(connection));
- _server.SendTo(connection.Id, new LeaveLobbyResponsePacket { Result = result, LobbyId = packet.LobbyId });
+ SendTo(connection.Id, new LeaveLobbyResponsePacket { Result = result, LobbyId = packet.LobbyId });
if (result == LobbyActionResult.Ok) {
// the manager drops a lobby as soon as its last player leaves, so a missing
// lobby here means it became empty
if (_lobbyManager[packet.LobbyId] is { } lobby) {
- _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby });
+ Broadcast(new LobbyUpdatedPacket { Lobby = lobby });
}
else {
- _server.Broadcast(new LobbyDeletedPacket { LobbyId = packet.LobbyId });
+ Broadcast(new LobbyDeletedPacket { LobbyId = packet.LobbyId });
}
}
}
- finally {
- _stateLock.Release();
- }
}
private async Task ManageSetReadyAsync(NetworkConnection connection, SetReadyPacket packet) {
- await _stateLock.WaitAsync();
- try {
- var result = _lobbyManager.SetReady(packet.LobbyId, connection.Id, packet.Ready);
+ {
+ var result = _lobbyManager.SetReady(packet.LobbyId, AccountId(connection), packet.Ready);
- _server.SendTo(connection.Id, new SetReadyResponsePacket { Result = result, LobbyId = packet.LobbyId });
+ SendTo(connection.Id, new SetReadyResponsePacket { Result = result, LobbyId = packet.LobbyId });
if (result == LobbyActionResult.Ok && _lobbyManager[packet.LobbyId] is { } lobby) {
- _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby });
+ Broadcast(new LobbyUpdatedPacket { Lobby = lobby });
}
}
- finally {
- _stateLock.Release();
- }
}
- private async Task ClientDisconnectedAsync(NetworkConnection connection) {
- await _stateLock.WaitAsync();
- try {
- _playerNames.Remove(connection.Id);
-
- List updated = [];
- List deletedIds = [];
- _lobbyManager.RemovePlayerFromAllLobbies(connection.Id, updated, deletedIds);
-
- foreach (var lobby in updated) {
- _server.Broadcast(new LobbyUpdatedPacket { Lobby = lobby });
- }
-
- foreach (var id in deletedIds) {
- _server.Broadcast(new LobbyDeletedPacket { LobbyId = id });
- }
-
- DisconnectFromGame(connection);
- }
- finally {
- _stateLock.Release();
- }
- }
-
- ///
- /// Resigns a disconnected player from the game they were playing, if any
- ///
- ///
- /// The connection is forgotten first, so the packets that follow only reach the players still connected
- ///
- /// the connection that just disconnected
- private void DisconnectFromGame(NetworkConnection connection) {
- var session = _gameManager.RemovePlayer(connection.Id, out var playerId);
- if (session == null || playerId == 0 || session.Game.Over) {
- return;
- }
-
- // resigning only passes the turn when it was the resigning player's turn; otherwise nothing changes for the others
- var heldTheTurn = session.Game.CurrentPlayer == playerId;
- var result = session.Game.Resign(playerId);
- if (result.Result != GameActionResult.Ok) {
- return;
- }
-
- _server.BroadcastTo(session.ConnectionIds,
- new PlayerEliminatedPacket { GameId = session.Id, PlayerId = (uint)playerId, Update = session.TakeUpdate() });
-
- if (result.GameOver) {
- EndGame(session);
- }
- else if (heldTheTurn) {
- _server.BroadcastTo(session.ConnectionIds, new TurnStartedPacket {
- GameId = session.Id, Turn = result.Turn, PlayerId = (uint)result.NextPlayer, Update = session.TakeUpdate()
- });
- }
- }
+ private Task ClientDisconnectedAsync(NetworkConnection connection) => _cts.IsCancellationRequested ? Task.CompletedTask : WithStateAsync(() => {
+ _playerNames.Remove(connection.Id);
+ _authenticated.Remove(connection.Id);
+ _sessionTokens.Remove(connection.Id);
+ _gameManager.Disconnect(connection.Id);
+ return Task.CompletedTask;
+ }, false);
///
/// Tells the players of a finished game who won and forgets the game
///
/// the session whose game is over
private void EndGame(GameSession session) {
- _server.BroadcastTo(session.ConnectionIds, new GameOverPacket {
+ BroadcastTo(session.ConnectionIds, new GameOverPacket {
GameId = session.Id, Winner = (uint)session.Game.Winner, Players = session.TakeUpdate().Players
});
- _gameManager.RemoveGame(session.Id);
+ // Retain completed games so their members can reconnect and inspect the result.
}
private async Task StartLobbiesLoopAsync(CancellationToken ct) {
@@ -363,14 +315,13 @@ private async Task StartLobbiesLoopAsync(CancellationToken ct) {
try {
while (await timer.WaitForNextTickAsync(ct)) {
- await _stateLock.WaitAsync(ct);
try {
- foreach (var lobby in _lobbyManager.TakeStartingLobbies()) {
- await StartLobbyAsync(lobby);
- }
+ await WithStateAsync(async () => {
+ foreach (var lobby in _lobbyManager.TakeStartingLobbies()) await StartLobbyAsync(lobby);
+ }, false);
}
- finally {
- _stateLock.Release();
+ catch (Exception e) when (e is not OperationCanceledException) {
+ Console.Error.WriteLine($"Lobby update failed; state restored: {e}");
}
}
}
@@ -388,22 +339,25 @@ private async Task StartLobbiesLoopAsync(CancellationToken ct) {
///
/// the lobby to start
private async Task StartLobbyAsync(LobbyData lobby) {
- var connectionIds = lobby.Players.Select(player => player.PlayerId).ToList();
+ var online = _authenticated.ToDictionary(pair => pair.Value, pair => pair.Key);
+ var connectionIds = lobby.Players.Where(player => online.ContainsKey(player.PlayerId))
+ .Select(player => online[player.PlayerId]).ToList();
try {
- var session = await _gameManager.CreateGameAsync(lobby, _gameData);
+ var session = await _gameManager.CreateGameAsync(lobby, _gameData, onlineConnections: online);
Console.WriteLine($"Starting game for lobby {lobby.Id} with {lobby.PlayersCount} players");
// notify the players that their game started and send them its full state
- _server.BroadcastTo(connectionIds, new GameStartedPacket { LobbyId = lobby.Id, Players = lobby.Players });
- _server.BroadcastTo(connectionIds, session.BuildState());
+ BroadcastTo(connectionIds, new GameStartedPacket { LobbyId = lobby.Id, Players = lobby.Players });
+ BroadcastTo(connectionIds, session.BuildState());
}
catch (Exception e) {
Console.Error.WriteLine($"Couldn't start the game for lobby {lobby.Id}: {e}");
+ throw;
}
- _server.Broadcast(new LobbyDeletedPacket { LobbyId = lobby.Id });
+ Broadcast(new LobbyDeletedPacket { LobbyId = lobby.Id });
}
///
@@ -425,7 +379,8 @@ private async Task StartLobbyAsync(LobbyData lobby) {
/// true if a session was found and the connection is a player in it
private bool TryResolveSession(NetworkConnection connection, ulong gameId,
[NotNullWhen(true)] out GameSession? session, out int playerId, out GameActionResult result) {
- session = _gameManager[gameId];
+ session = FindSession(gameId, AccountId(connection));
+ if (session?.Game.Over == true) session.Join(AccountId(connection), connection.Id);
if (session == null) {
playerId = 0;
result = GameActionResult.GameNotFound;
@@ -443,161 +398,136 @@ private bool TryResolveSession(NetworkConnection connection, ulong gameId,
}
private async Task ManageGetGameStateAsync(NetworkConnection connection, GetGameStatePacket packet) {
- await _stateLock.WaitAsync();
- try {
+ {
var response = TryResolveSession(connection, packet.GameId, out var session, out _, out var check)
? session.BuildState()
: new GameStatePacket { Result = check, GameId = packet.GameId };
- _server.SendTo(connection.Id, response);
- }
- finally {
- _stateLock.Release();
+ SendTo(connection.Id, response);
}
}
private async Task ManageMoveTroopAsync(NetworkConnection connection, MoveTroopPacket packet) {
- await _stateLock.WaitAsync();
- try {
+ {
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) {
- _server.SendTo(connection.Id, new MoveTroopResponsePacket { Result = check });
+ SendTo(connection.Id, new MoveTroopResponsePacket { Result = check });
return;
}
var result = session.Game.MoveTroop(playerId, packet.From, packet.To);
- _server.SendTo(connection.Id, new MoveTroopResponsePacket { Result = result });
+ SendTo(connection.Id, new MoveTroopResponsePacket { Result = result });
if (result != GameActionResult.Ok) {
return;
}
- _server.BroadcastTo(session.ConnectionIds, new TroopMovedPacket {
+ BroadcastTo(session.ConnectionIds, new TroopMovedPacket {
GameId = packet.GameId, PlayerId = (uint)playerId, From = packet.From, To = packet.To,
Update = session.TakeUpdate()
});
}
- finally {
- _stateLock.Release();
- }
}
private async Task ManageAttackAsync(NetworkConnection connection, AttackPacket packet) {
- await _stateLock.WaitAsync();
- try {
+ {
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) {
- _server.SendTo(connection.Id, new AttackResponsePacket { Result = check });
+ SendTo(connection.Id, new AttackResponsePacket { Result = check });
return;
}
var result = session.Game.Attack(playerId, packet.From, packet.Target);
- _server.SendTo(connection.Id, new AttackResponsePacket { Result = result.Result });
+ SendTo(connection.Id, new AttackResponsePacket { Result = result.Result });
if (result.Result != GameActionResult.Ok) {
return;
}
- _server.BroadcastTo(session.ConnectionIds, new CombatPacket {
+ BroadcastTo(session.ConnectionIds, new CombatPacket {
GameId = packet.GameId, PlayerId = (uint)playerId, From = packet.From, Target = packet.Target,
AttackerHp = result.AttackerHp, DefenderHp = result.DefenderHp, AttackerKilled = result.AttackerKilled,
DefenderKilled = result.DefenderKilled, AttackerPosition = result.AttackerPosition,
Update = session.TakeUpdate()
});
}
- finally {
- _stateLock.Release();
- }
}
private async Task ManageTrainTroopAsync(NetworkConnection connection, TrainTroopPacket packet) {
- await _stateLock.WaitAsync();
- try {
+ {
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) {
- _server.SendTo(connection.Id, new TrainTroopResponsePacket { Result = check });
+ SendTo(connection.Id, new TrainTroopResponsePacket { Result = check });
return;
}
var result = session.Game.TrainTroop(playerId, packet.City, (TroopType)packet.TroopType);
- _server.SendTo(connection.Id, new TrainTroopResponsePacket { Result = result });
+ SendTo(connection.Id, new TrainTroopResponsePacket { Result = result });
if (result != GameActionResult.Ok) {
return;
}
- _server.BroadcastTo(session.ConnectionIds, new TroopTrainedPacket {
+ BroadcastTo(session.ConnectionIds, new TroopTrainedPacket {
GameId = packet.GameId, PlayerId = (uint)playerId, Position = packet.City, TroopType = packet.TroopType,
Update = session.TakeUpdate()
});
}
- finally {
- _stateLock.Release();
- }
}
private async Task ManageResearchTechAsync(NetworkConnection connection, ResearchTechPacket packet) {
- await _stateLock.WaitAsync();
- try {
+ {
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) {
- _server.SendTo(connection.Id, new ResearchTechResponsePacket { Result = check });
+ SendTo(connection.Id, new ResearchTechResponsePacket { Result = check });
return;
}
var result = session.Game.ResearchTech(playerId, packet.TechId);
- _server.SendTo(connection.Id, new ResearchTechResponsePacket { Result = result });
+ SendTo(connection.Id, new ResearchTechResponsePacket { Result = result });
if (result != GameActionResult.Ok) {
return;
}
- _server.BroadcastTo(session.ConnectionIds, new TechResearchedPacket {
+ BroadcastTo(session.ConnectionIds, new TechResearchedPacket {
GameId = packet.GameId, PlayerId = (uint)playerId, TechId = packet.TechId, Update = session.TakeUpdate()
});
}
- finally {
- _stateLock.Release();
- }
}
private async Task ManageBuildAsync(NetworkConnection connection, BuildPacket packet) {
- await _stateLock.WaitAsync();
- try {
+ {
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) {
- _server.SendTo(connection.Id, new BuildResponsePacket { Result = check });
+ SendTo(connection.Id, new BuildResponsePacket { Result = check });
return;
}
var result = session.Game.Build(playerId, packet.Position, (BuildingType)packet.Building);
- _server.SendTo(connection.Id, new BuildResponsePacket { Result = result.Result });
+ SendTo(connection.Id, new BuildResponsePacket { Result = result.Result });
if (result.Result != GameActionResult.Ok) {
return;
}
- _server.BroadcastTo(session.ConnectionIds, new BuildingBuiltPacket {
+ BroadcastTo(session.ConnectionIds, new BuildingBuiltPacket {
GameId = packet.GameId, PlayerId = (uint)playerId, Position = packet.Position, Building = packet.Building,
Update = session.TakeUpdate()
});
}
- finally {
- _stateLock.Release();
- }
}
private async Task ManageCaptureAsync(NetworkConnection connection, CapturePacket packet) {
- await _stateLock.WaitAsync();
- try {
+ {
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) {
- _server.SendTo(connection.Id, new CaptureResponsePacket { Result = check });
+ SendTo(connection.Id, new CaptureResponsePacket { Result = check });
return;
}
var result = session.Game.Capture(playerId, packet.Position);
- _server.SendTo(connection.Id, new CaptureResponsePacket { Result = result.Result });
+ SendTo(connection.Id, new CaptureResponsePacket { Result = result.Result });
if (result.Result != GameActionResult.Ok) {
return;
}
- _server.BroadcastTo(session.ConnectionIds, new CityCapturedPacket {
+ BroadcastTo(session.ConnectionIds, new CityCapturedPacket {
GameId = packet.GameId, PlayerId = (uint)playerId, Position = packet.Position,
PreviousOwner = (uint)result.PreviousOwner, EliminatedPlayer = (uint)result.EliminatedPlayer,
Update = session.TakeUpdate()
@@ -608,21 +538,17 @@ private async Task ManageCaptureAsync(NetworkConnection connection, CapturePacke
EndGame(session);
}
}
- finally {
- _stateLock.Release();
- }
}
private async Task ManageEndTurnAsync(NetworkConnection connection, EndTurnPacket packet) {
- await _stateLock.WaitAsync();
- try {
+ {
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) {
- _server.SendTo(connection.Id, new EndTurnResponsePacket { Result = check });
+ SendTo(connection.Id, new EndTurnResponsePacket { Result = check });
return;
}
var result = session.Game.EndTurn(playerId);
- _server.SendTo(connection.Id, new EndTurnResponsePacket { Result = result.Result });
+ SendTo(connection.Id, new EndTurnResponsePacket { Result = result.Result });
if (result.Result != GameActionResult.Ok) {
return;
@@ -632,21 +558,20 @@ private async Task ManageEndTurnAsync(NetworkConnection connection, EndTurnPacke
EndGame(session);
}
else {
- _server.BroadcastTo(session.ConnectionIds, new TurnStartedPacket {
+ BroadcastTo(session.ConnectionIds, new TurnStartedPacket {
GameId = packet.GameId, Turn = result.Turn, PlayerId = (uint)result.NextPlayer,
Update = session.TakeUpdate()
});
}
}
- finally {
- _stateLock.Release();
- }
}
public void Dispose() {
Stop();
_cts.Dispose();
- _server.Dispose();
+ _server?.Dispose();
+ _certificate?.Dispose();
+ _store.Dispose();
_stateLock.Dispose();
GC.SuppressFinalize(this);
}
diff --git a/OpenPolytopia.Server/GameSession.cs b/OpenPolytopia.Server/GameSession.cs
index bd9567a..696941c 100644
--- a/OpenPolytopia.Server/GameSession.cs
+++ b/OpenPolytopia.Server/GameSession.cs
@@ -14,6 +14,7 @@ namespace OpenPolytopia.Server;
///
public class GameSession {
private readonly Dictionary _connections;
+ private readonly Dictionary _accounts;
private readonly Dictionary _playerIdByConnection;
private readonly Dictionary _names;
private readonly ulong[] _tileSnapshot;
@@ -49,7 +50,8 @@ public class GameSession {
internal GameSession(ulong id, Game game, Dictionary connections, Dictionary names) {
Id = id;
Game = game;
- _connections = connections;
+ _connections = new(connections);
+ _accounts = new(connections);
_names = names;
_playerIdByConnection = connections.ToDictionary(pair => pair.Value, pair => pair.Key);
@@ -83,6 +85,29 @@ internal int RemoveConnection(uint connectionId) {
return playerId;
}
+ /// Persistent account ids keyed by in-game player id.
+ public IReadOnlyDictionary Accounts => _accounts;
+
+ /// Names retained independently of active connections.
+ public IReadOnlyDictionary Names => _names;
+
+ /// Resolves durable membership without requiring an open game view.
+ internal void Rename(uint accountId, string name) => _names[PlayerIdOfAccount(accountId)] = name;
+
+ public int PlayerIdOfAccount(uint accountId) =>
+ _accounts.FirstOrDefault(pair => pair.Value == accountId).Key;
+
+ /// Attaches a member to this game's updates. Never creates a new seat.
+ public bool Join(uint accountId, uint connectionId) {
+ var playerId = PlayerIdOfAccount(accountId);
+ if (playerId == 0) return false;
+ RemoveConnection(connectionId);
+ if (_connections.TryGetValue(playerId, out var oldConnection)) RemoveConnection(oldConnection);
+ _connections[playerId] = connectionId;
+ _playerIdByConnection[connectionId] = playerId;
+ return true;
+ }
+
///
/// Diffs the current grid and troops against the last snapshot and refreshes it
///
@@ -158,6 +183,7 @@ public GameStatePacket BuildState() {
/// the player's public state, ready to send over the network
private GamePlayerData BuildPlayerData(PlayerState player) => new() {
PlayerId = (uint)player.Id,
+ AccountId = _accounts.GetValueOrDefault(player.Id),
Name = _names.GetValueOrDefault(player.Id, ""),
Tribe = (uint)player.Tribe,
Stars = player.Stars,
diff --git a/OpenPolytopia.Server/LobbyManager.cs b/OpenPolytopia.Server/LobbyManager.cs
index 6d68b67..842fa60 100644
--- a/OpenPolytopia.Server/LobbyManager.cs
+++ b/OpenPolytopia.Server/LobbyManager.cs
@@ -13,6 +13,19 @@ public class LobbyManager {
private readonly Dictionary _lobbies = new();
private ulong _nextId;
+ /// Last allocated id, retained even after the last lobby is removed.
+ public ulong LastId => _nextId;
+
+ /// Restores durable lobby state before accepting connections.
+ public void Restore(ulong lastId, IEnumerable lobbies) {
+ _lobbies.Clear();
+ _nextId = lastId;
+ foreach (var lobby in lobbies) {
+ if (lobby.Id == 0 || lobby.Id > lastId || !_lobbies.TryAdd(lobby.Id, lobby))
+ throw new InvalidDataException("Invalid persisted lobby id");
+ }
+ }
+
///
/// All the lobbies on the server
///
@@ -41,14 +54,14 @@ public class LobbyManager {
/// the new lobby, or if it couldn't be created
/// the result of the operation
public LobbyActionResult CreateLobby(uint maxPlayers, uint worldSize, LobbyPlayerData creator,
- out LobbyData? lobby) {
- if (!LobbyRules.IsValidLobby(maxPlayers, worldSize)) {
+ out LobbyData? lobby, uint timerMode = 1) {
+ if (!LobbyRules.IsValidLobby(maxPlayers, worldSize) || timerMode > 1) {
lobby = null;
return LobbyActionResult.InvalidParameters;
}
lobby = new LobbyData {
- Id = ++_nextId, MaxPlayers = maxPlayers, WorldSize = worldSize, Players = [creator]
+ Id = ++_nextId, MaxPlayers = maxPlayers, WorldSize = worldSize, Players = [creator], TimerMode = timerMode
};
_lobbies[lobby.Id] = lobby;
return LobbyActionResult.Ok;
diff --git a/OpenPolytopia.Server/OpenPolytopia.Server.csproj b/OpenPolytopia.Server/OpenPolytopia.Server.csproj
index fb394f1..7f07158 100644
--- a/OpenPolytopia.Server/OpenPolytopia.Server.csproj
+++ b/OpenPolytopia.Server/OpenPolytopia.Server.csproj
@@ -10,6 +10,11 @@
true
+
+
+
+
+
diff --git a/OpenPolytopia.Server/README.md b/OpenPolytopia.Server/README.md
new file mode 100644
index 0000000..bec80d0
--- /dev/null
+++ b/OpenPolytopia.Server/README.md
@@ -0,0 +1,45 @@
+# Dedicated server
+
+Run locally with `dotnet run --project OpenPolytopia.Server -- 6969 127.0.0.1`.
+The server saves accounts, sessions, lobbies and games to `openpolytopia.db` in the working directory.
+Set `OPENPOLYTOPIA_DATABASE` to an absolute path on a persistent volume for deployment.
+Run one server process per database. Preserve the database when updating or restarting the server.
+
+Remote connections require TLS. Set `OPENPOLYTOPIA_TLS_CERTIFICATE` to a PFX certificate with its private key
+and `OPENPOLYTOPIA_TLS_PASSWORD` to its password, then bind to the desired interface through
+`OPENPOLYTOPIA_BIND_ADDRESS` (or the second command-line argument). The certificate must be trusted by
+clients and match the hostname they use. Only loopback bindings permit plaintext development connections;
+clients enable TLS automatically for other hosts and never fall back to plaintext after TLS failure.
+
+The SQLite store uses WAL and transactional writes. Back up using SQLite's backup API, or stop the server
+before copying the database; copying only the main file during live writes can miss the WAL.
+Schema and game snapshot versions reject unsupported formats. Gameplay content must remain compatible
+with existing snapshots; changes to packed tiles, troops or technology definitions need a migration.
+Completed games remain available to their members.
+
+## Accounts and connections
+
+After the protocol handshake, send `RegisterAccountPacket`, `LoginPacket` or `ResumeSessionPacket`.
+Usernames are unique without regard to case and accept 3–32 ASCII letters, digits or underscores.
+Passwords accept 12–128 characters. Passwords are salted and derived with PBKDF2-HMAC-SHA256;
+only hashes of opaque session tokens are stored. Sessions expire after 30 days.
+The server bounds authentication attempts across connections to limit expensive password work.
+
+`AuthenticationPacket` supplies the stable account id and a session token. The handshake id is not an
+account identity. The client stores the token per server address; it never stores the password.
+`LogoutPacket` revokes that session. Logging in from another connection detaches the old connection.
+`SetNamePacket` changes the authenticated account's display name; it cannot create or authenticate accounts.
+The protocol version is changed so older clients fail the handshake cleanly.
+
+An account can belong to multiple lobbies and games. Lobby operations identify the target lobby explicitly.
+Disconnecting preserves lobby membership and readiness. Fully ready lobbies may start while members are offline.
+
+`GetMyGamesPacket` lists retained game ids. `JoinGamePacket` opens an existing seat, subscribes to its updates
+and returns a full `GameStatePacket`. It never creates a seat in a game the account does not own.
+`LeaveGamePacket` closes that view without resigning; disconnecting closes all views with the same effect.
+Gameplay requests require an open view and remain subject to the engine's ownership and turn checks.
+`ResignGamePacket` is the explicit permanent resignation operation and does not require an open view.
+
+Successful state-changing responses are queued only after SQLite commits. Lobby ids, membership, full game
+state and results survive a restart. Transports are deliberately absent from snapshots: reconnecting clients
+must authenticate and open their games again.
diff --git a/OpenPolytopia.Server/ServerStore.Archive.cs b/OpenPolytopia.Server/ServerStore.Archive.cs
new file mode 100644
index 0000000..523c838
--- /dev/null
+++ b/OpenPolytopia.Server/ServerStore.Archive.cs
@@ -0,0 +1,56 @@
+namespace OpenPolytopia.Server;
+
+using System.Globalization;
+using Microsoft.Data.Sqlite;
+
+/// A completed match and its account membership, stored outside the live snapshot.
+public sealed record ArchivedGame(ulong Id, string State, IReadOnlyCollection Accounts);
+
+public sealed partial class ServerStore {
+ private void SaveCompletedGames(SqliteTransaction transaction, IReadOnlyList? games) {
+ if (games == null) return;
+ foreach (var game in games) {
+ var id = game.Id.ToString(CultureInfo.InvariantCulture);
+ using var command = _connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = "INSERT INTO completed_games (id, state) VALUES ($id, $state) ON CONFLICT(id) DO UPDATE SET state = excluded.state";
+ command.Parameters.AddWithValue("$id", id);
+ command.Parameters.AddWithValue("$state", game.State);
+ command.ExecuteNonQuery();
+ foreach (var account in game.Accounts) {
+ using var member = _connection.CreateCommand();
+ member.Transaction = transaction;
+ member.CommandText = "INSERT OR IGNORE INTO completed_game_members (game_id, account_id) VALUES ($id, $account)";
+ member.Parameters.AddWithValue("$id", id);
+ member.Parameters.AddWithValue("$account", account);
+ member.ExecuteNonQuery();
+ }
+ }
+ }
+
+ /// Reads a completed match only when the account is one of its members.
+ public string? LoadCompletedGame(ulong gameId, uint accountId) {
+ lock (_lock) {
+ ThrowIfDisposed();
+ using var command = _connection.CreateCommand();
+ command.CommandText = "SELECT state FROM completed_games JOIN completed_game_members ON id = game_id WHERE id = $id AND account_id = $account";
+ command.Parameters.AddWithValue("$id", gameId.ToString(CultureInfo.InvariantCulture));
+ command.Parameters.AddWithValue("$account", accountId);
+ return command.ExecuteScalar() as string;
+ }
+ }
+
+ /// Lists a member's completed matches without loading their world snapshots.
+ public IReadOnlyList CompletedGameIds(uint accountId) {
+ lock (_lock) {
+ ThrowIfDisposed();
+ using var command = _connection.CreateCommand();
+ command.CommandText = "SELECT game_id FROM completed_game_members WHERE account_id = $account";
+ command.Parameters.AddWithValue("$account", accountId);
+ using var reader = command.ExecuteReader();
+ var ids = new List();
+ while (reader.Read()) ids.Add(ulong.Parse(reader.GetString(0), CultureInfo.InvariantCulture));
+ return ids;
+ }
+ }
+}
diff --git a/OpenPolytopia.Server/ServerStore.cs b/OpenPolytopia.Server/ServerStore.cs
new file mode 100644
index 0000000..09e33ba
--- /dev/null
+++ b/OpenPolytopia.Server/ServerStore.cs
@@ -0,0 +1,564 @@
+// cspell:words AUTOINCREMENT
+namespace OpenPolytopia.Server;
+
+using System.Security.Cryptography;
+using Microsoft.Data.Sqlite;
+
+///
+/// A registered account, as stored by
+///
+///
+/// Never carries any secret: password material and session tokens never leave the store
+///
+public sealed record Account {
+ ///
+ /// Stable id of the account, never reused once assigned
+ ///
+ public required uint Id { get; init; }
+
+ ///
+ /// Normalized (lowercase) username, unique across the store
+ ///
+ public required string Username { get; init; }
+
+ ///
+ /// Name to show to other players, the username exactly as it was typed at registration time
+ ///
+ public required string DisplayName { get; init; }
+}
+
+///
+/// The result of a successful authentication
+///
+/// the authenticated account
+///
+/// the raw session token, only ever returned here: the store keeps a hash of it and can't recover it
+///
+public sealed record AuthResult(Account Account, string Token);
+
+///
+/// Persistent server storage: accounts, sessions and the server state snapshot
+///
+///
+///
+/// Every API is synchronous and thread-safe; the store serializes all the accesses to the underlying
+/// SQLite connection through its own lock
+///
+///
+/// Passwords are stored as PBKDF2-HMAC-SHA256 hashes over a per-account random salt, session tokens as
+/// SHA-256 hashes of 256 bits of cryptographically random data; neither can be recovered from the database
+///
+///
+public sealed partial class ServerStore : IDisposable {
+ ///
+ /// Schema version this class knows how to read and write
+ ///
+ ///
+ /// A database with a higher version was written by a newer server and is rejected on open
+ ///
+ public const int SCHEMA_VERSION = 2;
+
+ ///
+ /// Minimum length of a username
+ ///
+ public const int MIN_USERNAME_LENGTH = 3;
+
+ ///
+ /// Maximum length of a username
+ ///
+ public const int MAX_USERNAME_LENGTH = 32;
+
+ ///
+ /// Minimum length of a password
+ ///
+ public const int MIN_PASSWORD_LENGTH = 12;
+
+ ///
+ /// Maximum length of a password
+ ///
+ public const int MAX_PASSWORD_LENGTH = 128;
+
+ ///
+ /// PBKDF2 iterations used to derive password hashes
+ ///
+ public const int PBKDF2_ITERATIONS = 600_000;
+
+ private const int SALT_SIZE = 16;
+ private const int HASH_SIZE = 32;
+ private const int TOKEN_SIZE = 32;
+
+ ///
+ /// How long a session stays valid after it's been created
+ ///
+ public static TimeSpan SessionLifetime { get; } = TimeSpan.FromDays(30);
+
+ ///
+ /// Salt used to burn the same amount of time on a login for an account that doesn't exist
+ ///
+ private static readonly byte[] _dummySalt = new byte[SALT_SIZE];
+
+ private readonly SqliteConnection _connection;
+ private readonly TimeProvider _timeProvider;
+ private readonly Lock _lock = new();
+ private bool _disposed;
+
+ ///
+ /// Opens (creating it if needed) the database at and migrates it
+ ///
+ /// path of the SQLite database file
+ ///
+ /// clock used for session creation and expiry, if
+ ///
+ /// if is empty
+ ///
+ /// if the database was written by a newer server, i.e. its schema version is greater than
+ ///
+ ///
+ public ServerStore(string databasePath, TimeProvider? timeProvider = null) {
+ ArgumentException.ThrowIfNullOrWhiteSpace(databasePath);
+
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ _connection = new SqliteConnection(new SqliteConnectionStringBuilder {
+ DataSource = databasePath, Mode = SqliteOpenMode.ReadWriteCreate, ForeignKeys = true, Pooling = false
+ }.ToString());
+ _connection.Open();
+
+ try {
+ Execute("PRAGMA journal_mode = WAL;");
+ Execute("PRAGMA foreign_keys = ON;");
+ Migrate();
+ }
+ catch {
+ _connection.Dispose();
+ throw;
+ }
+ }
+
+ ///
+ /// Registers a new account and opens a session for it
+ ///
+ ///
+ /// username to register, 3 to 32 ASCII letters, digits or underscores; it's kept as-is as the display
+ /// name and lowercased to check for duplicates
+ ///
+ /// password of the account, 12 to 128 characters
+ /// the new account and the raw token of its session
+ ///
+ /// if the username or the password don't respect the bounds above, or if an account with the same
+ /// normalized username already exists
+ ///
+ public AuthResult Register(string username, string password) {
+ var normalized = NormalizeUsername(username);
+ ValidatePassword(password);
+
+ var salt = RandomNumberGenerator.GetBytes(SALT_SIZE);
+ var hash = DeriveKey(password, salt);
+
+ lock (_lock) {
+ ThrowIfDisposed();
+ using var transaction = _connection.BeginTransaction();
+
+ if (FindAccountByUsername(transaction, normalized) is not null) {
+ throw new ArgumentException($"an account named '{normalized}' already exists", nameof(username));
+ }
+
+ using var command = _connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = """
+ INSERT INTO accounts (username, display_name, salt, hash, iterations)
+ VALUES ($username, $display_name, $salt, $hash, $iterations)
+ RETURNING id;
+ """;
+ command.Parameters.AddWithValue("$username", normalized);
+ command.Parameters.AddWithValue("$display_name", username);
+ command.Parameters.AddWithValue("$salt", salt);
+ command.Parameters.AddWithValue("$hash", hash);
+ command.Parameters.AddWithValue("$iterations", PBKDF2_ITERATIONS);
+
+ var account = new Account {
+ Id = (uint)(long)command.ExecuteScalar()!, Username = normalized, DisplayName = username
+ };
+ var token = CreateSession(transaction, account.Id);
+ transaction.Commit();
+ return new AuthResult(account, token);
+ }
+ }
+
+ ///
+ /// Authenticates an account with its password and opens a new session for it
+ ///
+ /// username of the account, in any casing
+ /// password of the account
+ ///
+ /// the account and the raw token of the new session, or if the credentials don't
+ /// match an account
+ ///
+ ///
+ /// Never tells apart an unknown username from a wrong password, neither by its result nor by how long
+ /// it takes to answer
+ ///
+ public AuthResult? Login(string username, string password) {
+ ArgumentNullException.ThrowIfNull(username);
+ ArgumentNullException.ThrowIfNull(password);
+
+ if (!IsValidUsername(username) || !IsValidPassword(password)) {
+ // still burn a derivation so that malformed input isn't faster to reject than a wrong password
+ DeriveKey(password.Length == 0 ? "\0" : password, _dummySalt);
+ return null;
+ }
+
+ var normalized = username.ToLowerInvariant();
+ AccountRecord? record;
+ lock (_lock) {
+ ThrowIfDisposed();
+ using var read = _connection.BeginTransaction();
+ record = FindAccountByUsername(read, normalized);
+ read.Commit();
+ }
+ // Password work must not hold the database lock while gameplay commits are waiting.
+ var hash = Rfc2898DeriveBytes.Pbkdf2(password, record?.Salt ?? _dummySalt,
+ record?.Iterations ?? PBKDF2_ITERATIONS, HashAlgorithmName.SHA256, HASH_SIZE);
+ if (record is null || !CryptographicOperations.FixedTimeEquals(hash, record.Hash)) return null;
+ lock (_lock) {
+ ThrowIfDisposed();
+ using var transaction = _connection.BeginTransaction();
+ var current = FindAccountByUsername(transaction, normalized);
+ if (current == null || !CryptographicOperations.FixedTimeEquals(current.Hash, record.Hash)) return null;
+ var token = CreateSession(transaction, current.Account.Id);
+ transaction.Commit();
+ return new AuthResult(current.Account, token);
+ }
+ }
+
+ ///
+ /// Authenticates a session token handed out by or
+ ///
+ /// the raw session token
+ ///
+ /// the account the session belongs to, or if the token is unknown, malformed,
+ /// revoked or expired
+ ///
+ ///
+ /// Expired sessions are dropped from the database when they're hit
+ ///
+ public Account? Resume(string token) {
+ if (!TryHashToken(token, out var tokenHash)) {
+ return null;
+ }
+
+ lock (_lock) {
+ ThrowIfDisposed();
+ using var transaction = _connection.BeginTransaction();
+
+ using var command = _connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = """
+ SELECT sessions.expires_at, accounts.id, accounts.username, accounts.display_name
+ FROM sessions JOIN accounts ON accounts.id = sessions.account_id
+ WHERE sessions.token_hash = $token_hash;
+ """;
+ command.Parameters.AddWithValue("$token_hash", tokenHash);
+
+ Account? account;
+ using (var reader = command.ExecuteReader()) {
+ if (!reader.Read()) {
+ return null;
+ }
+
+ if (reader.GetInt64(0) <= _timeProvider.GetUtcNow().ToUnixTimeMilliseconds()) {
+ account = null;
+ }
+ else {
+ account = new Account {
+ Id = (uint)reader.GetInt64(1), Username = reader.GetString(2), DisplayName = reader.GetString(3)
+ };
+ }
+ }
+
+ if (account is null) {
+ DeleteSession(transaction, tokenHash);
+ }
+
+ transaction.Commit();
+ return account;
+ }
+ }
+
+ ///
+ /// Revokes a session, making its token useless
+ ///
+ /// the raw session token
+ /// if a session was revoked, otherwise
+ public bool Logout(string token) {
+ if (!TryHashToken(token, out var tokenHash)) {
+ return false;
+ }
+
+ lock (_lock) {
+ ThrowIfDisposed();
+ using var transaction = _connection.BeginTransaction();
+ var revoked = DeleteSession(transaction, tokenHash) > 0;
+ transaction.Commit();
+ return revoked;
+ }
+ }
+
+ ///
+ /// Reads back the server state snapshot saved by
+ ///
+ /// the snapshot, or if none was ever saved
+ public string? LoadState() {
+ lock (_lock) {
+ ThrowIfDisposed();
+ using var command = _connection.CreateCommand();
+ command.CommandText = "SELECT state FROM server_state WHERE id = 0;";
+ return command.ExecuteScalar() as string;
+ }
+ }
+
+ ///
+ /// Saves the server state snapshot, replacing the previous one
+ ///
+ ///
+ /// The store treats the snapshot as an opaque blob: what goes in it, and its format, are up to the caller
+ ///
+ /// the JSON snapshot of the server state
+ /// Display names committed with the snapshot.
+ /// Completed matches archived in the same transaction.
+ /// if is
+ public void SaveState(string state, IReadOnlyDictionary? renamedAccounts = null,
+ IReadOnlyList? completedGames = null) {
+ ArgumentNullException.ThrowIfNull(state);
+
+ lock (_lock) {
+ ThrowIfDisposed();
+ using var transaction = _connection.BeginTransaction();
+ using var command = _connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = """
+ INSERT INTO server_state (id, state) VALUES (0, $state)
+ ON CONFLICT(id) DO UPDATE SET state = excluded.state;
+ """;
+ command.Parameters.AddWithValue("$state", state);
+ command.ExecuteNonQuery();
+ if (renamedAccounts != null) {
+ foreach (var (accountId, name) in renamedAccounts) {
+ if (name.Length is < 1 or > 32 || name.Any(char.IsControl)) throw new ArgumentException("Invalid display name");
+ using var rename = _connection.CreateCommand();
+ rename.Transaction = transaction;
+ rename.CommandText = "UPDATE accounts SET display_name = $name WHERE id = $id";
+ rename.Parameters.AddWithValue("$name", name);
+ rename.Parameters.AddWithValue("$id", accountId);
+ if (rename.ExecuteNonQuery() != 1) throw new ArgumentException("Unknown account");
+ }
+ }
+ SaveCompletedGames(transaction, completedGames);
+ transaction.Commit();
+ }
+ }
+
+ ///
+ /// Closes the database
+ ///
+ public void Dispose() {
+ lock (_lock) {
+ if (_disposed) {
+ return;
+ }
+
+ _disposed = true;
+ _connection.Dispose();
+ }
+ }
+
+ #region Schema
+
+ private void Migrate() {
+ using var transaction = _connection.BeginTransaction();
+
+ using var version = _connection.CreateCommand();
+ version.Transaction = transaction;
+ version.CommandText = "PRAGMA user_version;";
+ var current = Convert.ToInt64(version.ExecuteScalar());
+
+ if (current > SCHEMA_VERSION) {
+ throw new InvalidOperationException(
+ $"database schema version {current} is newer than the supported version {SCHEMA_VERSION}");
+ }
+
+ if (current == SCHEMA_VERSION) {
+ return;
+ }
+
+ using var schema = _connection.CreateCommand();
+ schema.Transaction = transaction;
+ if (current == 0) {
+ schema.CommandText = """
+ CREATE TABLE accounts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL UNIQUE,
+ display_name TEXT NOT NULL,
+ salt BLOB NOT NULL,
+ hash BLOB NOT NULL,
+ iterations INTEGER NOT NULL
+ ) STRICT;
+
+ CREATE TABLE sessions (
+ token_hash BLOB PRIMARY KEY,
+ account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL
+ ) STRICT;
+
+ CREATE INDEX sessions_account_id ON sessions(account_id);
+
+ CREATE TABLE server_state (
+ id INTEGER PRIMARY KEY CHECK (id = 0),
+ state TEXT NOT NULL
+ ) STRICT;
+
+ PRAGMA user_version = 1;
+ """;
+ schema.ExecuteNonQuery();
+ }
+ schema.CommandText = """
+ CREATE TABLE completed_games (id TEXT PRIMARY KEY, state TEXT NOT NULL) STRICT;
+ CREATE TABLE completed_game_members (game_id TEXT NOT NULL REFERENCES completed_games(id),
+ account_id INTEGER NOT NULL, PRIMARY KEY(account_id, game_id)) STRICT;
+ PRAGMA user_version = 2;
+ """;
+ schema.ExecuteNonQuery();
+ transaction.Commit();
+ }
+
+ private void Execute(string sql) {
+ using var command = _connection.CreateCommand();
+ command.CommandText = sql;
+ command.ExecuteNonQuery();
+ }
+
+ #endregion
+
+ #region Accounts and sessions
+
+ private sealed record AccountRecord(Account Account, byte[] Salt, byte[] Hash, int Iterations);
+
+ private AccountRecord? FindAccountByUsername(SqliteTransaction transaction, string normalized) {
+ using var command = _connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = """
+ SELECT id, username, display_name, salt, hash, iterations FROM accounts WHERE username = $username;
+ """;
+ command.Parameters.AddWithValue("$username", normalized);
+
+ using var reader = command.ExecuteReader();
+ if (!reader.Read()) {
+ return null;
+ }
+
+ var account = new Account {
+ Id = (uint)reader.GetInt64(0), Username = reader.GetString(1), DisplayName = reader.GetString(2)
+ };
+ var salt = (byte[])reader.GetValue(3);
+ var hash = (byte[])reader.GetValue(4);
+ return new AccountRecord(account, salt, hash, reader.GetInt32(5));
+ }
+
+ ///
+ /// Inserts a new session for an account and returns its raw token
+ ///
+ private string CreateSession(SqliteTransaction transaction, uint accountId) {
+ var token = RandomNumberGenerator.GetBytes(TOKEN_SIZE);
+ var now = _timeProvider.GetUtcNow();
+
+ using var command = _connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = """
+ INSERT INTO sessions (token_hash, account_id, created_at, expires_at)
+ VALUES ($token_hash, $account_id, $created_at, $expires_at);
+ """;
+ command.Parameters.AddWithValue("$token_hash", SHA256.HashData(token));
+ command.Parameters.AddWithValue("$account_id", (long)accountId);
+ command.Parameters.AddWithValue("$created_at", now.ToUnixTimeMilliseconds());
+ command.Parameters.AddWithValue("$expires_at", now.Add(SessionLifetime).ToUnixTimeMilliseconds());
+ command.ExecuteNonQuery();
+
+ return Convert.ToBase64String(token);
+ }
+
+ private int DeleteSession(SqliteTransaction transaction, byte[] tokenHash) {
+ using var command = _connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = "DELETE FROM sessions WHERE token_hash = $token_hash;";
+ command.Parameters.AddWithValue("$token_hash", tokenHash);
+ return command.ExecuteNonQuery();
+ }
+
+ #endregion
+
+ #region Validation and crypto
+
+ ///
+ /// Validates a username and returns its normalized form
+ ///
+ private static string NormalizeUsername(string username) {
+ ArgumentNullException.ThrowIfNull(username);
+ if (!IsValidUsername(username)) {
+ throw new ArgumentException(
+ $"a username must be {MIN_USERNAME_LENGTH} to {MAX_USERNAME_LENGTH} ASCII letters, digits or underscores",
+ nameof(username));
+ }
+
+ return username.ToLowerInvariant();
+ }
+
+ private static void ValidatePassword(string password) {
+ ArgumentNullException.ThrowIfNull(password);
+ if (!IsValidPassword(password)) {
+ throw new ArgumentException(
+ $"a password must be {MIN_PASSWORD_LENGTH} to {MAX_PASSWORD_LENGTH} characters long", nameof(password));
+ }
+ }
+
+ private static bool IsValidUsername(string? username) {
+ if (username is null || username.Length is < MIN_USERNAME_LENGTH or > MAX_USERNAME_LENGTH) {
+ return false;
+ }
+
+ foreach (var character in username) {
+ if (!char.IsAsciiLetterOrDigit(character) && character != '_') {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool IsValidPassword(string? password) =>
+ password is not null && password.Length is >= MIN_PASSWORD_LENGTH and <= MAX_PASSWORD_LENGTH;
+
+ private static byte[] DeriveKey(string password, byte[] salt) =>
+ Rfc2898DeriveBytes.Pbkdf2(password, salt, PBKDF2_ITERATIONS, HashAlgorithmName.SHA256, HASH_SIZE);
+
+ ///
+ /// Hashes a raw session token, failing if it isn't a well-formed 256 bits token
+ ///
+ private static bool TryHashToken(string? token, out byte[] tokenHash) {
+ tokenHash = [];
+ if (token is null) {
+ return false;
+ }
+
+ Span raw = stackalloc byte[TOKEN_SIZE];
+ if (!Convert.TryFromBase64String(token, raw, out var written) || written != TOKEN_SIZE) {
+ return false;
+ }
+
+ tokenHash = SHA256.HashData(raw);
+ return true;
+ }
+
+ #endregion
+
+ private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
+}
diff --git a/OpenPolytopia.Server/ServerTls.cs b/OpenPolytopia.Server/ServerTls.cs
new file mode 100644
index 0000000..d19b8e9
--- /dev/null
+++ b/OpenPolytopia.Server/ServerTls.cs
@@ -0,0 +1,109 @@
+namespace OpenPolytopia.Server;
+
+using System.Net;
+using System.Security.Cryptography.X509Certificates;
+
+///
+/// Loads and validates the TLS configuration of the server
+///
+///
+/// Passwords and tokens travel on this connection, so a server reachable from outside this machine
+/// has to serve TLS; the certificate comes from a PKCS#12 file pointed at by
+/// , unlocked with
+///
+public static class ServerTls {
+ ///
+ /// Environment variable holding the path of the PKCS#12 (.pfx) file with the certificate and its private key
+ ///
+ public const string CERTIFICATE_PATH_ENV = "OPENPOLYTOPIA_TLS_CERTIFICATE";
+
+ ///
+ /// Environment variable holding the password of the PKCS#12 file; unset means no password
+ ///
+ public const string CERTIFICATE_PASSWORD_ENV = "OPENPOLYTOPIA_TLS_PASSWORD";
+
+ ///
+ /// Loads the certificate configured through the environment
+ ///
+ /// the certificate or null if isn't set
+ /// if the configured file doesn't exist
+ /// if the file can't be read as a certificate with a private key
+ public static X509Certificate2? LoadCertificate() {
+ var path = Environment.GetEnvironmentVariable(CERTIFICATE_PATH_ENV);
+ if (string.IsNullOrWhiteSpace(path)) {
+ return null;
+ }
+
+ if (!File.Exists(path)) {
+ throw new FileNotFoundException($"{CERTIFICATE_PATH_ENV} points to a file that doesn't exist", path);
+ }
+
+ var password = Environment.GetEnvironmentVariable(CERTIFICATE_PASSWORD_ENV);
+
+ X509Certificate2 certificate;
+ try {
+ certificate = X509CertificateLoader.LoadPkcs12FromFile(path, password);
+ }
+ catch (Exception e) {
+ throw new InvalidOperationException($"Failed to load the TLS certificate from '{path}': {e.Message}", e);
+ }
+
+ // without the private key we can't answer a single handshake, better to fail at startup
+ if (!certificate.HasPrivateKey) {
+ certificate.Dispose();
+ throw new InvalidOperationException($"The TLS certificate at '{path}' has no private key");
+ }
+
+ return certificate;
+ }
+
+ ///
+ /// Checks that the given bind address is allowed to run without TLS
+ ///
+ ///
+ /// Only a server bound to loopback, so tests and local development, may run plaintext;
+ /// anything reachable from the network needs a certificate
+ ///
+ /// the address the server binds to; null means every interface
+ /// the loaded certificate, or null if there is none
+ /// if a non loopback server has no certificate
+ public static void Validate(string? bindAddress, X509Certificate2? certificate) {
+ if (certificate != null || IsLoopback(bindAddress)) {
+ return;
+ }
+
+ throw new InvalidOperationException(
+ $"Refusing to listen on '{bindAddress ?? "*"}' without TLS: accounts send passwords and tokens over " +
+ $"this connection. Set {CERTIFICATE_PATH_ENV} to a .pfx file (and {CERTIFICATE_PASSWORD_ENV} to its " +
+ "password) or bind to 127.0.0.1 for local development.");
+ }
+
+ ///
+ /// Loads the configured certificate and checks it against the bind address
+ ///
+ /// the address the server binds to; null means every interface
+ /// the certificate to serve TLS with, or null when running plaintext on loopback
+ /// if a non loopback server has no certificate
+ public static X509Certificate2? LoadAndValidate(string? bindAddress) {
+ var certificate = LoadCertificate();
+
+ try {
+ Validate(bindAddress, certificate);
+ }
+ catch {
+ certificate?.Dispose();
+ throw;
+ }
+
+ return certificate;
+ }
+
+ ///
+ /// Checks if a bind address only accepts connections from this same machine
+ ///
+ /// the address the server binds to; null means every interface
+ private static bool IsLoopback(string? bindAddress) =>
+ !string.IsNullOrWhiteSpace(bindAddress) &&
+ IPAddress.TryParse(bindAddress, out var ip) &&
+ IPAddress.IsLoopback(ip);
+}
diff --git a/OpenPolytopia.UnitTest/AuthenticationFlowTest.cs b/OpenPolytopia.UnitTest/AuthenticationFlowTest.cs
new file mode 100644
index 0000000..d7323dd
--- /dev/null
+++ b/OpenPolytopia.UnitTest/AuthenticationFlowTest.cs
@@ -0,0 +1,53 @@
+namespace OpenPolytopia;
+
+using System.Threading.Tasks;
+using Common.Network.Packets;
+using Server;
+using Shouldly;
+
+public class AuthenticationFlowTest {
+ [Fact]
+ public async Task DuplicateLoginKeepsTheExistingSession() {
+ using var database = new TempDatabase();
+ await using var server = await TestServer.StartAsync(database.Path);
+ using var client = await TestClient.ConnectAsync(server);
+ var registered = await client.RegisterAsync("duplicate_login", TestGames.PASSWORD);
+ var repeated = await client.LoginAsync("duplicate_login", TestGames.PASSWORD);
+ repeated.Ok.ShouldBeTrue();
+ repeated.PlayerId.ShouldBe(registered.PlayerId);
+ repeated.Token.ShouldBe(registered.Token);
+ await client.SendAsync(new GetLobbiesPacket());
+ (await client.ExpectAsync()).Lobbies.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task LogoutRevokesTheTokenAndAllowsAnotherLogin() {
+ using var database = new TempDatabase();
+ await using var server = await TestServer.StartAsync(database.Path);
+ using var client = await TestClient.ConnectAsync(server);
+ var registered = await client.RegisterAsync("logout_player", TestGames.PASSWORD);
+ await client.SendAsync(new LogoutPacket());
+ (await client.ExpectAsync()).Ok.ShouldBeFalse();
+ (await client.ResumeAsync(registered.Token)).Ok.ShouldBeFalse();
+ (await client.LoginAsync("logout_player", TestGames.PASSWORD)).Ok.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task RateLimitedResumeIsRetryableAndDoesNotRevokeTheToken() {
+ using var database = new TempDatabase();
+ AuthResult registered;
+ using (var store = new ServerStore(database.Path)) registered = store.Register("retry_player", TestGames.PASSWORD);
+ await using var server = await TestServer.StartAsync(database.Path);
+ using var client = await TestClient.ConnectAsync(server);
+ for (var attempt = 0; attempt < 60; attempt++) {
+ var invalid = await client.ResumeAsync("invalid");
+ invalid.Ok.ShouldBeFalse();
+ invalid.Retryable.ShouldBeFalse();
+ }
+ var limited = await client.ResumeAsync(registered.Token);
+ limited.Ok.ShouldBeFalse();
+ limited.Retryable.ShouldBeTrue();
+ using var check = new ServerStore(database.Path);
+ check.Resume(registered.Token)!.Id.ShouldBe(registered.Account.Id);
+ }
+}
diff --git a/OpenPolytopia.UnitTest/CompletedGameArchiveTest.cs b/OpenPolytopia.UnitTest/CompletedGameArchiveTest.cs
new file mode 100644
index 0000000..88c59b7
--- /dev/null
+++ b/OpenPolytopia.UnitTest/CompletedGameArchiveTest.cs
@@ -0,0 +1,84 @@
+namespace OpenPolytopia;
+
+using System;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Common.Gameplay;
+using Common.Network.Packets;
+using Microsoft.Data.Sqlite;
+using Server;
+using Shouldly;
+
+public class CompletedGameArchiveTest {
+ [Fact]
+ public async Task CompletedGameLeavesLiveSnapshotAndRemainsAccessibleAfterRestart() {
+ using var database = new TempDatabase();
+ ulong id;
+ string token;
+ uint accountId;
+ await using (var server = await TestServer.StartAsync(database.Path)) {
+ using var alice = await TestClient.ConnectAsync(server);
+ var auth = await alice.RegisterAsync(TestGames.UniqueName("archive_alice"), TestGames.PASSWORD);
+ token = auth.Token;
+ accountId = auth.PlayerId;
+ using var bob = await TestClient.ConnectAsync(server);
+ await bob.RegisterAsync(TestGames.UniqueName("archive_bob"), TestGames.PASSWORD);
+ id = await TestGames.StartGameAsync(alice, bob);
+ await alice.SendAsync(new ResignGamePacket { GameId = id });
+ (await alice.ExpectAsync()).Result.ShouldBe(GameActionResult.Ok);
+ using var store = new ServerStore(database.Path);
+ using var snapshot = JsonDocument.Parse(store.LoadState()!);
+ snapshot.RootElement.GetProperty("Games").GetArrayLength().ShouldBe(0);
+ store.CompletedGameIds(accountId).ShouldContain(id);
+ store.LoadCompletedGame(id, uint.MaxValue).ShouldBeNull();
+ await alice.SendAsync(new JoinGamePacket { GameId = id });
+ (await alice.ExpectAsync(p => p.GameId == id && p.Over)).Result.ShouldBe(GameActionResult.Ok);
+ }
+ await using var restarted = await TestServer.StartAsync(database.Path);
+ using var member = await TestClient.ConnectAsync(restarted);
+ (await member.ResumeAsync(token)).Ok.ShouldBeTrue();
+ await member.SendAsync(new GetMyGamesPacket());
+ (await member.ExpectAsync()).GameIds.ShouldContain(id);
+ await member.SendAsync(new JoinGamePacket { GameId = id });
+ (await member.ExpectAsync(p => p.GameId == id)).Over.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void ArchiveFailureRollsBackLiveSnapshotAndAccountRename() {
+ using var database = new TempDatabase();
+ using var store = new ServerStore(database.Path);
+ var auth = store.Register("archive_owner", TestGames.PASSWORD);
+ store.SaveState("before");
+ using var connection = new SqliteConnection($"Data Source={database.Path};Pooling=False");
+ connection.Open();
+ using var command = connection.CreateCommand();
+ command.CommandText = "CREATE TRIGGER refuse_archive BEFORE INSERT ON completed_games BEGIN SELECT RAISE(FAIL, 'injected failure'); END;";
+ command.ExecuteNonQuery();
+ Should.Throw(() => store.SaveState("after", new System.Collections.Generic.Dictionary {
+ [auth.Account.Id] = "renamed"
+ }, [new ArchivedGame(ulong.MaxValue, "result", [auth.Account.Id])]));
+ store.LoadState().ShouldBe("before");
+ store.Resume(auth.Token)!.DisplayName.ShouldBe("archive_owner");
+ store.CompletedGameIds(auth.Account.Id).ShouldBeEmpty();
+ command.CommandText = "DROP TRIGGER refuse_archive";
+ command.ExecuteNonQuery();
+ store.SaveState("after", completedGames: [new ArchivedGame(ulong.MaxValue, "result", [auth.Account.Id])]);
+ store.LoadCompletedGame(ulong.MaxValue, auth.Account.Id).ShouldBe("result");
+ }
+
+ [Fact]
+ public void VersionOneDatabaseMigratesWithoutLosingState() {
+ using var database = new TempDatabase();
+ using (var store = new ServerStore(database.Path)) store.SaveState("legacy");
+ using (var connection = new SqliteConnection($"Data Source={database.Path};Pooling=False")) {
+ connection.Open();
+ using var command = connection.CreateCommand();
+ command.CommandText = "DROP TABLE completed_game_members; DROP TABLE completed_games; PRAGMA user_version = 1;";
+ command.ExecuteNonQuery();
+ }
+ using var migrated = new ServerStore(database.Path);
+ migrated.LoadState().ShouldBe("legacy");
+ migrated.CompletedGameIds(1).ShouldBeEmpty();
+ }
+}
diff --git a/OpenPolytopia.UnitTest/GameManagerTest.cs b/OpenPolytopia.UnitTest/GameManagerTest.cs
index 5c20a11..b5fba0a 100644
--- a/OpenPolytopia.UnitTest/GameManagerTest.cs
+++ b/OpenPolytopia.UnitTest/GameManagerTest.cs
@@ -73,17 +73,18 @@ public async Task TestCreateGameRejectsInvalidPlayerCount(int players) {
}
[Fact]
- public async Task TestCreateGameRejectsPlayerAlreadyInAnotherGame() {
+ public async Task TestPlayerCanBelongToMultipleGames() {
var firstLobby = NewLobby();
var firstSession = await CreateGameAsync(firstLobby);
var secondLobby = NewLobby();
secondLobby.Id = 8;
secondLobby.Players[1].PlayerId = 102;
- await Should.ThrowAsync(() => CreateGameAsync(secondLobby));
+ var secondSession = await CreateGameAsync(secondLobby);
_gameManager[firstLobby.Id].ShouldBe(firstSession);
- _gameManager[secondLobby.Id].ShouldBeNull();
+ _gameManager[secondLobby.Id].ShouldBe(secondSession);
+ _gameManager.FindByAccount(firstLobby.Players[0].PlayerId).Count().ShouldBe(2);
_gameManager.FindByConnection(firstLobby.Players[0].PlayerId).ShouldBe(firstSession);
}
diff --git a/OpenPolytopia.UnitTest/GamePersistenceTest.cs b/OpenPolytopia.UnitTest/GamePersistenceTest.cs
new file mode 100644
index 0000000..49f84a6
--- /dev/null
+++ b/OpenPolytopia.UnitTest/GamePersistenceTest.cs
@@ -0,0 +1,482 @@
+namespace OpenPolytopia;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Common;
+using Common.Gameplay;
+using Godot;
+using Shouldly;
+
+///
+/// Roundtrip tests for and
+///
+///
+/// Every restore rebuilds the content from scratch, so a restored game never shares a manager, a tech tree or a
+/// player state with the game the snapshot came from: anything that survives the roundtrip really was in the snapshot
+///
+public class GamePersistenceTest {
+ ///
+ /// The content a game is built from, rebuilt fresh for every restore
+ ///
+ private readonly record struct Content(TroopManager Troops, TribeManager Tribes, BuildingManager Buildings,
+ TechTreeDefinition TechTree);
+
+ private static Content NewContent(uint gridSize) {
+ var troops = new TroopManager(gridSize);
+ var tribes = new TribeManager();
+ var buildings = new BuildingManager();
+
+ troops.RegisterTroops(EmbeddedResources.LoadTroops().ShouldNotBeNull());
+ tribes.RegisterTribes(EmbeddedResources.LoadTribes().ShouldNotBeNull());
+ buildings.RegisterBuildings(EmbeddedResources.LoadBuildings().ShouldNotBeNull());
+
+ return new Content(troops, tribes, buildings,
+ TechTreeDefinition.FromSerializedData(EmbeddedResources.LoadTechTree().ShouldNotBeNull()));
+ }
+
+ private static Game Roundtrip(Game game) => Restore(game.ToSnapshot());
+
+ private static Game Restore(GameSnapshot snapshot) {
+ var content = NewContent(snapshot.GridSize);
+ return Game.Restore(snapshot, content.Troops, content.Tribes, content.Buildings, content.TechTree);
+ }
+
+ ///
+ /// Asserts two snapshots describe the very same game, member by member
+ ///
+ private static void ShouldMatch(GameSnapshot actual, GameSnapshot expected) {
+ actual.Version.ShouldBe(expected.Version);
+ actual.GridSize.ShouldBe(expected.GridSize);
+ actual.Tiles.ShouldBe(expected.Tiles);
+ actual.Troops.ShouldBe(expected.Troops);
+ actual.CityIndexes.ShouldBe(expected.CityIndexes);
+ actual.MaxTurns.ShouldBe(expected.MaxTurns);
+ actual.Turn.ShouldBe(expected.Turn);
+ actual.CurrentPlayer.ShouldBe(expected.CurrentPlayer);
+ actual.Started.ShouldBe(expected.Started);
+ actual.Over.ShouldBe(expected.Over);
+ actual.Winner.ShouldBe(expected.Winner);
+
+ actual.Players.Count.ShouldBe(expected.Players.Count);
+ foreach (var (actualPlayer, expectedPlayer) in actual.Players.Zip(expected.Players)) {
+ actualPlayer.Id.ShouldBe(expectedPlayer.Id);
+ actualPlayer.Tribe.ShouldBe(expectedPlayer.Tribe);
+ actualPlayer.Stars.ShouldBe(expectedPlayer.Stars);
+ actualPlayer.Score.ShouldBe(expectedPlayer.Score);
+ actualPlayer.Alive.ShouldBe(expectedPlayer.Alive);
+ actualPlayer.ResearchedTechs.ShouldBe(expectedPlayer.ResearchedTechs);
+ }
+ }
+
+ ///
+ /// Builds a started game and plays a bit of everything on it: research, building, training, moving and fighting
+ ///
+ ///
+ /// Nothing in the engine is random, so the same sequence on two equal games always lands on the same state; that's
+ /// what makes the "keep playing after a restore" tests meaningful
+ ///
+ private static Game PlayedGame() {
+ var game = GameTestFixture.NewStartedGame(3);
+
+ // player 1 researches, harvests a fruit into its capital and trains a second troop
+ game.Players[0].Stars = 40;
+ game.ResearchTech(1, "farming").ShouldBe(GameActionResult.Ok);
+ game.Grid.ModifyTile(GameTestFixture.P(0, 0), (ref Tile tile) => tile.Modifier = (int)FieldTileModifier.Fruit);
+ game.Build(1, GameTestFixture.P(0, 0), BuildingType.Fruit).Result.ShouldBe(GameActionResult.Ok);
+ game.MoveTroop(1, GameTestFixture.P(1, 1), GameTestFixture.P(2, 2)).ShouldBe(GameActionResult.Ok);
+ game.TrainTroop(1, GameTestFixture.P(1, 1), TroopType.Warrior).ShouldBe(GameActionResult.Ok);
+ game.EndTurn(1).Result.ShouldBe(GameActionResult.Ok);
+
+ // player 2 walks its warrior out of its capital and takes a hit at nothing in particular
+ game.Players[1].Stars = 25;
+ game.MoveTroop(2, GameTestFixture.P(6, 1), GameTestFixture.P(5, 2)).ShouldBe(GameActionResult.Ok);
+ game.EndTurn(2).Result.ShouldBe(GameActionResult.Ok);
+
+ // player 3 keeps its stars and its troop where they are
+ game.EndTurn(3).Result.ShouldBe(GameActionResult.Ok);
+
+ return game;
+ }
+
+ [Fact]
+ public void TestRoundtripOfAGameThatHasNotStarted() {
+ var game = GameTestFixture.NewGame();
+ var snapshot = game.ToSnapshot();
+
+ var restored = Roundtrip(game);
+
+ restored.Started.ShouldBeFalse();
+ restored.Turn.ShouldBe(0u);
+ restored.CurrentPlayer.ShouldBe(0);
+ ShouldMatch(restored.ToSnapshot(), snapshot);
+ }
+
+ [Fact]
+ public void TestRoundtripPreservesTheWholeBoard() {
+ var game = PlayedGame();
+ var snapshot = game.ToSnapshot();
+
+ var restored = Roundtrip(game);
+
+ restored.Grid.Size.ShouldBe(game.Grid.Size);
+ for (var index = 0u; index < game.Grid.Size * game.Grid.Size; index++) {
+ restored.Grid[index].Raw.ShouldBe(game.Grid[index].Raw, $"tile {index} differs");
+ restored.Troops[index].Raw.ShouldBe(game.Troops[index].Raw, $"troop {index} differs");
+ }
+
+ ShouldMatch(restored.ToSnapshot(), snapshot);
+ }
+
+ [Fact]
+ public void TestRoundtripPreservesCityInternals() {
+ var game = GameTestFixture.NewStartedGame();
+ var cityId = game.CityIdsOf(1).Single();
+
+ // every field of the packed city data, set to something that isn't its default
+ game.Cities.ModifyCity(cityId, (ref CityData city) => {
+ city.Level = 5;
+ city.Population = 3;
+ city.Troops = 7;
+ city.Parks = 2;
+ city.Wall = true;
+ city.Forge = true;
+ city.Connected = true;
+ });
+
+ var restored = Roundtrip(game);
+
+ restored.Cities.Cities.ShouldBe(game.Cities.Cities);
+ var city = restored.Cities[cityId];
+ city.Owner.ShouldBe(1);
+ city.Level.ShouldBe(5);
+ city.Population.ShouldBe(3);
+ city.Troops.ShouldBe(7);
+ city.Parks.ShouldBe(2);
+ city.Wall.ShouldBeTrue();
+ city.Forge.ShouldBeTrue();
+ city.Capital.ShouldBeTrue();
+ city.Connected.ShouldBeTrue();
+ city.Stars.ShouldBe(game.Cities[cityId].Stars);
+ }
+
+ [Fact]
+ public void TestRoundtripPreservesEveryCityAndItsIndex() {
+ var game = GameTestFixture.NewStartedGame(4);
+ var village = game.Cities.RegisterCity(GameTestFixture.P(3, 3));
+ game.Cities.ModifyCity(village, (ref CityData city) => city.Level = 2);
+
+ var restored = Roundtrip(game);
+
+ restored.Cities.Cities.Count.ShouldBe(5);
+ for (var id = 1u; id <= 5; id++) {
+ restored.Cities.GetIndex(id).ShouldBe(game.Cities.GetIndex(id));
+ restored.Cities[id].ToULong().ShouldBe(game.Cities[id].ToULong());
+ }
+
+ restored.Cities[village].Level.ShouldBe(2);
+ restored.Cities[village].Owner.ShouldBe(0);
+ }
+
+ [Fact]
+ public void TestRoundtripPreservesTroopRawState() {
+ var game = GameTestFixture.NewStartedGame();
+ var position = GameTestFixture.P(3, 3);
+ game.Troops.SpawnTroop(position, 1, 1, TroopType.Rider);
+ game.Troops.SetVeteran(position);
+ game.Troops.ModifyTroop(position, (ref TroopData troop) => {
+ troop.Hp = 12;
+ troop.Moved = true;
+ troop.Attacked = true;
+ });
+
+ var restored = Roundtrip(game);
+ var troop = restored.Troops[position];
+
+ troop.IsValid().ShouldBeTrue();
+ troop.Type.ShouldBe(TroopType.Rider);
+ troop.Player.ShouldBe(1u);
+ troop.City.ShouldBe(1u);
+ troop.Hp.ShouldBe(12u);
+ troop.Veteran.ShouldBeTrue();
+ troop.Moved.ShouldBeTrue();
+ troop.Attacked.ShouldBeTrue();
+ restored.MaxHpOf(troop).ShouldBe(game.MaxHpOf(game.Troops[position]));
+ }
+
+ [Fact]
+ public void TestRoundtripPreservesPlayersInTurnOrder() {
+ var game = PlayedGame();
+
+ var restored = Roundtrip(game);
+
+ restored.Players.Count.ShouldBe(game.Players.Count);
+ foreach (var (actual, expected) in restored.Players.Zip(game.Players)) {
+ actual.Id.ShouldBe(expected.Id);
+ actual.Tribe.ShouldBe(expected.Tribe);
+ actual.Stars.ShouldBe(expected.Stars);
+ actual.Alive.ShouldBe(expected.Alive);
+ actual.Score.ScoreValue.ShouldBe(expected.Score.ScoreValue);
+ restored[expected.Id].ShouldBe(actual);
+ }
+ }
+
+ [Fact]
+ public void TestRoundtripPreservesResearchedTechnology() {
+ var game = GameTestFixture.NewStartedGame();
+ game.Players[0].Stars = 100;
+ game.ResearchTech(1, "riding").ShouldBe(GameActionResult.Ok);
+ game.ResearchTech(1, "roads").ShouldBe(GameActionResult.Ok);
+
+ var restored = Roundtrip(game);
+
+ restored.Players[0].TechTree.ResearchedIds().ShouldBe(game.Players[0].TechTree.ResearchedIds());
+ restored.Players[0].TechTree.HasResearched("riding").ShouldBeTrue();
+ restored.Players[0].TechTree.HasResearched("roads").ShouldBeTrue();
+
+ // the starting node of the tribe is restored because it was researched, not because a tribe hands it out
+ restored.Players[0].TechTree.HasResearched("organization").ShouldBeTrue();
+ restored.Players[0].TechTree.HasResearched("climbing").ShouldBeFalse();
+
+ // player 2 never researched anything past its start, and researching for one player never touched the other
+ restored.Players[1].TechTree.ResearchedIds().ShouldBe(game.Players[1].TechTree.ResearchedIds());
+ restored.Players[1].TechTree.HasResearched("riding").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void TestRoundtripPreservesScoreAndSettingsAndTurnState() {
+ var game = GameTestFixture.NewStartedGame(3, new GameSettings { MaxTurns = 12 });
+ game.Players[1].Score.AddScore(ScoreType.MonumentsBuilt);
+ game.Players[1].Score.AddScore(ScoreType.LoseCity(2));
+ game.EndTurn(1).Result.ShouldBe(GameActionResult.Ok);
+
+ var restored = Roundtrip(game);
+
+ restored.Settings.MaxTurns.ShouldBe(12u);
+ restored.Turn.ShouldBe(game.Turn);
+ restored.CurrentPlayer.ShouldBe(2);
+ restored.Started.ShouldBeTrue();
+ restored.Over.ShouldBeFalse();
+ restored.Winner.ShouldBe(0);
+ restored.Players[1].Score.ScoreValue.ShouldBe(game.Players[1].Score.ScoreValue);
+ }
+
+ [Fact]
+ public void TestRoundtripPreservesEliminatedPlayersAndAFinishedGame() {
+ var game = GameTestFixture.NewStartedGame(3);
+ game.Resign(2).Result.ShouldBe(GameActionResult.Ok);
+ game.Resign(3).Result.ShouldBe(GameActionResult.Ok);
+
+ game.Over.ShouldBeTrue();
+ game.Winner.ShouldBe(1);
+
+ var restored = Roundtrip(game);
+
+ restored.Over.ShouldBeTrue();
+ restored.Winner.ShouldBe(1);
+ restored.Players[0].Alive.ShouldBeTrue();
+ restored.Players[1].Alive.ShouldBeFalse();
+ restored.Players[2].Alive.ShouldBeFalse();
+
+ // a finished game stays finished: every action is refused exactly like on the original
+ restored.EndTurn(1).Result.ShouldBe(GameActionResult.GameOver);
+ ShouldMatch(restored.ToSnapshot(), game.ToSnapshot());
+ }
+
+ [Fact]
+ public void TestSnapshotIsNotAViewOnTheLiveGame() {
+ var game = GameTestFixture.NewStartedGame();
+ var snapshot = game.ToSnapshot();
+
+ game.Players[0].Stars = 999;
+ game.Grid.ModifyTile(GameTestFixture.P(4, 4), (ref Tile tile) => tile.Kind = TileKind.Mountain);
+ game.Troops.SpawnTroop(GameTestFixture.P(4, 4), 1, 1, TroopType.Warrior);
+ game.Cities.RegisterCity(GameTestFixture.P(3, 3));
+
+ var restored = Restore(snapshot);
+
+ restored.Players[0].Stars.ShouldNotBe(999);
+ restored.Grid[GameTestFixture.P(4, 4)].Kind.ShouldBe(TileKind.Field);
+ restored.Troops[GameTestFixture.P(4, 4)].IsValid().ShouldBeFalse();
+ restored.Cities.Cities.Count.ShouldBe(2);
+ }
+
+ [Fact]
+ public void TestRestoredGameKeepsPlayingIdenticallyToTheOriginal() {
+ var original = PlayedGame();
+ var restored = Roundtrip(original);
+
+ // the same sequence of actions, played on both, has to land on the same state
+ foreach (var game in new[] { original, restored }) {
+ game.CurrentPlayer.ShouldBe(1);
+ game.Players[0].Stars = 30;
+ // tier 2, unlocked by the farming PlayedGame researched: it only passes if the tech tree survived the restore
+ game.ResearchTech(1, "construction").ShouldBe(GameActionResult.Ok);
+ game.MoveTroop(1, GameTestFixture.P(2, 2), GameTestFixture.P(3, 3)).ShouldBe(GameActionResult.Ok);
+ game.EndTurn(1).Result.ShouldBe(GameActionResult.Ok);
+
+ game.MoveTroop(2, GameTestFixture.P(5, 2), GameTestFixture.P(4, 3)).ShouldBe(GameActionResult.Ok);
+ game.EndTurn(2).Result.ShouldBe(GameActionResult.Ok);
+
+ game.EndTurn(3).Result.ShouldBe(GameActionResult.Ok);
+ }
+
+ ShouldMatch(restored.ToSnapshot(), original.ToSnapshot());
+ }
+
+ [Fact]
+ public void TestRestoredGameResolvesCombatIdenticallyToTheOriginal() {
+ var original = GameTestFixture.NewStartedGame();
+
+ // two troops of different players next to each other, outside anybody's capital
+ original.Troops.SpawnTroop(GameTestFixture.P(3, 3), 1, 1, TroopType.Warrior);
+ original.Troops.SpawnTroop(GameTestFixture.P(4, 3), 2, 2, TroopType.Defender);
+
+ var restored = Roundtrip(original);
+
+ var originalAttack = original.Attack(1, GameTestFixture.P(3, 3), GameTestFixture.P(4, 3));
+ var restoredAttack = restored.Attack(1, GameTestFixture.P(3, 3), GameTestFixture.P(4, 3));
+
+ originalAttack.Result.ShouldBe(GameActionResult.Ok);
+ restoredAttack.ShouldBe(originalAttack);
+ ShouldMatch(restored.ToSnapshot(), original.ToSnapshot());
+ }
+
+ [Fact]
+ public void TestRestoredGameEndsOnTheTurnLimitLikeTheOriginal() {
+ var game = GameTestFixture.NewStartedGame(2, new GameSettings { MaxTurns = 2 });
+ game.EndTurn(1).Result.ShouldBe(GameActionResult.Ok);
+ game.EndTurn(2).Turn.ShouldBe(2u);
+
+ var restored = Roundtrip(game);
+
+ restored.Settings.MaxTurns.ShouldBe(2u);
+ restored.EndTurn(1).Result.ShouldBe(GameActionResult.Ok);
+
+ var last = restored.EndTurn(2);
+ last.GameOver.ShouldBeTrue();
+ restored.Over.ShouldBeTrue();
+ restored.Turn.ShouldBe(2u);
+ }
+
+ [Fact]
+ public void TestRestoreRejectsASnapshotOfAnotherVersion() {
+ var snapshot = GameTestFixture.NewStartedGame().ToSnapshot();
+ var older = Clone(snapshot, version: GameSnapshot.CURRENT_VERSION - 1);
+
+ Should.Throw(() => Restore(older));
+ }
+
+ [Fact]
+ public void TestRestoreRejectsArraysThatDoNotMatchTheGrid() {
+ var snapshot = GameTestFixture.NewStartedGame().ToSnapshot();
+
+ Should.Throw(() => Restore(Clone(snapshot, tiles: [.. snapshot.Tiles.Skip(1)])));
+ Should.Throw(() => Restore(Clone(snapshot, troops: [.. snapshot.Troops.Skip(1)])));
+ }
+
+ [Fact]
+ public void TestRestoreRejectsATroopManagerSizedForAnotherGrid() {
+ var snapshot = GameTestFixture.NewStartedGame().ToSnapshot();
+ var content = NewContent(GameTestFixture.SIZE + 1);
+
+ Should.Throw(() =>
+ Game.Restore(snapshot, content.Troops, content.Tribes, content.Buildings, content.TechTree));
+ }
+
+ [Fact]
+ public void TestRestoreRejectsACityOutsideTheGrid() {
+ var snapshot = GameTestFixture.NewStartedGame().ToSnapshot();
+
+ Should.Throw(() =>
+ Restore(Clone(snapshot, cityIndexes: [GameTestFixture.SIZE * GameTestFixture.SIZE])));
+ }
+
+ [Fact]
+ public void TestRestoreRejectsATechThatIsNotInTheTree() {
+ var snapshot = GameTestFixture.NewStartedGame().ToSnapshot();
+ var players = snapshot.Players.Select(player => Clone(player, researched: ["not_a_tech"])).ToList();
+
+ Should.Throw(() => Restore(Clone(snapshot, players: players)));
+ }
+
+ [Fact]
+ public void TestRestoreRejectsAPlayerListThatIsNotAGame() {
+ var snapshot = GameTestFixture.NewStartedGame().ToSnapshot();
+
+ Should.Throw(() => Restore(Clone(snapshot, players: [snapshot.Players[0]])));
+ Should.Throw(() =>
+ Restore(Clone(snapshot, players: [snapshot.Players[0], snapshot.Players[0]])));
+ Should.Throw(() =>
+ Restore(Clone(snapshot, players: [Clone(snapshot.Players[0], id: 17), snapshot.Players[1]])));
+ }
+
+ [Fact]
+ public void TestRestoreRejectsATurnHeldByNobody() {
+ var snapshot = GameTestFixture.NewStartedGame().ToSnapshot();
+
+ Should.Throw(() => Restore(Clone(snapshot, currentPlayer: 9)));
+ }
+
+ [Fact]
+ public void TestBlobRoundtripOfTheBoardArrays() {
+ var snapshot = PlayedGame().ToSnapshot();
+
+ var tiles = SnapshotEncoding.UnpackTiles(SnapshotEncoding.PackTiles(snapshot.Tiles));
+ var troops = SnapshotEncoding.UnpackTroops(SnapshotEncoding.PackTroops(snapshot.Troops));
+ var cityIndexes = SnapshotEncoding.UnpackCityIndexes(SnapshotEncoding.PackCityIndexes(snapshot.CityIndexes));
+
+ tiles.ShouldBe(snapshot.Tiles);
+ troops.ShouldBe(snapshot.Troops);
+ cityIndexes.ShouldBe(snapshot.CityIndexes);
+
+ // the blobs are the actual columns of a database row, so a game has to come back out of them
+ var restored = Restore(Clone(snapshot, tiles: tiles, troops: troops, cityIndexes: cityIndexes));
+ ShouldMatch(restored.ToSnapshot(), snapshot);
+ }
+
+ [Fact]
+ public void TestBlobsAreLittleEndianAndSized() {
+ SnapshotEncoding.PackTiles([0x0102030405060708]).ShouldBe(
+ new byte[] { 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01 });
+ SnapshotEncoding.PackTroops([0x01020304]).ShouldBe(new byte[] { 0x04, 0x03, 0x02, 0x01 });
+
+ SnapshotEncoding.PackTiles([1, 2, 3]).Length.ShouldBe(24);
+ SnapshotEncoding.PackTroops([1, 2, 3]).Length.ShouldBe(12);
+ }
+
+ [Fact]
+ public void TestBlobsRejectATruncatedColumn() {
+ Should.Throw(() => SnapshotEncoding.UnpackTiles(new byte[7]));
+ Should.Throw(() => SnapshotEncoding.UnpackTroops(new byte[3]));
+ Should.Throw(() => SnapshotEncoding.UnpackCityIndexes(new byte[5]));
+ }
+
+ private static GameSnapshot Clone(GameSnapshot snapshot, int? version = null, ulong[]? tiles = null,
+ uint[]? troops = null, uint[]? cityIndexes = null, IReadOnlyList? players = null,
+ int? currentPlayer = null) =>
+ new() {
+ Version = version ?? snapshot.Version,
+ GridSize = snapshot.GridSize,
+ Tiles = tiles ?? snapshot.Tiles,
+ Troops = troops ?? snapshot.Troops,
+ CityIndexes = cityIndexes ?? snapshot.CityIndexes,
+ Players = players ?? snapshot.Players,
+ MaxTurns = snapshot.MaxTurns,
+ Turn = snapshot.Turn,
+ CurrentPlayer = currentPlayer ?? snapshot.CurrentPlayer,
+ Started = snapshot.Started,
+ Over = snapshot.Over,
+ Winner = snapshot.Winner
+ };
+
+ private static PlayerSnapshot Clone(PlayerSnapshot snapshot, int? id = null, IReadOnlyList? researched = null) =>
+ new() {
+ Id = id ?? snapshot.Id,
+ Tribe = snapshot.Tribe,
+ Stars = snapshot.Stars,
+ Score = snapshot.Score,
+ Alive = snapshot.Alive,
+ ResearchedTechs = researched ?? snapshot.ResearchedTechs
+ };
+}
diff --git a/OpenPolytopia.UnitTest/GameServerIntegrationTest.cs b/OpenPolytopia.UnitTest/GameServerIntegrationTest.cs
new file mode 100644
index 0000000..97e6e53
--- /dev/null
+++ b/OpenPolytopia.UnitTest/GameServerIntegrationTest.cs
@@ -0,0 +1,514 @@
+namespace OpenPolytopia;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Common.Gameplay;
+using Common.Network.Packets;
+using Godot;
+using Shouldly;
+
+///
+/// End to end tests of over a real loopback TCP socket and a real SQLite database
+///
+///
+/// Nothing here reaches into the server's internals: every assertion is made on the packets a client actually
+/// receives, so these tests fail exactly when a real client would break
+///
+public class GameServerIntegrationTest {
+ #region Accounts
+
+ [Fact]
+ public async Task TestRegisterCreatesAnAccountAndASession() {
+ using var database = new TempDatabase();
+ await using var server = await TestServer.StartAsync(database.Path);
+ using var client = await TestClient.ConnectAsync(server);
+
+ var username = TestGames.UniqueName("alice");
+ var response = await client.RegisterAsync(username, TestGames.PASSWORD);
+
+ response.Ok.ShouldBeTrue();
+ response.PlayerId.ShouldNotBe(0u);
+ response.Name.ShouldBe(username);
+ response.Token.ShouldNotBeNullOrEmpty();
+ }
+
+ [Fact]
+ public async Task TestRegisteringTheSameUsernameTwiceFails() {
+ using var database = new TempDatabase();
+ await using var server = await TestServer.StartAsync(database.Path);
+ var username = TestGames.UniqueName("bob");
+
+ using var first = await TestClient.ConnectAsync(server);
+ (await first.RegisterAsync(username, TestGames.PASSWORD)).Ok.ShouldBeTrue();
+
+ // a different transport, so the failure can only come from the duplicate username
+ using var second = await TestClient.ConnectAsync(server);
+ (await second.RegisterAsync(username, TestGames.PASSWORD)).Ok.ShouldBeFalse();
+ (await second.RegisterAsync(username.ToUpperInvariant(), TestGames.PASSWORD)).Ok.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task TestLoginRejectsWrongCredentialsAndAcceptsTheRightOnes() {
+ using var database = new TempDatabase();
+ await using var server = await TestServer.StartAsync(database.Path);
+ var username = TestGames.UniqueName("carol");
+
+ using var registrar = await TestClient.ConnectAsync(server);
+ var registered = await registrar.RegisterAsync(username, TestGames.PASSWORD);
+
+ using var client = await TestClient.ConnectAsync(server);
+ (await client.LoginAsync(username, "wrong password!")).Ok.ShouldBeFalse();
+ (await client.LoginAsync(TestGames.UniqueName("nobody"), TestGames.PASSWORD)).Ok.ShouldBeFalse();
+
+ var ok = await client.LoginAsync(username, TestGames.PASSWORD);
+ ok.Ok.ShouldBeTrue();
+ ok.PlayerId.ShouldBe(registered.PlayerId);
+ }
+
+ [Fact]
+ public async Task TestSessionResumesAfterADisconnect() {
+ using var database = new TempDatabase();
+ await using var server = await TestServer.StartAsync(database.Path);
+ var username = TestGames.UniqueName("dave");
+
+ string token;
+ uint accountId;
+ using (var client = await TestClient.ConnectAsync(server)) {
+ var registered = await client.RegisterAsync(username, TestGames.PASSWORD);
+ token = registered.Token;
+ accountId = registered.PlayerId;
+ }
+
+ using var reconnected = await TestClient.ConnectAsync(server);
+ var resumed = await reconnected.ResumeAsync(token);
+
+ resumed.Ok.ShouldBeTrue();
+ resumed.PlayerId.ShouldBe(accountId);
+ resumed.Name.ShouldBe(username);
+ }
+
+ [Fact]
+ public async Task TestSessionResumesOnANewServerSharingTheDatabase() {
+ using var database = new TempDatabase();
+ var username = TestGames.UniqueName("erin");
+
+ string token;
+ uint accountId;
+ await using (var first = await TestServer.StartAsync(database.Path)) {
+ using var client = await TestClient.ConnectAsync(first);
+ var registered = await client.RegisterAsync(username, TestGames.PASSWORD);
+ token = registered.Token;
+ accountId = registered.PlayerId;
+ }
+
+ await using var second = await TestServer.StartAsync(database.Path);
+
+ using var resuming = await TestClient.ConnectAsync(second);
+ var resumed = await resuming.ResumeAsync(token);
+ resumed.Ok.ShouldBeTrue();
+ resumed.PlayerId.ShouldBe(accountId);
+
+ // the password still works too, i.e. the whole account survived and not just the session
+ using var logging = await TestClient.ConnectAsync(second);
+ (await logging.LoginAsync(username, TestGames.PASSWORD)).PlayerId.ShouldBe(accountId);
+ }
+
+ [Fact]
+ public async Task TestUnauthenticatedClientsGetKicked() {
+ using var database = new TempDatabase();
+ await using var server = await TestServer.StartAsync(database.Path);
+ using var client = await TestClient.ConnectAsync(server);
+
+ // handshake done, but no account: anything but an authentication packet is a kick
+ await client.SendAsync(new GetLobbiesPacket());
+
+ await client.ShouldBeDisconnectedAsync();
+ }
+
+ #endregion
+
+ #region Lobbies
+
+ [Fact]
+ public async Task TestLobbyMembershipSurvivesADisconnectAndARestart() {
+ using var database = new TempDatabase();
+ var username = TestGames.UniqueName("frank");
+
+ ulong lobbyId;
+ string token;
+ uint accountId;
+ await using (var first = await TestServer.StartAsync(database.Path)) {
+ using var client = await TestClient.ConnectAsync(first);
+ var registered = await client.RegisterAsync(username, TestGames.PASSWORD);
+ token = registered.Token;
+ accountId = registered.PlayerId;
+
+ await client.SendAsync(new CreateLobbyPacket { MaxPlayers = 2, WorldSize = TestGames.WORLD_SIZE, Tribe = 0 });
+ var created = await client.ExpectAsync();
+ created.Result.ShouldBe(LobbyActionResult.Ok);
+ lobbyId = created.LobbyId;
+ }
+
+ await using var second = await TestServer.StartAsync(database.Path);
+ using var reconnected = await TestClient.ConnectAsync(second);
+ (await reconnected.ResumeAsync(token)).Ok.ShouldBeTrue();
+
+ await reconnected.SendAsync(new GetLobbiesPacket());
+ var lobbies = await reconnected.ExpectAsync();
+
+ var lobby = lobbies.Lobbies.SingleOrDefault(candidate => candidate.Id == lobbyId);
+ lobby.ShouldNotBeNull();
+ lobby.MaxPlayers.ShouldBe(2u);
+ lobby.WorldSize.ShouldBe(TestGames.WORLD_SIZE);
+ lobby.Players.Select(player => player.PlayerId).ShouldContain(accountId);
+
+ // the seat is still the account's own: joining it again is refused as a duplicate, not accepted twice
+ await reconnected.SendAsync(new JoinLobbyPacket { LobbyId = lobbyId, Tribe = 0 });
+ (await reconnected.ExpectAsync()).Result.ShouldBe(LobbyActionResult.AlreadyJoinedLobby);
+ }
+
+ [Fact]
+ public async Task TestOneAccountCanSitInSeveralLobbies() {
+ using var database = new TempDatabase();
+ await using var server = await TestServer.StartAsync(database.Path);
+
+ using var alice = await TestClient.ConnectAsync(server);
+ await alice.RegisterAsync(TestGames.UniqueName("gina"), TestGames.PASSWORD);
+ using var bob = await TestClient.ConnectAsync(server);
+ await bob.RegisterAsync(TestGames.UniqueName("hank"), TestGames.PASSWORD);
+
+ // one lobby alice creates herself...
+ await alice.SendAsync(new CreateLobbyPacket { MaxPlayers = 2, WorldSize = TestGames.WORLD_SIZE, Tribe = 0 });
+ var own = await alice.ExpectAsync();
+ own.Result.ShouldBe(LobbyActionResult.Ok);
+
+ // ...and one somebody else created
+ await bob.SendAsync(new CreateLobbyPacket { MaxPlayers = 2, WorldSize = TestGames.WORLD_SIZE, Tribe = 0 });
+ var other = await bob.ExpectAsync();
+ other.Result.ShouldBe(LobbyActionResult.Ok);
+ other.LobbyId.ShouldNotBe(own.LobbyId);
+
+ await alice.SendAsync(new JoinLobbyPacket { LobbyId = other.LobbyId, Tribe = 0 });
+ (await alice.ExpectAsync()).Result.ShouldBe(LobbyActionResult.Ok);
+
+ await alice.SendAsync(new GetLobbiesPacket());
+ var lobbies = await alice.ExpectAsync();
+ lobbies.Lobbies.Count(lobby => lobby[alice.AccountId] != null).ShouldBe(2);
+
+ // leaving one of them doesn't touch the other
+ await alice.SendAsync(new LeaveLobbyPacket { LobbyId = other.LobbyId });
+ (await alice.ExpectAsync()).Result.ShouldBe(LobbyActionResult.Ok);
+
+ await alice.SendAsync(new GetLobbiesPacket());
+ var afterLeaving = await alice.ExpectAsync();
+ afterLeaving.Lobbies.Count(lobby => lobby[alice.AccountId] != null).ShouldBe(1);
+ afterLeaving.Lobbies.Single(lobby => lobby[alice.AccountId] != null).Id.ShouldBe(own.LobbyId);
+ }
+
+ #endregion
+
+ #region Games
+
+ [Fact]
+ public async Task TestTwoPlayerGameStartsAndKeepsRunning() {
+ using var database = new TempDatabase();
+ await using var server = await TestServer.StartAsync(database.Path);
+
+ using var alice = await TestClient.ConnectAsync(server);
+ var aliceName = TestGames.UniqueName("iris");
+ await alice.RegisterAsync(aliceName, TestGames.PASSWORD);
+ using var bob = await TestClient.ConnectAsync(server);
+ var bobName = TestGames.UniqueName("jack");
+ await bob.RegisterAsync(bobName, TestGames.PASSWORD);
+
+ var gameId = await TestGames.StartGameAsync(alice, bob);
+
+ var state = await alice.GetStateAsync(gameId);
+ state.Result.ShouldBe(GameActionResult.Ok);
+ state.WorldSize.ShouldBe(TestGames.WORLD_SIZE);
+ state.Over.ShouldBeFalse();
+ state.CurrentPlayer.ShouldBe(1u);
+ state.Players.Count.ShouldBe(2);
+ state.Players.Single(player => player.Name == aliceName).PlayerId.ShouldBe(1u);
+ state.Players.Single(player => player.Name == bobName).PlayerId.ShouldBe(2u);
+ state.Players.ShouldAllBe(player => player.Alive);
+ state.Tiles.Length.ShouldBe((int)(TestGames.WORLD_SIZE * TestGames.WORLD_SIZE));
+
+ // the lobby is gone, its game replaced it
+ await alice.SendAsync(new GetLobbiesPacket());
+ (await alice.ExpectAsync()).Lobbies.ShouldNotContain(lobby => lobby.Id == gameId);
+
+ // a daily game must survive a few ticks of the start-lobbies loop without expiring on its own
+ (await alice.TryReceiveAsync(null, TimeSpan.FromSeconds(7))).ShouldBeNull();
+ (await alice.TryReceiveAsync(null, TimeSpan.Zero)).ShouldBeNull();
+ (await alice.GetStateAsync(gameId)).Turn.ShouldBe(state.Turn);
+ }
+
+ [Fact]
+ public async Task TestGameJoinIsRefusedToNonMembers() {
+ using var database = new TempDatabase();
+ await using var server = await TestServer.StartAsync(database.Path);
+
+ using var alice = await TestClient.ConnectAsync(server);
+ await alice.RegisterAsync(TestGames.UniqueName("kate"), TestGames.PASSWORD);
+ using var bob = await TestClient.ConnectAsync(server);
+ await bob.RegisterAsync(TestGames.UniqueName("liam"), TestGames.PASSWORD);
+
+ var gameId = await TestGames.StartGameAsync(alice, bob);
+
+ using var stranger = await TestClient.ConnectAsync(server);
+ await stranger.RegisterAsync(TestGames.UniqueName("mallory"), TestGames.PASSWORD);
+
+ await stranger.SendAsync(new JoinGamePacket { GameId = gameId });
+ var refused = await stranger.ExpectAsync();
+ refused.Result.ShouldBe(GameActionResult.NotInGame);
+ refused.Tiles.ShouldBeEmpty();
+
+ await stranger.SendAsync(new JoinGamePacket { GameId = gameId + 1000 });
+ (await stranger.ExpectAsync()).Result.ShouldBe(GameActionResult.GameNotFound);
+
+ // no membership means no state and no actions either
+ (await stranger.GetStateAsync(gameId)).Result.ShouldBe(GameActionResult.NotInGame);
+
+ await stranger.SendAsync(new EndTurnPacket { GameId = gameId });
+ (await stranger.ExpectAsync()).Result.ShouldBe(GameActionResult.NotInGame);
+
+ await stranger.SendAsync(new ResignGamePacket { GameId = gameId });
+ (await stranger.ExpectAsync()).Result.ShouldBe(GameActionResult.NotInGame);
+
+ await stranger.SendAsync(new GetMyGamesPacket());
+ (await stranger.ExpectAsync()).GameIds.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task TestLeavingAGameKeepsTheSeatAndRejoiningRestoresTheState() {
+ using var database = new TempDatabase();
+ await using var server = await TestServer.StartAsync(database.Path);
+
+ using var alice = await TestClient.ConnectAsync(server);
+ await alice.RegisterAsync(TestGames.UniqueName("nina"), TestGames.PASSWORD);
+ using var bob = await TestClient.ConnectAsync(server);
+ await bob.RegisterAsync(TestGames.UniqueName("oscar"), TestGames.PASSWORD);
+
+ var gameId = await TestGames.StartGameAsync(alice, bob);
+ var before = await alice.GetStateAsync(gameId);
+
+ await alice.SendAsync(new LeaveGamePacket { GameId = gameId });
+ var left = await alice.ExpectAsync();
+ left.Result.ShouldBe(GameActionResult.Ok);
+ left.GameId.ShouldBe(gameId);
+
+ // leaving is not resigning: the player is still alive and still owns the game
+ await alice.SendAsync(new GetMyGamesPacket());
+ (await alice.ExpectAsync()).GameIds.ShouldContain(gameId);
+
+ // ...but the view is closed, so gameplay packets bound to the transport don't resolve anymore
+ (await alice.GetStateAsync(gameId)).Result.ShouldBe(GameActionResult.NotInGame);
+
+ await alice.SendAsync(new JoinGamePacket { GameId = gameId });
+ var rejoined = await alice.ExpectAsync(packet => packet.Result == GameActionResult.Ok);
+ rejoined.GameId.ShouldBe(gameId);
+ rejoined.Turn.ShouldBe(before.Turn);
+ rejoined.CurrentPlayer.ShouldBe(before.CurrentPlayer);
+ rejoined.Tiles.ShouldBe(before.Tiles);
+ rejoined.Troops.ShouldBe(before.Troops);
+ rejoined.Players.Single(player => player.PlayerId == 1).Alive.ShouldBeTrue();
+
+ // and the seat works again
+ await alice.SendAsync(new EndTurnPacket { GameId = gameId });
+ (await alice.ExpectAsync()).Result.ShouldBe(GameActionResult.Ok);
+ }
+
+ [Fact]
+ public async Task TestAnAccountKeepsOneSeatPerGame() {
+ using var database = new TempDatabase();
+ await using var server = await TestServer.StartAsync(database.Path);
+
+ using var alice = await TestClient.ConnectAsync(server);
+ var aliceName = TestGames.UniqueName("peggy");
+ await alice.RegisterAsync(aliceName, TestGames.PASSWORD);
+ using var bob = await TestClient.ConnectAsync(server);
+ var bobName = TestGames.UniqueName("quinn");
+ await bob.RegisterAsync(bobName, TestGames.PASSWORD);
+
+ // alice hosts the first game, so she's player 1 there...
+ var first = await TestGames.StartGameAsync(alice, bob);
+ // ...and joins the second one, where she's player 2 instead
+ var second = await TestGames.StartGameAsync(bob, alice);
+ second.ShouldNotBe(first);
+
+ await alice.SendAsync(new GetMyGamesPacket());
+ var mine = await alice.ExpectAsync();
+ mine.GameIds.OrderBy(id => id).ShouldBe(new[] { first, second }.OrderBy(id => id));
+
+ var firstState = await alice.GetStateAsync(first);
+ firstState.Players.Single(player => player.Name == aliceName).PlayerId.ShouldBe(1u);
+ var secondState = await alice.GetStateAsync(second);
+ secondState.Players.Single(player => player.Name == aliceName).PlayerId.ShouldBe(2u);
+
+ // both games open the first turn on their own player 1, and the mapping decides who may act
+ await alice.SendAsync(new EndTurnPacket { GameId = second });
+ (await alice.ExpectAsync()).Result.ShouldBe(GameActionResult.NotYourTurn);
+
+ await alice.SendAsync(new EndTurnPacket { GameId = first });
+ (await alice.ExpectAsync()).Result.ShouldBe(GameActionResult.Ok);
+
+ // the turn only moved in the game it was sent for
+ var turnStarted = await bob.ExpectAsync(packet => packet.GameId == first);
+ turnStarted.PlayerId.ShouldBe(2u);
+ (await alice.GetStateAsync(second)).CurrentPlayer.ShouldBe(1u);
+ }
+
+ [Fact]
+ public async Task TestMovesAndTurnsSurviveAServerRestart() {
+ using var database = new TempDatabase();
+ var aliceName = TestGames.UniqueName("rita");
+ var bobName = TestGames.UniqueName("sam");
+
+ ulong gameId;
+ string aliceToken;
+ string bobToken;
+ uint movedTurn;
+ Vector2I destination;
+
+ await using (var first = await TestServer.StartAsync(database.Path)) {
+ using var alice = await TestClient.ConnectAsync(first);
+ aliceToken = (await alice.RegisterAsync(aliceName, TestGames.PASSWORD)).Token;
+ using var bob = await TestClient.ConnectAsync(first);
+ bobToken = (await bob.RegisterAsync(bobName, TestGames.PASSWORD)).Token;
+
+ gameId = await TestGames.StartGameAsync(alice, bob);
+ var state = await alice.GetStateAsync(gameId);
+
+ // move any troop of player 1 to any tile it accepts: the point is that the move sticks, not which one it is
+ destination = await MoveAnyTroopAsync(alice, bob, gameId, state);
+ movedTurn = state.Turn;
+
+ await alice.SendAsync(new EndTurnPacket { GameId = gameId });
+ (await alice.ExpectAsync()).Result.ShouldBe(GameActionResult.Ok);
+ var started = await alice.ExpectAsync(packet => packet.GameId == gameId);
+ started.PlayerId.ShouldBe(2u);
+ }
+
+ await using var second = await TestServer.StartAsync(database.Path);
+
+ using var reconnected = await TestClient.ConnectAsync(second);
+ (await reconnected.ResumeAsync(aliceToken)).Ok.ShouldBeTrue();
+
+ // the game itself survived, and so did the membership
+ await reconnected.SendAsync(new GetMyGamesPacket());
+ (await reconnected.ExpectAsync()).GameIds.ShouldContain(gameId);
+
+ await reconnected.SendAsync(new JoinGamePacket { GameId = gameId });
+ var restored = await reconnected.ExpectAsync(packet => packet.Result == GameActionResult.Ok);
+
+ restored.GameId.ShouldBe(gameId);
+ restored.CurrentPlayer.ShouldBe(2u);
+ restored.Turn.ShouldBeGreaterThanOrEqualTo(movedTurn);
+ restored.TroopAt(destination).Player.ShouldBe(1u);
+ restored.Players.Count.ShouldBe(2);
+ restored.Players.ShouldAllBe(player => player.Alive);
+
+ // and the restored turn order is enforced against the restored seats
+ await reconnected.SendAsync(new EndTurnPacket { GameId = gameId });
+ (await reconnected.ExpectAsync()).Result.ShouldBe(GameActionResult.NotYourTurn);
+
+ using var bobAgain = await TestClient.ConnectAsync(second);
+ (await bobAgain.ResumeAsync(bobToken)).Ok.ShouldBeTrue();
+ await bobAgain.SendAsync(new JoinGamePacket { GameId = gameId });
+ (await bobAgain.ExpectAsync(packet => packet.Result == GameActionResult.Ok)).CurrentPlayer
+ .ShouldBe(2u);
+
+ await bobAgain.SendAsync(new EndTurnPacket { GameId = gameId });
+ (await bobAgain.ExpectAsync()).Result.ShouldBe(GameActionResult.Ok);
+ }
+
+ [Fact]
+ public async Task TestResignationEliminatesThePlayerAndEndsTheGame() {
+ using var database = new TempDatabase();
+ await using var server = await TestServer.StartAsync(database.Path);
+
+ using var alice = await TestClient.ConnectAsync(server);
+ await alice.RegisterAsync(TestGames.UniqueName("tina"), TestGames.PASSWORD);
+ using var bob = await TestClient.ConnectAsync(server);
+ await bob.RegisterAsync(TestGames.UniqueName("user"), TestGames.PASSWORD);
+
+ var gameId = await TestGames.StartGameAsync(alice, bob);
+
+ await bob.SendAsync(new ResignGamePacket { GameId = gameId });
+ var resigned = await bob.ExpectAsync();
+ resigned.Result.ShouldBe(GameActionResult.Ok);
+ resigned.GameId.ShouldBe(gameId);
+
+ // the other player learns about it, and about the game ending with him as the winner
+ var eliminated = await alice.ExpectAsync(packet => packet.GameId == gameId);
+ eliminated.PlayerId.ShouldBe(2u);
+
+ var over = await alice.ExpectAsync(packet => packet.GameId == gameId);
+ over.Winner.ShouldBe(1u);
+ over.Players.Single(player => player.PlayerId == 2).Alive.ShouldBeFalse();
+ over.Players.Single(player => player.PlayerId == 1).Alive.ShouldBeTrue();
+
+ // resigning twice isn't allowed, and a finished game refuses gameplay
+ await bob.SendAsync(new ResignGamePacket { GameId = gameId });
+ (await bob.ExpectAsync()).Result.ShouldNotBe(GameActionResult.Ok);
+
+ await alice.SendAsync(new EndTurnPacket { GameId = gameId });
+ (await alice.ExpectAsync()).Result.ShouldNotBe(GameActionResult.Ok);
+
+ // the finished game is retained so its members can still look at the result
+ var final = await alice.GetStateAsync(gameId);
+ final.Over.ShouldBeTrue();
+ final.Winner.ShouldBe(1u);
+
+ await alice.SendAsync(new GetMyGamesPacket());
+ (await alice.ExpectAsync()).GameIds.ShouldContain(gameId);
+ }
+
+ #endregion
+
+ ///
+ /// Moves any troop of player 1 onto any neighboring tile the engine accepts
+ ///
+ ///
+ /// The world is generated from a random seed, so which tiles a troop may step on isn't known upfront: the helper
+ /// tries the neighbors until one is accepted, and asserts the resulting broadcast
+ ///
+ /// the position the troop ended up on
+ private static async Task MoveAnyTroopAsync(TestClient mover, TestClient observer, ulong gameId,
+ GameStatePacket state) {
+ var size = (int)state.WorldSize;
+
+ foreach (var from in state.TroopsOf(1)) {
+ for (var dy = -1; dy <= 1; dy++) {
+ for (var dx = -1; dx <= 1; dx++) {
+ var to = new Vector2I(from.X + dx, from.Y + dy);
+ if ((dx == 0 && dy == 0) || to.X < 0 || to.Y < 0 || to.X >= size || to.Y >= size) {
+ continue;
+ }
+
+ await mover.SendAsync(new MoveTroopPacket { GameId = gameId, From = from, To = to });
+ if ((await mover.ExpectAsync()).Result != GameActionResult.Ok) {
+ continue;
+ }
+
+ // both players see the move, with the same positions the mover asked for
+ foreach (var client in new[] { mover, observer }) {
+ var broadcast = await client.ExpectAsync(packet => packet.GameId == gameId);
+ broadcast.PlayerId.ShouldBe(1u);
+ broadcast.From.ShouldBe(from);
+ broadcast.To.ShouldBe(to);
+ broadcast.Update.Tiles.ShouldNotBeEmpty();
+ }
+
+ (await mover.GetStateAsync(gameId)).TroopAt(to).Player.ShouldBe(1u);
+ return to;
+ }
+ }
+ }
+
+ throw new Xunit.Sdk.XunitException("no troop of player 1 could move anywhere on the generated world");
+ }
+}
diff --git a/OpenPolytopia.UnitTest/GameServerTestHarness.cs b/OpenPolytopia.UnitTest/GameServerTestHarness.cs
new file mode 100644
index 0000000..a65d938
--- /dev/null
+++ b/OpenPolytopia.UnitTest/GameServerTestHarness.cs
@@ -0,0 +1,372 @@
+namespace OpenPolytopia;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading.Tasks;
+using Common.Network;
+using Common.Network.Packets;
+using Server;
+using Shouldly;
+
+///
+/// A listening on a free loopback port, backed by a throwaway SQLite file
+///
+///
+/// The database lives in a temporary directory owned by the test, so a restart can point a brand new server at the
+/// very same file; disposing the harness stops the server and awaits its accept loop before disposing it, which is
+/// what keeps the background loops from touching an already disposed store
+///
+internal sealed class TestServer : IAsyncDisposable {
+ private readonly GameServer _server;
+ private readonly Task _run;
+ private bool _stopped;
+
+ /// The loopback port the server is listening on
+ public int Port { get; }
+
+ /// Path of the SQLite database backing this server
+ public string DatabasePath { get; }
+
+ private TestServer(string databasePath, int port) {
+ DatabasePath = databasePath;
+ Port = port;
+ _server = new GameServer(port, "127.0.0.1", databasePath);
+ _run = _server.RunAsync();
+ }
+
+ ///
+ /// Starts a server and waits until its listener actually accepts connections
+ ///
+ /// the SQLite file to use; an existing one gets restored
+ public static async Task StartAsync(string databasePath) {
+ PacketRegistrar.RegisterAllPackets();
+ var server = new TestServer(databasePath, FreePort());
+ await server.WaitUntilListeningAsync();
+ return server;
+ }
+
+ ///
+ /// Stops the server and awaits its loops, without disposing it
+ ///
+ public async Task StopAsync() {
+ if (_stopped) {
+ return;
+ }
+
+ _stopped = true;
+ _server.Stop();
+ // RunAsync returns once the accept loop observes the cancellation and closes every client
+ await _run.WaitAsync(TimeSpan.FromSeconds(15));
+ }
+
+ public async ValueTask DisposeAsync() {
+ await StopAsync();
+ _server.Dispose();
+ }
+
+ ///
+ /// Polls the port until a plain TCP connect succeeds
+ ///
+ private async Task WaitUntilListeningAsync() {
+ await TestPolling.UntilAsync(async () => {
+ try {
+ using var probe = new TcpClient();
+ await probe.ConnectAsync(IPAddress.Loopback, Port);
+ return true;
+ }
+ catch (SocketException) {
+ return false;
+ }
+ }, TimeSpan.FromSeconds(10), "the server never started listening");
+ }
+
+ ///
+ /// Asks the OS for a free port, then hands it over to the server
+ ///
+ ///
+ /// Racy in theory, but the listener is only closed for the instant it takes the server to bind it, and every test
+ /// asks for its own port
+ ///
+ private static int FreePort() {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+}
+
+///
+/// A real TCP client of a , with the boilerplate every test needs
+///
+///
+/// Packets the tests don't ask for (lobby broadcasts, keep alive packets already answered by
+/// ...) are buffered instead of dropped, so a test can assert on them later and the
+/// order they arrive in never makes a test flaky
+///
+internal sealed class TestClient : IDisposable {
+ private static readonly TimeSpan DEFAULT_TIMEOUT = TimeSpan.FromSeconds(20);
+
+ private readonly ClientConnection _connection;
+ private readonly List _buffered = [];
+
+ /// Account id assigned by the server, valid after a successful authentication
+ public uint AccountId { get; private set; }
+
+ /// Session token of the last successful authentication
+ public string Token { get; private set; } = "";
+
+ /// Display name of the authenticated account
+ public string Name { get; private set; } = "";
+
+ /// Whether the transport is still up
+ public bool Connected => _connection.Connected;
+
+ private TestClient(ClientConnection connection) => _connection = connection;
+
+ ///
+ /// Connects to a server and completes the protocol handshake
+ ///
+ public static async Task ConnectAsync(TestServer server) {
+ PacketRegistrar.RegisterAllPackets();
+ var connection = new ClientConnection("127.0.0.1", server.Port);
+ await connection.ConnectAsync();
+
+ var client = new TestClient(connection);
+ await client.SendAsync(new HandshakePacket { Version = NetworkConstants.VERSION });
+ var response = await client.ExpectAsync();
+ response.Ok.ShouldBeTrue();
+ return client;
+ }
+
+ public Task SendAsync(IPacket packet) => _connection.SendPacketAsync(packet);
+
+ ///
+ /// Registers a new account and remembers its id and token
+ ///
+ public Task RegisterAsync(string username, string password) =>
+ AuthenticateAsync(new RegisterAccountPacket { Username = username, Password = password });
+
+ ///
+ /// Logs into an existing account and remembers its id and token
+ ///
+ public Task LoginAsync(string username, string password) =>
+ AuthenticateAsync(new LoginPacket { Username = username, Password = password });
+
+ ///
+ /// Resumes a session from a token handed out by a previous authentication
+ ///
+ public Task ResumeAsync(string token) =>
+ AuthenticateAsync(new ResumeSessionPacket { Token = token });
+
+ private async Task AuthenticateAsync(IPacket request) {
+ await SendAsync(request);
+ // pbkdf2 with 600k iterations isn't instant, and the server serializes every packet through its state lock
+ var response = await ExpectAsync(timeout: TimeSpan.FromSeconds(30));
+
+ if (response.Ok) {
+ AccountId = response.PlayerId;
+ Token = response.Token;
+ Name = response.Name;
+ }
+
+ return response;
+ }
+
+ ///
+ /// Waits for the first packet of a type matching an optional predicate
+ ///
+ ///
+ /// Already buffered packets are considered first; the matched one is consumed, everything else stays available
+ /// for the next call
+ ///
+ /// an optional extra condition the packet has to satisfy
+ /// how long to wait before failing the test
+ public async Task ExpectAsync(Func? predicate = null, TimeSpan? timeout = null) where T : IPacket {
+ var deadline = timeout ?? DEFAULT_TIMEOUT;
+ var packet = await TryReceiveAsync(predicate, deadline);
+ return packet ?? throw new Xunit.Sdk.XunitException($"no matching {typeof(T).Name} arrived within {deadline}");
+ }
+
+ ///
+ /// Waits for a packet the test expects not to arrive
+ ///
+ /// the packet if one arrived within , null otherwise
+ public Task TryReceiveAsync(Func? predicate, TimeSpan timeout) where T : IPacket =>
+ TestPolling.PollAsync(() => {
+ Drain();
+
+ for (var index = 0; index < _buffered.Count; index++) {
+ if (_buffered[index] is T match && (predicate == null || predicate(match))) {
+ _buffered.RemoveAt(index);
+ return match;
+ }
+ }
+
+ return default;
+ }, timeout);
+
+ ///
+ /// Waits until the server drops this connection
+ ///
+ public Task ShouldBeDisconnectedAsync(TimeSpan? timeout = null) => TestPolling.UntilAsync(
+ () => Task.FromResult(!Connected), timeout ?? DEFAULT_TIMEOUT, "the server never closed the connection");
+
+ private void Drain() {
+ while (_connection.IncomingPackets.TryDequeue(out var packet)) {
+ _buffered.Add(packet);
+ }
+ }
+
+ public void Dispose() => _connection.Dispose();
+}
+
+///
+/// Deterministic waiting: every test waits for a condition with a deadline instead of sleeping for a fixed time
+///
+internal static class TestPolling {
+ private static readonly TimeSpan INTERVAL = TimeSpan.FromMilliseconds(10);
+
+ ///
+ /// Polls until returns a non-default value or the timeout expires
+ ///
+ public static async Task PollAsync(Func probe, TimeSpan timeout) {
+ var deadline = DateTime.UtcNow + timeout;
+
+ while (true) {
+ var result = probe();
+ if (!EqualityComparer.Default.Equals(result, default)) {
+ return result;
+ }
+
+ if (DateTime.UtcNow >= deadline) {
+ return default;
+ }
+
+ await Task.Delay(INTERVAL);
+ }
+ }
+
+ ///
+ /// Polls until holds, failing the test when the timeout expires
+ ///
+ public static async Task UntilAsync(Func> condition, TimeSpan timeout, string message) {
+ var deadline = DateTime.UtcNow + timeout;
+
+ while (true) {
+ if (await condition()) {
+ return;
+ }
+
+ if (DateTime.UtcNow >= deadline) {
+ throw new Xunit.Sdk.XunitException($"{message} (waited {timeout})");
+ }
+
+ await Task.Delay(INTERVAL);
+ }
+ }
+}
+
+///
+/// A temporary directory holding the SQLite files of a single test
+///
+internal sealed class TempDatabase : IDisposable {
+ private readonly string _directory;
+
+ /// Path of the database file; it doesn't exist until a server opens it
+ public string Path { get; }
+
+ public TempDatabase() {
+ _directory = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"openpolytopia-it-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_directory);
+ Path = System.IO.Path.Combine(_directory, "server.db");
+ }
+
+ public void Dispose() {
+ try {
+ Directory.Delete(_directory, true);
+ }
+ catch (IOException) {
+ // a leftover wal file on a slow filesystem isn't worth failing a test over
+ }
+ }
+}
+
+///
+/// Helpers shared by the integration tests: names, credentials and the packet sequence that starts a real game
+///
+internal static class TestGames {
+ /// Smallest world a lobby accepts, so the tests generate the map as fast as possible
+ public const uint WORLD_SIZE = 11;
+
+ /// Passwords have a 12 characters minimum, every test account shares this one
+ public const string PASSWORD = "correct horse battery";
+
+ private static int _counter;
+
+ /// Builds a username unique across the whole test run
+ public static string UniqueName(string prefix) => $"{prefix}_{Interlocked.Increment(ref _counter)}";
+
+ ///
+ /// Creates a two player lobby, readies both players and waits for the game the server starts out of it
+ ///
+ ///
+ /// The server only looks for ready lobbies every 5 seconds, so the wait here has to outlast a full tick plus
+ /// the map generation
+ ///
+ /// the id of the started game, i.e. the id of the lobby
+ public static async Task StartGameAsync(TestClient host, TestClient guest) {
+ await host.SendAsync(new CreateLobbyPacket { MaxPlayers = 2, WorldSize = WORLD_SIZE, Tribe = 0 });
+ var created = await host.ExpectAsync();
+ created.Result.ShouldBe(LobbyActionResult.Ok);
+
+ // tribes.json only ships imperius, and the server refuses a lobby with a tribe it has no data for
+ await guest.SendAsync(new JoinLobbyPacket { LobbyId = created.LobbyId, Tribe = 0 });
+ (await guest.ExpectAsync()).Result.ShouldBe(LobbyActionResult.Ok);
+
+ await host.SendAsync(new SetReadyPacket { LobbyId = created.LobbyId, Ready = true });
+ (await host.ExpectAsync()).Result.ShouldBe(LobbyActionResult.Ok);
+ await guest.SendAsync(new SetReadyPacket { LobbyId = created.LobbyId, Ready = true });
+ (await guest.ExpectAsync()).Result.ShouldBe(LobbyActionResult.Ok);
+
+ var startTimeout = TimeSpan.FromSeconds(60);
+ foreach (var client in new[] { host, guest }) {
+ await client.ExpectAsync(packet => packet.LobbyId == created.LobbyId, startTimeout);
+ await client.ExpectAsync(packet => packet.GameId == created.LobbyId, startTimeout);
+ }
+
+ return created.LobbyId;
+ }
+
+ ///
+ /// Asks the server for the full state of a game the client is a member of
+ ///
+ public static async Task GetStateAsync(this TestClient client, ulong gameId) {
+ await client.SendAsync(new GetGameStatePacket { GameId = gameId });
+ return await client.ExpectAsync(packet => packet.GameId == gameId);
+ }
+
+ ///
+ /// Finds every tile holding a troop of a player, as grid positions
+ ///
+ public static List TroopsOf(this GameStatePacket state, uint playerId) {
+ List positions = [];
+
+ for (var index = 0; index < state.Troops.Length; index++) {
+ var troop = new Common.TroopData { Raw = state.Troops[index] };
+ if (troop.IsValid() && troop.Player == playerId) {
+ positions.Add(new Godot.Vector2I(index % (int)state.WorldSize, index / (int)state.WorldSize));
+ }
+ }
+
+ return positions;
+ }
+
+ /// Reads the packed troop of a position out of a full state packet
+ public static Common.TroopData TroopAt(this GameStatePacket state, Godot.Vector2I position) =>
+ new() { Raw = state.Troops[(position.Y * (int)state.WorldSize) + position.X] };
+}
diff --git a/OpenPolytopia.UnitTest/GameSessionTest.cs b/OpenPolytopia.UnitTest/GameSessionTest.cs
index 60eebe9..0206c57 100644
--- a/OpenPolytopia.UnitTest/GameSessionTest.cs
+++ b/OpenPolytopia.UnitTest/GameSessionTest.cs
@@ -84,6 +84,8 @@ public async Task TestBuildStateRoundTripsThroughSerialization() {
var packet = RoundTrip(session.BuildState());
+ packet.Players[0].AccountId.ShouldBe(100u);
+ packet.Players[1].AccountId.ShouldBe(101u);
packet.Result.ShouldBe(GameActionResult.Ok);
packet.GameId.ShouldBe(session.Id);
packet.WorldSize.ShouldBe(WORLD_SIZE);
diff --git a/OpenPolytopia.UnitTest/PlayerAbstractionTest.cs b/OpenPolytopia.UnitTest/PlayerAbstractionTest.cs
new file mode 100644
index 0000000..34999e6
--- /dev/null
+++ b/OpenPolytopia.UnitTest/PlayerAbstractionTest.cs
@@ -0,0 +1,27 @@
+namespace OpenPolytopia;
+
+using Common;
+using Common.Gameplay;
+using Shouldly;
+using Xunit;
+
+public class PlayerAbstractionTest {
+ private sealed record TestBot(TribeType Tribe, int Id) : IPlayer;
+
+ [Fact]
+ public async System.Threading.Tasks.Task AlternateParticipantCanGenerateAndPlay() {
+ var pieces = GameTestFixture.BuildPieces();
+ IPlayer[] players = [new Player(TribeType.Imperius, 1), new TestBot(TribeType.Imperius, 2)];
+ var grid = new Grid(16);
+ var cities = new CityManager(grid);
+ await new TerrainGeneration(grid, cities, pieces.Tribes, players, 42).GenerateMapAsync();
+ var troops = new TroopManager(16);
+ troops.RegisterTroops(EmbeddedResources.LoadTroops()!);
+ var game = new Common.Gameplay.Game(grid, cities, troops, pieces.Tribes, pieces.Buildings,
+ pieces.TechTree, players);
+ game.Start();
+ game.EndTurn(1).Result.ShouldBe(GameActionResult.Ok);
+ game.CurrentPlayer.ShouldBe(2);
+ game.EndTurn(2).Result.ShouldBe(GameActionResult.Ok);
+ }
+}
diff --git a/OpenPolytopia.UnitTest/ServerPersistenceFailureTest.cs b/OpenPolytopia.UnitTest/ServerPersistenceFailureTest.cs
new file mode 100644
index 0000000..2e5e09c
--- /dev/null
+++ b/OpenPolytopia.UnitTest/ServerPersistenceFailureTest.cs
@@ -0,0 +1,38 @@
+namespace OpenPolytopia;
+
+using System;
+using System.Threading.Tasks;
+using Common.Network.Packets;
+using Microsoft.Data.Sqlite;
+using Shouldly;
+
+public class ServerPersistenceFailureTest {
+ [Fact]
+ public async Task FailedSaveSendsNoSuccessAndRestoresLobbyIdentity() {
+ using var database = new TempDatabase();
+ await using var server = await TestServer.StartAsync(database.Path);
+ using var first = await TestClient.ConnectAsync(server);
+ var account = await first.RegisterAsync("rollback_player", TestGames.PASSWORD);
+ using var connection = new SqliteConnection(new SqliteConnectionStringBuilder {
+ DataSource = database.Path, Pooling = false
+ }.ToString());
+ connection.Open();
+ using var command = connection.CreateCommand();
+ command.CommandText = "CREATE TRIGGER refuse_state BEFORE INSERT ON server_state BEGIN SELECT RAISE(FAIL, 'injected failure'); END;";
+ command.ExecuteNonQuery();
+ await first.SendAsync(new CreateLobbyPacket { MaxPlayers = 2, WorldSize = 11 });
+ await first.ShouldBeDisconnectedAsync();
+ (await first.TryReceiveAsync(null, TimeSpan.Zero)).ShouldBeNull();
+ command.CommandText = "DROP TRIGGER refuse_state;";
+ command.ExecuteNonQuery();
+
+ using var resumed = await TestClient.ConnectAsync(server);
+ (await resumed.ResumeAsync(account.Token)).Ok.ShouldBeTrue();
+ await resumed.SendAsync(new GetLobbiesPacket());
+ (await resumed.ExpectAsync()).Lobbies.ShouldBeEmpty();
+ await resumed.SendAsync(new CreateLobbyPacket { MaxPlayers = 2, WorldSize = 11 });
+ var created = await resumed.ExpectAsync();
+ created.Result.ShouldBe(LobbyActionResult.Ok);
+ created.LobbyId.ShouldBe(1ul);
+ }
+}
diff --git a/OpenPolytopia.UnitTest/ServerStoreTest.cs b/OpenPolytopia.UnitTest/ServerStoreTest.cs
new file mode 100644
index 0000000..0e05b7c
--- /dev/null
+++ b/OpenPolytopia.UnitTest/ServerStoreTest.cs
@@ -0,0 +1,344 @@
+namespace OpenPolytopia;
+
+using Microsoft.Data.Sqlite;
+using Server;
+using Shouldly;
+
+///
+/// Tests against a real SQLite database in a temporary directory
+///
+public class ServerStoreTest : IDisposable {
+ private const string USERNAME = "Tester";
+ private const string PASSWORD = "correct horse battery";
+
+ ///
+ /// A clock the tests can move forward at will
+ ///
+ private sealed class FakeTimeProvider(DateTimeOffset now) : TimeProvider {
+ private DateTimeOffset _now = now;
+
+ public override DateTimeOffset GetUtcNow() => _now;
+
+ public void Advance(TimeSpan delta) => _now += delta;
+ }
+
+ private readonly string _directory =
+ Directory.CreateTempSubdirectory("openpolytopia-store-test-").FullName;
+
+ private string DatabasePath => Path.Combine(_directory, "server.db");
+
+ private ServerStore Open(TimeProvider? timeProvider = null) => new(DatabasePath, timeProvider);
+
+ public void Dispose() {
+ GC.SuppressFinalize(this);
+ Directory.Delete(_directory, true);
+ }
+
+ #region Registration
+
+ [Fact]
+ public void RegisterReturnsAnAccountAndASession() {
+ using var store = Open();
+
+ var registration = store.Register(USERNAME, PASSWORD);
+
+ registration.Account.Id.ShouldBe(1u);
+ registration.Account.Username.ShouldBe("tester");
+ registration.Account.DisplayName.ShouldBe(USERNAME);
+ registration.Token.ShouldNotBeNullOrWhiteSpace();
+ store.Resume(registration.Token).ShouldNotBeNull().Id.ShouldBe(registration.Account.Id);
+ }
+
+ [Fact]
+ public void RegisterAssignsDistinctIds() {
+ using var store = Open();
+
+ var first = store.Register("first_user", PASSWORD);
+ var second = store.Register("second_user", PASSWORD);
+
+ second.Account.Id.ShouldNotBe(first.Account.Id);
+ }
+
+ [Fact]
+ public void RegisterRejectsADuplicateNormalizedUsername() {
+ using var store = Open();
+ store.Register("Tester", PASSWORD);
+
+ Should.Throw(() => store.Register("tEsTeR", PASSWORD));
+ }
+
+ [Fact]
+ public void RegisterRollsBackTheAccountOfARejectedDuplicate() {
+ using var store = Open();
+ var original = store.Register("Tester", PASSWORD);
+
+ Should.Throw(() => store.Register("TESTER", "a different password"));
+
+ // the duplicate left nothing behind: the original credentials are still the only ones that work
+ store.Login("tester", PASSWORD).ShouldNotBeNull().Account.Id.ShouldBe(original.Account.Id);
+ store.Login("tester", "a different password").ShouldBeNull();
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("ab")]
+ [InlineData("this_username_is_way_too_long_to_be_accepted")]
+ [InlineData("has space")]
+ [InlineData("has-dash")]
+ [InlineData("\u00fc\u00f1\u00ef\u00e7\u00f6\u00e9")]
+ public void RegisterRejectsAnInvalidUsername(string username) {
+ using var store = Open();
+
+ Should.Throw(() => store.Register(username, PASSWORD));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("short")]
+ [InlineData("elevenChars")]
+ public void RegisterRejectsATooShortPassword(string password) {
+ using var store = Open();
+
+ Should.Throw(() => store.Register(USERNAME, password));
+ }
+
+ [Fact]
+ public void RegisterRejectsATooLongPassword() {
+ using var store = Open();
+
+ Should.Throw(() => store.Register(USERNAME, new string('x', 129)));
+ }
+
+ [Fact]
+ public void RegisterAcceptsThePasswordsAtTheBounds() {
+ using var store = Open();
+
+ store.Register("shortest", new string('x', 12)).ShouldNotBeNull();
+ store.Register("longest", new string('x', 128)).ShouldNotBeNull();
+ }
+
+ #endregion
+
+ #region Login
+
+ [Fact]
+ public void LoginAcceptsTheRightPasswordInAnyCasing() {
+ using var store = Open();
+ var registration = store.Register(USERNAME, PASSWORD);
+
+ var login = store.Login("TESTER", PASSWORD).ShouldNotBeNull();
+
+ login.Account.Id.ShouldBe(registration.Account.Id);
+ login.Account.DisplayName.ShouldBe(USERNAME);
+ login.Token.ShouldNotBe(registration.Token);
+ store.Resume(login.Token).ShouldNotBeNull();
+ // logging in doesn't invalidate the sessions that are already open
+ store.Resume(registration.Token).ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void LoginRejectsAWrongPassword() {
+ using var store = Open();
+ store.Register(USERNAME, PASSWORD);
+
+ store.Login(USERNAME, "wrong password!").ShouldBeNull();
+ }
+
+ [Fact]
+ public void LoginRejectsAnUnknownAccount() {
+ using var store = Open();
+ store.Register(USERNAME, PASSWORD);
+
+ store.Login("nobody", PASSWORD).ShouldBeNull();
+ }
+
+ [Theory]
+ [InlineData("ab", PASSWORD)]
+ [InlineData("has space", PASSWORD)]
+ [InlineData(USERNAME, "short")]
+ public void LoginRejectsMalformedCredentialsWithoutThrowing(string username, string password) {
+ using var store = Open();
+ store.Register(USERNAME, PASSWORD);
+
+ store.Login(username, password).ShouldBeNull();
+ }
+
+ #endregion
+
+ #region Sessions
+
+ [Fact]
+ public void ResumeRejectsAnUnknownOrMalformedToken() {
+ using var store = Open();
+ var registration = store.Register(USERNAME, PASSWORD);
+
+ store.Resume("").ShouldBeNull();
+ store.Resume("not base64 at all!").ShouldBeNull();
+ store.Resume(Convert.ToBase64String(new byte[16])).ShouldBeNull();
+ store.Resume(Convert.ToBase64String(new byte[32])).ShouldBeNull();
+ // a token of the right shape but with a flipped character isn't the one that was handed out
+ store.Resume(FlipFirstCharacter(registration.Token)).ShouldBeNull();
+ }
+
+ [Fact]
+ public void LogoutRevokesTheSession() {
+ using var store = Open();
+ var registration = store.Register(USERNAME, PASSWORD);
+ var other = store.Login(USERNAME, PASSWORD).ShouldNotBeNull();
+
+ store.Logout(registration.Token).ShouldBeTrue();
+
+ store.Resume(registration.Token).ShouldBeNull();
+ // revoking one session leaves the other ones alone
+ store.Resume(other.Token).ShouldNotBeNull();
+ // revoking twice is a no-op
+ store.Logout(registration.Token).ShouldBeFalse();
+ store.Logout("garbage").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ResumeRejectsAnExpiredSession() {
+ var clock = new FakeTimeProvider(DateTimeOffset.UnixEpoch);
+ using var store = Open(clock);
+ var registration = store.Register(USERNAME, PASSWORD);
+
+ clock.Advance(ServerStore.SessionLifetime - TimeSpan.FromMinutes(1));
+ store.Resume(registration.Token).ShouldNotBeNull();
+
+ clock.Advance(TimeSpan.FromMinutes(2));
+ store.Resume(registration.Token).ShouldBeNull();
+ // logging in again gives a session that's valid from now on
+ var renewed = store.Login(USERNAME, PASSWORD).ShouldNotBeNull();
+ store.Resume(renewed.Token).ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void AnExpiredSessionStaysRejectedAfterARestart() {
+ var clock = new FakeTimeProvider(DateTimeOffset.UnixEpoch);
+ string token;
+ using (var store = Open(clock)) {
+ token = store.Register(USERNAME, PASSWORD).Token;
+ }
+
+ clock.Advance(ServerStore.SessionLifetime + TimeSpan.FromDays(1));
+
+ using var restarted = Open(clock);
+ restarted.Resume(token).ShouldBeNull();
+ }
+
+ #endregion
+
+ #region Persistence
+
+ [Fact]
+ public void AccountsAndSessionsSurviveARestart() {
+ uint accountId;
+ string token;
+ using (var store = Open()) {
+ var registration = store.Register(USERNAME, PASSWORD);
+ accountId = registration.Account.Id;
+ token = registration.Token;
+ }
+
+ using var restarted = Open();
+
+ var resumed = restarted.Resume(token).ShouldNotBeNull();
+ resumed.Id.ShouldBe(accountId);
+ resumed.Username.ShouldBe("tester");
+ resumed.DisplayName.ShouldBe(USERNAME);
+ restarted.Login(USERNAME, PASSWORD).ShouldNotBeNull().Account.Id.ShouldBe(accountId);
+ restarted.Login(USERNAME, "wrong password!").ShouldBeNull();
+ }
+
+ [Fact]
+ public void AccountIdsAreNotReusedAcrossRestarts() {
+ uint firstId;
+ using (var store = Open()) {
+ firstId = store.Register("first_user", PASSWORD).Account.Id;
+ }
+
+ using var restarted = Open();
+ restarted.Register("second_user", PASSWORD).Account.Id.ShouldBeGreaterThan(firstId);
+ }
+
+ [Fact]
+ public void StateIsEmptyUntilItsSaved() {
+ using var store = Open();
+
+ store.LoadState().ShouldBeNull();
+ }
+
+ [Fact]
+ public void StateSurvivesARestart() {
+ const string SNAPSHOT = """{"turn":12,"games":[]}""";
+ using (var store = Open()) {
+ store.SaveState(SNAPSHOT);
+ }
+
+ using var restarted = Open();
+ restarted.LoadState().ShouldBe(SNAPSHOT);
+ }
+
+ [Fact]
+ public void SaveStateReplacesThePreviousSnapshot() {
+ using var store = Open();
+
+ store.SaveState("""{"turn":1}""");
+ store.SaveState("""{"turn":2}""");
+
+ store.LoadState().ShouldBe("""{"turn":2}""");
+ }
+
+ #endregion
+
+ #region Schema
+
+ [Fact]
+ public void OpeningRejectsANewerSchema() {
+ using (var store = Open()) {
+ store.Register(USERNAME, PASSWORD);
+ }
+
+ SetUserVersion(ServerStore.SCHEMA_VERSION + 1);
+
+ Should.Throw(() => Open());
+ }
+
+ [Fact]
+ public void OpeningAnAlreadyMigratedDatabaseKeepsItsData() {
+ using (var store = Open()) {
+ store.Register(USERNAME, PASSWORD);
+ store.SaveState("""{"turn":1}""");
+ }
+
+ using (Open()) { }
+
+ using var restarted = Open();
+ restarted.Login(USERNAME, PASSWORD).ShouldNotBeNull();
+ restarted.LoadState().ShouldBe("""{"turn":1}""");
+ }
+
+ [Fact]
+ public void UsingADisposedStoreThrows() {
+ var store = Open();
+ store.Dispose();
+ store.Dispose();
+
+ Should.Throw(() => store.LoadState());
+ }
+
+ #endregion
+
+ private static string FlipFirstCharacter(string token) =>
+ (token[0] == 'A' ? 'B' : 'A') + token[1..];
+
+ private void SetUserVersion(int version) {
+ using var connection = new SqliteConnection(new SqliteConnectionStringBuilder {
+ DataSource = DatabasePath, Pooling = false
+ }.ToString());
+ connection.Open();
+ using var command = connection.CreateCommand();
+ command.CommandText = $"PRAGMA user_version = {version};";
+ command.ExecuteNonQuery();
+ }
+}
diff --git a/OpenPolytopia.UnitTest/ServerStoreTransactionTest.cs b/OpenPolytopia.UnitTest/ServerStoreTransactionTest.cs
new file mode 100644
index 0000000..e764394
--- /dev/null
+++ b/OpenPolytopia.UnitTest/ServerStoreTransactionTest.cs
@@ -0,0 +1,25 @@
+namespace OpenPolytopia;
+
+using System;
+using System.Collections.Generic;
+using Server;
+using Shouldly;
+
+public class ServerStoreTransactionTest {
+ [Fact]
+ public void RenameAndSnapshotCommitTogether() {
+ using var store = new ServerStore(":memory:");
+ var login = store.Register("alice", "correct horse battery");
+ store.SaveState("updated lobbies", new Dictionary { [login.Account.Id] = "New name" });
+ store.Resume(login.Token)!.DisplayName.ShouldBe("New name");
+ store.LoadState().ShouldBe("updated lobbies");
+ }
+
+ [Fact]
+ public void FailedRenameRollsBackSnapshot() {
+ using var store = new ServerStore(":memory:");
+ store.SaveState("before");
+ Should.Throw(() => store.SaveState("after", new Dictionary { [123] = "Unknown" }));
+ store.LoadState().ShouldBe("before");
+ }
+}
diff --git a/OpenPolytopia.UnitTest/TransportSecurityTest.cs b/OpenPolytopia.UnitTest/TransportSecurityTest.cs
new file mode 100644
index 0000000..fb47349
--- /dev/null
+++ b/OpenPolytopia.UnitTest/TransportSecurityTest.cs
@@ -0,0 +1,304 @@
+namespace OpenPolytopia;
+
+using System;
+using System.IO;
+using System.Net;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Security.Authentication;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading;
+using System.Threading.Tasks;
+using Common.Network;
+using Common.Network.Packets;
+using Server;
+using Shouldly;
+
+///
+/// Covers the transport of the connection: plaintext on loopback, TLS everywhere else
+///
+[Collection(nameof(TransportSecurityTest))]
+public class TransportSecurityTest {
+ [Fact]
+ public void TestRepeatedClientDisposalIsSafe() {
+ using var client = new ClientConnection("localhost", 1);
+ client.Dispose();
+ client.Dispose();
+ }
+
+ [Fact]
+ public void TestGameServerRejectsPublicBindWithoutCertificate() {
+ var previous = Environment.GetEnvironmentVariable(ServerTls.CERTIFICATE_PATH_ENV);
+ try {
+ Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PATH_ENV, null);
+ Should.Throw(() => new GameServer(0, "0.0.0.0"));
+ using var local = new GameServer(0, "127.0.0.1", ":memory:");
+ }
+ finally { Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PATH_ENV, previous); }
+ }
+
+ private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(10);
+
+ private const string CERTIFICATE_PASSWORD = "test-password";
+
+ [Fact]
+ public void TestTlsIsOffForLoopbackAndOnForEverythingElse() {
+ new ClientConnection("127.0.0.1", 1).UsesTls.ShouldBeFalse();
+ new ClientConnection("localhost", 1).UsesTls.ShouldBeFalse();
+ new ClientConnection("LOCALHOST", 1).UsesTls.ShouldBeFalse();
+ new ClientConnection("::1", 1).UsesTls.ShouldBeFalse();
+
+ new ClientConnection("example.com", 1).UsesTls.ShouldBeTrue();
+ new ClientConnection("203.0.113.7", 1).UsesTls.ShouldBeTrue();
+
+ // an explicit choice always wins over the default
+ new ClientConnection("127.0.0.1", 1, true).UsesTls.ShouldBeTrue();
+ new ClientConnection("example.com", 1, false).UsesTls.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task TestPlaintextLoopbackConnectionDeliversPackets() {
+ var port = FreePort();
+ using var server = new ServerConnection(port, "127.0.0.1");
+ server.TlsEnabled.ShouldBeFalse();
+
+ var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ server.OnPacketReceived += (_, packet) => {
+ received.TrySetResult(packet);
+ return Task.CompletedTask;
+ };
+
+ _ = server.RunAsync();
+ await WaitForListenerAsync(port);
+
+ using var client = new ClientConnection("127.0.0.1", port);
+ await client.ConnectAsync();
+ await client.SendPacketAsync(new SetNamePacket { Name = "plaintext" });
+
+ var packet = await received.Task.WaitAsync(_timeout);
+ packet.ShouldBeOfType().Name.ShouldBe("plaintext");
+ }
+
+ [Fact]
+ public async Task TestTlsClientTimesOutWhenServerNeverCompletesHandshake() {
+ using var listener = new TcpListenerHolder();
+ using var client = new ClientConnection("127.0.0.1", listener.Port, true);
+ var connecting = client.ConnectAsync();
+ using var peer = await listener.Listener.AcceptTcpClientAsync().WaitAsync(_timeout);
+
+ // Keep TCP open without answering TLS: the client's own deadline must end the attempt.
+ await Should.ThrowAsync(connecting.WaitAsync(TimeSpan.FromSeconds(20)));
+ client.Connected.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task TestTlsConnectionRejectsAnUntrustedCertificate() {
+ var port = FreePort();
+ using var certificate = CreateSelfSignedCertificate();
+ using var server = new ServerConnection(port, "127.0.0.1", certificate);
+ server.TlsEnabled.ShouldBeTrue();
+
+ _ = server.RunAsync();
+ await WaitForListenerAsync(port);
+
+ // the certificate is self signed, so no trust store on earth accepts it:
+ // the client must refuse instead of falling back to plaintext
+ using var client = new ClientConnection("localhost", port, true);
+ await Should.ThrowAsync(client.ConnectAsync().WaitAsync(_timeout));
+ client.Connected.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task TestTlsServerDropsPlaintextClientsWithoutBlockingTheOthers() {
+ var port = FreePort();
+ using var certificate = CreateSelfSignedCertificate();
+ using var server = new ServerConnection(port, "127.0.0.1", certificate);
+
+ var connected = 0;
+ server.OnClientConnected += _ => Interlocked.Increment(ref connected);
+
+ _ = server.RunAsync();
+ await WaitForListenerAsync(port);
+
+ // a client speaking plaintext to a TLS server never becomes a connection
+ using var plaintext = new ClientConnection("127.0.0.1", port, false);
+ await plaintext.ConnectAsync();
+ await plaintext.SendPacketAsync(new SetNamePacket { Name = "clear" });
+
+ // the accept loop keeps working while that socket is being dropped
+ await WaitForListenerAsync(port);
+
+ connected.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task TestPacketsSurviveTheRoundTripOverAnAuthenticatedStream() {
+ PacketRegistrar.RegisterAllPackets();
+
+ using var certificate = CreateSelfSignedCertificate();
+ using var listener = new TcpListenerHolder();
+ var acceptTask = listener.Listener.AcceptTcpClientAsync();
+
+ using var clientTcp = new TcpClient();
+ await clientTcp.ConnectAsync(IPAddress.Loopback, listener.Port);
+ using var serverTcp = await acceptTask.WaitAsync(_timeout);
+
+ await using var serverSsl = new SslStream(serverTcp.GetStream(), false);
+
+ // the test pins this exact certificate instead of weakening the validation:
+ // production code never gets a callback at all
+ await using var clientSsl = new SslStream(clientTcp.GetStream(), false,
+ (_, remote, _, _) => remote != null && remote.GetCertHashString() == certificate.GetCertHashString());
+
+ await Task.WhenAll(
+ serverSsl.AuthenticateAsServerAsync(certificate),
+ clientSsl.AuthenticateAsClientAsync("localhost")).WaitAsync(_timeout);
+
+ clientSsl.IsEncrypted.ShouldBeTrue();
+
+ var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var serverConnection = new NetworkConnection(1, serverTcp, serverSsl);
+ serverConnection.OnPacketReceived += (_, packet) => {
+ received.TrySetResult(packet);
+ return Task.CompletedTask;
+ };
+ _ = serverConnection.RunAsync();
+
+ using var clientConnection = new NetworkConnection(0, clientTcp, clientSsl);
+ await clientConnection.SendPacketAsync(new SetNamePacket { Name = "encrypted" });
+
+ var packet = await received.Task.WaitAsync(_timeout);
+ packet.ShouldBeOfType().Name.ShouldBe("encrypted");
+ }
+
+ [Fact]
+ public void TestPlaintextIsOnlyAllowedOnLoopback() {
+ // every interface, so reachable from the network
+ Should.Throw(() => ServerTls.Validate(null, null));
+ Should.Throw(() => ServerTls.Validate("0.0.0.0", null));
+ Should.Throw(() => ServerTls.Validate("203.0.113.7", null));
+
+ // not an ip address, so we can't prove it's loopback
+ Should.Throw(() => ServerTls.Validate("example.com", null));
+
+ Should.NotThrow(() => ServerTls.Validate("127.0.0.1", null));
+ Should.NotThrow(() => ServerTls.Validate("::1", null));
+ }
+
+ [Fact]
+ public void TestACertificateAllowsAnyBindAddress() {
+ using var certificate = CreateSelfSignedCertificate();
+
+ Should.NotThrow(() => ServerTls.Validate(null, certificate));
+ Should.NotThrow(() => ServerTls.Validate("0.0.0.0", certificate));
+ }
+
+ [Fact]
+ public void TestTheCertificateGetsLoadedFromTheEnvironment() {
+ var path = Path.Combine(Path.GetTempPath(), $"openpolytopia-tls-{Guid.NewGuid():N}.pfx");
+ using var source = CreateSelfSignedCertificate();
+ File.WriteAllBytes(path, source.Export(X509ContentType.Pfx, CERTIFICATE_PASSWORD));
+
+ var oldPath = Environment.GetEnvironmentVariable(ServerTls.CERTIFICATE_PATH_ENV);
+ var oldPassword = Environment.GetEnvironmentVariable(ServerTls.CERTIFICATE_PASSWORD_ENV);
+
+ try {
+ Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PATH_ENV, null);
+ ServerTls.LoadCertificate().ShouldBeNull();
+
+ Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PATH_ENV, path);
+ Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PASSWORD_ENV, CERTIFICATE_PASSWORD);
+
+ using var loaded = ServerTls.LoadCertificate();
+ loaded.ShouldNotBeNull();
+ loaded.Subject.ShouldBe(source.Subject);
+ loaded.HasPrivateKey.ShouldBeTrue();
+
+ // a wrong password must fail loudly at startup instead of silently running plaintext
+ Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PASSWORD_ENV, "wrong");
+ Should.Throw(() => ServerTls.LoadCertificate());
+
+ Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PATH_ENV, $"{path}.missing");
+ Should.Throw(() => ServerTls.LoadCertificate());
+ }
+ finally {
+ Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PATH_ENV, oldPath);
+ Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PASSWORD_ENV, oldPassword);
+ File.Delete(path);
+ }
+ }
+
+ ///
+ /// Creates a self signed certificate for localhost, only good enough for a test
+ ///
+ private static X509Certificate2 CreateSelfSignedCertificate() {
+ using var rsa = RSA.Create(2048);
+ var request = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+
+ var alternativeNames = new SubjectAlternativeNameBuilder();
+ alternativeNames.AddDnsName("localhost");
+ alternativeNames.AddIpAddress(IPAddress.Loopback);
+ request.CertificateExtensions.Add(alternativeNames.Build());
+ request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
+
+ // server authentication
+ request.CertificateExtensions.Add(
+ new X509EnhancedKeyUsageExtension([new Oid("1.3.6.1.5.5.7.3.1")], false));
+
+ using var certificate =
+ request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1));
+
+ // round trip through a pfx so the private key is usable by SslStream on every platform
+ return X509CertificateLoader.LoadPkcs12(
+ certificate.Export(X509ContentType.Pfx, CERTIFICATE_PASSWORD), CERTIFICATE_PASSWORD,
+ X509KeyStorageFlags.Exportable);
+ }
+
+ ///
+ /// Grabs a free loopback port
+ ///
+ private static int FreePort() {
+ using var holder = new TcpListenerHolder();
+ return holder.Port;
+ }
+
+ ///
+ /// Waits until something is accepting connections on the port
+ ///
+ private static async Task WaitForListenerAsync(int port) {
+ var deadline = DateTime.UtcNow + _timeout;
+
+ while (DateTime.UtcNow < deadline) {
+ try {
+ using var probe = new TcpClient();
+ await probe.ConnectAsync(IPAddress.Loopback, port);
+ return;
+ }
+ catch (SocketException) {
+ await Task.Delay(20);
+ }
+ }
+
+ throw new TimeoutException($"Nothing started listening on port {port}");
+ }
+
+ ///
+ /// A started loopback listener on an OS assigned port
+ ///
+ private sealed class TcpListenerHolder : IDisposable {
+ public TcpListener Listener { get; }
+ public int Port { get; }
+
+ public TcpListenerHolder() {
+ Listener = new TcpListener(IPAddress.Loopback, 0);
+ Listener.Start();
+ Port = ((IPEndPoint)Listener.LocalEndpoint).Port;
+ }
+
+ public void Dispose() => Listener.Dispose();
+ }
+}
+
+[CollectionDefinition(nameof(TransportSecurityTest), DisableParallelization = true)]
+public class TransportEnvironmentCollection { }
diff --git a/OpenPolytopia/src/Game.cs b/OpenPolytopia/src/Game.cs
index c611620..27e27f7 100644
--- a/OpenPolytopia/src/Game.cs
+++ b/OpenPolytopia/src/Game.cs
@@ -2,50 +2,103 @@ namespace OpenPolytopia;
using Godot;
+///
+/// Login screen; it logs the player into his account and moves on to the lobby scene
+///
+///
+/// A session saved by a previous run gets resumed automatically as soon as the client connects,
+/// so this scene usually flashes by without the player typing anything
+///
public partial class Game : Control {
[Export] public PackedScene? LobbyScene;
private NetworkNode _network = null!;
+ private LineEdit _usernameEdit = null!;
+ private LineEdit _passwordEdit = null!;
+ private Button _loginButton = null!;
+ private Button _registerButton = null!;
private Label _statusLabel = null!;
- private string _playerName = "";
private bool _switching;
public override void _Ready() {
+ _usernameEdit = GetNode("CenterContainer/VBoxContainer/UsernameEdit");
+ _passwordEdit = GetNode("CenterContainer/VBoxContainer/PasswordEdit");
+ _loginButton = GetNode
public event Action? OnNameSet;
+ ///
+ /// Fired after every authentication attempt, including the automatic ones after a reconnection
+ ///
+ ///
+ /// The argument is whether the client is logged in now; a false after a is expected
+ ///
+ public event Action? OnAuthenticated;
+
+ ///
+ /// Fired after the server responds to or
+ ///
+ public event Action? OnGameState;
+
+ ///
+ /// Fired after the server responds to or
+ ///
+ public event Action? OnMembershipResult;
+
+ ///
+ /// Fired when the server sends the turn deadline of a game this client opened
+ ///
+ ///
+ /// The deadline is only meaningful against the server clock the packet carries: subtract the two to get
+ /// how long is left, the clock of this machine could be off by anything
+ ///
+ public event Action? OnGameClock;
+
+ ///
+ /// Fired with the game id when the server broadcasts a change to a game this client opened
+ ///
+ ///
+ /// The client doesn't track the game state, ask for it again with if needed
+ ///
+ public event Action? OnGameChanged;
+
///
/// Fired after the server responds to
///
@@ -162,7 +268,23 @@ public NetworkNode() {
_dispatcher.Register((_, packet) => ManageLobbyUpdated(packet));
_dispatcher.Register((_, packet) => ManageLobbyDeleted(packet));
// hand the game data over to the scenes
- _dispatcher.Register((_, packet) => OnGameStarted?.Invoke(packet));
+ _dispatcher.Register((_, packet) => ManageGameStarted(packet));
+ // account handling
+ _dispatcher.Register((_, packet) => ManageAuthentication(packet));
+ _dispatcher.Register((_, packet) => ManageMyGames(packet));
+ _dispatcher.Register((_, packet) => OnGameState?.Invoke(packet));
+ _dispatcher.Register((_, packet) => ManageMembershipResult(packet));
+ _dispatcher.Register((_, packet) => OnGameClock?.Invoke(packet));
+ // the gameplay broadcasts of every opened game; the client only reports that something changed
+ _dispatcher.Register((_, packet) => OnGameChanged?.Invoke(packet.GameId));
+ _dispatcher.Register((_, packet) => OnGameChanged?.Invoke(packet.GameId));
+ _dispatcher.Register((_, packet) => OnGameChanged?.Invoke(packet.GameId));
+ _dispatcher.Register((_, packet) => OnGameChanged?.Invoke(packet.GameId));
+ _dispatcher.Register((_, packet) => OnGameChanged?.Invoke(packet.GameId));
+ _dispatcher.Register((_, packet) => OnGameChanged?.Invoke(packet.GameId));
+ _dispatcher.Register((_, packet) => OnGameChanged?.Invoke(packet.GameId));
+ _dispatcher.Register((_, packet) => OnGameChanged?.Invoke(packet.GameId));
+ _dispatcher.Register((_, packet) => OnGameChanged?.Invoke(packet.GameId));
}
///
@@ -202,11 +324,17 @@ public override void _PhysicsProcess(double delta) {
return;
}
+ if (_handshakeDone && !_authenticated && !_authInFlight && _sessionToken != null && Time.GetTicksMsec() >= _resumeAt) {
+ _resumeAt = ulong.MaxValue;
+ _resuming = true;
+ _authInFlight = true;
+ Send(new ResumeSessionPacket { Token = _sessionToken });
+ }
var connection = _connection;
if (connection == null) {
// surface a connection attempt that failed before being established
if (Interlocked.Exchange(ref _disconnectedFlag, 0) == 1) {
- _pendingPackets.Clear();
+ ResetSessionState();
OnDisconnected?.Invoke();
}
@@ -229,16 +357,15 @@ public override void _PhysicsProcess(double delta) {
// manage a disconnection signalled by the background read task
if (Interlocked.Exchange(ref _disconnectedFlag, 0) == 1) {
- _handshakeDone = false;
connection.Dispose();
_connection = null;
- _lobbies.Clear();
- _pendingPackets.Clear();
+ ResetSessionState();
// wait before the first retry, the server could still be going down
_reconnectAt = DateTime.UtcNow + RECONNECT_DELAY;
OnLobbiesChanged?.Invoke();
+ OnMyGamesChanged?.Invoke();
OnDisconnected?.Invoke();
}
}
@@ -259,6 +386,14 @@ public void ConnectToServer() {
_reconnectAt = DateTime.UtcNow + RECONNECT_DELAY;
var (host, port) = ResolveServerAddress();
+
+ // the saved session belongs to a single server, load the one of the server being connected to
+ var serverKey = $"{host}:{port}";
+ if (serverKey != _serverKey) {
+ _serverKey = serverKey;
+ _sessionToken = LoadSession(serverKey);
+ }
+
_ = ConnectToServerAsync(host, port);
}
@@ -269,19 +404,80 @@ public void Disconnect() {
_reconnectAt = DateTime.MaxValue;
_connection?.Dispose();
_connection = null;
- _handshakeDone = false;
- _lobbies.Clear();
- _pendingPackets.Clear();
+ ResetSessionState();
OnLobbiesChanged?.Invoke();
+ OnMyGamesChanged?.Invoke();
// consume the disconnection signalled by disposing the connection
Interlocked.Exchange(ref _disconnectedFlag, 0);
}
///
- /// Registers the player on the server or renames him
+ /// Checks a username against the rules the server enforces
+ ///
+ /// the username to check
+ public static bool IsValidUsername(string username) =>
+ username.Length is >= MIN_USERNAME_LENGTH and <= MAX_USERNAME_LENGTH &&
+ username.All(character => character is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or (>= '0' and <= '9') or '_');
+
+ ///
+ /// Checks a password against the rules the server enforces
+ ///
+ /// the password to check
+ public static bool IsValidPassword(string password) =>
+ password.Length is >= MIN_PASSWORD_LENGTH and <= MAX_PASSWORD_LENGTH;
+
+ ///
+ /// Creates a new account and logs into it
+ ///
+ /// the username of the new account
+ /// the password of the new account; it never gets saved on disk
+ public void Register(string username, string password) {
+ if (_authInFlight) return;
+ _authInFlight = true;
+ _resumeAt = ulong.MaxValue;
+ _resuming = false;
+ Send(new RegisterAccountPacket { Username = username, Password = password });
+ }
+
+ ///
+ /// Logs into an existing account
+ ///
+ /// the username of the account
+ /// the password of the account; it never gets saved on disk
+ public void Login(string username, string password) {
+ if (_authInFlight) return;
+ _authInFlight = true;
+ _resumeAt = ulong.MaxValue;
+ _resuming = false;
+ Send(new LoginPacket { Username = username, Password = password });
+ }
+
+ ///
+ /// Logs out of the current account and forgets its saved session
+ ///
+ public void Logout() {
+ Send(new LogoutPacket());
+
+ // drop the token right away, the player asked not to be logged in again automatically
+ ClearSession();
+ _loggingOut = true;
+ _authenticated = false;
+ _accountId = 0;
+ _accountName = "";
+ _lobbies.Clear();
+ _myGames.Clear();
+ _authInFlight = false;
+ _resumeAt = ulong.MaxValue;
+ }
+
+ ///
+ /// Renames the player on the server
///
/// the new player's name
+ ///
+ /// This only changes the display name, accounts are created with
+ ///
public void SetName(string name) {
_requestedName = name;
Send(new SetNamePacket { Name = name });
@@ -292,13 +488,46 @@ public void SetName(string name) {
///
public void RefreshLobbies() => Send(new GetLobbiesPacket());
+ ///
+ /// Queries the server for the games this account takes part in
+ ///
+ public void RefreshMyGames() => Send(new GetMyGamesPacket());
+
+ ///
+ /// Opens one of the account's games and subscribes to its updates
+ ///
+ /// the id of the game to open
+ public void OpenGame(ulong gameId) => Send(new JoinGamePacket { GameId = gameId });
+
+ ///
+ /// Closes an opened game without resigning from it
+ ///
+ /// the id of the game to close
+ public void CloseGame(ulong gameId) => Send(new LeaveGamePacket { GameId = gameId });
+
+ ///
+ /// Resigns from a game
+ ///
+ ///
+ /// This eliminates the player from the game and cannot be undone
+ ///
+ /// the id of the game to resign from
+ public void ResignGame(ulong gameId) => Send(new ResignGamePacket { GameId = gameId });
+
+ ///
+ /// Asks the server for the full state of a game
+ ///
+ /// the id of the game
+ public void RequestGameState(ulong gameId) => Send(new GetGameStatePacket { GameId = gameId });
+
///
/// 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 });
+ /// turn timer of the game, 0 for live and 1 for daily
+ public void CreateLobby(uint maxPlayers, uint tribe, uint timerMode) =>
+ Send(new CreateLobbyPacket { MaxPlayers = maxPlayers, WorldSize = 14, Tribe = tribe, TimerMode = timerMode });
///
/// Joins an existing lobby
@@ -382,19 +611,32 @@ private static async Task ConnectToServerAsync(string host, int port) {
}
}
+ ///
+ /// Whether a packet is one of the few the server accepts before the client is logged in
+ ///
+ private static bool IsAccountPacket(IPacket packet) =>
+ packet is RegisterAccountPacket or LoginPacket or ResumeSessionPacket or LogoutPacket;
+
private static void Send(IPacket packet) {
var connection = _connection;
// without a connection there is no telling when the packet could go out, drop it
if (connection == null) {
+ if (IsAccountPacket(packet)) _authInFlight = false;
return;
}
- // 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 (!_handshakeDone) {
- _pendingPackets.Enqueue(packet);
- return;
+ // the server kicks whoever talks before logging in, and replaying requests made against a session
+ // that is gone would act on a state the player never saw: everything but the login waits for a scene to ask again
+ if (!_handshakeDone || !_authenticated) {
+ // connecting takes a moment, hold the login the player just asked for until the handshake is done
+ if (!_handshakeDone && packet is RegisterAccountPacket or LoginPacket) {
+ _pendingAuth = packet;
+ }
+
+ if (!_handshakeDone || !IsAccountPacket(packet)) {
+ return;
+ }
}
_ = SendAsync(connection, packet);
@@ -426,33 +668,89 @@ private void ManageHandshakeResponse(HandshakeResponsePacket packet) {
return;
}
- _playerId = packet.PlayerId;
+ _connectionId = packet.PlayerId;
_handshakeDone = true;
OnConnected?.Invoke();
- // register again after a reconnection, the server forgot this player
- if (_acceptedName != null) {
- Send(new SetNamePacket { Name = _acceptedName });
- }
-
- // send the packets queued while connecting; stop if a handler disconnected
- while (_handshakeDone && _pendingPackets.TryDequeue(out var pending)) {
+ // a login the player asked for while connecting wins over the automatic resume
+ if (_pendingAuth != null) {
+ var pending = _pendingAuth;
+ _pendingAuth = null;
Send(pending);
+ return;
}
- // get the initial lobby list
- RefreshLobbies();
+ // log in again after a reconnection, the server forgot this transport
+ if (_sessionToken != null) {
+ _resuming = true;
+ Send(new ResumeSessionPacket { Token = _sessionToken });
+ }
}
private void ManageSetNameResponse(SetNameResponsePacket packet) {
if (packet.Ok) {
- // remember the name to register again after a reconnection
- _acceptedName = _requestedName;
+ _accountName = _requestedName ?? _accountName;
}
OnNameSet?.Invoke(packet.Ok);
}
+ private void ManageAuthentication(AuthenticationPacket packet) {
+ _authInFlight = false;
+ _retryable = packet.Retryable;
+ if (_loggingOut && !packet.Ok) { _loggingOut = false; return; }
+ var resuming = _resuming;
+ _resuming = false;
+
+ if (!packet.Ok) {
+ // the server refused a token it handed out before: it expired or got revoked, ask the player to log in again
+ if (resuming && !packet.Retryable) ClearSession();
+ if (resuming && packet.Retryable) _resumeAt = Time.GetTicksMsec() + 3000;
+
+ _authenticated = false;
+ _accountId = 0;
+ _accountName = "";
+ _lobbies.Clear();
+ _myGames.Clear();
+
+ OnLobbiesChanged?.Invoke();
+ OnMyGamesChanged?.Invoke();
+ OnAuthenticated?.Invoke(false);
+ return;
+ }
+
+ _authenticated = true;
+ _accountId = packet.PlayerId;
+ _accountName = packet.Name;
+ SaveSession(packet.Token);
+
+ OnAuthenticated?.Invoke(true);
+
+ // get the initial lobby and game lists, the scenes are allowed to talk to the server now
+ RefreshLobbies();
+ RefreshMyGames();
+ }
+
+ private void ManageMyGames(MyGamesPacket packet) {
+ _myGames.Clear();
+ _myGames.AddRange(packet.GameIds);
+ OnMyGamesChanged?.Invoke();
+ }
+
+ private void ManageMembershipResult(MembershipResultPacket packet) {
+ OnMembershipResult?.Invoke(packet);
+
+ // resigning removes the player from the game, the list of his games changed
+ RefreshMyGames();
+ }
+
+ private void ManageGameStarted(GameStartedPacket packet) {
+ OnGameStarted?.Invoke(packet);
+
+ // the lobby became a game this account takes part in
+ RefreshMyGames();
+ }
+
private void ManageGetLobbiesResponse(GetLobbiesResponsePacket packet) {
_lobbies.Clear();
_lobbies.AddRange(packet.Lobbies);
@@ -477,4 +775,94 @@ private void ManageLobbyDeleted(LobbyDeletedPacket packet) {
OnLobbiesChanged?.Invoke();
}
}
+
+ ///
+ /// Forgets everything tied to a connection, keeping the session token to resume with
+ ///
+ private static void ResetSessionState() {
+ _authInFlight = false;
+ _loggingOut = false;
+ _resumeAt = ulong.MaxValue;
+ _handshakeDone = false;
+ _authenticated = false;
+ _resuming = false;
+ _pendingAuth = null;
+ _lobbies.Clear();
+ _myGames.Clear();
+ }
+
+ ///
+ /// Returns the session token saved for a server, or null when there is none
+ ///
+ /// the host:port of the server
+ private static string? LoadSession(string serverKey) {
+ var config = new ConfigFile();
+ if (config.Load(SESSION_FILE) != Error.Ok) {
+ return null;
+ }
+
+ var token = config.GetValue(serverKey, TOKEN_KEY, "").AsString();
+ return string.IsNullOrEmpty(token) ? null : token;
+ }
+
+ ///
+ /// Saves the session token of the server this client is connected to
+ ///
+ /// the opaque token the server handed out
+ private static void SaveSession(string token) {
+ _sessionToken = token;
+
+ var config = new ConfigFile();
+
+ // load first, the file holds the tokens of the other servers too
+ config.Load(SESSION_FILE);
+ config.SetValue(_serverKey, TOKEN_KEY, token);
+
+ var error = config.Save(SESSION_FILE);
+ if (error != Error.Ok) {
+ GD.PushError($"Cannot save the session token: {error}");
+ return;
+ }
+
+ RestrictSessionFilePermissions();
+ }
+
+ ///
+ /// Forgets the session token of the server this client is connected to
+ ///
+ private static void ClearSession() {
+ _sessionToken = null;
+
+ var config = new ConfigFile();
+ if (config.Load(SESSION_FILE) != Error.Ok || !config.HasSection(_serverKey)) {
+ return;
+ }
+
+ config.EraseSection(_serverKey);
+
+ var error = config.Save(SESSION_FILE);
+ if (error != Error.Ok) {
+ GD.PushError($"Cannot clear the session token: {error}");
+ }
+ }
+
+ ///
+ /// Keeps the session file readable by this user only
+ ///
+ ///
+ /// A session token is as good as a password, and the user data directory is world readable on most systems;
+ /// Windows has no equivalent of the unix file mode, there the file keeps the permissions it inherits
+ ///
+ private static void RestrictSessionFilePermissions() {
+ if (OperatingSystem.IsWindows()) {
+ return;
+ }
+
+ try {
+ File.SetUnixFileMode(ProjectSettings.GlobalizePath(SESSION_FILE), UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ }
+ catch (Exception e) {
+ GD.PushWarning($"Cannot restrict the permissions of the session file: {e.Message}");
+ }
+ }
}