Files
SlipItIn/ProjectPlan.md
Tim Krampitz bcd4e6a0d7 Projektplan und technische Spezifikation hinzugefügt
Detaillierter Projektplan für "Slip It In" ergänzt, inkl. Entwicklungsphasen, Architektur (EF Core, SignalR, DTOs), Spiellogik (Slip-/Challenge-Mechanik) und Testplan. Enthält C#-Codebeispiele für Entitäten, DbContext, GameHub, GameService sowie Grundkonfiguration für ASP.NET Core Backend. Dient als Leitfaden für die Implementierung mit .NET MAUI und ASP.NET Core.
2026-07-19 21:52:28 +02:00

28 KiB

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
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<Player> Players { get; set; } = [];
}
Phrase.cs
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<PlayerCard> PlayerCards { get; set; } = [];
}
Game.cs
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<Player> Players { get; set; } = [];
    public ICollection<GameRound> Rounds { get; set; } = [];
}
Player.cs
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<PlayerCard> Cards { get; set; } = [];
}
PlayerCard.cs
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
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<PlayerCard> ActiveCards { get; set; } = [];
    public ICollection<SlipChallenge> Challenges { get; set; } = [];
}
SlipChallenge.cs
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)

public class SlipItInDbContext : DbContext
{
    public SlipItInDbContext(DbContextOptions<SlipItInDbContext> options) : base(options) { }

    public DbSet<User> Users { get; set; }
    public DbSet<Phrase> Phrases { get; set; }
    public DbSet<Game> Games { get; set; }
    public DbSet<Player> Players { get; set; }
    public DbSet<PlayerCard> PlayerCards { get; set; }
    public DbSet<GameRound> GameRounds { get; set; }
    public DbSet<SlipChallenge> SlipChallenges { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // User
        modelBuilder.Entity<User>()
            .HasIndex(u => u.Username).IsUnique();
        modelBuilder.Entity<User>()
            .HasIndex(u => u.Email).IsUnique();

        // Game
        modelBuilder.Entity<Game>()
            .HasIndex(g => g.LobbyCode).IsUnique();
        modelBuilder.Entity<Game>()
            .HasOne(g => g.Host)
            .WithMany(u => u.Players)
            .HasForeignKey(g => g.HostId)
            .OnDelete(DeleteBehavior.Restrict);

        // Player
        modelBuilder.Entity<Player>()
            .HasOne(p => p.User)
            .WithMany(u => u.Players)
            .HasForeignKey(p => p.UserId)
            .OnDelete(DeleteBehavior.Cascade);
        modelBuilder.Entity<Player>()
            .HasOne(p => p.Game)
            .WithMany(g => g.Players)
            .HasForeignKey(p => p.GameId)
            .OnDelete(DeleteBehavior.Cascade);
        modelBuilder.Entity<Player>()
            .HasIndex(p => new { p.GameId, p.UserId }).IsUnique();

        // PlayerCard
        modelBuilder.Entity<PlayerCard>()
            .HasOne(pc => pc.Player)
            .WithMany(p => p.Cards)
            .HasForeignKey(pc => pc.PlayerId)
            .OnDelete(DeleteBehavior.Cascade);
        modelBuilder.Entity<PlayerCard>()
            .HasOne(pc => pc.Phrase)
            .WithMany(ph => ph.PlayerCards)
            .HasForeignKey(pc => pc.PhraseId)
            .OnDelete(DeleteBehavior.Restrict);
        modelBuilder.Entity<PlayerCard>()
            .HasOne(pc => pc.GameRound)
            .WithMany(gr => gr.ActiveCards)
            .HasForeignKey(pc => pc.GameRoundId)
            .OnDelete(DeleteBehavior.Cascade);

        // GameRound
        modelBuilder.Entity<GameRound>()
            .HasOne(gr => gr.Game)
            .WithMany(g => g.Rounds)
            .HasForeignKey(gr => gr.GameId)
            .OnDelete(DeleteBehavior.Cascade);

        // SlipChallenge
        modelBuilder.Entity<SlipChallenge>()
            .HasOne(sc => sc.GameRound)
            .WithMany(gr => gr.Challenges)
            .HasForeignKey(sc => sc.GameRoundId)
            .OnDelete(DeleteBehavior.Cascade);
        modelBuilder.Entity<SlipChallenge>()
            .HasOne(sc => sc.ChallengingPlayer)
            .WithMany()
            .HasForeignKey(sc => sc.ChallengingPlayerId)
            .OnDelete(DeleteBehavior.Restrict);
        modelBuilder.Entity<SlipChallenge>()
            .HasOne(sc => sc.TargetPlayer)
            .WithMany()
            .HasForeignKey(sc => sc.TargetPlayerId)
            .OnDelete(DeleteBehavior.Restrict);
        modelBuilder.Entity<SlipChallenge>()
            .HasOne(sc => sc.TargetCard)
            .WithMany()
            .HasForeignKey(sc => sc.TargetCardId)
            .OnDelete(DeleteBehavior.Restrict);
    }
}

SignalR GameHub & DTOs

1. SignalR DTOs (SlipItIn.Shared/Hubs/DTOs/)

GameStateDto.cs
public class GameStateDto
{
    public int GameId { get; set; }
    public string LobbyCode { get; set; } = string.Empty;
    public string Status { get; set; } = string.Empty;
    public List<PlayerInfoDto> 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<PhraseDto> 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)

public class GameHub : Hub
{
    private readonly IGameService _gameService;
    private readonly ILogger<GameHub> _logger;

    public GameHub(IGameService gameService, ILogger<GameHub> 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)

public interface IGameService
{
    Task<Game> CreateGameAsync(string hostUsername);
    Task<Game> JoinGameAsync(string lobbyCode, string username);
    Task<Game> StartGameAsync(int gameId);
    Task<Game> SetPlayerReadyAsync(int gameId, int playerId);
    Task<List<PlayerCard>> DealCardsAsync(int gameId);
    Task<bool> SubmitSlipAsync(int gameId, int playerId, int cardId);
    Task<SlipChallenge> CreateChallengeAsync(int gameId, int challengingPlayerId, int targetCardId);
    Task<SlipChallenge> ResolveChallengeAsync(int challengeId, bool approved);
    Task<GameStateDto> GetGameStateAsync(int gameId);
    Task<Game> GetGameAsync(int gameId);
}

public class GameService : IGameService
{
    private readonly SlipItInDbContext _context;
    private readonly ILogger<GameService> _logger;
    private readonly Random _random = new();

    public GameService(SlipItInDbContext context, ILogger<GameService> logger)
    {
        _context = context;
        _logger = logger;
    }

    public async Task<Game> 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<Game> 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<Game> 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<Game> 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<List<PlayerCard>> 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<PlayerCard>();

        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<bool> 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<SlipChallenge> 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<SlipChallenge> 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<GameStateDto> 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<Game> 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)

// In SlipItIn.Server/Program.cs

var builder = WebApplicationBuilder.CreateBuilder(args);

// Datenbankverbindung (SQLite für Development)
builder.Services.AddDbContext<SlipItInDbContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection") 
        ?? "Data Source=slipitIn.db"));

// Services registrieren
builder.Services.AddScoped<IGameService, GameService>();

// 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<SlipItInDbContext>();
    db.Database.Migrate();
}

app.MapControllers();
app.MapHub<GameHub>("/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