Files
SlipItIn/Agents/ProjectPlan.md
Tim Krampitz d29bff17a1 Backend für "Slip It In" mit ASP.NET Core erstellt
- Neues Backend-Projekt mit Entity Framework Core (PostgreSQL) und geteilten Modellen/DTOs angelegt
- Datenbankstruktur und Migrations implementiert (User, Game, Player, Phrase, PlayerCard, GameRound, SlipChallenge)
- SignalR-Hub für Spielkommunikation (Lobby, Spielstart, Kartenvergabe, Slip/Challenge) hinzugefügt
- Spiellogik und DB-Zugriffe im GameService gekapselt
- JWT-Authentifizierung und CORS für Entwicklung konfiguriert
- Projektdateien, NuGet-Abhängigkeiten und .gitignore aktualisiert
- Startkonfiguration und Umgebungsdateien angepasst
2026-07-23 21:39:15 +02:00

1068 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Projektplan: Slip It In
Dieses Dokument beschreibt die Phasen und Aufgaben zur Implementierung des Spiels "Slip It In" mit .NET MAUI (.NET 10) und einem ASP.NET Core Backend.
## Phase 1: Vorbereitung & Architektur-Setup
- [x] Erstellung der Verzeichnisstruktur im Workspace
- [x] Aufsetzen der gemeinsamen Datenmodelle (Shared Class Library)
- `User`, `Game`, `Phrase`, `PlayerCard`
- [x] Initialisierung des ASP.NET Core Web API & SignalR Backends
## Phase 2: Backend-Entwicklung (Web API & SignalR)
- [ ] **Datenbank & Persistenz**: Entity Framework Core Setup mit SQLite
- [ ] **User Management**: Einfache JWT-basierte Authentifizierung
- Registrierung (Username, Email, Passwort)
- Login mit BCrypt Password Hashing
- JWT Tokens für API-Authentifizierung
- Token-Validierung auf API-Endpoints
- [ ] **Game Engine & Hub**:
- `GameHub` für Echtzeit-Kommunikation
- Logik für Lobby-Erstellung und Beitritt via Code
- Logik für das Austeilen von 5 zufälligen Phrasen pro Spieler
- **Slip-Mechanik:**
- Spieler submittet "Slip" zu einer eigenen Phrase
- Gegner **beschuldigt** Spieler des "SlipIns" (= falsche Phrase)
- Beschuldigter **bestätigt oder lehnt ab**
- Bei **falscher Beschuldigung**: Beschuldiger erhält die gelöschte Phrase (Spieler hat dann >5 Phrasen)
- Bei **berechtigter Beschuldigung**: Beschuldigter muss Phrase abgeben
- Timer-gesteuerte Logik für Slip-Runden (30-60s pro Slip)
## Phase 2b: Backend-Erweiterungen (Sicherheit & In-Memory-State)
- [ ] **SignalR Auth Integration**: JWT-Claims Validation im `GameHub`
- Verhindert Spoofing von PlayerIds
- User-Identity aus JWT extrahiert und validiert
- Nur autorisierte Spieler können auf ihre Daten zugreifen
- [ ] **Datenschutz/Karten-Isolation**: Trennung von öffentlichen und privaten Daten
- `GameStateDto` Öffentliche Spieldaten (Spieler, Scores, aktive Runden)
- `PlayerHandDto` Private Handkarten (nur für den Spieler selbst sichtbar)
- Clients erhalten nur Daten, die sie sehen dürfen
- [ ] **EF Core Context Factory**: Umstellung von Scoped auf `IDbContextFactory`
- Vermeidet Parallelitätskonflikte bei SignalR-Aufrufen
- Jede SignalR-Methode nutzt eine eigene, isolierte DB-Session
- Verhindert Race Conditions beim Phrase-Transfer
- [ ] **Serverseitige Validierung**: Logik-Verlagerung auf Server
- Clients senden nur Wünsche (z.B. "Ich möchte dieser Phrase zuordnen")
- Server prüft alle Bedingungen (IsPlayersTurn, CardBelongsToPlayer, etc.)
- Server speichert nur validierte Zustandsänderungen in DB
## Phase 3: Client-Entwicklung (.NET MAUI)
- [ ] **Infrastruktur**:
- MVVM-Setup mit CommunityToolkit.Mvvm
- Integration von Dependency Injection für Services
- Messenger-Architektur mit `WeakReferenceMessenger` für Event-Entkopplung
- [ ] **Services**:
- `ApiService` für REST-Anfragen (mit JWT-Token aus `SecureStorage`)
- `SignalRService` für Echtzeit-Events mit automatischem Reconnect
- `GameStateService` für In-Memory-Spielzustand-Verwaltung
- `LocalStorageService` für State-Persistence (Wiederherstellung nach Crash)
- [ ] **UI/Views**:
- `LoginPage` & `RegisterPage`
- `LobbyPage` (Spielerliste & Start-Button für Host)
- `GameBoardPage` (Anzeige aller eigenen Phrasen, "Slip"-Button pro Phrase, Timer-Anzeige, gegnerische Spieler-Liste mit "Beschuldigen"-Button)
- `ChallengeNotificationOverlay` (Eingangende Beschuldigung: "Bestätigen" oder "Ablehnen"-Button)
## Phase 3b: MAUI Client-Stabilität
- [ ] **Messenger-Architektur**: Entkopplung via `WeakReferenceMessenger`
- SignalR-Events erzeugen Messages (z.B. `GameStateChangedMessage`)
- ViewModels abonnieren Messages statt direkter Service-Events
- Verhindert Memory Leaks durch Circular Dependencies
- [ ] **State Restoration**: Automatisches Re-Syncing
- App-Resume triggert `GameStateService.ResyncAsync()`
- SignalR-Reconnect lädt aktuellen Spielzustand vom Server
- Offline-Puffering: Lokale Aktionen werden queued und nach Reconnect abgesendet
- UI zeigt "Syncing..." bis Verbindung wiederhergestellt
## Phase 4: Testen & Optimierung
- [ ] Integrationstest der SignalR-Kommunikation zwischen mehreren MAUI-Clients
- [ ] Behandlung von Verbindungsabbrüchen (Reconnection Logic in SignalR)
- [ ] UI/UX-Polishing
---
## Implementierungsplan: Datenbank-Entitäten & SignalR GameHub
**Datenbank-Entscheidung:** SQLite wird für Development und Production verwendet. Die `slipitIn.db` Datei wird im Server-Projekt abgelegt und über Connection String konfiguriert.
### Architektur-Highlights (Sicherheit & Stabilität)
#### Backend-Sicherheit:
- **JWT-Claims Validation** im GameHub → Verhindert PlayerId-Spoofing
- **IDbContextFactory** statt Scoped DbContext → Isoliert SignalR-Aufrufe, verhindert Race Conditions
- **Serverseitige Validierung** → Clients senden nur Wünsche, Server prüft alles
- **Daten-Isolation** → `GameStateDto` (öffentlich) vs. `PlayerHandDto` (privat)
#### Client-Stabilität:
- **WeakReferenceMessenger** → Entkopplung von Services & ViewModels, verhindert Memory Leaks
- **Automatischer Reconnect** → SignalR mit exponentialem Backoff (0s, 2s, 10s, 30s)
- **State Persistence** → Offline-Aktionen in Queue, Auto-Resync nach App-Resume
- **Dual-Layer Storage** → In-Memory (`GameStateService`) + Local (`SecureStorage`/`Preferences`)
### Spielmechanik-Übersicht
**Grundkonzept:**
1. Jeder Spieler erhält **mindestens 5 Phrasen** (später können es >5 sein)
2. Ein Spieler **"slippt"** zu einer Phrase (behauptet, diese Phrase war in der Runde)
3. **Andere Spieler können beschuldigen**: "Das war kein echtes SlipIn! Diese Phrase stammte von dir!"
4. Der **beschuldigte Spieler antwortet**:
-**"Stimmt!"** (Beschuldigung berechtigt) → Phrase bleibt beim Beschuldiger
-**"Nein!"** (Beschuldigung falsch) → Beschuldiger erhält die Phrase
**Auswirkungen:**
- Erfolgreiche Beschuldigung → Beschuldiger hat eine neue Phrase
- Falsche Beschuldigung → Beschuldiger muss Phrase abgeben (hat danach evtl. >5)
- Spieler haben immer mindestens 5 Phrasen zum Spielen
### Datenbankentitäten (EF Core)
#### 1. Shared-Modelle (SlipItIn.Shared/Models/)
##### User.cs
```csharp
public class User
{
public int Id { get; set; }
public string Username { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public bool IsActive { get; set; } = true;
// Navigation
public ICollection<Player> 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<PlayerCard> 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<Player> Players { get; set; } = [];
public ICollection<GameRound> 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<PlayerCard> 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<PlayerCard> ActiveCards { get; set; } = [];
public ICollection<SlipChallenge> 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<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
```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<PlayerInfoDto> 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<PlayerCardDto> 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<SlipItInDbContext> _contextFactory;
private readonly ILogger<GameHub> _logger;
public GameHub(IGameService gameService, IDbContextFactory<SlipItInDbContext> contextFactory, ILogger<GameHub> logger)
{
_gameService = gameService;
_contextFactory = contextFactory;
_logger = logger;
}
/// <summary>
/// Validiert die JWT-Claims des aktuellen Nutzers
/// Wirft Exception wenn User nicht authentifiziert oder Claim fehlt
/// </summary>
private int GetAuthenticatedUserId()
{
var userIdClaim = Context.User?.FindFirst(ClaimTypes.NameIdentifier);
if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out var userId))
throw new UnauthorizedAccessException("Invalid or missing user claim");
return userId;
}
/// <summary>
/// Sichert ab, dass ein Spieler nur auf seine eigenen Daten zugreift
/// </summary>
private async Task ValidatePlayerAccessAsync(int playerId, int authUserId)
{
using var context = _contextFactory.CreateDbContext();
var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player?.UserId != authUserId)
throw new UnauthorizedAccessException("Player does not belong to authenticated user");
}
// 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<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)
```csharp
// In SlipItIn.Server/Program.cs
var builder = WebApplicationBuilder.CreateBuilder(args);
// Datenbankverbindung (SQLite)
builder.Services.AddDbContext<SlipItInDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")
?? "Data Source=slipitIn.db"));
// IDbContextFactory für SignalR-Isolation
builder.Services.AddDbContextFactory<SlipItInDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")
?? "Data Source=slipitIn.db"));
// Services registrieren
builder.Services.AddScoped<IGameService, GameService>();
// 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<SignalRJwtAuthorizationFilter>();
});
// 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<SlipItInDbContext>();
db.Database.Migrate();
}
app.MapControllers();
app.MapHub<GameHub>("/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<bool> 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<GameStateDto>("GameStateUpdated", state =>
WeakReferenceMessenger.Default.Send(new GameStateChangedMessage(state)));
_connection.On<PlayerHandDto>("PlayerHandUpdated", hand =>
WeakReferenceMessenger.Default.Send(new PlayerHandChangedMessage(hand)));
_connection.On<SlipChallengeDto>("ChallengeReceived", challenge =>
WeakReferenceMessenger.Default.Send(new ChallengeReceivedMessage(challenge)));
await _connection.StartAsync();
return true;
}
}
```
#### 3. MauiProgram.cs Dependency Injection
```csharp
builder.Services.AddSingleton<ILocalStorageService, LocalStorageService>();
builder.Services.AddSingleton<ISignalRService, SignalRService>();
builder.Services.AddSingleton<IGameStateService, GameStateService>();
builder.Services.AddSingleton<IApiService, ApiService>();
```
---
### 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