Skip to content

Commit 6438d99

Browse files
committed
feat: add persistent live and daily turn timers
1 parent 55fa0b4 commit 6438d99

16 files changed

Lines changed: 1571 additions & 28 deletions

OpenPolytopia.Common/Gameplay/GameActionResult.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,5 +109,8 @@ public enum GameActionResult : byte {
109109
InvalidTroopType = 32,
110110

111111
/// <summary>The tile isn't a valid capture target</summary>
112-
NotACaptureTarget = 33
112+
NotACaptureTarget = 33,
113+
114+
/// <summary>The request parameters do not match the current game state.</summary>
115+
InvalidParameters = 34
113116
}

OpenPolytopia.Common/Network/PacketRegistrar.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,6 @@ public static void RegisterAllPackets() {
4545
return;
4646
}
4747

48-
_registered = true;
49-
5048
RegisterPacket<KeepAlivePacket>(0);
5149
RegisterPacket<HandshakePacket>(1);
5250
RegisterPacket<HandshakeResponsePacket>(2);
@@ -103,6 +101,7 @@ public static void RegisterAllPackets() {
103101
RegisterPacket<MembershipResultPacket>(53);
104102
RegisterPacket<GameClockPacket>(54);
105103
RegisterPacket<ResolveOverdueTurnPacket>(55);
104+
_registered = true;
106105

107106
}
108107
}

