Detaillierter Projektplan für "Slip It In" ergänzt, inkl. Entwicklungsphasen, Architektur (EF Core, SignalR, DTOs), Spiellogik (Slip-/Challenge-Mechanik) und Testplan. Enthält C#-Codebeispiele für Entitäten, DbContext, GameHub, GameService sowie Grundkonfiguration für ASP.NET Core Backend. Dient als Leitfaden für die Implementierung mit .NET MAUI und ASP.NET Core.
834 lines
28 KiB
Markdown
834 lines
28 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
|
|
- [ ] Erstellung der Verzeichnisstruktur im Workspace
|
|
- [ ] Aufsetzen der gemeinsamen Datenmodelle (Shared Class Library)
|
|
- `User`, `Game`, `Phrase`, `PlayerCard`
|
|
- [ ] Initialisierung des ASP.NET Core Web API & SignalR Backends
|
|
|
|
## Phase 2: Backend-Entwicklung (Web API & SignalR)
|
|
- [ ] **Datenbank & Persistenz**: Entity Framework Core Setup mit SQLite
|
|
- [ ] **User Management**: Simple Registrierung, Login und JWT-basierte Authentifizierung
|
|
- [ ] **Game Engine & Hub**:
|
|
- `GameHub` für Echtzeit-Kommunikation
|
|
- Logik für Lobby-Erstellung und Beitritt via Code
|
|
- Logik für das Austeilen von 5 zufälligen Phrasen pro Spieler
|
|
- **Slip-Mechanik:**
|
|
- Spieler submittet "Slip" zu einer eigenen Phrase
|
|
- Gegner **beschuldigt** Spieler des "SlipIns" (= falsche Phrase)
|
|
- Beschuldigter **bestätigt oder lehnt ab**
|
|
- Bei **falscher Beschuldigung**: Beschuldiger erhält die gelöschte Phrase (Spieler hat dann >5 Phrasen)
|
|
- Bei **berechtigter Beschuldigung**: Beschuldigter muss Phrase abgeben
|
|
- Timer-gesteuerte Logik für Slip-Runden (30-60s pro Slip)
|
|
|
|
## Phase 3: Client-Entwicklung (.NET MAUI)
|
|
- [ ] **Infrastruktur**:
|
|
- MVVM-Setup mit CommunityToolkit.Mvvm
|
|
- Integration von Dependency Injection für Services
|
|
- [ ] **Services**:
|
|
- `ApiService` für REST-Anfragen
|
|
- `SignalRService` für Echtzeit-Events
|
|
- [ ] **UI/Views**:
|
|
- `LoginPage` & `RegisterPage`
|
|
- `LobbyPage` (Spielerliste & Start-Button für Host)
|
|
- `GameBoardPage` (Anzeige aller eigenen Phrasen, "Slip"-Button pro Phrase, Timer-Anzeige, gegnerische Spieler-Liste mit "Beschuldigen"-Button)
|
|
- `ChallengeNotificationOverlay` (Eingangende Beschuldigung: "Bestätigen" oder "Ablehnen"-Button)
|
|
|
|
## Phase 4: Testen & Optimierung
|
|
- [ ] Integrationstest der SignalR-Kommunikation zwischen mehreren MAUI-Clients
|
|
- [ ] Behandlung von Verbindungsabbrüchen (Reconnection Logic in SignalR)
|
|
- [ ] UI/UX-Polishing
|
|
|
|
---
|
|
|
|
## Implementierungsplan: Datenbank-Entitäten & SignalR GameHub
|
|
|
|
**Datenbank-Entscheidung:** SQLite wird für Development und Production verwendet. Die `slipitIn.db` Datei wird im Server-Projekt abgelegt und über Connection String konfiguriert.
|
|
|
|
### Spielmechanik-Übersicht
|
|
|
|
**Grundkonzept:**
|
|
1. Jeder Spieler erhält **mindestens 5 Phrasen** (später können es >5 sein)
|
|
2. Ein Spieler **"slippt"** zu einer Phrase (behauptet, diese Phrase war in der Runde)
|
|
3. **Andere Spieler können beschuldigen**: "Das war kein echtes SlipIn! Diese Phrase stammte von dir!"
|
|
4. Der **beschuldigte Spieler antwortet**:
|
|
- ✅ **"Stimmt!"** (Beschuldigung berechtigt) → Phrase bleibt beim Beschuldiger
|
|
- ❌ **"Nein!"** (Beschuldigung falsch) → Beschuldiger erhält die Phrase
|
|
|
|
**Auswirkungen:**
|
|
- Erfolgreiche Beschuldigung → Beschuldiger hat eine neue Phrase
|
|
- Falsche Beschuldigung → Beschuldiger muss Phrase abgeben (hat danach evtl. >5)
|
|
- Spieler haben immer mindestens 5 Phrasen zum Spielen
|
|
|
|
### Datenbankentitäten (EF Core)
|
|
|
|
#### 1. Shared-Modelle (SlipItIn.Shared/Models/)
|
|
|
|
##### User.cs
|
|
```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
|
|
{
|
|
public int PlayerId { get; set; }
|
|
public string Username { get; set; } = string.Empty;
|
|
public int Score { get; set; }
|
|
public bool IsReady { get; set; }
|
|
public List<PhraseDto> ActiveCards { get; set; } = [];
|
|
}
|
|
|
|
public class PhraseDto
|
|
{
|
|
public int CardId { get; set; }
|
|
public string Text { get; set; } = string.Empty;
|
|
public bool IsUsed { get; set; }
|
|
}
|
|
|
|
public class SlipChallengeDto
|
|
{
|
|
public int ChallengeId { get; set; }
|
|
public int ChallengingPlayerId { get; set; }
|
|
public string ChallengingPlayerName { get; set; } = string.Empty;
|
|
public int TargetCardId { get; set; }
|
|
public string Status { get; set; } = string.Empty;
|
|
}
|
|
```
|
|
|
|
#### 2. GameHub (SlipItIn.Server/Hubs/GameHub.cs)
|
|
```csharp
|
|
public class GameHub : Hub
|
|
{
|
|
private readonly IGameService _gameService;
|
|
private readonly ILogger<GameHub> _logger;
|
|
|
|
public GameHub(IGameService gameService, ILogger<GameHub> logger)
|
|
{
|
|
_gameService = gameService;
|
|
_logger = logger;
|
|
}
|
|
|
|
// Lobby-Verwaltung
|
|
public async Task CreateLobby(string username)
|
|
{
|
|
try
|
|
{
|
|
var game = await _gameService.CreateGameAsync(username);
|
|
var connectionId = Context.ConnectionId;
|
|
await Groups.AddToGroupAsync(connectionId, game.LobbyCode);
|
|
|
|
await Clients.Caller.SendAsync("LobbyCreated", new { GameId = game.Id, LobbyCode = game.LobbyCode });
|
|
_logger.LogInformation($"Lobby {game.LobbyCode} created by {username}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error creating lobby");
|
|
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
|
|
}
|
|
}
|
|
|
|
public async Task JoinLobby(string lobbyCode, string username)
|
|
{
|
|
try
|
|
{
|
|
var game = await _gameService.JoinGameAsync(lobbyCode, username);
|
|
var connectionId = Context.ConnectionId;
|
|
await Groups.AddToGroupAsync(connectionId, lobbyCode);
|
|
|
|
var gameState = await _gameService.GetGameStateAsync(game.Id);
|
|
await Clients.Group(lobbyCode).SendAsync("PlayerJoined", gameState);
|
|
_logger.LogInformation($"{username} joined lobby {lobbyCode}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error joining lobby");
|
|
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
|
|
}
|
|
}
|
|
|
|
public async Task PlayerReady(int gameId, int playerId)
|
|
{
|
|
try
|
|
{
|
|
var game = await _gameService.SetPlayerReadyAsync(gameId, playerId);
|
|
var gameState = await _gameService.GetGameStateAsync(gameId);
|
|
|
|
var lobbyCode = game.LobbyCode;
|
|
await Clients.Group(lobbyCode).SendAsync("GameStateUpdated", gameState);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error setting player ready");
|
|
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
|
|
}
|
|
}
|
|
|
|
// Spiel-Start & Kartenvergabe
|
|
public async Task StartGame(int gameId)
|
|
{
|
|
try
|
|
{
|
|
var game = await _gameService.StartGameAsync(gameId);
|
|
var cards = await _gameService.DealCardsAsync(gameId);
|
|
|
|
var lobbyCode = game.LobbyCode;
|
|
await Clients.Group(lobbyCode).SendAsync("GameStarted", new { GameId = gameId });
|
|
await Clients.Group(lobbyCode).SendAsync("CardsDealt", cards);
|
|
|
|
_logger.LogInformation($"Game {gameId} started");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error starting game");
|
|
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
|
|
}
|
|
}
|
|
|
|
// Slip-Mechaniken
|
|
public async Task SubmitSlip(int gameId, int playerId, int cardId)
|
|
{
|
|
try
|
|
{
|
|
var result = await _gameService.SubmitSlipAsync(gameId, playerId, cardId);
|
|
var game = await _gameService.GetGameAsync(gameId);
|
|
|
|
await Clients.Group(game.LobbyCode).SendAsync("SlipSubmitted", new
|
|
{
|
|
PlayerId = playerId,
|
|
CardId = cardId,
|
|
Success = result
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error submitting slip");
|
|
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
|
|
}
|
|
}
|
|
|
|
public async Task ChallengeSlip(int gameId, int challengingPlayerId, int targetCardId)
|
|
{
|
|
try
|
|
{
|
|
var challenge = await _gameService.CreateChallengeAsync(gameId, challengingPlayerId, targetCardId);
|
|
var game = await _gameService.GetGameAsync(gameId);
|
|
|
|
var challengeDto = new SlipChallengeDto
|
|
{
|
|
ChallengeId = challenge.Id,
|
|
ChallengingPlayerId = challengingPlayerId,
|
|
TargetCardId = targetCardId,
|
|
Status = challenge.Status.ToString()
|
|
};
|
|
|
|
await Clients.Group(game.LobbyCode).SendAsync("SlipChallenged", challengeDto);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error challenging slip");
|
|
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
|
|
}
|
|
}
|
|
|
|
public async Task ResolveChallenge(int challengeId, bool approved)
|
|
{
|
|
try
|
|
{
|
|
var result = await _gameService.ResolveChallengeAsync(challengeId, approved);
|
|
var game = await _gameService.GetGameAsync(result.GameRound.GameId);
|
|
|
|
await Clients.Group(game.LobbyCode).SendAsync("ChallengeResolved", new
|
|
{
|
|
ChallengeId = challengeId,
|
|
Approved = approved
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error resolving challenge");
|
|
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
|
|
}
|
|
}
|
|
|
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
|
{
|
|
_logger.LogInformation($"Client {Context.ConnectionId} disconnected");
|
|
await base.OnDisconnectedAsync(exception);
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 3. GameService (SlipItIn.Server/Services/GameService.cs)
|
|
```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 für Development)
|
|
builder.Services.AddDbContext<SlipItInDbContext>(options =>
|
|
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")
|
|
?? "Data Source=slipitIn.db"));
|
|
|
|
// Services registrieren
|
|
builder.Services.AddScoped<IGameService, GameService>();
|
|
|
|
// SignalR
|
|
builder.Services.AddSignalR();
|
|
|
|
// Controller & Services
|
|
builder.Services.AddControllers();
|
|
|
|
var app = builder.Build();
|
|
|
|
// Migrations anwenden
|
|
using (var scope = app.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<SlipItInDbContext>();
|
|
db.Database.Migrate();
|
|
}
|
|
|
|
app.MapControllers();
|
|
app.MapHub<GameHub>("/hubs/game");
|
|
|
|
app.Run();
|
|
```
|
|
|
|
---
|
|
|
|
### Testplan für Integrationstests
|
|
|
|
#### Integration Test: Lobby-Workflow
|
|
- [ ] Spieler erstellt Lobby → LobbyCode vergeben
|
|
- [ ] 2. Spieler tritt Lobby bei → GameState wird aktualisiert
|
|
- [ ] Host startet Spiel → Status wechselt zu InProgress
|
|
- [ ] Karten werden vergeben (5 pro Spieler)
|
|
|
|
#### Integration Test: Slip & Challenge-Mechanik
|
|
- [ ] **Slip einreichen**: Spieler A submittet Slip zu Phrase X → Phrase wird als IsUsed markiert
|
|
- [ ] **Falsche Beschuldigung**:
|
|
- [ ] Spieler B beschuldigt Spieler A des SlipIns bei Phrase X
|
|
- [ ] Spieler A lehnt Beschuldigung ab (Phrase war richtig)
|
|
- [ ] Challenge wird als Rejected markiert
|
|
- [ ] Phrase X wird zu Spieler B transferiert → Spieler B hat nun >5 Phrasen
|
|
- [ ] **Berechtigte Beschuldigung**:
|
|
- [ ] Spieler C beschuldigt Spieler A des SlipIns bei Phrase Y
|
|
- [ ] Spieler A bestätigt Beschuldigung (Phrase war falsch)
|
|
- [ ] Challenge wird als Approved markiert
|
|
- [ ] Phrase Y bleibt bei Spieler A (wird nicht übertragen)
|
|
- [ ] **Aktive Phrasen-Management**:
|
|
- [ ] Spieler hat immer 5 aktive Phrasen im GameBoard sichtbar
|
|
- [ ] Nach Phrase-Transfer werden UIs aktualisiert
|
|
|
|
#### Integration Test: SignalR-Gruppen
|
|
- [ ] Alle Hub-Clients in derselben Lobby-Gruppe erhalten Broadcasts bei Challenges
|
|
- [ ] Challenge-Notification wird an Beschuldigen-Spieler gesendet
|
|
- [ ] Disconnect-Handler aktualisiert Spielerstatus |