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
244 changes: 244 additions & 0 deletions OpenPolytopia.Common/Gameplay/Game.Persistence.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
namespace OpenPolytopia.Common.Gameplay;

using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// Capture and restore of a whole match, for persisting a game between two runs of the server
/// </summary>
/// <remarks>
/// A snapshot only carries what a game mutates; everything else it's built from is content, so
/// <see cref="Game.Restore"/> asks for it again and a restored game shares nothing with the one it came from
/// </remarks>
public partial class Game {
/// <summary>
/// Rebuilds a game from its parts, without running any of the setup the public constructor does
/// </summary>
/// <remarks>
/// The public constructor builds every <see cref="PlayerState"/> from the tribe, which is exactly what a restore
/// can't do: a restored player has the stars, the score and the researched nodes of the snapshot, not the ones a
/// tribe starts with
/// </remarks>
/// <param name="grid">the grid, already filled</param>
/// <param name="cityManager">the city manager, with every city already registered</param>
/// <param name="troopManager">the troop manager, already filled</param>
/// <param name="buildingManager">the registered building definitions</param>
/// <param name="settings">the settings of the game</param>
/// <param name="players">the players, in turn order, already restored</param>
private Game(Grid grid, CityManager cityManager, TroopManager troopManager, BuildingManager buildingManager,
GameSettings settings, List<PlayerState> players) {
Grid = grid;
Cities = cityManager;
Troops = troopManager;
Buildings = buildingManager;
Settings = settings;
Players = players;
_playersById = players.ToDictionary(player => player.Id);
_turnIndexById = players.Select((player, index) => (player.Id, index))
.ToDictionary(entry => entry.Id, entry => entry.index);
}

/// <summary>
/// Captures everything this game mutates into a snapshot
/// </summary>
/// <remarks>
/// The snapshot owns its arrays, so playing on after taking one never changes what was captured
/// </remarks>
/// <returns>the snapshot, restorable with <see cref="Restore"/></returns>
public GameSnapshot ToSnapshot() {
var cells = Grid.Size * Grid.Size;
var tiles = new ulong[cells];
var troops = new uint[cells];

for (var index = 0u; index < cells; index++) {
tiles[index] = Grid[index].Raw;
troops[index] = Troops[index].Raw;
}

return new GameSnapshot {
Version = GameSnapshot.CURRENT_VERSION,
GridSize = Grid.Size,
Tiles = tiles,
Troops = troops,
CityIndexes = [.. Cities.Cities],
Players = [
.. Players.Select(player => new PlayerSnapshot {
Id = player.Id,
Tribe = player.Tribe,
Stars = player.Stars,
Score = player.Score.ScoreValue,
Alive = player.Alive,
ResearchedTechs = [.. player.TechTree.ResearchedIds()]
})
],
MaxTurns = Settings.MaxTurns,
Turn = Turn,
CurrentPlayer = CurrentPlayer,
Started = Started,
Over = Over,
Winner = Winner
};
}

/// <summary>
/// Rebuilds the game a snapshot was taken from
/// </summary>
/// <remarks>
/// The content arguments have to be the same the original game was built from: a snapshot stores the researched
/// node ids, not the tech tree, and the raw tiles/troops, not the definitions they refer to, so restoring against
/// different content gives a game that isn't the one that was saved
/// <br/>
/// <paramref name="troopManager"/> is taken instead of built because only the caller has the troop definitions to
/// register in it; it must be empty and sized like the grid of the snapshot. The grid and the city manager are
/// built here because both are fully described by the snapshot
/// </remarks>
/// <param name="snapshot">the snapshot to restore</param>
/// <param name="troopManager">an empty troop manager, sized <see cref="GameSnapshot.GridSize"/>, with the troop definitions registered</param>
/// <param name="tribeManager">the registered tribes</param>
/// <param name="buildingManager">the registered building definitions</param>
/// <param name="techTreeDefinition">the shape of the tech tree the original game was built from</param>
/// <returns>the restored game, ready to be played on</returns>
/// <exception cref="ArgumentNullException">if any argument, or any member of the snapshot, is null</exception>
/// <exception cref="ArgumentException">
/// if the snapshot wasn't written by <see cref="GameSnapshot.CURRENT_VERSION"/>, if its arrays don't have one entry per
/// grid cell, if <paramref name="troopManager"/> isn't sized like the grid, if it doesn't have 2 to 16 players, if
/// a player id is out of range or used twice, if a tribe isn't registered, if a researched node isn't in the tech
/// tree of its player, if a city index is outside the grid or if there are more than 255 cities
/// </exception>
public static Game Restore(GameSnapshot snapshot, TroopManager troopManager, TribeManager tribeManager,
BuildingManager buildingManager, TechTreeDefinition techTreeDefinition) {
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentNullException.ThrowIfNull(troopManager);
ArgumentNullException.ThrowIfNull(tribeManager);
ArgumentNullException.ThrowIfNull(buildingManager);
ArgumentNullException.ThrowIfNull(techTreeDefinition);
ArgumentNullException.ThrowIfNull(snapshot.Tiles);
ArgumentNullException.ThrowIfNull(snapshot.Troops);
ArgumentNullException.ThrowIfNull(snapshot.CityIndexes);
ArgumentNullException.ThrowIfNull(snapshot.Players);

if (snapshot.Version != GameSnapshot.CURRENT_VERSION) {
throw new ArgumentException(
$"snapshot version {snapshot.Version} can't be restored; this build writes version {GameSnapshot.CURRENT_VERSION}",
nameof(snapshot));
}

if (snapshot.GridSize == 0) {
throw new ArgumentException("a snapshot of a game has a grid", nameof(snapshot));
}

var cells = snapshot.GridSize * snapshot.GridSize;
if (snapshot.Tiles.Length != cells) {
throw new ArgumentException(
$"a {snapshot.GridSize}x{snapshot.GridSize} grid has {cells} tiles, the snapshot has {snapshot.Tiles.Length}",
nameof(snapshot));
}

if (snapshot.Troops.Length != cells) {
throw new ArgumentException(
$"a {snapshot.GridSize}x{snapshot.GridSize} grid has {cells} cells, the snapshot has {snapshot.Troops.Length} troop slots",
nameof(snapshot));
}

if (troopManager.Size != snapshot.GridSize) {
throw new ArgumentException(
$"the troop manager is sized {troopManager.Size}, the snapshot needs {snapshot.GridSize}", nameof(troopManager));
}

if (snapshot.Players.Count is < 2 or > 16) {
throw new ArgumentException($"a game needs 2 to 16 players, the snapshot has {snapshot.Players.Count}",
nameof(snapshot));
}

var grid = new Grid(snapshot.GridSize);
var cityManager = new CityManager(grid);

// the cities go in first: registering one writes into its tile, so the raw tiles have to be written after it
foreach (var index in snapshot.CityIndexes) {
if (index >= cells) {
throw new ArgumentException($"city index {index} is outside a {snapshot.GridSize}x{snapshot.GridSize} grid",
nameof(snapshot));
}

cityManager.RegisterCity(index);
}

for (var index = 0u; index < cells; index++) {
grid[index] = new Tile { Raw = snapshot.Tiles[index] };
troopManager.SetRaw(index, snapshot.Troops[index]);
}

var players = new List<PlayerState>(snapshot.Players.Count);
var seenIds = new HashSet<int>(snapshot.Players.Count);

foreach (var playerSnapshot in snapshot.Players) {
ArgumentNullException.ThrowIfNull(playerSnapshot, nameof(snapshot));
ArgumentNullException.ThrowIfNull(playerSnapshot.ResearchedTechs, nameof(snapshot));

if (playerSnapshot.Id is < 1 or > 16) {
throw new ArgumentException($"player id {playerSnapshot.Id} is out of range; ids must be between 1 and 16",
nameof(snapshot));
}

if (!seenIds.Add(playerSnapshot.Id)) {
throw new ArgumentException($"player id {playerSnapshot.Id} is used by more than one player", nameof(snapshot));
}

var tribe = tribeManager[playerSnapshot.Tribe] ??
throw new ArgumentException($"tribe {playerSnapshot.Tribe} of player {playerSnapshot.Id} isn't registered",
nameof(snapshot));

players.Add(RestorePlayer(playerSnapshot, tribe, techTreeDefinition));
}

var game = new Game(grid, cityManager, troopManager, buildingManager,
new GameSettings { MaxTurns = snapshot.MaxTurns }, players) {
Turn = snapshot.Turn,
CurrentPlayer = snapshot.CurrentPlayer,
Started = snapshot.Started,
Over = snapshot.Over,
Winner = snapshot.Winner
};

// a started, unfinished game hands the turn to somebody, and turn order is looked up by that id
if (game is { Started: true, Over: false } && !game._turnIndexById.ContainsKey(game.CurrentPlayer)) {
throw new ArgumentException($"player {snapshot.CurrentPlayer} holds the turn but isn't in the game",
nameof(snapshot));
}

return game;
}

/// <summary>
/// Rebuilds the state of a single player
/// </summary>
/// <remarks>
/// The tech tree is built empty from the definition of the tribe, with the overrides applied exactly like
/// <see cref="TechTreeDefinition.CreateTechTree"/> does, and then only what the snapshot says was researched is
/// marked: the starting node of the tribe isn't researched for free, so a snapshot restores the researched set it
/// captured and nothing else
/// </remarks>
/// <param name="snapshot">the snapshot of the player</param>
/// <param name="tribe">the tribe of the player</param>
/// <param name="techTreeDefinition">the shape of the tech tree, before the overrides of the tribe</param>
/// <returns>the restored state</returns>
/// <exception cref="ArgumentException">if a researched node isn't in the tech tree of this player</exception>
private static PlayerState RestorePlayer(PlayerSnapshot snapshot, Tribe tribe,
TechTreeDefinition techTreeDefinition) {
var definition = tribe.TechOverrides is { Count: > 0 } overrides
? techTreeDefinition.Override(overrides)
: techTreeDefinition;
var techTree = new TechTree(definition);

foreach (var techId in snapshot.ResearchedTechs) {
if (!techTree.Research(techId)) {
throw new ArgumentException($"node {techId} of player {snapshot.Id} isn't in the tech tree", nameof(snapshot));
}
}

var player = new PlayerState(snapshot.Id, snapshot.Tribe, snapshot.Stars, techTree) { Alive = snapshot.Alive };
player.Score.Restore(snapshot.Score);
return player;
}
}
139 changes: 139 additions & 0 deletions OpenPolytopia.Common/Gameplay/GameSnapshot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
namespace OpenPolytopia.Common.Gameplay;

