- 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
1068 lines
38 KiB
Markdown
1068 lines
38 KiB
Markdown
# 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 |