OpenPolytopia.Common/Network/Packets/GamePackets.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ public partial class GameStatePacket : IPacket {
9696
/// </summary>
9797
[GeneratedPacket]
9898
public partial class MoveTroopPacket : IPacket {
99+
/// <summary>Round shown when the action was issued; required for live games.</summary>
100+
[PacketField] public uint ExpectedTurn;
99101
/// <summary>
100102
/// Id of the game, i.e. the id of the lobby it started from
101103
/// </summary>
@@ -168,6 +170,8 @@ public partial class TroopMovedPacket : IPacket {
168170
/// </summary>
169171
[GeneratedPacket]
170172
public partial class AttackPacket : IPacket {
173+
/// <summary>Round shown when the action was issued; required for live games.</summary>
174+
[PacketField] public uint ExpectedTurn;
171175
/// <summary>
172176
/// Id of the game, i.e. the id of the lobby it started from
173177
/// </summary>
@@ -270,6 +274,8 @@ public partial class CombatPacket : IPacket {
270274
/// </summary>
271275
[GeneratedPacket]
272276
public partial class TrainTroopPacket : IPacket {
277+
/// <summary>Round shown when the action was issued; required for live games.</summary>
278+
[PacketField] public uint ExpectedTurn;
273279
/// <summary>
274280
/// Id of the game, i.e. the id of the lobby it started from
275281
/// </summary>
@@ -342,6 +348,8 @@ public partial class TroopTrainedPacket : IPacket {
342348
/// </summary>
343349
[GeneratedPacket]
344350
public partial class ResearchTechPacket : IPacket {
351+
/// <summary>Round shown when the action was issued; required for live games.</summary>
352+
[PacketField] public uint ExpectedTurn;
345353
/// <summary>
346354
/// Id of the game, i.e. the id of the lobby it started from
347355
/// </summary>
@@ -402,6 +410,8 @@ public partial class TechResearchedPacket : IPacket {
402410
/// </summary>
403411
[GeneratedPacket]
404412
public partial class BuildPacket : IPacket {
413+
/// <summary>Round shown when the action was issued; required for live games.</summary>
414+
[PacketField] public uint ExpectedTurn;
405415
/// <summary>
406416
/// Id of the game, i.e. the id of the lobby it started from
407417
/// </summary>
@@ -474,6 +484,8 @@ public partial class BuildingBuiltPacket : IPacket {
474484
/// </summary>
475485
[GeneratedPacket]
476486
public partial class CapturePacket : IPacket {
487+
/// <summary>Round shown when the action was issued; required for live games.</summary>
488+
[PacketField] public uint ExpectedTurn;
477489
/// <summary>
478490
/// Id of the game, i.e. the id of the lobby it started from
479491
/// </summary>
@@ -546,6 +558,8 @@ public partial class CityCapturedPacket : IPacket {
546558
/// </summary>
547559
[GeneratedPacket]
548560
public partial class EndTurnPacket : IPacket {
561+
/// <summary>Round shown when the action was issued; required for live games.</summary>
562+
[PacketField] public uint ExpectedTurn;
549563
/// <summary>
550564
/// Id of the game, i.e. the id of the lobby it started from
551565
/// </summary>

OpenPolytopia.Server/GameManager.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public void Disconnect(uint connectionId) {
5454
/// <returns>the newly created, started and registered session</returns>
5555
/// <exception cref="ArgumentException">if the lobby doesn't have 2 to 16 players</exception>
5656
public async Task<GameSession> CreateGameAsync(LobbyData lobby, GameData data, int? seed = null,
57-
IReadOnlyDictionary<uint, uint>? onlineConnections = null) {
57+
IReadOnlyDictionary<uint, uint>? onlineConnections = null, DateTimeOffset? startedAt = null) {
5858
ArgumentNullException.ThrowIfNull(lobby);
5959
ArgumentNullException.ThrowIfNull(data);
6060

@@ -88,7 +88,10 @@ public async Task<GameSession> CreateGameAsync(LobbyData lobby, GameData data, i
8888
players);
8989
game.Start();
9090

91-
var session = new GameSession(lobby.Id, game, connections, names);
91+
var session = new GameSession(lobby.Id, game, connections, names) {
92+
Clock = new TurnClock((TurnTimerMode)lobby.TimerMode, game.Players.Select(p => p.Id))
93+
};
94+
session.Clock.Begin(game.CurrentPlayer, startedAt ?? DateTimeOffset.UtcNow);
9295
if (onlineConnections != null) {
9396
foreach (var connectionId in session.ConnectionIds.ToArray()) session.RemoveConnection(connectionId);
9497
foreach (var accountId in connections.Values) {

OpenPolytopia.Server/GameServer.Accounts.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ private void RegisterAccountHandlers() {
6666
var session = _gameManager[p.GameId];
6767
if (session != null && session.Join(AccountId(c), c.Id)) {
6868
SendTo(c.Id, session.BuildState());
69+
SendClock(session, [c.Id]);
6970
}
7071
else SendTo(c.Id, new GameStatePacket { GameId = p.GameId,
7172
Result = session == null ? GameActionResult.GameNotFound : GameActionResult.NotInGame });

OpenPolytopia.Server/GameServer.Persistence.cs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,26 +15,28 @@ public partial class GameServer {
1515
private readonly Dictionary<uint, string> _pendingRenames = new();
1616

1717
private sealed record SavedSession(ulong Id, GameSnapshot Game, Dictionary<int, uint> Accounts,
18-
Dictionary<int, string> Names);
18+
Dictionary<int, string> Names, TurnClockState? Clock);
1919
private sealed record SavedServer(int Version, ulong NextLobbyId, List<LobbyData> Lobbies,
2020
List<SavedSession> Games);
2121

22-
private string CaptureState() => JsonSerializer.Serialize(new SavedServer(1, _lobbyManager.LastId,
22+
private string CaptureState() => JsonSerializer.Serialize(new SavedServer(2, _lobbyManager.LastId,
2323
[.. _lobbyManager.Lobbies], [.. _gameManager.Sessions.Select(session => new SavedSession(session.Id,
24-
session.Game.ToSnapshot(), new(session.Accounts), new(session.Names)))]), _json);
24+
session.Game.ToSnapshot(), new(session.Accounts), new(session.Names), session.Clock?.ToSnapshot()))]), _json);
2525

2626
private void RestoreState(string? json) {
2727
if (json == null) return;
2828
var state = JsonSerializer.Deserialize<SavedServer>(json, _json) ?? throw new InvalidDataException("Empty server state");
29-
if (state.Version != 1) throw new InvalidDataException("Unsupported server state version");
29+
if (state.Version != 2) throw new InvalidDataException("Unsupported server state version");
3030
var nextLobbies = new LobbyManager();
3131
nextLobbies.Restore(state.NextLobbyId, state.Lobbies);
3232
var nextGames = new GameManager();
3333
foreach (var saved in state.Games) {
3434
var troops = new TroopManager(saved.Game.GridSize);
3535
troops.RegisterTroops(_gameData.TroopsSerializedData);
3636
var game = Game.Restore(saved.Game, troops, _gameData.Tribes, _gameData.Buildings, _gameData.TechTreeDefinition);
37-
var session = new GameSession(saved.Id, game, saved.Accounts, saved.Names);
37+
var session = new GameSession(saved.Id, game, saved.Accounts, saved.Names) {
38+
Clock = TurnClock.FromSnapshot(saved.Clock ?? throw new InvalidDataException("Missing saved turn clock"))
39+
};
3840
foreach (var connection in session.ConnectionIds.ToArray()) session.RemoveConnection(connection);
3941
nextGames.Restore(session);
4042
}
@@ -56,8 +58,12 @@ private async Task WithStateAsync(Func<Task> action, bool stateMayChange = true)
5658
var attachments = _gameManager.Sessions.Where(s => s.Connections.Count != 0).ToDictionary(s => s.Id, s => s.Connections.ToArray());
5759
try {
5860
before = _savedState ??= CaptureState();
59-
var changes = stateMayChange || _lobbyManager.Lobbies.Any(l => l.Starting);
61+
var now = _timeProvider.GetUtcNow();
62+
var changes = stateMayChange || _lobbyManager.Lobbies.Any(l => l.Starting) ||
63+
_gameManager.Sessions.Any(s => !s.Game.Over && s.Clock?.Mode == TurnTimerMode.Live && s.Clock.IsExpired(now));
64+
ProcessTimers(now);
6065
await action();
66+
SynchronizeClocks(_timeProvider.GetUtcNow());
6167
var after = changes ? CaptureState() : before;
6268
if (after != before || _pendingRenames.Count != 0) _store.SaveState(after, _pendingRenames);
6369
_savedState = after;
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
namespace OpenPolytopia.Server;
2+
3+
using OpenPolytopia.Common.Gameplay;
4+
using OpenPolytopia.Common.Network.Packets;
5+
6+
public partial class GameServer {
7+
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
8+
9+
private void RegisterTimerHandlers() {
10+
_dispatcher.Register<ResolveOverdueTurnPacket>((connection, packet) => {
11+
var session = _gameManager[packet.GameId];
12+
var playerId = session?.PlayerIdOfAccount(AccountId(connection)) ?? 0;
13+
var now = _timeProvider.GetUtcNow();
14+
var result = session == null ? GameActionResult.GameNotFound :
15+
playerId == 0 ? GameActionResult.NotInGame :
16+
session.Game.Over ? GameActionResult.GameOver :
17+
!session.Game[playerId]!.Alive ? GameActionResult.PlayerEliminated :
18+
session.Game.CurrentPlayer == playerId || session.Game.Turn != packet.ExpectedTurn ||
19+
session.Game.CurrentPlayer != packet.ExpectedPlayer || session.Clock?.Mode != TurnTimerMode.Daily ||
20+
!session.Clock.IsExpired(now) ? GameActionResult.InvalidParameters : GameActionResult.Ok;
21+
if (result == GameActionResult.Ok) AdvanceExpired(session!, packet.Kick, now);
22+
SendTo(connection.Id, new MembershipResultPacket { GameId = packet.GameId, Result = result });
23+
});
24+
}
25+
26+
private static (int Cities, int Units) ClockBonus(GameSession session, int playerId) =>
27+
((int)session.Game.OwnedCities(playerId), session.Game.Troops.Troops().Count(t => t.Troop.Player == playerId));
28+
29+
private void SendClock(GameSession session, IEnumerable<uint> recipients) {
30+
if (session.Clock?.DeadlineUtc is not { } deadline || session.Game.Over) return;
31+
BroadcastTo(recipients, new GameClockPacket {
32+
GameId = session.Id, TimerMode = (uint)session.Clock.Mode, PlayerId = (uint)session.Game.CurrentPlayer,
33+
DeadlineUnixMilliseconds = deadline.ToUnixTimeMilliseconds(),
34+
ServerUnixMilliseconds = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds()
35+
});
36+
}
37+
38+
// Apply expiry before accepting an action. A late EndTurn cannot erase a timeout.
39+
private void ProcessTimers(DateTimeOffset now) {
40+
foreach (var session in _gameManager.Sessions) {
41+
while (!session.Game.Over && session.Clock is { Mode: TurnTimerMode.Live } clock && clock.IsExpired(now)) {
42+
// Advance from the original deadline, so downtime cannot grant a new bank.
43+
AdvanceExpired(session, false, clock.DeadlineUtc!.Value);
44+
}
45+
}
46+
}
47+
48+
private void AdvanceExpired(GameSession session, bool kick, DateTimeOffset at) {
49+
var clock = session.Clock!;
50+
var playerId = session.Game.CurrentPlayer;
51+
var (cities, units) = ClockBonus(session, playerId);
52+
var decision = kick ? clock.Kick(at) : clock.Skip(cities, units, at);
53+
var eliminated = decision == TurnClockDecision.Eliminated;
54+
var result = eliminated ? session.Game.Resign(playerId) : session.Game.EndTurn(playerId);
55+
if (result.Result != GameActionResult.Ok) throw new InvalidOperationException("Clock and game disagree on the current turn");
56+
if (eliminated) BroadcastTo(session.ConnectionIds, new PlayerEliminatedPacket {
57+
GameId = session.Id, PlayerId = (uint)playerId, Update = session.TakeUpdate()
58+
});
59+
if (session.Game.Over) {
60+
EndGame(session);
61+
return;
62+
}
63+
clock.Begin(session.Game.CurrentPlayer, at);
64+
BroadcastTo(session.ConnectionIds, new TurnStartedPacket {
65+
GameId = session.Id, Turn = session.Game.Turn, PlayerId = (uint)session.Game.CurrentPlayer,
66+
Update = session.TakeUpdate()
67+
});
68+
SendClock(session, session.ConnectionIds);
69+
}
70+
71+
// EndTurn, resignation and capture can all change or end the active turn.
72+
private void SynchronizeClocks(DateTimeOffset now) {
73+
foreach (var session in _gameManager.Sessions) {
74+
var clock = session.Clock;
75+
if (clock == null || !clock.Running || (!session.Game.Over && clock.ActivePlayer == session.Game.CurrentPlayer)) continue;
76+
var (cities, units) = ClockBonus(session, clock.ActivePlayer);
77+
clock.Complete(cities, units, now);
78+
if (!session.Game.Over) {
79+
clock.Begin(session.Game.CurrentPlayer, now);
80+
SendClock(session, session.ConnectionIds);
81+
}
82+
}
83+
}
84+
}

OpenPolytopia.Server/GameServer.cs

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ namespace OpenPolytopia.Server;
1515
/// </remarks>
1616
/// <param name="port">the port to listen on</param>
1717
/// <param name="bindAddress">the ip address to bind to; null to listen on every interface</param>
18-
public partial class GameServer(int port, string? bindAddress = null, string? databasePath = null) : IDisposable {
18+
public partial class GameServer(int port, string? bindAddress = null, string? databasePath = null,
19+
TimeProvider? timeProvider = null) : IDisposable {
1920
/// <summary>
2021
/// How often the server checks for lobbies to start
2122
/// </summary>
22-
private static readonly TimeSpan START_LOBBY_INTERVAL = TimeSpan.FromSeconds(5);
23+
private static readonly TimeSpan START_LOBBY_INTERVAL = TimeSpan.FromSeconds(1);
2324

2425
/// <summary>
2526
/// Max lobbies the server accepts before refusing new ones
@@ -50,6 +51,7 @@ public async Task RunAsync() {
5051
_server = new ServerConnection(port, bindAddress, _certificate);
5152
RestoreState(_store.LoadState());
5253
RegisterHandlers();
54+
RegisterTimerHandlers();
5355

5456
_server.OnPacketReceived += ManagePacketAsync;
5557
_server.OnClientDisconnected += connection => _ = ClientDisconnectedAsync(connection);
@@ -344,13 +346,14 @@ private async Task StartLobbyAsync(LobbyData lobby) {
344346
.Select(player => online[player.PlayerId]).ToList();
345347

346348
try {
347-
var session = await _gameManager.CreateGameAsync(lobby, _gameData, onlineConnections: online);
349+
var session = await _gameManager.CreateGameAsync(lobby, _gameData, onlineConnections: online, startedAt: _timeProvider.GetUtcNow());
348350

349351
Console.WriteLine($"Starting game for lobby {lobby.Id} with {lobby.PlayersCount} players");
350352

351353
// notify the players that their game started and send them its full state
352354
BroadcastTo(connectionIds, new GameStartedPacket { LobbyId = lobby.Id, Players = lobby.Players });
353355
BroadcastTo(connectionIds, session.BuildState());
356+
SendClock(session, connectionIds);
354357
}
355358
catch (Exception e) {
356359
Console.Error.WriteLine($"Couldn't start the game for lobby {lobby.Id}: {e}");
@@ -378,7 +381,7 @@ private async Task StartLobbyAsync(LobbyData lobby) {
378381
/// </param>
379382
/// <returns>true if a session was found and the connection is a player in it</returns>
380383
private bool TryResolveSession(NetworkConnection connection, ulong gameId,
381-
[NotNullWhen(true)] out GameSession? session, out int playerId, out GameActionResult result) {
384+
[NotNullWhen(true)] out GameSession? session, out int playerId, out GameActionResult result, uint? expectedTurn = null) {
382385
session = _gameManager[gameId];
383386
if (session == null) {
384387
playerId = 0;
@@ -392,6 +395,12 @@ private bool TryResolveSession(NetworkConnection connection, ulong gameId,
392395
return false;
393396
}
394397

398+
if (expectedTurn.HasValue && expectedTurn.Value != session.Game.Turn &&
399+
(session.Clock?.Mode == TurnTimerMode.Live || expectedTurn.Value != 0)) {
400+
result = GameActionResult.InvalidParameters;
401+
return false;
402+
}
403+
395404
result = GameActionResult.Ok;
396405
return true;
397406
}
@@ -408,7 +417,7 @@ private async Task ManageGetGameStateAsync(NetworkConnection connection, GetGame
408417

409418
private async Task ManageMoveTroopAsync(NetworkConnection connection, MoveTroopPacket packet) {
410419
{
411-
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) {
420+
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check, packet.ExpectedTurn)) {
412421
SendTo(connection.Id, new MoveTroopResponsePacket { Result = check });
413422
return;
414423
}
@@ -429,7 +438,7 @@ private async Task ManageMoveTroopAsync(NetworkConnection connection, MoveTroopP
429438

430439
private async Task ManageAttackAsync(NetworkConnection connection, AttackPacket packet) {
431440
{
432-
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) {
441+
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check, packet.ExpectedTurn)) {
433442
SendTo(connection.Id, new AttackResponsePacket { Result = check });
434443
return;
435444
}
@@ -452,7 +461,7 @@ private async Task ManageAttackAsync(NetworkConnection connection, AttackPacket
452461

453462
private async Task ManageTrainTroopAsync(NetworkConnection connection, TrainTroopPacket packet) {
454463
{
455-
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) {
464+
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check, packet.ExpectedTurn)) {
456465
SendTo(connection.Id, new TrainTroopResponsePacket { Result = check });
457466
return;
458467
}
@@ -473,7 +482,7 @@ private async Task ManageTrainTroopAsync(NetworkConnection connection, TrainTroo
473482

474483
private async Task ManageResearchTechAsync(NetworkConnection connection, ResearchTechPacket packet) {
475484
{
476-
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) {
485+
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check, packet.ExpectedTurn)) {
477486
SendTo(connection.Id, new ResearchTechResponsePacket { Result = check });
478487
return;
479488
}
@@ -493,7 +502,7 @@ private async Task ManageResearchTechAsync(NetworkConnection connection, Researc
493502

494503
private async Task ManageBuildAsync(NetworkConnection connection, BuildPacket packet) {
495504
{
496-
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) {
505+
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check, packet.ExpectedTurn)) {
497506
SendTo(connection.Id, new BuildResponsePacket { Result = check });
498507
return;
499508
}
@@ -514,7 +523,7 @@ private async Task ManageBuildAsync(NetworkConnection connection, BuildPacket pa
514523

515524
private async Task ManageCaptureAsync(NetworkConnection connection, CapturePacket packet) {
516525
{
517-
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) {
526+
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check, packet.ExpectedTurn)) {
518527
SendTo(connection.Id, new CaptureResponsePacket { Result = check });
519528
return;
520529
}
@@ -541,7 +550,7 @@ private async Task ManageCaptureAsync(NetworkConnection connection, CapturePacke
541550

542551
private async Task ManageEndTurnAsync(NetworkConnection connection, EndTurnPacket packet) {
543552
{
544-
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check)) {
553+
if (!TryResolveSession(connection, packet.GameId, out var session, out var playerId, out var check, packet.ExpectedTurn)) {
545554
SendTo(connection.Id, new EndTurnResponsePacket { Result = check });
546555
return;
547556
}

OpenPolytopia.Server/GameSession.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ public class GameSession {
3030
/// </summary>
3131
public Game Game { get; }
3232

33+
/// <summary>The persistent turn clock, independent of connected clients.</summary>
34+
public TurnClock? Clock { get; internal set; }
35+
3336
/// <summary>
3437
/// Connection id of every player in the game, keyed by player id
3538
/// </summary>

0 commit comments

Comments
 (0)