using System.Collections.Generic;

/// <summary>
/// A lossless capture of everything a <see cref="Game"/> mutates while it's played
/// </summary>
/// <remarks>
/// Only mutable state lives here: the content a game is built from (troop definitions, building definitions, tribes
/// and the shape of the tech tree) isn't captured because it never changes during a match, so
/// <see cref="Game.Restore"/> takes it back from the caller
/// <br/>
/// Every member is a primitive, an array of primitives or a list of <see cref="PlayerSnapshot"/>, so a row of a
/// SQLite table maps one to one onto it: the two big arrays go in <c>BLOB</c> columns through
/// <see cref="SnapshotEncoding"/>, the players in a child table keyed by turn order
/// </remarks>
public sealed class GameSnapshot {
/// <summary>
/// The layout version written by <see cref="Game.ToSnapshot"/>
/// </summary>
/// <remarks>
/// Bumped whenever the meaning of a member or of a packed raw value changes, so a snapshot written by an older
/// build is rejected instead of silently restoring a wrong game
/// </remarks>
public const int CURRENT_VERSION = 1;

/// <summary>
/// The layout version this snapshot was written with, see <see cref="CURRENT_VERSION"/>
/// </summary>
public required int Version { get; init; }

/// <summary>
/// The width (and height) of the grid
/// </summary>
public required uint GridSize { get; init; }

/// <summary>
/// The packed representation of every tile, indexed like <see cref="Grid.this[uint]"/>
/// </summary>
/// <remarks>
/// This carries the custom data of the tile too, so the internals of every city (level, population, troops, parks,
/// wall, forge, capital and connected flags) ride along with it
/// </remarks>
public required ulong[] Tiles { get; init; }

/// <summary>
/// The packed representation of every troop, indexed like <see cref="TroopManager.this[uint]"/>
/// </summary>
/// <remarks>
/// A 0 is an empty tile, see <see cref="TroopData.IsValid"/>
/// </remarks>
public required uint[] Troops { get; init; }

/// <summary>
/// The grid index of every registered city, ordered by city id
/// </summary>
/// <remarks>
/// The city with id <c>i</c> is at position <c>i - 1</c>, exactly like <see cref="CityManager.Cities"/>
/// </remarks>
public required uint[] CityIndexes { get; init; }

/// <summary>
/// Every player, in turn order
/// </summary>
public required IReadOnlyList<PlayerSnapshot> Players { get; init; }

/// <summary>
/// <see cref="GameSettings.MaxTurns"/> of the game
/// </summary>
public required uint MaxTurns { get; init; }

/// <summary>
/// <see cref="Game.Turn"/> of the game
/// </summary>
public required uint Turn { get; init; }

/// <summary>
/// <see cref="Game.CurrentPlayer"/> of the game
/// </summary>
public required int CurrentPlayer { get; init; }

/// <summary>
/// <see cref="Game.Started"/> of the game
/// </summary>
public required bool Started { get; init; }

/// <summary>
/// <see cref="Game.Over"/> of the game
/// </summary>
public required bool Over { get; init; }

/// <summary>
/// <see cref="Game.Winner"/> of the game
/// </summary>
public required int Winner { get; init; }
}

