From c8489094470d1595f39edd7ff17490d0436702bc Mon Sep 17 00:00:00 2001 From: Enn3Developer Date: Sat, 5 Sep 2026 10:36:44 +0200 Subject: [PATCH 01/17] refactor: decouple game participants from human players --- OpenPolytopia.Common/Gameplay/Game.cs | 2 +- OpenPolytopia.Common/Player.cs | 10 ++++++- OpenPolytopia.Common/TerrainGeneration.cs | 2 +- .../PlayerAbstractionTest.cs | 27 +++++++++++++++++++ 4 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 OpenPolytopia.UnitTest/PlayerAbstractionTest.cs diff --git a/OpenPolytopia.Common/Gameplay/Game.cs b/OpenPolytopia.Common/Gameplay/Game.cs index 1ab3703f..46126acf 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/Player.cs b/OpenPolytopia.Common/Player.cs index 3f6915e6..63075991 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/TerrainGeneration.cs b/OpenPolytopia.Common/TerrainGeneration.cs index beb43f3d..2d423b71 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.UnitTest/PlayerAbstractionTest.cs b/OpenPolytopia.UnitTest/PlayerAbstractionTest.cs new file mode 100644 index 00000000..34999e61 --- /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); + } +} From 66517deafc5594e063ef0cba59ec8417625363f9 Mon Sep 17 00:00:00 2001 From: Enn3Developer Date: Sat, 5 Sep 2026 10:44:26 +0200 Subject: [PATCH 02/17] feat: add sqlite account storage and lossless game snapshots --- .../Gameplay/Game.Persistence.cs | 244 ++++++++ OpenPolytopia.Common/Gameplay/GameSnapshot.cs | 139 +++++ .../Gameplay/SnapshotEncoding.cs | 99 ++++ OpenPolytopia.Common/Score.cs | 11 + .../OpenPolytopia.Server.csproj | 5 + OpenPolytopia.Server/ServerStore.cs | 537 ++++++++++++++++++ OpenPolytopia.UnitTest/GamePersistenceTest.cs | 482 ++++++++++++++++ OpenPolytopia.UnitTest/ServerStoreTest.cs | 344 +++++++++++ 8 files changed, 1861 insertions(+) create mode 100644 OpenPolytopia.Common/Gameplay/Game.Persistence.cs create mode 100644 OpenPolytopia.Common/Gameplay/GameSnapshot.cs create mode 100644 OpenPolytopia.Common/Gameplay/SnapshotEncoding.cs create mode 100644 OpenPolytopia.Server/ServerStore.cs create mode 100644 OpenPolytopia.UnitTest/GamePersistenceTest.cs create mode 100644 OpenPolytopia.UnitTest/ServerStoreTest.cs diff --git a/OpenPolytopia.Common/Gameplay/Game.Persistence.cs b/OpenPolytopia.Common/Gameplay/Game.Persistence.cs new file mode 100644 index 00000000..6c60b0b5 --- /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/GameSnapshot.cs b/OpenPolytopia.Common/Gameplay/GameSnapshot.cs new file mode 100644 index 00000000..14709408 --- /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 autoincrement 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 00000000..4011f5e7 --- /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/Score.cs b/OpenPolytopia.Common/Score.cs index 6cd2c40b..e2d12739 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.Server/OpenPolytopia.Server.csproj b/OpenPolytopia.Server/OpenPolytopia.Server.csproj index fb394f1d..7f071583 100644 --- a/OpenPolytopia.Server/OpenPolytopia.Server.csproj +++ b/OpenPolytopia.Server/OpenPolytopia.Server.csproj @@ -10,6 +10,11 @@ true + + + + + diff --git a/OpenPolytopia.Server/ServerStore.cs b/OpenPolytopia.Server/ServerStore.cs new file mode 100644 index 00000000..35eda1ee --- /dev/null +++ b/OpenPolytopia.Server/ServerStore.cs @@ -0,0 +1,537 @@ +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 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 = 1; + + /// + /// 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(); + + lock (_lock) { + ThrowIfDisposed(); + using var transaction = _connection.BeginTransaction(); + + var record = FindAccountByUsername(transaction, normalized); + var salt = record?.Salt ?? _dummySalt; + var iterations = record?.Iterations ?? PBKDF2_ITERATIONS; + var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, HASH_SIZE); + + if (record is null || !CryptographicOperations.FixedTimeEquals(hash, record.Hash)) { + return null; + } + + var token = CreateSession(transaction, record.Account.Id); + transaction.Commit(); + return new AuthResult(record.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 + /// if is + public void SaveState(string state) { + 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(); + 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; + 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 = {SCHEMA_VERSION}; + """; + 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.UnitTest/GamePersistenceTest.cs b/OpenPolytopia.UnitTest/GamePersistenceTest.cs new file mode 100644 index 00000000..d6c652cd --- /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 TestRoundtripOfAGameThatHasntStarted() { + 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 TestRestoreRejectsArraysThatDontMatchTheGrid() { + 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 TestRestoreRejectsATechThatIsntInTheTree() { + 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 TestRestoreRejectsAPlayerListThatIsntAGame() { + 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/ServerStoreTest.cs b/OpenPolytopia.UnitTest/ServerStoreTest.cs new file mode 100644 index 00000000..8c934a36 --- /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("ünïcödé")] + 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(); + } +} From de220266164a4ab48e2801fc20f9a867ab02babf Mon Sep 17 00:00:00 2001 From: Enn3Developer Date: Sat, 5 Sep 2026 10:51:12 +0200 Subject: [PATCH 03/17] fix: resolve persistence spellcheck failures --- OpenPolytopia.Common/Gameplay/GameSnapshot.cs | 2 +- OpenPolytopia.Server/ServerStore.cs | 1 + OpenPolytopia.UnitTest/GamePersistenceTest.cs | 8 ++++---- OpenPolytopia.UnitTest/ServerStoreTest.cs | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/OpenPolytopia.Common/Gameplay/GameSnapshot.cs b/OpenPolytopia.Common/Gameplay/GameSnapshot.cs index 14709408..9d4a925e 100644 --- a/OpenPolytopia.Common/Gameplay/GameSnapshot.cs +++ b/OpenPolytopia.Common/Gameplay/GameSnapshot.cs @@ -100,7 +100,7 @@ public sealed class GameSnapshot { /// /// /// 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 autoincrement id isn't enough +/// whoever stores it: a SQLite child table needs an explicit order column, a plain automatically assigned id isn't enough /// public sealed class PlayerSnapshot { /// diff --git a/OpenPolytopia.Server/ServerStore.cs b/OpenPolytopia.Server/ServerStore.cs index 35eda1ee..8ee89147 100644 --- a/OpenPolytopia.Server/ServerStore.cs +++ b/OpenPolytopia.Server/ServerStore.cs @@ -1,3 +1,4 @@ +// cspell:words AUTOINCREMENT namespace OpenPolytopia.Server; using System.Security.Cryptography; diff --git a/OpenPolytopia.UnitTest/GamePersistenceTest.cs b/OpenPolytopia.UnitTest/GamePersistenceTest.cs index d6c652cd..49f84a63 100644 --- a/OpenPolytopia.UnitTest/GamePersistenceTest.cs +++ b/OpenPolytopia.UnitTest/GamePersistenceTest.cs @@ -100,7 +100,7 @@ private static Game PlayedGame() { } [Fact] - public void TestRoundtripOfAGameThatHasntStarted() { + public void TestRoundtripOfAGameThatHasNotStarted() { var game = GameTestFixture.NewGame(); var snapshot = game.ToSnapshot(); @@ -368,7 +368,7 @@ public void TestRestoreRejectsASnapshotOfAnotherVersion() { } [Fact] - public void TestRestoreRejectsArraysThatDontMatchTheGrid() { + public void TestRestoreRejectsArraysThatDoNotMatchTheGrid() { var snapshot = GameTestFixture.NewStartedGame().ToSnapshot(); Should.Throw(() => Restore(Clone(snapshot, tiles: [.. snapshot.Tiles.Skip(1)]))); @@ -393,7 +393,7 @@ public void TestRestoreRejectsACityOutsideTheGrid() { } [Fact] - public void TestRestoreRejectsATechThatIsntInTheTree() { + public void TestRestoreRejectsATechThatIsNotInTheTree() { var snapshot = GameTestFixture.NewStartedGame().ToSnapshot(); var players = snapshot.Players.Select(player => Clone(player, researched: ["not_a_tech"])).ToList(); @@ -401,7 +401,7 @@ public void TestRestoreRejectsATechThatIsntInTheTree() { } [Fact] - public void TestRestoreRejectsAPlayerListThatIsntAGame() { + public void TestRestoreRejectsAPlayerListThatIsNotAGame() { var snapshot = GameTestFixture.NewStartedGame().ToSnapshot(); Should.Throw(() => Restore(Clone(snapshot, players: [snapshot.Players[0]]))); diff --git a/OpenPolytopia.UnitTest/ServerStoreTest.cs b/OpenPolytopia.UnitTest/ServerStoreTest.cs index 8c934a36..0e05b7cf 100644 --- a/OpenPolytopia.UnitTest/ServerStoreTest.cs +++ b/OpenPolytopia.UnitTest/ServerStoreTest.cs @@ -85,7 +85,7 @@ public void RegisterRollsBackTheAccountOfARejectedDuplicate() { [InlineData("this_username_is_way_too_long_to_be_accepted")] [InlineData("has space")] [InlineData("has-dash")] - [InlineData("ünïcödé")] + [InlineData("\u00fc\u00f1\u00ef\u00e7\u00f6\u00e9")] public void RegisterRejectsAnInvalidUsername(string username) { using var store = Open(); @@ -95,7 +95,7 @@ public void RegisterRejectsAnInvalidUsername(string username) { [Theory] [InlineData("")] [InlineData("short")] - [InlineData("elevenchars")] + [InlineData("elevenChars")] public void RegisterRejectsATooShortPassword(string password) { using var store = Open(); From 2f2adfc4a88d98819bc2fe4898cf8676e2274f0e Mon Sep 17 00:00:00 2001 From: Enn3Developer Date: Sat, 5 Sep 2026 10:55:42 +0200 Subject: [PATCH 04/17] feat: secure remote connections with tls --- .../Network/ClientConnection.cs | 64 ++++- .../Network/NetworkConnection.cs | 19 +- .../Network/ServerConnection.cs | 119 ++++++-- OpenPolytopia.Server/ServerTls.cs | 109 +++++++ .../TransportSecurityTest.cs | 270 ++++++++++++++++++ 5 files changed, 560 insertions(+), 21 deletions(-) create mode 100644 OpenPolytopia.Server/ServerTls.cs create mode 100644 OpenPolytopia.UnitTest/TransportSecurityTest.cs diff --git a/OpenPolytopia.Common/Network/ClientConnection.cs b/OpenPolytopia.Common/Network/ClientConnection.cs index c64cc269..ed580e9f 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,9 +13,17 @@ 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); @@ -20,6 +31,11 @@ public class ClientConnection(string address, int port) : IDisposable { private readonly CancellationTokenSource _cts = new(); private NetworkConnection? _connection; + /// + /// 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 +54,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 +92,43 @@ 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 { + await ssl.AuthenticateAsClientAsync( + new SslClientAuthenticationOptions { + TargetHost = address, EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 + }, _cts.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); diff --git a/OpenPolytopia.Common/Network/NetworkConnection.cs b/OpenPolytopia.Common/Network/NetworkConnection.cs index d506aebb..58ba5d1b 100644 --- a/OpenPolytopia.Common/Network/NetworkConnection.cs +++ b/OpenPolytopia.Common/Network/NetworkConnection.cs @@ -11,8 +11,14 @@ 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(); private readonly SemaphoreSlim _writeLock = new(1, 1); private int _closed; @@ -116,6 +122,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/ServerConnection.cs b/OpenPolytopia.Common/Network/ServerConnection.cs index b11cf650..bd84abcf 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.Server/ServerTls.cs b/OpenPolytopia.Server/ServerTls.cs new file mode 100644 index 00000000..d19b8e9d --- /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/TransportSecurityTest.cs b/OpenPolytopia.UnitTest/TransportSecurityTest.cs new file mode 100644 index 00000000..c589bcda --- /dev/null +++ b/OpenPolytopia.UnitTest/TransportSecurityTest.cs @@ -0,0 +1,270 @@ +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 +/// +public class TransportSecurityTest { + 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 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(); + } +} From 83af556bdcf44da0b4e55d38cfbb935c24bad00e Mon Sep 17 00:00:00 2001 From: Enn3Developer Date: Sat, 5 Sep 2026 11:04:54 +0200 Subject: [PATCH 05/17] test: isolate certificate environment changes --- OpenPolytopia.UnitTest/TransportSecurityTest.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/OpenPolytopia.UnitTest/TransportSecurityTest.cs b/OpenPolytopia.UnitTest/TransportSecurityTest.cs index c589bcda..546b9975 100644 --- a/OpenPolytopia.UnitTest/TransportSecurityTest.cs +++ b/OpenPolytopia.UnitTest/TransportSecurityTest.cs @@ -18,6 +18,7 @@ namespace OpenPolytopia; /// /// Covers the transport of the connection: plaintext on loopback, TLS everywhere else /// +[Collection(nameof(TransportSecurityTest))] public class TransportSecurityTest { private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(10); @@ -268,3 +269,6 @@ public TcpListenerHolder() { public void Dispose() => Listener.Dispose(); } } + +[CollectionDefinition(nameof(TransportSecurityTest), DisableParallelization = true)] +public class TransportEnvironmentCollection { } From d6f03be5a791710f67f242d529ed3356156740ad Mon Sep 17 00:00:00 2001 From: Enn3Developer Date: Sat, 5 Sep 2026 11:16:20 +0200 Subject: [PATCH 06/17] fix: make client connection disposal idempotent --- OpenPolytopia.Common/Network/ClientConnection.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/OpenPolytopia.Common/Network/ClientConnection.cs b/OpenPolytopia.Common/Network/ClientConnection.cs index ed580e9f..e4f2db9a 100644 --- a/OpenPolytopia.Common/Network/ClientConnection.cs +++ b/OpenPolytopia.Common/Network/ClientConnection.cs @@ -30,6 +30,7 @@ public class ClientConnection(string address, int port, bool? useTls = null) : I 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 @@ -157,6 +158,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(); From 92c613fc4c7d44e3aa95d2f249c607c6d4437140 Mon Sep 17 00:00:00 2001 From: Enn3Developer Date: Sat, 5 Sep 2026 11:24:16 +0200 Subject: [PATCH 07/17] test: cover repeated client connection disposal --- OpenPolytopia.UnitTest/TransportSecurityTest.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/OpenPolytopia.UnitTest/TransportSecurityTest.cs b/OpenPolytopia.UnitTest/TransportSecurityTest.cs index 546b9975..7fc2e415 100644 --- a/OpenPolytopia.UnitTest/TransportSecurityTest.cs +++ b/OpenPolytopia.UnitTest/TransportSecurityTest.cs @@ -20,6 +20,13 @@ namespace OpenPolytopia; /// [Collection(nameof(TransportSecurityTest))] public class TransportSecurityTest { + [Fact] + public void TestRepeatedClientDisposalIsSafe() { + using var client = new ClientConnection("localhost", 1); + client.Dispose(); + client.Dispose(); + } + private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(10); private const string CERTIFICATE_PASSWORD = "test-password"; From 3296e898d15052a68f88bc9e37d6466aae37a379 Mon Sep 17 00:00:00 2001 From: Enn3Developer Date: Sat, 5 Sep 2026 10:55:18 +0200 Subject: [PATCH 08/17] feat: persist authenticated accounts and reconnectable game memberships --- .gitignore | 7 +- OpenPolytopia.Common/LobbyData.cs | 5 + .../Network/NetworkConstants.cs | 2 +- .../Network/PacketRegistrar.cs | 14 + .../Network/Packets/AccountPackets.cs | 69 +++ .../Network/Packets/LobbyPackets.cs | 2 + .../Network/Packets/TimerPackets.cs | 20 + OpenPolytopia.Server/GameManager.cs | 67 +-- OpenPolytopia.Server/GameServer.Accounts.cs | 87 +++ .../GameServer.Persistence.cs | 86 +++ OpenPolytopia.Server/GameServer.cs | 304 ++++------- OpenPolytopia.Server/GameSession.cs | 27 +- OpenPolytopia.Server/LobbyManager.cs | 19 +- OpenPolytopia.Server/README.md | 45 ++ OpenPolytopia.Server/ServerStore.cs | 13 +- OpenPolytopia.UnitTest/GameManagerTest.cs | 7 +- .../GameServerIntegrationTest.cs | 514 ++++++++++++++++++ .../GameServerTestHarness.cs | 372 +++++++++++++ .../ServerStoreTransactionTest.cs | 25 + OpenPolytopia/src/Game.cs | 90 ++- OpenPolytopia/src/Game.tscn | 27 +- OpenPolytopia/src/Lobby.cs | 347 ++++++++++-- OpenPolytopia/src/NetworkNode.cs | 423 ++++++++++++-- 23 files changed, 2237 insertions(+), 335 deletions(-) create mode 100644 OpenPolytopia.Common/Network/Packets/AccountPackets.cs create mode 100644 OpenPolytopia.Common/Network/Packets/TimerPackets.cs create mode 100644 OpenPolytopia.Server/GameServer.Accounts.cs create mode 100644 OpenPolytopia.Server/GameServer.Persistence.cs create mode 100644 OpenPolytopia.Server/README.md create mode 100644 OpenPolytopia.UnitTest/GameServerIntegrationTest.cs create mode 100644 OpenPolytopia.UnitTest/GameServerTestHarness.cs create mode 100644 OpenPolytopia.UnitTest/ServerStoreTransactionTest.cs diff --git a/.gitignore b/.gitignore index bd5ad012..9afac658 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/LobbyData.cs b/OpenPolytopia.Common/LobbyData.cs index ee703411..7a3ec23e 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/NetworkConstants.cs b/OpenPolytopia.Common/Network/NetworkConstants.cs index 367415cd..5b230194 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 f1f4d34a..b3bebe59 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 00000000..d118e359 --- /dev/null +++ b/OpenPolytopia.Common/Network/Packets/AccountPackets.cs @@ -0,0 +1,69 @@ +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 { + [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/LobbyPackets.cs b/OpenPolytopia.Common/Network/Packets/LobbyPackets.cs index 9c409ba8..523c3b57 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 00000000..4e90be88 --- /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.Server/GameManager.cs b/OpenPolytopia.Server/GameManager.cs index d47467bb..919ef010 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 00000000..8cc95458 --- /dev/null +++ b/OpenPolytopia.Server/GameServer.Accounts.cs @@ -0,0 +1,87 @@ +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 Queue _loginAttempts = new(); + + private uint AccountId(NetworkConnection connection) => _authenticated.GetValueOrDefault(connection.Id); + + private void RegisterAccountHandlers() { + _dispatcher.Register((c, p) => Authenticate(c, () => _store.Register(p.Username, p.Password))); + _dispatcher.Register((c, p) => Authenticate(c, () => _store.Login(p.Username, p.Password))); + _dispatcher.Register((c, p) => Authenticate(c, () => + _store.Resume(p.Token) is { } account ? new AuthResult(account, p.Token) : null)); + _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)] })); + _dispatcher.Register((c, p) => { + var session = _gameManager[p.GameId]; + SendTo(c.Id, session != null && session.Join(AccountId(c), c.Id) ? session.BuildState() : + new GameStatePacket { GameId = p.GameId, Result = session == null ? GameActionResult.GameNotFound : GameActionResult.NotInGame }); + }); + _dispatcher.Register((c, p) => { + var session = _gameManager[p.GameId]; + 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 = _gameManager[p.GameId]; + 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) { + // Bound expensive password work globally, including across reconnects. + var now = DateTimeOffset.UtcNow; + while (_loginAttempts.TryPeek(out var time) && now - time >= TimeSpan.FromMinutes(1)) _loginAttempts.Dequeue(); + if (_authenticated.ContainsKey(connection.Id) || _loginAttempts.Count >= 120) { + SendTo(connection.Id, new AuthenticationPacket()); + return; + } + _loginAttempts.Enqueue(now); + AuthResult? result; + try { result = authenticate(); } + catch (ArgumentException) { result = null; } + if (result == null) { + SendTo(connection.Id, new AuthenticationPacket()); + 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 00000000..08af7437 --- /dev/null +++ b/OpenPolytopia.Server/GameServer.Persistence.cs @@ -0,0 +1,86 @@ +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 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 string CaptureState() => JsonSerializer.Serialize(new SavedServer(1, _lobbyManager.LastId, + [.. _lobbyManager.Lobbies], [.. _gameManager.Sessions.Select(session => new SavedSession(session.Id, + session.Game.ToSnapshot(), new(session.Accounts), new(session.Names)))]), _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"); + _lobbyManager.Restore(state.NextLobbyId, state.Lobbies); + _gameManager.Clear(); + foreach (var saved in state.Games) { + 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); + _gameManager.Restore(session); + } + } + + // A successful reply must never describe state which has not reached SQLite. + private async Task WithStateAsync(Func action) { + await _stateLock.WaitAsync(); + string? before = null; + var committed = false; + var names = new Dictionary(_playerNames); + var attachments = _gameManager.Sessions.ToDictionary(s => s.Id, s => s.Connections.ToArray()); + try { + before = CaptureState(); + await action(); + var after = CaptureState(); + if (after != before || _pendingRenames.Count != 0) _store.SaveState(after, _pendingRenames); + committed = true; + foreach (var send in _outbox) send(); + } + catch { + if (committed) throw; + RestoreState(before); + _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 191419f5..d870f47e 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,7 +26,8 @@ public class GameServer(int port, string? bindAddress = null) : IDisposable { /// private const int MAX_LOBBIES = 100; - private readonly ServerConnection _server = new(port, bindAddress); + private readonly System.Security.Cryptography.X509Certificates.X509Certificate2? _certificate = ServerTls.LoadAndValidate(bindAddress); + private ServerConnection _server = null!; private readonly LobbyManager _lobbyManager = new(); private readonly GameManager _gameManager = new(); private readonly Dictionary _playerNames = new(); @@ -45,16 +46,24 @@ public class GameServer(int port, string? bindAddress = null) : IDisposable { /// 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; + await _stateLock.WaitAsync(); + _stateLock.Release(); + } } /// @@ -62,16 +71,17 @@ public async Task RunAsync() { /// public void Stop() { _cts.Cancel(); - _server.Stop(); + _server?.Stop(); } private async Task ManagePacketAsync(NetworkConnection connection, IPacket packet) { try { - await DispatchPacketAsync(connection, packet); + await WithStateAsync(() => DispatchPacketAsync(connection, packet)); } catch (Exception e) { // log the error without taking the server down Console.Error.WriteLine($"Error while managing {packet.GetType().Name} from client {connection.Id}: {e}"); + connection.Close(); } } @@ -87,6 +97,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 +118,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 +155,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 +203,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 +231,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; + }); /// /// 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 +299,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); + }); } - finally { - _stateLock.Release(); + catch (Exception e) when (e is not OperationCanceledException) { + Console.Error.WriteLine($"Lobby update failed; state restored: {e}"); } } } @@ -388,22 +323,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 }); } /// @@ -443,161 +381,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 +521,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 +541,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 bd9567a7..35dc1d36 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 /// diff --git a/OpenPolytopia.Server/LobbyManager.cs b/OpenPolytopia.Server/LobbyManager.cs index 6d68b676..842fa60a 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/README.md b/OpenPolytopia.Server/README.md new file mode 100644 index 00000000..bec80d06 --- /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.cs b/OpenPolytopia.Server/ServerStore.cs index 8ee89147..1a7843e1 100644 --- a/OpenPolytopia.Server/ServerStore.cs +++ b/OpenPolytopia.Server/ServerStore.cs @@ -322,7 +322,7 @@ public bool Logout(string token) { /// /// the JSON snapshot of the server state /// if is - public void SaveState(string state) { + public void SaveState(string state, IReadOnlyDictionary? renamedAccounts = null) { ArgumentNullException.ThrowIfNull(state); lock (_lock) { @@ -336,6 +336,17 @@ INSERT INTO server_state (id, state) VALUES (0, $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"); + } + } transaction.Commit(); } } diff --git a/OpenPolytopia.UnitTest/GameManagerTest.cs b/OpenPolytopia.UnitTest/GameManagerTest.cs index 5c20a115..b5fba0ae 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/GameServerIntegrationTest.cs b/OpenPolytopia.UnitTest/GameServerIntegrationTest.cs new file mode 100644 index 00000000..97e6e534 --- /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 00000000..a65d9386 --- /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/ServerStoreTransactionTest.cs b/OpenPolytopia.UnitTest/ServerStoreTransactionTest.cs new file mode 100644 index 00000000..e7643943 --- /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/src/Game.cs b/OpenPolytopia/src/Game.cs index c6116204..8523764f 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 +261,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)); } /// @@ -206,7 +321,7 @@ public override void _PhysicsProcess(double delta) { if (connection == null) { // surface a connection attempt that failed before being established if (Interlocked.Exchange(ref _disconnectedFlag, 0) == 1) { - _pendingPackets.Clear(); + _pendingAuth = null; OnDisconnected?.Invoke(); } @@ -229,16 +344,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 +373,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 +391,66 @@ 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) { + _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) { + _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(); + } + + /// + /// 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 +461,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,6 +584,12 @@ 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; @@ -390,11 +598,17 @@ private static void Send(IPacket packet) { 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 +640,87 @@ 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) { + 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) { + ClearSession(); + } + + _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 +745,91 @@ private void ManageLobbyDeleted(LobbyDeletedPacket packet) { OnLobbiesChanged?.Invoke(); } } + + /// + /// Forgets everything tied to a connection, keeping the session token to resume with + /// + private static void ResetSessionState() { + _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}"); + } + } } From 9692d30568e55c6fa076f5297e3dbd7d5e6cd89d Mon Sep 17 00:00:00 2001 From: Enn3Developer Date: Sat, 5 Sep 2026 10:59:37 +0200 Subject: [PATCH 09/17] fix: distinguish account identity from game seat ids --- OpenPolytopia.Common/Network/Packets/GameData.cs | 5 +++++ OpenPolytopia.Server/GameSession.cs | 1 + OpenPolytopia/src/Lobby.cs | 6 ++++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/OpenPolytopia.Common/Network/Packets/GameData.cs b/OpenPolytopia.Common/Network/Packets/GameData.cs index f11b6b1f..56be3db0 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.Server/GameSession.cs b/OpenPolytopia.Server/GameSession.cs index 35dc1d36..696941c9 100644 --- a/OpenPolytopia.Server/GameSession.cs +++ b/OpenPolytopia.Server/GameSession.cs @@ -183,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/src/Lobby.cs b/OpenPolytopia/src/Lobby.cs index 20b118f6..3d548021 100644 --- a/OpenPolytopia/src/Lobby.cs +++ b/OpenPolytopia/src/Lobby.cs @@ -44,6 +44,7 @@ public partial class Lobby : Control { // the countdown of the open game, measured against the local monotonic clock so a wrong system time can't skew it private bool _hasClock; private uint _clockPlayerId; + private uint _myGamePlayerId; private long _clockRemainingMs; private ulong _clockReceivedAt; @@ -461,7 +462,8 @@ private void OnGameState(GameStatePacket packet) { _openGameId = packet.GameId; // TODO: replace this with the game scene once the gameplay is implemented - var me = packet.Players.FirstOrDefault(player => player.PlayerId == _network.PlayerId); + var me = packet.Players.FirstOrDefault(player => player.AccountId == _network.PlayerId); + _myGamePlayerId = me?.PlayerId ?? 0; _gameStatusLabel.Text = packet.Over ? $"Game {packet.GameId}: over, player {packet.Winner} won" : $"Game {packet.GameId}: turn {packet.Turn}, player {packet.CurrentPlayer} plays" + @@ -481,7 +483,7 @@ public override void _Process(double delta) { } var left = _clockRemainingMs - (long)(Time.GetTicksMsec() - _clockReceivedAt); - var who = _clockPlayerId == _network.PlayerId ? "You have" : $"Player {_clockPlayerId} has"; + var who = _clockPlayerId == _myGamePlayerId ? "You have" : $"Player {_clockPlayerId} has"; _clockLabel.Text = left <= 0 ? "Turn overdue" : $"{who} {TimeSpan.FromMilliseconds(left):hh\\:mm\\:ss} left"; } From aea16297977b1ba239e279a37cb4cb9e5369c43b Mon Sep 17 00:00:00 2001 From: Enn3Developer Date: Sat, 5 Sep 2026 11:06:23 +0200 Subject: [PATCH 10/17] test: verify account ids survive game state serialization --- OpenPolytopia.UnitTest/GameSessionTest.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/OpenPolytopia.UnitTest/GameSessionTest.cs b/OpenPolytopia.UnitTest/GameSessionTest.cs index 60eebe9e..0206c574 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); From 55fa0b465c274976b1d1002d1d220e6b81ff34f9 Mon Sep 17 00:00:00 2001 From: Enn3Developer Date: Sat, 5 Sep 2026 11:26:15 +0200 Subject: [PATCH 11/17] fix: harden authentication and persistent state recovery --- .../Network/NetworkConnection.cs | 4 ++ .../Network/Packets/AccountPackets.cs | 2 + OpenPolytopia.Server/GameServer.Accounts.cs | 67 ++++++++++++++----- .../GameServer.Persistence.cs | 32 ++++++--- OpenPolytopia.Server/GameServer.cs | 26 +++++-- OpenPolytopia.Server/ServerStore.cs | 28 ++++---- .../AuthenticationFlowTest.cs | 53 +++++++++++++++ .../ServerPersistenceFailureTest.cs | 38 +++++++++++ OpenPolytopia/src/Game.cs | 2 +- OpenPolytopia/src/Lobby.cs | 5 ++ OpenPolytopia/src/NetworkNode.cs | 38 ++++++++++- 11 files changed, 250 insertions(+), 45 deletions(-) create mode 100644 OpenPolytopia.UnitTest/AuthenticationFlowTest.cs create mode 100644 OpenPolytopia.UnitTest/ServerPersistenceFailureTest.cs diff --git a/OpenPolytopia.Common/Network/NetworkConnection.cs b/OpenPolytopia.Common/Network/NetworkConnection.cs index 58ba5d1b..b65fb12a 100644 --- a/OpenPolytopia.Common/Network/NetworkConnection.cs +++ b/OpenPolytopia.Common/Network/NetworkConnection.cs @@ -19,6 +19,10 @@ namespace OpenPolytopia.Common.Network; /// 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; diff --git a/OpenPolytopia.Common/Network/Packets/AccountPackets.cs b/OpenPolytopia.Common/Network/Packets/AccountPackets.cs index d118e359..c88bd0c0 100644 --- a/OpenPolytopia.Common/Network/Packets/AccountPackets.cs +++ b/OpenPolytopia.Common/Network/Packets/AccountPackets.cs @@ -27,6 +27,8 @@ 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 = ""; diff --git a/OpenPolytopia.Server/GameServer.Accounts.cs b/OpenPolytopia.Server/GameServer.Accounts.cs index 8cc95458..cc7f41f9 100644 --- a/OpenPolytopia.Server/GameServer.Accounts.cs +++ b/OpenPolytopia.Server/GameServer.Accounts.cs @@ -7,15 +7,52 @@ namespace OpenPolytopia.Server; public partial class GameServer { private readonly Dictionary _authenticated = new(); private readonly Dictionary _sessionTokens = new(); - private readonly Queue _loginAttempts = 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, p) => Authenticate(c, () => _store.Register(p.Username, p.Password))); - _dispatcher.Register((c, p) => Authenticate(c, () => _store.Login(p.Username, p.Password))); - _dispatcher.Register((c, p) => Authenticate(c, () => - _store.Resume(p.Token) is { } account ? new AuthResult(account, p.Token) : null)); _dispatcher.Register((c, _) => { if (_sessionTokens.Remove(c.Id, out var token)) _store.Logout(token); _authenticated.Remove(c.Id); @@ -27,8 +64,11 @@ private void RegisterAccountHandlers() { new MyGamesPacket { GameIds = [.. _gameManager.FindByAccount(AccountId(c)).Select(s => s.Id)] })); _dispatcher.Register((c, p) => { var session = _gameManager[p.GameId]; - SendTo(c.Id, session != null && session.Join(AccountId(c), c.Id) ? session.BuildState() : - new GameStatePacket { GameId = p.GameId, Result = session == null ? GameActionResult.GameNotFound : GameActionResult.NotInGame }); + 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 = _gameManager[p.GameId]; @@ -52,20 +92,17 @@ private void RegisterAccountHandlers() { }); } - private void Authenticate(NetworkConnection connection, Func authenticate) { - // Bound expensive password work globally, including across reconnects. - var now = DateTimeOffset.UtcNow; - while (_loginAttempts.TryPeek(out var time) && now - time >= TimeSpan.FromMinutes(1)) _loginAttempts.Dequeue(); - if (_authenticated.ContainsKey(connection.Id) || _loginAttempts.Count >= 120) { - SendTo(connection.Id, new AuthenticationPacket()); + 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; } - _loginAttempts.Enqueue(now); AuthResult? result; try { result = authenticate(); } catch (ArgumentException) { result = null; } if (result == null) { - SendTo(connection.Id, new AuthenticationPacket()); + SendTo(connection.Id, new AuthenticationPacket { Retryable = retryable }); return; } diff --git a/OpenPolytopia.Server/GameServer.Persistence.cs b/OpenPolytopia.Server/GameServer.Persistence.cs index 08af7437..95798092 100644 --- a/OpenPolytopia.Server/GameServer.Persistence.cs +++ b/OpenPolytopia.Server/GameServer.Persistence.cs @@ -11,6 +11,7 @@ public partial class GameServer { 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, @@ -26,36 +27,51 @@ 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"); - _lobbyManager.Restore(state.NextLobbyId, state.Lobbies); - _gameManager.Clear(); + var nextLobbies = new LobbyManager(); + nextLobbies.Restore(state.NextLobbyId, state.Lobbies); + var nextGames = new GameManager(); foreach (var saved in state.Games) { 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); - _gameManager.Restore(session); + nextGames.Restore(session); } + _lobbyManager = nextLobbies; + _gameManager = nextGames; + _savedState = json; } // A successful reply must never describe state which has not reached SQLite. - private async Task WithStateAsync(Func action) { + 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 attachments = _gameManager.Sessions.ToDictionary(s => s.Id, s => s.Connections.ToArray()); + 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 = CaptureState(); + before = _savedState ??= CaptureState(); + var changes = stateMayChange || _lobbyManager.Lobbies.Any(l => l.Starting); await action(); - var after = CaptureState(); + var after = changes ? CaptureState() : before; if (after != before || _pendingRenames.Count != 0) _store.SaveState(after, _pendingRenames); + _savedState = after; committed = true; foreach (var send in _outbox) send(); } catch { if (committed) throw; - RestoreState(before); + 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) { diff --git a/OpenPolytopia.Server/GameServer.cs b/OpenPolytopia.Server/GameServer.cs index d870f47e..b3046070 100644 --- a/OpenPolytopia.Server/GameServer.cs +++ b/OpenPolytopia.Server/GameServer.cs @@ -28,8 +28,8 @@ public partial class GameServer(int port, string? bindAddress = null, string? da private readonly System.Security.Cryptography.X509Certificates.X509Certificate2? _certificate = ServerTls.LoadAndValidate(bindAddress); private ServerConnection _server = null!; - private readonly LobbyManager _lobbyManager = new(); - private readonly GameManager _gameManager = new(); + 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 @@ -41,6 +41,7 @@ public partial class GameServer(int port, string? bindAddress = null, string? da private readonly PacketDispatcher _dispatcher = new(); private readonly CancellationTokenSource _cts = new(); + private int _activeRequests; /// /// Runs the server until gets called @@ -61,6 +62,7 @@ public async Task RunAsync() { finally { _cts.Cancel(); await lobbyLoop; + while (Volatile.Read(ref _activeRequests) != 0) await Task.Delay(10); await _stateLock.WaitAsync(); _stateLock.Release(); } @@ -75,14 +77,28 @@ public void Stop() { } private async Task ManagePacketAsync(NetworkConnection connection, IPacket packet) { + if (_cts.IsCancellationRequested) return; + Interlocked.Increment(ref _activeRequests); try { - await WithStateAsync(() => 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) { @@ -281,7 +297,7 @@ private Task ClientDisconnectedAsync(NetworkConnection connection) => _cts.IsCan _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 @@ -302,7 +318,7 @@ private async Task StartLobbiesLoopAsync(CancellationToken ct) { try { await WithStateAsync(async () => { foreach (var lobby in _lobbyManager.TakeStartingLobbies()) await StartLobbyAsync(lobby); - }); + }, false); } catch (Exception e) when (e is not OperationCanceledException) { Console.Error.WriteLine($"Lobby update failed; state restored: {e}"); diff --git a/OpenPolytopia.Server/ServerStore.cs b/OpenPolytopia.Server/ServerStore.cs index 1a7843e1..cf2e3aa5 100644 --- a/OpenPolytopia.Server/ServerStore.cs +++ b/OpenPolytopia.Server/ServerStore.cs @@ -208,23 +208,25 @@ INSERT INTO accounts (username, display_name, salt, hash, iterations) } 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 record = FindAccountByUsername(transaction, normalized); - var salt = record?.Salt ?? _dummySalt; - var iterations = record?.Iterations ?? PBKDF2_ITERATIONS; - var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, HASH_SIZE); - - if (record is null || !CryptographicOperations.FixedTimeEquals(hash, record.Hash)) { - return null; - } - - var token = CreateSession(transaction, record.Account.Id); + 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(record.Account, token); + return new AuthResult(current.Account, token); } } diff --git a/OpenPolytopia.UnitTest/AuthenticationFlowTest.cs b/OpenPolytopia.UnitTest/AuthenticationFlowTest.cs new file mode 100644 index 00000000..d7323dd0 --- /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/ServerPersistenceFailureTest.cs b/OpenPolytopia.UnitTest/ServerPersistenceFailureTest.cs new file mode 100644 index 00000000..2e5e09c4 --- /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/src/Game.cs b/OpenPolytopia/src/Game.cs index 8523764f..27e27f79 100644 --- a/OpenPolytopia/src/Game.cs +++ b/OpenPolytopia/src/Game.cs @@ -114,7 +114,7 @@ private bool ValidateCredentials(out string username, out string password) { private void OnAuthenticated(bool ok) { if (!ok) { // a refused resume lands here too: the player logs in again by hand - _statusLabel.Text = "The server refused these credentials"; + _statusLabel.Text = _network.AuthenticationRetryable ? "Server busy. Please try again shortly." : "The server refused these credentials"; return; } diff --git a/OpenPolytopia/src/Lobby.cs b/OpenPolytopia/src/Lobby.cs index 3d548021..db7fef1f 100644 --- a/OpenPolytopia/src/Lobby.cs +++ b/OpenPolytopia/src/Lobby.cs @@ -39,6 +39,7 @@ public partial class Lobby : Control { private NetworkNode _network = null!; private ulong _openGameId; + private ulong _requestedGameId; private ulong _resignGameId; // the countdown of the open game, measured against the local monotonic clock so a wrong system time can't skew it @@ -206,6 +207,7 @@ private void OnMyGamesChanged() { private void OnAuthenticated(bool ok) { if (!ok) { + if (!_network.AuthenticationRetryable) GetTree().ChangeSceneToFile("res://src/Game.tscn"); return; } @@ -367,6 +369,7 @@ private void OnOpenPressed() { return; } + _requestedGameId = gameId; _gameStatusLabel.Text = $"Opening game {gameId}..."; _network.OpenGame(gameId); } @@ -449,6 +452,8 @@ private void OnGameStarted(GameStartedPacket packet) { } private void OnGameState(GameStatePacket packet) { + if (packet.GameId != _openGameId && packet.GameId != _requestedGameId) return; + if (packet.GameId == _requestedGameId) _requestedGameId = 0; if (packet.Result != GameActionResult.Ok) { _gameStatusLabel.Text = $"Game {packet.GameId}: {packet.Result}"; return; diff --git a/OpenPolytopia/src/NetworkNode.cs b/OpenPolytopia/src/NetworkNode.cs index bc6d6e87..76173f15 100644 --- a/OpenPolytopia/src/NetworkNode.cs +++ b/OpenPolytopia/src/NetworkNode.cs @@ -76,6 +76,13 @@ public partial class NetworkNode : Node { // account state; it survives a reconnection so the session can be resumed automatically private static bool _authenticated; + private static bool _loggingOut; + private static bool _authInFlight; + private static ulong _resumeAt = ulong.MaxValue; + private static bool _retryable; + + /// Whether the last authentication response was a temporary refusal. + public bool AuthenticationRetryable => _retryable; private static uint _accountId; private static string _accountName = ""; private static string? _sessionToken; @@ -317,6 +324,12 @@ 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 @@ -420,6 +433,9 @@ public static bool IsValidPassword(string password) => /// 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 }); } @@ -430,6 +446,9 @@ public void Register(string username, string password) { /// 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 }); } @@ -442,6 +461,14 @@ public void Logout() { // 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; } /// @@ -668,14 +695,16 @@ private void ManageSetNameResponse(SetNameResponsePacket packet) { } 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) { - ClearSession(); - } + if (resuming && !packet.Retryable) ClearSession(); + if (resuming && packet.Retryable) _resumeAt = Time.GetTicksMsec() + 3000; _authenticated = false; _accountId = 0; @@ -750,6 +779,9 @@ private void ManageLobbyDeleted(LobbyDeletedPacket packet) { /// 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; From e17a49ba0035f3411b6028fe6133e077cf7bef13 Mon Sep 17 00:00:00 2001 From: Enn3Developer Date: Sat, 5 Sep 2026 11:29:36 +0200 Subject: [PATCH 12/17] fix: allow login retry after a failed connection --- OpenPolytopia/src/NetworkNode.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/OpenPolytopia/src/NetworkNode.cs b/OpenPolytopia/src/NetworkNode.cs index 76173f15..1555b17f 100644 --- a/OpenPolytopia/src/NetworkNode.cs +++ b/OpenPolytopia/src/NetworkNode.cs @@ -334,7 +334,7 @@ public override void _PhysicsProcess(double delta) { if (connection == null) { // surface a connection attempt that failed before being established if (Interlocked.Exchange(ref _disconnectedFlag, 0) == 1) { - _pendingAuth = null; + ResetSessionState(); OnDisconnected?.Invoke(); } @@ -622,6 +622,7 @@ private static void Send(IPacket packet) { // without a connection there is no telling when the packet could go out, drop it if (connection == null) { + if (IsAccountPacket(packet)) _authInFlight = false; return; } From 8bdfa53bf2a2f1b02526a2343805818a4c1b4885 Mon Sep 17 00:00:00 2001 From: Enn3Developer Date: Sat, 5 Sep 2026 16:31:09 +0200 Subject: [PATCH 13/17] fix: enforce tls in server startup --- OpenPolytopia.Server/GameServer.cs | 9 ++++++--- OpenPolytopia.UnitTest/TransportSecurityTest.cs | 11 +++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/OpenPolytopia.Server/GameServer.cs b/OpenPolytopia.Server/GameServer.cs index 191419f5..2cc2481b 100644 --- a/OpenPolytopia.Server/GameServer.cs +++ b/OpenPolytopia.Server/GameServer.cs @@ -26,7 +26,8 @@ public class GameServer(int port, string? bindAddress = null) : IDisposable { /// private const int MAX_LOBBIES = 100; - private readonly ServerConnection _server = new(port, bindAddress); + private readonly System.Security.Cryptography.X509Certificates.X509Certificate2? _certificate = ServerTls.LoadAndValidate(bindAddress); + private ServerConnection _server = null!; private readonly LobbyManager _lobbyManager = new(); private readonly GameManager _gameManager = new(); private readonly Dictionary _playerNames = new(); @@ -45,6 +46,7 @@ public class GameServer(int port, string? bindAddress = null) : IDisposable { /// Runs the server until gets called /// public async Task RunAsync() { + _server = new ServerConnection(port, bindAddress, _certificate); RegisterHandlers(); _server.OnPacketReceived += ManagePacketAsync; @@ -62,7 +64,7 @@ public async Task RunAsync() { /// public void Stop() { _cts.Cancel(); - _server.Stop(); + _server?.Stop(); } private async Task ManagePacketAsync(NetworkConnection connection, IPacket packet) { @@ -646,7 +648,8 @@ private async Task ManageEndTurnAsync(NetworkConnection connection, EndTurnPacke public void Dispose() { Stop(); _cts.Dispose(); - _server.Dispose(); + _server?.Dispose(); + _certificate?.Dispose(); _stateLock.Dispose(); GC.SuppressFinalize(this); } diff --git a/OpenPolytopia.UnitTest/TransportSecurityTest.cs b/OpenPolytopia.UnitTest/TransportSecurityTest.cs index 7fc2e415..6ca6a37b 100644 --- a/OpenPolytopia.UnitTest/TransportSecurityTest.cs +++ b/OpenPolytopia.UnitTest/TransportSecurityTest.cs @@ -27,6 +27,17 @@ public void TestRepeatedClientDisposalIsSafe() { 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"); + } + finally { Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PATH_ENV, previous); } + } + private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(10); private const string CERTIFICATE_PASSWORD = "test-password"; From 9105a8cac8d3aa0f7458f847f1f50a6eaf550e0c Mon Sep 17 00:00:00 2001 From: Enn3Developer Date: Sat, 5 Sep 2026 16:32:24 +0200 Subject: [PATCH 14/17] fix: archive completed games outside live server snapshots --- OpenPolytopia.Server/GameServer.Accounts.cs | 8 +- .../GameServer.Persistence.cs | 41 ++++++--- OpenPolytopia.Server/GameServer.cs | 3 +- OpenPolytopia.Server/ServerStore.Archive.cs | 56 +++++++++++++ OpenPolytopia.Server/ServerStore.cs | 69 ++++++++------- .../CompletedGameArchiveTest.cs | 84 +++++++++++++++++++ 6 files changed, 218 insertions(+), 43 deletions(-) create mode 100644 OpenPolytopia.Server/ServerStore.Archive.cs create mode 100644 OpenPolytopia.UnitTest/CompletedGameArchiveTest.cs diff --git a/OpenPolytopia.Server/GameServer.Accounts.cs b/OpenPolytopia.Server/GameServer.Accounts.cs index cc7f41f9..d7cca95b 100644 --- a/OpenPolytopia.Server/GameServer.Accounts.cs +++ b/OpenPolytopia.Server/GameServer.Accounts.cs @@ -61,9 +61,9 @@ private void RegisterAccountHandlers() { SendTo(c.Id, new AuthenticationPacket()); }); _dispatcher.Register((c, _) => SendTo(c.Id, - new MyGamesPacket { GameIds = [.. _gameManager.FindByAccount(AccountId(c)).Select(s => s.Id)] })); + new MyGamesPacket { GameIds = [.. _gameManager.FindByAccount(AccountId(c)).Select(s => s.Id).Concat(_store.CompletedGameIds(AccountId(c))).Distinct()] })); _dispatcher.Register((c, p) => { - var session = _gameManager[p.GameId]; + var session = FindSession(p.GameId, AccountId(c)); if (session != null && session.Join(AccountId(c), c.Id)) { SendTo(c.Id, session.BuildState()); } @@ -71,14 +71,14 @@ private void RegisterAccountHandlers() { Result = session == null ? GameActionResult.GameNotFound : GameActionResult.NotInGame }); }); _dispatcher.Register((c, p) => { - var session = _gameManager[p.GameId]; + 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 = _gameManager[p.GameId]; + 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; diff --git a/OpenPolytopia.Server/GameServer.Persistence.cs b/OpenPolytopia.Server/GameServer.Persistence.cs index 95798092..500f8dec 100644 --- a/OpenPolytopia.Server/GameServer.Persistence.cs +++ b/OpenPolytopia.Server/GameServer.Persistence.cs @@ -19,9 +19,11 @@ private sealed record SavedSession(ulong Id, GameSnapshot Game, Dictionary Lobbies, List Games); - private string CaptureState() => JsonSerializer.Serialize(new SavedServer(1, _lobbyManager.LastId, - [.. _lobbyManager.Lobbies], [.. _gameManager.Sessions.Select(session => new SavedSession(session.Id, - session.Game.ToSnapshot(), new(session.Accounts), new(session.Names)))]), _json); + 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; @@ -31,11 +33,7 @@ private void RestoreState(string? json) { nextLobbies.Restore(state.NextLobbyId, state.Lobbies); var nextGames = new GameManager(); foreach (var saved in state.Games) { - 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); + var session = RestoreSession(saved); nextGames.Restore(session); } _lobbyManager = nextLobbies; @@ -43,6 +41,25 @@ private void RestoreState(string? json) { _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; @@ -58,10 +75,14 @@ private async Task WithStateAsync(Func action, bool stateMayChange = true) before = _savedState ??= CaptureState(); var changes = stateMayChange || _lobbyManager.Lobbies.Any(l => l.Starting); await action(); - var after = changes ? CaptureState() : before; - if (after != before || _pendingRenames.Count != 0) _store.SaveState(after, _pendingRenames); + 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 { diff --git a/OpenPolytopia.Server/GameServer.cs b/OpenPolytopia.Server/GameServer.cs index b3046070..f2e04a3c 100644 --- a/OpenPolytopia.Server/GameServer.cs +++ b/OpenPolytopia.Server/GameServer.cs @@ -379,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; diff --git a/OpenPolytopia.Server/ServerStore.Archive.cs b/OpenPolytopia.Server/ServerStore.Archive.cs new file mode 100644 index 00000000..523c838c --- /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 index cf2e3aa5..670df7a2 100644 --- a/OpenPolytopia.Server/ServerStore.cs +++ b/OpenPolytopia.Server/ServerStore.cs @@ -49,14 +49,14 @@ public sealed record AuthResult(Account Account, string Token); /// SHA-256 hashes of 256 bits of cryptographically random data; neither can be recovered from the database /// /// -public sealed class ServerStore : IDisposable { +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 = 1; + public const int SCHEMA_VERSION = 2; /// /// Minimum length of a username @@ -323,8 +323,11 @@ public bool Logout(string token) { /// 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) { + public void SaveState(string state, IReadOnlyDictionary? renamedAccounts = null, + IReadOnlyList? completedGames = null) { ArgumentNullException.ThrowIfNull(state); lock (_lock) { @@ -349,6 +352,7 @@ INSERT INTO server_state (id, state) VALUES (0, $state) if (rename.ExecuteNonQuery() != 1) throw new ArgumentException("Unknown account"); } } + SaveCompletedGames(transaction, completedGames); transaction.Commit(); } } @@ -388,31 +392,40 @@ private void Migrate() { using var schema = _connection.CreateCommand(); schema.Transaction = transaction; - 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 = {SCHEMA_VERSION}; + 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(); diff --git a/OpenPolytopia.UnitTest/CompletedGameArchiveTest.cs b/OpenPolytopia.UnitTest/CompletedGameArchiveTest.cs new file mode 100644 index 00000000..88c59b73 --- /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(); + } +} From b47a16a0bc0ff55aa97cfc3abb0dede5c6f043a8 Mon Sep 17 00:00:00 2001 From: Enn3Developer Date: Sat, 5 Sep 2026 16:39:01 +0200 Subject: [PATCH 15/17] fix: allow enough turns for pr summaries --- .github/workflows/claude-pr-summary.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-pr-summary.yml b/.github/workflows/claude-pr-summary.yml index 8b542605..8ab05d6b 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" From 8056fa4fa98825c5247c8aeae4069f71914eb349 Mon Sep 17 00:00:00 2001 From: Enn3Developer Date: Sat, 5 Sep 2026 16:41:26 +0200 Subject: [PATCH 16/17] style: clean up schema migration whitespace --- OpenPolytopia.Server/ServerStore.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/OpenPolytopia.Server/ServerStore.cs b/OpenPolytopia.Server/ServerStore.cs index 670df7a2..09e33bac 100644 --- a/OpenPolytopia.Server/ServerStore.cs +++ b/OpenPolytopia.Server/ServerStore.cs @@ -402,21 +402,21 @@ CREATE TABLE accounts ( 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(); From 4b1d65cded6615aea5279b7fce7772acd0d7cbe6 Mon Sep 17 00:00:00 2001 From: Enn3Developer Date: Sun, 6 Sep 2026 17:28:59 +0200 Subject: [PATCH 17/17] fix: time out stalled client tls handshakes --- OpenPolytopia.Common/Network/ClientConnection.cs | 5 ++++- OpenPolytopia.UnitTest/TransportSecurityTest.cs | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/OpenPolytopia.Common/Network/ClientConnection.cs b/OpenPolytopia.Common/Network/ClientConnection.cs index e4f2db9a..4d1587b0 100644 --- a/OpenPolytopia.Common/Network/ClientConnection.cs +++ b/OpenPolytopia.Common/Network/ClientConnection.cs @@ -26,6 +26,7 @@ namespace OpenPolytopia.Common.Network; 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(); @@ -117,10 +118,12 @@ 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 - }, _cts.Token); + }, handshake.Token); } catch { await ssl.DisposeAsync(); diff --git a/OpenPolytopia.UnitTest/TransportSecurityTest.cs b/OpenPolytopia.UnitTest/TransportSecurityTest.cs index 6ca6a37b..a5081f05 100644 --- a/OpenPolytopia.UnitTest/TransportSecurityTest.cs +++ b/OpenPolytopia.UnitTest/TransportSecurityTest.cs @@ -80,6 +80,18 @@ public async Task TestPlaintextLoopbackConnectionDeliversPackets() { 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();