diff --git a/OpenPolytopia.Common/Gameplay/GameActionResult.cs b/OpenPolytopia.Common/Gameplay/GameActionResult.cs index b10c858b..99f01672 100644 --- a/OpenPolytopia.Common/Gameplay/GameActionResult.cs +++ b/OpenPolytopia.Common/Gameplay/GameActionResult.cs @@ -109,5 +109,8 @@ public enum GameActionResult : byte { InvalidTroopType = 32, /// The tile isn't a valid capture target - NotACaptureTarget = 33 + NotACaptureTarget = 33, + + /// The request parameters do not match the current game state. + InvalidParameters = 34 } diff --git a/OpenPolytopia.Common/Network/PacketRegistrar.cs b/OpenPolytopia.Common/Network/PacketRegistrar.cs index b3bebe59..d86f3faf 100644 --- a/OpenPolytopia.Common/Network/PacketRegistrar.cs +++ b/OpenPolytopia.Common/Network/PacketRegistrar.cs @@ -45,8 +45,6 @@ public static void RegisterAllPackets() { return; } - _registered = true; - RegisterPacket(0); RegisterPacket(1); RegisterPacket(2); @@ -103,6 +101,7 @@ public static void RegisterAllPackets() { RegisterPacket(53); RegisterPacket(54); RegisterPacket(55); + _registered = true; } } diff --git a/OpenPolytopia.Common/Network/Packets/GamePackets.cs b/OpenPolytopia.Common/Network/Packets/GamePackets.cs index 203f23cc..492d7f3e 100644 --- a/OpenPolytopia.Common/Network/Packets/GamePackets.cs +++ b/OpenPolytopia.Common/Network/Packets/GamePackets.cs @@ -96,6 +96,8 @@ public partial class GameStatePacket : IPacket { /// [GeneratedPacket] public partial class MoveTroopPacket : IPacket { + /// Round shown when the action was issued; required for live games. + [PacketField] public uint ExpectedTurn; /// /// Id of the game, i.e. the id of the lobby it started from /// @@ -168,6 +170,8 @@ public partial class TroopMovedPacket : IPacket { /// [GeneratedPacket] public partial class AttackPacket : IPacket { + /// Round shown when the action was issued; required for live games. + [PacketField] public uint ExpectedTurn; /// /// Id of the game, i.e. the id of the lobby it started from /// @@ -270,6 +274,8 @@ public partial class CombatPacket : IPacket { /// [GeneratedPacket] public partial class TrainTroopPacket : IPacket { + /// Round shown when the action was issued; required for live games. + [PacketField] public uint ExpectedTurn; /// /// Id of the game, i.e. the id of the lobby it started from /// @@ -342,6 +348,8 @@ public partial class TroopTrainedPacket : IPacket { /// [GeneratedPacket] public partial class ResearchTechPacket : IPacket { + /// Round shown when the action was issued; required for live games. + [PacketField] public uint ExpectedTurn; /// /// Id of the game, i.e. the id of the lobby it started from /// @@ -402,6 +410,8 @@ public partial class TechResearchedPacket : IPacket { /// [GeneratedPacket] public partial class BuildPacket : IPacket { + /// Round shown when the action was issued; required for live games. + [PacketField] public uint ExpectedTurn; /// /// Id of the game, i.e. the id of the lobby it started from /// @@ -474,6 +484,8 @@ public partial class BuildingBuiltPacket : IPacket { /// [GeneratedPacket] public partial class CapturePacket : IPacket { + /// Round shown when the action was issued; required for live games. + [PacketField] public uint ExpectedTurn; /// /// Id of the game, i.e. the id of the lobby it started from /// @@ -546,6 +558,8 @@ public partial class CityCapturedPacket : IPacket { /// [GeneratedPacket] public partial class EndTurnPacket : IPacket { + /// Round shown when the action was issued; required for live games. + [PacketField] public uint ExpectedTurn; /// /// Id of the game, i.e. the id of the lobby it started from /// diff --git a/OpenPolytopia.Server/GameManager.cs b/OpenPolytopia.Server/GameManager.cs index 919ef010..78ddaf75 100644 --- a/OpenPolytopia.Server/GameManager.cs +++ b/OpenPolytopia.Server/GameManager.cs @@ -54,7 +54,7 @@ public void Disconnect(uint connectionId) { /// the newly created, started and registered session /// if the lobby doesn't have 2 to 16 players public async Task CreateGameAsync(LobbyData lobby, GameData data, int? seed = null, - IReadOnlyDictionary? onlineConnections = null) { + IReadOnlyDictionary? onlineConnections = null, DateTimeOffset? startedAt = null) { ArgumentNullException.ThrowIfNull(lobby); ArgumentNullException.ThrowIfNull(data); @@ -88,7 +88,10 @@ public async Task CreateGameAsync(LobbyData lobby, GameData data, i players); game.Start(); - var session = new GameSession(lobby.Id, game, connections, names); + var session = new GameSession(lobby.Id, game, connections, names) { + Clock = new TurnClock((TurnTimerMode)lobby.TimerMode, game.Players.Select(p => p.Id)) + }; + session.Clock.Begin(game.CurrentPlayer, startedAt ?? DateTimeOffset.UtcNow); if (onlineConnections != null) { foreach (var connectionId in session.ConnectionIds.ToArray()) session.RemoveConnection(connectionId); foreach (var accountId in connections.Values) { diff --git a/OpenPolytopia.Server/GameServer.Accounts.cs b/OpenPolytopia.Server/GameServer.Accounts.cs index d7cca95b..6b9ed3a0 100644 --- a/OpenPolytopia.Server/GameServer.Accounts.cs +++ b/OpenPolytopia.Server/GameServer.Accounts.cs @@ -66,6 +66,7 @@ private void RegisterAccountHandlers() { var session = FindSession(p.GameId, AccountId(c)); if (session != null && session.Join(AccountId(c), c.Id)) { SendTo(c.Id, session.BuildState()); + SendClock(session, [c.Id]); } else SendTo(c.Id, new GameStatePacket { GameId = p.GameId, Result = session == null ? GameActionResult.GameNotFound : GameActionResult.NotInGame }); diff --git a/OpenPolytopia.Server/GameServer.Persistence.cs b/OpenPolytopia.Server/GameServer.Persistence.cs index 500f8dec..08f2b73e 100644 --- a/OpenPolytopia.Server/GameServer.Persistence.cs +++ b/OpenPolytopia.Server/GameServer.Persistence.cs @@ -15,37 +15,44 @@ public partial class GameServer { private readonly Dictionary _pendingRenames = new(); private sealed record SavedSession(ulong Id, GameSnapshot Game, Dictionary Accounts, - Dictionary Names); + Dictionary Names, TurnClockState? Clock); private sealed record SavedServer(int Version, ulong NextLobbyId, List Lobbies, List Games); private static SavedSession SaveSession(GameSession session) => new(session.Id, - session.Game.ToSnapshot(), new(session.Accounts), new(session.Names)); + session.Game.ToSnapshot(), new(session.Accounts), new(session.Names), session.Clock?.ToSnapshot()); - private string CaptureState(bool includeCompleted = true) => JsonSerializer.Serialize(new SavedServer(1, _lobbyManager.LastId, + private string CaptureState(bool includeCompleted = true) => JsonSerializer.Serialize(new SavedServer(2, _lobbyManager.LastId, [.. _lobbyManager.Lobbies], [.. _gameManager.Sessions.Where(session => includeCompleted || !session.Game.Over).Select(SaveSession)]), _json); private void RestoreState(string? json) { if (json == null) return; var state = JsonSerializer.Deserialize(json, _json) ?? throw new InvalidDataException("Empty server state"); - if (state.Version != 1) throw new InvalidDataException("Unsupported server state version"); + if (state.Version is not (1 or 2)) throw new InvalidDataException("Unsupported server state version"); var nextLobbies = new LobbyManager(); nextLobbies.Restore(state.NextLobbyId, state.Lobbies); var nextGames = new GameManager(); foreach (var saved in state.Games) { - var session = RestoreSession(saved); + var session = RestoreSession(saved, state.Version == 1); nextGames.Restore(session); } _lobbyManager = nextLobbies; _gameManager = nextGames; - _savedState = json; + _savedState = state.Version == 1 ? CaptureState() : json; } - private GameSession RestoreSession(SavedSession saved) { + private GameSession RestoreSession(SavedSession saved, bool legacy = false) { var troops = new TroopManager(saved.Game.GridSize); troops.RegisterTroops(_gameData.TroopsSerializedData); var game = Game.Restore(saved.Game, troops, _gameData.Tribes, _gameData.Buildings, _gameData.TechTreeDefinition); var session = new GameSession(saved.Id, game, saved.Accounts, saved.Names); + if (saved.Clock != null) session.Clock = TurnClock.FromSnapshot(saved.Clock); + else if (!game.Over) { + if (!legacy) throw new InvalidDataException("Missing saved turn clock"); + // Pre-timer saves did not retain the chosen mode. Allow a full day after migration. + session.Clock = new TurnClock(TurnTimerMode.Daily, game.Players.Select(player => player.Id)); + session.Clock.Begin(game.CurrentPlayer, _timeProvider.GetUtcNow()); + } foreach (var connection in session.ConnectionIds.ToArray()) session.RemoveConnection(connection); return session; } @@ -73,8 +80,12 @@ private async Task WithStateAsync(Func action, bool stateMayChange = true) var attachments = _gameManager.Sessions.Where(s => s.Connections.Count != 0).ToDictionary(s => s.Id, s => s.Connections.ToArray()); try { before = _savedState ??= CaptureState(); - var changes = stateMayChange || _lobbyManager.Lobbies.Any(l => l.Starting); + var now = _timeProvider.GetUtcNow(); + var changes = stateMayChange || _lobbyManager.Lobbies.Any(l => l.Starting) || + _gameManager.Sessions.Any(s => !s.Game.Over && s.Clock?.Mode == TurnTimerMode.Live && s.Clock.IsExpired(now)); + ProcessTimers(now); await action(); + SynchronizeClocks(_timeProvider.GetUtcNow()); var completed = _gameManager.Sessions.Where(session => session.Game.Over).Select(session => new ArchivedGame(session.Id, JsonSerializer.Serialize(SaveSession(session), _json), session.Accounts.Values.ToArray())).ToArray(); var after = changes || completed.Length != 0 ? CaptureState(false) : before; diff --git a/OpenPolytopia.Server/GameServer.Timers.cs b/OpenPolytopia.Server/GameServer.Timers.cs new file mode 100644 index 00000000..eb0ba201 --- /dev/null +++ b/OpenPolytopia.Server/GameServer.Timers.cs @@ -0,0 +1,84 @@ +namespace OpenPolytopia.Server; + +using OpenPolytopia.Common.Gameplay; +using OpenPolytopia.Common.Network.Packets; + +public partial class GameServer { + private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; + + private void RegisterTimerHandlers() { + _dispatcher.Register((connection, packet) => { + var session = FindSession(packet.GameId, AccountId(connection)); + var playerId = session?.PlayerIdOfAccount(AccountId(connection)) ?? 0; + var now = _timeProvider.GetUtcNow(); + var result = session == null ? GameActionResult.GameNotFound : + playerId == 0 ? GameActionResult.NotInGame : + session.Game.Over ? GameActionResult.GameOver : + !session.Game[playerId]!.Alive ? GameActionResult.PlayerEliminated : + session.Game.CurrentPlayer == playerId || session.Game.Turn != packet.ExpectedTurn || + session.Game.CurrentPlayer != packet.ExpectedPlayer || session.Clock?.Mode != TurnTimerMode.Daily || + !session.Clock.IsExpired(now) ? GameActionResult.InvalidParameters : GameActionResult.Ok; + if (result == GameActionResult.Ok) AdvanceExpired(session!, packet.Kick, now); + SendTo(connection.Id, new MembershipResultPacket { GameId = packet.GameId, Result = result }); + }); + } + + private static (int Cities, int Units) ClockBonus(GameSession session, int playerId) => + ((int)session.Game.OwnedCities(playerId), session.Game.Troops.Troops().Count(t => t.Troop.Player == playerId)); + + private void SendClock(GameSession session, IEnumerable recipients) { + if (session.Clock?.DeadlineUtc is not { } deadline || session.Game.Over) return; + BroadcastTo(recipients, new GameClockPacket { + GameId = session.Id, TimerMode = (uint)session.Clock.Mode, PlayerId = (uint)session.Game.CurrentPlayer, + DeadlineUnixMilliseconds = deadline.ToUnixTimeMilliseconds(), + ServerUnixMilliseconds = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds() + }); + } + + // Apply expiry before accepting an action. A late EndTurn cannot erase a timeout. + private void ProcessTimers(DateTimeOffset now) { + foreach (var session in _gameManager.Sessions) { + while (!session.Game.Over && session.Clock is { Mode: TurnTimerMode.Live } clock && clock.IsExpired(now)) { + // Advance from the original deadline, so downtime cannot grant a new bank. + AdvanceExpired(session, false, clock.DeadlineUtc!.Value); + } + } + } + + private void AdvanceExpired(GameSession session, bool kick, DateTimeOffset at) { + var clock = session.Clock!; + var playerId = session.Game.CurrentPlayer; + var (cities, units) = ClockBonus(session, playerId); + var decision = kick ? clock.Kick(at) : clock.Skip(cities, units, at); + var eliminated = decision == TurnClockDecision.Eliminated; + var result = eliminated ? session.Game.Resign(playerId) : session.Game.EndTurn(playerId); + if (result.Result != GameActionResult.Ok) throw new InvalidOperationException("Clock and game disagree on the current turn"); + if (eliminated) BroadcastTo(session.ConnectionIds, new PlayerEliminatedPacket { + GameId = session.Id, PlayerId = (uint)playerId, Update = session.TakeUpdate() + }); + if (session.Game.Over) { + EndGame(session); + return; + } + clock.Begin(session.Game.CurrentPlayer, at); + BroadcastTo(session.ConnectionIds, new TurnStartedPacket { + GameId = session.Id, Turn = session.Game.Turn, PlayerId = (uint)session.Game.CurrentPlayer, + Update = session.TakeUpdate() + }); + SendClock(session, session.ConnectionIds); + } + + // EndTurn, resignation and capture can all change or end the active turn. + private void SynchronizeClocks(DateTimeOffset now) { + foreach (var session in _gameManager.Sessions) { + var clock = session.Clock; + if (clock == null || !clock.Running || (!session.Game.Over && clock.ActivePlayer == session.Game.CurrentPlayer)) continue; + var (cities, units) = ClockBonus(session, clock.ActivePlayer); + clock.Complete(cities, units, now); + if (!session.Game.Over) { + clock.Begin(session.Game.CurrentPlayer, now); + SendClock(session, session.ConnectionIds); + } + } + } +} diff --git a/OpenPolytopia.Server/GameServer.cs b/OpenPolytopia.Server/GameServer.cs index f2e04a3c..0fc5cefe 100644 --- a/OpenPolytopia.Server/GameServer.cs +++ b/OpenPolytopia.Server/GameServer.cs @@ -15,11 +15,12 @@ namespace OpenPolytopia.Server; /// /// the port to listen on /// the ip address to bind to; null to listen on every interface -public partial class GameServer(int port, string? bindAddress = null, string? databasePath = null) : IDisposable { +public partial class GameServer(int port, string? bindAddress = null, string? databasePath = null, + TimeProvider? timeProvider = null) : IDisposable { /// /// How often the server checks for lobbies to start /// - private static readonly TimeSpan START_LOBBY_INTERVAL = TimeSpan.FromSeconds(5); + private static readonly TimeSpan START_LOBBY_INTERVAL = TimeSpan.FromSeconds(1); /// /// Max lobbies the server accepts before refusing new ones @@ -48,8 +49,11 @@ public partial class GameServer(int port, string? bindAddress = null, string? da /// public async Task RunAsync() { _server = new ServerConnection(port, bindAddress, _certificate); - RestoreState(_store.LoadState()); + var storedState = _store.LoadState(); + RestoreState(storedState); + if (_savedState != storedState) _store.SaveState(_savedState!); RegisterHandlers(); + RegisterTimerHandlers(); _server.OnPacketReceived += ManagePacketAsync; _server.OnClientDisconnected += connection => _ = ClientDisconnectedAsync(connection); @@ -344,13 +348,14 @@ private async Task StartLobbyAsync(LobbyData lobby) { .Select(player => online[player.PlayerId]).ToList(); try { - var session = await _gameManager.CreateGameAsync(lobby, _gameData, onlineConnections: online); + var session = await _gameManager.CreateGameAsync(lobby, _gameData, onlineConnections: online, startedAt: _timeProvider.GetUtcNow()); Console.WriteLine($"Starting game for lobby {lobby.Id} with {lobby.PlayersCount} players"); // notify the players that their game started and send them its full state BroadcastTo(connectionIds, new GameStartedPacket { LobbyId = lobby.Id, Players = lobby.Players }); BroadcastTo(connectionIds, session.BuildState()); + SendClock(session, connectionIds); } catch (Exception e) { Console.Error.WriteLine($"Couldn't start the game for lobby {lobby.Id}: {e}"); @@ -378,7 +383,7 @@ private async Task StartLobbyAsync(LobbyData lobby) { /// /// true if a session was found and the connection is a player in it private bool TryResolveSession(NetworkConnection connection, ulong gameId, - [NotNullWhen(true)] out GameSession? session, out int playerId, out GameActionResult result) { + [NotNullWhen(true)] out GameSession? session, out int playerId, out GameActionResult result, uint? expectedTurn = null) { session = FindSession(gameId, AccountId(connection)); if (session?.Game.Over == true) session.Join(AccountId(connection), connection.Id); if (session == null) { @@ -393,6 +398,12 @@ private bool TryResolveSession(NetworkConnection connection, ulong gameId, return false; } + if (expectedTurn.HasValue && expectedTurn.Value != session.Game.Turn && + (session.Clock?.Mode == TurnTimerMode.Live || expectedTurn.Value != 0)) { + result = GameActionResult.InvalidParameters; + return false; + } + result = GameActionResult.Ok; return true; } @@ -409,7 +420,7 @@ private async Task ManageGetGameStateAsync(NetworkConnection connection, GetGame private async Task ManageMoveTroopAsync(NetworkConnection connection, MoveTroopPacket packet) { { - if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) { + if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check, packet.ExpectedTurn)) { SendTo(connection.Id, new MoveTroopResponsePacket { Result = check }); return; } @@ -430,7 +441,7 @@ private async Task ManageMoveTroopAsync(NetworkConnection connection, MoveTroopP private async Task ManageAttackAsync(NetworkConnection connection, AttackPacket packet) { { - if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) { + if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check, packet.ExpectedTurn)) { SendTo(connection.Id, new AttackResponsePacket { Result = check }); return; } @@ -453,7 +464,7 @@ private async Task ManageAttackAsync(NetworkConnection connection, AttackPacket private async Task ManageTrainTroopAsync(NetworkConnection connection, TrainTroopPacket packet) { { - if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) { + if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check, packet.ExpectedTurn)) { SendTo(connection.Id, new TrainTroopResponsePacket { Result = check }); return; } @@ -474,7 +485,7 @@ private async Task ManageTrainTroopAsync(NetworkConnection connection, TrainTroo private async Task ManageResearchTechAsync(NetworkConnection connection, ResearchTechPacket packet) { { - if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) { + if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check, packet.ExpectedTurn)) { SendTo(connection.Id, new ResearchTechResponsePacket { Result = check }); return; } @@ -494,7 +505,7 @@ private async Task ManageResearchTechAsync(NetworkConnection connection, Researc private async Task ManageBuildAsync(NetworkConnection connection, BuildPacket packet) { { - if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) { + if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check, packet.ExpectedTurn)) { SendTo(connection.Id, new BuildResponsePacket { Result = check }); return; } @@ -515,7 +526,7 @@ private async Task ManageBuildAsync(NetworkConnection connection, BuildPacket pa private async Task ManageCaptureAsync(NetworkConnection connection, CapturePacket packet) { { - if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) { + if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check, packet.ExpectedTurn)) { SendTo(connection.Id, new CaptureResponsePacket { Result = check }); return; } @@ -542,7 +553,7 @@ private async Task ManageCaptureAsync(NetworkConnection connection, CapturePacke private async Task ManageEndTurnAsync(NetworkConnection connection, EndTurnPacket packet) { { - if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) { + if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check, packet.ExpectedTurn)) { SendTo(connection.Id, new EndTurnResponsePacket { Result = check }); return; } diff --git a/OpenPolytopia.Server/GameSession.cs b/OpenPolytopia.Server/GameSession.cs index 696941c9..2d6d7dcc 100644 --- a/OpenPolytopia.Server/GameSession.cs +++ b/OpenPolytopia.Server/GameSession.cs @@ -30,6 +30,9 @@ public class GameSession { /// public Game Game { get; } + /// The persistent turn clock, independent of connected clients. + public TurnClock? Clock { get; internal set; } + /// /// Connection id of every player in the game, keyed by player id /// diff --git a/OpenPolytopia.Server/README.md b/OpenPolytopia.Server/README.md index bec80d06..39bd6656 100644 --- a/OpenPolytopia.Server/README.md +++ b/OpenPolytopia.Server/README.md @@ -43,3 +43,26 @@ Gameplay requests require an open view and remain subject to the engine's owners Successful state-changing responses are queued only after SQLite commits. Lobby ids, membership, full game state and results survive a restart. Transports are deliberately absent from snapshots: reconnecting clients must authenticate and open their games again. + +## Turn timers + +Lobby creation accepts `TimerMode = 0` for Live or `1` for 24-hour turns (the default). +Live games start each player with a 60-second bank. Ending a turn adds 8 seconds plus 12 per owned city +and 1 per owned unit to the unused balance. Only the current player's clock runs. Expiry skips the turn; +three total live timeouts eliminate that player. Voluntary turns do not erase previous timeouts. +These values follow the developer's [published Live rules](https://steamcommunity.com/games/874390/announcements/detail/3487503395165170265). + +Daily games grant 24 hours for each turn and keep accepting the current player's actions after expiry. +Another living participant can send `ResolveOverdueTurnPacket` to skip or kick the overdue player. +The request includes the expected turn and player to reject stale requests. Daily skips do not automatically +eliminate players; kicking is an explicit action. The client provides both controls, with confirmation for kicks. + +`GameClockPacket` sends the absolute UTC deadline alongside the server's current UTC time. The client displays +a countdown using elapsed local monotonic time. Banks, timeout counts and deadlines are persisted with the game. +Leaving, disconnecting or restarting never resets a clock. On restart, overdue live turns are processed from +the original deadlines until play catches up or the match ends. The server checks expiry before processing +requests as well as once per second, so a late move or end-turn request cannot escape a timeout. + +Gameplay action packets carry `ExpectedTurn`. Live games require the current round number; mismatches are +rejected so a delayed packet cannot act in a later round after several automatic skips. Daily games accept +zero for clients that do not send a turn guard; a nonzero value must match in either mode. diff --git a/OpenPolytopia.Server/TurnClock.cs b/OpenPolytopia.Server/TurnClock.cs new file mode 100644 index 00000000..f7e44986 --- /dev/null +++ b/OpenPolytopia.Server/TurnClock.cs @@ -0,0 +1,494 @@ +namespace OpenPolytopia.Server; + +using System; +using System.Collections.Generic; +using System.Linq; + +/// +/// How the turns of a game are timed +/// +public enum TurnTimerMode { + /// + /// Everyone is online: every player owns a time bank that only ticks while it's their turn, and running it dry + /// costs a timeout + /// + Live, + + /// + /// Play by mail: every turn gets its own 24 hours deadline, nothing happens automatically when it passes + /// + Daily +} + +/// +/// What the caller should do with the turn the clock was asked about +/// +/// +/// The clock never touches the game: it only tells the caller what its own bookkeeping says, and the caller applies +/// it (or doesn't) to the +/// +public enum TurnClockDecision { + /// + /// Nothing to do: the turn is still running, or no turn is running at all + /// + None, + + /// + /// The player ended their own turn, no timeout was recorded + /// + Completed, + + /// + /// The turn is over on time, but the player didn't end it themselves, so a timeout was recorded against them + /// + TimedOut, + + /// + /// The last timeout put the player over : the caller should + /// eliminate them + /// + Eliminated, + + /// + /// only: the deadline of the running turn has passed + /// + /// + /// Nothing happens on its own; some participant has to ask for it through or + /// + /// + Overdue +} + +/// +/// Serializable clock state of a single player +/// +/// +/// Plain mutable properties on purpose: this is what gets written to disk / sent over the wire, so it has to survive +/// any run of the mill serializer +/// +public class TurnClockPlayerState { + /// + /// Id of the player this state belongs to + /// + public int PlayerId { get; set; } + + /// + /// Seconds left in the player's bank, not counting the turn they may be playing right now + /// + /// + /// Only meaningful in ; while the player is on turn, the authoritative value is + /// , and this one is refreshed when the turn ends + /// + public double RemainingSeconds { get; set; } + + /// + /// How many turns this player failed to end themselves + /// + public int Timeouts { get; set; } + + /// + /// Whether the clock already told the caller to eliminate this player + /// + public bool Eliminated { get; set; } +} + +/// +/// Serializable state of a whole +/// +/// +/// Round tripping this through gives back a clock that behaves exactly like the +/// one it came from: deadlines are absolute UTC instants, so a reconnect or a server restart doesn't hand anyone +/// free time +/// +public class TurnClockState { + /// + /// The timing mode of the game + /// + public TurnTimerMode Mode { get; set; } + + /// + /// Id of the player currently on turn; 0 when no turn is running + /// + public int ActivePlayer { get; set; } + + /// + /// When the running turn started, in UTC; null when no turn is running + /// + public DateTimeOffset? TurnStartedUtc { get; set; } + + /// + /// The absolute UTC instant the running turn expires at; null when no turn is running + /// + public DateTimeOffset? DeadlineUtc { get; set; } + + /// + /// Clock state of every player in the game + /// + public List Players { get; set; } = []; +} + +/// +/// Keeps track of how long every player is taking, without knowing anything about the game itself +/// +/// +/// +/// The clock is a pure bookkeeper: it's fed the current instant by the caller (nothing here reads the system clock, +/// which is what makes it testable), it never mutates a game and it never advances a turn on its own. Everything it +/// has to say comes back as a the caller is free to act on. +/// +/// +/// Typical server loop: +/// +/// var clock = new TurnClock(TurnTimerMode.Live, game.Players.Select(player => player.Id)); +/// clock.Begin(game.CurrentPlayer, DateTimeOffset.UtcNow); +/// // ...on every tick: +/// if (clock.Poll(now) == TurnClockDecision.TimedOut) { +/// var decision = clock.Skip(cities, units, now); +/// game.NextTurn(); +/// if (decision == TurnClockDecision.Eliminated) { +/// game.Eliminate(playerId); +/// } +/// } +/// +/// +/// +public class TurnClock { + /// + /// Seconds every player starts the game with in their bank + /// + public const double INITIAL_BANK_SECONDS = 60d; + + /// + /// Seconds added to the bank for every turn played + /// + public const double TURN_BONUS_SECONDS = 8d; + + /// + /// Seconds added to the bank for every city the player owns when the turn ends + /// + public const double CITY_BONUS_SECONDS = 12d; + + /// + /// Seconds added to the bank for every unit the player owns when the turn ends + /// + public const double UNIT_BONUS_SECONDS = 1d; + + /// + /// How many timeouts a player is allowed to rack up before the clock asks for their elimination + /// + public const int TIMEOUTS_BEFORE_ELIMINATION = 3; + + /// + /// How long a turn lasts + /// + public static readonly TimeSpan DailyTurnLength = TimeSpan.FromHours(24); + + private readonly Dictionary _players; + + /// + /// The timing mode of this clock + /// + public TurnTimerMode Mode { get; } + + /// + /// Id of the player currently on turn; 0 when no turn is running + /// + public int ActivePlayer { get; private set; } + + /// + /// When the running turn started, in UTC; null when no turn is running + /// + public DateTimeOffset? TurnStartedUtc { get; private set; } + + /// + /// The absolute UTC instant the running turn expires at; null when no turn is running + /// + /// + /// This is what gets persisted, and it's never recomputed from a duration: a restart in the middle of a turn + /// resumes against the very same instant + /// + public DateTimeOffset? DeadlineUtc { get; private set; } + + /// + /// Whether a turn is currently being timed + /// + public bool Running => ActivePlayer != 0; + + /// + /// Clock state of every player, keyed by player id + /// + public IReadOnlyDictionary Players => _players; + + /// + /// Builds a clock for a fresh game + /// + /// how turns are timed + /// the ids of every player in the game + public TurnClock(TurnTimerMode mode, IEnumerable playerIds) { + if (!Enum.IsDefined(mode)) throw new ArgumentOutOfRangeException(nameof(mode)); + ArgumentNullException.ThrowIfNull(playerIds); + Mode = mode; + _players = playerIds.ToDictionary( + playerId => playerId, + playerId => new TurnClockPlayerState { PlayerId = playerId, RemainingSeconds = INITIAL_BANK_SECONDS }); + } + + private TurnClock(TurnClockState state) { + ArgumentNullException.ThrowIfNull(state); + if (!Enum.IsDefined(state.Mode) || state.Players == null || state.Players.Count is < 1 or > 16 || + state.Players.Any(p => p.PlayerId is < 1 or > 16 || !double.IsFinite(p.RemainingSeconds) || + p.RemainingSeconds < 0 || p.Timeouts < 0) || + (state.ActivePlayer == 0 && (state.DeadlineUtc != null || state.TurnStartedUtc != null)) || + (state.ActivePlayer != 0 && (state.DeadlineUtc == null || state.TurnStartedUtc == null || + state.DeadlineUtc < state.TurnStartedUtc || + !state.Players.Any(p => p.PlayerId == state.ActivePlayer && !p.Eliminated)))) { + throw new ArgumentException("Invalid saved clock", nameof(state)); + } + Mode = state.Mode; + ActivePlayer = state.ActivePlayer; + TurnStartedUtc = state.TurnStartedUtc?.ToUniversalTime(); + DeadlineUtc = state.DeadlineUtc?.ToUniversalTime(); + _players = state.Players.ToDictionary( + player => player.PlayerId, + player => new TurnClockPlayerState { + PlayerId = player.PlayerId, + RemainingSeconds = player.RemainingSeconds, + Timeouts = player.Timeouts, + Eliminated = player.Eliminated + }); + } + + /// + /// Rebuilds a clock from a persisted snapshot + /// + /// the snapshot, as returned by + /// a clock that carries on exactly where the snapshotted one left off + public static TurnClock FromSnapshot(TurnClockState state) => new(state); + + /// + /// Copies out the whole state of the clock, ready to be serialized + /// + /// + /// The returned object shares nothing with the clock, so mutating it afterwards is harmless + /// + /// the snapshot + public TurnClockState ToSnapshot() => new() { + Mode = Mode, + ActivePlayer = ActivePlayer, + TurnStartedUtc = TurnStartedUtc, + DeadlineUtc = DeadlineUtc, + Players = [ + .. _players.Values.Select(player => new TurnClockPlayerState { + PlayerId = player.PlayerId, + RemainingSeconds = player.RemainingSeconds, + Timeouts = player.Timeouts, + Eliminated = player.Eliminated + }) + ] + }; + + /// + /// Starts timing a turn + /// + /// + /// In the deadline is whatever is left in the player's bank; in + /// it's from now. Only the player passed here has + /// a clock running: everyone else's bank is frozen + /// + /// the player whose turn it is + /// the current instant + /// if a turn is already running + /// if the player isn't part of this clock, or was already eliminated + public void Begin(int playerId, DateTimeOffset now) { + if (Running) { + throw new InvalidOperationException($"turn of player {ActivePlayer} is still running"); + } + + var player = PlayerOrThrow(playerId); + if (player.Eliminated) { + throw new ArgumentException($"player {playerId} was eliminated", nameof(playerId)); + } + + var start = now.ToUniversalTime(); + ActivePlayer = playerId; + TurnStartedUtc = start; + DeadlineUtc = start + (Mode == TurnTimerMode.Daily + ? DailyTurnLength + : TimeSpan.FromSeconds(Math.Max(0d, player.RemainingSeconds))); + } + + /// + /// Whether the running turn is past its deadline + /// + /// the current instant + /// true if a turn is running and its deadline has passed + public bool IsExpired(DateTimeOffset now) => DeadlineUtc is { } deadline && now.ToUniversalTime() >= deadline; + + /// + /// Asks the clock what it thinks of the running turn, without changing anything + /// + /// the current instant + /// + /// if a turn ran out of bank, + /// if a turn is past its deadline, + /// otherwise + /// + public TurnClockDecision Poll(DateTimeOffset now) { + if (!Running || !IsExpired(now)) { + return TurnClockDecision.None; + } + + return Mode == TurnTimerMode.Daily ? TurnClockDecision.Overdue : TurnClockDecision.TimedOut; + } + + /// + /// How much time the running turn has left + /// + /// the current instant + /// if no turn is running or the deadline already passed + public TimeSpan Remaining(DateTimeOffset now) { + if (DeadlineUtc is not { } deadline) { + return TimeSpan.Zero; + } + + var left = deadline - now.ToUniversalTime(); + return left > TimeSpan.Zero ? left : TimeSpan.Zero; + } + + /// + /// How much time a player has banked + /// + /// + /// For the player on turn this is the live value, i.e. it shrinks as the turn goes on; for everybody else it's + /// frozen. Always outside of , where banks are unused + /// + /// the player to look at + /// the current instant + /// the seconds left in the player's bank, never negative + /// if the player isn't part of this clock + public TimeSpan BankOf(int playerId, DateTimeOffset now) { + var player = PlayerOrThrow(playerId); + if (Mode == TurnTimerMode.Daily) { + return TimeSpan.Zero; + } + + return playerId == ActivePlayer ? Remaining(now) : TimeSpan.FromSeconds(Math.Max(0d, player.RemainingSeconds)); + } + + /// + /// Ends the running turn because the player asked to + /// + /// + /// This never counts as a timeout, not even if the deadline slipped by while the packet was in flight: the player + /// did play their turn + /// + /// how many cities the player owns now + /// how many units the player owns now + /// the current instant + /// always + /// if no turn is running + public TurnClockDecision Complete(int cities, int units, DateTimeOffset now) { + EndTurn(cities, units, now); + return TurnClockDecision.Completed; + } + + /// + /// Ends the running turn against the will of the player, recording a timeout + /// + /// + /// In this is what the server calls once reports + /// ; in it's what a participant asking + /// to skip an turn triggers. The turn still earns its bonus, otherwise a + /// single timeout would leave the player with an empty bank forever + /// + /// how many cities the player owns now + /// how many units the player owns now + /// the current instant + /// + /// if this was the player's + /// th timeout, otherwise + /// + /// if no turn is running, or the turn isn't expired yet + public TurnClockDecision Skip(int cities, int units, DateTimeOffset now) { + if (!Running) { + throw new InvalidOperationException("no turn is running"); + } + + if (!IsExpired(now)) { + throw new InvalidOperationException($"turn of player {ActivePlayer} hasn't expired yet"); + } + + var player = _players[ActivePlayer]; + EndTurn(cities, units, now); + + player.Timeouts++; + if (Mode == TurnTimerMode.Daily || player.Timeouts < TIMEOUTS_BEFORE_ELIMINATION) { + return TurnClockDecision.TimedOut; + } + + player.Eliminated = true; + return TurnClockDecision.Eliminated; + } + + /// + /// Drops the player of an expired turn outright, without waiting for their remaining timeouts + /// + /// + /// Meant for the kick a participant can ask for on an + /// turn; as everything else here it only marks the clock's own state, the caller + /// is the one who has to remove the player from the game + /// + /// the current instant + /// always + /// if no turn is running, or the turn isn't expired yet + public TurnClockDecision Kick(DateTimeOffset now) { + if (!Running) { + throw new InvalidOperationException("no turn is running"); + } + + if (!IsExpired(now)) { + throw new InvalidOperationException($"turn of player {ActivePlayer} hasn't expired yet"); + } + + var player = _players[ActivePlayer]; + EndTurn(0, 0, now); + + player.Timeouts = TIMEOUTS_BEFORE_ELIMINATION; + player.Eliminated = true; + return TurnClockDecision.Eliminated; + } + + /// + /// Banks the earned time and stops the clock + /// + /// how many cities the player owns now + /// how many units the player owns now + /// the current instant + /// if no turn is running + private void EndTurn(int cities, int units, DateTimeOffset now) { + if (!Running) { + throw new InvalidOperationException("no turn is running"); + } + + var player = _players[ActivePlayer]; + if (Mode == TurnTimerMode.Live) { + var bonus = TURN_BONUS_SECONDS + (CITY_BONUS_SECONDS * cities) + (UNIT_BONUS_SECONDS * units); + player.RemainingSeconds = Remaining(now).TotalSeconds + bonus; + } + + ActivePlayer = 0; + TurnStartedUtc = null; + DeadlineUtc = null; + } + + /// + /// Looks up a player's state + /// + /// the player to look for + /// the player's clock state + /// if the player isn't part of this clock + private TurnClockPlayerState PlayerOrThrow(int playerId) => + _players.TryGetValue(playerId, out var player) + ? player + : throw new ArgumentException($"player {playerId} isn't part of this clock", nameof(playerId)); +} diff --git a/OpenPolytopia.UnitTest/GameServerTestHarness.cs b/OpenPolytopia.UnitTest/GameServerTestHarness.cs index a65d9386..30a870e8 100644 --- a/OpenPolytopia.UnitTest/GameServerTestHarness.cs +++ b/OpenPolytopia.UnitTest/GameServerTestHarness.cs @@ -31,10 +31,10 @@ internal sealed class TestServer : IAsyncDisposable { /// Path of the SQLite database backing this server public string DatabasePath { get; } - private TestServer(string databasePath, int port) { + private TestServer(string databasePath, int port, TimeProvider? timeProvider = null) { DatabasePath = databasePath; Port = port; - _server = new GameServer(port, "127.0.0.1", databasePath); + _server = new GameServer(port, "127.0.0.1", databasePath, timeProvider); _run = _server.RunAsync(); } @@ -42,9 +42,9 @@ private TestServer(string databasePath, int port) { /// Starts a server and waits until its listener actually accepts connections /// /// the SQLite file to use; an existing one gets restored - public static async Task StartAsync(string databasePath) { + public static async Task StartAsync(string databasePath, TimeProvider? timeProvider = null) { PacketRegistrar.RegisterAllPackets(); - var server = new TestServer(databasePath, FreePort()); + var server = new TestServer(databasePath, FreePort(), timeProvider); await server.WaitUntilListeningAsync(); return server; } @@ -319,8 +319,8 @@ internal static class TestGames { /// the map generation /// /// the id of the started game, i.e. the id of the lobby - public static async Task StartGameAsync(TestClient host, TestClient guest) { - await host.SendAsync(new CreateLobbyPacket { MaxPlayers = 2, WorldSize = WORLD_SIZE, Tribe = 0 }); + public static async Task StartGameAsync(TestClient host, TestClient guest, uint timerMode = 1) { + await host.SendAsync(new CreateLobbyPacket { MaxPlayers = 2, WorldSize = WORLD_SIZE, Tribe = 0, TimerMode = timerMode }); var created = await host.ExpectAsync(); created.Result.ShouldBe(LobbyActionResult.Ok); diff --git a/OpenPolytopia.UnitTest/GameServerTimerIntegrationTest.cs b/OpenPolytopia.UnitTest/GameServerTimerIntegrationTest.cs new file mode 100644 index 00000000..c80da422 --- /dev/null +++ b/OpenPolytopia.UnitTest/GameServerTimerIntegrationTest.cs @@ -0,0 +1,443 @@ +namespace OpenPolytopia; + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Common; +using Common.Gameplay; +using Common.Network.Packets; +using Server; +using Shouldly; + +/// +/// A that only moves when a test says so +/// +/// +/// The server reads the clock from its packet handlers and from its background loop, so the instant has to be +/// readable from any thread; ticks are a single long, which can publish atomically +/// +internal sealed class ManualTimeProvider(DateTimeOffset start) : TimeProvider { + private long _utcTicks = start.UtcTicks; + + public override DateTimeOffset GetUtcNow() => new(Interlocked.Read(ref _utcTicks), TimeSpan.Zero); + + /// Moves the clock forward; every deadline already handed out stays where it is + public void Advance(TimeSpan delta) => Interlocked.Add(ref _utcTicks, delta.Ticks); + + /// The current instant, in the unit uses + public long UnixMilliseconds => GetUtcNow().ToUnixTimeMilliseconds(); +} + +/// +/// End to end tests of the turn timers, over a real loopback socket and a real SQLite database +/// +/// +/// +/// Time is the only thing faked here: the server gets a nobody but the test moves, +/// so an expiry happens exactly when a test asks for it and never because a machine was slow. Everything else is +/// real, assertions included: they're made on the packets a client actually receives. +/// +/// +/// The server applies expired live timers at the top of every request and once per second from its background loop, +/// so after an the tests wait for the packets instead of sleeping. +/// +/// +public class GameServerTimerIntegrationTest { + /// A fixed instant, so a failure reports the same numbers on every machine + private static readonly DateTimeOffset _epoch = new(2026, 3, 1, 9, 0, 0, TimeSpan.Zero); + + /// of a game whose turns are timed by a bank + private const uint LIVE = (uint)TurnTimerMode.Live; + + /// of a game whose turns last 24 hours + private const uint DAILY = (uint)TurnTimerMode.Daily; + + private static readonly TimeSpan INITIAL_BANK = TimeSpan.FromSeconds(TurnClock.INITIAL_BANK_SECONDS); + + /// Long enough to outlast a few ticks of the server's one second loop + private static readonly TimeSpan QUIET = TimeSpan.FromSeconds(3); + + #region Live timers + + [Fact] + public async Task TestLiveDeadlineSurvivesAReconnectAndARestart() { + using var database = new TempDatabase(); + var time = new ManualTimeProvider(_epoch); + var aliceName = TestGames.UniqueName("timer_alice"); + + ulong gameId; + string aliceToken; + long deadline; + + await using (var first = await TestServer.StartAsync(database.Path, time)) { + using var bob = await TestClient.ConnectAsync(first); + await bob.RegisterAsync(TestGames.UniqueName("timer_bob"), TestGames.PASSWORD); + + // scoped, so the transport of player 1 is really gone before she comes back on a new one + using (var alice = await TestClient.ConnectAsync(first)) { + aliceToken = (await alice.RegisterAsync(aliceName, TestGames.PASSWORD)).Token; + gameId = await TestGames.StartGameAsync(alice, bob, LIVE); + + // the first turn is timed by player 1's untouched bank, and the deadline is an absolute instant + var started = await ClockAsync(alice, gameId, playerId: 1, notBefore: time.UnixMilliseconds); + started.TimerMode.ShouldBe(LIVE); + started.ServerUnixMilliseconds.ShouldBe(time.UnixMilliseconds); + started.DeadlineUnixMilliseconds.ShouldBe(time.UnixMilliseconds + (long)INITIAL_BANK.TotalMilliseconds); + deadline = started.DeadlineUnixMilliseconds; + + // a third of the bank is burned while the owner of the turn walks away + time.Advance(TimeSpan.FromSeconds(20)); + } + + using var reconnected = await TestClient.ConnectAsync(first); + (await reconnected.ResumeAsync(aliceToken)).Ok.ShouldBeTrue(); + await reconnected.SendAsync(new JoinGamePacket { GameId = gameId }); + (await reconnected.ExpectAsync(packet => packet.Result == GameActionResult.Ok)).CurrentPlayer + .ShouldBe(1u); + + // the very same deadline: being away doesn't refill the bank, and coming back doesn't either + var resumed = await ClockAsync(reconnected, gameId, playerId: 1, notBefore: time.UnixMilliseconds); + resumed.DeadlineUnixMilliseconds.ShouldBe(deadline); + resumed.ServerUnixMilliseconds.ShouldBe(time.UnixMilliseconds); + (resumed.DeadlineUnixMilliseconds - resumed.ServerUnixMilliseconds).ShouldBe(40_000); + } + + // the server goes away for another 20 seconds of game time, which the bank has to pay for as well + time.Advance(TimeSpan.FromSeconds(20)); + + await using var second = await TestServer.StartAsync(database.Path, time); + using var restarted = await TestClient.ConnectAsync(second); + (await restarted.ResumeAsync(aliceToken)).Ok.ShouldBeTrue(); + await restarted.SendAsync(new JoinGamePacket { GameId = gameId }); + var state = await restarted.ExpectAsync(packet => packet.Result == GameActionResult.Ok); + state.CurrentPlayer.ShouldBe(1u); + state.Over.ShouldBeFalse(); + + var restored = await ClockAsync(restarted, gameId, playerId: 1, notBefore: time.UnixMilliseconds); + restored.DeadlineUnixMilliseconds.ShouldBe(deadline); + (restored.DeadlineUnixMilliseconds - restored.ServerUnixMilliseconds).ShouldBe(20_000); + + // and the restored deadline is still in the future, so nothing expires on its own + (await restarted.TryReceiveAsync(null, QUIET)).ShouldBeNull(); + (await restarted.GetStateAsync(gameId)).CurrentPlayer.ShouldBe(1u); + } + + [Fact] + public async Task TestALateEndTurnCannotUndoALiveTimeout() { + using var database = new TempDatabase(); + var time = new ManualTimeProvider(_epoch); + await using var server = await TestServer.StartAsync(database.Path, time); + + using var alice = await TestClient.ConnectAsync(server); + await alice.RegisterAsync(TestGames.UniqueName("late_alice"), TestGames.PASSWORD); + using var bob = await TestClient.ConnectAsync(server); + await bob.RegisterAsync(TestGames.UniqueName("late_bob"), TestGames.PASSWORD); + + var gameId = await TestGames.StartGameAsync(alice, bob, LIVE); + var before = await alice.GetStateAsync(gameId); + before.CurrentPlayer.ShouldBe(1u); + + // one second past the deadline: whether the loop or the request itself notices, the turn is already gone + time.Advance(INITIAL_BANK + TimeSpan.FromSeconds(1)); + + // the round the packet names is still the one running, so it's the expired clock that refuses it and + // nothing else + await alice.SendAsync(new EndTurnPacket { GameId = gameId, ExpectedTurn = before.Turn }); + (await alice.ExpectAsync()).Result.ShouldBe(GameActionResult.NotYourTurn); + + var passed = await bob.ExpectAsync(packet => packet.GameId == gameId); + passed.PlayerId.ShouldBe(2u); + passed.Turn.ShouldBe(before.Turn); + + // the authoritative state agrees: the late packet changed nothing at all + var after = await alice.GetStateAsync(gameId); + after.CurrentPlayer.ShouldBe(2u); + after.Turn.ShouldBe(before.Turn); + after.Over.ShouldBeFalse(); + after.Players.ShouldAllBe(player => player.Alive); + + // player 2 got a full bank, timed from the deadline player 1 blew and not from now + var handed = await ClockAsync(bob, gameId, playerId: 2, notBefore: time.UnixMilliseconds); + handed.DeadlineUnixMilliseconds.ShouldBe( + _epoch.ToUnixTimeMilliseconds() + (long)(2 * INITIAL_BANK.TotalMilliseconds)); + + // and the timeout stuck: when the turn comes back, player 1 only has the bonus of the turn she never played + await bob.SendAsync(new EndTurnPacket { GameId = gameId, ExpectedTurn = after.Turn }); + (await bob.ExpectAsync()).Result.ShouldBe(GameActionResult.Ok); + + var punished = await ClockAsync(alice, gameId, playerId: 1, notBefore: time.UnixMilliseconds); + var left = punished.DeadlineUnixMilliseconds - punished.ServerUnixMilliseconds; + left.ShouldBeGreaterThan(0); + left.ShouldBeLessThan((long)INITIAL_BANK.TotalMilliseconds); + } + + [Fact] + public async Task TestEndingATurnVoluntarilyBanksTheBonus() { + using var database = new TempDatabase(); + var time = new ManualTimeProvider(_epoch); + await using var server = await TestServer.StartAsync(database.Path, time); + + using var alice = await TestClient.ConnectAsync(server); + await alice.RegisterAsync(TestGames.UniqueName("bank_alice"), TestGames.PASSWORD); + using var bob = await TestClient.ConnectAsync(server); + await bob.RegisterAsync(TestGames.UniqueName("bank_bob"), TestGames.PASSWORD); + + var gameId = await TestGames.StartGameAsync(alice, bob, LIVE); + var before = await alice.GetStateAsync(gameId); + + // player 1 thinks for ten seconds and then ends her turn herself, well inside the deadline + time.Advance(TimeSpan.FromSeconds(10)); + await alice.SendAsync(new EndTurnPacket { GameId = gameId, ExpectedTurn = before.Turn }); + (await alice.ExpectAsync()).Result.ShouldBe(GameActionResult.Ok); + var passed = await bob.ExpectAsync(packet => packet.GameId == gameId); + passed.PlayerId.ShouldBe(2u); + + // player 2's bank was frozen while it wasn't his turn + var handed = await ClockAsync(bob, gameId, playerId: 2, notBefore: time.UnixMilliseconds); + (handed.DeadlineUnixMilliseconds - handed.ServerUnixMilliseconds).ShouldBe((long)INITIAL_BANK.TotalMilliseconds); + + time.Advance(TimeSpan.FromSeconds(5)); + await bob.SendAsync(new EndTurnPacket { GameId = gameId, ExpectedTurn = passed.Turn }); + (await bob.ExpectAsync()).Result.ShouldBe(GameActionResult.Ok); + + // player 1 gets back what she didn't spend plus the bonus of a turn played, so more than she started with + var refilled = await ClockAsync(alice, gameId, playerId: 1, notBefore: time.UnixMilliseconds); + var left = refilled.DeadlineUnixMilliseconds - refilled.ServerUnixMilliseconds; + var unspent = (long)INITIAL_BANK.TotalMilliseconds - 10_000; + // every player starts with a capital and the warrior spawned on it, so the bonus is at least this much + var minimumBonus = (long)(TurnClock.TURN_BONUS_SECONDS + TurnClock.CITY_BONUS_SECONDS + + TurnClock.UNIT_BONUS_SECONDS) * 1000; + left.ShouldBeGreaterThanOrEqualTo(unspent + minimumBonus); + left.ShouldBeGreaterThan((long)INITIAL_BANK.TotalMilliseconds); + } + + [Fact] + public async Task TestThreeLiveTimeoutsEliminateThePlayer() { + using var database = new TempDatabase(); + var time = new ManualTimeProvider(_epoch); + await using var server = await TestServer.StartAsync(database.Path, time); + + using var alice = await TestClient.ConnectAsync(server); + await alice.RegisterAsync(TestGames.UniqueName("afk_alice"), TestGames.PASSWORD); + using var bob = await TestClient.ConnectAsync(server); + await bob.RegisterAsync(TestGames.UniqueName("afk_bob"), TestGames.PASSWORD); + + var gameId = await TestGames.StartGameAsync(alice, bob, LIVE); + + // one blown deadline costs a turn, not the game + time.Advance(INITIAL_BANK); + (await bob.ExpectAsync(packet => packet.GameId == gameId)).PlayerId.ShouldBe(2u); + (await bob.TryReceiveAsync(null, QUIET)).ShouldBeNull(); + (await bob.GetStateAsync(gameId)).Players.ShouldAllBe(player => player.Alive); + + // nobody plays for two hours: the server catches up on the deadlines it slept through, one turn at a time, + // and every one of them costs its owner another timeout + time.Advance(TimeSpan.FromHours(2)); + + // exactly three more turns change hands: the second timeout of each player and the third of player 1, which + // is the one that ends her game. A single advance, or one keyed off the wall clock, would produce fewer + foreach (var expected in new[] { 1u, 2u, 1u }) { + (await bob.ExpectAsync(packet => packet.GameId == gameId)).PlayerId.ShouldBe(expected); + } + + var eliminated = await bob.ExpectAsync(packet => packet.GameId == gameId); + eliminated.PlayerId.ShouldBe(1u); + + var over = await bob.ExpectAsync(packet => packet.GameId == gameId); + over.Winner.ShouldBe(2u); + over.Players.Single(player => player.PlayerId == 1).Alive.ShouldBeFalse(); + over.Players.Single(player => player.PlayerId == 2).Alive.ShouldBeTrue(); + + // the catch-up stops at the elimination instead of running on to the wall clock + (await bob.TryReceiveAsync(null, QUIET)).ShouldBeNull(); + + var final = await alice.GetStateAsync(gameId); + final.Over.ShouldBeTrue(); + final.Winner.ShouldBe(2u); + } + + #endregion + + #region Daily timers + + [Fact] + public async Task TestAnOverdueDailyTurnOnlyMovesWhenAnEligibleMemberAsks() { + using var database = new TempDatabase(); + var time = new ManualTimeProvider(_epoch); + await using var server = await TestServer.StartAsync(database.Path, time); + + using var alice = await TestClient.ConnectAsync(server); + await alice.RegisterAsync(TestGames.UniqueName("daily_alice"), TestGames.PASSWORD); + using var bob = await TestClient.ConnectAsync(server); + await bob.RegisterAsync(TestGames.UniqueName("daily_bob"), TestGames.PASSWORD); + using var carol = await TestClient.ConnectAsync(server); + await carol.RegisterAsync(TestGames.UniqueName("daily_carol"), TestGames.PASSWORD); + + var gameId = await StartThreePlayerGameAsync(alice, bob, carol); + var opened = await ClockAsync(alice, gameId, playerId: 1, notBefore: time.UnixMilliseconds); + opened.TimerMode.ShouldBe(DAILY); + opened.DeadlineUnixMilliseconds.ShouldBe( + time.UnixMilliseconds + (long)TurnClock.DailyTurnLength.TotalMilliseconds); + + var before = await alice.GetStateAsync(gameId); + before.Turn.ShouldBe(1u); + before.CurrentPlayer.ShouldBe(1u); + + // player 3 walks out of the game while player 1 still holds the turn + await carol.SendAsync(new ResignGamePacket { GameId = gameId }); + (await carol.ExpectAsync()).Result.ShouldBe(GameActionResult.Ok); + foreach (var client in new[] { alice, bob, carol }) { + (await client.ExpectAsync(packet => packet.GameId == gameId)).PlayerId.ShouldBe(3u); + // the resignation broadcasts a fresh full state; consume it so the later requests read their own answers + (await client.ExpectAsync(packet => packet.GameId == gameId)).Over.ShouldBeFalse(); + } + + time.Advance(TurnClock.DailyTurnLength + TimeSpan.FromHours(1)); + + // the player sitting on the overdue turn can't skip herself out of it + await alice.SendAsync(Resolve(gameId, kick: false, turn: before.Turn, player: 1)); + (await alice.ExpectAsync()).Result.ShouldBe(GameActionResult.InvalidParameters); + + // neither can a player who is out of the game + await carol.SendAsync(Resolve(gameId, kick: false, turn: before.Turn, player: 1)); + (await carol.ExpectAsync()).Result.ShouldBe(GameActionResult.PlayerEliminated); + + // nor anybody who was never in it + using var stranger = await TestClient.ConnectAsync(server); + await stranger.RegisterAsync(TestGames.UniqueName("daily_stranger"), TestGames.PASSWORD); + await stranger.SendAsync(Resolve(gameId, kick: false, turn: before.Turn, player: 1)); + (await stranger.ExpectAsync()).Result.ShouldBe(GameActionResult.NotInGame); + + await bob.SendAsync(Resolve(gameId + 1000, kick: false, turn: before.Turn, player: 1)); + (await bob.ExpectAsync()).Result.ShouldBe(GameActionResult.GameNotFound); + + // an opponent describing a turn that isn't the one running gets nothing either + await bob.SendAsync(Resolve(gameId, kick: false, turn: before.Turn + 7, player: 1)); + (await bob.ExpectAsync()).Result.ShouldBe(GameActionResult.InvalidParameters); + await bob.SendAsync(Resolve(gameId, kick: false, turn: before.Turn, player: 2)); + (await bob.ExpectAsync()).Result.ShouldBe(GameActionResult.InvalidParameters); + + // ...and the turn really didn't move under any of those refusals + (await alice.GetStateAsync(gameId)).CurrentPlayer.ShouldBe(1u); + + // the opponent naming the running turn does move it + await bob.SendAsync(Resolve(gameId, kick: false, turn: before.Turn, player: 1)); + (await bob.ExpectAsync(packet => packet.GameId == gameId)).Result + .ShouldBe(GameActionResult.Ok); + + var started = await alice.ExpectAsync(packet => packet.GameId == gameId); + started.PlayerId.ShouldBe(2u); + var handed = await bob.ExpectAsync(packet => + packet.GameId == gameId && packet.PlayerId == 2 && packet.ServerUnixMilliseconds >= time.UnixMilliseconds); + handed.DeadlineUnixMilliseconds.ShouldBe( + time.UnixMilliseconds + (long)TurnClock.DailyTurnLength.TotalMilliseconds); + + // skipping is not eliminating: player 1 is still alive, just out of the turn she sat on + var after = await alice.GetStateAsync(gameId); + after.CurrentPlayer.ShouldBe(2u); + after.Players.Single(player => player.PlayerId == 1).Alive.ShouldBeTrue(); + + // replaying the request that just succeeded is stale now, and the state it described is gone + await bob.SendAsync(Resolve(gameId, kick: false, turn: before.Turn, player: 1)); + (await bob.ExpectAsync()).Result.ShouldBe(GameActionResult.InvalidParameters); + (await alice.GetStateAsync(gameId)).CurrentPlayer.ShouldBe(2u); + } + + [Fact] + public async Task TestADailyTurnNeverExpiresOnItsOwnAndAKickEndsATwoPlayerGame() { + using var database = new TempDatabase(); + var time = new ManualTimeProvider(_epoch); + await using var server = await TestServer.StartAsync(database.Path, time); + + using var alice = await TestClient.ConnectAsync(server); + await alice.RegisterAsync(TestGames.UniqueName("kick_alice"), TestGames.PASSWORD); + using var bob = await TestClient.ConnectAsync(server); + await bob.RegisterAsync(TestGames.UniqueName("kick_bob"), TestGames.PASSWORD); + + var gameId = await TestGames.StartGameAsync(alice, bob, DAILY); + var before = await alice.GetStateAsync(gameId); + + // a day and a half with nobody playing: a daily deadline passing is not, by itself, an event + time.Advance(TurnClock.DailyTurnLength + TimeSpan.FromHours(12)); + + (await bob.TryReceiveAsync(null, QUIET)).ShouldBeNull(); + (await bob.TryReceiveAsync(null, TimeSpan.Zero)).ShouldBeNull(); + (await bob.TryReceiveAsync(null, TimeSpan.Zero)).ShouldBeNull(); + + var idle = await bob.GetStateAsync(gameId); + idle.Turn.ShouldBe(before.Turn); + idle.CurrentPlayer.ShouldBe(before.CurrentPlayer); + idle.Over.ShouldBeFalse(); + + // the opponent has had enough and drops the absent player outright + await bob.SendAsync(Resolve(gameId, kick: true, turn: before.Turn, player: 1)); + (await bob.ExpectAsync(packet => packet.GameId == gameId)).Result + .ShouldBe(GameActionResult.Ok); + + foreach (var client in new[] { alice, bob }) { + (await client.ExpectAsync(packet => packet.GameId == gameId)).PlayerId.ShouldBe(1u); + var over = await client.ExpectAsync(packet => packet.GameId == gameId); + over.Winner.ShouldBe(2u); + over.Players.Single(player => player.PlayerId == 1).Alive.ShouldBeFalse(); + } + + // the game is finished, so there's nothing left to resolve and no clock to hand out + var final = await alice.GetStateAsync(gameId); + final.Over.ShouldBeTrue(); + final.Winner.ShouldBe(2u); + + await bob.SendAsync(Resolve(gameId, kick: true, turn: before.Turn, player: 1)); + (await bob.ExpectAsync()).Result.ShouldBe(GameActionResult.GameOver); + + // the predicate skips the clock of the turn the kick ended: a finished game hands out no new one + (await bob.TryReceiveAsync( + packet => packet.ServerUnixMilliseconds >= time.UnixMilliseconds, QUIET)).ShouldBeNull(); + } + + #endregion + + private static ResolveOverdueTurnPacket Resolve(ulong gameId, bool kick, uint turn, uint player) => + new() { GameId = gameId, Kick = kick, ExpectedTurn = turn, ExpectedPlayer = player }; + + /// + /// Waits for the clock of a turn, ignoring the ones the server sent before + /// + /// + /// Every turn change broadcasts a clock, and the buffered ones are still there; matching on the server instant is + /// what keeps a test from asserting on the clock of a turn that already ended + /// + private static Task ClockAsync(TestClient client, ulong gameId, uint playerId, long notBefore) => + client.ExpectAsync(packet => packet.GameId == gameId && packet.PlayerId == playerId && + packet.ServerUnixMilliseconds >= notBefore); + + /// + /// Starts a three player daily game, so a player can be eliminated without the game ending + /// + /// the id of the started game + private static async Task StartThreePlayerGameAsync(TestClient host, TestClient second, TestClient third) { + await host.SendAsync(new CreateLobbyPacket { + MaxPlayers = 3, WorldSize = TestGames.WORLD_SIZE, Tribe = 0, TimerMode = DAILY + }); + var created = await host.ExpectAsync(); + created.Result.ShouldBe(LobbyActionResult.Ok); + + foreach (var client in new[] { second, third }) { + await client.SendAsync(new JoinLobbyPacket { LobbyId = created.LobbyId, Tribe = 0 }); + (await client.ExpectAsync()).Result.ShouldBe(LobbyActionResult.Ok); + } + + foreach (var client in new[] { host, second, third }) { + await client.SendAsync(new SetReadyPacket { LobbyId = created.LobbyId, Ready = true }); + (await client.ExpectAsync()).Result.ShouldBe(LobbyActionResult.Ok); + } + + var startTimeout = TimeSpan.FromSeconds(60); + foreach (var client in new[] { host, second, third }) { + await client.ExpectAsync(packet => packet.LobbyId == created.LobbyId, startTimeout); + await client.ExpectAsync(packet => packet.GameId == created.LobbyId, startTimeout); + } + + return created.LobbyId; + } +} diff --git a/OpenPolytopia.UnitTest/TimerMigrationTest.cs b/OpenPolytopia.UnitTest/TimerMigrationTest.cs new file mode 100644 index 00000000..4e2382a3 --- /dev/null +++ b/OpenPolytopia.UnitTest/TimerMigrationTest.cs @@ -0,0 +1,45 @@ +namespace OpenPolytopia; + +using System; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Common.Gameplay; +using Common.Network.Packets; +using Server; +using Shouldly; + +public class TimerMigrationTest { + [Fact] + public async Task PreTimerGameGetsOnePersistedDailyDeadlineOnUpgrade() { + using var database = new TempDatabase(); + var time = new ManualTimeProvider(new DateTimeOffset(2026, 9, 5, 12, 0, 0, TimeSpan.Zero)); + ulong id; + string token; + await using (var server = await TestServer.StartAsync(database.Path, time)) { + using var alice = await TestClient.ConnectAsync(server); + token = (await alice.RegisterAsync(TestGames.UniqueName("legacy_alice"), TestGames.PASSWORD)).Token; + using var bob = await TestClient.ConnectAsync(server); + await bob.RegisterAsync(TestGames.UniqueName("legacy_bob"), TestGames.PASSWORD); + id = await TestGames.StartGameAsync(alice, bob); + } + using (var store = new ServerStore(database.Path)) { + var state = JsonNode.Parse(store.LoadState()!)!; + state["Version"] = 1; + foreach (var game in state["Games"]!.AsArray()) game!.AsObject().Remove("Clock"); + store.SaveState(state.ToJsonString()); + } + var deadline = time.GetUtcNow().Add(TurnClock.DailyTurnLength).ToUnixTimeMilliseconds(); + for (var restart = 0; restart < 2; restart++) { + await using var server = await TestServer.StartAsync(database.Path, time); + using var alice = await TestClient.ConnectAsync(server); + (await alice.ResumeAsync(token)).Ok.ShouldBeTrue(); + await alice.SendAsync(new JoinGamePacket { GameId = id }); + var clock = await alice.ExpectAsync(packet => packet.GameId == id); + clock.TimerMode.ShouldBe((uint)TurnTimerMode.Daily); + clock.DeadlineUnixMilliseconds.ShouldBe(deadline); + using var store = new ServerStore(database.Path); + JsonNode.Parse(store.LoadState()!)!["Version"]!.GetValue().ShouldBe(2); + time.Advance(TimeSpan.FromHours(1)); + } + } +} diff --git a/OpenPolytopia.UnitTest/TurnClockTest.cs b/OpenPolytopia.UnitTest/TurnClockTest.cs new file mode 100644 index 00000000..0080718c --- /dev/null +++ b/OpenPolytopia.UnitTest/TurnClockTest.cs @@ -0,0 +1,406 @@ +namespace OpenPolytopia; + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Server; +using Shouldly; + +public class TurnClockTest { + // fixed instant so nothing here depends on the wall clock + private static readonly DateTimeOffset _start = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + + private static readonly int[] _playerIds = [1, 2]; + + private static TurnClock NewClock(TurnTimerMode mode = TurnTimerMode.Live) => new(mode, _playerIds); + + private static TurnClockState RoundTrip(TurnClockState state) => + JsonSerializer.Deserialize(JsonSerializer.Serialize(state)).ShouldNotBeNull(); + + [Fact] + public void TestEveryPlayerStartsWithTheInitialBank() { + var clock = NewClock(); + + clock.Running.ShouldBeFalse(); + foreach (var playerId in _playerIds) { + clock.BankOf(playerId, _start).ShouldBe(TimeSpan.FromSeconds(TurnClock.INITIAL_BANK_SECONDS)); + clock.Players[playerId].Timeouts.ShouldBe(0); + clock.Players[playerId].Eliminated.ShouldBeFalse(); + } + } + + [Fact] + public void TestBeginUsesTheBankAsDeadline() { + var clock = NewClock(); + + clock.Begin(1, _start); + + clock.Running.ShouldBeTrue(); + clock.ActivePlayer.ShouldBe(1); + clock.TurnStartedUtc.ShouldBe(_start); + clock.DeadlineUtc.ShouldBe(_start.AddSeconds(TurnClock.INITIAL_BANK_SECONDS)); + } + + [Fact] + public void TestBeginStoresTheDeadlineInUtc() { + var clock = NewClock(); + + // same instant, expressed in a different offset: the clock has to normalize it + clock.Begin(1, _start.ToOffset(TimeSpan.FromHours(5))); + + clock.DeadlineUtc.ShouldNotBeNull().Offset.ShouldBe(TimeSpan.Zero); + clock.DeadlineUtc.ShouldBe(_start.AddSeconds(TurnClock.INITIAL_BANK_SECONDS)); + } + + [Fact] + public void TestOnlyTheActivePlayerClockRuns() { + var clock = NewClock(); + clock.Begin(1, _start); + + var later = _start.AddSeconds(20); + + clock.BankOf(1, later).ShouldBe(TimeSpan.FromSeconds(40)); + clock.BankOf(2, later).ShouldBe(TimeSpan.FromSeconds(TurnClock.INITIAL_BANK_SECONDS)); + } + + [Fact] + public void TestBeginRejectsASecondTurn() { + var clock = NewClock(); + clock.Begin(1, _start); + + Should.Throw(() => clock.Begin(2, _start)); + } + + [Fact] + public void TestBeginRejectsAnUnknownPlayer() { + var clock = NewClock(); + + Should.Throw(() => clock.Begin(42, _start)); + } + + [Fact] + public void TestCompleteBanksTheTurnBonus() { + var clock = NewClock(); + clock.Begin(1, _start); + + // 20 seconds spent, 2 cities and 3 units owned + clock.Complete(2, 3, _start.AddSeconds(20)).ShouldBe(TurnClockDecision.Completed); + + var expected = 40d + TurnClock.TURN_BONUS_SECONDS + (2 * TurnClock.CITY_BONUS_SECONDS) + + (3 * TurnClock.UNIT_BONUS_SECONDS); + clock.BankOf(1, _start.AddSeconds(20)).ShouldBe(TimeSpan.FromSeconds(expected)); + clock.Running.ShouldBeFalse(); + clock.DeadlineUtc.ShouldBeNull(); + } + + [Fact] + public void TestCompleteNeverCountsAsATimeout() { + var clock = NewClock(); + clock.Begin(1, _start); + + // completing late still isn't a timeout: the player did play + clock.Complete(0, 0, _start.AddSeconds(90)).ShouldBe(TurnClockDecision.Completed); + + clock.Players[1].Timeouts.ShouldBe(0); + clock.Players[1].Eliminated.ShouldBeFalse(); + } + + [Fact] + public void TestCompleteWithoutARunningTurnThrows() { + var clock = NewClock(); + + Should.Throw(() => clock.Complete(0, 0, _start)); + } + + [Fact] + public void TestBankNeverGoesNegative() { + var clock = NewClock(); + clock.Begin(1, _start); + + clock.Remaining(_start.AddSeconds(500)).ShouldBe(TimeSpan.Zero); + clock.Complete(0, 0, _start.AddSeconds(500)); + + clock.BankOf(1, _start).ShouldBe(TimeSpan.FromSeconds(TurnClock.TURN_BONUS_SECONDS)); + } + + [Fact] + public void TestIsExpiredOnlyOnceTheBankIsDry() { + var clock = NewClock(); + clock.Begin(1, _start); + + clock.IsExpired(_start.AddSeconds(59)).ShouldBeFalse(); + clock.IsExpired(_start.AddSeconds(60)).ShouldBeTrue(); + } + + [Fact] + public void TestIsExpiredIsFalseWithoutARunningTurn() { + var clock = NewClock(); + + clock.IsExpired(_start.AddYears(1)).ShouldBeFalse(); + clock.Poll(_start.AddYears(1)).ShouldBe(TurnClockDecision.None); + } + + [Fact] + public void TestPollReportsALiveTimeout() { + var clock = NewClock(); + clock.Begin(1, _start); + + clock.Poll(_start.AddSeconds(30)).ShouldBe(TurnClockDecision.None); + clock.Poll(_start.AddSeconds(60)).ShouldBe(TurnClockDecision.TimedOut); + + // polling doesn't change anything: the turn is still the caller's to end + clock.Running.ShouldBeTrue(); + clock.Players[1].Timeouts.ShouldBe(0); + } + + [Fact] + public void TestSkipRecordsATimeoutAndEndsTheTurn() { + var clock = NewClock(); + clock.Begin(1, _start); + + clock.Skip(1, 0, _start.AddSeconds(60)).ShouldBe(TurnClockDecision.TimedOut); + + clock.Players[1].Timeouts.ShouldBe(1); + clock.Players[1].Eliminated.ShouldBeFalse(); + clock.Running.ShouldBeFalse(); + // a timed out turn still earns its bonus, otherwise the bank could never recover + clock.BankOf(1, _start).ShouldBe(TimeSpan.FromSeconds(TurnClock.TURN_BONUS_SECONDS + TurnClock.CITY_BONUS_SECONDS)); + } + + [Fact] + public void TestSkipBeforeTheDeadlineThrows() { + var clock = NewClock(); + clock.Begin(1, _start); + + Should.Throw(() => clock.Skip(0, 0, _start.AddSeconds(30))); + clock.Players[1].Timeouts.ShouldBe(0); + } + + [Fact] + public void TestThreeTimeoutsEliminateThePlayer() { + var clock = NewClock(); + + for (var turn = 0; turn < 2; turn++) { + clock.Begin(1, _start); + clock.Skip(0, 0, _start.AddSeconds(1000)).ShouldBe(TurnClockDecision.TimedOut); + } + + clock.Begin(1, _start); + clock.Skip(0, 0, _start.AddSeconds(1000)).ShouldBe(TurnClockDecision.Eliminated); + + clock.Players[1].Timeouts.ShouldBe(TurnClock.TIMEOUTS_BEFORE_ELIMINATION); + clock.Players[1].Eliminated.ShouldBeTrue(); + // the other player is untouched + clock.Players[2].Timeouts.ShouldBe(0); + } + + [Fact] + public void TestVoluntaryCompletionsDoNotPushTowardsElimination() { + var clock = NewClock(); + + clock.Begin(1, _start); + clock.Skip(0, 0, _start.AddSeconds(1000)); + for (var turn = 0; turn < 5; turn++) { + clock.Begin(1, _start); + clock.Complete(0, 0, _start.AddSeconds(1)); + } + + clock.Begin(1, _start); + clock.Skip(0, 0, _start.AddSeconds(1000)).ShouldBe(TurnClockDecision.TimedOut); + clock.Players[1].Timeouts.ShouldBe(2); + clock.Players[1].Eliminated.ShouldBeFalse(); + } + + [Fact] + public void TestBeginRejectsAnEliminatedPlayer() { + var clock = NewClock(); + for (var turn = 0; turn < TurnClock.TIMEOUTS_BEFORE_ELIMINATION; turn++) { + clock.Begin(1, _start); + clock.Skip(0, 0, _start.AddSeconds(1000)); + } + + Should.Throw(() => clock.Begin(1, _start)); + } + + [Fact] + public void TestDailyDeadlineIsTwentyFourHours() { + var clock = NewClock(TurnTimerMode.Daily); + clock.Begin(1, _start); + + clock.DeadlineUtc.ShouldBe(_start.AddHours(24)); + clock.Remaining(_start.AddHours(6)).ShouldBe(TimeSpan.FromHours(18)); + } + + [Fact] + public void TestDailyTurnsIgnoreTheBank() { + var clock = NewClock(TurnTimerMode.Daily); + clock.Begin(1, _start); + clock.Complete(3, 4, _start.AddHours(6)); + + clock.BankOf(1, _start).ShouldBe(TimeSpan.Zero); + + // the next turn gets its own full 24 hours, no matter how long the previous one took + clock.Begin(1, _start.AddHours(6)); + clock.DeadlineUtc.ShouldBe(_start.AddHours(30)); + } + + [Fact] + public void TestDailyOverdueDoesNotAdvanceOnItsOwn() { + var clock = NewClock(TurnTimerMode.Daily); + clock.Begin(1, _start); + + var late = _start.AddHours(30); + clock.IsExpired(late).ShouldBeTrue(); + clock.Poll(late).ShouldBe(TurnClockDecision.Overdue); + + // still the same player's turn until somebody asks for a skip + clock.Running.ShouldBeTrue(); + clock.ActivePlayer.ShouldBe(1); + clock.Players[1].Timeouts.ShouldBe(0); + } + + [Fact] + public void TestDailySkipEndsTheOverdueTurn() { + var clock = NewClock(TurnTimerMode.Daily); + clock.Begin(1, _start); + + clock.Skip(0, 0, _start.AddHours(30)).ShouldBe(TurnClockDecision.TimedOut); + + clock.Running.ShouldBeFalse(); + clock.Players[1].Timeouts.ShouldBe(1); + } + + [Fact] + public void TestKickEliminatesImmediately() { + var clock = NewClock(TurnTimerMode.Daily); + clock.Begin(1, _start); + + clock.Kick(_start.AddHours(30)).ShouldBe(TurnClockDecision.Eliminated); + + clock.Players[1].Eliminated.ShouldBeTrue(); + clock.Players[1].Timeouts.ShouldBe(TurnClock.TIMEOUTS_BEFORE_ELIMINATION); + clock.Running.ShouldBeFalse(); + } + + [Fact] + public void TestKickBeforeTheDeadlineThrows() { + var clock = NewClock(TurnTimerMode.Daily); + clock.Begin(1, _start); + + Should.Throw(() => clock.Kick(_start.AddHours(23))); + clock.Players[1].Eliminated.ShouldBeFalse(); + } + + [Fact] + public void TestSnapshotRoundTripKeepsTheDeadline() { + var clock = NewClock(); + clock.Begin(1, _start); + clock.Complete(1, 2, _start.AddSeconds(10)); + clock.Begin(2, _start.AddSeconds(10)); + + var restored = TurnClock.FromSnapshot(RoundTrip(clock.ToSnapshot())); + + restored.Mode.ShouldBe(TurnTimerMode.Live); + restored.ActivePlayer.ShouldBe(2); + restored.DeadlineUtc.ShouldBe(clock.DeadlineUtc); + restored.TurnStartedUtc.ShouldBe(clock.TurnStartedUtc); + restored.BankOf(1, _start).ShouldBe(clock.BankOf(1, _start)); + } + + [Fact] + public void TestRestartDoesNotGiveBackTheTimeSpentOffline() { + var clock = NewClock(); + clock.Begin(1, _start); + + // server goes down for 45 seconds and comes back from the snapshot + var restored = TurnClock.FromSnapshot(RoundTrip(clock.ToSnapshot())); + var back = _start.AddSeconds(45); + + restored.Remaining(back).ShouldBe(TimeSpan.FromSeconds(15)); + restored.IsExpired(back).ShouldBeFalse(); + restored.Poll(_start.AddSeconds(61)).ShouldBe(TurnClockDecision.TimedOut); + } + + [Fact] + public void TestSnapshotKeepsTimeoutCounters() { + var clock = NewClock(); + clock.Begin(1, _start); + clock.Skip(0, 0, _start.AddSeconds(1000)); + clock.Begin(1, _start); + clock.Skip(0, 0, _start.AddSeconds(1000)); + + var restored = TurnClock.FromSnapshot(RoundTrip(clock.ToSnapshot())); + + restored.Players[1].Timeouts.ShouldBe(2); + // the third timeout still eliminates, counters survived the restart + restored.Begin(1, _start); + restored.Skip(0, 0, _start.AddSeconds(1000)).ShouldBe(TurnClockDecision.Eliminated); + } + + [Fact] + public void TestSnapshotIsADetachedCopy() { + var clock = NewClock(); + var snapshot = clock.ToSnapshot(); + + snapshot.Players[0].Timeouts = 7; + snapshot.ActivePlayer = 99; + + clock.Players[_playerIds[0]].Timeouts.ShouldBe(0); + clock.ActivePlayer.ShouldBe(0); + } + + [Fact] + public void TestSnapshotOfADailyClockKeepsItsMode() { + var clock = NewClock(TurnTimerMode.Daily); + clock.Begin(1, _start); + + var restored = TurnClock.FromSnapshot(RoundTrip(clock.ToSnapshot())); + + restored.Mode.ShouldBe(TurnTimerMode.Daily); + restored.Poll(_start.AddHours(25)).ShouldBe(TurnClockDecision.Overdue); + } + + [Fact] + public void TestBankOfAnUnknownPlayerThrows() { + var clock = NewClock(); + + Should.Throw(() => clock.BankOf(42, _start)); + } + + [Fact] + public void TestFullLiveRoundOfTurns() { + var clock = NewClock(); + var now = _start; + + clock.Begin(1, now); + now = now.AddSeconds(15); + clock.Complete(1, 0, now).ShouldBe(TurnClockDecision.Completed); + + clock.Begin(2, now); + now = now.AddSeconds(25); + clock.Complete(1, 1, now).ShouldBe(TurnClockDecision.Completed); + + clock.BankOf(1, now).ShouldBe(TimeSpan.FromSeconds(45 + 8 + 12)); + clock.BankOf(2, now).ShouldBe(TimeSpan.FromSeconds(35 + 8 + 12 + 1)); + + clock.Begin(1, now); + clock.DeadlineUtc.ShouldBe(now.AddSeconds(65)); + } + [Fact] + public void TestDailySkipsNeverAutomaticallyEliminate() { + var clock = NewClock(TurnTimerMode.Daily); + for (var turn = 0; turn < 4; turn++) { + var now = _start.AddDays(turn); + clock.Begin(1, now); + clock.Skip(1, 1, now.AddDays(1)).ShouldBe(TurnClockDecision.TimedOut); + } + clock.Players[1].Eliminated.ShouldBeFalse(); + } + + [Fact] + public void TestRestoreRejectsAnActiveClockWithoutDeadline() { + var snapshot = NewClock().ToSnapshot(); + snapshot.ActivePlayer = 1; + Should.Throw(() => TurnClock.FromSnapshot(snapshot)); + } +} diff --git a/OpenPolytopia/src/Lobby.cs b/OpenPolytopia/src/Lobby.cs index db7fef1f..5dd221b3 100644 --- a/OpenPolytopia/src/Lobby.cs +++ b/OpenPolytopia/src/Lobby.cs @@ -32,6 +32,13 @@ public partial class Lobby : Control { private Button _openButton = null!; private Button _closeButton = null!; private Button _resignButton = null!; + private Button _skipButton = null!; + private Button _kickButton = null!; + private ConfirmationDialog _kickDialog = null!; + private GameStatePacket? _viewState; + private uint _clockMode; + private ulong _pendingOverdueGameId; + private ResolveOverdueTurnPacket? _confirmedKick; private ConfirmationDialog _resignDialog = null!; private Label _statusLabel = null!; private Label _gameStatusLabel = null!; @@ -183,6 +190,28 @@ private void BuildUi() { _resignButton.Pressed += OnResignPressed; gamesBar.AddChild(_resignButton); + _skipButton = new Button { Text = "Skip overdue turn", Disabled = true }; + _skipButton.Pressed += () => ResolveOverdue(false); + gamesBar.AddChild(_skipButton); + _kickButton = new Button { Text = "Kick overdue player", Disabled = true }; + _kickButton.Pressed += () => { + if (!CanResolveOverdue() || _viewState == null) return; + _confirmedKick = new ResolveOverdueTurnPacket { GameId = _openGameId, ExpectedTurn = _viewState.Turn, + ExpectedPlayer = _viewState.CurrentPlayer, Kick = true }; + _kickDialog.PopupCentered(); + }; + gamesBar.AddChild(_kickButton); + _kickDialog = new ConfirmationDialog { + Title = "Kick overdue player", DialogText = "Eliminate the player whose turn is overdue?", OkButtonText = "Kick" + }; + _kickDialog.Confirmed += () => { + if (_confirmedKick is not { } request) return; + _pendingOverdueGameId = request.GameId; + _network.ResolveOverdueTurn(request.GameId, request.ExpectedTurn, request.ExpectedPlayer, true); + _confirmedKick = null; + }; + AddChild(_kickDialog); + _gameStatusLabel = new Label(); gamesBar.AddChild(_gameStatusLabel); @@ -234,6 +263,8 @@ private void OnNetworkDisconnected() { /// private void ClearClock() { _hasClock = false; + _skipButton.Disabled = true; + _kickButton.Disabled = true; _clockLabel.Text = ""; } @@ -465,6 +496,7 @@ private void OnGameState(GameStatePacket packet) { } _openGameId = packet.GameId; + _viewState = packet; // TODO: replace this with the game scene once the gameplay is implemented var me = packet.Players.FirstOrDefault(player => player.AccountId == _network.PlayerId); @@ -488,6 +520,7 @@ public override void _Process(double delta) { } var left = _clockRemainingMs - (long)(Time.GetTicksMsec() - _clockReceivedAt); + _skipButton.Disabled = _kickButton.Disabled = !CanResolveOverdue(); var who = _clockPlayerId == _myGamePlayerId ? "You have" : $"Player {_clockPlayerId} has"; _clockLabel.Text = left <= 0 ? "Turn overdue" : $"{who} {TimeSpan.FromMilliseconds(left):hh\\:mm\\:ss} left"; } @@ -498,6 +531,7 @@ private void OnGameClock(GameClockPacket packet) { } // the deadline only means something next to the server clock it came with, the local one could be off by anything + _clockMode = packet.TimerMode; _clockRemainingMs = packet.DeadlineUnixMilliseconds - packet.ServerUnixMilliseconds; _clockReceivedAt = Time.GetTicksMsec(); _clockPlayerId = packet.PlayerId; @@ -511,7 +545,24 @@ private void OnGameChanged(ulong gameId) { } } + private bool CanResolveOverdue() => _hasClock && _clockMode == TIMER_DAILY && + _viewState is { Over: false } state && state.GameId == _openGameId && + state.CurrentPlayer != _myGamePlayerId && state.Players.Any(p => p.PlayerId == _myGamePlayerId && p.Alive) && + _clockRemainingMs <= (long)(Time.GetTicksMsec() - _clockReceivedAt); + + private void ResolveOverdue(bool kick) { + if (!CanResolveOverdue() || _viewState == null) return; + _pendingOverdueGameId = _openGameId; + _network.ResolveOverdueTurn(_openGameId, _viewState.Turn, _viewState.CurrentPlayer, kick); + } + private void OnMembershipResult(MembershipResultPacket packet) { + if (packet.GameId == _pendingOverdueGameId) { + _pendingOverdueGameId = 0; + _gameStatusLabel.Text = packet.Result == GameActionResult.Ok ? "Overdue turn resolved" : $"Error: {packet.Result}"; + _network.RequestGameState(packet.GameId); + return; + } // leaving and resigning share this response, only the client knows which one it asked for var resigned = packet.GameId == _resignGameId; _resignGameId = 0; diff --git a/OpenPolytopia/src/NetworkNode.cs b/OpenPolytopia/src/NetworkNode.cs index 1555b17f..1a6cadec 100644 --- a/OpenPolytopia/src/NetworkNode.cs +++ b/OpenPolytopia/src/NetworkNode.cs @@ -520,6 +520,10 @@ public void SetName(string name) { /// the id of the game public void RequestGameState(ulong gameId) => Send(new GetGameStatePacket { GameId = gameId }); + /// Resolves an overdue daily turn, guarded against a changed turn. + public void ResolveOverdueTurn(ulong gameId, uint turn, uint playerId, bool kick) => + Send(new ResolveOverdueTurnPacket { GameId = gameId, ExpectedTurn = turn, ExpectedPlayer = playerId, Kick = kick }); + /// /// Creates a new lobby; this player automatically joins it /// diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 8b3a7945..d1c13207 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -1 +1,19 @@ -# Getting Started \ No newline at end of file +# Getting Started +## Server storage and upgrades + +The server stores accounts and live matches in SQLite. Completed matches move to a +separate archive in the same transaction that saves the final live state. Members +can still list and open their completed matches after reconnecting or restarting +the server. Archived worlds are loaded on demand and are excluded from routine +live-state snapshots. + +Schema version 1 databases upgrade automatically to version 2 without removing +accounts, sessions, or game results. A pre-timer server snapshot also upgrades on +startup. Those snapshots did not retain the chosen timer mode, so existing active +matches receive a daily timer with a full 24 hours from the upgrade. The server +saves that deadline immediately; subsequent restarts do not reset it. Games that +already have timers retain their saved mode and deadline. + +Remote connections require TLS. Set `OPENPOLYTOPIA_TLS_CERTIFICATE` to a PKCS#12 +certificate file and `OPENPOLYTOPIA_TLS_PASSWORD` when it needs a password. Only a +server bound to a loopback address can start without a certificate.