# Projektplan: Slip It In Dieses Dokument beschreibt die Phasen und Aufgaben zur Implementierung des Spiels "Slip It In" mit .NET MAUI (.NET 10) und einem ASP.NET Core Backend. ## Phase 1: Vorbereitung & Architektur-Setup - [x] Erstellung der Verzeichnisstruktur im Workspace - [x] Aufsetzen der gemeinsamen Datenmodelle (Shared Class Library) - `User`, `Game`, `Phrase`, `PlayerCard` - [x] Initialisierung des ASP.NET Core Web API & SignalR Backends ## Phase 2: Backend-Entwicklung (Web API & SignalR) - [ ] **Datenbank & Persistenz**: Entity Framework Core Setup mit SQLite - [ ] **User Management**: Einfache JWT-basierte Authentifizierung - Registrierung (Username, Email, Passwort) - Login mit BCrypt Password Hashing - JWT Tokens für API-Authentifizierung - Token-Validierung auf API-Endpoints - [ ] **Game Engine & Hub**: - `GameHub` für Echtzeit-Kommunikation - Logik für Lobby-Erstellung und Beitritt via Code - Logik für das Austeilen von 5 zufälligen Phrasen pro Spieler - **Slip-Mechanik:** - Spieler submittet "Slip" zu einer eigenen Phrase - Gegner **beschuldigt** Spieler des "SlipIns" (= falsche Phrase) - Beschuldigter **bestätigt oder lehnt ab** - Bei **falscher Beschuldigung**: Beschuldiger erhält die gelöschte Phrase (Spieler hat dann >5 Phrasen) - Bei **berechtigter Beschuldigung**: Beschuldigter muss Phrase abgeben - Timer-gesteuerte Logik für Slip-Runden (30-60s pro Slip) ## Phase 2b: Backend-Erweiterungen (Sicherheit & In-Memory-State) - [ ] **SignalR Auth Integration**: JWT-Claims Validation im `GameHub` - Verhindert Spoofing von PlayerIds - User-Identity aus JWT extrahiert und validiert - Nur autorisierte Spieler können auf ihre Daten zugreifen - [ ] **Datenschutz/Karten-Isolation**: Trennung von öffentlichen und privaten Daten - `GameStateDto` – Öffentliche Spieldaten (Spieler, Scores, aktive Runden) - `PlayerHandDto` – Private Handkarten (nur für den Spieler selbst sichtbar) - Clients erhalten nur Daten, die sie sehen dürfen - [ ] **EF Core Context Factory**: Umstellung von Scoped auf `IDbContextFactory` - Vermeidet Parallelitätskonflikte bei SignalR-Aufrufen - Jede SignalR-Methode nutzt eine eigene, isolierte DB-Session - Verhindert Race Conditions beim Phrase-Transfer - [ ] **Serverseitige Validierung**: Logik-Verlagerung auf Server - Clients senden nur Wünsche (z.B. "Ich möchte dieser Phrase zuordnen") - Server prüft alle Bedingungen (IsPlayersTurn, CardBelongsToPlayer, etc.) - Server speichert nur validierte Zustandsänderungen in DB ## Phase 3: Client-Entwicklung (.NET MAUI) - [ ] **Infrastruktur**: - MVVM-Setup mit CommunityToolkit.Mvvm - Integration von Dependency Injection für Services - Messenger-Architektur mit `WeakReferenceMessenger` für Event-Entkopplung - [ ] **Services**: - `ApiService` für REST-Anfragen (mit JWT-Token aus `SecureStorage`) - `SignalRService` für Echtzeit-Events mit automatischem Reconnect - `GameStateService` für In-Memory-Spielzustand-Verwaltung - `LocalStorageService` für State-Persistence (Wiederherstellung nach Crash) - [ ] **UI/Views**: - `LoginPage` & `RegisterPage` - `LobbyPage` (Spielerliste & Start-Button für Host) - `GameBoardPage` (Anzeige aller eigenen Phrasen, "Slip"-Button pro Phrase, Timer-Anzeige, gegnerische Spieler-Liste mit "Beschuldigen"-Button) - `ChallengeNotificationOverlay` (Eingangende Beschuldigung: "Bestätigen" oder "Ablehnen"-Button) ## Phase 3b: MAUI Client-Stabilität - [ ] **Messenger-Architektur**: Entkopplung via `WeakReferenceMessenger` - SignalR-Events erzeugen Messages (z.B. `GameStateChangedMessage`) - ViewModels abonnieren Messages statt direkter Service-Events - Verhindert Memory Leaks durch Circular Dependencies - [ ] **State Restoration**: Automatisches Re-Syncing - App-Resume triggert `GameStateService.ResyncAsync()` - SignalR-Reconnect lädt aktuellen Spielzustand vom Server - Offline-Puffering: Lokale Aktionen werden queued und nach Reconnect abgesendet - UI zeigt "Syncing..." bis Verbindung wiederhergestellt ## Phase 4: Testen & Optimierung - [ ] Integrationstest der SignalR-Kommunikation zwischen mehreren MAUI-Clients - [ ] Behandlung von Verbindungsabbrüchen (Reconnection Logic in SignalR) - [ ] UI/UX-Polishing --- ## Implementierungsplan: Datenbank-Entitäten & SignalR GameHub **Datenbank-Entscheidung:** SQLite wird für Development und Production verwendet. Die `slipitIn.db` Datei wird im Server-Projekt abgelegt und über Connection String konfiguriert. ### Architektur-Highlights (Sicherheit & Stabilität) #### Backend-Sicherheit: - **JWT-Claims Validation** im GameHub → Verhindert PlayerId-Spoofing - **IDbContextFactory** statt Scoped DbContext → Isoliert SignalR-Aufrufe, verhindert Race Conditions - **Serverseitige Validierung** → Clients senden nur Wünsche, Server prüft alles - **Daten-Isolation** → `GameStateDto` (öffentlich) vs. `PlayerHandDto` (privat) #### Client-Stabilität: - **WeakReferenceMessenger** → Entkopplung von Services & ViewModels, verhindert Memory Leaks - **Automatischer Reconnect** → SignalR mit exponentialem Backoff (0s, 2s, 10s, 30s) - **State Persistence** → Offline-Aktionen in Queue, Auto-Resync nach App-Resume - **Dual-Layer Storage** → In-Memory (`GameStateService`) + Local (`SecureStorage`/`Preferences`) ### Spielmechanik-Übersicht **Grundkonzept:** 1. Jeder Spieler erhält **mindestens 5 Phrasen** (später können es >5 sein) 2. Ein Spieler **"slippt"** zu einer Phrase (behauptet, diese Phrase war in der Runde) 3. **Andere Spieler können beschuldigen**: "Das war kein echtes SlipIn! Diese Phrase stammte von dir!" 4. Der **beschuldigte Spieler antwortet**: - ✅ **"Stimmt!"** (Beschuldigung berechtigt) → Phrase bleibt beim Beschuldiger - ❌ **"Nein!"** (Beschuldigung falsch) → Beschuldiger erhält die Phrase **Auswirkungen:** - Erfolgreiche Beschuldigung → Beschuldiger hat eine neue Phrase - Falsche Beschuldigung → Beschuldiger muss Phrase abgeben (hat danach evtl. >5) - Spieler haben immer mindestens 5 Phrasen zum Spielen ### Datenbankentitäten (EF Core) #### 1. Shared-Modelle (SlipItIn.Shared/Models/) ##### User.cs ```csharp public class User { public int Id { get; set; } public string Username { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; public string PasswordHash { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? UpdatedAt { get; set; } public bool IsActive { get; set; } = true; // Navigation public ICollection Players { get; set; } = []; } ``` ##### Phrase.cs ```csharp public class Phrase { public int Id { get; set; } public string Text { get; set; } = string.Empty; public int CreatorId { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public bool IsActive { get; set; } = true; // Navigation public User Creator { get; set; } = null!; public ICollection PlayerCards { get; set; } = []; } ``` ##### Game.cs ```csharp public enum GameStatus { Lobby, InProgress, Completed, Cancelled } public class Game { public int Id { get; set; } public string LobbyCode { get; set; } = string.Empty; // 4-6 stelliger Code public int HostId { get; set; } public GameStatus Status { get; set; } = GameStatus.Lobby; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? StartedAt { get; set; } public DateTime? EndedAt { get; set; } public int MaxPlayers { get; set; } = 8; public int RoundDurationSeconds { get; set; } = 45; // Navigation public User Host { get; set; } = null!; public ICollection Players { get; set; } = []; public ICollection Rounds { get; set; } = []; } ``` ##### Player.cs ```csharp public class Player { public int Id { get; set; } public int UserId { get; set; } public int GameId { get; set; } public int Score { get; set; } = 0; public int SuccessfulSlips { get; set; } = 0; public int FailedSlips { get; set; } = 0; public DateTime JoinedAt { get; set; } = DateTime.UtcNow; public bool IsReady { get; set; } = false; public string? ConnectionId { get; set; } // SignalR-Verbindungs-ID // Navigation public User User { get; set; } = null!; public Game Game { get; set; } = null!; public ICollection Cards { get; set; } = []; } ``` ##### PlayerCard.cs ```csharp public class PlayerCard { public int Id { get; set; } public int PlayerId { get; set; } public int PhraseId { get; set; } public int GameRoundId { get; set; } public bool IsUsed { get; set; } = false; public DateTime AssignedAt { get; set; } = DateTime.UtcNow; // Navigation public Player Player { get; set; } = null!; public Phrase Phrase { get; set; } = null!; public GameRound GameRound { get; set; } = null!; } ``` ##### GameRound.cs ```csharp public enum RoundStatus { Waiting, Active, Resolving, Completed } public class GameRound { public int Id { get; set; } public int GameId { get; set; } public int RoundNumber { get; set; } public RoundStatus Status { get; set; } = RoundStatus.Waiting; public DateTime StartedAt { get; set; } = DateTime.UtcNow; public DateTime? EndedAt { get; set; } // Navigation public Game Game { get; set; } = null!; public ICollection ActiveCards { get; set; } = []; public ICollection Challenges { get; set; } = []; } ``` ##### SlipChallenge.cs ```csharp public enum ChallengeStatus { Pending, // Beschuldigung eingegangen, wartet auf Bestätigung/Ablehnung Approved, // Beschuldigung war berechtigt → Phrase bleibt bei Beschuldiger Rejected // Beschuldigung falsch → Beschuldiger erhält die Phrase } public class SlipChallenge { public int Id { get; set; } public int GameRoundId { get; set; } public int ChallengingPlayerId { get; set; } // Wer beschuldigt public int TargetPlayerId { get; set; } // Wer beschuldigt wird public int TargetCardId { get; set; } // Welche Phrase wird als falsch behauptet public ChallengeStatus Status { get; set; } = ChallengeStatus.Pending; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? ResolvedAt { get; set; } // Navigation public GameRound GameRound { get; set; } = null!; public Player ChallengingPlayer { get; set; } = null!; // Der Beschuldiger public Player TargetPlayer { get; set; } = null!; // Der Beschuldigte public PlayerCard TargetCard { get; set; } = null!; // Die angefochtene Phrase } ``` #### 2. DbContext (SlipItIn.Server/Data/SlipItInDbContext.cs) ```csharp public class SlipItInDbContext : DbContext { public SlipItInDbContext(DbContextOptions options) : base(options) { } public DbSet Users { get; set; } public DbSet Phrases { get; set; } public DbSet Games { get; set; } public DbSet Players { get; set; } public DbSet PlayerCards { get; set; } public DbSet GameRounds { get; set; } public DbSet SlipChallenges { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); // User modelBuilder.Entity() .HasIndex(u => u.Username).IsUnique(); modelBuilder.Entity() .HasIndex(u => u.Email).IsUnique(); // Game modelBuilder.Entity() .HasIndex(g => g.LobbyCode).IsUnique(); modelBuilder.Entity() .HasOne(g => g.Host) .WithMany(u => u.Players) .HasForeignKey(g => g.HostId) .OnDelete(DeleteBehavior.Restrict); // Player modelBuilder.Entity() .HasOne(p => p.User) .WithMany(u => u.Players) .HasForeignKey(p => p.UserId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasOne(p => p.Game) .WithMany(g => g.Players) .HasForeignKey(p => p.GameId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasIndex(p => new { p.GameId, p.UserId }).IsUnique(); // PlayerCard modelBuilder.Entity() .HasOne(pc => pc.Player) .WithMany(p => p.Cards) .HasForeignKey(pc => pc.PlayerId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasOne(pc => pc.Phrase) .WithMany(ph => ph.PlayerCards) .HasForeignKey(pc => pc.PhraseId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(pc => pc.GameRound) .WithMany(gr => gr.ActiveCards) .HasForeignKey(pc => pc.GameRoundId) .OnDelete(DeleteBehavior.Cascade); // GameRound modelBuilder.Entity() .HasOne(gr => gr.Game) .WithMany(g => g.Rounds) .HasForeignKey(gr => gr.GameId) .OnDelete(DeleteBehavior.Cascade); // SlipChallenge modelBuilder.Entity() .HasOne(sc => sc.GameRound) .WithMany(gr => gr.Challenges) .HasForeignKey(sc => sc.GameRoundId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasOne(sc => sc.ChallengingPlayer) .WithMany() .HasForeignKey(sc => sc.ChallengingPlayerId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(sc => sc.TargetPlayer) .WithMany() .HasForeignKey(sc => sc.TargetPlayerId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(sc => sc.TargetCard) .WithMany() .HasForeignKey(sc => sc.TargetCardId) .OnDelete(DeleteBehavior.Restrict); } } ``` --- ### SignalR GameHub & DTOs #### 1. SignalR DTOs (SlipItIn.Shared/Hubs/DTOs/) ##### GameStateDto.cs ```csharp public class GameStateDto { public int GameId { get; set; } public string LobbyCode { get; set; } = string.Empty; public string Status { get; set; } = string.Empty; public List Players { get; set; } = []; public int CurrentRound { get; set; } public int RoundTimeRemaining { get; set; } } public class PlayerInfoDto { // Öffentliche Daten (für alle sichtbar) public int PlayerId { get; set; } public string Username { get; set; } = string.Empty; public int Score { get; set; } public bool IsReady { get; set; } public int CardCount { get; set; } // Anzahl der Karten (nicht die Karten selbst!) } public class PlayerHandDto { // Private Daten (nur für den Spieler selbst) public int PlayerId { get; set; } public List Cards { get; set; } = []; } public class PlayerCardDto { public int CardId { get; set; } public int PhraseId { get; set; } public string Text { get; set; } = string.Empty; public bool IsUsed { get; set; } } public class SlipChallengeDto { public int ChallengeId { get; set; } public int ChallengingPlayerId { get; set; } public string ChallengingPlayerName { get; set; } = string.Empty; public int TargetPlayerId { get; set; } public int TargetCardId { get; set; } public string Status { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } } ``` #### 2. GameHub (SlipItIn.Server/Hubs/GameHub.cs) ```csharp 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"); } // Lobby-Verwaltung public async Task CreateLobby() { try { var userId = GetAuthenticatedUserId(); var game = await _gameService.CreateGameAsync(userId); var connectionId = Context.ConnectionId; await Groups.AddToGroupAsync(connectionId, game.LobbyCode); await Clients.Caller.SendAsync("LobbyCreated", new { GameId = game.Id, LobbyCode = game.LobbyCode }); _logger.LogInformation($"Lobby {game.LobbyCode} created by user {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 = ex.Message }); } } public async Task JoinLobby(string lobbyCode) { try { var userId = GetAuthenticatedUserId(); var game = await _gameService.JoinGameAsync(lobbyCode, userId); var connectionId = Context.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}"); } catch (UnauthorizedAccessException) { await Clients.Caller.SendAsync("Error", new { Message = "Unauthorized" }); } catch (Exception ex) { _logger.LogError(ex, "Error joining lobby"); await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); } } public async Task PlayerReady(int gameId, int playerId) { try { 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 (Exception ex) { _logger.LogError(ex, "Error setting player ready"); await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); } } // Spiel-Start & Kartenvergabe public async Task StartGame(int gameId) { try { 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 }); await Clients.Group(lobbyCode).SendAsync("CardsDealt", cards); _logger.LogInformation($"Game {gameId} started"); } catch (Exception ex) { _logger.LogError(ex, "Error starting game"); await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); } } // Slip-Mechaniken public async Task SubmitSlip(int gameId, int playerId, int cardId) { try { 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 (Exception ex) { _logger.LogError(ex, "Error submitting slip"); await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); } } public async Task ChallengeSlip(int gameId, int challengingPlayerId, int targetCardId) { try { var challenge = await _gameService.CreateChallengeAsync(gameId, challengingPlayerId, targetCardId); var game = await _gameService.GetGameAsync(gameId); var challengeDto = new SlipChallengeDto { ChallengeId = challenge.Id, ChallengingPlayerId = challengingPlayerId, TargetCardId = targetCardId, Status = challenge.Status.ToString() }; await Clients.Group(game.LobbyCode).SendAsync("SlipChallenged", challengeDto); } catch (Exception ex) { _logger.LogError(ex, "Error challenging slip"); await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); } } public async Task ResolveChallenge(int challengeId, bool approved) { try { 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 }); } catch (Exception ex) { _logger.LogError(ex, "Error resolving challenge"); await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); } } public override async Task OnDisconnectedAsync(Exception? exception) { _logger.LogInformation($"Client {Context.ConnectionId} disconnected"); await base.OnDisconnectedAsync(exception); } } ``` #### 3. GameService (SlipItIn.Server/Services/GameService.cs) ```csharp public interface IGameService { Task CreateGameAsync(string hostUsername); Task JoinGameAsync(string lobbyCode, string username); Task StartGameAsync(int gameId); Task SetPlayerReadyAsync(int gameId, int playerId); Task> DealCardsAsync(int gameId); Task SubmitSlipAsync(int gameId, int playerId, int cardId); Task CreateChallengeAsync(int gameId, int challengingPlayerId, int targetCardId); Task ResolveChallengeAsync(int challengeId, bool approved); Task GetGameStateAsync(int gameId); Task GetGameAsync(int gameId); } public class GameService : IGameService { private readonly SlipItInDbContext _context; private readonly ILogger _logger; private readonly Random _random = new(); public GameService(SlipItInDbContext context, ILogger logger) { _context = context; _logger = logger; } public async Task CreateGameAsync(string hostUsername) { var host = await _context.Users.FirstOrDefaultAsync(u => u.Username == hostUsername); if (host == null) throw new InvalidOperationException("Host not found"); var lobbyCode = GenerateLobbyCode(); var game = new Game { LobbyCode = lobbyCode, HostId = host.Id, Status = GameStatus.Lobby }; _context.Games.Add(game); await _context.SaveChangesAsync(); return game; } public async Task JoinGameAsync(string lobbyCode, string username) { var game = await _context.Games .Include(g => g.Players) .FirstOrDefaultAsync(g => g.LobbyCode == lobbyCode && g.Status == GameStatus.Lobby); if (game == null) throw new InvalidOperationException("Game not found or already started"); if (game.Players.Count >= game.MaxPlayers) throw new InvalidOperationException("Game is full"); var user = await _context.Users.FirstOrDefaultAsync(u => u.Username == username); if (user == null) throw new InvalidOperationException("User not found"); var player = new Player { UserId = user.Id, GameId = game.Id }; _context.Players.Add(player); await _context.SaveChangesAsync(); return game; } public async Task StartGameAsync(int gameId) { var game = await _context.Games.FirstOrDefaultAsync(g => g.Id == gameId); if (game == null) throw new InvalidOperationException("Game not found"); game.Status = GameStatus.InProgress; game.StartedAt = DateTime.UtcNow; var round = new GameRound { GameId = gameId, RoundNumber = 1 }; _context.GameRounds.Add(round); await _context.SaveChangesAsync(); return game; } public async Task SetPlayerReadyAsync(int gameId, int playerId) { var player = await _context.Players.FirstOrDefaultAsync(p => p.Id == playerId); if (player == null) throw new InvalidOperationException("Player not found"); player.IsReady = true; await _context.SaveChangesAsync(); return await _context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found"); } public async Task> DealCardsAsync(int gameId) { var players = await _context.Players.Where(p => p.GameId == gameId).ToListAsync(); var currentRound = await _context.GameRounds .Where(gr => gr.GameId == gameId && gr.Status == RoundStatus.Waiting) .FirstOrDefaultAsync(); if (currentRound == null) throw new InvalidOperationException("No active round found"); var allPhrases = await _context.Phrases.Where(p => p.IsActive).ToListAsync(); var dealtCards = new List(); foreach (var player in players) { var selectedPhrases = allPhrases.OrderBy(_ => _random.Next()).Take(5); foreach (var phrase in selectedPhrases) { var card = new PlayerCard { PlayerId = player.Id, PhraseId = phrase.Id, GameRoundId = currentRound.Id, IsUsed = false }; _context.PlayerCards.Add(card); dealtCards.Add(card); } } currentRound.Status = RoundStatus.Active; await _context.SaveChangesAsync(); return dealtCards; } public async Task SubmitSlipAsync(int gameId, int playerId, int cardId) { var card = await _context.PlayerCards.FirstOrDefaultAsync(pc => pc.Id == cardId); if (card == null) throw new InvalidOperationException("Card not found"); card.IsUsed = true; await _context.SaveChangesAsync(); return true; } public async Task CreateChallengeAsync(int gameId, int challengingPlayerId, int targetCardId) { var targetCard = await _context.PlayerCards.FirstOrDefaultAsync(pc => pc.Id == targetCardId); if (targetCard == null) throw new InvalidOperationException("Target card not found"); var currentRound = await _context.GameRounds .Where(gr => gr.GameId == gameId && gr.Status == RoundStatus.Active) .FirstOrDefaultAsync(); if (currentRound == null) throw new InvalidOperationException("No active round found"); var challenge = new SlipChallenge { GameRoundId = currentRound.Id, ChallengingPlayerId = challengingPlayerId, TargetPlayerId = targetCard.PlayerId, TargetCardId = targetCardId, Status = ChallengeStatus.Pending }; _context.SlipChallenges.Add(challenge); await _context.SaveChangesAsync(); return challenge; } public async Task ResolveChallengeAsync(int challengeId, bool approved) { var challenge = await _context.SlipChallenges .Include(sc => sc.GameRound) .Include(sc => sc.TargetCard) .FirstOrDefaultAsync(sc => sc.Id == challengeId); if (challenge == null) throw new InvalidOperationException("Challenge not found"); // Bestimme ob Beschuldigung berechtigt ist // approved = Beschuldigung stimmt (Phrase war falsch) // rejected = Beschuldigung stimmt nicht (Phrase war richtig) if (!approved) { // FALSCHE BESCHULDIGUNG: Beschuldiger erhält die Phrase des Beschuldigten var targetCard = challenge.TargetCard; targetCard.PlayerId = challenge.ChallengingPlayerId; _context.PlayerCards.Update(targetCard); } // Wenn approved: Phrase bleibt bei Beschuldigtem (= Phrase war tatsächlich ein SlipIn) challenge.Status = approved ? ChallengeStatus.Approved : ChallengeStatus.Rejected; challenge.ResolvedAt = DateTime.UtcNow; await _context.SaveChangesAsync(); return challenge; } public async Task GetGameStateAsync(int gameId) { var game = await _context.Games .Include(g => g.Players) .ThenInclude(p => p.User) .FirstOrDefaultAsync(g => g.Id == gameId); if (game == null) throw new InvalidOperationException("Game not found"); var playerInfos = game.Players.Select(p => new PlayerInfoDto { PlayerId = p.Id, Username = p.User.Username, Score = p.Score, IsReady = p.IsReady }).ToList(); return new GameStateDto { GameId = game.Id, LobbyCode = game.LobbyCode, Status = game.Status.ToString(), Players = playerInfos, CurrentRound = game.Rounds.Count }; } public async Task GetGameAsync(int gameId) { return await _context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found"); } private string GenerateLobbyCode() { const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; var code = new string(Enumerable.Range(0, 6) .Select(_ => chars[_random.Next(chars.Length)]) .ToArray()); return code; } } ``` --- ### Integrations-Konfiguration (Program.cs) ```csharp // In SlipItIn.Server/Program.cs var builder = WebApplicationBuilder.CreateBuilder(args); // Datenbankverbindung (SQLite) builder.Services.AddDbContext(options => options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection") ?? "Data Source=slipitIn.db")); // IDbContextFactory für SignalR-Isolation builder.Services.AddDbContextFactory(options => options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection") ?? "Data Source=slipitIn.db")); // Services registrieren builder.Services.AddScoped(); // JWT Authentication builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = "https://your-domain.com"; // oder issuer aus config options.TokenValidationParameters = new() { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true }; }); // SignalR mit JWT builder.Services.AddSignalR(options => { options.AddFilter(); }); // Controller & Services builder.Services.AddControllers(); builder.Services.AddAuthorization(); var app = builder.Build(); // Migrations anwenden using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } app.MapControllers(); app.MapHub("/hubs/game"); app.Run(); ``` --- ### MAUI Client-Services (SlipItIn/Services/) #### 1. GameStateService.cs – Zentrale Zustandsverwaltung ```csharp public interface IGameStateService { GameStateDto? CurrentGameState { get; } PlayerHandDto? CurrentPlayerHand { get; } Task ResyncAsync(); // App-Resume oder Reconnect Task UpdateGameStateAsync(GameStateDto state); Task UpdatePlayerHandAsync(PlayerHandDto hand); } public class GameStateService : IGameStateService { private readonly ISignalRService _signalR; private readonly ILocalStorageService _localStorage; private GameStateDto? _currentGameState; private PlayerHandDto? _currentPlayerHand; public GameStateDto? CurrentGameState => _currentGameState; public PlayerHandDto? CurrentPlayerHand => _currentPlayerHand; public async Task ResyncAsync() { if (_currentGameState?.GameId > 0) await _signalR.RequestGameStateAsync(_currentGameState.GameId); var queuedActions = await _localStorage.GetQueuedActionsAsync(); foreach (var action in queuedActions) await _signalR.SendActionAsync(action); } public async Task UpdateGameStateAsync(GameStateDto state) { _currentGameState = state; await _localStorage.SaveGameStateAsync(state); WeakReferenceMessenger.Default.Send(new GameStateChangedMessage(state)); } public async Task UpdatePlayerHandAsync(PlayerHandDto hand) { _currentPlayerHand = hand; await _localStorage.SavePlayerHandAsync(hand); WeakReferenceMessenger.Default.Send(new PlayerHandChangedMessage(hand)); } } ``` #### 2. SignalRService.cs – Mit automatischem Reconnect ```csharp public class SignalRService : ISignalRService { private HubConnection? _connection; public async Task ConnectAsync(string token) { _connection = new HubConnectionBuilder() .WithUrl(_hubUrl, options => { options.AccessTokenProvider = () => Task.FromResult(token); }) .WithAutomaticReconnect(new[] { TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30) }) .Build(); _connection.On("GameStateUpdated", state => WeakReferenceMessenger.Default.Send(new GameStateChangedMessage(state))); _connection.On("PlayerHandUpdated", hand => WeakReferenceMessenger.Default.Send(new PlayerHandChangedMessage(hand))); _connection.On("ChallengeReceived", challenge => WeakReferenceMessenger.Default.Send(new ChallengeReceivedMessage(challenge))); await _connection.StartAsync(); return true; } } ``` #### 3. MauiProgram.cs – Dependency Injection ```csharp builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); ``` --- ### Testplan für Integrationstests #### Integration Test: Lobby-Workflow - [ ] Spieler erstellt Lobby → LobbyCode vergeben - [ ] 2. Spieler tritt Lobby bei → GameState wird aktualisiert - [ ] Host startet Spiel → Status wechselt zu InProgress - [ ] Karten werden vergeben (5 pro Spieler) #### Integration Test: Slip & Challenge-Mechanik - [ ] **Slip einreichen**: Spieler A submittet Slip zu Phrase X → Phrase wird als IsUsed markiert - [ ] **Falsche Beschuldigung**: - [ ] Spieler B beschuldigt Spieler A des SlipIns bei Phrase X - [ ] Spieler A lehnt Beschuldigung ab (Phrase war richtig) - [ ] Challenge wird als Rejected markiert - [ ] Phrase X wird zu Spieler B transferiert → Spieler B hat nun >5 Phrasen - [ ] **Berechtigte Beschuldigung**: - [ ] Spieler C beschuldigt Spieler A des SlipIns bei Phrase Y - [ ] Spieler A bestätigt Beschuldigung (Phrase war falsch) - [ ] Challenge wird als Approved markiert - [ ] Phrase Y bleibt bei Spieler A (wird nicht übertragen) - [ ] **Aktive Phrasen-Management**: - [ ] Spieler hat immer 5 aktive Phrasen im GameBoard sichtbar - [ ] Nach Phrase-Transfer werden UIs aktualisiert #### Integration Test: SignalR-Gruppen - [ ] Alle Hub-Clients in derselben Lobby-Gruppe erhalten Broadcasts bei Challenges - [ ] Challenge-Notification wird an Beschuldigen-Spieler gesendet #### Integration Test: JWT-Auth & Sicherheit - [ ] SignalR-Hub lehnt Requests ohne gültiges JWT ab - [ ] Player-ID Spoofing wird verhindert (User kann nur auf seine Players zugreifen) - [ ] `IDbContextFactory` isoliert DB-Sessions bei parallelen Hub-Aufrufen - [ ] Keine Race Conditions beim Phrase-Transfer zwischen 2 Hub-Clients #### Integration Test: MAUI State Restoration - [ ] App-Pause speichert `GameStateDto` lokal - [ ] App-Resume synct Spielzustand mit Server - [ ] Offline-Aktionen werden in Queue gepuffert - [ ] Nach Reconnect werden gepufferte Aktionen abgesendet - [ ] UI zeigt "Syncing..." bis Verbindung wiederhergestellt - [ ] `WeakReferenceMessenger` - keine Memory Leaks bei View-Model Cleanup - [ ] Disconnect-Handler aktualisiert Spielerstatus