namespace GeoSus.Server; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; // SQLite databáze pro statistiky hráčů public class StatsDb : IDisposable { private readonly SqliteConnection _connection; private readonly ILogger _logger; private readonly object _lock = new(); public StatsDb(string dbPath, ILogger logger) { _logger = logger; var directory = Path.GetDirectoryName(dbPath); if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); _connection = new SqliteConnection($"Data Source={dbPath}"); _connection.Open(); InitializeSchema(); } private void InitializeSchema() { var sql = @" CREATE TABLE IF NOT EXISTS player_stats ( client_uuid TEXT PRIMARY KEY, display_name TEXT, total_games INTEGER DEFAULT 0, games_as_crew INTEGER DEFAULT 0, games_as_impostor INTEGER DEFAULT 0, crew_wins INTEGER DEFAULT 0, impostor_wins INTEGER DEFAULT 0, total_kills INTEGER DEFAULT 0, total_deaths INTEGER DEFAULT 0, tasks_completed INTEGER DEFAULT 0, bodies_reported INTEGER DEFAULT 0, emergency_meetings_called INTEGER DEFAULT 0, times_voted_out INTEGER DEFAULT 0, successful_votes INTEGER DEFAULT 0, total_playtime_seconds INTEGER DEFAULT 0, cheat_incidents INTEGER DEFAULT 0, last_seen_utc TEXT, created_at_utc TEXT ); CREATE INDEX IF NOT EXISTS idx_crew_wins ON player_stats(crew_wins DESC); CREATE INDEX IF NOT EXISTS idx_impostor_wins ON player_stats(impostor_wins DESC); CREATE INDEX IF NOT EXISTS idx_total_games ON player_stats(total_games DESC); "; using var cmd = _connection.CreateCommand(); cmd.CommandText = sql; cmd.ExecuteNonQuery(); _logger.LogInformation("StatsDb inicializována"); } #region CRUD operace public void EnsurePlayerExists(string clientUuid, string displayName) { lock (_lock) { var sql = @" INSERT INTO player_stats (client_uuid, display_name, created_at_utc, last_seen_utc) VALUES (@uuid, @name, @now, @now) ON CONFLICT(client_uuid) DO UPDATE SET display_name = @name, last_seen_utc = @now "; using var cmd = _connection.CreateCommand(); cmd.CommandText = sql; cmd.Parameters.AddWithValue("@uuid", clientUuid); cmd.Parameters.AddWithValue("@name", displayName); cmd.Parameters.AddWithValue("@now", DateTime.UtcNow.ToString("o")); cmd.ExecuteNonQuery(); } } public PlayerStats? GetPlayerStats(string clientUuid) { lock (_lock) { var sql = "SELECT * FROM player_stats WHERE client_uuid = @uuid"; using var cmd = _connection.CreateCommand(); cmd.CommandText = sql; cmd.Parameters.AddWithValue("@uuid", clientUuid); using var reader = cmd.ExecuteReader(); if (reader.Read()) { return ReadPlayerStats(reader); } return null; } } public List GetLeaderboard(string sortBy, int limit = 100) { lock (_lock) { var validColumns = new[] { "crew_wins", "impostor_wins", "total_games", "total_kills", "tasks_completed" }; if (!validColumns.Contains(sortBy)) sortBy = "total_games"; var sql = $"SELECT * FROM player_stats ORDER BY {sortBy} DESC LIMIT @limit"; using var cmd = _connection.CreateCommand(); cmd.CommandText = sql; cmd.Parameters.AddWithValue("@limit", limit); var results = new List(); using var reader = cmd.ExecuteReader(); while (reader.Read()) { results.Add(ReadPlayerStats(reader)); } return results; } } #endregion #region Inkrementální aktualizace public void IncrementKills(string clientUuid) { IncrementColumn(clientUuid, "total_kills"); } public void IncrementDeaths(string clientUuid) { IncrementColumn(clientUuid, "total_deaths"); } public void IncrementTasksCompleted(string clientUuid) { IncrementColumn(clientUuid, "tasks_completed"); } public void IncrementBodiesReported(string clientUuid) { IncrementColumn(clientUuid, "bodies_reported"); } public void IncrementEmergencyMeetings(string clientUuid) { IncrementColumn(clientUuid, "emergency_meetings_called"); } public void IncrementTimesVotedOut(string clientUuid) { IncrementColumn(clientUuid, "times_voted_out"); } public void IncrementSuccessfulVotes(string clientUuid) { IncrementColumn(clientUuid, "successful_votes"); } public void IncrementCheatIncidents(string clientUuid) { IncrementColumn(clientUuid, "cheat_incidents"); } private void IncrementColumn(string clientUuid, string column) { lock (_lock) { var sql = $@" UPDATE player_stats SET {column} = {column} + 1, last_seen_utc = @now WHERE client_uuid = @uuid "; using var cmd = _connection.CreateCommand(); cmd.CommandText = sql; cmd.Parameters.AddWithValue("@uuid", clientUuid); cmd.Parameters.AddWithValue("@now", DateTime.UtcNow.ToString("o")); cmd.ExecuteNonQuery(); } } #endregion #region Game end aktualizace public void RecordGameEnd(GameEndStats stats) { lock (_lock) { using var transaction = _connection.BeginTransaction(); try { foreach (var player in stats.Players) { var sql = @" UPDATE player_stats SET total_games = total_games + 1, games_as_crew = games_as_crew + @asCrew, games_as_impostor = games_as_impostor + @asImpostor, crew_wins = crew_wins + @crewWin, impostor_wins = impostor_wins + @impostorWin, total_playtime_seconds = total_playtime_seconds + @playtime, last_seen_utc = @now WHERE client_uuid = @uuid "; using var cmd = _connection.CreateCommand(); cmd.CommandText = sql; cmd.Parameters.AddWithValue("@uuid", player.ClientUuid); cmd.Parameters.AddWithValue("@asCrew", player.WasCrew ? 1 : 0); cmd.Parameters.AddWithValue("@asImpostor", player.WasImpostor ? 1 : 0); cmd.Parameters.AddWithValue("@crewWin", player.WasCrew && stats.CrewWon ? 1 : 0); cmd.Parameters.AddWithValue("@impostorWin", player.WasImpostor && !stats.CrewWon ? 1 : 0); cmd.Parameters.AddWithValue("@playtime", player.PlaytimeSeconds); cmd.Parameters.AddWithValue("@now", DateTime.UtcNow.ToString("o")); cmd.ExecuteNonQuery(); } transaction.Commit(); _logger.LogInformation("Game stats uloženy pro {Count} hráčů", stats.Players.Count); } catch { transaction.Rollback(); throw; } } } #endregion private PlayerStats ReadPlayerStats(SqliteDataReader reader) { return new PlayerStats { ClientUuid = reader.GetString(reader.GetOrdinal("client_uuid")), DisplayName = reader.IsDBNull(reader.GetOrdinal("display_name")) ? null : reader.GetString(reader.GetOrdinal("display_name")), TotalGames = reader.GetInt32(reader.GetOrdinal("total_games")), GamesAsCrew = reader.GetInt32(reader.GetOrdinal("games_as_crew")), GamesAsImpostor = reader.GetInt32(reader.GetOrdinal("games_as_impostor")), CrewWins = reader.GetInt32(reader.GetOrdinal("crew_wins")), ImpostorWins = reader.GetInt32(reader.GetOrdinal("impostor_wins")), TotalKills = reader.GetInt32(reader.GetOrdinal("total_kills")), TotalDeaths = reader.GetInt32(reader.GetOrdinal("total_deaths")), TasksCompleted = reader.GetInt32(reader.GetOrdinal("tasks_completed")), BodiesReported = reader.GetInt32(reader.GetOrdinal("bodies_reported")), EmergencyMeetingsCalled = reader.GetInt32(reader.GetOrdinal("emergency_meetings_called")), TimesVotedOut = reader.GetInt32(reader.GetOrdinal("times_voted_out")), SuccessfulVotes = reader.GetInt32(reader.GetOrdinal("successful_votes")), TotalPlaytimeSeconds = reader.GetInt32(reader.GetOrdinal("total_playtime_seconds")), CheatIncidents = reader.GetInt32(reader.GetOrdinal("cheat_incidents")), LastSeenUtc = reader.IsDBNull(reader.GetOrdinal("last_seen_utc")) ? null : reader.GetString(reader.GetOrdinal("last_seen_utc")), CreatedAtUtc = reader.IsDBNull(reader.GetOrdinal("created_at_utc")) ? null : reader.GetString(reader.GetOrdinal("created_at_utc")) }; } public void Dispose() { _connection.Close(); _connection.Dispose(); } } #region DTO public class PlayerStats { public required string ClientUuid { get; set; } public string? DisplayName { get; set; } public int TotalGames { get; set; } public int GamesAsCrew { get; set; } public int GamesAsImpostor { get; set; } public int CrewWins { get; set; } public int ImpostorWins { get; set; } public int TotalKills { get; set; } public int TotalDeaths { get; set; } public int TasksCompleted { get; set; } public int BodiesReported { get; set; } public int EmergencyMeetingsCalled { get; set; } public int TimesVotedOut { get; set; } public int SuccessfulVotes { get; set; } public int TotalPlaytimeSeconds { get; set; } public int CheatIncidents { get; set; } public string? LastSeenUtc { get; set; } public string? CreatedAtUtc { get; set; } // Computed properties pro API public double CrewWinRate => GamesAsCrew > 0 ? (double)CrewWins / GamesAsCrew : 0; public double ImpostorWinRate => GamesAsImpostor > 0 ? (double)ImpostorWins / GamesAsImpostor : 0; public double KillDeathRatio => TotalDeaths > 0 ? (double)TotalKills / TotalDeaths : TotalKills; public double AverageTasksPerGame => TotalGames > 0 ? (double)TasksCompleted / TotalGames : 0; } public class GameEndStats { public bool CrewWon { get; set; } public List Players { get; set; } = new(); } public class PlayerGameStats { public required string ClientUuid { get; set; } public bool WasCrew { get; set; } public bool WasImpostor { get; set; } public int PlaytimeSeconds { get; set; } } #endregion