diff --git a/OpenPolytopia.Common/Gameplay/Game.Persistence.cs b/OpenPolytopia.Common/Gameplay/Game.Persistence.cs new file mode 100644 index 0000000..6c60b0b --- /dev/null +++ b/OpenPolytopia.Common/Gameplay/Game.Persistence.cs @@ -0,0 +1,244 @@ +namespace OpenPolytopia.Common.Gameplay; + +using System; +using System.Collections.Generic; +using System.Linq; + +/// +/// Capture and restore of a whole match, for persisting a game between two runs of the server +/// +/// +/// A snapshot only carries what a game mutates; everything else it's built from is content, so +/// asks for it again and a restored game shares nothing with the one it came from +/// +public partial class Game { + /// + /// Rebuilds a game from its parts, without running any of the setup the public constructor does + /// + /// + /// The public constructor builds every from the tribe, which is exactly what a restore + /// can't do: a restored player has the stars, the score and the researched nodes of the snapshot, not the ones a + /// tribe starts with + /// + /// the grid, already filled + /// the city manager, with every city already registered + /// the troop manager, already filled + /// the registered building definitions + /// the settings of the game + /// the players, in turn order, already restored + private Game(Grid grid, CityManager cityManager, TroopManager troopManager, BuildingManager buildingManager, + GameSettings settings, List players) { + Grid = grid; + Cities = cityManager; + Troops = troopManager; + Buildings = buildingManager; + Settings = settings; + Players = players; + _playersById = players.ToDictionary(player => player.Id); + _turnIndexById = players.Select((player, index) => (player.Id, index)) + .ToDictionary(entry => entry.Id, entry => entry.index); + } + + /// + /// Captures everything this game mutates into a snapshot + /// + /// + /// The snapshot owns its arrays, so playing on after taking one never changes what was captured + /// + /// the snapshot, restorable with + public GameSnapshot ToSnapshot() { + var cells = Grid.Size * Grid.Size; + var tiles = new ulong[cells]; + var troops = new uint[cells]; + + for (var index = 0u; index < cells; index++) { + tiles[index] = Grid[index].Raw; + troops[index] = Troops[index].Raw; + } + + return new GameSnapshot { + Version = GameSnapshot.CURRENT_VERSION, + GridSize = Grid.Size, + Tiles = tiles, + Troops = troops, + CityIndexes = [.. Cities.Cities], + Players = [ + .. Players.Select(player => new PlayerSnapshot { + Id = player.Id, + Tribe = player.Tribe, + Stars = player.Stars, + Score = player.Score.ScoreValue, + Alive = player.Alive, + ResearchedTechs = [.. player.TechTree.ResearchedIds()] + }) + ], + MaxTurns = Settings.MaxTurns, + Turn = Turn, + CurrentPlayer = CurrentPlayer, + Started = Started, + Over = Over, + Winner = Winner + }; + } + + /// + /// Rebuilds the game a snapshot was taken from + /// + /// + /// The content arguments have to be the same the original game was built from: a snapshot stores the researched + /// node ids, not the tech tree, and the raw tiles/troops, not the definitions they refer to, so restoring against + /// different content gives a game that isn't the one that was saved + ///
+ /// is taken instead of built because only the caller has the troop definitions to + /// register in it; it must be empty and sized like the grid of the snapshot. The grid and the city manager are + /// built here because both are fully described by the snapshot + ///
+ /// the snapshot to restore + /// an empty troop manager, sized , with the troop definitions registered + /// the registered tribes + /// the registered building definitions + /// the shape of the tech tree the original game was built from + /// the restored game, ready to be played on + /// if any argument, or any member of the snapshot, is null + /// + /// if the snapshot wasn't written by , if its arrays don't have one entry per + /// grid cell, if isn't sized like the grid, if it doesn't have 2 to 16 players, if + /// a player id is out of range or used twice, if a tribe isn't registered, if a researched node isn't in the tech + /// tree of its player, if a city index is outside the grid or if there are more than 255 cities + /// + public static Game Restore(GameSnapshot snapshot, TroopManager troopManager, TribeManager tribeManager, + BuildingManager buildingManager, TechTreeDefinition techTreeDefinition) { + ArgumentNullException.ThrowIfNull(snapshot); + ArgumentNullException.ThrowIfNull(troopManager); + ArgumentNullException.ThrowIfNull(tribeManager); + ArgumentNullException.ThrowIfNull(buildingManager); + ArgumentNullException.ThrowIfNull(techTreeDefinition); + ArgumentNullException.ThrowIfNull(snapshot.Tiles); + ArgumentNullException.ThrowIfNull(snapshot.Troops); + ArgumentNullException.ThrowIfNull(snapshot.CityIndexes); + ArgumentNullException.ThrowIfNull(snapshot.Players); + + if (snapshot.Version != GameSnapshot.CURRENT_VERSION) { + throw new ArgumentException( + $"snapshot version {snapshot.Version} can't be restored; this build writes version {GameSnapshot.CURRENT_VERSION}", + nameof(snapshot)); + } + + if (snapshot.GridSize == 0) { + throw new ArgumentException("a snapshot of a game has a grid", nameof(snapshot)); + } + + var cells = snapshot.GridSize * snapshot.GridSize; + if (snapshot.Tiles.Length != cells) { + throw new ArgumentException( + $"a {snapshot.GridSize}x{snapshot.GridSize} grid has {cells} tiles, the snapshot has {snapshot.Tiles.Length}", + nameof(snapshot)); + } + + if (snapshot.Troops.Length != cells) { + throw new ArgumentException( + $"a {snapshot.GridSize}x{snapshot.GridSize} grid has {cells} cells, the snapshot has {snapshot.Troops.Length} troop slots", + nameof(snapshot)); + } + + if (troopManager.Size != snapshot.GridSize) { + throw new ArgumentException( + $"the troop manager is sized {troopManager.Size}, the snapshot needs {snapshot.GridSize}", nameof(troopManager)); + } + + if (snapshot.Players.Count is < 2 or > 16) { + throw new ArgumentException($"a game needs 2 to 16 players, the snapshot has {snapshot.Players.Count}", + nameof(snapshot)); + } + + var grid = new Grid(snapshot.GridSize); + var cityManager = new CityManager(grid); + + // the cities go in first: registering one writes into its tile, so the raw tiles have to be written after it + foreach (var index in snapshot.CityIndexes) { + if (index >= cells) { + throw new ArgumentException($"city index {index} is outside a {snapshot.GridSize}x{snapshot.GridSize} grid", + nameof(snapshot)); + } + + cityManager.RegisterCity(index); + } + + for (var index = 0u; index < cells; index++) { + grid[index] = new Tile { Raw = snapshot.Tiles[index] }; + troopManager.SetRaw(index, snapshot.Troops[index]); + } + + var players = new List(snapshot.Players.Count); + var seenIds = new HashSet(snapshot.Players.Count); + + foreach (var playerSnapshot in snapshot.Players) { + ArgumentNullException.ThrowIfNull(playerSnapshot, nameof(snapshot)); + ArgumentNullException.ThrowIfNull(playerSnapshot.ResearchedTechs, nameof(snapshot)); + + if (playerSnapshot.Id is < 1 or > 16) { + throw new ArgumentException($"player id {playerSnapshot.Id} is out of range; ids must be between 1 and 16", + nameof(snapshot)); + } + + if (!seenIds.Add(playerSnapshot.Id)) { + throw new ArgumentException($"player id {playerSnapshot.Id} is used by more than one player", nameof(snapshot)); + } + + var tribe = tribeManager[playerSnapshot.Tribe] ?? + throw new ArgumentException($"tribe {playerSnapshot.Tribe} of player {playerSnapshot.Id} isn't registered", + nameof(snapshot)); + + players.Add(RestorePlayer(playerSnapshot, tribe, techTreeDefinition)); + } + + var game = new Game(grid, cityManager, troopManager, buildingManager, + new GameSettings { MaxTurns = snapshot.MaxTurns }, players) { + Turn = snapshot.Turn, + CurrentPlayer = snapshot.CurrentPlayer, + Started = snapshot.Started, + Over = snapshot.Over, + Winner = snapshot.Winner + }; + + // a started, unfinished game hands the turn to somebody, and turn order is looked up by that id + if (game is { Started: true, Over: false } && !game._turnIndexById.ContainsKey(game.CurrentPlayer)) { + throw new ArgumentException($"player {snapshot.CurrentPlayer} holds the turn but isn't in the game", + nameof(snapshot)); + } + + return game; + } + + /// + /// Rebuilds the state of a single player + /// + /// + /// The tech tree is built empty from the definition of the tribe, with the overrides applied exactly like + /// does, and then only what the snapshot says was researched is + /// marked: the starting node of the tribe isn't researched for free, so a snapshot restores the researched set it + /// captured and nothing else + /// + /// the snapshot of the player + /// the tribe of the player + /// the shape of the tech tree, before the overrides of the tribe + /// the restored state + /// if a researched node isn't in the tech tree of this player + private static PlayerState RestorePlayer(PlayerSnapshot snapshot, Tribe tribe, + TechTreeDefinition techTreeDefinition) { + var definition = tribe.TechOverrides is { Count: > 0 } overrides + ? techTreeDefinition.Override(overrides) + : techTreeDefinition; + var techTree = new TechTree(definition); + + foreach (var techId in snapshot.ResearchedTechs) { + if (!techTree.Research(techId)) { + throw new ArgumentException($"node {techId} of player {snapshot.Id} isn't in the tech tree", nameof(snapshot)); + } + } + + var player = new PlayerState(snapshot.Id, snapshot.Tribe, snapshot.Stars, techTree) { Alive = snapshot.Alive }; + player.Score.Restore(snapshot.Score); + return player; + } +} diff --git a/OpenPolytopia.Common/Gameplay/GameSnapshot.cs b/OpenPolytopia.Common/Gameplay/GameSnapshot.cs new file mode 100644 index 0000000..9d4a925 --- /dev/null +++ b/OpenPolytopia.Common/Gameplay/GameSnapshot.cs @@ -0,0 +1,139 @@ +namespace OpenPolytopia.Common.Gameplay; + +using System.Collections.Generic; + +/// +/// A lossless capture of everything a mutates while it's played +/// +/// +/// Only mutable state lives here: the content a game is built from (troop definitions, building definitions, tribes +/// and the shape of the tech tree) isn't captured because it never changes during a match, so +/// takes it back from the caller +///
+/// Every member is a primitive, an array of primitives or a list of , so a row of a +/// SQLite table maps one to one onto it: the two big arrays go in BLOB columns through +/// , the players in a child table keyed by turn order +///
+public sealed class GameSnapshot { + /// + /// The layout version written by + /// + /// + /// Bumped whenever the meaning of a member or of a packed raw value changes, so a snapshot written by an older + /// build is rejected instead of silently restoring a wrong game + /// + public const int CURRENT_VERSION = 1; + + /// + /// The layout version this snapshot was written with, see + /// + public required int Version { get; init; } + + /// + /// The width (and height) of the grid + /// + public required uint GridSize { get; init; } + + /// + /// The packed representation of every tile, indexed like + /// + /// + /// This carries the custom data of the tile too, so the internals of every city (level, population, troops, parks, + /// wall, forge, capital and connected flags) ride along with it + /// + public required ulong[] Tiles { get; init; } + + /// + /// The packed representation of every troop, indexed like + /// + /// + /// A 0 is an empty tile, see + /// + public required uint[] Troops { get; init; } + + /// + /// The grid index of every registered city, ordered by city id + /// + /// + /// The city with id i is at position i - 1, exactly like + /// + public required uint[] CityIndexes { get; init; } + + /// + /// Every player, in turn order + /// + public required IReadOnlyList Players { get; init; } + + /// + /// of the game + /// + public required uint MaxTurns { get; init; } + + /// + /// of the game + /// + public required uint Turn { get; init; } + + /// + /// of the game + /// + public required int CurrentPlayer { get; init; } + + /// + /// of the game + /// + public required bool Started { get; init; } + + /// + /// of the game + /// + public required bool Over { get; init; } + + /// + /// of the game + /// + public required int Winner { get; init; } +} + +/// +/// A lossless capture of a single +/// +/// +/// The position of a player in is their turn order, so it has to be preserved by +/// whoever stores it: a SQLite child table needs an explicit order column, a plain automatically assigned id isn't enough +/// +public sealed class PlayerSnapshot { + /// + /// of the player, 1..16 + /// + public required int Id { get; init; } + + /// + /// of the player + /// + public required TribeType Tribe { get; init; } + + /// + /// of the player + /// + public required int Stars { get; init; } + + /// + /// of the player + /// + public required int Score { get; init; } + + /// + /// of the player + /// + public required bool Alive { get; init; } + + /// + /// The ids of every node this player researched, as returned by + /// + /// + /// The tech tree itself isn't captured: it's rebuilt from the definition and the overrides of the tribe, which are + /// content, and only the researched state is player state + /// + public required IReadOnlyList ResearchedTechs { get; init; } +} diff --git a/OpenPolytopia.Common/Gameplay/SnapshotEncoding.cs b/OpenPolytopia.Common/Gameplay/SnapshotEncoding.cs new file mode 100644 index 0000000..4011f5e --- /dev/null +++ b/OpenPolytopia.Common/Gameplay/SnapshotEncoding.cs @@ -0,0 +1,99 @@ +namespace OpenPolytopia.Common.Gameplay; + +using System; +using System.Buffers.Binary; + +/// +/// Converts the packed arrays of a to and from the BLOB a database column holds +/// +/// +/// The encoding is little endian and has no header: the length of the blob divided by the size of an element is the +/// number of cells, and the caller already knows how many it expects from +///
+/// Little endian is written explicitly instead of relying on the layout of the machine, so a database file written on +/// one architecture restores the same game on another +///
+public static class SnapshotEncoding { + /// + /// Packs into a blob + /// + /// the raw tiles + /// the blob, 8 bytes per tile + public static byte[] PackTiles(ReadOnlySpan tiles) { + var bytes = new byte[tiles.Length * sizeof(ulong)]; + for (var index = 0; index < tiles.Length; index++) { + BinaryPrimitives.WriteUInt64LittleEndian(bytes.AsSpan(index * sizeof(ulong)), tiles[index]); + } + + return bytes; + } + + /// + /// Unpacks a blob written by + /// + /// the blob + /// the raw tiles + /// if the length of the blob isn't a multiple of 8 + public static ulong[] UnpackTiles(ReadOnlySpan bytes) { + if (bytes.Length % sizeof(ulong) != 0) { + throw new ArgumentException( + $"a tile blob is {sizeof(ulong)} bytes per tile, got {bytes.Length} bytes", nameof(bytes)); + } + + var tiles = new ulong[bytes.Length / sizeof(ulong)]; + for (var index = 0; index < tiles.Length; index++) { + tiles[index] = BinaryPrimitives.ReadUInt64LittleEndian(bytes[(index * sizeof(ulong))..]); + } + + return tiles; + } + + /// + /// Packs into a blob + /// + /// the raw troops + /// the blob, 4 bytes per troop + public static byte[] PackTroops(ReadOnlySpan troops) { + var bytes = new byte[troops.Length * sizeof(uint)]; + for (var index = 0; index < troops.Length; index++) { + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(index * sizeof(uint)), troops[index]); + } + + return bytes; + } + + /// + /// Unpacks a blob written by + /// + /// the blob + /// the raw troops + /// if the length of the blob isn't a multiple of 4 + public static uint[] UnpackTroops(ReadOnlySpan bytes) { + if (bytes.Length % sizeof(uint) != 0) { + throw new ArgumentException( + $"a troop blob is {sizeof(uint)} bytes per troop, got {bytes.Length} bytes", nameof(bytes)); + } + + var troops = new uint[bytes.Length / sizeof(uint)]; + for (var index = 0; index < troops.Length; index++) { + troops[index] = BinaryPrimitives.ReadUInt32LittleEndian(bytes[(index * sizeof(uint))..]); + } + + return troops; + } + + /// + /// Packs into a blob + /// + /// the grid index of every city, ordered by city id + /// the blob, 4 bytes per city + public static byte[] PackCityIndexes(ReadOnlySpan cityIndexes) => PackTroops(cityIndexes); + + /// + /// Unpacks a blob written by + /// + /// the blob + /// the grid index of every city, ordered by city id + /// if the length of the blob isn't a multiple of 4 + public static uint[] UnpackCityIndexes(ReadOnlySpan bytes) => UnpackTroops(bytes); +} diff --git a/OpenPolytopia.Common/Score.cs b/OpenPolytopia.Common/Score.cs index 6cd2c40..e2d1273 100644 --- a/OpenPolytopia.Common/Score.cs +++ b/OpenPolytopia.Common/Score.cs @@ -19,4 +19,15 @@ public int ScoreValue { /// the scoring event to add, see [MethodImpl(MethodImplOptions.AggressiveInlining)] public void AddScore(ScoreType type) => ScoreValue += type.ToInt(); + + /// + /// Overwrites the score with the one of a persisted game + /// + /// + /// Only uses this: a score is otherwise only ever moved by + /// , so every point a player has comes from a scoring event + /// + /// the score to restore; clamped to 0 like every other write + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void Restore(int value) => ScoreValue = value; } diff --git a/OpenPolytopia.Server/OpenPolytopia.Server.csproj b/OpenPolytopia.Server/OpenPolytopia.Server.csproj index fb394f1..7f07158 100644 --- a/OpenPolytopia.Server/OpenPolytopia.Server.csproj +++ b/OpenPolytopia.Server/OpenPolytopia.Server.csproj @@ -10,6 +10,11 @@ true + + + + + diff --git a/OpenPolytopia.Server/ServerStore.cs b/OpenPolytopia.Server/ServerStore.cs new file mode 100644 index 0000000..8ee8914 --- /dev/null +++ b/OpenPolytopia.Server/ServerStore.cs @@ -0,0 +1,538 @@ +// cspell:words AUTOINCREMENT +namespace OpenPolytopia.Server; + +using System.Security.Cryptography; +using Microsoft.Data.Sqlite; + +/// +/// A registered account, as stored by +/// +/// +/// Never carries any secret: password material and session tokens never leave the store +/// +public sealed record Account { + /// + /// Stable id of the account, never reused once assigned + /// + public required uint Id { get; init; } + + /// + /// Normalized (lowercase) username, unique across the store + /// + public required string Username { get; init; } + + /// + /// Name to show to other players, the username exactly as it was typed at registration time + /// + public required string DisplayName { get; init; } +} + +/// +/// The result of a successful authentication +/// +/// the authenticated account +/// +/// the raw session token, only ever returned here: the store keeps a hash of it and can't recover it +/// +public sealed record AuthResult(Account Account, string Token); + +/// +/// Persistent server storage: accounts, sessions and the server state snapshot +/// +/// +/// +/// Every API is synchronous and thread-safe; the store serializes all the accesses to the underlying +/// SQLite connection through its own lock +/// +/// +/// Passwords are stored as PBKDF2-HMAC-SHA256 hashes over a per-account random salt, session tokens as +/// SHA-256 hashes of 256 bits of cryptographically random data; neither can be recovered from the database +/// +/// +public sealed 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 0000000..49f84a6 --- /dev/null +++ b/OpenPolytopia.UnitTest/GamePersistenceTest.cs @@ -0,0 +1,482 @@ +namespace OpenPolytopia; + +using System; +using System.Collections.Generic; +using System.Linq; +using Common; +using Common.Gameplay; +using Godot; +using Shouldly; + +/// +/// Roundtrip tests for and +/// +/// +/// Every restore rebuilds the content from scratch, so a restored game never shares a manager, a tech tree or a +/// player state with the game the snapshot came from: anything that survives the roundtrip really was in the snapshot +/// +public class GamePersistenceTest { + /// + /// The content a game is built from, rebuilt fresh for every restore + /// + private readonly record struct Content(TroopManager Troops, TribeManager Tribes, BuildingManager Buildings, + TechTreeDefinition TechTree); + + private static Content NewContent(uint gridSize) { + var troops = new TroopManager(gridSize); + var tribes = new TribeManager(); + var buildings = new BuildingManager(); + + troops.RegisterTroops(EmbeddedResources.LoadTroops().ShouldNotBeNull()); + tribes.RegisterTribes(EmbeddedResources.LoadTribes().ShouldNotBeNull()); + buildings.RegisterBuildings(EmbeddedResources.LoadBuildings().ShouldNotBeNull()); + + return new Content(troops, tribes, buildings, + TechTreeDefinition.FromSerializedData(EmbeddedResources.LoadTechTree().ShouldNotBeNull())); + } + + private static Game Roundtrip(Game game) => Restore(game.ToSnapshot()); + + private static Game Restore(GameSnapshot snapshot) { + var content = NewContent(snapshot.GridSize); + return Game.Restore(snapshot, content.Troops, content.Tribes, content.Buildings, content.TechTree); + } + + /// + /// Asserts two snapshots describe the very same game, member by member + /// + private static void ShouldMatch(GameSnapshot actual, GameSnapshot expected) { + actual.Version.ShouldBe(expected.Version); + actual.GridSize.ShouldBe(expected.GridSize); + actual.Tiles.ShouldBe(expected.Tiles); + actual.Troops.ShouldBe(expected.Troops); + actual.CityIndexes.ShouldBe(expected.CityIndexes); + actual.MaxTurns.ShouldBe(expected.MaxTurns); + actual.Turn.ShouldBe(expected.Turn); + actual.CurrentPlayer.ShouldBe(expected.CurrentPlayer); + actual.Started.ShouldBe(expected.Started); + actual.Over.ShouldBe(expected.Over); + actual.Winner.ShouldBe(expected.Winner); + + actual.Players.Count.ShouldBe(expected.Players.Count); + foreach (var (actualPlayer, expectedPlayer) in actual.Players.Zip(expected.Players)) { + actualPlayer.Id.ShouldBe(expectedPlayer.Id); + actualPlayer.Tribe.ShouldBe(expectedPlayer.Tribe); + actualPlayer.Stars.ShouldBe(expectedPlayer.Stars); + actualPlayer.Score.ShouldBe(expectedPlayer.Score); + actualPlayer.Alive.ShouldBe(expectedPlayer.Alive); + actualPlayer.ResearchedTechs.ShouldBe(expectedPlayer.ResearchedTechs); + } + } + + /// + /// Builds a started game and plays a bit of everything on it: research, building, training, moving and fighting + /// + /// + /// Nothing in the engine is random, so the same sequence on two equal games always lands on the same state; that's + /// what makes the "keep playing after a restore" tests meaningful + /// + private static Game PlayedGame() { + var game = GameTestFixture.NewStartedGame(3); + + // player 1 researches, harvests a fruit into its capital and trains a second troop + game.Players[0].Stars = 40; + game.ResearchTech(1, "farming").ShouldBe(GameActionResult.Ok); + game.Grid.ModifyTile(GameTestFixture.P(0, 0), (ref Tile tile) => tile.Modifier = (int)FieldTileModifier.Fruit); + game.Build(1, GameTestFixture.P(0, 0), BuildingType.Fruit).Result.ShouldBe(GameActionResult.Ok); + game.MoveTroop(1, GameTestFixture.P(1, 1), GameTestFixture.P(2, 2)).ShouldBe(GameActionResult.Ok); + game.TrainTroop(1, GameTestFixture.P(1, 1), TroopType.Warrior).ShouldBe(GameActionResult.Ok); + game.EndTurn(1).Result.ShouldBe(GameActionResult.Ok); + + // player 2 walks its warrior out of its capital and takes a hit at nothing in particular + game.Players[1].Stars = 25; + game.MoveTroop(2, GameTestFixture.P(6, 1), GameTestFixture.P(5, 2)).ShouldBe(GameActionResult.Ok); + game.EndTurn(2).Result.ShouldBe(GameActionResult.Ok); + + // player 3 keeps its stars and its troop where they are + game.EndTurn(3).Result.ShouldBe(GameActionResult.Ok); + + return game; + } + + [Fact] + public void TestRoundtripOfAGameThatHasNotStarted() { + var game = GameTestFixture.NewGame(); + var snapshot = game.ToSnapshot(); + + var restored = Roundtrip(game); + + restored.Started.ShouldBeFalse(); + restored.Turn.ShouldBe(0u); + restored.CurrentPlayer.ShouldBe(0); + ShouldMatch(restored.ToSnapshot(), snapshot); + } + + [Fact] + public void TestRoundtripPreservesTheWholeBoard() { + var game = PlayedGame(); + var snapshot = game.ToSnapshot(); + + var restored = Roundtrip(game); + + restored.Grid.Size.ShouldBe(game.Grid.Size); + for (var index = 0u; index < game.Grid.Size * game.Grid.Size; index++) { + restored.Grid[index].Raw.ShouldBe(game.Grid[index].Raw, $"tile {index} differs"); + restored.Troops[index].Raw.ShouldBe(game.Troops[index].Raw, $"troop {index} differs"); + } + + ShouldMatch(restored.ToSnapshot(), snapshot); + } + + [Fact] + public void TestRoundtripPreservesCityInternals() { + var game = GameTestFixture.NewStartedGame(); + var cityId = game.CityIdsOf(1).Single(); + + // every field of the packed city data, set to something that isn't its default + game.Cities.ModifyCity(cityId, (ref CityData city) => { + city.Level = 5; + city.Population = 3; + city.Troops = 7; + city.Parks = 2; + city.Wall = true; + city.Forge = true; + city.Connected = true; + }); + + var restored = Roundtrip(game); + + restored.Cities.Cities.ShouldBe(game.Cities.Cities); + var city = restored.Cities[cityId]; + city.Owner.ShouldBe(1); + city.Level.ShouldBe(5); + city.Population.ShouldBe(3); + city.Troops.ShouldBe(7); + city.Parks.ShouldBe(2); + city.Wall.ShouldBeTrue(); + city.Forge.ShouldBeTrue(); + city.Capital.ShouldBeTrue(); + city.Connected.ShouldBeTrue(); + city.Stars.ShouldBe(game.Cities[cityId].Stars); + } + + [Fact] + public void TestRoundtripPreservesEveryCityAndItsIndex() { + var game = GameTestFixture.NewStartedGame(4); + var village = game.Cities.RegisterCity(GameTestFixture.P(3, 3)); + game.Cities.ModifyCity(village, (ref CityData city) => city.Level = 2); + + var restored = Roundtrip(game); + + restored.Cities.Cities.Count.ShouldBe(5); + for (var id = 1u; id <= 5; id++) { + restored.Cities.GetIndex(id).ShouldBe(game.Cities.GetIndex(id)); + restored.Cities[id].ToULong().ShouldBe(game.Cities[id].ToULong()); + } + + restored.Cities[village].Level.ShouldBe(2); + restored.Cities[village].Owner.ShouldBe(0); + } + + [Fact] + public void TestRoundtripPreservesTroopRawState() { + var game = GameTestFixture.NewStartedGame(); + var position = GameTestFixture.P(3, 3); + game.Troops.SpawnTroop(position, 1, 1, TroopType.Rider); + game.Troops.SetVeteran(position); + game.Troops.ModifyTroop(position, (ref TroopData troop) => { + troop.Hp = 12; + troop.Moved = true; + troop.Attacked = true; + }); + + var restored = Roundtrip(game); + var troop = restored.Troops[position]; + + troop.IsValid().ShouldBeTrue(); + troop.Type.ShouldBe(TroopType.Rider); + troop.Player.ShouldBe(1u); + troop.City.ShouldBe(1u); + troop.Hp.ShouldBe(12u); + troop.Veteran.ShouldBeTrue(); + troop.Moved.ShouldBeTrue(); + troop.Attacked.ShouldBeTrue(); + restored.MaxHpOf(troop).ShouldBe(game.MaxHpOf(game.Troops[position])); + } + + [Fact] + public void TestRoundtripPreservesPlayersInTurnOrder() { + var game = PlayedGame(); + + var restored = Roundtrip(game); + + restored.Players.Count.ShouldBe(game.Players.Count); + foreach (var (actual, expected) in restored.Players.Zip(game.Players)) { + actual.Id.ShouldBe(expected.Id); + actual.Tribe.ShouldBe(expected.Tribe); + actual.Stars.ShouldBe(expected.Stars); + actual.Alive.ShouldBe(expected.Alive); + actual.Score.ScoreValue.ShouldBe(expected.Score.ScoreValue); + restored[expected.Id].ShouldBe(actual); + } + } + + [Fact] + public void TestRoundtripPreservesResearchedTechnology() { + var game = GameTestFixture.NewStartedGame(); + game.Players[0].Stars = 100; + game.ResearchTech(1, "riding").ShouldBe(GameActionResult.Ok); + game.ResearchTech(1, "roads").ShouldBe(GameActionResult.Ok); + + var restored = Roundtrip(game); + + restored.Players[0].TechTree.ResearchedIds().ShouldBe(game.Players[0].TechTree.ResearchedIds()); + restored.Players[0].TechTree.HasResearched("riding").ShouldBeTrue(); + restored.Players[0].TechTree.HasResearched("roads").ShouldBeTrue(); + + // the starting node of the tribe is restored because it was researched, not because a tribe hands it out + restored.Players[0].TechTree.HasResearched("organization").ShouldBeTrue(); + restored.Players[0].TechTree.HasResearched("climbing").ShouldBeFalse(); + + // player 2 never researched anything past its start, and researching for one player never touched the other + restored.Players[1].TechTree.ResearchedIds().ShouldBe(game.Players[1].TechTree.ResearchedIds()); + restored.Players[1].TechTree.HasResearched("riding").ShouldBeFalse(); + } + + [Fact] + public void TestRoundtripPreservesScoreAndSettingsAndTurnState() { + var game = GameTestFixture.NewStartedGame(3, new GameSettings { MaxTurns = 12 }); + game.Players[1].Score.AddScore(ScoreType.MonumentsBuilt); + game.Players[1].Score.AddScore(ScoreType.LoseCity(2)); + game.EndTurn(1).Result.ShouldBe(GameActionResult.Ok); + + var restored = Roundtrip(game); + + restored.Settings.MaxTurns.ShouldBe(12u); + restored.Turn.ShouldBe(game.Turn); + restored.CurrentPlayer.ShouldBe(2); + restored.Started.ShouldBeTrue(); + restored.Over.ShouldBeFalse(); + restored.Winner.ShouldBe(0); + restored.Players[1].Score.ScoreValue.ShouldBe(game.Players[1].Score.ScoreValue); + } + + [Fact] + public void TestRoundtripPreservesEliminatedPlayersAndAFinishedGame() { + var game = GameTestFixture.NewStartedGame(3); + game.Resign(2).Result.ShouldBe(GameActionResult.Ok); + game.Resign(3).Result.ShouldBe(GameActionResult.Ok); + + game.Over.ShouldBeTrue(); + game.Winner.ShouldBe(1); + + var restored = Roundtrip(game); + + restored.Over.ShouldBeTrue(); + restored.Winner.ShouldBe(1); + restored.Players[0].Alive.ShouldBeTrue(); + restored.Players[1].Alive.ShouldBeFalse(); + restored.Players[2].Alive.ShouldBeFalse(); + + // a finished game stays finished: every action is refused exactly like on the original + restored.EndTurn(1).Result.ShouldBe(GameActionResult.GameOver); + ShouldMatch(restored.ToSnapshot(), game.ToSnapshot()); + } + + [Fact] + public void TestSnapshotIsNotAViewOnTheLiveGame() { + var game = GameTestFixture.NewStartedGame(); + var snapshot = game.ToSnapshot(); + + game.Players[0].Stars = 999; + game.Grid.ModifyTile(GameTestFixture.P(4, 4), (ref Tile tile) => tile.Kind = TileKind.Mountain); + game.Troops.SpawnTroop(GameTestFixture.P(4, 4), 1, 1, TroopType.Warrior); + game.Cities.RegisterCity(GameTestFixture.P(3, 3)); + + var restored = Restore(snapshot); + + restored.Players[0].Stars.ShouldNotBe(999); + restored.Grid[GameTestFixture.P(4, 4)].Kind.ShouldBe(TileKind.Field); + restored.Troops[GameTestFixture.P(4, 4)].IsValid().ShouldBeFalse(); + restored.Cities.Cities.Count.ShouldBe(2); + } + + [Fact] + public void TestRestoredGameKeepsPlayingIdenticallyToTheOriginal() { + var original = PlayedGame(); + var restored = Roundtrip(original); + + // the same sequence of actions, played on both, has to land on the same state + foreach (var game in new[] { original, restored }) { + game.CurrentPlayer.ShouldBe(1); + game.Players[0].Stars = 30; + // tier 2, unlocked by the farming PlayedGame researched: it only passes if the tech tree survived the restore + game.ResearchTech(1, "construction").ShouldBe(GameActionResult.Ok); + game.MoveTroop(1, GameTestFixture.P(2, 2), GameTestFixture.P(3, 3)).ShouldBe(GameActionResult.Ok); + game.EndTurn(1).Result.ShouldBe(GameActionResult.Ok); + + game.MoveTroop(2, GameTestFixture.P(5, 2), GameTestFixture.P(4, 3)).ShouldBe(GameActionResult.Ok); + game.EndTurn(2).Result.ShouldBe(GameActionResult.Ok); + + game.EndTurn(3).Result.ShouldBe(GameActionResult.Ok); + } + + ShouldMatch(restored.ToSnapshot(), original.ToSnapshot()); + } + + [Fact] + public void TestRestoredGameResolvesCombatIdenticallyToTheOriginal() { + var original = GameTestFixture.NewStartedGame(); + + // two troops of different players next to each other, outside anybody's capital + original.Troops.SpawnTroop(GameTestFixture.P(3, 3), 1, 1, TroopType.Warrior); + original.Troops.SpawnTroop(GameTestFixture.P(4, 3), 2, 2, TroopType.Defender); + + var restored = Roundtrip(original); + + var originalAttack = original.Attack(1, GameTestFixture.P(3, 3), GameTestFixture.P(4, 3)); + var restoredAttack = restored.Attack(1, GameTestFixture.P(3, 3), GameTestFixture.P(4, 3)); + + originalAttack.Result.ShouldBe(GameActionResult.Ok); + restoredAttack.ShouldBe(originalAttack); + ShouldMatch(restored.ToSnapshot(), original.ToSnapshot()); + } + + [Fact] + public void TestRestoredGameEndsOnTheTurnLimitLikeTheOriginal() { + var game = GameTestFixture.NewStartedGame(2, new GameSettings { MaxTurns = 2 }); + game.EndTurn(1).Result.ShouldBe(GameActionResult.Ok); + game.EndTurn(2).Turn.ShouldBe(2u); + + var restored = Roundtrip(game); + + restored.Settings.MaxTurns.ShouldBe(2u); + restored.EndTurn(1).Result.ShouldBe(GameActionResult.Ok); + + var last = restored.EndTurn(2); + last.GameOver.ShouldBeTrue(); + restored.Over.ShouldBeTrue(); + restored.Turn.ShouldBe(2u); + } + + [Fact] + public void TestRestoreRejectsASnapshotOfAnotherVersion() { + var snapshot = GameTestFixture.NewStartedGame().ToSnapshot(); + var older = Clone(snapshot, version: GameSnapshot.CURRENT_VERSION - 1); + + Should.Throw(() => Restore(older)); + } + + [Fact] + public void TestRestoreRejectsArraysThatDoNotMatchTheGrid() { + var snapshot = GameTestFixture.NewStartedGame().ToSnapshot(); + + Should.Throw(() => Restore(Clone(snapshot, tiles: [.. snapshot.Tiles.Skip(1)]))); + Should.Throw(() => Restore(Clone(snapshot, troops: [.. snapshot.Troops.Skip(1)]))); + } + + [Fact] + public void TestRestoreRejectsATroopManagerSizedForAnotherGrid() { + var snapshot = GameTestFixture.NewStartedGame().ToSnapshot(); + var content = NewContent(GameTestFixture.SIZE + 1); + + Should.Throw(() => + Game.Restore(snapshot, content.Troops, content.Tribes, content.Buildings, content.TechTree)); + } + + [Fact] + public void TestRestoreRejectsACityOutsideTheGrid() { + var snapshot = GameTestFixture.NewStartedGame().ToSnapshot(); + + Should.Throw(() => + Restore(Clone(snapshot, cityIndexes: [GameTestFixture.SIZE * GameTestFixture.SIZE]))); + } + + [Fact] + public void TestRestoreRejectsATechThatIsNotInTheTree() { + var snapshot = GameTestFixture.NewStartedGame().ToSnapshot(); + var players = snapshot.Players.Select(player => Clone(player, researched: ["not_a_tech"])).ToList(); + + Should.Throw(() => Restore(Clone(snapshot, players: players))); + } + + [Fact] + public void TestRestoreRejectsAPlayerListThatIsNotAGame() { + var snapshot = GameTestFixture.NewStartedGame().ToSnapshot(); + + Should.Throw(() => Restore(Clone(snapshot, players: [snapshot.Players[0]]))); + Should.Throw(() => + Restore(Clone(snapshot, players: [snapshot.Players[0], snapshot.Players[0]]))); + Should.Throw(() => + Restore(Clone(snapshot, players: [Clone(snapshot.Players[0], id: 17), snapshot.Players[1]]))); + } + + [Fact] + public void TestRestoreRejectsATurnHeldByNobody() { + var snapshot = GameTestFixture.NewStartedGame().ToSnapshot(); + + Should.Throw(() => Restore(Clone(snapshot, currentPlayer: 9))); + } + + [Fact] + public void TestBlobRoundtripOfTheBoardArrays() { + var snapshot = PlayedGame().ToSnapshot(); + + var tiles = SnapshotEncoding.UnpackTiles(SnapshotEncoding.PackTiles(snapshot.Tiles)); + var troops = SnapshotEncoding.UnpackTroops(SnapshotEncoding.PackTroops(snapshot.Troops)); + var cityIndexes = SnapshotEncoding.UnpackCityIndexes(SnapshotEncoding.PackCityIndexes(snapshot.CityIndexes)); + + tiles.ShouldBe(snapshot.Tiles); + troops.ShouldBe(snapshot.Troops); + cityIndexes.ShouldBe(snapshot.CityIndexes); + + // the blobs are the actual columns of a database row, so a game has to come back out of them + var restored = Restore(Clone(snapshot, tiles: tiles, troops: troops, cityIndexes: cityIndexes)); + ShouldMatch(restored.ToSnapshot(), snapshot); + } + + [Fact] + public void TestBlobsAreLittleEndianAndSized() { + SnapshotEncoding.PackTiles([0x0102030405060708]).ShouldBe( + new byte[] { 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01 }); + SnapshotEncoding.PackTroops([0x01020304]).ShouldBe(new byte[] { 0x04, 0x03, 0x02, 0x01 }); + + SnapshotEncoding.PackTiles([1, 2, 3]).Length.ShouldBe(24); + SnapshotEncoding.PackTroops([1, 2, 3]).Length.ShouldBe(12); + } + + [Fact] + public void TestBlobsRejectATruncatedColumn() { + Should.Throw(() => SnapshotEncoding.UnpackTiles(new byte[7])); + Should.Throw(() => SnapshotEncoding.UnpackTroops(new byte[3])); + Should.Throw(() => SnapshotEncoding.UnpackCityIndexes(new byte[5])); + } + + private static GameSnapshot Clone(GameSnapshot snapshot, int? version = null, ulong[]? tiles = null, + uint[]? troops = null, uint[]? cityIndexes = null, IReadOnlyList? players = null, + int? currentPlayer = null) => + new() { + Version = version ?? snapshot.Version, + GridSize = snapshot.GridSize, + Tiles = tiles ?? snapshot.Tiles, + Troops = troops ?? snapshot.Troops, + CityIndexes = cityIndexes ?? snapshot.CityIndexes, + Players = players ?? snapshot.Players, + MaxTurns = snapshot.MaxTurns, + Turn = snapshot.Turn, + CurrentPlayer = currentPlayer ?? snapshot.CurrentPlayer, + Started = snapshot.Started, + Over = snapshot.Over, + Winner = snapshot.Winner + }; + + private static PlayerSnapshot Clone(PlayerSnapshot snapshot, int? id = null, IReadOnlyList? researched = null) => + new() { + Id = id ?? snapshot.Id, + Tribe = snapshot.Tribe, + Stars = snapshot.Stars, + Score = snapshot.Score, + Alive = snapshot.Alive, + ResearchedTechs = researched ?? snapshot.ResearchedTechs + }; +} diff --git a/OpenPolytopia.UnitTest/ServerStoreTest.cs b/OpenPolytopia.UnitTest/ServerStoreTest.cs new file mode 100644 index 0000000..0e05b7c --- /dev/null +++ b/OpenPolytopia.UnitTest/ServerStoreTest.cs @@ -0,0 +1,344 @@ +namespace OpenPolytopia; + +using Microsoft.Data.Sqlite; +using Server; +using Shouldly; + +/// +/// Tests against a real SQLite database in a temporary directory +/// +public class ServerStoreTest : IDisposable { + private const string USERNAME = "Tester"; + private const string PASSWORD = "correct horse battery"; + + /// + /// A clock the tests can move forward at will + /// + private sealed class FakeTimeProvider(DateTimeOffset now) : TimeProvider { + private DateTimeOffset _now = now; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan delta) => _now += delta; + } + + private readonly string _directory = + Directory.CreateTempSubdirectory("openpolytopia-store-test-").FullName; + + private string DatabasePath => Path.Combine(_directory, "server.db"); + + private ServerStore Open(TimeProvider? timeProvider = null) => new(DatabasePath, timeProvider); + + public void Dispose() { + GC.SuppressFinalize(this); + Directory.Delete(_directory, true); + } + + #region Registration + + [Fact] + public void RegisterReturnsAnAccountAndASession() { + using var store = Open(); + + var registration = store.Register(USERNAME, PASSWORD); + + registration.Account.Id.ShouldBe(1u); + registration.Account.Username.ShouldBe("tester"); + registration.Account.DisplayName.ShouldBe(USERNAME); + registration.Token.ShouldNotBeNullOrWhiteSpace(); + store.Resume(registration.Token).ShouldNotBeNull().Id.ShouldBe(registration.Account.Id); + } + + [Fact] + public void RegisterAssignsDistinctIds() { + using var store = Open(); + + var first = store.Register("first_user", PASSWORD); + var second = store.Register("second_user", PASSWORD); + + second.Account.Id.ShouldNotBe(first.Account.Id); + } + + [Fact] + public void RegisterRejectsADuplicateNormalizedUsername() { + using var store = Open(); + store.Register("Tester", PASSWORD); + + Should.Throw(() => store.Register("tEsTeR", PASSWORD)); + } + + [Fact] + public void RegisterRollsBackTheAccountOfARejectedDuplicate() { + using var store = Open(); + var original = store.Register("Tester", PASSWORD); + + Should.Throw(() => store.Register("TESTER", "a different password")); + + // the duplicate left nothing behind: the original credentials are still the only ones that work + store.Login("tester", PASSWORD).ShouldNotBeNull().Account.Id.ShouldBe(original.Account.Id); + store.Login("tester", "a different password").ShouldBeNull(); + } + + [Theory] + [InlineData("")] + [InlineData("ab")] + [InlineData("this_username_is_way_too_long_to_be_accepted")] + [InlineData("has space")] + [InlineData("has-dash")] + [InlineData("\u00fc\u00f1\u00ef\u00e7\u00f6\u00e9")] + public void RegisterRejectsAnInvalidUsername(string username) { + using var store = Open(); + + Should.Throw(() => store.Register(username, PASSWORD)); + } + + [Theory] + [InlineData("")] + [InlineData("short")] + [InlineData("elevenChars")] + public void RegisterRejectsATooShortPassword(string password) { + using var store = Open(); + + Should.Throw(() => store.Register(USERNAME, password)); + } + + [Fact] + public void RegisterRejectsATooLongPassword() { + using var store = Open(); + + Should.Throw(() => store.Register(USERNAME, new string('x', 129))); + } + + [Fact] + public void RegisterAcceptsThePasswordsAtTheBounds() { + using var store = Open(); + + store.Register("shortest", new string('x', 12)).ShouldNotBeNull(); + store.Register("longest", new string('x', 128)).ShouldNotBeNull(); + } + + #endregion + + #region Login + + [Fact] + public void LoginAcceptsTheRightPasswordInAnyCasing() { + using var store = Open(); + var registration = store.Register(USERNAME, PASSWORD); + + var login = store.Login("TESTER", PASSWORD).ShouldNotBeNull(); + + login.Account.Id.ShouldBe(registration.Account.Id); + login.Account.DisplayName.ShouldBe(USERNAME); + login.Token.ShouldNotBe(registration.Token); + store.Resume(login.Token).ShouldNotBeNull(); + // logging in doesn't invalidate the sessions that are already open + store.Resume(registration.Token).ShouldNotBeNull(); + } + + [Fact] + public void LoginRejectsAWrongPassword() { + using var store = Open(); + store.Register(USERNAME, PASSWORD); + + store.Login(USERNAME, "wrong password!").ShouldBeNull(); + } + + [Fact] + public void LoginRejectsAnUnknownAccount() { + using var store = Open(); + store.Register(USERNAME, PASSWORD); + + store.Login("nobody", PASSWORD).ShouldBeNull(); + } + + [Theory] + [InlineData("ab", PASSWORD)] + [InlineData("has space", PASSWORD)] + [InlineData(USERNAME, "short")] + public void LoginRejectsMalformedCredentialsWithoutThrowing(string username, string password) { + using var store = Open(); + store.Register(USERNAME, PASSWORD); + + store.Login(username, password).ShouldBeNull(); + } + + #endregion + + #region Sessions + + [Fact] + public void ResumeRejectsAnUnknownOrMalformedToken() { + using var store = Open(); + var registration = store.Register(USERNAME, PASSWORD); + + store.Resume("").ShouldBeNull(); + store.Resume("not base64 at all!").ShouldBeNull(); + store.Resume(Convert.ToBase64String(new byte[16])).ShouldBeNull(); + store.Resume(Convert.ToBase64String(new byte[32])).ShouldBeNull(); + // a token of the right shape but with a flipped character isn't the one that was handed out + store.Resume(FlipFirstCharacter(registration.Token)).ShouldBeNull(); + } + + [Fact] + public void LogoutRevokesTheSession() { + using var store = Open(); + var registration = store.Register(USERNAME, PASSWORD); + var other = store.Login(USERNAME, PASSWORD).ShouldNotBeNull(); + + store.Logout(registration.Token).ShouldBeTrue(); + + store.Resume(registration.Token).ShouldBeNull(); + // revoking one session leaves the other ones alone + store.Resume(other.Token).ShouldNotBeNull(); + // revoking twice is a no-op + store.Logout(registration.Token).ShouldBeFalse(); + store.Logout("garbage").ShouldBeFalse(); + } + + [Fact] + public void ResumeRejectsAnExpiredSession() { + var clock = new FakeTimeProvider(DateTimeOffset.UnixEpoch); + using var store = Open(clock); + var registration = store.Register(USERNAME, PASSWORD); + + clock.Advance(ServerStore.SessionLifetime - TimeSpan.FromMinutes(1)); + store.Resume(registration.Token).ShouldNotBeNull(); + + clock.Advance(TimeSpan.FromMinutes(2)); + store.Resume(registration.Token).ShouldBeNull(); + // logging in again gives a session that's valid from now on + var renewed = store.Login(USERNAME, PASSWORD).ShouldNotBeNull(); + store.Resume(renewed.Token).ShouldNotBeNull(); + } + + [Fact] + public void AnExpiredSessionStaysRejectedAfterARestart() { + var clock = new FakeTimeProvider(DateTimeOffset.UnixEpoch); + string token; + using (var store = Open(clock)) { + token = store.Register(USERNAME, PASSWORD).Token; + } + + clock.Advance(ServerStore.SessionLifetime + TimeSpan.FromDays(1)); + + using var restarted = Open(clock); + restarted.Resume(token).ShouldBeNull(); + } + + #endregion + + #region Persistence + + [Fact] + public void AccountsAndSessionsSurviveARestart() { + uint accountId; + string token; + using (var store = Open()) { + var registration = store.Register(USERNAME, PASSWORD); + accountId = registration.Account.Id; + token = registration.Token; + } + + using var restarted = Open(); + + var resumed = restarted.Resume(token).ShouldNotBeNull(); + resumed.Id.ShouldBe(accountId); + resumed.Username.ShouldBe("tester"); + resumed.DisplayName.ShouldBe(USERNAME); + restarted.Login(USERNAME, PASSWORD).ShouldNotBeNull().Account.Id.ShouldBe(accountId); + restarted.Login(USERNAME, "wrong password!").ShouldBeNull(); + } + + [Fact] + public void AccountIdsAreNotReusedAcrossRestarts() { + uint firstId; + using (var store = Open()) { + firstId = store.Register("first_user", PASSWORD).Account.Id; + } + + using var restarted = Open(); + restarted.Register("second_user", PASSWORD).Account.Id.ShouldBeGreaterThan(firstId); + } + + [Fact] + public void StateIsEmptyUntilItsSaved() { + using var store = Open(); + + store.LoadState().ShouldBeNull(); + } + + [Fact] + public void StateSurvivesARestart() { + const string SNAPSHOT = """{"turn":12,"games":[]}"""; + using (var store = Open()) { + store.SaveState(SNAPSHOT); + } + + using var restarted = Open(); + restarted.LoadState().ShouldBe(SNAPSHOT); + } + + [Fact] + public void SaveStateReplacesThePreviousSnapshot() { + using var store = Open(); + + store.SaveState("""{"turn":1}"""); + store.SaveState("""{"turn":2}"""); + + store.LoadState().ShouldBe("""{"turn":2}"""); + } + + #endregion + + #region Schema + + [Fact] + public void OpeningRejectsANewerSchema() { + using (var store = Open()) { + store.Register(USERNAME, PASSWORD); + } + + SetUserVersion(ServerStore.SCHEMA_VERSION + 1); + + Should.Throw(() => Open()); + } + + [Fact] + public void OpeningAnAlreadyMigratedDatabaseKeepsItsData() { + using (var store = Open()) { + store.Register(USERNAME, PASSWORD); + store.SaveState("""{"turn":1}"""); + } + + using (Open()) { } + + using var restarted = Open(); + restarted.Login(USERNAME, PASSWORD).ShouldNotBeNull(); + restarted.LoadState().ShouldBe("""{"turn":1}"""); + } + + [Fact] + public void UsingADisposedStoreThrows() { + var store = Open(); + store.Dispose(); + store.Dispose(); + + Should.Throw(() => store.LoadState()); + } + + #endregion + + private static string FlipFirstCharacter(string token) => + (token[0] == 'A' ? 'B' : 'A') + token[1..]; + + private void SetUserVersion(int version) { + using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { + DataSource = DatabasePath, Pooling = false + }.ToString()); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = $"PRAGMA user_version = {version};"; + command.ExecuteNonQuery(); + } +}