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.