From dede059983fae5aef03cd5a28e8ec8b74680bd6f Mon Sep 17 00:00:00 2001 From: Tim Krampitz Date: Sat, 22 Aug 2026 19:08:33 +0200 Subject: [PATCH] Removed obsolete documentation and planning. --- Agents/Architecture.md | 136 ----- Agents/ProjectPlan.md | 1068 ---------------------------------------- 2 files changed, 1204 deletions(-) delete mode 100644 Agents/Architecture.md delete mode 100644 Agents/ProjectPlan.md diff --git a/Agents/Architecture.md b/Agents/Architecture.md deleted file mode 100644 index 4ee87b6..0000000 --- a/Agents/Architecture.md +++ /dev/null @@ -1,136 +0,0 @@ -# Slip It In – Architektur-Updates (Phase 2b & 3b) - -Dieses Dokument fasst die Sicherheits- und Stabilitäts-Erweiterungen zusammen, die dem ProjectPlan.md hinzugefügt wurden. - -## 🔒 Backend-Sicherheit (Phase 2b) - -### 1. JWT-Claims Validation im GameHub -- **Problem**: Clients könnten falsche PlayerId-Claims senden (Spoofing) -- **Lösung**: `GetAuthenticatedUserId()` extrahiert User-ID aus JWT-Claims -- **Implementierung**: Jede Hub-Methode validiert `Context.User?.FindFirst(ClaimTypes.NameIdentifier)` - -### 2. IDbContextFactory statt Scoped DbContext -- **Problem**: Bei parallelen SignalR-Aufrufen kann es zu Race Conditions kommen -- **Lösung**: `IDbContextFactory` erzeugt isolierte Sessions pro Aufruf -- **Vorteil**: Phrase-Transfer ist thread-safe, keine Conflicts bei 2 gleichzeitigen Challenges - -### 3. Serverseitige Validierung -- **Regel**: Clients senden nur "Ich möchte X machen", Server prüft ALLES -- **Beispiel**: `SubmitSlipAsync()` prüft auf dem Server: - - ✅ Gehört die Phrase dem Spieler? - - ✅ Ist die Runde aktiv? - - ✅ Ist der Spieler noch im Spiel? - -### 4. Daten-Isolation (GameStateDto vs. PlayerHandDto) -- **GameStateDto**: Öffentliche Daten (Username, Score, CardCount) → für ALLE sichtbar -- **PlayerHandDto**: Private Daten (Text der eigenen Karten) → nur für den Spieler selbst -- **Ergebnis**: Gegner sehen nicht, welche Phrasen ich habe! - ---- - -## 📱 Client-Stabilität (Phase 3b) - -### 1. WeakReferenceMessenger für Event-Entkopplung -- **Problem**: Services rufen ViewModels direkt auf → Memory Leaks wenn VM disposed -- **Lösung**: Services senden Messages via `WeakReferenceMessenger.Default.Send()` -- **Vorteil**: ViewModels können sich abmelden ohne zirkuläre Abhängigkeiten - -**Beispiel:** -```csharp -// Service sendet Event -WeakReferenceMessenger.Default.Send(new GameStateChangedMessage(newState)); - -// ViewModel empfängt (mit auto-cleanup beim Dispose) -WeakReferenceMessenger.Default.Register(this, (r, m) => -{ - CurrentGameState = m.Value; -}); -``` - -### 2. Automatischer Reconnect -- **Strategie**: Exponential Backoff (0s → 2s → 10s → 30s) -- **Pufferung**: Offline-Aktionen werden in lokale Queue geschrieben -- **Resync**: Nach Reconnect wird `ResyncAsync()` aufgerufen - -**Flow:** -1. SignalR-Verbindung getrennt -2. Client speichert "Slip zu Phrase X" in lokale Queue -3. Nach 2s Automatischer Reconnect-Versuch -4. Nach erfolgreichem Reconnect: Queued Actions werden abgesendet - -### 3. State Restoration -- **App-Pause**: `GameStateService` speichert aktuellen State in `SecureStorage` -- **App-Resume**: `ResyncAsync()` wird aufgerufen - - ✅ Holt aktuellen GameState vom Server - - ✅ Sendet gepufferte Offline-Aktionen erneut - - ✅ UI zeigt "Syncing..." bis fertig - -### 4. Dual-Layer Storage -- **In-Memory Layer**: `GameStateService` halten aktuellen State -- **Persistent Layer**: `SecureStorage`/`Preferences` speichern Backup -- **Fehler-Handling**: Nach App-Crash kann State aus Persistent Layer wiederhergestellt werden - ---- - -## 📊 Schnellreferenz: Neue Components - -### Backend -``` -GameHub -├─ GetAuthenticatedUserId() → JWT-Validierung -├─ ValidatePlayerAccessAsync() → Authorization Check -└─ Alle Methoden nutzen IDbContextFactory - -Program.cs -├─ AddDbContextFactory() -├─ AddAuthentication(JwtBearerDefaults) -└─ AddSignalR() mit JWT-Filter - -DTOs -├─ GameStateDto (öffentlich) -├─ PlayerHandDto (privat) -└─ Keine CardText in GameStateDto! -``` - -### Client -``` -GameStateService -├─ CurrentGameState (in-memory) -├─ CurrentPlayerHand (in-memory) -├─ UpdateGameStateAsync() → speichert + sendet Message -└─ ResyncAsync() → bei App-Resume - -SignalRService -├─ ConnectAsync(token) mit Auto-Reconnect -├─ RequestGameStateAsync() -└─ On<> Handler senden Messages - -Messages (WeakReferenceMessenger) -├─ GameStateChangedMessage -├─ PlayerHandChangedMessage -└─ ChallengeReceivedMessage -``` - ---- - -## 🧪 Wichtigste Tests - -| Test | Ziel | -|------|------| -| **JWT-Auth** | Hub lehnt Requests ohne gültiges Token ab | -| **Player-Access** | User A kann nicht auf Players von User B zugreifen | -| **Race Condition** | Phrase-Transfer ist thread-safe bei 2 gleichzeitigen Challenges | -| **Offline-Queue** | Aktionen während Netzwerk-Ausfall werden gepuffert und später gesendet | -| **Memory Leaks** | Keine Leaks wenn ViewModels disposed werden (WeakReference) | -| **State-Sync** | App-Resume synct korrekt mit Server-State | - ---- - -## 🚀 Implementierungs-Reihenfolge - -1. **Backend zuerst**: JWT-Auth + IDbContextFactory konfigurieren -2. **GameService erweitern**: Serverseitige Validierung in alle Methoden -3. **Client-Services**: GameStateService + SignalRService implementieren -4. **Messaging**: WeakReferenceMessenger in ViewModels integrieren -5. **Tests**: Integrationstest-Suite schreiben -6. **UI**: Views an neue Message-Events binden diff --git a/Agents/ProjectPlan.md b/Agents/ProjectPlan.md deleted file mode 100644 index 35866a4..0000000 --- a/Agents/ProjectPlan.md +++ /dev/null @@ -1,1068 +0,0 @@ -# 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 PostgreSQL -- [ ] **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 \ No newline at end of file