Some checks failed
Build / build (pull_request) Has been cancelled
Doku/Repo: - README.md mit Setup, Run-Anleitung, Konfiguration und Sprachpolicy neu geschrieben - appsettings.json: committed JWT-Key und irreführende LocalDB-Connection-String entfernt - appsettings.Development.json: expliziter Dev-Only JWT-Key hinterlegt - build.yml: Build-CI für master/PRs hinzugefügt - slnx/aspire.config.json: Pfad-Casing korrigiert (Linux-kompatibel) - .gitignore: Kommentar zu den Legacy-SQLite-Einträgen Server: - Jwt:Key ist in Production Pflicht (Fail-fast statt Fallback-Secret) - Passwort-Mindestlänge 8 Zeichen bei Registrierung - StartGame: nur aus Lobby, mindestens 2 Spieler - SetPlayerReady: gameId/Status geprüft - SubmitSlip: Game-/Round-Status, Round-Membership, IsUsed validiert - CreateChallenge: Round-Bindung, kein Self-Challenge, nur gespielte Karten, keine Doppel-Challenges - ResolveChallenge: Pending-Guard, Scoring (Score/SuccessfulSlips/FailedSlips), Approved-Transfer gefixt - GetPlayerHand: nur Karten der aktiven Runde - RoundTimeRemaining: StartedAt wird bei Round-Aktivierung gesetzt - Lobby-Code: crypto-random mit Kollisions-Retry - EF: explizite NpgsqlDataSource-Auflösung aus DI - Hub: Reconnect-Gruppenbeitritt, ConnectionId-Cleanup beim Disconnect, ChallengingPlayerName gesetzt, SlipChallenged/ChallengeResolved konsistent Client: - Offline-Start löscht Session nicht mehr (NetworkError vs. Invalid unterschieden) - ResolveChallenge geht durch die Offline-Queue (kein Crash mehr bei Disconnect) - Queue: Poison-Entries werden nach 3 Retries verworfen statt die Queue ewig zu blockieren - SignalR: Verbindung wird sauber disposed/rebuildet, Token immer zur Laufzeit gelesen, Connect-Lock gegen parallele Starts - ViewModels: Thread-Marshaling in allen Receive-Handlern, kein async void mehr - LobbyViewModel: Logout deaktiviert den Singleton (kein stale-Navigation mehr), Reaktivierung via Activate() - GameBoard: lokaler 1s-Timer für RoundTimeRemaining - Overlay: x:DataType für kompilierte Bindings, Frame→Border - csproj: Preview-Logging-Package auf 10.0.10, Template-Cruft entfernt
438 lines
17 KiB
C#
438 lines
17 KiB
C#
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<SlipItInDbContext> _contextFactory;
|
|
private readonly ILogger<GameHub> _logger;
|
|
|
|
public GameHub(IGameService gameService, IDbContextFactory<SlipItInDbContext> contextFactory, ILogger<GameHub> logger)
|
|
{
|
|
_gameService = gameService;
|
|
_contextFactory = contextFactory;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validiert die JWT-Claims des aktuellen Nutzers
|
|
/// Wirft Exception wenn User nicht authentifiziert oder Claim fehlt
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sichert ab, dass ein Spieler nur auf seine eigenen Daten zugreift
|
|
/// </summary>
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Speichert die ConnectionId für einen Spieler in der Datenbank
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validiert dass der authentifizierte User der Host des Spiels ist
|
|
/// </summary>
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validiert dass der authentifizierte User der Ziel-Spieler einer Challenge ist
|
|
/// </summary>
|
|
private async Task<int> 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);
|
|
}
|
|
}
|