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();
+ }
+}