diff --git a/ProjectPlan.md b/ProjectPlan.md new file mode 100644 index 0000000..2ec2fa7 --- /dev/null +++ b/ProjectPlan.md @@ -0,0 +1,834 @@ +# 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 +- [ ] Erstellung der Verzeichnisstruktur im Workspace +- [ ] Aufsetzen der gemeinsamen Datenmodelle (Shared Class Library) + - `User`, `Game`, `Phrase`, `PlayerCard` +- [ ] 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**: Simple Registrierung, Login und JWT-basierte Authentifizierung +- [ ] **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 3: Client-Entwicklung (.NET MAUI) +- [ ] **Infrastruktur**: + - MVVM-Setup mit CommunityToolkit.Mvvm + - Integration von Dependency Injection für Services +- [ ] **Services**: + - `ApiService` für REST-Anfragen + - `SignalRService` für Echtzeit-Events +- [ ] **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 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. + +### 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 +{ + public int PlayerId { get; set; } + public string Username { get; set; } = string.Empty; + public int Score { get; set; } + public bool IsReady { get; set; } + public List ActiveCards { get; set; } = []; +} + +public class PhraseDto +{ + public int CardId { 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 TargetCardId { get; set; } + public string Status { get; set; } = string.Empty; +} +``` + +#### 2. GameHub (SlipItIn.Server/Hubs/GameHub.cs) +```csharp +public class GameHub : Hub +{ + private readonly IGameService _gameService; + private readonly ILogger _logger; + + public GameHub(IGameService gameService, ILogger logger) + { + _gameService = gameService; + _logger = logger; + } + + // Lobby-Verwaltung + public async Task CreateLobby(string username) + { + try + { + var game = await _gameService.CreateGameAsync(username); + 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 {username}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating lobby"); + await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); + } + } + + public async Task JoinLobby(string lobbyCode, string username) + { + try + { + var game = await _gameService.JoinGameAsync(lobbyCode, username); + 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($"{username} joined lobby {lobbyCode}"); + } + 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 für Development) +builder.Services.AddDbContext(options => + options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection") + ?? "Data Source=slipitIn.db")); + +// Services registrieren +builder.Services.AddScoped(); + +// SignalR +builder.Services.AddSignalR(); + +// Controller & Services +builder.Services.AddControllers(); + +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(); +``` + +--- + +### 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 +- [ ] Disconnect-Handler aktualisiert Spielerstatus \ No newline at end of file