38 KiB
38 KiB
Projektplan: Slip It In
Dieses Dokument beschreibt die Phasen und Aufgaben zur Implementierung des Spiels "Slip It In" mit .NET MAUI (.NET 10) und einem ASP.NET Core Backend.
Phase 1: Vorbereitung & Architektur-Setup
- Erstellung der Verzeichnisstruktur im Workspace
- Aufsetzen der gemeinsamen Datenmodelle (Shared Class Library)
User,Game,Phrase,PlayerCard
- Initialisierung des ASP.NET Core Web API & SignalR Backends
Phase 2: Backend-Entwicklung (Web API & SignalR)
- Datenbank & Persistenz: Entity Framework Core Setup mit SQLite
- User Management: 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:
GameHubfü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
WeakReferenceMessengerfür Event-Entkopplung
- Services:
ApiServicefür REST-Anfragen (mit JWT-Token ausSecureStorage)SignalRServicefür Echtzeit-Events mit automatischem ReconnectGameStateServicefür In-Memory-Spielzustand-VerwaltungLocalStorageServicefür State-Persistence (Wiederherstellung nach Crash)
- UI/Views:
LoginPage&RegisterPageLobbyPage(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
- SignalR-Events erzeugen Messages (z.B.
- 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
- App-Resume triggert
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:
- Jeder Spieler erhält mindestens 5 Phrasen (später können es >5 sein)
- Ein Spieler "slippt" zu einer Phrase (behauptet, diese Phrase war in der Runde)
- Andere Spieler können beschuldigen: "Das war kein echtes SlipIn! Diese Phrase stammte von dir!"
- Der beschuldigte Spieler antwortet:
- ✅ "Stimmt!" (Beschuldigung berechtigt) → Phrase bleibt beim Beschuldiger
- ❌ "Nein!" (Beschuldigung falsch) → Beschuldiger erhält die Phrase
Auswirkungen:
- Erfolgreiche Beschuldigung → Beschuldiger hat eine neue Phrase
- Falsche Beschuldigung → Beschuldiger muss Phrase abgeben (hat danach evtl. >5)
- Spieler haben immer mindestens 5 Phrasen zum Spielen
Datenbankentitäten (EF Core)
1. Shared-Modelle (SlipItIn.Shared/Models/)
User.cs
public class User
{
public int Id { get; set; }
public string Username { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public bool IsActive { get; set; } = true;
// Navigation
public ICollection<Player> Players { get; set; } = [];
}
Phrase.cs
public class Phrase
{
public int Id { get; set; }
public string Text { get; set; } = string.Empty;
public int CreatorId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public bool IsActive { get; set; } = true;
// Navigation
public User Creator { get; set; } = null!;
public ICollection<PlayerCard> PlayerCards { get; set; } = [];
}
Game.cs
public enum GameStatus
{
Lobby,
InProgress,
Completed,
Cancelled
}
public class Game
{
public int Id { get; set; }
public string LobbyCode { get; set; } = string.Empty; // 4-6 stelliger Code
public int HostId { get; set; }
public GameStatus Status { get; set; } = GameStatus.Lobby;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? StartedAt { get; set; }
public DateTime? EndedAt { get; set; }
public int MaxPlayers { get; set; } = 8;
public int RoundDurationSeconds { get; set; } = 45;
// Navigation
public User Host { get; set; } = null!;
public ICollection<Player> Players { get; set; } = [];
public ICollection<GameRound> Rounds { get; set; } = [];
}
Player.cs
public class Player
{
public int Id { get; set; }
public int UserId { get; set; }
public int GameId { get; set; }
public int Score { get; set; } = 0;
public int SuccessfulSlips { get; set; } = 0;
public int FailedSlips { get; set; } = 0;
public DateTime JoinedAt { get; set; } = DateTime.UtcNow;
public bool IsReady { get; set; } = false;
public string? ConnectionId { get; set; } // SignalR-Verbindungs-ID
// Navigation
public User User { get; set; } = null!;
public Game Game { get; set; } = null!;
public ICollection<PlayerCard> Cards { get; set; } = [];
}
PlayerCard.cs
public class PlayerCard
{
public int Id { get; set; }
public int PlayerId { get; set; }
public int PhraseId { get; set; }
public int GameRoundId { get; set; }
public bool IsUsed { get; set; } = false;
public DateTime AssignedAt { get; set; } = DateTime.UtcNow;
// Navigation
public Player Player { get; set; } = null!;
public Phrase Phrase { get; set; } = null!;
public GameRound GameRound { get; set; } = null!;
}
GameRound.cs
public enum RoundStatus
{
Waiting,
Active,
Resolving,
Completed
}
public class GameRound
{
public int Id { get; set; }
public int GameId { get; set; }
public int RoundNumber { get; set; }
public RoundStatus Status { get; set; } = RoundStatus.Waiting;
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
public DateTime? EndedAt { get; set; }
// Navigation
public Game Game { get; set; } = null!;
public ICollection<PlayerCard> ActiveCards { get; set; } = [];
public ICollection<SlipChallenge> Challenges { get; set; } = [];
}
SlipChallenge.cs
public enum ChallengeStatus
{
Pending, // Beschuldigung eingegangen, wartet auf Bestätigung/Ablehnung
Approved, // Beschuldigung war berechtigt → Phrase bleibt bei Beschuldiger
Rejected // Beschuldigung falsch → Beschuldiger erhält die Phrase
}
public class SlipChallenge
{
public int Id { get; set; }
public int GameRoundId { get; set; }
public int ChallengingPlayerId { get; set; } // Wer beschuldigt
public int TargetPlayerId { get; set; } // Wer beschuldigt wird
public int TargetCardId { get; set; } // Welche Phrase wird als falsch behauptet
public ChallengeStatus Status { get; set; } = ChallengeStatus.Pending;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ResolvedAt { get; set; }
// Navigation
public GameRound GameRound { get; set; } = null!;
public Player ChallengingPlayer { get; set; } = null!; // Der Beschuldiger
public Player TargetPlayer { get; set; } = null!; // Der Beschuldigte
public PlayerCard TargetCard { get; set; } = null!; // Die angefochtene Phrase
}
2. DbContext (SlipItIn.Server/Data/SlipItInDbContext.cs)
public class SlipItInDbContext : DbContext
{
public SlipItInDbContext(DbContextOptions<SlipItInDbContext> options) : base(options) { }
public DbSet<User> Users { get; set; }
public DbSet<Phrase> Phrases { get; set; }
public DbSet<Game> Games { get; set; }
public DbSet<Player> Players { get; set; }
public DbSet<PlayerCard> PlayerCards { get; set; }
public DbSet<GameRound> GameRounds { get; set; }
public DbSet<SlipChallenge> SlipChallenges { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// User
modelBuilder.Entity<User>()
.HasIndex(u => u.Username).IsUnique();
modelBuilder.Entity<User>()
.HasIndex(u => u.Email).IsUnique();
// Game
modelBuilder.Entity<Game>()
.HasIndex(g => g.LobbyCode).IsUnique();
modelBuilder.Entity<Game>()
.HasOne(g => g.Host)
.WithMany(u => u.Players)
.HasForeignKey(g => g.HostId)
.OnDelete(DeleteBehavior.Restrict);
// Player
modelBuilder.Entity<Player>()
.HasOne(p => p.User)
.WithMany(u => u.Players)
.HasForeignKey(p => p.UserId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Player>()
.HasOne(p => p.Game)
.WithMany(g => g.Players)
.HasForeignKey(p => p.GameId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Player>()
.HasIndex(p => new { p.GameId, p.UserId }).IsUnique();
// PlayerCard
modelBuilder.Entity<PlayerCard>()
.HasOne(pc => pc.Player)
.WithMany(p => p.Cards)
.HasForeignKey(pc => pc.PlayerId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<PlayerCard>()
.HasOne(pc => pc.Phrase)
.WithMany(ph => ph.PlayerCards)
.HasForeignKey(pc => pc.PhraseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PlayerCard>()
.HasOne(pc => pc.GameRound)
.WithMany(gr => gr.ActiveCards)
.HasForeignKey(pc => pc.GameRoundId)
.OnDelete(DeleteBehavior.Cascade);
// GameRound
modelBuilder.Entity<GameRound>()
.HasOne(gr => gr.Game)
.WithMany(g => g.Rounds)
.HasForeignKey(gr => gr.GameId)
.OnDelete(DeleteBehavior.Cascade);
// SlipChallenge
modelBuilder.Entity<SlipChallenge>()
.HasOne(sc => sc.GameRound)
.WithMany(gr => gr.Challenges)
.HasForeignKey(sc => sc.GameRoundId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<SlipChallenge>()
.HasOne(sc => sc.ChallengingPlayer)
.WithMany()
.HasForeignKey(sc => sc.ChallengingPlayerId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SlipChallenge>()
.HasOne(sc => sc.TargetPlayer)
.WithMany()
.HasForeignKey(sc => sc.TargetPlayerId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SlipChallenge>()
.HasOne(sc => sc.TargetCard)
.WithMany()
.HasForeignKey(sc => sc.TargetCardId)
.OnDelete(DeleteBehavior.Restrict);
}
}
SignalR GameHub & DTOs
1. SignalR DTOs (SlipItIn.Shared/Hubs/DTOs/)
GameStateDto.cs
public class GameStateDto
{
public int GameId { get; set; }
public string LobbyCode { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public List<PlayerInfoDto> Players { get; set; } = [];
public int CurrentRound { get; set; }
public int RoundTimeRemaining { get; set; }
}
public class PlayerInfoDto
{
// Ö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)
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)
public interface IGameService
{
Task<Game> CreateGameAsync(string hostUsername);
Task<Game> JoinGameAsync(string lobbyCode, string username);
Task<Game> StartGameAsync(int gameId);
Task<Game> SetPlayerReadyAsync(int gameId, int playerId);
Task<List<PlayerCard>> DealCardsAsync(int gameId);
Task<bool> SubmitSlipAsync(int gameId, int playerId, int cardId);
Task<SlipChallenge> CreateChallengeAsync(int gameId, int challengingPlayerId, int targetCardId);
Task<SlipChallenge> ResolveChallengeAsync(int challengeId, bool approved);
Task<GameStateDto> GetGameStateAsync(int gameId);
Task<Game> GetGameAsync(int gameId);
}
public class GameService : IGameService
{
private readonly SlipItInDbContext _context;
private readonly ILogger<GameService> _logger;
private readonly Random _random = new();
public GameService(SlipItInDbContext context, ILogger<GameService> logger)
{
_context = context;
_logger = logger;
}
public async Task<Game> CreateGameAsync(string hostUsername)
{
var host = await _context.Users.FirstOrDefaultAsync(u => u.Username == hostUsername);
if (host == null) throw new InvalidOperationException("Host not found");
var lobbyCode = GenerateLobbyCode();
var game = new Game
{
LobbyCode = lobbyCode,
HostId = host.Id,
Status = GameStatus.Lobby
};
_context.Games.Add(game);
await _context.SaveChangesAsync();
return game;
}
public async Task<Game> JoinGameAsync(string lobbyCode, string username)
{
var game = await _context.Games
.Include(g => g.Players)
.FirstOrDefaultAsync(g => g.LobbyCode == lobbyCode && g.Status == GameStatus.Lobby);
if (game == null) throw new InvalidOperationException("Game not found or already started");
if (game.Players.Count >= game.MaxPlayers) throw new InvalidOperationException("Game is full");
var user = await _context.Users.FirstOrDefaultAsync(u => u.Username == username);
if (user == null) throw new InvalidOperationException("User not found");
var player = new Player { UserId = user.Id, GameId = game.Id };
_context.Players.Add(player);
await _context.SaveChangesAsync();
return game;
}
public async Task<Game> StartGameAsync(int gameId)
{
var game = await _context.Games.FirstOrDefaultAsync(g => g.Id == gameId);
if (game == null) throw new InvalidOperationException("Game not found");
game.Status = GameStatus.InProgress;
game.StartedAt = DateTime.UtcNow;
var round = new GameRound { GameId = gameId, RoundNumber = 1 };
_context.GameRounds.Add(round);
await _context.SaveChangesAsync();
return game;
}
public async Task<Game> SetPlayerReadyAsync(int gameId, int playerId)
{
var player = await _context.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player == null) throw new InvalidOperationException("Player not found");
player.IsReady = true;
await _context.SaveChangesAsync();
return await _context.Games.FirstOrDefaultAsync(g => g.Id == gameId)
?? throw new InvalidOperationException("Game not found");
}
public async Task<List<PlayerCard>> DealCardsAsync(int gameId)
{
var players = await _context.Players.Where(p => p.GameId == gameId).ToListAsync();
var currentRound = await _context.GameRounds
.Where(gr => gr.GameId == gameId && gr.Status == RoundStatus.Waiting)
.FirstOrDefaultAsync();
if (currentRound == null) throw new InvalidOperationException("No active round found");
var allPhrases = await _context.Phrases.Where(p => p.IsActive).ToListAsync();
var dealtCards = new List<PlayerCard>();
foreach (var player in players)
{
var selectedPhrases = allPhrases.OrderBy(_ => _random.Next()).Take(5);
foreach (var phrase in selectedPhrases)
{
var card = new PlayerCard
{
PlayerId = player.Id,
PhraseId = phrase.Id,
GameRoundId = currentRound.Id,
IsUsed = false
};
_context.PlayerCards.Add(card);
dealtCards.Add(card);
}
}
currentRound.Status = RoundStatus.Active;
await _context.SaveChangesAsync();
return dealtCards;
}
public async Task<bool> SubmitSlipAsync(int gameId, int playerId, int cardId)
{
var card = await _context.PlayerCards.FirstOrDefaultAsync(pc => pc.Id == cardId);
if (card == null) throw new InvalidOperationException("Card not found");
card.IsUsed = true;
await _context.SaveChangesAsync();
return true;
}
public async Task<SlipChallenge> CreateChallengeAsync(int gameId, int challengingPlayerId, int targetCardId)
{
var targetCard = await _context.PlayerCards.FirstOrDefaultAsync(pc => pc.Id == targetCardId);
if (targetCard == null) throw new InvalidOperationException("Target card not found");
var currentRound = await _context.GameRounds
.Where(gr => gr.GameId == gameId && gr.Status == RoundStatus.Active)
.FirstOrDefaultAsync();
if (currentRound == null) throw new InvalidOperationException("No active round found");
var challenge = new SlipChallenge
{
GameRoundId = currentRound.Id,
ChallengingPlayerId = challengingPlayerId,
TargetPlayerId = targetCard.PlayerId,
TargetCardId = targetCardId,
Status = ChallengeStatus.Pending
};
_context.SlipChallenges.Add(challenge);
await _context.SaveChangesAsync();
return challenge;
}
public async Task<SlipChallenge> ResolveChallengeAsync(int challengeId, bool approved)
{
var challenge = await _context.SlipChallenges
.Include(sc => sc.GameRound)
.Include(sc => sc.TargetCard)
.FirstOrDefaultAsync(sc => sc.Id == challengeId);
if (challenge == null) throw new InvalidOperationException("Challenge not found");
// Bestimme ob Beschuldigung berechtigt ist
// approved = Beschuldigung stimmt (Phrase war falsch)
// rejected = Beschuldigung stimmt nicht (Phrase war richtig)
if (!approved)
{
// FALSCHE BESCHULDIGUNG: Beschuldiger erhält die Phrase des Beschuldigten
var targetCard = challenge.TargetCard;
targetCard.PlayerId = challenge.ChallengingPlayerId;
_context.PlayerCards.Update(targetCard);
}
// Wenn approved: Phrase bleibt bei Beschuldigtem (= Phrase war tatsächlich ein SlipIn)
challenge.Status = approved ? ChallengeStatus.Approved : ChallengeStatus.Rejected;
challenge.ResolvedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return challenge;
}
public async Task<GameStateDto> GetGameStateAsync(int gameId)
{
var game = await _context.Games
.Include(g => g.Players)
.ThenInclude(p => p.User)
.FirstOrDefaultAsync(g => g.Id == gameId);
if (game == null) throw new InvalidOperationException("Game not found");
var playerInfos = game.Players.Select(p => new PlayerInfoDto
{
PlayerId = p.Id,
Username = p.User.Username,
Score = p.Score,
IsReady = p.IsReady
}).ToList();
return new GameStateDto
{
GameId = game.Id,
LobbyCode = game.LobbyCode,
Status = game.Status.ToString(),
Players = playerInfos,
CurrentRound = game.Rounds.Count
};
}
public async Task<Game> GetGameAsync(int gameId)
{
return await _context.Games.FirstOrDefaultAsync(g => g.Id == gameId)
?? throw new InvalidOperationException("Game not found");
}
private string GenerateLobbyCode()
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var code = new string(Enumerable.Range(0, 6)
.Select(_ => chars[_random.Next(chars.Length)])
.ToArray());
return code;
}
}
Integrations-Konfiguration (Program.cs)
// In SlipItIn.Server/Program.cs
var builder = WebApplicationBuilder.CreateBuilder(args);
// Datenbankverbindung (SQLite)
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
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
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
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)
IDbContextFactoryisoliert DB-Sessions bei parallelen Hub-Aufrufen- Keine Race Conditions beim Phrase-Transfer zwischen 2 Hub-Clients
Integration Test: MAUI State Restoration
- App-Pause speichert
GameStateDtolokal - 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