using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using SlipItIn.Server.Data; using SlipItIn.Server.Services; using SlipItIn.Shared.DTOs; using System.Security.Claims; namespace SlipItIn.Server.Hubs; [Authorize] public class GameHub : Hub { private readonly IGameService _gameService; private readonly IDbContextFactory _contextFactory; private readonly ILogger _logger; public GameHub(IGameService gameService, IDbContextFactory contextFactory, ILogger logger) { _gameService = gameService; _contextFactory = contextFactory; _logger = logger; } /// /// Validiert die JWT-Claims des aktuellen Nutzers /// Wirft Exception wenn User nicht authentifiziert oder Claim fehlt /// private int GetAuthenticatedUserId() { var userIdClaim = Context.User?.FindFirst(ClaimTypes.NameIdentifier); if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out var userId)) throw new UnauthorizedAccessException("Invalid or missing user claim"); return userId; } /// /// Sichert ab, dass ein Spieler nur auf seine eigenen Daten zugreift /// private async Task ValidatePlayerAccessAsync(int playerId, int authUserId) { using var context = _contextFactory.CreateDbContext(); var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId); if (player?.UserId != authUserId) throw new UnauthorizedAccessException("Player does not belong to authenticated user"); } private async Task ValidateUserInGameAsync(int gameId, int authUserId) { using var context = _contextFactory.CreateDbContext(); var isParticipant = await context.Players.AnyAsync(p => p.GameId == gameId && p.UserId == authUserId); if (!isParticipant) throw new UnauthorizedAccessException("User is not part of this game"); } /// /// Speichert die ConnectionId für einen Spieler in der Datenbank /// private async Task UpdatePlayerConnectionIdAsync(int userId, int gameId, string connectionId) { using var context = _contextFactory.CreateDbContext(); var player = await context.Players .FirstOrDefaultAsync(p => p.UserId == userId && p.GameId == gameId); if (player != null) { player.ConnectionId = connectionId; await context.SaveChangesAsync(); } } /// /// Validiert dass der authentifizierte User der Host des Spiels ist /// private async Task ValidateHostAccessAsync(int gameId, int authUserId) { using var context = _contextFactory.CreateDbContext(); var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId); if (game?.HostId != authUserId) throw new UnauthorizedAccessException("Only the host can perform this action"); } /// /// Validiert dass der authentifizierte User der Ziel-Spieler einer Challenge ist /// private async Task ValidateChallengeTargetAccessAsync(int challengeId, int authUserId) { using var context = _contextFactory.CreateDbContext(); var challenge = await context.SlipChallenges .Include(c => c.TargetPlayer) .FirstOrDefaultAsync(c => c.Id == challengeId); if (challenge == null) throw new InvalidOperationException("Challenge not found"); if (challenge.TargetPlayer.UserId != authUserId) throw new UnauthorizedAccessException("Only the accused player can resolve this challenge"); return challenge.TargetPlayerId; } // Lobby-Verwaltung public async Task CreateLobby() { try { var userId = GetAuthenticatedUserId(); var connectionId = Context.ConnectionId; var game = await _gameService.CreateGameAsync(userId, connectionId); await Groups.AddToGroupAsync(connectionId, game.LobbyCode); await Clients.Caller.SendAsync("LobbyCreated", new { GameId = game.Id, LobbyCode = game.LobbyCode }); _logger.LogInformation("Lobby {LobbyCode} created by user {UserId}", game.LobbyCode, userId); } catch (UnauthorizedAccessException) { await Clients.Caller.SendAsync("Error", new { Message = "Unauthorized" }); } catch (Exception ex) { _logger.LogError(ex, "Error creating lobby"); await Clients.Caller.SendAsync("Error", new { Message = "An error occurred while creating the lobby." }); } } public async Task JoinLobby(string lobbyCode) { try { var userId = GetAuthenticatedUserId(); var connectionId = Context.ConnectionId; var game = await _gameService.JoinGameAsync(lobbyCode, userId, connectionId); await Groups.AddToGroupAsync(connectionId, lobbyCode); var gameState = await _gameService.GetGameStateAsync(game.Id); await Clients.Group(lobbyCode).SendAsync("PlayerJoined", gameState); _logger.LogInformation("User {UserId} joined lobby {LobbyCode}", userId, lobbyCode); } catch (UnauthorizedAccessException) { await Clients.Caller.SendAsync("Error", new { Message = "Unauthorized" }); } catch (InvalidOperationException ex) { // Geprüfte Fachfehler mit festen, harmonsierten Texten dürfen an den Client. await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); } catch (Exception ex) { _logger.LogError(ex, "Error joining lobby"); await Clients.Caller.SendAsync("Error", new { Message = "An error occurred while joining the lobby." }); } } public async Task PlayerReady(int gameId, int playerId) { try { var userId = GetAuthenticatedUserId(); await ValidatePlayerAccessAsync(playerId, userId); var game = await _gameService.SetPlayerReadyAsync(gameId, playerId); var gameState = await _gameService.GetGameStateAsync(gameId); var lobbyCode = game.LobbyCode; await Clients.Group(lobbyCode).SendAsync("GameStateUpdated", gameState); } catch (UnauthorizedAccessException ex) { await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); } catch (Exception ex) { _logger.LogError(ex, "Error setting player ready"); await Clients.Caller.SendAsync("Error", new { Message = "An error occurred while setting player status." }); } } // Spiel-Start & Kartenvergabe public async Task StartGame(int gameId) { try { // Nur der Host darf das Spiel starten var userId = GetAuthenticatedUserId(); await ValidateHostAccessAsync(gameId, userId); var game = await _gameService.StartGameAsync(gameId); var cards = await _gameService.DealCardsAsync(gameId); var lobbyCode = game.LobbyCode; await Clients.Group(lobbyCode).SendAsync("GameStarted", new { GameId = gameId }); // Alle Spieler und ihre ConnectionIds in einem Batch laden (Optimierung) using var context = _contextFactory.CreateDbContext(); var playerIds = cards.Select(c => c.PlayerId).Distinct().ToList(); var players = await context.Players .Where(p => playerIds.Contains(p.Id)) .ToDictionaryAsync(p => p.Id, p => p.ConnectionId); // Jeder Spieler erhält nur SEINE eigenen Karten foreach (var playerCards in cards.GroupBy(c => c.PlayerId)) { var hand = await _gameService.GetPlayerHandAsync(playerCards.Key); if (players.TryGetValue(playerCards.Key, out var connectionId) && connectionId != null) { await Clients.Client(connectionId).SendAsync("PlayerHandUpdated", hand); } } _logger.LogInformation("Game {GameId} started by user {UserId}", gameId, userId); } catch (UnauthorizedAccessException) { await Clients.Caller.SendAsync("Error", new { Message = "Only the host can start the game." }); } catch (Exception ex) { _logger.LogError(ex, "Error starting game"); await Clients.Caller.SendAsync("Error", new { Message = "An error occurred while starting the game." }); } } // Slip-Mechaniken public async Task SubmitSlip(int gameId, int playerId, int cardId) { try { var userId = GetAuthenticatedUserId(); await ValidatePlayerAccessAsync(playerId, userId); var result = await _gameService.SubmitSlipAsync(gameId, playerId, cardId); var game = await _gameService.GetGameAsync(gameId); await Clients.Group(game.LobbyCode).SendAsync("SlipSubmitted", new { PlayerId = playerId, CardId = cardId, Success = result }); } catch (UnauthorizedAccessException ex) { await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); } catch (InvalidOperationException ex) { // Geprüfte Fachfehler (z. B. "Round is not active") dürfen an den Client. await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); } catch (Exception ex) { _logger.LogError(ex, "Error submitting slip"); await Clients.Caller.SendAsync("Error", new { Message = "An error occurred while submitting slip." }); } } public async Task ChallengeSlip(int gameId, int challengingPlayerId, int targetCardId) { try { var userId = GetAuthenticatedUserId(); await ValidatePlayerAccessAsync(challengingPlayerId, userId); var challenge = await _gameService.CreateChallengeAsync(gameId, challengingPlayerId, targetCardId); var game = await _gameService.GetGameAsync(gameId); using var context = _contextFactory.CreateDbContext(); var challengingPlayer = await context.Players .Include(p => p.User) .FirstOrDefaultAsync(p => p.Id == challengingPlayerId); var challengeDto = new SlipChallengeDto { ChallengeId = challenge.Id, ChallengingPlayerId = challengingPlayerId, ChallengingPlayerName = challengingPlayer?.User.Username ?? string.Empty, TargetPlayerId = challenge.TargetPlayerId, TargetCardId = targetCardId, Status = challenge.Status.ToString(), CreatedAt = challenge.CreatedAt }; await Clients.Group(game.LobbyCode).SendAsync("SlipChallenged", challengeDto); // Sende direkte Notification an den beschuldigten Spieler var targetPlayer = await context.Players.FindAsync(challenge.TargetPlayerId); if (targetPlayer?.ConnectionId != null) { await Clients.Client(targetPlayer.ConnectionId).SendAsync("ChallengeReceived", challengeDto); } } catch (UnauthorizedAccessException ex) { await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); } catch (InvalidOperationException ex) { // Geprüfte Fachfehler (z. B. "Card does not belong to the active round") dürfen an den Client. await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); } catch (Exception ex) { _logger.LogError(ex, "Error challenging slip"); await Clients.Caller.SendAsync("Error", new { Message = "An error occurred while challenging slip." }); } } public async Task ResolveChallenge(int challengeId, bool approved) { try { var userId = GetAuthenticatedUserId(); await ValidateChallengeTargetAccessAsync(challengeId, userId); var result = await _gameService.ResolveChallengeAsync(challengeId, approved); var game = await _gameService.GetGameAsync(result.GameRound.GameId); await Clients.Group(game.LobbyCode).SendAsync("ChallengeResolved", new { ChallengeId = challengeId, Approved = approved }); // Aktualisierte Handkarten an betroffene Spieler senden var challengingHand = await _gameService.GetPlayerHandAsync(result.ChallengingPlayerId); var targetHand = await _gameService.GetPlayerHandAsync(result.TargetPlayerId); using var context = _contextFactory.CreateDbContext(); var challengingPlayer = await context.Players.FindAsync(result.ChallengingPlayerId); var targetPlayer = await context.Players.FindAsync(result.TargetPlayerId); if (challengingPlayer?.ConnectionId != null) await Clients.Client(challengingPlayer.ConnectionId).SendAsync("PlayerHandUpdated", challengingHand); if (targetPlayer?.ConnectionId != null) await Clients.Client(targetPlayer.ConnectionId).SendAsync("PlayerHandUpdated", targetHand); } catch (UnauthorizedAccessException ex) { await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); } catch (InvalidOperationException ex) { await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); } catch (Exception ex) { _logger.LogError(ex, "Error resolving challenge"); await Clients.Caller.SendAsync("Error", new { Message = "An error occurred while resolving challenge." }); } } public async Task RequestGameState(int gameId) { try { var userId = GetAuthenticatedUserId(); await ValidateUserInGameAsync(gameId, userId); var gameState = await _gameService.GetGameStateAsync(gameId); await Clients.Caller.SendAsync("GameStateUpdated", gameState); } catch (UnauthorizedAccessException ex) { await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); } catch (Exception ex) { _logger.LogError(ex, "Error requesting game state"); await Clients.Caller.SendAsync("Error", new { Message = "An error occurred while requesting game state." }); } } public override async Task OnConnectedAsync() { _logger.LogInformation("Client {ConnectionId} connected", Context.ConnectionId); // Speichere ConnectionId beim Player falls User bereits in einem Spiel ist (Lobby oder InProgress) try { var userId = GetAuthenticatedUserId(); using var context = _contextFactory.CreateDbContext(); var player = await context.Players .FirstOrDefaultAsync(p => p.UserId == userId && (p.Game.Status == Shared.Models.GameStatus.InProgress || p.Game.Status == Shared.Models.GameStatus.Lobby)); if (player != null) { player.ConnectionId = Context.ConnectionId; await context.SaveChangesAsync(); // (Re-)Join der Lobby-Gruppe, damit Group-Broadcasts nach einem Reconnect wieder ankommen. var lobbyCode = await context.Games .Where(g => g.Id == player.GameId) .Select(g => g.LobbyCode) .FirstAsync(); await Groups.AddToGroupAsync(Context.ConnectionId, lobbyCode); // Der Client bekommt nach einem Reconnect keinen vollständigen Zustand // mehr aus der Queue gespielt — er muss RequestGameState aufrufen. } } catch (UnauthorizedAccessException) { // Nicht authentifiziert — ConnectionId wird nicht gespeichert. } catch (Exception ex) { _logger.LogWarning(ex, "Failed to register connection for authenticated user"); } await base.OnConnectedAsync(); } public override async Task OnDisconnectedAsync(Exception? exception) { _logger.LogInformation("Client {ConnectionId} disconnected", Context.ConnectionId); // ConnectionId zurücksetzen, damit keine Nachrichten an eine tote Verbindung gehen. try { using var context = _contextFactory.CreateDbContext(); var players = await context.Players .Where(p => p.ConnectionId == Context.ConnectionId) .ToListAsync(); foreach (var player in players) player.ConnectionId = null; await context.SaveChangesAsync(); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to clear connection id on disconnect"); } await base.OnDisconnectedAsync(exception); } }