/// <summary>
/// A lossless capture of a single <see cref="PlayerState"/>
/// </summary>
/// <remarks>
/// The position of a player in <see cref="GameSnapshot.Players"/> is their turn order, so it has to be preserved by
/// whoever stores it: a SQLite child table needs an explicit order column, a plain automatically assigned id isn't enough
/// </remarks>
public sealed class PlayerSnapshot {
/// <summary>
/// <see cref="PlayerState.Id"/> of the player, 1..16
/// </summary>
public required int Id { get; init; }

/// <summary>
/// <see cref="PlayerState.Tribe"/> of the player
/// </summary>
public required TribeType Tribe { get; init; }

/// <summary>
/// <see cref="PlayerState.Stars"/> of the player
/// </summary>
public required int Stars { get; init; }

/// <summary>
/// <see cref="Score.ScoreValue"/> of the player
/// </summary>
public required int Score { get; init; }

/// <summary>
/// <see cref="PlayerState.Alive"/> of the player
/// </summary>
public required bool Alive { get; init; }

/// <summary>
/// The ids of every node this player researched, as returned by <see cref="TechTree.ResearchedIds"/>
/// </summary>
/// <remarks>
/// The tech tree itself isn't captured: it's rebuilt from the definition and the overrides of the tribe, which are
/// content, and only the researched state is player state
/// </remarks>
public required IReadOnlyList<string> ResearchedTechs { get; init; }
}
Loading
Loading