Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion OpenPolytopia.Common/Gameplay/GameActionResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,8 @@ public enum GameActionResult : byte {
InvalidTroopType = 32,

/// <summary>The tile isn't a valid capture target</summary>
NotACaptureTarget = 33
NotACaptureTarget = 33,

/// <summary>The request parameters do not match the current game state.</summary>
InvalidParameters = 34
}
3 changes: 1 addition & 2 deletions OpenPolytopia.Common/Network/PacketRegistrar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,6 @@ public static void RegisterAllPackets() {
return;
}

_registered = true;

RegisterPacket<KeepAlivePacket>(0);
RegisterPacket<HandshakePacket>(1);
RegisterPacket<HandshakeResponsePacket>(2);
Expand Down Expand Up @@ -103,6 +101,7 @@ public static void RegisterAllPackets() {
RegisterPacket<MembershipResultPacket>(53);
RegisterPacket<GameClockPacket>(54);
RegisterPacket<ResolveOverdueTurnPacket>(55);
_registered = true;

}
}
Expand Down
14 changes: 14 additions & 0 deletions OpenPolytopia.Common/Network/Packets/GamePackets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ public partial class GameStatePacket : IPacket {
/// </summary>
[GeneratedPacket]
public partial class MoveTroopPacket : IPacket {
/// <summary>Round shown when the action was issued; required for live games.</summary>
[PacketField] public uint ExpectedTurn;
/// <summary>
/// Id of the game, i.e. the id of the lobby it started from
/// </summary>
Expand Down Expand Up @@ -168,6 +170,8 @@ public partial class TroopMovedPacket : IPacket {
/// </summary>
[GeneratedPacket]
public partial class AttackPacket : IPacket {
/// <summary>Round shown when the action was issued; required for live games.</summary>
[PacketField] public uint ExpectedTurn;
/// <summary>
/// Id of the game, i.e. the id of the lobby it started from
/// </summary>
Expand Down Expand Up @@ -270,6 +274,8 @@ public partial class CombatPacket : IPacket {
/// </summary>
[GeneratedPacket]
public partial class TrainTroopPacket : IPacket {
/// <summary>Round shown when the action was issued; required for live games.</summary>
[PacketField] public uint ExpectedTurn;
/// <summary>
/// Id of the game, i.e. the id of the lobby it started from
/// </summary>
Expand Down Expand Up @@ -342,6 +348,8 @@ public partial class TroopTrainedPacket : IPacket {
/// </summary>
[GeneratedPacket]
public partial class ResearchTechPacket : IPacket {
/// <summary>Round shown when the action was issued; required for live games.</summary>
[PacketField] public uint ExpectedTurn;
/// <summary>
/// Id of the game, i.e. the id of the lobby it started from
/// </summary>
Expand Down Expand Up @@ -402,6 +410,8 @@ public partial class TechResearchedPacket : IPacket {
/// </summary>
[GeneratedPacket]
public partial class BuildPacket : IPacket {
/// <summary>Round shown when the action was issued; required for live games.</summary>
[PacketField] public uint ExpectedTurn;
/// <summary>
/// Id of the game, i.e. the id of the lobby it started from
/// </summary>
Expand Down Expand Up @@ -474,6 +484,8 @@ public partial class BuildingBuiltPacket : IPacket {
/// </summary>
[GeneratedPacket]
public partial class CapturePacket : IPacket {
/// <summary>Round shown when the action was issued; required for live games.</summary>
[PacketField] public uint ExpectedTurn;
/// <summary>
/// Id of the game, i.e. the id of the lobby it started from
/// </summary>
Expand Down Expand Up @@ -546,6 +558,8 @@ public partial class CityCapturedPacket : IPacket {
/// </summary>
[GeneratedPacket]
public partial class EndTurnPacket : IPacket {
/// <summary>Round shown when the action was issued; required for live games.</summary>
[PacketField] public uint ExpectedTurn;
/// <summary>
/// Id of the game, i.e. the id of the lobby it started from
/// </summary>
Expand Down
7 changes: 5 additions & 2 deletions OpenPolytopia.Server/GameManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public void Disconnect(uint connectionId) {
/// <returns>the newly created, started and registered session</returns>
/// <exception cref="ArgumentException">if the lobby doesn't have 2 to 16 players</exception>
public async Task<GameSession> CreateGameAsync(LobbyData lobby, GameData data, int? seed = null,
IReadOnlyDictionary<uint, uint>? onlineConnections = null) {
IReadOnlyDictionary<uint, uint>? onlineConnections = null, DateTimeOffset? startedAt = null) {
ArgumentNullException.ThrowIfNull(lobby);
ArgumentNullException.ThrowIfNull(data);

Expand Down Expand Up @@ -88,7 +88,10 @@ public async Task<GameSession> 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) {
Expand Down
1 change: 1 addition & 0 deletions OpenPolytopia.Server/GameServer.Accounts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
27 changes: 19 additions & 8 deletions OpenPolytopia.Server/GameServer.Persistence.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,37 +15,44 @@ public partial class GameServer {
private readonly Dictionary<uint, string> _pendingRenames = new();

private sealed record SavedSession(ulong Id, GameSnapshot Game, Dictionary<int, uint> Accounts,
Dictionary<int, string> Names);
Dictionary<int, string> Names, TurnClockState? Clock);
private sealed record SavedServer(int Version, ulong NextLobbyId, List<LobbyData> Lobbies,
List<SavedSession> 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<SavedServer>(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;
}
Expand Down Expand Up @@ -73,8 +80,12 @@ private async Task WithStateAsync(Func<Task> 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;
Expand Down
84 changes: 84 additions & 0 deletions OpenPolytopia.Server/GameServer.Timers.cs
Original file line number Diff line number Diff line change
@@ -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<ResolveOverdueTurnPacket>((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<uint> 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);
}
}
}
}
Loading
Loading